From af0897f8c781f5edaa2929e97eb282a7f4129e7c Mon Sep 17 00:00:00 2001 From: Shaun Zhou Date: Tue, 15 Sep 2026 17:19:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor(detect):=20=E6=95=B0=E6=8D=AE=E9=9B=86?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=89=81=E5=B9=B3=E7=BB=93=E6=9E=84=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E5=88=92=E5=88=86=20train/val=EF=BC=8CLabel=20Studio?= =?UTF-8?q?=20=E6=96=87=E4=BB=B6=E6=94=BE=E5=88=B0=E6=A0=B9=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepare_yolo_dataset.py 的产出先导入 Label Studio 复核,划分留到复核之后,于是: - 去掉按类别分层的 train/val 切分以及 --val-ratio / --seed,images/ 与 labels/ 下直接是文件;name_map.csv 去掉 split 列,summary.json 的逐类数量改为单列。 - label_studio/ 子目录的内容上移一层:扁平的数据集本身就是 label-studio-converter 要求的 /{images,labels,classes.txt},tasks.json 与 labeling config 直接 生成在根目录,不再需要额外的硬链接视图,DOCUMENT_ROOT 即数据集根目录。 - data.yaml 的 train 与 val 只能指向同一个 images/。ultralytics 能读,但验证指标 算的是训练集本身,mAP 虚高、早停与 best.pt 的选择失真。脚本结束时告警, detect.py 检测到 train == val 时也告警。 Co-Authored-By: Claude Opus 5 --- README.md | 58 ++++----- detect.py | 7 ++ scripts/prepare_yolo_dataset.py | 188 ++++++++++++----------------- tests/test_prepare_yolo_dataset.py | 92 ++++++-------- 4 files changed, 150 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index 6110b1a..d0c31b5 100644 --- a/README.md +++ b/README.md @@ -589,15 +589,15 @@ runs// ## 检测模型训练(YOLO) -上一步的产出按类别分目录,方便人工核对;检测训练要的是 train/val 划分——类别写在标签 -第一列,不靠目录名。所以先整理再训练: +上一步的产出按类别分目录,方便人工核对;YOLO 的类别写在标签第一列,不靠目录名。 +所以先拍平整理,导入 Label Studio 复核,再训练: ```bash -# 1) 拍平类别目录、分层切分、硬链接到 data/(不占额外磁盘) -uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_gradcam +# 1) 拍平类别目录、统一 PNG、按类别重命名,硬链接到 data/;同时生成 Label Studio 任务 +uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_shap --label-studio # 先看计划加 --dry-run -# 2) 训练 +# 2) 训练(训练前需要先划分,见下文"不划分 train/val") uv run python detect.py --config configs/detect/film.yaml # 只评估 / 续训 @@ -609,15 +609,22 @@ uv run python detect.py --config configs/detect/film.yaml --resume 产出: ``` -data/boxes_gradcam/ -├── images/{train,val}/<类别>_<序号>.png # 一律 PNG:PNG 原图硬链接,BMP 等无损转换 -├── labels/{train,val}/<类别>_<序号>.txt -├── classes.txt # 每行一个类别名,行号即 class_id -├── name_map.csv # 新文件名 ↔ 源文件路径 -├── data.yaml # path 写绝对路径,ultralytics 从哪跑都能读到 -└── summary.json # 划分明细、逐类数量、落盘方式 +data/boxes_shap/ +├── images/<类别>_<序号>.png # 一律 PNG:PNG 原图硬链接,BMP 等无损转换 +├── labels/<类别>_<序号>.txt +├── classes.txt # 每行一个类别名,行号即 class_id +├── name_map.csv # 新文件名 ↔ 源文件路径 +├── data.yaml # train 与 val 都指向 images/ +├── summary.json # 逐类数量、空标签处置、落盘方式 +├── tasks.json # 仅 --label-studio:每张图一个任务,框作为 predictions +└── tasks.label_config.xml # 仅 --label-studio:各类别的 RectangleLabels ``` +**不划分 train/val。** 这份数据集先导入 Label Studio 复核,划分留到复核之后。`data.yaml` +的 `train` 与 `val` 只能指向同一个 `images/`,ultralytics 能读,但验证指标算的是训练集本身, +mAP 会虚高,早停与 `best.pt` 的选择也随之失真。脚本结束时会告警,`detect.py` 检测到 +train 与 val 相同时也会告警。正式训练前要先划分。 + **图片统一成 PNG。** pearlescent / blue / uv 的原始数据是 BMP(共 10,814 张), `generate_boxes.py` 按原后缀链接,于是 YOLO 数据集里会混着两种格式。这一步按**文件头** (不是后缀)判断:真 PNG 照旧硬链接不占磁盘,其余无损转 PNG——不缩放像素,归一化的框坐标 @@ -626,8 +633,8 @@ data/boxes_gradcam/ **按类别重新编号。** 源文件名五花八门(`反面纵黑点_1_550mmX1561.71m_20260416160441_0.png`、 `pearlescent_404.bmp`……),输出统一改成 `<类别>_<序号>.png`,如 `blackdot_0042.png`。 -类别以标签第一列为准,文件名永远不会与标签矛盾;序号在类内按源路径排序、与 train/val 划分 -无关,换 `--seed` 或 `--val-ratio` 重跑,同一张图拿到的名字不变。原文件名里的正反面、位置、 +类别以标签第一列为准,文件名永远不会与标签矛盾;序号在类内按源路径排序,同一份来源重跑, +同一张图拿到的名字不变。原文件名里的正反面、位置、 时间戳等信息都在 `name_map.csv` 里(带 BOM,Excel 直接打开中文不乱码)。想保留原名加 `--keep-names`。 **classes.txt** 是给 LabelImg / X-AnyLabeling 这类标注工具用的,人工复核、修改框时 @@ -635,18 +642,8 @@ data/boxes_gradcam/ ### 导入 Label Studio 人工复核 -弱监督框需要人工把关。加 `--label-studio` 会额外生成一份可以直接导入的视图: - -```bash -uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_shap --label-studio -``` - -``` -data/boxes_shap/label_studio/ -├── images/*.png labels/*.txt classes.txt # 两个 split 拍平,硬链接 -├── tasks.json # 每张图一个任务,框作为 predictions -└── tasks.label_config.xml # 9 个类别的 RectangleLabels -``` +弱监督框需要人工把关。加 `--label-studio` 会在数据集根目录额外生成 `tasks.json` 与 +`tasks.label_config.xml`,数据集根目录本身就是 Label Studio 的 `DOCUMENT_ROOT`。 脚本结束时会打印带实际路径的导入步骤:设置 `LABEL_STUDIO_LOCAL_FILES_*` 环境变量启动、 粘贴 labeling config、添加 Local files 存储(**只保存不要 Sync**,否则每张图多出一个空任务)、 @@ -654,8 +651,8 @@ data/boxes_shap/label_studio/ 几个取舍: -- **为什么要拍平**:`label-studio-converter import yolo` 只认 `/{images,labels,classes.txt}`, - 且 `os.listdir` 不递归,看不见 `train/`、`val/`。按类别重命名后文件名全局唯一,可以直接合并。 +- **为什么是扁平结构**:`label-studio-converter import yolo` 只认 `/{images,labels,classes.txt}`, + 且 `os.listdir` 不递归,看不见子目录。数据集不划分、直接扁平,正好就是它要的结构。 - **硬链接而不是软链接**:Label Studio 读本地文件时会检查路径是否在 `DOCUMENT_ROOT` 之下, 软链接解析后可能落到外面。 - **框作为 predictions 而不是 annotations**:它们是模型生成的。审核员修改提交后才成为 annotation, @@ -667,11 +664,6 @@ data/boxes_shap/label_studio/ > **复核重点是 `normal`。** SHAP 在每张图上都框了东西,`normal` 有 4,295 个框、占 38%。 > 若 `normal` 表示无缺陷,这些框要删掉,否则检测器会学着"检测正常"。 -### 划分 - -按类别分层(`--val-ratio` 默认 0.2,`--seed` 默认 42,两次运行结果一致)。分层是必需的: -`black` 只有 53 张、`others` 69 张,随机切很容易让某个小类在验证集里挂零,指标就成了空的。 - ### 没有框的图怎么处理 Grad-CAM 没框出东西的有 339 张(占 3%)。它们**默认保留为负样本**,对压低误检有用。 diff --git a/detect.py b/detect.py index 132a6b1..e352a7f 100644 --- a/detect.py +++ b/detect.py @@ -69,6 +69,13 @@ def main() -> int: return 1 meta = yaml.safe_load(data_yaml.read_text(encoding="utf-8")) logger.info("数据集: %s(%d 类)", data_yaml, meta["nc"]) + if meta.get("train") == meta.get("val"): + # prepare_yolo_dataset.py / generate_boxes.py 的产出都不划分,两者指向同一目录 + logger.warning( + "data.yaml 的 train 与 val 相同(%s):验证集就是训练集,mAP 会虚高," + "早停与 best.pt 的选择也因此失真。正式训练前请先划分数据集", + meta.get("train"), + ) train_cfg = dict(cfg.get("train", {})) swanlab_cfg = dict(train_cfg.pop("swanlab", {})) diff --git a/scripts/prepare_yolo_dataset.py b/scripts/prepare_yolo_dataset.py index 2bbc828..2d43565 100644 --- a/scripts/prepare_yolo_dataset.py +++ b/scripts/prepare_yolo_dataset.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""把 generate_boxes.py 的产出整理成 YOLO 训练所需的数据集,放到 data/ 下。 +"""把 generate_boxes.py 的产出整理成扁平的 YOLO 数据集,放到 data/ 下。 uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_gradcam @@ -11,33 +11,33 @@ runs/boxes_gradcam/ └── labels/<类别>/<图名>.txt ``` -而 YOLO 检测训练要的是 **train/val 划分**——类别信息在标签文件的第一列里, -不靠目录名。这一步做三件事: +而 YOLO 的类别信息在标签文件的第一列里,不靠目录名。这一步做三件事: -1. 把按类别分的目录拍平,按 train/val 重新组织; -2. **按类别分层**切分,小类不至于在验证集里挂零; -3. 图片**统一成 PNG**:本来就是 PNG 的硬链接过去,不占额外磁盘;BMP 等其他格式 +1. 把按类别分的目录**拍平**,不划分 train/val——这份数据集先导入 Label Studio 复核, + 划分留到复核之后; +2. 图片**统一成 PNG**:本来就是 PNG 的硬链接过去,不占额外磁盘;BMP 等其他格式 (pearlescent / blue / uv 原始数据都是 BMP)无损转成 PNG。不缩放像素, 归一化的框坐标原样有效。 -4. 文件**按类别重新编号**:`<类别>_<序号>.png`,序号在类内按源路径排序,重跑结果一致。 +3. 文件**按类别重新编号**:`<类别>_<序号>.png`,序号在类内按源路径排序,重跑结果一致。 原文件名里的正反面、位置、时间戳等信息记在 `name_map.csv` 里,不会丢。 想保留原名加 `--keep-names`。 ``` data// -├── images/{train,val}/<类别>_<序号>.png -├── labels/{train,val}/<类别>_<序号>.txt -├── classes.txt # 每行一个类别名,行号即 class_id,给标注工具复核用 -├── name_map.csv # 新名 ↔ 源文件对照 -├── data.yaml +├── images/<类别>_<序号>.png +├── labels/<类别>_<序号>.txt +├── classes.txt # 每行一个类别名,行号即 class_id +├── name_map.csv # 新名 ↔ 源文件对照 +├── data.yaml # train 与 val 都指向 images/,不能直接用于评估 ├── summary.json -└── label_studio/ # 仅 --label-studio:导入 Label Studio 人工复核用 - ├── images/*.png labels/*.txt classes.txt # 两个 split 拍平,硬链接 - ├── tasks.json # 框作为 predictions - └── tasks.label_config.xml +├── tasks.json # 仅 --label-studio:框作为 predictions +└── tasks.label_config.xml # 仅 --label-studio ``` +这个结构正是 label-studio-converter 的 YOLO 导入要求的 `/{images,labels,classes.txt}`, +所以 `--label-studio` 直接在数据集根目录上转换,DOCUMENT_ROOT 就是数据集根目录。 + 没有框的图(标签为空)默认保留:YOLO 训练里它们是负样本,有助于压低误检。但**空标签的 语义是"这张图什么都没有"**,不是"有缺陷但没定位出来"。来自缺陷类别的空标签如果照搬, 等于在教模型抑制真实缺陷,所以可以用 `--empty-policy` 按来源类别分别处置: @@ -56,7 +56,6 @@ import io import json import logging import os -import random import shutil import sys from collections import Counter, defaultdict @@ -196,8 +195,8 @@ def class_of(sample: Sample, names: Sequence[str]) -> str: def assign_names(samples: Sequence[Sample], names: Sequence[str]) -> dict[Path, str]: """按类别重新编号,返回 {源图片: 新文件名主干},如 `blackdot_0042`。 - 序号在类内按源路径排序、与 train/val 划分无关:换 --val-ratio 或 --seed 重跑, - 同一张图拿到的名字不变,已经做过的人工复核记录还能对得上。 + 序号在类内按源路径排序:同一份来源重跑,同一张图拿到的名字不变, + 已经做过的人工复核记录还能对得上。 """ by_class: dict[str, list[Sample]] = defaultdict(list) for sample in samples: @@ -210,16 +209,12 @@ def assign_names(samples: Sequence[Sample], names: Sequence[str]) -> dict[Path, return mapping -LABEL_STUDIO_DIR = "label_studio" +def build_label_studio_tasks(out_dir: Path, num_images: int) -> dict: + """在数据集根目录上调用 label-studio-converter,生成 tasks.json 与 labeling config。 - -def build_label_studio_view(out_dir: Path, num_images: int) -> dict: - """在 /label_studio/ 下搭一份 label-studio-converter 能直接吃的扁平视图并转换。 - - label-studio-converter 的 YOLO 导入只认 `/{images,labels,classes.txt}`, - 且 os.listdir 不递归——train/val 子目录它看不见。重命名后文件名全局唯一, - 两个 split 可以直接拍平;用硬链接而不是软链接,因为 Label Studio 本地存储会检查 - 文件是否在 DOCUMENT_ROOT 之下,软链接解析后可能落到外面。 + converter 的 YOLO 导入只认 `/{images,labels,classes.txt}`,且 os.listdir 不递归—— + 这正是本脚本产出的扁平结构。图片是硬链接而不是软链接也有讲究:Label Studio 本地存储 + 会检查文件是否在 DOCUMENT_ROOT 之下,软链接解析后可能落到外面。 框以 predictions 导入:它们是 SHAP / Grad-CAM 生成的,不是人工标注。审核员修改提交 之后才成为 annotation,Label Studio 导出时也只导出 annotation,没审过的框不会混进去。 @@ -229,14 +224,7 @@ def build_label_studio_view(out_dir: Path, num_images: int) -> dict: except ImportError as exc: raise RuntimeError("--label-studio 需要 label-studio(uv sync 后可用)") from exc - view = out_dir / LABEL_STUDIO_DIR - for kind in ("images", "labels"): - for split in ("train", "val"): - for path in sorted((out_dir / kind / split).iterdir()): - link_or_copy(path, view / kind / path.name) - link_or_copy(out_dir / "classes.txt", view / "classes.txt") - - tasks_file = view / "tasks.json" + tasks_file = out_dir / "tasks.json" # converter 会往 stdout 打一份缺了环境变量的导入说明,还会刷一串 root logger 的 INFO; # 静音它们,由 main() 打印带实际路径的完整步骤 root_logger = logging.getLogger() @@ -246,7 +234,7 @@ def build_label_studio_view(out_dir: Path, num_images: int) -> dict: with contextlib.redirect_stdout(io.StringIO()): # 不传 image_dims:图片尺寸不统一,传了会把同一个宽高写进每一条标注 convert_yolo_to_ls( - input_dir=str(view), + input_dir=str(out_dir), out_file=str(tasks_file), out_type="predictions", image_ext=".png", @@ -257,13 +245,13 @@ def build_label_studio_view(out_dir: Path, num_images: int) -> dict: if len(tasks) != num_images: raise RuntimeError( f"label-studio-converter 只生成了 {len(tasks)} 个任务,应为 {num_images};" - f"检查 {view / 'images'}" + f"检查 {out_dir / 'images'}" ) return { "tasks": len(tasks), - "document_root": str(view.resolve()), + "document_root": str(out_dir.resolve()), "tasks_file": str(tasks_file.resolve()), - "label_config": str((view / "tasks.label_config.xml").resolve()), + "label_config": str((out_dir / "tasks.label_config.xml").resolve()), } @@ -337,8 +325,6 @@ def main() -> int: parser.add_argument("--source", required=True, help="generate_boxes.py 的产出目录") parser.add_argument("--out", default=None, help="输出目录;缺省 data/") parser.add_argument("--data-root", default="data", help="自动推导时的根目录,默认 data") - parser.add_argument("--val-ratio", type=float, default=0.2) - parser.add_argument("--seed", type=int, default=42) parser.add_argument( "--empty-policy", nargs="*", default=[], metavar="POLICY|类别=POLICY", help=( @@ -354,7 +340,7 @@ def main() -> int: ) parser.add_argument( "--label-studio", action="store_true", - help="额外生成 /label_studio/:拍平的硬链接视图 + tasks.json,可直接导入 Label Studio 复核", + help="额外在数据集根目录生成 tasks.json 与 labeling config,可直接导入 Label Studio 复核", ) parser.add_argument("--overwrite", action="store_true", help="输出目录已存在时先清空") parser.add_argument("--dry-run", action="store_true", help="只打印计划") @@ -403,35 +389,16 @@ def main() -> int: "/".join(as_negative), ) - # 按类别分层切分;无框的图单独作为一组分配,避免它们全落进同一侧 - grouped: dict[int | None, list[Sample]] = defaultdict(list) - for sample in samples: - # 退化成整图框的那些已经有了确定类别,跟着本类一起分层 - grouped[sample.full_box if sample.class_id is None else sample.class_id].append(sample) - - rng = random.Random(args.seed) - splits: dict[str, list] = {"train": [], "val": []} - for _class_id, group in sorted(grouped.items(), key=lambda kv: (kv[0] is None, kv[0])): - order = group[:] - rng.shuffle(order) - n_val = max(1, round(len(order) * args.val_ratio)) if len(order) > 1 else 0 - splits["val"].extend(order[:n_val]) - splits["train"].extend(order[n_val:]) - - counts = { - split: Counter( - names[s.class_id] if s.class_id is not None - else names[s.full_box] if s.full_box is not None - else "(无框)" - for s in items - ) - for split, items in splits.items() - } - logger.info("划分: train %d / val %d", len(splits["train"]), len(splits["val"])) - logger.info("%-14s %8s %8s", "类别", "train", "val") + counts = Counter( + names[s.class_id] if s.class_id is not None + else names[s.full_box] if s.full_box is not None + else "(无框)" + for s in samples + ) + logger.info("%-14s %8s", "类别", "数量") for name in names + ["(无框)"]: - if counts["train"][name] or counts["val"][name]: - logger.info("%-14s %8d %8d", name, counts["train"][name], counts["val"][name]) + if counts[name]: + logger.info("%-14s %8d", name, counts[name]) to_convert = sum(not is_png(sample.image) for sample in samples) if to_convert: @@ -442,7 +409,7 @@ def main() -> int: if args.dry_run: if args.label_studio: - logger.info("--label-studio:会在 %s 下生成导入视图", out_dir / LABEL_STUDIO_DIR) + logger.info("--label-studio:会在 %s 下生成 tasks.json", out_dir) logger.info("--dry-run:未写入任何文件") return 0 @@ -456,52 +423,51 @@ def main() -> int: modes: Counter[str] = Counter() seen: dict[str, Path] = {} name_rows = [] - for split, items in splits.items(): - for sample in items: - image = sample.image - stem = image.stem if renamed is None else renamed[image] - output_name = f"{stem}.png" - # 保留原名时,拍平并统一后缀后不同目录下的 a.png 与 a.bmp 会撞成同一个 a.png; - # 重新编号时只有类别名仅大小写不同才会撞。按小写比较:macOS 默认文件系统 - # 不区分大小写,A.png 与 a.png 也会互相覆盖。 - key = output_name.lower() - if key in seen: - hint = ( - "源数据集的文件名(去掉后缀后)需要全局唯一," - "去掉 --keep-names 按类别重新编号," - "或用 scripts/export_clusters.py --prefix-parent 重新导出" - if renamed is None - else "data.yaml 里有只差大小写的类别名,请先改成互不相同" - ) - logger.error("输出文件名冲突:%s 与 %s 都会落成 %s。%s", seen[key], image, output_name, hint) - return 1 - seen[key] = image - modes[materialize_png(image, out_dir / "images" / split / output_name)] += 1 - name_rows.append((split, output_name, class_of(sample, names), str(image.resolve()))) - destination = out_dir / "labels" / split / f"{stem}.txt" - if sample.full_box is None: - link_or_copy(sample.label, destination) - else: - # 整图框:内容与源标签不同,只能新写一份,不能链接 - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text(f"{sample.full_box} 0.5 0.5 1.0 1.0\n", encoding="utf-8") + for sample in samples: + image = sample.image + stem = image.stem if renamed is None else renamed[image] + output_name = f"{stem}.png" + # 保留原名时,拍平并统一后缀后不同目录下的 a.png 与 a.bmp 会撞成同一个 a.png; + # 重新编号时只有类别名仅大小写不同才会撞。按小写比较:macOS 默认文件系统 + # 不区分大小写,A.png 与 a.png 也会互相覆盖。 + key = output_name.lower() + if key in seen: + hint = ( + "源数据集的文件名(去掉后缀后)需要全局唯一," + "去掉 --keep-names 按类别重新编号," + "或用 scripts/export_clusters.py --prefix-parent 重新导出" + if renamed is None + else "data.yaml 里有只差大小写的类别名,请先改成互不相同" + ) + logger.error("输出文件名冲突:%s 与 %s 都会落成 %s。%s", seen[key], image, output_name, hint) + return 1 + seen[key] = image + modes[materialize_png(image, out_dir / "images" / output_name)] += 1 + name_rows.append((output_name, class_of(sample, names), str(image.resolve()))) + destination = out_dir / "labels" / f"{stem}.txt" + if sample.full_box is None: + link_or_copy(sample.label, destination) + else: + # 整图框:内容与源标签不同,只能新写一份,不能链接 + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(f"{sample.full_box} 0.5 0.5 1.0 1.0\n", encoding="utf-8") - write_data_yaml(out_dir / "data.yaml", names, root=out_dir.resolve(), - train="images/train", val="images/val") + # 不划分时 train 与 val 只能指向同一目录:ultralytics 能读,但验证指标算的是训练集本身 + write_data_yaml(out_dir / "data.yaml", names, root=out_dir.resolve(), train="images", val="images") write_classes_txt(out_dir / "classes.txt", names) label_studio = None if args.label_studio: try: - label_studio = build_label_studio_view(out_dir, len(samples)) + label_studio = build_label_studio_tasks(out_dir, len(samples)) except RuntimeError as exc: logger.error("%s", exc) return 1 # utf-8-sig:带 BOM,Excel / Numbers 直接打开中文源路径不乱码 with (out_dir / "name_map.csv").open("w", encoding="utf-8-sig", newline="") as handle: writer = csv.writer(handle) - writer.writerow(["split", "name", "class", "source"]) - writer.writerows(sorted(name_rows, key=lambda row: (row[2], row[1]))) + writer.writerow(["name", "class", "source"]) + writer.writerows(sorted(name_rows, key=lambda row: (row[1], row[0]))) (out_dir / "summary.json").write_text( json.dumps( { @@ -514,10 +480,8 @@ def main() -> int: "empty_policy": {"default": default_policy, **per_class_policy}, "renamed": renamed is not None, "label_studio": label_studio, - "val_ratio": args.val_ratio, - "seed": args.seed, - "split_sizes": {k: len(v) for k, v in splits.items()}, - "per_class": {k: dict(v) for k, v in counts.items()}, + "split": False, + "per_class": dict(counts), "link_modes": dict(modes), }, ensure_ascii=False, @@ -527,6 +491,10 @@ def main() -> int: ) logger.info("图片落盘方式: %s(hard=硬链接, copy=跨盘复制, convert=转成 PNG)", dict(modes)) logger.info("完成 -> %s", out_dir) + logger.warning( + "数据集未划分 train/val,data.yaml 的 train 与 val 都指向 images/。" + "直接拿去训练的话验证指标算的是训练集本身,会虚高;训练前需要另行划分。" + ) if label_studio: # label-studio-converter 自带的提示漏了环境变量(它在循环里改写了 image_root_url, # 与默认值比较时就不相等了),这里给出带实际路径的完整步骤 diff --git a/tests/test_prepare_yolo_dataset.py b/tests/test_prepare_yolo_dataset.py index f3e6421..aa50b95 100644 --- a/tests/test_prepare_yolo_dataset.py +++ b/tests/test_prepare_yolo_dataset.py @@ -50,7 +50,7 @@ def output_of(out, source_stem): with (out / "name_map.csv").open(encoding="utf-8-sig", newline="") as handle: for row in csv.DictReader(handle): if Path(row["source"]).stem == source_stem: - return out / "images" / row["split"] / row["name"] + return out / "images" / row["name"] return None @@ -63,19 +63,18 @@ def run(source, out, *extra): sys.argv = original -def test_flattens_classes_and_splits(source, tmp_path): - """类别目录被拍平,train/val 分开,图片与标签一一对应。""" +def test_flat_layout_without_split(source, tmp_path): + """类别目录被拍平、不划分 train/val:images/ 与 labels/ 下直接是文件,一一对应。""" out = tmp_path / "data" / "boxes" assert run(source, out) == 0 - for split in ("train", "val"): - images = sorted(p.stem for p in (out / "images" / split).iterdir()) - labels = sorted(p.stem for p in (out / "labels" / split).iterdir()) - assert images == labels - total = sum(len(list((out / "images" / s).iterdir())) for s in ("train", "val")) - assert total == 13 - # 拍平后不应再有 <类别>/ 这一层 - assert not (out / "images" / "train" / "grease").exists() + for kind in ("images", "labels"): + assert not [p for p in (out / kind).iterdir() if p.is_dir()] + images = sorted(p.stem for p in (out / "images").iterdir()) + labels = sorted(p.stem for p in (out / "labels").iterdir()) + assert images == labels + assert len(images) == 13 + assert json.loads((out / "summary.json").read_text(encoding="utf-8"))["split"] is False def test_hard_links_share_inode(source, tmp_path): @@ -87,13 +86,17 @@ def test_hard_links_share_inode(source, tmp_path): assert linked.stat().st_ino == original.stat().st_ino -def test_small_class_present_in_both_splits(source, tmp_path): - """只有 2 张的小类也要在验证集里留下一张,否则它的指标是空的。""" +def test_per_class_counts_in_summary(source, tmp_path): out = tmp_path / "data" / "boxes" assert run(source, out) == 0 per_class = json.loads((out / "summary.json").read_text(encoding="utf-8"))["per_class"] - assert per_class["train"]["whitedot"] == 1 - assert per_class["val"]["whitedot"] == 1 + assert per_class == {"grease": 6, "normal": 4, "(无框)": 1, "whitedot": 2} + + +def test_warns_that_data_is_not_split(source, tmp_path, capsys): + """train 与 val 指向同一目录,直接训练验证指标会虚高,必须提醒。""" + assert run(source, tmp_path / "data" / "boxes") == 0 + assert "未划分" in capsys.readouterr().out def test_empty_labels_kept_as_negatives_by_default(source, tmp_path): @@ -129,14 +132,14 @@ def test_empty_policy_full_writes_a_whole_image_box(source, tmp_path): assert run(source, out, "--empty-policy", "normal=full") == 0 image = output_of(out, "normal_0") - label = out / "labels" / image.parent.name / f"{image.stem}.txt" + label = out / "labels" / f"{image.stem}.txt" assert label.read_text(encoding="utf-8").split() == ["1", "0.5", "0.5", "1.0", "1.0"] # 重写而不是硬链接,源标签必须还是空的 assert (source / "labels" / "normal" / "normal_0.txt").read_text(encoding="utf-8") == "" - # 有了确定类别之后,它要跟着 normal 一起参与分层 + # 有了确定类别之后,计入 normal 而不是"(无框)" per_class = json.loads((out / "summary.json").read_text(encoding="utf-8"))["per_class"] - assert per_class["train"]["normal"] + per_class["val"]["normal"] == 5 + assert per_class["normal"] == 5 @pytest.mark.parametrize( @@ -161,19 +164,9 @@ def test_data_yaml_uses_absolute_path_and_source_names(source, tmp_path): assert Path(meta["path"]).is_absolute() assert meta["nc"] == 3 assert [meta["names"][i] for i in sorted(meta["names"])] == CLASSES + # 不划分:两者都指向扁平的 images/ + assert meta["train"] == meta["val"] == "images" assert (Path(meta["path"]) / meta["train"]).is_dir() - assert (Path(meta["path"]) / meta["val"]).is_dir() - - -def test_reproducible_split(source, tmp_path): - """同一个种子两次运行,划分必须一致。""" - first, second = tmp_path / "a", tmp_path / "b" - assert run(source, first) == 0 - assert run(source, second) == 0 - for split in ("train", "val"): - assert sorted(p.name for p in (first / "images" / split).iterdir()) == sorted( - p.name for p in (second / "images" / split).iterdir() - ) def test_name_collision_after_flattening_is_reported(source, tmp_path, capsys): @@ -210,7 +203,7 @@ def test_dry_run_writes_nothing(source, tmp_path): def _all_outputs(out, kind): - return [p for split in ("train", "val") for p in (out / kind / split).iterdir()] + return list((out / kind).iterdir()) def _add_sample(source, group, stem, suffix, class_id, *, fmt=None, mode="RGB"): @@ -358,16 +351,16 @@ def test_name_map_traces_back_to_source(source, tmp_path): rows = list(csv.DictReader(handle)) assert len(rows) == 13 for row in rows: - output = out / "images" / row["split"] / row["name"] + output = out / "images" / row["name"] assert output.read_bytes() == Path(row["source"]).read_bytes() assert row["name"].startswith(row["class"] + "_") -def test_names_stable_across_different_splits(source, tmp_path): - """序号与划分无关:换种子重跑,同一张源图拿到同一个名字。""" +def test_names_stable_across_reruns(source, tmp_path): + """重跑同一份来源,同一张源图拿到同一个名字,已做的复核记录才对得上。""" first, second = tmp_path / "a", tmp_path / "b" - assert run(source, first, "--seed", "1") == 0 - assert run(source, second, "--seed", "2") == 0 + assert run(source, first) == 0 + assert run(source, second) == 0 for stem in ("grease_0", "grease_5", "normal_3", "whitedot_1"): assert output_of(first, stem).name == output_of(second, stem).name @@ -398,19 +391,14 @@ def label_studio_out(source, tmp_path): def _tasks(out): - return json.loads((out / "label_studio" / "tasks.json").read_text(encoding="utf-8")) + return json.loads((out / "tasks.json").read_text(encoding="utf-8")) -def test_label_studio_view_is_flat_and_hard_linked(label_studio_out): - """converter 的 os.listdir 不递归,必须拍平;且与训练集共用 inode,不占磁盘。""" - view = label_studio_out / "label_studio" - images = sorted(view.glob("images/*.png")) - assert len(images) == 13 - assert not [p for p in (view / "images").iterdir() if p.is_dir()] - for image in images: - original = next(label_studio_out.glob(f"images/*/{image.name}")) - assert image.stat().st_ino == original.stat().st_ino - assert (view / "classes.txt").read_text(encoding="utf-8").split() == CLASSES +def test_label_studio_files_at_dataset_root(label_studio_out): + """tasks.json 与 labeling config 直接放在数据集根目录,不再有 label_studio/ 子目录。""" + assert (label_studio_out / "tasks.json").is_file() + assert (label_studio_out / "tasks.label_config.xml").is_file() + assert not (label_studio_out / "label_studio").exists() def test_label_studio_tasks_are_predictions_with_urls(label_studio_out): @@ -421,12 +409,12 @@ def test_label_studio_tasks_are_predictions_with_urls(label_studio_out): assert "annotations" not in task assert task["data"]["image"].startswith("/data/local-files/?d=images/") name = task["data"]["image"].rsplit("/", 1)[1] - assert (label_studio_out / "label_studio" / "images" / name).exists() + assert (label_studio_out / "images" / name).exists() def test_label_studio_boxes_round_trip_to_yolo(label_studio_out): """Label Studio 的左上角百分比坐标反算回 YOLO 中心点坐标,必须与标签一致。""" - view = label_studio_out / "label_studio" + view = label_studio_out checked = 0 for task in _tasks(label_studio_out): name = Path(task["data"]["image"].rsplit("/", 1)[1]).stem @@ -446,16 +434,16 @@ def test_label_studio_boxes_round_trip_to_yolo(label_studio_out): def test_label_studio_config_and_summary(label_studio_out): - config = (label_studio_out / "label_studio" / "tasks.label_config.xml").read_text(encoding="utf-8") + config = (label_studio_out / "tasks.label_config.xml").read_text(encoding="utf-8") for name in CLASSES: assert f'value="{name}"' in config summary = json.loads((label_studio_out / "summary.json").read_text(encoding="utf-8")) assert summary["label_studio"]["tasks"] == 13 - assert Path(summary["label_studio"]["document_root"]).is_absolute() + assert Path(summary["label_studio"]["document_root"]) == label_studio_out.resolve() def test_label_studio_not_built_by_default(source, tmp_path): out = tmp_path / "data" / "boxes" assert run(source, out) == 0 - assert not (out / "label_studio").exists() + assert not (out / "tasks.json").exists() assert json.loads((out / "summary.json").read_text(encoding="utf-8"))["label_studio"] is None