C#实战:ONNX Runtime + OpenCvSharp 跑通YOLO目标检测
前阵子有个做桌面端开发的朋友问我能不能在C#程序里直接跑目标检测。他手上是一个成熟的.NET桌面项目所有业务逻辑都写在C#里现在需要本地加载一个模型对图片做检测最好不引入Python那一套部署链路。我第一反应是用Python不香吗PyTorch两行代码就能起来。但聊了几句就明白了他的场景是纯离线、单机运行检测结果还要跟C#业务代码深度联动为这一个功能再单独起一个Python服务运维成本、进程通信、依赖管理全是麻烦。其实这件事有一套相当成熟的组合Microsoft.ML.OnnxRuntime负责推理计算OpenCvSharp负责图像预处理和结果绘制再加上一个YOLO系列的ONNX模型就能在C#里完成目标检测的完整闭环。这篇文章就把这套流程从头到尾拆开讲一遍环境怎么搭、模型怎么加载、预处理怎么做、输出怎么解析每一步都给出可以直接抄的代码。1. 为什么用C#做目标检测这套技术栈的分工与边界1.1 C#做推理的适用场景先泼一盆冷水。如果你的目标是做模型训练、快速原型验证、大量实验调参那老老实实用Python别折腾C#。但如果你要做的是把训练好的模型部署到桌面软件、工业上位机、WinForms/WPF业务系统里那C#反而是很合理的选择。我接触过不少类似需求产线质检软件需要调用视觉模型判断工件是否有缺陷但整个上位机都是用C#写的桌面工具需要给用户上传的照片做物体识别识别结果要读写本地数据库还有的是内部工具要把图片按内容自动分类归档。这些场景有个共同点核心业务已经在C#体系里检测只是其中一个功能模块。用C#直接推理最大的好处是没有跨进程通信的开销。如果你用Python Flask起一个HTTP服务C#这边每次检测都要走一次网络请求序列化、反序列化、进程切换哪怕局域网内延迟不高也总觉得别扭。而且Python环境的部署在客户机器上就是一场灾难你永远不知道目标机器上缺什么运行库、Python版本是不是兼容、pip能不能通。ONNX Runtime的C#包把模型和运行环境都打进了程序目录随主程序一起分发体验完全不一样。1.2 三个库各管哪一段这套组合里的三个角色各司其职Microsoft.ML.OnnxRuntime微软推出的ONNX模型推理引擎C#封装很成熟支持CPU和GPUCUDA、DirectML推理。它负责把ONNX模型加载进来、跑前向计算、把结果Tensor吐出来。OpenCvSharpOpenCV的C#封装。它负责读图、缩放、填充、颜色通道转换这些图像处理也负责最后把检测框画到图片上。没有它也行但你会发现自己实现图像Resize和画框非常痛苦。YOLO系列ONNX模型这是检测能力的来源。把训练好的YOLO权重导出成ONNX格式或者直接下载预训练模型的ONNX版本整个推理管道的输入和输出结构就固定下来了。三者合在一起就是一条完整的流水线OpenCvSharp读图并完成预处理ONNX Runtime执行模型推理再回到OpenCvSharp解析结果并绘制。中间的数据纽带是两个东西输入Tensor和输出Tensor。1.3 这套组合的边界在哪再说清楚什么场景不适合这么干。ONNX Runtime只是推理引擎它不负责训练。你没法在C#里反向传播、调loss、训模型。训练还是得在Python侧完成训完导出ONNX再交给C#。另外CPU推理的性能天花板是实打实存在的。一个640x640输入的YOLO模型在普通桌面CPU上单张推理大概几十到一百毫秒级别如果目标是实时处理高清视频流建议上GPU版本的ONNX Runtime或者用DirectML来调用显卡。这些后面会讲到。还有一个很容易忽略的边界模型权重文件是跟业务绑定的。YOLO训练出来的类别集合取决于你的训练数据如果你用的是COCO 80类预训练权重那就只能识别这80类换了自定义数据集训练的模型类别映射表就得跟着换。后处理代码里的“类别数”变量是一个必须跟模型匹配的硬参数。2. 环境准备与项目骨架NuGet版本选择的讲究2.1 创建项目和安装包我用的是.NET 8创建一个最简单的控制台项目就能跑通全流程。如果你要在WPF或WinForms里集成核心代码完全一样只是最后显示结果的方式不同。初始化项目dotnet new console -n Yolo26Demo cd Yolo26Demo然后添加NuGet包dotnet add package Microsoft.ML.OnnxRuntime dotnet add package OpenCvSharp4 dotnet add package OpenCvSharp4.runtime.win这里有个新手高频翻车点OpenCvSharp4本体只是C#封装原生运行库在OpenCvSharp4.runtime.win里两个都要装漏掉后者运行时会直接报DllNotFoundException。我用的是4.9.0之后的新版本C#封装的API风格已经很稳定了。OnnxRuntime这边默认装的包支持CPU推理。如果需要CUDA加速要装Microsoft.ML.OnnxRuntime.Gpu这个元包并在创建Session时指定CUDA的ExecutionProvider。后面单独说。2.2 项目目录和模型文件放置为了让流程清晰我会在项目里建这么几个目录Yolo26Demo/ ├── Yolo26Demo.csproj ├── Models/ │ └── yolo26.onnx ├── Images/ │ └── test.jpg ├── Labels/ │ └── coco.names └── Program.cs模型文件、图片、类别名文件各放各的目录。注意一个细节在csproj里要把模型和图片的“复制到输出目录”属性设置成“如果较新则复制”否则跑起来会发现找不到文件。在Visual Studio里就是右键文件、属性、复制到输出目录选“Copy if newer”命令行的话可以在csproj里加ItemGroup None UpdateModels\*.onnx CopyToOutputDirectoryPreserveNewest/CopyToOutputDirectory /None None UpdateImages\*.* CopyToOutputDirectoryPreserveNewest/CopyToOutputDirectory /None /ItemGroup2.3 版本之间的兼容性提醒OnnxRuntime和OpenCvSharp都是活跃更新的库版本大跨度升级一般不影响本文涉及的基本API但有个坑值得注意如果你将来换用GPU包注意CPU和GPU版本的包名差异并确保显卡驱动支持的CUDA版本跟ONNX Runtime要求的版本匹配。这块我见过有人装完Gpu包后跑不起来最后发现是CUDA版本太旧跟运行时不兼容。另外OpenCvSharp4.runtime.win只管Windows。如果你要跨平台做Linux部署需要用OpenCvSharp4.runtime.ubuntu之类的对应包或者干脆用OpenCvSharp4的源代码自行编译原生库。桌面场景下Windows最常见我这边全部按Windows来写。3. YOLO26的ONNX模型输入输出张量约定3.1 模型从哪里来这里稍微解释一下标题里的YOLO26。你可以把它理解为YOLO系列某一版本的ONNX导出模型市面上常见的YOLOv5、YOLOv8、YOLO11导出成ONNX之后接入C#的方式是一模一样的。如果你手头有自己训练好的YOLO模型从PyTorch导出ONNX时会指定输入输出名和动态轴如果你只是入门学习用官方预训练权重转好的.onnx文件就行。本文后续代码里我就用yolo26.onnx这个文件名来代指你换成自己实际拿到的模型文件即可。拿到ONNX模型后不要凭记忆猜输入输出结构直接写几行代码打印出Session的元数据using Microsoft.ML.OnnxRuntime; var session new InferenceSession(Models/yolo26.onnx); foreach (var item in session.InputMetadata) { Console.WriteLine($Input: {item.Key}, Shape: [{string.Join(,, item.Value.Dimensions)}]); } foreach (var item in session.OutputMetadata) { Console.WriteLine($Output: {item.Key}, Shape: [{string.Join(,, item.Value.Dimensions)}]); }我实测过典型的YOLO模型输出大概是输入imagesShape为[1, 3, 640, 640]即NCHW格式1张图、3个通道RGB、宽高640。输出output0Shape可能是[1, 84, 8400]或[1, 8400, 84]。这两个layout是不同的模型导出风格导致的。8400是输入640x640图像被模型下采样后生成的候选框anchor总数量。84来自“4个坐标 80个类别得分”对应COCO数据集的80类。如果你的模型是自定义类别数比如25类那这个数字就是42529。3.2 NCHW和NHWC的差别ONNX Runtime喂给模型的数据布局默认是NCHWbatch维度在最前然后通道维度Channel再是高Height和宽Width。而OpenCV读出来的图像是HWC连续存储的高、宽、通道而且通道顺序是BGR。这两个差异是所有预处理代码里最容易出错的地方。简单类比一下NCHW就像把三张平面图叠在一起R通道一张、G通道一张、B通道一张每个通道单独铺开HWC则是把每个像素的BGR三个值挨着写一行像素挨着一行像素。转换的过程就是要把原来“每个像素三个通道连续排列”的数据重排成“每个通道所有像素连续排列”。3.3 动态Shape的处理有些模型导出时把宽高设成动态轴元数据里显示的是[1, 3, -1, -1]。这表示输入尺寸不固定你可以送任意长宽进去。但实际用的时候我强烈建议固定成640x640原因有两个一是很多模型的锚点网格是按640训练的随意改尺寸会降低检测精度二是动态Shape在不同的输入尺寸间切换会有额外开销。后处理代码也更好写不用动态适配网格大小。如果模型支持动态轴你在构造输入Tensor时可以直接传[1, 3, height, width]Runtime会根据你实际传进去的尺寸分配。但为了流程稳定本文按固定640x640来写。4. 图像预处理letterbox缩放、归一化与内存布局4.1 为什么必须用letterbox而不是直接Resize很多人第一次写预处理直接把图Resize成640x640检测结果一塌糊涂。原因在于YOLO训练时使用了一种叫letterbox的预处理也就是把原图按比例缩放到640x640的框内多余的区域用灰色填充。比如一张1920x1080的横图等比缩放后高度只有360左右上下会各填充140像素的灰色边。如果你不填充、直接拉伸成640x640图像里的物体会变形。模型训练时没见过变形的输入检测精度自然下降。尤其是一些宽高比极端的图片直接Resize会让细长物体完全失真。所以letterbox不是可选项是必须项。填充用灰色114的原因也很有意思COCO数据集预处理时就是用(114,114,114)作为填充值模型在训练阶段见过的letterbox图就是这个颜色推理保持同款操作输入分布才能一致。4.2 Letterbox的代码实现using OpenCvSharp; static Mat Letterbox(Mat src, int targetSize, out float scale, out float padX, out float padY) { int srcW src.Width; int srcH src.Height; scale Math.Min((float)targetSize / srcW, (float)targetSize / srcH); int newW (int)Math.Round(srcW * scale); int newH (int)Math.Round(srcH * scale); Mat resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); padX (targetSize - newW) / 2.0f; padY (targetSize - newH) / 2.0f; Mat canvas new Mat(targetSize, targetSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); Rect roi new Rect((int)padX, (int)padY, newW, newH); resized.CopyTo(canvas[roi]); return canvas; }这段代码里我用了整数pad偏移来定位roi但计算时保留了float的padX/padY后者是后面坐标还原要用到的关键参数。有一个细节如果原图宽高比导致比值的商不是整数Math.Round之后缩放后的尺寸可能跟640略差一个像素这时pad会有零点几像素的偏差检测框还原时会有1像素以内的误差实际可忽略。如果追求严谨可以把scale改成targetSize / Math.Max(srcW, srcH)的写法把较长边拉到640短边按比例缩放再用(int)截断。4.3 BGR转RGB、归一化和HWC转CHWOpenCV默认读图是BGR通道顺序而大多数YOLO模型训练用的是RGB。所以需要倒一下通道顺序。同时模型输入期望像素值在0到1之间而读出来的像素是0到255需要做归一化。下面这段是核心代码static float[] ExtractPixels(Mat bgr, int targetSize) { float[] data new float[3 * targetSize * targetSize]; for (int y 0; y targetSize; y) { for (int x 0; x targetSize; x) { Vec3b pixel bgr.AtVec3b(y, x); // OpenCV 的 Vec3b 是 BGR 顺序 int rIndex 0 * targetSize * targetSize y * targetSize x; int gIndex 1 * targetSize * targetSize y * targetSize x; int bIndex 2 * targetSize * targetSize y * targetSize x; data[rIndex] pixel[2] / 255.0f; data[gIndex] pixel[1] / 255.0f; data[bIndex] pixel[0] / 255.0f; } } return data; }这段代码可读性高但性能一般。Mat.AtVec3b在单像素访问时每次都要做边界检查和类型转换对640x640共约40万像素来说总体耗时也就几十毫秒演示够用。如果要优化可以用Mat的GetArray把数据一次性取出来或者用不安全代码直接操作内存指针。我在生产项目里用的就是后者本质上是同一套逻辑只是把循环展开性能能快不少。有个值得注意的性能技巧如果你只是倒通道顺序可以用OpenCV的Cv2.CvtColor(color, color, ColorConversionCodes.BGR2RGB)但这样会多一次内存拷贝。更高效的做法是像我上面这样在填充float数组时直接把通道顺序颠倒过来省掉一次中间转换。4.4 预处理流水线的整体串联把读图和预处理放在一起string imagePath Images/test.jpg; using Mat src Cv2.ImRead(imagePath, ImreadModes.Color); if (src.Empty()) { Console.WriteLine(图片读取失败); return; } float scale; float padX, padY; using Mat letterboxed Letterbox(src, 640, out scale, out padX, out padY); float[] inputData ExtractPixels(letterboxed, 640);到这一步inputData就是一个长度为3*640*640的float数组按CHW顺序排列取值在0到1之间。接下来就交给ONNX Runtime了。5. 跑通ONNX Runtime推理Session创建与Run调用5.1 创建InferenceSessionInferenceSession可以直接从模型文件路径创建using var session new InferenceSession(Models/yolo26.onnx);如果你想在程序里一边加载一边显示进度也可以先读成字节数组再从字节数组创建Session。区别不大。这里有个隐藏的性能细节Session在创建时会加载模型结构、分配内存、做图优化。如果检测是循环执行的比如视频流这个过程只做一次放到程序启动阶段不要把new InferenceSession写进循环里。我见过有人每帧都new一个SessionCPU直接被干满速度慢得离谱。如果不想用默认的Session配置可以通过SessionOptions做一些调整var options new SessionOptions(); options.EnableMemoryPattern true; options.ExecutionMode ExecutionMode.ORT_SEQUENTIAL; options.IntraOpNumThreads Environment.ProcessorCount; using var session new InferenceSession(Models/yolo26.onnx, options);IntraOpNumThreads控制算子内部的线程数。对于单张图片推理线程数不是越多越好通常跟物理核心数相近就行。ORT_SEQUENTIAL表示算子按图顺序执行看起来保守但对帧级检测来说反而稳定。5.2 准备输入TensorONNX Runtime的输入是Tensorfloat对象需要指定维度using Microsoft.ML.OnnxRuntime.Tensors; var dims new[] { 1, 3, 640, 640 }; var inputTensor new DenseTensorfloat(inputData, dims); var inputName session.InputMetadata.Keys.First(); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(inputName, inputTensor) };inputName从元数据里读不要硬编码。不同模型导出时的输入名可能不同有的叫images有的叫input还有的叫data。从元数据拿最稳妥。5.3 执行Run并读取输出using var results session.Run(inputs); var outputTensor results.First().AsTensorfloat(); var outputDims outputTensor.Dimensions.ToArray(); Console.WriteLine($输出维度: [{string.Join(, , outputDims)}]);注意两点第一results要放在using里。Run返回的IDisposableReadOnlyCollectionNamedOnnxValue持有推理结果的引用不及时释放可能会在循环推理场景下造成内存持续增长。第二outputTensor不要跨越using块提前引用。如果你后面要做复杂的后处理我建议在using块内把数据拷贝到自己的数组里或者直接在using块内完成全部解析。float[] outputData new float[outputTensor.Length]; outputTensor.Buffer.Span.CopyTo(outputData);拷贝完成后就可以安心释放results了。这一步的代价是约84*8400个float的复制也就是几MB的数据对性能影响很小但会让后处理代码简洁很多、也安全很多。5.4 完整推理流程从预处理到拿到原始输出全链路如下using var session new InferenceSession(Models/yolo26.onnx); string inputName session.InputMetadata.Keys.First(); using Mat src Cv2.ImRead(Images/test.jpg, ImreadModes.Color); float scale, padX, padY; using Mat letterboxed Letterbox(src, 640, out scale, out padX, out padY); float[] inputData ExtractPixels(letterboxed, 640); var inputTensor new DenseTensorfloat(inputData, new[] { 1, 3, 640, 640 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(inputName, inputTensor) }; using var results session.Run(inputs); var outputTensor results.First().AsTensorfloat(); float[] outputData new float[outputTensor.Length]; outputTensor.Buffer.Span.CopyTo(outputData); var outputDims outputTensor.Dimensions.ToArray();跑完这一步outputData里就是模型的原始输出能不能变成检测框全看后处理怎么写。这也是整个链路里最容易写错的部分。6. 后处理才是难点坐标解码、置信度筛选与非极大值抑制6.1 先搞清楚输出Tensor的布局拿到原始输出数组后第一件事是判断layout。我遇到过的YOLO模型输出常见有两种[1, 84, 8400]: 第一维是batch第二维是特征4坐标80类第三维是候选框索引。[1, 8400, 84]: 第一维batch第二维候选框索引第三维特征。两种布局在内存展开后其实内容一样只是索引顺序反了。为了保证兼容我用维度信息做一次分支int batchSize outputDims[0]; int dim1 outputDims[1]; int dim2 outputDims[2]; bool layout84First dim1 84; // 1x84x8400 int numBoxes layout84First ? dim2 : dim1; int numFeatures layout84First ? dim1 : dim2;注意如果模型支持动态Shape输出维度里可能含-1此时dim1和dim2中会有一个是-1。这种情况可以用outputDims[^1]和outputDims[^2]结合numBoxes的计算来容错但我建议在导出ONNX时把输出固定成静态Shape代码会省心很多。6.2 从84个特征值里解析一个候选框先说结论一个候选框对应84个float。前4个是center_x, center_y, width, height相对于640x640输入图像的像素坐标第4到第84是80个类别的置信度得分。解析每个候选框的流程是取出坐标值。遍历从索引4开始的所有类别得分找到最大的得分和它对应的类别编号。如果最大得分低于阈值比如0.25丢弃。否则把坐标从中心宽高格式转成左上角右下角格式。class Detection { public Rect Box { get; set; } // XYXY public float Confidence { get; set; } public int ClassId { get; set; } } const int NumClasses 80; const float ConfidenceThreshold 0.25f; var rawDetections new ListDetection(); for (int i 0; i numBoxes; i) { float cx, cy, w, h; if (layout84First) { cx outputData[0 * numBoxes i]; cy outputData[1 * numBoxes i]; w outputData[2 * numBoxes i]; h outputData[3 * numBoxes i]; } else { int offset i * numFeatures; cx outputData[offset 0]; cy outputData[offset 1]; w outputData[offset 2]; h outputData[offset 3]; } float bestScore 0; int bestClass -1; for (int c 0; c NumClasses; c) { float score layout84First ? outputData[(c 4) * numBoxes i] : outputData[i * numFeatures c 4]; if (score bestScore) { bestScore score; bestClass c; } } if (bestScore ConfidenceThreshold) continue; float x1 cx - w / 2; float y1 cy - h / 2; float x2 cx w / 2; float y2 cy h / 2; rawDetections.Add(new Detection { Box new Rect((int)x1, (int)y1, (int)(x2 - x1), (int)(y2 - y1)), Confidence bestScore, ClassId bestClass }); }这段代码做的事情本质上是“把8400个候选框里明显没用的框去掉”。经过这一步通常能从8400个候选框里筛出几十个有效框。这里有一个很多人踩过的坑不要用“objectness分数”乘“类别分数”作为最终置信度。YOLOv8及之后的新版本模型输出里已经不再有objectness直接就是类别得分。如果你用的是YOLOv5系列的旧导出格式那5个特征位才是坐标objectness从索引5开始是80个类别。处理时务必先确认你的模型属于哪种格式。6.3 非极大值抑制NMS筛选完置信度之后同一只物体会被多个相近的候选框命中。NMS的作用就是保留每个物体最合适的那个框去掉重叠度过高的框。经典贪心NMS逻辑所有候选框按置信度从高到低排序取置信度最高的框加入结果集同时把所有跟它IoU超过阈值的框删掉重复直到列表为空。const float IouThreshold 0.45f; static float IoU(Rect a, Rect b) { int x1 Math.Max(a.Left, b.Left); int y1 Math.Max(a.Top, b.Top); int x2 Math.Min(a.Right, b.Right); int y2 Math.Min(a.Bottom, b.Bottom); int interW Math.Max(0, x2 - x1); int interH Math.Max(0, y2 - y1); float interArea interW * interH; float unionArea a.Width * a.Height b.Width * b.Height - interArea; return unionArea 0 ? 0 : interArea / unionArea; } var result new ListDetection(); var candidates new ListDetection(rawDetections); candidates.Sort((a, b) b.Confidence.CompareTo(a.Confidence)); while (candidates.Count 0) { var best candidates[0]; result.Add(best); candidates.RemoveAt(0); candidates.RemoveAll(d IoU(best.Box, d.Box) IouThreshold); }NMS的阈值调参很有讲究。IoUThreshold设得太小比如0.3会导致密集场景下重叠物体被误删漏检率升高太大比如0.7又会允许大量重叠框通过图上出现一堆重复框。我用0.45作为默认值具体项目要根据实测微调。如果把这段代码用于生产要注意RemoveAll里嵌套IoU的复杂度是O(n²)但经过置信度筛选之后候选框通常在几十到两三百之间完全跑得动。6.4 坐标还原到原图在前面的预处理里我把原图等比缩放并padding到了640x640。现在得到的框是在640x640坐标系里的要还原回原图坐标系用之前存下的scale和padX/padYstatic Rect MapToOriginal(Rect box, float scale, float padX, float padY) { float x1 (box.Left - padX) / scale; float y1 (box.Top - padY) / scale; float x2 (box.Right - padX) / scale; float y2 (box.Bottom - padY) / scale; x1 Math.Max(0, x1); y1 Math.Max(0, y1); x2 Math.Min(srcWidth - 1, x2); y2 Math.Min(srcHeight - 1, y2); return new Rect( (int)Math.Round(x1), (int)Math.Round(y1), (int)Math.Round(x2 - x1), (int)Math.Round(y2 - y1) ); }这里要取srcWidth和srcHeight也就是原图的实际宽高在预处理之前就记下来int srcWidth src.Width; int srcHeight src.Height;如果你用的是类名文件类别ID转换成可读名称var labels File.ReadAllLines(Labels/coco.names); string labelName labels[det.ClassId];7. 结果可视化与性能优化画框、计时、GPU会话7.1 OpenCvSharp绘制检测框后处理拿到的result列表已经包含了所有需要的信息。接下来在图上画出来foreach (var det in result) { Rect box MapToOriginal(det.Box, scale, padX, padY); string label ${labels[det.ClassId]} {det.Confidence:F2}; Cv2.Rectangle(src, box, new Scalar(0, 255, 0), 2); Cv2.PutText(src, label, new Point(box.Left, box.Top - 5), HersheyFonts.HersheySimplex, 0.6, new Scalar(0, 255, 0), 1); } Cv2.ImWrite(Images/output.jpg, src);Scalar(0, 255, 0)是OpenCV的BGR颜色表示绿色。如果想区分不同类别的框可以定义一个颜色数组按类别ID取色。PutText的字体大小和线宽要跟图片分辨率匹配640x640小图上0.6字体刚合适大图就要相应调大。7.2 中文路径读取的坑这里必须分享一个我在实际项目里踩过的坑Cv2.ImRead在Windows下读取中文路径文件会静默失败Mat.Empty()直接返回true但不报异常。排查半天才发现是路径编码问题。解决方案是先读字节再用ImDecodestring path Images/测试图片.jpg; using Mat src Cv2.ImDecode(File.ReadAllBytes(path), ImreadModes.Color); if (src.Empty()) { Console.WriteLine(图片读取失败); return; }同理Cv2.ImWrite写中文文件名也可能出问题可以用Cv2.ImEncode先编码成字节数组再File.WriteAllBytes写文件。这个坑不算难解决但它发生得悄无声息很容易让人怀疑是模型问题。7.3 推理耗时统计想知道预处理、推理、后处理分别花了多少时间最直接的办法是Stopwatch分段记录var sw System.Diagnostics.Stopwatch.StartNew(); // 预处理... sw.Stop(); Console.WriteLine($预处理耗时: {sw.ElapsedMilliseconds} ms); sw.Restart(); // session.Run... sw.Stop(); Console.WriteLine($推理耗时: {sw.ElapsedMilliseconds} ms); sw.Restart(); // 后处理... sw.Stop(); Console.WriteLine($后处理耗时: {sw.ElapsedMilliseconds} ms);我实测过在普通台式机的CPU上跑640x640的YOLO模型推理耗时通常在50-120毫秒之间预处理约10-30毫秒后处理因为在解析8400个候选框时做了大量索引计算实际也要几毫秒到十几毫秒。如果发现推理耗时异常高先看看是不是把Session创建放进循环里了如果发现预处理偏高多半是Mat.AtVec3b的逐像素访问太慢可以考虑用指针操作。7.4 开启GPU加速如果你的目标是视频级的实时检测CPU推理扛不住可以上GPU。ONNX Runtime在Windows下有两个加速路径CUDA和DirectML。CUDA需要NVIDIA显卡安装对应版本的CUDA和cuDNN然后var options new SessionOptions(); options.AppendExecutionProvider_CUDA(0); using var session new InferenceSession(Models/yolo26.onnx, options);DirectML则支持更广的显卡范围包括AMD和Intel核显var options new SessionOptions(); options.AppendExecutionProvider_DML(); using var session new InferenceSession(Models/yolo26.onnx, options);使用GPU的时候输入Tensor的创建跟CPU一样不需要额外改动。但要注意GPU Session从创建到第一次推理需要初始化显存和编译kernel第一次调用会明显偏慢这是正常的。性能数据要等“预热”几帧之后再统计才准确。7.5 一个容易忽略的优化点如果某个场景要连续处理大量图片比如批量识别一个文件夹里的几百张图可以把图片预处理里的循环改成并行。但注意ONNX Runtime的Session本身已经用了多线程多线程送图到同一个Session不一定会更快反而可能因为内部锁竞争变慢。更实用的优化方向是减小输入尺寸。如果检测对象是小物体640x640不能动但如果你的场景只关心大目标可以尝试把输入降到416x416或320x320推理速度会成倍提升。代价是漏检小物体的概率增加需要针对业务场景实测验证。我自己在实际项目里还有个经验模型输出解析那一步是后处理的关键瓶颈。如果你要追求极致性能可以考虑用Span而不是数组索引或者把outputData按连续内存的方式只遍历一次同时完成坐标解析、置信度筛选、NMS的候选过滤。不过这是性能调优阶段的活第一版先把功能跑通最重要。最后再分享一个实操建议调试阶段不要把模型、代码、阈值一股脑全上先拿一张简单图片跑通流程确认能画出框再逐步换复杂图片调参。目标检测这套链路预处理稍微错一点、坐标还原算错一个比例结果都会很诡异但代码本身又不会报错。遇到检测框位置完全不对、大小离谱的情况九成是letterbox的pad和scale没对上或者通道顺序没转。把预处理和后处理的数值打印出来对照一遍问题基本就浮出水面了。