首发于目标检测
RefineDet(5)_源码(1)_CVPR2018

RefineDet(5)_源码(1)_CVPR2018

本笔记介绍CVPR2018_RefineDet,中科院自动化所作品,RefineDet有caffe(官方)、mxnet、pytorch代码,本次学习pytorch代码;


1 RefineDet简介

1 RefineDet包含两个内部互联模块:ARM、ODM;----- 这是对1-stage目标检测算法的改进,同时也引入了类似2-stage的方案;

ARM作用

(1) 过滤negative anchors,减少分类器的搜索空间;

(2) 粗略地refine anchor的位置、尺度,为后续的regressor初始化更好的anchor;

ODM作用:将ARM refine后的anchor作为输入,进一步提升bbox reg + multi-class pred的精度;

2 提出TCB用于转换ARM中的特征,用于在ODM中预测bbox的loc、size、multi-class label,整体的多任务损失目标函数使得RefineDet可以做到训练阶段end2end;---- 作用类似FPN、DSSD的top-down特征金字塔生成方式;

注:ARM中提取的特征,集中精力于从海量 bg 中判别positive anchors,TCB用于将该特征升级为(transfer)能在ODM中预测bbox的loc、size、multi-class label的特征;---- 当然了,这个升级也不是单单靠TCB完成的,也跟ODM中目标函数有关,ODM就是为了预测具体类别,监督信息自然按这个目标函数来更新模型参数;


2 RefineDet详细介绍

RefineDet结构如fig 1,与1-stage的SSD类似,feed-forward梭一次,通过预定义的固定数量的default bboxes,预测目标的位置和类别信息,再通过NMS得到最终检测结果;

ARM:基于ImageNet上预训练的VGG-16、ResNet-101为主干网,去除不需要的分类层,新增若干辅助预测层即可(跟SSD有点类似的,可以参考SSD);

ODM:基于TCB输出的特征,再通过预测层(如3 × 3 conv),生成基于refined anchor的多分类(multi-class)输出 + bbox定位(shape offsets)


2.1 Two-Step Cascaded Regression

如 SSD 此类 1-stage 的目标检测算法,在特征金字塔不同尺度的feature map上,通过预定义的不同尺度default box预测目标的大小、位置,但整个bbox回归操作仅使用了一次,导致目标的定位不会非常精准;因此RefineDet虽然仍然是single shot方法,但借鉴了2-stage的目标检测算法,提出2-stage cascaded的bbox回归策略,可以更精准地定位bbox

也即,ARM中先第一次调整anchor的位置、尺度,使之为ODM提供修正后的anchor;整体操作方式与RPN类似,在参与预测的feature map每个位置上,密集采样 n 个anchors,每个anchor的尺度、长宽比是预定义好的,位置也是相对固定的;ARM就相当于RPN操作,为每个anchor预测其相对位置偏置(relative offsets,也即,对anchor原始位置的相对位移),并预测每个anchor的objectness二分类得分,那么最终就得到了 n 个调整后的anchor,当然了,并不是所有anchor都被判定为包含目标,ARM就只需要筛选判别为目标的anchor,走下一步ODM的流程;

得到ARM调整的anchor之后,再结合fig 1、2,利用TCB得到anchor在feature map上新的特征,并在ODM上进一步实施类似SSD的操作,预测bbox的位置、大小、具体类别等;操作就跟SSD很像了,ARM、ODM上feature map的尺度一致,对ARM中每个判定为object的1-stage refined anchor,进一步输出 C 类得分 + 2-stage refined anchor(也即4个坐标offsets),输出也与SSD保持一致,每个anchor对应C + 4维的输出


注:这里跟SSD很像,SSD没有使用RoI Pooling,直接在feature map中密集地预测每个位置上,预定义的不同尺度default bbox对应的类别与位置;ODM的操作与SSD一致,使用ARM之目的,是为了减少ODM需要预测的anchor数量,若ARM中预测anchor的objectness得分比较低,那么该位置就被认为没有目标,就不需要走ODM步骤了;另外一个重要作用,就是将anchor的位置refine两次,这也是RefineDet论文名字的由来;

