☰
mmDetection模型结构拆解:从Backbone到Head的全链路解析
2026/10/13 14:56:43 网站建设 项目流程

第一次在终端里把 mmDetection 的模型结构完整打印出来的时候,我盯着输出愣了好一会儿——一个训练好的检测模型,内部竟然嵌套了这么多子模块,从骨干网络到特征金字塔再到各种检测头,层层包裹,前缀越来越长。对新手来说,弄清这套结构之间的协作关系,往往比调参更让人头疼。这篇笔记就从网络架构角度入手,把 mmDetection 里一个检测模型的各部分拆开来看,包括它们各自管什么、怎么配合、改哪里会影响到最终结果。

这篇文章适合那些已经能跑通 mmDetection 训练流程、但对模型内部结构还比较模糊的同学,也适合准备自己改网络做实验的科研新手。我尽量把架构讲清楚的同时,给出一些直接在配置文件里能用的结论。

1. 模型打印出来的那一刻:先建立整体感官

第一次接触 mmDetection 的时候,大多数教程都会让你直接跑训练或测试流程,但很少有人让你先停下来看一眼模型本身长什么样。其实在项目源码里的模型构建阶段,一切结构信息都能通过简单的打印呈现出来。我自己习惯用一个很小的脚本,把配置加载后构建出模型对象,再打印它的整体结构。

from mmdet.models import build_detector from mmdet.apis import init_detector # 这里以常见的 Faster R-CNN 配置为例 config = 'configs/faster_rcnn/faster-rcnn_r50_fpn_1x_coco.py' model = build_detector(config.model) print(model)

输出结构非常长,但如果只关注骨架部分,会发现它基本遵循一个三段式结构:骨干网络(backbone)、颈部(neck)、检测头(head)。这种分层自己实现过检测网络的人应该很熟悉,mmDetection 的优势在于用统一的配置语言把这几个部分串接起来,替换任何一个组件都只改配置,不动逻辑代码。

Faster R-CNN 模式下,整个模型的顶层模块大致是这些,我用缩进简化后呈现:

FasterRCNN( (backbone): ResNet(...) (neck): FPN(...) (rpn_head): RPNHead(...) (roi_head): StandardRoIHead(...) (train_cfg): ... (test_cfg): ... )

这个结构对后续调试非常关键,因为当你改了配置却没有生效时,打印结构能立刻看出来改动落在哪个位置。我见过不少同学改 FPN 输出的通道数,结果改了 neck 的配置,但 backbone 输出通道没对应修改,打印结构后才发现中间维度对不上。

这里有个容易被忽略的细节:build_detector里的config.model不仅仅包含网络结构参数,它还会携带train_cfg和test_cfg两个配置项。这两个配置项不是网络层,却决定了训练和测试阶段的很多行为,例如 RPN 正负样本的采样数量、NMS 的参数、RoI 池化之后的阈值等。从架构理解的角度看,可以把它们理解为网络之外的两套控制逻辑,没有它们,模型结构虽然完整,但训练时照样会报错。

2. backbone 不一定是你以为的那个 ResNet

从 torchvision 里导入的 ResNet 和 mmDetection 中的 ResNet 是有区别的。很多新手在最开始自定义配置时,误以为 backbone 就是直接在torchvision.models.resnet50外面包一层,其实不然。最典型的区别在于构造参数:mmDetection 的 ResNet 允许指定out_indices、frozen_stages、norm_cfg等,这些参数直接服务于下游特征融合。

以默认的resnet50配置为例,骨干骨干网络的特征是由多个层级输出的,通常取out_indices=(0, 1, 2, 3),对应的是 conv2、conv3、conv4、conv5 四个阶段的输出。如果只取最后一个阶段,那后面的 FPN 就失去了多尺度信息的来源。曾经有个任务需要在更浅的特征上做检测,我把out_indices改成(0, 1, 2),结果模型直接报错,原因就是 FPN 输入层数不匹配,需要同步修改 neck 的in_channels。

backbone=dict( type='ResNet', depth=50, num_stages=4, out_indices=(0, 1, 2, 3), frozen_stages=1, norm_cfg=dict(type='BN', requires_grad=True), )

