☰
空洞卷积:不降分辨率扩大感受野,原理、实现与避坑指南
2026/10/16 6:21:46 网站建设 项目流程

做图像分割和检测的朋友,大概率都被同一个问题折磨过:网络想看得远,就得把特征图一路缩水,高层语义是有了,边缘细节却糊成一团。空洞卷积(Dilated Convolution)是我在解决这类“又想看得远、又想看得细”的矛盾时最常用的工具。它能在不损失分辨率的情况下撑大感受野,而且参数和计算量基本不涨,这个特性让它在语义分割、目标检测、语音序列建模里都成了常客。这篇内容适合刚接触空洞卷积的初学者,也适合已经在用但总被感受野和棋盘格伪影绕晕的同学。我会从原理、结构设计、PyTorch实现到踩坑经验一次讲完。

1. 分辨率与感受野的拉扯:空洞卷积为什么被频繁使用

1.1 分割任务里的经典困境

先还原一个非常真实的场景。你输入一张 512x512 的图片,要做一个像素级的语义分割。传统做法是让网络不断做卷积和下采样,一路缩到 32x32,这样最后一层的每个点能看到整张图的大致结构,分类很准,但它回不到原来的分辨率。要恢复细节就得靠上采样、跳跃连接,边缘还是免不了出现一块一块的钝感。更麻烦的是,小目标经常在下采样过程中直接消失,一个 8 像素宽的行人,几轮池化之后就只剩一个点。

这个问题的本质是:感受野和分辨率互相打架。想扩大感受野,通常要下采样或加大卷积核;可一旦下采样,空间细节就没了。而空洞卷积正是为这个矛盾设计的折中方案。它的思路非常直接:普通卷积核里的采样点本来是挨着的,空洞卷积把这些点之间的间隔拉开,卷积核不变大,覆盖范围却变大了。用大白话说,就是给卷积核“打了几个洞”。

1.2 空洞卷积怎么做到两头兼顾

用生活化的比喻来理解。普通 3x3 卷积像一个近视眼,只能看清正前方九宫格那么大的区域,看得清楚但视野小。空洞卷积把这只近视眼的两个瞳孔之间的距离拉开,看得远、看得广,但每个局部看得没那么密集。它没有增加卷积核里采样点的数量,只是把这些点放得更开,所以计算量和原来差不多。

这个特性带来的直接结果是:你可以在不下采样、不增大卷积核的前提下,让每一层覆盖更大的范围。比如输入一张街道场景,普通 3x3 卷积只能看到路面上的一个小坑,空洞卷积的一层就能同时看到人行道、车辆和标志牌的一部分。对分割任务来说,这意味着高分辨率特征图可以直接保留,同时高层感受野也够大,后面不需要做特别暴力的上采样也能获得不错的结果。

空洞卷积不是新奇概念,但它真正被大规模使用,是语义分割把分类网络改成全卷积结构之后的事。分类网络里习惯用池化和带步长的卷积来压分辨率,可分割网络不想压分辨率。于是大家就想:能不能把池化去掉,把最后几层的步长改成 1,同时用空洞卷积填补被稀释的感受野?实践证明这条路完全可行,而且效果比反复上下采样稳定得多。这也是为什么你会看到很多分割骨架网络在最后几个 stage 会切换成空洞卷积模式。

2. 核心原理:膨胀系数、等效卷积核与感受野计算

2.1 膨胀系数到底改了什么

空洞卷积里最核心的参数是膨胀系数 dilation,也叫空洞率。定义一个 3x3 卷积核,dilation=1 时就是普通卷积,相邻采样点距离是 1 个像素;dilation=2 时,相邻采样点之间隔一个像素;dilation=4 时,隔三个像素。这个参数并不改变卷积核里参与计算的权重数量,只改变权重在输入特征图上的分布位置。

比如一个 3x3 卷积核,dilation=2 时,它的九个采样点会落到输入图上间隔为 2 的网格点上,实际覆盖的区域是一个 5x5 的范围。虽然不是 5x5 的每个点都有权重,但网络能“感知”到整个 5x5 范围里的信息。这就是为什么我们常听人说“3x3 空洞卷积等效于 5x5 卷积”,严格说应该是“等效覆盖范围是 5x5”。下面这张表可以帮你快速建立直觉:

