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% 都够用。这真不是玄学,本质是权衡:训练样本少了学不到特征,验证样本少了指标不稳,得根据你的数据量去倒推比例。
随机种子这块,我习惯固定 seed=42。固定种子的意义不只是“每次运行都一样”,而是让你的实验可复现。今天跑一次,明天再跑一次,结果应该完全一致;你跟别人对比实验结果时,两边用同一个 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(description="YOLO 数据集划分工具") parser.add_argument("--images", required=True, help="原始图片目录") parser.add_argument("--labels", required=True, help="原始标签目录") parser.add_argument("--output", default="./dataset", help="输出目录,默认 ./dataset") parser.add_argument("--train", type=float, default=0.8, help="训练集比例,默认 0.8") parser.add_argument("--val", type=float, default=0.1, help="验证集比例,默认 0.1") parser.add_argument("--test", type=float, default=0.1, help="测试集比例,默认 0.1") parser.add_argument("--seed", type=int, default=42, help="随机种子,默认 42") parser.add_argument("--mode", choices=["copy", "move"], default="copy", help="copy 保留原文件(默认),move 则移动文件") parser.add_argument("--with-yaml", action="store_true", help="同时生成 data.yaml") parser.add_argument("--nc", type=int, default=None, help="类别数量,配合 --with-yaml") parser.add_argument("--names", nargs="*", default=None, 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(parents=True, exist_ok=True) 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(f"names 数量 {len(names)} 与 nc {nc} 不一致") out = Path(output) lines = [ "# 由 split_dataset.py 自动生成", "# 训练时如果相对路径报错,请改成绝对路径", f"train: {out / 'images' / 'train'}", f"val: {out / 'images' / 'val'}", f"test: {out / 'images' / 'test'}", f"nc: {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", encoding="utf-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 | 输出目录 |
| --train | 0.8 | 训练集比例 |
| --val | 0.1 | 验证集比例 |
| --test | 0.1 | 测试集比例 |
| --seed | 42 | 随机种子 |
| --mode | copy | copy 为复制,move 为移动 |
| --with-yaml | False | 是否生成 data.yaml |
| --nc | None | 类别数量,配合 --with-yaml |
| --names | None | 类别名称列表,配合 --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,脚本会自动推断 nc=2,省得每次数一遍。如果类别名有空格,记得用引号包起来,比如 --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(f"dataset/images/{subset}") label_dir = Path(f"dataset/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_SUFFIXES |
| labels 目录全是 .txt 但匹配不上 | 标签文件名带了额外后缀,比如 image_001.txt.txt | 运行前先用脚本打印 stem 列表,核对两边命名是否一致 |
| 训练时提示 No labels found | data.yaml 里 train/val 路径写错,或 labels 子目录为空 | 检查路径是否存在,重点检查 labels 下一级有没有误拆出多余目录 |
| 同一个 batch 里反复出现相似目标 | 视频连续抽帧,相似帧被拆到训练集和验证集 | 按视频 id 分组后再划分,或抽帧时加大间隔 |
| 两次运行结果不一致 | 没有固定 seed,或脚本被修改过 | 固定 seed,记录每个实验的 seed 值 |
| Windows 下中文路径报错 | 系统编码问题 | 尽量用英文路径,或运行前设置 PYTHONUTF8=1 |
| 某个子集是 0 张 | 比例设置让 round 之后归零 | 检查比例,比如 train 0.9 val 0.05 test 0.05 在 10 张图时 test 可能为 0 |
5.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 工具,但数据划分这一步用脚本循环控制最灵活,你完全清楚每一折的构成。
最后再提一个很多人会踩的坑:验证集不是越大越好,也不是越小越好,而是要和你的真实场景分布一致。如果你的目标是室内监控场景,数据里却混了一半野外航拍图,划分再漂亮,指标也会骗人。所以在跑划分脚本之前,先按场景把数据过一遍,把明显不属于目标领域的数据清掉,这一步比任何脚本都重要。