
车间里一台工控机要同时带四个相机正面、反面、左右侧面各一只原来靠两套独立软件来回切换一天下来操作工点鼠标都要点出腱鞘炎。后来我把整个检测程序重构成一套多相机缺陷检测源码技术栈锁定在VS2015、Qt5.9与Halcon20。这个组合放到今天看有点“老旧”但工业现场的香——稳定、兼容、改起来心里有底。这篇文章就围绕这套源码的架构思路和实操细节展开适合正在做机器视觉上位机集成、想在多相机检测项目里快速落地的工程师参考。1. 为什么死守VS2015Qt5.9这套组合的选型逻辑先说我踩过的一个坑之前用VS2022编译好的上位机程序拿到客户工控机上直接报“缺少VCRUNTIME140_1.dll”现场网络又没有外网折腾了半天下驱动包。从那以后我对工控机上的编译环境就一个原则——能多老就多老兼容性优先。VS2015对应MSVC14.0工具集在Win7/10的工控机上基本是“出厂标配”。1.1 HALCON 20与VS2015的版本兼容矩阵HALCON 20.11的C接口halconcpp本身是用MSVC14.0工具链编译的要求使用VS2015或更高版本。VS2015、VS2017、VS2019虽然都属于MSVC14.x系列二进制兼容但直接拿VS2015编译出来的程序是不会挑运行库环境的只要目标机器上有对应的VC Redistributable 2015即可。Qt方面5.9.8版本官方专门提供了msvc2015_64套件。这个套件在安装Qt时需要手动勾选很多人第一次装完发现VS里找不到Qt版本号就是漏了这一步。组件推荐版本关键说明Visual Studio2015 Update 3_MSC_VER1900与HALCON 20.11的C接口编译工具链一致Qt5.9.8 mscv2015_64Qt官方预编译的MSVC2015 64位二进制包HALCON20.11 Progresshalconcpp库与头文件基于VS2015工具链构建1.2 “老版本”在产线上的真实价值很多人觉得版本越新越好但机器视觉工控机不是这么回事。现场工控机上往往还跑着相机厂商的SDK、运动控制卡驱动、PLC通信库这些老驱动对新版VC运行时依赖很少却对老版本兼容极好。我们曾经把Qt版本从5.9升到5.15结果某个国产GigE相机SDK里的回调线程直接崩了查了两天才发现是Qt事件循环在新版本里的线程亲和性处理变了。所以从那以后只要是给产线配套的上位机我默认就是VS2015Qt5.9。这个组合经历了无数产线的“七年之痒”稳定性是验证过的Halcon20的授权方式也相对保守适合一次性部署的检测工位。2. 多相机并行采集的核心架构线程、缓冲区与界面解耦多相机检测第一个要解决的问题不是算法而是四路图像怎么同时进来还不互相拖累。四个相机如果串行采集曝光时间分别是5ms、8ms、5ms、10ms加上传输时间一路串下来走完一轮要40ms以上产线节拍直接超标。2.1 每个相机一条采集线程串行必卡并行是底线我的做法是每个相机单独一个CaptureWorker线程线程内部无限循环调用GrabImageAsync异步采集。Halcon的异步采集算子是做多相机并行非常趁手的工具。void CaptureWorker::run() { while (!m_bStop) { HalconCpp::HImage image; HalconCpp::HTuple acqHandle m_camera-GetHandle(); HalconCpp::GrabImageAsync(image, acqHandle, 5000); if (image.IsInitialized()) { // 深拷贝后入队避免引用计数混乱 m_camera-PushFrame(image.CopyImage()); } } }注意第三个参数是超时时间我习惯给5000ms而不是-1。用-1就是无限阻塞相机一旦掉线线程会永远卡在采集里出不来重连机制根本没法触发。2.2 生产者-消费者模型环形队列与丢帧策略每个相机都配一个QQueueHImage生产者是采集线程消费者是检测线程或者界面刷新线程。队列用QMutex加锁QWaitCondition唤醒。这里最关键的是给队列设上限一般是10帧左右满了就丢最旧的一帧。为什么要丢旧帧检测线程处理速度跟不上采集速度时如果队列无限增长内存会越吃越多而且送到界面上的永远是好几秒前的画面现场操作工看着画面慢半拍会以为软件死了。丢掉旧帧保证看到的画面近乎实时这个体验上的细节在客户验收时很加分。界面显示和缺陷检测我习惯分两条消费链显示链路上主动降帧率到15fps检测链路上保留完整帧率。因为检测结果要存档和触发报警一帧都不能丢显示画面丢几帧无所谓。2.3 相机抽象层让GigE、USB3和厂商SDK用同一套逻辑现场相机品牌混杂是很常见的事Hikrobot、大恒、Basler都有。Halcon的OpenFramegrabber对不同接口的相机参数项不一样但只要配置正确最终都是输出HImage对象。我封装了一个CameraWrap类把差异全部收敛到初始化、采集、关闭三个接口里。class CameraWrap { public: bool Open(const QString cameraType, const QString deviceName); bool Grab(HalconCpp::HImage img); void Close(); void SetExposure(double us); void SetGain(double db); private: HalconCpp::HTuple m_acqHandle; };初始化参数我是放在config文件里读的相机类型、IP、曝光、增益、触发模式都写在ini里。换相机型号只需改配置不用重编译。这个抽象层也是后面算法参数文件独立设计的物理基础——底层图像输入与上层检测逻辑完全解耦。3. Halcon20缺陷检测算法从模板对准到差异判读图像进来了接下来就是Halcon的活。四个相机面对的往往是同一个产品四个不同表面检测逻辑大同小异但参数完全不同。我总结下来工业缺陷检测里90%的场景可以拆成三步走先定位对准再分割差异最后过滤判级。下面展开说这三步的实现细节。3.1 第一件事永远是模板定位很多人拿到图像直接做阈值分割这是新手最容易犯的错误。产线震动、传送带抖动、机械定位误差都会让产品在画面里有几毫米的偏移整幅图直接做差异比对边缘误差就会被误判成缺陷。正确的做法是用形状模板先做刚性定位。Halcon里的create_shape_model基于边缘梯度信息生成模板然后find_shape_model得到当前图相对模板的行列偏移和旋转角度。// 创建模板模型离线做一次保存到文件 HalconCpp::HTuple modelId; HalconCpp::CreateShapeModel(templateImage, auto, -0.39, 0.79, auto, auto, ignore_local_polarity, 5, modelId); HalconCpp::WriteShapeModel(modelId, model.shm); // 在线检测时的定位 HalconCpp::HTuple row, col, angle, score; HalconCpp::FindShapeModel(currentImage, modelId, -0.39, 0.79, 0.5, 1, 0.5, least_squares, 0, 0.9, row, col, angle, score); // 把当前图仿射变换到模板坐标 HalconCpp::HTuple homMat; HalconCpp::VectorAngleToRigid(0, 0, 0, row, col, angle, homMat); HalconCpp::AffineTransImage(currentImage, alignImage, homMat, constant, false);定位这一步如果用形状匹配分数低于0.5基本可以判断是产品放歪太多或者来料本身就有大问题直接NG不用再往下走算法。3.2 缺陷提取的三条主流路径对准之后理论上当前图和模板图应该完全重合。接下来就是找差异。实际项目中我会根据缺陷特征选择下面三条路径之一或者组合使用。路径A图像差分阈值分割。适合明显污点、缺料、异物等灰度差异大的缺陷。把当前图与模板做SubImage差异大的像素就是潜在缺陷区域。HalconCpp::SubImage(alignImage, templateImage, diffImage, 1, 0); HalconCpp::Threshold(diffImage, regionDiff, 30, 255); HalconCpp::Connection(regionDiff, connectedDefects);路径B动态阈值分割。生产环境光照不可能绝对均匀金属件表面又有反光固定灰度阈值很容易误判。DynThreshold结合MeanImage可以很好地应对背景灰度缓慢变化的情况。HalconCpp::MeanImage(alignImage, meanImage, 21, 21); HalconCpp::DynThreshold(alignImage, meanImage, regionDiff, 12, dark); HalconCpp::Connection(regionDiff, connectedDefects); HalconCpp::SelectShape(connectedDefects, defectRegions, area, and, 30, 999999);这里的“21”是均值核尺寸要大于缺陷的最大尺寸但又不能太大导致背景估计失真。12是灰度差阈值用标准样件实际标定出来的值不是拍脑袋定的。路径C频域分析。针对LCD玻璃划伤、金属拉丝表面的细微纹理异常空间域很难分割可以用FFT变换转到频域把周期性纹理滤掉后再回空间域提取异常。路径C运算量大通常只在ROI小窗口内做能不用尽量不用。形态学处理在三条路径里都会用到。OpeningCircle去掉毛刺噪点ClosingCircle填补缺陷区域小孔最后SelectShape按面积、长宽比、灰度均值等特征做过滤输出最终缺陷区域。3.3 多相机参数独立与判级输出四个相机看的是同一个产品不同部位检测算法逻辑相似但参数必须独立。我在程序里为每个相机分配一套参数文件包含模板路径、匹配最低分数、分割阈值、形态学算子参数、面积阈值、NG判定规则。相机检测部位主要缺陷分割方式面积阈值(px)Camera1正面划痕、污点DynThreshold30Camera2反面缺料、毛刺SubImageThreshold50Camera3左侧表面异物DynThreshold20Camera4右侧打痕、氧化SubImageThreshold40判定结果用一个结构体统一输出相机编号、缺陷数量、每块缺陷的面积/位置、总判级OK或NG。这样PLC只需要读一个OK/NG信号上位机负责把缺陷截图和坐标记录到本地数据库方便追溯。4. VS2015Qt5.9工程搭建依赖配置与编译排查算法梳理清楚了回到工程本身。很多人拿着Halcon的示例跑得通一集成到Qt项目里就各种链接错误瓶颈几乎都出在项目配置上。这里把我踩过的坑再完整走一遍。4.1 HALCON20库接入VS2015的具体操作新建好Qt Widgets Application项目后按下面步骤配置项目右键选择“属性”平台选x64Halcon20的64位库和Qt的msvc2015_64都要求x64。C/C - 常规 - 附加包含目录添加C:\Program Files\MVTec\HALCON-20.11-Progress\include\halconcppC:\Program Files\MVTec\HALCON-20.11-Progress\include\halcon链接器 - 常规 - 附加库目录添加C:\Program Files\MVTec\HALCON-20.11-Progress\lib\x64-win64链接器 - 输入 - 附加依赖项写入halconcpp.lib。运行程序时确保C:\Program Files\MVTec\HALCON-20.11-Progress\bin\x64-win64在系统PATH里或者直接把halcon.dll、halconcpp.dll、hdevengine.dll拷贝到exe同目录。配置完成后编译通过不代表运行没问题。Halcon运行时依赖大量环境变量最省心的方式是安装完整HALCON开发包环境变量由安装程序自动配置。如果做绿色部署那三个dll必须随身携带。4.2 链接错误与运行时崩溃的常见原因这块单独拎出来说因为案例太多了。最容易遇到的是LNK2038错误mismatch detected for _ITERATOR_DEBUG_LEVEL。根本原因是Halcon的halconcpp.lib是Release版本库而你的工程用了Debug模式编译两者对C标准库的迭代器debug级别不一致链接器直接拒绝。我的建议是产线部署一律用Release编译。如果确实需要Debug调算法把Halcon库单独抽出来做成DLL并且给这个DLL提供一个纯C接口用Release编译Debug工程只调用DLL接口两边的运行库环境各归各的。这个方案前期多花半小时后面调试省心不少。还有一类“无法解析的外部符号–OpenFramegrabber”基本是两种原因一是忘了在附加依赖项里写halconcpp.lib二是项目平台选的x86而Halcon库明确是x64-win64架构不匹配链接出来的符号表自然对不上。4.3 图像显示不刷新与界面卡顿的线程同步Qt界面线程主线程里显示图像是必须遵守的约束。很多新手在线程里直接调QLabel::setPixmap运行不报错但画面闪烁严重时直接崩溃。原因很简单QPixmap只能在主线程创建跨线程操作是未定义行为。我的工程里采集线程拿到HImage后发射信号主线程槽函数里做显示。显示前把HImage转成QImage并且一定要深拷贝// 采集线程中 HalconCpp::HImage image; // ... GrabImageAsync ... int width 0, height 0; unsigned char *ptr image.GetImagePointer1(width, height); // 注意ptr指向的是Halcon内部缓冲必须在HImage存活期内使用 QImage frame(ptr, width, height, width, QImage::Format_Grayscale8); QImage disp frame.copy(); // 深拷贝非常重要 emit FrameReady(disp);主线程槽函数再setPixmap(QPixmap::fromImage(disp))。如果不做深拷贝HImage析构后缓冲区就释放了QImage指向野指针画面偶尔花屏问题非常难查。5. 现场调试中的典型问题与长期运行心得软件写完只是开始放到产线上跑24小时才是真正的考验。这一节专门记录我在这套多相机缺陷检测源码上实实在在遇到过的坑和处理方法。5.1 跑两小时掉帧内存泄漏排查第一个让我头疼的问题出现在稳定性测试阶段。程序刚开始跑内存占用稳定在300MB左右两小时后涨到1.5GB点击“停止检测”都要卡好一会儿。用任务管理器看内存曲线稳定上升不回落典型的持续泄漏。排查过程是这样的我先把UI显示链路注释掉内存曲线变平了说明泄漏不在Halcon算法而在图像显示链路。再往下查发现采集线程每次emit FrameReady(disp)时如果主线程槽函数还在处理上一帧Qt事件队列就会积压每一帧QImage占的内存不会立刻释放。加上HImage拷贝、变量作用域不明确一积压就是几百MB。解决思路是队列限长主动丢帧。我在采集线程里加了一个计数器如果上一帧还没被槽函数取走当前帧直接丢弃。同时把显示刷新频率降到15fps检测链路独立跑满帧率互不干扰。改造后内存曲线平稳24小时测试波动范围不超过50MB。5.2 相机掉线重连机制产线现场环境比实验室恶劣得多GigE相机掉线是最常见故障之一。网线松动、交换机端口接触不良、临时抱闸电流冲击导致相机供电波动都会让GrabImageAsync返回错误。曾经有一次因为一台相机掉线整个软件直接卡死现场停线半小时才排查出来。我在CameraWrap里设计了重连状态机采集失败后先关闭采集句柄释放资源等待2秒重新OpenFramegrabber最多连续重试5次。每次重连失败都在界面上弹出醒目的状态提示同时把错误写入本地日志。另外一个非常有用的硬件建议是GigE相机要开启巨帧Jumbo Frame网卡MTU调成9000。不调的后果是图像数据被拆成大量小包传输CPU占用率和丢包率直线上升千兆网口跑出的实际速率还不到300MB/s多相机同时采集时更容易掉线。5.3 打光一致性的坑这是最容易“翻车”的隐性因素。同一款产品上午检测良率99%下午突然掉到85%算法阈值完全没动查来查去发现是车间窗帘被人拉开自然光照在了传送带上。机器视觉的死穴就是环境光变化Halcon算法本身没问题但输入图像的光照已经变了。为应对这个问题我在每个相机位置加了遮光罩并且在程序里固化了一组“标准样件自检”流程每天开机后放上标准样件跑一遍模板匹配分数和缺陷虚检率如果匹配分数低于0.8或者虚检率异常就提示“光照系统异常请检查打光环境”。这个机制把光照漂移问题从“半夜被电话叫醒”变成了“现场操作工上班时自己就能发现”。5.4 日志、看门狗与参数文件备份最后分享一个长期运行的保障设计。程序里我加了多层日志图像采集帧率、算法单帧耗时、相机重连次数、每张NG图的文件名和缺陷坐标全部按天写入文件。算法单帧耗时的曲线很重要一旦发现耗时异常增长说明图像内容在变化或者系统资源被占满往往预示着硬件问题。产线上光靠软件本身扛不住所有意外我额外写了一个看门狗脚本每30秒检查一次软件进程是否响应无响应就杀掉并重启。同时参数文件每天自动备份一份到指定目录版本带时间戳换产品后想回滚配置有据可查。这套源码架构后来被我移植到过好几个项目里换产品、换相机、换检测算法核心的线程模型和参数管理思路基本没变过。机器视觉项目真正的壁垒不在某个算法有多高级而在于把采集、调度、显示、判级、日志这些基础工程做扎实。VS2015、Qt5.9和Halcon20的组合虽然谈不上时髦但在产线上它是真的扛得住这一票我投给稳定。