10个epoch达到0.98 IoU:LR-ASPP与MobileNet V3道路分割实践
2026/9/10 8:31:45 网站建设 项目流程

简介:面向自动驾驶与智能交通的道路图像语义分割实战资源包,以LR-ASPP为主干、MobileNetV3为轻量级特征提取网络,采用迁移学习训练10个epoch,验证集IoU达到0.98,适合有深度学习基础的开发者复现与二次开发。资源内含2000个文件,包括1829张PNG与157张JPG道路场景图像,涵盖不同光线和路况下的视频帧;7个Python脚本覆盖数据预处理、模型训练与评估;5个标签或配置TXT文件便于理解类别定义,2个训练完成的PTH权重可直接加载推理,压缩包整体99.62MB,目录结构清晰。已有259人学习下载。通过该资源可获取完整道路分割数据集、训练脚本和预训练模型,快速复现IoU 0.98的实验结果,也可基于自定义道路场景微调;PTH权重支持轻量化部署验证,为语义分割算法研究、毕业设计或工程落地提供实用参考。

1. 从 10 个 epoch 到 0.98 IoU:道路图像语义分割没你想的那么难

很多做自动驾驶相关任务的朋友会把语义分割想得很重,尤其是看到 DeepLabV3 的大模型之后。我这次拿到一批道路视频帧,标注了道路、车道线、车辆、行人这些目标,目标是把分割模型跑起来并部署到边缘设备上。最终选了 LR-ASPP 搭配 MobileNet V3 作为骨干网络,用迁移学习只训练了 10 个 epoch,验证集 IoU 就到了 0.98。这个结果不是靠堆数据堆出来的,而是预训练权重在特定道路场景下快速微调的结果。这个组合特别适合需要迅速验证分割效果、或者要把模型塞进实时链路的人,看完这一篇可以直接照做。

2. LR-ASPP 结构与 MobileNet V3 的取舍:为什么道路分割很少用 DeepLabV3

2.1 ASPP 到 LR-ASPP 的轻量化演进

ASPP(Atrous Spatial Pyramid Pooling)是 DeepLab 系列的核心,它把同一个特征图分别用 1×1 卷积和不同膨胀率的 3×3 空洞卷积并行抽取,再拼到一起。膨胀率越大,感受野越大,模型能同时看到小目标和巨大的背景区域。但对于道路分割来说,图像尺度相对固定,背景结构也规律,没必要把五个分支全保留。LR-ASPP 是专门为 MobileNet V3 精简出的版本,只保留了 1×1 卷积和两个 3×3 空洞卷积,膨胀率分别是 12 和 24,同时增加一条 low-level 特征分支用来补边缘细节。这样参数量比原版 ASPP 少一半多,在 GPU 和嵌入式设备上都能跑得动。

分支配置模块构成膨胀率主要作用
完整 ASPP1×1 卷积 + 3 个 3×3 空洞卷积 + Image Pooling6, 12, 18覆盖多尺度全局上下文
LR-ASPP1×1 卷积 + 2 个 3×3 空洞卷积 + low-level 1×1 卷积12, 24轻量多尺度 + 边缘补充

这个精简不是随意砍的。道路图像里行人或者小目标可能只占十几个像素,路面和背景则占了整张图,膨胀率 12 和 24 两层正好覆盖这两个尺度范围。如果换成膨胀率 6,对小目标稍有帮助,但计算代价上升,整体收益不明显。要是你的数据换成了遥感影像,图中目标尺度变化很大,那 LR-ASPP 不一定够用,回退到 DeepLabV3 语义分割那套方案更稳。所以选型的时候先看对象尺度分布,不要只看模型名气。

2.2 MobileNet V3 的深层特征与低层细节怎么接

MobileNet V3 在 ImageNet 上预训练过的分类特征,放到语义分割里有两个关键优势:深层特征分辨率低但语义信息强,低层特征细节多但噪声大。LR-ASPP 的 decoder 不会像 UNet 那样把特征逐层上采样再拼回去,而是从 backbone 的低层取一个特征图,先经过 1×1 卷积把通道压缩到 128 左右,再和 ASPP 上采样后的结果 concat。这个设计极大减少了 decoder 参数量,也让模型更适合高分辨率输入。

