1. 项目整体思路:为什么把YOLO放到边缘端跑
1.1 场景痛点:传统上位机方案卡在哪
去年年中接到一条产线的视觉检测改造需求,我当时最开始的想法是沿用过去的套路:工业相机拍照,图像传到工控机的上位机软件里,上位机调用一个算法模块做检测,再把结果通过IO卡丢给PLC。这套方案听着顺理成章,但实际跑起来有很现实的问题。
产线上相机是2000万像素的全局曝光相机,一张图算上ROI裁剪也有差不多20MB数据量,千兆网传上来大概需要160毫秒以上。本来相机的拍摄节拍是2秒一张,加个传输延迟问题不大,但我们又要求图像上传后还要做预处理、推理、结果判定、数据打包回传PLC,整个链路跑完,单张图的检测周期直接逼近1.8秒。这只是单相机的情况,产线是双工位交替拍照,上位机要同时处理两路图像,CPU占用动不动飙到85%以上。更尴尬的是,检测算法如果后续要升级,换模型就得重新发布整套上位机程序,产线停线陪着改代码,客户那边很难接受。
后来我们换了个思路,把检测这块从上位机里彻底剥离出来,下沉到一台边缘计算设备上,上位机只负责业务逻辑、流程控制和数据展示。这就是这个项目最终选定的架构——边缘端负责图像采集、预处理和YOLO推理,C#上位机负责工位调度、结果接收和与PLC的握手通信。
1.2 架构设计:边缘端加上位机的分工逻辑
整套系统最终的拓扑是这样:
- 相机直接接到边缘计算盒子(就是一台带独立GPU的迷你工控机),通过相机厂家SDK取流,本地完成ROI裁剪、畸变校正、缩放、归一化这些预处理。
- 边缘端跑YOLO推理,产出检测框、类别、置信度,然后把结构化结果通过gRPC推给上位机,不再传整张图片。
- C#上位机部署在普通工控机上,负责界面展示、工位逻辑、与PLC的Modbus TCP通信、结果判定和数据库落盘。
- 原始图像只在边缘端本地保存一份,方便后续追溯和误检分析,不占上位机带宽。
这个分工的核心逻辑是:尽量让图像数据在本地闭环,跨设备只传结果,不传原始图。一张20MB的原始图,经过检测之后压缩成几百个字节的JSON结果,整个传输延迟从几百毫秒降到几毫秒,上位机的负载也大幅下降。
1.3 技术选型复盘:C#凭什么接这活
有人会问,你边缘端都用YOLO了,干嘛不直接用Python一条龙写完,非要拉上C#?
说实话,推理端用Python写确实很顺畅,但在工业上位机这个场景里,Python不是最优答案。工业上位机要对PLC、要对数据库、要对MES系统,还要面对客户现场五花八门的Windows环境和老旧的.NET Framework版本——在这些地方,C#才是真正的舒适区。而且C#现在跨平台做得很好,.NET 6以上的版本在Linux边缘设备上跑gRPC服务端完全没问题。边缘端和上位机可以用同一种语言同时开发,代码风格统一,出了问题排查链路过短,一个人能顶两个端。
但这个项目里,边缘端还是保留了部分Python组件——主要是数据标注和训练脚本。实际部署的推理服务是C#写的,模型训练和调参用Python,中间通过ONNX模型文件桥接。
2. 模型训练与边缘端部署准备
2.1 数据来源与标注规范
这个项目的检测对象是铸造件表面的气孔、砂眼和裂纹,都属于典型的Small Object Detection,缺陷在2000万像素原图里往往只占几十到几百个像素。我们总共收集了大概12000张真实产线的缺陷图像,其中缺陷样本大概有8700张,正常样本3300张。
标注这块走了不少弯路。最初用通用的标注工具画矩形框,但小目标缺陷的框稍微偏一点,IoU差异就很明显,直接拉低mAP。后来我们把标注规范细化了:气孔要求框贴合缺陷最小外接矩形,砂眼要求框中心对准缺陷重心,裂纹这种细长目标允许用多边形标注后再转旋转矩形,训练时统一转成外接正矩形。这里有个小技巧——训练阶段不要直接用原始尺寸的标注框,按网络下采样倍数对齐一下,比如网络是640x640输入且下采样32倍,标注框的坐标最好取整到32的倍数附近,能减少标签与特征图对齐的误差。
数据增强方面,我没有一上来就上Mosaic、MixUp这种重增强——检测对象是小缺陷,过度裁剪和拼接会导致背景上下文丢失,模型容易把正常纹理误检为缺陷。最终采用的增强组合是:轻微旋转(正负15度以内)、随机亮度和对比度扰动(模拟不同光照)、随机裁剪后缩放(保持缺陷相对尺寸不变)以及少量高斯噪声。
2.2 训练配置与精度调优
我们边缘端的GPU是入门级的,显存只有6GB,所以模型不能选太大的。YOLO系列的选型对比过v8n和v8s,还有YOLO11n,最后定下来用v8n做底子,原因是它的参数量只有3.2M,640x640输入下推理延迟可以压到20毫秒以内(TensorRT FP16),精度我们通过后续的针对性优化补了回来。
训练时输入分辨率没有直接拉到640,而是先按1280跑了一个大分辨率模型当老师,再用教师模型对640输入的学生模型做蒸馏。此外还加了一个很有效的小技巧:把缺陷类别做成“二阶段识别”,也就是第一遍先跑一个粗糙的YOLO找候选区域,第二遍在候选区域放大后跑细分类。这个在推理阶段会稍麻烦一点,我们后面用C#实现时做了两级推理管线的支持。
最终在验证集上的指标是:
- mAP50:0.957
- mAP50-95:0.762(小目标能做到这个数已经很能打了)
- 气孔类别的召回率0.961,裂纹类别因为形状细长,误检率偏高,后续专门做了针对性优化。
2.3 模型导出与边缘端适配
这一步是整个项目里最容易翻车的地方。PyTorch训练的模型导出到ONNX,再转TensorRT,中间只要有一个算子不兼容,整个流程就要卡住。
我们用到的关键导出命令大致是:
import torch model.model.eval() dummy_input = torch.randn(1, 3, 640, 640) torch.onnx.export( model.model, dummy_input, "best_fp32.onnx", opset_version=17, input_names=["images"], output_names=["output0"], dynamic_axes={"images": {0: "batch"}} )导出时有几个关键点:opset_version不能太低,避免新算子不支持;dynamic_axes尽量只保留batch维度,宽高固定成640,这样TensorRT构建engine时能拿到更精确的优化信息。
ONNX转TensorRT用的是trtexec工具,FP16精度构建:
/opt/tensorrt/bin/trtexec \ --onnx=best_fp32.onnx \ --saveEngine=best_fp16.engine \ --fp16 \ --minShapes=images:1x3x640x640 \ --optShapes=images:1x3x640x640 \ --maxShapes=images:4x3x640x640构建engine过程中我发现一个坑:v8n模型里有些上采样算子会触发TensorRT的某次崩溃,排查到原因是插件版本不匹配,后来换了TensorRT 8.6的容器重新构建才通过。这块建议直接用NVIDIA官方NGC容器来做转换,省掉很多环境依赖问题。
3. C#边缘端推理工程化
3.1 推理方案选型:我用过的三种方案
从一开始想用的方案,到最终落地,C#这边的推理路线换过三轮。
第一轮直接用ONNX Runtime的C# API,简单省心,模型加载就能跑,CPU和GPU都支持。问题是延迟偏高,640x640输入在6GB的入门级GPU上,单次推理大约35毫秒,配合预处理和后处理,整体在50毫秒上下。做单路够用,双路并发就有点紧。
第二轮换成OpenVINO,Intel的推理套件,但我们的GPU是NVIDIA的,OpenVINO跑GPU得走插件模式,性能并不理想,CPU模式又明显不如直接用ONNX Runtime。这轮很快就放弃了。
第三轮就是最终的方案:TorchSharp加载PyTorch导出的TorchScript模型,还是在GPU上跑CUDA——这个方案性能很不错,但部署依赖重。后来因为要集成到客户的边缘盒子上,环境不可控,又回头选型,发现NVIDIA官方其实一直有人在维护一个叫Yolov8.Onnx.Runtime的封装模式,但说实话,那套方案在输入输出处理上还是太绕。
最后狠狠心,把ONNX Runtime和TensorRT通过P/Invoke封装成了统一的C#推理接口。模型推理走TensorRT,前处理、后处理用C#手写,这一套组合拳打下来,单张图端到端延迟压到了22毫秒。
3.2 ONNX Runtime集成实战
其实对于大多数场景,ONNX Runtime已经够用,我在这里给出最终项目里保留下来的一个通用封装(作为备用推理管线)。
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class YoloOnnxInference : IDisposable { private readonly InferenceSession _session; private readonly int _inputSize = 640; public YoloOnnxInference(string modelPath) { var options = new SessionOptions { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, IntraOpNumThreads = 4 }; _session = new InferenceSession(modelPath, options); } public List<YoloDetection> Detect(Mat image) { var resized = Cv2.Resize(image, new Size(_inputSize, _inputSize)); var input = Preprocess(resized); // BGR -> RGB, 归一化, HWC -> CHW var inputTensor = new DenseTensor<float>(input, new[] { 1, 3, _inputSize, _inputSize }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", inputTensor) }; using var results = _session.Run(inputs); var output = results.First().AsTensor<float>(); return Postprocess(output); } }这里面需要特别注意的是输入Tensor的维度顺序和数据类型。OpenCV读进来的是HWC的BGR图像,要转成CHW的RGB,数据类型必须是float,且归一化到0到1之间。很多新手在这里踩坑,出来的检测框全乱套,其实就差两步转换。
后处理核心就是解析输出Tensor,YOLOv8的输出是[1, 84, 8400],84 = 4个框坐标 + 80个类别概率(COCO),但不知道你的模型是不是COCO类。我们训练的是自定义缺陷类别,所以最后一维是类别数 + 4。这里要写一个通用的维度解析逻辑,直接用形状信息推断:
var shape = output.Shape; // [1, 84, 8400] int numClasses = shape[1] - 4; int numCandidates = shape[2];然后逐列做置信度筛选和NMS。
3.3 性能实测数据与硬件适配
边缘端最终选的是某品牌迷你工控机,CPU是八核低功耗版,GPU是6GB显存的入门级专业卡,功耗墙限制在45W。整机大概一本书大小,DIN导轨安装,适合塞进电气柜。
实际跑出来的数据大致是:
| 环节 | 耗时 |
|---|---|
| 相机SDK取流(1200万像素) | 58毫秒 |
| ROI裁剪+缩放+归一化 | 16毫秒 |
| TensorRT FP16推理(640x640) | 14毫秒 |
| 后处理(含NMS) | 6毫秒 |
| gRPC结果传输 | 2毫秒 |
| 端到端总计 | 约96毫秒 |
这个延迟意味着单张图从快门触发到结果回传PLC,可以控制在100毫秒以内,产线节拍2秒一拍的情况下,边缘端还剩余大量的算力余量。我们甚至用剩下的算力额外跑了一个简单的异常监控模型,专门检测传送带上的卡料——等于一个边缘盒子干了两个检测活。
4. 上位机与边缘端通信集成
4.1 通信协议选型:为什么选gRPC
上位机要跟边缘端通信,方案其实有几个:HTTP + JSON,简单粗暴,但实时性一般;MQTT,适合物联网分布式,但在这条产线的局域网场景下有点重;TCP + 自定义协议,性能最好,但每次改协议都要同步改两端的解析代码;最终选了gRPC,理由有几个:
- gRPC基于HTTP/2,客户端和服务端之间可以维持长连接,不用每次请求都重新握手,延迟低,带宽占用小。
- 接口定义用Protocol Buffers,改了消息结构能自动生成两端的强类型代码,不会出现两边字段名对不上这种低级问题。
- 自带流式传输,后续如果要加视频流推流,可以直接在一个连接上搞定,不用另起一路协议。
这个项目里我们定义了三个主要的gRPC服务:
service DefectInspection { rpc DetectImage(DetectRequest) returns (DetectReply); rpc StreamDetectResult(stream DetectRequest) returns (stream DetectReply); rpc GetHealthStatus(Empty) returns (HealthStatus); } message DetectRequest { string camera_id = 1; string image_path = 2; int64 timestamp = 3; } message DetectReply { string request_id = 1; bool has_defect = 2; repeated DefectBox defects = 3; float inference_ms = 4; int64 timestamp = 5; }image_path就是边缘端本地保存的原始图路径,上位机如果需要追溯,直接拿这个路径去边缘端拉图就行;正常情况下只传检测结果。
4.2 数据帧设计与心跳机制
虽然gRPC已经把消息帧封装好了,但工业上位机跟PLC握手的数据帧反而要自己设计。
上位机从PLC拿到的是触发信号,也就是一个bool位的上升沿,PLC通过Modbus寄存器传过来。上位机把触发事件转成检测请求发到边缘端,边缘端返回结果,上位机再写回PLC的寄存器。整个闭环的关键参数是超时控制,我们最初的版本里设了500毫秒超时,但是PLC扫描周期如果是50毫秒,500毫秒有可能在极端情况下漏掉一个上升沿。最后调成PLC每100毫秒同步一次触发状态,上位机做边沿检测后立即发请求,超时设为2秒——2秒内没返回结果就判定超时,PLC那边走超时物件剔除流程。
另外,边缘端和上位机之间维持一个心跳机制:边缘端每5秒上报一次运行状态,包括GPU利用率、显存占用、模型推理平均耗时、最近的错误码。上位机界面上做一个小图标,绿色正常、黄色性能劣化、红色离线。
4.3 上位机实时显示与结果落库
C#上位机这部分用WinForms实现的,客户现场对界面要求不高,重要的是信息密度和操作效率。主界面分成三块:左侧是实时检测结果列表,中间是当前检测图像缩略图和缺陷框叠加,底部是产线统计面板。
图像显示这里有个性能关键点——不要把边缘端的结果JSON转成图像再传过来,那样等于把原始的带宽压力又请回来了。正确做法是:上位机显示缩略图,直接用边缘端保存的原始图路径,通过gRPC单独开一个缩略图下载接口,用低分辨率图来显示;如果客户要点开大图看细节,再走另一个接口拉原始图。
结果落库用的是SQLite,按天分表,一张表存检测请求ID、相机ID、时间戳、缺陷数量、缺陷类别和坐标、检测耗时;另一张表存原始图像路径和缩略图路径。数据库的写入频率大概是每2秒一条,SQLite完全扛得住。实测连续跑72小时,数据库文件大约膨胀到1.2GB,我们每天凌晨做一次自动归档,把前一天的数据导出成CSV并压缩,再把原表清空。
5. 落地过程中的问题与排查实录
5.1 推理卡顿与帧率不稳
项目联调阶段遇到过一个很诡异的问题:单独压测边缘端推理服务的时候性能很正常,但和上位机连起来跑整个链路,推理延迟从正常的14毫秒飙升到80多毫秒,而且是一阵一阵的。
排查过程比较曲折。先怀疑是网络问题,抓包看gRPC请求和响应的时间差,发现瓶颈确实发生在边缘端内部。又怀疑是GPU被某个进程占了,一看并没有。最后在边缘端部署了性能计数器,发现CPU使用率有规律性的尖峰,而且和推理延迟的尖峰时间是吻合的——问题出现在C#侧的后处理代码里。
后处理中用到的NMS实现,按照类别循环,每个类别内部做IoU排序,这个逻辑在类别少的时候没问题。但我们的缺陷类别有6个,每张图的候选框有8400个,6个类别的排序操作叠加起来,最坏情况下要处理数万次IoU比较。我最初实现时直接用了List的Sort,每帧都触发一次内存分配,GC压力飙升,导致整个推理管线周期性抖动。
解决办法是:把NMS改成预分配内存的朴素实现,避免每帧分配新列表。同时把候选框的预筛选阈值从0.25提高到0.5,在进入NMS之前就已经筛掉了90%以上的低置信度候选框。优化之后,推理延迟在双路并发下稳定在20毫秒左右。
5.2 误检漏检的实际调优记录
产线试运行阶段最大的麻烦是误检——正常铸件表面有一些机加工纹路,在某些光照角度下很容易被模型当成裂纹。
我们尝试了三个方向的优化。第一个方向是数据层面,把正常纹理的图片加入训练集,同时用硬负挖掘(Hard Negative Mining)的策略,把之前模型误检的样图人工标注后重新加入训练,大约补了1800张负样本。第二个方向是后处理层面,对置信度阈值做类别差异化,裂纹类的阈值从0.25提升到0.55,虽然召回率掉了一点,但误检率降低了七成。第三个方向是业务逻辑层面,上位机加了一个“复检机制”——单张图被判为有缺陷时,边缘端会自动对缺陷区域做一次局部放大推理,只有两次结果一致的才判定为真阳性。
这三个方向合在一起,现场最终达到的指标是:漏检率小于0.2%,误检率小于1.5%,客户验收通过。
5.3 工业现场断网与重启恢复
产线环境最怕的是设备掉线后恢复不了,得人工过去重启。我们在整个系统里做了三层防护。
第一层,边缘端和上位机之间的gRPC连接断了之后,客户端会自动做指数退避重连,初始1秒,最大30秒。重连成功后,边缘端会把断线期间积压的检测结果按照时间戳顺序补发给上位机,保证数据不丢。
第二层,上位机与PLC通信的Modbus TCP如果断开,上位机会持续重试,并且把通信状态写入本地日志和界面状态栏;PLC那边也会检测到通信超时,自动把产线进入安全暂停模式,排出半成品后等待上位机恢复。
第三层,边缘端整机断电重启后,系统服务采用systemd托管,开机自启,并且通过一个持久化的本地队列保存未上报的结果,服务起来之后自动把队列里的数据推送给上位机。这一步很重要,否则边缘端重启的几十秒时间里产线上拍的照片就全丢了。
这三层机制目前稳定运行了几个月,断网重连测试做过十几次,最久一次是边缘端断电5分钟后再上电,恢复后1分钟内全部数据补发完成,PLC那边自动复位。
6. 个人复盘与后续扩展方向
项目上线之后我自己也复盘过几轮,有几点体会比较深。
第一大坑是启动时贪多求全,一开始把工位管理、设备管理、报表系统、用户权限全塞进上位机里,结果开发周期一拖再拖。后来狠心砍掉了将近一半的非核心需求,用“先跑通最小闭环,再逐步叠加功能”的方式推进,最终才在预算周期内交付。这个思路在工业项目里尤其重要——产线不会等你把界面做到完美再开始干活。
第二点是从第一天起就该有日志和性能监控,而不是出了问题再补。我们边缘端服务的prometheus指标是项目进行到一半才加上去的,导致前期几次性能抖动全靠人工猜,浪费时间。如果一开始就把推理耗时、检测率、误检率、GPU利用率全部可视化,排查问题的效率至少翻一倍。
第三点是模型迭代要跟业务剥离。现在边缘端加载模型支持热切换,上位机下发新模型文件后,边缘端用新的模型文件启动一个新的推理实例,推理完成后自动切换流量,不需要重启服务。这让后续模型优化不会影响产线生产,把“模型升级”变成了一个运营动作,而不是改造工程。
当前这套系统还有几个想继续扩展的方向。一是把几个边缘节点的检测结果汇聚到一台中心服务器上,做全局产线质量趋势分析,用统计方法提前预警模具磨损或者工艺参数漂移。二是把上位机的WinForms界面逐步迁移到跨平台框架上,这样客户将来要改成Linux工控机或者加一套Web端远程监控也更方便。三是尝试在边缘端跑更多的轻量模型,不只是缺陷检测——比如OCR识别批次号、零件姿态估计、装配步骤校验,这些都是工业现场常见的物料管理场景。
做工业视觉项目这几年,最大的感受是:技术本身往往不是瓶颈,如何把模型、算力、上位机、PLC和现场业务逻辑拧成一个可靠的系统,才是真正考功夫的地方。希望这篇分享能给你一些参考,少走几步弯路。