卷积核大小膨胀系数等效覆盖范围
3x313x3
3x325x5
3x337x7
3x349x9
3x3613x13
5x529x9
5x5313x13

计算公式是:等效覆盖尺寸 = 卷积核大小 + (卷积核大小 - 1) x (膨胀系数 - 1)。这个公式虽然短,但我建议你亲手算几遍,因为在多层串联叠加时很容易把膨胀系数直接相加,那是不对的,需要一层一层地换算等效覆盖范围再求感受野。

2.2 感受野怎么算:一个可以直接套用的公式

感受野指的是输出特征图上一个点,在原始输入上对应多大区域。单层空洞卷积的感受野,其实就是上面的“等效覆盖范围”。多层串联时,不能直接把每层的等效覆盖范围相加,因为新的一层是在上一层输出上继续卷积,覆盖范围会叠加。

当所有卷积层的步长都是 1 时,感受野计算可以简化为:

感受野 = 1 + 所有层的(等效覆盖尺寸 - 1)之和

我拿一个三层卷积举例。如果三层都是普通 3x3 卷积,等效覆盖尺寸都是 3,感受野等于 1 + 2 + 2 + 2 = 7。如果三层都是 dilation=2 的 3x3 空洞卷积,每层等效覆盖尺寸是 5,感受野就等于 1 + 4 + 4 + 4 = 13。同样是三层卷积,感受野从 7 扩大到 13,参数量却没变。这个差距在小目标检测里非常明显,6 个像素的差距往往就决定了一个目标能不能被识别出来。

如果中间某些层有 stride,情况会复杂一点,后面的层产生的感受野会被放大。公式是:当前层感受野 = 上一层感受野 + (当前层等效覆盖尺寸 - 1) x 前面所有层的 stride 连乘。经验之谈:调试空洞卷积时,先把所有 stride=1 的情况算清楚,再看有 stride 的情况,否则非常容易算错。

2.3 计算量几乎不涨,这才是关键

普通 3x3 卷积,假设输入通道是 C_in,输出通道是 C_out,输出特征图是 HxW,计算次数大约是 HxWxC_inxC_outx9。空洞卷积只改变采样位置,采样点数量仍然是 9 个,所以乘法次数和普通 3x3 卷积完全一样。参数量更不用说,权重只有 9xC_inxC_out 个,和膨胀系数一点关系都没有。这是空洞卷积最吸引人的地方:视场变大,但是账单一分钱没多花。

不过我要提醒一句,这是理论 FLOPs 的算法。实际部署到推理引擎时,空洞卷积的访存模式更分散,某些硬件优化不到位的后端可能不比普通卷积快,甚至会变慢。这个我放到工程坑的部分再展开。先记住一个结论:在精度和感受野的权衡上,空洞卷积是一个非常划算的方案,但对它的效率期待要留有余地。

3. 结构设计:串行、并行与混合膨胀方案

3.1 串行堆叠:让感受野层层展开

把几层空洞卷积串起来是一种非常自然的设计。比如连续用三层 dilation=2,等效覆盖尺寸都是 5,感受野按 1 -> 5 -> 9 -> 13 这样拾级而上。这种方式实现简单,代码改动小,适合用在已经有普通卷积的网络上,直接把最后几个卷积层的 dilation 参数改掉就行。

但串行路线有一个容易翻车的点:如果每层都用同一个膨胀系数,比如全都用 dilation=2,层数一多,输出特征图上会出现“棋盘格”伪影。原因是卷积核采样位置在空间上形成了固定间隔,有些区域始终没被采样到。后面我会单独讲混合膨胀方案来解这个问题。串行设计更适合先粗后细的思路,建议前几层用普通卷积,后面再逐步放大膨胀系数,一般不会出问题。

3.2 并行多尺度:像 ASP 模块那样分兵作战

