目标检测评价指标IoU、mAP与FPS计算及测量避坑指南
1. 三个指标到底在回答什么问题模型训完权重存下来评估脚本一跑输出里躺着三行东西IoU、mAP、FPS。很多人到这一步就停了把数字往表里一填觉得任务完成。我见过不少这样的现场有人汇报 mAP 0.42被追问这个 0.42 是在多少 IoU 阈值下、用的哪种插值、包含多少个类别的时候答不上来也有人拿自己的 FPS 去和别人比结果对方是批处理测的吞吐、自己是单张测的延迟两个数字根本不在一个坐标系里。IoU、mAP、FPS 这三个评价指标看着像三兄弟实际上各自回答的是完全不同的问题——IoU 回答一个框和另一个框贴得够不够紧mAP 回答整张验证集上模型的排序能力和检出能力综合起来到底几分FPS 回答这套东西在目标硬件上能不能跑得动。三个问题一层套一层任何一个理解偏了最后那张对比表格就会失真。这篇内容适合几类人看刚入门目标检测、被 PR 曲线和 AP 插值绕晕的同学已经能跑通训练脚本、但每次看评测报告都只看一个总数的人以及需要做模型选型和上线评估的工程同学。我会从 IoU 的除法讲起一路推到 mAP 的完整计算链条再把 FPS 这个最容易被数字游戏污染的部分拆开最后给出一套可以自己动手跑的代码和一份排查速查表。所有手算示例和参数选择我都会把为什么这么定讲清楚因为这类指标最大的坑不是不会算而是不知道自己在算哪一种版本。1.1 IoU模型和标注之间的重叠共识IoU 的全称是 Intersection over Union交并比。名字已经把公式写完了两个框的交集面积除以并集面积。听起来简单到不需要解释但它是整个检测评价体系的地基——mAP 里每一次这个检测框算不算命中判据都是 IoU 有没有超过某个阈值训练时的正负样本分配判据也是 IoU后处理里决定要不要抑制掉一个重叠框还是 IoU。同一个量在三个完全不同的环节里扮演三种角色取值逻辑和阈值选择都不一样这是很多人混淆的源头。它的取值范围是 0 到 1。0 表示完全不相交1 表示完全重合。实际标注里人手动框出来的框和完美框之间本来就有几个像素的抖动所以即便是人工复标同一张图两次标注的 IoU 往往也只有 0.85 到 0.95 之间。这个事实很关键它意味着 IoU 阈值设在 0.9 以上时模型实际上是在和标注噪声较劲分数掉下来不一定是模型的问题。1.2 mAP把一堆散点压成一个分数的折中方案检测任务和分类任务最本质的区别是分类的输出数量是固定的一张图一个标签检测的输出数量是浮动的一张图可能 3 个目标也可能 300 个。输出数量不固定就没法直接套用准确率。于是有了这么一套折中先按置信度把所有检测框排个序从高到低逐个判定是命中还是误报每一步都记下当时的精确率和召回率得到一串点连成一条曲线曲线下的面积就是 AP。多个类别各算一个 AP取平均就是 mAP。这套设计的巧妙之处在于它同时对排序质量和覆盖能力打分。一个模型如果把高置信度的框都给对了PR 曲线会一直贴在右上角如果高置信度里混了一堆误报曲线会迅速塌下来。所以 mAP 天然惩罚张口就来的模型这也是为什么它比单纯的召回率或精确率更能反映真实可用性。1.3 FPS最容易被数字游戏污染的那个FPS 是每秒处理的帧数衡量推理速度。它看起来是最客观的一个指标——计时器不会说谎。但恰恰因为它看起来客观反而成了三个指标里最容易被误用的。输入分辨率不同、批大小不同、有没有算上前处理和后处理、有没有做预热、计时有没有同步、跑在什么硬件上、精度是半精度还是单精度这些条件任何一个变了FPS 能差出好几倍。拿两个条件不同的 FPS 数字做对比结论基本没有意义。我在实际项目里养成一个习惯看到别人报 FPS先问三个问题——单张还是批处理、端到端还是只算主干、目标硬件的功耗档位是什么。这三个问题问完大部分我们的速度快 3 倍的说法就会自动缩水。2. IoU 拆开看从除法到变体2.1 一次完整的手算过程假设标注框 A 的坐标是 (100, 100, 300, 400)检测框 B 是 (150, 120, 320, 380)坐标格式统一为左上角加右下角。先算交集左上角取两个框 x 的最大值、y 的最大值得到 (150, 120)右下角取 x 的最小值、y 的最小值得到 (300, 380)。交集宽 150、高 260面积 39000。A 的面积是 200 乘 300 等于 60000B 的面积是 170 乘 260 等于 44200。并集等于 60000 加 44200 减 39000等于 65200。IoU 就是 39000 除以 65200约等于 0.598。这个过程里有两个必须处理好的边界情况。第一当两个框不相交时右下角减左上角会出现负数必须把宽高截断到 0否则会算出一个负面积把并集算大IoU 偏小。第二坐标格式一定要先确认清楚左上右下和中心点加宽高两种格式混用是新手最常犯的错误而且不会报错只会得到一个看起来有点低的 IoU。我在代码里通常会在函数入口加一句断言检查 x2 是否大于 x1省掉大量排查时间。def bbox_iou(box, boxes): # box: [x1, y1, x2, y2]boxes: N x 4 ix1 np.maximum(box[0], boxes[:, 0]) iy1 np.maximum(box[1], boxes[:, 1]) ix2 np.minimum(box[2], boxes[:, 2]) iy2 np.minimum(box[3], boxes[:, 3]) iw np.clip(ix2 - ix1, 0, None) # 关键截断到 0 ih np.clip(iy2 - iy1, 0, None) inter iw * ih area_a (box[2] - box[0]) * (box[3] - box[1]) area_b (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) union area_a area_b - inter return inter / np.clip(union, 1e-9, None)2.2 为什么 NMS 依赖 IoU损失函数却嫌弃它后处理里的非极大值抑制逻辑就是按置信度从高到低取框把和它 IoU 超过阈值的其他框压掉。这里用 IoU 是合理的因为判断的是这两个框是不是指向同一个物体只要重叠够多就大概率重复。但同样的 IoU 拿去做回归损失就有问题了。问题出在梯度上。当两个框完全不相交时IoU 恒等于 0梯度也是 0模型不知道该往哪个方向挪。这就好比两个人走散了你只告诉他你们没碰上不告诉他往东还是往西。于是后续出现了一系列改进广义 IoU 引入了最小闭包框的概念把不相交的情况也纳入计算保证始终有梯度距离 IoU 进一步把中心点距离加进去收敛更快完整 IoU 再把长宽比一致性考虑进来。这套演进的核心动机只有一个让框的位置关系这个信息在不相交时依然可以求导、可以指导优化。理解这一点你就明白为什么评测指标里的 IoU 和损失函数里的 IoU 家族要分开看。评测用的是最原始的定义因为评测要的是解释一致、跨模型可比任何改进版本都会引入额外约定破坏可比性。损失函数则可以随便改只要训得动、训得准。2.3 阈值怎么定别照抄 0.5IoU 阈值决定了多贴近才算命中。定 0.5 意味着允许比较大的偏移适合密集小目标和形状不规则的场景定 0.75 就苛刻得多通常用于对定位精度要求高的场合。我要强调的是这个阈值不是一个技术常量而是一个业务约定。仓储场景里数箱子框偏十个像素无所谓0.5 足够工业质检里找微小缺陷偏两个像素就可能导致后续裁切失败那就得往上提。实际操作中我建议做一次阈值敏感性扫描在 0.5、0.55、0.6、0.65、0.7、0.75 各算一遍 mAP画一条曲线出来。如果曲线在某个阈值之后断崖式下跌说明模型的定位精度是短板这时候优化方向应该是回归分支和标注质量而不是继续加数据。如果平缓下降说明定位能力扎实瓶颈在分类或召回。这个诊断手段比单纯看一个总数有用得多成本也就是多跑几次评测。3. mAP 的计算链条3.1 第一步把检测框和标注框配对评测的第一步不是算分数是配对。把所有图片的所有检测框按置信度从高到低排成一个全局序列然后逐个处理对当前检测框找出同一张图里、同一类别、IoU 最高且还没被匹配过的标注框如果这个最高 IoU 超过阈值就标记为真正例并把那个标注框标记为已占用否则标记为假正例。所有没被任何检测框匹配上的标注框最后统计为假负例。这里有三个容易出错的细节。一是一个标注框只能被匹配一次第二个和它高度重叠的检测框即使 IoU 也很高也必须算假正例否则召回率会被虚高。二是类别必须一致才能匹配把猫的框匹配到狗的标注上即使位置完美重合也是错的。三是排序必须是全局的不能按图片分别排序再拼起来因为 AP 的定义依赖于全局排序下的累积统计。3.2 第二步排序累积 TP/FP画 PR 曲线沿着排好的序列往前走每处理一个检测框就更新累计的真正例数和假正例数。当前点的精确率等于累计 TP 除以累计 TP 加累计 FP召回率等于累计 TP 除以标注框总数。把每个点画在召回率为横轴、精确率为纵轴的坐标系里就得到 PR 曲线。理想情况下曲线从左上角出发一路向右面积接近 1。真实曲线的形状通常是锯齿状的出现一个真正例精确率往上跳出现一个假正例精确率往下跌。这种锯齿是正常的不要以为代码写错了。理解锯齿的来源很重要因为它解释了为什么 AP 的两种算法会给出不同结果。3.3 第三步AP 的两种面积算法第一种是十一点插值法早期评测标准用的。把召回率轴从 0 到 1 等分成十一个点每个点上取召回率大于等于该点的所有位置里最大的精确率然后十一个值求平均。这种做法相当于把锯齿状的曲线拉成一个阶梯状的单调下降曲线再算平均高度。第二种是全点插值法现在的主流做法。对每一个出现在曲线上的召回率点取它右侧所有精确率的最大值然后按召回率的增量做加权求和。公式写出来就是AP 等于各个召回率增量乘以对应的插值精确率再全部加起来。相比十一点法它不丢失召回率轴的细节对小目标多的数据集更公平。两种算法在同一个模型上能差出零点零几有时候甚至更多。所以对比两个模型的 mAP 时第一件事是确认用的是同一套评测代码而不是看数字大小。我见过因为换了评测工具包导致 mAP 掉了 3 个点、团队白查两天的情况最后发现只是插值方式不同。3.4 AP50、AP75 与区间平均数字之间的差异同一份检测结果能算出好几个 AP区别只在 IoU 阈值。指标IoU 阈值主要反映什么AP500.5检出能力对定位要求宽松AP750.75定位精度框贴得紧不紧AP[.5:.95]0.5 到 0.95 步长 0.05 共 10 档取平均综合能力主流榜单主指标APs / APm / APl按目标框面积分档不同尺度目标的表现差异AP50 和 AP75 之间的差距是一个非常有效的诊断信号。差距小说明模型定位稳差距大说明模型能找得到但框不准这时候应该去看回归分支、锚框设计、以及标注框的质量。至于 AP[.5:.95]它把十档阈值平均等于把定位精度也摊进了总分所以数值通常比 AP50 低一大截这是正常的不要拿两个数字直接比。还有一个容易被忽略的点那个区间平均在各种尺度上还会再做一次平均。也就是说最终报出来的单个数实际上是类别 × IoU 档位 × 尺度档位三重平均之后的结果。小目标样本少的时候它的 AP 在平均里权重很低所以总分看起来还行小目标却漏得一塌糊涂的情况非常常见必须单独看分档指标。3.5 换成一个可复现的小例子设单类别场景标注框一共 3 个。检测框按置信度排好序后逐个判定结果如下排名置信度与最优标注的 IoU判定累计 TP累计 FP精确率召回率10.950.85TP101.0000.33320.900.62TP201.0000.66730.850.12FP210.6670.66740.800.55TP310.7501.000按全点插值先对每个召回率点取右侧最大精确率召回率 0.333 处右侧最大精确率是 1.00.667 处是 0.75因为后面有个 0.667 被 0.75 覆盖1.0 处是 0.75。然后按召回率增量加权AP 1.0 × (0.333 − 0) 0.75 × (0.667 − 0.333) 0.75 × (1.0 − 0.667) ≈ 0.333 0.250 0.250 0.833。换成十一点插值取召回率 0 到 1 的十一个等分点对应插值精确率依次是 1.0 四次0 到 0.3、0.75 七次0.4 到 1.0平均下来是 (4 × 1.0 7 × 0.75) / 11 ≈ 0.841。两者差 0.008量级不大但在数据集小的时候这个差异会被放大。这个例子建议自己拿笔算一遍算过之后 PR 曲线的每个拐点你都会有直觉。4. FPS 的真相与测量方法4.1 测 FPS 的四个必备动作第一预热。第一次前向传播会触发显存分配、算子编译、缓存加载耗时往往是稳态的好几倍。不预热直接计时测出来的数字偏低而且偏低多少完全看运气。我一般跑 10 到 20 次预热。第二同步。异步执行的设备上调用返回不代表计算完成不加同步直接读计时器会得到一个漂亮但虚假的高 FPS。所有计时区间的首尾都要做同步。第三明确计时范围。只算网络前向还是算上预处理、后处理尤其是非极大值抑制、以及数据搬运端到端延迟和纯推理吞吐是两个完全不同的指标。做产品选型看端到端做算法对比看纯推理两个都要报但要标清楚。第四固定输入条件。分辨率、批大小、精度模式、硬件功耗档、线程数全部写进报告。少写一项别人就复现不了。import time def bench(model, dummy, warmup20, iters100): model.eval() with torch.no_grad(): for _ in range(warmup): model(dummy) sync() t0 time.perf_counter() for _ in range(iters): model(dummy) sync() return iters / (time.perf_counter() - t0)4.2 拆解耗时构成找到真正的瓶颈一次完整的检测推理耗时大致分成五块数据从内存搬到设备、预处理缩放、归一化、通道调整、主干网络、检测头、后处理。很多人优化 FPS 时盯着主干网络结果发现改完只快了 5%因为瓶颈根本不在那儿。我的经验是先用分段计时把五块的比例打出来。常见的情况有这么几种预处理占比高说明缩放用的是通用插值且没做批内并行可以改成固定尺寸输入加批量缩放数据搬运占比高说明批太小、搬运次数太频繁或者可以提前把数据放到设备上并做固定内存锁页后处理占比高说明非极大值抑制是逐类别 Python 循环实现的可以向量化或者按批合并计算。在没有测量的情况下做优化基本等于闭着眼睛改代码。4.3 精度与速度的取舍表实际项目里几乎不可能既要最高精度又要最高速度必然是取一个工作点。下面这张表是我在几个不同量级场景里反复验证后总结的粗略对应关系具体数值会随硬件和模型结构变化但量级关系是稳定的。输入分辨率典型精度变化速度变化适用场景320 左右明显下降小目标损失最大大幅提升边缘设备实时预览480 到 512小幅下降明显提升主流实时业务640基准基准通用评估基准点800 以上小幅提升后趋于饱和显著下降高精度离线分析注意分辨率提升带来的精度收益是递减的而速度代价是近似平方增长的。所以我一般从 640 开始往上调调到精度曲线走平就停手不要在已经饱和的区间里继续堆分辨率。批大小的选择也是同理。批从 1 加到 8吞吐往往能提升一到两倍但单张延迟几乎不变甚至略升。如果业务是实时逐帧处理看的是延迟批大小就应该是 1如果是离线批处理视频看的是吞吐可以把批开大。这两个场景报出来的 FPS 完全不具备可比性但很多对比表格里就是混着放的。5. 从零跑通一次完整评测5.1 数据与目录组织先把预测结果和标注统一成同一套中间格式我最常用的是逐行存一条记录的文本文件图片名、类别名、置信度、四个坐标。这个格式的优点是肉眼可读、能直接用命令行工具筛、跨语言都能处理。目录上我习惯分三块annotations/放标注、predictions/放模型输出、eval_out/放评测产物避免复跑时互相覆盖。有一个细节值得单说坐标到底存归一化值还是绝对像素值。如果预测和标注一边归一化一边没有IoU 会算得莫名其妙。我的做法是统一在评测入口做一次转换全部还原成绝对像素值只在这一处做格式判断其他地方不再做任何假设。这个约定坚持下来能省掉大量分数为什么是 0的困惑。5.2 手写 IoU 与匹配逻辑匹配逻辑的核心是一个二维的 IoU 矩阵行是检测框、列是标注框、值是两个框的 IoU。有了这个矩阵逐个处理检测框就变成查表加标记。matched_gt {} tp, fp [], [] for det in dets_sorted: # 已按置信度全局降序 gts gt_by_img[det[img]] gts [g for g in gts if g[cls] det[cls]] if not gts: tp.append(0); fp.append(1); continue ious bbox_iou(det[box], np.array([g[box] for g in gts])) j int(np.argmax(ious)) key (det[img], id(gts[j])) if ious[j] iou_thr and key not in matched_gt: matched_gt[key] True tp.append(1); fp.append(0) else: tp.append(0); fp.append(1) # 已经占用过的 GT 不能再匹配注意最后那行注释这是最容易写错的地方。很多人只判断了 IoU 够不够忘了判断标注框是不是已经被占用结果召回率被虚高mAP 算出来好看但实际部署效果对不上。这个 bug 的特点是只在密集场景下暴露因为密集场景里同一物体附近容易出现多个高分框。5.3 计算 AP 与 mAP有了全局的 TP/FP 序列就可以按类别切片算累积量、算插值、算面积。多类别各自算完 AP 后取算术平均就是 mAP。def compute_ap(tp, fp, num_gt): tp_cum np.cumsum(tp) fp_cum np.cumsum(fp) recall tp_cum / max(num_gt, 1) precision tp_cum / np.clip(tp_cum fp_cum, 1e-9, None) # 全点插值每个召回率点上取右侧最大精确率 mrec np.concatenate([[0.0], recall, [1.0]]) mpre np.concatenate([[1.0], precision, [0.0]]) for i in range(len(mpre) - 2, -1, -1): mpre[i] max(mpre[i], mpre[i 1]) idx np.where(mrec[1:] ! mrec[:-1])[0] return float(np.sum((mrec[idx 1] - mrec[idx]) * mpre[idx 1]))这段代码里那个从后往前的循环就是在做取右侧最大的单调化处理。它把锯齿状的精确率序列变成单调不增的序列这样按召回率增量加权求和才有意义。我自己第一次实现的时候觉得这一步多余直接拿原始精确率去积分结果算出来的 AP 比官方工具包高了将近一倍原因就是锯齿被重复计入了面积。如果想让结果和公开榜单严格对齐建议直接调用成熟评测工具包跑一遍做交叉验证。跑通之后你会发现自己的实现和它的差异通常在千分位以内剩下的差异来自匹配细节和插值点数而不是算法错误。这一步验证非常值得做做一次之后你对 AP 的信心会完全不同。5.4 稳定的 FPS 基准脚本基准脚本的骨架前面已经给过了这里补充三个实操细节。第一用同一份固定输入跑所有对比模型不要每次随机生成随机输入可能导致某些算子走不同的分支。第二测三个统计量平均值、中位数、95 分位。只看平均值容易忽略抖动而实际部署的卡顿往往来自那 5% 的慢帧。第三把环境信息一起打印出来设备名、驱动版本、精度模式、线程数。这份日志存下来半年后复查还在。我还习惯做一件事把纯推理 FPS 和端到端 FPS 同时报出来两者之比能反映工程实现的健康程度。如果端到端只有纯推理的三分之一说明前处理和后处理占了大头这时候该去优化数据管线而不是换模型。6. 踩坑记录与速查6.1 指标对不上的排查路径发现自己的 mAP 和参考值差距较大时我一般按这个顺序查先确认类别映射是不是一一对应类别顺序错位是零分级别的问题再确认坐标格式是否统一中心点格式当左上角格式用IoU 会普遍偏低但不会报错然后确认排序是否全局按图片分别算再拼是常见的实现错误最后确认插值方式和 IoU 阈值是否一致。这四步查完九成以上的差异都能解释。如果差距很小比如千分位级别那就去看匹配细节是否有多个检测框匹配同一标注、是否有低置信度框被阈值截断、是否做了最大检测框数量限制。这些参数在不同工具包里默认值不同遇到边界样本就会放大成可见的差异。提示任何一次跨工具包的指标对比先拿同一份预测文件在两个工具里各跑一遍。如果两边结果就不一致那说明问题出在工具而非模型先统一评测口径再谈模型优劣。6.2 常见现象与成因对照表现象高概率原因建议动作AP50 正常AP75 塌陷回归分支定位粗糙加定位损失权重检查标注框贴合度总分正常小目标 AP 极低小样本占比低被平均稀释看分档指标调整输入尺度或特征层本地 mAP 高于参考值明显匹配时重复占用标注检查标注框占用标记逻辑验证集 mAP 高但实际漏检多单类主导类别平均被平均掉按类别看 AP找短板类别FPS 数字异常漂亮未同步、未预热、只算前向补齐计时规范分段计时批处理 FPS 高但线上卡顿混淆了吞吐和延迟单独测批为 1 的 95 分位延迟密度大的图召回率虚高未处理重叠检测框去重排查匹配逻辑中的唯一性约束6.3 我的几条实操习惯第一条所有指标必须带条件。报 mAP 时写清楚 IoU 阈值、插值方式、类别数和是否包含小目标档报 FPS 时写清楚分辨率、批大小、精度模式和硬件。这个习惯看着啰嗦但能让你的数据在半年后依然可用也能避免很多无谓的争论。第二条永远看分布不要只看均值。mAP 看每类的 AP 分布定位质量看 IoU 分布速度看耗时的分位数。一个均值掩盖的问题往往在分布里一眼就能看出来。我做过一个项目总分一直卡在 0.6 上不去把每类 AP 打出来之后发现占比最大的那一类 AP 有 0.85另外两类只有 0.4 左右问题立刻定位到了长尾类别上方向一改一周就上来了。第三条指标是用来指导下一步动作的不是用来提交的。拿到一组数字之后问自己哪个子指标最拖后腿拖后腿的原因是数据、标注、模型还是后处理只有能回答这个问题这组指标才算真的读懂了。IoU 告诉我们定位够不够mAP 告诉我们综合能力排第几FPS 告诉我们能不能落地——三者合起来才构成对一次检测任务的可信判断。单独拿任何一个出来说事都容易被数字带偏。我个人的体会是这套指标体系真正难的从来不是公式推导而是在每一次评测里保持口径一致。公式摆在那里谁都能抄能不能在一次又一次的迭代里用同一把尺子量到底才是拉开差距的地方。