深度剖析SSD(你那些似懂非懂的地方)

本文讲解的是SSD算法,其英文全名是Single Shot MultiBox Detector。Faster R-CNN,其先通过RPN网络得到候选框,然后再进行分类与回归,而Yolo与SSD可以一步到位完成检测。SSD相比其他算法有两大重要改变:
一:SSD提取了不同尺度的特征图来做检测,大尺度特征图(较靠前的特征图)可以用来检测小物体,而小尺度特征图(较靠后的特征图)用来检测大物体;
二:SSD采用了不同尺度和长宽比的先验框(Prior boxes, Default boxes,在Faster R-CNN中叫做锚,Anchors)
下面我们结合代码(tensorflow)讲解SSD算法。

网络结构

深度剖析SSD(你那些似懂非懂的地方)_第1张图片
SSD采用VGG16作为基础模型,然后在VGG16的基础上新增了卷积层来获得更多的特征图以用于检测。训练时同样为conv1_1,conv1_2,conv2_1,conv2_2,conv3_1,conv3_2,conv3_3,conv4_1,conv4_2,conv4_3,conv5_1,conv5_2,conv5_3(512),fc6经过3×3×1024的卷积(原来VGG16中的fc6是全连接层,这里变成卷积层,下面的fc7层同理),fc7经过1×1×1024的卷积,conv6_1,conv6_2(对应上图的conv8_2),conv7_1,conv7_2,conv,8_1,conv8_2,conv9_1,conv9_2,loss。然后针对conv4_3(4),fc7(6),conv6_2(6),conv7_2(6),conv8_2(4),conv9_2(4)的每一个再分别采用两个3*3大小的卷积核进行卷积,这两个卷积核是并列的。

    end_points = {}
   with tf.variable_scope(scope, 'ssd_300_vgg', [inputs], reuse=reuse):
       # Original VGG-16 blocks.
       net = slim.repeat(inputs, 2, slim.conv2d, 64, [3, 3], scope='conv1')
       end_points['block1'] = net
       net = slim.max_pool2d(net, [2, 2], scope='pool1')
       # Block 2.
       net = slim.repeat(net, 2, slim.conv2d, 128, [3, 3], scope='conv2')
       end_points['block2'] = net
       net = slim.max_pool2d(net, [2, 2], scope='pool2')
       # Block 3.
       net = slim.repeat(net, 3, slim.conv2d, 256, [3, 3], scope='conv3')
       end_points['block3'] = net
       net = slim.max_pool2d(net, [2, 2], scope='pool3')
       # Block 4.
       net = slim.repeat(net, 3, slim.conv2d, 512, [3, 3], scope='conv4')
       end_points['block4'] = net
       net = slim.max_pool2d(net, [2, 2], scope='pool4')
       # Block 5.
       net = slim.repeat(net, 3, slim.conv2d, 512, [3, 3], scope='conv5')
       end_points['block5'] = net
       net = slim.max_pool2d(net, [3, 3], stride=1, scope='pool5')

       # Additional SSD blocks.
       # Block 6: let's dilate the hell out of it!
       net = slim.conv2d(net, 1024, [3, 3], rate=6, scope='conv6')
       end_points['block6'] = net
       net = tf.layers.dropout(net, rate=dropout_keep_prob, training=is_training)
       # Block 7: 1x1 conv. Because the fuck.
       net = slim.conv2d(net, 1024, [1, 1], scope='conv7')
       end_points['block7'] = net
       net = tf.layers.dropout(net, rate=dropout_keep_prob, training=is_training)

       # Block 8/9/10/11: 1x1 and 3x3 convolutions stride 2 (except lasts).
       end_point = 'block8'
       with tf.variable_scope(end_point):
           net = slim.conv2d(net, 256, [1, 1], scope='conv1x1')
           net = custom_layers.pad2d(net, pad=(1, 1))
           net = slim.conv2d(net, 512, [3, 3], stride=2, scope='conv3x3', padding='VALID')
       end_points[end_point] = net
       end_point = 'block9'
       with tf.variable_scope(end_point):
           net = slim.conv2d(net, 128, [1, 1], scope='conv1x1')
           net = custom_layers.pad2d(net, pad=(1, 1))
           net = slim.conv2d(net, 256, [3, 3], stride=2, scope='conv3x3', padding='VALID')
       end_points[end_point] = net
       end_point = 'block10'
       with tf.variable_scope(end_point):
           net = slim.conv2d(net, 128, [1, 1], scope='conv1x1')
           net = slim.conv2d(net, 256, [3, 3], scope='conv3x3', padding='VALID')
       end_points[end_point] = net
       end_point = 'block11'
       with tf.variable_scope(end_point):
           net = slim.conv2d(net, 128, [1, 1], scope='conv1x1')
           net = slim.conv2d(net, 256, [3, 3], scope='conv3x3', padding='VALID')
       end_points[end_point] = net

       # Prediction and localisations layers.
       predictions = []
       logits = []
       localisations = []
       for i, layer in enumerate(feat_layers):
           with tf.variable_scope(layer + '_box'):
               p, l = ssd_multibox_layer(end_points[layer],
                                         num_classes,
                                         anchor_sizes[i],
                                         anchor_ratios[i],
                                         normalizations[i])
           predictions.append(prediction_fn(p))
           logits.append(p)
           localisations.append(l)

       return predictions, localisations, logits, end_points

上面的代码就是在构建网络,网络也就是和VGG差不多,endpoints这个字典,里面包含的是不同特征图的输出,就是SSD不是只利用一层特征,而是多层,所以这个地方存放多层的输出。

采用多尺度特征图用于检测

Default box
文章的核心之一是作者同时采用lower和upper的feature map做检测。这里假定有8×8和4×4两种不同的feature map。第一个概念是feature map cell,feature map cell 是指feature map中每一个小格子,如图中分别有64和16个cell。另外有一个概念:default box,是指在feature map的每个小格(cell)上都有一系列固定大小的box,如下图有4个(下图中的虚线框,仔细看格子的中间有比格子还小的一个box)。假设每个feature map cell有k个default box,那么对于每个default box都需要预测c个类别(这里c个包括背景)score和4个offset,那么如果一个feature map的大小是m×n,也就是有m×n个feature map cell,那么这个feature map就一共有(c+4)×k ×m×n 个输出。深度剖析SSD(你那些似懂非懂的地方)_第2张图片
这里需要强调一下:第二部分就是边界框的location,这4个offset(cx, cy, w, h)到底是什么?有很多同学认为直接输出了default boxes的中心坐标跟宽和高,这种理解是错误的。The bounding box offset output values are measured relative to a default box position relative to each feature map location。这4个值其实是真实坐标的变换值,用来对每个feature map位置的默认框位置进行测量。理解bounding boxes regression的同学应该很容易明白。深度剖析SSD(你那些似懂非懂的地方)_第3张图片
深度剖析SSD(你那些似懂非懂的地方)_第4张图片
深度剖析SSD(你那些似懂非懂的地方)_第5张图片
所以其实4个offset就是bbr中的d*(P)。

训练中还有一个东西:prior box,是指实际中选择的default box(每一个feature map cell 不是k个default box都取)。也就是说default box是一种概念,prior box是实际的选取。训练中一张完整的图片送进网络获得各个feature map,对于正样本训练来说,需要先将prior box与ground truth box做匹配,匹配成功说明这个prior box所包含的是个目标,但离完整目标的ground truth box还有段距离,训练的目的是保证default box的分类confidence的同时将prior box尽可能回归到ground truth box。 举个列子:假设一个训练样本中有2个ground truth box,所有的feature map中获取的prior box一共有8732个。那个可能分别有10、20个prior box能分别与这2个ground truth box匹配上。训练的损失包含定位损失和回归损失两部分。

从后面新增的卷积层中提取Conv7,Conv8_2,Conv9_2,Conv10_2,Conv11_2作为检测所用的特征图,加上Conv4_3层,共提取了6个特征图,其大小分别是 (38, 38), (19, 19), (10, 10), (5, 5), (3, 3), (1, 1) ,但是不同特征图设置的先验框数目不同(同一个特征图上每个单元设置的先验框是相同的,这里的数目指的是一个单元的先验框数目)。先验框的设置,包括尺度(或者说大小)和长宽比两个方面。对于先验框的尺度,其遵守一个线性递增规则:随着特征图大小降低,先验框尺度线性增加:
深度剖析SSD(你那些似懂非懂的地方)_第6张图片
可以看出这种default box在不同的feature层有不同的scale,在同一个feature层又有不同的aspect ratio,因此基本上可以覆盖输入图像中的各种形状和大小的object!
深度剖析SSD(你那些似懂非懂的地方)_第7张图片
具体到每一个feature map上获得prior box时,会从这6种中进行选择。如下表和图所示最后会得到(38×38×4 + 19×19×6 + 10×10×6 + 5×5×6 + 3×3×4 + 1×1×4)= 8732个prior box。
我们看到代码来更加深刻的理解多尺度。

