上一周有个朋友给我发来一张检测结果截图:图里一个人头被框了七八次,大大小小的矩形叠在一起,看着完全不像一个“训练好了”的模型。他跑来问我模型是不是废了,其实这太正常了——检测模型输出的本来就是一大堆候选框,真正让结果变干净的是后处理里的非极大值抑制。今天想把这一个函数从头到尾讲透:cv2.dnn.NMSBoxes(),它到底在做什么、每个参数怎么调、返回值有什么版本坑、以及和原理相关的调参思路。无论你用的是哪种检测模型,只要输出里有框,这篇文章应该都能帮上忙。
1. 一个检测模型画出来的框为什么这么多:NMS的工作位置
1.1 模型输出的“候选框”概念
先聊一个很多人没想明白的问题:目标检测模型为什么不直接输出“一个目标对应一个框”?
以常见的YOLO系模型为例,图像会先被切成网格,每个网格点会预测好几个先验框,再加上多尺度输出,最终一张 640×640 的图可能生成上万个候选。训练好的模型会从中挑出置信度较高的那部分,但即使过滤掉大量低分框,剩下几百个重叠框仍然很常见。
打个比方:模型像是“广撒网”,它先用大量框覆盖图像里可能有目标的区域,再用置信度排序告诉我们哪些框更可信。问题是,同一个目标周围可能同时有十几个框都“可信”,这些框彼此重叠、位置略有偏移。如果全都画出来,就是开头那种“叠罗汉”效果。
所以在模型输出之后、最终画框之前,必须做一件事:把这些重复表达同一目标的框合并成一个,只留下其中质量最高的那个。这个操作就是非极大值抑制,也就是NMS。
1.2 NMS在目标检测流水线里处于哪一环
一个标准的目标检测后处理流程大概是这样:
- 前向推理:把预处理后的图像送入模型,拿到原始输出张量。
- 解码坐标:把模型输出的中心点坐标、宽高和置信度转换成我们需要的像素框坐标。
- 低分过滤:按置信度阈值(score_threshold)把明显不靠谱的框先扔掉。
- NMS:在剩下的框里去重,抑制掉与最高分框重叠过大的候选框。
- 绘制或输出最终结果。
cv2.dnn.NMSBoxes()管的就是后两步里的“去重”环节。有些教程把低分过滤和NMS混在一起讲,其实它们分工完全不同:前者是看置信度本身够不够高,后者是看框和框之间重叠得有多厉害。这也是为什么这个函数同时需要两个阈值参数。
另外有个点值得先说明:cv2.dnn.NMSBoxes()并不要求你的模型一定是用OpenCV的dnn模块加载的,它只是一个独立的后处理工具。只要你有候选框坐标和对应的置信度列表,就能调用它,完全可以把PyTorch、某个其他推理框架甚至手工标出来的框丢进去。
2. 不读源码也能手写一个NMS:核心算法逐步拆解
2.1 从排序开始的四个步骤
NMS的算法思路说穿了只有四步,我第一次自己实现的时候只花了十几行代码。假设现在有一组候选框和对应分数:
- 按置信度从高到低排序。
- 取出当前分数最高的框,直接加入最终结果。
- 计算这个最高分框和所有剩余框的交并比(IoU)。
- 把IoU大于指定阈值的剩余框全部移除,因为它们和最高分框表达的是同一个目标。
重复第2到第4步,直到候选框列表为空。
整个过程里,“最高分框”就是当前集合里的极大值,而把重叠框抑制掉就是“非极大值抑制”这个名字的由来。请注意:每轮抑制完之后,下一个最高分框可能是另一个目标,也可能和当前目标相距很远,这样最终留下的每个框之间IoU都低于阈值。
2.2 IoU计算:重叠程度怎么量化
NMS里最关键的量是IoU,也就是交并比。它等于两个框重叠区域的面积除以两个框合并区域的总面积,取值范围是0到1:
- IoU = 0 表示两个框完全没有重叠。
- IoU = 1 表示两个框完全重合。
- IoU 越大,说明两个框越像在描述同一个目标。
并集面积的计算有一个小技巧:直接算“A的面积 + B的面积 - 重叠面积”就行,不需要真的手工画多边形。重叠面积则是两个框在x轴和y轴方向的重叠宽度之积,重叠宽度又可以用“两个右边界的最小值减去两个左边界的最大值”,如果结果为负数,就按0处理。
这个计算方式在像素坐标和归一化坐标下都成立,关键在于两个框必须用同一套坐标体系。
2.3 用NumPy验证一遍标准NMS流程
自己在NumPy里手写一遍NMS,真的能帮你把cv2.dnn.NMSBoxes()的每个细节都看明白。下面这个版本用标准左上角坐标加宽高的格式:
import numpy as np def nms(boxes, scores, iou_threshold): """ boxes: (N, 4) 的数组,每行是 [x, y, w, h] scores: (N,) 的数组 iou_threshold: 超过这个IoU的框被抑制 """ x1 = boxes[:, 0] y1 = boxes[:, 1] x2 = boxes[:, 0] + boxes[:, 2] y2 = boxes[:, 1] + boxes[:, 3] areas = (x2 - x1) * (y2 - y1) # 按置信度降序排列 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) h = np.maximum(0.0, yy2 - yy1) inter = w * h iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-6) # 保留IoU不超过阈值的框,进入下一轮 inds = np.where(iou <= iou_threshold)[0] order = order[inds + 1] return keep这里有个容易写错的地方:iou数组是和order[1:]对齐的,所以索引过滤时下标要加1,否则拿到的框就错位了。我第一次手写时就是忘了这个偏移,折腾了半天才查出来。
自己实现一遍的价值在于:当你调cv2.dnn.NMSBoxes()调出奇怪结果时,可以对两份输出做对比,快速定位是模型解码的问题,还是后处理逻辑的问题。两个结果一致,说明你的调用姿势没问题,锅在别处。
3. NMSBoxes() 五个参数逐个拆箱
3.1 必须传对的两个参数:bboxes与scores
cv2.dnn.NMSBoxes()的基本签名是这样的:
indices = cv2.dnn.NMSBoxes(bboxes, scores, score_threshold, nms_threshold, eta=1.0, top_k=0)第一个参数bboxes是候选框列表。注意它约定的格式是[x, y, width, height],x、y是左上角坐标,后面两个分别是宽和高。很多模型输出的原始框是[center_x, center_y, width, height],直接传进来就会在NMS阶段算出一堆错误的IoU,最终结果要么框错位,要么互相抑制得很奇怪。
bboxes的元素可以是一个由四个数组成的元组、列表或NumPy数组,但整体最好是规则的二维结构。你传一个(N, 4)的NumPy数组一般也能用,不过为了稳一点,我习惯先.tolist()转成嵌套列表,避免某些OpenCV版本对ndarray的处理和预期不一致。
第二个参数scores是每个框对应的置信度列表。它的长度必须和bboxes完全一致,而且顺序一一对应。这个函数内部会先按分数排序,所以传入顺序不会影响结果,但如果你想自己写调试代码对比索引,最好保持清醒。
3.2 score_threshold和nms_threshold的分工
score_threshold是置信度过滤阈值,低于它的框在NMS真正开始前就会被丢弃。默认情况下如果你传0,那就等于不做低分过滤,完全靠NMS去重。实际项目中这个值一般给0.1到0.5之间,具体看模型和任务。
nms_threshold是NMS要去重的IoU阈值,这个值的含义值得好好理解:它表示“最高分框和另一个框的IoU超过多少就认为它们重叠,进而抑制掉另一个框”。所以:
- 阈值调大(比如0.7),允许保留更多重叠框,结果里框更多,更容易出现同一个目标被画两个框的情况。
- 阈值调小(比如0.2),抑制力度变强,很多重叠框被删除,但也容易把旁边挨得很近的不同目标误杀。
YOLO系模型通常建议在0.4到0.5之间起步,具体再根据密集程度调整。
为了说明这两个阈值的区别,我用一张图举例:一个目标附近有两个候选框,置信度分别是0.9和0.6,IoU是0.55。如果score_threshold=0.5、nms_threshold=0.5,那么0.5的阈没法拦住0.6分框的进入,但NMS阶段IoU=0.55大于0.5,它会被最高分框抑制掉。如果score_threshold=0.7,那么0.6分框提前出局,根本走不到NMS,两者路径完全不同。
3.3 容易被忽略的eta和top_k
eta是adaptive NMS里的参数,默认值是1.0。当eta小于1.0时,算法会在每一轮选出一个最高分框后,把当前动态的置信度阈值乘上eta,相当于逐步降低门槛。这个机制在候选框特别多、分数分布跨度很大的时候理论上有点用,但实际工程里很少有人碰它,我建议初学者直接保持默认1.0,别给自己增加排查负担。
top_k更有实用一点。默认0表示不限制候选框数量;如果设为正整数,比如200,那么排序后只有分数最高的前200个框会参与NMS。它的作用主要是在候选框数量极大时控制计算量,或者作为一种额外的“只关心高分框”的过滤手段。
这里有个容易混淆的点:top_k是在score_threshold过滤之后、NMS开始之前作用的,而且它是“先排序再取前K个”,所以不会被低分框挤占名额。如果你同时设置了score_threshold=0.3和top_k=100,那么先丢掉低于0.3分的框,再从剩下的候选里取分数前100。
3.4 返回值形状与OpenCV版本的恩怨
这个函数的返回值在不同OpenCV版本里的形状变化,是我见过最多人踩的坑了,网上老代码直接拿到新版运行就报错的情况非常常见。
简单总结一下:
- 在OpenCV 4.5.1之前,返回的是Python列表,元素是保留框的下标,直接
for i in indices就能遍历。 - 在4.5.1之后,返回类型变成了NumPy数组,但不同小版本返回的形状还不一样。有的版本返回的是形状为
(N, 1)的二维列向量,有的版本又返回一维数组(N,)。
如果你写了一行代码专门适应某个特定版本,换个环境很可能就出事。最稳妥的做法是拿到返回值之后立刻规范化:
indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.5, nms_threshold=0.45) indices = np.array(indices).flatten()经过flatten()之后,不管底层返回的是列表、二维数组还是一维数组,你拿到的都是一个一维索引数组,后面遍历、切片全部统一,省心很多。
4. 从YOLO原始输出到干净检测框的完整示例
4.1 把中心点坐标换算成左上角坐标
现在用一个贴近实战的例子把这套流程串起来。假设你从某个检测模型拿到的原始输出是中心点格式的归一化坐标,每一行大概是[center_x, center_y, width, height, obj_score, class_scores...],图像尺寸是img_w和img_h。
解码部分可以这样做:
import cv2 import numpy as np def decode_yolo_output(raw_output, img_w, img_h, confidence_threshold=0.3): boxes = [] scores = [] class_ids = [] for row in raw_output: obj_conf = float(row[4]) if obj_conf < confidence_threshold: continue cx, cy, w, h = row[0], row[1], row[2], row[3] # 归一化坐标转像素坐标,再转左上角表示 x1 = int(round((cx - w / 2.0) * img_w)) y1 = int(round((cy - h / 2.0) * img_h)) w_px = int(round(w * img_w)) h_px = int(round(h * img_h)) cls_scores = row[5:] cls_id = int(np.argmax(cls_scores)) cls_score = float(cls_scores[cls_id]) final_score = obj_conf * cls_score boxes.append([x1, y1, w_px, h_px]) scores.append(final_score) class_ids.append(cls_id) return boxes, scores, class_ids有一个细节我想提醒:很多人在转换坐标时直接int(cx * img_w),这样得到的是中心点像素坐标,但cv2.dnn.NMSBoxes要的是左上角坐标。如果只有中心点,必须先算出x1 = cx - w/2,不然IoU全算错。另外,宽高如果算出来是0或负数,要尽早过滤掉,否则后续画框和NMS都可能出问题。
4.2 多类别检测的分类别NMS写法
一个常被忽略的问题是:NMS应该分类别做,而不是把所有类别的候选框混在一起做。
为什么?当一个“行人”和一个“自行车”挨得很近时,它们对应的框IoU可能很高。如果所有类别一起NMS,行人框和自行车框就会互相抑制,你最终只能看到其中一个类别。正确做法是按class_id分组,每个类别单独做一次NMS。
def class_aware_nms(boxes, scores, class_ids, score_threshold=0.5, nms_threshold=0.45): final_boxes = [] final_scores = [] final_class_ids = [] for cls_id in set(class_ids): cls_mask = [i for i, c in enumerate(class_ids) if c == cls_id] if not cls_mask: continue cls_boxes = [boxes[i] for i in cls_mask] cls_scores = [scores[i] for i in cls_mask] indices = cv2.dnn.NMSBoxes(cls_boxes, cls_scores, score_threshold, nms_threshold) indices = np.array(indices).flatten() for idx in indices: original_idx = cls_mask[idx] final_boxes.append(boxes[original_idx]) final_scores.append(scores[original_idx]) final_class_ids.append(class_ids[original_idx]) return final_boxes, final_scores, final_class_ids注意这里有个索引对应关系:cls_mask是从原始列表筛出来的过滤后的索引表,NMS返回的idx是在cls_boxes里的位置,所以取原始索引时一定要cls_mask[idx],不能直接拿idx去原列表取值。我见过不少人在这里写错,结果画出来的框完全对不上号。
4.3 画框验证NMS前后的差异
为了直观验证NMS到底干了什么,可以写一段画框代码,把NMS前后的结果都画出来对比。这一步在调试时特别有价值。
import random def draw_predictions(image, boxes, scores, class_ids, class_names=None): img = image.copy() colors = [(0, 255, 0), (0, 0, 255), (255, 0, 0), (255, 255, 0)] for box, score, cls_id in zip(boxes, scores, class_ids): x, y, w, h = box color = colors[int(cls_id) % len(colors)] cv2.rectangle(img, (x, y), (x + w, y + h), color, 2) label = f"{class_names[cls_id] if class_names else cls_id}: {score:.2f}" cv2.putText(img, label, (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) return img # 左侧画NMS前,右侧画NMS后 # before_img = draw_predictions(image, boxes, scores, class_ids) # after_img = draw_predictions(image, final_boxes, final_scores, final_class_ids)我自己的调试经验是:如果NMS前框很多但看起来都集中在同一目标附近,说明低分过滤和NMS基本在正常运行;如果NMS后框还留着明显重叠,多半是nms_threshold调大了;如果NMS后把两个不同目标合并成一个,多半是nms_threshold调太小,或者把不同类别混在一起做了NMS。
5. NMSBoxes() 使用中我踩过的几个典型坑
5.1 NMS返回索引的类型和形状坑
继续聊聊版本问题在实际代码里造成的报错。旧代码里很常见的一种写法是:
for i in indices: x, y, w, h = boxes[i]在OpenCV新版下,如果indices是形状为(N, 1)的二维数组,直接boxes[i]取到的是一个数组而不是一个整数,轻则画错框,重则报索引类型错误。如果indices是一维数组,有些场景看似正常,但代码一旦换到另一个版本又乱了。
所以我的建议很明确:所有经历过cv2.dnn.NMSBoxes的项目,都在调用后立刻统一执行indices = np.array(indices).flatten().astype(int)。类型也顺手转成int,因为OpenCV某些绑定环境下返回的元素类型可能不是Python原生int,画框时偶尔会触发OpenCV内部的类型检查报错。
5.2 坐标格式错位:中心点误当左上角
还有一种特别隐蔽的坑:模型的解码部分已经把中心点转成了左上角,但传给NMSBoxes之前做了一次归一化/反归一化,把坐标体系又弄混了。NMS本身不关心坐标是像素还是归一化,因为它只计算面积比例和相对关系,比例上没问题,结果形式上看起来也是对的。但只要有一次转换错位,IoU就会整体失真。
举个例子:如果两框都是“左上角+宽高”,一个用像素,一个用归一化,那么算出来的IoU完全没意义,更坑的是程序不会报错,只会在最终效果上表现出“框删得莫名其妙”。我在调试某个第三方模型时遇到过:模型输出的是中心点归一化格式,我在解码时误把中心点当成左上角直接乘了图像宽高,结果两个目标挨得近一点的框全被NMS删掉了,整了半个下午才定位到问题。排查这类问题最直接的方法,就是自己写一个打印函数,随机抽几个框打印x1, y1, x2, y2和图像宽高,人工验证坐标是否落在合理范围内。
5.3 所有类别一起做NMS的互相抑制问题
前面提到过分类别NMS的必要性,这里再展开讲一个真实经历。某次测试中,画面里有一个行人和一辆很近的自行车,检测模型两个类别的置信度都很高。我把所有框丢进cv2.dnn.NMSBoxes,nms_threshold设0.4,结果行人框被自行车框抑制掉了。从算法角度来说这“符合预期”,但站在使用者角度看就是漏检。
这种问题在同类目标拥挤的场景里同样存在,比如人群密集的地铁站,人与人之间的框天然高度重叠。如果全局nms_threshold设得太低,表现就是人数检测结果明显偏少。所以只要你的任务是多类别检测,第一反应就应该是分类别NMS,而不是去死磕全局阈值。
5.4 阈值方向调反导致“漏检”和“重框”
调参时方向感也很重要。有个常见的困惑是:“我调大nms_threshold,为什么检测框变少了?”这个问题我解释一下:
- 调大
nms_threshold意味着允许更大的IoU重叠,也就是NMS变得更宽松,更多重叠框被保留,框数应该变多。 - 调小
nms_threshold意味着只要重叠稍大就抑制,框数应该变少。
如果调大之后框数反而变少,那你要查一下是不是答案里同时有低分过滤在起作用,或者把参数名搞混了。score_threshold调大才会明显减少框数,因为高置信度过滤提前删掉了低分候选,NMS阶段可用的候选基数变小了。
我个人的调参顺序一般是:先固定nms_threshold=0.45,按score_threshold从低到高扫一遍,观察哪些是误报哪些是漏检;确认置信度阈值合理后,再扫nms_threshold,观察重复框和相邻目标误杀的情况。两个阈值分开调,比一起动要容易定位得多。
6. 从NMS原理反推后处理调优方向
6.1 NMS为什么容易在密集场景失灵
NMS本质上是一个贪心算法:每一步都相信当前最高分框代表了一个真实目标,然后删除所有与它重叠的框。这在目标稀疏、相互独立时非常好用,但在密集场景里就会出问题。
典型情况是两张图里人头紧挨着,每个人的检测框重叠度很高,但确确实实是两个独立目标。此时NMS会强行把低分的那个人头框删掉,造成漏检。这是所有标准NMS的固有缺陷,不是cv2.dnn.NMSBoxes()这个函数写错了,而是算法假设无法处理这种语义层面的“重叠但不同目标”。
如果任务场景本身就密集(人流统计、货架商品检测),那么后处理策略必须跟着场景调整,而不是永远套用一个固定的阈值。
6.2 简单可用的替代思路:Soft-NMS思想
有些时候阈值怎么调都不舒服,就可以考虑替代NMS方案。最经典的改进是Soft-NMS,思路和NMS略有不同:NMS是一旦IoU超过阈值就彻底删除框,Soft-NMS则是按照IoU大小对框的分数做衰减。
具体说,如果某个候选框与最高分框的IoU很大,就把这个候选框的分数乘上一个衰减系数,让它很难留在最终结果里;IoU不大的框,分数基本不受影响。这样既抑制了高度重复的框,又不会对相邻目标“一刀切”。
OpenCV的cv2.dnn.NMSBoxes本身不直接支持Soft-NMS,需要自己实现,但实现并不复杂,把之前手写NMS里的抑制逻辑替换成分数更新就行。对于常规项目,先用标准NMS跑通流程,遇到密集场景漏检之后再切Soft-NMS,是比较稳妥的演进路径。
6.3 一个务实的调参顺序建议
最后给一套我试下来比较顺手的调参路径,作为收尾再分享一次:
- 先用
score_threshold=0.25,nms_threshold=0.5跑一遍,把NMS前后的画框结果都保存下来。 - 看复检框有多少:如果大量重叠框没被清掉,调低
nms_threshold到0.4左右。 - 如果目标出现整体漏检且相邻目标被合并,把
nms_threshold回调到0.55甚至0.6,并检查是否有必要换Soft-NMS。 score_threshold不要一开始就设很高,这会让低分但真实的小目标直接丢掉。- 如果画面里目标有比较明显的尺度差异,考虑针对大目标和小目标分别做NMS,阈值可以不同。
这套流程不一定每次都能达到完美,但至少能让你很快定位问题出在低分过滤、重叠抑制,还是目标本身太密集。做目标检测后处理,最忌讳的就是“参数一顿乱试”,把原理和现象对应起来,调参才有效率。