Faster-RCNN论文细节原理解读+代码实现gluoncv(MXNet)

  Faster-RCNN开创了基于锚框(anchors)的目标检测框架,并且提出了RPN(Region proposal network),来生成RoI,用来取代之前的selective search方法。Faster-RCNN无论是训练/测试速度,还是物体检测的精度都超过了Fast-RCNN,并且实现了end-to-end训练。

  从RCNN到Fast-RCNN再到Faster-RCNN,后者无疑达到了这一系列算法的巅峰,并且后来的YOLO、SSD、Mask-RCNN、RFCN等物体检测框架都是借鉴了Faster-RCNN

  Faster-RCNN作为一种two-stage的物体检测框架,流程无疑比SSD这种one-stage物体检测框架要复杂,在阅读论文,以及代码复现的过程中也理解了很多细节,在这里记录一下自己的学习过程和自己的一点体会。

文章目录

  • 背景介绍
  • 论文主要贡献
  • 1、网络框架
  • 2、RPN(Region Proposal Network)
    • 处理流程
    • 详细步骤及代码
      • 在feature_map上生成锚框
      • 用conv3x3卷积进一步提取特征图
      • 用1x1卷积层进行二分类预测以及边界框回归预测
      • 使用预测的score和offset对锚框处理,输出Region Proposal
      • RPN整体代码
  • 3、对RPN输出的Region Proposal采样处理
    • 训练过程中的Region Proposal采样
    • 测试过程中的Region Proposal采样
    • Region Proposal采样代码
  • 4、RoI Pooling层
  • 5、后续Fast-RCNN处理
    • 处理流程
    • 代码
  • 6、总结
  • 7、题外话

背景介绍

  Fast-RCNN通过共享卷积层,极大地提升了整体的运算速度。Selective Search 反倒成为了限制计算效率的瓶颈。Faster-RCNN中使用卷积神经网络取代了Selective Search,这个网络就是Region Proposal Networks(RPN),Faster-RCNN将所有的步骤都包含到一个完整的框架中,真正实现了端对端(end-to-end)的训练。


论文主要贡献

  • 提出RPN,实现了端对端的训练
  • 提出了基于anchors的物体检测方法

1、网络框架

  Faster-RCNN总体流程框图如下(点击原图查看大图),通过这个框图我们比较一下Faster-RCNN和SSD的不同:

  • SSD中每一阶段生成的特征图,每个cell都会生成锚框,并且进行类别+边界框回归。
  • Faster-RCNN只对basenet提取出的特征图上生成锚框,并且对该锚框进行二分类(背景 or 有物体)+边界框回归,然后会进行NMS移除相似的结果,这样RPN最后会输出一系列region proposal,将这些region proposal区域从feature map中提取出来即为RoI,之后将会通过RoI pooling,进行真正的类别预测(判断属于哪一类)+边界框回归

  可以看出Faster-RCNN之所以被称为two-stage,是由于需要有RPN生成region proposal这一步骤。相比来看SSD可以看做是稠密采样,它对所有生成的锚框进行了预测,而没有进行筛选。

  RPN中还有一些细节操作,比如说采样比例的设置,如何进行预测,这个在后面的部分会详细说明。


2、RPN(Region Proposal Network)

处理流程

  RPN在Faster-RCNN中作用为生成RoI,RPN的处理流程具体如下,一些细节将在之后介绍:

  1. 输入为base_net提取出来的feature map,首先在feature map上生成锚框(anchor),其中每个cell有多个锚框。
  2. 通过一个conv_3x3,stride=1,padding=1的卷积层,进一步提取特征,输出特征图的大小不变,这里称为rpn_feature
  3. rpn_feature上用两个1x1卷积层进行预测输出,分别为每个锚框的二分类分数、每个锚框的坐标偏移量。
  4. 利用上面预测的分数以及偏移量,对锚框(anchor)进行非极大值抑制(NMS)操作,最终输出RoI候选区域

Faster-RCNN论文细节原理解读+代码实现gluoncv(MXNet)_第1张图片

详细步骤及代码

在feature_map上生成锚框

这一步中,会在feature_map每个cell上生成一系列不同大小和宽高比例的锚框。生成锚框的方式如下:
1. 选定一个锚框的基准大小,记为base,比如为16
2. 选定一组宽高比例(aspect ratios),比如为【0.5、1、2】
3. 选定一组大小比例(scales),比如为【16、32、64】
4. 那么每个cell将会生成ratios*scales个锚框,而每个锚框的形状大小的计算公式如下:
w i d t h a n c h o r = s i z e b a s e × s c a l e × 1 / r a t i o width_{anchor} = size_{base} \times scale \times \sqrt{ 1 / ratio} widthanchor=sizebase×scale×1/ratio h e i g h t a n c h o r = s i z e b a s e × s c a l e × r a t i o height_{anchor} = size_{base} \times scale \times \sqrt{ratio} heightanchor=sizebase×scale×ratio

