☰
多相机缺陷检测系统实战:VS2015+Qt5.9+Halcon20源码解析
2026/10/10 20:19:50 网站建设 项目流程

做过多相机缺陷检测的朋友应该都见过这类项目:生产线上一溜排开三四台相机,分别拍不同位置,上位机负责收画面、判好坏、报 NG。听起来简单,真把代码跑顺,里面还是有不少讲究的。这篇文章要讲的,就是一套真实可落地的多相机缺陷检测源码方案,技术组合是VS2015 + Qt5.9 + Halcon20,涵盖环境配置、相机采集、算法算子、界面集成和现场排坑。无论你是刚从 OpenCV 转到工业视觉,还是想给现有上位机项目换架构,这套从零搭起的流程基本都可以直接抄走。

先说结论:这套组合不是图新鲜,而是工控机软硬件环境、相机 SDK 兼容性和团队维护成本共同作用下的务实选择。下面按实际项目的搭建顺序来写,尽量把每一步“为什么这么做”也讲清楚,而不是只丢一堆代码。

1. 系统架构选择:三个组件为什么绑在一起

1.1 VS2015、Qt5.9、Halcon20 的组合逻辑

不少同行问我,为什么不用 VS2019 配 Qt 5.15,非要守着 VS2015 和 Qt5.9。原因很朴素:产线上一大批工控机是 Windows 7 或者早几年的 Windows 10 镜像,系统组件和运行库都比较老,装高版本开发环境反而不见得顺。VS2015 对应 Qt 5.9 官方预编译的 msvc2015 包,编译器版本、运行时库、调试信息都能对上,链接期少很多幺蛾子。Halcon20 在这套环境里跑得也很稳,它的 GenICam 接口能直接拉起绝大多数 GigE Vision 工业相机,同时把缺陷检测常用的算子封装得比较完整,不用自己造轮子。

所以这个组合的本质是分工明确:VS2015 提供编译工具链,Qt5.9 负责界面和线程调度,Halcon20 专注图像采集与算法。三者各管一段,后续出了问题也容易定位。

1.2 多相机检测系统的模块划分

从源码结构上看,我把整个项目分成五个模块,每个模块只干一件事:

模块主要技术职责
界面层Qt Widgets、QSS实时画面、参数配置、结果表格、状态指示
采集层Halcon HFramegrabber打开相机、连续取流、触发管理
调度层QThread、队列、信号槽把多个相机的图像帧按序送入检测线程
算法层Halcon 算子定位、预处理、缺陷分割、特征筛选
数据层Sqlite / 日志 / 图片存储保存检测记录、NG 图片、对接 MES

界面层绝不直接调用算法,采集层绝不负责显示。这是这套源码里最关键的边界约束。多相机项目一旦出现界面卡死、图像帧错乱这类问题,十有八九是各层越权了:要么在 UI 线程里跑了重算法,要么在采集线程里直接操作了界面控件。

1.3 为什么坚持单进程而不是拆服务

我也见过有人把相机采集拆成独立服务,通过共享内存或网络中转图像。但据我实际经验,十几路以内的产线视觉项目,拆服务反而增加复杂度:图像数据跨进程要序列化、反序列化,带宽和内存都会浪费,调试时还要多开一个进程。

这套源码用的是单进程多线程模型:一个采集线程对应一路相机,多个采集线程向同一个检测队列投递图像帧,再由检测线程统一处理。单进程的好处是图像对象可以直接以智能指针方式在线程间传递,不需要拷贝整块像素数据,内存开销小,实时性也够。真要扩展到大几十路相机的超大项目,再考虑把采集拆出去也不迟。

2. 环境配置与依赖链接:让 Qt5.9 能调用 Halcon20

2.1 组件版本匹配与安装顺序

这个项目我推荐的安装顺序是:先装 VS2015,再装 Qt 5.9.x,最后装 Halcon20。顺序主要影响环境变量和注册表关联,按照这个顺序来,VS2015 的编译器才能在 Qt Creator 里被正确识别。

具体版本上,VS2015 尽量打上 Update 3,不然个别 C++ 标准库头文件会缺东西。Qt 5.9 安装时记得勾选 msvc2015 64 位套件,如果项目需要 32 位,就额外勾 msvc2015 32 位。Halcon20 安装时选完整版,装完后在系统环境变量里确认HALCONROOT指向安装目录,HALCONARCH会由程序自动设置,一般不需要手动加。

