Files
ModelTrainEval/tests/test_prepare_yolo_dataset.py
T
Shaun ZhouandClaude Opus 5 af0897f8c7 refactor(detect): 数据集改为扁平结构、不划分 train/val,Label Studio 文件放到根目录
prepare_yolo_dataset.py 的产出先导入 Label Studio 复核,划分留到复核之后,于是:

- 去掉按类别分层的 train/val 切分以及 --val-ratio / --seed,images/ 与 labels/
  下直接是文件;name_map.csv 去掉 split 列,summary.json 的逐类数量改为单列。
- label_studio/ 子目录的内容上移一层:扁平的数据集本身就是 label-studio-converter
  要求的 <input>/{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 <noreply@anthropic.com>
2026-09-15 17:19:53 +08:00

450 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把 generate_boxes.py 的产出整理成 YOLO 训练集。"""
import csv
import json
import sys
from pathlib import Path
import pytest
pytest.importorskip("PIL")
import numpy as np # noqa: E402
import yaml # noqa: E402
from PIL import Image # noqa: E402
sys.path.insert(0, "scripts")
import prepare_yolo_dataset as pyd # noqa: E402
CLASSES = ["grease", "normal", "whitedot"]
@pytest.fixture
def source(tmp_path):
"""模拟 generate_boxes.py 的产出:images/<类别>/ + labels/<类别>/ + data.yaml。"""
root = tmp_path / "runs" / "boxes"
rng = np.random.default_rng(0)
# whitedot 只有 2 张(小类),normal 里有 1 张无框(负样本)
for class_id, (name, count) in enumerate(zip(CLASSES, (6, 5, 2), strict=True)):
image_dir = root / "images" / name
label_dir = root / "labels" / name
image_dir.mkdir(parents=True)
label_dir.mkdir(parents=True)
for i in range(count):
stem = f"{name}_{i}"
Image.fromarray(rng.integers(0, 255, (32, 32, 3), dtype=np.uint8)).save(
image_dir / f"{stem}.png"
)
empty = name == "normal" and i == 0
(label_dir / f"{stem}.txt").write_text(
"" if empty else f"{class_id} 0.5 0.5 0.2 0.2\n", encoding="utf-8"
)
(root / "data.yaml").write_text(
yaml.safe_dump({"nc": len(CLASSES), "names": dict(enumerate(CLASSES))}), encoding="utf-8"
)
return root
def output_of(out, source_stem):
"""通过 name_map.csv 找源文件对应的输出图片——重命名后只能这么找。"""
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["name"]
return None
def run(source, out, *extra):
argv = ["prepare_yolo_dataset.py", "--source", str(source), "--out", str(out), *extra]
original, sys.argv = sys.argv, argv
try:
return pyd.main()
finally:
sys.argv = original
def test_flat_layout_without_split(source, tmp_path):
"""类别目录被拍平、不划分 train/val:images/ 与 labels/ 下直接是文件,一一对应。"""
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
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):
"""用硬链接而不是复制,不占额外磁盘。"""
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
original = source / "images" / "grease" / "grease_0.png"
linked = output_of(out, "grease_0")
assert linked.stat().st_ino == original.stat().st_ino
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 == {"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):
"""无框的图默认保留——YOLO 里它们是压低误检的负样本。"""
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))
assert summary["empty_labels"] == {"normal": {"policy": "keep", "count": 1}}
assert summary["num_images"] == 13
def test_empty_policy_drop_removes_the_image(source, tmp_path):
out = tmp_path / "data" / "boxes"
assert run(source, out, "--empty-policy", "drop") == 0
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))
assert summary["num_images"] == 12
assert summary["empty_labels"]["normal"]["policy"] == "drop"
assert output_of(out, "normal_0") is None
def test_empty_policy_is_per_class(source, tmp_path):
"""缺陷类的空标签丢掉,normal 的留作负样本——两种处置能共存。"""
out = tmp_path / "data" / "boxes"
assert run(source, out, "--empty-policy", "drop", "normal=keep") == 0
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))
assert summary["empty_labels"]["normal"]["policy"] == "keep"
assert summary["num_images"] == 13
def test_empty_policy_full_writes_a_whole_image_box(source, tmp_path):
"""整图属性的类别退化成整图框:标签要重写,不能链接源文件(内容不同)。"""
out = tmp_path / "data" / "boxes"
assert run(source, out, "--empty-policy", "normal=full") == 0
image = output_of(out, "normal_0")
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 而不是"(无框)"
per_class = json.loads((out / "summary.json").read_text(encoding="utf-8"))["per_class"]
assert per_class["normal"] == 5
@pytest.mark.parametrize(
"tokens,expected",
[
(["drop", "nromal=keep"], "不在 data.yaml"), # 类别拼错
(["normal=kep"], "未知策略"), # 策略拼错
(["dorp"], "看不懂"), # 全局默认拼错
],
)
def test_empty_policy_rejects_typos(source, tmp_path, capsys, tokens, expected):
"""静默忽略拼错的类别名会让策略看起来生效了、其实没有。"""
assert run(source, tmp_path / "out", "--empty-policy", *tokens) != 0
assert expected in capsys.readouterr().out
def test_data_yaml_uses_absolute_path_and_source_names(source, tmp_path):
"""data.yaml 要能被 ultralytics 从任意工作目录读到,类别顺序沿用来源。"""
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
meta = yaml.safe_load((out / "data.yaml").read_text(encoding="utf-8"))
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()
def test_name_collision_after_flattening_is_reported(source, tmp_path, capsys):
"""保留原名时,不同类别目录下的同名文件拍平后会互相覆盖——必须报错而不是静默丢数据。"""
duplicate = source / "images" / "normal" / "grease_0.png"
Image.fromarray(np.zeros((32, 32, 3), dtype=np.uint8)).save(duplicate)
(source / "labels" / "normal" / "grease_0.txt").write_text("1 0.5 0.5 0.2 0.2\n", encoding="utf-8")
assert run(source, tmp_path / "data" / "boxes", "--keep-names") != 0
assert "冲突" in capsys.readouterr().out
def test_refuses_to_clobber_existing_output(source, tmp_path):
"""输出目录非空时要求显式 --overwrite。"""
out = tmp_path / "data" / "boxes"
out.mkdir(parents=True)
(out / "keep.txt").write_text("x", encoding="utf-8")
assert run(source, out) != 0
assert (out / "keep.txt").exists()
assert run(source, out, "--overwrite") == 0
assert not (out / "keep.txt").exists()
def test_missing_label_is_an_error(source, tmp_path):
"""图片多于标签说明来源目录不完整,不能悄悄跳过。"""
(source / "labels" / "grease" / "grease_0.txt").unlink()
assert run(source, tmp_path / "out") != 0
def test_dry_run_writes_nothing(source, tmp_path):
out = tmp_path / "data" / "boxes"
assert run(source, out, "--dry-run") == 0
assert not out.exists()
def _all_outputs(out, kind):
return list((out / kind).iterdir())
def _add_sample(source, group, stem, suffix, class_id, *, fmt=None, mode="RGB"):
"""往来源目录里加一张指定格式的图 + 一个非空标签。"""
size = (32, 24)
array = np.random.default_rng(1).integers(0, 255, (size[1], size[0], 3), dtype=np.uint8)
image = Image.fromarray(array)
if mode != "RGB":
image = image.convert(mode)
path = source / "images" / group / f"{stem}{suffix}"
image.save(path, format=fmt)
(source / "labels" / group / f"{stem}.txt").write_text(
f"{class_id} 0.5 0.5 0.2 0.2\n", encoding="utf-8"
)
return path, image
def test_all_output_images_are_png(source, tmp_path):
"""BMP / JPEG 来源都要落成真正的 PNG——后缀和文件头都得是。"""
_add_sample(source, "grease", "bmp_img", ".bmp", 0, fmt="BMP")
_add_sample(source, "normal", "jpg_img", ".jpg", 1, fmt="JPEG")
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
images = _all_outputs(out, "images")
assert len(images) == 15
assert {p.suffix for p in images} == {".png"}
assert all(pyd.is_png(p) for p in images)
# 标签跟着改名后的图片走,一一对应
assert sorted(p.stem for p in images) == sorted(p.stem for p in _all_outputs(out, "labels"))
def test_bmp_conversion_is_lossless(source, tmp_path):
"""转换不能缩放或有损压缩,否则归一化的框就不再对应原来的像素。"""
_, original = _add_sample(source, "grease", "bmp_img", ".bmp", 0, fmt="BMP")
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
converted = output_of(out, "bmp_img")
with Image.open(converted) as image:
assert image.size == original.size
assert np.array_equal(np.asarray(image), np.asarray(original))
def test_png_sources_are_still_hard_linked(source, tmp_path):
"""只有非 PNG 才转换;PNG 照旧硬链接,不占额外磁盘。"""
bmp, _ = _add_sample(source, "grease", "bmp_img", ".bmp", 0, fmt="BMP")
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))
assert summary["link_modes"] == {"hard": 13, "convert": 1}
converted = output_of(out, "bmp_img")
assert converted.stat().st_ino != bmp.stat().st_ino
assert bmp.read_bytes()[:2] == b"BM" # 源文件没被动过
def test_png_suffix_with_non_png_content_is_converted(source, tmp_path):
"""按文件头判断而不是后缀:写着 .png 其实是 JPEG 的也要转。"""
fake, _ = _add_sample(source, "grease", "fake", ".png", 0, fmt="JPEG")
assert not pyd.is_png(fake)
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
output = output_of(out, "fake")
assert pyd.is_png(output)
@pytest.mark.parametrize("mode", ["L", "RGBA", "CMYK"])
def test_conversion_handles_pixel_modes(source, tmp_path, mode):
"""灰度 / 带透明度原样保留;PNG 存不下的 CMYK 转成 RGB。"""
_add_sample(source, "grease", "moded", ".tif", 0, fmt="TIFF", mode=mode)
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
output = output_of(out, "moded")
with Image.open(output) as image:
assert image.format == "PNG"
assert image.mode == ("RGB" if mode == "CMYK" else mode)
def test_same_stem_different_suffix_collides(source, tmp_path, capsys):
"""grease_0.png 与另一目录的 grease_0.bmp 统一后缀后会撞成同一个文件,必须报错。"""
_add_sample(source, "normal", "grease_0", ".bmp", 1, fmt="BMP")
assert run(source, tmp_path / "data" / "boxes", "--keep-names") != 0
assert "冲突" in capsys.readouterr().out
def test_dry_run_reports_conversion_count(source, tmp_path, capsys):
_add_sample(source, "grease", "bmp_img", ".bmp", 0, fmt="BMP")
assert run(source, tmp_path / "out", "--dry-run") == 0
assert "1 张不是 PNG" in capsys.readouterr().out
def test_classes_txt_matches_data_yaml(source, tmp_path):
"""classes.txt 的行号就是 class_id,必须与 data.yaml 的 names 严格一致。"""
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
lines = (out / "classes.txt").read_text(encoding="utf-8").splitlines()
meta = yaml.safe_load((out / "data.yaml").read_text(encoding="utf-8"))
assert lines == CLASSES
assert lines == [meta["names"][i] for i in range(meta["nc"])]
assert (out / "classes.txt").read_text(encoding="utf-8").endswith("\n")
def test_classes_txt_rejects_names_that_break_line_numbering(tmp_path):
from datasets.yolo import write_classes_txt
with pytest.raises(ValueError, match="换行"):
write_classes_txt(tmp_path / "classes.txt", ["ok", "bad\nname"])
with pytest.raises(ValueError, match="为空"):
write_classes_txt(tmp_path / "classes.txt", ["ok", " "])
def _stems(out):
return sorted(p.stem for p in _all_outputs(out, "images"))
def test_files_renamed_by_class(source, tmp_path):
"""默认按 <类别>_<序号> 重新编号,序号在类内从 1 连续编到该类总数。"""
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
assert _stems(out) == (
[f"grease_{i:04d}" for i in range(1, 7)]
+ [f"normal_{i:04d}" for i in range(1, 6)]
+ [f"whitedot_{i:04d}" for i in range(1, 3)]
)
assert _stems(out) == sorted(p.stem for p in _all_outputs(out, "labels"))
def test_renamed_file_class_matches_its_label(source, tmp_path):
"""文件名里的类别必须与标签第一列一致,否则改名反倒制造了误导。"""
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
for label in _all_outputs(out, "labels"):
text = label.read_text(encoding="utf-8").split()
if text:
assert label.stem.rsplit("_", 1)[0] == CLASSES[int(text[0])]
def test_name_map_traces_back_to_source(source, tmp_path):
"""name_map.csv 覆盖每一张输出,且源路径真实存在、内容一致。"""
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
with (out / "name_map.csv").open(encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
assert len(rows) == 13
for row in rows:
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_reruns(source, tmp_path):
"""重跑同一份来源,同一张源图拿到同一个名字,已做的复核记录才对得上。"""
first, second = tmp_path / "a", tmp_path / "b"
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
def test_keep_names_preserves_source_stems(source, tmp_path):
"""--keep-names 保留原名,只是后缀统一成 .png。"""
_add_sample(source, "grease", "原名_中文", ".bmp", 0, fmt="BMP")
out = tmp_path / "data" / "boxes"
assert run(source, out, "--keep-names") == 0
assert "原名_中文" in _stems(out)
assert output_of(out, "原名_中文").name == "原名_中文.png"
def test_kept_empty_labels_named_after_source_folder(source, tmp_path):
"""空标签没有 class_id,按来源目录名编号;与同类有框的图共用序号,不会撞名。"""
out = tmp_path / "data" / "boxes"
assert run(source, out) == 0
assert output_of(out, "normal_0").stem.startswith("normal_")
assert len([s for s in _stems(out) if s.startswith("normal_")]) == 5
@pytest.fixture
def label_studio_out(source, tmp_path):
pytest.importorskip("label_studio_sdk.converter.imports.yolo")
out = tmp_path / "data" / "boxes"
assert run(source, out, "--empty-policy", "normal=keep", "--label-studio") == 0
return out
def _tasks(out):
return json.loads((out / "tasks.json").read_text(encoding="utf-8"))
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):
"""每张图一个任务;框作为 predictions,图片 URL 相对 DOCUMENT_ROOT。"""
tasks = _tasks(label_studio_out)
assert len(tasks) == 13
for task in tasks:
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 / "images" / name).exists()
def test_label_studio_boxes_round_trip_to_yolo(label_studio_out):
"""Label Studio 的左上角百分比坐标反算回 YOLO 中心点坐标,必须与标签一致。"""
view = label_studio_out
checked = 0
for task in _tasks(label_studio_out):
name = Path(task["data"]["image"].rsplit("/", 1)[1]).stem
lines = (view / "labels" / f"{name}.txt").read_text(encoding="utf-8").split()
results = task["predictions"][0]["result"]
if not lines:
assert results == [] # 保留下来的空标签:任务里没有框
continue
value = results[0]["value"]
class_id, cx, cy, w, h = lines
assert value["rectanglelabels"] == [CLASSES[int(class_id)]]
assert value["x"] / 100 + value["width"] / 200 == pytest.approx(float(cx))
assert value["y"] / 100 + value["height"] / 200 == pytest.approx(float(cy))
assert value["width"] / 100 == pytest.approx(float(w))
checked += 1
assert checked == 12
def test_label_studio_config_and_summary(label_studio_out):
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"]) == 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 / "tasks.json").exists()
assert json.loads((out / "summary.json").read_text(encoding="utf-8"))["label_studio"] is None