从产线误检说起

上周产线反馈了个头疼的问题:同一批电路板,用我们部署的YOLO模型检测,相邻两帧居然对同一个芯片给出了位置偏差很大的边界框。质检员手动复检时发现,两个框其实都只框住了芯片的一部分——模型在“抖动”。这种问题在NMS后处理中很常见:当多个重叠框的IoU达不到阈值时,NMS会武断地只留一个,其余全删。但在实际场景中,这些框可能都是“部分正确”的。

NMS的局限性

传统NMS的逻辑很简单:按置信度排序,最高分的框留下,与其重叠度高的框删除。这个逻辑有个致命假设——得分最高的框一定是最准确的。但在复杂场景下,模型会有多种“犹豫”:光照变化导致边界模糊、遮挡造成特征不全、相似物体密集排列……这时候多个框可能互补,而NMS的“赢者通吃”策略反而丢弃了有用信息。

我们试过调高IoU阈值,结果漏检增多;调低阈值,又出现重复框。这种二选一的困境,正是我们需要WBF(加权框融合)的原因。

WBF的核心思想

WBF不做“选择”,而是做“融合”。它的思路很工程师:既然多个检测框都有道理,那就把它们当做一个委员会,每个框根据置信度投票,最终决策是加权平均的结果。

具体实现时,WBF先对所有框按置信度排序,但不是直接删除重叠框,而是将IoU超过阈值的框归为一组。每组内,根据每个框的置信度计算权重,对框的坐标进行加权平均。置信度高的框话语权大,但低置信度框也能贡献信息。

def weighted_box_fusion(boxes, scores, iou_thr=0.5):
    """
    简易版WBF实现,实际部署要考虑很多边界条件
    boxes: [[x1,y1,x2,y2], ...]  # 注意这里用xyxy格式
    scores: 对应置信度列表
    """
    # 按置信度降序,这是老规矩了
    order = scores.argsort()[::-1]
    boxes = boxes[order]
    scores = scores[order]
    
    # 这里用列表存最终结果,动态添加
    fused_boxes = []
    fused_scores = []
    
    while len(boxes) > 0:
        # 取当前最高分框作为基准
        current_box = boxes[0]
        current_score = scores[0]
        
        # 找和它重叠的“伙伴”
        ious = calculate_iou(current_box, boxes)
        similar_mask = ious > iou_thr
        
        if np.sum(similar_mask) == 1:
            # 没有重叠框,直接保留
            fused_boxes.append(current_box)
            fused_scores.append(current_score)
        else:
            # 关键步骤:加权融合
            similar_boxes = boxes[similar_mask]
            similar_scores = scores[similar_mask]
            
            # 权重用置信度,这里可以尝试平方或其他加权方式
            weights = similar_scores / np.sum(similar_scores)
            
            # 坐标加权平均
            fused_box = np.sum(similar_boxes * weights[:, None], axis=0)
            fused_score = np.mean(similar_scores)  # 融合后的置信度可以灵活处理
            
            fused_boxes.append(fused_box)
            fused_scores.append(fused_score)
        
        # 移除已处理的框,继续下一轮
        boxes = boxes[~similar_mask]
        scores = scores[~similar_mask]
    
    return np.array(fused_boxes), np.array(fused_scores)

注意上面这个简化实现有个坑:加权融合时,如果两个框置信度都很高但位置偏差大,融合结果可能反而“模糊”。实际工程中要加判断,比如融合前后框面积变化过大时,考虑不融合。

多模型集成策略

WBF更强大的地方在于它能融合不同模型的输出。我们在产线部署时,用了三个变体模型:一个专注小目标,一个对大目标更准,另一个在遮挡情况下表现稳定。三个模型独立推理,结果用WBF融合。

# 实际项目中的多模型WBF片段
def multi_model_fusion(model_outputs_list):
    """
    model_outputs_list: 多个模型的输出列表
    每个元素是(boxes, scores, labels)
    """
    all_boxes = []
    all_scores = []
    all_labels = []
    
    # 收集所有模型的检测结果
    for boxes, scores, labels in model_outputs_list:
        # 这里可以加模型权重,比如A模型整体置信度乘0.9
        all_boxes.extend(boxes)
        all_scores.extend(scores)
        all_labels.extend(labels)
    
    # 按类别分别处理
    unique_labels = np.unique(all_labels)
    final_results = []
    
    for label in unique_labels:
        mask = np.array(all_labels) == label
        label_boxes = np.array(all_boxes)[mask]
        label_scores = np.array(all_scores)[mask]
        
        # 对同一类别的框做WBF
        fused_boxes, fused_scores = weighted_box_fusion(label_boxes, label_scores)
        
        for box, score in zip(fused_boxes, fused_scores):
            final_results.append([*box, score, label])
    
    return final_results

这种多模型融合在边缘设备上要谨慎。我们最初在Jetson上直接跑三个模型,帧率从25掉到8。后来改用TensorRT优化,模型量化到INT8,三个小模型并行跑,总算维持在15帧以上。

部署时的工程细节

WBF在服务器端跑很简单,但边缘端部署时要注意几点:

第一,IoU阈值别照搬论文。我们发现在密集小目标场景,阈值要降到0.3~0.4;大目标场景可以到0.6。这个参数和你的业务场景强相关。

第二,置信度加权方式可以调整。标准WBF用置信度直接加权,我们试过用置信度的平方,让高置信度框权重更大,效果在某些场景更好。

第三,内存管理。WBF需要保存所有候选框,内存消耗比NMS大。在嵌入式设备上,最好预设最大处理框数,避免内存溢出。

第四,实时性要求高的场景,可以分两阶段:先用轻量级NMS做初步筛选,再用WBF精细处理。这样能在精度和速度间折中。

个人经验谈

WBF不是银弹。我们在PCB缺陷检测上用它,mAP提升了3.2%;但在行人检测场景,提升不到1%。后来分析发现,行人检测的框通常比较规整,NMS已经够用。而PCB上的划痕、焊点缺陷,形状不规则,多个模型从不同角度“看”到的信息确实互补。

建议先分析你的检测失败案例:如果是模型根本“看不到”目标,WBF帮不上忙;如果是模型看到了但框不准,或者不同帧间结果不一致,WBF值得一试。

多模型集成时,别选三个同质化的模型。我们最初用三个不同训练轮次的YOLO变体,效果提升有限。后来换成了不同backbone(CSPDarknet、EfficientNet、MobileNet)的模型,差异大了,融合效果才明显。

最后提醒,WBF会增加推理延迟。在产线上,我们最终方案是:正常情况用NMS,当连续多帧检测结果波动大时,自动切换到WBF模式。这种动态策略,既保证了平均速度,又在关键时候提升了精度。

模型部署永远是权衡的艺术。WBF给了我们另一种权衡维度——不是“选哪个框”,而是“如何把框的信息合并起来”。这种思路,在其他工程问题上也值得借鉴。

Logo

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

更多推荐