SSD源码解读——网络搭建

之前,对SSD的论文进行了解读,可以回顾之前的博客:https://www.cnblogs.com/dengshunge/p/11665929.html

为了加深对SSD的理解,因此对SSD的源码进行了复现,主要参考的github项目是ssd.pytorch。同时,我自己对该项目增加了大量注释:https://github.com/Dengshunge/mySSD_pytorch

搭建SSD的项目,可以分成以下三个部分:

  1. 数据读取
  2. 网络搭建;
  3. 损失函数的构建
  4. 网络测试

接下来,本篇博客重点分析网络搭建


该部分整体比较简单,思路也很清晰。

首先,在train.py中,网络搭建的函数入口是函数build_ssd(),该函数需要传入以下几个参数:"train"或者"test"字符串、图片尺寸、类别数。其中,"train"或者"test"字符串用于区分该网络是用于训练还是测试,这两个阶段的网络有些许不同,本文主要将训练阶段的网络;而类别数需要加上背景,对于VOC而言,有20个类别,加上1个背景,即类别数是21。

ssd_net = build_ssd('train', voc['min_dim'], voc['num_classes'])

这里,先放一张SSD的网络结构图,可以看出,SSD网络是有3部分组成的,vgg主干网络,新增网络(Conv6之后的层)和用于检测的头部网络(Extra Feature Layers)。

接着,在ssd.py中,首先定了一个参数,如下所示。这里主要以SSD300为例。这些参数有什么用呢?字典base的参数指的是用于搭建VGG主干网络输出通道数,其中“M”表示需要进行maxpooling;字典extras的参数同样表示新增层的输出通道数,其中“S”表示需要stride=2的降采样;字典mbo的参数表示用于特征融合的层中,每个层对应未知(x,y)的锚点框数量,在SSD300中,使用了6个层进行特征融合,如Conv_4层中,每个位置使用4个锚点框进行预测。

base = {
    '300': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M',
            512, 512, 512],  # M表示maxpolling
    '512': [],
}
extras = {
    '300': [256, 'S', 512, 128, 'S', 256, 128, 256, 128, 256],  # S表示stride=2
    '512': [],
}
mbox = {
    '300': [4, 6, 6, 6, 4, 4],  # 每个特征图的每个点,对应锚点框的数量
    '512': [],
}

当定义完需要使用到的参数后,可以进行如具体搭建的环节。函数build_ssd()的定义如下所示。利用函数multibox()来构建SSD网络的各个部分,分别是VGG主干网络,新增层和用于检测的头部网络(或许可以理解为分类头和回归头)。而VGG主干网络是通过函数vgg()来实现,新增层是通过函数add_extras()来实现,而函数multibox()则搭建用于检测的头部网络。最后用这些层来初始化类SSD。

def build_ssd(phase, size=300, num_class=21):
    if phase != 'test' and phase != 'train':
        raise ("ERROR: Phase: " + phase + " not recognized")
    base_, extras_, head_ = multibox(vgg(base[str(size)]),
                                     add_extras(extras[str(size)], in_channels=1024),
                                     mbox[str(size)],
                                     num_class)
    return SSD(phase, size, base_, extras_, head_, num_class)

我们来看一下VGG主干网络是如何搭建的。函数vgg()需要将上述的base字典传入进去,根据base字典,来搭建卷积层和池化层。作者对vgg网络进行了改进,即将fc6和fc7更改成conv6和conv7。值得留意的是,在conv6中,使用了空点卷积,dilation=6,增大感受野。在SSD论文的最后,也讨论了空洞卷积对结果有好的影响。最后,将这些卷积层和池化层放入list中,并返回这个list。

def vgg(cfg=base['300'], batch_norm=False):
    '''
    该函数来源于torchvision.models.vgg19()中的make_layers()
    '''
    layers = []
    in_channels = 3

    # vgg主体部分,到论文的conv6之前
    for v in cfg:
        if v == 'M':
            # ceil_mode是向上取整
            layers += [nn.MaxPool2d(kernel_size=2, stride=2, ceil_mode=True)]
        else:
            conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
            if batch_norm:
                layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)]
            else:
                layers += [conv2d, nn.ReLU(inplace=True)]
            in_channels = v
    pool5 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1, ceil_mode=True)

    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

