# 骨架的预训练权重路径
pretrained='torchvision://resnet50',
backbone=dict(
type='ResNet', # 骨架类名,后面的参数都是该类的初始化参数
depth=50,
num_stages=4,
out_indices=(0, 1, 2, 3),
frozen_stages=1,
norm_cfg=dict(type='BN', requires_grad=True),
norm_eval=True,
style='pytorch'),
neck=dict(
type='FPN',
in_channels=[256, 512, 1024, 2048], # 骨架多尺度特征图输出通道
out_channels=256, # 增强后通道输出
num_outs=5), # 输出num_outs个多尺度特征图
#=================== mmdet/apis/train.py ==================
# 1.初始化 data_loaders ,内部会初始化 GroupSampler
data_loader = DataLoader(dataset,...)
# 2.基于是否使用分布式训练,初始化对应的 DataParallel
if distributed:
model = MMDistributedDataParallel(...)
else:
model = MMDataParallel(...)
# 3.初始化 runner
runner = EpochBasedRunner(...)
# 4.注册必备 hook
runner.register_training_hooks(cfg.lr_config, optimizer_config,
cfg.checkpoint_config, cfg.log_config,
cfg.get('momentum_config', None))
# 5.如果需要 val,则还需要注册 EvalHook
runner.register_hook(eval_hook(val_dataloader, **eval_cfg))
# 6.注册用户自定义 hook
runner.register_hook(hook, priority=priority)
# 7.权重恢复和加载
if cfg.resume_from:
runner.resume(cfg.resume_from)
elif cfg.load_from:
runner.load_checkpoint(cfg.load_from)
# 8.运行,开始训练
runner.run(data_loaders, cfg.workflow, cfg.total_epochs)
def train(self, data_loader, **kwargs):
self.model.train()
self.mode = 'train'
self.data_loader = data_loader
self.call_hook('before_train_epoch')
for i, data_batch in enumerate(self.data_loader):
self.call_hook('before_train_iter')
self.run_iter(data_batch, train_mode=True)
self.call_hook('after_train_iter')
self.call_hook('after_train_epoch')
def val(self, data_loader, **kwargs):
self.model.eval()
self.mode = 'val'
self.data_loader = data_loader
self.call_hook('before_val_epoch')
for i, data_batch in enumerate(self.data_loader):
self.call_hook('before_val_iter')
with torch.no_grad():
self.run_iter(data_batch, train_mode=False)
self.call_hook('after_val_iter')
self.call_hook('after_val_epoch') # will call all the registered hooks
def run_iter(self, data_batch, train_mode, **kwargs):
if train_mode:
# 对于每次迭代,最终是调用如下函数
outputs = self.model.train_step(data_batch,...)
else:
# 对于每次迭代,最终是调用如下函数
outputs = self.model.val_step(data_batch,...)
if 'log_vars' in outputs:
self.log_buffer.update(outputs['log_vars'],...)
self.outputs = outputs
@HOOKS.register_module()
class OptimizerHook(Hook):
def __init__(self, grad_clip=None):
self.grad_clip = grad_clip
def after_train_iter(self, runner):
runner.optimizer.zero_grad()
runner.outputs['loss'].backward()
if self.grad_clip is not None:
grad_norm = self.clip_grads(runner.model.parameters())
runner.optimizer.step()
#=================== mmdet/models/detectors/base.py/BaseDetector ==================
def train_step(self, data, optimizer):
# 调用本类自身的 forward 方法
losses = self(**data)
# 解析 loss
loss, log_vars = self._parse_losses(losses)
# 返回字典对象
outputs = dict(
loss=loss, log_vars=log_vars, num_samples=len(data['img_metas']))
return outputs
def forward(self, img, img_metas, return_loss=True, **kwargs):
if return_loss:
# 训练模式
return self.forward_train(img, img_metas, **kwargs) # 在各种算法子类中实现
else:
# 测试模式
return self.forward_test(img, img_metas, **kwargs) # 在各种算法子类中实现
#============= mmdet/models/detectors/two_stage.py/TwoStageDetector ============
def forward_train(...):
# 先进行 backbone+neck 的特征提取
x = self.extract_feat(img)
losses = dict()
# RPN forward and loss
if self.with_rpn:
# 训练 RPN
proposal_cfg = self.train_cfg.get('rpn_proposal',
self.test_cfg.rpn)
# 主要是调用 rpn_head 内部的 forward_train 方法
rpn_losses, proposal_list = self.rpn_head.forward_train(x,...)
losses.update(rpn_losses)
else:
proposal_list = proposals
# 第二阶段,主要是调用 roi_head 内部的 forward_train 方法
roi_losses = self.roi_head.forward_train(x, ...)
losses.update(roi_losses)
return losses
#============= mmdet/models/detectors/single_stage.py/SingleStageDetector ============
def forward_train(...):
super(SingleStageDetector, self).forward_train(img, img_metas)
# 先进行 backbone+neck 的特征提取
x = self.extract_feat(img)
# 主要是调用 bbox_head 内部的 forward_train 方法
losses = self.bbox_head.forward_train(x, ...)
return losses
- 目前 MMDetection 中 Head 模块主要是按照 stage 来划分,主要包括两个 package: dense_heads 和 roi_heads , 分别对应 two-stage 算法中的第一和第二个 stage 模块,如果是 one-stage 算法则仅仅有 dense_heads 而已。
- 上图中1为正,0为负
- 蓝色框: 真实值( ground-truth)
- 红色框:anchor box (锚框)
- 红色点:anchor point (锚点)
- RetinaNet:使用IoU同时在spatial and scale dimension选择正样本(1)
- 选择与groundTruth的IoU>=0.5(positive threshold)的初设anchor为正样本1,IOU<(negative threshold)的为负样本0,其他忽略,其中的两个threshold都是我们人为拟定的,对训练样本中所有的检测目标都适用,如图(a)所示。
- IoU来定义正负样本的方式会导致小尺寸物体的正样本数量相对大尺寸物体正样本数量偏少,进而对小样本检测性能不高。
- 模型对这种人为拟定的超参(positive/negative threshold)敏感。
- FCOS:首先在spatial dimension发现候选正样本(?),然后在scale dimension选择最后的正样本(1)
- FCOS没有默认anchor box,而是默认anchor point,如图(b)所示。
- FCOS有两步骤,首先是Spatial Constraint,如果默认point在目标内即预设为?,在目标外预设为0;然后是Scale Constarint(这部分细节这就不提了,可在FCOS论文中找到),大意是如果在这层feature map里需要regress的值 minumun value<(regress value)
- RetinaNet:分类采用Intersection over Union(IoU),回归采用Bounding Box (BOX)
- FCOS:分类采用Spatial and scale Constraint,回归采用Point方式收敛
- 比较目的:由于分类和回归方式都不同,上表想证明到底是分类方式还是回归方式导致检测模型的性能不同。
- 分类方法相同:Table中显示当分类方式一样(横向对比),比如,IoU时,使用Box回归(mAP:37.0)与使用Point回归(mAP:36.9)时性能差别不大;同时,分类使用Spatial and Scale Constraint时,使用Box回归(mAP:37.8)与使用Point回归(mAP:37.8)时性能也差别不大。
- 回归方法相同:Table中显示当回归方式一样(纵向对比),比如,Box时,使用IoU分类(mAP:37.0)比使用Spatial and Scale Constraint分类(mAP:37.8)时性能低;同时,回归使用Point时,使用IoU分类(mAP:36.9)比使用Spatial and Scale Constraint分类(mAP:37.8)性能低。
- 结论: 由于模型的性能差异与Classification的方式有关(相关性大),与选择使用box或者Point来进行回归无关(相关性不大)。
def forward_train(self,
x,
img_metas,
gt_bboxes,
gt_labels=None,
gt_bboxes_ignore=None,
proposal_cfg=None,
**kwargs):
# 调用各个子类实现的 forward 方法
outs = self(x)
if gt_labels is None:
loss_inputs = outs + (gt_bboxes, img_metas)
else:
loss_inputs = outs + (gt_bboxes, gt_labels, img_metas)
# 调用各个子类实现的 loss 计算方法
losses = self.loss(*loss_inputs, gt_bboxes_ignore=gt_bboxes_ignore)
if proposal_cfg is None:
return losses
else:
# two-stage 算法还需要返回 proposal
proposal_list = self.get_bboxes(*outs, img_metas, cfg=proposal_cfg)
return losses, proposal_list
# BBoxTestMixin 是多尺度测试时候调用
class AnchorHead(BaseDenseHead, BBoxTestMixin):
# feats 是 backbone+neck 输出的多个尺度图
def forward(self, feats):
# 对每张特征图单独计算预测输出
return multi_apply(self.forward_single, feats)
# head 模块分类回归分支输出
def forward_single(self, x):
cls_score = self.conv_cls(x)
bbox_pred = self.conv_reg(x)
return cls_score, bbox_pred
def forward_train(self,
x,
img_metas,
proposal_list,
gt_bboxes,
gt_labels,
...):
if self.with_bbox or self.with_mask:
num_imgs = len(img_metas)
sampling_results = []
for i in range(num_imgs):
# 对每张图片进行 bbox 正负样本属性分配
assign_result = self.bbox_assigner.assign(
proposal_list[i], ...)
# 然后进行正负样本采样
sampling_result = self.bbox_sampler.sample(
assign_result,
proposal_list[i],
...)
sampling_results.append(sampling_result)
losses = dict()
if self.with_bbox:
# bbox 分支 forward,返回 loss
bbox_results = self._bbox_forward_train(...)
losses.update(bbox_results['loss_bbox'])
if self.with_mask:
# mask 分支 forward,返回 loss
return losses
def _bbox_forward_train(self, x, sampling_results, gt_bboxes, gt_labels,
img_metas):
rois = bbox2roi([res.bboxes for res in sampling_results])
# forward
bbox_results = self._bbox_forward(x, rois)
# 计算 target
bbox_targets = self.bbox_head.get_targets(...)
# 计算 loss
loss_bbox = self.bbox_head.loss(...)
return ...
def _bbox_forward(self, x, rois):
# roi 提取
bbox_feats = self.bbox_roi_extractor(
x[:self.bbox_roi_extractor.num_inputs], rois)
# bbox head 网络前向
cls_score, bbox_pred = self.bbox_head(bbox_feats)
return ...
从上述逻辑可以看出,StandardRoIHead 中 forward_train 函数仅仅是对内部的 bbox_head 相关函数进行调用,例如 get_targets 和 loss,本身 StandardRoIHead 类不做具体算法逻辑计算。
可以参考 Faster R-CNN 配置文件理解 StandardRoIHead 和 bbox_head 的关系:
roi_head=dict(
type='StandardRoIHead',
bbox_roi_extractor=dict(
type='SingleRoIExtractor',
roi_layer=dict(type='RoIAlign', output_size=7, sampling_ratio=0),
out_channels=256,
featmap_strides=[4, 8, 16, 32]),
bbox_head=dict(
type='Shared2FCBBoxHead',
in_channels=256,
fc_out_channels=1024,
roi_feat_size=7,
num_classes=80,
bbox_coder=dict(
type='DeltaXYWHBBoxCoder',
target_means=[0., 0., 0., 0.],
target_stds=[0.1, 0.1, 0.2, 0.2]),
reg_class_agnostic=False,
loss_cls=dict(
type='CrossEntropyLoss', use_sigmoid=False, loss_weight=1.0),
loss_bbox=dict(type='L1Loss', loss_weight=1.0))))
def simple_test_bboxes(self,
x,
...):
rois = bbox2roi(proposals)
# roi 提取+ forward,输出预测结果
bbox_results = self._bbox_forward(x, rois)
cls_score = bbox_results['cls_score']
bbox_pred = bbox_results['bbox_pred']
det_bboxes = []
det_labels = []
for i in range(len(proposals)):
# 对预测结果进行解码输出 bbox 和对应 label
det_bbox, det_label = self.bbox_head.get_bboxes(...)
det_bboxes.append(det_bbox)
det_labels.append(det_label)
return det_bboxes, det_labels
# 使用 pytorch 提供的在 imagenet 上面训练过的权重作为预训练权重
pretrained='torchvision://resnet50',
backbone=dict(
# 骨架网络类名
type='ResNet',
# 表示使用 ResNet50
depth=50,
# ResNet 系列包括 stem+ 4个 stage 输出
num_stages=4,
# 表示本模块输出的特征图索引,(0, 1, 2, 3),表示4个 stage 输出都需要,
# 其 stride 为 (4,8,16,32),channel 为 (256, 512, 1024, 2048)
out_indices=(0, 1, 2, 3),
# 表示固定 stem 加上第一个 stage 的权重,不进行训练
frozen_stages=1,
# 所有的 BN 层的可学习参数都不需要梯度,也就不会进行参数更新
norm_cfg=dict(type='BN', requires_grad=True),
# backbone 所有的 BN 层的均值和方差都直接采用全局预训练值,不进行更新
norm_eval=True,
# 默认采用 pytorch 模式
style='pytorch'),
neck=dict(
type='FPN',
# ResNet 模块输出的4个尺度特征图通道数
in_channels=[256, 512, 1024, 2048],
# FPN 输出的每个尺度输出特征图通道
out_channels=256,
# FPN 输出特征图个数
num_outs=5),
rpn_head=dict(
type='RPNHead',
# FPN 层输出特征图通道数
in_channels=256,
# 中间特征图通道数
feat_channels=256,
# 后面分析
anchor_generator=dict(
type='AnchorGenerator',
scales=[8],
ratios=[0.5, 1.0, 2.0],
strides=[4, 8, 16, 32, 64]),
# 后面分析
bbox_coder=dict(
type='DeltaXYWHBBoxCoder',
target_means=[.0, .0, .0, .0],
target_stds=[1.0, 1.0, 1.0, 1.0]),
# 后面分析
loss_cls=dict(
type='CrossEntropyLoss', use_sigmoid=True, loss_weight=1.0),
loss_bbox=dict(type='L1Loss', loss_weight=1.0)),
def _init_layers(self):
"""Initialize layers of the head."""
# 特征通道变换
self.rpn_conv = nn.Conv2d(
self.in_channels, self.feat_channels, 3, padding=1)
# 分类分支,类别固定是2,表示前/背景分类
# 并且由于 cls loss 是 bce,故实际上 self.cls_out_channels=1
self.rpn_cls = nn.Conv2d(self.feat_channels,
self.num_anchors * self.cls_out_channels, 1)
# 回归分支,固定输出4个数值,表示基于 anchor 的变换值
self.rpn_reg = nn.Conv2d(self.feat_channels, self.num_anchors * 4, 1)
anchor_generator=dict(
type='AnchorGenerator',
# 相当于 octave_base_scale,表示每个特征图的 base scales
scales=[8],
# 每个特征图有 3 个高宽比例
ratios=[0.5, 1.0, 2.0],
# 特征图对应的 stride,必须和特征图 stride 一致,不可以随意更改
strides=[4, 8, 16, 32, 64]),
assigner=dict(
# 最大 IoU 原则分配器
type='MaxIoUAssigner',
# 正样本阈值
pos_iou_thr=0.7,
# 负样本阈值
neg_iou_thr=0.3,
# 正样本阈值下限
min_pos_iou=0.3,
# 忽略 bboxes 的阈值,-1 表示不忽略
ignore_iof_thr=-1),
sampler=dict(
# 随机采样
type='RandomSampler',
# 采样后每张图片的训练样本总数,不包括忽略样本
num=256,
# 正样本比例
pos_fraction=0.5,
# 正负样本比例,用于确定负样本采样个数上界
neg_pos_ub=-1,
# 是否加入 gt 作为 proposals 以增加高质量正样本数
add_gt_as_proposals=False)
bbox_coder=dict(
type='DeltaXYWHBBoxCoder',
target_means=[.0, .0, .0, .0],
target_stds=[1.0, 1.0, 1.0, 1.0]),
target_means 和 target_stds 相当于对 bbox 回归的 4 个参数 ( t x , t y , t w , t h ) (t_x, t_y, t_w, t_h) (tx,ty,tw,th) 进行变换。
在不考虑 target_means 和 target_stds 情况下,其编码公式如下:
t x ∗ = ( x ∗ − x a ) / w a t y ∗ = ( y ∗ − y a ) / h a t w ∗ = l o g ( w ∗ / w a ) t h ∗ = l o g ( h ∗ / h a ) t_x^* = (x^* - x_a)/w_a \\ t_y^* = (y^* - y_a)/h_a \\ t_w^* = log(w^*/w_a) \\ t_h^* = log(h^*/h_a) tx∗=(x∗−xa)/waty∗=(y∗−ya)/hatw∗=log(w∗/wa)th∗=log(h∗/ha)
考虑编码过程 target_means 和 target_stds 情况下,核心代码如下:
dx = (gx - px) / pw
dy = (gy - py) / ph
dw = torch.log(gw / pw)
dh = torch.log(gh / ph)
deltas = torch.stack([dx, dy, dw, dh], dim=-1)
# 最后减掉均值,除以标准差
means = deltas.new_tensor(means).unsqueeze(0)
stds = deltas.new_tensor(stds).unsqueeze(0)
deltas = deltas.sub_(means).div_(stds)
# 先乘上 stds,加上 means
means = deltas.new_tensor(means).view(1, -1).repeat(1, deltas.size(1) // 4)
stds = deltas.new_tensor(stds).view(1, -1).repeat(1, deltas.size(1) // 4)
denorm_deltas = deltas * stds + means
dx = denorm_deltas[:, 0::4] # 0, 4, 8 ...
dy = denorm_deltas[:, 1::4] # 1, 5, 9 ...
dw = denorm_deltas[:, 2::4] # 2, 6, 10 ...
dh = denorm_deltas[:, 3::4] # 3, 7, 11 ...
# wh 解码
gw = pw * dw.exp()
gh = ph * dh.exp()
# 中心点 xy 解码
gx = px + pw * dx
gy = py + ph * dy
# 得到 x1y1x2y2 的 gt bbox 预测坐标
x1 = gx - gw * 0.5
y1 = gy - gh * 0.5
x2 = gx + gw * 0.5
y2 = gy + gh * 0.5
loss_cls=dict(
type='CrossEntropyLoss', use_sigmoid=True, loss_weight=1.0),
loss_bbox=dict(type='L1Loss', loss_weight=1.0)),
rpn=dict(
# 是否跨层进行 NMS 操作
nms_across_levels=False,
# nms 前每个输出层最多保留 1000 个预测框
nms_pre=1000,
# nms 后每张图片最多保留 1000 个预测框
nms_post=1000,
# 每张图片最终输出检测结果最多保留 1000 个,RPN 层没有使用这个参数
max_num=1000,
# nms 阈值
nms_thr=0.7,
# 过滤掉的最小 bbox 尺寸
min_bbox_size=0),
if cfg.nms_pre > 0 and scores.shape[0] > cfg.nms_pre:
# sort is faster than topk
# _, topk_inds = scores.topk(cfg.nms_pre)
ranked_scores, rank_inds = scores.sort(descending=True)
topk_inds = rank_inds[:cfg.nms_pre]
scores = ranked_scores[:cfg.nms_pre]
rpn_bbox_pred = rpn_bbox_pred[topk_inds, :]
anchors = anchors[topk_inds, :]
scores = torch.cat(mlvl_scores)
anchors = torch.cat(mlvl_valid_anchors)
rpn_bbox_pred = torch.cat(mlvl_bbox_preds)
proposals = self.bbox_coder.decode(
anchors, rpn_bbox_pred, max_shape=img_shape)
nms_cfg = dict(type='nms', iou_threshold=cfg.nms_thr)
dets, keep = batched_nms(proposals, scores, ids, nms_cfg)
return dets[:cfg.nms_post]
roi_head=dict(
# 一次 refine head,另外对应的是级联结构
type='StandardRoIHead',
bbox_roi_extractor=dict(
type='SingleRoIExtractor',
roi_layer=dict(type='RoIAlign', output_size=7, sampling_ratio=0),
out_channels=256,
featmap_strides=[4, 8, 16, 32]),
bbox_head=dict(
# 2 个共享 FC 模块
type='Shared2FCBBoxHead',
# 输入通道数,相等于 FPN 输出通道
in_channels=256,
# 中间 FC 层节点个数
fc_out_channels=1024,
# RoIAlign 或 RoIPool 输出的特征图大小
roi_feat_size=7,
# 类别个数
num_classes=80,
# bbox 编解码策略,除了参数外和 RPN 相同,
bbox_coder=dict(
type='DeltaXYWHBBoxCoder',
target_means=[0., 0., 0., 0.],
target_stds=[0.1, 0.1, 0.2, 0.2]),
# 影响 bbox 分支的通道数,True 表示 4 通道输出,False 表示 4×num_classes 通道输出
reg_class_agnostic=False,
# CE Loss
loss_cls=dict(
type='CrossEntropyLoss', use_sigmoid=False, loss_weight=1.0),
# L1 Loss
loss_bbox=dict(type='L1Loss', loss_weight=1.0))),
第2步的映射规则是在 FPN 论文中提出。假设某个 proposal 是由第 4 个 特征图层检测出来的,为啥该 proposal 不是直接去对应特征图层切割就行,还需要重新映射?原因是这些 proposal 是 RPN 测试阶段检测出来的,大部分 proposal 可能符合前面设定,但是也有很多不符合的,也就是说测试阶段上述一致性不一定满足,需要重新映射,公式如下:
k = ⌊ k 0 + l o g 2 ( w h / 224 ) ⌋ k=\lfloor k_0 + log_2(\sqrt{wh}/224)\rfloor k=⌊k0+log2(wh/224)⌋
def map_roi_levels(self, rois, num_levels):
"""Map rois to corresponding feature levels by scales.
- scale < finest_scale * 2: level 0
- finest_scale * 2 <= scale < finest_scale * 4: level 1
- finest_scale * 4 <= scale < finest_scale * 8: level 2
- scale >= finest_scale * 8: level 3
"""
scale = torch.sqrt(
(rois[:, 3] - rois[:, 1]) * (rois[:, 4] - rois[:, 2]))
target_lvls = torch.floor(torch.log2(scale / self.finest_scale + 1e-6))
target_lvls = target_lvls.clamp(min=0, max=num_levels - 1).long()
return target_lvls
if self.num_shared_fcs > 0:
x = x.flatten(1)
# 两层共享 FC
for fc in self.shared_fcs:
x = self.relu(fc(x))
x_cls = x
x_reg = x
# 不共享的分类和回归分支输出
cls_score = self.fc_cls(x_cls) if self.with_cls else None
bbox_pred = self.fc_reg(x_reg) if self.with_reg else None
return cls_score, bbox_pred
rcnn=dict(
assigner=dict(
# 和 RPN 一样,正负样本定义参数不同
type='MaxIoUAssigner',
pos_iou_thr=0.5,
neg_iou_thr=0.5,
min_pos_iou=0.5,
match_low_quality=False,
ignore_iof_thr=-1),
sampler=dict(
# 和 RPN 一样,随机采样参数不同
type='RandomSampler',
num=512,
pos_fraction=0.25,
neg_pos_ub=-1,
# True,RPN 中为 False
add_gt_as_proposals=True)
if self.with_bbox or self.with_mask:
num_imgs = len(img_metas)
sampling_results = []
# 遍历每张图片,单独计算 BBox Assigner 和 BBox Sampler
for i in range(num_imgs):
# proposal_list 是 RPN test 输出的候选框
assign_result = self.bbox_assigner.assign(
proposal_list[i], gt_bboxes[i], gt_bboxes_ignore[i],
gt_labels[i])
# 随机采样
sampling_result = self.bbox_sampler.sample(
assign_result,
proposal_list[i],
gt_bboxes[i],
gt_labels[i],
feats=[lvl_feat[i][None] for lvl_feat in x])
sampling_results.append(sampling_result)
# 特征重映射+ RoI 区域特征提取+ 网络 forward + Loss 计算
losses = dict()
# bbox head forward and loss
if self.with_bbox:
bbox_results = self._bbox_forward_train(x, sampling_results,
gt_bboxes, gt_labels,
img_metas)
losses.update(bbox_results['loss_bbox'])
# mask head forward and loss
if self.with_mask:
mask_results = self._mask_forward_train(x, sampling_results,
bbox_results['bbox_feats'],
gt_masks, img_metas)
losses.update(mask_results['loss_mask'])
return losses
def _bbox_forward_train(self, x, sampling_results, gt_bboxes, gt_labels,
img_metas):
rois = bbox2roi([res.bboxes for res in sampling_results])
# 特征重映射+ RoI 特征提取+ 网络 forward
bbox_results = self._bbox_forward(x, rois)
# 计算每个样本对应的 target, bbox encoder 在内部进行
bbox_targets = self.bbox_head.get_targets(sampling_results, gt_bboxes,
gt_labels, self.train_cfg)
# 计算 loss
loss_bbox = self.bbox_head.loss(bbox_results['cls_score'],
bbox_results['bbox_pred'], rois,
*bbox_targets)
bbox_results.update(loss_bbox=loss_bbox)
return bbox_results
def _bbox_forward(self, x, rois):
# 特征重映射+ RoI 区域特征提取,仅仅考虑前 num_inputs 个特征图
bbox_feats = self.bbox_roi_extractor(
x[:self.bbox_roi_extractor.num_inputs], rois)
# 共享模块
if self.with_shared_head:
bbox_feats = self.shared_head(bbox_feats)
# 独立分类和回归 head
cls_score, bbox_pred = self.bbox_head(bbox_feats)
bbox_results = dict(
cls_score=cls_score, bbox_pred=bbox_pred, bbox_feats=bbox_feats)
return bbox_results
rcnn=dict(
score_thr=0.05,
nms=dict(type='nms', iou_threshold=0.5),
max_per_img=100)
NMS:Non-Maximum Suppression (非极大值抑制)
思想:搜素局部最大值,抑制非极大值。
用途:消除多余的框,找到最佳的物体检测位置。
左图是人脸检测的候选框结果,每个边界框有一个置信度得分(confidence score),如果不使用非极大值抑制,就会有多个候选框出现。右图是使用非极大值抑制之后的结果,符合我们人脸检测的预期结果。
NMS输入
NMS工作流程
backbone=dict(
type='ResNet',
depth=50,
num_stages=4,
out_indices=(0, 1, 2, 3),
frozen_stages=1,
norm_cfg=dict(type='BN', requires_grad=False),
norm_eval=True,
style='caffe'),
# pytorch
mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True
# caffe
mean=[103.530, 116.280, 123.675], std=[1.0, 1.0, 1.0], to_rgb=False
neck=dict(
type='FPN',
in_channels=[256, 512, 1024, 2048],
out_channels=256,
start_level=1,
add_extra_convs=True,
extra_convs_on_inputs=False, # use P5
num_outs=5,
relu_before_extra_convs=True),
# 4 层卷积 forward
cls_score, bbox_pred, cls_feat, reg_feat = super().forward_single(x)
if self.centerness_on_reg: # trick
# 将 centerness 分支放到 bbox 分支上
centerness = self.conv_centerness(reg_feat)
else:
centerness = self.conv_centerness(cls_feat)
# scale the bbox_pred of different level
# float to avoid overflow when enabling FP16
# 可学习 scale
bbox_pred = scale(bbox_pred).float()
if self.norm_on_bbox: # trick
# 因为输出值一定是正数,故直接用 relu 进行截取
bbox_pred = F.relu(bbox_pred)
if not self.training:
bbox_pred *= stride # bbox 输出解码过程
else:
# 早期做法
bbox_pred = bbox_pred.exp()
return cls_score, bbox_pred, centerness
num_pos = torch.tensor(
len(pos_inds), dtype=torch.float, device=bbox_preds[0].device)
num_pos = max(reduce_mean(num_pos), 1.0)
loss_cls = self.loss_cls(
flatten_cls_scores, flatten_labels, avg_factor=num_pos)
centerness_denorm = max(
reduce_mean(pos_centerness_targets.sum().detach()), 1e-6)
loss_bbox = self.loss_bbox(
pos_decoded_bbox_preds,
pos_decoded_target_preds,
weight=pos_centerness_targets,
avg_factor=centerness_denorm)
def centerness_target(self, pos_bbox_targets):
# only calculate pos centerness targets, otherwise there may be nan
left_right = pos_bbox_targets[:, [0, 2]]
top_bottom = pos_bbox_targets[:, [1, 3]]
centerness_targets = (
left_right.min(dim=-1)[0] / left_right.max(dim=-1)[0]) * (
top_bottom.min(dim=-1)[0] / top_bottom.max(dim=-1)[0])
return torch.sqrt(centerness_targets)
# loss 计算
loss_centerness = self.loss_centerness(
pos_centerness, pos_centerness_targets, avg_factor=num_pos)
因为 FCOS 算法性能优于 RetinaNet,并且其额外引入了许多 trick,为了公平对比,将 FCOS 所提 trick 也迁移到 RetinaNet 中,并且设置 RetinaNet 的 anchor 个数为 1,结果如上所示。可以看出即使所有 trick 都加上 RetinaNet 依然差了 0.8 mAP,说明上面这些 trick 不是根本原因。
排除上述 trick 因素后,两个算法的区别是:
从上述对比可以看出:anchor-based 和 anchor-free 的性能差异根本在于正负样本定义(即Bbox Assigner)。具体区别可从下图看出:
对于 PL1 和 PL2 两个输出层,RetinaNet 直接采用全局统一的 IoU 阈值进行分配,可以确定上图蓝色 1 位置是正样本,而 FCOS 会分成两个步骤进行分配,第一步通过中心采样阈值计算后续正样本,第二步通过回归 scale 范围计算最终正样本,可以确定上图有两个蓝色 1 位置都是正样本,也就是说相比 RetinaNet 算法会产生更多的正样本。
基于上述结论可以知道 FCOS 这种分配机制还是不错的,但是其依然存在超参难以设置问题,故 ATSS 算法的核心就在于提出一个自适应的 Bbox Assigner 算法。为了条理清晰,我们依然按照前面 Backbone、Neck 和 Head 等顺序进行解读。
# backbone 部分和 RetinaNet 完全相同
backbone=dict(
type='ResNet',
depth=50,
num_stages=4,
out_indices=(0, 1, 2, 3),
frozen_stages=1,
norm_cfg=dict(type='BN', requires_grad=True),
norm_eval=True,
style='pytorch'),
# neck 部分,add_extra_convs 参数不同,其余完全相同
neck=dict(
type='FPN',
in_channels=[256, 512, 1024, 2048],
out_channels=256,
start_level=1,
add_extra_convs='on_output',
num_outs=5),
# head 部分和 FCOS 相同,也是加入了额外的 centerness 分支
bbox_head=dict(
type='ATSSHead',
num_classes=80,
in_channels=256,
stacked_convs=4,
feat_channels=256),
这部分是 ATSS 核心:其基本思路是通过某种手段给输出特征图上每个点计算其适应度值,值越大表示越适合作为正样本;然后计算所有适应度值的统计值得到全局阈值(这就是要找的自适应阈值);最后高于阈值才是正样本,其余都是负样本,从而实现自适应分配的目的。
其简要流程为:(根据L2距离找topk个anchor点,根据IoU的均值和标准差计算自适应阈值)
某种手段是指计算每个 gt bbox 和所有 anchor 之间的 IoU topk 操作;每个 gt bbox 和 anchor 计算得到的 IoU 值即为适应度值,值越大越可能是正样本;计算 gt bbox 和候选 anchor 的 IoU 均值和标准差即为对适应度值计算统计值得到全局阈值;然后采用每个 gt bbox 各自的全局预测进行切分即可得到正样本。
举个例子简要概述:假设当前图片中,一共 2 个 gt bbox,一共 5 个输出层,每层都是 100 个 anchor
其能够实现自适应的原因是:
需要特别强调:ATSS 自定义分配策略可以用于 anchor-free,也可以用于 anchor-based,当用于 anchor-free 后,其 anchor 设置仅仅用于计算特征图上面点的正负样本属性,不会参与后续任何计算,而且由于其自适应策略,anchor 的设置不当影响没有 anchor-based 类算法大。其 anchor 设置为:
anchor_generator=dict(
type='AnchorGenerator',
ratios=[1.0],
octave_base_scale=8,
scales_per_octave=1,
strides=[8, 16, 32, 64, 128]),
train_cfg=dict(
assigner=dict(type='ATSSAssigner', topk=9),
allowed_border=-1,
pos_weight=-1,
debug=False),
bbox_head=dict(
type='ATSSHead',
num_classes=80,
in_channels=256,
stacked_convs=4,
feat_channels=256,
anchor_generator=dict(
type='AnchorGenerator',
ratios=[1.0],
octave_base_scale=8,
scales_per_octave=1,
strides=[8, 16, 32, 64, 128]),
bbox_coder=dict(
type='DeltaXYWHBBoxCoder',
target_means=[.0, .0, .0, .0],
target_stds=[0.1, 0.1, 0.2, 0.2]),
loss_cls=dict(
type='FocalLoss',
use_sigmoid=True,
gamma=2.0,
alpha=0.25,
loss_weight=1.0),
loss_bbox=dict(type='GIoULoss', loss_weight=2.0),
loss_centerness=dict(
type='CrossEntropyLoss', use_sigmoid=True, loss_weight=1.0)),
参考: