1. 数据集合并的“为什么”与“是什么”

如果你刚开始接触目标检测,尤其是用像YOLO、SSD、Faster R-CNN这些经典模型做实验,Pascal VOC数据集大概率是你的第一块“磨刀石”。但很快你就会发现一个有点让人困惑的操作:很多论文和开源代码里,都喜欢把VOC2007和VOC2012两个数据集合在一起训练,但测试的时候,却又只用VOC2007的测试集。我第一次看到这个设定也是一头雾水,心想:“这不是耍赖吗?用两个数据集训练,却用一个更小的数据集测试,这结果能准吗?”

后来踩过几次坑,自己动手跑通了整个流程才明白,这其实是目标检测领域一个非常经典且务实的基准设定。简单来说,VOC2007的测试集是公开且标准的评估集,大家约定俗成用它来横向比较不同模型的性能,确保“比赛”的公平性。而VOC2012没有官方的测试集标签(或者说,它的测试集标签不公开,用于线上比赛),所以我们没法用它来公平地评估模型。那为了提升模型能力,最直接的办法就是把VOC2012的训练+验证数据(也就是trainval)也拿过来,和VOC2007的trainval数据一起,组成一个更大的训练集。这样,模型在训练时“见”到的数据更多、更丰富,理论上泛化能力会更好,最终在VOC2007的测试集上“考试”时,成绩自然也更有可能提高。

所以,这个“07+12联合训练,07上测试”的模式,核心目标就是:在统一的、公认的评测标准下,尽可能利用所有可用的标注数据来提升模型性能。最终,你会得到一个包含16551张训练图像和4952张测试图像的数据集。听起来很简单,对吧?但当你真正动手去组织这些数据时,就会发现魔鬼藏在细节里。不同的代码仓库、不同的框架,对数据集的目录结构要求可能截然不同。我见过不少新手朋友,模型代码都调通了,却卡在数据准备这一步,被各种路径和文本文件搞得晕头转向。接下来,我就结合自己多年的实战经验,给你详细拆解两种最高效、也最常用的数据组织方式,帮你彻底搞定这个“前期准备工作”。

2. 第一种方式:保持独立,代码聚合

这种方式我称之为“分而治之”法。它的核心思想是:保持VOC2007和VOC2012原始文件夹的独立性,不移动任何图片或标注文件,仅仅在代码加载数据时,通过逻辑将两个数据源的图像列表合并起来。这种方法最清晰,也最不容易出错,特别适合基于PyTorch或TensorFlow等框架、自己编写或修改数据加载逻辑的场景。

2.1 数据下载与目录准备

首先,你得把原始数据弄到手。你需要从Pascal VOC官网下载四个压缩包:

  • VOCtrainval_06-Nov-2007.tar
  • VOCtest_06-Nov-2007.tar
  • VOCtrainval_11-May-2012.tar
  • VOCtest_11-May-2012.tar (这个我们实际上用不到,可以不下)

下载完后,我建议你建立一个清晰的工作目录,比如 /data/VOCdevkit/。然后,按以下步骤操作:

  1. 解压 VOCtrainval_11-May-2012.tar,你会得到一个 VOCdevkit 文件夹,里面包含 VOC2012。直接将这个 VOC2012 文件夹移动到 /data/VOCdevkit/ 下。
  2. 解压 VOCtrainval_06-Nov-2007.tar 和 VOCtest_06-Nov-2007.tar。你会发现它们解压后内部结构都是 VOCdevkit/VOC2007/...。你需要做的是将这两个压缩包里的 VOC2007 文件夹内容合并。
    • 具体操作:分别解压到两个临时文件夹,比如 temp1 和 temp2。
    • 将 temp1/VOCdevkit/VOC2007/ 下的所有内容(Annotations, ImageSets, JPEGImages等),复制到你的目标目录 /data/VOCdevkit/VOC2007/ 下。
    • 再将 temp2/VOCdevkit/VOC2007/ 下的所有内容,也复制到同一个 /data/VOCdevkit/VOC2007/ 目录下。注意,这里不是覆盖,是合并。特别是 ImageSets/Main/ 下的 .txt 文件,trainval.txt 和 test.txt 都会存在,这很重要。
  3. 最终,你的目录结构应该是这样的:
