论文 - MMDetection: Open MMLab Detection Toolbox and Benchmark - 2019

Github - open-mmlab/mmdetection

Github 项目 - mmdetection 目标检测库 - AIUAI

mmdetection 实现了分布式训练和非分布式训练,其分别使用的是 MMDistributedDataParallelMMDataParallel .

训练过程中的所有输出,包括 log 文件和 checkpoints 文件,自动保存到 config 配置文件中的 work_dir 路径中.

1. 学习率(lr)设置

[1] - config 文件中的默认学习率是对于 8 GPUs 和 2 img/gpu 而言的(batchsize= 8x2=16).

[2] - 基于 Linear Scaling Rule 策略,可根据具体的 GPUs 数量和每张 GPU 的图片数,得到的 batchsize 的大小,以正比例的设置学习率,如,对于 4GPUs x 2img/gpu = 8 (batchsize),设置 lr=0.01; 对于 16GPUs x 4img/gpu = 64 (batchsize),设置 lr=0.08.

2. 单 GPU 训练

python3 tools/train.py ${CONFIG_FILE} \
    --work_dir ${YOUR_WORK_DIR} #指定work_dir 路径.

3. 多 GPUs 训练

dist_train.sh:

#!/usr/bin/env bash

PYTHON=${PYTHON:-"python3"}

CONFIG=$1
GPUS=$2

$PYTHON -m torch.distributed.launch \ 
    --nproc_per_node=$GPUS \
    $(dirname "$0")/train.py $CONFIG --launcher pytorch ${@:3}

多 GPUs 训练:

./tools/dist_train.sh ${CONFIG_FILE} ${GPU_NUM} [optional arguments]

可选参数(optional arguments) 说明:

[1] - --validate - (强烈推荐),训练过程中,每 k 个 epochs 进行一次验证(默认k=1).

[2] - --work_dir ${WORK_DIR} - config文件中设定的工作路径.

[3] - --resume_from ${CHECKPOINT_FILE} - 从 checkpiont 文件恢复训练.

[4] - resume_fromload_from 的区别:

resume_from 同时加载模型权重(model weights) 和优化状态(optimizer status),且 epoch 是继承了指定 checkpoint 的信息. 一般用于意外终端的训练过程的恢复.

load_from 仅加载模型权重(model weights),训练过程的 epoch 是从 0 开始训练的. 一般用于模型 finetuning.

注:

尝试了这种分布式训练,一直出问题,可以试试:

python3 tools/train.py configs/faster_rcnn_r50_fpn_1x.py --gpus 2 --validate

4. 多机训练

对于由 slurm 管理的集群上,mmdetection 的运行,可以采用 slurm_train.sh 脚本:

slurm_train.sh

#!/usr/bin/env bash

set -x

PARTITION=$1
JOB_NAME=$2
CONFIG=$3
WORK_DIR=$4
GPUS=${5:-8}
GPUS_PER_NODE=${GPUS_PER_NODE:-8}
CPUS_PER_TASK=${CPUS_PER_TASK:-5}
SRUN_ARGS=${SRUN_ARGS:-""}
PY_ARGS=${PY_ARGS:-"--validate"}

srun -p ${PARTITION} \
    --job-name=${JOB_NAME} \
    --gres=gpu:${GPUS_PER_NODE} \
    --ntasks=${GPUS} \
    --ntasks-per-node=${GPUS_PER_NODE} \
    --cpus-per-task=${CPUS_PER_TASK} \
    --kill-on-bad-exit=1 \
    ${SRUN_ARGS} \
    python -u tools/train.py ${CONFIG} --work_dir=${WORK_DIR} --launcher="slurm" ${PY_ARGS}

运行:

./tools/slurm_train.sh ${PARTITION} ${JOB_NAME} ${CONFIG_FILE} ${WORK_DIR} [${GPUS}]

例如,在 dev 分区,采用 16 GPUs 训练 Mask R-CNN 示例:

