
简介一套基于C#与ONNX Runtime部署YOLOv8模型的红绿灯检测完整工程适合.NET开发者、计算机视觉入门者以及智能交通、自动驾驶辅助场景的研究人员解决在C#环境中加载深度学习模型并实时完成目标检测与状态识别的问题。压缩包共65个文件整体约199.61MB内含Visual Studio解决方案sln/csproj/suo、11个C#核心源码文件、traffic-lights.onnx模型、onnxruntime与OpenCvSharp等运行库、配置及资源文件从模型读取、图像预处理、推理执行到检测框与置信度后处理的代码均有覆盖能够帮助读者清晰掌握ONNX模型在C#端的部署流程。已有310人学习下载。工程结构规整附带标签文本和检测结果基类便于二次开发将模型替换为其他YOLOv8导出模型后也可迁移至车辆、行人等检测任务。1. C# 里跑 Yolov8 Detect为什么用 Onnx Runtime 做红绿灯检测红绿灯检测这类小目标识别任务训练阶段大家习惯在 Python 里用 YOLOv8 出结果可一到交付就卡住现场没有 Python 环境工控机性能参差检测逻辑还得嵌进 C# 上位机。把权重导出成 Onnx再用 Onnx Runtime 在 C# 工程里做推理是最省事的落地路径不用重写模型也不依赖 Python 运行时一次拿到候选框、类别和置信度直接对接界面和通信。这套方案适合已经在写 .NET 界面和 PLC 通信的工程师。模型先用 Ultralytics 导出成 onnxC# 侧只做三件事读图预处理、调 InferenceSession 推理、解析输出并画框。红绿灯尺寸小、置信度低解码和 NMS 的处理顺序直接决定漏检率下面把每一步的参数和坑逐个讲清楚。2. 环境与模型C# 工程接入 Onnx Runtime把 pt 权重转成 onnx2.1 NuGet 引包OnnxRuntime 与 OpenCvSharp 的搭配C# 侧依赖两组包推理引擎 Microsoft.ML.OnnxRuntime图像读取、缩放和画框用 OpenCvSharp4。Windows 下还需要一个原生运行库 OpenCvSharp4.runtime.winLinux 部署时换成对应 ubuntu 的 runtime 包。版本号以 NuGet 页面当前稳定版为准注意 OnnxRuntime 的位数要和进程目标一致x64 工程别用 AnyCPU 去加载原生库真机上报 BadImageFormatException 多半是这个原因。包名作用安装命令Microsoft.ML.OnnxRuntimeCPU 推理默认执行提供程序Install-Package Microsoft.ML.OnnxRuntimeMicrosoft.ML.OnnxRuntime.DirectMLWindows 显卡加速自带完整运行时Install-Package Microsoft.ML.OnnxRuntime.DirectMLOpenCvSharp4图像处理与画框接口Install-Package OpenCvSharp4OpenCvSharp4.runtime.winWindows 原生运行库Install-Package OpenCvSharp4.runtime.win提示DirectML 包和标准包都包含完整运行时二选一引用同时引用会冲突。先拿 CPU 包跑通流程确认输出正确后再换加速包。2.2 把 best.pt 导出成 best.onnx 的 Ultralytics 脚本训练好的 best.pt 不能直接加载先用 Python 侧导一次这是社区标准做法from ultralytics import YOLO model YOLO(best.pt) # 红绿灯数据集训练出来的权重 model.export(formatonnx, imgsz640, opset12, dynamicFalse)同目录生成 best.onnx。format 指定导出格式imgsz 是模型输入边长默认 640。红绿灯目标小想让近景以外的小灯更好检可以用 imgsz960 重新导出推理耗时按面积约翻倍CPU 上要掂量。opset12 兼容性好新版 OnnxRuntime 支持更高 opset没特殊需求不用调大。导出过程会剥掉 NMS 和画框逻辑输出是一张原始预测张量后处理必须自己在 C# 里写。2.3 Netron 核对 input/output名字和形状别靠猜写代码前用 Netron 打开 best.onnx 核对输入名通常叫 images形状 1x3x640x640排布是 NCHW输出名通常叫 output0形状 1x(4类别数)x8400。这两个名字在 C# 里要原样照抄Session 按名字绑张量写错一个字母推理直接报错。另外确认输出头没有挂 sigmoid 和 NMS 节点Ultralytics 导出默认不包含这两步所以得分不是概率值后面过滤阈值要按原始 logits 的尺度调。如果输出形状第一维大于 1说明导出了动态 batchC# 里首帧输入的 batch 要一致否则形状对不上。常见误用是拿 COCO 的 1x84x8400 当通用形状自己训的模型类别数往往只有三到五个输出第二维是 4N对接前先打印 output0 的形状比盯着代码猜更稳。3. 读懂 Yolov8 Detect 输出8400 个候选框在 C# 里的解码顺序3.1 8400 从哪来三尺度特征图与 4N 维度Yolov8 的 Detect 头在下采样 8 倍、16 倍、32 倍的三个特征图上做预测640 输入对应 80x80、40x40、20x20 三个网格加起来 640016004008400 个候选位置。每个位置存一个 4N 维向量前 4 维是中心点 cx、cy 和宽高 w、h后 N 维是各类别得分。COCO 预训练权重 N80所以常见形状是 1x84x8400自己训练的红绿灯模型只有红灯、绿灯、黄灯几类输出是 1x(4N)x8400。骨干网络里 C2f 模块怎么搭和这一步无关推理代码只关心输出张量排布。输出维度值说明dim01batch 固定 1dim14N4 个几何值加 N 个类别得分dim28400三尺度锚点总数3.2 列主序解码按 行 x 8400 列 取数output0 在内存里是列主序第 c 维、第 j 个候选要按 c * 8400 j 取。最多人踩的坑是拿 [8400, 84] 的思路去读把类别维当最后一维取出来的坐标和得分全是错位的。先调用 AsTensor ().ToArray() 展平成一维 float[]再循环解码public class Detection { public float X1, Y1, X2, Y2, Score; public int ClassId; } // output 是 output0 展平后的 float[]batch1 int numClasses output.Length / anchors - 4; var detections new ListDetection(); for (int j 0; j anchors; j) { float cx output[j]; // 第 0 行中心点 x float cy output[1 * anchors j]; // 第 1 行中心点 y float w output[2 * anchors j]; float h output[3 * anchors j]; float bestScore 0f; int bestClass -1; for (int c 0; c numClasses; c) { float score output[(4 c) * anchors j]; if (score bestScore) { bestScore score; bestClass c; } } if (bestScore confThreshold) continue; detections.Add(new Detection { X1 cx - w / 2f, Y1 cy - h / 2f, X2 cx w / 2f, Y2 cy h / 2f, Score bestScore, ClassId bestClass }); }导出模型自带 DFL 解码输出坐标是 640x640 输入空间的像素值不是归一化比例解码后可以直接当坐标用。cx、cy 是中心点要换算成左上角和右下角为下一步 NMS 做准备。用最大值当整框置信度可以避免多类别互相干扰如果只关心红绿灯也可以只扫第 4 维之后你关心的那几个类别省掉其余循环。上面的实现每次访问都是托管数组按索引取值单帧解析毫秒级完成调试阶段够用。要压耗时可以 unsafe 固定数组指针后按行指针遍历或者把四个几何值先读到连续内存再算减少边界检查。3.3 置信度阈值与类别 ID 映射红绿灯是小目标几个像素的灯头在特征图里占比很小得分普遍比行人车辆低confThreshold 先给 0.25实拍远景再往下调到 0.15。阈值太低会把路灯、车尾灯带进来太高会漏掉暗光下的绿灯。类别 ID 的顺序和训练时 data.yaml 里的 names 一致比如 names 写成 0: red、1: green、2: yellow那么 ClassId0 就是红灯。验证解析对不对的最快方法拿 COCO 预训练权重导出的模型跑一张路口照片如果 ClassId 落在 9说明坐标和类别维都读对了因为 COCO 的第 9 类正好是交通信号灯。4. 从预处理到画框C# 调用 Onnx 推理并实现 NMS4.1 Letterbox 缩放短边缩放加灰边别直接拉伸预处理看起来简单其实决定小目标检测的成败。直接把任意尺寸图片 Resize 到 640x640 会改变宽高比红绿灯被拉扁后回归框跟着偏NMS 后框位对不齐灯头。常见做法是短边缩放、灰边补齐填充值 114 和训练时一致const int InputSize 640; using var src Cv2.ImRead(traffic.jpg, ImreadModes.Color); float ratio Math.Min(InputSize / (float)src.Width, InputSize / (float)src.Height); int newW (int)Math.Round(src.Width * ratio); int newH (int)Math.Round(src.Height * ratio); using var resized src.Resize(new OpenCvSharp.Size(newW, newH)); using var canvas new Mat(InputSize, InputSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new OpenCvSharp.Rect(0, 0, newW, newH)]);ratio 是缩放系数newW、newH 是等比缩放后的尺寸后面映射回原图全靠它。这里把图放左上角右下两条边放灰边如果改成居中填充要另外记录 padX(640-newW)/2 的偏移映射时减回去。补 114 不是玄学Ultralytics 训练管线的默认填充就是这个值推理保持一致可以减少域偏移比随便填 0 效果更稳。4.2 构建 NCHW 输入用 InferenceSession.Run 推理画布是 HWC 排布的 BGR 图像需要先转 RGB、归一化到 0-1再重排成 1x3x640x640。Session 建议做成单例加载模型开销不小每帧 new 一次 InferenceSession 帧率直接对半砍using var rgb new Mat(); Cv2.CvtColor(canvas, rgb, ColorConversionCodes.BGR2RGB); var inputTensor new DenseTensorfloat(new[] { 1, 3, InputSize, InputSize }); for (int y 0; y InputSize; y) for (int x 0; x InputSize; x) { var px rgb.AtVec3b(y, x); inputTensor[0, 0, y, x] px.Item0 / 255f; // R 通道 inputTensor[0, 1, y, x] px.Item1 / 255f; // G 通道 inputTensor[0, 2, y, x] px.Item2 / 255f; // B 通道 } var sessionOptions new SessionOptions(); sessionOptions.IntraOpNumThreads 4; using var session new InferenceSession(best.onnx, sessionOptions); using var results session.Run(new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, inputTensor) }); var output results.First(r r.Name output0).AsTensorfloat().ToArray();输入名 images 和输出名 output0 就是 2.3 节从 Netron 抄下来的名字务必和模型保持一致。逐像素 At 写法慢但逻辑清晰跑通后可以 Mat 转 float 再分通道拷贝。SessionOptions 里的线程数按机器物理核设别超过核数超了反而因为抢占调度变慢。4.3 NMS 合并重叠框并把坐标映射回原图解码出来的候选框会大量重叠同一盏灯在相邻网格、相邻尺度都会命中直接画会叠成一团。NMS 按得分排序保留局部最大值框。OpenCvSharp 自带 NMSBoxes不用手写 IoU但参数要知道怎么调var boxes detections.Select(d new OpenCvSharp.Rect( (int)d.X1, (int)d.Y1, (int)(d.X2 - d.X1), (int)(d.Y2 - d.Y1))).ToList(); float[] scores detections.Select(d d.Score).ToArray(); Cv2.Dnn.NMSBoxes(boxes, scores, confThreshold, nmsThreshold, out int[] keep); var final keep.Select(i detections[i]).ToList(); foreach (var d in final) { float x1 d.X1 / ratio; // 左上角放置时 pad 为 0 float y1 d.Y1 / ratio; float x2 d.X2 / ratio; float y2 d.Y2 / ratio; Cv2.Rectangle(src, new OpenCvSharp.Rect((int)x1, (int)y1, (int)(x2 - x1), (int)(y2 - y1)), new Scalar(0, 255, 0), 2); }nmsThreshold 是 IoU 阈值红绿灯候选框小、互相重叠少0.5 够用行人密集场景才需要往 0.7 调。先 NMS 再反算映射顺序别反。NMS 在 letterbox 坐标空间做映射回原图时除以 ratio如果做了居中填充还要先减偏移。数据分布可以参考下面这张参数表参数典型值说明confThreshold0.15 ~ 0.25小目标场景取低值避免漏检nmsThreshold0.45 ~ 0.70框重叠多时取大值InputSize640 / 960小目标密集时用 960IntraOpNumThreads物理核一半留核给界面和采集线程5. 红绿灯检测的进阶处理HSV 颜色判定、int8 量化与验证模型如果只输出一类 traffic light或者红灯绿灯区分度不够可以在 C# 里对检测框再做一次颜色判定不用重新训练。把框裁出来转 HSV分别统计红绿区间像素占比哪个比例高就按哪个报using var hsv new Mat(); Cv2.CvtColor(cropped, hsv, ColorConversionCodes.BGR2HSV); var redMask Cv2.InRange(hsv, new Scalar(0, 100, 100), new Scalar(10, 255, 255)); var greenMask Cv2.InRange(hsv, new Scalar(40, 100, 100), new Scalar(85, 255, 255)); double redRatio Cv2.CountNonZero(redMask) / (double)(cropped.Width * cropped.Height); double greenRatio Cv2.CountNonZero(greenMask) / (double)(cropped.Width * cropped.Height);红色在 HSV 里绕 0 度一圈一个区间盖不全更稳妥的写法是加一段 160 到 180 的低端区间两个掩膜做或运算。这个判定只对模型给到的框负责框歪了或者把路灯框进来颜色判定会跟着错所以颜色阈值要和模型检测质量一起调。然后是 int8 量化。红绿灯检测大多跑在 CPU 工控机上用 onnxruntime.quantization 把模型量化到 int8 能让模型体积减半以上单帧耗时通常明显下降代价是精度轻微掉点。静态量化需要准备几十张带标注的代表图做校准比动态量化效果稳。量化后的 onnx 在 C# 侧不用改代码同一个 InferenceSession 直接加载只是首帧预热时间略长。最后是验证顺序固定一段有红绿灯的本地视频分别记录原始 fp32 模型和 int8 模型每帧耗时、漏检数和误检数确认框位和 IoU 掉点可接受再上现场。红绿灯切换瞬间检测框会抖连续 3 帧里同一位置类别一致再上报能过滤掉大部分闪烁误报。现场机器核多的话把 IntraOpNumThreads 设成物理核数一半采集、解码和画框各占一个线程整链路帧率比单线程裸跑高一截。本文还有配套的精品资源点击获取