今天总结一下学习的SSD(single shot detector)单发多框物体检测框架,总的来说物体检测处理流程和图像分类的总体流程差不多,只不过多了很多细节。
  总结一下,实现一个网络的基本流程:

  1. 首先需要定义整体网络中小的功能块,比如说ResNet中的残差小块,GoogLeNet中的inception等,一些可以抽象出来的功能块。
  2. 搞清楚每一层的输出形状,以及要进行的处理。比如说SSD中对每一层的输出特征图都要进行预测类别和预测边界框回归量
  3. 继承nn.Block定义一个完整的网络,重写实现其中的__init()__函数和forward()函数
  4. 获取用于训练的小批量数据
  5. 定义损失函数的计算,定义优化器
  6. 定义评估方法,如何进行模型评估
  7. 训练模型
    • 首先获取用于训练小批量数据
    • 前向运算,获得模型输出
    • 计算损失函数
    • 反向传播
    • 用优化器迭代更新参数
    • 输出模型训练信息,并且重复上述步骤

参考资料:动手深度学习

SSD简介

框架说明

  SSD是一种多尺度的目标检测模型,SSD主要由一个基础网络快和若干个多尺度特征块串联而成。

  • 基础网络块:一般选择常用的深度卷积神经网络,比如分类层之前截断的VGG,ResNet等,用来提取图像的特征。
  • 多尺度特征块,可以将上一层特征图的高和宽减小,增大每个像素的感受野,越靠近输出层的多尺度特征块输出的特征图越小,基于特征图生成的锚框数量也越小,更加适合检测尺寸较大的目标

SSD具体的检测过程如下图所示:

图片来自于《动手深度学习》

图片失效啦)


SSD中锚框的类别预测层

  SSD中对每个特征图中的锚框都要预测类别,如果物体类别数为$q$,那么SSD中预测的类别数应为$q+1$,其中0表示该锚框中只有背景。
  为了减少运算量,SSD中采用通道数来表示预测的类别,类别预测层的输出高和宽等于输入的高和宽,而通道数 = 每个像素生成的锚框个数(物体类别数+1),其中输入和输出在特征图在空间坐标上一一对应。比如说第$i$个锚框各个类别的预测分数在第 $(i-1)$\(物体类别数+1)到 $i$ * (物体类别数+1)通道之间。
  从论文中可以看出,预测所用的卷积核大小均为$3*3$,步幅为$1$,填充为$1$,保证输入和输出的大小一致
  使用MXNet实现类别预测层代码如下

1
2
def cls_predictors(num_anchors,num_classes):
return nn.Conv2D(channels=num_anchors*(num_classes+1),kernel_size=3,padding=1)


SSD中锚框的偏移量预测层

  SSD中对每个特征图中的锚框还需要预测四个偏移量,还是采用通道数来表示预测的偏移量,从论文中可以看出,偏移量预测层输出的通道数应为 $4$每个像素生成的锚框个数。其中第$i$个锚框预测的4个偏移量在 第$(i-1)4$ 到 $i*4$ 个通道中
  使用MXNet实现偏移量预测层代码如下

1
2
def bbox_predictors(num_anchors):
return nn.Conv2D(channels=num_anchors*4,kernel_size=3,padding=1)


SSD中高和宽减半模块

  由于SSD是多尺度的目标检测框架,所以高和宽减半模块主要是减小输入特征图的宽和高,增大每个像素的感受野,从而使越靠近输出层的特征图每个像素的感受野越大,从论文中可以看到,实现为128通道的$11$卷积,后面接上256通道的$33$卷积,并设置步幅为2以减小输入特征图的尺寸,不过这个网络我们也可以自己设计,只需要一步步增大特征图的感受野即可。下面我采用 128通道的$11$卷积,后面接上256通道的$33$卷积,最后加上$3*3$的MaxPooling,并设置步幅为2。
  使用MXNet实现宽和高减半层代码如下

