028、后处理改进(一):NMS的缺陷分析与Soft-NMS、DIoU-NMS优化
从一次漏检说起
上周调一个嵌入式端的YOLO模型,在测试集上mAP看着还行,一到真实场景就出问题——密集目标场景下,同一个目标经常只出一个框,旁边紧挨着的另一个目标直接消失。第一反应是模型特征提取能力不足,但可视化中间特征图发现,模型其实两个目标都响应了,只是最后输出只剩一个。
问题出在后处理。默认的NMS(Non-Maximum Suppression)在目标重叠度高时,会直接干掉分数稍低的框,哪怕它们其实是另一个目标。这个坑很多工程师都踩过,尤其是做安防、工业检测这类目标密集的场景。
今天我们就拆开NMS,看看它到底在哪出了问题,以及怎么用Soft-NMS、DIoU-NMS这些优化策略把它修好。
NMS的经典流程与致命缺陷
标准NMS的代码大家应该都写过:
def nms(boxes, scores, iou_threshold):
# 按置信度降序排列
order = scores.argsort()[::-1]
keep = []
while order.size > 0:
i = order[0]
keep.append(i)
# 计算当前框和剩余框的IoU
ious = compute_iou(boxes[i], boxes[order[1:]])
# 删掉IoU大于阈值的框 —— 问题就出在这!
inds = np.where(ious <= iou_threshold)[0]
order = order[inds + 1] # 注意索引偏移
return keep
看起来挺合理:保留最高分框,抑制掉和它重叠度高的其他框。但这里有个强假设:同一个目标的所有候选框中,分数最高的那个一定是最准的。实际上不是这样,尤其是模型在复杂场景下,可能出现分数高的框反而定位差一点,分数稍低的框更准。
更严重的是,当两个目标靠得很近时,它们的框之间IoU可能也很高。比如两个人并肩站立,检测框IoU可能超过0.5,这时候NMS会直接干掉其中一个——这就是我们开头遇到的漏检问题。
Soft-NMS:温柔一点,别直接删除
Soft-NMS的思路很直观:既然直接删除(hard suppression)太粗暴,那就改成降低分数。重叠度越高,分数降得越多,而不是一刀切。
def soft_nms(boxes, scores, iou_thresh=0.5, sigma=0.5, score_thresh=0.001):
# sigma控制惩罚强度,越小惩罚越狠
keep = []
for i in range(len(boxes)):
max_pos = i + np.argmax(scores[i:])
boxes[i], boxes[max_pos] = boxes[max_pos].copy(), boxes[i].copy()
scores[i], scores[max_pos] = scores[max_pos], scores[i]
keep.append(i)
for j in range(i+1, len(boxes)):
iou = compute_iou(boxes[i], boxes[j])
# 核心改动:用高斯函数衰减分数,而不是直接置零
if iou > iou_thresh:
scores[j] *= np.exp(-(iou * iou) / sigma) # 高斯衰减
# 也有线性衰减版本:scores[j] *= (1 - iou) if iou > thresh else 1
# 把分数太低的框过滤掉,注意这里阈值设得很低
remaining = np.where(scores[i+1:] > score_thresh)[0]
boxes = np.concatenate([boxes[:i+1], boxes[i+1:][remaining]])
scores = np.concatenate([scores[:i+1], scores[i+1:][remaining]])
return keep
注意看那个np.exp(-(iou * iou) / sigma),这就是高斯衰减。两个框IoU越大,第二个框的分数乘的系数越小。这样即使两个框重叠很高,低分框也不会直接消失,只是分数降低。如果它真的是另一个目标,在后续排序中还有机会被保留。
实际部署时,Soft-NMS计算量比NMS略高,因为要遍历并更新分数。但在CPU上通常还能接受,如果上GPU可以并行优化。效果上,在密集目标场景(比如COCO的crowd子集)能有1-2个点的AP提升。
DIoU-NMS:考虑中心点距离,更符合直觉
但Soft-NMS还是只依赖IoU,没考虑框的几何关系。比如两个框重叠区域大,但可能一个框完全包含另一个(嵌套场景),或者两个框中心点离得很远但边缘搭了一点(其实可能是两个目标)。
DIoU-NMS把中心点距离加进来了:
def diou_nms(boxes, scores, iou_thresh=0.5, beta=0.6):
# beta是距离惩罚的权重
order = scores.argsort()[::-1]
keep = []
while order.size > 0:
i = order[0]
keep.append(i)
for j in order[1:]:
iou = compute_iou(boxes[i], boxes[j])
# 计算中心点距离惩罚
c_x1, c_y1 = (boxes[i][0]+boxes[i][2])/2, (boxes[i][1]+boxes[i][3])/2
c_x2, c_y2 = (boxes[j][0]+boxes[j][2])/2, (boxes[j][1]+boxes[j][3])/2
dist = ((c_x1-c_x2)**2 + (c_y1-c_y2)**2)
# 计算最小包围框对角线长度
c_w = max(boxes[i][2], boxes[j][2]) - min(boxes[i][0], boxes[j][0])
c_h = max(boxes[i][3], boxes[j][3]) - min(boxes[i][1], boxes[j][1])
c_diag = c_w**2 + c_h**2 + 1e-7 # 防止除零
# DIoU = IoU - (dist / c_diag)
diou = iou - (dist / c_diag)
# 用DIoU做抑制判断
if diou < iou_thresh:
# 保留这个框
pass
else:
# 从order中移除
order = order[order != j]
return keep
这个公式DIoU = IoU - (dist^2 / c_diag^2)很有意思:如果两个框中心点离得远,即使IoU不低,DIoU也会变小,就不容易被抑制。这更符合“两个框中心点远很可能是不同目标”的直觉。
在目标有部分遮挡但中心点分离明显的场景,DIoU-NMS效果比Soft-NMS更好。不过计算量又大了一点,因为要算中心点距离和外接矩形。
工程落地时的几点经验
-
别盲目换NMS:如果场景里目标稀疏、重叠少,传统NMS足够快且稳定。先分析你的数据,可视化一下GT框的IoU分布,再决定要不要上复杂后处理。
-
Soft-NMS的sigma要调:默认0.5不一定适合你的任务。在验证集上画一条曲线:横轴sigma(比如0.1到1.0),纵轴AP。通常0.3-0.6之间有个最优值,太小接近hard NMS,太大抑制不足。
-
部署时注意数值稳定性:Soft-NMS里的指数衰减可能让分数变得极小,最后浮点数下溢。所以代码里我设了
score_thresh=0.001,再小的直接丢弃,避免后续计算出问题。 -
嵌入式端谨慎用DIoU-NMS:多一次距离计算,在ARM CPU上可能增加几毫秒。如果帧率要求高,不如试试把Soft-NMS的线性衰减版本改成查表法,用空间换时间。
-
后处理和模型训练一起调:如果你的NMS阈值从0.5改成0.6,模型训练时最好也调整一下正负样本匹配的IoU阈值(比如ATSS、SimOTA中的设置),让训练和推理更匹配。
写在最后
后处理改进是个细活儿,容易被人忽略,但效果立竿见影。尤其是现在模型精度进入平台期,改一波NMS可能比调一个月网络结构提升还明显。
下次遇到密集目标漏检,别急着骂模型不行,先把NMS可视化出来看看——很可能只是后处理太“暴力”。先试Soft-NMS,简单有效;如果目标有特殊几何分布(比如中心点分离明显),再考虑DIoU-NMS。
实际项目里,我通常会在推理代码里留个后处理开关,方便AB测试。毕竟,没有最好的NMS,只有最适合你场景的NMS。
更多推荐
所有评论(0)