
1. 为什么我劝你手写一遍NMS目标检测这摊活儿绕不开一个名字——非极大值抑制圈子里都叫它NMS。你跑完任何一个检测模型不管是单阶段还是两阶段最后都会拿到一堆密密麻麻的候选框每个框还带着一个置信度分数。问题是同一个目标往往被预测出十几个甚至几十个框它们互相重叠、互相打架你要是不处理画出来的图就是一团糊。NMS干的事情说白了就一句话从一堆重叠的框里挑出最好的那个把跟它太像的其余框全部干掉。这个逻辑听起来简单到不行但真正自己动手写一遍你会发现坑比想象中多得多。我见过太多人调库调得飞起torchvision.ops.nms一行搞定结果面试被问“IoU怎么算的”“为什么阈值设0.5”“Soft-NMS和普通NMS差在哪”直接卡壳。造轮子的意义不在于你以后不用现成的而在于你知道现成的里面发生了什么出问题的时候你能定位效果不好的时候你能改。这篇文章适合谁看如果你正在学目标检测已经能跑通模型但对手写后处理心里没底那这篇就是给你写的。如果你是想复习基础的老手也可以跟着过一遍我在里面塞了不少实际踩坑的经验。核心关键词就三个NMS、IoU、置信度排序我会围绕它们把整个轮子从零造出来包括原理推导、代码实现、参数调优和常见翻车现场。先说清楚一件事NMS不是深度学习模型的一部分它是后处理。模型负责吐框NMS负责筛框。这个定位很重要因为它意味着NMS是纯算法逻辑不涉及梯度、不涉及训练你完全可以用Python加NumPy手撸出来而且撸一遍之后你对整个检测流程的理解会上一个台阶。2. NMS的核心原理拆解2.1 从“挑最好的”说起贪心策略的本质NMS的核心思想是贪心。什么叫贪心就是每一步都做当前看起来最优的选择不回头。具体到NMS流程是这样的先把所有候选框按置信度从高到低排个序然后从最高的开始把它定为“保留框”接着拿它跟剩下的所有框算IoU谁跟它重叠得太厉害超过阈值谁就被抑制掉。处理完第一个再从剩下的里面挑置信度最高的重复同样的操作直到所有框都被处理过一遍。为什么用贪心而不是全局最优因为全局最优在这个问题上是个NP难的东西你要考虑所有框的组合计算量爆炸。而贪心策略在实际中效果足够好速度快实现简单。这就是工程上的取舍——不追求理论最优追求性价比最高。我打个生活化的比方。假设你在一个班里选班长规则是先让票数最高的人当班长然后把他所有好朋友关系特别近的都排除掉再从剩下的人里选票数最高的当副班长再排除他的好朋友以此类推。这样选出来的人彼此之间“关系不会太近”保证了多样性。NMS就是这个逻辑只不过“票数”换成了置信度“好朋友”换成了IoU高的框。2.2 IoU衡量两个框有多“像”的尺子IoU全称Intersection over Union中文叫交并比。它的定义极其直白两个框的交集面积除以它们的并集面积。交集就是两个框重叠的那块区域并集就是两个框合起来覆盖的总面积。IoU的取值范围是0到10表示完全不重叠1表示完全重合。计算IoU的难点不在公式而在坐标的边界处理。两个矩形框通常用左上角和右下角坐标表示比如(x1, y1, x2, y2)。求交集的时候交集的左上角是max(x1_a, x1_b)右下角是min(x2_a, x2_b)。这里有个坑如果算出来的交集右下角坐标小于左上角坐标说明两个框根本不相交交集面积应该是0而不是负数。这个判断要是漏了后面算出来的IoU会出各种诡异的值。还有一个细节是坐标格式。有的框架用(x1, y1, x2, y2)有的用(cx, cy, w, h)即中心点加宽高。手写的时候一定要统一我建议统一转成(x1, y1, x2, y2)再算因为交集的逻辑用角点表示最直观。转换公式是x1 cx - w/2y1 cy - h/2x2 cx w/2y2 cy h/2这个别搞反了。2.3 置信度排序为什么必须先排序排序是NMS的第一步也是整个贪心策略成立的前提。你想如果不排序你随便挑一个框开始抑制那很可能先把真正的好框给干掉了。排序保证了每次都是从当前最优的框开始处理这样保留下来的框质量才有保障。排序用降序置信度高的在前。实现上就是一次argsort然后反转或者直接对负值排序。这里有个性能上的小技巧如果候选框数量很大比如上万排序本身也是开销但相比后面两两算IoU的O(n²)复杂度排序的O(n log n)可以忽略不计。所以别在排序上抠性能把精力放在减少候选框数量上——这也是为什么很多模型会在NMS之前先做一轮置信度过滤把分数低于某个值的框直接扔掉。2.4 抑制阈值那个决定“多像才算太像”的数字阈值是NMS里最需要调的参数。典型值在0.4到0.7之间0.5是最常见的默认值。阈值越低抑制越狠保留的框越少容易漏检阈值越高抑制越松保留的框越多容易误检。这个权衡没有标准答案取决于你的任务。举个例子如果你检测的是密集人群人和人挨得很近IoU天然就高这时候阈值设太低会把旁边的人也抑制掉导致漏检所以密集场景通常用较高的阈值比如0.6到0.7。反过来如果你检测的是稀疏的大目标比如马路上的车框之间重叠少阈值可以设低一点比如0.4到0.5把冗余框清干净。我个人的经验是先用0.5跑一版看效果然后根据漏检和误检的情况微调。如果发现明明有目标但没框出来往高调如果发现同一个目标有好几个框往低调。调的时候每次动0.05别一下跳太多不然你根本不知道是哪个方向起了作用。3. 手写NMS的完整实操3.1 环境准备与数据格式约定我用的环境很简单Python 3加上NumPy不需要任何深度学习框架。这样做的目的是让你专注于算法本身不被框架的API干扰。你可以在任何有Python的地方跑这段代码本地、服务器、甚至在线笔记本都行。数据格式我统一约定为框用(x1, y1, x2, y2)表示分数是一个一维数组框和分数一一对应。这是最通用的格式从模型输出转过来也方便。如果你拿到的框是(cx, cy, w, h)先转一下再喂进来。import numpy as np def xywh_to_xyxy(boxes): # boxes: (N, 4) 格式为 (cx, cy, w, h) x1 boxes[:, 0] - boxes[:, 2] / 2 y1 boxes[:, 1] - boxes[:, 3] / 2 x2 boxes[:, 0] boxes[:, 2] / 2 y2 boxes[:, 1] boxes[:, 3] / 2 return np.stack([x1, y1, x2, y2], axis1)这段转换代码看着简单但我踩过坑如果w和h是负数或者零转换出来的框是无效的。正常模型输出不会这样但如果你自己造数据测试记得检查一下。另外np.stack的axis1别写错写成axis0就变成按行堆叠了形状全乱。3.2 IoU计算的向量化实现单个IoU好算但NMS里要反复算一个框和一堆框的IoU用循环会慢到哭。所以必须向量化。思路是把“一个框”广播成和“一堆框”同样的形状然后批量算交集和并集。def compute_iou(box, boxes): # box: (4,) 单个框 # boxes: (N, 4) 一堆框 x1 np.maximum(box[0], boxes[:, 0]) y1 np.maximum(box[1], boxes[:, 1]) x2 np.minimum(box[2], boxes[:, 2]) y2 np.minimum(box[3], boxes[:, 3]) inter_w np.maximum(0, x2 - x1) inter_h np.maximum(0, y2 - y1) inter_area inter_w * inter_h area_box (box[2] - box[0]) * (box[3] - box[1]) area_boxes (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) union_area area_box area_boxes - inter_area return inter_area / np.maximum(union_area, 1e-6)这里有两个关键点。第一np.maximum(0, x2 - x1)保证了不相交时交集面积为0不会出现负数。第二分母加了1e-6防止除零虽然理论上并集面积不会为0框有面积但万一传进来一个退化框这个保护能救命。我实测过不加这个保护在某些脏数据上会直接出NaN然后整个NMS结果全废。3.3 主循环贪心抑制的落地有了IoU函数主循环就顺理成章了。核心是维护一个“待处理”的索引列表每次取分数最高的保留它然后把它抑制掉的框从列表里剔除。def nms(boxes, scores, iou_threshold0.5): # boxes: (N, 4) xyxy # scores: (N,) order scores.argsort()[::-1] keep [] while order.size 0: i order[0] keep.append(i) if order.size 1: break ious compute_iou(boxes[i], boxes[order[1:]]) inds np.where(ious iou_threshold)[0] order order[inds 1] return np.array(keep)这段代码短但每一行都有讲究。scores.argsort()[::-1]是降序排列[::-1]反转别漏。order[inds 1]里的1是因为ious对应的是order[1:]索引偏移了一位这个偏移量写错的话结果会莫名其妙地少框或者多框我第一次写的时候就栽在这。还有一个边界情况order.size 1时直接break不然order[1:]是空数组虽然不会报错但逻辑上没必要继续。这个判断加上之后代码更清晰。3.4 完整可运行示例与结果验证光看代码不够得跑起来验证。我造了一组测试数据三个框其中两个高度重叠一个独立。boxes np.array([ [10, 10, 50, 50], [12, 12, 52, 52], [100, 100, 140, 140] ], dtypenp.float32) scores np.array([0.9, 0.8, 0.7]) keep nms(boxes, scores, iou_threshold0.5) print(keep) # 预期输出 [0 2]第一个框和第二个框IoU很高大概0.8左右超过0.5所以第二个被抑制。第三个框跟谁都不重叠保留。结果应该是[0, 2]。你跑一下如果输出是这个说明轮子造对了。验证的时候我建议多造几组边界数据完全重叠的框IoU1、完全不重叠的框IoU0、刚好卡在阈值上的框。特别是刚好等于阈值的情况代码里用的是意味着IoU等于阈值时保留这个细节不同实现可能不一样你要清楚自己用的是哪种。4. 参数调优与常见翻车现场4.1 阈值选择的实战经验阈值这个东西理论讲再多不如上手调。我整理了一个经验表是我在不同任务上试出来的大致范围你可以作为起点。任务类型推荐阈值原因稀疏大目标车辆、行人0.4 - 0.5框重叠少可以抑制狠一点密集小目标人群、货架商品0.6 - 0.7目标挨得近抑制太狠会漏单目标检测0.3 - 0.4只有一个目标冗余框清干净多类别检测0.5 左右类别间独立做NMS用默认值稳注意最后一行多类别检测时NMS是按类别分别做的。也就是说一个“人”的框和一个“车”的框即使IoU很高也不应该互相抑制因为它们类别不同。这个逻辑要是搞错把不同类别的框混在一起做NMS结果会非常离谱。实现上就是按类别分组每组单独调一次NMS。4.2 那些年我踩过的坑坑一坐标格式不统一。模型输出是(cx, cy, w, h)我直接拿去算IoU结果交集算出来全是0因为把中心点当左上角用了。这个错误很隐蔽因为代码不报错只是结果不对。后来我养成了习惯任何框进NMS之前先打印前几个看看数值范围正常xyxy格式下x2应该大于x1如果发现x2比x1小八成是格式搞错了。坑二分数没有归一化。有的模型输出logits有的输出概率如果分数范围是负的或者超过1排序逻辑虽然不受影响但如果你在NMS之前做了分数过滤比如只保留分数大于0.5的logits直接过滤会全军覆没。所以过滤之前先确认分数是不是概率不是的话先过个sigmoid或者softmax。坑三大量框导致速度慢。我遇到过一张图输出两万多个候选框的情况纯Python循环的NMS跑了快一秒。解决办法有两个一是在NMS之前先做置信度过滤把明显不可能的框扔掉通常能砍掉90%以上二是用向量化更彻底的实现或者干脆上CUDA版本。实际部署中置信度过滤是标配别省这一步。坑四阈值设了0或者1。阈值0意味着任何有重叠的框都被抑制只剩一个阈值1意味着只有完全重合的框才被抑制几乎等于没做NMS。这两个极端值基本不会用但如果你不小心传错了结果会非常奇怪。我建议在函数入口加个断言0 iou_threshold 1把错误挡在门外。4.3 常见问题速查表现象可能原因排查方向输出框数量为0阈值太低或分数过滤太狠检查阈值和分数范围同一目标多个框阈值太高降低阈值每次0.05目标漏检阈值太低好框被抑制提高阈值结果不稳定排序不稳定或浮点误差检查argsort和IoU精度速度极慢候选框太多加置信度预过滤这张表是我实际调试时总结的基本上遇到问题对着查能省不少时间。特别是“结果不稳定”这一条有时候是因为两个框分数完全一样排序顺序不确定导致结果有微小差异。这种情况可以在排序时加个次级排序键比如按坐标排保证确定性。5. 从NMS到Soft-NMS一个自然的扩展5.1 硬抑制的问题在哪普通NMS是“硬抑制”——IoU超过阈值直接删掉分数归零。这个做法有个明显的问题如果两个真实目标确实挨得很近它们的框IoU天然就高硬抑制会把其中一个真目标干掉。密集场景下这个问题特别突出比如一群人站在一起NMS之后可能只剩几个人。这个问题的根源在于NMS假设“IoU高就是同一个目标”但现实中IoU高也可能是两个不同的目标挨得近。硬抑制没有给这种可能性留余地。5.2 Soft-NMS的核心改动Soft-NMS的思路很优雅不直接删框而是降低它的分数。IoU越高分数降得越狠。这样即使一个框被抑制了如果它本身分数很高降完之后可能还是比别的框高仍有机会被保留。公式上就是把原来的“分数置零”换成“分数乘以一个衰减因子”衰减因子通常是(1 - IoU)或者高斯形式。实现上改动很小主循环里把inds np.where(ious threshold)换成对所有框更新分数然后重新排序。但要注意Soft-NMS需要多轮迭代因为分数变了之后排序会变通常跑个几轮就稳定了。代价是速度比普通NMS慢一些但密集场景下效果提升明显。我实测下来在人群检测任务上Soft-NMS比普通NMS的召回率能高好几个点代价是推理时间增加大概20%到30%。如果你的任务对漏检敏感这个交换是值得的。5.3 什么时候该用哪种选择逻辑很简单目标稀疏、追求速度用普通NMS目标密集、追求召回用Soft-NMS。还有一个折中方案是调高普通NMS的阈值但阈值调高会引入更多误检不如Soft-NMS来得干净。另外提一句现在还有一些NMS的变体比如按分数加权融合框的或者考虑类别一致性的。这些都属于进阶玩法但只要你把普通NMS的轮子造明白了这些变体你都能看懂改起来也就是几行代码的事。造轮子的价值就在这里——底层通了上层随便换。6. 造完轮子之后的一点体会手写一遍NMS最大的收获不是代码本身而是对检测后处理整个链路的理解清晰了。你知道框是怎么来的、怎么排的、怎么筛的调参的时候心里有数出问题的时候知道往哪看。现成的库当然要用但用的时候你不再是黑盒调用而是知道里面在发生什么。我个人的习惯是任何核心算法至少手写一遍最小实现。不用写得多完美能跑通、能验证、能改就行。NMS是个很好的练手项目因为它足够简单半天就能搞定但涉及的知识点——排序、IoU、贪心、阈值调优——都是目标检测里的通用技能。最后分享一个小技巧写完NMS之后拿它跟torchvision.ops.nms对一下结果。同样的输入两者的输出应该基本一致浮点误差范围内。如果差很多说明你的实现有问题对着调。这个对拍的过程比看十篇教程都管用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。