一、SSD原理
本文记录一阶段目标检测模型SSD的学习笔记及代码复现过程。首先个人感觉SSD和YOLO相比稍微简单一些,理论比较直白,不过实现起来依然比较繁琐。本文大体以李沐老师的Gluon教学视频7-9为基础,在Anchors sampling和Training Targets两个点上加上了一些自己的挖掘。
根据李沐老师的说法,SSD的算法可以看做Faster-RCNN的一个简化,具体说来就是直接把RPN中那个负责Region Proposal的二分类Softmax扩展了一下,区分出背景的同时将前景细分成物体的类别,即扩展成了n+1分类,同时bbox regressor一步回归到位。这样理论上就足够得到box和类别信息了。不过为了提高准确性,SSD在此基础上又加上多尺度预测,具体来说就是将主干网络输出的特征通过downsampling Conv降低一半再预测cls和box,以此类推。
看到这里,一个必须要想清楚的问题是:尺寸较小的feature map负责提高大目标还是小目标的精确度?
这个问题其实本质是对anchor概念的理解。
1.1 Anchors
关于Anchors的概念,等到写Faster-RCNN时再详细记录。这里先简单理解,就是在特征图的每个像素点上采样一组预定义尺寸和形状的先验框,然后希望这些所有尺度的先验框能够覆盖图像中所有可能的boundingbox位置。
当然,anchors的巧妙之处远不止这些。比如anchor的引入使我们得以避开了编码boudingbox规则这一难题,见[1]。
上图回答了1.1的问题。每一组anchors都是以feature map中的像素为中心定义的,故anchors的尺寸和像素之间的距离——grid的尺寸成正比,即和feature map的尺寸成反比。所以大的feature map负责检测小物体,小的feature map负责检测大物体。下面的Anchors Sampling代码实现部分也有类似的可视化结果。
这里有个小问题:上面说的小的feature map负责检测大物体是因为anchor占feature的比例很大,这个说法应该是在我们默认深度神经网络feature map越小,receptive field越大这一前提下?我们假设小的feature map的感受野已经足够覆盖原图大部分区域?
二、各部件实现:
由于SSD原来相对简单,这一部分以理论和代码块实现相结合的形式展开记录。基本的组件有如下几块:
- Anchors Sampling.
- Class Predictor.
- Bbox Predictor.
- Downsample Block.
- 整合得到完整的SSD模型.
- 数据集加载(有时间会记录rec文件生成方法).
- Training Targets.
- 优化:Focal Loss与Negative example mining.
2.1 Anchors Sampling
原始图像送入主干网络之后,得到一个feature map,如 。现在我们需要在该feature的每一个像素点(x, y)上sample出多个预先定义的anchor。这一点和RPN是一致的。不过有个细节,SSD的anchor不像Faster-RCNN那样,指定了一组size和一组宽高比,最后的anchor数就是size的个数n乘以ratio的个数m;SSD简化了下,将anchor总数改成 。
规则见下图:简单的说就是宽高比不变时,只使用第一个ratio(通常为1),得到几个不同大小的正方形;而size不变时,只使用第一个size,得到几个相同size不同宽高比的anchor,这两种情况会有一个anchor取了两次(第一个size和第一个ratio对应anchor),去掉一个即可。
虽然我们可以像课程中那样,用contrib.ndarray中的MultiBoxPrior来sample anchors, 并且实际模型训练中肯定也会使用这个函数以获取最佳优化效果和backward能力,但是在此之前有必要先用Python简单实现下,以加深理解。简单起见,这里就直接用Numpy和list,不用NDArray了。因为NDArray对于list的支持不是很友好。
中间的探索过程比较长,就不列了,刚兴趣的同学可以参考此notebook。下面直接上代码:
import numpy as np
import time
import matplotlib.pyplot as plt
from mxnet import nd, autograd
from mxnet.contrib.ndarray import MultiBoxPrior
def sample_anchor(area, ratio, center_coor, input_shape):
"""
实现:给定anchor的面积和宽高比,以及中心坐标,求出anchor的box
area:anchor的绝对面积(size_ratio * size_input)
ratio:单个宽高比
center_coor:tuple: (x, y)
input_shape: tuple: (h, w, c)
输出:单个长度为4的list
"""
x, y = center_coor
H, W = input_shape[:2]
dh = np.sqrt(area/ratio)
dw = dh * ratio
# 返回对输入feature尺寸归一化之后的anchor坐标
anchor = [(x-dw/2)/W, (y-dh/2)/H, (x+dw/2)/W, (y+dh/2)/H]
return anchor
def sample_one_pixel(sizes, ratios, center_coor, input_shape):
"""
实现:给定sizes和ratios两个list以及输入图像宽高,在单个坐标点上sample anchors.
sizes: list of size_ratios, 为了便于计算,首先转换为list_of_absolute_areas
输出:lists of lists, n+m-1个对输入尺寸归一化过的输出anchor,每个anchor为四维list
"""
H, W = input_shape[:2]
areas = [size**2 * H*W for size in sizes] #获取绝对areas
anchors = []
for a in areas:
anchors.append(sample_anchor(a, ratios[0], center_coor, input_shape))
for r in ratios[1:]:
anchors.append(sample_anchor(areas[0], r, center_coor, input_shape))
return np.array(anchors) # .reshape(-1, 4) # 这里发现lists of lists 直接变array不需要reshape。但是NDArray不支持类似的模糊转换
def sample_one_feature(feature, sizes, ratios):
input_shape = feature.shape
H, W = input_shape[:2]
n, m = len(sizes), len(ratios)
anchors = np.zeros((H, W, n+m-1, 4))
for h in range(H):
for w in range(W):
# 这里实现需要的组织方式
anchors[h,w,:,:]=sample_one_pixel(sizes, ratios, (w,h), input_shape)
return anchors
def draw_one_pixel(feature, center_coor, anchors):
colors = ['blue', 'green', 'red', 'black', 'magenta']
x, y = center_coor
feature_display = np.array([feature[:,:,0]]*3).transpose((1,2,0))
plt.imshow(np.ones_like(feature_display)*255)
anchors_this_pixel = anchors[y,x,:,:]
for i, anchor in enumerate(anchors_this_pixel):
plt.gca().add_patch(plt.Rectangle((anchor[0]*n, anchor[1]*n),
(anchor[2]-anchor[0])*n, (anchor[3]-anchor[1])*n, fill=False, color=colors[i]))
plt.grid()
plt.show()
if __name__ == "__main__":
sizes = [.5, .25, .1]
ratios = [1, 2., .5]
plt.subplot(121)
n = 10
feature = np.arange(n*n*6).reshape(n, n, -1)
print(feature.shape)
anchors = sample_one_feature(feature, sizes, ratios)
print(anchors.shape)
draw_one_pixel(feature, (4, 4), anchors)
plt.subplot(122)
n = 40
feature = np.arange(n*n*6).reshape(n, n, -1)
print(feature.shape)
anchors = sample_one_feature(feature, sizes, ratios)
print(anchors.shape)
draw_one_pixel(feature, (16, 16), anchors)
注意:上面的可视化再次显示出,anchor的尺寸是随着grid的尺寸成比例变化的。即,feature map越小,gird越少,anchor覆盖面积越大。进一步,小feature map是为了检测大目标,而大的feature map是为了检测小目标。
现在再来熟悉下Mxnet中C++实现的MultiBoxPrior接口:
可见MultiBoxPrior的输出已经被reshape过了,维度为(batchSize, total_num_anchors, 4)
另外这里有一个重要的问题待后续解决:sizes如何预定义。YOLO中的Anchor直接通过cluster获得,SSD呢?
2.2 预测物体类别——class_predictor
SSD的一个重要特点就是,其在Faster-RCNN RPN的基础上,将二分类的Softmax直接扩展为n+1分类,其中n代表类别,1代表是否包含物体。这里cls_predictor就是将每个scale的feature送入一个Conv layer,该Conv要保证输出的特征通道数为num_anchors*(num_classes+1),即每个通道对应一个anchor对 某个类别的置信度。具体见下面描述:
- 注意,这里存放class_probabilities的形式和YOLO稍有不同,YOLO是直接将每个grid cell用FC回归到指定长度向量,向量每个位置代表啥都是很清楚的。SSD由于不使用FC回归,而是用"same" conv,因此得到的不是一个长向量,而是一个维度为[batchSize, (numClasses+1) * numAnchors, h_feature, w_feature]的特征图。其中的含义如下:每一个h_feature x w_feature大小的通道存放了所有像素点的某一个anchor对某个类的预测概率。所以所有的class probabilities总共有h_feature x w_feature x (numClasses+1) * numAnchors个数值。
from mxnet.gluon import nn
import mxnet as mx
def cls_predictor(num_anchors, num_classes):
return nn.Conv2D(num_anchors*(num_classes+1), 3, 1, 1)
2.3 预测边界框——box_predictor
同理,将每个scale的feature送入一个新的Conv layer,该Conv只需要保证输出channel数为num_anchors * 4.
def box_predictor(num_anchors):
return nn.Conv2D(num_anchors*4, 3, 1, 1)
2.4 减半(下采样)模块
接下来定义减半模块。这里直接通过两个'same'卷积后接一个MaxPooling实现. 这种连续多个conv组成的小block中有一个小细节: 往往最后一个Conv负责输出指定的通道数, 也就是说前面几个Conv其实通道数是可以改变的。这时候一个选项就是把前面第一个卷即块先通过1x1 Conv减少通道数,然后接下来的Conv都使用这个小通道数, 操作到最后一个Conv时再把通道数升上来, 这样相比一开始的Conv就带着目标通道数进行一系列连续运算, 参数量可以减少一些. 这个就是Residual Block Bottleneck的想法. 不过这里先按教程中的做法来搭建.
def downsample_block(channels):
block = nn.HybridSequential()
for _ in range(2):
block.add(
nn.Conv2D(channels, 3, 1, 1),
nn.BatchNorm(),
nn.Activation('relu')
)
block.add(nn.MaxPool2D(strides=2))
return block
定义好了三种基本网络块, 现在有两个很自然的问题:
①这三个模块之间是如何连接的?
②不同scale上的两个Conv head的输出应该如何组合到一起, 得到最后的, 要送入loss函数的形式?问题答案如下:
主干网络和若干downsample block是首尾相连的,然后每个scale上的Conv heads(box_predictor和cls_predictor)均与主干网络(或downsample)在当前尺度下的输出特征图相连接,这些Conv heads之间没有连接。在模型前向传播过程中,每个scale经过Conv heads之后,会得到两个代表anchors的class probability和box的tensor,我们需要将每个scale的这两个tensor分别(按某种维度)Concat到一起,最终得到两个较大的tensor,分别代表多尺度anchors的class probability和box信息,最后我们将这两个合并之后的tensor送入loss函数。
SSD中会在多个尺度上进行预测。由于每个尺度上的输入高宽和锚框的选取不一样,导致其形状各不相同。下面例子中我们构造两个尺度的输入,其中第二个为第一个的高宽减半。然后构造两个类别预测层,其分别对每个输入像素构造5个和3个锚框。
可见, 预测的输出格式为(批量大小,通道数,高,宽)。可以看到除了批量大小外,其他维度大小均不一样。因此Concat时可以利用的维度就只有第一个维度. 故我们需要考虑如何将它们变形成统一的格式, 进而将多尺度的输出合并起来,让后续的处理变得简单。
我们首先将通道,即预测结果,放到最后(这里是因为通常分类任务习惯将class probability放到最后一维,这里不放也没关系)。其实这里我不知道为啥要调整下把通道放最后一维(可能是为了适应其他接口?),根据2.2部分下面的分析,除了第一维代表batchSize之外,其他所有维度乘起来代表的就是总共的class probabilities。因为不同尺度下批量大小保持不变,所以将结果转成二维的(批量大小,高 × 宽 × 通道数)格式,方便之后的拼接。
然后将多个尺度的preds按最后一个维度拼接就可以了.
def flatten_pred(pred):
return pred.transpose(axes=(0, 2, 3, 1)).flatten()
def concat_preds(preds_multi_scales, F=mx.ndarray):
return F.concat(*[flatten_pred(pred) for pred in preds_multi_scales], dim=1)
## check
concat_preds([y1, y2]).shape
[Out]: (2, 25300)
2.5 完整模型
2.5.1 定义主干网络
这里先用一个简单的主干网络与上述部件结合起来, 搭建一个完整的ToySSD模型. 后面的实验中可以再尝试不同的主干网络, 如ResNet50.
直接用连续三个下采样模块构成主干网络, 实现对输入图像进行8倍下采样:
def body_block(channel_list=[16, 32, 64]):
net = nn.HybridSequential()
for channels in channel_list:
net.add(downsample_block(channels))
return net
2.5.2 一个"容器"模型
在2.4中我们已经讨论过模型各个部件的关系了。为了代码简洁,我们定义一个额外的函数将所有组件放到一个Block(或者直接放到一个list中)。注意:虽然这个Block是一个单独的HybridBlock对象,但是和之前定义的完整网络不一样,该对象只是一个容器将所有组件存放起来,看做list即可(之所以可以当list使用,是利用HybridSequential可以直接unpack的特点)
对比SSD和前面练习的分类网络, 一个有趣的地方是, 分类任务通常可以一气呵成地定义一个完整模型, 然后前向传播直接pred = model(X)
; 而SSD并不是一个传统的前后相连的网络模型, 而是由几个相对独立的部件构成, 理论上我们只需要先把不同的组件放到同一个容器中保存, 然后前向传播时在各个尺度分别输出预测结果。 换句话说, 其实我们并没有"定义"模型, 只是定义了部件, 前向传播过程需要我们具体定义这些部件功能。
理论上这个用于存储各部件的容器是很宽泛的, 比如我们可以用list或者tuple, 只要到时候能够unpack取出各个组件进行前向传播就可以了. 不过一个实际问题在于, 如果用这种方式, 返回的代表模型的"容器"只能在unpack之后对每个部件分别初始化, 显然会使代码显得比较繁冗. 故我当时放弃了下面这种写法, 尽管这种写法更能体现出SSD这种松散的结构特点:
经过上面分析, 我们可以把各个部件放到同一个HybridSequential对象中, 尽管这些部件之间的连接看起来是序贯连接(依次放入了model.add函数作为参数), 实际上这个model只是一个容器而已. 其优势在于: ①可以像一般容器一样unpack; ②初始化时只需要调用model.initialize()即可初始化所有部件.
def ssd_model(num_anchors, num_classes):
downsamplers, cls_predictors, box_predictors = nn.HybridSequential(), nn.HybridSequential(), nn.HybridSequential()
for _ in range(3):
downsamplers.add(downsample_block(128))
for _ in range(5):
cls_predictors.add(cls_predictor(num_anchors, num_classes))
box_predictors.add(box_predictor(num_anchors))
model = nn.HybridSequential()
model.add(body_block([16, 32, 64]), cls_predictors, box_predictors, downsamplers)
return model
这种网络组织的方式非常有趣, 但是仔细一想又很自然, 因为HybridSequential对象本身就支持索引, 有一些list的特性, 故可以unpack出各个组件也是容易理解的。我们可以通过下面的验证进一步确定这一点:
t_model = ssd_model(5, 2)
print(len(t_model))
[Out]: 4
2.5.3 定义前向传播
由于反向传播不需要我们考虑, 模型的前向传播就成为整个网络最重要的部分。我们需要将前面定义的部件都整合起来。
该前向传播函数需要接收的输入: ①上面的"容器"模型; ②输入图像矩阵; ③sizes和ratios, 用于逐个scale中sample anchors, 而sample出的anchor用于和当前batch的labels比较IOU, 进而生成当前batch的training targets(sample anchor应该在前向传播中完成, training_targets的获取可以放在后面就行)。
该前向传播函数的输出: ①所有scale的anchors②所有scale的cls_preds③所有scale的box_preds。
根据接口的输入输出容易定义出如下ssd_forward函数:
from mxnet.contrib.ndarray import MultiBoxPrior
def ssd_forward(model, x, sizes, ratios, F=mx.ndarray, verbose=True):
# !!注意别写成 backbone, cls_predictors, box_predictors, downsamplers = ssd_model(5, 2)了...
backbone, cls_predictors, box_predictors, downsamplers = model
x = backbone(x)
anchors, cls_preds, box_preds = [], [], []
#anchors, cls_preds, box_preds = [None]*5, [None]*5, [None]*5
for i in range(5):
# MultiBoxPrior: (batch_size, num_total_anchors_this_scale, 4)
anchors.append(MultiBoxPrior(x, sizes[i], ratios[i]))
cls_preds.append(cls_predictors[i](x))
box_preds.append(box_predictors[i](x))
if verbose:
print('Predict scale {}, {} with {} anchors.'.format(i, x.shape, anchors[-1].shape[1]))
if i < 3:
x = downsamplers[i](x)
elif i == 3:
x = nn.GlobalMaxPool2D()(x)
## i=4时用的是GAP之后的feature,到这里就不用管了,所以没有else
# 注意concat anchors时,将各个尺度的anchors按num_anchors那个维度拼接。得到的结果是(batchSize, num_total_anchors, 4)
return (F.concat(*anchors, dim=1), concat_preds(cls_preds, F), concat_preds(box_preds, F))
- 注意: 这个函数中我设置了一个参数F, 是希望将nd的操作扩展为Symble和NDArray公有的操作, 从而在模型定义完, 调试无误之后使用model.hybridize()接口。
- 进一步, 将ssd_model和ssd_forward封装到一个类中:
class ToySSD(nn.HybridBlock):
def __init__(self, num_classes, **kwags):
super(ToySSD, self).__init__(**kwags)
# 思考:sizes是怎么得到的??
self.sizes = [[0.2, 0.272], [0.37, 0.447], [0.54, 0.619],
[0.71, 0.79], [0.88, 0.961]]
self.ratios = [[1, 2, 0.5]] * 5
self.num_classes = num_classes
num_anchors = len(self.sizes[0]) + len(self.ratios[0]) - 1
with self.name_scope():
self.model = ssd_model(num_anchors, num_classes)
def hybrid_forward(self, F, x):
anchors, cls_preds, box_preds = ssd_forward(self.model, x, self.sizes, self.ratios, F, verbose=False)
# 这里还需要额外的一步:将cls_preds整理成最后一个维度代表probability的形式。这样维度变成:
# (batchSize, num_total_anchors, class_prob_each_anchor)
cls_preds = cls_preds.reshape(0, -1, self.num_classes+1)
return anchors, cls_preds, box_preds
这段代码中值得注意的是, 最后需要额外的一步, 将cls_preds整理成
(batchSize, num_total_anchors, class_proba_each_anchor)
的形式。这是分类任务中一个必要的步骤: 将代表class_probability的tensor单独放到一个维度(通常是最后一维), 以配合cls_loss = SoftmaxCrossEntropyLoss(axis=-1)
一起使用。语义分割中这个问题会更明显, 因为目标是对每个像素进行分类, 而FCN最后一个转置卷积层的输出维度是(batchSize, numClasses, height, width)
, 即原本代表通道的那一维(第1维)存放类别信息, 因此loss用的是SoftmaxCrossEntropyLoss(axis=1)。总而言之, 计算机视觉任务中, 网络输出的predict_tensor可能是高度浓缩的, 因此搞清楚哪个维度代表了什么含义, 哪一维应该拿出来求交叉熵, 是很重要的。
三、总结与遗留
这篇博客记录了SSD的学习笔记, 除了李沐老师讲的知识之外, 加上了一些自己对contrib库函数的探索, 以及学习过程中的思考。总结下, 这篇博客主要涉及一下几个有趣的点, 以及几个遗留问题:
3.1 几个有趣的点:
在Anchor Sampling阶段, 每个尺度的特征图都直接映射到原图大小(Fig. 1), 特征图上每个像素点Sample出的anchor尺寸和grid尺寸成正比, 和特征图大小成反比, 即大特征图用于检测小目标, 小特征图用于检测大目标。
SSD和FasterRCNN采集Anchor的数目不一样多, SSD是n+m-1个。
SSD直接将各个尺度的特征图通过两个Conv head回归到分别代表box_preds和num_classes+1分类的cls_preds上。
"容器"模型的思想: 模型并不一定要是严格的连接关系, 甚至有时候并没有严格定义的模型, 只是为了初始化方便将几个部件放到同一个HybridSequential中。只要前向传播能unpack出各个部件, 实现预期输出即可。
Concat时往往先找公共维度, 如果很多维度尺寸不一样, 可以考虑乘到一起, 再利用公共维度进行拼接。
定义模型时, 如果用到NDArray的接口, 想办法用F代替, 这样或许可以用model.hybridize()。
搞清楚输出tensor的哪一维要拿出来求交叉熵, 为什么是这一维!!
3.2 遗留问题:
SSD的sizes如何选择?
每个scale的cls_pred以及box_pred为啥要先用same卷积再flatten?可以用fc head吗?
答:如果用fc的话可能要求每个scale feature map维度固定。关于每个scale的cls_predictior输出维度的含义。为什么flatten前要将代表通道的维度放到最后面?
[1]: DeepLearning for CV with Python. ImageNet Bundle.
[2]: 动手学深度学习