☰
mmDetection数据输入全解析:从Dataset到DataLoader的完整链路
2026/10/19 10:14:51 网站建设 项目流程

如果你跟我一样,最开始接触目标检测,用的不是那些开箱即用的在线训练平台,而是直接在某台服务器上把代码拉下来,面对一堆陌生的目录结构和看不懂的配置,大概率会经历这样的阶段:模型能训练,loss能掉,测试也能出框,但你要是问自己一句"图片到底是怎么从硬盘上一路走到模型里的",很多人就答不上来了。

这个"答不上来"其实很要命。因为科研和调库不一样,调库你只要知道接口长什么样就行,科研你得随时改数据、改预处理、改格式,出问题要能定位是哪个环节错了。而 mmDetection 这个框架里,数据输入恰恰是所有自定义改动最先碰到的部分。我见过太多同学,改数据集的时候报错,看到一堆堆栈直接蒙掉,最后把锅甩给"框架太复杂"——其实大多数时候,问题都出在数据链路上,而且并不复杂,只是你没有把这条链路完整地走一遍。

所以我这篇笔记,就想把 mmDetection 的数据输入部分彻底讲透。不绕弯子,从"数据输入到底包含哪些组件"开始,到配置文件里每一行是什么意思,再到实际踩过的坑,一步一步带着你把这条链路走通。你不需要已经读过源码,只要你跑通过一次 mmdet 训练,这篇笔记就能帮你把缺口补上。

1. 数据输入到底在说什么:一次从硬盘到显存的数据旅行

1.1 很多人学了半年 mmdet,卡在"输入"这一步

我先说一个最常见的现象。大部分人学 mmdet,第一件事是跑一个 demo,比如用自己的数据训练一个检测器。在这个过程里,你大概率接触过.py配置文件里长这样的片段:

train_dataloader = dict( batch_size=2, num_workers=4, persistent_workers=True, sampler=dict(type='DefaultSampler', shuffle=True), dataset=dict( type='CocoDataset', data_root='data/coco/', ann_file='annotations/instances_train2017.json', data_prefix=dict(img='train2017/'), filter_cfg=dict(filter_empty_gt=True, min_size=32), pipeline=[...] ) )

很多人对待这段配置的态度是"能用就行"。改改data_root,换换ann_file,然后训练就启动了。但你要问他:pipeline里那几十行到底是按什么顺序跑的?每个type对应的类在哪里?CocoDataset加载标注之后数据以什么形式存在?DataLoader 又是怎么把一个个样本拼成一个 batch 的?基本就沉默了。

这不是你不努力,而是框架的抽象层级确实多。mmdet 的数据输入并不只是"读图片+读标注"这么简单,它是由数据集类、流水线 transform、采样器、DataLoader、数据预处理器这几个角色共同完成的。你只有把每个角色的职责分清楚,才能在出问题的时候第一时间判断"是哪个角色把事情搞砸了"。

1.2 数据输入链路的三段式架构:读文件、pipeline 加工、组装 batch

我习惯把 mmdet 的数据输入理解成一个"三段式流水线",因为这样最容易记忆:

第一段是原生数据读取。由 Dataset 子类负责,比如CocoDataset。它做的事情是解析标注文件,把每张图片的路径、宽高、标注框、类别 id 等等,整理成一个个 Python dict。这个 dict 就是整条流水线的"原材料",后面所有操作都是对这个 dict 做增删改。

第二段是pipeline 逐级加工。配置里pipeline是一个列表,里面每一项都是一个字典,指定了某个 transform 类的名字和参数。mmdet 会按列表顺序,依次调用每个 transform,每次都传入上一步处理过的 dict,处理完再传回这个 dict。这一段的职责非常杂:读取图像数组、做随机翻转缩放、转 tensor、归一化、打包成特殊的数据结构等等。

第三段是组装 batch 与最终预处理。DataLoader 拿到 pipeline 输出的一个个 dict 之后,通过collate_fn把这些 dict 合并成一个 batch。但这个 batch 还不是模型直接能吃的输入,还要经过DetDataPreprocessor再加工一次,比如把图片 pad 到同一尺寸、做归一化、把标注信息整理成模型需要的格式,最后才是模型真正看到的输入。