./tools/slurm_train.sh dev mask_r50_1x configs/mask_rcnn_r50_fpn_1x.py /nfs/xxxx/mask_rcnn_r50_fpn_1x 16

5. 定制数据集

对于自定义的数据集,最简单的方式是,将数据集装换为 mmdetection 中已有数据集的格式(如 COCO 和 PASCAL VOC).

5.1. COCO 数据集格式

以包含 5 个类别的定制数据集为例,假设已经被转换为 COCO 格式.

[1] - 新建 mmdet/dataset/custom_dataset.py:

from .coco import CocoDataset
from .registry import DATASETS


@DATASETS.register_module
class CustomDataset(CocoDataset):
    CLASSES = ('a', 'b', 'c', 'd', 'e')

[2] - 编辑 mmdet/datasets/__init__.py,添加:

from .custom_dataset import CustomDataset

[3] - 在 config 文件中即可使用 CustomDateset,类似于 CocoDataset.

如:

# dataset settings
dataset_type = 'CustomDataset'
data_root = 'data/custom/'
img_norm_cfg = dict(
    mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True)
data = dict(
    imgs_per_gpu=2,
    workers_per_gpu=2,
    train=dict(
        type=dataset_type,
        ann_file=data_root + 'annotations/custom_train.json',
        img_prefix=data_root + 'custom_train/',
        img_scale=(1333, 800),
        img_norm_cfg=img_norm_cfg,
        size_divisor=32,
        flip_ratio=0.5,
        with_mask=False,
        with_crowd=True,
        with_label=True),
    val=dict(
        type=dataset_type,
        ann_file=data_root + 'annotations/custom_test.json',
        img_prefix=data_root + 'custom_test/',
        img_scale=(1333, 800),
        img_norm_cfg=img_norm_cfg,
        size_divisor=32,
        flip_ratio=0,
        with_mask=False,
        with_crowd=True,
        with_label=True),
    test=dict(
        type=dataset_type,
        ann_file=data_root + 'annotations/custom_test.json',
        img_prefix=data_root + 'custom_test/',
        img_scale=(1333, 800),
        img_norm_cfg=img_norm_cfg,
        size_divisor=32,
        flip_ratio=0,
        with_mask=False,
        with_label=False,
        test_mode=True))

5.2. 非 COCO 数据集格式

如果不想将定制数据集的标注数据转换为 COCO 或 PASCAL 格式,mmdetection 也是支持的.

mmdetection 定义了一个简单的标注数据格式,所有的数据集都是与之兼容的,不管是在线的还是离线的.

mmdetection 的数据标注格式为由 dict 构成的 list 格式,每个 dict 对应于一张图片.

[1] - 对于 testing,其包含 3 个 field:filename(相对路径)、widthheight.

[2] - 对于training,其包含 4 个 field:filename(相对路径)、widthheightann. ann 也是一个 dict,其至少包含 2 个 field:boxeslabels,均是 numpy arrays 格式. 一些数据集可能会提供其它标注信息,如 crowd/difficult/ignored bboxes,而 mmdetection 采用的是 bboxes_ignorelabels_ignore 来表示.

例如,

[
    {
        'filename': 'a.jpg',
        'width': 1280,
        'height': 720,
        'ann': {
            'bboxes': <np.ndarray, float32> (n, 4),
            'labels': <np.ndarray, int64> (n, ),
            'bboxes_ignore': <np.ndarray, float32> (k, 4),
            'labels_ignore': <np.ndarray, int64> (k, ) (optional field)
        }
    },
    ...
]

对于定制数据集,有两种处理方式:

[1] - 在线转换数据标注格式

自定义一个新的 Dataset class,继承于 CustomDataset,并重写 load_annotations(self, ann_file)get_ann_info(self, idx),类似于 mmdet/datasets/coco.pymmdet/datasets/voc.py.

[2] - 离线转换数据标注格式

