Files
ModelTrainEval/scripts/visualize_detections.py
T
Shaun ZhouandClaude Opus 5 ddd77653b5 fix(detect): 推理尺寸与训练尺寸不一致时报警
用 640 整图训练的权重跑 160 切片推理,结果看起来完全正常——照常出框、照常有置信度,
但类别系统性地错:实测 mosquito 的 TP 归零,760 张里 599 张被判成 blackdot,
而同一权重整图推理时 mosquito 有 92% 判对。这种错误不会自己暴露。

从 checkpoint 里读训练时的 imgsz,与当前切片尺寸不符就警告。三个调用点都接上:
可视化、批量推送预测、ML backend 加载权重时。visualize 的 summary.json 也补上
tile_size / tile_overlap,免得事后分不清某次产出是怎么跑的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:33:41 +08:00

289 lines
12 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.
#!/usr/bin/env python
"""用训练好的检测模型在整个数据集上推理,把预测框与真值框画在一起供人工核对。
uv run python scripts/visualize_detections.py \\
--weights runs/detect/film/weights/best.pt \\
--data data/film_yolo/data.yaml \\
--out runs/detect/film_vis
图上三种颜色:**绿=真值**,**蓝=命中的预测**,**红=没对上真值的预测**(误检)。
只有绿框没有蓝框,就是漏检。匹配规则与 mAP 一致(按置信度贪心、IoU 阈值、类别需一致),
所以图上看到的和指标算的是同一回事。
产出按**类别**分目录,与 `generate_boxes.py` 的产出结构一致:
```
runs/<name>/
├── <类别>/<图名>.png # 画好框的图。类别取真值里框最多的那个;
│ # 没有真值框时按文件名前缀归类(<类别>_<序号> 是
│ # prepare_label_studio.py 的命名),认不出来就放 background/
├── index.csv # 每张图一行:split、结果类型、真值数/预测数/命中数/最高置信度
└── summary.json # 逐类 TP/FP/FN 与各结果类型的张数
```
train / val 不分目录,但 `index.csv` 里有 `split` 列——训练集的图模型见过,看结果时要留意。
同样地,"命中 / 漏检 / 误检"记在 `index.csv` 的 `outcome` 列里,按它排序筛选即可。
`--only errors` 只输出有漏检或误检的图,扫起来快得多。
模型的输入固定为 `--tile-size`(默认 160,与训练一致)时,图会按同样规则切片推理再拼回,
全程不缩放。`--tile-size 0` 退回整图推理(ultralytics 自己 letterbox),用于和旧模型对比。
"""
from __future__ import annotations
import argparse
import csv
import json
import shutil
import sys
from collections import Counter, defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import yaml # noqa: E402
from datasets.yolo import IMAGE_SUFFIXES # noqa: E402
from engine.detection import Box, draw_boxes, match_boxes, parse_label # noqa: E402
from engine.tiled_inference import check_size_matches, load_image, predict_image # noqa: E402
from utils import setup_logging # noqa: E402
from utils.device import DeviceNotAvailableError, describe_device, resolve_device # noqa: E402
logger = None
OUTCOMES = ("ok", "missed", "extra", "none")
def outcome(n_gt: int, n_pred: int, match) -> str:
"""一张图的结果类型。missed 优先于 extra:漏检比误检更该先看。"""
if n_gt == 0 and n_pred == 0:
return "none"
if match.unmatched_ground_truth:
return "missed"
if match.unmatched_predictions:
return "extra"
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"))
names = meta["names"]
classes = [names[i] for i in sorted(names)] if isinstance(names, dict) else list(names)
root = Path(meta.get("path", data_yaml.parent))
if not root.is_absolute():
root = (data_yaml.parent / root).resolve()
splits = {}
for split in ("train", "val"):
entry = meta.get(split)
if not entry:
continue
directory = root / entry
if not directory.is_dir():
raise FileNotFoundError(f"{data_yaml} 里的 {split} 指向 {directory},但它不存在")
images = sorted(p for p in directory.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
# train 与 val 指向同一目录时(未划分的数据集)只跑一次
if any(directory == root / meta.get(other, "") for other in splits):
continue
splits[split] = images
if not splits:
raise FileNotFoundError(f"{data_yaml} 里没有可用的 train / val")
return classes, splits
def label_path_for(image: Path) -> Path:
"""YOLO 约定:把路径里的 images/ 换成 labels/,后缀换成 .txt。"""
parts = list(image.parts)
for index in range(len(parts) - 1, -1, -1):
if parts[index] == "images":
parts[index] = "labels"
break
return Path(*parts).with_suffix(".txt")
def main() -> int:
global logger
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--weights", required=True, help="训练好的 .pt")
parser.add_argument("--data", required=True, help="数据集的 data.yaml")
parser.add_argument("--out", required=True, help="产出目录,建议放在 runs/ 下")
parser.add_argument("--device", default="auto", help="auto / cuda / mps / cpu")
parser.add_argument("--imgsz", type=int, default=640)
parser.add_argument("--conf", type=float, default=0.25, help="置信度阈值")
parser.add_argument("--iou-threshold", type=float, default=0.5, help="判定命中的 IoU")
parser.add_argument("--batch", type=int, default=16)
parser.add_argument(
"--tile-size", type=int, default=160,
help="模型输入边长;按它切片推理再拼回,全程不缩放。0=整图推理(会 letterbox)",
)
parser.add_argument("--tile-overlap", type=int, default=32, help="切片重叠像素")
parser.add_argument(
"--only", choices=("all", "errors"), default="all",
help="errors=只输出有漏检或误检的图,统计仍按全量计",
)
parser.add_argument("--limit", type=int, default=0, help="每个 split 只跑前 N 张(试参数用)")
parser.add_argument("--overwrite", action="store_true", help="产出目录已存在时先清空")
args = parser.parse_args()
out_dir = Path(args.out)
logger = setup_logging()
try:
device = resolve_device(args.device)
classes, splits = load_dataset(Path(args.data))
except (DeviceNotAvailableError, FileNotFoundError) as exc:
logger.error("%s", exc)
return 1
if args.limit:
splits = {name: images[: args.limit] for name, images in splits.items()}
total = sum(len(images) for images in splits.values())
logger.info("设备: %s | 权重: %s", describe_device(device), args.weights)
logger.info(
"数据集: %s(%d 类,%s)", args.data, len(classes),
",".join(f"{name} {len(images)} 张" for name, images in splits.items()),
)
if args.tile_size > 0:
logger.info(
"conf=%.2f 命中 IoU=%.2f 切片 %dx%d 重叠 %d(不缩放)",
args.conf, args.iou_threshold, args.tile_size, args.tile_size, args.tile_overlap,
)
else:
logger.warning(
"conf=%.2f 命中 IoU=%.2f 整图推理 imgsz=%d —— ultralytics 会 letterbox 缩放",
args.conf, args.iou_threshold, args.imgsz,
)
if out_dir.exists() and any(out_dir.iterdir()):
if not args.overwrite:
logger.error("%s 已存在且非空;确认要覆盖请加 --overwrite", out_dir)
return 1
shutil.rmtree(out_dir)
from PIL import Image
from ultralytics import YOLO
model = YOLO(args.weights)
check_size_matches(model, args.tile_size, logger)
device_arg = device.index or 0 if device.type == "cuda" else device.type
outcomes: dict[str, Counter[str]] = {name: Counter() for name in splits}
stats: dict[str, dict[str, Counter[str]]] = {
name: {"tp": Counter(), "fp": Counter(), "fn": Counter()} for name in splits
}
scores: dict[str, list[float]] = defaultdict(list)
rows = []
processed = 0
for split, images in splits.items():
for image_path in images:
predictions = predict_image(
model, load_image(image_path), size=args.tile_size, overlap=args.tile_overlap,
conf=args.conf, device=device_arg, batch=args.batch,
whole_image_imgsz=args.imgsz,
)
label_file = label_path_for(image_path)
truth = parse_label(label_file.read_text(encoding="utf-8")) if label_file.exists() else []
match = match_boxes(predictions, truth, args.iou_threshold)
kind = outcome(len(truth), len(predictions), match)
outcomes[split][kind] += 1
for _, truth_index in match.pairs:
stats[split]["tp"][classes[truth[truth_index].class_id]] += 1
for index in match.unmatched_predictions:
stats[split]["fp"][classes[predictions[index].class_id]] += 1
for index in match.unmatched_ground_truth:
stats[split]["fn"][classes[truth[index].class_id]] += 1
for box in predictions:
scores[classes[box.class_id]].append(box.score)
rows.append({
"split": split, "name": image_path.name, "outcome": kind,
"n_gt": len(truth), "n_pred": len(predictions), "n_matched": len(match.pairs),
"max_conf": round(max((b.score for b in predictions), default=0.0), 4),
"gt_classes": "|".join(sorted({classes[b.class_id] for b in truth})),
"pred_classes": "|".join(sorted({classes[b.class_id] for b in predictions})),
})
if args.only == "all" or kind in ("missed", "extra"):
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")
draw_boxes(canvas, truth, predictions, classes, match)
canvas.save(destination)
processed += 1
if processed % 1000 == 0:
logger.info("处理 [%d/%d]", processed, total)
with (out_dir / "index.csv").open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
summary = {
"weights": str(Path(args.weights).resolve()),
"data": str(Path(args.data).resolve()),
"conf": args.conf,
"iou_threshold": args.iou_threshold,
"imgsz": args.imgsz,
"tile_size": args.tile_size,
"tile_overlap": args.tile_overlap,
"outcomes": {split: dict(counter) for split, counter in outcomes.items()},
"per_class": {
split: {
name: {
"tp": counters["tp"][name], "fp": counters["fp"][name],
"fn": counters["fn"][name],
}
for name in classes
}
for split, counters in stats.items()
},
}
(out_dir / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
)
for split, counter in outcomes.items():
shown = sum(counter[k] for k in ("missed", "extra"))
logger.info(
"%s:全部命中 %d,漏检 %d,误检 %d,负样本判对 %d(有问题的 %d 张,占 %.1f%%)",
split, counter["ok"], counter["missed"], counter["extra"], counter["none"],
shown, 100 * shown / max(sum(counter.values()), 1),
)
logger.info("%-12s %8s %8s %8s %8s", "类别", "TP", "FP", "FN", "召回")
for name in classes:
tp, fp, fn = (stats[split][k][name] for k in ("tp", "fp", "fn"))
if tp or fp or fn:
logger.info(
"%-12s %8d %8d %8d %7.1f%%",
name, tp, fp, fn, 100 * tp / max(tp + fn, 1),
)
logger.info("产出目录: %s(index.csv 可按 outcome / max_conf 排序筛选)", out_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())