
简介一份面向C# WinForm开发者的YOLOv11目标检测部署演示资料包配套ONNX模型与运行说明。资源基于VS2019和.NET Framework 4.7.2环境集成OpenCvSharp4.8.0与ONNX Runtime 1.16.2完整展示了从模型加载、图像预处理到推理结果展示的桌面端实现流程适合具备一定C#基础、希望将深度学习能力落地到Windows应用的开发者学习参考。整个压缩包约53.65MB共62个文件其中包含9个C#源码文件、14个动态库、7个XML配置文件及onnx模型文件等目录结构清晰便于按模块阅读和复用。目前已有2178人学习下载。借助源码可重点理解WinForm界面设计、图像处理、模型加载与推理调用等关键环节并可通过配套运行说明快速复现项目减少环境配置和集成验证的踩坑成本。1. 用 C# WinForms 跑 YOLOv11 ONNX为什么这个组合值得试做上位机的同行应该都有同感模型训练在 Python落地到客户机器上却常常被要求用 C# WinForms 写界面。YOLOv11 是现在目标检测领域绕不开的新模型而 ONNX 是打通 PyTorch 到 .NET 最省事的一环——这份演示源码加上配套模型正好把「C# WinForms 部署 YOLOv11 ONNX 模型」这条链路完整走了一遍从模型导出到 UI 显示检测框你不用再查一堆碎片博客。我拆过不少类似的工程这个包难得的是把运行说明也写清楚了适合刚接触 C# ONNX 的新手也能让老手快速验证模型效果。这篇文章就按我自己的踩坑顺序把怎么导模型、怎么写 C# 推理、哪些地方容易翻车一次说透。2. YOLOv11 的 ONNX 导出与模型结构先搞懂张量再写 C#2.1 从 PyTorch 到 ONNX导出命令与参数选择YOLOv11 官方是基于 ultralytics 库训练的导出 ONNX 不需要你自己写torch.onnx.export一行命令就能完成。我一般会用这样的脚本from ultralytics import YOLO model YOLO(yolo11n.pt) model.export( formatonnx, # 导出为 ONNX imgsz640, # 推理输入尺寸 dynamicFalse, # 固定 batch 和图像尺寸 opset12, # ONNX 算子集版本 simplifyTrue, # 使用 onnx-simplifier 化简 halfFalse # 保持 FP32CPU 上稳妥 )导出后会在同目录生成yolo11n.onnx。几个参数里我最关注的是dynamicFalse因为 C# 侧固定输入[1, 3, 640, 640]能省掉很多处理动态 shape 的麻烦。opset12是 ONNX Runtime 全平台都支持得比较好的版本如果你用的 Runtime 很老opset 太高会直接加载失败。simplifyTrue会去掉一些只对训练有用的冗余计算文件更小推理也快一点。export 完之后建议用 Netron 打开模型看一眼输入输出节点。不同版本的 ultralytics 导出的输出差别很大有的只有一个输出张量[1, 84, 8400]有的是三个输出分别返回 box、score、class。这一步不确认后面写 C# 解析就会抓瞎。2.2 输出张量的三种形态看懂融合头与拆分头YOLOv11 的 ONNX 输出按我遇到的至少有三类融合输出单个张量[1, 84, 8400]。其中 84 4框坐标 80COCO 类别数8400 是 3 个特征层上的预测框总数640×640 输入下是 80×80 40×40 20×20 8400。拆分输出VecOps 形式输出[1, 4, 8400]框、[1, 80, 8400]类别 logits等。自定义输出如果你用自己数据集训练类别数从 80 变成了别的大小84 这个数字也要跟着变。在 C# 里写解析之前我一般先打印一下输出维度using Microsoft.ML.OnnxRuntime; void InspectOutput(InferenceSession session) { var metadata session.OutputMetadata; foreach (var item in metadata) { var shape string.Join(,, item.Value.Dimensions); Console.WriteLine($输出名: {item.Key}形状: [{shape}]); } }如果打印出来是[1, 84, 8400]那么在访问数据的时候要注意Tensorfloat的下标顺序是[batch, channel, feature]也就是第 0 维是 batch第 1 维是 84第 2 维是 8400。如果你想按「每行一个候选框」的方式读取需要转置成[1, 8400, 84]否则后面写循环会很别扭。转置可以用两个 for 循环完成虽然慢一点但第一次调试时最直观。2.3 letterbox 预处理为什么是必须的YOLOv11 训练时用的是 letterbox 等比缩放把图像缩放到 640×640剩余区域填充灰色通常是 114但 RGB 下是 114,114,114。如果直接用new Bitmap(640, 640)然后拉伸绘制画面会被压变形检测精度会掉不少。这一步在 C# 里用Graphics.DrawImage就能做关键是计算缩放比例和 paddingfloat scale Math.Min((float)640 / srcWidth, (float)640 / srcHeight); int newW (int)(srcWidth * scale); int newH (int)(srcHeight * scale); int padX (640 - newW) / 2; int padY (640 - newH) / 2;推理完的坐标要先减去padX、padY再除以scale才能对应到原始图像上的像素位置。这个换算漏掉任一步检测框都会偏移。Netron 里看到的输入节点名通常是images归一化则是把像素值除以 255 后转换成一个[1, 3, 640, 640]的浮点张量通道顺序是 RGB。注意 OpenCV 的imread读出来是 BGR如果 C# 里用Bitmap.GetPixel读到的顺序是 RGB这个不要搞混。3. WinForms 工程搭建一个完整的 ONNX 推理流程3.1 项目结构、NuGet 包与运行环境我建议用 .NET 6 或 .NET 8 创建 WinForms 项目Microsoft.ML.OnnxRuntime对老 .NET Framework 的支持虽然存在但新版 YOLOv11 算子需要较新的 Runtime老框架上容易遇到 Dll 加载问题。热词里有人问 VS2015坦白说 VS2015 默认装的 .NET Framework 4.6 跑新版 Runtime 很吃力至少用 VS2019 更新到 4.7.2 以上。项目需要引入的包我按用途列个表NuGet 包名版本建议用途Microsoft.ML.OnnxRuntime1.17.1 及以上ONNX 模型推理OpenCvSharp4.Windows4.8.0图像缩放、格式转换可选System.Drawing.Common6.0.0WinForms 绘制检测框如果用 .NET 6 官方包管理在csproj里加入 PackageReference 即可。加载模型只靠new InferenceSession(onnxPath)这一句不需要额外初始化非常直接。整个项目结构很清晰MainForm.cs负责选图和画框YoloV11Detector.cs封装了预处理、推理、后处理。3.2 核心推理代码预处理、会话运行与后处理先定义一个检测结果类后面画框要用public class Detection { public float X1, Y1, X2, Y2; // 左上角、右下角坐标原图像素 public float Confidence; public int ClassId; public string ClassName; }然后核心的检测类我习惯把阈值设成公开字段调试时直接在属性窗口改。初始化模型很简单public YoloV11Detector(string modelPath, string labelPath) { _session new InferenceSession(modelPath); _labels File.ReadAllLines(labelPath); }注意File.ReadAllLines读进来的标签顺序必须和训练时的data.yaml里的 names 顺序一致否则检测框的类别名会错位。预处理里最麻烦的是把Bitmap变成DenseTensorfloat。下面这段代码把 640×640 的 Bitmap 按 CHW 排列填入张量private DenseTensorfloat BitmapToTensor(Bitmap bmp) { int size 640; var result new DenseTensorfloat(new[] { 1, 3, size, size }); int stride size * size; for (int y 0; y size; y) { for (int x 0; x size; x) { var pixel bmp.GetPixel(x, y); // 模型训练用的是 RGB所以通道顺序是 R, G, B result[0, 0, y, x] pixel.R / 255f; result[0, 1, y, x] pixel.G / 255f; result[0, 2, y, x] pixel.B / 255f; } } return result; }参数说明DenseTensorfloat的构造函数传入[1, 3, 640, 640]四个维度分别是 batch、通道、高、宽。GetPixel虽然慢但演示够用要提速可以用LockBits后面进阶部分再展开。推理运行的代码很短public ListDetection Detect(Bitmap image) { var originalW image.Width; var originalH image.Height; var (letterboxed, padX, padY, scale) Letterbox(image); var tensor BitmapToTensor(letterboxed); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, tensor) }; using (var output _session.Run(inputs)) { var data output.First().AsTensorfloat(); // 解析 data得到候选框 } }Letterbox返回padX、padY和scale这些用于坐标还原。_session.Run(inputs)的输入名images要和你 Netron 里看到的一致不一致可以改成实际名字。后处理这一步假设输出是[1, 84, 8400]融合格式。先找每个候选框的最高类别得分int numBoxes data.Dimensions[2]; // 8400 int numClasses data.Dimensions[1] - 4; // 80 for (int i 0; i numBoxes; i) { float maxScore 0; int maxClassId -1; for (int j 4; j data.Dimensions[1]; j) { float score data[0, j, i]; if (score maxScore) { maxScore score; maxClassId j - 4; } } if (maxScore _confidenceThreshold) continue; float cx data[0, 0, i]; float cy data[0, 1, i]; float w data[0, 2, i]; float h data[0, 3, i]; // 中心点转左上角右下角并还原到原图坐标 float x1 (cx - w / 2f - padX) / scale; float y1 (cy - h / 2f - padY) / scale; float x2 (cx w / 2f - padX) / scale; float y2 (cy h / 2f - padY) / scale; candidates.Add(new Detection { X1 x1, Y1 y1, X2 x2, Y2 y2, Confidence maxScore, ClassId maxClassId, ClassName _labels[maxClassId] }); }这一步最容易出错的是把data[0, j, i]写成data[0, i, j]。因为DenseTensor的下标就是按声明的顺序[0, 84, 8400]中间是 84最后是 8400领会了这个就不会乱。拿到候选框之后还要做 NMS非极大值抑制用来去掉重叠框。我一般用最简单的按置信度降序、循环删除高 IoU 框的做法private ListDetection NonMaxSuppression(ListDetection candidates, float iouThreshold) { var sorted candidates.OrderByDescending(c c.Confidence).ToList(); var result new ListDetection(); while (sorted.Count 0) { var best sorted[0]; result.Add(best); sorted.RemoveAt(0); sorted.RemoveAll(c IoU(best, c) iouThreshold); } return result; }IoU就是计算两个矩形交集面积除以并集面积这个不难但注意分数比较最好用 float 而不是 double避免隐性类型转换问题。3.3 异步与界面绘制让检测框流畅显示WinForms 里如果在按钮点击事件里直接跑推理窗口会卡死图像越大越明显。正确做法是用async/await配合Task.Run把推理放到线程池private async void DetectButton_Click(object sender, EventArgs e) { var bitmap (Bitmap)pictureBox.Image.Clone(); btnDetect.Enabled false; statusLabel.Text 检测中...; var detections await Task.Run(() detector.Detect(bitmap)); DrawDetections(detections); statusLabel.Text $检测到 {detections.Count} 个目标; btnDetect.Enabled true; }Bitmap.Clone()是为了防止 UI 线程访问图片时推理线程同时修改图片资源引发 GDI 错误。DrawDetections里用Graphics.DrawRectangle和DrawString画框和标签。这里有个细节Graphics创建的Pen和Brush用完要Dispose不然 GDI 句柄会泄漏长时间运行后界面绘制会越来越慢。提示如果检测对象是摄像头实时画面建议用PictureBox的Paint事件重绘而不是每次都新建 Bitmap。这个 demo 先做单张图片检测逻辑会更清晰。4. 避坑与常见问题部署 YOLOv11 ONNX 时最容易翻车的 5 个点4.1 输出张量维度不对解析直接数组越界现象程序抛IndexOutOfRangeException或者检测框乱飞、宽度高度为负数。原因YOLOv11 导出 ONNX 时不同版本的 ultralytics 生成的输出格式不同。有的输出[1, 84, 8400]有的输出[1, 8400, 84]还有的是拆分式多输出。我一开始按旧教程写data[0, i, j]越界后才发现实际维度是[1, 8400, 84]。解决拿到 ONNX 文件后第一步就用 Netron 看输出形状或者用上面提到的InspectOutput方法打印维度。写解析代码前先确认是「融合头」还是「拆分头」然后固定一种方式。4.2 预处理不一致检测精度全无现象同一个模型Python 里检测很准C# 里同一张图要么检不出要么框偏得离谱。原因三个细节一是没用 letterbox直接new Bitmap(640, 640)然后绘制拉伸图像变形二是通道顺序搞反模型训练用 RGB而Bitmap.GetPixel返回的顺序是 RGB但你又用了 OpenCV 的Mat导致变成了 BGR三是漏掉了归一化像素值没有除以 255。解决严格复现训练时的预处理链。训练时用 letterbox推理必须也用 letterboxpadding 用灰色填充归一化、通道顺序都要写对。建议把预处理单独封装成函数并用同一张图在 Python 端跑一遍对比输出。4.3 ONNX Runtime 版本太老模型加载直接失败现象new InferenceSession()抛DllNotFoundException或者提示Op Runtime error说找不到某个算子。原因YOLOv11 使用了一些较新的算子例如 C2PSA 相关的卷积结构老版本的 ONNX Runtime 不认识加载就会失败。有人还在用 VS2015 自带的 NuGet 包缓存版本停留在 1.7 这种远古版本。解决把Microsoft.ML.OnnxRuntime升级到 1.17.1 以上最好是 1.18 或 1.19。如果是 x86 目标平台注意对应引入Microsoft.ML.OnnxRuntime.Managed和本机库运行目录下要有onnxruntime.dll。4.4 UI 假死点一下按钮就转圈现象点击「检测」按钮后窗体无响应过几秒甚至十几秒才恢复像死机了。原因推理直接在 UI 线程执行_session.Run是同步阻塞调用CPU 耗时全卡在 UI 线程上。解决用Task.Run包住推理调用或者用BackgroundWorker。更新 UI 时注意用Invoke或者async/await在 UI 上下文继续执行。另外如果处理的是大图预处理中的GetPixel同样很耗时也要放在后台线程里。4.5 letterbox 的 padding 没还原检测框整体偏移现象检测框能看到但位置偏左上或右下越靠近图像边缘偏移越严重。原因推理结果里的坐标是 letterbox 后图像上的坐标直接画到原图上没做「减 padding、除以 scale」的逆变换。解决在Letterbox函数里保存padX、padY、scale解析框坐标时执行还原计算。特别注意scale取的是width和height两个缩放比中的较小值不能两个方向分别算。5. 从 demo 到可用性能优化与模型替换的进阶技巧如果只是跑通 demo前面几章就够了。但真要把这套逻辑用到工控机或客户电脑上还有三件事值得做。第一换 GPU 推理。把 NuGet 包换成Microsoft.ML.OnnxRuntime.Gpu然后在初始化会话时启用 CUDAvar sessionOptions new SessionOptions(); sessionOptions.AppendExecutionProvider_CUDA(0); _session new InferenceSession(modelPath, sessionOptions);注意 GPU 版本会额外引入 CUDA 和 cuDNN 依赖部署时要把对应 DLL 带上。如果客户机器没有 NVIDIA 显卡就别开 GPU否则会直接报错。稳妥做法是先检测是否存在 CUDA 设备不行就退回 CPU。第二替换成自己训练的模型。用model.export(formatonnx)导出你自己的权重后C# 端只需要改两个地方标签文件换成自己的names列表如果训练时改了imgsz把_inputSize同步改掉。如果你训练时类别数是N那么输出张量的第二维就是4 N解析循环里的numClasses要跟着变。换模型之后依旧先用 Netron 扫一眼输出别偷懒。第三用LockBits和SpanT省掉不必要的拷贝。GetPixel在 640×640 的循环里会跑很多次换成LockBits后速度能提升好几倍。另外DenseTensor.Buffer可以直接操作底层数组避免把float[]再拷贝一次到张量里。这些优化在 demo 里可以先不写但做成产品前值得补上。我自己的习惯是拿到任何新导出的 YOLOv11 ONNX先花两分钟在 Netron 里看输入输出节点再写 C# 端代码。不管在线还是离线这个动作从来没省过省了后面就要花更长时间调坐标偏移。希望这次拆包的经验能帮到你少走我走过的弯路。本文还有配套的精品资源点击获取