我画过一张很简陋的示意图给自己看,大概长这样:

图片路径/标注 →(Dataset 解析)→ dict →(pipeline 逐级处理)→ dict(含图像数组、gt 信息)→(collate)→ batch dict →(DataPreprocessor)→ 模型输入

你把这五步记住,后面所有细节都是往这五步里填肉。

2. 注册机制与配置驱动:为什么改一个 config 就能换数据源

2.1 一套"名字找类、参数实例化"的构建体系

第一次打开 mmdet 源码的人,大概率会被一个东西震住:里面的类上面都挂着一个装饰器,比如@DATASETS.register_module(),或者@TRANSFORMS.register_module()。这个装饰器不是在炫技,而是 mmdet 整个"配置驱动"思想的基石。

你可以把注册机制想象成一本花名册。每个类在定义的时候,就把自己的名字登记到某个类别的花名册里。当配置文件里写type='CocoDataset'时,框架就会去DATASETS这个花名册里查有没有一个叫CocoDataset的项,有就把配置文件里其他参数一并传进去,创建实例。

所以你在配置文件里随便写:

dataset=dict(type='CocoDataset', ...)

框架自动能找到并实例化这个类。这个设计有巨大的好处:你想要换数据集、换预处理,不需要去改框架源码,只需要改配置文本。科研跑实验最怕的就是为了一个小小的改动去动源码,一动就容易影响其他地方,而注册机制从根上解决了这个问题。

2.2 完整路径:从 mmdet.datasets 到 config 里一个 dict 的实例化过程

那注册机制到底是怎么生效的?这里有一个特别容易被忽略的细节:你必须在某个地方 import 过那个类,它才会被登记到花名册里。

如果你去看 mmdet 的__init__.py,会发现它做了大量的导入操作,把所有内置数据集、transform、模型都 import 进来了。所以正常情况下,你装好 mmdet,然后写from mmdet.datasets import *(或者框架内部帮你 import),所有内置类就都在花名册里了。

整个流程简单说就是:

  1. 你写配置:dataset=dict(type='CocoDataset', data_root='data/coco/', ...)
  2. mmdet 读取到type字段,去DATASETS注册表里查找'CocoDataset'
  3. 找到了对应的类,于是把配置里其他字段作为关键字参数,调用CocoDataset(data_root='data/coco/', ...)
  4. 构造函数执行的时候,内部会调用load_data_list()之类的方法,真正去解析标注文件,得到一个data_list
  5. 之后训练循环里,通过__getitem__访问某个样本,再调用self.pipeline(...)对单个样本做变换

这里有一个点值得说透:配置里除了type之外的字段,全部会作为关键字参数传给构造函数。所以你在网上看别人的配置时,看到一些奇怪的自定义字段传进去,不用惊讶,那只是那个类的__init__里定义了这些参数。如果你自定义了一个数据集类,也照这个规则写就行。

2.3 科研中如何利用注册机制快速做实验

我自己的一个习惯是:永远不直接改内置类的源码,而是继承它,注册成一个新类。举个例子,如果我想在加载图片时额外做点什么事,我不会去改LoadImageFromFile,而是写这样的代码:

from mmdet.datasets.transforms import LoadImageFromFile from mmdet.registry import TRANSFORMS @TRANSFORMS.register_module() class MyLoadImage(LoadImageFromFile): def __init__(self, my_param=1, **kwargs): super().__init__(**kwargs) self.my_param = my_param def transform(self, results): results = super().transform(results) # 在这里做你自定义的事情 return results

然后在配置里:

pipeline=[ dict(type='MyLoadImage', my_param=2), ... ]

这样我只新增了一个文件,没有动框架任何源码。后面跑对比实验的时候,可以随时把type换回LoadImageFromFile,完全无痛。这个操作几乎是所有 mmdet 二次开发的通用姿势。你一旦掌握了"继承+注册"这个套路,自定义数据集、自定义 transform、自定义模型结构,思路全都一样。

