☰
LR-ASPP + MobileNet v3:道路语义分割边缘部署实战指南
2026/10/13 9:17:38 网站建设 项目流程

简介:本资源面向计算机视觉学习者与道路场景语义分割开发者,提供基于LR-ASPP与MobileNet v3骨干网络的完整实战方案,适合具备一定PyTorch基础、希望掌握轻量级分割模型迁移学习流程的中高级读者。压缩包共2000个文件,以1829张png与157张jpg图像数据为主,另含7个py训练与推理脚本、5个txt说明文件及2个pth权重文件,整体约99.62MB,覆盖数据、代码与模型权重三类核心内容。作者采用迁移学习策略仅训练10个epoch,验证集IoU即达到0.98,说明该方案在道路图像分割任务上具备较高精度与收敛效率。读者可据此复现完整训练流程,理解LR-ASPP结构设计与MobileNet v3特征提取的配合方式,并借助已训练权重直接开展推理验证。目前已有259人学习下载,适合作为分割项目入门与迁移学习调参的参考案例。

1. LR-ASPP 配 MobileNet v3:道路语义分割为什么成了边缘部署的甜点组合

如果你正在做道路场景的语义分割,大概率绕不开一个矛盾:精度高的模型跑不动,跑得动的模型精度又不够看。LR-ASPP(Lite Reduced Atrous Spatial Pyramid Pooling)搭配 MobileNet v3 这个组合,就是冲着这个矛盾来的。它把空洞空间金字塔池化做了大幅精简,只保留全局池化和一个轻量分支,再挂上 MobileNet v3 的深度可分离卷积骨干,整体参数量和计算量压到能在边缘设备上实时推理的程度,同时在道路、车辆、行人、天空、建筑这几类常见目标上还能保持可用的分割质量。

这个方案适合谁?一类是做车载感知、巡检机器人、道路病害检测的工程师,需要在算力有限的板子上跑分割;另一类是想入门语义分割但被大模型训练成本劝退的开发者,拿这个组合当第一个能跑通、能出图、能调参的基线非常合适。它不追求刷榜,追求的是「我能在自己的数据上快速迭代出一个能用的东西」。接下来我会把骨干选型、LR-ASPP 结构、数据管线、训练配置、踩坑记录和调优技巧按落地顺序讲清楚,每一步都给出可复现的代码和参数说明。

2. 骨干与头部分解:MobileNet v3 和 LR-ASPP 各自在干什么

2.1 为什么骨干选 MobileNet v3 而不是 ResNet

道路分割的输入分辨率通常不低,512×512 起步,1024×2048 也常见。ResNet-50 在这个分辨率下,光骨干的浮点运算量就能把边缘设备压垮。MobileNet v3 的核心是深度可分离卷积加 SE 注意力模块,再配合 h-swish 激活函数,在同等精度下把计算量降了一个量级。它有两个版本,Large 和 Small,做分割一般选 Large,因为需要足够的感受野和通道数来支撑密集预测。

具体到分割任务,骨干要输出多级特征图。MobileNet v3 Large 的典型输出是:stride 4 的特征 24 通道,stride 8 的 40 通道,stride 16 的 112 通道,stride 32 的 960 通道。LR-ASPP 通常接在 stride 16 和 stride 32 这两级上,因为再往前的特征语义太弱,再往后的空间分辨率又太低。

import torch import torch.nn as nn class MobileNetV3Backbone(nn.Module): """简化版 MobileNet v3 Large 骨干,输出 stride 16 和 stride 32 两级特征""" def __init__(self): super().__init__() # 这里用 torchvision 的预训练权重做初始化,实际项目里替换成自己的加载逻辑 from torchvision.models import mobilenet_v3_large base = mobilenet_v3_large(weights='IMAGENET1K_V1') features = list(base.features.children()) # 前 12 层到 stride 16,后面到 stride 32 self.stage16 = nn.Sequential(*features[:13]) # 输出 112 通道 self.stage32 = nn.Sequential(*features[13:]) # 输出 960 通道 def forward(self, x): x16 = self.stage16(x) # stride 16 x32 = self.stage32(x16) # stride 32 return x16, x32

这段代码的关键在于切分点。features[:13]对应到 stride 16 的输出,通道数是 112;features[13:]继续下采样到 stride 32,通道数 960。切分点选错会导致后面 LR-ASPP 的输入通道对不上,这是新手最容易翻车的地方。参数上,weights='IMAGENET1K_V1'表示用 ImageNet 预训练权重,如果做的是道路分割且数据量不大,预训练能明显加快收敛。

2.2 LR-ASPP 到底精简了什么

