LR-ASPP 配 MobileNet v3:道路语义分割快速验证方案
简介本资源面向计算机视觉方向的学习者与道路场景分割任务实践者提供基于LR-ASPP与MobileNet v3骨干网络的语义分割完整实战方案采用迁移学习策略训练10个epoch验证集IoU达到0.98适合希望快速复现高精度分割效果、理解轻量级网络在道路图像上应用的中高级读者。压缩包共2000个文件约99.62MB其中1829个png与157个jpg为道路场景图像及对应标注数据7个py脚本涵盖模型构建、训练与推理流程5个txt记录训练配置与说明2个pth为训练好的权重文件目录结构清晰便于直接加载与二次训练。目前已有259人学习下载读者可获取完整的训练代码、预训练权重与数据集组织方式快速验证迁移学习在分割任务中的收敛表现并在此基础上调整骨干网络或损失函数开展自己的实验。1. 道路语义分割选型为什么 LR-ASPP 配 MobileNet v3 值得先跑一遍如果你手头有一批车载摄像头抽帧图想快速验证「路面、车道线、路沿、障碍物」能不能被稳定分开又不想一上来就扛 DeepLab v3 那种显存和推理延迟那 LR-ASPP 加 MobileNet v3 这套组合值得先跑一遍。LR-ASPP 全称 Lite Reduced Atrous Spatial Pyramid Pooling是 MobileNet v3 原文里为移动端语义分割配套设计的轻量解码头核心思路是把 ASPP 里昂贵的多尺度空洞卷积换成「全局池化分支 逐点卷积」的极简结构参数量和计算量都压得很低。配上 MobileNet v3 的深度可分离卷积主干整套模型在道路场景这种类别少、结构重复度高的任务上往往几个 epoch 就能看到像样的 mask。迁移学习在这里不是可选项是必选项——主干权重直接决定你前 10 个 epoch 是收敛还是原地打转。2. 环境与数据准备从抽帧图到可训练的 mask 目录2.1 依赖版本与目录结构这套流程对框架版本不算挑剔但分割任务里 torchvision 和 numpy 的版本错位经常导致 mask 读取时通道数对不上。我一般锁一个能跑通的组合# 建议在独立虚拟环境里装避免和已有 CUDA 环境打架 python -m venv seg_env source seg_env/bin/activate # Windows 用 seg_env\Scripts\activate pip install torch2.1.0 torchvision0.16.0 --index-url https://download.pytorch.org/whl/cu118 pip install numpy1.24.3 opencv-python4.8.1 pillow10.0.1 tqdm4.66.1这里 torch 2.1 配 torchvision 0.16 是官方对齐过的组合cu118 对应 CUDA 11.8。如果你机器只有 CPU把--index-url那行去掉装默认版即可但训练会慢到让你怀疑人生10 个 epoch 在 500 张图上大概要跑几小时。numpy 锁 1.24 是因为 1.26 之后部分旧版 opencv 会报_ARRAY_API not found属于典型的玄学报错。目录我习惯这样摆后面脚本直接按这个路径写road_seg/ ├── images/ # 原图frameXXX.jpg ├── masks/ # 标注 mask同名 png ├── splits/ │ ├── train.txt │ └── val.txt └── train.pyimages 和 masks 里的文件名必须一一对应mask 用单通道 png像素值就是类别 id0 背景、1 路面、2 车道线……。如果你拿到的是彩色 mask得先转成灰度索引图否则读进来是三通道训练时 loss 直接算错。2.2 划分训练验证集与 Dataset 写法10 个 epoch 能到 0.98 的验证 IoU前提是验证集不能和训练集有同一段视频的相邻帧。抽帧图相邻帧几乎一模一样随机划分会让验证分数虚高这是分割任务里最常见的自欺欺人。我一般按视频段划分或者至少按帧号间隔切import os, random img_dir road_seg/images names sorted([f for f in os.listdir(img_dir) if f.endswith(.jpg)]) # 按帧号排序后每隔 5 帧取一帧做验证避免相邻帧泄漏 val_names [n for i, n in enumerate(names) if i % 5 0] train_names [n for n in names if n not in set(val_names)] os.makedirs(road_seg/splits, exist_okTrue) with open(road_seg/splits/train.txt, w) as f: f.write(\n.join(train_names)) with open(road_seg/splits/val.txt, w) as f: f.write(\n.join(val_names)) print(ftrain: {len(train_names)}, val: {len(val_names)})i % 5 0这个间隔不是随便定的道路场景相邻帧差异极小间隔 5 帧以上才能让验证集真正反映泛化。如果你数据来自多个视频段更稳妥的是按段划分把整段留作验证。Dataset 部分关键是同步做图像和 mask 的增强翻转、随机裁剪都要用同一个随机种子否则图和 mask 会错位import torch from torch.utils.data import Dataset import cv2 import numpy as np class RoadSegDataset(Dataset): def __init__(self, root, split_file, size(512, 512)): self.root root self.size size with open(split_file) as f: self.names [l.strip() for l in f if l.strip()] def __len__(self): return len(self.names) def __getitem__(self, idx): name self.names[idx] img cv2.imread(f{self.root}/images/{name})[:, :, ::-1] # BGR-RGB mask cv2.imread(f{self.root}/masks/{name.replace(.jpg, .png)}, 0) img cv2.resize(img, self.size) mask cv2.resize(mask, self.size, interpolationcv2.INTER_NEAREST) # 同步随机翻转用同一个随机数决定 if np.random.rand() 0.5: img img[:, ::-1] mask mask[:, ::-1] img torch.from_numpy(img.copy()).permute(2, 0, 1).float() / 255.0 mask torch.from_numpy(mask.copy()).long() return img, maskmask 的 resize 必须用INTER_NEAREST用默认的双线性会把类别 id 插值成 1.5 这种不存在的值训练时表现为 loss 突然变 NaN。图像归一化到 0-1 就够MobileNet v3 的预训练权重自带均值方差处理不用再手动减。3. LR-ASPP 解码头拆解全局池化分支怎么接 MobileNet v33.1 主干特征提取与空洞卷积的取舍MobileNet v3 主干在分割任务里通常取到倒数第二层特征输出 stride 16 的特征图通道数 960Large或 576Small。道路图像分辨率高、目标细长stride 16 已经丢了不少车道线细节所以 LR-ASPP 会在主干末端再接一个 stride 8 的浅层特征做融合。这里有个选型理由要说清为什么不用 ASPP 那套 6、12、18 的空洞卷积因为道路场景的尺度变化主要来自近处路面和远处路面尺度跨度没城市街景那么夸张全局池化分支已经能提供足够的上下文多尺度空洞卷积带来的收益抵不上它的延迟。LR-ASPP 的结构可以概括成两条支路一条是 1x1 卷积把主干特征压到 128 通道另一条是全局平均池化后接 1x1 卷积再上采样回来两条相加后再和 stride 8 的浅层特征拼接最后 1x1 卷积出类别数。全局池化那条支路就是「Lite Reduced」里 Reduced 的来源它用全局信息替代了多尺度空洞。import torch.nn as nn import torch.nn.functional as F from torchvision.models import mobilenet_v3_large class LRASPPHead(nn.Module): def __init__(self, in_ch, low_ch, num_classes): super().__init__() # 主干高层特征压缩 self.high_conv nn.Sequential( nn.Conv2d(in_ch, 128, 1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue) ) # 全局池化分支提供图像级上下文 self.global_pool nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_ch, 128, 1, biasFalse), nn.Sigmoid() # 用 sigmoid 做门控比直接相加更稳 ) # 浅层特征压缩 self.low_conv nn.Sequential( nn.Conv2d(low_ch, 128, 1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue) ) self.classifier nn.Conv2d(256, num_classes, 1) def forward(self, high, low): h self.high_conv(high) g self.global_pool(high) # [B,128,1,1] h h * g # 门控相乘 h F.interpolate(h, sizelow.shape[-2:], modebilinear, align_cornersFalse) l self.low_conv(low) out torch.cat([h, l], dim1) return self.classifier(out)nn.Sigmoid()那步是我踩过坑之后改的原版 LR-ASPP 全局分支是直接相加但在道路数据上远处路面和近处路面亮度差异大直接相加会让全局信息淹没局部细节改成门控相乘后验证 IoU 稳定不少。align_cornersFalse是分割任务上采样的默认选择设 True 会让边缘偏移半个像素细车道线会断。3.2 迁移学习加载策略与冻结层选择迁移学习不是把预训练权重一加载就完事冻结哪些层直接决定前几个 epoch 的收敛速度。MobileNet v3 的预训练权重来自 ImageNet 分类主干前几层学的是边缘、纹理这些对道路分割同样有用可以冻后面几层学的是语义类别和分割任务差异大要放开训。def build_model(num_classes4): backbone mobilenet_v3_large(weightsIMAGENET1K_V2) # 取 stride 16 的高层特征和 stride 8 的浅层特征 features backbone.features high_ch 960 # large 版最后一层通道 low_ch 40 # 浅层特征通道按实际取 # 冻结前 7 个 block只训后面和头 for i, block in enumerate(features): if i 7: for p in block.parameters(): p.requires_grad False class Net(nn.Module): def __init__(self): super().__init__() self.features features self.head LRASPPHead(high_ch, low_ch, num_classes) def forward(self, x): # 逐层取两个尺度的特征 low None for i, block in enumerate(self.features): x block(x) if i 3: low x # stride 8 附近 return self.head(x, low) return Net()i 7这个阈值不是固定的Small 版主干层数少冻到第 5 个 block 就够。判断标准很简单解冻太多前 2 个 epoch loss 震荡冻太多后面 loss 降不下去。我一般先冻一半看第一个 epoch 的 loss 曲线再调。weightsIMAGENET1K_V2是 torchvision 里 MobileNet v3 的预训练权重标识别写成旧版的pretrainedTrue新版本会报弃用警告。4. 训练循环与 IoU 计算10 个 epoch 到 0.98 的关键参数4.1 损失函数与优化器配置分割任务里 CrossEntropy 配 Dice 是常见组合道路场景类别极不均衡背景像素远多于车道线单用 CE 会让模型倾向于全预测背景。Dice 对前景敏感能把这个倾向拉回来。import torch import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth1.0): super().__init__() self.smooth smooth def forward(self, logits, target): probs F.softmax(logits, dim1) num_classes logits.shape[1] dice 0.0 for c in range(num_classes): p probs[:, c] t (target c).float() inter (p * t).sum() dice (2 * inter self.smooth) / (p.sum() t.sum() self.smooth) return 1 - dice / num_classes def train_one_epoch(model, loader, optimizer, device, ce_weight0.5): model.train() ce nn.CrossEntropyLoss() dice DiceLoss() total 0.0 for img, mask in loader: img, mask img.to(device), mask.to(device) optimizer.zero_grad() out model(img) # 上采样回原图尺寸再算 loss out F.interpolate(out, sizemask.shape[-2:], modebilinear, align_cornersFalse) loss ce_weight * ce(out, mask) (1 - ce_weight) * dice(out, mask) loss.backward() optimizer.step() total loss.item() return total / len(loader)ce_weight0.5是起点如果验证集上车道线 IoU 明显低于其他类把它降到 0.3 让 Dice 占主导。优化器用 AdamW学习率 1e-4weight decay 1e-4。10 个 epoch 这种短训练不要上余弦退火用 StepLR 每 4 个 epoch 降一半就够退火在短周期里反而让后期学习率太小、学不动。4.2 IoU 计算与验证集评估验证 IoU 到 0.98 这个数字前提是类别少且验证集干净。计算时要注意忽略掉没有标注的像素否则背景类会把分数拉高。torch.no_grad() def evaluate(model, loader, device, num_classes4): model.eval() inter torch.zeros(num_classes) union torch.zeros(num_classes) for img, mask in loader: img, mask img.to(device), mask.to(device) out model(img) out F.interpolate(out, sizemask.shape[-2:], modebilinear, align_cornersFalse) pred out.argmax(1) for c in range(num_classes): p (pred c) t (mask c) inter[c] (p t).sum().item() union[c] (p | t).sum().item() iou inter / (union 1e-6) return iou.mean().item(), iouunion 1e-6是防止某个类别在验证集里一个像素都没有导致除零。返回的 per-class IoU 比平均 IoU 更有诊断价值如果背景 0.99、车道线 0.6平均 0.98 是假象得回去看 Dice 权重和增强策略。训练脚本主循环把上面几块串起来device cuda if torch.cuda.is_available() else cpu model build_model(num_classes4).to(device) optimizer torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr1e-4, weight_decay1e-4 ) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size4, gamma0.5) train_loader torch.utils.data.DataLoader( RoadSegDataset(road_seg, road_seg/splits/train.txt), batch_size8, shuffleTrue, num_workers4 ) val_loader torch.utils.data.DataLoader( RoadSegDataset(road_seg, road_seg/splits/val.txt), batch_size4, shuffleFalse, num_workers2 ) for epoch in range(10): loss train_one_epoch(model, train_loader, optimizer, device) miou, per_cls evaluate(model, val_loader, device) scheduler.step() print(fepoch {epoch1} loss {loss:.4f} mIoU {miou:.4f} per-class {per_cls.numpy().round(3)})batch_size 8 在 512x512 输入下大概占 6-8G 显存显存不够就降到 4 并把学习率同步减半。num_workers在 Windows 上设 0否则多进程读图会卡死这是血泪经验。5. 避坑与排查训练不收敛、IoU 虚高、显存爆的常见原因现象一第一个 epoch loss 就是 NaN。原因多半是 mask 读取时通道数不对彩色 mask 被当成三通道读进来或者 mask 里有 255 这种超出类别数的像素值。解决用cv2.imread(path, 0)强制单通道训练前跑一遍np.unique(mask)确认像素值都在[0, num_classes-1]范围内超出的直接 clip 或重新标注。现象二验证 IoU 0.98 但实际推理一塌糊涂。原因是训练验证集划分时相邻帧泄漏验证集里的图和训练集几乎一样。解决按帧号间隔或视频段划分验证集里不能出现和训练集同一秒的帧。判断方法很简单把验证集图片拿去训练集里做最近邻检索如果能找到几乎相同的图划分就有问题。现象三训练到第 5 个 epoch loss 突然反弹。常见于学习率没降、或者 BatchNorm 在 batch_size 太小时统计量不稳。解决把 StepLR 的 step_size 调小或者把 batch_size 提到 8 以上如果显存不允许把主干里的 BatchNorm 换成 GroupNorm但会牺牲一点速度。现象四显存够但报 CUDA out of memory。多半是验证阶段没加torch.no_grad()或者上采样时保留了计算图。解决评估函数一定包torch.no_grad()插值用F.interpolate而不是带梯度的上采样层。另外 DataLoader 的num_workers开太大也会占显存设成 CPU 核数的一半就够。现象五车道线 IoU 始终上不去。原因是类别不均衡加下采样丢细节。解决把 Dice 权重调高、输入分辨率从 512 提到 768、或者在 loss 里给车道线类加 class weight。如果还不行检查标注本身细车道线在 resize 时用最近邻会不会断成虚线。6. 进阶技巧用 TTA 和类别权重把细长目标再拉一截10 个 epoch 到 0.98 之后想再往上抠最划算的是测试时增强TTA和类别权重微调这两招。TTA 不用重训推理时把图水平翻转、多尺度各跑一遍再平均细长车道线的断裂能补回来不少。我一般跑原图、水平翻转、0.75 和 1.25 两个尺度四路 softmax 平均后取 argmaxtorch.no_grad() def predict_tta(model, img, device): model.eval() img img.to(device) probs torch.zeros(1, 4, img.shape[-2], img.shape[-1]).to(device) scales [1.0, 0.75, 1.25] for s in scales: if s ! 1.0: new_size (int(img.shape[-2] * s), int(img.shape[-1] * s)) x F.interpolate(img, sizenew_size, modebilinear, align_cornersFalse) else: x img for flip in [False, True]: xi torch.flip(x, dims[3]) if flip else x out model(xi) out F.interpolate(out, sizeimg.shape[-2:], modebilinear, align_cornersFalse) if flip: out torch.flip(out, dims[3]) probs F.softmax(out, dim1) return probs.argmax(1)四路平均后车道线这种细目标的 IoU 通常能再涨 1-2 个点代价是推理时间翻四倍。如果部署端算力紧只保留水平翻转这一路性价比最高。类别权重那招更直接在 CrossEntropyLoss 里传weighttorch.tensor([0.5, 1.0, 3.0, 2.0])把车道线和路沿的权重抬高让模型不敢忽略它们。权重值不用精确算按类别像素占比的倒数开根号就差不多。还有个容易被忽略的点验证时把 mask 的忽略区域比如图像边缘没标注的像素排除掉再算 IoU否则那些像素会被算进背景类让分数虚高。我习惯在 Dataset 里把未标注像素设成 255评估时用mask ! 255做掩码。从那以后我每次跑分割任务都强制先看一眼 per-class IoU 和验证集划分的帧号分布再决定要不要信那个平均分。希望帮到你。本文还有配套的精品资源点击获取