目标检测评估指标解析:从TP/FP到PV-RCNN的3D IoU计算
做3D点云目标检测的人迟早要跟 TP、FP、TN、FN 这四个统计量打交道。PV-RCNN 训练完之后你跑评估脚本最后打印出来一堆 Precision、Recall、AP 和 mAP其实所有数字都是从这四个基础计数推导出来的。最近我在研读一个开源框架里 PV-RCNN 的评估模块把从预测框到 TP/FP/TN/FN 统计的整条链路翻了一遍今天就把核心逻辑和代码实现掰开揉碎讲清楚。这篇文章适合正在调点云检测模型、想彻底搞懂评估指标的人我会从代码层面告诉你这四个值是怎么算出来的以及哪些地方最容易算错、导致指标虚高或者虚低。1. 先搞清楚四个指标在检测场景里的真实含义1.1 从模型输出到四个计数的映射在分类任务里TP、FP、TN、FN 的定义很直接你预测一个类别预测对了就是 TP预测错了是 FP没预测出来但实际存在是 FN预测不存在且实际也不存在是 TN。但在目标检测里情况要稍微绕一点因为检测输出的是“框”框和框之间不是完全相等的默认一个框只要和真实标注的框重叠超过某个 IoU 阈值就算预测正确。举个 PV-RCNN 推理后的输出例子。模型对场景里的车会输出一组预测框每个框通常包含中心坐标 x、y、z尺寸 w、l、h朝向角 yaw还有置信度 score 和类别 label。评估代码拿到这些结果再配合数据集自带的 ground truthGT框就要判断每个预测框到底是“真阳性”还是“假阳性”。我把常用的对应关系列一下指标检测场景中的含义判定条件TP正确检测到的目标预测框与某个 GT 框类别一致且 IoU ≥ 阈值且该 GT 框此前未被其他预测框匹配过FP误检目标预测框与所有同类别 GT 框的 IoU 都低于阈值或者匹配到的 GT 框已经被其他更高置信度的预测框占用了TN正确未检测的区域检测任务里通常不计算或按整帧/体素格来定义FN漏检目标某个 GT 框没有被任何预测框以 IoU ≥ 阈值的方式命中这里最值得留意的是判定条件里的“且该 GT 框此前未被其他预测框匹配过”。一个 GT 框只能对应一个成功的检测结果否则重复框会被同时标记成 TPPrecision 就会虚高。很多自己写评估脚本的人第一次都栽在这个点上后面我会专门讲匹配顺序。1.2 为什么 TN 常常被忽略严格说TP、FP、TN、FN 是四元组但目标检测的评估代码里TN 基本不会去算。原因很直观一张场景中“没有目标”的区域是无限多个的你不可能把每个空白区域都当成负样本框来统计。如果硬要定义 TN就得对空间做体素化或者滑窗采样那定义本身就会影响指标反而不客观。更贴近实际的解释是检测任务关心的是“我有没有把目标框出来”而不是“我有没有把非目标区域都正确判别为负”。这就像找人你只关心找到了谁、找错了谁、漏掉了谁不会去统计“这张大街上没有被误认为人的石头有多少块”。所以你在绝大多数目标检测评估代码里能看到 TP、FP、FN 的统计却看不到 TN就是这个原因。不过在 PV-RCNN 的某些场景里如果你把点云分成体素格也可以把“没有被分配为正的体素格”当作 TN 来算 IoU 或分类损失但那是训练阶段的体素分支在做的事不是评估阶段的指标统计。评估阶段TN 基本退出舞台重点全在 TP、FP、FN 上。1.3 评估代码的整体数据流搞清楚了四个指标的含义再看代码就不会晕。PV-RCNN 评估模块的数据流我概括成下面几步读取模型输出的预测框包括类别、置信度、坐标尺寸读取对应帧的 GT 框按类别将预测框和 GT 框分组逐类处理对每个类别计算预测框和 GT 框之间的 3D IoU 矩阵根据 IoU 阈值执行贪心匹配确定每个预测框是 TP 还是 FP统计没被匹配到的 GT 框数量得到 FN按置信度对所有预测框排序为后面计算 PR 曲线做准备。这个过程可以浓缩成一段伪代码评估脚本的核心骨架大概长这样def evaluate(pred_annos, gt_annos, classes, iou_thresholds): all_preds [] # 保存每帧每个预测框的 TP/FP 标记 all_scores [] total_gt 0 for pred, gt in zip(pred_annos, gt_annos): for cls in classes: pred_cls select_by_class(pred, cls) gt_cls select_by_class(gt, cls) if len(gt_cls) 0: # 没有 GT 时所有预测都是 FP mark_as_fp(pred_cls) continue iou_matrix compute_iou_matrix(pred_cls, gt_cls) match_result greedy_matching(iou_matrix, iou_thresholds[cls]) # match_result 记录每个预测框是否命中 GT mark_tp_fp(pred_cls, match_result) fn_count count_unmatched_gt(match_result, len(gt_cls)) total_gt fn_count all_preds.extend(pred_cls) all_scores.extend(pred_cls.score) return all_preds, all_scores, total_gt看到这里你已经知道评估代码的骨干了。接下来才是重点3D IoU 到底怎么算匹配逻辑又是怎么写的。2. 代码解读第一步3D IoU 怎么算出来2.1 预测框和 GT 框的字段对齐在写 IoU 计算之前要先确认预测框和 GT 框的字段是否对齐。PV-RCNN 常见输出格式是 7 维框加额外信息我以最常见的 7 维框为例[x, y, z, w, l, h, yaw]。这里有个容易踩坑的点w和l的顺序。有的代码里w是宽度、l是长度但不同数据集标注的顺序可能相反。如果你不先做字段对齐后面的 IoU 全是错的而且错得很隐蔽几个类别的 AP 看起来只是偏低不会直接报错。另外yaw 角的定义也要统一。有的数据以 x 轴正方向为 0 度逆时针为正有的以 y 轴正方向为 0 度。坐标系的差异会导致框的朝向完全反过来。评估代码里通常会把预测框和 GT 框都转换到同一个坐标系再做 IoU 计算。所以读代码时第一步不是看 IoU 函数而是看数据预处理里有没有坐标转换。我建议你调模型验证时随机取几帧把预测框和 GT 框画在点云俯视图上先肉眼确认方向一致再往下算能省很多事。2.2 旋转框 IoU 的通用算法3D 框的 IoU 计算比 2D 框麻烦因为框是带朝向的。常见做法不是直接在三维空间算而是先算两个框在俯视图BEV投影旋转矩形之间的交集面积再计算它们在高度方向上的重叠长度两者相乘得到三维交集体积公式是IoU 交集体积 / (预测框体积 GT框体积 - 交集体积)BEV 投影矩形求交的典型方法是多边形裁剪。简单说把一个矩形当成凸多边形用另一个矩形去裁剪它保留落在对方内部的顶点最终得到一个重合区域的多边形再用鞋带公式Shoelace Formula算面积。写出来的核心逻辑大概是def polygon_intersection_area(poly1, poly2): # 用 Sutherland-Hodgman 算法裁剪 output poly1 for edge in edges(poly2): input output output [] for i in range(len(input)): curr input[i] prev input[i - 1] if inside(curr, edge): if not inside(prev, edge): output.append(intersect(prev, curr, edge)) output.append(curr) elif inside(prev, edge): output.append(intersect(prev, curr, edge)) return polygon_area(output)三维高度方向的重叠则简单很多取两个框 z 轴范围[z_min, z_max]的最大下界和最小上界如果最小上界大于最大下界高度重叠就是正数否则为 0。实际做的时候多数人会直接调用现成的 3D IoU 实现比如基于旋转框的矢量化计算。但我建议你至少手写一次这个投影求交过程因为理解之后调 BUG 时才不会一脸茫然。比如你会发现某个开源实现里并没有处理两个框完全重合的边界条件或者没有正确处理空交集多边形这些都会导致 IoU 算成负数或 nan。2.3 IoU 阈值与贪心匹配的逻辑IoU 算出来是一个矩阵行数是预测框数量列数是 GT 框数量。比如某帧有 8 个预测框6 个 GT 框那矩阵就是 8x6每个元素表示第 i 个预测框和第 j 个 GT 框的 IoU 值。有了矩阵接下来就是匹配。我强调一下PV-RCNN 评估里的匹配是逐类别进行的且一个 GT 框最多只能被一个预测框匹配。很多框架用贪心策略我把它翻译成人话先从 IoU 矩阵中找到最大值如果这个最大值大于阈值就把对应预测框和 GT 框匹配成功把这一行和这一列都“划掉”表示这个预测框已经被判定、这个 GT 框已经被占用重复以上过程直到矩阵里没有大于阈值的数据。对应的匹配逻辑在代码里大概这样写def greedy_matching(iou_matrix, iou_threshold): matched_gt np.zeros(iou_matrix.shape[1], dtypebool) match_flags np.zeros(iou_matrix.shape[0], dtypeint) # 1TP, 0FP # 不断寻找当前最大 IoU while True: idx np.argmax(iou_matrix) i, j np.unravel_index(idx, iou_matrix.shape) if iou_matrix[i, j] iou_threshold: break match_flags[i] 1 matched_gt[j] True iou_matrix[i, :] -1.0 iou_matrix[:, j] -1.0 # 未匹配的预测框就是 FP match_flags[match_flags 0] 0 return match_flags, matched_gt这段代码虽然简短但里面有细节值得琢磨。为什么要不断取全局最大 IoU而不是按预测框的顺序依次匹配因为这样做可以避免置信度高的预测框抢占一个 IoU 更高但置信度略低的框的 GT。评估时我们希望“最接近”的框赢而不是“最先出现”的框赢全局最大匹配更符合人对“正确检测”的判断。另外IoU 阈值的设置也因类别而异。在常用的车载检测基准里Car 类别阈值通常取 0.7Pedestrian 和 Cyclist 取 0.5。阈值越高要求预测框和 GT 框越重合TP 的判定就越苛刻。这个阈值不是拍脑袋定的它代表定位精度的要求调模型时如果发现 Car 的 AP 很低可以先看看是不是大多数框的 IoU 都卡在 0.5 到 0.7 之间。3. 代码解读第二步置信度排序决定 TP/FP 的累计曲线3.1 为什么要先排序再统计匹配完成之后每个预测框有了一个身份TP 或 FP。但注意此时还没有形成最终指标。因为目标检测的评估不是只看一个固定置信度阈值下的检测结果而是要看模型在不同置信度阈值下的整体性能。比如你把预测框的 score 阈值设为 0.9可能只剩 2 个框准确率很高但召回率很低设为 0.1可能出来 15 个框召回率上去了但误检也上去了。单独取一个阈值不能全面反映模型好坏所以评估框架会把所有帧的预测框收集起来按 score 从高到低排序然后从高到低逐一“放行”观察每个累计点上的 precision 和 recall。这就是 PR 曲线的由来横坐标是召回率纵坐标是精确率每个点对应一个置信度阈值下的累计统计。PV-RCNN 的输出里每个框自带 score排序后的第一名的置信度最高最后一个的置信度最低。排好序后我们从第一个框开始持续累积累计 TP 数量从排序起点到当前框中所有标记为 TP 的框总数累计 FP 数量从排序起点到当前框中所有标记为 FP 的框总数当前阈值下的 Precision 累计TP / (累计TP 累计FP)当前阈值下的 Recall 累计TP / GT总数。理解了这一点就能明白为什么代码里先把所有帧的预测框拼成一个列表然后调用 sort 按 score 降序排列再逐行计算。3.2 一个手算示例我用手算的示例演示一下避免只看代码印象不深。假设某类别 GT 框总数为 10模型对某帧和上一帧的预测框合并后按 score 降序排列前 6 个框的匹配结果如下排名scoreTP/FP累计TP累计FPPrecisionRecall10.98TP101.00.120.92TP201.00.230.85FP210.6670.240.78TP310.750.350.64FP320.60.360.55TP420.6670.4你看第 3 名的框虽然是 FP但 Precision 从 1.0 掉到了 0.667这就是误检的代价。到了第 6 名Recall 才到 0.4说明还有 6 个 GT 没被召回最终 FN 数量是 6。这个表也是我调试模型时最爱看的中间量。我会在评估脚本里加一段日志打印出排名前 30 个框的 score、类别、TP/FP 状态然后肉眼扫一遍。如果发现高置信度的预测框里混着大量 FP说明模型在“自信地错”一般不是后处理阈值的问题而是特征或 NMS 参数需要调。3.3 从累计 TP/FP 到 PR 曲线和 AP累计完 TP 和 FP下一步就是 APAverage Precision计算。经典的做法是 11 点插值在 Recall 从 0 到 1 之间均匀取 11 个点每个点取当前阈值下能达到的最大 Precision然后求平均。后来很多框架改成 40 点插值即按 Recall 划分 40 个区间在每个区间取最大 Precision 再平均。这里我不想把公式展开太复杂只强调一点无论哪种插值核心输入就是刚才按 score 排序后的累计 TP 和累计 FP。PV-RCNN 评估代码一般不会从头写插值逻辑而是把排序后的累计结果交给一个计算 AP 的函数。但你要能读懂它不然模型某个类别的 AP 不高时不知道是召回问题TP 占比低还是精度问题FP 太多。判断方法很简单如果 AP 低且 PR 曲线的召回率整体上不去FN 多重点查模型漏检如果 Precision 掉得快FP 多重点查误检。这两种情况的调法完全不一样前者可能需要增加训练数据的类别均衡、降低正样本阈值后者可能需要提高 NMS 的 IoU 阈值、过滤低置信度框。4. 多帧聚合与常见实现细节4.1 逐帧统计怎么汇总PV-RCNN 评估不是只在单帧上算指标而是要在整个验证集的所有帧上统计。单帧的 GT 数量是几十个但整个集合理有几千个。逐帧处理之后必须把各帧的结果汇总成一个全局列表。最常见的做法是每帧处理完就把该帧所有预测框的 score、TP/FP 标记、类别追加到三个全局列表里同时把该帧的 GT 总数累加到一个变量。整个数据集跑完再拿全局列表排序、计算 PR 曲线。这个流程看起来简单但有一个容易出 BUG 的地方跨帧的预测框如果直接拼在一起但没有按帧打标记后续就无法追踪某个框来自哪一帧可视化定位问题时非常痛苦。所以我在写评估脚本时会给每个预测框加一列 frame_id方便出问题时回溯到原始点云帧。伪代码如下all_detections defaultdict(list) # key: class, value: list of (score, tp_fp) num_gt defaultdict(int) for frame_id, pred in enumerate(pred_annos): for cls in classes: dets pred[cls] # match 之后得到 tp_flags all_detections[cls].extend(zip(dets[score], tp_flags, [frame_id]*len(dets))) num_gt[cls] len(gt[cls]) for cls in classes: dets sorted(all_detections[cls], keylambda x: x[0], reverseTrue) # 接着算 PR注意这里的排序对象是所有帧拼接后的检测框而不是单帧。这个全局排序的意思是说评估时把整个验证集当成一个大场景按照模型的置信度统一排序然后看真实世界中模型在高置信度决策时到底做得好不好。这样得到的 AP 才是稳定的不会被单帧样本量少导致的抖动干扰。4.2 聚合之后TP/FP 的数量怎么转化为最终指标聚合完就能算最终的指标。Recall 的计算是Recall 累计TP / 全部GT数量这里的“全部GT数量”等于所有帧、所有类别下 GT 的总数也等于 TP 匹配到的 GT 数加上 FN 数。换句话说FN 数量不需要单独统计可以用 GT 总数减去所有匹配成功的 GT 数得到。代码里常见的写法是在逐帧匹配时记录 matched_gt 的数量最后用 total_gt - total_matched_gt 得到 FN。Precision 的计算前面已经提过不再赘述。还有一个要留意的细节在计算 mAP 时多个类别的 AP 是分别算完再求平均的但每个类别对应的 IoU 阈值可能不同。你在综合对比模型性能时不能只看一个 mAP 数字最好把每个类别的 AP 单独列出来看看是哪一类拖了后腿。4.3 TN 在点云检测里还能不能算虽然前面说评估阶段基本忽略 TN但有一个特殊场景会用到当你把自己的检测结果做成“体素级别”的语义分割时比如 PV-RCNN 里的点云特征学习可能会把每个点或每个体素当作一个样本判断它是否是目标表面点。这种“逐点逐体素”的视角下TN 是有意义的它可以统计“被正确分类为非目标区域”的点数。但要注意这跟最终目标检测框的 TP/FP/FN 统计是两套指标系统。前者是辅助训练的分类指标后者才是衡量检测结果的核心指标。如果你在网上看到有人讨论“检测任务中为什么没有 TN”多半说的是后者。千万别在写评估脚本时为了凑出 TN 而把没有物体的点云区域当成负样本那会让指标失去可比性。5. 实战中踩过的坑和排查方法5.1 坐标系统和旋转角方向的坑我印象最深的一个坑是坐标轴方向不一致导致的 IoU 全错。有一次我换了一个数据集格式没有统一坐标转换结果 Car 类的 AP 掉了十几个点但训练 loss 看起来很正常。排查时发现预测框的 yaw 定义和 GT 的 yaw 定义一个顺时针一个逆时针导致 3D IoU 计算出来的重叠很小很多明明对准的框被判成 FP。排查这类问题有一个快方法取出一个预测框和一个 GT 框单独算它们的 IoU并打印中心点坐标、框尺寸、yaw 角再画到点云上。如果视觉上它们重叠了但计算出的 IoU 接近 0那一定是坐标定义问题。如果 IoU 数值和视觉不一致继续查高度方向和长宽顺序。5.2 匹配去重没做FP 虚高另一个频繁踩的坑是重复匹配。如果匹配代码只是简单地对每个预测框找最大 IoU 的 GT不去标记“这个 GT 已被占用”那么两个预测框可能同时匹配到同一个 GT结果是一个正确目标被统计成两个 TPFN 也被多算。最终的表现是 AP 虚高模型看起来很强上线后根本达不到这个效果。我检查重复匹配的技巧是在匹配完成后检查每个 GT 框被多少个预测框匹配了。正常情况这个数字只能是 0 或 1。如果出现大于 1 的情况说明匹配逻辑没有正确划掉已匹配的行或列。这个检查只需要几行代码但能在调模型早期就发现问题。match_counts np.zeros(num_gt, dtypeint) for i in range(num_pred): if pred_is_tp[i]: match_counts[pred_matched_gt[i]] 1 assert (match_counts 1).all(), 存在 GT 被重复匹配5.3 通过日志和可视化定位 FP 与 FN统计完 TP、FP、FN我发现最有用的调试手段不是看最终 AP而是把每个类别的 FP 和 FN 可视化出来。对 FP我喜欢打印出置信度最高的前 20 个误检框看看它们到底框住了什么。很多时候模型把路边的护栏、广告牌甚至点云噪声误认成了车这时候调整类别判断分支或者过滤方法就很有针对性。对 FN我会把所有没匹配上的 GT 框单独标出来看看漏检的目标有什么共同特点。可能是目标太小、太远、点太稀疏也可能是雷达扫描盲区。有一次我们发现 Pedestrian 的 FN 集中在遮挡严重的情况单纯调 NMS 没用最后是在训练数据里增加了遮挡样本的复制增强才把这一类的 AP 拉起来。这些操作听起来简单但极其重要。只盯着 mAP 数字调模型就像只知道考试成绩却看不到错题效率很低。把 TP、FP、FN 三个维度的样本拆开来看问题往往一目了然。结尾实际跑 PV-RCNN 评估时我把 TP、FP、TN、FN 的计算链路完整走了一遍之后最大的感受是这四个数本身不复杂复杂的是代码里的各种隐式约定。坐标定义、长宽顺序、匹配策略、跨帧聚合任何一个环节出问题最后打印出来的指标都不可信。我自己调试评估脚本时一定会先在少量数据上做可视化核对确认框的朝向和匹配结果正确再放心跑全量评估。另外也提醒一句看到别人报告的高 AP 时先问一句用的什么 IoU 阈值、怎么处理重复匹配否则那些数字可能根本没法对比。这个步骤看似琐碎但恰恰是模型效果能否真正落地评估的关键。