前阵子帮一个做自动驾驶感知的团队排查分割精度卡住的问题,骨架从 ResNet 换到 EfficientNet 再到 Swin,mIoU 就是卡在 79% 上下浮不动。后来我把注意力从 backbone 挪到分割头上,发现他们一直在用单分支的普通卷积做分类,缺少一个能同时感知“近处卡车”和“远处行人”的上下文模块。等我把空洞空间金字塔池化(ASPP)接上去,mIoU 直接涨了 2 个多点。
这篇文章就围绕 ASPP 展开,我会把它拆到不能再拆:它解决了什么问题、空洞卷积的账怎么算、标准结构里的每一条分支各自在做什么、PyTorch 怎么实现、工程落地会踩哪些坑,最后聊聊 DenseASPP、LR-ASPP 这些变体,以及现在 Transformer 分割模型大行其道的背景下,ASPP 还有没有存在价值。想做语义分割、理解 DeepLabV3 源码、或者只是想给检测/分割模型加一个多尺度上下文模块的,都可以把这篇当参考。
1. 为什么分割网络绕不开“多尺度上下文”
1.1 一个让所有分割模型头疼的问题
语义分割任务的本职,是给图像里的每个像素打上类别标签。听起来简单,但真正跑起来你会发现,最大的敌人不是分类不准,而是同一个类别的目标在画面里的尺寸能差出几十倍。
拿自动驾驶场景举例:一辆近处的卡车可能占满屏幕的三分之一,远处同一辆车可能只有几十个像素宽。分类网络里,这种尺度差异还算好处理,因为最后要做的是全局平均池化,把整张图的信息浓缩成一个向量,目标小一点大一点影响不大。但分割不一样,它要在保留空间位置的前提下对每个像素做预测。固定感受野的卷积核,对近处的卡车能覆盖完整轮廓,到远处那辆小车上,一个 3x3 卷积看到的就只是几个点,很难判断它到底是车、是路障还是绿化带。
这种问题在遥感图像里更夸张。一栋大楼和一栋别墅在图像上的尺度差异可能超过十倍,道路更是横跨整张图。如果分割头没有多尺度上下文提取能力,小物体漏检和大物体内部断裂几乎是必然的。
我早期做分割项目时走过一个弯路:模型效果不好就换 backbone,从 ResNet50 换到 ResNet101,再换到 HRNet,指标涨得微乎其微。后来才反应过来,backbone 提取的是“从像素到语义”的逐步抽象,但到了分割头,它需要的是“从单尺度语义到多尺度语义”的聚合。这一步没做好,前面提的特征再强也发挥不出来。
1.2 多尺度的常见解法:图像金字塔与编码器-解码器
在 ASPP 之前,解决多尺度问题主要有两条路。
第一条是图像金字塔。把输入图像缩放成多个尺度,分别送进同一个网络做推理,最后把多张概率图融合起来。优点很明显,每个尺度都能利用预训练权重,不需要重新设计结构。缺点更明显:推理时间直接翻几倍,训练时反向传播也要过多个尺度,显存开销成倍增长。DeepLabV2 当年在测试阶段用过多尺度融合加 CRF 后处理,效果确实好,但部署时这个成本不是谁都能接受的。
第二条是编码器-解码器结构。典型代表是 U-Net 和 FCN 的变体,通过跳连把浅层高分辨率特征和深层语义特征拼接起来,用解码器逐步恢复空间细节。这条路的优势是边缘细节恢复得好,缺点是解码器关注的更多是“位置信息”而不是“上下文信息”。浅层特征虽然有高分辨率,但语义抽象程度低,对小目标周围环境的建模能力有限。
ASPP 的思路和这两条路都不一样。它不动输入图像,也不引入解码器,而是在同一个特征图上并行跑多个不同空洞率的空洞卷积,每个分支负责不同范围的上下文,最后拼在一起。相当于只用一次前向,就拿到了“看局部、看近邻、看远处、看全局”的多种视野,计算量比图像金字塔小得多,结构又比编码器-解码器更直接地针对上下文建模。这也是它在 DeepLabV3 里被当作核心模块的原因。
2. 空洞卷积:先算清“感受野”这笔账
2.1 标准卷积为什么“看不远”
空洞卷积是 ASPP 的砖块,想理解 ASPP,先把空洞卷积这笔账算清楚。
标准 3x3 卷积的感受野就是 3x3。想扩大感受野,最直接的办法是堆叠卷积层:两层 3x3 卷积堆起来,感受野变成 5x5;三层变 7x7。但这种扩张是线性的,每多一层只增加有限的感受野宽度,网络深度的代价却不小。
另一个常见的扩大感受野手段是池化。池化确实能把感受野撑大很多,但代价是特征图分辨率降低。分割任务对空间位置极度敏感,backbone 已经下采样 16 倍甚至 32 倍了,再为了感受野丢分辨率,小目标的边缘信息就彻底没了。
所以分割模型需要一种“只扩大视野、不缩小分辨率”的卷积操作,空洞卷积就是为这个场景设计的。
2.2 空洞卷积:插空的代价与收益
空洞卷积的直观理解是:在标准卷积核的像素之间插入空格,让卷积核“撑大”,但参与计算的采样点数量不变。
比如一个 3x3 的卷积核,空洞率 rate=2,等效核大小是 5x5,但里面只有原来那 9 个位置有参数,其余位置全是 0。放在特征图上滑的时候,它每隔一个像素采一个点,视野覆盖了 5x5 的区域,实际计算量还是 9 次乘加。
有效核大小的公式是:
k_eff = dilation × (k - 1) + 1
- rate=1:有效核是 3x3
- rate=6:有效核是 6×2+1=13x13
- rate=12:有效核是 12×2+1=25x25
- rate=18:有效核是 18×2+1=37x37
- rate=24:有效核是 24×2+1=49x49
这个“免费”是关键。扩张到 49x49 的有效视野,参数量还是 9 个。对于一个有 256 个输入通道、256 个输出通道的卷积层来说,参数量始终是 256×256×9,跟 rate 无关。计算量也基本不变,因为每个输出位置还是做固定次数的乘加。
代价是什么呢?空洞卷积的采样点是稀疏的。rate=18 时,它在 37x37 的范围内只采 9 个点,中间大量区域被跳过。这意味着它能“感知”到大范围内的信息,但对这些信息的利用是离散的。所以空洞卷积不能无脑叠加太大 rate,否则单个采样点之间的关联会弱到网络难以学习。
2.3 一张表看清 rate 与感受野的关系
下面是 3x3 卷积核在不同 rate 下的有效感受野和采样点密度,这张表我在做 ASPP 参数选择时经常翻出来对照。
| rate | 有效核大小 | 实际采样点数 | 相对感受野扩张倍数 | 特征图上连续覆盖能力 |
|---|---|---|---|---|
| 1 | 3x3 | 9 | 1x | 连续 |
| 2 | 5x5 | 9 | 约 2.8x | 隔 1 采 1 |
| 6 | 13x13 | 9 | 约 18.8x | 隔 5 采 1 |
| 12 | 25x25 | 9 | 约 69.4x | 隔 11 采 1 |
| 18 | 37x37 | 9 | 约 152x | 隔 17 采 1 |
| 24 | 49x49 | 9 | 约 267x | 隔 23 采 1 |
从这里也能看出 ASPP 为什么用一组不同 rate 而不是只用一个超大 rate。超大 rate 虽然看得远,但采样点太稀疏,对局部结构的感知能力下降得厉害。多个不同 rate 并行,相当于用不同疏密程度的“渔网”去捞信息,近处的细节用小网眼的网,远处的轮廓用大网眼的网,最后把捞上来的信息拼在一起。
3. ASPP 的核心结构:四条卷积支路加一条池化支路
3.1 从 DeepLabV2 到 DeepLabV3:ASPP 是怎么演变的
ASPP 最早出现在 DeepLabV2,当时的结构是四个并行的 3x3 空洞卷积,rate 分别取 6、12、18、24。到了 DeepLabV3,这个结构做了一次重要调整:第一条分支从 3x3 空洞卷积换成了 1x1 普通卷积,三个空洞卷积的 rate 变成 6、12、18,另外增加了一条全局平均池化分支,最后所有分支的输出拼接后过一个 1x1 卷积降维。
这个调整背后有两个考量。第一,backbone 到高层的特征图分辨率已经比较低,过大的 rate(比如 24)在低分辨率特征图上的有效采样点非常稀疏,可能大部分落在 padding 区域,分支退化成“摆设”。第二,全局平均池化能直接提供整张图的全局上下文,比靠大 rate 硬撑出来的“伪全局”更可靠。
所以标准 ASPP(DeepLabV3 版本)的分支组成是:
| 分支 | 操作 | 输出尺寸 |
|---|---|---|
| 1 | 1x1 Conv,256 通道 | 与输入相同 |
| 2 | 3x3 Conv,rate=6,256 通道 | 与输入相同 |
| 3 | 3x3 Conv,rate=12,256 通道 | 与输入相同 |
| 4 | 3x3 Conv,rate=18,256 通道 | 与输入相同 |
| 5 | 全局平均池化 + 1x1 Conv,256 通道,再上采样 | 与输入相同 |
五个分支的输出在通道维度上拼接,得到 5×256=1280 通道的特征图,再过 1x1 卷积降到 256 通道,同时加了一个 Dropout(p=0.5)做正则。
3.2 每条分支各管多远的上下文
用语言描述每条分支的“职责”,比记结构更有用。
1x1 卷积分支负责的是“本位置”的信息。它不融合邻域,只做通道间的线性组合,相当于把高层特征重新映射了一遍。这一支保证了即使其他分支因为各种原因失效,ASPP 的输出里至少还有当前像素自身的强语义特征。
rate=6 的分支有效感受野是 13x13,对应的是“小邻域”。对于照片里十几像素宽的小目标,这个范围基本能覆盖住目标本身加一点周围环境。小目标检测主要靠这一支。
rate=12 的分支有效感受野是 25x25,算“中等上下文”。中等体量的目标,比如行人、车辆,加上它们周边的环境信息,基本在这个范围。它帮助区分“路上的车”和“路边的车”,这种区分在纯像素级别是看不出来的。
rate=18 的分支有效感受野是 37x37,对应“大范围上下文”。大型目标的整体一致性靠这一支维持。比如说一个大卡车横跨整个特征图,单靠小感受野分支可能把车头和车身判成两个东西,大感受野分支能看到它们属于同一个连通区域,输出自然更平滑。
一条分支覆盖不开的,由 1x1 卷积在拼接后自适应地分配权重。这也是 ASPP 结构“自适应”的来源:网络自己学每一条分支的重要程度。
3.3 全局平均池化分支为什么必须留
很多人第一次看 ASPP 结构时会疑惑:已经有三个大空洞率分支了,全局平均池化分支不是重复吗?
实际差异非常大。空洞卷积的感受野再大,也只是在局部区域里采样,采样点覆盖不到整张图。假设特征图是 32x32,rate=18 的有效核是 37x37,虽然已经超过特征图尺寸,但实际采样中大量位置落在 padding 区域或特征图边界外,真正能采到有效像素的点有限。全局平均池化则不同,它把整张特征图所有位置取平均,等效于感受野覆盖全图,一锤子买卖拿到全局统计量。
我在训练初期可视化过 ASPP 各分支输出,感受很深:三个空洞卷积分支输出的是带着明显局部纹理的模式图,而全局池化分支的输出是一张非常平滑、只有大区域差异的响应图。后者对大目标内部一致性的贡献,前面几个分支给不了。
全局池化分支的操作细节也值得注意:先用 AdaptiveAvgPool2d(1) 把特征图压成 1x1,过 1x1 卷积升到 256 通道,然后用双线性插值上采样回原始特征图尺寸,最后参与拼接。这个上采样过程会引入一些“块状感”,不过后面的 1x1 卷积和分割头可以学习修正它,实际影响不大。
3.4 ASPP、SPP 和 PPM:三个容易混淆的模块
ASPP 经常被拿来和空间金字塔池化(SPP)、PSPNet 里的金字塔池化模块(PPM)比较。很多初学者会把它们混为一谈,这里做一次彻底区分。
SPP 来自目标检测领域的 SPPNet。它的核心目的是让网络接受任意尺寸输入,通过多级池化(比如 1x1、2x2、4x4)把特征图压成固定长度的向量,再接全连接层。它改变的是特征图的空间尺寸,输出不是密集预测所需要的“与输入同尺寸”。
PPM 来自 PSPNet,结构和 ASPP 有点类似:多个不同尺寸的池化核(1x1、2x2、3x3、6x6)对特征图做池化,各自过 1x1 卷积后再上采样回原尺寸,最后拼接。它和 ASPP 的区别在于:PPM 靠不同大小的池化窗口切分区域来获取不同尺度的上下文,是“区域级”的;ASPP 靠不同空洞率的卷积核做滑动采样,是“像素邻域级”的。PPM 的池化结果天然带一种“分块统计”的语义,ASPP 则更连续。
三者的关系可以这样记:SPP 解决输入尺寸不定,PPM 用多级池化做上下文,ASPP 用多空洞率卷积做上下文。ASPP 和 PPM 目标相近,但实现机制完全不同,在部署时选哪个要结合 backbone 和任务特点,没有绝对的好坏。
4. 手撕一个 ASPP:PyTorch 实现与维度校验
4.1 可以直接抄的 ASPP 代码
直接给出一份可运行的 PyTorch 实现,完整对应 DeepLabV3 标准结构。
import torch import torch.nn as nn import torch.nn.functional as F class ASPPConvBlock(nn.Sequential): """3x3 空洞卷积 + BN + ReLU""" def __init__(self, in_channels, out_channels, dilation): super().__init__( nn.Conv2d( in_channels, out_channels, kernel_size=3, padding=dilation, dilation=dilation, bias=False, ), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ) class ASPPPoolBranch(nn.Module): """全局平均池化 + 1x1 卷积 + 上采样""" def __init__(self, in_channels, out_channels): super().__init__() self.pool = nn.AdaptiveAvgPool2d(1) self.conv = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ) def forward(self, x): size = x.shape[-2:] y = self.pool(x) y = self.conv(y) return F.interpolate(y, size=size, mode="bilinear", align_corners=False) class ASPP(nn.Module): """空洞空间金字塔池化模块""" def __init__(self, in_channels, out_channels=256, rates=(6, 12, 18)): super().__init__() self.branches = nn.ModuleList( [ # 1x1 卷积分支 nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ), # 三个空洞卷积分支 ASPPConvBlock(in_channels, out_channels, rates[0]), ASPPConvBlock(in_channels, out_channels, rates[1]), ASPPConvBlock(in_channels, out_channels, rates[2]), # 全局池化分支 ASPPPoolBranch(in_channels, out_channels), ] ) self.project = nn.Sequential( nn.Conv2d(out_channels * 5, out_channels, kernel_size=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), nn.Dropout(0.5), ) def forward(self, x): features = [branch(x) for branch in self.branches] x = torch.cat(features, dim=1) return self.project(x)测试一下维度:
if __name__ == "__main__": x = torch.randn(2, 2048, 32, 32) aspp = ASPP(in_channels=2048, out_channels=256) y = aspp(x) print(y.shape) # 期望输出 torch.Size([2, 256, 32, 32])这份代码里有两个容易写错的地方。第一个是在全局池化分支里,F.interpolate的目标尺寸必须用输入x的空间尺寸,而不是写死某个值。很多模型里 ASPP 前面是一个固定输出尺寸的 backbone,写死可能也能跑,但一旦换输入分辨率就会报错。第二个是三个空洞卷积的padding必须等于dilation,少写一个,特征图尺寸就变了,后面拼接就会对不上。
4.2 padding 为什么取等于 rate:公式推导
很多人背住了“空洞卷积 padding 要等于 rate”,但不理解为什么。这里用公式推一遍。
3x3 卷积核,空洞率为 d,有效核大小为:
k_eff = d × (3 - 1) + 1 = 2d + 1
步长为 1 时,输出尺寸公式为:
out = (H + 2×padding - k_eff) + 1
想让 out 保持等于 H,只需要:
H + 2×padding - (2d + 1) + 1 = H 2×padding = 2d padding = d
所以对于 3x3 空洞卷积,padding 取 rate 值恰好保持特征图尺寸不变。如果是 5x5 空洞卷积,则 padding 要取 2×rate。这个公式在接 ASPP 之前改空洞率时很有用,别只记结论,关键时刻能自己算。
4.3 接入 ResNet 骨干与分割头的完整示例
实际使用 ASPP 时,前面要接 backbone,后面要接分类层。以 ResNet50 为例,一个完整的 DeepLabV3 风格分割头长这样:
import torchvision.models as models def get_backbone(pretrained=True): backbone = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1 if pretrained else None) backbone = list(backbone.children())[:-2] # 去掉 avgpool 和 fc return nn.Sequential(*backbone) class SimpleSegHead(nn.Module): def __init__(self, in_channels=2048, num_classes=21): super().__init__() self.aspp = ASPP(in_channels=in_channels, out_channels=256) self.classifier = nn.Sequential( nn.Conv2d(256, 256, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Conv2d(256, num_classes, kernel_size=1), ) def forward(self, x): return self.classifier(self.aspp(x)) if __name__ == "__main__": backbone = get_backbone(pretrained=False) seg_head = SimpleSegHead(in_channels=2048, num_classes=19) x = torch.randn(2, 3, 512, 512) with torch.no_grad(): f = backbone(x) print(f.shape) # torch.Size([2, 2048, 16, 16]) out = seg_head(f) print(out.shape) # torch.Size([2, 19, 16, 16])注意这里的 backbone 输出是输入分辨率的 1/32。如果希望保持更高分辨率,需要把 ResNet 最后两个 stage 的下采样步长改成 1,并用空洞卷积补偿感受野。torchvision 提供了现成参数:
backbone = models.resnet50( weights=models.ResNet50_Weights.IMAGENET1K_V1, replace_stride_with_dilation=[False, False, True], )第三个 True 表示最后一个 stage 不再下采样,整体输出变成输入的 1/16。这样 ASPP 输入的 feature map 分辨率更高,小目标的特征保留得更好,代价是显存和计算量上升。
4.4 output_stride 对 rate 的影响
在使用 DeepLabV3 的语境下,output_stride(简称 OS)决定了 backbone 最终输出的特征图相对原图的缩小倍数。标准推荐是 OS=16 时,ASPP 的 rate 取 (6, 12, 18)。如果用了 OS=8,特征图分辨率翻倍,同样感受野对应的 rate 也要翻倍,取 (12, 24, 36)。
为什么?因为 rate 是相对特征图像素距离定义的。特征图分辨率越高,同样 rate 对应的原图距离越短,如果不调整 rate,感受野相比原来反而变小了。保持“在原图上的有效感受野”一致,就需要按分辨率比例放大 rate。
实操里我一般默认 OS=16,这是 DeepLabV3 论文验证过的最平衡配置。OS=8 虽然精度略高,但特征图面积是 OS=16 的 4 倍,ASPP 五个分支跑起来显存和耗时都会显著上涨,移动端基本不用考虑。
5. 实测中 ASPP 相关的坑与调参思路
5.1 大空洞率在低分辨率特征图上会“失效”
ASPP 不是随便往哪一层接都能提升效果。我在一个城市场景分割项目里试过把 ASPP 接到 ResNet 第二层输出上,特征图是原图的 1/8 分辨率,rate=18 的有效核有 37x37 那么大,而特征图本身可能只有 64x64,卷积核撑开到比特征图还大,采样时大量点落在 padding 区域,分支实际采到的有效信息很少。效果自然不理想。
判断分支有没有“失效”,可以直接可视化每个分支的输出响应。如果某个分支的输出图和别的分支高度相似,或者几乎全是靠近边界的亮斑,大概率就是这个分支的 rate 对当前分辨率来说太大了。解决办法是降低 rate,或者干脆减少分支数。对低分辨率特征图,rate 取 2、4、6 往往比 6、12、18 更合适。
反过来,如果特征图分辨率很高(比如原图的 1/4),rate=18 又显得太小,看不了多远。ASPP 的 rate 应该随特征图分辨率变化,不是一套参数打天下。
5.2 全局池化分支对边缘信息的影响
全局平均池化的本质是“用整图信息替换局部信息”,它对大目标内部一致性贡献大,但对边缘细节是负作用。如果把池化分支的输出可视化,会发现它是一张非常平滑的图,边缘信息几乎为零。
好在 ASPP 有拼接结构,202 层后 1x1 卷积可以学出“边缘位置少信池化分支、内部位置多信池化分支”的通道权重,所以标准结构里这个副作用是可被学习修正的。真正危险的做法是在 ASPP 后面直接接一个 1x1 卷积分类,没有中间修正层。此时边缘像素的预测会明显发糊。
我习惯在 ASPP 后面加两层 3x3 卷积再接分类层,相当于给“多尺度的粗结果”一个精修的机会。简单有效,而且显存占用很小。这个细节在 torchvision 官方 DeepLabV3 实现里也有,说明不是玄学。
5.3 小 batch 训练时 BN 把上下文支路拖垮
ASPP 每个分支都自带 BatchNorm,这是标准结构,但会产生一个实际工程问题:batch size 太小的时候,BN 的统计量不稳定。
分割任务显存消耗大,很多人单卡 batch 只能开到 2 甚至 1。此时每个分支的 BN 在训练中估计的均值方差噪声很大,尤其全局池化分支的特征是经过池化后的全局向量,统计量本身就和小 batch 不匹配。表现就是训练集上效果不错,验证集上上下文融合出来的结果明显变差。
解决方法有三个。一是加大 batch size,哪怕用梯度累积,也比在 BN 上硬撑强。二是在小 batch 场景下把 BN 换成 GroupNorm,结构和代码几乎不用大改,稳定性好很多。三是训练时固定 backbone 的 BN 参数,只让 ASPP 里的 BN 更新,但这个方案在预训练权重和目标任务分布差异大时不推荐。如果是移动端部署,我一般直接用 GroupNorm,省去 batch 大小带来的不确定性。
5.4 关于“棋盘伪影”的常见误解
经常有人把分割结果里出现的棋盘状纹理归因于空洞卷积,说空洞卷积会导致网格伪影。这种说法不够准确。空洞卷积本身确实存在采样稀疏的问题,但成熟训练出来的分割模型很少因为 ASPP 产生明显的棋盘状输出。
棋盘伪影最常见来源是转置卷积或者反卷积上采样时核重叠不均匀,和 ASPP 关系不大。不过有一种情况需要注意:当你单独把 rate 很大的空洞卷积分支抽出来可视化时,确实能看到离散的采样点痕迹,但这只是中间特征,不是最终预测。
真正需要警惕的是,在边界密集、目标密集的区域,如果模型把大 rate 分支的权重学得太高,可能会让相邻像素的预测趋于一致,边界被“糊开”。这时候调小对应分支的 rate,或者增加一个标准 3x3 卷积层混合信息,通常能缓解。
5.5 显存、速度与精度的取舍
ASPP 五个分支并行,显存占用不是五个普通卷积的简单相加,因为每个分支都要保留一份完整特征图用于拼接。以 512x512 输入、OS=16 为例,ASPP 中间特征图是 32x32x256 的若干个副本,多分支带来的显存增量大概是几十 MB 级别,这个量级对显卡不算压力。但如果你把 ASPP 接在 OS=8 的高分辨率特征上,分支间特征图面积翻 4 倍,显存增长就很明显了。
速度上,空洞卷积的计算量和普通 3x3 卷积基本一致,ASPP 相对于一个普通 3x3 卷积分割头,主要开销是多出来的四个分支和最后的拼接。实测在 GPU 上大约增加不到 10% 的推理延迟,在 CPU 上可能增加 20% 以上。如果在意速度,可以考虑砍分支:保留 1x1、rate=6、rate=12,去掉 rate=18 和全局池化,效果会掉一点但速度提升可观。
6. ASPP 的变体,以及它在 Transformer 时代的位置
6.1 DenseASPP:用密集连接补采样稀疏
标准 ASPP 的五个分支是相互独立的,每个分支的输入都是同一个特征图。DenseASPP 的思路是让不同 rate 的分支像 DenseNet 那样串联起来:后面分支的输入不仅包含原始特征图,还包含前面分支的输出。
这样做的动机很直接:大 rate 分支采样稀疏,如果能把小 rate 分支提取的局部细节特征传给它,大分支在做大范围上下文聚合时就不再是孤军奋战。实验显示 DenseASPP 在街景分割上比标准 ASPP 略优,但代价是显存上升,因为每个分支都要保留前面所有特征图用于拼接。工程上如果你的显存足够宽裕,可以试试;否则还是标准 ASPP 更稳。
6.2 LR-ASPP:移动端的轻量化改造
LR-ASPP 出自 MobileNetV3 的搜索论文,目标是在 EdgeTPU 上跑语义分割。它只保留两条核心路径:一条低分辨率路径,用全局平均池化获取全局上下文;一条高分辨率路径,直接从浅层拿细粒度特征。两条路径通过一个小型特征融合模块合并。
对移动端应用来说,标准 ASPP 五个分支的计算量还是偏大。LR-ASPP 把分支砍到两个,速度上优势明显,精度上也有取舍。如果你在做手机端或嵌入式端的语义分割,可以先从 LR-ASPP 开始,而不是直接上完整版 ASPP。MobileNetV3-Large 官方训练脚本里自带 LR-ASPP 的实现,可以直接参考。
6.3 可变形卷积能不能替代 ASPP
可变形卷积的思路是给每个采样点学习一个偏移量,让卷积核的形状自适应目标几何形变。理论上它能达到比固定 rate 更好的采样位置,甚至替代 ASPP 的多分支结构。我在一个医学影像分割任务上试过用可变形卷积替代 ASPP,效果确实不差,但有两个问题。
一是训练不稳定。可变形卷积需要额外学习偏移量,数据量不够大时偏移量很容易过拟合,训练曲线明显比固定 rate 的 ASPP 更抖。二是显存和速度都不占优势,偏移量本身的存储和计算开销不小。所以综合考虑稳定性、确定性和部署友好度,在大多数视觉任务里我仍然首选固定 rate 的 ASPP。可变形卷积适合做大模型、大数据的场景,不太适合中小团队快速迭代。
6.4 Transformer 分割模型还需要 ASPP 吗
现在很多分割模型切到了 Transformer 架构,比如 SegFormer、Mask2Former。这类模型的自注意力机制天然具备全局感受野,看起来 ASPP 不再必要。
实际观察下来,Transformer 模型确实不再显式使用 ASPP,但它们往往用其他模块实现类似功能。SegFormer 的 MLP decoder 本质上也是把不同层输出做多尺度融合;Mask2Former 利用 masked attention 做可变形注意力采样;Swin Transformer 的窗口注意力加跨窗口连接也是多尺度思路的变体。ASPP 退役了吗?准确说,是它的思想被吸收进了新的架构里。
如果你还在用 CNN backbone,ASPP 依然是效果、复杂度、工程可维护性之间平衡最好的多尺度上下文模块之一。如果你已经切到 Transformer backbone,不必硬塞一个 ASPP 进去,但需要确认你的模型是否具备等价的多尺度上下文聚合能力。很多人在尝试把 Swin 和 ASPP 拼在一起时发现收益有限,是因为 Transformer 本身已经覆盖了这部分功能,再叠加属于冗余。
我自己在实际项目中的习惯是:CNN 系模型优先接 ASPP,Transformer 系模型先把多尺度特征融合做扎实,再考虑要不要补一个轻量上下文模块。结构是死的,思路是活的,搞清楚 ASPP 解决的“不同尺度上下文信息同时在位”这个核心问题,远比赛一堆模型名字有用。