从一次漏检说起

上周调一个嵌入式端的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更好。不过计算量又大了一点,因为要算中心点距离和外接矩形。


工程落地时的几点经验

  1. 别盲目换NMS:如果场景里目标稀疏、重叠少,传统NMS足够快且稳定。先分析你的数据,可视化一下GT框的IoU分布,再决定要不要上复杂后处理。

  2. Soft-NMS的sigma要调:默认0.5不一定适合你的任务。在验证集上画一条曲线:横轴sigma(比如0.1到1.0),纵轴AP。通常0.3-0.6之间有个最优值,太小接近hard NMS,太大抑制不足。

  3. 部署时注意数值稳定性:Soft-NMS里的指数衰减可能让分数变得极小,最后浮点数下溢。所以代码里我设了score_thresh=0.001,再小的直接丢弃,避免后续计算出问题。

  4. 嵌入式端谨慎用DIoU-NMS:多一次距离计算,在ARM CPU上可能增加几毫秒。如果帧率要求高,不如试试把Soft-NMS的线性衰减版本改成查表法,用空间换时间。

  5. 后处理和模型训练一起调:如果你的NMS阈值从0.5改成0.6,模型训练时最好也调整一下正负样本匹配的IoU阈值(比如ATSS、SimOTA中的设置),让训练和推理更匹配。


写在最后

后处理改进是个细活儿,容易被人忽略,但效果立竿见影。尤其是现在模型精度进入平台期,改一波NMS可能比调一个月网络结构提升还明显。

下次遇到密集目标漏检,别急着骂模型不行,先把NMS可视化出来看看——很可能只是后处理太“暴力”。先试Soft-NMS,简单有效;如果目标有特殊几何分布(比如中心点分离明显),再考虑DIoU-NMS。

实际项目里,我通常会在推理代码里留个后处理开关,方便AB测试。毕竟,没有最好的NMS,只有最适合你场景的NMS。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