最近把一套基于 Qt 5.12.4 的视觉流程框架从零搭到能正常编译、能跑通整套测试流程,整个过程比我预想的要曲折不少。这套框架的底层图像处理部分全部走 Halcon,界面和业务调度在 Qt 这边,主相机采集、图像预处理、模板定位、简单尺寸测量都已经挂上,编译通过,现场跑图也稳定,算是一个可以拿来当模板的阶段性成果。
说实话,Qt 联合 Halcon 做视觉框架不是新鲜事,网上也有一堆零散资料,但真自己动手把编译环境、线程模型、显示刷新、流程调度串起来的时候,坑很多。特别是 Halcon 的版本、Qt 的编译器位宽、运行库依赖这些东西,稍有不对就是一堆无法解析的外部符号或者启动就崩。
这篇文章就把我在这个项目里从环境配置到框架设计、再到联调测试的完整过程写出来,包括关键代码、参数设置、踩过的坑和最终采用的方案。如果你正好在搞 Qt + Halcon 的视觉项目,或者正准备搭一套类似的流程框架,这篇应该能帮你少走不少弯路。
1. 项目背景与整体选型思路
1.1 为什么要用 Qt 做视觉流程框架
做工业视觉项目,底层算法跑 Halcon 基本没什么悬念,但“算法之外”的部分才是最磨人的。你需要一个界面来显示实时图像、需要配置相机参数、需要把图像处理流程节点化、需要把结果回传并通知 UI 刷新。这些需求如果全部用 Halcon 自带的 HDevelop 脚本或者 MFC 做,后期扩展和维护都会比较痛苦。
我选择 Qt 的核心原因是它的信号槽机制和跨平台能力。视觉框架最怕的就是图像采集线程和界面刷新线程互相干扰,而 Qt 的信号槽恰好能把“数据来了”和“刷新界面”这两件事解耦。再加上 Qt 的布局系统适合做参数配置面板,后续就算要加流程节点编辑或者数据统计报表,都在一个相对统一的框架里。
项目的核心定位是做一个可复用的视觉处理框架,而不是一次性 Demo。所以从一开始我就把界面层、业务层、算法层拆开,Halcon 只负责图像处理,Qt 负责调度和交互。
1.2 为什么锁定 Qt 5.12.4 和 Halcon 的搭配组合
Qt 版本我特意选了 5.12.4,而不是当时更新的 5.14 或者后来的 5.15,原因是稳定性和兼容性。5.12 是 LTS 版本,支持周期长,而且 5.12.4 这个修复版本问题相对少。做工业视觉不像做互联网应用可以追新,现场设备一用就是好几年,编译器版本、依赖库版本都不能随便动。
Halcon 侧我使用的是与其配套的 C++ 接口。需要特别注意的是 Halcon 对 Qt 的编译器版本很敏感,尤其在使用 MSVC 2017 编译的 Qt 版本时,Halcon 的导出库必须与之严格对应。如果 Qt 用的是 MinGW,而 Halcon 库是 MSVC 编译的,链接阶段会出现大量“无法解析的外部符号”,因为两套编译器生成的导入库格式根本不通用。
最终我的环境组合是:
| 项目 | 选型 |
|---|---|
| 操作系统 | Windows 10 64 位 |
| Qt 版本 | Qt 5.12.4 |
| 编译器 | MSVC2017 64bit |
| 构建工具 | qmake |
| Halcon 接口 | halconcpp(C++ 接口) |
| Halcon 库位数 | x64-win64 |
| 开发环境 | 安装了 Qt 插件(Qt Visual Studio Tools 或 Qt Creator 均可) |
1.3 环境配置的第一步操作
确认这套组合之后,环境配置有几个动作必须做对。首先是安装 Qt 5.12.4 时,组件里必须勾选 MSVC 2017 64-bit 这一项,如果只装了 MinGW 版本,后面链接 Halcon 就会非常被动。其次,Halcon 安装时记得选择含 C++ 编程接口的完整模式,否则 halconcpp 的头文件和库都不会出现。
安装完之后,需要在项目文件里手工指定 Halcon 的头文件和库路径。我的习惯是在工程的 .pro 文件里单独保留一段注释,写清楚当前使用的 Halcon 版本和路径,方便以后同事接手或者换机重装时快速还原环境。
注意:安装 Halcon 时如果杀毒软件拦截导致动态库没有完整释放,后面运行程序会提示找不到 halcon.dll。这种情况直接重新运行修复安装,别花时间去手动拷贝,因为 HDevelop 和 halconcpp 的依赖关系比较密,漏一个文件后期排查起来很痛苦。
2. 框架设计的几个关键决定
2.1 界面层、业务层、算法层三层拆分
搭框架之前我想得很清楚:如果所有代码都堆在 MainWindow 里,短期内跑通没问题,但后面加算法流程节点、加权限管理、加远程通信时一定会乱套。所以一开始就按三层来做:
- 界面层:负责显示图像、显示结果、编辑参数。这一层只知道有 QImage 和 QPixmap,不关心 Halcon 里跑的是什么算法。
- 业务层:负责流程调度、参数管理、相机启停、结果统计。这一层做决策,比如“当前测试模式是走模板定位还是走划痕检测”。
- 算法层:封装 Halcon 的调用,向上层只暴露输入输出接口。比如输入一张图像,返回一组测量结果。
这个拆法最大的好处是,Halcon 升级版本、更换算子、或者把算法替换成其它库时,只需要动最内层,界面和业务流程基本不用改。我实际测试中,算法层内部为了性能做了一些调整,界面代码几乎没碰。
2.2 Halcon 图像处理必须放独立线程
在一个视觉系统里,图像采集和长时间处理的耗时是不可控的。如果直接放在 GUI 线程里跑,界面马上卡死,拖动窗口都会变成灾难。所以我在架构里把采集和算法处理都放到了独立的线程中。
具体做法是新建一个工作线程,事件循环由 Qt 的 QThread 管理。采集线程循环里调用 GrabImageAsync 取一帧图像,然后交给算法模块处理,处理完成之后产生一个结果对象,通过信号量交给界面线程。
界面线程只做两件事:响应显示刷新、等待用户操作。这样不管算法跑 10 毫秒还是 500 毫秒,界面始终是灵活的。用户也可以随时停止测试,而不用干瞪眼等处理结束。
使用 Qt 的跨线程信号槽时需要注意连接方式。默认的 Auto Connection 在跨线程时会自动变成 Queued Connection,所以发送信号和槽函数接收之间的参数类型必须是 Qt 元类型系统认识的类型。如果不认识,要在调用 qRegisterMetaType 注册一下,否则运行时会看到类似“无法排队参数类型”的警告,信号直接丢弃。
2.3 播放模式与单步模式
视觉框架里比较基础但重要的功能就是“播放”和“单步”。播放模式适合现场连续运行,图像一帧一帧地从相机里取,算法每帧都执行,界面持续刷新。单步模式主要用于调试算法流程,每点一次按钮就取一帧、处理一帧,方便仔细看中间结果。
这两个模式在架构上统一成同一个处理入口,只是触发方式不同。播放模式下是采集线程自动循环调用处理入口,单步模式下是用户在界面触发一次。这个设计让调试和上线维护变得很顺畅,关键中间变量都能通过参数面板实时查看。
2.4 流程节点配置
视觉项目的流程往往不是固定写死的。比如某一台设备要先用模板定位,再抠图检测,最后做尺寸测量;另一台设备可能只需要灰度分析。所以我把算法流程拆成节点,用配置文件描述节点顺序和参数。
配置文件我用的是通用点对点的配置格式,因为 Qt 自带的 QSettings 处理简单键值对方便,但嵌套列表和结构体支持比较弱。通用格式在编辑器的配合下更直观,我可以用文本编辑器改完保存,框架启动时直接加载,不用重新编译。
3. 核心实现细节与关键代码
3.1 Halcon 图像与 Qt 图像的转换
Qt 显示图像用的通常是 QImage,而 Halcon 侧处理的图像类型是 HImage / HObject。这两者之间不会自动兼容,所以我封装了一个转换函数,把 Halcon 图像数据拷贝到 QImage 里。
先看采集部分,Halcon 的开头很简单,利用 HOperatorSet 配合相机句柄:
#include "halconcpp/HalconCpp.h" using namespace HalconCpp; // 打开相机并设置基本参数 HTuple hv_AcqHandle; HOperatorSet::OpenFramegrabber("GigEVision2", 0, 0, 0, 0, 0, 0, "default", -1, "default", -1, "false", "default", "default", 0, -1, &hv_AcqHandle); // 采集一帧图像 HObject ho_Image; HOperatorSet::GrabImageAsync(&ho_Image, hv_AcqHandle, -1);这里 GrabImageAsync 的第三个参数传 -1 表示无限等待,直到相机返回一帧图像。如果相机没有触发信号,程序会一直阻塞在这里,所以实际项目里通常用一个较小的超时值,或者放到单独的采集线程里。
采集完成后把 HImage 转成 QImage,核心思路是通过 GetImagePointer1 拿到图像数据指针,再把数据拷贝到 QImage 的缓冲区。我封装成下面的函数:
QImage HImage2QImage(const HObject& hoImage) { HImage hImg(hoImage); int width = hImg.Width(); int height = hImg.Height(); HTuple hvType = hImg.GetImageType(); if (hvType.S() == "byte") { HTuple hvPointer, hvType; hImg.GetImagePointer1(&hvPointer, &hvType, &width, &height); // 注意:这里的数据格式是 8 位灰度 QImage result((uchar*)hvPointer.L(), width, height, QImage::Format_Grayscale8); return result.copy(); // 必须拷贝,否则源数据释放后指针失效 } return QImage(); }这里面最容易被忽略的就是 return result.copy()。Halcon 的图像数据内存由 Halcon 管理,HObject 一旦离开作用域或重新赋值,旧的数据可能被释放。如果直接把指针传给 QImage,显示时极可能出现花屏或者随机崩溃。拷贝一份是必须的,不要为了省一次内存拷贝而埋雷。
彩色图像转换稍微复杂一些,需要处理三通道交织格式。不过工业视觉项目里绝大多数场景都是 8 位灰度,先把灰度链路做扎实,需要彩色时再单独扩展。
3.2 图像处理流程的封装与调用
算法层内部我按算子的逻辑组装了几个基础函数,比如灰度化、二值化、形态学处理、连通域分析。这些函数都以 HObject 为输入输出,便于串联。
一个典型检测流程是这样的:原始灰度图 -> 阈值分割 -> 形态学开运算去噪 -> 连通域筛选 -> 获取目标区域坐标。
bool ProcessGrayImage(const HObject& hoInputImage, HObject& hoResult, double thresholdMin, double thresholdMax, int openSize, int minArea) { HObject hoRegions, hoConnected, hoSelected; HTuple hvArea, hvRow, hvColumn; // 1. 阈值分割 HOperatorSet::Threshold(hoInputImage, &hoRegions, thresholdMin, thresholdMax); // 2. 开运算去噪 HOperatorSet::OpeningCircle(hoRegions, &hoRegions, openSize); // 3. 连通域分析 HOperatorSet::Connection(hoRegions, &hoConnected); // 4. 筛选面积大于 minArea 的区域 HOperatorSet::SelectShape(hoConnected, &hoSelected, "area", "and", minArea, 99999999); // 5. 提取区域中心坐标 HOperatorSet::AreaCenter(hoSelected, &hvArea, &hvRow, &hvColumn); hoResult = hoSelected; return hvArea.Length() > 0; }这段代码的逻辑很直白,但有两个细节值得强调。第一是 OpeningCircle 结构元素尺寸 openSize 的选择,尺寸太小去不掉噪声,太大又会把目标区域的边缘磨掉,直接导致后续 AreaCenter 的中心坐标偏移。我实际测试中发现,结构元素半径大约为目标宽度的十分之一到五分之一时效果比较稳。
第二是 SelectShape 的 minArea 阈值。这个值如果设置过大,会把小目标漏掉;设置过小又会把灰尘、反光点当成目标。最稳妥的做法是先用单步模式跑几组现场图,把目标区域的最小面积统计出来,再往该值的二分之一左右设置,留出余量。
3.3 结果显示与叠加
Halcon 的 HDevelop 里很方便的一点是能直接显示区域和坐标信息,但在 Qt 里就得自己画。我的做法是在 QLabel 上显示 QImage,然后用 QPainter 在图像上绘制检测框、中心和十字线。
显示层代码:
void MainWindow::OnResultReady(const QImage& img, double centerRow, double centerCol) { QPixmap pixmap = QPixmap::fromImage(img); QPainter painter(&pixmap); painter.setPen(QPen(Qt::green, 2)); // 画十字中心点 painter.drawLine(centerCol - 30, centerRow, centerCol + 30, centerRow); painter.drawLine(centerCol, centerRow - 30, centerCol, centerRow + 30); // 画矩形框(示意,实际由算法层返回区域信息) painter.drawRect(centerCol - 50, centerRow - 50, 100, 100); painter.end(); ui->labelImage->setPixmap(pixmap); }这块比较简单,但有一个显示性能问题:如果播放模式下每帧都做,而且图像尺寸很大(比如 500 万像素相机),QPixmap 的创建和绘制会消耗不少时间。常规优化手段是只在图像内容变化时才更新界面,或者降低界面刷新频率。例如设定一个 30 毫秒的定时器,把最新一帧图像结果保存在成员变量中,定时器到时才刷新界面,这样能避免高频信号造成的 UI 抖动。
3.4 线程与信号槽的完整链路
为了让框架结构更清晰,我定义了一个 VisionWorker 类,封装采集和处理的循环:
class VisionWorker : public QObject { Q_OBJECT public: explicit VisionWorker(QObject* parent = nullptr); ~VisionWorker() override; public slots: void startGrabLoop(); void stopGrabLoop(); signals: void frameReady(const QImage& img, double centerRow, double centerCol, bool isOk); void logMessage(const QString& msg); private: std::atomic<bool> m_running; // Halcon 相机句柄可以存为成员变量,比如 HTuple m_acqHandle };启动时把 VisionWorker 对象塞进一个 QThread 里,然后让线程运行起来,调用 startGrabLoop 进入采集循环。
m_workerThread = new QThread(this); m_worker = new VisionWorker(); m_worker->moveToThread(m_workerThread); connect(m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater); connect(this, &MainWindow::startRequested, m_worker, &VisionWorker::startGrabLoop); connect(m_worker, &VisionWorker::frameReady, this, &MainWindow::OnResultReady); m_workerThread->start();这套模式是 Qt 多线程的标准姿势。注意一点:不要直接调用 m_worker->startGrabLoop(),要通过信号触发,否则实际还是在当前线程里执行的。这也是很多新手容易踩的坑。
停止时也通过信号让 stopGrabLoop 在 worker 线程内调用,避免直接去阻塞一个正在跑循环的线程。我是用一个原子变量作为循环条件的开关,循环内部每取一帧判断一次,给用户一个响应式的“停止”按钮。
3.5 参数保存与程序配置
视觉项目里相机和算法参数经常会调,如果每次调完都要重新编译,那调试效率太低了。所以我做了一个参数管理类,负责把界面上的参数保存到本地配置文件,启动时自动加载。
核心数据结构是一个字典,key 是参数名,value 是字符串或数字。界面上的 SpinBox、ComboBox、LineEdit 都绑定到对应的 key 上。用户点击“保存参数”时,清空旧配置,写入当前所有控件的值;程序启动时读取配置回填到控件。
这个实现很简单,但做起来很舒服。测试不同物体时,只要调整参数并保存,重启程序就是一个新配置,现场调试非常方便。后续如果要做流程节点编辑,也可以把节点信息按同样的格式存进去,一脉相承。
4. 编译配置与运行环境管理
4.1 项目文件与库路径配置
项目编译能正常通过,是整篇一切结论的前提。我的 .pro 文件核心配置如下:
TEMPLATE = app QT += core gui CONFIG += c++11 INCLUDEPATH += "D:/Halcon/include/halconcpp" LIBS += "D:/Halcon/lib/x64-win64/halconcpp.lib" SOURCES += \ main.cpp \ MainWindow.cpp \ VisionWorker.cpp HEADERS += \ MainWindow.h \ VisionWorker.h这里编译不过最常见的原因:一个是 include 路径没写对,导致找不到 HalconCpp.h;另一个是 lib 的架构不对——如果你的 Qt 是 32 位的,却链接了 64 位 Halcon 库,链接器会直接报格式错误,连谈“无法解析”的机会都没有。
我建议正式编译前先检查一下:Qt 的构建套件是否明确指向 MSVC2017 64bit,以及 Halcon 的库路径是否包含 x64-win64。这俩确定后,再往下走错误率会小很多。
提示:在 .pro 文件里尽量使用正斜杠或转义后的反斜杠。Windows 下如果用反斜杠结尾写路径,偶尔会解析出问题。另外路径两端不要留空格,这些低级问题往往最难发现。
4.2 运行时动态库依赖处理
编译通过只是第一步,运行时还差一堆 DLL。Halcon 的程序运行时需要 halconcpp.dll、halcon.dll、hdevengine.dll 以及它依赖的第三方运行时库,少一个都会在启动时报“无法启动此程序,因为计算机中丢失 xxx.dll”。
解决方式有两种。一种是把 Halcon 安装目录下的 bin/x64-win64 路径加到系统环境变量 PATH 里,调试期比较省事,但换到普通用户机器上就不太优雅。另一种是把项目需要用到的几个 dll 直接拷贝到 exe 同目录下,这样程序不依赖系统环境变量,部署给现场时直接整体打包目录即可。
如果项目还要发布给其他人用,可以考虑用 windeployqt 自动收集 Qt 的运行库,再把 Halcon 相关的 dll 放进去。
4.3 Release/Debug 版本与依赖不一致问题
这里有一个非常容易踩的坑:Debug 版程序用 debug 版本的 Halcon 库编译,Release 版不能用同名字的 debug 库。Halcon 的 debug/release 库一般是独立的文件名或者存放在不同目录,链接时如果混用了,编译可能通过,运行时则会出现难以解释的崩溃和异常。
我在调试阶段用的 Debug 编译,后来为了测试现场速度切换到 Release,一开始偷懒没有检查链接库配置,结果程序启动后在初始化相机时直接崩了。查了半天才发现是 debug 版本的依赖库文件和 release 版本不匹配。所以正式跑测试前,一定要确认当前构建是 Release 模式、链接的是 release 库、运行目录里放的是 release 版 dll。这三点保持一致,才能省心。
5. 测试阶段常见问题与排查实录
5.1 编译级:无法解析的外部符号
这个错的常见表现是链接时大量出现 “unresolved external symbol”,尤其是 Halcon 相关的函数。绝大多数情况下是三种原因:
- 没有添加 halconcpp.lib 到 LIBS
- 添加了 lib,但是 32 位和 64 位不匹配
- Halcon 库是用旧版本接口编译的,而程序调用了新版本里才存在的函数
检查顺序也很简单:先确认 .pro 文件里 lib 路径是否正确,再确认构建套件位数与 lib 目录是否匹配,最后检查 Halcon 版本号。
5.2 运行级:程序启动直接闪退
启动闪退如果发生在有 Halcon 调用的地方,优先怀疑运行目录缺 dll,或者 Halcon 初始化环境失败。如果是 Visual Studio 调试器里跑,直接看输出窗口打印的模块加载信息,能定位到加载哪一步失败。
另一个容易忽略的原因是 Qt 插件目录问题。程序发布时如果少了 platforms 目录下的 qwindows.dll,Qt 界面根本起不来,表现就是双击 exe 没有任何反应。用 windeployqt 可以把这个补齐。
5.3 运行级:图像显示卡顿与界面掉帧
连续播放模式下界面卡顿,常见原因是主线程做了太多图像处理或者图像拷贝。我的处理方式是把耗时操作全部移到 worker 线程,界面只接收处理完成的 QImage 和结果。
还有一个优化点:不要把大图的 QImage 每次都转成 QPixmap。QPixmap 的生成涉及到硬件相关的操作,比较耗时。可以缓存最新的 QPixmap,只在图像尺寸变化时重新生成。或者降低刷新频率,显示上一帧结果,保证界面流畅,人的肉眼根本感觉不出来。
5.4 图像内容:显示出来是全黑或全白的
这种情况大概率是图像数据类型转换的问题。Halcon 里的图像可能是 12 位、16 位深度,或者不是 byte 类型。直接用 8 位灰度的转换函数去处理,数据范围对不上,显示出来的自然什么也看不见。
解决办法是在转换函数里判断图像类型,如果不能处理就返回一个空 QImage,同时在日志中打印图像的类型、宽度、高度等信息,方便快速定位。不要直接强转,否则会得到一张看似正常但内容全错的图。
5.5 内存问题:长时间运行内存持续上涨
视觉程序开一天,内存涨个不停,最后卡死,这是很多项目的通病。原因几乎都是 Halcon 图像变量在循环里不断累积。Halcon 的 HObject 变量如果在每次循环里都 new 或者接受新数据,而旧数据没有释放,内存就会被越堆越高。
排查方式是在采集循环里观察:如果每次循环结束后之前图像对象没有被清空,那内存就会涨。使用 HOperatorSet::ClearImage 或者直接把 HObject 变量重新赋值,可以让 Halcon 自动释放不再使用的数据。
注意:Halcon 对象和普通指针不一样,内部有自动管理机制,但循环内外频繁赋值时要特别小心。最稳妥的做法是为每一帧图像使用独立变量名或者确保每次循环开始前旧变量被重置。别再手动调用不匹配的释放接口,容易造成二次释放崩溃。
5.6 相机相关:取图超时和无图像
工业相机尤其是 GigE 接口相机,第一次连接时可能因为网口带宽和包大小设置问题导致取图超时。Halcon 里的做法是调整采集设备的参数,比如减小包大小或者设置相机触发模式,让采集环境更稳定。
如果是 USB3 相机,则优先检查供电和线材质量,这类相机对线缆和主控的兼容性比较挑,经常出现能枚举到设备但采集不出图的状况。
6. 我整理的问题速查表
把这段时间踩过的问题整理成一张表,方便以后快速对照排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 编译报无法解析的外部符号 | 未链接 lib / 位数不匹配 / 接口版本不符 | 检查 .pro 的 LIBS 和构建套件位数 |
| 启动崩溃 | 缺 dll 或 Halcon 运行库不匹配 | 拷贝完整依赖库到 exe 目录 |
| 双击无界面 | Qt platforms 插件缺失 | 运行 windeployqt 工具补齐 |
| 图像显示全黑 | 图像类型不是 8 位灰度 | 判断类型并在转换函数中处理 |
| 播放卡顿 | 主线程承担耗时任务 | 移动处理任务到独立线程,降低刷新频率 |
| 内存缓慢上涨 | Halcon 图像变量未释放 | 确保循环内旧图像对象被重置 |
| 相机无法取图 | 网络参数或触发模式错误 | 调整包大小、触发源,重启相机 |
| 跨线程信号丢失 | 参数类型未注册 | 使用 qRegisterMetaType 注册自定义类型 |
7. 一点个人体会
搭这个框架,前前后后花了不少时间,其中最耗神的不是 Halcon 算法本身,而是把 Qt 的事件循环、Halcon 的图像生命周期、相机的采集模式这三件事协调好。只要这三者配合到位,整个框架跑起来会非常顺,界面上按钮响应流畅,图像刷新稳定,换相机或换检测流程也只是改参数的事情。
如果你也正在做类似的项目,我的建议是先别急着堆功能,把编译环境和线程架构磨扎实。环境基础没打好,后面每加一个功能都会牵一发而动全身。框架搭稳了之后,加算法、加流程、加报告模块都是水到渠成的事。
最后再分享一个小技巧:在处理 Halcon 图像变量时,尽量使用局部对象并按值传递,配合 Qt 的信号槽机制,可以大幅减少手动管理内存的心智负担。这一点在长时间运行的视觉系统里十分重要。希望这套实践能帮你把 Qt 联合 Halcon 的视觉流程框架也搭得稳定、顺手。