最近在折腾检测模型后处理的时候,有好几个朋友问我:NMS到底是怎么把一个目标周围那一堆框收敛成一个的?其实NMS(Non-Maximum Suppression,非极大值抑制)这个算法,是目标检测里最不起眼却又最关键的一环。你训练再好的模型,后处理里NMS写不对、参数调不好,最终结果照样稀烂。这篇就把NMS从原理到工程实现、从标准版本到各种改进变体,完整梳理一遍。
1. 先搞懂NMS到底解决什么问题
检测模型跑完一次推理,经常会输出一"大把"检测框,尤其是在目标密集、重叠较多的场景里。很多刚入门的朋友第一次看到模型输出的可视化结果会被吓到:一个目标周围密密麻麻全是框,置信度从0.3到0.95都有。这些框大多围绕同一个目标产生,只有置信度最高的那个才是最合适的表达。
1.1 为什么模型会输出一堆冗余框
以anchor-based检测器为例,训练阶段会在同一目标的附近生成大量anchor,多个anchor都可能命中目标并产生高置信度的预测。anchor-free类检测器也存在类似问题:中心预测和尺度预测会在目标附近形成多个响应点,从而解码出多个高度重叠的框。
这些冗余框在训练阶段其实是"故意的"——让网络有足够的正样本学习,模型不会、也不需要在输出层强制只保留一个框。所以冗余框的清理工作必须放到推理阶段的后处理里完成,NMS就是干这个的。
1.2 NMS的核心思想:保留局部最大,干掉冗余重复
NMS本质上是一种局部极大值抑制策略。它的逻辑非常朴素:在一个目标的邻域范围内,置信度分数最高的那个框被看作"局部极大值";其余和它重叠度很高、但分数相对低的框,被当成描述同一个目标的重复候选,直接抑制掉。
如果你想找一个生活化的类比:你开了一场会,多个部门提交了同一份项目的总结报告,内容高度重合。你只会保留信息最完整、写的最准的那份(置信度最高),其余内容相似的报告直接归档(抑制)。重叠度怎么看?就是这份报告和那份报告的雷同程度,对应到算法里就是IoU(Intersection over Union,交并比)。
2. NMS完整计算流程与代码实现
标准NMS的算法流程网上版本很多,但很多贴出来的代码有几个隐藏的边界问题。这里我从步骤到代码完整走一遍,标出那些特别容易踩坑的地方。
2.1 标准NMS算法四步走
整个流程可以用四步概括:
- 输入一组检测框,每个框包括坐标信息和置信度分数。按分数从高到低排序。
- 从当前候选集合中取出分数最高的框,加入保留列表。
- 计算这个最高分框与剩余所有候选框的IoU,若IoU大于阈值,则将这些候选框直接删除。
- 从未处理的剩余框中重复步骤2和3,直到候选集合为空。
这里有一个极其容易搞错的点:每次"抑制"只拿当前最高分框去和剩余框比较,而不是拿所有已保留的框去和剩余框比较。如果拿多个已保留框去重复抑制,会把原本该保留的框误删,导致漏检。
2.2 用一个例子手工推演NMS
假设现在有5个检测框,坐标和置信度如下:
| 框编号 | 坐标(x1,y1,x2,y2) | 置信度 |
|---|---|---|
| A | (10, 10, 60, 80) | 0.90 |
| B | (8, 12, 58, 78) | 0.85 |
| C | (12, 8, 62, 82) | 0.80 |
| D | (100, 100, 160, 180) | 0.75 |
| E | (102, 105, 158, 185) | 0.70 |
第一步,按置信度排序,当前顺序是A、B、C、D、E。
第二步,取A,加入保留列表。计算A与剩余框的IoU:A与B重叠严重(IoU约为0.75,大于阈值0.5),B被抑制;A与C重叠也很严重(IoU约为0.62),C被抑制;A与D几乎没有重叠(IoU约0.0),保留D;A与E同样无重叠,保留E。
第三步,候选集合中剩下D和E。取D,加入保留列表。计算D与E的IoU,假设约为0.61,也超过阈值,于是E被抑制。
最终保留A和D,正好是两个独立目标各保留了一个框。如果你拿A、D同时去和其余框做多次抑制,就可能误删某些分数不低但实际属于另一个目标的框,这点在密集场景中尤其致命。
2.3 可运行的Python实现与细节说明
先给一个足够清晰、适合深入理解的Python实现,使用NumPy加速:
import numpy as np def nms(dets, iou_threshold=0.5): """ dets: 二维数组,每行格式为 [x1, y1, x2, y2, score] iou_threshold: IoU阈值 """ if len(dets) == 0: return [] x1 = dets[:, 0] y1 = dets[:, 1] x2 = dets[:, 2] y2 = dets[:, 3] scores = dets[:, 4] # 计算每个框的面积 areas = (x2 - x1 + 1) * (y2 - y1 + 1) # 按置信度从高到低排序 order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(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:]]) # 计算交集面积 w = np.maximum(0.0, xx2 - xx1 + 1) h = np.maximum(0.0, yy2 - yy1 + 1) inter = w * h # 计算IoU iou = inter / (areas[i] + areas[order[1:]] - inter) # 仅保留IoU小于阈值的框 idx = np.where(iou <= iou_threshold)[0] order = order[idx + 1] return keep这里order[idx + 1]是新手最常见的坑:iou数组是和order[1:]对应的,idx找到的是iou数组中的位置,需要加1才能映射回整个order数组里对应的索引。写错过一次就会理解为什么反复提醒要小心索引错位。
3. 关键参数与真实工程中的选型考量
NMS说简单,代码几十行就写完,但真正在项目里用起来,参数怎么定、代码怎么写更高效,都能直接影响最终效果。这一章把关键参数和工程侧选型讲透。
3.1 IoU阈值怎么选
IoU阈值是NMS最核心的超参。阈值设得太低,抑制过度,两个高度重叠但实际属于不同目标的框会被误删,表现为漏检;阈值设得太高,抑制不足,大量冗余框残留,表现为重复检测。
我在实际项目中的经验参考:
| 场景 | 推荐IoU阈值 | 说明 |
|---|---|---|
| 通用目标检测(COCO) | 0.4~0.5 | 平衡精度和召回率,COCO官方多任务评测也默认取0.5附近 |
| 密集场景(人群/车辆拥堵) | 0.5~0.6 | 目标紧密相邻,需要更宽容的IoU阈值保留邻接目标 |
| 高精度小目标 | 0.3~0.4 | 小目标本身重叠度高,需要更严格的抑制避免重复 |
| 多类别混合 | 按类别分别做NMS | 不同类别之间不应互相抑制,通常每个类别单独计算 |
需要特别提醒的是:用COCO mAP作为指标评估时,mAP@[0.5:0.95]是不同IoU阈值下的平均表现,但这和NMS内部的IoU阈值不是一回事。NMS在推理阶段是"后处理步骤",mAP评测是对最终框和真值框算IoU匹配,两者的含义完全不同,千万别混。
3.2 置信度阈值与NMS的配合关系
通常NMS之前会先做一次置信度过滤,把分数很低的框直接丢弃。这个阶段的目的是减少NMS输入规模。最合理的配置是:先设一个较低的置信度阈值(比如0.02或0.05),再做NMS,最后按业务需求决定要不要用更高的置信度阈值做最终输出筛选。这样既不会误删有效框,又不会把大量无效框送进NMS。
我在YOLO系模型上常用的组合是:置信度粗滤0.01,NMS IoU阈值0.5,最终输出置信度阈值0.25或0.3。这样前两步保证了候选框的覆盖量,最后一步精确控制输出的框数量。
3.3 多类别场景:分别做还是统一做
多数检测模型输出多个类别,正确的做法一般是每个类别分别做NMS。因为不同类别的检测框即使空间重叠严重(比如一个人旁边停着一辆车),也不应该被抑制。
但也有例外情况,某些细粒度任务希望一个目标只对应一个类别标签。比如同一物体被同时检测为"车辆"和"卡车",你希望只保留分数最高的那个类别的框。这时可以做跨类别NMS,让不同类别的重叠框互相抑制,只留下分数最高的类别。这种需求在OCR字符识别中也很常见:同一个字符可能被多个类别同时命中,直接跨类别NMS可以得到更干净的识别结果。
4. 标准NMS的缺陷与主流改进方案
标准NMS在大多数场景够用,但它确实存在几个被讨论很多的问题。这一章逐一说明,再介绍业界常用的改进变体,方便你按实际需求选型。
4.1 标准NMS的三个明显痛点
第一个痛点是"一刀切"的抑制策略。两个高度重叠的框,只要IoU略大于阈值,低分框直接被删除。但低分框的边界可能反而更精确,此时直接删掉会降低定位质量。
第二个痛点是密集目标漏检。目标紧密排列时,IoU很高但实际属于不同目标,标准NMS会误删其中一个目标,这是大批场景的召回率瓶颈。
第三个痛点是阈值敏感。不同图片里目标密度差异巨大,全局同一个IoU阈值难以同时兼顾稀疏场景和密集场景。
4.2 Soft-NMS:改"硬抑制"为"软衰减"
Soft-NMS不再直接删除重叠框,而是按IoU大小降低其置信度。IoU越大,置信度扣减越多。这样就给了低分重叠框一线生机:如果它确实是另一个目标的框,只是和当前框重叠较高,分数被压低后依然可能在最终结果中保留。
Soft-NMS有两种常见的衰减方式:
- 线性衰减:分数乘以(1 - IoU),重叠越多衰减越狠。
- 高斯衰减:分数乘以高斯函数(e^{-IoU^2 / \sigma}),衰减更平滑。
Soft-NMS大概率会小幅提升密集场景的召回率,但会带来轻微推理耗时增加和额外的超参调节成本。我的经验是:如果标准NMS漏检集中在密集重叠区域,优先试Soft-NMS,改动小、见效快。
4.3 DIoU-NMS等更多改进思路
DIoU-NMS在IoU的基础上引入中心点距离信息。它把"重叠比例"和"中心距离"共同纳入抑制条件:如果两个框中心点距离较远,即使IoU较高,也不立即抑制。这能显著改善密集场景下相邻目标的漏检。
加权NMS不直接删框或降分,而是把多个高重叠框按置信度进行坐标加权平均,合成一个质量更高的框。适合定位精度要求极高的业务,比如医学图像中的病灶检测。代价是增加了计算量和实现复杂度。
4.4 不同改进方案的适用场景对比
| 方法 | 核心思路 | 适用场景 | 缺点 |
|---|---|---|---|
| 标准NMS | 直接删除重叠框 | 通用目标检测 | 密集场景漏检、边界质量差 |
| Soft-NMS | 降低重叠框分数 | 密集场景提召回 | 增加调参难度 |
| DIoU-NMS | 加入中心距离判断 | 相邻目标重叠 | 计算量略高 |
| 加权NMS | 多个框加权合并 | 高精度定位 | 实现复杂、速度慢 |
| 融合NMS/类间NMS | 跨类别统一抑制 | 细粒度分类 | 可能误删有效类别 |
选型建议:项目起步先用标准NMS作为基线,观察漏检原因。如果漏检集中在目标重叠,优先试Soft-NMS和DIoU-NMS;如果定位精度不足,再考虑加权NMS。工程部署阶段还要考虑推理引擎是否原生支持某种NMS变体,否则自定义算子的性能损耗可能超过算法收益。
5. 从目标检测到其他视觉任务:NMS的延伸应用
NMS的影响力不只停留在目标检测里。很多视觉任务的后处理阶段都在用"局部极大值抑制"的思想,只是形式略有不同。
5.1 目标检测:最经典的主战场
YOLO、SSD、Faster R-CNN、FCOS、DETR等主流检测器都绕不开NMS。哪怕是DETR这种基于Transformer的端到端检测器,理论上已经通过集合预测避免了重复框,但很多工程落地时依然会叠加一个轻量NMS做二次兜底。原因很简单:推理阶段的预测结果依然存在小概率重复框,加上NMS能让指标更稳定,错觉是榜单好看了、但实际部署帧率下降约1%-3%,要根据业务权衡是否保留这个"保险"。
5.2 人脸关键点与文本检测中的NMS使用
人脸检测经常输出带关键点的检测框,NMS抑制掉冗余框之后,关键点也要随之清理,否则同一个人的关键点会出现多次预测。通常的做法是:先做框级别的NMS,再用保留的框索引去选取对应的关键点,保证"一个框对应一组关键点"。
文本检测和OCR场景中文字密集,文本框高度重叠、形状复杂,标准NMS容易误删相邻文本行。业界常用"按文本行聚合后再NMS"或"多边形NMS"来处理不规则文本框,同时要避免把相邻行的框错误抑制。这个场景里,NMS的IoU阈值通常要调低,并且要更关注召回率而不是精确率。
5.3 3D目标检测与多目标跟踪场景
自动驾驶领域的3D目标检测需要对3D框计算IoU,涉及空间几何运算,比2D NMS计算量大很多。工程实现常先根据距离或类别粗滤,再做3D NMS。远处目标和近处目标密度差异大,常常需要按距离分段设定不同的IoU阈值。
多目标跟踪领域,NMS通常作为检测后处理的一部分。先通过NMS得到干净的检测框,再送入关联算法(比如ByteTrack、BoT-SORT等)做帧间匹配。最近不少跟踪方案特意保留低分检测框供跟踪匹配使用,于是"是否在NMS阶段删除低分框"也成了跟踪效果的关键因素。这里建议把NMS的置信度粗滤阈值降低,让更多低分框进入跟踪关联,再通过跟踪逻辑筛除真正无用的框。
5.4 边缘检测、关键点检测等更多场景
边缘检测里的非极大值抑制大家肯定不陌生:Canny边缘检测就在梯度幅值图上沿梯度方向做局部极大值抑制,细化了边缘。这和检测框NMS思路一致,只是作用的目标从"框"变成了"像素梯度值"。
关键点检测(比如AlphaPose、OpenPose的姿态估计)会在热度图上寻找峰值响应点。热度图上一片区域可能有多个局部峰值,同样用NMS思想找到最强响应点,抑制周围的弱响应。很多人只关注检测框NMS,其实关键点NMS在姿态估计中同样重要。
6. 工程踩坑记录与排查思路
最后这部分是我自己想着重写的实战心得。NMS代码不长,但工程中出的问题往往都不在算法本身,而是藏在数据、索引、实现细节里。
6.1 五个高频坑位,我挨个踩过
- 坐标格式不统一:有的模型输出xyxy,有的输出xywh,有的输出归一化坐标。NMS入口必须统一到同一种格式,否则IoU全是错的。
- 边界溢出坐标未裁剪:某些框解码后会超出图像边界,宽高为负,IoU计算出现负数或异常放大。NMS前先对所有坐标做clip到[0, W/H]范围内。
- 多类别NMS遗漏:前面提过,跨类别统一做NMS会误删不同类别目标。需要按类别分组或指定类别列表逐个处理。
- 浮点误差导致计算结果抖动:两个高度重叠的框,IoU非常接近阈值时,细微的浮点误差可能导致框被删或不被删,结果不稳定。解决方案是阈值比较用
<=和>=保持统一,并增加极小的epsilon。 - 排序稳定性:分数相同时,不同排序算法得到的框顺序可能不同,导致NMS结果抖动。建议排序时增加第二关键字(比如x1坐标或下标),保证顺序稳定可复现。
6.2 NMS速度优化方案
当检测框数量很多时,NMS是后处理阶段不可忽视的性能瓶颈。毫秒级耗时在端侧设备上很致命。
工程上最实用的方案:
- 先做Top-K截断,只对置信度最高的前200~300个框做NMS。绝大多数场景有效框都排在前列,截断几乎不影响精度。
- 用向量化实现的框架自带NMS,比如Python里使用NumPy版本、PyTorch里使用
torchvision.ops.nms,不要自己写循环式纯Python版本。 - 端侧部署可使用ONNX Runtime或TensorRT提供的NMS算子和EfficientNMS插件。
- 多类别NMS尽量用矩阵化并行处理,避免纯for循环逐类别跑。
6.3 阈值到底怎么调,我总结了一个可复用的调参方法
不要凭感觉定阈值。我在项目里一般用"扫描法":先在验证集上固定置信度阈值,把IoU阈值从0.3扫描到0.7(步长0.05),画出mAP或F1曲线,选曲线最高点对应的IoU阈值。
然后固定IoU阈值,再扫描置信度阈值(0.1到0.5,步长0.05),观察精确率和召回率的变化趋势,找到业务可接受的均衡点。这个方法麻烦一点,但比拍脑袋定参数靠谱得多。
6.4 一个困扰很久的隐蔽问题:NMS和类别不平衡的关系
最后补充一个很容易被忽略的工程问题:模型类别极度不均衡时,少量类别分数很高、多数类别分数普遍偏低。如果全局只用高分类别做NMS初始框,低分类别的目标容易被误删。这时需要按类别分别做NMS,并且每类别单独设定置信度粗滤阈值,而非统一用一个全局阈值。
比如我做过一个工业质检项目,良品类别分数普遍在0.9以上,瑕疵类别分数普遍只有0.3到0.6。全局阈值0.5时,瑕疵框基本都被NMS前过滤光了。改成按类别设定阈值后,瑕疵类别用0.2粗滤、NMS IoU阈值0.4,检出率立刻好转。
NMS这个算法,算法流程本身确实不难,但真正决定效果上限的往往是你对场景的理解和对细节的把控。从标准NMS到Soft-NMS、DIoU-NMS,再到工程化的速度优化和调参策略,每一步积累起来,才能让检测管线在真实业务里又准又稳又快地跑起来。