/data/VOCdevkit/
├── VOC2007
│   ├── Annotations  (包含5011个trainval的xml + 4952个test的xml)
│   ├── ImageSets
│   │   └── Main
│   │       ├── trainval.txt
│   │       ├── test.txt
│   │       └── ...
│   ├── JPEGImages   (包含5011个trainval图片 + 4952个test图片)
│   └── ... (其他文件夹,SegmentationClass等,检测任务可忽略)
└── VOC2012
    ├── Annotations  (包含11540个trainval的xml)
    ├── ImageSets
    │   └── Main
    │       └── trainval.txt
    ├── JPEGImages   (包含11540个trainval图片)
    └── ...

你看,VOC2007文件夹里现在既有训练验证集,也有测试集。而VOC2012文件夹里只有训练验证集。物理上,它们是分开的两个文件夹。

2.2 数据加载的核心代码逻辑

数据怎么合,关键看数据加载器怎么写。这里我以一个典型的PyTorch Dataset 类为例,展示其核心的 __init__ 方法。这个类的任务就是根据我们传入的 image_sets 参数,去对应的文件夹里找到所有需要的图片ID。

import os.path as osp

class VOCDetection(data.Dataset):
    def __init__(self, root, img_size, image_sets=[('2007', 'trainval'), ('2012', 'trainval')], transform=None, target_transform=None, dataset_name='VOC0712'):
        self.root = root # 指向 /data/VOCdevkit
        self.img_size = img_size
        self.image_set = image_sets # 指定要加载哪些集合
        self.transform = transform
        self.target_transform = target_transform
        self.name = dataset_name

        # 定义XML和图片路径的模板
        self._annopath = osp.join('%s', 'Annotations', '%s.xml')
        self._imgpath = osp.join('%s', 'JPEGImages', '%s.jpg')

        self.ids = [] # 这个列表将存储所有需要的 (根路径, 图片ID)

        # 核心的合并逻辑就在这里!
        for (year, name) in image_sets:
            # 根据年份拼出子文件夹路径,如 /data/VOCdevkit/VOC2007
            rootpath = osp.join(self.root, 'VOC' + year)
            # 读取对应的 .txt 文件,比如 VOC2007/ImageSets/Main/trainval.txt
            list_file = osp.join(rootpath, 'ImageSets', 'Main', name + '.txt')
            with open(list_file, 'r') as f:
                for line in f:
                    # 去掉换行符,得到图片ID,如 `000001`
                    image_id = line.strip()
                    # 将 (根路径, 图片ID) 作为一个元组存入列表
                    self.ids.append((rootpath, image_id))

这段代码是精髓。当 image_sets=[('2007', 'trainval'), ('2012', 'trainval')] 时,它会先读取VOC2007的trainval.txt(5011个ID),再读取VOC2012的trainval.txt(11540个ID),然后全部添加到 self.ids 列表中。这样,self.ids 最终就有16551个元组,每个元组都包含了这张图片所在的根目录和它的ID。后续,__getitem__ 方法就可以利用 self._imgpath % (rootpath, image_id) 来拼接出完整的图片路径,用 self._annopath % (rootpath, image_id) 来拼接出标注文件路径。

2.3 训练与测试的调用示例

在实际项目中,你通常会这样使用它:

训练阶段:加载07和12的所有训练验证数据。

# 假设你的数据根目录
VOC_ROOT = '/data/VOCdevkit'
# 创建训练数据集
train_dataset = VOCDetection(
    root=VOC_ROOT,
    img_size=640,
    image_sets=[('2007', 'trainval'), ('2012', 'trainval')], # 合并两者
    transform=train_transform, # 数据增强
    target_transform=annotation_transform
)

测试/验证阶段:只加载VOC2007的测试集。

# 创建测试数据集
test_dataset = VOCDetection(
    root=VOC_ROOT,
    img_size=640,
    image_sets=[('2007', 'test')], # 仅用07的测试集
    transform=test_transform, # 通常只做归一化和Resize
    target_transform=annotation_transform
)

这种方式的好处非常明显:结构清晰,与原数据集保持一致,便于管理和调试。你想单独检查VOC2007或VOC2012的数据都非常方便。缺点是,你需要有一个支持这种灵活 image_sets 参数的数据加载器。很多现代的目标检测框架(如MMDetection、Detectron2)的数据集注册机制,本质上也是这种思想的延伸。

3. 第二种方式:物理合并,统一管理