将定制数据集的标注格式,转化为以上期望的格式,并保存为 pickle 文件或 json 文件,类似于 tools/convert_datasets/pascal_voc.py. 然后,即可使用 CustomDataset.

pascal_voc.py:

import argparse
import os.path as osp
import xml.etree.ElementTree as ET

import mmcv
import numpy as np

from mmdet.core import voc_classes

label_ids = {name: i + 1 for i, name in enumerate(voc_classes())}


def parse_xml(args):
    xml_path, img_path = args
    tree = ET.parse(xml_path)
    root = tree.getroot()
    size = root.find('size')
    w = int(size.find('width').text)
    h = int(size.find('height').text)
    bboxes = []
    labels = []
    bboxes_ignore = []
    labels_ignore = []
    for obj in root.findall('object'):
        name = obj.find('name').text
        label = label_ids[name]
        difficult = int(obj.find('difficult').text)
        bnd_box = obj.find('bndbox')
        bbox = [
            int(bnd_box.find('xmin').text),
            int(bnd_box.find('ymin').text),
            int(bnd_box.find('xmax').text),
            int(bnd_box.find('ymax').text)
        ]
        if difficult:
            bboxes_ignore.append(bbox)
            labels_ignore.append(label)
        else:
            bboxes.append(bbox)
            labels.append(label)
    if not bboxes:
        bboxes = np.zeros((0, 4))
        labels = np.zeros((0, ))
    else:
        bboxes = np.array(bboxes, ndmin=2) - 1
        labels = np.array(labels)
    if not bboxes_ignore:
        bboxes_ignore = np.zeros((0, 4))
        labels_ignore = np.zeros((0, ))
    else:
        bboxes_ignore = np.array(bboxes_ignore, ndmin=2) - 1
        labels_ignore = np.array(labels_ignore)
    annotation = {
        'filename': img_path,
        'width': w,
        'height': h,
        'ann': {
            'bboxes': bboxes.astype(np.float32),
            'labels': labels.astype(np.int64),
            'bboxes_ignore': bboxes_ignore.astype(np.float32),
            'labels_ignore': labels_ignore.astype(np.int64)
        }
    }
    return annotation


def cvt_annotations(devkit_path, years, split, out_file):
    if not isinstance(years, list):
        years = [years]
    annotations = []
    for year in years:
        filelist = osp.join(devkit_path, 'VOC{}/ImageSets/Main/{}.txt'.format(
            year, split))
        if not osp.isfile(filelist):
            print('filelist does not exist: {}, skip voc{} {}'.format(
                filelist, year, split))
            return
        img_names = mmcv.list_from_file(filelist)
        xml_paths = [
            osp.join(devkit_path, 'VOC{}/Annotations/{}.xml'.format(
                year, img_name)) for img_name in img_names
        ]
        img_paths = [
            'VOC{}/JPEGImages/{}.jpg'.format(year, img_name)
            for img_name in img_names
        ]
        part_annotations = mmcv.track_progress(parse_xml,
                                               list(zip(xml_paths, img_paths)))
        annotations.extend(part_annotations)
    mmcv.dump(annotations, out_file)
    return annotations


def parse_args():
    parser = argparse.ArgumentParser(
        description='Convert PASCAL VOC annotations to mmdetection format')
    parser.add_argument('devkit_path', help='pascal voc devkit path')
    parser.add_argument('-o', '--out-dir', help='output path')
    args = parser.parse_args()
    return args


