ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#中YOLOv8路面坑洼检测:ONNX Runtime部署全流程解析

C#中YOLOv8路面坑洼检测:ONNX Runtime部署全流程解析 简介这是一份面向C#开发者的YOLOv8路面坑洼检测项目源码使用ONNX Runtime在C#环境中加载并执行目标检测模型可用于实时摄像头画面或本地图片的路面缺陷识别。包体共41个文件、30.1MB核心包括11个C#源文件窗体界面、检测结果类、程序入口等1个pothole.onnx模型文件以及OpenCvSharp、Microsoft.ML.OnnxRuntime等依赖库DLL另含配置与资源文件结构清晰便于直接打开调试。已有670人学习下载。通过该工程开发者能掌握C#中集成ONNX模型、调用YOLOv8进行目标检测、结合OpenCvSharp处理图像及标注结果的全流程适合对计算机视觉与路面状况监测感兴趣的初中级C#程序员作为实战入门参考。1. 一个路况巡检项目目标是在 C# 上位机里用 ONNX Runtime 实时推理 YOLOv8 路面坑洼检测模型现场工控机是 Windows 10没有独立显卡上位机要用 C# 显示视频流、保存检测结果、把坑洼位置发给 PLC。用 Python 做演示很顺利真到落地就卡住了打包体积大、进程不稳定、和现有 C# 程序交互麻烦。C# 加载 ONNX Runtime 跑 YOLOv8 检测是这个场景下最稳的落地方式。标题里的 C# Onnx Yolov8 Detect 路面坑洼检测本质就是一条链路用 YOLOv8 训练好坑洼模型导出 ONNX 格式再由 C# 程序完成预处理、推理和后处理。这篇文章按这条链路展开适合做道路巡检、工业视觉质检以及任何想在 .NET 环境里集成 YOLOv8 检测的工程师。2. 模型准备PyTorch 导出 ONNX以及导出后必须检查的三件事2.1 ONNX 和 ONNX Runtime 的差别决定你后面怎么排错先把两个名词拆开。ONNX 是一种模型格式里面存的是网络结构和权重类似你训练出来的 .pt 文件ONNX Runtime 是推理引擎它负责读取 ONNX 文件并在 CPU 或 GPU 上执行计算。在 C# 里通过 NuGet 引入 Microsoft.ML.OnnxRuntime 包就等于把推理引擎搬进了程序。很多人习惯说“加载 ONNX 模型”听起来像是一个动作实际是两件事第一步把 .onnx 文件交给 ONNX Runtime 解析第二步由 ONNX Runtime 调度算子执行计算。所以后面遇到加载失败先分清是模型文件本身结构有问题还是引擎读不出来。这两类问题的排查方向完全不同。2.2 用 ultralytics 导出 ONNX固定 shape 比动态 shape 更适合 C#训练用自己的数据集跑 YOLOv8最后得到 best.pt 和 last.pt部署时一定选验证集上 mAP 最高的 best.pt。坑洼检测这类任务数据分布受光照、路面材质影响很大过拟合的风险比通用目标检测高best.pt 通常比训练末期权重更稳。导出 ONNX 用 ultralytics 官方接口一次搞定不需要手动 torch.onnx.exportfrom ultralytics import YOLO model YOLO(runs/detect/pothole/weights/best.pt) model.export( formatonnx, imgsz640, opset12, dynamicFalse, simplifyTrue, )这段代码的逻辑加载训练好的 YOLOv8 权重调用 export 把模型转成 ONNX 格式。imgsz640 表示输入尺寸固定为 640x640dynamicFalse 关掉动态维度simplifyTrue 会用 onnxsim 做一次计算图精简。参数说明opset12 在 ONNX Runtime 和算子支持之间比较平衡如果现场工控机很老降到 11 更稳。dynamicFalse 对部署更友好动态 shape 会迫使 C# 侧每次推理都处理变长输入内存和耗时都不划算。路面坑洼检测的输入来自固定相机分辨率通常不变固定 shape 完全够用。2.3 导出后用 Python 打印输入输出 shape别急着写 C#很多人在 C# 里折腾半天最后发现是模型导出时输出和预期不一致。导出后先用 Python 加载 ONNX 模型确认输入输出的张量结构import onnxruntime as ort sess ort.InferenceSession(pothole_yolov8s.onnx, providers[CPUExecutionProvider]) for inp in sess.get_inputs(): print(input:, inp.name, inp.shape) for out in sess.get_outputs(): print(output:, out.name, out.shape)打印结果示例input 是 imagesshape 是 [1, 3, 640, 640]output 是 output0shape 是 [1, 5, 8400]。这 5 代表 4 个位置参数加 1 个类别得分因为路面坑洼是单类别模型。8400 是 YOLOv8 在 640 输入下三个检测尺度80x80、40x40、20x20累积出的候选框数量计算方式就是 6400 加 1600 加 400。模型输出 shape说明COCO 预训练模型[1, 84, 8400]4 80 个类别路面坑洼单类模型[1, 5, 8400]4 1 个类别C# 代码里的 NumClasses 常量要和这里的通道数对应。如果漏了这步到 C# 里就会发现输出张量的维度对不上还得回头重新查导出配置。3. C# 加载 ONNX 模型推理类、预处理和后处理全流程3.1 从零搭一个最小推理类先跑通再优化用 C# 做 YOLOv8 推理依赖两个 NuGet 包Microsoft.ML.OnnxRuntime 提供推理引擎OpenCvSharp4 处理图像读取和缩放。新建一个检测器类核心就是 InferenceSession 对象using System; using Microsoft.ML.OnnxRuntime; public class YoloPotholeDetector : IDisposable { private readonly InferenceSession _session; private const int InputWidth 640; private const int InputHeight 640; public YoloPotholeDetector(string modelPath) { var options new SessionOptions(); options.IntraOpNumThreads Math.Max(1, Environment.ProcessorCount / 2); _session new InferenceSession(modelPath, options); } public void Dispose() { _session?.Dispose(); } }这段代码的逻辑构造 SessionOptions 配置 ONNX Runtime 行为再用模型路径创建 InferenceSession。IntraOpNumThreads 控制算子内部并发线程上限默认按逻辑处理器数量开线程但现场工控机通常启用了超线程全开反而让线程切换开销变大延迟更不稳定。参数说明Environment.ProcessorCount / 2 是起步值如果程序里还要跑界面渲染和 PLC 通讯这个值可以进一步压到 2 或 4。InferenceSession 实现了 IDisposable程序退出时要释放否则 ONNX Runtime 的原生内存不会立刻回收。3.2 预处理letterbox 缩放、BGR 转 RGB、归一化YOLOv8 训练时输入是正方形图路面相机的原始画面通常是 1920x1080 或 1280x720直接 Resize 会把图像拉伸变形检测框坐标也会跟着歪。正确处理方式是 letterbox等比例缩放剩余区域用灰色填充private float[] Preprocess(Mat image, out float scale, out float padX, out float padY) { // 计算等比缩放比例取宽高中较小的缩放系数 scale Math.Min((float)InputWidth / image.Width, (float)InputHeight / image.Height); int newW (int)Math.Round(image.Width * scale); int newH (int)Math.Round(image.Height * scale); // 计算填充偏移量letterbox 的灰边会平均分布在两侧 padX (InputWidth - newW) / 2f; padY (InputHeight - newH) / 2f; Mat resized new Mat(); Cv2.Resize(image, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); // 创建 640x640 灰色画布填充值 114 与 YOLOv8 训练时保持一致 Mat letterbox new Mat(InputHeight, InputWidth, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(letterbox[new Rect((int)padX, (int)padY, newW, newH)]); // OpenCvSharp 默认读成 BGRYOLOv8 训练用 RGB必须转换 Cv2.CvtColor(letterbox, letterbox, ColorConversionCodes.BGR2RGB); // 转为模型输入格式NCHWfloat 类型数值范围 0-1 float[] input new float[3 * InputHeight * InputWidth]; int idx 0; for (int c 0; c 3; c) { for (int y 0; y InputHeight; y) { for (int x 0; x InputWidth; x) { input[idx] letterbox.AtVec3b(y, x)[c] / 255f; } } } return input; }这里有三个坑在代码里直接规避了第一灰边填充值用 114不是 0YOLOv8 训练时就是这么处理的第二转成 RGB 后再逐通道取值否则 R 和 B 通道会互换第三归一化到 [0,1]模型权重是按这个范围训练的。3.3 推理和后处理从 output0 张量到可用的检测框Session.Run 执行一次推理拿到输出后要按 [1, 4nc, 8400] 的结构解析。这个输出是 CHW 排列C# 里逐元素读取时最好先转置再按候选框维度处理public ListPotholeBox Detect(Mat image) { float[] inputData Preprocess(image, out float scale, out float padX, out float padY); // 构造 ONNX Runtime 输入张量 using var inputTensor new DenseTensorfloat(inputData, new[] { 1, 3, InputHeight, InputWidth }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, inputTensor) }; using var results _session.Run(inputs); var output results.First().AsTensorfloat(); // output shape: [1, 4 numClasses, 8400] var rawBoxes DecodeBoxes(output, scale, padX, padY); return NonMaxSuppression(rawBoxes); }逻辑说明DenseTensor 把一维 float 数组包装成推理引擎要求的 NCHW 形状。输入名 images 要和导出 ONNX 时的 input_names 保持一致名字对不上会直接报错。Run 返回的结果实现了 IDisposable用 using 包裹避免内存泄漏。后处理函数是把输出张量里的每个候选框解码private const int NumClasses 1; private const float ConfThreshold 0.25f; private ListPotholeBox DecodeBoxes(Tensorfloat output, float scale, float padX, float padY) { int channels output.Dimensions[1]; int numAnchors output.Dimensions[2]; // 把 [1, channels, anchors] 转成 [anchors, channels]方便按行读取 float[,] transposed new float[numAnchors, channels]; for (int a 0; a numAnchors; a) { for (int c 0; c channels; c) { transposed[a, c] output[0, c, a]; } } var boxes new ListPotholeBox(); for (int i 0; i numAnchors; i) { // 单类别模型直接取第 5 个通道作为类别得分 float clsScore transposed[i, 4]; if (clsScore ConfThreshold) continue; float cx transposed[i, 0]; float cy transposed[i, 1]; float w transposed[i, 2]; float h transposed[i, 3]; // 把 letterbox 坐标系还原到原图坐标系 float x1 (cx - w / 2 - padX) / scale; float y1 (cy - h / 2 - padY) / scale; float x2 (cx w / 2 - padX) / scale; float y2 (cy h / 2 - padY) / scale; boxes.Add(new PotholeBox(x1, y1, x2, y2, clsScore)); } return boxes; }这段代码的核心逻辑把 CHW 排列转成行优先再从每行解析出中心坐标、宽高和类别得分。坐标反算公式里的 padX 和 padY 来自 letterbox 的填充偏移除以 scale 是把缩放后的坐标映射回原图。最后做 NMS 去重。路面坑洼场景通常是单类别直接对所有候选框按得分排序逐个剔除高 IoU 的重复框即可不需要按类别分组private ListPotholeBox NonMaxSuppression(ListPotholeBox boxes, float iouThreshold 0.45f) { var result new ListPotholeBox(); var candidates boxes.OrderByDescending(b b.Score).ToList(); while (candidates.Count 0) { var best candidates[0]; result.Add(best); candidates.RemoveAt(0); // IoU 大于阈值的框认为是同一个目标直接丢弃 candidates.RemoveAll(b IoU(best, b) iouThreshold); } return result; }IoU 计算用标准交并比公式两个框的交集面积除以并集面积。如果检测结果里同一坑洼出现大量重复框把 iouThreshold 调高到 0.5如果不同坑洼靠得太近被合并则调低到 0.3。4. 路面坑洼场景的工程化调优置信度阈值、输入尺寸和线程模型4.1 坑洼目标的置信度普遍偏低阈值要从 0.25 往下探通用目标检测里 0.25 的置信度阈值很常见但路面坑洼不一样。坑洼是低纹理目标边缘不锐利和阴影、修补色差的边界很模糊模型输出的类别得分普遍低于车辆和行人。我的经验是路面坑洼从 0.15 起步调试实在不行降到 0.1然后用 NMS 把重复框压掉。C# 代码里不必硬编码阈值做成配置文件方便现场调试public class DetectorConfig { public float ConfidenceThreshold { get; set; } 0.15f; public float NmsIoUThreshold { get; set; } 0.35f; public int InputSize { get; set; } 640; }配置用 System.Text.Json 读取启动时加载到检测器里。现场调试时只改 JSON不用重新编译程序。这个习惯能省大量时间尤其是路测人员不在你身边的时候。4.2 小目标漏检别急着上 1280先做 ROI 裁切输入分辨率从 640 提到 1280对远处小坑洼的召回提升明显但推理耗时接近翻三倍。路面检测有个特点坑洼只会出现在固定的车道区域内画面天空和护栏几乎没有检测价值。先裁切再缩放比整体放大输入更划算。把原图底部 60% 区域作为 ROI裁下来缩放到 640Rect roi new Rect(0, (int)(image.Height * 0.35), image.Width, (int)(image.Height * 0.65)); Mat crop new Mat(image, roi);逻辑说明ROI 裁掉画面顶部 35% 的天空和远景保留路面主体。crop 后面传给 Detect 方法即可。这样同一分辨率下坑洼在输入图中的像素占比变大小目标召回率提升推理耗时不变。4.3 多路视频流时让每个线程持有独立 Session如果现场是四路相机同时检测千万不要让四个线程共用一个 InferenceSession 实例。虽然 ONNX Runtime 官方说 Session.Run 线程安全但多线程并发调用时内部线程池会激烈竞争OpenCvSharp 的 Mat 对象在多线程间共享也容易出原生内存错误。稳妥做法是每个相机线程创建自己的检测器实例每个实例独立加载一次模型。ONNX Runtime 加载模型有内存映射机制多实例共享同一文件路径时操作系统层面通常不会重复占满内存这点不用过于担心。5. 避坑与排查C# 跑 YOLOv8 检测的 4 个踩坑记录5.1 模型加载正常但没有检测框输出值全是异常或 0现象程序不报错推理也执行了就是死活不出框。原因八成是预处理没做归一化。把 0-255 的像素值直接塞进模型权重数值链路被放大输出层的类别得分被推到极低。还有小概率是 BGR 和 RGB 通道顺序反了结果表现是漏检严重但偶尔出框。解决确认预处理里做了pixel / 255f归一化并且 CvtColor 从 BGR 转到了 RGB。可以用一张已知的测试图在 Python 里跑一遍 ONNX 模型把输出结果打印出来和 C# 对比。两边都是同一模型同一张图输出应该完全一致。5.2 有检测框但位置整体偏移尺寸也明显不对现象坑洼被框住了但框不在坑洼上整体往某个方向偏移框的宽高比例也看着别扭。原因坐标还原时漏掉了 letterbox 的 padX 和 padY。解码时直接用了cx - w / 2除以 scale没减填充偏移导致框整体偏移。宽高不对通常是 scale 计算的系数错了或者把填充后的尺寸当成了原图尺寸。解决检查 DecodeBoxes 里坐标反算公式是否完整。x1 (cx - w / 2 - padX) / scalepadX 和 padY 缺一不可。可以用一张 1920x1080 的图在画面上画一个已知位置的矩形走一遍检测流程看还原坐标是否回到原位。5.3 程序启动时抛 DllNotFoundException 或 EntryPointNotFoundException现象代码编译通过一运行就报找不到 DLL 或找不到入口点。原因这类问题集中在原生依赖上。OpenCvSharp 的 native 库没有正确复制到输出目录或者 Microsoft.ML.OnnxRuntime 的 native 库和目标平台架构不匹配。最常见的是在 x64 机器上跑 x86 构建或者 NuGet 包版本不一致。解决确认项目目标平台是 x64NuGet 包版本和引用保持一致。检查输出目录下的 runtimes 文件夹里面应该有对应平台的 native 文件。建议把 OpenCvSharp 和 ONNX Runtime 升级到同一时期的新版本但升级前先在测试机上跑一遍同一段视频避免 API 变化引发额外问题。5.4 CPU 线程开满推理时间反而波动大现象推理延迟从 40ms 到 120ms 来回跳CPU 占用一直顶着 100%画面卡顿明显。原因SessionOptions 默认按逻辑核数开线程超线程让线程数翻倍算子内部频繁切换上下文。再加上多线程调用同一个 Session延迟就变得极不稳定。解决把 IntraOpNumThreads 限制在物理核心数的一半例如四核八线程的工控机设置为 2。调整后单帧延迟可能小幅上升但波动会明显减小整体帧率表现更好。6. 进阶int8 动态量化把推理压进 30ms以及如何验证不掉点CPU 环境下想进一步提速int8 动态量化是最直接的路径。它对权重做 int8 量化推理时激活值仍用 float 计算精度损失比静态量化小很多。量化脚本用 Python 执行一行命令生成新模型from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( pothole_yolov8s.onnx, pothole_yolov8s_int8.onnx, weight_typeQuantType.QInt8, op_types_to_quantize[Conv, MatMul] )参数说明weight_type 指定权重类型为有符号 int8op_types_to_quantize 限定只量化 Conv 和 MatMul 两类算子。能落到 30ms 的前提是模型本身是 YOLOv8s 或更小的版本YOLOv8x 即使量化也很难在纯 CPU 环境达到这个速度。量化模型在 C# 侧无需改代码把模型路径换成 int8 版本即可。验证方法很直接同一段视频分别用 fp32 和 int8 跑一遍把每帧检出的坑洼数量和置信度写入 JSON 文件对比。如果 int8 版本漏检明显多于 fp32不要上。我一般会让现场保留 fp32 版本作为兜底量化版本打上新版本号先在同一个数据集上对比 mAP。int8 掉点超过 3% 就不上这是我在多个部署项目里养成的习惯。希望帮到你。本文还有配套的精品资源点击获取
返回列表