接下来讨论一下在预览图上确认低倍扫描范围的界面实现。和参数确认相比,这个对话框的实现技术要复杂的多,其他细节也麻烦的多。而同时,相对于后面要介绍的在低倍图上确认高倍扫描的界面,又要简单很多,起着一个承前启后的作用。

程序结构调整

在开始开发之前,我们发现一个很烦恼的事情,就是我们要测试CellScanner项目中的界面会很麻烦。因为它是一个exe,我们无法直接引用,必须在测试项目中使用源码,从而要修改一堆的路径,还很容易出错。

接下来,我们会再新建一个DLL子项目,把新的界面组件也都放在这里面。这一步应该是轻车熟路了。新的库工程参数如下图所示:

然后,把它添加到CellScanner项目中。

我们最终的目标是把CellScanner中的所有的类都挪过来,在CellScanner中只留下一个main.c就可以了。暂时嘛,先维持现状不变。

创建目录的时候,建议在项目目录下再建立一个目录,把源码都放进去。比如,我们在项目目录下又建立了一个目录cellwidgets,这样,在其他项目中,可以这样使用:#include "cellwidgets/previewchoosedialog.h"。这样有啥好处呢?好处的是你一眼就能知道这个头文件在哪里,知道它是外部的头文件。

另外,这也体现了我们在界面中使用英文而提供中文资源文件的价值——哪怕我们把一个组件挪个位置,重写一下中文翻译也不是什么麻烦事。如果反过来,就有的好看了😓

增加了库之后,我们还需要把新的qm文件添加到CellScaner的资源中,并在main.cpp中加载翻译器时加载它。

1
2
3
4
5
6
7
8
if(configer->Language()=="CH")
{
...
ret = widgets_translator.load(":/CellWidgets_zh_CN.qm");
...
ret = a.installTranslator(&widgets_translator);
...
}

然后还要修改ScannerSuit.pro工程文件,调整SUBDIR中的顺序,将其放到FrameworksCellScanner之间,将它添加到CellScanner的依赖中,并为它建立依赖关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
TEMPLATE = subdirs
CONFIG = ordered
SUBDIRS += \
Adaptors \
Frameworks \
CellWidgets \
CellScanner \
UnitTest

Frameworks.depends = Adaptors
CellScanner.depends = Adaptors Frameworks CellWidgets
UnitTest.depends = Adaptors Frameworks CellScanner

设计PreivewChooseDialog

接下来我们向里面增加一个对话框,将其命名为PreviewChooseDialog,设计界面并为其添加接口。

这个对话框的显示会很简单,就是一个图像显示控件和两个用于显示/调整扫描区域的Spin控件。图像显示部分我们先用一个QGraphicsView控件,后面我们会将其提升为自定义类。

这个类需要来显示预览图,标准的预览图实际尺寸一半是超过了显示器的分辨率,我们需要适当调整对话框大小,不能让它太小。

另外一种可能更好的做法是直接在主界面上显示控件控制,不要弹出对话框了。但是这个会带来无谓的复杂性,不符合我们的初衷。在真实产品中大家可以自己来尝试一下,可以用QGraphicsWidgets来实现,能够带来相当不错的效果。

我们规划这个对话框的行为是,显示样本的图片作为背景,在上面显示一个方框用来表示要做低倍扫描的区域,并且可以用鼠标来调整它的位置和大小。同时,在对话框上面也利用两个Spin来显示区域的实际尺寸,并且也可以做微调(这个不是本书的重点)。要做到这一点,最方便的选择就是使用Qt的Graphics框架,在QGraphicsView中显示图像,扫描范围使用一个QGraphicsItem来表示,并且实现使用鼠标来移动和调整大小的功能。

Qt在GraphicsFrame中支持了图元的移动操作,但是没有支持鼠标调整大小的机制,我们需要自己实现。我们的目的是,用一个矩形框表示要调节的范围,当鼠标移动到它的边缘上,会改变鼠标的形状,当在边缘按下鼠标并拖动,会调整对应方向的尺寸,当在中央部分按下鼠标并拖动,会调整它的位置。

实现这个功能,很多初学者下意识的做法是在对话框中实现所有功能。这个不是不能实现,但是,我们还会有一个10倍图中选择高倍扫描范围的对话空,要比这个要复杂的多。到时候代码又要重新实现一遍。这不是一个好的习惯。

