做工业视觉的人,大概率都遇到过这种“两头堵”的局面:工件背景复杂、反光严重、来料位置随机,传统算法用阈值分割、模板匹配怎么调都不稳;可要是换成深度学习检测,框是框出来了,坐标却始终差那么几个像素,机械手一抓一个偏。后来我把整套架构做成C#上位机 + ONNX Runtime跑YOLO + Halcon精测量的组合,才算是把“找得到”和“测得准”两个需求同时满足了。
这篇文章就把这套工业级视觉AI架构从零搭建的完整思路写出来:为什么这么选型、代码怎么写、坐标怎么转、Halcon怎么集成、产线上会踩哪些坑。适合正在做视觉项目落地的工程师、想把深度学习检测塞进现有上位机体系的团队、以及从纯Halcon或纯DL转向复合架构的朋友参考。
1. 四个组件各自的分工与选型逻辑:这套架构到底在解决什么问题
1.1 YOLO和Halcon不是替代关系,而是接力关系
很多人有个误解,觉得深度学习检测和传统机器视觉是竞争关系,上了YOLO就可以扔掉Halcon。实际做产线项目会发现,这两个东西压根不在一个维度上。
YOLO这类目标检测模型,强在用数据学出来的特征做识别,对光照变化、复杂背景、形变遮挡的容忍度极高。你要在一个反光严重的金属表面上找一个划痕,或者在来料位置随机的情况下找出一块异形工件,传统算法会写到你想吐,而YOLO只要数据到位,很快就能跑出像样的检测框。
但问题来了:YOLO输出的是矩形框,是“目标在哪一片区域”的估计。它的框精度通常在几个像素级别,而且框的边缘跟真实工件的边缘不完全对齐。你要让机械手精确抓取一个旋转了37.5度的工件,或者要测量某个边缘到某条基准线的距离是3.218毫米,YOLO给不了这种级别的结果。
Halcon恰恰强在这个地方。它有亚像素边缘提取、卡尺测量、拟合直线圆、手眼标定、畸变校正、坐标系变换这一整套精密测量工具链。但Halcon的传统算法本身不具备“语义理解”能力,面对复杂背景时ROI参数往往要靠人工反复调。
所以这两者的正确关系是接力:YOLO负责在海量像素中找到“目标大概在这里”,Halcon负责在目标区域内做“到底精确在哪里、是多少度、多少毫米”的精密测量。一个管粗定位,一个管精测量,各干各最擅长的事。
1.2 为什么中间层选了ONNX Runtime
选定YOLO之后,紧接着的问题就是模型怎么在C#里跑起来。这里其实有一条不太合理的常规路径:很多人先拿PyTorch训练模型,然后部署的时候发现PyTorch和C#之间没有顺手的官方接口,于是要么用Python写一个推理服务通过HTTP协议通信,要么改采OpenCV的DNN模块硬跑。
用Python推理服务的方式,问题在于多一跳网络通信就多一个故障点。产线上的网络抖动、服务进程被杀、Python环境被污染,都是实实在在的事故来源。OpenCV DNN则只适合简单验证,它对YOLOv8这类模型的支持往往滞后,而且后处理得完全自己写,性能也比不上专门的推理引擎。
ONNX Runtime在这里的价值就是一座桥。PyTorch训练出来的模型可以导出成ONNX格式,而ONNX Runtime提供了官方C# NuGet包,可以直接嵌入C#进程内做推理,不需要外部服务,不需要跨进程通信。它在CPU和GPU上都有优化,部署形态和一个DLL差不多,非常干净。
这几个方案的差距,我做了个对比:
| 方案 | 跨语言难度 | 产线部署形态 | CPU/GPU支持 | 维护成本 |
|---|---|---|---|---|
| Python推理服务 | 需要网络通信 | 独立进程,依赖Python环境 | 好,但多一层资源管理 | 较高 |
| ONNX Runtime | 低,NuGet直引 | DLL嵌入C#进程 | 原生支持 | 低 |
| OpenCV DNN | 低 | DLL | 一般,新模型支持滞后 | 低但能力受限 |
| TensorRT.NET | 中 | 绑定库 | NVIDIA专用,安装复杂 | 较高 |
用ONNX Runtime还有一个隐性好处:以后模型换了也不怕。不管是YOLOv8还是未来某个更新的检测模型,只要导出成ONNX,C#端的接口基本不变。把模型文件替换掉,重新加载Session就行,代码层面几乎不用动。
1.3 为什么上层集成选C#而不是Python/C++
工业视觉上位机这块,C#/.NET其实已经接近事实标准。产线上大量设备都有PLC、运动控制卡、机器人控制器,它们通信用的S7协议、Modbus、Socket、串口,在C#里都有非常成熟的库。现场工程师后期要改界面、加功能、接数据库,C#的开发效率也比C++高一大截。
而且Halcon本身提供了两套.NET接口:一套是在C#里直接调用算子的HalconDotNet,另一套是C#里嵌入HDevEngine执行Halcon脚本。C#做集成层可以同时把这两套都吃进来。你要是用Python写上位机,Halcon的集成反而绕。
有些人可能担心C#性能不够。实际上视觉系统里最重的计算都在ONNX Runtime和Halcon内部完成,C#负责的是调度、通信、界面、数据流转,这些场景下C#的性能完全够用。真正需要关心的是有没有频繁的接口拷贝和内存分配,这个后面专门讲。
2. 整个系统的分层与数据流:从相机到机械手坐标到底经过了几次转换
2.1 四层架构和每一层的边界
这套系统的代码组织,我习惯分成四层,分层清晰了后面所有问题都好处理。
第一层是采集层,负责从工业相机取流,不管是GigE还是USB3,不管是面阵还是线阵,统一在这一层封装成“输出一帧图像”的接口。上层完全不关心底层是哪个品牌的相机。
第二层是推理层,也就是ONNX Runtime跑YOLO的地方。输入原始图像,输出检测框数组(类别、置信度、四个坐标)。
第三层是测量层,也就是Halcon干的活。输入检测框和原始图像,输出最终的测量结果(精确中心坐标、角度、边缘距离、OK/NG判断)。
第四层是应用层,也就是C#的上位机部分,负责调用前面两层、管理界面显示、跟PLC/机械手通信、保存检测结果和图像日志。
分层的原则是:每一层的输入输出都是普通的数据结构和图像,层与层之间不共享内部状态。我有一次接手别人的项目,他把Halcon的HObject当作全局变量到处传,改一个地方崩三个地方。后来强制改成每层接口只传递明确数据,稳定性立刻上来了。
2.2 一帧图像从采集到输出的完整流转过程
一帧图像从相机出来到机械手拿到坐标,中间要经过八次关键的转换。理解了这八次转换,整个系统你在脑子里就有一个完整地图了。
第一步,相机输出原始像素数据,通常是灰度图或者BGR彩图,存放在相机SDK申请的内存缓冲里。
第二步,数据拷贝到深度学习模型需要的格式。YOLO要求的是一个固定尺寸(比如640x640)的RGB浮点张量,这一步要做resize、填充、通道重排、归一化。
第三步,ONNX Runtime推理,得到一个1x84x8400的原始输出张量。这个张量经过解码和NMS后,变成N个检测框,每个框是原始图像像素坐标系下的x1,y1,x2,y2。
第四步,把检测框的像素坐标转换成Halcon图像里的行列坐标。如果Halcon用的就是同一帧原始图像,这一步只是数值的表示方式变了。
第五步,根据检测框生成一个带膨胀余量的ROI区域,在ROI内部执行Halcon测量。为什么必须膨胀?因为YOLO框本身有误差,如果严格按检测框边缘做测量,很容易截到工件的内部噪声或阴影,导致边缘提取失败。
第六步,Halcon测量输出亚像素精度的坐标和角度,但这时候还是图像坐标系下的像素值和弧度值。
第七步,标定和手眼矩阵参与运算,把像素行列坐标转换成机械手底座坐标系下的毫米坐标和角度。
第八步,坐标通过TCP/IP、Modbus或数字IO送给PLC或机器人控制器,由它们执行抓取或对位动作。
这个流程里最容易出错的反而不是深度学习和Halcon本身,而是第四步和第七步的坐标变换。这两个地方出过太多“明明检测到了结果却完全不能用”的事故。
2.3 各环节数据格式的转换点
深度学习部分和Halcon部分对图像格式的假设不一致,这是个必须提前知道的坑。
YOLO在训练时通常用的是RGB图像,预处理时要把BGR(OpenCV读图顺序)或者相机直接输出的Bayer数据转成RGB再归一化到0到1。Halcon这边,它内部的图像表示是独立于C#内存的,用GenImage1等方式可以引用外部内存,但Halcon处理彩色图时内部的通道顺序是RGB。
我的一个建议是:如果现场场景不是特别依赖颜色特征,直接在采集层就把图像转成灰度图。灰度图可以同时喂给YOLO(单通道复制成三通道)和Halcon,完全绕开BGR/RGB的混乱。颜色确实重要的场景,再把通道转换收敛在一个工具类里,不要让通道顺序问题散落到各处。
3. C#集成ONNX Runtime完整代码路径:加载模型、预处理、推理、后处理
3.1 环境准备与NuGet包
实操层面的第一步是把该引的包引进来。我用的是最基础的一套组合:
- Microsoft.ML.OnnxRuntime(CPU推理版本)
- OpenCvSharp4(图像读入、resize等预处理)
- Halcon的
halcondotnet和hdevenginedotnet两个DLL(这两个需要从Halcon安装目录里引用,不走NuGet)
我建议新手先把CPU版本的ONNX Runtime跑通。YOLOv8n在CPU上跑640x640输入,单帧推理通常在几十毫秒到一两百毫秒之间,调试阶段完全够用。等流程跑通了,再把包换成Microsoft.ML.OnnxRuntime.Gpu,改一行代码就能切到CUDA执行。
模型文件这边,YOLOv8的官方仓库自带导出命令:
yolo export model=yolov8n.pt format=onnx opset=12 simplify=true导出的时候注意看终端的输出,它会打印Input和Output的名字和形状。不同版本导出的输入名,常见的有images,输出名有output0,但也见过直接叫数字的。后面代码里要用Session的Metadata动态查询这些名字,不要写死。
3.2 预处理Letterbox的正确写法
YOLO预处理里最容易写错的是Letterbox。它的作用是等比例缩放图像,把长边缩放到640,短边方向用灰色填充补到640,而不是直接把整张图拉伸成640x640。直接拉伸会改变物体长宽比,导致检测精度下降。
Letterbox还带一个隐藏作用:数字模型在训练时看到的所有图像都是640x640的填充图,推理时如果不保持同样的填充方式,模型的感受野和物体尺度就偏离了训练分布,小目标容易漏检。
C#里用OpenCvSharp可以这样写:
public Mat Letterbox(Mat src, int targetSize = 640) { int h = src.Rows; int w = src.Cols; double scale = Math.Min((double)targetSize / w, (double)targetSize / h); int newW = (int)Math.Round(w * scale); int newH = (int)Math.Round(h * scale); int padW = (targetSize - newW) / 2; int padH = (targetSize - newH) / 2; Mat resized = new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); Mat canvas = new Mat(targetSize, targetSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new Rect(padW, padH, newW, newH)]); return canvas; }这里填充值114是YOLO训练脚本里的常规配置,马上就能猜到是测试阶段的平均值。你换成0也可以,但最好和导出模型时的预处理保持一致。
3.3 推理与Tensor交互
ONNX Runtime的C#接口使用起来很简洁。先创建Session:
using var session = new InferenceSession("yolov8n.onnx");然后构造输入Tensor。这里的关键是内存布局。YOLO的输入形状是NCHW,也就是1x3x640x640。C#里可以用DenseTensor<float>直接构造,数据是一个平铺的一维数组,顺序是:C通道最外层,H次之,W最内层。
float[] data = new float[3 * 640 * 640]; // 假设canvas是Letterbox后的Mat,在OpenCvSharp里内存布局是HWC,通道顺序是BGR for (int y = 0; y < 640; y++) { for (int x = 0; x < 640; x++) { Vec3b pixel = canvas.Get<Vec3b>(y, x); data[0 * 640 * 640 + y * 640 + x] = pixel[2] / 255f; // R data[1 * 640 * 640 + y * 640 + x] = pixel[1] / 255f; // G data[2 * 640 * 640 + y * 640 + x] = pixel[0] / 255f; // B } } var inputTensor = new DenseTensor<float>(data, new[] { 1, 3, 640, 640 }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", inputTensor) }; using var results = session.Run(inputs); var output = results.First().AsTensor<float>();注意我的通道转换:模型训练时用的是RGB,OpenCvSharp存的是BGR,所以取像素时pixel[2]对应红色通道。这个错一个括号就是全网找了两个小时查不出结果的那种坑。
如果你有大批量图像要处理,这个最外层的三层for循环会变成性能瓶颈。优化方式有两个:一是用OpenCvSharp的Split把BGR通道拆开再各自转float,二是用Cv2.Dnn.BlobFromImage()一步到位生成NCHW浮点张量,后一种我实测效率更高。
3.4 YOLOv8输出张量解码与NMS
跑完推理得到的output,形状是1x84x8400。84的含义是4个框坐标加80个类别置信度,8400是三个不同尺度特征图上的候选框总数:80x80加40x40加20x20。
这8400个候选框的坐标,是以640x640输入图像为参考系的,不是原始图像坐标。每个候选框的格式是cx、cy、w、h,也就是中心点坐标加宽高。
解码的逻辑如下:
int numClasses = 80; int numBoxes = 8400; float confidenceThreshold = 0.25f; float scale = ...; // 从Letterbox步骤拿到的scale float padW = ...; float padH = ...; List<YoloBox> boxes = new List<YoloBox>(); for (int i = 0; i < numBoxes; i++) { float cx = output[0, 0, i]; float cy = output[0, 1, i]; float w = output[0, 2, i]; float h = output[0, 3, i]; float maxScore = 0; int classId = -1; for (int c = 0; c < numClasses; c++) { float score = output[0, 4 + c, i]; if (score > maxScore) { maxScore = score; classId = c; } } if (maxScore < confidenceThreshold) continue; // 还原到原始图像坐标系 float x1 = (cx - w / 2 - padW) / scale; float y1 = (cy - h / 2 - padH) / scale; float x2 = (cx + w / 2 - padW) / scale; float y2 = (cy + h / 2 - padH) / scale; boxes.Add(new YoloBox(x1, y1, x2, y2, maxScore, classId)); }解码出来的boxes需要再做一次NMS(非极大值抑制),把重叠的重复框去掉。NMS的标准实现是:按分数从高到低排序,取当前最高分的框加入结果,去掉所有和它IoU超过阈值的框,重复直到没有剩余。具体代码我就不贴了,网上很多现成实现,重点是IoU阈值现场调,0.45到0.7之间看重叠情况。
3.5 性能优化:预热、复用和线程选择
代码跑通之后就该考虑性能了。我在实际项目里做了三件直接影响产线节拍的事。
第一,模型预热。ONNX Runtime在第一次推理时会做很多初始化工作,首次调用可能比后续慢一个数量级。程序启动后找一张典型的现场图先跑一次推理,不取结果,只为了把初始化时间消耗在启动阶段。
第二,复用Tensor缓冲。每帧都重新new一个8400长度的张量数组,在长时间运行时会给GC造成压力。我一般用一个固定大小的float[],每次填充数据覆盖旧内容,再用它构造DenseTensor。注意DenseTensor在构造时会复制数据,如果不能接受这次拷贝,需要研究背后使用NativeMemory的零拷贝方式。
第三,选择合适的执行线程数。CPU推理时ONNX Runtime会默认使用机器全部物理核做算子内并行。工业一体机上可能还跑着相机SDK、界面、PLC通信,全部核心被推理占满会导致其他任务卡顿。用SessionOptions设置SetIntraOpNumThreads(4),限制为一个合理值,整体系统表现反而更流畅。
4. YOLO检测框如何变成Halcon的精确结果:坐标链路与ROI设计的核心细节
4.1 把检测框还原到原始图像坐标系
YOLO框最终要交给Halcon使用,第一步必须把它还原到原始图像坐标。上一节代码里已经有了还原公式:
float scale = ...; // Letterbox计算的缩放比例 float padX = ...; // Letterbox填充的宽方向半填充 float padY = ...; float rawX1 = (x1 - padX) / scale; float rawY1 = (y1 - padY) / scale;为什么是减pad再除以scale?因为Letterbox是把原图缩小后贴在640x640画布中央的。现在要把检测框从画布坐标映射回原图坐标,就得先把填充部分去掉,再把缩小过的坐标按比例放大。
这一步结束后,检测框就在和原始图像同一个坐标系里了,Halcon处理时可以认为检测框的行列值和图像像素是对应的。
我强烈建议在这个还原过程里写一个单元测试,用一张人工合成的图像验证:原图画两个已知位置的矩形,经过YOLO模型得到框后,反算回去的坐标应该和原始位置误差在几个像素以内。这个测试能拦截大量因为预处理细节写错导致的“偏了几个像素”问题。
4.2 在Halcon中构造ROI和测量工具
拿到原始坐标系的矩形框之后,要把这个框转换成一个Halcon可用的HObject。最简单的方式是直接在C#里生成一个矩形Region,传给测量算子。
double rowCenter = (rawY1 + rawY2) / 2.0; double colCenter = (rawX1 + rawX2) / 2.0; double height = Math.Abs(rawY2 - rawY1); double width = Math.Abs(rawX2 - rawX1); // 膨胀1.3倍,预留误差余量 double roiHeight = height * 1.3; double roiWidth = width * 1.3; HOperatorSet.GenRectangle1(out HObject roiRect, rowCenter - roiHeight / 2, colCenter - roiWidth / 2, rowCenter + roiHeight / 2, colCenter + roiWidth / 2);膨胀这一下非常关键。YOLO框紧贴目标边缘,但目标的实际边缘可能因为阴影、反光、透视关系在框内或框外几像素处。ROI膨胀后,测量算子可以在一个更宽裕的范围内搜索真实的边缘点。
ROI就绪后,按场景选择测量工具。
如果目标有明确的边缘线,用卡尺工具。这是最经典的做法:
gen_measure_rectangle2在指定位置生成一个测量矩形,内部有一组平行的测量线,沿着测量线找灰度突变点。- 关键参数包括矩形长度len1、宽度len2、测量线数量num_measures、Sigma平滑系数、Threshold边缘强度阈值。
- 找出的边缘点通过
fit_line_contour_xld拟合直线,就能得到亚像素级的位置和角度。
如果有明确的圆形定位孔,用gen_measure_circle加measure_pairs,拟合出精确的圆心和半径。
说明一下为什么用ROI裁剪而不是整图测量:Halcon的测量算子在ROI区域内运行,运行速度和鲁棒性都会大幅提升。整图测量不仅慢,而且会把ROI外面的干扰边缘也计入拟合结果,数据很容易被污染。
4.3 从像素坐标到机械手世界坐标
Halcon测量出来的是图像坐标系下的行列值和角度,机械手要用的是它自己坐标系下的毫米坐标。这个转换依赖标定。
标定分为两个部分。一是相机自身的畸变校正和像素到毫米的尺度标定,Halcon里用标定板的find_caltab和calibrate_cameras完成。二是相机坐标系到机械手坐标系的位姿关系,用Halcon的手眼标定算子解决,大概步骤是:让机械手带着标定板移动多个位姿,拍照后输入每个位姿的机器人坐标,解出相机相对机械手基座的变换矩阵。
如果现场对精度要求没那么苛刻,也可以用简化的仿射标定:找三个已知机械手坐标的点,测出它们在图像中的像素坐标,用vector_to_hom_mat2d算出仿射矩阵。后面每个测量结果直接乘这个矩阵,就能得到毫米坐标。这个方法只能处理平面场景并且不能补偿镜头畸变,但胜在快。
我在一个项目里遇到过这种情况:用YOLO在整幅图中检测出工件,用Halcon在ROI区域拟合出工件主轴的精确角度,然后把倾斜角度发送给机械手,让它在抓取前先旋转对应的角度。整个流程里,YOLO框坐标用来生成ROI,Halcon角度用来做纠偏,两个结果各司其职,缺一不可。
5. Halcon集成的两种姿势:HOperatorSet与HDevEngine,以及脚本热更新
5.1 两种集成方式怎么选
Halcon在C#里的集成方式大致分两种。
一种是直接用HalconDotNet,在C#代码里调用HOperatorSet下的各种算子。这种方式的优点是性能好、便于和C#代码深度结合,缺点是算法逻辑一旦复杂,C#代码就会变得又臭又长,而且每次调整测量参数都要重新编译整个上位机程序。
另一种是用HDevEngine,也就是在Halcon的HDevelop中写好脚本.hdev,C#程序通过HDevEngine加载脚本并执行。优点是算法和程序分离,现场调试时只需要改脚本文件,不用重新编译上位机;缺点是有一定的引擎调用开销,而且需要理解脚本和C#之间的参数传递。
两种方式并不是互斥的。我的经验是:核心测量逻辑用HDevEngine做脚本化,便于现场迭代;一些简单的辅助操作(比如生成ROI、存取图像)直接用HOperatorSet,减少脚本层的数据搬运。
5.2 用HDevEngine调用Halcon脚本的代码骨架
C#端调用HDevEngine的核心步骤包括初始化引擎、加载脚本、设置参数、执行、读取结果。
// 初始化引擎 HDevEngine engine = new HDevEngine(); engine.SetProcedurePath(new HTuple(new string[] { @"D:\halcon_scripts" })); // 加载脚本 HDevProcedure proc = new HDevProcedure("measure_part"); HDevProcedureCall call = proc.CreateCall(); // 设置输入参数:图像和检测框的坐标 call.SetInputIconicVariable("inImage", hoImage); call.SetInputCtrlVariable("inRow1", row1); call.SetInputCtrlVariable("inCol1", col1); call.SetInputCtrlVariable("inRow2", row2); call.SetInputCtrlVariable("inCol2", col2); // 执行脚本 call.Execute(); // 读取输出 HTuple outCenterRow = call.GetOutputCtrlVariableTuple("outCenterRow"); HTuple outCenterCol = call.GetOutputCtrlVariableTuple("outCenterCol"); HTuple outAngle = call.GetOutputCtrlVariableTuple("outAngle");这里有一个很实际的问题:Halcon脚本里的函数名、参数名要和C#端保持完全一致,大小写和下划线都不能错。我在项目里就直接规定,脚本里所有输入参数统一加in前缀,所有输出统一加out前缀,从命名上杜绝混乱。
C#和Halcon脚本之间的图像传递,HDevEngine会在内部处理好,但要注意hoImage的生命周期。C#端的HObject如果被释放了,脚本里再用就会直接报错。建议在调用前确认HObject仍然有效。
5.3 现场调参不重启:脚本热更新与回滚
为什么脚本化集成这么重要?因为工业现场的视觉算法参数几乎不可能一次调好。光源强度变了要改阈值,换了个批次工件要改ROI尺寸,产品升级要改检测逻辑。如果每次改参数都要重新编译发布上位机,现场工程师根本没法干活。
我做的脚本热更新逻辑很简单:程序启动后先加载脚本,然后起一个后台线程每秒检查脚本文件的最后修改时间和文件哈希。如果发现脚本变了,就尝试重新加载,加载失败就保留旧版本继续跑,同时弹出警告。
public class HalconScriptManager { private HDevProcedure _currentProc; private readonly string _scriptPath; private DateTime _lastWriteTime; public void CheckAndReload() { DateTime lastWrite = File.GetLastWriteTimeUtc(_scriptPath); if (lastWrite == _lastWriteTime) return; try { HDevProcedure newProc = new HDevProcedure(Path.GetFileNameWithoutExtension(_scriptPath)); _currentProc.Dispose(); _currentProc = newProc; _lastWriteTime = lastWrite; } catch (Exception ex) { // 新脚本有问题,保留旧脚本继续运行 Log.Error($"脚本加载失败,沿用旧版本. 错误: {ex.Message}"); } } }这个机制的另一个作用是回滚。如果换了个新脚本发现测量结果不对劲,现场工程师把旧脚本内容恢复保存,程序会自动加载回旧版本。整个过程不需要重启上位机,不会打断产线。
5.4 跨线程调Halcon必须注意的事
Halcon的线程安全是个容易被忽略的雷区。虽然新版HDevEngine声称线程安全,但我在不同环境下实测,多个线程同时调用同一个HDevProcedureCall或同时使用同一个HObject做处理,依然会出现概率性的崩溃和内存异常。
稳妥的做法有两种。
一种是在程序里做一个测量工作线程,所有图像按顺序排队,由这个单一线程串行执行Halcon调用。这种方式最简单也最安全,缺点是吞吐量受单线程限制,但对于大多数节拍在几秒以内的产线场景完全够用。
另一种是每个工作线程独立创建自己的HDevEngine实例和脚本对象,各线程之间完全不共享Halcon资源。这种方式适合节拍要求高、需要并行处理多条轨道的情况,但内存占用会增加。
我的建议是优先用单工作线程加有界队列的方案。首先它足够可靠,其次它天然解决了图像乱序的问题,调试时责任链路非常清晰。真要并发的时候,再按照第二种方案演进。
6. 从Demo到产线系统:稳定性、容错和那些我踩过的坑
6.1 超时、队列与资源释放
Demo程序跑得再好,上了产线都可能因为一个意外卡死整条线。工业环境里的程序和实验室Demo最大的区别,就是要处理各种“不配合”的情况。
第一件必须做的事是给每个环节加超时。相机取流可能超时,ONNX推理可能因为GPU被占用而变慢,Halcon脚本可能因为图像异常而卡住。C#里用Task.WhenAny加Task.Delay来实现超时控制是最简单的套路:
var inferenceTask = Task.Run(() => RunInference(image)); var completed = await Task.WhenAny(inferenceTask, Task.Delay(2000)); if (completed != inferenceTask) { Log.Error("YOLO推理超时,跳过本帧"); return null; }第二件事是有界队列。相机采集的图像不能无限往内存里堆,我用BlockingCollection<T>(boundedCapacity: 5)作为帧缓冲。如果处理速度跟不上采集速度,直接丢帧并记录“处理超载”,而不是让内存持续增长直到系统崩溃。
第三件事是资源释放。ONNX Runtime的Session、Halcon的HObject、OpenCvSharp的Mat都是非托管资源。每次处理完一帧,所有中间对象都要放到finally或using里释放。这里没有捷径,只能靠严格的编码习惯和代码评审。
6.2 长时间运行的内存与显存管理
产线设备是7x24小时跑的,程序不能运行三天就内存爆掉。我遇到过一个典型的泄漏场景:C#的Bitmap对象只调用了Dispose而没有调用System.Drawing.Bitmap底层的句柄释放,导致GDI句柄数持续上涨,运行两天后UI整个卡死。
排查内存泄漏的方法也很常规:程序里定时记录GC.GetTotalMemory、Process.WorkingSet64、GPU显存使用量,画成趋势图。如果某个指标单调上涨,基本可以判定有泄漏,再用逐步屏蔽功能模块的方式定位到具体代码。
GPU显存的管理同样要细心。ONNX Runtime的CUDA执行提供器会在Session创建时申请显存,如果程序频繁创建和销毁Session,显存碎片会越来越严重。正确做法是Session全局只创建一次,始终复用。另外,如果推理失败导致异常抛出,要在catch里检查显存状态,必要时释放Session重新创建。
6.3 现场排查经验:几个记忆深刻的故障
写到最后,分享几个我实际踩过的故障,每一个都花了不少时间才定位。
第一个是输入张量名称不匹配。导出的ONNX模型输入名是images,代码里却写成了input,运行时直接报无效输入名。这种问题用Session的InputMetadata打印出来一看便知,但新手很容易忽略。
第二个是预处理归一化错误。我接手过一个项目,推理结果时好时坏,后来发现是归一化时用了0-255范围没有除以255,模型输入数值范围全部错掉。这个问题在YOLOv5以前的老版本里因为模型内部有BN层兜底,还不容易暴露,但换到新模型后立刻现形。
第三个是Halcon License问题。开发机上跑得好好的,部署到产线工控机之后,一到半夜某个时间点就频繁告警,结果是开发版License的有效期到了。后面我们在程序里做了License剩余天数监控,提前告警,再没出过半夜躺被窝被电话叫起来的状况。
第四个是相机丢帧和图像序列号错位。产线节拍快的时候,相机偶尔会丢一帧,但计数器和图像没有绑定,导致保存的结果图像和序列号对不上,追溯时根本说不清哪张图对应哪个产品。后来我把相机的帧号、时间戳和检测结果绑定成一条完整记录,这个问题才算根治。
抛开这些具体故障,我的整体建议是:第一版系统不要追求复杂功能,先保证所有图像的检测结果都能保存下来、都能追溯到原始图像、都能回放到当时的算法参数。有了这个底子,后面不管算法怎么升级、参数怎么调,你都有据可查。视觉系统的稳定性是“可追溯性”堆出来的,不是靠运气。
这套C#加ONNX Runtime加YOLO加Halcon的架构,我在现场项目里反复磨合之后,最大的体会是:别一上来就想着上GPU、上大模型、上并行处理。很多产线场景,一张640x640的灰度图、一个CPU推理的YOLOv8n、一段简洁的Halcon测量脚本,已经能解决八成以上的问题。先把最朴素的串联流程跑稳,把坐标变换和标定这些基础链路搞扎实,再根据实际节拍决定要不要引入性能和复杂度。如果让我给后来者一个建议,那就是:重心永远放在“从检测框到测量结果的这条坐标链路上”,这条链路通透了,整个系统就稳了一大半。