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

12 阅读8分钟

核心算子锻造(二)

上一篇 核心算子锻造(一),我们已经亲手完成了 YOLO 中最基础的 Conv 模块,也知道了Conv模块在稳定梯度方面的威力,接下来要干什么呢?

是持续堆叠Conv形成一个目标检测模型?还是,要“锻造”一些新的模块(算子)?

因为毕竟,这一卷的题目就叫“核心算子锻造”,总不能就只有Conv一个吧。

胡乱猜测总不是办法,挨个踩坑试错效率太低,显然也不合适。

第二卷中我们说过:我们不再完全闭门造车,而是会选择’YOLO11 OBB‘作为后续持续升级的参考蓝图。不知道下一步怎么走的时候,不妨看一看”蓝图“吧。


3.2 "偷师"藏经阁:直面 Ultralytics 源码

3.2.1 顺藤摸瓜:Ultralytics YOLO 总体结构

我们使用的是 Ultralytics YOLO v11的源码,可以从github上下载,这里要注意,不同版本的源码可能略有差别。

在直接打开某一个 Python 文件之前,我们先看一下 Ultralytics YOLO 源码的大体结构。

把与我们后续学习最相关的部分抽出来,大致是这样:

ultralytics/
│
├── cfg/                         # 各类配置文件
│   └── models/
│       └── 11/
│           └── yolo11-obb.yaml  # YOLO11 OBB 的模型结构蓝图
│
├── nn/                          # 神经网络核心代码
│   ├── modules/
│   │   ├── conv.py              # Conv、autopad 等基础卷积模块
│   │   ├── block.py             # Bottleneck、C2f、C3k2、SPPF 等网络基础模块
│   │   └── head.py              # Detect、OBB 等检测头
│   │
│   └── tasks.py                 # 根据 YAML 组装模型等核心逻辑
│
├── data/                        # 数据读取、增强等
├── engine/                      # 训练、验证、预测等流程
└── utils/                       # 各类通用工具

看起来文件很多,但对我们现在而言,真正需要先关注的其实只有两块:

cfg/models/11/        → “模型准备怎么搭”
nn/modules/           → “这些模块具体是怎么造的”

一个负责画蓝图,一个负责造零件。


3.2.2 按图索骥:寻找核心算子

刚刚在对 Ultralytics YOLO 源码结构的注释中,我们写了:

cfg/models/11/        → 模型准备怎么搭
yolo11-obb.yaml       → YOLO11 OBB 的模型结构蓝图

对于已经写完Conv不知道下一步怎么走的我们而言,这不正是我们需要的吗? yolo11-obb.yaml,就我们要找的蓝图。

第一次看到 .yaml 文件的读者不用紧张。YAML 本质上就是一种用文本保存配置信息的格式。

你可以把它理解成一张比较容易让人阅读的“配置表”。

例如: yolo11-obb.yaml文件里会写:

backbone:
  - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2
  - [-1, 1, Conv, [128, 3, 2]] # 1-P2/4
  - [-1, 2, C3k2, [256, False, 0.25]]
  - [-1, 1, Conv, [256, 3, 2]] # 3-P3/8
  - [-1, 2, C3k2, [512, False, 0.25]]
  ...
head:
  ...
  - [-1, 1, Conv, [256, 3, 2]]
  - [[-1, 13], 1, Concat, [1]] # cat head P4
  - [-1, 2, C3k2, [512, False]] # 19 (P4/16-medium)
  ...

意思很直白:

backbone 下面描述 Backbone 怎么搭
head     下面描述检测部分怎么搭

它不是网络模型本身,也不在这里完成卷积计算。它更像一张 模型装配清单:

第一个位置装什么模块,第二个位置装什么模块,某个模块重复几次,需要传入哪些参数……

真正负责读取这张清单、找到对应网络模块并组装网络的工作,后面会由 tasks.py 中的模型解析逻辑完成。

不过现在这些还都不是的重点,我们现在只做一件事:找模块名字

打开 yolo11-obb.yaml,我们先只看 Backbone 中的内容,因为这是模型的主体结构。

backbone:
  # [from, repeats, module, args]
  - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2
  - [-1, 1, Conv, [128, 3, 2]] # 1-P2/4
  - [-1, 2, C3k2, [256, False, 0.25]]
  - [-1, 1, Conv, [256, 3, 2]] # 3-P3/8
  - [-1, 2, C3k2, [512, False, 0.25]]
  - [-1, 1, Conv, [512, 3, 2]] # 5-P4/16
  - [-1, 2, C3k2, [512, True]]
  - [-1, 1, Conv, [1024, 3, 2]] # 7-P5/32
  - [-1, 2, C3k2, [1024, True]]
  - [-1, 1, SPPF, [1024, 5]] # 9
  - [-1, 2, C2PSA, [1024]] # 10

现在我们只盯着第三个位置,也就是模块名字:

Conv
Conv
C3k2
...
SPPF
C2PSA

这时候,出现了一个我们认识的老朋友:

Conv

刚才在 main.py 里,我们把Conv模块一行一行写出来,现在它真正出现在了 YOLO11 OBB 的模型蓝图里。 而且它并不是只出现一次。YOLO 会反复使用 Conv 完成基础特征提取、通道调整和下采样。