class SSDNet(object):
   """Implementation of the SSD VGG-based 300 network.
   The default features layers with 300x300 image input are:
     conv4 ==> 38 x 38
     conv7 ==> 19 x 19
     conv8 ==> 10 x 10
     conv9 ==> 5 x 5
     conv10 ==> 3 x 3
     conv11 ==> 1 x 1
   The default image size used to train this network is 300x300.
   """
   default_params = SSDParams(
       img_shape=(300, 300),
       num_classes=21,
       no_annotation_label=21,
       feat_layers=['block4', 'block7', 'block8', 'block9', 'block10', 'block11'],
       feat_shapes=[(38, 38), (19, 19), (10, 10), (5, 5), (3, 3), (1, 1)],
       anchor_size_bounds=[0.15, 0.90],
       # anchor_size_bounds=[0.20, 0.90],
       anchor_sizes=[(21., 45.),
                     (45., 99.),
                     (99., 153.),
                     (153., 207.),
                     (207., 261.),
                     (261., 315.)],
       # anchor_sizes=[(30., 60.),
       #               (60., 111.),
       #               (111., 162.),
       #               (162., 213.),
       #               (213., 264.),
       #               (264., 315.)],
       anchor_ratios=[[2, .5],
                      [2, .5, 3, 1./3],
                      [2, .5, 3, 1./3],
                      [2, .5, 3, 1./3],
                      [2, .5],
                      [2, .5]],
       anchor_steps=[8, 16, 32, 64, 100, 300],
       anchor_offset=0.5,
       normalizations=[20, -1, -1, -1, -1, -1],
       prior_scaling=[0.1, 0.1, 0.2, 0.2]
       )

这里注意代码中的size不是根据Sk来算的,其实框的大小是可以自己根据经验直接给的。
以上这就是SSD中最重要的创新点。

训练过程

(1)先验框匹配
在训练过程中,首先要确定训练图片中的ground truth(真实目标)与哪个先验框来进行匹配,与之匹配的先验框所对应的边界框将负责预测它。在Yolo中,ground truth的中心落在哪个单元格,该单元格中与其IOU最大的边界框负责预测它。但是在SSD中却完全不一样,SSD的先验框与ground truth的匹配原则主要有两点。首先,对于图片中每个ground truth,找到与其IOU最大的先验框,该先验框与其匹配,这样,可以保证每个ground truth一定与某个先验框匹配。通常称与ground truth匹配的先验框为正样本,反之,若一个先验框没有与任何ground truth进行匹配,那么该先验框只能与背景匹配,就是负样本。一个图片中ground truth是非常少的, 而先验框却很多,如果仅按第一个原则匹配,很多先验框会是负样本,正负样本极其不平衡,所以需要第二个原则。第二个原则是:对于剩余的未匹配先验框,若某个ground truth的IOU大于某个阈值(一般是0.5),那么该先验框也与这个ground truth进行匹配。
尽管一个ground truth可以与多个先验框匹配,但是ground truth相对先验框还是太少了,所以负样本相对正样本会很多。为了保证正负样本尽量平衡,SSD采用了hard negative mining,就是对负样本进行抽样,抽样时按照置信度误差(预测背景的置信度越小,误差越大)进行降序排列,选取误差的较大的top-k作为训练的负样本,以保证正负样本比例接近1:3。