SSD与RefineDet另一区别为:SSD仅使用1-stage的default box分类、位置预测方案,RefineDet对anchor的调整却是2-stage的,ARM会对预定义anchor做1st-stage refine,ODM将ARM refined anchor做2nd-stage refine,检测的bbox就会更准了


这里稍微对比下RefineDet与frcnn的区别:

(1) frcnn通过1st-stage的RPN对anchor做了第一次调整,再通过2nd-stage的Fast RCNN做第二次调整,anchor的分类、回归是在两个分支上,独立地完成的;1、2-stage间的特征通过RoI pooling衔接

(2) RefineDet中ARM的操作与RPN类似,但ARM上的特征通过TCB传至ODM(TCB在操作上与DSSD、FPN类似,top-down地融合了高低层feature map特征),并通过ARM上输出的anchor objectness得分,避免了在ODM上预测所有anchor,达到了文中提到的减少anchor搜索空间之目的(有点类似RON中的objectness attention操作),并做了1st-stage的anchor调整;ODM上的操作就与SSD类似了,输出目标位置和分类,做了2-ndstage的anchor调整,且ARM指导了ODM没必要对所有anchor都做此类预测

(3) RefineDet代码(pytorch、mxnet)中,并未使用1st-stage中anchor的objectness得分来抑制non-object的anchor,而是在2nd-stage中和bg-cls一起抑制非目标检测框的;


2.2 Negative Anchor Filtering

目标检测中有一个问题就是训练阶段,正负样本数量不均衡,负样本数量特别多,RefineDet为此设计了negative anchor filtering机制,过滤了海量的易分负样本(well-classified negative anchors),使得正负样本数据更均衡了;

也即训练阶段,对于一个refined anchor,且其gt label为负样本,如果ARM中预测其objectness得分小于某阈值(“真实” 负样本轻易地被模型判定为non-object,太容易了,还是不要给模型训练为好,不然模型容易骄傲~~~),在ODM训练阶段就直接舍弃之;也即,只有ARM输出的 refined 难分负样本 + 所有正样本参与到ODM的训练

注:这点作者讲得不详细,其实跟OHEM策略类似,目标就是为了找到难分负样本,一方面使得正负样本数量均衡,另一方面在易分负样本上训练没啥意思,自然需要找到难分负样本,给模型的训练带来挑战;那么ARM中所有参与训练的负样本,我们自然希望找到objectness得分高的负样本objectness得分高,就意味着该负样本被模型误判为object了,自然得进一步训练,以纠正模型的这种误操作),过滤objectness得分低的负样本(模型轻易就判别出该负样本是non-object了,可以不用再学了);---- 这个操作在pytorch_refindet中有显示

预测阶段,若ARM中某anchor的objectness得分小于特定阈值,就不会进入ODM阶段,也即ARM首先对所有密集采样的anchor,通过objectness得分过滤到判别为背景的anchorfilters out the regularly tiled anchors with the negative confidence scores > thres),对剩余anchor做位置、尺度refine(第一次refine);TCB转换特征后,ODM进一步利用剩余的anchor采用SSD这种直接预测具体类别 + bbox位置的方式操作一波(第二次refine),最终输出top400置信度得分的bboxes,再通过IoU = 0.45的NMS操作,并保留最多200个bboxes作为最终检测结果;---- 这个操作可能是我理解有误,在pytorch_refindet未有体现,实际上ARM中所有anchor都直接传到了ODM中,但ARM中所有anchor确实完成了1st-stage的refine,ODM中为每个anchor进一步预测2nd-stage的bbox reg + cls,结合1st-stage的objectness得分 + 2nd-stage的bbox cls(具体类别)得分,一起筛选有效anchor,并结合2nd-stage的bbox reg预测结果,完成2nd-stage的refine后,最后NMS输出结果;


