简介:面向C# Winform开发者的YOLO26-OBB旋转框检测部署实例,在.NET Framework 4.8环境下即可加载ONNX模型完成带角度的目标识别,无需CUDA/CuDNN,CPU就能直接推理。资源基于官方yolo26n-obb.pt转换得到ONNX模型,配套可运行的演示工程,适合需要集成旋转框检测能力的中高级.NET工程师参考。包体共92个文件,核心包括C#源码、DLL依赖库和ONNX模型文件,另有XML文档、配置文件、Visual Studio解决方案、调试符号等辅助内容,整体约70MB。依赖库已随包提供,省去手动配置环境的工作,目录结构清晰,源码、模型与使用说明分开放置,可快速编译调试,也能直接替换自训练的YOLO26-OBB模型进行验证。目前已有131人学习下载,说明文档覆盖VS2019、onnxruntime 1.22.1、OpenCvSharp 4.11.0等环境组合,并给出模型转换命令与部署步骤,可帮助读者少走弯路。运行演示即可直观看到旋转框标注结果,也能基于源码二次开发,用于遥感影像、工业质检等旋转目标检测场景,是一份从环境配置、模型转换到界面展示都覆盖的完整参考实现。
1. C# WinForms部署YOLO26-OBB旋转框检测:这份onnx演示源码为什么值得你跑一遍
旋转框检测和普通检测的差别,用一个字说清就是“角度”。普通框输出(x, y, w, h)四项,旋转框多一个θ,但就这一个量,部署时能把人折腾疯。后处理里要写旋转NMS,角度定义还分度数、弧度、开区间、闭区间,一个地方搞不对,画出来的框就是斜着飞的。与其自己从零啃,不如拿一份已经跑通的 C# winform 工程直接改。这份压缩包把 yolo26-obb 的 onnx 部署链路全打通了:打开 WinForms 界面选图、ONNX Runtime 推理、旋转框后处理、PictureBox 上画框,源码都在里面,还自带一个模型和说明文件,非常适合遥感、工业质检、文档扫描这类非水平目标的桌面检测工具开发。包不大,解压就能编译,省下的是你对着黑匣子猜角度的几周时间。
2. 解包看资源:理解OBB模型输出格式与C#侧ONNX Runtime初始化
先说结论:拿到压缩包别急着用 VS 双击运行,先把说明文档翻一遍,再用代码把模型的输入输出打印出来,这两步能帮你避开 90% 的部署问题。后面我就按“解压看什么 → 输出张量含义 → 会话初始化”这个顺序讲。
2.1 解压后的文件结构落到哪些文件
这个演示包解压后,典型的布局是这样:
C#_YOLO26_OBB_Demo/ ├── YOLO26ObbDemo.sln ├── YOLO26ObbDemo/ │ ├── YOLO26ObbDemo.csproj │ ├── Form1.cs │ ├── Form1.Designer.cs │ ├── Program.cs │ ├── Models/ │ │ └── yolo26_obb.onnx │ └── 说明文档.md先打开说明文档,确认模型导出的框架版本和输入尺寸,比如640x640还是1024x1024,以及模型期望的颜色通道顺序。很多导出的 OBB 模型虽然训练时用的是 RGB,但 C# 里用 OpenCvSharp 读图是 BGR,如果代码里没有做通道反转,检测精度会明显下降,而且下降得很隐蔽——不是不检测,而是置信度偏低、框偏小。
再看Form1.cs里的按钮事件。典型的调用顺序是:OpenFileDialog选图 →Cv2.ImRead读图 → 预处理 →session.Run→ 后处理 → 在 PictureBox 里显示。整个链路在后面两章会逐段展开,这里你只需要把入口找到,给自己一个总览。如果说明文档里还有模型在哪些图像上验证过,建议顺手记一下,后面测试时优先用同类的图,方便对照精度是否正常。
2.2 yolo26-obb模型的输出张量:每行不只是cx、cy、w、h
普通 YOLO 检测模型的输出通常是[batch, num_anchors, 4 + 1 + num_classes],即每个 anchor 由中心点坐标、宽高、置信度、类别得分组成。OBB 模型在几何参数里加了角度,常见的输出维度变成[batch, num_anchors, 7]。以单类检测为例,7 个数的含义如下:
| 列索引 | 含义 | 典型值范围 |
|---|---|---|
| 0 | cx,旋转框中心 X | 0~640 |
| 1 | cy,旋转框中心 Y | 0~640 |
| 2 | w,旋转框宽度 | 0~640 |
| 3 | h,旋转框高度 | 0~640 |
| 4 | angle,旋转角度 | 角度制或弧度制取决于导出脚本 |
| 5 | objectness,目标存在得分 | 0~1 |
| 6 | class score,类别得分 | 0~1 |
num_anchors这个维度在动态导出时是不固定的,具体数量取决于模型输入尺寸和检测头下采样倍数,常见是 8400(YOLO 系列三个尺度头加起来的结果)。所以 C# 侧解析时不要写死行数,用输出的实际维度去遍历。后面演示代码里我会用output.Dimensions[1]动态取行数。
说到角度,这里先提醒一个最容易混淆的点:角度是弧度还是度数,取决于训练源码和导出脚本。YOLO 系列 OBB 多数沿用angle ∈ [0, π/2)或[0, π)的弧度表示,但也有人把角度乘了180/π。这份资源的说明文档里如果写了角度单位,就按文档来;如果没写,你可以在第四章用一组已知旋转角度的图去反推,这是一个很有效的验证技巧,具体操作放在最后一部分。
2.3 C# 里初始化 ONNX Runtime 会话:两个关键配置
要用 C# 跑 onnx,NuGet 里至少要装两个包:Microsoft.ML.OnnxRuntime负责推理,OpenCvSharp4(以及OpenCvSharp4.runtime.win)负责图像解码和绘制。版本上我一般选较新的稳定版本,ONNX Runtime 对动态 shape 的支持从较早版本开始就相对完整,太老的版本遇到个别算子会报错。初始化代码大致长这样:
using Microsoft.ML.OnnxRuntime; using OpenCvSharp; // 会话配置 var sessionOptions = new SessionOptions(); sessionOptions.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; // 指定CPU EP。若后续要上GPU,需要换包并改用CUDA或OpenVINO EP sessionOptions.AppendExecutionProvider_CPU(); // 加载模型 string modelPath = Path.Combine(AppContext.BaseDirectory, "Models", "yolo26_obb.onnx"); using var session = new InferenceSession(modelPath, sessionOptions); // 把模型输入输出信息打到控制台,这一步强烈建议保留 foreach (var kv in session.InputMetadata) { var shape = string.Join(",", kv.Value.Dimensions); Console.WriteLine($"输入节点: {kv.Key}, 形状: {shape}"); } foreach (var kv in session.OutputMetadata) { var shape = string.Join(",", kv.Value.Dimensions); Console.WriteLine($"输出节点: {kv.Key}, 形状: {shape}"); }这段代码里,GraphOptimizationLevel.ORT_ENABLE_ALL会让 ONNX Runtime 在加载时做全量图优化,能省一部分推理耗时;如果模型包含不常见算子并且优化阶段出错,可以降级到ORT_ENABLE_BASIC排查。AppendExecutionProvider_CPU()表示优先使用 CPU 执行,机器上没有 CUDA 环境时就别硬上 GPU EP,否则会话初始化直接抛异常。
打印输入输出信息是我每次都保留的调试习惯。模型文件是黑匣子,但通过InputMetadata和OutputMetadata能确认动态维度到底是{1, 3, 640, 640}还是{1, 3, -1, -1},输出节点到底叫output0还是别的名字。之后构建输入字典时键名必须和这里打印的完全一致,大小写都不能差。
比较讽刺的是,很多样例代码里把输入键写死成"images",但模型实际输入名是"input"。跑起来报Failed to find input算是这个场景里最常见的报错之一。所以无论是在这个 demo 上改,还是拿自己的模型替换,先把元数据打印出来再动手,比任何文档都可靠。这个习惯从我用 C# 部署第一个 onnx 模型起就保留下来了,也是我排错时最先执行的一步。
3. 预处理到推理:LetterBox、Tensor构建与Run调用的每个细节
预处理直接决定检测精度,这句话对旋转框检测尤其适用。旋转框对目标边缘的敏感度远高于水平框,缩放时如果用简单的Resize把图片拉变形,角度特征会被破坏,原本该检出来的目标可能直接消失。所以 OBB 部署普遍用 LetterBox,保持宽高比缩放后补边。
3.1 LetterBox缩放函数
LetterBox 的核心是:取目标尺寸与原始宽高的比值中最小的那个作为缩放系数,短边补灰边。C# 用 OpenCvSharp 写出来如下:
public static Mat LetterBox(Mat src, int targetSize, out float scale, out int padX, out int padY) { scale = Math.Min((float)targetSize / src.Width, (float)targetSize / src.Height); int newW = Math.Max(1, (int)(src.Width * scale)); int newH = Math.Max(1, (int)(src.Height * scale)); padX = (targetSize - newW) / 2; padY = (targetSize - newH) / 2; Mat resized = new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); Mat canvas = new Mat(targetSize, targetSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); // 把缩放后的图拷贝到画布中心 resized.CopyTo(canvas[new Rect(padX, padY, newW, newH)]); resized.Dispose(); src.Dispose(); return canvas; }主要参数:targetSize是模型输入边长,常见 640;scale是缩放因子,后面把检测坐标映射回原图时还要用到;padX/padY是左右和上下的补边量,逆映射时要减掉。补边色用 114 而不是 0,是因为 YOLO 训练时普遍用灰色填充,换成纯黑相当于改变了数据分布,量化模型上尤其敏感。
实际使用时要注意坐标回推的写法,这是旋转框部署里一个常见的错位来源。如果原图里有个目标中心在(x_orig, y_orig),LetterBox 之后它的中心变成(x_orig * scale + padX, y_orig * scale + padY),推理完再把模型输出的cx减掉padX、除以scale才能回到原图坐标。如果你漏了padX/padY的补偿,框看起来会整体向右下角偏移,这个偏移量还不小,尤其是在输入尺寸远远大于原图的时候。
3.2 从Mat到Tensor输入
模型吃的是归一化浮点张量,C# 端要做三件事:把 Mat 的连续数据取出来、BGR 转 RGB、除以 255。下面这段是演示源码里比较核心的转换部分:
public static Tensor<float> MatToTensor(Mat bgr, int targetSize) { using var rgb = new Mat(); Cv2.CvtColor(bgr, rgb, ColorConversionCodes.BGR2RGB); var tensor = new DenseTensor<float>(new[] { 1, 3, targetSize, targetSize }); var data = tensor.Buffer.Span; // 连续内存,按NCHW顺序排列 unsafe { byte* p = (byte*)rgb.DataPointer; int channels = 3; int h = rgb.Rows; int w = rgb.Cols; for (int c = 0; c < channels; c++) { for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { // tensorIndex按照NCHW计算:先通道,再高,再宽 int tensorIndex = c * h * w + y * w + x; int matIndex = y * w * channels + x * channels + (channels - 1 - c); data[tensorIndex] = p[matIndex] / 255f; } } } } return tensor; }这里最容易犯错的是matIndex的通道顺序。OpenCV 内存里 BGR 是低位在前,要把 BGR 转成 RGB,我用了(channels - 1 - c)做镜像索引。这个写法比先Cv2.CvtColor再逐通道赋值省一次内存拷贝,代价是unsafe代码块。如果你怕指针操作不稳妥,也可以先CvtColor再按通道拷贝,性能上差距不大,但理解 NCHW 排列顺序是必须的。
一个需要注意的点是DenseTensor<float>.Buffer.Span,它依赖System.Memory包。如果你用的是 .NET Framework 4.7.2 以下版本,或者项目引用了比较旧的依赖,编译时会找不到Span。解决方法是升级目标框架到 4.7.2 以上,或者引入System.Memory的 NuGet 包。这个坑在 WinForms 老项目里特别常见,因为很多项目的目标框架还停在 .NET Framework 4.6.1。
3.3 执行推理并拿到输出
推理调用本身很简短:
var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", tensor) // 节点名以2.2打印的为准 }; string outputName = "output0"; // 从OutputMetadata取得 using var results = session.Run(inputs, new[] { outputName }); var output = results.First().AsTensor<float>(); // output的shape是 [1, N, 7],N需要动态取 int rows = output.Dimensions[1]; int cols = output.Dimensions[2]; Console.WriteLine($"输出shape: [{output.Dimensions[0]}, {rows}, {cols}]");关于Run的第二个参数,我见过不少新手不传输出节点名,这在只有一个输出节点的模型上没区别;但 OBB 模型有的导出会附带一个额外输出(比如原始解码前的张量),多出来的数据虽然不影响取结果,但会白白增加一次内存拷贝。显式传输出名是更干净的做法。
推理完成后先别急着画框,把rows和cols打出来核对一遍。cols == 7说明是带角度的 OBB 输出,cols == 6可能少了一个 objectness。这个 demo 里模型是按 7 列解析的,如果你替换成自己的模型发现列数不对,去检查导出脚本的参数,而不是硬改代码。因为我在实际项目里就吃过这个亏:当时把 6 列输出硬当 7 列解析,cx之后的列全错位了,画出来的框是散的,后来打印 shape 才发现维度对不上。
4. 旋转框后处理:角度解码、旋转NMS与WinForms画框
模型推理输出的是两维矩阵,不是可以直接画的图形。后处理要做三件事:按阈值过滤低置信度框、用旋转NMS去掉重复框、把旋转框转成四个顶点坐标绘制到界面上。顺序不能乱,否则会画出大量重叠框或者漏掉目标。
4.1 解码输出到旋转矩形
一条检测结果有 7 个数,首先要区分cx, cy是在哪个坐标系里。如果模型导出时没有做 decode,输出会多一层 sigmoid 和网格偏移的处理。但这个包里的演示模型在导出时已经带了解码逻辑,输出里的cx, cy, w, h都相对 LetterBox 输入图,解析代码直接从行里取值即可:
var candidates = new List<RotatedRectInfo>(); float confThreshold = 0.25f; float objThreshold = 0.25f; for (int i = 0; i < rows; i++) { int baseIndex = i * cols; float objConf = output[baseIndex + 5]; if (objConf < objThreshold) continue; float clsConf = output[baseIndex + 6]; float finalConf = objConf * clsConf; if (finalConf < confThreshold) continue; float cx = output[baseIndex + 0]; float cy = output[baseIndex + 1]; float w = output[baseIndex + 2]; float h = output[baseIndex + 3]; float angle = output[baseIndex + 4]; // 如果模型输出的是角度制,这里要转弧度再给OpenCV用 angle = angle * (float)(Math.PI / 180.0); candidates.Add(new RotatedRectInfo(cx, cy, w, h, angle, finalConf)); }阈值取舍是我在项目里调过好多轮的参数:confThreshold=0.25在多数场景下能平衡召回率和误检。如果你检测的目标小且密集,建议先降到 0.15 看看到底是漏检还是误检,再把类别的置信度阈值单独调。注意这里finalConf的取法,有的模型训练时已经把 objectness 合并进类别得分,输出只有 6 列;遇到这种就不要再乘objConf,否则所有分数都会掉一个数量级,目标全部被过滤掉。
另外,demo 里代码在拿到模型输出后,还需要把 LetterBox 的补边量还原回去。具体做法是:cx = (cx - padX) / scale,cy = (cy - padY) / scale,宽高同理。这个逆映射代码一般就放在解码循环里,千万别在图已经显示后再去调整坐标,那样会牵扯 PictureBox 的缩放模式,增加不必要的复杂度。
4.2 旋转框NMS:为什么不能拿普通NMS顶上
普通 NMS 计算的是水平外接矩形的 IoU,旋转框之间如果夹角大,外接矩形会大面积重叠但实际框并不重叠,普通 NMS 会误删掉正确的一个框。所以在 OBB 后处理里必须用旋转框的相交面积来算 IoU。代码上我一般是先把RotatedRect转成四个顶点,然后计算两个四边形的交集面积:
public static float RotatedIou(RotatedRect a, RotatedRect b) { Point2f[] ptsA = a.Points(); // 返回四个顶点 Point2f[] ptsB = b.Points(); // 用多边形裁剪计算交集面积 List<Point2f> intersection = IntersectConvex(ptsA.ToList(), ptsB.ToList()); if (intersection.Count < 3) return 0f; float interArea = ContourArea(intersection); float unionArea = a.Size.Width * a.Size.Height + b.Size.Width * b.Size.Height - interArea; return interArea / unionArea; } public static List<RotatedRectInfo> RotatedNms(List<RotatedRectInfo> boxes, float iouThreshold) { var result = new List<RotatedRectInfo>(); var sorted = boxes.OrderByDescending(x => x.Score).ToList(); while (sorted.Count > 0) { var best = sorted[0]; result.Add(best); sorted.RemoveAt(0); sorted.RemoveAll(c => { var iou = RotatedIou(best.Rect, c.Rect); return iou > iouThreshold; }); } return result; }IntersectConvex本质上是一个多边形裁剪,用 Sutherland-Hodgman 算法写。在目标数量不多(几百条候选)时性能完全够,但如果你在工业检测里一帧画面有上千个候选框,建议提前把低于 0.5 置信度的候选过滤掉再做 NMS,不然旋转 IoU 的计算会占据大量 CPU 时间。iouThreshold我常用 0.45,如果检测目标是细长杆件(比如螺钉、指针),框的长宽比很大,角度稍有偏差 IoU 就掉得很快,这时可以放宽到 0.6,避免同一个目标被保留成两个框。
这里还有一个容易被忽略的问题:ContourArea在 OpenCvSharp 里接收的是List<Point>或List<Point2f>,如果你把Point2f[]直接传进去,部分版本会报类型不匹配,需要先.ToList()。另外,如果intersection计算出来只有两个点,说明两个四边形仅仅擦边,按 0 处理是对的。
4.3 在WinForms里把旋转框画出来
WinForms 的 PictureBox 默认用 GDI+ 绘图,旋转框只需要把四个顶点用直线连起来:
private void DrawRotatedBoxes(Bitmap frame, List<RotatedRectInfo> boxes) { using var g = Graphics.FromImage(frame); g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; foreach (var box in boxes) { var pts = box.Rect.Points(); // OpenCvSharp的Point2f[] PointF[] screen = new PointF[4]; for (int i = 0; i < 4; i++) { screen[i] = new PointF(pts[i].X, pts[i].Y); } using var pen = new Pen(Color.Lime, 2f); g.DrawPolygon(pen, screen); g.DrawString(box.Score.ToString("F2"), SystemFonts.DefaultFont, Brushes.Lime, screen[0]); } pictureBox1.Image?.Dispose(); pictureBox1.Image = frame; }这里有两个细节:第一,坐标不能直接用Center和Size画椭圆或矩形,因为 GDI+ 的DrawEllipse不支持旋转角,必须用顶点连线;第二,PictureBox 的Image属性赋值前要把上一帧Dispose掉,否则工具跑一个晚上,内存占用肉眼可见地往上跑,这是我做上位机时踩过的资源泄漏坑。如果你需要鼠标滚轮缩放显示,可以把SizeMode设成Zoom,但顶点坐标和显示坐标之间要做比例换算,不然画出来的框和图会错位。
画框的颜色和线宽建议做成界面上的配置项,因为不同场景对视觉表现的要求差别很大。遥感图通常底图亮,用深色或高饱和度的颜色更好;工业现场光照复杂,可以考虑在框线上再加一个对比色外描边。这些在 demo 里虽然只演示了青色画笔,但改动成本很低,一行Pen的颜色值而已。
5. 避坑指南:旋转框部署最容易翻车的五个细节
旋转框检测的部署坑,和普通检测完全不在一个维度上。下面这五条是我实际用 C# 部署类似 OBB 模型时真实遇到的,按“现象 → 原因 → 解决”的顺序写,方便你对照排查。
5.1 现象:程序跑起来数组越界,或者输出全是无意义的小数
代码里按cols每行取值,但实际模型输出的最后一维长度和代码里写的不一致。比如我代码里写死了 7,模型输出是 6,那baseIndex + 6就会取到下一行的第一列,结果就是一堆异常数字。
这个问题的根源,是换了模型或换了导出参数后没有重新核对输出张量形状。解决方法是回到 2.3 的打印逻辑,把session.OutputMetadata里的维度打出来,确认cols到底是多少。还可以直接用 Netron 打开.onnx文件,看输出节点的 shape 标注,两个来源相互印证。从那以后,我每次换模型都会强制走一遍“打印元数据 → 对照列数 → 再写解析”的流程,不再凭经验猜。
5.2 现象:两个目标明明不重叠,NMS 却把其中一个删掉了
场景通常是画面里有两个交叉摆放的细长物体,水平外接矩形大面积重叠,但实际边框没有交集。普通 NMS 只算外接矩形的 IoU,这个 IoU 值很大,于是删掉了置信度低的那一个。
解决方法是把 NMS 换成第四章里的RotatedNms,用多边形裁剪算真实交集面积。如果你的项目允许引入更重的依赖,也可以直接用OpenCvSharp里RotatedRect的Intersect方法做初步筛选。注意换完之后,阈值要重新调,旋转 IoU 的数值通常比水平外接矩形的 IoU 小,0.45 可能比原来 0.5 还要宽松。
5.3 现象:框的中心点位置很准,但框是斜的,而且角度偏差很大
中心对、角度错,十有八九是角度单位没换算。模型输出的是弧度,代码里直接当成度数用,或者反过来。还有一种情况是训练脚本里角度定义是[0, 90)区间(短边为基准),而 OpenCvSharp 的RotatedRect用[0, 180)区间(宽度边为基准),两者相差一个象限或需要90 - angle的转换。
解决方法是:先做一张只有单个目标的图,手动旋转一个已知角度(比如 30 度)作为测试图,跑模型后打印输出角度。如果打印出来是0.52,说明是弧度制;如果打印出来是30,说明是角度制。之后再确认 OpenCV 画出来的框长边方向和角度匹配,就完成角度定义校准了。这套验证流程放在最后一章,代码也不复杂。
5.4 现象:长时间运行能跑,但内存缓慢上涨,最终卡死
这个现象在 WinForms 里最隐蔽,因为不是马上崩溃,跑一两个小时才出问题。原因通常有两个:一是pictureBox1.Image赋值前没有 Dispose 上一帧;二是每次处理新图都新建DenseTensor,旧张量虽然没有引用,但伴随的影像数据没有及时释放。
解决方法是:在DrawRotatedBoxes结束前显式 Dispose 旧 Image;复用输入 Tensor,把DenseTensor<float>声明提到循环外部,只更新Buffer.Span里的值。对于长时间值守的质检工具,还可以加一个计数器,每处理 100 帧手动GC.Collect()一次,但不要频繁调用,否则性能会明显下降。
5.5 现象:GPU 推理报错“Op not implemented for GPU”
OBB 模型里如果带了训练时定义的自定义算子,ONNX Runtime 的 GPU EP 不一定有对应实现,会话初始化或首次推理时会直接抛异常。
解决方法是:先用 CPU EP 跑通全流程,确认模型本身没问题;再升级Microsoft.ML.OnnxRuntime.Gpu到与模型导出时匹配的版本。如果 GPU 版本还是不行,检查Cv2.Resize到模型输入尺寸时是否走了 GPU 内存拷贝,有时 CPU 上的预处理反而更快。这个 demo 里默认 CPU EP,先跑通再考虑 GPU,是我推荐的最稳路径。
6. 进阶验证:用一张已知角度的测试图确认模型输出与OpenCV坐标系的对应关系
旋转框部署最让人没底的事情就是:你根本不知道模型输出的角度是规整的,还是一团乱麻。与其纠结训练源码怎么写的,不如直接做一次单目标验证:找一张包含倾斜目标的图片,用 OpenCV 算出目标实际倾斜角度,再和模型输出的角度对比,一次就能确认单位和你该做的换算。
验证思路是这样:先手工在图片上框出目标区域,用Cv2.MinAreaRect得到精确的外接旋转矩形;然后把同一张图跑一遍模型,打印模型输出的角度和RotatedRect的角度。如果两者接近,说明你的解析逻辑对了;如果差了 90 度,说明模型的角度是相对于短边定义的;如果数值差得很诡异,则要考虑单位换算。
private void VerifyAngleWithOpencv(string imagePath, float modelAngle) { using var src = Cv2.ImRead(imagePath); // 这里假设你已经在图上手动选了一个目标的roi var roi = new Rect(100, 80, 220, 120); using var roiMat = src[roi]; // 二值化后找最小外接旋转矩形 using var gray = new Mat(); using var binary = new Mat(); Cv2.CvtColor(roiMat, gray, ColorConversionCodes.BGR2GRAY); Cv2.Threshold(gray, binary, 0, 255, ThresholdTypes.Binary | ThresholdTypes.Otsu); var contours = new Point[][] { }; var hierarchy = new HierarchyIndex[] { }; Cv2.FindContours(binary, out contours, out hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); if (contours.Length == 0) return; var minRect = Cv2.MinAreaRect(contours[0]); float opencvAngle = minRect.Angle; Console.WriteLine($"OpenCV角度: {opencvAngle}, 模型角度: {modelAngle}, 差值: {opencvAngle - modelAngle}"); }这段代码的意义不是写一个通用工具,而是给你一个判断基准。MinAreaRect返回的Angle范围是[-90, 0),单位是度;模型输出的角度如果在这个范围内,说明二者可以用同一套坐标逻辑;如果模型输出是弧度制的小数,直接乘180/π再比较。做完这一次验证,后面写批量处理脚本时,你就不需要对每个框做角度校准了。
我自己的习惯是把这个验证函数留在工程里,不管项目最终交付给谁,换模型的时候都会跑一遍。因为模型重新导出后,角度定义、输出列数、置信度合并方式都可能被训练方的脚本改动,而文档常常不同步更新。这种“拿一张图实测”的方式,比读十页导出文档都直观。每次换完模型的第一个下午,我都会强制自己走一遍这套验证,确认输出干净了再继续往下做业务。这份资源里配套的模型和示例图正好拿来跑这个验证流程,跑通了,后面的事情就顺了。希望帮到你。
本文还有配套的精品资源,点击获取