一. 背景
本文档以ssd300作为背景网络进行解读,以Tensorflow,Keras为框架
原始代码:https://github.com/pierluigiferrari/ssd_keras
分析后的代码:https://github.com/Freshield/LEARN_detection/tree/master/a4_github_better_ssd/a2_analyze_model
目前网上基本都网络部分讲的比较多,但是真正训练和预测部分都相对粗略,所以自己网上找了一个相对比较好的ssd检测作为蓝本来分析,然后把相应的过程用大白话给表达出来,方便大家可以更好的理解网络。
二 到 三请看上一篇
https://zhuanlan.zhihu.com/p/122357989
四. 数据部分
数据这个部分之所以重要是因为不同于分割和分类,数据基本直接从原始数据获得即可,最多只是做一些数据增强。SSD的数据部分,尤其是label部分需要通过许多步骤,最终生成和预测一样的大小,也就是(batch, 8732, 33)这样的矩阵,而原始的数据只是表示了分类号和相关坐标,其中转换可见一斑。
数据主要分为原数据读取(对原始数据转换为二进制格式保存),
生成器的读取数据batchX,batchY(这部分就是构造生成器遍历从二进制格式中恢复数据),
数据增强(对图像进行数据增强,同时最主要的是把图像进行resize),
encoder对batchY进行处理,这部分是数据部分最重要的处理部分,把原始的batchY进行一系列处理最后编码成预测的格式,然后就可以进行之后的loss计算。
1.原数据读取 a.解析xml这里以原始的SSD举例,是使用的VOC数据集,其中包括了VOC2007以及VOC2012。这个数据集图像为大小不一的RGB图像,而label以xml的形式存在。一张图像对应一个xml文件。总共20个类别,之后如果加上背景类则一共21个类别。同时,数据集还有有一个索引文件,表明了数据的id以及是如果分配trainval的数据集的。所以这里的第一步就是要解析原数据。
首先遍历图像路径,label路径和索引目录
读取图像的索引
生成图像的文件路径,并放入filenames列表
如果label存在则进行解析
读取label的xml,遍历所有的objects
得到一个object的类名,检测框,生成字典,放入这个label的boxes列表
把本label的boxes放入labels列表
如果是难例,放入eval_neutral列表(并没有用到)
保存数据集长度,索引并返回
b.生成hdf5(可以想成dict)这里之所以生成hdf5的原因是hdf5是一种二进制格式,并且支持多平台并且对分布式友好,是一种比较方便的格式,这里要把数据都存进单独的hdf5文件中方便之后的训练时使用。
首先生成原始的文件,生成原始的key,value结构
遍历数据集的长度
读取图像数据,保存图像的shape到hdf5_image_shape,拉平图像存到hdf5_images
如果有label,保存shape到hdf5_label_shapes,并把label拉平存到hdf5_labels
保存图像索引到hdf5_image_ids
保存难例到hdf5_eval_neutral(没有用到)
最终数据会trainval存为一个文件,test存为一个文件
其中图像数据和label数据虽然已经拉平,如果恢复回原始shape的话,如下所示
image (???, ???, 3),label [[class_id, xmin, ymin, xmax, ymax], ...],这里需要注意,我们的网络预测需要的是中心点长宽这种形式,而原始数据为左上右下点坐标的形式。
这里的图像之所以为问号是因为VOC数据集的图像大小是不一的,这个会之后在生成器生成数据时实时resize,而这里label的长度也是不定的,因为一张图会有n个objects,所以最后label的shape应该是(n_objects, 5)
2.生成器的读取数据batchX, batchY a.shuffle首先,需要把相应的图像索引乱序排列下
b.batchX首先从hdf5中得到batch索引的图像,然后得到batchX的图像并恢复回原图大小,需要注意的是这里依然为任意大小,并非300,300,3
c.batchY通过同样的序列从hdf5获得对应的label矩阵,然后恢复会原来的大小,举例的值如下:
[[3,170,110,465,294], [1,180,190,250,330]],这里代表这张图有两个目标框,第一个框类别号为3,第二个框类别号为1,对应的坐标顺序为xmin,ymin,xmax,ymax。这里需要注意,我们的网络预测需要的是中心点长宽这种形式,而原始数据为左上右下点坐标的形式。所以之后需要做相应的转换。所以batchY为(b,n_objects,5)
3.数据增强在得到了原始的数据batchX和batchY之后,下一步是做数据增强以及数据转换。
这部分的操作很值得借鉴,把要做的所有操作也就是transform,都是用同样的输入输出,然后建立一个操作列表,把相应的增强操作按照顺序放到操作列表中,这样之后被生成器顺序调用。
可以看到,这里通过这个操作列表把相关的操作和生成器进行了解耦,生成器只知道顺序调用操作列表中的transform而不知道也无需知道都是哪些transform,只知道如果调用相关的接口即可。
对于图像来说有许多的数据增强操作如随机亮度,随机饱和度,随机色度等等,但是这些其实都是可选项,而且对于其他图像如医学图像等,可能并不合适,这里最重要最需要知道的就是Resize这个transform。
这个transform就像名字那样是用来转换尺寸的,再通过这个操作之后,图像就统一从原始的各自不同的大小变换为(300,300,3),同时batchY由于坐标是和原图对应的,所以当原图的大小进行变换时,
batchY的坐标位置也会进行相应的大小。总体来说,在经过数据增强之后,我们得到batchX(b,300,300,3),batchY也是对应的相应的位置,shape依然是(b, n_objects, 5)
4.encoder对batchY进行处理在进行数据增强后,便是SSD的数据部分最重要的地方了,也就是对batchY进行处理。回想下我们的网络预测是把(b,300,300,3)的输入经过网络输出为(b,8732,33)这样的矩阵,这也就是我们的y_pred,那么相应的label数据也就是我们的y_true或者说是batchY也需要是同样的(b,8732,33)的大小,这一步就是关于这部分如何转换的。
这里引入一个题外话,个人觉得SSD其实网络部分就是一个密集采样,效果还不错的主要原因就在于数据的编码部分,loss的分类计算部分以及预测后的decoder部分三个共同影响的结果,这几个部分从某种意义上替代了two stages的RPN网络。
a.生成返回矩阵回想一下模型部分的priorbox,就像那部分最后说的,先验框的部分其实是不需要计算可以直接生成的,所以priorbox只要知道了数据的信息,如feature map的大小,先验框数量和长宽比等,就可以直接生成。
这里我们就直接先生成默认的返回矩阵,大小和y_pred一样(b,8732,33),这里的33为21+4+8,其中21为类别,4为label的坐标,8为先验框的坐标和variance。
然后我们默认所有的先验框都为背景类,也就是第0维为1。label的坐标部分我们先用先验框的坐标作为默认值,其实这里并不影响,因为到时候计算loss的时候坐标只会计算正例也就是有非背景类的坐标,最后8个值为先验框的坐标和variance值。
b.遍历batch并转换数据格式我们当前的batchY为(b,n_objects,5),这里对batch进行遍历,然后得到的label是(n_objects,5)这样的形式。
然后我们目前虽然在resize部分进行了坐标的转换,不过当前依然是相对于全图的坐标,而我们的y_pred的坐标是归一化后的坐标,所以这里我们也先对数据进行归一化,也就是相应的xmin,xmax除以原图的w也就是300,ymin,ymax除以原图的h也就是300.
在这之后,由于当前的坐标为xmin,ymin,xmax,ymax,但是我们无论计算IOU还是和y_pred计算偏移都需要cx,cy,w,h的形式,所以这里进行相应的转换。这个转换其实也非常简单,cx也就等于(xmax-xmin)//2,cy等于(ymax-ymin)//2,w等于xmax-xmin,h等于ymax-ymin这样通过numpy的广播可以一下就完成转换。
c.计算IOU生成IOU矩阵iou其实翻译过来就是交并比,如上图这样,把先验框和label框的交集,除以两个的并集。这里注意,我们计算的iou是label和先验框的iou,而不是预测出来的框。
另外,这里会通过numpy的广播一下计算一个label框和所有的8732个先验框的iou值,代码里也叫similarity矩阵,其实是一个东西。
iou矩阵在数据处理编码中主要提供了一个选择的根据,之后的label框的生成都是根据这里iou矩阵中不同框的iou值来进行选择的。
这里计算的方法是一样的,所以下边就以简单的两个框进行说明。
i.转换我们现在有label(cx,cy,w,h),bbox(cx,cy,w,h),首先为了计算iou我们需要把表示方法从中心表示法转换为xmin,ymin,xmax,ymax的表示法,也非常简单就是xmin=cx-w/2,xmax=cx+w/2,ymin=cy-h/2,ymax=cy+h/2。
这样我们就得到了变换后的label(xmin,xmax,ymin,ymax)和bbox(xmin,xmax,ymin,ymax)
ii.交集首先我们来计算交集
这是两个框的情况,从上图可以得出,交集的部分,也就是要求出中间的小矩形的坐标位置,然后就可以得到交集的面积。而小矩形的坐标位置的xmin,ymin其实就是两个框中xmin,ymin的较大的那个,而小矩形的坐标位置的xmax,ymax是两个框中xmax,ymax较小的那个,也就是:
xmin_inter=max(xmin1, xmin2)
ymin_inter=max(ymin1, ymin2)
xmax_inter=min(xmax1, xmax2)
ymax_inter=min(ymax1, ymax2)
然后交集的面积就等于(xmax_inter-xmin_inter) * (ymax_inter-ymin_inter)
那么这是一个框的情况,具体到我们的网络部分,我们有label(n_objects, 4),
bbox(8732, 4)(这里相当于把先验框的坐标部分提取出来),那么首先就是对两边进行拓展,变成同样的大小,对于label进行tile后,变为label(n_objects, 8732, 4),这里的tile操作可以理解为复制。然后对于bbox也进行tile,变为(n_objects, 8732, 4)。
注意这里的4为xmin,ymin,xmax,ymax于是先对label,bbox的第0,1维求max得到xmin_inter,ymin_inter,然后对label,bbox的第2,3维求min得到xmax_inter,ymax_inter。再让第2维减第0维得到xmax_inter-xmin_inter,用第3维减第1维得到ymax_inter-ymin_inter,最后两个结果相乘就得到了intersection部分(n_objects, 8732)。
iii.并集并集的计算相对于交集就简单许多,并集相当于box1的面积加上box2的面积减去交集的面积也就是box1_area+box2_area-intersection。
box1的面积为(xmax1-xmin1) * (ymax1-ymin1)
box2的面积为(xmax2-xmin2) * (ymax2-ymin2)
那么对于我们的矩阵label(n_objects, 4)和bbox(8732, 4)计算如下:
由于numpy可以方便的广播特性,所以这里可以先分别对label和bbox使用上边的维度计算,得到label(n_objects)和bbox(8732),这两个现在相当于是label每个框的面积,bbox每个框的面积。然后使用使用tile把label拓展为(n_objects, 8732),把bbox拓展为(n_objects, 8732),直接进行label+bbox-intersection就可以得到并集union(n_objects, 8732)
最后根据公式iou=intersection/union,就可以得到了iou矩阵。
通过numpy广播对label矩阵(n_objects, 5)计算iou得到全部的iou矩阵
这里很有意思的是这是一个矩阵,大小为(n_objects, 8732),其实就代表着,每个object的label框和8732所有的先验框的iou值。
d.贪心二分匹配这个名字听起来虽然唬人,但是其实目的很单纯,就是要找出每个label的框最匹配的那个bbox。而这里的难点在于,如果两个label框都和一个bbox匹配,需要找出最大的框,然后再匹配剩下的。
整体流程是找出我们刚才的iou矩阵(n_objects, 8732)中整个矩阵最匹配的也就是iou值最高的位置,然后把再在iou矩阵除了这个label框和这个bbox框剩下的矩阵中找寻最匹配的,直到找完所有label框最匹配的,而这里的trick是复制一个iou矩阵,因为之后还有继续使用iou矩阵,把复制后的iou矩阵中最匹配的找出来,然后横向把这个label一行都置零,纵向把这个bbox框都置零,就是一个大十字的零,再找下一个最大的,再置零,可以想象,这样一共会重复n_objects次,得到n_objects个框。
具体的流程如下:首先复制iou矩阵 -> 生成label的索引数也就是从0到n_objects -> 遍历n_objects次 -> 找到各label最大的iou索引 -> 比较各label最大的iou值得到最大的iou的label的索引 -> 保存iou值和label的相关信息到matches列表 -> 置零这个label行和这个bbox的列 -> 继续重复找到iou最大的label与i及它的iou最大索引
最终返回matches列表 -> 把返回矩阵相应的bbox填上label的class的one_hot矩阵和label的坐标矩阵,并把iou矩阵相应框的位置置零,注意这里只置零单独的框,也就是一共置零n_objects个框。
e.match multi得到所有大于iou阈值的label最匹配的bbox需要注意的是,这里是要得到所有大于iou阈值的label匹配的bbox,可能会有人觉得既让贪心二分匹配得到的都是iou最大的,这里所有大于iou阈值的的框,那贪心二分匹配岂不是多此一举。其实这里重要的点在于所有大于阈值的label,贪心二分匹配主要解决的是当所有的框都小于iou阈值时,我仍会给所有label框找到一个先验框进行匹配,然后进行反向传播来进行学习,这尤其在最开始训练时尤其重要。如果没有贪心二分匹配算法的话,那么可能就一直没有大于iou阈值的框,然后就没有label,就所有loss都为0,就没有办法学习了。
整体流程为:生成bbox的索引列表[0,1,...,8731],找到iou矩阵(n_objects, 8732)每个bbox最匹配的label索引也就是从列找到最大值的索引(8732,),找到bbox最匹配的iou的值这里是找相应的位置的值(8732,)。这里的操作主要是为了把bbox匹配到iou值最大的label,这样先从列上找出每个bbox应该匹配那个label,然后在把相应的iou值都提取出来好进行iou值阈值处理。
然后找到所有iou大于阈值的bbox索引
找到这些iou大于阈值的label索引,这里得到label的索引主要是为了之后得到这些label的class的one_hot矩阵
根据bbox的索引,在返回矩阵中的相应bbox位置填入label的class的one_hot矩阵和label的坐标位置。
把bbox的索引在iou矩阵中的相应位置置零
f.去除iou过高的反例上边的时候iou阈值一般会设置为0.5,那么我们有理由相信,当iou阈值在0.3到0.5之间的先验框有可能会干扰我们的正例学习,所以这里会找到超过(去除iou的阈值)也就是0.3并小于(match multi的iou阈值)也就是0.5之间的先验框。
不过理论上其实一般不会影响太大,所以真正代码这里的去除iou阈值也为0.5,所以并没有真正的使用。
g.对batchY的坐标编码在这之前我们已经选出了我们要的框,最后一步就是对所有的batchY进行编码。我们的y_encoded为(b,8732,21+4+8),其中这里的4+4+4分别时(cx,cy,w,h)gt,(cx,cy,w,h)anchor以及variacne的v0,v1,v2,v3。这里的gt部分也就是ground truth也就是label部分,除了我们找出来的相应的和label对应上的框外,其他也就是背景也就是反例的gt这里和anchor是一样的,但是这没有什么问题,因为在计算loss的时候,坐标回归部分我们只会计算正例的部分。
坐标这里就像我们在模型部分所说的那样,我们的网络并不能直接cx,cy,w,h,所以我们需要对这些换算成相对坐标和相对大小,也就是lx,ly,lw,lh。
简单的理解我们的编码,就是要得到真实的框的中心点,相对于先验框的平移的距离,然后真是的框的长宽相对于先验框的缩放比例。
另外,这里的编码方法其实和faster rcnn如出一辙。
lx=(cx(gt)-cx(anchor))/w(anchor),这里的意思的就是真实label的中心点cx相对于先验框的中心点cx的偏移量,相对于先验框的w的比例大小。
这里加上variance后,最终的lx=(cx(gt)-cx(anchor))/w(anchor)/v0,这里v0为0.1,也就是相当于扩大了10倍,这样在计算loss的时候也相当于扩大了10倍,反向传播的时候梯度也扩大了10倍,就可以让网络更好的进行学习。
相对的ly=(cy(gt)-cy(anchor))/h(anchor)/v1
对于lw和lh的计算公式和cx,cy稍有不同
lw=(log(w(gt)/w(anchor)))/v2,抛开后边为了增大梯度的v2,前边的lw相应于label对bbox的宽的比值,这里使用log主要是两个原因:
首先我们考虑下log的曲线
我们这里a是大于1的,可以看出随着x的增加曲线越加的平缓,也就是说如果label和anchor的比例过大是可以起到平缓的作用,这对于不同目标的大小尺寸有一定的作用,考虑下小目标的长宽比就相对较小,而大目标的长宽比就会相对较大,而使用了log之后,可以平衡这中间的差别。这就类似yolo网络中,编码部分使用的是w**0.5是类似的道理。
第二个是当我们预测时,需要把这里的编码变成解码,我们预测的w=exp(lw x v2)x w(anchor),那么我们看下exp函数的样子
这里可以看出exp有一个我们在预测宽度时非常需要的一个特性,也就是非负性。因为我们预测的宽度不可能是一个负数。
同理,这里有lh=(log(h(gt)/h(anchor)))/v3
这里代码部分直接使用numpy选择相应的维度进行运算即可,最终返回y_encoded(b,8732,21+4+8)