def main():
    args = parse_args()
    devkit_path = args.devkit_path
    out_dir = args.out_dir if args.out_dir else devkit_path
    mmcv.mkdir_or_exist(out_dir)

    years = []
    if osp.isdir(osp.join(devkit_path, 'VOC2007')):
        years.append('2007')
    if osp.isdir(osp.join(devkit_path, 'VOC2012')):
        years.append('2012')
    if '2007' in years and '2012' in years:
        years.append(['2007', '2012'])
    if not years:
        raise IOError('The devkit path {} contains neither "VOC2007" nor '
                      '"VOC2012" subfolder'.format(devkit_path))
    for year in years:
        if year == '2007':
            prefix = 'voc07'
        elif year == '2012':
            prefix = 'voc12'
        elif year == ['2007', '2012']:
            prefix = 'voc0712'
        for split in ['train', 'val', 'trainval']:
            dataset_name = prefix + '_' + split
            print('processing {} ...'.format(dataset_name))
            cvt_annotations(devkit_path, year, split,
                            osp.join(out_dir, dataset_name + '.pkl'))
        if not isinstance(year, list):
            dataset_name = prefix + '_test'
            print('processing {} ...'.format(dataset_name))
            cvt_annotations(devkit_path, year, 'test',
                            osp.join(out_dir, dataset_name + '.pkl'))
    print('Done!')


if __name__ == '__main__':
    main()

6. 模型训练的主要单元

基于 mmdetection 训练检测器的主要单元包括:数据加载(data loading)、模型(model) 和迭代管道( iteration pipeline).

6.1. 数据加载

mmdetection 使用 DatasetDataLoader 进行 multiple workers 的数据加载.

Dataset 返回的是 a dict of data items corresponding the arguments of models' forward method.

由于在目标检测任务中,数据可能不是相同的尺寸(如,image size, gt box size 等),mmdetection 采用了在 mmcv 库中的新的 DataContainer,以收集和分发(collect and distribute)不同尺寸的数据. 参考 data_container.py.

6.2. 模型定义

mmdetection 定义了 4 种基本的可定制化模型模块(模型部件):

[1] - backbone:FCN 网络模块,提取特征图,如,ResNet,MobileNet.

[2] - neck:backbones 和 heads 网络之间的模块,如,FPN, APFPN.

[3] - head:特定任务的网络模块,如,bbox 预测和 mask 预测.

[4] - roi extractor:用于从特征图提取 RoI 特征的模块,比如,RoI Align.

基于基本模块,SingleStageDetectorTwoStageDetector 通用检测模型设计框架如图:

6.2.1. 构建 backbones 模块

以 MobileNet 开发新部件为例:

[1] - 创建新文件 - mmdet/models/backbones/mobilenet.py

import torch.nn as nn

from ..registry import BACKBONES


@BACKBONES.register_module
class MobileNet(nn.Module):

    def __init__(self, arg1, arg2):
        pass

    def forward(x):  # should return a tuple
        pass

[2] - 在 mmdet/models/backbones/__init__.py 导入该模块:

from .mobilenet import MobileNet

[3] - 在 config 文件中进行使用:

