
简介一份面向 C# 开发者的 YOLOv8 红绿灯检测源码基于 ONNX Runtime 与 OpenCVSharp 实现模型推理适合有一定 C# 基础、希望将深度学习模型部署到 .NET 桌面应用中的开发者。压缩包共 65 个文件约 199.61MB主要包含 11 个 C# 源文件、19 个动态库、2 个 ONNX 模型以及解决方案、配置和可执行文件目录结构完整可直接打开工程对照学习。核心代码覆盖模型加载、图像预处理、推理执行、后处理输出等完整链路并附带红绿灯模型与标签文件便于理解检测框、置信度得分与红绿灯状态的对应关系还可学习预处理缩放归一化、候选框筛选等实现细节。界面部分采用 Windows 窗体实现辅助代码中包含结果封装类整体工程性较强。已有 310 人学习下载适合从事智能交通、自动驾驶辅助或实时图像分析项目的开发者参考借鉴。1. 用 C# 跑 YOLOv8 红绿灯检测卡点在预处理和后处理红绿灯检测在路侧视频分析、驾驶辅助和交通仿真里都是高频需求。过去用传统视觉做灯色识别遇到逆光、雨夜、灯珠重影几乎集体失灵换成 YOLOv8 之后检测和状态分类一次性输出模型层面不再区分先检测灯体、再分类颜色两个阶段。这个源码包把训练好的红绿灯模型导出成 traffic-lights.onnx工程侧用 C# 配合 Microsoft.ML.OnnxRuntime 加载模型图像读写交给 OpenCvSharpWinForms 界面直接框出灯体并给出置信度。整个流程不依赖 Python 运行时生产机上装个 .NET Framework 就能跑适合在 C# 上位机里做图像识别、或者想用 YOLOv8 替换掉传统视觉方案的人。2. OnnxRuntime 模型加载与会话初始化先别急着碰推理2.1 从解决方案的文件结构看三层依赖打开 Onnx Yolov8 Detect.sln先看项目引用了哪几个包比看运行效果更能判断源码质量。这个项目的文件组织拆成三层Form1.cs 负责界面显示与交互Microsoft.ML.OnnxRuntime 负责加载 traffic-lights.onnx 并执行推理OpenCvSharp 负责图像读取、缩放和检测框绘制。三个模块各自独立替换任何一层都不牵动另外两层。文件类型职责traffic-lights.onnx模型文件YOLOv8 导出的 ONNX 权重决定检测能力lable.txt标签文件每行一个类别名顺序对应模型输出通道Form1.cs / Form1.Designer.cs界面层图像源选择、结果绘制、阈值输入DetectionResult.cs / Result.cs数据层承载标签、置信度、检测框坐标app.config / Settings.settings配置层阈值、模型路径等运行参数常见误区的差别有人把 lable.txt 当成普通文本随意调整实际上 ONNX 输出的类别索引在导出那一刻就固定了标签文件只是给索引起可读名字。源码里如果出现 label[0] 红灯 这种硬编码换成别的模型后第一个出错的就是它。2.2 初始化 InferenceSession动态读取输入输出维度ONNX 是开放神经网络交换格式PyTorch 训练出的权重导出成 onnx 后推理侧不需要装 PythonONNX Runtime 直接加载运行这是整个方案能落到 C# 的关键。加载模型时把 SessionOptions 和 InferenceSession 配合好SessionOptions 决定模型跑在 CPU 还是 GPU 上InferenceSession 负责真正的模型解析与内存分配。下面这段代码先追加一个执行提供程序再创建会话并把输入输出元数据打出来确认using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using System.Linq; string modelPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, traffic-lights.onnx); var sessionOptions new SessionOptions(); // CPU 兜底任何 Windows 机器都能跑 sessionOptions.AppendExecutionProvider_CPU(); using var session new InferenceSession(modelPath, sessionOptions); // 动态读取维度不要把 640/84/8400 写死在解析代码里 var inputMeta session.InputMetadata.First(); var outputMeta session.OutputMetadata.First(); Console.WriteLine($input name: {inputMeta.Key}, dims: {string.Join(x, inputMeta.Value.Dimensions)}); Console.WriteLine($output dims: {string.Join(x, outputMeta.Value.Dimensions)});YOLOv8 官方导出的 ONNX 输入名默认是 images输入形状 [1, 3, 640, 640]输出 [1, 84, 8400]。84 是 4 个坐标加 80 类如果换成自己训练的红绿灯模型类别数大概率不是 80输出通道数也会跟着变。用 Dimensions 动态读出来的值去初始化后处理换模型时就不用动解析层代码。提示InferenceSession 构造期间就会解析模型文件并分配内存模型路径不对、onnxruntime.dll 版本不对都会在这一步抛异常。先确认 bin 目录下存在 onnxruntime.dll 和 onnxruntime_providers_shared.dll再排查业务代码。2.3 CPU、DirectML、CUDA 三种 Provider 怎么选红绿灯检测是单帧图像输入推理负载在视觉模型里算轻的。640×640 输入在 i5 级 CPU 上单次推理大约 30~50ms做抓拍分析和每秒两三帧巡检完全够用。想跑实时视频流优先考虑 DirectML它随 Windows 显卡驱动走不用单独装 CUDA Toolkit1660Ti 这类显卡上跑 YOLOv8 常见能到 15~25ms 一帧。CUDA 虽然峰值性能更高但 onnxruntime.Gpu 包的版本必须和机器上的 CUDA、cuDNN 对齐错一个版本就在 session 构造时报错。切换只差一行// Windows 下优先 DirectML省去 CUDA 环境配置的坑 sessionOptions.AppendExecutionProvider_DML(); // NuGet 引用 onnxruntime.Gpu 包后再换成 CUDA(0) sessionOptions.AppendExecutionProvider_CUDA(0);两套 Provider 的原生库不通用CPU 版的 onnxruntime.dll 不能叠加 CUDA 执行GPU 包装到没显卡的机器上启动又慢又占内存。调试阶段先用 CPU 把完整流程跑通正式部署再按目标机器切换执行提供程序。这也是 .sln 的 bin 目录下同时出现 x64 和 x86 文件夹的原因平台参数在项目属性里选不要手动删。同理同样的 ONNX 结构换到 NCNN 或 OpenVINO 里也能跑但预处理和后处理的接口要跟着推理引擎重新调一遍没必要一开始就绑死一个引擎。3. OpenCvSharp 图像预处理Letterbox、颜色顺序与张量构造3.1 直接 Resize 到 640 会漏检红灯YOLOv8 训练时会把输入图按比例缩放到长边不超过 640再用 114 灰度填充成正方形这个流程叫 Letterbox。表面上直接 Cv2.Resize 到 640×640 也能把图塞进模型但长宽比被拉伸后灯体形状和训练数据分布不一致。红绿灯在 1080p 画面里常常只有 20×20 像素属于典型小目标预处理再变形漏检率就会明显上升。源码包里的做法是把缩放比例和填充量从预处理传出来后处理阶段再按这两个量把坐标映射回原图信息在这一环丢掉就会画错位置。3.2 预处理代码与每步的作用private static DenseTensorfloat Preprocess(Mat src, int inputSize, out float ratio, out int padX, out int padY) { // 1. 等比缩放短边留空 ratio Math.Min((float)inputSize / src.Width, (float)inputSize / src.Height); int newW (int)Math.Round(src.Width * ratio); int newH (int)Math.Round(src.Height * ratio); // 2. 用 114 灰度填充成正方形 padX (inputSize - newW) / 2; padY (inputSize - newH) / 2; using Mat resized new Mat(); Cv2.Resize(src, resized, new OpenCvSharp.Size(newW, newH)); using Mat canvas new Mat(inputSize, inputSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new Rect(padX, padY, newW, newH)]); // 3. OpenCV 读进来是 BGRYOLOv8 训练数据是 RGB using Mat rgb new Mat(); Cv2.CvtColor(canvas, rgb, ColorConversionCodes.BGR2RGB); // 4. HWC - CHW逐像素除以 255 归一化 var tensor new DenseTensorfloat(new[] { 1, 3, inputSize, inputSize }); for (int c 0; c 3; c) for (int y 0; y inputSize; y) for (int x 0; x inputSize; x) tensor[0, c, y, x] rgb.AtVec3b(y, x)[c] / 255f; return tensor; // ratio/padX/padY 留给后处理映射 }一步步拆开看第一步保证长边不超过 640第二步用 114 填充第三步把 BGR 转成 RGB第四步把 HWC 布局调整为 CHW 并归一化到 0~1。最后构造出来的 [1, 3, 640, 640] 就是模型输入要求的维度维度不一致时 session.Run 直接报形状错误。预处理涉及三个参数直接列成表更清楚参数推荐值说明inputSize640 或与导出模型一致决定 DenseTensor 维度fill 值114YOLOv8 训练默认填充色归一化方式除以 255与训练时的数据增强保持一致参数上要注意的点fill 色保持 114改成 0黑色填充会让模型在边缘感知到额外内容小目标召回率受影响。padX/padY 用整数除法奇数像素损失最多 1 个像素对检测框精度几乎无感。如果输入是 4K 截图这种大图先算一下缩放比例超过 2000 像素就先降采样再走 Letterbox能明显减少内存峰值。3.3 推理调用与输入名匹配using var inputs new ListNamedOnnxValue { // 输入名用元数据里的 key而不是写死 images NamedOnnxValue.CreateFromTensor(inputMeta.Key, tensor) }; using var results session.Run(inputs); using var output results.First(); var outputTensor output.AsTensorfloat();输入名从 inputMeta.Key 取YOLOv8 官方导出叫 images但有人会在 Netron 里改输入名写死的话换模型就崩。results 返回的是可释放集合output 本身也要释放这里的 using 顺序保证先释放输出再释放会话结果避免原生内存泄漏。拿到 outputTensor 后接下来就是第 4 章的解析工作。4. 解析 YOLOv8 的 1×84×8400 输出坐标还原与 NMS4.1 输出张量布局以及 C2f 对工程侧的影响YOLOv8 检测头的 ONNX 输出形状是 [1, 4numClasses, 8400]和 YOLOv5 的 [1, 25200, 85] 排列不一样。8400 来自 80×80、40×40、20×20 三个尺度的特征图分别覆盖小、中、大目标。关键在内存顺序channels 轴在前anchors 轴在后第 i 个锚点的坐标 cx 要取 data[i]类别分数取 data[(4 c) * 8400 i]。YOLOv8 里的 C2f 模块增强了特征复用但没有改变输出头的格式网上有些教程按 YOLOv5 的 [anchors, channels] 顺序解析套到 v8 上要么框全都偏要么直接越界。通道下标含义取值说明0cx中心点 x相对 letterbox 画布范围 0~11cy中心点 y2w检测框宽度3h检测框高度4 ~ 4numClasses-1各类别分数取最大值的下标作为类别4.2 置信度过滤与标签映射private static ListDetectionResult ParseOutput(Tensorfloat output, string[] labels, float confThreshold) { int numClasses labels.Length; // 从 lable.txt 动态取 int numAnchors output.Dimensions[2]; float[] data output.ToArray(); var list new ListDetectionResult(); for (int i 0; i numAnchors; i) { float cx data[i]; float cy data[1 * numAnchors i]; float w data[2 * numAnchors i]; float h data[3 * numAnchors i]; float bestScore 0f; int bestClass -1; for (int c 0; c numClasses; c) { float score data[(4 c) * numAnchors i]; if (score bestScore) { bestScore score; bestClass c; } } if (bestScore confThreshold || bestClass 0) continue; list.Add(new DetectionResult { LabelIndex bestClass, Label labels[bestClass], Score bestScore, // 相对画布的坐标转成左上角 宽高 X cx - w / 2f, Y cy - h / 2f, Width w, Height h }); } return list; }YOLOv8 没有独立的 objectness 分支类别分数最大值就是置信度所以 confThreshold 既是类别门槛又是存在性门槛。调参时先设 0.4 跑一组验证图看漏检集中在哪类场景再决定往下还是往上不要一上来压到 0.1。labels 数组在调用前用 File.ReadAllLines(lablePath) 读一次并和模型输出维度校验行数对不上就在这抛异常而不是让程序崩溃在奇怪的位置。提示检测框给的是灯的几何位置类别给的是颜色状态两者必须组合使用。单独拿类别分数做判断会在灯体重叠或相机过曝时产生错误状态。4.3 NMS 去重与坐标映射回原图模型对同一个灯体会输出多个重叠框NMS 按分数从高到低保留抑制重叠度超阈值的框。手写 NMS 不依赖 OpenCV Dnn 模块WinForms 项目里引用更干净private static ListDetectionResult Nms(ListDetectionResult cand, float iouThreshold) { var result new ListDetectionResult(); var ordered cand.OrderByDescending(d d.Score).ToList(); while (ordered.Count 0) { var best ordered[0]; result.Add(best); ordered.RemoveAt(0); ordered.RemoveAll(d IoU(best, d) iouThreshold); } return result; } private static float IoU(DetectionResult a, DetectionResult b) { float x1 Math.Max(a.X, b.X), y1 Math.Max(a.Y, b.Y); float x2 Math.Min(a.X a.Width, b.X b.Width); float y2 Math.Min(a.Y a.Height, b.Y b.Height); float inter Math.Max(0f, x2 - x1) * Math.Max(0f, y2 - y1); float union a.Width * a.Height b.Width * b.Height - inter; return inter / union; }IoU 在相对坐标系里计算比例是统一的和像素坐标系结果等价。下面这一步把相对坐标还原到原图x1 (X × 640 - padX) / ratioy 同理。private static Rect MapToOriginal(DetectionResult d, float ratio, int padX, int padY) { float x1 (d.X * 640f - padX) / ratio; float y1 (d.Y * 640f - padY) / ratio; float x2 ((d.X d.Width) * 640f - padX) / ratio; float y2 ((d.Y d.Height) * 640f - padY) / ratio; return new Rect((int)Math.Round(x1), (int)Math.Round(y1), (int)Math.Round(x2 - x1), (int)Math.Round(y2 - y1)); }WinForms 展示时画框和刷新 PictureBox 不要再塞进 UI 线程里做否则拖动窗口就卡顿。常见做法是把检测链路丢到 Task.Run 里完成后回到 UI 线程用 BitmapConverter 转图foreach (var d in afterNms) { Rect r MapToOriginal(d, ratio, padX, padY); Cv2.Rectangle(src, r, new Scalar(0, 0, 255), 2); string text ${d.Label} {d.Score:0.00}; Cv2.PutText(src, text, new Point(r.X, r.Y - 6), HersheyFonts.HersheySimplex, 0.7, new Scalar(0, 0, 255), 2); } pictureBox1.Image?.Dispose(); // 上一帧的 Bitmap 要释放否则内存只涨不回 pictureBox1.Image OpenCvSharp.Extensions.BitmapConverter.ToBitmap(src);每次刷新前 Dispose 旧 Bitmap 这个细节很关键很多人把 Task.Run 加上了内存还在涨就是漏了这句。5. 阈值调优、GPU 切换与发布时最容易翻车的三个细节5.1 红绿灯场景的置信度和 IOU 怎么定红灯绿灯目标小而且类别错误代价高。我一般把锚点过滤阈值和状态判定阈值分开先用 0.3 做锚点过滤保证小灯体不丢再对检测框做第二次状态判断红灯类别分数超过 0.6 才输出红灯否则输出未知。这比单一阈值应对复杂天气更实用宁可少报一次也别把红灯报成绿灯。IOU 阈值设在 0.45~0.5红绿灯框之间基本不重叠阈值太低会误删相邻车道的灯太高又会出现同一个灯重复画框。5.2 推理切换到 GPU 与 int8 量化的边界CPU 上 640 输入单帧大约 30~50ms切 DirectML 后通常能压到 20ms 左右1660Ti 级显卡常见在 15~25ms。切换方式sessionOptions.AppendExecutionProvider_DML();DirectML 对 int8 量化模型的支持因显卡驱动而异。如果模型做过 int8 量化先在 CPU 上验证输出正常再切 DirectML部分驱动下 int8 输出会有数值偏移。红绿灯对类别敏感建议先用 fp32 模型跑通业务量化优化放到后面做。5.3 发布前检查清单平台、路径、标签对齐项目发布时的坑按出现频率排第一是 x64/x86 不匹配平台选 x64bin/x64 下 OpenCvSharpExtern.dll 必须是 x64 版混用会在第一次调用 Cv2 读图时抛 BadImageFormatException而不是启动时报错第二是模型路径用 AppDomain.CurrentDomain.BaseDirectory 拼接避免绝对路径换机器失效第三是 lable.txt 行数和模型类别数不一致解析时数组越界用标签数组长度初始化 numClasses 并和 output dims 校验。往 lable.txt 加新类别时回到训练侧重新导出模型导出参数保持和当前工程一致yolo export modelbest.pt formatonnx imgsz640 opset12imgsz 和 opset 必须和源码里模型的输入尺寸一致否则输出张量的后处理入口就和解析代码对不上了。本文还有配套的精品资源点击获取