接下来,我们来了解一下SSD在vgg中新增层,即conv7之后的网络层。同样,函数add_extras()需要传入字典extras,来构建网络层。这里可以留意一下,kernel_size的写法,(1,3)为一个元祖tuple,flag来控制取哪个值,即可变换使用3*3或者1*1的卷积核,减少代码的冗余。最后将新构建的层存入list中,并返回这个list。

def add_extras(cfg=extras['300'], in_channels=1024):
    '''
    完成SSD后半部分的网络构建,即作者新加上去的网络,从conv7之后到conv11_2
    '''
    layers = []
    flag = False  # 交替控制卷积核,使用1*1或者使用3*3
    for k, v in enumerate(cfg):
        if in_channels != 'S':
            if v == 'S':
                layers += [nn.Conv2d(in_channels, cfg[k + 1], kernel_size=(1, 3)[flag], stride=2, padding=1)]
            else:
                layers += [nn.Conv2d(in_channels, v, kernel_size=(1, 3)[flag])]
            flag = not flag
        in_channels = v
    return layers

当有了vgg的主干网络和新增层后,可以将某些层进行特征融合和预测了。这里,就需要使用到函数multibox()。需要将vgg主干网络和新增层的list、字典mbox和类别数传入函数中。首先,函数multibox()会创建两个list,用于保存位置回归的层和置信度的层。对于每个用于融合的特征层,会分成两部分,一个用于回归,使用3*3的卷积,输出通道数是cfg[k] * 4,其中cfg[k]表示每个位置上锚点框的数量,4表示[x_min,y_min,x_max,y_max];另外一个用于类别的判断,也是使用3*3的卷积,输出通道数是cfg[k] * num_class,表示每个锚点框判断其属于哪一个类别,在voc中,num_class=21(包含背景)。可以理解成将此特征层分成了分类头和回归头,每个锚点框会输出4个坐标和21个类别置信度。最后将vgg主干网络、新增层、分类头和回归头返回。

def multibox(vgg, extra_layers, cfg, num_class):
    '''
    返回vgg网络,新增网络,位置网络和置信度网络
    '''
    loc_layers = []  # 判断位置
    conf_layers = []  # 判断置信度

    vgg_source = [21, -2]  # 21表示conv4_3的序号,-2表示conv7的序号
    for k, v in enumerate(vgg_source):
        # vgg[v]表示需要提取的特征图
        # cfg[k]代表该特征图下每个点对应的锚点框数量
        loc_layers += [nn.Conv2d(vgg[v].out_channels, cfg[k] * 4, kernel_size=3, padding=1)]
        conf_layers += [nn.Conv2d(vgg[v].out_channels, cfg[k] * num_class, kernel_size=3, padding=1)]

    for k, v in enumerate(extra_layers[1::2], 2):
        # [1::2]表示,从第1位开始,步长为2
        # 这么做的目的是,新增加的层,都包含2层卷积,需要提取后面那层卷积的结果作为特征图
        loc_layers += [nn.Conv2d(v.out_channels, cfg[k] * 4, kernel_size=3, padding=1)]
        conf_layers += [nn.Conv2d(v.out_channels, cfg[k] * num_class, kernel_size=3, padding=1)]

    return vgg, extra_layers, (loc_layers, conf_layers)

函数multibox()返回的各个层,用于初始化类SSD。首先,由于因为“train”阶段和“test”阶段是有点区别的,本节依然主要将“train”阶段,因此,需要传入phase参数,参数只能是两个值(train,test)。函数PriorBox()的作用是来创建先验锚点框,返回的shape为[8732,4],其中具有8732个锚点框,4表示每个锚点框的坐标[中心点x,中心点y,宽,高],这里的坐标值有点不太一样。由于传入的网络层是以list列表的形式,因此,用nn.ModuleList()将其转换为pytorch的网络结构。

