自己解析的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是按类来算的,在调用这个函数的时候,输入的不是总体的检测结果,而是一类的。所以要计算多类,其实是用循环对不同类都调用一次来计算。
从代码来看,首先是从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计算方式?
问题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:
if ovmax > ovthresh:
if not R['difficult'][jmax]:
if not R['det'][jmax]:
tp[d] = 1.
R['det'][jmax] = 1
else:
fp[d] = 1.
else:
fp[d] = 1.
检测出来的框没有标注,说明可能是背景误检,也可能是我们忘记标注了,我们做一个假设,只要我们设置的阈值够大,那么很有可能,他不是背景,而是我们忘记标注的。事实上显示出来的也是这样,我们可以写程序把所有检测出来的满足阈值但没有label的crop出来,看看里面是不是有背景,就会发现其实背景几乎没有。但问题在于,这种自动标注,他的标签可能不准,回到问题2,类间相似性让他很可能误标。当然如果我们只设计螺栓单类,那就不一样了。就一类,那么基本不会误检。但缺陷的自动标注,是不现实的。因为不是漏标的有无问题了,而是有什么的问题。
漏标在计算中的体现是什么呢?iou等于0。看上面代码。如果Iou不满足阈值,就会被标记为负样本。所以其实,检测结果没有Label的,都会被计算为误标。修改代码,把没有label的检测结果不算入正样本也不算入负样本,可以得到对比实验。结果会好一点。如果漏标的越严重,那么AP的取值就会越低,但问题在于,训练的时候,检测到一个没有label的数据,就没有Loss,所以对模型是有影响,少学了这些漏标的样本。也说明AP这个指标还是不够的。可能还需要漏检率等其他指标。而没有label的检测结果,你不能说他是检测错的,也不能说他是检测对的,要具体看了才知道,因为没有label。这是AP的一个缺陷。
问题4:
07版的AP计算是采样了11个点,线性连线计算面积,而之后的版本,用的好像是积分,更准确了吧,计算记过有差异,但不会很大就对了。
问题5:
为什么一类中会有多个recall和precision?因recall和precision的计算,是以图为单位进行的,那么每一张图都有一个,全部画出来,就有了PR曲线了。
问题6:
细致描述:这是一个很有意思的问题。目标检测的类别,不是平行的,是有层级结构的,如下图所示:而我们只打了子类的标签,现在要计算水果的AP,没有标签怎么办呢?没关系,几类加在一起就行了,行吗?不行,因为里面还有子类之间误检的问题,子类之间误检,其实在大类上,并不算误检,而是一个正确结果,但子类结果相加,并不会考虑误检,原因在前面代码分析给出了。为什么误检也算正确呢?因为它提取到的,的确是水果的特征,而经过softmax输出的,也的确是水果中的某一类,模型知道是水果,但分不清是什么水果。所以说模型在大类上,没判断错对吧。
假设我A下有两个子类分别是B和C。那么如果检测到C实际是B或者检测到B实际是C,也就是误检,也算他正确,这个要怎么修改程序呢?
确定一下几点问题的关键:
1. 没有标签的计算结果,从头到尾都没有影响。——>>只用处理误检
2. 误检在每一类单独计算的时候,是被丢弃的。
3.丢弃之后不做其他处理:设A类被检测为B类,在计算B类AP的时候,处理这个误检结果,处理方式是丢弃。
而在计算A类的AP的时候,这个检测结果不发挥效用。即:误检结果会被丢弃处理,然后不做其他操作。
4.当我们设置有一个大类的时候,期内几个子类间的误检,在大类上,是正确的,不算误检,这个时候我们要想办法将它处理,保证结果的正确性。
通过对关键点的分析,明确目标如下:
对误检的目标在大类范围内搜索groundtrue,大类内只要有label与其iou大于阈值,则算做大类检测结果的tp。
于是我们要在算法中增加额外的一些处理过程:
1. 建立一个不在标签中的大类的gt集合
2.对大类中的每一个子类计算AP的时候,要对每一个结果都要额外的,丢到大类gt集合里面去匹配一次iou,只要有交并比大于阈值条件的结果,就算这是一个tp,而不是一个只能丢弃的误检样本。这样就会有一下两种情况:
a.如果没有在大类gt集合中匹配到符合阈值要求的iou的label,就说明这个是背景噪声,
b.如果有,说明这个检测结果,是子类间的误检。
按这个思路,就可以实现一个有层级结构的类别检测任务的大类和子类的检测结果。
那么为什么我们不在标注的时候打上大类的标签呢?因为这是多标签学习的内容了。我没学过。
只要在代码中实现上面两点,就能够实现这个无标签类的AP计算任务。