这里的frozen_stages=1表示第一个阶段的参数不参与梯度更新。这既是训练技巧,也是架构理解的一部分:既然底层特征相对通用,冻结它们可以节省显存,同时缓解小数据集上的过拟合。很多人在迁移学习时会忽略这个字段,认为预训练权重加载后所有层都应该微调,实际上冻结前几层往往效果更好。

另一个 mmDetection 版本和原版 ResNet 的不同点在于权重的初始化方式。使用 mmDetection 时,预训练权重的加载额外依赖 mmcv 的 load checkpoint 逻辑,通常是通过配置文件里的load_from字段指定。如果你自定义了一个新的骨干网络,而不是直接使用库内置的 ResNet,加载预训练权重时,键名必须完全匹配,否则会静默跳过。这个坑出现得很隐蔽,特征是模型能跑,但精度一直上不去,此时查看日志里的load checkpoint from ...后面紧跟的缺失键列表才是关键。

3. FPN 到底在融合什么:多尺度信息的对齐才是核心

FPN 是检测框架中容易理解但很难讲清楚的部分,因为它的实现看着不复杂,但是为什么这样设计,以及和原始特征图的对齐关系,值得认真走一遍。从我个人的理解看,FPN 的本质是把高层的语义信息和低层的空间细节进行融合,同时保持各层特征有独立的输出通道,以便不同尺度的目标都能分配到合适的检测层。

在 mmDetection 配置里,FPN 通常长这样:

neck=dict( type='FPN', in_channels=[256, 512, 1024, 2048], out_channels=256, num_outs=5, )

这段配置对应的就是骨干网络输出 C2、C3、C4、C5 四个阶段特征后,FPN 通过自上而下路径产生 P2、P3、P4、P5 四层,再加上对 C5 做下采样得到 P6,总共五层输出。为什么是num_outs=5,而不是 4?因为 RPN 通常需要一个额外的更小分辨率层来检测大目标。如果配置文件里设置num_outs=6,就需要额外指定平滑系数,否则会直接沿用默认的 0.5。

理解 FPN 的数据流向有一个很直观的方法:把每个输出层想象成一张分辨率不同的特征图,P2 是最精细的,P5 是最粗略的,每一层都融合了相邻层的信息。这个融合过程是通过 1x1 卷积改变通道数后再与上一层上采样的结果相加来实现的,所以 FPN 每层的输出通道数必须一致,配置里的out_channels统一设为 256 就是这个原因。

实操里最容易出错的点是in_channels的顺序必须和 backbone 的out_indices顺序一一对应。假设 backbone 输出顺序是(C2, C3, C4, C5),那in_channels就要写成[512, 1024, 2048]对应后三层的输入通道数,如果多加了一个256,模型虽然能建立,但训练时维度完全对不上。这个问题我自己排查过一整晚,最后是通过打印每层 tensors 的 shape 才发现的。

提示:修改 backbone 后,第一优先检查的顺序是:backbone 输出通道数、FPN 输入通道数、检测头的 in_channels。这三个维度一旦出现缺口,错误信息往往会在 RPN 或 RoIHead 的 forward 里冒出来,很难一眼定位到根因。

4. 检测头结构:两阶段框架里头的分工逻辑

Faster R-CNN 为代表的粗粒度两阶段结构,在我刚接触时觉得它有些绕,但理解后会发现这种设计把任务拆得很干净。第一阶段 RPN 负责提出候选区域,第二阶段 RoIHead 再对每个候选区域精细分类和回归。两者在网络结构上是分离的,在训练时却是联动的。

RPNHead 的结构不那么复杂,它接受 FPN 输出的多层特征,对每层分别做一次 3x3 卷积,然后再分叉成两个分支:一个负责分类目标分数,一个是回归坐标偏移。mmDetection 的 RPNHead 默认的 anchor 生成方式集成了多尺度、多比例的配置,配合num_anchors参数确定每层 anchor 的数量。能体现 RPN 特点的是它的正负样本定义:通过assigner和sampler两个组件完成,这部分在配置中通常以train_cfg.rpn的形式出现。

