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:
Shaun Zhou
2026-09-18 15:06:13 +08:00
co-authored by Claude Opus 5
parent 9439cbb56a
commit 36fff984b7
2 changed files with 46 additions and 9 deletions
+25 -9
View File
@@ -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")