接下来讨论一下在预览图上确认低倍扫描范围的界面实现。和参数确认相比,这个对话框的实现技术要复杂的多,其他细节也麻烦的多。而同时,相对于后面要介绍的在低倍图上确认高倍扫描的界面,又要简单很多,起着一个承前启后的作用。
程序结构调整 在开始开发之前,我们发现一个很烦恼的事情,就是我们要测试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
中的顺序,将其放到Frameworks
和CellScanner
之间,将它添加到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 ¢erPos, 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 ¢erPos, 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; };
在构造函数中,我们为图元设置了ItemisSelectable
和ItemisMovable
两个属性。前者让它可以接收鼠标选中操作,后者我们可以直接利用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); }
查看效果 现在我们可以看一下对话框的效果了。打开测试项目CellWidgetsTester
的main.cpp
,修改main()
函数:
1 2 3 4 5 6 7 8 9 10 int main (int argc, char *argv[]) { QApplication a (argc, argv) ; 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 (); }
现在可以运行程序了:
图像是原比例显示的,我们要用滚轮缩放才能看全。好像不是我们想要的?我们在构造函数里面最后加一行,来应付一下:
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->end (); delete painter; }
我们利用了Path的功能,从视图区域中扣掉了图元所占的区域,剩下的区域用了一个半透明的蒙版给花了一下,就得到了这种效果。
我们动态的利用QGraphicsView
的items()
得到的图元中根据类型找到的第一个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 ; }
到此为止,单张扫描的主体部分就完成了。剩下的主要就是润色,信息显示等工作了。这里就不再花时间了。从下一章开始,我们会转向批量扫描的实现。