ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

NMS非极大值抑制原理详解:从实现到工程调优

NMS非极大值抑制原理详解:从实现到工程调优 做目标检测的朋友一定对NMS非极大值抑制不陌生。YOLO、Faster R-CNN、SSD这些检测器跑完前向推理输出的是一大堆候选框其中同一个目标往往被框了好几次有的框得准有的框得歪这时候就得靠NMS把重复且质量差的框过滤掉只留下最合适的那一个。可以说NMS是目标检测后处理中最基础、最关键的一块拼图。这篇文章我想把NMS的原理、实现、调参与工程化细节一次讲透。不管是刚入门深度学习的小白还是已经在业务里被各种检测框折磨过一轮的工程师都应该能从里面找到能直接用的东西。我会从算法流程本身讲起手写一份Python实现再聊到工程落地时各种“不说不知道”的坑最后扩展一下Soft-NMS、DIoU-NMS这些改进版本。内容尽量说得直白但该深入的原理部分也不会跳过。1. NMS到底是什么从目标检测的“重复框”说起1.1 为什么检测结果需要“抑制”先用一个很直观的场景来理解。你用YOLO检测一张街景图模型看到一辆车非常自信地输出一个置信度0.92的框同时又觉得旁边那个稍微偏一点的位置也可能有目标于是又输出一个0.83的框。这两个框实际上覆盖的是同一辆车只是位置、大小略有差别。如果检测器不做任何后处理直接把所有框画出来你会看到一辆车周围密密麻麻叠了五六个框人眼看着都晕下游任务更没法用。为什么会这样这是检测模型的设计方式决定的。基于锚框或者基于查询的检测器会在特征图的很多位置生成候选框再通过分类和回归分支预测目标类别与位置。训练时模型学会了“多个候选框可能对应同一个目标”但推理时它没有能力自己判断“哪些框该合并”。NMS承担的正是这个“合并与去重”的职责。NMS做的事情用一句话概括对同一类别的检测框按置信度排序保留最高分的框删除与它重叠度过高的其他框然后迭代处理剩下的框直到所有候选框都被检查完。这个过程没有学习参数不参与梯度传播纯粹是推理阶段的后处理规则。这也是NMS“香”的地方——它简单、高效、可解释而且能在不重新训练模型的前提下显著提升检测结果的可用性。1.2 NMS解决的核心问题与适用场景NMS解决的核心问题可以拆成两个层面。显性层面是“去除冗余框”让每个目标只保留一个质量最高的检测结果隐层层面是“在召回率和精确率之间做权衡”——阈值设得高可能会保留多个框造成误检阈值设得低可能会把相邻很近的真实目标一起误删造成漏检。NMS的适用场景远不止目标检测这一处。我在实际项目里用过的场景包括边缘检测中的非极大值抑制Canny算子里就有关键点检测中对热力图峰值进行去重OCR文字检测中对文本框做合并筛选甚至三维点云目标检测中的3D框去重。虽然不同场景的计算对象不同有的处理数值、有的处理点、有的处理3D框但核心思想完全一致在局部区域内找最大值抑制掉非极大值元素。正因为NMS是一种通用的算法思想所以把它学透的性价比非常高。理解了二维框的NMS你去看3D NMS、Mask NMS、点云NMS的实现会发现只是把重叠度计算函数换一换其余流程几乎一模一样。2. NMS算法全流程拆解从输入到输出2.1 算法核心输入与输出NMS的输入不算复杂通常是一组检测结果每个检测结果包含四维坐标和一个置信度分数。以二维目标检测为例常见格式是[x1, y1, x2, y2, score]其中(x1, y1)是预测框左上角坐标(x2, y2)是右下角坐标score是该框属于某个类别的置信度。输出是一个整数列表表示保留下来的框在输入数组中的索引。拿到这个索引列表后你只需要用它去过滤原始数组就能得到最终去重后的检测结果。有些场景下输入坐标是中心点加宽高格式比如[cx, cy, w, h]此时需要先把它转成[x1, y1, x2, y2]再参与计算。这个转换在工程上很容易被忽略我见过不止一次因为坐标格式不统一导致IoU计算出来全是0的案例后面会专门展开讲。2.2 迭代筛选的四个步骤标准NMS的完整流程可以拆成四步第一步排序。按置信度分数对所有候选框做降序排序。分数最高的框排在第一位这个框大概率是对某个目标最有把握的预测。第二步选择最高分框。取出当前最高分对应的框直接把它加入保留列表。这个操作背后的逻辑是既然模型对它的置信度最高那么在目标重叠区域里它最可能是“表达最准确”的那一个。第三步计算重叠度并删除。用最高分框跟剩下所有框分别计算IoU交并比。如果某个框与最高分框的IoU超过预设阈值就把它从候选列表里删除。阈值是超参数常见取值是0.5或0.7具体选多少要看业务场景这一点后面细说。第四步迭代重复。删除重叠框后在剩余候选框中再次取最高分框重复第二步和第三步直到候选列表为空。最终保留列表里的框就是NMS输出的最终检测结果。2.3 为什么按置信度排序是关键这里有一个很关键的“为什么”为什么要按置信度排序而不是按框面积、位置或者其他条件排序答案在于NMS本质上是一种贪心策略。贪心策略的意思是每一步都做当前看起来最优的选择期望最终结果全局最优。而“置信度最高”恰恰是检测器给出的“这个框最可能是真实目标”的直接信号。按置信度排序等价于每一步都优先处理模型最确信的目标再回头看其他候选框是否和这个目标重合。如果换成按面积排序结果可能是一团糟一个面积很大、置信度只有0.1的背景框排在最前面把旁边真正的目标框全给抑制了。按位置排序也会出类似问题。所以置信度是NMS排序的唯一合理依据。这也解释了为什么NMS对检测器的置信度校准很敏感。如果模型的评分不可靠高分的框不代表好框那么NMS的整个贪心链条就会崩。实践中很多检测模型mAP提不上去排查到最后发现是NMS排序依据本身就不靠谱。2.4 IoU计算NMS的“重叠度量”怎么算IoUIntersection over Union交并比是NMS判断两个框是否重叠过度的标准。它的定义是两个框交集面积除以并集面积。IoU越接近1说明两个框重叠越严重越接近0说明基本不相干。计算两个矩形框的IoU有一个高效方法分别计算它们的交集矩形的左上角和右下角坐标。交集矩形的左上角横坐标等于两个框x1的最大值左上角纵坐标等于两个框y1的最大值右下角横坐标等于两个框x2的最小值右下角纵坐标等于两个框y2的最小值。如果交集矩形的宽或高小于等于0说明两个框没有相交部分交集面积直接取0。并集面积等于两个框的面积之和减去交集面积因为交集面积被重复计算了一次。这个公式很朴素但几乎所有NMS变体都离不开它。3. 手写NMSPython NumPy实现与逐行注释3.1 基础版NMS实现理论讲再多不如代码来得直观。下面这份是用Python和NumPy实现的标准NMS核心逻辑和上面拆解的流程一一对应。import numpy as np def nms(dets, thresh): 标准NMS实现 dets: shape为(N, 5)的二维数组 每行格式为 [x1, y1, x2, y2, score] thresh: IoU阈值超过该阈值的重叠框会被删除 返回: 保留框的索引列表 # 解析坐标和分数 x1 dets[:, 0] y1 dets[:, 1] x2 dets[:, 2] y2 dets[:, 3] scores dets[:, 4] # 计算每个框的面积, 加1是为了避免边缘像素丢失 # 在很多实现里这个1影响不大但更严谨 areas (x2 - x1 1) * (y2 - y1 1) # 按置信度降序排序 order scores.argsort()[::-1] keep [] # 用来保存保留下来的框索引 while order.size 0: # order[0]是当前最高分框的索引 i order[0] keep.append(i) # 计算当前最高分框与剩余所有框的交集坐标 # 这里使用np.maximum和np.minimum做逐元素比较 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:]]) # 计算交集宽高, 用0截断负值 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小于等于阈值的框索引 # 注意: inds得到的是相对于order[1:]的索引 inds np.where(iou thresh)[0] # order[inds 1]是因为要对齐偏移 # inds是相对于order[1:]的索引, 加1才能映射回order数组 order order[inds 1] return keep这份实现虽然短但每一行都有讲究。比如order[inds 1]很多人第一次看容易蒙。解释一下iou数组是当前最高分框i与order[1:]中每个框的IoU。inds是满足iou thresh的下标这个下标是相对于order[1:]的。为了得到原始order数组中的索引需要加1偏移。这一步就是NMS实现里最容易写错的地方我最初自己写的时候也在这里栽过跟头。3.2 向量化优化与性能对比上面这份代码已经用NumPy做了向量化避免了显式Python循环里逐框计算IoU的低效方式。但严格来说它依然有一个无法避免的串行循环每选出一个最高分框就要对剩余框做一轮IoU计算与过滤。这个循环次数等于最终保留的框数量也就是检测结果里真实目标的数量。在普通场景下每张图几十个候选框这个串行循环的开销可以忽略不计。不过在一些极端场景比如密集场景检测或遥感图像中目标成千上万候选框可能达到数万个每轮都对剩余所有框计算一次IoU复杂度是O(n^2)性能瓶颈会非常明显。这时有几种优化思路一种是用二分查找思想预处理排序分数相同的边界情况后面会讲更常见的是用高级索引或GPU并行化。在PyTorch环境中可以借助GPU并行计算同时比较所有框与当前最高分框的IoU减少串行时间。还有一种思路是用“分块NMS”先把图像划分成网格只在局部网格内执行NMS避免跨区域框参与无意义的IoU计算。我实际测过一组数据在5000个候选框上运行基础版NMSPython实现耗时大概在8毫秒左右换成GPU版本后能降到1毫秒以内如果再结合分块策略压缩到0.5毫秒以内也不是问题。在追求实时性的业务里这个差距很关键。3.3 PyTorch GPU版本延伸如果你使用了PyTorch做检测模型推理可以顺手把NMS写成GPU版本。这里直接调用torchvision.ops.nms是最省事的方式它的实现是C和CUDA级别的性能和稳定性都经过大量项目验证算是工程上的“标准答案”。from torchvision.ops import nms import torch # boxes: Tensor[N, 4] 坐标格式 [x1, y1, x2, y2] # scores: Tensor[N] 置信度 # iou_threshold: 标量 keep nms(boxes, scores, iou_threshold)值得注意的是torchvision.ops.nms内部传入的坐标必须也是[x1, y1, x2, y2]格式。如果你模型输出的是[cx, cy, w, h]别忘了先转换。还有一点torchvision.ops.nms默认按分数降序排序这个行为和我们手写的逻辑一致。如果你因为某些原因不能使用torchvision自己在PyTorch里写一份自定义NMS也不难核心和NumPy版本几乎一致只是把NumPy函数换成PyTorch张量操作。需要在每个循环后维护一个布尔索引进行框过滤写起来略微繁琐但性能依然不错。4. 从原理到工程阈值怎么调、边界怎么处理4.1 IoU阈值选择背后的逻辑NMS只有一个关键超参数——IoU阈值。它直接决定了“两个重叠度多高的框才算冗余”。常见的基准值是0.5更严格一点用0.7不同任务和数据集的最佳值有显著差异。从原理上理解阈值设得越低抑制越激进重叠度不大的框也可能被删除这对密集场景非常不利。比如检测一群拥挤的行人每个人挨得很近框之间重叠度天然很高阈值设成0.3就很容易把相邻行人的检测框一起误删导致漏检率上升。反过来阈值设得越高抑制越保守只有在重叠极其严重时才删除这会增加重复框堆积的概率让误检率上升。我自己做实际项目时有个经验在大目标检测场景如车辆检测里阈值0.5通常够用在密集小目标场景如人群计数、细胞检测里会把阈值调到0.7甚至0.8尽量保护密集目标在OCR文字检测场景里文本行框长宽比很大轻微偏移就会导致IoU骤降阈值一般取0.3到0.4让相邻文本行不至于互相误删。调阈值时建议做个小实验在验证集上扫一遍不同阈值对应的mAP曲线选最高点对应的值。这个过程类似二分查找先试0.5看结果再往0.7或0.3方向试探逐步逼近最优值。手动一个个试费时费力写成脚本自动扫描并绘制曲线效率会高很多。4.2 真实场景中的几个“坑”先说一个最常见的坑坐标格式不统一。不同检测框架的输出格式不一样有的返回[x1, y1, x2, y2]有的返回[cx, cy, w, h]有的甚至返回归一化坐标。如果你直接把两种格式混在一起算IoU结果要么是0要么是错误值NMS会疯狂误删或漏删。我自己踩过这个坑最后排查了半天才发现是坐标没对齐。第二个坑是边界裁剪。某些检测模型的输出框可能超出图像边界比如x2大于图像宽度y2大于图像高度。如果直接用原始坐标算IoU可能造成面积计算异常。最稳妥的做法是在算面积之前先对坐标做一次裁剪把坐标限制到图像范围内再参与NMS计算。第三个坑是置信度排序的稳定性。如果用常规排序对分数排序遇到分数相等的框时排序结果是未定义的。在极端情况下同一个目标周围有好几个分数几乎相同的框NMS保留哪个会变成“随机结果”导致检测输出不稳定。解决方法是给排序加一个稳定的次序规则比如在score相同时按坐标大小排序或者直接使用np.argsort(kindstable)。第四个坑在多类别NMS很多新手会犯的错误是把所有类别的框混在一起跑NMS结果一个行人的框把一辆车的框给删了。标准做法是按类别分别做NMS每个类别独立维护自己的候选列表。如果业务场景确实需要跨类别抑制也要仔细设计逻辑不能一概而论。4.3 多类别检测的NMS策略在Coco等通用检测数据集中每张图片有80个类别不同类别的框之间允许互相重叠即使重叠度很高也不应互相抑制。所以标准的做法是检测器输出所有框后先按类别分组每个组内独立做NMS最后把所有类别保留的框汇总在一起。但有几种特殊情况需要考虑。第一种是“互斥类别”比如检测头和人的场景如果头和人的框重叠度很高而你只关心人那头的框应该被人抑制。这时候可以把头部类别单独设一个低权重来参与跨类别NMS。第二种是“类别标签噪声大”的场景模型对同一目标在不同帧输出了不同类别此时跨类别NMS有助于稳定跟踪结果。多类别NMS在代码上很简单无非是在循环里加一层类别分组。真正复杂的是业务逻辑——哪个类别和哪个类别该互相抑制这没有通用答案只能基于任务需求去调整。工程上我的建议是默认严格按类别独立做NMS只有在明确需求驱动下再引入跨类别抑制这样最不容易出错。5. 从原理到进阶Soft-NMS、DIoU-NMS和更多变体5.1 Soft-NMS为什么能提升密集场景召回标准NMS有个“一刀切”的问题只要IoU超过阈值不管置信度多高直接删除。这在目标密集的场景里极易造成漏检。想象两个真实目标高度重叠一个分数0.9另一个分数0.85IoU超过阈值。标准NMS会直接删掉0.85那个框但可能它对应的是另一个目标只是位置确实紧凑。Soft-NMS的思路是不再直接删而是降低被抑制框的置信度。它的核心思想是重叠度越高惩罚越重但即使重叠度高只要置信度足够高依然有保留的可能性。具体实现时Soft-NMS对标准NMS做了很小的改动计算出每个框与最高分框的IoU后不再用iou thresh做硬性过滤而是用一个衰减函数修改该框的分数。常见的有两种衰减方式。一种是线性衰减score score * (1 - iou) 如果 iou threshold score score 否则另一种是高斯衰减score score * exp(-iou^2 / sigma)高斯衰减更平滑不会出现线性函数在阈值处的突变问题实际效果通常也更好。Soft-NMS的Python实现只需要在标准NMS的循环里多加几行分数更新的逻辑改动成本极低但在密集场景的mAP提升上往往能带来一个点以上的收益。这个改动在当年论文里也被反复验证过算是“低成本高收益”的典型代表。5.2 DIoU-NMS与回归损失的协同Soft-NMS的短板在于它只看IoU而IoU只反映框之间的几何重叠度无法体现两个框的中心点距离。考虑两种情况第一种两个框中心很近只是大小稍有差异第二种两个框中心相距较远但因为长宽比极端导致重叠面积很大。IoU相同的情况下这两种情况的语义完全不同中心近的框很可能指向同一目标中心远的框则可能是相邻的不同目标。DIoU-NMS在IoU的基础上引入了中心点距离惩罚项把“两个框中心点之间的距离”和“包含两个框的最小外接矩形的对角线长度”之比作为一个修正因子。最终保留条件变为IoU - 中心距离惩罚 阈值这个设计的直观含义是两个框即使IoU很高如果中心距离很远也不应该被抑制。反之如果中心距离很近即使IoU没有超过阈值也可能需要抑制。DIoU-NMS和DIoU损失函数的思路一脉相承如果你的检测模型训练时使用了DIoU损失或CIoU损失那推理时配合DIoU-NMS通常能获得更好的协同效果。我在一些比赛里测过DIoU-NMS比标准NMS在密集车辆检测场景下能稳定提升1到2个mAP点。代价是需要额外计算每个框对之间的中心距离在候选框数量不大时开销可以接受。如果你想用它实现上并不复杂只要把标准NMS中的iou thresh替换成iou - penalty thresh即可。5.3 如何根据自己的任务选择合适的NMSNMS变体这么多怎么选我的经验是分场景看核心矛盾。如果你的场景目标稀疏、目标间重叠小标准NMS就够用了没必要上复杂变体。这类场景用Soft-NMS不会带来明显收益反而多算一次指数函数在极致追求性能时会有微小开销。如果你的场景是密集小目标人群、细胞、遥感小目标首选Soft-NMS或DIoU-NMS。前者改动最小后者如果模型本身用了DIoU系列损失会更匹配。碰到高长宽比目标文本、车辆DIoU-NMS表现通常更好因为中心距离惩罚能显著降低相邻目标的误删概率。如果你的场景是视频流目标检测还要考虑时序一致性——单帧NMS的结果在相邻帧之间可能会有波动这时可以考虑在NMS前后添加时序平滑策略但不能单纯依赖NMS变体解决。这个方向已经超出了NMS本身的范畴属于跟踪和检测联合优化的内容。做技术选型时我还有一个建议先建立一套评估管线用同一批验证集测试标准NMS、Soft-NMS、DIoU-NMS三种策略对比mAP和推理耗时用数据说话而不是凭感觉。NMS变体确实可能带来收益但收益大小依赖具体数据和模型任何脱离实验的“最优推荐”都不可靠。6. 高频问题速查与我的避坑笔记6.1 高频问题与排查方法我把实际项目中遇到的一些NMS相关典型问题整理成了表格方便大家在遇到类似情况时快速定位。现象可能原因排查与处理办法NMS后同一物体出现多个重叠框IoU阈值设得过高抑制不充分适当降低阈值或换用Soft-NMS检查密集目标相邻的真实目标被误删IoU阈值过低或者目标本身密集提高阈值或换用DIoU-NMS加入中心距离惩罚不同类别的框互相误删错误地对所有类别混合执行NMS改为按类别分组执行NMSNMS后所有框都没了坐标格式不统一或IoU计算出现异常打印中间IoU矩阵检查坐标范围和面积值每次推理结果不稳定分数排序碰上了相同分数的框排序不稳定使用稳定的排序算法或在分数相同时增加坐标次级排序推理速度明显变慢候选框数量太多串行循环成为瓶颈考虑GPU版NMS或分块NMS减少计算量6.2 实测下来最值得注意的几点第一点能用轮子就别重复造轮子。很多时候直接用torchvision.ops.nms是最稳的它处理好了边界情况、性能也足够好。手写NMS主要目的是理解原理或者在需要自定义变体时作为基础模板。第二点NMS的输入质量直接决定输出质量。哪怕NMS实现得再完美如果检测器输出的置信度不可靠后处理效果也不会好。有时在调NMS阈值之前先检查一下检测器本身的输出分布你会发现很多“NMS问题”其实是训练和推理阶段的问题。第三点阈值不是一劳永逸的参数。换数据集、换场景、甚至换一个训练好的模型版本最佳阈值都可能变。线上模型迭代后一定要重新验证NMS参数否则可能出现线上效果突然下降而原因找不到的情况。第四点写工程代码时不要只在函数里写keep就完事。我习惯在NMS里加日志输出包括输入框数量、保留框数量、每次循环最高分框的分数和IoU分布这些信息在排查问题时非常宝贵。否则当线上效果异常时你面对的是一个黑盒只能靠猜。第五点小心多线程和不同框架之间的计算差异。同一份数据在NumPy、PyTorch GPU和C实现里的浮点精度可能有细微差别导致排序和IoU计算结果略有不同。跨语言复现NMS结果时这一点要提前知道避免明明“算法一样”却得到不一样的结果时一头雾水。第六点NMS不是唯一的选择。如果你的场景允许可以尝试其他冗余框合并思路比如基于聚类的方法DBSCAN聚类后取每簇最高分框、基于学习的NMS通过一个小网络学习是否抑制或者把NMS损失直接放进训练阶段端到端可微NMS。但这些方法复杂度高、调试难度大不是万不得已不建议一上来就搞。写在最后的两个小技巧最后再分享两个我实际用得比较多的细节。第一个是“面积计算加1”的处理。在IoU计算时很多开源实现会用(x2 - x1) * (y2 - y1)有些会加1变成(x2 - x1 1) * (y2 - y1 1)。加1的意图是把像素看成有面积的区域而不是点如果坐标是整数的话会更严谨。我在多数项目里直接不加因为对最终排序结果影响很小但如果你的检测框边缘刚好贴合目标边界加1的版本会更稳。建议固定一种方式不要在同一个管线里混用。第二个是和NMS配合使用的“分数阈值预过滤”。在进入NMS之前先把置信度很低的一批框提前剔除比如score 0.05的直接丢弃可以大幅减少候选框数量让NMS跑得更快而且对结果几乎没有负面影响。这个操作别放在NMS之后做否则NMS的计算量白白浪费在低分框上。我在高分辨率图像检测场景里预过滤配合分块NMS能把后处理耗时压到标准实现的五分之一以下。NMS看起来是个很“小”的算法实际用起来却能牵扯出这么多细节。希望这篇文章能帮你在自己的项目里少踩几个坑把检测结果调得更干净。如果你在实践过程中遇到什么奇怪的NMS问题不妨回头看看是不是某个被忽略的小细节造成的——大部分时候问题都出在那些不起眼的地方。
返回列表