
做目标检测训练的人大概率都经历过这种尴尬自己辛辛苦苦攒了一批图片用标注工具一张张画好框目录结构也照搬了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/存放数据集划分文件即各类txtImageSets/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 ∪ valtest ∩ 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标注进入训练结合这段代码有三个关键信息点读取时默认每行是一个不带扩展名的文件名如果我在txt里写成了000001.jpg拼接后就变成JPEGImages/000001.jpg.jpg直接报文件不存在。读取时会对每一行做strip但如果txt里有空行空行也会被当成一条数据导致后续解析失败。图片后缀被固定为.jpg如果你的数据集里混入了PNG或JPEG需要提前统一。所以生成txt的时候规矩只有一个每行一个文件名不带扩展名末尾换行不掺空行。2. 划分比例、随机种子与源目录选择这三件事决定划分质量2.1 划分比例怎么定最常见的比例有两套。一套适合中等规模以上的数据集比如数量超过1000张trainval : test 8 : 2train : val 8 : 2。也就是说整体数据最终按 64% / 16% / 20% 分成训练、验证、测试三份。另一套适合小数据集比如只有一两百张标注图trainval : test 9 : 1train : val 8 : 2。测试集只留10%因为样本总量太少时20%的测试集只有二三十张图评估出来的指标方差非常大随便调个随机种子结果可能差好几个点。实际项目里我很少用固定的“玄学比例”而是先看标注数量再决定。比如总共2000张图我倾向取 trainval1600、test400trainval内部再取 train1280、val320。这些都是整除友好的数字也方便别人阅读日志时快速核对数量。有一个原则比具体比例更重要test划分出来之后就不要动了。调参阶段只能在train上训练、在val上验证反复试。等整个流程确定下来最后才用test做一次终极评估。如果把test当成验证集来回看模型对test的“拟合”会在无形中发生最终报告的指标是虚高的。2.2 随机种子必须固定并且要显式记录random.shuffle()如果不设随机种子每次运行得到的划分都不一样。第一次跑实验某张图片进了train第二次跑同一张图片可能进了val。这样调参时你会看到模型性能忽高忽低却完全找不到原因。统一做法是在生成脚本里固定一个种子比如 seed2025。需要说明的是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_okTrue) # 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.xmlsplit(.)[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.txttrainval的行数应当等于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的图片就会直接报缺失。有三条解决路径把所有图片统一重命名成小写.jpg读取代码里动态搜索真实扩展名生成脚本里只保留能对应到真实图片的样本把对不上的打印出来人工处理。我倾向于在脚本里加一段校验列出所有“在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后复制的会覆盖先复制的。更稳妥的做法是按批次分目录存放然后让生成脚本递归搜索所有XMLall_xmls glob.glob(os.path.join(ann_dir, **, *.xml), recursiveTrue)同时对重名做去重校验发现重名立刻打印警告。合并数据之后建议整体重新划分一次而不是在原有划分基础上追加。直接追加大批新数据会让原有的train和test比例失衡新数据集中出现在某一边破坏数据分布的一致性。5.3 在训练流程里动态生成txt的尝试有的同学喜欢在训练脚本启动前自动检查txt是否存在不在就调用生成脚本。这个思路很好可以省去手动步骤。但注意要把划分脚本做成独立可复用的模块而不是把生成逻辑塞进训练代码某个犄角旮旯里否则后面想单独重新划分时就很难找了。我现在的固定流程是把生成脚本放一份到项目的tools/目录下参数seed、比例、目标类别全部显式写在脚本头部或做成命令行参数。每次生成完先把行数统计信息提交到版本管理备注里。这样就算三个月后回头查实验也能知道当时数据是怎么划分的。6. 一点自己的体会VOC格式的txt文件生成看着是个几行代码的小工具但它卡在“原始标注”和“训练流程”之间属于那种错了很难第一时间发现的环节。脚本本身不复杂真正影响实验结果的是对划分逻辑的理解是对随机种子、源目录、后缀统一这些细节的把控。我在实际项目中踩过多次坑之后已经把“先校验再写文件、报错要显式、seed要固化、test划分后不再动”当成了铁律。如果你也准备自建目标检测数据集希望这篇文章能让你少走这几段弯路一次把数据准备这件事做扎实。