From 0c5feaf9d967f340e05f21f701bb7b7e3a9ec476 Mon Sep 17 00:00:00 2001 From: ZwwWayne Date: Sun, 6 Dec 2020 20:43:10 +0800 Subject: [PATCH 1/8] add engine for unified entrypoints in downstream projects --- mmcv/__init__.py | 1 + mmcv/engine/__init__.py | 9 ++ mmcv/engine/test.py | 131 ++++++++++++++++++++ mmcv/engine/train.py | 0 mmcv/engine/utils.py | 246 +++++++++++++++++++++++++++++++++++++ mmcv/runner/base_runner.py | 5 +- 6 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 mmcv/engine/__init__.py create mode 100644 mmcv/engine/test.py create mode 100644 mmcv/engine/train.py create mode 100644 mmcv/engine/utils.py diff --git a/mmcv/__init__.py b/mmcv/__init__.py index 74ee0442fc..c33cfe3c3f 100644 --- a/mmcv/__init__.py +++ b/mmcv/__init__.py @@ -1,6 +1,7 @@ # Copyright (c) Open-MMLab. All rights reserved. # flake8: noqa from .arraymisc import * +from .engine import * from .fileio import * from .image import * from .utils import * diff --git a/mmcv/engine/__init__.py b/mmcv/engine/__init__.py new file mode 100644 index 0000000000..5cb9529e0e --- /dev/null +++ b/mmcv/engine/__init__.py @@ -0,0 +1,9 @@ +from .test import collect_results_cpu, collect_results_gpu, multi_gpu_test +from .utils import (default_args_parser, gather_info, set_random_seed, + setup_cfg, setup_envs, setup_logger) + +__all__ = [ + 'default_args_parser', 'gather_info', 'setup_cfg', 'setup_envs', + 'setup_logger', 'multi_gpu_test', 'collect_results_gpu', + 'collect_results_cpu', 'set_random_seed' +] diff --git a/mmcv/engine/test.py b/mmcv/engine/test.py new file mode 100644 index 0000000000..86f79aa3c9 --- /dev/null +++ b/mmcv/engine/test.py @@ -0,0 +1,131 @@ +import os.path as osp +import pickle +import shutil +import tempfile +import time + +import torch +import torch.distributed as dist + +import mmcv +from mmcv.runner import get_dist_info + + +def multi_gpu_test(model, data_loader, tmpdir=None, gpu_collect=False): + """Test model with multiple gpus. + + This method tests model with multiple gpus and collects the results + under two different modes: gpu and cpu modes. By setting 'gpu_collect=True' + it encodes results to gpu tensors and use gpu communication for results + collection. On cpu mode it saves the results on different gpus to 'tmpdir' + and collects them by the rank 0 worker. + + Args: + model (nn.Module): Model to be tested. + data_loader (nn.Dataloader): Pytorch data loader. + tmpdir (str): Path of directory to save the temporary results from + different gpus under cpu mode. + gpu_collect (bool): Option to use either gpu or cpu to collect results. + + Returns: + list: The prediction results. + """ + model.eval() + results = [] + dataset = data_loader.dataset + rank, world_size = get_dist_info() + if rank == 0: + prog_bar = mmcv.ProgressBar(len(dataset)) + time.sleep(2) # This line can prevent deadlock problem in some cases. + for i, data in enumerate(data_loader): + with torch.no_grad(): + result = model(return_loss=False, rescale=True, **data) + results.extend(result) + + if rank == 0: + batch_size = len(result) + for _ in range(batch_size * world_size): + prog_bar.update() + + # collect results from all ranks + if gpu_collect: + results = collect_results_gpu(results, len(dataset)) + else: + results = collect_results_cpu(results, len(dataset), tmpdir) + return results + + +def collect_results_cpu(result_part, size, tmpdir=None): + rank, world_size = get_dist_info() + # create a tmp dir if it is not specified + if tmpdir is None: + MAX_LEN = 512 + # 32 is whitespace + dir_tensor = torch.full((MAX_LEN, ), + 32, + dtype=torch.uint8, + device='cuda') + if rank == 0: + mmcv.mkdir_or_exist('.dist_test') + tmpdir = tempfile.mkdtemp(dir='.dist_test') + tmpdir = torch.tensor( + bytearray(tmpdir.encode()), dtype=torch.uint8, device='cuda') + dir_tensor[:len(tmpdir)] = tmpdir + dist.broadcast(dir_tensor, 0) + tmpdir = dir_tensor.cpu().numpy().tobytes().decode().rstrip() + else: + mmcv.mkdir_or_exist(tmpdir) + # dump the part result to the dir + mmcv.dump(result_part, osp.join(tmpdir, f'part_{rank}.pkl')) + dist.barrier() + # collect all parts + if rank != 0: + return None + else: + # load results of all parts from tmp dir + part_list = [] + for i in range(world_size): + part_file = osp.join(tmpdir, f'part_{i}.pkl') + part_list.append(mmcv.load(part_file)) + # sort the results + ordered_results = [] + for res in zip(*part_list): + ordered_results.extend(list(res)) + # the dataloader may pad some samples + ordered_results = ordered_results[:size] + # remove tmp dir + shutil.rmtree(tmpdir) + return ordered_results + + +def collect_results_gpu(result_part, size): + rank, world_size = get_dist_info() + # dump result part to tensor with pickle + part_tensor = torch.tensor( + bytearray(pickle.dumps(result_part)), dtype=torch.uint8, device='cuda') + # gather all result part tensor shape + shape_tensor = torch.tensor(part_tensor.shape, device='cuda') + shape_list = [shape_tensor.clone() for _ in range(world_size)] + dist.all_gather(shape_list, shape_tensor) + # padding result part tensor to max length + shape_max = torch.tensor(shape_list).max() + part_send = torch.zeros(shape_max, dtype=torch.uint8, device='cuda') + part_send[:shape_tensor[0]] = part_tensor + part_recv_list = [ + part_tensor.new_zeros(shape_max) for _ in range(world_size) + ] + # gather all result part + dist.all_gather(part_recv_list, part_send) + + if rank == 0: + part_list = [] + for recv, shape in zip(part_recv_list, shape_list): + part_list.append( + pickle.loads(recv[:shape[0]].cpu().numpy().tobytes())) + # sort the results + ordered_results = [] + for res in zip(*part_list): + ordered_results.extend(list(res)) + # the dataloader may pad some samples + ordered_results = ordered_results[:size] + return ordered_results diff --git a/mmcv/engine/train.py b/mmcv/engine/train.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mmcv/engine/utils.py b/mmcv/engine/utils.py new file mode 100644 index 0000000000..15c448a1cc --- /dev/null +++ b/mmcv/engine/utils.py @@ -0,0 +1,246 @@ +import argparse +import os +import os.path as osp +import random +import time + +import numpy as np +import torch + +from ..runner import get_dist_info, init_dist +from ..utils import (Config, DictAction, import_modules_from_strings, + mkdir_or_exist) + + +def set_random_seed(seed, deterministic=False): + """Set random seed. + + Args: + seed (int): Seed to be used. + deterministic (bool): Whether to set the deterministic option for + CUDNN backend, i.e., set `torch.backends.cudnn.deterministic` + to True and `torch.backends.cudnn.benchmark` to False. + Default: False. + """ + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + if deterministic: + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + + +def default_args_parser(): + """Default argument parser for OpenMMLab projects. + + This function is used as a default argument parser in OpenMMLab projects. + To add customized arguments, users can create a new parser function which + calls this functions first. + + Returns: + :obj:`argparse.ArgumentParser`: Argument parser + """ + parser = argparse.ArgumentParser( + description='OpenMMLab Default Argument Parser') + + # common arguments for both training and testing + parser.add_argument('config', help='config file path') + parser.add_argument( + '--tmpdir', + help='tmp directory used for collecting results from multiple ' + 'workers, available when gpu-collect is not specified') + parser.add_argument( + '--launcher', + choices=['none', 'pytorch', 'slurm', 'mpi'], + default='none', + help='job launcher') + parser.add_argument('--local_rank', type=int, default=0) + parser.add_argument( + '--gpu-collect', + action='store_true', + help='whether to use gpu to collect results in multi-gpu testing.') + + # common arguments for training + parser.add_argument('--work-dir', help='the dir to save logs and models') + parser.add_argument( + '--resume-from', help='the checkpoint file to resume from') + parser.add_argument( + '--no-validate', + action='store_true', + help='whether not to evaluate the checkpoint during training') + group_gpus = parser.add_mutually_exclusive_group() + group_gpus.add_argument( + '--gpus', + type=int, + help='number of gpus to use ' + '(only applicable to non-distributed training)') + group_gpus.add_argument( + '--gpu-ids', + type=int, + nargs='+', + help='ids of gpus to use ' + '(only applicable to non-distributed training)') + parser.add_argument('--seed', type=int, default=None, help='random seed') + parser.add_argument( + '--deterministic', + action='store_true', + help='whether to set deterministic options for CUDNN backend.') + parser.add_argument( + '--cfg-options', + nargs='+', + action=DictAction, + help='override some settings in the used config, the key-value pair ' + 'in xxx=yyy format will be merged into config file.') + + # common arguments for testing + parser.add_argument('checkpoint', help='checkpoint file') + parser.add_argument('--out', help='output result file in pickle format') + parser.add_argument( + '--fuse-conv-bn', + action='store_true', + help='Whether to fuse conv and bn, this will slightly increase' + 'the inference speed') + parser.add_argument( + '--format-only', + action='store_true', + help='Format the output results without perform evaluation. It is' + 'useful when you want to format the result to a specific format and ' + 'submit it to the test server') + parser.add_argument( + '--eval', + type=str, + nargs='+', + help='evaluation metrics, which depends on the dataset, e.g., "bbox",' + ' "segm", "proposal" for COCO, and "mAP", "recall" for PASCAL VOC') + parser.add_argument('--show', action='store_true', help='show results') + parser.add_argument( + '--show-dir', help='directory where painted images will be saved') + parser.add_argument( + '--show-score-thr', + type=float, + default=0.3, + help='score threshold (default: 0.3)') + # TODO: decide whether to maintain two place for modifing eval options + parser.add_argument( + '--eval-options', + nargs='+', + action=DictAction, + help='custom options for evaluation, the key-value pair in xxx=yyy ' + 'format will be kwargs for dataset.evaluate() function') + + return parser + + +def setup_cfg(args): + """Set up config. + + Arguments: + args (:obj:`argparse.ArgumentParser`): arguments from entry point + + Returns: + Config: config dict + """ + cfg = Config.fromfile(args.config) + # merge config from args.cfg_options + if args.cfg_options is not None: + cfg.merge_from_dict(args.cfg_options) + + # work_dir is determined in this priority: CLI > segment in file > filename + if args.work_dir is not None: + # update configs according to CLI args if args.work_dir is not None + cfg.work_dir = args.work_dir + elif cfg.get('work_dir', None) is None: + # use config filename as default work_dir if cfg.work_dir is None + cfg.work_dir = osp.join('./work_dirs', + osp.splitext(osp.basename(args.config))[0]) + if args.resume_from is not None: + cfg.resume_from = args.resume_from + if args.gpu_ids is not None: + cfg.gpu_ids = args.gpu_ids + else: + cfg.gpu_ids = range(1) if args.gpus is None else range(args.gpus) + + import_modules_from_strings(**cfg.get('custom_imports', [])) + return Config + + +def setup_envs(cfg, args): + """Setup running environments. + + This function initialize the running environment. + It does the following things in order: + + 1. Set local rank in the environment + 2. Set cudnn benchmark + 3. Initialize distributed function + 4. Create work dir anddump config file + 5. Set random seed + + Args: + cfg (:obj:`Config`): [description] + args (:obj:``): [description] + """ + # set local rank + if 'LOCAL_RANK' not in os.environ: + os.environ['LOCAL_RANK'] = str(args.local_rank) + + # set cudnn_benchmark + torch.backends.cudnn.benchmark = cfg.get('cudnn_benchmark', False) + + # init distributed env first, since logger depends on the dist info. + if args.launcher == 'none': + distributed = False + else: + distributed = True + init_dist(args.launcher, **cfg.dist_params) + # re-set gpu_ids with distributed training mode + _, world_size = get_dist_info() + cfg.gpu_ids = range(world_size) + + # create work_dir + mkdir_or_exist(osp.abspath(cfg.work_dir)) + # dump config + cfg.dump(osp.join(cfg.work_dir, osp.basename(args.config))) + + # set random seeds + if args.seed is not None: + set_random_seed(args.seed, deterministic=args.deterministic) + cfg.seed = args.seed + return distributed + + +def setup_logger(cfg, get_root_logger): + timestamp = time.strftime('%Y%m%d_%H%M%S', time.localtime()) + log_file = osp.join(cfg.work_dir, f'{timestamp}.log') + get_root_logger(log_file=log_file, log_level=cfg.log_level) + return timestamp + + +def gather_info(cfg, args, distributed, get_root_logger, collect_env): + """ + 1. collect & log env info + 2. collect exp name, config + """ + # init the meta dict to record some important information such as + # environment info and seed, which will be logged + meta = dict() + # log env info + env_info_dict = collect_env() + env_info = '\n'.join([(f'{k}: {v}') for k, v in env_info_dict.items()]) + dash_line = '-' * 60 + '\n' + meta['env_info'] = env_info + meta['config'] = cfg.pretty_text + meta['seed'] = args.seed + meta['exp_name'] = osp.basename(args.config) + + # log some basic info + logger = get_root_logger() + logger.info('Environment info:\n' + dash_line + env_info + '\n' + + dash_line) + logger.info(f'Set random seed to {args.seed}, ' + f'deterministic: {args.deterministic}') + logger.info(f'Distributed training: {distributed}') + logger.info(f'Config:\n{cfg.pretty_text}') + + return meta diff --git a/mmcv/runner/base_runner.py b/mmcv/runner/base_runner.py index 82cb76a868..70c5e4e364 100644 --- a/mmcv/runner/base_runner.py +++ b/mmcv/runner/base_runner.py @@ -55,7 +55,8 @@ def __init__(self, logger=None, meta=None, max_iters=None, - max_epochs=None): + max_epochs=None, + timestamp=None): if batch_processor is not None: if not callable(batch_processor): raise TypeError('batch_processor must be callable, ' @@ -119,7 +120,7 @@ def __init__(self, self._model_name = self.model.__class__.__name__ self._rank, self._world_size = get_dist_info() - self.timestamp = get_time_str() + self.timestamp = timestamp if timestamp is not None else get_time_str() self.mode = None self._hooks = [] self._epoch = 0 From 49fd931f6605bbb7195a1162b4f4874842036d11 Mon Sep 17 00:00:00 2001 From: ZwwWayne Date: Sun, 6 Dec 2020 21:06:42 +0800 Subject: [PATCH 2/8] add multi_gpu_test back to packages --- mmcv/engine/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mmcv/engine/utils.py b/mmcv/engine/utils.py index 15c448a1cc..81107275ee 100644 --- a/mmcv/engine/utils.py +++ b/mmcv/engine/utils.py @@ -94,7 +94,10 @@ def default_args_parser(): 'in xxx=yyy format will be merged into config file.') # common arguments for testing - parser.add_argument('checkpoint', help='checkpoint file') + parser.add_argument( + '--test-only', action='store_true', help='whether to perform evaluate') + parser.add_argument( + '--checkpoint', help='checkpoint file used in evaluation') parser.add_argument('--out', help='output result file in pickle format') parser.add_argument( '--fuse-conv-bn', @@ -161,8 +164,9 @@ def setup_cfg(args): else: cfg.gpu_ids = range(1) if args.gpus is None else range(args.gpus) - import_modules_from_strings(**cfg.get('custom_imports', [])) - return Config + if cfg.get('custom_imports', None): + import_modules_from_strings(**cfg['custom_imports']) + return cfg def setup_envs(cfg, args): From 3ece1c3b195af7332f4849f72d6eb485399b92cb Mon Sep 17 00:00:00 2001 From: ZwwWayne Date: Sun, 20 Dec 2020 21:12:35 +0800 Subject: [PATCH 3/8] Refactor engine utils --- mmcv/engine/__init__.py | 6 +- mmcv/engine/utils.py | 113 ++++++++++++++------------------ mmcv/utils/config.py | 24 ++++++- tests/test_engine/test_utils.py | 8 +++ tests/test_utils/test_config.py | 15 +++++ 5 files changed, 100 insertions(+), 66 deletions(-) create mode 100644 tests/test_engine/test_utils.py diff --git a/mmcv/engine/__init__.py b/mmcv/engine/__init__.py index 5cb9529e0e..a02c4243bf 100644 --- a/mmcv/engine/__init__.py +++ b/mmcv/engine/__init__.py @@ -1,9 +1,9 @@ from .test import collect_results_cpu, collect_results_gpu, multi_gpu_test from .utils import (default_args_parser, gather_info, set_random_seed, - setup_cfg, setup_envs, setup_logger) + setup_cfg, setup_envs) __all__ = [ 'default_args_parser', 'gather_info', 'setup_cfg', 'setup_envs', - 'setup_logger', 'multi_gpu_test', 'collect_results_gpu', - 'collect_results_cpu', 'set_random_seed' + 'multi_gpu_test', 'collect_results_gpu', 'collect_results_cpu', + 'set_random_seed' ] diff --git a/mmcv/engine/utils.py b/mmcv/engine/utils.py index 81107275ee..4c7d393462 100644 --- a/mmcv/engine/utils.py +++ b/mmcv/engine/utils.py @@ -2,7 +2,6 @@ import os import os.path as osp import random -import time import numpy as np import torch @@ -62,9 +61,6 @@ def default_args_parser(): help='whether to use gpu to collect results in multi-gpu testing.') # common arguments for training - parser.add_argument('--work-dir', help='the dir to save logs and models') - parser.add_argument( - '--resume-from', help='the checkpoint file to resume from') parser.add_argument( '--no-validate', action='store_true', @@ -81,17 +77,6 @@ def default_args_parser(): nargs='+', help='ids of gpus to use ' '(only applicable to non-distributed training)') - parser.add_argument('--seed', type=int, default=None, help='random seed') - parser.add_argument( - '--deterministic', - action='store_true', - help='whether to set deterministic options for CUDNN backend.') - parser.add_argument( - '--cfg-options', - nargs='+', - action=DictAction, - help='override some settings in the used config, the key-value pair ' - 'in xxx=yyy format will be merged into config file.') # common arguments for testing parser.add_argument( @@ -119,11 +104,6 @@ def default_args_parser(): parser.add_argument('--show', action='store_true', help='show results') parser.add_argument( '--show-dir', help='directory where painted images will be saved') - parser.add_argument( - '--show-score-thr', - type=float, - default=0.3, - help='score threshold (default: 0.3)') # TODO: decide whether to maintain two place for modifing eval options parser.add_argument( '--eval-options', @@ -135,30 +115,44 @@ def default_args_parser(): return parser -def setup_cfg(args): +def setup_cfg(args, cfg_args): """Set up config. + Note: + This function assumes the arguments are parsed from the parser of + defined by :meth:`default_args_parser`, which contains necessary keys + for distributed training including 'launcher', 'local_rank', etc. + Arguments: args (:obj:`argparse.ArgumentParser`): arguments from entry point + cfg_args (list[str]): list of key-value pairs that will be merged + into cfgs. Returns: Config: config dict """ cfg = Config.fromfile(args.config) # merge config from args.cfg_options - if args.cfg_options is not None: - cfg.merge_from_dict(args.cfg_options) - - # work_dir is determined in this priority: CLI > segment in file > filename - if args.work_dir is not None: - # update configs according to CLI args if args.work_dir is not None - cfg.work_dir = args.work_dir - elif cfg.get('work_dir', None) is None: + if len(cfg_args) > 0: + cfg.merge_from_list(cfg_args) + + if cfg.get('work_dir', None) is None: # use config filename as default work_dir if cfg.work_dir is None cfg.work_dir = osp.join('./work_dirs', osp.splitext(osp.basename(args.config))[0]) - if args.resume_from is not None: - cfg.resume_from = args.resume_from + + # initialize some default but necessary options + cfg.seed = cfg.get('seed', None) + cfg.deterministic = cfg.get('deterministic', False) + cfg.resume_from = cfg.get('resume_from', None) + + cfg.launcher = args.launcher + cfg.local_rank = args.local_rank + if args.launcher == 'none': + cfg.distributed = False + else: + cfg.distributed = True + if args.gpu_ids is not None: cfg.gpu_ids = args.gpu_ids else: @@ -169,7 +163,7 @@ def setup_cfg(args): return cfg -def setup_envs(cfg, args): +def setup_envs(cfg, dump_cfg=True): """Setup running environments. This function initialize the running environment. @@ -182,69 +176,64 @@ def setup_envs(cfg, args): 5. Set random seed Args: - cfg (:obj:`Config`): [description] - args (:obj:``): [description] + cfg (:obj:`Config`): Config object. + dump_cfg: Whether to dump configs. """ # set local rank if 'LOCAL_RANK' not in os.environ: - os.environ['LOCAL_RANK'] = str(args.local_rank) + os.environ['LOCAL_RANK'] = str(cfg.local_rank) # set cudnn_benchmark torch.backends.cudnn.benchmark = cfg.get('cudnn_benchmark', False) # init distributed env first, since logger depends on the dist info. - if args.launcher == 'none': - distributed = False - else: - distributed = True - init_dist(args.launcher, **cfg.dist_params) + if cfg.distributed: + init_dist(cfg.launcher, **cfg.dist_params) # re-set gpu_ids with distributed training mode _, world_size = get_dist_info() cfg.gpu_ids = range(world_size) # create work_dir mkdir_or_exist(osp.abspath(cfg.work_dir)) - # dump config - cfg.dump(osp.join(cfg.work_dir, osp.basename(args.config))) + if cfg.local_rank == 0 and dump_cfg: + # dump config + cfg.dump(osp.join(cfg.work_dir, osp.basename(cfg.filename))) # set random seeds - if args.seed is not None: - set_random_seed(args.seed, deterministic=args.deterministic) - cfg.seed = args.seed - return distributed + if cfg.seed is not None: + set_random_seed(cfg.seed, deterministic=cfg.deterministic) -def setup_logger(cfg, get_root_logger): - timestamp = time.strftime('%Y%m%d_%H%M%S', time.localtime()) - log_file = osp.join(cfg.work_dir, f'{timestamp}.log') - get_root_logger(log_file=log_file, log_level=cfg.log_level) - return timestamp +def gather_info(cfg, logger, env_info_dict): + """Gather running information. + This function do the following things in order: -def gather_info(cfg, args, distributed, get_root_logger, collect_env): - """ - 1. collect & log env info - 2. collect exp name, config + 1. collect & log env info + 2. collect exp name, config + + Args: + cfg (:obj:`Config`): Config object. + logger (:obj:`logging.logger`): Logger. + env_info_dict (dict): Environment information. """ # init the meta dict to record some important information such as # environment info and seed, which will be logged meta = dict() # log env info - env_info_dict = collect_env() env_info = '\n'.join([(f'{k}: {v}') for k, v in env_info_dict.items()]) dash_line = '-' * 60 + '\n' meta['env_info'] = env_info meta['config'] = cfg.pretty_text - meta['seed'] = args.seed - meta['exp_name'] = osp.basename(args.config) + meta['seed'] = cfg.seed + meta['exp_name'] = osp.basename(cfg.filename) # log some basic info - logger = get_root_logger() logger.info('Environment info:\n' + dash_line + env_info + '\n' + dash_line) - logger.info(f'Set random seed to {args.seed}, ' - f'deterministic: {args.deterministic}') - logger.info(f'Distributed training: {distributed}') + logger.info(f'Set random seed to {cfg.seed}, ' + f'deterministic: {cfg.deterministic}') + logger.info(f'Distributed training: {cfg.distributed}') logger.info(f'Config:\n{cfg.pretty_text}') return meta diff --git a/mmcv/utils/config.py b/mmcv/utils/config.py index 76739c361a..1b9c617a8f 100644 --- a/mmcv/utils/config.py +++ b/mmcv/utils/config.py @@ -404,7 +404,7 @@ def dump(self, file=None): mmcv.dump(cfg_dict, file) def merge_from_dict(self, options): - """Merge list into cfg_dict. + """Merge dict into cfg_dict. Merge the dict parsed by MultipleKVAction into this cfg. @@ -434,6 +434,28 @@ def merge_from_dict(self, options): super(Config, self).__setattr__( '_cfg_dict', Config._merge_a_into_b(option_cfg_dict, cfg_dict)) + def merge_from_list(self, option_list): + """Merge list into cfg_dict. + + Merge the dict parsed by MultipleKVAction into this cfg. + + Examples: + >>> options = {'model.backbone.depth': 50, + ... 'model.backbone.with_cp':True} + >>> cfg = Config(dict(model=dict(backbone=dict(type='ResNet')))) + >>> cfg.merge_from_dict(options) + >>> cfg_dict = super(Config, self).__getattribute__('_cfg_dict') + >>> assert cfg_dict == dict( + ... model=dict(backbone=dict(depth=50, with_cp=True))) + + Args: + options (dict): dict of configs to merge from. + """ + assert len(option_list) % 2 == 0, '"option_list" should be specified' \ + f'in pair , got odd length {len(option_list)}.' + options = {k: v for k, v in zip(option_list[0::2], option_list[1::2])} + self.merge_from_dict(options) + class DictAction(Action): """ diff --git a/tests/test_engine/test_utils.py b/tests/test_engine/test_utils.py new file mode 100644 index 0000000000..3b97b4c1a7 --- /dev/null +++ b/tests/test_engine/test_utils.py @@ -0,0 +1,8 @@ +from mmcv.engine import default_args_parser + + +def test_default_args(): + parser = default_args_parser() + known_args, cfg_args = parser.parse_known_args( + ['./configs/config.py', 'dist_params.port', '2999']) + assert cfg_args == ['dist_params.port', '2999'] diff --git a/tests/test_utils/test_config.py b/tests/test_utils/test_config.py index e13daff122..bf4a139164 100644 --- a/tests/test_utils/test_config.py +++ b/tests/test_utils/test_config.py @@ -220,6 +220,21 @@ def test_merge_from_dict(): assert cfg.item3 is False +def test_merge_from_list(): + cfg_file = osp.join(data_path, 'config/a.py') + cfg = Config.fromfile(cfg_file) + input_options = [ + 'item2.a', 1, 'item2.b', 0.1, 'item2.c', 'c', 'item3', False + ] + cfg.merge_from_list(input_options) + assert cfg.item2 == dict(a=1, b=0.1, c='c') + assert cfg.item3 is False + + # test invalid option list that is in odd length + with pytest.raises(AssertionError): + cfg.merge_from_list(['item2.a', 1, 'item2.b', 0.1, 'item3']) + + def test_merge_delete(): cfg_file = osp.join(data_path, 'config/delete.py') cfg = Config.fromfile(cfg_file) From 85afd8269c0cdb619be8056ea546493b809ca4fa Mon Sep 17 00:00:00 2001 From: ZwwWayne Date: Mon, 28 Dec 2020 16:05:04 +0800 Subject: [PATCH 4/8] add unit test & command line support --- mmcv/engine/utils.py | 2 +- mmcv/utils/config.py | 69 +++++++++++++++++++--------- tests/test_engine/test_entrypoint.py | 24 ++++++++++ tests/test_engine/test_utils.py | 35 +++++++++++++- 4 files changed, 106 insertions(+), 24 deletions(-) create mode 100644 tests/test_engine/test_entrypoint.py diff --git a/mmcv/engine/utils.py b/mmcv/engine/utils.py index 4c7d393462..3ed727205c 100644 --- a/mmcv/engine/utils.py +++ b/mmcv/engine/utils.py @@ -134,7 +134,7 @@ def setup_cfg(args, cfg_args): cfg = Config.fromfile(args.config) # merge config from args.cfg_options if len(cfg_args) > 0: - cfg.merge_from_list(cfg_args) + cfg.merge_from_arg_list(cfg_args) if cfg.get('work_dir', None) is None: # use config filename as default work_dir if cfg.work_dir is None diff --git a/mmcv/utils/config.py b/mmcv/utils/config.py index 1b9c617a8f..36d75d17ba 100644 --- a/mmcv/utils/config.py +++ b/mmcv/utils/config.py @@ -62,6 +62,28 @@ def add_args(parser, cfg, prefix=''): return parser +def parse_int_float_bool(val): + """Parse string value to be integer, float, or boolean value. + + Args: + val (str): String of variable + + Returns: + int, float, bool, str: Converted variable + """ + try: + return int(val) + except ValueError: + pass + try: + return float(val) + except ValueError: + pass + if val.lower() in ['true', 'false']: + return True if val.lower() == 'true' else False + return val + + class Config: """A facility for config and config files. @@ -434,26 +456,43 @@ def merge_from_dict(self, options): super(Config, self).__setattr__( '_cfg_dict', Config._merge_a_into_b(option_cfg_dict, cfg_dict)) - def merge_from_list(self, option_list): - """Merge list into cfg_dict. + def merge_from_arg_list(self, option_list, strict=True): + """Merge unparsed argument list into cfg_dict. Merge the dict parsed by MultipleKVAction into this cfg. Examples: - >>> options = {'model.backbone.depth': 50, - ... 'model.backbone.with_cp':True} + >>> options = ['model.backbone.depth', 50, + ... 'model.backbone.with_cp', True] >>> cfg = Config(dict(model=dict(backbone=dict(type='ResNet')))) - >>> cfg.merge_from_dict(options) + >>> cfg.merge_from_args_list(options, strict=False) >>> cfg_dict = super(Config, self).__getattribute__('_cfg_dict') >>> assert cfg_dict == dict( ... model=dict(backbone=dict(depth=50, with_cp=True))) Args: - options (dict): dict of configs to merge from. + option_list (list[str]): List of raw arguments to merge from. """ assert len(option_list) % 2 == 0, '"option_list" should be specified' \ f'in pair , got odd length {len(option_list)}.' - options = {k: v for k, v in zip(option_list[0::2], option_list[1::2])} + + def arg2key(key_name): + if key_name.startswith('--'): + opt_key = key_name[2:] + elif strict: + raise ValueError('Expect argument to be start with "--"' + f'Got {key_name}') + else: + opt_key = key_name + return opt_key.replace('-', '_') + + options = {} + for key, val in zip(option_list[0::2], option_list[1::2]): + key = arg2key(key) + val = [parse_int_float_bool(v) for v in val.split(',')] + if len(val) == 1: + val = val[0] + options[key] = val self.merge_from_dict(options) @@ -464,25 +503,11 @@ class DictAction(Action): be passed as comma separated values, i.e KEY=V1,V2,V3 """ - @staticmethod - def _parse_int_float_bool(val): - try: - return int(val) - except ValueError: - pass - try: - return float(val) - except ValueError: - pass - if val.lower() in ['true', 'false']: - return True if val.lower() == 'true' else False - return val - def __call__(self, parser, namespace, values, option_string=None): options = {} for kv in values: key, val = kv.split('=', maxsplit=1) - val = [self._parse_int_float_bool(v) for v in val.split(',')] + val = [parse_int_float_bool(v) for v in val.split(',')] if len(val) == 1: val = val[0] options[key] = val diff --git a/tests/test_engine/test_entrypoint.py b/tests/test_engine/test_entrypoint.py new file mode 100644 index 0000000000..d8e6a3fc60 --- /dev/null +++ b/tests/test_engine/test_entrypoint.py @@ -0,0 +1,24 @@ +import os.path as osp +import tempfile +import time + +from mmcv.engine import default_args_parser, gather_info, setup_cfg, setup_envs +from mmcv.utils import collect_env, get_logger + +data_path = osp.join(osp.dirname(osp.dirname(__file__)), 'data') + + +def test_entrypoint(): + with tempfile.TemporaryDirectory() as tmp_dir: + opts = f'{data_path}/config/a.py --work-dir {tmp_dir} --log-level INFO' + args, cfg_opts = default_args_parser().parse_known_args(opts.split()) + cfg = setup_cfg(args, cfg_opts) + + setup_envs(cfg) + timestamp = time.strftime('%Y%m%d_%H%M%S', time.localtime()) + log_file = osp.join(cfg.work_dir, f'{timestamp}.log') + logger = get_logger( + name='mmcv', log_file=log_file, log_level=cfg.log_level) + + meta = gather_info(cfg, logger, collect_env()) + assert meta is not None diff --git a/tests/test_engine/test_utils.py b/tests/test_engine/test_utils.py index 3b97b4c1a7..c5589faec9 100644 --- a/tests/test_engine/test_utils.py +++ b/tests/test_engine/test_utils.py @@ -1,8 +1,41 @@ +import pytest + from mmcv.engine import default_args_parser +from mmcv.utils import Config def test_default_args(): parser = default_args_parser() known_args, cfg_args = parser.parse_known_args( - ['./configs/config.py', 'dist_params.port', '2999']) + './configs/config.py dist_params.port 2999'.split()) assert cfg_args == ['dist_params.port', '2999'] + + cfg = Config(dict(a=1, b=dict(b1=[0, 1]))) + + # test ValueError when strict=False and argument does not start + # with "--" + with pytest.raises(ValueError): + cfg.merge_from_arg_list(cfg_args) + + cfg.merge_from_arg_list(cfg_args, strict=False) + assert cfg.dist_params.port == 2999 + + # test nargs before unknow args + known_args, cfg_args = parser.parse_known_args( + './configs/config.py --gpu-ids 1 2 3 --work-dir work_dirs'.split()) + assert cfg_args == ['--work-dir', 'work_dirs'] + + cfg = Config(dict(a=1, b=dict(b1=[0, 1]))) + cfg.merge_from_arg_list(cfg_args) + assert cfg.work_dir == 'work_dirs' + + # test float, bool type and list + known_args, cfg_args = parser.parse_known_args( + './configs/config.py --a-b a_b --list 1,2,3.5 --bool True'.split()) + assert cfg_args == ['--a-b', 'a_b', '--list', '1,2,3.5', '--bool', 'True'] + + cfg = Config(dict(a=1, b=dict(b1=[0, 1]))) + cfg.merge_from_arg_list(cfg_args) + assert cfg.a_b == 'a_b' + assert cfg.list == [1, 2, 3.5] + assert cfg.bool From 4351bf23ce4a7d2fca89b2e2b70d4e93b78af836 Mon Sep 17 00:00:00 2001 From: ZwwWayne Date: Mon, 28 Dec 2020 16:25:46 +0800 Subject: [PATCH 5/8] try pass build w/o torch --- .github/workflows/build.yml | 2 +- mmcv/__init__.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aad4adacdb..3d52bf2d0f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,7 +48,7 @@ jobs: - name: Run unittests and generate coverage report run: | pip install -r requirements/test.txt - pytest tests/ --ignore=tests/test_runner --ignore=tests/test_optimizer.py --ignore=tests/test_cnn --ignore=tests/test_parallel.py --ignore=tests/test_ops --ignore=tests/test_load_model_zoo.py --ignore=tests/test_utils/test_logging.py --ignore=tests/test_image/test_io.py --ignore=tests/test_utils/test_registry.py --ignore=tests/test_utils/test_parrots_jit.py + pytest tests/ --ignore=tests/test_runner --ignore=tests/test_optimizer.py --ignore=tests/test_cnn --ignore=tests/test_parallel.py --ignore=tests/test_ops --ignore=tests/test_load_model_zoo.py --ignore=tests/test_utils/test_logging.py --ignore=tests/test_image/test_io.py --ignore=tests/test_utils/test_registry.py --ignore=tests/test_utils/test_parrots_jit.py --ignore=tests/test_engine build_without_ops: runs-on: ubuntu-latest diff --git a/mmcv/__init__.py b/mmcv/__init__.py index c33cfe3c3f..74ee0442fc 100644 --- a/mmcv/__init__.py +++ b/mmcv/__init__.py @@ -1,7 +1,6 @@ # Copyright (c) Open-MMLab. All rights reserved. # flake8: noqa from .arraymisc import * -from .engine import * from .fileio import * from .image import * from .utils import * From d9d7b91bcd0aa98cde945ddfc8306983ff35d57e Mon Sep 17 00:00:00 2001 From: ZwwWayne Date: Mon, 28 Dec 2020 16:51:59 +0800 Subject: [PATCH 6/8] clean unit tests --- tests/test_utils/test_config.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/test_utils/test_config.py b/tests/test_utils/test_config.py index f207da4ef5..013bb2f5c5 100644 --- a/tests/test_utils/test_config.py +++ b/tests/test_utils/test_config.py @@ -238,21 +238,6 @@ def test_merge_from_dict(): cfg.merge_from_dict(input_options, allow_list_keys=True) -def test_merge_from_list(): - cfg_file = osp.join(data_path, 'config/a.py') - cfg = Config.fromfile(cfg_file) - input_options = [ - 'item2.a', 1, 'item2.b', 0.1, 'item2.c', 'c', 'item3', False - ] - cfg.merge_from_list(input_options) - assert cfg.item2 == dict(a=1, b=0.1, c='c') - assert cfg.item3 is False - - # test invalid option list that is in odd length - with pytest.raises(AssertionError): - cfg.merge_from_list(['item2.a', 1, 'item2.b', 0.1, 'item3']) - - def test_merge_delete(): cfg_file = osp.join(data_path, 'config/delete.py') cfg = Config.fromfile(cfg_file) From 5b1292f34b5ee8de3307d4870f9829e432c28857 Mon Sep 17 00:00:00 2001 From: ZwwWayne Date: Mon, 28 Dec 2020 21:56:31 +0800 Subject: [PATCH 7/8] fix merge bug --- mmcv/utils/config.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/mmcv/utils/config.py b/mmcv/utils/config.py index cf63dcd154..f20d3b7ba5 100644 --- a/mmcv/utils/config.py +++ b/mmcv/utils/config.py @@ -95,11 +95,11 @@ def parse_iterable(val): list | tuple: The expanded list or tuple from the string. Examples: - >>> DictAction._parse_iterable('1,2,3') + >>> parse_iterable('1,2,3') [1, 2, 3] - >>> DictAction._parse_iterable('[a, b, c]') + >>> parse_iterable('[a, b, c]') ['a', 'b', 'c'] - >>> DictAction._parse_iterable('[(1, 2, 3), [a, b], c]') + >>> parse_iterable('[(1, 2, 3), [a, b], c]') [(1, 2, 3), ['a', 'b], 'c'] """ @@ -133,11 +133,11 @@ def find_next_comma(string): val = val[1:-1] elif ',' not in val: # val is a single value - return DictAction._parse_int_float_bool(val) + return parse_int_float_bool(val) values = [] while len(val) > 0: comma_idx = find_next_comma(val) - element = DictAction._parse_iterable(val[:comma_idx]) + element = parse_iterable(val[:comma_idx]) values.append(element) val = val[comma_idx + 1:] if is_tuple: @@ -600,9 +600,6 @@ def arg2key(key_name): options = {} for key, val in zip(option_list[0::2], option_list[1::2]): key = arg2key(key) - val = [parse_int_float_bool(v) for v in val.split(',')] - if len(val) == 1: - val = val[0] options[key] = parse_iterable(val) self.merge_from_dict(options) @@ -620,8 +617,5 @@ def __call__(self, parser, namespace, values, option_string=None): options = {} for kv in values: key, val = kv.split('=', maxsplit=1) - val = [parse_int_float_bool(v) for v in val.split(',')] - if len(val) == 1: - val = val[0] options[key] = parse_iterable(val) setattr(namespace, self.dest, options) From cd92f81215f17a3bb19a7b9469fd065212a7b0cd Mon Sep 17 00:00:00 2001 From: ZwwWayne Date: Mon, 28 Dec 2020 22:24:51 +0800 Subject: [PATCH 8/8] rename --- tests/test_engine/{test_utils.py => test_launch_utils.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/test_engine/{test_utils.py => test_launch_utils.py} (100%) diff --git a/tests/test_engine/test_utils.py b/tests/test_engine/test_launch_utils.py similarity index 100% rename from tests/test_engine/test_utils.py rename to tests/test_engine/test_launch_utils.py