ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt实现VisionPro风格Mask遮罩工具:从QGraphicsView到QImage的实践

Qt实现VisionPro风格Mask遮罩工具:从QGraphicsView到QImage的实践 简介基于Qt开发的仿VisionPro图像遮罩工具主要面向需要实现图像区域掩膜绘制、遮挡或标注功能的Qt开发者。工具支持遮罩起点与终点形状在矩形/圆形间自由切换遮罩颜色可由用户自定义线条粗细支持1~150像素范围调节同时提供绘图/移动图片模式与橡皮擦功能便于精细化调整release模式下处理20MB以内图片仍能保持流畅交互可胜任常见图像标注、区域抠图与视觉任务预处理场景。资源包大小约53KB共19个文件以cpp、h源码为主配合ui界面文件、pro工程文件、qrc资源及css样式表工程结构简洁适合直接编译学习或移植到现有项目。目前已有1228人学习下载对QGraphicsView图形视图框架、自定义场景交互及绘图性能优化感兴趣的读者可将其作为实战参考。1. 为什么要在Qt里重写一个VisionPro风格的mask遮罩工具接过机器视觉上位机项目的同事大概都遇到过这种需求现场只用厂内自研的Qt框架不上整套康耐视VisionPro环境但视觉算法又要求必须能圈出“不参与计算”的干扰区。VisionPro学习记录里最常出现的几个词里就有MaskRegion它做的事很纯粹——在图像上画一块闭合区域让模板匹配、灰度统计、边缘提取只处理窗口内的像素。这个工具跟mask rcnn那种逐像素语义分割输出的掩膜是两回事它更接近几何编辑人在前面画后面立即参与计算拖完顶点要马上见效。在Qt里重写这么一版核心就两件事第一给操作员一个类似VisionPro的区域编辑交互能拖顶点、能旋转矩形、能保存配方第二在运行时把图形快速转成真正的8位单通道mask位图丢给后续视觉算法。这篇文章把Qt绘图、QGraphicsView、QPainterPath转QImage这条链路完整串起来给出可以直接照抄的封装适合手里有Qt基础、需要自研视觉调试工具的开发者。2. 用Qt图形视图框架搭建mask遮罩编辑骨架2.1 把VisionPro的mask拆成“交互层-图形层-位图层”VisionPro的MaskRegion工具从用户视角看是一组带控制点的几何形状方框自带8个句柄按住边缘还能旋转。而视觉库真正消费的数据是一张和原图同尺寸的二值图这中间必须有图形层做转换否则一拖顶点、一缩放位图没法增量更新。所以我一般会在这层放QPolygonF闭合影射点序列天然对应顶点句柄QGraphicsPolygonItem可以直接显示绘画时只需遍历顶点。整个结构的调用链是这样// 交互事件(QGraphicsSceneMouseEvent) // - MaskRegionItem::setPolygon(QPolygonF) // - buildMaskFromPolygon(QPolygonF, QSize) // - QImage(Format_Grayscale8)QPolygonF既是编辑态的源数据又是生成mask的输入避免中间对象的重复维护。如果你的mask需要内外环、环形禁运区就把这一层换成QListQPolygonF并约定正方向表示“并入”反方向表示“排除”。2.2 用QGraphicsView建一个可缩放、可贴图的画布自绘控件完全可以在paintEvent里画完所有内容但任务一旦进入“旋转、缩放到顶点、撤销重做”阶段QGraphicsScene已经把这些坐标换算做完了没有必要重造轮子。最小可用骨架是这样class MaskEditorView : public QGraphicsView { public: explicit MaskEditorView(QWidget *parent nullptr); void setBackgroundImage(const QImage img); protected: void wheelEvent(QWheelEvent *event) override; private: QGraphicsScene *scene_; QGraphicsPixmapItem *bgImageItem_; MaskRegionItem *regionItem_; }; MaskEditorView::MaskEditorView(QWidget *parent) : QGraphicsView(parent) { scene_ new QGraphicsScene(this); setScene(scene_); setRenderHint(QPainter::Antialiasing, true); } void MaskEditorView::wheelEvent(QWheelEvent *event) { const double factor std::pow(1.0015, event-angleDelta().y()); scale(factor, factor); }代码逻辑说明所有绘制都在一个大坐标系中完成wheelEvent只用scale()一处修改view的变换矩阵放大缩小时不需要重建图形项。setBackgroundImage把QPixmap放在场景坐标(0,0)之后绘制mask也用(0,0)因此scene坐标的数值就是图像像素坐标任何换算都不会有人为偏差。参数说明factor用指数函数而不是直接乘1.1或1.2。滚轮一格angleDelta().y()是120乘以1.0015的120次幂约等于1.196手感接近一步放大19.6%滚轮物理加速的设备每帧只滚60时幅度自动减半。把底数调成1.001适合小图精细微调调成1.003适合大图快速浏览这些参数都是现场可调的。2.3 写一个可拖顶点的MaskRegionItem直接拿QGraphicsPolygonItem用顶点句柄还得自己画。更干净的做法是继承QGraphicsObject自己管理多边形、句柄和绘制外部只通过setPolygon与它交互。class MaskRegionItem : public QGraphicsObject { Q_OBJECT public: enum ShapeMode { RotatedRect, Polygon }; MaskRegionItem(const QPolygonF poly, ShapeMode mode); QRectF boundingRect() const override; QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget nullptr) override; void setPolygon(const QPolygonF poly); QPolygonF polygon() const; protected: void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; private: QPolygonF poly_; ShapeMode mode_; int dragVertexIndex_ -1; qreal handleRadius_ 6.0; };paint里先画半透明白色填充再沿poly_画一条浅青色边最后在顶点位置画小圆。命中检测很直接mousePressEvent里遍历顶点取距离小于handleRadius_ 8.0的点作为命中记录dragVertexIndex_mouseMoveEvent把对应顶点更新成event-scenePos()。矩形模式时额外保存center、width、height、angle而不是直接改顶点数组否则从“旋转矩形”退化成不规则四边形。这里有一个常见坑如果只实现boundingRect点击远离斜边的空白区域也会选中图形项这在误操作上是灾难。所以务必重写shape()返回闭合路径Qt会按路径精确判断命中QPainterPath MaskRegionItem::shape() const { QPainterPath path; path.addPolygon(poly_); path.closeSubpath(); return path; }顶点拖到图像边缘之外是另一个常见事故拖动时最好加上原图尺寸限制。矩形模式的顶点数总是4Polygon模式下顶点数允许双击边长添加但保存配方时要把坐标不在图像范围内的顶点清掉避免后面和VisionPro导出的JSON对不上。3. 在Qt里把图形路径转换成可参与视觉计算的mask位图3.1 从QPolygonF生成8位单通道maskmask不只是给界面看的透明层在视觉库眼里就是一张灰度图像白色表示保留黑色表示排除。综合性能和内存用QImage::Format_Grayscale8定义单通道像素一个像素一个uchar没有padding可以直接交给OpenCV或HALCON封装成矩阵头。最小实现如下QImage buildMaskFromPolygon(const QPolygonF poly, const QSize imageSize) { QImage mask(imageSize, QImage::Format_Grayscale8); mask.fill(0); // 默认全黑也就是“全部忽略” QPainterPath path; path.addPolygon(poly); path.closeSubpath(); path.setFillRule(Qt::WindingFill); QPainter painter(mask); painter.setRenderHint(QPainter::Antialiasing, true); painter.fillPath(path, QColor(255, 255, 255)); painter.end(); return mask; }逻辑说明mask的像素原点与QGraphicsScene的坐标原点都取左上角所以poly中的坐标跟图像像素一一对应不需要再做一次映射fill(0)清空可以保证重复调用时不会残留上一次的区域。fillPath之后的白色区域就是有效区域后续视觉算法判断像素为255时参与计算。抗锯齿参数值得单独讲fillPath开抗锯齿时边界像素会出现127中间灰度边缘不是硬1/0。对模板匹配这类容错算法无所谓但如果后面要做形态学开闭运算建议把抗锯齿关掉边界尽量锐利。两种配置都各有用我把它们放在一起对比目标场景建议配置结果模板匹配、边缘提取Antialiasingtrue边界平滑减少拉链噪声二值化形态学、缺陷检测Antialiasingfalse硬边缘mask求反后不产生碎点灰度直方图统计Antialiasingtrue半灰度像素会改变直方图需要阈值归类3.2 旋转矩形的坐标转换QTransform一次完成平移和旋转VisionPro的Region在配方里给的是“中心点X/Y、宽W、高H、角度Angle”调试工位上的标定表、相机位姿参数也是这一套。Qt里若用QPolygonF直接保存矩形四个顶点一旦角度变化就要重构整套坐标维护成本偏高。建议统一用QTransform换算QPolygonF rotatedRectToPolygon(const QPointF center, qreal width, qreal height, qreal angleDeg) { QTransform t; t.translate(center.x(), center.y()); t.rotate(angleDeg); // 图像坐标系Y向下正角度为顺时针 QPolygonF local; local QPointF(-width / 2, -height / 2) QPointF(width / 2, -height / 2) QPointF(width / 2, height / 2) QPointF(-width / 2, height / 2); return t.map(local); }参数说明t.rotate()在Qt里输入的是度数不是弧度做视觉的人习惯填弧度这是第一优先级检查点。旋转中心点是translate后的原点也就是center四个局部顶点相对中心是±w/2、±h/2。t.map返回的是变换后的QPolygonF不修改t反复调用不会产生累积误差。如果想做“mask是否包含某坐标”的命中测试把目标点用同一t.inverted()映射回局部坐标再做min/max判断避免对旋转矩形斜边做几何求交。3.3 mask叠加灰度图的逐像素乘法mask生成出来要马上能进图像处理流程。把原始灰度图与mask逐像素相乘最简单场景下就能实现“黑掉不关心区域”。这个循环不调QPainter因为按像素操作直接访问内存比绘制引擎快一个数量级以上QImage applyMaskToGray(const QImage gray, const QImage mask) { Q_ASSERT(gray.size() mask.size()); Q_ASSERT(gray.format() QImage::Format_Grayscale8); QImage result(gray.size(), QImage::Format_Grayscale8); const uchar *g gray.constBits(); const uchar *m mask.constBits(); uchar *r result.bits(); const qsizetype total gray.width() * gray.height(); for (qsizetype i 0; i total; i) r[i] (uchar)((g[i] * m[i]) 8); return result; }逻辑说明g[i]和m[i]都取值0到255乘积最大65025右移8位后回到0到255等价于除以256把“白掩膜255”归一化为系数1。任何m[i]0的位置输出0任何m[i]255的位置原值保留想实现可调权重掩膜时把mask中间值映射为0到1的系数即可。循环前的Q_ASSERT是廉价防御两个图像尺寸不一致直接崩溃比在视觉算法里跑出错误结果更容易定位。4. 仿VisionPro交互时必调的参数与三个坐标坑4.1 句柄半径换算逻辑坐标系与设备坐标系VisionPro里的句柄在屏幕上永远是固定大小放大缩小后还能精确拖。Qt默认的QGraphicsItem绘制都在逻辑坐标系中进行如果写死qreal handleRadius 6.0显示到屏幕上会随着view缩放变化。放大到200%时句柄变成12px缩小到25%时变成1.5px几乎点不中——这就是Qt“逻辑坐标系”和“设备坐标系”差异的典型体现。保持屏幕恒定大小的惯用做法void MaskEditorView::scaleChanged(qreal factor) { qreal deviceHandlePx 6.0; // 期望的屏幕像素大小 regionItem_-setHandleRadius(deviceHandlePx / factor); }参数说明factor是QGraphicsView当前的缩放系数取view-transform().m11()也能算出来。句柄建议做成8像素上下太小在触屏工位上没法用。这个换算只影响显示层不需要改poly数据mask实际生成的位图大小与view缩放无关永远是图像原分辨率。提示到这里你会发现鼠标拖拽顶点时只更新QPolygonF还不够Qt不会自动更新图元形状要手动调用prepareGeometryChange()再update()否则放大缩小时会出现残留和跳帧。4.2 边界线宽固定为0而不是1mask图形项的选中态外框如果用QPen默认宽度1缩放到50%以下时会消失或被背景图盖住放大到400%又粗得挡住图像细节。使用宽度为0的QPen即可0宽度的画笔是cosmetic笔宽度始终是像素级1不参与任何变换QPen outlinePen(QColor(0, 200, 255), 0); outlinePen.setCosmetic(true); painter-setPen(outlinePen); painter-setBrush(QColor(255, 255, 255, 40)); painter-drawPolygon(poly_);setCosmetic(true)告诉画家这个笔的宽度永远固定为1设备像素Qt会把它当常量渲染不做缩放计算运行开销也比普通笔小。一个坑是如果给QPen设置虚线样式cosmetic笔在低缩放级别下降间距会越来越密需要保持原始视觉观感时建议用实线。4.3 三个高频坑mask空洞、联动手柄和像素越界第一个坑是mask填充出现空洞。根因是多边形自交或者填充规则用错。QPainterPath默认用OddEvenFill自交的形状内部会被当成镂空。要在构建path后显式setFillRule(Qt::WindingFill)按绕数非零判定通用图像里表现更接近VisionPro的区域填充结果。第二个坑是旋转矩形拖一个顶点其余三个不动矩形变成平行四边形。保存和编辑旋转矩形时不要用顶点数组保留中心点、宽高、角度。拖动旋转句柄时改角度值拖动角点时重新推导Width/Height最后调用buildRotatedRect重建poly。只有Polygon模式才允许自由拖单顶点。第三个坑是mask位图与原始图像的长宽、格式不一致。applyMaskToGray入口已经断言了size相等但在调用链更上游经常遇到QImage::scaled后宽高没有换位、或convertToFormat后缓存没刷新的情况。调试时把mask导出一张PNG叠加在原图上肉眼看一版比一切日志都快QImage debugOverlay gray.convertToFormat(QImage::Format_RGB32); QPainter p(debugOverlay); p.setOpacity(0.35); p.drawImage(0, 0, mask); p.end(); debugOverlay.save(debug_mask_overlay.png);这段代码也是给“视觉工具诊断”功能留的口子。把mask画成半透明叠在原图上方便现场工程师判断遮罩是否偏了一个像素。5. 把mask遮罩工具接入实际图像管线并验证5.1 最小闭环调用流程接收一张相机图后先放入MaskEditorView同时触发一次buildMaskFromPolygon保留QImage句柄供后续滤镜使用。用户每次松开鼠标拖拽时用一个信号maskChanged(QPolygonF)通知上层。配方的JSON字段可以设计成{ type: rotated_rect, center: [320.5, 240.8], width: 120, height: 80, angle_deg: 45.0 }读取配方时走buildRotatedRect再setPolygon保存时从RotatedRect数据反向序列化。这里不建议直接存PolygonF顶点因为浮点小数在跨版本、跨平台时容易偏移而中心点/宽高/角度语义更稳定。5.2 用位运算快速统计mask覆盖率验证mask没有空返回或覆盖整幅图像最直接的数据是输出覆盖率。不要写双重循环单通道Grayscale8可以让一维bits循环直接跑完double maskCoverage(const QImage mask) { Q_ASSERT(mask.format() QImage::Format_Grayscale8); int counted 0; const uchar *b mask.constBits(); const qsizetype n mask.width() * mask.height(); for (qsizetype i 0; i n; i) if (b[i] 127) counted; return double(counted) / n; }覆盖率的意义是回读一份由VisionPro生成的参考mask两边比值差在±1%以内即可认为坐标换算没跑偏超过这个范围优先检查角度符号而不是mask生成代码。5.3 打包部署时最容易崩的两个点工具写完后要交给现场最省事的是把MaskEditorView封装成Qt Designer自定义控件在界面库里像拖QLabel一样拖进去。发布之前先检查两处一是在Qt 5.15.2 MSVC2019_64下直接丢exe给工控机会报qt_qpa_platform_plugin_path错误或者双击无反应用windeployqt打包确保platforms/qwindows.dll被带过去二是在启用OpenGL的机器上出现黑色画布且偶发崩溃检查QGraphicsView的viewport更新是否都在主线程绘制离屏QImage时不要长时间持有QPainterpaint函数结束后手动end()再释放。部署壳做完之后回到参考mask做像素级异或对比边缘1像素抗锯齿差异是正常的中心区域差异必须为0。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进