拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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

之前那篇把视觉框架的整体骨架搭完了,评论区里追问最多的不是相机采集也不是算法封装,反而是流程编辑区那块画布——就是那个看起来像思维导图的拖拽式算法流程图。这个需求其实很实在:用Qt从零撸一个轻量级视觉框架,图像处理那部分网上资料一抓一大把,真正让人卡壳的反而是"怎么像海康VM那样,把算子节点从左侧拖到画布上,再用连线拼成一张能跑的算法流程图"。第2章我就把这块从头拆开,包含图元体系怎么切、拖拽链路怎么走通、连线为什么用贝塞尔而不是直线、流程数据怎么落成JSON,以及我在实际开发里踩到的那几个不太好查的坑。

先说结论:这套东西的技术底座其实就是QGraphicsScene+QGraphicsView+QGraphicsItem这经典三件套,难点从来不在"会不会用",而在"切分粒度和交互细节有没有想清楚"。切分没想清楚,写到第三周就会发现加一个功能要改五个类,最后只能推倒重来。

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 QList<QListWidgetItem*> 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_cast<NodeItem*>(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); }

关键点是只更新与自己相连的连线,而不是遍历场景里所有连线。我用一个QSet<ConnectionItem*>在节点的成员里维护连接集合,连线创建和销毁时同步增删。两百个节点的情况下,如果每次移动都全量刷新连线,拖动会有肉眼可见的迟滞;只刷新相关连线之后,拖动跟手了很多。

还有一点,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_cast<PortItem*>(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_cast<NodeItem*>(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)返回边界,多留的那点余量正好把曲线的外扩吃进去。这种残影问题在拖动节点的时候特别明显,但排查起来很费劲,因为它是"有时候有有时候没有",跟拖动的路径相关。

这套东西整体写下来,图元体系、拖拽链路、曲线绘制、序列化这四块是主干,性能优化和撤销重做是让体验从"能用"到"好用"的分水岭。真要说的话,我建议先别急着做贝塞尔和撤销,先用直线和最简的拖拽把链路跑通,确认节点创建、连线建立、存盘回读这条闭环没问题了,再往上叠视觉和交互的细节。闭环跑通之前加的花里胡哨的功能,最后大概率都要重写。

返回列表