1. 为什么先说builder:读 mmdetection 源码的第一道门槛
如果你有认真读过 mmdetection 的源码,应该有同感:大多数分析文章会把重心放在模型结构、训练策略或者数据流水线上,但实际当你准备自己改点东西的时候,最先卡住你的往往不是某个检测头怎么实现,而是builder.py背后那套“注册 + 配置解析”的机制。很多刚入门的同学第一次打开 mmdetection 的代码仓库,对着builder、build_from_cfg、REGISTRY这些名字看半天,也理不清它们究竟在做什么。
我当初也是同样的状态。那时我正尝试在自定义数据集上复现某个检测实验,只改了配置文件里的model = dict(type='MyBackbone'),一跑训练就报KeyError: 'MyBackbone is not in the registry'。当时第一反应是以为注册表写错了,翻来覆去在builder.py里找所谓的“注册列表”,结果发现根本没有一张硬编码的表。真正理解之后才明白:mmdetection 从头到尾靠的都是一套通用的“注册-查找-构建”机制,而这一套机制的核心代码,就是builder.py里那几百行逻辑。
这篇文章我不打算把它写成“源码逐行翻译”式的流水账,而是想以一份“阅读笔记 + 实操记录”的形式,把你真正需要掌握的核心脉络、关键设计思路和踩过的坑一起梳理出来。内容会涉及:builder 在整体架构里的位置、注册表(Registry)的底层实现、build_from_cfg的完整解析流程、以及实际阅读源码时的调试技巧和常见错误。适合正在读 mmdetection 源码、准备二次开发或者想搞懂“配置驱动”到底怎么实现的同学参考。
2. 整体设计思路:为什么 mmdetection 要用“注册表 + builder”
2.1 从一段配置文件说起
mmdetection 的使用方式对用户来说非常“轻”——你几乎不需要改 Python 代码,只需要维护一套配置文件:
model = dict( type='FasterRCNN', backbone=dict( type='ResNet', depth=50, out_indices=(0, 1, 2, 3)), neck=dict( type='FPN', in_channels=[256, 512, 1024, 2048], out_channels=256), rpn_head=dict( type='RPNHead', in_channels=256, feat_channels=256), roi_head=dict( type='StandardRoIHead', bbox_roi_extractor=dict( type='SingleRoIExtractor', roi_layer=dict(type='RoIAlign', output_size=7, sampling_ratio=0), out_channels=256, featmap_strides=[4, 8, 16, 32]), bbox_head=dict( type='Shared2FCBBoxHead', in_channels=256, fc_out_channels=1024, num_classes=80)))如果让我从零手写一个模型工厂,最直接的做法是写一堆if type == 'ResNet': return ResNet(...)之类的条件分支。但目标检测框架里有几百种模块,条件分支会膨胀成一座不可维护的“屎山”。而且每次新增模块,都得记得去某个公共工厂函数里加上对应分支,这对一个开源项目来说是不可接受的。
mmdetection 的解法是把“类的注册”和“对象的构建”解耦。任何模块类在定义时,通过@BACKBONES.register_module()这类装饰器登记到对应的注册表里;当配置文件写好时,再由统一的构建函数按type字段从注册表里把类取出来,实例化。
2.2 注册机制的核心价值
这套设计把“可扩展性”放在了最高优先级。新增一个 Backbone、一个 Head、一个 Dataset,只需要在文件里加上@BACKBONES.register_module(),然后再在配置里改一行type,代码主链路完全不用动。它等于是做了一个“类名到类的映射表”,让配置文件和 Python 类之间存在一条明确的索引关系。
换个生活化的类比:你去一家餐厅吃饭,不用走进后厨告诉厨师“热锅、下油、放葱姜蒜”,只需要对着菜单说“来一份鱼香肉丝”。厨房里早就把不同菜品对应的做法“注册”好了,你只需点“type”。builder 就是那个帮你把“菜名”变成“实际菜品”的传菜员。
2.3 builder 在 mmdetection 全流程中的位置
抛开纷繁复杂的模型结构,整个框架的启动链路其实可以用一句话概括:读配置 -> 构建模型 -> 构建数据集 -> 构建优化器 -> 训练/评估。而每一处“构建”几乎都绕不开builder.py:
build_backbone走的是BACKBONES.build(...)build_head走的是HEADS.build(...)build_dataset走的是DATASETS.build(...)build_optimizer走的是OPTIMIZERS.build(...)- Runner 本身也是通过
RUNNERS.build(...)构造的
也就是说,builder.py是连接“配置世界”和“代码世界”的中枢。
3. 注册表的底层实现:Registry 类拆解
3.1 Registry 的成员结构和基本方法
Registry类定义在 mmcv 的mmcv/utils/registry.py中,mmdetection 的builder.py是基于它构建的。整个类的核心数据结构异常简单:
class Registry: def __init__(self, name): self._name = name self._module_dict = dict() @property def name(self): return self._name @property def module_dict(self): return self._module_dict_module_dict就是那张“类名 -> 类对象”的映射字典。你向这张表里塞入一个新的类,就是“注册”;从表里按 key 取出来,就是“查找”;用取出来的类实例化对象,就是“构建”。
再看register_module这个关键方法:
def register_module(self, module_class=None, force=False): if not (isinstance(module_class, type) or module_class is None): raise TypeError(...) if module_class is not None: self._register_module(module_class, force=force) return module_class def _register(cls): self._register_module(cls, force=force) return cls return _register这段逻辑不复杂,但它支持两种用法:
@BACKBONES.register_module(),装饰器形式,Python 会把被装饰的类作为module_class传进来,直接注册并返回原类。@BACKBONES.register_module(force=True),带参数的装饰器,适用于“想要覆盖已有注册类”的场景。
然后看最底层的注册动作:
def _register_module(self, module_class, force=False): module_name = module_class.__name__ if not force and module_name in self._module_dict: raise KeyError('{} is already registered in {}'.format(module_name, self.name)) self._module_dict[module_name] = module_class换句话说,默认情况下同一个注册表里不允许出现两个同名的类,避免静默覆盖。这个设计在多人协作时非常重要。我前面提到的KeyError: 'MyBackbone is not in the registry',本质上是构建时_module_dict里没有'MyBackbone'这个 key,而不是注册表本身坏了。
3.2 作用域(scope)机制:解决“同名不同包”的冲突
Registry类里还有一个容易被忽略但非常关键的字段:scope。mmcv 的新版本中,Registry.__init__接收的不再只有name,还有一个scope参数,默认值通常是包的根命名空间,比如mmdet、mmcv、mmseg。
理解 scope 最快的方式是举一个实际例子。假设你自己写了一个第三方插件包my_plugin,里面也定义了一个ResNet:
@BACKBONES.register_module() class ResNet: pass如果没有 scope 机制,这个自定义ResNet就会直接覆盖 mmdetection 自带的ResNet,导致所有用到type='ResNet'的配置全部走你的实现,这显然是灾难。有了 scope 之后,同一个type='ResNet'的查找过程会先看当前包的范围,优先从对应 scope 下查找,避免不同包之间同名类的“串味”。
Registry内部通过_register_module找到module_class.__module__的值,然后从该模块路径中提取包名,拼进一个带 scope 前缀的索引里。所以同一个类名在不同包下可以被同时注册,它们通过scope区分。构建时,如果当前配置没有显式指定 scope,就默认从当前包范围(比如mmdet)里找;如果指定了type='mmcv:ResNet'这种带scope:Class的写法,就能精确地跨包取用类。
这个机制我强烈建议你在阅读源码时多做几次实验,因为它是“配置驱动多框架协同”的核心。实际上,配合mmcv 和 mmdetection 混合开发的场景,例如在 mmdetection 的配置里直接使用 mmcv 提供的type='mmcv:MaxIoUAssigner',靠的就是 scope 的前缀匹配逻辑。
3.3 get 方法:构建前的最后一步查找
注册表本身还提供了一个get方法:
def get(self, key): scope = self.scope if isinstance(key, str): if key not in self._module_dict: if key in self._children: return self._children[key].get(key) else: raise KeyError(...) return self._module_dict[key] else: return key注意get方法里有一层“如果找不到就去子注册表找”的递归逻辑。所谓“子注册表”(_children) 是 mmcv 为了支持不同注册表之间的继承关系引入的,例如MODELS可能是父注册表,BACKBONES、HEADS、NECKS都是它的子注册表。MODELS.get('ResNet')会先尝试从自身找,找不到再逐级下放到子注册表查找。这一层设计在实际使用中很重要,因为你经常会在配置文件里只写一个model = dict(type='FasterRCNN'),而FasterRCNN这个类实际上是挂在MODELS这个父注册表下的,它的内部组件(backbone、neck、head)则分别挂在子注册表下。
4. build_from_cfg:配置到对象的转换引擎
4.1 全流程核心函数拆解
builder.py的精华,最终都沉淀在build_from_cfg这个函数里。它是一个“所有模块构建的通用入口”,不区分你是模型、数据集还是优化器,只要给它一个配置字典和一个注册表,它就能返回对应的实例。这段逻辑用简化的伪代码看长这样:
def build_from_cfg(cfg, registry, default_args=None): if not isinstance(cfg, dict): raise TypeError('cfg must be a dict, but got {}'.format(type(cfg))) if 'type' not in cfg: raise KeyError('the cfg dict must contain the key "type", but got {}'.format(cfg)) args = cfg.copy() obj_type = args.pop('type') if isinstance(obj_type, str): obj_cls = registry.get(obj_type) elif isinstance(obj_type, type): obj_cls = obj_type else: raise TypeError(...) if default_args is not None: for name, value in default_args.items(): if name not in args: args[name] = value return obj_cls(**args)整个流程可以浓缩成四步:检查配置合法性 -> 取出type字段并从注册表拿到类对象 -> 合并默认参数 -> 实例化并返回。其中type字段支持传字符串或者直接传 Python 类对象两种方式。传字符串时走注册表查找,传类对象时直接就用那个类,这种方式在写测试代码或者调试自定义模块时很方便。
default_args参数容易被忽略,但它是个很实用的设计。比如构建整个检测器时,可能在build_detector内部把train_cfg、test_cfg这类全局配置统一塞进每个组件的默认参数里,避免在配置文件的每个子模块下重复写一遍。
4.2 递归构建:配置嵌套怎么展开
有些读者可能会问:如果 backbone 配置里本身还有子模块(比如某些 Neck 内部还有一个额外的特征融合模块),build_from_cfg会递归构建吗?
答案是:不会自动递归。build_from_cfg只负责把当前层级的dict按type构建成“类对象”,如果某个类的__init__参数里期待一个子模块,它需要自己调用build_from_cfg或对应注册表的build方法来完成递归。
举个例子,FPN这个类在初始化时可能会接收neck或extra_stages之类的配置:
class FPN(nn.Module): def __init__(self, in_channels, out_channels, ..., extra_stages=None): super(FPN, self).__init__() if extra_stages is not None: self.extra_stages = build_from_cfg(extra_stages, MODELS)所以框架里的递归构建并不是build_from_cfg的自动行为,而是由每个模块类内部显式约定去调用。这也是为什么配置里经常出现一层套一层——每一层都是build_from_cfg的“一次现场处理”,而递归的触控点由模块自己决定。我这里给你一个建议:当你自己写新的模块时,如果内部需要子组件,记得不要手动去拼参数字典,直接用build_from_cfg(cfg, MODELS),这样配置的风格才能和官方代码保持一致。
4.3 type 字段的字符串检查
build_from_cfg还有一个容易被忽视的细节:字符串 type 不允许带空格,但允许带点号。例如'mmcv:RoIAlign'这种写法会被 registry 解析成 scope 与类名两部分。如果类名带了包路径中的点,比如'mmdet.models.backbones.ResNet',在旧版 mmcv 里是会被当作普通字符串去_module_dict中查找的,查不到就报错。这种细节如果不知道,很容易在跨包使用某个类时卡住。
5. 从 builder 反推:mmdetection 的模块分层与注册表分布
5.1 主要注册表一览
builder.py文件虽然代码量不大,但它集中定义了最顶层的几个注册表。我手头看的版本大致是这样:
| 注册表变量 | 作用域 | 用途 |
|---|---|---|
BACKBONES | mmdet | 骨干网络,如 ResNet、ResNeXt、Swin |
NECKS | mmdet | 特征金字塔、FPN、PAFPN 等 |
ROI_EXTRACTORS | mmdet | RoI 特征提取器 |
HEADS | mmdet | 各类 head,包括 RPNHead、BBoxHead、MaskHead |
LOSSES | mmdet | 各类损失函数 |
DETECTORS | mmdet | 完整检测器,如 FasterRCNN、MaskRCNN |
DATASETS | mmdet | 数据集类 |
TRANSFORMS | mmdet | 数据增强变换,如 RandomFlip、Resize |
OPTIMIZERS | mmcv | 优化器,例如 SGD、AdamW |
RUNNERS | mmcv | 训练/测试 Runner |
MODELS | mmcv | 顶层模型注册表,通常作为其他模型相关子注册表的父级 |
仔细琢磨这个表格,你会发现两个不同层级:OPTIMIZERS和RUNNERS来自 mmcv,其余大多来自 mmdetection。这个分布意味着你如果想把某个 mmcv 里的优化器配置到 mmdetection 的实验里,就得写type='mmcv:AdamW'这种带 scope 的写法。
5.2 子注册表之间的继承关系
MODELS不只是个摆设,它会被传入Registry构造函数的parent参数,从而建立父子关系:
BACKBONES = Registry('backbone', parent=MODELS) NECKS = Registry('neck', parent=MODELS) HEADS = Registry('head', parent=MODELS)这个继承关系有什么用?有两个实际场景我遇到过。
第一,配置里写model = dict(type='FasterRCNN')时,model本身是挂在DETECTORS下的,但FasterRCNN这个类在初始化时内部会调用build_backbone、build_neck等,这些build_xxx函数实际都是BACKBONES.build(...)、NECKS.build(...)的简写包装,最终走的是它们自己的注册表,而不是从DETECTORS里找。
第二,如果你自己写了一个新的完整检测器,想挂在DETECTORS下,但又希望在MODELS.get(...)时也能找到它,由于继承关系存在,MODELS查不到时会继续去子注册表里找,这样统一入口做组件替换时会方便很多。例如有人想写一个自定义的build_model流程,只用一个MODELS.build(cfg)就能把任何模型组件构建出来,背后的支撑就是这种递归查找能力。
5.3 为什么需要“构建器”这一层,而不直接调类
这个问题我思考了很久,后来在写插件时终于想通:如果直接在代码里到处import对应的类来实例化,那么整个框架就绑死了一组硬编码的类;而经过build_from_cfg中转,模块之间的关系全部“延迟”到运行时,由配置来决定。说得再直白一点:你可以把整个 mmdetection 当成一个“配置驱动的框架”,builder.py就是你自定义行为和框架既有行为之间的握手协议。你要做的不是去改人家的build_backbone,而是向BACKBONES注册自己的类,再用任何你喜欢的配置去描述它。
6. 源码阅读实操路线与验证方法
6.1 从哪个文件开始读
我的建议是不要一上来就钻builder.py里的每一行细节,而是先感性地建立“配置 -> 构建”的全局印象。操作顺序可以是这样:
- 打开官方已有的一个配置文件,比如 Faster R-CNN 的
faster_rcnn_r50_fpn_1x_coco.py。 - 找到
model = dict(...)部分的type字段。 - 在工程里搜索
class FasterRCNN,找到类的定义文件。 - 再看类定义头部是否带
@DETECTORS.register_module()。 - 然后退回
builder.py,看build_detector是怎么封装的。 - 再往下追
build_from_cfg。
这个顺序是最自然的溯源路径:从结果反推原因。而不是一上来就背诵 Registry 的源码。
6.2 自己写一个最小注册示例
为了验证理解,我用一个几十行的小脚本做过实验,效果比干看源码好得多。你可以直接把下面这个例子丢进一个新.py文件里跑:
from mmcv.utils import Registry # 创建一个玩具注册表 TOY = Registry('toy') @TOY.register_module() class ToyA: def __init__(self, value): self.value = value def show(self): print('ToyA:', self.value) @TOY.register_module() class ToyB(ToyA): def show(self): print('ToyB:', self.value) # 模拟 build_from_cfg 的简化版 def toy_build(cfg): obj_type = cfg.pop('type') obj_cls = TOY.get(obj_type) return obj_cls(**cfg) # 构造配置 cfg1 = dict(type='ToyA', value=10) cfg2 = dict(type='ToyB', value=20) a = toy_build(cfg1) b = toy_build(cfg2) a.show() # ToyA: 10 b.show() # ToyB: 20跑通这个脚本之后,再回去读build_from_cfg的原版实现,思路会通畅很多。你甚至会自己发现原版里比玩具版多出的那些边界检查和default_args逻辑,都有什么存在意义。
6.3 如何快速定位某个模块是否注册成功
实际使用中,如果要确认一个类确实注册进去了,最快的办法不是打印_module_dict的全部内容(那会刷屏),而是这样:
from mmdet.models import BACKBONES BACKBONES.get('ResNet')如果能返回类对象,说明注册成功;如果抛KeyError,说明没注册上。这里的get会走子注册表逻辑,所以即使模块挂在子注册表下也能查到。这是我排查“明明写了装饰器却找不到”类问题时最常用的探针。
7. 常见问题与排查技巧实录
7.1KeyError: 'xxx is not in the registry'
这个报错出现频率最高,我把它拆成几种原因。
第一种,模块类没有写装饰器。比如你自己定义了MyBackbone,但忘了在 class 声明上方加@BACKBONES.register_module(),那无论配置写得多对,注册表里都没有你。
第二种,写了装饰器但导入顺序不对。register_module()这个动作只有在模块被导入时才会执行。如果你的模块定义在一个没有被 import 的文件里,注册动作就不会发生。mmdetection 通常在mmdet/models/backbones/__init__.py里统一from .my_backbone import MyBackbone,如果你新增文件后没有加到对应的__init__.py中,同样会报这个错。
第三种,类和注册表不是同一个。例如你在HEADS下试图注册一个 backbone,然后配置里又用BACKBONES.build来构建,跨度不对,那自然找不到。
7.2 自定义模块改了代码但训练没有任何变化
这种情况比报错更让人困惑。后来我发现大概率是模块根本没有被加载。例如配置里使用type='MyPlugin',但工程里没有哪个__init__.py会import这个模块,mmdetection 启动时压根不知道它的存在,这时build_from_cfg就会提示找不到。排除方法很简单:在训练脚本开头强制import my_plugin,或者直接在运行时打印dir(BACKBONES.module_dict)确认。
7.3 同名类覆盖导致行为诡异
我曾在实验里同时加载了两个不同插件包,它们都注册了名为MyHead的类。由于Registry默认不允许重复注册,第二次注册会抛出KeyError。但当某个包通过register_module(force=True)强行覆盖时,模型就可能用上“我以为不会生效”的类,导致结果异常难排查。遇到这种情况,最有效的诊断是打印注册表中对应类名的模块来源:
print(BACKBONES.module_dict['MyHead'].__module__)这样能直接看到当前生效的实现到底来自哪个文件。
7.4 配置文件里 type 带前缀却解析失败
如果在配置里写type='mmcv:RoIAlign'却报找不到,先检查当前 mmcv 版本是否支持 scope 语法,以及RoIAlign是否真的注册在mmcv这个 scope 下。跨包引用时前缀必须和包的 scope 完全一致,不能随便写。
7.5 构建时传参报错
build_from_cfg相当于执行obj_cls(**args),如果配置里某个字段是类对象构造所不期望的,一样会报TypeError: __init__() got an unexpected keyword argument 'xxx'。这类问题通常被配置文件的冗余字段引起。排查时先把args打印出来,把多余的键删掉即可。
8. 从源码阅读到二次开发:builder 机制的实用延展
8.1 注册表模式的泛化价值
读builder.py给我最大的收获并不是 mmdetection 这个框架本身,而是“注册表模式”这套通用设计思想。它其实可以迁移到很多其他场合。比如你自己写一套分布式训练框架,想让每个 worker 能根据配置动态选择数据增强策略,完全可以照搬 Registry + build_from_cfg 的结构,而不是在代码里写一堆 if-else。这套模式的核心套路就是:约定好注册入口,提供统一的构建函数,配置与代码彻底解耦。
8.2 插件化开发的接入点
基于 builder 机制,很多二次开发都不用动框架主代码。例如我想在 mmdetection 加入一个新数据集格式,只需要:
- 自定义 Dataset 类,继承官方基类,加
@DATASETS.register_module()。 - 在配置里把
dataset的type改成自定义类的类名。 - 确保文件被导入,比如通过
custom_imports或在入口脚本里import。
这个体验非常舒服。只要你对“注册的时机”和“构建的入口”有清晰认知,就基本不会被框架束缚。
8.3 Debug 时的数据流理解
实际 Debug 时,建议在build_from_cfg里打断点,一步步看cfg怎么传入、obj_type怎么取出、obj_cls是哪个类、args里最终有哪些参数。这比漫无目的地在模型内部打点高效得多。尤其当你怀疑“配置没有生效”时,先确认构建阶段传入的参数,再往模型内部看,能少走很多弯路。
9. 一些值得反复体会的源码细节
如果只追求“会用”,前面的内容已经足够。但如果你和我一样想真正把这段源码读透,下面这几个细节值得单独拿出来体会。
第一,Registry用了很多 Python 的元编程技巧,比如装饰器类方法里返回函数的设计。register_module在module_class is None时返回一个_register函数,这使得它既能做无参装饰器,又能做有参装饰器。这个技巧在你自己设计类库时可以借鉴。
第二,build_from_cfg对配置字典做的是浅拷贝cfg.copy(),然后pop('type')。这意味着原来的配置字典并不会被修改,后续重复使用同一份配置字典不会有副作用。这一点很多新手没有意识到,他们担心pop会破坏原配置。
第三,scope 机制的引入实际提升了框架的“多包共存”能力,配合 mmcv 的“插件包”导入逻辑,理论上可以拼装出极其灵活的跨库组合。但副作用是如果 scope 写错,报错信息不会提示你是 scope 不匹配,只会在注册表里找不到。排查这种问题时要有点耐心。
10. 写在最后的实操经验
读builder.py这段时间,我自己踩过的最深的一个坑,是在一个自定义数据集上跑了很久,后来发现数据集类压根没被注册,每次实验用的都是官方默认类。原因是项目里新增的数据集文件没有加入__init__.py,而 mmdetection 的DATASETS.build只在构建时按type查找,文件不导入,注册动作什么都不发生。后来我把所有自定义模块统一收集到一个插件包,并在配置里显式引入导入声明,就再没出过这类问题。
还有一个体会:读这种源码千万别急着记住每一行代码。你应该建立的是一张“地图”——知道某个机制在哪,知道某个问题该查哪段逻辑,比背下整个文件重要得多。等你真正需要动手改代码时,再回过来精读细节,效率会高很多。
如果你正准备读 mmdetection 的源码,建议先花半天时间把 builder 这套机制琢磨清楚,它会成为你理解后续一切配置、模块扩展、跨包协作的基石。之后再去追 model、runner、dataset 的实现,你会发现整条链路串联得非常顺畅。
最后分享一个小技巧:在阅读任何以“配置驱动”为设计核心的项目源码时,都可以先找到那一个“把配置字典变成对象”的函数,把它读透,剩下的主线基本就能自然展开了。mmdetection 里这个函数就是build_from_cfg,而它背后,就是builder。