行人检测评估:FPPI曲线绘制与log-average miss rate计算实战

发布时间:2026/9/1 13:22:49
行人检测评估:FPPI曲线绘制与log-average miss rate计算实战 简介面向目标检测、行人检测及计算机视觉算法研究者这份资源提供了一套基于Matlab的miss rate versus false positives per imageFPPI曲线绘制代码。FPPI是衡量检测器在单位图像误检次数下漏检率的核心指标适用于算法对比、参数调优和论文实验展示。压缩包共10840个文件大小约161.4MB主体为10682个txt结果文件用于保存检测框、置信度等输出137个vbb为视频序列标注文件对应评测所需的真值信息7个m脚本实现读取、计算与绘图流程另有1个mat数据文件整体目录层级清晰便于按数据集批量运行。已有1178人学习配合资源描述中的使用说明可快速上手。拿到后只需将自身检测结果按约定格式放置运行脚本即可得到标准FPPI曲线免去自行实现评估指标的繁琐过程适合需要标准化评估行人检测器性能的研究与工程人员。 做行人检测或者自动驾驶感知的朋友应该都有过这种经历模型在验证集上的mAP已经刷到不错了可真拿测试视频一跑要么帧帧都在框某个根本不存在的目标要么该认出来的行人悄悄漏了过去。mAP这种聚合指标根本表达不了这些细分的实际问题。我后来在对比检测器性能时基本都改用一条曲线——miss rate versus false positives per imageFPPI横坐标是每张图片的误检数纵坐标是漏检率画在log-log坐标下检测器在“可接受误检”前提下的真实漏检水平一目了然。这篇文章就以这个标题为主线把从数据准备、匹配规则到FPPI曲线绘制代码的完整流程记录下来代码可以直接抄去用适合正在做检测器评估、行人检测基准测试的同学。1. 为什么行人检测领域普遍用FPPI曲线代替mAP1.1 FPPI和miss rate到底在描述什么FPPI的全称是False Positives Per Image也就是平均每张图片上的误检框数量。假设测试集有1000张图片检测器输出了50个没有对应目标的框那么FPPI就是0.05。反过来miss rate指的是漏检率等于1减召回率。测试集里一共标注了1000个行人最后漏掉了100个miss rate就是10%。这两个指标搭配使用回答的问题是在允许一定误检的条件下这个检测器到底能找回多少目标拿自动驾驶场景举例系统每帧误检两个假行人车上的人肯定受不了但如果完全不误检代价是把真正过马路的行人漏掉那就更危险了。FPPI曲线就是把这两种代价的权衡关系摊开给你看。1.2 mAP和ROC在这类任务里为什么不好使mAP是PR曲线下的面积把不同置信度下检测器的综合性能压成了一个数。问题在于两个模型mAP一样实际表现可能天差地别。一个可能是低置信度区域堆了大量误检另一个可能是高置信度区域漏检严重但平均下来数值却一样。而且mAP的评价方式对所有类别一视同仁对单类别的行人检测来说它并没有回答“系统在每张图允许一次误检时漏检率是多少”这个部署时最要命的问题。ROC曲线在目标检测场景下同样尴尬。ROC的横轴用的是FPR即FP除以FP加TNTN是所有被认为是背景的区域。在目标检测里背景区域的数量比检测框多好几个数量级哪怕FP翻几倍FPR也几乎看不出变化。而FPPI的分母直接是图片数量和部署时使用者感受到的“误检频率”严格对应。所以行人检测、遥感目标检测这类对误检敏感的任务都习惯用FPPI曲线来做评估基准Petr Dollar那篇行人检测综述也是这么做的。2. 画曲线绕不开的前置工作数据格式和匹配规则2.1 检测结果文件和GT文件的长相在写绘制代码之前先要明确输入数据长什么样。我习惯用纯文本文件每行一个框空格分隔检测结果文件image_0001.jpg 0.923 104.5 210.3 183.2 410.1 image_0001.jpg 0.512 580.0 130.2 660.8 330.4 image_0002.jpg 0.873 20.1 50.3 99.7 180.9字段分别是图片名、置信度分数、左上角x、左上角y、右下角x、右下角y。GT标注文件不用存置信度image_0001.jpg 100.0 200.0 190.0 420.0 image_0001.jpg 575.0 120.0 665.0 340.0这里有一个特别容易忽略的坑坐标系的统一。不少检测器推理时会把图像缩放到固定尺寸输出的坐标是缩放后的而GT标注文件里是原图坐标。两种情况混在一起算IoU结果肯定不对。我一般会要求检测器在输出时就映射回原图坐标或者准备一个坐标缩放函数在加载数据后统一处理一遍。2.2 匹配规则IoU阈值和贪心策略有了数据下一步是把检测框和GT框做匹配。匹配规则是整个评估逻辑的核心写成文字就是一句话按置信度从高到低遍历所有检测框对每一个检测框在当前图片的所有GT中寻找IoU最高且尚未被匹配的那个如果IoU达到阈值就标记为TP否则记为FP。为什么必须按置信度排序因为同一个目标很可能被输出多个检测框如果不排序低置信度的框可能先占用了GT高置信度的框反而变成FP这显然不合理。按置信度排序再贪心匹配能保证每个GT只被最高置信度的那个检测框匹配上其余重复检测框都会被正确惩罚为FP。至于IoU阈值行人检测社区一般用0.5更严格的任务会用到0.7代码里最好做成参数方便切换。3. FPPI曲线的核心算法拆解3.1 从置信度排序到累计TP/FP状态清楚了匹配规则曲线的计算就顺理成章了。核心思想是把所有检测框按置信度从高到低排序然后依次“吞入”每一个检测框每吞入一个就更新一次TP和FP的累计值并计算当前的miss rate和FPPI。具体来说维护两个变量tp表示当前已经匹配上的GT数量。因为每个TP对应一个匹配GT所以漏检的目标数等于总GT数减去tp。fp表示当前累计的误检框数量。于是每一步都有miss rate 总GT数 - tp / 总GT数FPPI fp / 图片总数随着阈值不断降低被接受的检测框越来越多曲线就从左上角的0, 1附近一路往右下走直到所有检测框都被纳入计算。3.2 阈值扫描理解成“逐步吞入检测框”很多第一次接触FPPI曲线的人会纠结阈值到底怎么取实际上当我将检测框按置信度排序后每一个检测框的置信度都可以看作一个潜在的阈值。比如排序后第三个检测框的分数是0.85那么“阈值等于0.85”就意味着前三个检测框被接受后面的全部被拒绝。所以逐一遍历检测框就等价于遍历了所有有意义的工作点。这也是逐点绘制和阈值扫描的本质关系不是预先选定一组阈值而是让数据自己决定曲线的转折点。每加入一个检测框若它是TPmiss rate下降若它是FPFPPI增大。两者共同在曲线上走出一个阶梯状路径。3.3 为什么横纵坐标必须用log-logmiss rate的动态范围经常从0.01到1FPPI则可能从0.001跨越到10甚至更高。如果用线性坐标画低FPPI区域的曲线会被压缩到几乎看不到而实际评估中最关心的恰恰是0.01到1这个FPPI区间的表现。log-log坐标的核心作用是把低数量级区域放大让检测器在“低误检频率”下的漏检情况清晰呈现。这也是和普通精度-召回曲线最直观的差异PR曲线的坐标尺度是线性的适合看总体性能FPPI曲线用对数尺度专门用于观察误检敏感场景下的边际性能变化。4. 可直接抄作业的Python实现4.1 核心计算函数下面是我整理的一套轻量级Python实现不依赖任何目标检测库只需要numpy、pandas和matplotlib。import numpy as np import pandas as pd import matplotlib.pyplot as plt from collections import defaultdict def compute_iou(box1, box2): 计算两个矩形框的IoU坐标格式都是[x1, y1, x2, y2] x1_min max(box1[0], box2[0]) y1_min max(box1[1], box2[1]) x2_max min(box1[2], box2[2]) y2_max min(box1[3], box2[3]) inter_w max(x2_max - x1_min, 0) inter_h max(y2_max - y1_min, 0) inter inter_w * inter_h area1 (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 (box2[2] - box2[0]) * (box2[3] - box2[1]) union area1 area2 - inter return inter / union if union 0 else 0.0 def compute_fppi_curve(detections, gts, num_images, iou_threshold0.5): detections: list每个元素为 [image_id, score, x1, y1, x2, y2] gts: list每个元素为 [image_id, x1, y1, x2, y2] num_images: 测试集图片总数 返回: list of (fppi, miss_rate)按置信度降序排列 # 按置信度从高到低排序 detections sorted(detections, keylambda det: det[1], reverseTrue) # 按图片分组GT保留每个GT的原始索引 gt_by_image defaultdict(list) for gt in gts: gt_by_image[gt[0]].append(gt) total_gt len(gts) matched_gt defaultdict(set) # image_id - set of matched gt indices tp 0 fp 0 curve [] for det in detections: image_id, score, x1, y1, x2, y2 det det_box [x1, y1, x2, y2] best_iou 0.0 best_idx -1 if image_id in gt_by_image: for idx, gt in enumerate(gt_by_image[image_id]): if idx in matched_gt[image_id]: continue iou compute_iou(det_box, gt[1:]) if iou best_iou: best_iou iou best_idx idx if best_iou iou_threshold: tp 1 matched_gt[image_id].add(best_idx) else: fp 1 miss_rate (total_gt - tp) / total_gt fppi fp / num_images curve.append((fppi, miss_rate)) return curve这个函数返回的curve列表就是所有工作点。每个点对应一个检测框被纳入后的累积状态。注意我在匹配逻辑中维护了matched_gt集合保证了每个GT最多被一个检测框匹配这正是上一节提到的重复检测惩罚策略的实现。4.2 完整绘图脚本与示例数据生成有了核心函数再写一个简短的demo造一批模拟数据来看曲线效果# 生成模拟检测结果和GT np.random.seed(42) num_images 500 gts [] for img_id in range(num_images): # 平均每张图0.8个GT目标 n_targets np.random.poisson(0.8) for _ in range(n_targets): x1 np.random.uniform(0, 1000) y1 np.random.uniform(0, 600) w np.random.uniform(30, 90) h np.random.uniform(60, 170) gts.append([img_id, x1, y1, x1 w, y1 h]) # 对每个GT以60%概率生成一个带噪声的检测框真阳性 detections [] for gt in gts: if np.random.rand() 0.6: score np.random.uniform(0.6, 0.95) dx np.random.uniform(-8, 8) dy np.random.uniform(-8, 8) detections.append([ gt[0], score, gt[1] dx, gt[2] dy, gt[3] dx, gt[4] dy ]) # 再注入一些随机误检框假阳性 for _ in range(200): img_id np.random.randint(0, num_images) score np.random.uniform(0.15, 0.7) x1 np.random.uniform(0, 1000) y1 np.random.uniform(0, 600) w np.random.uniform(30, 100) h np.random.uniform(60, 180) detections.append([img_id, score, x1, y1, x1 w, y1 h]) def plot_fppi_curve(curves, labels, save_pathNone, x_range(1e-3, 10)): plt.figure(figsize(8, 6)) for curve, label in zip(curves, labels): fppi [p[0] for p in curve] miss [p[1] for p in curve] # 过滤掉fppi0的点log坐标无法显示0 valid [(f, m) for f, m in zip(fppi, miss) if f 0] if valid: valid_f, valid_m zip(*valid) plt.loglog(valid_f, valid_m, lw2, labellabel) else: print(warning: curve %s has no valid positive fppi points % label) plt.xlim(x_range) plt.ylim(0.01, 1.0) plt.xlabel(False positives per image (FPPI)) plt.ylabel(Miss rate) plt.title(Miss Rate vs FPPI) plt.legend(loclower left) plt.grid(True, whichboth, linestyle--, alpha0.4) if save_path: plt.savefig(save_path, dpi200) plt.show() curve compute_fppi_curve(detections, gts, num_images) plot_fppi_curve([curve], [My Detector], x_range(1e-2, 1))跑完这段代码就能看到一条经典的向下倾斜的曲线。demo里真阳性占据高置信度区间所以曲线左端下降快随机误检分数偏低所以曲线右端变得平缓。这说明置信度排序的质量直接决定了曲线的形态。4.3 对数平均漏检率的计算曲线画出来之后还需要一个单一数值来横向比较多个检测器。论文里最常用的就是对数平均漏检率简写为log-average miss rate计算方法是在FPPI取10^-2到10^0这个区间内对数空间均匀采9个点对每个点对应的miss rate做插值然后求平均。def log_average_miss_rate(curve, fppi_range(0.01, 1.0), samples9): 计算log-average miss rate。 参考Dollar等人在行人检测评估中使用的标准做法。 fppi np.array([p[0] for p in curve]) miss np.array([p[1] for p in curve]) # 排序并去重 order np.argsort(fppi) fppi fppi[order] miss miss[order] mask np.concatenate(([True], np.diff(fppi) 0)) fppi fppi[mask] miss miss[mask] # 在FPPI的对数空间均匀采样 sample_fppi np.logspace( np.log10(fppi_range[0]), np.log10(fppi_range[1]), samples ) miss_vals [] for sf in sample_fppi: idx np.searchsorted(fppi, sf) if idx 0: miss_vals.append(miss[0]) elif idx len(fppi): miss_vals.append(miss[-1]) else: m0, m1 miss[idx - 1], miss[idx] f0, f1 fppi[idx - 1], fppi[idx] ratio (sf - f0) / (f1 - f0) miss_vals.append(m0 (m1 - m0) * ratio) return float(np.mean(miss_vals))算出来的数值可以直接标在论文的图例里比如“Method A (MR: 23.5%)”。这个MR值越低越好业内对比时基本都拿它当最终指标。5. 绘图实战中那些容易翻车的细节5.1 IoU阈值到底选0.5还是0.7我在不同数据集上都踩过IoU阈值选择的坑。0.5是行人检测社区最常见的选择方便横向对比但0.5意味着一个框覆盖目标一半面积就算命中对定位精度要求高的场景比如自动驾驶里的近距离行人其实太宽松了。0.7能明显区分出定位更精准的检测器但如果检测器本身输出框偏大0.7又可能造成大量合理命中被误判为FP。我的建议是评估代码里把IoU阈值设成可配置参数默认0.5跑通流程再在实验分析里补一个0.7的交叉验证。这样既保证了指标可比性又能量化定位精度差异。5.2 FPPI等于0的时候怎么办log-log坐标无法显示0值。当测试集很大且检测器性能极好时可能出现某个高置信度阶段FP始终保持为0曲线上会出现fppi0的点。直接plot会导致数据丢失或报错。常见处理方式有三种把fppi0的点的横坐标替换成一个很小的值比如1e-5因为小于实际纵轴范围的量级对曲线形态几乎没有影响。绘制前直接过滤fppi0的点但要注意曲线左端点位置会产生跳变。使用step图配合xlim裁剪把横轴下限设为大于0的值比如0.001或0.01。我一般用第一种简单直接不会破坏曲线连续性。5.3 ignore区域和多类别过滤公开数据集里还有一个高频坑ignore标注。Caltech和CityPersons里都有不少被标记为ignore的GT这些目标通常太小、遮挡太严重或者超出图像边界标注者明确表示“不算评估目标”。有些检测框恰好和ignore GT重叠如果不加处理这些检测框既不该算TP也不该算FP否则会严重污染曲线。处理方式是在匹配逻辑里加一步判断如果一个检测框的最佳IoU对应的GT是ignore GT直接跳过该检测框不计入tp也不计入fp同时ignore GT也不应该参与总GT数的统计。多类别任务同理计算FPPI前先按类别过滤GT和检测框只保留目标类别的数据。5.4 曲线梯度和置信度分数的关系画完曲线后我习惯检查曲线左端的下降速度。如果曲线在FPPI还很小的地方就已经快速掉到很低说明检测器的高分输出大多是可靠的真阳性置信度排序质量高。反之如果曲线左端平缓、右端才陡降说明真正能用的检测只有靠大量低分输出来覆盖置信度校准有问题。这个分析思路经常能帮我反推模型训练中的问题。比如有一次曲线在10^-1附近出现明显平台检查后发现是模型在夜间场景大量误检路灯和路牌后来在训练数据里加强了夜间负样本采样曲线左端的平台立刻消失了。6. 进阶从一条曲线到一组曲线的工程化对比6.1 同一张图绘制多条曲线对比实验时通常会把4到6条曲线画在一张图上颜色和线型要能区分图例统一放在右下或左下角。为了排版清晰我习惯把每条曲线的log-average miss rate直接写进图例文字比如“Baseline (MR: 31.2%)”“改进版 (MR: 24.8%)”。这样读者不用自己查表一眼就知道每个模型的最终水平。多条曲线对比时横轴范围、纵轴范围、IoU阈值必须完全一致否则图没有可比性。所有检测器的结果文件也应该走同一份评估脚本不要各自实现一遍匹配逻辑不然很容易因为一个微小差异导致指标对不上。6.2 log-average miss rate的区间选择前面代码里默认采用了10^-2到10^0的FPPI区间。为什么不用更小的值因为FPPI低于0.01时大多数检测器的miss rate都在90%以上区分度极低只有极少数超强模型才在那个区间有有意义的区分。FPPI高于1则已经超出实际系统可接受范围每张图误检超过一次基本没法部署。所以取0.01到1作为平均区间在行人是那个社区已经形成共识。如果你做的是工业场景对误检要求更苛刻也可以把区间改成10^-3到10^0。但无论怎么改报告指标时一定要注明采样区间否则不同区间的MR值没有可比性。6.3 从曲线形态定位模型问题最后分享一个我从这条曲线上获益最多的经验不是拿它对比而是拿它找问题。曲线左端下降快说明高分框可靠曲线中段有明显的长平台往往意味着存在某一类特定误检。我去查对应FPPI区间内的FP样本十有八九都能发现一个明显的失败模式比如背景里的树影、车辆的规则纹理、雨天的伞。这也正是FPPI曲线比mAP更有价值的地方mAP只告诉你排名变了FPPI曲线能告诉你模型在哪个“误检容忍级别”下、因为什么原因失效。排查完这些点再回头调模型改进效果在曲线上会非常直观地体现出来。本文还有配套的精品资源点击获取