☰
行人检测评估:FPPI曲线绘制与log-average miss rate计算实战
2026/10/15 8:21:50 网站建设 项目流程

简介:面向目标检测、行人检测及计算机视觉算法研究者,这份资源提供了一套基于Matlab的miss rate versus false positives per image(FPPI)曲线绘制代码。FPPI是衡量检测器在单位图像误检次数下漏检率的核心指标,适用于算法对比、参数调优和论文实验展示。压缩包共10840个文件,大小约161.4MB,主体为10682个txt结果文件,用于保存检测框、置信度等输出;137个vbb为视频序列标注文件,对应评测所需的真值信息;7个m脚本实现读取、计算与绘图流程,另有1个mat数据文件,整体目录层级清晰,便于按数据集批量运行。已有1178人学习,配合资源描述中的使用说明,可快速上手。拿到后只需将自身检测结果按约定格式放置,运行脚本即可得到标准FPPI曲线,免去自行实现评估指标的繁琐过程,适合需要标准化评估行人检测器性能的研究与工程人员。 做行人检测或者自动驾驶感知的朋友应该都有过这种经历:模型在验证集上的mAP已经刷到不错了,可真拿测试视频一跑,要么帧帧都在框某个根本不存在的目标,要么该认出来的行人悄悄漏了过去。mAP这种聚合指标,根本表达不了这些细分的实际问题。我后来在对比检测器性能时,基本都改用一条曲线——miss rate versus false positives per image(FPPI),横坐标是每张图片的误检数,纵坐标是漏检率,画在log-log坐标下,检测器在“可接受误检”前提下的真实漏检水平一目了然。这篇文章就以这个标题为主线,把从数据准备、匹配规则到FPPI曲线绘制代码的完整流程记录下来,代码可以直接抄去用,适合正在做检测器评估、行人检测基准测试的同学。

1. 为什么行人检测领域普遍用FPPI曲线代替mAP

1.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加TN,TN是所有被认为是背景的区域。在目标检测里,背景区域的数量比检测框多好几个数量级,哪怕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”就意味着前三个检测框被接受,后面的全部被拒绝。所以逐一遍历检测框,就等价于遍历了所有有意义的工作点。

这也是逐点绘制和阈值扫描的本质关系:不是预先选定一组阈值,而是让数据自己决定曲线的转折点。每加入一个检测框,若它是TP,miss rate下降;若它是FP,FPPI增大。两者共同在曲线上走出一个阶梯状路径。

3.3 为什么横纵坐标必须用log-log

miss rate的动态范围经常从0.01到1,FPPI则可能从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_threshold=0.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, key=lambda det: det[1], reverse=True) # 按图片分组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_path=None, 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] # 过滤掉fppi=0的点,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, lw=2, label=label) 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(loc='lower left') plt.grid(True, which='both', linestyle='--', alpha=0.4) if save_path: plt.savefig(save_path, dpi=200) 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), samples=9): """ 计算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,曲线上会出现fppi=0的点。直接plot会导致数据丢失或报错。

常见处理方式有三种:

  • 把fppi=0的点的横坐标替换成一个很小的值(比如1e-5),因为小于实际纵轴范围的量级,对曲线形态几乎没有影响。
  • 绘制前直接过滤fppi=0的点,但要注意曲线左端点位置会产生跳变。
  • 使用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曲线能告诉你模型在哪个“误检容忍级别”下、因为什么原因失效。排查完这些点再回头调模型,改进效果在曲线上会非常直观地体现出来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询