第二种方式我称之为“一锅端”法。它更直接,尤其受一些经典项目(比如原版Darknet YOLOv3)的青睐。它的做法是:把所有需要的图片和标注文件,物理上复制到同一个新的目录结构下,并生成统一的训练/测试列表文件。这种方式下,代码加载数据时只需要认准一个地方,逻辑可以写得非常简洁。

3.1 创建全新的统一目录

我们不再区分VOC2007和VOC2012的文件夹。而是在你的项目目录下(比如 yolov3/data/),新建三个文件夹:

  • images/: 存放所有图片文件(.jpg)
  • annotations/: 存放所有标注文件(.xml)
  • ImageSets/: 存放划分好的文本文件

然后,开始“搬家”工作:

  1. 将 VOCdevkit/VOC2007/JPEGImages/ 下的所有图片(共5011+4952=9963张),复制到 data/images/。
  2. 将 VOCdevkit/VOC2012/JPEGImages/ 下的所有图片(共11540张),复制到同一个 data/images/。注意检查是否有重名文件,VOC数据集本身的ID是唯一的,所以直接复制不会覆盖。
  3. 同样地,将两个数据集 Annotations/ 下的所有 .xml 文件,复制到 data/annotations/。
  4. 现在,images 和 annotations 文件夹下应该各有 9963 + 11540 = 21503 个文件?等等,这里有个关键点!我们只合并用于训练和验证的数据。VOC2007的测试集图片和标注,我们同样需要复制过来,因为最终测试时要用到。所以,images 和 annotations 文件夹最终的文件总数应该是:VOC2007全部(9963) + VOC2012 trainval(11540) = 21503。没错。

3.2 生成统一的划分文件

接下来是关键一步:生成告诉模型“哪些图片用于训练,哪些用于测试”的列表文件。我们在 data/ImageSets/ 下创建两个文件:train.txt 和 test.txt。

  • train.txt 的内容,是 VOC2007的 trainval.txt 和 VOC2012的 trainval.txt 的简单合并。你可以用命令行 cat 操作,或者写个Python脚本合并。这个文件应该有 5011 + 11540 = 16551 行,每行就是一个图片ID(不带后缀),例如:
000001
000002
...
2012_004331
  • test.txt 的内容,直接就是 VOC2007的 test.txt。这个文件有4952行。

重要提示:这里的 train.txt 和 test.txt 里面只包含图片ID,不包含路径和后缀。路径信息我们会在后续脚本或配置中指定。

3.3 适应YOLO格式的转换脚本

很多目标检测框架,特别是YOLO系列,需要特定格式的标签文件(通常是每张图片对应一个 .txt 文件,里面存放归一化后的中心坐标和宽高)。因此,我们通常需要一个转换脚本。下面这个 voc_label.py 脚本是经典做法,它需要放在与 data 文件夹同级的目录下运行。

import xml.etree.ElementTree as ET
import os
from os import listdir, getcwd

# 类别列表,必须和你的模型定义一致
classes = ["aeroplane", "bicycle", "bird", "boat", "bottle", "bus", "car", "cat", "chair", "cow", "diningtable", "dog", "horse", "motorbike", "person", "pottedplant", "sheep", "sofa", "train", "tvmonitor"]

# 转换坐标的函数 (绝对坐标 -> YOLO归一化坐标)
def convert(size, box):
    dw = 1. / size[0]
    dh = 1. / size[1]
    x = (box[0] + box[1]) / 2.0
    y = (box[2] + box[3]) / 2.0
    w = box[1] - box[0]
    h = box[3] - box[2]
    x = x * dw
    w = w * dw
    y = y * dh
    h = h * dh
    return (x, y, w, h)

def convert_annotation(image_id):
    # 读取XML文件
    in_file = open('data/annotations/%s.xml' % (image_id))
    # 准备写入的YOLO格式标签文件
    out_file = open('data/labels/%s.txt' % (image_id), 'w')

    tree = ET.parse(in_file)
    root = tree.getroot()
    size = root.find('size')
    w = int(size.find('width').text)
    h = int(size.find('height').text)

    for obj in root.iter('object'):
        difficult = obj.find('difficult').text
        cls = obj.find('name').text
        # 忽略困难样本或不在类别列表中的物体
        if cls not in classes or int(difficult) == 1:
            continue
        cls_id = classes.index(cls)
        xmlbox = obj.find('bndbox')
        # 获取原始坐标
        b = (float(xmlbox.find('xmin').text), float(xmlbox.find('xmax').text), float(xmlbox.find('ymin').text), float(xmlbox.find('ymax').text))
        # 转换为YOLO格式
        bb = convert((w, h), b)
        # 写入文件:类别ID 中心x 中心y 宽度 高度
        out_file.write(str(cls_id) + " " + " ".join([str(a) for a in bb]) + '\n')
    in_file.close()
    out_file.close()