现实里的目标尺寸千差万别,一个 3 像素的圆点和一块 300 像素的招牌,不可能用同一个感受野去兼顾。并行多尺度方案就是同时开几个分支,每个分支用不同的膨胀系数,各自提取不同尺度的上下文,然后把结果拼到一起。语义分割里经典的 ASP 模块就是这个思路,通常用 1x1 卷积配合几个不同 dilation 的空洞卷积分支,再接上全局池化分支,最终把所有特征拼起来。

AS 模块的好处是灵活,网络一次能同时看到局部细节、中等上下文和全局结构。我在实际项目里通常先按膨胀系数 6、12、18 起步,再根据目标尺寸调整。如果检测对象偏小,就把 18 换成 12;如果场景大、目标很稀疏,可以加一个 24 的分支。每个分支的卷积核基本都是 3x3,算下来整体计算量还在可控范围内,比用多个不同尺寸的卷积核划算得多。

3.3 HDC 混合膨胀:别让棋盘格毁掉你的特征图

连续使用相同膨胀系数带来的棋盘格伪影,是空洞卷积最著名的坑。为了说清楚它,我们可以算一下:三层 dilation=2 的 3x3 卷积堆在一起,等效感受野是 13x13,但采样点位置之间存在一个固定的 2 像素间隔,等效于在特征图上“挑着看”,中间有一大圈信息虽然理论上在感受野范围内,却没有实际进入计算。输出的特征图就会出现规则的空洞或条纹,尤其在物体边缘位置非常显眼。

解决办法是使用一组“锯齿状”的膨胀系数,比如 [1, 2, 5, 1, 2, 5],或者 [1, 2, 4, 8] 这种递增序列。关键原则是让这组系数的最大公约数为 1,这样采样点能够比较均匀地覆盖全图。我自己常用的默认组合是 [1, 2, 5],因为这个序列既不会让第一层太稀疏,又能快速把感受野拉开。如果你是在一个已经下采样过的低分辨率特征图上做膨胀卷积,膨胀系数别贪大,尽量从 1 和 2 开始,否则起不到精细特征提取的作用。

4. 实操:在 PyTorch 里实现空洞卷积

4.1 一个最基础的空洞卷积层

PyTorch 里用 Conv2d 就能直接实现空洞卷积,参数名是 dilation。下面是一个最小例子:

import torch import torch.nn as nn # 输入:1 张 3 通道的 64x64 图片 x = torch.randn(1, 3, 64, 64) # 普通 3x3 卷积 conv1 = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, padding=1) y1 = conv1(x) print(y1.shape) # torch.Size([1, 16, 64, 64]) # 空洞卷积:3x3,dilation=2 conv2 = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, dilation=2, padding=2) y2 = conv2(x) print(y2.shape) # torch.Size([1, 16, 64, 64])

留意 padding 的变化。普通 3x3 卷积保持尺寸需要 padding=1;dilation=2 的 3x3 卷积,等效覆盖范围是 5x5,要保持输出尺寸就得 padding=2。这里的规律是:对于奇数卷积核,padding = dilation x (kernel_size - 1) / 2。不少新手只改了 dilation 忘了改 padding,结果输出尺寸突然小了几像素,后面接的层全对不上。

4.2 写一个感受野验证脚本

理论学习再多,都不如直接跑一个脚本来得踏实。我调试时习惯写一个函数,把每一层的 kernel_size、dilation、stride 列出来,直接算出整条链路的感受野:

def compute_receptive_field(layers): """ layers: list of dict, 每个元素包含 {"kernel": int, "dilation": int, "stride": int} 返回整条链路的感受野 """ receptive_field = 1 stride_product = 1 for layer in layers: k = layer["kernel"] d = layer["dilation"] s = layer["stride"] effective_k = k + (k - 1) * (d - 1) receptive_field += (effective_k - 1) * stride_product stride_product *= s return receptive_field # 三层普通 3x3 卷积 normal_layers = [ {"kernel": 3, "dilation": 1, "stride": 1}, {"kernel": 3, "dilation": 1, "stride": 1}, {"kernel": 3, "dilation": 1, "stride": 1}, ] print(compute_receptive_field(normal_layers)) # 7 # 三层 dilation=2 的 3x3 空洞卷积 dilated_layers = [ {"kernel": 3, "dilation": 2, "stride": 1}, {"kernel": 3, "dilation": 2, "stride": 1}, {"kernel": 3, "dilation": 2, "stride": 1}, ] print(compute_receptive_field(dilated_layers)) # 13

