ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

目标检测后处理:IOU与NMS从原理到工程化实战

目标检测后处理:IOU与NMS从原理到工程化实战

做目标检测的朋友都知道,模型跑通只是第一步,真正决定最终效果上限的,往往是两段看起来很不起眼的计算:IOU与NMS。IOU负责衡量两个检测框到底重合成什么样,NMS负责把同一目标周围那些重复的、多出来的框“压”下去。无论是训练时设置正负样本阈值,还是推理时清理预测结果,几乎每一步都在和这两个计算打交道。

这篇文章我把IOU和NMS从公式到代码、从单独实现到工程化加速讲透,包含我落地的项目里实际验证过的写法和踩过的坑。适合刚接触检测任务、想自己动手实现后处理逻辑的读者,也适合准备面试时想把细节储备补上的朋友。通篇没有玄学,都是可以直接抄过去的实现方案和参数经验。

1. IOU如何计算:从几何定义到第一行代码

1.1 从“两个框重叠了多少”说起

IOU的全称是Intersection over Union,中文一般叫交并比。在目标检测里,它计算的是两个矩形框的交集面积与并集面积的比值。公式很简洁:

IOU = |A ∩ B| / |A ∪ B|

其中A和B是两个检测框。取值范围是[0, 1],两个框完全不重叠时为0,完全重合时为1。你可以把它想成两个相框在墙上叠着:重叠部分越大,IOU越大,说明这两个框指的可能就是同一个目标。

那为什么要用“交集除以并集”而不是单纯的“重叠面积”或者“中心距离”呢?关键在于尺度不变性。一个300像素的大目标和一个30像素的小目标,如果都用重叠面积来衡量,大目标微小的偏移可能造成很大的面积变化,而小目标就算偏移了一半,重叠面积绝对值依然很小。用并集做分母后,重叠程度被归一化到比例尺度上,比较结果就公平了。

这里有个很容易混淆的点:检测里常见的框坐标有几种表示方式,最通用的是xyxy格式(左上角x1, y1,右下角x2, y2)。后面所有公式和代码我默认都用这个格式,方便统一。

1.2 手写IOU函数:代码与每一步的用意

虽然很多深度学习框架都内置了IOU算子,但我建议每个做检测的人都至少手写一遍,因为这是理解后处理一切变体的基础。下面这段是单个框与单个框的IOU计算:

def compute_iou(box_a, box_b): # 输入均为 [x1, y1, x2, y2] 格式 # 1. 计算交集矩形的左上角和右下角 x1 = max(box_a[0], box_b[0]) y1 = max(box_a[1], box_b[1]) x2 = min(box_a[2], box_b[2]) y2 = min(box_a[3], box_b[3]) # 2. 计算交集宽高,注意小于0时取0 inter_w = max(x2 - x1, 0) inter_h = max(y2 - y1, 0) inter_area = inter_w * inter_h # 3. 计算各自面积和并集面积 area_a = (box_a[2] - box_a[0]) * (box_a[3] - box_a[1]) area_b = (box_b[2] - box_b[0]) * (box_b[3] - box_b[1]) union_area = area_a + area_b - inter_area # 4. 防止除零 if union_area == 0: return 0.0 return inter_area / union_area

这里最值得强调的就是第二步的max(..., 0)。如果不做这个处理,两个相距很远的框会得到负的宽高,而负数乘负数会变成正数,最终算出一个明显错误甚至大于1的IOU。这是手写IOU最常见的bug,没有之一。

每次这个函数被调用都要做一堆max和min判断,如果对两堆框同时计算,就得用广播技巧或者循环。别急,第三节会讲到向量化版本。

1.3 IOU在训练、推理、评估中的三个角色

很多人只把IOU当后处理工具,其实它在检测全流程里有三个完全不同的角色。

训练阶段,IOU是正负样本分配的核心依据。以两阶段检测器为例,预先铺设了一堆候选框(anchor或proposal),这些框和标注真实框之间算IOU,超过某个阈值(比如0.5)就算正样本,低于另一个阈值(比如0.3)算负样本,中间那些不参与训练。如果阈值设置不合理,样本就会失衡,模型怎么训都上不去。

