☰
多相机缺陷检测系统:基于VS2015+Qt5.9+Halcon20的上位机架构实践
2026/10/5 16:09:11 网站建设 项目流程

车间里一台工控机要同时带四个相机,正面、反面、左右侧面各一只,原来靠两套独立软件来回切换,一天下来操作工点鼠标都要点出腱鞘炎。后来我把整个检测程序重构成一套多相机缺陷检测源码,技术栈锁定在VS2015、Qt5.9与Halcon20。这个组合放到今天看有点“老旧”,但工业现场的香——稳定、兼容、改起来心里有底。这篇文章就围绕这套源码的架构思路和实操细节展开,适合正在做机器视觉上位机集成、想在多相机检测项目里快速落地的工程师参考。

1. 为什么死守VS2015+Qt5.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_VER=1900,与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事件循环在新版本里的线程亲和性处理变了。

所以从那以后,只要是给产线配套的上位机,我默认就是VS2015+Qt5.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 生产者-消费者模型:环形队列与丢帧策略

每个相机都配一个QQueue<HImage>,生产者是采集线程,消费者是检测线程或者界面刷新线程。队列用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正面划痕、污点DynThreshold30
Camera2反面缺料、毛刺SubImage+Threshold50
Camera3左侧表面异物DynThreshold20
Camera4右侧打痕、氧化SubImage+Threshold40

判定结果用一个结构体统一输出:相机编号、缺陷数量、每块缺陷的面积/位置、总判级OK或NG。这样PLC只需要读一个OK/NG信号,上位机负责把缺陷截图和坐标记录到本地数据库,方便追溯。

4. VS2015+Qt5.9工程搭建:依赖配置与编译排查

算法梳理清楚了,回到工程本身。很多人拿着Halcon的示例跑得通,一集成到Qt项目里就各种链接错误,瓶颈几乎都出在项目配置上。这里把我踩过的坑再完整走一遍。

4.1 HALCON20库接入VS2015的具体操作

新建好Qt Widgets Application项目后,按下面步骤配置:

  1. 项目右键选择“属性”,平台选x64(Halcon20的64位库和Qt的msvc2015_64都要求x64)。
  2. C/C++ -> 常规 -> 附加包含目录,添加:
    • C:\Program Files\MVTec\HALCON-20.11-Progress\include\halconcpp
    • C:\Program Files\MVTec\HALCON-20.11-Progress\include\halcon
  3. 链接器 -> 常规 -> 附加库目录,添加:
    • C:\Program Files\MVTec\HALCON-20.11-Progress\lib\x64-win64
  4. 链接器 -> 输入 -> 附加依赖项,写入halconcpp.lib。
  5. 运行程序时确保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的组合虽然谈不上时髦,但在产线上它是真的扛得住,这一票我投给稳定。

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

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

立即咨询