几个月前,我们团队接手了一个产线级视觉检测项目,需求本身不复杂:C#上位机控制工业相机拍照,调用YOLO模型做缺陷检测,再把结果同步给PLC实现自动分拣。从实验室里跑通Demo到产线上稳定运行,这中间踩的坑远比想象中多。很多人觉得YOLO部署无非就是拿个ONNX模型,用C#调用一下推理引擎,实际上线一小时就会被打脸——相机触发、图像传输、推理耗时、结果判定、通信握手,任何一个环节都可能让整个系统卡壳。这篇博文就把我们从理论到产线的完整过程拆开来讲,包括方案选型、关键代码逻辑、调试思路、踩坑记录和避坑技巧,给正在做或者准备做类似项目的朋友一个参考。
适合谁看?如果你正在用C#做上位机、想把YOLO视觉方案落地到实际产线、或者已经在做但被各种隐藏问题折磨,这篇文章应该能帮你省下不少时间。
1. 项目背景与整体架构选型
1.1 为什么选择C#上位机+YOLO这套组合
先说背景:我们的检测目标是某工厂产线上的零部件外观缺陷,包括表面划痕、缺料、边缘毛刺三类。产线节拍要求单件检测时间不超过2秒,检测精度要求召回率在95%以上。团队之前有C#开发经验,算法部分则用YOLO系列模型做了大量验证。
说实话,选C#做上位机在当时几乎是唯一选择:工控领域的主流PLC通信库、工业相机SDK,比如海康、大华的官方驱动,C#封装都很成熟,而且WinForm/WPF开发效率高,产线操作工培训成本也低。至于视觉算法这块,YOLO已经是工业视觉里绕不开的方案,检测速度快、精度高,而且模型导出和部署路径非常清晰,PyTorch训练完导出ONNX,C#侧直接调用。
很多人纠结要不要用Python直接做全套,我的建议是:如果只是做实验、调算法,Python完全没问题。但如果要对接产线设备、做长期维护、和MES系统交互,C#上位机的生态优势太明显了。我们项目里PLC用的是MODBUS TCP协议,上位机用C#写一个通信类库,几百行代码就搞定了,换成Python还得额外处理很多边界问题。
1.2 系统整体架构与数据流设计
整个系统的硬件组成很简单:一台工控机(i5处理器、16GB内存、无独立显卡,算力全靠CPU)、一个千兆网工业相机、光源控制器、PLC和执行机构。软件架构上分成三层:
- 界面层:C# WinForm负责参数配置、实时画面显示、检测结果统计
- 业务逻辑层:负责相机触发、图像采集、推理调用、结果判定、PLC通信
- 算法服务层:封装YOLO推理,提供图像输入、结果输出的标准接口
数据流是这样的:PLC给上位机发送拍照信号(电平触发)→ 上位机控制相机采集图像 → 图像送入推理模块 → 推理结果返回 → 上位机根据判定规则决定OK/NG → 结果写入PLC寄存器 → PLC驱动分拣机构动作。
这里有个关键设计决策:我们把YOLO推理放在独立线程池中,而不是和UI线程混在一起。否则图像推理期间界面会卡死,操作工一旦误操作,整个流程就乱了。线程池队列容量可以配置,默认是10个任务,超出后丢弃并记录日志,防止内存暴涨。
2. 核心模块设计与关键细节
2.1 工业相机采集与图像预处理
产线视觉项目里,相机采集是最容易出问题但又最容易被忽视的环节。我们用的是某国产工业相机,200万像素黑白相机,配了一个16mm定焦镜头,工作距离大概280mm,视野范围调到刚好覆盖零部件整个表面。
采图这块有几个点必须说明:
第一,相机触发模式。我们用的是硬触发模式,PLC通过IO卡发一个脉冲信号给相机的触发线,相机收到信号后立即曝光采集。比软触发(上位机发指令触发)可靠得多,因为软触发存在线程调度延迟,在高节拍下容易丢帧。硬触发延迟可以控制在微秒级别,完全满足工业需求。
第二,图像格式。工业相机默认输出的可能是Bayer原始格式,在C#里要用SDK转换,我们直接设置相机输出Mono8格式,也就是每个像素8位灰度,省去转换环节。YOLO模型输入也需要归一化到0-255的灰度图,直接用Mono8最方便。
预处理方面踩过一个坑:YOLO输入尺寸一般要求640×640,但相机原图分辨率是1920×1200。直接Resize会丢失小目标细节,后来我们改成先按长边等比缩放,再对剩余区域填充灰色,相当于Letterbox处理。这样小缺陷的特征保留度比直接拉伸好很多,前期测试阶段漏检率直接降了3个百分点。
2.2 YOLO推理引擎的集成方案
C#调用YOLO推理,主流方案有两个:一个是ONNX Runtime,微软官方维护,跨平台、支持CPU和GPU;另一个是OpenVINO,Intel家的,对自家硬件优化好。我们工控机是Intel CPU,理论上OpenVINO推理速度更快,但这个项目里工控机算力还算够用,而且ONNX Runtime的部署更通用,后续换硬件不用改代码,所以最终选了ONNX Runtime。
模型导出这一步坑也不少。训练好的YOLO模型用PyTorch导出ONNX时,要注意几个参数:opset_version建议用17以上,动态轴要显式声明,否则C#侧导出时输入尺寸写死就麻烦了。我们用的是:
import torch import torch.onnx model = torch.load("best.pt", map_location="cpu")["model"].float() model.eval() dummy_input = torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy_input, "best.onnx", opset_version=17, input_names=["images"], output_names=["outputs"], dynamic_axes={"images": {0: "batch_size"}} )C#侧加载模型时,我用的是ONNX Runtime的.NET API,直接以NuGet包引入即可。关键代码逻辑:
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var session = new InferenceSession("best.onnx"); var inputMeta = session.InputMetadata.First(); var inputName = inputMeta.Key; var inputTensor = new DenseTensor<float>(new[] { 1, 3, 640, 640 }); // 填充图像数据... var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(inputName, inputTensor) }; using (var results = session.Run(inputs)) { var output = results.First().AsTensor<float>(); // 解析output:四个值,分别是x1, y1, x2, y2, confidence, class_id }这里有个重要的点:YOLOv5的ONNX输出是一个[1, 25200, 6]的张量,其中25200是三个尺度feature map的anchor总数,6表示x1、y1、x2、y2、confidence、class_id。需要在这些候选框上做非极大值抑制(NMS),筛掉重叠的框。如果不做NMS,同一个缺陷会被框三四次,计数器直接爆掉。
2.3 结果判定逻辑与PLC交互设计
算法的输出只是“检测框坐标+置信度”,上位机的职责是把这些转换为产线能执行的动作。这里不能只根据置信度判定OK/NG,因为实际产线上,一次拍照可能出现多个缺陷,也可能同一个位置有多个重叠框。我们制定了三层判定逻辑:
- 第一层:对每个检测框做NMS,保留最终确定的框
- 第二层:根据置信度阈值(默认0.5)筛掉低质量框
- 第三层:业务规则判定,比如同一零部件上出现任何缺陷类别中置信度最高的框,即判定为NG
细化到我们项目里:如果检测到1个划痕框,但置信度只有0.45,单独看它可能没到阈值。缺陷往往在临界状态下是模糊的——光照稍微一变,同一位置有时检出0.6,有时0.4。所以我们的业务规则是:只要存在任何一个框的置信度超过阈值,就判定NG,而不是要求所有框都超过。为避免重复计数,对同一坐标附近的多个框,我们把它们合并成一个缺陷记录。
PLC通信我们用的是MODBUS TCP,这是工业现场最常见的协议。上位机作为Client,PLC作为Server。上位机周期性地读取PLC的寄存器区(地址30001),其中第1个寄存器存放PLC发送的“拍照请求信号”,第2个寄存器用于上位机返回检测结果(1=OK,2=NG),第3个寄存器是握手信号。这块的通信协议看似简单,实际调试中踩了不少坑,后面常见问题章节会展开说。
3. 从Demo到产线的实操落地过程
3.1 环境搭建与通信验证
项目开始的第一周,主要在搭环境、验证通信链路。先把工控机系统装好,Windows 10专业版,然后装.NET运行时(我们用的是.NET Framework 4.8,因为相机SDK和老代码兼容性更好)。装环境时有个小坑:ONNX Runtime的NuGet包有原生依赖,需要确保VC++运行库已安装,否则运行时会报找不到DLL的错误。
接着做通信链路验证。先测上位机到PLC的MODBUS TCP连接——用Modbus Poll工具先测好寄存器读写,确认PLC地址映射正确之后,再用我们C#的通信类去读。这个阶段发现一个重要问题:PLC那边的寄存器地址分配是工程师预先定义好的,必须严格按照他们的地址表来,一个位写错,整个信号就乱了。建议拿到PLC地址表后,先在程序里写一个“寄存器自检”功能,启动时自动读一遍所有寄存器,显示在界面上,方便排查。
相机SDK验证,设置相机IP和子网掩码时,注意工业相机和工控机要在同一个网段,否则Camera SDK扫描不到。我们当时就是把IPC的IP设成了192.168.0.2,工控机网卡还保留设成192.168.0.1,结果扫描不到相机。后来发现网卡驱动默认开启了“巨型帧”,导致图像数据传输丢包,关掉就正常了。
3.2 推理服务与上位机的数据对接
环境稳定后,开始正式写推理模块。我把YOLO推理封装成一个独立的YoloDetector类,构造函数加载模型,提供Detect(Bitmap image)方法,返回一个List<DetectionResult>,每个结果包含类别、置信度和矩形区域。这样UI层、业务逻辑层都依赖这个统一接口,后续换模型或者换推理后端都只改一处。
封装完推理模块,再写一个生产者-消费者模式的检测服务类。生产者是PLC发来的拍照信号,消费者是推理线程。每当PLC发来信号,上位机就采图、推理,这个过程在消费者线程里完成。代码结构大概是这样:
private void PlcSignalLoop() { while (true) { // 读取PLC拍照请求信号 int req = plc.ReadRegister(30001); if (req == 1) { // 采集图像 Bitmap frame = camera.GetImage(); // 推理 var detections = detector.Detect(frame); // 判定 int result = Judge(detections); // 写回PLC plc.WriteRegister(30002, result); plc.WriteRegister(30003, 1); // 握手完成信号 // 更新界面统计 UpdateUi(detections); } Thread.Sleep(30); } }这个循环里用的Thread.Sleep(30)是轮询PLC的间隔。实际产线对实时性要求没那么高,30毫秒足够快到人眼看不出延迟。但如果产线节拍更快,就需要改成消息队列+事件通知的方式,否则轮询会成为瓶颈。
界面设计上,我加了一个实时检测结果面板:左边是一块大画面区域,直接把相机图像实时显示,检测框以不同颜色覆盖(红色NG,绿色OK);右边是统计数据,包括总检测数、OK数、NG数、通过率,以及一个扇形图显示各类缺陷占比。操作工不需要任何培训,一眼就能看出当前产线状态。
3.3 产线节拍优化与UI设计
Demo阶段跑通后,就要开始抠时间,因为产线要求单件2秒以内。我们先做了分阶段的耗时分析,结果发现问题集中在三块:图像采集耗时约350ms(曝光时间太长)、推理约900ms、PLC通信和逻辑判断约200ms,单件总耗时1.45秒,理论上是够的。但实际连续跑起来,偶尔会出现2.5秒的情况,说明存在偶发阻塞。
排查之后发现三个原因:一是推理线程和UI线程共用同一个内存中的Bitmap对象,UI更新画面时锁定了图像,推理线程就得等;二是相机SDK内部有个默认的重传机制,偶尔网络抖动时会阻塞;三是PLC通信用了同步读写,如果某个时刻PLC有不响应的情况,会占用线程。
解决办法很简单:给图像数据加一把独立的锁,UI显示时复制一份缩略图而不是直接引用原图;把PLC通信改为异步模式,同时增加一个看门狗,如果PLC通信超时3秒,就自动重连。优化后,单件耗时稳定在1.3-1.6秒,满足产线要求。
4. 实际产线调试中的踩坑记录
4.1 漏检与误检问题排查
系统上线第一天,就发现两个致命问题:漏检率飙升,有些明显的划痕居然没检出来;同时误检率也高得离谱,有些完全正常的零件被标成NG。
先说漏检。逐一排查后,发现跟训练时的图像分布有关。训练样本大多是均匀光照下拍的清晰图像,但产线上的实际场景复杂得多——零件位置有偏差、光线角度有变化、甚至相机镜头上有点灰。YOLO模型的鲁棒性没有想象中好,之前测试时的85%准确率在真实产线上可能直接掉到70%。
解决办法是现场采集1000张真实产线图像,人工标注后重新训练模型。这一步是必经之路,光靠实验室数据集上线的视觉系统,十个有九个会在现场翻车。重新训练的模型漏检率才压到了5%以内。
误检问题更隐蔽。后来发现是光源闪烁导致图像亮度周期性变化,YOLO对亮度极其敏感,稍有偏移就把高频纹理误判成划痕。解决方法是调整光源控制器的频率,把频闪模式改为常亮模式,同时加了一个自动曝光校准策略——每隔一段时间用一张标准白板校正相机的曝光时间,保证亮度一致。
4.2 高延迟问题的根源分析
排查延迟时发现一个很有意思的问题:生产Electron架构的软件用久了会延迟高,但我们的C#程序也会偶发高延迟,原因出在垃圾回收上。推理过程需要申请大量内存来存放图像数据和结果,频繁的GC会让推理线程卡顿。
解决方案有三个:一是减少内存分配,比如图像数据用byte[]复用,而不是每次采集都新建对象;二是使用GC.TryStartNoGCRegion,在关键推理期间临时禁GC,让推理线程不被中断;三是把推理线程的优先级调到最高,虽然这样做UI在推理期间会有一点卡顿,但产线场景UI本来就不需要那么流畅,可以接受。
实测这三个方案组合使用后,推理线程的最大停顿时间从原先的300ms降到了50ms以内。如果不想这么复杂,一个更简单的方案是直接加一块便宜的GPU卡,推理主要扔到GPU,CPU几乎不参与,GC影响就小多了。
4.3 相机标定与光照环境的影响
还有个大坑是相机安装角度导致的坐标偏差。我们在实验室里用的相机是正对着零件的俯视角度,产线上由于结构限制,相机只能倾斜安装,结果就是同一个缺陷在图像里的位置和实际位置存在偏移。检测框倒是能框住,但如果需要精确的机械手定位,这个偏移就是大麻烦。
用棋盘格标定板做了一次完整的相机标定,得到内参和畸变系数,然后校正图像。YOLO推理在校正后的图像上运行。如果不需要机械手定位,只是做OK/NG判定,其实可以不标定。但如果有定位需求,这一步必须做,否则就是反复试错。
光照这块,产线车间的环境光影响非常大。窗户边的产线和封闭车间的产线,亮度差好几倍。我们最终用的是条形光源+漫射板的组合,把入射光打散,极大减少了零件表面反光引起的误检。光源的角度要反复试,最佳位置是让反射光不直接进入相机镜头,否则图像里会出现大面积高光区域,YOLO完全找不到特征。
5. 常见问题速查表与独家避坑技巧
5.1 问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 相机SDK扫描不到设备 | 网段不一致/IP冲突 | 检查工控机网卡和相机IP | 统一网段,关闭巨型帧 |
| 推理速度慢 | CPU过热降频/模型过大 | 监控CPU温度与负载 | 升级硬件或采用OpenVINO |
| 偶尔漏检 | 模型鲁棒性不足 | 分析漏检样本特征 | 重新采集现场数据训练 |
| 检测结果有延迟 | GC阻塞/线程争用 | 观察推理线程停顿 | 内存复用、调整线程优先级 |
| PLC通信超时 | 寄存器映射错误/网线松动 | 用Modbus Poll验证 | 增加看门狗自动重连 |
| 误检率偏高 | 光源波动/反光 | 查看图像亮度分布 | 改进光源角度,稳定曝光 |
5.2 给后来者的避坑清单
第一,别拿Demo直接上线。Demo和产线的差距是巨大的,产线的光照、抖动、粉尘、网络波动,每一个都会让准确率跳水。一定要先花时间在真实产线上采集数据,重新训练或微调模型。
第二,通信协议一定要提前确认。PLC工程师给的寄存器表和上位机开发者理解的往往有偏差,最早拿到地址表就逐位核对,实测通信没问题再进行下一步。
第三,日志系统必须做好。产线出问题的时候,没有日志就等于抓瞎。我习惯在每个关键步骤都写日志:拍照时间、图像保存路径、推理结果、PLC写入状态。这样排查问题的时候,只需要打开日志文件,就能还原整个流程。
第四,UI别搞花哨。产线上操作工大多是初学者,界面越简单越好。我见到过有人把检测画面做得像游戏界面,各种图表动效,结果操作工根本看不懂。一个大画面+三个统计数字,就是最好的界面。
第五,对异常情况要有兜底逻辑。比如PLC发了拍照信号但相机采集超时,不能就这么卡死;推理结果出现异常值,不能直接当成NG或者OK,要有第三个状态“异常报警”。
系统稳定运行之后,我个人最大的体会是:这类项目的难点不在技术本身,而在工程化思维。YOLO模型训练、C#写上位机都是成熟技术,但把它们组合起来在恶劣环境里可靠运行,靠的是对细节的打磨和对异常的预判。如果你在做类似的系统,建议多留出一点时间做现场测试,宁可前期慢一点,也别让产线停线等你调代码。好用的系统,都是慢慢磨出来的。