举个例子,我们按照论文中取3种大小比例以及3种长宽比例,那么每个cell生成的锚框个数为 k = 9 k=9 k=9,而假设我们的特征图大小为 W × H = 2400 W\times H=2400 W×H=2400,那么我们一共生成了 W H k WHk WHk个锚框。可以看到,生成的锚框数量非常多,有大量的重复区域。RPN输出时不应该使用所有锚框,所以采用NMS 来去除大量重复的锚框,而只选择一些得分较高的锚框作为RoI输出。其实,RPN在训练时也进行了采样,这个后面具体介绍。RPN生成的锚框如下图所示:

Faster-RCNN论文细节原理解读+代码实现gluoncv(MXNet)_第2张图片

MXNet中,生成锚框的类源码如下所示:

class RPNAnchorGenerator(gluon.Block):
    """
    @输入参数
    stride:int              
        特征图的每个像素感受野大小,通常为原图和特征图尺寸比例
    base_size:int           
        默认大小
    ratios:int              
        宽高比
    scales:int              
        大小比例
        
        每个锚框为   width = base_size*size/sqrt(ratio)  
                    height = base_size*size*sqrt(ratio)
        
    alloc_size:(int,int)          
        默认的特征图大小(H,W),以后每次生成直接索引切片
    """

    def __init__(self, stride, base_size, ratios, scales, alloc_size, **kwargs):
        super(RPNAnchorGenerator, self).__init__(**kwargs)
        if not base_size:
            raise ValueError("Invalid base_size: {}".format(base_size))
        # 防止非法输入
        if not isinstance(ratios, (tuple, list)):
            ratios = [ratios]
        if not isinstance(scales, (tuple, list)):
            scales = [scales]

        # 每个像素的锚框数
        self._num_depth = len(ratios) * len(scales)
        # 预生成锚框
        anchors = self._generate_anchors(stride, base_size, ratios, scales, alloc_size)
        self.anchors = self.params.get_constant('anchor_', anchors)

    @property
    def num_depth(self):
        return self._num_depth

    def _generate_anchors(self, stride, base_size, ratios, scales, alloc_size):
        # 计算中心点坐标
        px, py = (base_size - 1) * 0.5, (base_size - 1) * 0.5
        base_sizes = []
        for r in ratios:
            for s in scales:
                size = base_size * base_size / r
                ws = np.round(np.sqrt(size))
                w = (ws * s - 1) * 0.5
                h = (np.round(ws * r) * s - 1) * 0.5
                base_sizes.append([px - w, py - h, px + w, py + h])
        # 每个像素的锚框
        base_sizes = np.array(base_sizes)

        # 下面进行偏移量的生成
        width, height = alloc_size
        offset_x = np.arange(0, width * stride, stride)
        offset_y = np.arange(0, height * stride, stride)
        offset_x, offset_y = np.meshgrid(offset_x, offset_x)
        # 生成(H*W,4)
        offset = np.stack((offset_x.ravel(), offset_y.ravel(),
                           offset_x.ravel(), offset_y.ravel()), axis=1)

        # 下面广播到每一个anchor中    (1,N,4) + (M,1,4)
        anchors = base_sizes.reshape((1, -1, 4)) + offset.reshape((-1, 1, 4))
        anchors = anchors.reshape((1, 1, width, height, -1)).astype(np.float32)
        return anchors

    # 对原始生成的锚框进行切片操作
    def forward(self, x):
        # 切片索引
        anchors = self.anchors.value
        a = nd.slice_like(anchors, x * 0, axes=(2, 3))
        return a.reshape((1, -1, 4))

用conv3x3卷积进一步提取特征图

  这一步中就是RPN进一步抽取特征,生成的RPN-feature map提供给之后的类别预测和回归预测。该步骤中使用的是kernel_size=3x3,strides=1,padding=1,Activation='relu'的卷积层,不改变特征图的尺寸,这也是为了之后的1x1卷积层预测时,空间位置能够一一对应,而用通道数来表示预测的类别分数和偏移量。这一步的代码很简单,就是单独的构建了一个3x3 Conv2D的卷积层。


# 第一个提取特征的3x3卷积
self.conv1 = nn.Sequential()
self.conv1.add(nn.Conv2D(channels, kernel_size=3, strides=1, padding=1, 
						 weight_initializer=weight_initializer), nn.Activation('relu'))

用1x1卷积层进行二分类预测以及边界框回归预测

  我们在第一步中生成了固定的默认锚框,这一步我们需要用两个1x1卷积层对每个锚框分别预测(1)类别分数(背景or物体) s c o r e score score(2)锚框偏移量 o f f s e t offset offset。而这些预测值 s c o r e 、 o f f s e t score、offset scoreoffset将用于后面的NMS操作,可以去除一些得分低,或者有大量重复区域的锚框,从而最终输出良好的Region Proposal给后面网络进行处理。

  • 类别分数 s c o r e score score,RPN中只关心是否有物体,所以是个二分类问题(背景、物体)。
  • 锚框的坐标偏移量 o f f s e t offset offset,一般为4个值, Δ x c e n t e r 、 Δ y c e n t e r 、 Δ w i d t h 、 Δ h e i g h t \boldsymbol\Delta xcenter、\boldsymbol\Delta ycenter、\boldsymbol\Delta width、\boldsymbol\Delta height ΔxcenterΔycenterΔwidthΔheight

  上面介绍了,两个1x1卷积层的输入为RPN-feature map,1x1卷积并不改变特征图尺寸,我们采用通道数来表示对应cell锚框的预测值。假设输入RPN-feature map 形状为 ( C , H , W ) (C,H,W) (CHW),每个cell生成了 k k k个锚框。输出的锚框分数和偏移量在空间位置上一一对应(也就是尺寸不变)。

  • 类别分数,输出通道应为 ( k × 2 , H , W ) (k\times2,H,W) (k×2,H,W),不同通道表示每个类别的分数
  • 偏移量预测,输出通道应为 ( k × 4 , H , W ) (k\times4,H,W) (k×4,H,W),不同通道表示锚框的坐标偏移量

