NMS这个缩写,在物体检测领域几乎每天都要见到,全称是Non-Maximum Suppression,中文一般叫非极大值抑制。它不在backbone里,也不算loss的一部分,但只要是做检测,从经典的两阶段Faster R-CNN、单阶段YOLO,到最近很火的多模态大模型微调物体检测,输出端永远绕不开它。这篇文章不打算只讲表面流程,而是把NMS、softNMS、softerNMS、IoU-Net这五类后处理方案放在一起,从nms原理讲到每个改进方法的动机、公式、实现细节和工程调参经验。对于正在跑检测模型、想提升mAP但又不知道从哪里下手的人,这篇文章应该能帮你把后处理这笔账算清楚。
1. 从物体检测的输出结构讲起:NMS为什么必须存在
1.1 检测框的来源与天然冗余
一个检测模型在推理时,输出并不是“这张图里有哪些目标”这么干净,而是一大堆候选框。每个候选框包含四维坐标和若干个类别置信度,或者再加一个目标性得分。这些框的来源并不统一:两阶段模型里,RPN网络会在特征图上铺大量anchor,每个位置有多个尺度、多个长宽比,一个目标往往同时命中几十个anchor;单阶段模型直接在特征图每个点预测框,天然会产生密集覆盖;哪怕是DETR这类基于query的端到端模型,虽然去掉了anchor,但在困难样本上同一个目标仍然会对应多个query。所以候选框冗余是检测任务的结构性问题,不是某一种模型的bug。
既然一个目标对应了多个框,每个框又有不同的分数和位置精度,就需要一个后处理步骤来“合并同类项”,保留最合适的那个框,把多余的重叠框去掉。这个步骤不做的话,模型一次输出几百个框,评估指标mAP会很难看,因为同一个GT被预测了多次,precision会被压得很低。后处理看起来是小事,处理不好对最终AP的影响往往比换一个更强的backbone还要大,尤其是密集场景和边界模糊的目标。
1.2 NMS原理:一句话版本与完整流程
nms原理用一句话说就是:在所有候选框里,先挑分数最高的框保留下来,然后把所有和它重叠度过高的框都干掉,再在剩下的框里重复同样的操作,直到没有框可以选。这里的“重叠度”用IoU衡量,IoU是两个框交集面积除以并集面积,范围0到1,值越大说明两个框越像在描述同一个目标。IoU等于1代表完全重合,等于0代表完全没有重叠,这个指标本身也是后面所有NMS变体共同依赖的基础。
标准NMS的流程拆开看是这样:
- 按分数降序排列所有候选框;
- 取当前分数最高的框,加入保留列表;
- 计算它和其余所有框的IoU,把IoU大于阈值的框从候选集合里删除;
- 回到第二步,直到候选集合为空。
Python实现如下,这段代码是纯numpy的,适合理解逻辑:
import numpy as np def nms(dets, thresh): 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 = inter / (areas[i] + areas[order[1:]] - inter) inds = np.where(iou <= thresh)[0] order = order[inds + 1] return keep这段代码里,iou <= thresh的框会留下,等于说和当前最高分框重叠度超过阈值的都被抑制。实际使用时通常会按类别分别做NMS,因为不同类别的目标可以合法地重叠在一起,比如旁边的人抱着一只猫,人和猫的框重叠很正常。实现上可以用torchvision.ops.batched_nms一次做完按类NMS,省掉手工循环,后面第6章会专门说加速和算子选择。
2. 传统NMS的三大硬伤:阈值、密集场景与分数误导
2.1 IoU阈值是一把双刃剑
NMS最关键的参数就是IoU阈值,它直接决定“两个框有多像才应该被合并”。这个参数非常敏感:阈值调大,比如0.7,意味着只有高度重叠的框才会被删除,结果是框保留得多,误检增多,输出里会出现大量重复检测;阈值调小,比如0.3,又会让稍微重叠一点的不同目标被误删,漏检飙升。我见过不少同学拿到模型第一件事就是改NMS阈值,改成0.4、0.45、0.5,结果AP忽上忽下,最后依然不知道哪个更好。
正确做法应该是把阈值当成超参来做网格搜索。在验证集上,从0.3到0.7每隔0.05跑一遍NMS,观察AP曲线,通常会出现一个比较平缓的平台,选平台中间的值最稳。还要注意,不同数据集、不同模型的“最佳阈值”差异很大。比如anchor密集的模型和DETR类模型,候选框分布完全不同,后处理和分数分布也不一样,不能照搬别人论文里的参数。
2.2 密集遮挡场景:NMS最容易误伤目标的地方
如果画面里目标彼此挨得很近,比如超市货架上并排的罐头、人群中的行人、显微镜下的细胞,传统NMS就很容易翻车。原因很简单:两个不同目标的框IoU可能超过阈值,其中一个分数略低,就会被直接删掉。典型的情况是,一个目标被另一个目标遮住一大半,分数低一点的检测框本来定位是对的,但因为和分数更高的相邻框重叠过多被抑制,最终形成漏检。
这种情况在行人检测里尤其常见。行人经常成群出现,互相遮挡严重,早期行人检测论文里专门研究怎么改进NMS,softNMS就是在这样的背景下火起来的。密集场景下,把IoU阈值调低只会更糟,因为不同目标的框之间哪怕只重叠了三分之一,也会被当成同一个目标处理;调高又会让一个目标多个框被同时保留,输出混乱。所以传统NMS在密集场景下几乎是无解的,必须改后处理逻辑本身。
2.3 分类分数不等于定位质量
还有一个更隐蔽的问题:NMS本质上是拿分类置信度来代表框的质量,但分类分数高并不代表这个框定位准。模型可能对某个目标非常确信,但框偏了半个身位;另一个框IoU更高、定位更准,分类分数反而低一些。用分类分数做排序,很容易留下一个“高置信度但框偏了”的结果,把更准的框排挤掉。
这暴露了传统检测框架里分类和定位被耦合在一起的问题。mAP的计算要求框和GT的IoU达到一定阈值才算检测成功,如果留下的框定位不准,哪怕分类分数再高,AP依然上不去。后面要讲的softerNMS和IoU-Net,本质上都在处理这件事:要么把定位不确定性显式建模出来,要么直接预测IoU来指导后处理排序。
3. softNMS:把“删除”改成“衰减”
3.1 从二值惩罚到连续衰减
softNMS是2017年提出的经典改进,思路非常直接:传统NMS遇到和最高分框重叠度超过阈值的框,直接删掉,这是一个二值决策;softNMS则把“删除”变成“分数衰减”,重叠度越高的框,分数降得越狠,但不会直接消失。这样一来,如果一个框只是因为和最高分框重叠而被削弱,但它本身确实对应着另一个真实目标,只要分数没有降到最底部,后面仍有机会被选中。
这个改动的意义在于,NMS从“非黑即白”变成了“软惩罚”,减少了密集场景下的误删风险。原论文实验显示,在PASCAL VOC和MS COCO上,faster R-CNN等经典模型换上softNMS后,mAP通常能提升1到2个点,密集场景下收益更明显。最关键的是,softNMS不需要重新训练模型,任何已经训好的检测器都可以直接换后处理,这是它最大的工程价值。
3.2 两种衰减方式与实现代码
softNMS里有两种常用的衰减方式:
- 线性衰减:当IoU超过阈值
Nt时,score = score * (1 - IoU),IoU等于1时分数直接变成0。 - 高斯衰减:
score = score * exp(-IoU^2 / sigma),对所有与最高分框有重叠的框都会降分,IoU越大降幅越大,sigma控制衰减速度。
高斯版本对重叠度不那么高的框更宽容,实际效果普遍比线性版本更稳。原论文实验里,线性版本阈值Nt取0.3、高斯版本sigma取0.5可以作为一个起点,但具体数值还是要结合自己的验证集调整。
核心更新逻辑可以写成这样:
import numpy as np def soft_nms_update(scores, iou, gamma, method="gaussian"): if method == "linear": new_scores = np.where(iou >= gamma, scores * (1 - iou), scores) elif method == "gaussian": new_scores = scores * np.exp(-iou * iou / gamma) return new_scores实际完整流程和标准NMS类似:每次从当前集合里找最高分框,把它保留下来,计算它和其余框的IoU,再用上面的函数更新所有剩余框的score,而不是直接删除。重复这个过程,直到所有框都被选完或者剩余最高分低于设定的score阈值。因为每一轮都在动态更新分数,排序结果会随着衰减过程变化,所以实现时不能用一次性排序替代。
3.3 softNMS的收益边界和适用场景
softNMS不是万能的。它缓解了“误删”问题,但并没有解决“分类分数不等于定位质量”的问题,因为排序依据仍然是分类score。如果某个框分类分数虚高但定位很差,softNMS依然会优先保留它。另外,由于低分框不会被硬删除,输出候选框数量会比传统NMS多,score阈值需要适当提高,否则假阳性框会变多。
在实际项目里,softNMS最适合的场景是:检测模型已经训练好,不想改训练代码,且验证集上明显存在“目标挨得近导致漏检”的问题。先做一次NMS阈值网格搜索,如果发现最优阈值偏低(比如0.3左右),说明误删问题比较严重,这时候换softNMS通常能再涨一个台阶。注意,如果目标本身非常稀疏,每张图就一两个目标,softNMS的收益会很有限,没必要增加那点计算量。
4. softerNMS:让定位质量参与后处理
4.1 核心动机:网络不确定自己的框准不准
softerNMS和softNMS名字像,解决的问题却完全不同。softNMS处理的是“重叠目标被误删”,softerNMS处理的是“保留的框定位不够准”。如果把分类分数比喻成“这个框是不是一个目标”,那定位质量就是“这个框有多贴合目标”。这两件事是可以分开建模的,而传统检测框架往往只用分类分数来代表框的质量,这会带来一个明显问题:模型对目标很确信,但对边界框的位置可能并没有那么确信,尤其是遮挡边缘、运动模糊、小目标这些场景。
softerNMS的核心思想是让网络显式输出“我对这个框每个坐标的置信度”。具体做法是在回归头里额外预测每个坐标的方差,这个方差表示网络对这个坐标的不确定程度。推理时,方差小的框参与定位的权重更大,方差大的框权重更小,从而得到一个更稳、更准的最终框。
4.2 训练阶段:KL Loss与方差输出
要让网络学会输出方差,需要把原本的回归损失换成概率形式的损失。对每个坐标,假设网络输出的预测值x_e是高斯分布的均值,方差sigma^2由网络同时输出,回归目标x_gt是这个分布的一个采样点,那么负对数似然损失可以写成:
L_reg = log(sigma) + (x_gt - x_e)^2 / (2 * sigma^2)
这个式子很直观:当预测坐标偏离GT很远时,让方差变大,可以避免loss爆炸;当预测坐标很准,模型会倾向于把方差压小,拿这个坐标当作高置信度输出。实际训练时,网络通常直接输出log(sigma^2),再用exp映射回正数,避免方差出现负值。训练初期要让方差头保持较大的值,否则loss会不稳定,常见的做法是给方差分支一个较小的学习率,或者对GT坐标也引入一个很小的方差做平滑。
训练改动其实不大:在原有bbox回归头旁边并联一个方差输出头,loss加一项KL回归即可。但要注意,这个分支需要和检测head一起训练才能学到有意义的方差,已训练好的模型没法直接补一个方差头,也就是说softerNMS的落地成本比softNMS高。
4.3 推理阶段:方差加权投票
推理时,softerNMS并不改变候选框的筛选流程,一般还是先用softNMS或者传统NMS选出一组属于同一目标的候选框,然后对它们的坐标做加权平均,替代“直接保留最高分框”的硬选择。
加权公式大致是:
x_final = sum(score_i / sigma_i^2 * x_i) / sum(score_i / sigma_i^2)
权重由分类分数和方差的倒数共同决定。分类分数高说明“这个框大概率对应一个真实目标”,方差小说明“这个框的边界位置可信”,两者同时高,这个框才能主导最终的定位结果。反之,一个分数还行但方差很大的框,对最终坐标的贡献会被压得很低。
我自己的使用体验是,softerNMS在标注噪声比较大的数据集上收益最明显。比如外包标注的框质量参差不齐,或者小目标边缘本身模糊,网络回归方差会比较大,投票机制能把几个互相偏移的框平均出一个更稳的结果。但如果标注质量本来就很高、模型定位已经足够准,投票带来的提升会比较有限,反而增加了训练和推理的复杂度。
5. IoU-Net:用预测IoU替代分类分数做排序
5.1 解耦分类与定位:为什么需要IoU预测
IoU-Net的思路比softerNMS更直接:与其用分类分数去猜框好不好,不如直接训练一个分支,预测“当前这个框和真实目标之间的IoU”。这个预测值就是定位质量本身,排序和抑制都不再用分类score,而是用预测IoU。整个逻辑在两阶段检测器里很顺:RPN出来的proposal经过RoI池化后喂给分类和回归头,旁边再接一个IoU预测头,输入还是同一份特征,输出一个0到1的标量。
这个设计解决了一个根本问题:分类分数和定位质量的相关性并没有我们想象的那么强。我见过不少失败案例,模型对某个目标非常确定,分类分数高达0.95,但它在目标边缘上多框了一个背景区域,IoU只有0.5;另一个框分类分数0.6,但边界贴合得非常好,IoU有0.85。传统NMS会把前一个框当成“更高质量”的检测结果保留,后续的评估自然吃亏。IoU-Net直接绕开了这个误导。
5.2 IoU预测分支的训练与实现
IoU预测头的训练标签很好构造:当前候选框和它匹配到的GT框之间算一个真实IoU,这个值就是回归目标。损失函数可以用MSE或者BCE,输入特征是来自backbone的RoI特征,经过几层全连接之后输出一个标量。这个头可以和分类、回归头并行训练,也可以放到多任务学习的框架里一起优化。
论文里还配套提出了Precise RoI Pooling,用来消除RoI Align的离散量化误差。因为RoI Align在做双线性采样时会对坐标取整或做有限点采样,这个误差在普通检测任务里可能无所谓,但对IoU预测这种精度敏感的任务会有影响。所以IoU-Net在实现细节上对池化层的要求更高。如果只在标准检测模型上简单加一个IoU头而不换池化层,效果会打一些折扣。
5.3 IoU引导的NMS与定位精修
推理阶段的改动主要有两点。第一点,NMS排序依据从分类分数改成预测IoU,先处理“定位质量高”的框,而不是“分类置信度高”的框,这样可以避免高分类分但框偏了的候选抢跑。第二点,预测IoU可以作为最终检测分数的组成部分,比如和分类分数相乘,或者做一个带权融合,让最终排序更接近真实质量。
IoU-Net还有一个衍生技巧:用预测IoU对保留框做定位精修。因为在两阶段框架里,回归头已经预测了一个框偏移,IoU头可以判断这个偏移之后的结果到底好不好,如果预测IoU很低,说明回归头对这个proposal的输出并不可靠,可以选择继续迭代回归或者直接降低这个框的排名。这种“用预测质量反过来指导位置”的思路,和单纯做回归或者单纯做NMS相比,又往前走了一步。
5.4 NMS家族横向对比表
为了把几种方案放在同一张图里看清楚,我整理了一个对照表:
| 方法 | 需要重新训练 | 推理额外成本 | 主要解决 | 适用场景 |
|---|---|---|---|---|
| 标准NMS | 否 | 极小 | 重复框去重 | 通用基线 |
| softNMS | 否 | 低 | 密集重叠导致的漏检 | 已训练模型快速提升 |
| softerNMS | 是 | 中 | 保留框定位不准 | 定位精度要求高 |
| IoU-Net | 是 | 中 | 分类分数误导排序 | 新模型训练、追求整体AP |
从这张表能看出来,softNMS是“零成本换算法”,softerNMS和IoU-Net都是“训练阶段就要开始改造”。如果只是想在已有模型上快速涨点,softNMS几乎是唯一选择;如果要从零训一个新模型,并且希望后处理上限更高,IoU-Net或者softerNMS更值得投入。
6. 工程落地:选型、调参与多模态微调pipeline的NMS集成
6.1 后处理调参的正确顺序
很多人在训练检测模型时把精力全放在backbone、数据增强和loss上,后处理只用一个默认的NMS阈值,这是非常可惜的。后处理调参几乎是最便宜的涨点手段,成本几乎为零。我建议的顺序是:先固定一个用起来最顺手的NMS实现,比如torchvision.ops.nms;然后对分数阈值、IoU阈值、最大输出框数三个参数做网格搜索;最后再考虑是否需要换算法。
具体来说,分数阈值可以从0.05开始试,太低会留下大量低质量框,太高会漏检;NMS阈值在0.3到0.7之间按0.05步长搜索;最大输出框数需要结合任务看,小目标多的图可以放到300甚至500。还有个容易被忽略的点:如果对多个类别同时做NMS,别忘了一定要按类别操作,否则不同类别目标重叠时会被错误抑制。
6.2 加速与算子选择
NMS本身是一个非常小的算子,但在视频流和大batch推理时,累计耗时也不容忽视。PyTorch环境下最推荐直接用torchvision.ops.nms,它支持CUDA,单卡推理时基本没有瓶颈。如果输出检测框数量特别大,比如每张图几千个候选框,可以在NMS之前先按score做一次topk截断,比如保留前2000个框,这样可以显著减少后续排序和IoU计算的耗时,代价只是可能丢掉极少数低分真框,实际操作中我几乎不会因为topk截断掉点。
如果做TensorRT部署,建议直接用EfficientNMS插件,它把score阈值、IoU阈值、最大输出框数都集成在一个层里,还能自动完成按batch处理。EfficientNMS支持的排序方式通常只有按score硬排序,如果需要softNMS或者自定义IoU排序,就得在模型外部用host端逻辑实现,部署代价会变大。所以工程选型时还要考虑部署平台的算子支持,不能只看论文里的mAP。
6.3 qwen3-vl微调物体检测:VLM输出同样需要NMS
最近多模态大模型做物体检测越来越常见,比如用qwen3-vl微调物体检测,本质是把检测任务转成对话式生成,模型输出一段文本,里面包含框坐标和类别。但就算模型是生成式输出,同一个目标依然可能被多个query或多次采样生成相似的框,而且VLM的坐标通常是归一化数值,解码回原图分辨率后,这些重复框之间会因为文本量化和采样随机性产生小偏移。如果不去重,mAP会被同一目标的重复框拉低。
实际接入时,我的处理管线是:模型解码出结构化结果(类别、置信度、归一化坐标)→ 坐标反算到原图 → 按类别收集候选框 → 做NMS后处理。这里有个细节很容易踩坑:多模态模型输出的“置信度”往往不是校准良好的概率,直接拿它做NMS排序容易出问题。我的经验是先做一次score校准,比如在验证集上统计置信度分布后做一个单调映射;或者干脆用softNMS,它对置信度噪声的容忍度更高。在qwen3-vl微调物体检测的评测里,我把后处理从普通NMS换成softNMS,在目标较密集的自定义数据集上mAP提升了1个多点,而且不需要重新训练模型。
还要注意一个VLM独有的问题:模型可能会在说“图片里没有目标”的句子里也生成坐标token,解析时如果只按格式抽取,不做非空判断和类别过滤,后处理阶段会多出一堆假框。所以建议在进入NMS之前先做两层过滤:第一层过滤掉不在指定类别列表中的结果;第二层过滤掉置信度过低的输出。这样后续NMS的输入才比较干净。
6.4 方法选型速查
最后放一个选型速查表,可以根据自己项目的处境直接找对应方案:
| 你的处境 | 推荐后处理 |
|---|---|
| 训练好的常规模型,想快速提升mAP | 先调NMS阈值,再试softNMS |
| 密集遮挡场景 | softNMS;重新训练时可考虑IoU-Net |
| 定位精度/框质量要求高 | softerNMS或IoU-Net |
| 完全不想动训练代码 | NMS加调参,或softNMS |
| 多模态大模型微调物体检测 | softNMS加score校准 |
我在实际项目里被NMS坑过不止一次。早前做一个货架商品检测项目,模型在训练集上mAP涨得很好,一到现场就漏检并排的同类商品。当时第一反应是加数据、调backbone,折腾了好几版都没太大变化。后来查了一圈,发现是默认的NMS阈值0.5把所有重叠度稍高的不同商品当成一个目标处理了。换成softNMS之后,AP直接涨了2个点。这次之后我做任何检测项目,都会先把后处理三件套——score阈值、IoU阈值、最大输出框数——在验证集上扫一遍,再考虑要不要动网络。另一个小技巧是,如果同时在很多类别上做NMS,别写循环,直接batched_nms一次搞定,速度快不少。后处理看起来是“脏活”,但恰恰是性价比最高、最容易拿到免费涨点的地方。希望这篇文章能帮你把NMS这笔账算清楚,下次调模型少走点弯路。