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物体检测框架要复杂,在阅读论文,以及代码复现的过程中也理解了很多细节,在这里记录一下自己的学习过程和自己的一点体会。

背景介绍

  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候选区域

图片失效啦~

详细步骤及代码

在feature_map上生成锚框

这一步中,会在feature_map每个cell上生成一系列不同大小和宽高比例的锚框。生成锚框的方式如下: 1. 选定一个锚框的基准大小,记为base,比如为16 2. 选定一组宽高比例(aspect ratios),比如为【0.5、1、2】 3. 选定一组大小比例(scales),比如为【16、32、64】 4. 那么 **每个cell** 将会生成ratios*scales个锚框,而每个锚框的形状大小的计算公式如下: $$ width_{anchor} = size_{base} \times scale \times \sqrt{ 1 / ratio}$$ $$ height_{anchor} = size_{base} \times scale \times \sqrt{ratio}$$ 举个例子,我们按照论文中取3种大小比例以及3种长宽比例,那么每个cell生成的锚框个数为$k=9$,而假设我们的特征图大小为$W\times H=2400$,那么我们一共生成了$WHk$个锚框。可以看到,生成的锚框数量非常多,有大量的重复区域。RPN输出时不应该使用所有锚框,所以采用NMS 来去除大量重复的锚框,而只选择一些得分较高的锚框作为RoI输出。其实,RPN在训练时也进行了采样,这个后面具体介绍。RPN生成的锚框如下图所示:

Fig.1 锚框的生成示意图

MXNet中,生成锚框的类源码如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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的卷积层。

1
2
3
4
5

# 第一个提取特征的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物体)$score$(2)锚框偏移量$offset$。而这些预测值$score、offset$将用于后面的NMS操作,可以去除一些得分低,或者有大量重复区域的锚框,从而最终输出良好的Region Proposal给后面网络进行处理。

  • 类别分数$score$,RPN中只关心是否有物体,所以是个二分类问题(背景、物体)。
  • 锚框的坐标偏移量$offset$,一般为4个值,$\boldsymbol\Delta xcenter、\boldsymbol\Delta ycenter、\boldsymbol\Delta width、\boldsymbol\Delta height$。

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

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

图片失效啦~

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

1
2
3
4
5
6
# 预测偏移量和预测类别的卷积层
# 使用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都生成多个锚框,并且预测$score、offset$,我们生成了$WHk$个锚框(大约有2W个),不难想象,大量的锚框其实都是背景,而且有着大量的重叠锚框,我们不可能将所有的锚框都当做Region Proposal输出给RoI Pooling层,提供给Fast-RCNN进行后面的进一步运算。第一个原因是会造成计算量过大,第二个原因是大量的背景框,重复的锚框是没有意义的,我们应该输出得分最高的topk个锚框。最后一步的Region Proposal具体处理过程如下:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
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 如图所示,去除了大量的重复锚框,和得分低的背景区域:

图片失效啦~

RPN整体代码

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# 定义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采样

图片失效啦~

  训练过程的采样在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中,$scores$为前topk个(比如300)的样本,目的就是提取最有可能为物体的区域输入到后面的网络了。

Region Proposal采样代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144

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)。
图片失效啦~

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


5、后续Fast-RCNN处理

处理流程

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

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

图片失效啦~

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

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
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都是我的个人浅薄理解,欢迎大家指出我存在的问题~