Faster-RCNN论文细节原理解读+代码实现gluoncv(MXNet)_第3张图片

  代码很简单,就是添加两个卷积层并前向运算:

# 预测偏移量和预测类别的卷积层
 # 使用sigmoid预测,减少通道数
 self.score = nn.Conv2D(anchor_depth, kernel_size=1, strides=1, padding=0,
                        weight_initializer=weight_initializer)
 self.loc = nn.Conv2D(anchor_depth * 4, kernel_size=1, strides=1, padding=0,
                      weight_initializer=weight_initializer)

使用预测的score和offset对锚框处理,输出Region Proposal

  上面的步骤中,我们会对feature map的每个cell都生成多个锚框,并且预测 s c o r e 、 o f f s e t score、offset scoreoffset,我们生成了 W H k WHk WHk个锚框(大约有2W个),不难想象,大量的锚框其实都是背景,而且有着大量的重叠锚框,我们不可能将所有的锚框都当做Region Proposal输出给RoI Pooling层,提供给Fast-RCNN进行后面的进一步运算。第一个原因是会造成计算量过大,第二个原因是大量的背景框,重复的锚框是没有意义的,我们应该输出得分最高的topk个锚框。最后一步的Region Proposal具体处理过程如下:

  • 将上一步预测的偏移量加到生成的默认锚框中,我们把这些区域称作RoI
  • 对超出图像边界的RoI进行剪切,保证所有RoI都在原始图像内部
  • 丢弃小于我们设定最小尺寸的锚框
  • 根据我们预测的 s c o r e score score,对RoI进行非极大值抑制操作(NMS),去除得分较低以及重复区域的RoI
  • 最后我们选择得分为topk的RoI输出,作为最终输出的Region Proposal(比如说前2000个)

  通过这一步,我们筛选出了置信度最高的Region Proposal,也就是我们认为最有可能有物体的区域,输入到后面的Fast-RCNN网络中,进行最终的分类以及再一次的边界框回归预测。MXNet GluonCV 中生成Region Proposal的类源码如下:

class RPNProposal(gluon.Block):
    """
    @:parameter
    ------------------
    clip : float
        如果提供,将bbox剪切到这个值
    num_thresh : float
        nms的阈值,用于去除重复的框
   train_pre_nms : int
        训练时对前 train_pre_nms 进行 NMS操作
    train_post_nms : int
        训练时进行NMS后,返回前 train_post_nms 个region proposal
    test_pre_nms : int
        测试时对前 test_pre_nms 进行 NMS操作
    test_post_nms : int
        测试时进行NMS后,返回前 test_post_nms 个region proposal
    min_size : int
        小于 min_size 的 proposal将会被舍弃
    
    stds : tuple of int 
        计算偏移量用的标准差
    
    """

    def __init__(self, clip, nms_thresh, train_pre_nms, train_post_nms,
                 test_pre_nms, test_post_nms, min_size, stds, **kwargs):
        super(RPNProposal, self).__init__(**kwargs)
        self._clip = clip
        self._nms_thresh = nms_thresh
        self._train_pre_nms = train_pre_nms
        self._train_post_nms = train_post_nms
        self._test_pre_nms = test_pre_nms
        self._test_post_nms = test_post_nms
        self._min_size = min_size
        self._bbox_decoder = NormalizedBoxCenterDecoder(stds=stds, clip=clip)
        self._cliper = BBoxClipToImage()
        self._bbox_tocenter = BBoxCornerToCenter(axis=-1, split=False)

    """
    @:parameter
    scores : (B,N,1) 
        通过RPN预测的得分输出(sigmoid之后) (0,1)
    offsets : ndarray (B,N,4)
        通过RPN预测的锚框偏移量
    anchors : ndarray (B,N,4)
        生成的默认锚框,坐标编码方式为 Corner
    img : ndarray (B,C,H,W)
        图像的张量,用来剪切锚框
    
    @:returns
    
    
    """

    def forward(self, scores, offsets, anchors, img):
        # 训练和预测的处理流程不同
        if autograd.is_training():
            pre_nms = self._train_pre_nms
            post_nms = self._train_post_nms
        else:
            pre_nms = self._test_pre_nms
            post_nms = self._test_post_nms
        with autograd.pause():
            # 将预测的偏移量加到anchors中
            rois = self._bbox_decoder(offsets, self._bbox_tocenter(anchors))
            rois = self._cliper(rois, img)

            # 下面将所有尺寸小于设定最小值的ROI去除
            x_min, y_min, x_max, y_max = nd.split(rois, num_outputs=4, axis=-1)
            width = x_max - x_min
            height = y_max - y_min
            invalid_mask = (width < self._min_size) + (height < self._min_size)

            # 将对应位置的score 设为-1
            scores = nd.where(invalid_mask, nd.ones_like(scores) * -1, scores)
            invalid_mask = nd.repeat(invalid_mask, repeats=4, axis=-1)
            rois = nd.where(invalid_mask, nd.ones_like(rois) * -1, rois)

            # 下面进行NMS操作
            pre = nd.concat(scores, rois, dim=-1)
            pre = nd.contrib.box_nms(pre, overlap_thresh=self._nms_thresh, topk=pre_nms,
                                     coord_start=1, score_index=0, id_index=-1, force_suppress=True)
            # 下面进行采样
            result = nd.slice_axis(pre,axis=1, begin=0, end=post_nms)
            rpn_score = nd.slice_axis(result, axis=-1, begin=0, end=1)
            rpn_bbox = nd.slice_axis(result, axis=-1, begin=1, end=None)

        return rpn_score, rpn_bbox

  RPN最终输出的Region Proposal 如图所示,去除了大量的重复锚框,和得分低的背景区域:
Faster-RCNN论文细节原理解读+代码实现gluoncv(MXNet)_第4张图片

RPN整体代码

  RPN的处理流程如上所述,下面是RPN层的整体代码:

# 定义RPN网络
# RPN网络输出应为一系列 region proposal  默认为 2000个
class RPN(nn.Block):
    """
    @输入参数
    channels : int
        卷积层的输出通道
    stride:int              
        特征图的每个像素感受野大小,通常为原图和特征图尺寸比例
    base_size:int           
        默认大小
    ratios:int              
        宽高比
    scales:int              
        大小比例
        
        每个锚框为   width = base_size*size/sqrt(ratio)  
                    height = base_size*size*sqrt(ratio)
        
    alloc_size:(int,int)          
        默认的特征图大小(H,W),以后每次生成直接索引切片
        
    clip : float
        如果设置则将边界框剪切到该值
    nms_thresh : float
        非极大值抑制的阈值
    train_pre_nms : int
        训练时对前 train_pre_nms 进行 NMS操作
    train_post_nms : int
        训练时进行NMS后,返回前 train_post_nms 个region proposal
    test_pre_nms : int
        测试时对前 test_pre_nms 进行 NMS操作
    test_post_nms : int
        测试时进行NMS后,返回前 test_post_nms 个region proposal
    min_size : int
        小于 min_size 的 proposal将会被舍弃
    
    """

    def __init__(self, channels, stride, base_size, ratios,
                 scales, alloc_size, clip, nms_thresh,
                 train_pre_nms, train_post_nms, test_pre_nms, test_post_nms
                 , min_size, **kwargs):
        super(RPN, self).__init__(**kwargs)
        weight_initializer = mx.init.Normal(sigma=0.01)
        # 锚框生成器
        self._anchor_generator = RPNAnchorGenerator(stride, base_size, ratios, scales, alloc_size)
        anchor_depth = self._anchor_generator.num_depth
        self._rpn_proposal = RPNProposal(clip, nms_thresh, train_pre_nms,
                                         train_post_nms, test_pre_nms, test_post_nms, min_size, stds=(1., 1., 1., 1.))
        # 第一个提取特征的3x3卷积
        self.conv1 = nn.Sequential()
        self.conv1.add(nn.Conv2D(channels, kernel_size=3, strides=1, padding=1, weight_initializer=weight_initializer),
                       nn.Activation('relu'))
        # 预测偏移量和预测类别的卷积层
        # 使用sigmoid预测,减少通道数
        self.score = nn.Conv2D(anchor_depth, kernel_size=1, strides=1, padding=0,
                               weight_initializer=weight_initializer)
        self.loc = nn.Conv2D(anchor_depth * 4, kernel_size=1, strides=1, padding=0,
                             weight_initializer=weight_initializer)

    # 前向运算函数


    def forward(self, x, img):
        """
         产生锚框,并且对每个锚框进行二分类,以及回归预测
        ************************
         
         注意,这一阶段只是进行了粗采样,在RCNN中还要进行一次采样
         
         @:parameter
          -------------
          x : (B,C,H,W)
             由basenet提取出的特征图
         img : (B,C,H,W)
             图像tensor,用来剪切超出边框的锚框
    
         @:returns
         -----------------
         (1)训练阶段
         rpn_score : ndarray (B,train_post_nms,1)
             输出的region proposal 分数 (用来给RCNN采样)
    
         rpn_box : ndarray (B,train_post_nms,4)
             输出的region proposal坐标 Corner
    
         raw_score : ndarray (B,N,1)
             卷积层的原始输出,用来训练RPN
    
         rpn_bbox_pred : ndarray (B,N,4)
             卷积层的原始输出,用来训练RPN
    
         anchors : ndarray (1,N,4)
             生成的锚框
    
         (2)预测阶段
         
         rpn_score : ndarray (B,train_post_nms,1)
         输出的region proposal 分数 (用来给RCNN采样)
    
         rpn_box : ndarray (B,train_post_nms,4)
             输出的region proposal坐标 Corner
    
         """
        anchors = self._anchor_generator(x)
        # 提取特征
        feat = self.conv1(x)
        # 预测
        raw_score = self.score(feat)
        raw_score = raw_score.transpose((0, 2, 3, 1)).reshape(0, -1, 1)
        rpn_scores = mx.nd.sigmoid(mx.nd.stop_gradient(raw_score))
        rpn_bbox_pred = self.loc(feat)
        rpn_bbox_pred = rpn_bbox_pred.transpose((0, 2, 3, 1)).reshape(0, -1, 4)
        # 下面生成region proposal
        rpn_score, rpn_box = self._rpn_proposal(
           rpn_scores, mx.nd.stop_gradient(rpn_bbox_pred), anchors,img)
        # 处于训练阶段
        if autograd.is_training():
            # raw_score, rpn_bbox_pred 用于 RPN 的训练
            return rpn_score, rpn_box, raw_score, rpn_bbox_pred, anchors
        # 处于预测阶段
        return rpn_score, rpn_box


