两年前我从一个Demo级视觉项目,一路改到能24小时挂机的产线系统,用的主栈就是C# + YOLO + PLC。项目背景不复杂:某条组装线需要一个检测工位,把尺寸异常、表面有明显缺陷的工件拦下来。最初团队用一台上位机跑Python + YOLOv8,检测效果确实不错,但离“产线可用”还差得远——相机触发不稳定、结果无法可靠传给PLC、跑一天就内存爆掉。这篇文章把从Demo到产线的全过程拆开讲清楚:硬件怎么选、C#上位机怎么组织、YOLO模型怎么接入、PLC怎么握手,以及高可用架构是怎么一点点补出来的。适合正在做机器视觉部署、尤其是想把自己训练的YOLO模型端到端落地的工程师参考。
1. 项目概述与整体方案选型
1.1 为什么偏偏是 C# + YOLO + PLC
先说结论:这个组合在工业视觉里非常常见,不是因为它最酷,而是因为它能把“算法效果”和“产线控制”之间的缝隙补得最稳。
YOLO负责的是检测本身。它把图像里的缺陷框出来,给坐标、类别、置信度。模型效果好,不等于系统能用。工业现场要的是:相机被PLC信号触发、拍图、推理、结果在几十毫秒内写回PLC、PLC把不良品吹掉或推掉,同时上位机能记录数据、报警、防止漏检。这一整条链路里,Python只适合做算法验证,不适合做控制枢纽。
C#在这里承担三个角色。第一,上位机软件,负责相机采集、图像显示、参数配置、数据记录。第二,模型宿主,通过ONNX Runtime加载YOLO模型,把输出结果转成业务数据。第三,PLC通信中枢,通过Modbus TCP、MC协议或S7协议读写PLC寄存器,保证每次检测都有明确的握手反馈。
为什么不用纯Python跑完整套?我踩过坑。Python在Demo阶段很爽,但一到长时间运行就暴露问题:内存回收不可控、异常堆栈容易把进程搞挂、依赖环境在工控机上迁移成本高。用C# + ONNX Runtime之后,整个上位机可以打包成单个可执行文件,工控机上不需要装Python环境,这就减少了一大类部署问题。
1.2 所谓“高可用”,到底要可用到什么程度
这个系统上线前,甲方提的要求只有一句话:不能因为视觉系统导致产线停线超过10分钟。这句话翻译过来,就是要保证“单点故障可恢复、数据不丢、状态不混乱”。
拆开看需要做到几点。第一,7x24小时连续运行,内存不能缓慢增长,线程不能死锁,定时器不能漂移。第二,单张检测耗时必须稳定,不能因为某张图的推理时间波动而积压触发信号。第三,通信断了要自动重连,PLC那侧不能因为上位机重启就把产线卡死。第四,模型更新不能影响在线运行,不能在换模型时被迫停线。第五,每一件被测工件都要有检测记录,NG图片必须留存,否则出了问题没法追溯。
这些问题在Demo阶段完全看不出来。你拿笔记本连个USB相机跑几十张图,根本碰不到“跑三天后通信卡死”或者“夜班光源色温变化导致误检”这种鬼问题。所以高可用不是上线后补的,而是从架构第一天就要有意识。
2. 硬件选型与产线集成的底层逻辑
2.1 相机、镜头与光源:模型之外的第一道坑
很多做算法的人默认“YOLO模型好就行了”,但工业视觉项目里,图像质量决定模型上限。我们这工位检测的是长度约60mm、宽度约40mm的小型工件,需要识别表面划痕、凹坑、边缘破损几类缺陷。
相机选型有个简单计算。我用的是500万像素黑白GigE相机,分辨率2448x2048。如果视野覆盖80mm x 60mm,像素精度就是80/2448约等于0.033mm。最小缺陷尺寸大概0.5mm,折算下来有15个像素左右,YOLO完全能学出来。如果换成200万像素相机,像素精度约0.06mm,0.5mm也有8个像素,也不是不行,但边缘细节会差一些。工业检测里宁可像素冗余,不要卡着临界值选型。
镜头方面,选的是35mm定焦工业镜头,工作距离控制在300mm左右。这里要特别注意靶面尺寸配合:1/1.8"的相机配对应靶面的镜头,否则视野边缘会发虚。光源是我们最后悔没早点重视的部分。最初用普通环形光源,发现划痕在不同角度下反差完全不一样,模型在白天样本上表现还行,晚上就开始乱跳。后来换成低角度条形光源,通过漫射和阴影突出表面刻痕,配合高频光源控制器消除频闪,检测稳定性明显提升。
光源控制器看似不起眼,但产线电压波动会造成亮度波动,如果光源由普通开关电源供电,亮度波动会直接变成误检。建议在方案阶段就把光源控制器纳入配置,不要省这台设备。产线上的光照不是实验室恒定的,必须用可控光源把变量压下去。
2.2 PLC信号接口设计与握手协议
PLC与视觉系统之间,最怕的就是“信号丢一次,工件漏一件”。我们用的PLC原生支持以太网通信,一开始写了一个简单的TCP通信模块,后来发现必须做“脉冲握手”才能防止漏检。
具体做法是定义一组寄存器作为信号队列。D0是触发请求寄存器,由PLC写入,上升沿表示“有一个工件到了”。D1是检测结果寄存器,由上位机写入,0表示OK,1表示NG,2表示检测异常。D2是握手状态寄存器,上位机完成检测后置1,PLC处理完当前结果再清0。
正常流程是:上位机空闲时循环读取D0,发现D0从0变1,立即触发相机采集并开始推理;推理完成后写入D1结果,把D2置1,表示“结果已就绪”;PLC读到D2为1后,根据D1的值执行分拣动作,动作完成后清D0并清D2,允许上位机进入下一轮。这个握手机制保证了“一进一出”的严格对应,即使PLC扫描周期和上位机线程有偏差,也不会重复检或漏检。
硬件接线方面,除了网络信号,我还保留了一路硬IO输出,由上位机通过数字IO卡直接给PLC一个脉冲,作为心跳的物理备份。因为通信协议再怎么稳定,也怕网线被老鼠咬断。真正的高可用是在物理链路和逻辑链路之间做冗余,而不是赌某一条链路永远不出问题。
3. C#上位机的模块设计与YOLO推理落地
3.1 状态机:别让检测流程变成一团乱麻
上位机不是“采集一张、检测一张、写一条结果”这么简单。产线一旦跑起来,触发信号可能随时进来,PLC可能忽然重启,相机可能掉线,模型推理线程可能阻塞,如果不用状态机管住流程,代码很快就变成一团乱麻。
我们把每个工件的检测过程抽象成状态机:空闲、等待触发、采图、推理、结果输出、异常处理。空闲状态只做一件事:轮询PLC触发。触发到来后进入采图状态,此时相机采集线程把最新一帧图像放入队列,推理线程从队列取图,推理结束后进入结果输出状态,写PLC、存数据库、显示UI,然后回到空闲。
每个状态都有超时保护。采图超过500ms判为相机异常,触发一次自动重连;推理超过800ms判为模型或CPU异常,记录报警并回到空闲;结果输出如果PLC不返回ACK,则重试三次,仍失败就报警并人工干预。这套状态机让每一步都有明确的“下一跳”,不会因为某个异常分支让进程卡死。
现场还有个容易被忽略的点:端到端的检测延迟要可观测。我在状态机每个转换处都打时间戳,这样一旦某个环节变慢,能直接定位是采图、推理还是通信。没有这层埋点,出问题时只能靠猜。
3.2 ONNX Runtime集成YOLO的完整思路
模型部署上,我们最终把YOLOv8从PyTorch导出为ONNX格式,再用ONNX Runtime的C# API加载。这样做的最大好处是运行时环境干净,不依赖Python,也不依赖CUDA版本太多复杂的包。GPU推理用CUDA Execution Provider,CPU推理直接走CPU EP,切换起来非常方便。
导出ONNX时可以固定输入尺寸为640x640,批量大小设为1。整个推理流程包括预处理、推理、后处理三部分。预处理要做Letterbox,也就是把原始图像等比缩放到640x640,多余的边用灰色填充,同时记录缩放比例和填充偏移,后处理要把检测框还原回原图坐标。
ONNX输出通常是一个float数组,形状是[1, 4+类别数, 候选框数]。以常见COCO 80类为例,就是[1, 84, 8400]。但我们自定义检测只有几类缺陷,所以输出可能是[1, 4+3, 8400]。解析时要按通道维度遍历每个候选框,先找出置信度最高的类别,再过滤低分框,最后做NMS去重。
int candidates = ...; // 8400 int numClasses = ...; // 类别数 int stride = 4 + numClasses; for (int i = 0; i < candidates; i++) { float cx = output[0 * candidates + i]; float cy = output[1 * candidates + i]; float w = output[2 * candidates + i]; float h = output[3 * candidates + i]; float maxConf = 0f; int maxCls = -1; for (int c = 0; c < numClasses; c++) { float conf = output[(4 + c) * candidates + i]; if (conf > maxConf) { maxConf = conf; maxCls = c; } } if (maxConf < confThreshold) continue; // 这里需要将cx, cy, w, h从640坐标系还原到原始图像坐标系 // 再经过NMS过滤重叠框 }NMS我自己写了一个简化版:按置信度从高到低排序,依次保留当前框,剔除所有与该框IOU大于阈值的其他框。对于一个只检测几个缺陷的工位来说,候选框数量有限,纯C#跑NMS,耗时完全可以忽略。
部署下来,单张图在CPU上约120ms,GPU上约20ms。后来为了控制成本,工控机用了一块低功耗GPU,推理稳定在20到30ms,还要加上图像传输和PLC通信,整单检测周期控制在150ms以内,完全满足产线节拍。
3.3 与PLC的通信和异常恢复
PLC通信模块不能只是“发一条命令、收一条回复”那么简单。我们在C#里写了一个独立的后台线程,专门负责与PLC的以太网通信,主线程与通信线程通过并发队列交换数据,避免UI卡顿。
通信协议用第三方开源Modbus TCP库实现,配合PLC侧做寄存器读写。整个模块有几层保护:第一,TCP长连接断线后每隔1秒重连一次,带指数退避,避免PLC一重启就把上位机自己也拖死。第二,每次读写都加了超时,默认500ms,超过就报通信异常,但不阻塞检测主流程。第三,心跳机制:上位机每500ms向PLC写一个递增的心跳计数,PLC如果超过3秒没收到心跳,则认为上位机异常,自动控制产线急停或转人工操作。
PLC侧也要做保护。上位机启动时,会先读取PLC的“工位使能”位,如果为0,说明产线未准备好,此时上位机不执行拍照检测,只做状态显示。这避免了一个常见问题:上位机还在初始化,PLC就开始发触发信号,导致第一批工件没检测。
4. 模型训练到产线部署的实战细节
4.1 数据采集、标注与增强
YOLO模型在Demo阶段往往精度不错,但一到产线就翻车,最核心原因就是训练数据太“干净”。实验室采集的样本是在固定光源、固定角度、固定背景下拍的,产线上工件来料方向、氧化程度、灰尘附着都不同。
我们后来专门安排了两轮数据采集。第一轮从产线收集真实样本,包括合格品和各种缺陷形态,尽量覆盖白班、夜班、不同批次材料。第二轮做了一个“困难样本挖掘”:先用已训练的模型跑一遍产线实时画面,把置信度低但实际是缺陷的图挑出来人工复核,再补进训练集。这两轮让召回率提高了一个档次。
标注时要注意一个细节:缺陷边界不要标得太碎。比如一条划痕,如果标成几段独立小框,模型会去学“小框”而不是“划痕”。我们统一规范:同一物理缺陷用一个大框完整框住,框外边只留少量背景。类别名也尽量明确:划痕、凹坑、边缘缺损、异物。类别不要设太细,否则样本不够,模型容易混淆。
数据增强方面,用了亮度扰动、对比度扰动、噪声扰动、小角度旋转。有一点很多人忽略:增强幅度不能大到改变缺陷本质。划痕类缺陷高频信息重要,旋转超过30度后缺陷方向会失真,反而降低效果。我建议增强以光度类为主,几何类增强控制在10度以内,且不要水平翻转——如果缺陷在物体表面有方向性,翻转后语义可能就变了。
4.2 精度调优与量化部署
训练本身用ultralytics的YOLOv8就能跑。核心不是调参,而是建立一套“产线指标”而不是单纯看mAP。工业检测最怕漏检,所以召回率比精确率优先。如果100件不良品里漏掉1件,后果远大于把2件良品误判成不良。我们调阈值的基本原则是:保证召回率在99.5%以上,误检率控制在可接受范围。
置信度阈值对工业场景影响很大。模型刚训练完,默认阈值0.25看着还好,但产线上NG品被漏检几次后,我们把阈值降到了0.1,结果误检率升高了,但漏检率几乎降到0。后来通过增加难负样本,又把误检压了回去。这个过程说明:阈值一定要基于产线实际数据调,不能拍脑袋。
部署阶段尝试过FP16和INT8量化。FP16精度损失很小,在GPU上推理速度有明显提升。INT8损失略大,对细小划痕这种边缘类缺陷不够友好,最终量产选了FP16。量化后必须重新跑一遍验证集,而且要用产线新收集的数据来评估,不能只拿训练集测。
4.3 模型版本管理与热更新
产线不可能接受“换模型停机半小时”。我们设计了一套简单的热更新流程:系统启动时从一个配置文件读取当前模型路径,模型文件放在磁盘的版本目录下。需要升级时,先把新模型文件和对应的配置文件复制到目标目录,再触发上位机的“模型重载”事件。
进程内重载要注意文件占用问题。ONNX Runtime加载模型后,如果文件句柄被锁,替换文件会失败。解决办法是加载模型前把整个模型文件读入字节数组,再用字节数组创建会话,这样不会锁住磁盘文件。C#里可以用MemoryStream或者直接传byte[]给SessionOptions,具体做法要看ONNX Runtime的API版本。
为了保证安全,我们加了“影子模型”机制:新模型先以影子模式跑一段时间,结果只写入日志,不参与PLC控制。等确认结果与老模型一致性达标后,再手动切换到正式模式。这一招在工业现场特别好用,既避免了模型回退,又不用停线。
5. 高可用架构与故障自愈实践
5.1 看门狗与双机热备的取舍
高可用第一步是给主程序加看门狗。我们写了一个独立的小进程,每5秒检查一次主程序的窗口、线程和心跳文件,发现主程序无响应超过15秒就强制重启。看门狗本身由Windows计划任务设置开机自启,并设置为若看门狗退出则系统报警。
但看门狗只能保证“宕机后拉起”,不能保证“拉起期间产线不漏检”。如果主程序重启需要20秒,这期间PLC还在按节拍运行,就会有一批工件没检测。因此我们又做了双机热备:两台工控机,一主一备,同时连接PLC。PLC通过心跳判断谁是主机,正常情况下只有主机接收检测任务,备机处于待命状态。
热备切换最大的坑是状态同步。备机虽然待命,但内存里没有当前工件的触发上下文。一旦主机故障,备机接手的瞬间可能正好有工件经过,备机相机还没初始化完。我们的解决办法是:PLC检测到主机掉线后,自动把当前输送线暂停一拍,等待备机就绪,再继续走料。这牺牲了一点节拍,但保证了不会漏检。
如果你的产线没有机械暂停条件,双机热备就不是最优解。更现实的做法可能是:主机故障时立即声光报警,PLC控制这一段输送线停机,由人工处理积压工件。想清楚你的现场能不能接受“切换瞬间的短暂停顿”,再决定要不要上双机。热备不是万能药,设计不好反而引入更多故障点。
5.2 数据缓存、日志与可追溯性
高可用不只是“不宕机”,还要“宕机后能交代”。每件工件的检测记录我们都存SQLite,表结构包含唯一流水号、检测时间、OK/NG、缺陷坐标、所属批次、模型版本、PLC握手状态。SQLite轻量,本地写入快,不需要单独部署数据库服务。
网络通信难免中断,MES没有实时数据不能怪网络。我们的策略是先写本地库,再由一个后台线程定时尝试上传MES,上传成功后标记为已同步。这个“先落库、后上传”的模式,保证网络断了也能追溯。图片存储上,OK图片随机抽存,NG图片全部保存JPEG,保留90天。磁盘占用需要提前算:每天生产2万件,假设5%是NG,每张图200KB,每天占用约200MB,90天约18GB,一块普通固态盘就能搞定。
日志体系我强烈建议结构化。不要只写一行文本,而是JSON格式:时间、线程ID、状态机状态、PLC命令、耗时、异常信息。这样后面查问题时可以直接写脚本分析,或者丢进日志平台。我在现场就靠这套日志定位过“每2小时卡一次”的诡异问题,最后发现是某个网卡检测机制触发GC导致的。
5.3 性能调优:把单张检测耗时压进200ms
产线节拍要求单工位检测周期不超过200ms。一开始Demo环境只追求“能跑”,到了产线就要开始抠细节。
第一个重点是相机采集。GigE相机用软件触发还是硬件触发?我们最终选了硬件触发:光电传感器检测到工件到位,直接给相机一个信号,相机采集完成后通过回调通知上位机。这样上位机不会轮询浪费时间。图像传输使用千兆网,单张图传回内存大概30ms。若用USB相机,这个时间会不稳定,因为USB接口调度不是实时性设计。
第二个重点是图像队列。推理和采集是两个线程,采集线程不断向队列塞图,推理线程一张张取。如果推理偶尔慢了一下,队列会积压,此时不能无限积压,否则内存暴涨。我们给队列设置了最大长度5,如果队列满了,直接丢弃最旧的一张,同时记录一次“丢帧告警”。从产线节拍看,丢一帧可能意味着漏检,所以丢帧告警要能响到产线管理者那里。
第三个重点是内存分配。C#里最容易犯的错是每帧都new一个Bitmap,生成大量托管对象,GC一跑就把CPU拉高。我做了位图对象池,相机回调得到的图像数据写入池中的Bitmap,用完归还。推理结果对象也用复用策略,大幅减少了GC压力。实测连续跑一整天,内存曲线基本是一条直线,只有轻微波动。
6. 常见问题与排查技巧实录
6.1 Demo转产线的十大经典坑
第一个坑:模型在Demo上准,在产线上误检一堆。原因通常是光源不同、样本分布不同、背景不同。做法参考前面:去产线采真实数据,困难样本挖掘,加光源硬件控制。
第二个坑:触发信号丢帧。PLC给相机的信号脉冲宽度太短,相机还没反应过来就结束了。后来在PLC里把触发输出改为电平保持50ms以上,问题解决。
第三个坑:通信偶尔超时。TCP连接本身没问题,但中间有交换机,断网重连没有做好。给通信模块加了断线重连和心跳,基本根治。
第四个坑:工控机蓝屏。一度以为是软件问题,查到最后是电源供电不足。工业现场电压波动大,工控机电源最好用冗余电源或者带稳压功能的工业电源。
第五个坑:内存持续上涨。原因是程序里某个静态事件把相机回调引用一直挂着,导致Bitmap没释放。用内存分析工具跑了一晚上定位到,改成弱引用后解决。
第六个坑:模型更新后检测效果变差。没有保留旧模型基线,回退只能重新训练。后来所有模型都有版本号和对应测试报告,更新前要先对比。
第七个坑:PLC死机后恢复,寄存器里的触发标志还是1,上位机一启动就误触发。解决方法是上位机启动时先复位所有通信相关寄存器,并等PLC稳定2秒后再进入检测循环。
第八个坑:UI线程堵塞。UI界面显示实时图像时直接在采集线程刷新,导致帧率波动。改成采集线程只发通知,UI主动取图刷新。
第九个坑:系统时间漂移,导致检测记录和MES时间对不上。用NTP服务器定期同步工控机时间,并确保PLC时间也统一来源。
第十个坑:测试不充分就上夜班。我们第一天夜间试运行,凌晨3点光源控制器过热导致图像亮度漂移,误检率飙升。从此所有光源控制器都留出散热空间,并做了温度监测报警。
6.2 典型故障速查表与排查步骤
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 连续误检NG | 光源亮度漂移、镜头污染、模型阈值过低 | 检查光源控制器电流、清洁镜头、查看置信度分布 |
| 触发后无图像 | 相机掉线、触发信号未到达、网线松动 | 看相机状态、检查PLC输出信号、重启相机服务 |
| 检测结果不出 | 模型推理阻塞、CPU占用过高、NMS死循环 | 看推理耗时日志、内存曲线、崩溃日志 |
| PLC通信中断 | 网线故障、PLC程序卡死、上位机线程异常 | 检查心跳计数、重新初始化通信模块、Ping测试 |
| 偶发漏检 | 触发丢帧、PLC信号被覆盖、队列溢出 | 查看丢帧日志、信号时序、队列告警记录 |
排查步骤我有一套固定流程:先看心跳和状态机日志,确认问题发生在采集、推理还是通信;再拉最近10分钟的耗时曲线,看有没有异常峰值;然后查PLC寄存器映射,确认实际信号和上位机读到的是否一致;最后才动代码。不要一上来就改逻辑,那样只会把问题越改越乱。
7. 最后几条工程建议
这个项目做完之后,我最大的体会是:工业视觉项目的难度往往不在模型,而在系统边界。YOLO的训练可以靠开源工具快速完成,但从一张测试图到一条稳定产线,中间要跨过的是硬件同步、通信握手、异常恢复、数据追溯这四座山。如果你也要把Demo推向产线,建议不要急着优化模型,先把信号链跑通:PLC发一个触发,上位机能采到图,推理结果能写回PLC并且PLC能正确分拣,这是整个系统的地基。
还有一个小技巧:在产线旁边搭一个模拟工位,用PLC模拟信号做半实物仿真。所有软件逻辑先在模拟环境里跑三天,确认看门狗、断线重连、模型热更新都正常,再接入真实产线。这一步能省掉大量跟现场争夺时间的痛苦。工业现场的稳定性不是靠某一块代码写得完美,而是靠每一层都留有冗余和退路。希望这篇复盘能帮你少踩几个我踩过的坑。