# 主流程
sets = ['train', 'test'] # 处理训练集和测试集
wd = getcwd() # 获取当前工作目录

for image_set in sets:
    # 创建存放标签的文件夹
    if not os.path.exists('data/labels/'):
        os.makedirs('data/labels/')
    # 读取划分文件,得到图片ID列表
    image_ids = open('data/ImageSets/%s.txt' % (image_set)).read().strip().split()
    # 创建一个新的文件,里面存放所有图片的**绝对路径**
    list_file = open('data/%s.txt' % (image_set), 'w')
    for image_id in image_ids:
        # 写入图片路径,例如:/home/user/yolov3/data/images/000001.jpg
        list_file.write('%s/data/images/%s.jpg\n' % (wd, image_id))
        # 转换对应的XML标注为YOLO格式的txt文件
        convert_annotation(image_id)
    list_file.close()

运行这个脚本后,你的 data 目录下会发生这些变化:

  1. 生成 data/labels/ 文件夹,里面为 train.txt 和 test.txt 中列出的每一张图片,都生成了一个同名的 .txt 标签文件。
  2. 生成 data/train.txt 和 data/test.txt 文件。注意,此处的 train.txt 和 ImageSets/ 下的同名文件内容完全不同!ImageSets/train.txt 里是图片ID,而 data/train.txt 里是每张图片的完整绝对路径。后者才是最终给数据加载器使用的文件。

3.4 配置文件与训练启动

最后,你需要准备模型运行的配置文件。以YOLOv3为例,在 data/ 目录下创建 voc.data 文件:

classes=20
train=data/train.txt  # 指向刚生成的、包含路径的train.txt
valid=data/test.txt    # 指向刚生成的、包含路径的test.txt
names=data/voc.names
backup=backup/
eval=coco

再创建 voc.names 文件,按顺序写入20个类别的名称。

完成以上所有步骤,你的数据集就彻底组织好了。这时,你运行训练命令,模型就会从 data/train.txt 中读取16551个图片路径进行训练,并在每个epoch后,用 data/test.txt 中的4952个图片路径进行验证。这种方式将所有数据扁平化处理,对代码侵入性小,特别适合那些要求固定输入数据路径的仓库或工具。

4. 两种方式深度对比与选型建议

搞清楚了两种方法的操作流程,我们再来深入对比一下,帮你做出最适合自己情况的选择。这两种方式没有绝对的优劣,只有是否适合你的项目场景。

从目录结构看:

  • 方式一(代码聚合):保持了VOC数据集的原始层级 VOCdevkit/VOC2007/ 和 VOCdevkit/VOC2012/。结构清晰,数据来源一目了然。如果你还需要用这个数据集做分割等其他任务,这种方式不会破坏原始布局。
  • 方式二(物理合并):创建了全新的扁平化结构 data/images/, data/labels/。所有文件混在一起,脱离了VOC的原始语境。好处是路径简单,对于只关心图片和标签文件的应用来说更直接。

从数据加载逻辑看:

  • 方式一:数据加载器(Dataset类)需要具备“多数据集聚合”的能力。它通过遍历 image_sets 参数中指定的多个(年份,集合)对,动态地构建索引列表。这给了你极大的灵活性,你可以轻松地组合任意年份的任意集合(例如 [('2007', 'train'), ('2012', 'val')]),而无需移动任何文件。
  • 方式二:数据加载器逻辑通常更简单。它只需要从一个固定的文本文件(如 data/train.txt)中读取所有图片的路径即可。数据集的划分已经通过前期的脚本处理“固化”下来了。想换一种划分方式?你得重新跑一遍合并和生成脚本。

从适用场景看:

  • 选择方式一,如果你的项目是:基于PyTorch/TensorFlow等框架从零开始或深度定制;你希望数据加载模块灵活、可配置,便于进行消融实验(比如只训练VOC2007看看效果);你的代码库可能还需要处理其他遵循类似多文件夹结构的数据集。
  • 选择方式二,如果你的项目是:使用像Darknet YOLO、一些较老或特定风格的代码库,它们要求数据以固定的 train.txt/val.txt 路径列表形式提供;你追求极简的启动流程,希望“准备好数据,改个配置文件就能跑”;你不需要频繁切换不同的数据子集进行实验。

从维护和调试角度看:

  • 方式一在调试时更方便。如果某张图片加载出错,你可以立刻从 self.ids 列表里看到它来自 VOC2007 还是 VOC2012,然后直接去对应的原始文件夹查找,定位问题很快。
  • 方式二一旦完成合并,原始来源信息就丢失了。如果 data/images 里出现一个损坏的文件,你可能需要去翻看复制记录才能知道它最初来自哪个压缩包。

在我自己的实践中,对于研究性质的项目或快速原型开发,我更喜欢方式一。它的灵活性是无价的。我可以写一个通用的VOC数据集类,然后通过传参轻松地在“07+12联合训练”、“仅07训练”、“07训练12验证”等模式间切换,这对于分析模型性能和数据影响至关重要。而对于复现某个经典论文的精确结果,或者使用一个成熟但“固执己见”的代码库时,我会老老实实按照它的要求(往往是方式二)来准备数据,避免在数据加载环节引入任何不确定性。

5. 实战中的常见“坑”与解决方案

无论选择哪种方式,在实际操作中都难免会遇到一些坑。我把自己和同事们踩过的雷总结一下,希望能帮你节省大量调试时间。

坑点一:文件路径错误或权限问题。 这是最常见的问题。脚本报错“No such file or directory”时,别慌。首先,用 print 或 logging 把脚本尝试读取的完整路径打印出来。然后,去终端里用 ls -l 命令检查这个路径是否存在,以及你的程序是否有读取权限。在方式二中,要特别注意 voc_label.py 脚本里生成的 data/train.txt 中的路径是绝对路径还是相对路径,是否与训练脚本的当前工作目录匹配。

坑点二:类别ID不匹配。 在方式二的转换脚本中,classes 列表的顺序必须和你模型里定义的类别顺序严格一致。如果模型里 0 对应“aeroplane”,而你的转换脚本里 0 对应“person”,那训练就会完全乱套。一个很好的习惯是,把这个 classes 列表单独保存成一个文件(如 voc.names),并在数据集加载和模型定义中都引用这个文件,确保源头唯一。

坑点三:忽略“difficult”标签。 Pascal VOC的标注中,每个物体有一个 difficult 属性(0或1)。按照惯例,在模型训练和评估中,通常都会忽略 difficult=1 的物体。上面的转换脚本已经做了这个判断(if int(difficult) == 1: continue)。你一定要确保这个逻辑存在,否则可能会把一些难以识别的目标也加入训练,影响模型对清晰目标的判断,或者在评估时得到偏低的结果。

坑点四:数据泄露。 这是最严重也最隐蔽的坑。切记,VOC2012的测试集(VOC2012test)绝对不能以任何形式出现在你的训练或验证流程中。因为它的标签是不公开的,如果你不小心把它混入了训练集,就相当于“考试前偷看了答案”,得到的性能指标是虚假的,没有任何参考价值。在方式一中,只要你不在 image_sets 参数里加入 ('2012', 'test') 就没事。在方式二中,你从一开始就不应该下载或解压 VOCtest_11-May-2012.tar 这个文件。

坑点五:图像尺寸不一致。 VOC数据集里的图片尺寸是不固定的。在训练时,我们通常会将图片缩放到一个统一的尺寸(如640x640)。这里有个细节:在缩放图片的同时,标注框(Bounding Box)的坐标也必须进行相应的缩放。大多数数据增强库(如Albumentations, torchvision transforms)都会自动处理这个问题。但如果你是自己写预处理管道,一定要确保坐标变换和图像变换是同步的。一个检查方法是:在数据加载后,将框画回原图(或缩放后的图)上,肉眼观察框和物体是否还对齐。

最后,给一个小建议:在正式开始漫长的大型模型训练之前,先用极小的子集(比如每类选5张图)跑通整个流程。创建一个 debug 模式,让数据加载器只返回前100个样本。这样可以快速验证你的数据管道、模型前向传播、损失计算甚至反向传播是否都工作正常,避免浪费几天时间才发现问题出在数据准备阶段。数据处理是深度学习项目的地基,地基打牢了,后面建高楼才会稳。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