ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# + ONNX + YOLO + Halcon:工业视觉系统集成部署实战架构

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 硬件和软件清单

先把环境定下来。我常用的基础配置是:

  • CPU:Intel Core i7 或同级别以上,优先选高主频而不是多核,因为视觉推理很多是串行依赖。
  • 内存:16 GB 起步,单机四相机以上建议 32 GB。图片缓存加上 Halcon 的临时对象,内存占用比你想象的高。
  • 显卡:可选,建议 8 GB 显存以上的工业卡或游戏卡。显存不够时模型推理容易崩。
  • 相机:GigE 或 USB3 工业相机,分辨率按检测精度选,500 万像素是产线最常见的。
  • 软件:Windows 10/11 64 位,Visual Studio 2022,.NET 6 或 .NET 8,Halcon 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 HalconDotNet

Microsoft.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(format="onnx", imgsz=640, opset=12, dynamic=False, simplify=True)

导出完成后,同目录下会多一个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。

导出的时候有两个点要注意。第一,dynamic=False表示固定输入尺寸,工业检测里一张图片就是一个批次,固定尺寸最稳定,推理速度也最快。第二,simplify=True会去掉一些冗余算子,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.At<Vec3b>(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 DenseTensor<float>(inputData, 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>();

这里有一个非常重要的认知:InferenceSession对象不是多线程安全的。四台相机同时采图,如果共用同一个 session,你会发现内存占用飙升、速度反而变慢,甚至会偶发崩溃。建议每台相机对应的处理线程维护各自的InferenceSession,模型文件都是同一个,但对象各自独立,互不干扰。

4.2 YOLO输出后处理与NMS

ONNX 输出的张量形状是[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 List<YoloDetection> PostProcess(float[] data, int channels, int anchors, float confThreshold, float iouThreshold, float ratio, int padX, int padY) { List<YoloDetection> detections = new List<YoloDetection>(); 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 List<YoloDetection> NonMaxSuppression(List<YoloDetection> detections, float iouThreshold) { var sorted = detections.OrderByDescending(d => d.Score).ToList(); var result = new List<YoloDetection>(); 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, List<YoloDetection> detections); void ReportNg(int jobId, string productCode, List<YoloDetection> 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、显卡占用,重点看内存曲线是不是一直上升。工业现场最怕的不是算法不准,而是跑半个月后某个夜里突然卡死,所以稳定性永远排第一位。把上面这些细节做到位,这套架构在产线上扛住三五年是没问题的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进