然后就是我的一个纠正,有一位同学提到了一个很好的问题:

有个疑惑,为什么RefineDet算是一个one-stage的方法呢?思路不是先得到粗筛然后再修正吗?

我的回答

你提出了一个非常好的问题,yolo,ssd等1-stage方案的特点就是直接在feature map上预测bbox,frcnn等2-stage方案的特点就是先有个rpn的粗筛,再进一步精筛。

这里有一点,就是现阶段1/2-stage算法的界限越来越模糊了,我在读refinedet论文时,也有和你一样的疑惑,1st-stage作用感觉和rpn区别不大,也是objectness先验+bbox reg,再结合2nd-stage,只不过把Fast rcnn的分支替换成了ssd的方式而已。为何称为1-stage?

不过当我阅读refinedet代码时,我明白了1-stage的意思,arm中虽然做了objectness判断+bbox reg,但作者为了代码实现的方便,其实1st-stage调整后的anchor,并未做抑制,而是在2nd-stage再结合具体类别的bbox cls + 1st-stage的objectness先验一起做的抑制。那么也就是说,1st/2nd-stage中,bbox调整了两次,也筛选了两次,但两次筛选是一起在2nd-stage完成的,这点在pytorch/mxnet的源码里都有体现,不过作者官方的caffe源码我还没来得及看,我猜跟我的思路应该一致;----- 但1st-ARM中anchor的抑制体现在了模型的训练中;

我还是想强调的是,现在1/2-stage算法的界限越来越模糊了,另外因为我当初理解得不深,所以表达不清楚,造成了一些误解。

另外为什么源码实现上要这么操作?

我觉得主要是为了实现的方便,不然要通过记录index来知道哪些arm抑制的anchor没必要过odm,再把index映射回原位置,还不如一抹黑先当他们都未被抑制,计算了reg后再秋后算账一起抑制掉,计算量是稍微增加了,但实现上就很方便,特别是对于ssd, refinedet这种直接在feature map的pixel-wise上计算bbox cls+reg的算法,统一抑制筛选的方式,真的实现起来就很方便了,只用添加几行代码即可;


3 RefineDet源码学习

先学习RefineDet_vgg主干网代码:

import os
import torch
import torch.nn as nn
import torch.nn.functional as F

from layers import *

# vgg主干网结构
vgg_base = {
    '320': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'C', 512, 512, 512, 'M', 512, 512, 512],
}

# This function is derived from torchvision VGG make_layers()
# https://github.com/pytorch/vision/blob/master/torchvision/models/vgg.py
# vgg的backbone加载,未灌入参数
def vgg(cfg, i, batch_norm=False):
    layers = []
    in_channels = i   # 初始化的输入通道数,image dim = 3
    for v in cfg:     # cfg就是base中的取值
        if v == 'M':
            layers += [nn.MaxPool2d(kernel_size=2, stride=2)]                   # 'M'对应pooling
        elif v == 'C':
            layers += [nn.MaxPool2d(kernel_size=2, stride=2, ceil_mode=True)]   # 'C'也对应pooling,做了feature map尺度规整
        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

    # 后接vgg其它层操作
    pool5 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
    conv6 = nn.Conv2d(512, 1024, kernel_size=3, padding=6, dilation=6)   # 这个就类似global pooling
    conv7 = nn.Conv2d(1024, 1024, kernel_size=1)
    layers += [pool5, conv6, nn.ReLU(inplace=True), conv7, nn.ReLU(inplace=True)]

    return layers

