资讯动态

YOLOv8多任务模型实战:检测+可行驶区域+车道线分割

发布时间:2026/10/1 3:25:40 来源:尧图企业网站定制
简介面向自动驾驶与图像分割开发者这份资源提供基于YOLOv8的多任务统一模型将目标检测、可行驶区域分割与车道线分割集成于一个轻量级框架。整体采用统一的轻量分割头和统一损失函数无需针对特定任务定制便于在嵌入式平台或实时场景中快速部署。压缩包共包含1313个文件体积28.54MB以859个Python源码文件为核心配合171个Markdown说明文档、43个YAML配置、4个Jupyter Notebook及若干可执行与安装脚本覆盖模型定义、训练推理和环境搭建等环节整体目录结构清晰便于按模块查阅。目前已有1348人学习下载适合具备一定深度学习基础、希望减少多任务系统冗余的开发者。通过源码与文档可掌握多任务头设计、统一损失计算、数据集组织及模型导出方式是快速落地自动驾驶感知任务的有效参考。1. 一次前向推理出三张图YOLOv8 多任务模型到底改了哪根筋接触过自动驾驶实车项目的同行应该都有类似体验车上那套感知管线早期主流方案是目标检测、可行驶区域分割、车道线分割各跑一个模型三个模型串行或者并行推。串行延迟累加并行则显存和算力开销直接翻倍到 Orin 这类板子上还得折腾多路流的调度。YOLOv8 做多任务这件事本质是把三个任务塞进同一个网络骨架里共享 backbone 的浅层特征和 neck 的融合特征只把末端 head 拆成三大分支一次前向推理同时输出检测框、可行驶区域掩码和车道线掩码。对边缘设备来说省下的显存和推理延迟非常可观这也是很多毕业设计、车载 Demo 和量产预研选这条路的直接原因。它的适用人群很明确正在做自动驾驶感知、想用一套代码同时搞定检测和分割、或准备把模型压到边缘设备上的开发者。前提是你的需求里这三个任务的输入是同一路摄像头画面、分辨率也一致否则多任务的收益会被预处理开销吃掉。这篇笔记我按自己拆这套代码的实际顺序来写先看结构怎么改再讲数据集怎么做然后给训练参数和踩坑记录最后聊板端验证。2. 读懂 YOLOv8 多任务网络结构共享主干与三头输出2.1 从官方 Detect 头到三叉戟分割分支插在哪个位置YOLOv8 官方仓库里已经有yolov8-seg这个变体它支持检测 实例分割双任务。我们要做的多任务更进一步把其中的分割头拆成两个并行的分割分支一个输出可行驶区域Freespace一个输出车道线。官方segment模型的 head 结构大致是Detect后接一个SegHeadSegHead内部有一系列上采样卷积层把 neck 输出的特征图恢复到输入分辨率。我们在这个基础上复制出两条并行的分割分支各自独立输出各自的掩码互不共享分割头内部的参数。这样做最直接的好处是任务冲突可控。如果让两个分割任务共用一个分割头的全部参数可行驶区域和车道线的语义尺度差异很大——前者是整块连通的区域边界变化平缓后者是细长线条需要更高分辨率的细节特征——共用卷积核时两个任务会互相拉扯最终 mIoU 都不高。拆成两个独立分支后backbone 和 neck 依然是共享的只有最后那几层上采样卷积各干各的参数量增加有限但收敛稳定很多。结构改动的核心位置在ultralytics/nn/tasks.py的DetectionModel初始化逻辑里官方seg版本已经定义了yolov8-seg.yaml里面通过head字段指定了分割头。多任务版本的做法是在这个基础上增加一个新的分割头配置两个分割头各自读取 neck 的同一层输出。neck 输出的特征图有三个尺度P3、P4、P5一般两个分割头都从 P3 取特征因为 P3 分辨率最高对可行驶区域边界和车道线细线最友好。2.2 参数可视化对比一张表看清多任务头的额外代价很多人担心多任务是不是把模型体积翻倍了。实际拆开看backbone 占了绝大多数参数量分割头只占很小一部分。用官方yolov8s做基准检测头大约占 3% 到 5% 的参数量而两个分割头叠加之后大约增加 15% 到 20%。如果你的分割分支用的是轻量级上采样结构比如只做两次双线性插值加一层卷积额外开销还能再压一半。结构部分yolov8s 检测模型多任务模型检测 双分割说明backboneC2f SPPF约 8.7M 参数与左相同共享这部分不动neckPAN-FPN约 1.3M 参数与左相同共享特征融合层共用检测头约 0.5M 参数约 0.5M 参数不变分割头无单个约 0.6M两个约 1.2M 参数两个分支互不共享总参数量约 10.5M约 11.7M增加约 11.4%这个增量对于显存是完全可以接受的。以 RTX 3060 12G 为例输入分辨率 640x640、batch size 8 时官方检测模型训练显存占用约 5.2G多任务模型约 6.8G仍在显存预算内。真正吃显存的是 seg_head 里的上采样层因为要把 80x80 的特征图放大到 640x640 再算 loss中间特征图的显存开销不可忽略。2.3 代码定位与配置入口改哪几个文件心里要有数如果你拿的是 GitHub 上已有的多任务实现文件结构大致逃不出这几个文件ultralytics/ ├── cfg/ │ ├── models/ │ │ └── v8/ │ │ ├── yolov8-multitask.yaml # 模型结构定义 │ │ └── yolov8-multitask-seg.yaml # 或者合并在这一个文件 │ └── datasets/ │ └── multitask.yaml # 数据集配置 ├── nn/ │ ├── modules.py # SegHead 模块定义 │ └── tasks.py # MultiTaskModel 初始化 ├── data/ │ └── base.py # 数据加载逻辑接收 mask 路径 └── utils/ └── loss.py # 多任务复合 loss 计算核心的两个改动点是modules.py里新增的SegHead类以及tasks.py里如何把两个 seg head 注册到模型字典。常见的写法是定义一个MultiTaskModel它的forward返回一个三元组(det_output, freespace_seg, lane_seg)。det_output的形状是(batch, 4 num_classes 1, num_anchors)两个分割输出则是(batch, num_classes, 640, 640)格式的 logits。有人会把 mask 输出层接到Sigmoid之前训练时用 BCEWithLogits 算 loss推理时再Sigmoid也有人直接输出已经是 Sigmoid 的结果哪种都行但要注意 loss 函数里别重复激活。我一般会在这个阶段做一次结构 sanity check随机生成一个小 batch 的假图过一遍模型确认三个输出的形状和预期一致再进入数据集制作环节。这一步能挡掉大概三分之一的结构改动错误。3. 把数据集做成 YOLO 三件套检测标注、掩码图与目录组织3.1 一份原始图像配两份标注txt 与 PNG mask 并存多任务数据集的组织原则是同一张原始 RGB 图检测任务用 YOLO 格式的 txt 标注类别 id 归一化中心点 归一化宽高两个分割任务各用一张 PNG 格式的掩码图。掩码图里前景像素的灰度值就是类别 id背景是 0。可行驶区域掩码的像素值用 1车道线掩码的像素值用 2或者反过来只要逻辑一致。这里要特别注意可行驶区域里包含了车道线所占的像素两者在空间上有重叠。正确的做法是让可行驶区域掩码把车道线区域也涂成 1车道线掩码单独把线区域涂成 2两个任务各算各的损失互不排斥——因为可行驶区域的定义本来就是“车能开过去的全部路面”。dataset/ ├── images/ │ ├── train/ │ │ ├── img_0001.jpg │ │ └── img_0002.jpg │ └── val/ ├── labels_det/ │ ├── train/ │ │ ├── img_0001.txt │ │ └── img_0002.txt │ └── val/ ├── masks_freespace/ │ ├── train/ │ │ ├── img_0001.png │ │ └── img_0002.png │ └── val/ └── masks_lane/ ├── train/ │ ├── img_0001.png │ └── img_0002.png └── val/目录名称可以按你的习惯调整但images和labels_det的配对关系必须严格遵循 YOLO 的命名规则图片img_0001.jpg对应的检测标注必须是同名的img_0001.txt。两个 mask 目录里的文件名也要和图片名完全一致只是扩展名是.png。我在代码里一般会把labels_det、masks_freespace、masks_lane三个目录一起挂在同一个父目录下这样数据集配置文件里只需要写一个根路径省去拼接出错的机会。3.2 推荐用 LabelMe 而非 LabelImgpolygon 一键转 mask检测框的标注用 LabelImg 就能完成导出 YOLO 格式的 txt。但分割掩码如果用 LabelImg 的矩形标注做边界会非常粗糙可行驶区域的分割质量会直接被标注质量卡死。我推荐用 LabelMe 画 polygon因为 LabelMe 导出的 JSON 里存的是多边形顶点列表后面用脚本把 polygon 填充成 mask 是现成的操作。LabelMe 还支持同一张图画多组 polygon并且可以把可行驶区域和车道线分开两个文件存。import json import numpy as np import cv2 import glob import os def polygon_to_mask(json_path, img_shape, class_map): json_path: LabelMe 导出的 JSON 文件路径 img_shape: (height, width) 原始图像尺寸 class_map: {freespace: 1, lane: 2} 像素值映射 with open(json_path, r, encodingutf-8) as f: data json.load(f) mask np.zeros(img_shape, dtypenp.uint8) for shape in data[shapes]: label shape[label] points np.array(shape[points], dtypenp.int32) class_id class_map.get(label) if class_id is not None: cv2.fillPoly(mask, [points], colorclass_id) return mask if __name__ __main__: class_map {freespace: 1, lane: 2} for json_file in glob.glob(labelme_jsons/*.json): img_file json_file.replace(labelme_jsons, images).replace(.json, .jpg) img cv2.imread(img_file) h, w img.shape[:2] # 一张 JSON 里同时有 freespace 和 lane 两种 polygon full_mask polygon_to_mask(json_file, (h, w), class_map) # 按像素值拆分出两个 mask freespace_mask np.where(full_mask 1, 1, 0).astype(np.uint8) lane_mask np.where(full_mask 2, 1, 0).astype(np.uint8) base os.path.splitext(os.path.basename(json_file))[0] cv2.imwrite(fmasks_freespace/train/{base}.png, freespace_mask) cv2.imwrite(fmasks_lane/train/{base}.png, lane_mask)这段脚本的核心是cv2.fillPoly它能把 polygon 顶点列表填充成实心区域。class_map里定义的像素值是后续训练 loss 计算的依据值必须与损失函数里的类别索引对应。np.where拆 mask 的逻辑是如果两个 polygon 在空间上重叠重叠区域会取后画的类别像素值拆分时各自取各自的值互不干扰。如果你想做的更严谨可以在标注时约定两个类别不能有重叠像素。3.3 数据集配置文件写法与验证脚本数据集配置文件指向三个标注来源同时给出类别名称。YOLOv8 的检测部分只用names列表两个分割任务的分类数通常是 1二分类分割所以配置里额外写两个字段来描述 mask 目录。# multitask.yaml path: /data/autopilot_dataset train: images/train val: images/val # 检测任务 nc: 3 names: [car, pedestrian, cyclist] # 分割任务 seg_names: [freespace] freespace_mask_dir: masks_freespace lane_mask_dir: masks_lane seg_num_classes: 1path是所有相对路径的根。train和val指向图像目录数据加载器在读取每张图片时会根据图片文件名去labels_det找检测标注去masks_freespace和masks_lane找分割掩码。这里有个容易出的问题YOLOv8 官方数据加载器默认只读labels目录多任务版本必须修改base.py里的load_dataset方法否则找不到 mask 路径会直接报错。from pathlib import Path import cv2 def verify_annotation_pairs(img_dir, det_dir, mask_dirs, sample_num5): 验证图片、检测标注、分割掩码三者是否一一对应 img_list sorted(Path(img_dir).glob(*.jpg)) for img_path in img_list[:sample_num]: det_path Path(det_dir) / (img_path.stem .txt) assert det_path.exists(), fMissing det label: {det_path} for mask_dir in mask_dirs: mask_path Path(mask_dir) / (img_path.stem .png) assert mask_path.exists(), fMissing mask: {mask_path} mask cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) assert mask is not None, fMask read error: {mask_path} assert set(np.unique(mask)).issubset({0, 1}), \ fMask {mask_path} has illegal pixel values: {np.unique(mask)} print(fAll {sample_num} samples passed verification) verify_annotation_pairs( img_dir/data/autopilot_dataset/images/train, det_dir/data/autopilot_dataset/labels_det/train, mask_dirs[/data/autopilot_dataset/masks_freespace/train, /data/autopilot_dataset/masks_lane/train] )验证脚本主要做两件事检查文件是否存在以及检查掩码像素值是否合法。像素值不是 0 和 1 的时候要回到标注环节查原因常见原因是 LabelMe 导出时把背景也导成了 polygon或者填充时用了别的灰度值。这个验证步骤放在训练之前能避免训练到一半才发现数据加载异常。4. 训练配置与损失权重让三个任务互不拖后腿4.1 损失函数设计三份 loss 按什么比例合并多任务模型的 loss 是三个独立 loss 的加权和检测用CIoU BCEYOLOv8 内置的v8DetectionLoss两个分割分支各用BCEWithLogitsLoss。分割 loss 的 weight 是两维的第一维是背景类权重第二维是前景类权重。可行驶区域中前景像素占比通常远高于背景车道线前景像素占比极低如果两类不设权重模型会趋向于把所有像素预测成背景来降低 loss——这就是典型的类别不平衡问题。# tasks.py 中 MultiTaskModel 的 get_loss 逻辑关键片段 def loss(self, batch, preds): det_pred, freespace_logits, lane_logits preds # 检测 loss 用原来的 v8DetectionLoss det_loss self.det_loss(batch, det_pred) # 可行驶区域前景多权重倾向小一些 fs_target batch[freespace_mask].float() fs_weight torch.tensor([0.3, 0.7]).to(fs_target.device) fs_loss self.bce_loss(freespace_logits, fs_target, fs_weight) # 车道线前景极少权重给大一点 lane_target batch[lane_mask].float() lane_weight torch.tensor([0.1, 0.9]).to(lane_target.device) lane_loss self.bce_loss(lane_logits, lane_target, lane_weight) # 最终加权 total_loss 1.0 * det_loss 0.8 * fs_loss 1.2 * lane_loss return total_loss, { loss_det: det_loss.item(), loss_freespace: fs_loss.item(), loss_lane: lane_loss.item(), }fs_weight和lane_weight里的两个数字分别是背景和前景的权重。可行驶区域前景面积大前景权重给 0.7 就够车道线前景像素少前景权重给 0.9 才能让模型关注到细线条。0.8和1.2是总 loss 层面的缩放系数——这个数值要按实际数据分布做调整如果你发现检测 mAP 掉得厉害说明分割 loss 在整个 loss 里占比过大把两个系数都往 0.5 压反过来如果分割 mask 边界糊成一团说明分割分支没有拿到足够的梯度把系数往上调。4.2 训练命令与超参数推荐从预训练权重起步更省心直接用官方yolov8s.pt权重作为初始化前几轮收敛会快很多。但注意官方预训练权重只有检测头的参数加载到多任务模型里时两个分割头的卷积层是随机初始化的。Ultralytics 的权重加载逻辑是按 key 匹配的匹配不上的层自动保留随机初始化所以这也是一个可用的方案。yolo train \ modelcfg/models/v8/yolov8-multitask.yaml \ datacfg/datasets/multitask.yaml \ pretrainedyolov8s.pt \ epochs100 \ batch8 \ imgsz640 \ lr00.01 \ lrf0.01 \ warmup_epochs3 \ patience15 \ optimizerSGD \ seed42lr00.01是 SGD 的初始学习率这是 Ultralytics 的默认值配 batch size 8 在 12G 显存上跑得动。如果显存不够把 batch 降到 4同时把 lr0 降到 0.005因为 batch size 减半时梯度方差会变大学习率同样减半才不至于震荡。warmup_epochs3让模型在前 3 个 epoch 用很小学习率热身避免随机初始化的分割头在初始阶段输出极端预测值把 loss 炸掉。patience15是早停轮数——多任务模型比单任务更容易出现过拟合尤其是分割头的参数量比检测头多15 轮没有一个指标变好就该停。训练过程中重点盯三件事loss_det是否在下降loss_freespace和loss_lane是否藕断丝连地跟着下降。如果某条分割 loss 降了但另外一条不降直接调 high-level 权重而不是盲目加大 lr。用yolo train跑的时候命令行里指定plotsTrueUltralytics 会输出 loss 曲线图三个 loss 画在一起对比非常直观。4.3 显存不够时的梯度累积与混合精度设置多任务分割头非常吃显存因为要在高分辨率特征图上做 dense 预测。如果你的卡只有 8G 显存batch size 8 跑不动很正常。梯度累积是官方的推荐方式batch设成 4accumulate设成 2效果等于 batch size 8但梯度是分两批累积的峰值显存只算 batch 4 的开销。yolo train \ modelcfg/models/v8/yolov8-multitask.yaml \ datacfg/datasets/multitask.yaml \ batch4 \ accumulate2 \ ampTrue \ imgsz640ampTrue是混合精度训练激活函数和卷积层的计算用 FP16主权重保持 FP32。实测中混合精度能把显存再降 20% 左右同时训练速度提升 30% 以上。但要注意分割 head 里的上采样层对 FP16 的数值精度比较敏感个别实现里用nn.Upsample配合 FP16 会出现边界伪影。如果发现分割输出有规律的噪点优先检查是否是 AMP 引入的必要时把分割分支的amp关掉或者全模型关掉 AMP。5. 推理、评估与常见坑训练时看不见的翻车现场5.1 推理脚本三个输出如何各归其位模型训练完后推理流程和单任务模型有很大差异。检测头输出直接走 NMS两个分割头输出的是全分辨率 logits要经过Sigmoid和阈值化才能得到二值掩码。这个流程我一般在单独的infer.py里封装好方便后面接到板端推理框架时对比输出。import torch from ultralytics.nn.tasks import MultiTaskModel import cv2 import numpy as np def preprocess(img_path, input_size640): img cv2.imread(img_path) # BGR h, w img.shape[:2] ratio min(input_size / h, input_size / w) new_h, new_w int(h * ratio), int(w * ratio) img_resized cv2.resize(img, (new_w, new_h)) canvas np.zeros((input_size, input_size, 3), dtypenp.uint8) canvas[:new_h, :new_w] img_resized # 归一化 NCHW 转 tensor img_tensor torch.from_numpy(canvas.transpose(2, 0, 1)[None]) / 255.0 return img_tensor, (h, w), (new_h, new_w), ratio model MultiTaskModel(cfg/models/v8/yolov8-multitask.yaml) model.load_state_dict(torch.load(best.pt)[model].state_dict()) model.eval() img_tensor, (h, w), (new_h, new_w), ratio preprocess(test.jpg) with torch.no_grad(): det_out, fs_logits, lane_logits model(img_tensor) # 检测头后处理NMS 拿到框 boxes, scores, cls_ids nms_postprocess(det_out) # 具体实现略 # 分割头sigmoid 阈值 还原到原图尺寸 fs_mask (torch.sigmoid(fs_logits[0, 0]) 0.5).cpu().numpy().astype(np.uint8) lane_mask (torch.sigmoid(lane_logits[0, 0]) 0.35).cpu().numpy().astype(np.uint8) fs_mask cv2.resize(fs_mask, (w, h)) # 最近邻插值保持二值性 lane_mask cv2.resize(lane_mask, (w, h))两个分割阈值不一样可行驶区域阈值取 0.5 足够因为它的目标区域面积大、边界分明车道线阈值取 0.35原因是车道线的像素在上下文中占比极小模型给出的概率值普遍偏低阈值太高会把细线条全部滤掉。这个 0.35 的经验值来自实车测试如果你换了一个数据集建议先在验证集上遍历一下阈值看 mIoU 随阈值变化的曲线找拐点。cv2.resize还原掩码到原图分辨率时插值方法必须用INTER_NEAREST。如果用了双线性插值掩码边缘会被平滑成灰度渐变二值判断的逻辑就被破坏边界会整体偏移一个像素左右——在 720p 图像上可能无所谓但在车载相机标定后的像素级映射里这个偏移量可能直接影响后续控制模块的计算。5.2 常见坑 1mask 文件名不一致导致训练时崩到怀疑人生现象训练在第一个 epoch 就报RuntimeError: Found invalid path或者AssertionError。原因数据加载器读图后按引擎里的规则找同名 mask但你的 mask 扩展名是.jpeg或者.png文件是RGB三通道模式加载器按单通道读出来 shape 对不上。解决统一用.png格式保存 mask并且用 OpenCV 的IMREAD_GRAYSCALE模式读取验证一下。写export.py脚本批量检查所有 mask 的单通道性和像素值范围别靠眼睛看。5.3 常见坑 2加载官方预训练权重报 shape mismatch现象load_state_dict直接报missing keys和unexpected keys模型完全无法初始化。原因多任务模型比单任务多了一整套分割头参数官方yolov8s.pt里没有这些 key直接load必然失败。解决要么用strictFalse加载这会自动忽略缺失的分割头权重要么先用随机权重跑 10 个 epoch 让分割头先收敛到一个合理范围再加载官方权重做微调。我实际推荐后面这种先用 640x640 的随机初始化训练 20 个 epochloss 降到不再大幅波动后保存再拿这个 checkpoint 当预训练继续训练mAP 会明显更稳。5.4 常见坑 3可行驶区域边缘出现方块状锯齿现象推理结果里分割边界有明显棋盘格、方块感不是平滑曲线。原因分割头里的上采样用了nn.ConvTranspose2d转置卷积在高倍率上采样时会产生棋盘效应另外一个隐含原因是训练时imgsz640推理时如果图像长宽比不是 1:1preprocess 阶段需要 paddingmask 还原到原图时又做了一次 resize锯齿被放大。解决把分割头上采样层改成nn.Upsample(scale_factor2, modebilinear)后接一个 3x3 卷积推理时 mask 还原改成先resize到new_h, new_w再裁剪掉 padding 区域不要直接一口气resize到原图尺寸。5.5 常见坑 4训练时 loss 下降但 mask 输出全黑现象训练曲线上三个 loss 都在降但推理时分割掩码全为 0什么都分割不出来。原因大概率是数据加载环节的 mask 读取出错比如把 RGB 图读成了三通道 mask模型计算 loss 时拿(batch, 3, h, w)的 logits 去跟(batch, 1, h, w)的 target 对齐损失数值看起来在降但梯度方向不指向有效区域。解决在训练脚本里加断言——每次数据加载后检查batch[freespace_mask].shape的第 2 维是否为 1以及像素数值是否只在{0, 1}内。另一个思路是在验证集上跑一次model.eval()输出 raw mask 的直方图看概率分布是否集中在 0 附近。如果是从数据端排查重心放回 mask 的通道数和像素值。5.6 常见坑 5早停触发过早模型还没学会分割就停了现象训练到第 30 个 epoch 就 early stop但看 loss 曲线还在缓慢下降继续训练显然还能提升。原因MultiTask 模型的patience默认值 15 是按官方单任务检测模型定的多任务的三个 loss 收敛速度不同步检测任务往往先稳定分割任务还在爬坡。此时验证集的 mAP 指标已经不再变化但分割 mIoU 还在上升而 Ultralytics 的早停只看主指标。解决把patience调到 30或者直接在训练脚本里关掉 early stop自己根据fitness曲线的变化幅度判断——我更建议超参上直接给足轮数100 个 epoch 不动然后用 Weight Averaging 收尾。6. 把模型搬上板子之前ONNX 导出与分离输出的效率验证6.1 导出 ONNX 的三个关键设置多输出、动态轴和简化图训练完的多任务模型在板端部署前第一步是转 ONNX。和单任务模型不同这里必须保证 ONNX 导出后的输出节点有三个。Ultralytics 集成了export命令但多任务模型要手工指定输出名否则导出的是整体输出 tensor后续在板端解析时非常痛苦。python export_onnx.py \ --weights best.pt \ --imgsz 640 \ --opset 12 \ --simplify \ --output multitask.onnxopset 12是部署环境支持的算子版本上限如果你的推理引擎比较老比如某些板卡的 SDK 只支持到 opset 11导出的模型会出现未知算子错误。simplify把 ONNX 图做一次常量折叠和冗余节点清理这是常规做法。导出完成后必须用onnxruntime或netron验证三个输出节点的名称和形状名称分别是det_out、freespace_seg、lane_seg这类自定义名字形状分别是(1, 84, 8400)、(1, 1, 640, 640)、(1, 1, 640, 640)——检测头的 8400 是三个尺度特征图锚点数的总和这个数字不要试图手算去对形状验证直接用模型打印出来。6.2 TensorRT 量化前先跑 FP16验证算子的兼容性转完 ONNX 后如果目标平台是 NVIDIA 系列下一步就是转 TensorRT。多任务模型里最可能在 TensorRT 转换时出问题的算子有两个分割头里的Resize双线性上采样和Sigmoid。Sigmoid在新版本 TensorRT 里已经被融合但旧版本上如果遇到Unsupported operation需要把分割头的激活从Sigmoid替换成ReLU或者直接用Clamp——这个修改很痛苦所以建议先检查板卡的 TensorRT 版本再规划。FP16 模式下跑一遍完整推理盯着输出掩码看有没有出现灰斑、闪烁或条纹状伪影。我在 RK3588 上测试时发现FP16 的Sigmoid在 0 附近数值精度下降车道线这种细长目标容易整体消失阈值变成 0.35 都救不回来。后来把激活函数换成了HardSigmoid效果明显改善。如果你也遇到类似现象建议在板端实测时多调几次阈值别直接在训练时的 0.35 值上一条道走到黑。6.3 效率验证不只是看 FPS三任务的耗时占比拆开算很多人在板子上只测一个端到端 FPS实际上多任务模型最应该关心的是三个任务各自的耗时占比。用 TensorRT 的 profiler 或者简单地在代码里cudaEvent计时把检测头、可行驶区域分割头、车道线分割头各自的推理时间拆出来。实测里常见的情况是分割头尤其是车道线分支占掉总耗时的一半以上因为它的特征图上采样到 640x640 后还做了一层卷积。这时不要全局优化单个算子而是直接从算法层面砍掉一部分计算——比如把分割输出降到320x320再由 CPU 端做一次resize到原图尺寸这个改动在板端实现起来非常快。// TensorRT 推理伪代码分离三任务计时 cudaStream_t stream; // 假设 engine 已经把三个输出按名称分开 float *det_out, *fs_out, *lane_out; auto t0 std::chrono::high_resolution_clock::now(); context-enqueueV3(stream); // 整体推理 auto t1 std::chrono::high_resolution_clock::now(); // 额外的后处理mask resizeCPU cv::resize(fs_mask, fs_mask_full, cv::Size(w, h), 0, 0, cv::INTER_NEAREST); auto t2 std::chrono::high_resolution_clock::now(); float infer_ms time_diff(t0, t1); // 前向推理耗时 float post_ms time_diff(t1, t2); // mask 后处理耗时上面这个计时方式能一眼看出瓶颈在哪。正常板端运行时推理部分 8~12msmask 后处理 1~2ms如果你发现 post_ms 占了 5ms 以上优先查resize是不是用了双线性或者目标尺寸过大——720p 的 mask 用INTER_NEAREST应该是几百微秒级别。后处理再往下做就是用 CUDA kernel 把Sigmoid、阈值化、resize全部融合成一个 pass这是 FLOPs 和使用体验最优解的终点能不动就不动先保持简单实现跑通再说。我从第一次做多任务模型到现在每次训练结束都会强制走一遍同样的流程先查验证集的 mask 是否出现全黑或者全白再跑一次 ONNX 导出脚本确认输出节点名没变最后上板子测延迟并拆出三个任务各自的耗时。这个顺序在项目迭代过程中帮我堵掉了至少三四次回退到训练阶段的低效操作。希望这篇笔记里的结构解读和踩坑记录能帮你在多任务模型这条路上少走几步弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