具体到 MobileNet V3 Large,backbone 输出的高层特征是原图 1/32 分辨率,低层分支取的是倒数第二层的 1/8 分辨率。torchvision 的实现里已经把这些连接封装好了,我们调用模型时不需要手动改 backbone 结构。需要注意一点:如果 low-level 特征的通道数太大,concat 之后 decoder 计算量会成倍增加,这就是官方低层分支用 1×1 卷积压通道的原因。我们自己换类别数时,最终分类卷积的输出通道也要对应调整,负责 decode 的部分才不会报尺寸不匹配。

2.3 用 torchvision 构造模型并替换分类头

实际项目里我一般都直接用torchvision.models.segmentation里的lraspp_mobilenet_v3_large,不会自己去写空洞卷积拼接。代码量很短:

import torch from torchvision.models.segmentation import lraspp_mobilenet_v3_large num_classes = 8 model = lraspp_mobilenet_v3_large(pretrained_backbone=True) model.classifier[4] = torch.nn.Conv2d( in_channels=model.classifier[4].in_channels, out_channels=num_classes, kernel_size=1 )

pretrained_backbone=True的意思是只加载 MobileNet V3 在 ImageNet 上的分类预训练权重,LR-ASPP 的 decoder 保持随机初始化。如果设置成pretrained=True,则会加载整个分割模型在 COCO 上的权重。数据量比较小时,我更推荐只加载 backbone,因为 decoder 在 COCO 上学到的语义和道路场景差别较大,直接微调反而可能拖慢收敛。最后一行把 classifier 里最后的 1×1 卷积换掉,in_channels从原有输入通道数取,out_channels改成自己数据集的类别数,其他分支不用动。

2.4 为什么不直接上 DeepLabV3

DeepLabV3 在自动驾驶数据集上确实精度高,但模型体积和推理延迟也高。如果项目对精度要求不是 99% 级别,而是要跑在 Jeston Nano 或者手机端,LR-ASPP 是更现实的起点。我这次的验证集 IoU 到了 0.98,可见轻量模型在小规模道路场景里不会明显吃亏。真正的瓶颈反而转移到数据质量和类别均衡上。如果后面要复现遥感影像语义分割,这一类轻量网络也是先验证可行性的首选,因为遥感大图的推理速度更关键。

3. 道路视频帧如何变成训练数据:标注、格式与 Dataset 设计

3.1 从原始帧到 Roboflow 标准的目录结构

我拿到的文件名长得像frame508_jpg.rf.0616a3ff7fe7efb4052249dcb3e30f3e.jpg,这是 Roboflow 导出时的常见命名,rf后面跟着哈希,用来区分数据版本。如果你从视频里抽帧再标注,建议先统一成下面这种目录结构,否则训练脚本很快会被文件名搞乱:

dataset/ images/ train/ 000.jpg 001.jpg valid/ masks/ train/ 000.png 001.png valid/

我一般先写脚本把frame*_jpg.rf.*这类文件重命名成顺序数字,同时把对应的 mask 文件同步改名。这里有个容易被忽略的坑:文件名里的哈希不是按时间顺序生成的,直接用sorted()排序可能把frame9排到frame10后面,导致图像和 mask 错位。所以一定要按文件名里的帧编号提取整数再排序,不能在目录列表上直接取索引。

3.2 类别设计与颜色映射

道路语义分割数据集的类别定义会影响类别均衡和 IoU 的含义。在这份数据里我定义了 8 类:背景、道路、车道线、车辆、行人、交通标志、建筑、其他物体。标注工具导出后,mask 应该是单通道灰度图,像素值直接用类别编号0~7,不能用 RGB 彩色图。如果发现 mask 是三个通道,必须先用convert('L')转成灰度,否则 CrossEntropyLoss 训练时不会报错,但损失会一直不降,因为你把三通道当成了一个通道。

类别ID类别名称约定颜色 (R,G,B)
0背景(0,0,0)
1道路(128,0,0)
2车道线(255,255,255)
3车辆(0,255,0)
4行人(255,0,255)
5交通标志(0,128,255)
6建筑(128,128,0)
7其他(128,0,128)

这个颜色表不参与训练,只在可视化时用来把预测索引映射回彩色图。拿到别人导出的数据集后,第一件事就是检查类别顺序和颜色表是否一致。我踩过两次坑:一次是类别顺序对不上,验证集可视化里所有mask整体错位;另一次是背景像素被标成 255,这本来是无效区域,但我没在损失函数里设置ignore_index=255,导致损失出现 NaN。正确做法是 Dataset 里统一把 255 映射为 0,或者在 loss 里忽略它。

