简介:面向视频检测开发者的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 模型输入输出的约定:预处理为什么绕不开 letterbox
YoloV8 导出的 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.Get<Vec3b>(y, x); data[index++] = pixel.Item2 /* 除 255? 放到后面统一处理 */; } } } // 实际工程中我会用指针遍历代替 Get<Vec3b>,性能差距接近 10 倍 for (int i = 0; i < data.Length; i++) data[i] /= 255f; return data; }这段代码里Get<Vec3b>只是示意图,逐像素操作在真实场景下性能很差。常见的做法是把Mat直接转成byte[],再手动按通道索引重排成 CHW,一次Buffer.BlockCopy加上两层循环就能搞定,对 640×640 输入耗时可以压到 1ms 以内。填充坐标(targetSize - newW) / 2必须在后处理阶段原样记住,因为模型输出的坐标就是在这个画布坐标系下归一化的,还原检测框时要先用它反算偏移量。
2.2 会话初始化参数:GPU 推理的开关藏在哪
OnnxRuntime 的 C# 会话默认只跑 CPU,GPU 不是装上 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是最顺手的方案。帧率不高的场景下,每帧重新创建输入数组问题不大;但如果你要跑 1080p@60fps 的视频,频繁分配float[]会引起 GC 压力,帧率会出现周期性掉到个位数。我一般会在循环外预分配好缓冲区,每帧只更新数据内容。
using var capture = new VideoCapture(videoPath); Mat frame = new Mat(); using var inputTensor = OrtValue.CreateTensorValueFromMemory<float>( 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 List<DetectedBox> PostProcess(float[,,] output, float confThresh, float iouThresh) { int numBoxes = output.GetLength(2); // 8400 int numClasses = output.GetLength(1) - 4; // 80 var candidates = new List<DetectedBox>(); 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 List<DetectedBox>(); 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 | 要求 cuDNN |
|---|---|---|
| 1.15.x 及更早 | CUDA 11.8 | cuDNN 8.6.0 |
| 1.16.x ~ 1.18.x | CUDA 12.x | cuDNN 8.9.x |
| 1.19.x 之后 | CUDA 12.x | cuDNN 9.x |
对应关系以 NuGet 包页面的说明为准,我上面给的是自己用过的组合。判断你手上环境是否匹配,最直接的办法是看两个 DLL:onnxruntime_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.OnnxRuntime(CPU 版)而不是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-gpu=memory.used --format=csv对比,能看到当前进程显存有明显变化(比如从 200MB 涨到 1GB 以上),说明 CUDA EP 确实接管了推理。如果显存始终不变,那大概率推理落在 CPU,GPU 只在做视频解码。
另一个更直接的手段是强制只走 CPU 跑一遍,对比帧率。在会话初始化时不追加 CUDA EP,其余代码完全不动,跑一段固定长度的视频,记录平均 FPS。然后切回 GPU 版再跑一遍,两值对比:如果差异在 1.2 倍以内,说明 GPU 没有发挥预期,需要回到第 4 章排查版本;如果 GPU 版反而更慢,基本是模型太小(比如 YoloV8n),GPU 初始化开销盖过了推理收益,这时可以考虑换更大的模型或者提高输入分辨率。
我在自己的实践里,习惯在窗口标题栏实时显示帧率、GPU 耗时和推理耗时三段数据。帧率反映整体视频处理能力,GPU 耗时只反映session.Run的推理耗时,两者差距能直接暴露预处理或后处理的瓶颈。比如 GPU 耗时 3ms,但帧率只有 20FPS,说明大把时间花在了 Mat 转换、NMS 和画框上。
从那以后,我每次拿到新的 GPU 推理工程,第一件事不是看代码,而是强制走一遍这套验证流程:确认 EP 列表、查显存占用、CPU/GPU 对比帧率。三件事做完,环境问题能筛掉大半,剩下才值得花时间去调模型和后处理。希望这份 Demo 能帮你少走同样的弯路,把精力留到真正需要调优的地方。
本文还有配套的精品资源,点击获取