标准 ASPP 用多个不同膨胀率的空洞卷积并行采样,捕捉多尺度上下文。LR-ASPP 的思路是:全局池化分支负责整图级别的上下文,另一个分支用深度可分离卷积加一个较大的膨胀率来补充局部多尺度信息,最后把两路拼起来。它砍掉了 ASPP 里多个并行空洞卷积分支,计算量降下来,但保留了全局和局部两个尺度的信息。

class LRASPPHead(nn.Module): """LR-ASPP 分割头,输入 stride16 和 stride32 两级特征""" def __init__(self, in16=112, in32=960, num_classes=6): super().__init__() # 全局池化分支:对 stride32 特征做全局平均池化再升维 self.global_pool = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in32, 128, 1, bias=False), nn.BatchNorm2d(128), nn.ReLU(inplace=True) ) # 局部分支:对 stride32 做 1x1 降维 self.local_conv = nn.Sequential( nn.Conv2d(in32, 128, 1, bias=False), nn.BatchNorm2d(128), nn.ReLU(inplace=True) ) # stride16 特征降维后与上采样后的 stride32 融合 self.low_conv = nn.Sequential( nn.Conv2d(in16, 128, 1, bias=False), nn.BatchNorm2d(128), nn.ReLU(inplace=True) ) self.classifier = nn.Conv2d(128, num_classes, 1) def forward(self, x16, x32): h, w = x16.shape[2:] # 全局分支:池化后广播回原尺寸 gp = self.global_pool(x32) gp = nn.functional.interpolate(gp, size=(h, w), mode='bilinear', align_corners=False) # 局部分支:1x1 降维后上采样 lp = self.local_conv(x32) lp = nn.functional.interpolate(lp, size=(h, w), mode='bilinear', align_corners=False) # 低层特征降维 low = self.low_conv(x16) # 三路相加后分类 out = gp + lp + low return self.classifier(out)

逻辑上,全局池化分支把 stride32 的 960 通道压成 128 通道的全局向量,再广播回 stride16 的尺寸;局部分支同样把 stride32 降到 128 通道后上采样;低层分支把 stride16 的 112 通道降到 128。三路相加后过 1×1 卷积输出类别数。参数上,num_classes=6对应道路、车辆、行人、天空、建筑、其他这六类,实际类别数按你的数据集改。128是中间通道数,想再轻量可以降到 64,但精度会掉一点,这个后面调优章节会细说。

2.3 两级特征融合的尺寸对齐问题

上采样用interpolate到x16的尺寸,而不是直接乘 2,是因为 stride16 和 stride32 的尺寸关系不一定是严格两倍,尤其输入分辨率不是 32 的整数倍时。用size=(h, w)显式指定目标尺寸,能避免尺寸不匹配导致的拼接或相加报错。align_corners=False是分割任务里的常见选择,和训练时的数据增强配合更稳。

3. 数据管线与训练配置:从道路标注到能收敛的 batch

3.1 道路分割数据集的标注格式与读取

道路场景的数据集常见两种标注:一种是像素级掩码图,每个像素值就是类别 id;另一种是 Cityscapes 风格的标注,需要从标注 json 转成掩码。这里以掩码图为例,假设目录结构是images/和masks/,文件名一一对应。

import os import numpy as np from PIL import Image from torch.utils.data import Dataset, DataLoader import torchvision.transforms as T class RoadSegDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size=(512, 512), augment=True): self.img_dir = img_dir self.mask_dir = mask_dir self.img_size = img_size self.augment = augment self.names = sorted(os.listdir(img_dir)) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img = Image.open(os.path.join(self.img_dir, name)).convert('RGB') mask = Image.open(os.path.join(self.mask_dir, name)).convert('L') # 统一尺寸,分割任务必须用最近邻插值处理 mask img = img.resize(self.img_size, Image.BILINEAR) mask = mask.resize(self.img_size, Image.NEAREST) img = T.ToTensor()(img) img = T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])(img) mask = np.array(mask, dtype=np.int64) return img, mask

关键点在 mask 的 resize 必须用Image.NEAREST,用双线性会把类别 id 插值成不存在的值,训练时直接报类别越界或者学出噪声。归一化参数用的是 ImageNet 的均值和方差,因为骨干加载了 ImageNet 预训练权重,输入分布要对齐。img_size设成 512×512 是精度和显存的折中,显存够可以上 768 或 1024。

3.2 训练循环与损失函数选择

道路分割里类别极不平衡,道路和天空占大头,行人和车辆占比小。交叉熵单独用会让模型偏向大类别,常见做法是交叉熵加 Dice 损失,或者用带类别权重的交叉熵。

