万物|炼器 从零手搓工业级旋转目标检测网络 · 卷3 —— 核心算子锻造(三)

核心算子锻造(三)


上一篇 核心算子锻造(二),我们已经顺着 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)y = F(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)y = x + F(x) y=x+F(x)

于是,原来的:

复制代码
x → Conv → Conv → 输出

变成了:

scss 复制代码
           ┌→ Conv → Conv → F(x) ─┐
输入 x────┤                       ├→ 相加 → 输出 y
           └──────── x ───────────┘
                 直通捷径

关键就在这个 + x。它相当于给信息额外修了一条不经过中间卷积层的"直通车道"。前向传播时,原始输入可以直接参与最终输出;反向传播时,梯度同样多了一条可以直接传回输入端的路径。

为什么这有助于梯度传播?

对于:
y=x+F(x)y=x+F(x) y=x+F(x)

我们想知道 x 发生一点变化时,y 会跟着变化多少(这是网络模型能够优化的基础),这在数学上就是求 y 对 x 的导数;而反向传播时,梯度正是利用这样的导数一层层向前传递的。

y 对 x 的导数 dydx \frac{dy}{dx} dxdy写成数学表达式就是这样的:
dydx =1+ dF(x)dx \frac{dy}{dx} = 1+\frac{dF(x)}{dx} dxdy=1+dxdF(x)

这里的 1 来自那条直接的 x → y 路径,而 dF(x)dx \frac{dF(x)}{dx} 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

下一步,我们继续沿着已经掌握的这些模块,把网络真正一层一层搭起来。

相关推荐
桃西西呀1 小时前
你的 Agent 每判一次都要为大模型吐的字买单?-Laya模型帮你做判断
人工智能·llm·ai编程
品牌常新1 小时前
安防巡检机器人推荐:园区仓储楼宇怎么选
人工智能
释厄6231 小时前
多黑洞具现论·物理/生物/金融三域显化·虚拟宇宙黑洞·奇点=0 圆圈
开发语言·数据结构·人工智能·程序人生·安全威胁分析
具身AGI1 小时前
大脑之外还要一套脊髓,物理AI 交互具身 的下一关
人工智能
haliu1 小时前
【FHE】(十二):为什么我们把 OpenMP 换成了自研线程池
人工智能·嵌入式·c·fhe·推理引擎·c11·边缘推理·同态加密推理
瞬维AI2 小时前
RPA与AI智能体的跨平台自动化执行架构:从任务编排到异常处理
人工智能·自动化·rpa
呆呆槑_Xiong2 小时前
2026年AI网文写作指南:灵蟹创作与主流AI工具全解析
人工智能
YOLO数据集集合2 小时前
睡岗检测数据集 | 睡岗检测 行为识别 岗位监督 安全管理9114期
深度学习·yolo·目标检测·数据集·安全管理·行为识别·睡岗检测
SJZR2 小时前
图解归一化
人工智能·机器学习