简介:面向计算机视觉初学者的YOLO多任务道路识别项目代码包,基于YOLOv5、YOLOv8、YOLOv11系列模型,解决复杂道路环境下的车辆、斑马线、交通标志、路灯等7类目标的实时检测问题。资源为zip压缩包,共46个文件,约115KB,以txt文本、jpg图像样本、yaml模型配置文件、py训练脚本及png结果展示图为主,其中txt与jpg组合构成训练所需的标注数据与图片样本,yaml用于配置检测类别,py为数据处理与训练入口,结构清晰便于对照学习。项目依托CULane道路数据集,作者使用labelimg完成自定义标注,并提供了数据集处理、YAML编写及模型训练的全流程说明,已有105人学习。对想入门目标检测、理解从数据标注到模型部署完整链路的学习者,这份代码包可直接作为实践参考,省去环境搭建与数据整理的大量摸索时间。
1. 夜间车道的多任务感知:为什么单检测模型不够,单分割模型也不行
夜间场景会让检测模型的性能明显滑落,正午强光下车道线几乎消失,雨雾天气里车道线与积水倒影难以区分——这三类图像我在车载辅助驾驶预研项目里都实测过。单跑目标检测,模型看得到障碍物却说不清哪块区域可行驶;单跑车道线分割,模型又看不清远处的来车。于是我当时同时挂了三个模型跑同一路视频流,在边缘推理盒子上帧率从25掉到8,这还没算同步与调度带来的额外损耗。多任务道路识别的做法是:把目标检测、车道线检测、可行区域分割压进同一个YOLO框架,共享Backbone与Neck,一次前向推理同时输出检测框、车道线掩码和可行驶区域掩码。下文按我实际落地的顺序展开:架构怎么选、数据怎么洗、训练参数怎么设、坑在哪里,最后收一条能真实跑出实时帧率的推理链路。
2. 多任务架构拆解:共享Backbone与三路Head的取舍逻辑
2.1 共享主干与Neck:多任务省算力的根源不是什么黑盒技巧
我最早做多任务改造时,最关心的问题是它凭什么比三个独立模型省算力。把三个独立模型的网络结构打印出来做对比,结论其实不复杂:一个典型的检测模型,在640×640输入下,CSPDarknet风格的Backbone承担的浮点运算量占整个模型推理的一大半,Neck的FPN/PAN结构继续占据第二大部分,直接负责输出的Head只占很小的比例。换句话说,让三个模型各自跑一遍Backbone,相当于有三份重复的浮点运算在做几乎相同的事。多任务共享结构把这一段重复计算合并成了单份,这是它省算力的根本来源,跟所谓的黑盒技巧无关。
我实际对比过三种组织方式。第一种是三个独立模型并行跑,单帧总耗时按加法计算,显存占用按三倍计算;第二种是把三个模型塞进同一个推理进程轮流执行,显存有一部分共享,但计算总量没有压缩;第三种才是多任务共享结构,一个Backbone、一个Neck、三个Head。在同一个推理设备上,单检测模型耗时约30毫秒,单分割模型约25毫秒,串行合计约55毫秒;多任务共享结构一次前向约35毫秒,加上后处理中分割头两次上采样的开销,稳定在38毫秒左右。省下来的不是隐藏的魔法,是重复计提的卷积开销被合并了。
这个对比也让我明确了一个选型边界:多任务只有在三个模型串行开销显著时才收益大;如果部署平台算力低到连单独一个检测模型都跑不顺,多任务多出来的两个Head照样是负担。所以动手做架构改动之前,先把三个模型的前向耗时加一遍,如果总和本来就低于实时要求,那多任务的意义就不大。算清楚前提条件再动模型,这是我在这个项目里学到的第一个判断习惯。
Neck在多任务里的角色,远比很多人以为的更重要。我最初的版本直接拿检测模型自带的PAN结构跑多任务训练,车道线的预测在远距离处几乎失效。问题出在把车道线Head直接接在P5这一个尺度上,P5的步长是32,远处的车道线在特征图上可能只占不到一个像素。在这种特征上做分割再上采样,结果就是一片断断续续的斑点。后来我把Neck的输出改成P3、P4、P5三路,给每个任务按需取用:检测任务在三个尺度上都做目标匹配,车道线Head接P3和P4两层浅层特征,可行区域Head接P4和P5两层高层语义。改完之后,远距离车道线的连续性明显改善。这个结构调整不需要增加多少计算量,但对任务效果的影响非常直观,属于多任务改造中性价比最高的改动。
2.2 三路Head的输出规格与损失函数选型
三个Head输出各自的结果,规格差异不小。下面这张表是我在项目里反复用来对齐团队认知的规格表,按这个参数定义模型和损失,基本能避开维度不一致这类低级问题。
| 输出头 | 输出张量形状 | 激活函数 | 损失函数 | 默认权重 |
|---|---|---|---|---|
| 目标检测头 | [B, num_anchors, 4+1+C] | Sigmoid + 线性 | CIoU + 置信度BCE + 分类BCE | 1.0 |
| 车道线头 | [B, 1, H/4, W/4] | Sigmoid | BCE + Dice | 0.5 |
| 可行区域头 | [B, C_d, H/4, W/4] | Sigmoid | Focal + Dice | 0.5 |
车道线和可行区域的输出分辨率放在H/4而不是原图,这是显存和速度权衡的结果。我第一次试过把分割头直接输出到原图尺寸,显存占用几乎翻倍,训练速度肉眼可见变慢,可分割边缘的精度并没有实质提升。原因是分割头在H/4特征图上做双线性插值回原图,和从更高分辨率解码再放大相比,车道线这类细长结构在视觉上损失并不大,但显存代价却是成倍的。现在我的习惯是分割头统一输出H/4,后续要全分辨率掩码时用双线性上采样一次,省下来的显存可以换更大的批大小,反而对分割质量帮助更大。
损失函数的选择同样有讲究。车道线分割头最初只用BCE损失,训练出来的掩码孔洞很多,线条内部经常断开;加入Dice损失之后,连续区域交叠的梯度信号更强,孔洞明显减少。可行区域头最初用普通交叉熵,白天与夜间样本占比悬殊时收敛很慢,损失曲线来回震荡;换成Focal Loss后,困难样本占比提高,收敛开始平稳。目标检测头保留CIoU、置信度BCE和分类BCE即可,多任务改造不需要改动检测头的损失。整体原则是:分割头需要能压制背景噪声并强调重叠区域的损失,检测头需要的是宽松但位置敏感的回归度量。
2.3 检测头与分割头的梯度协作:避免任务之间互相抵消
这是多任务架构里讨论得最多、也最容易让新手卡壳的问题。我遇到的具体现象是:三个头各自算损失,训练一段时间后检测Loss和分割Loss都在下降,但同一份验证集上检测mAP却低于单任务模型,车道线精度也低于单分割模型。问题不在收敛能力,而在共享层的梯度被两个任务互相拉扯。我做了两项针对性调整,才把这层互相干扰压下来。
第一项是给不同Head设置不同的梯度回传尺度。检测头的回归分支输出的梯度幅度通常比分割分支大一个量级,如果把两边梯度直接相加,分割任务对共享层的影响几乎可以忽略。我在训练脚本里临时打印每个Head对共享层的梯度L2范数,观察前十个epoch,然后取一个中位数比值,把弱的一侧梯度乘以2到4的系数再回传。这么改完之后,损失曲线在同一个阶段内变得更平滑,最终检测mAP和车道线IoU都高于分头训练,说明梯度尺度的对齐确实有效。
第二项是给分割Head开一条浅层近道。车道线Head和可行区域Head不是只从Neck最后一层拿特征,而是额外从P3和P4这两层较浅的特征上各拉一路拼接。P3保留的相对完整的边缘纹理和空间细节,对车道线连续性和可行区域边界都有帮助。这样即使共享层里检测梯度和分割梯度存在抵消,分割任务也能靠自己的近道特征完成主要学习,不再完全依赖共享层的梯度方向。这两项改动的成本很低,但对最终效果的影响显著,是我在多任务训练里最看重的两个实用技巧。
任务主导权也应该在架构层面提前定好。我选择检测任务为主导,因为下游的刹车与避障逻辑最直接依赖目标框的位置和类别。车道线与可行区域是辅助任务,它们不参与NMS,只在后端充当区域约束。这个选择决定了Neck分叉位置:检测Head从P5拿高层特征,分割Head在P3和P4上独立解码。如果反过来让分割主导,远距离检测框的召回率会下降,因为P5的高层语义特征被更多分配给了分割分支。先想清楚主次,再动结构,后面调参能少走很多弯路。
3. 数据准备与格式转换:从JSON标注到YOLO多任务样本集
3.1 标注组织规范:一图四文件的目录结构
多任务项目起步时,最花时间的往往不是训练,而是把散落的标注整理成训练器能直接读的格式。很多团队习惯把目标检测标注放一个文件夹,车道线掩码放另一个,可行区域掩码又放第三个,训练机器上三个路径都要挂载,一个挂错就要等几个小时后才发现问题。我的做法是从一开始就固定成“一图四文件”的规范:每个样本目录下固定四个文件,缺一不可。
dataset/ train/ images/ # 每张原图, 如 00001.jpg det_labels/ # 每图一个txt, YOLO格式: class cx cy w h lane_masks/ # 每图一个8位png, 0/1 车道线掩码 drivable_masks/ # 每图一个8位png, 0/1/2 可行区域掩码 val/这个组织方式的好处是训练代码只需要一个根路径参数,三个标签路径全部由根路径推导出来,不会出现标签路径失联的问题。掩码用png而不是jpg来保存,因为png是无损压缩,车道线这种单像素级别的线条不会被有损压缩抹掉;jpg格式在低码率下会出现块状伪影,训练时会被当作伪标签,干扰分割头的收敛。
3.2 从BDD100K到多任务标签的转换脚本
公开数据里,BDD100K这一类的道路感知数据拥有检测、车道线、可行区域三类标注,是预研阶段做多任务联合训练合适的起点。它的原始标注是一个JSON文件,每帧图像对应一组labels,包含box2d框、poly2d折线、多边形区域;需要转换成前面说的一图四文件格式。下面这段转换脚本我做过很多次,注释标出了真正需要注意的位置。
import json, os import numpy as np import cv2 def draw_lane(mask, vertices, thickness=5): pts = np.array(vertices, dtype=np.int32).reshape(-1, 1, 2) # 折线点集可能被拆成多段, 用 polylines 连续画宽线, 避免断头 cv2.polylines(mask, [pts], isClosed=False, color=1, thickness=thickness) return mask def bdd_box_to_yolo(box, img_w, img_h): x1, y1, x2, y2 = int(box['x1']), int(box['y1']), int(box['x2']), int(box['y2']) cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0 w, h = x2 - x1, y2 - y1 return cx / img_w, cy / img_h, w / img_w, h / img_h def convert_sample(bdd_json_path, sample_id, out_dir, img_dir): with open(bdd_json_path, 'r', encoding='utf-8') as f: bdd = json.load(f) sample = bdd[sample_id] img = cv2.imread(os.path.join(img_dir, sample['name'])) H, W = img.shape[:2] lane_mask = np.zeros((H, W), dtype=np.uint8) drivable_mask = np.zeros((H, W), dtype=np.uint8) det_lines = [] for label in sample['labels']: cat = label['category'] if cat in ('car', 'truck', 'bus', 'rider', 'person'): cx, cy, w, h = bdd_box_to_yolo(label['box2d'], W, H) if w < 4 / W or h < 4 / H: continue # 过滤极小框, 避免噪声正样本 det_lines.append(f"0 {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}\n") elif 'lane' in cat and label.get('poly2d'): vertices = label['poly2d'][0]['vertices'] if vertices: lane_mask = draw_lane(lane_mask, vertices) elif cat.startswith('drivable') or cat == 'road': vertices = label['poly2d'][0]['vertices'] pts = np.array(vertices, dtype=np.int32).reshape(-1, 1, 2) cv2.fillPoly(drivable_mask, [pts], 1) with open(os.path.join(out_dir, 'det_labels', sample_id + '.txt'), 'w') as f: f.writelines(det_lines) cv2.imwrite(os.path.join(out_dir, 'lane_masks', sample_id + '.png'), lane_mask) cv2.imwrite(os.path.join(out_dir, 'drivable_masks', sample_id + '.png'), drivable_mask)这里几点要重点解释。第一,poly2d里的vertices坐标不统一,有的是像素坐标,有的被归一化到[0,1]区间,转换前必须对每个label做判断,统一换算成像素坐标;否则车道线会集中画到图片左上角一小块区域,训练曲线看着正常,一推理就露馅。第二,车道线用cv2.polylines配thickness=5来画,能把原始单像素折线变成5像素宽的车道线掩码。宽度太窄会让分割头的正样本像素占比过低,训练一开始就偏向全背景,宽度太大又会让车道线的边缘定位变差,5个像素是一个折中值。
第三,drivable_mask中我没有处理车辆遮挡区域,实际更严谨的做法是先把检测框转成ignore mask,把检测框内部的可行驶区域标成2,训练时忽略这一部分。原因是检测框内的像素属于被车辆遮挡的区域,OnGround真值无法确定。加上这一步以后,可行区域分割的边界精度大约能提升两三个点,代价是转换脚本多一次轮廓绘制。如果只是先验证多任务训练链路,可以后补,但正式项目建议一步到位。
3.3 掩码坐标系与标签对齐的三个边界坑
转换过程中最常出错的不是转换逻辑本身,而是边界情况。第一个坑是极小目标框。数据集中存在高度只有几个像素的远距离目标框,转成YOLO格式后归一化高度可能小于0.001,训练时会被当成极小的正样本,对损失产生噪声,甚至让模型在远距离上输出一堆不可靠的小框。我的处理办法是过滤长或宽小于4个像素的目标框。
第二个坑来自车道线折线的自相交。个别样本中一条车道线标注被拆成多段,polyline在连续段之间出现跳跃,同一个mask上这一段会从画面左边直接飞到右边。分割头学到的会是一条贯穿全图的斜线,而不是真实车道。解决方式是按折线坐标的两两距离判断,超过图像宽高一半的跳变就拆成独立线段重新连接;这个逻辑可以放在转换脚本里,也可以后处理时处理。
第三个坑是掩码尺寸同步问题。训练时图像会做缩放和裁剪,mask也必须同步缩放。使用同步缩放时要保持相同插值方式,不要用双线性插值去缩放二值掩码。双线性插值会在车道线边缘产生介于0和1之间的过渡值,训练时被当成软标签,让模型预测出模糊边界。这个坑特别隐蔽,因为训练损失是下降的,但验证时分割IoU始终偏低,而且提不上去。统一的处理方案是:mask缩放用最近邻插值,保持硬标签,图像本身用双线性插值保持视觉质量。
4. 训练参数与损失权重:让三个头一起收敛的启动配置
4.1 多任务训练入口与基础配置
训练端的第一个决定是选什么框架。常见做法是基于开源YOLO复现工程做多任务改造,或者用通用深度学习框架自己搭。我的经验是:预研验证阶段用前者能省很多底层的图像增强时间,生产级推理集成用后者更可控。下面这段启动命令是通用训练入口的典型写法,其中几个参数对多任务场景是经过验证的合理初值。
# 多任务道路识别训练入口(示例) python train.py \ --data configs/multitask_road.yaml \ --weights pretrained/yolov5m.pt \ --task multitask \ --img-size 640 \ --batch-size 16 \ --epochs 120 \ --loss-weight det=1.0 lane=0.5 drivable=0.5 \ --warmup-epochs 5 \ --lr0 0.005 \ --lrf 0.01 \ --multi-scale逐项说明含义。--task multitask告诉训练器同时优化三个Head,否则框架默认只跑检测头。--loss-weight是本文方案里最需要调的参数,三类损失的比值在warmup阶段结束后才真正生效。--warmup-epochs 5表示前五个epoch用较低学习率预热,让Backbone统计量先稳定,避免三个随机初始化的Head在初始权重碰撞下发生崩坏。--multi-scale随机改变输入分辨率,对车道线和目标框两种尺度都有帮助,代价是训练时间上升,属于标准的多任务标配。
对应的多任务数据配置YAML可以参考下面这份,它把数据路径和任务定义集中到了一起,训练器启动时一次性加载。
# multitask_road.yaml task: multitask data: train: dataset/train/images val: dataset/val/images classes: det: [0] # 这里简化为单类, 实际按场景把car/truck等映射成多类 lane: enabled: True mask_dir: dataset/train/lane_masks weight: 0.5 drivable: enabled: True mask_dir: dataset/train/drivable_masks weight: 0.5数据读取逻辑里最关键的实现细节是:每个iteration需要同时加载det txt、lane mask、drivable mask三份标注。如果训练框架里三个任务用异步线程处理,线程间的锁竞争会让数据加载变成性能瓶颈,实际吞吐量会掉三分之一左右。我在改造时把三个任务放到同一个数据加载器里同步读取,也就是每次iteration一次性读三个文件路径,不要用三个独立loader并行读,这样内存占用和延迟都可控。
4.2 损失权重的平衡原理与实操调整顺序
多任务训练最容易出现三种损失互相内耗。假设检测头损失在0.8,车道线损失在0.2,可行区域损失在0.4,直接相加时总损失几乎由检测主导,分割任务的学习被淹没。反过来分割权重设太高,检测Head梯度又会被冲淡。解决这个问题的常见做法不是一次性给出一组固定权重,而是先跑20个epoch观察三个损失数值本身的数量级,然后把它们调成接近同一个尺度。
我的实操顺序是这样的:固定det权重为1.0跑十个epoch,观察lane和drivable的Loss绝对值;如果lane Loss在0.1到0.3,drivable Loss在0.3到0.7,那么初始权重都可以设到0.5。然后看总损失曲线是否平稳。如果检测Loss在epoch5左右出现一个明显回升点,多半是分割任务梯度太强,要降低分割权重,比如从0.5降到0.3。如果检测Loss持续下降,但验证集车道线掩码碎裂,那是分割权重太低,回传梯度不足以驱动分割头,把lane权重大幅提升到0.8甚至0.9。
另一个常用策略是分阶段训练。第一阶段只训检测Head,冻结lane和drivable的参数,让Backbone先学好通用特征;第二阶段放开所有Head联合训练。我在某次对比实验里验证过,分阶段收敛速度快约两成,最终精度基本持平。但分阶段有个坑:如果你忘记取消冻结,或者不小心用了同一份包含已训练Head的权重作为从头训练的起点,两个分割Head就带着不同的预训练分布参与联合训练,可能会出现收敛后车道线边界偏一侧的怪问题。更稳的做法是共享Backbone加载预训练权重,三个Head全部随机初始化,只让共享部分继承通用视觉特征,这样回避了不同Head预训练分布不一致带来的偏移风险。
4.3 优化器、学习率与批大小的配合
多任务训练对批大小的敏感度比单任务高很多。我试过batch_size=8和batch_size=32两种设置,相同epoch数下分割头IoU相差约6个点,原因是分割任务每个样本的正负像素比例差异很大,小批次下梯度方差大,分割头难以稳定收敛。显存允许时尽量往上加,至少16起步;如果显存只够8,配合梯度累积模拟更大批次会好很多,不要硬上。
优化器我固定用带动量的SGD,不用自适应学习率的优化器。原因是在多任务结构里,自适应优化器会分别调整每个参数的步长,等于隐式地修改手工设置的损失权重,辛苦调好的权重比会被抵消。SGD配合lr0、lrf和warmup的使用,各任务的权重语义更直接,出问题时也好排查。学习率方面,单任务YOLO常用0.01起步,但多任务模型包含更多随机初始化的Head,初始学习率建议减半到0.005,否则前几个epoch的Loss曲线会拉出一个很大的尖峰,即使被warmup压回去,也在浪费训练时间。
EMA(指数移动平均)对多任务同样是推荐的配置项。由于多任务模型的参数平面比单任务更复杂,找到的极值点周围普遍更陡峭,EMA平均后的权重往往落在更平滑的位置,推理时的稳定性会更好。不过EMA对三个Head会产生不同影响,检测框的抖动被压低,分割mask的边缘也更规则,这是我在对比推理结果时看到的实际效果。训练脚本里开EMA即可,一般衰减系数用0.99到0.999。
4.4 验证指标不能只看mAP:三头分别报告指标
用单个mAP来判断多任务模型是否合格是个误区,因为mAP只反映检测头的质量,车道线和可行区域的好坏它一点体现不出来。我在每次评估时同时看三个指标:检测任务看mAP50与mAP50-95,车道线任务看IoU与最大车道线宽度下的召回率,可行区域任务看IoU与边界F1。后面两个要单独写评估函数,在验证集上逐类计算,便于观察每个任务的独立变化趋势。
实际组合是打印一张评估表,每一行是一个任务,某个epoch结束时把三个指标并列输出。如果检测mAP在上涨但车道线IoU在下降,说明权重配比里车道线权重偏低;如果可行区域IoU上涨但检测mAP停滞,那可能是分割头把共享特征的梯度带偏了。这三个指标的相对变化比绝对值更值得关注,因为它们能告诉我问题发生在权重层、梯度层还是数据层。单独追求任何一个指标的提升,都可能让多任务失去其意义,这也是多任务项目里“整体感知”思路在评估层面的体现。
5. 多任务训练避坑指南:四个每次重训都会踩的问题
5.1 现象:检测头正常收敛,车道线IoU却长期停滞
训练日志里检测Loss下降得很漂亮,验证集mAP也在涨,只有车道线IoU贴着地面不动。我最初以为模型结构有问题,反复调整Neck分叉、尝试加深分割头,都没用。后来把每个任务的Loss单独打到终端上才发现,车道线Loss数值非常小,在0.1附近浮动,回传到共享层的梯度L2范数只有检测头的十分之一不到。原因就是第2章里说的损失权重失衡:分割任务的梯度在共享层被检测任务淹没了。解决方法是把lane权重从0.5提到0.9,并给分割头的共享层梯度增加2倍缩放。只改这两项,两个epoch后车道线IoU就明显上升。经验是:遇到单任务指标滞涨,先看Loss绝对量级,再谈权重和结构,不要一上来就动网络。
5.2 现象:训练中途Loss尖峰后恢复,验证集精度永久下降
训练到大约第80个epoch时突然出现一次Loss尖峰,然后曲线慢慢恢复,看起来一切正常,但验证精度比之前低了一个多点,继续加epoch也回不去。我最初怀疑是学习率设置问题,把--lr0调低重跑,尖峰消失了,最终精度仍然偏低。逐帧排查后才发现,是某个极端样本的像素值范围异常,把BN统计量带偏了。解决方式是在数据加载的预处理阶段做异常值清洗,过滤掉对比度极端、整体偏色严重的样本。如果你用的是现成训练器,可以另写一个小脚本统计每个batch的输入像素均值和方差,超过阈值就告警,这样能更早发现数据污染问题。
5.3 现象:服务器端推理正常,导到端侧后车道线出现规律横纹
训练和服务器端验证都正常,模型导出到端侧推理时才出现规律性横条纹。这类问题通常在导出阶段,部分推理库对双线性上采样支持不够彻底,把双线性上采样错误地退化成最近邻实现,导致分割mask出现可见的块状纹理。解决方法是导出时检查计算图里的Upsample节点,确认端侧算子库对该节点的实现和训练时一致;或者直接在训练时把分割头输出分辨率设为原图的一半,端侧只需要一次双线性放大,减少对高倍数上采样的依赖。如果你在端侧工具链上看到分割mask出现规则的网格线,优先怀疑这里,而不是模型权重。
5.4 现象:夜间与雨雾样本的精度明显低于白天
训练数据里白天样本多、夜间样本少,Loss曲线整体下降趋势正常,但夜间验证集的三个指标都比白天低不少。这不是多任务的老问题,但多任务场景下处理起来要更小心,因为三个任务在夜间同时变差,你很难分辨是检测的问题还是分割的问题。我的处理方式是把夜间样本和雨雾样本按比例过采样,同时在训练时做亮度扰动和雨线噪声增强。建议直接离线做增强,把增强后的样本写入训练集,而不是每次训练时实时生成,否则每个batch的加载时间都会明显上升。
5.5 现象:三个头的损失都在下降,但验证集上目标框位置系统性偏移
一个比较隐蔽的问题:三个任务各自收敛良好,验证集上检测框却出现系统性偏移,比如所有框整体偏左上角几个像素。排查发现是数据转换时,检测标签的坐标归一化方向与模型Resize的坐标对齐不一致:原图1280×720缩放到640×640时,letterbox补边方式不同,导致标签坐标整体偏移。解决方式是在数据加载器里统一先做相同fig的letterbox参数,再换算标签坐标。具体做法是记录每个样本的缩放比例和补边偏移量,在读取det标签时直接做逆变换,或把标签预先生成到模型输入坐标系中。这类问题最容易在混用不同来源的YOLO格式数据时出现,值得在数据检查阶段多留一个心眼。
6. TensorRT导出与端侧后处理:把多任务模型压到实时帧率
6.1 导出流程:PyTorch权重到TensorRT的完整链路
多任务模型训练完成后,真正的落地关卡才刚刚开始。我在端侧设备上拿到PyTorch权重直接跑推理,发现速度比训练时还慢,原因是端侧推理库对动态shape和部分算子的支持不直接,需要先把模型导出成ONNX,再交给TensorRT做图优化和半精度量化。下面是我常用的导出命令链。
# 第一步: 导出ONNX, 固定batch与输入尺寸 python export.py --weights runs/multitask/best.pt \ --include onnx --img-size 640 --batch 1 # 第二步: 用trtexec生成FP16的TensorRT引擎 trtexec --onnx=model.onnx --saveEngine=model_fp16.engine \ --fp16 --minShapes=input:1x3x640x640 \ --optShapes=input:1x3x640x640 \ --maxShapes=input:1x3x640x640导出时最容易踩的两个坑。第一是ONNX里分割头的上采样动态尺寸问题,TensorRT要求输入shape固定,转ONNX时务必把--img-size固定成训练时尺寸,否则后续动态shape会触发重新profile,延迟显著上升。第二是FP16量化会让分割mask边缘噪声变大,这在模型精度可接受范围内通常不用管,但如果车道线细线和背景对比度太弱,FP16数值截断会导致细小线段断裂,此时可以退回FP32,或者只对检测分支做FP16、分割分支保持FP32,虽然工程上麻烦一点,但能换来更好的视觉连贯性。
6.2 后处理技巧:把掩码变成下游可直接消费的结构化输出
多任务输出的是原始张量,下游决策模块一般不愿意直接读像素掩码,它要么要的是车道线控制点,要么要的是可行驶区域的外包络多边形。我在后处理里通常做三件事:检测头直接输出框坐标和类别;车道线mask经过连通域提取后按纵向降采样生成关键点,再拟合成三次样条曲线,因为三次样条能表达弯道和起伏,比直线拟合更贴合;可行驶区域mask按置信度阈值转二值,再用轮廓提取导出凹包边界。这样的输出几乎不需要下游再做二次解析,调试起来也更直观。
这里有一个稳定的习惯:分离任务处理时不要让三个任务串行等待同一把线程锁。检测框的NMS和分割mask的连通域提取分别放两个线程,用队列做缓冲,这样即便一次分割mask处理慢了几毫秒,检测框的延迟也不会被拖累。我在某边缘盒子上通过这个改动,把端到端延迟从50毫秒压到了42毫秒。整个过程里踩坑最深的还是坐标对齐和上采样一致性,它们不体现在训练指标里,但每次都直接影响真实场景的表现。现在每改一版数据或模型,我都会先跑一遍夜间样本做视觉检查,再上指标验证,这个习惯帮我拦住了多次即将上线的缺陷。希望这篇笔记对你有用。
本文还有配套的精品资源,点击获取