def tf_ssd_bboxes_encode_layer(labels,
                              bboxes,
                              anchors_layer,
                              num_classes,
                              no_annotation_label,
                              ignore_threshold=0.5,
                              prior_scaling=[0.1, 0.1, 0.2, 0.2],
                              dtype=tf.float32):
   """Encode groundtruth labels and bounding boxes using SSD anchors from
   one layer.
   Arguments:
     labels: 1D Tensor(int64) containing groundtruth labels;
     bboxes: Nx4 Tensor(float) with bboxes relative coordinates;
     anchors_layer: Numpy array with layer anchors;
     matching_threshold: Threshold for positive match with groundtruth bboxes;
     prior_scaling: Scaling of encoded coordinates.
   Return:
     (target_labels, target_localizations, target_scores): Target Tensors.
   """
   # Anchors coordinates and volume.
   yref, xref, href, wref = anchors_layer
   ymin = yref - href / 2.
   xmin = xref - wref / 2.
   ymax = yref + href / 2.
   xmax = xref + wref / 2.
   vol_anchors = (xmax - xmin) * (ymax - ymin)

   # Initialize tensors...
   shape = (yref.shape[0], yref.shape[1], href.size)
   feat_labels = tf.zeros(shape, dtype=tf.int64)
   feat_scores = tf.zeros(shape, dtype=dtype)

   feat_ymin = tf.zeros(shape, dtype=dtype)
   feat_xmin = tf.zeros(shape, dtype=dtype)
   feat_ymax = tf.ones(shape, dtype=dtype)
   feat_xmax = tf.ones(shape, dtype=dtype)

   def jaccard_with_anchors(bbox):
       """Compute jaccard score between a box and the anchors.
       """
       int_ymin = tf.maximum(ymin, bbox[0])
       int_xmin = tf.maximum(xmin, bbox[1])
       int_ymax = tf.minimum(ymax, bbox[2])
       int_xmax = tf.minimum(xmax, bbox[3])
       h = tf.maximum(int_ymax - int_ymin, 0.)
       w = tf.maximum(int_xmax - int_xmin, 0.)
       # Volumes.
       inter_vol = h * w
       union_vol = vol_anchors - inter_vol \
           + (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])
       jaccard = tf.div(inter_vol, union_vol)
       return jaccard

   def intersection_with_anchors(bbox):
       """Compute intersection between score a box and the anchors.
       """
       int_ymin = tf.maximum(ymin, bbox[0])
       int_xmin = tf.maximum(xmin, bbox[1])
       int_ymax = tf.minimum(ymax, bbox[2])
       int_xmax = tf.minimum(xmax, bbox[3])
       h = tf.maximum(int_ymax - int_ymin, 0.)
       w = tf.maximum(int_xmax - int_xmin, 0.)
       inter_vol = h * w
       scores = tf.div(inter_vol, vol_anchors)
       return scores

   def condition(i, feat_labels, feat_scores,
                 feat_ymin, feat_xmin, feat_ymax, feat_xmax):
       """Condition: check label index.
       """
       ### 逐元素比较大小,其实就是遍历label,因为i在body返回的时候加1了,直到遍历完
       r = tf.less(i, tf.shape(labels))
       return r[0]

   def body(i, feat_labels, feat_scores,
            feat_ymin, feat_xmin, feat_ymax, feat_xmax):
       """Body: update feature labels, scores and bboxes.
       Follow the original SSD paper for that purpose:
         - assign values when jaccard > 0.5;
         - only update if beat the score of other bboxes.
       """
       # Jaccard score.
       label = labels[i]
       bbox = bboxes[i]
       ### 返回的是交并比,算某一层上所有的框和图像中第一个框的交并比
       jaccard = jaccard_with_anchors(bbox)
       # Mask: check threshold + scores + no annotations + num_classes.
       ### 这个地方是帅选掉交并比小于0的
       mask = tf.greater(jaccard, feat_scores)
       # mask = tf.logical_and(mask, tf.greater(jaccard, matching_threshold))

       mask = tf.logical_and(mask, feat_scores > -0.5)
       mask = tf.logical_and(mask, label < num_classes)
       imask = tf.cast(mask, tf.int64)
       fmask = tf.cast(mask, dtype)
       # Update values using mask.
       feat_labels = imask * label + (1 - imask) * feat_labels
       ## tf.where表示如果mask为镇则jaccard,否则为feat_scores
       feat_scores = tf.where(mask, jaccard, feat_scores)
       ###
       feat_ymin = fmask * bbox[0] + (1 - fmask) * feat_ymin
       feat_xmin = fmask * bbox[1] + (1 - fmask) * feat_xmin
       feat_ymax = fmask * bbox[2] + (1 - fmask) * feat_ymax
       feat_xmax = fmask * bbox[3] + (1 - fmask) * feat_xmax

       # Check no annotation label: ignore these anchors...
       # interscts = intersection_with_anchors(bbox)
       # mask = tf.logical_and(interscts > ignore_threshold,
       #                       label == no_annotation_label)
       # # Replace scores by -1.
       # feat_scores = tf.where(mask, -tf.cast(mask, dtype), feat_scores)

       return [i+1, feat_labels, feat_scores,
               feat_ymin, feat_xmin, feat_ymax, feat_xmax]
   # Main loop definition.
   i = 0
   [i, feat_labels, feat_scores,
    feat_ymin, feat_xmin,
    feat_ymax, feat_xmax] = tf.while_loop(condition, body,
                                          [i, feat_labels, feat_scores,
                                           feat_ymin, feat_xmin,
                                           feat_ymax, feat_xmax])
   # Transform to center / size.
   feat_cy = (feat_ymax + feat_ymin) / 2.
   feat_cx = (feat_xmax + feat_xmin) / 2.
   feat_h = feat_ymax - feat_ymin
   feat_w = feat_xmax - feat_xmin
   # Encode features.
   ### prior_scaling=[0.1, 0.1, 0.2, 0.2]
   feat_cy = (feat_cy - yref) / href / prior_scaling[0]
   feat_cx = (feat_cx - xref) / wref / prior_scaling[1]
   feat_h = tf.log(feat_h / href) / prior_scaling[2]
   feat_w = tf.log(feat_w / wref) / prior_scaling[3]
   # Use SSD ordering: x / y / w / h instead of ours.
   feat_localizations = tf.stack([feat_cx, feat_cy, feat_w, feat_h], axis=-1)
   ## feat_labels 表示返回每个anchor对应的类别,feat_localizations返回的是一种变换,
   ## feat_scores 每个anchor与gt对应的最大的交并比
   return feat_labels, feat_localizations, feat_scores