这个对话空的接口应该很简单,当创建的时候我们直接将数据传进去,当退出的时候,通过getResult()查询结果就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CELLWIDGETS_EXPORT PreviewChooseDialog : public QDialog
{
Q_OBJECT
public:
static constexpr qreal PIXEL_WIDTH{19.1188596491228}; // 每个像素多少纳米
static constexpr qreal PIXEL_HEIGHT{19.178431372549}; // 每个像素多少纳米
explicit PreviewChooseDialog(const cv::Mat image, const QRect& rect,
double x_ratio=PIXEL_WIDTH, double y_ratio=PIXEL_HEIGHT, QWidget *parent = nullptr);
~PreviewChooseDialog();
QRect getResult();
...
private:
...
qreal _pixelWidth;
qreal _pixelHeight;
};

实现ZoomableView和PreviewChooseView

我们使用QGraphicsView来在对话框中显示预览图,因为预览图的分辨率大于对话空,我们还希望用户能用鼠标滚轮来缩放图片大小,以便用户可以仔细观看。

这个目标未必有价值,因为实际上,对于预览图,看的再仔细也没有什么价值。这里只是演示这个实现而已。

我们实现一个ZoomableView来实现图像根据鼠标滚动缩放的功能。这个类作为一个基类,为所有具有类似诉求的QGraphicsView派生类服务。

1
2
3
4
5
6
7
8
9
class ZoomableView : public QGraphicsView
{
Q_OBJECT
public:
ZoomableView(QWidget *parent=nullptr);
protected:
void wheelEvent(QWheelEvent *event) override;
void zoomAt(const QPoint &centerPos, double factor);
};

它会实现wheelEvent(),根据鼠标运动方向计算出缩放比例来。

1
2
3
4
5
6
7
8
9
10
11
12
void ZoomableView::wheelEvent(QWheelEvent *event)
{
if (event->buttons() & Qt::LeftButton)
{
event->ignore();
return;
}

double factor = qPow(1.0003, event->angleDelta().y());
zoomAt(event->angleDelta(), factor);
event->accept();
}

zoomAt()则实现具体的缩放。它首先获取鼠标位置,将其作为变换锚点,然后计算变换矩阵。这里我们限制了缩放比例的范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void ZoomableView::zoomAt(const QPoint &centerPos, double factor)
{
if (dragMode() == NoDrag)
{
return;
}
QPointF targetScenePos = mapToScene(centerPos);
ViewportAnchor oldAnchor = transformationAnchor();
setTransformationAnchor(QGraphicsView::NoAnchor);

QTransform matrix = transform();

matrix.translate(targetScenePos.x(), targetScenePos.y())
.scale(factor, factor)
.translate(-targetScenePos.x(), -targetScenePos.y());

auto w = qreal(viewport()->width()) / sceneRect().width();
auto h = qreal(viewport()->height()) / sceneRect().height();

if (matrix.m11() >= 1 || matrix.m11() < qMin(w, h))
{
return;
}

setTransform(matrix);

setTransformationAnchor(oldAnchor);
}

我们再从ZoomableView中派生出在这里用于显示的PreviewChooseView,并修改对话框,将graphicView提升为PreviewChooseView

当前这样就可以了,后续我们会为它增加特殊的显示效果。

接下来,我们要创建用于表示选择范围的图元类。

定义ResizedRectItem

我们创建一个QGraphicsItem的派生类ResizedRectItem。从QGraphicsItem派生出自己的类,最低限度只需要实现boundingRect()paint()两个函数。我们先提供基本的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ResizedRectItem : public QGraphicsItem
{
public:
enum { Type = UserType + 1 };
int type() const override{ return Type; }
ResizedRectItem(const QRectF& rect=QRectF(), QGraphicsItem* parent=nullptr);
QRectF boundingRect() const override;
void setRect(const QRectF& rect);
QRectF rect() const;

protected:
virtual void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget = nullptr) override;
private:
struct Implementation;
QScopedPointer<Implementation> _impl;
};