继续往下看,新的模块名字出现了。

C3k2
SPPF
C2PSA

其中 C3k2 在 Backbone 和后续特征融合部分反复出现,显然是一个重要角色;SPPF 则出现在 Backbone 靠后的地方, C2PSA 出现在 Backbone 的最后位置。

对于锻造核心算子这个任务而言,下一步的任务,已经被 YAML 自己“点名”出来了:

Conv     ← 已经完成
C3k2     ← 接下来重点
SPPF     ← 接下来重点
C2PSA    ← 稍后完成

这就是蓝图的作用了。

YOLO11 OBB 的结构蓝图告诉我们:想继续往下搭,这几个模块绕不开。

有一点要特别说明一下,C2PSA模块开始引入 Attention,也就是注意力机制,相对于卷积而言,这是另一条不一样的路径,为了行文的连续,我们把这个模块放到后面再讲,本卷除了Conv之外,我们主要关注C3k2和SPPF两个模块。


3.2.3 抽丝剥茧:梳理核心算子依赖

知道了要写这几个模块,但是这几个模块是干什么的?该怎么写呢?先写哪个后写哪个?

比如,YAML 可以告诉我们:

这里需要一个 C3k2。

可是它并不会告诉我们:

  • C3k2 内部到底长什么样;
  • 它还依赖哪些更基础的模块;
  • 如果要自己手搓,应该怎么写。

这就像一张汽车装配图上写着:

这里安装发动机

装配图告诉你“要发动机”,但它不会告诉你发动机里面的活塞、曲轴和气缸是怎么工作的。

想知道内部结构,还得继续顺着名字去看这些模块的实现源码。

我们再回到刚才的源码目录,block.py文件,顾名思义,应该就各类基础模块实现的地方:

block.py             # Bottleneck、C2f、C3k2、SPPF 等网络基础模块

我们在block.py文件中搜索 C3k2,定位到C3k2模块的实现代码:

class C3k2(C2f):
    """Faster Implementation of CSP Bottleneck with 2 convolutions."""
    def __init__(self, c1, c2, n=1, c3k=False, e=0.5, g=1, shortcut=True):
        """Initializes the C3k2 module, a faster CSP Bottleneck with 2 convolutions and optional C3k blocks."""
        super().__init__(c1, c2, n, shortcut, g, e)
        self.m = nn.ModuleList(
            C3k(self.c, self.c, 2, shortcut, g) if c3k else Bottleneck(self.c, self.c, shortcut, g) for _ in range(n)
        )

发现 C3k2 继承自 C2f ,而 C2f 内部又使用 Bottleneck,再往下追 Bottleneck,原来还是Conv你小子。

特别说明:Ultralytics 官方源码中的 C3k2 其实还提供了另一条分支:当 c3k=True 时,内部的 Bottleneck 会换成 C3k。而 C3k 本身又建立在 C3 模块之上。为了不在这里同时引入过多新模块,我们这一阶段先实现 c3k=False 的基础版本,也就是只使用 Bottleneck。等后面需要完整对齐 YOLO11 官方结构时,再补上 C3/C3k 分支。

画成关系图就是这样:

                  ┌─────────────────────┐
                  │                     │
Conv → Bottleneck → C2f → C3k2
                         │
                         └── 内部暂时使用 Bottleneck

看到这里,虽然还未必知道各个模块该怎么写,但模块之间的实现顺序就已经非常清晰了。因为源码中的依赖关系已经给了我们最好的指引,我们要做的就是从底层开始一块块的构建:

Conv          ← 已完成
  ↓
Bottleneck    ← 下一步
  ↓
C2f
  ↓
C3k2

SPPF模块的梳理思路也是一样的,源码给了我们最好的指引。

注意,上述探索“藏经阁”的过程,我们并没有把 Ultralytics 源码从头读到尾。

我们实际上只做了三件事:

第一步:看整体目录
        ↓
知道模型蓝图和核心模块大概分别放在哪里

第二步:打开 yolo11-obb.yaml
        ↓
看看完整 OBB 网络到底使用了哪些模块

第三步:在block.py或其他实现文件中检索对应模块,梳理依赖关系
        ↓
发现 C3k2 → C2f → Bottleneck → Conv 的依赖关系

而这三步已经足够回答开头的问题:

Conv 造完以后,下一步到底造什么?


💡 关键洞察

第一次读一个大型深度学习项目的源码,最忌讳的就是“从第一行开始往后读”。

更高效的方法是:先从模型结构中找到自己想研究的模块,再顺着模块名定位源码,然后沿着依赖关系一层层往下追。

这样,庞大的源码仓库就不再是一片森林,而会慢慢变成一张可以导航的地图。


下一节 核心算子锻造(三),我们就沿着刚刚找到的这条依赖链逐步实现C3k2和SPPF两个模块。

这其中Bottleneck是一个非常重要的基础模块。它会第一次把一个极其重要的思想正式带进我们的网络:

残差连接。

而这个看似简单的“捷径”,正是深层网络能够继续往更深处走的重要武器之一。