☰
目标检测VOC格式ImageSets/Main数据集划分txt文件生成与避坑指南
2026/10/19 16:47:39 网站建设 项目流程

做目标检测训练的人,大概率都经历过这种尴尬:自己辛辛苦苦攒了一批图片,用标注工具一张张画好框,目录结构也照搬了VOC的标准格式——JPEGImages里放原图、Annotations里放XML标注,结果把数据丢给训练脚本,程序直接报错,说找不到训练样本。翻代码一看才发现,人家是从ImageSets/Main/train.txt读文件名列表的,而这个目录下空荡荡,一个txt都没有。

VOC数据集这套格式里,图片和标注只是原料,真正决定“哪些图进训练集、哪些图进验证集、哪些图留下来做测试”的,是ImageSets/Main下的几个txt文件:train.txt、val.txt、trainval.txt、test.txt。文件名列表写得好不好,直接影响训练是否正常启动、模型评估是否可靠。这篇文章就把这几个txt文件的生成逻辑讲透,我会从目录结构、划分原理、脚本实现到踩坑排查整条链路走一遍。无论你是手工标注了一两千张自有数据,还是准备把公开数据集重新划分成自己的训练验证测试组合,这篇文章都能帮你少走弯路。

1. ImageSets/Main下的txt文件到底是什么逻辑

1.1 一份标准VOC目录的完整长相

很多教程一上来就让你建文件夹,但没说清楚每个目录的职责。这里先花一两分钟把结构理顺。

标准VOC数据集的根目录通常长这样:

目录/文件作用
JPEGImages/存放所有原始图片,统一为JPG格式
Annotations/存放每张图片对应的XML标注文件,文件名与图片名一一对应
ImageSets/Main/存放数据集划分文件,即各类txt
ImageSets/Layout/人体部位任务专用,自建检测数据集一般用不到
SegmentationClass/语义分割任务的掩码图,目标检测用不到
SegmentationObject/实例分割任务的掩码图,目标检测用不到

目标检测训练最关心的其实只有前三个。ImageSets/Main下的txt文件,本质是“文件名清单”,每行放一个图片的名称,不带扩展名。比如:

000001 000002 000003

训练脚本读取这个文件后,自己拼出图片路径JPEGImages/000001.jpg和标注路径Annotations/000001.xml。

1.2 train、val、trainval、test之间的集合关系

这4个txt文件不是彼此独立的,它们之间存在固定的集合逻辑。最常见的划分方式是:先把全部数据一分为二,一部分叫trainval,另一部分叫test;再从trainval里拆出train和val。

用集合关系表示就是:

  • trainval = train ∪ val
  • test ∩ trainval = ∅
  • 全部样本 =trainval + test

为什么中间要多一个trainval?这是从官方VOC数据集沿用下来的设计。训练脚本可能只需要train.txt做训练,但有的算法(比如一些目标检测框架的反省消融实验)希望拿到一个略大的集合去求统计量,或者希望在 train/val 之外再自主划分一个验证集,这时trainval就派上用场了。

对自建数据集来说,我建议还是把这4个文件都生成出来。哪怕你眼下只用到train.txt和val.txt,留着trainval.txt和test.txt也方便以后做对比实验。test.txt尤其别省,它是模型最终评估的唯一依据。把测试数据混进训练过程调参,是初学阶段特别容易犯的错误。

1.3 训练脚本是怎么消费这些txt的

搞清楚消费逻辑,才能明白为什么txt里有那么多细节要注意。下面是一段典型的目标检测数据加载伪代码:

with open('ImageSets/Main/train.txt') as f: lines = f.readlines() for line in lines: name = line.strip() # 取出行首尾空白 img_path = os.path.join('JPEGImages', name + '.jpg') xml_path = os.path.join('Annotations', name + '.xml') # 加载图片和对应XML标注,进入训练

结合这段代码,有三个关键信息点:

  1. 读取时默认每行是一个不带扩展名的文件名,如果我在txt里写成了000001.jpg,拼接后就变成JPEGImages/000001.jpg.jpg,直接报文件不存在。
  2. 读取时会对每一行做strip,但如果txt里有空行,空行也会被当成一条数据,导致后续解析失败。
  3. 图片后缀被固定为.jpg,如果你的数据集里混入了PNG或JPEG,需要提前统一。

所以生成txt的时候,规矩只有一个:每行一个文件名,不带扩展名,末尾换行,不掺空行。

2. 划分比例、随机种子与源目录选择,这三件事决定划分质量

2.1 划分比例怎么定

最常见的比例有两套。一套适合中等规模以上的数据集(比如数量超过1000张):trainval : test = 8 : 2,train : val = 8 : 2。也就是说,整体数据最终按 64% / 16% / 20% 分成训练、验证、测试三份。

另一套适合小数据集(比如只有一两百张标注图):trainval : test = 9 : 1,train : val = 8 : 2。测试集只留10%,因为样本总量太少时,20%的测试集只有二三十张图,评估出来的指标方差非常大,随便调个随机种子,结果可能差好几个点。

实际项目里我很少用固定的“玄学比例”,而是先看标注数量再决定。比如总共2000张图,我倾向取 trainval=1600、test=400,trainval内部再取 train=1280、val=320。这些都是整除友好的数字,也方便别人阅读日志时快速核对数量。

有一个原则比具体比例更重要:test划分出来之后就不要动了。调参阶段只能在train上训练、在val上验证,反复试。等整个流程确定下来,最后才用test做一次终极评估。如果把test当成验证集来回看,模型对test的“拟合”会在无形中发生,最终报告的指标是虚高的。

2.2 随机种子必须固定,并且要显式记录

random.shuffle()如果不设随机种子,每次运行得到的划分都不一样。第一次跑实验:某张图片进了train;第二次跑,同一张图片可能进了val。这样调参时你会看到模型性能忽高忽低,却完全找不到原因。

统一做法是在生成脚本里固定一个种子,比如 seed=2025。需要说明的是,Python的random在每次调用shuffle前用random.seed(42)固定,只能保证在本机、本Python版本下可复现。换一台机器或者升级Python版本,结果可能仍然不同。如果你需要跨机器严格复现划分,更稳妥的办法是把划分结果固化到txt文件里,之后都从txt读取,而不是每次都重新划分。

还有一个容易被忽略的细节:有的同事在生成脚本里写了seed,但shuffle之前又调用了别的random操作,比如中途随机抽了一张图做可视化,导致shuffle的实际序被打乱。我的习惯是单独创建一个Random实例,比如local_rng = random.Random(seed_locked),只让划分逻辑使用这个实例,避免外部random调用干扰。

2.3 用Annotations目录而不是JPEGImages目录作为划分源

这是我在项目里帮别人排查数据问题时发现的高频坑。很多人写生成脚本时习惯先扫描JPEGImages目录,拿到所有图片名,然后shuffle、切分、写txt。这在数据非常干净的时候没问题,但实际自建数据时,JPEGImages里经常会混入一些“没标注的废图”——比如你爬图的时候多下了几张,或者标注过程中因为某些原因跳过了一部分。

如果按图片目录生成,训练时读取到某张图片的XML标注,打开发现不存在或者为空,轻则报错中断,重则这个样本被错误地当成背景图参与训练。反过来,以Annotations/*.xml为基准生成文件名清单,能保证进入集合的每个样本都有对应标注。XML文件数量和JPEGImages不一致时,也更容易发现数据问题。

所以我的标准做法很明确:扫描标注文件目录,得到的是真正有效的样本清单。

2.4 shuffle之后要不要sort

这个问题经常在论坛里看到,答案取决于下游脚本是否依赖固定顺序。有的训练框架会顺序读取txt,虽然对随机梯度下降影响不大,但如果你拿到一份开源代码,它可能按txt顺序做某个批次划分,顺序乱了,之前保存的checkpoint对应的数据顺序就变了,后续续训时可能出现微妙的不一致。

我自己的习惯是:先对文件名列表做一次sort,保证可读;再用本地Random实例shuffle到随机顺序,写出的txt保持shuffle后的顺序。这样从宏观上看,train里数据的分布确实是随机的;从操作上看,文件也是一份稳定的快照。只要不重新生成txt,顺序永远不会变。

3. 手写生成脚本:从目录扫描到输出四个txt的完整实现

3.1 约定目录结构

写脚本前先约定好输入输出路径。一次典型的项目目录如下:

VOCdevkit/ VOC2007/ JPEGImages/ Annotations/ ImageSets/ Main/

脚本放在VOCdevkit/VOC2007/下运行时,所有路径用相对当前目录的形式即可。如果你的项目目录不同,代码里的基础路径需要同步调整。

3.2 完整代码

下面这段脚本是我最近项目里一直在用的生成代码,去掉了业务相关的改动,保留了核心逻辑:

import os import glob import random # 基础路径,按需修改 base_dir = '.' ann_dir = os.path.join(base_dir, 'Annotations') main_dir = os.path.join(base_dir, 'ImageSets', 'Main') # 划分参数 split_seed = 42 # 固定随机种子 trainval_ratio = 0.8 # trainval占全部样本的比例 train_ratio = 0.8 # train占trainval的比例 os.makedirs(main_dir, exist_ok=True) # 1. 从Annotations目录获取所有已标注样本 xml_files = glob.glob(os.path.join(ann_dir, '*.xml')) name_list = [os.path.splitext(os.path.basename(x))[0] for x in xml_files] name_list.sort() # 2. 固定种子并打乱 local_rng = random.Random(split_seed) local_rng.shuffle(name_list) # 3. 按数量切分 total_count = len(name_list) trainval_count = int(total_count * trainval_ratio) train_count = int(trainval_count * train_ratio) trainval_names = name_list[:trainval_count] test_names = name_list[trainval_count:] train_names = trainval_names[:train_count] val_names = trainval_names[train_count:] # 4. 写出txt def write_txt(filepath, names): with open(filepath, 'w') as f: for name in names: f.write(name + '\n') write_txt(os.path.join(main_dir, 'trainval.txt'), trainval_names) write_txt(os.path.join(main_dir, 'test.txt'), test_names) write_txt(os.path.join(main_dir, 'train.txt'), train_names) write_txt(os.path.join(main_dir, 'val.txt'), val_names) # 5. 打印统计信息 print('total: {}, trainval: {}, test: {}, train: {}, val: {}'.format( total_count, len(trainval_names), len(test_names), len(train_names), len(val_names)))

运行后终端会输出类似这样的结果:

total: 1200, trainval: 960, test: 240, train: 768, val: 192

四个txt文件就出现在ImageSets/Main/下了。

3.3 代码里几个容易理解错的点

这段代码虽然不长,但有几处值得解释。

os.path.splitext(os.path.basename(x))[0]是用来去掉XML路径的目录部分和.xml后缀,得到纯文件名。有人习惯用x.split('.')[0],但那种写法在文件名本身包含点号的时候会出错。比如文件叫scene.2024.xml,split('.')[0]得到的是scene,而splitext得到的是scene.2024,显然后者才是对的。

int(total_count * trainval_ratio)是简单截断。数据总量能被比例整除时没有影响,不能被整除时,会从 test 那边少拿或带着小数截断。如果你想更精确,可以用round(),但要注意四舍五入的方向是否符合预期。我的建议是保持简单截断,并让打印的统计信息作为最终依据,看到数量不对劲再手动调整。

使用独立的local_rng = random.Random(split_seed)而不是全局random.seed(split_seed),是为了避免脚本里其他random调用影响划分结果。这个细节是排查过“同一份数据每次划分结果都不同”之后才注意到的,建议大家直接沿用。

3.4 运行后怎么验证结果

脚本跑完,先别急着拿去训练,花30秒做一个自检:

wc -l ImageSets/Main/train.txt wc -l ImageSets/Main/val.txt wc -l ImageSets/Main/trainval.txt

trainval的行数应当等于train加val之和。如果trainval是960行,train是768行,val应该是192行。再拿一个文件名到JPEGImages目录里确认图片存在,到Annotations目录确认XML存在,双确认之后再去启动训练。

4. 生成之后的排查清单:这些坑我基本都踩过

4.1 路径拼接的两种典型错误

第一种是把绝对路径写进了txt。比如有人为了“方便”,在txt里写了VOC2007/JPEGImages/000001.jpg。如果读取代码本身不做任何路径处理,直接拿这行内容去open(),问题不大;但如果读取代码提前拼了前缀,比如os.path.join('data', line),就会变成data/VOC2007/JPEGImages/000001.jpg这种已经不存在的路径。txt内容保持最干净的文件名,把路径拼接的职责留给读取方,这是最通用的约定。

第二种是Windows和Linux路径分隔符混用。glob在Windows上返回的可能是Annotations\000001.xml这种反斜杠路径,但os.path.basename()对两种分隔符都能正确处理,所以只要严格用os.path系列函数,就不会出问题。反过来,如果手工用split('/')去切,在Windows上就会漏掉。

4.2 图片扩展名不统一导致的神秘缺失

VOC官方要求JPEGImages下统一是JPG图片,但自建数据里经常混入PNG、JPEG、甚至大写.JPG。如果训练代码固定拼接.jpg,那些.JPG的图片就会直接报缺失。有三条解决路径:

  1. 把所有图片统一重命名成小写.jpg;
  2. 读取代码里动态搜索真实扩展名;
  3. 生成脚本里只保留能对应到真实图片的样本,把对不上的打印出来人工处理。

我倾向于在脚本里加一段校验,列出所有“在Annotations有标注但JPEGImages里找不到同名JPG图片”的样本。校验代码很简单:

import os missing = [] for name in name_list: jpg_path = os.path.join('JPEGImages', name + '.jpg') if not os.path.exists(jpg_path): missing.append(name) if missing: print('missing jpg:', missing[:10], 'total', len(missing))

不要跳过这一步。我曾因为图片里混了几张.JPG后缀的图,训练跑到一半才报错,浪费了将近两个小时排查日志。

4.3 空txt和“0个样本”却不报错

生成脚本最隐蔽的问题是:如果Annotations目录路径写错了,glob返回空列表,脚本不会报错,只会安静地输出total: 0,然后生成4个空txt。很多初学朋友看到脚本“成功运行”,就以为一切正常,直到训练时才发现数据集长度是0。

针对这个情况,脚本里最好加上显式校验:

if len(name_list) == 0: raise SystemExit('Annotations目录下没有找到任何XML文件,请检查路径')

类似的,trainval_count或train_count计算出0,也会生成空文件。划分参数异常时,同样应该及时终止而不是静默生成。这种“失败要大声”的思路,在数据准备阶段尤其重要。

4.4 与开源框架读取逻辑的兼容性

不同框架对txt文件的消费方式略有差异。有些读取trainval.txt然后自己在内部继续划分,有些直接读train.txt;有些每行自动加.jpg,有些需要自己处理扩展名。在动手生成之前,花5分钟看一下训练脚本的数据加载部分,确认它读的是哪个文件、文件名的格式要求是什么。

我之前接过一个项目,训练代码读取的是Imageset/test.txt,目录名大小写都跟标准的ImageSets/Main不一样。如果我只是按“标准约定”生成文件,根本不会命中。看读取源码,永远是最可靠的做法。

5. 进阶玩法:按类别筛选、合并数据集与动态生成

5.1 只生成包含特定类别的训练文件

有时候你的数据里有多个类别,但当前任务只关心其中某个类别。比如所有标注图里有“人”和“车”,这次只想先训练一个行人检测模型。这时可以在生成清单之前先过滤XML,只保留包含目标类别的图片。

核心函数可以用xml.etree.ElementTree解析:

import xml.etree.ElementTree as ET def contains_class(xml_path, target_class): tree = ET.parse(xml_path) root = tree.getroot() for obj in root.iter('object'): name = obj.findtext('name') if name and name.strip() == target_class: return True return False target = 'person' filtered = [] for name in name_list: xml_path = os.path.join(ann_dir, name + '.xml') if contains_class(xml_path, target): filtered.append(name)

过滤之后再走shuffle和切分的流程。这里唯一的坑是类别名必须与XML里object/name的文本完全一致,大小写、空格都不能差。我在项目里见过标注时不小心把类名写成了"Person "带尾随空格的情况,过滤结果直接少了一批数据且没有任何报错。脚本里最好先统计一遍所有XML里出现的类别及其数量,打印出来确认后再过滤。

5.2 合并多个批次的自有数据

如果你的数据是分多次标注的,比如第一批拍了300张标好,第二批又补了200张,最简单的做法是把所有XML全部复制到同一个Annotations目录。但这样做有一个隐患:不同批次的图片可能存在重名,比如两个批次都叫img_001,后复制的会覆盖先复制的。

更稳妥的做法是按批次分目录存放,然后让生成脚本递归搜索所有XML:

all_xmls = glob.glob(os.path.join(ann_dir, '**', '*.xml'), recursive=True)

同时对重名做去重校验,发现重名立刻打印警告。合并数据之后,建议整体重新划分一次,而不是在原有划分基础上追加。直接追加大批新数据,会让原有的train和test比例失衡,新数据集中出现在某一边,破坏数据分布的一致性。

5.3 在训练流程里动态生成txt的尝试

有的同学喜欢在训练脚本启动前自动检查txt是否存在,不在就调用生成脚本。这个思路很好,可以省去手动步骤。但注意要把划分脚本做成独立可复用的模块,而不是把生成逻辑塞进训练代码某个犄角旮旯里,否则后面想单独重新划分时就很难找了。

我现在的固定流程是:把生成脚本放一份到项目的tools/目录下,参数(seed、比例、目标类别)全部显式写在脚本头部或做成命令行参数。每次生成完,先把行数统计信息提交到版本管理备注里。这样就算三个月后回头查实验,也能知道当时数据是怎么划分的。

6. 一点自己的体会

VOC格式的txt文件生成看着是个几行代码的小工具,但它卡在“原始标注”和“训练流程”之间,属于那种错了很难第一时间发现的环节。脚本本身不复杂,真正影响实验结果的,是对划分逻辑的理解,是对随机种子、源目录、后缀统一这些细节的把控。我在实际项目中踩过多次坑之后,已经把“先校验再写文件、报错要显式、seed要固化、test划分后不再动”当成了铁律。如果你也准备自建目标检测数据集,希望这篇文章能让你少走这几段弯路,一次把数据准备这件事做扎实。

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

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

立即咨询