078、AP 手动计算脚本:从 Prediction JSON 到 101-point Interpolation mAP
2026/6/11 3:29:56 网站建设 项目流程

078、AP 手动计算脚本:从 Prediction JSON 到 101-point Interpolation mAP

一、一个让我熬夜到凌晨三点的 bug

去年做某个工业检测项目,模型在验证集上 mAP 显示 0.85,客户现场实测却频频漏检。我盯着 tensorboard 上的曲线看了两个小时,突然意识到一个可怕的事实——我用的评估脚本在计算 AP 时,对 recall 的采样点只有 11 个。更致命的是,那个脚本把 confidence 阈值设成了 0.5 才开始计算,导致大量低置信度的正确检测被直接丢弃。

从那天起,我决定手写一套完整的 mAP 计算脚本,用 101-point interpolation 替代粗糙的 11-point,并且把每一步的中间结果都打印出来,方便调试。今天就把这套脚本的核心逻辑拆开揉碎了讲给你听。

二、输入数据长什么样

先明确我们手里有什么。假设你已经跑完推理,得到了一个 prediction.json,格式大概是:

[{"image_id":"img_001","bbox":[100,200,50,80],# x1, y1, w, h"score":0.92,"category_id":1},...]

同时你还有一个 ground_truth.json,格式类似,但多了一个"ignore"字段(用于标记 crowd 区域或难例)。这里有个坑:bbox 的格式必须统一。YOLO 输出的是归一化的 cx,cy,w,h,但 COCO 格式要求的是 x1,y1,w,h。如果你直接拿 YOLO 的输出去算 mAP,结果会完全不对。我习惯在推理脚本里就转成 COCO 格式,省得后面反复调试。

三、核心逻辑:从零构建 AP 计算管线

1. 数据预处理:把 JSON 变成可计算的表格

第一步,把两个 JSON 文件读进来,按 image_id 和 category_id 建立索引。这里我踩过一个坑:同一个图片里可能有多个相同类别的目标,必须用列表存储,不能直接用 dict 覆盖

defload_annotations(gt_json,pred_json):# 别这样写:gt_dict = {ann['image_id']: ann for ann in gt_json}# 同一个 image_id 可能有多个标注gt_dict={}foranningt_json:img_id=ann['image_id']ifimg_idnotingt_dict:gt_dict[img_id]=[]gt_dict[img_id].append(ann)# 预测结果同理pred_dict={}foranninpred_json:img_id=ann['image_id']ifimg_idnotinpred_dict:pred_dict[img_id]=[]pred_dict[img_id].append(ann)returngt_dict,pred_dict

2. 按类别计算:每个类别独立处理

mAP 的全称是 mean Average Precision,先算每个类别的 AP,再取平均。所以我们需要按 category_id 分组。这里有个细节:只计算那些在 ground truth 中出现的类别。如果某个类别在预测结果里有,但 ground truth 里没有,直接跳过,否则会拉低 mAP。

defcompute_ap_per_class(gt_dict,pred_dict,category_id,iou_threshold=0.5):# 收集该类别下所有图片的 GT 和预测gt_boxes=[]# 每个元素是 (image_id, bbox, ignore_flag)pred_boxes=[]# 每个元素是 (image_id, bbox, score)forimg_idingt_dict:foranningt_dict[img_id]:ifann['category_id']==category_id:gt_boxes.append((img_id,ann['bbox'],ann.get('ignore',False)))forimg_idinpred_dict:foranninpred_dict[img_id]:ifann['category_id']==category_id:pred_boxes.append((img_id,ann['bbox'],ann['score']))iflen(gt_boxes)==0:returnNone# 没有 GT 的类别不参与计算# 按 score 降序排列预测框pred_boxes.sort(key=lambdax:x[2],reverse=True)# 接下来就是核心的匹配和 AP 计算...

3. 匹配逻辑:IoU 计算与分配

这是整个脚本最绕的部分。对于每个预测框,我们要找到它匹配的 GT 框。匹配规则是:同一个图片内,IoU 最大的 GT 框,且 IoU 必须大于阈值。如果多个预测框匹配到同一个 GT 框,只有 score 最高的那个算 True Positive,其余算 False Positive。

这里有个容易忽略的点:ignore 标记的 GT 框不参与 AP 计算,但会影响匹配。比如一个预测框匹配到了 ignore 的 GT 框,这个预测框既不算 TP 也不算 FP,直接跳过。这在处理 crowd 场景时非常关键。

defmatch_predictions(pred_boxes,gt_boxes,iou_threshold):# 按图片分组处理img_ids=set([p[0]forpinpred_boxes]+[g[0]forgingt_boxes])tp=[]# 每个预测框的 TP/FP 标记fp=[]score=[]forimg_idinimg_ids:img_preds=[pforpinpred_boxesifp[0]==img_id]img_gts=[gforgingt_boxesifg[0]==img_id]# 记录每个 GT 框是否已被匹配gt_matched=[False]*len(img_gts)forpredinimg_preds:score.append(pred[2])max_iou=0max_gt_idx=-1forgt_idx,gtinenumerate(img_gts):iou=compute_iou(pred[1],gt[1])ifiou>max_iou:max_iou=iou max_gt_idx=gt_idxifmax_iou>=iou_threshold:# 检查匹配到的 GT 是否是 ignoreifimg_gts[max_gt_idx][2]:# ignore flagtp.append(0)fp.append(0)# 既不算 TP 也不算 FPelifnotgt_matched[max_gt_idx]:tp.append(1)fp.append(0)gt_matched[max_gt_idx]=Trueelse:tp.append(0)fp.append(1)# 重复匹配,算 FPelse:tp.append(0)fp.append(1)returntp,fp,score

4. 101-point Interpolation:这才是精髓

传统的 11-point 方法只在 recall 的 11 个等分点(0, 0.1, 0.2, …, 1.0)上采样 precision,然后取平均。但这样会丢失很多细节,尤其是当 recall 曲线不平滑时。101-point 方法在 0 到 1 之间均匀取 101 个点(步长 0.01),每个点取该 recall 值右侧的最大 precision。

defcompute_ap_101point(tp,fp,score,num_gt):# 先按 score 排序,计算累计 TP 和 FPsorted_indices=np.argsort(score)[::-1]tp_cum=np.cumsum(np.array(tp)[sorted_indices])fp_cum=np.cumsum(np.array(fp)[sorted_indices])# 计算 precision 和 recallprecision=tp_cum/(tp_cum+fp_cum+1e-10)recall=tp_cum/num_gt# 101-point interpolationap=0.0forrinnp.linspace(0,1,101):# 找到所有 recall >= r 的点,取最大 precisionmask=recall>=rifnp.any(mask):p=np.max(precision[mask])else:p=0.0ap+=p ap/=101returnap

这里有个细节:num_gt 是当前类别下所有非 ignore 的 GT 框数量。ignore 的 GT 框不参与 recall 计算,否则 recall 会被低估。

四、调试经验:那些年我踩过的坑

坑1:IoU 计算中的坐标转换

YOLO 的 bbox 格式是 [cx, cy, w, h],COCO 是 [x1, y1, w, h]。如果你直接拿 YOLO 的输出去算 IoU,结果会完全错误。我习惯在推理脚本里就转成 COCO 格式:

defyolo_to_coco(bbox,img_w,img_h):cx,cy,w,h=bbox x1=(cx-w/2)*img_w y1=(cy-h/2)*img_h w=w*img_w h=h*img_hreturn[x1,y1,w,h]

坑2:score 排序的稳定性

当两个预测框的 score 完全相同时,排序顺序会影响 TP/FP 分配。虽然概率很小,但为了可复现性,建议在排序时加入第二个关键字(比如 bbox 的坐标)。

pred_boxes.sort(key=lambdax:(x[2],x[1][0],x[1][1]),reverse=True)

坑3:ignore 标记的处理

很多开源代码直接忽略 ignore 的 GT 框,但这样会导致 recall 计算不准确。正确的做法是:ignore 的 GT 框不参与 recall 分母,但匹配到 ignore 的预测框也不计入 TP/FP。这样既不会惩罚模型在 crowd 区域的检测,也不会因为忽略这些区域而虚高 recall。

五、完整脚本的骨架

把上面所有逻辑串起来,一个完整的 mAP 计算脚本大概长这样:

defcompute_map(gt_json,pred_json,iou_threshold=0.5):gt_dict,pred_dict=load_annotations(gt_json,pred_json)# 获取所有类别categories=set()forimg_idingt_dict:foranningt_dict[img_id]:categories.add(ann['category_id'])aps=[]forcat_idincategories:ap=compute_ap_per_class(gt_dict,pred_dict,cat_id,iou_threshold)ifapisnotNone:aps.append(ap)mAP=np.mean(aps)ifapselse0.0returnmAP,aps

六、个人经验性建议

  1. 永远不要相信第三方库的默认实现。我见过太多人直接用 pycocotools 的 cocoeval,结果发现自己的数据格式不对,或者 ignore 标记没处理好。建议至少手写一次完整的计算流程,理解每一步在做什么。

  2. 把中间结果打印出来。在调试阶段,我会把每个类别的 precision-recall 曲线数据保存成 CSV,然后用 Excel 画图。这样能直观地看到模型在哪个 recall 区间表现差,是漏检多还是误检多。

  3. 101-point 不是银弹。如果你的数据集很小(比如每个类别只有几十个 GT),101-point 和 11-point 的差异其实不大。但对于大规模数据集(比如 COCO),101-point 能更准确地反映模型性能。

  4. 注意 IoU 阈值的选择。mAP@0.5 和 mAP@0.5:0.95 反映的是不同层面的性能。如果你的任务对定位精度要求高(比如自动驾驶),建议关注高 IoU 阈值下的 AP。如果只是做目标存在性检测,mAP@0.5 就足够了。

  5. 最后,也是最重要的mAP 只是一个指标,不是真理。我曾经见过一个模型 mAP 很高,但在实际场景中频繁漏检小目标。原因很简单:小目标的 IoU 计算对坐标偏差非常敏感,而 mAP 对大小目标是一视同仁的。所以,永远要结合具体业务场景来分析指标。

这套脚本我用了两年,从最初的 50 行到现在的 300 行,每一步都是踩坑踩出来的。希望你能少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询