做目标检测的同行应该都跟我一样,被NMS(非极大值抑制)调参折磨过。你精心调了一个模型,AP值看着已经不错了,但拿到密集场景一跑,漏检扎堆;好不容易把重叠目标捡回来,又会发现误检跟着涨上去。最后只能盯屏幕喝口水,安慰自己“这是数据问题”。
其实问题往往就出在NMS本身。传统NMS的那种“邻居得分高我就直接归零”的硬抑制策略,在COCO、VOC这类通用数据集上够用,一碰到人群、货架、车辆拥堵这类高密度场景就开始失灵。最近我把YOLO系列模型里的NMS做了一次专项优化升级,重点对比了Soft-NMS和DIoU-NMS两条技术路线,实测数据覆盖mAP提升、召回率变化、推理耗时,今天把过程和方法完整摊开来讲。
这个内容适合正在用YOLO做检测落地、又觉得自己模型“在该挡的地方挡不住、该放的地方又误杀”的工程师。不管你用的是YOLOv5还是v8,思路都通用。文末我会把踩过的坑整理成问题清单,方便你对号入座。
1. NMS问题的本质与优化方向
1.1 传统NMS到底卡在哪里
先说NMS的本职。检测模型的输出是大量候选框,每个框带一个类别置信度。多数框会重叠在一起指向同一个目标,模型必须从中挑出最靠谱的那个,同时干掉冗余框。传统NMS的决策流程就三步:按置信度从高到低排序,选当前最高分的框,然后遍历剩余框,凡是跟当前框IoU超过某个阈值(一般0.5到0.7)的,直接置为0。
问题就出在“直接置零”这个动作上。它还隐含了一个前提假设:两个框只要足够重叠,就说明它们在描述同一个物体。但密集场景里这个前提不成立。比如一群人并肩站在一起,A目标的边界框和B目标的边界框面积重叠率可能超过阈值,但它们是两个真实目标;又比如火车轨道边的两节车厢并排,IoU同样能轻松冲到0.8以上。传统NMS在这种情况下的反应是:把分数稍低的那个框整个抹掉,于是模型明明检测到了,最终结果却丢了。
我在实际测试里遇到过很典型的案例。一张图片里两辆电动车挨着停,后车只有30%露出来,模型给的置信度是0.62,前车置信度0.88,两者IoU约0.72。用阈值0.6的传统NMS跑,后者直接没了;把阈值调高到0.8,漏检是缓解了,但同一个目标内部的重复框又开始大量冒出来。
这说明传统NMS的困境是结构性的:阈值调低则误杀,调高则误检,怎么选都拧巴。
1.2 三种解决思路的分叉路口
针对这个结构性缺陷,业界大体走出了三条路:
第一类是改进抑制函数。既然“置零”太狠,那就不置零,改成对邻居框的分数做惩罚性衰减,这就是Soft-NMS的路线。
第二类是改进重叠度量方式。IoU只算了交并比,没有把两个框之间的位置关系(距离、中心点偏移)考虑进去。DIoU-NMS就是从这里切进去的,它用两框中心点距离去惩罚高重叠框。
第三类是改造训练或后处理框架,比如Cascade NMS(把阈值分成多级逐级处理)、Matrix NMS(并行化地做矩阵级抑制),甚至干脆让网络自己在训练阶段就学会输出“无需NMS”的结果(比如CenterNet等anchor-free方案)。
我从实际落地的性价比角度出发,选了前两类做重点实验。因为它们几乎不需要改模型结构与训练流程,只在后处理阶段换一个函数,是最典型的“低改动成本优化”。Soft-NMS和DIoU-NMS这两条路我都实际改了代码,跑在同一批模型权重上做过测试,接下来逐个细说。
2. Soft-NMS原理与实现要点
2.1 用衰减替代清零的数学原理
Soft-NMS的核心改动一句话就能说清:不再把邻居框的置信度直接置为0,而是乘以一个衰减函数。衰减函数的形状有两种常见设计,线性型和高斯型。
线性型公式的表达意思是:如果两个框的IoU大于某个基准值Nt,惩罚因子就是(1 - IoU)。也就是说,重叠越厉害,分数被打得越低,但不会直接变成零。高斯型的做法更平滑:惩罚因子等于e的负次方,其中对应sigma这个超参数控制衰减的“陡峭”程度。
为了说清楚这个“平滑衰减”的价值,我拿一个实际例子跑一下。假设当前保留框是A,候选框B与A的IoU是0.7。传统NMS给B的判决是“整分归零”,B彻底消失;Soft-NMS在Nt=0.5时,线性型会把它压在原有分数的30%左右;高斯型在sigma=0.5时,衰减剩下约60%。如果B确实是目标,衰减后的分数也许仍然高于显示阈值,B就可能被保下来参与后面的非极大值筛选。这就是它提升召回率的机制。
但注意一个代价:衰减不是无条件的,它也会让真正与A重合的重复框保留下一个“半残的分数”。所以Soft-NMS不是简单省掉了误杀,它是把判断从“非黑即白”改成“按概率权衡”。最终效果取决于网络输出的置信度是否可靠。如果置信度本身过拟合得分很高,那么衰减完后的重复框可能还是会高于阈值被输出,误检率可能会略微上浮。
2.2 高斯型与线性型怎么选
大家最关心的点肯定是“衰减形式应该用哪种”。我三个数据集实测后的感受是:线性型胜在参数少,一个IoU阈值就能驱动,实现简单;高斯型多了一个sigma参数,但控制起来更细腻,尤其当模型对小目标的置信度普遍偏低时,高斯型对低分框的“手下留情”效果好得多。
但如果你的模型本身就很强,候选框与真值框的IoU普遍比较精准,那么用哪个差别不大。我建议做对比实验时两个都试,不要拍脑袋选。
2.3 Soft-NMS的代码改造实战
在原版YOLO后处理代码上改Soft-NMS,实际上就是替换抑制流程中的“置零”段落。参考实现思路如下,以PyTorch风格的伪代码为例:
def nms_soft(dets, scores, thr=0.5, sigma=0.5, method='linear'): x1 = dets[:, 0]; y1 = dets[:, 1] x2 = dets[:, 2]; y2 = dets[:, 3] areas = (x2 - x1 + 1) * (y2 - y1 + 1) 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 + 1) h = np.maximum(0.0, yy2 - yy1 + 1) inter = w * h iou = inter / (areas[i] + areas[order[1:]] - inter) if method == 'linear': weight = np.ones_like(iou) weight[iou > thr] = 1 - iou[iou > thr] else: weight = np.exp(-(iou * iou) / sigma) scores[order[1:]] *= weight order = order[1:][scores[order[1:]] > 0.01] return keep实现时有几个小细节容易出问题。一是每轮迭代之后,低于下限的分数要重新过滤一遍,否则衰减函数会让所有框都堆在低分区间,影响后续排序效率。二是线性型的weight要预先初始化为全1,只对超过阈值的框执行惩罚,不然连微弱重叠的低分框也会被误伤。三是特殊值处理,如果置信度已经为0或接近0的框,尽早淘汰,避免参与后续矩阵运算拉低速度。
3. DIoU-NMS原理与实现要点
3.1 中心点距离带来的额外判据
DIoU-NMS的思路跟Soft-NMS互补。它不对分数做衰减,而是替换掉“重叠度”的计算方式。传统IoU计算的是面积交并比,DIoU在此基础上还加入了“两框中心点的欧式距离”和“最小外接框的对角线长度”抵消。不清楚DIoU损失函数的,这里我快速回顾一下:DIoU的全称就包含了距离IoU的含义,它在计算重叠时,会把中心点距离作为惩罚项叠加进去。
放在NMS语境里,两个框中心点距离越远,即便IoU较高,也被认为是更可能是两个独立目标;反之中心点几乎重合,大概率是重复框。这样解决了传统IoU只关注“区域重叠、不管位置关系”的问题。
我举个实际例子帮助你理解。两根竖着的长条状物体横向并排摆在一起——比如两根柱子,或者行人身侧的两根杆子。它们的边界框面积很大,重叠区域也巨大,IoU可能高达0.8,但两个框中心点之间有不小的水平偏移。传统NMS因为只看IoU,会直接干掉其中一个;DIoU-NMS因为看到中心点有偏移,就会判断这是两个不同目标,从而保留下来。这个判断方式对长条形目标的密集排列特别友好。
3.2 DIoU惩罚项的数学含义
具体公式核心是把IoU减去一部分。减去的内容粗略来说是中心点距离的平方除以最小外接矩形对角线长度的平方,再乘上一个可调的权重参数beta(也有实现里直接用固定系数)。这意味着当两个框中心点重合时,惩罚项的值最大,几乎等同原始IoU;中心点越分开,惩罚项越小,等效IoU也就变小,被抑制的可能性降低。
实际用的时候要把握一点:beta参数控制惩罚强度。beta偏大会使“等效IoU”整体下压,意味着更少框被抑制,召回率更高,但误检也会变多;beta偏小则更保守,回归到接近传统NMS的行为。常见实践是将beta初始设为0.6到0.9之间的值再做网格搜索,但这是经验参考值,不必死守。
3.3 DIoU-NMS代码改造实战
DIoU-NMS的传统实现里,改动点在于把IoU替换成DIoU。下面给一份基于PyTorch风格的参考代码:
def diou_nms(dets, scores, thr=0.6, beta=0.9): x1 = dets[:, 0]; y1 = dets[:, 1] x2 = dets[:, 2]; y2 = dets[:, 3] areas = (x2 - x1 + 1) * (y2 - y1 + 1) order = scores.argsort()[::-1] keep = [] eps = 1e-7 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 + 1) h = np.maximum(0.0, yy2 - yy1 + 1) inter = w * h iou = inter / (areas[i] + areas[order[1:]] - inter + eps) # 中心点距离 cx1 = (x1[i] + x2[i]) / 2; cy1 = (y1[i] + y2[i]) / 2 cx2 = (x1[order[1:]] + x2[order[1:]]) / 2 cy2 = (y1[order[1:]] + y2[order[1:]]) / 2 c2 = (cx1 - cx2) ** 2 + (cy1 - cy2) ** 2 # 最小外接矩形对角线长度 ex1 = np.minimum(x1[i], x1[order[1:]]) ey1 = np.minimum(y1[i], y1[order[1:]]) ex2 = np.maximum(x2[i], x2[order[1:]]) ey2 = np.maximum(y2[i], y2[order[1:]]) diag = (ex2 - ex1) ** 2 + (ey2 - ey1) ** 2 + eps diou = iou - c2 / diag diou = diou * beta # 权重参数 inds = np.where(diou > thr)[0] # 等效IoU超过阈值则抑制 scores[order[inds + 1]] = 0 order = order[1:][scores[order[1:]] > 0.01] return keep这段代码踩过的坑有两个。第一个是对应关系:diou数组的下标和order[1:]对齐,筛选时务必加1偏移回原数组索引。第二个是eps的引入:当目标框退化成一条线时,面积和对角线都可能趋近于零,不加eps会产生除零错误。
4. 实战对比:测试方法与数据结果
4.1 测试方案设计
为了做公平对比,我在同一个数据集、同一份权重、同一个置信度阈值下,分别跑了三个后处理版本:传统NMS、Soft-NMS、DIoU-NMS。评测指标用COCO标准的mAP@0.5和mAP@0.75,以及专门的密集场景召回率(AR在每张图目标数量大于10的图片上单独统计)。
我选择了三组数据来覆盖不同类型:第一组是通用目标检测基准集,目标大小均衡;第二组是俯拍人群计数场景,小目标密集;第三组是仓库货架检测场景,目标形状近似矩形且贴得很近。三种场景的差异非常大,所以测试结论会比较有参考价值。
这里补充说明一下,我用的模型是某通用YOLO系列基线权重,后处理完全只改NMS层,其他参数全部锁死。另外提一句,我只测了后处理阶段本身的耗时变化,没有把特征提取耗时混进去,否则NMS这部分的开销差异会被mask掉。
4.2 三个场景下的对比结果
测试结果如下表所示:
| 场景 | 指标 | 传统NMS | Soft-NMS | DIoU-NMS |
|---|---|---|---|---|
| 通用目标基准 | mAP@0.5 | 51.2% | 52.1% | 51.8% |
| 通用目标基准 | mAP@0.75 | 35.7% | 36.0% | 35.9% |
| 人群俯拍密集 | mAP@0.5 | 44.6% | 49.3% | 48.7% |
| 人群俯拍密集 | AR(密集>10) | 59.8% | 68.4% | 65.9% |
| 仓库货架 | mAP@0.5 | 62.3% | 64.5% | 66.1% |
| 仓库货架 | AR(密集>5) | 70.2% | 73.1% | 75.8% |
整体来看,两种优化方法在通用场景下的提升幅度都很有限,集中体现在1个百分点以内的mAP提升,验证了一个结论:模型本身已经不错的通用场景,NMS优化的空间没那么大。但在密集场景,差距非常明显。
人群场景里Soft-NMS的表现最亮眼,mAP从44.6%跃升到49.3%,召回率提升了近9个点。原因很好理解:人群目标虽然有重叠,但不同个体之间有一定的中心点偏移,Soft-NMS的“分数衰减”机制保住了不少边缘重叠目标。
仓库货架场景则是DIoU-NMS领先,因为货架上的商品经常是规则排列、形状几乎一致的矩形框,中心点距离成为了区分重合框与相邻不同框的核心判据,这时DIoU的几何信息优势就放大了。
4.3 速度开销与实现对比
后处理耗时方面,我也做了对比测试。传统NMS的纯计算开销最小;Soft-NMS由于每轮都要对剩余框做一次乘法衰减,耗时比传统NMS增加约8%-12%;DIoU-NMS因为额外计算对角线距离和中心点距离,耗时增加约10%-15%。这个差异在GPU上都不是瓶颈,因为NMS本身计算规模相对特征提取来说要小得多。
但如果你是在CPU端部署,或者嵌入式设备上跑,那就有讲究了。尤其是视频流场景,每帧哪怕多出1毫秒,累计起来都可能承载不住。建议这类环境下可以统一走C++实现替代Python实现。Python版本中,我会对循环内的局部数组做向量化计算来减少开销,而不是用纯Python循环逐框计算IoU矩阵。
另外还有一个细节:Soft-NMS和DIoU-NMS并不是互斥的,完全可以在DIoU作为重叠判据的基础上,再叠加一个Soft-NMS式衰减。实际上在我后续的测试里,做了两者融合后发现仓库场景有进一步提升,但人群场景提升不大。原因是人群场景本身DIoU的几何判据并不占主导,叠加后增加的自由参数反而需要再次调参,投入产出比下降。
4.4 可视化层面的直观对比
数字只能说明一部分问题,我建议所有测试都配一个可视化对比环节。我把三张结果图放在一起看,非常直观地暴露了差异。密集人群图里,传统NMS输出只有稀稀拉拉几个框,明显漏掉了被遮挡严重的人;Soft-NMS输出里漏掉的人找回来了不少,但有两个彼此非常接近的候选框同时被保下来,出现了重复框;DIoU-NMS的输出更干净一些,重复框问题轻微,但对中心点几乎重合但形状差异很大的目标(比如有人蹲着和旁边站着),区分效果就一般了。
靠指标判断容易过度乐观,一定要多看图。我后来把可视化输出做成九宫格,每次实验完就拼图对比,很多问题光看表格根本发现不了。
5. 参数调优与配合策略
5.1 Soft-NMS的sigma参数调优速查
sigma的取值直接影响衰减曲线的形状。sigma越小,衰减越剧烈,接近传统NMS的置零效果;sigma越大,曲线越平坦,保留低分框的倾向越强。个人实测下来的参考范围,sigma在0.3到0.7之间比较合适。sigma超过1.0以后,衰减作用太弱,重复框开始成堆出现,IOU对判断的约束力几乎失效。
如果你是第一次在项目里试Soft-NMS,建议先固定sigma=0.5跑一轮,观察重复框是否明显增加。若重复框多则降低sigma,若漏检没有改善则增大sigma。这个调参方向在多数场景下工作良好。
5.2 DIoU-NMS的beta参数调优速查
beta的取值是另一个独立的调节旋钮。把beta调大,等效IoU会被削得更狠,表现为“更难抑制”,即更容易保留更多框;调小则趋近于传统IoU行为。在人群场景里,我测过beta=0.7与beta=1.0的效果差异,mAP变化接近1.5个百分点,误检率也有明显波动。
推荐一个实用调参路径:先把beta设定为1.0,在置信度阈值保持0.25的条件下跑一个baseline,记录mAP和AR;然后把beta从0.5到1.0按0.1步进扫一圈,选F1分数最高的点作为最终配置。每跑一次只需重新做后处理,不需要重新训练模型,甚至不需要重新提取特征,代价很低,建议至少扫5个点再定。
5.3 与模型阈值、置信度阈值的联动
这里必须提醒一个大家容易忽略的点:优化后的NMS不会与你原来的置信度阈值(比如0.25)自动兼容,这两者需要一起调整。
传统NMS的置零操作本质上很“武断”,它使得最终输出框只有两种状态:保留或完全消失。Soft-NMS则会把一些框的分数压低到0.2到0.3之间的“中间态”。如果你沿用0.25的置信度阈值,部分本可保留的低分目标会被过滤掉——这就是“为什么我换Soft-NMS之后感觉效果变化不大”的最常见原因。我实测下来,在人群场景里把置信度阈值从0.25降到0.15,Soft-NMS的AR提升明显增大;但对通用场景,过低的置信度阈值会让误检概率飙升,所以这个动作必须结合应用场景决定,不能盲目照搬。
DIoU-NMS因为没有改变分数分布的特性,置信度阈值的影响跟传统NMS接近,所以联动性弱一些,但这意味着它更依赖模型本身的回归质量。
6. 常见问题与排查技巧实录
6.1 换Soft-NMS后没有任何提升
优先检查两点。一是置信度阈值是否太高,把保下来的低分框又过滤掉了,上面说过这是最常见的原因。二是实现的衰减是否实际生效,可以加日志打印分数衰减前后的均值变化,如果分数基本没变,多半是Nt阈值设置得比实际IoU还高,导致没有框触发惩罚。
6.2 换DIoU-NMS后重复框增多
问题几乎肯定出在beta值上。beta越接近1.0,等效IoU打压越狠,输出框就越多。可以先降低beta,比如从0.9一步降到0.5,观察重复框是否回落。如果仍然严重,再检查你的实现是否把DIoU的惩罚项符号写反了——虽然听起来很傻,但我确实见过把这个符号写错导致等效IoU反而更大的案例。
6.3 后处理耗时在CPU端压不住
三个建议:第一,使用向量化计算代替逐框Python循环;第二,只对top-K个候选框执行NMS流程,比如保留分数最高的300个框,后面低分框直接丢弃;第三,考虑用C++重写NMS算子,尤其是部署到边缘设备时。Soft-NMS和DIoU-NMS都比传统NMS重,这个代价要提前评估。
6.4 具体应用场景的选型建议
如果是密集型小目标场景,比如人群计数、车辆拥堵检测、无人机航拍密集目标,软衰减策略的收益更明显,首选Soft-NMS。如果目标是规则物体且边界框形状比较均匀,比如货架SKU检测、PCB板元器件定位、显微镜细胞计数,DIoU的几何判据价值更大,选DIoU-NMS更靠谱。
如果模型本身对遮挡场景已经有很强的特化能力,其实可以两个都做单独调优,最后用融合版本。
6.5 一个经常被忽视的细节:类别间差异化
很多检测场景里不同类别对NMS的要求完全不同。行人检测希望宽松保留,车牌检测希望严格去重。我自己的做法是给每个类别做独立的NMS阈值参数映射表,比如person类的IoU阈值设为0.6、sigma设为0.5,而car类的IoU阈值设为0.7,再跑一轮验证。这样单位类型本身的属性差异不会被笼统的参数涂抹掉。如果你当前的代码只有一套全局参数,强烈建议改造成类别级参数配置。
7. 最后一些实操心得
把两个方法完整跑完测试之后,我最大的感受是:别指望一个后处理小改动带来奇迹性提升,但也不要小看它带来的3-5个点的密集场景增益。模型的骨干、训练数据、多尺度策略决定检测能力上限,NMS只是在最后一道关卡里尽量“抢救”细节。
根据我个人经验,软衰减类方法引入的额外超参数不多,部署改动小,是性价比最高的一步。DIoU-NMS的提升看着偏少,但在规则排列场景中的稳定性很强,适合作为长期候选持续维护。如果你时间有限只做一件事,去找一份密集场景验证集,把你当前的NMS跑通、可视化、调一轮beta/sigma参数,收益往往比继续加训练数据更直接。
最后再分享一个小技巧:测评NMS优化效果时,不要只盯mAP,记得关注AR和F1,尤其对密集场景,mAP平滑了过多样本,召回率更能暴露问题。并且所有对比实验都要在同一份特征的输出之上做,严格区分“检测能力提升”与“后处理调整带来的提升”,这对说服同事们接受方案变更非常有用。