比较坑的一点是:工程位数必须和 Halcon 运行时位数一致。如果 Qt 是 64 位,Halcon 也必须是 64 位安装包,链接和运行阶段才不吃闭门羹。之前有个同事把 32 位 Halcon 的 dll 拷到 64 位工程目录里,编译能过,一启动就崩,查了半天才发现是位数不匹配。

2.2 Qt 工程接入 Halcon20 的配置细节

在 Qt 的.pro文件里,核心配置就三行:

INCLUDEPATH += $$(HALCONROOT)/include INCLUDEPATH += $$(HALCONROOT)/include/halconcpp LIBS += -L$$(HALCONROOT)/lib/$$(HALCONARCH) -lhalconcpp -lhalcon

这里用$$(HALCONROOT)和$$(HALCONARCH)是读取系统环境变量,比写死绝对路径强,换电脑部署时不用改代码。

写 C++ 代码时,头文件统一引入:

#include "HalconCpp.h" using namespace HalconCpp;

如果工程里同时用了QT += core gui widgets,可能遇到min、max宏冲突,或者foreach重定义之类的问题。解决办法是在HalconCpp.h之前先定义NO_MINMAX这种保护宏,或者把 Halcon 头文件的包含放到 Qt 头文件之后,具体要看报错情况。多数时候,把using namespace HalconCpp放在函数内部而不是全局,可以避免命名空间污染。

编译通过只是第一步,运行时还要保证能加载 Halcon 的 dll。项目 launch 之前,把halcon.dll、halconcpp.dll以及hcanvas.dll拷到 exe 同目录,或者把它们所在的bin目录加到PATH环境变量里。否则程序会在启动阶段直接弹“找不到 halcon.dll”,看起来特别像代码崩了,其实只是运行库没带全。

2.3 相机接入方式:Halcon 直接打开还是用厂商 SDK

多相机项目第一个决策点是:相机图像到底由谁来抓。

Halcon 自己带了通用相机接口,最常用的就是GigEVision2。用这个接口的好处是代码统一,不管相机是哪个牌子,打开方式差不多。以 Basler、海康、大华等主流 GigE 相机为例,Halcon 20 的 GenICam 基本都支持,直接通过设备名或 IP 地址打开即可。

但有些项目里相机的 SDK 有特殊功能,比如私有触发协议、特定数据校验、自定义 chunk data,这时 Halcon 的通用接口可能不够。备选方案是用厂商 SDK 抓图到内存 buffer,再转成 Halcon 的HImage。

我这里实现的思路是:主流相机优先走 Halcon 接口,异常相机走厂商 SDK 封装层,封装层对外只暴露一个打开、抓帧、关闭的接口。这样上层调度代码不用感知底层差异,后面换相机型号也只需要改驱动适配类。

3. 多相机采集线程模型与图像调度

3.1 同步采集与异步采集的取舍

Halcon 取图有两条路:GrabImage是同步阻塞,调用后直到下一帧图像完整返回才继续往下走;GrabImageAsync是异步方式,配合GrabImageStart可以连续取帧。

单相机项目用同步没问题,但多相机就不太行了。想象一下四路相机都用同步采集:第一路相机等待曝光和传输时,线程被卡住,其他几路根本排不上号,整体帧率会被压得很低。所以这套源码里的采集线程统一用异步模式:

void FrameGrabWorker::run() { try { m_grabber.GrabImageStart(-1); while (m_running) { HalconCpp::HImage frame = m_grabber.GrabImageAsync(-1); emit frameReady(m_index, frame); } } catch (HalconCpp::HException &e) { emit grabError(m_index, QString::fromLocal8Bit(e.ErrorCodeText().Text())); } }

GrabImageStart的-1表示用默认参数启动连续抓取,GrabImageAsync的-1表示无限等待下一帧。实际项目中,我更习惯把等待时间设成一个可配置值(比如 1000 毫秒),避免相机异常断开后线程永远挂在取图调用里。

3.2 采集线程与检测线程的数据队列

多路相机同时投递图像,算法还没处理完上一张,下一张就来了,所以必须有一个队列做缓冲。我在源码里用一个带锁的环形队列,设计得很朴素,但很实用:

