很多人跑通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 四类检测结果的工程定义
从概念上讲,检测任务的每个输出框,跟真实标注框比对之后,都能归到四类里:
- TP(True Positive):检测框确实命中了一个真实目标。判断命中的标准不是"看起来像",而是检测框和某个真实框的IoU超过设定阈值,而且这个真实框之前没有被其他检测框匹配过。
- FP(False Positive):检测框是虚报的。它要么跟所有真实框的IoU都低于阈值,要么匹配到的真实框已经被别的更高置信度检测框抢走了。
- FN(False Negative):真实目标漏检了。数据里确实有一个目标框,但所有检测框都没能跟它匹配上。
- TN(True 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轴(俯仰方向)的旋转角,用弧度表示。
- 类别ID:Car、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.7,Pedestrian和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, key=lambda x: x.score, reverse=True) # 计算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和1,fp同理。
这个设计很巧妙。因为后面画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,你可以把这个过程理解成"数数":每往后看一个检测框,就相当于把置信度阈值降低一档。新纳入的框如果是TP,tp_cs加1,Recall提高;如果是FP,fp_cs加1,Precision下降。
于是你得到了一组对应的 (Precision, Recall) 点。把这些点连起来,就是PR曲线。注意,这条曲线是不平滑的,因为每新增一个框,Precision和Recall都在跳变。
4.2 为什么同一召回率下会对应多个精度点
细看PR曲线你会发现,在某些Recall值上,Precision会来回抖动。原因是TP和FP是交替出现的。
举个例子。假设当前已经匹配了10个TP,Recall是0.5。这时候如果下一个检测框是FP,tp_cs不变,fp_cs加1,Precision变低,但Recall保持不变。再下一个框如果是TP,Recall涨到0.52,Precision又回来了。于是同一段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全是0,Recall恒为0,AP必定是0。这就是FN的"间接但致命"的影响。
在评估输出的日志里,你通常能看到类似这样的信息:
Car AP@0.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.7,Pedestrian和Cyclist是0.5。这个值不是拍脑袋定的,而是跟数据集的标注标准和对齐精度有关。车辆框通常标注得更准,所以阈值高一点;行人和骑车人姿态多变,标注本身就有模糊性,阈值定太高会把本来正确的检测框误判成FP。
你在评估代码里改这个值,会立刻看到AP的剧烈变化。比如把Car的阈值从0.7改成0.5,很多原本IoU在0.5到0.7之间的检测框从FP变成了TP,AP可能直接跳高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全是0,Recall就是0。排查方法很简单:打印一帧数据里gt_annos[i]['name']和dt_annos[i]['name'],看两边类别名能不能对应上。
6.2 为什么Precision明显偏低
精度偏低最常见的原因是对检测框的置信度排序做错了。如果你的代码没有按置信度降序排列就直接做贪心匹配,那么后面那些高分框可能被前面的低分框抢走了GT,导致低分框成了TP,高分框成了FP。最终统计的时候,高置信度区间混进大量FP,Precision被压低。
解决办法是:在匹配之前,必须确保dt_boxes按照score降序排列。不同实现里命名可能不同,有的叫scores,有的叫confidence,但逻辑一致。
还有一种情况是GT没有按帧做匹配。比如你把所有帧的检测框和所有帧的真实框一次性放到一个巨大的IoU矩阵里计算,跨帧的框也会发生匹配。这就乱套了,一个完全没有重叠的不同帧目标也能因为IoU刚好大于0.7而变成TP,Precision虚高。合理的做法是逐帧计算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数量对照一下。这样做过之后,你再也不会被评估代码的逻辑绕晕。