简介基于U-Net的道路目标语义分割系统是一份面向深度学习与计算机视觉学习者的PyTorch实战资源专注于道路图像中道路、车辆、行人、建筑物等类别的像素级分类可服务于自动驾驶、智能交通等场景。压缩包共7个文件以Python脚本为主5个py辅以环境配置文件yml和说明文档md整体仅10KB。Python脚本分别承担程序入口、模型训练、推理预测、数据加载及辅助工具等职责environment.yml用于复现依赖环境README提供使用指引。资源覆盖从数据准备、模型构建、训练调参到评估预测的完整流程基于U-Net的编码器-解码器结构详细展示了PyTorch动态计算图下的实现细节并提供像素精度、mIoU等评估指标的相关工具。目前已有118人学习适合希望系统掌握语义分割项目落地、并参考完整工程结构的中级开发者。1. 道路目标语义分割为什么U-Net仍是落地首选车在路上跑真正要回答的不是“这里有个车”而是“这整块路面、车道线、行人、锥桶分别占哪些像素”。目标检测给的是框实例分割给的是对象轮廓而道路目标语义分割要把一帧图像里每一个像素都归类成道路、车辆、行人、标志牌或背景。这正是U-Net这类编码器-解码器结构的强项它不做目标计数只做稠密分类。很多团队拿YOLO系模型做检测后发现下游的路径规划、可行驶区域计算仍需要像素级结果于是又回来补语义分割。U-Net虽然不是最新潮的语义分割算法但它结构简单、显存占用可控、在小样本道路数据集上也能收敛仍然是工程落地里性价比最高的起点。这篇文章我从数据制作、模型选型、训练调参到推理加速给出一套可以直接复现的道路目标语义分割方案。2. 从任务到模型语义分割的输入输出与U-Net骨架选型2.1 语义分割和实例分割、目标检测的边界为什么是“逐像素”任务目标检测输出的是“类别 边界框坐标”一张图里出现两个重叠的行人检测模型只会给两个框不会告诉框内哪些像素属于人。实例分割更进一步给每个独立对象一个掩码两个重叠行人的掩码是分开的。语义分割则完全不区分个体它把类别相同的所有像素合并成一片区域两个行人在语义掩码里都是“person”类中间不留分隔线。这个区别决定了工程任务的组合方式。如果你的下游只需要知道“前方有障碍物、大概在哪个位置”YOLO类的检测模型就够可一旦要做可行驶区域提取、车道偏离预警、自动泊车的车位线拟合就必须拿到像素级归属。道路目标语义分割最常见的输出是一个与输入图像同尺寸的灰度图每个像素值对应该像素的类别编号比如0表示背景、1表示道路、2表示车道线、3表示车辆、4表示行人。后续算法可以在这个掩码上做连通域分析、找边界、算面积检测框给不了这些。也正因为如此语义分割模型的评估指标和检测模型完全不同。检测看mAP语义分割看mIoU平均交并比和像素准确率。mIoU的计算方式是对于每个类别计算预测掩码与真实掩码的交集面积除以并集面积再对所有类取平均。mIoU的一个隐藏性质是它对类别不平衡敏感道路占比大、行人占比小如果模型把所有像素都预测成道路道路类的IoU很高但行人类的IoU是0总体mIoU会被拉下来。所以训练时不能只看总loss要按类别分别看IoU。2.2 U-Net的编码器-解码器结构跳跃连接在道路场景里的真实作用U-Net最早是2015年提出的医学图像分割模型名字来自它的U形拓扑左边一层层下采样提取语义特征右边一层层上采样恢复分辨率中间用跳跃连接把同尺度的浅层特征拼到解码器。道路场景里这个结构有几个现实优势。先说下采样。编码器部分通常用卷积加池化或步长为2的卷积把特征图从原图尺寸逐步缩小到1/32每一步都在增大感受野。浅层特征保留边缘、纹理深层特征保留“这是车”、“这是路面”的语义。如果没有解码器直接输出低分辨率掩码再粗暴上采样到原图边缘就是一片糊。U-Net的解码器逐级上采样每上采样一次就和编码器对应层的特征拼接一次让边缘细节逐步恢复。跳跃连接还有一个容易忽略的作用让梯度更容易回传到浅层。道路分割的训练数据经常有大量阴影、雨雾浅层特征需要被充分调整。没有跳跃连接的网络在深度较大时浅层卷积的梯度会非常弱训练起来像黑匣子一样难以收敛。U-Net的跳跃连接相当于给梯度修了一条近路这也是它在数据量不大的道路数据集上比一些纯Transformer分割模型更稳的原因。解码器每一层输出之后接一个3×3卷积和ReLU最后一层用1×1卷积把通道数变成类别数再接Softmax或按通道取argmax得到类别编号。我一般会在每个解码块后面加Dropout概率0.1到0.2对雨雾场景有一定正则作用。2.3 主干网络怎么选从轻量到高精度的一组常见配置U-Net的精髓不在卷积块而在“编码器-解码器-跳跃连接”这个框架所以编码器可以换成各种预训练分类网络。常见做法是保留U-Net的解码器和跳跃连接逻辑把编码器替换成ResNet34、ResNet50或MobileNetV3。我按部署目标给出三档配置。第一档是轻量实时适合车载嵌入式设备。编码器用MobileNetV3-Large输入图像缩到512×512初始通道32解码器通道按[256, 128, 64, 32]递减。这一档在RTX 3060上训练一张图约50毫秒推理时用TensorRT半精度能跑到每秒25帧以上。缺点是车道线这类细长结构的召回率偏低。第二档是精度优先适合离线分析或高算力域控。编码器用ResNet50输入尺寸768×768解码器通道[512, 256, 128, 64]配合多尺度训练。这一档在Cityscapes验证集上的mIoU通常在70%上下只做道路相关类时更高但显存占用接近10GB批量大小只能开到4到6。第三档是平衡型也是我个人最常用的。编码器用ResNet34输入尺寸640×640解码器通道[256, 128, 64, 32]在不牺牲太多速度的前提下对车辆、行人的边缘分割比MobileNet版干净很多。选型时还有一个容易忽略的点预训练权重从哪来。直接用ImageNet预训练的ResNet做编码器比随机初始化收敛快得多。PyTorch的torchvision模型自带权重但如果你自己改了编码器输入通道比如接了红外图或深度图预训练权重就不能直接用了。道路目标语义分割一般还是RGB输入所以直接用标准预训练即可。U-Net本质上是一套连接方式不要自己发明新的卷积块老老实实套经典结构踩坑最少。3. 造数据与标数据把道路图像变成分割任务的训练集3.1 公开数据集与自采数据的取舍Cityscapes、BDD100K与你的场景道路目标语义分割的数据集制作是整个项目里最耗时间、也最决定上限的环节。公开数据集里Cityscapes提供的是欧洲城市的街道图像有精细标注的像素级语义掩码类别定义清晰但分辨率是1024×2048图像风格偏干净几乎没有国内常见的电动车、三轮车、塑料水马。BDD100K是驾驶视频帧规模大、场景覆盖白天黑夜雨雾但语义标注是粗粒度的车道线标得不够精细。如果你做国内道路场景直接用Cityscapes训练的模型迁移到国内道路mIoU通常会掉5到10个百分点原因是路缘石样式、交通设施颜色和车辆外形差异很大。我的建议是“公开数据自采数据”混合。先拿Cityscapes或BDD100K做预训练再用自采的几百张到几千张图像微调。自采数据不需要一上来就标几千张先每类收集50到100个典型样本把模型训起来再用模型辅助标注人工修正迭代扩充。这个做法能有效降低冷启动成本。自采数据的采集也不是随便拍。摄像头安装高度、俯仰角、焦距都要和部署环境保持一致。同一场景换一个安装角度模型性能会出现明显下降这是做道路分割的老坑。采集时注意覆盖不同光照——逆光、夜晚、雨天、隧道以及不同道路类型——高速、城市、乡村、园区。如果只看天气不看路况模型会对没见过的道路纹理很困惑。3.2 标签制作从多边形到灰度掩码的转换脚本常见标注工具是Labelme标注员在图像上画多边形导出为JSON文件。语义分割模型训练需要的是与图像同尺寸的灰度掩码所以必须把多边形填充成掩码。下面是一个通用的转换脚本读取Labelme JSON并生成PNG灰度图。假设类别编号是background0, road1, lane2, vehicle3, pedestrian4。import json import numpy as np import cv2 from labelme.utils import shape_to_mask def convert_labelme_json(json_path, img_shape, label_map): with open(json_path, r, encodingutf-8) as f: data json.load(f) mask np.zeros(img_shape[:2], dtypenp.uint8) for shape in data[shapes]: label shape[label] if label not in label_map: continue points np.array(shape[points], dtypenp.float32) # shape_type 可能是 polygon、rectangle 或 line shape_type shape.get(shape_type, polygon) if shape_type rectangle: points [points[0], [points[1][0], points[0][1]], points[1], [points[0][0], points[1][1]]] mask_temp shape_to_mask(img_shape[:2], points, shape_typepolygon) elif shape_type line: # 车道线常画成线需要加宽度 mask_temp draw_line_segment(img_shape[:2], points, width5) else: mask_temp shape_to_mask(img_shape[:2], points, shape_typepolygon) mask[mask_temp] label_map[label] return mask def draw_line_segment(shape, points, width5): mask np.zeros(shape, dtypenp.uint8) pts points.astype(np.int32).reshape(-1, 1, 2) cv2.polylines(mask, [pts], isClosedFalse, color1, thicknesswidth) return mask.astype(bool)这段代码最需要注意的坑是类别编号必须连续从0开始否则损失函数里的CrossEntropy会报错或静默错位。label_map建议用字典显式映射比如{background:0, road:1, lane:2, vehicle:3, pedestrian:4}而不是直接使用标注工具里显示的字符串。另一个容易出错的点是shape_to_mask期望输入的是数值型浮点坐标如果从JSON读出的是字符串要做类型转换。车道线通常不是闭合多边形而是折线需要额外加厚度上面代码里我用cv2.polylines画线宽度为5像素这个宽度要根据实际车道线在图像中的粗细调整。转换完以后我习惯把所有掩码整理成同名PNG放在mask目录图像放在images目录并用一个txt文件按行记录每张图像的路径和掩码路径方便后续PyTorch的Dataset读取。3.3 数据增强光照、雨雾与类别不平衡的应对道路分割最需要的增强不是随机的水平翻转而是针对光照和几何的增强。我一般使用albumentations库它专门针对分割任务设计图像和掩码可以同步变换避免自己写同步逻辑出错。import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.RandomResizedCrop(height512, width512, scale(0.5, 1.0), p0.5), A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(brightness_limit0.2, contrast_limit0.2, p0.5), A.HueSaturationValue(hue_shift_limit10, sat_shift_limit20, val_shift_limit20, p0.3), A.RandomGamma(p0.2), A.GaussNoise(var_limit(10.0, 30.0), p0.2), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ])RandomResizedCrop是提升泛化能力的关键它随机裁剪并缩放相当于给模型提供了多尺度视角。但要注意裁剪后必须保证掩码和图像使用完全相同的几何参数albumentations会自动处理如果你自己写增强一定要用同一个随机种子。HueSaturationValue对白天到傍晚的色偏很有用夜间场景则更多依赖RandomBrightnessContrast和GaussNoise。类别不平衡是道路分割里更隐蔽的问题。城市道路图像中道路类可能占60%以上行人和锥桶往往只占几个像素。如果不处理模型会把行人附近的像素也归类为道路因为大多数训练样本里那些像素确实是道路。除了后续章节要讲的损失函数数据端可以先做类别频率统计对频率过低的类别做小幅旋转、平移或复制粘贴增强即把行人小图抠出来粘贴到其他图像的道路区域。这个方法听起来粗糙但在少量目标类上非常有效相当于用最直接的方式给模型“看更多”行人。4. 训练U-Net的完整流程配置、损失函数与调参4.1 最小可跑训练脚本数据加载、模型初始化与训练循环训练脚本我通常分成四部分数据加载、模型初始化、损失函数、训练循环。下面给出一个基于PyTorch的最小可跑版本模型使用PyTorch官方提供的torchvision.segmentation里的fcn_resnet50或自己拼U-Net为了清晰这里直接用segmentation_models_pytorch库简称SMP它封装了U-Net的各种主干是业界最常见的语义分割模型库之一。import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader import segmentation_models_pytorch as smp import cv2 import numpy as np class RoadDataset(Dataset): def __init__(self, image_paths, mask_paths, transformNone): self.image_paths image_paths self.mask_paths mask_paths self.transform transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image cv2.imread(self.image_paths[idx]) image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) if self.transform: augmented self.transform(imageimage, maskmask) image augmented[image] mask augmented[mask] # mask 转成 long tensor形状为 H*W mask torch.from_numpy(mask).long() return image, mask model smp.Unet( encoder_nameresnet34, encoder_weightsimagenet, in_channels3, classes5, decoder_use_batchnormTrue, ) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) train_loader DataLoader(train_dataset, batch_size8, shuffleTrue, num_workers4, pin_memoryTrue) for epoch in range(30): model.train() running_loss 0.0 for images, masks in train_loader: images images.to(device) masks masks.to(device) logits model(images) # shape: B, num_classes, H, W loss criterion(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() print(fEpoch {epoch1}, Loss: {running_loss/len(train_loader):.4f}) torch.save(model.state_dict(), fcheckpoints/unet_resnet34_epoch{epoch1}.pth)这段代码里最关键的两个地方第一mask从文件读进来必须是灰度图不能是彩色图否则会变成三个通道导致维度对不上。第二模型输出logits的shape是(B, C, H, W)CrossEntropyLoss期望目标的shape是(B, H, W)并不需要给mask做one-hot。很多人在这里把mask转成one-hot再算loss结果维度错乱报错信息还不好理解。如果使用SMP库它自带的encoder预训练权重是从ImageNet来的不要关掉encoder_weights参数否则浅层特征需要从头学同样epoch下mIoU会低不少。训练过程中每轮保存一次权重是底线至少要保存最后一轮和最佳验证mIoU对应的权重方便后面回滚。SMP库是一个第三方库但已经成为语义分割落地的常见选择它支持对每个解码块做dropout配置。如果你不希望引入额外依赖也可以用PyTorch自己实现U-Net代码量在200行左右但没必要重复造轮子。4.2 损失函数怎么选交叉熵、带权交叉熵与Dice Loss道路分割最常用的基础损失是CrossEntropyLoss它对每个像素独立计算分类交叉熵实现简单、训练稳定。但正如前面提到的类别不平衡问题直接使用交叉熵会让模型偏向高频类别。我一般在两类情况下换损失函数。第一种情况是类别频率已知可以用带权交叉熵。权重的设置不是拍脑袋而是按类别频率的倒数归一化。例如道路类占60%车辆类占10%行人占2%那么给行人类的权重就设为几十。在PyTorch里CrossEntropyLoss有一个weight参数传入一个与类别数相同的浮点数组。权重的具体数值需要根据训练情况调整如果权重设得过大模型会被行人区域带偏导致背景误判率上升。第二种情况是目标形状细长比如车道线和路缘石用Dice Loss更合适。Dice Loss直接优化区域重叠度对类别不平衡的鲁棒性比交叉熵好但它的缺点是梯度在预测极值区域不稳定单独使用容易收敛到局部最优。常见做法是把CrossEntropyLoss和DiceLoss按系数相加我一般用0.7倍的交叉熵加0.3倍的DiceLoss。class CombinedLoss(nn.Module): def __init__(self, class_weightsNone): super().__init__() self.ce nn.CrossEntropyLoss(weightclass_weights) self.dice smp.losses.DiceLoss(modemulticlass) def forward(self, logits, masks): ce_loss self.ce(logits, masks) dice_loss self.dice(logits, masks) return 0.7 * ce_loss 0.3 * dice_loss使用DiceLoss时要注意mode参数multiclass模式会逐个类别计算Dice再取平均而binary模式只适用于单类分割。如果你训练5类就必须用multiclass。另一个注意点是SMP的DiceLoss期望输入是logits未经过Softmax它会内部处理概率化不需要你手动先做Softmax。如果你在损失函数里手动Softmax一次梯度计算会重复训练曲线会奇怪地波动。实际训练中我的观察是交叉熵主导时模型收敛稳定Dice权重过高时早期loss会快速下降但后期容易在细长结构上出现振铃效应。0.3是一个比较安全的比例如果你发现行人类别还是丢可以把Dice权重调到0.4但不要超过0.5。4.3 学习率、批量大小与图像尺寸三个最影响结果的参数这三个参数决定了训练能否收敛也决定显存是否爆掉。学习率我通常从1e-4开始配合AdamW优化器比SGD稳定得多。SGD需要精细调整动量和学习率策略在这个任务里没必要冒险。如果你用的是ImageNet预训练主干前几个epoch可以给一个较小的主干学习率比如主干的lr乘以0.1解码器保持全速率这样防止预训练特征被快速破坏。我在实验中见过直接把整网lr设为1e-3的情况loss在第2个epoch就变成NaN就是因为主干特征被大学习率冲崩。批量大小受显存限制。训练U-Net时输入图像512×512ResNet34编码器批量大小8占用约8GB显存。如果图像上调到768×768批量大小建议降到4。批量大小还影响BatchNorm的统计量批量太小2以下会导致BatchNorm不稳定此时应该改用GroupNorm或在模型外部做统计平均。有些团队为了省显存把批量设为1这不是不行但需要把BatchNorm换成InstanceNorm或使用累积梯度的方式否则效果很不稳定。图像尺寸决定了模型对细节的分辨上限。512×512是速度和精度的平衡点车道线在512尺寸下大约是3到5像素宽模型分割这类细长目标的IoU通常在50%到70%之间。如果部署分辨率本身就是1280×720建议训练输入也接近部署比例不要一味用正方形裁剪否则模型对宽幅场景的泛化会差。多尺寸训练可以缓解这个问题但训练时间会线性增加。我的经验是先固定512×512跑通全流程再在验证集上观察哪些类别表现差决定是否改用768×768。5. 避坑与排查道路分割训练中常见的5个问题5.1 现象loss下降但mIoU不动训练时交叉熵loss从1.2降到0.4看起来在收敛但验证集mIoU始终停在0.3左右。这个现象几乎每个做语义分割的人都会遇到。原因是类别不平衡下交叉熵的下限很低模型只要把大量道路像素预测对loss就会显著下降而行人、车辆这些少数类并没有被学好。另一个常见原因是验证集和训练集的类别分布差异太大比如训练集道路占比70%验证集道路占比85%模型会把验证集里更多的像素预测成道路导致边缘错分。解决方法是按类别打印IoU不要只看平均mIoU。如果行人类IoU明显低就启用带权交叉熵或提高DiceLoss比例。同时检查验证集图像里是否有训练阶段没见过的光照条件比如夜间图像在白天样本训练时会出现系统性误分类。最简单的验证方式是把自己标错的验证样本拉出来看模型预测的掩码覆盖是否集中在大尺度区域如果是基本可以定位为类别不平衡问题而不是模型结构问题。5.2 现象小目标行人、锥桶完全丢失模型在验证图像上把行人区域预测成背景或者只预测出半个身体。这是因为特征图下采样到1/32时16×16像素的行人在深层特征里只剩一个点很难被准确还原。我把这种情况归为两类原因一是输入分辨率太低行人边缘信息在早期下采样就已经丢失二是损失函数里行人的梯度权重太低。解决的第一优先级是提高输入分辨率把512变成640或768不要先调损失函数。分辨率提升对整体mIoU的提升通常有2到5个点对行人类别尤其明显。第二优先级是数据增强里的RandomResizedCrop它会随机放大局部区域让小目标在训练时获得更多像素表现。如果你已经用了较大的裁剪缩放比仍丢失小目标再考虑给行人做复制粘贴增强或修改损失权重。不要一开始就上复杂模块先把分辨率拉起来。5.3 现象边缘锯齿与车道线断裂输出掩码的车道线在长直线上表现还行一到弯道处边缘就像台阶而且中间经常断成几截。这个问题的根源是解码器上采样时细节恢复不足。U-Net虽然用了跳跃连接但高位解码层输出特征图的通道数有限加上训练时几何增强里的缩放会拉伸车道线让模型对线的连续性建模不充分。解决手法有几个。第一减小RandomResizedCrop的scale下限从0.5改为0.75避免训练时车道路面被缩放得过于畸变。第二在解码器最后一层输出后增加一个平均池化分支做边界监督但实现较复杂我一般不推荐。第三后处理时对掩码做形态学闭运算用3×3或5×5的核把细线缺口连通。这个后处理能提升视觉连续性但对IoU数值提升有限。如果要做车道线级别的精细分割建议单独用车道线检测模型而不是通用语义分割因为语义分割的类别定义本身就不擅长处理线性拓扑。5.4 现象验证集和测试集表现不一致模型在验证集上的mIoU不错但换到另一批实拍图像上效果明显下降。最常见的原因是我前面提到的数据同分布问题。验证集和训练集来自同一摄像头的同一时段光照、角度几乎一致模型自然表现好。换到不同摄像头、不同安装角度模型的编码器会对色调、畸变非常敏感。这也是很多团队的模型在仿真环境里很好、实车测试翻车的核心原因。我习惯在训练集之外单独留一个“场景漂移集”专门挑选与训练集差异最大的图像比如隧道、逆光、雨天用于评估模型真正的泛化能力。如果在这个子集上mIoU掉很多优先做图像归一化方面的增强用RandomBrightnessContrast和RandomGamma把模型对光照的依赖打散。另一个验证是可视化预测结果快速扫一遍就能发现模型是不是在靠路面纹理猜类别而不是靠语义。5.5 现象显存溢出与训练速度慢输入图像768×768批量大小8报CUDA out of memory。这个问题的原因很直接编码器特征图的显存开销随分辨率平方增长。常见的解法按优先级排序先减批量大小到4或2再考虑梯度累积用多个小批量累积后再更新等于综合了大批量效果和低显存占用再考虑开启PyTorch的amp混合精度它能减少约一半显存占用同时训练速度提升30%到50%最后才是降低输入分辨率因为分辨率影响精度是一个不可逆的妥协。速度慢的问题通常出在DataLoader的瓶颈。num_workers设成0时图像读取和预处理全在主进程GPU经常空闲。把num_workers设为CPU核数的一半配合pin_memoryTrue能明显提高GPU利用率。另外如果数据是若干张小图分散在磁盘读文件会比解码GPU慢建议把所有训练图像做成LMDB或MemoryMapped文件但这一优化要在数据量大于几万张时才划算几百张的数据集不必这么做。6. 从单张图到实时视频推理加速与可视化验证技巧6.1 推理脚本把模型输出变成道路俯瞰图训练完成后推理端要做的不是简单把模型输出打印出来而是把它转成可用的结果层。下面是一个典型的推理脚本读取一张图像输出彩色可视化掩码同时生成可行驶区域二值图。import cv2 import torch import numpy as np import segmentation_models_pytorch as smp model smp.Unet(encoder_nameresnet34, encoder_weightsNone, in_channels3, classes5) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval().cuda() color_map { 0: (0, 0, 0), # 背景 1: (128, 128, 128), # 道路 2: (0, 255, 0), # 车道线 3: (255, 0, 0), # 车辆 4: (0, 0, 255), # 行人 } def infer_and_visualize(image_path, save_path): image cv2.imread(image_path) image_rgb cv2.cvtColor(image, cv2.COLOR_BGR2RGB) orig_h, orig_w image.shape[:2] img cv2.resize(image_rgb, (640, 640)) img img.astype(np.float32) / 255.0 img (img - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) img torch.from_numpy(img).permute(2, 0, 1).unsqueeze(0).cuda() with torch.no_grad(): logits model(img) pred torch.argmax(logits, dim1).squeeze(0).cpu().numpy() # H, W pred cv2.resize(pred, (orig_w, orig_h), interpolationcv2.INTER_NEAREST) vis np.zeros((orig_h, orig_w, 3), dtypenp.uint8) for cls_id, color in color_map.items(): vis[pred cls_id] color out cv2.addWeighted(image_rgb, 0.5, vis, 0.5, 0) cv2.imwrite(save_path, vis) # 可行驶区域道路 车道线 drivable np.isin(pred, [1, 2]).astype(np.uint8) * 255 cv2.imwrite(save_path.replace(.png, _drivable.png), drivable)这段代码有两个关键细节。第一推理时的预处理必须与训练时完全一致包括缩放尺寸、归一化均值和标准差。很多人训练用了albumentations的Normalize推理时忘记用同样的均值和标准差导致颜色通道整体偏移模型表现断崖式下降。第二预测分辨率从640×640恢复到原图时必须用最近邻插值而不是双线性双线性会在类别编号之间插出中间值产生杂色。可行驶区域在这个例子里定义为道路类和车道线类实际项目中要按你自己的类别语义调整背景类里可能有行人或障碍物不能简单把非背景都归为可行驶。6.2 半精度、TensorRT与降分辨率三种常用加速手段部署到实车时PyTorch的普通推理速度通常不够。我给出三种最常组合使用的加速手段。第一是半精度推理在支持FP16的GPU上将模型权重和输入转成half类型显存减半、速度提升约40%。实现上只需在推理前调用model.half()并将输入图像转成torch.float16。注意BatchNorm层在FP16下计算可能不稳定最好把模型转成torch.jit.trace或直接用TensorRT。第二是TensorRT把训练好的权重导成engine文件。这是实车部署的主流方式。有没有CUDA环境无所谓TensorRT是NVIDIA的SDK。转换的关键是先使用torch.onnx.export导出ONNX模型再用trtexec或Python API构建FP16引擎。导出时注意固定输入尺寸因为TensorRT要求静态尺寸如果你训练是640×640导出尺寸也必须是640×640动态尺寸支持较麻烦道路场景一般不需要。ONNX导出还有一个常见坑torch.argmax算子导出不完全兼容要在导出前把argmax替换成softmax后取最大值索引或用topk算子。第三种是降分辨率。如果部署端算法可以接受640×480的输入把模型输入从640×640改成480×320左右推理延迟可以再降一半。降分辨率不只是缩放输入还要重新训练或至少微调模型因为模型的特征统计会随输入尺寸改变。如果只是想快速验证可以直接把测试图像resize到小尺寸推理但mIoU会掉几个点。我的经验是先做半精度再做TensorRT最后才考虑降分辨率因为前两种改变的是计算效率不损失太多精度。6.3 验证结果的三个闭合检查交并比、可视化与bad case复盘模型训完不是看一眼mIoU就完事我每次交付前会做三个闭合检查。第一个检查是按类别计算IoU且每个类别的IoU要与上次实验对比。如果某类的IoU从0.6掉到0.5哪怕平均mIoU上升也要找出原因可能是新增强策略对该类别不友好。第二个检查是可视化对比。我习惯把原图、真实掩码、预测掩码、以及“预测错误像素高亮”的图拼在一起保存。错误高亮图里红色表示预测成错误类别蓝色表示漏检。通过这批图能直观发现模型是否是靠颜色而不是形状来识别目标。比如红色的车如果经常被误分成路面很大原因是训练数据里红车样本太少或没有。第三个检查是bad case复盘把验证集中IoU最低的20张图单独抽出来逐张分析失败原因。这些图通常包含极端光照、遮挡、罕见车辆或标错标签。标错标签的bad case很多是因为标注员把远处的车标漏了模型反而比人标得准这种要修正标签而不是改模型。做完这一轮复盘把bad case按原因分类数据缺失补数据标签错误修标签模型泛化不足才改模型。这个习惯帮我避免了很多次“调参调半天最后发现是数据问题”的无效劳动。另一个值得养成的习惯是记录每一版实验的参数配置和结果。我会用一个简单的表格记录日期、输入尺寸、主干、批量大小、损失函数、各主要类别IoU和平均mIoU。这比翻代码提交历史方便得多也更容易看出哪一次改动带来的提升是真实的。做道路目标语义分割的难点往往不是模型结构而是数据分布、类别不均衡和测试环境差异。把这三个点控住U-Net这套方案足够覆盖绝大多数车载和路侧场景。希望这些经验帮到你少走我走过的弯路。本文还有配套的精品资源点击获取