Files
ModelTrainEval/scripts/visualize_detections.py
T
Shaun ZhouandClaude Opus 5 43c8c4a179 fix(detect): 可视化推理自己按批切分,避免 CUDA OOM
model.predict 不理会传入的 batch 参数,把整个图片列表当成一个 batch,8969 张图在
16 GiB 显卡上直接 OOM(单次要分配 10.27 GiB)。改成自己按 --batch 切片调用。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:47:44 +08:00

265 lines
11 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 阈值、类别需一致),
所以图上看到的和指标算的是同一回事。
产出按"结果类型"分目录,直接对着看失败模式:
```
runs/<name>/
├── train/ val/ # 分开放:训练集的图模型见过,指标不可信
│ ├── ok/<图名>.png # 全部命中,没有多余预测
│ ├── missed/<图名>.png # 有漏检
│ ├── extra/<图名>.png # 有误检(含背景图上凭空框出的)
│ └── none/<图名>.png # 既无真值也无预测——负样本判对了
├── index.csv # 每张图一行:真值数/预测数/命中数/最高置信度
└── summary.json # 逐类 TP/FP/FN 与各结果类型的张数
```
`--only errors` 只输出有漏检或误检的图,扫起来快得多。
"""
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 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 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(
"--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()),
)
logger.info("conf=%.2f IoU=%.2f imgsz=%d", 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)
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
def predict_in_batches(images):
"""自己按批切分:ultralytics 的 predict 不理会 batch 参数,
一次性把整个列表当成一个 batch,上万张图必然 OOM。"""
for start in range(0, len(images), args.batch):
chunk = images[start:start + args.batch]
results = model.predict(
source=[str(p) for p in chunk], imgsz=args.imgsz, conf=args.conf,
device=device_arg, verbose=False,
)
yield from zip(chunk, results, strict=True)
for split, images in splits.items():
for image_path, result in predict_in_batches(images):
predictions = [
Box(int(cls), *(float(v) for v in xyxy), float(score))
for xyxy, cls, score in zip(
result.boxes.xyxyn.tolist(), result.boxes.cls.tolist(),
result.boxes.conf.tolist(), strict=True,
)
]
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 / split / kind / 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,
"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())