多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Qt轻量级视觉框架:拖拽式算法流程图画布实现

Qt轻量级视觉框架:拖拽式算法流程图画布实现 之前那篇把视觉框架的整体骨架搭完了评论区里追问最多的不是相机采集也不是算法封装反而是流程编辑区那块画布——就是那个看起来像思维导图的拖拽式算法流程图。这个需求其实很实在用Qt从零撸一个轻量级视觉框架图像处理那部分网上资料一抓一大把真正让人卡壳的反而是怎么像海康VM那样把算子节点从左侧拖到画布上再用连线拼成一张能跑的算法流程图。第2章我就把这块从头拆开包含图元体系怎么切、拖拽链路怎么走通、连线为什么用贝塞尔而不是直线、流程数据怎么落成JSON以及我在实际开发里踩到的那几个不太好查的坑。先说结论这套东西的技术底座其实就是QGraphicsSceneQGraphicsViewQGraphicsItem这经典三件套难点从来不在会不会用而在切分粒度和交互细节有没有想清楚。切分没想清楚写到第三周就会发现加一个功能要改五个类最后只能推倒重来。1. 为什么海康VM那套拖拽流程图值得自己复刻一遍1.1 视觉流程配置常见的三种做法和各自的痛点在真正动手之前我梳理过市面上视觉软件配置算法流程的几种思路搞清楚它们的取舍才知道自己要抄的是哪一条路。第一种是参数表格填法。左边一个树形列表列出所有算法步骤右边一张属性表让你填参数。这种做法的实现成本最低一个QTreeWidget加QTableWidget就能糊出来。但它的致命问题是看不见数据流向一个流程跑到第八步你根本不知道第八步的输入图像是第三步的原始图还是第五步的滤波结果全靠命名和记忆。节点一多维护成本指数级上升。第二种是配置文件写死调用链。用一段 JSON 或者脚本描述步骤顺序程序启动时按顺序执行。它足够灵活但改一个参数就要重新编辑文件、重启调试体验非常差非开发人员基本用不了。第三种就是拖拽式流程图。节点从工具箱拖进画布节点之间用连线表达数据依赖连线方向就是执行顺序。海康VM、Halcon的HDevelop、不少机器视觉平台走的都是这条路。它的核心优势在于拓扑结构本身就是执行序列——画完图流程就跑通了,所见即所得。1.2 拖拽式流程图到底解决了什么实际问题我总结下来它主要解决三件事。第一数据依赖可视化。一条连线从A节点的输出图像端口指向B节点的输入图像端口这层关系是画出来的不是猜出来的排查问题时一眼就能看出哪一步喂错了数据。第二模块解耦。每个算子节点只关心我要什么输入、我给出什么输出节点之间不互相持有引用加一个新算子只需要实现一个节点类然后注册到工具箱不用动流程调度的代码。第三调试粒度细。你可以在任意一条连线上打断点让流程只跑到这一步就停下来把中间图显示出来这在参数调优阶段能省掉大量时间。这三点里我最看重的是第二点。因为一个视觉框架能不能持续加功能取决于新算子接入的成本有多低。1.3 轻量级框架的边界在哪里轻量级这三个字是有代价的得提前划定边界不然会陷入无止境的功能膨胀。我的定位是单机运行、节点规模在几十到两百个之间、不需要分布式调度、不需要脚本引擎做动态逻辑。超出这些范围的比如上千节点的超大流程、需要跨机器分发任务、需要自定义脚本节点就不在轻量级的射程内了。定好边界之后很多技术选型就顺了。比如连线不需要做智能绕障寻路——那是给超大流程图准备的中小规模手动摆一下就够了。再比如撤销重做不需要支持跨会话持久化一个会话内的QUndoStack足够用。把力气花在刀刃上而不是去追一个自己都养不起的复杂度。2. 用QGraphicsScene打底图元体系怎么切分才不返工2.1 场景、视图、图元三层结构各自的职责Qt的图形视图框架是标准的MVC味道但它把模型和渲染揉得更紧一些理解这三层的分工是后面所有设计的前提。QGraphicsScene是一个无限大的逻辑坐标系平面它管理所有图元、负责碰撞检测、负责事件分发。它不负责画也不关心窗口大小。QGraphicsView是视口它把场景的一块矩形区域映射到屏幕上负责缩放、平移、渲染。同一个场景可以挂多个视图比如一个主视图加上一个缩略图视图。QGraphicsItem是图元它知道怎么画自己、怎么处理落在自己身上的鼠标事件、自己的边界在哪。这里有一个新手最容易搞混的点图元的坐标是相对父图元的不是相对场景的。一个端口图元的pos()是相对于它所属节点图元的而节点的pos()才是相对于场景的。取绝对坐标必须用scenePos()或者mapToScene()取相对坐标必须用mapFromItem()。连线端点算错位置九成是这里出了问题。2.2 节点基类的抽象设计节点是整个画布的主角。我选择从QGraphicsObject派生而不是QGraphicsItem。原因是QGraphicsObject自带信号槽能力节点状态变化比如参数被修改需要通知外部刷新属性面板有信号会方便很多。代价是它多继承了一层QObject每个实例多占几十字节内存在两百节点的规模下完全可以接受。节点基类需要承载这些信息唯一标识一个自增ID或者QUuid序列化时用它而不是指针、显示名称、算子类型决定运行时调用哪个处理函数、参数表一个键值容器序列化时直接落JSON、输入端口列表和输出端口列表。节点基类还要实现几个关键方法。boundingRect()返回节点的绘制范围这个值必须是固定的不要在里面做任何计算或者动态查询因为这个函数会被高频调用。paint()负责画节点外观——标题栏、图标、背景、边框。shape()返回用于命中测试的精确轮廓默认实现用的是boundingRect()的矩形对于圆角矩形节点来说够用了。2.3 端口、连线、临时连线的对象建模端口是节点的子图元setParentItem(node)之后它就会跟随节点移动省去了手动同步坐标的麻烦。端口需要重写shape()返回一个比自身视觉尺寸更大的圆形因为直径十几个像素的圆点用鼠标去点是很难点准的把命中区域放大到视觉尺寸的1.5到2倍手感会好很多。连线我选择继承QGraphicsPathItem因为它本质就是一条路径基类已经处理好了路径的绘制和边界计算。连线持有源端口指针和目标端口指针重写updatePath()方法根据两个端口的场景坐标重新计算路径。节点移动时调用这个方法即可。临时连线是拖拽过程中那条跟着鼠标跑的线它不属于任何节点是一条独立的图元拖拽结束后要么被销毁要么被替换成一条正式连线。把它和正式连线分开建类是为了避免正式连线上挂着半截悬空的指针逻辑会清爽很多。3. 从鼠标按下到落点吸附拖拽全链路的代码级拆解3.1 侧边栏工具项到画布的拖拽发起左侧工具箱我用的是一个QListWidget每一项对应一种算子。拖拽的发起方式有两种我选了Qt原生拖拽而不是手动模拟。在列表项上按住鼠标拖动时重写QListWidget::mimeData()返回一个携带自定义MIME类型的QMimeData自定义类型名比如application/x-vision-node数据里塞算子类型字符串。同时把列表的dragEnabled打开、dragDropMode设成DragOnly。QMimeData* ToolBoxList::mimeData(const QListQListWidgetItem* items) const { if (items.isEmpty()) return nullptr; auto* mime new QMimeData; QString type items.first()-data(Qt::UserRole).toString(); mime-setData(application/x-vision-node, type.toUtf8()); return mime; }选QMimeData而不是自己维护一个当前拖拽的算子成员变量是因为原生拖拽把拖拽状态交给了系统管理中途按下Esc取消、拖到窗口外释放这些边界情况都自动处理了自己模拟反而容易漏。3.2 拖拽过程中的预览与合法性判断画布这一侧重写QGraphicsView的dragEnterEvent、dragMoveEvent、dropEvent三个方法。dragEnterEvent里检查MIME格式匹配就acceptProposedAction()不匹配就忽略让事件继续往上传。拖拽过程中的预览我用的是QDrag::setPixmap()在发起端设置一张半透明的节点缩略图。这样做的好处是预览完全由系统绘制我不用在画布上维护额外的临时图元也就没有拖到一半取消了但幽灵图元没清掉这种问题。合法性判断放在dragMoveEvent里。比如我一开始就禁止节点落在已有节点身上判断方式是构造一个以当前位置为中心的矩形用scene-items(rect)查一下有没有返回节点类型的图元。有的话就不接受这次移动鼠标会显示禁止光标。void FlowView::dragMoveEvent(QDragMoveEvent* event) { if (!event-mimeData()-hasFormat(application/x-vision-node)) return; QPointF scenePos mapToScene(event-pos()); QRectF probe(scenePos - QPointF(60, 30), QSizeF(120, 60)); bool occupied false; for (auto* item : scene()-items(probe)) { if (qgraphicsitem_castNodeItem*(item)) { occupied true; break; } } occupied ? event-ignore() : event-acceptProposedAction(); }这里有个细节探测矩形要用节点的大致尺寸而不是鼠标点否则会出现释放点和已有节点的边角重叠了但没检测到的情况。3.3 落点吸附与网格对齐dropEvent里做两件事从MIME数据里取出算子类型在mapToScene(event-pos())的位置创建节点。创建位置需要做网格吸附。我设了20像素的网格把坐标四舍五入到最近的格点const int grid 20; QPointF snapped(std::round(scenePos.x() / grid) * grid, std::round(scenePos.y() / grid) * grid);吸附的意义不只是好看。它保证了节点的坐标永远是网格的整数倍序列化存进JSON的是干净的数字回读之后不会出现节点位置偏移这种莫名其妙的问题。另外节点对齐之后连线在视觉上也会更整齐。再进一步如果落点附近恰好有另一个节点并且它们的纵向中心接近我会把新节点吸附到与已有节点同一条水平线上。这个小功能对画流程图的体验提升非常明显随手一拖就是整齐的一排。3.4 节点移动时的连线跟随节点被拖动后与它相连的所有连线必须重新计算路径。这里靠的是itemChange钩子QVariant NodeItem::itemChange(GraphicsItemChange change, const QVariant value) { if (change ItemPositionHasChanged) { for (auto* conn : m_connections) { conn-updatePath(); } } return QGraphicsObject::itemChange(change, value); }关键点是只更新与自己相连的连线而不是遍历场景里所有连线。我用一个QSetConnectionItem*在节点的成员里维护连接集合连线创建和销毁时同步增删。两百个节点的情况下如果每次移动都全量刷新连线拖动会有肉眼可见的迟滞只刷新相关连线之后拖动跟手了很多。还有一点ItemPositionHasChanged是位置已经改变后触发的适合做视图刷新。如果你需要在位置改变之前拦截或者修正位置比如做边界约束要用ItemPositionChange返回值就是最终生效的位置。这两个钩子很容易用错我当时就是先用了后者去刷新连线结果连线永远慢一帧。4. 连线绘制的难点贝塞尔曲线、碰撞检测与自动避让4.1 为什么选贝塞尔曲线而不是直线一开始我图省事用直线连两个端口结果节点稍微多摆几个画面就变成了一团乱麻——多条线交叉重叠根本看不清哪条线从哪来。换成贝塞尔曲线之后观感立刻好了原因在于控制点的位置让曲线有了方向感。具体做法是三次贝塞尔。起点P0是输出端口中心终点P3是输入端口中心两个控制点分别从起点和终点水平延伸QPainterPath ConnectionItem::buildPath(const QPointF p0, const QPointF p3) { qreal dx std::abs(p3.x() - p0.x()); qreal d std::clamp(dx * 0.5, 40.0, 150.0); QPointF c1(p0.x() d, p0.y()); QPointF c2(p3.x() - d, p3.y()); QPainterPath path(p0); path.cubicTo(c1, c2, p3); return path; }控制点偏移量d这里做了个夹取太小了曲线会拐得很急像折线太大了会在中间甩出一个夸张的鼓包。40到150这个区间是我反复试出来的节点间距在100到400像素之间都比较自然。从输出端口出发时曲线是水平的到达输入端口时也是水平的这种进出都是水平的特征是流程图类编辑器的通行做法Houdini和Blender的节点编辑器也是这个逻辑。4.2 临时连线的跟随与端口命中判定拖拽连线的交互是在输出端口上按住鼠标拖出来一条线松开时如果落在某个输入端口上就建立正式连线落在空白处就取消。临时连线我用一条独立的QGraphicsPathItem把它加到场景里但单独管理。鼠标在输出端口上按下时创建它起点固定为该端口的场景坐标鼠标移动时终点用mapToScene(event-pos())。松开时的命中判定是这里最容易出错的地方。不能用scene()-itemAt()直接取鼠标下的图元因为它有可能取到端口的父节点也有可能在多个图元重叠时取到你不想要的那个。我的做法是遍历场景里的所有PortItem找到第一个满足port-sceneBoundingRect().adjusted(-8,-8,8,8).contains(scenePos)且方向匹配输出连输入的端口。PortItem* FlowScene::findPortAt(const QPointF scenePos, PortDirection want) { for (auto* item : items()) { auto* port qgraphicsitem_castPortItem*(item); if (!port) continue; if (port-direction() ! want) continue; if (port-sceneBoundingRect().adjusted(-8,-8,8,8).contains(scenePos)) return port; } return nullptr; }这里的adjusted(-8,-8,8,8)是给命中区域留的容差。严格按视觉尺寸判定用户会经常差一两个像素没连上体验很差。这个容差和端口shape()的放大是两回事前者用于拖拽落点判定后者用于鼠标点击判定。4.3 连线穿节点的问题与处理贝塞尔曲线有个天然缺陷它会穿过中间的节点。当源节点和目标节点在纵向上隔了好几个节点时曲线会直接从中间那些节点身上划过去视觉上很乱。彻底解决要靠寻路算法把每个节点当作障碍物做A*搜索让连线绕开。但前面说过轻量级框架不背这个复杂度。我用的折中方案有两个一是给连线设置比节点更低的Z值让连线永远绘制在节点下面穿过去的时候被节点遮住观感上不那么突兀二是在曲线中间插入可以手动拖拽的控制手柄用户觉得某条线穿得难看自己拖一下调整。conn-setZValue(-1); // 线在节点下方 node-setZValue(0);这个Z值顺序是必须从一开始就定死的。我一开始没管默认Z值都是0图元绘制顺序就取决于加入场景的先后结果后加入的连线会盖在已有节点上面看起来像浮在空中。5. 流程数据的序列化把画布存成JSON再读回来5.1 节点与连线的数据结构设计序列化的核心原则是用稳定ID而不是指针关联。指针在内存里是地址进程一重启就失效根本没法存。每个节点创建时分配一个自增ID或者QUuid连线记录的是从哪个节点的第几个输出端口到哪个节点的第几个输入端口。{ version: 1, nodes: [ { id: n1, type: ImageLoad, x: 100, y: 200, params: { path: D:/test.bmp, index: 0 } }, { id: n2, type: Binarize, x: 400, y: 200, params: { threshold: 128, method: OTSU } } ], connections: [ { from: {node: n1, port: 0}, to: {node: n2, port: 0} } ] }用ID关联而不是节点数组下标是因为删除和插入会让下标全部错位。ID是稳定的节点怎么增删引用关系都不会乱。这个坑我在早期版本踩过——用下标存连线删掉第一个节点之后第二条连线指向了错误的节点排查了半天才发现是下标漂移。5.2 保存与加载的完整链路保存的时候遍历场景里所有图元分挑出NodeItem和ConnectionItem两类分别构建JSON数组。QJsonArray nodesArr; for (auto* item : scene-items()) { auto* node qgraphicsitem_castNodeItem*(item); if (!node) continue; QJsonObject obj; obj[id] node-id(); obj[type] node-typeName(); obj[x] node-pos().x(); obj[y] node-pos().y(); obj[params] node-paramsToJson(); nodesArr.append(obj); } QJsonObject root; root[version] 1; root[nodes] nodesArr; root[connections] buildConnectionArray(); QFile file(path); file.open(QIODevice::WriteOnly); file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));加载必须分两遍做。第一遍只创建节点把ID到节点指针的映射建好第二遍才根据连线数据通过映射找到源节点和目标节点取对应的端口创建连线。如果混在一边做很可能出现连线要用到的节点还没被创建的问题尤其是JSON里节点数组的顺序和连线引用顺序不一致的时候。5.3 版本兼容与容错JSON根节点里的version字段不是装饰。任何一次数据结构变动——比如某个节点的参数改了名字、新增了一种连线属性——都要升版本号加载的时候按版本走不同的解析分支。容错同样重要。遇到引用不存在的节点ID的连线直接跳过并记一条警告绝对不要让程序崩掉。用户手改过JSON文件、或者文件在写入过程中被中断导致残缺都是现实中会发生的。我现在的加载逻辑是解析失败的节点用默认参数兜底解析失败的连线直接丢弃最后弹一个提示告诉用户有N条连线因数据异常被忽略。auto it idMap.find(srcId); if (it idMap.end()) { qWarning() connection references missing node: srcId; continue; // 丢弃这条连线不中断整个加载 }6. 性能与体验的坑从卡顿到顺滑的排查记录6.1 大量节点下的重绘优化节点数量上了一百之后拖动开始发涩。用QElapsedTimer打了个点发现问题不在节点本身而在连线的路径重算和整个视口的重绘。第一个优化是给节点开启设备坐标缓存setCacheMode(QGraphicsItem::DeviceCoordinateCache);节点外观在缩放和平移不变的情况下是不会变的缓存成位图之后paint()就不用每次都重新走一遍绘制逻辑。视觉上完全看不出差别但拖动时的帧率提升很明显。第二个优化是缩小重绘区域。把视图的更新模式设成最小重绘view-setViewportUpdateMode(QGraphicsView::MinimalViewportUpdate);默认的FullViewportUpdate每次任何图元变化都重绘整个视口节点一多就是灾难。改成最小更新之后只有发生变化的那一小块区域会重绘。代价是图元重叠时可能出现残留但我们的节点都是不透明的实心块没有半透明叠加所以不会出问题。第三个是boundingRect()必须按约定返回固定的矩形。任何在boundingRect()里去查数据库、算路径、遍历素组的实现都会让场景的索引结构失效性能断崖式下跌。6.2 缩放平移时的坐标换算视图支持滚轮缩放之后出现过一个很典型的问题鼠标位置换算错位缩放后拖拽节点节点会跳。根因是直接把event-pos()当成了场景坐标。event-pos()是控件坐标缩放和平移都会影响它和场景坐标的对应关系。正确做法永远是QPointF scenePos view-mapToScene(event-pos());反过来的方向用view-mapFromScene()。自定义的交互逻辑里只要涉及鼠标位置脑子里就要过一遍我现在处理的是控件坐标还是场景坐标这一条能省掉大量的调试时间。缩放本身用的是view-scale(factor, factor)配合setTransformationAnchor(QGraphicsView::AnchorUnderMouse)让缩放围绕鼠标位置进行而不是围绕视图中心。这个设置直接影响缩放手感不设的话每次缩放画面都会往中心跑很难用。6.3 撤销重做与框选撤销重做用QUndoStack每类操作对应一个QUndoCommand子类。新增节点、删除节点、新增连线、删除连线、移动节点各做一个。redo()执行操作undo()执行反向操作。这里有个细节移动节点的命令要在移动开始前记录旧位置在移动结束后记录新位置。如果用ItemPositionHasChanged里逐帧记录一次拖动会产生几十条命令撤销一次只挪动一个像素。正确做法是在mousePressEvent里记下起始位置mouseReleaseEvent里比较起始和结束位置只有真正发生了位移才压入一条命令。框选直接用view-setDragMode(QGraphicsView::RubberBandDrag)。要注意的是框选默认只选中图元不包括连线因为连线的shape()通常是细长的路径很难被矩形框住。如果需要支持批量删除连线得在框选结束后手动遍历矩形内的连线图元。7. 我实际开发中踩过的几个坑有几个坑不太容易通过看文档发现都是调试了好一阵才定位到的这里单独说一下。第一个坑是连线端点的坐标取值。端口是节点的子图元取它位置时我一开始用了port-pos()得到的是相对节点的坐标结果连线全部挤在节点内部。正确的是port-scenePos()或者port-mapToScene(port-boundingRect().center())取端口中心。这个错误在单节点的时候看不出来节点一多立刻爆炸。第二个坑是删除节点时的野指针。删掉一个节点挂在它身上的端口和连线并不会自动销毁连线里存的端口指针就悬空了下次刷新连线直接崩溃。我最终的方案是在节点的析构函数里主动断开所有连接遍历自己的连接集合对每条连线调用scene()-removeItem()然后delete同时把自己从对端端口的连接列表里摘掉。删除顺序和引用清理必须成对出现少一边就是崩溃。第三个坑是图元的层级。前面提过Z值这里补充一个相关的选中状态的高亮边框会绘制在图元自身之上如果节点的边框和连线在同一层选中时的虚线框可能会被后绘制的连线盖住。解决办法是把选中高亮画在节点的paint()里而不是依赖外部的选中框。第四个坑是序列化时的遍历顺序。QGraphicsScene::items()的返回顺序是不确定的文档里没有保证。我早期版本依赖这个顺序做ID分配导致同一个流程存两次JSON里的ID排列不一样做版本对比的时候满屏都是差异。改成ID由节点自己在构造时申请跟遍历顺序彻底解耦之后才稳定下来。第五个坑是网格吸附和手动微调的冲突。网格吸附做完之后用户想微调节点位置却发现怎么拖都只能停在格点上。我的处理是按住Alt键时临时关闭吸附允许自由定位。这个交互后来被好几个同事夸过说是终于能对齐了。最后分享一个不太起眼但很有用的技巧给每条连线的高度和宽度都加一点视觉余量。贝塞尔曲线的控制点偏移会让路径超出端口之间的包围盒如果直接用端口坐标算boundingRect()曲线最鼓的那一段会跑到边界之外导致update()刷不到画面上留下残影。我的做法是路径计算完之后用path.boundingRect().adjusted(-20,-20,20,20)返回边界多留的那点余量正好把曲线的外扩吃进去。这种残影问题在拖动节点的时候特别明显但排查起来很费劲因为它是有时候有有时候没有跟拖动的路径相关。这套东西整体写下来图元体系、拖拽链路、曲线绘制、序列化这四块是主干性能优化和撤销重做是让体验从能用到好用的分水岭。真要说的话我建议先别急着做贝塞尔和撤销先用直线和最简的拖拽把链路跑通确认节点创建、连线建立、存盘回读这条闭环没问题了再往上叠视觉和交互的细节。闭环跑通之前加的花里胡哨的功能最后大概率都要重写。
返回列表