简介:一份面向 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 加载模型,图像读写交给 OpenCvSharp,WinForms 界面直接框出灯体并给出置信度。整个流程不依赖 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 后,推理侧不需要装 Python,ONNX 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 Toolkit,1660Ti 这类显卡上跑 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 DenseTensor<float> 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 读进来是 BGR,YOLOv8 训练数据是 RGB using Mat rgb = new Mat(); Cv2.CvtColor(canvas, rgb, ColorConversionCodes.BGR2RGB); // 4. HWC -> CHW,逐像素除以 255 归一化 var tensor = new DenseTensor<float>(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.At<Vec3b>(y, x)[c] / 255f; return tensor; // ratio/padX/padY 留给后处理映射 }一步步拆开看:第一步保证长边不超过 640,第二步用 114 填充,第三步把 BGR 转成 RGB,第四步把 HWC 布局调整为 CHW 并归一化到 0~1。最后构造出来的 [1, 3, 640, 640] 就是模型输入要求的维度,维度不一致时 session.Run 直接报形状错误。预处理涉及三个参数,直接列成表更清楚:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| inputSize | 640 或与导出模型一致 | 决定 DenseTensor 维度 |
| fill 值 | 114 | YOLOv8 训练默认填充色 |
| 归一化方式 | 除以 255 | 与训练时的数据增强保持一致 |
参数上要注意的点:fill 色保持 114,改成 0(黑色填充)会让模型在边缘感知到额外内容,小目标召回率受影响。padX/padY 用整数除法,奇数像素损失最多 1 个像素,对检测框精度几乎无感。如果输入是 4K 截图这种大图,先算一下缩放比例,超过 2000 像素就先降采样再走 Letterbox,能明显减少内存峰值。
3.3 推理调用与输入名匹配
using var inputs = new List<NamedOnnxValue> { // 输入名用元数据里的 key,而不是写死 "images" NamedOnnxValue.CreateFromTensor(inputMeta.Key, tensor) }; using var results = session.Run(inputs); using var output = results.First(); var outputTensor = output.AsTensor<float>();输入名从 inputMeta.Key 取,YOLOv8 官方导出叫 images,但有人会在 Netron 里改输入名,写死的话换模型就崩。results 返回的是可释放集合,output 本身也要释放,这里的 using 顺序保证先释放输出再释放会话结果,避免原生内存泄漏。拿到 outputTensor 后,接下来就是第 4 章的解析工作。
4. 解析 YOLOv8 的 1×84×8400 输出:坐标还原与 NMS
4.1 输出张量布局,以及 C2f 对工程侧的影响
YOLOv8 检测头的 ONNX 输出形状是 [1, 4+numClasses, 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 上要么框全都偏,要么直接越界。
| 通道下标 | 含义 | 取值说明 |
|---|---|---|
| 0 | cx | 中心点 x,相对 letterbox 画布,范围 0~1 |
| 1 | cy | 中心点 y |
| 2 | w | 检测框宽度 |
| 3 | h | 检测框高度 |
| 4 ~ 4+numClasses-1 | 各类别分数 | 取最大值的下标作为类别 |
4.2 置信度过滤与标签映射
private static List<DetectionResult> ParseOutput(Tensor<float> output, string[] labels, float confThreshold) { int numClasses = labels.Length; // 从 lable.txt 动态取 int numAnchors = output.Dimensions[2]; float[] data = output.ToArray(); var list = new List<DetectionResult>(); 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 List<DetectionResult> Nms(List<DetectionResult> cand, float iouThreshold) { var result = new List<DetectionResult>(); 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) / ratio,y 同理。
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 不匹配,平台选 x64,bin/x64 下 OpenCvSharpExtern.dll 必须是 x64 版,混用会在第一次调用 Cv2 读图时抛 BadImageFormatException,而不是启动时报错;第二是模型路径,用 AppDomain.CurrentDomain.BaseDirectory 拼接,避免绝对路径换机器失效;第三是 lable.txt 行数和模型类别数不一致,解析时数组越界,用标签数组长度初始化 numClasses 并和 output dims 校验。
往 lable.txt 加新类别时,回到训练侧重新导出模型,导出参数保持和当前工程一致:
yolo export model=best.pt format=onnx imgsz=640 opset=12imgsz 和 opset 必须和源码里模型的输入尺寸一致,否则输出张量的后处理入口就和解析代码对不上了。
本文还有配套的精品资源,点击获取