3. pipeline 里面到底发生了什么:核心 transform 逐帧拆解

3.1 一次输入图像在 pipeline 里的身份变化

我拿一个最常见的训练 pipeline 来说。下面这段配置基本是很多检测任务默认的样子:

pipeline = [ dict(type='LoadImageFromFile'), dict(type='LoadAnnotations', with_bbox=True), dict(type='Resize', scale=(1333, 800), keep_ratio=True), dict(type='RandomFlip', prob=0.5), dict(type='Normalize', mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True), dict(type='Pad', size_divisor=32), dict(type='DefaultFormatBundle'), dict(type='Collect', keys=['img', 'gt_bboxes', 'gt_labels']), ]

注意,不同版本 mmdet(2.x 和 3.x)的 pipeline 写法略有差异,但核心思想完全一致。上面这个是偏 2.x 风格的写法,3.x 会用PackDetInputs代替后面两项,但前几项基本一样。

我可以告诉你这条 pipeline 跑完之后,你的数据经历了什么:

  • LoadImageFromFile:根据results['img_path']读取图片,得到 H×W×3 的 numpy 数组,也可能顺便读取results['ori_shape']、results['img_shape']等元信息。
  • LoadAnnotations:根据results['ann_info']把标注信息取出来,放到results['gt_bboxes']和results['gt_labels']里。
  • Resize:把图片缩放,同时必须把gt_bboxes同步缩放。
  • RandomFlip:以一定概率翻转图片,同时翻转 bbox。
  • Normalize:把像素值减去均值、除以标准差。
  • Pad:把图片 pad 到size_divisor的整数倍,为后面网络的下采样做准备。
  • DefaultFormatBundle:把 numpy 数组转成 PyTorch Tensor,把 bbox 也转成 Tensor。
  • Collect:只保留keys里指定的字段,丢掉其他没用的。

这一条链路走完,一个 dict 的img字段从"文件路径",变成"numpy 数组",最后变成"Tensor"。这就是我前面说的"原材料 → 加工品"的过程。

3.2 Resize 与 bbox 同步:不只是缩图

我之所以把 Resize 单独拿出来讲,是因为它是小白最容易出问题的环节。

很多人在自己写的预处理代码里,做缩放可能就是cv2.resize一下完事。但在目标检测里,你缩放图片的同时,标注的 bbox 坐标也必须跟着变。否则就会出现"图变大了,框还在原来的小坐标上"这种离谱问题。

mmdet 里的Resize是怎么处理这个问题的呢?它内部有一个_resize_bboxes之类的逻辑。简单说:

  • 如果keep_ratio=True,它会先根据目标scale(如 1333×800)计算一个缩放比例ratio,然后图片按这个比例缩放,如果长宽比不一致,会在短边方向填充到目标尺寸,或者保持原尺寸然后后续用 Pad 补齐。
  • bbox 的坐标(x1, y1, x2, y2)会分别乘以这个ratio。
  • 但这里有个非常隐蔽的点:如果图片这次缩放是"保持长宽比+pad"的,那 bbox 坐标的平移量要和 pad 的偏移量对齐。不同实现里算 pad 偏移的方式不一样,所以千万别自己在 pipeline 外面手动写一个 resize 就以为万事大吉。

我举个例子。假设原始图片是 800×600(宽×高),目标是scale=(1333, 800)。如果keep_ratio=True,框架会先把长边缩放到 1333,得到宽 1333、高约 1000(因为 600×1333/800 ≈ 1000),然后发现高度超出 800,于是重新计算,把短边限制在 800,长边相应变成约 1066。最终得到一个 1066×800 的图。你的 bbox 坐标要跟着这个比例一起变,并不是简单乘以某个固定数。

这也是为什么强烈建议:所有几何变换,能用框架内置的就用框架内置的,因为它们是经过测试的。自己写的 transform 十有八九会在这种细节上出 bug。

3.3 随机翻转、标准化与 Pad:为什么顺序不能乱

Pipeline 的顺序是有讲究的,不是随便排的。