model = dict(
    ...
    backbone=dict(
        type='MobileNet',
        arg1=xxx,
        arg2=xxx),
    ...

6.2.2. 构建 necks 模块

基于 mmdetection 提供的基本模块及检测器设计框架,通过 config 文件即可无痛定义网络模型.

如果需要实现新的网络模块,如 Path Aggregation Network for Instance Segmentation 论文中的 PAFPN(path aggregation FPN),需要做两件事:

[1] - 创建新文件,mmdet/models/necks/pafpn.py

from ..registry import NECKS

@NECKS.register
class PAFPN(nn.Module):

    def __init__(self,
                in_channels,
                out_channels,
                num_outs,
                start_level=0,
                end_level=-1,
                add_extra_convs=False):
        pass

    def forward(self, inputs):
        # implementation is ignored
        pass

[2] - 修改 config 文件:

FPN 设置内容为:

neck=dict(
    type='FPN',
    in_channels=[256, 512, 1024, 2048],
    out_channels=256,
    num_outs=5)

修改为:

neck=dict(
    type='PAFPN',
    in_channels=[256, 512, 1024, 2048],
    out_channels=256,
    num_outs=5)

6.2.3. 定义新模型

mmdetection 定义新模型,需要继承 BaseDetector,其主要定义以下 abstract 方法:

[1] - extract_feat(),给定 image batch,shape 为 (n, c, h, w),提取特征图.

[2] - forward_train(),训练模式的 forward 方法.

[3] - simple_test(),不进行数据增强,单尺度(single scale)测试.

[4] - aug_test(),数据增强(如,multi-scale, flip 等)进行测试.

具体可参考: TwoStageDetector.

6.3. 迭代管道

mmdetection 对于单机和多机环境,采用分布式训练.

假设服务器有 8 块 GPUs,训练时会启动 8 个进程(processes),每个进程在一个 GPU 上进行运行.

每个进程具有独立的模型、数据加载和优化器(optimizer).

模型参数仅在开始时进行同步一次.

一次 forward 和 backward 计算后,所有 GPUs 的梯度将进行 allreduced,然后优化器更新模型参数.

由于梯度是 allreduced,在迭代结束后,所有进程的模型参数保持一致.

7. 模型测试

7.1. 数据集测试

mmdetection 提供了对 COCO、PASCAL VOC 等整个数据集进行精度评价的测试脚本,并支持:

[1] - 单 GPU 测试

[2] - 多 GPU 测试

[3] - 可视化检测结果.

如:

# single-gpu testing
python tools/test.py ${CONFIG_FILE} ${CHECKPOINT_FILE} [--out ${RESULT_FILE}] [--eval ${EVAL_METRICS}] [--show]

# multi-gpu testing
./tools/dist_test.sh ${CONFIG_FILE} ${CHECKPOINT_FILE} ${GPU_NUM} [--out ${RESULT_FILE}] [--eval ${EVAL_METRICS}]

参数说明:

[1] - RESULT_FILE - 输出结果保存的文件,pickle 格式. 如果不进行指定,则不保存测试结果.

[2] - EVAL_METRICS - 用于评测检测结果的项,可选项为 proposal_fast, proposal, bbox, segm, keypoints.

[3] - --show - 如果指定该参数,检测结果会进行可视化.(仅适用于单 GPU 测试.)

例如,假设已经有训练的 checkpoint 文件,并放于 checkpoints/ 路径.

[1] - 测试 Faster R-CNN,并可视化检测结果.

python tools/test.py configs/faster_rcnn_r50_fpn_1x.py \
    checkpoints/faster_rcnn_r50_fpn_1x_20181010-3d1b3351.pth \
    --show

[2] - 测试 Mask R-CNN,并计算 bbox 和 mask AP.

python tools/test.py configs/mask_rcnn_r50_fpn_1x.py \
    checkpoints/mask_rcnn_r50_fpn_1x_20181010-069fa190.pth \
    --out results.pkl --eval bbox segm

[3] - 在 8 GPUs 上测试 Mask R-CNN,计算 bbox 和 mask AP.

./tools/dist_test.sh configs/mask_rcnn_r50_fpn_1x.py \
    checkpoints/mask_rcnn_r50_fpn_1x_20181010-069fa190.pth \
    8 --out results.pkl --eval bbox segm

7.2. 图片测试

#!/usr/bin/python3
#!--*-- coding:utf-8 --*--
import os
from mmdet.apis import init_detector, inference_detector, show_result
import time
import random

#配置文件
config_file = 'configs/cascade_rcnn_r101_fpn_1x.py'
checkpoint_file = 'checkpoints/cascade_rcnn_r101_fpn_1x_20181129-d64ebac7.pth'

#加载模型
model = init_detector(config_file, checkpoint_file, device='cuda:0')

#测试单张图片
img = '/path/to/test.jpg'  
#或
#img = mmcv.imread(img), which will only load it once
start = time.time()
result = inference_detector(model, img)
print('[INFO]timecost: ', time.time() - start)
show_result(img, result, model.CLASSES)

#测试多张图片
imgs = ['test1.jpg', 'test2.jpg']
for i, result in enumerate(inference_detector(model, imgs)):
    show_result(imgs[i], result, model.CLASSES, 
print('[INFO]Done.')
Last modification:July 17th, 2019 at 12:24 pm