
前阵子我负责一个低速园区接驳车的视觉感知模块任务是把车道线检测完整跑通“训练—导出—部署”这条链路。模型在训练集上的 mIoU 已经爬到了 0.85 以上当时我以为剩下工作就是导出再转换结果 TensorRT 跑出来的第一帧画面直接让我懵了图像上方的车道线完全断裂远处背景被错误标记成车道而且整帧输出看起来像被“毛刺”覆盖了一样。后来排查下来问题根本不在训练而在导出时的算子兼容性和部署时的精度配置。那次之后我才真正意识到所谓的“从训练到 TensorRT 部署闭环”难点通常不是模型本身而是中间那条没人系统讲的工程链路。这篇内容就是我复盘整个落地过程后整理出来的实战记录覆盖模型选型、训练细节、ONNX 导出、TensorRT 构建、后处理联调以及实车场景里那些最容易翻车的坑。如果你也在做车道线检测相关的部署项目或者正准备把 PyTorch 模型搬到 TensorRT 上跑推理这篇文章应该能帮你少走不少弯路。1. 训练到部署最耗时间的其实是中间那段路很多做算法的人天然会把精力放在训练精度上我也是这样。但实际走完一遍闭环之后我统计了一下整个项目的时间分配模型训练和调参大概占了三成剩下七成都花在导出、转换、精度对齐和板端排查上。训练环境里模型表现很好不代表部署后表现好PyTorch 里的 Tensor 行为和 TensorRT engine 里的行为中间隔着 ONNX 这个中间表示每一步都有自己的脾气。1.1 为什么模型在训练环境里表现不错上了推理引擎就崩这个问题的本质是训练和推理在数学行为上并不完全一致。训练时模型用的是浮点前向传播反传时梯度更新可以让参数不断修正部署时 TensorRT 会做算子融合、精度折叠和内存复用尤其是打开 FP16 后卷积、归一化、激活函数的计算精度都会降下来。精度降低本身不一定导致效果崩但如果你模型里的某些层对数值特别敏感——比如 batch normalization 在推理时被折叠成一条线性变换再叠加均匀分布的量化误差就可能在输出概率图上形成系统性偏差。我在项目里第一次踩到这个问题就是把训练好的分割模型导出 ONNX 后直接用 trtexec 默认 FP16 构建 engine。训练时我用的输入分辨率是 960x480模型里带了几个上采样层。FP16 下跑出来的 logits 分布和 PyTorch 的 fp32 输出差异很大尤其是概率接近 0.5 的边界像素部署后不是被推到 0 就是被推到 1语义分割结果看起来就“缺胳膊少腿”。后来我对比了同一张图的十六进制输出才发现从第二个上采样层开始FP16 的误差就开始累积。这个问题不是个例。如果你训练时用的是混合精度某些层的权重本身已经是 FP16 表示部署时再用 FP16 转换误差可能叠加如果你训练时全程 FP32部署时直接砍成 FP16也可能因为动态范围或舍入方式不同而翻车。所以千万不要默认“训练能跑部署就一定行”。1.2 项目需求梳理多大算力、多低延迟、多少路输入动手之前先把部署目标搞清楚。车道线检测只是自动驾驶感知链路里的一个环节在低速园区接驳车这种场景下一般会和其他视觉任务共享同一个推理设备所以单模型不能把算力吃光。我这次的目标设备是一块消费级 GPU大概 8GB 显存算力放在今天只能算中端水平。需求是单模型单路视频流端到端延迟控制在 30ms 以内也就是模型推理加后处理总共不能超过三帧的时间。输入分辨率不能无脑选大960x480 是我反复权衡后的结果。为什么不是 1280x720因为对车道线检测来说720p 多出来的主要是垂直方向的信息而车道线最需要的是水平方向的视野和远端细节。960x480 在 40 米距离内已经能提供足够的分辨率同时把计算量压到 720p 的一半左右。这里还要考虑一个容易忽略的问题后处理会不会上 CPU。如果你把分割输出从 GPU 拷回 CPU再做聚类、拟合那 10ms 的传输和同步时间就没了。所以在项目需求阶段就应该确定“后处理尽量留在 GPU 上或者至少是可以通过 CUDA 加速的算子”。我最后采用的方式是神经网络输出直接留在显存里用一个小 CUDA kernel 做行方向 softmax 和峰值提取再把几点坐标传回 CPU 做曲线拟合这样传输的数据量从整张概率图降到了几十个点延迟基本可以忽略。2. 训练阶段真正要锁死的东西数据、损失、输出表达模型选型我不展开讲核心就一句对车道线这种结构强、类别单一的目标轻量级分割模型加行锚点分类头比直接套通用语义分割模型要划算得多。我的方案是一个小型编码器加一个双向特征融合模块输出两支一支是逐像素的车道线概率图另一支是固定行锚点上的分类 logits。这样做不是为了刷榜而是为了让部署阶段的 TensorRT 转换和后处理都更可控。2.1 数据公开数据集预训练 自采数据微调数据这块我的建议是“先公共后自采”。先用公开的车道线数据集做预训练让模型学到基础的车道结构、光照变化和路面纹理再用自己采集的园区道路数据做微调。公开数据集和自采数据之间的比例我踩出来的经验是 31 到 41 比较稳自采数据太少模型会把公开数据集的 bias 直接带过来自采数据太多基础特征又容易被带偏。自采数据要注意一个细节车道线不是每帧都清晰。车辆压线、逆光、树影遮挡、水泥路裂缝这些在训练集里看起来是干扰项但恰恰是部署后决定成败的样本。所以我做微调数据的时候故意把“模糊”“残缺”“被遮挡”的帧多保留了一些而不是只挑质量最好的画面。后来在实车测试里这个决定帮了大忙模型在恶劣条件下的鲁棒性很大程度上来自这部分脏数据。标注格式也得统一。我的模型输出是二值概率图加行锚点分类所以标注要能同时生成像素级 mask 和离散的行点标签。这个转换在数据预处理脚本里完成不要在训练过程中实时算否则每一步都可能成为 pipeline 的瓶颈。而且标注的坐标系必须和输入图像严格对齐尤其是经过裁剪或 resize 后label 和 image 的对应关系一旦错位模型怎么调都救不回来。2.2 输出表达的选择二值分割 行锚点分类为什么不用纯分割因为纯分割后处理很麻烦。一个像素级 mask 上可能有多条车道线粘连你还得做连通域分析、实例切分才能算出每条车道的参数。而我在这个项目里并不需要区分“第几根车道线”的实例 ID我只需要把左右车道线和本车道的边界找出来。所以用二值概率图表示“是不是车道线”配合行锚点上每个锚点属于“车道线还是背景”的分类反而更好处理。这个输出表达还有一个好处行锚点分类天然适合用小分辨率特征图来做。我在特征图上每隔 8 个像素设一个锚点每条车道线只需要预测 60 个左右的位置点数量小后续拟合也很轻量。到 TensorRT 部署阶段这个输出比纯分割 mask 更友好因为不需要额外的实例分割头也不存在 embedding 匹配这类复杂算子。2.3 损失函数与训练策略损失函数我用了两项分割分支用 BCE Dice 组合行锚点分支用 CrossEntropy Loss。BCE 负责把前景召回率拉上去Dice 负责缓解“车道线像素只占图像 5% 以下”的样本不平衡问题CrossEntropy 则直接把行方向上的位置误差转化为分类问题让模型在每一个水平条带上找到最可能的车道位置。训练策略上有两个值得提的点。第一我关闭了前 10 个 epoch 的自动混合精度等 loss 稳定下降后再打开 AMP。这样做的原因是早期梯度波动比较大混合精度下可能出现 inf 或 nan一旦出现整个训练基本报废。第二我把输入图像的亮度扰动范围调得比常规大很多车道线检测对光照极其敏感阴影边界和车道线边缘在视觉上很难区分模型需要充分见过各种亮度形态。2.4 训练阶段的评价指标训练时不能只看 mIoU还要额外关注“有效车道线连续性”。mIoU 对车道线检测来说太宏观了一条车道线中间断了 15 米mIoU 可能只下降零点几个点但对下游控制模块是致命的。我在训练过程中增加了一个自定义指标把预测概率图按行取 argmax还原成一条曲线然后和真值曲线计算平均横向偏移。这个偏移控制在 0.2 米以内才说明模型输出的几何信息足够准。顺便说一句这个指标的计算方式最好和部署后处理保持一致否则训练时评价的东西和推理时用的东西不是一回事闭环就闭合不上。3. ONNX 导出模型改造、算子替换与动态 Shape 取舍把训练好的 PyTorch 模型转成 ONNX是整个链路里最容易翻车的一步。PyTorch 的动态图机制让 forward 过程高度灵活但 ONNX 导出是静态追踪你的代码里只要有一个控制流分支依赖 Tensor 数值或者用了某个自定义 autograd Function导出就可能报错或者导出一个逻辑错误的图。3.1 导出前先做模型瘦身我建议先花一天时间把模型的 forward 方法做一个精简版本只保留推理需要的路径把所有训练相关的分支dropout、aux loss、梯度计算相关逻辑全部摘掉。这个步骤看起来多余实际上能解决一大堆后续问题。我当时踩过一个坑模型 forward 里为了训练方便在返回 logits 的同时还返回了一个中间特征图用于辅助监督。导出时我偷懒没改结果 ONNX 图里就多了两个额外输出。TensorRT 构建后推理时需要绑定 3 个输出 Tensor显存占用也多了不少完全是浪费。后来我把 forward 改成“trainTrue 返回两个输出trainFalse 只返回主干输出”再用 torch.onnx.export 的模型包装类把推理路径固定下来问题才解决。另一个常见问题是动态尺寸相关算子。很多分割头会用 torch.nn.functional.interpolate 做上采样这个算子在 ONNX 里通常能被支持但如果你在 forward 里根据输入张量的 H、W 动态计算缩放因子导出时可能生成动态 shape 的 Resize 节点TensorRT 对这个节点的支持并不总是完美。我的做法是在导出前把输入固定成 (1,3,H,W)然后所有上采样目标的尺寸都写成常量不要从输入 shape 推导。3.2 常用算子兼容性排查表我在这个项目里遇到并处理过的算子兼容性问题整理成一个表格方便你对照检查算子或模块PyTorch 用法ONNX 导出风险TensorRT 部署建议BatchNorm2d常用低会被折叠成 scale shift导出时最好设置 trainingFalse确保使用 running_mean/running_varReLU / ReLU6常见低无interpolate上采样低到中size 必须固定固定目标尺寸避免动态 ResizeMaxPool2d下采样低无grid_sample自定义采样高ONNX 节点可能不完整尽量避免某部署板上不支持对应的 GridSample 算子split / unbind维度切分中会生成多个 Slice 节点可以用 reshape/view 代替的部分尽量代替Roll / channel shuffle通道重排中可能生成大量节点展平成 reshape 或使用 1x1 卷积等价替换自定义 autograd Function不推荐极高需要手写 plugin直接替换为标准算子组合所谓“替换为标准算子组合”就是用一系列 reshape、transpose、matmul 和卷积去模拟一个复杂算子的数学行为虽然节点数量多了但每个节点都是推理引擎熟知的兼容性反而好。我那个模型里的 channel attention 模块原本有一个 roll 操作在 ONNX 里展开成十几个 Slice 节点TensorRT 构建虽然能过但推理性能很一般。后来我改用 1x1 卷积等价替换性能提升了大约 15%。3.3 动态 shape 和固定 shape 怎么选很多项目为了适配不同输入分辨率会使用动态 shape。但车道线检测在自动驾驶场景里输入分辨率通常是固定的因为下游标定、感兴趣区域、控制链路都基于固定内参矩阵。所以我最终选了固定 (1,3,480,960) 的单 batch 输入。这个选择让 TensorRT 的优化空间大了很多因为它不需要为不同尺寸准备多套 kernel 或做动态内存规划。如果你确实需要动态 shape我的建议是不要让它“完全动态”而是设置一个 profile 范围比如 H 在 480~720、W 在 960~1280 之间。trtexec 支持 --minShapes、--optShapes、--maxShapes 三个参数TensorRT 会为 optShapes 做最大程度优化超出 min 和 max 范围就会报错。特别注意optShapes 不一定是最大值而是你最常使用的分辨率通常就是你训练时的分辨率。动态 shape 还有一个坑如果模型里有 Resize 或 Interpolate 算子这些算子的输出尺寸会随输入变化TensorRT 需要在不同 profile 下都保持行为一致否则可能需要在运行时重新设置 tensor address。我在做动态 shape 测试时发现一个很有意思的问题同一份 ONNX固定 shape 构建的 engine 推理延迟是 12ms动态 shape 构建的 engine 在同样输入下延迟却达到 17ms。原因就是动态 shape 会让 TensorRT 在一些算子融合上变得保守例如它没办法把两个连续卷积在一个执行流里做极其激进的融合。所以能固定就固定这是部署性能的免费午餐。4. TensorRT 构建与推理FP16/INT8、校准与常见报错ONNX 导出成功只是开始TensorRT 构建本身又是一道关卡。构建工具不是只有 trtexecPython API 可以更精细地控制精度和内存策略。我用的是 Python API 方式因为后续要集成到推理服务里API 方式更灵活。4.1 构建流程和关键参数构建 engine 的基本流程是从 ONNX 文件创建 network配置 builder config指定工作空间大小和精度标志然后序列化成二进制文件。关键参数包括workspace/内存池大小我设置为 1GB。TensorRT 优先在这个范围内寻找算子融合方案太小会导致部分 kernel 失败太大也不会全部占满只是允许它尝试。FP16 使能builder_config.set_flag(trt.BuilderFlag.FP16)。这个会显著加速卷积但要注意精度对齐。INT8 使能builder_config.set_flag(trt.BuilderFlag.INT8)同时设置校准器。INT8 的加速比通常比 FP16 更高但校准不好会掉点。动态 shape profile如果用固定 shape不需要设 profile但最好显式设置 optimize profile 为实际输入尺寸。我当时用 Python 写了一个构建函数核心代码大致如下import tensorrt as trt def build_engine(onnx_path, save_path, precisionfp16): logger trt.Logger(trt.Logger.WARNING) builder trt.Builder(logger) network builder.create_network( 1 int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH) ) parser trt.OnnxParser(network, logger) with open(onnx_path, rb) as f: if not parser.parse(f.read()): for i in range(parser.num_errors): print(parser.get_error(i)) return None config builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 30) if precision fp16: config.set_flag(trt.BuilderFlag.FP16) elif precision int8: config.set_flag(trt.BuilderFlag.INT8) config.set_flag(trt.BuilderFlag.FP16) # 设置校准器见下文 serialized_engine builder.build_serialized_network(network, config) with open(save_path, wb) as f: f.write(serialized_engine) print(fengine saved to {save_path})注意builder.build_serialized_network在某些 TensorRT 版本里返回的是HostMemory对象可以直接写入文件。如果你用旧版本可能要改成bytearray再写。版本差异导致的行为不一致是部署中很常见的坑。4.2 FP16 和 INT8 精度校准FP16 大部分情况下直接开启即可但正如前面说的要在训练和部署之间做一次数值比对。我建议在转换后跑 100 张训练集图片分别用 PyTorch 和 TensorRT 输出 logits计算每个通道的 max abs diff。如果 diff 超过 1e-2就要考虑是哪些层引入的误差或者干脆退回 FP32。INT8 更加棘手。TensorRT 的 INT8 量化需要一组校准图像用于观察每层激活值的分布然后计算阈值。校准图像要尽量贴近真实部署场景不要只用训练集里最干净的图。我当时用 500 张自采道路图片做校准包含白天、逆光、阴天和夜间结果发现夜间图片占了 200 张的情况下INT8 engine 的夜间 mIoU 只掉了 1.2%比全部用白天图片校准的效果好得多。要写校准器主要实现IInt8EntropyCalibrator2接口核心是提供一组输入 Tensor 和对应的内存地址。这里有个容易踩的坑校准器每次迭代返回的是 CPU 内存指针而 TensorRT 构建时可能并行执行内存不能是临时变量必须在迭代期间保持有效。我在代码里预先分配了一块内存每次只改内容这样就不会出现段错误。INT8 并不是必选项。在这个项目里FP16 的推理速度已经满足 30ms 的延迟需求INT8 虽然能再快 25%但校准和精度维护成本很高。所以最终我保留了 FP16 作为主配置INT8 作为备用方案。如果你对延迟极度敏感INT8 值得深入如果只是常规业务FP16 是性价比最高的平衡点。4.3 推理代码的骨架engine 构建好后推理环节也有自己的套路。核心是创建 execution context绑定输入输出缓冲区然后调用execute_async_v2。这里要注意几个细节输入输出内存在显存上需要通过cuda.mem_alloc分配不能用普通的 Python list。每个 context 有自己的内部状态多线程推理时每个线程要创建独立的 context。用execute_async_v2需要传入 CUDA stream并且要保证 stream 与内存操作同步。简化后的推理代码如下import pycuda.driver as cuda import pycuda.autoinit # 自动初始化 CUDA class TensorRTInference: def __init__(self, engine_path): with open(engine_path, rb) as f: runtime trt.Runtime(trt.Logger()) self.engine runtime.deserialize_cuda_engine(f.read()) self.context self.engine.create_execution_context() self.input_volume 1 * 3 * 480 * 960 self.output_volume 1 * 2 * 480 * 960 self.stream cuda.Stream() self.d_input cuda.mem_alloc(self.input_volume * np.float32().itemsize) self.d_output cuda.mem_alloc(self.output_volume * np.float32().itemsize) def infer(self, input_batch): cuda.memcpy_htod_async(self.d_input, input_batch, self.stream) self.context.execute_async_v2( bindings[int(self.d_input), int(self.d_output)], stream_handleself.stream.handle, ) output np.empty(self.output_volume, dtypenp.float32) cuda.memcpy_dtoh_async(output, self.d_output, self.stream) cuda.Stream.synchronize(self.stream) return output.reshape(1, 2, 480, 960)这里有个很重要的点execute_async_v2是异步的它把任务提交到流上后立即返回。如果你紧接着做 CPU 后处理必须先同步。我在优化时把同步前的数据拷贝和后处理放到另一条流上并行但这样做会引入不少并发复杂度。对于单模型单路流直接同步完全够用。4.4 几个需要特别留意的问题TensorRT 构建时如果报The engine plan file is generated on a different version...说明你正在用另一个 TensorRT 版本反序列化旧 engine解决方案很简单重新构建不要在版本之间倒腾。只要 ONNX 文件不变重新构建很快没必要省这个时间。还有一种常见错误是Assertion failed: sizes.size() 4这通常来自某个需要四维输入但实际拿到了三维的算子尤常见于基于固定 shape 的 view、reshape 和 transpose 组合。我在调这个错误时发现是模型里的一个 Flatten 层把 (N,C,H,W) 拉成了 (N,CHW)后续 Conv2d 无法处理。解决方法是把 Flatten 改成permute reshape让维度保持在四维范围内。另一个问题是输出 tensor 的 shape 和预期不一致。特别是模型里有torch.argmax或torch.max这类算子ONNX 导出后可能变成两个节点一个用于计算索引一个用于计算最大值。TensorRT 对这类组合的支持有时比预期差输出数值可能正确但索引精度被降低。我在最终模型里刻意避免了 argmax把 argmax 放到后处理阶段用 CUDA 小 kernel 来做行方向峰值提取这样模型部分纯粹是卷积、ReLU、上采样这些标准算子构建更顺畅。5. 端到端闭环验证与性能调优模型能推理了不等于闭环通了。真正的闭环是摄像头图像经过预处理进入 TensorRT 推理得到概率图和行锚点输出再经过后处理拟合出曲线最后把曲线发送给下游控制模块。这里面任何一个环节的延迟都会叠加到端到端延迟上。5.1 单帧延迟、端到端延迟与抖动部署时不能只看模型的单帧延迟还要看端到端延迟和延迟抖动。单帧延迟只统计从输入 Tensor 到输出 Tensor 的 GPU 时间通常很低我的 FP16 engine 大约是 11~12ms。但端到端延迟包括图像采集、预处理、H2D 拷贝、推理、D2H 拷贝、后处理、通信实际测量下来约 24ms。抖动方面GPU 推理相对稳定主要波动来自后处理里 CPU 上的多项式拟合——如果那个拟合是 Python 循环实现的每一帧的时间能差出 5ms。我一发现这个就把多项式拟合改成纯 NumPy 向量化操作也就是用最小二乘法一次性解出系数矩阵而不是 for 循环逐条车道拟合。改动之后后处理时间从 6~8ms 降到了 2.5ms抖动也明显改善。5.2 车道线后处理从概率图到多项式曲线我的后处理分三步第一步在概率图上按行做 softmax 或直接取每个行锚点上的最大值得到一组合法位置第二步用 RANSAC 或简单的最小二乘法把位置点拟合成三次多项式一般用 x a * y^3 b * y^2 c * y d 这种形式因为车道线在图像里的水平位置和垂直距离更接近这个关系第三步根据相机内参和安装高度把图像坐标系里的多项式投射到车辆坐标系生成用于控制模块的曲率半径和横向偏移。这个后处理里最容易被忽略的是“置信度”维度。如果某个行锚点上的分类概率太低说明这个位置的车道线可能被遮挡或已经出界直接把它拿去做拟合会把曲线拉歪。我的做法是设置一个固定阈值 0.6低于这个值的锚点直接丢弃不参加拟合。同时如果一条车道线的有效锚点数量少于 15 个我就认为这次检测不可靠返回一个较低的置信度而不是硬拟合一条很可能错误的曲线。5.3 实车场景中的失效模式在实车上测试你会发现训练集里再多样本也覆盖不完真实世界的意外。我遇到最典型的失效模式有三个。第一个是阴影边界被误判为车道线。园区道路两侧的树影横向扫过路面阴影边界在图像上是一条锐利的高对比度边缘和二值化的车道线特征非常接近。训练时即便加了阴影增强模型仍然会被一些特殊情况骗过。我的应对方案是在后处理里加一个强先验车道线的宽度在图像中的投影范围是有限制的如果一个“疑似车道线”的宽度明显超过正常范围就把它当作阴影丢弃。这个规则在模型层解决不了只能在后处理规则里处理。第二个是雨夜反光。湿路面上车轮痕迹交织成一片亮线概率图会输出一大片高响应区域行锚点分类也会出现连续多个锚点被触发。这种情况靠纯视觉很难完美解决我在项目里做的是和下游控制模块联动如果连续三帧检测到左右车道线消失且置信度低就进入降级模式让车辆减速并等待人工接管。这不算检测算法的胜利但却是工程闭环必须要有的机制。第三个是相机曝光突变比如从隧道出来的一瞬间图像整体过曝车道线被白化。预处理阶段我加了一个自适应直方图均衡能缓解一部分但过曝严重时所有视觉模型都会失效。延迟和可靠性之间的平衡最后只能靠系统设计兜底不可能单靠模型完全解决。5.4 我最后保留的部署参数经过多轮实验对比我最终保留的部署配置是输入分辨率 960x480FP16 engine固定 batch size 1后处理用 CUDA kernel 提取行锚点位置再加 NumPy 向量化多项式拟合。单帧推理延迟约 11ms端到端延迟约 24msINT8 方案保留为一个备选 profile但不在默认链路启用。精度方面FP16 相对 FP32 在测试集上的 mIoU 掉了 0.8%有效车道线连续性指标没有明显下降这个损失在我的场景里完全可接受。INT8 相对 FP16 又掉了约 1.5%但延迟只提升了 4ms所以我没有把它设成默认。如果将来算力更紧张或者需要同时跑更多任务我会再把 INT8 校准细化一遍包括按日夜场景分别建立校准集。如果你也在做类似的车道线检测部署我最想提醒你的一点是train 和 deploy 要当成同一个问题来设计而不是两个独立环节。训练时就想清楚输出表达怎么被后处理消费导出前就把推理路径单独整理出来部署时先做输入输出数值对齐再做性能优化这样整个环才不会走到最后一步才发现根基不稳。另一个小技巧是保存一份带校准集的可复现构建脚本每次更新模型后半小时内就能拿到新的 engine而不是每次靠记忆手工配置参数。