C#用OpenCvSharp+ONNX Runtime部署YOLOv11分类模型
2026/9/8 21:41:08 网站建设 项目流程

简介:面向 C# WinForm 开发者的 YOLOv11 ONNX 图像分类部署源码,适合希望在桌面应用中集成深度学习能力的初中级开发者。项目测试环境为 VS2019 与 .NET Framework 4.7.2,使用纯 OpenCvSharp 4.8.0 完成图像加载、缩放归一化、模型推理和分类结果读取,不依赖 PyTorch、TensorFlow 等额外运行时,能够在传统桌面工具中直接落地图像识别。资源包共 44 个文件,压缩后 54.4MB,内含 10 个 cs 源码文件、11 个 dll 动态库、onnx 与 pt 格式的 YOLOv11 模型、若干演示图片、配置文件及 VS 工程文件,并包含应用入口、窗体界面、结果基类等模块,层次清楚,便于打开编译和对照学习。已有 1119 人学习下载。仔细阅读源码可掌握 WinForm 项目中接入 OpenCvSharp 的完整链路,理解 Yolov11ClsManager 如何封装模型加载与推理逻辑,以及分类结果对象在界面上的呈现方式;对复用代码搭建自己的图像分类工具、理解 ONNX 跨平台部署都很有帮助。

1. 项目概述:为什么我用纯 OpenCvSharp 做 YOLOv11 分类部署

先说结论:这是一个用 C# WinForms 写图像分类工具的真实落地案例,核心推理引擎是 OpenCvSharp + ONNX Runtime,模型用的是 YOLOv11 导出的 ONNX 格式。项目源码可以直接运行,能识别单张图片、批量文件夹,也可以接摄像头实时识别,适合做上位机、质检工具、教学演示这类场景。

图像分类的需求其实很常见:给一张图片,判断它是猫还是狗、是良品还是次品、是哪种花。传统做法是调 Python + PyTorch,但很多桌面工具、工业上位机是 C# 写的,这时候最顺手的就是 WinForms 界面配合 C# 推理。这套方案的好处在于:不需要装 Python 环境、不需要 GPU、不需要额外启动服务进程,一个 exe 就能跑起来,部署到没装任何开发环境的电脑上也没问题。

选型上我特意避开了 OpenCVSharp 之外的其他 C# OpenCV 封装。网上有人用 OpenCvSharp4.Windows 这个包,也有人用旧版的 OpenCvSharp3,两者 API 差异不大,但坑不一样,我会在后面的环境配置部分专门说清楚。整个项目中 OpenCvSharp 只负责图像读取、缩放、颜色转换这类预处理和后处理的图像操作,模型推理交给 ONNX Runtime,这个分工最清晰,也最容易排查问题。

这个项目适合谁来参考?如果你正在做 C# 上位机开发、WinForms 桌面工具,需要往程序里集成图像识别能力,或者你想把 Python 训练好的 YOLO 模型搬到 C# 端,不依赖 Python 运行时,那这篇文章基本上可以把你的路趟平。

2. 环境准备与依赖配置:踩过的版本坑

2.1 NuGet 包选型清单

项目依赖其实非常少,核心就三个包:

OpenCvSharp4 (版本 4.8.0.20230708 或更新) OpenCvSharp4.runtime.win (Windows 运行库,必须安装) Microsoft.ML.OnnxRuntime (推荐 1.16.3 或更新版本)

这里必须提醒一个老坑:OpenCvSharp4 和 OpenCvSharp4.Windows 这两个包不要混着装。OpenCvSharp4.Windows 是带原生依赖的,而 OpenCvSharp4 + OpenCvSharp4.runtime.win 是分离式。我一开始图省事直接装了 OpenCvSharp4.Windows,结果后来引用了其他库,DLL 加载顺序错乱,OpenCV 原生库一直报 "Unable to load DLL 'OpenCvSharpExtern'",折腾了半天。最后统一用 OpenCvSharp4 + runtime.win 组合,问题消失。

