1. 项目概述:为什么要在C#里跑YOLOv5?
如果你是一个做工业视觉、上位机软件开发,或者任何需要在Windows桌面环境里集成目标检测功能的C#开发者,那你肯定对“模型部署”这事儿又爱又恨。爱的是,像YOLOv5这样的模型,检测精度和速度确实给力;恨的是,主流的Python生态和你的C#/.NET生产环境之间,总像隔着一道鸿沟。难道为了用个模型,还得在客户机器上配一套Python环境,或者搞个复杂的服务化调用?这显然不现实。
这时候,ONNX(Open Neural Network Exchange)格式就成了那道关键的桥梁。它就像一个“中间商”,不生产模型,只做模型的格式转换。你可以用PyTorch、TensorFlow等框架训练好YOLOv5模型,然后将其导出为标准的.onnx文件。这个文件包含了模型的结构和权重,并且与具体的训练框架解耦。接下来,你就可以在C#项目中,通过ONNX Runtime这个高性能推理引擎,直接加载并运行这个.onnx文件,实现模型的本地化调用。
这么做的好处显而易见:
- 环境干净:客户端无需安装Python、PyTorch等一堆科学计算库,只需要你的C#应用和ONNX Runtime的依赖,部署复杂度直线下降。
- 性能优异:ONNX Runtime针对不同硬件(CPU/GPU)做了深度优化,推理速度往往不输原框架,有时甚至更快。
- 语言无关:一套模型,可以在Python、C#、C++、Java等多种语言环境中使用,真正实现了“一次训练,到处部署”。
- 集成顺畅:对于C# WinForms、WPF甚至是ASP.NET Core应用,你可以将检测逻辑像调用一个普通类库一样无缝集成进去,处理摄像头视频流、图片文件或者内存中的Bitmap数据都行。
所以,“YOLO V5 ONNX模型在C#中部署”这个标题背后,解决的正是广大.NET开发者将前沿AI能力低成本、高性能地集成到自有应用中的核心痛点。接下来,我就以一个实际的上位机检测项目为例,拆解从模型准备到C#集成的完整链路和所有技术细节。
2. 核心思路与工具链选型
在动手写代码之前,理清整个流程和选对工具是成功的一半。整个部署流程可以概括为:训练/获取模型 -> 导出ONNX -> C#环境配置 -> 编写推理代码 -> 前后处理集成。
2.1 模型准备:从PyTorch到ONNX
你首先得有一个YOLOv5模型。通常有两种方式:
- 使用官方预训练模型:直接从Ultralytics的YOLOv5 GitHub仓库下载
yolov5s.pt、yolov5m.pt等不同大小的预训练模型。它们在大数据集(如COCO)上训练过,开箱即用,适合通用目标检测。 - 训练自己的定制模型:如果你有特定场景的需求(如检测PCB缺陷、特定零件),就需要用自己的数据集在YOLOv5框架下进行微调训练,得到自己的
.pt权重文件。
得到.pt文件后,下一步就是将其转换为ONNX格式。这里我强烈推荐使用YOLOv5官方提供的export.py脚本。它经过充分测试,能正确处理YOLO模型特有的后处理逻辑(如锚框计算)。
关键转换命令与参数解析:
python export.py --weights yolov5s.pt --include onnx --imgsz 640 640 --opset 12 --dynamic--weights: 指定你的.pt权重文件路径。--include onnx: 指定输出格式为ONNX。--imgsz 640 640: 指定模型输入的图像尺寸(高度,宽度)。这里有个大坑:YOLOv5的输入必须是正方形,且最好是32的倍数(因为网络有5次下采样,2^5=32)。640x640是最常用的尺寸。你必须确保C#里送进去的图片最终也调整到这个尺寸。--opset 12: 指定ONNX算子集版本。版本不宜过低(可能缺少某些算子支持),也不宜盲目追新(ONNX Runtime可能还未完全支持)。Opset 12是一个广泛兼容的稳定版本。--dynamic: 这个参数至关重要。它允许模型输入/输出的batch size和图像尺寸是动态的。如果不加,模型会被固定为导出时imgsz指定的尺寸。加上后,在C#端,你可以灵活处理不同尺寸的图片,或者进行批处理推理。导出的ONNX模型输入输出会是batch_size, channels, height, width这样的符号形式。
实操心得:导出ONNX后,务必用Netron(一个可视化工具)打开生成的
.onnx文件看一眼。确认输入节点的名字(通常是images)和形状(如[1, 3, 640, 640]),以及输出节点的名字和形状。YOLOv5 v6.0以后版本的输出通常是[1, 25200, 85]这样的格式(85=4个坐标+1个置信度+80个类别分数),这个信息对后续C#端解析输出至关重要。
2.2 C#端工具链:ONNX Runtime详解
在C#中加载和运行ONNX模型,我们依赖微软的ONNX Runtime。它提供了原生的C API以及多种语言绑定,其中就包括对.NET的完美支持。
NuGet包选择:在Visual Studio的NuGet包管理器中,你需要根据运行环境安装对应的包:
Microsoft.ML.OnnxRuntime: 这是纯CPU版本的包。如果你的应用运行在无独立显卡的服务器或普通PC上,就用这个。它体积小,依赖少。Microsoft.ML.OnnxRuntime.Gpu: 这是支持CUDA的GPU版本。如果你的部署机器有NVIDIA显卡,并且安装了对应版本的CUDA和cuDNN,安装这个包可以极大加速推理速度,尤其是处理视频流或多路并发时。
如何选择?
- 对于实时性要求高的桌面应用(如实时视频检测),优先考虑GPU版本。
- 对于服务器端批量处理图片,如果CPU足够强(如至强系列),CPU版本也可能够用,且部署更简单。
- 你可以先在开发机上安装GPU版本进行开发和性能测试,发布时根据目标环境决定携带哪个包。
一个重要的架构概念:InferenceSession在ONNX Runtime中,核心类是InferenceSession。你可以把它理解为一个“模型计算引擎”。初始化InferenceSession时,需要传入ONNX模型文件的路径或字节流,这个过程会加载模型并为其准备执行环境(如绑定CPU/GPU)。创建InferenceSession的成本相对较高,因此最佳实践是将其作为单例或静态变量在整个应用生命周期内复用,而不是每次推理都创建新的。
3. 环境搭建与项目配置
理论说完了,我们开始动手搭环境。假设我们创建一个名为YoloOnnxCSharpDemo的WPF或WinForms项目。
3.1 创建项目与安装NuGet包
- 打开Visual Studio 2022,新建一个“.NET桌面应用”(WPF或Windows窗体应用均可),.NET版本建议选择6.0或8.0(LTS长期支持版)。
- 在“解决方案资源管理器”中右键点击项目,选择“管理NuGet程序包”。
- 在“浏览”选项卡中,搜索
Microsoft.ML.OnnxRuntime.Gpu(如果你有GPU环境)或Microsoft.ML.OnnxRuntime。选择稳定版本(如1.16.3)进行安装。安装时,它会自动安装其依赖项。
3.2 准备模型与测试资源
在项目根目录下,创建一个文件夹,比如叫Models。将之前导出的yolov5s.onnx文件复制到这个文件夹中。非常重要的一步:在Visual Studio中右键点击这个.onnx文件,选择“属性”,将“复制到输出目录”设置为“如果较新则复制”或“始终复制”。这样在编译后,模型文件会自动出现在你的bin\Debug或bin\Release目录下,代码里可以用相对路径(如./Models/yolov5s.onnx)来访问。
同样,准备一个Assets文件夹,放几张测试图片,也设置“复制到输出目录”。
3.3 处理GPU依赖(如果使用GPU版本)
如果你安装了GPU版本的NuGet包,要确保目标机器上有匹配的CUDA环境。例如,Microsoft.ML.OnnxRuntime.Gpu 1.16.3通常对应CUDA 11.x。你需要在目标机器上安装相应版本的CUDA Toolkit和cuDNN。对于开发机,安装好NVIDIA驱动和CUDA开发环境即可。
注意事项:如果你的应用打算分发到客户机器,而你不能控制其CUDA环境,那么打包GPU版本会非常麻烦。一种折中方案是:在代码中做运行时回退。即,尝试创建GPU Session,如果失败(抛出异常),则捕获异常并回退到创建CPU Session。这样可以编写一份代码,同时适应两种环境,但首次运行GPU失败会有性能损耗和延迟。
4. 核心推理类设计与实现
接下来,我们构建一个核心的推理类YoloOnnxProcessor,它将封装所有与ONNX Runtime交互的细节,对外提供简洁的接口。
4.1 类结构与初始化
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using System.Drawing; using System.Drawing.Imaging; public class YoloOnnxProcessor : IDisposable { private InferenceSession _session; private readonly string[] _classNames; // COCO数据集的80个类别名 private readonly float _confidenceThreshold = 0.5f; // 置信度阈值 private readonly float _iouThreshold = 0.45f; // 非极大值抑制(NMS)的IOU阈值 // 模型固定的输入尺寸 private const int ModelInputWidth = 640; private const int ModelInputHeight = 640; public YoloOnnxProcessor(string modelPath, bool useGpu = true) { // 初始化类别名(这里以COCO为例,如果是自定义模型需要替换) _classNames = new string[] { "person", "bicycle", "car", ... , "toothbrush" }; // 配置Session选项 SessionOptions options = new SessionOptions(); if (useGpu) { try { // 尝试启用CUDA执行提供程序 options.AppendExecutionProvider_CUDA(); Console.WriteLine("CUDA provider enabled."); } catch (Exception ex) { Console.WriteLine($"Failed to enable CUDA: {ex.Message}. Falling back to CPU."); useGpu = false; } } if (!useGpu) { // 使用CPU。也可以设置线程数等:options.IntraOpNumThreads = Environment.ProcessorCount; options.AppendExecutionProvider_CPU(); } // 创建推理会话(单例,开销大) _session = new InferenceSession(modelPath, options); // 验证模型输入格式(可选) var inputMeta = _session.InputMetadata; foreach (var name in inputMeta.Keys) { Console.WriteLine($"Input name: {name}, Shape: {string.Join(",", inputMeta[name].Dimensions)}"); } } public void Dispose() { _session?.Dispose(); } }在构造函数中,我们创建了InferenceSession。注意SessionOptions的配置,它决定了模型在哪里运行。AppendExecutionProvider_CUDA()就是告诉ONNX Runtime使用GPU。
4.2 图像预处理:从Bitmap到Tensor
这是将C#中常见的Bitmap或byte[]图像数据,转换为模型所需输入格式的关键步骤。YOLOv5的输入要求是:归一化到[0,1]的RGB三通道图像,尺寸为640x640,且数据布局是NCHW(即[Batch, Channel, Height, Width])。
private DenseTensor<float> PreprocessImage(Bitmap image) { // 1. 调整图像大小并保持宽高比(Letterbox) var (resized, xOffset, yOffset, scale) = ResizeAndPadImage(image, ModelInputWidth, ModelInputHeight); // 2. 将Bitmap数据转换为Tensor var inputTensor = new DenseTensor<float>(new[] { 1, 3, ModelInputHeight, ModelInputWidth }); var bitmapData = resized.LockBits(new Rectangle(0, 0, resized.Width, resized.Height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); unsafe { byte* scan0 = (byte*)bitmapData.Scan0.ToPointer(); int stride = bitmapData.Stride; for (int y = 0; y < resized.Height; y++) { byte* row = scan0 + (y * stride); for (int x = 0; x < resized.Width; x++) { // 内存布局是BGR inputTensor[0, 0, y, x] = row[x * 3 + 2] / 255.0f; // R通道 inputTensor[0, 1, y, x] = row[x * 3 + 1] / 255.0f; // G通道 inputTensor[0, 2, y, x] = row[x * 3] / 255.0f; // B通道 } } } resized.UnlockBits(bitmapData); resized.Dispose(); // 释放临时Bitmap return inputTensor; } // Letterbox缩放:保持原图比例,用灰色填充边缘 private (Bitmap resizedImage, int xOffset, int yOffset, float scale) ResizeAndPadImage(Bitmap source, int targetWidth, int targetHeight) { float scale = Math.Min((float)targetWidth / source.Width, (float)targetHeight / source.Height); int newWidth = (int)(source.Width * scale); int newHeight = (int)(source.Height * scale); Bitmap resized = new Bitmap(targetWidth, targetHeight, PixelFormat.Format24bppRgb); using (Graphics g = Graphics.FromImage(resized)) { g.Clear(Color.FromArgb(114, 114, 114)); // YOLO常用的填充色 int x = (targetWidth - newWidth) / 2; int y = (targetHeight - newHeight) / 2; g.DrawImage(source, x, y, newWidth, newHeight); return (resized, x, y, scale); } }预处理详解:
- Letterbox缩放:直接拉伸图片会导致目标变形。YOLO的标准做法是保持原图宽高比进行缩放,然后将缩放后的图像放在一个640x640的灰色画布中央。
xOffset,yOffset,scale这三个值必须记录下来,用于后续将模型输出的归一化坐标反算回原始图片上的坐标。 - 颜色通道与归一化:
Bitmap的Format24bppRgb格式在内存中是BGR顺序,而模型需要RGB。所以我们在赋值时调整了顺序(row[x*3+2]是R)。同时,将像素值从0-255除以255.0,归一化到0-1之间。 - 内存布局:我们创建了一个形状为
[1, 3, 640, 640]的DenseTensor,并按照NCHW的顺序填充数据。这里使用了unsafe代码和指针操作来直接访问Bitmap内存,这是性能最高的方式。如果对unsafe有顾虑,可以使用GetPixel方法,但速度会慢很多,不适合实时处理。
4.3 执行推理与输出解析
预处理得到Tensor后,就可以喂给模型了。
public List<DetectionResult> Detect(Bitmap image) { // 1. 预处理 var inputTensor = PreprocessImage(image); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", inputTensor) // “images”是输入节点名,需与模型一致 }; // 2. 执行推理 using IDisposableReadOnlyCollection<DisposableNamedOnnxValue> results = _session.Run(inputs); // 3. 获取输出 var output = results.First().AsTensor<float>(); // output.Shape 通常是 [1, 25200, 85] // 4. 解析原始输出(后处理) var rawDetections = ParseModelOutput(output); // 5. 应用非极大值抑制(NMS)过滤重叠框 var filteredDetections = ApplyNms(rawDetections, _iouThreshold); // 6. 将检测框坐标映射回原图 var finalResults = MapToOriginalImage(filteredDetections, image.Width, image.Height, inputTensor); return finalResults; }_session.Run是执行推理的核心方法,它返回一个包含所有输出节点的集合。对于YOLOv5,我们通常只关心第一个输出(即检测结果)。
解析模型输出 (ParseModelOutput):模型输出的[1, 25200, 85]张量,其中25200是模型在640x640网格上预设的锚框数量。每个锚框有85个值:
[0:4]: 边界框的中心点x, y,宽度w,高度h(都是相对于640x640输入图像的归一化坐标)。[4]: 该框包含目标的置信度(objectness score)。[5:85]: 80个类别的条件概率(conditional class probabilities)。
我们需要遍历这25200个预测,筛选出置信度高于阈值(如0.5)的框,并计算每个框的最终类别置信度(objectness * max(class_probability)),同时记录下类别索引。
非极大值抑制NMS (ApplyNms):经过上一步,我们可能得到成百上千个重叠的检测框。NMS的目的是保留最有可能的那个,抑制掉其他重叠度高的。标准流程是:
- 将所有检测框按置信度从高到低排序。
- 选取置信度最高的框,加入最终结果列表。
- 计算该框与剩余所有框的IoU(交并比)。
- 剔除IoU大于阈值(如0.45)的框(因为它们很可能是同一个物体)。
- 重复步骤2-4,直到没有剩余框。
坐标映射 (MapToOriginalImage):经过Letterbox预处理,模型检测的坐标是基于640x640填充后图像的。我们需要利用之前保存的xOffset, yOffset, scale,将这些坐标转换回原始输入图像的坐标系。
// 伪代码逻辑 originalX = (modelX * 640 - xOffset) / scale; originalY = (modelY * 640 - yOffset) / scale; originalWidth = (modelWidth * 640) / scale; originalHeight = (modelHeight * 640) / scale;同时,需要确保转换后的坐标不超出原始图像的边界。
4.4 定义结果类
public class DetectionResult { public Rectangle BoundingBox { get; set; } // System.Drawing.Rectangle public string Label { get; set; } public float Confidence { get; set; } }5. 在UI中集成与展示
推理类完成后,就可以在UI中调用了。这里以WPF为例,演示一个简单的图片检测并画框的流程。
5.1 后台检测逻辑
// 在ViewModel或后台代码中 private YoloOnnxProcessor _processor; private void LoadModel() { string modelPath = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"Models\yolov5s.onnx"); _processor = new YoloOnnxProcessor(modelPath, useGpu: true); } private void ProcessImage(string imagePath) { if (_processor == null) return; using (Bitmap bitmap = new Bitmap(imagePath)) { var results = _processor.Detect(bitmap); // 将结果传递给UI线程进行绘制 Application.Current.Dispatcher.Invoke(() => { DrawDetections(bitmap, results); }); } }5.2 前端绘制检测框
在WPF中,你可以在Canvas或Image控件上叠加绘制矩形和文本。
private void DrawDetections(Bitmap sourceBitmap, List<DetectionResult> results) { // 将Bitmap转换为BitmapImage以在WPF Image控件显示(略) // myImage.Source = bitmapImage; // 在Canvas上绘制 myCanvas.Children.Clear(); foreach (var detection in results) { // 绘制矩形框 Rectangle rect = new Rectangle { Width = detection.BoundingBox.Width, Height = detection.BoundingBox.Height, Stroke = Brushes.Red, StrokeThickness = 2, Fill = Brushes.Transparent }; Canvas.SetLeft(rect, detection.BoundingBox.X); Canvas.SetTop(rect, detection.BoundingBox.Y); myCanvas.Children.Add(rect); // 绘制标签文本 TextBlock label = new TextBlock { Text = $"{detection.Label} ({detection.Confidence:F2})", Foreground = Brushes.White, Background = Brushes.Red, FontSize = 12, Padding = new Thickness(2) }; Canvas.SetLeft(label, detection.BoundingBox.X); Canvas.SetTop(label, detection.BoundingBox.Y - 20); myCanvas.Children.Add(label); } }6. 性能优化与高级技巧
当基础功能跑通后,下一步就是考虑如何让它跑得更快、更稳、更省资源。
6.1 性能优化点
- 会话复用:重申一遍,
InferenceSession的创建非常耗时,务必全局单例复用。 - 输入Tensor复用:对于固定尺寸的输入,可以预分配输入
DenseTensor内存,在每次推理时复用,避免频繁的GC(垃圾回收)。 - 批量推理:ONNX模型支持批量输入。如果你有多张图片需要处理,可以将它们堆叠成一个
[batch_size, 3, 640, 640]的Tensor一次性进行推理,这比循环单张处理效率高得多,尤其在使用GPU时能充分利用其并行计算能力。 - 异步处理:对于UI应用,将耗时的
Detect方法放在Task.Run中异步执行,避免阻塞UI线程导致界面卡顿。 - 图片解码优化:如果图片来自文件或网络流,使用
System.Drawing的Bitmap构造函数可能不是最快的。可以考虑使用ImageSharp或SkiaSharp等更现代的库进行解码和预处理,它们性能更好,且不依赖GDI+。
6.2 处理动态输入尺寸
虽然我们在导出模型时使用了--dynamic参数,但在C#端处理动态尺寸需要一些技巧。你需要根据每张输入图片的实际尺寸,动态创建输入Tensor。同时,Letterbox计算中的xOffset, yOffset, scale也需要动态计算。核心逻辑不变,只是将ModelInputWidth/Height从常量变为由输入图片决定(但需是32的倍数)。
6.3 封装与扩展
将YoloOnnxProcessor类进一步封装,可以提供更友好的API,例如:
DetectAsync(Stream imageStream):支持流输入。DetectBatch(List<Bitmap> images):支持批量检测。- 事件
DetectionCompleted:用于异步通知。 - 属性
ConfidenceThreshold,IouThreshold:允许运行时动态调整。
7. 常见问题与排查实录
在实际部署中,你几乎一定会遇到下面这些问题。
7.1 模型加载失败
- 症状:创建
InferenceSession时抛出异常。 - 排查:
- 文件路径:确认模型文件路径正确,并且已“复制到输出目录”。
- 模型格式:用Netron打开ONNX文件,确认它是有效的。有时PyTorch导出会因算子不支持而失败。
- Opset版本:如果报错提示某些算子不支持,尝试在导出时降低
opset版本(如从13降到12)。 - CUDA环境(GPU版):如果使用GPU版本,确认CUDA、cuDNN已正确安装,且版本与ONNX Runtime GPU包匹配。可以在代码中
catch异常,并回退到CPU模式作为兜底。
7.2 推理结果为空或错乱
- 症状:能运行,但检测不到目标,或者框的位置完全不对。
- 排查:
- 预处理不一致:这是最常见的原因!确保你的预处理和模型训练/导出时的预处理完全一致。YOLOv5官方预处理包含了归一化(/255),但没有均值减法。如果你用了其他模型的预处理代码(如减均值除标准差),结果必然错误。
- 颜色通道:确认是RGB顺序,且归一化到[0,1]。
- 坐标映射错误:检查Letterbox缩放和坐标反算的逻辑。画图调试,把模型输出的原始框(在640x640上)和反算回原图的框都画出来,看对应关系是否正确。
- 置信度阈值:阈值设得太高(如0.9)会导致很多检测被过滤掉。先从0.25开始测试,逐步调高。
- NMS阈值:IoU阈值设得太低(如0.2)会过度抑制,导致一个物体只保留一个框;设得太高(如0.7)会导致多个重叠框残留。
7.3 内存泄漏
- 症状:长时间运行或处理大量图片后,程序内存持续增长。
- 排查:
- Dispose调用:确保
InferenceSession、Bitmap、Tensor等实现了IDisposable的对象在使用后都被正确释放(using语句或手动调用Dispose)。 - Tensor内存:ONNX Runtime在
Run方法中返回的DisposableNamedOnnxValue也需要Dispose。上面的示例代码中using语句确保了这一点。 - UI对象:在WPF中,动态添加到Canvas的图形元素,如果不再需要,也应该从
Children中移除,以便GC回收。
- Dispose调用:确保
7.4 性能不达标
- 症状:推理速度比预期慢很多。
- 排查:
- 是否真的在用GPU:在初始化时查看日志,确认
CUDA provider enabled.。也可以在任务管理器中查看GPU利用率是否在推理时升高。 - 预热:第一次推理通常较慢,因为涉及模型初始化、内核编译等。进行几次“热身”推理后再开始计时。
- 输入尺寸:确保输入给模型的Tensor就是640x640,不要在预处理中传递错误尺寸。
- Profiling:使用性能分析工具(如Visual Studio Profiler)找到热点。瓶颈很可能在图像预处理(Bitmap操作)或后处理(NMS循环),而不是模型推理本身。
- 是否真的在用GPU:在初始化时查看日志,确认
7.5 部署到无网络环境
- 方案:将整个输出目录(包含你的exe、依赖dll、模型文件、运行时库)打包即可。对于GPU版本,如果目标机器没有CUDA环境,你需要将CUDA相关的dll(如
cudart64_11.dll,cublas64_11.dll等)也一并拷贝到exe同级目录,但这通常很复杂且涉及许可问题。因此,对于不可控的环境,优先考虑使用CPU版本部署,虽然慢,但确定性最高。
整个流程走下来,你会发现最大的挑战往往不是调用ONNX Runtime的那几行代码,而是对模型输入输出格式的精确理解,以及前后处理环节与训练侧的对齐。这部分工作需要耐心和细致的调试。一旦打通,你就拥有了在C#生态中自由调用各种ONNX格式AI模型的能力,这无疑会为你开发的应用程序注入强大的智能。