refactor(detect): 可视化产出改为按类别分目录,与 generate_boxes 一致
原先按 split 和结果类型(ok/missed/extra/none)分目录,层级太深。改成 <类别>/<图名>.png,与 runs/boxes_shap 的结构一致。类别取真值里框最多的那个; 负样本没有真值框,按文件名前缀归类(<类别>_<序号> 是 prepare_label_studio 的命名), 认不出来放 background/。 split 与结果类型仍记在 index.csv 的列里,排序筛选即可。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9439cbb56a
commit
36fff984b7
@@ -10,19 +10,20 @@
|
||||
只有绿框没有蓝框,就是漏检。匹配规则与 mAP 一致(按置信度贪心、IoU 阈值、类别需一致),
|
||||
所以图上看到的和指标算的是同一回事。
|
||||
|
||||
产出按"结果类型"分目录,直接对着看失败模式:
|
||||
产出按**类别**分目录,与 `generate_boxes.py` 的产出结构一致:
|
||||
|
||||
```
|
||||
runs/<name>/
|
||||
├── train/ val/ # 分开放:训练集的图模型见过,指标不可信
|
||||
│ ├── ok/<图名>.png # 全部命中,没有多余预测
|
||||
│ ├── missed/<图名>.png # 有漏检
|
||||
│ ├── extra/<图名>.png # 有误检(含背景图上凭空框出的)
|
||||
│ └── none/<图名>.png # 既无真值也无预测——负样本判对了
|
||||
├── index.csv # 每张图一行:真值数/预测数/命中数/最高置信度
|
||||
└── summary.json # 逐类 TP/FP/FN 与各结果类型的张数
|
||||
├── <类别>/<图名>.png # 画好框的图。类别取真值里框最多的那个;
|
||||
│ # 没有真值框时按文件名前缀归类(<类别>_<序号> 是
|
||||
│ # prepare_label_studio.py 的命名),认不出来就放 background/
|
||||
├── index.csv # 每张图一行:split、结果类型、真值数/预测数/命中数/最高置信度
|
||||
└── summary.json # 逐类 TP/FP/FN 与各结果类型的张数
|
||||
```
|
||||
|
||||
train / val 不分目录,但 `index.csv` 里有 `split` 列——训练集的图模型见过,看结果时要留意。
|
||||
同样地,"命中 / 漏检 / 误检"记在 `index.csv` 的 `outcome` 列里,按它排序筛选即可。
|
||||
|
||||
`--only errors` 只输出有漏检或误检的图,扫起来快得多。
|
||||
|
||||
**预测框默认约束为互不重叠**(`--max-overlap 0`)。人工标注的真值里一处缺陷只有一个框、
|
||||
@@ -71,6 +72,18 @@ def outcome(n_gt: int, n_pred: int, match) -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
def class_dir_for(truth: list[Box], classes: list[str], stem: str) -> str:
|
||||
"""一张图归到哪个类别目录。
|
||||
|
||||
有真值框时取框最多的那个类别;没有框(负样本)时退回文件名前缀——
|
||||
数据集里的图名是 `<类别>_<序号>`,前缀就是人工归类的类别;认不出来就放 background。
|
||||
"""
|
||||
if truth:
|
||||
return classes[Counter(box.class_id for box in truth).most_common(1)[0][0]]
|
||||
prefix = stem.rsplit("_", 1)[0]
|
||||
return prefix if prefix in classes else "background"
|
||||
|
||||
|
||||
def load_dataset(data_yaml: Path) -> tuple[list[str], dict[str, list[Path]]]:
|
||||
"""从 data.yaml 读类别与各 split 的图片列表。"""
|
||||
meta = yaml.safe_load(data_yaml.read_text(encoding="utf-8"))
|
||||
@@ -235,7 +248,10 @@ def main() -> int:
|
||||
})
|
||||
|
||||
if args.only == "all" or kind in ("missed", "extra"):
|
||||
destination = out_dir / split / kind / f"{image_path.stem}.png"
|
||||
destination = (
|
||||
out_dir / class_dir_for(truth, classes, image_path.stem)
|
||||
/ f"{image_path.stem}.png"
|
||||
)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(image_path) as raw:
|
||||
canvas = raw.convert("RGB")
|
||||
|
||||
@@ -87,3 +87,24 @@ def test_load_dataset_reports_a_missing_directory(dataset):
|
||||
dataset.write_text(yaml.safe_dump(meta), encoding="utf-8")
|
||||
with pytest.raises(FileNotFoundError, match="不存在"):
|
||||
vd.load_dataset(dataset)
|
||||
|
||||
|
||||
# ---------------- 产出的目录结构 ----------------
|
||||
|
||||
|
||||
def test_class_dir_uses_the_dominant_ground_truth_class():
|
||||
classes = ["blackdot", "grease", "normal"]
|
||||
truth = [from_yolo(1, .5, .5, .2, .2), from_yolo(0, .1, .1, .1, .1), from_yolo(1, .8, .8, .1, .1)]
|
||||
assert vd.class_dir_for(truth, classes, "grease_0007") == "grease"
|
||||
|
||||
|
||||
def test_class_dir_falls_back_to_the_filename_prefix():
|
||||
"""负样本没有真值框,按 <类别>_<序号> 的命名归类。"""
|
||||
classes = ["blackdot", "grease", "normal"]
|
||||
assert vd.class_dir_for([], classes, "normal_1234") == "normal"
|
||||
|
||||
|
||||
def test_class_dir_falls_back_to_background_for_unknown_names():
|
||||
classes = ["blackdot", "grease", "normal"]
|
||||
assert vd.class_dir_for([], classes, "IMG_20260101") == "background"
|
||||
assert vd.class_dir_for([], classes, "nofix") == "background"
|
||||
|
||||
Reference in New Issue
Block a user