文章组织
1. 图像预处理
2. 网络组织
2.1. 网络结构
3. 实现细节——训练
3.1. 锚框生成层
3.2. 区域提议层
3.2.1. 区域提议网络
3.3. 提议层
3.4. 锚框目标层
3.4.1. 计算RPN损失
3.5. 计算分类层损失
3.6. 提议目标层
3.7. 剪裁池化
3.8. 分类层
4. 实现细节——预测
5. 附录
5.1. ResNet50网络结构
5.2. 非极大值抑制(NMS)
注意文中几个概念:预测框——我们进行网络计算得到的框;锚框——根据不同位置不同比例尺度前期自动生成的框;GT框——文件中已标定好的前景物体周围的框。
下图1展示了Faster R-CNN整体结构,先来大体了解下网络运行流程。首先,原始图像PQ经过图像预处理Rescale到MN;然后经过一个流行的图像分类网络(例如ResNet50,去掉最后所有全连接层)得到一个特征图Feature map;之后经过一个33卷积在一个像素位置产生9个锚框去通过两个不同路径,一个路径做分类(此处二分类),一个路径做回归;再之后,首先通过一个二分类路径,9个框2个概率总共18个值(后面的rpn_cls_scores),经过softmax等形成rpn_cls_prob;而下面通过一个回归路径,回归的参数有4个代表一个框,共9个框36个参数(rpn_bbox_pred);将rpn_cls_prob和rpn_bbox_pred传入proposal进行提议,得到能够保留的感兴趣的区域RoIs和相应的分数;再经过RoIPooing将不同尺寸的提议变换到相同尺寸送入全连接层;最后通过两个全连接层(一个是分类,此处21类;一个是回归)进行学习。
1. 图像预处理
下面的预处理步骤在图像送入网络之前应用在图像身上,训练和预测阶段都需要进行这样预处理。均值向量(Mean Vector,31,每个值和相应颜色通道相关)不是当前图像的像素均值,而是所有训练和测试图像都使用的相同的值。简单理解就是长宽比例不变,根据600和1000范围内自行调整。
其中targetSize和maxSize默认值分别是600和1000。
2. 网络组织
Faster R-CNN使用神经网络解决两个问题:(1)在输入图像中识别可能包含前景目标物体的区域(感兴趣区域,Region of Interest,RoI);(2)计算每个RoI目标类概率分布——计算RoI包含某类对象的概率,用户可以选择最高概率目标类作为分类结果。
Faster R-CNN包含三个主要网络结构:(1)头部网络;(2)区域提议网络(Region Proposal Network,RPN);(3)分类网络。
2.1. 网络结构
下图左边展示Faster R-CNN总体框架,右边显示详细结构。每一步都展示详细的维度,帮助理解网络是如何运行的。
3. 实现细节——训练
理解了训练,那么理解预测就很简单了,预测就是训练网络的一个子网络部分。训练的目的就是调整RPN和分类网络的参数以及微调头部网络的权重。RPN网络目标是产生有意义的RoI,而分类网络目标是为每个RoI赋予目标类分数。因此,为了训练此网络,我们需要相应的GT(ground truth,真值),包含包围物体的bounding box(边界框)的坐标和这些目标的类别。
先来了解下边界框回归系数(bounding box regression coefficients)和边界框重叠(bounding box overlap):
(1)边界框回归系数(回归目标):Faster R-CNN一个目标是产生能够匹配目标边界的好的边界框,其通过获取给定的边界框(这是前期阶段通过例如SS或者滑动窗口获取的,由左上角坐标或者中心点坐标、宽度和高度定义),并通过一组回归系数(也就是这里回归任务需要学习得到的)调整其左上角、宽度和高度来得到这些边界框。这些系数通过下面的方式进行计算。假设目标边界框和我们的原始边界框左上角坐标通过分别定义,目标边界框和我们的原始边界框宽高分别定义为。然后,回归目标(将原始边界框转换为目标边界框的函数的系数)是:
。上面四个系数是回归网络真正需要学习的,而不是那些坐标和宽高,通过学习这些系数,已知原始框坐标和宽高就能按照公式求出目标框坐标和宽高,然后和GT求loss。注意,回归系数对于无剪切的放射变换是不变的,下图4展示了这一过程。下图展示了这些参数具体计算过程:
(2)交并重叠(Intersection over Union,IoU)我们需要计算一个边界框和另外一个边界框的重叠程度,两个重叠边界框重叠度为1,而不重叠的两个边界框重叠度为0。IoU计算如下图:
了解以上的定义之后,让我们深入理解这些层的实现细节。
(1)锚框生成层:该层在特征图(头部网络最后生成的)的每个像素位置生成9个不同尺度和横纵比的锚框,例如最后生成特征图为,那么最后得到个锚框(边界框)。再应用一定的规则将这些所有框映射回原始图像中(下面描述的generate_anchors_pre函数)生成anchors作为提议层参数。
(2)提议层:首先将上述生成的anchors按照回归系数rpn_bbox_pred进行转换(下面bbox_transform_inv函数),转换到预测的边框pred_boxes;然后进行削减(将超出图像边界的框进行削减,下面clip_boxes函数);然后按锚框分数排名选取前12000个锚框;最后通过NMS非极大值抑制产生的锚框再选取前2000个作为最终的提议锚框。
(3)锚框目标层:该层主要目的是为锚框打标签,正例为1,负例为0,“不关心”为-1。首先计算传入的所有锚框(假设N个)和所有GT框(例如猫狗,假设K个)相互之间两两重合度,生成(N, K)矩阵;查看某个锚框与所有GT框重合度最大值,N个锚框和这些GT框重合度最大值就有N个,将这N个最大值中小于0.3的设置为0(背景),意思就是和所有GT框都进行对比了重合度都很小那就当做背景;前景标签有两种情况,一种是所有K个GT框都有一个与之重合度最大的锚框,将这K个锚框设置为1(前景),还有一种情况是N个锚框中(除去前面已经分配的背景和前景锚框)与所有GT重合度最大值大于0.7设置为1(前景)。其实最终我们按分数值保留256个框。如果刚才的操作后前景数太多(通常大于128),则随机的将多余128个的前景设置为-1(不关心);同样背景数太多也进行随机赋值-1。计算N个anchors和每个对应的GT框(上面已经找到对应的GT框)的回归系数,仅仅正样本(前景)才有回归系数。
(4)RPN损失:RPN损失函数包含:RPN生成的边界框正确分类为前景/背景的交叉熵;预测和目标回归系数之间的距离。
(5)提议目标层:该层主要目的是从proposal_layer输出的RoIs和scores中采样和GT框重合度高的RoIs。首先计算所有RoIs和GT框重合度,假设为(N, K),求N个RoIs中每个和K个GT框最大重合度,并附上相应GT的标签;然后找出最大重合度大于0.5的RoIs选择为前景,介于0.1到0.5的选择为背景;然后相应的随机去除一些前景和背景,将数量变为256个RoIs,保留选择的前景标签,将剩余的背景标签标记为0;然后再计算这些RoIs和相应GT框的回归系数(背景框不会计算),N个RoIs回归输出为(N, 21 * 4)。
(6)RoI池化层:类似SPP模块,将最后得到的不同大小的RoI进行池化操作,得到数量相同的特征,传入到全连接层中。
(7)分类层:分类层接收RoI池化层的输出特征图,并将其经过一系列卷积层。输出被喂入两个全连接层。第一个对于每个区域提议生成类别概率分布,第二个生成一系列具体类边界框回归。
(8)分类损失:在反向传播过程中,误差梯度也会流向RPN网络,因此训练分类层也会修改RPN网络的权值。分类损失的组成:RPN网络进行的二分类任务和边界框回归;最后分类层进行的多分类任务和边界框回归。
3.1. 锚框生成层
锚框生成层在输入图像上产生一系列不同尺寸和比例的边界框(又叫“anchor boxes”)。这些边界框在所有图像上都一样,其中一些边界框会包含到前景对象,当然大部分不会。RPN网络的目标是学习辨别哪些边框是好的,即可能包含前景对象并产生目标回归系数,这些目标回归系数能更好的将锚框转变为更好的边界框。下图展示了这些锚框是如何生成的:
上图是图像,经过头部网络尺寸缩小16倍,生成,其中600/16=37.5,由于像素没有小数值,向上进一位得到38。因此得到1900个像素位置,每个像素位置有9个框,映射回原图是一个区域,如上图的红色三个、绿色三个和蓝色三个。每个颜色三个框比例分别为1:2、1:1和2:1。总共得到个框,通过在特征图上得到的这17100个框,按一定规则转换(下面generate_anchors_pre函数)到输入图像上的框如上图右边所示。其实会发现有很多重叠并且很多超出边界的框,怎样消除这些冗余框就是下面要做的事。下面展示生成框的代码,基础框的size为16,左上角和右下角坐标分别为(0, 0)和(15, 15),按照不同比例和尺度缩放每个位置生成9个box。
import numpy as np
def generate_anchors(base_size=16, ratios=[0.5, 1, 2], scales=2 ** np.arange(3, 6)):
# 由比例和尺度选择生成锚框,三种比例配对三种尺度,每个位置9个锚框,初始锚框位置为(0, 0, 15, 15),大小为16×16
base_anchor = np.array([1, 1, base_size, base_size]) - 1
ratio_anchors = _ratio_enum(base_anchor, ratios)
anchors = np.vstack([_scale_enum(ratio_anchors[i, :], scales) for i in range(ratio_anchors.shape[0])])
return anchors
def _whctrs(anchor):
# 返回一个锚框宽,高,中心点x,中心点y
w = anchor[2] - anchor[0] + 1
h = anchor[3] - anchor[1] + 1
x_ctr = anchor[0] + 0.5 * (w - 1)
y_ctr = anchor[1] + 0.5 * (h - 1)
return w, h, x_ctr, y_ctr
def _mkanchors(ws, hs, x_ctr, y_ctr):
# 给出围绕在中心点(x_ctr, y_ctr),宽度(ws)和高度(hs)为向量,输出一组锚框
ws = ws[:, np.newaxis]
hs = hs[:, np.newaxis]
anchors = np.hstack((x_ctr - 0.5 * (ws - 1), y_ctr - 0.5 * (hs - 1), x_ctr + 0.5 * (ws - 1), y_ctr + 0.5 * (hs - 1)))
return anchors
def _ratio_enum(anchor, ratios):
# 列举一组各种比例的锚框,默认锚框为(0, 0, 15, 15)
w, h, x_ctr, y_ctr = _whctrs(anchor)
size = w * h
size_ratios = size / ratios
ws = np.round(np.sqrt(size_ratios))
hs = np.round(ws * ratios)
anchors = _mkanchors(ws, hs, x_ctr, y_ctr)
return anchors
def _scale_enum(anchor, scales):
# 列举一组各种尺度的锚框,共三个尺度
w, h, x_ctr, y_ctr = _whctrs(anchor)
ws = w * scales
hs = h * scales
anchors = _mkanchors(ws, hs, x_ctr, y_ctr)
return anchors
if __name__ == '__main__':
a = generate_anchors()
print(a)
"""
[[ -84. -40. 99. 55.]
[-176. -88. 191. 103.]
[-360. -184. 375. 199.]
[ -56. -56. 71. 71.]
[-120. -120. 135. 135.]
[-248. -248. 263. 263.]
[ -36. -80. 51. 95.]
[ -80. -168. 95. 183.]
[-168. -344. 183. 359.]]
"""
3.2. 区域提议层
R-CNN第一个版本使用Selective Search(SS)产生区域提议。最近版本中,使用基于滑动窗口的技术生成一组密集候选区域,然后使用神经网络驱动的区域提议网络根据包含前景对象区域的概率对区域提议进行排序。区域提议层有两个目标:从一系列锚框中辨别出前景和背景框;通过一组回归系数修改锚框的位置和宽高,以提高锚框质量。
区域提议层包含一个区域提议网络和三个层——提议层、锚框目标层和提议目标层。如下图所示。
3.2.1. 区域提议网络
以上区域提议网络首先通过rpn_net生成特征图,然后有两个分支。一个分支进行是否有物体的二分类类别判定,首先生成类分数,经过softmax后生成类概率,总共有个框,所有框都进行判断;另一个分支进行输出个框的每个框四个回归系数。
3.3. 提议层
首先将上述生成的anchors按照回归系数rpn_bbox_pred进行转换(下面bbox_transform_inv函数),转换到预测的边框pred_boxes;然后进行削减(将超出图像边界的框进行削减,下面clip_boxes函数);然后按锚框分数排名选取前12000个锚框;最后通过NMS非极大值抑制产生的锚框再选取前2000个作为最终的提议锚框。
首先由RPN模块的输出rpn_bbox_pred(边界框回归系数)和锚框生成层生成的anchors转换锚框生成新的边界框(下面的bbox_transform_inv函数);然后剪裁超出图像边界的box(仅仅将超出的部分削减,box数目不变,如下图10所示);另一边将同等数量的带分数的box(由RPN模块输出的rpn_cls_prob)按前景区域概率进行排序;然后两边都同时在NMS之前选择top分数的box;将前面的锚框和现在的带分数排序的box进行合并大于阈值的box留下;再进行NMS后共产生个box。
下面代码显示提议层的具体操作。
import torch
from model.config import cfg
from torchvision.ops import nms
from model.bbox_transform import bbox_transform_inv, clip_boxes
def proposal_layer(rpn_cls_prob, rpn_bbox_pred, im_info, cfg_key, _feat_stride, anchors, num_anchors):
"""
rpn_cls_prob: 经过RPN模块后各个框类(二类)概率,batch * h * w * (num_anchors * 2)
rpn_bbox_pred: 经过RPN模块后各个边界框系数,batch * h * w * (num_anchors * 4)
im_info: 前两个维度为图像宽高
anchors: 是generate_anchors_pre输出值,共w * h * 9个边界框
num_anchors: 默认9
RPN_PRE_NMS_TOP_N表示进行NMS前保留的框数,默认12000
RPN_POST_NMS_TOP_N表示进行NMS后保留的框数,默认2000
返回:blob=shape(经过NMS后保留的anchor数, 5),5的第一个是0,后四个是坐标值,需要进行后续类别的存储
返回:scores=shape(经过NMS后保留的anchor数, 1)
"""
if type(cfg_key) == bytes:
cfg_key = cfg_key.decode('utf-8')
pre_nms_topN = cfg[cfg_key].RPN_PRE_NMS_TOP_N
post_nms_topN = cfg[cfg_key].RPN_POST_NMS_TOP_N
nms_thresh = cfg[cfg_key].RPN_NMS_THRESH
# 得到前景分数和边界框
scores = rpn_cls_prob[:, :, :, num_anchors:]
rpn_bbox_pred = rpn_bbox_pred.view((-1, 4))
scores = scores.contiguous().view(-1, 1)
proposals = bbox_transform_inv(anchors, rpn_bbox_pred)
proposals = clip_boxes(proposals, im_info[:2])
# 选择最高区域提议
scores, order = scores.view(-1).sort(descending=True)
if pre_nms_topN > 0:
order = order[:pre_nms_topN]
scores = scores[:pre_nms_topN].view(-1, 1)
proposals = proposals[order.data, :]
# 非极大值抑制,keep从高到低排序
keep = nms(proposals, scores.squeeze(1), nms_thresh)
# NMS后保留最高的多个区域提议
if post_nms_topN > 0:
keep = keep[:post_nms_topN]
proposals = proposals[keep, :]
scores = scores[keep, ]
# 仅支持单张图片作为输入,因为一张图片就可产生256个锚框
batch_inds = proposals.new_zeros(proposals.size(0), 1)
blob = torch.cat((batch_inds, proposals), 1)
return blob, scores
边界框转换函数,利用原始边界框和边界框系数将边界框进行调整得到预测的边界框。
def bbox_transform_inv(boxes, deltas):
if len(boxes) == 0:
return deltas.detach() * 0
# 各个框的宽度高度和中心点坐标
widths = boxes[:, 2] - boxes[:, 0] + 1.0
heights = boxes[:, 3] - boxes[:, 1] + 1.0
ctr_x = boxes[:, 0] + 0.5 * widths
ctr_y = boxes[:, 1] + 0.5 * heights
# 各个框的边界框回归系数
dx = deltas[:, 0::4]
dy = deltas[:, 1::4]
dw = deltas[:, 2::4]
dh = deltas[:, 3::4]
# 通过原始框和边界框回归系数进行运算得到的预测边界框中心点和宽高
pred_ctr_x = dx * widths.unsqueeze(1) + ctr_x.unsqueeze(1)
pred_ctr_y = dy * heights.unsqueeze(1) + ctr_y.unsqueeze(1)
pred_w = torch.exp(dw) * widths.unsqueeze(1)
pred_h = torch.exp(dh) * heights.unsqueeze(1)
# 得到预测的各个框的左上角和右下角坐标
pred_boxes = torch.cat([_.unsqueeze(2) for _ in [pred_ctr_x - 0.5 * pred_w, pred_ctr_y - 0.5 * pred_h, pred_ctr_x + 0.5 * pred_w, pred_ctr_y + 0.5 * pred_h]], 2).view(len(boxes), -1)
return pred_boxes
削减边界框到图像边界,超出图像边界的框被削减,如上图10所示。numpy.clamp(a, b)函数将值限制在[a, b]之间。
def clip_boxes(boxes, im_shape):
if not hasattr(boxes, 'data'):
boxes_ = boxes.numpy()
boxes = boxes.view(boxes.size(0), -1, 4)
boxes = torch.stack([boxes[:, :, 0].clamp(0, im_shape[1] - 1), boxes[:, :, 1].clamp(0, im_shape[0] - 1), boxes[:, :, 2].clamp(0, im_shape[1] - 1), boxes[:, :, 3].clamp(0, im_shape[0] - 1)], 2).view(boxes.size(0), -1)
return boxes
在函数generate_anchors生个9个box基础上,按照所有共位置,生成个box。
import numpy as np
def generate_anchors_pre(height, width, feat_stride, anchor_scales=(8, 16, 32), anchor_ratios=(0.5, 1, 2)):
# 给出不同比例尺度生成框,生成所有位置的框,共w * h * 9个,并且返回框的数量
# feat_stride = 16,因为图像降采样16倍
# 返回anchors为(w * h * 9, 4)
# 返回length为w * h
anchors = generate_anchors(ratios=np.array(anchor_ratios), scales=np.array(anchor_scales))
A = anchors.shape[0] # A = 9
shift_x = np.arange(0, width) * feat_stride
shift_y = np.arange(0, height) * feat_stride
shift_x, shift_y = np.meshgrid(shift_x, shift_y)
shifts = np.vstack((shift_x.ravel(), shift_y.ravel(), shift_x.ravel(), shift_y.ravel())).transpose()
K = shifts.shape[0] # K = w * h
anchors = anchors.reshape((1, A, 4)) + shifts.reshape((1, K, 4)).transpose((1, 0, 2))
anchors = anchors.reshape((K * A, 4)).astype(np.float32, copy=False)
length = np.int32(anchors.shape[0])
return anchors, length
3.4. 锚框目标层
该层主要目的是为锚框打标签,正例为1,负例为0,“不关心”为-1。首先计算传入的所有锚框(假设N个)和所有GT框(例如猫狗,假设K个)相互之间两两重合度,生成(N, K)矩阵;查看某个锚框与所有GT框重合度最大值,N个锚框和这些GT框重合度最大值就有N个,将这N个最大值中小于0.3的设置为0(背景),意思就是和所有GT框都进行对比了重合度都很小那就当做背景;前景标签有两种情况,一种是所有K个GT框都有一个与之重合度最大的锚框,将这K个锚框设置为1(前景),还有一种情况是N个锚框中(除去前面已经分配的背景和前景锚框)与所有GT重合度最大值大于0.7设置为1(前景)。其实最终我们按分数值保留256个框。如果刚才的操作后前景数太多(通常大于128),则随机的将多余128个的前景设置为-1(不关心);同样背景数太多也进行随机赋值-1。计算N个anchors和每个对应的GT框(上面已经找到对应的GT框)的回归系数,仅仅正样本(前景)才有回归系数。首先看看RPN损失如何计算。
3.4.1. 计算RPN损失
记住RPN层目标是产生好的边界框。从一系列锚框中,RPN层必须学习分别一个锚框是前景还是背景并且计算回归系数来调整前景框边框位置和宽高。RPN损失就是用来激励学习这样的行为。
RPN损失是分类损失和边界框回归损失之和。分类损失使用交叉熵损失惩罚分类错误的框,回归损失使用真实回归系数(锚框与最接近匹配GT计算,参见图4)和网络预测的回归系数之间的差距(锚框与预测框计算,参见RPN网络结构图中的rpn_bbx_pred_net)。
其中表示锚框的序号,表示锚框预测是否为物体概率,如果锚框为正,GT标签为1,否则为0;是预测边界框回归系数,而是一个相关正锚框GT边界框回归系数。下面是对边界框回归系数的计算,分别表示预测框、锚框和GT框:
我们计算预测的(通过预测框和锚框)和目标的(通过GT框和锚框 )回归系数之间差异。有四项——对应于左上角坐标和边界框宽高。平滑L1函数定义:,
这里可以任意值(代码中设置为3)。因此,为了计算loss,计算两项:类标签(前景或背景);前景锚框的目标回归系数。
示例如上图所示,首先选择N个输入框与图像中每个对象IoU最大的留下作为前景框,这样无论如何先产生K(对象数量)个框,以防和对象IoU最大的框都没有超过阈值0.7的;然后选择重合度超过一定阈值的方框。
注意,只有与某些GT真值框重叠度超过阈值的锚框才被选为前景框,这样以防离真值框太远的框不能和真值框形成线性映射。类似地,重叠小于阈值的框被标记为背景框。既不是前景框也不是背景框的框被标记为“不在乎”,这些框不会包含在计算RPN损失中。
输入:RPN网络的输出(类分数);锚框(由锚框生成层生成);GT boxes。
输出:好的前景/背景框和相关的类标签;目标回归系数。
下面详细看一下锚框目标层的代码。
import torch
import numpy as np
import numpy.random as npr
from model.config import cfg
from utils.bbox import bbox_overlaps
from model.bbox_transform import bbox_transform
def anchor_target_layer(rpn_cls_score, gt_boxes, im_info, _feat_stride, all_anchors, num_anchors):
"""
rpn_cls_score: batch * h * w * (num_anchors * 2)
im_info: 前两个维度是宽高
_feat_stride: 16
all_anchors: generate_anchors_pre的输出,共w * h * 9个边界框
num_anchors: 9
"""
A = num_anchors # A = 9
total_anchors = all_anchors.shape[0] # w * h * 9
K = total_anchors / num_anchors # w * h
_allowed_border = 0
height, width = rpn_cls_score.shape[1:3]
# 仅保留在图像中的anchors
inds_inside = np.where((all_anchors[:, 0] >= -_allowed_border) & (all_anchors[:, 1] >= -_allowed_border) & (all_anchors[:, 2] < im_info[1] + _allowed_border) & (all_anchors[:, 3] < im_info[0] + _allowed_border))[0]
anchors = all_anchors[inds_inside, :]
# label: 1 是 positive, 0 是negative, -1是不关心
labels = np.empty((len(inds_inside), ), dtype=np.float32)
labels.fill(-1)
# 锚框和GT真值框的重合度
overlaps = bbox_overlaps(np.ascontiguousarray(anchors, dtype=np.float), np.ascontiguousarray(gt_boxes, dtype=np.float))
argmax_overlaps = overlaps.argmax(axis=1) # 第二根轴最大值序号,得到N个值,每个值表示从K个值中取得的最大值序号
max_overlaps = overlaps[np.arange(len(inds_inside)), argmax_overlaps] # 上述描述代表的值
gt_argmax_overlaps = overlaps.argmax(axis=0)
gt_max_overlaps = overlaps[gt_argmax_overlaps, np.arange(overlaps.shape[1])]
gt_argmax_overlaps = np.where(overlaps == gt_max_overlaps)[0] # 每N个值中最大值的序号,是K维向量
# cfg.TRAIN.RPN_CLOBBER_POSITIVES = False
if not cfg.TRAIN.RPN_CLOBBER_POSITIVES:
# 首先设置背景标签,以便正标签可以清除它们
# cfg.TRAIN.RPN_NEGATIVE_OVERLAP = 0.3
labels[max_overlaps < cfg.TRAIN.RPN_NEGATIVE_OVERLAP] = 0
# 前景标签:对于每个gt,锚框拥有最大重合度
labels[gt_argmax_overlaps] = 1 # 只有K个,正好对应图像中物体数量
# 前景标签:超过阈值IoU
# cfg.TRAIN.RPN_POSITIVE_OVERLAP = 0.7
labels[max_overlaps >= cfg.TRAIN.RPN_POSITIVE_OVERLAP] = 1
# cfg.TRAIN.RPN_CLOBBER_POSITIVES = True
if cfg.TRAIN.RPN_CLOBBER_POSITIVES:
# 最后分配背景标签,这样负标签可以清除正标签
labels[max_overlaps < cfg.TRAIN.RPN_NEGATIVE_OVERLAP] = 0
# 如果我们有太多前景,采样正标签
# cfg.TRAIN.RPN_FG_FRACTION = 0.5
# cfg.TRAIN.RPN_BATCHSIZE = 256
num_fg = int(cfg.TRAIN.RPN_FG_FRACTION * cfg.TRAIN.RPN_BATCHSIZE) # 128
fg_inds = np.where(labels == 1)[0] # 值为1的序号列表
# 如果前景太多,超过128,则将一些前景设置为-1,剩下128个
if len(fg_inds) > num_fg:
disable_inds = npr.choice(fg_inds, size=(len(fg_inds) - num_fg), replace=False)
labels[disable_inds] = -1
# 如果有太多负样本,同样进行采样删除
num_bg = cfg.TRAIN.RPN_BATCHSIZE - np.sum(labels == 1)
bg_inds = np.where(labels == 0)[0]
# 如果背景大于256 - 正样本的数量,则删除一些
if len(bg_inds) > num_bg:
disable_inds = npr.choice(bg_inds, size=(len(bg_inds) - num_bg), replace=False)
labels[disable_inds] = -1
# 计算N个锚框和N个相应的GT边界框回归系数,输出(N, 4)
bbox_targets = np.zeros((len(inds_inside), 4), dtype=np.float32)
bbox_targets = _compute_targets(anchors, gt_boxes[argmax_overlaps, :])
# inside权重参数
bbox_inside_weights = np.zeros((len(inds_inside), 4), dtype=np.float32)
# 仅仅正样本有回归系数
# cfg.TRAIN.RPN_BBOX_INSIDE_WEIGHTS = (1.0, 1.0, 1.0, 1.0)
bbox_inside_weights[labels == 1, :] = np.array(cfg.TRAIN.RPN_BBOX_INSIDE_WEIGHTS)
# outside权重参数
bbox_outside_weights = np.zeros((len(inds_inside), 4), dtype=np.float32)
# cfg.TRAIN.RPN_POSITIVE_WEIGHT = -1.0
if cfg.TRAIN.RPN_POSITIVE_WEIGHT < 0:
num_examples = np.sum(labels >= 0)
positive_weights = np.ones((1, 4)) * 1.0 / num_examples
negative_weights = np.ones((1, 4)) * 1.0 / num_examples
else:
assert ((cfg.TRAIN.RPN_POSITIVE_WEIGHT > 0) & (cfg.TRAIN.RPN_POSITIVE_WEIGHT < 1))
positive_weights = (cfg.TRAIN.RPN_POSITIVE_WEIGHT / np.sum(labels == 1))
negative_weights = ((1.0 - cfg.TRAIN.RPN_POSITIVE_WEIGHT) / np.sum(labels == 0))
bbox_outside_weights[labels == 1, :] = positive_weights
bbox_outside_weights[labels == 0, :] = negative_weights
# 将矩阵扩充至total_anchors这么大,相应的扩充的其余部分使用-1或0代替
labels = _unmap(labels, total_anchors, inds_inside, fill=-1)
bbox_targets = _unmap(bbox_targets, total_anchors, inds_inside, fill=0)
bbox_inside_weights = _unmap(bbox_inside_weights, total_anchors, inds_inside, fill=0)
bbox_outside_weights = _unmap(bbox_outside_weights, total_anchors, inds_inside, fill=0)
"""制作成RPN格式,包括labels,bbox_targets,bbox_inside_weights,bbox_outside_weights"""
# labels
labels = labels.reshape((1, height, width, A)).transpose(0, 3, 1, 2) # (1, 9, height, width)
labels = labels.reshape((1, 1, A * height, width)) # (1, 1, 9 * height, width)
rpn_labels = labels
# bbox_targets
bbox_targets = bbox_targets.reshape((1, height, width, A * 4)) # (1, height, width, 36)
rpn_bbox_targets = bbox_targets
# bbox_inside_weights
bbox_inside_weights = bbox_inside_weights.reshape((1, height, width, A * 4)) # (1, height, width, 36)
rpn_bbox_inside_weights = bbox_inside_weights
# bbox_outside_weights
bbox_outside_weights = bbox_outside_weights.reshape((1, height, width, A * 4)) # (1, height, width, 36)
rpn_bbox_outside_weights = bbox_outside_weights
return rpn_labels, rpn_bbox_targets, rpn_bbox_inside_weights, rpn_bbox_outside_weights
def _unmap(data, count, inds, fill=0):
if len(data.shape) == 1:
ret = np.empty((count, ), dtype=np.float32)
ret.fill(fill)
ret[inds] = data
else:
ret = np.empty((count, ) + data.shape[1:], dtype=np.float32)
ret.fill(fill)
ret[inds, :] = data
return ret
def _compute_targets(ex_rois, gt_rois):
assert ex_rois.shape[0] == gt_rois.shape[0]
assert ex_rois.shape[1] == 4
assert gt_rois.shape[1] == 5
return bbox_transform(torch.from_numpy(ex_rois), torch.from_numpy(gt_rois[:, :4])).numpy()
计算所有N个经过NMS后保留的框与K个GT框之间的IoU,所以输出维度为(N, K)。
import torch
import numpy as np
def bbox_overlaps(boxes, query_boxes):
# boxes=shape(N, 4)
# query_boxes=shape(K, 4)
# 返回:(N, K)在boxes和query_boxes之间的重合度
if isinstance(boxes, np.ndarray):
boxes = torch.from_numpy(boxes)
query_boxes = torch.from_numpy(query_boxes)
out_fn = lambda x: x.numpy()
else:
out_fn = lambda x: x
# 计算N个box的面积以及K个query_box的面积
box_areas = (boxes[:, 2] - boxes[:, 0] + 1) * (boxes[:, 3] - boxes[:, 1] + 1) # N * 1
query_areas = (query_boxes[:, 2] - query_boxes[:, 0] + 1) * (query_boxes[:, 3] - query_boxes[:, 1] + 1) # K * 1
# 相应交集的宽高
iw = (torch.min(boxes[:, 2:3], query_boxes[:, 2:3].t()) - torch.max(boxes[:, 0:1], query_boxes[:, 0:1].t()) + 1).clamp(min=0)
ih = (torch.min(boxes[:, 3:4], query_boxes[:, 3:4].t()) - torch.max(boxes[:, 1:2], query_boxes[:, 1:2].t()) + 1).clamp(min=0)
# 每个box和每个query_box做加法,再减去相应的交集
# N * 1 + 1 * K - 相应的交集
ua = box_areas.view(-1, 1) + query_areas.view(1, -1) - iw * ih
overlaps = iw * ih / ua
return out_fn(overlaps)
计算边界框回归系数。
def bbox_transform(ex_rois, gt_rois):
ex_widths = ex_rois[:, 2] - ex_rois[:, 0] + 1.0
ex_heights = ex_rois[:, 3] - ex_rois[:, 1] + 1.0
ex_ctr_x = ex_rois[:, 0] + 0.5 * ex_widths
ex_ctr_y = ex_rois[:, 1] + 0.5 * ex_heights
gt_widths = gt_rois[:, 2] - gt_rois[:, 0] + 1.0
gt_heights = gt_rois[:, 3] - gt_rois[:, 1] + 1.0
gt_ctr_x = gt_rois[:, 0] + 0.5 * gt_widths
gt_ctr_y = gt_rois[:, 1] + 0.5 * gt_heights
targets_dx = (gt_ctr_x - ex_ctr_x) / ex_widths
targets_dy = (gt_ctr_y - ex_ctr_y) / ex_heights
targets_dw = torch.log(gt_widths / ex_widths)
targets_dh = torch.log(gt_heights / ex_heights)
targets = torch.stack((targets_dx, targets_dy, targets_dw, targets_dh), 1)
return targets
3.5. 计算分类层损失
和RPN损失类似,分类层损失有两部分——分类损失和边界框回归损失。
RPN层和分类层关键不同的地方在于RPN层仅仅处理两类分类——前景和背景,而分类层处理我们需要训练辨别的所有目标分类(加上背景类)。
分类损失是以真实对象类和预测类得分为参数的交叉熵损失。其计算如下图11所示:
3.6. 提议目标层
该层主要目的是从proposal_layer输出的RoIs和scores中采样和GT框重合度高的RoIs。首先计算所有RoIs和GT框重合度,假设为(N, K),求N个RoIs中每个和K个GT框最大重合度,并附上相应GT的标签;然后找出最大重合度大于0.5的RoIs选择为前景,介于0.1到0.5的选择为背景;然后相应的随机去除一些前景和背景,将数量变为256个RoIs,保留选择的前景标签,将剩余的背景标签标记为0;然后再计算这些RoIs和相应GT框的回归系数(背景框不会计算),N个RoIs回归输出为(N, 21 * 4)。
还有一些额外的逻辑试图确保前景和背景区域的总数是恒定的。如果发现的背景区域太少,则尝试通过随机重复一些背景索引来填充批,以弥补不足。
接下来,在每个RoI和最近匹配的GT真值框之间计算边界框目标回归目标(这也包括背景RoI,因为这些RoI也存在重叠的GT真值框)。这些回归目标针对所有类展开,如下图所示。
输入:由提议层生成的RoIs和scores;GT信息。
输出:被选择的满足重合准则的前景和背景RoIs;对于RoIs的具体类目标回归系数。
提议目标层代码。
import torch
import numpy as np
import numpy.random as npr
from model.config import cfg
from utils.bbox import bbox_overlaps
from model.bbox_transform import bbox_transform
def proposal_target_layer(rpn_rois, rpn_scores, gt_boxes, _num_classes):
"""
为GT真实目标分配目标检测提议,生成提议分类标签和边框回归系数
rpn_rois为proposal_layer返回的值
rpn_scores为proposal_layer返回的值
"""
# 提议RoIs来自RPN,(0, x1, y1, x2, y2)
all_rois = rpn_rois
all_scores = rpn_scores
# 在候选RoI集合中包含GT真值框,默认False
if cfg.TRAIN.USE_GT:
zeros = rpn_rois.new_zeros(gt_boxes.shape[0], 1)
all_rois = torch.cat((all_rois, torch.cat((zeros, gt_boxes[:, :-1]), 1)), 0)
all_scores = torch.cat((all_scores, zeros), 0)
num_images = 1
rois_per_image = cfg.TRAIN.BATCH_SIZE / num_images
fg_rois_per_image = int(round(cfg.TRAIN.FG_FRACTION * rois_per_image))
# 采样分类标签和边界框回归的RoIs
labels, rois, roi_scores, bbox_targets, bbox_inside_weights = _sample_rois(all_rois, all_scores, gt_boxes, fg_rois_per_image, rois_per_image, _num_classes)
rois = rois.view(-1, 5)
roi_scores = roi_scores.view(-1)
labels = labels.view(-1, 1)
bbox_targets = bbox_targets.view(-1, _num_classes * 4)
bbox_inside_weights = bbox_inside_weights.view(-1, _num_classes * 4)
bbox_outside_weights = (bbox_inside_weights > 0).float()
return rois, roi_scores, labels, bbox_targets, bbox_inside_weights, bbox_outside_weights
def _get_bbox_regression_labels(bbox_target_data, num_classes):
"""
边界框回归目标(bbox_target_data)以紧凑形式存储在N × (class, tx, ty, tw, th)
返回:bbox_target:N × 4K回归目标
返回:bbox_inside_weights:N ×4K内部权值
"""
clss = bbox_target_data[:, 0]
bbox_targets = clss.new_zeros(clss.numel(), 4 * num_classes)
bbox_inside_weights = clss.new_zeros(bbox_targets.shape)
inds = (clss > 0).nonzero().view(-1) # (m, )
if inds.numel() > 0:
clss = clss[inds].contiguous().view(-1, 1)
dim1_inds = inds.unsqueeze(1).expand(inds.size(0), 4) # (m, 4)
dim2_inds = torch.cat([4 * clss, 4 * clss + 1, 4 * clss + 2, 4 * clss + 3], 1).long() # (m, 4)
bbox_targets[dim1_inds, dim2_inds] = bbox_target_data[inds][:, 1:]
bbox_inside_weights[dim1_inds, dim2_inds] = bbox_targets.new(cfg.TRAIN.BBOX_INSIDE_WEIGHTS).view(-1, 4).expand_as(dim1_inds)
return bbox_targets, bbox_inside_weights
def _compute_targets(ex_rois, gt_rois, labels):
# 对于一张图像计算边界回归目标
# ex_rois=shape(m, 4),gt_rois=shape(m, 4),labels=shape(m, )
# 返回(m, 5),m是最终保留的边框数
assert ex_rois.shape[0] == gt_rois.shape[0]
assert ex_rois.shape[1] == 4
assert gt_rois.shape[1] == 4
targets = bbox_transform(ex_rois, gt_rois)
if cfg.TRAIN.BBOX_NORMALIZE_TARGETS_PRECOMPUTED:
# 可选地通过预先计算的平均值和标准偏差规范化目标
targets = ((targets - targets.new(cfg.TRAIN.BBOX_NORMALIZE_MEANS)) / targets.new(cfg.TRAIN.BBOX_NORMALIZE_STDS))
return torch.cat([labels.unsqueeze(1), targets], 1)
def _sample_rois(all_rois, all_scores, gt_boxes, fg_rois_per_image, rois_per_image, num_classes):
# 采样包含前景和背景样例的一系列随机RoIs
overlaps = bbox_overlaps(all_rois[:, 1:5].data, gt_boxes[:, :4].data) # (N, K)
max_overlaps, gt_assignment = overlaps.max(1) # 对行求最大,得到最大值以及最大值序号,N维
labels = gt_boxes[gt_assignment, [4]] # 最后一个维度是标签
# 选择前景RoIs是那些重合度大于FG_THRESH
# cfg.TRAIN.FG_THRESH = 0.5
fg_inds = (max_overlaps >= cfg.TRAIN.FG_THRESH).nonzero().view(-1)
# 选择那些在[BG_THRESH_LO, BG_THRESH_HI)之间的背景RoIs
# BG_THRESH_HI = 0.5
# BG_THRESH_LO = 0.1
bg_inds = ((max_overlaps < cfg.TRAIN.BG_THRESH_HI) + (max_overlaps >= cfg.TRAIN.BG_THRESH_LO) == 2).nonzero().view(-1)
# 对原始版本的小修改,其中我们确保对固定数量的区域进行采样
if fg_inds.numel() > 0 and bg_inds.numel() > 0:
fg_rois_per_image = min(fg_rois_per_image, fg_inds.numel())
fg_inds = fg_inds[torch.from_numpy(npr.choice(np.arange(0, fg_inds.numel()), size=int(fg_rois_per_image), replace=False)).long().to(gt_boxes.device)]
bg_rois_per_image = rois_per_image - fg_rois_per_image
to_replace = bg_inds.numel() < bg_rois_per_image
bg_inds = bg_inds[torch.from_numpy(npr.choice(np.arange(0, bg_inds.numel()), size=int(bg_rois_per_image), replace=to_replace)).long().to(gt_boxes.device)]
elif fg_inds.numel() > 0:
to_replace = fg_inds.numel() < rois_per_image
fg_inds = fg_inds[torch.from_numpy(npr.choice(np.arange(0, fg_inds.numel()), size=int(rois_per_image), replace=to_replace)).long().to(gt_boxes.device)]
fg_rois_per_image = rois_per_image
elif bg_inds.numel() > 0:
to_replace = bg_inds.numel() < rois_per_image
bg_inds = bg_inds[torch.from_numpy(npr.choice(np.arange(0, bg_inds.numel()), size=int(rois_per_image), replace=to_replace)).long().to(gt_boxes.device)]
fg_rois_per_image = 0
else:
import pdb
pdb.set_trace()
# 我们选择的索引(fg和bg)
keep_inds = torch.cat([fg_inds, bg_inds], 0)
# 从各种数组中选择采样值
labels = labels[keep_inds].contiguous()
# 对背景RoIs标签设置为0
labels[int(fg_rois_per_image):] = 0
rois = all_rois[keep_inds].contiguous()
roi_scores = all_scores[keep_inds].contiguous()
# (m, 5)
bbox_target_data = _compute_targets(rois[:, 1:5].data, gt_boxes[gt_assignment[keep_inds]][:, :4].data, labels.data)
# 边界框回归目标和边界框内部权重
bbox_targets, bbox_inside_weights = _get_bbox_regression_labels(bbox_target_data, num_classes)
# labels=shape(m, ),rois=shape(m, 5),roi_scores=shape(m, 1),bbox_targets=shape(m, 4K),bbox_inside_weights=shape(m, 4K)
return labels, rois, roi_scores, bbox_targets, bbox_inside_weights
3.7. 剪裁池化
提议目标层产生有意义的RoI,以便我们在训练期间使用相关的类标签和回归系数进行分类。下一步是从头部网络生成的特征图中提取与这些RoI相对应的区域。然后,提取的特征图穿过网络的其余部分,为每个RoI生成对象类概率分布和回归系数。剪裁池化的工作是从卷积特征图中提取区域。
其目的是将一个warping函数(由2×3仿射变换矩阵描述)应用于一个输入特征映射以输出一个扭曲的特征映射。如下图所示:
剪裁池化包含两个步骤:
(1)对于一组目标坐标,应用给定的仿射变换生成源坐标网格。
。这里是规格化的宽高坐标,因此都介于-1与1之间。
(2)在源坐标处对输入映射进行采样,以生成输出映射。每个坐标定义输入中的空间位置,其中应用采样内核(例如双线性采样内核)以获得输出特征映射中特定像素处的值。
空间变换中描述的采样方法给出了一个可微的采样机制,允许损失梯度流回到输入特征图和采样网格坐标。
幸运地是,pytorch中已经实现剪裁池化操作,并且拥有以上两步的方法。torch.nn.functional.affine_grid函数采用仿射矩阵作为输入生成一组采样坐标,torch.nn.functional.grid_sample函数在这些坐标处进行采样。
为了使用剪裁池化,我们需要做下面事情:
(1)将RoI坐标除以头部网络的步长。提议目标层生成的RoI的坐标在原始图像空间中。要将这些坐标带到由头部生成的输出特征图的空间中,我们必须将它们除以步长(在当前实现中为16)。
(2)我们需要仿射矩阵。仿射矩阵的计算如下所示。
(3)我们还需要目标特征图上x和y维度上的点数。这由参数cfg.POOLING_SIZE(默认7)提供。因此,在剪裁池化时,非平方RoI用于从卷积特征映射中提取区域,这些区域被扭曲成大小恒定的正方形窗口。当剪裁池化的输出传递到需要固定维数输入的进一步卷积和完全连接的层时,必须完成这种扭曲。
3.8. 分类层
剪裁池化层接收由提议目标层输出的RoI box和由头部网络输出的卷积特征映射,并输出平方特征映射。然后,特征映射通过ResNet的第4层,然后沿着空间维度进行平均池。结果(在代码中称为“fc7”)是每个RoI的一维特征向量。这个过程如下所示。
然后特征向量穿过两个全连接层——bbox_pred_net和cls_score_net。cls_score_net层对于每个边界框生成类分数(可以使用softmax转换成概率)。bbox_pred_net层生成具体类边界框回归系数,它们与提议目标层生成的原始边界框坐标组合,生成最终边界框。详细步骤请看下图:
回忆下两组边界框回归系数的区别——一组由RPN网络产生,另一组由分类网络产生。第一组用于训练RPN层以生成良好的前景边界框。由锚框目标层生成目标回归系数,即将RoI框与其最接近的匹配GT真值边界框对齐所需的系数。第二组边界框系数由分类层生成。这些系数是特定于类的,即为每个RoI框的每个对象类生成一组系数。这些参数的目标回归系数由提议目标层生成。注意,分类网络在平方特征映射上操作,平方特征映射是由应用于头部网络输出的仿射变换的结果。然而,由于回归系数对无剪切的仿射变换是不变的,因此,由提议目标层计算的目标回归系数可以与由分类网络产生的目标回归系数进行比较,并作为有效的学习信号。
值得注意的是,在训练分类层时,错误梯度也会传播到RPN网络。这是因为在剪裁池化中使用的RoI框坐标本身就是网络输出,因为它们是将RPN网络生成的回归系数应用于锚框的结果。在反向传播过程中,误差梯度将通过剪裁池化层传播回RPN层。
4. 实现细节——预测
详细预测过程如下图:
不使用锚框目标层和提议目标层。RPN网络应该已经学会了如何将锚框分为背景框和前景框,并生成良好的边界框系数。提议层简单地将边界框系数应用于排名靠前的锚框,并执行NMS以消除具有大量重叠的框。为了更清楚起见,下面显示这些步骤的输出。生成的框被发送到分类层,在分类层中生成类分数和类特定的边界框回归系数。
红框显示按分数排列的前6名框。绿色框显示应用RPN网络计算的回归系数后的定位框。绿色框看起来更适合潜在对象。注意,应用回归系数后,矩形仍然是矩形,即没有剪切。还要注意矩形之间的显著重叠。这种冗余是通过应用非极大值抑制来解决的。
红色框显示NMS之前前5边界框,绿色框限制NMS后前5边界框。通过抑制重合框,其他框(分数列表中较低)有机会移除。
从最终的分类分数数组(维度:n,21)中,我们选择对应于某个前景对象的列,比如汽车。然后,我们选择该数组中与最大得分对应的行。这一行与最有可能成为汽车的提议相对应。现在让这一行的索引为car_score_max_idx,让最后的边界框坐标数组(应用回归系数后)为bboxes(维度:n, 21×4)。从这个数组中,我们选择与car_score_max_idx对应的行。我们希望与汽车列对应的边界框应该比其他边界框(与错误的对象类对应)更适合测试图像中的汽车。事实确实如此。红色框对应于原始方案框,蓝色框对应于汽车类的计算边界框,白色框对应于其他(不正确的)前景类。可以看出,蓝色的框比其他框更适合实际的汽车。
为了显示最终的分类结果,我们应用了另一轮NMS,并对类分数应用了一个目标检测阈值。然后,我们画出所有符合检测阈值的RoI对应的变换边界框。结果如下所示。
5. 附录
5.1. ResNet50网络结构
5.2. 非极大值抑制
非极大值抑制是一种通过消除重叠量大于阈值的框来减少候选框数量的技术。首先根据一些条件(通常是右下角的y坐标)对框进行排序。然后,我们查看框列表,并抑制那些IoU与所考虑的框重叠超过阈值的框。按y坐标对框进行排序会导致保留一组重叠框中的最低框。这可能并不总是理想的结果。R-CNN中使用的NMS按前景分数对框进行排序。这将导致保留一组重叠框中得分最高的框。下图显示了这两种方法的区别。黑色的数字是每个框的前景分数。右边的图像显示了将NMS应用于左边图像的结果。第一个图使用标准NMS(框按右下角的y坐标排列)。这将导致保留较低分数的方框。第二个图使用修改后的NMS(框按前景分数排列)。这将导致保留最高前景分数的框,这更为可取。在这两种情况下,假设盒子之间的重叠度高于NMS重叠度。
参考
文章原文:http://www.telesens.co/2018/03/11/object-detection-and-classification-using-r-cnns/
pytorch项目:https://github.com/ruotianluo/pytorch-faster-rcnn
论文原文:https://arxiv.org/pdf/1506.01497.pdf
可供参考的NMS图解:https://docs.google.com/presentation/d/1aeRvtKG21KHdD5lg6Hgyhx5rPq_ZOsGjG5rJ1HP7BbA/pub?start=false&loop=false&delayms=3000#slide=id.p