Lecture 03 学习讲义
正文
工具
按问题查找
区域、像素与实例多个区域怎样复用同一份视觉计算?

LAB 09 · 检测几何:框、匹配、NMS 与 AP

并入相关学习路径

固定框匹配、PR/AP、NMS误删相邻对象反例

对应课堂(8 页)

可先阅读已有结果,再决定是否运行。重算时请核对各节记录的数据、参数、环境与日期。

本册是 LAB 10 的检测几何工作台:固定框和分数,分别计算训练分配、评价匹配、NMS 和 AP。它不训练图像模型,对应 P33–35。

初读只选一件事:第 1 节核坐标与 IoU;第 3–4 节追踪重复框和 AP;第 5–6 节比较 NMS 去重与误删相邻对象。深入定义在 CLS 07,可直接引用 AP 排序积分 和 NMS 与匹配的区别。

做完保留一项“只改分数”或“只改 NMS 阈值”的对照,解释 precision/recall/AP 哪个量变了以及原因。若复跑,函数有先后依赖,需从环境格按序执行;绘图和运行记录可以折叠阅读。

此段独立阅读
展开完整实现代码
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)
此段独立阅读
展开完整实现代码
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)
此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

1 · 坐标先说清:连续 xyxy,不加一

框写成 (x1, y1, x2, y2),表示连续坐标边界。宽为 max(0, x2-x1),高为 max(0, y2-y1);面积计算不使用像素闭区间的 +1。本实验允许零面积框,其零并集 IoU 约定为 0;拒绝倒置坐标和非有限数。

两框交集的左上角是两组左上角的逐坐标最大值,右下角是两组右下角的逐坐标最小值;先把交集宽高截断到非负,再相乘。

IoU⁡(A,B)=∣A∩B∣∣A∣+∣B∣−∣A∩B∣. \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 矩阵;最后的可视化代码格会显示坐标图。

此段独立阅读
展开完整实现代码
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))
此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

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 分配规则。其他检测器可能使用一对一集合匹配、不同阈值或动态分配;不能把本例规则推广为所有检测器。

此段独立阅读
展开完整实现代码
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])
此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

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 各有一个准确预测,属于另一种情形。

此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

4 · AP 必须说明积分协议

本例使用 all-points interpolated AP / precision envelope:先累加 TP 和 FP,得到每个排名位置的 precision、recall;从右向左取 precision 的累计最大值,形成单调不增的上包络;只在 recall 实际增长的区间计算面积。

pinterp(r)=max⁡r~≥rp(r~),AP=∑k(rk−rk−1)pinterp(rk). 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,得到

AP50=12×1+12×23=56. \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。

此段独立阅读
展开完整实现代码
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)
此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

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。

此段独立阅读
展开完整实现代码
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)
此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

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 是真实数据的最佳阈值”,因为这里只有两个手工场景。

此段独立阅读
展开完整实现代码
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)
此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

可视化与实际输出

这格用标准库生成三张 SVG 图,图中的坐标、上包络、面积和抑制结果均取自上格的实际计算。绘图部分可以折叠;前面的机制计算不依赖它。

此段独立阅读
展开完整实现代码
def svg_document(width, height, fragments):
    return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
            f'viewBox="0 0 {width} {height}"><rect width="100%" height="100%" fill="#f7f8fa"/>'
            '<style>text{font-family:Arial,sans-serif;fill:#162235} .title{font-size:22px;font-weight:bold}'
            '.label{font-size:15px}.small{font-size:13px}.axis{stroke:#738095;stroke-width:1}</style>'
            + "".join(fragments) + "</svg>")


def svg_text(x, y, value, cls="label", extra=""):
    return f'<text x="{x}" y="{y}" class="{cls}" {extra}>{escape(str(value))}</text>'


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'<path d="M {x} 100 V 540 M 80 {y} H 520" stroke="#e0e4e9"/>')
    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'<rect x="{80 + x1 * 55}" y="{100 + y1 * 55}" width="{(x2 - x1) * 55}" '
                    f'height="{(y2 - y1) * 55}" fill="none" stroke="#111827" stroke-width="4" stroke-dasharray="9 5"/>')
    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'<rect x="{80 + x1 * 55}" y="{100 + y1 * 55}" width="{(x2 - x1) * 55}" '
                    f'height="{(y2 - y1) * 55}" fill="none" stroke="{colors[i]}" stroke-width="2"/>')
    geometry.append(f'<line x1="585" x2="615" y1="{128+i*50}" y2="{128+i*50}" stroke="{colors[i]}" stroke-width="4"/>')
    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'<path d="M {x} {top} V {top+height} M {left} {y} H {left+width}" stroke="#dbe1e7"/>',
               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'<rect x="{x0}" y="{y}" width="{x1-x0}" height="{top+height-y}" fill="#cce4ec"/>')
        pr.append(f'<path d="M {x0} {y} H {x1}" stroke="#136b87" stroke-width="4"/>')
        if interval_i + 1 < len(intervals):
            next_y = top + (1 - env[intervals[interval_i + 1] + 1]) * height
            pr.append(f'<path d="M {x1} {y} V {next_y}" stroke="#136b87" stroke-width="3"/>')
    for i, (r, p) in enumerate(zip(result["recall"], result["precision"])):
        x, y = left + r * width, top + (1 - p) * height
        pr += [f'<circle cx="{x}" cy="{y}" r="5" fill="#cc662c"/>',
               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'<rect x="{left+25+x1*105}" y="{145+y1*85}" width="{(x2-x1)*105}" '
                   f'height="{(y2-y1)*85}" fill="none" stroke="{colors[i]}" stroke-width="4" '
                   + ('stroke-dasharray="8 5"' if i == 1 else '') + '/>')
    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.")
此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

7 · 只改一个条件,并用结果回答

保留基准输入与断言;在新的代码格中用 .copy() 创建改动副本,再调用相同函数。这样,固定基准检查继续说明实现是否正确,实验结果说明条件变化产生什么影响。

  1. **只改排名:**保持全部坐标和类别不变,把 P2 分数从 0.80 调成 0.60,再重新评价。它排到 P3 后,AP50 会怎样变化?解释为什么最终 recall 没变,AP 却可能变化。
  2. **只改定位:**保持分数与其他框不变,把 P3 左右边界同时左移 0.4。AP75 改善来自哪个 IoU 变化?不要把它归因于 NMS。
  3. **只改 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 的内存或磁盘数据。

此段独立阅读
展开完整实现代码
# 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"])
此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

来源与范围

  • Dive into Deep Learning:Anchor Boxes:连续坐标 IoU、anchor 目标分配与 NMS 机制的官方教材来源。
  • all-points AP 是本册明确给出的教学评价协议;计算、上包络、边界处理与断言均可直接检查。此处不声称完整复现某个检测评测工具包。
此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

本次基准的静态图

以下三图来自本册已完成本地计算;重新执行绘图格可从同一输入重建。

框与匹配

precision–recall 与 AP

NMS 阈值对照

此段独立阅读
区域、像素与实例多个区域怎样复用同一份视觉计算?

运行记录

此格记录本次运行时间、环境、输入和输出哈希。在独立 notebook 中,只记录本次目录里实际存在的文件;完整交付的本地运行还记录脚本与 notebook 本身的哈希。

此段独立阅读
展开完整实现代码
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.")
此段独立阅读