4、非极大值抑制(NMS):原理讲解、cv2.dnn.NMSBoxes使用、消除冗余检测框
YOLO 跑完一张图,经常给我吐出一堆框。同一个物体上,可能叠了三四个框,有的偏左,有的偏右,置信度还都挺高。这时候怎么办?全留着肯定不行。NMS 就是干这个的——把冗余的框干掉,只留最好的那个。
我个人习惯把 NMS 比作「选美比赛」。一堆候选框上台,先挑分数最高的当冠军,然后看其他选手跟冠军长得像不像——太像的就淘汰,不像的留下继续比。就这么简单。
4.1 为什么需要 NMS?
先看一个实际场景。我去年做一个行人检测项目,摄像头画面里一个人走过去,YOLO 输出了 7 个检测框。7 个框框住同一个人,有的框住上半身,有的框住下半身,有的框住整个人但偏移了一点。
你想想看,如果把这些框全部画到画面上,那画面就乱成一锅粥了。而且后续的跟踪、计数逻辑也会被干扰——同一个目标被重复计数,数据全乱套。
NMS 要解决的核心问题就两个:
- 同一个目标只保留一个框——选置信度最高的那个
- 不同目标各自保留自己的框——不能把相邻的不同物体误删
关键认知:NMS 不是 YOLO 独有的,它是目标检测领域的通用后处理步骤。不管用 Faster R-CNN、SSD 还是 YOLO,最后一步基本都是 NMS。
4.2 NMS 的核心原理
说白了,NMS 就三步:
- 排序:把所有检测框按置信度从高到低排序
- 选冠军:取置信度最高的框,保留它
- 淘汰:计算其他框与冠军框的 IoU,超过阈值的就干掉
然后重复第 2、3 步,直到所有框都被处理完。
这里有个细节——IoU 阈值怎么设?我一般设 0.5。设得太低(比如 0.3),两个挨得近的不同物体容易被误删;设得太高(比如 0.7),同一个物体上的冗余框可能清不干净。具体调多少,得看你的场景。
我的经验:密集场景(比如人群、车流)建议 IoU 阈值设 0.3-0.4,宽松场景(比如空旷道路上的单个行人)设 0.5-0.6 就够了。没有万能参数,跑一遍验证集看看效果最靠谱。
为什么会这样?因为密集场景里不同目标的框天然就挨得近,IoU 阈值设高了,NMS 就「手下留情」,结果冗余框没清干净。反过来,宽松场景里目标间距大,阈值设低点也没事。
4.3 IoU 计算——NMS 的数学基础
IoU(Intersection over Union)就是两个框的交集面积除以并集面积。公式很简单:
IoU = 交集面积 / 并集面积
取值范围 0 到 1。0 表示完全不重叠,1 表示完全重合。
我手写过一个 IoU 计算函数,方便调试时用:
def compute_iou(box1, box2):
# box 格式: [x1, y1, x2, y2]
x1 = max(box1[0], box2[0])
y1 = max(box1[1], box2[1])
x2 = min(box1[2], box2[2])
y2 = min(box1[3], box2[3])
inter_area = max(0, x2 - x1) * max(0, y2 - y1)
box1_area = (box1[2] - box1[0]) * (box1[3] - box1[1])
box2_area = (box2[2] - box2[0]) * (box2[3] - box2[1])
union_area = box1_area + box2_area - inter_area
return inter_area / union_area if union_area > 0 else 0
嗯,这里要注意——坐标别搞反了。OpenCV 里坐标是 (x, y, w, h) 格式,但 NMS 函数要求的是 (x1, y1, x2, y2) 格式。转换一下就行:
x1, y1, x2, y2 = x, y, x + w, y + h
我曾经踩过的坑:有一次直接拿 (x, y, w, h) 传进 NMS 函数,结果所有框都被保留了,因为 IoU 算出来全是 0。排查了半天才发现是坐标格式不对。所以,传参前一定确认格式。
4.4 cv2.dnn.NMSBoxes 使用详解
OpenCV 自带了 NMS 函数,不用自己手写。接口长这样:
indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold, nms_threshold)
| 参数 | 类型 | 说明 |
|---|---|---|
| boxes | list | 检测框列表,每个框格式为 [x1, y1, x2, y2] |
| scores | list | 每个框对应的置信度分数 |
| score_threshold | float | 置信度阈值,低于此值的框直接丢弃 |
| nms_threshold | float | NMS 的 IoU 阈值 |
返回值 indices 是一个数组,里面是保留下来的框的索引。用这些索引去原列表里取框就行。
完整的使用流程:
import cv2
import numpy as np
# 假设这是 YOLO 的输出
boxes = [
[50, 30, 200, 180], # [x1, y1, x2, y2]
[55, 35, 195, 175],
[300, 50, 450, 200],
[310, 55, 440, 195]
]
scores = [0.92, 0.85, 0.78, 0.72]
# 执行 NMS
indices = cv2.dnn.NMSBoxes(boxes, scores, 0.5, 0.4)
# 提取保留的框
if len(indices) > 0:
for i in indices.flatten():
x1, y1, x2, y2 = boxes[i]
conf = scores[i]
print(f"保留框: ({x1}, {y1}, {x2}, {y2}), 置信度: {conf:.2f}")
注意:OpenCV 4.5.1 之后,NMSBoxes 返回的 indices 形状变了。老版本返回的是 (N, 1) 的二维数组,新版本返回的是 (N,) 的一维数组。我建议统一用 indices.flatten() 处理,兼容两种版本。
4.5 NMS 的完整流程可视化
下面这张图展示了 NMS 从输入到输出的完整流程:
4.6 实际项目中的 NMS 调优
我在项目中总结了几条 NMS 调优经验,分享给你:
- 先过滤低置信度框:score_threshold 设 0.5 左右,先把明显不靠谱的框扔掉,减少 NMS 的计算量
- 按类别分别做 NMS:不同类别的物体之间不需要做 NMS。比如一个人和一个车,就算框重叠了也不该删。OpenCV 的 NMSBoxes 不支持按类别,需要自己写循环
- Soft-NMS 了解一下:标准 NMS 是「一刀切」,超过阈值就删。Soft-NMS 是「温柔版」,超过阈值就降低分数,而不是直接删。对密集场景效果更好
按类别做 NMS 的代码模板:
def nms_per_class(all_boxes, all_scores, all_class_ids, score_thresh=0.5, nms_thresh=0.4):
final_boxes = []
final_scores = []
final_class_ids = []
unique_classes = set(all_class_ids)
for cls in unique_classes:
# 提取当前类别的框和分数
cls_indices = [i for i, c in enumerate(all_class_ids) if c == cls]
cls_boxes = [all_boxes[i] for i in cls_indices]
cls_scores = [all_scores[i] for i in cls_indices]
# 执行 NMS
keep = cv2.dnn.NMSBoxes(cls_boxes, cls_scores, score_thresh, nms_thresh)
if len(keep) > 0:
for i in keep.flatten():
idx = cls_indices[i]
final_boxes.append(all_boxes[idx])
final_scores.append(all_scores[idx])
final_class_ids.append(all_class_ids[idx])
return final_boxes, final_scores, final_class_ids
4.7 常见问题与避坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| NMS 后框还是很多 | IoU 阈值设得太高 | 降低 nms_threshold(比如从 0.5 降到 0.3) |
| NMS 把不同物体删了 | IoU 阈值设得太低 | 提高 nms_threshold(比如从 0.3 升到 0.5) |
| NMS 没起作用 | 坐标格式不对,IoU 全为 0 | 检查 boxes 格式是否为 [x1, y1, x2, y2] |
| 检测结果抖动 | 视频帧间 NMS 结果不稳定 | 考虑加入时序信息,或使用 Soft-NMS |
我曾经踩过的另一个坑:在视频流里逐帧做 NMS,结果相邻帧的检测框位置跳来跳去。后来发现是 score_threshold 设得太低,低置信度的框在帧间随机出现。把阈值从 0.3 提到 0.5 后,抖动问题基本解决了。
NMS 看起来简单,但调好了能显著提升检测效果。我建议你拿到一个新数据集后,先跑一遍 NMS 参数扫描——固定其他参数,只调 IoU 阈值,看看 mAP 怎么变化。这样能找到最适合你场景的参数组合。