2、传统NMS算法原理:排序、循环抑制、IoU计算、阈值设定

目标检测模型跑完前向推理,会吐出一大堆候选框。一张图里可能就几个目标,但候选框能有好几千个。这里面绝大多数都是冗余的——同一个物体被框了好几次。NMS(非极大值抑制)就是干这个的:把重复的框干掉,只留下最好的那个。

说白了,NMS的核心逻辑就四个字:去重保优。我刚开始做检测时,觉得这步很简单,不就是比大小嘛。后来踩了不少坑才发现,NMS的每个细节都直接影响最终效果。咱们今天就把这四步拆开揉碎了讲清楚。

2.1 算法全流程概览

传统NMS的流程,我习惯用四个步骤来记:

  1. 按置信度排序——把候选框从高分到低分排好
  2. 选最高分框——当前最高分的框直接保留
  3. 计算IoU——拿保留框跟剩下的框算重叠度
  4. 阈值抑制——IoU超过阈值的框直接扔掉

重复2-4步,直到所有框都被处理完。就这么简单。

核心要点: NMS的本质是贪心策略——每次选当前最优,然后干掉跟它长得像的。虽然简单,但效果出奇的好。

传统NMS算法流程图 传统NMS算法流程图 输入候选框集合 按置信度降序排序 集合为空? 是 输出结果 否 取最高分框加入结果 计算IoU,抑制重叠框 循环

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、阈值这四个环节吃透,后面学任何变体都能快速上手。