资讯详情

资讯详情

LR-ASPP + MobileNet v3:道路语义分割边缘部署实战指南

简介本资源面向计算机视觉学习者与道路场景语义分割开发者提供基于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-ASPPLite 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(weightsIMAGENET1K_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 的输出通道数是 112features[13:]继续下采样到 stride 32通道数 960。切分点选错会导致后面 LR-ASPP 的输入通道对不上这是新手最容易翻车的地方。参数上weightsIMAGENET1K_V1表示用 ImageNet 预训练权重如果做的是道路分割且数据量不大预训练能明显加快收敛。2.2 LR-ASPP 到底精简了什么标准 ASPP 用多个不同膨胀率的空洞卷积并行采样捕捉多尺度上下文。LR-ASPP 的思路是全局池化分支负责整图级别的上下文另一个分支用深度可分离卷积加一个较大的膨胀率来补充局部多尺度信息最后把两路拼起来。它砍掉了 ASPP 里多个并行空洞卷积分支计算量降下来但保留了全局和局部两个尺度的信息。class LRASPPHead(nn.Module): LR-ASPP 分割头输入 stride16 和 stride32 两级特征 def __init__(self, in16112, in32960, num_classes6): super().__init__() # 全局池化分支对 stride32 特征做全局平均池化再升维 self.global_pool nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in32, 128, 1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue) ) # 局部分支对 stride32 做 1x1 降维 self.local_conv nn.Sequential( nn.Conv2d(in32, 128, 1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue) ) # stride16 特征降维后与上采样后的 stride32 融合 self.low_conv nn.Sequential( nn.Conv2d(in16, 128, 1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue) ) 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), modebilinear, align_cornersFalse) # 局部分支1x1 降维后上采样 lp self.local_conv(x32) lp nn.functional.interpolate(lp, size(h, w), modebilinear, align_cornersFalse) # 低层特征降维 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_classes6对应道路、车辆、行人、天空、建筑、其他这六类实际类别数按你的数据集改。128是中间通道数想再轻量可以降到 64但精度会掉一点这个后面调优章节会细说。2.3 两级特征融合的尺寸对齐问题上采样用interpolate到x16的尺寸而不是直接乘 2是因为 stride16 和 stride32 的尺寸关系不一定是严格两倍尤其输入分辨率不是 32 的整数倍时。用size(h, w)显式指定目标尺寸能避免尺寸不匹配导致的拼接或相加报错。align_cornersFalse是分割任务里的常见选择和训练时的数据增强配合更稳。3. 数据管线与训练配置从道路标注到能收敛的 batch3.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), augmentTrue): 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, dtypenp.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_weightsNone, dice_weight0.5): super().__init__() self.ce nn.CrossEntropyLoss(weightclass_weights, ignore_index255) self.dice_weight dice_weight def forward(self, logits, target): ce_loss self.ce(logits, target) # Dice 损失按类别计算后取平均 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() union p.sum() t.sum() dice 1 - (2 * inter 1e-6) / (union 1e-6) dice dice / num_classes return ce_loss self.dice_weight * diceignore_index255用来忽略标注里的无效像素很多数据集用 255 表示未标注区域。class_weights可以按类别频率的倒数来设比如行人类别权重给到 5 到 10道路给 1。dice_weight0.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(), lr0.01, momentum0.9, weight_decay1e-4) criterion CombinedLoss(class_weightstorch.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, sizemasks.shape[1:], modebilinear, align_cornersFalse) loss criterion(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step()注意 logits 要上采样回 mask 的尺寸再算损失因为 LR-ASPP 输出是 stride16 的而 mask 是原图尺寸。上采样用双线性align_cornersFalse和前面保持一致。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_cornersTrue试试虽然和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, padding3, dilation3, groups128, biasFalse), nn.Conv2d(128, 128, 1, biasFalse), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue) )这里用groups128做深度可分离dilation3扩大感受野。改完之后重新训练观察小目标类别的 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), dim1)) # 水平翻转 preds.append(F.softmax(model(torch.flip(img, dims[3])), dim1).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)), modebilinear, align_cornersFalse) out F.softmax(model(scaled), dim1) out F.interpolate(out, size(h, w), modebilinear, align_cornersFalse) preds.append(out) return torch.stack(preds).mean(dim0).argmax(dim1)注意翻转后的预测要翻回来再平均多尺度的输出要插值回原尺寸。这个函数在验证和部署时都能用代价是推理时间乘以增强倍数。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} ], momentum0.9, weight_decay1e-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], labelslist(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), iouignore_index255的像素在算混淆矩阵前要过滤掉否则会污染统计。np.nanmean处理某些类别在验证集里不出现的情况。拿到每个类别的 IoU 后如果某个类别连续几个实验都低于 0.3就要考虑是不是标注质量有问题而不是继续调模型。这套组合我从最早做道路巡检项目时就开始用中间换过几次骨干和头部但 LR-ASPP 加 MobileNet v3 始终是快速验证新想法的首选基线。它的价值不在于刷多高的指标而在于让你把精力花在数据和场景上而不是跟模型较劲。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →