TSD : task-aware spatial disentanglement
代码地址 : https://github.com/Sense-X/TSD
该论文提出的方法取得OpenImage Object Detection Challenge 2019 冠军。
数据集
OpenImages Challenge 2019 目标检测数据集,是 OpenImages V5数据集的一个子集,有174万图片,1460万个bbox和500个类别(5个level,每个level下的类别都有隶属关系)。
出发点
相比于分类任务,目标检测任务多了一个回归的分支,这两分支几乎共享相同的参数,但其实是有冲突的。IoU-Net里面发现“高的分类分数的location经常预测出不好的bbox”。为了解决这个问题,就再多加一个分支来预测IOU作为定位置信度,但是不对齐的问题仍然存在。(因为提取的特征都是对于一个点,这个点对应到原图不一定就符合“显著的地区用来分类,边缘的地区用来回归”)。Double-Head R-CNN也可以被认为是多加了一个分支,但是因为两个分支的特征是同一个proposal的RoI特征,问题依然存在。所以需要在空间上分解分类和局部化的梯度流(spatially disentangle the gradient flows of classification and localization.)
本文结合代码来阅读(实际上是看了论文我也不太懂)
遇到很多问题应该是版本不兼容,按照https://github.com/Sense-X/TSD/blob/master/docs/INSTALL.md最终Python==3.7,PyTorch1.1错误就消失了。
先复习一下RPN的结构。RPN的提出代替了SS,使候选区域提取的时间开销几乎降为0。/mmdet/models/anchor_heads/rpn_head.py
由于使用了FPN(c2,c3,c4,c5 — p2,p3,p4,p5,p6,采样率分别是4,8,16,32,64)。这里不使用stage1的输出是因为占内存。ResNet-FPN作为RPN输入的feature map是p2,p3,p4,p5,p6,而作为后续Fast RCNN的输入则是p2,p3,p4,p5,使用p6是因为想获得更大的anchor尺度512×512。接下来的问题就是为生成的proposals(Fast RCNN的输入)选择哪层feature map来得到ROI区域。
k0是scale=224的ROI所选取的层,RetinaNet论文设为4。也就是224尺度的大小属于p4。
而在实际代码里(mmdet/models/roi_extractors/single_level.py)做了一点变化:
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
Args:
rois (Tensor): Input RoIs of all batch, shape (k, 5). index = 0 which batch_img
num_levels (int): Total level number.
Returns:
Tensor: Level index (0-based) of each RoI, shape (k, )
"""
scale = torch.sqrt(
(rois[:, 3] - rois[:, 1] + 1) * (rois[:, 4] - rois[:, 2] + 1))
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
self.finest_scale = 56,也就是p2的尺度是56左右,假设生成的ROI尺度分别有32, 64, 128, 256, 512,那么分配的level分别是0,0,1,2,3,也就是p2,p2,p3,p4,p5。
cls分支如果损失函数使用sigmoid的话,通道数要除去背景,通道就是1,也就是binary_cross_entropy,先经过sigmoid函数在进行BCE损失计算。否则就是cross_entroy,此时标签代表属于0或1哪一类,通道就是2。
生成anchor的过程先生成该层特征图对应stride,对应scale(实际上乘以8,这样每个特征图预测的大小scale分别是32, 64, 128, 256, 512)的三个ratio对应的偏差坐标,再加上特征图上个点对应原图的坐标。
# 生成一层的anchor
def grid_anchors(self, featmap_size, stride=16, device='cuda'):
base_anchors = self.base_anchors.to(device)
# 三个ratio下的base anchor(求出该特征图特定stride下, 之后再乘以8)的xmin,ymin,xmax, ymax
# 这样每个特征图预测的大小scale分别是32, 64, 128, 256, 512
feat_h, feat_w = featmap_size
shift_x = torch.arange(0, feat_w, device=device) * stride # 对应到原图
shift_y = torch.arange(0, feat_h, device=device) * stride
shift_xx, shift_yy = self._meshgrid(shift_x, shift_y)
shifts = torch.stack([shift_xx, shift_yy, shift_xx, shift_yy], dim=-1)
'''
tensor([[ 0, 0, 0, 0],
[ 4, 0, 4, 0],
[ 8, 0, 8, 0],
...,
[ 788, 1212, 788, 1212],
[ 792, 1212, 792, 1212],
[ 796, 1212, 796, 1212]], device='cuda:0')
'''
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
生成anchors后先判断是否在图片边界范围内,然后挑选正负样本。RPN生成的proposal作为fast rcnn的输入,同样先挑选正负样本。利用MaxIoUAssigner根据阈值分配正负样本,RandomSampler挑选正负样本,根据阶段的不同参数也不同。
train_cfg = dict(
rpn=dict(
assigner=dict(
type='MaxIoUAssigner',
pos_iou_thr=0.7,
neg_iou_thr=0.3,
min_pos_iou=0.3,
ignore_iof_thr=-1), # 当gt包含需忽略的bbox时使用,-1表示不忽略
sampler=dict(
type='RandomSampler',
num=256, # 计算rpn的损失
pos_fraction=0.5,
neg_pos_ub=-1, # 大于该比例的负样本忽略,-1表示不忽略
add_gt_as_proposals=False),
allowed_border=0,
pos_weight=-1, # 正样本权重,-1表示不改变原始的权重 原始权重就是1
debug=False),
rpn_proposal=dict(
nms_across_levels=False,
nms_pre=2000,
nms_post=2000,
max_num=2000,
nms_thr=0.7,
min_bbox_size=0),
rcnn=dict(
assigner=dict(
type='MaxIoUAssigner',
pos_iou_thr=0.5,
neg_iou_thr=0.5,
min_pos_iou=0.5,
ignore_iof_thr=-1),
sampler=dict(
type='RandomSampler',
num=512, # 从2000个proposal选512个样本
pos_fraction=0.25,
neg_pos_ub=-1,
add_gt_as_proposals=True),
pos_weight=-1,
debug=False))
下面再来看看RoIAlign,经过为一张图的512个proposal分配不同level之后,然后结合这些level的特征图得到ROI的对应的特征(mmdet/models/roi_extractors/single_level.py),再经过RoIAlign操作得到(num_proposal, 256, 7, 7)的特征。
接下来就进入正题,介绍TSD!!
分为三个部分来介绍,前馈过程、target设置、损失函数
图中标红的就是前馈过程的输出,保留原来faster rcnn的分支(右),再加了一个TSD分支(左)。其中delta_r感觉像是自适应学习roi的偏移量(只对正的样本进行矫正),之所以是2通道,就是对应了x,y坐标的偏移。
def bbox_target_single_tsd(pos_bboxes, #[num_pos, 4]
neg_bboxes, #[num_neg, 4]
pos_gt_bboxes, # [num_pos, 4]
pos_gt_labels, #[num_neg, 4]
rois, # torch.Size([512, 5])
delta_c, # torch.Size([512, 98])
delta_r, # torch.Size([512, 2])
cls_score_, # torch.Size([512, 81])
bbox_pred_, # torch.Size([512, 324])
TSD_cls_score_,# torch.Size([512, 81])
TSD_bbox_pred_,# torch.Size([512, 324])
cfg,
reg_classes=1,
cls_pc_margin=0.2, # 0.3
loc_pc_margin=0.2, # 0.3
target_means=[.0, .0, .0, .0],
target_stds=[1.0, 1.0, 1.0, 1.0]):
num_pos = pos_bboxes.size(0)
num_neg = neg_bboxes.size(0)
num_samples = num_pos + num_neg # 512
labels = pos_bboxes.new_zeros(num_samples, dtype=torch.long)
label_weights = pos_bboxes.new_zeros(num_samples)
bbox_targets = pos_bboxes.new_zeros(num_samples, 4)
bbox_weights = pos_bboxes.new_zeros(num_samples, 4)
TSD_labels = pos_bboxes.new_zeros(num_samples, dtype=torch.long)
TSD_label_weights = pos_bboxes.new_zeros(num_samples)
TSD_bbox_targets = pos_bboxes.new_zeros(num_samples, 4)
TSD_bbox_weights = pos_bboxes.new_zeros(num_samples, 4)
#generte P_r according to delta_r and rois
w = rois[:,3]-rois[:,1]+1
h = rois[:,4]-rois[:,2]+1
scale = 0.1
rois_r = rois.new_zeros(rois.shape[0],rois.shape[1])
rois_r[:,0] = rois[:,0]
rois_r[:,1] = rois[:,1]+delta_r[:,0]*scale*w
rois_r[:,2] = rois[:,2]+delta_r[:,1]*scale*h
rois_r[:,3] = rois[:,3]+delta_r[:,0]*scale*w
rois_r[:,4] = rois[:,4]+delta_r[:,1]*scale*h
TSD_pos_rois = rois_r[:num_pos]
pos_rois = rois[:num_pos]
if num_pos > 0:
labels[:num_pos] = pos_gt_labels
TSD_labels[:num_pos] = pos_gt_labels
pos_weight = 1.0 if cfg.pos_weight <= 0 else cfg.pos_weight # -1
label_weights[:num_pos] = pos_weight
TSD_label_weights[:num_pos] = pos_weight
pos_bbox_targets = bbox2delta(pos_bboxes, pos_gt_bboxes, target_means,
target_stds)
TSD_pos_bbox_targets = bbox2delta(TSD_pos_rois[:,1:], pos_gt_bboxes, target_means,
target_stds)
bbox_targets[:num_pos, :] = pos_bbox_targets
bbox_weights[:num_pos, :] = 1
TSD_bbox_targets[:num_pos, :] = TSD_pos_bbox_targets
TSD_bbox_weights[:num_pos, :] = 1
# compute PC for TSD
# 1. compute the PC for classification
cls_score_soft = F.softmax(cls_score_,dim=1)
TSD_cls_score_soft = F.softmax(TSD_cls_score_,dim=1)
cls_pc_margin = torch.tensor(cls_pc_margin).to(labels.device)
cls_pc_margin = torch.min(1-cls_score_soft[np.arange(len(TSD_labels)),labels],cls_pc_margin).detach() # torch.Size([512])
pc_cls_loss = F.relu(-(TSD_cls_score_soft[np.arange(len(TSD_labels)),TSD_labels] - cls_score_soft[np.arange(len(TSD_labels)),labels].detach() - cls_pc_margin))
# 2. compute the PC for localization
N = bbox_pred_.shape[0]
bbox_pred_ = bbox_pred_.view(N,-1,4) # torch.Size([512, 81, 4])
TSD_bbox_pred_ = TSD_bbox_pred_.view(N,-1,4) # torch.Size([512, 81, 4])
sibling_head_bboxes = delta2bbox(pos_bboxes, bbox_pred_[np.arange(num_pos), labels[:num_pos]], means=target_means, stds=target_stds)
TSD_head_bboxes = delta2bbox(TSD_pos_rois[:,1:], TSD_bbox_pred_[np.arange(num_pos), TSD_labels[:num_pos]], means=target_means, stds=target_stds)
ious, gious = iou_overlaps(sibling_head_bboxes, pos_gt_bboxes)
TSD_ious, TSD_gious = iou_overlaps(TSD_head_bboxes, pos_gt_bboxes)
loc_pc_margin = torch.tensor(loc_pc_margin).to(ious.device)
loc_pc_margin = torch.min(1-ious.detach(),loc_pc_margin).detach()
pc_loc_loss = F.relu(-(TSD_ious - ious.detach() - loc_pc_margin))
if num_neg > 0:
label_weights[-num_neg:] = 1.
TSD_label_weights[-num_neg:] = 1.
return labels, label_weights, bbox_targets, bbox_weights, TSD_labels, TSD_label_weights, TSD_bbox_targets, TSD_bbox_weights, pc_cls_loss, pc_loc_loss
如前馈图可见,有6个输出,其中两种分支的cls和bbox损失函数计算方法都一样。就是多了个Progressive constraint( M c l s M_{cls} Mcls、 M l o c M_{loc} Mloc),在target设置时就已经算了,那么为什么要求这个呢?论文说可以自适应地学习特定于任务的特征表示,从而进行分类和定位。表达式其实可以看作是渐进性约束,即令TSD和传统ROI Pooling主干结果保持一定margin,使得TSD部分的回归分类结果优于sibling head分支的结果。 加粗部分摘抄于https://zhuanlan.zhihu.com/p/126359766