推理阶段,IOU用于判断两个框是否指向同一个目标。一个目标附近可能同时激活了多个框,我们通过IOU和置信度做去重,这也就是NMS要做的事情。

评估阶段,IOU又是mAP这套指标的计算基准。PASCAL VOC时代常用IOU>0.5判定为检测正确,COCO则更严格,要求IOU>0.75才算准,还专门统计0.5到0.95区间内不同阈值下的平均精度。

关于阈值,我见过不少人把训练时的正样本IOU阈值和推理时的NMS阈值取成同一个值,这其实没有必然联系。训练匹配阈值影响的是模型学习哪些位置,推理NMS阈值决定的是同一目标周围允许多少重叠框留下来,两者完全可以分开调,混为一谈会越调越乱。

2. NMS计算的核心逻辑与完全实现

2.1 重复框从哪来,NMS在抑制什么

模型预测时并不会只输出一个框,而是基于特征图上的每个位置、每个预设尺寸都输出预测。一个目标只要稍微符合激活条件,附近可能就会冒出五六个甚至十几个框。这些框可能都指向同一个目标,只是位置和大小略有差异。如果不做后处理,最终画出来的效果就是目标周围一堆半透明方框叠在一起,完全没法看。

非极大值抑制,就是要在这一堆候选框里,留下“局部置信度最高框”,同时把与该框重叠度超过阈值的冗余框删除。名字里的“极大值”指的是置信度分数,“非极大值抑制”就是抑制掉那些分数不是最高的邻居框。

有一个很自然的疑问:为什么不直接取分数最高的那个框就完事了?因为一张图里往往有多个目标,每个目标都有自己的最高分框。NMS要做的不是全局只留一个,而是在每个目标的局部邻域内各留一个,同时保证两个靠得很近的真实目标不会被错误地合并。

2.2 经典NMS的算法步骤拆解

经典NMS的流程可以拆成四步:

  1. 把所有候选框按置信度分数从高到低排序。
  2. 选当前分数最高的框M,直接认定它需要保留,加入结果集合。
  3. 遍历剩余所有框,计算它们与M的IOU,凡是IOU大于阈值的,都认为与M指向同一目标,丢弃。
  4. 回到第2步,直到候选框集合为空。

这里面的关键在于第3步的“IOU大于阈值就丢”。这其实也在用IOU做一次目标重合度的硬判决:重叠太多,就认为是同一个目标;重叠不够,哪怕距离很近也各自保留。

为什么从分数最高的框开始?因为置信度高的预测通常更可靠。但这里也有个隐患:如果最高分的框本身是假阳性,那么跟着它一起被抑制掉的很可能包括一个位置正确的低分真阳性框。这也是后面要讲Soft-NMS等改进方法的动机。

2.3 手写NMS完整实现

下面是一个直接在numpy数组上实现的NMS函数,输入N个框和对应的分数,输出保留框的索引。代码逻辑和上面伪代码严格一一对应:

import numpy as np def nms(boxes, scores, iou_threshold): """ boxes: (N, 4) 的 ndarray,每行是 [x1, y1, x2, y2] scores: (N,) 的 ndarray iou_threshold: 重叠超这个值的框会被抑制 """ # 0. 空输入直接返回空 if len(boxes) == 0: return np.array([]) x1 = boxes[:, 0] y1 = boxes[:, 1] x2 = boxes[:, 2] y2 = boxes[:, 3] # 1. 提前算好面积,避免循环里重复计算 areas = (x2 - x1) * (y2 - y1) # 2. 按分数降序排列,得到索引序列 order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] # 当前最高分框 keep.append(i) if order.size == 1: break # 3. 计算当前最高分框与剩下所有框的IOU 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:]]) inter = np.maximum(xx2 - xx1, 0) * np.maximum(yy2 - yy1, 0) iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-12) # 4. 保留IOU小于阈值的框 inds = np.where(iou <= iou_threshold)[0] order = order[inds + 1] return np.array(keep)

