首页 / 资讯中心 / 文章详情

3D目标检测评估函数深度拆解:从IoU到AP,手写自己的eval工具

3D目标检测评估函数深度拆解:从IoU到AP,手写自己的eval工具 ★ FEATURED ARTICLE
不知道你有没有遇到过这种情况3D目标检测的模型推理已经跑通预测框、置信度、类别全都拿到了但看到代码里那两行get_official_eval_result和get_coco_eval_result时突然愣住——只知道它能打印评估报表却不知道表格里的数字是怎么算出来的。更头疼的是如果你想加一个指标比如只看某个类别、按距离分桶统计、或者把结果输出成JSON供自动化流程使用就不知道该改哪里了。这篇文章就用一个实战视角把这两个常用评估函数的内部逻辑彻底拆开。先讲评估指标的基本概念再对比KITTI风格和COCO风格两套评价体系的差异最后手把手带你改写出自己的eval函数。内容不绕弯直接对着代码讲原理适合刚接触3D检测的工程师和学生也适合项目里需要自定义评测指标的开发者。读完你不仅能看懂那两个函数还能顺手写出一个适配自己需求的评估工具。1. 评估指标核心概念先把“尺子”的刻度搞明白1.1 一切都要回到IoU不管是2D检测还是3D检测评估的本质都是拿预测框和真实框做匹配。而匹配的依据就是两者重叠程度——IoU。IoU全称是Intersection over Union即交集面积除以并集面积。这个值越接近1说明预测框和真实框越贴合。3D检测里其实有两个层面的IoUBEV IoU把所有立体框压缩到鸟瞰视角俯视平面只算二维矩形的重叠程度。KITTI官方评估在较早期主要用的是这种方式因为当时的标注和预测都带有航向角误差直接在俯视图上评估对角度误差更宽容。3D IoU在三维空间里计算两个长方体所占体积的交并比。这种方式更严格一个预测框即使位置对了航向角转偏了十几度3D IoU就会掉得很厉害。实际代码里BEV IoU实现起来更简单缺点是忽略了高度维度的语义3D IoU则更精准但计算复杂度高一些。很多评估脚本默认让用户传入两个维度一个box表示三维位姿通常七维x, y, z, dx, dy, dz, yaw另一个维度用来判断是否匹配。你去看get_official_eval_result和get_coco_eval_result内部的匹配逻辑本质上都是在做同一件事先算IoU再根据IoU是否超过阈值判定这个预测框是正样本TP还是误检FP。1.2 AP怎么算从置信度排序到PR曲线有了匹配还不够3D检测最终输出往往是一堆带置信度的框。评估时必须按照置信度从高到低逐个判断。每遇到一个框如果它能和某个真实框匹配上就记一个TP真正例如果匹配不上或者和已经匹配过的真实框重复就记一个FP假正例。随着阈值不断降低预测框越来越多TP和FP不断累计就画出了一条Precision-Recall曲线。Precision精确率当前置信度阈值以上的预测框里面匹配正确的比例。Recall召回率当前置信度阈值以上找回来的真实框占全部真实框的比例。AP就是这条PR曲线下的面积。常见算法有两种11点插值法在Recall的0到1区间里等分11个点取每个点对应的最大Precision再求平均。这是早期VOC时代的做法对PR曲线的形状变化不敏感。积分法梯形面积法对所有Recall值变化的位置做梯形面积累加得到的AP更精细也更能反映曲线真实形态。COCO风格指标一般用积分法。mAP就是把所有类别的AP取平均。注意是每个类别各自算AP再对所有类别做平均而不是把所有类别的框混在一起算一个AP。这一点很多自写eval时非常容易踩坑。1.3 为什么不能只看训练loss不少刚入门的朋友会有一个误区觉得模型训练时loss一直在降效果应该不错。但loss是一个混合了分类、回归、角度多个分支的加权和它只代表优化方向上的形变不代表最终的“决策质量”。举个例子模型的回归loss可能在收敛但分类分数普遍偏低这会导致推理时阈值设稍高一点就漏检大量目标。或者航向角误差分布很大loss数值不大但3D IoU掉的厉害。评估函数的存在就是把训练后的模型变成一个可量化的、不同方法之间可比的结果。当你拿到get_official_eval_result输出的那一堆Easy/Moderate/Hard的AP值时其实是在回答三个问题我在简单场景下能找到多少目标在困难场景下还剩多少误检和漏检之间我平衡在哪里2. get_official_eval_result 深度拆解2.1 它到底输出了什么get_official_eval_result这个名字在很多开源3D检测框架中都能见到。它的核心风格来自KITTI官方的评测标准按类别输出AP而且每个类别还会按照Easy、Moderate、Hard三个难度等级分别统计。输出长什么样通常是这样一份表格类别DifficultyAP11点插值法CarEasy89.12CarModerate76.45CarHard65.32PedestrianEasy55.20PedestrianModerate44.80PedestrianHard38.10这里的 Easy/Moderate/Hard 并不是随便划分的。KITTI数据集中每个目标都带有遮挡程度Occlusion和截断程度Truncation属性。Easy 指高度大于一定像素、遮挡少、截断少的目标Moderate 和 Hard 依次放宽。这个划分直接影响AP结果所以你在网上下载不同模型时一定要对清楚是不是同一套难度划分标准否则数值完全没有可比性。2.2 输入数据的约定看一下函数的典型调用接口一般的代码会接受以下参数def get_official_eval_result(gt_annos, dt_annos, class_names, **kwargs): ...不同的框架实现细节略有差异但大体上要求gt_annos真实标注的列表每个元素包含name类别名、bbox2D框有时也有3D信息、dimensions长宽高、location位置xyz、rotation_y朝向角、occluded、truncated、alpha等字段。dt_annos预测结果的列表除了类别外通常还有score置信度和对应的3D框信息。class_names需要评估的类别名单例如[Car, Pedestrian, Cyclist]。注意一个细节get_official_eval_result内部常常会把“预测结果”里面的类别先过滤一遍只保留在class_names中出现的类别。如果你传入了一批模型不应该输出的未知类别的框这些框会被直接丢弃不参与TP/FP统计。这在某些场景下会掩盖误检问题后面写自己的eval时需要留意。2.3 核心执行流程匹配逻辑逐行拆解我在实际阅读这类代码时会把它理解成四个阶段。阶段一类别分离。把真实框和预测框分别按照类别拆成多个子集。评估某个类别的AP时只会在对应类别的框之间进行匹配。这种“逐类评估”直接保证了mAP不会出现类别间框数量不均衡导致的指标漂移。阶段二按置信度倒序排序。每个类别的预测框必须按照score从大到小排列。这是整个评估过程的“定海神针”。因为后续的PR曲线是以置信度阈值滑动的方式累积的如果不先排序后面的TP/FP累计逻辑就全乱了。阶段三贪心配对。对每个预测框寻找IoU超过规定阈值的真实框。同时维护一个“真实框已被匹配”的标记数组一旦某个真实框被一个预测框匹配过后续即使有更高IoU的预测框也不能再匹配它。这样保证一个真实目标只会被“命中”一次避免重复计数导致AP虚高。阶段四生成PR曲线点。从最高置信度到最低置信度不断累积TP和FP。每次累积后计算当前的Precision和Recall记录到数组里。最后计算AP。其中阶段三最容易出错。有些代码为了提高召回会在匹配时允许一个真实框被多个预测框“命中”但只把第一个作为TP后面的仍然计FP。这样做的问题在于模型只要对一个目标输出大量低置信度框就会导致召回归零AP被严重拉低。官方评估里采取的“一但对上就锁死GT”的策略是有道理的它衡量的是“模型能否精确输出一个框”而不是“模型能否输出一堆框覆盖目标”。2.4 为什么你的结果和论文里的数字对不上很多朋友在复现模型时都会遇到一个困惑我把模型跑通了但用get_official_eval_result算出来的AP要比论文里写的低好几个点。原因基本可以分成三类第一评估代码版本差异。不同开源库对KITTI官方指标的复现并不完全一致。有的库会在计算AP前对框做尺寸过滤有的会把高度信息折进匹配有的甚至改了难度划分方式。这些细节都会让数字偏移。第二坐标系预处理不同。KITTI的数据坐标系通常是相机坐标系也就是z轴朝前。但模型输出的3D框许多是LIDAR坐标系即x轴朝前。如果评估前没有做坐标变换IoU计算就会完全失准。我遇到过不止一次调试了半天最后发现是坐标轴没对齐。第三角度处理不同。KITTI标注里rotation_y是绕Y轴旋转的角度有的库会把它转换到[-π, π]有的则保持[0, 2π]。在计算IoU时角度不同步会导致重叠区域判断错误这种错误很难一眼察觉但在PR曲线上的体现就是AP整体偏低一个固定量。3. get_coco_eval_result 拆解3.1 它和official eval有什么本质差异get_coco_eval_result借用了2D检测中COCO指标的设计思路在3D检测中做适配。它和get_official_eval_result的核心区别在于评估策略IoU阈值不再是固定的单一值。COCO风格指标会在多个IoU阈值上分别算AP常见的是0.5、0.75甚至从0.5到0.95以0.05步长的十个阈值全部算一遍最后取平均得到AP[0.5:0.95]。而official eval通常只使用一个默认阈值在KITTI场景往往对2D框和BEV框分别处理。不区分难度等级。COCO风格指标不会按照遮挡、截断去划分Easy/Hard而是把所有样本混在一起计算。但更细粒度地描述了“框定得准不准”直接通过提高IoU阈值来度量。匹配策略上往往更严格。COCO评估里还包含了对重叠预测框的处理更加精细化。用生活化类比来说get_official_eval_result像一场分组赛按水平把选手分到不同组里每组只跟自己组的人比get_coco_eval_result像一场全能赛不分组但用更苛刻的尺子量每个人的综合能力。3.2 输出指标解读在普通代码中get_coco_eval_result的输出通常是一个字典包含以下常见键键名含义AP_0.5IoU阈值0.5下的平均精确率AP_0.75IoU阈值0.75下的精确率AP_allIoU从0.5到0.95等步长下的AP均值AR_0.5IoU阈值0.5下的平均召回率AR_all多IoU阈值下的召回率均值这种多阈值的做法有一个好处它能区分出“差不多能检出”和“非常精准地检出”两种模型。有些模型可能在AP_0.5上表现很好但AP_0.75明显下降说明它预测的回归精度不足。对于3D检测来说这个特性尤其重要因为自动驾驶、机器人抓取等场景对框的精度要求往往是厘米级的。3.3 在3D检测中采用COCO风格的合理性把COCO评估方法引入3D检测早期看起来有点“水土不服”但后来很多开源库都保留了这种实现原因其实很实在工业生产中需要一个跨数据集的统一评价口径。如果你在自动驾驶领域往往同时使用KITTI、nuScenes、Waymo等多个数据集做验证。每个数据集都有自己的评测协议这给横向比较带来了巨大成本。借助COCO风格的统一多IoU评估机制至少可以消除一部分因数据协议差异带来的指标偏差。哪怕不同的3D数据集的坐标体系和类别差异仍在但“同一个预测框到底能经得起多大IoU阈值挑战”这个维度是通用的。我个人的建议是模型对比时两种指标都看一下official eval帮你理解算法在特定数据分布上的表现趋势COCO风格让你快速判断回归精度的上限。4. 动手改写自己的eval函数4.1 第一步确定评估需求动手前先想清楚你的评估目标。我见过的常见需求有以下几种只看某一个类别的AP比如自动驾驶场景只关心Car的检测质量。按距离把目标分隔开分别评估近处和远处目标的检测效果。这往往比单一mAP更能反映模型的实际部署价值。需要把评估结果输出成JSON或CSV方便接入自动化测试流程。需要在端上运行一个精简版eval评估时不能依赖完整标注库需要自己手写核心逻辑。明确了需求接下来的代码结构才好设计。我个人习惯是先做一个“最小可用版”只支持单类别、单一IoU阈值验证逻辑正确再扩展成多类别、多阈值、带距离分桶的完整版本。4.2 第二步统一框的表示格式3D检测的框表示五花八门。有的是七维向量[x, y, z, dx, dy, dz, yaw]有的是八组角点坐标。为确保eval函数不被上游格式绑架建议进入评估函数前先把所有框统一到七维向量格式。def normalize_box(box): 约定输入box为[x, y, z, dx, dy, dz, yaw] x, y, z是物体中心坐标 dx, dy, dz分别是长、宽、高 yaw是绕Z轴的航向角单位弧度 if len(box) 8: # 八角点形式可以用PCA估计朝向并求最小外接框 corners box.reshape(4, 2) center corners.mean(axis0) # 这里做简化处理真实工程里要更精细 dx float((corners[:, 0].max() - corners[:, 0].min())) dy float((corners[:, 1].max() - corners[:, 1].min())) yaw 0.0 return np.array([center[0], center[1], 0.0, dx, dy, 0.0, yaw]) return np.asarray(box, dtypenp.float32)需要注意yaw的定义在不同坐标系里差异很大。LIDAR坐标系中绕Z轴正方向旋转通常是从X轴逆时针往Y轴转相机坐标系则不同。评估函数内部一定要统一否则同一个模型在不同代码库间结果差异巨大。4.3 第三步实现3D内外接矩形IoU三维IoU的实现有很多种。最常用的一种做法是“先算BEVIoU再乘上高度重叠比例”。计算BEV区域的交并比可以使用多边形裁剪算法比如Sutherland-Hodgman。如果你不排斥依赖库可以使用shapely简化实现如果想项目里轻量化可以自己写矩形旋转后的角点求交。下面给一个不依赖第三方库的简化版2D BEV IoU实现思路import numpy as np def poly_area(poly): # 简单多边形面积公式Shoelace公式 if len(poly) 3: return 0.0 x poly[:, 0] y poly[:, 1] return 0.5 * np.abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) def convex_intersection(p1, p2): # 这里省略多边形裁剪的完整实现 # 工程中可采用Sutherland-Hodgman逐边裁剪 pass def rotation_2d(angle): c np.cos(angle) s np.sin(angle) return np.array([[c, -s], [s, c]]) def box_corners(box): # box: [x, y, dx, dy, yaw] x, y, dx, dy, yaw box R rotation_2d(yaw) hx, hy dx / 2.0, dy / 2.0 local np.array([ [-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy] ]) world local R.T np.array([x, y]) return world核心逻辑就是把矩形的四个角点旋转平移到世界坐标做多边形裁剪求交集面积再算并集面积。如果你的预算紧张也可以取矩形AABB近似但这样会对倾斜角度大的框产生极大误差。3D检测里目标通常都有偏航角AABB近似并不可取。4.4 第四步实现匹配和TP/FP统计有了IoU函数匹配流程就可以封装成一个类。类的输入是预测框数组和真实框数组输出是TP/FP数组。def evaluate_single_class(pred_boxes, pred_scores, gt_boxes, iou_threshold0.5): num_pred len(pred_scores) num_gt len(gt_boxes) # 置信度降序索引 order np.argsort(-pred_scores) # 标记GT是否已被匹配 gt_matched np.zeros(num_gt, dtypebool) tp np.zeros(num_pred, dtypebool) fp np.zeros(num_pred, dtypebool) for i in range(num_pred): idx order[i] best_iou -1.0 best_gt -1 for j in range(num_gt): if gt_matched[j]: continue iou_val compute_3d_iou(pred_boxes[idx], gt_boxes[j]) if iou_val best_iou: best_iou iou_val best_gt j if best_iou iou_threshold: tp[idx] True gt_matched[best_gt] True else: fp[idx] True return tp, fp这段代码把两个关键细节体现出来了一是按置信度降序遍历二是每个GT只匹配一次。这两点同时满足PR曲线才是可信的。4.5 第五步计算AP并输出结果得到所有预测框的TP/FP标记后就可以自顶向下累计PR曲线。这里用面积法做AP计算贴近COCO风格。def compute_ap(tp, fp, num_gt): cum_tp np.cumsum(tp) cum_fp np.cumsum(fp) recall cum_tp / max(num_gt, 1) precision cum_tp / np.maximum(cum_tp cum_fp, 1e-10) # 在尾部补一个点保证积分区间到1 recall np.concatenate(([0.0], recall, [1.0])) precision np.concatenate(([1.0], precision, [0.0])) ap 0.0 for i in range(1, len(recall)): delta_r recall[i] - recall[i - 1] ap precision[i] * delta_r return ap需要注意的是如果num_gt为0说明这一帧没有任何真实目标。此时无论预测了什么最终AP应该处理成“无GT跳过本帧”而不是算出一个很奇怪的数值。这在实际评估中经常遇到空帧城市道路、完全没有目标出现的点云片段都很常见。4.6 第六步在外层封装多类别与多帧支持真实情况中eval是对整个测试集执行的而不是单帧。所以外层要构建一个函数遍历所有数据帧把某个类别在所有帧中的预测框和真实框拼到一起再做匹配。这种“跨帧统计”和“逐帧评估再平均”是两种不同的策略。KITTI官方评估其实是把整个测试集的GT和预测先按类别汇总再统一计算AP。OpenPCDet的official eval也遵循这个思路。def eval_all(gt_list, pred_list, class_names, iou_threshold0.5): result {} for class_name in class_names: all_pred_boxes [] all_pred_scores [] all_gt_boxes [] for gt, pred in zip(gt_list, pred_list): # 按类别过滤 gt_mask [name class_name for name in gt[name]] pred_mask [name class_name for name in pred[name]] for box in pred[boxes][pred_mask]: all_pred_boxes.append(box) for score in pred[scores][pred_mask]: all_pred_scores.append(score) for box in gt[boxes][gt_mask]: all_gt_boxes.append(box) all_pred_boxes np.array(all_pred_boxes) all_pred_scores np.array(all_pred_scores) all_gt_boxes np.array(all_gt_boxes) tp, fp evaluate_single_class( all_pred_boxes, all_pred_scores, all_gt_boxes, iou_threshold ) ap compute_ap(tp, fp, len(all_gt_boxes)) ar tp.sum() / max(len(all_gt_boxes), 1) result[class_name] {AP: ap, AR: ar, GT数量: len(all_gt_boxes)} result[mAP] np.mean([v[AP] for v in result.values()]) return result到这里一个基本可用的eval函数就成型了。你可以在它的基础上扩展距离分桶、每个样本独立导出、多IoU阈值平均等功能。5. 实际使用中的踩坑点与排查技巧5.1 角度的定义范围和符号方向这是所有自写eval里最容易出错的地方。很多3D框的yaw角有正负方向差异有的代码里顺时针为正有的逆时针为正。如果你简单地把两个不同来源的框丢进IoU计算里可能会导致全部匹配失败AP直接变0。排查方法很直接写一个单元测试把两个已知空间关系完全相同的框传入IoU函数看结果是否约等于1。如果偏差很大十有八九是角度定义不同。然后在normalize阶段做一个符号翻转或范围映射即可。5.2 一个预测框不能匹配多个GT如果漏掉“一个GT只能匹配一次”这个逻辑模型只要对同一个目标输出多个相似框AP就会被严重低估。因为多余的框会被当成FP而FP数量一多Precision就被拖垮PR曲线下面积当然变小。实际中我还见过更隐蔽的版本代码没有维护gt_matched数组而是反过来让每个GT匹配一个最高IoU的预测框。这样会造成同一个预测框同时命中多个GTTP数量被放大。这两种错误只要出现结果就像“假币”表面上数值合理实际完全不可信。5.3 空数组和越界空GT帧、空预测帧、类别没有GT但预测却有一堆框这些情况都要在代码开头做保护。许多初学者写匹配循环时不会想到len(gt_boxes) 0或len(pred_scores) 0的情况一旦遇到就会报错或产生除零。建议在evaluate_single_class函数最开始就加上判断if num_gt 0: return np.zeros(num_pred, dtypebool), np.ones(num_pred, dtypebool) if num_pred 0: return np.array([]), np.array([])前一种情况下所有预测都是FP后一种情况下直接返回空标记说明这一帧没有任何检测结果。5.4 负尺寸框和无效框过滤模型推理输出偶尔会出现dx、dy、dz为负数的异常框。如果没有预先过滤多边形面积计算会因为顶点顺序错乱产生负值IoU可能算成一个诡异的负数匹配结果自然全乱。建议在进入eval前做一次合法性检查把所有尺寸小于0.01米的框直接丢弃。valid (pred_boxes[:, 3] 0.01) (pred_boxes[:, 4] 0.01) (pred_boxes[:, 5] 0.01) pred_boxes pred_boxes[valid] pred_scores pred_scores[valid]这个过滤动作不要省略也别把这当成一种“作弊”。真实部署时下游模块根本不会接受物理上不可能的负尺寸框评估阶段提前清理只会让结果更贴近实际表现。5.5 不同软件库结果不一致的原因跨库对比评估结果时我见过差别最大的情况是两个库对“匹配成功”的定义不同。有的库使用BEV IoU作为匹配依据有的库使用3D IoU有的库在计算TP时还要求类别一致有的只按类别索引分开了自然不会出现类别不一致。更隐蔽的是有的库在输出AP前会先做一次“框分数过滤”比如把score低于0.1的框直接丢掉而其他库没有这个操作。阈值一低低置信度框带来大量FPAP自然下降。所以你在引用任何评估数字时文档里务必写明用了什么IoU阈值、按什么坐标系、有没有预先过滤低分框、难度等级是怎么划分的。这比模型本身更重要——数字没有上下文就没有意义。5.6 性能优化与工程化如果你在非常大的测试集上做评估比如数万帧点云Python写的朴素IoU计算会慢到无法接受。一个常见优化做法是把所有框转成8角点表示然后一次性向量化计算两两之间的交比。如果数据量进一步变大建议把单帧的候选取出来先做类别过滤和简单的位置粗筛只有中心距离在一定范围内的框才进入精细IoU计算这能省掉大量无效计算。实在需要极速评估时可以改成C实现或者用numba加速。对于一般实验场景上面提供的Python版本已经足够用了。写在最后的个人体会我最早接触这两个函数的时候其实也一样只是在拉取模型输出后顺手复制了官方示例代码。直到有一次需要把某个多模态融合模型的评估扩展到“近距离/远距离分别统计”才发现自己根本不理解里面每个步骤在干什么。后来自己手写了一遍eval从IoU到PR曲线再到AP积分才算彻底把3D检测的评估体系吃透。这个过程里踩过的最大的坑就是匹配顺序和重复匹配规则——如果你发现自写eval和官方结果总是差几个点先别怀疑实现细节回去看看是不是这两个基础逻辑错了。如果你也打算自己写eval我的建议是先做一个单类别、单阈值的最小版本拿一组已知数据验证AP数值正确再去扩展其他功能。先把地基打牢后面加再多的指标都顺手。这个理解过程虽然枯燥但真的值得。希望这篇文章能帮你少走一些弯路。
阅读完成 · 觉得有帮助?
咨询建站