写在前面
本博客将对SSD进行简单介绍,并讲解搭建SSD的pytorch目标检测平台;
什么是SSD(single shot multibox detector)
首先应该了解,ssd是一种one-stage的基于多框预测的目标检测方法,所谓one- stage是指目标检测和分类是同时完成的。其主要思路是,利用CNN提取特征之后,均匀地在图片的不同位置进行密集抽样,抽样时可以选择不同尺度和不同长宽比,物体的分类和预测框的回归同时进行,整个过程只需一步,因此最大的优点是速度快.
但是因为采用均匀的密集采样,导致正负样本不平衡,导致训练困难,模型准确度稍低。
实现思路
SSD采用的backbone是vgg网络,这里的vgg网络相较一般的vgg网络有一定的修改,主要修改如下:
- 将vgg16的FC6、FC7变成卷积层;
- 去掉所有的dropout和FC8;
- 新增CONV6--CONV9;
具体的特征提取过程如下:
- 对输入的图片,进行resize,到300*300的shape;
- conv1,经过两次[3,3]卷积网络,输出的特征层为64,输出net变成(300,300,64);再2x2最大池化,最大池化步长为2,输出net为(150,150,64);
- conv2,经过两次[3,3]卷积网络,输出的特征层为128,输出net变成(150,150,128);再2x2最大池化,该最大池化步长为2,输出net为(75,75,128);
- conv3,经过三次[3,3]卷积网络,输出的特征层为256,输出net变成(75,75,256);再2x2最大池化,该最大池化步长为2,输出net为(38,38,256);
- conv4,经过三次[3,3]卷积网络,输出的特征层为512,输出net变成(38,38,512);再2x2最大池化,该池化最大步长为2,输出net为(19,19,512);
- conv5,经过三次[3,3]卷积网络,输出的特征层为512,输出net变成(19,19,512);再3x3最大池化,该池化最大步长为1,输出net为(19,19,512);
- 利用卷积代替全连接层,进行一次[3,3]卷积网络和一次[1,1]卷积网络,分别为fc6和fc7,输出通道数为1024,因此输出的net为(19,19,1024);
- conv6,经过一次[1,1]卷积网络,调整通道数,一次步长为2的[3,3]卷积网络,输出的通道数为512,因此输出的net为(10,10,512);
- conv7,经过一次[1,1]卷积网络,调整通道数,一次步长为2的[3,3]卷积网络,输出的通道数为256,因此输出的net为(5,5,256);
- conv8,经过一次[1,1]卷积网络,调整通道数,一次padding为valid的[3,3]卷积网络,输出的通道数为256,因此输出的net为(3,3,256);
- conv9,经过一次[1,1]卷积网络,调整通道数,一次padding为valid的[3,3]卷积网络,输出的通道数为(1,1,256);
base = [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'C', 512, 512, 512, 'M',
512, 512, 512]
def vgg(i):
layers = []
in_channels = i
for v in base:
if v == 'M':
layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
elif v == 'C':
layers += [nn.MaxPool2d(kernel_size=2, stride=2, ceil_mode=True)]
else:
conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
layers += [conv2d, nn.ReLU(inplace=True)]
in_channels = v
pool5 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
conv6 = nn.Conv2d(512, 1024, kernel_size=3, padding=6, dilation=6)
conv7 = nn.Conv2d(1024, 1024, kernel_size=1)
layers += [pool5, conv6,
nn.ReLU(inplace=True), conv7, nn.ReLU(inplace=True)]
return layers
def add_extras(i, batch_norm=False):
# Extra layers added to VGG for feature scaling
layers = []
in_channels = i
# Block 6
# 19,19,1024 -> 10,10,512
layers += [nn.Conv2d(in_channels, 256, kernel_size=1, stride=1)]
layers += [nn.Conv2d(256, 512, kernel_size=3, stride=2, padding=1)]
# Block 7
# 10,10,512 -> 5,5,256
layers += [nn.Conv2d(512, 128, kernel_size=1, stride=1)]
layers += [nn.Conv2d(128, 256, kernel_size=3, stride=2, padding=1)]
# Block 8
# 5,5,256 -> 3,3,256
layers += [nn.Conv2d(256, 128, kernel_size=1, stride=1)]
layers += [nn.Conv2d(128, 256, kernel_size=3, stride=1)]
# Block 9
# 3,3,256 -> 1,1,256
layers += [nn.Conv2d(256, 128, kernel_size=1, stride=1)]
layers += [nn.Conv2d(128, 256, kernel_size=3, stride=1)]
return layers
从提取的特征中获取预测结果
从结构图中可以看到,我们分别选取一下特征:
- conv4的第三次卷积的特征;
- fc7卷积的特征;
- con6-conv9第二次卷积的特征;
将上述6个特征层称之为有效特征层,获取预测结果。对于每一个有效特征层,将进行如下操作,分别是
- 一次num_priors x 4的卷积
- 一次num_priors x num_classes的卷积
- 计算对应的先验框;
其中,num_Priors指的是特征层每一个特征点拥有的先验框数目,按照上述6个有效特征层的顺序,每个特征层num_priors的取值为4,6,6,6,4,4;
上述操作分别对应的对象为:
- num_priors x 4的卷积 用于预测 该特征层上 每一个网格点上 每一个先验框 的变化情况。
- num_priors x num_classes的卷积 用于预测 该特征层上 每一个网格点上 每一个预测 对应的种类。
- 每一个特征层的 每一个特征点上 对应的 若干个先验框。
故而,所有特征层对应的预测结果的shape如下:
实现代码:
class SSD(nn.Module):
def __init__(self, phase, base, extras, head, num_classes):
super(SSD, self).__init__()
self.phase = phase
self.num_classes = num_classes
self.cfg = Config
self.vgg = nn.ModuleList(base)
self.L2Norm = L2Norm(512, 20)
self.extras = nn.ModuleList(extras)
self.priorbox = PriorBox(self.cfg)
with torch.no_grad():
self.priors = Variable(self.priorbox.forward())
self.loc = nn.ModuleList(head[0])
self.conf = nn.ModuleList(head[1])
if phase == 'test':
self.softmax = nn.Softmax(dim=-1)
self.detect = Detect(num_classes, 0, 200, 0.01, 0.45)
def forward(self, x):
sources = list()
loc = list()
conf = list()
# 获得conv4_3的内容
for k in range(23):
x = self.vgg[k](x)
s = self.L2Norm(x)
sources.append(s)
# 获得fc7的内容
for k in range(23, len(self.vgg)):
x = self.vgg[k](x)
sources.append(x)
# 获得后面的内容
for k, v in enumerate(self.extras):
x = F.relu(v(x), inplace=True)
if k % 2 == 1:
sources.append(x)
# 添加回归层和分类层
for (x, l, c) in zip(sources, self.loc, self.conf):
loc.append(l(x).permute(0, 2, 3, 1).contiguous())
conf.append(c(x).permute(0, 2, 3, 1).contiguous())
# 进行resize
loc = torch.cat([o.view(o.size(0), -1) for o in loc], 1)
conf = torch.cat([o.view(o.size(0), -1) for o in conf], 1)
if self.phase == "test":
# loc会resize到batch_size,num_anchors,4
# conf会resize到batch_size,num_anchors,
output = self.detect(
loc.view(loc.size(0), -1, 4), # loc preds
self.softmax(conf.view(conf.size(0), -1,
self.num_classes)), # conf preds
self.priors
)
else:
output = (
loc.view(loc.size(0), -1, 4),
conf.view(conf.size(0), -1, self.num_classes),
self.priors
)
return output
mbox = [4, 6, 6, 6, 4, 4]
def get_ssd(phase,num_classes):
vgg, extra_layers = add_vgg(3), add_extras(1024)
loc_layers = []
conf_layers = []
vgg_source = [21, -2]
for k, v in enumerate(vgg_source):
loc_layers += [nn.Conv2d(vgg[v].out_channels,
mbox[k] * 4, kernel_size=3, padding=1)]
conf_layers += [nn.Conv2d(vgg[v].out_channels,
mbox[k] * num_classes, kernel_size=3, padding=1)]
for k, v in enumerate(extra_layers[1::2], 2):
loc_layers += [nn.Conv2d(v.out_channels, mbox[k]
* 4, kernel_size=3, padding=1)]
conf_layers += [nn.Conv2d(v.out_channels, mbox[k]
* num_classes, kernel_size=3, padding=1)]
SSD_MODEL = SSD(phase, vgg, extra_layers, (loc_layers, conf_layers), num_classes)
return SSD_MODEL
一些关于特征层的解释:
利用 num_priorsx4 的卷积 对于 每一个特征有效层对应的先验框 进行调整 获得预测框。(为什么是乘4,因为一个框需要:长、宽、x_offset、y_offset来确定位置)
每一个有效特征层将整个图片分成与其长宽对应的网格,比如conv4-3的特征层就是将整个图像分成38x38的网格,然后在每个网格中心建立一定数目的先验框,对于conv4-3的特征层来说,每个网格中心建立4个先验框,因此对于conv4-3整个特征层来说,一共建立了38x38x4=5776个先验框,这些先验框遍布整张图片,网络的预测结果会对这些框进行调整获得预测结果。
在SSD中,使用num_priorsx4的卷积的结果对先验框进行调整;其中,x_offset、y_offset表示真实框距离先验框中心的x、y轴的偏离情况;h、w表示真实框的宽、高相较于先验框的变化情况。
SSD的解码过程:
将每个网格的每个中心点加上其对应的x_offset、y_offset,加完之后的结果就是预测框的中心;利用h和w调整先验框获得预测框的宽和高,至此就可以在图片上绘制预测框;
而获得最终的预测结果,需要对每一个预测框进行 得分排序 和 非极大抑制筛选。(几乎是所有目标检测都有的part)
- 先取出每一类得分 大于 self.obj_threshold的 框 和 得分;
- 利用框的 位置和得分 进行非最大抑制;
# Adapted from https://github.com/Hakuyume/chainer-ssd
def decode(loc, priors, variances):
boxes = torch.cat((
priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:],
priors[:, 2:] * torch.exp(loc[:, 2:] * variances[1])), 1)
boxes[:, :2] -= boxes[:, 2:] / 2
boxes[:, 2:] += boxes[:, :2]
return boxes
class Detect(Function):
def __init__(self, num_classes, bkg_label, top_k, conf_thresh, nms_thresh):
self.num_classes = num_classes
self.background_label = bkg_label
self.top_k = top_k
self.nms_thresh = nms_thresh
if nms_thresh <= 0:
raise ValueError('nms_threshold must be non negative.')
self.conf_thresh = conf_thresh
self.variance = Config['variance']
def forward(self, loc_data, conf_data, prior_data):
loc_data = loc_data.cpu()
conf_data = conf_data.cpu()
num = loc_data.size(0) # batch size
num_priors = prior_data.size(0)
output = torch.zeros(num, self.num_classes, self.top_k, 5)
conf_preds = conf_data.view(num, num_priors,
self.num_classes).transpose(2, 1)
# 对每一张图片进行处理
for i in range(num):
# 对先验框解码获得预测框
decoded_boxes = decode(loc_data[i], prior_data, self.variance)
conf_scores = conf_preds[i].clone()
for cl in range(1, self.num_classes):
# 对每一类进行非极大抑制
c_mask = conf_scores[cl].gt(self.conf_thresh)
scores = conf_scores[cl][c_mask]
if scores.size(0) == 0:
continue
l_mask = c_mask.unsqueeze(1).expand_as(decoded_boxes)
boxes = decoded_boxes[l_mask].view(-1, 4)
# 进行非极大抑制
ids, count = nms(boxes, scores, self.nms_thresh, self.top_k)
output[i, cl, :count] = \
torch.cat((scores[ids[:count]].unsqueeze(1),
boxes[ids[:count]]), 1)
flt = output.contiguous().view(num, -1, 5)
_, idx = flt[:, :, 0].sort(1, descending=True)
_, rank = idx.sort(1)
flt[(rank < self.top_k).unsqueeze(-1).expand_as(flt)].fill_(0)
return output
最后,将这些经过筛选后的预测框直接绘制在图片上,就可以获得结果。
训练部分
1.对于真实框的处理
分成第一步先找到真实框对应的先验框;对真实框进行编码;
找真实框对应的先验框:
所找到的先验框将负责这个真实框的预测工作,首先需要将每一个真实框和所有先验框进行IOU计算,确定每一个真实框和所有先验框的重合程度; 选出和每一个真实框重合程度大于 一定 threshold 的先验框, 这表明 这个真实框将有这些先验框负责预测;
一个先验框只能负责一个真实框的预测,如果存在某一个先验框和多个真实框的重合程度都满足条件,那么排序后,这个先验框只负责与其IOU最大的真实框的预测;
经过此步骤,可以找到每一个先验框所负责预测的真实框;下一步,需要根据这些真实框和先验框获得网络的预测结果;
def match(threshold, truths, priors, variances, labels, loc_t, conf_t, idx):
# 计算所有的先验框和真实框的重合程度
overlaps = jaccard(
truths,
point_form(priors)
)
# 所有真实框和先验框的最好重合程度
# [truth_box,1]
best_prior_overlap, best_prior_idx = overlaps.max(1, keepdim=True)
best_prior_idx.squeeze_(1)
best_prior_overlap.squeeze_(1)
# 所有先验框和真实框的最好重合程度
# [1,prior]
best_truth_overlap, best_truth_idx = overlaps.max(0, keepdim=True)
best_truth_idx.squeeze_(0)
best_truth_overlap.squeeze_(0)
# 找到与真实框重合程度最好的先验框,用于保证每个真实框都要有对应的一个先验框
best_truth_overlap.index_fill_(0, best_prior_idx, 2)
# 对best_truth_idx内容进行设置
for j in range(best_prior_idx.size(0)):
best_truth_idx[best_prior_idx[j]] = j
# 找到每个先验框重合程度最好的真实框
matches = truths[best_truth_idx] # Shape: [num_priors,4]
conf = labels[best_truth_idx] + 1 # Shape: [num_priors]
# 如果重合程度小于threhold则认为是背景
conf[best_truth_overlap < threshold] = 0 # label as background
loc = encode(matches, priors, variances)
loc_t[idx] = loc # [num_priors,4] encoded offsets to learn
conf_t[idx] = conf # [num_priors] top class label for each prior
对真实框进行编码:
根据先前的介绍,直接利用ssd网络预测到的结果,并不是预测框在图片上的真实位置,需要经过解码才能得到真实位置;
因此训练时,需要计算loss,这个loss是相对于ssd网络的预测结果的。所谓编码,就是对真实框的信息进行处理,使得它的结构和预测结果的格式相同,更具体地说,将真实框的位置信息格式转化为ssd预测结果的格式;
从预测结果获得真实框成为解码,从真实框获得预测结果就是编码,仅此将解码过程逆过来就是编码了。
因此可以利用真实框和先验框进行编码,获得该特征点对应的先验框应该具有的预测结果,分别是:
- num_priors x 4的卷积(调整框的位置)
- num_priors x num_classes的卷积 (每一个类别的得分)
二者都通过先验框对应的真实框获得;
def encode(matched, priors, variances):
g_cxcy = (matched[:, :2] + matched[:, 2:])/2 - priors[:, :2]
g_cxcy /= (variances[0] * priors[:, 2:])
g_wh = (matched[:, 2:] - matched[:, :2]) / priors[:, 2:]
g_wh = torch.log(g_wh) / variances[1]
return torch.cat([g_cxcy, g_wh], 1)
2.利用处理完的真实框与对应图片的预测结果计算loss
loss的计算总共分成三部分:
- 获得所有正标签的框的预测结果的回归loss
- 获得所有正标签的种类的预测结果的交叉熵loss
- 获得一定负标签的种类的预测结果的交叉熵loss
在ssd的训练过程中,存在正负样本极不平衡的问题,即 存在对应真实框的先验框可能只有若干个,而不存在对应真实框的负样本却有成千个,这就将导致负样本的loss值极大。基于这个考虑减少选取负样本的数量,对于ssd的训练来说,常见情况:取三倍正样本数量的负样本进行训练;(参考博客)
代码:
class MultiBoxLoss(nn.Module):
def __init__(self, num_classes, overlap_thresh, prior_for_matching,
bkg_label, neg_mining, neg_pos, neg_overlap, encode_target,
use_gpu=True):
super(MultiBoxLoss, self).__init__()
self.use_gpu = use_gpu
self.num_classes = num_classes
self.threshold = overlap_thresh
self.background_label = bkg_label
self.encode_target = encode_target
self.use_prior_for_matching = prior_for_matching
self.do_neg_mining = neg_mining
self.negpos_ratio = neg_pos
self.neg_overlap = neg_overlap
self.variance = Config['variance']
def forward(self, predictions, targets):
# 回归信息,置信度,先验框
loc_data, conf_data, priors = predictions
# 计算出batch_size
num = loc_data.size(0)
# 取出所有的先验框
priors = priors[:loc_data.size(1), :]
# 先验框的数量
num_priors = (priors.size(0))
num_classes = self.num_classes
# 创建一个tensor进行处理
loc_t = torch.Tensor(num, num_priors, 4)
conf_t = torch.LongTensor(num, num_priors)
for idx in range(num):
# 获得框
truths = targets[idx][:, :-1].data
# 获得标签
labels = targets[idx][:, -1].data
# 获得先验框
defaults = priors.data
# 找到标签对应的先验框
match(self.threshold, truths, defaults, self.variance, labels,
loc_t, conf_t, idx)
if self.use_gpu:
loc_t = loc_t.cuda()
conf_t = conf_t.cuda()
# 转化成Variable
loc_t = Variable(loc_t, requires_grad=False)
conf_t = Variable(conf_t, requires_grad=False)
# 所有conf_t>0的地方,代表内部包含物体
pos = conf_t > 0
# 求和得到每一个图片内部有多少正样本
num_pos = pos.sum(dim=1, keepdim=True)
# 计算回归loss
pos_idx = pos.unsqueeze(pos.dim()).expand_as(loc_data)
loc_p = loc_data[pos_idx].view(-1, 4)
loc_t = loc_t[pos_idx].view(-1, 4)
loss_l = F.smooth_l1_loss(loc_p, loc_t, size_average=False)
# 转化形式
batch_conf = conf_data.view(-1, self.num_classes)
# 你可以把softmax函数看成一种接受任何数字并转换为概率分布的非线性方法
# 获得每个框预测到真实框的类的概率
loss_c = log_sum_exp(batch_conf) - batch_conf.gather(1, conf_t.view(-1, 1))
loss_c = loss_c.view(num, -1)
loss_c[pos] = 0
# 获得每一张图新的softmax的结果
_, loss_idx = loss_c.sort(1, descending=True)
_, idx_rank = loss_idx.sort(1)
# 计算每一张图的正样本数量
num_pos = pos.long().sum(1, keepdim=True)
# 限制负样本数量
num_neg = torch.clamp(self.negpos_ratio*num_pos, max=pos.size(1)-1)
neg = idx_rank < num_neg.expand_as(idx_rank)
# 计算正样本的loss和负样本的loss
pos_idx = pos.unsqueeze(2).expand_as(conf_data)
neg_idx = neg.unsqueeze(2).expand_as(conf_data)
conf_p = conf_data[(pos_idx+neg_idx).gt(0)].view(-1, self.num_classes)
targets_weighted = conf_t[(pos+neg).gt(0)]
loss_c = F.cross_entropy(conf_p, targets_weighted, size_average=False)
# Sum of losses: L(x,c,l,g) = (Lconf(x, c) + αLloc(x,l,g)) / N
N = num_pos.data.sum()
loss_l /= N
loss_c /= N
return loss_l, loss_c
训练结果:
使用voc格式进行训练,注意config文件下面的Num_Classes=分类数目+1;运行的时候,因为跑不起来,将epoch改成5,batch_size改成4,勉强跑起来,结果如下:
参考博客: