十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

目标检测FPPI曲线绘制:从原理到Python代码实现与踩坑指南

目标检测FPPI曲线绘制:从原理到Python代码实现与踩坑指南 简介针对目标检测与行人检测场景这份代码包实现miss rate versus false positives per imageFPPI曲线的绘制适用于需要客观评估检测器精度、对比不同算法性能的研究者和开发者。压缩包共收录10840个文件体积约161.4MB其中txt检测结果文件多达10682个另有137个vbb标注文件、7个Matlab脚本和1个mat数据文件。txt文件采用通用格式存储检测框信息可直接用于计算漏检率与误报率vbb文件来自标准行人检测标注体系m脚本完成数据读取、匹配和FPPI曲线绘制mat文件则存放工作区变量与预设参数便于直接复用。目前已有1178人浏览学习。整体代码逻辑清晰、模块分工明确读者只需替换检测输出文件即可得到标准FPPI曲线免去自行搭建评估流程的繁琐工作同时包内完整数据与标注也可作为复现经典行人检测评估流程的学习样本。1. 这个曲线为什么值得画从一次算法评估说起大概两年前我在调一个行人检测模型的时候发现了一个特别尴尬的现象模型的mAP看着挺好看但落地到实际视频流里误报多得让人头皮发麻。后来跟前辈一聊才意识到问题出在评估指标上——mAP这种基于PR曲线的指标对“每张图允许出现多少误报”这件事不够敏感而实际业务场景恰恰最关心这个。这时候就该FPPI曲线出场了。miss rate versus false positives per image翻译过来就是“漏检率与单图误检数之间的关系曲线”简称FPPI曲线。在目标检测领域尤其是行人检测、车辆检测这种对误报容忍度极低的任务里FPPI曲线几乎是论文标配。它回答的问题非常直接如果一张图里平均允许出现0.1个误检模型能保住多少召回率如果放宽到1个误检呢不同算法之间在“低误报区间”的差距往往就是实际部署时的生死线。我自己刚开始画这条曲线的时候也是疯狂踩坑网上关于mAP的教程一抓一大把但关于FPPI绘制的完整代码和细节说明却少得可怜。这篇文章就把我从数据准备、原理理解到代码实现、踩坑排查的完整过程整理出来代码直接用Python就能跑希望能帮到同样被这个曲线折磨的朋友。2. 先把两个核心指标吃透miss rate与FPPI2.1 这两个指标到底在算什么在写代码之前必须把指标本身的定义搞明白否则画出来的图自己都解释不通。Miss Rate漏检率也叫Miss Rate at FPPI它和Recall其实是互补关系。Recall是“查全率”表示所有真实目标里有百分之多少被检测出来了Miss Rate就是没被检测出来的那部分公式是Miss Rate 1 - Recall FN / (FN TP)FN是漏检的目标数TP是正确检出的目标数。为什么用Miss Rate而不是直接用Recall因为FPPI曲线的传统来自Dollar等人关于行人检测的研究在这个领域研究者更习惯看“漏了多少”而不是“找回多少”表达习惯问题但本质上是一回事。FPPIFalse Positives Per Image直译是“单张图像的假正例数”公式FPPI 检测器输出的所有误检总数 / 图像总数这里有个容易踩的坑FPPI是平均到“每一张图”的不是平均到“每一个目标”的。也就是说你的测试集里如果有一张特别难的图产生了20个误检其他99张都很干净那FPPI算的是20/1000.2而不是20/某个目标数。这个特性和mAP里按所有检测框统一排名的逻辑完全不同也是它更适合衡量“实际运行体验”的原因——毕竟真实运行的时候系统面对的就是一帧一帧的图像。2.2 为什么PR曲线和mAP替代不了它PR曲线和FPPI曲线最大的区别在于横轴的归一化方式。PR曲线的横轴是Precision分母是“所有被检测出来的框”这导致它天然偏向“测一堆框但是质量高”的策略而FPPI曲线的横轴是“平均每张图的误检数”和图像数量挂钩和“目标总数”没有直接关系。举个例子A算法在每张图上平均抛50个框最后挑出10个高置信度的框误检率控制得很好PR曲线会非常漂亮。但实际部署时这张图传给下游模块的是50个框还是10个框往往是50个。如果下游模块对误报零容忍这个策略在PR曲线上是虚高的。FPPI曲线直接按每张图算误检负担更能反映系统真实的“打扰率”。还有一个实际原因很多目标检测数据集比如Caltech行人数据集的标注密度差异很大有的图就1个人有的图有30个人。如果你看的是“总误检/总目标数”这种归一化方式人多的图会稀释误检的影响而FPPI按图像平均每张图的权重相等更贴近真实摄像头画面分布。2.3 理解检测器的置信度阈值扫描不管是画mAP还是FPPI背后的核心机制都是同一个通过调整置信度阈值获得一系列不同的工作点把这些点连成一条曲线。检测器对每一个预测框都会输出一个置信度分数比如0.95、0.87、0.43。设一个阈值t置信度大于等于t的框保留小于t的框丢弃。t设得越高保留的框越少误检自然少但漏检会增多miss rate变高t设得越低保留的框越多召回上来了但误检也爆炸。FPPI曲线就是把这个过程可视化横轴是FPPI越小越好纵轴是miss rate越小越好曲线越靠近左下角说明这个检测器“在同等误检水平下漏检最少”性能越好。这里有一个容易忽略的细节miss rate和FPPI并不是单调对应的。阈值从低往高扫FPPI通常单调下降因为框少了但miss rate不一定单调上升因为有些阈值下虽然框多但正确框的比例也可能变高导致miss rate出现小幅波动。很多人在画图时发现曲线不是严格单调的就怀疑代码写错了其实这是正常现象论文里的曲线也都是带锯齿的。3. 写代码前的数据准备检测结果应该长什么样3.1 检测结果的存储格式在实际动手写FPPI绘制代码之前你得先确认手头的检测结果文件是什么格式。我平时最常用的是以下两种格式一按图像分组的JSON或TXT每张图对应一个条目里面记录所有检测框、置信度分数以及真实标注框{ image_001.jpg: { detections: [ {bbox: [x1, y1, x2, y2], score: 0.92, class: person}, {bbox: [x1, y1, x2, y2], score: 0.45, class: person} ], gt_boxes: [ {bbox: [x1, y1, x2, y2], class: person}, {bbox: [x1, y1, x2, y2], class: person} ] }, ... }格式二平铺列表每一行就是一个检测框包含图片名、框坐标、置信度真实标注同理。这种格式在目标检测竞赛里很常见处理起来也方便。无论哪种格式关键就是三件事每个检测框都有置信度分数、每张图的真实目标框已知、图片名能对得上。如果你只有PR曲线绘制用的中间结果通常也是够的因为两者的基础数据完全一样只是统计口径不同。3.2 什么是“一个检测框算命中”在计算TP/FP之前必须定义一个判定规则什么样的检测框算是“命中了真实目标”最常见的是IoU交并比阈值法。IoU 检测框与真实框的交集面积 / 检测框与真实框的并集面积如果IoU ≥ 0.5就认为这个检测框命中了对应的真实框如果同一个真实框被多个检测框命中通常只算置信度最高的那个为TPTrue Positive其他都算FP。这就是NMS非极大值抑制的思想虽然检测器输出时通常已经做过NMS但保险起见匹配时还是要做“一个真实框只能匹配一个检测框”的限制。这个匹配逻辑和mAP计算完全一致所以如果你之前写过mAP的评估代码直接复用那一部分就行。不过我建议单独封装一个函数避免和mAP的代码耦合太深后面调参会方便很多。4. 核心绘制代码实现从计算到出图4.1 第一步计算每个阈值下的TP和FP先写一个函数输入是“所有检测框列表”和“所有真实框列表”输出是一组在不同置信度阈值下的FPPI和miss rate。我直接给完整代码这是经过多次项目验证的版本import numpy as np import json from collections import defaultdict def compute_iou(box1, box2): 计算两个框的IoUbox格式为[x1, y1, x2, y2] x1_min, y1_min, x1_max, y1_max box1 x2_min, y2_min, x2_max, y2_max box2 inter_xmin max(x1_min, x2_min) inter_ymin max(y1_min, y2_min) inter_xmax min(x1_max, x2_max) inter_ymax min(y1_max, y2_max) inter_w max(0, inter_xmax - inter_xmin) inter_h max(0, inter_ymax - inter_ymin) inter_area inter_w * inter_h area1 (x1_max - x1_min) * (y1_max - y1_min) area2 (x2_max - x2_min) * (y2_max - y2_min) union_area area1 area2 - inter_area if union_area 0: return 0.0 return inter_area / union_area def match_detections_to_gt(detections, gt_boxes, iou_threshold0.5): 对单张图片的检测结果与真实框进行匹配。 返回每个检测框是否为TP1或FP0。 matched_gt set() tp_fp np.zeros(len(detections), dtypeint) # 按置信度从高到低排序确保高置信度检测框优先匹配 sorted_idx np.argsort([d[score] for d in detections])[::-1] for idx in sorted_idx: det detections[idx] best_iou 0.0 best_gt_idx -1 for gt_idx, gt in enumerate(gt_boxes): if gt_idx in matched_gt: continue iou compute_iou(det[bbox], gt[bbox]) if iou best_iou: best_iou iou best_gt_idx gt_idx if best_iou iou_threshold and best_gt_idx ! -1: tp_fp[idx] 1 matched_gt.add(best_gt_idx) # 否则tp_fp保持0代表FP return tp_fp def compute_fppi_missrate_all(detections_by_image, gt_by_image, iou_threshold0.5, num_thresholds50): 对测试集所有图片计算不同置信度阈值下的FPPI和miss rate。 detections_by_image: dictimage_id - list of {bbox: [x1,y1,x2,y2], score: float} gt_by_image: dictimage_id - list of {bbox: [x1,y1,x2,y2]} num_images len(gt_by_image) all_scores [] # 先收集所有置信度分数用于确定扫描区间 for img_id, dets in detections_by_image.items(): for det in dets: all_scores.append(det[score]) if len(all_scores) 0: return [], [] # 在置信度分数的min~max之间均匀取num_thresholds个阈值 # 注意要额外包含一个0.99之上的阈值让FPPI能趋近于0 min_score min(all_scores) max_score max(all_scores) thresholds np.linspace(min_score, max_score, num_thresholds) thresholds np.append(thresholds, max_score 1e-6) fppi_list [] missrate_list [] for thr in thresholds: total_fp 0 total_tp 0 total_fn 0 for img_id in gt_by_image.keys(): dets detections_by_image.get(img_id, []) gts gt_by_image.get(img_id, []) # 只保留置信度 thr 的检测框 valid_dets [d for d in dets if d[score] thr] if len(valid_dets) 0: # 没有检测框所有真实目标都算漏检 total_fn len(gts) continue tp_fp match_detections_to_gt(valid_dets, gts, iou_threshold) tp_count int(np.sum(tp_fp 1)) fp_count int(np.sum(tp_fp 0)) total_tp tp_count total_fp fp_count total_fn max(0, len(gts) - tp_count) fppi total_fp / num_images miss_rate total_fn / (total_tp total_fn) if (total_tp total_fn) 0 else 1.0 fppi_list.append(fppi) missrate_list.append(miss_rate) return np.array(fppi_list), np.array(missrate_list)核心逻辑总结就三步按阈值过滤检测框 → 逐图做IoU匹配得到TP/FP → 累加所有图的TP/FP/FN计算FPPI和miss rate。4.2 第二步绘制log-log曲线算出FPPI和miss rate之后画图反而是最简单的部分。有一点需要注意FPPI曲线通常用log-log坐标轴绘制。原因是误检率往往横跨几个数量级从0.01到100线性坐标下低误检区间的差异根本看不出来而实际部署最关心的恰恰是这个区间。import matplotlib.pyplot as plt def plot_fppi_curve(fppi_list, missrate_list, method_nameMy Detector, save_pathfppi_curve.png): 绘制FPPI曲线使用log-log坐标轴。 plt.figure(figsize(8, 6)) # 论文里通常会在 [10^-2, 10^0] 之间画参考线 plt.plot(fppi_list, missrate_list, markero, linestyle-, linewidth1.5, markersize3, labelmethod_name) plt.xscale(log) plt.yscale(log) plt.xlim([1e-4, 1e2]) plt.ylim([0.01, 1.0]) plt.xlabel(False Positives Per Image (FPPI), fontsize12) plt.ylabel(Miss Rate, fontsize12) plt.title(Miss Rate vs FPPI, fontsize14) plt.grid(True, whichboth, linestyle--, alpha0.6) plt.legend(locupper right, fontsize10) if save_path: plt.savefig(save_path, dpi300, bbox_inchestight) print(f曲线已保存到: {save_path}) plt.show() # 使用示例 if __name__ __main__: # 用fake数据演示整个流程 np.random.seed(42) # 构造20张测试图 gt_by_image {} detections_by_image {} for i in range(20): num_gt np.random.randint(1, 5) gts [] for _ in range(num_gt): x1 np.random.uniform(0, 400) y1 np.random.uniform(0, 400) w np.random.uniform(20, 80) h np.random.uniform(40, 120) gts.append({bbox: [x1, y1, x1 w, y1 h]}) gt_by_image[fimg_{i:03d}] gts # 随机生成检测框数量比真实目标多一些 num_dets np.random.randint(1, 8) dets [] for _ in range(num_dets): x1 np.random.uniform(0, 400) y1 np.random.uniform(0, 400) w np.random.uniform(20, 80) h np.random.uniform(40, 120) score np.random.uniform(0.1, 0.95) dets.append({bbox: [x1, y1, x1 w, y1 h], score: score}) detections_by_image[fimg_{i:03d}] dets fppi, miss compute_fppi_missrate_all(detections_by_image, gt_by_image) plot_fppi_curve(fppi, miss)运行这段代码你就能得到一条FPPI曲线。如果你的检测器性能足够好曲线应该靠近左下角如果曲线整体偏右上角说明漏检率和误检率都不太理想。4.3 多模型对比与log-average miss rate实际写论文或做汇报时很少有只画一条曲线的情况。通常是把baseline、自己的方法、几个SOTA放在同一张图里对比。这时只需要把不同模型的结果依次调用compute_fppi_missrate_all再循环画到同一张图上即可。另外很多论文会报一个单值指标log-average miss rate。它是在FPPI从10^-2到10^0这个区间内对miss rate取对数后平均再转换回线性值。计算方法如下def log_average_miss_rate(fppi, missrate, start0.01, end1.0): 计算log-average miss rate。 只取FPPI在[start, end]范围内的点对miss rate求log平均。 如果某些区间没有采样点需要插值。 # 在log空间均匀采样9个点论文常见做法 fppi_levels np.logspace(np.log10(start), np.log10(end), 9) missrate_at_levels [] for fppi_level in fppi_levels: # 找FPPI最接近当前level的点对应的miss rate idx np.argmin(np.abs(fppi - fppi_level)) missrate_at_levels.append(missrate[idx]) return np.exp(np.mean(np.log(np.maximum(missrate_at_levels, 1e-10)))) lamr log_average_miss_rate(fppi, miss) print(fLog-average Miss Rate: {lamr:.4f})这里有个细节值得注意直接取“最接近的点”在FPPI采样点稀疏时不够精确更严谨的做法是用相邻点做线性插值。但多数情况下只要你的阈值采样足够密两种方法的结果差异很小。5. 常见问题与排查技巧实录5.1 曲线出现“平台段”或“断崖式下跌”正常吗正常但需要排查原因。如果你发现曲线在某个FPPI点之后miss rate突然大幅下降大概率是那个置信度阈值附近恰好覆盖了一批高置信度的正确检测框。这种“阶跃”在数据量小时非常明显比如测试集只有几百张图每个阈值点上的TP/FN变化被放大曲线就会显得很不平滑。如果数据量足够大几千张以上曲线通常会平滑很多。如果曲线出现很长的水平平台段说明miss rate对阈值不敏感可能是因为你的检测器输出分数普遍偏低或者大部分目标压根没被检测到miss rate恒等于1-FN占比。这时候去查检测器本身而不是画图代码。5.2 为什么我算出来的FPPI比想象中大很多一个非常常见的坑没有正确过滤低置信度的“垃圾框”。很多检测器在默认设置下会输出大量低置信度的框比如score0.01这些框在实际部署中根本不会用但因为你的阈值扫描区间是从min_score开始的这些垃圾框全被计入了FP导致整条曲线严重偏右。解决办法很简单在扫描阈值时设置一个下限比如max(min(all_scores), 0.05)或更严格的值。或者在预处理时直接丢掉置信度低于某个下限的检测框。另一个坑是重复统计了同一张图的同一批检测框。有一次我发现FPPI曲线在阈值很低时竟然超过10排查后发现是数据加载时把同一个检测结果文件读了两次导致所有框都翻倍了。建议写代码时加个断言所有图片的检测框总数 文件里的行数。5.3 IoU阈值应该选多少0.5还是0.75这取决于你的任务严格程度。行人检测传统上用0.5因为行人框的标注本身有一定模糊性但如果是精确定位的任务比如车牌识别、工业质检0.75甚至0.9都不过分。我的建议是如果要和已发表论文对比先查清楚对方用的IoU阈值保持一致。如果只是自己评估可以同时算0.5和0.75两组看模型在不同严格程度下的表现是否稳定。有些模型在0.5下很优秀但到0.75就崩了这通常说明框的位置不够精确只是“撞上了”而已。5.4 画出来的曲线为什么不是单调下降的FPPI和miss rate理论上都是阈值变化的函数但正如前面说的miss rate不一定严格单调。更常见的问题是FPPI在阈值升高的过程中偶尔也会出现不降反升的情况这是因为某些检测框被过滤掉后原本被它“抢占”的真实框可以匹配给另一个置信度稍低的检测框导致TP的归属发生变化FP的数量也随之波动。这不是代码的bug而是匹配算法的固有特性。论文里的曲线也都是有锯齿的不用刻意平滑。5.5 关于“参考线”和“图例”的几个建议论文里的FPPI曲线图通常会画一条“随机检测器”的参考线或者标注等误检率的辅助线。我做图时比较习惯在图中加入FPPI1的竖直参考线因为很多业务场景的误报预算就是“每张图最多1个误检”。代码很简单plt.axvline(x1.0, colorgray, linestyle--, alpha0.7, linewidth1)图例方面我建议在label里直接加上log-average miss rate的值比如labelf{method_name} (LAMR{lamr:.3f})这样读者扫一眼图例就能看到数值不用再去正文里翻。6. 整个流程的一次完整演示最后放一个综合示例把从加载JSON到出图的完整流程串起来方便你直接改造使用。import json import numpy as np import matplotlib.pyplot as plt from collections import defaultdict # 假设你的检测结果和GT都是如下格式 # { # image_001.jpg: { # detections: [{bbox: [...], score: 0.9, class: person}, ...], # gt_boxes: [{bbox: [...], class: person}, ...] # }, ... # } with open(eval_results.json, r) as f: data json.load(f) detections_by_image {} gt_by_image {} for img_id, info in data.items(): detections_by_image[img_id] info[detections] gt_by_image[img_id] info[gt_boxes] # 计算多组IoU阈值下的曲线 plt.figure(figsize(8, 6)) for iou_thr in [0.5, 0.75]: fppi, miss compute_fppi_missrate_all( detections_by_image, gt_by_image, iou_thresholdiou_thr, num_thresholds40 ) lamr log_average_miss_rate(fppi, miss) plt.plot(fppi, miss, markero, markersize3, linewidth1.5, labelfIoU{iou_thr} (LAMR{lamr:.3f})) plt.xscale(log) plt.yscale(log) plt.xlim([1e-4, 1e1]) plt.ylim([0.01, 1.0]) plt.xlabel(False Positives Per Image (FPPI)) plt.ylabel(Miss Rate) plt.title(Miss Rate vs FPPI) plt.grid(True, whichboth, linestyle--, alpha0.6) plt.legend(locupper right) plt.savefig(final_fppi_comparison.png, dpi300, bbox_inchestight) plt.show()实际使用中把eval_results.json换成你自己的文件路径把detections和gt_boxes的字段名对齐基本就能跑了。7. 一些个人经验与注意事项FPPI绘制这件事代码本身不难真正花时间的往往是数据格式的整理和IoU匹配逻辑的验证。我踩过最大的坑是匹配逻辑里忘了按置信度排序导致高置信度的框反而被低置信度的框抢占了GT整条曲线都失真了。所以强烈建议你写完匹配函数之后先拿几张图手动核对一下TP/FP的判定结果确认无误再批量跑。第二个建议是不要只画FPPI曲线建议同时输出log-average miss rate。曲线是给人看的数值才是给人对比的。论文审稿人也好业务方也好都喜欢看单值指标有曲线有数值说服力直接翻倍。第三个建议是关于阈值采样数量的。我见过有人用1000个阈值画图结果fppi在低区间几乎密集成一团黑也有人只用5个阈值曲线糙得没法看。实测下来30到50个阈值在绝大多数情况下都足够平滑既保证颜值又保证计算速度。最后想说FPPI曲线只是评估工具不是目的。真正有价值的是读懂曲线背后的信息你的模型在低误报预算下到底行不行在高误报预算下有没有瓶颈和别的算法相比短板在哪。把这些看明白了画图这件事才算真正发挥了作用。本文还有配套的精品资源点击获取
返回列表