先说RandomFlip为什么必须在Resize后面、Normalize前面。因为翻转是针对"像素坐标"的操作,它需要知道图片当前的实际尺寸。如果先归一化再翻转,理论上也能做,但翻转后你还要考虑 bbox 坐标跟着变,归一化又不影响坐标,所以放在一起处理更自然。更重要的是,RandomFlip要随机生成一个概率,如果放在 Resize 前面,这个随机性不会影响后续流程,但语义上放在 resize 之后更清晰——因为翻转的坐标系基于缩放后的图片。

再说Normalize为什么在Pad前面。归一化是对每个像素做(x - mean) / std。如果先 Pad 再归一化,填充区域(通常填 0)会被当成真实像素去减均值除标准差,这会把填充区域变成负值,而且数值还会被放大很多,完全不是 0。如果先归一化再 Pad,填充值一般就是 0,模型看到的是"干净"的边界。所以框架默认顺序是Normalize→Pad,这也是很多从别的框架转过来的同学容易忽视的点。

至于Pad的作用,就一句话:让 batch 里所有图片的尺寸对齐,并且让尺寸是某个数的倍数。检测模型尤其是带 FPN 的模型,特征图要经历多次下采样,如果输入尺寸不是 32 的倍数,不同层特征图对齐会很麻烦。size_divisor=32就是保证输入能被 32 整除。

3.4 打包与 DataLoader:collate 把 list 变成 batch

经过前面这些变换,你的数据还是"一个样本一个样本"地躺在 pipeline 输出的 dict 里。但训练时模型希望一次吃一个 batch,比如一次给 2 张或 8 张图。DataLoader的collate_fn就是做这个合并动作的。

如果你自己调试过,你会发现每次__getitem__返回的 dict 里,img是个 Tensor,形状是(3, H, W)。DataLoader 会把一个 batch 里所有样本的imgTensor 叠在一起,得到(B, 3, H, W)。但是问题来了:如果 batch 里两张图尺寸不一样,怎么叠?

答案就在前面那个Pad里。因为 pipeline 已经把所有图 pad 到一样大了,所以叠起来是没问题的。这也是为什么Pad几乎是所有检测任务 pipeline 里必备的一环。但凡你在某个自定义数据集里漏掉了Pad,训练跑起来大概率报 "stack expects each tensor to be equal size" 之类的错。

在 mmdet 3.x 里,PackDetInputs会在Collect的基础上做更彻底的打包:它会把图像、标注、元信息全部整理进一个DetDataSample对象。DataLoader 拿到一堆DetDataSample之后,再用collate把它合成一个 batch 级的DetDataSample。这个对象后面会直接传给模型的loss或predict方法。你在 3.x 的源码里会频繁看到这种风格。

4. 配置一个可用数据输入:从 Dataset 到 DataLoader

4.1 一张最小可用配置的逐参数解读

我干脆给一份注释很全的最小配置,你对比着自己的配置看:

# 数据根目录,通常里面放着图片文件夹和标注文件 data_root = 'data/mydata/' train_dataloader = dict( batch_size=2, # 每次迭代喂给模型的图片数量,显存不够就调小 num_workers=4, # 用几个子进程来加载数据,不是越大越好,后面会说 persistent_workers=True, # 训练完一个 epoch 不销毁 worker,避免反复创建进程的开销 sampler=dict( type='DefaultSampler', # 默认采样器,shuffle=True 表示每个 epoch 打乱数据顺序 shuffle=True ), dataset=dict( type='CocoDataset', # 数据集类型,按 COCO 格式组织 data_root=data_root, ann_file='annotations/train.json', # 标注文件路径,相对于 data_root data_prefix=dict(img='images/'), # 图片文件夹路径,相对于 data_root filter_cfg=dict(filter_empty_gt=True, min_size=32), # 过滤掉没有标注的图和太小的图 pipeline=[...] # 训练时用的数据变换流水线 ) )

逐行看你会发现,大部分字段都是在告诉框架"去哪里拿数据",真正对数据内容做处理的只有pipeline。所以如果你换了新数据集,改的往往就是data_root、ann_file、data_prefix这三样,如果标注格式不是 COCO,就需要换type或者自己写一个 Dataset 类。

