feat(detect): 全量推理可视化,预测框与真值框画在一起

scripts/visualize_detections.py:在整个数据集上推理,绿=真值、蓝=命中的预测、
红=没对上真值的预测,按结果类型分目录(ok / missed / extra / none),再配一份
index.csv(逐图的真值数、预测数、命中数、最高置信度)与 summary.json(逐类 TP/FP/FN)。
--only errors 只输出有问题的图。

匹配逻辑放在 engine/detection.py:按置信度贪心、IoU 阈值、类别需一致,与 mAP 的规则
一致,所以图上看到的漏检误检和指标算的是同一回事。same_class=False 可以单看定位准不准
——定位对但分类错,和完全没找到是两种问题。

train 与 val 分开放:训练集的图模型见过,那部分结果不能当泛化能力看。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-17 21:47:02 +08:00
co-authored by Claude Opus 5
parent ad50c38f41
commit 788bc36ab8
4 changed files with 608 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
"""检测结果的匹配与绘制。"""
import pytest
pytest.importorskip("PIL")
from engine.detection import ( # noqa: E402
COLOR_GT,
COLOR_MATCHED,
COLOR_UNMATCHED,
Box,
draw_boxes,
from_yolo,
iou,
match_boxes,
parse_label,
)
def test_from_yolo_converts_center_to_corners():
assert from_yolo(2, 0.5, 0.5, 0.2, 0.4) == Box(2, 0.4, 0.3, 0.6, 0.7)
def test_parse_label_reads_boxes_and_empty_files():
boxes = parse_label("0 0.5 0.5 0.2 0.2\n3 0.1 0.1 0.1 0.1\n")
assert [b.class_id for b in boxes] == [0, 3]
assert parse_label("") == [] # 负样本
assert parse_label("\n\n") == []
@pytest.mark.parametrize(
"a,b,expected",
[
((0, 0, 1, 1), (0, 0, 1, 1), 1.0), # 完全重合
((0, 0, 1, 1), (2, 2, 3, 3), 0.0), # 不相交
((0, 0, 1, 1), (1, 1, 2, 2), 0.0), # 只碰到角
((0, 0, 2, 2), (1, 1, 3, 3), 1 / 7), # 交 1,并 7
],
)
def test_iou(a, b, expected):
assert iou(Box(0, *a), Box(0, *b)) == pytest.approx(expected)
def test_matches_by_confidence_first():
"""两个预测抢同一个真值时,置信度高的赢——与 mAP 的规则一致。"""
truth = [from_yolo(0, 0.5, 0.5, 0.2, 0.2)]
weak = Box(0, 0.4, 0.4, 0.6, 0.6, 0.3)
strong = Box(0, 0.41, 0.41, 0.59, 0.59, 0.9)
match = match_boxes([weak, strong], truth, iou_threshold=0.3)
assert match.pairs == [(1, 0)] # 下标 1 = strong
assert match.unmatched_predictions == [0]
assert match.unmatched_ground_truth == []
def test_low_iou_counts_as_both_miss_and_false_positive():
truth = [from_yolo(0, 0.2, 0.2, 0.1, 0.1)]
prediction = Box(0, 0.7, 0.7, 0.9, 0.9, 0.9)
match = match_boxes([prediction], truth)
assert match.pairs == []
assert match.unmatched_predictions == [0] # 误检
assert match.unmatched_ground_truth == [0] # 同时也是漏检
def test_class_mismatch_is_not_a_hit_unless_allowed():
"""定位准但分类错:默认不算命中,same_class=False 时可单看位置。"""
truth = [from_yolo(1, 0.5, 0.5, 0.2, 0.2)]
prediction = Box(0, 0.4, 0.4, 0.6, 0.6, 0.9)
assert match_boxes([prediction], truth).pairs == []
assert match_boxes([prediction], truth, same_class=False).pairs == [(0, 0)]
def test_each_ground_truth_is_claimed_once():
"""一个真值只能被一个预测认领,多出来的预测算误检。"""
truth = [from_yolo(0, 0.5, 0.5, 0.4, 0.4)]
predictions = [
Box(0, 0.31, 0.31, 0.69, 0.69, 0.9),
Box(0, 0.32, 0.32, 0.68, 0.68, 0.8),
]
match = match_boxes(predictions, truth)
assert len(match.pairs) == 1
assert len(match.unmatched_predictions) == 1
def test_no_prediction_on_background_is_clean():
match = match_boxes([], [])
assert match == ([], [], [])
def test_multiple_boxes_are_matched_pairwise():
truth = [from_yolo(0, 0.2, 0.2, 0.1, 0.1), from_yolo(1, 0.8, 0.8, 0.1, 0.1)]
predictions = [
Box(1, 0.75, 0.75, 0.85, 0.85, 0.7),
Box(0, 0.15, 0.15, 0.25, 0.25, 0.9),
]
match = match_boxes(predictions, truth)
assert sorted(match.pairs) == [(0, 1), (1, 0)]
assert not match.unmatched_predictions and not match.unmatched_ground_truth
def _colors(image):
return {pixel for pixel in image.getdata()}
def test_draw_uses_a_distinct_colour_per_role():
"""三种角色三种颜色:绿=真值,蓝=命中的预测,红=没对上的预测。"""
from PIL import Image
truth = [from_yolo(0, 0.25, 0.25, 0.2, 0.2), from_yolo(0, 0.75, 0.25, 0.2, 0.2)]
predictions = [
Box(0, 0.16, 0.16, 0.34, 0.34, 0.9), # 命中第一个
Box(0, 0.5, 0.8, 0.6, 0.9, 0.4), # 没有对应真值
]
match = match_boxes(predictions, truth, iou_threshold=0.5)
assert match.pairs == [(0, 0)]
image = Image.new("RGB", (200, 200), "black")
draw_boxes(image, truth, predictions, ["blackdot"], match)
present = _colors(image)
assert COLOR_GT in present and COLOR_MATCHED in present and COLOR_UNMATCHED in present
def test_draw_handles_boxes_at_the_border():
"""框贴着上边缘时,标签文字要画到框内,不能画到图外去。"""
from PIL import Image
image = Image.new("RGB", (64, 64), "black")
prediction = Box(0, 0.0, 0.0, 0.5, 0.05, 0.9)
draw_boxes(image, [], [prediction], ["blackdot"], match_boxes([prediction], []))
assert COLOR_UNMATCHED in _colors(image)
+89
View File
@@ -0,0 +1,89 @@
"""可视化脚本里不依赖模型的部分。"""
import sys
from pathlib import Path
import pytest
pytest.importorskip("yaml")
import yaml # noqa: E402
sys.path.insert(0, "scripts")
import visualize_detections as vd # noqa: E402
from engine.detection import Box, from_yolo, match_boxes # noqa: E402
def _match(predictions, truth):
return match_boxes(predictions, truth)
@pytest.mark.parametrize(
"truth,predictions,expected",
[
([], [], "none"), # 负样本判对
([from_yolo(0, .5, .5, .2, .2)], [], "missed"), # 完全没检出
([], [Box(0, .4, .4, .6, .6, .9)], "extra"), # 背景上凭空框出
([from_yolo(0, .5, .5, .2, .2)], [Box(0, .41, .41, .59, .59, .9)], "ok"),
],
)
def test_outcome_categories(truth, predictions, expected):
assert vd.outcome(len(truth), len(predictions), _match(predictions, truth)) == expected
def test_missed_takes_priority_over_extra():
"""一张图同时有漏检和误检时归为 missed——漏检更该先看。"""
truth = [from_yolo(0, .2, .2, .1, .1)]
predictions = [Box(0, .7, .7, .9, .9, .9)]
assert vd.outcome(1, 1, _match(predictions, truth)) == "missed"
def test_label_path_swaps_images_for_labels():
image = Path("/data/film/images/val/blackdot_0001.png")
assert vd.label_path_for(image) == Path("/data/film/labels/val/blackdot_0001.txt")
def test_label_path_uses_the_last_images_segment():
"""路径里多处出现 images 时,只换最靠后的那一段。"""
image = Path("/srv/images/film/images/train/a.png")
assert vd.label_path_for(image) == Path("/srv/images/film/labels/train/a.txt")
@pytest.fixture
def dataset(tmp_path):
for split in ("train", "val"):
(tmp_path / "images" / split).mkdir(parents=True)
(tmp_path / "images" / split / f"{split}_0001.png").write_bytes(b"")
data_yaml = tmp_path / "data.yaml"
data_yaml.write_text(
yaml.safe_dump({
"path": str(tmp_path), "train": "images/train", "val": "images/val",
"nc": 2, "names": {0: "blackdot", 1: "grease"},
}),
encoding="utf-8",
)
return data_yaml
def test_load_dataset(dataset):
classes, splits = vd.load_dataset(dataset)
assert classes == ["blackdot", "grease"]
assert sorted(splits) == ["train", "val"]
assert splits["val"][0].name == "val_0001.png"
def test_load_dataset_skips_a_split_pointing_at_the_same_directory(dataset):
"""未划分的数据集 train 与 val 指向同一目录,不该把同一批图跑两遍。"""
meta = yaml.safe_load(dataset.read_text(encoding="utf-8"))
meta["val"] = meta["train"]
dataset.write_text(yaml.safe_dump(meta), encoding="utf-8")
_, splits = vd.load_dataset(dataset)
assert list(splits) == ["train"]
def test_load_dataset_reports_a_missing_directory(dataset):
meta = yaml.safe_load(dataset.read_text(encoding="utf-8"))
meta["val"] = "images/nope"
dataset.write_text(yaml.safe_dump(meta), encoding="utf-8")
with pytest.raises(FileNotFoundError, match="不存在"):
vd.load_dataset(dataset)