C#上位机部署YOLOv5旋转目标检测:从ONNX到OpenCvSharp完整实现
发布时间:2026/9/8 11:21:14来源:尧图网络
简介面向C#开发者与计算机视觉工作者的YOLOv5旋转目标检测实践工程基于OpenCvSharp DNN模块实现无需依赖PyTorch环境即可在.NET平台完成模型加载、预处理、前向推理、检测框解析与旋转角度输出。压缩包共133个文件以cs源代码、dll动态库、onnx模型、config配置、exe可执行文件为主同时包含Visual Studio解决方案文件与resources资源整体约94.04MB目录结构清晰便于直接打开调试。已有733人学习下载。工程覆盖从模型转换、推理调用到边界框绘制的完整流程并附带性能优化思路适合希望掌握C#部署旋转目标检测技术的开发者参考与二次开发。 C#上位机里接yolov5旋转目标检测最近问的人明显多起来了。不奇怪工业现场凡是涉及定位、抓取、巡检的项目方正矩形框往往不够用目标一倾斜框里就全是背景后续做区域矫正、机械手抓取根本没法用。这毛病我早年也踩过后来把yolov5的旋转检测模型通过OpenCvSharp的DNN模块接进C#跑通之后整套逻辑就固定下来了。这篇文章把源码实现完整放出来顺便把我踩过的角度定义、letterbox、NMS这些坑讲清楚。不管你是做上位机集成、毕业设计还是写工具型软件这套代码基本可以直接套用。1. 项目背景与方案选型思路1.1 为什么要做旋转目标检测部署普通yolov5输出的矩形框是轴对齐的也就是宽高边始终平行于图像坐标轴。目标一旦倾斜摆放矩形框就会框进去大量背景区域几个目标离得近的时候框与框之间大面积重叠NMS很容易把本该存在的目标直接抑制掉。对于芯片引脚检测、航拍船舶识别、工业零件定位这类场景旋转框才是最合适的输出形式——它用最小外接矩形把目标包住框得准下游做仿射变换或者抓取姿态计算也方便。实际项目中我遇到最多的是PCB板上的元件定位和流水线上的长条形工件检测。这类目标方向随机普通检测框输出的中心点虽然大概准确但旋转角度完全没信息机械手拿不到朝向就没法抓取。上了旋转目标检测之后一条完整的链路就通了检测框给出了中心点、宽高和角度上位机直接把这几项发给plc或者机械手控制器省掉了用轮廓拟合做二次计算的中间环节精度和稳定性都提升了一个台阶。1.2 为什么选C#加OpenCvSharp DNN在选择推理框架的时候我首先排除了Python方案。不是Python不行而是工业上位机环境大多不允许你装一个解释器进去交付给现场的东西最好是单目录、拷贝即用。C方案跑得快但开发效率确实低跟WinForms/WPF界面代码混在一起写起来人很痛苦。最后落地的组合是C#加OpenCvSharp理由有三条。第一OpenCvSharp的DNN模块直接读ONNX文件不需要额外安装CUDA、OpenVINO这类运行时NuGet装好包之后整个工程就能发布成绿色目录这点对现场部署来说太重要了。第二OpenCV本身在工控软件圈子里覆盖面极广很多上位机项目本来就引用了OpenCvSharp做图像处理再加一个DNN调用而已几乎没有迁移成本。第三DNN模块的API很简洁ReadNetFromOnnx加Net.Forward两步就能拿到原始张量推理之外所有逻辑都攥在自己手里调试起来非常直观。有人可能会问为什么不用ONNX Runtime的C#官方包它确实也是个好方案而且性能略微占优。但上线时如果碰到老工控机、老系统ONNX Runtime的native依赖偶尔会出幺蛾子OpenCV的DNN模块反而是最稳的那个。当然如果项目对推理性能有更高要求后续换后端也是很容易的前提是后处理代码抽离干净这一点文末我会讲。2. 旋转目标检测原理与ONNX导出要点2.1 旋转框的两种主流表示法搞旋转目标检测先得知道模型输出的是什么。目前主流的表示法有两类五参数法和四角点法。五参数法输出的是cx、cy、w、h、theta这五个值也就是旋转矩形的中心坐标、宽高和旋转角度。这种表示的优点很直接跟OpenCV里的RotatedRect结构天然对应存起来紧凑后处理计算也方便。缺点是角度回归存在周期性0度和180度原本是同一个方向模型却要区分开训练的时候容易产生loss震荡需要做角度范围约束和边界处理。四角点法干脆不预测角度直接输出旋转矩形的四个顶点坐标一共8个值。这种表示对目标形状的表达更灵活不规则的四边形也能描述但后处理需要自己保证四个点的顺序一致比如统一按顺时针排列否则计算交并比的时候会出乱子。本文源码以五参数法为例因为实际部署最常用OpenCvSharp的RotatedRect拿来就能用旋转框的绘制、IOU计算都有现成API。如果你手头模型是四角点输出只要把解码部分换成角点坐标再把旋转框IOU换成多边形IOU整体框架完全不用动。2.2 DNN推理路径与后处理职责OpenCvSharp的DNN模块只负责一件事把输入图像张量送进网络把原始输出张量拿回来。它不帮你做任何检测层面的后处理。这和Python侧完全不一样——Python的yolov5工具链里有现成的non_max_suppressionC#这边什么都没有连anchor解码、置信度过滤、旋转框IOU计算全都得自己写。所以C#部署旋转检测真正的工作量不在推理那一行代码上而在后处理。推理就是ReadNetFromOnnx、BlobFromImage、SetInput、Forward四步走完。后处理要处理的数据量却不小以640乘640输入、yolov5s为骨架的旋转模型输出张量通常是1乘25200乘(类别数加5)的规模也就是两万多个候选框每个都要解码、过滤、再经过旋转NMS合并。这块逻辑写得清不清楚直接决定检测结果准不准、跑得快不快。另外一个很容易被忽略的问题是输入图像到网络的resize方式。OpenCV的BlobFromImage只做普通resize不帮你做letterbox。如果你训练模型的时候用了letterbox填充部署时不做同样的操作检测框坐标整体就会偏移尤其是长宽比大的图片框完全是歪的。2.3 ONNX导出与输出形状确认旋转目标检测的训练工程很多比如基于yolov5的旋转框改进版本训练完用官方export.py加上include onnx参数导出就能得到ONNX文件。导出的时候要注意如果训练工程已经包含了decode的逻辑导出的ONNX输出坐标就已经是相对于输入尺寸的像素坐标C#端拿到后只需要做一次尺度映射。如果导出的是不含decode层的特征图那你就要自己实现anchor解码复杂度会高一截。我建议尽量导出带decode的版本省事且不容易出错。模型拿到手之后第一件事不是写代码而是用Netron打开ONNX文件看输出节点的形状。这一步务必做因为不同工程导出的张量布局不一样有的输出是1乘25200乘85有的则是1乘85乘25200甚至通道顺序都不同。花两分钟确认一下能省掉后面一个下午的调试时间。确认完输出形状再对照训练工程里的类别列表把类别数量和索引顺序记好后处理代码里的参数就全定了。3. 源码实现从加载模型到旋转NMS3.1 环境依赖与模型加载项目基于.NET 6以上版本NuGet安装OpenCvSharp4和OpenCvSharp4.runtime.win两个包即可。如果是Linux工控机换成对应runtime包。安装完成后直接用OpenCvSharp.Dnn命名空间里的API。模型加载代码很简单核心是ReadNetFromOnnxusing OpenCvSharp; using OpenCvSharp.Dnn; string modelPath D:\models\yolov5s_rotated.onnx; Net net CvDnn.ReadNetFromOnnx(modelPath); net.SetPreferableBackend(CvDnn.Backend.OpenCV); net.SetPreferableTarget(CvDnn.Target.CPU);这里后端选的Backend.OpenCV也就是OpenCV自带的DNN推理实现。如果现场机器是纯CPU这是兼容性最好的选择。如果OpenCV是带CUDA的编译版本可以换成CvDnn.Backend.CUDA但NuGet官方包不支持GPU需要自己编译或者找已编译的带CUDA版本。SetPreferableTarget的CPU选项在大多数工控机上都能稳定跑性能后面实测部分再讲。3.2 图像预处理与前向推理预处理最关键的坑就是letterbox。我吃过亏在这里多说几句。yolov5系列训练时为了保持目标不变形会把原图等比缩放到目标尺寸剩余部分用灰色填充这就是letterbox。如果你直接BlobFromImage无条件拉伸到640乘640图片比例一变检测框自然就歪了。可靠的做法是自己做letterbox全程控制坐标映射关系private static Mat Letterbox(Mat src, int targetW, int targetH, out float scale, out int padX, out int padY) { scale Math.Min((float)targetW / src.Width, (float)targetH / src.Height); int newW (int)Math.Round(src.Width * scale); int newH (int)Math.Round(src.Height * scale); padX (targetW - newW) / 2; padY (targetH - newH) / 2; Mat canvas new Mat(new Size(targetW, targetH), MatType.CV_8UC3, new Scalar(114, 114, 114)); Mat roi new Mat(canvas, new Rect(padX, padY, newW, newH)); OpenCvSharp.Cv2.Resize(src, roi, new Size(newW, newH)); roi.Dispose(); return canvas; }letterbox之后的图直接用BlobFromImage转成网络输入。注意yolov5训练时用的是RGB顺序OpenCV读图默认是BGR所以swapRB参数必须传true否则颜色通道对不上推理置信度会很低。Mat canvas Letterbox(src, 640, 640, out float scale, out int padX, out int padY); Mat blob CvDnn.BlobFromImage(canvas, 1.0 / 255.0, new Size(640, 640), new Scalar(0, 0, 0), true, false); net.SetInput(blob); Mat output net.Forward();3.3 后处理解码置信度与坐标拿到output这个Mat之后先把它拷贝成一维float数组按之前Netron里确认的shape去解析。这里以最常见的1乘25200乘(类别数加5)布局为例float[] data new float[output.Total()]; System.Runtime.InteropServices.Marshal.Copy(output.Data, data, 0, data.Length); int rows 25200; int cols nc 5;假设五参数法模型输出排列是cx, cy, w, h, angle, class0, class1, ...那么遍历每一个候选框时取第一个score作为目标置信度。不过这里有个小细节要注意有些旋转检测模型保留了objectness分支置信度要拿目标置信度乘类别概率有些模型则没有objectness直接把类别概率当score。这个必须对照训练工程在检测头里的写法来确定。下面这段给的是带objectness的通用写法ListRotatedRect boxes new ListRotatedRect(); Listfloat scores new Listfloat(); for (int i 0; i rows; i) { int offset i * cols; float objConf data[offset 4]; if (objConf confThresh) continue; float maxCls 0f; int clsId -1; for (int j 5; j cols; j) { if (data[offset j] maxCls) { maxCls data[offset j]; clsId j - 5; } } float score objConf * maxCls; if (score confThresh) continue; float cx data[offset]; float cy data[offset 1]; float w data[offset 2]; float h data[offset 3]; float ang data[offset 4]; // 映射回原图坐标 float x (cx - padX) / scale; float y (cy - padY) / scale; float bw w / scale; float bh h / scale; boxes.Add(new RotatedRect(new Point2f(x, y), new Size2f(bw, bh), ang)); scores.Add(score); }注意角度单位。训练工程里角度可能用弧度也可能用角度OpenCvSharp的RotatedRect要求的是角度且范围是负90度到0度。如果模型输出角度范围不一致必须按训练时的定义做换算不然框画出来会原地乱转。具体怎么处理下一部分重点讲。3.4 旋转框NMS与结果绘制普通目标检测的NMS用axis-aligned矩形框的IOU旋转检测不一样两个旋转矩形求交并比交集区域是一个不规则的凸多边形面积要用多边形求交来算。OpenCvSharp里的Cv2.RotatedRectangleIntersection正是干这个的返回两个旋转矩形的相交区域顶点再用ContourArea求交集面积private static float RotatedIoU(RotatedRect r1, RotatedRect r2) { Point2f[] intersectPts; RotatedRectIntersectTypes type Cv2.RotatedRectangleIntersection(r1, r2, out intersectPts); if (type RotatedRectIntersectTypes.None || intersectPts.Length 3) return 0f; float interArea Cv2.ContourArea(intersectPts); float area1 r1.Size.Width * r1.Size.Height; float area2 r2.Size.Width * r2.Size.Height; float unionArea area1 area2 - interArea; return unionArea 0 ? 0f : interArea / unionArea; }有了旋转框IOUNMS流程和常规的NMS没有本质区别按score从高到低排序依次取框抑制掉所有与当前框IOU超过阈值的框。private static ListRotatedRect RotatedNms(ListRotatedRect boxes, Listfloat scores, float iouThresh) { var order scores.Select((s, idx) new { s, idx }) .OrderByDescending(x x.s) .Select(x x.idx).ToArray(); bool[] suppressed new bool[boxes.Count]; ListRotatedRect result new ListRotatedRect(); for (int i 0; i order.Length; i) { if (suppressed[order[i]]) continue; result.Add(boxes[order[i]]); for (int j i 1; j order.Length; j) { if (suppressed[order[j]]) continue; if (RotatedIoU(boxes[order[i]], boxes[order[j]]) iouThresh) suppressed[order[j]] true; } } return result; }绘制旋转框比画普通矩形多一步从RotatedRect拿到四个顶点再画线。RotatedRect.Points()返回的顶点顺序是稳定的直接首尾相连画四条线就行foreach (RotatedRect rect in result) { Point2f[] pts rect.Points(); Point p0 new Point((int)pts[0].X, (int)pts[0].Y); Point p1 new Point((int)pts[1].X, (int)pts[1].Y); Point p2 new Point((int)pts[2].X, (int)pts[2].Y); Point p3 new Point((int)pts[3].X, (int)pts[3].Y); Cv2.Line(img, p0, p1, new Scalar(0, 255, 0), 2); Cv2.Line(img, p1, p2, new Scalar(0, 255, 0), 2); Cv2.Line(img, p2, p3, new Scalar(0, 255, 0), 2); Cv2.Line(img, p3, p0, new Scalar(0, 255, 0), 2); }到这里完整的检测链路就通起来了原图画letterbox转blobForward拿原始输出解码坐标旋转NMS去重绘制显示。整个流程封装成一个Detect方法输入Mat输出结果列表就非常好用了。4. 部署过程中的坑与实测排查4.1 角度定义不一致框乱转的根因旋转目标检测部署中我最想说的一个坑就是角度定义。训练工程不同角度的定义天差地别。有的用弧度有的用角度有的范围是负90度到0度有的范围是0度到180度有的角度的参考边是矩形长边有的是短边。这些微小的差异到了推理端表现就是检测框要么乱转方向要么宽高互换。排查技巧是拿一张已知目标的测试图先用Python脚本跑一次打印出模型的原始输出框和C#这边解码出来的框做对比。如果置信度一致但角度对不上多半是角度范围或者角度单位没对齐。换算方式通常是训练用弧度部署改成角度就乘上57.2958训练角度范围是0到180OpenCV RotatedRect要求负90到0就用角度值减90处理。前提是搞清楚训练工程的angle定义。4.2 检测结果是空白预处理三连坑模型加载正确、代码逻辑也没问题但检测结果一个框都没有这种情况八成出在预处理上。我整理过最常见的三个原因。第一个是swapRB没设true。yolov5训练用RGBOpenCV读图是BGR不交换通道模型相当于看了一张颜色完全错乱的图特征提取不出来置信度自然全部低于阈值。第二个是归一化系数不对。BlobFromImage的第二个参数设1.0除以255.0如果你训练时还减过均值和方差部署端也要对应加上Mean和Scale。第三个是letterbox的填充值不一致训练时用灰值114部署代码里填充成了0或者反过来都会导致短边上的特征被污染。这几类问题都不报错程序正常跑完就是没结果最容易被新手忽视。我的经验是先在Python端用onnxruntime跑同一张图确认模型本身没问题再逐项比对C#端的预处理参数。两边结果一致了再往后做NMS调整。4.3 CPU推理性能与UI线程阻塞OpenCvSharp DNN在纯CPU上跑yolov5s旋转模型640乘640输入实测在i5-8500这颗老CPU上大概是80到120毫秒一帧。如果换成yolov5n或者更小的主干能压到50毫秒上下。这个速度做离线检测或者低速在线检测完全够用但如果你要跑到实时视频流里还要求界面不卡那就必须要处理线程问题。最忌讳的做法是把推理直接丢在UI线程里图片一大界面直接假死。正确姿势是用Task.Run把检测逻辑丢到后台线程结果通过Invoke回到UI线程刷新。如果是不间断的视频流建议再加一个简单的队列用单消费者线程从队列里取帧检测避免每帧图像疯狂创建线程导致任务堆积。我见过不少上位机项目就是在这个点上卡住的热搜里那个C#循环数据采集和UI刷新卡顿的话题底层原因基本就是同步操作卡住了消息泵。4.4 扫码枪触发检测与工程化建议这类检测功能真正落地到产线通常不会是自己一个人盯着屏幕看而是配合扫码枪或者光电传感器触发。扫码枪本质上是模拟键盘输入在WinForms里监听KeyDown事件遇到指定的扫码前置字符就触发一次检测任务把当前工件的检测结果和条码关联起来存库。如果扫码枪配置成串口模式那就更直接了SerialPort的DataReceived事件里开一个异步检测任务即可。工程化方面我强烈建议把整个检测逻辑封装成一个独立类输入是Mat输出是结果对象列表跟界面代码彻底解耦。模型路径、类别名、置信度阈值、IOU阈值全部用配置文件管理不要写死在代码里。这样以后想从OpenCvSharp DNN换成ONNX Runtime或者OpenVINO只需要改类内部实现对外接口不变。我自己就是先这样搭的骨架后来项目迁到带NVIDIA显卡的工控机换了CUDA后端上层调用代码一行没动。这种扩展性前期花半小时整理结构后面省下的时间远不止半小时。本文还有配套的精品资源点击获取
网站建设高端定制企业官网