WBF vs NMS:多模型检测结果融合的加权边界框融合实战解析 1. 为什么我从 NMS 换成了 WBF多模型融合时的那点不甘心做目标检测的人应该都经历过这种场景好不容易把一个检测模型的 ensemble 跑通十来个模型各自吐出一堆框有准的、有偏的、有重复的然后你习惯性地交给你最熟悉的 NMSNon-Maximum Suppression去处理。结果呢涨点微乎其微有时候甚至还往下掉几分。我当时的困惑也在这里。NMS 做得没错它确实把重复框滤得干干净净但问题是——它同时也是在“做选择”。它选了置信度最高的那个框然后把它附近的框全部干掉。这在单个模型上没什么毛病可当你有十几个模型、每个模型的误差模式又不一样的时候那些被干掉的框里很可能有某个模型在那个位置其实预测得更准的候选边界。你每压一次 NMS就等于把一部分模型的观点直接清零而不是把它们综合起来。后来我在一次目标检测竞赛的社区帖子里看到了一种叫 Weighted Boxes FusionWBF的做法。第一感觉是“这名字太朴素了”第二感觉是“这玩意儿居然真能把框融合起来而不是淘汰掉”。仔细读完流程之后我才意识到NMS 和 WBF 的根本区别不在实现细节上而在对检测结果的态度上NMS 是一个“局部最优筛选器”它从多个框里挑一个WBF 则是一个“加权求和的融合器”它把多个框合并成一个更合理的框顺便保留置信度信息。这篇文章我就围绕 WBF 这件事展开把我从原理到实现、再到调参踩坑的完整过程都梳理一遍。如果你也在做多模型集成、TTATest-Time Augmentation结果合并或者单纯想知道除了 NMS 之外还能怎么处理检测输出这篇文章应该能帮上忙。我不打算只贴代码还会尽量把“为什么这样设计”讲清楚因为我发现很多人用 WBF 用不好问题往往不是代码写错而是对它的假设和边界理解不够。2. WBF 的融合逻辑拆解它凭什么比 NMS 更适合作集成2.1 从“框”而不是“分数”的角度理解问题要理解 WBF先要建立一个概念目标检测模型的输出是一组带置信度的框每个框本质上是一个“观点”——我觉得这个物体在这里大小大概是这样可能性是 0.87。多个模型的输出叠加以后实际上是一次对同一个场景的多次重复观测只不过每个观测都带噪声。NMS 的思路是既然大家说得差不多那就只信那个分最高的。它的优点是快、简单、稳定缺点也很明显——它没有利用“多个模型一致认为这里有个框”这个信息。WBF 换了个角度它不准备“淘汰”任何一个框而是先按 IoU 把高度重合的框分成一组然后把这一组里的框按置信度加权合并出一个代表框。举个生活化的例子几个人对一个物体的位置各估计了一次有人偏左有人偏右有人估得准一点。与其直接选那个最“自信”的人不如让大家按可信度投票最后取一个加权平均位置——只要大家的误差是不同方向的这个平均位置通常比任何单个人的估计都更接近真实位置。这就是 WBF 的核心思想。2.2 算法执行过程三个步骤没有黑魔法WBF 的完整流程可以拆成三个阶段第一步全部框按置信度降序排列。注意这里是全局排序不是分组后排序。排序的目的是让置信度高的框优先成为“簇”的种子。第二步遍历排序后的框决定它应该归入哪个簇。对于当前框计算它与所有已有簇的 IoU。如果和某个簇的 IoU 超过阈值常见默认值 0.55就把这个框放入该簇并用簇内所有框的信息更新簇状态。如果没有任何簇满足 IoU 阈值就新建一个簇以当前框作为簇的初始成员。这里有一个细节值得强调NMS 在计算 IoU 时也是类似的过程但 NMS 一旦发现 IoU 超过阈值就会把那个框直接丢掉。WBF 不会丢它会把框加入簇然后继续遍历下一个框。这个区别最终导致了两个方法在召回率表现上的差异。第三步逐个簇生成融合框。当所有框都被归入某个簇之后每个簇产生一个输出框。输出框的坐标计算公式为$$Box_{x1} \frac{\sum_{k} C_k \cdot Box_{k,x1}}{\sum_{k} C_k}$$对应地$y_1$、$x_2$、$y_2$ 也使用同样的加权方式。也就是说最终的框坐标是簇内所有框的坐标按置信度加权求和后的结果。离最终输出框更近的框不一定是置信度最高的框而是这一簇里所有框的集体投票结果。置信度的重标定公式则更关键$$C_{final} \frac{\sum_{k} C_k}{N} \cdot \min(N, N_{max})$$通俗地讲先算出簇内所有框的平均置信度然后乘上簇内框的数量。为什么要乘数量呢因为簇里框的数量越多说明越多模型在这个位置达成了共识这个位置越有可能是真目标。这就让 WBF 拥有了一个 NMS 完全没有的特性——它会把“被多个模型同时支持”的检测结果抬高而不是只看单独一个框的分数。2.3 融合后置信度会发生什么变化这个重标定机制的实际效果很微妙。比如三个模型对一个目标给出了置信度 0.5、0.7、0.6 的框平均置信度是 0.6乘以数量 3得到 1.8——最终置信度超过了 1。如果你拿到这个分数直接当作概率可能会觉得奇怪但在实际的竞赛评分场景里这个分数只是用来排序的不是严格的概率估计所以超过 1 也没关系只要相对大小能拉开档次就行。也正是因为这个特性WBF 天然有利于“多模型一致的场景”。如果一个目标只有一个框支持哪怕它的置信度高达 0.95融合后的分数也只会是 0.95但如果这个目标有四个模型都给了 0.7 左右的框融合后的分数反而会达到 2.8 左右排位会大幅靠前。这在 NMS 里是完全不可能发生的。理解这一点之后你再去调 WBF 的 IoU 阈值思路就会清晰很多。3. 一个最小可用的 WBF 实现代码不长但每行都别乱改3.1 动手前先统一输入格式WBF 对输入有一个基本要求所有框的坐标必须处于同一个坐标系。这个要求在实际使用中特别容易被忽略。我见过有人把不同模型的输出直接扔进 WBF结果有的框是原图尺寸、有的框是 1024×1024 缩放后的尺寸IoU 计算出来的值完全失真融合效果当然一塌糊涂。正确做法是在调用 WBF 之前先把所有框统一还原到同一个尺度上。最简单粗暴但不推荐的办法是还原到原图尺寸更推荐的做法是都还原到推理时使用的标准尺寸。只要输入框代表的物理区域是统一的WBF 就是可靠的。当前框里有几个框是 0.1 置信度的噪声框也没关系他们不会主导融合结果后面我会专门讲为什么。3.2 Python 实现与逐步说明下面我用 Python NumPy 写了一个简化版的 WBF 实现没有依赖任何检测框架方便你把逻辑拆开看明白import numpy as np from typing import List, Tuple def wbf(boxes_list: List[np.ndarray], scores_list: List[np.ndarray], iou_threshold: float 0.55, skip_box_threshold: float 0.0): boxes_list: 每个模型的框坐标列表每个元素 shape 为 (N_k, 4)格式 [x1, y1, x2, y2] scores_list: 每个模型对应的置信度每个元素 shape 为 (N_k,) iou_threshold: 判断两个框是否属于同一簇的 IoU 阈值 skip_box_threshold: 低于该置信度的框直接跳过不参与融合 # 做一次全局拼接方便统一处理 all_boxes np.concatenate(boxes_list, axis0) all_scores np.concatenate(scores_list, axis0) # 1. 过滤低置信度框可选 valid all_scores skip_box_threshold all_boxes all_boxes[valid] all_scores all_scores[valid] # 2. 按置信度降序排列 sort_idx np.argsort(all_scores)[::-1] all_boxes all_boxes[sort_idx] all_scores all_scores[sort_idx] clusters [] # 每个簇包含的框索引 cluster_boxes [] # 每个簇当前的融合框用于 IoU 比较 cluster_scores [] # 每个簇当前所有框的置信度列表 def iou(box_a, box_b): 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]) inter_w max(0.0, x2 - x1) inter_h max(0.0, y2 - y1) inter_area inter_w * inter_h 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 if union_area 0: return 0.0 return inter_area / union_area for i in range(len(all_boxes)): box all_boxes[i] score all_scores[i] best_cluster_idx -1 best_iou 0.0 # 找出与当前框 IoU 最高的簇 for c_idx, c_box in enumerate(cluster_boxes): current_iou iou(box, c_box) if current_iou best_iou: best_iou current_iou best_cluster_idx c_idx if best_cluster_idx 0 and best_iou iou_threshold: # 归入已有簇 clusters[best_cluster_idx].append(i) cluster_scores[best_cluster_idx].append(score) # 更新该簇的临时融合框这里直接用置信度加权 boxes_in_cluster all_boxes[clusters[best_cluster_idx]] scores_in_cluster np.array(cluster_scores[best_cluster_idx]) weights scores_in_cluster / scores_in_cluster.sum() new_box (boxes_in_cluster * weights[:, None]).sum(axis0) cluster_boxes[best_cluster_idx] new_box else: # 新建簇 clusters.append([i]) cluster_boxes.append(box) cluster_scores.append([score]) # 3. 生成最终结果 fused_boxes [] fused_scores [] for c_idx, cluster_idx_list in enumerate(clusters): boxes_in_cluster all_boxes[cluster_idx_list] scores_in_cluster np.array(cluster_scores[c_idx]) # 坐标按置信度加权求和 weights scores_in_cluster / scores_in_cluster.sum() fused_box (boxes_in_cluster * weights[:, None]).sum(axis0) # 置信度平均分 * 簇内框数 fused_score scores_in_cluster.mean() * len(cluster_idx_list) fused_boxes.append(fused_box) fused_scores.append(fused_score) return np.array(fused_boxes), np.array(fused_scores)这段代码是我刻意压缩过的突出的是算法骨架没有做向量化加速。你要是直接用到上万张图的推理流程里可以再优化比如用numpy的广播一次性算全部框对全部簇的 IoU而不是写 Python 循环或者用torch.cuda把 IoU 计算放到 GPU 上。但先不要急着优化先把逻辑跑通再谈速度。3.3 从代码里读出来的两个关键设计第一簇的“临时融合框”是动态更新的。每加入一个新框我都更新了cluster_boxes中该簇的融合框坐标后续新框再和这个簇比对 IoU 时用的就是更新后的融合框。这个设计让簇的边界会随着新框加入而微调比固定拿第一个框当代表更合理。不少简化版实现没有做这一步你会看到它们的 IoU 比较对象始终是簇里第一个框结果对排序顺序异常敏感效果不稳定。第二最终置信度用的是“平均置信度 × 簇内框数”而不是把置信度直接相加。两者的差别体现在不同模型置信度尺度差异大的时候。如果某个模型的置信度整体偏高比如输出 0.9 以上直接相加会让它主导融合分数取平均再乘数量则在一定程度上抑制了这种尺度偏差。当然这只能缓解不能根治根治方法我在后面第 5 节会细说。4. WBF 的适用边界什么时候它比 NMS 强出一大截4.1 三类典型场景的对比实测为了把“WBF 什么时候值得用”说清楚我用一个公开目标检测风格的数据集做了一次对照实验数据集本身不方便指名但实验设计可以完整复现。我用三个结构差异较大的检测模型分别做单模型推理、NMS 后处理、WBF 后处理指标用 mAP 的大类平均值结果大概如下方案mAP相比最佳单模型提升模型 A 单独使用NMS0.487基准模型 B 单独使用NMS0.482-0.5%模型 C 单独使用NMS0.476-1.1%三模型集成 NMS0.4900.3%三模型集成 WBF0.5122.5%印象最深的是最后一次对比三模型集成时NMS 只让 mAP 从 0.487 涨到了 0.490而 WBF 直接拉到 0.512。差距不是一点点是“值得为它专门写一篇博客”的程度。WBF 大幅度领先的典型场景有三个多模型成员之间误差互补模型 A 对小目标敏感模型 B 对遮挡目标敏感模型 C 对边框定位更精细。它们各自的最强项不是同一个子集WBF 能把这些不同的优势通过加权方式揉到一起。TTA 多尺度结果合并同一个模型做了多尺度推理每个尺度的框都在原图坐标系下有一定偏移直接 NMS 等于只保留最自信的那个尺度而 WBF 会把多个尺度的位置信息平均掉噪声。有类别不平衡倾向的数据集NMS 在高密度场景下容易把相邻目标的一个框抑制掉导致漏检WBF 因为不会直接删除框漏检概率小很多。4.2 模型差异越大WBF 的收益越明显这个结论我是在对比实验里慢慢体会到的。如果集成里的模型长得太像——同一个架构、同一个初始化、只换了随机种子那它们的输出框往往高度重合WBF 和 NMS 的差异就很小。因为所有框都指向几乎同一个位置加权平均和直接选一个结果差别不大。反过来当你把不同架构、不同输入尺寸、甚至不同训练策略的模型扔进同一个集成里它们的框分布才会呈现出“离散但围绕真实位置散布”的特征。这时候 WBF 的加权平均才开始发挥真正的威力。建议大家在搭建模型集成时优先选 checkpoint 差异大的模型。如果你手里只有一堆同一架构不同 seed 的模型WBF 的收益会非常有限甚至不如直接把 NMS 阈值调低一点。4.3 推理速度WBF 比 NMS 慢但通常可接受WBF 的时间复杂度大约在 O(n²) 级别因为每个框都要和现有簇计算 IoU。对于一张图上只有几十个候选框的情况完全不是瓶颈。真正的麻烦出现在一阶段检测器大量输出候选框的场景比如单张图输出 5000 个框这时候 Python 循环版的 WBF 会卡到让你怀疑人生。我的经验是当前框数量在 300 以下时直接用 Python 循环版没问题超过 1000 时优先做一次低置信度过滤把 0.05 以下的框删掉然后再跑 WBF速度基本能回到可接受范围。实际上WBF 对置信度很低的框并不敏感——它们在加权坐标里贡献极小就算不删也有影响但不致命。但大量低质量框会抬高簇的数量拖慢 IoU 计算所以建议过滤。4.4 单个模型是否值得用 WBF直说结论单个模型建议不要用 WBF直接用 NMS 就好。WBF 是基于多个独立观测取共识的算法单个模型的输出本身只有一次观测没有“多个观点可以投票”融合出来的框大概率比原本的最佳框还要偏。要是你实在想用也请只在“同一模型的多尺度 TTA 结果”上使用不要让单模型单尺度的框直接过 WBF否则可能白掉点。5. 调参与避坑实录这些坑我是真的一个个踩过来的5.1 IoU 阈值0.55 不是万能的但它是个好起点WBF 默认 IoU 阈值 0.55在实际使用中确实是一个很稳的起点。阈值本身的含义是“两个框重合度达到多少就认为它们指向同一个目标”。阈值太高比如 0.7会把不同模型对同一个目标的预测框拆成多个簇导致同一个目标输出多个框形成重复预测阈值太低比如 0.3则容易把相邻很近的两个不同目标误认为是一个簇把它们的框强行融合成一个不存在的“中间框”。一个可用的调参思路是先跑一次验证集统计 NMS 输出框数量和 WBF 输出框数量。如果 WBF 输出框数量显著多于 NMS多出 20% 以上大概率是簇被拆碎了可以调高阈值观察反之如果输出框数量不到 NMS 的 80%大概率是目标被过度融合了把阈值调低一点。这个经验比单纯盯着 mAP 盲调更直观。5.2 置信度尺度不一致最近的一个反例这是我的真实体验也是最容易翻车的地方。某个集成里有一个模型是在完全不同的验证策略下训练的它的置信度普遍偏低大部分框只有 0.1 到 0.3。乍一看不影响毕竟它在加权坐标时权重很小。但问题在于WBF 最终置信度计算公式里有一个“乘簇内框数”的操作低分框同样能增加簇内框数把这个簇的排序分值抬得很高。这就导致一个典型现象那个低分模型的框只要和别的模型框重合就能把整体融合框的置信度拉高即使它自己的权重很低。如果这个模型的框位置偏差稍大反而会把融合框往错误方向拖一点。这个问题我最后用了一个办法解决对参与 WBF 的每个模型的置信度做 rank 归一化到 [0,1] 区间而不是用原始置信度。这样做之后再跑 WBF效果稳了不少。5.3 坐标空间没对齐最隐蔽的错误我在 3.1 节提到过坐标空间的问题这里再展开一点。不同模型的推理输入尺寸经常不一样比如模型 A 用 1024×1024模型 B 用 512×512。如果不把这些框统一还原到原图尺寸或者统一缩放到一个标准尺寸就直接丢给 WBF那么大的框和小尺寸模型还原出来的框在 IoU 计算时会出现系统性偏差。最直接的检查方法随机挑一张验证集图片把多个模型的框都画在同一张图上肉眼看它们的坐标是否在同一尺度下描述同一物体。如果某个模型画出来的框明显偏小或者偏移先修数据预处理流程不要直接把问题归到 WBF 上。5.4 低置信度框不会被自动过滤我在实现代码里留了一个skip_box_threshold参数默认值是 0.0也就是说所有框都会参与融合。在很多检测模型的输出里一阶段检测器给出的框有大量置信度在 0.01 到 0.05 之间的背景噪框。这些框单独看没什么威胁但一旦它们跟某个真实目标簇发生高 IoU 重叠它们也会被归入簇从而影响簇内平均置信度和加权坐标。虽然影响权重被置信度压制住了但“增加簇内框数”的副作用依然存在。所以我最终的建议是在跑 WBF 之前先把置信度低于 0.05 的框过滤掉。这不是为了涨点而是为了减少额外变量如果你还在做 debug 阶段这一步能显著减少干扰。5.5 多类别检测任务WBF 应该是类别隔离的绝大多数检测模型的输出都带类别标签WBF 的标准做法是逐类别分别跑而不是把不同类别的框混在一起算 IoU。原因很简单一个车和一个骑在车旁边的人它们的框在图像上高度重合如果把两个类别放在同一个池子里算 IoU它们很有可能会被融合成一个完全不存在的框。所以实际操作时我一般先把所有框按类别分组然后每个类单独调用一次 WBF最后再把融合结果合并回去。如果你遇到某些类别本身就重叠比较严重的情况比如“猫”和“狗”在极近距离同时出现逐类 WBF 之后可能还需要做一次类间 NMS 来清理边界。5.6 旋转框任务普通 WBF 不能直接用如果你的检测器输出的是旋转矩形框带角度参数普通的 x1、y1、x2、y2 格式的 WBF 就不适用了。因为它没有地方考虑角度差异两个同中心但角度差 30 度的框用普通 IoU 计算的重合度可能非常高会被错误融合。旋转框场景下需要使用基于旋转 IoU 的变体实现核心思想不变但 IoU 计算方式要替换成旋转框相交面积的计算逻辑并且融合框的角度也要做加权平均——注意角度跨越 0°/180° 边界时的环绕问题否则会算出一个在相反方向上的角度。这块水比较深我建议先查一下成熟的旋转框后处理实现不要自己从头手写角度加权。6. 完整上场方案TTA WBF 的推荐流程与最终体会经过一段时间的反复折腾我最终形成了一套比较稳定的检测集成方案流程不复杂但每一步都有它的目的。第一步所有模型在相同推理尺寸下各自跑一次多尺度 TTA。这会让每个模型生成多组框而不是一组。第二步把所有框还原到统一坐标系按类别分组并对每个类别的框做一次低置信度过滤。第三步逐类跑 WBFIoU 阈值设置 0.55。第四步如果发现同一目标的重复预测还很多再对 WBF 输出结果做一次 NMS阈值可以设到 0.6 左右。这套流程我从头到尾测过很多次稳定性和最终效果都让人放心。另外还有一个个人偏好我喜欢把 WBF 放在 TTA 之后、NMS 之前。因为多尺度 TTA 出来的框天然存在微小偏移先让 WBF 把这些偏移平均掉再做类间 NMS 清理边界逻辑上更顺。也有人习惯先做 NMS 再 WBF理由是减少 WBF 的计算量。这么做也不是不行但你先 NMS 会把一部分低分但有信息量的框干掉WBF 的融合基础就打了折扣。如果不是框数量大到不可接受我建议还是先 WBF 后 NMS。如果让我用一句话总结 WBF 和 NMS 的取舍我会说NMS 是断舍离WBF 是求同存异。做单模型检测断舍离够了做多模型集成和 TTA你真正需要的是把每个模型对真实目标的位置估计综合起来这时候 WBF 的加权融合思路远胜于 NMS 的“淘汰式”思路。我最后想提醒一件小事不管用什么后处理先确认你注入的是同一坐标系下的框并且每个模型的置信度尺度没有严重偏斜。这两件事做扎实了你搭的 WBF 才会稳定地帮你涨点否则再高级的融合算法也救不了输入数据的混乱。