核心算子锻造(三)
上一篇 核心算子锻造(二),我们已经顺着 yolo11-obb.yaml 摸进了 Ultralytics 的源码,也给后面的"锻造路线"排好了顺序。
是时候把他们"手搓"出来了。
3.3 灵力分流的暴力美学:C3k2 模块
C3k2 看起来名字有点唬人,但把它拆开以后,会发现它并不是凭空冒出来的。暂不考虑c3k=True,C3k2 真正的依赖关系只有:
text
Conv
↓
Bottleneck
↓
C2f
↓
C3k2
其中 Conv 已经完成。接下来就是 Bottleneck 。
3.3.1 Bottleneck:残差连接,梯度高速公路
3.3.1.1残差连接的直观理解
Bottleneck 是深度学习中最经典的结构之一,由 ResNet 在2015年提出。它的核心思想之一是残差连接(Residual Connection)。
在普通的网络结构中,输入 x 经过若干层计算后得到输出,可以简单写成:
y=F(x)
这里的 F(x) 可以理解成"网络对输入做了一系列加工之后得到的结果"。例如我们马上就要实现的这个 Bottleneck 中,输入 x 是依次经过两个 Conv 模块:
scss
x → Conv → Conv → F(x)
所以,F(x) 并不是一个神秘的数学函数,它代表的就是:原始输入经过两个卷积模块处理以后得到的新特征。
残差连接巧妙的地方在于,它没有只使用加工后的 F(x) 作为输出,而是在旁边额外保留了一条不经过这两个卷积模块的"直通路线",把原始输入 x 直接送到最后,再与 F(x) 相加:
y=x+F(x)
于是,原来的:
x → Conv → Conv → 输出
变成了:
scss
┌→ Conv → Conv → F(x) ─┐
输入 x────┤ ├→ 相加 → 输出 y
└──────── x ───────────┘
直通捷径
关键就在这个 + x。它相当于给信息额外修了一条不经过中间卷积层的"直通车道"。前向传播时,原始输入可以直接参与最终输出;反向传播时,梯度同样多了一条可以直接传回输入端的路径。
为什么这有助于梯度传播?
对于:
y=x+F(x)
我们想知道 x 发生一点变化时,y 会跟着变化多少(这是网络模型能够优化的基础),这在数学上就是求 y 对 x 的导数;而反向传播时,梯度正是利用这样的导数一层层向前传递的。
y 对 x 的导数 dxdy写成数学表达式就是这样的:
dxdy=1+dxdF(x)
这里的 1 来自那条直接的 x → y 路径,而 dxdF(x) 来自经过两个卷积模块的那条路径。
这正是残差连接最重要的地方:
即使经过
F(x)这条复杂路线传回来的梯度变得很弱,网络仍然保留了一条由x直接通向输出的捷径,使梯度不必完全依赖中间那一串卷积层才能向后传播。
因此,残差连接并不是保证"梯度一定不会变小",更不是说梯度一定大于 1,而是为梯度增加了一条更加直接、更加通畅的传播路径,从而显著缓解深层网络训练时的梯度传播困难。
💡 炼器笔记
可以普通网络想象成只有一条盘山公路。信使(梯度)想回到山脚,必须沿着每一段弯弯绕绕的山路依次往回走,中间任何一段出了问题,信号都可能越来越弱。> 残差连接则是在盘山路旁边又修了一条直通隧道。卷积这条"盘山路"仍然负责学习新的特征,但梯度不再只有这一条路可走------必要时,它还可以沿着
x这条捷径更加直接地向前传播。
3.3.1.2Bottleneck 的实现
基于已经完成的 Conv,我们来实现一个标准的 Bottleneck 模块,其实现与 Ultralytics 结构一致,请把以下代码输入 main.py 文件:
python
class Bottleneck(nn.Module):
"""
YOLO 风格 Bottleneck:
两个 Conv + 可选残差连接
参考:
ultralytics/nn/modules/block.py::Bottleneck
"""
def __init__(self, c1, c2, shortcut=True, g=1, k=(3, 3), e=0.5):
super().__init__()
# 中间通道数
c_ = int(c2 * e)
self.cv1 = Conv(c1, c_, k[0], 1)
self.cv2 = Conv(c_, c2, k[1], 1, g=g)
# 只有输入输出形状一致时,才能直接做 x + F(x)
self.add = shortcut and c1 == c2
def forward(self, x):
y = self.cv2(self.cv1(x))
if self.add:
return x + y
return y
Bottleneck 模块的核心其实只有两件事:
text
第一件:输入x经过Conv → Conv,得到输出y
第二件:条件允许时,加上输入 x
其中self.add = shortcut and c1 == c2 尤其重要。因为做:
python
x + y
要求两个张量形状一致。如果:
text
x : [B, 64, H, W]
y : [B, 128, H, W]
通道数不一样,是不能直接相加的。所以我们要像下面这样做判断,首先要判断两个张量的通道数c1和·c2是不是一样,其次是是否允许残差连接,如果都是true,才输出残差连接的结果,也就是return x + y:
text
shortcut=True
并且
c1 == c2
3.3.1.3Bottleneck?瓶颈?
有心的读者也许会在疑惑,这个残差模块为什么会叫Bottleneck呢?Bottleneck,瓶颈?
还有一些细心的读者,也许会从在Bottleneck的实现代码中发现2个陌生的参数 g 和 e,这2个参数是干什么的?
g 参数表示分组卷积的组数,默认 g=1,也就是普通卷积。为了保持本教程的简洁,关于分组卷积我们不做过多的介绍,本教程中所有的 g 都默认为1。
关于 e ,在Bottleneck的实现代码中是这么写的:
python
......
e=0.5
# 中间通道数
c_ = int(c2 * e)
self.cv1 = Conv(c1, c_, k[0], 1)
self.cv2 = Conv(c_, c2, k[1], 1, g=g)
......
输入经过第一个卷积层 cv1 ,输出的通道数是c_,然后再送入第二个卷积层 cv2 ,这里的 c_ 就表示中间通道数 ,也叫隐藏通道数 ,显然 e 就是用来控制这个中间通道的通道数的,其本质上是指相对于"目标输出通道数"的扩展/压缩比例 ,因为c_是 c2 (而不是 c1 )乘以 e 取整得到的。
e 通常小于1,比如这里就是取0.5,为什么要先把数据的通道数从c1压缩到 c_ ,然后再回到 c2呢?核心目的其实是为了减少中间卷积的计算量和参数量,比如 64 通道压缩成 32 通道,中间大量计算都只需要在 32 个通道上完成,可以明显节省计算。当然,因为还要和原始输入 x 做残差相加,二者形状必须一致,所以通道数量最后还是要重新恢复到 c2 。
正因为参数 e 的存在,而且 e 通常小于1,中间先把通道压窄,再恢复到输出通道数,形成了"瓶颈"形状。这就是这个残差模块被命名为Bottleneck的原因。
3.3.2 分流炼化:从 CSP 到 C3k2
之前我们已经有了 Conv 模块,刚刚我们完成了 Bottleneck模块。
卷积,残差,看着亲手炼制的这2件基础"阵法",是不是有点小激动了呢?接下来,反复叠加这两个模块,不就是卷积神经网络嘛?"卷积大阵"不过如此。
这样确实可以工作,只不过,随着"卷积大阵"不断变深、变宽,随之而来的参数量、计算量和内存访问成本越来越高,阵法是能运行,但效率太低了,尤其是在实时检测领域和使用性能比较一般的设备时,这个问题就越发明显,工程上没法接受。
对网络的各种改进相继出现,CSP (Cross Stage Partial Network,跨阶段局部网络)就是其中被广泛接受的改进之一。
3.3.2.1CSP网络结构设计
2019年,Chien-Yao Wang 等人发表了一篇题为 "CSPNet: A New Backbone that can Enhance Learning Capability of CNN"的文章,提出:
深层网络中存在大量重复的梯度信息(duplicate gradient information)。
当时很多网络模型(如 DenseNet )存在大量层之间彼此连接、不断复用前面特征的结构。这种网络模型特征利用率虽然很高,但不同层在反向传播时,也可能反复接收到非常相似的梯度组合。换句话说,有一部分工作实际上是重复的。
CSPNet 希望在尽量保持网络学习能力的同时,减少这些重复的信息流和计算。
假设原来一个网络阶段处理数据的流程是这样的:
text
输入特征
↓
Block
↓
Block
↓
Block
↓
输出
也就是所有特征全部参加每一次复杂计算,这里有很多重复的梯度信息。
那要减少计算量,干脆就别让全部特征都走一遍这些复杂计算了,CSP 的想法非常朴素:
既然没必要让全部特征都反复经过整套复杂计算,那就先分成两部分,一部分去复杂计算,一部分就别复杂计算了,直接往输出那走吧。
于是变成:
markdown
┌── 一部分 ──→ 复杂网络 ─────┐
输入特征 ── 分流 ─┤ ├─→ 融合 → 输出
└── 另一部分 ────────────────┘
其中一部分特征进入复杂结构深入加工,另一部分则绕过这一阶段的大部分复杂计算,到阶段末尾再重新与前面复杂计算得到的结果融合。这样既减少不必要的重复计算 ,也能让让不同加工深度的信息都能保留下来。
这就是CSP , Cross Stage Partial Network 思想, Partial------只让部分特征参与完整阶段计算 ,而那条绕过复杂计算、从入口一直跨到阶段末尾的路径,就是 Cross Stage------跨越整个 Stage 。
这个结构看起来和刚刚讲的残差连接非常类似,都有一个"绕路"的操作,但他们处理问题的层级不一样。
残差连接通常是:
markdown
┌→ Conv → Conv ─┐
输入 x ──┤ ├→ 相加
└────── x ──────┘
重点是给一个 Block 内部增加直接的梯度传播路径。
而 CSP 更像:
markdown
┌→ 一串 Block ─────┐
输入 ────┤ ├→ Concat
└→ 跨阶段直通 ──────┘
重点是 把一个 Stage 的特征拆开,让不同部分经历不同深度的加工,再重新融合。
所以可以简单理解为:残差连接是在一个模块里面修捷径,而CSP是在整个阶段里面做分流。二者不是互相替代,反而经常组合使用 ------ CSP 的"加工分支"里面完全可以继续放 Bottleneck,而 Bottleneck 内部又继续使用残差连接。
这正是后面 C2f 的结构。
3.3.2.2 C2f:把每一级结果都用起来
C2f 可以看成 CSP 思想的一种具体实现。
它先用一个 1×1 Conv 调整通道数,然后把结果沿通道方向切成两份,因为已经是输入 x 经过处理后的结果了,所以我们管分成的2份叫 y0 和 y1:
输入
↓
1×1 Conv
↓
分成 y0 和 y1
接下来,y0 直接保留下来,而 y1 进入 复杂结构进行深加工,假设这个复杂结构中有2个Bottleneck:
scss
y0 ──────────────────────────┐
│
y1 ──────────────────────────┐
↓
Bottleneck_1 → b1 ------ ------│
↓ │
Bottleneck_2 → b2
│
↓
Concat(y0,y1,b1,b2)
↓
1×1 Conv
↓
输出
这里最值得注意的地方是,C2f 并不是只留下最后一个 Bottleneck 的结果,既然每个 Bottleneck 都已经生成了一份新的特征,为什么只要最后一份?干脆全部拿来用好了。
这个 C2f网络 在数据处理过程中会产生以下几个数据:
y0:几乎没有经过深加工;y1:刚刚分流出来;b1:经过一次 Bottleneck;b2:经过两次 Bottleneck。
这四份特征最后全部参加融合,也就是说,C2f 会同时收集 加工较浅的特征 和 加工较深的特征 并进行融合,这比单纯把所有特征一路送到底拿最后结果,更充分地利用了中间过程产生的信息。
C2f的实现并不复杂,请把以下代码输入main.py文件:
python
class C2f(nn.Module):
"""CSP Bottleneck with 2 convolutions."""
def __init__(self, c1, c2, n=1, shortcut=False, g=1, e=0.5):
super().__init__()
# 定义网络中需要用到的各个模块,包括分流前调整通道的cv1和最后融合数据的cv2,
# 以及包含n个Bottleneck的复杂处理结构
self.c = int(c2 * e)
self.cv1 = Conv(c1, 2 * self.c, 1, 1)
self.cv2 = Conv((2 + n) * self.c, c2, 1)
self.m = nn.ModuleList(Bottleneck(self.c, self.c, shortcut, g, k=(3, 3), e=1.0) for _ in range(n))
def forward(self, x):
y = list(self.cv1(x).chunk(2, dim=1))
for m in self.m:
y.append(m(y[-1]))
return self.cv2(torch.cat(y, dim=1))
先看第一句:
y = list(self.cv1(x).chunk(2, dim=1))
其中 self.cv1(x) 先调整通道数,随后 chunk(2, dim=1) 沿通道维度一分为二,chunk是pytorch内置的张量处理方法,用于把一个 Tensor 沿指定维度切分成多个块。
这里是分成2份,于是得到 y = [y0, y1] ,接下来这几行中 self.m是那个包含多个处理模块的网络,我们这里是包含了n个Bottleneck:
css
for m in self.m:
y.append(m(y[-1]))
这段代码的意思就是,通过for循环,依次用self.m中模块来处理列表 y 中的最后一个数据,并把结果继续保存下来,这里可能有点绕,用一个简单的例子捋一下数据处理的过程,如果 n=2,也就是 self.m 中有2个Bottleneck:
ini
开始,输入x调整通道数后被一分为二:
y = [y0, y1]
y1经过第1个 Bottleneck:
y = [y0, y1, b1]
b1经过第2个 Bottleneck:
y = [y0, y1, b1, b2]
最后 torch.cat(y, dim=1) 把它们沿通道方向全部拼起来,再交给 cv2 做一次统一融合。
这三步正好对应了 CSP 的核心:分流、逐级加工并保留结果、全部融合。
3.3.2.3 C3k2:在 C2f 的基础上继续升级
理解了 C2f,再看 YOLO11 源码中大量出现的 C3k2,事情就简单多了。官方源码中:
kotlin
class C3k2(C2f):
这句话已经说明了它们最重要的关系,即 C3k2 直接继承 C2f,C2f 已经实现好的:
cv1;- 通道分流;
- 多级特征保存;
Concat;cv2;forward();
这些都可以继续使用。
C3k2 主要变化的是分流以后,内部到底使用什么模块来继续加工特征,官方实现大致提供两种选择:
ini
C3k2 内部
│
├── c3k=False → Bottleneck
│
└── c3k=True → C3k
这一阶段我们先选择最简单的实现,也就是 Bottleneck 分支,因此,我们现在实现的 C3k2 可以非常简洁,请把以下代码输入main.py文件:
python
class C3k2(C2f):
"""C3k2:当前使用 Bottleneck 作为内部特征提取单元。"""
def __init__(self, c1, c2, n=1, e=0.5, g=1, shortcut=True):
super().__init__(c1=c1, c2=c2, n=n, shortcut=shortcut, g=g, e=e)
甚至连 forward() 都不需要重新写。因为真正的数据流已经由父类 C2f 完成了:
输入
↓
cv1
↓
分流
↓
Bottleneck 链
↓
保留各级结果
↓
Concat
↓
cv2
↓
输出
舍弃了c3k分支的C3k2,实际就是C2f。后面如果需要进一步完善 C3k2 时,我们再参考官方实现,把 C3k 分支补进来。再梳理一下C3k2 的主线:
CSP
↓
分流
C2f
↓
分流 + 逐级加工 + 多阶段特征融合
C3k2
↓
保留 C2f 框架,并进一步允许更灵活的内部特征提取结构
看似复杂的 C3k2,一路拆到底,依然建立在前面已经掌握的几个基本动作上:
Conv、分流、Bottleneck、Concat、再融合。
3.4 洞幽探微:SPPF 快速空间金字塔池化
完成了 C3k2,按照前面确定的蓝图,接下来要实现 Backbone 中的另一个重要模块------SPPF。
SPPF 的全称是 Spatial Pyramid Pooling - Fast ,中文一般译为快速空间金字塔池化。
名字听起来有点长,不过先不用管什么"空间金字塔",我们先回答一个更重要的问题:
为什么需要 SPPF?
3.4.1 追根究底:SPPF 到底要解决什么问题
第 2 卷介绍 FPN 时,我们已经遇到过一次"多尺度"的特征金字塔网络FPN
当时的问题是:
小目标需要高分辨率特征,大目标更依赖深层语义,怎么同时兼顾?
于是 FPN 保留了不同层级的特征图:
P3:分辨率较高,例如 32×32,空间细节更多
P4:中等分辨率,例如 16×16
P5:分辨率较低,例如 8×8,语义信息更强
然后再通过上采样、Concat 等操作,让不同层级的特征互相融合。
到了 SPPF,这里的"多尺度"却是另外一回事。
现在经过多次下采样和 C3k2 的层层加工,我们已经走到了 Backbone 的后段。此时特征图虽然变小了,但里面的特征已经越来越抽象、越来越丰富。
不过,要判断一个目标时,仅仅知道当前位置"有什么特征"往往还不够,最好能够结合它周围的信息一起判断。比如模型在当前位置发现了一段类似"机翼边缘"的特征:
markdown
只看这一小块:
╱
─────╱
像不像机翼?
不一定。
它可能真的是飞机的一部分,也可能只是建筑物、舰船或者其他目标上的相似边缘。如果当前位置还能同时参考更大的周围区域:
markdown
机翼
──────────
│
│ 机身
│
──────────
机翼
那么判断"这里是不是飞机"的依据显然会更加充分。
所以到了网络较深的位置,我们希望每个位置不仅拥有丰富的局部特征,还能够进一步汇聚更大范围的上下文信息 。这就涉及一个重要概念------感受野(Receptive Field) 。
特征图上的一个点,并不是只由原图中的一个像素决定的。它前面已经经过很多层卷积,因此这个点实际上会受到原图中一片区域的影响。
这片能够影响当前特征点的输入区域,就叫这个点的感受野。
假设连续使用 3×3 卷积,并且 stride=1、dilation=1,那么可以简单理解为:
markdown
1 层 3×3 Conv
↓
感受野约为 3×3
2 层 3×3 Conv
↓
感受野约为 5×5
3 层 3×3 Conv
↓
感受野约为 7×7
为什么两层 3×3 卷积不是 6×6,而是 5×5?
第一层的一个输出点会读取原图中的 3×3 区域:
□ □ □ □ □
□ ■ ■ ■ □
□ ■ ■ ■ □
□ ■ ■ ■ □
□ □ □ □ □
第二层的一个输出点,又会读取第一层特征图上的 3×3 个位置。
而第一层这 9 个位置,每一个又分别来自原图中的一个 3×3 区域。
这些区域叠加起来,相当于在原来的 3×3 周围又向四周各扩展了一格:
■ ■ ■ ■ ■
■ ■ ■ ■ ■
■ ■ ■ ■ ■
■ ■ ■ ■ ■
■ ■ ■ ■ ■
于是感受野就从 3×3扩大到了 5×5,如果继续堆叠卷积,确实可以让一个位置看到越来越大的范围。
但是新的问题又来了:
如果我们的主要目的只是让当前位置汇聚更大范围的信息,有没有必要继续堆很多卷积?
卷积里面有需要学习的权重。继续增加卷积层,也意味着继续增加计算。
SPPF 选择了另一种更加直接的办法:池化(Pooling)。
3.4.2 化繁为简:用池化汇聚更大的上下文
池化和卷积有一点像:
它们都会拿一个窗口,在特征图上滑动。
不同的是,卷积窗口里面带着需要训练的权重,而池化通常没有需要训练的权重,只按照固定规则对窗口里的数值进行汇总。最常见的两种池化就是:
MaxPool :取最大值
AveragePool :取平均值
假设输入张量中某个 2×2 区域是:
┌───┬───┐
│ 1 │ 3 │
├───┼───┤
│ 2 │ 8 │
└───┴───┘
注意,这里的 1、3、2、8 是输入特征中的数值,不是卷积核里的权重。
如果进行 MaxPool:
scss
max(1, 3, 2, 8) = 8
图形表示就是:
输入 2×2 区域 MaxPool 输出
┌───┬───┐ ┌───┐
│ 1 │ 3 │ │ 8 │
├───┼───┤ ─────→ └───┘
│ 2 │ 8 │
└───┴───┘
可以理解为: 在这一小片区域中,把响应最强的特征保留下来。
如果使用 AveragePool:
ini
(1 + 3 + 2 + 8) / 4 = 3.5
得到:
输入 2×2 区域 AveragePool 输出
┌───┬───┐ ┌─────┐
│ 1 │ 3 │ │ 3.5 │
├───┼───┤ ─────→ └─────┘
│ 2 │ 8 │
└───┴───┘
它保留的是这一片区域的整体平均响应。
池化为什么也能扩大感受野?
感受野能不能扩大,关键并不在于这个操作是不是卷积,而在于:
计算一个输出位置时,它参考了多大范围的输入。
例如一个 5×5 Conv:
markdown
读取周围 5×5 个位置
↓
分别乘上可学习权重
↓
加起来得到一个新值
而一个 5×5 MaxPool:
markdown
读取周围 5×5 个位置
↓
从中取最大值
↓
得到一个新值
它们的计算方法完全不同,但是有一点是相同的:一个输出位置都参考了周围 5×5 范围的信息。
所以 MaxPool 同样能够扩大当前位置汇聚信息的范围。而且 MaxPool 没有需要学习的卷积权重,如果这里只是想进一步汇聚周围信息,它会更加简单。
不过这里还要注意:池化并不等于下采样,很多 CNN 会使用:
ini
kernel_size = 2
stride = 2
或者:
ini
kernel_size = 3
stride = 2
于是池化窗口每次移动两格,特征图也随之缩小。因此大家经常把 Pooling 和"下采样"联系在一起。但真正决定空间尺寸是否缩小的关键参数之一,是 stride。
SPPF 一般使用的是以下池化参数:
ini
kernel_size = 5
stride = 1
padding = 2
也就是 5×5 的窗口,每次只移动一个位置。因此输入和输出的高、宽保持不变。
SPPF 使用 MaxPool,并不是为了继续把特征图压小,而是为了:
在保持特征图空间尺寸不变的情况下,让每个位置汇聚更大范围的上下文信息。
不过,只进行一次 5×5 MaxPool,只能得到一种范围的上下文。如果希望当前位置既保留原来的信息,又能够同时参考更近、更远的周围区域,就需要得到多个不同范围的特征。
SPPF 的做法非常巧妙:它没有准备多个不同大小的池化层(早期的SPP网络则会准备多个不同大小的池化层),而是反复使用同一个 5×5 MaxPool,并把每一次池化后的结果都保留下来。
具体来说,它连续进行了三次 5×5 MaxPool:
x0
│
▼
5×5 MaxPool
│
▼
y1
│
▼
5×5 MaxPool
│
▼
y2
│
▼
5×5 MaxPool
│
▼
y3
第一次池化时,y1 中的一个位置汇聚了 x0 周围 5×5 范围的信息。第二次池化并不是重新处理 x0,而是继续处理已经池化过一次的 y1。因此,信息范围会进一步向外扩展:
markdown
第 1 次 5×5 MaxPool
↓
约 5×5 范围
第 2 次 5×5 MaxPool
↓
约 9×9 范围
第 3 次 5×5 MaxPool
↓
约 13×13 范围
这样一来,SPPF 就同时得到了:
x0:原始特征
y1:约 5×5 范围的上下文
y2:约 9×9 范围的上下文
y3:约 13×13 范围的上下文
而且由于SPPF使用的池化不改变特征值的大小,所以 x0、y1、y2、y3 的高和宽始终相同,可以直接沿通道维进行拼接:
markdown
x0 ───────────────────────────────┐
│
y1 ───────────────────────────────┤
├──→ Concat
y2 ───────────────────────────────┤
│
y3 ───────────────────────────────┘
于是,当前位置最终同时拥有了原始信息以及不同范围的上下文信息。
这就是 SPPF 最核心的设计:
连续使用同一个
5×5 MaxPool,逐步扩大信息汇聚范围,并把不同范围的特征全部保留下来进行融合。
到这里,我们可以解释SPPF名字里的 Fast 是怎么来的了。
SPPF 来源于 SPP,也就是 Spatial Pyramid Pooling。原始 SPP 思想来自何恺明等人在 2014 年提出的 SPP-net,不过当时最初要解决的主要问题,是让 CNN 能够把不同尺寸的特征图转换成固定长度的表示。后来 YOLO 中使用的 SPP 更侧重利用不同池化范围汇聚多尺度上下文。
典型的 SPP 可以理解成:
markdown
┌──→ 5×5 MaxPool ──┐
│ │
输入特征 ────────────┼──→ 9×9 MaxPool ──┼──→ Concat
│ │
└──→13×13 MaxPool ──┘
也就是直接用三个不同大小的池化窗口。而 SPPF 改成:
x0
│
├────────────────────────────────────┐
▼ │
5×5 MaxPool │
│ │
▼ │
y1 ───────────────────────────────────┤
│ │
▼ │
5×5 MaxPool │
│ │
▼ │
y2 ───────────────────────────────────┤
│ │
▼ │
5×5 MaxPool │
│ │
▼ │
y3 ───────────────────────────────────┘
传统 SPP 对每个位置分别扫描:
ini
5×5 → 25 个位置
9×9 → 81 个位置
13×13 → 169 个位置
合计:25 + 81 + 169 = 275个位置
而 SPPF 连续做三次 5×5:
ini
25 + 25 + 25 = 75个位置
从结构上已经能看出:
SPPF 利用了前一次池化的结果,用连续的小窗口代替并行的大窗口,减少了重复计算。
这就是 SPPF 中这个 F------Fast 的核心来源。
到这里,SPPF 的设计思路已经非常清楚了:
markdown
深层特征图
↓
希望汇聚更大范围的信息
↓
使用 MaxPool 扩大上下文范围
↓
连续三次 5×5
↓
得到 5×5 / 9×9 / 13×13 不同范围的信息
↓
全部保留下来
↓
Concat
↓
再进行融合
接下来,就可以正式手搓 SPPF 了。
3.4.3 按图施工:手搓 SPPF
先不要急着看代码,先把整个数据流画出来:
scss
输入 x
│
▼
1×1 Conv
│
▼
x0 ─────────────────────────────────┐
│ │
▼ │
5×5 MaxPool │
│ │
▼ │
y1 ─────────────────────────────────┤
│ │
▼ │
5×5 MaxPool │
│ │
▼ │
y2 ─────────────────────────────────┤
│ │
▼ │
5×5 MaxPool │
│ │
▼ │
y3 ─────────────────────────────────┘
│
▼
Concat(x0,y1,y2,y3)
│
▼
1×1 Conv
│
▼
输出
第一层 1×1 Conv的目的是调整通道数,比如先把通道数压到一半,压缩可以控制后续的通道规模和计算量。
接下来连续三次 MaxPool:
x0 → y1 → y2 → y3
因为池化参数是 kernel_size=5、stride=1、padding=2 ,所以四份特征的空间尺寸始终一致,可以把它们直接沿通道维度进行 Concat。最后再用一个 1×1 Conv 把四路信息融合起来,并恢复到需要的输出通道数。
整个 SPPF 实际上就只有三步:
调整通道
↓
连续池化
↓
拼接并融合
现在把它写成具体代码。把下面的代码加入 main.py:
python
class SPPF(nn.Module):
"""Spatial Pyramid Pooling - Fast."""
def __init__(self, c1, c2, k=5):
super().__init__()
# 隐藏通道:先压缩到输入通道数的一半
c_ = c1 // 2
# 调整通道数
self.cv1 = Conv(c1, c_, 1, 1, act=False)
# 四路特征拼接后,再进行融合
self.cv2 = Conv(c_ * 4, c2, 1, 1)
# 5×5 最大池化,stride=1,因此不改变特征图尺寸
self.m = nn.MaxPool2d(kernel_size=k, stride=1, padding=k // 2)
def forward(self, x):
x0 = self.cv1(x)
y1 = self.m(x0)
y2 = self.m(y1)
y3 = self.m(y2)
y = torch.cat([x0, y1, y2, y3], dim=1)
return self.cv2(y)
这份代码和前面的结构图几乎可以一一对应:
ini
代码 结构
x0 = self.cv1(x) 调整通道
↓
y1 = self.m(x0) 第 1 次池化
↓
y2 = self.m(y1) 第 2 次池化
↓
y3 = self.m(y2) 第 3 次池化
↓
torch.cat(...) 四路拼接
↓
self.cv2(y) 融合输出
至此,我们又完成了一个新的核心模块:
Conv
↓
Bottleneck
↓
C2f
↓
C3k2
↓
SPPF
下一步,我们继续沿着已经掌握的这些模块,把网络真正一层一层搭起来。