feat(detect): 按来源类别处置无框图片(--empty-policy)

空标签在 YOLO 里的语义是"这张图什么都没有",是一个明确的负样本断言,而不是
"有缺陷但没能定位出来"。原先一律保留,于是 runs/boxes_gradcam 的 339 张空标签
里有 270 张是人工标成缺陷、Grad-CAM 却失手的图(blackdot 占该类 11%、black 占
24%),它们正以"此图无缺陷"的身份进入训练,等于在教模型抑制真实缺陷。

--empty-policy 接一个全局默认加若干 `类别=策略`:
  keep 当负样本保留(默认)/ drop 丢弃 / full 退化成整图框

整图框需要重写标签而不是链接源文件(内容不同),且有了确定类别之后要跟着本类
一起参与分层划分。类别名或策略拼错直接报错——静默忽略会让策略看着生效其实没有。
没显式指定、又落到 keep 的类别会打一条告警。

替换掉原来的 --drop-empty:它是一刀切,会把 normal 那 69 张有价值的负样本一起扔掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-11 16:34:16 +08:00
co-authored by Claude Opus 5
parent d6c6aca325
commit 012b3efc73
3 changed files with 247 additions and 34 deletions
+52 -5
View File
@@ -91,13 +91,60 @@ def test_empty_labels_kept_as_negatives_by_default(source, tmp_path):
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"] == 1
assert summary["empty_labels"] == {"normal": {"policy": "keep", "count": 1}}
assert summary["num_images"] == 13
out_dropped = tmp_path / "data" / "dropped"
assert run(source, out_dropped, "--drop-empty") == 0
dropped = json.loads((out_dropped / "summary.json").read_text(encoding="utf-8"))
assert dropped["num_images"] == 12
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 not (out / "labels" / "train" / "normal_0.txt").exists()
assert not (out / "images" / "train" / "normal_0.png").exists()
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
label = next(
p for split in ("train", "val")
for p in (out / "labels" / split).iterdir()
if p.stem == "normal_0"
)
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["train"]["normal"] + per_class["val"]["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):