news 2026/10/1 4:28:15

ONNXRuntime部署UFLDv2车道线检测:从PyTorch到CPU推理的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ONNXRuntime部署UFLDv2车道线检测:从PyTorch到CPU推理的完整指南

简介:面向自动驾驶与智能交通领域,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 numpy

GPU版本还需要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.lib

vscode做编辑器和构建前端就够了,编译还是交给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.1

EMA比直接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,只要按上面的做法填好张量,基本不会出大问题。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 4:26:19

DeepSeek Harness开源AI工作台:工程化落地LLM技能编排

1. 这不是又一个“AI玩具”&#xff0c;而是一套能真正跑通需求闭环的工程化工作台你有没有过这样的经历&#xff1a;产品经理甩来一句“做个智能客服&#xff0c;能自动回复用户关于退货政策的问题”&#xff0c;你点头说好&#xff0c;转身打开Hugging Face搜模型、搭API、写…

作者头像 李华
网站建设 2026/10/1 4:25:11

Python生成器与yield实战:从内存优化到数据管道

开门见山说一个我早期踩过的坑&#xff1a;处理一份几GB的服务端日志&#xff0c;我傻乎乎地用了列表推导式把每一行都读进内存&#xff0c;程序瞬间吃掉好几个G内存&#xff0c;同事在旁边看了一眼说“这玩意儿用生成器不就行了”。那时候我只知道生成器是个“节省内存的迭代工…

作者头像 李华
网站建设 2026/10/1 4:24:38

C语言while循环详解:语法、执行流程与常见坑全解析

我先说个真实场景&#xff1a;很多刚学C语言的读者&#xff0c;第一次写"输出1到100"这个作业时&#xff0c;第一反应是把printf复制一百遍&#xff0c;或者写十遍然后改数字。我当时见过最夸张的一份代码&#xff0c;是用宏定义批处理生生拼出了100行printf&#xf…

作者头像 李华
网站建设 2026/10/1 4:24:36

外挂检测原理揭秘:反作弊系统如何识别与封禁违规玩家

打游戏最烦的不是掉线&#xff0c;不是猪队友&#xff0c;而是你在认真对枪的时候&#xff0c;屏幕上突然弹出一句"游戏安全组件运行时发生异常&#xff0c;请关闭不必要的软件"&#xff0c;然后游戏直接把你踢下线。最近好几个朋友都跑来问我这个问题&#xff0c;尤…

作者头像 李华
网站建设 2026/10/1 4:24:21

开源版Jev本地部署全攻略:从Ollama到RAG实战

1. 为什么“本地部署”这件事值得认真对待1.1 从“调用接口”到“把模型搬回家”的转变这两年我身边做开发的朋友&#xff0c;聊天话题从“你调哪个接口”慢慢变成了“你本地跑什么模型”。这个转变不是赶时髦&#xff0c;而是被现实逼出来的。接口调用有它的好处&#xff0c;开…

作者头像 李华
网站建设 2026/10/1 4:24:16

Python字母数字识别实战:从OpenCV预处理到Tesseract与CNN

简介&#xff1a;这是一份面向课程设计场景的字母数字识别工程&#xff0c;基于 Python 3.7 与 TensorFlow 2.1 实现&#xff0c;在 EMNIST 数据集上完成手写英文字母和数字的分类&#xff0c;同时以 ResNet 的简易实现展示卷积网络在图像识别任务中的落地过程。压缩包共 50 个…

作者头像 李华