4.2 train/val/test 三套 pipeline 的差异

很多人一开始会犯一个错:直接复用训练时的pipeline去验证和测试。这在逻辑上当然也能跑,但效果和速度都会有影响。

标准的做法是:

  • 训练 pipeline:包含随机增强,比如RandomFlip、随机Resize、RandomCrop等。
  • 验证/测试 pipeline:只做必要处理,比如LoadImageFromFile、Resize、Normalize、Pad,不做随机翻转。

为什么测试时不做随机增强?因为增强的目的是增加数据多样性、提升泛化,但测试时你希望同一个物体每次输入模型都得到相同的结果,如果测试时还随机翻转,那模型输出就不可复现了,评估指标也会抖来抖去。

还有一点:测试时的Resize也常常和训练时不一样。scale的选择会影响 mAP。如果你训练时用的是(1333, 800),测试时最好用同一个尺度,否则模型的感受野和对目标大小的适应性会变化,指标会掉。

4.3 性能相关参数怎么调:num_workers、pin_memory、persistent_workers

这几参数看起来不起眼,但对训练速度影响非常大。

num_workers是数据加载的进程数。如果你是 CPU 读取图片,GPU 在算前向,两个是并行的。进程数太少,GPU 会等着数据;进程数太多,进程切换和内存复制开销又会上来。我自己的经验是:num_workers = 4其实是一个通用起点,如果机器内存大、CPU 核数多,可以试 8,但别盲目拉到 16,因为实测并不一定更快,有时候还会因为内存带宽而变慢。

pin_memory=True是个好东西。它的意思是让 DataLoader 把数据放到锁页内存里,这样 GPU 从 CPU 拷贝数据会更快。代价是占用的内存变成不可交换的,也就是说你不能再依赖虚拟内存去"假装"内存够用。所以如果你的内存很紧张,先别开这个,否则就是杀鸡取卵。

persistent_workers=True是我强烈建议开的一个参数。默认情况下,每个 epoch 结束,DataLoader 会把 worker 进程停掉,下一个 epoch 再重新启动。这个重启是有开销的。开了persistent_workers之后 worker 一直活着,数据加载会稳定提速不少。代价是内存占用会稍微涨一点,因为 worker 不会释放一些缓存。只要不是内存捉襟见肘,开它。

5. 自定义数据集与常见报错排查实录

5.1 最短路径:把新数据集接入 mmdet 而不改框架源码

我接触过的科研项目,很少有直接用公开 COCO 数据的,大多数都是自己的标注数据。最常见的情况是:标注文件是 COCO 格式的 JSON。这种情况最简单的做法是直接用CocoDataset,只要路径配对就行。

但如果你的数据是别的格式,比如每个图片一个对应的.txt,每行是class x1 y1 x2 y2,你可以走自定义 Dataset 的路子。我看过不少教程,会教你从头写__getitem__、__len__,实际上 mmdet 已经把大部分框架搭好了,你只需要做一件事:实现load_data_list(self),返回一个 list,每个元素是一个 dict,包含img_path、width、height、instances这些字段。

官网的 dataset 基类已经帮你处理好了对pipeline的调用,你只需要告诉它"样本长什么样"。

举个例子,一个极简版本:

from mmdet.datasets import BaseDetDataset from mmdet.registry import DATASETS @DATASETS.register_module() class MyTxtDataset(BaseDetDataset): def load_data_list(self): data_list = [] # 假设你的 txt 里每一行是:图片名 class_id x1 y1 x2 y2 for line in open(self.ann_file): parts = line.strip().split() img_name = parts[0] bbox = [float(x) for x in parts[2:6]] label = int(parts[1]) data_list.append(dict( img_path=f'{self.data_root}/{img_name}', width=0, # 有些场景可以在加载时再读取真实宽高 height=0, instances=[dict(bbox=bbox, bbox_label=label)] )) return data_list

这里面有些细节需要根据你实际的标注格式去填,但整体思路就是这样。比从头实现一个 Dataset 要省太多事了。

