mAP的计算原理
mAP的具体计算原理可以参看【1】,这里针对第三部分的代码实现进行了过程解释,也即从代码层面解释了如何计算mAP。
计算mAP的过程:
总过程:首先确定iou阈值,然后计算每一类的ap数值,最后计算所有类ap的均值,可得到在该iou阈值下的mAP,某类的ap值反映的是该类平衡精确率和召回率的能力。
大过程,计算ap的过程:首先得到某类的PR曲线,然后计算PR曲线下的面积就是ap值,关于ap值的计算分voc07和voc10两种办法,总面积受到最大recall的影响,最大recall肯定不到1,且计算面积时计算到最后一个recall值(非1)【2】
小过程,计算PR曲线:首先得到被识别为A类的所有bbox,然后按照得分将所有bbox从大到小排列,然后分别计算每个bbox与对应GT中A类的bbox的最大iou,定义两个一维向量tp和fp长度等于所有预测bbox的数量,从上到下(按照得分减小顺序)针对第i个bbox,如果与GT的iou大于设定阈值且该GT之前没被用过,则该bbox为被正确识别的bbox则tp[i]被设置为1,fp[i]被设置为0,如果与GT的iou小于设定阈值或该GT被用过,则该bbox为被错误识别的bbox,则tp[i]被设置为0,fp[i]被设置为1,通过np.cumsum()函数实现不同得分阈值下的P和R的计算,这里得分阈值是隐性的不可知的,由于事先对bbox列表按照得分从大到小进行了排列,且求和顺序从上到下,所以tp和fp从上到下累计求和,每一个求和值所对应的得分阈值单调递减,如此得到了fp和tp列表,计算不同隐性阈值下的precision就很容易了,而召回率,其是就是tp除以真实GT数量,分母是一个定值,所以计算mAP前一定要进行nms否则结果会很不好,如此便得到了从大到小的隐性阈值下所对应的P曲线和R曲线,这样也得到了PR对应关系,即PR曲线,这里需要注意,在PR曲线上,沿着R增大的方向,所对应的得分阈值并不是线性递减,是非线性,在第230行注释的代码可以实现绘制曲线图,横坐标是从上到小排列的点的行号或编号,纵坐标是得分,通过斜率可以看出不同得分段的数据量,越平缓的部分数据量越大。
注:某类的ap反映的是该类平衡正确率和召回率的能力,其受在0~1范围内正确和错误目标得分的分布影响,如果正确目标的得分全部集中在1附近,错误目标的得分全部集中在0附近,中间没有交集,那ap为1(前提召回率能到1),交集越多,ap值越低,所以如果有很多高分错误项,那ap肯定高不了,且每一类ap的计算方式不受总类别数量的影响,计算方式永远相同。
注:np.cumsum()函数的解释,np.cumsum([1,2,3,4]) = [1,3,6,10],从小到大累计求和,由于fp和tp从上到下对应的阈值单调递减,那使用该函数操作,等效于设定隐性阈值求P和R。
注:voc07计算方法是在R上均匀选取11个值,然后求对应P值的均值,voc10是先求P的单调递减包络面,然后积分求面积,但积分时横坐标的长度是从recall从0到最大recall值,一般这个值必然小于1,所以ap值也就是PR曲线下面积受到曲线变化趋势和最大recall的双重影响【2】
mAP的对比分析
当前为了评价一个检测网络,存在多种评价指标,分别为mAP,同等recall下比较precision,总的最大acc,总的正确检出目标数,总的正确检出非背景目标数。现在需要理一下这几个评价指标分别评价的是模型的什么能力,以及如何利用这些指标评测模型。
什么是mAP,mAP是每一个类ap的均值,具体mAP的计算方法可以看上一节,但是每一类ap其实反映的是该模型在该数据集上平衡recall和precision的能力,其数值为PR曲线下面的面积,这个面积的计算方法有两种,这个面积最终数值与最大recall有关系,实际使用时最大recall一定是小于1的,原因是一部分该类bbox被其他类错误的拿走了,而面积的计算是从recall为0一直计算到最后一个最大recall,所以最大recall会影响ap值进而影响mAP,但是有时候,我们根本不可能用到这么大的recall,那mAP的评价就与实际使用感受有区别了,所以有时候可以限制面积计算时的recall的范围,这样可能mAP反映的问题更能贴近实际感受,例如最大recall设置为相互比较的两个模型最大recall的最小值。此外,其实影响ap数值最大的因素是在识别为A类的所有bbox,然后得分从1排列到0后,正确bbox和错误bbox的分布情况,如果正确的bbox基本都是靠近1的,错误的bbox基本上都是靠近0的,且正确与错误bbox的得分没有交集,那么ap将尽可能接近1,如果有交集则ap将减小,交集越严重ap值越小。如果有模型具有很多的高分错误项,那其ap和mAP必然很低。mAP的计算方式是确定的,其不受分几类或类别是什么的影响。有些检测网络输出全是前景目标,有些检测网络输出除了前景还多了一个背景,这都不会影响到mAP的计算过程,无非针对前者mAP反映的是n个前景类别平衡P和R的能力,后者mAP反映的是n个前景类别+1个背景类别平衡P和R的能力。
什么是同等recall下比较precision,其实就是在业务要求的召回率下比较正确率,两个模型在相同数据下的每一类在相同recall下比较precision。这个当前是比较科学比较行得通的反映每一类检测能力的指标,单看mAP其实其跟实际使用时的感受或要求还是有区别。
什么是总的最大acc,就是该模型在该数据集下,所检索出的所有正确的bbox除以总的bbox数目,acc的计算必须针对该数据集里所有类别统一计算。需要尝试所有不同的得分阈值,以区别前景和背景,并在所得到的所有acc中选取最大值即为该模型在该数据集下的最大acc,然后比较acc才有意义,否则都是在不同标准下比较acc没有实际意义。该指标也是一个比较科学的反映整体类别检测性能的指标,但是由于业务方不好给予数据集,估计该指标不常用。
什么是总的正确检出目标数,就是该模型在该数据集下所能检测出的所有正确类别的bbox数目,其实由于数据集确定,总的数据量也就确定了,那该指标等效于总的acc。
什么是总的正确检出的非背景目标数,就是该模型在该数据集下所能检测出的所有正确类别的非背景的bbox数目。在当前应用下,其实就是两个模型,在相同数据集下,通过改变不同阈值所能检出的非背景的bbox的最大数目。
重点:不管哪种评价方式都需要指定模型指定数据集甚至有些还需要确定阈值,在这一系列条件下给出的,并不是单单根据模型给出的。且每种评价指标都是在不同维度上评价一个模型,所以完全可能存在冲突场景,即在这个指标下A模型好,在另一个指标下B模型好,所以要根据业务需求确定评价指标。比较不同模型的关键是确定指标,设置相同条件然后计算指标。
mAP的实现代码
下面为mAP的实现代码,为了计算mAP需要事先准备两个json文件,第一个json如下面第一图所示,下面图中是第一个json的第一行,下面是格式化后的样子,正常书写其实就是一行,代表第一幅图的标注结果,包含图片的地址,宽高和标注的bbox类别,数据集里当然是有多幅图片所以第一个json当然是多行,一行对应一幅图。第一个json对应GT,第二个json对应预测结果,也是一行对应一幅图,下面第二幅图展示了第二个json的第一行格式化后的结果。注意两个json都包含label_id这一属性,它的值必须用是>=0,且与label对应好。
GT的json:
{
"name":"/xxx/yyy/zzz.jpg",
"bbox_list":[
{
"bbox_xyxy":[
1391,
546,
1429,
635
],
"label_id":"5",
"label_name":"person"
},
{
"bbox_xyxy":[
924,
601,
962,
633
],
"label_id":"0",
"label_name":"car"
},
{
"bbox_xyxy":[
704,
594,
800,
666
],
"label_id":"0",
"label_name":"car"
}
],
"width":1920,
"height":1080
}
{
...
}
...
predict的json:
{
"name":"/xxx/yyy/zzz.jpg",
"width":1920,
"height":1080,
"pred_bbox_list":[
{
"bbox_xyxy":[
704,
594,
800,
663
],
"prob":0.9132,
"label_id":"0",
"label_name":"car",
},
{
"bbox_xyxy":[
924,
600,
964,
632
],
"prob":0.8464,
"label_id":"0",
"label_name":"car",
}
]
}
{
...
}
...
计算mAP:
import json
import os
import numpy as np
import matplotlib.pyplot as plt
def load_txt(src_path, df=None, one_list=False, one_line=False):
rs = []
with open(src_path, 'r') as txt:
for line in txt:
if one_line:
rs.append(line.strip())
else:
if df is None:
t = line.strip().split()
else:
t = line.strip().split(df)
if one_list or len(t) == 1:
rs.append(t[0])
else:
rs.append(t)
return rs
def load_line_json(src_path):
src_txt = load_txt(src_path, one_line=True)
src_txt = [json.loads(t) for t in src_txt]
return src_txt
def parse_rec(filename):
""" Parse a PASCAL VOC xml file """
tree = ET.parse(filename)
objects = []
for obj in tree.findall('object'):
obj_struct = {}
obj_struct['name'] = obj.find('name').text
obj_struct['pose'] = obj.find('pose').text
obj_struct['truncated'] = int(obj.find('truncated').text)
obj_struct['difficult'] = int(obj.find('difficult').text)
bbox = obj.find('bndbox')
obj_struct['bbox'] = [
int(bbox.find('xmin').text),
int(bbox.find('ymin').text),
int(bbox.find('xmax').text),
int(bbox.find('ymax').text)
]
objects.append(obj_struct)
return objects
def load_gt(inp):
name_dict = {}
imagenames = []
classnames_dict = {}
src_txt = load_line_json(inp)
for t in src_txt:
file_path = t["name"]
pic_name = os.path.basename(file_path)
imagenames.append(pic_name)
width = t["width"]
height = t["height"]
bbox_list = t["bbox_list"]
objects = []
for b in bbox_list:
obj_struct = {}
obj_struct["name"] = b["label_id"]
obj_struct['pose'] = "mid"
obj_struct['truncated'] = 0
obj_struct['difficult'] = 0
obj_struct['bbox'] = [int(t) for t in b["bbox_xyxy"]]
objects.append(obj_struct)
# classname dict
if b["label_id"] not in classnames_dict:
classnames_dict[b["label_id"]] = b["label_name"]
if pic_name not in name_dict:
name_dict[pic_name] = objects
else:
print(pic_name, "exists")
# print("load gt nums=", len(imagenames))
return imagenames, name_dict, classnames_dict
def load_pred(inp):
cls_dict = {}
imagenames = []
src_txt = load_line_json(inp)
for t in src_txt:
file_path = t["name"]
pic_name = os.path.basename(file_path)
imagenames.append(pic_name)
bbox_list = t["pred_bbox_list"]
for b in bbox_list:
tmp = [pic_name]
label_id = b["label_id"]
prob = float(b["prob"])
bbox = [float(t) for t in b["bbox_xyxy"]]
tmp.append(prob)
tmp.extend(bbox)
if label_id not in cls_dict:
cls_dict[label_id] = []
cls_dict[label_id].append(tmp)
# print("load gt nums=", len(imagenames))
return cls_dict
# 以及recall和precision,计算ap,其中recall和precision是一维向量
def voc_ap(rec, prec, use_07_metric=False):
# 如果使用voc07计算办法,那就是11 point metric,其是就是按照召回率等间隔挑选11个点,求这11个点的均值
if use_07_metric:
ap = 0.
for t in np.arange(0., 1.1, 0.1):
if np.sum(rec >= t) == 0:
p = 0
else:
p = np.max(prec[rec >= t])
ap = ap + p / 11.
# 如果使用voc11计算办法,那就是先寻找precision的包络面,然后计算面积,包络面保证单调递减
else:
# correct AP calculation
# first append sentinel values at the end
mrec = np.concatenate(([0.], rec, [1.]))
mpre = np.concatenate(([0.], prec, [0.]))
# compute the precision envelope
for i in range(mpre.size - 1, 0, -1):
mpre[i - 1] = np.maximum(mpre[i - 1], mpre[i])
# to calculate area under PR curve, look for points
# where X axis (recall) changes value
i = np.where(mrec[1:] != mrec[:-1])[0]
# and sum (\Delta recall) * prec
ap = np.sum((mrec[i + 1] - mrec[i]) * mpre[i + 1]) # 这样计算,其是召回率变化范围是从0到最大值,而不是到1,很多情况下召回率最大值远小于1
return ap
# 计算某iou阈值下某类的ap数值
def voc_eval(pred_path,
gt_path,
label_id,
iou_ovthresh=0.5,
use_07_metric=False,
use_diff=False):
### load gt
imagenames, recs, _ = load_gt(gt_path)
# extract gt objects for this class
class_recs = {}
npos = 0
for imagename in imagenames:
R = [obj for obj in recs[imagename] if obj['name'] == label_id] # 提取所有被识别为label_id类的bbox
bbox = np.array([x['bbox'] for x in R])
if use_diff:
difficult = np.array([False for x in R]).astype(np.bool)
else:
difficult = np.array([x['difficult'] for x in R]).astype(np.bool)
det = [False] * len(R)
npos = npos + sum(~difficult)
class_recs[imagename] = {
'bbox': bbox,
'difficult': difficult,
'det': det # 某GT的bbox是否被使用过,每个GT框只能被匹配一次
}
# load pred rs
pre_rs_dict = load_pred(pred_path)
splitlines = pre_rs_dict[label_id]
image_ids = [x[0] for x in splitlines]
confidence = np.array([float(x[1]) for x in splitlines])
BB = np.array([[float(z) for z in x[2:]] for x in splitlines])
nd = len(image_ids)
tp = np.zeros(nd)
fp = np.zeros(nd)
if BB.shape[0] > 0:
# sort by confidence
sorted_ind = np.argsort(-confidence)
sorted_scores = np.sort(-confidence)
BB = BB[sorted_ind, :] # 被识别为A类的bbox按照得分从大到小排列
image_ids = [image_ids[x] for x in sorted_ind]
# go down dets and mark TPs and FPs
for d in range(nd):
R = class_recs[image_ids[d]]
bb = BB[d, :].astype(float)
ovmax = -np.inf
BBGT = R['bbox'].astype(float)
if BBGT.size > 0:
# compute overlaps
# intersection
ixmin = np.maximum(BBGT[:, 0], bb[0])
iymin = np.maximum(BBGT[:, 1], bb[1])
ixmax = np.minimum(BBGT[:, 2], bb[2])
iymax = np.minimum(BBGT[:, 3], bb[3])
iw = np.maximum(ixmax - ixmin + 1., 0.)
ih = np.maximum(iymax - iymin + 1., 0.)
inters = iw * ih
# union
uni = ((bb[2] - bb[0] + 1.) * (bb[3] - bb[1] + 1.) +
(BBGT[:, 2] - BBGT[:, 0] + 1.) *
(BBGT[:, 3] - BBGT[:, 1] + 1.) - inters)
overlaps = inters / uni
ovmax = np.max(overlaps)
jmax = np.argmax(overlaps)
if ovmax > iou_ovthresh:
if not R['difficult'][jmax]:
if not R['det'][jmax]:
tp[d] = 1.
R['det'][jmax] = 1 # 每个GT的bbox只能被使用一次
else:
fp[d] = 1.
else:
fp[d] = 1.
# compute precision recall
fp = np.cumsum(fp) # 计算的fp是向量的原因在于np.cumsum()函数
tp = np.cumsum(tp) # 计算的tp是向量的原因在于np.cumsum()函数
rec = tp / float(npos) # 计算召回率,直接是正确识别为A类的bbox数量除以总的A类bbox的数量
# avoid divide by zero in case the first detection matches a difficult
# ground truth
prec = tp / np.maximum(tp + fp, np.finfo(np.float64).eps)
ap = voc_ap(rec, prec, use_07_metric)
# if iou_ovthresh == 0.5:
# plt.figure(figsize=(9,12))
# plt.plot(sorted_scores, 'r-')
# plt.savefig("./scores_%s.png" % str(label_id), format='png')
return rec.tolist(), prec.tolist(), round(float(ap), 3)
def cal_voc_mAP(pred_path, gt_path):
_, _, classnames_dict = load_gt(gt_path)
label_id_list = sorted(list(classnames_dict.keys()))
iou_list = [round(t * 0.01, 2) for t in range(50, 100, 5)]
rs = []
ap5 = []
ap95 = []
for label_id in label_id_list:
label_name = classnames_dict[label_id]
ap_list = []
for iou in iou_list:
rec, prec, ap = voc_eval(pred_path=pred_path,
gt_path=gt_path,
label_id=label_id,
iou_ovthresh=iou,
use_diff=True)
ap_list.append(ap)
rs.append([
label_id, label_name, ap_list[0],
round(float(np.mean(ap_list)), 3)
])
ap5.append(ap_list[0])
ap95.append(float(np.mean(ap_list)))
mAP_rs = [
"-", "All class",
round(float(np.mean(ap5)), 3),
round(float(np.mean(ap95)), 3)
]
rs.insert(0, mAP_rs)
for t in rs:
print("{} {:<12s} AP@0.5 = {:.3f} mAP@0.5:0.95 = {:.3f}".format(
t[0], t[1], t[2], t[3]))
## print rs
# label_id, label_name, AP@0.5, mAP@0.5:0.95
# - All class AP@0.5 = 0.732 mAP@0.5:0.95 = 0.430
# 0 vehicle AP@0.5 = 0.870 mAP@0.5:0.95 = 0.577
# 1 pedestrian AP@0.5 = 0.673 mAP@0.5:0.95 = 0.326
# 2 cyclist AP@0.5 = 0.652 mAP@0.5:0.95 = 0.387
if __name__ == "__main__":
pred_path = "mAP_result_list.json"
gt_path = "mAP_GT_list.json"
cal_voc_mAP(pred_path=pred_path, gt_path=gt_path)