这个文章主要是讲一些ssd里面的代码实现和一点点自己的理解,对于ssd算法的一些基础请转到我的另一个博客
参考了一些网上的SSD的实现,现在对其进行tensorflow的实现讲解,我将一行一行的讲解实现过程。附一个我写的SSD算法的详解链接

一、Backbone函数(基于VGG16)

根据paper里面提到的一些基于vgg16的改动。与Yolo最后采用全连接层不同,SSD直接采用卷积对不同的特征图来进行提取检测结果。对于形状为 mxnxp 的特征图,只需要采用 3x3xp 这样比较小的卷积核得到检测值。

    #定义一些卷积等函数
    def conv2d(self,x,filter,k_size,stride=[1,1],padding='same',dilation=[1,1],activation=tf.nn.relu,scope='conv2d'):
        return tf.layers.conv2d(inputs=x, filters=filter, kernel_size=k_size,
                    strides=stride, dilation_rate=dilation, padding=padding,
                    name=scope, activation=activation)

    def max_pool2d(self,x, pool_size, stride, scope='max_pool2d'):
        return tf.layers.max_pooling2d(inputs=x, pool_size=pool_size, strides=stride, name=scope, padding='same')

    def pad2d(self,x, pad):
        return tf.pad(x, paddings=[[0, 0], [pad, pad], [pad, pad], [0, 0]])

    def dropout(self,x, d_rate=0.5):
        return tf.layers.dropout(inputs=x, rate=d_rate)



    # 基于vgg16函数的backbone
    #b1
    net = self.conv2d(x,filter=64,k_size=[3,3],scope='conv1_1')
    net = self.conv2d(net,64,[3,3],scope='conv1_2')
    net = self.max_pool2d(net,pool_size=[2,2],stride=[2,2],scope='pool1')
    #b2
    net = self.conv2d(net, filter=128, k_size=[3, 3], scope='conv2_1')
    net = self.conv2d(net, 128, [3, 3], scope='conv2_2')
    net = self.max_pool2d(net, pool_size=[2, 2], stride=[2, 2], scope='pool2')
    #b3
    net = self.conv2d(net, filter=256, k_size=[3, 3], scope='conv3_1')
    net = self.conv2d(net, 256, [3, 3], scope='conv3_2')
    net = self.conv2d(net, 256, [3, 3], scope='conv3_3')
    net = self.max_pool2d(net, pool_size=[2, 2], stride=[2, 2], scope='pool3')
    #b4
    net = self.conv2d(net, filter=512, k_size=[3, 3], scope='conv4_1')
    net = self.conv2d(net, 512, [3, 3], scope='conv4_2')
    net = self.conv2d(net, 512, [3, 3], scope='conv4_3')
    check_points['block4'] = net
    net = self.max_pool2d(net, pool_size=[2, 2], stride=[2, 2], scope='pool4')
    #b5
    net = self.conv2d(net, filter=512, k_size=[3, 3], scope='conv5_1')
    net = self.conv2d(net, 512, [3, 3], scope='conv5_2')
    net = self.conv2d(net, 512, [3, 3], scope='conv5_3')
    net = self.max_pool2d(net, pool_size=[3, 3], stride=[1, 1], scope='pool4')
    #b6
    net = self.conv2d(net,1024,[3,3],dilation=[6,6],scope='conv6')
    #b7
    net = self.conv2d(net,1024,[1,1],scope='conv7')
    check_points['block7'] = net
    #b8
    net = self.conv2d(net,256,[1,1],scope='conv8_1x1')
    net = self.conv2d(self.pad2d(net,1),512,[3,3],[2,2],scope='conv8_3x3',padding='valid')
    check_points['block8'] = net
    #b9
    net = self.conv2d(net, 128, [1, 1], scope='conv9_1x1')
    net = self.conv2d(self.pad2d(net,1), 256, [3, 3], [2, 2], scope='conv9_3x3', padding='valid')
    check_points['block9'] = net
    #b10
    net = self.conv2d(net, 128, [1, 1], scope='conv10_1x1')
    net = self.conv2d(net, 256, [3, 3], scope='conv10_3x3', padding='valid')
    check_points['block10'] = net
    #b11
    net = self.conv2d(net, 128, [1, 1], scope='conv11_1x1')
    net = self.conv2d(net, 256, [3, 3], scope='conv11_3x3', padding='valid')
    check_points['block11'] = net

