公司动态

基于ONNX Runtime的C#车道线检测:UFLD-v2模型工程实践

📅 2026/8/31 9:47:03
基于ONNX Runtime的C#车道线检测:UFLD-v2模型工程实践
简介本资源是基于C#实现的Ultra-Fast-Lane-Detection-v2模型ONNX推理工程面向自动驾驶、智能驾驶辅助系统开发人员及计算机视觉方向的.NET开发者解决实时车道线检测这一核心感知任务。项目完整封装了ONNX Runtime推理流程、OpenCvSharp图像预处理与后处理逻辑并适配CULane数据集训练的ufldv2_culane_res18_320x1600.onnx模型支持Windows平台x64/x86双架构部署。压缩包含79个文件涵盖12个核心C#源码如frmMain.cs、Common.cs、CrowdPoint.cs、10个关键DLL含onnxruntime.dll、OpenCvSharp.dll等、9张测试图像、5份配置文件及完整VS解决方案.sln与项目文件.csproj整体体积达757.8MB结构规范、模块职责清晰。已有356人学习下载提供开箱即用的可执行程序.exe、调试符号.pdb、资源文件.resx及设计时支持便于快速验证模型效果、调试推理性能或二次集成到车载视觉系统中。 说实话我最初接到这个任务的时候内心是有点拒绝的。项目是一个工业级的辅助驾驶验证平台上位机是用 C# 写的硬性要求是不装 Python 环境、不引入一堆运行时依赖把车道线检测接进去。开源车道线项目大多默认走 Python PyTorchC# 能用的成熟方案屈指可数。我把筛选范围缩小到“ONNX Runtime 能跑、模型够小、帧率够高”三条硬指标上最后真正跑通的就是 Ultra-Fast-Lane-Detection-v2 这个模型。这篇文章不是论文复现报告也不是手把手教程是我把 UFLD-v2 从 PyTorch 转到 ONNX再在 C# 里实现完整前后处理、最终跑出稳定车道线的全过程。里面会包含模型导出、图像预处理、推理调用、后处理、阈值调试、性能调优这些环节目标是让同样需要在 .NET 环境里做车道线检测的人少走我踩过的坑。1. 为什么要在 C# 里落地车道线检测从项目选型说起1.1 遇到的现实约束这个项目接进来的时候上位机框架已经是 .NET 6 了工业相机采集、UI 显示、传感器标定这些模块全都跑在 C# 侧。如果要为车道线检测单独部署一套 Python 服务就得面临 Python 环境管理、进程间通信、帧同步、崩溃恢复等一系列问题运维成本相当高。更麻烦的是部分部署现场是 Windows 工控机甚至不允许安装 Anaconda、Miniconda 这类工具。这种情况下C# 程序集 ONNX Runtime 原生库的方案几乎就是最优解引用简单、打包方便、性能也可控。所以当时我给自己定了几个原则模型推理必须走 ONNX Runtime纯粹用 NuGet 依赖解决不引入外部进程。模型文件编译期就放到项目里不依赖模型下载服务。图像采集和显示统一走 OpenCvSharp 或 AForge 这类 C# 可用的视觉库。整个链路必须做到 25 FPS 以上最好 CPU 端就能跑。1.2 模型选型UFLD-v2 为什么合适车道线检测的开源模型其实不少但绝大多数在设计之初就没考虑过 C# 调用。我简单筛过几类语义分割类模型比如 SCNN、LaneNet 这类输出是像素级分割图后处理需要做连通域分析、实例聚类写起来复杂CPU 上速度也不容易上去。基于目标检测的方案车道线是长条形目标用框去表示很别扭后处理还要接 NMS性价比不高。基于分类的 UFLD 系把车道线检测建模成逐行分类问题输出是一个固定大小的概率向量非常轻量后处理逻辑简单很适合工程落地。UFLD-v2 比 v1 更适合 C# 场景的原因在于v2 多了一个辅助分割分支。训练时这个分支帮助主干网络学到更稳定的特征推理时还能把分割图的概率当做一个软掩码过滤掉分类分支的误检点。说白了就是同样的单模型结构下v2 的鲁棒性更好。1.3 整体架构怎么搭我最后落地的架构很简单用文字描述就是一条直线图像采集OpenCvSharp / AForge→ 图像预处理Resize 归一化 HWC 转 CHW→ ONNX 推理OnnxRuntime→ 后处理argmax 坐标换算 置信度过滤→ 绘制并显示整个链路里最容易被低估的是后处理。很多人觉得模型跑完拿到输出就结束了但在车道线检测里模型输出只是一堆概率数字怎么把这堆数字变成图像上连续的曲线才真正决定效果好不好。2. Ultra-Fast-Lane-Detection-v2 的模型思路先理解再动手2.1 从分类角度理解车道线检测不画框也不做像素级分割UFLD 系最核心的思路是“逐行分类”。这个思路你可以这样理解车道线在图像里是一条近似竖直的长条与其把它当成一个目标框或者一整片分割区域不如在图像高度方向上预先放很多条水平线每条线上只回答一个问题——“车道线横向落在哪个位置”。具体到模型内部它在图像高度方向预置了一组 row anchor行锚点这些锚点在图像里的 y 坐标是固定的归一化后大概从 0.25 到 0.95。每个锚点上模型把 x 方向划分成若干个格子输出一个概率分布取概率最高的格子作为车道线在该行上的横向位置。以官方 Tusimple 配置为例输入尺寸 800x320griding_num 为 100也就是 x 方向分成 100 个格子再加上一个背景类所以每个锚点需要预测 101 类的概率。模型预测 4 条车道线每条车道线有 56 个 row anchor所以最终 loc 输出就是 [1, 4, 56, 101]。2.2 UFLD-v2 相对 v1 的改进v1 的思路是纯粹的逐行分类速度快但在一些复杂场景下误检比较明显尤其是护栏、路沿、树影这类纹理和车道线相似的目标。v2 的改进是加了辅助分割分支。模型前向时除了输出 loc分类概率还会输出一个 seg分割图通道数是 5 或 6对应背景加车道线。训练的时候分割分支提供额外监督让主干网络学到更细粒度的空间特征推理的时候分割分支的输出可以作为软掩码来校验分类结果——如果分类分支认为某个位置是车道线但分割分支对应区域概率很低那这个点就很可能是误检。实测下来这个辅助分支对稳定性的提升很明显尤其是在车身颠簸、图像模糊的帧里分类分支会出现不少孤立跳变点分割分支能过滤掉相当一部分。2.3 输入输出参数表下面这几个参数是整个后处理的基石建议直接记在项目文档里。项值说明输入尺寸800x320推理时的固定分辨率需与训练一致输入格式RGBfloat320~1OpenCvSharp 读出来是 BGR要转换loc 输出[1, 4, 56, 101]4 条车道56 个 row anchor100 格子 背景seg 输出[1, 5, 320, 800]背景 4 条车道的分割概率图默认阈值loc 0.3seg 0.3场景变化时需要调节row anchor 数量56从训练配置读取不要硬编码提示不同训练配置下row anchor 数量、griding_num、输入尺寸都可能不一样。你在 C# 里写数组维度的时候务必先检查导出 ONNX 模型的实际输出形状别凭记忆写死。2.3 输入输出参数表这个模型还有一点特别适合 C#它没有 anchor、没有 NMS、没有复杂的动态形状输出张量维度完全固定。这意味着你在 C# 里可以一次性分配好所有数组循环里每一帧复用不需要在推理过程中反复申请内存。这个特征在 .NET 的 GC 环境下非常吃香因为频繁分配大数组会触发 GC导致帧率抖动。3. 环境搭建与 ONNX 模型导出最简单也最容易翻车的环节3.1 C# 端依赖包C# 侧的依赖其实很少核心就两个包OnnxRuntime 和 OpenCvSharp。dotnet add package Microsoft.ML.OnnxRuntime dotnet add package OpenCvSharp4.Windows dotnet add package OpenCvSharp4.runtime.win如果你的目标机器有 NVIDIA 显卡并且确定要跑 GPU 推理可以把第一个包换成 Microsoft.ML.OnnxRuntime.Gpu。但注意GPU 包对 CUDA 和 cuDNN 版本有对应要求NuGet 包版本和 CUDA 版本对不上时会直接加载失败。我后来在实际项目中CPU 版跑 800x320 已经能到 25 FPS 以上所以 GPU 不是必需品反而省了很多环境折腾。3.2 PyTorch 导出完整操作导出 ONNX 其实是整个环节里最容易出错的一步。官方仓库的模型结构是解析配置生成的直接 torch.onnx.export 不一定能一次成功。我整理了一份能用的导出脚本核心逻辑是加载预训练权重把模型切成 eval 模式传入固定大小的 dummy input导出 loc 和 seg 两个输出。import torch from model.model import parsingNet from utils.config import Config # 读取官方配置row_anchor 和 griding_num 都在配置里 cfg Config(configs/tusimple.py) net parsingNet( pretrainedFalse, backbone18, num_classescfg.griding_num 1, num_lanes4, row_anchorcfg.train_row_anchor ) state_dict torch.load(tusimple.pth, map_locationcpu) net.load_state_dict(state_dict, strictFalse) net.eval() dummy_input torch.zeros(1, 3, cfg.img_height, cfg.img_width) with torch.no_grad(): loc, seg net(dummy_input) print(loc:, loc.shape) print(seg:, seg.shape) torch.onnx.export( net, dummy_input, lanenet.onnx, input_names[input], output_names[loc, seg], opset_version11, dynamic_axesNone ) print(export done)导出脚本里有几个细节值得注意dynamic_axes 我直接设成了 None。原因很简单后处理逻辑依赖固定的 row anchor 数量和 griding_num动态尺寸等于给自己埋坑。而且推理时输入的图像大小是固定 Resize 到 800x320动态尺寸在这里没有任何收益。strictFalse 这个参数要小心。如果加载权重时某些 key 没对上模型可能带着随机初始化的层在推理输出结果会莫名奇差。建议导出前先用一张真实图片跑一遍 PyTorch 前向确认输出是合理值再导。有些版本的官方仓库 forward 方法在 eval 模式下只返回 loc 不返回 seg需要检查 forward 实现。如果发现 seg 输出为 None需要稍微改一下 forward让导出时两个分支都输出。3.3 导出后必须做的事验证与形状检查拿到 lanenet.onnx 之后千万别急着写 C# 代码。先做两件事。第一用 onnxsim 简化模型消除一些冗余算子顺便修复导出时可能产生的 Reshape 问题。python -m onnxsim lanenet.onnx lanenet_sim.onnx第二用独立脚本读取 ONNX确认输出形状和你预期的一致。import onnxruntime as ort import numpy as np sess ort.InferenceSession(lanenet_sim.onnx) input_name sess.get_inputs()[0].name loc_name sess.get_outputs()[0].name seg_name sess.get_outputs()[1].name dummy np.zeros((1, 3, 320, 800), dtypenp.float32) loc, seg sess.run([loc_name, seg_name], {input_name: dummy}) print(loc, loc.shape) print(seg, seg.shape)这一步能筛掉大多数启动崩溃问题。如果你发现输出数量不对或者形状不是 [1, 4, 56, 101]优先回到导出环节检查不要在 C# 端强行适配错误的输出。4. 核心源码拆解从 Bitmap 到车道线的完整管线4.1 图像读取与图像预处理C# 端我统一用 OpenCvSharp 做图像读取和显示因为它读出来的 Mat 可以直接走 LockBits 类似的像素操作性能比 System.Drawing 的 GetPixel 快一个数量级。图像预处理的核心步骤有三个Resize、BGR 转 RGB、归一化并转 CHW。using OpenCvSharp; Mat LoadAndPreprocess(string path, out Mat original) { byte[] bytes File.ReadAllBytes(path); original Cv2.ImDecode(bytes, ImreadModes.Color); Mat resized new Mat(); Cv2.Resize(original, resized, new OpenCvSharp.Size(800, 320)); // OpenCvSharp 读入的是 BGR模型训练用的是 RGB Mat rgb new Mat(); Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB); float[] input new float[3 * 320 * 800]; unsafe { byte* ptr (byte*)rgb.Data; // HWC - CHW并归一化到 0~1 for (int h 0; h 320; h) { for (int w 0; w 800; w) { int idx h * 800 w; input[idx] ptr[idx * 3] / 255f; input[800 * 320 idx] ptr[idx * 3 1] / 255f; input[2 * 800 * 320 idx] ptr[idx * 3 2] / 255f; } } } return rgb; }这个函数里我用了 unsafe 直接操作 Mat.Data 指针原因很简单如果用Mat.GetPixel或AtVec3b一个一个取800x320 的图要遍历 256000 个像素每帧多花好几毫秒。直接指针操作快得多。提示如果你的项目部署环境是 LinuxOpenCvSharp 的 Mat.Data 指针依然可用但 System.Drawing 的 Bitmap 不行。这也是我推荐 OpenCvSharp 而不是 GDI 的重要原因。4.2 推理会话与输入张量构造模型会话建议在程序启动时创建一次复用一个 InferenceSession 实例。避免每次推理都重新加载模型那个开销非常大。using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class LaneDetector : IDisposable { private readonly InferenceSession _session; private readonly int _inputWidth 800; private readonly int _inputHeight 320; private readonly int _gridingNum 100; private readonly int _numLanes 4; private readonly int _rowAnchorCount 56; public LaneDetector(string modelPath, int? deviceId null) { var options new SessionOptions(); int threads Environment.ProcessorCount 2 ? Environment.ProcessorCount - 1 : 1; options.IntraOpNumThreads threads; options.AppendExecutionEP(new OrtCUDAProviderOptions(deviceId ?? 0)); _session new InferenceSession(modelPath, options); } public ListListPoint Detect(Mat bgrFrame) { float[] input Preprocess(bgrFrame, out Mat original); var inputTensor new DenseTensorfloat(input, new[] { 1, 3, _inputHeight, _inputWidth }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input, inputTensor) }; using var results _session.Run(inputs); var loc results[0].AsTensorfloat(); var seg results[1].AsTensorfloat(); return Postprocess(loc, seg, original); } }这里有几个容易忽略的坑IntraOpNumThreads不要设置成机器最大核心数。实测在超线程机器上线程数等于核心数时反而会因为线程切换开销导致性能下降。设置成核心数减一通常效果最好。_session.Run返回的结果需要释放用 using 包装能保证内存不泄漏。如果你在循环里一直跑不释放输出结果内存会持续上涨。GPU 的 ExecutionProvider 在 CPU 推理时会退化并打印警告如果你没有 NVIDIA 显卡直接去掉AppendExecutionEP那行。4.3 后处理从概率向量到坐标点C# 端后处理是整个项目最核心的部分也是最容易写错的地方。我直接贴出完整逻辑。const float LocThreshold 0.3f; const float SegThreshold 0.3f; // row anchor 在图像高度方向上的归一化位置需与训练配置一致 float[] rowAnchors new float[] { 0.25f, 0.26f, 0.27f, 0.28f, 0.29f, 0.30f, // 省略中间值…… 0.94f, 0.95f }; private ListListPoint Postprocess(Tensorfloat loc, Tensorfloat seg, Mat original) { var lanePoints new ListListPoint(); for (int lane 0; lane _numLanes; lane) { var points new ListPoint(); for (int row 0; row _rowAnchorCount; row) { int bestCol -1; float bestScore 0; // 最后一个类101是背景类不需要比较 for (int col 0; col _gridingNum; col) { float score loc[0, lane, row, col]; if (score bestScore) { bestScore score; bestCol col; } } if (bestCol 0 || bestScore LocThreshold) continue; float x bestCol * _inputWidth / (float)_gridingNum; float y rowAnchors[row] * _inputHeight; if (x 0 || x _inputWidth || y 0 || y _inputHeight) continue; int segX (int)Math.Round(x); int segY (int)Math.Round(y); float segScore seg[0, lane 1, segY, segX]; if (segScore SegThreshold) continue; points.Add(new Point((int)x, (int)y)); } if (points.Count 3) lanePoints.Add(points); } // 将模型坐标映射回原图坐标 float scaleX original.Width / (float)_inputWidth; float scaleY original.Height / (float)_inputHeight; return lanePoints .Select(lane lane.Select(p new Point((int)(p.X * scaleX), (int)(p.Y * scaleY))).ToList()) .ToList(); }这段代码的逻辑线是对每条车道、每个 row anchor先找分类概率最大的格子如果最大概率还达不到阈值就认为这个位置没有车道线然后换算成图像坐标再用分割分支概率验证一遍通过才收入点集。我实际调参的时候发现LocThreshold和SegThreshold对结果的影响是相互叠加的。分类阈值主要控制“跳变点”分割阈值主要控制“粘连误检”。两个阈值都偏低时护栏和路沿会被识别成车道线都偏高时远处车道线会断掉因为远处像素少分类和分割置信度都低。5. 后处理算法详解坐标换算、置信度过滤与车道线绘制5.1 坐标换算的精度问题模型内部处理的是 800x320 的输入图但原始视频帧可能是 1920x1080 或者 1280x720。换算坐标时最简单的方式是直接乘缩放比例。x 方向的换算公式是x_original colIdx * (originalWidth / gridingNum)。 y 方向的换算公式是y_original rowAnchor[rowIdx] * originalHeight。这里有一个容易踩的精度坑如果直接把colIdx * originalWidth / gridingNum用整数除法计算结果会被截断导致远处车道线出现几个像素的偏移。积累起来就是整条车道线偏左或偏右。务必先转 float 再乘除最后才取整。另外如果原图比例和 800:320 不一致直接等比缩放会拉伸图像。这在车道线检测里通常没问题因为模型训练时就是这么处理的但画线的时候要记得按 x 和 y 的缩放系数分开计算而不是用同一个系数。5.2 置信度过滤策略坐标换算只是基础真正影响检测效果的是置信度过滤。我的策略分三层分类结果过滤如果某行锚点上最大分类概率低于 0.3直接跳过该点。分割结果过滤如果分割图上对应位置的概率低于 0.3同样跳过。点数过滤如果某条车道线经过前两步过滤后剩余点数少于 3 个说明是连续误检整条丢弃。这三层过滤缺一不可。只做第一层会出现不少孤立噪点只做第二层远处车道线丢失严重不做第三层一些随机纹理也会被拼成一条假车道线。在高速公路上我可以把阈值调高到 0.4 和 0.4因为高速路面干净、车道线清晰但在乡道或雨天场景阈值得回调到 0.25 左右否则车道线会断得不成样子。这种场景相关性和经验关系建议做成可配置项放进配置文件方便现场调试。5.3 绘制与曲线平滑拿到坐标点之后绘制就简单了。// OpenCvSharp 中使用 BGR 颜色黄色是 (0, 255, 255) foreach (var pts in lanePoints) { if (pts.Count 2) continue; // 按 y 坐标排序保证连线方向一致 pts.Sort((a, b) a.Y.CompareTo(b.Y)); Cv2.Polylines(colorFrame, new[] { pts.ToArray() }, false, new Scalar(0, 255, 255), 3, LineTypes.AntiAlias); }对于弯道比较明显的场景直接连折线会显得有一点点生硬。我的经验是如果只是做可视化折线完全够用但如果后续要把车道线坐标交给控制模块做横向偏移计算建议对点集做一次多项式拟合比如用 y 做自变量、x 做因变量拟合二次或三次曲线。需要强调的一点是务必在拿到点后先按 y 排序再连线。模型输出的 row anchor 顺序本身是从上到下但在 C# 里经过过滤之后中间可能丢失了一些点如果不重新排序连线会来回折返画出来的车道线像乱麻一样。6. 实测效果、性能调优与后续扩展6.1 跑通 Demo 后的实测观察我在 i5-10400、16GB 内存的工控机上跑过完整的 C# 程序用的 CPU 版 OnnxRuntime。800x320 输入的推理耗时大概是 20 到 30 毫秒后处理 1 到 2 毫秒加上图像采集和绘制整体大概 30 到 35 FPS。换到带 GTX 1660 的机器用 OnnxRuntime.Gpu CUDA EP推理耗时降到了 3 到 6 毫秒瓶颈反而变成了摄像头帧率和图像显示性能余量很充足。环境推理耗时后处理耗时整体帧率CPU i5-1040020-30ms1-2ms约 30 FPSGPU GTX 16603-6ms1-2ms120 FPS 以上我第一次跑通的时候最直观的感受是这个模型在 CPU 上的速度比我想象中好太多。之前担心 C# 调用 ONNX 会有额外性能损耗实际上 OnnxRuntime 的 native 推理核心在底层都是 C 实现C# 只是调用层性能损耗基本可以忽略。6.2 INT8 量化与 GPU 推理的加速收益如果你只能在 CPU 上部署又觉得 30 FPS 不够可以考虑 INT8 量化。OnnxRuntime 提供了一套 Python 侧的量化工具量化后的模型仍然可以被 C# 端直接加载接口完全一样。from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( lanenet_sim.onnx, lanenet_int8.onnx, weight_typeQuantType.QInt8 )我测试过动态量化后的模型推理耗时大概能再降低 40% 左右精度损失在可接受范围内。但有一个坑量化后的模型概率分布会发生偏移之前调好的 0.3 阈值可能需要重新校准。建议量化后先跑一批真实场景图片重新观察 loc 输出的分数范围再决定阈值设多少。至于 GPU 推理前面说过CUDA、cuDNN、OnnxRuntime.Gpu 三者的版本必须匹配。你如果不想折腾这些单纯 CPU INT8 已经能覆盖大多数实时场景。6.3 摄像头输入与真实场景落地建议项目里如果直接接摄像头OpenCvSharp 的 VideoCapture 是最快的方式。var capture new VideoCapture(0); capture.Set(VideoCaptureProperties.FrameWidth, 1280); capture.Set(VideoCaptureProperties.FrameHeight, 720); capture.Set(VideoCaptureProperties.Fps, 30); capture.Set(VideoCaptureProperties.Exposure, -6); Mat frame new Mat(); while (capture.IsOpened()) { if (!capture.Read(frame)) break; var lanes detector.Detect(frame); DrawLanes(frame, lanes); Cv2.ImShow(Lane Detection, frame); if (Cv2.WaitKey(1) q) break; }如果你习惯用 AForge 操作摄像头也没有问题。AForge 的 VideoCaptureDevice 能拿到 Bitmap 帧把 Bitmap 转成 OpenCvSharp 的 Mat 之后再进检测管线就行。但 AForge 设置摄像头属性曝光、增益、白平衡的接口比较底层用起来不如 OpenCvSharp 直观。我踩过最大的坑是摄像头自动曝光。默认情况下摄像头从室内开到室外、或者经过桥洞的时候自动曝光会剧烈变化画面亮度突变车道线检测会在一两秒内完全失效。解决方案是关闭自动曝光固定曝光值或者在亮度变化大的场景下做全局直方图均衡化。还有一个小细节OpenCvSharp 在 Windows 下读取中文路径的文件直接Cv2.ImRead(path)会失败返回空 Mat。必须先File.ReadAllBytes再用Cv2.ImDecode这个坑我在导模型测图片时卡了半个小时。6.4 模型转换到嵌入式平台如果你之后打算把同一个模型部署到 Jetson 或者 RK3588 这类嵌入式平台ONNX 模型也可以继续作为中间格式。比如用 onnx2ncnn 转成 ncnn 格式或者用 onnxruntime 的嵌入式版本直接加载。C# 端的后处理思路不需要变但 C 嵌入式端通常不走 C#而是直接把 Python 或 C 后处理移植过去。这个项目跑通之后我最大的感受是以 Cloud 为基础架构很多模型在 .NET 生态里落地时最大的障碍其实不是模型本身而是各种格式转换和调试信息不透明的组合问题。UFLD-v2 这个模型结构简单、输出明确反而是这种场景下最合适的候选人。最后再分享一个经验在你自己的项目里复现这套方案时建议先把模型文件、输入尺寸、输出形状、rowAnchors 数组这四样东西固定下来写进一个配置文件。因为后续你可能会换模型、调分辨率、改阈值这些参数一旦有一处对不上后处理出来的车道线就会乱得莫名其妙。把这四个参数钉死整个链路就稳了一半。本文还有配套的精品资源点击获取