# refinedet网络结构定义
class RefineSSD(nn.Module):
    """Single Shot Multibox Architecture
    The network is composed of a base VGG network followed by the
    added multibox conv layers.  Each multibox layer branches into
        1) conv2d for class conf scores
        2) conv2d for localization predictions
        3) associated priorbox layer to produce default bounding
           boxes specific to the layer's feature map size.
    See: https://arxiv.org/pdf/1512.02325.pdf for more details.

    Args:
        phase: (string) Can be "test" or "train"
        base: VGG16 layers for input, size of either 300 or 500
        extras: extra layers that feed to multibox loc and conf layers
        head: "multibox head" consists of loc and conf conv layers
    """

    def __init__(self, size, num_classes, use_refine=False):
        super(RefineSSD, self).__init__()
        self.num_classes = num_classes
        self.size = size
        self.use_refine = use_refine

        # SSD network,backbone
        self.base = nn.ModuleList(vgg(vgg_base['320'], 3))

        # Layer learns to scale the l2 normalized features from conv4_3
        self.L2Norm_4_3 = L2Norm(512, 10)    # 参照parsenet,与论文稍微有点出入,论文中是在conv3_3、conv4_3上设置
        self.L2Norm_5_3 = L2Norm(512, 8)     # 参照parsenet

        # 接在vgg base后的新增层
        self.extras = nn.Sequential(nn.Conv2d(1024, 256, kernel_size=1, stride=1, padding=0), nn.ReLU(inplace=True), \
                                    nn.Conv2d(256, 512, kernel_size=3, stride=2, padding=1), nn.ReLU(inplace=True))

        # arm, oup_channel为何是12? 3 x 4, 对应3个anchor
        if use_refine:
            self.arm_loc = nn.ModuleList([nn.Conv2d(512, 12, kernel_size=3, stride=1, padding=1), \
                                          nn.Conv2d(512, 12, kernel_size=3, stride=1, padding=1), \
                                          nn.Conv2d(1024, 12, kernel_size=3, stride=1, padding=1), \
                                          nn.Conv2d(512, 12, kernel_size=3, stride=1, padding=1), \
                                          ])
            self.arm_conf = nn.ModuleList([nn.Conv2d(512, 6, kernel_size=3, stride=1, padding=1), \
                                           nn.Conv2d(512, 6, kernel_size=3, stride=1, padding=1), \
                                           nn.Conv2d(1024, 6, kernel_size=3, stride=1, padding=1), \
                                           nn.Conv2d(512, 6, kernel_size=3, stride=1, padding=1), \
                                           ])   # 类别,objectness,3 x 2

        # odm
        self.odm_loc = nn.ModuleList([nn.Conv2d(256, 12, kernel_size=3, stride=1, padding=1), \
                                      nn.Conv2d(256, 12, kernel_size=3, stride=1, padding=1), \
                                      nn.Conv2d(256, 12, kernel_size=3, stride=1, padding=1), \
                                      nn.Conv2d(256, 12, kernel_size=3, stride=1, padding=1), \
                                      ])
        self.odm_conf = nn.ModuleList([nn.Conv2d(256, 3*num_classes, kernel_size=3, stride=1, padding=1), \
                                       nn.Conv2d(256, 3*num_classes, kernel_size=3, stride=1, padding=1), \
                                       nn.Conv2d(256, 3*num_classes, kernel_size=3, stride=1, padding=1), \
                                       nn.Conv2d(256, 3*num_classes, kernel_size=3, stride=1, padding=1), \
                                       ])       # 类别,objectness,3 x num_classes

        # arm操作的最高层feature map,无需结合更高层feature map
        self.last_layer_trans = nn.Sequential(nn.Conv2d(512, 256, kernel_size=3, stride=1, padding=1),
                                              nn.ReLU(inplace=True),
                                              nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
                                              nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1))

        # 对应fig 2中eltw-sum上半部分
        self.trans_layers = nn.ModuleList([nn.Sequential(nn.Conv2d(512, 256, kernel_size=3, stride=1, padding=1),
                                                         nn.ReLU(inplace=True),
                                                         nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1)), \
                                           nn.Sequential(nn.Conv2d(512, 256, kernel_size=3, stride=1, padding=1),
                                                         nn.ReLU(inplace=True),
                                                         nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1)), \
                                           nn.Sequential(nn.Conv2d(1024, 256, kernel_size=3, stride=1, padding=1),
                                                         nn.ReLU(inplace=True),
                                                         nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1)), \
                                           ])

        # 对应fig 2中eltw-sum右半部分,相当于feature map上采样操作,结合fig 1、2,只需做三次上采样
        self.up_layers = nn.ModuleList([nn.ConvTranspose2d(256, 256, kernel_size=2, stride=2, padding=0),
                                        nn.ConvTranspose2d(256, 256, kernel_size=2, stride=2, padding=0),
                                        nn.ConvTranspose2d(256, 256, kernel_size=2, stride=2, padding=0), ])

        # 对应fig 2中eltw-sum下半部分
        self.latent_layrs = nn.ModuleList([nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
                                           nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
                                           nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
                                           ])

        self.softmax = nn.Softmax()

    def forward(self, x, test=False):
        """Applies network layers and ops on input image(s) x.

        Args:
            x: input image or batch of images. Shape: [batch,3*batch,300,300].

        Return:
            Depending on phase:
            test:
                Variable(tensor) of output class label predictions,
                confidence score, and corresponding location predictions for
                each object detected. Shape: [batch,topk,7]

            train:
                list of concat outputs from:
                    1: confidence layers, Shape: [batch*num_priors,num_classes]
                    2: localization layers, Shape: [batch,num_priors*4]
                    3: priorbox layers, Shape: [2,num_priors*4]
        """
        arm_sources = list()
        arm_loc_list = list()
        arm_conf_list = list()
        obm_loc_list = list()
        obm_conf_list = list()
        obm_sources = list()

        # apply vgg up to conv4_3 relu
        for k in range(23):
            x = self.base[k](x)
        s = self.L2Norm_4_3(x)    # conv4_3上牵出一个arm检测分支
        arm_sources.append(s)

        # apply vgg up to conv5_3
        for k in range(23, 30):
            x = self.base[k](x)
        s = self.L2Norm_5_3(x)    # conv5_3上牵出一个arm检测分支
        arm_sources.append(s)

        # apply vgg up to fc7
        for k in range(30, len(self.base)):
            x = self.base[k](x)
        arm_sources.append(x)     # vgg-reduced版本下,全卷积的fc7上牵出一个arm检测分支

        # conv6_2,接在vgg base后的新增层
        x = self.extras(x)
        arm_sources.append(x)     # vgg base后新增的extras后,全卷积的conv6_2上牵出一个arm检测分支,相当于是最后一个conv layer

        # apply multibox head to arm branch, arm分支的cls + loc预测,刚好对应arm_sources新增的4个分支
        if self.use_refine:
            for (x, l, c) in zip(arm_sources, self.arm_loc, self.arm_conf):
                arm_loc_list.append(l(x).permute(0, 2, 3, 1).contiguous())
                arm_conf_list.append(c(x).permute(0, 2, 3, 1).contiguous())
            arm_loc = torch.cat([o.view(o.size(0), -1) for o in arm_loc_list], 1)      # 所有分支上的loc结果,做一个concate聚合
            arm_conf = torch.cat([o.view(o.size(0), -1) for o in arm_conf_list], 1)    # 所有分支上的cls结果,做一个concate聚合

        # refinedet最高层,也即全卷积的conv6_2上牵出来的tcb模块,对应论文fig 1中最后一个tcb,无需高层feature map,直接类似fig 2的上半部分操作即可
        x = self.last_layer_trans(x)
        obm_sources.append(x)              # odm预测分支

        # get transformed layers,特征层转换,对应fig 2中eltw-sum的上半部分,也是从arm_sources分支上牵出来的
        trans_layer_list = list()
        for (x_t, t) in zip(arm_sources, self.trans_layers):
            trans_layer_list.append(t(x_t))

        # fpn module
        trans_layer_list.reverse()    # 因为是fpn-style的top-down结构,trans_layer_list掉换个顺序先
        arm_sources.reverse()         # arm_sources同样调换顺序

        # 整个操作其实很简单,就是tcb模块,[u(x) + t]对应fig 2上、右半部分,[l]对应fig 2下半部分,再加了两个relu操作,
        for (t, u, l) in zip(trans_layer_list, self.up_layers, self.latent_layrs):
            x = F.relu(l(F.relu(u(x) + t, inplace=True)), inplace=True)
            obm_sources.append(x)

        obm_sources.reverse()   # odm构建好了,从top-down调换成down-top结构,方便预测
        for (x, l, c) in zip(obm_sources, self.odm_loc, self.odm_conf):    # odm分支的cls + loc预测,与arm分支刚好对应
            obm_loc_list.append(l(x).permute(0, 2, 3, 1).contiguous())
            obm_conf_list.append(c(x).permute(0, 2, 3, 1).contiguous())
        obm_loc = torch.cat([o.view(o.size(0), -1) for o in obm_loc_list], 1)       # 所有分支上的loc结果,做一个concate聚合
        obm_conf = torch.cat([o.view(o.size(0), -1) for o in obm_conf_list], 1)     # 所有分支上的cls结果,做一个concate聚合

        # apply multibox head to source layers
        if test:
            if self.use_refine:
                output = (
                    arm_loc.view(arm_loc.size(0), -1, 4),               # arm, loc preds
                    self.softmax(arm_conf.view(-1, 2)),                 # arm, conf preds, softmax
                    obm_loc.view(obm_loc.size(0), -1, 4),               # odm, loc preds
                    self.softmax(obm_conf.view(-1, self.num_classes)),  # odm, conf preds, softmax
                )
            else:
                output = (
                    obm_loc.view(obm_loc.size(0), -1, 4),               # odm, loc preds
                    self.softmax(obm_conf.view(-1, self.num_classes)),  # odm, conf preds
                )
        else:
            if self.use_refine:
                output = (
                    arm_loc.view(arm_loc.size(0), -1, 4),                   # arm, loc preds
                    arm_conf.view(arm_conf.size(0), -1, 2),                 # arm, conf preds
                    obm_loc.view(obm_loc.size(0), -1, 4),                   # odm, loc preds
                    obm_conf.view(obm_conf.size(0), -1, self.num_classes),  # odm, conf preds
                )
            else:
                output = (
                    obm_loc.view(obm_loc.size(0), -1, 4),                   # odm, loc preds
                    obm_conf.view(obm_conf.size(0), -1, self.num_classes),  # odm, conf preds
                )

        return output

    # refinedet有两种训练方式:直接使用vgg模型参数、基于vgg继续训练,还有一种就是在已有refinedet上继续finetune
    # 这里对应着模型加载和参数赋值操作
    def load_weights(self, base_file):
        other, ext = os.path.splitext(base_file)
        if ext == '.pkl' or '.pth':
            print('Loading weights into state dict...')
            self.load_state_dict(torch.load(base_file, map_location=lambda storage, loc: storage))
            print('Finished!')
        else:
            print('Sorry only .pth and .pkl files supported.')


# Refinedet网络构建
def build_net(size=320, num_classes=21, use_refine=False):
    if size != 320:
        print("Error: Sorry only SSD300 and SSD512 is supported currently!")
        return

    return RefineSSD(size, num_classes=num_classes, use_refine=use_refine)


论文参考

CVPR2018_RefineDet_Single-Shot Refinement Neural Network for Object Detection


代码:

github.com/sfzhang15/Re:caffe,官方版

github.com/lzx1413/Pyto:pytorch

github.com/MTCloudVisio:mxnet

发布于 2018-11-26

文章被以下专栏收录