在构造函数中,我们为图元设置了ItemisSelectableItemisMovable两个属性。前者让它可以接收鼠标选中操作,后者我们可以直接利用Qt框架提供的在图元上按下鼠标拖动图元的功能。后面我们会关掉这个属性来自己实现。当前我们先利用系统功能看一下效果。

paint就是画一个绿边的透明的矩形框。这个是最常用的图元的实现。第一段代码是画一个矩形框,第二段是在它被选中后在它外面(我们将它的boundingRect()向外扩了一点距离)画出一个虚线框。这其实就是QGraphicsRectItem的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
ResizedRectItem::ResizedRectItem(const QRectF &rect, QGraphicsItem *parent)
: QGraphicsItem(parent)
, _impl{new Implementation}
{
_impl->_rect = rect;
setFlags(QGraphicsItem::ItemIsSelectable | QGraphicsItem::ItemIsMovable);
}

QRectF ResizedRectItem::boundingRect() const
{
return _impl->_rect.adjusted(-ENLARGE_SIZE, -ENLARGE_SIZE, ENLARGE_SIZE, ENLARGE_SIZE);
}

void ResizedRectItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
painter->setPen(QPen(Qt::darkGreen, 4));
painter->setBrush(Qt::NoBrush);
painter->drawRect(_impl->_rect);

if(option->state & QStyle::State_Selected)
{
auto rect = boundingRect();
painter->setPen(QPen(option->palette.windowText(), 0, Qt::DashLine));
painter->setBrush(Qt::NoBrush);
const qreal pad = painter->pen().widthF()/2;
painter->drawRect(rect.adjusted(pad, pad, -pad, -pad));
}
}

集成到对话框

接下来我们将它们集成到PreviewChooseDialog中。编辑构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PreviewChooseDialog::PreviewChooseDialog(cv::Mat image, const QRectF& rect, double ratio, QWidget *parent)
: QDialog(parent)
, ui(new Ui::PreviewChooseDialog)
{
ui->setupUi(this);
ui->graphicsView->setScene(new QGraphicsScene(this));

auto pixmap = ImageTool::MatToQPixmap(image);
ui->graphicsView->scene()->setSceneRect(pixmap.rect());
ui->graphicsView->scene()->addPixmap(pixmap);

auto item = new ResizedRectItem(rect);
ui->graphicsView->scene()->addItem(item);
}

查看效果

现在我们可以看一下对话框的效果了。打开测试项目CellWidgetsTestermain.cpp,修改main()函数:

1
2
3
4
5
6
7
8
9
10
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
//MainWindow w;
//w.show();
auto mat = cv::imread("D:/DataStore/TestData/scanstub/2024-02-19-092454/preview.jpg");
PreviewChooseDialog dlg(mat, QRect(500,500,300,500), 1.0);
return dlg.exec();
//return a.exec();
}

现在可以运行程序了:

图像是原比例显示的,我们要用滚轮缩放才能看全。好像不是我们想要的?我们在构造函数里面最后加一行,来应付一下:

1
QTimer::singleShot(100, [this, rect=pixmap.rect()](){ui->graphicsView->fitInView(rect, Qt::KeepAspectRatio);});

现在就好了。我们在绿色图元上按下鼠标,就能拖动它导出运动。现在我们要实现鼠标拖动图元修改大小。

实现鼠标拉动图元缩放

接收Hover事件

首先,要让鼠标移动到图像上面后,要根据所在的位置修改鼠标光标的形状。首先要打开图元接收鼠标悬浮事件的开关。在ResizedRectItem构造函数中增加下面一行:

1
setAcceptHoverEvents(true);

处理鼠标Hover,修改鼠标光标

在类中重载hoverMoveEvent()

1
2
3
4
5
6
7
8
9
10
void ResizedRectItem::hoverMoveEvent(QGraphicsSceneHoverEvent *event)
{
auto id = getHandleAreaId(event->scenePos());
if(id != _impl->_curHandleAreaId)
{
setHandleAreaCursor(id);
_impl->_curHandleAreaId = id;
}
return QGraphicsItem::hoverMoveEvent(event);
}

按照一般的想法,要实现就要实现enter,move和leave三个函数,我们只实现了move一个实际上就够了。其他两个其实都不需要。