这个实现有几个细节值得说。order[inds + 1]看起来很绕,但作用很明确:因为iou计算里只涉及了order[1:]这些索引,iou数组第k个元素对应的是order[k+1],所以筛选出的位置k要加1才能映射回原始顺序数组。如果你不小心用了order[inds],就会因为索引错位丢掉或重复处理不少框。

另外+ 1e-12是为了防止两个面积为0的非法框参与计算时除零。加了之后对正常结果几乎没有影响,但能避免NaN传播。

2.4 NMS实现里的两个隐性细节

第一个是面积计算的数值边界。如果某个框的坐标是反的,比如x2小于x1,面积就会算成负的。很多模型的输出在极端情况下确实会出现这种畸形框,建议在进入NMS前把这些框过滤掉,或者给面积加上clip,否则负数面积会污染整个IOU计算。

第二个是相同分数的排序稳定性。scores.argsort()默认是非稳定排序,如果两个框分数完全一样,谁排前面取决于原始顺序。这对绝大多数场景无影响,但如果你要严格复现实验或做对比,可以考虑带kind='stable'的排序,保证同类情况多次运行结果完全一致。

3. 进阶工程实操:向量化、多类别与边界细节

3.1 向量化IOU矩阵与NMS加速

第三节里那段循环代码适合理解原理,但在实际推理时,一帧图像可能产生几百到几千个候选框,如果每个框都逐个和剩余框算IOU,Python级别的循环会明显拖慢整体速度。更高效的做法是提前算好整个N x N的IOU矩阵,然后基于矩阵做迭代抑制。

下面是一个用numpy广播一次性计算所有框之间IOU的版本:

def pairwise_iou(boxes): """ boxes: (N, 4) 的 ndarray,xyxy格式 返回: (N, N) 的 IOU 矩阵 """ n = boxes.shape[0] # 用广播实现两两左上角和右下角的比较 lt = np.maximum(boxes[:, None, :2], boxes[None, :, :2]) rb = np.minimum(boxes[:, None, 2:], boxes[None, :, 2:]) wh = np.clip(rb - lt, a_min=0, a_max=None) inter = wh[..., 0] * wh[..., 1] areas = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) union = areas[:, None] + areas[None, :] - inter ious = np.where(union > 0, inter / np.maximum(union, 1e-12), 0) return ious

有了IOU矩阵,NMS的循环里就不需要反复调用坐标运算了,每次只需要在矩阵中取一行,筛掉重叠过高的索引。实测N=3000时,矩阵版本比逐框循环快一个量级。

要注意的是,这个方案的时间和内存复杂度都是O(N²)。N为1000时,矩阵是100万个浮点数,约8MB,没问题;但N到10万时就是8000MB,直接内存爆炸。所以如果候选框特别多,建议先按置信度低分过滤掉一批,或者只对当前局部邻域内的框做矩阵计算,别一上来就对全图所有框做全排列。

3.2 多类别NMS的正确打开方式

检测模型通常输出多个类别,分类别处理是常规操作。做法很简单:对每个类别单独执行一遍NMS,再把保留的索引汇总回来。代码示例如下:

def nms_per_class(boxes, scores, class_ids, iou_threshold): best_keep = [] for cls in np.unique(class_ids): cls_mask = class_ids == cls cls_boxes = boxes[cls_mask] cls_scores = scores[cls_mask] local_keep = nms(cls_boxes, cls_scores, iou_threshold) # 把局部索引映射回原始整体索引 original_idx = np.where(cls_mask)[0][local_keep] best_keep.extend(original_idx) return np.array(best_keep)

分类别处理的好处是,一个“人”框和一个“自行车”框即使完全重叠也不会互相误伤。这在大部分检测场景下是对的。

但我也遇到过需要重新审视的情况:当两个不同类别的目标在图像上高度重叠时,分类别NMS各自保留一个框,最终输出中会出现两个几乎重合但标签不同的检测结果。这在某些严格业务里会被当作重复检测,需要通过规则合并,或者干脆使用跨类别的全局NMS。我的建议是,优先用分类别NMS,然后结合具体业务进行二次处理,比如按重叠度和类别优先级做合并。

