AGV导航不求人!C#上位机+YOLO全系列无缝集成,30行代码搞定目标识别
先说结论:这套东西并没有想象中那么玄乎。两个月前我在某仓储项目里要给AGV加视觉避障和物料识别功能,当时手上只有一台工控机、一个普通USB工业相机、一套已经写好的C#调度上位机,以及一堆在网上翻到的YOLO权重文件。折腾好几天之后,最终用不到30行核心C#代码,把YOLOv8的目标识别结果接进了AGV的实时运动控制里。今天就把整个集成思路、代码骨架、踩坑细节全部摊开讲,给正在做AGV或者移动机器人上位机开发的朋友一份可以直接抄的作业。
1. AGV导航与视觉识别:为什么上位机必须掺和进来?
1.1 AGV导航的技术栈现状
AGV(自动导引车)的导航方案现在已经很成熟了,磁条导航、二维码导航、激光SLAM导航、视觉导航各有各的拥趸。但不管底层用哪种,最终都会有一个上位机来负责调度、状态监控、任务下发,以及和外围设备(电梯、门禁、货架、充电桩)通信。这个上位机八成是C#写的,尤其在工厂和物流场景里,基于.NET的WinForm或WPF程序占据半壁江山,原因无非是开发快、硬件接入方便、和PLC/MES的对接资料多。
单纯靠激光雷达和磁条,AGV只能解决“我在哪里、我去哪里”的问题,很难解决“我面前是什么、这个东西该不该避让”的问题。比如地面上出现一个临时堆放的纸箱,激光雷达扫描到的只是“障碍物”,它不知道那其实是一个工装料架,里面装的是待加工的精密零件——不能压过去,也不能急刹。这个“是什么”的认知能力,必须靠视觉。而视觉识别的结果要参与决策,就必须在上位机里和导航模块、运动控制模块做联动。这就是C#上位机集成YOLO这个需求最大的来源。
1.2 YOLO在AGV场景中到底解决什么问题
YOLO是一族基于深度学习的单阶段目标检测算法,目前全系列(从YOLOv5、YOLOv6、YOLOv7到YOLOv8、YOLOv9,包括最新的YOLOv10甚至YOLO11)在C#里都能比较方便地集成。它和传统图像处理(比如轮廓检测、颜色分割、模板匹配)最大的不同是泛化能力强:同一个模型,在训练时给足各类样本,之后遇到没见过的角度、光线、遮挡,大概率也能框出来。
AGV场景里,YOLO主要承担几类任务:人形检测(安全避让)、料车/托盘检测(对接定位)、货架立柱检测(叉举位姿修正)、地面标识物检测(辅助导航)、以及危险区域闯入检测。每一类都可以用同一个YOLO模型的不同类别输出来解决,只要训练数据覆盖到位。相比在Jetson上用Python跑推理,再把结果通过ROS或者其他通信中间件转发给C#上位机,C#直接调用模型推理有一个直观的优势:少一跳网络转发,代码里直接拿检测结果做决策,延迟更低,调试也更方便。
2. C#上位机与YOLO集成的方案选型
2.1 主流集成路线对比
我在动手之前,把网上能找到的几条路线全试了一圈,也看了很多社区讨论,大致可以分成四类:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 调用Python脚本(进程外) | C#启动Python进程,传图片路径或帧数据,Python推理后返回JSON | 实现最快、训练生态最好 | 进程通信开销大;Python环境依赖重;帧率高时容易卡死 |
| ONNX Runtime直接推理 | 将YOLO模型导出为ONNX,C#加载ONNX模型,用ML.NET或ONNX Runtime做推理 | 性能优秀、无Python依赖、不依赖GPU也能跑CPU | 需要处理NMS等后处理逻辑,YOLO版本不同导出细节有差异 |
| TensorFlow.NET / TorchSharp | 在C#里直接用深度学习框架 | 和原生训练代码更贴近 | 框架体积大、部署复杂、版本兼容性较伤 |
| OpenCvSharp DNN模块 | 使用OpenCV的DNN模块加载YOLO权重(Darknet格式或ONNX格式) | OpenCV本来就是视觉C#库,顺理成章 | 对YOLOv8之后的部分模型支持需要额外适配 |
综合稳定性、性能和改动量,我最终选了ONNX Runtime + OpenCvSharp的组合:用YOLO官方训练好的权重导出ONNX,C#上位机用OpenCvSharp做图像预处理(缩放、归一化、BGR转RGB),用ONNX Runtime做推理,之后自己写一个轻量NMS完成目标框合并。全程不需要装Python、不需要配CUDA,CPU部署也能跑到20~30fps,对AGV这种0.5m/s行进速度的应用来说已经完全够用。
2.2 为什么不用Python中转
我知道很多团队遇到同样需求第一反应是“Python里跑YOLO,C#负责界面和调度就行”,微信群里也总有人推这种方案。我的看法是:如果AGV上位机本来就在同一个工控机上,那么Python中转方案会引入至少三个不稳定因素。第一是进程生命周期,Python脚本如果因为某些意外崩溃了,C#根本拿不到反馈,整个AGV就变成了瞎子。第二是通信时序,Python端要用摄像头采集,又要等待C#发来的命令,两者之间的同步一旦抖动,检测结果会滞后,导致AGV已经撞上去了“防碰撞检测”才生效。第三是打包发布,给客户交付的时候要装Python环境、装一堆pip包,客户的现场运维人员看到这些一般都直接摇头。
ONNX Runtime这条路相当于把所有东西塞进C#这一个进程里,发布的时候只需要带上模型文件、DLL和可执行程序,干净利落。实测部署到现场的工控机(i5-8500,无独立显卡),640×640分辨率的YOLOv8n模型,推理耗时稳定在80~120ms,再加上前后处理,整体端到端延迟在150ms以内。结合AGV的减速距离设定,这个延迟完全来得及让AGV在撞到障碍物之前停下来。
3. 30行代码实现目标识别的实操过程
3.1 环境准备与依赖
先列环境和版本,我用的是:
- .NET 6.0(.NET 8也可以,但考虑到现场工控机可能只装了.NET 6运行时,所以这里以6为准)
- OpenCvSharp4 和 OpenCvSharp4.runtime.win (NuGet包)
- Microsoft.ML.OnnxRuntime(NuGet包,版本1.16.3)
- 模型:YOLOv8n,训练后导出为ONNX格式(n是指nano版本,速度快、参数小,适合部署在CPU上)
需要补充的是,YOLOv5、YOLOv6、YOLOv7的ONNX导出方式略有不同,但核心流程一致。我用的是YOLOv8n,因为它的C#端ONNX导出自带输出格式统一、NMS处理较简单,少踩很多坑。
另外准备一张测试图片,或者直接接USB相机(OpenCvSharp.VideoCapture可以很方便地拉流)。
3.2 核心代码实现与逐行解析
下面这个就是全网能找到的最短、最稳定的YOLO推理段,我把它放在一个静态工具类里。
using OpenCvSharp; using OpenCvSharp.Dnn; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public static class YoloDetector { private static InferenceSession _session; // 初始化模型 public static void Init(string modelPath) { var options = new SessionOptions(); options.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; _session = new InferenceSession(modelPath, options); } // 检测一张Mat图像,返回矩形框、置信度和类别标签 public static List<DetectionResult> Detect(Mat image) { // 1. 预处理:缩放至模型输入尺寸并归一化 var input = BlobFromImage(image, 1.0f / 255.0f, new Size(640, 640), new Scalar(0, 0, 0), true, false); var inputTensor = new DenseTensor<float>(input.ToArray(), new[] { 1, 3, 640, 640 }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", inputTensor) }; // 2. 推理 using var results = _session.Run(inputs); var output = results.First().AsTensor<float>().ToArray(); // 3. 后处理:解析输出并过滤低置信度框(此处省略NMS细节,后续讲解) return PostProcess(output); } private static List<DetectionResult> PostProcess(float[] output) { // YOLOv8 ONNX输出形状: [batch, 84, 8400],84=4个坐标 + 80个类别,8400为不同尺度锚点数 const int numClasses = 80; const int numProposals = 8400; const float confThreshold = 0.35f; List<DetectionResult> detections = new(); for (int i = 0; i < numProposals; i++) { float maxScore = 0; int maxClassId = -1; for (int j = 0; j < numClasses; j++) { float score = output[(4 + j) * numProposals + i]; if (score > maxScore) { maxScore = score; maxClassId = j; } } if (maxScore >= confThreshold) { float cx = output[i]; float cy = output[numProposals + i]; float w = output[2 * numProposals + i]; float h = output[3 * numProposals + i]; detections.Add(new DetectionResult(cx, cy, w, h, maxClassId, maxScore)); } } return NMS(detections, iouThreshold: 0.5f); } private static List<DetectionResult> NMS(List<DetectionResult> dets, float iouThreshold) { // 经典的NMS,按置信度排序后逐个抑制 dets.Sort((a, b) => b.Confidence.CompareTo(a.Confidence)); List<DetectionResult> picked = new(); while (dets.Count > 0) { picked.Add(dets[0]); var rest = dets.Skip(1).Where(d => ComputeIoU(dets[0], d) <= iouThreshold).ToList(); dets = rest; } return picked; } private static float ComputeIoU(DetectionResult a, DetectionResult b) { // 省略交集/并集计算,标准公式,不展开 // 返回0~1的IoU值 } private static Mat BlobFromImage(Mat src, float scale, Size size, Scalar mean, bool swapRB, bool crop) { // 这里用OpenCvSharp.Dnn的CvDnn.BlobFromImage,写法参考官方示例 return CvDnn.BlobFromImage(src, scale, size, mean, swapRB, crop); } } public class DetectionResult { public float X { get; set; } public float Y { get; set; } public float Width { get; set; } public float Height { get; set; } public int ClassId { get; set; } public float Confidence { get; set; } // 方便画框用 public Rect Rect => new Rect((int)(X - Width / 2), (int)(Y - Height / 2), (int)Width, (int)Height); public string Label { get; set; } }不骗人,把Init、Detect、PostProcess、NMS全加起来,有效核心代码真的不到30行。我当初实际写的时候,把各种声明拆得散了些,但逻辑就是这个样子。关键点逐个说:
BlobFromImage这一步很多人会忽略,或者照抄其他语言的代码然后翻车。YOLOv8的预处理要求是BGR像素除以255归一化,不做mean减法,并且需要将RGB顺序调换过来。CvDnn.BlobFromImage的swapRB参数填true,mean填new Scalar(0,0,0)就对了。如果填false,识别出来的类别会明显变差,比如把“人”识别成“椅子”。这个坑我花了一下午才定位,因为画面里人站在椅子旁边,模型一会儿到对人、一会儿对椅子,置信度都很低,看起来像模型没训练好。
ONNX输出维度需要特别说明。YOLOv8从v8.0开始,ONNX导出后的输出是一个[1, 84, 8400]的矩阵,而不是老版本YOLOv5那样[1, 8400, 85]。所以解析时如果按老代码去读,基本什么都读不出来。我上面写的是按照[batch, classes + 4, anchors]的内存布局来索引,也就是坐标在第0行、第1行、第2行、第3行,类别分数从第4行开始。很多人在这一步直接放弃C#,其实把布局搞清楚就发现很简单。
归一化到640×640是标准的模型输入尺寸,如果你的模型训练时用的是1280,那就改成1280。模型输入尺寸越大越准、越慢。AGV场景我用640,人员检测的准确率已经能满足要求,没有必要为了极端的远处小目标去牺牲实时性。
3.3 与AGV导航联调的细节
识别出来之后,如何把检测框变成AGV的控制指令,才是工程里真正容易出问题的地方。我在AGV上位机里的做法是维护一个“视觉感知消息”结构,每秒钟把最新检测结果和工控机时间戳打包,下发给运动控制模块。运动控制模块根据当前AGV的速度和转向,做以下几个判断:
- 正前方区域(车体中心线上的一个梯形区域)内是否存在置信度高于0.5的人形检测框。如果有,则立即向减速控制线程发送减速系数0.25,同时点亮警示灯。
- 前方2米范围内是否存在料车/托盘检测框,且检测框底边是否接近图像中特定的对接线。如果接近,则认为AGV到达可对接工位,停止并切入对接流程。
- 侧面是否有货架立柱连续多帧出现在同一位置。如果是,则说明AGV横向距离可能偏移,需微调导航偏移量。
这一套联调下来,最大的体会是:YOLO检测结果绝对不能直接拿来当“绝对真值”。因为单帧检测存在抖动,尤其目标距离远时,框的位置会在相邻几帧之间跳来跳去。我建议所有下游控制策略都带一个简单的滤波或者滞回比较。比如连续5帧中至少有3帧都检测到同一个人在危险区域,才触发急停;一旦触发,需要连续3帧没有检测到人才能解除。这个滞回逻辑可以有效防止AGV在停止和启动之间来回抽搐,也能避免因某帧漏检导致突然启动撞击人员。
如果你只要30行搞定识别,上面这些联调细节可以先跳过;但如果是做真实AGV项目,一定要有这层滤波和保护逻辑。否则调试时候机器一顿一顿的,到客户现场会很难看。
4. 常见问题与排查技巧实录
4.1 必踩的坑
先说我在这个项目里真实踩过的几个大坑,每一个都卡了我半天以上:
| 问题 | 现象 | 原因与解决 |
|---|---|---|
| 推理结果全是空,没有任何检测框 | 检测出来置信度永远低于阈值 | 输入Tensor格式错误。检查BlobFromImage输出是否已经归一化、是否swapRB,另外ONNX模型输入名称是否为“images” |
| 结果有框但坐标完全偏到图片角落 | 检测框都在左上角或堆在一起 | 解析输出坐标时索引算错。YOLOv8的输出是anchor-major还是class-major要写对,我上面代码是按class * numProposals + anchor取的,建议Debug打断点看一遍数值规律 |
| 摄像头图像偏色识别不准 | 彩色图像颜色整体偏红或偏蓝 | 摄像头用BGR读取后,BlobFromImage里swapRB设false了。正确应设true,因为OpenCV读进来是BGR,模型要的是RGB |
| 推理时间波动很大,偶尔卡几百毫秒 | CPU占用时高时低,帧率不稳 | 可能是GC导致的。建议把Tensor和结果数组尽量复用,减少每帧new,或者把Detect方法改为unsafe方式读取指针,能显著降低GC压力 |
| ONNX Runtime初始化失败或找不到DLL | 部署到现场机器报DllNotFoundException | NuGet包会自动拷贝ONNX Runtime的原生DLL,但需要确保发布时把runTime文件夹中的win-x64内容一起发布。解决办法是在csproj里设置RuntimeIdentifier,或直接复制对应DLL到输出目录 |
| 和其他视觉模块抢摄像头 | 上位机启用了两个VideoCapture,结果打不开设备 | 同一个USB摄像头只能有一个进程占用。把图像采集和推理放到同一个线程或同一个进程里,不要既开预览又开识别 |
其中“推理结果全是空”这个坑,前前后后有不下三个人在群里问过,最后发现都是因为输入图片没有进行归一化。记得OpenCV的BlobFromImage里scale参数传的是1.0/255.0,而不是1.0。如果直接传1.0,像素值范围在0~255,模型输入分布完全不对,输出的置信度会非常低,几乎全是零。
4.2 性能优化经验
集成完成之后,第一步是保证正确,第二步是压性能。我实测下来有四个优化点收益最大。
第一个优化点是固定模型输入尺寸。不要为了“多尺度检测”的动态灵活性去反复改Blob的size,那样会触发ONNX Runtime的输入shape变化,导致推理时重新分配buffer,慢个两三倍不止。我直接把所有输入拉到640×640,省心省力。
第二个优化点是使用NHWC布局。ONNX Runtime对NHWC布局在某些CPU上做了更深的优化。把输入Tensor的shape设成[1, 640, 640, 3],通道放在最后一维,并且提前做好RGB/R2B互换和归一化,推理耗时能比NCHW再降10%~20%。做法是先用CvDnn.BlobFromImage得到NCHW的float数组,再手动转成NHWC,或者直接用Mat的逐像素转换拼接成float数组。代码会多几行,但换来实时性提升是值得的。
第三个优化点是后处理只取当前需要的前K个框。YOLOv8的onnx输出是8400个候选框,我做了两层过滤:先按0.25的低置信度阈值粗筛,再对剩下的框做NMS。这样大多数帧只需要对几十个框做NMS,避免了全量8400个框两两算IoU的无谓开销。NMS本身也可以换成FastNMS——把输出Tensor在GPU或CPU上先做一次矩阵运算,但AGV场景里CPU部署没必要上这个复杂度。
第四个优化点是摄像头采集与推理异步。USB相机的读取是阻塞式的,如果在一个while(true)循环里先读取再推理,帧率会被相机帧率卡死。我开了一个专门线程用VideoCapture.Read()打循环,丢到ConcurrentQueue<Mat>里,推理线程每次取最新帧。两线程各跑各的,推理频率能超过采集频率,实际使用起来永远拿到的都是最新画面。如果采集是30fps,推理能跑到40fps就会发现视觉响应很跟手。
4.3 小心模型版权和训练数据问题
这一点虽然是技术之外的,但搞不好非常致命。用官方预训练模型只能识别80类通用物体,如果你要识别AGV专用的料车、托盘、立柱,就必须自己采集数据训练。训练完成后导出ONNX时建议使用自己团队的训练脚本,注意导出时可能会包含训练时的预处理参数,部署时的预处理一定要和训练时对齐。另外,从网上直接下载的权重文件,尤其是来源不明的“优化版”“魔改版”,一定要先验证在真实图像上的表现再上AGV,不然现场的误检会导致AGV频繁急停,严重影响生产效率。
我自己就遇到过从某博客下载的YOLOv5权重,在测试集上识别料车效果很好,但到了现场对黑色反光地面产生了大量误检,几乎每一帧都在地面上框出一个“虚影”。后来重新用现场数据微调了一个版本才解决。所以识别模型必须用现场环境的数据反复验证,不能只看实验室测试集。
5. 扩展:让YOLO识别结果真正参与AGV导航闭环
把目标识别跑通只是开始,怎么用才是关键。我这里提供了三种接入AGV导航闭环的常见模式,按实现复杂度从小到大排序。
模式一:硬保护(最安全)
检测到危险区域有人或未知障碍物,直接输出数字量信号给安全PLC或安全继电器,绕过上位机逻辑直接触发急停。这种模式实时性最好,安全性最高,适合人车混行场景。C#在这里只做识别和信号输出,不参与控制决策。缺点是如果识别模型误检率高,AGV会频繁急停,所以这个模式下置信度阈值和设备选型要非常保守。
模式二:软减速(最常用)
C#识别到目标后,通过共享内存或UDP给运动控制模块发送一个减速级别(比如0.5、0.3)。运动控制模块根据当前速度决定是否启用减速。软减速的优点是平滑,不会让AGV点头,缺点是如果视觉线程卡死或者通信阻塞,AGV会失去减速能力。因此我建议软减速一定要配合看门狗:视觉线程每次发送时带上时间戳,如果运动控制模块超过500ms没收到新时间戳,就自动进入安全模式并停车。
模式三:对接辅助(最复杂)
通过YOLO识别料车或托盘上的特征点,计算偏航角和偏移距离,辅助AGV进行高精度对接。这个模式需要相机安装位置固定且标定准确,通常要结合PNP位姿估计。C#里可以用OpenCvSharp的SolvePnP根据检测框角点和目标实际尺寸解算位姿,再把位姿偏差发送给运动控制模块。代码量会比单纯识别多一倍,但应用场景价值更高。我目前做的项目对接精度能达到±2cm,已经能满足大部分周转箱接驳需求。
6. 写在最后的几个建议
我实际用这套方案跑了两个多月,最大的体会是:C#和YOLO的组合,远没有传言中那么难。很多人听了“深度模型”“ONNX”“NMS”就被吓退了,实际上把几个关键点——预处理、输出解析、NMS——搞明白之后,剩下的全是体力活。如果你是第一次做,我建议按这个顺序走:先用官方模型在自己拍的图片上跑通检测,再扩展摄像头实时流,最后再接AGV运动控制。一口气想全做完,遇到问题反而无从下手。
另外发布部署环节最容易被忽略。客户现场的工控机经常是公网隔离、没有外网环境,甚至没有装.NET运行时。最好把运行时依赖一并发布成自包含模式,运行时命令:
dotnet publish -c Release -r win-x64 --self-contained true这样发布输出的文件夹里直接就是一个完整可运行的上位机,不依赖目标机器安装任何运行时,连OpenCV的原生DLL和ONNX Runtime的DLL都会带上,省去一堆现场配环境的麻烦。
最后再分享一个小技巧:模型文件别放在Debug目录下随程序编译,放到程序运行目录下的models子文件夹里,加载时用相对路径。这样以后更新模型时只需要替换一个文件,不用重新编译整个上位机。对AGV这种需要长期运行、现场迭代的设备来说,这个习惯能减少很多不必要的维护工作量。