然后是两个辅助函数,用于识别鼠标在哪个区域。其中,updatehandleArea()需要在每次图元尺寸和位置发生变化时更新。当然如果不考虑性能问题,也可以在hoverMoveEvent()里面调用,这样最安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void ResizedRectItem::updateHandleArea()
{
auto rect = boundingRect();
_impl->_handleAreas[eTopLeft] = QRectF(rect.left(), rect.top(), HANDLE_SIZE, HANDLE_SIZE);
_impl->_handleAreas[eTop] = QRectF(rect.left()+HANDLE_SIZE, rect.top(), rect.width()-2*HANDLE_SIZE, HANDLE_SIZE);
_impl->_handleAreas[eTopRight] = QRectF(rect.right()-HANDLE_SIZE, rect.top(), HANDLE_SIZE, HANDLE_SIZE);
_impl->_handleAreas[eRight] = QRectF(rect.right()-HANDLE_SIZE, rect.top()+HANDLE_SIZE, HANDLE_SIZE, rect.height()-2*HANDLE_SIZE);
_impl->_handleAreas[eBottomRight] = QRectF(rect.right()-HANDLE_SIZE, rect.bottom()-HANDLE_SIZE, HANDLE_SIZE, HANDLE_SIZE);
_impl->_handleAreas[eBottom] = QRectF(rect.left()+HANDLE_SIZE, rect.bottom()-HANDLE_SIZE, rect.width()-2*HANDLE_SIZE, HANDLE_SIZE);
_impl->_handleAreas[eBottomLeft] = QRectF(rect.left(), rect.bottom()-HANDLE_SIZE, HANDLE_SIZE, HANDLE_SIZE);
_impl->_handleAreas[eLeft] = QRectF(rect.left(), rect.top()+HANDLE_SIZE, HANDLE_SIZE, rect.height()-2*HANDLE_SIZE);
_impl->_handleAreas[eCenter] = QRectF(rect.left()+HANDLE_SIZE, rect.top()+HANDLE_SIZE, rect.width()-2*HANDLE_SIZE, rect.height()-2*HANDLE_SIZE);
}

int ResizedRectItem::getHandleAreaId(const QPointF &pos) const
{
return std::distance(std::cbegin(_impl->_handleAreas), std::find_if(std::cbegin(_impl->_handleAreas), std::cend(_impl->_handleAreas), [pos](const QRectF& rect){ return rect.contains(pos);}));
}

然后是根据位置来修改鼠标光标形状:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void ResizedRectItem::setHandleAreaCursor(int id)
{
switch(id)
{
case eTopLeft:
case eBottomRight:
setCursor(Qt::SizeFDiagCursor);
break;
case eBottomLeft:
case eTopRight:
setCursor(Qt::SizeBDiagCursor);
break;
case eTop:
case eBottom:
setCursor(Qt::SizeVerCursor);
break;
case eLeft:
case eRight:
setCursor(Qt::SizeHorCursor);
break;
case eCenter:
setCursor(Qt::SizeAllCursor);
break;
default:
setCursor(Qt::ArrowCursor);
break;
}
}

实现鼠标拖动和缩放

接下来,我们要重载mousePressEvent()mouseMoveEvent()mouseReleaseEvent()三个函数。都是处理拖放的标准处理了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void ResizedRectItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
_impl->_pressMousePos = event->scenePos();
_impl->_pressRect = this->rect();
_impl->_mousePressed = true;
return QGraphicsItem::mousePressEvent(event);
}

void ResizedRectItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{
RETURN_IF(!_impl->_mousePressed);
int dx = event->scenePos().x() - _impl->_pressMousePos.x();
int dy = event->scenePos().y() - _impl->_pressMousePos.y();

adjust(_impl->_curHandleAreaId, dx, dy);
}

void ResizedRectItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
_impl->_mousePressed = false;
return QGraphicsItem::mouseReleaseEvent(event);
}

void ResizedRectItem::adjust(int resize_type, int dx, int dy)
{
// 偷懒,先只实现上下左右了:
auto rect = _impl->_pressRect;
switch(resize_type)
{
case eTopLeft:
case eLeft:
rect.adjust(dx, 0, 0, 0);
break;
case eTopRight:
case eTop:
rect.adjust(0, dy, 0, 0);
break;
case eBottomRight:
case eRight:
rect.adjust(0, 0, dx, 0);
break;
case eBottomLeft:
case eBottom:
rect.adjust(0, 0, 0, dy);
break;
case eCenter:
rect.translate(dx, dy);
break;
default:
break;
}
prepareGeometryChange();
this->setRect(rect);
this->update();
}