3.3 归一化、增强和 Dataset 实现

在写 Dataset 前先确定输入尺寸。MobileNet V3 要求输入边长能被 32 整除,我统一用(256, 256),这个分辨率对道路分割够用,也能让训练快不少。归一化必须使用 ImageNet 统计出的 mean/std,因为 backbone 预训练用的是这套参数,如果换成了自己的均值,预训练特征相当于白加载。数据增强我只用随机水平翻转和随机亮度/对比度调整,不用随机裁剪,因为道路图像需要保留完整的透视结构。

from torch.utils.data import Dataset from PIL import Image class RoadSegDataset(Dataset): def __init__(self, image_dir, mask_dir, transform, target_size=(256, 256)): self.images = sorted(image_dir.iterdir()) self.masks = sorted(mask_dir.iterdir()) self.transform = transform self.target_size = target_size def __getitem__(self, idx): img = Image.open(self.images[idx]).convert('RGB') mask = Image.open(self.masks[idx]).convert('L') img = img.resize(self.target_size, Image.BILINEAR) mask = mask.resize(self.target_size, Image.NEAREST) if self.transform: img, mask = self.transform(img, mask) return img, mask

这里的关键是 mask 不能用BILINEAR插值,因为类别边界会被插成中间值,之后argmax会得到错误类别。图片和 mask 如果要一起做翻转,必须保证两者使用同一个随机状态,我习惯写一个自定义 Compose,在内部先torch.set_rng_state让随机状态对齐,或者直接在 transform 里传入seed。还有一点:Dataset 里不需要对 mask 做 one-hot 编码,CrossEntropyLoss会自动处理,保留整数索引张量即可。

3.4 连续帧的采样与数据分布

如果原始素材是视频流,连续帧之间背景变化很小,直接按 1 帧间隔采样会造成训练集高度相似,验证集又出现过拟合的假象。我一般按每秒抽 1~2 帧,再用随机偏移把训练和验证分开,避免同一段道路出现在两个集合里。这份数据里有些帧是车辆静止的,有些是转弯场景,类别分布差异很大。不做帧采样直接训练,loss 会表现很好,但把模型放到新视频上会明显掉点。

4. 迁移学习训练:冻结、超参、损失函数与 0.98 IoU 的真实含义

4.1 冻结骨干网的前期层

迁移学习的关键是决定冻结哪些层。这段训练里我冻结了 MobileNet V3 的前 12 层,只微调后半部分和 decoder。前几层学的是边缘、颜色、纹理这些通用特征,道路图像和 ImageNet 没有本质区别,不需要再更新;高层的语义特征才更贴近具体类别。如果你的数据只有几十张,那可以整个 backbone 都冻结,只训练 decoder。如果反过来把所有层都冻结,模型效率会非常低,精度也会卡在 0.7 附近。数据量越大,可微调的层数越多,这个比例要自己把握。

4.2 为什么用 CrossEntropy 加 Dice Loss

道路分割里背景和道路的像素占比经常超过 80%,只用 CrossEntropyLoss 会学会把大块区域预测成背景和道路,行人、车道线这些小目标全部被忽略。我习惯加一个 Dice Loss,它直接优化区域重叠程度,对小目标更友好。下面实现只依赖 PyTorch 基础算子:

import torch.nn.functional as F def dice_loss(pred, target, smooth=1.0): pred = torch.softmax(pred, dim=1) target = F.one_hot(target, num_classes=pred.shape[1]).permute(0, 3, 1, 2).float() intersection = (pred * target).sum(dim=(2, 3)) union = pred.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) dice = (2.0 * intersection + smooth) / (union + smooth) return 1.0 - dice.mean()

pred是模型输出的原始 logits,target是整数索引 mask。F.one_hot先把它变成[B, H, W, C],再通过permute转成[B, C, H, W]才能和 pred 按通道对齐。smooth是为了防止分子分母都为 0,一般取 1.0。训练时总损失用0.7 * CrossEntropyLoss + 0.3 * dice_loss,这样能保留交叉熵的稳定梯度,同时用小物体上的区域重叠约束拉高召回率。

4.3 评估指标:IOU 计算逻辑