我第一次看 RPN 的正负样本定义时,最困惑的是为什么需要 "anchor-based" 的匹配逻辑,直接用 GT 中心坐标预测不就行了吗?后来亲手画过几次 anchor 和 GT 的交并比分布才明白:RPN 不直接预测绝对坐标,而是预测相对 anchor 的偏移,这个偏移量通常是归一化过的,数值范围小、模型容易收敛。这个设计思想贯穿整个两阶段框架,弄懂了它对理解后续的 RoIHead 会有很大帮助。

RoIHead 这部分,roi_head的类型选择决定了第二阶段的具体形式。以StandardRoIHead为例,它内部包含 bbox_head,bbox_head 的输入是 RoI 池化后的特征。RoIAlign 的配置参数比如roi_layer的output_size和采样率会影响输出特征的分辨率。很多新手误以为 RoIAlign 是一个普通池化层,改改 kernel size 就能适配各种尺寸的输出,实际上它和 RoIPool 的内在差异,恰恰是两阶段方法精度优势的来源之一。

这里不得不提一下 bbox_head 的最终输出节点数计算公式。假设类别数num_classes是 80,那分类分支输出节点是 80,回归分支输出节点是 320,因为每个类别要对应 4 个坐标偏移参数。这个公式看似简单,实际改自定义数据集类别时极其容易出错。比如你把num_classes从 80 改成自己的 10 类,忘记同步改 bbox_head 里的 Reg 分支节点数,训练时会出现 loss 维度不匹配的报错。反过来,如果你改了类别数却没有重新训练,只加载旧权重来做测试,很容易出现类别索引越界错误。

5. Anchor 生成机制:从"我怎么都记不住"到画出图就懂

Anchor 是目标检测里一个绕不开的概念,尤其在使用 anchor-based 方法时。我第一次接触 anchor 生成时,总是记不住不同尺度、不同比例到底生成了多少个框,后来干脆写了一段脚本把每层 anchor 可视化出来才算真正理解。

mmDetection 默认的 anchor 生成由anchor_generator控制,核心参数有strides、ratios、scales。其中strides对应特征图相对原图的步长,例如 P2 层 stride 是 4,P3 层是 8,依次类推。每个特征图上的每个位置会生成len(ratios) * len(scales)个 anchor。比如ratios=[0.5, 1.0, 2.0]、scales=[8],那每个位置就有 3 个 anchor。

我曾经遇到过一个典型的精度问题:小目标检测效果差,后来逐个检查每层特征图的 anchor 尺寸范围,发现自定义的 anchor 配置在低级特征层根本没有覆盖到小目标尺寸。调整scales和ratios之后,小目标效果立刻改善。这个经验说明,如果不用可视化工具检查 anchor 分布,单凭直觉调参往往事倍功半。

在 mmDetection 的配置里,anchor_generator 通常是放在rpn_head下的:

rpn_head=dict( type='RPNHead', in_channels=256, feat_channels=256, anchor_generator=dict( type='AnchorGenerator', scales=[8], ratios=[0.5, 1.0, 2.0], strides=[4, 8, 16, 32, 64], ), )

这里scales的含义需要解释一下:它并不是最终的 anchor 边长,而是相对于基础边长的一个倍率。Default 情况下,基础边长由 stride 直接决定,比如 stride=4 的那一层,基础边长为 4,如果scales=8,那实际 anchor 边长会更大。真正在代码里计算时,anchors 的最终尺寸是scale * stride。

所以修改 anchor 配置时,不要只盯着scales看,要结合strides和特征图分层一个个算一遍。手动计算一遍各层 anchor 尺寸后,我发现了一个容易忽略的点:FPN 的最深层 P6 对应的 stride 是 64,这里的 anchor 尺寸通常特别大,如果检测目标中没有这么大的目标,这部分层基本属于浪费计算资源,可以考虑在配置里减少深层输出或调整生成器的策略。

6. 前向传播的一次完整走读:用一个小案例把数据流串起来

