# LAB 09 · 检测几何:框、匹配、NMS 与 AP
并入相关学习路径
固定框匹配、PR/AP、NMS误删相邻对象反例
对应课堂(8 页)
可先阅读已有结果,再决定是否运行。重算时请核对各节记录的数据、参数、环境与日期。
**学习路径:** [一张图怎样变成一组框](/course/study/image-detection.html)
本册是 [LAB 10](https://deepnote.com/project/38755e4e-23f6-4f5f-8c68-8a2e4114b1c1/notebook/a14237bfa9dd413d959ab4e458e11f42) 的检测几何工作台:固定框和分数,分别计算训练分配、评价匹配、NMS 和 AP。它不训练图像模型,对应 [P33–35](/course/pages/l3-r2-p33.html)。
初读只选一件事:第 1 节核坐标与 IoU;第 3–4 节追踪重复框和 AP;第 5–6 节比较 NMS 去重与误删相邻对象。深入定义在 [CLS 07](https://deepnote.com/project/38755e4e-23f6-4f5f-8c68-8a2e4114b1c1/notebook/586e66e049764158a50747e69c219312),可直接引用 [AP 排序积分](/course/blocks/ap-ranking-example.html) 和 [NMS 与匹配的区别](/course/blocks/nms-versus-matching.html)。
做完保留一项“只改分数”或“只改 NMS 阈值”的对照,解释 precision/recall/AP 哪个量变了以及原因。若复跑,函数有先后依赖,需从环境格按序执行;绘图和运行记录可以折叠阅读。
[此段独立阅读](/course/notebook-blocks/bd1bc858091e46a9b05ab50b784fbf79.html)展开完整实现代码
```
import os, tempfile
from pathlib import Path
# Isolated ephemeral workspace supports detached runs with readonly project storage.
SPATIAL_RUN_DIR = Path(tempfile.mkdtemp(prefix='lecture03-'))
os.chdir(SPATIAL_RUN_DIR)
print('Ephemeral experiment directory:', SPATIAL_RUN_DIR)
```
[此段独立阅读](/course/notebook-blocks/464a6a439b674b42bdf6e18175401eee.html)展开完整实现代码
```
from pathlib import Path
from datetime import datetime, timezone
import hashlib
import json
import platform
import time
from html import escape
import numpy as np
STARTED_AT = datetime.now(timezone.utc).isoformat()
STARTED_CLOCK = time.perf_counter()
OUT = Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()
OUT.mkdir(parents=True, exist_ok=True)
NMS_THRESHOLD = 0.50
EVAL_THRESHOLDS = (0.50, 0.75)
GT = np.array([[0, 0, 2, 2], [3, 0, 5, 2]], dtype=float)
PRED = np.array([[0, 0, 2, 2], [.2, 0, 2.2, 2], [3.4, 0, 5.4, 2], [7, 7, 8, 8]], float)
SCORES = np.array([.9, .8, .7, .4])
CLASSES = np.zeros(4, dtype=int)
```
[此段独立阅读](/course/notebook-blocks/594bc8e645d4447c9c7e522cc8f9a03f.html)
## 1 · 坐标先说清:连续 xyxy,不加一
框写成 `(x1, y1, x2, y2)`,表示连续坐标边界。宽为 `max(0, x2-x1)`,高为 `max(0, y2-y1)`;面积计算不使用像素闭区间的 `+1`。本实验允许零面积框,其零并集 IoU 约定为 0;拒绝倒置坐标和非有限数。
两框交集的左上角是两组左上角的逐坐标最大值,右下角是两组右下角的逐坐标最小值;先把交集宽高截断到非负,再相乘。
$
\operatorname{IoU}(A,B)=\frac{|A\cap B|}{|A|+|B|-|A\cap B|}.
$
GT1=`(0,0,2,2)`,GT2=`(3,0,5,2)`,面积均为 4。所有预测属于同一类别;分数用于排名,不是本例的真实正确概率。
| 预测 | 分数 | xyxy | 与 GT1 的 IoU | 与 GT2 的 IoU |
| --- | --- | --- | --- | --- |
| P1 | 0.90 | `(0,0,2,2)` | 1 | 0 |
| P2 | 0.80 | `(0.2,0,2.2,2)` | 9/11 ≈ 0.818182 | 0 |
| P3 | 0.70 | `(3.4,0,5.4,2)` | 0 | 2/3 ≈ 0.666667 |
| P4 | 0.40 | `(7,7,8,8)` | 0 | 0 |
输入 shape 为 GT `(2,4)`、预测 `(4,4)`、分数 `(4,)`。广播计算得到 `(4,2)` IoU 矩阵:**行是预测,列是真值**。手算 P2:交集 `1.8×2=3.6`,并集 `4+4−3.6=4.4`,所以 IoU=`3.6/4.4=9/11`。P3 的交集为 `1.6×2=3.2`,并集为 `4.8`。
下方代码先核对 IoU 矩阵;最后的可视化代码格会显示坐标图。
[此段独立阅读](/course/notebook-blocks/f4deca9d4809465288f3a8e07e5320ab.html)展开完整实现代码
```
def boxes_xyxy(values):
"""Validate an (N,4) array of continuous-coordinate boxes."""
boxes = np.asarray(values, dtype=float).reshape(-1, 4)
if not np.isfinite(boxes).all():
raise ValueError("Box coordinates must be finite")
if np.any(boxes[:, 2:] < boxes[:, :2]):
raise ValueError("Require x2 >= x1 and y2 >= y1")
return boxes
def box_iou(boxes_a, boxes_b):
"""Return (N,M) IoUs. A zero-area union is defined to have IoU 0."""
a, b = boxes_xyxy(boxes_a), boxes_xyxy(boxes_b)
intersection_size = np.maximum(
0.0, np.minimum(a[:, None, 2:], b[None, :, 2:])
- np.maximum(a[:, None, :2], b[None, :, :2])
)
intersection = intersection_size.prod(axis=2)
area_a = (a[:, 2:] - a[:, :2]).prod(axis=1)
area_b = (b[:, 2:] - b[:, :2]).prod(axis=1)
union = area_a[:, None] + area_b[None, :] - intersection
return np.divide(intersection, union, out=np.zeros_like(union), where=union > 0)
print("IoU shape:", box_iou(PRED, GT).shape)
print(box_iou(PRED, GT))
```
[此段独立阅读](/course/notebook-blocks/6694e8372194400db0397906978305bf.html)
## 2 · 训练分配和评价匹配解决两个问题
本例的训练分配采用 D2L anchor 教学规则:先把与某个 GT 的最大 IoU **≥0.50** 的 anchor 分配给该 GT;再逐次选择剩余 IoU 矩阵的最大项,移除对应行列,强制为每个 GT 保留一个不同的 anchor。此例假定 anchor 数不少于 GT 数,未引入真实检测器可能使用的忽略区间、采样比例或动态分配。
| 项目 | 训练 anchor 目标分配 | 本例的评价匹配 |
| --- | --- | --- |
| 要回答的问题 | 哪些候选框应学习哪个对象? | 已输出的哪些预测算正确检测? |
| 主要输入 | anchor 与 GT 的几何关系 | 预测分数、预测框与 GT |
| 一个 GT 可对应几个框 | 可以多个正样本 | 在同一图像、同一类别、同一评价阈值下最多一个 TP |
| 是否按预测分数排序 | 本例不使用预测分数 | 按分数降序,平分保留输入顺序 |
| 低 IoU 是否可能被选中 | 强制覆盖步骤可能选中 | 必须满足指定评价 IoU 阈值 |
为方便对照,代码临时把四个预测的坐标用作四个 anchor 的几何输入,**并未把预测分数用于训练分配**。得到 `[GT1, GT1, GT2, 背景]`;P1/P2 两处几何位置可以共同学习 GT1。另一个低重叠例子中,两个强制正样本与相应 GT 的 IoU 分别只有 **0.1225 和 0.09**,清楚说明“训练中被分配”不等于“评价中定位合格”。
这是一种 anchor 分配规则。其他检测器可能使用一对一集合匹配、不同阈值或动态分配;不能把本例规则推广为所有检测器。
[此段独立阅读](/course/notebook-blocks/311a83a867ee4c7dadefdaca58f5c533.html)展开完整实现代码
```
def assign_training_anchors(anchors, gt_boxes, positive_iou=0.50):
"""D2L-style target assignment: threshold positives + forced GT coverage.
One anchor has at most one GT, but several anchors may target the same GT.
Returns GT index per anchor (-1 means background in this simplified lab).
Scores and predicted classes are intentionally absent from the inputs.
"""
if not 0 <= positive_iou <= 1:
raise ValueError("positive_iou must be in [0,1]")
ious = box_iou(anchors, gt_boxes)
n_anchor, n_gt = ious.shape
assigned = np.full(n_anchor, -1, dtype=int)
forced = np.zeros(n_anchor, dtype=bool)
if n_gt == 0:
return assigned, forced
if n_anchor < n_gt:
raise ValueError("Forced GT coverage requires anchors >= GTs")
best_gt = ious.argmax(axis=1)
best_iou = ious[np.arange(n_anchor), best_gt]
positive = best_iou >= positive_iou
assigned[positive] = best_gt[positive]
remaining = ious.copy()
for _ in range(n_gt):
anchor_i, gt_i = np.unravel_index(np.argmax(remaining), remaining.shape)
assigned[anchor_i] = gt_i
forced[anchor_i] = True
remaining[anchor_i, :] = -1.0
remaining[:, gt_i] = -1.0
return assigned, forced
print("0-based GT targets (-1=background):", assign_training_anchors(PRED, GT)[0])
```
[此段独立阅读](/course/notebook-blocks/ef7e83a286ba4b14912267d87243dc13.html)
## 3 · 评价先降序,再限制同一 GT 只用一次
本例与 LAB10 统一使用 **VOC 风格的贪心对应规则**:对每个预测,先在该图像、该类别的全部 GT 中找 IoU 最大者,再检查它是否满足阈值且未被使用。若最大 IoU 的 GT 已被使用,就记为重复 FP,**不转而匹配次高 IoU 的未用 GT**。评价 IoU 阈值是 **≥**,分别使用 0.50 与 0.75;平分时保留输入顺序。两个阈值独立评价,各自从空的已匹配集合开始。这里只借用该匹配思路,不声称复现完整 VOC 评估器。
| 排名/预测 | AP50 结果与理由 | 累计 precision | 累计 recall | AP75 结果 |
| --- | --- | --- | --- | --- |
| 1 / P1 | TP:首次匹配 GT1 | 1 | 1/2 | TP |
| 2 / P2 | FP:符合几何阈值的 GT1 已被使用 | 1/2 | 1/2 | FP:重复 |
| 3 / P3 | TP:首次匹配 GT2 | 2/3 | 1 | FP:2/3 < 0.75 |
| 4 / P4 | FP:没有满足阈值的 GT | 1/2 | 1 | FP |
P2 不是因为框“不够像”而成为 FP;P3 在 AP75 中也不是因为分数低而失败。两类错误分别来自重复输出和定位精度。代码逐预测保存原因、匹配对象、累计 TP/FP、precision 与 recall,不能只凭最后一个 recall 代替 AP。
**协议不能混用。**另一种规则是“先排除已用 GT,再选剩余 IoU 最大者”,它在重叠对象场景可能给出不同结果。取 GT1=`(0,0,2,2)`、GT2=`(0.5,0,2.5,2)`,预测 P1、P2 **都完全复制 GT1**,两预测对 GT 的 IoU 行均为 `[1.0, 0.6]`。在 IoU 阈值 0.50 下:
| 匹配规则 | P1 | P2 | AP50 | 最终 recall |
| --- | --- | --- | --- | --- |
| 本册:先选最大 IoU GT,再检查是否已用 | TP→GT1 | 重复 FP,仍以 GT1 为最佳 | 0.5 | 0.5 |
| 对照:先排除已用 GT,再找最佳 | TP→GT1 | TP→GT2,IoU=0.6 | 1.0 | 1.0 |
后者把两个完全相同的预测分配给不同对象。这组可执行反例说明:同为“一对一”,贪心选择顺序仍然属于评价协议,必须与报告的指标一起说明。后面的相邻对象 NMS 反例则为每个 GT 各有一个准确预测,属于另一种情形。
[此段独立阅读](/course/notebook-blocks/8eced6ce67e84d02929cf23760a5eb09.html)
## 4 · AP 必须说明积分协议
本例使用 **all-points interpolated AP / precision envelope**:先累加 TP 和 FP,得到每个排名位置的 precision、recall;从右向左取 precision 的累计最大值,形成单调不增的上包络;只在 recall 实际增长的区间计算面积。
$
p_{\rm interp}(r)=\max_{\tilde r\ge r}p(\tilde r),\qquad
\mathrm{AP}=\sum_k (r_k-r_{k-1})p_{\rm interp}(r_k).
$
实现在前后补上边界点,最后补 `(recall=1, precision=0)`,保证没有达到的召回区间贡献零。**不对原始四个 precision 求算术平均,也不使用梯形积分。**
AP50 的 precision 为 `[1,1/2,2/3,1/2]`,recall 为 `[1/2,1/2,1,1]`。重复 recall 不增加面积;后半区间使用精度上包络 `2/3`,得到
$
\mathrm{AP50}=\tfrac12\times1+\tfrac12\times\tfrac23=\tfrac56.
$
AP75 只召回 GT1,最大 recall 为 1/2。未召回的后半区间精度为零,故 AP75=`1/2×1+1/2×0=1/2`。
最后的可视化代码格会显示两个评价阈值下的 PR 上包络。
这里的 AP50/AP75 表示同一 all-points 协议在不同 IoU 阈值的结果。它**不等于 COCO 的 101 个 recall 采样点 AP 实现**,也不是 `0.50:0.05:0.95` 多阈值 mAP。对真实数据集,应按指定协议汇总同类预测,并在各图像内部匹配,再构建整体 PR 曲线;不能直接把单张图像 AP 的平均值当作数据集 AP。本教学实现没有 crowd、ignore、面积分组或 maxDets。
[此段独立阅读](/course/notebook-blocks/03a49740c8684af3bd417c1030853845.html)展开完整实现代码
```
def all_points_ap(recall, precision):
"""All-points interpolated AP = sum of recall increments * envelope.
This is not 11-point AP, the mean of raw precisions, or COCO 101-point AP.
A sentinel at recall 1 with precision 0 gives unreached recall zero credit.
"""
recall, precision = np.asarray(recall, float), np.asarray(precision, float)
if recall.shape != precision.shape or np.any(np.diff(recall) < -1e-12):
raise ValueError("Aligned arrays and nondecreasing recall required")
if np.any((recall < 0) | (recall > 1) | (precision < 0) | (precision > 1)):
raise ValueError("Recall and precision must be in [0,1]")
mrec = np.r_[0.0, recall, 1.0]
mpre = np.r_[0.0, precision, 0.0]
envelope = np.maximum.accumulate(mpre[::-1])[::-1]
changed = np.where(mrec[1:] > mrec[:-1])[0]
ap = np.sum((mrec[changed + 1] - mrec[changed]) * envelope[changed + 1])
return float(ap), mrec, envelope
def evaluate_single_class(pred_boxes, scores, gt_boxes, iou_threshold=0.50):
"""One image / one class: choose the best-IoU GT, then check if already used.
VOC-style greedy matching, aligned with LAB10: a used best GT makes the
prediction a duplicate FP; do not fall back to a lower-IoU unused GT.
Score ties and IoU ties preserve input order. Threshold comparison is >=.
No crowd, ignore regions, maxDets, area bins, or dataset pooling.
"""
if not 0 <= iou_threshold <= 1:
raise ValueError("iou_threshold must be in [0,1]")
pred_boxes, gt_boxes = boxes_xyxy(pred_boxes), boxes_xyxy(gt_boxes)
scores = np.asarray(scores, dtype=float)
if scores.shape != (len(pred_boxes),) or not np.isfinite(scores).all():
raise ValueError("Require one finite score per prediction")
if len(gt_boxes) == 0:
raise ValueError("AP is undefined for this no-GT single-class example")
order = np.argsort(-scores, kind="stable")
used = np.zeros(len(gt_boxes), dtype=bool)
overlaps = box_iou(pred_boxes, gt_boxes)
tp, rows = [], []
for rank, pred_i in enumerate(order, 1):
gt_i = int(np.argmax(overlaps[pred_i]))
best_iou = float(overlaps[pred_i, gt_i])
meets_threshold = best_iou >= iou_threshold
is_tp = bool(meets_threshold and not used[gt_i])
if is_tp:
used[gt_i] = True
reason = "new GT matched"
elif meets_threshold:
reason = "duplicate: maximum-IoU GT already used; no fallback"
else:
reason = "IoU below evaluation threshold"
tp.append(int(is_tp))
rows.append({"rank": rank, "prediction": int(pred_i + 1),
"score": float(scores[pred_i]), "status": "TP" if is_tp else "FP",
"gt": gt_i + 1 if is_tp else None,
"best_gt": gt_i + 1, "best_iou_any_gt": best_iou, "reason": reason})
tp = np.asarray(tp, dtype=int)
cumulative_tp, cumulative_fp = np.cumsum(tp), np.cumsum(1 - tp)
recall = cumulative_tp / len(gt_boxes)
precision = np.divide(cumulative_tp, np.arange(1, len(tp) + 1), dtype=float)
ap, mrec, envelope = all_points_ap(recall, precision)
for i, row in enumerate(rows):
row.update(cumulative_tp=int(cumulative_tp[i]), cumulative_fp=int(cumulative_fp[i]),
recall=float(recall[i]), precision=float(precision[i]))
return {"iou_threshold": iou_threshold, "rows": rows, "ap": ap,
"recall": recall.tolist(), "precision": precision.tolist(),
"envelope_recall": mrec.tolist(), "precision_envelope": envelope.tolist(),
"final_recall": float(recall[-1]) if len(recall) else 0.0}
for threshold in EVAL_THRESHOLDS:
evaluation = evaluate_single_class(PRED, SCORES, GT, threshold)
print("IoU threshold:", threshold, "all-points AP:", evaluation["ap"])
for row in evaluation["rows"]:
print(row)
```
[此段独立阅读](/course/notebook-blocks/86f64f86923e48fcaaea1efc4926457c.html)
## 5 · NMS 的阈值不是评价的阈值
代码实现 **class-aware NMS**:按分数降序保留当前框;仅当另一个框与它**类别相同且 IoU > 0.50** 时才抑制。相等时保留;不同类别即使坐标完全一致也保留。本例没有额外分数过滤。NMS 输入不包含 GT,因此不是训练匹配,也不能知道两个重叠框是否对应同一个真实对象。
原例 P1/P2 同类,互相 IoU=`9/11 > 0.50`,所以 P2 被删,保留原编号 `[P1,P3,P4]`。
| 条件 | AP50 的 TP/FP 顺序 | AP50 | AP75 | AP50 最终 recall |
| --- | --- | --- | --- | --- |
| NMS 前 | TP, FP, TP, FP | 5/6 | 1/2 | 1 |
| NMS 后 | TP, TP, FP | 1 | 1/2 | 1 |
NMS 清理了 P2 重复错误,因此 AP50 改善;它没有调整 P3 坐标,AP75 没有改善。NMS 后仍留有 P4 这个 FP,但它排在全部真值被召回之后,故此处 all-points AP50 仍为 1。**AP=1 不表示预测列表中绝无 FP。**
[此段独立阅读](/course/notebook-blocks/cf51f5d05100404d88c27df74895324d.html)展开完整实现代码
```
def class_aware_nms(boxes, scores, classes, iou_threshold=0.50):
"""Keep a box unless a higher-scored kept box of SAME class has IoU > threshold.
Equality is retained. There is no extra confidence cutoff in this lab.
"""
boxes = boxes_xyxy(boxes)
scores, classes = np.asarray(scores, float), np.asarray(classes)
if scores.shape != (len(boxes),) or classes.shape != (len(boxes),):
raise ValueError("Scores/classes must have shape (N,)")
if not np.isfinite(scores).all() or not 0 <= iou_threshold <= 1:
raise ValueError("Finite scores and an IoU threshold in [0,1] required")
remaining = np.argsort(-scores, kind="stable")
kept, suppressed = [], []
while len(remaining):
current, remaining = int(remaining[0]), remaining[1:]
kept.append(current)
overlap = box_iou(boxes[current:current + 1], boxes[remaining])[0]
remove = (classes[remaining] == classes[current]) & (overlap > iou_threshold)
for victim, iou_value in zip(remaining[remove], overlap[remove]):
suppressed.append({"prediction": int(victim + 1), "by": current + 1,
"iou": float(iou_value), "class": int(classes[current])})
remaining = remaining[~remove]
return np.asarray(kept, dtype=int), suppressed
print("NMS keeps 1-based predictions:", class_aware_nms(PRED, SCORES, CLASSES, NMS_THRESHOLD)[0] + 1)
```
[此段独立阅读](/course/notebook-blocks/afc86821efbb4021940cb56bf27497ec.html)
## 6 · 反例:相邻真实对象被 NMS 合并
把场景换成两个同类 GT:`(0,0,2,2)` 与 `(0.5,0,2.5,2)`。二者交集为 3,并集为 5,互相 IoU=`0.6`。对应预测与 GT 完全一致,分数分别为 0.95、0.90。
NMS 前,两个预测分别匹配不同 GT,AP50=1、recall=1。使用相同 NMS 阈值 0.50,第二个预测会被抑制,于是 AP50=0.5、recall=0.5。仅将 NMS 阈值改为 0.70,两框都保留。本例还断言不同类别的完全重合框能同时保留,检查了 class-aware 分支。
接下格将同时验证原例与相邻真对象反例,再生成对照图。
由此可以支持:“固定 NMS 规则会在重复框清理与相邻对象保留之间产生取舍。”不能支持:“0.70 是真实数据的最佳阈值”,因为这里只有两个手工场景。
[此段独立阅读](/course/notebook-blocks/0a71ec88a039404fbc98fde29df4fe9d.html)展开完整实现代码
```
GT = np.array([[0, 0, 2, 2], [3, 0, 5, 2]], dtype=float)
PRED = np.array([[0, 0, 2, 2], [.2, 0, 2.2, 2], [3.4, 0, 5.4, 2], [7, 7, 8, 8]], float)
SCORES = np.array([.9, .8, .7, .4])
CLASSES = np.zeros(4, dtype=int)
iou_matrix = box_iou(PRED, GT)
training_assignment, forced_anchors = assign_training_anchors(PRED, GT, positive_iou=.5)
baseline = {f"AP{round(t * 100)}": evaluate_single_class(PRED, SCORES, GT, t)
for t in EVAL_THRESHOLDS}
kept, suppressed = class_aware_nms(PRED, SCORES, CLASSES, NMS_THRESHOLD)
after_nms = {f"AP{round(t * 100)}": evaluate_single_class(PRED[kept], SCORES[kept], GT, t)
for t in EVAL_THRESHOLDS}
for result in after_nms.values():
for row in result["rows"]:
row["prediction"] = int(kept[row["prediction"] - 1] + 1)
# Two real, same-class objects overlap by IoU=0.6. Both predictions are exact.
NEIGHBOR_GT = np.array([[0, 0, 2, 2], [.5, 0, 2.5, 2]], float)
NEIGHBOR_SCORES = np.array([.95, .90])
neighbor_kept, neighbor_suppressed = class_aware_nms(
NEIGHBOR_GT, NEIGHBOR_SCORES, [0, 0], NMS_THRESHOLD)
neighbor_before = evaluate_single_class(NEIGHBOR_GT, NEIGHBOR_SCORES, NEIGHBOR_GT, .5)
neighbor_after = evaluate_single_class(NEIGHBOR_GT[neighbor_kept],
NEIGHBOR_SCORES[neighbor_kept], NEIGHBOR_GT, .5)
neighbor_kept_070, _ = class_aware_nms(NEIGHBOR_GT, NEIGHBOR_SCORES, [0, 0], .70)
# A forced training positive can fall below the positive IoU threshold.
LOW_OVERLAP_ANCHORS = np.array([[0, 0, .7, .7], [.8, 0, .9, .1], [3, 0, 3.6, .6]])
low_assignment, low_forced = assign_training_anchors(LOW_OVERLAP_ANCHORS, GT, .5)
# Protocol counterexample: two exact copies of GT1, while GT2 overlaps it by 0.6.
# The production evaluator above must NOT reassign a duplicate to second-best GT2.
DUPLICATE_NEIGHBOR_PRED = np.repeat(NEIGHBOR_GT[:1], 2, axis=0)
protocol_standard = evaluate_single_class(DUPLICATE_NEIGHBOR_PRED, NEIGHBOR_SCORES,
NEIGHBOR_GT, .5)
# The following trace deliberately implements the OTHER protocol for comparison.
alternative_used = np.zeros(len(NEIGHBOR_GT), dtype=bool)
alternative_tp = []
alternative_ious = box_iou(DUPLICATE_NEIGHBOR_PRED, NEIGHBOR_GT)
for pred_i in np.argsort(-NEIGHBOR_SCORES, kind="stable"):
eligible = np.flatnonzero((~alternative_used) & (alternative_ious[pred_i] >= .5))
if len(eligible):
gt_i = int(eligible[np.argmax(alternative_ious[pred_i, eligible])])
alternative_used[gt_i] = True
alternative_tp.append(int(len(eligible) > 0))
alternative_recall = np.cumsum(alternative_tp) / len(NEIGHBOR_GT)
alternative_precision = np.cumsum(alternative_tp) / np.arange(1, len(alternative_tp) + 1)
alternative_ap = all_points_ap(alternative_recall, alternative_precision)[0]
# Meaningful invariants and protocol boundaries, not a network benchmark.
assert iou_matrix.shape == (4, 2)
np.testing.assert_allclose(iou_matrix, [[1, 0], [9 / 11, 0], [0, 2 / 3], [0, 0]])
assert training_assignment.tolist() == [0, 0, 1, -1]
assert [r["status"] for r in baseline["AP50"]["rows"]] == ["TP", "FP", "TP", "FP"]
assert [r["status"] for r in baseline["AP75"]["rows"]] == ["TP", "FP", "FP", "FP"]
np.testing.assert_allclose([baseline["AP50"]["ap"], baseline["AP75"]["ap"]], [5 / 6, .5])
assert kept.tolist() == [0, 2, 3]
np.testing.assert_allclose([after_nms["AP50"]["ap"], after_nms["AP75"]["ap"]], [1, .5])
np.testing.assert_allclose([neighbor_before["ap"], neighbor_after["ap"]], [1, .5])
assert neighbor_kept_070.tolist() == [0, 1]
assert low_assignment.tolist() == [0, -1, 1] and low_forced.tolist() == [True, False, True]
assert class_aware_nms([[0, 0, 2, 2]] * 2, [.9, .8], [0, 1], .5)[0].tolist() == [0, 1]
assert class_aware_nms([[0, 0, 2, 2]] * 2, [.9, .8], [0, 0], 1.0)[0].tolist() == [0, 1]
assert box_iou([[0, 0, 0, 0]], [[0, 0, 0, 0]]).item() == 0
assert evaluate_single_class([], [], GT, .5)["ap"] == 0
assert [r["status"] for r in protocol_standard["rows"]] == ["TP", "FP"]
assert alternative_tp == [1, 1]
np.testing.assert_allclose([protocol_standard["ap"], alternative_ap], [.5, 1.0])
results = {
"data_identity": "deterministic teaching example; no model trained or inferred",
"coordinates": "continuous xyxy, no +1",
"ap_protocol": "all-points precision envelope; single image / one class; not COCO mAP",
"matching_protocol": "VOC-style: maximum-IoU same-image/class GT, then used check; no fallback; IoU >= threshold",
"nms_protocol": {"class_aware": True, "suppress_if": "IoU > threshold", "threshold": NMS_THRESHOLD},
"input_shapes": {"gt": list(GT.shape), "pred": list(PRED.shape), "scores": list(SCORES.shape)},
"gt": GT.tolist(), "pred": PRED.tolist(), "scores": SCORES.tolist(),
"iou_matrix_pred_by_gt": iou_matrix.tolist(),
"training_assignment_gt_1based": [int(x + 1) if x >= 0 else None for x in training_assignment],
"training_forced": forced_anchors.tolist(),
"baseline": baseline, "nms_kept_predictions": (kept + 1).tolist(),
"nms_suppressed": suppressed, "after_nms": after_nms,
"neighbor_case": {"gt": NEIGHBOR_GT.tolist(), "mutual_iou": box_iou(NEIGHBOR_GT[:1], NEIGHBOR_GT[1:]).item(),
"before_nms": neighbor_before, "after_nms": neighbor_after,
"suppressed": neighbor_suppressed, "kept_at_nms_070": (neighbor_kept_070 + 1).tolist()},
"low_iou_forced_training_case": {"anchors": LOW_OVERLAP_ANCHORS.tolist(),
"ious": box_iou(LOW_OVERLAP_ANCHORS, GT).tolist(),
"assignment_gt_1based": [int(x + 1) if x >= 0 else None for x in low_assignment],
"forced": low_forced.tolist()},
"matching_protocol_counterexample": {"gt": NEIGHBOR_GT.tolist(),
"pred": DUPLICATE_NEIGHBOR_PRED.tolist(),
"iou_matrix": alternative_ious.tolist(),
"best_gt_then_used": protocol_standard,
"best_unused_alternative": {"tp": alternative_tp,
"recall": alternative_recall.tolist(),
"precision": alternative_precision.tolist(),
"ap": alternative_ap}},
"checks": "all assertions passed",
}
(OUT / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
print("IoU matrix (prediction x GT):\n", np.round(iou_matrix, 6))
print("Training anchor targets (GT1, GT2; None=background):", results["training_assignment_gt_1based"])
for label, experiment in [("Before NMS", baseline), ("After NMS", after_nms)]:
for metric, result in experiment.items():
print(label, metric, "=", round(result["ap"], 9),
"sequence:", [r["status"] for r in result["rows"]], "recall:", result["final_recall"])
print("NMS kept original predictions:", (kept + 1).tolist())
print("Neighbor objects AP50:", neighbor_before["ap"], "->", neighbor_after["ap"],
"; recall:", neighbor_before["final_recall"], "->", neighbor_after["final_recall"])
print("Low-IoU forced anchors:", results["low_iou_forced_training_case"])
print("Protocol counterexample (two copies of GT1): maximum-IoU GT then used ->",
[r["status"] for r in protocol_standard["rows"]], "AP50", protocol_standard["ap"],
"; best-unused alternative ->", alternative_tp, "AP50", alternative_ap)
```
[此段独立阅读](/course/notebook-blocks/e4240ba681bd49f3871529d076ed4b97.html)
## 可视化与实际输出
这格用标准库生成三张 SVG 图,图中的坐标、上包络、面积和抑制结果均取自上格的实际计算。绘图部分可以折叠;前面的机制计算不依赖它。
[此段独立阅读](/course/notebook-blocks/ba39de56ef7d4d2882d9441ecd6d1424.html)展开完整实现代码
```
def svg_document(width, height, fragments):
return (f'")
def svg_text(x, y, value, cls="label", extra=""):
return f'{escape(str(value))}'
geometry = [svg_text(32, 36, "Two GT boxes / four scored predictions", "title"),
svg_text(32, 62, "Continuous xyxy coordinates; dashed = GT; solid = prediction", "label")]
for value in range(9):
x, y = 80 + value * 55, 100 + value * 55
geometry.append(f'')
geometry += [svg_text(x - 4, 566, value, "small"), svg_text(55, y + 5, value, "small")]
geometry += [svg_text(293, 592, "x coordinate"), svg_text(20, 327, "y", "label")]
for i, b in enumerate(GT):
x1, y1, x2, y2 = b
geometry.append(f'')
geometry.append(svg_text(83 + x1 * 55, 88, f"GT{i+1}"))
colors = ["#156a9c", "#d16a20", "#188164", "#845ec2"]
for i, b in enumerate(PRED):
x1, y1, x2, y2 = b
geometry.append(f'')
geometry.append(f'')
geometry.append(svg_text(627, 134 + i * 50, f"P{i+1} | score {SCORES[i]:.2f}"))
geometry.append(svg_text(627, 153 + i * 50, f"IoU(GT1,GT2) = {iou_matrix[i,0]:.3f}, {iou_matrix[i,1]:.3f}", "small"))
geometry += [svg_text(585, 372, "AP50: TP, FP, TP, FP"),
svg_text(585, 399, "P2 overlaps GT1, already used."),
svg_text(585, 444, "AP75: TP, FP, FP, FP"),
svg_text(585, 471, "P3 IoU = 2/3 < 0.75."),
svg_text(585, 527, "The axes include the distant FP.", "small")]
(OUT / "geometry.svg").write_text(svg_document(955, 615, geometry), encoding="utf-8")
pr = [svg_text(28, 36, "All-points AP integrates the precision envelope", "title"),
svg_text(28, 62, "Original predictions, before NMS | solid: envelope; dots: raw ranked precision", "label")]
for panel, key in enumerate(["AP50", "AP75"]):
result = baseline[key]
left, top, width, height = 80 + panel * 460, 108, 340, 290
for tick in [0, .25, .5, .75, 1]:
x, y = left + tick * width, top + (1 - tick) * height
pr += [f'',
svg_text(x - 8, top + height + 26, f"{tick:g}", "small"),
svg_text(left - 35, y + 4, f"{tick:g}", "small")]
mrec, env = np.array(result["envelope_recall"]), np.array(result["precision_envelope"])
intervals = np.flatnonzero(np.diff(mrec) > 0)
for interval_i, j in enumerate(intervals):
x0, x1 = left + mrec[j] * width, left + mrec[j + 1] * width
y = top + (1 - env[j + 1]) * height
pr.append(f'')
pr.append(f'')
if interval_i + 1 < len(intervals):
next_y = top + (1 - env[intervals[interval_i + 1] + 1]) * height
pr.append(f'')
for i, (r, p) in enumerate(zip(result["recall"], result["precision"])):
x, y = left + r * width, top + (1 - p) * height
pr += [f'',
svg_text(x + (10 if x < left + width else -26), y + (18 if i in (0, 3) else -9), f"P{i+1}", "small")]
pr += [svg_text(left + 108, 94, f"{key} = {result['ap']:.6f}"),
svg_text(left + 130, 451, "Recall"), svg_text(left - 38, 85, "Precision", "small")]
pr += [svg_text(80, 489, "AP50 = (1/2)*1 + (1/2)*(2/3) = 5/6"),
svg_text(540, 489, "AP75 = (1/2)*1 + (1/2)*0 = 1/2"),
svg_text(80, 529, "Repeated recall adds no area; the interval from 0.5 to 1 uses precision 2/3.", "small")]
(OUT / "precision_recall.svg").write_text(svg_document(970, 556, pr), encoding="utf-8")
nms = [svg_text(28, 36, "The same NMS rule can remove a duplicate or a real object", "title"),
svg_text(28, 63, "Class-aware NMS: suppress same-class boxes only when IoU > 0.50", "label")]
for panel, (title, boxes, mutual, before, after) in enumerate([
("A. Duplicate predictions for one object", PRED[:2], 9/11, 5/6, 1.0),
("B. Two adjacent real objects", NEIGHBOR_GT, .6, 1.0, .5)]):
left = 40 + panel * 485
nms.append(svg_text(left, 110, title))
for i, b in enumerate(boxes):
x1, y1, x2, y2 = b
nms.append(f'')
nms += [svg_text(left + 25, 345, f"Mutual IoU = {mutual:.3f} > 0.50"),
svg_text(left + 25, 373, "Blue P1 retained; dashed orange P2 removed."),
svg_text(left + 25, 410, f"AP50: {before:.6f} -> {after:.6f}")]
if panel == 0:
nms += [svg_text(left + 25, 442, "P2 was a duplicate FP; full-scene AP improves.", "small"),
svg_text(left + 25, 465, "P3 and P4 remain in the original scene.", "small")]
else:
nms += [svg_text(left + 25, 442, "P2 was a TP; recall falls from 1.0 to 0.5.", "small"),
svg_text(left + 25, 465, "Changing only NMS threshold to 0.70 keeps both.", "small")]
(OUT / "nms_tradeoff.svg").write_text(svg_document(1000, 505, nms), encoding="utf-8")
# Rich display is optional; SVG files are created above without IPython.
try:
from IPython.display import SVG, display
for figure in ["geometry.svg", "precision_recall.svg", "nms_tradeoff.svg"]:
display(SVG(filename=str(OUT / figure)))
except ImportError:
print("SVG files saved; open them in a browser for viewing.")
```
[此段独立阅读](/course/notebook-blocks/8d1df38317244d5097b2ef92388bf925.html)
## 7 · 只改一个条件,并用结果回答
保留基准输入与断言;在新的代码格中用 `.copy()` 创建改动副本,再调用相同函数。这样,固定基准检查继续说明实现是否正确,实验结果说明条件变化产生什么影响。
- **只改排名:**保持全部坐标和类别不变,把 P2 分数从 0.80 调成 0.60,再重新评价。它排到 P3 后,AP50 会怎样变化?解释为什么最终 recall 没变,AP 却可能变化。
- **只改定位:**保持分数与其他框不变,把 P3 左右边界同时左移 0.4。AP75 改善来自哪个 IoU 变化?不要把它归因于 NMS。
- **只改 NMS 阈值:**比较 0.50 与 0.70 在相邻真对象案例里的保留列表。再尝试更密集或分数顺序颠倒的对象,说明单一阈值何时仍失败。
**完成标准:**保存真实运行输出,核对 `(4,2)` IoU 矩阵、原始 AP50=`5/6` 与 AP75=`0.5`;说明训练分配与评价 one-to-one 的差别;给出 NMS 有帮助与有损害的各一个场景。结论必须写出坐标、阈值、类别条件和 AP 协议。
本地运行 `python lab09.py` 会生成 `results.json`、三张 SVG 图与 `run.json`。`run.json` 记录 UTC 执行时间、Python/NumPy 版本、关键结果、输入 SHA-256 与脚本/教学材料/图的 SHA-256。notebook 的代码包含完整输入和函数,按顺序运行即可,不依赖其他 notebook 的内存或磁盘数据。
[此段独立阅读](/course/notebook-blocks/a913f088886f4de38fb1812ed586dc33.html)展开完整实现代码
```
# Optional one-condition experiments: preserve the baseline arrays.
scores_rank_test = SCORES.copy()
scores_rank_test[1] = .60
print("P2 score 0.60, AP50:", evaluate_single_class(PRED, scores_rank_test, GT, .5)["ap"])
boxes_localization_test = PRED.copy()
boxes_localization_test[2, [0, 2]] -= .4
print("P3 shifted left 0.4, AP75:", evaluate_single_class(boxes_localization_test, SCORES, GT, .75)["ap"])
for threshold in [.50, .70]:
candidate_keep, _ = class_aware_nms(NEIGHBOR_GT, NEIGHBOR_SCORES, [0, 0], threshold)
print("Neighbor NMS", threshold, "keeps", (candidate_keep + 1).tolist(),
"AP50", evaluate_single_class(NEIGHBOR_GT[candidate_keep],
NEIGHBOR_SCORES[candidate_keep], NEIGHBOR_GT, .5)["ap"])
```
[此段独立阅读](/course/notebook-blocks/7a672ab1408b4a068337b1162bb625a6.html)
## 来源与范围
- [Dive into Deep Learning:Anchor Boxes](https://d2l.ai/chapter_computer-vision/anchor.html):连续坐标 IoU、anchor 目标分配与 NMS 机制的官方教材来源。
- all-points AP 是本册明确给出的教学评价协议;计算、上包络、边界处理与断言均可直接检查。此处不声称完整复现某个检测评测工具包。
[此段独立阅读](/course/notebook-blocks/8f24850741144b31ac103742e94f7ecd.html)
## 本次基准的静态图
以下三图来自本册已完成本地计算;重新执行绘图格可从同一输入重建。



[此段独立阅读](/course/notebook-blocks/e40ebaef99ea481ea734c690ce85f20d.html)
## 运行记录
此格记录本次运行时间、环境、输入和输出哈希。在独立 notebook 中,只记录本次目录里实际存在的文件;完整交付的本地运行还记录脚本与 notebook 本身的哈希。
[此段独立阅读](/course/notebook-blocks/01778787c2ab4952b60963cbffd5b7c1.html)展开完整实现代码
```
artifact_names = ["lab09.py", "notebook.json", "teaching.md", "results.json",
"geometry.svg", "precision_recall.svg", "nms_tradeoff.svg"]
artifact_hashes = {name: hashlib.sha256((OUT / name).read_bytes()).hexdigest()
for name in artifact_names if (OUT / name).exists()}
input_json = json.dumps({"gt": GT.tolist(), "pred": PRED.tolist(), "scores": SCORES.tolist(),
"nms_threshold": NMS_THRESHOLD, "eval_thresholds": EVAL_THRESHOLDS},
sort_keys=True, separators=(",", ":"))
manifest = {"notebook_id": "90d213b4e1e04c7e889ee6fc042b2358",
"notebook_name": "LAB 09 · 检测几何:框、匹配、NMS 与 AP",
"started_at_utc": STARTED_AT, "finished_at_utc": datetime.now(timezone.utc).isoformat(),
"elapsed_seconds": time.perf_counter() - STARTED_CLOCK,
"execution": "actual local Python execution", "status": "passed",
"command": "python lab09.py", "python_version": platform.python_version(),
"numpy_version": np.__version__, "input_sha256": hashlib.sha256(input_json.encode()).hexdigest(),
"sha256": artifact_hashes, "key_results": {"AP50": baseline["AP50"]["ap"],
"AP75": baseline["AP75"]["ap"], "nms_AP50": after_nms["AP50"]["ap"],
"nms_AP75": after_nms["AP75"]["ap"], "neighbor_nms_AP50": neighbor_after["ap"],
"protocol_duplicate_AP50": protocol_standard["ap"],
"alternative_best_unused_AP50": alternative_ap}}
(OUT / "run.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("Assertions: passed. Saved results.json, 3 SVG figures and run.json.")
```
[此段独立阅读](/course/notebook-blocks/66bcd9627b4841bfbeed4228ff02efd7.html)