从Demo到产线:手搓一套高可用工业视觉检测系统(C#+YOLO+PLC实战复盘)
写在前面:
很多算法工程师都有个误区:觉得模型mAP刷到99%项目就成功了。
直到去年在一家汽车零部件厂驻场三个月,被产线老师傅指着鼻子骂“你这电脑一卡,我这一整批货都得报废”,我才明白:在工业界,稳定性 > 准确性,实时性 > 先进性。这篇文章不聊复杂的Transformer架构,也不堆砌数学公式。我想复盘一个真实落地的项目:如何用C#把YOLOv8塞进工控机,通过Modbus TCP跟十年前的西门子S7-1200 PLC“对话”,最终实现毫秒级缺陷剔除。
如果你正卡在“算法能跑,但没法控制设备”这一步,或者想从纯算法转型做全栈落地,希望这篇踩坑实录能帮你省下两周的调试时间。
一、为什么是 C#?为什么不是 Python?
在项目选型会上,我也曾坚持用Python + Flask + WebSocket的方案。理由很充分:生态好、库多、开发快。
但现场电气主管只问了我三个问题:
- “工控机重启后,你的Python环境能不能自动起来?”
- “如果相机丢帧了,你的GIL锁会不会卡死整个线程?”
- “我们要跟现有的SCADA系统集成,你们谁写过C#的DLL调用?”
那一刻我意识到,工业现场的“通用语言”是C# (.NET)。
- 多线程优势:.NET的多线程模型在处理“采集、推理、通信、UI”四路并发时,比Python的GIL友好太多。
- 硬件亲和性:海康、巴斯勒的相机SDK,西门子、三菱的PLC库,原生支持都是.NET。
- 部署简单:打包成一个exe,丢进启动项,不用配conda环境,不用管pip依赖冲突。
所以,架构定调:C# WPF (UI+逻辑) + ONNX Runtime (推理) + NModbus (通信)。
二、核心难点:如何打破“算法”与“控制”的墙?
1. 时序同步的陷阱
最容易翻车的地方在于时序。
新手常做的逻辑是:相机拍照 -> YOLO推理(50ms) -> 发现缺陷 -> 发送Modbus信号 -> PLC动作
致命问题:传送带速度是0.5米/秒。当你花50ms推理完再发信号,产品已经向前跑了2.5厘米。如果剔除机构在相机后方30厘米处,这2.5厘米的误差可能导致气缸推空,或者把良品当次品打了。
工业级解法:
视觉只负责“判”,PLC负责“断”和“行”。
- 视觉上位机:检测到缺陷,立刻记录当前的编码器位置或时间戳,并瞬间给PLC发一个“有缺陷”的标志位(Flag)。
- PLC:收到Flag后,不立即动作,而是持续读取产线编码器的数值。当
当前编码器值 - 拍照时编码器值 == 预设偏移量时,才驱动电磁阀。
这样,无论上位机推理慢了10ms还是100ms,只要在产品到达剔除位之前把信号送到,PLC都能精准执行。把时间敏感的逻辑交给实时性更强的PLC,是工业控制的铁律。
2. 通信协议的“握手”设计
直接用Modbus写一个寄存器触发?太危险了。网络抖动一下,信号丢了怎么办?PLC没读到怎么办?
我们设计了一套**“三态握手”机制**(寄存器地址规划见文末附录):
- Request (上位机写):置位
Trigger_Flag = 1,同时写入Defect_Type。 - Acknowledge (PLC读):PLC扫描到
Trigger_Flag为1,将其内部状态机置为“待处理”,并主动将Trigger_Flag清零(告诉上位机:我收到了,你可以发下一个了)。 - Busy Check (上位机读):上位机在发新信号前,先读PLC的
System_Status寄存器。如果PLC处于“忙”或“故障”状态,暂停发送,防止指令堆积。
这套逻辑虽然多写了十几行代码,但在现场连续跑72小时压力测试时,一次信号丢失都没发生。
三、实战代码:剥离框架看本质
为了让大家能直接复用,我剔除了项目中繁琐的UI绑定,只保留最核心的推理引擎和通信服务。
1. 零拷贝的YOLO推理封装
很多人用OpenCvSharp处理完图片,转成byte[]再喂给ONNX,这在高频检测中是性能杀手。
我们采用内存指针直传,避免GC(垃圾回收)造成的卡顿。
// 核心片段:YoloEngine.cs
public class YoloEngine : IDisposable
{
private readonly InferenceSession _session;
private readonly float[] _inputBuffer;
private readonly GCHandle _bufferHandle; // 关键:锁定内存,防止GC移动
public YoloEngine(string modelPath)
{
var options = new SessionOptions();
options.InterOpNumThreads = 1;
options.IntraOpNumThreads = Environment.ProcessorCount;
// 如果有显卡,这里追加 CUDA Provider
// options.AppendExecutionProvider_CUDA(0);
_session = new InferenceSession(modelPath, options);
// 预分配640x640x3的缓冲区,全程复用
_inputBuffer = new float[3 * 640 * 640];
_bufferHandle = GCHandle.Alloc(_inputBuffer, GCHandleType.Pinned);
}
public List<Detection> Detect(Mat frame)
{
// 1. 预处理 (Resize + Normalize + HWC2CHW) 直接写入 _inputBuffer
Preprocess(frame, _inputBuffer);
// 2. 创建Tensor,直接使用 pinned 内存地址,无拷贝
var inputShape = new long[] { 1, 3, 640, 640 };
using var inputOrtValue = OrtValue.CreateTensorValueFromMemory(
_bufferHandle.AddrOfPinnedObject(),
inputShape,
TensorElementType.Float
);
// 3. 推理
var inputs = new NamedOnnxValue[] { NamedOnnxValue.CreateFromTensor("images", inputOrtValue) };
using var results = _session.Run(inputs);
// 4. 后处理 (NMS...)
return Postprocess(results);
}
public void Dispose()
{
_bufferHandle.Free(); // 必须释放
_session?.Dispose();
}
}
经验之谈:在产线全速运行时,GC的一次Full Collection可能导致界面卡顿200ms,对于高速产线这就是致命的。预分配内存+Pinned Handle是必选项。
2. 健壮的Modbus通信服务
不要相信网络永远稳定。我们需要一个带心跳检测和自动重连的后台服务。
// 核心片段:PlcService.cs
public class PlcService
{
private IModbusMaster _master;
private CancellationTokenSource _cts;
private bool _isConnected = false;
// 启动心跳守护线程
public void Start(string ip)
{
_cts = new CancellationTokenSource();
Task.Run(() => HeartbeatLoop(ip), _cts.Token);
}
private async Task HeartbeatLoop(string ip)
{
int heartbeatVal = 0;
while (!_cts.IsCancellationRequested)
{
try
{
if (!_isConnected) await ConnectAsync(ip);
// 每秒写入心跳寄存器,PLC侧监控这个值,不变则报警
await _master.WriteSingleRegisterAsync(1, 40005, (ushort)heartbeatVal++);
// 顺便读取PLC状态,判断是否急停
var status = await _master.ReadHoldingRegistersAsync(1, 40001, 1);
HandlePlcStatus(status[0]);
}
catch (Exception ex)
{
Console.WriteLine($"[Warn] PLC通信中断: {ex.Message}");
_isConnected = false;
// 触发UI报警,甚至联动声光塔灯
OnPlcDisconnected?.Invoke();
}
await Task.Delay(1000);
}
}
// 发送缺陷信号的原子操作
public async Task SendDefectSignal(int type)
{
if (!_isConnected) return;
// 一次性写入类型和触发标志,减少TCP交互次数
ushort[] data = { (ushort)type, 1 };
await _master.WriteMultipleRegistersAsync(1, 40003, data);
}
}
四、那些只有现场才知道的“坑”
1. 光照是玄学,也是科学
在实验室调好的模型,到了车间早上能跑,下午就废。因为车间顶棚的天窗会随着太阳角度变化,导致背景亮度剧烈波动。
解决方案:
- 硬件上:必须上光源控制器,用频闪光源配合相机硬触发,彻底屏蔽环境光。
- 算法上:训练集必须包含不同时间段、不同光照条件下的图片。如果条件允许,加入简单的传统图像处理(如直方图均衡化)作为预处理前置步骤。
2. “误杀”比“漏杀”更可怕?
理论上漏杀(Bad Pass)是质量事故,误杀(False Reject)是成本浪费。
但在实际博弈中,如果误杀率超过3%,产线工人会因为频繁停机复检而直接关掉检测系统。
策略:
- 设置双阈值。置信度 > 0.8 直接剔除;0.5 ~ 0.8 之间的,标记为“可疑”,推送到人工复检台,不直接停机。
- 引入时间滤波。如果一个缺陷只在单帧出现,相邻帧没有,大概率是噪点或反光,忽略它。只有连续2帧以上检测到,才确认为真缺陷。
3. 日志就是黑匣子
系统出问题时,电气说是软件问题,软件说是相机问题,相机说是光线问题。
一定要做详尽的日志:
- 记录每一张图的拍摄时间、推理耗时、结果。
- 记录每一次Modbus收发的具体报文。
- 关键点:当检测到缺陷时,自动保存原图和标注图到本地磁盘(按日期/班次分类)。这是后续优化模型和扯皮的最有力证据。
五、结语:从“做题家”到“工程师”
在学校里,我们追求模型的SOTA(State of the Art),追求mAP提升0.1个点。
在工厂里,客户只关心:能不能24小时不宕机?坏了能不能一键重启?误报会不会影响产量?
这套 C#+YOLO+PLC 的方案,虽然没有用到最新的Vision Transformer,也没有复杂的分布式架构,但它胜在简单、可控、易维护。它打通了AI算法与物理世界的“最后一公里”,让代码真正变成了生产力。
如果你也想投身工业AI,建议先从理解业务开始,再去钻研算法。毕竟,能解决实际问题的代码,才是好代码。
附录:寄存器映射表(参考)
| 地址 | 名称 | 读写 | 说明 |
|---|---|---|---|
| 40001 | System_Status | R/W | Bit0:运行, Bit1:故障, Bit2:忙 |
| 40002 | Control_Cmd | R/W | Bit0:复位, Bit1:启动, Bit2:急停 |
| 40003 | Defect_Type | R/W | 缺陷类型代码 (1:划痕, 2:裂纹…) |
| 40004 | Trigger_Flag | R/W | 1:触发剔除 (PLC读后清零) |
| 40005 | Heartbeat | R/W | 心跳计数,每秒+1 |
(注:具体地址需根据现场PLC型号调整,西门子S7-1200需注意V区偏移)
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)