简介:面向自动驾驶与智能交通领域,ONNXRuntime 部署 Ultra-Fast-Lane-Detection-v2 车道线检测工程给出了 C++ 与 Python 两套完整推理源码,适合有一定深度学习基础、希望快速上手 ONNX 模型落地的开发者;工程围绕 ONNXRuntime 完成模型加载、输入预处理、推理输出与可视化后处理,并提供测试图片对照结果。资源共 23 个文件,包含 18 张 jpg 测试图、2 个 py 脚本、2 个 cpp 源文件和 1 份 md 说明文档,压缩包仅 4.35MB,其中 Python 版便于调试与二次开发,C++ 版更贴近性能敏感场景。通过源码可掌握 ONNXRuntime 会话创建、张量维度转换、批量推理等关键写法,也能借鉴车道线分割结果的解码与叠加绘制思路;项目已有 548 人学习/下载,特别适合做车道线检测工程参考或模型部署实践。
1. ONNXRuntime部署Ultra-Fast-Lane-Detection-v2:这条车道线模型为什么要换运行时
把Ultra-Fast-Lane-Detection-v2从PyTorch搬到ONNXRuntime,是我在车载视觉和工控检测里反复做的一件事。模型本身不复杂,但PyTorch的推理环境太重,现场往往只有一台没有显卡的IPC,这时候ONNXRuntime就成了最稳的中间层。这篇笔记围绕ONNXRuntime部署UFLDv2的Python和C++两条路线,讲清楚模型输出长什么样、导出和编译时哪些参数不能动,以及我自己踩过坑的经验。适合手里已经有.pth权重,正要往服务端或者嵌入式Linux迁移的工程师。
2. 部署前先搞懂UFLDv2的模型输出:行分类、row anchor与ONNX导出
2.1 行分类为什么比语义分割更适合车道线推理
UFLDv2的关键卖点是不做像素级分割。它把图像在纵向上划分成多个row anchor,每个row anchor又横向划分成若干个cell;模型只预测车道线在每个row anchor上落在哪个cell,以及cell内的亚像素偏移。输出因此不是一张分割图,而是两个张量:cls(分类概率)和loc(偏移量)。这个设计和ONNXRuntime很般配,前向过程基本是卷积和矩阵乘,算子类型干净,在CPU上反而比PyTorch的eager模式好跑.
先纠正一个概念:ONNX是模型格式,ONNXRuntime是加载这个格式的推理引擎。你导出的.onnx只是产物,真正在C++或Python里干活的是onnxruntime动态库。很多人把两者混为一谈,后面排查问题时容易绕弯路。
2.2 从PyTorch导出ONNX:opset、动态轴和输入张量名
下面这段以我这个工程为例,你的仓库如果类名不同,把parsingNet换成你自己的模型类。导出前务必确认三个参数:num_lanes、num_row_anchor、num_cols,这三个值必须和训练配置一致,后面解码全靠它们。
import torch from model.model import parsingNet state_dict = torch.load("ufldv2.pth", map_location="cpu") model = parsingNet( pretrained=False, num_lanes=4, # 训练时检测几条车道线 num_row_anchor=72, # 纵向划分多少个候选行 num_cols=200, # 每个行内横向划分多少个cell ) model.load_state_dict(state_dict) model.eval() dummy = torch.randn(1, 3, 320, 800, requires_grad=False) torch.onnx.export( model, dummy, "ufldv2.onnx", input_names=["input"], output_names=["loc", "cls"], opset_version=11, dynamic_axes={"input": {0: "batch"}}, do_constant_folding=True, )opset_version建议固定在11。ONNXRuntime对低opset的兼容性最稳,嵌入式平台上某些老版本动态库不支持opset 17以上的算子;如果后续要做INT8量化,11也最省心。dynamic_axes只打开batch维度,图像宽高不要动,保持固定320x800,C++端内存布局会简单很多,也更不容易触发动态shape的性能陷阱。
导出后在控制台会看到两个输出名:loc和cls。如果顺序反了,后面代码也要跟着调,建议在Python里先跑一次,把输出顺序固定下来。
2.3 用Netron核对输出节点:shape对不上时的排查顺序
ONNXRuntime部署翻车,一半以上是输入输出shape没对上。打开Netron加载ufldv2.onnx,先看输入节点:batch维一般显示为动态,C、H、W分别对应3、320、800。再看输出节点,loc通常包含2个通道对应x偏移和某种辅助量,cls的最后一维等于num_cols。
核对顺序我一般是:输入C H W -> 输出名字和数量 -> cls最后一维是否等于num_cols -> loc通道数是2。任何一个对不上,先回到torch.onnx.export,不要急着改部署代码。不要相信仓库README里的shape,以你实际导出的为准。
3. Python端跑通ONNX推理:从预处理到画线的完整回路
3.1 安装onnxruntime:CPU、GPU和Python版本怎么匹配
Python端最省事,但要分清两个包:onnxruntime和onnxruntime-gpu。CPU环境装前者,NVIDIA显卡环境装后者,两者不能同时存在,否则会出现provider冲突。顺手把opencv-python装上,后面读图和画线都要用。
pip install onnxruntime==1.16.3 pip install opencv-python numpyGPU版本还需要CUDA和cuDNN,版本要和onnxruntime-gpu的构建版本匹配。如果现场机器没有显卡,直接装CPU版即可,UFLDv2这种结构在CPU上虽然不快,但做单路视频流勉强够用。
3.2 预处理加推理:留给ONNXRuntime的必须是连续内存
Python推理代码不长,但有几个细节会影响成败,比如输入张量的内存必须连续、归一化顺序不能错、输入名要从session里读而不是自己猜。
import cv2 import numpy as np import onnxruntime as ort def preprocess(image, target_hw=(320, 800), mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)): img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (target_hw[1], target_hw[0])) img = img.astype(np.float32) / 255.0 img = (img - np.array(mean, dtype=np.float32)) / np.array(std, dtype=np.float32) img = np.transpose(img, (2, 0, 1))[None] # ascontiguousarray保证底层内存连续,session.run不会隐式帮你做 return np.ascontiguousarray(img) session = ort.InferenceSession( "ufldv2.onnx", providers=["CPUExecutionProvider"] ) input_name = session.get_inputs()[0].name output_names = [o.name for o in session.get_outputs()] print("input shape:", session.get_inputs()[0].shape) print("output names:", output_names) frame = cv2.imread("test.jpg") x = preprocess(frame) loc, cls = session.run(output_names, {input_name: x}) print("loc shape:", loc.shape, "cls shape:", cls.shape)理论上可以用session.run(None, ...)让运行时按导出顺序返回所有输出,但我习惯用output_names显式指定。如果你的模型在导出时输出顺序是cls在前、loc在后,这里就会直接反映出来,不需要靠猜。
3.3 后处理还原坐标:cls取最大概率、loc补偏移
解码是部署里最容易写错的一步。我用的换算逻辑是:y方向直接取row_anchor里的值,x方向用cls最大概率对应的cell序号,再加上loc里的偏移,最后除以num_cols乘回原图宽度。row_anchor在大多数仓库里是归一化纵坐标,取值在0到1之间,直接乘original_height。
def decode_lanes(loc, cls, row_anchor, original_size, threshold=0.3): # loc/cls第一个维度都是 batch * num_lanes * num_row_anchor # 以单batch为例,squeeze后按lane和row拆开 loc = np.squeeze(loc) # [num_lanes*num_row_anchor, 2, num_row_anchor] cls = np.squeeze(cls) # [num_lanes*num_row_anchor, num_row_anchor, num_cols] num_row = len(row_anchor) num_lanes = loc.shape[0] // num_row orig_w, orig_h = original_size lanes = [] for lane_idx in range(num_lanes): pts = [] for row_idx in range(num_row): idx = lane_idx * num_row + row_idx prob = cls[idx, row_idx] if cls.ndim == 3 else cls[idx] cell = int(np.argmax(prob)) if prob[cell] < threshold: continue # loc的第二维第一通道是x偏移,这里取row_idx对应值 offset = loc[idx, 0, row_idx] x = (cell + offset) / 200 * orig_w y = row_anchor[row_idx] * orig_h pts.append((x, y)) lanes.append(pts) return lanes这里有一个版本差异:有些仓库导出来的cls是二维[num_lanes*num_row_anchor, num_cols],没有保留row维,此时cls[idx]直接就是概率向量。我代码里用cls.ndim == 3做了分支兼容。row_anchor如果存的是像素坐标,就不要乘orig_h,直接作为y;归一化版本才需要乘,这一点建议以训练时的config为准。
4. C++端集成onnxruntime动态库:从CMake到Ort::Session
4.1 下载onnxruntime动态库并用vscode配置C++环境
C++部署的核心资产是onnxruntime动态库,Windows下是onnxruntime.dll与其对应的onnxruntime.lib,Linux下是libonnxruntime.so。从GitHub Releases下载对应版本后,目录结构保持如下,CMake直接引用。
ufld_demo/ ├── CMakeLists.txt ├── main.cpp └── onnxruntime/ ├── include/onnxruntime_cxx_api.h └── lib/onnxruntime.libvscode做编辑器和构建前端就够了,编译还是交给CMake。先创建一个最小CMake工程,注意C++标准至少17,OpenCV和ONNXRuntime两个依赖缺一不可。
cmake_minimum_required(VERSION 3.16) project(ufld_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) find_package(OpenCV REQUIRED) set(ORT_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/onnxruntime/include) set(ORT_LIB_DIR ${CMAKE_SOURCE_DIR}/onnxruntime/lib) include_directories(${ORT_INCLUDE_DIR}) link_directories(${ORT_LIB_DIR}) add_executable(ufld_demo main.cpp) target_link_libraries(ufld_demo ${OpenCV_LIBS} onnxruntime)Windows下运行前把onnxruntime.dll复制到exe同目录,否则启动就报缺dll。Linux下记得export LD_LIBRARY_PATH指向libonnxruntime.so所在目录,这套东西做不好,后面的坑全是环境问题。
4.2 创建Ort::Session并执行推理
C++代码里先初始化Env和SessionOptions,再加载模型。SessionOptions里我最看重两个设置:图优化级别和线程数。ORT_ENABLE_ALL会做一些算子融合,对CPU推理提升明显;线程数不建议超过物理核数,否则上下文切换反而拖慢速度。
#include <opencv2/opencv.hpp> #include <onnxruntime_cxx_api.h> #include <array> #include <vector> #include <memory> int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "ufld_v2"); Ort::SessionOptions options; options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); options.SetIntraOpNumThreads(6); Ort::Session session(env, "ufldv2.onnx", options); // 固定输入shape,batch=1 std::array<int64_t, 4> input_shape{1, 3, 320, 800}; const char* input_name = "input"; std::vector<const char*> output_names{"loc", "cls"}; cv::Mat img = cv::imread("test.jpg"); cv::Mat rgb; cv::cvtColor(img, rgb, cv::COLOR_BGR2RGB); cv::Mat resized; cv::resize(rgb, resized, cv::Size(800, 320)); // CHW连续内存,注意这里是RGB顺序,不能直接用BGR std::vector<float> input_data(3 * 320 * 800); for (int c = 0; c < 3; ++c) for (int h = 0; h < 320; ++h) for (int w = 0; w < 800; ++w) input_data[c * 320 * 800 + h * 800 + w] = resized.at<cv::Vec3b>(h, w)[c] / 255.0f; auto memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); auto output_tensors = session.Run( Ort::RunOptions{nullptr}, &input_name, &input_tensor, 1, output_names.data(), output_names.size()); const float* loc_data = output_tensors[0].GetTensorMutableData<float>(); const float* cls_data = output_tensors[1].GetTensorMutableData<float>(); // 后续解码和Python端逻辑一致,只是把数组下标换成指针运算 return 0; }注意input_data里存的是RGB顺序,而且我用at<cv::Vec3b>逐个像素读取,虽然慢但很直观。如果要压性能,把resized转成CV_32FC3后用指针连扫,再按通道拆分重排,能省掉一半循环时间。拿不准的时候就照着上面这份直白写法先跑通。
4.3 C++后处理:把输出张量变成车道线坐标
解码时最容易出问题的是指针越界。ONNXRuntime输出的连续缓冲,长度等于shape各维乘积,写循环前先算出total_len。我通常把输出shape打印出来人工核对一次:
auto shape_info = output_tensors[0].GetTensorTypeAndShapeInfo(); auto loc_shape = shape_info.GetShape(); size_t loc_total = 1; for (auto dim : loc_shape) loc_total *= dim;之后按idx = lane_index * num_row_anchor + row_index取数据。C++没有Python那么方便的切片,我建议直接用结构体数组存点。
struct LanePoint { float x; float y; }; std::vector<std::vector<LanePoint>> decode_lanes( const float* loc, const float* cls, const float* row_anchor, int num_row, int num_lanes, int num_cols, float image_w, float image_h, float threshold) { std::vector<std::vector<LanePoint>> lanes(num_lanes); for (int lane = 0; lane < num_lanes; ++lane) { for (int row = 0; row < num_row; ++row) { int idx = lane * num_row + row; const float* prob = cls + idx * num_cols; int cell = 0; float max_prob = 0.0f; for (int j = 0; j < num_cols; ++j) { if (prob[j] > max_prob) { max_prob = prob[j]; cell = j; } } if (max_prob < threshold) continue; float x_offset = loc[idx * 2 * num_row + 0 * num_row + row]; float x = (cell + x_offset) / num_cols * image_w; float y = row_anchor[row] * image_h; lanes[lane].push_back({x, y}); } } return lanes; }路沿高度的点用row_anchor归一化值乘原图高,这个换算在C++和Python里必须完全一致,否则同一张图两边画出来的线不重合。解码完成后可以用cv::line把所有点连起来,这就是最终的可视化结果。
5. 部署避坑:onnxruntime版本、输入顺序与CPU性能的4条血泪经验
5.1 现象:推理输出全黑或全零
模型跑起来了,但画出来的线全是乱的,甚至所有概率都接近0。原因多数是预处理没对齐训练配置。最常见的是忘了除以255,或者mean/std顺序写反。UFLDv2训练时用ImageNet均值方差,你换成自己拍脑袋的数值,输出概率分布就完全漂了。解决:把预处理逐项和训练代码比对,尤其是归一化要放在resize之后,不能先归一化再resize,插值会污染数值。
5.2 现象:C++调用Session时出现access violation c0000005
C++版最常见的崩溃点是给Session.Run传了已经释放的内存,或者input/output name是std::string的临时对象。Ort::Run的name参数要求const char*,如果你把临时字符串的.c_str()传进去,函数返回后name已经失效。解决:把input_name和output_names定义为const char*数组,生命周期覆盖整个调用;如果你把逻辑封装成DLL再给C#调用,遇到c0000005还要额外检查C#侧delegate的调用约定,裸指针传给C++后别在托管层提前GC。
5.3 现象:CPU推理只有2 FPS,单帧要300ms
先看有没有把线程数设满。SessionOptions里SetIntraOpNumThreads不设置的话,默认行为在不同版本里不一致,可能出现单线程跑满但其他核空闲。另外图优化级别要打开,ORT_ENABLE_ALL能吃到算子融合红利。如果这两项都调了还慢,检查是不是动态shape导致运行时反复做内存重排,把宽高固定成训练尺寸,不要因为现场图像大小不同就resize成别的分辨率,宁可多一次resize开销。
5.4 现象:PyTorch精度正常,ONNX输出却有偏移
这个问题最玄学,看起来是“模型被转换坏了”,实际上多数是导出时batch维没有固定。opset 11下如果你把dynamic_axes写得太宽,例如把宽高也设成动态,ONNXRuntime内部会走较保守的路径,某些算子融合被禁用,结果就是数值对不上。解决:再导一次,dynamic_axes只保留batch维;如果还不行,用同一张图分别跑PyTorch和ONNXRuntime,把cls最大概率的下标打印出来对比,偏移量通常是1,说明cell序号错位,不是模型坏了。
5.5 现象:CUDA provider加载失败
装的是onnxruntime-gpu,但推理还是落在CPU,或者直接报failed to load CUDA。原因几乎都是CUDA/cuDNN版本和onnxruntime构建时用的版本不匹配。解决:去官网查清楚你下载的onnxruntime-gpu版本对应的CUDA主版本和cuDNN小版本。如果现场机器不能换驱动,就用CPU版并降低输入分辨率,UFLDv2对分辨率敏感,320x800降到288x640能换来接近一倍的帧率提升。
6. 把部署做实:从单张图片到视频流的稳定性验证
6.1 用视频流找性能上限
单张图片测速毫无意义,视频流才能暴露真实瓶颈。我用一个EMA滑动平均统计帧率,避免瞬时抖动干扰判断。Python里直接对读取视频帧做完整预处理、推理、后处理,统计每秒帧数。
fps_ema = 0.0 while cap.isOpened(): ret, frame = cap.read() if not ret: break t0 = time.perf_counter() # preprocess + session.run + decode x = preprocess(frame) loc, cls = session.run(output_names, {input_name: x}) lanes = decode_lanes(loc, cls, row_anchor, (frame.shape[1], frame.shape[0])) dt = time.perf_counter() - t0 fps_ema = fps_ema * 0.9 + (1.0 / dt) * 0.1EMA比直接1/dt稳,不会因为某帧调度毛刺把数字拉得忽高忽低。如果fps_ema在长时间运行里持续下降,往下查内存增长,多半是解码后vector没有清干净,或者Python侧session.run内部缓存在膨胀。
6.2 多线程与Session复用:哪些可以共享,哪些必须隔离
ONNXRuntime的Session是线程安全的,同一个Session可以同时被多个线程调用Run,这是我最常用的服务端部署方式。但输入tensor绝不能共享,每个线程必须创建自己的Ort::Value或Python侧numpy数组。另外不要每次请求都重新创建Session,加载模型和分配内存是重活,一个进程一个Session,按输入分辨率固定住,并发再大也只是排队问题。C++里IoRunOptions可以传空指针,不用自己构造。
6.3 用INT8量化给CPU部署留余量
如果CPU推理速度卡在需求边缘,可以考虑对ONNX模型做动态量化。onnxruntime的量化API支持QInt8动态量化,UFLDv2的cls和loc输出对量化不像检测回归框那么敏感,一般能压掉大部分体积,速度提升20%到40%。
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic("ufldv2.onnx", "ufldv2_int8.onnx", weight_type=QuantType.QInt8)注意量化后要重新跑一遍同一组测试图,对比每种车道线的置信度变化。row anchor概率边缘场景容易出现断续,阈值建议从0.3提高到0.35再测一轮。量化不是白嫖,实测过数据才敢上生产。
我自己的习惯是:先固定输入尺寸和线程数,再打开图优化,最后才考虑量化。每一步都留一个可回退的切换开关,哪天线上翻车,还能快速切回上一版配置。ONNXRuntime部署UFLDv2这件事,百分之六十的时间花在两头,导出时把输入输出查清楚,后处理时把坐标换算写对;中间那段session.run,只要按上面的做法填好张量,基本不会出大问题。希望帮到你。
本文还有配套的精品资源,点击获取