简介:这是一份面向C#开发者的YOLOV6目标检测部署源码,利用OpenCvSharp的DNN模块加载ONNX格式模型,在Windows桌面应用中实现完整的实时目标检测流程,涵盖模型读取、图像缩放归一化、前向推理、输出解析与检测框绘制。资源共27个文件,包括9个C#源码文件、6个DLL依赖库、1个ONNX模型、2张测试图片、项目工程配置及界面资源文件,整个rar包约71.53MB,目录结构清晰,可直接打开解决方案编译运行,并借助测试图片快速验证效果。已有379人学习下载。代码按Common、frmMain、frmShow等模块划分,逻辑直观,既适合刚接触OpenCvSharp与DNN的开发者快速上手,也为需要将YOLOV6集成到C#项目中的工程人员提供了现成可改的参照。通过阅读和调试源码,可以掌握DNN模块加载外部模型、处理视频或图像的思路,并了解模型文件、依赖项与项目配置之间的组织方式。 C#上位机里做目标检测,我见过太多人一上来就奔着TensorRT、CUDA去,结果光环境就能折腾一星期。如果你的场景是产线视觉、一体机图像识别、桌面工具这类不需要极限性能的项目,OpenCvSharp的DNN模块直接加载ONNX模型,反而是最务实的方案。YOLOV6作为美团开源的目标检测模型,部署链路成熟、精度和速度平衡得好,配合C#跑CPU推理,完全能满足大部分工业场景。
这篇不聊虚的,直接把我从YOLOV6模型转换到C# OpenCvSharp DNN调用、再到性能调优的完整过程写出来。源码部分我会逐段拆解,包括LetterBox预处理、张量解析、NMS后处理那些容易踩的坑,方便你照着抄。
1. 模型这道坎:YOLOV6导出ONNX的格式细节
1.1 官方仓库导出命令与输出形状
很多人在C#侧代码写得没问题,结果模型一加载就报错,或者识别结果一团糟,根子往往出在ONNX导出这一步。YOLOV6的官方仓库是Meituan/YOLOv6,建议直接用它的tools/export.py脚本导出,不要自己写torch.onnx.export,因为YOLOV6的检测头里有DFL(Distribution Focal Loss)结构,自己导很容易丢掉关键的解码逻辑。
我常用的导出命令是:
python tools/export.py --weights yolov6s.pt --batch 1 --img 640 --simplify几个参数解释一下:
--batch 1:部署场景基本都按batch 1导出,导出后模型输入shape固定为[1,3,640,640],方便OpenCV处理。--img 640:输入尺寸。YOLOV6训练时默认就是640,除非你专门训练过其他分辨率,否则不要乱改。--simplify:用onnx-simplifier简化模型,去掉一些冗余节点,能减小体积,同时降低OpenCV DNN解析算子的概率。
导出完成后,用Netron打开看一眼输出节点。标准的YOLOV6 ONNX,输出张量shape一般是[1, 8400, 85]或者[1, 85, 8400]。8400是三个尺度特征图融合后的候选框总数,85的含义是:前4个值是框坐标,第5个值是objectness置信度,后面80个是COCO类别概率。
1.2 为什么我不建议用end2end导出
官方export.py里有个--end2end参数,加上之后会把NMS也导进ONNX模型里。乍一听很方便,省得自己在C#里写后处理,但实际上OpenCV DNN对这类内嵌NMS节点的支持并不稳定,不同版本OpenCV解析结果差异很大,有些算子在C#侧直接就会抛异常。我自己试过几次之后,还是老老实实导出不带NMS的版本,后处理在C#里自己写,反而可控性更高。
另外要注意,不同版本的YOLOV6代码,输出的坐标格式可能有差异。有的版本输出是center_x, center_y, width, height,有的版本已经解码成了x1, y1, x2, y2。这个信息Netron里看不出来,保险做法是导出后随便找一张图跑一遍Python推理,打印出前几个框的坐标值,和C#侧解析出来的对比一下,心里就有数了。后面我给的源码是按XYWH格式写的,如果你拿到的是XYXY格式,改两行就行。
2. C#工程环境:OpenCvSharp的包选择与Net初始化
2.1 NuGet包别再选错
在Visual Studio里新建一个控制台项目或者类库,NuGet里搜OpenCvSharp,需要装两个包:
OpenCvSharp4OpenCvSharp4.runtime.win
第二个包才是真正的原生OpenCV DLL。如果你只装OpenCvSharp4,编译能通过,一运行就报TypeInitializationException,提示找不到opencv_world.dll,就是因为缺少runtime包。常见的坑还有装成了OpenCvSharp4.Windows,这个包是老版的,新版作者建议用runtime.win替代。
版本方面,建议用4.8.0以上。YOLOV6导出的ONNX里有一些较新算子(比如Gather、Sigmoid的某些变体),太老的OpenCV解析不了,会在net.Forward()时直接崩溃。我目前用的是4.8.0.20230708,实测YOLOV6s、YOLOV6n都没问题。
2.2 初始化Net与后端选择
模型加载很简单,核心就是三行代码:
_net = CvDnn.ReadNetFromOnnx(modelPath); _net.SetPreferableBackend(Backend.OpenCV); _net.SetPreferableTarget(Target.CPU);Backend.OpenCV是默认的CPU推理后端,适合绝大多数场景。如果你的OpenCV是带OpenVINO支持的版本,可以换成Backend.OPENVINO,推理速度能提升不少,但NuGet官方包不含OpenVINO,需要自己编译OpenCV,这个门槛比较高,新手不建议折腾。
再说一下GPU。OpenCvSharp想走CUDA也不是不行,但需要自己编译带CUDA的OpenCV,而且编译完的DLL体积巨大、依赖繁琐,我用过一次之后就放弃了。如果你的项目真的需要GPU加速,不如直接用ONNX Runtime或者TensorRT,没必要在OpenCV上死磕CUDA。
3. 源码逐段拆解:从LetterBox到NMS的完整推理链
3.1 预处理必须用LetterBox而不是直接Resize
目标检测模型的输入要求尺寸固定,很多人图省事直接用Cv2.Resize把原图拉伸到640x640。这样做有一个隐患:图片里的物体会被拉伸变形,导致检测框偏移、置信度下降。YOLOV6训练时用的是LetterBox,也就是等比缩放后填充灰边,推理时也必须保持一致,否则精度会明显变差。
public static Mat LetterBox(Mat src, int inputSize, out float scale, out int padX, out int padY) { int srcW = src.Width; int srcH = src.Height; scale = Math.Min((float)inputSize / srcW, (float)inputSize / srcH); int newW = (int)Math.Round(srcW * scale); int newH = (int)Math.Round(srcH * scale); Mat resized = new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); Mat canvas = new Mat(inputSize, inputSize, MatType.CV_8UC3, Scalar.All(114)); padX = (inputSize - newW) / 2; padY = (inputSize - newH) / 2; resized.CopyTo(new Mat(canvas, new Rect(padX, padY, newW, newH))); resized.Dispose(); return canvas; }这里填充色用114,是YOLO系列训练时的默认值,不要改成0,否则会引入额外偏差。scale和padX/padY需要返回出去,因为后面还原检测框到原图坐标时要用。
3.2 前向推理与输出张量解读
预处理完之后,把Mat转成Blob,喂给网络,这一步有不少参数要注意:
Mat blob = CvDnn.BlobFromImage(inputMat, 1.0 / 255.0, new Size(640, 640), Scalar.All(0), true, false); _net.SetInput(blob); Mat output = _net.Forward();参数逐个说:
1.0 / 255.0:像素值归一化,YOLOV6训练时就是除以255,保持一致。new Size(640, 640):注意这里要和模型输入对齐。如果你的模型输入是[1,3,416,416],这里就填416。Scalar.All(0):均值,训练时没做均值归一化,所以传0。true:swapRB。因为训练时用的是BGR顺序,OpenCV默认Mat也是BGR,但BlobFromImage的这一步容易把人搞混。你只需要记住,如果训练代码里用的是cv2读取图片,这里就传true。false:crop,保持false。
_net.Forward()返回的Mat就是网络的输出。拿到手之后,第一步先检查维度:
int rows = output.Size(1); // 8400 int cols = output.Size(2); // 85然后读取数据。这里不要用output.GetArray(out float[,] array),因为OpenCV的Mat在内存里不一定是托管数组的连续布局,直接Marshal.Copy反而更稳妥:
float[] data = new float[rows * cols]; Marshal.Copy(output.Ptr(0), data, 0, data.Length);3.3 置信度过滤、坐标还原和NMS
拿到8400x85的数组之后,遍历每一行,先取objectness,再找到80个类别分数里最大的那个,两者相乘作为最终置信度。这一步的公式是score = objectness * class_score,YOLOV6和YOLOV5的处理逻辑一致。
for (int i = 0; i < rows; i++) { int offset = i * cols; float objectness = data[offset + 4]; float maxClassScore = 0; int classId = -1; for (int j = 5; j < cols; j++) { if (data[offset + j] > maxClassScore) { maxClassScore = data[offset + j]; classId = j - 5; } } float conf = objectness * maxClassScore; if (conf < confThreshold) continue; // 假设输出是中心点+宽高 float cx = data[offset + 0]; float cy = data[offset + 1]; float w = data[offset + 2]; float h = data[offset + 3]; float x1 = (cx - w / 2 - padX) / scale; float y1 = (cy - h / 2 - padY) / scale; float x2 = (cx + w / 2 - padX) / scale; float y2 = (cy + h / 2 - padY) / scale; Rect box = new Rect((int)x1, (int)y1, (int)(x2 - x1), (int)(y2 - y1)); boxes.Add(box); scores.Add(conf); classIds.Add(classId); }坐标还原的逻辑是:模型输出的坐标是基于640x640输入图像的,所以要先减去填充的pad,再除以缩放比例scale,才能映射到原图坐标。
过滤完低置信度的框之后,剩下的框之间还会有大量重叠,需要NMS去掉冗余框。OpenCvSharp直接封装了NMSBoxes:
CvDnn.NMSBoxes(boxes, scores, confThreshold, nmsThreshold, out int[] indices); List<Detection> results = new List<Detection>(); foreach (int idx in indices) { results.Add(new Detection { Box = boxes[idx], Confidence = scores[idx], ClassId = classIds[idx], ClassName = _classNames[classIds[idx]] }); }3.4 Detector完整代码
把上面的逻辑组装成一个Detector类,完整源码如下:
using System; using System.Collections.Generic; using System.Runtime.InteropServices; using OpenCvSharp; using OpenCvSharp.Dnn; public class YoloV6Detector { private readonly Net _net; private readonly string[] _classNames; private readonly float _confThreshold; private readonly float _nmsThreshold; private const int InputSize = 640; public YoloV6Detector(string modelPath, string[] classNames, float confThreshold = 0.25f, float nmsThreshold = 0.45f) { _classNames = classNames; _confThreshold = confThreshold; _nmsThreshold = nmsThreshold; _net = CvDnn.ReadNetFromOnnx(modelPath); _net.SetPreferableBackend(Backend.OpenCV); _net.SetPreferableTarget(Target.CPU); } public List<Detection> Detect(Mat image) { // 1. LetterBox预处理 Mat letterBoxed = LetterBox(image, InputSize, out float scale, out int padX, out int padY); // 2. 构建Blob并推理 Mat blob = CvDnn.BlobFromImage(letterBoxed, 1.0 / 255.0, new Size(InputSize, InputSize), Scalar.All(0), true, false); _net.SetInput(blob); Mat output = _net.Forward(); // 3. 解析输出 int rows = output.Size(1); int cols = output.Size(2); float[] data = new float[rows * cols]; Marshal.Copy(output.Ptr(0), data, 0, data.Length); List<Rect> boxes = new List<Rect>(); List<float> scores = new List<float>(); List<int> classIds = new List<int>(); for (int i = 0; i < rows; i++) { int offset = i * cols; float objectness = data[offset + 4]; float maxClassScore = 0; int classId = -1; for (int j = 5; j < cols; j++) { if (data[offset + j] > maxClassScore) { maxClassScore = data[offset + j]; classId = j - 5; } } float conf = objectness * maxClassScore; if (conf < _confThreshold) continue; float cx = data[offset + 0]; float cy = data[offset + 1]; float w = data[offset + 2]; float h = data[offset + 3]; float x1 = (cx - w / 2 - padX) / scale; float y1 = (cy - h / 2 - padY) / scale; float x2 = (cx + w / 2 - padX) / scale; float y2 = (cy + h / 2 - padY) / scale; boxes.Add(new Rect((int)x1, (int)y1, (int)(x2 - x1), (int)(y2 - y1))); scores.Add(conf); classIds.Add(classId); } // 4. NMS CvDnn.NMSBoxes(boxes, scores, _confThreshold, _nmsThreshold, out int[] indices); List<Detection> detections = new List<Detection>(); foreach (int idx in indices) { detections.Add(new Detection { Box = boxes[idx], Confidence = scores[idx], ClassId = classIds[idx], ClassName = _classNames[classIds[idx]] }); } blob.Dispose(); output.Dispose(); letterBoxed.Dispose(); return detections; } private static Mat LetterBox(Mat src, int inputSize, out float scale, out int padX, out int padY) { int srcW = src.Width; int srcH = src.Height; scale = Math.Min((float)inputSize / srcW, (float)inputSize / srcH); int newW = (int)Math.Round(srcW * scale); int newH = (int)Math.Round(srcH * scale); Mat resized = new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); Mat canvas = new Mat(inputSize, inputSize, MatType.CV_8UC3, Scalar.All(114)); padX = (inputSize - newW) / 2; padY = (inputSize - newH) / 2; resized.CopyTo(new Mat(canvas, new Rect(padX, padY, newW, newH))); resized.Dispose(); return canvas; } } public class Detection { public Rect Box { get; set; } public float Confidence { get; set; } public int ClassId { get; set; } public string ClassName { get; set; } }注意,类名数组我这里没贴完整,COCO的80个类别你直接搜一下就有,网上到处都是,关键是数组顺序要和模型训练时的类别顺序一致,否则识别出来标签和框对不上。
4. 性能实测、报错清单与上位机集成经验
4.1 CPU推理耗时与瓶颈
我在i5-8400、16G内存的机器上测过,输入640x640,单线程CPU推理:
| 模型 | 单张耗时 | 备注 |
|---|---|---|
| YOLOV6n | 约25-35ms | 轻量,适合实时场景 |
| YOLOV6s | 约60-80ms | 精度更好,一秒钟10帧左右 |
| YOLOV6m | 约120-150ms | 勉强能用,卡顿感明显 |
如果只是偶尔检测一张图,这个速度完全够用。如果是视频流或相机连续检测,YOLOV6s在CPU上会比较吃力,建议降级到YOLOV6n,或者换ONNX Runtime试试。另外,第一次调用_net.Forward()会比后续慢很多,因为要加载初始化和预热。所以项目启动时最好先用一张黑图跑一次推理,把预热时间消化掉。
4.2 我踩过的三个坑
第一个坑是net.Forward()直接崩溃。这个问题绝大多数是因为ONNX模型里有OpenCV DNN不支持的算子。解决办法是升级OpenCvSharp版本到4.8以上,如果还不行,就得回到导出环节,检查是不是加了--end2end,或者用--simplify重新导一遍。
第二个坑是识别结果全是空。如果是这样,先别怀疑模型,优先检查后处理的数据解析是不是正确的。尤其要注意置信度阈值,COCO模型一般0.25是合理值,你把阈值设到0.5以上,很多小目标就全被过滤了。还有一个隐蔽问题是BlobFromImage的swapRB参数,训练时用了BGR、推理时传了false,图像颜色通道全反了,识别精度直接崩。
第三个坑是内存越用越多。Mat在C#里是托管对象,但是底层引用的原生内存不会自动回收。如果Detect方法里每次都new Mat不Dispose,跑一段时间内存就上去了。我上面的代码里已经做了Dispose,但如果图片数量很大,建议考虑对象池或者直接在OpenCV里复用输出缓冲区。
4.3 上位机场景的线程与性能建议
做上位机的朋友应该都有体会,C#里最烦的就是UI线程卡顿。检测推理是典型的重计算操作,一定会阻塞UI线程,导致界面假死。我的做法是单独用一个工作线程跑相机取图和检测,检测完成后再通过BeginInvoke或者Channel把结果发回UI线程。
处理循环数据采集和UI刷新卡顿,核心原则是不要在UI线程做任何耗时操作。你可以这样组织:
private void CameraGrabbed(object sender, EventArgs e) { Mat frame = new Mat(); // 从相机取帧 // ... // 放到后台线程推理 Task.Run(() => { var detections = _detector.Detect(frame); // 回到UI线程刷新显示 BeginInvoke(new Action(() => { DrawResults(detections); })); frame.Dispose(); }); }再提一个很多人忽视的性能优化点:如果检测框只需要画在原图上,不要在Detect方法里既解析又画图,画图也消耗CPU。把检测结果以类或结构体返回,统一在UI线程里画,逻辑会清爽很多。
另外,如果你的相机分辨率很高(比如200万像素以上),直接送到Detect方法里做LetterBox,每次缩放整张大图会有开销。我一般会先在取图回调里对原图做一次粗缩放,比如缩放到1280width,再做检测,精度损失不大,速度提升明显。
根据我个人经验,OpenCvSharp DNN部署YOLOV6最值得记住的一点是:这类部署工作80%的时间都花在“让模型输出格式和代码解析格式对齐”上。如果你拿到一个不知道怎么导出的ONNX,第一件事就是把Netron打开,把每个输出节点的shape记下来。然后程序里加一行日志,把output.Size(0)、output.Size(1)、output.Size(2)打出来,和Netron里的对应上,再开始写解析代码。这样可以少走很多弯路,你也就不用体验那种调了一晚上置信度阈值结果发现是坐标解析反了的绝望了。
本文还有配套的精品资源,点击获取