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)的训练。
Faster-RCNN总体流程框图如下(点击原图查看大图),通过这个框图我们比较一下Faster-RCNN和SSD的不同:
可以看出Faster-RCNN之所以被称为two-stage,是由于需要有RPN生成region proposal这一步骤。相比来看SSD可以看做是稠密采样,它对所有生成的锚框进行了预测,而没有进行筛选。
RPN中还有一些细节操作,比如说采样比例的设置,如何进行预测,这个在后面的部分会详细说明。
RPN在Faster-RCNN中作用为生成RoI,RPN的处理流程具体如下,一些细节将在之后介绍:
这一步中,会在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生成的锚框如下图所示:
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))
这一步中就是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卷积层对每个锚框分别预测(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 score、offset将用于后面的NMS操作,可以去除一些得分低,或者有大量重复区域的锚框,从而最终输出良好的Region Proposal给后面网络进行处理。
上面介绍了,两个1x1卷积层的输入为RPN-feature map,1x1卷积并不改变特征图尺寸,我们采用通道数来表示对应cell锚框的预测值。假设输入RPN-feature map 形状为 ( C , H , W ) (C,H,W) (C,H,W),每个cell生成了 k k k个锚框。输出的锚框分数和偏移量在空间位置上一一对应(也就是尺寸不变)。
代码很简单,就是添加两个卷积层并前向运算:
# 预测偏移量和预测类别的卷积层
# 使用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)
上面的步骤中,我们会对feature map的每个cell都生成多个锚框,并且预测 s c o r e 、 o f f s e t score、offset score、offset,我们生成了 W H k WHk WHk个锚框(大约有2W个),不难想象,大量的锚框其实都是背景,而且有着大量的重叠锚框,我们不可能将所有的锚框都当做Region Proposal输出给RoI Pooling层,提供给Fast-RCNN进行后面的进一步运算。第一个原因是会造成计算量过大,第二个原因是大量的背景框,重复的锚框是没有意义的,我们应该输出得分最高的topk个锚框。最后一步的Region Proposal具体处理过程如下:
通过这一步,我们筛选出了置信度最高的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 如图所示,去除了大量的重复锚框,和得分低的背景区域:
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
上面说道通过RPN层后,我们进行了粗采样,输出了大约2000个Region Proposal,然而我们并不会将这个2000个Region Proposal全部送入RoI Pooling中进行计算,这样效率很低、计算很慢。论文作者对这些Region Proposal进行了采样处理,只采样了一小部分的Region Proposal送入之后的网络运算,而且训练过程的采样和预测过程的采样是不一样的。下面详细介绍一下处理流程。
训练过程的采样在Fast-RCNN论文中有提到,由于要考虑训练过程中正负样本均衡的问题,最终输出了128个Region Proposal,其中正样本的比例为0.25。正负样本的定义如下:
将所有Region Proposal打上标记后,进行随机采样,其中采样正样本的比例为0.25,其余的为负样本。最终采样输出128个Region Proposal,送入之后的网络进行处理计算。
测试过程中的采样很简单,直接采样Region Proposal中, s c o r e s scores scores为前topk个(比如300)的样本,目的就是提取最有可能为物体的区域输入到后面的网络了。
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
通过上一步的采样后,我们得到了一堆没有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()
就能实现了。
到了这一步我们的处理已经到了尾声了,我们通过RoI Pooling已经得到了固定尺寸的feature map,最后一步就是用Fast-RCNN,进行预测类别分数以及边界框的回归。具体的处理流程如下:
最后如果是测试的话,那么将输入的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
总的来说Faster-RCNN主要的改进地方在于用RPN来生成候选区域,使整个预测,训练过程都能用深度学习的方法完成。Faster-RCNN达到了这一系列算法的巅峰,并且在论文中提出的基于anchor的物体检测方法,更是被之后的state-of-the-art的框架广泛采用。Faster-RCNN 在 COCO和PASCAL数据集上都取得了当时最好的成绩,感兴趣的话,具体数据在论文中都有详细提到。Faster-RCNN比SSD处理流程要复杂许多,其中还涉及到非常多的细节,例如如何对anchor进行标记,如何对整个网络进行训练等等,这些我会另外写一篇博客来记录Faster-RCNN的训练过程。
Faster-RCNN我也是学习了很久了,从读论文到看源码,最深的一个感受就是“纸上得来终觉浅,绝知此事要躬行”。论文上始终都是宏观的东西,看完之后觉得自己似乎是懂了,但是当写代码时,才会发现有许多许多问题。我想只有当把代码和论文同时完全理解,才能算真正的看懂了吧。现在我的水平还完全不够,还停留在能看懂,稍微改改能用的阶段,如果是一篇新论文,要自己从零开始复现,目前的我还做不到,不过坚持下去多看多想多学多写,每天进步一点点,我想在毕业之前应该能达到我想要的目标吧~
学习过程中还有一个很深的体会就是多看底层源码,我就是通过看GluonCV中Faster-RCNN源码才理解了论文中的许多细节,总之多向这些优秀的代码学习吧,特别是深度学习框架的一些高级API使用,只有看了源码才会想到,原来代码还可以这样编~
以上Faster-RCNN都是我的个人浅薄理解,欢迎大家指出我存在的问题~