看这部分写的有点不太好读,因为他是函数里面写函数,调用自己的函数,关键是他把自己写的函数放在中间,使得代码前面一半后面一半,中间是一些函数,不仔细往后看还以为结束了。
深度剖析SSD(你那些似懂非懂的地方)_第8张图片
开头是这样的,ymin,xmin,ymax,xmax之类的是把之前的坐标换成了左上角和右上角的坐标,方便求交并比,注意这个地方像y_ref之类的都是一个numpy数组,是整个特征图所以的中心点,所以这个地方相当于是numpy的广播性质,可不是一个框的操作,而是整个层的操作,shape是tensor的形状,feat_labels,feat_scores,feat_ymin这些是为了保存结果的,形状应该和我们框坐标之类的一样。
接下来,应该跳过那些函数,看后面的.
深度剖析SSD(你那些似懂非懂的地方)_第9张图片
当imask为1,那么就是label,否则label就是0,也就是背景,那imask什么时候为1,imask = tf.cast(mask, tf.int64),而mask又是大于feat_score的,所以这个地方因为是循环,遍历所有的目标,那么选择框的方式就是,选择交比比最大的,也就是某一个目标他对应的框里面,交并比最大的,这是一种策略,但是论文中还提到,高于0.5的我们也有对应的目标,但是代码没有这中策略,它只是选择了交并比最大的。feat_scores = tf.where(mask, jaccard, feat_scores),这个地方就是更新feat_scores,也就是体现是选择交并比最大的。
深度剖析SSD(你那些似懂非懂的地方)_第10张图片
其实和我们的Faster rcnn是一样的,是求真实框与anchor之间的变换,你把上面随便一个移项,就会得anchor经过伸缩变换得到真实的框,所以这个地方回归的是一种变换,因为实际我们的框是存在的,然后经过我们回归得到的变换,经过变换得到真实框,所以这个地方损失函数其实是我们预测的是变换,我们实际的框和anchor之间的变换和我们预测的变换之间的loss。我们回归的是一种变换。并不是直接预测框,这个和YOLO是不一样的。和Faster RCNN是一样的。
(2)损失函数
训练样本确定了,然后就是损失函数了。损失函数定义为位置误差(locatization loss, loc)与置信度误差(confidence loss, conf)的加权和:
深度剖析SSD(你那些似懂非懂的地方)_第11张图片
深度剖析SSD(你那些似懂非懂的地方)_第12张图片
这里为什么使用smoothL1呢?
深度剖析SSD(你那些似懂非懂的地方)_第13张图片

预测过程

预测过程比较简单,对于每个预测框,首先根据类别置信度确定其类别(置信度最大者)与置信度值,并过滤掉属于背景的预测框。然后根据置信度阈值(如0.5)过滤掉阈值较低的预测框。对于留下的预测框进行解码,根据先验框得到其真实的位置参数(解码后一般还需要做clip,防止预测框位置超出图片)。解码之后,一般需要根据置信度进行降序排列,然后仅保留top-k(如400)个预测框。最后就是进行NMS算法,过滤掉那些重叠度较大的预测框。最后剩余的预测框就是检测结果了。

参考文章:
https://zhuanlan.zhihu.com/p/33544892
https://blog.csdn.net/qq1483661204/article/details/79776065

你可能感兴趣的:(深度剖析SSD(你那些似懂非懂的地方))