1. 简介
由于提出
结构以及
的概念,
-
是最为经典的目标检测模型之一。本文将介绍基于
的
实现。代码来自参考
部分。如下图是
-
给出的
的结构:
对于输入大小为 的图像,经过特征提取网络后得到的特征图大小为 。通过得到的特征图产生相对于原图的 ,在原论文中的特征图上每个位置共产生 个 ,所以最终产生的 数为 。在 的设置中,尺寸分为 、 、 三种,比例分为 、 、 三种,两两组合共形成9种 。现在,先来看产生这 个 的代码。代码来自参考 。
2. 基于RPN产生~20k个候选框
首先针对特征图的左上角顶点产生 个 :
# 针对特征图左上角顶点产生anchor
def generate_base_anchor(base_size=16, ratios=None, anchor_scale=None):
"""
这里假设得到的特征图的大小为w×h,每个位置共产生9个anchor,所以一共产生的anchor
数为w×h×9。原论文中anchor的比例为1:2、2:1和1:1,尺度为128、256和512(相对于
原图而言)。所以在16倍下采样的特征图的上的实际尺度为8、16和32。
"""
# anchor的比例和尺度
if anchor_scale is None:
anchor_scale = [8, 16, 32]
if ratios is None:
ratios = [0.5, 1, 2]
# 特征图的左上角位置映射回原图的位置
py = base_size / 2
px = base_size / 2
# 初始化变量(9,4),这里以特征图的最左上角顶点为例产生anchor
base_anchor = np.zeros((len(ratios) * len(anchor_scale), 4), dtype=np.float32)
# 循环产生9个anchor
for i in range(len(ratios)):
for j in range(len(anchor_scale)):
# 生成高和宽(相对于原图而言)
# 以i=0、j=0为例,h=16×8×(0.5)^1/2、w=16×8×1/0.5,则h×w=128^2
h = base_size * anchor_scale[j] * np.sqrt(ratios[i])
w = base_size * anchor_scale[j] * np.sqrt(1. / ratios[i])
# 当前生成的anchor的索引(0~8)
index = i * len(anchor_scale) + j
# 计算anchor的左上角和右下角坐标
base_anchor[index, 0] = py - h / 2
base_anchor[index, 1] = px - w / 2
base_anchor[index, 2] = py + h / 2
base_anchor[index, 3] = px + w / 2
# 相对于原图大小的anchor(x_min,y_min,x_max,y_max)
return base_anchor
我们调用上面函数,看一下打印的结果:
我们以结果图的第一行为例说明(现在先不用在意那些越界的 )。首先计算它的宽和高:
然后计算它的面积:
由该 的宽高和面积我们可以看到,它是尺寸为 、比例为 的 。上面函数是针对特征图的左上角顶点映射回原图产生的 ,我们需要整幅特征图的结果。则定义如下函数:
def generate_all_base_anchor(base_anchor, feat_stride, height, width):
"""
height*feat_stride/width*feat_stride相当于原图的高/宽,相当于从0开始,
每隔feat_stride=16采样一个位置,这相当于在16倍下采样的特征图上逐步采样。这
个过程用于确定每组anchor的中心点位置。
"""
# 纵向偏移量[0,16,32,...]
shift_y = np.arange(0, height * feat_stride, feat_stride)
# 横向偏移量[0,16,32,...]
shift_x = np.arange(0, width * feat_stride, feat_stride)
# np.meshgrid的作用是将两个一维向量变为两个二维矩阵。其中,返回的第一个二维
# 矩阵的行向量为第一个参数、重复次数为第二个参数的长度;第二个二维矩阵的列向量
# 为第二个参数、重复次数为第一个参数的长度。即得到的shift_x和shift_y如下:
# shift_x = [[0,16,32,...],
# [0,16,32,...],
# [0,16,32,...],
# ...]
# shift_y = [[0, 0, 0,... ],
# [16,16,16,...],
# [32,32,32,...],
# ...]
# 注意此时shift_x和shift_y都等于特征图的尺度,且每一个位置的之对应于特征图上
# 的一个点,两个矩阵的值的组合对应于特征图上的点映射回原图的左上角坐标。
shift_x, shift_y = np.meshgrid(shift_x, shift_y)
# np.ravel()将矩阵展开成一个一维向量,即shift_x和shift_y展开后的形式分别为:
# [0,16,32,...,0,16,32,..,0,16,32,...],(1,w*h)
# [0,0,0,...,16,16,16,...,32,32,32,...],(1,w*h)
# axis=0相当于按行堆叠,得到的形状为(4,w*h);
# axis=1相当于按列堆叠,得到的形状为(w*h,4)。该语句得到的shift的值为:
# [[0, 0, 0, 0],
# [16, 0, 16, 0],
# [32, 0, 32, 0],
# ...]
shift = np.stack((shift_y.ravel(), shift_x.ravel(),
shift_y.ravel(), shift_x.ravel()), axis=1)
# 每个位置anchor数
num_anchor_per_loc = base_anchor.shape[0]
# 获取特征图上的总位置数
num_loc = shift.shape[0]
# 用generate_base_anchor产生的左上角位置的anchor加上偏移量即可得到
# 后面anchor的信息(这里只针对anchor中心点位置的改变,不改变anchor的
# 宽和高)。我们首先定义最终anchor的形状,我们知道应该为w*h*9,则所有
# anchor的存储的变量为(w*h*9,4)。首先将首位置产生的anchor形状改变为
# (1,9,4),再将shift的形状改变为(1,w*h,4)。并通过transpose函数改变
# shift的形状为(w*h,1,4),然后使用广播机制将二者相加,即二者的形状分
# 别为(1,num_anchor_per_loc,4)+(num_loc,1,4),最终相加得到的结果
# 形状为(num_loc,num_anchor_per_loc,4)。这里,相加的第一项为:
# [[[x_min_0,y_min_0,x_max_0,y_max_0],
# [x_min_1,y_min_1,x_max_1,y_max_1],
# ...,
# [x_min_8,y_min_8,x_max_8,y_max_8]]]
# 相加的第二项为:
# [[[0, 0, 0, 0]],
# [[0, 16, 0, 16]],
# [[0, 32, 0, 32]],
# ...]
# 在相加的过程中,我们首先将两个加数展开成目标形状。具体地,第一个则可以
# 展开为:
# [[[x_min_0,y_min_0,x_max_0,y_max_0],
# [x_min_1,y_min_1,x_max_1,y_max_1],
# ...,
# [x_min_8,y_min_8,x_max_8,y_max_8]],
# [[x_min_0,y_min_0,x_max_0,y_max_0],
# [x_min_1,y_min_1,x_max_1,y_max_1],
# ...,
# [x_min_8,y_min_8,x_max_8,y_max_8]],
# [[x_min_0,y_min_0,x_max_0,y_max_0],
# [x_min_1,y_min_1,x_max_1,y_max_1],
# ...,
# [x_min_8,y_min_8,x_max_8,y_max_8]],
# ...]
# 第二个可以展开为:
# [[[0, 0, 0, 0],
# [0, 0, 0, 0],
# ...],
# [[0, 16, 0, 16],
# [0, 16, 0, 16],
# ...],
# [[0, 32, 0, 32],
# [0, 32, 0, 32],
# ...],
# ...]
# 现在二者维度一致,可以直接相加。得到的结果的形状为:
# (num_loc,num_anchor_per_loc,4)
anchor = base_anchor.reshape((1, num_anchor_per_loc, 4)) + \
shift.reshape((1, num_loc, 4)).transpose((1, 0, 2))
# 将anchor的形状reshape为最终的形状(num_loc*num_anchor_per_loc,4)。
anchor = anchor.reshape((num_loc * num_anchor_per_loc, 4)).astype(np.float32)
return anchor
上面代码都有非常详细的注释,我们再来看其中几个比较重要的函数。
-
np.arange(start=0, end, step=1)
:以步长为step
生成[start, end)
范围内的一个等差数组。如:扫描二维码关注公众号,回复: 11515636 查看本文章 -
np.meshgrid(x, y)
:以向量x
和向量y
为基础返回一个(2, y.length(), x.length())
矩阵。这里,如果参数不是一维向量,该函数会首先将其按行展开为一维向量。并且,元素的展开方式有所不同:第一个参数按行展开,第二个参数按列展开。如:
-
np.stack(arrays, axis=0)
:在axis=0
的维度上将arrays
进行堆叠。我们首先以一维向量为例:
由于a
只有一维,在对自身使用stack
函数时不会产生变化。现在做如下变化:
我们可以看到,当axis=0
时,相当于将a
按行堆叠;当axis=1
时,相当于将a
按列堆叠。其他高维的向量亦如此。 -
transpose()
:将矩阵按照某种规律转置,转置方法由具体的参数而定。如:
我们首先将a
的形状固定为(1, 2, 4)
,此时调用函数transpose
得到b
。由于原a.shape = (1, 2, 3)
分别对应于第零维、第一维和第二维,即0、1、2
;transpose(2,0,1)
相当于把原第零维的元素放到第二个位置、将第一维的元素放到第三个位置、将第二维的元素放到第一个位置,即对应于shape: (1, 2, 4)=>(4, 1, 2)
。其他的变换亦如此。
3. ~20k个候选框(1):RPN
由 产生约 个候选框后,一方面,挑选出一部分用于训练 。具体地,从约 个候选框中选出 个候选框,即 个正样本和 个负样本。挑选过程如下:
- 对于每个真实框,选择和他具有最大交并比的候选框作为正样本。显然,由于图中的标注目标偏少,无法满足训练要求,我们再进行以下步骤;
- 对于剩下的候选框,如果其和某个真实框的交并比大于设定的阈值,我们也认为它的正样本;
- 同时设定一个负样本阈值,如果候选框同真实框的交兵比小于阈值,则作为负样本。注意,在选择正样本和负样本时,要严格满足数量的要求。
class AnchorTargetCreator(object):
def __init__(self, n_sample=256, pos_iou_thresh=0.7, neg_iou_thresh=0.3, pos_ratio=0.5):
# 总样本采样数
self.n_sample = n_sample
# 正、负样本的阈值
self.pos_iou_thresh = pos_iou_thresh
self.neg_iou_thresh = neg_iou_thresh
# 正负样本采样比率
self.pos_ratio = pos_ratio
def __call__(self, bbox, anchor, img_size):
img_H, img_W = img_size
# ~20k个anchor
n_anchor = len(anchor)
# 只保留合法的Anchor
inside_index = _get_inside_index(anchor, img_H, img_W)
anchor = anchor[inside_index]
# 返回每个anchor与bbox对应的最大交并比索引以及正负样本采样结果
argmax_ious, label = self._create_label(inside_index, anchor, bbox)
# 计算回归目标
loc = bbox2loc(anchor, bbox[argmax_ious])
# 根据索引得到候选框
label = _unmap(label, n_anchor, inside_index, fill=-1)
loc = _unmap(loc, n_anchor, inside_index, fill=0)
return loc, label
def _create_label(self, inside_index, anchor, bbox):
# label: 1表示正样本索引,0表示负样本,-1表示忽略
label = np.empty((len(inside_index),), dtype=np.int32)
label.fill(-1)
# 返回每个anchor与bbox对应的最大交并比和索引以及由第一步产生的正样本索引
argmax_ious, max_ious, gt_argmax_ious = self._calc_ious(anchor, bbox, inside_index)
# 最大交并比小于阈值,首先选择为负样本
label[max_ious < self.neg_iou_thresh] = 0
# 第一步产生的正样本
label[gt_argmax_ious] = 1
# 第二步产生的正样本
label[max_ious >= self.pos_iou_thresh] = 1
# 如果正样本数量大于128,再次随机采样
n_pos = int(self.pos_ratio * self.n_sample)
pos_index = np.where(label == 1)[0]
if len(pos_index) > n_pos:
disable_index = np.random.choice(
pos_index, size=(len(pos_index) - n_pos), replace=False)
label[disable_index] = -1
# 如果负样本数量大于128,再次随机采样
n_neg = self.n_sample - np.sum(label == 1)
neg_index = np.where(label == 0)[0]
if len(neg_index) > n_neg:
disable_index = np.random.choice(
neg_index, size=(len(neg_index) - n_neg), replace=False)
label[disable_index] = -1
return argmax_ious, label
def _calc_ious(self, anchor, bbox, inside_index):
# 计算anchor和bbox之间的交并比,返回形状为(len(anchor),len(bbox))
# 即一个二维矩阵反应anchor与bbox两两之间的交并比大小
ious = bbox_iou(anchor, bbox)
# 对于每一个anchor,求出与之有最大交并比的bbox的索引
# axis=1按列求最大值,返回形状为(1,len(bbox))
argmax_ious = ious.argmax(axis=1)
max_ious = ious[np.arange(len(inside_index)), argmax_ious]
# 对于每一个bbox,求出与之有最大交并比的anchor的索引
# axis=0按行求最大值,返回形状为(len(anchor),1)
gt_argmax_ious = ious.argmax(axis=0)
gt_max_ious = ious[gt_argmax_ious, np.arange(ious.shape[1])]
# 对应于挑选正样本的第一步,与bbox有最大交并比的anchor为正样本,得到其索引
gt_argmax_ious = np.where(ious == gt_max_ious)[0]
return argmax_ious, max_ious, gt_argmax_ious
其中,bbox2loc
为根据真实框和候选框计算偏移的函数。公式如下:
def bbox2loc(src_bbox, dst_bbox):
# 预测框(xmin,ymin,xmax,ymax) => (x,y,w,h)
height = src_bbox[:, 2] - src_bbox[:, 0]
width = src_bbox[:, 3] - src_bbox[:, 1]
ctr_y = src_bbox[:, 0] + 0.5 * height
ctr_x = src_bbox[:, 1] + 0.5 * width
# 真实框(xmin,ymin,xmax,ymax) => (x,y,w,h)
base_height = dst_bbox[:, 2] - dst_bbox[:, 0]
base_width = dst_bbox[:, 3] - dst_bbox[:, 1]
base_ctr_y = dst_bbox[:, 0] + 0.5 * base_height
base_ctr_x = dst_bbox[:, 1] + 0.5 * base_width
# 极小值,保证除数不为零
eps = np.finfo(height.dtype).eps
height = np.maximum(height, eps)
width = np.maximum(width, eps)
# 套公式
dy = (base_ctr_y - ctr_y) / height
dx = (base_ctr_x - ctr_x) / width
dh = np.log(base_height / height)
dw = np.log(base_width / width)
# 将结果堆叠
loc = np.vstack((dy, dx, dh, dw)).transpose()
return loc
4. ~20k个候选框(2):Fast RCNN
由 产生约 个候选框后,另一方面,挑选出一部分用于训练 - 。这里,在训练阶段和推理阶段所挑选处理的候选框的数量不同。在训练阶段,挑选出约 个候选框,利用非极大值抑制得到约 个候选框;在推理阶段,挑选出约 个候选框,利用非极大值抑制得到约 个候选框。这里挑选的规则是候选框的分类置信度。
class ProposalCreator:
def __init__(self, parent_model, nms_thresh=0.7, n_train_pre_nms=12000, n_train_post_nms=2000,
n_test_pre_nms=6000, n_test_post_nms=300, min_size=16):
self.parent_model = parent_model
self.nms_thresh = nms_thresh
self.n_train_pre_nms = n_train_pre_nms
self.n_train_post_nms = n_train_post_nms
self.n_test_pre_nms = n_test_pre_nms
self.n_test_post_nms = n_test_post_nms
self.min_size = min_size
def __call__(self, loc, score, anchor, img_size, scale=1.):
# 训练阶段和推理阶段使用不同数量的候选框
if self.parent_model.training:
n_pre_nms = self.n_train_pre_nms
n_post_nms = self.n_train_post_nms
else:
n_pre_nms = self.n_test_pre_nms
n_post_nms = self.n_test_post_nms
# 根据偏移得到anchor的实际信息
roi = loc2bbox(anchor, loc)
# 将预测框的宽高限定在预设的范围内
roi[:, slice(0, 4, 2)] = np.clip(
roi[:, slice(0, 4, 2)], 0, img_size[0])
roi[:, slice(1, 4, 2)] = np.clip(
roi[:, slice(1, 4, 2)], 0, img_size[1])
min_size = self.min_size * scale
hs = roi[:, 2] - roi[:, 0]
ws = roi[:, 3] - roi[:, 1]
keep = np.where((hs >= min_size) & (ws >= min_size))[0]
roi = roi[keep, :]
score = score[keep]
# 排序,得到高置信度部分的候选框
order = score.ravel().argsort()[::-1]
if n_pre_nms > 0:
order = order[:n_pre_nms]
roi = roi[order, :]
score = score[order]
# nms过程,这里不详细展开.pytorch1.2+可以通过from torchvision.ops import nms导入直接使用
keep = nms(torch.from_numpy(roi).cuda(), torch.from_numpy(score).cuda(), self.nms_thresh)
if n_post_nms > 0:
keep = keep[:n_post_nms]
roi = roi[keep.cpu().numpy()]
# 返回生成的候选框
return roi
在最终经由非极大值抑制挑选出候选框后,后面的工作就是
-
的内容,这里不再介绍。其中,loc2bbox
函数就是bbox2loc
函数的逆过程,即根据偏移得到真实框值。
5. RPN主体部分
有图 可以看到, 共有两个方向的输出。一方面是在 部分通过卷积得到两个分支,分别为分类和回归;另一方面产生候选区域作为 - 部分的输入。下面是具体的代码:
class RegionProposalNetwork(nn.Module):
def __init__(self, in_channels=512, mid_channels=512, ratios=[0.5, 1, 2],
anchor_scales=[8, 16, 32], feat_stride=16,
proposal_creator_params=dict(), ):
super(RegionProposalNetwork, self).__init__()
# 特征图左上角顶点对应的anchor
self.anchor_base = generate_anchor_base(anchor_scales=anchor_scales, ratios=ratios)
# 下采样倍数
self.feat_stride = feat_stride
# 产生Fast RCNN的候选框
self.proposal_layer = ProposalCreator(self, **proposal_creator_params)
n_anchor = self.anchor_base.shape[0]
self.conv1 = nn.Conv2d(in_channels, mid_channels, 3, 1, 1)
self.score = nn.Conv2d(mid_channels, n_anchor * 2, 1, 1, 0)
self.loc = nn.Conv2d(mid_channels, n_anchor * 4, 1, 1, 0)
# 权重初始化
normal_init(self.conv1, 0, 0.01)
normal_init(self.score, 0, 0.01)
normal_init(self.loc, 0, 0.01)
def forward(self, x, img_size, scale=1.):
n, _, hh, ww = x.shape
# 产生所有的anchor
anchor = _enumerate_shifted_anchor(np.array(self.anchor_base), self.feat_stride, hh, ww)
n_anchor = anchor.shape[0] // (hh * ww)
# rpn部分的回归分支
h = F.relu(self.conv1(x))
rpn_locs = self.loc(h)
rpn_locs = rpn_locs.permute(0, 2, 3, 1).contiguous().view(n, -1, 4)
# rpn部分的分类分支
rpn_scores = self.score(h)
rpn_scores = rpn_scores.permute(0, 2, 3, 1).contiguous()
rpn_softmax_scores = F.softmax(rpn_scores.view(n, hh, ww, n_anchor, 2), dim=4)
rpn_fg_scores = rpn_softmax_scores[:, :, :, :, 1].contiguous()
rpn_fg_scores = rpn_fg_scores.view(n, -1)
rpn_scores = rpn_scores.view(n, -1, 2)
# 产生rois部分
rois = list()
roi_indices = list()
for i in range(n):
roi = self.proposal_layer(
rpn_locs[i].cpu().data.numpy(),
rpn_fg_scores[i].cpu().data.numpy(),
anchor, img_size,
scale=scale)
batch_index = i * np.ones((len(roi),), dtype=np.int32)
rois.append(roi)
roi_indices.append(batch_index)
rois = np.concatenate(rois, axis=0)
roi_indices = np.concatenate(roi_indices, axis=0)
return rpn_locs, rpn_scores, rois, roi_indices, anchor
6. RPN部分的损失函数
- 整体的损失函数定义如下:
其中,第一部分是分类损失, 表示分类分支总计算的样本数。这里, 和 - 部分的数值不同;第二部分是回归损失, 表示回归分支总计算的样本数。其中,在回归损失部分乘了一个 表示回归损失只针对正样本。且分类损失部分使用的交叉熵损失,回归损失部分使用的是 损失。首先来看手动实现 损失的部分:
def _smooth_l1_loss(x, t, in_weight, sigma):
# 相当于公式中的1/β
sigma2 = sigma ** 2
# 相当于公式中的|x|
diff = in_weight * (x - t)
abs_diff = diff.abs()
# 相当于公式中的判断条件
flag = (abs_diff.data < (1. / sigma2)).float()
# 根据|x|的范围选择不同分支计算
y = (flag * (sigma2 / 2.) * (diff ** 2) +
(1 - flag) * (abs_diff - 0.5 / sigma2))
return y.sum()
def _fast_rcnn_loc_loss(pred_loc, gt_loc, gt_label, sigma):
in_weight = torch.zeros(gt_loc.shape).cuda()
in_weight[(gt_label > 0).view(-1, 1).expand_as(in_weight).cuda()] = 1
loc_loss = _smooth_l1_loss(pred_loc, gt_loc, in_weight.detach(), sigma)
# 通过总参与计算的样本数将损失值归一化
loc_loss /= ((gt_label >= 0).sum().float())
return loc_loss
然后是计算 部分的损失函数的主体部分:
class FasterRCNNTrainer(nn.Module):
def __init__(self, faster_rcnn):
super(FasterRCNNTrainer, self).__init__()
self.faster_rcnn = faster_rcnn
# smoothl1损失函数的参数
self.rpn_sigma = 3
# 得到rpn部分参与损失计算的样本
self.anchor_target_creator = AnchorTargetCreator()
def forward(self, imgs, bboxes, labels, scale):
# 只支持batch_size=1的计算
n = bboxes.shape[0]
if n != 1:
raise ValueError('Currently only batch size 1 is supported.')
_, _, H, W = imgs.shape
img_size = (H, W)
# 经cnn产生的特征图
features = self.faster_rcnn.extractor(imgs)
# 经rpn产生的候选框
rpn_locs, rpn_scores, rois, roi_indices, anchor = \
self.faster_rcnn.rpn(features, img_size, scale)
# Since batch size is one, convert variables to singular form
bbox = bboxes[0]
rpn_score = rpn_scores[0]
rpn_loc = rpn_locs[0]
# rpn_loss
gt_rpn_loc, gt_rpn_label = self.anchor_target_creator(
at.tonumpy(bbox), anchor, img_size)
gt_rpn_label = at.totensor(gt_rpn_label).long()
gt_rpn_loc = at.totensor(gt_rpn_loc)
# 回归损失,调用自定义的smoothl1损失函数
rpn_loc_loss = _fast_rcnn_loc_loss(
rpn_loc, gt_rpn_loc, gt_rpn_label.data, self.rpn_sigma)
# 分类损失,调用pytorch自带的交叉熵损失函数
rpn_cls_loss = F.cross_entropy(rpn_score, gt_rpn_label.cuda(), ignore_index=-1)
_gt_rpn_label = gt_rpn_label[gt_rpn_label > -1]
_rpn_score = at.tonumpy(rpn_score)[at.tonumpy(gt_rpn_label) > -1]
return rpn_loc_loss, rpn_cls_loss
7. 总结
- 至今仍是广大研究者关注的重点模型之一,同时它也在工业界被广泛应用。本文基于Faster RCNN的代码大致介绍了 的代码实现流程。该代码结构清晰、通俗易懂,是学习 - 的一套重要的的代码。如果想对 - 做更加细致的了解,可以移步代码仓库仔细阅读代码部分。
参考
- https://github.com/chenyuntc/simple-faster-rcnn-pytorch.
- 《Faster+R-CNN原理和代码讲解_GiantPandaCV》电子书.
- https://shadowthink.com/.