3、对RPN输出的Region Proposal采样处理

  上面说道通过RPN层后,我们进行了粗采样,输出了大约2000个Region Proposal,然而我们并不会将这个2000个Region Proposal全部送入RoI Pooling中进行计算,这样效率很低、计算很慢。论文作者对这些Region Proposal进行了采样处理,只采样了一小部分的Region Proposal送入之后的网络运算,而且训练过程的采样和预测过程的采样是不一样的。下面详细介绍一下处理流程。

训练过程中的Region Proposal采样

Faster-RCNN论文细节原理解读+代码实现gluoncv(MXNet)_第5张图片
  训练过程的采样在Fast-RCNN论文中有提到,由于要考虑训练过程中正负样本均衡的问题,最终输出了128个Region Proposal,其中正样本的比例为0.25。正负样本的定义如下:

  • 如果一个Region Proposal与任意一个ground truth的 IoU 大于设定阈值(默认为0.5),那么标记其为正样本,否则为负样本。

  将所有Region Proposal打上标记后,进行随机采样,其中采样正样本的比例为0.25,其余的为负样本。最终采样输出128个Region Proposal,送入之后的网络进行处理计算。

测试过程中的Region Proposal采样

  测试过程中的采样很简单,直接采样Region Proposal中, s c o r e s scores scores为前topk个(比如300)的样本,目的就是提取最有可能为物体的区域输入到后面的网络了。

Region Proposal采样代码