实现蒙版效果

最后一点,我们现在是画了一个表示图元的方框,但是从界面上看却还是有点不够明显,当然可能是我们的配色不好,换一种亮度更高的颜色就能好一点,比如我们把线颜色改成Qt::green,就明显好多了。另外还有线宽,也应该根据视图的缩放大小调整,而不使用一个场景中的固定值;再比如我们修改一下这个图元的表示,比如当鼠标移动过去后高亮,画出手柄等等,这些都是很繁琐的事情,我们就不多赘述了。这里用另外一种图像显示效果,我们为图元周围的背景加一层蒙版,让它们看上去不那么显眼,这样图元选中的区域就自然明显了。

我们看到QGraphicsView有一个成员函数drawForeground,它的描述如下:

Draws the foreground of the scene using painter, after the background and all items are drawn. Reimplement this function to provide a custom foreground for this view …

我重点标出了最重要的一句话:在背景和所有图元绘制完成后执行。

QGraphicsScene也有这个函数,不过既然我们特意实现了View类,而没有实现Scene类,那么就在View里面实现了。

一般来说,如果涉及到要在外部对图元进行操作的实现,我个人更喜欢在Scene中实现,这样更简单,可以省去坐标转换的问题。

PreviewChooseView重载虚函数drawForeground()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void PreviewChooseView::drawForeground(QPainter *painter, const QRectF &rect)
{
auto items = this->items();
auto item = qgraphicsitem_cast<ResizedRectItem *>(*std::find_if(items.begin(), items.end(), [](QGraphicsItem* p){return qgraphicsitem_cast<ResizedRectItem *>(p)!=nullptr;}));
QGraphicsView::drawForeground(painter, rect);
QRegion view(this->viewport()->rect());
QRect region(mapFromScene(item->rect()).boundingRect());
painter = new QPainter();
painter->begin(viewport());
QPainterPath painterPath;
painterPath.addRegion(view.subtracted(region));
painter->fillPath(painterPath, QBrush(QColor(64,64,64,128)));
//painter->setPen(Qt::white);
//painter->drawPolygon(region);
painter->end();
delete painter;
}

我们利用了Path的功能,从视图区域中扣掉了图元所占的区域,剩下的区域用了一个半透明的蒙版给花了一下,就得到了这种效果。

  • 我们动态的利用QGraphicsViewitems()得到的图元中根据类型找到的第一个ResizedRectItem作为我们要找的图元,而不是在View中保存它的引用,这个没有太大的特殊想法。这样主要的好处是解耦比较好。
  • 在计算图元的区域的时候一定不要忘记了mapFromScen()函数,不然谁也不知道它会飞到哪里去。而mapFromScen(const QRectF&)返回的是QPolygonF,这个大概是考虑到View如果有仿射变换,得到的不是一个矩形吧,但是对我们来说就有点麻烦。我们用一个boundingRect()来再转换成QRectF

最后是获取结果:

1
2
3
4
5
6
7
QRect PreviewChooseDialog::getResult()
{
auto items = ui->graphicsView->items();
auto iter = std::find_if(items.cbegin(), items.cend(), [](const auto p){ return qgraphicsitem_cast<ResizedRectItem *>(p)!=nullptr;});
auto rect = iter==items.cend() ? QRect() : qgraphicsitem_cast<ResizedRectItem *>(*iter)->rect().toRect();
return rect;
}

完成后,就要集成到ScannerMainWindow中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void ScannerMainWindow::onSigChooseLowScanRange(cv::Mat image, QRect &rect, bool &ok)
{
PreviewChooseDialog dlg(image, rect);
if( dlg.exec() != QDialog::Accepted)
{
ok = false;
}
else
{
ok = true;
rect = dlg.getResult();
}
return;
}

到此为止,单张扫描的主体部分就完成了。剩下的主要就是润色,信息显示等工作了。这里就不再花时间了。从下一章开始,我们会转向批量扫描的实现。