本节摘要:CV 专场的收官题:手写交并比 IoU,再手写非极大值抑制 NMS,追问压向阈值敏感性、类别隔离与软抑制变体。这道题不依赖任何框架,纯几何加排序,是检测方向面试里最能看出"能不能白板推演"的一道,也是主线候选人全场唯一一次零失误。
别以为检测题的后处理沾着"深度学习"的边就不好下手——它恰恰是白板上最友好的一类:几何可以画出来,排序可以列成表,边界情况可以拿特殊位置自测。现场十一过了模型段之后,面试官把话题推向流水线最后一环,也就是本题登场的地方:模型的原始输出是一堆重叠的框,谁来决定"哪个框算数"。
"检测模型的原始输出我给你了:若干个框加置信分。你先手写 IoU——交并比;然后写 NMS——非极大值抑制,按分数从高到低保留,抑制掉与已保留框重叠超过阈值的。写完回答:阈值调高、调低分别会怎样?不同类别的框要不要互相抑制?"
问句拆开是四层:IoU 的几何定义、NMS 的排序与抑制逻辑、阈值的失效模式、类别维度的处理。前三层是代码分,最后一层是工程分。
候选人先画图再落笔。他在白板上画了两个矩形,标出交集、标出各自的面积,写下 IoU = 交集面积 除以 (面积和 − 交集面积)。画完特意圈出"并集不是面积直接相加"这一行——他说这是自己当年踩过的坑。
import numpy as np def iou(box_a, box_b): # 框格式 [x1, y1, x2, y2],左上与右下 xA = max(box_a[0], box_b[0]) # 交集左上角取较靠内的 yA = max(box_a[1], box_b[1]) xB = min(box_a[2], box_b[2]) # 交集右下角取较靠外的 yB = min(box_a[3], box_b[3]) inter = max(0.0, xB - xA) * max(0.0, yB - yA) # 相离时截断为 0 if inter == 0: return 0.0 area_a = (box_a[2] - box_a[0]) * (box_a[3] - box_a[1]) area_b = (box_b[2] - box_b[0]) * (box_b[3] - box_b[1]) return inter / (area_a + area_b - inter) a = [0.0, 0.0, 4.0, 4.0] print('部分重叠:', round(iou(a, [1.0, 1.0, 5.0, 5.0]), 4)) print('完全相离:', iou(a, [10.0, 10.0, 12.0, 12.0])) print('大含小 :', iou(a, [1.0, 1.0, 2.0, 2.0]))
部分重叠: 0.3913 完全相离: 0.0 大含小 : 0.0625
三种特殊位置逐个核对:部分重叠的两框各十六格面积、交集九格,九除以二十三约零点三九;相离框交集为零,直接返回零;包含关系里交集就是小框自身,一除以十六得零点零六二五。IoU 只在零到一之间取值——这是写完代码后最省事的自检:算出负数或超过一,必然是交集忘了截断,或并集忘了减交集。