class RCNNTargetSampler(gluon.Block):
    """
    @:parameter
    ------------
    num_images : int
        每个batch的图片数,目前仅支持1
    num_inputs : int
        输入的RoI 数量
    num_samples : int
        输出的采样 RoI 数量
    pos_thresh : float
        正类样本阈值
        
    pos_ratio : float
        采样正样本的比例
        
    max_gt_box : int
    
    
    """

    def __init__(self, num_images, num_inputs, num_samples, pos_thresh, pos_ratio, max_gt_box, **kwargs):
        super(RCNNTargetSampler, self).__init__(**kwargs)
        self._num_images = num_images
        self._num_inputs = num_inputs
        self._num_samples = num_samples
        self._pos_thresh = pos_thresh
        self._pos_ratios = pos_ratio
        self._max_pos = int(np.round(num_samples * pos_ratio))
        self._max_gt_box = max_gt_box

    def forward(self, rois, scores, gt_bboxes):
        """
        @:parameter
        -----------
        rois : ndarray (B,self._num_inputs,4)
            RPN输出的roi区域坐标,Corner

        scores : ndarray (B,self._num_inputs,1)
            RPN输出的roi区域分数,(0,1) -1表示忽略

        gt_bboxes:ndarray (B,M,4)
            ground truth box 坐标

        @:returns
        -----------
        new_rois : ndarray (B,self._num_samples,4)
            采样后的RoI区域
        new_samples : ndarray (B,self._num_samples,1)
            采样后RoI区域的标签 1:pos -1:neg 0:ignore
        new_matches : ndarray (B,self._num_samples,1)
            采样后的RoI匹配的锚框编号 [0,M)
        
        """

        new_rois, new_samples, new_matches = [], [], []

        # 对每个batch分别进行处理
        for i in range(self._num_images):
            roi = nd.squeeze(nd.slice_axis(rois, axis=0, begin=i, end=i + 1), axis=0)
            score = nd.squeeze(nd.slice_axis(scores, axis=0, begin=i, end=i + 1), axis=0)
            gt_bbox = nd.squeeze(nd.slice_axis(gt_bboxes, axis=0, begin=i, end=i + 1), axis=0)

            # 将ground truth的分数设置为1 形状为(M,1)
            gt_score = nd.ones_like(nd.sum(gt_bbox, axis=-1, keepdims=True))

            # 将ground truth 和 roi 拼接 (N+M,4) (N+m,1)
            roi = nd.concat(roi, gt_bbox, dim=0)
            score = nd.concat(score, gt_score, dim=0).squeeze(axis=-1)

            # 计算iou   (N+M,M)
            iou = nd.contrib.box_iou(roi, gt_bbox, format='corner')
            # (N+M,)
            iou_max = nd.max(iou, axis=-1)
            # (N+M,)  与哪个ground truth 匹配
            iou_argmax = nd.argmax(iou, axis=-1)

            # 将所有的标记为 2 neg
            mask = nd.ones_like(iou_argmax) * 2
            # 标记ignore 为 0
            mask = nd.where(score < 0, nd.zeros_like(mask), mask)

            # 将正类标记为 3 pos
            pos_idx = (iou_max >= self._pos_thresh)

            mask = nd.where(pos_idx, nd.ones_like(mask) * 3, mask)

            # 下面进行shuffle操作
            rand = nd.random.uniform(0, 1, shape=(self._num_inputs + self._max_gt_box,))
            # 取前面 N+M 个 对mask 做shuffle操作
            rand = nd.slice_like(rand, mask)
            # shuffle 操作后的 index
            index = nd.argsort(rand)
            # 将三个结果进行shuffle
            mask = nd.take(mask, index)
            iou_argmax = nd.take(iou_argmax, index)

            # 下面进行采样
            # 排序 3:pos 2:neg 0:ignore
            order = nd.argsort(mask, is_ascend=False)
            # 取topk个作为正例
            topk = nd.slice_axis(order, axis=0, begin=0, end=self._max_pos)
            # 下面取出相对应的值
            pos_indices = nd.take(index, topk)
            pos_samples = nd.take(mask, topk)
            pos_matches = nd.take(iou_argmax, topk)

            # 下面将原来的标签改了
            pos_samples = nd.where(pos_samples == 3, nd.ones_like(pos_samples), pos_samples)
            pos_samples = nd.where(pos_samples == 2, nd.ones_like(pos_samples) * -1, pos_samples)

            index = nd.slice_axis(index, axis=0, begin=self._max_pos, end=None)
            mask = nd.slice_axis(mask, axis=0, begin=self._max_pos, end=None)
            iou_argmax = nd.slice_axis(iou_argmax, axis=0, begin=self._max_pos, end=None)

            # 对负样本进行采样
            # neg 2---->4
            mask = nd.where(mask == 2, nd.ones_like(mask) * 4, mask)
            order = nd.argsort(mask, is_ascend=False)
            num_neg = self._num_samples - self._max_pos
            bottomk = nd.slice_axis(order, axis=0, begin=0, end=num_neg)

            neg_indices = nd.take(index, bottomk)
            neg_samples = nd.take(mask, bottomk)
            neg_matches = nd.take(iou_argmax, topk)

            neg_samples = nd.where(neg_samples == 3, nd.ones_like(neg_samples), neg_samples)
            neg_samples = nd.where(neg_samples == 4, nd.ones_like(neg_samples) * -1, neg_samples)

            # 输出
            new_idx = nd.concat(pos_indices, neg_indices, dim=0)
            new_sample = nd.concat(pos_samples, neg_samples, dim=0)
            new_match = nd.concat(pos_matches, neg_matches, dim=0)

            new_rois.append(roi.take(new_idx))
            new_samples.append(new_sample)
            new_matches.append(new_match)

        new_rois = nd.stack(*new_rois, axis=0)
        new_samples = nd.stack(*new_samples, axis=0)
        new_matches = nd.stack(*new_matches, axis=0)

        return new_rois, new_samples, new_matches

4、RoI Pooling层

  通过上一步的采样后,我们得到了一堆没有class score的Region Proposal,这些Region Proposal是对应于我们第一步base net 提取出来 feature map上的区域。可以从网络图中看到,我们最终将Region Proposal又输出回我们feature map,我们可以将RPN看做是一个额外的中间过程,这也是Faster-RCNN被称为two-stage的原因。由于输出的Region Proposal大小并不一致,而Fast-RCNN最后为全连接层,需要输出固定尺寸的特征,所以RoI Pooling层的作用就是将这些大小不同的Region Proposal,映射输出为统一大小的特征图。比如我设置RoI Pooling层的输出大小为(14,14),那么无论输入的特征图尺寸是什么,输出的特征图均为(14,14)。
Faster-RCNN论文细节原理解读+代码实现gluoncv(MXNet)_第6张图片

  代码的话直接使用nd.ROIPooling()就能实现了。


5、后续Fast-RCNN处理

处理流程

  到了这一步我们的处理已经到了尾声了,我们通过RoI Pooling已经得到了固定尺寸的feature map,最后一步就是用Fast-RCNN,进行预测类别分数以及边界框的回归。具体的处理流程如下:

  1. 使用卷积层再提取一次特征
  2. 进行全局池化,将特征图尺寸变为(channel,1,1)
  3. 通过两个不同的全连接层,分别预测类别分数和进行坐标回归
    • 类别预测全连接层有num_classes+1个神经元,其中包括所有类别和背景
    • 坐标回归全连接层有4*num_classes个神经元,它会为每一个类别预测4个坐标回归值 Δ x c e n t e r 、 Δ y c e n t e r 、 Δ w i d t h 、 Δ h e i g h t \boldsymbol\Delta xcenter、\boldsymbol\Delta ycenter、\boldsymbol\Delta width、\boldsymbol\Delta height ΔxcenterΔycenterΔwidthΔheight