import torch import torch.nn as nn import torch.nn.functional as F class CombinedLoss(nn.Module): def __init__(self, class_weights=None, dice_weight=0.5): super().__init__() self.ce = nn.CrossEntropyLoss(weight=class_weights, ignore_index=255) self.dice_weight = dice_weight def forward(self, logits, target): ce_loss = self.ce(logits, target) # Dice 损失按类别计算后取平均 probs = F.softmax(logits, dim=1) 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() union = p.sum() + t.sum() dice += 1 - (2 * inter + 1e-6) / (union + 1e-6) dice = dice / num_classes return ce_loss + self.dice_weight * dice

ignore_index=255用来忽略标注里的无效像素,很多数据集用 255 表示未标注区域。class_weights可以按类别频率的倒数来设,比如行人类别权重给到 5 到 10,道路给 1。dice_weight=0.5是经验值,想让小类别更敏感可以调到 1.0,但太高会让训练不稳定。

训练配置上,优化器用 SGD 加 momentum 0.9,初始学习率 0.01,配合 poly 衰减策略。batch size 根据显存来,8GB 显存跑 512×512 大概能到 8。训练轮数 100 到 150 轮,道路分割数据集通常在这个量级能收敛。

# 训练循环核心片段 model = nn.Sequential() # 这里替换成骨干加 LR-ASPP 头的完整模型 optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) criterion = CombinedLoss(class_weights=torch.tensor([1.0, 3.0, 8.0, 1.0, 1.0, 2.0])) for epoch in range(150): # poly 衰减:lr = base_lr * (1 - epoch / max_epoch)^0.9 lr = 0.01 * (1 - epoch / 150) ** 0.9 for param_group in optimizer.param_groups: param_group['lr'] = lr model.train() for imgs, masks in train_loader: imgs, masks = imgs.cuda(), masks.cuda() logits = model(imgs) # 上采样回原图尺寸再算损失 logits = F.interpolate(logits, size=masks.shape[1:], mode='bilinear', align_corners=False) loss = criterion(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step()

注意 logits 要上采样回 mask 的尺寸再算损失,因为 LR-ASPP 输出是 stride16 的,而 mask 是原图尺寸。上采样用双线性,align_corners=False和前面保持一致。poly 衰减的指数 0.9 是分割任务里的常见设置,比 step 衰减更平滑。

4. 避坑与排查:道路分割训练里最容易翻车的五件事

4.1 损失不下降,mask 全是同一类

现象:训练几个 epoch 后 loss 卡在某个值不动,可视化预测结果发现整张图都预测成道路或天空。原因通常是类别权重没设,或者 mask 读取时类别 id 映射错了。解决:先统计训练集里每个类别的像素占比,把权重按占比倒数设置,同时检查 mask 的像素值范围是否和num_classes对得上。用np.unique(mask)打印一下,如果出现大于类别数的值,说明标注有问题。

4.2 验证集精度远低于训练集

现象:训练 loss 降到很低,但验证集 mIoU 只有 0.3 左右。原因多半是过拟合,道路分割数据集如果场景单一,模型会记住训练场景。解决:加强数据增强,随机缩放、随机裁剪、颜色抖动都用上;同时把 weight_decay 从 1e-4 提到 5e-4。如果还不行,检查验证集的归一化参数是否和训练集一致,这个细节经常被忽略。

4.3 边缘设备上推理速度不达标

现象:在服务器上跑得好好的,部署到边缘设备帧率只有个位数。原因通常是输入分辨率没降,或者模型还在用 float32。解决:把输入从 1024×2048 降到 512×1024,推理时用torch.no_grad()并转成半精度。LR-ASPP 本身已经够轻,瓶颈往往在骨干的前几层,可以考虑把第一层卷积的 stride 从 2 改成 4,但会损失一些细节,需要权衡。

4.4 上采样后边缘出现锯齿

现象:分割结果的物体边缘呈阶梯状,尤其是车辆和行人轮廓。原因是对低分辨率 logits 直接双线性上采样,没有利用低层特征的空间细节。解决:在 LR-ASPP 头里把 stride8 甚至 stride4 的特征也引进来,做多级融合。或者训练时用align_corners=True试试,虽然和False差别不大,但某些分辨率下边缘会更平滑。

4.5 多卡训练时 BN 统计量不同步

现象:单卡训练正常,换成多卡后精度掉得厉害。原因是 BatchNorm 在每张卡上独立计算统计量,batch 被切分后统计量偏差大。解决:把 BN 换成 SyncBN,或者直接把 batch size 调大让每张卡的 batch 不至于太小。如果显存不够,用梯度累积模拟大 batch,但要注意 BN 的统计量仍然是在小 batch 上算的,SyncBN 更稳妥。

