淘先锋技术网

首页 1 2 3 4 5 6 7

写在前面  

本博客将对SSD进行简单介绍,并讲解搭建SSD的pytorch目标检测平台;

什么是SSD(single shot multibox detector)

首先应该了解,ssd是一种one-stage的基于多框预测的目标检测方法,所谓one- stage是指目标检测和分类是同时完成的。其主要思路是,利用CNN提取特征之后,均匀地在图片的不同位置进行密集抽样,抽样时可以选择不同尺度和不同长宽比,物体的分类和预测框的回归同时进行,整个过程只需一步,因此最大的优点是速度快.

但是因为采用均匀的密集采样,导致正负样本不平衡,导致训练困难,模型准确度稍低。

实现思路

SSD采用的backbone是vgg网络,这里的vgg网络相较一般的vgg网络有一定的修改,主要修改如下:

  1. 将vgg16的FC6、FC7变成卷积层;
  2. 去掉所有的dropout和FC8;
  3. 新增CONV6--CONV9;

具体的特征提取过程如下:

  1. 对输入的图片,进行resize,到300*300的shape;
  2. conv1,经过两次[3,3]卷积网络,输出的特征层为64,输出net变成(300,300,64);再2x2最大池化,最大池化步长为2,输出net为(150,150,64);
  3. conv2,经过两次[3,3]卷积网络,输出的特征层为128,输出net变成(150,150,128);再2x2最大池化,该最大池化步长为2,输出net为(75,75,128);
  4. conv3,经过三次[3,3]卷积网络,输出的特征层为256,输出net变成(75,75,256);再2x2最大池化,该最大池化步长为2,输出net为(38,38,256);
  5. conv4,经过三次[3,3]卷积网络,输出的特征层为512,输出net变成(38,38,512);再2x2最大池化,该池化最大步长为2,输出net为(19,19,512);
  6. conv5,经过三次[3,3]卷积网络,输出的特征层为512,输出net变成(19,19,512);再3x3最大池化,该池化最大步长为1,输出net为(19,19,512);
  7. 利用卷积代替全连接层,进行一次[3,3]卷积网络和一次[1,1]卷积网络,分别为fc6和fc7,输出通道数为1024,因此输出的net为(19,19,1024);
  8. conv6,经过一次[1,1]卷积网络,调整通道数,一次步长为2的[3,3]卷积网络,输出的通道数为512,因此输出的net为(10,10,512);
  9. conv7,经过一次[1,1]卷积网络,调整通道数,一次步长为2的[3,3]卷积网络,输出的通道数为256,因此输出的net为(5,5,256);
  10. conv8,经过一次[1,1]卷积网络,调整通道数,一次padding为valid的[3,3]卷积网络,输出的通道数为256,因此输出的net为(3,3,256);
  11. 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的计算总共分成三部分:

  1. 获得所有正标签的框的预测结果的回归loss
  2. 获得所有正标签的种类的预测结果的交叉熵loss
  3. 获得一定负标签的种类的预测结果的交叉熵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,勉强跑起来,结果如下:

参考博客:

https://blog.csdn.net/weixin_44791964/article/details/104981486?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522162833867916780255213007%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=162833867916780255213007&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_v2~rank_v29-2-104981486.pc_v2_rank_blog_default&utm_term=ssd&spm=1018.2226.3001.4450