
简介面向视频检测开发者的C# YoloV8 OnnxRuntime GPU版Demo源码包基于微软OnnxRuntime推理引擎与YOLOv8模型提供Visual Studio解决方案、演示工程、onnx模型及完整第三方依赖适合具备C#基础和深度学习常识的开发者用于快速搭建实时视频分析原型。压缩包共353个文件约984.69MB其中dll、lib、targets等为运行库与编译配置onnx为检测模型cs、resx、csproj为工程源码mp4用于测试视频输入目录结构完整且依赖已内置。已有322人学习下载可直接在配置好GPU环境中编译运行省去自行下载模型和逐项引包的耗时。开发者可在此基础上扩展安防监控、交通流量统计、智能零售等场景调整模型输入输出便能实现特定目标识别、人群密度监测等功能。1. C# 调 YoloV8 没那么玄一个 GPU 视频检测 Demo 的骨架C# 上位机里调用 YoloV8 已经不算新鲜事可真到自己动手搭工程光是 CUDA、cuDNN 和 OnnxRuntime 的版本纠缠就能耗掉两三个工作日。这份 GPU 版 Demo 把整条调用链一次理顺OnnxRuntime 加载 YOLOv8 模型、按帧读取视频、GPU 推理、NMS 后处理、画框显示 FPS全套源码带环境拿到手编译就能跑。它解决的是工业视觉和桌面软件集成目标检测最常见的版本匹配问题特别适合想把 YoloV8 检测能力塞进现有 C# 项目的开发者也适合刚接触 OnnxRuntime.GPU、需要一份参考实现的人照着抄。2. YoloV8 在 OnnxRuntime 下的推理链路从 ONNX 张量到 C# 检测框一份能够落地的推理 Demo核心不是模型本身而是数据怎么进、怎么出、怎么解释。YoloV8 导出的 ONNX 在许多细节上跟你想的“把图片丢进去就有结果”不太一样这一章带你走完整条链路。2.1 模型输入输出的约定预处理为什么绕不开 letterboxYoloV8 导出的 ONNX 模型输入形状通常是[1, 3, 640, 640]依次是 batch、通道、高、宽。这不代表你喂进去的原始视频帧必须是 640×640而是推理前要对每一帧做 letterbox 等比缩放长边缩到 640短边按同一比例缩放不足的部分用灰色填充。用 OpenCvSharp 读出来的Mat是 HWC 排布通道顺序是 BGR而模型要的是 CHW 的 RGB顺序错一位画面颜色就会失真。像素值默认是 0~255需要除以 255 归一化。推理完成后检测框的坐标也是相对 640×640 的归一化坐标必须乘回原图宽高画框才能跟画面贴合。Demo 源码里通常封装好了这个转换但很多人在自己重写时容易漏掉填充比例的计算。这里用一段参考代码说明 Mat 转输入 Tensor 的过程// 将一帧 Mat 转成模型要求的 CHW float 数组RGB 顺序 归一化 float[] MatToTensor(Mat frame, int targetSize 640) { int srcW frame.Width, srcH frame.Height; float scale Math.Min((float)targetSize / srcW, (float)targetSize / srcH); int newW (int)Math.Round(srcW * scale); int newH (int)Math.Round(srcH * scale); // 等比缩放注意用 INTER_LINEAR 保持边缘平滑 Mat resized new Mat(); Cv2.Resize(frame, resized, new Size(newW, newH)); // 生成 letterbox 画布填充色 114/114/114 是 Yolo 系列的惯例 Mat canvas Mat.Zeros(targetSize, targetSize, MatType.CV_8UC3); canvas.SetTo(new Scalar(114, 114, 114)); Rect roi new Rect((targetSize - newW) / 2, (targetSize - newH) / 2, newW, newH); resized.CopyTo(new Mat(canvas, roi)); // HWC(BGR) → CHW(RGB)同时归一化 float[] data new float[3 * targetSize * targetSize]; int index 0; for (int c 2; c 0; c--) // BGR - RGB { for (int y 0; y targetSize; y) { for (int x 0; x targetSize; x) { Vec3b pixel canvas.GetVec3b(y, x); data[index] pixel.Item2 /* 除 255? 放到后面统一处理 */; } } } // 实际工程中我会用指针遍历代替 GetVec3b性能差距接近 10 倍 for (int i 0; i data.Length; i) data[i] / 255f; return data; }这段代码里GetVec3b只是示意图逐像素操作在真实场景下性能很差。常见的做法是把Mat直接转成byte[]再手动按通道索引重排成 CHW一次Buffer.BlockCopy加上两层循环就能搞定对 640×640 输入耗时可以压到 1ms 以内。填充坐标(targetSize - newW) / 2必须在后处理阶段原样记住因为模型输出的坐标就是在这个画布坐标系下归一化的还原检测框时要先用它反算偏移量。2.2 会话初始化参数GPU 推理的开关藏在哪OnnxRuntime 的 C# 会话默认只跑 CPUGPU 不是装上 NuGet 包就自动生效必须在OrtSessionOptions里显式追加 CUDA 执行提供器。这一节是最容易被当成“黑匣子”的部分源码里一般会有对应代码关键参数值得逐行确认// 初始化 ONNX Runtime 全局环境 using var env new OrtEnv(yolov8-demo); // 名字随意仅作标识 // 会话选项是 GPU 加速的真正开关 using var sessionOptions new OrtSessionOptions(); sessionOptions.SetSessionGraphOptimizationLevel(GraphOptimizationLevel.ORT_ENABLE_ALL); // 关键追加 CUDA 执行提供器不写这句就是纯 CPU 跑 sessionOptions.AppendExecutionProvider_CUDA(0); // 加载模型文件 using var session new OrtSession(env, modelPath, sessionOptions);AppendExecutionProvider_CUDA(0)的参数是设备 ID多卡机器上可以改成 1、2。OnnxRuntime 较新版本里图形优化级别默认是ORT_ENABLE_ALL但保险起见建议显式指定因为它会影响算子融合策略进而影响帧率。如果你拿到的是老版本源码这里可能用的是OrtSessionOptions.AppendExecutionProvider_CUDA(string.Empty)之类的旧 API 风格需要按当前 NuGet 包版本调整。GPU 内存这块有个参数容易被忽略OrtCudaProviderOptions可以限制显存上限。视频检测跑长视频流时显存不足会导致推理直接抛异常但很多人并不知道可以在会话层面设置 GpuMemoryLimit。常见做法是先不设上限跑一遍观察显存占用峰值再按 1.5 倍余量去限制免得和其他图形程序抢显存。3. 视频检测主循环拉帧、推理、后处理三个硬骨头Demo 能跑出效果主循环的健壮性比单帧推理耗时更关键。视频源可能来自摄像头、本地文件或网络流每一帧都可能节奏不均主循环必须在帧率波动下依然稳定输出检测结果。3.1 OpenCV 拉帧与推理张量复用视频读取用 OpenCvSharp 的VideoCapture是最顺手的方案。帧率不高的场景下每帧重新创建输入数组问题不大但如果你要跑 1080p60fps 的视频频繁分配float[]会引起 GC 压力帧率会出现周期性掉到个位数。我一般会在循环外预分配好缓冲区每帧只更新数据内容。using var capture new VideoCapture(videoPath); Mat frame new Mat(); using var inputTensor OrtValue.CreateTensorValueFromMemoryfloat( inputData, new long[] { 1, 3, 640, 640 }); while (capture.Read(frame)) { if (frame.Empty()) break; // 把 frame 的数据填进预分配的 inputData此处省略转换代码 FillInputData(frame, ref inputDataArray); // 推理输出形状一般是 [1, 84, 8400] using var output session.Run(new[] { images }, new[] { inputTensor }, new[] { output0 }); // 解析输出、NMS、画框... }这里有个关键点OrtValue.CreateTensorValueFromMemory包装的是托管数组推理是同步阻塞的所以数组在Run返回前不能被改写。如果你的推理循环里还有别的线程在写同一个缓冲区要加锁或者改用每帧独立数组。Run的输入输出名称images和output0来自 ONNX 模型自身的节点命名不同导出工具链给出的名字不同先在 Netron 里看一眼再填不要照抄。3.2 NMS 后处理YoloV8 的 8400 个候选框怎么收敛模型输出形状[1, 84, 8400]的含义是每个候选框 4 个坐标值cx、cy、w、h 80 个类别分数8400 来自三个尺度的网格80×80 40×40 20×20。后处理的逻辑就是先按置信度阈值筛选再做类别级别的 NMS。static ListDetectedBox PostProcess(float[,,] output, float confThresh, float iouThresh) { int numBoxes output.GetLength(2); // 8400 int numClasses output.GetLength(1) - 4; // 80 var candidates new ListDetectedBox(); for (int i 0; i numBoxes; i) { // 找出分数最高的类别过滤低置信度框 float maxScore 0; int bestClass -1; for (int c 0; c numClasses; c) { float score output[0, c 4, i]; if (score maxScore) { maxScore score; bestClass c; } } if (maxScore confThresh) continue; float cx output[0, 0, i], cy output[0, 1, i]; float w output[0, 2, i], h output[0, 3, i]; candidates.Add(new DetectedBox( cx - w / 2, cy - h / 2, w, h, maxScore, bestClass)); } // 简单 NMS同一类别中与已选框 IoU 超阈值的丢弃 var results new ListDetectedBox(); foreach (var box in candidates.OrderByDescending(b b.Score)) { bool suppressed results.Any(keep keep.ClassId box.ClassId IoU(keep, box) iouThresh); if (!suppressed) results.Add(box); } return results; }output[0, c 4, i]的索引顺序必须跟模型导出时的布局一致。YoloV8 导出的 ONNX 在部分框架下可能是[1, 8400, 84]不确认的话先打印output.Shape再写解析逻辑。NMS 里OrderByDescending是方便写法候选框多时耗时高成熟项目一般按类别分组、逐类别做抑制或者直接上 ONNX Runtime 自带的 NMS 算子把后处理也塞进模型图里。后处理后的坐标是 letterbox 画布坐标系要还原到原始帧坐标得记录scale和padX/padY画框之前统一换算否则框的位置会整体偏右下或者等比错位。4. 跑通 Demo 的关键GPU 环境的版本矩阵与配置清单“带环境”三个字是这个资源最值钱的地方GPU 推理翻车八成翻在版本不对齐。这一章把版本搭配和源码里必须改的配置项讲透。4.1 CUDA、cuDNN、OnnxRuntime.GPU 的版本配对OnnxRuntime 的 GPU 包是针对特定 CUDA 版本编译的版本错位时症状很怪有的报DLL load failed有的启动正常但一推理就崩还有的静默回退到 CPU。不同大版本的要求大致如下OnnxRuntime.GPU 版本要求 CUDA要求 cuDNN1.15.x 及更早CUDA 11.8cuDNN 8.6.01.16.x ~ 1.18.xCUDA 12.xcuDNN 8.9.x1.19.x 之后CUDA 12.xcuDNN 9.x对应关系以 NuGet 包页面的说明为准我上面给的是自己用过的组合。判断你手上环境是否匹配最直接的办法是看两个 DLLonnxruntime_providers_cuda.dll和onnxruntime_providers_shared.dll把它们复制出来看依赖的 CUDA 版本号。GPU 版部署包通常会把必要的 CUDA 运行库和 cuDNN 放在一个dll目录下并通过设置环境变量PATH让程序能找到。如果你的程序在其他机器上跑不起来先确认这几项CUDA 驱动是否已装1660Ti 到 RTX 4090 都行驱动版本不能太低bin目录是否被加入 PATH程序输出目录里是否有onnxruntime.dll和onnxruntime_providers_cuda.dll显卡驱动是 Game Ready 还是 Studio 版都可以但必须在 NVIDIA 控制面板里能看到 CUDA 版本DirectML 是另一条路OnnxRuntime.DirectML包不依赖 CUDA集成简单但推理性能和算子覆盖都比 CUDA EP 差一截。这个 Demo 既然标了 GPU 版默认按 CUDA 路径配置。4.2 源码里必须改的配置项Demo 跑通前有几个写死在代码里的配置需要按自己环境改。最常见的遗漏是模型路径写的是yolov8n.onnx但资源包只放了yolov8s.onnx。建议把模型路径、视频路径、保存路径统一提到一个Config.cs里public static class Config { // 模型文件路径建议用绝对路径或相对路径都写完 public static readonly string ModelPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, models, yolov8n.onnx); // 视频源文件路径或摄像头索引 public static readonly string VideoSource test.mp4; // 0 表示摄像头 // 后处理阈值调低 recall 高 precision 低调高反之 public static readonly float ConfThreshold 0.25f; public static readonly float IouThreshold 0.45f; // 推理尺寸跟模型导出时的尺寸必须一致 public static readonly int InputSize 640; }ConfThreshold调到 0.25 是 COCO 预训练模型比较常规的数值换到自己的业务场景比如检测特定工业零件往往要调到 0.5 以上避免误检。IouThreshold一般不动除非你发现同一目标被画了重叠的框。这两个值后处理阶段直接查表改动不需要重新编译。源码里如果硬编码了onnxruntime.dll的加载路径记得检查一下是不是相对路径。用相对路径时AppDomain.CurrentDomain.BaseDirectory在调试和发布两个模式下指向不同目录最常见的报错是“找不到指定模块”实际原因是 DLL 搜索路径不对而不是文件不存在。5. 避坑专题GPU 检测 Demo 最容易翻车的五个细节这一章是血泪经验汇总。每条都按“现象 → 原因 → 解决”写遇到同类问题可以直接对号入座。5.1 帧率和 CPU 版一样低GPU 没生效现象程序能跑检测结果也对但帧率只有 5~8 FPS任务管理器里 GPU 占用率极低。原因会话初始化时AppendExecutionProvider_CUDA没有真正生效。常见两种情况一是 NuGet 包装的是Microsoft.ML.OnnxRuntimeCPU 版而不是Microsoft.ML.OnnxRuntime.GPU二是动态库加载失败后 OnnxRuntime 静默回退到了 CPU EP。解决在会话创建后打印实际启用的执行提供器foreach (var provider in session.GetAvailableProviders()) Console.WriteLine($启用 EP: {provider});如果输出只有CpuExecutionProvider检查项目引用的包名是否带.GPU并把 CUDA 相关 DLL 复制到输出目录。还有个小技巧用 GPU-Z 或nvidia-smi看推理过程中显卡利用率是不是有曲线跳动如果一直是 0%说明推理根本没落到 GPU 上。5.2 会话初始化直接崩溃DLL 加载失败现象程序一启动就抛DllNotFoundException或者AccessViolationException断点在new OrtSession上。原因CUDA、cuDNN 版本与 OnnxRuntime 的预期版本不一致。显卡驱动只是前提驱动自带的 CUDA 运行库版本往往偏老不一定满足 OnnxRuntime 的要求。解决把资源包里面带的 CUDA 运行库和 cuDNN 放到统一目录程序启动前把它加到 DLL 搜索路径。我常用的做法是在Main开头写上// 优先加载本地 DLL避免和系统目录里的 CUDA 版本冲突 string dllDir Path.Combine(AppDomain.CurrentDomain.BaseDirectory, dll); if (Directory.Exists(dllDir)) Environment.SetEnvironmentVariable(PATH, dllDir ; Environment.GetEnvironmentVariable(PATH));这里有个细节PATH环境变量要在OrtEnv创建前设置因为依赖解析发生在首次加载 ONNX Runtime 时。另外不同机器上如果装过多个 CUDA 版本系统 PATH 里可能先搜到不匹配的 DLL用上述方式把本地目录放到最前面能避开这个坑。5.3 视频越跑越卡内存或显存持续上涨现象长视频跑到中段内存明显增长帧率逐步下降甚至最终崩溃。原因主循环里每帧都创建新的Mat、OrtValue没有及时释放。GPU 推理中显存不一定会立刻回收特别是 CUDA EP 的显存池机制可能导致显存占用只增不减。解决循环内Mat和OrtValue用using包裹或者统一循环体外创建。另外记得处理capture.Read(frame)时复用一个frame实例避免反复分配。我自己的习惯是每处理 100 帧调一次GC.Collect()虽然不优雅但能保证长时间运行的稳定性。显存这块可以在nvidia-smi -l 1下观察如果发现某个进程显存持续上升优先排查是否有张量没有释放。5.4 检测框位置偏移、大小不对现象框能画出来但是位置整体右移或者下移小目标框明显偏大。原因后处理没有用 letterbox 的偏移量和缩放系数还原坐标。输出坐标是相对 640×640 画布的直接乘原图比例会导致整体偏移。另一个常见原因是Rect roi的起点用了(newW/2, newH/2)实际上应该是((targetSize - newW)/2, (targetSize - newH)/2)。解决把scale、padX、padY在预处理阶段存下来后处理阶段回代float originalW frame.Width, originalH frame.Height; float scale Math.Min((float)InputSize / originalW, (float)InputSize / originalH); float padX (InputSize - originalW * scale) / 2; float padY (InputSize - originalH * scale) / 2; // 还原先减去 padding再除以 scale float x (box.X - padX) / scale; float y (box.Y - padY) / scale;这个坑特别隐蔽因为边框偏一点点肉眼不容易察觉。批量测试时可以让模型检测一张带明显边缘特征的标准测试图观察框和物体轮廓的重合度。5.5 换了显卡后帧率反而下降现象从 RTX 2060 换到 RTX 4060帧率没有提升反而下降甚至报错。原因显卡驱动、CUDA 运行库新旧不一致。某些新卡对旧版 CUDA 运行库兼容性一般而且不同代显卡对算子融合的收益不同同样代码在 Ampere 和 Ada 架构上的表现会有明显差异。解决优先更新显卡驱动到当前版本再把 CUDA 运行库换成 OnnxRuntime 官方推荐的最低版本而非更高版本。不要盲目追求新 CUDA比如 OnnxRuntime 1.16 对应 CUDA 12.0你装了 CUDA 12.4 反而可能因为运行库版本过新导致 cuDNN 查找失败。如果换卡后发现帧率异常可以先删除本地dll目录里的 CUDA 运行库改用驱动自带版本交叉验证一下。6. 验证 GPU 加速真正生效显存占用、CUDA 日志与帧率对比Demo 跑通之后最重要的一件事不是看画面而是确认推理到底走没走 GPU。只看任务管理器里的 GPU 占用率并不严谨因为视频解码也会占用 GPU 的 Compute Engine。先说最简单的验证方法推理前后各查一次显存占用。用nvidia-smi --query-gpumemory.used --formatcsv对比能看到当前进程显存有明显变化比如从 200MB 涨到 1GB 以上说明 CUDA EP 确实接管了推理。如果显存始终不变那大概率推理落在 CPUGPU 只在做视频解码。另一个更直接的手段是强制只走 CPU 跑一遍对比帧率。在会话初始化时不追加 CUDA EP其余代码完全不动跑一段固定长度的视频记录平均 FPS。然后切回 GPU 版再跑一遍两值对比如果差异在 1.2 倍以内说明 GPU 没有发挥预期需要回到第 4 章排查版本如果 GPU 版反而更慢基本是模型太小比如 YoloV8nGPU 初始化开销盖过了推理收益这时可以考虑换更大的模型或者提高输入分辨率。我在自己的实践里习惯在窗口标题栏实时显示帧率、GPU 耗时和推理耗时三段数据。帧率反映整体视频处理能力GPU 耗时只反映session.Run的推理耗时两者差距能直接暴露预处理或后处理的瓶颈。比如 GPU 耗时 3ms但帧率只有 20FPS说明大把时间花在了 Mat 转换、NMS 和画框上。从那以后我每次拿到新的 GPU 推理工程第一件事不是看代码而是强制走一遍这套验证流程确认 EP 列表、查显存占用、CPU/GPU 对比帧率。三件事做完环境问题能筛掉大半剩下才值得花时间去调模型和后处理。希望这份 Demo 能帮你少走同样的弯路把精力留到真正需要调优的地方。本文还有配套的精品资源点击获取