做.NET方向的同学想在自己项目里接入YOLO26推理,很多人第一反应是用ONNX Runtime,或者套Python写个服务再调接口。我折腾了一圈下来,发现用C#直接调OpenVinoSharp在Intel系CPU上做YOLO26部署不仅可行,而且延迟表现比预期好不少。这篇就完整记录一下我的踩坑过程,从模型准备到C#代码,再到后处理细节,全部给出来。
先说背景:我正在维护一个桌面端的质检Demo,业务方希望在现有WinForms程序里直接集成目标检测能力,不想额外开Python进程。对比过几种方案后,最后选了OpenVINO + OpenVinoSharp这条路。OpenVinoSharp是OpenVINO的C#封装库,API设计得很直观,底层直接走OpenVINO的C API,相当于在C#里拿到的推理体验和原生C++基本一致。YOLO26作为YOLO系列的最新迭代,在检测头结构上做了不少简化,部署起来比早期版本还要顺手。
1. 项目整体设计与方案选型
1.1 为什么选OpenVINO而不是其他推理后端
选择推理引擎之前,我把几个主流方案都列出来做了对比。如果只用CPU推理,OpenVINO对Intel平台有深度优化,支持异构计算,可以把预处理、推理、后处理按阶段分配到不同设备;而ONNX Runtime的CPU EP虽然通用,但少了很多针对核内指令集的优化。如果后续要上GPU,OpenVINO也提供了GPU插件,只要别在AMD/Apple芯片上指望性能就行。用我们实际的测试数据来说,在同一颗CPU上跑同一个YOLO26模型,OpenVINO的端到端耗时比ONNX Runtime默认配置大概快20%到30%,幅度相当可观。
OpenVinoSharp帮我们省去了手写P/Invoke的麻烦。它把OpenVINO的Core、CompiledModel、InferRequest等关键对象都封装成了C#类,还带了一些图像处理的辅助方法。你不需要去记C API指针的生命周期,也不用纠结什么时侯调用dispose。但要注意的是,OpenVinoSharp并非官方项目,属于社区绑定,API版本跟进需要自己睁大眼睛,后面我会专门讲版本坑。
1.2 YOLO26模型结构认知
YOLO26具体结构细节,我拿到的这份模型是基于公开权重训练的,输入尺寸默认640x640,输出张量形状为[1, 84, 8400]。其中84表示4个边界框坐标加上80个类别概率,8400是三个尺度下候选框的总数。这个输出格式和YOLOv8很像,意味着后处理阶段依然需要解码坐标并执行NMS,不能图省事直接拿输出画框。
需要注意的是,不同训练框架导出的YOLO26可能自带不同的后处理逻辑。有些版本会把NMS放进模型图里,导出后输出是[1, 300, 6]这类定长结果;有些版本导出时去掉了NMS,只输出原始预测张量。我们这里采用不含NMS的版本,因为这样通用性最强,后面自己在C#里控制置信度阈值和IoU阈值,调参也更灵活。导出模型时,如果框架提示动态shape问题,我建议直接固定到640x640,省心得多。
1.3 整体技术链路
整套方案分为三条线:模型端负责把PyTorch权重转成ONNX,再转成OpenVINO IR格式(也可以直接用ONNX加载);代码端搭建C#项目,引用OpenVinoSharp相关NuGet包;运行端需要处理好预处理的像素格式、归一化、通道顺序,以及推理后的坐标换算。三条线打通后,函数调用链大概是:
加载模型 -> 读取图片 -> 像素预处理 -> 推理 -> 解析输出 -> NMS -> 坐标映射 -> 绘制框流程本身不复杂,但每个环节都有魔鬼细节。比如预处理里的尺度缩放方式,如果直接拉伸到640x640,物体比例会变形,对检测精度影响很大。这里我采用等比缩放加padding到正方形,和训练时保持一致的letterbox操作。后面代码里会有完整实现。
2. 环境准备与工具链搭建
2.1 NuGet包版本选择
先把用到的包列出来,这是我目前测下来比较稳定的搭配:
| 包名 | 版本 |
|---|---|
| OpenVinoSharp.Core | 2024.6.0 |
| OpenVinoSharp.Extensions | 2024.6.0 |
| OpenCvSharp4 | 4.10.0 |
| OpenCvSharp4.runtime.win | 4.10.0 |
OpenVinoSharp.Extensions里带了一些图像处理方法,比如Mat和Tensor互转的接口,可以省掉不少底层代码。不过我后面还是直接用了OpenCvSharp做图像解码,因为OpenCV在图像预处理上的操作更顺手。
版本坑提醒:早期版本的OpenVinoSharp有过Tensor和OVTensor命名上的调整,如果你在网上找到旧文章,代码里的OpenVinoSharp.Core.Tensor可能已经过期,会导致编译不通过。建议先写一行测试代码确认API能否正常调用,再往项目里堆代码。
2.2 模型导出与IR转换
训练端用什么框架不重要,PyTorch导出ONNX时重点设置这几个参数:
opset_version建议12以上,我用的是12;- 输入shape固定为
[1, 3, 640, 640]; - 动态轴全部关掉;
- 输出节点只保留未经NMS的预测张量。
导出完先别急着转IR,用Python做个快速验证,读一张测试图,跑ONNX Runtime看看输出形状和数值范围。这一步能提前发现模型本身的问题,避免后面在C#里排查半天。
确认ONNX没问题后,再用OpenVINO的模型转换工具生成IR格式:
mo --input_model yolo26.onnx --output_dir ./ir --compress_to_fp16OpenVINO 2024版本里,mo命令能直接对ONNX做转换,--compress_to_fp16会让模型体积缩小一半,推理速度也略快一些。转完后会得到yolo26.xml和yolo26.bin两个文件。如果你嫌麻烦,OpenVinoSharp也支持直接加载ONNX模型,底层会自动帮你转,但第一次加载会慢一点,而且稳定性不如显式转过的IR格式。所以我建议还是多花一分钟把IR转出来。
2.3 C#项目结构规划
新建一个控制台应用或WinForms项目都行。为了演示方便,我用的控制台应用,目录结构大概这样:
Yolo26Demo/ |-- Models/ | |-- yolo26.xml | |-- yolo26.bin |-- Utils/ | |-- Preprocess.cs | |-- NMS.cs |-- Inference/ | |-- Yolo26Inference.cs |-- Program.csYolo26Inference类负责整个推理流程的封装,对外暴露一个Detect(Mat image)方法,返回检测框列表。这样UI层只需要关心画框逻辑,不用处理张量变换。
3. 核心代码实现详解
3.1 初始化推理器与加载模型
用OpenVinoSharp加载IR模型并创建推理器,代码很简洁:
using OpenVinoSharp; using OpenVinoSharp.Extensions; using OpenCvSharp; public class Yolo26Inference : IDisposable { private Core _core; private CompiledModel _compiledModel; private InferRequest _inferRequest; private const int InputWidth = 640; private const int InputHeight = 640; private const float ConfThreshold = 0.25f; private const float NmsThreshold = 0.45f; public Yolo26Inference(string modelPath, string device = "CPU") { _core = new Core(); // 如果模型是ONNX格式,可以直接加载;IR格式更推荐 _compiledModel = _core.CompileModel(modelPath, device); _inferRequest = _compiledModel.CreateInferRequest(); } public void Dispose() { _inferRequest?.Dispose(); _compiledModel?.Dispose(); _core?.Dispose(); } }这里CompileModel的第二个参数可以是"CPU"、"GPU"或"AUTO"。如果使用AUTO模式,OpenVINO会优先尝试GPU,失败后自动回退CPU,听起来不错,但实际跑起来首帧初始化时间会变长。对桌面程序来说,我习惯固定到CPU,除非明确知道用户有可用的集显或独显并且驱动没问题。
3.2 数据预处理——letterbox与张量填充
推理前需要把任意尺寸的图片缩放到640x640正方形,同时保持宽高比。实现代码如下:
public static Mat Letterbox(Mat src, int targetWidth, int targetHeight) { int srcW = src.Width; int srcH = src.Height; double scale = Math.Min((double)targetWidth / srcW, (double)targetHeight / 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)); // 创建灰色画布并放置缩放图 Mat canvas = new Mat(new Size(targetWidth, targetHeight), MatType.CV_8UC3, new Scalar(114, 114, 114)); int offsetX = (targetWidth - newW) / 2; int offsetY = (targetHeight - newH) / 2; resized.CopyTo(canvas[new Rect(offsetX, offsetY, newW, newH)]); return canvas; }注意OpenCV里Mat的宽度和高度属性,千万别写反。上面用的MatType.CV_8UC3是8位三通道。如果原图是4通道带alpha的,需要先调用Cv2.CvtColor转成BGR。
预处理紧接着要把Mat转成float[]数组。这里有一处容易踩坑:模型的输入是NCHW布局,即batch、channel、height、width。而OpenCV默认的Mat数据在内存里是HWC连续存储。需要逐像素展开并做通道重排及归一化:
public static float[] MatToTensor(Mat letterboxed) { // 假设输入是CV_8UC3,BGR int channels = 3; int height = letterboxed.Rows; int width = letterboxed.Cols; float[] tensor = new float[channels * height * width]; int index = 0; for (int c = 0; c < channels; c++) { for (int h = 0; h < height; h++) { for (int w = 0; w < width; w++) { Vec3b pixel = letterboxed.At<Vec3b>(h, w); // 输入模型需要RGB顺序(根据训练配置,YOLO26通常用RGB) // 如果模型是BGR训练的,则不需要交换 float value = c == 0 ? pixel.Item2 : c == 1 ? pixel.Item1 : pixel.Item0; tensor[index++] = value / 255.0f; } } } return tensor; }这里我假设模型训练时用的是RGB归一化到0~1的输入。实际上有些权重导出来反而期望BGR且不归一化,最稳妥的办法是拿原始训练仓库里的推理代码看一眼预处理。不匹配会导致检测置信度极低或者完全不出框,这是最常见的“模型坏了”假象。
得到float[]后,填充到OpenVinoSharp的Tensor里:
using OpenVinoSharp; ulong[] shape = { 1, 3, InputHeight, InputWidth }; Tensor inputTensor = new Tensor(OVType.Fp32, shape, tensor); _inferRequest.SetInputTensor(0, inputTensor);如果SDK版本里的Tensor构造函数签名不一样,可以改用_inferRequest.SetInputTensor("images", inputTensor),通过输入端口名称绑定。
3.3 推理执行与输出读取
调用推理只需要一行:
_inferRequest.Infer();然后取输出张量。因为模型只有一个输出端口,我们可以直接取索引0:
Tensor outputTensor = _inferRequest.GetOutputTensor(0); float[] outputData = outputTensor.GetData<float>(); ulong[] outShape = outputTensor.Shape;输出的outShape通常是[1, 84, 8400]。拿到一维数组后,要按shape重新理解数据。在C#里没有多维数组切片那么方便,我通常把它转成float[,]或者直接用索引计算。8400个候选框每个有84个值,顺序为:前4个是cx, cy, w, h在640x640尺度下的预测值,后面80个是各类别概率。注意是未经过sigmoid的logits,后处理时需要做sigmoid得到0~1概率。
3.4 解码与NMS实现
先写一个简单的sigmoid:
private static float Sigmoid(float x) => 1.0f / (1.0f + MathF.Exp(-x));遍历所有8400个候选框:
public List<DetectionResult> Decode(float[] output, int gridCount, int classCount) { List<DetectionResult> boxes = new List<DetectionResult>(); for (int i = 0; i < gridCount; i++) { int offset = i * (4 + classCount); float cx = output[offset + 0]; float cy = output[offset + 1]; float w = output[offset + 2]; float h = output[offset + 3]; float score = 0; int bestClass = -1; for (int c = 0; c < classCount; c++) { float p = Sigmoid(output[offset + 4 + c]); if (p > score) { score = p; bestClass = c; } } if (score < ConfThreshold) continue; // 预测坐标是相对于640x640输入图的,letterbox里可能有填充边,需要映射回原图 float x1 = (cx - w / 2f); float y1 = (cy - h / 2f); float x2 = (cx + w / 2f); float y2 = (cy + h / 2f); boxes.Add(new DetectionResult { X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, Score = score, ClassId = bestClass }); } return NMS(boxes); }这里的坐标是以640x640为基准的,最后映射回原图时需要用letterbox记录的比例和偏移量。假设缩放比例为scale,偏移量为(offsetX, offsetY),则:
float originalX1 = (box.X1 - offsetX) / scale; float originalY1 = (box.Y1 - offsetY) / scale; float originalX2 = (box.X2 - offsetX) / scale; float originalY2 = (box.Y2 - offsetY) / scale;NMS我用了一个最经典的实现,按分数降序贪心,用IoU过滤重叠框:
private List<DetectionResult> NMS(List<DetectionResult> boxes) { var results = new List<DetectionResult>(); var sorted = boxes.OrderByDescending(b => b.Score).ToList(); while (sorted.Count > 0) { var first = sorted[0]; results.Add(first); sorted.RemoveAt(0); for (int i = sorted.Count - 1; i >= 0; i--) { float iou = ComputeIou(first, sorted[i]); if (iou > NmsThreshold) sorted.RemoveAt(i); } } return results; } private static float ComputeIou(DetectionResult a, DetectionResult b) { float interX1 = Math.Max(a.X1, b.X1); float interY1 = Math.Max(a.Y1, b.Y1); float interX2 = Math.Min(a.X2, b.X2); float interY2 = Math.Min(a.Y2, b.Y2); float interArea = Math.Max(0, interX2 - interX1) * Math.Max(0, interY2 - interY1); float areaA = (a.X2 - a.X1) * (a.Y2 - a.Y1); float areaB = (b.X2 - b.X1) * (b.Y2 - b.Y1); return interArea / (areaA + areaB - interArea); }如果你的场景对实时性要求比较高,可以考虑把NMS改成并行版本。我目前单线程处理8400框约花费2到5毫秒,对桌面端来说完全够用。
3.5 整体调用入口
把所有方法串起来,对外暴露的检测接口长这样:
public List<DetectionResult> Detect(Mat image) { if (image.Empty()) return new List<DetectionResult>(); Mat letterboxed = Preprocess.Letterbox(image, InputWidth, InputHeight); float[] tensor = Preprocess.MatToTensor(letterboxed); ulong[] shape = { 1, 3, InputHeight, InputWidth }; Tensor inputTensor = new Tensor(OVType.Fp32, shape, tensor); _inferRequest.SetInputTensor(0, inputTensor); _inferRequest.Infer(); Tensor outputTensor = _inferRequest.GetOutputTensor(0); float[] outputData = outputTensor.GetData<float>(); var boxes = Decode(outputData, 8400, 80); // 映射回原图坐标 return boxes; }这里每个操作流程都清楚了。建议把Letterbox里算出来的scale、offsetX、offsetY也保存到字段里,供后面坐标映射使用。上面的示例为了简洁省略了这部分,实际代码中不要漏掉。
4. 部署中的常见问题与快速排查
4.1 推理结果全为0或置信度异常
这个问题我遇到过几次,大部分情况是预处理顺序和模型训练时不匹配。比如模型输入要RGB,你喂了BGR,分类概率就会一团糟。排查方式:拿一张已知物体位置的图片,走一遍完整推理,打印出最前的几个候选框的置信度,如果都低于0.01,基本就是预处理错了。可以试试把MatToTensor里的通道顺序调换,或者把归一化改成除以255再加mean/std。
另外还有一种情况,模型在导出时隐藏了前处理(比如把归一化层也固化了),那我们在C#端就不要再除以255,直接提交原始0~255像素值。判断方法是:读ONNX的输入节点,看有没有Constant节点接了Div。如果不确定,就两种方式各试一次,看哪组结果正常。
4.2 输出Tensor shape与预期不符
YOLO26有可能因训练版本不同,输出[1, 8400, 84]而不是[1, 84, 8400]。两者数据顺序完全不同,对应关系要调整。打开ONNX的时候在Python里打印一下:
import onnx model = onnx.load("yolo26.onnx") for out in model.graph.output: print(out.name, [d.dim_value for d in out.type.tensor_type.shape.dim])拿到真实shape后再写解析。很多网上教程默认是[bs, cls+4, anchors],但新版本YOLO可能改成[bs, anchors, cls+4],不能照搬。
4.3 OpenVinoSharp与OpenVINO运行时版本不匹配
OpenVinoSharp的NuGet包版本,和你在机器上安装的OpenVINO Runtime版本如果不一致,运行时会报找不到DLL之类的错误。解决办法是把OpenVinoSharp.Core依赖的原生DLL放到程序输出目录。我用的2024.6版本会自动拷贝openvino_c.dll等文件,如果你的系统装了更高版本的OpenVINO,可能因为公共运行库冲突导致初始化失败。这时候可以先卸载全局OpenVINO,只保留项目本地DLL,确保干净。
4.4 推理性能不达标的优化思路
如果发现CPU推理很慢,先检查模型是否转成了IR格式。没转IR直接用ONNX加载,会换来额外的转换开销,虽然只在第一次,但每次程序启动都会做,拉长首帧时间。其次考虑设置set_cpu_num_threads,OpenVINO默认会用满所有核心,但在桌面端容易造成UI卡顿。可以在C#里通过Core.SetConfig设置CPU线程数:
var config = new Dictionary<string, string> { { "NUM_STREAMS", "1" }, { "CPU_THREADS_NUM", "4" } }; _core.SetConfig(config, "CPU");过犹不及,线程数不是越大越好。我们测试4到6线程相对均衡。
5. 性能实测与对比记录
5.1 不同设备下的端到端耗时
测了三种设备:某笔记本的移动CPU(13代i7)、某台式机桌面CPU(10代i5)、以及一块集成显卡。模型均为IR格式,FP16精度,输入640x640,只算推理,不算预处理和后处理。
| 设备 | 平均推理耗时 | 备注 |
|---|---|---|
| 移动CPU(4核8线程) | 48ms | 打开超线程后速度变化不大 |
| 桌面CPU(6核12线程) | 32ms | 线程数设为4时最优 |
| 集成显卡GPU | 21ms | 依赖驱动和集成显卡性能,初始化慢 |
如果加上预处理和NMS,单帧总耗时落在60到100毫秒之间,对桌面质检场景完全够用。如果用实时视频流,建议把预处理改成并行,并用连续的内存块来存放图像数据,减少Mat拷贝。
5.2 与YOLOv8的横向对比
我在同一台机器上分别部署了YOLOv8s和YOLO26s,模型大小和计算量都在同一量级。对比结果:
| 模型 | 推理耗时 | mAP(验证集粗略) | 部署难度 |
|---|---|---|---|
| YOLOv8s | 39ms | 44.5% | 老教程多,踩坑少 |
| YOLO26s | 32ms | 46.9% | 新模型,资料少 |
YOLO26在精度和速度上都有小幅提升,但整体提升没有跨越式的变化。如果你手头项目已经稳定跑在YOLOv8上,没必要为了赶新鲜立刻换。但如果是新项目,直接上YOLO26是不错的选择,毕竟部署流程和v8几乎一致。
6. 进一步优化与实操心得
最后一个部分,聊几个我实际调试中觉得很有价值的小改进。
坐标回映射时,一定要记得把letterbox的偏移扣除。如果你的图不是正方形,很多人会把坐标算错,导致框在图上偏移约一个边距。最保险的做法是把letterbox过程封装成一个LetterboxInfo类,把Scale和OffsetX/OffsetY一起返回,不要在别处重新计算。
图像解码不要用Cv2.ImRead的默认方式读取中文路径,OpenCV在老版本里对中文路径支持很差,会返回空Mat。这时候先用File.ReadAllBytes读字节数组,再用Cv2.ImDecode转成Mat,实测最稳定。
关于NMS阈值,置信度阈值0.25和NMS阈值0.45在COCO类任务上比较通用,但你的业务场景中物体数量特别多或重叠严重时,建议调低置信度到0.1,同时把NMS阈值提高到0.5,先看检测上限,再根据准召率回调。
最后想说的是,OpenVinoSharp这种社区封装的项目,最大的问题不是性能,而是文档和版本碎片化。遇到编译不通过时,先检查NuGet包版本和OpenVINO运行时版本是否同系列,再去项目仓库的issue里搜关键词,基本都能找到答案。总之,用C#部署YOLO26这件事本身不复杂,复杂的是细节匹配。只要把预处理格式、输出张量布局、版本对应关系这三件事理清楚,整条链路就能顺畅跑起来。