做了几年 C# 上位机之后你会发现,凡是和相机、图像沾边的活,最后都会回到同一个问题:怎么把训练好的模型塞进现有程序里。尤其是 YOLO 这类目标检测模型,很多团队默认先在 Python 里跑通,等真要集成到 C# 服务时,往往卡在模型加载这一关。ONNX Runtime 是这条路里最成熟且不依赖 Python 运行时的一种选择,我这几年的经验也证实了:它能在预算内稳定地跑 YOLO 的 onnx 模型,还能顺带切 GPU 加速。这篇文章会以“从零加载解读”为主线,讲清楚用 C# 加载 YOLO onnx 模型之后,到底应该从模型里读出哪些信息,这些信息又如何指导后面的预处理、推理和后处理。适合的读者包括准备在 .NET 里做目标检测的 C# 开发者,以及已经能跑 Python 推理但还没看懂 ONNX 接口的人。
1. 为什么我坚持在 C# 里做 YOLO 推理,而不是换成 Python
1.1 单独跑一个 Python 推理服务,听起来解耦,实际上很麻烦
以前我也试过“让 Python 管模型,C# 管业务”的方案。业务层需要调一个检测结果,于是我在 C# 这边发 HTTP 请求,Python 那边装了一套推理环境。刚开始看起来挺清爽,但实际维护起来问题很多。要给客户部署的时候,每台机器都得装 Python、装推理依赖库,涉及 GPU 的时候还得装对应版本的 CUDA 和 cuDNN。版本一乱,模型加载不出来或者算出来的结果全是零,排查成本很高。
另外一个容易被忽略的问题是延迟。工业相机采集一帧图像后,要从 C# 进程把图像数据序列化出来,传给 Python,再等结果回来。一次两次无所谓,做实时检测时,这种跨进程传输的损耗和不确定性,经常让整个流程变得很难调。既然目标检测只占业务的一小块,那我更希望它变成程序里的一个普通函数,而不是一个需要单独运维的服务。
1.2 ONNX Runtime 在这条路上到底解决了什么
后来我把目光集中到 ONNX Runtime 上。它的思路很简单:模型不管是用哪个框架训练的,只要导出为 onnx 格式,就能交给 ONNX Runtime 统一执行。YOLO 家族现在官方或社区基本都会提供 onnx 导出版本,比如以 YOLOv8 或 YOLO11 为代表的目标检测系列,最终导出的文件可以直接被 C# 端加载。
相比直接用 TorchSharp 这类库,ONNX Runtime 更贴近“只做推理”这个诉求。它不要求你复制训练环境的全套依赖,也不要求 C# 项目去理解底层的算子细节。你只需要一个 NuGet 包,再传入一个模型文件,剩下的推理工作就交给引擎。它甚至允许你用同一个 session 对象在 CPU 和 GPU 之间切换执行提供器,这对我来说是加分项。
当然也得说清楚:C# 这条路不适合训练模型。模型训练、调参、数据集迭代,这些仍然留在 Python 生态里。我们需要的只是“把它部署到生产环境”。既然目标明确,接下来就看具体的工程流程。
2. 环境准备:一条 dotnet add 命令就够的依赖
2.1 安装 CPU 版依赖
先建一个控制台项目来跑通全流程。如果你最终要做的是 WPF、WinForms 或 ASP.NET Core 服务,思路一样,只是把代码放到对应项目里。
dotnet new console -n YoloOnnxDemo cd YoloOnnxDemo dotnet add package Microsoft.ML.OnnxRuntime dotnet add package OpenCvSharp4 dotnet add package OpenCvSharp4.runtime.win这里Microsoft.ML.OnnxRuntime是 ONNX Runtime 的官方 NuGet 包,CPU 版就够了。OpenCvSharp4负责图像读取、尺寸调整和像素操作,比原生画布处理要方便得多。Windows 环境再配合OpenCvSharp4.runtime.win才能把原生库带进来。如果项目是 Linux 服务端,就换成对应的改版包,比如OpenCvSharp4.runtime.ubuntu.22.04-x64。
注意:NuGet 包版本建议锁定一个稳定版本。ONNX Runtime 迭代得很快,但模型文件不会跟着它一起变。我遇到过几次升级包之后,原来能跑的 demo 却报算子不支持的情况,虽然概率不高,但生产项目里不值得冒险。最好在项目里显式指定主版本号,而不是直接写
*。
2.2 GPU 和 CPU 换着用的配置姿势
如果你的机器有 NVIDIA 显卡,想上 CUDA 加速,需要把包换成Microsoft.ML.OnnxRuntime.Gpu。这个包体积更大,依赖也更多,但不要求你在 C# 里额外写 CUDA 代码。
GPU 和 CPU 的切换,本质上是换一个 Execution Provider。ONNX Runtime 的执行逻辑是:优先把能跑的算子派发到指定设备,剩下不支持的算子自动回退到 CPU。所以你在代码里只需要:
using Microsoft.ML.OnnxRuntime; var sessionOptions = new SessionOptions { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL }; // 如果装了 GPU 包,可以手动指定 CUDA sessionOptions.AppendExecutionProvider_CUDA();没有 GPU 时就删掉这行AppendExecutionProvider_CUDA(),或者在普通 CPU 包下什么都不加。
实际部署时,最麻烦的不是代码,而是 GPU 包的版本匹配。GPU 版 ONNX Runtime 内部依赖特定的 CUDA/cuDNN 版本,版本不对会直接加载失败。官方文档里写得清清楚楚,必须完全对应。我第一次踩这个坑时,模型加载报错的信息根本不是“CUDA 缺失”,而是类似“无法找到指定模块”这种难懂的错误,最后才发现是 CUDA 版本不一致。
3. 第一次加载:InferenceSession 的初始化其实就两步
3.1 最简单的会话创建代码
ONNX Runtime 把“加载模型”写得非常轻量。InferenceSession一创建,模型文件就会被读取并构建内部执行图。你可以把它理解成数据库连接池里的连接,创建一次,反复使用,而不是每帧都重新加载。
using System; using System.IO; using Microsoft.ML.OnnxRuntime; string modelPath = "yolo11n.onnx"; if (!File.Exists(modelPath)) { throw new FileNotFoundException("模型文件不存在,请检查路径:" + modelPath); } using var sessionOptions = new SessionOptions { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL }; using var session = new InferenceSession(modelPath, sessionOptions); Console.WriteLine("模型加载成功");这里的GraphOptimizationLevel.ORT_ENABLE_ALL表示让 ONNX Runtime 尽可能做图优化。对于 YOLO 这种结构比较固定的模型,优化之后执行效率会有明显提升。如果你在排查推理结果异常,可以临时改成ORT_DISABLE_ALL,确认是不是图优化引入了问题。虽然这种情况很少,但它确实是排查方向之一。
InferenceSession实现了IDisposable接口,程序退出时记得释放。服务端场景尤其要注意,别因为 session 没有释放导致模型文件被占用,后面想更新模型文件都删不掉。
3.2 线程与并发使用注意
InferenceSession本身是线程安全的,同一个 session 可以同时被多个线程调用。很多 C# 开发者会把“线程安全”理解成“可以随便并发”,但这有一个前提:session.Run 返回的结果对象要及时处理。
比如多线程并发检测,多个线程同时调用session.Run是允许的,但每个线程拿到的输出张量是独立分配的。如果放着这些输出对象不管,内存会慢慢积累。下一章我会专门讲信息解析,这里先记住一个原则:session 可以复用,返回结果不能囤积。
4. 模型加载之后,第一步要做的不是推理而是“问路”
4.1 把输入输出节点的“身份证”打出来
很多教程上来就直接教你怎么拼输入张量,跑session.Run。但 YOLO 的 onnx 模型并不是全家桶,不同框架、不同导出脚本生成的模型,输入输出节点名可能完全不同。
比如有人用 YOLOv8 导出的 at 模型,输入节点可能叫images,输出节点可能叫output0;而旧版 YOLOv5 的某些导出脚本,输入节点可能叫input,输出可能是output。如果你在代码里写死了images,后面换个模型就抓瞎。
所以加载模型后的第一件事,是把模型暴露出来的输入输出信息打印到控制台。这段代码就是最基础的“问路”流程:
Console.WriteLine("模型输入节点:"); foreach (var name in session.InputNames) { var meta = session.InputMetadata[name]; Console.WriteLine($"名称: {name}, 形状: {string.Join(" x ", meta.Dimensions)}, 类型: {meta.ElementType}"); } Console.WriteLine("模型输出节点:"); foreach (var name in session.OutputNames) { var meta = session.OutputMetadata[name]; Console.WriteLine($"名称: {name}, 形状: {string.Join(" x ", meta.Dimensions)}, 类型: {meta.ElementType}"); }InputMetadata和OutputMetadata是InferenceSession上最实用的信息源。meta.Dimensions是一个 int 数组,比如[1, 3, 640, 640],就表示这个输入是四维张量。meta.ElementType告诉我们张量里存的是float还是其他类型。
我每次拿到新的 YOLO 模型,第一件事都是跑这个信息打印。输出结果通常类似:
模型输入节点: 名称: images, 形状: 1 x 3 x 640 x 640, 类型: System.Single 模型输出节点: 名称: output0, 形状: 1 x 84 x 8400, 类型: System.Single看到输出张量形状是1 x 84 x 8400,我心里就有数了。这是 YOLOv8/YOLO11 目标检测模型的常见输出布局。后面会详细讲这个形状怎么拆。
4.2 动态轴、静态轴和类型信息怎么看
Meta.Dimensions 里可能出现-1,比如-1 x 3 x 640 x 640。这个-1表示该维是动态的,最常见的是 batch 维度。意思是:这个模型在导出时,允许 batch size 不固定。你在运行时传入1 x 3 x 640 x 640可以,传4 x 3 x 640 x 640也可以,只是性能不一定最优。
这里有一个容易踩的坑:虽然模型支持动态 batch,但 C# 里的输入张量形状必须和运行时实际要用的形状一致。你不能说模型支持动态 batch,我就直接传一个[null, 3, 640, 640]。DenseTensor 必须给出具体数值,比如[1, 3, 640, 640]。
meta.Dimensions还会暴露另一个重要信息:张量是 NCHW 还是 NHWC 布局。YOLO 目标检测模型绝大多数是 NCHW,也就是[batch, channel, height, width]。C# 端在拼接输入 tensor 时,必须严格按照这个顺序填数据。
4.3 从输出形状反推模型框架
输出形状直接告诉我们这个模型是什么风格。常见的几个形状:
| 输出形状 | 常见模型 | 含义 |
|---|---|---|
1 x 84 x 8400 | YOLOv8 / YOLO11 COCO | 每个 anchor 有 4 个 box 坐标 + 80 个类别分数 |
1 x 56 x 8400 | YOLOv8 / YOLO11 自定义 52 类 | 4 个 box 坐标 + 52 个类别分数 |
1 x 85 x 25200或1 x 25200 x 85 | 旧版 YOLOv5 | 4 个 box 坐标 + 1 个 objectness + 80 个类别分数 |
1 x 6 x 100或类似小形状 | 带 end2end 后处理的导出模型 | 已经包含后处理结果,通常直接是检测框和类别 |
如果只看到84,那大概率是4 + 80,其中 80 对应 COCO 的 80 个类别。如果你知道自己模型训练的类别数不是 80,那 84 就要调整为4 + 你的类别数。
打印出这行信息之后,你才知道后面该怎么解析输出,而不是靠猜。
5. 预处理:letterbox 和 CHW 的那些细节
5.1 letterbox 为什么是标配
YOLO 模型训练时通常会把输入图片统一缩放到固定尺寸,比如 640x640。但直接缩放会破坏原始图片的宽高比。如果原始图片是 16:9,硬拉到 1:1,检测目标会变形,模型精度会明显下降。
所以标准做法是 letterbox:保持宽高比缩放,把超过画布的部分用灰边填充。这种“在灰边上补像素”的操作,能让图片尽量不变形,同时满足模型输入尺寸要求。
这一步骤有三个关键参数:缩放比例scale、水平填充padX、垂直填充padY。这三个值必须保存下来,后处理时还要用它们把检测框坐标还原回原图。
5.2 OpenCvSharp 预处理实操
我用一个方法来完成整段预处理,返回可以直接塞给模型的 float 数组:
using System; using OpenCvSharp; static float[] Preprocess(Mat source, int inputWidth, int inputHeight, out float scale, out int padX, out int padY) { // 1. 计算等比缩放比例 scale = Math.Min((float)inputWidth / source.Width, (float)inputHeight / source.Height); int resizedWidth = (int)Math.Round(source.Width * scale); int resizedHeight = (int)Math.Round(source.Height * scale); // 2. 等比缩放原图 using var resized = new Mat(); Cv2.Resize(source, resized, new Size(resizedWidth, resizedHeight), 0, 0, InterpolationFlags.Linear); // 3. 创建灰色画布并填充到中央 padX = (inputWidth - resizedWidth) / 2; padY = (inputHeight - resizedHeight) / 2; using var canvas = new Mat(inputHeight, inputWidth, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new Rect(padX, padY, resizedWidth, resizedHeight)]); // 4. 转为 CHW 顺序的 float 数组,并归一化到 [0, 1] float[] tensor = new float[3 * inputHeight * inputWidth]; for (int y = 0; y < inputHeight; y++) { for (int x = 0; x < inputWidth; x++) { Vec3b color = canvas.At<Vec3b>(y, x); // OpenCvSharp 默认读取为 BGR tensor[0 * inputHeight * inputWidth + y * inputWidth + x] = color[0] / 255f; tensor[1 * inputHeight * inputWidth + y * inputWidth + x] = color[1] / 255f; tensor[2 * inputHeight * inputWidth + y * inputWidth + x] = color[2] / 255f; } } return tensor; }这段代码为了可读性用了逐像素At,性能不理想。在实际项目里,尤其是做视频流实时检测时,更推荐用Mat.GetIndexer或者直接操作像素指针,速度能快好几倍。先把流程跑通,再做性能优化,这是我建议的顺序。
还有一个很关键的点:有些 YOLO 模型输入标准化的均值和方差不是简单的除以 255。如果你的模型在导出时没有把归一化写进图里,那你还需要额外做(pixel / 255 - mean) / std的处理。这个信息从模型文件本身看不出来,只能靠导出方确认。绝大多数 YOLO 官方导出会包含归一化处理,所以/255f一般够用。
5.3 颜色通道顺序也是一个常见误区
OpenCV 读取图像默认是BGR顺序,而很多深度学习模型的训练流程用的是RGB。官方 YOLO 训练代码大多基于 OpenCV 读取数据,内部使用的就是 BGR。所以你在预处理时用BGR顺序填数据,通常是对的。
但“通常”不代表“一定”。我建议你用一个颜色鲜明的目标做简单测试,比如红苹果或蓝色包装盒。如果识别精度莫名其妙很差,先把通道换过来试试,也就是把color[0]和color[2]对调。这个调试成本几乎为零,却能排除一个很隐蔽的问题。
6. 推理张量怎么组织:DenseTensor 与 Run 调用
6.1 DenseTensor 的内存排列
ONNX Runtime 在 C# 端接受的数据结构是Tensor<float>,最常见的实现是DenseTensor<float>。它要求数据按行优先的方式存在一个一维 float 数组里。
如果你要构造一个[1, 3, 640, 640]张量,那么数组前 640x640 个元素是第一个通道,紧接着是第二个通道,然后是第三个通道。这就是所谓的 NCHW 布局。
很多人在这一步犯错,是因为直接用Bitmap.GetPixel一行一列地拿像素,再塞进数组,结果把HWC当成CHW塞进去。模型不会报错,因为字节数一样,但推理结果会变成一坨噪声。所以预处理方法里最后那三段循环,顺序和公式不能想当然。
6.2 一次 Run 调用与结果生命周期
推理调用本身很简洁:
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; float[] tensorData = Preprocess(imageMat, 640, 640, out float scale, out int padX, out int padY); var inputName = session.InputNames[0]; // 不要写死,优先取模型打印出来的名称 var inputTensor = new DenseTensor<float>(tensorData, new[] { 1, 3, 640, 640 }); using (var results = session.Run(new[] { NamedOnnxValue.CreateFromTensor(inputName, inputTensor) })) { var outputTensor = results[0].AsTensor<float>(); // 在这里解析 outputTensor }session.Run的输入是一个IReadOnlyCollection<NamedOnnxValue>。当一个模型有多个输入时,你需要为每个输入创建一个NamedOnnxValue。YOLO 常规只有一个输入,所以这不是大问题。
重点说using (var results ...)。results对象是一个非托管资源的集中营,里面装着 ONNX Runtime 分配的输出张量。如果不Dispose,内存不会立刻归还给系统。我见过不少长期运行的 C# 推理程序,内存曲线就是一根持续上升的斜线,最后被 OOM 杀掉,排查时才发现是输出张量一直没释放。
7. 输出张量解析:从坐标到检测框的全流程
7.1 按维度索引而不是拍平数组
输出张量[1, 84, 8400]看起来简单,但一维 float 数组里怎么取数还是有讲究的。
你可以把输出想成三层的立体结构:最外层 batch=1,中间层有 84 个通道,最内层有 8400 个 anchor。数组中某个元素的地址是(channel * 8400) + anchor。
前 4 个通道是 box 信息,分别是cx、cy、w、h。从第 4 个通道开始是各类别分数。遍历逻辑可以这样组织:
var dims = outputTensor.Dimensions; int numAnchors = dims[2]; // 8400 int boxChannels = 4; int numClasses = dims[1] - boxChannels; // 84 - 4 = 80 float[] data = outputTensor.ToArray(); for (int anchor = 0; anchor < numAnchors; anchor++) { float cx = data[0 * numAnchors + anchor]; float cy = data[1 * numAnchors + anchor]; float w = data[2 * numAnchors + anchor]; float h = data[3 * numAnchors + anchor]; float bestScore = 0f; int bestClass = -1; for (int c = 0; c < numClasses; c++) { float score = data[(boxChannels + c) * numAnchors + anchor]; if (score > bestScore) { bestScore = score; bestClass = c; } } if (bestScore < 0.5f) continue; // 到这里,我们拿到一个候选框 }这段代码里我默认了 YOLOv8/YOLO11 的输出布局,也就是[batch, 4 + numClasses, numAnchors]。如果你的模型打印结果是[1, 8400, 84],那索引逻辑就要完全反过来。这就是前面“先打印模型信息”的价值所在。
7.2 置信度过滤和简单的 NMS
拿到了候选框之后,还需要做非极大值抑制,因为同一个目标会生成很多互相重叠的框。
NMS 的思路很简单:先按置信度从高到低排序,挨个选框,凡是和当前框交并比大于阈值的,直接丢掉。我用一个精简的实现来说明:
using System.Collections.Generic; using System.Linq; using System.Drawing; public class Detection { public RectangleF Box { get; set; } public float Score { get; set; } public int ClassId { get; set; } } static float IoU(RectangleF a, RectangleF b) { float left = Math.Max(a.Left, b.Left); float top = Math.Max(a.Top, b.Top); float right = Math.Min(a.Right, b.Right); float bottom = Math.Min(a.Bottom, b.Bottom); float interW = Math.Max(0, right - left); float interH = Math.Max(0, bottom - top); float interArea = interW * interH; float unionArea = a.Width * a.Height + b.Width * b.Height - interArea; return unionArea <= 0 ? 0 : interArea / unionArea; } static List<Detection> NMS(List<Detection> detections, float iouThreshold) { var result = new List<Detection>(); var sorted = detections.OrderByDescending(d => d.Score).ToList(); while (sorted.Count > 0) { var current = sorted[0]; result.Add(current); sorted.RemoveAt(0); sorted.RemoveAll(d => IoU(current.Box, d.Box) > iouThreshold); } return result; }这个实现适合演示和一般使用,性能上还有优化空间。真实项目里如果一帧有上千个候选框,可以考虑用按类别分组后再做 NMS,或者使用更优秀的快速排序策略。我的经验是,先把 NMS 做对,再考虑做快。
7.3 坐标还原回原图
模型输出框的坐标,位于预处理后的 640x640 画布内。如果你想画到原始图片上,必须做逆向处理。
根据预处理时的 letterbox 参数,还原公式是:
float originalX = (cx - padX) / scale; float originalY = (cy - padY) / scale; float originalW = w / scale; float originalH = h / scale;注意cx、cy是框中心点坐标,不是左上角。YOLOv8 的 andbox 通道默认是中心点加宽高,而 YOLOv5 的某些导出格式可能是xywh。统一成左上角的话,还要再减一次:
var rect = new RectangleF( originalX - originalW / 2f, originalY - originalH / 2f, originalW, originalH);只要是 YOLOv8/YOLO11 的 ONNX 导出,输出坐标基本都是基于 640x640 输入图像的像素坐标,没有归一化。这点可以在实际输出里验证:打印第一个 anchor 的 cx,如果数值落在 0 到 640 之间,说明是像素坐标;如果落在 0 到 1 之间,说明需要先乘以 640 才能继续解析。
8. 长跑不掉内存:YOLO 进程内存持续上涨的排查思路
8.1 内存上涨的表现和根因
很多人跑 demo 的时候没感觉,一旦把算法放进一个摄像头实时服务里连续跑几个小时后,进程内存会稳定增长,最后被吃掉。这个问题不是 YOLO 模型本身带来的,而是 C# 侧对非托管资源的释放出了问题。
最常见的原因有三个:
第一,session.Run返回的results没有及时释放。非托管输出缓冲不会被 .NET 垃圾回收器马上回收,于是每帧都会累积一部分内存。
第二,预处理时的Mat对象没有释放。OpenCvSharp 的Mat虽然实现了 finalizer,但在高频率推理场景里,finalizer 的触发速度跟不上分配速度。
第三,帧图像本身被引用住了。如果队列管理器一直持有原始Mat,那这部分内存更是只增不减。
我在排查时习惯先看进程内存曲线是稳定斜坡还是阶梯式上涨。稳定斜坡大概率是某个 native 资源一直在泄漏;阶梯式上涨则更像缓存没有及时刷新。
8.2 避免每帧都分配一堆托管对象
有几个习惯从写第一行代码就该养起来。
输出张量解析完之后,立即丢弃引用,不要攒在一个 List 里等最后处理。如果你需要跨帧统计,只保留解析出来的Detection列表,不保留原始输出张量。
Mat对象尽量用using包裹。不用using时也要确保在 finally 里调用Dispose。还有,不要用一个全局Mat反复读取摄像头帧,这会让旧帧在赋值时被白白覆盖而不被释放。
GPU 内存也需要留意。ONNX Runtime 的 GPU provider 自带内存池,所以 GPU 显存和进程内存略有上涨是正常的。只要曲线不是无限上升,一般不用过度紧张。但如果每次调用session.Run后显存都上涨,那就要检查results是否释放了,因为 GPU 输出张量在释放时才会归还显存。
我自己在监控服务里做过的优化是:把推理和解析放在独立线程,主线程只负责收集检测结果并绘制框。这样即使某一帧图像处理慢,也不会阻塞相机采集。但无论怎么优化,资源和结果的释放始终要有纪律。
9. 踩过的几个模型信息坑,帮你提前排雷
9.1 输入名不能写死
这是我第一次接入新模型时犯的错误。上一版代码用images作为输入名,这次换了模型,运行直接报错,系统提示找不到名为images的输入节点。
后来我养成了习惯:所有输入输出名都从session.InputNames和session.OutputNames动态取。即使你很清楚当前模型的名字,也要先打印一遍。因为你不能保证模型文件在生产环境里永远不变。
9.2 模型文件时好时坏?先看官方导出配置
有些团队拿到的 YOLO onnx 模型是直接从别处拷来的,没有附带导出命令。这种情况下,直接在 C# 里推理容易遇到各种怪问题,比如输出维度多了个 1,或者输出布局是 NHWC。
我的建议是,把模型拿到手后先跑一次信息打印,再对照导出方的配置确认:输入尺寸、是否带 NMS、输出是否做了转置。这些信息从模型文件本身和打印结果里能看出来。
如果输出张量是[1, 8400, 84],但你的解析代码按[1, 84, 8400]来跑,结果会完全错误。遇到这种情况,不要改解析逻辑硬编,而是先确认模型导出时的设置。很多导出脚本都有permute或transpose选项,重新导一次可能是最快解法。
9.3 输出形状和官方仓库对不上时怎么办
官方仓库给的 YOLOv8 输出默认是[1, 84, 8400]。但你拿到的模型可能是[1, 7, 8400],那就是自定义类别数只有 3 个;也可能是[1, 5, 8400],那就是只有单一类别加背景。信息打印会直接显示出来,别等到解析出一堆异常框再去猜。
另外,有些带 end2end 后处理的模型输出不是[1, 84, 8400],而是已经处理好的[1, 300, 6]形状。这种模型在导出时已经把 NMS 做完了,每一行对应一个检测结果,比如[x1, y1, x2, y2, score, class]。如果你的模型打印出来是这个形状,解析逻辑就要完全换一套,不要再做 NMS。
我见过最隐蔽的问题是模型导出了两个输出节点,一个是原始输出,一个是后处理输出。如果你只拿了第一个节点来解析,而后处理节点才是真正能用的结果,检测效果自然不对。这种问题轻则精度下降,重则让你怀疑自己的 C# 代码是不是写错了。
加载模型之后打印输入输出形状,看起来只是一行简单代码,却能在后面帮你避开绝大多数“模型和代码对不上”的坑。你也可以把它写成一个统一的方法,任何 .onnx 模型拿来先跑一遍再继续。这个习惯,简单但真的实用。