PV-RCNN评估代码拆解:TP/FP与mAP计算逻辑

发布时间:2026/10/14 10:07:53
PV-RCNN评估代码拆解:TP/FP与mAP计算逻辑 很多人跑通PV-RCNN之后第一件事就是看自己在验证集上的mAP。改了半天网络结构、调了一堆训练参数结果评估输出的指标纹丝不动或者高得离谱怎么看都不对劲。这时候八成不是模型的问题而是没搞懂评估代码里TP、FP、TN、FN到底是怎么算出来的。我一开始也栽在这上面。以为只要检测框和真实框叠得够多就能算TP后来翻代码才发现整个过程远比算个重叠度复杂。它涉及IoU怎么定义、匹配策略是什么、按什么顺序遍历检测框、一个真实框能不能被匹配多次、困难样本怎么处理每一步都在影响最终的TP、FP数量进而影响Precision、Recall和mAP。这篇文章我就把PV-RCNN相关的评估代码从输入到输出完整拆一遍重点对齐TP、FP、TN、FN的计算逻辑。你看完之后再去查自己模型的评估结果就会清楚很多。这个内容适合两类人看一种是正在复现或魔改3D检测模型的想搞清楚验证集指标到底受什么影响另一种是准备自己写评估脚本的想避开KITTI评估流程里那些绕来绕去的坑。1. 先说清楚在3D检测里TP、FP、TN、FN到底代表什么1.1 四类检测结果的工程定义从概念上讲检测任务的每个输出框跟真实标注框比对之后都能归到四类里TPTrue Positive检测框确实命中了一个真实目标。判断命中的标准不是看起来像而是检测框和某个真实框的IoU超过设定阈值而且这个真实框之前没有被其他检测框匹配过。FPFalse Positive检测框是虚报的。它要么跟所有真实框的IoU都低于阈值要么匹配到的真实框已经被别的更高置信度检测框抢走了。FNFalse Negative真实目标漏检了。数据里确实有一个目标框但所有检测框都没能跟它匹配上。TNTrue Negative在目标检测评估里这个概念基本不参与计算。原因下面单说。这里有个细节很多初学者会忽略一个真实框只能被匹配一次但一个检测框可以同时和多个真实框有高IoU。所以匹配顺序、置信度排序直接决定了一个检测框到底是TP还是FP。代码里并不是简单地IoU大于阈值就算TP。1.2 为什么TN在检测评估代码里被跳过先想一个问题目标检测模型的输入是一整帧点云里面有成百上千个可能的框位置。除了那些真实标注的目标剩下的背景区域理论上都可以被视作负样本。但背景是连续的、无穷多个的你没法把所有可能的错误框都枚举出来。所以检测评估里TN没有实际意义。代码里根本不会去统计正确地没有框出背景区域这种事件。评估只用两类正样本事件来驱动——你找出来的目标里有多少是对的Precision真实目标里你找回了多少Recall。但如果往网络训练那边看TN就很重要了。检测头里做前景/背景二分类的时候每个被采样到的候选框都会算一个分类损失背景正确分类就是TN它参与损失计算。所以你要分清评估阶段的TN不计算训练阶段的TN隐式存在。不少人把这两个阶段的概念混在一起导致看代码时特别困惑。在PV-RCNN的评估实现里你真正会遇到的变量基本是这几个tp、fp、gt_ds或等效的GT数量统计、num_match_gt。没有直接叫TN的数组。后面我会逐个说明这些数组怎么生成、怎么用。2. 评估模块的数据组织和核心入口2.1 数据从哪来GT框和检测框的统一格式要理解TP/FP怎么算先得知道评估代码拿到的是什么样的数据。在典型的3D检测评估流程里真实框和检测框都会被统一成类似的格式。每个框至少有这些字段位置中心点坐标通常在地图坐标系或相机坐标系下包含x、y、z三个值。尺寸长、宽、高。朝向角绕z轴俯仰方向的旋转角用弧度表示。类别IDCar、Pedestrian、Cyclist等类别编号。置信度检测框特有表示模型对这个框的把握程度。除此之外真实框还带有难度标志一般根据目标在画面里的尺寸、遮挡程度、截断程度分成Easy、Moderate、Hard三档。评估的时候不是所有真实框都参与统计而是根据你指定的难度等级只保留满足条件的真实框。检测框则是模型推理输出的结果。这里有个关键点检测框必须先按置信度从高到低排序评估代码才能做贪心匹配。这个顺序直接影响匹配结果后面详细说。2.2 关键函数入口按类别、按难度循环评估在代表性实现里评估模块通常长这样def get_official_eval_result(gt_annos, dt_annos, current_classes, difficultys): gt_annos: 每一帧的真实标注列表 dt_annos: 每一帧的检测框列表 current_classes: 需要评估的类别列表 difficultys: 难度过滤列表如 [0, 1, 2] min_overlaps get_min_overlaps(gt_annos, current_classes) result for classe_id in current_classes: for difficulty in difficultys: # 逐类别、逐难度统计TP/FP tp, fp, gt_ds, pred_ds prepare_data( gt_annos, dt_annos, classe_id, difficulty) # 根据tp和fp计算precision/recall/AP ... return result这里最核心的一个函数是prepare_data它的任务就是把当前类别、当前难度下的所有真实框和所有检测框取出来然后算出一组TP、FP数组。min_overlaps则是不同类别对应的IoU阈值配置——Car通常用0.7Pedestrian和Cyclist通常用0.5。prepare_data干了三件事。第一把真实框和检测框分别存成数组第二把检测框按置信度降序排列同时记录每个检测框属于哪一帧第三把真实框也按帧分组方便后续逐帧算IoU。做完这些才进入真正的匹配循环。理解这段逻辑的关键是评估不是全局匹配而是逐帧匹配。每一帧单独算IoU矩阵单独做匹配最后把所有帧的TP、FP结果拼在一起。因为自动驾驶场景里帧与帧之间是独立采样跨帧的框根本没有可比性。3. 核心代码从IoU矩阵到TP/FP统计3.1 先算BEV下的IoU为什么不是三维IoU先说一个容易糊涂的点。PV-RCNN评估里算IoU用的不是完整的3D体素重叠而是把3D框投影到俯视图BEV也就是鸟瞰图在二维平面里算矩形重叠面积占并集面积的比例。为什么要这么做有两个原因。第一历史原因。公开的自动驾驶检测数据集标准评估协议里定义的就是2D投影或BEV下的匹配。后来的研究者为了对比公平都沿用这套标准。你用3D IoU去算结果跟论文里的mAP完全不具可比性。第二精度合理性。自动驾驶场景里目标高度方向的误差其实不太影响是否检测到目标这个判断但水平方向x、y的偏差非常关键。BEV IoU天然把重点放在水平定位精度上。代码层面的实现通常长这样def bev_box_iou(boxes1, boxes2): boxes1: 检测框, shape [N, 5] (x, y, 长, 宽, 朝向角) boxes2: 真实框, shape [M, 5] 返回: IoU矩阵 [N, M] # 1. 根据中心坐标和边长计算每个框在BEV下的角点坐标 # 2. 对每对框计算相交多边形的面积 # 3. IoU 交面积 / (框1面积 框2面积 - 交面积)第2步是代码里最关键的实现政变。很多自己写过IoU的人第一反应是两个旋转矩形相交直接算矩形相交面积不就行了吗。但旋转矩形相交后的重叠区域不一定是矩形可能是不规则多边形。正确的做法是计算两个矩形的所有边求交点再用多边形面积公式如鞋带公式算重叠面积。这就解释了一个现象为什么网上有些实现算出来的IoU当两个框完全重合时是1.0但当框稍微错开一点数值就跳变得很夸张而官方代码的结果却平滑得多。3.2 匹配矩阵的构建逻辑拿到每一帧的IoU矩阵之后下一步是构建匹配关系。这个过程的代码实现很有讲究。以prepare_data内部逻辑为例伪代码如下def prepare_data(gt_annos, dt_annos, current_class, difficulty): gt_ds [] # 每个真实框 dt_ds [] # 每个检测框 # 遍历每一帧 for i in range(num_frames): # 取出当前帧的真实框和检测框 gt_boxes extract_gt_boxes(gt_annos[i], current_class, difficulty) dt_boxes extract_dt_boxes(dt_annos[i], current_class) # 按置信度从高到低排序检测框 dt_boxes sorted(dt_boxes, keylambda x: x.score, reverseTrue) # 计算BEV IoU矩阵, shape [num_dt, num_gt] iou_matrix bev_box_iou(dt_boxes, gt_boxes) # 初始化匹配计数 num_match_gt len(gt_boxes) # 对每个检测框执行贪心匹配 for dt_idx, dt_box in enumerate(dt_boxes): best_gt_idx -1 best_iou 0 for gt_idx, gt_box in enumerate(gt_boxes): iou iou_matrix[dt_idx][gt_idx] if iou best_iou: best_iou iou best_gt_idx gt_idx if best_iou min_overlap and num_match_gt 0: tp[dt_idx] 1 # 匹配成功且该GT还没被匹配过 num_match_gt - 1 # 这个GT被占了后面不能再匹配 else: fp[dt_idx] 1 # 要么IoU不够要么GT已被抢走这里有几个值得注意的设计点。检测框必须按置信度排序这也是为什么不同实现里你调置信度阈值会导致TP/FP结果完全不一样。排序之后置信度最高的框优先选择自己最匹配的真实框如果它看中的真实框已经被更前面的框匹配了它就只能降级成FP。这个逻辑在Greedy Matching里叫一夫一妻制保证了一个真实框最多对应一个检测框。另外为什么要有num_match_gt这个计数其实仔细看代码会发现有些实现压根不检查这个计数只靠GT一旦被匹配记录matched标志下次跳过来完成同样的约束。但用计数的方式有个额外好处你可以一眼看出这一帧还有多少个真实框没有被匹配这样在循环外就能算出FN。3.3 TP、FP数组是怎么累加的在整个数据集的评估中tp和fp是全局变量每个检测框对应一个元素置信度高的排在前面。假设整个验证集有1000个检测框那么tp就是一个长度1000的数组里面只有0和1fp同理。这个设计很巧妙。因为后面画PR曲线时需要从一个最高的置信度阈值开始逐步放宽计算出不同阈值下的Precision和Recall。如果把每个框到底是TP还是FP单独存下来只要按顺序累加就能快速得到任意阈值下的TP总数和FP总数不需要重新匹配。代码大致是tp_cs np.cumsum(tp) # 到第i个框为止一共匹配成功的数量 fp_cs np.cumsum(fp) # 到第i个框为止一共虚报的数量 gt_total len(gt_ds) # 所有GT真实框的总数 precision tp_cs / (tp_cs fp_cs 1e-10) recall tp_cs / (gt_total 1e-10)gt_total是所有帧里满足当前类别、当前难度的真实框数量它既是Recall的分母也是FN计算的基础。FN gt_total - 匹配成功的真实框总数这个值可以由gt_total - tp_cs[-1]直接得到。但在PR曲线绘制中FN不直接出现而是通过Recall的降低来体现。4. TP/FP/TN/FN如何汇总成最终评估指标4.1 从TP、FP数组到Precision-Recall曲线一旦有了全局的tp、fp数组之后所有指标的计算都变得非常简单直接。还是沿用上面的tp_cs和fp_cs你可以把这个过程理解成数数每往后看一个检测框就相当于把置信度阈值降低一档。新纳入的框如果是TPtp_cs加1Recall提高如果是FPfp_cs加1Precision下降。于是你得到了一组对应的 (Precision, Recall) 点。把这些点连起来就是PR曲线。注意这条曲线是不平滑的因为每新增一个框Precision和Recall都在跳变。4.2 为什么同一召回率下会对应多个精度点细看PR曲线你会发现在某些Recall值上Precision会来回抖动。原因是TP和FP是交替出现的。举个例子。假设当前已经匹配了10个TPRecall是0.5。这时候如果下一个检测框是FPtp_cs不变fp_cs加1Precision变低但Recall保持不变。再下一个框如果是TPRecall涨到0.52Precision又回来了。于是同一段Recall附近你会看到多个不同的Precision值。所以在计算AP时官方代码会对每个Recall区间取该区间内的最大Precision这叫召回率点上的精度上限recall-precision envelope。特别是11点插值AP它把Recall从0到1均匀分成11个点在每个点取后续最大的Precision再求平均。有实现也提供了全点积分的版本对PR曲线的所有折点做积分。两种算法算出来的AP有差异一般在小数点后第二三位才能看出区别。所以当你对比不同论文的mAP时要先确认他们用的是哪种AP计算方式否则0.01的差距可能是算法差异不一定说明模型好坏。4.3 FN最终如何影响AP前面说了FN不直接出现在PR曲线计算里。但你看计算式recall tp_cs / gt_totalgt_total是固定的所以Recall完全由TP数量决定。而 FN gt_total - 匹配成功的真实框总数所以FN越少最后的最大Recall就越高。如果模型一个真实框都没找到tp_cs全是0Recall恒为0AP必定是0。这就是FN的间接但致命的影响。在评估输出的日志里你通常能看到类似这样的信息Car AP0.70, 0.70, 0.70: bbox AP: 90.11, 88.89, 87.56 bev AP: 89.67, 87.23, 84.11 3d AP: 88.11, 78.21, 76.33它分别展示了2D框、BEV框、3D框三种匹配方式下的AP。PV-RCNN论文报告的是BEV和3D两种。BEV AP就是我们前面讲的基于投影矩形IoU的评估3D AP则要算完整的三维box IoU匹配逻辑和TP/FP统计方式完全一样只是IoU换成三维版本。5. 实操实录跑通PV-RCNN评估代码的几个关键细节5.1 坐标系的坑先转成BEV再算IoU我曾经在评估代码里看到有人直接拿3D框的中心坐标和尺寸去算BEV IoU出来的指标特别差。问题出在坐标系变换。在公开自动驾驶数据集里真实框的标注通常在相机坐标系下x轴向右、y轴向下、z轴向前。而检测框输出可能是在LiDAR坐标系或者经过变换后的坐标系坐标轴方向完全不同。如果直接套IoU计算x和y可能谁是谁都搞错了长宽也反了IoU结果必然全乱。正确做法是评估模块里统一有一个坐标转换的步骤把真实框和检测框都转到同一个坐标系下再计算BEV平面上的投影框。具体来说取出每个框的顶部俯视图四角坐标用x、y坐标和朝向角构成旋转矩形完全不看z值。写自己的评估脚本时建议先拿一帧数据做可视化把真实框和检测框都画到鸟瞰图上确认两者重叠关系跟你想的一致再继续调后面的指标计算。5.2 难度划分是怎么影响GT数量的PV-RCNN评估里Easy、Moderate、Hard三档难度看起来是并列的但它们是层层包含的关系。也就是说Hard档的真实框集合包含Moderate档包含Easy档。你评估Moderate的时候使用的GT数量比Easy多FN的基数也更大。代码里的难度过滤逻辑大致如下Easy目标在图像中的高度大于一定像素遮挡等级为0截断程度低于阈值。Moderate目标高度略低遮挡等级不超过1截断程度放宽。Hard目标高度进一步放宽遮挡不超过2截断程度进一步放宽。关键是检测框不按难度过滤只有真实框按难度过滤。不管一个检测框检测的目标多小、遮得多厉害只要它匹配到的真实框在当前的难度过滤集合里就算有效TP。所以当你发现Easy的AP比Hard高很多时不用惊讶。Easy集合里只有那些最好检的目标GT总数少检测框一旦匹配上Precision自然高。Hard集合里塞满了遮挡严重的困难样本稍有不慎就漏检TP减少FN增加Recall上不去AP自然低。5.3 min_overlap到底怎么影响TP/FP前面提到的min_overlap就是IoU阈值。不同类别不一样通常Car是0.7Pedestrian和Cyclist是0.5。这个值不是拍脑袋定的而是跟数据集的标注标准和对齐精度有关。车辆框通常标注得更准所以阈值高一点行人和骑车人姿态多变标注本身就有模糊性阈值定太高会把本来正确的检测框误判成FP。你在评估代码里改这个值会立刻看到AP的剧烈变化。比如把Car的阈值从0.7改成0.5很多原本IoU在0.5到0.7之间的检测框从FP变成了TPAP可能直接跳高5个点以上。但这不是模型变好了只是评估变宽松了。所以我建议如果单纯想跟论文对比一点都不要改min_overlap保持默认。如果想分析自己的模型定位精度倒是可以额外跑几个不同阈值下的指标看IoU在0.5、0.6、0.7下AP的衰减速度能反映出定位误差到底有多大。6. 实战问题排查TP/FP/TN/FN计算最常见的坑6.1 为什么召回率算出来是0如果你发现验证集上Recall恒为0先别怀疑模型。最常见的两个原因一是难度过滤出了问题。做的检测框全部被过滤掉或者GT过滤后集合为空gt_total为0代码为了防止除零错误会直接把AP置0。这种时候输出日志里AP全是0像个异常值。二是类别ID对不上。真实框和检测框的类别编号不一致。比如真实框里Car用0表示检测框里Car用1表示那么评估时拿类别1的检测框去匹配类别0的真实框永远匹配不上TP全是0Recall就是0。排查方法很简单打印一帧数据里gt_annos[i][name]和dt_annos[i][name]看两边类别名能不能对应上。6.2 为什么Precision明显偏低精度偏低最常见的原因是对检测框的置信度排序做错了。如果你的代码没有按置信度降序排列就直接做贪心匹配那么后面那些高分框可能被前面的低分框抢走了GT导致低分框成了TP高分框成了FP。最终统计的时候高置信度区间混进大量FPPrecision被压低。解决办法是在匹配之前必须确保dt_boxes按照score降序排列。不同实现里命名可能不同有的叫scores有的叫confidence但逻辑一致。还有一种情况是GT没有按帧做匹配。比如你把所有帧的检测框和所有帧的真实框一次性放到一个巨大的IoU矩阵里计算跨帧的框也会发生匹配。这就乱套了一个完全没有重叠的不同帧目标也能因为IoU刚好大于0.7而变成TPPrecision虚高。合理的做法是逐帧计算IoU矩阵逐帧匹配。6.3 为什么指标看起来异常高指标高不一定都是好事要留意数据泄漏或重复计算。数据泄漏的情况训练的时候就已经把验证集的标注信息传给了模型或者验证集本身参与了训练。检测模型如果把验证集的场景记住推理结果会异常地好。这种问题纯从代码上很难发现但在指标上会有蛛丝马迹——比如AP高得离谱人眼去看检测效果却没那么惊艳。重复计算的情况评估循环里没有清空上一轮的tp、fp数组。比如跑完Car类再跑Pedestrian类时tp数据里残留了Car的框导致Pedestrian的指标包含了Car的信息。代码问题把数组初始化放到每个类别循环内部即可。6.4 评估代码跑不通的排查思路我自己调试这类评估代码时有个固定流程分享出来给你参考第一步先用单帧、单类别数据跑通全流程。随便挑一帧GT只有一个Car模型输出也只有一个框验证TP、FP的统计是否正确。等单帧跑通了再逐步加帧。第二步打印中间的IoU矩阵。选一对已知应该匹配的框人工算一遍BEV IoU跟代码输出对比。这个步骤能最快暴露坐标转换或者IoU计算实现的问题。第三步检查tp和fp数组的形状。预期长度等于检测框总数且tp[i]和fp[i]为互斥的0/1关系。如果你发现同一个框既不算TP也不算FP或者两者都是1说明匹配循环里的逻辑有缺陷。第四步把PR曲线画出来。拿tp累加和fp累加用matplotlib画一下一眼就能看出曲线形状是否合理。正常的PR曲线应该是Precision随Recall升高而下降如果出现阶梯状跳升多半是匹配逻辑错了。7. 一点踩坑心得最后聊点代码之外的体会。我第一次看PV-RCNN评估代码时最花时间的地方不是IoU而是搞懂为什么tp和fp是按检测框逐个记录的数组而不是一个总数量。后来才明白这种设计是为了快速计算不同置信度阈值下的PR曲线。这套思路不只是PV-RCNN在用几乎所有基于标准评估协议的目标检测框架都是这么组织的。还有一点是千万不要把训练时的分类指标和评估时的检测指标混在一起。训练时算的TP、TN是针对每个候选框的前景/背景二分类而评估时TP、FP是针对最终输出的目标框和真实框的匹配结果。一个框在训练时可能是正确分类的背景负样本在评估时可能因为置信度太高被当成了一个错误的正样本FP。这两个身份不冲突因为它们的定义域完全不一样。如果你现在正在魔改PV-RCNN我建议你先做一件事把验证集上某一帧的GT和检测结果分别可视化出来再跑一遍评估用输出日志里的TP数量对照一下。这样做过之后你再也不会被评估代码的逻辑绕晕。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询