3.3 容易被忽略的边界细节

第一个值得说的是坐标格式转换。模型输出可能是归一化的xywh格式,也可能是中心点加宽高格式,在后处理前统一转成xyxy并转换成浮点数。格式没统一,后处理算出来的一切结果都没有意义。

第二个是输入输出长度为零的边界情况。空输入时,NMS函数必须返回空数组而不是报错。尤其在做批量推理时,总会遇到某一帧没有检测到目标,如果后处理这段代码没有防御性判断,线上就会闪退。

第三个是坐标越界问题。模型直接输出的框可能超出图像边界,比如x2大于图像宽度。如果图像后续要缩放或裁剪,这些越界框会导致区域计算失效。我习惯在做NMS前先把坐标clip到图像范围内,再参与计算。

第四个可能比较隐蔽,关于数值精度。在端侧或GPU部署时,如果全程用float16,框坐标相减的误差会被放大,导致IOU出现略微漂移,尤其在目标很小、宽高只有几个像素的时候。稳妥的做法是IOU计算时把坐标转回float32,或者至少转回更高精度。

4. 变体方法:Soft-NMS、DIoU-NMS与阈值调参

4.1 Soft-NMS:重叠框的分数衰减策略

经典NMS对重叠框是“一刀切”:IOU超过阈值,直接删除。问题在于,如果两个真实目标挨得很近,其中一个分数略低,它可能被高分的邻居框连带删除,最终漏检。

Soft-NMS的思路是给重叠框一个“缓冲”,不直接删除,而是按重叠程度打一个折扣。两种常见的更新时间规则:

线性惩罚: $s_i = s_i \times (1 - IOU(M, B_i))$,当IOU大于阈值时。

高斯惩罚: $s_i = s_i \times \exp(-\frac{IOU(M, B_i)^2}{\sigma})$。

实际实现时,流程依然按置信度排序顺序处理,但对每个当前最高分框,不再硬性丢弃相邻框,只是把它们的分数降低。处理一轮之后,低于某个置信度阈值的框就被淘汰,剩下的继续参与排序。这样原本会被“连坐”的低分框有机会保留下来,代价是整体计算量变大,因为被衰减的框还要继续参与后续处理。

Soft-NMS在拥挤场景、行人和密集小目标上的收益明显,但如果目标本身比较孤立,它的收益有限,还会因为保留更多框而增加误检率。

4.2 DIoU-NMS:把中心距离与重叠度一起考虑

经典NMS还有一个几何盲区:只用IOU判断,完全没考虑两个框的中心距离。举个极端例子,一个大框完全嵌套一个小框,两者中心距离很远,但IOU可能因为大框面积巨大而比较低,小框容易被误判为“不重叠”而保留;反过来,两个中心很近但面积差异很大的框,IOU可能也不高,同样不好判断。

DIoU-NMS改用了DIoU作为抑制指标。DIoU在IOU基础上额外考虑了中心点距离惩罚:

$DIoU = IOU - \frac{\rho^2(b, b^{gt})}{c^2}$

其中rho是两个框中心点的欧式距离,c是同时包含两个框的最小外接矩形的对角线长度。这个惩罚项能更准确地反映两个框在空间上的真实关系,特别适合处理重叠度不高但中心很近的边界情况。实际使用中,DIoU-NMS对低重叠目标更宽容,对小目标和密集目标效果明显好过经典NMS。

4.3 IOU/NMS阈值怎么选:实操调参经验

阈值选择没有绝对标准,但有一套经验规律可循。

如果NMS的IOU阈值偏大,比如0.7,意味着两个框重叠度超过0.7才会被抑制,最终留下更多框,召回会更高,但误检也会增多。如果阈值偏小,比如0.3,抑制激进,精确率提高但容易漏检。

不同任务的经验值差异很大。通用目标检测常用0.5;行人检测因为人体姿态多样,重叠情况多,很多项目会调到0.4左右;文档版面分析里的框往往比较规整,0.3就能达到不错的去重效果;小目标密集场景则建议0.5到0.6,避免把相邻目标误杀。

