2、传统NMS算法原理:排序、循环抑制、IoU计算、阈值设定
目标检测模型跑完前向推理,会吐出一大堆候选框。一张图里可能就几个目标,但候选框能有好几千个。这里面绝大多数都是冗余的——同一个物体被框了好几次。NMS(非极大值抑制)就是干这个的:把重复的框干掉,只留下最好的那个。
说白了,NMS的核心逻辑就四个字:去重保优。我刚开始做检测时,觉得这步很简单,不就是比大小嘛。后来踩了不少坑才发现,NMS的每个细节都直接影响最终效果。咱们今天就把这四步拆开揉碎了讲清楚。
2.1 算法全流程概览
传统NMS的流程,我习惯用四个步骤来记:
- 按置信度排序——把候选框从高分到低分排好
- 选最高分框——当前最高分的框直接保留
- 计算IoU——拿保留框跟剩下的框算重叠度
- 阈值抑制——IoU超过阈值的框直接扔掉
重复2-4步,直到所有框都被处理完。就这么简单。
核心要点: NMS的本质是贪心策略——每次选当前最优,然后干掉跟它长得像的。虽然简单,但效果出奇的好。
2.2 第一步:按置信度排序
模型输出的每个候选框,都带一个置信度分数。这个分数表示模型认为框里真的有目标的把握有多大。NMS的第一步,就是把所有框按置信度从高到低排好。
为什么要排序?你想想看,如果不排序直接随机选,很可能选到一个低分框作为基准,把旁边的高分框给误杀了。排序保证了我们每次拿到的都是当前最靠谱的框。
我的经验: 排序这一步看似简单,但我在项目中遇到过一个问题——当候选框数量特别大(比如超过10万个)时,全量排序会拖慢速度。后来我改用部分排序,只排前K个高分框,效果差不多但速度快了很多。
2.3 第二步:循环抑制
排序之后,进入主循环。流程是这样的:
- 从候选列表中取出置信度最高的框,直接加入最终结果
- 拿这个框跟列表中剩下的所有框算IoU
- IoU超过阈值的,从列表中移除
- 重复,直到列表为空
这个循环是NMS的核心。我刚开始写的时候,觉得这不就是个while循环嘛,有啥好说的。后来发现,循环的终止条件、每次取出的框怎么处理,这些细节写不对,结果就全乱了。
2.4 第三步:IoU计算
IoU(Intersection over Union)就是两个框的交集面积除以并集面积。公式很简单:
IoU = 交集面积 / 并集面积
取值范围在0到1之间。0表示完全不重叠,1表示完全重合。实际计算时,需要算两个框的相交区域:
# 两个框的坐标格式: [x1, y1, x2, y2]
# x1,y1是左上角,x2,y2是右下角
def compute_iou(box1, box2):
# 计算交集区域的坐标
x_left = max(box1[0], box2[0])
y_top = max(box1[1], box2[1])
x_right = min(box1[2], box2[2])
y_bottom = min(box1[3], box2[3])
# 如果没有交集,直接返回0
if x_right < x_left or y_bottom < y_top:
return 0.0
# 交集面积
inter_area = (x_right - x_left) * (y_bottom - y_top)
# 各自面积
area1 = (box1[2] - box1[0]) * (box1[3] - box1[1])
area2 = (box2[2] - box2[0]) * (box2[3] - box2[1])
# 并集面积 = 总面积 - 交集
union_area = area1 + area2 - inter_area
return inter_area / union_area
注意: 坐标计算时一定要保证x_right > x_left且y_bottom > y_top,否则会出现负面积。我曾经在项目里遇到过这个问题,排查了半天才发现是坐标顺序搞反了。
2.5 第四步:阈值设定
阈值是NMS的"杀伐标准"。常见的阈值范围是0.4到0.7。阈值设得越低,杀得越狠;设得越高,留得越多。
| 阈值 | 特点 | 适用场景 |
|---|---|---|
| 0.3 - 0.4 | 抑制非常严格,框很少 | 目标稀疏、要求高精度 |
| 0.5 | 最常用,平衡性好 | 通用场景,YOLO默认值 |
| 0.6 - 0.7 | 抑制宽松,框较多 | 密集小目标、遮挡严重 |
阈值怎么选?我个人的习惯是:先看你的任务对召回率要求高不高。如果漏检代价大(比如安防监控),阈值可以设高一点,宁可多留几个框也别漏掉。如果精度要求高(比如工业质检),阈值设低一点,把冗余框杀干净。
2.6 完整代码实现
把上面四步串起来,就是一个完整的NMS函数:
def nms(detections, iou_threshold=0.5):
"""
detections: [[x1, y1, x2, y2, score], ...]
iou_threshold: IoU阈值
返回: 保留的检测框索引
"""
if len(detections) == 0:
return []
# 1. 按置信度排序
scores = [d[4] for d in detections]
indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
keep = []
while len(indices) > 0:
# 2. 取最高分框
current = indices[0]
keep.append(current)
# 3. 计算当前框与剩下所有框的IoU
to_remove = [current]
for i in indices[1:]:
iou = compute_iou(detections[current], detections[i])
# 4. 阈值抑制
if iou > iou_threshold:
to_remove.append(i)
# 移除被抑制的框
indices = [i for i in indices if i not in to_remove]
return keep
关键点: 这段代码虽然简单,但它是所有NMS变体的基础。Soft-NMS、DIoU-NMS、Cluster-NMS都是在它的基础上改的。把这个吃透了,后面学优化版本会轻松很多。
2.7 传统NMS的局限性
传统NMS虽然好用,但有几个硬伤:
- 硬阈值一刀切:IoU超过阈值直接干掉,没有回旋余地。两个目标挨得很近时,很容易误杀
- 贪心策略的缺陷:每次只选当前最优,不考虑全局。如果最高分框位置不准,后面全跟着错
- 类别无关:传统NMS不区分类别。不同类别的目标如果重叠,也会被抑制掉
这些问题怎么解决?嗯,这就是后面章节要讲的内容了。Soft-NMS、DIoU-NMS、自适应阈值……每个方案都针对一个痛点做了改进。
我的建议: 刚开始做NMS优化时,别急着上复杂版本。先把传统NMS的代码写熟,把每个参数的影响摸透。我见过太多人一上来就搞Soft-NMS,结果连基础阈值怎么调都没搞明白。
传统NMS虽然简单,但它是所有后处理优化的起点。把排序、循环、IoU、阈值这四个环节吃透,后面学任何变体都能快速上手。