5. 调优技巧:让 LR-ASPP 在道路场景多榨出几个点

5.1 中间通道数和膨胀率的联合调整

LR-ASPP 头里的 128 通道是个可调参数。我一般会先跑一组对比:64、128、256 三个档位,在验证集上看 mIoU 和推理耗时的曲线。道路分割里 128 通常是拐点,再往上精度涨得慢,耗时涨得快。膨胀率方面,局部分支如果加空洞卷积,膨胀率设 3 或 6 比较常见,再大对小目标不友好。

# 在 LRASPPHead 里把局部分支改成带膨胀率的深度可分离卷积 self.local_conv = nn.Sequential( nn.Conv2d(in32, 128, 3, padding=3, dilation=3, groups=128, bias=False), nn.Conv2d(128, 128, 1, bias=False), nn.BatchNorm2d(128), nn.ReLU(inplace=True) )

这里用groups=128做深度可分离,dilation=3扩大感受野。改完之后重新训练,观察小目标类别的 IoU 变化,如果行人 IoU 涨了但道路没掉,说明膨胀率选对了。

5.2 用测试时增强换精度

如果推理耗时有余量,测试时增强(TTA)是性价比很高的手段。对输入做水平翻转和多尺度缩放,把预测结果平均后取 argmax。道路分割里水平翻转几乎不损失什么,多尺度用 0.75、1.0、1.25 三档,mIoU 通常能涨 1 到 2 个点。

def tta_predict(model, img): model.eval() preds = [] with torch.no_grad(): # 原始尺度 preds.append(F.softmax(model(img), dim=1)) # 水平翻转 preds.append(F.softmax(model(torch.flip(img, dims=[3])), dim=1).flip(dims=[3])) # 多尺度 for scale in [0.75, 1.25]: h, w = img.shape[2:] scaled = F.interpolate(img, size=(int(h*scale), int(w*scale)), mode='bilinear', align_corners=False) out = F.softmax(model(scaled), dim=1) out = F.interpolate(out, size=(h, w), mode='bilinear', align_corners=False) preds.append(out) return torch.stack(preds).mean(dim=0).argmax(dim=1)

注意翻转后的预测要翻回来再平均,多尺度的输出要插值回原尺寸。这个函数在验证和部署时都能用,代价是推理时间乘以增强倍数。

5.3 学习率 warmup 和分层衰减

MobileNet v3 的预训练权重对学习率敏感,直接上 0.01 有时候会震荡。加 5 个 epoch 的 warmup,从 0.001 线性升到 0.01,能稳很多。另外骨干和头可以用不同的学习率,头是新初始化的,学习率可以给骨干的 10 倍。

# 分层学习率设置 backbone_params = list(model.backbone.parameters()) head_params = list(model.head.parameters()) optimizer = torch.optim.SGD([ {'params': backbone_params, 'lr': 0.001}, {'params': head_params, 'lr': 0.01} ], momentum=0.9, weight_decay=1e-4)

warmup 阶段只调 head 的学习率,backbone 保持小学习率微调。这个策略在数据量不大的道路数据集上尤其管用,能避免预训练特征被破坏。

5.4 用混淆矩阵定位薄弱类别

mIoU 是个平均数,看不出哪个类别拖后腿。训练完在验证集上算混淆矩阵,按类别看 IoU。道路分割里经常是行人、骑行者这类小目标 IoU 低。定位到之后,针对性地在数据增强里加小目标的过采样,或者把损失函数里对应类别的权重再调高。我自己的习惯是每个实验都存一份混淆矩阵,对比不同配置时一眼就能看出改动影响了哪些类别。

from sklearn.metrics import confusion_matrix import numpy as np def compute_miou(preds, labels, num_classes): preds = preds.flatten() labels = labels.flatten() mask = labels != 255 cm = confusion_matrix(labels[mask], preds[mask], labels=list(range(num_classes))) iou = [] for c in range(num_classes): inter = cm[c, c] union = cm[c, :].sum() + cm[:, c].sum() - inter iou.append(inter / union if union > 0 else float('nan')) return np.nanmean(iou), iou

ignore_index=255的像素在算混淆矩阵前要过滤掉,否则会污染统计。np.nanmean处理某些类别在验证集里不出现的情况。拿到每个类别的 IoU 后,如果某个类别连续几个实验都低于 0.3,就要考虑是不是标注质量有问题,而不是继续调模型。

这套组合我从最早做道路巡检项目时就开始用,中间换过几次骨干和头部,但 LR-ASPP 加 MobileNet v3 始终是快速验证新想法的首选基线。它的价值不在于刷多高的指标,而在于让你把精力花在数据和场景上,而不是跟模型较劲。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询