ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#上位机集成YOLOv11:基于ONNX Runtime的完整部署指南

C#上位机集成YOLOv11:基于ONNX Runtime的完整部署指南 简介基于C#平台、.NET Framework 4.7.2环境成功部署YOLO-V11深度学习模型的完整资源包面向需要在Windows端实现目标检测的C#开发者和AI部署工程师。资源以ONNX格式模型为核心结合Microsoft.ML.OnnxRuntime 1.19.2推理引擎与OpencvSharp4.0图像处理库展示了从模型加载、图像预处理到推理输出的全流程工程实现。资源包内共342个文件压缩后约879.71MB包含onnx模型、C#源码、已编译DLL、NuGet依赖包、配置及说明文档等目录结构清晰便于直接参考或二次开发。目前已有248人学习下载适用于希望避开Python环境、在纯.NET生态中集成YOLO-V11的开发者。借助资源包可快速掌握ONNX Runtime调用方式、OpenCvSharp图像预处理技巧以及C#深度学习应用的项目组织方法为工业级视觉项目落地提供切实可复用的工程样例。 如果你和我一样主要做C#上位机开发但手里突然被塞了一个训练好的YOLOv11模型要求集成到现有的WinForms或工控软件里——那这篇文章就是给你看的。我把整个流程完整跑通了一遍从把yolo11n.pt导出成ONNX到在.NET Framework 4.7.2环境下用ONNX Runtime做推理再到后处理解析、NMS过滤、性能优化全程脱离Python环境最后嵌入到现有上位机项目里稳定运行。整个过程踩了不少坑今天一次性整理出来保证每一步都能跟着做。这个需求其实比听起来更普遍。很多做视觉检测的上位机工程师手头项目跑的还是老框架工控机上不方便部署Python客户也不可能让你重写整套软件。而YOLOv11又是目前落地性价比很高的检测模型精度和速度都够用。所以这篇博文的核心就是在不改变原项目框架结构的前提下把深度学习模型无缝部署进去。1. 先搞清楚为什么非要在.NET Framework 4.7.2上跑深度学习模型1.1 老平台上位机的现实处境很多工控领域的项目跑的依然是.NET Framework 4.x WinForms的组合原因无非是历史包袱太重第三方相机SDK、PLC通信库、数据库组件都是基于.NET Framework编译的迁到.NET Core/现代.NET成本极高。加上工控机的操作系统版本普遍较老现场运行环境也五花八门能不动现有框架就绝不动这是最稳妥的工程决策。在这种情况下想把深度学习视觉检测塞进去最大的难点反而是环境兼容性而不是算法本身。Python训练好的模型再牛总不能要求客户现场装Anaconda、配CUDA、搭PyTorch环境吧所以必须找一个能在老框架下稳定运行、部署简单、不依赖庞大运行时的方式把模型“搬”到C#这边来。1.2 方案选型ONNX Runtime是最平滑的路线我在动手之前把主流方案都过了一遍简单列一下对比方案是否支持.NET Framework 4.7.2推理性能部署复杂度维护活跃度ML.NET支持但自定义视觉模型支持一般中中等一般TensorFlow.NET支持但绑定API陈旧中高低OpenCvSharp DNN模块支持中上中高ONNX Runtime支持高有CPU/GPU加速低一个原生DLL搞定非常高综合下来ONNX Runtime OpenCvSharp的组合是最优解。ONNX Runtime是微软和社区共同维护的跨平台推理引擎C#封装层已经做得很完善NuGet包直接引用就能用。OpenCvSharp负责图像读取、缩放、颜色转换等预处理工作两个库加在一起项目引用和原生DLL加起来也不大拷贝到工控机上就能跑不需要装任何Python环境。注意网上有些人推荐直接用OpenCvSharp的DNN模块跑ONNX确实也能跑但OpenCvSharp内置的NMS等后处理能力偏弱YOLO输出解析要自己写大量代码而且OpenCV自带DNN对部分新算子支持不及时。ONNX Runtime在算子兼容性和推理优化上都更稳所以我还是推荐用ONNX Runtime做推理OpenCvSharp只负责图像处理。2. 模型导出从ultralytics拿到可部署的ONNX文件2.1 导出命令与参数选择先用Python环境把训练好的.pt权重导出为ONNX格式。这里假设你已经有一个训练好的yolo11n.pt或者是官方预训练权重也可以。在训练环境中执行pip install ultralytics onnx onnxsim yolo export modelyolo11n.pt formatonnx dynamicTrue imgsz640 simplifyTrue几个关键参数说明一下formatonnx导出ONNX格式这是所有部署环境都能识别的通用模型格式。imgsz640推理输入尺寸。YOLOv11默认训练尺寸是640x640如果你训练时改了尺寸这里要对应改。不要为了省一点推理时间盲目减小精度损失会让客户觉得你的检测“变笨了”。dynamicTrue允许输入动态尺寸。这个参数我建议导出时开着方便调试阶段用不同尺寸验证但C#端真正推理时还是固定640x640动态尺寸只作为后备方案。simplifyTrue用onnxsim对模型结构做简化删掉冗余算子推理速度会快一点点文件体积也更小。如果你用的是自定义数据集类别数不是COCO的80类导出的输出维度会从1x84x8400变成1x(4N)x8400这个细节后面写后处理代码时会用到千万记得在自己的模型上确认一遍输出shape。2.2 导出的模型结构解析用Netron打开导出的yolo11n.onnx你会看到输入节点名通常叫images输出节点名通常叫output0这是ultralytics默认的命名。输入shape是(1, 3, 640, 640)输出shape是(1, 84, 8400)。这里面的数据含义很重要8400表示模型在3个尺度下生成的候选框总数640x640输入时对应80x80 40x40 20x20 8400个候选位置。84表示每个候选框的属性向量长度前4个是目标的坐标信息中心点x、中心点y、宽、高后80个是COCO数据集的类别置信度。如果是自定义类别数N这里就是4N。输出数据是按列存储的也就是说在C#里解析时内存布局是[batch, 84, 8400]你遍历8400个候选框时跨步是8400而不是84这个顺序搞反了会导致解析出来的结果完全错乱。3. C#端推理代码三步走实现检测3.1 环境搭建NuGet包版本选择在Visual Studio中新建一个.NET Framework 4.7.2的WinForms项目或者直接加到你的现有项目里然后通过NuGet安装三个包Microsoft.ML.OnnxRuntime 1.15.1 OpenCvSharp4.Windows 4.8.0.20230708 OpenCvSharp4 4.8.0.20230708版本选择上我特意说明一下ONNX Runtime的C#包从1.16版本开始对.NET Framework的支持策略有调整虽然理论上仍然兼容但我实测下来1.15.1在4.7.2下最稳各种DLL依赖问题最少。OpenCvSharp4.Windows自带runtime.win会自动把原生DLL复制到输出目录省去手动配置的麻烦。装完包之后记得把项目的平台目标改成x64。ONNX Runtime的原生DLL是区分架构的你在“项目属性 - 生成 - 平台目标”里选择x64如果默认是AnyCPU运行时会找不到对应的原生DLL直接报DllNotFoundException。3.2 图像预处理从图片到Tensor模型输入要求是一张640x640x3的RGB图像像素值归一化到0~1之间。我们在C#端拿到相机或图像文件后通常是一张任意尺寸的BGR图像所以要经过几个处理步骤letterbox缩放、BGR转RGB、HWC转CHW、归一化。using OpenCvSharp; // 返回处理后的Mat和缩放比例、填充尺寸 private Mat PreprocessImage(Mat src, out float scale, out int padX, out int padY, int inputSize 640) { int newW, newH; float scaleX (float)inputSize / src.Width; float scaleY (float)inputSize / src.Height; scale Math.Min(scaleX, scaleY); newW (int)(src.Width * scale); newH (int)(src.Height * scale); padX (inputSize - newW) / 2; padY (inputSize - newH) / 2; Mat resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); // 使用CopyMakeBorder做letterbox填充填充色为灰色114 Mat canvas new Mat(inputSize, inputSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new Rect(padX, padY, newW, newH)]); // BGR - RGB Mat rgb new Mat(); Cv2.CvtColor(canvas, rgb, ColorConversionCodes.BGR2RGB); return rgb; }然后把这个Mat转成一个float[]数组顺序是[通道][高度][宽度]每个像素除以255.0f归一化private float[] MatToTensor(Mat img, int inputSize 640) { // 确保数据连续 Mat continuousImg img.IsContinuous() ? img : img.Clone(); byte[] bytes new byte[inputSize * inputSize * 3]; Marshal.Copy(continuousImg.Data, bytes, 0, bytes.Length); float[] tensor new float[3 * inputSize * inputSize]; // YOLO输入是RGBCHW顺序 for (int c 0; c 3; c) { for (int h 0; h inputSize; h) { for (int w 0; w inputSize; w) { int byteIndex h * inputSize * 3 w * 3 c; tensor[c * inputSize * inputSize h * inputSize w] bytes[byteIndex] / 255.0f; } } } return tensor; }注意这一步千万别用mat.GetArray或者逐像素Atbyte去读速度会慢到怀疑人生。上面的方案是直接取Mat底层字节数组再手动转CHW速度基本能满足实时性要求。3.3 使用ONNX Runtime执行推理核心推理代码非常简洁加载模型、创建Session、构造输入Tensor、Run整个流程不超过20行using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class YoloV11Predictor : IDisposable { private readonly InferenceSession _session; private readonly string _inputName; private const int InputSize 640; public YoloV11Predictor(string modelPath) { _session new InferenceSession(modelPath); _inputName _session.InputMetadata.Keys.First(); } public float[] Run(float[] inputTensor) { var input new DenseTensorfloat(inputTensor, new[] { 1, 3, InputSize, InputSize }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_inputName, input) }; using (var results _session.Run(inputs)) { var output results.First().AsTensorfloat(); // 拷贝输出到float数组方便后续解析 float[] outputArray new float[output.Length]; output.Buffer.Span.CopyTo(outputArray); return outputArray; } } public void Dispose() _session.Dispose(); }这里输出tensor的shape是(1, 84, 8400)results.First()拿到的是第一个输出节点的数据直接转成float[]就行。3.4 后处理坐标解码 置信度过滤 NMS这一步是整个部署过程里最容易写错、也最影响性能的地方。我们先从输出数组里还原出每个候选框的坐标和类别置信度public class Detection { public float X1, Y1, X2, Y2; // 左上角和右下角坐标 public float Confidence; // 置信度 public int ClassId; // 类别ID } private ListDetection PostProcess(float[] output, int numClasses, float confThreshold, float nmsThreshold, int inputSize) { int numAnchors output.Length / (numClasses 4); // 8400 ListDetection detections new ListDetection(); // 输出数组按 (1, 84, 8400) 排列注意跨步是 numAnchors for (int i 0; i numAnchors; i) { float maxScore 0; int maxClassId -1; for (int j 0; j numClasses; j) { float score output[(4 j) * numAnchors i]; if (score maxScore) { maxScore score; maxClassId j; } } if (maxScore confThreshold) continue; float xCenter output[i]; float yCenter output[numAnchors i]; float width output[2 * numAnchors i]; float height output[3 * numAnchors i]; detections.Add(new Detection { X1 xCenter - width / 2, Y1 yCenter - height / 2, X2 xCenter width / 2, Y2 yCenter height / 2, Confidence maxScore, ClassId maxClassId }); } // NMS过滤重叠框 return NMS(detections, nmsThreshold); }NMS的实现有很多种网上也有现成的类库但在.NET Framework环境里我建议用最朴素的循环实现别用LINQ具体原因在第4章会展开说。这里提供一个手写NMS版本private ListDetection NMS(ListDetection detections, float nmsThreshold) { ListDetection result new ListDetection(); // 按置信度降序排列 Detection[] sorted detections.OrderByDescending(d d.Confidence).ToArray(); bool[] suppressed new bool[sorted.Length]; for (int i 0; i sorted.Length; i) { if (suppressed[i]) continue; result.Add(sorted[i]); for (int j i 1; j sorted.Length; j) { if (suppressed[j]) continue; float iou ComputeIoU(sorted[i], sorted[j]); if (iou nmsThreshold) suppressed[j] true; } } return result; } private float ComputeIoU(Detection a, Detection b) { float x1 Math.Max(a.X1, b.X1); float y1 Math.Max(a.Y1, b.Y1); float x2 Math.Min(a.X2, b.X2); float y2 Math.Min(a.Y2, b.Y2); float interW Math.Max(0, x2 - x1); float interH Math.Max(0, y2 - y1); float interArea interW * interH; float areaA (a.X2 - a.X1) * (a.Y2 - a.Y1); float areaB (b.X2 - b.X1) * (b.Y2 - b.Y1); float unionArea areaA areaB - interArea; return unionArea 0 ? 0 : interArea / unionArea; }后处理拿到检测框之后别忘了把坐标从640x640还原回原图尺寸。这一步很简单用前面记录的scale、padX、padY反算回去即可float ox (det.X1 - padX) / scale; float oy (det.Y1 - padY) / scale; float ow (det.X2 - padX) / scale; float oh (det.Y2 - padY) / scale;4. 实战中踩过的坑DLL加载、维度匹配、NMS耗时4.1 ONNX Runtime加载失败99%是平台目标不对我第一次跑起来就报了一个经典错误System.DllNotFoundException: 无法加载 DLL“onnxruntime”: 找不到指定的模块。排查步骤其实就三步第一步确认项目平台目标是x64。VS默认的AnyCPU在运行时偶尔会用x86模式导致加载不了x64原生DLL把平台目标改成x64后问题解决。第二步检查输出目录里是否有onnxruntime.dll。如果用的NuGet包管理方式默认会拷贝但有时候你手动改过输出路径或者用了自定义构建步骤DLL就不见了把依赖项重新“复制到输出目录”即可。第三步如果项目里同时存在多个ONNX Runtime版本引用我遇到过NuGet包降级冲突的情况最后在packages.config里统一版本号解决。另外要提醒一点ONNX Runtime原生依赖msvcp140.dll和vcruntime140.dll如果目标工控机是精简系统可能缺这两个VC运行库。现场部署时要么装一次“VC 2015-2022 Redistributable”要么把这两个DLL直接放在程序目录下省得客户现场折腾。4.2 输入Tensor维度对不上dynamic导出带来的困惑有同事按照网上教程导出模型时用的是dynamicTrue然后拿着导出的ONNX在C#里报错Invalid input shape. Expected: [ batch, 3, height, width ], Got: [1, 3, 640, 640]问题出在dynamic模型在C#端构造Tensor时维度信息需要显式提供。我用的方案是干脆固定输入尺寸导出时不用dynamic直接把模型固化成640x640输入C#端永远按640x640处理。如果确实需要动态尺寸构造Tensor时用DenseTensor的完整维度描述并且确保动态轴的值写对比如new[] { 1, 3, 640, 640 }里的1表示batch为1。个人建议生产环境下能固定尺寸就固定动态尺寸在推理性能和内存使用上都不占优势特别是老框架下能用简单方案就别引入不必要的复杂度。4.3 NMS耗时太高LINQ的代价比你想的大这是我踩过最狠的一个坑。一开始图省事后处理全用LINQ写detections detections .OrderByDescending(d d.Confidence) .GroupBy(d d.ClassId) .SelectMany(g g) .Where(...) .ToList();看起来简洁对吧实测下来yolo11n模型在CPU上推理本身只要30毫秒左右但加上这套LINQ后处理整体耗时飙到300多毫秒实时性完全不可用。原因就在于84x8400约70万个浮点数的循环里频繁的Lambda表达式、匿名对象、List扩容导致的GC分配直接把性能拖垮了。解决方案也很简单后处理热循环里全部用原始数组和手写循环杜绝LINQ和隐式装箱。具体做了三件事用float[]数组而不是Listfloat保存中间结果NMS排序用手写循环或浅拷贝后的Array.Sort复用Detection对象池避免每帧new几百个对象。优化后同样的后处理逻辑从300毫秒降到30-40毫秒效果立竿见影。4.4 每帧都分配大数组内存抖动和GC卡顿刚开始写的时候我是这样写的float[] tensor new float[1 * 3 * 640 * 640]; // 约122万个float近5MB float[] output new float[outputLength];每跑一帧就new两个大数组在连续检测时GC频繁回收表现为UI周期性卡顿和内存持续上涨。后来改成把这两个数组作为类字段复用只在第一次加载时分配一次private readonly float[] _inputTensor new float[3 * 640 * 640]; private float[] _outputBuffer;Run完之后再把结果拷贝到_outputBuffer而不是每次都新建。这个改动看起来很基础但在长时间跑工业检测的场景里影响非常大稳定运行一整天都不再出现内存异常增长。5. 性能实测与上位机集成建议5.1 不同模型尺寸的CPU推理耗时我用自己的开发机i5-84006核无独立显卡做了一组基准测试结果如下模型输入尺寸CPU推理耗时总耗时含预处理后处理yolo11n640约 28 ms约 40 msyolo11s640约 65 ms约 80 msyolo11m640约 120 ms约 140 ms如果目标工控机有NVIDIA独立显卡ANVIDIA GPU需要额外安装Microsoft.ML.OnnxRuntime.Gpu包并确保CUDA和cuDNN版本与ONNX Runtime匹配需要找对应版本。常见的坑是版本不匹配导致GPU会话创建失败CPU推理耗时降到5-10毫秒效果非常明显。但GPU部署的兼容性是个大坑我后面单独写一篇说这里先给一个结论先在CPU上把整条流程跑通再考虑GPU加速不要一上来就掉进CUDA版本地狱。5.2 相机采集 推理 UI刷新不卡顿的线程架构把YOLO推理嵌入上位机后最常见的问题不是模型跑不起来而是程序卡顿。很多人的第一版代码长这样// 相机回调里直接做推理 camera.ImageGrabbed (s, e) { var mat e.Frame; var detections predictor.Predict(mat); // 这里跑了几十毫秒 DrawOnUI(detections); // 直接在回调线程操作UI又卡 };相机回调线程被推理阻塞下一帧图像就会堆积画面延迟越来越严重UI线程如果被直接操作整个界面直接假死。正确的做法是生产者-消费者模型生产线程相机回调只负责把图像Mat放进并发队列不做任何耗时操作。推理线程单独一个后台线程不断从队列取图执行预处理、推理、后处理。UI线程推理线程处理完一帧后通过Control.BeginInvoke把检测结果列表抛给UI线程UI线程只负责画框和刷新。典型代码结构如下private ConcurrentQueueMat _frameQueue new ConcurrentQueueMat(); private bool _running; public void StartDetectionLoop() { _running true; Task.Run(() { while (_running) { if (_frameQueue.TryDequeue(out Mat frame)) { var detections _predictor.Predict(frame); // 用BeginInvoke通知UI线程刷新 this.BeginInvoke(new Action(() UpdateUI(frame, detections))); } else { Thread.Sleep(1); } } }); }队列长度需要做限制如果相机帧率高于推理速度只保留最新一帧把旧帧丢弃画面延迟才能控制住。实测下来i5-8400跑yolo11n整体帧率稳定在20FPS左右UI操作顺滑不卡顿。最后分享一个部署细节整个方案跑通之后我还发现一个小技巧ONNX Runtime的模型加载是可以热升级的。也就是说你完全可以做一个“模型文件选择”功能让现场工程师通过界面切换不同的ONNX模型文件而不用重新编译整个上位机软件。这对现场调试非常有帮助客户换一个场景、换一批检测目标只需要丢一个新的ONNX文件进去就行。我在实际项目里还封装了一个简单的模型管理模块启动时扫描指定目录下的.onnx文件自动加载最新修改的一个。碰到模型更新现场人员只需要拷贝文件覆盖重启软件即生效省去了大量现场沟通成本。最后再提醒一句如果你手头的模型是自定义训练的导出ONNX时务必记下你的类别名称和类别数C#端后处理的numClasses参数要和模型匹配。我见过太多人栽在这个看似不起眼的小参数上——模型检测结果全乱还以为是推理代码写错了。把这套流程跑通后你应该会发现C#部署深度学习模型并没有想象中那么难关键是把模型导出、Tensor转换、后处理这三个环节的细节搞清楚剩下的就是常规工程活了。本文还有配套的精品资源点击获取
返回列表