From 93b3c091f52ea20e349e0dbf2909278200129142 Mon Sep 17 00:00:00 2001 From: zhiltsov-max Date: Mon, 27 Jan 2020 20:20:42 +0300 Subject: [PATCH] [Datumaro] CLI updates + better documentation (#1057) --- .vscode/settings.json | 8 +- README.md | 3 + cvat/apps/dataset_manager/bindings.py | 34 +- .../export_templates/README.md | 8 +- .../extractors/cvat_rest_api_task_images.py | 7 +- cvat/apps/dataset_manager/task.py | 12 +- cvat/apps/dataset_manager/util.py | 5 + datumaro/CONTRIBUTING.md | 119 ++++ datumaro/README.md | 170 ++++- datumaro/datum.py | 2 +- datumaro/datumaro/__init__.py | 89 --- datumaro/datumaro/__main__.py | 4 +- datumaro/datumaro/cli/__init__.py | 1 - datumaro/datumaro/cli/__main__.py | 109 +++ datumaro/datumaro/cli/add_command.py | 21 - datumaro/datumaro/cli/commands/__init__.py | 6 + datumaro/datumaro/cli/commands/add.py | 8 + datumaro/datumaro/cli/commands/create.py | 8 + .../explain.py} | 66 +- datumaro/datumaro/cli/commands/export.py | 8 + datumaro/datumaro/cli/commands/remove.py | 8 + datumaro/datumaro/cli/contexts/__init__.py | 6 + .../datumaro/cli/contexts/item/__init__.py | 36 + .../cli/{ => contexts}/model/__init__.py | 117 ++-- .../datumaro/cli/contexts/project/__init__.py | 647 ++++++++++++++++++ .../cli/{ => contexts}/project/diff.py | 0 .../datumaro/cli/contexts/source/__init__.py | 247 +++++++ datumaro/datumaro/cli/create_command.py | 21 - datumaro/datumaro/cli/export_command.py | 69 -- datumaro/datumaro/cli/inference/__init__.py | 33 - datumaro/datumaro/cli/item/__init__.py | 38 - datumaro/datumaro/cli/project/__init__.py | 361 ---------- datumaro/datumaro/cli/remove_command.py | 21 - datumaro/datumaro/cli/source/__init__.py | 254 ------- datumaro/datumaro/cli/stats_command.py | 69 -- datumaro/datumaro/cli/util/__init__.py | 33 + datumaro/datumaro/cli/util/project.py | 25 +- .../datumaro/components/algorithms/rise.py | 2 +- .../components/converters/__init__.py | 2 +- .../converters/{ms_coco.py => coco.py} | 23 +- .../datumaro/components/converters/cvat.py | 12 +- .../components/converters/datumaro.py | 2 +- .../components/converters/tfrecord.py | 5 +- .../datumaro/components/converters/voc.py | 50 +- .../datumaro/components/converters/yolo.py | 2 +- .../datumaro/components/dataset_filter.py | 2 + datumaro/datumaro/components/extractor.py | 4 +- .../components/extractors/__init__.py | 5 +- .../extractors/{ms_coco.py => coco.py} | 2 +- .../datumaro/components/extractors/cvat.py | 10 +- .../components/extractors/image_dir.py | 55 ++ .../datumaro/components/extractors/voc.py | 18 +- .../formats/{ms_coco.py => coco.py} | 0 .../datumaro/components/importers/__init__.py | 14 +- .../importers/{ms_coco.py => coco.py} | 2 +- .../components/importers/image_dir.py | 26 + datumaro/datumaro/components/project.py | 38 +- datumaro/datumaro/util/test_utils.py | 25 +- datumaro/docs/cli_design.mm | 124 +--- datumaro/docs/design.md | 57 +- datumaro/docs/images/cli_design.png | Bin 93636 -> 35845 bytes datumaro/docs/quickstart.md | 325 --------- datumaro/docs/user_manual.md | 563 +++++++++++++++ datumaro/setup.py | 2 +- datumaro/test.py | 5 - datumaro/tests/test_coco_format.py | 6 +- datumaro/tests/test_cvat_format.py | 15 +- datumaro/tests/test_datumaro_format.py | 7 +- datumaro/tests/test_image.py | 4 +- datumaro/tests/test_image_dir_format.py | 48 ++ datumaro/tests/test_project.py | 1 + datumaro/tests/test_voc_format.py | 6 +- utils/cli/requirements.txt | 4 +- 73 files changed, 2461 insertions(+), 1678 deletions(-) create mode 100644 datumaro/CONTRIBUTING.md create mode 100644 datumaro/datumaro/cli/__main__.py delete mode 100644 datumaro/datumaro/cli/add_command.py create mode 100644 datumaro/datumaro/cli/commands/__init__.py create mode 100644 datumaro/datumaro/cli/commands/add.py create mode 100644 datumaro/datumaro/cli/commands/create.py rename datumaro/datumaro/cli/{explain_command.py => commands/explain.py} (85%) create mode 100644 datumaro/datumaro/cli/commands/export.py create mode 100644 datumaro/datumaro/cli/commands/remove.py create mode 100644 datumaro/datumaro/cli/contexts/__init__.py create mode 100644 datumaro/datumaro/cli/contexts/item/__init__.py rename datumaro/datumaro/cli/{ => contexts}/model/__init__.py (66%) create mode 100644 datumaro/datumaro/cli/contexts/project/__init__.py rename datumaro/datumaro/cli/{ => contexts}/project/diff.py (100%) create mode 100644 datumaro/datumaro/cli/contexts/source/__init__.py delete mode 100644 datumaro/datumaro/cli/create_command.py delete mode 100644 datumaro/datumaro/cli/export_command.py delete mode 100644 datumaro/datumaro/cli/inference/__init__.py delete mode 100644 datumaro/datumaro/cli/item/__init__.py delete mode 100644 datumaro/datumaro/cli/project/__init__.py delete mode 100644 datumaro/datumaro/cli/remove_command.py delete mode 100644 datumaro/datumaro/cli/source/__init__.py delete mode 100644 datumaro/datumaro/cli/stats_command.py rename datumaro/datumaro/components/converters/{ms_coco.py => coco.py} (95%) rename datumaro/datumaro/components/extractors/{ms_coco.py => coco.py} (98%) create mode 100644 datumaro/datumaro/components/extractors/image_dir.py rename datumaro/datumaro/components/formats/{ms_coco.py => coco.py} (100%) rename datumaro/datumaro/components/importers/{ms_coco.py => coco.py} (96%) create mode 100644 datumaro/datumaro/components/importers/image_dir.py delete mode 100644 datumaro/docs/quickstart.md create mode 100644 datumaro/docs/user_manual.md delete mode 100644 datumaro/test.py create mode 100644 datumaro/tests/test_image_dir_format.py diff --git a/.vscode/settings.json b/.vscode/settings.json index d9724300..d681cd03 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,5 +25,11 @@ } ], "python.linting.pylintEnabled": true, - "python.envFile": "${workspaceFolder}/.vscode/python.env" + "python.envFile": "${workspaceFolder}/.vscode/python.env", + "python.testing.unittestEnabled": true, + "python.testing.unittestArgs": [ + "-v", + "-s", + "./datumaro", + ], } diff --git a/README.md b/README.md index 5dcdf86a..27567e3b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ CVAT is free, online, interactive video and image annotation tool for computer v - [Installation guide](cvat/apps/documentation/installation.md) - [User's guide](cvat/apps/documentation/user_guide.md) - [Django REST API documentation](#rest-api) +- [Datumaro dataset framework](datumaro/README.md) - [Command line interface](utils/cli/) - [XML annotation format](cvat/apps/documentation/xml_format.md) - [AWS Deployment Guide](cvat/apps/documentation/AWS-Deployment-Guide.md) @@ -34,6 +35,8 @@ CVAT is free, online, interactive video and image annotation tool for computer v ## Supported annotation formats Format selection is possible after clicking on the Upload annotation / Dump annotation button. +[Datumaro](datumaro/README.md) dataset framework allows additional dataset transformations +via its command line tool. | Annotation format | Dumper | Loader | | ---------------------------------------------------------------------------------- | ------ | ------ | diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index cc758fd0..2923f7dc 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -1,3 +1,8 @@ + +# Copyright (C) 2019-2020 Intel Corporation +# +# SPDX-License-Identifier: MIT + from collections import OrderedDict import os import os.path as osp @@ -6,7 +11,7 @@ from django.db import transaction from cvat.apps.annotation.annotation import Annotation from cvat.apps.engine.annotation import TaskAnnotation -from cvat.apps.engine.models import Task, ShapeType +from cvat.apps.engine.models import Task, ShapeType, AttributeType import datumaro.components.extractor as datumaro from datumaro.util.image import lazy_image @@ -128,18 +133,33 @@ class CvatTaskExtractor(datumaro.Extractor): attrs = {} db_attributes = db_label.attributespec_set.all() for db_attr in db_attributes: - attrs[db_attr.name] = db_attr.default_value + attrs[db_attr.name] = db_attr label_attrs[db_label.name] = attrs map_label = lambda label_db_name: label_map[label_db_name] + def convert_attrs(label, cvat_attrs): + cvat_attrs = {a.name: a.value for a in cvat_attrs} + dm_attr = dict() + for attr_name, attr_spec in label_attrs[label].items(): + attr_value = cvat_attrs.get(attr_name, attr_spec.default_value) + try: + if attr_spec.input_type == AttributeType.NUMBER: + attr_value = float(attr_value) + elif attr_spec.input_type == AttributeType.CHECKBOX: + attr_value = attr_value.lower() == 'true' + dm_attr[attr_name] = attr_value + except Exception as e: + slogger.task[self._db_task.id].error( + "Failed to convert attribute '%s'='%s': %s" % \ + (attr_name, attr_value, e)) + return dm_attr + for tag_obj in cvat_anno.tags: anno_group = tag_obj.group if isinstance(anno_group, int): anno_group = anno_group anno_label = map_label(tag_obj.label) - anno_attr = dict(label_attrs[tag_obj.label]) - for attr in tag_obj.attributes: - anno_attr[attr.name] = attr.value + anno_attr = convert_attrs(tag_obj.label, tag_obj.attributes) anno = datumaro.LabelObject(label=anno_label, attributes=anno_attr, group=anno_group) @@ -150,9 +170,7 @@ class CvatTaskExtractor(datumaro.Extractor): if isinstance(anno_group, int): anno_group = anno_group anno_label = map_label(shape_obj.label) - anno_attr = dict(label_attrs[shape_obj.label]) - for attr in shape_obj.attributes: - anno_attr[attr.name] = attr.value + anno_attr = convert_attrs(shape_obj.label, shape_obj.attributes) anno_points = shape_obj.points if shape_obj.type == ShapeType.POINTS: diff --git a/cvat/apps/dataset_manager/export_templates/README.md b/cvat/apps/dataset_manager/export_templates/README.md index 82067fa2..a375bbdc 100644 --- a/cvat/apps/dataset_manager/export_templates/README.md +++ b/cvat/apps/dataset_manager/export_templates/README.md @@ -6,17 +6,15 @@ python -m virtualenv .venv . .venv/bin/activate # install dependencies -sed -r "s/^(.*)#.*$/\1/g" datumaro/requirements.txt | xargs -n 1 -L 1 pip install +pip install -e datumaro/ pip install -r cvat/utils/cli/requirements.txt # set up environment PYTHONPATH=':' export PYTHONPATH -ln -s $PWD/datumaro/datum.py ./datum -chmod a+x datum # use Datumaro -./datum --help +datum --help ``` -Check Datumaro [QUICKSTART.md](datumaro/docs/quickstart.md) for further info. +Check Datumaro [docs](datumaro/README.md) for more info. diff --git a/cvat/apps/dataset_manager/export_templates/extractors/cvat_rest_api_task_images.py b/cvat/apps/dataset_manager/export_templates/extractors/cvat_rest_api_task_images.py index 28baafad..f6d5da6b 100644 --- a/cvat/apps/dataset_manager/export_templates/extractors/cvat_rest_api_task_images.py +++ b/cvat/apps/dataset_manager/export_templates/extractors/cvat_rest_api_task_images.py @@ -1,3 +1,8 @@ + +# Copyright (C) 2019-2020 Intel Corporation +# +# SPDX-License-Identifier: MIT + from collections import OrderedDict import getpass import json @@ -27,7 +32,7 @@ class cvat_rest_api_task_images(datumaro.Extractor): def _image_local_path(self, item_id): task_id = self._config.task_id return osp.join(self._cache_dir, - 'task_{}_frame_{:06d}.jpg'.format(task_id, item_id)) + 'task_{}_frame_{:06d}.jpg'.format(task_id, int(item_id))) def _make_image_loader(self, item_id): return lazy_image(item_id, diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 5f0b422d..7c361a61 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -1,3 +1,8 @@ + +# Copyright (C) 2019-2020 Intel Corporation +# +# SPDX-License-Identifier: MIT + from datetime import timedelta import json import os @@ -217,8 +222,9 @@ class TaskProject: if dst_format == EXPORT_FORMAT_DATUMARO_PROJECT: self._remote_export(save_dir=save_dir, server_url=server_url) else: - self._dataset.export_project(output_format=dst_format, - save_dir=save_dir, save_images=save_images) + converter = self._dataset.env.make_converter(dst_format, + save_images=save_images) + self._dataset.export_project(converter=converter, save_dir=save_dir) def _remote_image_converter(self, save_dir, server_url=None): os.makedirs(save_dir, exist_ok=True) @@ -246,7 +252,7 @@ class TaskProject: if db_video is not None: for i in range(self._db_task.size): frame_info = { - 'id': str(i), + 'id': i, 'width': db_video.width, 'height': db_video.height, } diff --git a/cvat/apps/dataset_manager/util.py b/cvat/apps/dataset_manager/util.py index 8ad9aabc..c18db840 100644 --- a/cvat/apps/dataset_manager/util.py +++ b/cvat/apps/dataset_manager/util.py @@ -1,3 +1,8 @@ + +# Copyright (C) 2019-2020 Intel Corporation +# +# SPDX-License-Identifier: MIT + import inspect import os, os.path as osp import zipfile diff --git a/datumaro/CONTRIBUTING.md b/datumaro/CONTRIBUTING.md new file mode 100644 index 00000000..07b93548 --- /dev/null +++ b/datumaro/CONTRIBUTING.md @@ -0,0 +1,119 @@ +## Table of Contents + +- [Installation](#installation) +- [Usage](#usage) +- [Testing](#testing) +- [Design](#design-and-code-structure) + +## Installation + +### Prerequisites + +- Python (3.5+) +- OpenVINO (optional) + +``` bash +git clone https://github.com/opencv/cvat +``` + +Optionally, install a virtual environment: + +``` bash +python -m pip install virtualenv +python -m virtualenv venv +. venv/bin/activate +``` + +Then install all dependencies: + +``` bash +while read -r p; do pip install $p; done < requirements.txt +``` + +If you're working inside CVAT environment: +``` bash +. .env/bin/activate +while read -r p; do pip install $p; done < datumaro/requirements.txt +``` + +## Usage + +> The directory containing Datumaro should be in the `PYTHONPATH` +> environment variable or `cvat/datumaro/` should be the current directory. + +``` bash +datum --help +python -m datumaro --help +python datumaro/ --help +python datum.py --help +``` + +``` python +import datumaro +``` + +## Testing + +It is expected that all Datumaro functionality is covered and checked by +unit tests. Tests are placed in `tests/` directory. + +To run tests use: + +``` bash +python -m unittest discover -s tests +``` + +If you're working inside CVAT environment, you can also use: + +``` bash +python manage.py test datumaro/ +``` + +## Design and code structure + +- [Design document](docs/design.md) + +### Command-line + +Use [Docker](https://www.docker.com/) as an example. Basically, +the interface is divided on contexts and single commands. +Contexts are semantically grouped commands, +related to a single topic or target. Single commands are handy shorter +alternatives for the most used commands and also special commands, +which are hard to be put into any specific context. + +![cli-design-image](docs/images/cli_design.png) + +- The diagram above was created with [FreeMind](http://freemind.sourceforge.net/wiki/index.php/Main_Page) + +Model-View-ViewModel (MVVM) UI pattern is used. + +![mvvm-image](docs/images/mvvm.png) + +### Datumaro project and environment structure + + +``` +├── [datumaro module] +└── [project folder] + ├── .datumaro/ + | ├── config.yml + │   ├── .git/ + │   ├── importers/ + │   │   ├── custom_format_importer1.py + │   │   └── ... + │   ├── statistics/ + │   │   ├── custom_statistic1.py + │   │   └── ... + │   ├── visualizers/ + │   │ ├── custom_visualizer1.py + │   │ └── ... + │   └── extractors/ + │   ├── custom_extractor1.py + │   └── ... + ├── dataset/ + └── sources/ + ├── source1 + └── ... +``` + diff --git a/datumaro/README.md b/datumaro/README.md index e7b58f54..f7f1c46e 100644 --- a/datumaro/README.md +++ b/datumaro/README.md @@ -1,36 +1,176 @@ -# Dataset framework +# Dataset Framework (Datumaro) -A framework to prepare, manage, build, analyze datasets +A framework to build, transform, and analyze datasets. + + +``` +CVAT annotations -- ---> Annotation tool +... \ / +COCO-like dataset -----> Datumaro ---> dataset ------> Model training +... / \ +VOC-like dataset -- ---> Publication etc. +``` + + +## Contents + +- [Documentation](#documentation) +- [Features](#features) +- [Installation](#installation) +- [Usage](#usage) +- [Examples](#examples) +- [Contributing](#contributing) ## Documentation --[Quick start guide](docs/quickstart.md) +- [User manual](docs/user_manual.md) +- [Design document](docs/design.md) +- [Contributing](CONTRIBUTING.md) -## Installation +## Features -Python3.5+ is required. +- Dataset format conversions: + - COCO (`image_info`, `instances`, `person_keypoints`, `captions`, `labels`*) + - [Format specification](http://cocodataset.org/#format-data) + - `labels` are our extension - like `instances` with only `category_id` + - PASCAL VOC (`classification`, `detection`, `segmentation` (class, instances), `action_classification`, `person_layout`) + - [Format specification](http://host.robots.ox.ac.uk/pascal/VOC/voc2012/htmldoc/index.html) + - YOLO (`bboxes`) + - [Format specification](https://github.com/AlexeyAB/darknet#how-to-train-pascal-voc-data) + - TF Detection API (`bboxes`, `masks`) + - Format specifications: [bboxes](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/using_your_own_dataset.md), [masks](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/instance_segmentation.md) + - CVAT + - [Format specification](https://github.com/opencv/cvat/blob/develop/cvat/apps/documentation/xml_format.md) +- Dataset building operations: + - Merging multiple datasets into one + - Dataset filtering with custom conditions, for instance: + - remove all annotations except polygons of a certain class + - remove images without a specific class + - remove occluded annotations from images + - keep only vertically-oriented images + - remove small area bounding boxes from annotations + - Annotation conversions, for instance + - polygons to instance masks and vise-versa + - apply a custom colormap for mask annotations + - remap dataset labels +- Dataset comparison +- Model integration: + - Inference (OpenVINO and custom models) + - Explainable AI ([RISE algorithm](https://arxiv.org/abs/1806.07421)) -To install into a virtual environment do: +> Check the [design document](docs/design.md) for a full list of features + +## Installation + +Optionally, create a virtual environment: ``` bash python -m pip install virtualenv python -m virtualenv venv . venv/bin/activate -pip install -r requirements.txt ``` -## Execution - -The tool can be executed both as a script and as a module. +Install Datumaro package: ``` bash -PYTHONPATH="..." -python -m datumaro -python path/to/datum.py +pip install 'git+https://github.com/opencv/cvat#egg=datumaro&subdirectory=datumaro' ``` -## Testing +## Usage + +There are several options available: +- [A standalone command-line tool](#standalone-tool) +- [A python module](#python-module) + +### Standalone tool + + +``` + User + | + v ++------------------+ +| CVAT | ++--------v---------+ +------------------+ +--------------+ +| Datumaro module | ----> | Datumaro project | <---> | Datumaro CLI | <--- User ++------------------+ +------------------+ +--------------+ +``` + ``` bash -python -m unittest discover -s tests +datum --help +python -m datumaro --help ``` + +### Python module + +Datumaro can be used in custom scripts as a library in the following way: + +``` python +from datumaro.components.project import Project # project-related things +import datumaro.components.extractor # annotations and high-level interfaces +# etc. +project = Project.load('directory') +``` + +## Examples + + + + +- Convert [PASCAL VOC](http://host.robots.ox.ac.uk/pascal/VOC/voc2012/index.html#data) to COCO, keep only images with `cat` class presented: + ```bash + # Download VOC dataset: + # http://host.robots.ox.ac.uk/pascal/VOC/voc2012/VOCtrainval_11-May-2012.tar + datum project import --format voc --input-path + datum project export --format coco --filter '/item[annotation/label="cat"]' + ``` + +- Convert only non-occluded annotations from a CVAT-annotated project to TFrecord: + ```bash + # export Datumaro dataset in CVAT UI, extract somewhere, go to the project dir + datum project extract --filter '/item/annotation[occluded="False"]' \ + --mode items+anno --output-dir not_occluded + datum project export --project not_occluded \ + --format tf_detection_api -- --save-images + ``` + +- Annotate COCO, extract image subset, re-annotate it in CVAT, update old dataset: + ```bash + # Download COCO dataset http://cocodataset.org/#download + # Put images to coco/images/ and annotations to coco/annotations/ + datum project import --format coco --input-path + datum project export --filter '/image[images_I_dont_like]' --format cvat \ + --output-dir reannotation + # import dataset and images to CVAT, re-annotate + # export Datumaro project, extract to 'reannotation-upd' + datum project project merge reannotation-upd + datum project export --format coco + ``` + +- Annotate instance polygons in CVAT, export as masks in COCO: + ```bash + datum project import --format cvat --input-path + datum project export --format coco -- --segmentation-mode masks + ``` + +- Apply an OpenVINO detection model to some COCO-like dataset, + then compare annotations with ground truth and visualize in TensorBoard: + ```bash + datum project import --format coco --input-path + # create model results interpretation script + datum model add mymodel openvino \ + --weights model.bin --description model.xml \ + --interpretation-script parse_results.py + datum model run --model mymodel --output-dir mymodel_inference/ + datum project diff mymodel_inference/ --format tensorboard --output-dir diff + ``` + + + + +## Contributing + +Feel free to [open an Issue](https://github.com/opencv/cvat/issues/new) if you +think something needs to be changed. You are welcome to participate in development, +development instructions are available in our [developer manual](CONTRIBUTING.md). diff --git a/datumaro/datum.py b/datumaro/datum.py index d6ae4d2c..12c150bd 100755 --- a/datumaro/datum.py +++ b/datumaro/datum.py @@ -1,7 +1,7 @@ #!/usr/bin/env python import sys -from datumaro import main +from datumaro.cli.__main__ import main if __name__ == '__main__': diff --git a/datumaro/datumaro/__init__.py b/datumaro/datumaro/__init__.py index ea5ad68e..cd825f56 100644 --- a/datumaro/datumaro/__init__.py +++ b/datumaro/datumaro/__init__.py @@ -2,92 +2,3 @@ # Copyright (C) 2019 Intel Corporation # # SPDX-License-Identifier: MIT - -import argparse -import logging as log -import sys - -from .cli import ( - project as project_module, - source as source_module, - item as item_module, - model as model_module, - # inference as inference_module, - - create_command as create_command_module, - add_command as add_command_module, - remove_command as remove_command_module, - export_command as export_command_module, - # diff_command as diff_command_module, - # build_command as build_command_module, - stats_command as stats_command_module, - explain_command as explain_command_module, -) -from .version import VERSION - - -KNOWN_COMMANDS = { - # contexts - 'project': project_module.main, - 'source': source_module.main, - 'item': item_module.main, - 'model': model_module.main, - # 'inference': inference_module.main, - - # shortcuts - 'create': create_command_module.main, - 'add': add_command_module.main, - 'remove': remove_command_module.main, - 'export': export_command_module.main, - # 'diff': diff_command_module.main, - # 'build': build_command_module.main, - 'stats': stats_command_module.main, - 'explain': explain_command_module.main, -} - -def get_command(name, args=None): - return KNOWN_COMMANDS[name] - -def loglevel(name): - numeric = getattr(log, name.upper(), None) - if not isinstance(numeric, int): - raise ValueError('Invalid log level: %s' % name) - return numeric - -def parse_command(input_args): - parser = argparse.ArgumentParser() - parser.add_argument('command', choices=KNOWN_COMMANDS.keys(), - help='A command to execute') - parser.add_argument('args', nargs=argparse.REMAINDER) - parser.add_argument('--version', action='version', version=VERSION) - parser.add_argument('--loglevel', type=loglevel, default='info', - help="Logging level (default: %(default)s)") - - general_args = parser.parse_args(input_args) - command_name = general_args.command - command_args = general_args.args - return general_args, command_name, command_args - -def set_up_logger(general_args): - loglevel = general_args.loglevel - log.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', - level=loglevel) - -def main(args=None): - if args is None: - args = sys.argv[1:] - - general_args, command_name, command_args = parse_command(args) - - set_up_logger(general_args) - - command = get_command(command_name, general_args) - try: - return command(command_args) - except Exception as e: - log.error(e) - raise - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/datumaro/datumaro/__main__.py b/datumaro/datumaro/__main__.py index 9a055fae..27148356 100644 --- a/datumaro/datumaro/__main__.py +++ b/datumaro/datumaro/__main__.py @@ -4,9 +4,9 @@ # SPDX-License-Identifier: MIT import sys -from . import main + +from datumaro.cli.__main__ import main if __name__ == '__main__': sys.exit(main()) - diff --git a/datumaro/datumaro/cli/__init__.py b/datumaro/datumaro/cli/__init__.py index a9773073..cd825f56 100644 --- a/datumaro/datumaro/cli/__init__.py +++ b/datumaro/datumaro/cli/__init__.py @@ -2,4 +2,3 @@ # Copyright (C) 2019 Intel Corporation # # SPDX-License-Identifier: MIT - diff --git a/datumaro/datumaro/cli/__main__.py b/datumaro/datumaro/cli/__main__.py new file mode 100644 index 00000000..0ed611d0 --- /dev/null +++ b/datumaro/datumaro/cli/__main__.py @@ -0,0 +1,109 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import argparse +import logging as log +import sys + +from . import contexts, commands +from .util import CliException, add_subparser +from ..version import VERSION + + +_log_levels = { + 'debug': log.DEBUG, + 'info': log.INFO, + 'warning': log.WARNING, + 'error': log.ERROR, + 'critical': log.CRITICAL +} + +def loglevel(name): + return _log_levels[name] + +def _make_subcommands_help(commands, help_line_start=0): + desc = "" + for command_name, _, command_help in commands: + desc += (" %-" + str(max(0, help_line_start - 2 - 1)) + "s%s\n") % \ + (command_name, command_help) + return desc + +def make_parser(): + parser = argparse.ArgumentParser(prog="datumaro", + description="Dataset Framework", + formatter_class=argparse.RawDescriptionHelpFormatter) + + parser.add_argument('--version', action='version', version=VERSION) + parser.add_argument('--loglevel', type=loglevel, default='info', + help="Logging level (options: %s; default: %s)" % \ + (', '.join(_log_levels.keys()), "%(default)s")) + + known_contexts = [ + ('project', contexts.project, "Actions on projects (datasets)"), + ('source', contexts.source, "Actions on data sources"), + ('model', contexts.model, "Actions on models"), + ] + known_commands = [ + ('create', commands.create, "Create project"), + ('add', commands.add, "Add source to project"), + ('remove', commands.remove, "Remove source from project"), + ('export', commands.export, "Export project"), + ('explain', commands.explain, "Run Explainable AI algorithm for model"), + ] + + # Argparse doesn't support subparser groups: + # https://stackoverflow.com/questions/32017020/grouping-argparse-subparser-arguments + help_line_start = max((len(e[0]) for e in known_contexts + known_commands), + default=0) + help_line_start = max((2 + help_line_start) // 4 + 1, 6) * 4 # align to tabs + subcommands_desc = "" + if known_contexts: + subcommands_desc += "Contexts:\n" + subcommands_desc += _make_subcommands_help(known_contexts, + help_line_start) + if known_commands: + if subcommands_desc: + subcommands_desc += "\n" + subcommands_desc += "Commands:\n" + subcommands_desc += _make_subcommands_help(known_commands, + help_line_start) + if subcommands_desc: + subcommands_desc += \ + "\nRun '%s COMMAND --help' for more information on a command." % \ + parser.prog + + subcommands = parser.add_subparsers(title=subcommands_desc, + description="", help=argparse.SUPPRESS) + for command_name, command, _ in known_contexts + known_commands: + add_subparser(subcommands, command_name, command.build_parser) + + return parser + +def set_up_logger(args): + log.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', + level=args.loglevel) + +def main(args=None): + parser = make_parser() + args = parser.parse_args(args) + + set_up_logger(args) + + if 'command' not in args: + parser.print_help() + return 1 + + try: + return args.command(args) + except CliException as e: + log.error(e) + return 1 + except Exception as e: + log.error(e) + raise + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/datumaro/datumaro/cli/add_command.py b/datumaro/datumaro/cli/add_command.py deleted file mode 100644 index 49113084..00000000 --- a/datumaro/datumaro/cli/add_command.py +++ /dev/null @@ -1,21 +0,0 @@ - -# Copyright (C) 2019 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse - -from . import source as source_module - - -def build_parser(parser=argparse.ArgumentParser()): - source_module.build_add_parser(parser). \ - set_defaults(command=source_module.add_command) - - return parser - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - - return args.command(args) diff --git a/datumaro/datumaro/cli/commands/__init__.py b/datumaro/datumaro/cli/commands/__init__.py new file mode 100644 index 00000000..7656b7ef --- /dev/null +++ b/datumaro/datumaro/cli/commands/__init__.py @@ -0,0 +1,6 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from . import add, create, explain, export, remove \ No newline at end of file diff --git a/datumaro/datumaro/cli/commands/add.py b/datumaro/datumaro/cli/commands/add.py new file mode 100644 index 00000000..b2864039 --- /dev/null +++ b/datumaro/datumaro/cli/commands/add.py @@ -0,0 +1,8 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +# pylint: disable=unused-import + +from ..contexts.source import build_add_parser as build_parser diff --git a/datumaro/datumaro/cli/commands/create.py b/datumaro/datumaro/cli/commands/create.py new file mode 100644 index 00000000..16f6737c --- /dev/null +++ b/datumaro/datumaro/cli/commands/create.py @@ -0,0 +1,8 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +# pylint: disable=unused-import + +from ..contexts.project import build_create_parser as build_parser \ No newline at end of file diff --git a/datumaro/datumaro/cli/explain_command.py b/datumaro/datumaro/cli/commands/explain.py similarity index 85% rename from datumaro/datumaro/cli/explain_command.py rename to datumaro/datumaro/cli/commands/explain.py index 8a83f7da..9b5a6432 100644 --- a/datumaro/datumaro/cli/explain_command.py +++ b/datumaro/datumaro/cli/commands/explain.py @@ -9,25 +9,35 @@ import os import os.path as osp from datumaro.components.project import Project -from datumaro.components.algorithms.rise import RISE from datumaro.util.command_targets import (TargetKinds, target_selector, ProjectTarget, SourceTarget, ImageTarget, is_project_path) from datumaro.util.image import load_image, save_image -from .util.project import load_project +from ..util import MultilineFormatter +from ..util.project import load_project -def build_parser(parser=argparse.ArgumentParser()): +def build_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor(help="Run Explainable AI algorithm", + description="Runs an explainable AI algorithm for a model.") + parser.add_argument('-m', '--model', required=True, help="Model to use for inference") parser.add_argument('-t', '--target', default=None, help="Inference target - image, source, project " "(default: current dir)") - parser.add_argument('-d', '--save-dir', default=None, + parser.add_argument('-o', '--output-dir', dest='save_dir', default=None, help="Directory to save output (default: display only)") method_sp = parser.add_subparsers(dest='algorithm') - rise_parser = method_sp.add_parser('rise') + rise_parser = method_sp.add_parser('rise', + description=""" + RISE: Randomized Input Sampling for + Explanation of Black-box Models algorithm|n + |n + See explanations at: https://arxiv.org/pdf/1806.07421.pdf + """, + formatter_class=MultilineFormatter) rise_parser.add_argument('-s', '--max-samples', default=None, type=int, help="Number of algorithm iterations (default: mask size ^ 2)") rise_parser.add_argument('--mw', '--mask-width', @@ -46,7 +56,7 @@ def build_parser(parser=argparse.ArgumentParser()): help="IoU match threshold in Non-maxima suppression (default: no NMS)") rise_parser.add_argument('--conf', '--det-conf-thresh', dest='det_conf_thresh', default=0.0, type=float, - help="Confidence threshold for detections (default: do not filter)") + help="Confidence threshold for detections (default: include all)") rise_parser.add_argument('-b', '--batch-size', default=1, type=int, help="Inference batch size (default: %(default)s)") rise_parser.add_argument('--progressive', action='store_true', @@ -59,6 +69,21 @@ def build_parser(parser=argparse.ArgumentParser()): return parser def explain_command(args): + project_path = args.project_dir + if is_project_path(project_path): + project = Project.load(project_path) + else: + project = None + args.target = target_selector( + ProjectTarget(is_default=True, project=project), + SourceTarget(project=project), + ImageTarget() + )(args.target) + if args.target[0] == TargetKinds.project: + if is_project_path(args.target[1]): + args.project_dir = osp.dirname(osp.abspath(args.target[1])) + + import cv2 from matplotlib import cm @@ -69,6 +94,7 @@ def explain_command(args): if str(args.algorithm).lower() != 'rise': raise NotImplementedError() + from datumaro.components.algorithms.rise import RISE rise = RISE(model, max_samples=args.max_samples, mask_width=args.mask_width, @@ -162,31 +188,3 @@ def explain_command(args): raise NotImplementedError() return 0 - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - if 'command' not in args: - parser.print_help() - return 1 - - project_path = args.project_dir - if is_project_path(project_path): - project = Project.load(project_path) - else: - project = None - try: - args.target = target_selector( - ProjectTarget(is_default=True, project=project), - SourceTarget(project=project), - ImageTarget() - )(args.target) - if args.target[0] == TargetKinds.project: - if is_project_path(args.target[1]): - args.project_dir = osp.dirname(osp.abspath(args.target[1])) - except argparse.ArgumentTypeError as e: - print(e) - parser.print_help() - return 1 - - return args.command(args) diff --git a/datumaro/datumaro/cli/commands/export.py b/datumaro/datumaro/cli/commands/export.py new file mode 100644 index 00000000..afeb73cd --- /dev/null +++ b/datumaro/datumaro/cli/commands/export.py @@ -0,0 +1,8 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +# pylint: disable=unused-import + +from ..contexts.project import build_export_parser as build_parser \ No newline at end of file diff --git a/datumaro/datumaro/cli/commands/remove.py b/datumaro/datumaro/cli/commands/remove.py new file mode 100644 index 00000000..0e0d076f --- /dev/null +++ b/datumaro/datumaro/cli/commands/remove.py @@ -0,0 +1,8 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +# pylint: disable=unused-import + +from ..contexts.source import build_remove_parser as build_parser \ No newline at end of file diff --git a/datumaro/datumaro/cli/contexts/__init__.py b/datumaro/datumaro/cli/contexts/__init__.py new file mode 100644 index 00000000..95019b7b --- /dev/null +++ b/datumaro/datumaro/cli/contexts/__init__.py @@ -0,0 +1,6 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from . import project, source, model, item \ No newline at end of file diff --git a/datumaro/datumaro/cli/contexts/item/__init__.py b/datumaro/datumaro/cli/contexts/item/__init__.py new file mode 100644 index 00000000..1df66809 --- /dev/null +++ b/datumaro/datumaro/cli/contexts/item/__init__.py @@ -0,0 +1,36 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import argparse + +from ...util import add_subparser + + +def build_export_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor() + return parser + +def build_stats_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor() + return parser + +def build_diff_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor() + return parser + +def build_edit_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor() + return parser + +def build_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor() + + subparsers = parser.add_subparsers() + add_subparser(subparsers, 'export', build_export_parser) + add_subparser(subparsers, 'stats', build_stats_parser) + add_subparser(subparsers, 'diff', build_diff_parser) + add_subparser(subparsers, 'edit', build_edit_parser) + + return parser diff --git a/datumaro/datumaro/cli/model/__init__.py b/datumaro/datumaro/cli/contexts/model/__init__.py similarity index 66% rename from datumaro/datumaro/cli/model/__init__.py rename to datumaro/datumaro/cli/contexts/model/__init__.py index b168248f..5f40bd38 100644 --- a/datumaro/datumaro/cli/model/__init__.py +++ b/datumaro/datumaro/cli/contexts/model/__init__.py @@ -9,9 +9,49 @@ import os import os.path as osp import shutil -from ..util.project import load_project +from datumaro.components.config import DEFAULT_FORMAT +from ...util import add_subparser +from ...util.project import load_project +def build_openvino_add_parser(parser=argparse.ArgumentParser()): + parser.add_argument('-d', '--description', required=True, + help="Path to the model description file (.xml)") + parser.add_argument('-w', '--weights', required=True, + help="Path to the model weights file (.bin)") + parser.add_argument('-i', '--interpretation-script', required=True, + help="Path to the network output interpretation script (.py)") + parser.add_argument('--plugins-path', default=None, + help="Path to the custom Inference Engine plugins directory") + parser.add_argument('--copy', action='store_true', + help="Copy the model data to the project") + + return parser + +def openvino_args_extractor(args): + my_args = argparse.Namespace() + my_args.description = args.description + my_args.weights = args.weights + my_args.interpretation_script = args.interpretation_script + my_args.plugins_path = args.plugins_path + return my_args + +def build_add_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor() + + parser.add_argument('name', + help="Name of the model to be added") + launchers_sp = parser.add_subparsers(dest='launcher') + + build_openvino_add_parser(launchers_sp.add_parser('openvino')) \ + .set_defaults(launcher_args_extractor=openvino_args_extractor) + + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + parser.set_defaults(command=add_command) + + return parser + def add_command(args): project = load_project(args.project_dir) @@ -55,39 +95,16 @@ def add_command(args): return 0 -def build_openvino_add_parser(parser): - parser.add_argument('-d', '--description', required=True, - help="Path to the model description file (.xml)") - parser.add_argument('-w', '--weights', required=True, - help="Path to the model weights file (.bin)") - parser.add_argument('-i', '--interpretation-script', required=True, - help="Path to the network output interpretation script (.py)") - parser.add_argument('--plugins-path', default=None, - help="Path to the custom Inference Engine plugins directory") - parser.add_argument('--copy', action='store_true', - help="Copy the model data to the project") - return parser - -def openvino_args_extractor(args): - my_args = argparse.Namespace() - my_args.description = args.description - my_args.weights = args.weights - my_args.interpretation_script = args.interpretation_script - my_args.plugins_path = args.plugins_path - return my_args +def build_remove_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor() -def build_add_parser(parser): parser.add_argument('name', - help="Name of the model to be added") - launchers_sp = parser.add_subparsers(dest='launcher') - - build_openvino_add_parser(launchers_sp.add_parser('openvino')) \ - .set_defaults(launcher_args_extractor=openvino_args_extractor) - + help="Name of the model to be removed") parser.add_argument('-p', '--project', dest='project_dir', default='.', help="Directory of the project to operate on (default: current dir)") - return parser + parser.set_defaults(command=remove_command) + return parser def remove_command(args): project = load_project(args.project_dir) @@ -97,31 +114,39 @@ def remove_command(args): return 0 -def build_remove_parser(parser): - parser.add_argument('name', - help="Name of the model to be removed") +def build_run_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor() + + parser.add_argument('-o', '--output-dir', dest='dst_dir', required=True, + help="Directory to save output") + parser.add_argument('-m', '--model', dest='model_name', required=True, + help="Model to apply to the project") parser.add_argument('-p', '--project', dest='project_dir', default='.', help="Directory of the project to operate on (default: current dir)") + parser.set_defaults(command=run_command) return parser +def run_command(args): + project = load_project(args.project_dir) + + dst_dir = osp.abspath(args.dst_dir) + os.makedirs(dst_dir, exist_ok=False) + project.make_dataset().apply_model( + save_dir=dst_dir, + model_name=args.model_name) -def build_parser(parser=argparse.ArgumentParser()): - command_parsers = parser.add_subparsers(dest='command_name') + log.info("Inference results have been saved to '%s'" % dst_dir) - build_add_parser(command_parsers.add_parser('add')) \ - .set_defaults(command=add_command) + return 0 - build_remove_parser(command_parsers.add_parser('remove')) \ - .set_defaults(command=remove_command) - return parser +def build_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor() -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - if 'command' not in args: - parser.print_help() - return 1 + subparsers = parser.add_subparsers() + add_subparser(subparsers, 'add', build_add_parser) + add_subparser(subparsers, 'remove', build_remove_parser) + add_subparser(subparsers, 'run', build_run_parser) - return args.command(args) + return parser diff --git a/datumaro/datumaro/cli/contexts/project/__init__.py b/datumaro/datumaro/cli/contexts/project/__init__.py new file mode 100644 index 00000000..0ba03461 --- /dev/null +++ b/datumaro/datumaro/cli/contexts/project/__init__.py @@ -0,0 +1,647 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import argparse +from enum import Enum +import logging as log +import os +import os.path as osp +import shutil + +from datumaro.components.project import Project +from datumaro.components.comparator import Comparator +from datumaro.components.dataset_filter import DatasetItemEncoder +from datumaro.components.extractor import AnnotationType +from .diff import DiffVisualizer +from ...util import add_subparser, CliException, MultilineFormatter +from ...util.project import make_project_path, load_project, \ + generate_next_dir_name + + +def build_create_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor(help="Create empty project", + description=""" + Create a new empty project.|n + |n + Examples:|n + - Create a project in the current directory:|n + |s|screate -n myproject|n + |n + - Create a project in other directory:|n + |s|screate -o path/I/like/ + """, + formatter_class=MultilineFormatter) + + parser.add_argument('-o', '--output-dir', default='.', dest='dst_dir', + help="Save directory for the new project (default: current dir") + parser.add_argument('-n', '--name', default=None, + help="Name of the new project (default: same as project dir)") + parser.add_argument('--overwrite', action='store_true', + help="Overwrite existing files in the save directory") + parser.set_defaults(command=create_command) + + return parser + +def create_command(args): + project_dir = osp.abspath(args.dst_dir) + project_path = make_project_path(project_dir) + + if osp.isdir(project_dir) and os.listdir(project_dir): + if not args.overwrite: + raise CliException("Directory '%s' already exists " + "(pass --overwrite to force creation)" % project_dir) + else: + shutil.rmtree(project_dir) + os.makedirs(project_dir, exist_ok=True) + + if not args.overwrite and osp.isfile(project_path): + raise CliException("Project file '%s' already exists " + "(pass --overwrite to force creation)" % project_path) + + project_name = args.name + if project_name is None: + project_name = osp.basename(project_dir) + + log.info("Creating project at '%s'" % project_dir) + + Project.generate(project_dir, { + 'project_name': project_name, + }) + + log.info("Project has been created at '%s'" % project_dir) + + return 0 + +def build_import_parser(parser_ctor=argparse.ArgumentParser): + import datumaro.components.importers as importers_module + builtin_importers = [name for name, cls in importers_module.items] + + parser = parser_ctor(help="Create project from existing dataset", + description=""" + Creates a project from an existing dataset. The source can be:|n + - a dataset in a supported format (check 'formats' section below)|n + - a Datumaro project|n + |n + Formats:|n + Datasets come in a wide variety of formats. Each dataset + format defines its own data structure and rules on how to + interpret the data. For example, the following data structure + is used in COCO format:|n + /dataset/|n + - /images/.jpg|n + - /annotations/|n + |n + In Datumaro dataset formats are supported by + Extractor-s and Importer-s. + An Extractor produces a list of dataset items corresponding + to the dataset. An Importer creates a project from the + data source location. + It is possible to add a custom Extractor and Importer. + To do this, you need to put an Extractor and + Importer implementation scripts to + /.datumaro/extractors + and /.datumaro/importers.|n + |n + List of supported dataset formats: %s|n + |n + Examples:|n + - Create a project from VOC dataset in the current directory:|n + |s|simport -f voc -i path/to/voc|n + |n + - Create a project from COCO dataset in other directory:|n + |s|simport -f coco -i path/to/coco -o path/I/like/ + """ % ', '.join(builtin_importers), + formatter_class=MultilineFormatter) + + parser.add_argument('-o', '--output-dir', default='.', dest='dst_dir', + help="Directory to save the new project to (default: current dir)") + parser.add_argument('-n', '--name', default=None, + help="Name of the new project (default: same as project dir)") + parser.add_argument('--copy', action='store_true', + help="Copy the dataset instead of saving source links") + parser.add_argument('--skip-check', action='store_true', + help="Skip source checking") + parser.add_argument('--overwrite', action='store_true', + help="Overwrite existing files in the save directory") + parser.add_argument('-i', '--input-path', required=True, dest='source', + help="Path to import project from") + parser.add_argument('-f', '--format', required=True, + help="Source project format") + # parser.add_argument('extra_args', nargs=argparse.REMAINDER, + # help="Additional arguments for importer (pass '-- -h' for help)") + parser.set_defaults(command=import_command) + + return parser + +def import_command(args): + project_dir = osp.abspath(args.dst_dir) + project_path = make_project_path(project_dir) + + if osp.isdir(project_dir) and os.listdir(project_dir): + if not args.overwrite: + raise CliException("Directory '%s' already exists " + "(pass --overwrite to force creation)" % project_dir) + else: + shutil.rmtree(project_dir) + os.makedirs(project_dir, exist_ok=True) + + if not args.overwrite and osp.isfile(project_path): + raise CliException("Project file '%s' already exists " + "(pass --overwrite to force creation)" % project_path) + + project_name = args.name + if project_name is None: + project_name = osp.basename(project_dir) + + log.info("Importing project from '%s' as '%s'" % \ + (args.source, args.format)) + + source = osp.abspath(args.source) + project = Project.import_from(source, args.format) + project.config.project_name = project_name + project.config.project_dir = project_dir + + if not args.skip_check or args.copy: + log.info("Checking the dataset...") + dataset = project.make_dataset() + if args.copy: + log.info("Cloning data...") + dataset.save(merge=True, save_images=True) + else: + project.save() + + log.info("Project has been created at '%s'" % project_dir) + + return 0 + + +class FilterModes(Enum): + # primary + items = 1 + annotations = 2 + items_annotations = 3 + + # shortcuts + i = 1 + a = 2 + i_a = 3 + a_i = 3 + annotations_items = 3 + + @staticmethod + def parse(s): + s = s.lower() + s = s.replace('+', '_') + return FilterModes[s] + + @classmethod + def make_filter_args(cls, mode): + if mode == cls.items: + return {} + elif mode == cls.annotations: + return { + 'filter_annotations': True + } + elif mode == cls.items_annotations: + return { + 'filter_annotations': True, + 'remove_empty': True, + } + else: + raise NotImplementedError() + + @classmethod + def list_options(cls): + return [m.name.replace('_', '+') for m in cls] + +def build_export_parser(parser_ctor=argparse.ArgumentParser): + import datumaro.components.converters as converters_module + builtin_converters = [name for name, cls in converters_module.items] + + parser = parser_ctor(help="Export project", + description=""" + Exports the project dataset in some format. Optionally, a filter + can be passed, check 'extract' command description for more info. + Each dataset format has its own options, which + are passed after '--' separator (see examples), pass '-- -h' + for more info. If not stated otherwise, by default + only annotations are exported, to include images pass + '--save-images' parameter.|n + |n + Formats:|n + In Datumaro dataset formats are supported by Converter-s. + A Converter produces a dataset of a specific format + from dataset items. It is possible to add a custom Converter. + To do this, you need to put a Converter + definition script to /.datumaro/converters.|n + |n + List of supported dataset formats: %s|n + |n + Examples:|n + - Export project as a VOC-like dataset, include images:|n + |s|sexport -f voc -- --save-images|n + |n + - Export project as a COCO-like dataset in other directory:|n + |s|sexport -f coco -o path/I/like/ + """ % ', '.join(builtin_converters), + formatter_class=MultilineFormatter) + + parser.add_argument('-e', '--filter', default=None, + help="Filter expression for dataset items") + parser.add_argument('--filter-mode', default=FilterModes.i.name, + type=FilterModes.parse, + help="Filter mode (options: %s; default: %s)" % \ + (', '.join(FilterModes.list_options()) , '%(default)s')) + parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, + help="Directory to save output (default: a subdir in the current one)") + parser.add_argument('--overwrite', action='store_true', + help="Overwrite existing files in the save directory") + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + parser.add_argument('-f', '--format', required=True, + help="Output format") + parser.add_argument('extra_args', nargs=argparse.REMAINDER, default=None, + help="Additional arguments for converter (pass '-- -h' for help)") + parser.set_defaults(command=export_command) + + return parser + +def export_command(args): + project = load_project(args.project_dir) + + dst_dir = args.dst_dir + if dst_dir: + if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): + raise CliException("Directory '%s' already exists " + "(pass --overwrite to force creation)" % dst_dir) + else: + dst_dir = generate_next_dir_name('%s-export-%s' % \ + (project.config.project_name, args.format)) + dst_dir = osp.abspath(dst_dir) + + try: + converter = project.env.make_converter(args.format, + cmdline_args=args.extra_args) + except KeyError: + raise CliException("Converter for format '%s' is not found" % \ + args.format) + + filter_args = FilterModes.make_filter_args(args.filter_mode) + + log.info("Loading the project...") + dataset = project.make_dataset() + + log.info("Exporting the project...") + dataset.export_project( + save_dir=dst_dir, + converter=converter, + filter_expr=args.filter, + **filter_args) + log.info("Project exported to '%s' as '%s'" % \ + (dst_dir, args.format)) + + return 0 + +def build_extract_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor(help="Extract subproject", + description=""" + Extracts a subproject that contains only items matching filter. + A filter is an XPath expression, which is applied to XML + representation of a dataset item. Check '--dry-run' parameter + to see XML representations of the dataset items.|n + |n + To filter annotations use the mode ('-m') parameter.|n + Supported modes:|n + - 'i', 'items'|n + - 'a', 'annotations'|n + - 'i+a', 'a+i', 'items+annotations', 'annotations+items'|n + When filtering annotations, use the 'items+annotations' + mode to point that annotation-less dataset items should be + removed. To select an annotation, write an XPath that + returns 'annotation' elements (see examples).|n + |n + Examples:|n + - Filter images with width < height:|n + |s|sextract -e '/item[image/width < image/height]'|n + |n + - Filter images with large-area bboxes:|n + |s|sextract -e '/item[annotation/type="bbox" and + annotation/area>2000]'|n + |n + - Filter out all irrelevant annotations from items:|n + |s|sextract -m a -e '/item/annotation[label = "person"]'|n + |n + - Filter out all irrelevant annotations from items:|n + |s|sextract -m a -e '/item/annotation[label="cat" and + area > 99.5]'|n + |n + - Filter occluded annotations and items, if no annotations left:|n + |s|sextract -m i+a -e '/item/annotation[occluded="True"]' + """, + formatter_class=MultilineFormatter) + + parser.add_argument('-e', '--filter', default=None, + help="XML XPath filter expression for dataset items") + parser.add_argument('-m', '--mode', default=FilterModes.i.name, + type=FilterModes.parse, + help="Filter mode (options: %s; default: %s)" % \ + (', '.join(FilterModes.list_options()) , '%(default)s')) + parser.add_argument('--dry-run', action='store_true', + help="Print XML representations to be filtered and exit") + parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, + help="Output directory (default: update current project)") + parser.add_argument('--overwrite', action='store_true', + help="Overwrite existing files in the save directory") + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + parser.set_defaults(command=extract_command) + + return parser + +def extract_command(args): + project = load_project(args.project_dir) + + if not args.dry_run: + dst_dir = args.dst_dir + if dst_dir: + if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): + raise CliException("Directory '%s' already exists " + "(pass --overwrite to force creation)" % dst_dir) + else: + dst_dir = generate_next_dir_name('%s-filter' % \ + project.config.project_name) + dst_dir = osp.abspath(dst_dir) + + dataset = project.make_dataset() + + filter_args = FilterModes.make_filter_args(args.mode) + + if args.dry_run: + dataset = dataset.extract(filter_expr=args.filter, **filter_args) + for item in dataset: + encoded_item = DatasetItemEncoder.encode(item, dataset.categories()) + xml_item = DatasetItemEncoder.to_string(encoded_item) + print(xml_item) + return 0 + + if not args.filter: + raise CliException("Expected a filter expression ('-e' argument)") + + os.makedirs(dst_dir, exist_ok=False) + dataset.extract_project(save_dir=dst_dir, filter_expr=args.filter, + **filter_args) + + log.info("Subproject has been extracted to '%s'" % dst_dir) + + return 0 + +def build_merge_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor(help="Merge projects", + description=""" + Updates items of the current project with items + from the other project.|n + |n + Examples:|n + - Update a project with items from other project:|n + |s|smerge -p path/to/first/project path/to/other/project + """, + formatter_class=MultilineFormatter) + + parser.add_argument('other_project_dir', + help="Directory of the project to get data updates from") + parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, + help="Output directory (default: current project's dir)") + parser.add_argument('--overwrite', action='store_true', + help="Overwrite existing files in the save directory") + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + parser.set_defaults(command=merge_command) + + return parser + +def merge_command(args): + first_project = load_project(args.project_dir) + second_project = load_project(args.other_project_dir) + + dst_dir = args.dst_dir + if dst_dir: + if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): + raise CliException("Directory '%s' already exists " + "(pass --overwrite to force creation)" % dst_dir) + + first_dataset = first_project.make_dataset() + first_dataset.update(second_project.make_dataset()) + + first_dataset.save(save_dir=dst_dir) + + if dst_dir is None: + dst_dir = first_project.config.project_dir + dst_dir = osp.abspath(dst_dir) + log.info("Merge results have been saved to '%s'" % dst_dir) + + return 0 + +def build_diff_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor(help="Compare projects", + description=""" + Compares two projects.|n + |n + Examples:|n + - Compare two projects, consider bboxes matching if their IoU > 0.7,|n + |s|s|s|sprint results to Tensorboard: + |s|sdiff path/to/other/project -o diff/ -f tensorboard --iou-thresh 0.7 + """, + formatter_class=MultilineFormatter) + + parser.add_argument('other_project_dir', + help="Directory of the second project to be compared") + parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, + help="Directory to save comparison results (default: do not save)") + parser.add_argument('-f', '--format', + default=DiffVisualizer.DEFAULT_FORMAT, + choices=[f.name for f in DiffVisualizer.Format], + help="Output format (default: %(default)s)") + parser.add_argument('--iou-thresh', default=0.5, type=float, + help="IoU match threshold for detections (default: %(default)s)") + parser.add_argument('--conf-thresh', default=0.5, type=float, + help="Confidence threshold for detections (default: %(default)s)") + parser.add_argument('--overwrite', action='store_true', + help="Overwrite existing files in the save directory") + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the first project to be compared (default: current dir)") + parser.set_defaults(command=diff_command) + + return parser + +def diff_command(args): + first_project = load_project(args.project_dir) + second_project = load_project(args.other_project_dir) + + comparator = Comparator( + iou_threshold=args.iou_thresh, + conf_threshold=args.conf_thresh) + + dst_dir = args.dst_dir + if dst_dir: + if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): + raise CliException("Directory '%s' already exists " + "(pass --overwrite to force creation)" % dst_dir) + else: + dst_dir = generate_next_dir_name('%s-%s-diff' % ( + first_project.config.project_name, + second_project.config.project_name) + ) + dst_dir = osp.abspath(dst_dir) + if dst_dir: + log.info("Saving diff to '%s'" % dst_dir) + + visualizer = DiffVisualizer(save_dir=dst_dir, comparator=comparator, + output_format=args.format) + visualizer.save_dataset_diff( + first_project.make_dataset(), + second_project.make_dataset()) + + return 0 + +def build_transform_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor(help="Transform project", + description=""" + Applies some operation to dataset items in the project + and produces a new project. + + [NOT IMPLEMENTED YET] + """, + formatter_class=MultilineFormatter) + + parser.add_argument('-t', '--transform', required=True, + help="Transform to apply to the project") + parser.add_argument('-o', '--output-dir', dest='dst_dir', default=None, + help="Directory to save output (default: current dir)") + parser.add_argument('--overwrite', action='store_true', + help="Overwrite existing files in the save directory") + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + parser.set_defaults(command=transform_command) + + return parser + +def transform_command(args): + raise NotImplementedError("Not implemented yet.") + + # project = load_project(args.project_dir) + + # dst_dir = args.dst_dir + # if dst_dir: + # if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): + # raise CliException("Directory '%s' already exists " + # "(pass --overwrite to force creation)" % dst_dir) + # dst_dir = osp.abspath(args.dst_dir) + + # project.make_dataset().transform_project( + # method=args.transform, + # save_dir=dst_dir + # ) + + # log.info("Transform results saved to '%s'" % dst_dir) + + # return 0 + +def build_info_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor(help="Get project info", + description=""" + Outputs project info. + """, + formatter_class=MultilineFormatter) + + parser.add_argument('--all', action='store_true', + help="Print all information") + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + parser.set_defaults(command=info_command) + + return parser + +def info_command(args): + project = load_project(args.project_dir) + config = project.config + env = project.env + dataset = project.make_dataset() + + print("Project:") + print(" name:", config.project_name) + print(" location:", config.project_dir) + print("Plugins:") + print(" importers:", ', '.join(env.importers.items)) + print(" extractors:", ', '.join(env.extractors.items)) + print(" converters:", ', '.join(env.converters.items)) + print(" launchers:", ', '.join(env.launchers.items)) + + print("Sources:") + for source_name, source in config.sources.items(): + print(" source '%s':" % source_name) + print(" format:", source.format) + print(" url:", source.url) + print(" location:", project.local_source_dir(source_name)) + + def print_extractor_info(extractor, indent=''): + print("%slength:" % indent, len(extractor)) + + categories = extractor.categories() + print("%scategories:" % indent, ', '.join(c.name for c in categories)) + + for cat_type, cat in categories.items(): + print("%s %s:" % (indent, cat_type.name)) + if cat_type == AnnotationType.label: + print("%s count:" % indent, len(cat.items)) + + count_threshold = 10 + if args.all: + count_threshold = len(cat.items) + labels = ', '.join(c.name for c in cat.items[:count_threshold]) + if count_threshold < len(cat.items): + labels += " (and %s more)" % ( + len(cat.items) - count_threshold) + print("%s labels:" % indent, labels) + + print("Dataset:") + print_extractor_info(dataset, indent=" ") + + subsets = dataset.subsets() + print(" subsets:", ', '.join(subsets)) + for subset_name in subsets: + subset = dataset.get_subset(subset_name) + print(" subset '%s':" % subset_name) + print_extractor_info(subset, indent=" ") + + print("Models:") + for model_name, model in env.config.models.items(): + print(" model '%s':" % model_name) + print(" type:", model.launcher) + + return 0 + + +def build_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor( + description=""" + Manipulate projects.|n + |n + By default, the project to be operated on is searched for + in the current directory. An additional '-p' argument can be + passed to specify project location. + """, + formatter_class=MultilineFormatter) + + subparsers = parser.add_subparsers() + add_subparser(subparsers, 'create', build_create_parser) + add_subparser(subparsers, 'import', build_import_parser) + add_subparser(subparsers, 'export', build_export_parser) + add_subparser(subparsers, 'extract', build_extract_parser) + add_subparser(subparsers, 'merge', build_merge_parser) + add_subparser(subparsers, 'diff', build_diff_parser) + add_subparser(subparsers, 'transform', build_transform_parser) + add_subparser(subparsers, 'info', build_info_parser) + + return parser diff --git a/datumaro/datumaro/cli/project/diff.py b/datumaro/datumaro/cli/contexts/project/diff.py similarity index 100% rename from datumaro/datumaro/cli/project/diff.py rename to datumaro/datumaro/cli/contexts/project/diff.py diff --git a/datumaro/datumaro/cli/contexts/source/__init__.py b/datumaro/datumaro/cli/contexts/source/__init__.py new file mode 100644 index 00000000..b20be3de --- /dev/null +++ b/datumaro/datumaro/cli/contexts/source/__init__.py @@ -0,0 +1,247 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import argparse +import logging as log +import os +import os.path as osp +import shutil + +from ...util import add_subparser, CliException, MultilineFormatter +from ...util.project import load_project + + +def build_add_parser(parser_ctor=argparse.ArgumentParser): + import datumaro.components.extractors as extractors_module + extractors_list = [name for name, cls in extractors_module.items] + + base_parser = argparse.ArgumentParser(add_help=False) + base_parser.add_argument('-n', '--name', default=None, + help="Name of the new source") + base_parser.add_argument('-f', '--format', required=True, + help="Source dataset format") + base_parser.add_argument('--skip-check', action='store_true', + help="Skip source checking") + base_parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + + parser = parser_ctor(help="Add data source to project", + description=""" + Adds a data source to a project. The source can be:|n + - a dataset in a supported format (check 'formats' section below)|n + - a Datumaro project|n + |n + The source can be either a local directory or a remote + git repository. Each source type has its own parameters, which can + be checked by:|n + '%s'.|n + |n + Formats:|n + Datasets come in a wide variety of formats. Each dataset + format defines its own data structure and rules on how to + interpret the data. For example, the following data structure + is used in COCO format:|n + /dataset/|n + - /images/.jpg|n + - /annotations/|n + |n + In Datumaro dataset formats are supported by Extractor-s. + An Extractor produces a list of dataset items corresponding + to the dataset. It is possible to add a custom Extractor. + To do this, you need to put an Extractor + definition script to /.datumaro/extractors.|n + |n + List of supported source formats: %s|n + |n + Examples:|n + - Add a local directory with VOC-like dataset:|n + |s|sadd path path/to/voc -f voc_detection|n + - Add a local file with CVAT annotations, call it 'mysource'|n + |s|s|s|sto the project somewhere else:|n + |s|sadd path path/to/cvat.xml -f cvat -n mysource -p somewhere/else/ + """ % ('%(prog)s SOURCE_TYPE --help', ', '.join(extractors_list)), + formatter_class=MultilineFormatter, + add_help=False) + parser.set_defaults(command=add_command) + + sp = parser.add_subparsers(dest='source_type', metavar='SOURCE_TYPE', + help="The type of the data source " + "(call '%s SOURCE_TYPE --help' for more info)" % parser.prog) + + dir_parser = sp.add_parser('path', help="Add local path as source", + parents=[base_parser]) + dir_parser.add_argument('url', + help="Path to the source") + dir_parser.add_argument('--copy', action='store_true', + help="Copy the dataset instead of saving source links") + + repo_parser = sp.add_parser('git', help="Add git repository as source", + parents=[base_parser]) + repo_parser.add_argument('url', + help="URL of the source git repository") + repo_parser.add_argument('-b', '--branch', default='master', + help="Branch of the source repository (default: %(default)s)") + repo_parser.add_argument('--checkout', action='store_true', + help="Do branch checkout") + + # NOTE: add common parameters to the parent help output + # the other way could be to use parse_known_args() + display_parser = argparse.ArgumentParser( + parents=[base_parser, parser], + prog=parser.prog, usage="%(prog)s [-h] SOURCE_TYPE ...", + description=parser.description, formatter_class=MultilineFormatter) + class HelpAction(argparse._HelpAction): + def __call__(self, parser, namespace, values, option_string=None): + display_parser.print_help() + parser.exit() + + parser.add_argument('-h', '--help', action=HelpAction, + help='show this help message and exit') + + # TODO: needed distinction on how to add an extractor or a remote source + + return parser + +def add_command(args): + project = load_project(args.project_dir) + + if args.source_type == 'git': + name = args.name + if name is None: + name = osp.splitext(osp.basename(args.url))[0] + + if project.env.git.has_submodule(name): + raise CliException("Git submodule '%s' already exists" % name) + + try: + project.get_source(name) + raise CliException("Source '%s' already exists" % name) + except KeyError: + pass + + rel_local_dir = project.local_source_dir(name) + local_dir = osp.join(project.config.project_dir, rel_local_dir) + url = args.url + project.env.git.create_submodule(name, local_dir, + url=url, branch=args.branch, no_checkout=not args.checkout) + elif args.source_type == 'path': + url = osp.abspath(args.url) + if not osp.exists(url): + raise CliException("Source path '%s' does not exist" % url) + + name = args.name + if name is None: + name = osp.splitext(osp.basename(url))[0] + + if project.env.git.has_submodule(name): + raise CliException("Git submodule '%s' already exists" % name) + + try: + project.get_source(name) + raise CliException("Source '%s' already exists" % name) + except KeyError: + pass + + rel_local_dir = project.local_source_dir(name) + local_dir = osp.join(project.config.project_dir, rel_local_dir) + + if args.copy: + log.info("Copying from '%s' to '%s'" % (url, local_dir)) + if osp.isdir(url): + # copytree requires destination dir not to exist + shutil.copytree(url, local_dir) + url = rel_local_dir + elif osp.isfile(url): + os.makedirs(local_dir) + shutil.copy2(url, local_dir) + url = osp.join(rel_local_dir, osp.basename(url)) + else: + raise Exception("Expected file or directory") + else: + os.makedirs(local_dir) + + project.add_source(name, { 'url': url, 'format': args.format }) + + if not args.skip_check: + log.info("Checking the source...") + try: + project.make_source_project(name).make_dataset() + except Exception: + shutil.rmtree(local_dir, ignore_errors=True) + raise + + project.save() + + log.info("Source '%s' has been added to the project, location: '%s'" \ + % (name, rel_local_dir)) + + return 0 + +def build_remove_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor(help="Remove source from project", + description="Remove a source from a project.") + + parser.add_argument('-n', '--name', required=True, + help="Name of the source to be removed") + parser.add_argument('--force', action='store_true', + help="Ignore possible errors during removal") + parser.add_argument('--keep-data', action='store_true', + help="Do not remove source data") + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + parser.set_defaults(command=remove_command) + + return parser + +def remove_command(args): + project = load_project(args.project_dir) + + name = args.name + if not name: + raise CliException("Expected source name") + try: + project.get_source(name) + except KeyError: + if not args.force: + raise CliException("Source '%s' does not exist" % name) + + if project.env.git.has_submodule(name): + if args.force: + log.warning("Forcefully removing the '%s' source..." % name) + + project.env.git.remove_submodule(name, force=args.force) + + source_dir = osp.join(project.config.project_dir, + project.local_source_dir(name)) + project.remove_source(name) + project.save() + + if not args.keep_data: + shutil.rmtree(source_dir, ignore_errors=True) + + log.info("Source '%s' has been removed from the project" % name) + + return 0 + +def build_parser(parser_ctor=argparse.ArgumentParser): + parser = parser_ctor(description=""" + Manipulate data sources inside of a project.|n + |n + A data source is a source of data for a project. + The project combines multiple data sources into one dataset. + The role of a data source is to provide dataset items - images + and/or annotations.|n + |n + By default, the project to be operated on is searched for + in the current directory. An additional '-p' argument can be + passed to specify project location. + """, + formatter_class=MultilineFormatter) + + subparsers = parser.add_subparsers() + add_subparser(subparsers, 'add', build_add_parser) + add_subparser(subparsers, 'remove', build_remove_parser) + + return parser diff --git a/datumaro/datumaro/cli/create_command.py b/datumaro/datumaro/cli/create_command.py deleted file mode 100644 index eb52458b..00000000 --- a/datumaro/datumaro/cli/create_command.py +++ /dev/null @@ -1,21 +0,0 @@ - -# Copyright (C) 2019 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse - -from . import project as project_module - - -def build_parser(parser=argparse.ArgumentParser()): - project_module.build_create_parser(parser) \ - .set_defaults(command=project_module.create_command) - - return parser - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - - return args.command(args) diff --git a/datumaro/datumaro/cli/export_command.py b/datumaro/datumaro/cli/export_command.py deleted file mode 100644 index 3bd3efe6..00000000 --- a/datumaro/datumaro/cli/export_command.py +++ /dev/null @@ -1,69 +0,0 @@ - -# Copyright (C) 2019 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import os.path as osp - -from datumaro.components.project import Project -from datumaro.util.command_targets import (TargetKinds, target_selector, - ProjectTarget, SourceTarget, ImageTarget, ExternalDatasetTarget, - is_project_path -) - -from . import project as project_module -from . import source as source_module -from . import item as item_module - - -def export_external_dataset(target, params): - raise NotImplementedError() - -def build_parser(parser=argparse.ArgumentParser()): - parser.add_argument('target', nargs='?', default=None) - parser.add_argument('params', nargs=argparse.REMAINDER) - - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - - return parser - -def process_command(target, params, args): - project_dir = args.project_dir - target_kind, target_value = target - if target_kind == TargetKinds.project: - return project_module.main(['export', '-p', target_value] + params) - elif target_kind == TargetKinds.source: - return source_module.main(['export', '-p', project_dir, '-n', target_value] + params) - elif target_kind == TargetKinds.item: - return item_module.main(['export', '-p', project_dir, target_value] + params) - elif target_kind == TargetKinds.external_dataset: - return export_external_dataset(target_value, params) - return 1 - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - - project_path = args.project_dir - if is_project_path(project_path): - project = Project.load(project_path) - else: - project = None - try: - args.target = target_selector( - ProjectTarget(is_default=True, project=project), - SourceTarget(project=project), - ExternalDatasetTarget(), - ImageTarget() - )(args.target) - if args.target[0] == TargetKinds.project: - if is_project_path(args.target[1]): - args.project_dir = osp.dirname(osp.abspath(args.target[1])) - except argparse.ArgumentTypeError as e: - print(e) - parser.print_help() - return 1 - - return process_command(args.target, args.params, args) diff --git a/datumaro/datumaro/cli/inference/__init__.py b/datumaro/datumaro/cli/inference/__init__.py deleted file mode 100644 index f5d48b7c..00000000 --- a/datumaro/datumaro/cli/inference/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ - -# Copyright (C) 2019 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse - - -def run_command(args): - return 0 - -def build_run_parser(parser): - return parser - -def build_parser(parser=argparse.ArgumentParser()): - command_parsers = parser.add_subparsers(dest='command') - - build_run_parser(command_parsers.add_parser('run')). \ - set_defaults(command=run_command) - - return parser - -def process_command(command, args): - return 0 - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - if 'command' not in args: - parser.print_help() - return 1 - - return args.command(args) diff --git a/datumaro/datumaro/cli/item/__init__.py b/datumaro/datumaro/cli/item/__init__.py deleted file mode 100644 index 6082932a..00000000 --- a/datumaro/datumaro/cli/item/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ - -# Copyright (C) 2019 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse - - -def build_export_parser(parser): - return parser - -def build_stats_parser(parser): - return parser - -def build_diff_parser(parser): - return parser - -def build_edit_parser(parser): - return parser - -def build_parser(parser=argparse.ArgumentParser()): - command_parsers = parser.add_subparsers(dest='command_name') - - build_export_parser(command_parsers.add_parser('export')) - build_stats_parser(command_parsers.add_parser('stats')) - build_diff_parser(command_parsers.add_parser('diff')) - build_edit_parser(command_parsers.add_parser('edit')) - - return parser - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - if 'command' not in args: - parser.print_help() - return 1 - - return args.command(args) diff --git a/datumaro/datumaro/cli/project/__init__.py b/datumaro/datumaro/cli/project/__init__.py deleted file mode 100644 index 234f89d7..00000000 --- a/datumaro/datumaro/cli/project/__init__.py +++ /dev/null @@ -1,361 +0,0 @@ - -# Copyright (C) 2019 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import logging as log -import os -import os.path as osp -import shutil - -from datumaro.components.project import Project -from datumaro.components.comparator import Comparator -from datumaro.components.dataset_filter import DatasetItemEncoder -from .diff import DiffVisualizer -from ..util.project import make_project_path, load_project - - -def build_create_parser(parser): - parser.add_argument('-d', '--dest', default='.', dest='dst_dir', - help="Save directory for the new project (default: current dir") - parser.add_argument('-n', '--name', default=None, - help="Name of the new project (default: same as project dir)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - return parser - -def create_command(args): - project_dir = osp.abspath(args.dst_dir) - project_path = make_project_path(project_dir) - - if osp.isdir(project_dir) and os.listdir(project_dir): - if not args.overwrite: - log.error("Directory '%s' already exists " - "(pass --overwrite to force creation)" % project_dir) - return 1 - else: - shutil.rmtree(project_dir) - os.makedirs(project_dir, exist_ok=args.overwrite) - - if not args.overwrite and osp.isfile(project_path): - log.error("Project file '%s' already exists " - "(pass --overwrite to force creation)" % project_path) - return 1 - - project_name = args.name - if project_name is None: - project_name = osp.basename(project_dir) - - log.info("Creating project at '%s'" % (project_dir)) - - Project.generate(project_dir, { - 'project_name': project_name, - }) - - log.info("Project has been created at '%s'" % (project_dir)) - - return 0 - -def build_import_parser(parser): - import datumaro.components.importers as importers_module - importers_list = [name for name, cls in importers_module.items] - - parser.add_argument('-s', '--source', required=True, - help="Path to import a project from") - parser.add_argument('-f', '--format', required=True, - help="Source project format (options: %s)" % (', '.join(importers_list))) - parser.add_argument('-d', '--dest', default='.', dest='dst_dir', - help="Directory to save the new project to (default: current dir)") - parser.add_argument('-n', '--name', default=None, - help="Name of the new project (default: same as project dir)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('--copy', action='store_true', - help="Copy the dataset instead of saving source links") - parser.add_argument('--skip-check', action='store_true', - help="Skip source checking") - # parser.add_argument('extra_args', nargs=argparse.REMAINDER, - # help="Additional arguments for importer (pass '-- -h' for help)") - return parser - -def import_command(args): - project_dir = osp.abspath(args.dst_dir) - project_path = make_project_path(project_dir) - - if osp.isdir(project_dir) and os.listdir(project_dir): - if not args.overwrite: - log.error("Directory '%s' already exists " - "(pass --overwrite to force creation)" % project_dir) - return 1 - else: - shutil.rmtree(project_dir) - os.makedirs(project_dir, exist_ok=args.overwrite) - - if not args.overwrite and osp.isfile(project_path): - log.error("Project file '%s' already exists " - "(pass --overwrite to force creation)" % project_path) - return 1 - - project_name = args.name - if project_name is None: - project_name = osp.basename(project_dir) - - log.info("Importing project from '%s' as '%s'" % \ - (args.source, args.format)) - - source = osp.abspath(args.source) - project = Project.import_from(source, args.format) - project.config.project_name = project_name - project.config.project_dir = project_dir - - if not args.skip_check or args.copy: - log.info("Checking the dataset...") - dataset = project.make_dataset() - if args.copy: - log.info("Cloning data...") - dataset.save(merge=True, save_images=True) - else: - project.save() - - log.info("Project has been created at '%s'" % (project_dir)) - - return 0 - -def build_build_parser(parser): - return parser - -def build_export_parser(parser): - parser.add_argument('-e', '--filter', default=None, - help="Filter expression for dataset items. Examples: " - "extract images with width < height: " - "'/item[image/width < image/height]'; " - "extract images with large-area bboxes: " - "'/item[annotation/type=\"bbox\" and annotation/area>2000]'" - "filter out irrelevant annotations from items: " - "'/item/annotation[label = \"person\"]'" - ) - parser.add_argument('-a', '--filter-annotations', action='store_true', - help="Filter annotations instead of dataset " - "items (default: %(default)s)") - parser.add_argument('-d', '--dest', dest='dst_dir', required=True, - help="Directory to save output") - parser.add_argument('-f', '--output-format', required=True, - help="Output format") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('extra_args', nargs=argparse.REMAINDER, default=None, - help="Additional arguments for converter (pass '-- -h' for help)") - return parser - -def export_command(args): - project = load_project(args.project_dir) - - dst_dir = osp.abspath(args.dst_dir) - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - log.error("Directory '%s' already exists " - "(pass --overwrite to force creation)" % dst_dir) - return 1 - os.makedirs(dst_dir, exist_ok=args.overwrite) - - log.info("Loading the project...") - dataset = project.make_dataset() - - log.info("Exporting the project...") - dataset.export_project( - save_dir=dst_dir, - output_format=args.output_format, - filter_expr=args.filter, - filter_annotations=args.filter_annotations, - cmdline_args=args.extra_args) - log.info("Project exported to '%s' as '%s'" % \ - (dst_dir, args.output_format)) - - return 0 - -def build_stats_parser(parser): - parser.add_argument('name') - return parser - -def build_docs_parser(parser): - return parser - -def build_extract_parser(parser): - parser.add_argument('-e', '--filter', default=None, - help="XML XPath filter expression for dataset items. Examples: " - "extract images with width < height: " - "'/item[image/width < image/height]'; " - "extract images with large-area bboxes: " - "'/item[annotation/type=\"bbox\" and annotation/area>2000]' " - "filter out irrelevant annotations from items: " - "'/item/annotation[label = \"person\"]'" - ) - parser.add_argument('-a', '--filter-annotations', action='store_true', - help="Filter annotations instead of dataset " - "items (default: %(default)s)") - parser.add_argument('--remove-empty', action='store_true', - help="Remove an item if there are no annotations left after filtration") - parser.add_argument('--dry-run', action='store_true', - help="Print XML representations to be filtered and exit") - parser.add_argument('-d', '--dest', dest='dst_dir', required=True, - help="Output directory") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - return parser - -def extract_command(args): - project = load_project(args.project_dir) - - dst_dir = osp.abspath(args.dst_dir) - if not args.dry_run: - os.makedirs(dst_dir, exist_ok=False) - - dataset = project.make_dataset() - - kwargs = {} - if args.filter_annotations: - kwargs['remove_empty'] = args.remove_empty - - if args.dry_run: - dataset = dataset.extract(filter_expr=args.filter, - filter_annotations=args.filter_annotations, **kwargs) - for item in dataset: - encoded_item = DatasetItemEncoder.encode(item, dataset.categories()) - xml_item = DatasetItemEncoder.to_string(encoded_item) - print(xml_item) - return 0 - - dataset.extract_project(save_dir=dst_dir, filter_expr=args.filter, - filter_annotations=args.filter_annotations, **kwargs) - - log.info("Subproject extracted to '%s'" % (dst_dir)) - - return 0 - -def build_merge_parser(parser): - parser.add_argument('other_project_dir', - help="Directory of the project to get data updates from") - parser.add_argument('-d', '--dest', dest='dst_dir', default=None, - help="Output directory (default: current project's dir)") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - return parser - -def merge_command(args): - first_project = load_project(args.project_dir) - second_project = load_project(args.other_project_dir) - - first_dataset = first_project.make_dataset() - first_dataset.update(second_project.make_dataset()) - - dst_dir = args.dst_dir - first_dataset.save(save_dir=dst_dir) - - if dst_dir is None: - dst_dir = first_project.config.project_dir - dst_dir = osp.abspath(dst_dir) - log.info("Merge result saved to '%s'" % (dst_dir)) - - return 0 - -def build_diff_parser(parser): - parser.add_argument('other_project_dir', - help="Directory of the second project to be compared") - parser.add_argument('-d', '--dest', default=None, dest='dst_dir', - help="Directory to save comparison results (default: do not save)") - parser.add_argument('-f', '--output-format', - default=DiffVisualizer.DEFAULT_FORMAT, - choices=[f.name for f in DiffVisualizer.Format], - help="Output format (default: %(default)s)") - parser.add_argument('--iou-thresh', default=0.5, type=float, - help="IoU match threshold for detections (default: %(default)s)") - parser.add_argument('--conf-thresh', default=0.5, type=float, - help="Confidence threshold for detections (default: %(default)s)") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the first project to be compared (default: current dir)") - return parser - -def diff_command(args): - first_project = load_project(args.project_dir) - second_project = load_project(args.other_project_dir) - - comparator = Comparator( - iou_threshold=args.iou_thresh, - conf_threshold=args.conf_thresh) - - save_dir = args.dst_dir - if save_dir is not None: - log.info("Saving diff to '%s'" % save_dir) - os.makedirs(osp.abspath(save_dir)) - visualizer = DiffVisualizer(save_dir=save_dir, comparator=comparator, - output_format=args.output_format) - visualizer.save_dataset_diff( - first_project.make_dataset(), - second_project.make_dataset()) - - return 0 - -def build_transform_parser(parser): - parser.add_argument('-d', '--dest', dest='dst_dir', required=True, - help="Directory to save output") - parser.add_argument('-m', '--model', dest='model_name', required=True, - help="Model to apply to the project") - parser.add_argument('-f', '--output-format', required=True, - help="Output format") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - return parser - -def transform_command(args): - project = load_project(args.project_dir) - - dst_dir = osp.abspath(args.dst_dir) - os.makedirs(dst_dir, exist_ok=False) - project.make_dataset().apply_model( - save_dir=dst_dir, - model_name=args.model_name) - - log.info("Transform results saved to '%s'" % (dst_dir)) - - return 0 - - -def build_parser(parser=argparse.ArgumentParser()): - command_parsers = parser.add_subparsers(dest='command_name') - - build_create_parser(command_parsers.add_parser('create')) \ - .set_defaults(command=create_command) - - build_import_parser(command_parsers.add_parser('import')) \ - .set_defaults(command=import_command) - - build_export_parser(command_parsers.add_parser('export')) \ - .set_defaults(command=export_command) - - build_extract_parser(command_parsers.add_parser('extract')) \ - .set_defaults(command=extract_command) - - build_merge_parser(command_parsers.add_parser('merge')) \ - .set_defaults(command=merge_command) - - build_build_parser(command_parsers.add_parser('build')) - build_stats_parser(command_parsers.add_parser('stats')) - build_docs_parser(command_parsers.add_parser('docs')) - build_diff_parser(command_parsers.add_parser('diff')) \ - .set_defaults(command=diff_command) - - build_transform_parser(command_parsers.add_parser('transform')) \ - .set_defaults(command=transform_command) - - return parser - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - if 'command' not in args: - parser.print_help() - return 1 - - return args.command(args) diff --git a/datumaro/datumaro/cli/remove_command.py b/datumaro/datumaro/cli/remove_command.py deleted file mode 100644 index f419cd3a..00000000 --- a/datumaro/datumaro/cli/remove_command.py +++ /dev/null @@ -1,21 +0,0 @@ - -# Copyright (C) 2019 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse - -from . import source as source_module - - -def build_parser(parser=argparse.ArgumentParser()): - source_module.build_add_parser(parser). \ - set_defaults(command=source_module.remove_command) - - return parser - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - - return args.command(args) diff --git a/datumaro/datumaro/cli/source/__init__.py b/datumaro/datumaro/cli/source/__init__.py deleted file mode 100644 index 8fa3364b..00000000 --- a/datumaro/datumaro/cli/source/__init__.py +++ /dev/null @@ -1,254 +0,0 @@ - -# Copyright (C) 2019 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import logging as log -import os -import os.path as osp -import shutil - -from ..util.project import load_project - - -def build_create_parser(parser): - parser.add_argument('-n', '--name', required=True, - help="Name of the source to be created") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - return parser - -def create_command(args): - project = load_project(args.project_dir) - config = project.config - - name = args.name - - if project.env.git.has_submodule(name): - log.fatal("Submodule '%s' already exists" % (name)) - return 1 - - try: - project.get_source(name) - log.fatal("Source '%s' already exists" % (name)) - return 1 - except KeyError: - pass - - dst_dir = osp.join(config.project_dir, config.sources_dir, name) - project.env.git.init(dst_dir) - - project.add_source(name, { 'url': name }) - project.save() - - log.info("Source '%s' has been added to the project, location: '%s'" \ - % (name, dst_dir)) - - return 0 - -def build_import_parser(parser): - sp = parser.add_subparsers(dest='source_type') - - repo_parser = sp.add_parser('repo') - repo_parser.add_argument('url', - help="URL of the source git repository") - repo_parser.add_argument('-b', '--branch', default='master', - help="Branch of the source repository (default: %(default)s)") - repo_parser.add_argument('--checkout', action='store_true', - help="Do branch checkout") - - dir_parser = sp.add_parser('dir') - dir_parser.add_argument('url', - help="Path to the source directory") - dir_parser.add_argument('--copy', action='store_true', - help="Copy the dataset instead of saving source links") - - parser.add_argument('-n', '--name', default=None, - help="Name of the new source") - parser.add_argument('-f', '--format', default=None, - help="Name of the source dataset format (default: 'project')") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.add_argument('--skip-check', action='store_true', - help="Skip source checking") - return parser - -def import_command(args): - project = load_project(args.project_dir) - - if args.source_type == 'repo': - name = args.name - if name is None: - name = osp.splitext(osp.basename(args.url))[0] - - if project.env.git.has_submodule(name): - log.fatal("Submodule '%s' already exists" % (name)) - return 1 - - try: - project.get_source(name) - log.fatal("Source '%s' already exists" % (name)) - return 1 - except KeyError: - pass - - dst_dir = project.local_source_dir(name) - project.env.git.create_submodule(name, dst_dir, - url=args.url, branch=args.branch, no_checkout=not args.checkout) - - source = { 'url': args.url } - if args.format: - source['format'] = args.format - project.add_source(name, source) - - if not args.skip_check: - log.info("Checking the source...") - project.make_source_project(name) - project.save() - - log.info("Source '%s' has been added to the project, location: '%s'" \ - % (name, dst_dir)) - elif args.source_type == 'dir': - url = osp.abspath(args.url) - if not osp.exists(url): - log.fatal("Source path '%s' does not exist" % url) - return 1 - - name = args.name - if name is None: - name = osp.splitext(osp.basename(url))[0] - - try: - project.get_source(name) - log.fatal("Source '%s' already exists" % (name)) - return 1 - except KeyError: - pass - - dst_dir = url - if args.copy: - dst_dir = project.local_source_dir(name) - log.info("Copying from '%s' to '%s'" % (url, dst_dir)) - shutil.copytree(url, dst_dir) - url = name - - source = { 'url': url } - if args.format: - source['format'] = args.format - project.add_source(name, source) - - if not args.skip_check: - log.info("Checking the source...") - project.make_source_project(name) - project.save() - - log.info("Source '%s' has been added to the project, location: '%s'" \ - % (name, dst_dir)) - - return 0 - -def build_remove_parser(parser): - parser.add_argument('-n', '--name', required=True, - help="Name of the source to be removed") - parser.add_argument('--force', action='store_true', - help="Ignore possible errors during removal") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - return parser - -def remove_command(args): - project = load_project(args.project_dir) - - name = args.name - if name is None: - log.fatal("Expected source name") - return - - if project.env.git.has_submodule(name): - if args.force: - log.warning("Forcefully removing the '%s' source..." % (name)) - - project.env.git.remove_submodule(name, force=args.force) - - project.remove_source(name) - project.save() - - log.info("Source '%s' has been removed from the project" % (name)) - - return 0 - -def build_export_parser(parser): - parser.add_argument('-n', '--name', required=True, - help="Source dataset to be extracted") - parser.add_argument('-e', '--filter', default=None, - help="Filter expression for dataset items. Examples: " - "extract images with width < height: " - "'/item[image/width < image/height]'; " - "extract images with large-area bboxes: " - "'/item[annotation/type=\"bbox\" and annotation/area>2000]'" - ) - parser.add_argument('-a', '--filter-annotations', action='store_true', - help="Filter annotations instead of dataset " - "items (default: %(default)s)") - parser.add_argument('-d', '--dest', dest='dst_dir', required=True, - help="Directory to save output") - parser.add_argument('-f', '--output-format', required=True, - help="Output format") - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - parser.add_argument('--overwrite', action='store_true', - help="Overwrite existing files in the save directory") - parser.add_argument('extra_args', nargs=argparse.REMAINDER, default=None, - help="Additional arguments for converter (pass '-- -h' for help)") - return parser - -def export_command(args): - project = load_project(args.project_dir) - - dst_dir = osp.abspath(args.dst_dir) - if not args.overwrite and osp.isdir(dst_dir) and os.listdir(dst_dir): - log.error("Directory '%s' already exists " - "(pass --overwrite to force creation)" % dst_dir) - return 1 - os.makedirs(dst_dir, exist_ok=args.overwrite) - - log.info("Loading the project...") - source_project = project.make_source_project(args.name) - dataset = source_project.make_dataset() - - log.info("Exporting the project...") - dataset.export_project( - save_dir=dst_dir, - output_format=args.output_format, - filter_expr=args.filter, - filter_annotations=args.filter_annotations, - cmdline_args=args.extra_args) - log.info("Source '%s' exported to '%s' as '%s'" % \ - (args.name, dst_dir, args.output_format)) - - return 0 - -def build_parser(parser=argparse.ArgumentParser()): - command_parsers = parser.add_subparsers(dest='command_name') - - build_create_parser(command_parsers.add_parser('create')) \ - .set_defaults(command=create_command) - build_import_parser(command_parsers.add_parser('import')) \ - .set_defaults(command=import_command) - build_remove_parser(command_parsers.add_parser('remove')) \ - .set_defaults(command=remove_command) - build_export_parser(command_parsers.add_parser('export')) \ - .set_defaults(command=export_command) - - return parser - - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - if 'command' not in args: - parser.print_help() - return 1 - - return args.command(args) diff --git a/datumaro/datumaro/cli/stats_command.py b/datumaro/datumaro/cli/stats_command.py deleted file mode 100644 index 333883de..00000000 --- a/datumaro/datumaro/cli/stats_command.py +++ /dev/null @@ -1,69 +0,0 @@ - -# Copyright (C) 2019 Intel Corporation -# -# SPDX-License-Identifier: MIT - -import argparse -import os.path as osp - -from datumaro.components.project import Project -from datumaro.util.command_targets import (TargetKinds, target_selector, - ProjectTarget, SourceTarget, ExternalDatasetTarget, ImageTarget, - is_project_path -) - -from . import project as project_module -from . import source as source_module -from . import item as item_module - - -def compute_external_dataset_stats(target, params): - raise NotImplementedError() - -def build_parser(parser=argparse.ArgumentParser()): - parser.add_argument('target', nargs='?', default=None) - parser.add_argument('params', nargs=argparse.REMAINDER) - - parser.add_argument('-p', '--project', dest='project_dir', default='.', - help="Directory of the project to operate on (default: current dir)") - - return parser - -def process_command(target, params, args): - project_dir = args.project_dir - target_kind, target_value = target - if target_kind == TargetKinds.project: - return project_module.main(['stats', '-p', target_value] + params) - elif target_kind == TargetKinds.source: - return source_module.main(['stats', '-p', project_dir, target_value] + params) - elif target_kind == TargetKinds.item: - return item_module.main(['stats', '-p', project_dir, target_value] + params) - elif target_kind == TargetKinds.external_dataset: - return compute_external_dataset_stats(target_value, params) - return 1 - -def main(args=None): - parser = build_parser() - args = parser.parse_args(args) - - project_path = args.project_dir - if is_project_path(project_path): - project = Project.load(project_path) - else: - project = None - try: - args.target = target_selector( - ProjectTarget(is_default=True, project=project), - SourceTarget(project=project), - ExternalDatasetTarget(), - ImageTarget() - )(args.target) - if args.target[0] == TargetKinds.project: - if is_project_path(args.target[1]): - args.project_dir = osp.dirname(osp.abspath(args.target[1])) - except argparse.ArgumentTypeError as e: - print(e) - parser.print_help() - return 1 - - return process_command(args.target, args.params, args) diff --git a/datumaro/datumaro/cli/util/__init__.py b/datumaro/datumaro/cli/util/__init__.py index a9773073..49319983 100644 --- a/datumaro/datumaro/cli/util/__init__.py +++ b/datumaro/datumaro/cli/util/__init__.py @@ -3,3 +3,36 @@ # # SPDX-License-Identifier: MIT +import argparse +import textwrap + + +class CliException(Exception): pass + +def add_subparser(subparsers, name, builder): + return builder(lambda **kwargs: subparsers.add_parser(name, **kwargs)) + +class MultilineFormatter(argparse.HelpFormatter): + """ + Keeps line breaks introduced with '|n' separator + and spaces introduced with '|s'. + """ + + def __init__(self, keep_natural=False, **kwargs): + super().__init__(**kwargs) + self._keep_natural = keep_natural + + def _fill_text(self, text, width, indent): + text = self._whitespace_matcher.sub(' ', text).strip() + text = text.replace('|s', ' ') + + paragraphs = text.split('|n ') + if self._keep_natural: + paragraphs = sum((p.split('\n ') for p in paragraphs), []) + + multiline_text = '' + for paragraph in paragraphs: + formatted_paragraph = textwrap.fill(paragraph, width, + initial_indent=indent, subsequent_indent=indent) + '\n' + multiline_text += formatted_paragraph + return multiline_text diff --git a/datumaro/datumaro/cli/util/project.py b/datumaro/datumaro/cli/util/project.py index 6e1f5e65..dde4531a 100644 --- a/datumaro/datumaro/cli/util/project.py +++ b/datumaro/datumaro/cli/util/project.py @@ -3,6 +3,7 @@ # # SPDX-License-Identifier: MIT +import os import os.path as osp from datumaro.components.project import Project, \ @@ -17,4 +18,26 @@ def make_project_path(project_dir, project_filename=None): def load_project(project_dir, project_filename=None): if project_filename: project_dir = osp.join(project_dir, project_filename) - return Project.load(project_dir) \ No newline at end of file + return Project.load(project_dir) + +def generate_next_dir_name(dirname, basedir='.', sep='.'): + """ + If basedir does not contain dirname, returns dirname itself, + else generates a dirname by appending separator to the dirname + and the number, next to the last used number in the basedir for + files with dirname prefix. + """ + + def _to_int(s): + try: + return int(s) + except Exception: + return 0 + sep_count = dirname.count(sep) + 2 + + files = [e for e in os.listdir(basedir) if e.startswith(dirname)] + if files: + files = [e.split(sep) for e in files] + files = [_to_int(e[-1]) for e in files if len(e) == sep_count] + dirname += '%s%s' % (sep, max(files, default=0) + 1) + return dirname \ No newline at end of file diff --git a/datumaro/datumaro/components/algorithms/rise.py b/datumaro/datumaro/components/algorithms/rise.py index 8e75f10a..277bedd2 100644 --- a/datumaro/datumaro/components/algorithms/rise.py +++ b/datumaro/datumaro/components/algorithms/rise.py @@ -8,7 +8,7 @@ import numpy as np from math import ceil -from datumaro.components.extractor import * +from datumaro.components.extractor import AnnotationType def flatmatvec(mat): diff --git a/datumaro/datumaro/components/converters/__init__.py b/datumaro/datumaro/components/converters/__init__.py index e2f5e3d8..0991ed29 100644 --- a/datumaro/datumaro/components/converters/__init__.py +++ b/datumaro/datumaro/components/converters/__init__.py @@ -5,7 +5,7 @@ from datumaro.components.converters.datumaro import DatumaroConverter -from datumaro.components.converters.ms_coco import ( +from datumaro.components.converters.coco import ( CocoConverter, CocoImageInfoConverter, CocoCaptionsConverter, diff --git a/datumaro/datumaro/components/converters/ms_coco.py b/datumaro/datumaro/components/converters/coco.py similarity index 95% rename from datumaro/datumaro/components/converters/ms_coco.py rename to datumaro/datumaro/components/converters/coco.py index e6a3b12a..f2017a19 100644 --- a/datumaro/datumaro/components/converters/ms_coco.py +++ b/datumaro/datumaro/components/converters/coco.py @@ -14,9 +14,9 @@ import pycocotools.mask as mask_utils from datumaro.components.converter import Converter from datumaro.components.extractor import ( - DEFAULT_SUBSET_NAME, AnnotationType, PointsObject, BboxObject, MaskObject + DEFAULT_SUBSET_NAME, AnnotationType, PointsObject, MaskObject ) -from datumaro.components.formats.ms_coco import CocoTask, CocoPath +from datumaro.components.formats.coco import CocoTask, CocoPath from datumaro.util import find from datumaro.util.image import save_image import datumaro.util.mask_tools as mask_tools @@ -139,7 +139,10 @@ class _CaptionsConverter(_TaskConverter): 'caption': ann.caption, } if 'score' in ann.attributes: - elem['score'] = float(ann.attributes['score']) + try: + elem['score'] = float(ann.attributes['score']) + except Exception as e: + log.warning("Failed to convert attribute 'score': %e" % e) self.annotations.append(elem) @@ -202,7 +205,7 @@ class _InstancesConverter(_TaskConverter): polygons = [p.get_polygon() for p in polygons] if self._context._segmentation_mode == SegmentationMode.guess: - use_masks = leader.attributes.get('is_crowd', + use_masks = True == leader.attributes.get('is_crowd', find(masks, lambda x: x.label == leader.label) is not None) elif self._context._segmentation_mode == SegmentationMode.polygons: use_masks = False @@ -342,7 +345,10 @@ class _InstancesConverter(_TaskConverter): 'iscrowd': int(is_crowd), } if 'score' in ann.attributes: - elem['score'] = float(ann.attributes['score']) + try: + elem['score'] = float(ann.attributes['score']) + except Exception as e: + log.warning("Failed to convert attribute 'score': %e" % e) return elem @@ -448,7 +454,10 @@ class _LabelsConverter(_TaskConverter): 'category_id': int(ann.label) + 1, } if 'score' in ann.attributes: - elem['score'] = float(ann.attributes['score']) + try: + elem['score'] = float(ann.attributes['score']) + except Exception as e: + log.warning("Failed to convert attribute 'score': %e" % e) self.annotations.append(elem) @@ -570,7 +579,7 @@ class CocoConverter(Converter): def build_cmdline_parser(cls, parser=None): import argparse if not parser: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(prog='coco') parser.add_argument('--save-images', action='store_true', help="Save images (default: %(default)s)") diff --git a/datumaro/datumaro/components/converters/cvat.py b/datumaro/datumaro/components/converters/cvat.py index 242af837..475bc0b9 100644 --- a/datumaro/datumaro/components/converters/cvat.py +++ b/datumaro/datumaro/components/converters/cvat.py @@ -14,6 +14,14 @@ from datumaro.components.formats.cvat import CvatPath from datumaro.util.image import save_image +def _cast(value, type_conv, default=None): + if value is None: + return default + try: + return type_conv(value) + except Exception: + return default + def pairwise(iterable): a = iter(iterable) return zip(a, a) @@ -261,6 +269,8 @@ class _SubsetWriter: raise NotImplementedError("unknown shape type") for attr_name, attr_value in shape.attributes.items(): + if isinstance(attr_value, bool): + attr_value = 'true' if attr_value else 'false' if attr_name in self._get_label(shape.label).attributes: self._writer.add_attribute(OrderedDict([ ("name", str(attr_name)), @@ -325,7 +335,7 @@ class CvatConverter(Converter): def build_cmdline_parser(cls, parser=None): import argparse if not parser: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(prog='cvat') parser.add_argument('--save-images', action='store_true', help="Save images (default: %(default)s)") diff --git a/datumaro/datumaro/components/converters/datumaro.py b/datumaro/datumaro/components/converters/datumaro.py index 246d1911..635817d4 100644 --- a/datumaro/datumaro/components/converters/datumaro.py +++ b/datumaro/datumaro/components/converters/datumaro.py @@ -287,7 +287,7 @@ class DatumaroConverter(Converter): def build_cmdline_parser(cls, parser=None): import argparse if not parser: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(prog='datumaro') parser.add_argument('--save-images', action='store_true', help="Save images (default: %(default)s)") diff --git a/datumaro/datumaro/components/converters/tfrecord.py b/datumaro/datumaro/components/converters/tfrecord.py index 7d6c5c19..72b9c95c 100644 --- a/datumaro/datumaro/components/converters/tfrecord.py +++ b/datumaro/datumaro/components/converters/tfrecord.py @@ -10,6 +10,7 @@ import os.path as osp import string from datumaro.components.extractor import AnnotationType, DEFAULT_SUBSET_NAME +from datumaro.components.converter import Converter from datumaro.components.formats.tfrecord import DetectionApiPath from datumaro.util.image import encode_image from datumaro.util.tf_util import import_tf as _import_tf @@ -97,7 +98,7 @@ def _make_tf_example(item, get_label_id, get_label, save_images=False): return tf_example -class DetectionApiConverter: +class DetectionApiConverter(Converter): def __init__(self, save_images=False, cmdline_args=None): super().__init__() @@ -113,7 +114,7 @@ class DetectionApiConverter: def build_cmdline_parser(cls, parser=None): import argparse if not parser: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(prog='tf_detection_api') parser.add_argument('--save-images', action='store_true', help="Save images (default: %(default)s)") diff --git a/datumaro/datumaro/components/converters/voc.py b/datumaro/datumaro/components/converters/voc.py index 18c99783..0d1eec2a 100644 --- a/datumaro/datumaro/components/converters/voc.py +++ b/datumaro/datumaro/components/converters/voc.py @@ -23,6 +23,19 @@ from datumaro.util.image import save_image from datumaro.util.mask_tools import apply_colormap, remap_mask +def _convert_attr(name, attributes, type_conv, default=None, warn=True): + d = object() + value = attributes.get(name, d) + if value is d: + return default + + try: + return type_conv(value) + except Exception as e: + log.warning("Failed to convert attribute '%s'='%s': %s" % \ + (name, value, e)) + return default + def _write_xml_bbox(bbox, parent_elem): x, y, w, h = bbox bbox_elem = ET.SubElement(parent_elem, 'bndbox') @@ -185,26 +198,17 @@ class _Converter: obj_label = self.get_label(obj.label) ET.SubElement(obj_elem, 'name').text = obj_label - pose = attr.get('pose') - if pose is not None: - pose = VocPose[pose] - else: - pose = VocPose.Unspecified + pose = _convert_attr('pose', attr, lambda v: VocPose[v], + VocPose.Unspecified) ET.SubElement(obj_elem, 'pose').text = pose.name - truncated = attr.get('truncated') - if truncated is not None: - truncated = int(truncated) - else: - truncated = 0 - ET.SubElement(obj_elem, 'truncated').text = '%d' % truncated + truncated = _convert_attr('truncated', attr, int, 0) + ET.SubElement(obj_elem, 'truncated').text = \ + '%d' % truncated - difficult = attr.get('difficult') - if difficult is not None: - difficult = int(difficult) - else: - difficult = 0 - ET.SubElement(obj_elem, 'difficult').text = '%d' % difficult + difficult = _convert_attr('difficult', attr, int, 0) + ET.SubElement(obj_elem, 'difficult').text = \ + '%d' % difficult bbox = obj.get_bbox() if bbox is not None: @@ -219,16 +223,16 @@ class _Converter: objects_with_parts.append(new_obj_id) - actions = {k: v for k, v in obj.attributes.items() - if self._is_action(obj_label, k)} + label_actions = self._get_actions(obj_label) actions_elem = ET.Element('actions') - for action in self._get_actions(obj_label): - presented = action in actions and actions[action] + for action in label_actions: + presented = _convert_attr(action, attr, + lambda v: int(v == True), 0) ET.SubElement(actions_elem, action).text = \ '%d' % presented objects_with_actions[new_obj_id][action] = presented - if len(actions) != 0: + if len(actions_elem) != 0: obj_elem.append(actions_elem) if set(self._tasks) & set([None, @@ -502,7 +506,7 @@ class VocConverter(Converter): def build_cmdline_parser(cls, parser=None): import argparse if not parser: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(prog='voc') parser.add_argument('--save-images', action='store_true', help="Save images (default: %(default)s)") diff --git a/datumaro/datumaro/components/converters/yolo.py b/datumaro/datumaro/components/converters/yolo.py index cf0d1db7..a25c7b04 100644 --- a/datumaro/datumaro/components/converters/yolo.py +++ b/datumaro/datumaro/components/converters/yolo.py @@ -41,7 +41,7 @@ class YoloConverter(Converter): def build_cmdline_parser(cls, parser=None): import argparse if not parser: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(prog='yolo') parser.add_argument('--save-images', action='store_true', help="Save images (default: %(default)s)") diff --git a/datumaro/datumaro/components/dataset_filter.py b/datumaro/datumaro/components/dataset_filter.py index a32b5df6..73c7ce81 100644 --- a/datumaro/datumaro/components/dataset_filter.py +++ b/datumaro/datumaro/components/dataset_filter.py @@ -57,6 +57,8 @@ class DatasetItemEncoder: @staticmethod def _get_label(label_id, categories): label = '' + if label_id is None: + return '' if categories is not None: label_cat = categories.get(AnnotationType.label) if label_cat is not None: diff --git a/datumaro/datumaro/components/extractor.py b/datumaro/datumaro/components/extractor.py index 8c07cfe3..afc221ac 100644 --- a/datumaro/datumaro/components/extractor.py +++ b/datumaro/datumaro/components/extractor.py @@ -213,7 +213,7 @@ class MaskObject(Annotation): (self.label == other.label) and \ (self.z_order == other.z_order) and \ (self.image is not None and other.image is not None and \ - np.all(self.image == other.image)) + np.array_equal(self.image, other.image)) class RleMask(MaskObject): # pylint: disable=redefined-builtin @@ -546,7 +546,7 @@ class DatasetItem: (self.annotations == other.annotations) and \ (self.path == other.path) and \ (self.has_image == other.has_image) and \ - (self.has_image and np.all(self.image == other.image) or \ + (self.has_image and np.array_equal(self.image, other.image) or \ not self.has_image) class IExtractor: diff --git a/datumaro/datumaro/components/extractors/__init__.py b/datumaro/datumaro/components/extractors/__init__.py index 6e5f323b..0b7a1947 100644 --- a/datumaro/datumaro/components/extractors/__init__.py +++ b/datumaro/datumaro/components/extractors/__init__.py @@ -5,7 +5,7 @@ from datumaro.components.extractors.datumaro import DatumaroExtractor -from datumaro.components.extractors.ms_coco import ( +from datumaro.components.extractors.coco import ( CocoImageInfoExtractor, CocoCaptionsExtractor, CocoInstancesExtractor, @@ -29,6 +29,7 @@ from datumaro.components.extractors.voc import ( from datumaro.components.extractors.yolo import YoloExtractor from datumaro.components.extractors.tfrecord import DetectionApiExtractor from datumaro.components.extractors.cvat import CvatExtractor +from datumaro.components.extractors.image_dir import ImageDirExtractor items = [ ('datumaro', DatumaroExtractor), @@ -56,4 +57,6 @@ items = [ ('tf_detection_api', DetectionApiExtractor), ('cvat', CvatExtractor), + + ('image_dir', ImageDirExtractor), ] \ No newline at end of file diff --git a/datumaro/datumaro/components/extractors/ms_coco.py b/datumaro/datumaro/components/extractors/coco.py similarity index 98% rename from datumaro/datumaro/components/extractors/ms_coco.py rename to datumaro/datumaro/components/extractors/coco.py index f6d1f9e1..05404f21 100644 --- a/datumaro/datumaro/components/extractors/ms_coco.py +++ b/datumaro/datumaro/components/extractors/coco.py @@ -15,7 +15,7 @@ from datumaro.components.extractor import (Extractor, DatasetItem, BboxObject, CaptionObject, LabelCategories, PointsCategories ) -from datumaro.components.formats.ms_coco import CocoTask, CocoPath +from datumaro.components.formats.coco import CocoTask, CocoPath from datumaro.util.image import lazy_image diff --git a/datumaro/datumaro/components/extractors/cvat.py b/datumaro/datumaro/components/extractors/cvat.py index 200fe88e..e3c869c4 100644 --- a/datumaro/datumaro/components/extractors/cvat.py +++ b/datumaro/datumaro/components/extractors/cvat.py @@ -91,7 +91,15 @@ class CvatExtractor(Extractor): shape.update(image) elif ev == 'end': if el.tag == 'attribute' and shape is not None: - shape['attributes'][el.attrib['name']] = el.text + attr_value = el.text + if el.text in ['true', 'false']: + attr_value = attr_value == 'true' + else: + try: + attr_value = float(attr_value) + except Exception: + pass + shape['attributes'][el.attrib['name']] = attr_value elif el.tag in cls._SUPPORTED_SHAPES: if track is not None: shape['frame'] = el.attrib['frame'] diff --git a/datumaro/datumaro/components/extractors/image_dir.py b/datumaro/datumaro/components/extractors/image_dir.py new file mode 100644 index 00000000..561fa9d8 --- /dev/null +++ b/datumaro/datumaro/components/extractors/image_dir.py @@ -0,0 +1,55 @@ + +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from collections import OrderedDict +import os +import os.path as osp + +from datumaro.components.extractor import DatasetItem, Extractor +from datumaro.util.image import lazy_image + + +class ImageDirExtractor(Extractor): + _SUPPORTED_FORMATS = ['.png', '.jpg'] + + def __init__(self, url): + super().__init__() + + assert osp.isdir(url) + + items = [] + for name in os.listdir(url): + path = osp.join(url, name) + if self._is_image(path): + item_id = osp.splitext(name)[0] + item = DatasetItem(id=item_id, image=lazy_image(path)) + items.append((item.id, item)) + + items = sorted(items, key=lambda e: e[0]) + items = OrderedDict(items) + self._items = items + + self._subsets = None + + def __iter__(self): + for item in self._items.values(): + yield item + + def __len__(self): + return len(self._items) + + def subsets(self): + return self._subsets + + def get(self, item_id, subset=None, path=None): + if path or subset: + raise KeyError() + return self._items[item_id] + + def _is_image(self, path): + for ext in self._SUPPORTED_FORMATS: + if osp.isfile(path) and path.endswith(ext): + return True + return False diff --git a/datumaro/datumaro/components/extractors/voc.py b/datumaro/datumaro/components/extractors/voc.py index f1ad0712..086649f5 100644 --- a/datumaro/datumaro/components/extractors/voc.py +++ b/datumaro/datumaro/components/extractors/voc.py @@ -230,6 +230,8 @@ class VocExtractor(Extractor): if self._task is not VocTask.person_layout: break + if bbox is None: + continue item_annotations.append(BboxObject( *bbox, label=part_label_id, group=obj_id)) @@ -247,16 +249,16 @@ class VocExtractor(Extractor): @staticmethod def _parse_bbox(object_elem): - try: - bbox_elem = object_elem.find('bndbox') - xmin = int(bbox_elem.find('xmin').text) - xmax = int(bbox_elem.find('xmax').text) - ymin = int(bbox_elem.find('ymin').text) - ymax = int(bbox_elem.find('ymax').text) - return [xmin, ymin, xmax - xmin, ymax - ymin] - except Exception: + bbox_elem = object_elem.find('bndbox') + if bbox_elem is None: return None + xmin = float(bbox_elem.find('xmin').text) + xmax = float(bbox_elem.find('xmax').text) + ymin = float(bbox_elem.find('ymin').text) + ymax = float(bbox_elem.find('ymax').text) + return [xmin, ymin, xmax - xmin, ymax - ymin] + class VocClassificationExtractor(VocExtractor): def __init__(self, path): super().__init__(path, task=VocTask.classification) diff --git a/datumaro/datumaro/components/formats/ms_coco.py b/datumaro/datumaro/components/formats/coco.py similarity index 100% rename from datumaro/datumaro/components/formats/ms_coco.py rename to datumaro/datumaro/components/formats/coco.py diff --git a/datumaro/datumaro/components/importers/__init__.py b/datumaro/datumaro/components/importers/__init__.py index 7c952d2c..cc009dbf 100644 --- a/datumaro/datumaro/components/importers/__init__.py +++ b/datumaro/datumaro/components/importers/__init__.py @@ -4,22 +4,18 @@ # SPDX-License-Identifier: MIT from datumaro.components.importers.datumaro import DatumaroImporter -from datumaro.components.importers.ms_coco import CocoImporter - -from datumaro.components.importers.voc import ( - VocImporter, - VocResultsImporter, -) - +from datumaro.components.importers.coco import CocoImporter +from datumaro.components.importers.voc import VocImporter, VocResultsImporter from datumaro.components.importers.tfrecord import DetectionApiImporter from datumaro.components.importers.yolo import YoloImporter from datumaro.components.importers.cvat import CvatImporter +from datumaro.components.importers.image_dir import ImageDirImporter items = [ ('datumaro', DatumaroImporter), - ('ms_coco', CocoImporter), + ('coco', CocoImporter), ('voc', VocImporter), ('voc_results', VocResultsImporter), @@ -29,4 +25,6 @@ items = [ ('tf_detection_api', DetectionApiImporter), ('cvat', CvatImporter), + + ('image_dir', ImageDirImporter), ] \ No newline at end of file diff --git a/datumaro/datumaro/components/importers/ms_coco.py b/datumaro/datumaro/components/importers/coco.py similarity index 96% rename from datumaro/datumaro/components/importers/ms_coco.py rename to datumaro/datumaro/components/importers/coco.py index cb0fb838..9e3d38e6 100644 --- a/datumaro/datumaro/components/importers/ms_coco.py +++ b/datumaro/datumaro/components/importers/coco.py @@ -8,7 +8,7 @@ from glob import glob import logging as log import os.path as osp -from datumaro.components.formats.ms_coco import CocoTask, CocoPath +from datumaro.components.formats.coco import CocoTask, CocoPath class CocoImporter: diff --git a/datumaro/datumaro/components/importers/image_dir.py b/datumaro/datumaro/components/importers/image_dir.py new file mode 100644 index 00000000..ef2cdd43 --- /dev/null +++ b/datumaro/datumaro/components/importers/image_dir.py @@ -0,0 +1,26 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import os.path as osp + + +class ImageDirImporter: + EXTRACTOR_NAME = 'image_dir' + + def __call__(self, path, **extra_params): + from datumaro.components.project import Project # cyclic import + project = Project() + + if not osp.isdir(path): + raise Exception("Can't find a directory at '%s'" % path) + + source_name = osp.basename(osp.normpath(path)) + project.add_source(source_name, { + 'url': source_name, + 'format': self.EXTRACTOR_NAME, + 'options': dict(extra_params), + }) + + return project diff --git a/datumaro/datumaro/components/project.py b/datumaro/datumaro/components/project.py index a1e9645d..34acf41f 100644 --- a/datumaro/datumaro/components/project.py +++ b/datumaro/datumaro/components/project.py @@ -105,15 +105,17 @@ class GitWrapper: def _git_dir(base_path): return osp.join(base_path, '.git') - def init(self, path): - spawn = not osp.isdir(GitWrapper._git_dir(path)) - self.repo = git.Repo.init(path=path) + @classmethod + def spawn(cls, path): + spawn = not osp.isdir(cls._git_dir(path)) + repo = git.Repo.init(path=path) if spawn: author = git.Actor("Nobody", "nobody@example.com") - self.repo.index.commit('Initial commit', author=author) - return self.repo + repo.index.commit('Initial commit', author=author) + return repo - def get_repo(self): + def init(self, path): + self.repo = self.spawn(path) return self.repo def is_initialized(self): @@ -316,7 +318,9 @@ class Dataset(Extractor): categories.update(source.categories()) for source in sources: for cat_type, source_cat in source.categories().items(): - assert categories[cat_type] == source_cat + if not categories[cat_type] == source_cat: + raise NotImplementedError( + "Merging different categories is not implemented yet") dataset = Dataset(categories=categories) # merge items @@ -395,11 +399,12 @@ class Dataset(Extractor): return item - def extract(self, filter_expr, filter_annotations=False, **kwargs): + def extract(self, filter_expr, filter_annotations=False, remove_empty=False): if filter_annotations: - return self.transform(XPathAnnotationsFilter, filter_expr, **kwargs) + return self.transform(XPathAnnotationsFilter, filter_expr, + remove_empty) else: - return self.transform(XPathDatasetFilter, filter_expr, **kwargs) + return self.transform(XPathDatasetFilter, filter_expr) def update(self, items): for item in items: @@ -468,7 +473,9 @@ class ProjectDataset(Dataset): categories.update(source.categories()) for source in self._sources.values(): for cat_type, source_cat in source.categories().items(): - assert categories[cat_type] == source_cat + if not categories[cat_type] == source_cat: + raise NotImplementedError( + "Merging different categories is not implemented yet") if own_source is not None and len(own_source) != 0: categories.update(own_source.categories()) self._categories = categories @@ -651,17 +658,18 @@ class ProjectDataset(Dataset): launcher = self._project.make_executable_model(model_name) self.transform_project(InferenceWrapper, launcher, save_dir=save_dir) - def export_project(self, save_dir, output_format, - filter_expr=None, filter_annotations=False, **converter_kwargs): + def export_project(self, save_dir, converter, + filter_expr=None, filter_annotations=False, remove_empty=False): # NOTE: probably this function should be in the ViewModel layer save_dir = osp.abspath(save_dir) os.makedirs(save_dir, exist_ok=True) dataset = self if filter_expr: - dataset = dataset.extract(filter_expr, filter_annotations) + dataset = dataset.extract(filter_expr, + filter_annotations=filter_annotations, + remove_empty=remove_empty) - converter = self.env.make_converter(output_format, **converter_kwargs) converter(dataset, save_dir) def extract_project(self, filter_expr, filter_annotations=False, diff --git a/datumaro/datumaro/util/test_utils.py b/datumaro/datumaro/util/test_utils.py index 9219f5cf..e855fad0 100644 --- a/datumaro/datumaro/util/test_utils.py +++ b/datumaro/datumaro/util/test_utils.py @@ -7,6 +7,7 @@ import inspect import os import os.path as osp import shutil +import tempfile def current_function_name(depth=1): @@ -32,8 +33,22 @@ class FileRemover: class TestDir(FileRemover): def __init__(self, path=None, ignore_errors=False): if path is None: - path = osp.abspath('temp_%s' % current_function_name(2)) - - os.makedirs(path, exist_ok=ignore_errors) - - super().__init__(path, is_dir=True, ignore_errors=ignore_errors) \ No newline at end of file + path = osp.abspath('temp_%s-' % current_function_name(2)) + path = tempfile.mkdtemp(dir=os.getcwd(), prefix=path) + else: + os.makedirs(path, exist_ok=ignore_errors) + + super().__init__(path, is_dir=True, ignore_errors=ignore_errors) + +def ann_to_str(ann): + return vars(ann) + +def item_to_str(item): + return '\n'.join( + [ + '%s' % vars(item) + ] + [ + 'ann[%s]: %s' % (i, ann_to_str(a)) + for i, a in enumerate(item.annotations) + ] + ) \ No newline at end of file diff --git a/datumaro/docs/cli_design.mm b/datumaro/docs/cli_design.mm index 4c7b188c..0ff17cb2 100644 --- a/datumaro/docs/cli_design.mm +++ b/datumaro/docs/cli_design.mm @@ -2,146 +2,64 @@ - - + - - + - - + - - + - - + - - + - - + - - - - - - - + - - - - - - - - - - - - - + + - - + - - - - - - - - + - - + - - - + - - - - - - - - - - - - + - - - + + - - + - - - - - - + + - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - diff --git a/datumaro/docs/design.md b/datumaro/docs/design.md index 69d4d198..7d89e8eb 100644 --- a/datumaro/docs/design.md +++ b/datumaro/docs/design.md @@ -5,7 +5,6 @@ ## Table of contents - [Concept](#concept) -- [Design](#design) - [RC 1 vision](#rc-1-vision) ## Concept @@ -70,53 +69,6 @@ Datumaro is: - guidance for key frame selection for tracking ([paper](https://arxiv.org/abs/1903.11779)) Use case: more effective annotation, better predictions -## Design - -### Command-line - -Use Docker as an example. Basically, the interface is partitioned -on contexts and shortcuts. Contexts are semantically grouped commands, -related to a single topic or target. Shortcuts are handy shorter -alternatives for the most used commands and also special commands, -which are hard to be put into specific context. - -![cli-design-image](images/cli_design.png) - -- [FreeMind tool link](http://freemind.sourceforge.net/wiki/index.php/Main_Page) - -### High-level architecture - -- Using MVVM UI pattern - -![mvvm-image](images/mvvm.png) - -### Datumaro project and environment structure - - -``` -├── [datumaro module] -└── [project folder] - ├── .datumaro/ - │   ├── config.yml - │   ├── .git/ - │   ├── importers/ - │   │   ├── custom_format_importer1.py - │   │   └── ... - │   ├── statistics/ - │   │   ├── custom_statistic1.py - │   │   └── ... - │   ├── visualizers/ - │   │ ├── custom_visualizer1.py - │   │ └── ... - │   └── extractors/ - │   ├── custom_extractor1.py - │   └── ... - └── sources/ - ├── source1 - └── ... -``` - - ## RC 1 vision In the first version Datumaro should be a project manager for CVAT. @@ -139,18 +91,20 @@ can be downloaded by user to be operated on with Datumaro CLI. ### Interfaces - [x] Python API for user code - - [ ] Installation as a package + - [x] Installation as a package - [x] A command-line tool for dataset manipulations ### Features -- Dataset format support (reading, exporting) +- Dataset format support (reading, writing) - [x] Own format + - [x] CVAT - [x] COCO - [x] PASCAL VOC + - [x] YOLO + - [x] TF Detection API - [ ] Cityscapes - [ ] ImageNet - - [ ] CVAT - Dataset visualization (`show`) - [ ] Ability to visualize a dataset @@ -199,6 +153,7 @@ can be downloaded by user to be operated on with Datumaro CLI. - export the task - convert to a training format - train a DL model + - [x] Use case "annotate - reannotate problematic images - merge" - [ ] Use case "annotate and estimate quality" - create a task - annotate diff --git a/datumaro/docs/images/cli_design.png b/datumaro/docs/images/cli_design.png index 702728c442a7938af24630916fcde41b14c66c33..f83b1430ec53a28546cd82d9c2d4b2031474d01d 100644 GIT binary patch literal 35845 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 literal 93636 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 diff --git a/datumaro/docs/quickstart.md b/datumaro/docs/quickstart.md deleted file mode 100644 index d5fb98a6..00000000 --- a/datumaro/docs/quickstart.md +++ /dev/null @@ -1,325 +0,0 @@ -# Quick start guide - -## Installation - -### Prerequisites - -- Python (3.5+) -- OpenVINO (optional) - -### Installation steps - -Download the project to any directory. - -Set up a virtual environment: - -``` bash -python -m pip install virtualenv -python -m virtualenv venv -. venv/bin/activate -while read -r p; do pip install $p; done < requirements.txt -``` - -## Usage - -The directory containing the project should be in the -`PYTHONPATH` environment variable. The other way is to invoke -commands from that directory. - -As a python module: - -``` bash -python -m datumaro --help -``` - -As a standalone python script: - -``` bash -python datum.py --help -``` - -As a python library: - -``` python -import datumaro -``` - -## Workflow - -> **Note**: command invocation **syntax is subject to change, refer to --help output** - -The key object is the project. It can be created or imported with -`project create` and `project import` commands. The project is a combination of -dataset and environment. - -If you want to interact with models, you should add them to project first. - -Implemented commands ([CLI design doc](images/cli_design.png)): -- project create -- project import -- project diff -- project transform -- source add -- explain - -### Create a project - -Usage: - -``` bash -python datum.py project create --help - -python datum.py project create \ - -d -``` - -Example: - -``` bash -python datum.py project create -d /home/my_dataset -``` - -### Import a project - -This command creates a project from an existing dataset. Supported formats: -- MS COCO -- Custom formats via custom `importers` and `extractors` - -Usage: - -``` bash -python -m datumaro project import --help - -python -m datumaro project import \ - \ - -d \ - -t -``` - -Example: - -``` bash -python -m datumaro project import \ - /home/coco_dir \ - -d /home/project_dir \ - -t ms_coco -``` - -An _MS COCO_-like dataset should have the following directory structure: - - -``` -COCO/ -├── annotations/ -│   ├── instances_val2017.json -│   ├── instances_train2017.json -├── images/ -│   ├── val2017 -│   ├── train2017 -``` - - -Everything after the last `_` is considered as a subset name. - -### Register a model - -Supported models: -- OpenVINO -- Custom models via custom `launchers` - -Usage: - -``` bash -python -m datumaro model add --help -``` - -Example: register OpenVINO model - -A model consists of a graph description and weights. There is also a script -used to convert model outputs to internal data structures. - -``` bash -python -m datumaro model add \ - openvino \ - -d -w -i -``` - -Interpretation script for an OpenVINO detection model (`convert.py`): - -``` python -from datumaro.components.extractor import * - -max_det = 10 -conf_thresh = 0.1 - -def process_outputs(inputs, outputs): - # inputs = model input, array or images, shape = (N, C, H, W) - # outputs = model output, shape = (N, 1, K, 7) - # results = conversion result, [ [ Annotation, ... ], ... ] - results = [] - for input, output in zip(inputs, outputs): - input_height, input_width = input.shape[:2] - detections = output[0] - image_results = [] - for i, det in enumerate(detections): - label = int(det[1]) - conf = det[2] - if conf <= conf_thresh: - continue - - x = max(int(det[3] * input_width), 0) - y = max(int(det[4] * input_height), 0) - w = min(int(det[5] * input_width - x), input_width) - h = min(int(det[6] * input_height - y), input_height) - image_results.append(BboxObject(x, y, w, h, - label=label, attributes={'score': conf} )) - - results.append(image_results[:max_det]) - - return results - -def get_categories(): - # Optionally, provide output categories - label map etc. - # Example: - label_categories = LabelCategories() - label_categories.add('person') - label_categories.add('car') - return { AnnotationType.label: label_categories } -``` - -### Run a model inference - -This command сreates a new project from the current project. The new -one annotations are the model outputs. - -Usage: - -``` bash -python -m datumaro project transform --help - -python -m datumaro project transform \ - -m \ - -d -``` - -Example: - -``` bash -python -m datumaro project import <...> -python -m datumaro model add mymodel <...> -python -m datumaro project transform -m mymodel -d ../mymodel_inference -``` - -### Compare datasets - -The command compares two datasets and saves the results in the -specified directory. The current project is considered to be -"ground truth". - -``` bash -python -m datumaro project diff --help - -python -m datumaro project diff -d -``` - -Example: compare a dataset with model inference - -``` bash -python -m datumaro project import <...> -python -m datumaro model add mymodel <...> -python -m datumaro project transform <...> -d ../inference -python -m datumaro project diff ../inference -d ../diff -``` - -### Run inference explanation - -Usage: - -``` bash -python -m datumaro explain --help - -python -m datumaro explain \ - -m \ - -d \ - -t \ - \ - -``` - -Example: run inference explanation on a single image with visualization - -``` bash -python -m datumaro project create <...> -python -m datumaro model add mymodel <...> -python -m datumaro explain \ - -m mymodel \ - -t 'image.png' \ - rise \ - -s 1000 --progressive -``` - -### Extract data subset based on filter - -This command allows to create a subprject form a project, which -would include only items satisfying some condition. XPath is used as a query -format. - -Usage: - -``` bash -python -m datumaro project extract --help - -python -m datumaro project extract \ - -p \ - -d \ - -f '' -``` - -Example: - -``` bash -python -m datumaro project extract \ - -p ../test_project \ - -d ../test_project-extract \ - -f '/item[image/width < image/height]' -``` - -Item representation: - -``` xml - - 290768 - minival2014 - - 612 - 612 - 3 - - - 80154 - bbox - 39 - 264.59 - 150.25 - 11.199999999999989 - 42.31 - 473.87199999999956 - - - 669839 - bbox - 41 - 163.58 - 191.75 - 76.98999999999998 - 73.63 - 5668.773699999998 - - ... - -``` - -## Links -- [TensorFlow detection model zoo](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/detection_model_zoo.md) -- [How to convert model to OpenVINO format](https://docs.openvinotoolkit.org/latest/_docs_MO_DG_prepare_model_convert_model_tf_specific_Convert_Object_Detection_API_Models.html) -- [Model convert script for this model](https://github.com/opencv/cvat/blob/3e09503ba6c6daa6469a6c4d275a5a8b168dfa2c/components/tf_annotation/install.sh#L23) diff --git a/datumaro/docs/user_manual.md b/datumaro/docs/user_manual.md new file mode 100644 index 00000000..0b61c11a --- /dev/null +++ b/datumaro/docs/user_manual.md @@ -0,0 +1,563 @@ +# Quick start guide + +## Contents + +- [Installation](#installation) +- [Interfaces](#interfaces) +- [Supported dataset formats and annotations](#formats-support) +- [Command line workflow](#command-line-workflow) + - [Create a project](#create-project) + - [Add and remove data](#add-and-remove-data) + - [Import a project](#import-project) + - [Extract a subproject](#extract-subproject) + - [Merge projects](#merge-project) + - [Export a project](#export-project) + - [Compare projects](#compare-projects) + - [Get project info](#get-project-info) + - [Register a model](#register-model) + - [Run inference](#run-inference) + - [Run inference explanation](#explain-inference) +- [Links](#links) + +## Installation + +### Prerequisites + +- Python (3.5+) +- OpenVINO (optional) + +### Installation steps + +Optionally, set up a virtual environment: + +``` bash +python -m pip install virtualenv +python -m virtualenv venv +. venv/bin/activate +``` + +Install Datumaro: +``` bash +pip install 'git+https://github.com/opencv/cvat#egg=datumaro&subdirectory=datumaro' +``` + +> You can change the installation branch with `.../cvat@#egg...` +> Also note `--force-reinstall` parameter in this case. + +## Interfaces + +As a standalone tool: + +``` bash +datum --help +``` + +As a python module: +> The directory containing Datumaro should be in the `PYTHONPATH` +> environment variable or `cvat/datumaro/` should be the current directory. + +``` bash +python -m datumaro --help +python datumaro/ --help +python datum.py --help +``` + +As a python library: + +``` python +import datumaro +``` + +## Formats support + +List of supported formats: +- COCO (`image_info`, `instances`, `person_keypoints`, `captions`, `labels`*) + - [Format specification](http://cocodataset.org/#format-data) + - `labels` are our extension - like `instances` with only `category_id` +- PASCAL VOC (`classification`, `detection`, `segmentation` (class, instances), `action_classification`, `person_layout`) + - [Format specification](http://host.robots.ox.ac.uk/pascal/VOC/voc2012/htmldoc/index.html) +- YOLO (`bboxes`) + - [Format specification](https://github.com/AlexeyAB/darknet#how-to-train-pascal-voc-data) +- TF Detection API (`bboxes`, `masks`) + - Format specifications: [bboxes](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/using_your_own_dataset.md), [masks](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/instance_segmentation.md) +- CVAT + - [Format specification](https://github.com/opencv/cvat/blob/develop/cvat/apps/documentation/xml_format.md) + +List of supported annotation types: +- Labels +- Bounding boxes +- Polygons +- Polylines +- (Key-)Points +- Captions +- Masks + +## Command line workflow + +> **Note**: command invocation syntax is subject to change, +> **always refer to command --help output** + +The key object is the Project. The Project is a combination of +a Project's own dataset, a number of external data sources and an environment. +An empty Project can be created by `project create` command, +an existing dataset can be imported with `project import` command. +A typical way to obtain projects is to export tasks in CVAT UI. + +Available CLI commands: +![CLI design doc](images/cli_design.png) + +If you want to interact with models, you need to add them to project first. + +### Import project + +This command creates a Project from an existing dataset. + +Supported formats are listed in the command help. +In Datumaro dataset formats are supported by Extractors and Importers. +An Extractor produces a list of dataset items corresponding +to the dataset. An Importer creates a Project from the +data source location. It is possible to add a custom Extractor and Importer. +To do this, you need to put an Extractor and Importer implementation scripts to +`/.datumaro/extractors` and `/.datumaro/importers`. + +Usage: + +``` bash +datum project import --help + +datum project import \ + -i \ + -o \ + -f +``` + +Example: create a project from COCO-like dataset + +``` bash +datum project import \ + -i /home/coco_dir \ + -o /home/project_dir \ + -f coco +``` + +An _MS COCO_-like dataset should have the following directory structure: + + +``` +COCO/ +├── annotations/ +│   ├── instances_val2017.json +│   ├── instances_train2017.json +├── images/ +│   ├── val2017 +│   ├── train2017 +``` + + +Everything after the last `_` is considered a subset name in the COCO format. + +### Create project + +The command creates an empty project. Once a Project is created, there are +a few options to interact with it. + +Usage: + +``` bash +datum project create --help + +datum project create \ + -o +``` + +Example: create an empty project `my_dataset` + +``` bash +datum project create -o my_dataset/ +``` + +### Add and remove data + +A Project can be attached to a number of external Data Sources. Each Source +describes a way to produce dataset items. A Project combines dataset items from +all the sources and its own dataset into one composite dataset. You can manage +project sources by commands in the `source` command line context. + +Datasets come in a wide variety of formats. Each dataset +format defines its own data structure and rules on how to +interpret the data. For example, the following data structure +is used in COCO format: + +``` +/dataset/ +- /images/.jpg +- /annotations/ +``` + + +In Datumaro dataset formats are supported by Extractors. +An Extractor produces a list of dataset items corresponding +to the dataset. It is possible to add a custom Extractor. +To do this, you need to put an Extractor +definition script to `/.datumaro/extractors`. + +Usage: + +``` bash +datum source add --help +datum source remove --help + +datum source add \ + path \ + -p \ + -n + +datum source remove \ + -p \ + -n +``` + +Example: create a project from a bunch of different annotations and images, +and generate TFrecord for TF Detection API for model training + +``` bash +datum project create +# 'default' is the name of the subset below +datum source add path -f coco_instances +datum source add path -f cvat +datum source add path -f voc_detection +datum source add path -f datumaro +datum source add path -f image_dir +datum project export -f tf_detection_api +``` + +### Extract subproject + +This command allows to create a sub-Project from a Project. The new project +includes only items satisfying some condition. [XPath](https://devhints.io/xpath) +is used as query format. + +There are several filtering modes available ('-m/--mode' parameter). +Supported modes: +- 'i', 'items' +- 'a', 'annotations' +- 'i+a', 'a+i', 'items+annotations', 'annotations+items' + +When filtering annotations, use the 'items+annotations' +mode to point that annotation-less dataset items should be +removed. To select an annotation, write an XPath that +returns 'annotation' elements (see examples). + +Usage: + +``` bash +datum project extract --help + +datum project extract \ + -p \ + -o \ + -e '' +``` + +Example: extract a dataset with only images which width < height + +``` bash +datum project extract \ + -p test_project \ + -o test_project-extract \ + -e '/item[image/width < image/height]' +``` + +Example: extract a dataset with only large annotations of class `cat` and any non-`persons` + +``` bash +datum project extract \ + -p test_project \ + -o test_project-extract \ + --mode annotations -e '/item/annotation[(label="cat" and area > 999.5) or label!="person"]' +``` + +Example: extract a dataset with only occluded annotations, remove empty images + +``` bash +datum project extract \ + -p test_project \ + -o test_project-extract \ + -m i+a -e '/item/annotation[occluded="True"]' +``` + +Item representations are available with `--dry-run` parameter: + +``` xml + + 290768 + minival2014 + + 612 + 612 + 3 + + + 80154 + bbox + 39 + 264.59 + 150.25 + 11.199999999999989 + 42.31 + 473.87199999999956 + + + 669839 + bbox + 41 + 163.58 + 191.75 + 76.98999999999998 + 73.63 + 5668.773699999998 + + ... + +``` + +### Merge projects + +This command combines multiple Projects into one. + +Usage: + +``` bash +datum project merge --help + +datum project merge \ + -p \ + -o \ + +``` + +Example: update annotations in the `first_project` with annotations +from the `second_project` and save the result as `merged_project` + +``` bash +datum project merge \ + -p first_project \ + -o merged_project \ + second_project +``` + +### Export project + +This command exports a Project in some format. + +Supported formats are listed in the command help. +In Datumaro dataset formats are supported by Converters. +A Converter produces a dataset of a specific format +from dataset items. It is possible to add a custom Converter. +To do this, you need to put a Converter +definition script to /.datumaro/converters. + +Usage: + +``` bash +datum project export --help + +datum project export \ + -p \ + -o \ + -f \ + [-- ] +``` + +Example: save project as VOC-like dataset, include images + +``` bash +datum project export \ + -p test_project \ + -o test_project-export \ + -f voc \ + -- --save-images +``` + +### Get project info + +This command outputs project status information. + +Usage: + +``` bash +datum project info --help + +datum project info \ + -p +``` + +Example: + +``` bash +datum project info -p /test_project + +Project: + name: test_project2 + location: /test_project +Sources: + source 'instances_minival2014': + format: coco_instances + url: /coco_like/annotations/instances_minival2014.json +Dataset: + length: 5000 + categories: label + label: + count: 80 + labels: person, bicycle, car, motorcycle (and 76 more) + subsets: minival2014 + subset 'minival2014': + length: 5000 + categories: label + label: + count: 80 + labels: person, bicycle, car, motorcycle (and 76 more) +``` + +### Register model + +Supported models: +- OpenVINO +- Custom models via custom `launchers` + +Usage: + +``` bash +datum model add --help +``` + +Example: register an OpenVINO model + +A model consists of a graph description and weights. There is also a script +used to convert model outputs to internal data structures. + +``` bash +datum project create +datum model add \ + -n openvino \ + -d -w -i +``` + +Interpretation script for an OpenVINO detection model (`convert.py`): + +``` python +from datumaro.components.extractor import * + +max_det = 10 +conf_thresh = 0.1 + +def process_outputs(inputs, outputs): + # inputs = model input, array or images, shape = (N, C, H, W) + # outputs = model output, shape = (N, 1, K, 7) + # results = conversion result, [ [ Annotation, ... ], ... ] + results = [] + for input, output in zip(inputs, outputs): + input_height, input_width = input.shape[:2] + detections = output[0] + image_results = [] + for i, det in enumerate(detections): + label = int(det[1]) + conf = det[2] + if conf <= conf_thresh: + continue + + x = max(int(det[3] * input_width), 0) + y = max(int(det[4] * input_height), 0) + w = min(int(det[5] * input_width - x), input_width) + h = min(int(det[6] * input_height - y), input_height) + image_results.append(BboxObject(x, y, w, h, + label=label, attributes={'score': conf} )) + + results.append(image_results[:max_det]) + + return results + +def get_categories(): + # Optionally, provide output categories - label map etc. + # Example: + label_categories = LabelCategories() + label_categories.add('person') + label_categories.add('car') + return { AnnotationType.label: label_categories } +``` + +### Run model + +This command applies model to dataset images and produces a new project. + +Usage: + +``` bash +datum model run --help + +datum model run \ + -p \ + -m \ + -o +``` + +Example: launch inference on a dataset + +``` bash +datum project import <...> +datum model add mymodel <...> +datum model run -m mymodel -o inference +``` + +### Compare projects + +The command compares two datasets and saves the results in the +specified directory. The current project is considered to be +"ground truth". + +``` bash +datum project diff --help + +datum project diff -o +``` + +Example: compare a dataset with model inference + +``` bash +datum project import <...> +datum model add mymodel <...> +datum project transform <...> -o inference +datum project diff inference -o diff +``` + +### Explain inference + +Usage: + +``` bash +datum explain --help + +datum explain \ + -m \ + -o \ + -t \ + \ + +``` + +Example: run inference explanation on a single image with visualization + +``` bash +datum project create <...> +datum model add mymodel <...> +datum explain \ + -m mymodel \ + -t 'image.png' \ + rise \ + -s 1000 --progressive +``` + +## Links +- [TensorFlow detection model zoo](https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/detection_model_zoo.md) +- [How to convert model to OpenVINO format](https://docs.openvinotoolkit.org/latest/_docs_MO_DG_prepare_model_convert_model_tf_specific_Convert_Object_Detection_API_Models.html) +- [Model conversion script example](https://github.com/opencv/cvat/blob/3e09503ba6c6daa6469a6c4d275a5a8b168dfa2c/components/tf_annotation/install.sh#L23) diff --git a/datumaro/setup.py b/datumaro/setup.py index 6f3e02d7..7880e644 100644 --- a/datumaro/setup.py +++ b/datumaro/setup.py @@ -62,7 +62,7 @@ setuptools.setup( ], entry_points={ 'console_scripts': [ - 'datum=datumaro:main', + 'datum=datumaro.cli.__main__:main', ], }, ) diff --git a/datumaro/test.py b/datumaro/test.py deleted file mode 100644 index 184bbff5..00000000 --- a/datumaro/test.py +++ /dev/null @@ -1,5 +0,0 @@ -import unittest - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/datumaro/tests/test_coco_format.py b/datumaro/tests/test_coco_format.py index 1631434e..e32303b6 100644 --- a/datumaro/tests/test_coco_format.py +++ b/datumaro/tests/test_coco_format.py @@ -12,7 +12,7 @@ from datumaro.components.extractor import (Extractor, DatasetItem, BboxObject, CaptionObject, LabelCategories, PointsCategories ) -from datumaro.components.converters.ms_coco import ( +from datumaro.components.converters.coco import ( CocoConverter, CocoImageInfoConverter, CocoCaptionsConverter, @@ -112,7 +112,7 @@ class CocoImporterTest(TestCase): def test_can_import(self): with TestDir() as temp_dir: self.COCO_dataset_generate(temp_dir.path) - project = Project.import_from(temp_dir.path, 'ms_coco') + project = Project.import_from(temp_dir.path, 'coco') dataset = project.make_dataset() self.assertListEqual(['val'], sorted(dataset.subsets())) @@ -142,7 +142,7 @@ class CocoConverterTest(TestCase): if not importer_params: importer_params = {} - project = Project.import_from(test_dir.path, 'ms_coco', + project = Project.import_from(test_dir.path, 'coco', **importer_params) parsed_dataset = project.make_dataset() diff --git a/datumaro/tests/test_cvat_format.py b/datumaro/tests/test_cvat_format.py index 1cbdb743..8a4c95ad 100644 --- a/datumaro/tests/test_cvat_format.py +++ b/datumaro/tests/test_cvat_format.py @@ -14,7 +14,7 @@ from datumaro.components.converters.cvat import CvatConverter from datumaro.components.project import Project import datumaro.components.formats.cvat as Cvat from datumaro.util.image import save_image -from datumaro.util.test_utils import TestDir +from datumaro.util.test_utils import TestDir, item_to_str class CvatExtractorTest(TestCase): @@ -108,7 +108,7 @@ class CvatExtractorTest(TestCase): BboxObject(0, 2, 4, 2, label=0, attributes={ 'occluded': True, 'z_order': 1, - 'a1': 'true', 'a2': 'v3' + 'a1': True, 'a2': 'v3' }), PolyLineObject([1, 2, 3, 4, 5, 6, 7, 8], attributes={'occluded': False, 'z_order': 0}), @@ -175,7 +175,8 @@ class CvatConverterTest(TestCase): self.assertEqual(len(source_subset), len(parsed_subset)) for idx, (item_a, item_b) in enumerate( zip(source_subset, parsed_subset)): - self.assertEqual(item_a, item_b, str(idx)) + self.assertEqual(item_a, item_b, '%s:\n%s\nvs.\n%s\n' % \ + (idx, item_to_str(item_a), item_to_str(item_b))) def test_can_save_and_load(self): label_categories = LabelCategories() @@ -209,12 +210,12 @@ class CvatConverterTest(TestCase): ] ), - DatasetItem(id=0, subset='s2', image=np.zeros((5, 10, 3)), + DatasetItem(id=2, subset='s2', image=np.ones((5, 10, 3)), annotations=[ PolygonObject([0, 0, 4, 0, 4, 4], label=3, group=4, attributes={ 'z_order': 1, 'occluded': False }), - PolyLineObject([5, 0, 9, 0, 5, 5]), # will be skipped + PolyLineObject([5, 0, 9, 0, 5, 5]), # will be skipped as no label ] ), ]) @@ -236,7 +237,7 @@ class CvatConverterTest(TestCase): PointsObject([1, 1, 3, 2, 2, 3], label=2, attributes={ 'z_order': 0, 'occluded': False, - 'a1': 'x', 'a2': '42' }), + 'a1': 'x', 'a2': 42 }), ] ), DatasetItem(id=1, subset='s1', @@ -250,7 +251,7 @@ class CvatConverterTest(TestCase): ] ), - DatasetItem(id=0, subset='s2', image=np.zeros((5, 10, 3)), + DatasetItem(id=2, subset='s2', image=np.ones((5, 10, 3)), annotations=[ PolygonObject([0, 0, 4, 0, 4, 4], label=3, group=4, diff --git a/datumaro/tests/test_datumaro_format.py b/datumaro/tests/test_datumaro_format.py index 3a83c424..77b1b1c0 100644 --- a/datumaro/tests/test_datumaro_format.py +++ b/datumaro/tests/test_datumaro_format.py @@ -9,7 +9,7 @@ from datumaro.components.extractor import (Extractor, DatasetItem, LabelCategories, MaskCategories, PointsCategories ) from datumaro.components.converters.datumaro import DatumaroConverter -from datumaro.util.test_utils import TestDir +from datumaro.util.test_utils import TestDir, item_to_str from datumaro.util.mask_tools import generate_colormap @@ -26,7 +26,7 @@ class DatumaroConverterTest(TestCase): 'y': '2', }), BboxObject(1, 2, 3, 4, label=4, id=4, attributes={ - 'score': 10.0, + 'score': 1.0, }), BboxObject(5, 6, 7, 8, id=5, group=5), PointsObject([1, 2, 2, 0, 1, 1], label=0, id=5), @@ -92,7 +92,8 @@ class DatumaroConverterTest(TestCase): self.assertEqual(len(source_subset), len(parsed_subset)) for idx, (item_a, item_b) in enumerate( zip(source_subset, parsed_subset)): - self.assertEqual(item_a, item_b, str(idx)) + self.assertEqual(item_a, item_b, '%s:\n%s\nvs.\n%s\n' % \ + (idx, item_to_str(item_a), item_to_str(item_b))) self.assertEqual( source_dataset.categories(), diff --git a/datumaro/tests/test_image.py b/datumaro/tests/test_image.py index 424fd9c8..1e1ed5c7 100644 --- a/datumaro/tests/test_image.py +++ b/datumaro/tests/test_image.py @@ -31,7 +31,7 @@ class ImageTest(TestCase): image_module._IMAGE_BACKEND = load_backend dst_image = image_module.load_image(path) - self.assertTrue(np.all(src_image == dst_image), + self.assertTrue(np.array_equal(src_image, dst_image), 'save: %s, load: %s' % (save_backend, load_backend)) def test_encode_and_decode_backends(self): @@ -48,5 +48,5 @@ class ImageTest(TestCase): image_module._IMAGE_BACKEND = load_backend dst_image = image_module.decode_image(buffer) - self.assertTrue(np.all(src_image == dst_image), + self.assertTrue(np.array_equal(src_image, dst_image), 'save: %s, load: %s' % (save_backend, load_backend)) \ No newline at end of file diff --git a/datumaro/tests/test_image_dir_format.py b/datumaro/tests/test_image_dir_format.py new file mode 100644 index 00000000..27568d55 --- /dev/null +++ b/datumaro/tests/test_image_dir_format.py @@ -0,0 +1,48 @@ +import numpy as np +import os.path as osp + +from unittest import TestCase + +from datumaro.components.project import Project +from datumaro.components.extractor import Extractor, DatasetItem +from datumaro.util.test_utils import TestDir +from datumaro.util.image import save_image + + +class ImageDirFormatTest(TestCase): + class TestExtractor(Extractor): + def __iter__(self): + return iter([ + DatasetItem(id=1, image=np.ones((10, 6, 3))), + DatasetItem(id=2, image=np.ones((5, 4, 3))), + ]) + + def test_can_load(self): + with TestDir() as test_dir: + source_dataset = self.TestExtractor() + + for item in source_dataset: + save_image(osp.join(test_dir.path, '%s.jpg' % item.id), + item.image) + + project = Project.import_from(test_dir.path, 'image_dir') + parsed_dataset = project.make_dataset() + + self.assertListEqual( + sorted(source_dataset.subsets()), + sorted(parsed_dataset.subsets()), + ) + + self.assertEqual(len(source_dataset), len(parsed_dataset)) + + for subset_name in source_dataset.subsets(): + source_subset = source_dataset.get_subset(subset_name) + parsed_subset = parsed_dataset.get_subset(subset_name) + self.assertEqual(len(source_subset), len(parsed_subset)) + for idx, (item_a, item_b) in enumerate( + zip(source_subset, parsed_subset)): + self.assertEqual(item_a, item_b, str(idx)) + + self.assertEqual( + source_dataset.categories(), + parsed_dataset.categories()) \ No newline at end of file diff --git a/datumaro/tests/test_project.py b/datumaro/tests/test_project.py index c30a570c..93a2aad4 100644 --- a/datumaro/tests/test_project.py +++ b/datumaro/tests/test_project.py @@ -353,6 +353,7 @@ class DatasetFilterTest(TestCase): BboxObject(1, 2, 3, 4, label=4, id=4, attributes={ 'a': 1.0 }), BboxObject(5, 6, 7, 8, id=5, group=5), PointsObject([1, 2, 2, 0, 1, 1], label=0, id=5), + MaskObject(id=5, image=np.ones((3, 2))), MaskObject(label=3, id=5, image=np.ones((2, 3))), PolyLineObject([1, 2, 3, 4, 5, 6, 7, 8], id=11), PolygonObject([1, 2, 3, 4, 5, 6, 7, 8]), diff --git a/datumaro/tests/test_voc_format.py b/datumaro/tests/test_voc_format.py index 0c9c8eea..de58ce40 100644 --- a/datumaro/tests/test_voc_format.py +++ b/datumaro/tests/test_voc_format.py @@ -141,9 +141,9 @@ def generate_dummy_voc(path): obj2head_elem = ET.SubElement(obj2_elem, 'part') ET.SubElement(obj2head_elem, 'name').text = VOC.VocBodyPart(1).name obj2headbb_elem = ET.SubElement(obj2head_elem, 'bndbox') - ET.SubElement(obj2headbb_elem, 'xmin').text = '5' + ET.SubElement(obj2headbb_elem, 'xmin').text = '5.5' ET.SubElement(obj2headbb_elem, 'ymin').text = '6' - ET.SubElement(obj2headbb_elem, 'xmax').text = '7' + ET.SubElement(obj2headbb_elem, 'xmax').text = '7.5' ET.SubElement(obj2headbb_elem, 'ymax').text = '8' obj2act_elem = ET.SubElement(obj2_elem, 'actions') for act in VOC.VocAction: @@ -328,7 +328,7 @@ class VocExtractorTest(TestCase): lambda x: x.type == AnnotationType.bbox and \ get_label(extractor, x.label) == VOC.VocBodyPart(1).name) self.assertTrue(obj2.id == obj2head.group) - self.assertListEqual([5, 6, 2, 2], obj2head.get_bbox()) + self.assertListEqual([5.5, 6, 2, 2], obj2head.get_bbox()) self.assertEqual(2, len(item.annotations)) diff --git a/utils/cli/requirements.txt b/utils/cli/requirements.txt index 55fe4f56..14cc33a6 100644 --- a/utils/cli/requirements.txt +++ b/utils/cli/requirements.txt @@ -1,2 +1,2 @@ -Pillow==6.2.0 -requests==2.20.1 +Pillow>=6.2.0 +requests>=2.20.1