手动走一遍前向传播的流程,比反复读代码更有效。拿一张 800x800 的输入图像举例,它先后经过归一化、数据增强、送入模型。模型内部输入首先进入 backbone,经过 ResNet 四个阶段后得到 C2 到 C5 四层特征,分辨率从 200x200 逐渐降到 25x25。这些特征进入 FPN 后,经过融合产生 P2 到 P5 四层,外加 P6 用于 RPN.

此时,RPN 会在每层特征图上生成大量 anchor,并预测每个 anchor 是否包含目标,以及相对于 anchor 的坐标偏移。经过 NMS 和 top-k 过滤后,大概会保留一两千个候选框。这些候选框会送到 RoIAlign,将每个候选框对应的特征区域裁剪并统一到固定尺寸,比如 7x7。随后进入 bbox_head,通过全连接层或卷积层得到每个候选框的类别分数和更精细的坐标回归。

最后在测试阶段,这些结果经过去除低置信度、NMS 合并重复框等后处理,形成最终输出。这个过程说起来一长串,但在 mmDetection 的代码里都封装在forward函数的各个子模块里。自己写一个简单网络走一遍这个流程,能彻底搞清楚每个张量的 shape 流转,至少能避免一半的维度错误。

我在本地搭过一个小实验脚本,手动构造一个伪输入 tensor 进去,一个层一个层地打印输出形状:

import torch from mmdet.models import build_detector model = build_detector(config.model) model.eval() x = torch.randn(1, 3, 800, 800) with torch.no_grad(): feats = model.backbone(x) for i, feat in enumerate(feats): print(f'backbone output {i}: {feat.shape}') neck_feats = model.neck(feats) for i, feat in enumerate(neck_feats): print(f'neck output {i}: {feat.shape}')

这种打印能很快发现配置里某些参数的不一致性。比如 FPN 输出的层数比预期少一层,或者某些层的通道数是偶数的,导致后续某个 GroupNorm 无法分组。

提示:先跑 model.eval() 再加no_grad能避免很多不必要的显存占用和计算。走读前向传播时,也可以直接调用model.forward(x, return_loss=False)看整体输出。

7. 改网络结构时最常踩的三个坑

7.1 backbone 输出和 FPN 输入的通道断层

这个是新手最容易踩的坑。很多人自定义 backbone 时会替换成更轻量的网络,但忘记修改 FPN 的in_channels,或者修改了out_indices但顺序没对齐,模型建立时看起来正常,运行时却报通道不匹配。定位方法很简单:打印 backbone 输出 tensor 的 shape,逐个对 FPN 配置里的in_channels,一定能发现不一致。

7.2 检测头类别数和新数据集的类别数脱节

切换自定义数据集时,除了修改数据集的metainfo,检测头里的num_classes也必须跟着改。如果你用的是 COCO 预训练权重,而自己的数据集只有两类,那么分类头最后的全连接层节点数不匹配。此时需要设置load_from并配合strict=False加载权重,让不匹配的层随机初始化。这里的关键是日志中会出现unexpected key或missing key的提示,很多人直接忽略,结果模型虽然能训,但精度上传得很慢。

7.3 修改特征层数后 RPN 的 anchor 分配没跟上

如果增加或减少了 FPN 的输出层数,RPN 的strides和anchor_generator必须同步修改,否则 anchor 生成时找不到对应层。这个问题比较隐蔽,因为报错信息可能只是简单的索引越界,不会直接提示 anchor 配置有误。我一直建议保存模型结构快照时,连同配置文件中的 FPNnum_outs和 RPNstrides一起打印出来,做一个对应关系表,排查起来效率会高很多。

8. 用配置文件反推结构关系,比直接看源码更快

我在入门阶段最大的误区是试图一行行读源码,后来发现配置文件本身就是结构的最佳说明书。mmDetection 的注册机制天然适合"配置即结构"的思维模式:你不需要关注具体类名怎么实现,只需要关心它接收哪些参数、输出什么维度的特征。理解架构时,我建议从配置倒推结构,而不是从代码推配置。

拿到一个模型配置后,先画出它的主干路径:输入图像 -> backbone -> neck -> rpn_head / roi_head -> 输出。然后再在主干路径上标注出每个模块输入输出的通道数、步长、输出层数,这样整个模型的数据流就能在脑内跑通。比如 ResNet 的输出通道是 [256, 512, 1024, 2048],FPN 输出全部统一成 256 通道,RPNHead 里的in_channels必须相等,RoIHead 的 bbox_head 也依赖这个 256 通道。

这种方式尤其适合快速评估一个新提出的模型,比如你想复现一篇论文的检测器,不需要逐行复现它内部的模块实现,直接用 mmDetection 的现有组件替换配置,剩下的工作集中在组件之间的连接参数上。我自己在复现一个自定义注意力模块时,就是用这种方式把论文里的模块包成一个nn.Module,注册到 MMDetection 里,然后在配置中用type=MyAttention引用,整个过程不到半小时就接上了。

9. 网络结构里那些"看不见"的计算逻辑

网络架构不仅仅等于用 PyTorch 搭出来的几个层,mmDetection 里很多训练和测试时的控制逻辑,是通过配置文件里的train_cfg和test_cfg注入的。从架构视角看,这些配置决定了模块之间的协作方式,例如 RPN 的正负样本采样如何控制、RoIHead 的训练 batch 大小、测试时 NMS 的阈值等。

具体来看,train_cfg里的rpn部分会配置assigner(比如 MaxIoUAssigner)和sampler(比如 RandomSampler),它们的参数直接影响 RPN 学到的特征表达。比如说pos_iou_thr=0.7表示 anchor 和 GT 的交并比大于 0.7 才视为正样本,这个值如果设得太高,正样本数量就会稀少,模型容易漏检;设得太低,负样本的语义不纯,分类器会混乱。

RoIHead 的assigner同样有几个关键参数,比如pos_iou_thr=0.5、neg_iou_thr=0.5,它们定义了第二阶段的正负样本划分边界。很多实验对比里所谓"换个分配策略涨点"的结论,本质上就是在调整这些看不见的计算逻辑,而不是网络结构本身。

理解这一点后,你在读别人的实验配置时就能抓住重点了:看一个检测模型好不好,不能只看 backbone 和 neck,还要看配套的训练策略,train_cfg里的很多细节决定了同样结构下收敛效果的不同。这也是 mmDetection 作为科研工具箱的优势之一,它允许你把结构和策略解耦开来,分别做消融实验。

10. 多 GPU 和数据并行对网络结构的影响

最后提一个容易被忽略的实际问题:分布式训练时,模型结构里某些模块的行为会发生变化。特别是 BatchNorm 层,在单卡和单卡训练时,BN 统计的是单个 batch 的均值和方差;在多卡分布式训练时,默认使用 SyncBN,其统计量会在多卡之间同步。如果不了解这一点,很可能会误以为自己模型结构有问题,实际是训练配置里的norm_cfg需要为SyncBN做额外调整。

mmDetection 里通过norm_cfg=dict(type='BN', requires_grad=True)控制归一化层类型。改成SyncBN的方式很简单,但要注意 SyncBN 对 batch size 的依赖更明显,如果每张卡的 batch size 都很小,SyncBN 的统计结果就会比普通 BN 更稳定,显存占用也会略高。实际上,很多检测模型在配置里直接使用SyncBN并不总是最优,小数据集或单卡环境下,普通 BN 反而效果更好,这需要你自己做对比试验。

多 GPU 训练时另一个容易影响结构理解的因素是fp16设置。开启混合精度训练后,某些层的数据类型会发生变化,特别是在自定义模块里如果忘记加fp16_enabled之类的处理,可能会和 mmDetection 内部的 autocast 不兼容。这个问题不常见,但一旦遇到,报错位置往往在自定义损失函数或自定义层里,容易被误判为结构问题。

我对网络结构的理解真正有质的提升,就是从一次打印模型、一次手动画出数据流开始的。那些复杂的嵌套关系,其实只要抓住骨干网络、特征融合、检测头、训练策略这几个部分,就能快速在脑海里建立完整的图谱。希望这篇笔记能给你提供一个比逐行读源码更轻松的入门路径。

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

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

立即咨询