多相机缺陷检测系统实战:VS2015+Qt5.9+Halcon20源码解析

发布时间:2026/10/10 20:19:54
多相机缺陷检测系统实战:VS2015+Qt5.9+Halcon20源码解析 做过多相机缺陷检测的朋友应该都见过这类项目生产线上一溜排开三四台相机分别拍不同位置上位机负责收画面、判好坏、报 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 能调用 Halcon202.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设为onTriggerSource设为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 的OpenWindowvoid 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信号连接到一个槽函数槽函数只更新一个内部的QHashint, 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 线程只负责显示这套朴素结构已经能应付绝大多数节拍需求。真到了某个工位需要更高吞吐量的阶段再针对性地拆并行也不迟。如果你也要做类似的视觉上位机我建议先别急着写界面把采集、队列、检测、退出这几条链路先搭通再逐步往上加功能。相机取流、线程退出、图像保存这几个环节的代码写扎实了后面所有扩展都会很省心。