该片解读在官网的这篇文章的基础之上对细节进行补充,仅供自己学习使用,不足之处请在评论区指出。轻松掌握 MMDetection 中常用算法(一):RetinaNet 及配置详解 - 知乎 (zhihu.com)
本文是结合上面官方提供的文章和b站噼里啪啦博主的讲解结合起来学习的。
解读总共包含两部分,第一部分是对代码含义解读,第二部分就是对代码debug进行调试,了解数据流。?
本文不同于官方提供的解读文章共有两点:debug调试和3.2.0版本的代码位置说明。
?该图片引用B站噼里啪啦博主。该网络的创新点是提出Focal Loss焦点损失函数。从而使得一阶段检测器的精度能够超过二阶段检测器。
这里我选用的配置文件位置在configs/retinanet/retinanet_r18_fpn_1x_coco.py。配置文件的含义如下。
以Resnet18为例,骨干网络配置如下。
backbone=dict(
#表示使用resnet18
depth=18,
#表示固定stem+第一个stage的权重,不进行训练
frozen_stages=1,
#使用pytorch提供的在imagenet上面训练过的权重作为预训练权重
init_cfg=dict(checkpoint='torchvision://resnet18', type='Pretrained'),
#所有BN层的可学习参数都需要梯度更新
norm_cfg=dict(requires_grad=True, type='BN'),
#backbone所有的BN层的均值和方差都直接采用全局预训练值,不进行更新
norm_eval=True,
#Resnet系列包含stem+4个stage输出
num_stages=4,
#表示四个stage的输出索引
out_indices=(
0,
1,
2,
3,
),
#默认采用pytorch模式
style='pytorch',
#骨架网络名
type='ResNet'),
?在训练过程中stem+第一个stage不进行训练即参数不更新。BN层的均值和方差不更新,但β和γ这些可学习参数进行更新。尽量不更改上面的设置,效果更稳定。
resnet提出了骨架网络设计范式即stem+ n个stage+cls head,其forward流程是stem->4个stage->分类head,stem的输出stride是4,而4个stage的输出stride是4,8,16,32。这里的stride指的是下采样率。因为retinanet后面需要接FPN,故需要输出4个尺度特征图,因此四个stage的索引都需要。
以下代码位置:mmdet/models/backbones/resnet.py的641行左右。简要代码如下
for i, layer_name in enumerate(self.res_layers):
res_layer = getattr(self, layer_name)
x = res_layer(x)
#如果i在out_incices中才保留
if i in self.out_indices:
outs.append(x)
return tuple(outs)
?首先由下图可以看出,该骨干网络由stem+4个layer和分类head构成,其中每个layer又包含2个basicblock,接下来debug看一下self的属性构成是否和分析一样。
?按照以下操作可以查看backbone网络结构。
?操作4及可以看到backbone网络构成和分析一致。
?接着继续往下debug。循环四轮后结果如下,可以看出此时已经将layer0~layer3的输出均已添加至outs列表中。
该参数表示你想冻结前几个stages的权重,Resnet结构包括stem+4个stage。
以下代码所在位置mmdet/models/backbones/resnet.py的613行左右。
#固定权重,需要两个步骤:1,设置eval模式; 2.requires_grad=False
def _freeze_stages(self):
if self.frozen_stages >= 0:
#固定stem权重
if self.deep_stem:
self.stem.eval()
for param in self.stem.parameters():
param.requires_grad = False
#上面的if条件不懂,直接跳到这里
else:
#将stem中的BN层和卷积层都设置为eval模式
self.norm1.eval()
#遍历stem中的BN层和卷积层
for m in [self.conv1, self.norm1]:
#遍历stem中的BN层和卷积层的参数
for param in m.parameters():
#设置上面遍历的参数不需要梯度更新
param.requires_grad = False
#固定stage权重
for i in range(1, self.frozen_stages + 1):
m = getattr(self, f'layer{i}')
m.eval()
for param in m.parameters():
param.requires_grad = False
? ? 由图7我们可以看出stem的构成包含conv1+bn1+relu+maxpool。这里只需要更新stem中的conv1+bn1的参数即可。
? ?由以下debug图得知,m参数遍历conv1+bn1两大层(不遍历里面的具体参数),当m遍历到conv1时,conv1内包含的参数如绿色箭头2所示。然后param再遍历conv1内的参数,如绿色箭头3所指。
? ? ?由图7我们可知backbone由stem+4个layer+分类head构成,而每个layer又包含一个ResLayer,每个ResLayer又包含两个BasicBlock。理解这里的stage指的是layer,
? ? ?在627行调试,可以看到m的结果如绿色箭头1,点击右侧的“视图”如绿色箭头2所示,页面就会呈现绿色箭头3的画面。debug调试后可以看出,m取出layer1的所有属性,而param则是依次取出绿色箭头所指的conv1->bn1->conv2...。
? ?(3)train处的设置
需要特别注意的是:上述函数不能仅仅在类初始化的时候调用,因为在训练模式下,运行时候会调用model.train()导致BN层又进入train模式,最终BN没有固定,故需要在resnet中重写train方法。
以下代码的位置mmdet/models/backbones/resnet.py的648行左右。
def train(self, mode=True):
"""Convert the model into training mode while keep normalization layer
freezed."""
#这行代码会导致BN进入train模式
super(ResNet, self).train(mode)
#再次调用,固定stem和前n个stage的BN
self._freeze_stages()
#如果所有的BN都采用全局均值和方差,则需要对整个网络的BN都开启eval模式
if mode and self.norm_eval:
for m in self.modules():
# trick: eval have effect on BatchNorm only
if isinstance(m, _BatchNorm):
m.eval()
norm_cfg表示所采用的归一化算子,一般是BN或者GN,而requires_grad表示该算子是否需要梯度,也就是是否进行参数更新,而布尔参数norm_eval是 用于控制整个骨架网络的归一化算子是否需要变成eval模式。
RetinaNet中用法是norm_cfg=dict(type='BN',requires_grad=True),表示通过Registry模式实例化BN类,并且设置为参数可学习。在MMdetection中会常看到通过字典配置方式来实例化某个类的做法,底层是采用了装饰器模式进行构建,最大好处是扩展性极强,类和类之间的耦合度降低。
style='caffe'和style='pytorch'的差别就在Bottleneck模块中
Bottleneck是标准的1X1-3X3-1X1结构,考虑stride=2下采样的场景,caffe模式下,stride参数放置在第一个1X1卷积上,而Pytorch模式下,strde放在第二个3X3卷积上。
以下代码的位置:mmdet/models/backbones/resnet.py的154行左右。
if self.style == 'pytorch':
self.conv1_stride = 1
self.conv2_stride = stride
else:
self.conv1_stride = stride
self.conv2_stride = 1
?出现两种模式的原因是因为 ResNet 本身就有不同的实现,torchvision 的 resnet 和早期 release 的 resnet 版本不一样,使得目标检测框架在使用 Backbone 的时候有两种不同的配置,不过目前新网络都是采用 PyTorch 模式。
Neck模块即为FPN,其简要结构如下所示。
MMdetection中对应的配置为:
neck=dict(
#额外输出层的特征图来源
add_extra_convs='on_input',
#resnet模块输出的4个尺度特征图通道数
in_channels=[
64,
128,
256,
512,
],
#FPN输出特征图个数
num_outs=5,
#FPN输出的每个尺度特征图通道数
out_channels=256,
#从输入多尺度特征图的第几个开始计算
start_level=1,
type='FPN')
?
前面说过 ResNet 输出 4 个不同尺度特征图 (c2,c3,c4,c5),stride 分别是 (4,8,16,32),通道数为 (256,512,1024,2048),通过配置文件我们可以知道:
start_level=1
?说明虽然输入是 4 个特征图,但是实际上 FPN 中仅仅用了后面三个,可见图一,只用了C3,C4,C5。num_outs=5
?说明 FPN 模块虽然是接收 3 个特征图,但是输出 5 个特征图add_extra_convs='on_input'
?说明额外输出的 2 个特征图的来源是骨架网络输出,而不是 FPN 层本身输出又作为后面层的输入out_channels=256
?说明了 5 个输出特征图的通道数都是 256。总结:FPN 模块接收 c3, c4, c5 三个特征图,输出 P3-P7 五个特征图,通道数都是 256, stride 为 (8,16,32,64,128),其中大 stride (特征图小)用于检测大物体,小 stride (特征图大)用于检测小物体。
?
Head的结构图可参见图2。
RetinaNet的Head包括分类和检测两个分支,且每个分支都包括4个卷积层,不进行参数共享,分类Head输出通道是num_class*K,检测head输出通道是4*K,K是anchor个数,虽然每个Head的分类和回归分支权重不共享,但5个输出特征图的Head模块权重是共享的。
其完整配置如下:
bbox_head=dict(
anchor_generator=dict(
octave_base_scale=4,
ratios=[
0.5,
1.0,
2.0,
],
scales_per_octave=3,
strides=[
8,
16,
32,
64,
128,
],
type='AnchorGenerator'),
bbox_coder=dict(
target_means=[
0.0,
0.0,
0.0,
0.0,
],
target_stds=[
1.0,
1.0,
1.0,
1.0,
],
type='DeltaXYWHBBoxCoder'),
#中间特征图通道数
feat_channels=256,
#FPN层输出特征图通道数
in_channels=256,
loss_bbox=dict(loss_weight=1.0, type='L1Loss'),
loss_cls=dict(
alpha=0.25,
gamma=2.0,
loss_weight=1.0,
type='FocalLoss',
use_sigmoid=True),
#自己数据集类别个数
num_classes=4,
#每个分支堆叠4层卷积
stacked_convs=4,
type='RetinaHead'),
以下代码位置mmdet/models/dense_heads/retina_head.py的64行左右。以下代码结合图2 和debug调试即可读懂。
def _init_layers(self):
"""Initialize layers of the head."""
self.relu = nn.ReLU(inplace=True)
self.cls_convs = nn.ModuleList()
self.reg_convs = nn.ModuleList()
in_channels = self.in_channels
for i in range(self.stacked_convs):
self.cls_convs.append(
ConvModule(
in_channels,
self.feat_channels,
3,
stride=1,
padding=1,
conv_cfg=self.conv_cfg,
norm_cfg=self.norm_cfg))
self.reg_convs.append(
ConvModule(
in_channels,
self.feat_channels,
3,
stride=1,
padding=1,
conv_cfg=self.conv_cfg,
norm_cfg=self.norm_cfg))
in_channels = self.feat_channels
self.retina_cls = nn.Conv2d(
in_channels,
self.num_base_priors * self.cls_out_channels,
3,
padding=1)
reg_dim = self.bbox_coder.encode_size
self.retina_reg = nn.Conv2d(
in_channels, self.num_base_priors * reg_dim, 3, padding=1)
?在下图的位置处调试,
?如图20可以看出,此时代码仅仅运行到64行,所以head内部的结构并没有堆叠的4个卷积快。
由图21可以看出,随着代码的一行又一行的运行,head的内部结构正在逐步增添新的子模块。
?由图22可以得知,此时的head内部已经是分类和回归分支上分别堆叠了4个卷积块。
以下代码的位置:mmdet/models/dense_heads/retina_head.py的112行左右。
#x是p3-p7中的某个特征图
cls_feat = x
reg_feat = x
#4层不共享卷积参数
for cls_conv in self.cls_convs:
cls_feat = cls_conv(cls_feat)
for reg_conv in self.reg_convs:
reg_feat = reg_conv(reg_feat)
#输出特征图
cls_score = self.retina_cls(cls_feat)
bbox_pred = self.retina_reg(reg_feat)
return cls_score, bbox_pred
?debug后的得分矩阵如绿色箭头所示。
RetinaNet属于Anchor-based算法,在运行bbox属性分配前需要得到每个输出特征图位置的anchor列表,故在分析BBox Assigner前,需要先详细说明下anchor生成过程,其配置文件如下:
anchor_generator=dict(
#特征图anchor的base scale,值越大,所有的anchor的尺度会越大
octave_base_scale=4,
#每个特征图有3个高宽比例
ratios=[
0.5,
1.0,
2.0,
],
#每个特征图有3个尺度,octave_base_scaleXstridesX(2**0, 2**(1/3), 2**(2/3))
scales_per_octave=3,
#特征图对应的stride,必须与特征图stride一致,不可以随意更改。
strides=[
8,
16,
32,
64,
128,
],
type='AnchorGenerator'),
从上面配置可以看出:RetinaNet一共5个输出特征图,每个特征图上有3种尺度和3种宽高比,每个位置(每个像素)一共9个anchor,并且通过octave_base_scale参数来控制全局anchor的base scales,如果自定义数据集中普遍都是大物体或者小物体,则可以修改octave_base_scale参数。
以下代码所在位置:mmdet/models/task_modules/prior_generators/anchor_generator.py的715行左右。
w = base_size
h = base_size
#检查center变量是否为None,是则计算中心点,否则采用所传中心点
if center is None:
x_center = self.center_offset * (w - 1)
y_center = self.center_offset * (h - 1)
else:
x_center, y_center = center
#计算特征图相对于原图的高宽比例,可以理解为特征图的缩小倍数
h_ratios = torch.sqrt(ratios)
w_ratios = 1 / h_ratios
#base_size 乘上宽高比例乘上尺度,就可以得到 n 个 anchor 的原图尺度wh值
if self.scale_major:
ws = (w * w_ratios[:, None] * scales[None, :]).view(-1)
hs = (h * h_ratios[:, None] * scales[None, :]).view(-1)
else:
ws = (w * scales[:, None] * w_ratios[None, :]).view(-1)
hs = (h * scales[:, None] * h_ratios[None, :]).view(-1)
# use float anchor and the anchor's center is aligned with the
# pixel center
# 得到 x1y1(左上角坐标)x2y2(右下角坐标) 格式的 base_anchor 坐标值
base_anchors = [
x_center - 0.5 * (ws - 1), y_center - 0.5 * (hs - 1),
x_center + 0.5 * (ws - 1), y_center + 0.5 * (hs - 1)
]
base_anchors = torch.stack(base_anchors, dim=-1).round()
以下代码位置:mmdet/models/task_modules/prior_generators/anchor_generator.py的398行左右。
#从特征图的大小中获取高度和宽度。
feat_h, feat_w = featmap_size
#根据特征图的宽度和高度以及步长计算出在原图上的x轴和y轴方向上的偏移量
shift_x = torch.arange(0, feat_w, device=device) * stride[0]
shift_y = torch.arange(0, feat_h, device=device) * stride[1]
#使用_meshgrid方法生成一个网格,该网格的每个点的坐标都是其在x轴和y轴方向上的偏移量。
shift_xx, shift_yy = self._meshgrid(shift_x, shift_y)
#将x轴和y轴方向上的偏移量堆叠在一起,形成一个形状为(K, 1, 4)的张量,其中K是锚点的数量。
shifts = torch.stack([shift_xx, shift_yy, shift_xx, shift_yy], dim=-1)
#将偏移量张量的类型设置为与基础锚点相同。
shifts = shifts.type_as(base_anchors)
# first feat_w elements correspond to the first row of shifts
# add A anchors (1, A, 4) to K shifts (K, 1, 4) to get
# shifted anchors (K, A, 4), reshape to (K*A, 4)
#将基础锚点和偏移量相加,得到所有可能的锚点。
all_anchors = base_anchors[None, :, :] + shifts[:, None, :]
#将所有可能的锚点展平成一维张量
all_anchors = all_anchors.view(-1, 4)
# first A rows correspond to A anchors of (0, 0) in feature map,
# then (0, 1), (0, 2), ...
return all_anchors
?简单来说就是:假设一共m个输出特征图
计算得到输出特征图上面每个点对应原图anchor坐标后,就可以和gt信息计算每个anchor的正负样本属性,对应配置如下
assigner=dict(
#忽略bboxes的阈值,-1表示不忽略
ignore_iof_thr=-1,
#正样本阈值下限
min_pos_iou=0,
#负样本阈值
neg_iou_thr=0.4,
#正样本阈值
pos_iou_thr=0.5,
#最大iou原则分配器
type='MaxIoUAssigner'),
假设所有输出特征的所有anchor总数一共n个,对应某张图片中gt bbox个数为m,首先初始化长度为n的assigned_gt_inds,全部赋值为-1,表示当前全部设置为忽略样本。
以下代码位置:mmdet/models/task_modules/assigners/max_iou_assigner.py的257行左右。
# 1. assign -1 by default
assigned_gt_inds = overlaps.new_full((num_bboxes, ),
-1,
dtype=torch.long)
由下图可以看出,该张图片的所有特征图的anchor一共有193374个,gt一共有两个。
由下图可以看出已经将该张图片的193374个anchor的对应索引全部赋值为-1。
将每个anchor与所有gt bbox计算iou,找到最大iou,如果该iou小于neg_iou_thr或者在背景样本阈值范围内,则该anchor对应索引位置的assigned_gt_inds设置为0,表示负样本(背景样本)
以下代码位置:mmdet/models/task_modules/assigners/max_iou_assigner.py的278行左右。
# for each anchor, which gt best overlaps with it
# for each anchor, the max iou of all gts
max_overlaps, argmax_overlaps = overlaps.max(dim=0)
# for each gt, which anchor best overlaps with it
# for each gt, the max iou of all proposals
gt_max_overlaps, gt_argmax_overlaps = overlaps.max(dim=1)
# 2. assign negative: below
# the negative inds are set to be 0
if isinstance(self.neg_iou_thr, float):
assigned_gt_inds[(max_overlaps >= 0)
& (max_overlaps < self.neg_iou_thr)] = 0
elif isinstance(self.neg_iou_thr, tuple):
assert len(self.neg_iou_thr) == 2
#可以设置一个范围
assigned_gt_inds[(max_overlaps >= self.neg_iou_thr[0])
& (max_overlaps < self.neg_iou_thr[1])] = 0
将每个anchor和所有的gt bbox计算iou,找出最大iou,如果当前iou大于等于pos_iou_thr,则设置该anchor对应所有的assigner_gt_inds设置为当前匹配gt bbox的编号+1(后面会减掉1),表示该anchor负责预测该gt bbox,且是高质量anchor,之所以要加一,是为了区分背景样本(背景样本的assigned_gt_inds值为0),这里建议看b站霹雳巴拉博主讲的faster rcnn的那一块。
以下代码位置:mmdet/models/task_modules/assigners/max_iou_assigner.py的294行左右。
pos_inds = max_overlaps >= self.pos_iou_thr
assigned_gt_inds[pos_inds] = argmax_overlaps[pos_inds] + 1
在第三步计算高质量正样本中可能会出现某些 gt bbox 没有分配给任何一个 anchor (由于 iou 低于?pos_iou_thr
),导致该 gt bbox 不被认为是前景物体,此时可以通过?self.match_low_quality=True
?配置进行补充正样本。
对于每个 gt bbox 需要找出和其最大 iou 的 anchor 索引,如果其 iou 大于?min_pos_iou
,则将该 anchor 对应索引的?assigned_gt_inds
?设置为正样本,表示该 anchor 负责预测对应的 gt bbox。通过本步骤,可以最大程度保证每个 gt bbox 都有相应的 anchor 负责预测,但是如果其最大 iou 值还是小于?min_pos_iou
,则依然不被认为是前景物体。
代码位置:mmdet/models/task_modules/assigners/max_iou_assigner.py的297行。
if self.match_low_quality:
# Low-quality matching will overwrite the assigned_gt_inds assigned
# in Step 3. Thus, the assigned gt might not be the best one for
# prediction.
# For example, if bbox A has 0.9 and 0.8 iou with GT bbox 1 & 2,
# bbox 1 will be assigned as the best target for bbox A in step 3.
# However, if GT bbox 2's gt_argmax_overlaps = A, bbox A's
# assigned_gt_inds will be overwritten to be bbox 2.
# This might be the reason that it is not used in ROI Heads.
for i in range(num_gts):
if gt_max_overlaps[i] >= self.min_pos_iou:
if self.gt_max_assign_all:
max_iou_inds = overlaps[i, :] == gt_max_overlaps[i]
assigned_gt_inds[max_iou_inds] = i + 1
else:
assigned_gt_inds[gt_argmax_overlaps[i]] = i + 1
?
此时可以可以得到如下总结:
在anchor-based算法中,为了利用anchor信息进行更快更好的收敛,一般会对head输出的 bbox分支4个值进行编解码操作,作用有两个:
1.更好的平衡分类和回归loss,以及平衡bbox四个预测值的loss
2.训练过程中引入anchor信息,加快收敛。
RetinaNet采用的编解码函数是主流的DeltaXYWHBBoxCoder,其配置如下:
bbox_coder=dict(
target_means=[
0.0,
0.0,
0.0,
0.0,
],
target_stds=[
1.0,
1.0,
1.0,
1.0,
],
type='DeltaXYWHBBoxCoder'),
编解码具体步骤和公式参考官网轻松掌握 MMDetection 中常用算法(一):RetinaNet 及配置详解 - 知乎 (zhihu.com)
这段设计的代码位置:mmdet/models/task_modules/coders/delta_xywh_bbox_coder.py
依然是参考原文。同时推荐去b站噼里啪啦博主的yolov3第三小节的讲解处观看理解。
对应的配置如下所示。更多调试过程和faster-rcnn差不多。mmdetection3.2.0之faster-rcnn源码解读-CSDN博客
test_cfg=dict(
#最终输出的每张图片最多bbox个数
max_per_img=100,
#过滤掉最小的bbpx尺寸
min_bbox_size=0,
#nms方法和nms阈值
nms=dict(iou_threshold=0.5, type='nms'),
#nms前每个输出层最多保留1K个proposal
nms_pre=1000,
#分值阈值
score_thr=0.05),