C# + ONNX + YOLO + Halcon:工业视觉系统集成部署实战架构 1. 这套架构解决什么问题为什么值得自己搭做了好几年工业视觉项目最常被问的一句话是你到底用 Halcon 还是用 AI这问题其实问偏了。真正在产线上跑的东西很少是单选题。标定、测量、引导定位用 Halcon 非常顺手但遇到表面缺陷、复杂分类、乱序抓取这些场景传统算法得写一堆规则才能勉强跑换成 YOLO 这类目标检测模型效果瞬间就上来了。问题是模型训练好了怎么部署Python 脚本在开发机上跑得挺好到了 Windows 工控机上要做成服务、连 PLC、接 MES还是得回到 C# 这个成熟的生态里。所以这套「C# ONNX YOLO Halcon」的架构本质上不是炫技是产线现实逼出来的组合拳。C# 负责主流程和业务逻辑ONNX Runtime 负责把训练好的 YOLO 模型跑起来Halcon 继续干它最擅长的传统视觉活。三者的分工很清楚AI 做“看不明白”的检测传统视觉做“需要精确到亚像素”的测量C# 做“保证 7×24 小时稳定跑”的胶水层。这篇文章适合谁看如果你正在做工业视觉项目手头有 Halcon 授权但不想把所有逻辑都堆在 HDevelop 里或者你想把 YOLO 模型落地到 Windows 工控机却不知道该从哪里下手那么这篇内容就是照着抄的作业。我会按实际项目里的搭建顺序把架构设计、环境准备、模型导出、核心代码、踩坑记录全部过一遍尽量让你看完之后能直接搭出一个可运行的骨架。2. 整体架构设计从相机到MES的消息链路2.1 四层模块划分与职责工业视觉系统最忌讳的是所有功能写在一个文件里。我在做这套架构时第一件事就是按职责把系统切成四层每一层只干自己那点事层与层之间通过接口通信这样后面换相机、换模型、换 Halcon 版本都不会牵一发动全身。层级技术选型主要职责相机接入层Halcon / 相机SDK采图、硬触发、软触发、曝光控制视觉处理层C# ONNX Runtime HDevEngineYOLO深度学习推理、Halcon定位测量、结果融合业务逻辑层C# 服务 HMI流程调度、判定逻辑、日志记录、报警处理数据交互层PLC通信、MES接口、数据库信号握手、工件追溯、配方下发、结果上传相机接入层不建议自己写多线程抓图Halcon 的OpenFramegrabber GrabImageAsync已经封装好了 Gige Vision / USB3 Vision 的底层协议而且它自带缓冲管理比你自己用 SDK 裸调要稳得多。视觉处理层是这套架构的核心后面我会重点展开。业务逻辑层用 WinForms 或 WPF 都行看你团队哪个熟界面本身不要掺视觉算法。数据交互层用现成的通信库S7 协议用开源库Modbus 用标准库MES 对接就是用 HTTP 或数据库读写没必要自己造轮子。2.2 为什么选C#当胶水层而不是Python或C很多人会问AI 部署用 Python 不是更顺手吗确实开发阶段 Python 很舒服但到了工业现场问题就来了Windows 工控机上要装 Python 环境、各种依赖、显卡驱动版本经常被误升级一旦运行环境被破坏产线停线你哭都来不及。C# 编译成单个可执行文件加几个原生 DLL依赖关系清楚很多配合 .NET 的配置文件还能热改参数。C 性能最好但开发效率低尤其做界面和对接系统的时候特别痛苦。C# 在工业视觉里有个非常大的优势Halcon 官方提供 .NET 接口ONNX Runtime 也有原生的 C# NuGet 包你可以在同一个进程里直接互相调用不需要开一堆子进程甚至跨语言通信中间件。你想想如果每个环节都走 HTTP 或者进程间通信一个视觉节拍 500 毫秒光通信开销就能吃掉四分之一。还有一点很实际PLC 厂商的通讯组件、扫描枪的上位机 DLL、数据库驱动几乎都有 C# 示例。产线维护工程师以后要改逻辑看 C# 也比看 C 容易得多。当然C# 不是万能的但在这个场景里它是综合成本最低的胶水层。2.3 模型推理放ONNX传统视觉留给Halcon为什么要单独弄一个 ONNX 层而不是直接调 PyTorch 或者 TensorFlow 的 C# 接口因为模型是会迭代的。今天用 YOLOv8明天可能换成新出的检测模型出了 bug 还可能回退到旧版本如果在代码里写死某个训练框架每次换模型都要重新编译一个 DLL太疼了。ONNX 相当于一个模型交换格式PyTorch 训练完导出一次 ONNX部署端只要用InferenceSession加载同一个文件就行框架怎么体验对部署端完全透明。Halcon 在这里的定位不是深度学习框架而是传统视觉工具箱。它的标定算子、形状匹配、边缘提取、亚像素测量闭着眼睛都比 OpenCV 稳定。尤其是相机标定这块我见过太多项目先用 OpenCV 标定再转 Halcon最后精度对不上又回来重标。如果你项目里已经有 Halcon 授权那直接把 Halcon 当做视觉算法库用不要让 C# 去重复实现那些底层几何计算没意义。选型上还有一个关键点ONNX Runtime 默认支持 CPU 推理也可以挂显卡加速。CPU 足够跑 YOLOv8n 或 YOLOv8s但如果是工件多、节拍快建议还是上一张支持 CUDA 的显卡。工业现场用 GPU 有个坑驱动不能随意更新否则可能不兼容后面第五节我会专门讲。3. 从0搭建环境与项目骨架3.1 硬件和软件清单先把环境定下来。我常用的基础配置是CPUIntel Core i7 或同级别以上优先选高主频而不是多核因为视觉推理很多是串行依赖。内存16 GB 起步单机四相机以上建议 32 GB。图片缓存加上 Halcon 的临时对象内存占用比你想象的高。显卡可选建议 8 GB 显存以上的工业卡或游戏卡。显存不够时模型推理容易崩。相机GigE 或 USB3 工业相机分辨率按检测精度选500 万像素是产线最常见的。软件Windows 10/11 64 位Visual Studio 2022.NET 6 或 .NET 8Halcon 20.11 及以上版本。如果你的 Halcon 版本比较老比如 13.0也可以照用但 .NET 接口名称可能会有差异代码里我提到的新 API 需要按你实际版本调整。3.2 创建解决方案与NuGet依赖VS 里我习惯建两个项目一个类库放核心视觉逻辑叫VisionSystem.Core一个 WinForms 工程放界面叫VisionSystem.Hmi。这样以后你打算把核心逻辑做成 Windows 服务不需要动界面代码。命令行方式创建骨架dotnet new sln -n VisionSystem dotnet new classlib -n VisionSystem.Core dotnet new winforms -n VisionSystem.Hmi dotnet sln add VisionSystem.Core VisionSystem.Hmi dotnet add VisionSystem.Core reference VisionSystem.Hmi当然上面最后一条引用方向反了应该是 Hmi 引用 Core我在实际创建时是dotnet add VisionSystem.Hmi reference VisionSystem.Core然后是 NuGet 包。在VisionSystem.Core项目里安装dotnet add package Microsoft.ML.OnnxRuntime.Gpu dotnet add package OpenCvSharp4.Windows dotnet add package HalconDotNetMicrosoft.ML.OnnxRuntime.Gpu这个包默认带 CUDA 支持如果你不上显卡就换成Microsoft.ML.OnnxRuntime。OpenCvSharp4.Windows是给图像预处理用的YOLO 的前处理和后处理用 OpenCV 最顺手。HalconDotNet就是 Halcon 的官方 .NET 接口。3.3 把YOLO模型导出为ONNX我默认你已经用 YOLOv8 训练好了模型。训练细节这里不展开单说导出这一步用 Python 一行命令from ultralytics import YOLO model YOLO(runs/detect/train/weights/best.pt) model.export(formatonnx, imgsz640, opset12, dynamicFalse, simplifyTrue)导出完成后同目录下会多一个best.onnx。注意看一下导出的输入输出张量在大多数版本里输入节点名images输出节点名output0输入形状[1, 3, 640, 640]输出形状[1, 84, 8400]如果训练时只有 5 类目标那么输出形状里的 84 就要换成4 5 9也就是前 4 位是框的坐标信息后面 5 位是每个类的得分。8400 这个数是 YOLO 三个尺寸特征图预测点的总数80×80 40×40 20×20 8400。导出的时候有两个点要注意。第一dynamicFalse表示固定输入尺寸工业检测里一张图片就是一个批次固定尺寸最稳定推理速度也最快。第二simplifyTrue会去掉一些冗余算子ONNX Runtime 加载更快。导完之后一定用 ONNX Runtime 在本地跑一张图验证不要只在 Python 里验证因为部署端和训练端的数值细节可能有细微差别。4. C#代码实现YOLO推理与Halcon联动4.1 ONNX Runtime推理代码在做推理之前先要有一张 640×640 的输入图。你不能直接把工业相机拍到的原图塞进去因为分辨率通常是 2448×2048 甚至更高。需要做一个 letterbox 缩放也就是按比例缩放到 640×640多余部分用灰色填充保证长宽比例不变这样才能避免目标变形导致检测精度下降。预处理这一步我直接用 OpenCvSharp 实现public static float[] Preprocess(Mat bgrImage, int inputSize, out float ratio, out int padX, out int padY) { ratio Math.Min((float)inputSize / bgrImage.Width, (float)inputSize / bgrImage.Height); int newW (int)Math.Round(bgrImage.Width * ratio); int newH (int)Math.Round(bgrImage.Height * ratio); padX (inputSize - newW) / 2; padY (inputSize - newH) / 2; Mat resized new Mat(); Cv2.Resize(bgrImage, resized, new OpenCvSharp.Size(newW, newH)); Mat canvas new Mat(inputSize, inputSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new OpenCvSharp.Rect(padX, padY, newW, newH)]); float[] data new float[3 * inputSize * inputSize]; int index 0; for (int y 0; y inputSize; y) { for (int x 0; x inputSize; x) { Vec3b pixel canvas.AtVec3b(y, x); data[0 * inputSize * inputSize y * inputSize x] pixel.Item2 / 255f; // R data[1 * inputSize * inputSize y * inputSize x] pixel.Item1 / 255f; // G data[2 * inputSize * inputSize y * inputSize x] pixel.Item0 / 255f; // B } } return data; }注意这里用的是Vec3b循环取像素开发调试没问题但生产环境速度偏慢。上线前要改成指针遍历或者Mat.GetArray否则一秒钟处理几十张图时会成为瓶颈。ONNX Runtime 加载模型和推理的代码很简单using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; SessionOptions sessionOptions new SessionOptions { LogSeverityLevel OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR }; if (useGpu) { sessionOptions.AppendExecutionProvider_CUDA(0); } else { sessionOptions.AppendExecutionProvider_CPU(); } InferenceSession session new InferenceSession(modelPath, sessionOptions); float[] inputData Preprocess(bgrImage, 640, out float ratio, out int padX, out int padY); var inputTensor new DenseTensorfloat(inputData, new[] { 1, 3, 640, 640 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, inputTensor) }; using var results session.Run(inputs); var output results.First().AsTensorfloat();这里有一个非常重要的认知InferenceSession对象不是多线程安全的。四台相机同时采图如果共用同一个 session你会发现内存占用飙升、速度反而变慢甚至会偶发崩溃。建议每台相机对应的处理线程维护各自的InferenceSession模型文件都是同一个但对象各自独立互不干扰。4.2 YOLO输出后处理与NMSONNX 输出的张量形状是[1, 84, 8400]它的含义和 PyTorch 里的[1, 8400, 84]不一样数据在内存里是通道优先排列。我在第一次调通的时候在这里卡了很久后来干脆写了一个单独的类来做解码和后处理。代码逻辑如下public class YoloDetection { public RectangleF Box { get; set; } public float Score { get; set; } public int ClassId { get; set; } } private static ListYoloDetection PostProcess(float[] data, int channels, int anchors, float confThreshold, float iouThreshold, float ratio, int padX, int padY) { ListYoloDetection detections new ListYoloDetection(); for (int i 0; i anchors; i) { float maxScore 0; int maxClassId -1; for (int c 4; c channels; c) { float score data[c * anchors i]; if (score maxScore) { maxScore score; maxClassId c - 4; } } if (maxScore confThreshold) continue; float cx data[i]; float cy data[1 * anchors i]; float w data[2 * anchors i]; float h data[3 * anchors i]; float x (cx - w / 2 - padX) / ratio; float y (cy - h / 2 - padY) / ratio; float boxW w / ratio; float boxH h / ratio; detections.Add(new YoloDetection { Box new RectangleF(x, y, boxW, boxH), Score maxScore, ClassId maxClassId }); } return NonMaxSuppression(detections, iouThreshold); }NMS非极大值抑制的作用是去掉重复框。因为 YOLO 同一个目标往往会有好几个预测框我们要保留得分最高的那个然后删掉和它重合度过高的框private static ListYoloDetection NonMaxSuppression(ListYoloDetection detections, float iouThreshold) { var sorted detections.OrderByDescending(d d.Score).ToList(); var result new ListYoloDetection(); while (sorted.Count 0) { var best sorted[0]; result.Add(best); sorted.RemoveAt(0); sorted.RemoveAll(d IntersectionOverUnion(best.Box, d.Box) iouThreshold); } return result; } private static float IntersectionOverUnion(RectangleF a, RectangleF b) { float x1 Math.Max(a.Left, b.Left); float y1 Math.Max(a.Top, b.Top); float x2 Math.Min(a.Right, b.Right); float y2 Math.Min(a.Bottom, b.Bottom); float intersection Math.Max(0, x2 - x1) * Math.Max(0, y2 - y1); float union a.Width * a.Height b.Width * b.Height - intersection; return union 0 ? intersection / union : 0; }效率上这段代码还有优化空间但你先把逻辑跑通。实际项目里如果目标数量少这速度完全扛得住如果一帧图片有几十上百个目标建议把 NMS 改成按类别分别跑避免不同类的框互相误删。4.3 通过HDevEngine调用Halcon算法YOLO 出了检测框接下来往往要做精确测量。比如 AI 发现工件上有个缺陷区域但缺陷的具体面积、长度、是否超公差需要用 Halcon 做亚像素处理。这里的连接点就是 HDevEngine它在 C# 里直接调用 HDevelop 写好的过程文件。我的做法是先用 HDevelop 把算法封装成过程比如一个analyze_defect.hdvp文件内部输入一个图像和一个区域输出面积和灰度均值。然后在 C# 里加载这个过程using HalconDotNet; HDevEngine engine new HDevEngine(); engine.SetProcedurePath(D:\vision\halcon_procs); HDevProcedure procedure new HDevProcedure(analyze_defect); HDevProcedureCall call procedure.CreateCall(); call.SetInputIconicVarParam(Image, hoImage); call.SetInputCtrlParam(MinGray, 80); call.Execute(); HTuple area call.GetOutputCtrlParam(DefectArea); HObject resultRegion call.GetOutputIconicVarParam(ResultRegion);这段代码有个细节engine和procedure不需要每次采图都重新创建开销很大。把它们在系统初始化时创建好存成全局对象或者单例每一帧只需要创建call并执行就可以了。HDevProcedureCall执行完要Dispose否则内存涨得很快后面我会讲。Halcon 的HObject和 C# 的Mat之间的互相转换路径一定要理清楚。如果是 Halcon 采到的图直接给 YOLO 前处理我建议先转成Mat因为 OpenCvSharp 的像素遍历简单。如果是相机 SDK 直接输出的 byte 数组也可以直接构造Mat再走 YOLO 流程。不要全程把HObject和Mat来回转每转一次都有内存拷贝和格式切换的成本。4.4 与PLC和MES对接的接口封装视觉系统不是独立运行的它要和产线配合。我最常用的对接方式是PLC 给一个“拍照请求”信号视觉系统收到信号后采图、推理、测量然后把 OK/NG 结果写回 PLC同时把检测数据通过 MES 接口上传。这块我只给一个 C# 接口设计的建议public interface IVisionResultReporter { void ReportOk(int jobId, string productCode, ListYoloDetection detections); void ReportNg(int jobId, string productCode, ListYoloDetection detections, string reasonCode); }PLC 通信模块、MES 上报模块都去实现这个接口主流程只面向接口编程。以后从 Modbus 改成 S7、从本地数据库改成 HTTP 接口都只替换实现类不动视觉处理代码。工业项目里“流程代码写得足够抽象”是后面半年省心还是费心的分水岭。5. 生产落地中的常见问题与排查5.1 推理速度不达标同样一张图有人跑 50 毫秒有人跑 300 毫秒差距不在模型大小而在工程细节。先检查你有没有把图像 Resize 放在 GPU 上如果每次都在 CPU 上缩放然后把大图数据拷贝进 GPU时间全花在拷贝上了。处理方法是提前在 CPU 阶段把图缩放到 640×640再一次性上传。其次不要在循环里反复创建InferenceSession。有些新手把加载模型写在每次检测函数里一次检测就要加载一次这不叫推理叫折磨硬盘。模型必须在程序启动时加载一次。最后如果用的是 CPU 推理把SessionOptions里的IntraOpNumThreads按实际核心数配置不要放任默认。显卡推理时尽量固定输入尺寸避免动态 shape 导致 CUDA 每次重新分配内存。5.2 Halcon和C#内存冲突与泄漏HObject 和 HDevProcedureCall 都是非托管资源不主动释放垃圾回收又来不及处理跑一晚上内存就涨上去了。我见过最严重的一次内存从 2 GB 涨到 12 GB最后系统卡死。对策很简单所有临时HObject用完立刻Dispose所有HDevProcedureCall放进using或者try-finally。HImage如果是从HalconDotNet接口拿到的注意它可能引用了相机缓冲区的内存不能随便释放否则相机 SDK 会崩。稳妥的做法是HOperatorSet.CopyImage复制一份到独立内存用完释放自己的副本。5.3 模型误检多怎么办YOLO 模型在实验室里 mAP 很高到了现场误检一堆最常见的原因是现场光照和训练数据不一致。工业现场打光不一样同样的工件拍出来完全是两个模样。所以做深度学习训练时最好直接采产线的真实图片不要用网上数据集。如果模型已经上线短期内不能重新训练可以先调置信度阈值。我在实际项目里会把默认置信度放在 0.25但对 NG 判定场景会提到 0.5 以上宁可漏检再走人工复检也不要误杀导致大量过杀停产。还有一招是对检测结果做规则过滤比如目标太小、长宽比不合理、位置不在有效区域都可以直接丢弃。这些过滤规则写在 C# 里比改模型快得多。5.4 多相机并发处理多相机同时工作如果每个相机都独立采图但共享同一个 ONNX 模型 session性能瓶颈非常明显。我在第三节也提到了InferenceSession不保证线程安全所以多相机一定要做线程隔离。我常用的做法是每个相机一个独立线程线程内穿一个完整的视觉链路包括采图、预处理、推理、后处理、Halcon 测量。线程之间不共享可变状态只通过队列把最终结果交给主线程显示和上传。这样每个相机看起来就是一套独立系统稳定性最好。如果显卡显存不够撑不起每个线程一个 session可以考虑用SemaphoreSlim限流让多个相机线程共用一个 GPU 推理入口但这样节拍会受影响。最省显存的做法是 CPU 推理多线程显卡只跑一个主相机的高精度检测。说到底得先算清楚产线节拍要求是多少再决定资源分配。5.5 常见问题速查表现象可能原因处理方式推理结果坐标偏了前处理时没记住 pad 和 ratio后处理坐标还原时必须用同一组 padX / padY / ratio输出数组越界模型输出通道数不是 4类数根据训练类别数调整channels显卡推理报错CUDA 或 cuDNN 版本与 ONNX Runtime 不匹配查看 ONNX Runtime 版本对应表固定驱动版本Halcon 调用偶发崩溃HDevEngine 多线程共用每个线程创建独立的 engine 或加锁内存持续上涨HObject 或 call 未 Dispose严格释放临时对象做内存快照对比6. 最后聊一点工程化体会这套架构真正让我觉得值得的不是某个单点技术多牛而是它把“AI 模型迭代”这件事变得非常轻。模型训练好了导出 ONNX直接扔到指定目录C# 程序重新加载一下就能切换不需要重新编译整个系统。Halcon 那边如果算法要调也只需要改 HDevelop 过程文件C# 代码基本不动。我个人在实际项目中踩过最大的坑就是一开始把太多逻辑写进了 HDevelop导致 C# 这边完全无法控制流程。后来我把 HDevelop 只当成“算法算子库”谁调、怎么调、结果怎么用全部由 C# 决定系统瞬间就清晰了。如果你也在搭类似的系统我建议你从第一天就坚持这个边界C# 管流程ONNX 管深度学习模型推理Halcon 管传统视觉算子谁也不越界。还有一个小技巧上线前一定要做连续 48 小时压力测试记录内存、CPU、显卡占用重点看内存曲线是不是一直上升。工业现场最怕的不是算法不准而是跑半个月后某个夜里突然卡死所以稳定性永远排第一位。把上面这些细节做到位这套架构在产线上扛住三五年是没问题的。