验证集的 IoU 不能直接用accuracy_score,类别不平衡会让准确率虚高。我按类别分别算交集和并集,再取平均:

def compute_iou(pred, target, num_classes): iou_sum = 0.0 for cls in range(num_classes): p = (pred == cls) t = (target == cls) inter = (p & t).sum().float() union = (p | t).sum().float() iou_sum += (inter + 1e-6) / (union + 1e-6) return iou_sum / num_classes

其中predtarget都是[B, H, W]的整数张量,num_classes=8。注意这里没有忽略背景类,如果背景在数据里占了 80%,背景 IoU 很容易接近 1.0,把整体均值拉高。所以我除了看 mean IoU,还会单独打印每个类别的 IoU。0.98 这个数如果主要来自背景和道路,那模型对小目标的学习仍需验证。

4.4 10 个 epoch 的训练循环与超参配置

训练参数我固定在下面这几个值上,方便复现和调参。使用 AdamW 而不是 SGD,因为它对学习率更不敏感。初始学习率3e-4,batch size 16,训练 10 个 epoch。在单张 3090 上大概跑 5 分钟。训练循环代码如下:

optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) criterion = torch.nn.CrossEntropyLoss(ignore_index=255) model.train() for epoch in range(10): for images, masks in train_loader: images = images.cuda() masks = masks.cuda() pred = model(images)['out'] loss = 0.7 * criterion(pred, masks) + 0.3 * dice_loss(pred, masks) optimizer.zero_grad() loss.backward() optimizer.step() print(f'epoch {epoch+1}, loss {loss.item():.4f}')

model(images)['out']取的是 LR-ASPP 的主输出,还有一个['aux']辅助输出,这里没用到。ignore_index=255让之前提到的无效区域不会产生梯度。10 个 epoch 之后验证集 mean IoU 到了 0.98,但这个数字在类别极不平衡时只能参考。我后来检查了每一类:道路和背景的 IoU 都在 0.99,车道线只有 0.83,车辆 0.91,行人 0.76。所以 0.98 是均值,真正要优化的小目标还有空间。要是你的业务只关心车辆和道路,那 0.98 已经足够,如果关心行人,就得在数据增强和多尺度推理上再下功夫。

5. 落地技巧:连续帧预测与 ONNX 导出

5.1 连续帧推理与 mask 可视化

训练完成后最常见的落地方式是把模型接到视频流上。这里有个容易忽略的问题:每帧图像输入前必须做和训练一致的归一化,并且model.eval()torch.no_grad()不能省,否则显存会随着视频长度慢慢涨。我一般用 OpenCV 读帧,然后执行下面这套流程:

import cv2, torch model.eval() cap = cv2.VideoCapture('road.mp4') with torch.no_grad(): for _ in range(int(cap.get(cv2.CAP_PROP_FRAME_COUNT))): ret, frame = cap.read() rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img = torch.from_numpy(rgb).permute(2, 0, 1).float() img = (img / 255 - mean) / std out = model(img.unsqueeze(0).cuda())['out'] seg = out.argmax(dim=1).squeeze(0).cpu().numpy() # 用颜色表把 seg 映射回 RGB 并与原帧叠加

这里的meanstd必须是训练时用到的常量,不能临时改。seg是整数索引图,不能直接当灰度图显示,要先用前面那张颜色表映射成 RGB,再和原帧做 alpha 融合。连续帧之间模型输出互不依赖,这也是静态分割模型好落地的地方,不需要维护时序状态。

5.2 导出 ONNX

部署到 C++ 或 ONNX Runtime 时,我会把模型导出成 ONNX。导出前先确保模型处于 eval 状态,我用opset_version=11而不是更新的版本,兼容性更好。动态 batch 维度可以让视频流里一次处理多帧,减少调度开销:

model.eval() x = torch.randn(1, 3, 256, 256).cuda() torch.onnx.export(model, x, "lraspp.onnx", input_names=["input"], output_names=["output"], opset_version=11, dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}})

导出后建议用 onnxruntime 对同一张图做一次输出对比,误差一般都在 1e-5 以内。如果误差偏大,优先检查是不是没加model.eval(),或者归一化常量不一致。这个流程直接换成遥感影像语义分割也成立,只要把数据切片换成遥感区域,类别改成背景、道路、建筑,训练和推理流程可以原封不动复用,轻量模型跑大范围影像时优势更明显。

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

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

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

立即咨询