ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

YOLO26数据准备:训练/验证/测试集划分脚本与实践指南

YOLO26数据准备:训练/验证/测试集划分脚本与实践指南 YOLO26 最近的热度确实高新功能多、讨论也多但不管模型怎么变训练前的数据准备工作都省不掉。我这里说的“数据准备”不是标数据而是把现成的图片和标签拆成训练集、验证集、测试集三份。这一步如果偷懒用鼠标手动拖拽文件夹或者干脆不分后面训练时大概率会冒出一堆奇怪问题。这篇文章把我一直在用的划分脚本完整放出来顺带把为什么要这么分、目录规范是什么、跑完怎么自查都讲清楚照着做就行。1. 训练还没开始为什么先卡在数据集划分这一关1.1 训练集、验证集、测试集各管什么事先对齐一下概念。很多人把验证集和测试集混着叫其实分工完全不同。训练集就是给模型学特征的模型通过反向传播调整权重用的就是训练集的样本。验证集是训练过程中用来“盯着”模型表现的比如每个 epoch 结束后算一次 mAP用来判断要不要早停、学习率要不要降。它的核心意义在于模拟模型没见过的新数据但又不能真的拿最终评估数据来干这事。测试集则是整个训练彻底结束后最后跑一次推理、出最终指标用的它在训练过程中绝对不能参与任何决策否则评估结果就是自欺欺人。我在实际项目里见过不少把 test 集拿去当 val 用的情况甚至有人压根不设 test 集。对工程落地来说test 集必须留。举个真实例子你训练完要给甲方报一个 mAP如果这个数字是用验证集算的而验证集在训练时已经被“参考”过无数次那这指标是有水分的。等部署到现场真实数据一进来表现立刻打回原形。所以从源头把三分法立住后面所有实验结论才站得住脚。1.2 划分不合理会引发哪些问题划分不合理带来的问题很典型三个字数据泄漏。同一个目标出现在训练集某张图里又出现在验证集另一张图里模型相当于提前“背过答案”。这种情况在视频抽帧数据集里特别常见相邻几帧高度相似随机一拆就把几乎一样的图分到两个子集里去了。一旦泄漏训练时的 val mAP 虚高得吓人但部署到真实场景后立刻崩盘你都不知道该信哪个指标。另一种问题是比例乱来。比如训练集 99%、验证集 1%验证集里只有几张图那早停策略基本废了每个 epoch 的 loss 波动巨大你根本看不出模型是不是真的在收敛。还有人把 train、val、test 三者加起来不等于 1比如填了 0.8、0.1、0.2脚本也不检查最后 test 比 val 还大整个训练流程乱套。这些问题靠手拖文件夹是防不住的必须有一个统一逻辑的脚本来兜底。1.3 什么时候可以不用划分脚本当然也有不需要脚本的时候。如果数据是从开源数据集下载的人家本来就已经分好 train/val/test直接拿来用就行或者你只是想临时跑通一个 demo不在乎指标严谨性随便塞一下也能训练。但只要你准备训自己的数据集并且后面要对比实验、调参、报告指标那就老老实实走脚本。一次划分长期复用比每次手动折腾高效得多。2. 动手之前YOLO26的数据集格式和目录规范2.1 YOLO26沿用的一套目录格式YOLO26 虽然网络结构有不少变化但数据集的组织方式跟之前版本保持一致。官方训练代码默认读的就是 images/train、images/val 这种结构。你按下面的目录格式整理好训练基本不用改路径。dataset/ ├── images/ │ ├── train/ │ │ ├── img_0001.jpg │ │ └── ... │ ├── val/ │ │ └── ... │ └── test/ │ └── ... ├── labels/ │ ├── train/ │ │ ├── img_0001.txt │ │ └── ... │ ├── val/ │ │ └── ... │ └── test/ │ └── ... └── data.yaml图片和标签分两个大目录各自下面再按 train/val/test 分子目录。图片格式比较随意jpg、png、bmp、webp 都行。标签是 YOLO 格式的 txt 文件每行一个目标格式固定为类别ID 中心点x 中心点y 宽度 高度后四项都是相对图片尺寸归一化到 0 到 1 的值。这个格式要求是整个 YOLO 体系的核心约定划分脚本只需要保证文件配对正确至于标签内容合不合法那是标注阶段的事。2.2 图片和标签文件名的配对规则这套体系里最容易出错的就是文件名配对。YOLO 要求图片和它的标签拥有相同的主文件名只是扩展名不同。比如 image_001.jpg 对应的标签必须是 image_001.txt不能是 image_001 (1).txt也不能放在别的目录里。很多标注工具导出的文件名里带空格、带括号、带中文这些都会让匹配变得非常脆弱。我的脚本里刻意按主文件名去找 .txt 后缀的文件就是为了杜绝这类问题。比如一张图叫 001_001.jpg脚本就去 labels 目录找 001_001.txt找不到就把这个图片记入 missing_labels 列表并在运行结束时打印警告。这样你一眼就能看出原始数据里有多少张图没标、多少个标签没图而不是等到训练报错才发现。2.3 划分比例和随机种子怎么定比例怎么定我一般这样给建议数据量在一万张以下用 8:1:1 最稳。训练数据不够的时候就放宽到 7:2:1把验证集比例放大因为小数据下指标方差大需要更多验证样本来稳住早停和调参的判断。数据量到十万级验证集和测试集各给 1% 甚至 0.5% 都够用。这真不是玄学本质是权衡训练样本少了学不到特征验证样本少了指标不稳得根据你的数据量去倒推比例。随机种子这块我习惯固定 seed42。固定种子的意义不只是“每次运行都一样”而是让你的实验可复现。今天跑一次明天再跑一次结果应该完全一致你跟别人对比实验结果时两边用同一个 seed才能保证数据划分一致否则你们俩的指标差异可能来自划分而不是模型改进。所以脚本里我把 seed 做成参数默认 42想换就换但每次实验必须记录自己用的 seed 值。3. 完整划分脚本代码、逐段解读、参数说明3.1 脚本设计思路与功能清单这个脚本我用得比较久设计原则就几条不依赖任何第三方库只用 Python 标准库因为很多人的训练环境是内网装不了 pip 包标准库方案拿去就能跑路径和参数全部从命令行传入避免改代码默认行为保守也就是 copy 而不是 move复制是安全的原数据还在划分结果不满意随时重来遇到异常直接抛错绝不静默跳过。为什么这么强调异常处理因为划分这个步骤你希望它能“一票否决”有配对不上、比例错误这类问题当场报出来比训练到一半再发现要省事得多。脚本的功能清单如下自动扫描图片目录中所有常见格式的图片并在标签目录里匹配同名 .txt按 train / val / test 比例随机划分支持固定随机种子自动创建 YOLO 规范的目录结构支持 copy 和 move 两种模式自动统计缺失标签的图片、缺失图片的标签运行结束前打印警告可选生成 data.yaml把类别信息和数据路径一并写进去校验三个比例之和必须等于 1防止低级错误3.2 完整脚本代码把下面代码保存为 split_dataset.py放在数据集所在目录的上一级即可。#!/usr/bin/env python3 # -*- coding: utf-8 -*- YOLO26 / YOLO 系列数据集划分脚本 功能 1. 自动扫描 --images 目录中的所有图片并匹配 --labels 目录中的同名 txt 标签 2. 按 --train / --val / --test 比例随机拆分为 train / val / test 三个子集 3. 使用固定随机种子保证每次运行结果一致 4. 自动创建 YOLO 规范的目录结构支持 copy / move 两种模式 5. 可选生成 data.yaml省去手写配置的时间 用法示例 python split_dataset.py --images ./images --labels ./labels python split_dataset.py --images ./images --labels ./labels --output ./dataset --train 0.8 --val 0.1 --test 0.1 --seed 2025 python split_dataset.py --images ./images --labels ./labels --with-yaml --nc 2 --names person car import os import random import shutil import argparse from pathlib import Path IMAGE_SUFFIXES {.jpg, .jpeg, .png, .bmp, .webp, .tif, .tiff} def parse_args(): parser argparse.ArgumentParser(descriptionYOLO 数据集划分工具) parser.add_argument(--images, requiredTrue, help原始图片目录) parser.add_argument(--labels, requiredTrue, help原始标签目录) parser.add_argument(--output, default./dataset, help输出目录默认 ./dataset) parser.add_argument(--train, typefloat, default0.8, help训练集比例默认 0.8) parser.add_argument(--val, typefloat, default0.1, help验证集比例默认 0.1) parser.add_argument(--test, typefloat, default0.1, help测试集比例默认 0.1) parser.add_argument(--seed, typeint, default42, help随机种子默认 42) parser.add_argument(--mode, choices[copy, move], defaultcopy, helpcopy 保留原文件默认move 则移动文件) parser.add_argument(--with-yaml, actionstore_true, help同时生成 data.yaml) parser.add_argument(--nc, typeint, defaultNone, help类别数量配合 --with-yaml) parser.add_argument(--names, nargs*, defaultNone, help类别名称列表配合 --with-yaml) return parser.parse_args() def collect_pairs(images_dir, labels_dir): 扫描图片目录按主文件名匹配标签返回三元组。 image_map {} for name in os.listdir(images_dir): ext os.path.splitext(name)[1].lower() if ext in IMAGE_SUFFIXES: image_map[os.path.splitext(name)[0]] os.path.join(images_dir, name) pairs [] missing_labels [] for stem, img_path in image_map.items(): label_path os.path.join(labels_dir, stem .txt) if os.path.isfile(label_path): pairs.append((img_path, label_path)) else: missing_labels.append(stem) orphan_labels [] label_names {os.path.splitext(n)[0] for n in os.listdir(labels_dir) if n.lower().endswith(.txt)} for stem in label_names: if stem not in image_map: orphan_labels.append(stem) return pairs, missing_labels, orphan_labels def create_dirs(output, subsets, namespaces): 批量为每个子集创建 images/labels 目录。 for subset in subsets: for namespace in namespaces: (Path(output) / namespace / subset).mkdir(parentsTrue, exist_okTrue) def write_yaml(output, nc, names): 生成 data.yaml写入相对路径、类别数量与类别名称。 if names is None: names [str(i) for i in range(nc)] if len(names) ! nc: raise ValueError(fnames 数量 {len(names)} 与 nc {nc} 不一致) out Path(output) lines [ # 由 split_dataset.py 自动生成, # 训练时如果相对路径报错请改成绝对路径, ftrain: {out / images / train}, fval: {out / images / val}, ftest: {out / images / test}, fnc: {nc}, names:, ] for i, name in enumerate(names): lines.append(f {i}: {name}) yaml_path out / data.yaml yaml_path.write_text(\n.join(lines) \n, encodingutf-8) print([INFO] 已生成 data.yaml:, yaml_path) def split_and_distribute(pairs, args): 洗牌、按比例切分、复制或移动文件。 random.seed(args.seed) shuffled pairs[:] random.shuffle(shuffled) total len(shuffled) n_train round(total * args.train) n_val round(total * args.val) n_test total - n_train - n_val if n_train 0 or n_val 0: print([WARN] 某个子集数量为 0请检查比例设置。) partitions { train: shuffled[:n_train], val: shuffled[n_train:n_train n_val], test: shuffled[n_train n_val:], } for subset, items in partitions.items(): for img_path, label_path in items: img_dst Path(args.output) / images / subset / Path(img_path).name label_dst Path(args.output) / labels / subset / Path(label_path).name if args.mode copy: shutil.copy2(img_path, img_dst) shutil.copy2(label_path, label_dst) else: shutil.move(img_path, img_dst) shutil.move(label_path, label_dst) print([INFO] 划分完成子集统计:) print(f train: {len(partitions[train])} 张) print(f val: {len(partitions[val])} 张) print(f test: {len(partitions[test])} 张) return partitions def main(): args parse_args() if abs((args.train args.val args.test) - 1.0) 1e-6: raise ValueError(train val test 比例之和必须等于 1) pairs, missing_labels, orphan_labels collect_pairs(args.images, args.labels) if not pairs: raise RuntimeError(没有找到任何图片与标签配对请检查目录路径) print(f[INFO] 共匹配到 {len(pairs)} 对 图片-标签) if missing_labels: print(f[WARN] {len(missing_labels)} 张图片缺少标签: {missing_labels[:5]}...) if orphan_labels: print(f[WARN] {len(orphan_labels)} 个标签没有对应图片: {orphan_labels[:5]}...) create_dirs(args.output, [train, val, test], [images, labels]) split_and_distribute(pairs, args) if args.with_yaml: if args.nc is None and args.names is not None: args.nc len(args.names) if args.nc is None: raise ValueError(--with-yaml 时必须提供 --nc 或 --names) write_yaml(args.output, args.nc, args.names) if __name__ __main__: main()3.3 核心逻辑逐段拆解先说参数解析这一块。我没有把所有逻辑都写死在主流程里而是把图片目录、标签目录、输出目录、比例、种子、模式这些全做成命令行参数。这么做的好处是换数据集时不用改代码同一份代码在不同机器上都能跑。比例如 train 0.7 val 0.2 test 0.1直接命令行敲进去脚本自动完成检查与分配。再说 collect_pairs 这个函数。它先遍历图片目录按扩展名过滤出图片文件把主文件名作为 key、完整路径作为 value 存进字典。然后用这个主文件名去标签目录拼 path比如 stem.jpg 就去查 stem.txt。注意这里我把“有图片没标签”和“有标签没图片”分开统计这是很多现成脚本忽略的地方。实际标注过程中经常出现标到一半没存、或者某张图被删了但标签还在的情况这两种数据留着都会在训练时产生噪声提前揪出来才能保证数据集干净。最后是 split_and_distribute。洗牌前我用shuffled pairs[:]做了一个浅拷贝避免把外部传入的列表顺序改掉。洗牌用固定 seed随后按 round 计算每个子集的数量最后一个 test 用总数减前两个来兜底这样即使 round 有微小误差三个集合加起来也一定等于总数。文件分发时默认 copy2它会连文件的修改时间等元数据一起复制方便你之后核对。4. 把脚本跑起来使用步骤与实战配置4.1 命令行参数速查用之前先把参数表过一遍心里有数后面调参就快。参数默认值说明--images必填原始图片目录--labels必填原始标签目录--output./dataset输出目录--train0.8训练集比例--val0.1验证集比例--test0.1测试集比例--seed42随机种子--modecopycopy 为复制move 为移动--with-yamlFalse是否生成 data.yaml--ncNone类别数量配合 --with-yaml--namesNone类别名称列表配合 --with-yaml比例之和必须等于 1这个脚本启动时会校验。如果你只想分成两份比如 train 0.9、test 0.1、val 0可以用 --train 0.9 --val 0 --test 0.1脚本会把 val 子集建成空目录不影响训练。4.2 三种典型用法照着抄第一种默认 8:1:1 直接划分。把脚本保存为 split_dataset.py在命令行进入脚本所在目录执行python split_dataset.py --images ./images --labels ./labels运行结束后会在 ./dataset 下生成 images/ 和 labels/ 两个目录各自带 train/val/test。第一次跑建议就用默认的 copy 模式划分完去 dataset 里随机抽几个文件看看配对是否正确。第二种要生成 data.yaml同时指定类别信息python split_dataset.py --images ./images --labels ./labels --with-yaml --nc 2 --names person car这里 --nc 和 --names 可以只给一个比如你只给了 --names person car脚本会自动推断 nc2省得每次数一遍。如果类别名有空格记得用引号包起来比如 --names traffic light car。第三种磁盘不够、想省空间用 move 模式python split_dataset.py --images ./images --labels ./labels --mode movemove 会把原文件直接移动到输出目录速度比 copy 快得多也不占双倍磁盘。但风险在于一旦移动完成原始目录就空了。所以我建议先 copy 跑一次确认没问题或者确认自己有备份再上 move。4.3 自动生成 data.yaml 的细节用了 --with-yaml 之后脚本会在输出目录下生成 data.yaml内容类似这样# 由 split_dataset.py 自动生成 # 训练时如果相对路径报错请改成绝对路径 train: dataset/images/train val: dataset/images/val test: dataset/images/test nc: 2 names: 0: person 1: car这里我刻意把 train/val/test 路径写成相对路径而不是写死绝对路径因为不同机器的项目位置不一样。你训练时工作目录在 dataset 的上一级这个相对路径就能直接识别。如果换到别的机器上跑路径对不上就把这三行改成自己机器上的绝对路径。这种“给个能用的默认值 一行注释”的做法比直接写死路径或者完全不写要实用得多。4.4 划分完成后如何自查划分完别急着开始训练先做三件事。第一统计每个子集的图片和标签数量要求一一对应。第二随机抽三四张图把标签框画上去看一眼确认标签没有张冠李戴。第三确认 test 集在整个调参过程中没有被碰过训练时候的 data.yaml 也不要指向 test。这里给你一个简单自查脚本保存成 check_split.py 放在 dataset 同级目录跑from pathlib import Path for subset in [train, val, test]: img_dir Path(fdataset/images/{subset}) label_dir Path(fdataset/labels/{subset}) imgs list(img_dir.glob(*)) labels list(label_dir.glob(*.txt)) print(subset, 图片:, len(imgs), 标签:, len(labels)) img_stems {p.stem for p in imgs} label_stems {p.stem for p in labels} print( 仅图片无标签:, len(img_stems - label_stems), 仅标签无图片:, len(label_stems - img_stems))如果某个子集出现“仅图片无标签”数量大于 0说明配对逻辑出了问题回查原始目录里有没有同主文件名但不同扩展名的文件。5. 实测中踩过的坑和排查技巧5.1 常见问题速查表这几年用下来我遇到的高频问题基本就是下面这些整理成了一张表你可以直接对照排查。问题现象根本原因解决办法脚本提示 0 对配对图片扩展名是大写 .JPG或 .tif 等不在支持列表里先看 images 目录里的实际扩展名把缺失的后缀加入脚本里的 IMAGE_SUFFIXESlabels 目录全是 .txt 但匹配不上标签文件名带了额外后缀比如 image_001.txt.txt运行前先用脚本打印 stem 列表核对两边命名是否一致训练时提示 No labels founddata.yaml 里 train/val 路径写错或 labels 子目录为空检查路径是否存在重点检查 labels 下一级有没有误拆出多余目录同一个 batch 里反复出现相似目标视频连续抽帧相似帧被拆到训练集和验证集按视频 id 分组后再划分或抽帧时加大间隔两次运行结果不一致没有固定 seed或脚本被修改过固定 seed记录每个实验的 seed 值Windows 下中文路径报错系统编码问题尽量用英文路径或运行前设置 PYTHONUTF81某个子集是 0 张比例设置让 round 之后归零检查比例比如 train 0.9 val 0.05 test 0.05 在 10 张图时 test 可能为 05.2 三个最容易被忽视的细节第一个细节不要小看空标签文件。标注工具偶尔会生成 0 字节的 txt这种文件既不影响 YOLO 训练也不报错但它代表这张图没有标注目标。如果空标签图大量存在你对训练集有效样本的判断就会被带偏。我的脚本不主动过滤空标签因为空标签是合法的负样本但我建议你划分完以后单独统计一下空标签数量心里有数。第二个细节不同任务的标签内容不一样。目标检测的 txt 是 class x y w h姿态估计的 txt 内容要复杂得多实例分割任务如果用的是 mask 图那就不是简单复制一个 txt 能搞定的。这个脚本内部的配对、划分逻辑是通用的但如果你跑的是 YOLO26 的 pose 或 seg 版本请先确认你的标签文件到底长什么样分割任务的 mask 目录要一并纳入划分。第三个细节划分前务必检查 --output 不要指向原始数据目录内部。如果把输出目录放到 images 里面copy 模式会在复制过程中把新生成的文件也当成原始图片扫描导致结果越复制越多目录结构彻底乱掉。我建议输出目录放在原始数据目录的同级命名清楚比如 ./dataset。6. 关于数据划分几个写在最后的小经验我自己养成的一个习惯是划分完以后把所有文件名列表导出一份 txt 存起来。这样即使哪天误删了数据集目录也能根据清单重新组织发论文、出项目文档时需要说明数据划分方式这个清单就是最直接的依据。你可以在主脚本外面套一层把每个子集的文件名写进 split_result.txt。数据集很小的时候比如只有几百张8:1:1 会让验证集薄得没法看。这种情况下我一般直接做 5 折交叉验证脚本外循环换 seed轮流把每一折当验证集最后取平均指标。虽然 YOLO 生态有现成的 k-fold 工具但数据划分这一步用脚本循环控制最灵活你完全清楚每一折的构成。最后再提一个很多人会踩的坑验证集不是越大越好也不是越小越好而是要和你的真实场景分布一致。如果你的目标是室内监控场景数据里却混了一半野外航拍图划分再漂亮指标也会骗人。所以在跑划分脚本之前先按场景把数据过一遍把明显不属于目标领域的数据清掉这一步比任何脚本都重要。
返回列表