Faster-RCNN论文细节原理解读+代码实现gluoncv(MXNet)_第7张图片

  最后如果是测试的话,那么将输入的Region Proposal加上我们预测的偏移量,然后根据预测得分再进行一次NMS操作,那么就可以得到我们最终输出的物体框。并且我们可以设定一个阈值(如0.5),得分大于阈值的物体框我们才进行输出。

代码

class FasterRCNN(RCNN):
    """
    @:parameter
    -------------
    """

    def __init__(self, features, top_features, classes,
                 short=600, max_size=1000, train_patterns=None,
                 nms_thresh=0.3, nms_topk=400, post_nms=100,
                 roi_mode='align', roi_size=(14, 14), stride=16, clip=None,
                 rpn_channel=1024, base_size=16, scales=(8, 16, 32),
                 ratios=(0.5, 1, 2), alloc_size=(128, 128), rpn_nms_thresh=0.7,
                 rpn_train_pre_nms=12000, rpn_train_post_nms=2000,
                 rpn_test_pre_nms=6000, rpn_test_post_nms=300, rpn_min_size=16,
                 num_sample=128, pos_iou_thresh=0.5, pos_ratio=0.25, max_num_gt=300,
                 **kwargs):

        super(FasterRCNN, self).__init__(
            features=features, top_features=top_features, classes=classes,
            short=short, max_size=max_size, train_patterns=train_patterns,
            nms_thresh=nms_thresh, nms_topk=nms_topk, post_nms=post_nms,
            roi_mode=roi_mode, roi_size=roi_size, stride=stride, clip=clip, **kwargs)

        self._max_batch = 1  # 最大支持batch=1
        self._num_sample = num_sample
        self._rpn_test_post_nms = rpn_test_post_nms
        self._target_generator = {RCNNTargetGenerator(self.num_class)}

        with self.name_scope():
            # Faster-RCNN的RPN
            self.rpn = RPN(
                channels=rpn_channel, stride=stride, base_size=base_size,
                scales=scales, ratios=ratios, alloc_size=alloc_size,
                clip=clip, nms_thresh=rpn_nms_thresh, train_pre_nms=rpn_train_pre_nms,
                train_post_nms=rpn_train_post_nms, test_pre_nms=rpn_test_pre_nms,
                test_post_nms=rpn_test_post_nms, min_size=rpn_min_size)

            # 用来给训练时Region Proposal采样,正负样本比例为0.25
            self.sampler = RCNNTargetSampler(
                num_images=self._max_batch, num_inputs=rpn_train_post_nms,
                num_samples=self._num_sample, pos_thresh=pos_iou_thresh,
                pos_ratio=pos_ratio, max_gt_box=max_num_gt)

    @property
    def target_generator(self):

        return list(self._target_generator)[0]

    def forward(self, x, gt_boxes=None):
        """
        :param x: ndarray (B,C,H,W)
        :return: 
        """

        def _split_box(x, num_outputs, axis, squeeze_axis=False):
            a = nd.split(x, axis=axis, num_outputs=num_outputs, squeeze_axis=squeeze_axis)
            if not isinstance(a, (list, tuple)):
                return [a]
            return a

        # 首先用basenet抽取特征
        feat = self.features(x)

        # 输入RPN网络
        if autograd.is_training():
            # 训练过程
            rpn_score, rpn_box, raw_rpn_score, raw_rpn_box, anchors = self.rpn(feat, nd.zeros_like(x))
            # 采样输出
            rpn_box, samples, matches = self.sampler(rpn_box, rpn_score, gt_boxes)
        else:
            # 预测过程
            # output shape (B,N,4)
            _, rpn_box = self.rpn(feat, x)
        # 对输出的Region Proposal 进行采样
        # 输出送到后面运算的RoI
        # rois shape = (B,self._num_sampler,4),

        num_roi = self._num_sample if autograd.is_training() else self._rpn_test_post_nms

        # 将rois变为2D,加上batch_index
        with autograd.pause():
            roi_batchid = nd.arange(0, self._max_batch, repeat=num_roi)

            rpn_roi = nd.concat(*[roi_batchid.reshape((-1, 1)), rpn_box.reshape((-1, 4))], dim=-1)
            rpn_roi = nd.stop_gradient(rpn_roi)

        # RoI Pooling 层
        if self._roi_mode == 'pool':
            # (Batch*num_roi,channel,H,W)
            pool_feat = nd.ROIPooling(feat, rpn_roi, self._roi_size, 1 / self._stride)

        elif self._roi_mode == 'align':
            pool_feat = nd.contrib.ROIAlign(feat, rpn_roi, self._roi_size,
                                            1 / self._stride, sample_ratio=2)
        else:
            raise ValueError("Invalid roi mode: {}".format(self._roi_mode))

        top_feat = self.top_features(pool_feat)
        avg_feat = self.global_avg_pool(top_feat)
        # 类别预测,回归预测
        # output shape (B*num_roi,(num_cls+1)) -> (B,N,C)
        cls_pred = self.class_predictor(avg_feat)
        # output shape (B*num_roi,(num_cls)*4) -> (B,N,C,4)
        box_pred = self.bbox_predictor(avg_feat)

        cls_pred = cls_pred.reshape((self._max_batch, num_roi, self.num_class + 1))
        box_pred = box_pred.reshape((self._max_batch, num_roi, self.num_class, 4))

        # 训练过程
        if autograd.is_training():

            return (cls_pred, box_pred, rpn_box, samples, matches,
                    raw_rpn_score, raw_rpn_box, anchors)
        # 预测过程
        # 还要进行的步骤,将预测的类别和预测的偏移量加到输入的RoI中
        else:
            # 直接输出所有类别的信息
            # cls_id (B,N,C) scores(B,N,C)
            cls_ids, scores = self.cls_decoder(nd.softmax(cls_pred, axis=-1))

            # 将所有的C调换到第一维
            # (B,N,C)  -----> (B,N,C,1) -------> (B,C,N,1)
            cls_ids = cls_ids.transpose((0, 2, 1)).reshape((0, 0, 0, 1))
            # (B,N,C)  -----> (B,N,C,1) -------> (B,C,N,1)
            scores = scores.transpose((0, 2, 1)).reshape((0, 0, 0, 1))
            # (B,N,C,4) -----> (B,C,N,4),
            box_pred = box_pred.transpose((0, 2, 1, 3))

            rpn_boxes = _split_box(rpn_box, num_outputs=self._max_batch, axis=0, squeeze_axis=False)
            cls_ids = _split_box(cls_ids, num_outputs=self._max_batch, axis=0, squeeze_axis=True)
            scores = _split_box(scores, num_outputs=self._max_batch, axis=0, squeeze_axis=True)
            box_preds = _split_box(box_pred, num_outputs=self._max_batch, axis=0, squeeze_axis=True)

            results = []
            # 对每个batch分别进行decoder nms
            for cls_id, score, box_pred, rpn_box in zip(cls_ids, scores, box_preds, rpn_boxes):
                # box_pred(C,N,4)   rpn_box(1,N,4)   box (C,N,4)
                box = self.box_decoder(box_pred, self.box_to_center(rpn_box))

                # cls_id (C,N,1) score (C,N,1) box (C,N,4)
                # result (C,N,6)
                res = nd.concat(*[cls_id, score, box], dim=-1)
                # nms操作 (C,self.nms_topk,6)
                res = nd.contrib.box_nms(res, overlap_thresh=self.nms_thresh, valid_thresh=0.0001,
                                         topk=self.nms_topk, coord_start=2, score_index=1, id_index=0,
                                         force_suppress=True)

                res = res.reshape((-3, 0))
                results.append(res)

            results = nd.stack(*results, axis=0)
            ids = nd.slice_axis(results, axis=-1, begin=0, end=1)
            scores = nd.slice_axis(results, axis=-1, begin=1, end=2)
            bboxes = nd.slice_axis(results, axis=-1, begin=2, end=6)

        # 输出为score,bbox
        return ids, scores, bboxes


6、总结

  总的来说Faster-RCNN主要的改进地方在于用RPN来生成候选区域,使整个预测,训练过程都能用深度学习的方法完成。Faster-RCNN达到了这一系列算法的巅峰,并且在论文中提出的基于anchor的物体检测方法,更是被之后的state-of-the-art的框架广泛采用。Faster-RCNN 在 COCO和PASCAL数据集上都取得了当时最好的成绩,感兴趣的话,具体数据在论文中都有详细提到。Faster-RCNN比SSD处理流程要复杂许多,其中还涉及到非常多的细节,例如如何对anchor进行标记,如何对整个网络进行训练等等,这些我会另外写一篇博客来记录Faster-RCNN的训练过程。

7、题外话

  Faster-RCNN我也是学习了很久了,从读论文到看源码,最深的一个感受就是“纸上得来终觉浅,绝知此事要躬行”。论文上始终都是宏观的东西,看完之后觉得自己似乎是懂了,但是当写代码时,才会发现有许多许多问题。我想只有当把代码和论文同时完全理解,才能算真正的看懂了吧。现在我的水平还完全不够,还停留在能看懂,稍微改改能用的阶段,如果是一篇新论文,要自己从零开始复现,目前的我还做不到,不过坚持下去多看多想多学多写,每天进步一点点,我想在毕业之前应该能达到我想要的目标吧~

  学习过程中还有一个很深的体会就是多看底层源码,我就是通过看GluonCV中Faster-RCNN源码才理解了论文中的许多细节,总之多向这些优秀的代码学习吧,特别是深度学习框架的一些高级API使用,只有看了源码才会想到,原来代码还可以这样编~

  以上Faster-RCNN都是我的个人浅薄理解,欢迎大家指出我存在的问题~

你可能感兴趣的:(深度学习)