这个脚本建议所有做分割和检测的人都留一份。很多时候模型结构看着没问题,输出却很奇怪,我用这个脚本一算,发现某层感受野比预期小太多,原因就是中间夹了一个 stride=2 的下采样层,导致后面的膨胀卷积“看起来很大”,实际覆盖范围被压缩了。

4.3 一块能直接用的多尺度融合模块

实操中我更常用的是并行多尺度结构。下面这个模块可以当成一个基础版 ASP 块来用,三个分支分别用 dilation=6、12、18,再接一个 1x1 卷分支做全局上下文补充:

import torch import torch.nn as nn import torch.nn.functional as F class MultiScaleDilatedBlock(nn.Module): def __init__(self, in_channels, out_channels, rates=(6, 12, 18)): super().__init__() self.branches = nn.ModuleList() for rate in rates: self.branches.append( nn.Sequential( nn.Conv2d( in_channels, out_channels, kernel_size=3, padding=rate, dilation=rate, ), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ) ) # 1x1 卷积分支,聚合全局上下文 self.global_branch = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ) def forward(self, x): feats = [branch(x) for branch in self.branches] global_feat = self.global_branch(x) feats.append(global_feat) return torch.cat(feats, dim=1) # 测试:输入 64x64,32 通道 x = torch.randn(2, 32, 64, 64) block = MultiScaleDilatedBlock(32, 16) out = block(x) print(out.shape) # torch.Size([2, 64, 64, 64])

这个模块的默认膨胀系数是按分辨率 64 到 128 设计的,如果你用更高分辨率输入,可以把膨胀系数整体调大,比如 12、24、36。低分辨率输入反而要调小,否则 3x3 卷积在 padding 区域花掉太多计算。我见过有人把这张模块直接塞进一个小目标检测网络,然后用默认的 6/12/18,在 256x256 输入上效果反而比普通卷积差,把膨胀系数改成 2/4/6 之后立刻正常了。

5. 常见问题与排查实录

5.1 padding、dilation、stride 三者怎么搭配

这是被问得最多的一个问题。先说结论:padding 只影响输出尺寸,不影响感受野;stride 影响感受野的放大倍数;dilation 影响单层等效覆盖范围。三者的关系可以总结为:输出尺寸由 padding、dilation、stride 共同决定;感受野则由 dilation、stride、层数共同决定。所以不要只盯着一个参数调。

最常见组合是 stride=1,这样不会丢分辨率。此时要保持特征图尺寸不变,padding 直接用 dilation 乘以 (kernel_size - 1) / 2 即可。如果 kernel_size=3,那就是 padding = dilation。如果 kernel_size=5,padding = 2 x dilation。另一个常见组合是用 stride=2 配合 dilation 增大后层感受野,但这时输出尺寸会减半,通常用于下采样。我建议把 stride 和 dilation 分开看待:stride 负责压缩空间,dilation 负责弥补视野,不要指望 dilation 能替代 stride。

5.2 感受野算错,到底错在哪

感受野算错一般就三种原因。第一,忘了把中间层的 stride 考虑进去,导致后面层的感受野被低估。第二,把 padding 也算进感受野,其实 padding 只会让边界信息参与计算,并不会扩大真正的有效感受野。第三,只算最后一层,忽略整条链路的累加。第四,把等效覆盖尺寸直接相加,忽略中间隔的层数。前面那个函数已经可以解决大多数问题,你只要把每层参数填对,结果就八九不离十。

还有一个容易被忽略的点:理论感受野不等于有效感受野。一根 3x3 卷积核在大扩张率下,中心位置的权重贡献远大于边缘位置,所以名义上看到 13x13,真正对输出产生强影响的可能只有中间 7x7。这一点在小目标上尤其明显。想验证有效感受野,可以给输入图的不同位置打上标记点,逐个看输出响应;或者用随机噪声作为输入,观察梯度回传到输入的分布范围。这种方法比纯看公式可靠得多。

