从RepPoints来学习mmdetection框架

最近想精看一篇论文:RepPoints: Point Set Representation for Object Detection当然这只是V1的版本。
官方代码地址:https://github.com/microsoft/RepPoints
因为代码是基于mmdetection(v1.0rc0因为是比较早的一个工作了)的,所以这次趁此机会来打开mmdetection在我心中神秘的大门,看完之后发现这个看似庞大的框架还是很清晰的,也促进了我对detector的理解。
ps:需要注意的是mmdetection v1.x和mmdetection v2.x是不兼容的,具体可看compatibility.md

其实这两篇博客已经很清楚地阐明了mmdetection的架构(very recommend):

  • 轻松掌握 MMDetection 整体构建流程(一)
  • 轻松掌握 MMDetection 整体构建流程(二)

下面就是我自己对于RepPointsV1的代码过程中的一点理解,只为了下次能更快回忆起来


一、关于构建一个batch数据的问题

这里先放一段配置文件的关于data的一段:

data = dict(
    imgs_per_gpu=2,    # ddp模式下的batch size
    workers_per_gpu=2, # 最后被用在了num_workers
    train=dict(
        type=dataset_type,
        ann_file=data_root + 'annotations/instances_train2017.json',
        img_prefix=data_root + 'train2017/',
        img_scale=(1333, 800), # 最长边不超过1333,最短边不超过800
        img_norm_cfg=img_norm_cfg,
        size_divisor=32, # 使宽高都是size_divisor的倍数
        flip_ratio=0.5, # 水平翻转的概率。默认是水平的,小于它则翻转
        with_mask=False,
        with_crowd=False,
        with_label=True),

可以看到img_scale=(1333, 800),size_divisor=32这两个有关的参数,其实一张图片从硬盘被读取要经过以下步骤:

  1. 根据img_scale=(1333, 800),因为是keep_ratio保持宽高比的缩放,所以在最长边不超过1333,最短边不超过800中取小者为缩放倍率对读取的图片进行缩放,这里一般是放大,因为coco图片没有这么大,这样得到的图片大小记为img_shape,原始数据集中图片大小为ori_shape
  2. 在图片的right和bottom两边进行pad,让pad之后的宽高他们都是size_divisor也就是32的倍数,这样得到的图片大小记为pad_shape
  3. 把pad后的图,经过dataloader以后stack一起构成一个batch的数据

其实这里会发现一个问题,对于不同大小和宽高比的图片,经过前两步之后可能是不一样大的,这样经过dataloader应该是不能堆叠在一起的啊,后来我就发现了原因所在,最主要就是用了自定义的collate_fn,定义在mmcv.parallel.collate.py文件下的collate函数中:
从RepPoints来学习mmdetection框架_第1张图片
可以看到里面有个F.pad函数,经过分析之后会发现,这样就会把一个batch中的图片的长和宽都统一填充到这个batch中最大的长和宽,这样输入到之后的网络就是一样的大小了
其实再怎么变都不能让一个batch下的图片是不一样大小的,这是显然的

二、FPN的理解

之前一直以为FPN很神秘,虽然自己也懂bottom-up和top-down的支路和网络搭建,但是后续头部共享参数感觉有点懵。
首先看一下mmdetection里面FPN的一个典型用法:

neck=dict(
    type='FPN',
    in_channels=[256, 512, 1024, 2048], # 骨架多尺度特征图输出通道
    out_channels=256, # 增强后通道输出
    num_outs=5), # 输出num_outs个多尺度特征图

具体可看我下面这幅图:

  • 其中in_channels=[256, 512, 1024, 2048]就是resnet50/101中layer1,layer2,layer3,layer4(也可以说是stage)的输出通道数,而out_channels=256就是输出的多尺度特征图期待的通道数,num_outs控制输出几个特征图
  • 从backbone到多尺度输出,中间会经过bottom-up和top-down的支路和一些3x3的卷积层,到最后多尺度输出的stride分别是8,16,32,64,128。
  • 然后每一个多尺度输出特征图之后都会接一个head network,因为在代码里就定义了一个head,每个level的特征都是经过这个,所以就是参数共享了。
    从RepPoints来学习mmdetection框架_第2张图片

三、怎么分配正负样本

要解决这个问题,就需要来看一下head network了,这个不同的算法就是体现在这里,前面大体都是一样的:

  1. 这里的回归相当于是two stage refine的,就是生成两阶段的偏置,第二阶段的offset就是在利用第一阶段的offset生成的pseudo bbox的基础上再refine一下,得到最后的预测框,所以这里就需要两种assigner。
  2. 而这里的分类只在第一阶段生成的pseudo bbox上进行,所以这里用的是回归中第一阶段的样本分配的结果,只是这里监督的是类别

代码里面有两种assigner: PointAssigner和MaxIoUAssigner。assigner的作用就是为每一个预测出来的框(proposlas或者第一阶段的point,因为一个feature point其实就是对应一个proposal)分配一个回归标签和分类标签,以便于后续的损失计算和梯度回传【有点像给你分配对象,但是你得达到什么条件,(*^__^*) 嘻嘻……】。

  • PointAssigner: 当我们把一张图片获得的5层的第一阶段的offset field映射回原图时,就会获得很多的点(下面那幅图只画了三个level示意一下)那我们先需要知道GT在哪一个level了,这里根据论文是根据这个公式 ⌊ l o g 2 ( w B h B / 4 ) ⌋ \lfloor log_{2}(\sqrt{ w_{B}h_{B}}/4) \rfloor log2(wBhB /4),知道GT在哪一个level后,要某一种规则来确定这些点哪些是正样本哪些是负样本?:这里需要分别从sample和gt的角度来看:

    1. 从sample角度看:哪个GT跟我最近(这里因为是点,所以说GT BBOX center和sample point)我就被分配到他的位置和类别
    2. 从gt角度看:虽然你这个sample最近的是我这个GT,,但是我前k个最近的samples里面也得要有你才算啊

    经过上面的aggsiner之后:每个sample就会被分为正或负。正的就会有对应的gt bbox坐标(这个是在原图上的)还有类别,其余负样本就全是0。
    ps:其实我感觉这个阶段也可以用bbox作为分配的基本元素,像第二阶段一样,第一阶段更像是anchor的作用,来辅助生成proposal/pseudo bbox

  • MaxIoUAssigner: 这个assigner就是普通的很多算法都用到的了,其用来分配的基本元素是框,而主要根据IoU来断定哪些是正样本哪些是负样本,还有哪些是忽略的,这里就不像PointAssigner还需要分level再去分配了,直接所有level的框跟原图的gt框进行IoU计算然后分配。具体做法是用第一阶段生成pseudo bbox来跟GT BBOX计算IoU,也是从proposal和gt的角度来确定:先为全部分配为-1代表忽略的,0是负样本,正样本就是gt的索引

    1. 从proposal角度看:我跟所有的GT的IoU都小于neg_iou_threshold,那就是负样本了,那跟哪个GT的IoU都最大且大于pos_iou_threshold,那就为他分配这个GT
    2. 从gt角度看:可能要大于pos_iou_threshold的太少了,又规定一个min_pos_iou,在所有sample里跟我IoU最大的那个(可能有IoU相同的许多个)超过min_pos_iou就可以给你分配我这个GT,虽然咋们之间的IoU可能不会大于pos_iou_threshold

    经过上面的aggsiner之后:每个sample就会被分为正或负或者忽略。正的就会有对应的gt bbox的索引和类别,其余负样本就全是0,忽略是通过weight为0处将其mask out。

这样每个sample都有标签与之对应,就可以计算分类的Focal loss和SmoothL1 Loss了。
从RepPoints来学习mmdetection框架_第3张图片
从RepPoints来学习mmdetection框架_第4张图片

下面这张图是具体的head连接图,用这幅图对照代码会更容易看懂reppoints_head.py中的forward_single函数
从RepPoints来学习mmdetection框架_第5张图片

四、backward去哪了

这是我看完训练代码竟然一开始没有找到类似loss.backward()这样的话的时候提出的疑问,后来发现是在hook里面实现的:

@HOOKS.register_module()
class OptimizerHook(Hook):

    def __init__(self, grad_clip=None):
        self.grad_clip = grad_clip

    def clip_grads(self, params):
        params = list(
            filter(lambda p: p.requires_grad and p.grad is not None, params))
        if len(params) > 0:
            clip_grad.clip_grad_norm_(params, **self.grad_clip)

    def after_train_iter(self, runner):
        runner.optimizer.zero_grad()
        runner.outputs['loss'].backward()
        if self.grad_clip is not None:
            self.clip_grads(runner.model.parameters())
        runner.optimizer.step()

所以在runner的train()函数里面通过self.call_hook('after_train_iter')就会调用所有已经注册过的hooks下的after_train_iter方法,当然也就包括了这里的OptimizerHook,从而进行反向传播和梯度裁剪。

五、一张图片里面有不同数量的gt_bboxes怎么办?

其实这个问题一开始还没想到,是因为在训练过程中发现经过dataloader出来的img经过上面讨论的已经没问题了,一个batch下都是同样大小堆叠在一起的,但是因为一张图片里面有不同数量的gt_bboxes,如果直接全部堆叠在一起,你就不知道哪几个gt_bbox是对应哪张图片了,所以这是一个需要解决的问题,先放出经过dataloader出来的gt_bboxes是一个怎样的形式:
[tensor(n1,4), tensor(n2,4),…, tensor(nBatch,4)],也就是说是一个包含Tensor的list【对应的如果也使用了mask的话,经过pycocotools加载后gt_masks也是一样的,会变成一个包含ndarray的list[ndarray(n1,h1,w1), ndarray(n2,h2,w2), …, ndarray(nBatch,hBatch,wBatch)],这时gt_masks的大小是跟pad_shape是一致的】
其实这是通过DataContainer这个类实现的,从下面这段来自__getitem__可以看到数据都经过了DataContainer的包裹

data = dict(
            img=DC(to_tensor(img), stack=True),
            img_meta=DC(img_meta, cpu_only=True),
            gt_bboxes=DC(to_tensor(gt_bboxes)))

这里主要通过自定义DataLoader里面的一个参数:collate_fn和DataParallel里面的scatter函数来实现的

def collate(batch, samples_per_gpu=1):
    """Puts each data field into a tensor/DataContainer with outer dimension
    batch size.

    Extend default_collate to add support for
    :type:`~mmcv.parallel.DataContainer`. There are 3 cases.

    1. cpu_only = True, e.g., meta data
    2. cpu_only = False, stack = True, e.g., images tensors
    3. cpu_only = False, stack = False, e.g., gt bboxes
    """

    if not isinstance(batch, collections.Sequence):
        raise TypeError(f'{batch.dtype} is not supported.')

    if isinstance(batch[0], DataContainer):
        assert len(batch) % samples_per_gpu == 0
        stacked = []
        if batch[0].cpu_only:
            for i in range(0, len(batch), samples_per_gpu):
                stacked.append(
                    [sample.data for sample in batch[i:i + samples_per_gpu]])
            return DataContainer(
                stacked, batch[0].stack, batch[0].padding_value, cpu_only=True)
        elif batch[0].stack:
            for i in range(0, len(batch), samples_per_gpu):
                assert isinstance(batch[i].data, torch.Tensor)

                if batch[i].pad_dims is not None:
                    ndim = batch[i].dim()
                    assert ndim > batch[i].pad_dims
                    max_shape = [0 for _ in range(batch[i].pad_dims)]
                    for dim in range(1, batch[i].pad_dims + 1):
                        max_shape[dim - 1] = batch[i].size(-dim)
                    for sample in batch[i:i + samples_per_gpu]:
                        for dim in range(0, ndim - batch[i].pad_dims):
                            assert batch[i].size(dim) == sample.size(dim)
                        for dim in range(1, batch[i].pad_dims + 1):
                            max_shape[dim - 1] = max(max_shape[dim - 1],
                                                     sample.size(-dim))
                    padded_samples = []
                    for sample in batch[i:i + samples_per_gpu]:
                        pad = [0 for _ in range(batch[i].pad_dims * 2)]
                        for dim in range(1, batch[i].pad_dims + 1):
                            pad[2 * dim -
                                1] = max_shape[dim - 1] - sample.size(-dim)
                        padded_samples.append(
                            F.pad(
                                sample.data, pad, value=sample.padding_value))
                    stacked.append(default_collate(padded_samples))
                elif batch[i].pad_dims is None:
                    stacked.append(
                        default_collate([
                            sample.data
                            for sample in batch[i:i + samples_per_gpu]
                        ]))
                else:
                    raise ValueError(
                        'pad_dims should be either None or integers (1-3)')

        else:
            for i in range(0, len(batch), samples_per_gpu):
                stacked.append(
                    [sample.data for sample in batch[i:i + samples_per_gpu]])
        return DataContainer(stacked, batch[0].stack, batch[0].padding_value)
    elif isinstance(batch[0], collections.Sequence):
        transposed = zip(*batch)
        return [collate(samples, samples_per_gpu) for samples in transposed]
    elif isinstance(batch[0], collections.Mapping):
        return {
            key: collate([d[key] for d in batch], samples_per_gpu)
            for key in batch[0]
        }
    else:
        return default_collate(batch)

可以看到像gt bboxes这种cpu_only = False, stack = False的对象,是会变成一个list的,就是上面的【61-64行所做的】

class MMDataParallel(DataParallel):

    def scatter(self, inputs, kwargs, device_ids):
        return scatter_kwargs(inputs, kwargs, device_ids, dim=self.dim)

Anyway,不了解也没关系,大家都是拿来直接用的。

额外收获

1、在文档的notice处说You can run python(3) setup.py develop or pip install -v -e . to install mmdetection if you want to make modifications to it frequently.一开始还没怎么懂,后来自己用的时候发现,当mmdet编译完成的时候会链接到环境里,被认为是一个环境的包,然后默认改这个mmdet里面的代码就会被认为对包进行了改动(warning: 一般我们不会直接改包里的东西),这时候就可以进行重新编译mmdet(在pycharm里的体现就是重新编译完以后又可以ctrl+左键跳转mmdet下的代码了,否则会出现一些下划线的,虽然不影响运行,看着还挺难受的)

你可能感兴趣的:(目标检测,PyTorch)