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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a43267b55
commit
af0897f8c7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user