1
2
3
4
5
6
7
8
9
def down_sample_blk(num_channels):
blk = nn.Sequential()
blk.add(nn.BatchNorm(),nn.Activation('relu'),
nn.Conv2D(channels=num_channels//2,kernel_size=1,strides=1),
nn.BatchNorm(),nn.Activation('relu'),
nn.Conv2D(channels=num_channels,kernel_size=3,strides=1,padding=1))

blk.add(nn.MaxPool2D(pool_size=2))
return blk


SSD中的基础网络

  SSD中的基础网络块用于提取图像特征,论文中采用的是VGG-16,并且将最后两个全连接层,换成了卷积层。通常情况下,基础网络输出的特征图相对来说较大,分辨率较高,所以通常在其上用来检测小物体。
  使用MXNet实现VGG代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def vgg_blk(num_channels):
blk = nn.Sequential()
for _ in range(2):
blk.add(nn.BatchNorm(),
nn.Activation('relu'),
nn.Conv2D(channels=num_channels,kernel_size=3,strides=1,padding=1))
blk.add(nn.BatchNorm(),nn.Activation('relu'),
nn.Conv2D(channels=num_channels,kernel_size=1,strides=1))
blk.add(nn.MaxPool2D(pool_size=2,strides=2))

return blk


def vgg_16():
vgg_16 = nn.Sequential()
conv = (32,64,128,256,512,512)
vgg_16.add(nn.BatchNorm(),nn.Activation('relu'),
nn.Conv2D(channels=conv[0],kernel_size=7,strides=1,padding=3),
nn.BatchNorm(),nn.Activation('relu'),
nn.Conv2D(channels=conv[0],kernel_size=3,strides=1,padding=1))
#vgg_16.add(nn.MaxPool2D(pool_size=2,strides=2))
for i in range(3):
vgg_16.add(vgg_blk(conv[i+1]))

#最后两层换成卷积
vgg_16.add(nn.BatchNorm(),nn.Activation('relu'),
nn.Conv2D(channels=conv[4],kernel_size=3,strides=1,padding=1),
nn.BatchNorm(),nn.Activation('relu'),
nn.Conv2D(channels=conv[5],kernel_size=1))

return vgg_16


连接各个层输入的函数

  以上SSD中的所有模块我们都已经构造完成了,由于每一层都会特征图都会输出预测的锚框类别,以及预测的锚框偏移量,我们需要将其拼接起来,以便后续的损失函数计算。我们直接将所有的预测结果拉成一个2D矩阵,其中第一维为批量大小,所有预测结果均在第二维进行拼接。
  拼接函数的代码如下所示

1
2
3
4
5
6
#把通道数换到最后
def flatten_pred(pred):
return pred.transpose((0,2,3,1)).flatten()

def concat_pred(preds):
return nd.concat(*[flatten_pred(pred) for pred in preds],dim=1)


定义SSD中的每一层前向运算函数

  SSD中每一层的特征图都要有三个输出,分别为①、默认锚框 形状为(1,锚框总数,4) ②、类别预测,形状为(批量大小,每个像素锚框数(总类别数+1),高,宽) ③、偏移量预测,形状为(批量大小,每个像素的锚框数4,高,宽)
  所以输入参数中应包括这一个特征图我们想生成锚框的大小比例,以及宽高比例,这是可以由我们自己指定的超参数,我们可以在前面的层生成比较多的小锚框,而在后面的层生成较少的大锚框。
  拼接函数的代码如下所示

1
2
3
4
5
6
7
8
9
10
def blk_forward(net,X,sizes,ratios,cls_predictor,bbox_predictor):
#首先计算这一层的输出
Y = net(X)
#生成默认锚框
anchors = contrib.nd.MultiBoxPrior(Y,sizes=sizes,ratios=ratios)
#进行预测
cls_preds = cls_predictor(Y)
bbox_preds = bbox_predictor(Y)
#输出生成的默认锚框,类别预测,偏移量预测
return anchors,cls_preds,bbox_preds

定义SSD完整模型

  定义SSD的结构为基础网络+3个宽高减半模块+全局最大池化层,最后将所有层的输出都通过上面定义的concat函数进行拼接,网络最后的输出为 生成的锚框类别预测偏移量预测
  SSD结构完整代码如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def get_blk(i):
if i==0:
return vgg_16()
elif i==4:
return nn.GlobalMaxPool2D()
else:
return down_sample_blk(256)

class SSD(nn.Block):
def __init__(self,num_classes,**kwargs):
super(SSD,self).__init__(**kwargs)
self.num_classes = num_classes #需要预测的总类别数
self.sizes = [[0.2, 0.272], [0.37, 0.447], [0.54, 0.619], [0.71, 0.79],
[0.88, 0.961]]
self.ratios = [[0.35, 1.5, 0.75]] * 5 #超参数,每一层我们需要生成的锚框宽高比和大小比例
#下面定义每一层的网络
for i in range(5):
#这一层的每个像素的锚框个数
num_anchors = len(self.sizes[i])+len(self.ratios[i])-1
#卷积层
setattr(self,'blk_%d' % i,get_blk(i))
#类别预测
setattr(self,'cls_pred_%d' % i,cls_predictor(num_anchors,num_classes))
#锚框偏移量预测
setattr(self,'bbox_pred_%d' % i ,bbox_predictor(num_anchors))

def forward(self,X):
#前向运算函数
anchors,cls_preds,bbox_preds= [],[],[]

for i in range(5):
#进行一次前向运算
X,anchor,cls_pred,bbox_pred = blk_forward(getattr(self,'blk_%d' % i),X,self.sizes[i],self.ratios[i],
getattr(self,'cls_pred_%d' % i),
getattr(self,'bbox_pred_%d' % i))
#将输出追加到结果中
anchors.append(anchor)
cls_preds.append(cls_pred)
bbox_preds.append(bbox_pred)
#最后将结果输出
return(nd.concat(*anchors,dim=1),
concat_pred(cls_preds).reshape((0,-1,self.num_classes+1)),
concat_pred(bbox_preds))


定义损失函数

  • 平滑$L_1$范数损失函数
      SSD中将$L_1$范数损失函数替换成为平滑$L_1$范数损失函数。后者在零点附近做了平滑处理,其中可以通过设置超参数$\sigma$来控制平滑的区域

图片失效啦)

  • 焦点损失函数

  其中类别预测中,使用了焦点损失:设真实类别$j$的预测概率是$p_j$,交叉熵损失为$- \log p_j$。那么给定超参数$\gamma$、$\alpha$,该损失的定义为:

图片失效啦)

可以通过增大$\gamma$来有效的减小正类预测概率较大时候的损失。

  我们可以通过继承gluon.loss.Loss类来重写损失函数,只需要重写其中的__init()__,以及hybrid_forward方法即可,MXNet可以帮我们实现自动求导

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class smooth_L1Loss(gloss.Loss):
def __init__(self,sigma=0.5,weight=None,batch_axis=0,data_axis=-1,**kwargs):
super(smooth_L1Loss,self).__init__(weight,batch_axis,**kwargs)
self.axis = data_axis
self._sigma = sigma

#loss里面有很多函数可以自己来定义loss
def hybrid_forward(self,F,pred,label,sample_weight=None):
loss = F.smooth_l1((pred-label),scalar=self._sigma)
loss = gluon.loss._apply_weighting(F,loss,self._weight,sample_weight)
return F.mean(loss,axis = self._batch_axis,exclude =True)

#######################################################
class focal_SoftMaxCrossEntropy(gloss.Loss):
def __init__(self,gamma=2.0,data_axis=-1,batch_axis=0,
sparse_label = True,from_logits = False,eps =1e-5,weight=None,**kwargs):
super(focal_SoftMaxCrossEntropy,self).__init__(weight,batch_axis,**kwargs)
self._gamma = 2.0
self._axis = data_axis
self._sparse_label = sparse_label
self._from_logits = from_logits
self._eps = eps

def hybrid_forward(self,F,pred,label,smaple_weight = None):
if not self._from_logits:
#计算一次softmax
pred = F.softmax(pred,axis = self._axis)
if self._sparse_label:
#这里keep_dim是为了后面与 smaple_weight相乘
pred = nd.pick(pred,label,axis=self._axis,keepdims=True)
loss = -((1-pred)**self._gamma)*F.log(pred+self._eps)
loss = gluon.loss._apply_weighting(F,loss,self._weight,smaple_weight)
return F.mean(loss,axis = self._batch_axis,exclude=True)

定义优化器、以及评价函数

  优化器直接使用sgd优化器,评价函数直接使用$L1$损失函数即可

1
2
3
4
5
6
7
8
tiny_SSD.initialize(init = init.Xavier(),ctx=ctx)
trainer = gluon.Trainer(tiny_SSD.collect_params(),'sgd',{'learning_rate':0.2,'wd':5e-4})

def cls_eval(cls_pred,cls_label):
return (cls_pred.argmax(axis=-1)==cls_label).mean().asscalar()

def bbox_eval(bbox_pred,bbox_label,bbox_mask):
return (bbox_pred*bbox_mask-bbox_label*bbox_mask).abs().mean().asscalar()

获取训练数据,训练模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def train(num_epoches):
for epoch in range(num_epoches):
start = time.time()
train_cls_acc = 0
train_bbox_loss = 0
train_iter.reset()

for i,batch in enumerate(train_iter):
#获取小批量数据
X = batch.data[0].as_in_context(ctx)
Y = batch.label[0].as_in_context(ctx)
with autograd.record():
#前向运算
anchors,cls_preds,bbox_preds = tiny_SSD(X)
#标记锚框获得标签,这里还可以设置负采样
bbox_labels,bbox_masks,cls_labels= contrib.nd.MultiBoxTarget(anchors,Y,cls_preds.transpose((0,2,1)))
#计算损失
l_cls = focal_loss(cls_preds,cls_labels)
l_bbox = smooth_L1(bbox_preds*bbox_masks,bbox_labels*bbox_masks)
l_total = l_cls+l_bbox
#反向传播
l_total.backward()
#迭代参数
trainer.step(batch_size)
train_cls_acc += cls_eval(cls_preds,cls_labels)
train_bbox_loss += bbox_eval(bbox_preds,bbox_labels,bbox_masks)
#训练完epoch输出结果
print('epoch %2d , train_cls_acc %.2f , bbox mae %.2e , time %.1f sec' %
(epoch+1,train_cls_acc/(i+1),train_bbox_loss/(i+1),time.time()-start))