目标检测AP计算 voc_eval.py代码全面解析

自己解析的voc_eval.py,对于目标检测测评指标只懂公式还是不够的,来看看代码吧。

----------------------------逐句解析----------------------------

# --------------------------------------------------------
# Fast/er R-CNN
# Licensed under The MIT License [see LICENSE for details]
# Written by Bharath Hariharan
# --------------------------------------------------------
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import xml.etree.ElementTree as ET
import os
import pickle
import numpy as np

def parse_rec(filename):#这个代码负责解析xml里面的标签,主要就是读取xml中关键内容,然后保存下来。
  """ 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 voc_ap(rec, prec, use_07_metric=False):#计算AP的函数,、
  """ ap = voc_ap(rec, prec, [use_07_metric])
  Compute VOC AP given precision and recall.
  If use_07_metric is true, uses the
  VOC 07 11 point method (default:False).
  """
  if use_07_metric:
    # 11 point 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.
  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])
  return ap


def voc_eval(detpath,
             annopath,
             imagesetfile,
             classname,
             cachedir,
             ovthresh=0.5,
             use_07_metric=False,
             use_diff=False):
  """rec, prec, ap = voc_eval(detpath,
                              annopath,
                              imagesetfile,
                              classname,
                              [ovthresh],
                              [use_07_metric])

  Top level function that does the PASCAL VOC evaluation.

  detpath: Path to detections
      detpath.format(classname) should produce the detection results file.
  annopath: Path to annotations
      annopath.format(imagename) should be the xml annotations file.
  imagesetfile: Text file containing the list of images, one image per line.
  classname: Category name (duh)
  cachedir: Directory for caching the annotations
  [ovthresh]: Overlap threshold (default = 0.5)
  [use_07_metric]: Whether to use VOC07's 11 point AP computation
      (default False)
  """
  # assumes detections are in detpath.format(classname)  
  # assumes annotations are in annopath.format(imagename) 
  # assumes imagesetfile is a text file with each line an image name
  # cachedir caches the annotations in a pickle file

  # first load gt  读取真实标签
  if not os.path.isdir(cachedir): #判断缓存文件是否存在
    os.mkdir(cachedir)#不存在则创建一个
  cachefile = os.path.join(cachedir, '%s_annots.pkl' % imagesetfile)#创建一个缓存文件路径
  # read list of images
  with open(imagesetfile, 'r') as f:#打开imagesetfile
    lines = f.readlines()#直接全部读取
  imagenames = [x.strip() for x in lines]#去掉每个元素头和尾巴的字符

  if not os.path.isfile(cachefile):#如果缓存路径对应的文件没有,则载入读取annotations
    # load annotations
    recs = {}#生成一个字典
    for i, imagename in enumerate(imagenames): #对于每一张图像进行循环
      recs[imagename] = parse_rec(annopath.format(imagename))#在字典里面放入每个图像缓存的标签路径
      if i % 100 == 0:#在这里输出标签读取进度。
        print('Reading annotation for {:d}/{:d}'.format(#从这里可以看出来imagenames是什么,是一个测试集合的名字列表,这个Print输出进度。
          i + 1, len(imagenames)))
    # save
    print('Saving cached annotations to {:s}'.format(cachefile))#读取的标签保存到一个文件里面
    with open(cachefile, 'w') as f:#打开缓存文件
      pickle.dump(recs, f)#dump是序列化保存,load是反序列化解析
  else:#如果已经有缓存标签文件了,就直接读取
    # load
    with open(cachefile, 'rb') as f:
      try:
        recs = pickle.load(f)#用Load读取pickle里的文件
      except:
        recs = pickle.load(f, encoding='bytes')#如果读取不了,先二进制解码后再读取

  # extract gt objects for this class#从读取的换从文件中提取出一类的gt
  class_recs = {}
  npos = 0
  for imagename in imagenames:#存在Pickle里的是recs不是Imagenames,读取出来的也是recs
    R = [obj for obj in recs[imagename] if obj['name'] == classname]#首先用循环读取recs里面每一个图像名称里的目标类,从if obj['name']看出来Obj的类型是字典
#recs里面是什么呢{图像名字:[‘第一个标签类别名字’:xxx,‘bbox’:[xxx,xxx,xxx,xxx]],   [‘第一个标签类别名字’:xxx,‘bbox’:[xxx,xxx,xxx,xxx]],。。。。后面的都一样,有多少个标签就有多少个键值      }
    bbox = np.array([x['bbox'] for x in R])#R就是读取出来的该类别的键值对的值,Np.array就是一个指针指向一个多维数据,而不是多个指针指向每一个数据。节约内存左右啦
    if use_diff:#use_diff是之前设置的一个参数把,在R里面有一个键值对是'difficult':0,估计是一个多余操作,在目标检测里面没有这个设置
      difficult = np.array([False for x in R]).astype(np.bool)#astype是修改数据类型的,type是获取数据类型,
    else:
      difficult = np.array([x['difficult'] for x in R]).astype(np.bool)#反正就是改成0或者1的布尔数据类型呗,就是true或者false呗。
    det = [False] * len(R)#det我怀疑是detection的缩写,至于里面存放什么是个问号。
    npos = npos + sum(~difficult)#npos是之间设置为0的一个记录量,这里对difficult求和?可是我数据中这个都是0啊,就忽略把,可能是困难样本标记?反正关于dif的到这里也结束了,就是记录了一下
    class_recs[imagename] = {'bbox': bbox,#重点在这里,我要读取的是该类的所有gt,设置了一个量class_recs[],上面都是提取操作,这里建立的class_是一个字典,键值对是图像名字:{框位置:,diffcult",det:}键值也是一个字典,总之就是二层字典。
                             'difficult': difficult,
                             'det': det}

  # read dets#det和dets是什么?从代码里面用print测试一下发现:det是与difficult相关的一个量,无所谓,但dets是检测结果的路径,读取出来就是图片名字、得分、bbox四个值来回。
  detfile = detpath.format(classname)
  with open(detfile, 'r') as f:#打开该类别的检测结果的txt
    lines = f.readlines()#直接读取全部

  splitlines = [x.strip().split(' ') for x in lines]#去掉\n
  image_ids = [x[0] for x in splitlines]#以图片名称为索引建立一个索引列表image_ids
  confidence = np.array([float(x[1]) for x in splitlines])#把第二列的得分提取出来作为一个列表,用np.array保存为连续取值,用一个指针解决
  BB = np.array([[float(z) for z in x[2:]] for x in splitlines])#对bbox进行数值类型转换,转换为float,这个代码真的很精炼...膜拜。

  nd = len(image_ids)#统计检测出来的目标数量
  tp = np.zeros(nd)#tp = true positive 就是检测结果中检测对的-检测是A类,结果是A类
  fp = np.zeros(nd)#fp = false positive 检测结果中检测错的-检测是A类,结果gt是B类。

  if BB.shape[0] > 0:#。shape是numpy里面的函数,用于计算矩阵的行数shape[0]和列数shape[1]
    # sort by confidence#按得分排序
    sorted_ind = np.argsort(-confidence)#如果confidence没有负号,就是从小到大排序,加了一个符号,直接改变功能,666.
#总之np.argsort就是将得分从大到小排序,然后提取其排序结果对应原来的数据的索引,并输出到sorted_ind中,如sorted_ind第一个数是7,则代表原来第7个经过排序后再第一位。
    sorted_scores = np.sort(-confidence)#加个负号,从大到小排序,为什么和上面功能重复了?其实不重复,上面输出的是排序的索引,是一个索引,这里输出的是重新排序后数值结果。
#其实sorted_ind才是用到的,下面也没有再用sorted_scores了,如果想自己看一看还可以用用。
    BB = BB[sorted_ind, :]#按排序的索引提取出数据放到BB里面。
    image_ids = [image_ids[x] for x in sorted_ind]#因为图像名称列表和得分列表出自同一个列表,得到排序索引之后,就可以按这个排序去提取Bbox了,

    # go down dets and mark TPs and FPs#标记正负样本
    for d in range(nd):#对于每一个检测结果
      R = class_recs[image_ids[d]]#首先用image_ids[]提取名称,作为键值对的键,去提取R,R就是类别名字、得分、坐标
      bb = BB[d, :].astype(float)#BB是置信度排序后的数据,bb就是把第d个元素的置信度从BB里面提取出来。
      ovmax = -np.inf#设置一个负无穷?
      BBGT = R['bbox'].astype(float)#提取真实的BB坐标,并且转换为跟检测结果一样的float变量

      if BBGT.size > 0:#size就是计算这个地方有没有gt,如果有,就计算交并比,那如果没有呢????????也就是说这个样本的计算值在分母中而不在分子中了?那就是只会拉低检测率。
        # compute overlaps
        # intersection#计算交并比的方法就是,对于左上角的(x,y)都取最大,右下角的坐标(x,y)都取最小,得到重叠区域。
        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.) +#计算交并比,计算来就是检测出的框面积+gt框面积,减掉重合的面积,就是总面积,然后除一下重叠面积。就是交、并比了。
               (BBGT[:, 2] - BBGT[:, 0] + 1.) *
               (BBGT[:, 3] - BBGT[:, 1] + 1.) - inters)

        overlaps = inters / uni#这是交并比计算结果。
        ovmax = np.max(overlaps)#ovmax前面设为负无穷,所以这里其实就是如果有重叠区域,那么ovmax就是重叠区域,如果没有,就是负无穷。
        jmax = np.argmax(overlaps)#jmax也是计算最大,但是输出的是索引,与argsort差不多意思吧,反正Np里面有arg的可能都是输出索引。索引比实际数值可能更有用。
#这里面有一个小点:overlaps不一定是一个取值,可能是一个列表。因为有些检测结果是多个Bbox无法区分,所以就会去对比找到交并比最大的,筛选检测结果。ovmax就是一个筛的过程
      if ovmax > ovthresh:#跟设置的阈值比较,如果大于,就是正样本。
        if not R['difficult'][jmax]:#如果不是difficule样本,就继续,否则打上负样本
          if not R['det'][jmax]:#如果不是xxx,哎,反正跟这两层循环都没关系啦
            tp[d] = 1.#就是满足阈值,打上正样本标签,
            R['det'][jmax] = 1
          else:
            fp[d] = 1.#不满足阈值打上负样本标签,加个点就是float了。
      else:
        fp[d] = 1.

  # compute precision recall#计算召回率
  fp = np.cumsum(fp)#计算负样本数量
  tp = np.cumsum(tp)#计算正样本数量
  rec = tp / float(npos)#计算召回率,给一个简单点的介绍,npos其实就是前面在gt中统计的样本数量啦,tp就是检测出来的样本
  #这里做一个简练的总结,recall就是在真实的样本与检测正确的真实样本的比,precision就是检测正确的样本与真实检测正确的样本的比。(很绕口,可以跳过。
  # 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)#然后计算precision
  ap = voc_ap(rec, prec, use_07_metric)#得到recall和precision,调用voc_ap计算ap。

  return rec, prec, ap
#整个过程就是这样,要一点点读才能搞懂,不懂的话就用print尝试看一下到底是什么东西。

函数参数流分析:

----------------------------代码可以按函数分三段:----------------------------

def parse_rec(filename):#这个代码负责解析xml里面的标签,主要就是读取xml中关键内容,然后保存下来。

def voc_ap(rec, prec, use_07_metric=False):#计算AP的函数,

def voc_eval(detpath, annopath, imagesetfile, classname, cachedir, ovthresh=0.5, use_07_metric=False, use_diff=False):

----------------------------输入参数解析----------------------------

parse_rec(filename)参数是xml文件的绝对路径

voc_ap()参数是rec(recall)、prec(precision)、use_07_metric=False(是否使用07版的AP计算方法)

voc_eva()参数较多:

  1.detpath :检测结果的路径,存在data/results/Main/里面有很多txt文档,分别是每一类的检测结果。

  2.annopath:标签路径

  3.imagesetfile:不用说

  4. classname :ap是按类计算的,这个所以你要告诉他你要计算哪一类,即类别名

  5.cachedir:缓存路径

  7.ovthresh:判断正样本检测正确的IOU阈值

  8.use_07_metric=False, 就忽略吧,就是用07版踩点的AP计算方法

  9.use_diff=False 就是区别困难样本的数据标记,反正我是没用到

----------------------------三个函数整体功能解析----------------------------

parse_rec(): 这里的rec,是矩形的意思,功能是""" Parse a PASCAL VOC xml file """==解析xml文件

def voc_ap(): 用recall和precision计算AP

voc_eval(): 载入检测结果以及从xml中提取的gt缓存文件,然后计算Iou、阈值对比、计算recall和precision

注意,eval_ap.py是按类来算的,在调用这个函数的时候,输入的不是总体的检测结果,而是一类的。所以要计算多类,其实是用循环对不同类都调用一次来计算。

----------------------------关键函数 voc_eva()解析----------------------------

从代码来看,首先是从xml中提取groundtrue,然后将保存的txt检测结果提取出来。

最后按得分重新排序一下检测结果,然后从头到尾巴,判断一下阈值,如果大于我们设定的阈值,我们就当做它是检测对的,与gt是一样的。这里面有个问题,假设检测结果很低,但是IOU很高,其实也算他检测对,但一般IOU高的,经过softmax输出的检测结果都不低。

当我们通过对比gt的iou来判断检测结果是不是对的时候,其实我们就是在区分正样本和负样本了。代码有这样一段:

   
         if not R['det'][jmax]:
            tp[d] = 1.
            R['det'][jmax] = 1
         else:
            fp[d] = 1.

其实就是按排序后的检测结果索引,生成一个长度一样的列表[0 0 0 0 0 0 0 0]

然后复制,得到tp和fp,然后通过判断检测交并比,在tp或者fp里面标记为1,如下

tp=[0,0,0,1,0,1,0,0]

fp=[1,1,1,0,1,0,1,1]

其实计算一个就行了,取反就是另一个了。

然后代码中又有下面一段:

  # compute precision recall#计算召回率
  fp = np.cumsum(fp)#计算负样本数量
  tp = np.cumsum(tp)#计算正样本数量
  rec = tp / float(npos)
  prec = tp / np.maximum(tp + fp, np.finfo(np.float64).eps)#然后计算precision

用np.cumsum()统计一下里面1的个数,就知道true positive和 false positive数量了,然后计算recall和precision。

这里面的npos,其实是gt里面的样本数量,就是我标记了npos个这类样本,结果检测出来tp个,那么就是召回率。

而Precision有点问题:

np.maximum是取了检测出来的满足阈值条件的样本的总和,与np.finfo(np.float64).eps。

所以其实就是为了保证不会出现分母为0的情况。设置了一个最小数。

----------------------------其他问题----------------------------

1.没有检测出来的label,会有什么影响?

2.误检会产生什么影响?

3.检测出来没有标注的框会有什么影响?

4.2007的AP和2010之后的AP相比有什么区别?

5.为什么计算出来的recall和precision会构成曲线?

6.怎么在AP计算中把多个类合成一类来计算AP,只要这个gt的检测结果,在大类中的任意一个子类里,就算正确,要怎么这种层级结构的AP计算方式?

Answer:

问题1.没检测出来的label,在计算presicion的时候,不会用到,precision只是检测结果和检测结果的比值。但在recall的分母中,是标签的总数,所以其实recall会低。recall低了会怎么样?

问题2、误检就是Iou等于0。也就是这个检测结果在阈值判断的时候,就会被丢掉,所以不会计算入fp或者tp里面,也不会计算到nops里面,就是分母中都没有,而分子是tp,更不会了。【所以其实误检,是没有影响的。】

这是个错误结论。因为我们没站在另一个类的角度考虑。误检代表什么,B类有一个标签被检测到A类上了,而A类直接在阈值判断过程中就丢弃掉这检测结果了,这是我们考虑的。所以相当于,B类这个label,其实检测出了一个差不多一样的bbox,但是类别错了。所以其实计算B类的时候,相对而言我们的tp中就会很大可能的少一个。而分母不变的情况下,那么recall和precision就会减少。

根本原因就是两类之间的类间差异过小,相似性过大。也就是用于区分两类目标的像素区域,其实只占到我们标注区域当中的很小一部分。这种混淆会使得如果B类会误检为A类,那么A类也可能误检为B类。这种角逐就体现在看谁样本多了,训练就偏向那一方。所以两类的AP都会造成影响。这就是误检,也就是类间相似性体现在AP上的影响。

下图是一个目标两个标签,是一个特例,也就是虽然误检,但是A类和B类都被检测出了框,而且准确率还颇高,这种情况影响相对是小一点的。

下图是相似性的一种体现:包含。

可以看到,并没有把螺栓框全,从特征上来说,这个部分就意味着看不见销子丢失后剩下的空空的小孔,是不可见缺销。那么这样来看,所有的正常螺栓,只要我们有框没有把螺栓的尾部框进去,那么特征表达出来的,其实就是不可见孔的销子丢失,即,每一个正常螺栓,都有存在不可见销子缺失的特征。如下图这样,没个正常螺栓都有可能被截取这样一个框。所以我们要标记正常螺栓,来减小这种共同部分的特征的学习,从标签上强迫模型去学习非这个相似区域的特征,关注他们不同的地方。以减少误检。(解决办法之一就是,不可见螺栓缺销标注对象,不能是其他标注的一个子集,必须要有差异的地方被标出,按照这个思路重新审核建库可以提高准确率)

问题3:

检测出来的框没有标注,说明可能是背景误检,也可能是我们忘记标注了,我们做一个假设,只要我们设置的阈值够大,那么很有可能,他不是背景,而是我们忘记标注的。事实上显示出来的也是这样,我们可以写程序把所有检测出来的满足阈值但没有label的crop出来,看看里面是不是有背景,就会发现其实背景几乎没有。但问题在于,这种自动标注,他的标签可能不准,回到问题2,类间相似性让他很可能误标。当然如果我们只设计螺栓单类,那就不一样了。就一类,那么基本不会误检。但缺陷的自动标注,是不现实的。因为不是漏标的的有无问题了,而是有什么的问题。

漏标在计算中的体现是什么呢?iou为0。这个结果被丢弃。所以其实漏标是没有影响的。

问题4:

07版的AP计算是采样了11个点,线性连线计算面积,而之后的版本,用的好像是积分,更准确了吧,计算记过有差异,但不会很大就对了。

问题5:为什么一类中会有多个recall和precision?因recall和precision的计算,是以图为单位进行的,那么每一张图都有一个,全部画出来,就有了PR曲线了。

问题6:

假设我A下有两个子类分别是B和C。那么如果检测到C实际是B或者检测到B实际是C,也就是误检,也算他正确,这个要怎么修改程序呢?

猜你喜欢

转载自blog.csdn.net/gusui7202/article/details/83930430