还要注意置信度阈值和NMS阈值的联动。我的习惯是NMS之前先用一个比较低的置信度阈值过滤掉明显不可靠的框,比如0.01到0.05,这样做能减少NMS的计算压力。但不要把置信度阈值设得太高再进NMS,否则有些低置信度但位置准确的框会在被高置信度错框“引导”抑制时彻底消失,这是后处理中隐蔽的精度损失来源。

如果模型还设置了每张图最多保留框数,比如只取TopK个结果,正确的顺序是:先低置信度过滤,再做NMS,最后按分数取TopK。顺序反了可能白白丢掉高置信度框。

5. 常见问题与排查实录

5.1 后处理代码的五个经典坑

我在实际项目里接手过不少检测推理代码,下面这五个问题出现频率最高,每个都值得单独写进你的排查清单。

现象常见原因解决思路
输出图像上一堆半透明重复框NMS根本没执行,或者NMS的IOU阈值设得过大(如0.9)检查后处理是否被跳过,阈值回退到0.5附近
两个真实目标只出一个框NMS阈值太低,相邻目标被误合并调高NMS阈值,或改用Soft-NMS
小目标批量消失置信度阈值设得过高,小目标本身分数就偏低降低置信度阈值,用NMS过滤而非前置砍分
同一目标反复出现但框大小不同多类别NMS没有按类别分开,跨类互相抑制失败先分类别执行,再决定是否跨类二次合并
推理速度突然变慢单帧框太多,双重循环计算IOU成为瓶颈用向量化IOU矩阵,前置低分过滤

这里补充一下第二个坑的细节。两个靠近的目标被合并,并不是总能靠调大阈值解决,因为如果两个框的重叠度真的很高,阈值再大也可能无法区分。这种时候优先考虑DIoU-NMS,它能利用中心距离信息把“正好挨着”的两个目标拆开。

5.2 性能瓶颈与优化思路

前面说过,NMS的原始实现是O(N²)复杂度。我用过一个极端案例:某检测模型单帧输出接近一万个候选框,纯Python循环版本的NMS跑一次需要几百毫秒,完全没法用于实时推理。

先别急着上复杂的工程方案,第一优化永远是“减少进入NMS的框”。置信度阈值从0.01提到0.1,候选框数量可能直接降一个量级,性能立刻改善。第二步才是向量化IOU矩阵,把计算量从Python循环下沉到numpy底层。

如果向量化之后内存依然紧张,还有一个折中方案:只对按分数排序后的每个窗口做局部IOU计算,比如每次只处理相邻的64个框,效果和全局矩阵相近,但内存占用小得多。这个方法我实际用过,精度损失几乎可以忽略。

5.3 我的验收方法:先用随机框做回归测试

最后分享我平时做后处理代码验收的土办法:用随机生成的框和分数,先跑一个“最简单、最笨”的双重循环NMS当基准结果,再跑新的向量化或优化版本,对比两边保留框索引是否完全一致。随机多跑几组,把坐标格式、空输入、单框输入、两个完全重合的框、两个相邻但IOU刚过阈值的框这些边界情况都覆盖进去,比在真实数据集上反复调参更高效。

这个方法看起来简单,但真的救过我很多次。有一次我重构NMS时把类别索引映射写错了,如果用真实图像去肉眼判断,很难发现问题,但随机回归测试第一轮就报出了索引不匹配。后处理代码往往是“看着简单,错起来又不好找”的模块,固定一套回归用例是性价比最高的防线。

上面这些内容,做视觉的同事看完可能会觉得基础,但基础的东西恰恰最影响工程体验。我在新项目里最常遇到的是多类别处理和阈值搭配问题,单独看任何一个环节都简单,连起来就会出现千奇百怪的现象。如果你也准备自己实现一套检测后处理,我的建议是从IOU和NMS的手写版本开始,先把测试用例补齐,之后无论换什么模型,这套代码都能稳稳复用。最后提醒一句:手写一遍之后,你才会真正理解为什么成熟的推理框架要提供专门优化过的算子,而不是简单用两个for循环替代码就能解决所有问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进