接下来看类SSD中的函数forward(),用于前向推理。按顺序对输入图片进行处理,在conv4中,需要对特征图进行L2正则化。并将用于特征融合的特征图存在放sources中。在得到5个用于融合的特征图后,将这些特征图输入到分类头和回归头中,每个特征图对应各自的分类头和回归头。这里注意一下,分类头或者回归头卷积后,使用了permute()函数。该函数的作用是交换维度,原本的维度是[batch_size,channel,height,weight],交换维度后变成了[batch_size,height,weight,channel],这样做的目的是方便后续的处理。将处理后的结果保存在loc和conf这两个List中。后续接着对loc和conf进行变换,利用view()函数,最终,loc的shape为[batch_size,8732*4],conf的shape为[batch_size,8732*21]。

最后,将loc和conf这两个List又变换维度,返回出去,用于计算loss损失函数(感觉这么多变换,有点重复呀,应该可以省略一部分)。"train"阶段和"test"阶段返回的结果类似,其中不同点是,在test阶段,置信度需要经过softmax。

class SSD(nn.Module):
    '''
    构建SSD的主函数,将base(vgg)、新增网络和位置网络与置信度网络组合起来
    '''

    def __init__(self, phase, size, base, extras, head, num_classes):
        super(SSD, self).__init__()
        self.phase = phase
        self.num_classes = num_classes
        self.priors = torch.Tensor(PriorBox(voc))
        self.size = size

        # SSD网络
        self.vgg = nn.ModuleList(base)
        # 对conv4_3的特征图进行L2正则化
        self.L2Norm = L2Norm(512, 20)

        self.extras = nn.ModuleList(extras)
        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=self.num_classes, top_k=200,
                                 conf_thresh=0.01, nms_thresh=0.45)

    def forward(self, x):
        sources = []  # 保存特征图
        loc = []  # 保存每个特征图进行位置网络后的信息
        conf = []  # 保存每个特征图进行置信度网络后的信息

        # 处理输入至conv4_3
        for k in range(23):
            x = self.vgg[k](x)

        # 对conv4_3进行L2正则化
        s = self.L2Norm(x)
        sources.append(s)

        # 完成vgg后续的处理
        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)

        # 将特征图送入位置网络和置信度网络
        # l(x)或者c(x)的shape为[batch_size,channel,height,weight],使用了permute后,变成[batch_size,height,weight,channel]
        # 这样做应该是为了方便后续处理
        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())

        # 进行格式变换
        loc = torch.cat([o.view(o.size(0), -1) for o in loc], 1)  # [batch_size,34928],锚点框的数量8732*4=34928
        conf = torch.cat([o.view(o.size(0), -1) for o in conf], 1)

        if self.phase == 'train':
            output = (loc.view(loc.size(0), -1, 4),  # [batch_size,num_priors,4]
                      conf.view(conf.size(0), -1, self.num_classes),  # [batch_size,num_priors,21]
                      self.priors)  # [num_priors,4]
        else:  # Test
            output = self.detect(
                loc.view(loc.size(0), -1, 4),  # 位置预测
                self.softmax(conf.view((conf.size(0), -1, self.num_classes))),  # 置信度预测
                self.priors.cuda()  # 先验锚点框
            )

        return output

在上面类SSD中,提及到了先验锚点框的构建函数PriorBox(),这个函数在models/prior_box.py中。首先,根据用于融合的特征图尺寸和product()函数,生成一系列的点,如(0,0),(0,1),(0,2)等。然后根据这些像素点位置,偏移0.5作为锚点框的中心点,即cx和cy,并将其归一化。然后计算论文中的$s_k$和${s_k}'$,对应s_k和s_k_prime,先计算$a_r=1$的情况,再计算其余$a_r$的情况。此时,mean的shape为[1,34928],因此,需要使用view()函数,将其切割出来,变成[8732,4]。记得,这里的锚点框的坐标是[中心点x,中心点y,宽,高]。

