什么是SSD目标检测算法
SSD是一种非常优秀的one-stage目标检测方法,one-stage算法就是目标检测和分类同时完成的,其主要思路是利用CNN提取特征后,均匀地在图片的不同位置进行密集抽样,抽样时可以采用不同尺度和长宽比,物体分类与预测框的回归同时进行,整个过程只需要一步,所以其优势就是速度快,
但是均匀的密集采样的一个重要的缺点是训练比较困难,这主要是因为正样本与负样本背景(背景)及其不均衡,导致模型准确度稍低。
SSD的英文全名是Single Shot MultiBox Detector,Single shot说明SSD算法属于one-stage方法,MultiBox说明SSD算法基于多框预测。
SSD实现思路
一、预测部分
1.主干网络介绍
Final detections
这里的VGG网络有一定的修改,主要修改的地方就是:
1、将VGG16的FC6和FC7层转化为卷积层。
2、去掉所有的Dropout层和FC8层
3、新增了conv6,Conv7,Conv8,Conv9
如图所示,输入的图片经过了改进的VGG网络(Conv1->fc7)和几个另加的卷积层(Conv6->conv9),进行特征提取:
a、输入一张图片,被resize到300*300的reshape
b、conv1,经过两次的【3,3】卷积网络,输出的特征层为64,输出为(300,300,64)再2*2最大池化,输出net为(150,150,64)
c、conv2,经过两次的【3,3】卷积网络,输出的特征层为128,输出为(150,150,128)再2*2最大池化,输出net为(75,75,128)
d、conv3,经过三次的【3,3】卷积网络,输出的特征层为256,输出为(75,75,256)再2*2最大池化,输出net为(38,38,256)
e、conv4,经过三次的【3,3】卷积网络,输出的特征层为512,输出为(38,38,512)再2*2最大池化,输出net为(19,19,512)
f、conv5,经过三次的【3,3】卷积网络,输出的特征层为512,输出为(19,19,512)再步长为1,卷积核大小为3*3最大池化,输出net为(19,19,512)
g、利用卷积代替全连接层,进行了一次【3,3】卷积网络和一次【1,1】卷积网络,输出的特征层为1024,因此输出的net为(19,19,1024)
(从这里往前都是VGG的结构)
h、conv6,经过一次【1,1】卷积网络,调整通道数,一次步长为2的【3,3】卷积网络,输出的特征层为512,因此输出的net为(10,10,512)
i、conv7,经过一次【1,1】卷积网络,调整通道数,一次步长为2的【3,3】卷积网络,输出的特征层为256,因此输出的net为(5,5,256)
j、conv8,经过一次【1,1】卷积网络,调整通道数,一次padding为valid的【3,3】卷积网络,输出的特征层为256,因此输出的net为(1,1,256)
k、conv9,经过一次【1,1】卷积网络,调整通道数,一次padding为valid【3,3】卷积网络,输出的特征层为256,因此输出的net为(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) # (19,19,512)
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
# Block6
# 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)]
# block7
# 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
2、从特征预测结果
由上图我们可以知道,我们分别取conv4的第三次卷积的特征、fc7的特征、conv6的第二次卷积的特征,conv7的第二次卷积的特征、conv8的第二次卷积的特征、conv9的第二次卷积的特征,为了和普通特征层区分,我们称之为有效特征层,来获取预测结果。
对获取到的每一个有效特征层,我们分别对其进行一次num-priors*4的卷积、一次num_priors*num_classes的卷积、并需要计算每一个有效特征层对应的先验框。而num_priors指的是该特征层所拥有的先验框数量。
其中:
num_priors*4的卷积用于预测 该特征层上 每一个网格点上 每一个先验框的变化情况。(为什么说是变化情况呢,这是因为ssd的预测结果需要结合先验框获得预测框)
num_priors*num_classes的卷积用于预测该特征层上 每一个网格点上 每一个预测框对应的种类。
每一个有效特征层对应的先验框对应着该特征层上 每一个网格点上 预先设定好的多个框。
所有的特征层对应的预测结果的shape如下:
实现代码为:
import torch
import torch.nn as nn
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_szie,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
3、预测结果的解码
我们通对每一个特征层的处理,可以获得三个内容,分别是:
num_priors * 4的卷积 用于预测 该特征层上, 每一个网格点上 每一个先验框的变化情况。**
num_priors*num_classes的卷积 用于预测 该特征层上 每一个网格点上 每一个预测框对应的种类。
每一个有效特征层对应的先验框对应着该特征层上 每一个网格点上 预先设定好的多个框。
我们利用num_priors *4的卷积 与 每一个有效特征层对应的先验框 获得框的真实位置
每一个有效特征层对应的先验框就是,如图所示的作用:
每一个有效特征层将整个图片分成与其长宽对应的网格,如conv4-3的特征层就是将整个图像分成38*38个网格;然后从每个网格中心建立多个先验框,如conv4-3的 特征层就是建立了4个先验框;对于conv4-3的特征层来讲,整个图片被分成38*38个网格,每个网格中心对应4个先验框,一共包含了,38*38*4个,5776个先验框。
(图片可直接拖拽)
先验框虽然可以代表一定的框的位置与框的大小信息,但是其是有限的,无法表示任意情况,因此还需要调整,ssd利用num_priors*4的卷积的结果对先验框进行调整。
num_priors*4中的num_priors表示了这个网格点所包含的先验框的数量,其中的4表示了x_offset、y_offset、h和w的调整的情况。
x_offset与y_offset代表真实框距离先验框中心的xy轴偏移情况。
hw代表了真实框的宽与高相对于先验框的变化情况。
SSD 的解码过程就是将每个网络的中心点加上它对应的x_offset和y_offset,加完后的结果就是预测框的中心,然后再利用 先验框和h、w结合 计算出预测框的位置了。
当然得到的最终的预测结构还要进行得分排序与非极大抑制筛选、这一部分基本上是所有目标检测通用的部分。
1、取出每一类得分大于self.obj_threshold的框和得分。
2、利用框的维诶之和得分进行非极大抑制。
实现的代码如下:
import torch
import torch.nn.functional
# 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) # batchsize
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
4、在原图上进行绘制
通过第三步,我们可以获得预测框在原图上的位置,而且这些预测框都是经过筛选的,这些筛选后的框可以直接绘制在图片上,就可以获得结果了
二、训练部分
1、真实框的处理
从预测部分我们知道,每个特征层的预测结果,num_priors*4的卷积 用于预测 该特征层上 每一个网格点上 每一个先验框 的变化情况。
也就是说,我们直接利用ssd网格预测到的结果,并不是预测框在图片上的真实位置,需要解码才能得到真实位置。
而在训练的时候,我们需要计算loss函数,这个loss函数是相对于ssd网络的预测结果的。我们需要把图片输入到当前的ssd网络中,得到预测结果;同时还需要把真实框的位置信息格式转化为ssd预测结果的格式信息。
也就是,我们需要找到 每一张用于训练的图片的每一个 真实框对应的先验框,并求出如果想要得到这样的一个真实框,我们的预测结果应该是怎么样的。
从预测结果获得真实框的过程被称作编码,而从真实框获得预测结果的过程就是编码的过程。
因此我们只需要将解码的过程逆过来就是编码过程了。
实现代码如下:
在训练的时候我们只需要选择iou最大的先验框就行了,这个iou最大的先验框就是我们用来预测这个真实框所用的先验框。
因此我们还要经过一次筛选,将上述代码获得的真实框对应的所有的iou较大先验框的预测结果中,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_prios]
#如果重合程度小于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
2、 利用处理完的真实框与对应图片的预测结果计算loss、
loss分为三个部分:
1、获取所有正标签的框的预测结果的回归loss
2、获取所有正标签的种类的预测结果的交叉熵Loss
3、获取一定负标签的种类的的预测结果的交叉熵loss
由于在ssd的训练过程中,正负样本极其不平衡,即存在对应真实框的先验框可能只有十来个,但是不存在对应真实框的负样本曲却有几千个,这就会导致负样本的loss值极大,因此我们可以考虑减少负样本的选取,对于ssd的训练来讲,常见的情况是取三倍正样本的负样本用于训练。这个三倍呢,也可以修改,调整成自己喜欢的数字。
实现代码如下:
class MultiBoxLoss(nn.Moudle):
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_minig = 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>0的地方,代表内部包含物体
pos = conf_t > 0
# 求和得到每一个图片内部有多少正样本
num_pos = pos.num(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)+alloc(x,l,g))/N
N = num_pos.data.sum()
loss_l /= N
loss_c /= N
return loss_l, loss_c