ONNX Runtime 版本上,我建议用 1.16.3 或 1.17.x,新版本对 YOLOv11 的算子支持更完整。如果你试过发现某些节点报 "Unsupported operator" 的话,优先检查是不是 ONNX Runtime 版本太老。

2.2 YOLOv11 模型的获取与转换

我自己用的是从 YOLOv11 官方仓库导出的分类模型,权重文件是 yolov11n-cls.pt,类别数是 1000(ImageNet)。如果你要用自己的数据集训练,导出命令是:

yolo export model=yolov11n-cls.pt format=onnx imgsz=224

这里有个细节:imgsz 参数必须和后面预处理代码里设置的尺寸一致,通常分类模型是 224,但有些自定义训练用 256 或 384,你要是导成 224 却在代码里 resize 到 384,模型直接输出一堆乱概率。导出成功后你会在同目录得到 yolov11n-cls.onnx 文件,以及一个可选的 metadata.yaml。

分类模型的 ONNX 输出形状是 [1, num_classes],不像检测模型有多个输出头,所以后处理极其简单。这也意味着整个推理管线可以非常轻量。

3. 图像预处理:分类模型的命门

图像分类的预处理流程是:读取图像 → Resize → 转 RGB → 归一化 → HWC 转 CHW → 转成模型输入格式。这四步每一步都有讲究,但坑最深的是两个:颜色通道顺序和归一化参数。

3.1 预处理完整代码

using OpenCvSharp; using System; public static class YoloPreprocess { public static float[] Preprocess(string imagePath, int inputSize = 224) { // 读取图像(注意中文路径问题,后面细说) using var mat = ReadImageSafe(imagePath); return Preprocess(mat, inputSize); } public static float[] Preprocess(Mat src, int inputSize = 224) { // 1. Resize 到模型输入尺寸 using var resized = new Mat(); Cv2.Resize(src, resized, new Size(inputSize, inputSize), 0, 0, InterpolationFlags.Linear); // 2. BGR 转 RGB using var rgb = new Mat(); Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB); // 3. 归一化到 0-1 rgb.ConvertTo(rgb, MatType.CV_32FC3, 1.0 / 255.0); // 4. HWC 转 CHW 并展平成一维数组 int channels = 3; int height = inputSize; int width = inputSize; float[] result = new float[channels * height * width]; // 方案A:使用 Mat 索引(容易理解但慢) // 方案B:直接用 unsafe 指针(快) unsafe { var data = (float*)rgb.Data; int spatial = height * width; // C# 的数组布局是 NCHW,需要把 HWC 数据按通道拆开 for (int h = 0; h < height; h++) { for (int w = 0; w < width; w++) { // 当前像素在 HWC 数据中的起始位置 int hwcIndex = (h * width + w) * 3; result[0 * spatial + h * width + w] = data[hwcIndex + 0]; // R result[1 * spatial + h * width + w] = data[hwcIndex + 1]; // G result[2 * spatial + h * width + w] = data[hwcIndex + 2]; // B } } } return result; } private static Mat ReadImageSafe(string path) { // 中文路径处理:Cv2.ImRead 在有中文路径时可能返回空 Mat if (System.IO.File.Exists(path)) { byte[] bytes = System.IO.File.ReadAllBytes(path); return Cv2.ImDecode(bytes, ImreadModes.Color); } throw new System.IO.FileNotFoundException("图片不存在", path); } }

3.2 为什么是 BGR 转 RGB 而不是反过来?

很多初学者在这块栽跟头:OpenCV 默认读图是 BGR 顺序,而 PyTorch 训练时用的是 RGB 顺序,YOLO 模型在 PyTorch 下训练时图像都是 RGB 的。如果你不转,直接把 BGR 数据喂给模型,相当于把 R 通道和 B 通道互换,模型输出会非常离谱,比如“猫”图片识别成“狗”的概率极高,而且完全没规律。Cv2.CvtColor 的 ColorConversionCodes.BGR2RGB 就是干这个的,不能省。

3.3 归一化:为什么不是减均值除方差?

YOLOv11 分类模型在导出时,预处理已经内置了缩放。官方推理代码中用的就是x /= 255,并没有做 ImageNet 的 mean/std 标准化(即不减去 [0.485, 0.456, 0.406])。这跟 ResNet 那些模型不一样,ResNet 需要减均值除方差,但 YOLOv5/v8/v11 的导出模型默认不需要。如果你拿 ResNet 的预处理逻辑套到 YOLOv11 上,输出概率同样会错乱。

所以最简单可靠的判断标准:看你导出模型用的源仓库官方推理代码。YOLOv11 官方分类预测代码就是简单除以 255,你就照做。

4. 模型推理:ONNX Runtime 接入与输出解析

4.1 推理核心代码

模型加载和推理我用的是 Microsoft.ML.OnnxRuntime,这个库本身是微软官方维护,性能和 C++ 版本差不多,C# 下调用非常方便。

using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class Yolo11Classifier : IDisposable { private readonly InferenceSession _session; private readonly int _inputSize; private readonly string[] _labels; public Yolo11Classifier(string modelPath, string[] labels, int inputSize = 224) { _inputSize = inputSize; _labels = labels; var options = new SessionOptions(); // CPU 推理推荐开启内存优化 options.EnableMemoryPattern = true; options.EnableCpuMemArena = true; // 如果模型文件有问题,这里会直接抛异常,方便排查 _session = new InferenceSession(modelPath, options); } public (int top1Index, float top1Score, float[] allScores) Infer(Mat image) { // 1. 预处理 float[] inputData = YoloPreprocess.Preprocess(image, _inputSize); // 2. 构造输入 Tensor var dimensions = new[] { 1, 3, _inputSize, _inputSize }; var tensor = new DenseTensor<float>(inputData, dimensions); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", tensor) }; // 3. 推理 using (var results = _session.Run(inputs)) { // 分类模型通常只有一个输出 var output = results.First().AsTensor<float>(); float[] scores = output.ToArray(); // 4. Softmax + TopK float[] probs = Softmax(scores); int top1Index = ArgMax(probs); float top1Score = probs[top1Index]; return (top1Index, top1Score, probs); } } private static float[] Softmax(float[] logits) { float max = logits.Max(); float sum = 0.0f; var exp = new float[logits.Length]; for (int i = 0; i < logits.Length; i++) { exp[i] = (float)Math.Exp(logits[i] - max); sum += exp[i]; } for (int i = 0; i < exp.Length; i++) { exp[i] /= sum; } return exp; } private static int ArgMax(float[] arr) { int idx = 0; float max = arr[0]; for (int i = 1; i < arr.Length; i++) { if (arr[i] > max) { max = arr[i]; idx = i; } } return idx; } public void Dispose() => _session?.Dispose(); }

需要注意,_session.Run(inputs)里传入的输入名 "images" 是从哪里来的?这个是模型输入节点的名字,不是随便写的。你可以在导出 ONNX 后用 Netron 工具打开模型查看,也可以直接用代码获取:

// 查看模型输入输出信息 foreach (var input in _session.InputMetadata) { Console.WriteLine($"Input: {input.Key}, dims: {string.Join(",", input.Value.Dimensions)}"); } foreach (var output in _session.OutputMetadata) { Console.WriteLine($"Output: {output.Key}"); }

我第一次跑通时就是在网上抄了一个示例代码,输入名写的是 "input",结果运行报错 "Failed to process input(s): Invalid Feed Input Name",改成 "images" 后就好了。这种问题用上面的代码一分钟定位。

4.2 Softmax 的必要性与数值稳定性

模型输出的一层叫 logits(未归一化的得分),范围可能是 -10 到 20 这种,不能直接当概率看。Softmax 的作用是把这组数压到 0~1 之间且总和为 1,这样 top1 的值才有“置信度”的意义。

Softmax 的朴素实现是exp(x_i) / sum(exp(x_j)),但 x 很大的时候 exp 会溢出为 inf。所以标准做法是先减去最大值再算 exp,这在数学上完全等价,但数值上稳定得多。上面代码里已经处理了,直接抄就行。

5. WinForms 界面开发:UI 刷新卡顿问题的解法

5.1 界面布局与控件

WinForms 界面我做得比较简洁:

  • 一个 PictureBox:显示要识别的图片
  • 一个 Button:选择图片文件
  • 一个 Button:批量识别文件夹
  • 一个 Label:显示识别结果(类别 + 置信度)
  • 一个 ListBox:显示 Top5 结果

界面布局用 TableLayoutPanel 包一层,可以实现简单的自适应缩放。WinForms 窗体缩放其实讲究很多,如果你直接把控件固定在左上角,窗体拉大后显示效果就很丑。我建议用 TableLayoutPanel 的百分比列宽来固定 PictureBox 区域,再配合Dock = Fill做剩余控件的填充,实测缩放效果还不错。

5.2 UI 卡顿问题:如何避免 WinForms 假死

很多人写 WinForms 程序时直接在按钮点击事件里同步调用推理,模型小还好,换个大模型或者批量识别时界面直接转圈圈假死。原因是推理操作占用了 UI 线程,Windows 消息循环被阻塞。解决方案很简单:用async/await+Task.Run把推理放到线程池里,用Control.Invokeawait回 UI 线程更新结果。

private async void btnSelectImage_Click(object sender, EventArgs e) { using var ofd = new OpenFileDialog(); ofd.Filter = "图片文件|*.jpg;*.jpeg;*.png;*.bmp"; if (ofd.ShowDialog() != DialogResult.OK) return; try { btnSelectImage.Enabled = false; lblStatus.Text = "识别中..."; // 异步执行推理,避免 UI 卡死 (int top1Index, float top1Score, float[] probs) result = await Task.Run(() => _classifier.Infer(Cv2.ImRead(ofd.FileName))); // 回到 UI 线程显示结果 lblResult.Text = $"识别结果:{_labels[result.top1Index]}({result.top1Score:P2})"; pictureBox1.Image = new Bitmap(ofd.FileName); // Top5 显示 listBoxTop5.Items.Clear(); var top5 = result.probs .Select((p, idx) => (prob: p, idx)) .OrderByDescending(x => x.prob) .Take(5); foreach (var item in top5) { listBoxTop5.Items.Add($"{_labels[item.idx]}:{item.prob:P2}"); } } catch (Exception ex) { MessageBox.Show($"识别失败:{ex.Message}"); } finally { btnSelectImage.Enabled = true; lblStatus.Text = "就绪"; } }

核心要点Task.Run内部不要碰任何 UI 控件,否则会报线程间操作无效的异常。所有 UI 更新放回 await 之后的代码段里,因为 await 默认会在原来的 SynchronizationContext(也就是 UI 线程)上继续执行。

5.3 摄像头实时识别的简单方案

如果你要接摄像头,同样建议在后台线程跑采集帧。初始化一个VideoCapture对象,用定时器或专用采集线程不断 Grab + Retrieve,然后丢到推理线程。由于实时场景对帧率有要求,我通常的做法是“跳帧推理”:每采集 5 帧只推理 1 帧,其余帧直接显示。CPU 推理下 YOLOv11n 分类模型单帧大约 20~40ms,加上预处理 10ms,一秒钟大约能跑 15~25 帧,跳帧后UI比较流畅。

private void timerCapture_Tick(object sender, EventArgs e) { if (_capture == null || !_capture.IsOpened()) return; using var frame = new Mat(); _capture.Read(frame); if (frame.Empty()) return; // 直接显示当前帧 var bmp = BitmapConverter.ToBitmap(frame); pictureBox1.Image?.Dispose(); pictureBox1.Image = bmp; // 跳帧推理 _frameCount++; if (_frameCount % 5 == 0) { _frameCount = 0; _ = Task.Run(() => { var (idx, score, _) = _classifier.Infer(frame); // 用 BeginInvoke 回 UI 线程,避免阻塞 lblResult.BeginInvoke(() => { lblResult.Text = $"摄像头识别:{_labels[idx]}({score:P2})"; }); }); } }

6. 常见问题与排查技巧实录

6.1 问题速查表

现象可能原因解决方法
输出概率全为0.001左右,且类别完全不对预处理颜色通道没转RGB,或归一化方式不对检查 CvtColor 和归一化代码
运行时提示 "Invalid Feed Input Name"输入节点名写错用代码输出 InputMetadata 查看真实名称
输出全部是 NaN 或超大数Softmax 溢出或模型输入尺寸不符检查 imgsz 和 Preprocess 的 inputSize 是否一致
OpenCvSharpExtern.dll 加载失败OpenCvSharp4 和 runtime.win 版本不一致统一两个包的版本号
中文路径下读取图片返回空 MatImRead 对中文路径兼容差用 File.ReadAllBytes + ImDecode 替代
WinForms 界面卡死推理放在了 UI 线程用 async/await + Task.Run
摄像头画面卡顿每帧都做推理跳帧推理,每 5 帧识别一次
部署到客户电脑上提示缺 DLL缺少 VC++ 运行库 或 未发布 Native 依赖发布时勾选“包含本机依赖”或手动带上 runtime.win 的原生库

6.2 三个容易忽略的细节

第一,Mat 转数组维度顺序。OpenCvSharp 的Mat.Data是 HWC 布局,而 ONNX 模型需要的输入是 NCHW。我在第一次写的时候直接调了mat.GetArray(out float[] data),拿到的数组是按 HWC 排的,结果全部识别错误,相当于把多维数组强行展平了。这个问题特别隐蔽,因为它不报错,只是输出结果完全不对。上面代码展示了正确的拆通道方式。

第二,PictureBox 的 Image 资源释放。WinForms 里 PictureBox 的 Image 属性如果反复赋值不 Dispose,内存会不断增长,长时间跑批量识别很容易爆内存。上面代码里每次更新前先pictureBox1.Image?.Dispose(),这种细节在桌面工具里非常重要,比什么性能优化都管用。

第三,Batch 大小。ONNX Runtime 支持动态 batch,但 YOLOv11 分类模型导出时默认 batch 是 1。如果你想一次推理多张图,导出时要加上batch=4这样的参数。如果你的场景是单张图片识别,就老老实实用 1,没必要追求动态维度,反而会增加内存占用。

7. 这套方案还能怎么扩展

最后分享一个我实际用过的扩展方向。项目跑通之后,我在接口上做了简单抽象,把Infer(Mat)的方法签名统一成接口,然后在下面扩展了两个重载:一个接收视频文件路径,逐帧抽取后推理;另一个接收 Byte[] 数组,方便从网络或数据库中直接拿图片字节流进行识别。这样整个工具就从一个纯看图的 Demo 变成了一个可复用的识别组件。

关于性能优化,如果后续觉得 CPU 推理慢,可以考虑 OpenVINO 的 Execution Provider。ONNX Runtime 是支持 CPU、CUDA、TensorRT、OpenVINO 等多家后端的,代码改动非常小,只需要在 SessionOptions 里加一行options.AppendExecutionProvider_OpenVINO(),但需要额外安装对应 NuGet 包(如Microsoft.ML.OnnxRuntime.OpenVINO)。这个我还没在正式项目里强制要求,因为加上之后部署复杂度高了不少,除非你的目标是 Intel 平台的工业电脑,否则先用 CPU 跑着,等真遇到性能瓶颈再升级,方向已经很明确了。

如果你也准备把手上的 YOLO 模型集成到 C# 项目里,建议先照着这个流程跑通最小 Demo,再逐步加摄像头、批量、线程池这些功能。分类模型是 YOLO 系列里最好部署的一种,代码量少、调试难度低,等你把它吃透了,再往检测、分割模型上走会顺手很多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询