IoU 稳了之后,NMS 的骨架只剩排序加循环:按分数从高到低,每轮取出最高分保留,算它与剩余框的 IoU,超阈值的删掉,不足的留下进入下一轮。
def nms(boxes, scores, iou_thr=0.5): x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] areas = (x2 - x1) * (y2 - y1) order = scores.argsort()[::-1] # 下标按分数从高到低 keep = [] while order.size > 0: i = order[0] # 本轮最高分,必然保留 keep.append(int(i)) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1) ov = inter / (areas[i] + areas[order[1:]] - inter) # 向量化算一批 IoU order = order[1:][ov <= iou_thr] # 只留下重叠不超阈值的下标 return keep boxes = np.array([[0., 0., 10., 10.], [1., 1., 11., 11.], [9., 9., 20., 20.], [100., 100., 110., 110.], [2., 2., 12., 12.]]) scores = np.array([0.90, 0.80, 0.85, 0.70, 0.60]) keep = nms(boxes, scores) print('保留下标:', keep) print('保留分数:', scores[keep].tolist()) print('阈值0.3 :', nms(boxes, scores, 0.3)) print('阈值0.7 :', nms(boxes, scores, 0.7))
保留下标: [0, 2, 3, 4] 保留分数: [0.9, 0.85, 0.7, 0.6] 阈值0.3 : [0, 2, 3] 阈值0.7 : [0, 2, 1, 3, 4]
候选人指着结果逐条解读:分数零点八的框与零点九的框重叠约零点六八,超过零点五阈值被删;零点六的框与零点九的框重叠约零点四七,压着线活了下来;一百开外那个零点七的框谁也不挨着,稳稳保留。阈值扫到零点三,压线的零点六也被删——漏检;扫到零点七,连零点八的都活了下来——同一目标留下两个框,冗余。他把笔一放:"阈值不是超参数调优题,是在漏检与冗余之间选立场,召回优先的任务取高阈值,精度优先取低。"
追问一:五框循环里每轮都算一批 IoU,复杂度怎样,能再快吗? 候选人答:"最坏 O(n²)——极端情况全部框互相重叠,每轮只删一个。工程上的快法是把比较对象换成'已保留集合'并用数据结构维护,或者干脆改成 matrix NMS:把全部成对 IoU 一次算成矩阵,按高斯核衰减改分数而不是硬删,整个后处理变成矩阵运算,还能跟网络一起吃 GPU。"
追问二:不同类别的框要不要互相抑制? "要隔离。行人与汽车框重叠再大也不是同一目标,按类别分组各做各的 NMS。工程实现里有个惯用法:把 (N, 4) 的框展开成 (N × C, 4),类别维拍进样本维,跑一遍 NMS 再收缩回来,不用写双重循环。"
追问三:为什么按分数排序,而不是按 IoU 或者别的? "置信分是模型对'框里有目标'的押注,NMS 的目标就是给每个目标留最高分的那个。但分数不完美——被遮挡目标的分数天然偏低,可能被旁边的完整框压掉。这正是 soft-NMS 的动机:不删除邻框,改成按重叠度衰减它的分数,重叠越多扣得越狠,让下一轮自己跟别人比。"
软抑制的线性版只有一行改动:惩罚后的分数 s = s * (1 - IoU)(高斯版用指数衰减,更平滑),然后照常排序。候选人现场把变式写了出来,只改了两行:
def soft_nms_one_round(boxes, scores, iou_thr=0.5): i = scores.argmax() rest = [j for j in range(len(scores)) if j != i] ov = np.array([iou(boxes[i].tolist(), boxes[j].tolist()) for j in rest]) scores[rest] *= (1 - ov) # 不删除,衰减重叠框的分数 return i, scores s = scores.copy().astype(float) idx, s2 = soft_nms_one_round(boxes, s) print('本轮保留:', idx, '衰减后:', s2.round(3).tolist())
本轮保留: 0 衰减后: [0.9, 0.255, 0.846, 0.7, 0.318]
零点八的框被衰减到零点二五五,基本出局;零点六的框只被扣到零点三一八,还有翻盘机会——这正是软抑制与硬删除的分野:边界例子交给后续轮次与更低的阈值去裁决。变式还能再延伸:加权融合框(用邻框按 IoU 加权微调保留框坐标)、旋转框 IoU(交集变成多边形裁剪,思路不变、几何变难)、以及跟踪场景里的跨帧 IoU 匹配。面试官通常只追问到第二层,能把第三层说成"思路不变、实现变难",就够了。
这道题的翻车点集中而固定:交集不截断负值,相离框算出负 IoU;并集忘了减交集,重叠越大 IoU 越低;argsort 忘了取反,每轮保留的是最低分;在循环里原地改 order 数组,边界处下标错位;阈值讨论只会背"零点五常用",说不出失效模式。候选人这场零失误,他把功劳记在开头那个动作上——先画两个框、把交集圈出来,代码里的每一行都有图上的对应物,写错比写对还难。
面试官的评语一针见血:"几何题画图自检、特殊位置当测试用例,这两个习惯比 NMS 本身值钱。下一场换 NLP,没有图可画了,看你怎么办。"
关键直觉:NMS 是"排序定基调、阈值定立场"的算法——代码只有骨架十行,分数的立场(漏检还是冗余)才是面试官真正想听的那一句。