bool PipelineQueue::push(FrameTask &&task, int maxQueueSize) { QMutexLocker locker(&m_mutex); if (m_frames.size() >= maxQueueSize) { m_frames.removeFirst(); // 丢弃最旧帧,保证实时性 } m_frames.push_back(std::move(task)); m_cond.wakeOne(); return true; }

FrameTask里面至少包含相机编号、帧编号、收到时间和HImage对象。队列长度我一般控制在 16 到 32 之间,太长会导致检测结果滞后于产线节拍,太短又容易丢帧。实时性优先的原则是:图像处理慢一点可以接受,但采集线程不能被阻塞,否则后面触发来的帧会越积越多。

检测线程只从队列里取任务,处理完后把结果发回 UI 层。这种设计让算法线程成为唯一的计算瓶颈,四路相机、八路相机都不会因为多线程抢 CPU 而互相拖慢,CPU 占用也更可控。

3.3 触发模式下多相机的配合

工业现场通常不是相机自己不停拍,而是等传感器或 PLC 给触发信号。Halcon 里通过open_framegrabber之后的参数设置来实现硬触发,比如把TriggerMode设为on,TriggerSource设为Line1。多相机触发时要注意:每个相机的曝光时间和触发延迟要独立设置,否则同一时刻多个相机同时拍照,码流会挤在一起。

如果几个相机拍的是同一工件的不同面,还需要考虑触发延迟的补偿。举例来说,工件从传感器位置传到第一个相机视野需要 50ms,传到第二个相机视野需要 120ms,那两台相机的触发延迟就要分别对应调整。这部分参数我通常放到配置文件的相机列表里,每路相机有独立的trigger_delay字段,现场调参时不用重新编译代码。

4. 缺陷检测算子实现与算法落地方案

4.1 表面划伤的检测流程

划伤类缺陷在金属件、玻璃面板、塑料外壳上都常见。它的特点是:细长、方向不一、灰度与周围有明显差异。直接对整个图像做阈值分割往往会引入大量噪声,所以我的做法是先做方向性增强,再做形态学滤波。

核心流程大致是:灰度化 → 均值滤波 → 原图与背景差分 → 阈值 → 形态学开闭 → 连通域 → 按面积和长度筛选。

HObject ho_Image, ho_Gray, ho_Mean, ho_Diff, ho_Region, ho_Connected; HTuple hv_Area, hv_Row, hv_Column; Rgb1ToGray(ho_Image, &ho_Gray); MeanImage(ho_Gray, &ho_Mean, 9, 9); SubImage(ho_Gray, ho_Mean, &ho_Diff, 1, 0); DynThreshold(ho_Mean, ho_Diff, &ho_Region, 6, "dark"); Connection(ho_Region, &ho_Connected); SelectShape(ho_Connected, &ho_Region, "area", "and", 50, 99999); AreaCenter(ho_Region, &hv_Area, &hv_Row, &hv_Column);

这里均值滤波的窗口尺寸很关键。窗口太小,背景纹理压不下去;窗口太大,划伤区域本身也被抹平了。我在现场一般先看工件的灰度图,划伤宽度大概几个像素,均值窗口就开到划伤宽度的 3 到 5 倍,然后再微调。阈值部分,我建议用 DynThreshold 不用固定 Threshold,因为生产时光照会有波动,固定阈值很容易在上午下午出现误检差异。

4.2 脏污与异物区域的判定

脏污、异物这类缺陷和划伤不同,它在图像上通常表现为一块异常暗区或明区,面积相对更大,形状不一定规则。我用的方案是“背景差分 + 面积筛选”。

背景差分的前提是获得一张干净的背景估计图。如果工件表面是均匀材质,均值滤波就能当背景;如果表面有复杂纹理,可以先做中值滤波,或者用多张正常图像求平均作为模板。

差分后,暗异物的灰度会落到低灰度段,亮异物会落到高灰度段,分别做两段阈值:

Threshold(ho_Diff, &ho_DarkRegion, 0, 30); Threshold(ho_Diff, &ho_LightRegion, 220, 255);

然后对两个区域分别做连通域和面积筛选。这里有一个容易踩的坑:异物和工件边缘反光容易混在一起。所以在筛选之前,我会先用 Halcon 的ReduceDomain把检测区域裁剪到产品本身的 ROI 内,再排除边框区域。现场的载具、夹具反光如果被算进来,NG 率就会暴涨,这属于典型误检,不是算法不行,而是 ROI 没卡准。

4.3 坐标换算与缺陷结果组织

缺陷找到了,下一步要把像素坐标换算成机械坐标或实际物理坐标,否则现场工人无法根据结果去处理不良品。

如果相机固定且视野平面和产品平面平行,可以做一个简单的比例标定:拍摄已知尺寸的标准件,量出像素距离,算出每个像素对应的毫米值。比如标准件长度 100mm,图像上对应 2000 像素,那么标定系数就是 0.05mm/pixel。再把缺陷中心换算到以产品原点为基准的世界坐标:

double scale = 0.05; double xWorld = (hv_Col.D() - offsetX) * scale; double yWorld = (hv_Row.D() - offsetY) * scale;

如果相机安装时有倾斜角度,简单比例就不够用了,需要用 Halcon 的VectorToHomMat2d做一个仿射变换,先在标定板上取至少三组对应点,算变换矩阵,再把像素坐标转换到机械坐标。转换之后,缺陷数据统一组织成结构体写入检测结果列表,字段包括:相机编号、缺陷类型、中心坐标 X/Y、面积、所在图像路径、判定结果、检测耗时。

5. Qt 界面集成与结果可视化

5.1 把 Halcon 窗口嵌入到 QWidget 中

Halcon 的显示窗口可以直接绑定到一个原生窗口句柄上。在 Qt 里,我先写一个HalconWidget,继承自QWidget,用它来接收 Halcon 的渲染输出。

关键代码是拿到 Qt 控件的winId(),再交给 Halcon 的OpenWindow:

void HalconWidget::attachHalconWindow() { HalconCpp::Hlong winId = (HalconCpp::Hlong)this->winId(); HalconCpp::HTuple hWinId = winId; if (m_hWindow != nullptr) { HalconCpp::CloseWindow(m_hWindow); } HalconCpp::OpenWindow(0, 0, this->width(), this->height(), hWinId, "visible", "", &m_hWindow); }

这里有几个细节要提醒:winId()在窗口还没有显示出来时可能拿到无效句柄,所以attachHalconWindow要么在show()之后调用,要么在paintEvent第一次触发时再初始化。窗口尺寸变化时,Halcon 窗口也需要同步调整,我在resizeEvent里调用SetWindowExtents,多相机分屏时更要特别注意,不然画面会拉伸变形。

DispObj可以直接把图像刷新到窗口上:

HalconCpp::DispObj(image, m_hWindow);

每次取到新帧后,调用这一个函数即可。如果想叠加检测框,先SetColor设置颜色,再用DispRectangle1或DispCircle画缺陷包围框。

5.2 多相机画面的实时刷新策略

四路相机如果每路 30 帧,UI 每秒要刷新 120 次画面,再加上表格和日志,界面线程很容易被拖垮。我的处理方式是每个相机的图像先缓存到对应控件里,再统一用定时器按 30 FPS 刷新。

具体做法是,采集线程发来的frameReady信号连接到一个槽函数,槽函数只更新一个内部的QHash<int, HImage> m_latestFrame,不直接触发DispObj。界面上的QTimer每 33ms 触发一次重绘,把所有相机的最新帧一次性刷到各个HalconWidget上。这样即使某路相机瞬间来了很多帧,界面也只每秒刷 30 次,CPU 占用非常平稳。

这种“生产者 – 缓存 – 定时消费者”的模式在多相机项目里比“来一帧画一帧”可靠得多,也是这套源码里我觉得性价比最高的优化手段。

5.3 NG 结果列表、图像存储与交互

检测结果在界面右侧用一个QTableView展示,每行一条 NG 记录。列设计为:时间、相机号、缺陷类型、X 坐标、Y 坐标、面积、图像路径、备注。点击某行时,主画面切到对应相机并圈出缺陷位置,方便操作员快速查看。

图片存储方面,我按日期和相机号建目录,只保存 NG 图,OK 图默认不存,否则一天下来硬盘很容易被几十 GB 的图片塞满。NG 图上除了原始画面,还会用 Halcon 的算子把检测到的缺陷区域以红色多边形叠加到图片里,保存成 png,这样后续追溯时不用重新跑算法就能看到缺陷位置。

界面层还有一个独立的手动测试模式,可以在不触发产线信号的情况下,手动单张抓图、单张检测。这个功能在现场调试时特别好用,毕竟调整拍摄角度、光源亮度都是逐步试出来的,不能每次都要等产线跑一圈。

6. 现场调试实录:那些容易踩的坑

6.1 GigE 多路相机丢帧与带宽处理

多路 GigE 相机最典型的问题就是丢帧或画面卡顿。曾有一个项目上四路 500 万像素相机跑 30 帧,画面时而撕裂,检测结果也频繁漏帧。排查下来,瓶颈在带宽。

一路 500 万像素、Mono8 格式、30 帧时,一帧大概是 5MB 左右,单路带宽需求接近 1.2Gbps。四路全开早就超过千兆网卡的极限了。解决办法是二选一:把相机像素格式改为 Mono8 并降低到 15 帧,或者把四路相机分到两个独立的千兆网卡上,各带两路。我最后选了后者,因为检测节拍要求不降帧率。

补充一个习惯:相机和上位机之间最好用专用交换机,而不是直接走办公网络。办公网络的广播报文很多,会在高端相机传输时产生干扰,丢帧概率会明显增加。

6.2 采集失败与设备识别困难

open_framegrabber打开相机失败是比较常见的。先不怀疑代码,按照下面几项逐个排查:

  • 相机 IP 是否和电脑网卡在同一个网段,且没有 IP 冲突。
  • 网卡巨型帧(Jumbo Frame)是否开启,Halcon 的 GigE Vision 接口对网络包大小敏感。
  • 相机厂商工具能否正常打开相机,如果厂商工具都打不开,问题在相机或网络环境。
  • Halcon 是否真的识别到了设备,可以调用InfoFramegrabber或直接看接口列表里是否出现对应的设备名。

有时相机拔插多了,系统里缓存了旧设备信息,也需要用厂商配置工具重新分配 IP 或恢复出厂设置,这比改代码更管用。

6.3 界面卡顿与内存增长

界面卡顿的原因,最常见的不是显示层,而是算法被放到了主线程。排查时,我先看 Qt 主线程的 CPU 占用,如果高得离谱,就把检测逻辑全部搬进QThread。采集线程和检测线程都不要直接操作控件,所有 UI 更新统一走信号槽,这样 Qt 的事件循环不会被阻塞,界面就能保持顺滑。

内存增长则要看 Halcon 对象的生命周期。HImage 和 HObject 虽然内部有引用计数,但如果你把图像对象持久保存在一个全局列表里,不清理旧数据,内存就会一直涨。我后来给每个相机只保留“最近一帧”和“最近 100 条检测结果”,超过的旧数据主动Clear,内存曲线就平稳了。

6.4 误检和漏检的处理思路

误检通常比漏检更让现场头疼,因为漏检可以通过多拍几次发现,误检会导致 OK 品被频繁拦截,产线效率直线下降。

我处理误检的原则是:先用离线图片库反复回归算法,再上产线实测。在算法调试阶段,我把现场采集的几百张正常品和几百张不良品全部存到固定目录,每次改完算子就跑一遍离线回归,统计误检率和漏检率。只有离线回归通过,才允许上机试跑。

漏检的情况则要多从打光找原因。划痕方向换一个角度就看不见,往往是光源角度不对,常见对策是用低角度环形光或多角度组合光源。算法层面可以结合多个方向的滤波结果做综合判断,但这属于补丁,不如打光修正更治本。

7. 项目落地后的个人心得

这套源码跑顺之后,我最大的体会是:工业视觉项目的成败,往往不在于用了多高级的算法,而在于工程化细节是否扎实。线程边界是否清楚、队列是否限长、相机断开后能否自动恢复、图片存储会不会把硬盘撑爆,这些才是决定现场能不能长期稳定运行的关键。

以前我也热衷于把项目做成全自动、全并行、大而全的架构,但后来发现,在产线这种嘈杂的环境里,最简单的模型往往最可靠。多相机项目上,单采集线程对应单相机、单检测线程统一处理、UI 线程只负责显示,这套朴素结构已经能应付绝大多数节拍需求。真到了某个工位需要更高吞吐量的阶段,再针对性地拆并行也不迟。

如果你也要做类似的视觉上位机,我建议先别急着写界面,把采集、队列、检测、退出这几条链路先搭通,再逐步往上加功能。相机取流、线程退出、图像保存这几个环节的代码写扎实了,后面所有扩展都会很省心。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询