
简介这份源码面向具备一定C#基础的开发者与计算机视觉学习者提供在.NET环境中借助ONNX Runtime运行Yolov8-OBB模型、实现旋转目标检测的完整方案可用于车辆、文字、遥感等倾斜物体的识别与定位。压缩包共300个文件约411.7MB包含dll动态库、xml配置、cs源码、onnx模型、nupkg依赖包及sln解决方案等覆盖从模型加载、推理到界面展示的完整工程结构。目前已有1522人学习下载。读者可参考其中的示例应用与依赖组织方式理解如何在C#中集成深度学习模型、处理旋转边界框输出并掌握ONNX跨框架部署的排错思路对深入计算机视觉与C#应用开发具有较高学习价值。1. C# 接 Onnx 跑 Yolov8-OBB旋转框检测到底比水平框多解决了什么如果你用 C# 做过工业质检、遥感影像或者文档版面分析大概率遇到过这种场景目标本身是斜的水平框一包进去背景占了一大半后处理算出来的长宽比、角度全是错的。Yolov8-OBB 就是冲着这个问题来的——它在检测头里多回归了一个角度参数输出的是带旋转角度的矩形框。而 Onnx 作为跨平台的推理中间格式配合 C# 的 OnnxRuntime能在不依赖 Python 环境的前提下把模型跑在 Windows 上位机、工控机甚至嵌入式设备上。这套组合的核心价值在于训练用 Python 侧的 ultralytics 生态部署用 C# 原生推理中间靠一次torch.onnx.export打通。适合谁适合已经有 YOLO 训练经验、需要在 .NET 环境里落地旋转检测的工程师也适合正在做 C# 上位机视觉模块、想把 Python 模型塞进产线软件的人。下面从模型导出、C# 推理、后处理解码到踩坑排查一步步拆开讲。2. 从 PyTorch 到 OnnxYolov8-OBB 导出时哪些参数决定了 C# 端能不能跑通2.1 为什么 OBB 的导出比普通检测多一层坑普通 YOLOv8 检测头输出的是[batch, 4nc, num_anchors]四个坐标加类别分数后处理做 NMS 就行。OBB 不一样它的输出维度里多了一个角度分量典型输出是[batch, 4nc1, num_anchors]其中多出来的那一维是旋转角。更关键的是ultralytics 在导出 OBB 模型时默认会把旋转框解码逻辑从中心点、宽高、角度还原成四个角点嵌进计算图里也可能保留原始输出让 C# 端自己解码这取决于导出时的simplify和opset设置。我一般会先确认模型输出层的形状。用 Netron 打开导出的 onnx 文件看最后一层输出是[1, 7, 8400]还是[1, 8400, 7]前者是通道优先后者是锚点优先。C# 端解析时如果维度顺序搞反拿到的就是一堆乱码数值。常见做法是导出时加simplifyTrue让 ultralytics 把解码逻辑固化进去这样 C# 端只需要做 NMS 和坐标映射不用自己实现角度还原。2.2 导出命令与关键参数逐条说明from ultralytics import YOLO # 加载训练好的 OBB 模型权重 model YOLO(yolov8n-obb.pt) # 导出为 ONNX指定输入尺寸和 opset model.export( formatonnx, imgsz1024, # 输入分辨率必须和 C# 端预处理一致 opset12, # opset 11 对旋转算子支持不稳建议 12 以上 simplifyTrue, # 固化解码逻辑减少 C# 端手写后处理 dynamicFalse, # 固定 batch1避免 C# 端动态维度处理 halfFalse # 工控机多数用 FP32FP16 需要显卡支持 )这段代码执行后会在权重同级目录生成.onnx文件。imgsz设成 1024 是因为旋转目标通常尺寸较大640 下小目标的角度回归容易糊。opset12是血泪经验opset 11 在部分 OnnxRuntime 版本上对RotatedRect相关算子支持不完整推理直接报NOT_IMPLEMENTED。simplifyTrue会把cv2.boxPoints那套逻辑转成 onnx 算子C# 端拿到的就是已经解码好的角点坐标省掉自己写旋转解码的麻烦。dynamicFalse是为了让 C# 端用固定尺寸创建Tensor动态轴在 C# 里处理起来很啰嗦除非你确实需要变长输入。导出完成后用一段 Python 脚本验证 onnx 模型能否正常推理确认输出形状和数值范围。这一步不做后面 C# 调不通就是两眼一抹黑。import onnxruntime as ort import numpy as np sess ort.InferenceSession(yolov8n-obb.onnx) # 构造一个假输入尺寸和导出时一致 dummy np.random.randn(1, 3, 1024, 1024).astype(np.float32) outputs sess.run(None, {sess.get_inputs()[0].name: dummy}) for i, out in enumerate(outputs): print(f输出 {i} 形状: {out.shape}, 数值范围: [{out.min():.3f}, {out.max():.3f}])如果输出形状是[1, 8400, 7]说明是锚点优先格式每行 7 个值分别是cx, cy, w, h, angle, conf, cls或者x1,y1,x2,y2,x3,y3,x4,y4的变体具体看导出时的 simplify 行为。数值范围如果出现大量负数或者超过图像尺寸的值说明解码逻辑没固化进去需要回头检查simplify是否生效。3. C# 端 OnnxRuntime 推理张量构造、会话创建与内存复用3.1 环境准备与 NuGet 包选择C# 跑 Onnx 靠的是Microsoft.ML.OnnxRuntime这个 NuGet 包。CPU 版直接装Microsoft.ML.OnnxRuntimeGPU 版装Microsoft.ML.OnnxRuntime.Gpu但 GPU 版需要本机有 CUDA 和 cuDNN版本匹配很讲究工控机上我一般先用 CPU 版跑通再换 GPU。另外图像预处理需要OpenCvSharp4和OpenCvSharp4.runtime.win这两个包配合使用负责 resize、归一化和通道转换。# 在项目目录下执行安装核心依赖 dotnet add package Microsoft.ML.OnnxRuntime --version 1.16.3 dotnet add package OpenCvSharp4 --version 4.8.0 dotnet add package OpenCvSharp4.runtime.win --version 4.8.0版本号不是随便写的OnnxRuntime 1.16.x 对 opset 12 的旋转算子支持最稳再高的版本在部分 Windows 7 工控机上会缺 DLL。OpenCvSharp 4.8 的Cv2.Dnn模块和 OnnxRuntime 不冲突但要注意OpenCvSharp4.runtime.win必须和主包版本一致否则运行时报DllNotFoundException。3.2 创建推理会话与输入张量using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; public class YoloObbDetector : IDisposable { private InferenceSession _session; private readonly int _inputSize 1024; public YoloObbDetector(string modelPath) { // 创建会话选项线程数根据 CPU 核心数调整 var options new SessionOptions(); options.IntraOpNumThreads 4; options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; _session new InferenceSession(modelPath, options); } public float[] Preprocess(Mat image, out float scale, out int padW, out int padH) { // 保持长宽比的 letterbox 缩放 int w image.Width, h image.Height; scale Math.Min((float)_inputSize / w, (float)_inputSize / h); int newW (int)(w * scale), newH (int)(h * scale); padW (_inputSize - newW) / 2; padH (_inputSize - newH) / 2; using var resized new Mat(); Cv2.Resize(image, resized, new Size(newW, newH)); using var padded new Mat(new Size(_inputSize, _inputSize), MatType.CV_8UC3, Scalar.All(114)); resized.CopyTo(new Mat(padded, new Rect(padW, padH, newW, newH))); // 转 RGB、归一化、HWC 转 CHW using var rgb new Mat(); Cv2.CvtColor(padded, rgb, ColorConversionCodes.BGR2RGB); var tensor new DenseTensorfloat(new[] { 1, 3, _inputSize, _inputSize }); for (int y 0; y _inputSize; y) for (int x 0; x _inputSize; x) { var pixel rgb.AtVec3b(y, x); tensor[0, 0, y, x] pixel.Item0 / 255f; tensor[0, 1, y, x] pixel.Item1 / 255f; tensor[0, 2, y, x] pixel.Item2 / 255f; } return tensor.ToArray(); } }预处理这段有几个参数必须和 Python 导出时对齐。_inputSize必须等于导出时的imgsz否则模型内部锚点尺寸对不上。letterbox 的填充值用 114 是 YOLO 系列的惯例用 0 填充会在边缘产生假目标。归一化除以 255 是 ultralytics 默认行为如果你训练时用了自定义归一化这里要同步改。IntraOpNumThreads设成 4 是保守值工控机 CPU 核心少设太高反而抢线程导致推理抖动。3.3 执行推理与输出解析public ListObbResult Detect(Mat image) { float[] inputData Preprocess(image, out float scale, out int padW, out int padH); var inputTensor new DenseTensorfloat(inputData, new[] { 1, 3, _inputSize, _inputSize }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_session.InputMetadata.Keys.First(), inputTensor) }; using var results _session.Run(inputs); var output results.First().AsTensorfloat(); // 输出形状 [1, 8400, 7] 或 [1, 7, 8400]根据实际调整 var dims output.Dimensions; int numAnchors dims[1] 7 ? dims[2] : dims[1]; int stride dims[1] 7 ? 1 : dims[2]; var detections new ListObbResult(); for (int i 0; i numAnchors; i) { float cx output[0, 0, i]; float cy output[0, 1, i]; float w output[0, 2, i]; float h output[0, 3, i]; float angle output[0, 4, i]; float conf output[0, 5, i]; if (conf 0.25f) continue; // 还原到原图坐标 float ox (cx - padW) / scale; float oy (cy - padH) / scale; detections.Add(new ObbResult(ox, oy, w / scale, h / scale, angle, conf)); } return NmsRotated(detections, 0.45f); }输出解析这里最容易翻车的是维度顺序。如果导出时simplifyTrue且模型输出是[1, 8400, 7]那么output[0, i, 0]才是第一个锚点的 cx而不是output[0, 0, i]。我一般会在代码里加一个形状判断分支根据dims[1]是否等于 7 来决定索引方式。置信度阈值 0.25 是起步值实际产线要根据漏检和误检的代价调整OBB 任务里角度回归的置信度通常比普通检测低可以适当降到 0.2。坐标还原时减 pad 再除 scale顺序不能反否则框会整体偏移。4. 旋转框 NMS 与角度解码C# 端手写后处理的三个关键决策4.1 旋转 IoU 为什么不能直接用水平框的算法水平框的 IoU 是两个矩形交集面积除以并集面积用Math.Max和Math.Min就能算。旋转框不行两个带角度的矩形交集是一个多边形需要先求角点再用多边形裁剪算法算交集面积。C# 里没有现成的旋转 IoU 库我一般用 Sutherland-Hodgman 多边形裁剪手写一个或者用 OpenCvSharp 的Cv2.RotatedRectangleIntersection后者返回交集多边形的顶点再算面积。private float RotatedIoU(ObbResult a, ObbResult b) { // 将中心点宽高角度转为四个角点 var ptsA GetCornerPoints(a); var ptsB GetCornerPoints(b); var interPts new ListPoint2f(); var status Cv2.RotatedRectangleIntersection( new RotatedRect(new Point2f(a.Cx, a.Cy), new Size2f(a.W, a.H), a.Angle), new RotatedRect(new Point2f(b.Cx, b.Cy), new Size2f(b.W, b.H), b.Angle), out Point2f[] intersectionPts); if (status RectanglesIntersectTypes.None) return 0f; float interArea Cv2.ContourArea(intersectionPts); float unionArea a.W * a.H b.W * b.H - interArea; return interArea / unionArea; }Cv2.RotatedRectangleIntersection的返回值有三种None表示不相交Partial表示部分相交Full表示一个完全包含另一个。只有Partial和Full时intersectionPts才有意义。角度单位是度不是弧度OpenCvSharp 的RotatedRect角度范围是[-90, 0)从模型输出的角度如果范围不同需要先归一化再传入。4.2 NMS 的排序策略与阈值选择旋转 NMS 和水平 NMS 的流程一样按置信度降序排列依次保留最高分框抑制与它 IoU 超过阈值的框。区别在于 IoU 计算换成了旋转版本计算量大约是三到五倍。8400 个锚点里经过置信度过滤通常只剩几十到几百个再两两算旋转 IoU 在 CPU 上也能接受但如果锚点数量上万建议先做一次粗筛只对中心点距离小于两者对角线之和的框对计算 IoU。private ListObbResult NmsRotated(ListObbResult dets, float iouThreshold) { var sorted dets.OrderByDescending(d d.Conf).ToList(); var keep new ListObbResult(); var suppressed new bool[sorted.Count]; for (int i 0; i sorted.Count; i) { if (suppressed[i]) continue; keep.Add(sorted[i]); for (int j i 1; j sorted.Count; j) { if (suppressed[j]) continue; if (RotatedIoU(sorted[i], sorted[j]) iouThreshold) suppressed[j] true; } } return keep; }iouThreshold设 0.45 是旋转检测的常用值比水平检测的 0.5 略低因为旋转框对角度敏感两个角度差几度的框 IoU 可能还有 0.6但实际是同一个目标的不同回归结果。如果发现同一目标出多个框降到 0.4如果相邻目标被误抑制升到 0.5。这个参数没有万能值必须拿实际图片跑一批看效果。4.3 角度归一化与长边定义Yolov8-OBB 输出的角度定义和 OpenCV 的RotatedRect不完全一致。ultralytics 训练时用的角度范围通常是[0, 90)表示框的旋转角度而 OpenCV 的RotatedRect角度是[-90, 0)且宽高定义可能互换。如果直接把模型输出的角度塞进RotatedRect画出来的框会转 90 度。常见做法是模型输出的w和h中w是长边还是短边取决于训练配置。我一般会加一个判断如果w h交换w和h同时角度加 90 度再归一化到[-90, 0)。这样保证RotatedRect的宽是长边角度在 OpenCV 的约定范围内。这一步不做NMS 的 IoU 计算会出错因为同一个目标的两个框可能因为宽高互换导致 IoU 算出来是 0。5. 避坑与排查C# 调 Onnx 跑 OBB 模型最常见的五类翻车5.1 推理报错 NOT_IMPLEMENTEDRotatedRect 算子不支持现象_session.Run抛出OnnxRuntimeException提示某个算子NOT_IMPLEMENTED算子名里带Rotated或NonMaxSuppression。原因导出时opset太低或者simplifyTrue把 NMS 也固化进去了而 OnnxRuntime 的 CPU 版本对某些 NMS 变体支持不完整。解决把opset提到 12 以上导出时加simplifyTrue但不要加nmsTrue。如果已经导出的模型改不了换 OnnxRuntime 版本1.16.x 对旋转相关算子支持比 1.14 好很多。实在不行就在 Python 侧把 NMS 去掉C# 端自己写。5.2 输出全是 NaN 或数值极大现象output里出现NaN或者数值超过 1e6。原因预处理归一化不对。模型训练时输入是0-1浮点C# 端如果忘了除以 255输入值在0-255经过几层卷积就爆了。另一种可能是 letterbox 填充值用了 0而训练时用的是 114。解决检查tensor[0, c, y, x]是否除以了 255检查填充Scalar是否设成 114。用同一张图在 Python 和 C# 各跑一次对比预处理后的张量数值差异应该在 1e-3 以内。5.3 框的位置整体偏移或缩放不对现象画出来的旋转框位置对但尺寸偏大或偏小或者整体往一个方向偏移。原因坐标还原时padW和padH减错了或者scale用反了。letterbox 的还原公式是原图坐标 (模型坐标 - pad) / scale如果写成(模型坐标 / scale) - pad就错了。解决拿一张已知目标位置的图在 Python 里打印还原后的坐标C# 里也打印逐值对比。注意padW和padH是整数除法会有半个像素的误差一般不影响但如果精度要求高用浮点计算。5.4 同一目标出多个框NMS 没生效现象一个目标周围有三四个旋转框置信度都差不多。原因旋转 IoU 计算错误或者角度归一化没做导致两个实际重叠的框算出来 IoU 为 0。解决先检查RotatedIoU函数用两个已知重叠的旋转框测试手动算一遍 IoU 对比。再检查角度是否归一化到[-90, 0)宽高是否统一成长边为宽。如果都对了还抑制不掉降低iouThreshold到 0.4 试试。5.5 工控机上推理速度慢单帧超过 500ms现象开发机上跑 50ms部署到工控机变成 500ms 以上。原因工控机 CPU 主频低、核心少IntraOpNumThreads设太高导致线程切换开销大。或者用了 GPU 版但工控机没有独立显卡回退到 CPU 后反而比纯 CPU 版慢。解决IntraOpNumThreads设成物理核心数的一半比如四核设 2。确认 NuGet 包是 CPU 版还是 GPU 版没有 N 卡就用 CPU 版。输入尺寸从 1024 降到 640 能提速约 2.5 倍但小目标角度精度会下降需要权衡。6. 把 OBB 模型塞进 C# 上位机的三个进阶技巧6.1 用 ArrayPool 复用输入张量内存每次Detect都new DenseTensor会触发 GC产线连续跑几个小时可能因为 GC 停顿导致丢帧。我一般用ArrayPoolfloat.Shared.Rent租一块固定大小的数组预处理时直接往里写推理完Return回去。这样输入张量的内存在整个会话生命周期内只分配一次。private readonly ArrayPoolfloat _pool ArrayPoolfloat.Shared; private float[] _inputBuffer; public YoloObbDetector(string modelPath) { _inputBuffer _pool.Rent(1 * 3 * _inputSize * _inputSize); // ... 其余初始化 } public void Dispose() { _pool.Return(_inputBuffer); _session?.Dispose(); }Rent返回的数组长度可能大于请求值所以写入时要用_inputSize计算索引不能依赖_inputBuffer.Length。Dispose里必须Return否则池子会不断重新分配失去复用意义。6.2 多模型并行时的会话隔离一个上位机可能同时跑检测、分割、分类多个 Onnx 模型。如果共用一个InferenceSessionRun方法是线程安全的但会串行执行。我一般给每个模型建独立会话用Task.Run并行调用但要注意SessionOptions里的IntraOpNumThreads要按模型数量分摊否则线程总数超过 CPU 核心数反而变慢。模型数量单模型线程数总线程数四核 CPU 建议144可以224推荐313留一核给 UI414勉强6.3 用固定测试图做回归验证每次改完预处理或后处理代码拿一张固定的测试图跑一遍把输出的框坐标和置信度打印出来和上一次的记录对比。差异超过 1e-3 就说明改动影响了结果。这个习惯帮我抓过好几次“以为只是改个日志格式结果动了归一化系数”的翻车。测试图最好选一张有代表性的包含不同角度、不同尺寸的目标存成test_obb.jpg放在项目里写个Debug方法一键跑通。我自己的习惯是模型导出后先在 Python 里跑一遍存下基准输出C# 端跑完对比数值对不上就不往下走。这个笨办法省掉了很多在产线上调试的时间。希望帮到你。本文还有配套的精品资源点击获取