目标检测评估避坑指南:FPPI曲线与DET曲线正确绘制方法
简介实现 miss rate versus false positives per imageFPPI曲线绘制的完整代码包适用于目标检测与行人检测任务中的算法评估、论文复现及实验对比。基于提供的 Matlab 脚本和示例数据可快速生成标准 FPPI 曲线便于研究者、工程师检验检测器在不同误检率下的召回表现。压缩包约 161.4MB共 10840 个文件其中包含 137 个 vbb 格式标注文件、10682 个 txt 结果或标签文件以及用于绘图的 m 脚本和 mat 数据结构文件类型覆盖从标注解析到曲线绘制的完整链路目录中另有少量 macOS 元数据文件不影响使用。目前已有 1178 人学习下载。借助该资源读者能省去自行搭建评估流程的时间直接基于标准 VBB 标注与检测结果输出 FPPI 曲线为横向对比不同检测算法提供可靠依据。1. 把 FPPI 曲线画对目标检测评估里最容易糊弄过去的一环做目标检测的人尤其是搞行人检测的一定绕不开 miss rate versus false positives per imageFPPI这条曲线。它跟 mAP 这种“平均成绩”不同——mAP 高不代表检测器在极低误检率下还能用而安全监控、自动驾驶这类场景恰恰关心“每张图最多容忍 0.1 个误检时漏检率是多少”。这套 FPPI 绘制代码解决的正是这个问题把检测结果按置信度排序逐步扫描阈值算出每个阈值下的漏检率和平均每图误检数最终画出一条在双对数坐标下的 DET 曲线。适合做行人检测、通用目标检测评估的开发者也适合写论文需要复现 baseline 曲线的同学。这篇笔记会把计算原理、代码实现和排错经验一次讲透。2. FPPI 与 miss rate 的数学定义先看懂两个指标再动手写代码2.1 从 IoU 匹配到 TP/FP/FN四个基本量的判定逻辑FPPI 曲线的横纵坐标不是凭空来的它完全基于目标检测里的三个基础统计量TPTrue Positive、FPFalse Positive、FNFalse Negative。判定逻辑很简单对每个检测框计算它与所有真实标注框的 IoU交并比如果最大 IoU 大于某个阈值行人检测常用 0.5且该真实框还没被其他检测框匹配过就记为 TP否则记为 FP。如果一个真实标注框没有被任何检测框匹配到就记为 FN。这个“一框一匹配”的逻辑必须做成贪心匹配否则一个真实框匹配多个检测框TP 会被高估。常见做法是按置信度从高到低逐个处理检测框每匹配成功一个真实框就把该真实框从待匹配集合里移除。代码里我会用一个掩码数组来记录真实框是否已被占用匹配顺序从置信度最高开始。def match_detections(det_boxes, gt_boxes, iou_threshold0.5): matched_gt set() tp np.zeros(len(det_boxes), dtypebool) fp np.zeros(len(det_boxes), dtypebool) order np.argsort(-det_boxes[:, 4]) # 按置信度降序 for i in order: if len(matched_gt) len(gt_boxes): fp[i] True continue max_iou 0.0 max_gt_idx -1 for j, gt in enumerate(gt_boxes): if j in matched_gt: continue iou compute_iou(det_boxes[i, :4], gt) if iou max_iou: max_iou iou max_gt_idx j if max_iou iou_threshold: tp[i] True matched_gt.add(max_gt_idx) else: fp[i] True return tp, fp这段代码的关键参数是iou_threshold0.5 是 PASCAL VOC 的约定但行人检测某些数据集比如更严格的遮挡场景会用 0.4 或 0.5你需要跟测试集协议保持一致。det_boxes的每一行是[x1, y1, x2, y2, score]gt_boxes的每一行是[x1, y1, x2, y2]。compute_iou就是常规的交并比计算建议用向量化写法别在 Python 循环里嵌套两层否则一张图几百个框能让你等上几分钟。FN 的计算不依赖检测框而是直接用len(gt_boxes) - len(matched_gt)。注意如果检测框把同一个真实框匹配了多次因为matched_gt集合的存在第二、第三次匹配都会被判定为 FP这是正确的也符合论文里的评估协议。2.2 数据组织图像路径、置信度、框坐标缺一不可在动手计算之前你得先从检测器的输出整理出一个统一格式的结果文件。我一般会把它组织成三张表检测表每行一个检测框列依次是图像路径、置信度、x1、y1、x2、y2标注表每行一个真实框列是图像路径、类别、x1、y1、x2、y2以及一个图像列表所有参与评估的图的路径。有个细节容易被忽略图像列表里包含的是“所有测试图像”而不是“有检测结果的图像”。如果一个检测器在某张图上一个框都没输出这张图也必须算进总图像数否则 FPPI 的分母变小曲线会整体右移给你的检测器一种“看起来误检更少”的假象。这类问题我在第 5 章还会专门讲。# 检测结果文件示例img_001.jpg 0.987 12 34 120 200 # 标注文件示例img_001.jpg person 10 30 118 198解析时建议用 Pandas 的read_csv别用csv.DictReader手写循环。一个实际经验检测结果经常有重复行同一个检测框被写了两次会导致 TP 被重复计算、FP 虚高用drop_duplicates按六个字段去重是值得做的。2.3 计算 FPPI 与 miss rate从累积统计到坐标点有了每张图的 TP/FP/FN 之后FPPI 和 miss rate 的计算就是纯粹的累积统计。具体做法是把全部检测结果按置信度降序排成一条长表从置信度最高开始逐个“点亮”检测框。每点亮一个框就更新全局统计total_fp加 1如果它是 FPtotal_miss重新用公式total_gt - total_tp计算。FPPI 等于total_fp / num_imagesmiss rate 等于total_miss / total_gt。这里有个致命细节miss rate 的分母是测试集中所有真实框的总数不是“当前阈值下未匹配的真实框数”。换句话说即使在最高置信度阈值下miss rate 也等于 1——因为一个检测框都没匹配。只有当阈值逐步降低TP 增多miss rate 才逐步下降。曲线的起点在左上角或右上角取决于坐标轴方向终点在右下角这是 DET 曲线的标准形态。def compute_fppi_missrate(det_boxes_all, gt_boxes_all, image_count): # det_boxes_all: [N, 5]gt_boxes_all: [M, 4] total_gt len(gt_boxes_all) tp, fp match_detections(det_boxes_all, gt_boxes_all, 0.5) order np.argsort(-det_boxes_all[:, 4]) total_tp 0 total_fp 0 fppi_list [] missrate_list [] for idx in order: if tp[idx]: total_tp 1 else: total_fp 1 fppi total_fp / image_count missrate (total_gt - total_tp) / total_gt fppi_list.append(fppi) missrate_list.append(missrate) return np.array(fppi_list), np.array(missrate_list)这里有个隐藏问题match_detections的匹配结果是跟每张图绑定的跨图做全局匹配是不对的。如果你的两张图里各有一个行人检测器把两张图都检测为目标但匹配到同一个真实框——这种情况在逐图匹配时不会发生因为在每张图内做匹配后TP/FP 已定跨图累加只是求和。所以我建议在match_detections里按图分组处理而不是把det_boxes_all当作一个大列表去匹配gt_boxes_all。上面的代码省略了分组循环实战中要套一层按image_id的循环。3. 绘制 DET 曲线的核心代码从排序到双对数坐标3.1 解析检测结果文件并构建统一数据结构拿到检测结果和标注之后第一步是把原始文件解析成可供绘图代码直接消费的结构。我的习惯是做一个build_eval_data函数输入是三个路径输出是三个字典image_ids按名称排序的图像 id 列表、detections键为 image_id值为 N×5 数组、annotations键为 image_id值为 M×4 数组。这里强调“按名称排序”是为了后续调试时能快速对齐避免图像列表顺序错乱导致的 FPPI 计算偏差。import os import numpy as np import pandas as pd def build_eval_data(det_file, gt_file, image_list_file): det_df pd.read_csv(det_file, headerNone, names[img, score, x1, y1, x2, y2], sep\s) gt_df pd.read_csv(gt_file, headerNone, names[img, cls, x1, y1, x2, y2], sep\s) with open(image_list_file) as f: image_ids [line.strip() for line in f if line.strip()] detections {img: np.empty((0, 5)) for img in image_ids} annotations {img: np.empty((0, 4)) for img in image_ids} for img, group in det_df.groupby(img): if img in detections: detections[img] group[[x1, y1, x2, y2, score]].values for img, group in gt_df.groupby(img): if img in annotations: annotations[img] group[[x1, y1, x2, y2]].values return image_ids, detections, annotations这段代码的参数说明sep\s适配空格或制表符分隔的文本文件如果检测结果是 CSV 逗号分隔就把它改成sep,。image_list_file是测试图像的清单每行一个图像名。det_df里的img列跟image_list_file里的名字必须完全一致不能有相对路径或后缀名差异否则会出现检测结果全部被丢弃的情况。这个函数我一般会先跑一个冒烟测试打印几个 image_id 的检测框数量确认数据量级合理。3.2 多阈值扫描与坐标对生成避免重复匹配在 2.3 的累积统计里我每遍历一个检测框就记录一个坐标点这种方法没问题但会碰到一个问题如果两个检测框的置信度完全相同排序顺序可能不稳定导致曲线在该处出现微小锯齿。更稳妥的做法是先对置信度做去重只保留置信度变化的位置作为阈值点。这样也能显著减少最终输出点数让曲线渲染更流畅。另一个值得注意的陷阱如果你的检测结果里有大量置信度在 0.5 以下的框曲线最右端会非常密而最左端高置信度区域点数太少导致曲线在低误检率部分看起来跳跃很大。常见处理是画图时用marker.把每个点都标出来同时用窄线连接相邻点这样点数少也不会显得空洞。def compute_curve(image_ids, detections, annotations, iou_threshold0.5): all_det_scores [] all_tp [] all_fp [] for img in image_ids: dets detections[img] if len(dets) 0: continue tp, fp match_detections(dets, annotations[img], iou_threshold) all_det_scores.extend(dets[:, 4].tolist()) all_tp.extend(tp.tolist()) all_fp.extend(fp.tolist()) order np.argsort(-np.array(all_det_scores), kindmergesort) scores np.array(all_det_scores)[order] tp_arr np.array(all_tp)[order] fp_arr np.array(all_fp)[order] total_gt sum(len(ann) for ann in annotations.values()) total_images len(image_ids) cum_tp np.cumsum(tp_arr) cum_fp np.cumsum(fp_arr) unique_mask np.r_[True, np.diff(scores) ! 0] fppi cum_fp[unique_mask] / total_images missrate 1.0 - cum_tp[unique_mask] / total_gt return fppi, missrate参数说明kindmergesort是稳定排序保证相同置信度的框保持原有顺序这一点对曲线平滑性有实际影响。np.diff(scores) ! 0用来提取置信度变化的索引np.r_[True, ...]在头部补一个 True保证第一个点总是被留下。total_gt是所有图像真实框的总数不能动态更新否则 miss rate 曲线会在不同阈值下使用不同的分母数值完全失真。3.3 用 Matplotlib 绘制双对数 DET 曲线绘制 DET 曲线跟画 ROC 曲线最大的不同是坐标轴必须用对数刻度。行人检测论文里横轴 FPPI 通常从 0.01 到 1甚至到 10纵轴 miss rate 从 0.01% 到 100%。如果你的检测器精度不行曲线会直接撞到右边界或上边界这种情况对数轴能很直观地暴露问题。import matplotlib.pyplot as plt def plot_det_curve(fppi, missrate, output_pathdet_curve.pdf): fig, ax plt.subplots(figsize(6, 5)) ax.set_xscale(log) ax.set_yscale(log) ax.set_xlim([0.01, 10]) ax.set_ylim([0.001, 1]) ax.plot(fppi, missrate, color#1f77b4, linewidth1.5, marker., markersize4) ax.set_xlabel(False positives per image (FPPI)) ax.set_ylabel(Miss rate) ax.grid(True, whichmajor, linestyle--, alpha0.6) ax.grid(True, whichminor, linestyle:, alpha0.3) fig.tight_layout() fig.savefig(output_path, dpi300)这里的ax.set_xlim和ax.set_ylim是画好曲线的关键。如果检测器特别强FPPI 可以画到 0.001 量级但大部分情况下 0.01 起步已经够用。ax.grid(True, whichminor)在双对数坐标下非常重要因为 Matplotlib 默认只画主刻度网格而双对数坐标里主刻度之间跨度很大没有次刻度网格几乎无法判断曲线斜率。4. 可视化参数调优对数轴、曲线平滑与多模型对比4.1 对数坐标轴的 0 值处理裁剪与填充的边界双对数坐标下有且仅有两个值不能出现0 和负数。如果检测器在某个极低 FPPI 区间没有任何数据点怎么办直接硬画会报错曲线会中断。我的处理方式是先把数据做一次“下限保护”将所有计算出的 FPPI 和 miss rate 中的 0 值替换为一个极小的正数比如1e-6。但这里有个细节miss rate 在第一轮匹配前等于 1.0这是正常值只有 FPPI 可能在某段区间为 0高置信度阈值下通常 FP 数极少。另外x 轴的最小值要小于数据中的最小 FPPI否则第一个点会被裁剪到边界上视觉上曲线会从左侧被“切掉”。如果检测器表现极好最小 FPPI 只有1e-5那就把ax.set_xlim的下限设成1e-6让它既不挡数据又不会让曲线离边界太近导致细节被压缩。fppi np.maximum(fppi, 1e-6) missrate np.maximum(missrate, 1e-6)这两行代码建议放在plot_det_curve内部而不是在计算阶段处理。原因是计算阶段保持原始数据更有利于数值验证绘图阶段再改不影响统计结果。如果你在 DataFrame 里保存了原始数据绘图时临时裁剪后面想重新计算某个点还来得及。4.2 曲线平滑Logit 空间插值 vs 滑窗平均原始点画出来的曲线往往锯齿感很强尤其当图像数量少、检测器输出不稳定时。直接对双对数坐标做线性插值不可取因为 FPPI 和 miss rate 在原始空间分布不均插出来会扭曲。经验做法是在 log 空间做插值对log10(fppi)和log10(missrate)均匀采样然后反变换回原值。这能保证曲线在低值区间平滑不会因为少数几个点把斜率拉飞。实际操作中我并不建议把插值后的曲线直接作为论文里的正式结果——审稿人会质疑曲线真实性。更稳妥的做法是只对可视化的连线段做插值而把原始数据点用散点标记标出来。下面是常用的插值方法控制在合理范围内。from scipy.interpolate import interp1d def smooth_curve(fppi, missrate, num_points100): log_fppi np.log10(np.maximum(fppi, 1e-6)) log_miss np.log10(np.maximum(missrate, 1e-6)) f interp1d(log_fppi, log_miss, kindlinear, bounds_errorFalse, fill_valueextrapolate) interp_log_fppi np.linspace(log_fppi.min(), log_fppi.max(), num_points) interp_log_miss f(interp_log_fppi) return np.power(10, interp_log_fppi), np.power(10, interp_log_miss)参数说明num_points100是插值点数量太小曲线不平滑太大增加矢量图体积。fill_valueextrapolate允许在插值范围之外外推这是因为 DET 曲线经常只有一段数据如果不做外推曲线无法画到坐标轴边界。滑窗平均是另一个选择但风险更大窗口太大曲线整体右移FPPI 值被改变窗口太小平均效果等同原始数据。我一般不用滑窗平均处理 DET 曲线它更适合时间序列信号。如果你处理的是检测器的多个随机种子结果直接对log10(fppi)和log10(missrate)取均值而不是直接对原始数值取均值。4.3 多模型对比配色、线型与图例的工程化配置当你想在同一个坐标轴里对比多个检测器时曲线数量一多图例和配色就会变成大问题。我的固定习惯是不超过 5 条曲线用颜色区分超过 5 条改用线型实线、虚线、点划线与颜色同时区分这样即使黑白打印或灰度显示也能分辨。线宽统一在 1.5 到 2.0 之间太细在 PDF 里发虚太粗又盖住其他曲线。图例位置是关键。DET 曲线在高 FPPI、低 miss rate 的那一侧通常比较空也就是右下角把legend(loclower right)放在这里有八成概率不会挡数据。如果空间实在不够就手动指定bbox_to_anchor让图例浮在坐标轴外部右下角这是论文里的常见布局。curves [ (detector_a.pdf, #1f77b4, -), (detector_b.pdf, #ff7f0e, --), ] for file_path, color, linestyle in curves: fppi, miss np.load(file_path) ax.plot(fppi, miss, colorcolor, linestylelinestyle, linewidth1.8) ax.legend(loclower right)这里我直接存了.npy文件来保存各模型算好的 FPPI/miss rate 数组避免每次绘图都重新跑一遍匹配代码。这在工程上非常实用一次计算反复调样式不用每次改字体都重算数据。5. 常见排查与避坑FPPI 曲线不靠谱的五个根源5.1 曲线右端掉头向下匹配阈值不统一现象FPPI 增大到某个点后miss rate 不降反升曲线在右侧形成一个“钩子”。原因不同图像调用了不同的 IoU 阈值。比如有的图用了 0.5有的图用了 0.4导致 TP/FP 判定标准不一致曲线尾部数据失真。解决把所有图像的匹配逻辑统一到一个函数用同一个iou_threshold。建议在函数入口加一个断言检查所有图像的阈值一致否则直接报错。这个坑我踩过一次后来习惯在任何评估代码里把匹配函数抽出来不写多个版本。5.2 横轴范围显示为空白log 轴的零值与负值现象画出的曲线只有右半部分或者完全空白终端报错 warning。原因数据处理过程中出现了 0 或负的 FPPI 值或者没有设置set_xlimMatplotlib 在 log 轴下无法处理这些值。解决在计算阶段用np.maximum(fppi, 1e-6)做下限保护同时显式设置set_xlim和set_ylim。之后每次改数据都先打印fppi.min()和missrate.max()确认范围。5.3 计算结果与论文对不上图像数量统计口径不一致现象用同样的测试集画出的曲线跟论文里差了一个档次FPPI 整体偏小。原因图像列表里包含了训练集中的图或者某些图片有损坏被检测器跳过导致 FPPI 分母变大也可能是检测器跳过了无检测结果的图图像列表里少了图。解决用 2.2 的build_eval_data函数统计参与评估的图像列表确认len(image_ids)跟测试集总数一致并打印几条无检测结果但真实包含目标的图像 ID手动检查。5.4 曲线锯齿严重置信度排序不稳定现象曲线在低频段明显抖动同一条曲线重复运行两次细微处有差别。原因np.argsort默认用快速排序不稳定相同置信度的检测框顺序每次可能不一样。累积cum_tp和cum_fp对这些框异常敏感。解决排序时加kindmergesort让相同置信度的框以稳定顺序输出。另一个多层调用时确保使用相同的解析顺序不要用集合类型存储检测框。5.5 曲线左端不归零GT 框过滤策略差异现象即使阈值高到只输出几个框左下角仍然有 FPmiss rate 也不是 1.0。原因检测器输出里可能有置信度接近 1 的跳过帧、坐标越界的检测框这些框在比赛中可能被过滤但匹配逻辑里还在参与计算。解决在匹配前加一步过滤——去掉x2 x1或y2 y1的非法框去掉完全在图像边界外的框。这个过滤要在所有模型上统一执行否则横向对比失去意义。6. 进阶用法把 FPPI 曲线变成工程决策工具画好单条 DET 曲线只是第一步。我在实际项目里会用更进阶的方式同时绘制多条曲线并计算一个“参考工作点”——比如起点是要求 FPPI ≤ 0.1 时的最小 miss rate终点是要求 miss rate ≤ 0.1 时的最小 FPPI。这个工作点可以做成函数每次评估完直接输出两个关键数值用来横向对比模型迭代前后的效果。def summarize_curve(fppi, missrate, fppi_target0.1): idx np.argsort(fppi) sorted_fppi fppi[idx] sorted_miss missrate[idx] within sorted_fppi fppi_target if np.any(within): ref_miss sorted_miss[within].min() else: ref_miss 1.0 return ref_miss这个函数的价值在于它把曲线压缩成一个可读的分数。比如 A 模型在 FPPI0.1 时 miss rate 是 0.22B 模型是 0.18那么 B 在这个安全场景下就有明显优势——直观、可量化比“看起来曲线更低”更有说服力。我还会顺手把这两个数组保存为.csv每次跑完自动记录到结果目录方便复盘。用 PowerShell 或 Python 脚本批量跑评估时我会给每个模型命名加一个标签比如v1.0-fppi.csv然后写一个小函数把多个模型的曲线叠加到同一个坐标轴里。这里血泪经验是一定要把坐标轴的范围统一否则模型 A 的曲线画到 [0.01, 1]模型 B 画到 [0.001, 10]视觉上完全没法对比。还有一个容易被忽略的细节对于行人检测这类密集场景测试集里每张图的行人数可能差异很大。如果一张图有 50 个行人另一张图只有 1 个miss rate 在 FPPI 上会被“平均”掉。我一般会把每张图的检测数做一次分布统计确认测试集没有这种极端不均衡否则曲线会掩盖严重漏检问题。从那以后我每次评估检测器都强制走一遍这套流程先算 FPPI 和 miss rate 的原始数组保存.csv再画图画图前打印数据范围、确认对数轴边界最后用summarize_curve输出工作点跟着基线对比。这套习惯帮我少走了很多弯路。希望帮到你。本文还有配套的精品资源点击获取