简介:一份面向自动驾驶与计算机视觉研究者的街景图像语义分割方法技术文档。文档针对现有语义分割方法分割精度不高、参数量大等问题,提出基于注意力机制的语义分割网络,系统介绍残差网络特征提取、空间注意力模块与通道注意力模块并行细化特征图的原理,并通过Cityscapes和CamVid数据集上的实验对比展示其轻量高效优势,可帮助读者掌握高精度分割方案的设计思路。资源包共包含1个docx文件,大小约367KB,内容涵盖中英文摘要、关键词、引言、方法阐述与实验分析,结构完整,还讨论了该方法在自动驾驶感知、医学图像分析和地理遥感等领域的应用价值,适合作为相关课题研究或论文写作的参考。目前已有240人学习,值得自动驾驶、计算机视觉方向的学生和工程师查阅。
1. 街景图像语义分割为什么绕不开注意力机制
一个反直觉的结论先说在前面:给语义分割模型加上注意力模块,不等于一定涨点。加错位置、加错类型,反而会让 mIoU 掉 1~2 个点,这在街景图像上尤其常见。街景场景的特点是类别多、尺度跨度大、小目标密集——行人、交通标志、车道线、杆件挤在同一张图里,再加上光照差异、遮挡和透视变形,普通卷积的感受野根本覆盖不过来。注意力机制解决的就是这种“看得见但看不准”的问题:让网络在融合特征时,知道哪些通道、哪些位置、哪些长距离关系值得优先处理。
这篇文章围绕“基于注意力机制的街景图像语义分割”展开,覆盖三类注意力(SE 通道注意力、CBAM 空间注意力、多头自注意力)的选型理由、街景数据集制作、模型改造和训练避坑。适合两类人:一是想复现注意力分割方案、却不知道从哪里改代码的算法工程师;二是已经跑通 DeepLabV3+ 这类基线、想通过注意力机制继续提点的人。下面直接按落地顺序讲。
2. 三类注意力在街景任务里的分工:先想清楚再动手
2.1 通道注意力与 SE 模块:让网络知道“该看什么”
SE(Squeeze-and-Excitation)通道注意力机制是所有注意力方案里最轻量、也最容易上手的一种。它的思路很简单:把每个特征图通道压缩成一个全局描述子,再用两个全连接层学习通道之间的重要性,最后用 Sigmoid 得到 0~1 的权重,把原来的特征图逐通道加权。
街景任务里 SE 为什么有效?语义分割最终要输出像素级类别,不同类别高度依赖不同的特征通道。以行车场景为例,车道线靠边缘和方向特征,天空靠低频颜色特征,行人靠纹理和形状特征。如果没有通道注意力,网络在融合高、中、低层特征时,背景通道和目标通道会被一视同仁地叠加。加 SE 之后,模型能学会“这一步优先放大哪些通道”的规则。
PyTorch 里一个可用的 SE 模块实现如下:
import torch from torch import nn class SEBlock(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.squeeze = nn.AdaptiveAvgPool2d(1) self.excitation = nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels), nn.Sigmoid() ) def forward(self, x): b, c, h, w = x.shape y = self.squeeze(x).view(b, c) y = self.excitation(y).view(b, c, 1, 1) return x * y逻辑说明:squeeze用全局平均池化把空间信息压缩成一个点,excitation里的第一个Linear降维到channels // reduction以控制参数量,第二个Linear升维回去,最后 Sigmoid 输出每个通道的权重。代码里的reduction=16是 SE 论文的默认值,表示参数量压缩到原来的 1/16。实际操作中,reduction在 8 到 16 之间比较合理,太小参数量涨得快,对显存不友好;太大通道间的差异会被抹平。
使用边界也要说清楚:SE 只重标定通道,不解决空间混淆。街景里两个不同类别若在相同通道上响应相近,SE 帮不上什么忙。
2.2 空间注意力与 CBAM:在像素级别上做筛选
CBAM(Convolutional Block Attention Module)在 SE 的基础上补齐了空间维度,结构上把通道注意力和空间注意力串联起来:先对特征图做通道重标定,再用空间注意力找出“哪里值得看”。空间注意力用平均池化和最大池化各生成一张图,拼在一起走一个 7×7 卷积,得到空间权重图,与特征图逐像素相乘。
街景场景里空间注意力有价值的地方在于:背景类(道路、建筑、植被)在图像里占大面积,目标类(行人、自行车、交通标志)只占很小的像素比例。普通卷积在空间上是共享权重的,小目标即使响应正确,也会被后续池化和下采样稀释。CBAM 的空间注意力机制能生成一张与输入分辨率一致的权重图,让模型在前向传播时对小目标位置的响应做显式放大。
一个可插入到分割模型里的 CBAM 模块写法:
import torch from torch import nn class CBAM(nn.Module): def __init__(self, channels, reduction=16, kernel_size=7): super().__init__() # 通道注意力 self.avg_pool = nn.AdaptiveAvgPool2d(1) self.max_pool = nn.AdaptiveMaxPool2d(1) self.mlp = nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels) ) # 空间注意力 self.conv = nn.Conv2d(2, 1, kernel_size=kernel_size, padding=kernel_size // 2, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): b, c, _, _ = x.shape avg_out = self.mlp(self.avg_pool(x).view(b, c)).view(b, c, 1, 1) max_out = self.mlp(self.max_pool(x).view(b, c)).view(b, c, 1, 1) channel_weight = self.sigmoid(avg_out + max_out) x = x * channel_weight # 空间注意力 avg_spatial = torch.mean(x, dim=1, keepdim=True) max_spatial, _ = torch.max(x, dim=1, keepdim=True) spatial_feat = torch.cat([avg_spatial, max_spatial], dim=1) spatial_weight = self.sigmoid(self.conv(spatial_feat)) return x * spatial_weight逻辑说明:通道注意力部分在 SE 的基础上多了一个最大池化分支,最大池化捕捉的是“最强烈的通道响应”,平均池化捕捉整体分布,两者相加比单独使用平均池化更稳定。空间注意力部分,dim=1求均值得到通道维度的平均响应,max得到通道维度的最大响应,两张图拼成 2 通道后过一层7×7卷积。kernel_size默认 7,因为这个卷积要覆盖比较大的空间邻域;窗口太小,空间权重图容易变成噪点图。
注意:CBAM 的空间注意力是在通道注意力之后计算的,传入的是已经重标定过的特征图。把它放在 Backbone 每个 Stage 的输出位置,比放在网络最后更有效;最后那层特征图分辨率太低,空间权重图没有足够细节。
2.3 自注意力与多头注意力:QKV 在长距离依赖里的代价
自注意力机制和前面两类注意力有本质区别。SE 和 CBAM 是在已有特征图上计算权重,而自注意力是把特征图的每个位置当成一个“查询”,在整张图的范围里搜索与它相关的其他位置,重新生成特征。这就是常说的 QKV 结构:Q(Query)代表当前位置要查询什么,K(Key)代表其他位置能提供什么,V(Value)代表被聚合的内容。
多头注意力机制是自注意力的扩展,把 Q、K、V 切成多个头,每个头在不同表示子空间里计算相关性,最后拼回去。街景图像里最吃长距离依赖的是道路延伸和遮挡推理:一条车道被车辆遮挡,要继续判断它的走向,需要跨越几十甚至上百像素建立联系;一个行人被栏杆挡住一半,要判断完整轮廓,得从远处相似的纹理推测。这些场景中,局部卷积核做不到,自注意力可以做。
但代价非常直接——计算复杂度是 O(N²),N 是位置的个数。在街景分割里,输入通常是 1024×2048 级别,即使在 1/8 下采样的特征图上也有 128×256 个位置,全局自注意力一张图就能吃光显存。我一般不会在街景分割里直接做全局多头自注意力,常见的可靠做法是两种替代:只在高层的低分辨率特征图上做,比如在 1/16 或 1/32 的特征图上,用 256 个通道以内的多头自注意力;或者用轻量级的坐标注意力(Coordinate Attention)来近似长距离依赖,它把特征图按行、列方向分别做池化,只建立“行内”和“列内”的长距离关系,复杂度是 O(H+W)。
2.4 选型对照:不同场景下注意力模块怎么选
| 模块 | 参数量增量 | 解决的问题 | 适合街景哪类痛点 | 落地位置 |
|---|---|---|---|---|
| SE | 极小 | 通道间重要性 | 类别特征差异大 | Backbone 各 Stage 输出后 |
| CBAM | 较小 | 通道 + 空间显著区域 | 小目标易被稀释 | Backbone 各 Stage 后,或 ASPP 前 |
| 多头自注意力 | 大 | 全局长距离依赖 | 车道延伸、遮挡推理 | 高层 1/16 特征图 |
| 坐标注意力 | 小 | 跨空间长距离依赖 | 道路延伸,兼顾效率 | 替换自注意力,插在 Backbone 后段 |
选型顺序上,不要一上来就上多头自注意力。我的顺序是:先把基线跑通,加 SE 看涨跌,再加 CBAM 看涨跌,最后才尝试自注意力。每加一个模块跑一次消融,才能分辨提点是“注意力机制带来的”还是“参数量增加带来的”。
3. 把街景图像做成语义分割数据集:标签合并与增强
3.1 从 Cityscapes 原始标签到训练类别:映射脚本
街景语义分割的首选公开数据集是 Cityscapes,它的 gtFine 标签有 34 个类别,但官方推荐的训练类别只有 19 类。原始标签里,每个类别用 RGB 颜色表示,比如道路是 (128, 64, 128),行人属于大类,具体到 rider、person 等子类又会细分。训练分割模型之前,必须把彩色标签图转换成单通道索引图,每个像素存 0~18 的类别编号,忽略区域存 255。
转换脚本的核心是一个颜色到索引的映射字典:
import numpy as np from PIL import Image import os # Cityscapes 19 类对应的颜色(RGB) cityscapes_colors = { 0: [128, 64, 128], # road 1: [244, 35, 232], # sidewalk 2: [70, 70, 70], # building 3: [102, 102, 156], # wall 4: [190, 153, 153], # fence 5: [153, 153, 153], # pole 6: [250, 170, 30], # traffic light 7: [220, 220, 0], # traffic sign 8: [107, 142, 35], # vegetation 9: [152, 251, 152], # terrain 10: [70, 130, 180], # sky 11: [220, 20, 60], # person 12: [255, 0, 0], # rider 13: [0, 0, 142], # car 14: [0, 0, 70], # truck 15: [0, 60, 100], # bus 16: [0, 80, 100], # train 17: [0, 0, 230], # motorcycle 18: [119, 11, 32], # bicycle } def convert_color_label_to_index(label_path, output_path): label_img = np.array(Image.open(label_path).convert('RGB')) h, w, _ = label_img.shape index_map = np.full((h, w), 255, dtype=np.uint8) for idx, color in cityscapes_colors.items(): mask = np.all(label_img == np.array(color), axis=-1) index_map[mask] = idx # 忽略区域:Cityscapes 提供的 ignore_label 一般是 255 Image.fromarray(index_map).save(output_path)逻辑说明:遍历 19 个类别的颜色,在标签图里逐像素判断 RGB 是否完全匹配,匹配则写入对应的索引。index_map初始化为 255,保证不属于 19 类的像素(比如自行车拖车、道路边缘的杂项)不参与训练。转换后要再检查一遍,我见过有人把Image.open漏了convert('RGB'),读到灰度模式导致所有 mask 匹配失败,整个标签图全变成 ignore,训练出来的模型效果和随机猜测差不多。
更稳妥的方法是直接复用 Cityscapes 官方提供的labels.py里的trainId映射,它对ignoreInEval和hasInstances这些属性处理得更完整。如果你用的是 LabelMe 或其他标注工具生成的数据,本质思路一样:建立一个“标注类别 → 训练索引”的映射,再做逐像素转换。
3.2 街景专属增强策略:光照与尺度优先
街景图像和普通自然图像最大的区别在于存在强烈的光照变化和透视规律。同一段路,晴天、阴天、逆光、夜间拍出来的效果差异极大,而卷积模型对光照非常敏感。常用的随机旋转、翻转和裁剪可以用,但有两个街景专属的坑要注意:
第一,不能随便做垂直翻转。垂直翻转后,天空到了图像下方,道路到了上方,真实街景中这种分布几乎不会出现,会让模型学习到错误的先验。我一般只做水平翻转,概率设 0.5。第二,随机缩放的比例不能太小。街景里小目标多,如果缩放过狠,行人可能变成几个像素,标签也跟着被压缩出锯齿,容易把类别边缘学坏。我一般把随机缩放控制在 0.5~2.0 倍,配合随机裁剪保证长边对齐。
用 Albumentations 实现一套街景增强管道:
import albumentations as A train_transform = A.Compose([ A.RandomScale(scale_limit=(0.5, 2.0), p=0.8), A.PadIfNeeded(min_height=None, min_width=None, border_mode=0), A.RandomCrop(height=512, width=1024, p=1.0), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast( brightness_limit=0.3, contrast_limit=0.3, p=0.8 ), A.HueSaturationValue(hue_shift_limit=10, sat_shift_limit=20, val_shift_limit=30, p=0.8), ])逻辑说明:RandomScale和RandomCrop配合是为了让模型对小尺度目标和大尺度目标都有感知;PadIfNeeded的border_mode=0表示用常量填充,防止缩放后边缘出现非整数对齐。RandomBrightnessContrast模拟晴天和阴天的光照差,HueSaturationValue模拟不同时间段天空和植被的颜色偏移。
参数设置上,brightness_limit我建议调到 0.3,太小对夜间场景几乎没有增强效果,太大则会让车道线颜色失真,干扰类别判断。HueSaturationValue里hue_shift_limit不要超过 15,街景中红绿灯、交通标志对色相敏感,过度扰动会让模型分不清红灯和绿灯。
3.3 类别不平衡:车道、行人和杆件不能一视同仁
Cityscapes 19 类中,道路和建筑占据 60% 以上的像素,而行人和交通标志可能连 2% 都不到。直接用交叉熵损失,模型会把所有像素预测成道路和建筑,因为这么做 loss 也很低。计算每个类别的权重是解不平衡的基础手段:
def compute_class_weights(label_dir, num_classes=19): counts = np.zeros(num_classes, dtype=np.float64) for f in os.listdir(label_dir): label = np.array(Image.open(os.path.join(label_dir, f)), dtype=np.uint8) for c in range(num_classes): counts[c] += np.sum(label == c) total = counts.sum() weights = total / (counts + 1e-6) weights = weights / weights.max() return torch.tensor(weights, dtype=torch.float32)逻辑说明:统计每个类别像素出现的次数,用总像素数除以类别像素数得到权重,出现越少的类别权重越大。注意加1e-6防止某个类别在子集里完全没出现导致除零。weights / weights.max()是归一化,把最大权重压到 1,避免梯度爆炸。
有了权重之后,就把这个weights传给nn.CrossEntropyLoss(weight=weights, ignore_index=255)。我在实际项目里发现,这一步做完,行人、交通标志的 mIoU 通常能提升 5~8 个点,比重堆模型结构还管用。如果你的数据里类别本身就不均衡,这个脚本全套迁移过来即可。
4. 在 DeepLabV3+ 上插注意力:模型改法与训练超参
4.1 基线选择:ResNet50 还是 MobileNetV3
街景语义分割的落地场景决定 backbone 怎么选。常见做法是:如果是离线实验、以精度优先,用 ResNet50 或 ResNet101;如果要做成车载或嵌入式实时系统,用 MobileNetV3。MobileNetV3 本身就是带 SE 通道注意力机制的,它的 bottleneck 里内置了 SE 模块。这也意味着,选 MobileNetV3 作为 backbone 时,“加注意力”已经有一部分是模型自带的,后续再叠加 CBAM 需要评估是否真的有增量。ResNet50 则比较“干净”,没有自带注意力,插入 SE 或 CBAM 的消融结果更可信。
从街景分割的特点来看,我一般推荐先从 ResNet50 开始。理由很现实:Cityscapes 训练集只有 2975 张图,分辨率又高,ResNet101 很容易过拟合;ResNet50 配合 ImageNet 预训练权重,能快速验证注意力模块的效果。工程上,ResNet50 的 ASPP 输入特征图是 2048 通道的,即使插入 CBAM,显存占用也在可控范围。
4.2 注意力模块的插入位置:Backbone 各阶段还是 ASPP 分支
这是注意力分割最容易踩坑的地方。同样的 CBAM,插在 ResNet 的 stage2 之后和插在 ASPP 的每个分支之后,效果差别很大。我的经验是:在 Backbone 的每个 stage 输出之后插入 SE 或 CBAM,做的是“特征提取阶段的通道和空间筛选”;在 ASPP 的多尺度分支后面插入,做的是“多尺度特征融合后的筛选”。两者不冲突,但优先级不同。显存有限时,先插 Backbone 的 stage3、stage4,再插 ASPP 最后一个融合层。
修改 ResNet 的示例,把 CBAM 加到每个 stage 输出之后:
from torchvision.models.resnet import ResNet, Bottleneck class AttentionResNet(ResNet): def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = self.relu(x) x = self.maxpool(x) x = self.layer1(x) x = self.attention1(x) x = self.layer2(x) x = self.attention2(x) x = self.layer3(x) x = self.attention3(x) x = self.layer4(x) x = self.attention4(x) return x def build_attention_resnet50(attention_type='cbam', channels=[256, 512, 1024, 2048]): model = AttentionResNet(Bottleneck, [3, 4, 6, 3]) for i, ch in enumerate(channels): if attention_type == 'cbam': setattr(model, f'attention{i + 1}', CBAM(ch)) elif attention_type == 'se': setattr(model, f'attention{i + 1}', SEBlock(ch)) return model逻辑说明:继承torchvision.models.resnet.ResNet,在四个 stage 的输出后插入注意力模块。channels参数对应 ResNet50 四个 stage 的输出通道数。CBAM 在 stage 的输出分辨率上计算空间注意力,stage1 输出分辨率是输入图的 1/4,空间注意力图还保留足够细节;stage4 输出分辨率只有 1/32,空间注意力基本退化成通道注意力,这时候改插 SE 更划算。
更激进的做法是把 CBAM 插到 ASPP 的每个分支里。ASPP 用 1×1 卷积和 3×3 空洞卷积并行捕捉不同感受野,注意力模块可以放到每个分支最后。这个方案会增加约 10% 的显存,但多尺度融合后的特征会被空间注意力重新校准,对靠细长纹理区分的类别(车道线、杆件)有帮助。
4.3 训练配置与关键超参
街景语义分割训练的典型配置如下:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 输入分辨率 | 512×1024 | 在精度和显存之间平衡 |
| 初始学习率 | 0.02(ResNet50) | 配合 poly 策略,不用 step 衰减 |
| 学习率策略 | poly,power=0.9 | 训练后期学习率平缓下降 |
| 优化器 | SGD,momentum=0.9,weight_decay=1e-4 | 比 Adam 泛化性好 |
| Loss | 加权交叉熵 + Lovász-Softmax | 加权 CE 保底,Lovász 提 mIoU |
| Batch size | 8(单卡 V100 32G) | 多卡时用同步 BN |
| 训练轮数 | 120 epoch | Cityscapes 2975 张图足够收敛 |
| 混合精度 | 开启 AMP | 显存减半,训练加速 |
poly 学习率策略的 PyTorch 写法:
def poly_lr(epoch, max_epoch, initial_lr, power=0.9): return initial_lr * (1 - epoch / max_epoch) ** power逻辑说明:poly 策略让学习率随着训练进度平滑衰减,前 50 个 epoch 保持较高学习率,后面越来越小。相比每 30 个 epoch 直接除以 10 的 step 策略,poly 在分割任务里通常能多涨 1~2 个 mIoU。power=0.9是 DeepLabV3+ 的常用默认值,改到 1.0 会让学习率衰减更快,适合训练轮数更少的场景。
训练时还要注意一点:ResNet backbone 的 batch norm(BN)在迁移到不同分辨率时会不稳定。多卡训练时用同步 BN(torch.nn.SyncBatchNorm.convert_sync_batchnorm),单卡时尽量用预训练权重附带的标准 BN。街景图像的 batch 里,白天夜景混在同一个 batch,BN 统计会被拉偏,这也是很多人忽略的玄学问题。
5. 注意力分割训练避坑清单:5 次翻车换来的经验
5.1 现象:训练 loss 一路下降,mIoU 却纹丝不动
第一次在 ResNet50 + DeepLabV3+ 上加 SE 就遇到这个问题,训练了 30 个 epoch,CE loss 从 1.2 降到 0.5,mIoU 停在 58% 左右。
原因很快定位到:我没有计算类别权重,直接用了默认的交叉熵。街景里道路、建筑占大头,模型把主要精力放在把背景类分的更“精细”上,小目标学不动。另一个隐藏原因,是我把标签映射写错了,部分训练图的 ignore 区域占到了 30% 以上,模型在“看不见”的像素上没有梯度。
解决方法是两件事同时做:按第 3 章的compute_class_weights计算类别权重,并在训练前统计一次标签的 ignore 比例。我要求这个比例必须低于 10%,如果超过,说明映射脚本有 bug。改完之后同样的模型 mIoU 从 58% 涨到 64%。
5.2 现象:加上 CBAM 之后显存直接 OOM
ResNet50 + ASPP 原本能塞进 batch size 8(V100 32G),在 stage3、stage4 插入 CBAM 后 batch size 降到 4 还偶尔 OOM。
原因是 CBAM 的空间注意力需要保留原始分辨率的特征图来计算空间权重,stage3 输出是 1/8 分辨率、1024 通道,这个特征图本身就很大;空间注意力又额外增加了一份显存开销。其次是 batch size 没降、输入分辨率没降,两者叠加直接爆显存。
解决:先降输入到 384×768,把 batch size 稳住;再考虑把 stage1、stage2 的 CBAM 去掉,只保留 stage3、stage4。我在多个项目里验证,stage1、stage2 的注意力增益很小,去掉后 mIoU 几乎不变,显存省下将近 20%。如果还 OOM,就把 stage4 的 CBAM 换成 SE,让高层的“空间注意力”退化为“通道注意力”。
5.3 现象:行人、交通标志仍然漏检,mIoU 上不去
注意力模块加上了,loss 也正常,但行人、摩托车这些占像素少、形状多变的类别,预测结果还是大面积漏检。
原因有两个:第一,输入分辨率不够。512×1024 下,一个行人可能只有 24×60 像素,经过五次下采样后只剩 3×7 像素,任何注意力机制都无法从 3×7 的响应里恢复轮廓。第二,加权交叉熵权重设置过猛,小目标权重太大,模型开始把道路边缘误判成行人,产生了大量假阳。
解决:输入分辨率提升到 768×1536(batch size 相应降到 4),并把类别权重的上限压到 10 以内,避免少数类别过拟合。另外,我在分割头里加了一个辅助深度监督:在 stage3 输出位置加一个小的分类头,把监督信号直接传到较低层,小目标的梯度不再等 stage4 慢慢往回传。辅助头在推理时去掉,不影响速度。
5.4 现象:夜间和逆光场景大面积误分类
这个坑很邪门:白天测试集 mIoU 72%,放到夜间街景图上,天空被预测成建筑,阴影里的路面被预测成植被,逆光下的行人轮廓基本是错的。
原因大概率出在训练数据的色彩分布上。Cityscapes 基本是白天良好光照采集的,模型学到的是“亮的是天空、暗的是道路”这种统计关系,光照一变就翻车。注意力机制在这里不是没用,但它只学习特征通道和位置的权重,并没有解决图像整体光照差异。
解决:增强阶段加入光照扰动,用RandomBrightnessContrast加上HueSaturationValue,把亮度扰动范围调到 0.35,色相扰动适度收紧到 10。如果项目里有少量夜间数据,一定混合进训练集;不要把夜间作为单独的评价集去看,模型没见过的分布硬测没有参考价值。
5.5 现象:模型推理速度不达标,多头自注意力成了瓶颈
在 1/16 特征图上做多头自注意力后,mIoU 确实涨了约 1.5 个点,但推理耗时从 25ms 变成 48ms,换到 Jetson 这类边缘设备上更惨。
原因:多头自注意力在 128×256 的特征图上计算 QKV 矩阵乘法的复杂度是 O(N²),N=32768,一张图要算上亿次乘法。参数量不大,但浮点计算量非常大。
解决:我把它换成了坐标注意力或者窗口注意力。窗口注意力限制在 7×7 的局部窗口内,相当于一个动态的、可学习的空间卷积,速度跟 CBAM 差不多。如果必须保留全局自注意力,就把特征图下采样到 1/32 再做,并且只在 stage4 输出后做一次,不要每个 stage 都做。
6. 验证注意力机制有没有真起作用:可视化和消融实验
6.1 用 Grad-CAM 对比注意力图的差异
注意力模块加完之后,第一步不是看 mIoU,而是看它输出的空间注意力图到底关注在哪里。用 Grad-CAM 对街景输入图生成类激活热力图,和模型的预测图叠在一起看,能快速发现注意力是不是学偏了。比如,如果模型对行人类别的 Grad-CAM 高亮区域大部分集中在道路和建筑上,说明注意力被背景带偏了。
简短实现一个 Grad-CAM 流程:
def grad_cam(model, input_tensor, target_class): model.eval() features = {} def hook_fn(name): def hook(module, input, output): features[name] = output return hook target_layer = model.backbone.layer4[-1] handle = target_layer.register_forward_hook(hook_fn('feat')) output = model(input_tensor) logits = output[0, target_class] logits.backward() handle.remove() grads = target_layer.weight.grad # 简化示意,实际用 hook 存梯度 weights = grads.mean(dim=(2, 3), keepdim=True) cam = (weights * features['feat']).sum(dim=1, keepdim=True).relu() return torch.nn.functional.interpolate(cam, size=input_tensor.shape[-2:], mode='bilinear', align_corners=False)逻辑说明:Grad-CAM 的核心是用目标类别的梯度去加权特征图,权重大的通道说明对目标类别贡献大。代码里我用register_forward_hook取到 feature map,实际工程中还需要注册一个 backward hook 来取梯度,这里做了简化。比对时,把 CAM 热力图和注意力模块输出的spatial_weight图叠加显示,如果两者高亮区域一致,说明空间注意力和类别判别区域对齐;如果不一致,说明注意力被别的东西吸引走了。
6.2 消融实验的排布
验证注意力增益的唯一可靠方法是消融实验,而且顺序很重要。按这个表格逐项做:
| 模型配置 | mIoU | 参数量增量 | 单张推理耗时 |
|---|---|---|---|
| DeepLabV3+ ResNet50 基线 | — | — | — |
| 基线 + SE | — | — | — |
| 基线 + CBAM | — | — | — |
| 基线 + SE + CBAM | — | — | — |
| 基线 + 多头自注意力(高层) | — | — | — |
看结果的顺序也讲究:先看参数量增量能不能带来对等的 mIoU 提升,再看推理耗时涨了多少。街景分割如果是为了落地到量产,耗时比参数量更重要。我通常要求:一个注意力模块至少带来 0.8 个 mIoU 的提升才值得保留,否则就是“为了加而加”。
最后说一个我自己的习惯:每一次改模型都保存一个独立的配置文件和评估结果,哪怕只是改了reduction从 16 到 8。注意力模块的超参数非常敏感,同一个模块在不同 backbone、不同分辨率下,表现可能完全相反。把每一版结果记录在表格里,翻车时才有后悔药可查。
希望这篇记录能帮你在街景语义分割的注意力改造上少走几趟弯路。
本文还有配套的精品资源,点击获取