5.2 报错速查表:五次踩坑五个根因

我梳理了几个数据输入环节最常见的报错,附上根因和解决方案,你大概率会碰到其中至少一两个:

报错现象根因解决办法
FileNotFoundError: [Errno 2] No such file or directory指向某张图片data_prefix路径配错,或者图片文件名和标注文件里的文件名对不上检查data_root与data_prefix的拼接结果,直接在终端 ls 一下确认路径
TypeError: bbox must be a kind of Tensor之类LoadAnnotations没有在Resize之前执行,导致 bbox 字段不存在或不是预期格式检查 pipeline 顺序,LoadAnnotations应该放在任何需要 bbox 的几何变换之前
RuntimeError: stack expects each tensor to be equal sizebatch 内图片尺寸不一致,说明缺少Pad或Pad的size_divisor设置不对在 pipeline 里加dict(type='Pad', size_divisor=32),确保所有图 H 和 W 都能被 32 整除
训练 loss 很低但 mAP 也很低大概率是标注加载环节出了问题,比如类别 id 偏移、bbox 坐标没归一化、或者图片 RGB/BGR 通道顺序搞反可视化一个 batch 的标注,看一眼框是不是正确落在目标上;再检查to_rgb参数
一个 epoch 刚开始就卡住不动num_workers开太大导致死锁,或filter_empty_gt把所有样本都过滤光了先把num_workers=0跑一次,确定数据链路没问题再调大;检查过滤后的样本数量

说一个我印象特别深的真实案例。有个同学把一张 224×224 的图输入一个默认配置的检测模型,结果在Pad那里报错,说尺寸不是 32 的倍数。他当时不理解,说"我明明在 pipeline 里写了 Pad 啊"。我让他打印了 Pad 前后的 shape,才发现他把Pad放在了Normalize前面,而且没有指定size_divisor,默认值是 1,等于没 pad。所以说,别光看 pipeline 里有哪些 transform,还要注意它们的参数和顺序。

5.3 调试数据输入链路的三个实用手段

最后分享三个我常用的调试手段,真心实用。

第一个是打印单样本输出。写一个极简脚本,手动构建一个 pipeline 和一个 dataset,然后调用dataset[0],把输出的 dict 的keys()打印出来,看看是否包含预期的img、gt_bboxes、gt_labels等字段。如果缺了哪个,基本就是 pipeline 里少了对应的 transform。

第二个是可视化增强后的结果。很多小白只看输出 dict 里的数字,根本不知道这些数字长什么样。我是建议直接把results['img']画出来,同时把gt_bboxes画上去,贴到屏幕上肉眼检查。一旦 bbox 错位,你一眼就能看出来,比盯着数值猜快得多。

第三个是利用断点或日志定位卡点。数据链路里最容易卡死的地方是自定义 Dataset 里的load_data_list。如果配置里写了filter_empty_gt=True,而你的标注文件又有问题,可能所有样本都被过滤掉,训练循环根本跑不起来。这时候你可以直接在所有样本过滤逻辑前面打个日志,输出一下样本数,就能马上发现问题。

最后再分享一个小技巧

我每次新接手一个 mmdet 项目,都会先做一件事:把训练集里的前 8 张图连同标注框可视化出来,确认数据没毛病,然后才开始调模型。很多同学跳过这一步直接训练,最后 loss 爆炸才回头查数据,浪费的时间够我跑十次可视化了。

数据输入这部分,说难真不难,说简单也不简单。它难在抽象层级多,容易让人产生"框架很复杂"的错觉;简单在于,只要你把"Dataset 读原生数据 → pipeline 逐级加工 → DataLoader 拼 batch → Preprocessor 最终预处理"这条链子走通一遍,后续所有自定义操作都是在这个链子上加加减减而已。

希望这篇笔记能帮你把这块壁垒拆掉。后续我大概率还会写 mmdet 的模型构建和训练流程,如果你有在数据输入上卡了很久的问题,可以在评论区丢给我,我挑典型的继续补坑。

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

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

立即咨询