上面的backbone我们建立了一个基础网络。

二、分类和回归网络结构

SSD的检测值也与Yolo不太一样。对于每个单元的每个先验框,其都输出一套独立的检测值(见下面的代码里面的位置预测和类别预测的conv的输出的channel),对应一个边界框,主要分为两个部分。第一部分是各个类别的置信度或者评分,值得注意的是SSD将背景也当做了一个特殊的类别,如果检测目标共有 c 个类别,SSD其实需要预测 c+1 个置信度值,其中第一个置信度指的是不含目标或者属于背景的评分。

   #定义一个SSD预测分类和回归坐标的函数,用于对每个feature map的box进行分类和回归
	def ssd_prediction(self, x, num_classes, box_num, isL2norm, scope='multibox'):
	    reshape = [-1] + x.get_shape().as_list()[1:-1]  # 去除第一个和最后一个得到shape
	    with tf.variable_scope(scope):
	        if isL2norm:
	            x = self.l2norm(x)
	            print(x)
	        # #预测位置  ----->>>> 坐标和大小  回归
	        location_pred = self.conv2d(x, filter=box_num * 4, k_size=[3,3], activation=None,scope='conv_loc')
	        location_pred = tf.reshape(location_pred, reshape + [box_num, 4])
	        # 预测类别   ----->>>> 分类 sofrmax
	        class_pred = self.conv2d(x, filter=box_num * num_classes, k_size=[3,3], activation=None, scope='conv_cls')
	        class_pred = tf.reshape(class_pred, reshape + [box_num, num_classes])
	        return location_pred, class_pred

三、生成default box