def PriorBox(cfg):
    '''
    为所有特征图生成预设的锚点框,返回所有生成的锚点框,尺寸为[8732,4],
    每行表示[中心点x,中心点y,宽,高]
    '''
    image_size = cfg['min_dim']  # 300
    feature_maps = cfg['feature_maps']  # [38, 19, 10, 5, 3, 1],特征图尺寸
    steps = cfg['steps']  # [8, 16, 32, 64, 100, 300]
    min_sizes = cfg['min_sizes']  # [30, 60, 111, 162, 213, 264]
    max_sizes = cfg['max_sizes']  # [60, 111, 162, 213, 264, 315]
    aspect_ratios = cfg['aspect_ratios']  # [[2], [2, 3], [2, 3], [2, 3], [2], [2]]

    mean = []
    # 为所有特征图生成锚点框
    for k, f in enumerate(feature_maps):
        # product(list1,list2)的作用是依次取出list1中的每1个元素,与list2中的每1个元素,
        # 组成元组,然后,将所有的元组组成一个列表,返回
        # 而这里使用了repeat,说明1个list重复2次
        for i, j in product(range(f), repeat=2):
            f_k = image_size / steps[k]
            # 计算中心点,这里的j是沿x方向变化的
            cx = (j + 0.5) / f_k
            cy = (i + 0.5) / f_k

            # aspect_ratio=1有两种情况,s_k=s_k,s_k=sqrt(s_k*s_(k+1))
            s_k = min_sizes[k] / image_size
            mean += [cx, cy, s_k, s_k]

            s_k_prime = sqrt(s_k * (max_sizes[k] / image_size))
            mean += [cx, cy, s_k_prime, s_k_prime]

            # 剩余的aspect_ratio
            for ar in aspect_ratios[k]:
                mean += [cx, cy, s_k * sqrt(ar), s_k / sqrt(ar)]
                mean += [cx, cy, s_k / sqrt(ar), s_k * sqrt(ar)]

    # 此时的mean是1*34928的list,要4个数就分割出来,所以需要用view,从而变成[8732,4],即有8732个锚点框
    output = torch.Tensor(mean).view(-1, 4)
    if cfg['clip']:
        # 对每个元素进行截断限制,限制为[0,1]之间
        output.clamp_(min=0, max=1)
    return output

最后,类SSD中还对conv4的特征层使用了L2正则化,该函数在models/l2norm.py中。在函数forwand()中,按每个通道对其值进行L2正则化,即除以通道的平方根来实现归一化。

class L2Norm(nn.Module):
    '''
    对conv4_3进行l2归一化
    '''

    def __init__(self, n_channels, scale):
        super(L2Norm, self).__init__()
        self.n_channels = n_channels
        self.gamma = scale
        self.eps = 1e-10
        self.weight = nn.Parameter(torch.Tensor(self.n_channels))  # n_channels个随机数
        self.reset_parameters()

    def reset_parameters(self):
        # 使用gamma来填充weight的每个值
        nn.init.constant_(self.weight, self.gamma)

    def forward(self, x):
        # 按通道进行求值
        norm = x.pow(2).sum(dim=1, keepdim=True).sqrt() + self.eps  # [1,1,38,38]
        x = torch.div(x, norm)
        # 将weight通过3个unsqueeze展开成[1,512,1,1],然后通过expand_as进行扩展,形成[1,512,38,38]
        out = self.weight.unsqueeze(0).unsqueeze(2).unsqueeze(3).expand_as(x) * x
        return out

 


 

至此,SSD的网络搭建过程已经完成了,通过类SSD的forward()函数,即能返回预测框的坐标和类别置信度,以此可以计算损失函数。 

 

 

 

posted @ 2019-12-01 11:33  啊顺  阅读(3640)  评论(0编辑  收藏  举报