ARTICLE DETAIL

资讯详情

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

ONNXRuntime部署UFLD-v2车道线检测:从Python到C++实战指南

ONNXRuntime部署UFLD-v2车道线检测:从Python到C++实战指南 简介基于ONNXRuntime部署Ultra-Fast-Lane-Detection-v2车道线检测的完整工程包面向自动驾驶、智能交通方向的开发者以及需要落地推理部署的深度学习工程师。压缩包共23个文件以18张示例图、C与Python源码各2个、1份说明文档为主整体仅4.35MB图片用于运行效果可视化C版本适合低延迟性能场景Python版本便于快速调试说明文档辅助环境配置与流程梳理。已有548人学习下载。资源内含ONNX模型、双语言推理代码与图像运行样例可直接在CPU或GPU环境复现对比两套实现能系统掌握模型转换、会话创建、输入预处理、车道线后处理等关键环节为在真实道路场景中集成车道线识别功能提供可复用工程参考。1. ONNXRuntime 部署 UFLD-v2 车道线这套源码包能干什么车道线检测做到“CPU 实时”是有捷径的Ultra-Fast-Lane-Detection-v2 就是这条路上绕不开的模型它不搞逐像素分割而是把车道线检测转成行方向分类省掉一大半计算量。手头这套资源就是围绕它来的包含 C 和 Python 两套 ONNXRuntime 部署源码、训练好的 onnx 模型、测试图和一份 README解压后能直接跑出车道线叠加结果。适合正在做 ADAS 预警、L2 车道保持、智能车竞赛的开发者也适合想搞懂“分类式车道线后处理”的人。与其自己从 PyTorch 导模型再裸写推理不如直接在这个工程上改输入输出。2. UFLD-v2 模型结构与 ONNX 部署原理先弄懂 hybrid anchor 再跑推理2.1 Hybrid Anchor 是什么为什么 UFLD-v2 不用语义分割第一次跑 UFLD 系列的部署代码很多人会困惑为什么模型输出不是一张分割图而是一个四维张量。这是因为 UFLD-v2 走的是“行分类 anchor 分类”路线。每个预定义的行位置上车道线只会落在一个水平位置或者干脆不存在所以模型要预测的就是“这一行上哪一列是车道线”本质是一个分类任务。UFLD-v2 在 v1 基础上引入了 hybrid anchor也就是局部 anchor 和全局 anchor 配合。局部 anchor 密集覆盖车身近处弯道和坡度变化大的地方能表达得更细全局 anchor 用来兜住远处接近消失点的大范围区域避免只看近处导致远处车道线稀疏。这个设计直接影响了后面部署时的输出张量形状所以后处理代码里那些 reshape 和 argmax 不是玄学每一维都有实际含义。对做部署的人来说知道这条原理有个实际好处当输出维度和你手里的模型不一致时你能判断是 anchor 数量设置变了而不是代码抄错。UFLD-v2 的输出一般不是[1, 4, 1001, 201]就是[1, 4, 201, 1001]差的就是维度顺序后面讲避坑时会专门提这一点。2.2 为什么选 ONNXRuntime一份模型三端复用这个资源包用 ONNXRuntime 而不直接调 PyTorch是因为 ONNXRuntime 对“部署”这件事的边界定义更清楚模型是 PyTorch 导出的 onnx训练框架和推理框架解耦C 和 Python 共用同一个模型文件不需要在两边维护两套权重。ONNXRuntime 的核心优化都在 SessionOptions 和 ExecutionProvider 这两个概念里。SessionOptions 里的图优化级别能自动做算子融合比如把 Conv BatchNorm ReLU 合并成一个节点intra_op_num_threads 控制单算子内部线程数对 CPU 推理影响很大。ExecutionProvider 则决定了模型跑在 CPU 还是 GPU常见配置是CPUExecutionProvider和CUDAExecutionProvider代码里可以在 providers 参数里同时传多个运行时按顺序选。Python 部署用的是onnxruntime.InferenceSessionC 部署用的是Ort::Session两个 API 的加载逻辑完全对应只是内存管理方式不同。这正是这套源码包的价值先读 Python 版本把流程跑通再对照 C 版本解决性能和内存问题两套代码放一起看部署的通用套路基本就摸清了。2.3 从 PyTorch 到 ONNX固定输入尺寸与输入输出名资源包里的模型已经是 onnx 格式正常情况下你不需要重新导出但万一你想换 backbone 或者重训模型导出这一步还是得会。常见做法是用torch.onnx.export注意输入尺寸要固定因为 UFLD-v2 的 anchor 数量是跟着输入分辨率走的动态尺寸导出会让后处理代码没法写死形状。import torch from model import Model # 以实际训练工程为准 model Model(backboneresnet18, num_lanes4) model.load_state_dict(torch.load(ufldv2.pth, map_locationcpu)) model.eval() dummy_input torch.randn(1, 3, 288, 800) torch.onnx.export( model, dummy_input, model.onnx, input_names[input], output_names[output], opset_version17, dynamic_axesNone ) print(export done)这里dynamic_axesNone是刻意的推理时输入必须是[1, 3, 288, 800]这样可以避开动态 shape 带来的一切麻烦。opset_version 建议用 17 左右太老的 opset 有些算子不支持图优化太新的又可能依赖新版本 onnxruntime。导出完先跑一次onnx.checker.check_model再用onnxruntime做一次推理对比确认输出数值和 PyTorch 基本一致再部署。这套资源包自带的模型输入输出规格一般是这样的实际以文件打印为准名称Shape说明input[1, 3, 288, 800]RGB 图归一化到 [-1, 1]output[1, 4, 1001, 201]4 个通道的 anchor logits预处理resize 归一化(x / 255 - 0.5) / 0.5拿到模型后第一件事是打印session.get_inputs()和session.get_outputs()确认输入输出名是input/output。很多网上下载的模型导出时名字不一样后面 C 代码里直接用字符串找 tensor 就会找不到这一点在避坑章节还会展开。3. Python 端部署主流程InferenceSession、预处理与后处理全套代码3.1 环境准备与文件构成先看清资源包再动手解压资源包后目录结构大致是这样的main.py、main.cpp、images/、onnxruntime/、README.md模型文件也在包内。images目录放的是测试图片onnxruntime目录里是动态库README.md里会写明模型文件名和依赖版本先读它再跑代码能省掉一半排错时间。Python 端环境只需要装三个库建议直接用 pip 安装版本差距不会太大pip install numpy opencv-python onnxruntime装完可以顺手验证一下 onnxruntime 是否正常工作。CPU 版本默认就有GPU 版本需要额外装 CUDA 相关依赖但先不要急着上 GPUUFLD-v2 在 CPU 上单帧延迟已经能到几十毫秒量级先把 CPU 流程跑通再考虑加速。我一般会用onnxruntime.get_available_providers()看一眼当前支持哪些 provider确认CPUExecutionProvider在列表里。如果是在 Windows 上注意 Python 最好是 3.8 到 3.11 之间太新的版本有些预编译包可能没有对应 wheel。3.2 Python 推理主流程预处理、InferenceSession、Run预处理是部署里最容易错的一环核心要求是“和训练时保持一致”。UFLD-v2 训练用的图像是 RGB 顺序尺寸是 800×288先做(x / 255 - 0.5) / 0.5归一化然后从 HWC 转成 CHW。OpenCV 读出来是 BGR所以第一步必须转颜色空间。import cv2 import numpy as np import onnxruntime as ort def preprocess(image_path, width800, height288): img cv2.imread(image_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (width, height), interpolationcv2.INTER_LINEAR) img img.astype(np.float32) / 255.0 img (img - 0.5) / 0.5 img img.transpose(2, 0, 1)[np.newaxis, ...] return np.ascontiguousarray(img, dtypenp.float32) sess_options ort.SessionOptions() sess_options.intra_op_num_threads 4 sess_options.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED session ort.InferenceSession( model.onnx, sess_options, providers[CPUExecutionProvider] ) input_name session.get_inputs()[0].name output_name session.get_outputs()[0].name print(input:, input_name, session.get_inputs()[0].shape) print(output:, output_name, session.get_outputs()[0].shape) x preprocess(images/test.jpg) outs session.run([output_name], {input_name: x}) print(output shape:, outs[0].shape)这段代码里intra_op_num_threads设为 4 是个折中线程数并不是越多越快小模型线程开多了反而浪费在调度上。ORT_ENABLE_EXTENDED是比基本优化更激进的图优化级别把能融合的算子尽量融合。get_inputs()[0].name这种方式比直接写死字符串更稳因为模型输入名可能不是以为的input只有打印出来才确定。后面几行代码就是在验证模型是否能正常加载、输入输出 shape 是否符合预期这一步是后面所有调试的地基。3.3 后处理与可视化把输出转成车道关键点模型输出的四维张量不是直接能画的车道线要解码成一组关键点。这里的解码逻辑和 2.1 讲的 hybrid anchor 对应输出的某个通道代表局部 anchor 的分类 logits对它做 softmax 后逐行取最大类别就能得到“这一行上车道线在哪一列”。下面这份是简化版后处理目的是让你先看到图上有线画出来资源和官方完全对齐的解码以main.py里的post_process为准。import numpy as np from scipy.special import softmax def decode_lanes(output, img_size(800, 288), thresh0.4): # output: [1, 4, 1001, 201]先压掉 batch 维 pred output[0] local_logits pred[0] # [1001, 201] row_prob softmax(local_logits, axis-1) col_idx np.argmax(row_prob, axis-1) # 每行最可能的列位置 row_conf np.max(row_prob, axis-1) points [] for r in range(row_conf.shape[0]): if row_conf[r] thresh: continue x int(col_idx[r] / 201.0 * img_size[0]) y int(r / 1000.0 * img_size[1]) points.append((x, y)) return points pts decode_lanes(outs[0]) for p in pts: cv2.circle(img_show, p, 2, (0, 0, 255), -1)这段解码里axis-1表示沿着最后一维做 softmax也就是每个行位置上对 201 个列候选做分类。thresh用来过滤置信度太低的行位置这部分在近处通常很稳定远处接近消失点的区域置信度低过滤掉反而干净。坐标映射要注意col_idx / 201 * 800才是原图横坐标因为模型输出里的 201 对应的是列方向的网格数不是像素坐标。如果你把这张图画出来发现点全堆在图像一侧大概率是维度顺序反了也就是模型输出其实是[1, 4, 201, 1001]这时把 softmax 的 axis 改成 0坐标映射反过来算就行。这个判断方法比反复猜代码更快也是排查后处理问题的通用思路。4. C 端部署主流程Ort::Session 的内存管理与 OpenCV 交互4.1 C 环境onnxruntime 动态库与 OpenCV 版本选择C 版本比 Python 多出来的工作量集中在“动态库怎么链”和“内存怎么管”这两件事上。资源包里onnxruntime/目录放的是 onnxruntime 的动态库和头文件如果你用的是 Windows需要确认里面有onnxruntime.dll和onnxruntime_cxx_api.h如果是 Linux则是libonnxruntime.so。OpenCV 版本建议用 4.x4.5 以上都行主要用到的是cv::dnn::blobFromImage和基础的图像读写。编译工具链上Windows 可以用 Visual Studio 2022也可以用 VS Code 配 CMakeLinux 上直接用 gcc 加 CMake。这里的关键是让 CMake 同时找到 OpenCV 和 onnxruntime 的头文件目录与库目录常见做法是在 CMakeLists 里手动指定路径。cmake_minimum_required(VERSION 3.16) project(ufldv2_onnx) find_package(OpenCV REQUIRED) set(ONNXRUNTIME_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/onnxruntime/include) set(ONNXRUNTIME_LIB_DIR ${CMAKE_SOURCE_DIR}/onnxruntime/lib) include_directories(${ONNXRUNTIME_INCLUDE_DIR} ${OpenCV_INCLUDE_DIRS}) link_directories(${ONNXRUNTIME_LIB_DIR}) add_executable(main main.cpp) target_link_libraries(main ${OpenCV_LIBS} onnxruntime)link_directories在这里是必需的因为 onnxruntime 不是通过 find_package 找到的只能手动指定库目录。Windows 上编译成功后运行前记得把onnxruntime.dll复制到可执行文件同目录或者把库目录加进系统 PATH否则会报找不到动态库。还有一点容易踩如果编译是 x64但系统装的是 x86 的 Visual C Redistributable运行时会报 0xc000007b 这类错误后面对应专门讲。4.2 Ort::Session 主流程输入输出的内存管理C 的Ort::Session使用方式跟 Python 的InferenceSession很像差别在于所有 tensor 都要自己分配内存并用Ort::Value包装。一个很容易翻车的点是GetTensorMutableData拿到的是裸指针OpenCV 的 Mat 数据指针如果生命周期结束这块内存就失效了所以要么把数据拷贝出来要么保证 Mat 在推理期间一直存活。#include onnxruntime_cxx_api.h #include opencv2/opencv.hpp #include vector int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, ufldv2-lane); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel(GRAPH_OPTIMIZATION_LEVEL_ENABLE_EXTENDED); const wchar_t* model_path Lmodel.onnx; Ort::Session session(env, model_path, session_options); // 打印模型输入输出信息 Ort::AllocatedStringPtr input_name session.GetInputNameAllocated(0, Ort::AllocatorWithDefaultOptions()); Ort::AllocatedStringPtr output_name session.GetOutputNameAllocated(0, Ort::AllocatorWithDefaultOptions()); printf(input: %s\n, input_name.get()); printf(output: %s\n, output_name.get()); std::vectorint64_t input_shape{1, 3, 288, 800}; size_t input_size 1 * 3 * 288 * 800; std::vectorfloat input_data(input_size); // 这里把预处理后的数据填入 input_data Ort::MemoryInfo memory_info Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat(memory_info, input_data.data(), input_size, input_shape.data(), input_shape.size()); std::vectorconst char* input_names{input_name.get()}; std::vectorconst char* output_names{output_name.get()}; auto output_tensors session.Run(Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names.data(), output_names.size()); float* output_data output_tensors[0].GetTensorMutableDatafloat(); // output 形状从 output_tensors[0].GetTensorTypeAndShapeInfo() 读取 return 0; }这段代码里CreateTensor用的是input_data.data()所以input_data不能在session.Run之前被销毁这一点是 C 部署最常见的崩溃来源之一。Ort::AllocatedStringPtr是 onnxruntime 1.13 之后引入的智能指针类型老的教程里直接返回char*的写法在新版本已经废弃。session.Run返回的是一个std::vectorOrt::Value这里只取第一个输出如果你的模型有多个输出记得用下标访问后再转成float*。4.3 blobFromImage 预处理与坐标换算和 Python 保持一致C 端的预处理推荐绕开cv::dnn::blobFromImage因为它的默认参数很容易让人算错归一化。blobFromImage内部做的是(src * scale - mean)没有除以标准差这一步所以如果你直接写scale1.0/255, mean0.5得出的结果是x/255 - 0.5少了一个除以 0.5模型输入分布就对不上检测结果会明显变差。更好的做法是手动完成归一化和 HWC 到 CHW 的转换每一步都和 Python 版本对齐cv::Mat img cv::imread(images/test.jpg); cv::cvtColor(img, img, cv::COLOR_BGR2RGB); cv::resize(img, img, cv::Size(800, 288), 0, 0, cv::INTER_LINEAR); std::vectorcv::Mat channels(3); cv::split(img, channels); for (int i 0; i 3; i) { channels[i].convertTo(channels[i], CV_32FC1, 2.0 / 255.0, -1.0); } cv::merge(channels, img); // 现在是 HWC 的 float 图 float* input_data new float[3 * 288 * 800]; int idx 0; for (int c 0; c 3; c) { for (int h 0; h 288; h) { for (int w 0; w 800; w) { input_data[idx] img.atcv::Vec3f(h, w)[c]; } } }这里convertTo的scale2.0/255.0和shift-1.0组合起来等价于(x / 255.0 - 0.5) / 0.5因为2*x/255 - 1 2 * (x/255 - 0.5)。手动三重循环转 CHW 虽然慢但保证和 Python 的transpose(2,0,1)完全一致。如果你追求速度可以把convertTo换成预处理一次性完成但第一次跑通阶段不建议优化这个先把结果对齐再说。C 版本的可视化输出用cv::circle画关键点或者把点集合用cv::polylines连成线。坐标换算和 Python 一样注意输出网格数到原图像素坐标的比例关系即可。如果你发现 C 画的点和 Python 画的位置有偏差先检查 resize 的插值方式是不是都是INTER_LINEAR再检查颜色通道顺序通常问题出在这两处。5. 部署避坑手册五个最容易翻车的位置与排查办法5.1 检测结果错位严重车道线满图乱飘现象是跑了代码图画出来了但点全堆在图像的某个角落或者线和真实车道完全对不上看起来像随机噪声。原因基本逃不出两个一是预处理归一化写错了比如只做了x/255-0.5忘了除以 0.5模型输入的数值范围从[-1, 1]变成了[-0.5, 0.5]分布一偏输出概率图全是乱的二是 RGB/BGR 顺序反了OpenCV 读图默认 BGR模型训练时用的是 RGB不转颜色空间车道线特征全被交换了通道。判断方法很简单把预处理后的图像保存一份用numpy的mean和std看一下数值范围如果不是均值接近 0、方差接近 1归一化就有问题。解决方式是严格复刻训练代码里的预处理不要自创参数。UFLD-v2 的官方预处理就是(x / 255 - 0.5) / 0.5RGB 顺序resize 到 800×288。先把这一步做成一个独立函数后续所有语言版本都调用同一逻辑就能从源头避免错位。5.2 C 运行即崩Access Violation C0000005现象是 C 程序编译通过但运行到推理那一步直接报Access violation reading location有时候是 0xC0000005有时候是0x00000000弹窗后程序退出。原因大概率不是模型问题而是内存生命周期管理失误。Ort::Value::CreateTensor是包装外部传入的数据指针如果这个指针指向的内存已经被释放session.Run在内部读取时就会触发非法访问。常见场景是把input_data放在一个局部作用域里函数结束就销毁了但input_tensor还持着这个地址。解决方式是让输入数据的生命周期覆盖整个推理阶段最简单粗暴的办法是把input_data声明在main函数里或者包成一个类成员。另外输出 tensor 拿到的float*指针所有权属于Ort::Value不要在output_tensors析构后继续使用需要拷贝到自己的容器里再处理后处理。5.3 找不到 onnxruntime.dll 或报 0xc000007b现象是 Windows 上编译成功后运行 exe弹出“找不到 onnxruntime.dll”或者错误码 0xc000007b程序无法启动。原因有两个层面一是动态库搜索路径不包含 onnxruntime 所在目录Windows 默认只搜 exe 同目录、系统目录和 PATH二是缺少 Visual C Redistributableonnxruntime 动态库本身依赖它目标机器没装这个运行库加载 dll 时架构或依赖校验失败就报 0xc000007b。解决方式是把onnxruntime.dll复制到 exe 所在的输出目录然后在目标机器上安装对应架构的vc_redist.x64.exe。另有一个容易忽略的细节如果你的 OpenCV 是动态链接的opencv_world4xxx.dll也要一起带上否则换一台电脑运行还会翻车。建议把onnxruntime.dll、OpenCV 的 dll 都放在 exe 同目录下省得配 PATH。5.4 C 结果和 Python 结果不一致现象是同一张图Python 版本检测结果正常C 版本画出来的车道线有偏移或者远处点明显偏少但代码逻辑看起来一模一样。原因多数出在预处理细节上。最常见的有两个一是cv::resize的插值算法不一致Python 端如果没指定interpolation默认是INTER_LINEAR而 C 端用了别的插值两边的像素值就略有差异二是使用cv::dnn::blobFromImage时对输入图像的处理顺序和 Python 手写的transpose不同blobFromImage参数里 swapRB、mean、scale 的组合容易计算错误。解决方式是统一走 4.3 的手动预处理流程不要依赖blobFromImage的默认行为。然后两边都保存预处理后的图像做对比像素级差异应该在个位数以内如果差很多就逐行检查 scale 和 shift 参数。这个排查方法也适用于把 Python 代码翻译成其他语言时。5.5 模型输出 shape 和代码对不上现象是打印出来的输出 shape 是[1, 4, 201, 1001]但代码里按[1, 4, 1001, 201]写结果 softmax 的维度全错画出来的点完全不对。原因是你手里的 onnx 模型可能不是同一版本导出的。UFLD-v2 的 anchor 数量随输入分辨率变化不同的导出脚本、不同的网络配置输出的维度顺序也可能不同。很多教程里的代码都是针对特定模型写的换个模型直接跑必然对不上。解决方式是每次拿到新模型都先打印输入输出信息。Python 用session.get_outputs()[0].shapeC 用output_tensors[0].GetTensorTypeAndShapeInfo().GetShape()把 shape 打印出来再调整代码里的常量。这个习惯应该固化成部署流程的第一步不要默认任何模型和教程里的一样。6. 跑通之后三件事让检测更稳更快6.1 单帧测速与线程数调优跑通基础流程后第一件事是测单帧延迟确认你的硬件跑这个模型到底什么水平。测速时要注意 warmup 和循环次数单帧时间受 CPU 降频、缓存预热影响很大先跑 10 次预热再正式计时取 100 次的平均耗时才可信。auto start std::chrono::high_resolution_clock::now(); auto output_tensors session.Run(...); auto end std::chrono::high_resolution_clock::now(); double ms std::chrono::durationdouble, std::milli(end - start).count();线程数可以从 1 开始逐级往上试找到你机器上的拐点。UFLD-v2 这种轻量模型线程超过 4 个后性能提升非常有限有时候反而下降。SetIntraOpNumThreads设置的只是单个算子内部的并行度如果你跑的是多路视频流建议把线程数调低一点避免线程竞争这样整体吞吐更高。6.2 摄像头实时检测与车道线时序平滑把单张图片换成摄像头视频流核心改动是把推理放进一个循环里每一帧都走一次预处理、推理、后处理。这里有一个参数层面的建议视频分辨率通常不是 800×288每帧都做全图 resize 没问题但注意保持宽高比不要为了省时间直接用cv::resize的最近邻插值车道线对边缘敏感最近邻会产生明显锯齿。时序平滑是一个容易被忽略的细节。单帧检测在远处会抖动因为远处车道线越靠近消失点行分类的置信度越低前后帧的 argmax 结果可能跳到相邻列。常见做法是做一个指数移动平均lane_smooth alpha * lane_current (1 - alpha) * lane_prevalpha取 0.6 到 0.7 之间。注意在弯道比较大的时候平滑系数太大反而会让检测结果滞后这个参数需要实测调整。6.3 坐标映射与可视化进阶最后聊一个可视化技巧。模型输出的行列索引映射到原图坐标时很多人直接乘比例但如果你是在摄像头画面里叠加车道线原图可能经过裁剪不是直接 resize 的关系。正确的做法是先记录从原图到模型输入的变换参数比如缩放比例和偏移量然后后处理出来的点做一次逆变换再画到原图上。资源包里的测试图是直接 resize 的所以直接乘比例没问题但换成视频流或任意分辨率时一定要补上这一步。我在第一次部署 UFLD-v2 时也犯过 5.4 里的错C 和 Python 结果对不上排查了两天才发现是 blobFromImage 的 mean 算错了。从那以后我每次拿到模型都强制走一遍“打印输入输出 shape、对比预处理中间结果、核对归一化参数”这三步之后再没被这类问题卡住。希望这些经验能帮你省下同样的弯路祝你一次跑通。本文还有配套的精品资源点击获取
返回列表