C# WinForm部署YOLO26-OBB旋转框检测ONNX模型实战
简介面向需要在 Windows 桌面端集成旋转目标检测能力的开发者这份源码演示了如何在 C# WinForm 中调用 YOLO26-OBB 的 ONNX 模型完成旋转框检测模型来自官方预训练权重转换导出时采用 opset 12已内置到压缩包中推理链路支持纯 CPU 运算无需安装 CUDA 和 cuDNN测试基于 VS2019、.NET Framework 4.8、onnxruntime 1.22.1 与 OpenCvSharp 4.11硬件门槛低适合普通办公电脑直接体验。压缩包为 7z 格式共 92 个文件大小约 70MB除完整的主程序工程含项目文件、解决方案、窗体页面、资源配置、编译配置外还包含 onnx 旋转检测模型、30 个 dll 动态库、若干 xml/config 配置说明及 txt 使用文档目录结构清晰便于按模块查阅。已有 131 人学习下载。通过工程中的检测管理类与调用示例可快速理解从模型加载、图像预处理、旋转框推理到结果绘制的完整流程也能参照说明用自己的模型替换官方权重只需保持 yolo26-obb 框架导出的格式即可继续复用现有界面与逻辑便于后续二次开发和功能扩展。1. C# 里跑 YOLO26-OBB 旋转框检测不是把矩形框加个角度那么简单做工业视觉和上位机的朋友应该都有同感普通 YOLO 的目标框是 axis-aligned 的矩形可到了 PCB 缺陷、工件定位、文本检测这些场景目标本身是斜着躺的矩形框会把大量背景包进来IOU 算出来虚高NMS 压完之后框的位置也不贴边。旋转框检测 OBBOriented Bounding Box就是来解决这个问题的而 YOLO26-OBB 把旋转框检测做进了 YOLO 系列的结构里配合 ONNX Runtime 在 C# WinForm 里部署推理速度能压到几十毫秒一帧。这份C# winform部署yolo26-obb旋转框检测的onnx模型演示源码模型说明.7z给的正是完整可跑的 C# 工程加转换好的 ONNX 模型今天我把整个部署链路从头拆到尾包括坐标系的坑和 NMS 的写法。适合谁主要是 WinForm 上位机开发者和做工业视觉集成的工程师——你要在 C# 里接 ONNX 模型但不想碰 Python 服务或者你已经在用 YOLO 做矩形检测想升级到旋转框又或者你被OpenCvSharp的RotatedRect绘制和GetRotationMatrix2D的坐标换算折腾过。这篇文直接把实现路径、参数含义和推理代码铺开照着抄就能跑。2. ONNX 模型导出与 OBB 输出头解析从 PyTorch 到 C# 的坐标变换2.1 旋转框检测的四种输出格式选对坐标系才能少改代码YOLO26-OBB 的输出和普通 YOLO 有本质区别。普通 YOLO 每个 anchor 输出cx, cy, w, h, score, classes而 OBB 版本输出的是cx, cy, w, h, angle, score, classes——多了一个角度参数。但这个角度怎么定义不同版本差异很大。PyTorch 版 YOLO26-OBB 的 angle 是弧度制范围在-pi/4到pi/4之间表示框的旋转角度相对于 x 轴的偏转。注意不是0到2*pi也不是-pi/2到pi/2。这个范围选择是为了保证角度和宽高组合唯一——你想想一个旋转 90 度的框如果把宽高也互换它其实和没旋转是一样的所以限定在 45 度范围内就能保证参数不冗余。导出 ONNX 后这个角度还是弧度但有些第三方转换脚本会偷偷把它改成角度制这就是第一个坑。我看过模型内部结构YOLO26-OBB 在 Detect 头里对 angle 分支用了sigmoid激活然后乘上pi/2再减去pi/4把输出映射到上面说的范围内。所以导出后你拿 Netron 看图angle 分支的输出范围是[-0.785, 0.785]左右。在 C# 里拿到这个值直接用就行不需要再做sigmoid逆运算——ONNX 里的激活函数已经帮算好了。2.2 导出前的预处理对齐letterbox的填充比例必须双向一致OBB 模型的预处理比普通分类模型多一个心眼。训练时用的是letterbox加灰边填充通常是 114 而不是 0把任意尺寸的图等比缩放到模型输入尺寸比如640x640。推理时如果缩放比例算错或者填充色不对检测精度会断崖式下跌而且不是全错是边缘目标错。常见的预处理顺序是读取图片 → 按长边等比缩放 → 计算填充量 → 在底部和右侧填灰边。C# 里用OpenCvSharp写的话Cv2.Resize之后用Cv2.CopyMakeBorder填充。注意填充边必须加到右下侧不是居中否则坐标偏移计算会多一套逻辑。// 1. 计算缩放比例模型输入 640x640取长边缩放 float scale Math.Min(640f / src.Width, 640f / src.Height); int newW (int)Math.Round(src.Width * scale); int newH (int)Math.Round(src.Height * scale); // 2. 等比缩放后用灰边填充到 640x640注意是右下填充 using var resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); int padW 640 - newW; int padH 640 - newH; using var padded new Mat(); Cv2.CopyMakeBorder(resized, padded, 0, padH, 0, padW, BorderTypes.Constant, new Scalar(114, 114, 114)); // 3. BGR - RGBHWC - CHW归一化到 0~1构造 1x3x640x640 输入 using var rgb new Mat(); Cv2.CvtColor(padded, rgb, ColorConversionCodes.BGR2RGB); // 这里直接挨个像素填充到 float 数组速度慢但直观实际可以用 Mat 的 ConvertTo 配合 Split 优化 float[] input new float[1 * 3 * 640 * 640]; int idx 0; for (int c 0; c 3; c) for (int h 0; h 640; h) for (int w 0; w 640; w) input[idx] rgb.AtVec3b(h, w)[c] / 255f;这段代码里的padW和padH在推理拿到结果后做坐标还原时要反向减回去。我见过有人用Cv2.Resize的Fx, Fy参数直接等比缩放结果因为取整误差导致填充量差 1 个像素——单张图没问题跑批量的时候坐标偶尔偏几个像素很玄学。稳妥做法是先算scale再手动Resize不用Fx。2.3 ONNX Runtime 推理会话配置SessionOptions的线程与内存优化C# 里跑 ONNX 用的是Microsoft.ML.OnnxRuntime这个 NuGet 包。配置会话时有个容易翻车的点SessionOptions的ExecutionMode默认是ORT_SEQUENTIAL串行对单图推理没问题但如果你想同时处理多路摄像头就得设成ORT_PARALLEL。IntraOpNumThreads控制单次推理内部算子并行的线程数InterOpNumThreads控制算子之间流水线并行的线程数。工业现场的内存限制比个人电脑严苛SessionOptions.AppendExecutionProvider_CPU是默认的但你可以通过设置EnableMemoryPattern false来关闭内存模式预分析——代价是推理会慢 5% 左右换来的是启动时不会因为内存碎片而崩。我在一个连续运行两个月的工控机上碰到过内存模式预分析失败的报错关掉之后就好了。// SessionOptions 配置 var sessionOptions new SessionOptions { ExecutionMode ExecutionMode.ORT_SEQUENTIAL, ExecutionOrder ExecutionOrder.DEFAULT, IntraOpNumThreads Environment.ProcessorCount / 2, InterOpNumThreads 1, EnableMemoryPattern false, // 工业现场稳定优先牺牲一点速度 LogSeverityLevel OrtLoggingLevel.ORT_ERROR }; using var session new InferenceSession(yolo26_obb.onnx, sessionOptions); // 输入输出名可以从 session.InputMetadata / OutputMetadata 拿到 string inputName session.InputMetadata.Keys.First(); string outputName session.OutputMetadata.Keys.First(); // 通常是 output0 using var inputTensor new DenseTensorfloat(input, new[] { 1, 3, 640, 640 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensorfloat(inputName, inputTensor) }; using var results session.Run(inputs); var output results.First().AsTensorfloat();注意这里session.Run返回的DisposableNamedOnnxValue一定要using释放否则内存会一直涨。output的维度通常是[1, 215, 8400]或类似结构——215 是 5坐标角度 210类别数的组合8400 是不同尺度特征图80x80 40x40 20x20展平后的总预测数。当然不同模型的类别数和 anchor 数不同得先PrintShape确认。2.4 输出张量后处理从[1,215,8400]到候选框列表拿到output后要做的事是转置、阈值过滤、反算坐标。output的布局是[batch, channels, anchors]而我们习惯的写法是[anchors, channels]所以先转置。// output shape 假设 [1, 215, 8400]先把 batch 维度去掉再转置 int numAnchors output.Dimensions[2]; int numChannels output.Dimensions[1]; float[,] transposed new float[numAnchors, numChannels]; for (int i 0; i numAnchors; i) for (int j 0; j numChannels; j) transposed[i, j] output[0, j, i]; // 前 5 个通道是 cx, cy, w, h, angle后面是类别分数 float scoreThreshold 0.35f; var candidates new Listfloat[](); for (int i 0; i numAnchors; i) { float objScore transposed[i, 4]; // 有些模型没有独立 obj直接取类别分数最大值看模型而定 float maxCls 0; int clsId -1; for (int c 5; c numChannels; c) { if (transposed[i, c] maxCls) { maxCls transposed[i, c]; clsId c; } } float finalScore maxCls; // 如果模型有 obj 分支则 finalScore objScore * maxCls if (finalScore scoreThreshold) continue; candidates.Add(new float[] { transposed[i, 0], transposed[i, 1], transposed[i, 2], transposed[i, 3], transposed[i, 4], finalScore, clsId }); }这里的clsId是类别索引后面绘制和统计要用。注意如果模型输出里带独立的 objectness 分数有些 YOLO 变体在 OBB 里也保留了 obj 分支最终分数要乘上它否则小目标会误检。我的经验是从[1,215,8400]的形状能看出模型有没有 obj——如果没有 obj通道数应该是5 numClasses如果有 obj则是6 numClasses。2.5 坐标尺度还原一个scale和两个pad的关系推理得到的cx, cy, w, h都是在 640x640 输入坐标系里的要映射回原图必须做逆letterbox。公式是// 假设原图宽度为 srcW高度为 srcH float scale Math.Min(640f / srcW, 640f / srcH); int newW (int)Math.Round(srcW * scale); int newH (int)Math.Round(srcH * scale); int padW (640 - newW) / 2; // 注意这里是居中填充的算法 int padH (640 - newH) / 2; float cx_orig (cx - padW) / scale; float cy_orig (cy - padH) / scale; float w_orig w / scale; float h_orig h / scale; // angle 不用缩放但要确认是弧度制这里有个大坑letterbox的填充方式。YOLOv5 之后的多数据增强版本默认是居中填充上下或左右各填一半但有些训练脚本用的是右下填充。如果你的模型是从 Ultralytics 官方仓库导出的用的是居中填充如果是从第三方仓库导出的可能是右下填充。判断方法很简单拿一张长宽比极端比如 1920x1080的图片跑一次推理看检测框在原图上是不是偏的。偏了就把padW和padH改成0和pad的关系重算一次就好。我在代码里统一写成居中填充的算法因为这是官方 YOLO 系列的标准做法模型说明文档里没有特别标注就用这个。3. C# WinForm 部署完整链路模型加载、异步推理与画面叠加3.1 部署架构和文件清单源码包里到底有什么这份资源解开后核心文件分三块第一块是 ONNX 模型本身或者可能自带导出脚本第二块是 C# WinForm 工程包含窗体代码、推理封装类和绘制工具类第三块是说明文档里面有模型训练时的类别清单和输入输出 shape 对照表。用 Visual Studio 2022 打开工程后NuGet依赖主要有Microsoft.ML.OnnxRuntime和OpenCvSharp4或者OpenCvSharp4.Windows。如果工程打开后报找不到依赖先检查是否在 NuGet 包管理器里还原过——别急着改代码这是最常见的首次打开翻车点。3.2 推理类封装把 ONNX 会话和预处理/后处理隔离工程头顶级的做法是建一个ObbDetector类把InferenceSession、预处理、推理、后处理封装进去UI 层只负责拿图片、显示结果。这样切换模型或者调整阈值的时候不用动窗体代码。public class ObbDetector : IDisposable { private InferenceSession _session; private float _scoreThreshold; private int _inputSize 640; public ObbDetector(string modelPath, float scoreThreshold 0.35f) { _scoreThreshold scoreThreshold; var opts new SessionOptions { EnableMemoryPattern false }; _session new InferenceSession(modelPath, opts); } public ListRotatedRectResult Detect(Mat image) { // 1. letterbox 预处理 var (inputTensor, scale, padW, padH) Preprocess(image); // 2. 推理 using var results _session.Run(new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensorfloat(images, inputTensor) }); var output results.First().AsTensorfloat(); // 3. 后处理 坐标还原 var boxes Postprocess(output, scale, padW, padH, image.Width, image.Height); return boxes; } }这个类的好处是线程安全——InferenceSession.Run本身是线程安全的UI 线程和后台推理线程可以共用同一个Detector实例不用每次 new 一个 session。我见过有人每个摄像头线程都单独 new 一个InferenceSession内存直接翻倍工控机上跑三个月必挂。3.3 WinForm 界面集成PictureBox刷新与双缓冲防闪烁实时检测里最容易翻车的不是推理是 UI 绘制。PictureBox默认的双缓冲如果不设置视频帧率超过 15fps 时画面会闪到没法看。解决方案有两个一个是把PictureBox换成自定义控件并设置DoubleBuffered true另一个是直接在OnPaint里用Graphics绘制不依赖PictureBox.Image属性。public class VideoPanel : Panel { public VideoPanel() { DoubleBuffered true; SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); } private Bitmap _frame; private ListRotatedRectResult _boxes new(); public void UpdateFrame(Bitmap frame, ListRotatedRectResult boxes) { lock (_locker) { _frame frame; _boxes boxes; } Invalidate(); // 触发 OnPaint } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); lock (_locker) { if (_frame ! null) e.Graphics.DrawImage(_frame, 0, 0, Width, Height); foreach (var box in _boxes) DrawRotatedBox(e.Graphics, box); } } }这段代码的精髓是lock加Invalidate。推理线程把最新帧和检测结果丢进来UI 线程只管画二者互不阻塞。如果你直接在推理线程里调用pictureBox.Image ...会被跨线程访问控件的异常或者卡顿恶心到。lock锁的对象_locker记得声明成private readonly object _locker new();别用this锁避免和外部锁冲突。3.4 绘制旋转框RotatedRect画法而不是Rect画法拿到cx, cy, w, h, angle之后OpenCvSharp 里没有直接用点集绘制旋转框的 API得用RotatedRect配合Points()方法。// 用推理得到的 cx_orig, cy_orig, w_orig, h_orig, angle 构造旋转矩形 var rotatedRect new RotatedRect( new Point2f(cx_orig, cy_orig), new Size2f(w_orig, h_orig), angle * 180.0f / (float)Math.PI // 注意Cv2 用角度制ONNX 输出是弧度制 ); Point2f[] vertices rotatedRect.Points(); // 返回四个顶点顺序不定 for (int i 0; i 4; i) { Cv2.Line(image, vertices[i], vertices[(i 1) % 4], new Scalar(0, 0, 255), 2); }注意RotatedRect的angle是角度制0~360而 ONNX 输出是弧度制且范围在-45°~45°必须乘以180/π转换。差这一个转换画出来的框会偏 90 度或者斜着飞。我在现场调式时第一次就直接用弧度传进去结果框是横着的检查半天才想到单位问题——这种低级错误很常见。3.5 异步任务与CancellationToken别让 UI 卡住推理放进Task.Run是基本操作但注意取消机制。工业现场如果用户点关闭窗口时推理线程还在跑而InferenceSession已经被Dispose会直接抛ObjectDisposedException。血泪经验是关闭窗体前先Cancel再等待任务结束最后才释放 session。private CancellationTokenSource _cts new(); private async Task InferenceLoopAsync() { while (!_cts.IsCancellationRequested) { var frame CaptureFrame(); // 从摄像头或文件读图耗时小的操作放 UI 线程外 var boxes await Task.Run(() _detector.Detect(frame), _cts.Token); _videoPanel.UpdateFrame(frame, boxes); await Task.Delay(30, _cts.Token); // 约 33fps } } protected override void OnFormClosing(FormClosingEventArgs e) { _cts.Cancel(); // 等待推理循环退出后再 Dispose base.OnFormClosing(e); }这里await Task.Delay带着_cts.Token是关键否则取消后线程还会空转几十毫秒。FormClosing事件里只发取消信号不直接Disposesession等任务确认退出后在一个finally块里释放——具体实现可以用await等待任务完成再继续关窗体。4. 避坑指南OBB 部署中的五个高频翻车点4.1 角度单位混用弧度 vs 角度现象检测框位置正确但角度明显不对有时横躺的物体画成了竖着的框。原因ONNX 模型输出是弧度制范围[-π/4, π/4]而 OpenCvSharp 的RotatedRect需要用角度制。还有个容易忽略的细节PyTorch 里的角度正方向是逆时针x 轴正向为基准而 OpenCV 的RotatedRect角度正方向是顺时针。所以即使单位转换对了方向也可能反。解决先angle_deg angle_rad * 180 / π如果框整体转了 90 度或对称翻转就把角度取负试试。我一般用一张已知方向的测试图比如横着的矩形做一次冒烟验证角度对了再上产线。4.2letterbox填充颜色不一致现象边缘小目标漏检中心区域正常而且换一张分辨率不同的图漏检位置不一样。原因训练时用 114 灰边填充推理时用了Scalar(0)黑色填充。模型在训练时从没见过黑边输入边缘特征的响应会异常。解决把填充颜色改成Scalar(114, 114, 114)。如果你不知道训练时用的值看模型说明文档没有就优先试 114。另外注意是 BGR 还是 RGB——OpenCV 里Scalar默认是 BGRScalar(114,114,114)三个通道等值就没这个困扰但如果是彩色填充通道顺序错了模型输出会全错。4.3 NMS 没做就输出重复框现象同一个目标被多个高重叠检测框覆盖角度相似但坐标略偏。原因直接把阈值过滤后的所有候选框返回没有做非极大值抑制。OBB 的 NMS 和普通框 NMS 实现不一样不能直接套用Cv2的NMSBoxes它只支持 axis-aligned 的Rect得用旋转矩形的 IOU 计算。解决用RotatedRect的Intersection计算交点多边形再算面积或者对齐中心点后用Cv2.SimpleBlobDetector之类的间接法。没有现成OpenCvSharp内置 OBB-NMS自己写一个核心思想是用角度差先分组组内再按分数和重叠面积抑制。4.4 模型输入输出名字写死现象换了一个同样结构的 ONNX 模型就报错提示输入名字不存在。原因不同导出工具给 ONNX 节点的命名规则不一样有的叫images有的叫input有的叫x输出名也可能是output0、scores或者detections。解决不要硬编码名字启动时用session.InputMetadata.Keys和session.OutputMetadata.Keys动态读取。这样换模型只需改路径不用改代码。我在封装ObbDetector时就是构造里取一次名字存字段之后Run用它。4.5 CPU 推理线程数设置不当导致卡顿或延迟波动现象推理速度不稳定时快时慢CPU 占用率时高时低延迟抖动大。原因IntraOpNumThreads设置过大时线程频繁切换导致缓存不友好设置过小时单个算子并行度不够。工业现场 CPU 还要跑其他程序全核推理会抢占资源。解决IntraOpNumThreads设为物理核数的一半左右并且在文档里注明“产线部署建议把推理线程优先级设为ThreadPriority.BelowNormal”避免和 HMI 刷新抢 CPU。我实测过同一台 i5-8500 上开 6 线程反而不如开 3 线程稳定延迟方差小了一个数量级。5. 进阶验证与性能优化从能跑到跑得快5.1 用OnnxRuntime的RunOptions做多实例批处理如果你要同时处理两个摄像头可以不用开两个推理线程而是把两帧拼成batch2的输入一次跑完。DenseTensor的维度变成[2,3,640,640]输出会多一个维度。批量推理的吞吐量比单张推理高一倍多一些因为模型内部算子可以复用——不是严格两倍内存带宽有瓶颈但基本不会低于 1.6 倍。// 伪代码示意构造 batch2 输入 float[] batchInput new float[2 * 3 * 640 * 640]; // 把 frame1 填到 [0, :, :, :]frame2 填到 [1, :, :, :] var tensor new DenseTensorfloat(batchInput, new[] { 2, 3, 640, 640 }); using var results _session.Run(new[] { NamedOnnxValue.CreateFromTensorfloat(inputName, tensor) }); var output results.First().AsTensorfloat(); // output shape: [2, 215, 8400] 或类似注意后处理要按 batch 维分开循环坐标还原用的scale和pad要分别对应各自原图的尺寸。5.2 精度和速度的权衡float32转float16推理OnnxRuntime 在 CPU 上支持float16但不是所有算子都有 fp16 实现转不好反而更慢。通用经验是Intel CPU 的工控机上fp32 就够如果你的目标机器有 AVX512 或者 GPU再用 fp16。别迷信 int8 量化——OBB的 angle 分支对量化误差特别敏感int8 量化后预测框角度会整体偏移 1~2 度放在高精度定位场景就废了。这份资源如果带了量化说明按说明走没带的话别自己量化。5.3 验证方法用已知旋转角度的人工合成图做冒烟测试我每次部署完 OBB 模型必做一件事用代码生成一张包含已知旋转角度的矩形块图片跑模型看检测框的角度误差。// 生成一张有已知旋转矩形的测试图 int imgSize 640; using var testImg new Mat(imgSize, imgSize, MatType.CV_8UC3, new Scalar(255, 255, 255)); Cv2.FillPoly(testImg, new Point[] { new Point(200, 150), new Point(400, 150), new Point(400, 250), new Point(200, 250) }, new Scalar(0, 0, 0)); // 旋转 30 度 using var rotated new Mat(); Cv2.WarpAffine(testImg, rotated, Cv2.GetRotationMatrix2D(new Point2f(300, 200), 30, 1.0), new Size(imgSize, imgSize));跑推理后如果模型给出的angle换算成角度接近 30 度±1 度说明角度定义正确如果输出是-60度或120度说明方向多义性处理出了问题——这种多义性在 OBB 里很常见因为你用(w, h, angle)和(h, w, angle90°)描述的是同一个旋转矩形。解决方法是后处理时做角度归一化angle (angle 180) % 180把角度映射到[0, 180)这样细长物体的朝向不会因为宽高互换而出现 90 度跳变。5.4 现场部署习惯日志里打印推理耗时和坐标结果说实话OBB 部署最怕的不是模型跑不出来而是跑出来的框偶尔偏移但系统不报错。我养成一个习惯日志里对每帧打印inference_time_ms和检测框的cx, cy, angle写到本地 CSV。线上抽查时如果发现角度突然跳出合理范围就能回查是模型退化还是输入异常。这是很土但真有效的做法。从那以后我每次部署 OBB 模型都会强制走一遍冒烟测试日志流程这份资源里如果能附带测试图片最好没有的话用上面的方式自己造图也一样踏实。希望帮到你。本文还有配套的精品资源点击获取