# 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'' '' + "".join(fragments) + "") 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) ## 本次基准的静态图 以下三图来自本册已完成本地计算;重新执行绘图格可从同一输入重建。 ![框与匹配](/assets/display/8598b4f35f0c1f867e932c55.webp) ![precision–recall 与 AP](/assets/display/b723b855b886fdcda83717ce.webp) ![NMS 阈值对照](/assets/display/f0262a290b9cdbe851e2f89b.webp) [此段独立阅读](/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)