1、前言
在目標檢測中我們常常使用AP(Average Precision)作為模型對某種目標精測精度的評價指標,該如何計算AP呢?
AP就是P—R曲線下的面積,我們需要做的就是根據不同的置信度閾值(p_threshold),計算出這模型得到的預測框的(R,P),然后作出P—R曲線,並求解面積,就能得到目標檢測模型對該檢測種類的AP。(在VOC2010之后,計算AP需要對做出來的P—R曲線做一個平滑,之后會提到)
和分類模型計算P(精確度)R(召回度)不一樣的是(分類模型計算P,R可以參看我這篇博文AUC、精確率和召回率),在目標檢測中我們是沒法知道我們模型出來的預測框的真實值是什么的(甚至標記的真實框的個數和預測框的個數都是不匹配的),該怎樣計算P和R呢?其實計算P,R最重要的指標只有3個,分別是:
TP | 真實值為正——>預測值為正 |
FP | 真實值為負——>預測值為正 |
FN | 真實值為正——>預測值為負 |
因此在目標檢測中我們只需要計算出以上三個指標就可以了。
2、例子
我們直接用例子來進行解釋,假設我們有5個GT(Ground_Truth就是目標檢測中人工標注的對象的矩形框,可以認為是真實物體的標簽,下面用BB1—BB5來表示),我們模型對該種識別種類返回了10個預測框,接下來我們計算該種類的AP:
1、根據預測框計算與5個GT的IOU,如果IOU大於iou_threshold(IOU閾值,不要和前面的p_threshold弄混了)則標記為1,否則標記為0。比如我們預測出來10個框,根據iou_threshold可以得到這些框的IOU結果分別為(1,0,1,0,1,1,1,0,0,1),還有這些框的置信度分別為[0.9,0.85,0.7,0.6,0.45,0.25,0.2,0.15,0.13,0.12](需要對框從小到大排序),並且我們還知道這些預測框和哪一個GT在做IOU,[BB1,BB2,BB1,BB2,BB3,BB4,BB3,BB1,BB4,BB5],如果做一個表來表示結果應該如下圖:
編號 | GT | 置信度 | IOU結果 |
1 | BB1 | 0.9 | 1 |
2 | BB2 | 0.85 | 0 |
3 | BB1 | 0.7 | 1 |
4 | BB2 | 0.6 | 0 |
5 | BB3 | 0.45 | 1 |
6 | BB4 | 0.25 | 1 |
7 | BB3 | 0.2 | 1 |
8 | BB1 | 0.15 | 0 |
9 | BB4 | 0.13 | 0 |
10 | BB5 | 0.12 | 1 |
2、要注意雖然預測出來有10個框,但是我們實際上只有5個GT(也就是只有5個物體),換句話說其實你預測的10個框里可能有對同一個GT的重復預測(可能有幾個預測框都和同一個GT滿足IOU>iou_threshold,比如1號框和3號框都是同時對BB1的預測)。
3、接下來我們就是需要根據不同的p_threshold(置信度閾值),計算出這10個預測框的(R,P)點,然后作出P——R曲線,並求解面積。問題就在於如何求對應p_threshold下的P和R,下面我以p_threshold=0.6為例,計算上面表格中10個框的(R,P)具體做法。
- 由於編號1,2,3,4的預測框的置信度都大於等於p_threshold,因此我同意他們的IOU結果(即認為編號1預測框的標簽是BB1的准確框,編號2不是BB2的准確框,編號3也是BB1的准確框,編號4不是BB2的准確框),並且我認為前面四個框(編號1-4)都預測為正例。
- 其余的預測框我都不認同他們的IOU結果(即我不認為編號5的預測框是BB3的准確框,我也不認為編號8不是BB1的准確框),並且我仍然把剩下的6個框都預測為負例。
- 由上面兩種看法,我們就可以得到當p_threshold=0.6時:這樣我們就可以算出P=TP/(TP+FP),R=TP/(TP+FN)
- TP = 1(只有編號1是正確預測,編號3雖然也是正確預測但是屬於重復預測了歸納到FP中,誰讓它置信度小呢)
- FP = 3(因為編號2的IOU結果表示它為負類,但是我預測為正類,因此編號2是FP,同理編號4也是。編號3是認為重復預測了,因為編號1已經正確預測了BB1)
- FN = 4(這個指標可以直接由GT個數-TP個數得到,因為你可以理解為真實的有5個正例,但是你只預測對了1個(TP=1),所以剩下的框都預測錯了,因此FN=GT-TP)
上面的例子說完其實可能有疑問說為什么不取分析編號5-10的框呢?置信度小於我設定的閾值p_threshold,我沒有辦法認同他們的IOU結果因此我不知道他們的IOU結果是對的還是錯的,因此我們並不能也沒有必要去分析他們。(因為我們並不在意TN是多少)
另一個疑問是,為什么你要把置信度大於p_threshold的框都預測為正例,其余的是負例,難道不是應該尊重IOU結果么?我的想法是模型既然輸出的這些預測框,那么肯定是模型認為這些預測框都是目標框,但是置信度小於了我設定的p_threshold自然是應該認為模型識別為負例了。
其實,以上的看法都是我我自己根據網上博文得到的結果的理解,並且計算之后發現這一套邏輯是符合最后結果的,為了方便大家及以和自己去理解這個指標。
3、AP的計算
通過上面的例子,我們知道不同的p_threshold可以計算得出不同的(R,P),由這些點可以畫出圖像(這個圖像就是P—R圖像),我們可以知道P會隨着R上升而下降,根據VOC2010的計算公式,我們需要對P—R曲線做一個平滑,具體的方式很簡單。就是每一個點的R1所對應的P1調整為$P1 = \max\limits_{R>R1}P(R)$。用圖像來表示就是從圖一平滑成圖二。
圖一
圖二
根據平滑后的P—R圖像的結果我們就可以很輕松的計算面積了(其實就是矩形求面積)。
4、mAP的計算
對於mAP(mean of Average Precision)的計算,其實就是在目標檢測中我們可能是在檢測多個目標,因此我們對每種類型的目標都可以計算出這個種類的AP,最后再對各個種類進行求平均就可以了。
5、代碼及解釋
根據上面的分析我們可以知道,對我們在對P-R曲線做光滑的時候其實就是在保證P隨着R的增加應該是單調不增的函數,如何得到呢?我們可以對precision列表(根據recall從小到大對應排序)采用從末尾到首端不斷取最大值就可以了。(也就是完整代碼代碼的第48行—第53行)
其實我們還可以發現,我們並不需要對所有的p_threshold從0到1的所有取值去得到(R,P)點,因為只有當p_threshold大於某個框的置信度的時候,P和R才會改變,因此我們算(R,P)點只需要計算p_threshold等於各個框的置信度的時候就可以了。(並且我們會發現隨着p_threshold的增加,racall是一定增加的。)
在完整代碼的第48行到第57行我們求面積的時候運用了錯位的方式巧妙的找到了precision突變的那些位置,為了更好的展示這部分的工作,我虛構了一些precision和recall來讓大家更好的理解(可以結合圖二來理解,數據不同)。代碼如下:
mpre = np.array([0.,0.98,0.88,0.88,0.25,0.3,0.32,0.21,0.33,0.1,0.]) for i in range(mpre.size - 1, 0, -1): mpre[i - 1] = np.maximum(mpre[i - 1], mpre[i]) print(mpre)
[0.98 0.98 0.88 0.88 0.33 0.33 0.33 0.33 0.33 0.1 0. ]
mrec = np.array([0.,0.12,0.12,0.56,0.56,0.56,0.75,0.75,0.82,0.93,1.0]) i = np.where(mrec[1:] != mrec[:-1])[0] print(i) print('mrec[i + 1]:',mrec[i + 1]) print("mrec[i]:",mrec[i]) print("mpre[i + 1]:",mpre[i + 1])
[0 2 5 7 8 9] mrec[i + 1]: [0.12 0.56 0.75 0.82 0.93 1. ] mrec[i]: [0. 0.12 0.56 0.75 0.82 0.93] mpre[i + 1]: [0.98 0.88 0.33 0.33 0.1 0. ]
上面的示例可以看出來完整代碼的第57行到第60行是可以完全符合我們求取矩形面積框的需求的。
因為代碼是基於VOC數據集編寫的,所以我們最好了解一下VOC數據集的XML文件格式:
<annotation> <folder>VOC2012</folder> <filename>2007_000392.jpg</filename> //文件名 <source> //圖像來源(不重要) <database>The VOC2007 Database</database> <annotation>PASCAL VOC2007</annotation> <image>flickr</image> </source> <size> //圖像尺寸(長寬以及通道數) <width>500</width> <height>332</height> <depth>3</depth> </size> <segmented>1</segmented> //是否用於分割(在圖像物體識別中01無所謂) <object> //檢測到的物體 <name>horse</name> //物體類別 <pose>Right</pose> //拍攝角度 <truncated>0</truncated> //是否被截斷(0表示完整) <difficult>0</difficult> //目標是否難以識別(0表示容易識別) <bndbox> //bounding-box(包含左下角和右上角xy坐標) <xmin>100</xmin> <ymin>96</ymin> <xmax>355</xmax> <ymax>324</ymax> </bndbox> </object> <object> //檢測到多個物體 <name>person</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>198</xmin> <ymin>58</ymin> <xmax>286</xmax> <ymax>197</ymax> </bndbox> </object> </annotation>
完整代碼如下:
import numpy as np import os import pickle import xml.etree.ElementTree as ET def parse_rec(filename): """ 解析PASCAL VOC xml文件 return:[{'name': xxx, 'bbox': [xmin, ymin, xmax, ymax]},{},....] """ 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 voc_ap(rec, prec, use_07_metric=False): """ 給定recall和precision之后計算返回AP,其中recall是從小到大排序,precision每一個元素是對應排序后的recall的值 rec: np.array prec: np.array use_07_metric:如果為True則采用07年的方式計算AP """ if use_07_metric: # VOC在2010之后換了評價方法,所以決定是否用07年的 ap = 0. for t in np.arange(0., 1.1, 0.1): # 07年的采用11個點平分recall來計算 if np.sum(rec >= t) == 0: p = 0 else: p = np.max(prec[rec >= t]) # 取一個recall閾值t之后最大的precision ap = ap + p / 11. # 將11個precision加和平均 else: # 這里是使用VOC2010年后的方法求mAP,計算光滑后PR曲線的面積,不再是固定的11個點 # 在rec的首和尾添加值來完成更好完成 # 在prec的尾部添加0為了更好得到“光滑”后的prec的值 mrec = np.concatenate(([0.], rec, [1.])) # recall和precision前后分別加了一個值,因為recall最后是1,所以 mpre = np.concatenate(([0.], prec, [0.])) # 右邊加了1,precision加的是0 # 調整mpre,從后往前取最大值,保證prec單調不增。 for i in range(mpre.size - 1, 0, -1): mpre[i - 1] = np.maximum(mpre[i - 1], mpre[i]) # 計算PR曲線下的面積 # X軸為R(recall的值) i = np.where(mrec[1:] != mrec[:-1])[0] # 返回了所有改變了recall的點的位置 # 求每個矩形的面積和 # 具體理解見前文解釋 ap = np.sum((mrec[i + 1] - mrec[i]) * mpre[i + 1]) return ap # 計算每個類別對應的AP,mAP是所有類別AP的平均值 # 主要是處理得到rec, prec數組 def voc_eval(detpath, annopath, imagesetfile, classname, cachedir, ovthresh=0.5, use_07_metric=False): """ return:rec, prec, ap Top level function that does the PASCAL VOC evaluation. detpath: 檢測結果的文件路徑。檢測結果文件的每一行應該是:img_ID,置信度,xmin, ymin, xmax, ymax detpath應該是這樣的字符串'./results/comp4_det_test_{}.txt' detpath.format(classname) should produce the detection results file. annopath: Path to annotations annopath應該是這樣的字符串"dataset/voc/VOC2007/Annotations/{}.xml" annopath.format(imagename) should be the xml annotations file. imagesetfile: 儲存了圖片名的text,每一行是一張圖片的名。'dataset/voc/VOC2007/ImageSets/Main/test.txt' classname: 類別名 cachedir: 用於存儲注解(annotations)的路徑,生成一個pickle_file [ovthresh]: IOU_threshold (default = 0.5) [use_07_metric]: 是否使用07年的計算ap的方式(default False) """ # 第一步獲得各圖片的GT # 如果不存在注釋路徑的文件夾,則先創建文件夾 if not os.path.isdir(cachedir): os.mkdir(cachedir) cachefile = os.path.join(cachedir, 'annoys.pkl') # 讀取圖片名,並儲存在列表中 with open(imagesetfile, 'r') as f: lines = f.readlines() imagenames = [x.strip() for x in lines] if not os.path.isfile(cachefile): # 如果沒有現成的解析好的圖片GT數據則需要從xml文件中解析出來,並保存成pkl文件 # 這里提取的是所有測試圖片中的所有object gt信息, 07年的test真實標注是可獲得的,12年就沒有了 recs = {} for i, imagename in enumerate(imagenames): recs[imagename] = parse_rec(annopath.format(imagename)) # 獲取圖片中對應的GT的解析 if i % 100 == 0: print('Reading annotation for {:d}/{:d}'.format( i + 1, len(imagenames))) # save print('Saving cached annotations to {:s}'.format(cachefile)) with open(cachefile, 'wb') as f: pickle.dump(recs, f) else: # 如果有現成圖片GT數據,則直接讀取 with open(cachefile, 'rb') as f: recs = pickle.load(f) # 從上面的recs中提取出我們要判斷的那類目標的標注信息(GT) class_recs = {} npos = 0 for imagename in imagenames: R = [obj for obj in recs[imagename] if obj['name'] == classname] # [obj,obj,....] 每個obj={'name': xxx, 'bbox': [xmin, ymin, xmax, ymax]} bbox = np.array([x['bbox'] for x in R]) # 二維數組,(number_obj,4),該張圖片有number_obj個類別為classname的目標框 difficult = np.array([x['difficult'] for x in R]).astype(np.bool) det = [False] * len(R) # 該圖片中該類別對應的所有bbox的是否已被匹配的標志位 npos = npos + sum(~difficult) # 累計所有圖片中的該類別目標的GT總數,不算diffcult class_recs[imagename] = {'bbox': bbox, 'difficult': difficult, 'det': det} # 第二步讀取模型識別的結果 detfile = detpath.format(classname) # 讀取相應類別的檢測結果文件,每一行對應一個檢測目標 with open(detfile, 'r') as f: lines = f.readlines() # 讀取所有行 splitlines = [x.strip().split(' ') for x in lines] # 處理為[[image_id, 置信度, xmin, ymin, xmax, ymax ],[]...] 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]) # 二維數組,(number_bbox,4) # sort by confidence 按置信度由大到小排序 sorted_ind = np.argsort(-confidence) # 獲得Indx # sorted_scores = np.sort(-confidence) BB = BB[sorted_ind, :] # 對BB重排序 image_ids = [image_ids[x] for x in sorted_ind] # 對image_ids重排序 # 記下dets並對每個image打上標注是TP還是FP nd = len(image_ids) # 檢測結果文件的行數 tp = np.zeros(nd) # 用於標記每個檢測結果是tp還是fp fp = np.zeros(nd) for d in range(nd): # 取出該條檢測結果所屬圖片中的所有ground truth R = class_recs[image_ids[d]] # 其實image_id就是image_name,R={'bbox': bbox(二維數組),'difficult': difficult,'det': [bool]} bb = BB[d, :].astype(float) # bb一維數組 ovmax = -np.inf BBGT = R['bbox'].astype(float) # 二維數組 if BBGT.size > 0: # compute overlaps 計算與該圖片中所有ground truth的最大重疊度(IOU) # 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 # 一維 # 重疊部分面積一維 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 # 計算得到檢測結果的這個框與該張圖片的所有該類比的GT的IOU,一維 ovmax = np.max(overlaps) jmax = np.argmax(overlaps) # 這里就是具體的分配TP和FP的規則了 if ovmax > ovthresh: # 如果最大的重疊度大於一定的閾值(IOU_threshold) if not R['difficult'][jmax]: # 如果最大重疊度對應的ground truth為difficult就忽略,因為上面npos就沒算 if not R['det'][jmax]: # 如果對應的最大重疊度的ground truth以前沒被匹配過則匹配成功,即tp tp[d] = 1. R['det'][jmax] = 1 # 表示框被匹配過了 else: # 若之前有置信度更高的檢測結果匹配過這個ground truth,則此次檢測結果為fp fp[d] = 1. else: # 該圖片中沒有對應類別的目標ground truth或者與所有ground truth重疊度都小於閾值 fp[d] = 1. # 計算 precision recall fp = np.cumsum(fp) # 累加函數np.cumsum([1, 2, 3, 4]) -> [1, 3, 6, 10] tp = np.cumsum(tp) rec = tp / float(npos) # tp/GT,也就得到了voc_ap函數所需要的rec了 # avoid divide by zero in case the first detection matches a difficult # 避免除以零 prec = tp / np.maximum(tp + fp, np.finfo(np.float64).eps) ap = voc_ap(rec, prec, use_07_metric) return rec, prec, ap
下面給出函數voc_eval的具體輸入格式以及需要的各個文件的格式:
detpath = '/home/g4/桌面/detection_result_{}.txt' annopath = '/home/g4/桌面/labels/{}.xml' imagesetfile = '/home/g4/imagesetfile.txt' classname = '煙霧' cachedir = '.' rec, prec, ap = voc_eval(detpath,annopath,imagesetfile,classname,cachedir) ap
其中detection_result_煙霧.txt長這樣:
imagesetfile.txt內容是:
參考網址:
https://zhuanlan.zhihu.com/p/56961620
AP,mAP計算詳解(代碼全解) - 知乎 (zhihu.com)
(6條消息) Pascal VOC中mAP的計算_laizi_laizi的博客-CSDN博客
https://blog.csdn.net/andeyeluguo/article/details/89361013
https://www.cnblogs.com/blog4ljy/p/9195752.html