5.3 棋盘格伪影到底怎么排查

棋盘格伪影的典型表现是:特征图热力图上出现规律的网格纹理,像国际象棋。出现这种问题的原因前面已经提过,就是连续几层用了相同的膨胀系数,导致采样点形成固定间隔的栅格,很多区域永远进不了卷积计算。排查方法也很直接:把各层 dilation 打出来,看有没有重复,看它们之间是否互质。

我的避坑经验是三条。第一,连续使用空洞卷积时,优先选择 [1, 2, 5, 1, 2, 5] 这样的循环组合,让采样点在多次卷积后尽可能覆盖连续区域。第二,第一层尽量用 dilation=1,而不是一上来就用大膨胀系数,因为第一层输出的位置偏差会影响后面所有层。第三,如果数据集目标普遍偏小,可以完全不使用大于 12 的膨胀系数,小目标在高膨胀率下越来越容易被“漏采”。棋盘格不是只能事后发现,调参前算一遍采样覆盖范围,能省很多事。

5.4 工程里的几个隐形坑

空洞卷积在框架里写起来很容易,但部署和训练时有几个坑值得记下来。第一,推理引擎的支持度。某些加速库对 dilation 大的卷积处理并不好,可能退回通用算子,速度反而比普通 3x3 卷积还慢。如果你的模型要上移动端或边缘设备,建议先在目标推理环境里跑一遍基准测试,别只看论文里的 FLOPs 数字。第二,显存消耗。空洞卷积的中间激活张量和普通卷积一样大,因为它不缩小分辨率。连续堆叠空洞卷积时,整份特征图一直在内存里占着,显存可能比你想的要高。第三,BatchNorm 和空洞卷积的交互。在低批次训练时,即使改了膨胀率,BN 的滑动均值也可能不稳定,我试过在个别任务上把 BN 换成 InstanceNorm 效果更稳,但换回大模型训练又不一定适用,需要针对任务试。

5.5 一张速查表:问题、原因与解法

为了让你排查起来更快,我把实际操作中积累的高频问题整理成一张表:

现象可能原因建议解法
输出尺寸变小改了 dilation 忘了改 paddingpadding = dilation x (kernel - 1) / 2
感受野不够大中间插入 stride=2,单层覆盖被压缩调整层序,或在 stride 后增加膨胀卷积补偿
特征图有棋盘格连续多层使用相同膨胀系数改用 [1, 2, 5] 或 [1, 2, 4, 8] 组合
小目标检测变差膨胀系数过大,有效感受野稀疏调小膨胀系数,配合小 padding
部署推理变慢算子不支持、访存分散用普通卷积替代部分空洞卷积分支
训练损失波动大大膨胀率改变特征分布,BN 统计不稳降低学习率或换用 InstanceNorm 试一下

这张表不能覆盖所有情况,但能帮你快速定位最常见的异常。我一般调试时是先看输出尺寸,再看感受野,最后看特征图可视化,三步排查下来基本能找到问题。

6. 写在最后的一点实践心得

空洞卷积不是银弹,它只是把“看得远”和“看得细”这对矛盾向后推了一把。在我自己的项目里,最常用的套路是:普通卷积负责底层细节,小膨胀系数的空洞卷积负责中程上下文,大膨胀系数配合全局池化分支负责大场景语义,最后用跳跃连接把不同尺度拼在一起。这样设计出来的网络既不会因为纯用高斯核而丢失边缘,也不会因为重复膨胀而出现棋盘格。

我建议你在自己的模型里动手改一个层试试,就用 dilation=2 和 padding=2 替换掉某个 3x3 普通卷积,然后把输出和特征图可视化出来,对比前后差异。空洞卷积这东西,看十篇讲解不如亲手跑一次。如果你在实验中也遇到过“加了空洞卷积反而变花、变慢、变差”的情况,回头按这张速查表逐条核对,大概率能很快找到原因。

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

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

立即咨询