在这里插入图片描述
在上图中我们可以看到对于待预测的6个feature map,每个层都会生成对应个数的default box,那么这个box是如何生成的呢?接下来的代码会具体讲解。
对于产生的正方形的默认框,一大一小共两个,其边长计算公式为:小边长=min_size,而大边长=sqrt(min_size*max_size)。对于产生的长方形默认框,我们需要计算它的高(height)和宽(width),其中,height=1/sqrt(aspect_ratio)*min_size,width=sqrt(aspect_ratio)*min_size,对其高和宽翻转后得到另一个面积相同但宽高相互置换的长方形。如图所示:
在这里插入图片描述

  anchor_sizes = [[21., 45.], [45., 99.], [99., 153.],[153., 207.],[207., 261.], [261., 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]
  prior_scaling = [0.1, 0.1, 0.2, 0.2] #特征图先验框缩放比例
  n_boxes = [5776,2166,600,150,36,4]  #8732个
  
  def ssd_anchor_layer(self,img_size,feature_map_size,anchor_size,anchor_ratio,anchor_step,box_num,offset=0.5):
      #获取feature map的坐标
      y,x = np.mgrid[0:feature_map_size[0],0:feature_map_size[1]]
      
      #论文中提到每一个default的中心坐标设置为((i+0.5)/abs(f),(j+0.5)/abs(f)),f为每一个feature map的大小
      y = (y.astype(np.float32) + offset) * anchor_step /img_size[0]
      x = (x.astype(np.float32) + offset) * anchor_step /img_size[1]

      y = np.expand_dims(y,axis=-1)
      x = np.expand_dims(x,axis=-1)
      #计算两个长宽比为1的h、w

      h = np.zeros((box_num,),np.float32)
      w = np.zeros((box_num,),np.float32)
      
      #第一个默认框(小正方形的计算公式:h=w=min_size)
      h[0] = anchor_size[0] /img_size[0]
      w[0] = anchor_size[0] /img_size[0]
      
      #第二个默认框(大正方形的计算公式:h=w=sart(max_siez*min_size))
      h[1] = (anchor_size[0] * anchor_size[1]) ** 0.5 / img_size[0]
      w[1] = (anchor_size[0] * anchor_size[1]) ** 0.5 / img_size[1]

      # paper里面的公式:w=s*sqrt(a), h=s/sqrt(a)
      for i,j in enumerate(anchor_ratio):
          h[i + 2] = anchor_size[0] / img_size[0] / (j ** 0.5)
          w[i + 2] = anchor_size[0] / img_size[1] * (j ** 0.5)
      return y,x,h,w

四、解码过程

在SSD中,通俗的说就是先产生一些预选的default box(类似于anchor box),然后标签是 ground truth box,预测的是bounding box,现在有三种框,从default box到ground truth有个变换关系,从default box到prediction bounding box有一个变换关系,如果这两个变换关系是相同的,那么就会导致 prediction bounding box 与 ground truth重合,如下图:
在这里插入图片描述
所以回归的就是这两个 变换关系: l∗ 与 g∗ ,只要二者接近,就可以使prediction bounding box接近 ground truth box 。上面的 g∗ 是只在训练的时候才有的,inference 时,就只有 l∗ 了,但是这时候的 l∗ 已经相当精确了,所以就可以产生比较准确的定位效果。

现在的问题是生成的 default box (下面讲怎么生成)是有很多的,那么势必会导致只有少部分是包含目标或者是与目标重叠关系比较大的,那当然只有这以少部分才是我们的重点观察对象,我们才能把他用到上述提到的回归过程中去。因为越靠近标签的default box回归的时候越容易,如果二者一个在最上边,一个在最下边,那么回归的时候难度会相当大,而且也会更耗时间。

我们在上面定义了ssd的分类预测和回归预测两个函数,同时也生成了8732个默认的box,下面就是如何将default box和预测的offset结合映射回原图。
计算公式:
bcx = dw*(lcx * k1) + dcx
即:预测的x坐标=先验框的w x (预测的x * 缩放比例) + 先验框的x
bcy = dh*(lcy * k2) + dcy
bw = dw * exp(lw * k3)
bh = dh * exp(lh * k4)
到这里看不懂没关系,我们先看decode的代码,在代码之后会详细的讲这个变换。

def ssd_decode(self,location,box,prior_scaling):
    y_a, x_a, h_a, w_a = box
   
     #计算公式在上面进行了详细的解释
    cx = location[:, :, :, :, 0] * w_a * prior_scaling[0] + x_a  #########################
    cy = location[:, :, :, :, 1] * h_a * prior_scaling[1] + y_a
    w = w_a * tf.exp(location[:, :, :, :, 2] * prior_scaling[2])
    h = h_a * tf.exp(location[:, :, :, :, 3] * prior_scaling[3])
    
    bboxes = tf.stack([cy - h / 2.0, cx - w / 2.0, cy + h / 2.0, cx + w / 2.0], axis=-1)

    return bboxes

边界框的location,包含4个值 (cx, cy, w, h) ,分别表示边界框的中心坐标以及宽高。但是真实预测值其实只是边界框相对于先验框的转换值(paper里面说是offset,但是觉得transformation更合适,参见mask-RCNN)。d先验框位置用:
在这里插入图片描述
表示,其对应边界框用 :
在这里插入图片描述
表示,那么边界框的预测值 l 其实是 b 相对于 d 的转换值:
在这里插入图片描述
习惯上,我们称上面这个过程为边界框的编码(encode),预测时,你需要反向这个过程,即进行解码(decode),从预测值 l 中得到边界框的真实位置 b :
在这里插入图片描述
然而,在SSD的Caffe源码实现中还有trick,那就是设置variance超参数来调整检测值,通过bool参数variance_encoded_in_target来控制两种模式,当其为True时,表示variance被包含在预测值中,就是上面那种情况。但是如果是False(大部分采用这种方式,训练更容易?),就需要手动设置超参数variance,用来对 l 的4个值进行放缩(这也就是上面提到的prior_scaling),此时边界框需要这样解码:
在这里插入图片描述
参考:https://zhuanlan.zhihu.com/p/33544892

GitHub 加速计划 / te / tensorflow
184.54 K
74.12 K
下载
一个面向所有人的开源机器学习框架
最近提交(Master分支:26 天前 )
a49e66f2 PiperOrigin-RevId: 663726708 1 个月前
91dac11a This test overrides disabled_backends, dropping the default value in the process. PiperOrigin-RevId: 663711155 1 个月前
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