From 74f720a3d2865c7d88807b33ec4997754498f35e Mon Sep 17 00:00:00 2001 From: zhiltsov-max Date: Fri, 22 Nov 2019 19:13:10 +0300 Subject: [PATCH] Add dataset export facility (#813) * Add datumaro django application * Add cvat task datumaro bindings * Add REST api for task export * Add scheduler service * Updated CHANGELOG.md --- .travis.yml | 1 + .vscode/launch.json | 17 + CHANGELOG.md | 2 + Dockerfile | 4 + cvat/apps/dataset_manager/__init__.py | 0 cvat/apps/dataset_manager/bindings.py | 176 +++++ .../extractors/cvat_rest_api_task_images.py | 120 +++ cvat/apps/dataset_manager/task.py | 351 +++++++++ cvat/apps/dataset_manager/util.py | 15 + cvat/apps/engine/models.py | 11 + cvat/apps/engine/views.py | 63 ++ cvat/requirements/base.txt | 1 + cvat/settings/base.py | 1 + datumaro/README.md | 36 + datumaro/datum.py | 8 + datumaro/datumaro/__init__.py | 89 +++ datumaro/datumaro/__main__.py | 12 + datumaro/datumaro/cli/__init__.py | 5 + datumaro/datumaro/cli/add_command.py | 21 + datumaro/datumaro/cli/create_command.py | 21 + datumaro/datumaro/cli/explain_command.py | 192 +++++ datumaro/datumaro/cli/export_command.py | 69 ++ datumaro/datumaro/cli/inference/__init__.py | 33 + datumaro/datumaro/cli/item/__init__.py | 38 + datumaro/datumaro/cli/model/__init__.py | 127 ++++ datumaro/datumaro/cli/project/__init__.py | 283 +++++++ datumaro/datumaro/cli/project/diff.py | 274 +++++++ datumaro/datumaro/cli/remove_command.py | 21 + datumaro/datumaro/cli/source/__init__.py | 219 ++++++ datumaro/datumaro/cli/stats_command.py | 69 ++ datumaro/datumaro/cli/util/__init__.py | 5 + datumaro/datumaro/cli/util/project.py | 20 + datumaro/datumaro/components/__init__.py | 5 + .../components/algorithms/__init__.py | 5 + .../datumaro/components/algorithms/rise.py | 219 ++++++ datumaro/datumaro/components/comparator.py | 113 +++ datumaro/datumaro/components/config.py | 238 ++++++ datumaro/datumaro/components/config_model.py | 83 ++ datumaro/datumaro/components/converter.py | 8 + .../components/converters/__init__.py | 43 ++ .../components/converters/datumaro.py | 294 ++++++++ .../datumaro/components/converters/ms_coco.py | 386 ++++++++++ .../datumaro/components/converters/voc.py | 370 +++++++++ .../datumaro/components/dataset_filter.py | 193 +++++ datumaro/datumaro/components/extractor.py | 549 ++++++++++++++ .../components/extractors/__init__.py | 50 ++ .../components/extractors/datumaro.py | 214 ++++++ .../datumaro/components/extractors/ms_coco.py | 297 ++++++++ .../datumaro/components/extractors/voc.py | 705 +++++++++++++++++ .../datumaro/components/formats/__init__.py | 5 + .../datumaro/components/formats/datumaro.py | 12 + .../datumaro/components/formats/ms_coco.py | 23 + datumaro/datumaro/components/formats/voc.py | 103 +++ .../datumaro/components/importers/__init__.py | 24 + .../datumaro/components/importers/datumaro.py | 25 + .../datumaro/components/importers/ms_coco.py | 69 ++ datumaro/datumaro/components/importers/voc.py | 77 ++ datumaro/datumaro/components/launcher.py | 95 +++ .../datumaro/components/launchers/__init__.py | 13 + .../datumaro/components/launchers/openvino.py | 189 +++++ datumaro/datumaro/components/project.py | 712 ++++++++++++++++++ datumaro/datumaro/util/__init__.py | 20 + datumaro/datumaro/util/command_targets.py | 110 +++ datumaro/datumaro/util/image.py | 30 + datumaro/datumaro/util/mask_tools.py | 96 +++ datumaro/datumaro/util/test_utils.py | 39 + datumaro/docs/cli_design.mm | 147 ++++ datumaro/docs/design.md | 228 ++++++ datumaro/docs/images/cli_design.png | Bin 0 -> 93636 bytes datumaro/docs/images/mvvm.png | Bin 0 -> 30318 bytes datumaro/docs/quickstart.md | 325 ++++++++ datumaro/requirements.txt | 11 + datumaro/test.py | 5 + datumaro/tests/__init__.py | 0 datumaro/tests/test_RISE.py | 230 ++++++ datumaro/tests/test_coco_format.py | 389 ++++++++++ datumaro/tests/test_command_targets.py | 131 ++++ datumaro/tests/test_datumaro_format.py | 101 +++ datumaro/tests/test_diff.py | 142 ++++ datumaro/tests/test_project.py | 450 +++++++++++ datumaro/tests/test_voc_format.py | 487 ++++++++++++ supervisord.conf | 6 + 82 files changed, 10370 insertions(+) create mode 100644 cvat/apps/dataset_manager/__init__.py create mode 100644 cvat/apps/dataset_manager/bindings.py create mode 100644 cvat/apps/dataset_manager/export_templates/extractors/cvat_rest_api_task_images.py create mode 100644 cvat/apps/dataset_manager/task.py create mode 100644 cvat/apps/dataset_manager/util.py create mode 100644 datumaro/README.md create mode 100755 datumaro/datum.py create mode 100644 datumaro/datumaro/__init__.py create mode 100644 datumaro/datumaro/__main__.py create mode 100644 datumaro/datumaro/cli/__init__.py create mode 100644 datumaro/datumaro/cli/add_command.py create mode 100644 datumaro/datumaro/cli/create_command.py create mode 100644 datumaro/datumaro/cli/explain_command.py create mode 100644 datumaro/datumaro/cli/export_command.py create mode 100644 datumaro/datumaro/cli/inference/__init__.py create mode 100644 datumaro/datumaro/cli/item/__init__.py create mode 100644 datumaro/datumaro/cli/model/__init__.py create mode 100644 datumaro/datumaro/cli/project/__init__.py create mode 100644 datumaro/datumaro/cli/project/diff.py create mode 100644 datumaro/datumaro/cli/remove_command.py create mode 100644 datumaro/datumaro/cli/source/__init__.py create mode 100644 datumaro/datumaro/cli/stats_command.py create mode 100644 datumaro/datumaro/cli/util/__init__.py create mode 100644 datumaro/datumaro/cli/util/project.py create mode 100644 datumaro/datumaro/components/__init__.py create mode 100644 datumaro/datumaro/components/algorithms/__init__.py create mode 100644 datumaro/datumaro/components/algorithms/rise.py create mode 100644 datumaro/datumaro/components/comparator.py create mode 100644 datumaro/datumaro/components/config.py create mode 100644 datumaro/datumaro/components/config_model.py create mode 100644 datumaro/datumaro/components/converter.py create mode 100644 datumaro/datumaro/components/converters/__init__.py create mode 100644 datumaro/datumaro/components/converters/datumaro.py create mode 100644 datumaro/datumaro/components/converters/ms_coco.py create mode 100644 datumaro/datumaro/components/converters/voc.py create mode 100644 datumaro/datumaro/components/dataset_filter.py create mode 100644 datumaro/datumaro/components/extractor.py create mode 100644 datumaro/datumaro/components/extractors/__init__.py create mode 100644 datumaro/datumaro/components/extractors/datumaro.py create mode 100644 datumaro/datumaro/components/extractors/ms_coco.py create mode 100644 datumaro/datumaro/components/extractors/voc.py create mode 100644 datumaro/datumaro/components/formats/__init__.py create mode 100644 datumaro/datumaro/components/formats/datumaro.py create mode 100644 datumaro/datumaro/components/formats/ms_coco.py create mode 100644 datumaro/datumaro/components/formats/voc.py create mode 100644 datumaro/datumaro/components/importers/__init__.py create mode 100644 datumaro/datumaro/components/importers/datumaro.py create mode 100644 datumaro/datumaro/components/importers/ms_coco.py create mode 100644 datumaro/datumaro/components/importers/voc.py create mode 100644 datumaro/datumaro/components/launcher.py create mode 100644 datumaro/datumaro/components/launchers/__init__.py create mode 100644 datumaro/datumaro/components/launchers/openvino.py create mode 100644 datumaro/datumaro/components/project.py create mode 100644 datumaro/datumaro/util/__init__.py create mode 100644 datumaro/datumaro/util/command_targets.py create mode 100644 datumaro/datumaro/util/image.py create mode 100644 datumaro/datumaro/util/mask_tools.py create mode 100644 datumaro/datumaro/util/test_utils.py create mode 100644 datumaro/docs/cli_design.mm create mode 100644 datumaro/docs/design.md create mode 100644 datumaro/docs/images/cli_design.png create mode 100644 datumaro/docs/images/mvvm.png create mode 100644 datumaro/docs/quickstart.md create mode 100644 datumaro/requirements.txt create mode 100644 datumaro/test.py create mode 100644 datumaro/tests/__init__.py create mode 100644 datumaro/tests/test_RISE.py create mode 100644 datumaro/tests/test_coco_format.py create mode 100644 datumaro/tests/test_command_targets.py create mode 100644 datumaro/tests/test_datumaro_format.py create mode 100644 datumaro/tests/test_diff.py create mode 100644 datumaro/tests/test_project.py create mode 100644 datumaro/tests/test_voc_format.py diff --git a/.travis.yml b/.travis.yml index f600822d28d2..87631e76006a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,4 +13,5 @@ before_script: script: - docker exec -it cvat /bin/bash -c 'python3 manage.py test cvat/apps utils/cli' + - docker exec -it cvat /bin/bash -c 'python3 manage.py test datumaro/' - docker exec -it cvat /bin/bash -c 'cd cvat-core && npm install && npm run test && npm run coveralls' diff --git a/.vscode/launch.json b/.vscode/launch.json index de8015b16b89..1fefab7fee5b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -71,6 +71,22 @@ "env": {}, "console": "internalConsole" }, + { + "name": "server: RQ - scheduler", + "type": "python", + "request": "launch", + "stopOnEntry": false, + "justMyCode": false, + "pythonPath": "${config:python.pythonPath}", + "program": "${workspaceRoot}/manage.py", + "args": [ + "rqscheduler", + ], + "django": true, + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole" + }, { "name": "server: RQ - low", "type": "python", @@ -177,6 +193,7 @@ "server: django", "server: RQ - default", "server: RQ - low", + "server: RQ - scheduler", "server: git", ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index b4daaa00ee99..6b5d465adabf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ https://github.com/opencv/cvat/issues/750). - Auto segmentation using Mask_RCNN component (Keras+Tensorflow Mask R-CNN Segmentation) - Added MOT CSV format support - Ability to dump/load annotations in LabelMe format from UI +- REST API to export an annotation task (images + annotations) +- Datumaro is an experimental framework to build, analyze, debug and visualize datasets for DL algorithms ### Changed - diff --git a/Dockerfile b/Dockerfile index 001be81efa06..cb5da2f40539 100644 --- a/Dockerfile +++ b/Dockerfile @@ -154,6 +154,10 @@ COPY utils ${HOME}/utils COPY cvat/ ${HOME}/cvat COPY cvat-core/ ${HOME}/cvat-core COPY tests ${HOME}/tests +COPY datumaro/ ${HOME}/datumaro + +RUN sed -r "s/^(.*)#.*$/\1/g" ${HOME}/datumaro/requirements.txt | xargs -n 1 -L 1 pip3 install --no-cache-dir + # Binary option is necessary to correctly apply the patch on Windows platform. # https://unix.stackexchange.com/questions/239364/how-to-fix-hunk-1-failed-at-1-different-line-endings-message RUN patch --binary -p1 < ${HOME}/cvat/apps/engine/static/engine/js/3rdparty.patch diff --git a/cvat/apps/dataset_manager/__init__.py b/cvat/apps/dataset_manager/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py new file mode 100644 index 000000000000..b6949470afbc --- /dev/null +++ b/cvat/apps/dataset_manager/bindings.py @@ -0,0 +1,176 @@ +from collections import OrderedDict +import os +import os.path as osp + +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 + +import datumaro.components.extractor as datumaro +from datumaro.util.image import lazy_image + + +class CvatImagesDirExtractor(datumaro.Extractor): + _SUPPORTED_FORMATS = ['.png', '.jpg'] + + def __init__(self, url): + super().__init__() + + items = [] + for (dirpath, _, filenames) in os.walk(url): + for name in filenames: + path = osp.join(dirpath, name) + if self._is_image(path): + item_id = Task.get_image_frame(path) + item = datumaro.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 + + +class CvatTaskExtractor(datumaro.Extractor): + def __init__(self, url, db_task, user): + self._db_task = db_task + self._categories = self._load_categories() + + cvat_annotations = TaskAnnotation(db_task.id, user) + with transaction.atomic(): + cvat_annotations.init_from_db() + cvat_annotations = Annotation(cvat_annotations.ir_data, db_task) + + dm_annotations = [] + + for cvat_anno in cvat_annotations.group_by_frame(): + dm_anno = self._read_cvat_anno(cvat_anno) + dm_item = datumaro.DatasetItem( + id=cvat_anno.frame, annotations=dm_anno) + dm_annotations.append((dm_item.id, dm_item)) + + dm_annotations = sorted(dm_annotations, key=lambda e: e[0]) + self._items = OrderedDict(dm_annotations) + + 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 _load_categories(self): + categories = {} + label_categories = datumaro.LabelCategories() + + db_labels = self._db_task.label_set.all() + for db_label in db_labels: + db_attributes = db_label.attributespec_set.all() + label_categories.add(db_label.name) + + for db_attr in db_attributes: + label_categories.attributes.add(db_attr.name) + + categories[datumaro.AnnotationType.label] = label_categories + + return categories + + def categories(self): + return self._categories + + def _read_cvat_anno(self, cvat_anno): + item_anno = [] + + categories = self.categories() + label_cat = categories[datumaro.AnnotationType.label] + + label_map = {} + label_attrs = {} + db_labels = self._db_task.label_set.all() + for db_label in db_labels: + label_map[db_label.name] = label_cat.find(db_label.name)[0] + + attrs = {} + db_attributes = db_label.attributespec_set.all() + for db_attr in db_attributes: + attrs[db_attr.name] = db_attr.default_value + label_attrs[db_label.name] = attrs + map_label = lambda label_db_name: label_map[label_db_name] + + 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 = datumaro.LabelObject(label=anno_label, + attributes=anno_attr, group=anno_group) + item_anno.append(anno) + + for shape_obj in cvat_anno.labeled_shapes: + anno_group = shape_obj.group + 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_points = shape_obj.points + if shape_obj.type == ShapeType.POINTS: + anno = datumaro.PointsObject(anno_points, + label=anno_label, attributes=anno_attr, group=anno_group) + elif shape_obj.type == ShapeType.POLYLINE: + anno = datumaro.PolyLineObject(anno_points, + label=anno_label, attributes=anno_attr, group=anno_group) + elif shape_obj.type == ShapeType.POLYGON: + anno = datumaro.PolygonObject(anno_points, + label=anno_label, attributes=anno_attr, group=anno_group) + elif shape_obj.type == ShapeType.RECTANGLE: + x0, y0, x1, y1 = anno_points + anno = datumaro.BboxObject(x0, y0, x1 - x0, y1 - y0, + label=anno_label, attributes=anno_attr, group=anno_group) + else: + raise Exception("Unknown shape type '%s'" % (shape_obj.type)) + + item_anno.append(anno) + + return item_anno \ No newline at end of file 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 new file mode 100644 index 000000000000..276caa62f8bf --- /dev/null +++ b/cvat/apps/dataset_manager/export_templates/extractors/cvat_rest_api_task_images.py @@ -0,0 +1,120 @@ +from collections import OrderedDict +import getpass +import json +import os, os.path as osp +import requests + +from datumaro.components.config import (Config, + SchemaBuilder as _SchemaBuilder, +) +import datumaro.components.extractor as datumaro +from datumaro.util.image import lazy_image, load_image + +from cvat.utils.cli.core import CLI as CVAT_CLI, CVAT_API_V1 + + +CONFIG_SCHEMA = _SchemaBuilder() \ + .add('task_id', int) \ + .add('server_host', str) \ + .add('server_port', int) \ + .build() + +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)) + + def _make_image_loader(self, item_id): + return lazy_image(item_id, + lambda item_id: self._image_loader(item_id, self)) + + def _is_image_cached(self, item_id): + return osp.isfile(self._image_local_path(item_id)) + + def _download_image(self, item_id): + self._connect() + os.makedirs(self._cache_dir, exist_ok=True) + self._cvat_cli.tasks_frame(task_id=self._config.task_id, + frame_ids=[item_id], outdir=self._cache_dir) + + def _connect(self): + if self._session is not None: + return + + session = None + try: + print("Enter credentials for '%s:%s':" % \ + (self._config.server_host, self._config.server_port)) + username = input('User: ') + password = getpass.getpass() + + session = requests.Session() + session.auth = (username, password) + + api = CVAT_API_V1(self._config.server_host, + self._config.server_port) + cli = CVAT_CLI(session, api) + + self._session = session + self._cvat_cli = cli + except Exception: + if session is not None: + session.close() + + def __del__(self): + if hasattr(self, '_session'): + if self._session is not None: + self._session.close() + + @staticmethod + def _image_loader(item_id, extractor): + if not extractor._is_image_cached(item_id): + extractor._download_image(item_id) + local_path = extractor._image_local_path(item_id) + return load_image(local_path) + + def __init__(self, url): + super().__init__() + + local_dir = url + self._local_dir = local_dir + self._cache_dir = osp.join(local_dir, 'images') + + with open(osp.join(url, 'config.json'), 'r') as config_file: + config = json.load(config_file) + config = Config(config, schema=CONFIG_SCHEMA) + self._config = config + + with open(osp.join(url, 'images_meta.json'), 'r') as images_file: + images_meta = json.load(images_file) + image_list = images_meta['images'] + + items = [] + for entry in image_list: + item_id = entry['id'] + item = datumaro.DatasetItem( + id=item_id, image=self._make_image_loader(item_id)) + items.append((item.id, item)) + + items = sorted(items, key=lambda e: e[0]) + items = OrderedDict(items) + self._items = items + + self._cvat_cli = None + self._session = None + + def __iter__(self): + for item in self._items.values(): + yield item + + def __len__(self): + return len(self._items) + + def subsets(self): + return None + + def get(self, item_id, subset=None, path=None): + if path or subset: + raise KeyError() + return self._items[item_id] diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py new file mode 100644 index 000000000000..40a480750fb0 --- /dev/null +++ b/cvat/apps/dataset_manager/task.py @@ -0,0 +1,351 @@ +from datetime import timedelta +import json +import os +import os.path as osp +import shutil +import sys +import tempfile +from urllib.parse import urlsplit + +from django.utils import timezone +import django_rq + +from cvat.apps.engine.log import slogger +from cvat.apps.engine.models import Task, ShapeType +from .util import current_function_name, make_zip_archive + +_DATUMARO_REPO_PATH = osp.join(__file__[:__file__.rfind('cvat/')], 'datumaro') +sys.path.append(_DATUMARO_REPO_PATH) +from datumaro.components.project import Project +import datumaro.components.extractor as datumaro +from .bindings import CvatImagesDirExtractor, CvatTaskExtractor + + +_MODULE_NAME = __package__ + '.' + osp.splitext(osp.basename(__file__))[0] +def log_exception(logger=None, exc_info=True): + if logger is None: + logger = slogger + logger.exception("[%s @ %s]: exception occurred" % \ + (_MODULE_NAME, current_function_name(2)), + exc_info=exc_info) + +_TASK_IMAGES_EXTRACTOR = '_cvat_task_images' +_TASK_ANNO_EXTRACTOR = '_cvat_task_anno' +_TASK_IMAGES_REMOTE_EXTRACTOR = 'cvat_rest_api_task_images' + +def get_export_cache_dir(db_task): + return osp.join(db_task.get_task_dirname(), 'export_cache') + +class TaskProject: + @staticmethod + def _get_datumaro_project_dir(db_task): + return osp.join(db_task.get_task_dirname(), 'datumaro') + + @staticmethod + def create(db_task): + task_project = TaskProject(db_task) + task_project._create() + return task_project + + @staticmethod + def load(db_task): + task_project = TaskProject(db_task) + task_project._load() + task_project._init_dataset() + return task_project + + @staticmethod + def from_task(db_task, user): + task_project = TaskProject(db_task) + task_project._import_from_task(user) + return task_project + + def __init__(self, db_task): + self._db_task = db_task + self._project_dir = self._get_datumaro_project_dir(db_task) + self._project = None + self._dataset = None + + def _create(self): + self._project = Project.generate(self._project_dir) + self._project.add_source('task_%s' % self._db_task.id, { + 'url': self._db_task.get_data_dirname(), + 'format': _TASK_IMAGES_EXTRACTOR, + }) + self._project.env.extractors.register(_TASK_IMAGES_EXTRACTOR, + CvatImagesDirExtractor) + + self._init_dataset() + self._dataset.define_categories(self._generate_categories()) + + self.save() + + def _load(self): + self._project = Project.load(self._project_dir) + self._project.env.extractors.register(_TASK_IMAGES_EXTRACTOR, + CvatImagesDirExtractor) + + def _import_from_task(self, user): + self._project = Project.generate(self._project_dir) + + self._project.add_source('task_%s_images' % self._db_task.id, { + 'url': self._db_task.get_data_dirname(), + 'format': _TASK_IMAGES_EXTRACTOR, + }) + self._project.env.extractors.register(_TASK_IMAGES_EXTRACTOR, + CvatImagesDirExtractor) + + self._project.add_source('task_%s_anno' % self._db_task.id, { + 'format': _TASK_ANNO_EXTRACTOR, + }) + self._project.env.extractors.register(_TASK_ANNO_EXTRACTOR, + lambda url: CvatTaskExtractor(url, + db_task=self._db_task, user=user)) + + self._init_dataset() + + def _init_dataset(self): + self._dataset = self._project.make_dataset() + + def _generate_categories(self): + categories = {} + label_categories = datumaro.LabelCategories() + + db_labels = self._db_task.label_set.all() + for db_label in db_labels: + db_attributes = db_label.attributespec_set.all() + label_categories.add(db_label.name) + + for db_attr in db_attributes: + label_categories.attributes.add(db_attr.name) + + categories[datumaro.AnnotationType.label] = label_categories + + return categories + + def put_annotations(self, annotations): + patch = {} + + categories = self._dataset.categories() + label_cat = categories[datumaro.AnnotationType.label] + + label_map = {} + attr_map = {} + db_labels = self._db_task.label_set.all() + for db_label in db_labels: + label_map[db_label.id] = label_cat.find(db_label.name) + + db_attributes = db_label.attributespec_set.all() + for db_attr in db_attributes: + attr_map[(db_label.id, db_attr.id)] = db_attr.name + map_label = lambda label_db_id: label_map[label_db_id] + map_attr = lambda label_db_id, attr_db_id: \ + attr_map[(label_db_id, attr_db_id)] + + for tag_obj in annotations['tags']: + item_id = str(tag_obj['frame']) + item_anno = patch.get(item_id, []) + + anno_group = tag_obj['group'] + if isinstance(anno_group, int): + anno_group = [anno_group] + anno_label = map_label(tag_obj['label_id']) + anno_attr = {} + for attr in tag_obj['attributes']: + attr_name = map_attr(tag_obj['label_id'], attr['id']) + anno_attr[attr_name] = attr['value'] + + anno = datumaro.LabelObject(label=anno_label, + attributes=anno_attr, group=anno_group) + item_anno.append(anno) + + patch[item_id] = item_anno + + for shape_obj in annotations['shapes']: + item_id = str(shape_obj['frame']) + item_anno = patch.get(item_id, []) + + anno_group = shape_obj['group'] + if isinstance(anno_group, int): + anno_group = [anno_group] + anno_label = map_label(shape_obj['label_id']) + anno_attr = {} + for attr in shape_obj['attributes']: + attr_name = map_attr(shape_obj['label_id'], attr['id']) + anno_attr[attr_name] = attr['value'] + + anno_points = shape_obj['points'] + if shape_obj['type'] == ShapeType.POINTS: + anno = datumaro.PointsObject(anno_points, + label=anno_label, attributes=anno_attr, group=anno_group) + elif shape_obj['type'] == ShapeType.POLYLINE: + anno = datumaro.PolyLineObject(anno_points, + label=anno_label, attributes=anno_attr, group=anno_group) + elif shape_obj['type'] == ShapeType.POLYGON: + anno = datumaro.PolygonObject(anno_points, + label=anno_label, attributes=anno_attr, group=anno_group) + elif shape_obj['type'] == ShapeType.RECTANGLE: + x0, y0, x1, y1 = anno_points + anno = datumaro.BboxObject(x0, y0, x1 - x0, y1 - y0, + label=anno_label, attributes=anno_attr, group=anno_group) + else: + raise Exception("Unknown shape type '%s'" % (shape_obj['type'])) + + item_anno.append(anno) + + patch[item_id] = item_anno + + # TODO: support track annotations + + patch = [datumaro.DatasetItem(id=id_, annotations=anno) \ + for id_, ann in patch.items()] + + self._dataset.update(patch) + + def save(self, save_dir=None, save_images=False): + if self._dataset is not None: + self._dataset.save(save_dir=save_dir, save_images=save_images) + else: + self._project.save(save_dir=save_dir) + + def export(self, dst_format, save_dir, save_images=False, server_url=None): + if self._dataset is None: + self._init_dataset() + if dst_format == DEFAULT_FORMAT: + self._dataset.save(save_dir=save_dir, save_images=save_images) + elif dst_format == DEFAULT_FORMAT_REMOTE: + self._remote_export(save_dir=save_dir, server_url=server_url) + else: + self._dataset.export(output_format=dst_format, + save_dir=save_dir, save_images=save_images) + + def _remote_image_converter(self, save_dir, server_url=None): + os.makedirs(save_dir, exist_ok=True) + + db_task = self._db_task + items = [] + config = { + 'server_host': 'localhost', + 'server_port': '', + 'task_id': db_task.id, + } + if server_url: + parsed_url = urlsplit(server_url) + config['server_host'] = parsed_url.netloc + port = 80 + if parsed_url.port: + port = parsed_url.port + config['server_port'] = int(port) + + images_meta = { + 'images': items, + } + for db_image in self._db_task.image_set.all(): + frame_info = { + 'id': db_image.frame, + 'width': db_image.width, + 'height': db_image.height, + } + items.append(frame_info) + + with open(osp.join(save_dir, 'config.json'), 'w') as config_file: + json.dump(config, config_file) + with open(osp.join(save_dir, 'images_meta.json'), 'w') as images_file: + json.dump(images_meta, images_file) + + def _remote_export(self, save_dir, server_url=None): + if self._dataset is None: + self._init_dataset() + + os.makedirs(save_dir, exist_ok=True) + self._dataset.save(save_dir=save_dir, save_images=False, merge=True) + + exported_project = Project.load(save_dir) + source_name = 'task_%s_images' % self._db_task.id + exported_project.add_source(source_name, { + 'format': _TASK_IMAGES_REMOTE_EXTRACTOR, + }) + self._remote_image_converter( + osp.join(save_dir, exported_project.local_source_dir(source_name)), + server_url=server_url) + exported_project.save() + + templates_dir = osp.join(osp.dirname(__file__), + 'export_templates', 'extractors') + target_dir = osp.join( + exported_project.config.project_dir, + exported_project.config.env_dir, + exported_project.env.config.extractors_dir) + os.makedirs(target_dir, exist_ok=True) + shutil.copyfile( + osp.join(templates_dir, _TASK_IMAGES_REMOTE_EXTRACTOR + '.py'), + osp.join(target_dir, _TASK_IMAGES_REMOTE_EXTRACTOR + '.py')) + + # NOTE: put datumaro component to the archive so that + # it was available to the user + shutil.copytree(_DATUMARO_REPO_PATH, osp.join(save_dir, 'datumaro'), + ignore=lambda src, names: ['__pycache__'] + [ + n for n in names + if sum([int(n.endswith(ext)) for ext in + ['.pyx', '.pyo', '.pyd', '.pyc']]) + ]) + + +DEFAULT_FORMAT = "datumaro_project" +DEFAULT_FORMAT_REMOTE = "datumaro_project_remote" +DEFAULT_CACHE_TTL = timedelta(hours=10) +CACHE_TTL = DEFAULT_CACHE_TTL + +def export_project(task_id, user, dst_format=None, server_url=None): + try: + db_task = Task.objects.get(pk=task_id) + + if not dst_format: + dst_format = DEFAULT_FORMAT + + cache_dir = get_export_cache_dir(db_task) + save_dir = osp.join(cache_dir, dst_format) + archive_path = osp.normpath(save_dir) + '.zip' + + task_time = timezone.localtime(db_task.updated_date).timestamp() + if not (osp.exists(archive_path) and \ + task_time <= osp.getmtime(archive_path)): + os.makedirs(cache_dir, exist_ok=True) + with tempfile.TemporaryDirectory( + dir=cache_dir, prefix=dst_format + '_') as temp_dir: + project = TaskProject.from_task(db_task, user) + project.export(dst_format, save_dir=temp_dir, save_images=True, + server_url=server_url) + + os.makedirs(cache_dir, exist_ok=True) + make_zip_archive(temp_dir, archive_path) + + archive_ctime = osp.getctime(archive_path) + scheduler = django_rq.get_scheduler() + cleaning_job = scheduler.enqueue_in(time_delta=CACHE_TTL, + func=clear_export_cache, + task_id=task_id, + file_path=archive_path, file_ctime=archive_ctime) + slogger.task[task_id].info( + "The task '{}' is exported as '{}' " + "and available for downloading for next '{}'. " + "Export cache cleaning job is enqueued, " + "id '{}', start in '{}'".format( + db_task.name, dst_format, CACHE_TTL, + cleaning_job.id, CACHE_TTL)) + + return archive_path + except Exception: + log_exception(slogger.task[task_id]) + raise + +def clear_export_cache(task_id, file_path, file_ctime): + try: + if osp.exists(file_path) and osp.getctime(file_path) == file_ctime: + os.remove(file_path) + slogger.task[task_id].info( + "Export cache file '{}' successfully removed" \ + .format(file_path)) + except Exception: + log_exception(slogger.task[task_id]) + raise \ No newline at end of file diff --git a/cvat/apps/dataset_manager/util.py b/cvat/apps/dataset_manager/util.py new file mode 100644 index 000000000000..8ad9aabcf05b --- /dev/null +++ b/cvat/apps/dataset_manager/util.py @@ -0,0 +1,15 @@ +import inspect +import os, os.path as osp +import zipfile + + +def current_function_name(depth=1): + return inspect.getouterframes(inspect.currentframe())[depth].function + + +def make_zip_archive(src_path, dst_path): + with zipfile.ZipFile(dst_path, 'w') as archive: + for (dirpath, _, filenames) in os.walk(src_path): + for name in filenames: + path = osp.join(dirpath, name) + archive.write(path, osp.relpath(path, src_path)) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 079d5b9b05d9..11710f60c7f3 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -86,6 +86,17 @@ def get_frame_path(self, frame): return path + @staticmethod + def get_image_frame(image_path): + assert image_path.endswith('.jpg') + index = os.path.splitext(os.path.basename(image_path))[0] + + path = os.path.dirname(image_path) + d2 = os.path.basename(path) + d1 = os.path.basename(os.path.dirname(path)) + + return int(d1) * 10000 + int(d2) * 100 + int(index) + def get_frame_step(self): match = re.search("step\s*=\s*([1-9]\d*)", self.frame_filter) return int(match.group(1)) if match else 1 diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index b7c8d621e4c1..691951538d47 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT import os +import os.path as osp import re import traceback from ast import literal_eval @@ -25,6 +26,7 @@ from django_filters import rest_framework as filters import django_rq from django.db import IntegrityError +from django.utils import timezone from . import annotation, task, models @@ -45,6 +47,7 @@ from rest_framework.permissions import SAFE_METHODS from cvat.apps.annotation.models import AnnotationDumper, AnnotationLoader from cvat.apps.annotation.format import get_annotation_formats +import cvat.apps.dataset_manager.task as DatumaroTask # Server REST API @login_required @@ -443,6 +446,66 @@ def frame(self, request, pk, frame): "cannot get frame #{}".format(frame), exc_info=True) return HttpResponseBadRequest(str(e)) + @action(detail=True, methods=['GET'], serializer_class=None, + url_path='export/') + def dataset_export(self, request, pk): + """Export task as a dataset in a specific format""" + + db_task = self.get_object() + + action = request.query_params.get("action", "") + action = action.lower() + if action not in ["", "download"]: + raise serializers.ValidationError( + "Unexpected parameter 'action' specified for the request") + + dst_format = request.query_params.get("format", "") + if not dst_format: + dst_format = DatumaroTask.DEFAULT_FORMAT + dst_format = dst_format.lower() + if 100 < len(dst_format) or not re.fullmatch(r"^[\w_-]+$", dst_format): + raise serializers.ValidationError( + "Unexpected parameter 'format' specified for the request") + + rq_id = "task_dataset_export.{}.{}".format(pk, dst_format) + queue = django_rq.get_queue("default") + + rq_job = queue.fetch_job(rq_id) + if rq_job: + task_time = timezone.localtime(db_task.updated_date) + request_time = timezone.localtime(rq_job.meta.get('request_time', datetime.min)) + if request_time < task_time: + rq_job.cancel() + rq_job.delete() + else: + if rq_job.is_finished: + file_path = rq_job.return_value + if action == "download" and osp.exists(file_path): + rq_job.delete() + + timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M_%S") + filename = "task_{}-{}-{}.zip".format( + db_task.name, timestamp, dst_format) + return sendfile(request, file_path, attachment=True, + attachment_filename=filename.lower()) + else: + if osp.exists(file_path): + return Response(status=status.HTTP_201_CREATED) + elif rq_job.is_failed: + exc_info = str(rq_job.exc_info) + rq_job.delete() + return Response(exc_info, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) + else: + return Response(status=status.HTTP_202_ACCEPTED) + + ttl = DatumaroTask.CACHE_TTL.total_seconds() + queue.enqueue_call(func=DatumaroTask.export_project, + args=(pk, request.user, dst_format), job_id=rq_id, + meta={ 'request_time': timezone.localtime() }, + result_ttl=ttl, failure_ttl=ttl) + return Response(status=status.HTTP_201_CREATED) + class JobViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.UpdateModelMixin): queryset = Job.objects.all().order_by('id') diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index 0e3553c9a676..3c8c5d52617d 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -18,6 +18,7 @@ redis==3.2.0 requests==2.20.0 rjsmin==1.0.12 rq==1.0.0 +rq-scheduler==0.9.1 scipy==1.2.1 sqlparse==0.2.4 django-sendfile==0.3.11 diff --git a/cvat/settings/base.py b/cvat/settings/base.py index e5339d949b11..d6356464f248 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -95,6 +95,7 @@ def generate_ssh_keys(): 'cvat.apps.authentication', 'cvat.apps.documentation', 'cvat.apps.git', + 'cvat.apps.dataset_manager', 'cvat.apps.annotation', 'django_rq', 'compressor', diff --git a/datumaro/README.md b/datumaro/README.md new file mode 100644 index 000000000000..e7b58f54e90b --- /dev/null +++ b/datumaro/README.md @@ -0,0 +1,36 @@ +# Dataset framework + +A framework to prepare, manage, build, analyze datasets + +## Documentation + +-[Quick start guide](docs/quickstart.md) + +## Installation + +Python3.5+ is required. + +To install into a virtual environment do: + +``` 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. + +``` bash +PYTHONPATH="..." +python -m datumaro +python path/to/datum.py +``` + +## Testing + +``` bash +python -m unittest discover -s tests +``` diff --git a/datumaro/datum.py b/datumaro/datum.py new file mode 100755 index 000000000000..d6ae4d2c85de --- /dev/null +++ b/datumaro/datum.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +import sys + +from datumaro import main + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/datumaro/datumaro/__init__.py b/datumaro/datumaro/__init__.py new file mode 100644 index 000000000000..48088c40801e --- /dev/null +++ b/datumaro/datumaro/__init__.py @@ -0,0 +1,89 @@ + +# 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 .components.config 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) + return command(command_args) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/datumaro/datumaro/__main__.py b/datumaro/datumaro/__main__.py new file mode 100644 index 000000000000..9a055fae8f16 --- /dev/null +++ b/datumaro/datumaro/__main__.py @@ -0,0 +1,12 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import sys +from . import main + + +if __name__ == '__main__': + sys.exit(main()) + diff --git a/datumaro/datumaro/cli/__init__.py b/datumaro/datumaro/cli/__init__.py new file mode 100644 index 000000000000..a9773073830c --- /dev/null +++ b/datumaro/datumaro/cli/__init__.py @@ -0,0 +1,5 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + diff --git a/datumaro/datumaro/cli/add_command.py b/datumaro/datumaro/cli/add_command.py new file mode 100644 index 000000000000..49113084b4e1 --- /dev/null +++ b/datumaro/datumaro/cli/add_command.py @@ -0,0 +1,21 @@ + +# 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/create_command.py b/datumaro/datumaro/cli/create_command.py new file mode 100644 index 000000000000..eb52458be0e6 --- /dev/null +++ b/datumaro/datumaro/cli/create_command.py @@ -0,0 +1,21 @@ + +# 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/explain_command.py b/datumaro/datumaro/cli/explain_command.py new file mode 100644 index 000000000000..195b1026b9af --- /dev/null +++ b/datumaro/datumaro/cli/explain_command.py @@ -0,0 +1,192 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import argparse +import cv2 +import logging as log +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 +from .util.project import load_project + + +def build_parser(parser=argparse.ArgumentParser()): + 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, + help="Directory to save output (default: display only)") + + method_sp = parser.add_subparsers(dest='algorithm') + + rise_parser = method_sp.add_parser('rise') + 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', + dest='mask_width', default=7, type=int, + help="Mask width (default: %(default)s)") + rise_parser.add_argument('--mh', '--mask-height', + dest='mask_height', default=7, type=int, + help="Mask height (default: %(default)s)") + rise_parser.add_argument('--prob', default=0.5, type=float, + help="Mask pixel inclusion probability (default: %(default)s)") + rise_parser.add_argument('--iou', '--iou-thresh', + dest='iou_thresh', default=0.9, type=float, + help="IoU match threshold for detections (default: %(default)s)") + rise_parser.add_argument('--nms', '--nms-iou-thresh', + dest='nms_iou_thresh', default=0.0, type=float, + 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)") + 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', + help="Visualize results during computations") + + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + parser.set_defaults(command=explain_command) + + return parser + +def explain_command(args): + from matplotlib import cm + + project = load_project(args.project_dir) + + model = project.make_executable_model(args.model) + + if str(args.algorithm).lower() != 'rise': + raise NotImplementedError() + + rise = RISE(model, + max_samples=args.max_samples, + mask_width=args.mask_width, + mask_height=args.mask_height, + prob=args.prob, + iou_thresh=args.iou_thresh, + nms_thresh=args.nms_iou_thresh, + det_conf_thresh=args.det_conf_thresh, + batch_size=args.batch_size) + + if args.target[0] == TargetKinds.image: + image_path = args.target[1] + image = load_image(image_path) + if model.preferred_input_size() is not None: + h, w = model.preferred_input_size() + image = cv2.resize(image, (w, h)) + + log.info("Running inference explanation for '%s'" % image_path) + heatmap_iter = rise.apply(image, progressive=args.progressive) + + image = image / 255.0 + file_name = osp.splitext(osp.basename(image_path))[0] + if args.progressive: + for i, heatmaps in enumerate(heatmap_iter): + for j, heatmap in enumerate(heatmaps): + hm_painted = cm.jet(heatmap)[:, :, 2::-1] + disp = (image + hm_painted) / 2 + cv2.imshow('heatmap-%s' % j, hm_painted) + cv2.imshow(file_name + '-heatmap-%s' % j, disp) + cv2.waitKey(10) + print("Iter", i, "of", args.max_samples, end='\r') + else: + heatmaps = next(heatmap_iter) + + if args.save_dir is not None: + log.info("Saving inference heatmaps at '%s'" % args.save_dir) + os.makedirs(args.save_dir, exist_ok=True) + + for j, heatmap in enumerate(heatmaps): + save_path = osp.join(args.save_dir, + file_name + '-heatmap-%s.png' % j) + cv2.imwrite(save_path, heatmap * 255.0) + else: + for j, heatmap in enumerate(heatmaps): + disp = (image + cm.jet(heatmap)[:, :, 2::-1]) / 2 + cv2.imshow(file_name + '-heatmap-%s' % j, disp) + cv2.waitKey(0) + elif args.target[0] == TargetKinds.source or \ + args.target[0] == TargetKinds.project: + if args.target[0] == TargetKinds.source: + source_name = args.target[1] + dataset = project.make_source_project(source_name).make_dataset() + log.info("Running inference explanation for '%s'" % source_name) + else: + project_name = project.config.project_name + dataset = project.make_dataset() + log.info("Running inference explanation for '%s'" % project_name) + + for item in dataset: + image = item.image + if image is None: + log.warn( + "Dataset item %s does not have image data. Skipping." % \ + (item.id)) + continue + + if model.preferred_input_size() is not None: + h, w = model.preferred_input_size() + image = cv2.resize(image, (w, h)) + heatmap_iter = rise.apply(image) + + image = image / 255.0 + file_name = osp.splitext(osp.basename(image_path))[0] + heatmaps = next(heatmap_iter) + + if args.save_dir is not None: + log.info("Saving inference heatmaps at '%s'" % args.save_dir) + os.makedirs(args.save_dir, exist_ok=True) + + for j, heatmap in enumerate(heatmaps): + save_path = osp.join(args.save_dir, + file_name + '-heatmap-%s.png' % j) + cv2.imwrite(save_path, heatmap * 255.0) + + if args.progressive: + for j, heatmap in enumerate(heatmaps): + disp = (image + cm.jet(heatmap)[:, :, 2::-1]) / 2 + cv2.imshow(file_name + '-heatmap-%s' % j, disp) + cv2.waitKey(0) + else: + 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/export_command.py b/datumaro/datumaro/cli/export_command.py new file mode 100644 index 000000000000..3bd3efe68d58 --- /dev/null +++ b/datumaro/datumaro/cli/export_command.py @@ -0,0 +1,69 @@ + +# 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 new file mode 100644 index 000000000000..f5d48b7cff38 --- /dev/null +++ b/datumaro/datumaro/cli/inference/__init__.py @@ -0,0 +1,33 @@ + +# 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 new file mode 100644 index 000000000000..6082932a1570 --- /dev/null +++ b/datumaro/datumaro/cli/item/__init__.py @@ -0,0 +1,38 @@ + +# 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/model/__init__.py b/datumaro/datumaro/cli/model/__init__.py new file mode 100644 index 000000000000..b168248f1c4a --- /dev/null +++ b/datumaro/datumaro/cli/model/__init__.py @@ -0,0 +1,127 @@ + +# 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 add_command(args): + project = load_project(args.project_dir) + + log.info("Adding '%s' model to '%s' project" % \ + (args.launcher, project.config.project_name)) + + options = args.launcher_args_extractor(args) + + if args.launcher == 'openvino' and args.copy: + config = project.config + env_config = project.env.config + + model_dir_rel = osp.join( + config.env_dir, env_config.models_dir, args.name) + model_dir = osp.join( + config.project_dir, model_dir_rel) + + os.makedirs(model_dir, exist_ok=True) + + shutil.copy(options.description, + osp.join(model_dir, osp.basename(options.description))) + options.description = \ + osp.join(model_dir_rel, osp.basename(options.description)) + + shutil.copy(options.weights, + osp.join(model_dir, osp.basename(options.weights))) + options.weights = \ + osp.join(model_dir_rel, osp.basename(options.weights)) + + shutil.copy(options.interpretation_script, + osp.join(model_dir, osp.basename(options.interpretation_script))) + options.interpretation_script = \ + osp.join(model_dir_rel, osp.basename(options.interpretation_script)) + + project.add_model(args.name, { + 'launcher': args.launcher, + 'options': vars(options), + }) + + project.save() + + 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_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) + + 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) + + project.remove_model(args.name) + project.save() + + return 0 + +def build_remove_parser(parser): + parser.add_argument('name', + 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 + + +def build_parser(parser=argparse.ArgumentParser()): + command_parsers = parser.add_subparsers(dest='command_name') + + build_add_parser(command_parsers.add_parser('add')) \ + .set_defaults(command=add_command) + + build_remove_parser(command_parsers.add_parser('remove')) \ + .set_defaults(command=remove_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/project/__init__.py b/datumaro/datumaro/cli/project/__init__.py new file mode 100644 index 000000000000..1f4db9786685 --- /dev/null +++ b/datumaro/datumaro/cli/project/__init__.py @@ -0,0 +1,283 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import argparse +import logging as log +import os +import os.path as osp + +from datumaro.components.project import Project +from datumaro.components.comparator import Comparator +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 not args.overwrite and osp.isfile(project_path): + log.error("Project file '%s' already exists" % (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('source_path', + 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('extra_args', nargs=argparse.REMAINDER, + help="Additional arguments for importer") + 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 import_command(args): + project_dir = osp.abspath(args.dst_dir) + project_path = make_project_path(project_dir) + if not args.overwrite and osp.isfile(project_path): + log.error("Project file '%s' already exists" % (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_path, args.format)) + + source_path = osp.abspath(args.source_path) + project = Project.import_from(source_path, args.format) + project.config.project_name = project_name + project.config.project_dir = project_dir + project = project.make_dataset() + project.save(merge=True, save_images=False) + + 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]'" + ) + 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('--save-images', action='store_true', + help="Save images") + return parser + +def export_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().export( + save_dir=dst_dir, + output_format=args.output_format, + filter_expr=args.filter, + save_images=args.save_images) + 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="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('-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) + os.makedirs(dst_dir, exist_ok=False) + + project.make_dataset().extract(filter_expr=args.filter, save_dir=dst_dir) + 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().transform( + 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/project/diff.py b/datumaro/datumaro/cli/project/diff.py new file mode 100644 index 000000000000..8fdae82bc21d --- /dev/null +++ b/datumaro/datumaro/cli/project/diff.py @@ -0,0 +1,274 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from collections import Counter +import cv2 +from enum import Enum +import numpy as np +import os +import os.path as osp + +_formats = ['simple'] + +import warnings +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + import tensorboardX as tb + _formats.append('tensorboard') + +from datumaro.components.extractor import AnnotationType + + +Format = Enum('Formats', _formats) + +class DiffVisualizer: + Format = Format + DEFAULT_FORMAT = Format.simple + + _UNMATCHED_LABEL = -1 + + + def __init__(self, comparator, save_dir, output_format=DEFAULT_FORMAT): + self.comparator = comparator + + if isinstance(output_format, str): + output_format = Format[output_format] + assert output_format in Format + self.output_format = output_format + + self.save_dir = save_dir + if output_format is Format.tensorboard: + logdir = osp.join(self.save_dir, 'logs', 'diff') + self.file_writer = tb.SummaryWriter(logdir) + if output_format is Format.simple: + self.label_diff_writer = None + + self.categories = {} + + self.label_confusion_matrix = Counter() + self.bbox_confusion_matrix = Counter() + + def save_dataset_diff(self, extractor_a, extractor_b): + if self.save_dir: + os.makedirs(self.save_dir, exist_ok=True) + + if len(extractor_a) != len(extractor_b): + print("Datasets have different lengths: %s vs %s" % \ + (len(extractor_a), len(extractor_b))) + + self.categories = {} + + label_mismatch = self.comparator. \ + compare_dataset_labels(extractor_a, extractor_b) + if label_mismatch is None: + print("Datasets have no label information") + elif len(label_mismatch) != 0: + print("Datasets have mismatching labels:") + for a_label, b_label in label_mismatch: + if a_label is None: + print(" > %s" % b_label.name) + elif b_label is None: + print(" < %s" % a_label.name) + else: + print(" %s != %s" % (a_label.name, b_label.name)) + else: + self.categories.update(extractor_a.categories()) + self.categories.update(extractor_b.categories()) + + self.label_confusion_matrix = Counter() + self.bbox_confusion_matrix = Counter() + + if self.output_format is Format.tensorboard: + self.file_writer.reopen() + + for i, (item_a, item_b) in enumerate(zip(extractor_a, extractor_b)): + if item_a.id != item_b.id or not item_a.id or not item_b.id: + print("Dataset items #%s '%s' '%s' do not match" % \ + (i + 1, item_a.id, item_b.id)) + continue + + label_diff = self.comparator.compare_item_labels(item_a, item_b) + self.update_label_confusion(label_diff) + + bbox_diff = self.comparator.compare_item_bboxes(item_a, item_b) + self.update_bbox_confusion(bbox_diff) + + self.save_item_label_diff(item_a, item_b, label_diff) + self.save_item_bbox_diff(item_a, item_b, bbox_diff) + + if len(self.label_confusion_matrix) != 0: + self.save_conf_matrix(self.label_confusion_matrix, + 'labels_confusion.png') + if len(self.bbox_confusion_matrix) != 0: + self.save_conf_matrix(self.bbox_confusion_matrix, + 'bbox_confusion.png') + + if self.output_format is Format.tensorboard: + self.file_writer.flush() + self.file_writer.close() + elif self.output_format is Format.simple: + if self.label_diff_writer: + self.label_diff_writer.flush() + self.label_diff_writer.close() + + def update_label_confusion(self, label_diff): + matches, a_unmatched, b_unmatched = label_diff + for label in matches: + self.label_confusion_matrix[(label, label)] += 1 + for a_label in a_unmatched: + self.label_confusion_matrix[(a_label, self._UNMATCHED_LABEL)] += 1 + for b_label in b_unmatched: + self.label_confusion_matrix[(self._UNMATCHED_LABEL, b_label)] += 1 + + def update_bbox_confusion(self, bbox_diff): + matches, mispred, a_unmatched, b_unmatched = bbox_diff + for a_bbox, b_bbox in matches: + self.bbox_confusion_matrix[(a_bbox.label, b_bbox.label)] += 1 + for a_bbox, b_bbox in mispred: + self.bbox_confusion_matrix[(a_bbox.label, b_bbox.label)] += 1 + for a_bbox in a_unmatched: + self.bbox_confusion_matrix[(a_bbox.label, self._UNMATCHED_LABEL)] += 1 + for b_bbox in b_unmatched: + self.bbox_confusion_matrix[(self._UNMATCHED_LABEL, b_bbox.label)] += 1 + + @classmethod + def draw_text_with_background(cls, frame, text, origin, + font=cv2.FONT_HERSHEY_SIMPLEX, scale=1.0, + color=(0, 0, 0), thickness=1, bgcolor=(1, 1, 1)): + text_size, baseline = cv2.getTextSize(text, font, scale, thickness) + cv2.rectangle(frame, + tuple((origin + (0, baseline)).astype(int)), + tuple((origin + (text_size[0], -text_size[1])).astype(int)), + bgcolor, cv2.FILLED) + cv2.putText(frame, text, + tuple(origin.astype(int)), + font, scale, color, thickness) + return text_size, baseline + + def draw_detection_roi(self, frame, x, y, w, h, label, conf, color): + cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2) + + text = '%s %.2f%%' % (label, 100.0 * conf) + text_scale = 0.5 + font = cv2.FONT_HERSHEY_SIMPLEX + text_size = cv2.getTextSize(text, font, text_scale, 1) + line_height = np.array([0, text_size[0][1]]) + self.draw_text_with_background(frame, text, + np.array([x, y]) - line_height * 0.5, + font, scale=text_scale, color=[255 - c for c in color]) + + def get_label(self, label_id): + cat = self.categories.get(AnnotationType.label) + if cat is None: + return str(label_id) + return cat.items[label_id].name + + def draw_bbox(self, img, shape, color): + x, y, w, h = shape.get_bbox() + self.draw_detection_roi(img, int(x), int(y), int(w), int(h), + self.get_label(shape.label), shape.attributes.get('score', 1), + color) + + def get_label_diff_file(self): + if self.label_diff_writer is None: + self.label_diff_writer = \ + open(osp.join(self.save_dir, 'label_diff.txt'), 'w') + return self.label_diff_writer + + def save_item_label_diff(self, item_a, item_b, diff): + _, a_unmatched, b_unmatched = diff + + if 0 < len(a_unmatched) + len(b_unmatched): + if self.output_format is Format.simple: + f = self.get_label_diff_file() + f.write(item_a.id + '\n') + for a_label in a_unmatched: + f.write(' >%s\n' % self.get_label(a_label)) + for b_label in b_unmatched: + f.write(' <%s\n' % self.get_label(b_label)) + elif self.output_format is Format.tensorboard: + tag = item_a.id + for a_label in a_unmatched: + self.file_writer.add_text(tag, + '>%s\n' % self.get_label(a_label)) + for b_label in b_unmatched: + self.file_writer.add_text(tag, + '<%s\n' % self.get_label(b_label)) + + def save_item_bbox_diff(self, item_a, item_b, diff): + _, mispred, a_unmatched, b_unmatched = diff + + if 0 < len(a_unmatched) + len(b_unmatched) + len(mispred): + img_a = item_a.image.copy() + img_b = img_a.copy() + for a_bbox, b_bbox in mispred: + self.draw_bbox(img_a, a_bbox, (0, 255, 0)) + self.draw_bbox(img_b, b_bbox, (0, 0, 255)) + for a_bbox in a_unmatched: + self.draw_bbox(img_a, a_bbox, (255, 255, 0)) + for b_bbox in b_unmatched: + self.draw_bbox(img_b, b_bbox, (255, 255, 0)) + + img = np.hstack([img_a, img_b]) + + path = osp.join(self.save_dir, 'diff_%s' % item_a.id) + + if self.output_format is Format.simple: + cv2.imwrite(path + '.png', img) + elif self.output_format is Format.tensorboard: + self.save_as_tensorboard(img, path) + + def save_as_tensorboard(self, img, name): + img = img[:, :, ::-1] # to RGB + img = np.transpose(img, (2, 0, 1)) # to (C, H, W) + img = img.astype(dtype=np.uint8) + self.file_writer.add_image(name, img) + + def save_conf_matrix(self, conf_matrix, filename): + import matplotlib.pyplot as plt + + classes = None + label_categories = self.categories.get(AnnotationType.label) + if label_categories is not None: + classes = { id: c.name for id, c in enumerate(label_categories.items) } + if classes is None: + classes = { c: 'label_%s' % c for c, _ in conf_matrix } + classes[self._UNMATCHED_LABEL] = 'unmatched' + + class_idx = { id: i for i, id in enumerate(classes.keys()) } + matrix = np.zeros((len(classes), len(classes)), dtype=int) + for idx_pair in conf_matrix: + index = (class_idx[idx_pair[0]], class_idx[idx_pair[1]]) + matrix[index] = conf_matrix[idx_pair] + + labels = [label for id, label in classes.items()] + + fig = plt.figure() + fig.add_subplot(111) + table = plt.table( + cellText=matrix, + colLabels=labels, + rowLabels=labels, + loc ='center') + table.auto_set_font_size(False) + table.set_fontsize(8) + table.scale(3, 3) + # Removing ticks and spines enables you to get the figure only with table + plt.tick_params(axis='x', which='both', bottom=False, top=False, labelbottom=False) + plt.tick_params(axis='y', which='both', right=False, left=False, labelleft=False) + for pos in ['right','top','bottom','left']: + plt.gca().spines[pos].set_visible(False) + + for idx_pair in conf_matrix: + i = class_idx[idx_pair[0]] + j = class_idx[idx_pair[1]] + if conf_matrix[idx_pair] != 0: + if i != j: + table._cells[(i + 1, j)].set_facecolor('#FF0000') + else: + table._cells[(i + 1, j)].set_facecolor('#00FF00') + + plt.savefig(osp.join(self.save_dir, filename), + bbox_inches='tight', pad_inches=0.05) diff --git a/datumaro/datumaro/cli/remove_command.py b/datumaro/datumaro/cli/remove_command.py new file mode 100644 index 000000000000..f419cd3a7e14 --- /dev/null +++ b/datumaro/datumaro/cli/remove_command.py @@ -0,0 +1,21 @@ + +# 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 new file mode 100644 index 000000000000..cd17e2e89caf --- /dev/null +++ b/datumaro/datumaro/cli/source/__init__.py @@ -0,0 +1,219 @@ + +# 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("Source '%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 data to the project") + + parser.add_argument('-f', '--format', default=None, + help="Name of the source dataset format (default: 'project')") + parser.add_argument('-n', '--name', default=None, + help="Name of the source to be imported") + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + 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) + 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) + 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('-d', '--dest', dest='dst_dir', required=True, + help="Directory to save output") + parser.add_argument('-f', '--output-format', required=True, + help="Output format (default: %(default)s)") + parser.add_argument('-p', '--project', dest='project_dir', default='.', + help="Directory of the project to operate on (default: current dir)") + return parser + +def export_command(args): + project = load_project(args.project_dir) + + dst_dir = osp.abspath(args.dst_dir) + os.makedirs(dst_dir, exist_ok=False) + + source_project = project.make_source_project(args.name) + source_project.make_dataset().export( + save_dir=args.dst_dir, + output_format=args.output_format) + 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 new file mode 100644 index 000000000000..333883dedcef --- /dev/null +++ b/datumaro/datumaro/cli/stats_command.py @@ -0,0 +1,69 @@ + +# 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 new file mode 100644 index 000000000000..a9773073830c --- /dev/null +++ b/datumaro/datumaro/cli/util/__init__.py @@ -0,0 +1,5 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + diff --git a/datumaro/datumaro/cli/util/project.py b/datumaro/datumaro/cli/util/project.py new file mode 100644 index 000000000000..6e1f5e650bdc --- /dev/null +++ b/datumaro/datumaro/cli/util/project.py @@ -0,0 +1,20 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import os.path as osp + +from datumaro.components.project import Project, \ + PROJECT_DEFAULT_CONFIG as DEFAULT_CONFIG + + +def make_project_path(project_dir, project_filename=None): + if project_filename is None: + project_filename = DEFAULT_CONFIG.project_filename + return osp.join(project_dir, project_filename) + +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 diff --git a/datumaro/datumaro/components/__init__.py b/datumaro/datumaro/components/__init__.py new file mode 100644 index 000000000000..a9773073830c --- /dev/null +++ b/datumaro/datumaro/components/__init__.py @@ -0,0 +1,5 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + diff --git a/datumaro/datumaro/components/algorithms/__init__.py b/datumaro/datumaro/components/algorithms/__init__.py new file mode 100644 index 000000000000..a9773073830c --- /dev/null +++ b/datumaro/datumaro/components/algorithms/__init__.py @@ -0,0 +1,5 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + diff --git a/datumaro/datumaro/components/algorithms/rise.py b/datumaro/datumaro/components/algorithms/rise.py new file mode 100644 index 000000000000..e61737d31749 --- /dev/null +++ b/datumaro/datumaro/components/algorithms/rise.py @@ -0,0 +1,219 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +# pylint: disable=unused-variable + +import cv2 +import numpy as np +from math import ceil + +from datumaro.components.extractor import * + + +def flatmatvec(mat): + return np.reshape(mat, (len(mat), -1)) + +def expand(array, axis=None): + if axis is None: + axis = len(array.shape) + return np.expand_dims(array, axis=axis) + +class RISE: + """ + Implements RISE: Randomized Input Sampling for + Explanation of Black-box Models algorithm + See explanations at: https://arxiv.org/pdf/1806.07421.pdf + """ + + def __init__(self, model, + max_samples=None, mask_width=7, mask_height=7, prob=0.5, + iou_thresh=0.9, nms_thresh=0.0, det_conf_thresh=0.0, + batch_size=1): + self.model = model + self.max_samples = max_samples + self.mask_height = mask_height + self.mask_width = mask_width + self.prob = prob + self.iou_thresh = iou_thresh + self.nms_thresh = nms_thresh + self.det_conf_thresh = det_conf_thresh + self.batch_size = batch_size + + @staticmethod + def split_outputs(annotations): + labels = [] + bboxes = [] + for r in annotations: + if r.type is AnnotationType.label: + labels.append(r) + elif r.type is AnnotationType.bbox: + bboxes.append(r) + return labels, bboxes + + @staticmethod + def nms(boxes, iou_thresh=0.5): + indices = np.argsort([b.attributes['score'] for b in boxes]) + ious = np.array([[a.iou(b) for b in boxes] for a in boxes]) + + predictions = [] + while len(indices) != 0: + i = len(indices) - 1 + pred_idx = indices[i] + to_remove = [i] + predictions.append(boxes[pred_idx]) + for i, box_idx in enumerate(indices[:i]): + if iou_thresh < ious[pred_idx, box_idx]: + to_remove.append(i) + indices = np.delete(indices, to_remove) + + return predictions + + def normalize_hmaps(self, heatmaps, counts): + eps = np.finfo(heatmaps.dtype).eps + mhmaps = flatmatvec(heatmaps) + mhmaps /= expand(counts * self.prob + eps) + mhmaps -= expand(np.min(mhmaps, axis=1)) + mhmaps /= expand(np.max(mhmaps, axis=1) + eps) + return np.reshape(mhmaps, heatmaps.shape) + + def apply(self, image, progressive=False): + assert len(image.shape) == 3, \ + "Expected an input image in (H, W, C) format" + assert image.shape[2] in [3, 4], \ + "Expected BGR or BGRA input" + image = image[:, :, :3].astype(np.float32) + + model = self.model + iou_thresh = self.iou_thresh + + image_size = np.array((image.shape[:2])) + mask_size = np.array((self.mask_height, self.mask_width)) + cell_size = np.ceil(image_size / mask_size) + upsampled_size = np.ceil((mask_size + 1) * cell_size) + + rng = lambda shape=None: np.random.rand(*shape) + samples = np.prod(image_size) + if self.max_samples is not None: + samples = min(self.max_samples, samples) + batch_size = self.batch_size + + result = next(iter(model.launch(expand(image, 0)))) + result_labels, result_bboxes = self.split_outputs(result) + if 0 < self.det_conf_thresh: + result_bboxes = [b for b in result_bboxes \ + if self.det_conf_thresh <= b.attributes['score']] + if 0 < self.nms_thresh: + result_bboxes = self.nms(result_bboxes, self.nms_thresh) + + predicted_labels = set() + if len(result_labels) != 0: + predicted_label = max(result_labels, + key=lambda r: r.attributes['score']).label + predicted_labels.add(predicted_label) + if len(result_bboxes) != 0: + for bbox in result_bboxes: + predicted_labels.add(bbox.label) + predicted_labels = { label: idx \ + for idx, label in enumerate(predicted_labels) } + + predicted_bboxes = result_bboxes + + heatmaps_count = len(predicted_labels) + len(predicted_bboxes) + heatmaps = np.zeros((heatmaps_count, *image_size), dtype=np.float32) + total_counts = np.zeros(heatmaps_count, dtype=np.int32) + confs = np.zeros(heatmaps_count, dtype=np.float32) + + heatmap_id = 0 + + label_heatmaps = None + label_total_counts = None + label_confs = None + if len(predicted_labels) != 0: + step = len(predicted_labels) + label_heatmaps = heatmaps[heatmap_id : heatmap_id + step] + label_total_counts = total_counts[heatmap_id : heatmap_id + step] + label_confs = confs[heatmap_id : heatmap_id + step] + heatmap_id += step + + bbox_heatmaps = None + bbox_total_counts = None + bbox_confs = None + if len(predicted_bboxes) != 0: + step = len(predicted_bboxes) + bbox_heatmaps = heatmaps[heatmap_id : heatmap_id + step] + bbox_total_counts = total_counts[heatmap_id : heatmap_id + step] + bbox_confs = confs[heatmap_id : heatmap_id + step] + heatmap_id += step + + ups_mask = np.empty(upsampled_size.astype(int), dtype=np.float32) + masks = np.empty((batch_size, *image_size), dtype=np.float32) + + full_batch_inputs = np.empty((batch_size, *image.shape), dtype=np.float32) + current_heatmaps = np.empty_like(heatmaps) + for b in range(ceil(samples / batch_size)): + batch_pos = b * batch_size + current_batch_size = min(samples - batch_pos, batch_size) + + batch_masks = masks[: current_batch_size] + for i in range(current_batch_size): + mask = (rng(mask_size) < self.prob).astype(np.float32) + cv2.resize(mask, (int(upsampled_size[1]), int(upsampled_size[0])), + ups_mask) + + offsets = np.round(rng((2,)) * cell_size) + mask = ups_mask[ + int(offsets[0]):int(image_size[0] + offsets[0]), + int(offsets[1]):int(image_size[1] + offsets[1]) ] + batch_masks[i] = mask + + batch_inputs = full_batch_inputs[:current_batch_size] + np.multiply(expand(batch_masks), expand(image, 0), out=batch_inputs) + + results = model.launch(batch_inputs) + for mask, result in zip(batch_masks, results): + result_labels, result_bboxes = self.split_outputs(result) + + confs.fill(0) + if len(predicted_labels) != 0: + for r in result_labels: + idx = predicted_labels.get(r.label, None) + if idx is not None: + label_total_counts[idx] += 1 + label_confs[idx] += r.attributes['score'] + for r in result_bboxes: + idx = predicted_labels.get(r.label, None) + if idx is not None: + label_total_counts[idx] += 1 + label_confs[idx] += r.attributes['score'] + + if len(predicted_bboxes) != 0 and len(result_bboxes) != 0: + if 0 < self.det_conf_thresh: + result_bboxes = [b for b in result_bboxes \ + if self.det_conf_thresh <= b.attributes['score']] + if 0 < self.nms_thresh: + result_bboxes = self.nms(result_bboxes, self.nms_thresh) + + for detection in result_bboxes: + for pred_idx, pred in enumerate(predicted_bboxes): + if pred.label != detection.label: + continue + + iou = pred.iou(detection) + assert 0 <= iou and iou <= 1 + if iou < iou_thresh: + continue + + bbox_total_counts[pred_idx] += 1 + + conf = detection.attributes['score'] + bbox_confs[pred_idx] += conf + + np.multiply.outer(confs, mask, out=current_heatmaps) + heatmaps += current_heatmaps + + if progressive: + yield self.normalize_hmaps(heatmaps.copy(), total_counts) + + yield self.normalize_hmaps(heatmaps, total_counts) \ No newline at end of file diff --git a/datumaro/datumaro/components/comparator.py b/datumaro/datumaro/components/comparator.py new file mode 100644 index 000000000000..842a3963a989 --- /dev/null +++ b/datumaro/datumaro/components/comparator.py @@ -0,0 +1,113 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from itertools import zip_longest +import numpy as np + +from datumaro.components.extractor import AnnotationType, LabelCategories + + +class Comparator: + def __init__(self, + iou_threshold=0.5, conf_threshold=0.9): + self.iou_threshold = iou_threshold + self.conf_threshold = conf_threshold + + @staticmethod + def iou(box_a, box_b): + return box_a.iou(box_b) + + # pylint: disable=no-self-use + def compare_dataset_labels(self, extractor_a, extractor_b): + a_label_cat = extractor_a.categories().get(AnnotationType.label) + b_label_cat = extractor_b.categories().get(AnnotationType.label) + if not a_label_cat and not b_label_cat: + return None + if not a_label_cat: + a_label_cat = LabelCategories() + if not b_label_cat: + b_label_cat = LabelCategories() + + mismatches = [] + for a_label, b_label in zip_longest(a_label_cat.items, b_label_cat.items): + if a_label != b_label: + mismatches.append((a_label, b_label)) + return mismatches + # pylint: enable=no-self-use + + def compare_item_labels(self, item_a, item_b): + conf_threshold = self.conf_threshold + + a_labels = set([ann.label for ann in item_a.annotations \ + if ann.type is AnnotationType.label and \ + conf_threshold < ann.attributes.get('score', 1)]) + b_labels = set([ann.label for ann in item_b.annotations \ + if ann.type is AnnotationType.label and \ + conf_threshold < ann.attributes.get('score', 1)]) + + a_unmatched = a_labels - b_labels + b_unmatched = b_labels - a_labels + matches = a_labels & b_labels + + return matches, a_unmatched, b_unmatched + + def compare_item_bboxes(self, item_a, item_b): + iou_threshold = self.iou_threshold + conf_threshold = self.conf_threshold + + a_boxes = [ann for ann in item_a.annotations \ + if ann.type is AnnotationType.bbox and \ + conf_threshold < ann.attributes.get('score', 1)] + b_boxes = [ann for ann in item_b.annotations \ + if ann.type is AnnotationType.bbox and \ + conf_threshold < ann.attributes.get('score', 1)] + a_boxes.sort(key=lambda ann: 1 - ann.attributes.get('score', 1)) + b_boxes.sort(key=lambda ann: 1 - ann.attributes.get('score', 1)) + + # a_matches: indices of b_boxes matched to a bboxes + # b_matches: indices of a_boxes matched to b bboxes + a_matches = -np.ones(len(a_boxes), dtype=int) + b_matches = -np.ones(len(b_boxes), dtype=int) + + iou_matrix = np.array([ + [self.iou(a, b) for b in b_boxes] for a in a_boxes + ]) + + # matches: boxes we succeeded to match completely + # mispred: boxes we succeeded to match, having label mismatch + matches = [] + mispred = [] + + for a_idx, a_bbox in enumerate(a_boxes): + if len(b_boxes) == 0: + break + matched_b = a_matches[a_idx] + iou_max = max(iou_matrix[a_idx, matched_b], iou_threshold) + for b_idx, b_bbox in enumerate(b_boxes): + if 0 <= b_matches[b_idx]: # assign a_bbox with max conf + continue + iou = iou_matrix[a_idx, b_idx] + if iou < iou_max: + continue + iou_max = iou + matched_b = b_idx + + if matched_b < 0: + continue + a_matches[a_idx] = matched_b + b_matches[matched_b] = a_idx + + b_bbox = b_boxes[matched_b] + + if a_bbox.label == b_bbox.label: + matches.append( (a_bbox, b_bbox) ) + else: + mispred.append( (a_bbox, b_bbox) ) + + # *_umatched: boxes of (*) we failed to match + a_unmatched = [a_boxes[i] for i, m in enumerate(a_matches) if m < 0] + b_unmatched = [b_boxes[i] for i, m in enumerate(b_matches) if m < 0] + + return matches, mispred, a_unmatched, b_unmatched diff --git a/datumaro/datumaro/components/config.py b/datumaro/datumaro/components/config.py new file mode 100644 index 000000000000..330d01f5bf2e --- /dev/null +++ b/datumaro/datumaro/components/config.py @@ -0,0 +1,238 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import yaml + + +class Schema: + class Item: + def __init__(self, ctor, internal=False): + self.ctor = ctor + self.internal = internal + + def __call__(self, *args, **kwargs): + return self.ctor(*args, **kwargs) + + def __init__(self, items=None, fallback=None): + self._items = {} + if items is not None: + self._items.update(items) + self._fallback = fallback + + def _get_items(self, allow_fallback=True): + all_items = {} + + if allow_fallback and self._fallback is not None: + all_items.update(self._fallback) + all_items.update(self._items) + + return all_items + + def items(self, allow_fallback=True): + return self._get_items(allow_fallback=allow_fallback).items() + + def keys(self, allow_fallback=True): + return self._get_items(allow_fallback=allow_fallback).keys() + + def values(self, allow_fallback=True): + return self._get_items(allow_fallback=allow_fallback).values() + + def __contains__(self, key): + return key in self.keys() + + def __len__(self): + return len(self._get_items()) + + def __iter__(self): + return iter(self._get_items()) + + def __getitem__(self, key): + default = object() + value = self.get(key, default=default) + if value is default: + raise KeyError('Key "%s" does not exist' % (key)) + return value + + def get(self, key, default=None): + found = self._items.get(key, default) + if found is not default: + return found + + if self._fallback is not None: + return self._fallback.get(key, default) + +class SchemaBuilder: + def __init__(self): + self._items = {} + + def add(self, name, ctor=str, internal=False): + if name in self._items: + raise KeyError('Key "%s" already exists' % (name)) + + self._items[name] = Schema.Item(ctor, internal=internal) + return self + + def build(self): + return Schema(self._items) + +class Config: + def __init__(self, config=None, fallback=None, schema=None, mutable=True): + # schema should be established first + self.__dict__['_schema'] = schema + self.__dict__['_mutable'] = True + + self.__dict__['_config'] = {} + if fallback is not None: + for k, v in fallback.items(allow_fallback=False): + self.set(k, v) + if config is not None: + self.update(config) + + self.__dict__['_mutable'] = mutable + + def _items(self, allow_fallback=True, allow_internal=True): + all_config = {} + if allow_fallback and self._schema is not None: + for key, item in self._schema.items(): + all_config[key] = item() + all_config.update(self._config) + + if not allow_internal and self._schema is not None: + for key, item in self._schema.items(): + if item.internal: + all_config.pop(key) + return all_config + + def items(self, allow_fallback=True, allow_internal=True): + return self._items( + allow_fallback=allow_fallback, + allow_internal=allow_internal + ).items() + + def keys(self, allow_fallback=True, allow_internal=True): + return self._items( + allow_fallback=allow_fallback, + allow_internal=allow_internal + ).keys() + + def values(self, allow_fallback=True, allow_internal=True): + return self._items( + allow_fallback=allow_fallback, + allow_internal=allow_internal + ).values() + + def __contains__(self, key): + return key in self.keys() + + def __len__(self): + return len(self.items()) + + def __iter__(self): + return iter(zip(self.keys(), self.values())) + + def __getitem__(self, key): + default = object() + value = self.get(key, default=default) + if value is default: + raise KeyError('Key "%s" does not exist' % (key)) + return value + + def __setitem__(self, key, value): + return self.set(key, value) + + def __getattr__(self, key): + return self.get(key) + + def __setattr__(self, key, value): + return self.set(key, value) + + def __eq__(self, other): + try: + for k, my_v in self.items(allow_internal=False): + other_v = other[k] + if my_v != other_v: + return False + return True + except Exception: + return False + + def update(self, other): + for k, v in other.items(): + self.set(k, v) + + def remove(self, key): + if not self._mutable: + raise Exception("Cannot set value of immutable object") + + self._config.pop(key, None) + + def get(self, key, default=None): + found = self._config.get(key, default) + if found is not default: + return found + + if self._schema is not None: + found = self._schema.get(key, default) + if found is not default: + # ignore mutability + found = found() + self._config[key] = found + return found + + return found + + def set(self, key, value): + if not self._mutable: + raise Exception("Cannot set value of immutable object") + + if self._schema is not None: + if key not in self._schema: + raise Exception("Can not set key '%s' - schema mismatch" % (key)) + + schema_entry = self._schema[key] + schema_entry_instance = schema_entry() + if not isinstance(value, type(schema_entry_instance)): + if isinstance(value, dict) and \ + isinstance(schema_entry_instance, Config): + schema_entry_instance.update(value) + value = schema_entry_instance + else: + raise Exception("Can not set key '%s' - schema mismatch" % (key)) + + self._config[key] = value + return value + + @staticmethod + def parse(path): + with open(path, 'r') as f: + return Config(yaml.safe_load(f)) + + @staticmethod + def yaml_representer(dumper, value): + return dumper.represent_data( + value._items(allow_internal=False, allow_fallback=False)) + + def dump(self, path): + with open(path, 'w+') as f: + yaml.dump(self, f) + +yaml.add_multi_representer(Config, Config.yaml_representer) + + +class DefaultConfig(Config): + def __init__(self, default=None): + super().__init__() + self.__dict__['_default'] = default + + def set(self, key, value): + if key not in self.keys(allow_fallback=False): + value = self._default(value) + return super().set(key, value) + else: + return super().set(key, value) + + +VERSION = '0.1.0' +DEFAULT_FORMAT = 'datumaro' \ No newline at end of file diff --git a/datumaro/datumaro/components/config_model.py b/datumaro/datumaro/components/config_model.py new file mode 100644 index 000000000000..d02ed41b8af0 --- /dev/null +++ b/datumaro/datumaro/components/config_model.py @@ -0,0 +1,83 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from datumaro.components.config import Config, \ + DefaultConfig as _DefaultConfig, \ + SchemaBuilder as _SchemaBuilder + + +SOURCE_SCHEMA = _SchemaBuilder() \ + .add('url', str) \ + .add('format', str) \ + .add('options', str) \ + .build() + +class Source(Config): + def __init__(self, config=None): + super().__init__(config, schema=SOURCE_SCHEMA) + + +MODEL_SCHEMA = _SchemaBuilder() \ + .add('launcher', str) \ + .add('model_dir', str, internal=True) \ + .add('options', dict) \ + .build() + +class Model(Config): + def __init__(self, config=None): + super().__init__(config, schema=MODEL_SCHEMA) + + +ENV_SCHEMA = _SchemaBuilder() \ + .add('models_dir', str) \ + .add('importers_dir', str) \ + .add('launchers_dir', str) \ + .add('converters_dir', str) \ + .add('extractors_dir', str) \ + \ + .add('models', lambda: _DefaultConfig( + lambda v=None: Model(v))) \ + .build() + +ENV_DEFAULT_CONFIG = Config({ + 'models_dir': 'models', + 'importers_dir': 'importers', + 'launchers_dir': 'launchers', + 'converters_dir': 'converters', + 'extractors_dir': 'extractors', +}, mutable=False, schema=ENV_SCHEMA) + + +PROJECT_SCHEMA = _SchemaBuilder() \ + .add('project_name', str) \ + .add('format_version', int) \ + \ + .add('sources_dir', str) \ + .add('dataset_dir', str) \ + .add('build_dir', str) \ + .add('subsets', list) \ + .add('sources', lambda: _DefaultConfig( + lambda v=None: Source(v))) \ + .add('filter', str) \ + \ + .add('project_filename', str, internal=True) \ + .add('project_dir', str, internal=True) \ + .add('env_filename', str, internal=True) \ + .add('env_dir', str, internal=True) \ + .build() + +PROJECT_DEFAULT_CONFIG = Config({ + 'project_name': 'undefined', + 'format_version': 1, + + 'sources_dir': 'sources', + 'dataset_dir': 'dataset', + 'build_dir': 'build', + + 'project_filename': 'config.yaml', + 'project_dir': '', + 'env_filename': 'datumaro.yaml', + 'env_dir': '.datumaro', +}, mutable=False, schema=PROJECT_SCHEMA) diff --git a/datumaro/datumaro/components/converter.py b/datumaro/datumaro/components/converter.py new file mode 100644 index 000000000000..7d2aece0e349 --- /dev/null +++ b/datumaro/datumaro/components/converter.py @@ -0,0 +1,8 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +class Converter: + def __call__(self, extractor, save_dir): + raise NotImplementedError() diff --git a/datumaro/datumaro/components/converters/__init__.py b/datumaro/datumaro/components/converters/__init__.py new file mode 100644 index 000000000000..8b7ab56db90b --- /dev/null +++ b/datumaro/datumaro/components/converters/__init__.py @@ -0,0 +1,43 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from datumaro.components.converters.datumaro import DatumaroConverter + +from datumaro.components.converters.ms_coco import ( + CocoConverter, + CocoImageInfoConverter, + CocoCaptionsConverter, + CocoInstancesConverter, + CocoPersonKeypointsConverter, + CocoLabelsConverter, +) + +from datumaro.components.converters.voc import ( + VocConverter, + VocClassificationConverter, + VocDetectionConverter, + VocLayoutConverter, + VocActionConverter, + VocSegmentationConverter, +) + + +items = [ + ('datumaro', DatumaroConverter), + + ('coco', CocoConverter), + ('coco_images', CocoImageInfoConverter), + ('coco_captions', CocoCaptionsConverter), + ('coco_instances', CocoInstancesConverter), + ('coco_person_kp', CocoPersonKeypointsConverter), + ('coco_labels', CocoLabelsConverter), + + ('voc', VocConverter), + ('voc_cls', VocClassificationConverter), + ('voc_det', VocDetectionConverter), + ('voc_segm', VocSegmentationConverter), + ('voc_action', VocActionConverter), + ('voc_layout', VocLayoutConverter), +] diff --git a/datumaro/datumaro/components/converters/datumaro.py b/datumaro/datumaro/components/converters/datumaro.py new file mode 100644 index 000000000000..9e17f82bd368 --- /dev/null +++ b/datumaro/datumaro/components/converters/datumaro.py @@ -0,0 +1,294 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +# pylint: disable=no-self-use + +import cv2 +import json +import os +import os.path as osp + +from datumaro.components.converter import Converter +from datumaro.components.extractor import ( + DEFAULT_SUBSET_NAME, + AnnotationType, Annotation, + LabelObject, MaskObject, PointsObject, PolygonObject, + PolyLineObject, BboxObject, CaptionObject, + LabelCategories, MaskCategories, PointsCategories +) +from datumaro.components.formats.datumaro import DatumaroPath +from datumaro.util.mask_tools import apply_colormap + + +def _cast(value, type_conv, default=None): + if value is None: + return default + try: + return type_conv(value) + except Exception: + return default + +class _SubsetWriter: + def __init__(self, name, converter): + self._name = name + self._converter = converter + + self._data = { + 'info': {}, + 'categories': {}, + 'items': [], + } + + self._next_mask_id = 1 + + @property + def categories(self): + return self._data['categories'] + + @property + def items(self): + return self._data['items'] + + def write_item(self, item): + annotations = [] + self.items.append({ + 'id': item.id, + 'path': item.path, + 'annotations': annotations, + }) + + for ann in item.annotations: + if isinstance(ann, LabelObject): + converted_ann = self._convert_label_object(ann) + elif isinstance(ann, MaskObject): + converted_ann = self._convert_mask_object(ann) + elif isinstance(ann, PointsObject): + converted_ann = self._convert_points_object(ann) + elif isinstance(ann, PolyLineObject): + converted_ann = self._convert_polyline_object(ann) + elif isinstance(ann, PolygonObject): + converted_ann = self._convert_polygon_object(ann) + elif isinstance(ann, BboxObject): + converted_ann = self._convert_bbox_object(ann) + elif isinstance(ann, CaptionObject): + converted_ann = self._convert_caption_object(ann) + else: + raise NotImplementedError() + annotations.append(converted_ann) + + def write_categories(self, categories): + for ann_type, desc in categories.items(): + if isinstance(desc, LabelCategories): + converted_desc = self._convert_label_categories(desc) + elif isinstance(desc, MaskCategories): + converted_desc = self._convert_mask_categories(desc) + elif isinstance(desc, PointsCategories): + converted_desc = self._convert_points_categories(desc) + else: + raise NotImplementedError() + self.categories[ann_type.name] = converted_desc + + def write(self, save_dir): + with open(osp.join(save_dir, '%s.json' % (self._name)), 'w') as f: + json.dump(self._data, f) + + def _convert_annotation(self, obj): + assert isinstance(obj, Annotation) + + ann_json = { + 'id': _cast(obj.id, int), + 'type': _cast(obj.type.name, str), + 'attributes': obj.attributes, + 'group': _cast(obj.group, int, None), + } + return ann_json + + def _convert_label_object(self, obj): + converted = self._convert_annotation(obj) + + converted.update({ + 'label_id': _cast(obj.label, int), + }) + return converted + + def _save_mask(self, mask): + mask_id = None + if mask is None: + return mask_id + + if self._converter._apply_colormap: + categories = self._converter._extractor.categories() + categories = categories[AnnotationType.mask] + colormap = categories.colormap + + mask = apply_colormap(mask, colormap) + + mask_id = self._next_mask_id + self._next_mask_id += 1 + + filename = '%d%s' % (mask_id, DatumaroPath.MASK_EXT) + masks_dir = osp.join(self._converter._annotations_dir, + DatumaroPath.MASKS_DIR) + os.makedirs(masks_dir, exist_ok=True) + path = osp.join(masks_dir, filename) + cv2.imwrite(path, mask) + return mask_id + + def _convert_mask_object(self, obj): + converted = self._convert_annotation(obj) + + mask = obj.image + mask_id = None + if mask is not None: + mask_id = self._save_mask(mask) + + converted.update({ + 'label_id': _cast(obj.label, int), + 'mask_id': _cast(mask_id, int), + }) + return converted + + def _convert_polyline_object(self, obj): + converted = self._convert_annotation(obj) + + converted.update({ + 'label_id': _cast(obj.label, int), + 'points': [float(p) for p in obj.get_points()], + }) + return converted + + def _convert_polygon_object(self, obj): + converted = self._convert_annotation(obj) + + converted.update({ + 'label_id': _cast(obj.label, int), + 'points': [float(p) for p in obj.get_points()], + }) + return converted + + def _convert_bbox_object(self, obj): + converted = self._convert_annotation(obj) + + converted.update({ + 'label_id': _cast(obj.label, int), + 'bbox': [float(p) for p in obj.get_bbox()], + }) + return converted + + def _convert_points_object(self, obj): + converted = self._convert_annotation(obj) + + converted.update({ + 'label_id': _cast(obj.label, int), + 'points': [float(p) for p in obj.points], + 'visibility': [int(v.value) for v in obj.visibility], + }) + return converted + + def _convert_caption_object(self, obj): + converted = self._convert_annotation(obj) + + converted.update({ + 'caption': _cast(obj.caption, str), + }) + return converted + + def _convert_label_categories(self, obj): + converted = { + 'labels': [], + } + for label in obj.items: + converted['labels'].append({ + 'name': _cast(label.name, str), + 'parent': _cast(label.parent, str), + }) + return converted + + def _convert_mask_categories(self, obj): + converted = { + 'colormap': [], + } + for label_id, color in obj.colormap.items(): + converted['colormap'].append({ + 'label_id': int(label_id), + 'r': int(color[0]), + 'g': int(color[1]), + 'b': int(color[2]), + }) + return converted + + def _convert_points_categories(self, obj): + converted = { + 'items': [], + } + for label_id, item in obj.items.items(): + converted['items'].append({ + 'label_id': int(label_id), + 'labels': [_cast(label, str) for label in item.labels], + 'adjacent': [int(v) for v in item.adjacent], + }) + return converted + +class _Converter: + def __init__(self, extractor, save_dir, + save_images=False, apply_colormap=False): + self._extractor = extractor + self._save_dir = save_dir + self._save_images = save_images + self._apply_colormap = apply_colormap + + def convert(self): + os.makedirs(self._save_dir, exist_ok=True) + + images_dir = osp.join(self._save_dir, DatumaroPath.IMAGES_DIR) + os.makedirs(images_dir, exist_ok=True) + self._images_dir = images_dir + + annotations_dir = osp.join(self._save_dir, DatumaroPath.ANNOTATIONS_DIR) + os.makedirs(annotations_dir, exist_ok=True) + self._annotations_dir = annotations_dir + + subsets = self._extractor.subsets() + if len(subsets) == 0: + subsets = [ None ] + subsets = [n if n else DEFAULT_SUBSET_NAME for n in subsets] + subsets = { name: _SubsetWriter(name, self) for name in subsets } + + for subset, writer in subsets.items(): + writer.write_categories(self._extractor.categories()) + + for item in self._extractor: + subset = item.subset + if not subset: + subset = DEFAULT_SUBSET_NAME + writer = subsets[subset] + + if self._save_images: + self._save_image(item) + writer.write_item(item) + + for subset, writer in subsets.items(): + writer.write(annotations_dir) + + def _save_image(self, item): + image = item.image + if image is None: + return + + image_path = osp.join(self._images_dir, + str(item.id) + DatumaroPath.IMAGE_EXT) + cv2.imwrite(image_path, image) + +class DatumaroConverter(Converter): + def __init__(self, save_images=False, apply_colormap=False): + super().__init__() + self._save_images = save_images + self._apply_colormap = apply_colormap + + def __call__(self, extractor, save_dir): + converter = _Converter(extractor, save_dir, + apply_colormap=self._apply_colormap, + save_images=self._save_images) + converter.convert() \ No newline at end of file diff --git a/datumaro/datumaro/components/converters/ms_coco.py b/datumaro/datumaro/components/converters/ms_coco.py new file mode 100644 index 000000000000..d8d2da899c97 --- /dev/null +++ b/datumaro/datumaro/components/converters/ms_coco.py @@ -0,0 +1,386 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import cv2 +import json +import numpy as np +import os +import os.path as osp + +import pycocotools.mask as mask_utils + +from datumaro.components.converter import Converter +from datumaro.components.extractor import ( + DEFAULT_SUBSET_NAME, AnnotationType, PointsObject, BboxObject +) +from datumaro.components.formats.ms_coco import CocoAnnotationType, CocoPath +from datumaro.util import find +import datumaro.util.mask_tools as mask_tools + + +def _cast(value, type_conv, default=None): + if value is None: + return default + try: + return type_conv(value) + except Exception: + return default + +class _TaskConverter: + def __init__(self): + self._min_ann_id = 1 + + data = { + 'licenses': [], + 'info': {}, + 'categories': [], + 'images': [], + 'annotations': [] + } + + data['licenses'].append({ + 'name': '', + 'id': 0, + 'url': '' + }) + + data['info'] = { + 'contributor': '', + 'date_created': '', + 'description': '', + 'url': '', + 'version': '', + 'year': '' + } + self._data = data + + def is_empty(self): + return len(self._data['annotations']) == 0 + + def save_image_info(self, item, filename): + if item.has_image: + h, w, _ = item.image.shape + else: + h = 0 + w = 0 + + self._data['images'].append({ + 'id': _cast(item.id, int, 0), + 'width': int(w), + 'height': int(h), + 'file_name': filename, + 'license': 0, + 'flickr_url': '', + 'coco_url': '', + 'date_captured': 0, + }) + + def save_categories(self, dataset): + raise NotImplementedError() + + def save_annotations(self, item): + raise NotImplementedError() + + def write(self, path): + next_id = self._min_ann_id + for ann in self.annotations: + if ann['id'] is None: + ann['id'] = next_id + next_id += 1 + + with open(path, 'w') as outfile: + json.dump(self._data, outfile) + + @property + def annotations(self): + return self._data['annotations'] + + @property + def categories(self): + return self._data['categories'] + + def _get_ann_id(self, annotation): + ann_id = annotation.id + if ann_id: + self._min_ann_id = max(ann_id, self._min_ann_id) + return ann_id + +class _InstancesConverter(_TaskConverter): + def save_categories(self, dataset): + label_categories = dataset.categories().get(AnnotationType.label) + if label_categories is None: + return + + for idx, cat in enumerate(label_categories.items): + self.categories.append({ + 'id': 1 + idx, + 'name': cat.name, + 'supercategory': cat.parent, + }) + + def save_annotations(self, item): + for ann in item.annotations: + if ann.type != AnnotationType.bbox: + continue + + is_crowd = ann.attributes.get('is_crowd', False) + segmentation = None + if ann.group is not None: + if is_crowd: + segmentation = find(item.annotations, lambda x: \ + x.group == ann.group and x.type == AnnotationType.mask) + if segmentation is not None: + binary_mask = np.array(segmentation.image, dtype=np.bool) + binary_mask = np.asfortranarray(binary_mask, dtype=np.uint8) + segmentation = mask_utils.encode(binary_mask) + area = mask_utils.area(segmentation) + segmentation = mask_tools.convert_mask_to_rle(binary_mask) + else: + segmentation = find(item.annotations, lambda x: \ + x.group == ann.group and x.type == AnnotationType.polygon) + if segmentation is not None: + area = ann.area() + segmentation = [segmentation.get_points()] + if segmentation is None: + is_crowd = False + segmentation = [ann.get_polygon()] + area = ann.area() + + elem = { + 'id': self._get_ann_id(ann), + 'image_id': _cast(item.id, int, 0), + 'category_id': _cast(ann.label, int, -1) + 1, + 'segmentation': segmentation, + 'area': float(area), + 'bbox': ann.get_bbox(), + 'iscrowd': int(is_crowd), + } + if 'score' in ann.attributes: + elem['score'] = float(ann.attributes['score']) + + self.annotations.append(elem) + +class _ImageInfoConverter(_TaskConverter): + def is_empty(self): + return len(self._data['images']) == 0 + + def save_categories(self, dataset): + pass + + def save_annotations(self, item): + pass + +class _CaptionsConverter(_TaskConverter): + def save_categories(self, dataset): + pass + + def save_annotations(self, item): + for ann in item.annotations: + if ann.type != AnnotationType.caption: + continue + + elem = { + 'id': self._get_ann_id(ann), + 'image_id': _cast(item.id, int, 0), + 'category_id': 0, # NOTE: workaround for a bug in cocoapi + 'caption': ann.caption, + } + if 'score' in ann.attributes: + elem['score'] = float(ann.attributes['score']) + + self.annotations.append(elem) + +class _KeypointsConverter(_TaskConverter): + def save_categories(self, dataset): + label_categories = dataset.categories().get(AnnotationType.label) + if label_categories is None: + return + points_categories = dataset.categories().get(AnnotationType.points) + if points_categories is None: + return + + for idx, kp_cat in points_categories.items.items(): + label_cat = label_categories.items[idx] + + cat = { + 'id': 1 + idx, + 'name': label_cat.name, + 'supercategory': label_cat.parent, + 'keypoints': [str(l) for l in kp_cat.labels], + 'skeleton': [int(i) for i in kp_cat.adjacent], + } + self.categories.append(cat) + + def save_annotations(self, item): + for ann in item.annotations: + if ann.type != AnnotationType.points: + continue + + elem = { + 'id': self._get_ann_id(ann), + 'image_id': _cast(item.id, int, 0), + 'category_id': _cast(ann.label, int, -1) + 1, + } + if 'score' in ann.attributes: + elem['score'] = float(ann.attributes['score']) + + keypoints = [] + points = ann.get_points() + visibility = ann.visibility + for index in range(0, len(points), 2): + kp = points[index : index + 2] + state = visibility[index // 2].value + keypoints.extend([*kp, state]) + + num_visible = len([v for v in visibility \ + if v == PointsObject.Visibility.visible]) + + bbox = find(item.annotations, lambda x: \ + x.group == ann.group and \ + x.type == AnnotationType.bbox and + x.label == ann.label) + if bbox is None: + bbox = BboxObject(*ann.get_bbox()) + elem.update({ + 'segmentation': bbox.get_polygon(), + 'area': bbox.area(), + 'bbox': bbox.get_bbox(), + 'iscrowd': 0, + 'keypoints': keypoints, + 'num_keypoints': num_visible, + }) + + self.annotations.append(elem) + +class _LabelsConverter(_TaskConverter): + def save_categories(self, dataset): + label_categories = dataset.categories().get(AnnotationType.label) + if label_categories is None: + return + + for idx, cat in enumerate(label_categories.items): + self.categories.append({ + 'id': 1 + idx, + 'name': cat.name, + 'supercategory': cat.parent, + }) + + def save_annotations(self, item): + for ann in item.annotations: + if ann.type != AnnotationType.label: + continue + + elem = { + 'id': self._get_ann_id(ann), + 'image_id': _cast(item.id, int, 0), + 'category_id': int(ann.label) + 1, + } + if 'score' in ann.attributes: + elem['score'] = float(ann.attributes['score']) + + self.annotations.append(elem) + +class _Converter: + _TASK_CONVERTER = { + CocoAnnotationType.image_info: _ImageInfoConverter, + CocoAnnotationType.instances: _InstancesConverter, + CocoAnnotationType.person_keypoints: _KeypointsConverter, + CocoAnnotationType.captions: _CaptionsConverter, + CocoAnnotationType.labels: _LabelsConverter, + } + + def __init__(self, extractor, save_dir, save_images=False, task=None): + if not task: + task = list(self._TASK_CONVERTER.keys()) + elif task in CocoAnnotationType: + task = [task] + self._task = task + self._extractor = extractor + self._save_dir = save_dir + self._save_images = save_images + + def make_dirs(self): + self._images_dir = osp.join(self._save_dir, CocoPath.IMAGES_DIR) + os.makedirs(self._images_dir, exist_ok=True) + + self._ann_dir = osp.join(self._save_dir, CocoPath.ANNOTATIONS_DIR) + os.makedirs(self._ann_dir, exist_ok=True) + + def make_task_converter(self, task): + return self._TASK_CONVERTER[task]() + + def make_task_converters(self): + return { + task: self.make_task_converter(task) for task in self._task + } + + def save_image(self, item, subset_name, filename): + path = osp.join(self._images_dir, subset_name, filename) + cv2.imwrite(path, item.image) + + return path + + def convert(self): + self.make_dirs() + + subsets = self._extractor.subsets() + if len(subsets) == 0: + subsets = [ None ] + + for subset_name in subsets: + if subset_name: + subset = self._extractor.get_subset(subset_name) + else: + subset_name = DEFAULT_SUBSET_NAME + subset = self._extractor + + task_converters = self.make_task_converters() + for task_conv in task_converters.values(): + task_conv.save_categories(subset) + for item in subset: + filename = '' + if item.has_image: + filename = str(item.id) + CocoPath.IMAGE_EXT + if self._save_images: + self.save_image(item, subset_name, filename) + for task_conv in task_converters.values(): + task_conv.save_image_info(item, filename) + task_conv.save_annotations(item) + + for task, task_conv in task_converters.items(): + if not task_conv.is_empty(): + task_conv.write(osp.join(self._ann_dir, + '%s_%s.json' % (task.name, subset_name))) + +class CocoConverter(Converter): + def __init__(self, task=None, save_images=False): + super().__init__() + self._task = task + self._save_images = save_images + + def __call__(self, extractor, save_dir): + converter = _Converter(extractor, save_dir, + save_images=self._save_images, task=self._task) + converter.convert() + +def CocoInstancesConverter(save_images=False): + return CocoConverter(CocoAnnotationType.instances, + save_images=save_images) + +def CocoImageInfoConverter(save_images=False): + return CocoConverter(CocoAnnotationType.image_info, + save_images=save_images) + +def CocoPersonKeypointsConverter(save_images=False): + return CocoConverter(CocoAnnotationType.person_keypoints, + save_images=save_images) + +def CocoCaptionsConverter(save_images=False): + return CocoConverter(CocoAnnotationType.captions, + save_images=save_images) + +def CocoLabelsConverter(save_images=False): + return CocoConverter(CocoAnnotationType.labels, + save_images=save_images) \ No newline at end of file diff --git a/datumaro/datumaro/components/converters/voc.py b/datumaro/datumaro/components/converters/voc.py new file mode 100644 index 000000000000..c513150e7f9e --- /dev/null +++ b/datumaro/datumaro/components/converters/voc.py @@ -0,0 +1,370 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import cv2 +from collections import OrderedDict, defaultdict +import os +import os.path as osp +from lxml import etree as ET + +from datumaro.components.converter import Converter +from datumaro.components.extractor import DEFAULT_SUBSET_NAME, AnnotationType +from datumaro.components.formats.voc import VocLabel, VocAction, \ + VocBodyPart, VocPose, VocTask, VocPath, VocColormap, VocInstColormap +from datumaro.util import find +from datumaro.util.mask_tools import apply_colormap + + +def _write_xml_bbox(bbox, parent_elem): + x, y, w, h = bbox + bbox_elem = ET.SubElement(parent_elem, 'bndbox') + ET.SubElement(bbox_elem, 'xmin').text = str(x) + ET.SubElement(bbox_elem, 'ymin').text = str(y) + ET.SubElement(bbox_elem, 'xmax').text = str(x + w) + ET.SubElement(bbox_elem, 'ymax').text = str(y + h) + return bbox_elem + +class _Converter: + _LABELS = set([entry.name for entry in VocLabel]) + _BODY_PARTS = set([entry.name for entry in VocBodyPart]) + _ACTIONS = set([entry.name for entry in VocAction]) + + def __init__(self, task, extractor, save_dir, + apply_colormap=True, save_images=False): + + assert not task or task in VocTask + self._task = task + self._extractor = extractor + self._save_dir = save_dir + self._apply_colormap = apply_colormap + self._save_images = save_images + + self._label_categories = extractor.categories() \ + .get(AnnotationType.label) + self._mask_categories = extractor.categories() \ + .get(AnnotationType.mask) + + def convert(self): + self.init_dirs() + self.save_subsets() + + def init_dirs(self): + save_dir = self._save_dir + subsets_dir = osp.join(save_dir, VocPath.SUBSETS_DIR) + cls_subsets_dir = osp.join(subsets_dir, + VocPath.TASK_DIR[VocTask.classification]) + action_subsets_dir = osp.join(subsets_dir, + VocPath.TASK_DIR[VocTask.action_classification]) + layout_subsets_dir = osp.join(subsets_dir, + VocPath.TASK_DIR[VocTask.person_layout]) + segm_subsets_dir = osp.join(subsets_dir, + VocPath.TASK_DIR[VocTask.segmentation]) + ann_dir = osp.join(save_dir, VocPath.ANNOTATIONS_DIR) + img_dir = osp.join(save_dir, VocPath.IMAGES_DIR) + segm_dir = osp.join(save_dir, VocPath.SEGMENTATION_DIR) + inst_dir = osp.join(save_dir, VocPath.INSTANCES_DIR) + images_dir = osp.join(save_dir, VocPath.IMAGES_DIR) + + os.makedirs(subsets_dir, exist_ok=True) + os.makedirs(ann_dir, exist_ok=True) + os.makedirs(img_dir, exist_ok=True) + os.makedirs(segm_dir, exist_ok=True) + os.makedirs(inst_dir, exist_ok=True) + os.makedirs(images_dir, exist_ok=True) + + self._subsets_dir = subsets_dir + self._cls_subsets_dir = cls_subsets_dir + self._action_subsets_dir = action_subsets_dir + self._layout_subsets_dir = layout_subsets_dir + self._segm_subsets_dir = segm_subsets_dir + self._ann_dir = ann_dir + self._img_dir = img_dir + self._segm_dir = segm_dir + self._inst_dir = inst_dir + self._images_dir = images_dir + + def get_label(self, label_id): + return self._label_categories.items[label_id].name + + def save_subsets(self): + subsets = self._extractor.subsets() + if len(subsets) == 0: + subsets = [ None ] + + for subset_name in subsets: + if subset_name: + subset = self._extractor.get_subset(subset_name) + else: + subset_name = DEFAULT_SUBSET_NAME + subset = self._extractor + + class_lists = OrderedDict() + clsdet_list = OrderedDict() + action_list = OrderedDict() + layout_list = OrderedDict() + segm_list = OrderedDict() + + for item in subset: + item_id = str(item.id) + if self._save_images: + data = item.image + if data is not None: + cv2.imwrite(osp.join(self._images_dir, + str(item_id) + VocPath.IMAGE_EXT), + data) + + labels = [] + bboxes = [] + masks = [] + for a in item.annotations: + if a.type == AnnotationType.label: + labels.append(a) + elif a.type == AnnotationType.bbox: + bboxes.append(a) + elif a.type == AnnotationType.mask: + masks.append(a) + + if len(bboxes) != 0: + root_elem = ET.Element('annotation') + if '_' in item_id: + folder = item_id[ : item_id.find('_')] + else: + folder = '' + ET.SubElement(root_elem, 'folder').text = folder + ET.SubElement(root_elem, 'filename').text = \ + item_id + VocPath.IMAGE_EXT + + if item.has_image: + h, w, c = item.image.shape + size_elem = ET.SubElement(root_elem, 'size') + ET.SubElement(size_elem, 'width').text = str(w) + ET.SubElement(size_elem, 'height').text = str(h) + ET.SubElement(size_elem, 'depth').text = str(c) + + item_segmented = 0 < len(masks) + if item_segmented: + ET.SubElement(root_elem, 'segmented').text = '1' + + objects_with_parts = [] + objects_with_actions = defaultdict(dict) + + main_bboxes = [] + layout_bboxes = [] + for bbox in bboxes: + label = self.get_label(bbox.label) + if label in self._LABELS: + main_bboxes.append(bbox) + elif label in self._BODY_PARTS: + layout_bboxes.append(bbox) + + for new_obj_id, obj in enumerate(main_bboxes): + attr = obj.attributes + + obj_elem = ET.SubElement(root_elem, 'object') + ET.SubElement(obj_elem, 'name').text = self.get_label(obj.label) + + pose = attr.get('pose') + if pose is not None: + ET.SubElement(obj_elem, 'pose').text = VocPose[pose].name + + truncated = attr.get('truncated') + if truncated is not None: + ET.SubElement(obj_elem, 'truncated').text = '%d' % truncated + + difficult = attr.get('difficult') + if difficult is not None: + ET.SubElement(obj_elem, 'difficult').text = '%d' % difficult + + bbox = obj.get_bbox() + if bbox is not None: + _write_xml_bbox(bbox, obj_elem) + + for part in VocBodyPart: + part_bbox = find(layout_bboxes, lambda x: \ + obj.id == x.group and \ + self.get_label(x.label) == part.name) + if part_bbox is not None: + part_elem = ET.SubElement(obj_elem, 'part') + ET.SubElement(part_elem, 'name').text = part.name + _write_xml_bbox(part_bbox.get_bbox(), part_elem) + + objects_with_parts.append(new_obj_id) + + actions = [x for x in labels + if obj.id == x.group and \ + self.get_label(x.label) in self._ACTIONS] + if len(actions) != 0: + actions_elem = ET.SubElement(obj_elem, 'actions') + for action in VocAction: + presented = find(actions, lambda x: \ + self.get_label(x.label) == action.name) is not None + ET.SubElement(actions_elem, action.name).text = \ + '%d' % presented + + objects_with_actions[new_obj_id][action] = presented + + if self._task in [None, + VocTask.detection, + VocTask.person_layout, + VocTask.action_classification]: + with open(osp.join(self._ann_dir, item_id + '.xml'), 'w') as f: + f.write(ET.tostring(root_elem, + encoding='unicode', pretty_print=True)) + + clsdet_list[item_id] = True + layout_list[item_id] = objects_with_parts + action_list[item_id] = objects_with_actions + + for label_obj in labels: + label = self.get_label(label_obj.label) + if label not in self._LABELS: + continue + class_list = class_lists.get(item_id, set()) + class_list.add(label_obj.label) + class_lists[item_id] = class_list + + clsdet_list[item_id] = True + + for mask_obj in masks: + if mask_obj.attributes.get('class') == True: + self.save_segm(osp.join(self._segm_dir, + item_id + VocPath.SEGM_EXT), + mask_obj, self._mask_categories.colormap) + if mask_obj.attributes.get('instances') == True: + self.save_segm(osp.join(self._inst_dir, + item_id + VocPath.SEGM_EXT), + mask_obj, VocInstColormap) + + segm_list[item_id] = True + + if len(item.annotations) == 0: + clsdet_list[item_id] = None + layout_list[item_id] = None + action_list[item_id] = None + segm_list[item_id] = None + + if self._task in [None, + VocTask.classification, + VocTask.detection, + VocTask.action_classification, + VocTask.person_layout]: + self.save_clsdet_lists(subset_name, clsdet_list) + if self._task in [None, VocTask.classification]: + self.save_class_lists(subset_name, class_lists) + if self._task in [None, VocTask.action_classification]: + self.save_action_lists(subset_name, action_list) + if self._task in [None, VocTask.person_layout]: + self.save_layout_lists(subset_name, layout_list) + if self._task in [None, VocTask.segmentation]: + self.save_segm_lists(subset_name, segm_list) + + def save_action_lists(self, subset_name, action_list): + os.makedirs(self._action_subsets_dir, exist_ok=True) + + ann_file = osp.join(self._action_subsets_dir, subset_name + '.txt') + with open(ann_file, 'w') as f: + for item in action_list: + f.write('%s\n' % item) + + if len(action_list) == 0: + return + + for action in VocAction: + ann_file = osp.join(self._action_subsets_dir, + '%s_%s.txt' % (action.name, subset_name)) + with open(ann_file, 'w') as f: + for item, objs in action_list.items(): + if not objs: + continue + for obj_id, obj_actions in objs.items(): + presented = obj_actions[action] + f.write('%s %s % d\n' % \ + (item, 1 + obj_id, 1 if presented else -1)) + + def save_class_lists(self, subset_name, class_lists): + os.makedirs(self._cls_subsets_dir, exist_ok=True) + + if len(class_lists) == 0: + return + + for label in VocLabel: + ann_file = osp.join(self._cls_subsets_dir, + '%s_%s.txt' % (label.name, subset_name)) + with open(ann_file, 'w') as f: + for item, item_labels in class_lists.items(): + if not item_labels: + continue + presented = label.value in item_labels + f.write('%s % d\n' % \ + (item, 1 if presented else -1)) + + def save_clsdet_lists(self, subset_name, clsdet_list): + os.makedirs(self._cls_subsets_dir, exist_ok=True) + + ann_file = osp.join(self._cls_subsets_dir, subset_name + '.txt') + with open(ann_file, 'w') as f: + for item in clsdet_list: + f.write('%s\n' % item) + + def save_segm_lists(self, subset_name, segm_list): + os.makedirs(self._segm_subsets_dir, exist_ok=True) + + ann_file = osp.join(self._segm_subsets_dir, subset_name + '.txt') + with open(ann_file, 'w') as f: + for item in segm_list: + f.write('%s\n' % item) + + def save_layout_lists(self, subset_name, layout_list): + os.makedirs(self._layout_subsets_dir, exist_ok=True) + + ann_file = osp.join(self._layout_subsets_dir, subset_name + '.txt') + with open(ann_file, 'w') as f: + for item, item_layouts in layout_list.items(): + if item_layouts: + for obj_id in item_layouts: + f.write('%s % d\n' % (item, 1 + obj_id)) + else: + f.write('%s\n' % (item)) + + def save_segm(self, path, annotation, colormap): + data = annotation.image + if self._apply_colormap: + if colormap is None: + colormap = VocColormap + data = apply_colormap(data, colormap) + cv2.imwrite(path, data) + +class VocConverter(Converter): + def __init__(self, task=None, save_images=False, apply_colormap=False): + super().__init__() + self._task = task + self._save_images = save_images + self._apply_colormap = apply_colormap + + def __call__(self, extractor, save_dir): + converter = _Converter(self._task, extractor, save_dir, + apply_colormap=self._apply_colormap, + save_images=self._save_images) + converter.convert() + +def VocClassificationConverter(save_images=False): + return VocConverter(VocTask.classification, + save_images=save_images) + +def VocDetectionConverter(save_images=False): + return VocConverter(VocTask.detection, + save_images=save_images) + +def VocLayoutConverter(save_images=False): + return VocConverter(VocTask.person_layout, + save_images=save_images) + +def VocActionConverter(save_images=False): + return VocConverter(VocTask.action_classification, + save_images=save_images) + +def VocSegmentationConverter(save_images=False, apply_colormap=True): + return VocConverter(VocTask.segmentation, + save_images=save_images, apply_colormap=apply_colormap) diff --git a/datumaro/datumaro/components/dataset_filter.py b/datumaro/datumaro/components/dataset_filter.py new file mode 100644 index 000000000000..7b27c49914fe --- /dev/null +++ b/datumaro/datumaro/components/dataset_filter.py @@ -0,0 +1,193 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from lxml import etree as ET +from datumaro.components.extractor import (DatasetItem, Annotation, + LabelObject, MaskObject, PointsObject, PolygonObject, + PolyLineObject, BboxObject, CaptionObject, +) + + +def _cast(value, type_conv, default=None): + if value is None: + return default + try: + return type_conv(value) + except Exception: + return default + +class DatasetItemEncoder: + def encode_item(self, item): + item_elem = ET.Element('item') + ET.SubElement(item_elem, 'id').text = str(item.id) + ET.SubElement(item_elem, 'subset').text = str(item.subset) + + # Dataset wrapper-specific + ET.SubElement(item_elem, 'source').text = \ + str(getattr(item, 'source', None)) + ET.SubElement(item_elem, 'extractor').text = \ + str(getattr(item, 'extractor', None)) + + image = item.image + if image is not None: + item_elem.append(self.encode_image(image)) + + for ann in item.annotations: + item_elem.append(self.encode_object(ann)) + + return item_elem + + @classmethod + def encode_image(cls, image): + image_elem = ET.Element('image') + + h, w, c = image.shape + ET.SubElement(image_elem, 'width').text = str(w) + ET.SubElement(image_elem, 'height').text = str(h) + ET.SubElement(image_elem, 'depth').text = str(c) + + return image_elem + + @classmethod + def encode_annotation(cls, annotation): + assert isinstance(annotation, Annotation) + ann_elem = ET.Element('annotation') + ET.SubElement(ann_elem, 'id').text = str(annotation.id) + ET.SubElement(ann_elem, 'type').text = str(annotation.type.name) + + for k, v in annotation.attributes.items(): + ET.SubElement(ann_elem, k).text = str(v) + + ET.SubElement(ann_elem, 'group').text = str(annotation.group) + + return ann_elem + + @classmethod + def encode_label_object(cls, obj): + ann_elem = cls.encode_annotation(obj) + + ET.SubElement(ann_elem, 'label_id').text = str(obj.label) + + return ann_elem + + @classmethod + def encode_mask_object(cls, obj): + ann_elem = cls.encode_annotation(obj) + + ET.SubElement(ann_elem, 'label_id').text = str(obj.label) + + mask = obj.image + if mask is not None: + ann_elem.append(cls.encode_image(mask)) + + return ann_elem + + @classmethod + def encode_bbox_object(cls, obj): + ann_elem = cls.encode_annotation(obj) + + ET.SubElement(ann_elem, 'label_id').text = str(obj.label) + ET.SubElement(ann_elem, 'x').text = str(obj.x) + ET.SubElement(ann_elem, 'y').text = str(obj.y) + ET.SubElement(ann_elem, 'w').text = str(obj.w) + ET.SubElement(ann_elem, 'h').text = str(obj.h) + ET.SubElement(ann_elem, 'area').text = str(obj.area()) + + return ann_elem + + @classmethod + def encode_points_object(cls, obj): + ann_elem = cls.encode_annotation(obj) + + ET.SubElement(ann_elem, 'label_id').text = str(obj.label) + + x, y, w, h = obj.get_bbox() + area = w * h + bbox_elem = ET.SubElement(ann_elem, 'bbox') + ET.SubElement(bbox_elem, 'x').text = str(x) + ET.SubElement(bbox_elem, 'y').text = str(y) + ET.SubElement(bbox_elem, 'w').text = str(w) + ET.SubElement(bbox_elem, 'h').text = str(h) + ET.SubElement(bbox_elem, 'area').text = str(area) + + points = ann_elem.points + for i in range(0, len(points), 2): + point_elem = ET.SubElement(ann_elem, 'point') + ET.SubElement(point_elem, 'x').text = str(points[i * 2]) + ET.SubElement(point_elem, 'y').text = str(points[i * 2 + 1]) + ET.SubElement(point_elem, 'visible').text = \ + str(ann_elem.visibility[i // 2].name) + + return ann_elem + + @classmethod + def encode_polyline_object(cls, obj): + ann_elem = cls.encode_annotation(obj) + + ET.SubElement(ann_elem, 'label_id').text = str(obj.label) + + x, y, w, h = obj.get_bbox() + area = w * h + bbox_elem = ET.SubElement(ann_elem, 'bbox') + ET.SubElement(bbox_elem, 'x').text = str(x) + ET.SubElement(bbox_elem, 'y').text = str(y) + ET.SubElement(bbox_elem, 'w').text = str(w) + ET.SubElement(bbox_elem, 'h').text = str(h) + ET.SubElement(bbox_elem, 'area').text = str(area) + + points = ann_elem.points + for i in range(0, len(points), 2): + point_elem = ET.SubElement(ann_elem, 'point') + ET.SubElement(point_elem, 'x').text = str(points[i * 2]) + ET.SubElement(point_elem, 'y').text = str(points[i * 2 + 1]) + + return ann_elem + + @classmethod + def encode_caption_object(cls, obj): + ann_elem = cls.encode_annotation(obj) + + ET.SubElement(ann_elem, 'caption').text = str(obj.caption) + + return ann_elem + + def encode_object(self, o): + if isinstance(o, LabelObject): + return self.encode_label_object(o) + if isinstance(o, MaskObject): + return self.encode_mask_object(o) + if isinstance(o, BboxObject): + return self.encode_bbox_object(o) + if isinstance(o, PointsObject): + return self.encode_points_object(o) + if isinstance(o, PolyLineObject): + return self.encode_polyline_object(o) + if isinstance(o, PolygonObject): + return self.encode_polygon_object(o) + if isinstance(o, CaptionObject): + return self.encode_caption_object(o) + if isinstance(o, Annotation): # keep after derived classes + return self.encode_annotation(o) + + if isinstance(o, DatasetItem): + return self.encode_item(o) + + return None + +class XPathDatasetFilter: + def __init__(self, filter_text=None): + self._filter = None + if filter_text is not None: + self._filter = ET.XPath(filter_text) + self._encoder = DatasetItemEncoder() + + def __call__(self, item): + encoded_item = self._serialize_item(item) + if self._filter is None: + return True + return bool(self._filter(encoded_item)) + + def _serialize_item(self, item): + return self._encoder.encode_item(item) \ No newline at end of file diff --git a/datumaro/datumaro/components/extractor.py b/datumaro/datumaro/components/extractor.py new file mode 100644 index 000000000000..b51302f5c31b --- /dev/null +++ b/datumaro/datumaro/components/extractor.py @@ -0,0 +1,549 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from collections import namedtuple +from enum import Enum +import numpy as np + + +AnnotationType = Enum('AnnotationType', + [ + 'label', + 'mask', + 'points', + 'polygon', + 'polyline', + 'bbox', + 'caption', + ]) + +class Annotation: + # pylint: disable=redefined-builtin + def __init__(self, id=None, type=None, attributes=None, group=None): + if id is not None: + id = int(id) + self.id = id + + assert type in AnnotationType + self.type = type + + if attributes is None: + attributes = {} + else: + attributes = dict(attributes) + self.attributes = attributes + + if group is not None: + group = int(group) + self.group = group + # pylint: enable=redefined-builtin + + def __eq__(self, other): + if not isinstance(other, Annotation): + return False + return \ + (self.id == other.id) and \ + (self.type == other.type) and \ + (self.attributes == other.attributes) and \ + (self.group == other.group) + +class Categories: + def __init__(self, attributes=None): + if attributes is None: + attributes = set() + self.attributes = attributes + + def __eq__(self, other): + if not isinstance(other, Categories): + return False + return \ + (self.attributes == other.attributes) + +class LabelCategories(Categories): + Category = namedtuple('Category', ['name', 'parent']) + + def __init__(self, items=None, attributes=None): + super().__init__(attributes=attributes) + + if items is None: + items = [] + self.items = items + + self._indices = {} + self._reindex() + + def _reindex(self): + indices = {} + for index, item in enumerate(self.items): + assert item.name not in self._indices + indices[item.name] = index + self._indices = indices + + def add(self, name, parent=None): + assert name not in self._indices + + index = len(self.items) + self.items.append(self.Category(name, parent)) + self._indices[name] = index + + def find(self, name): + index = self._indices.get(name) + if index: + return index, self.items[index] + return index, None + + def __eq__(self, other): + if not super().__eq__(other): + return False + return \ + (self.items == other.items) + +class LabelObject(Annotation): + # pylint: disable=redefined-builtin + def __init__(self, label=None, + id=None, attributes=None, group=None): + super().__init__(id=id, type=AnnotationType.label, + attributes=attributes, group=group) + self.label = label + # pylint: enable=redefined-builtin + + def __eq__(self, other): + if not super().__eq__(other): + return False + return \ + (self.label == other.label) + +class MaskCategories(Categories): + def __init__(self, colormap=None, inverse_colormap=None, attributes=None): + super().__init__(attributes=attributes) + + # colormap: label id -> color + if colormap is None: + colormap = {} + self.colormap = colormap + self._inverse_colormap = inverse_colormap + + @property + def inverse_colormap(self): + from datumaro.util.mask_tools import invert_colormap + if self._inverse_colormap is None: + if self.colormap is not None: + try: + self._inverse_colormap = invert_colormap(self.colormap) + except Exception: + pass + return self._inverse_colormap + + def __eq__(self, other): + if not super().__eq__(other): + return False + for label_id, my_color in self.colormap.items(): + other_color = other.colormap.get(label_id) + if not np.array_equal(my_color, other_color): + return False + return True + +class MaskObject(Annotation): + # pylint: disable=redefined-builtin + def __init__(self, image=None, label=None, + id=None, attributes=None, group=None): + super().__init__(id=id, type=AnnotationType.mask, + attributes=attributes, group=group) + self._image = image + self._label = label + # pylint: enable=redefined-builtin + + @property + def label(self): + return self._label + + @property + def image(self): + if callable(self._image): + return self._image() + return self._image + + def painted_data(self, colormap): + raise NotImplementedError() + + def area(self): + raise NotImplementedError() + + def extract(self, class_id): + raise NotImplementedError() + + def bbox(self): + raise NotImplementedError() + + def __eq__(self, other): + if not super().__eq__(other): + return False + return \ + (self.label == other.label) and \ + (np.all(self.image == other.image)) + +def compute_iou(bbox_a, bbox_b): + aX, aY, aW, aH = bbox_a + bX, bY, bW, bH = bbox_b + in_right = min(aX + aW, bX + bW) + in_left = max(aX, bX) + in_top = max(aY, bY) + in_bottom = min(aY + aH, bY + bH) + + in_w = max(0, in_right - in_left) + in_h = max(0, in_bottom - in_top) + intersection = in_w * in_h + + a_area = aW * aH + b_area = bW * bH + union = a_area + b_area - intersection + + return intersection / max(1.0, union) + +class ShapeObject(Annotation): + # pylint: disable=redefined-builtin + def __init__(self, type, points=None, label=None, + id=None, attributes=None, group=None): + super().__init__(id=id, type=type, + attributes=attributes, group=group) + self.points = points + self.label = label + # pylint: enable=redefined-builtin + + def area(self): + raise NotImplementedError() + + def get_polygon(self): + raise NotImplementedError() + + def get_bbox(self): + points = self.get_points() + if not self.points: + return None + + xs = [p for p in points[0::2]] + ys = [p for p in points[1::2]] + x0 = min(xs) + x1 = max(xs) + y0 = min(ys) + y1 = max(ys) + return [x0, y0, x1 - x0, y1 - y0] + + def get_points(self): + return self.points + + def get_mask(self): + raise NotImplementedError() + + def __eq__(self, other): + if not super().__eq__(other): + return False + return \ + (self.points == other.points) and \ + (self.label == other.label) + +class PolyLineObject(ShapeObject): + # pylint: disable=redefined-builtin + def __init__(self, points=None, + label=None, id=None, attributes=None, group=None): + super().__init__(type=AnnotationType.polyline, + points=points, label=label, + id=id, attributes=attributes, group=group) + # pylint: enable=redefined-builtin + + def get_polygon(self): + return self.get_points() + + def area(self): + return 0 + +class PolygonObject(ShapeObject): + # pylint: disable=redefined-builtin + def __init__(self, points=None, + label=None, id=None, attributes=None, group=None): + super().__init__(type=AnnotationType.polygon, + points=points, label=label, + id=id, attributes=attributes, group=group) + # pylint: enable=redefined-builtin + + def get_polygon(self): + return self.get_points() + +class BboxObject(ShapeObject): + # pylint: disable=redefined-builtin + def __init__(self, x=0, y=0, w=0, h=0, + label=None, id=None, attributes=None, group=None): + super().__init__(type=AnnotationType.bbox, + points=[x, y, x + w, y + h], label=label, + id=id, attributes=attributes, group=group) + # pylint: enable=redefined-builtin + + @property + def x(self): + return self.points[0] + + @property + def y(self): + return self.points[1] + + @property + def w(self): + return self.points[2] - self.points[0] + + @property + def h(self): + return self.points[3] - self.points[1] + + def area(self): + return self.w * self.h + + def get_bbox(self): + return [self.x, self.y, self.w, self.h] + + def get_polygon(self): + x, y, w, h = self.get_bbox() + return [ + x, y, + x + w, y, + x + w, y + h, + x, y + h + ] + + def iou(self, other): + return compute_iou(self.get_bbox(), other.get_bbox()) + +class PointsCategories(Categories): + Category = namedtuple('Category', ['labels', 'adjacent']) + + def __init__(self, items=None, attributes=None): + super().__init__(attributes=attributes) + + if items is None: + items = {} + self.items = items + + def add(self, label_id, labels=None, adjacent=None): + if labels is None: + labels = [] + if adjacent is None: + adjacent = [] + self.items[label_id] = self.Category(labels, set(adjacent)) + + def __eq__(self, other): + if not super().__eq__(other): + return False + return \ + (self.items == other.items) + +class PointsObject(ShapeObject): + Visibility = Enum('Visibility', [ + ('absent', 0), + ('hidden', 1), + ('visible', 2), + ]) + + # pylint: disable=redefined-builtin + def __init__(self, points=None, visibility=None, label=None, + id=None, attributes=None, group=None): + if points is not None: + assert len(points) % 2 == 0 + + if visibility is not None: + assert len(visibility) == len(points) // 2 + for i, v in enumerate(visibility): + if not isinstance(v, self.Visibility): + visibility[i] = self.Visibility(v) + else: + visibility = [] + for _ in range(len(points) // 2): + visibility.append(self.Visibility.absent) + + super().__init__(type=AnnotationType.points, + points=points, label=label, + id=id, attributes=attributes, group=group) + + self.visibility = visibility + # pylint: enable=redefined-builtin + + def area(self): + return 0 + + def __eq__(self, other): + if not super().__eq__(other): + return False + return \ + (self.visibility == other.visibility) + +class CaptionObject(Annotation): + # pylint: disable=redefined-builtin + def __init__(self, caption=None, + id=None, attributes=None, group=None): + super().__init__(id=id, type=AnnotationType.caption, + attributes=attributes, group=group) + + if caption is None: + caption = '' + self.caption = caption + # pylint: enable=redefined-builtin + + def __eq__(self, other): + if not super().__eq__(other): + return False + return \ + (self.caption == other.caption) + +class DatasetItem: + # pylint: disable=redefined-builtin + def __init__(self, id, annotations=None, + subset=None, path=None, image=None): + assert id is not None + if not isinstance(id, str): + id = str(id) + assert len(id) != 0 + self._id = id + + if subset is None: + subset = '' + assert isinstance(subset, str) + self._subset = subset + + if path is None: + path = [] + self._path = path + + if annotations is None: + annotations = [] + self._annotations = annotations + + self._image = image + # pylint: enable=redefined-builtin + + @property + def id(self): + return self._id + + @property + def subset(self): + return self._subset + + @property + def path(self): + return self._path + + @property + def annotations(self): + return self._annotations + + @property + def image(self): + if callable(self._image): + return self._image() + return self._image + + @property + def has_image(self): + return self._image is not None + + def __eq__(self, other): + if not isinstance(other, __class__): + return False + return \ + (self.id == other.id) and \ + (self.subset == other.subset) and \ + (self.annotations == other.annotations) and \ + (self.image == other.image) + +class IExtractor: + def __iter__(self): + raise NotImplementedError() + + def __len__(self): + raise NotImplementedError() + + def subsets(self): + raise NotImplementedError() + + def get_subset(self, name): + raise NotImplementedError() + + def categories(self): + raise NotImplementedError() + + def select(self, pred): + raise NotImplementedError() + + def get(self, item_id, subset=None, path=None): + raise NotImplementedError() + +class _DatasetFilter: + def __init__(self, iterable, predicate): + self.iterable = iterable + self.predicate = predicate + + def __iter__(self): + return filter(self.predicate, self.iterable) + +class _ExtractorBase(IExtractor): + def __init__(self, length=None): + self._length = length + self._subsets = None + + def _init_cache(self): + subsets = set() + length = -1 + for length, item in enumerate(self): + subsets.add(item.subset) + length += 1 + + if self._length is None: + self._length = length + if self._subsets is None: + self._subsets = subsets + + def __len__(self): + if self._length is None: + self._init_cache() + return self._length + + def subsets(self): + if self._subsets is None: + self._init_cache() + return list(self._subsets) + + def get_subset(self, name): + if name in self.subsets(): + return self.select(lambda item: item.subset == name) + else: + raise Exception("Unknown subset '%s' requested" % name) + +class DatasetIteratorWrapper(_ExtractorBase): + def __init__(self, iterable, categories): + super().__init__(length=None) + self._iterable = iterable + self._categories = categories + + def __iter__(self): + return iter(self._iterable) + + def categories(self): + return self._categories + + def select(self, pred): + return DatasetIteratorWrapper( + _DatasetFilter(self, pred), self.categories()) + +class Extractor(_ExtractorBase): + def __init__(self, length=None): + super().__init__(length=None) + + def categories(self): + return {} + + def select(self, pred): + return DatasetIteratorWrapper( + _DatasetFilter(self, pred), self.categories()) + + +DEFAULT_SUBSET_NAME = 'default' \ No newline at end of file diff --git a/datumaro/datumaro/components/extractors/__init__.py b/datumaro/datumaro/components/extractors/__init__.py new file mode 100644 index 000000000000..2907a53e9fdf --- /dev/null +++ b/datumaro/datumaro/components/extractors/__init__.py @@ -0,0 +1,50 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from datumaro.components.extractors.datumaro import DatumaroExtractor + +from datumaro.components.extractors.ms_coco import ( + CocoImageInfoExtractor, + CocoCaptionsExtractor, + CocoInstancesExtractor, + CocoLabelsExtractor, + CocoPersonKeypointsExtractor, +) + +from datumaro.components.extractors.voc import ( + VocClassificationExtractor, + VocDetectionExtractor, + VocSegmentationExtractor, + VocLayoutExtractor, + VocActionExtractor, + VocComp_1_2_Extractor, + VocComp_3_4_Extractor, + VocComp_5_6_Extractor, + VocComp_7_8_Extractor, + VocComp_9_10_Extractor, +) + + +items = [ + ('datumaro', DatumaroExtractor), + + ('coco_images', CocoImageInfoExtractor), + ('coco_captions', CocoCaptionsExtractor), + ('coco_instances', CocoInstancesExtractor), + ('coco_person_kp', CocoPersonKeypointsExtractor), + ('coco_labels', CocoLabelsExtractor), + + ('voc_cls', VocClassificationExtractor), + ('voc_det', VocDetectionExtractor), + ('voc_segm', VocSegmentationExtractor), + ('voc_layout', VocLayoutExtractor), + ('voc_action', VocActionExtractor), + + ('voc_comp_1_2', VocComp_1_2_Extractor), + ('voc_comp_3_4', VocComp_3_4_Extractor), + ('voc_comp_5_6', VocComp_5_6_Extractor), + ('voc_comp_7_8', VocComp_7_8_Extractor), + ('voc_comp_9_10', VocComp_9_10_Extractor), +] \ No newline at end of file diff --git a/datumaro/datumaro/components/extractors/datumaro.py b/datumaro/datumaro/components/extractors/datumaro.py new file mode 100644 index 000000000000..429cb38e094d --- /dev/null +++ b/datumaro/datumaro/components/extractors/datumaro.py @@ -0,0 +1,214 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from collections import defaultdict +import json +import os.path as osp + +from datumaro.components.extractor import (Extractor, DatasetItem, + DEFAULT_SUBSET_NAME, + AnnotationType, + LabelObject, MaskObject, PointsObject, PolygonObject, + PolyLineObject, BboxObject, CaptionObject, + LabelCategories, MaskCategories, PointsCategories +) +from datumaro.components.formats.datumaro import DatumaroPath +from datumaro.util import dir_items +from datumaro.util.image import lazy_image +from datumaro.util.mask_tools import lazy_mask + + +class DatumaroExtractor(Extractor): + class Subset(Extractor): + def __init__(self, name, parent): + super().__init__() + self._parent = parent + self._name = name + self.items = [] + + def __iter__(self): + for item in self.items: + yield self._parent._get(item, self._name) + + def __len__(self): + return len(self.items) + + def categories(self): + return self._parent.categories() + + def __init__(self, path): + super().__init__() + + assert osp.isdir(path) + self._path = path + + annotations = defaultdict(list) + found_subsets = self._find_subsets(path) + parsed_anns = None + subsets = {} + for subset_name, subset_path in found_subsets.items(): + if subset_name == DEFAULT_SUBSET_NAME: + subset_name = None + subset = self.Subset(subset_name, self) + with open(subset_path, 'r') as f: + parsed_anns = json.load(f) + + for index, _ in enumerate(parsed_anns['items']): + subset.items.append(index) + + annotations[subset_name] = parsed_anns + subsets[subset_name] = subset + self._annotations = dict(annotations) + self._subsets = subsets + + self._categories = {} + if parsed_anns is not None: + self._categories = self._load_categories(parsed_anns) + + @staticmethod + def _load_categories(parsed): + categories = {} + + parsed_label_cat = parsed['categories'].get(AnnotationType.label.name) + if parsed_label_cat: + label_categories = LabelCategories() + for item in parsed_label_cat['labels']: + label_categories.add(item['name'], parent=item['parent']) + + categories[AnnotationType.label] = label_categories + + parsed_mask_cat = parsed['categories'].get(AnnotationType.mask.name) + if parsed_mask_cat: + colormap = {} + for item in parsed_mask_cat['colormap']: + colormap[int(item['label_id'])] = \ + (item['r'], item['g'], item['b']) + + mask_categories = MaskCategories(colormap=colormap) + categories[AnnotationType.mask] = mask_categories + + parsed_points_cat = parsed['categories'].get(AnnotationType.points.name) + if parsed_points_cat: + point_categories = PointsCategories() + for item in parsed_points_cat['items']: + point_categories.add(int(item['label_id']), + item['labels'], adjacent=item['adjacent']) + + categories[AnnotationType.points] = point_categories + + return categories + + def _get(self, index, subset_name): + item = self._annotations[subset_name]['items'][index] + + item_id = item.get('id') + + image_path = osp.join(self._path, DatumaroPath.IMAGES_DIR, + item_id + DatumaroPath.IMAGE_EXT) + image = None + if osp.isfile(image_path): + image = lazy_image(image_path) + + annotations = self._load_annotations(item) + + return DatasetItem(id=item_id, subset=subset_name, + annotations=annotations, image=image) + + def _load_annotations(self, item): + parsed = item['annotations'] + loaded = [] + + for ann in parsed: + ann_id = ann.get('id') + ann_type = AnnotationType[ann['type']] + attributes = ann.get('attributes') + group = ann.get('group') + + if ann_type == AnnotationType.label: + label_id = ann.get('label_id') + loaded.append(LabelObject(label=label_id, + id=ann_id, attributes=attributes, group=group)) + + elif ann_type == AnnotationType.mask: + label_id = ann.get('label_id') + mask_id = str(ann.get('mask_id')) + + mask_path = osp.join(self._path, DatumaroPath.ANNOTATIONS_DIR, + DatumaroPath.MASKS_DIR, mask_id + DatumaroPath.MASK_EXT) + mask = None + + if osp.isfile(mask_path): + mask_cat = self._categories.get(AnnotationType.mask) + if mask_cat is not None: + mask = lazy_mask(mask_path, mask_cat.inverse_colormap) + else: + mask = lazy_image(mask_path) + + loaded.append(MaskObject(label=label_id, image=mask, + id=ann_id, attributes=attributes, group=group)) + + elif ann_type == AnnotationType.polyline: + label_id = ann.get('label_id') + points = ann.get('points') + loaded.append(PolyLineObject(points, label=label_id, + id=ann_id, attributes=attributes, group=group)) + + elif ann_type == AnnotationType.polygon: + label_id = ann.get('label_id') + points = ann.get('points') + loaded.append(PolygonObject(points, label=label_id, + id=ann_id, attributes=attributes, group=group)) + + elif ann_type == AnnotationType.bbox: + label_id = ann.get('label_id') + x, y, w, h = ann.get('bbox') + loaded.append(BboxObject(x, y, w, h, label=label_id, + id=ann_id, attributes=attributes, group=group)) + + elif ann_type == AnnotationType.points: + label_id = ann.get('label_id') + points = ann.get('points') + loaded.append(PointsObject(points, label=label_id, + id=ann_id, attributes=attributes, group=group)) + + elif ann_type == AnnotationType.caption: + caption = ann.get('caption') + loaded.append(CaptionObject(caption, + id=ann_id, attributes=attributes, group=group)) + + else: + raise NotImplementedError() + + return loaded + + def categories(self): + return self._categories + + def __iter__(self): + for subset_name, subset in self._subsets.items(): + for index in subset.items: + yield self._get(index, subset_name) + + def __len__(self): + length = 0 + for subset in self._subsets.values(): + length += len(subset) + return length + + def subsets(self): + return list(self._subsets) + + def get_subset(self, name): + return self._subsets[name] + + @staticmethod + def _find_subsets(path): + anno_dir = osp.join(path, DatumaroPath.ANNOTATIONS_DIR) + if not osp.isdir(anno_dir): + raise Exception('Datumaro dataset not found at "%s"' % path) + + return { name: osp.join(anno_dir, name + '.json') + for name in dir_items(anno_dir, '.json', truncate_ext=True) + } \ No newline at end of file diff --git a/datumaro/datumaro/components/extractors/ms_coco.py b/datumaro/datumaro/components/extractors/ms_coco.py new file mode 100644 index 000000000000..1997b31ad505 --- /dev/null +++ b/datumaro/datumaro/components/extractors/ms_coco.py @@ -0,0 +1,297 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import numpy as np +import os.path as osp + +from pycocotools.coco import COCO +import pycocotools.mask as mask_utils + +from datumaro.components.extractor import (Extractor, DatasetItem, + AnnotationType, + LabelObject, MaskObject, PointsObject, PolygonObject, + BboxObject, CaptionObject, + LabelCategories, PointsCategories +) +from datumaro.components.formats.ms_coco import CocoAnnotationType, CocoPath +from datumaro.util.image import lazy_image + + +class RleMask(MaskObject): + # pylint: disable=redefined-builtin + def __init__(self, rle=None, label=None, + id=None, attributes=None, group=None): + lazy_decode = lambda: mask_utils.decode(rle).astype(np.bool) + super().__init__(image=lazy_decode, label=label, + id=id, attributes=attributes, group=group) + + self._rle = rle + # pylint: enable=redefined-builtin + + def area(self): + return mask_utils.area(self._rle) + + def bbox(self): + return mask_utils.toBbox(self._rle) + + def __eq__(self, other): + if not isinstance(other, __class__): + return super().__eq__(other) + return self._rle == other._rle + + +class CocoExtractor(Extractor): + class Subset(Extractor): + def __init__(self, name, parent): + super().__init__() + self._name = name + self._parent = parent + self.loaders = {} + self.items = set() + + def __iter__(self): + for img_id in self.items: + yield self._parent._get(img_id, self._name) + + def __len__(self): + return len(self.items) + + def categories(self): + return self._parent.categories() + + def __init__(self, path, task): + super().__init__() + + rootpath = path.rsplit(CocoPath.ANNOTATIONS_DIR, maxsplit=1)[0] + self._path = rootpath + self._task = task + self._subsets = {} + + subset_name = osp.splitext(osp.basename(path))[0] \ + .rsplit('_', maxsplit=1)[1] + subset = CocoExtractor.Subset(subset_name, self) + loader = self._make_subset_loader(path) + subset.loaders[task] = loader + for img_id in loader.getImgIds(): + subset.items.add(img_id) + self._subsets[subset_name] = subset + + self._load_categories() + + @staticmethod + def _make_subset_loader(path): + # COCO API has an 'unclosed file' warning + coco_api = COCO() + with open(path, 'r') as f: + import json + dataset = json.load(f) + + coco_api.dataset = dataset + coco_api.createIndex() + return coco_api + + def _load_categories(self): + loaders = {} + + for subset in self._subsets.values(): + loaders.update(subset.loaders) + + self._categories = {} + + label_loader = loaders.get(CocoAnnotationType.labels) + instances_loader = loaders.get(CocoAnnotationType.instances) + person_kp_loader = loaders.get(CocoAnnotationType.person_keypoints) + + if label_loader is None and instances_loader is not None: + label_loader = instances_loader + if label_loader is None and person_kp_loader is not None: + label_loader = person_kp_loader + if label_loader is not None: + label_categories, label_map = \ + self._load_label_categories(label_loader) + self._categories[AnnotationType.label] = label_categories + self._label_map = label_map + + if person_kp_loader is not None: + person_kp_categories = \ + self._load_person_kp_categories(person_kp_loader) + self._categories[AnnotationType.points] = person_kp_categories + + # pylint: disable=no-self-use + def _load_label_categories(self, loader): + catIds = loader.getCatIds() + cats = loader.loadCats(catIds) + + categories = LabelCategories() + label_map = {} + for idx, cat in enumerate(cats): + label_map[cat['id']] = idx + categories.add(name=cat['name'], parent=cat['supercategory']) + + return categories, label_map + # pylint: enable=no-self-use + + def _load_person_kp_categories(self, loader): + catIds = loader.getCatIds() + cats = loader.loadCats(catIds) + + categories = PointsCategories() + for cat in cats: + label_id, _ = self._categories[AnnotationType.label].find(cat['name']) + categories.add(label_id=label_id, + labels=cat['keypoints'], adjacent=cat['skeleton']) + + return categories + + def categories(self): + return self._categories + + def __iter__(self): + for subset_name, subset in self._subsets.items(): + for img_id in subset.items: + yield self._get(img_id, subset_name) + + def __len__(self): + length = 0 + for subset in self._subsets.values(): + length += len(subset) + return length + + def subsets(self): + return list(self._subsets) + + def get_subset(self, name): + return self._subsets[name] + + def _get(self, img_id, subset): + file_name = None + image_info = None + image = None + annotations = [] + for ann_type, loader in self._subsets[subset].loaders.items(): + if image is None: + image_info = loader.loadImgs(img_id)[0] + file_name = image_info['file_name'] + if file_name != '': + file_path = osp.join( + self._path, CocoPath.IMAGES_DIR, subset, file_name) + if osp.exists(file_path): + image = lazy_image(file_path) + + annIds = loader.getAnnIds(imgIds=img_id) + anns = loader.loadAnns(annIds) + + for ann in anns: + self._parse_annotation(ann, ann_type, annotations, image_info) + return DatasetItem(id=img_id, subset=subset, + image=image, annotations=annotations) + + def _parse_label(self, ann): + cat_id = ann.get('category_id') + if cat_id in [0, None]: + return None + return self._label_map[cat_id] + + def _parse_annotation(self, ann, ann_type, parsed_annotations, + image_info=None): + ann_id = ann.get('id') + attributes = {} + if 'score' in ann: + attributes['score'] = ann['score'] + + if ann_type is CocoAnnotationType.instances: + x, y, w, h = ann['bbox'] + label_id = self._parse_label(ann) + group = None + + is_crowd = bool(ann['iscrowd']) + attributes['is_crowd'] = is_crowd + + segmentation = ann.get('segmentation') + if segmentation is not None: + group = ann_id + + if isinstance(segmentation, list): + # polygon -- a single object might consist of multiple parts + for polygon_points in segmentation: + parsed_annotations.append(PolygonObject( + points=polygon_points, label=label_id, + group=group + )) + + # we merge all parts into one mask RLE code + img_h = image_info['height'] + img_w = image_info['width'] + rles = mask_utils.frPyObjects(segmentation, img_h, img_w) + rle = mask_utils.merge(rles) + elif isinstance(segmentation['counts'], list): + # uncompressed RLE + img_h, img_w = segmentation['size'] + rle = mask_utils.frPyObjects([segmentation], img_h, img_w)[0] + else: + # compressed RLE + rle = segmentation + + parsed_annotations.append(RleMask(rle=rle, label=label_id, + group=group + )) + + parsed_annotations.append( + BboxObject(x, y, w, h, label=label_id, + id=ann_id, attributes=attributes, group=group) + ) + elif ann_type is CocoAnnotationType.labels: + label_id = self._parse_label(ann) + parsed_annotations.append( + LabelObject(label=label_id, + id=ann_id, attributes=attributes) + ) + elif ann_type is CocoAnnotationType.person_keypoints: + keypoints = ann['keypoints'] + points = [p for i, p in enumerate(keypoints) if i % 3 != 2] + visibility = keypoints[2::3] + bbox = ann.get('bbox') + label_id = self._parse_label(ann) + group = None + if bbox is not None: + group = ann_id + parsed_annotations.append( + PointsObject(points, visibility, label=label_id, + id=ann_id, attributes=attributes, group=group) + ) + if bbox is not None: + parsed_annotations.append( + BboxObject(*bbox, label=label_id, group=group) + ) + elif ann_type is CocoAnnotationType.captions: + caption = ann['caption'] + parsed_annotations.append( + CaptionObject(caption, + id=ann_id, attributes=attributes) + ) + else: + raise NotImplementedError() + + return parsed_annotations + +class CocoImageInfoExtractor(CocoExtractor): + def __init__(self, path): + super().__init__(path, task=CocoAnnotationType.image_info) + +class CocoCaptionsExtractor(CocoExtractor): + def __init__(self, path): + super().__init__(path, task=CocoAnnotationType.captions) + +class CocoInstancesExtractor(CocoExtractor): + def __init__(self, path): + super().__init__(path, task=CocoAnnotationType.instances) + +class CocoPersonKeypointsExtractor(CocoExtractor): + def __init__(self, path): + super().__init__(path, task=CocoAnnotationType.person_keypoints) + +class CocoLabelsExtractor(CocoExtractor): + def __init__(self, path): + super().__init__(path, task=CocoAnnotationType.labels) \ No newline at end of file diff --git a/datumaro/datumaro/components/extractors/voc.py b/datumaro/datumaro/components/extractors/voc.py new file mode 100644 index 000000000000..1963f2d365cf --- /dev/null +++ b/datumaro/datumaro/components/extractors/voc.py @@ -0,0 +1,705 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from collections import defaultdict +from itertools import chain +import os +import os.path as osp +from xml.etree import ElementTree as ET + +from datumaro.components.extractor import (Extractor, DatasetItem, + AnnotationType, LabelObject, MaskObject, BboxObject, + LabelCategories, MaskCategories +) +from datumaro.components.formats.voc import VocLabel, VocAction, \ + VocBodyPart, VocTask, VocPath, VocColormap, VocInstColormap +from datumaro.util import dir_items +from datumaro.util.image import lazy_image +from datumaro.util.mask_tools import lazy_mask, invert_colormap + + +_inverse_inst_colormap = invert_colormap(VocInstColormap) + +# pylint: disable=pointless-statement +def _make_voc_categories(): + categories = {} + + label_categories = LabelCategories() + for label in chain(VocLabel, VocAction, VocBodyPart): + label_categories.add(label.name) + categories[AnnotationType.label] = label_categories + + def label_id(class_index): + class_label = VocLabel(class_index).name + label_id, _ = label_categories.find(class_label) + return label_id + colormap = { label_id(idx): tuple(color) \ + for idx, color in VocColormap.items() } + mask_categories = MaskCategories(colormap) + mask_categories.inverse_colormap # init inverse colormap + categories[AnnotationType.mask] = mask_categories + + return categories +# pylint: enable=pointless-statement + +class VocExtractor(Extractor): + class Subset(Extractor): + def __init__(self, name, parent): + super().__init__() + self._parent = parent + self._name = name + self.items = [] + + def __iter__(self): + for item in self.items: + yield self._parent._get(item, self._name) + + def __len__(self): + return len(self.items) + + def categories(self): + return self._parent.categories() + + def _load_subsets(self, subsets_dir): + dir_files = dir_items(subsets_dir, '.txt', truncate_ext=True) + subset_names = [s for s in dir_files if '_' not in s] + + subsets = {} + for subset_name in subset_names: + subset = __class__.Subset(subset_name, self) + + with open(osp.join(subsets_dir, subset_name + '.txt'), 'r') as f: + subset.items = [line.split()[0] for line in f] + + subsets[subset_name] = subset + return subsets + + def _load_cls_annotations(self, subsets_dir, subset_names): + dir_files = dir_items(subsets_dir, '.txt', truncate_ext=True) + + label_annotations = defaultdict(list) + label_anno_files = [s for s in dir_files \ + if '_' in s and s[s.rfind('_') + 1:] in subset_names] + for ann_file in label_anno_files: + with open(osp.join(subsets_dir, ann_file + '.txt'), 'r') as f: + label = ann_file[:ann_file.rfind('_')] + label_id = VocLabel[label].value + for line in f: + item, present = line.split() + if present == '1': + label_annotations[item].append(label_id) + + self._annotations[VocTask.classification] = dict(label_annotations) + + def _load_det_annotations(self): + det_anno_dir = osp.join(self._path, VocPath.ANNOTATIONS_DIR) + det_anno_items = dir_items(det_anno_dir, '.xml', truncate_ext=True) + det_annotations = dict() + for ann_item in det_anno_items: + with open(osp.join(det_anno_dir, ann_item + '.xml'), 'r') as f: + ann_file_data = f.read() + ann_file_root = ET.fromstring(ann_file_data) + item = ann_file_root.find('filename').text + item = osp.splitext(item)[0] + det_annotations[item] = ann_file_data + + self._annotations[VocTask.detection] = det_annotations + + def _load_categories(self): + self._categories = _make_voc_categories() + + def __init__(self, path, task): + super().__init__() + + self._path = path + self._subsets = {} + self._categories = {} + self._annotations = {} + self._task = task + + self._load_categories() + + def __len__(self): + length = 0 + for subset in self._subsets.values(): + length += len(subset) + return length + + def subsets(self): + return list(self._subsets) + + def get_subset(self, name): + return self._subsets[name] + + def categories(self): + return self._categories + + def __iter__(self): + for subset_name, subset in self._subsets.items(): + for item in subset.items: + yield self._get(item, subset_name) + + def _get(self, item, subset_name): + image = None + image_path = osp.join(self._path, VocPath.IMAGES_DIR, + item + VocPath.IMAGE_EXT) + if osp.isfile(image_path): + image = lazy_image(image_path) + + annotations = self._get_annotations(item) + + return DatasetItem(annotations=annotations, + id=item, subset=subset_name, image=image) + + def _get_label_id(self, label): + label_id, _ = self._categories[AnnotationType.label].find(label) + assert label_id is not None + return label_id + + def _get_annotations(self, item): + item_annotations = [] + + if self._task is VocTask.segmentation: + segm_path = osp.join(self._path, VocPath.SEGMENTATION_DIR, + item + VocPath.SEGM_EXT) + if osp.isfile(segm_path): + inverse_cls_colormap = \ + self._categories[AnnotationType.mask].inverse_colormap + item_annotations.append(MaskObject( + image=lazy_mask(segm_path, inverse_cls_colormap), + attributes={ 'class': True } + )) + + inst_path = osp.join(self._path, VocPath.INSTANCES_DIR, + item + VocPath.SEGM_EXT) + if osp.isfile(inst_path): + item_annotations.append(MaskObject( + image=lazy_mask(inst_path, _inverse_inst_colormap), + attributes={ 'instances': True } + )) + + cls_annotations = self._annotations.get(VocTask.classification) + if cls_annotations is not None and \ + self._task is VocTask.classification: + item_labels = cls_annotations.get(item) + if item_labels is not None: + for label in item_labels: + label_id = self._get_label_id(VocLabel(label).name) + item_annotations.append(LabelObject(label_id)) + + det_annotations = self._annotations.get(VocTask.detection) + if det_annotations is not None: + det_annotations = det_annotations.get(item) + if det_annotations is not None: + root_elem = ET.fromstring(det_annotations) + + for obj_id, object_elem in enumerate(root_elem.findall('object')): + attributes = {} + group = None + + obj_label_id = None + label_elem = object_elem.find('name') + if label_elem is not None: + obj_label_id = self._get_label_id(label_elem.text) + + obj_bbox = self._parse_bbox(object_elem) + + if obj_label_id is None or obj_bbox is None: + continue + + difficult_elem = object_elem.find('difficult') + if difficult_elem is not None: + attributes['difficult'] = (difficult_elem.text == '1') + + truncated_elem = object_elem.find('truncated') + if truncated_elem is not None: + attributes['truncated'] = (truncated_elem.text == '1') + + occluded_elem = object_elem.find('occluded') + if occluded_elem is not None: + attributes['occluded'] = (occluded_elem.text == '1') + + pose_elem = object_elem.find('pose') + if pose_elem is not None: + attributes['pose'] = pose_elem.text + + point_elem = object_elem.find('point') + if point_elem is not None: + point_x = point_elem.find('x') + point_y = point_elem.find('y') + point = [float(point_x.text), float(point_y.text)] + attributes['point'] = point + + actions_elem = object_elem.find('actions') + if actions_elem is not None and \ + self._task is VocTask.action_classification: + for action in VocAction: + action_elem = actions_elem.find(action.name) + if action_elem is None or action_elem.text != '1': + continue + + act_label_id = self._get_label_id(action.name) + assert group in [None, obj_id] + group = obj_id + item_annotations.append(LabelObject(act_label_id, + group=obj_id)) + + if self._task is VocTask.person_layout: + for part_elem in object_elem.findall('part'): + part = part_elem.find('name').text + part_label_id = self._get_label_id(part) + bbox = self._parse_bbox(part_elem) + group = obj_id + item_annotations.append(BboxObject( + *bbox, label=part_label_id, + group=obj_id)) + + if self._task in [VocTask.action_classification, VocTask.person_layout]: + if group is None: + continue + + item_annotations.append(BboxObject(*obj_bbox, label=obj_label_id, + attributes=attributes, id=obj_id, group=group)) + + return item_annotations + + @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: + return None + +class VocClassificationExtractor(VocExtractor): + _ANNO_DIR = 'Main' + + def __init__(self, path): + super().__init__(path, task=VocTask.classification) + + subsets_dir = osp.join(path, VocPath.SUBSETS_DIR, self._ANNO_DIR) + subsets = self._load_subsets(subsets_dir) + self._subsets = subsets + + self._load_cls_annotations(subsets_dir, subsets) + +class VocDetectionExtractor(VocExtractor): + _ANNO_DIR = 'Main' + + def __init__(self, path): + super().__init__(path, task=VocTask.detection) + + subsets_dir = osp.join(path, VocPath.SUBSETS_DIR, self._ANNO_DIR) + subsets = self._load_subsets(subsets_dir) + self._subsets = subsets + + self._load_det_annotations() + +class VocSegmentationExtractor(VocExtractor): + _ANNO_DIR = 'Segmentation' + + def __init__(self, path): + super().__init__(path, task=VocTask.segmentation) + + subsets_dir = osp.join(path, VocPath.SUBSETS_DIR, self._ANNO_DIR) + subsets = self._load_subsets(subsets_dir) + self._subsets = subsets + +class VocLayoutExtractor(VocExtractor): + _ANNO_DIR = 'Layout' + + def __init__(self, path): + super().__init__(path, task=VocTask.person_layout) + + subsets_dir = osp.join(path, VocPath.SUBSETS_DIR, self._ANNO_DIR) + subsets = self._load_subsets(subsets_dir) + self._subsets = subsets + + self._load_det_annotations() + +class VocActionExtractor(VocExtractor): + _ANNO_DIR = 'Action' + + def __init__(self, path): + super().__init__(path, task=VocTask.action_classification) + + subsets_dir = osp.join(path, VocPath.SUBSETS_DIR, self._ANNO_DIR) + subsets = self._load_subsets(subsets_dir) + self._subsets = subsets + + self._load_det_annotations() + + +class VocResultsExtractor(Extractor): + class Subset(Extractor): + def __init__(self, name, parent): + super().__init__() + self._parent = parent + self._name = name + self.items = [] + + def __iter__(self): + for item in self.items: + yield self._parent._get(item, self._name) + + def __len__(self): + return len(self.items) + + def categories(self): + return self._parent.categories() + + _SUPPORTED_TASKS = { + VocTask.classification: { + 'dir': 'Main', + 'mark': 'cls', + 'ext': '.txt', + 'path' : ['%(comp)s_cls_%(subset)s_%(label)s.txt'], + 'comp': ['comp1', 'comp2'], + }, + VocTask.detection: { + 'dir': 'Main', + 'mark': 'det', + 'ext': '.txt', + 'path': ['%(comp)s_det_%(subset)s_%(label)s.txt'], + 'comp': ['comp3', 'comp4'], + }, + VocTask.segmentation: { + 'dir': 'Segmentation', + 'mark': ['cls', 'inst'], + 'ext': '.png', + 'path': ['%(comp)s_%(subset)s_cls', '%(item)s.png'], + 'comp': ['comp5', 'comp6'], + }, + VocTask.person_layout: { + 'dir': 'Layout', + 'mark': 'layout', + 'ext': '.xml', + 'path': ['%(comp)s_layout_%(subset)s.xml'], + 'comp': ['comp7', 'comp8'], + }, + VocTask.action_classification: { + 'dir': 'Action', + 'mark': 'action', + 'ext': '.txt', + 'path': ['%(comp)s_action_%(subset)s_%(label)s.txt'], + 'comp': ['comp9', 'comp10'], + }, + } + + def _parse_txt_ann(self, path, subsets, annotations, task): + task_desc = self._SUPPORTED_TASKS[task] + task_dir = osp.join(path, task_desc['dir']) + ann_ext = task_desc['ext'] + if not osp.isdir(task_dir): + return + + ann_files = dir_items(task_dir, ann_ext, truncate_ext=True) + + for ann_file in ann_files: + ann_parts = filter(None, ann_file.strip().split('_')) + if len(ann_parts) != 4: + continue + _, mark, subset_name, label = ann_parts + if mark != task_desc['mark']: + continue + + label_id = VocLabel[label].value + anns = defaultdict(list) + with open(osp.join(task_dir, ann_file + ann_ext), 'r') as f: + for line in f: + line_parts = line.split() + item = line_parts[0] + anns[item].append((label_id, *line_parts[1:])) + + subset = VocResultsExtractor.Subset(subset_name, self) + subset.items = list(anns) + + subsets[subset_name] = subset + annotations[subset_name] = dict(anns) + + def _parse_classification(self, path, subsets, annotations): + self._parse_txt_ann(path, subsets, annotations, + VocTask.classification) + + def _parse_detection(self, path, subsets, annotations): + self._parse_txt_ann(path, subsets, annotations, + VocTask.detection) + + def _parse_action(self, path, subsets, annotations): + self._parse_txt_ann(path, subsets, annotations, + VocTask.action_classification) + + def _load_categories(self): + self._categories = _make_voc_categories() + + def _get_label_id(self, label): + label_id = self._categories[AnnotationType.label].find(label) + assert label_id is not None + return label_id + + def __init__(self, path): + super().__init__() + + self._path = path + self._subsets = {} + self._annotations = {} + + self._load_categories() + + def __len__(self): + length = 0 + for subset in self._subsets.values(): + length += len(subset) + return length + + def subsets(self): + return list(self._subsets) + + def get_subset(self, name): + return self._subsets[name] + + def categories(self): + return self._categories + + def __iter__(self): + for subset_name, subset in self._subsets.items(): + for item in subset.items: + yield self._get(item, subset_name) + + def _get(self, item, subset_name): + image = None + image_path = osp.join(self._path, VocPath.IMAGES_DIR, + item + VocPath.IMAGE_EXT) + if osp.isfile(image_path): + image = lazy_image(image_path) + + annotations = self._get_annotations(item, subset_name) + + return DatasetItem(annotations=annotations, + id=item, subset=subset_name, image=image) + + def _get_annotations(self, item, subset_name): + raise NotImplementedError() + +class VocComp_1_2_Extractor(VocResultsExtractor): + def __init__(self, path): + super().__init__(path) + + subsets = {} + annotations = defaultdict(dict) + + self._parse_classification(path, subsets, annotations) + + self._subsets = subsets + self._annotations = dict(annotations) + + def _get_annotations(self, item, subset_name): + annotations = [] + + cls_ann = self._annotations[subset_name].get(item) + if cls_ann is not None: + for desc in cls_ann: + label_id, conf = desc + label_id = self._get_label_id(VocLabel(int(label_id)).name) + annotations.append(LabelObject( + label_id, + attributes={ 'score': float(conf) } + )) + + return annotations + +class VocComp_3_4_Extractor(VocResultsExtractor): + def __init__(self, path): + super().__init__(path) + + subsets = {} + annotations = defaultdict(dict) + + self._parse_detection(path, subsets, annotations) + + self._subsets = subsets + self._annotations = dict(annotations) + + def _get_annotations(self, item, subset_name): + annotations = [] + + det_ann = self._annotations[subset_name].get(item) + if det_ann is not None: + for desc in det_ann: + label_id, conf, left, top, right, bottom = desc + label_id = self._get_label_id(VocLabel(int(label_id)).name) + annotations.append(BboxObject( + x=float(left), y=float(top), + w=float(right) - float(left), h=float(bottom) - float(top), + label=label_id, + attributes={ 'score': float(conf) } + )) + + return annotations + +class VocComp_5_6_Extractor(VocResultsExtractor): + def __init__(self, path): + super().__init__(path) + + subsets = {} + annotations = defaultdict(dict) + + task_dir = osp.join(path, 'Segmentation') + if not osp.isdir(task_dir): + return + + ann_files = os.listdir(task_dir) + + for ann_dir in ann_files: + ann_parts = filter(None, ann_dir.strip().split('_')) + if len(ann_parts) != 4: + continue + _, subset_name, mark = ann_parts + if mark not in ['cls', 'inst']: + continue + + item_dir = osp.join(task_dir, ann_dir) + items = dir_items(item_dir, '.png', truncate_ext=True) + items = { name: osp.join(item_dir, item + '.png') \ + for name, item in items } + + subset = VocResultsExtractor.Subset(subset_name, self) + subset.items = list(items) + + subsets[subset_name] = subset + annotations[subset_name][mark] = items + + self._subsets = subsets + self._annotations = dict(annotations) + + def _get_annotations(self, item, subset_name): + annotations = [] + + segm_ann = self._annotations[subset_name] + cls_image_path = segm_ann.get(item) + if cls_image_path and osp.isfile(cls_image_path): + inverse_cls_colormap = \ + self._categories[AnnotationType.mask].inverse_colormap + annotations.append(MaskObject( + image=lazy_mask(cls_image_path, inverse_cls_colormap), + attributes={ 'class': True } + )) + + inst_ann = self._annotations[subset_name] + inst_image_path = inst_ann.get(item) + if inst_image_path and osp.isfile(inst_image_path): + annotations.append(MaskObject( + image=lazy_mask(inst_image_path, _inverse_inst_colormap), + attributes={ 'instances': True } + )) + + return annotations + +class VocComp_7_8_Extractor(VocResultsExtractor): + def __init__(self, path): + super().__init__(path) + + subsets = {} + annotations = defaultdict(dict) + + task = VocTask.person_layout + task_desc = self._SUPPORTED_TASKS[task] + task_dir = osp.join(path, task_desc['dir']) + if not osp.isdir(task_dir): + return + + ann_ext = task_desc['ext'] + ann_files = dir_items(task_dir, ann_ext, truncate_ext=True) + + for ann_file in ann_files: + ann_parts = filter(None, ann_file.strip().split('_')) + if len(ann_parts) != 4: + continue + _, mark, subset_name, _ = ann_parts + if mark != task_desc['mark']: + continue + + layouts = {} + root = ET.parse(osp.join(task_dir, ann_file + ann_ext)) + root_elem = root.getroot() + for layout_elem in root_elem.findall('layout'): + item = layout_elem.find('image').text + obj_id = int(layout_elem.find('object').text) + conf = float(layout_elem.find('confidence').text) + parts = [] + for part_elem in layout_elem.findall('part'): + label_id = VocBodyPart[part_elem.find('class').text].value + bbox_elem = part_elem.find('bndbox') + 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) + bbox = [xmin, ymin, xmax - xmin, ymax - ymin] + parts.append((label_id, bbox)) + layouts[item] = [obj_id, conf, parts] + + subset = VocResultsExtractor.Subset(subset_name, self) + subset.items = list(layouts) + + subsets[subset_name] = subset + annotations[subset_name] = layouts + + self._subsets = subsets + self._annotations = dict(annotations) + + def _get_annotations(self, item, subset_name): + annotations = [] + + layout_ann = self._annotations[subset_name].get(item) + if layout_ann is not None: + for desc in layout_ann: + obj_id, conf, parts = desc + attributes = { + 'score': conf, + 'object_id': obj_id, + } + + for part in parts: + part_id, bbox = part + label_id = self._get_label_id(VocBodyPart(part_id).name) + annotations.append(BboxObject( + *bbox, label=label_id, + attributes=attributes)) + + return annotations + +class VocComp_9_10_Extractor(VocResultsExtractor): + def __init__(self, path): + super().__init__(path) + + subsets = {} + annotations = defaultdict(dict) + + self._parse_action(path, subsets, annotations) + + self._subsets = subsets + self._annotations = dict(annotations) + + def _get_annotations(self, item, subset_name): + annotations = [] + + action_ann = self._annotations[subset_name].get(item) + if action_ann is not None: + for desc in action_ann: + action_id, obj_id, conf = desc + label_id = self._get_label_id(VocAction(int(action_id)).name) + annotations.append(LabelObject( + label_id, + attributes={ + 'score': conf, + 'object_id': int(obj_id), + } + )) + + return annotations \ No newline at end of file diff --git a/datumaro/datumaro/components/formats/__init__.py b/datumaro/datumaro/components/formats/__init__.py new file mode 100644 index 000000000000..a9773073830c --- /dev/null +++ b/datumaro/datumaro/components/formats/__init__.py @@ -0,0 +1,5 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + diff --git a/datumaro/datumaro/components/formats/datumaro.py b/datumaro/datumaro/components/formats/datumaro.py new file mode 100644 index 000000000000..ef587b9bbbe9 --- /dev/null +++ b/datumaro/datumaro/components/formats/datumaro.py @@ -0,0 +1,12 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +class DatumaroPath: + IMAGES_DIR = 'images' + ANNOTATIONS_DIR = 'annotations' + MASKS_DIR = 'masks' + + IMAGE_EXT = '.jpg' + MASK_EXT = '.png' diff --git a/datumaro/datumaro/components/formats/ms_coco.py b/datumaro/datumaro/components/formats/ms_coco.py new file mode 100644 index 000000000000..bb3bb82bafdb --- /dev/null +++ b/datumaro/datumaro/components/formats/ms_coco.py @@ -0,0 +1,23 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from enum import Enum + + +CocoAnnotationType = Enum('CocoAnnotationType', [ + 'instances', + 'person_keypoints', + 'captions', + 'labels', # extension, does not exist in original COCO format + 'image_info', + 'panoptic', + 'stuff', +]) + +class CocoPath: + IMAGES_DIR = 'images' + ANNOTATIONS_DIR = 'annotations' + + IMAGE_EXT = '.jpg' \ No newline at end of file diff --git a/datumaro/datumaro/components/formats/voc.py b/datumaro/datumaro/components/formats/voc.py new file mode 100644 index 000000000000..b7acaffde6a4 --- /dev/null +++ b/datumaro/datumaro/components/formats/voc.py @@ -0,0 +1,103 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from enum import Enum +import numpy as np + + +VocTask = Enum('VocTask', [ + 'classification', + 'detection', + 'segmentation', + 'action_classification', + 'person_layout', +]) + +VocLabel = Enum('VocLabel', [ + ('aeroplane', 0), + ('bicycle', 1), + ('bird', 2), + ('boat', 3), + ('bottle', 4), + ('bus', 5), + ('car', 6), + ('cat', 7), + ('chair', 8), + ('cow', 9), + ('diningtable', 10), + ('dog', 11), + ('horse', 12), + ('motorbike', 13), + ('person', 14), + ('pottedplant', 15), + ('sheep', 16), + ('sofa', 17), + ('train', 18), + ('tvmonitor', 19), +]) + +VocPose = Enum('VocPose', [ + 'Unspecified', + 'Left', + 'Right', + 'Frontal', + 'Rear', +]) + +VocBodyPart = Enum('VocBodyPart', [ + 'head', + 'hand', + 'foot', +]) + +VocAction = Enum('VocAction', [ + 'other', + 'jumping', + 'phoning', + 'playinginstrument', + 'reading', + 'ridingbike', + 'ridinghorse', + 'running', + 'takingphoto', + 'usingcomputer', + 'walking', +]) + +def generate_colormap(length=256): + def get_bit(number, index): + return (number >> index) & 1 + + colormap = np.zeros((length, 3), dtype=int) + indices = np.arange(length, dtype=int) + + for j in range(7, -1, -1): + for c in range(3): + colormap[:, c] |= get_bit(indices, c) << j + indices >>= 3 + + return { + id: tuple(color) for id, color in enumerate(colormap) + } + +VocColormap = generate_colormap(len(VocLabel)) +VocInstColormap = generate_colormap(256) + +class VocPath: + IMAGES_DIR = 'JPEGImages' + ANNOTATIONS_DIR = 'Annotations' + SEGMENTATION_DIR = 'SegmentationClass' + INSTANCES_DIR = 'SegmentationObject' + SUBSETS_DIR = 'ImageSets' + IMAGE_EXT = '.jpg' + SEGM_EXT = '.png' + + TASK_DIR = { + VocTask.classification: 'Main', + VocTask.detection: 'Main', + VocTask.segmentation: 'Segmentation', + VocTask.action_classification: 'Action', + VocTask.person_layout: 'Layout', + } \ No newline at end of file diff --git a/datumaro/datumaro/components/importers/__init__.py b/datumaro/datumaro/components/importers/__init__.py new file mode 100644 index 000000000000..4383b467b660 --- /dev/null +++ b/datumaro/datumaro/components/importers/__init__.py @@ -0,0 +1,24 @@ + +# Copyright (C) 2019 Intel Corporation +# +# 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, +) + +items = [ + ('datumaro', DatumaroImporter), + + ('ms_coco', CocoImporter), + + ('voc', VocImporter), + ('voc_results', VocResultsImporter), +] \ No newline at end of file diff --git a/datumaro/datumaro/components/importers/datumaro.py b/datumaro/datumaro/components/importers/datumaro.py new file mode 100644 index 000000000000..40939b90cf18 --- /dev/null +++ b/datumaro/datumaro/components/importers/datumaro.py @@ -0,0 +1,25 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import os.path as osp + + +class DatumaroImporter: + EXTRACTOR_NAME = 'datumaro' + + def __call__(self, path): + from datumaro.components.project import Project # cyclic import + project = Project() + + if not osp.exists(path): + raise Exception("Failed to find 'datumaro' dataset at '%s'" % path) + + source_name = osp.splitext(osp.basename(path))[0] + project.add_source(source_name, { + 'url': path, + 'format': self.EXTRACTOR_NAME, + }) + + return project \ No newline at end of file diff --git a/datumaro/datumaro/components/importers/ms_coco.py b/datumaro/datumaro/components/importers/ms_coco.py new file mode 100644 index 000000000000..b3af4bfa5af2 --- /dev/null +++ b/datumaro/datumaro/components/importers/ms_coco.py @@ -0,0 +1,69 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from collections import defaultdict +import os +import os.path as osp + +from datumaro.components.formats.ms_coco import CocoAnnotationType, CocoPath + + +class CocoImporter: + _COCO_EXTRACTORS = { + CocoAnnotationType.instances: 'coco_instances', + CocoAnnotationType.person_keypoints: 'coco_person_kp', + CocoAnnotationType.captions: 'coco_captions', + CocoAnnotationType.labels: 'coco_labels', + CocoAnnotationType.image_info: 'coco_images', + } + + def __init__(self, task_filter=None): + self._task_filter = task_filter + + def __call__(self, path): + from datumaro.components.project import Project # cyclic import + project = Project() + + subsets = self.find_subsets(path) + + if len(subsets) == 0: + raise Exception("Failed to find 'coco' dataset at '%s'" % path) + + for ann_files in subsets.values(): + for ann_type, ann_file in ann_files.items(): + source_name = osp.splitext(osp.basename(ann_file))[0] + project.add_source(source_name, { + 'url': ann_file, + 'format': self._COCO_EXTRACTORS[ann_type], + }) + + return project + + @staticmethod + def find_subsets(dataset_dir): + ann_dir = os.path.join(dataset_dir, CocoPath.ANNOTATIONS_DIR) + if not osp.isdir(ann_dir): + raise NotADirectoryError( + 'COCO annotations directory not found at "%s"' % ann_dir) + + subsets = defaultdict(dict) + for ann_file in os.listdir(ann_dir): + subset_path = osp.join(ann_dir, ann_file) + if not subset_path.endswith('.json'): + continue + + name_parts = osp.splitext(ann_file)[0].rsplit('_', maxsplit=1) + ann_type = name_parts[0] + try: + ann_type = CocoAnnotationType[ann_type] + except KeyError: + raise Exception( + 'Unknown subset type %s, only known are: %s' % \ + (ann_type, + ', '.join([e.name for e in CocoAnnotationType]) + )) + subset_name = name_parts[1] + subsets[subset_name][ann_type] = subset_path + return dict(subsets) \ No newline at end of file diff --git a/datumaro/datumaro/components/importers/voc.py b/datumaro/datumaro/components/importers/voc.py new file mode 100644 index 000000000000..432cf374141e --- /dev/null +++ b/datumaro/datumaro/components/importers/voc.py @@ -0,0 +1,77 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import os +import os.path as osp + +from datumaro.components.formats.voc import VocTask, VocPath +from datumaro.util import find + + +class VocImporter: + _TASKS = [ + (VocTask.classification, 'voc_cls', 'Main'), + (VocTask.detection, 'voc_det', 'Main'), + (VocTask.segmentation, 'voc_segm', 'Segmentation'), + (VocTask.person_layout, 'voc_layout', 'Layout'), + (VocTask.action_classification, 'voc_action', 'Action'), + ] + + def __call__(self, path): + from datumaro.components.project import Project # cyclic import + project = Project() + + for task, extractor_type, task_dir in self._TASKS: + task_dir = osp.join(path, VocPath.SUBSETS_DIR, task_dir) + if not osp.isdir(task_dir): + continue + + project.add_source(task.name, { + 'url': path, + 'format': extractor_type, + }) + + if len(project.config.sources) == 0: + raise Exception("Failed to find 'voc' dataset at '%s'" % path) + + return project + + +class VocResultsImporter: + _TASKS = [ + ('comp1', 'voc_comp_1_2', 'Main'), + ('comp2', 'voc_comp_1_2', 'Main'), + ('comp3', 'voc_comp_3_4', 'Main'), + ('comp4', 'voc_comp_3_4', 'Main'), + ('comp5', 'voc_comp_5_6', 'Segmentation'), + ('comp6', 'voc_comp_5_6', 'Segmentation'), + ('comp7', 'voc_comp_7_8', 'Layout'), + ('comp8', 'voc_comp_7_8', 'Layout'), + ('comp9', 'voc_comp_9_10', 'Action'), + ('comp10', 'voc_comp_9_10', 'Action'), + ] + + def __call__(self, path): + from datumaro.components.project import Project # cyclic import + project = Project() + + for task_name, extractor_type, task_dir in self._TASKS: + task_dir = osp.join(path, task_dir) + if not osp.isdir(task_dir): + continue + dir_items = os.listdir(task_dir) + if not find(dir_items, lambda x: x == task_name): + continue + + project.add_source(task_name, { + 'url': task_dir, + 'format': extractor_type, + }) + + if len(project.config.sources) == 0: + raise Exception("Failed to find 'voc_results' dataset at '%s'" % \ + path) + + return project \ No newline at end of file diff --git a/datumaro/datumaro/components/launcher.py b/datumaro/datumaro/components/launcher.py new file mode 100644 index 000000000000..0e11e8cf076e --- /dev/null +++ b/datumaro/datumaro/components/launcher.py @@ -0,0 +1,95 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import numpy as np + +from datumaro.components.extractor import DatasetItem, Extractor + + +# pylint: disable=no-self-use +class Launcher: + def __init__(self): + pass + + def launch(self, inputs): + raise NotImplementedError() + + def preferred_input_size(self): + return None + + def get_categories(self): + return None +# pylint: enable=no-self-use + +class InferenceWrapper(Extractor): + class ItemWrapper(DatasetItem): + def __init__(self, item, annotations, path=None): + super().__init__(id=item.id) + self._annotations = annotations + self._item = item + self._path = path + + @DatasetItem.id.getter + def id(self): + return self._item.id + + @DatasetItem.subset.getter + def subset(self): + return self._item.subset + + @DatasetItem.path.getter + def path(self): + return self._path + + @DatasetItem.annotations.getter + def annotations(self): + return self._annotations + + @DatasetItem.image.getter + def image(self): + return self._item.image + + def __init__(self, extractor, launcher, batch_size=1): + super().__init__() + self._extractor = extractor + self._launcher = launcher + self._batch_size = batch_size + + def __iter__(self): + stop = False + data_iter = iter(self._extractor) + while not stop: + batch_items = [] + try: + for _ in range(self._batch_size): + item = next(data_iter) + batch_items.append(item) + except StopIteration: + stop = True + if len(batch_items) == 0: + break + + inputs = np.array([item.image for item in batch_items]) + inference = self._launcher.launch(inputs) + + for item, annotations in zip(batch_items, inference): + yield self.ItemWrapper(item, annotations) + + def __len__(self): + return len(self._extractor) + + def subsets(self): + return self._extractor.subsets() + + def get_subset(self, name): + subset = self._extractor.get_subset(name) + return InferenceWrapper(subset, + self._launcher, self._batch_size) + + def categories(self): + launcher_override = self._launcher.get_categories() + if launcher_override is not None: + return launcher_override + return self._extractor.categories() \ No newline at end of file diff --git a/datumaro/datumaro/components/launchers/__init__.py b/datumaro/datumaro/components/launchers/__init__.py new file mode 100644 index 000000000000..8d613a2ac53a --- /dev/null +++ b/datumaro/datumaro/components/launchers/__init__.py @@ -0,0 +1,13 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +items = [ +] + +try: + from datumaro.components.launchers.openvino import OpenVinoLauncher + items.append(('openvino', OpenVinoLauncher)) +except ImportError: + pass diff --git a/datumaro/datumaro/components/launchers/openvino.py b/datumaro/datumaro/components/launchers/openvino.py new file mode 100644 index 000000000000..28155539b98f --- /dev/null +++ b/datumaro/datumaro/components/launchers/openvino.py @@ -0,0 +1,189 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +# pylint: disable=exec-used + +import cv2 +import os +import os.path as osp +import numpy as np +import subprocess +import platform + +from openvino.inference_engine import IENetwork, IEPlugin + +from datumaro.components.launcher import Launcher + + +class InterpreterScript: + def __init__(self, path): + with open(path, 'r') as f: + script = f.read() + + context = {} + exec(script, context, context) + + process_outputs = context['process_outputs'] + assert callable(process_outputs) + self.__dict__['process_outputs'] = process_outputs + + get_categories = context.get('get_categories') + assert callable(get_categories) or get_categories is None + self.__dict__['get_categories'] = get_categories + + @staticmethod + def get_categories(): + return None + + @staticmethod + def process_outputs(inputs, outputs): + return [] + +class OpenVinoLauncher(Launcher): + _DEFAULT_IE_PLUGINS_PATH = "/opt/intel/openvino_2019.1.144/deployment_tools/inference_engine/lib/intel64" + _IE_PLUGINS_PATH = os.getenv("IE_PLUGINS_PATH", _DEFAULT_IE_PLUGINS_PATH) + + @staticmethod + def _check_instruction_set(instruction): + return instruction == str.strip( + subprocess.check_output( + 'lscpu | grep -o "{}" | head -1'.format(instruction), shell=True + ).decode('utf-8') + ) + + @staticmethod + def make_plugin(device='cpu', plugins_path=_IE_PLUGINS_PATH): + if plugins_path is None or not osp.isdir(plugins_path): + raise Exception('Inference engine plugins directory "%s" not found' % \ + (plugins_path)) + + plugin = IEPlugin(device='CPU', plugin_dirs=[plugins_path]) + if (OpenVinoLauncher._check_instruction_set('avx2')): + plugin.add_cpu_extension(os.path.join(plugins_path, + 'libcpu_extension_avx2.so')) + elif (OpenVinoLauncher._check_instruction_set('sse4')): + plugin.add_cpu_extension(os.path.join(plugins_path, + 'libcpu_extension_sse4.so')) + elif platform.system() == 'Darwin': + plugin.add_cpu_extension(os.path.join(plugins_path, + 'libcpu_extension.dylib')) + else: + raise Exception('Inference engine requires support of avx2 or sse4') + + return plugin + + @staticmethod + def make_network(model, weights): + return IENetwork.from_ir(model=model, weights=weights) + + def __init__(self, description, weights, interpretation_script, + plugins_path=None, model_dir=None, **kwargs): + if model_dir is None: + model_dir = '' + if not osp.isfile(description): + description = osp.join(model_dir, description) + if not osp.isfile(description): + raise Exception('Failed to open model description file "%s"' % \ + (description)) + + if not osp.isfile(weights): + weights = osp.join(model_dir, weights) + if not osp.isfile(weights): + raise Exception('Failed to open model weights file "%s"' % \ + (weights)) + + if not osp.isfile(interpretation_script): + interpretation_script = \ + osp.join(model_dir, interpretation_script) + if not osp.isfile(interpretation_script): + raise Exception('Failed to open model interpretation script file "%s"' % \ + (interpretation_script)) + + self._interpreter_script = InterpreterScript(interpretation_script) + + if plugins_path is None: + plugins_path = OpenVinoLauncher._IE_PLUGINS_PATH + + plugin = OpenVinoLauncher.make_plugin(plugins_path=plugins_path) + network = OpenVinoLauncher.make_network(description, weights) + self._network = network + self._plugin = plugin + self._load_executable_net() + + def _load_executable_net(self, batch_size=1): + network = self._network + plugin = self._plugin + + supported_layers = plugin.get_supported_layers(network) + not_supported_layers = [l for l in network.layers.keys() if l not in supported_layers] + if len(not_supported_layers) != 0: + raise Exception('Following layers are not supported by the plugin' + ' for the specified device {}:\n {}'. format( \ + plugin.device, ", ".join(not_supported_layers))) + + iter_inputs = iter(network.inputs) + self._input_blob_name = next(iter_inputs) + self._output_blob_name = next(iter(network.outputs)) + + # NOTE: handling for the inclusion of `image_info` in OpenVino2019 + self._require_image_info = 'image_info' in network.inputs + if self._input_blob_name == 'image_info': + self._input_blob_name = next(iter_inputs) + + input_type = network.inputs[self._input_blob_name] + self._input_layout = input_type if isinstance(input_type, list) else input_type.shape + + self._input_layout[0] = batch_size + network.reshape({self._input_blob_name: self._input_layout}) + self._batch_size = batch_size + + self._net = plugin.load(network=network, num_requests=1) + + def infer(self, inputs): + assert len(inputs.shape) == 4, \ + "Expected an input image in (N, H, W, C) format, got %s" % \ + (inputs.shape) + assert inputs.shape[3] == 3, \ + "Expected BGR input" + + n, c, h, w = self._input_layout + if inputs.shape[1:3] != (h, w): + resized_inputs = np.empty((n, h, w, c), dtype=inputs.dtype) + for inp, resized_input in zip(inputs, resized_inputs): + cv2.resize(inp, (w, h), resized_input) + inputs = resized_inputs + inputs = inputs.transpose((0, 3, 1, 2)) # NHWC to NCHW + inputs = {self._input_blob_name: inputs} + if self._require_image_info: + info = np.zeros([1, 3]) + info[0, 0] = h + info[0, 1] = w + info[0, 2] = 1.0 # scale + inputs['image_info'] = info + + results = self._net.infer(inputs) + if len(results) == 1: + return results[self._output_blob_name] + else: + return results + + def launch(self, inputs): + batch_size = len(inputs) + if self._batch_size < batch_size: + self._load_executable_net(batch_size) + + outputs = self.infer(inputs) + results = self.process_outputs(inputs, outputs) + return results + + def get_categories(self): + return self._interpreter_script.get_categories() + + def process_outputs(self, inputs, outputs): + return self._interpreter_script.process_outputs(inputs, outputs) + + def preferred_input_size(self): + _, _, h, w = self._input_layout + return (h, w) diff --git a/datumaro/datumaro/components/project.py b/datumaro/datumaro/components/project.py new file mode 100644 index 000000000000..e51c2baabbc2 --- /dev/null +++ b/datumaro/datumaro/components/project.py @@ -0,0 +1,712 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from collections import OrderedDict, defaultdict +import git +import importlib +from functools import reduce +import logging as log +import os +import os.path as osp +import sys + +from datumaro.components.config import Config, DEFAULT_FORMAT +from datumaro.components.config_model import * +from datumaro.components.extractor import * +from datumaro.components.launcher import * +from datumaro.components.dataset_filter import XPathDatasetFilter + + +def import_foreign_module(name, path): + module = None + default_path = sys.path.copy() + try: + sys.path = [ osp.abspath(path), ] + default_path + sys.modules.pop(name, None) # remove from cache + module = importlib.import_module(name) + sys.modules.pop(name) # remove from cache + except ImportError as e: + log.warn("Failed to import module '%s': %s" % (name, e)) + finally: + sys.path = default_path + return module + + +class Registry: + def __init__(self, config=None, item_type=None): + self.item_type = item_type + + self.items = {} + + if config is not None: + self.load(config) + + def load(self, config): + pass + + def register(self, name, value): + if self.item_type: + value = self.item_type(value) + self.items[name] = value + return value + + def unregister(self, name): + return self.items.pop(name, None) + + def get(self, key): + return self.items[key] # returns a class / ctor + + +class ModelRegistry(Registry): + def __init__(self, config=None): + super().__init__(config, item_type=Model) + + def load(self, config): + # TODO: list default dir, insert values + if 'models' in config: + for name, model in config.models.items(): + self.register(name, model) + + +class SourceRegistry(Registry): + def __init__(self, config=None): + super().__init__(config, item_type=Source) + + def load(self, config): + # TODO: list default dir, insert values + if 'sources' in config: + for name, source in config.sources.items(): + self.register(name, source) + + +class ModuleRegistry(Registry): + def __init__(self, config=None, builtin=None, local=None): + super().__init__(config) + + if builtin is not None: + for k, v in builtin: + self.register(k, v) + if local is not None: + for k, v in local: + self.register(k, v) + + +class GitWrapper: + def __init__(self, config=None): + self.repo = None + + if config is not None: + self.init(config.project_dir) + + @staticmethod + 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) + if spawn: + author = git.Actor("Nobody", "nobody@example.com") + self.repo.index.commit('Initial commit', author=author) + return self.repo + + def get_repo(self): + return self.repo + + def is_initialized(self): + return self.repo is not None + + def create_submodule(self, name, dst_dir, **kwargs): + self.repo.create_submodule(name, dst_dir, **kwargs) + + def has_submodule(self, name): + return name in [submodule.name for submodule in self.repo.submodules] + + def remove_submodule(self, name, **kwargs): + return self.repo.submodule(name).remove(**kwargs) + +def load_project_as_dataset(url): + # symbol forward declaration + raise NotImplementedError() + +class Environment: + PROJECT_EXTRACTOR_NAME = 'project' + + def __init__(self, config=None): + config = Config(config, + fallback=PROJECT_DEFAULT_CONFIG, schema=PROJECT_SCHEMA) + + env_dir = osp.join(config.project_dir, config.env_dir) + env_config_path = osp.join(env_dir, config.env_filename) + env_config = Config(fallback=ENV_DEFAULT_CONFIG, schema=ENV_SCHEMA) + if osp.isfile(env_config_path): + env_config.update(Config.parse(env_config_path)) + + self.config = env_config + + self.models = ModelRegistry(env_config) + self.sources = SourceRegistry(config) + + import datumaro.components.importers as builtin_importers + builtin_importers = builtin_importers.items + custom_importers = self._get_custom_module_items( + env_dir, env_config.importers_dir) + self.importers = ModuleRegistry(config, + builtin=builtin_importers, local=custom_importers) + + import datumaro.components.extractors as builtin_extractors + builtin_extractors = builtin_extractors.items + custom_extractors = self._get_custom_module_items( + env_dir, env_config.extractors_dir) + self.extractors = ModuleRegistry(config, + builtin=builtin_extractors, local=custom_extractors) + self.extractors.register(self.PROJECT_EXTRACTOR_NAME, + load_project_as_dataset) + + import datumaro.components.launchers as builtin_launchers + builtin_launchers = builtin_launchers.items + custom_launchers = self._get_custom_module_items( + env_dir, env_config.launchers_dir) + self.launchers = ModuleRegistry(config, + builtin=builtin_launchers, local=custom_launchers) + + import datumaro.components.converters as builtin_converters + builtin_converters = builtin_converters.items + custom_converters = self._get_custom_module_items( + env_dir, env_config.converters_dir) + if custom_converters is not None: + custom_converters = custom_converters.items + self.converters = ModuleRegistry(config, + builtin=builtin_converters, local=custom_converters) + + self.statistics = ModuleRegistry(config) + self.visualizers = ModuleRegistry(config) + self.git = GitWrapper(config) + + def _get_custom_module_items(self, module_dir, module_name): + items = None + + module = None + if osp.exists(osp.join(module_dir, module_name)): + module = import_foreign_module(module_name, module_dir) + if module is not None: + if hasattr(module, 'items'): + items = module.items + else: + items = self._find_custom_module_items( + osp.join(module_dir, module_name)) + + return items + + @staticmethod + def _find_custom_module_items(module_dir): + files = [p for p in os.listdir(module_dir) + if p.endswith('.py') and p != '__init__.py'] + + all_items = [] + for f in files: + name = osp.splitext(f)[0] + module = import_foreign_module(name, module_dir) + + items = [] + if hasattr(module, 'items'): + items = module.items + else: + if hasattr(module, name): + items = [ (name, getattr(module, name)) ] + else: + log.warn("Failed to import custom module '%s'." + " Custom module is expected to provide 'items' " + "list or have an item matching its file name." + " Skipping this module." % \ + (module_dir + '.' + name)) + + all_items.extend(items) + + return all_items + + def save(self, path): + self.config.dump(path) + + def make_extractor(self, name, *args, **kwargs): + return self.extractors.get(name)(*args, **kwargs) + + def make_importer(self, name, *args, **kwargs): + return self.importers.get(name)(*args, **kwargs) + + def make_launcher(self, name, *args, **kwargs): + return self.launchers.get(name)(*args, **kwargs) + + def make_converter(self, name, *args, **kwargs): + return self.converters.get(name)(*args, **kwargs) + + def register_model(self, name, model): + self.config.models[name] = model + self.models.register(name, model) + + def unregister_model(self, name): + self.config.models.remove(name) + self.models.unregister(name) + + +class Subset(Extractor): + def __init__(self, parent): + self._parent = parent + self.items = OrderedDict() + + def __iter__(self): + for item in self.items.values(): + yield item + + def __len__(self): + return len(self.items) + + def categories(self): + return self._parent.categories() + +class DatasetItemWrapper(DatasetItem): + def __init__(self, item, path, annotations, image=None): + self._item = item + self._path = path + self._annotations = annotations + self._image = image + + @DatasetItem.id.getter + def id(self): + return self._item.id + + @DatasetItem.subset.getter + def subset(self): + return self._item.subset + + @DatasetItem.path.getter + def path(self): + return self._path + + @DatasetItem.annotations.getter + def annotations(self): + return self._annotations + + @DatasetItem.has_image.getter + def has_image(self): + if self._image is not None: + return True + return self._item.has_image + + @DatasetItem.image.getter + def image(self): + if self._image is not None: + if callable(self._image): + return self._image() + return self._image + return self._item.image + +class ProjectDataset(Extractor): + def __init__(self, project): + super().__init__() + + self._project = project + config = self.config + env = self.env + + dataset_filter = None + if config.filter: + dataset_filter = XPathDatasetFilter(config.filter) + self._filter = dataset_filter + + sources = {} + for s_name, source in config.sources.items(): + s_format = source.format + if not s_format: + s_format = env.PROJECT_EXTRACTOR_NAME + options = {} + options.update(source.options) + + url = source.url + if not source.url: + url = osp.join(config.project_dir, config.sources_dir, s_name) + sources[s_name] = env.make_extractor(s_format, + url, **options) + self._sources = sources + + own_source = None + own_source_dir = osp.join(config.project_dir, config.dataset_dir) + if osp.isdir(own_source_dir): + own_source = env.make_extractor(DEFAULT_FORMAT, own_source_dir) + + # merge categories + # TODO: implement properly with merging and annotations remapping + categories = {} + for source in self._sources.values(): + 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 own_source is not None and len(own_source) != 0: + categories.update(own_source.categories()) + self._categories = categories + + # merge items + subsets = defaultdict(lambda: Subset(self)) + for source_name, source in self._sources.items(): + for item in source: + if dataset_filter and not dataset_filter(item): + continue + + existing_item = subsets[item.subset].items.get(item.id) + if existing_item is not None: + image = None + if existing_item.has_image: + # TODO: think of image comparison + image = lambda: existing_item.image + + path = existing_item.path + if item.path != path: + path = None + item = DatasetItemWrapper(item=item, path=path, + image=image, annotations=self._merge_anno( + existing_item.annotations, item.annotations)) + else: + s_config = config.sources[source_name] + if s_config and \ + s_config.format != self.env.PROJECT_EXTRACTOR_NAME: + # NOTE: consider imported sources as our own dataset + path = None + else: + path = item.path + if path is None: + path = [] + path = [source_name] + path + item = DatasetItemWrapper(item=item, path=path, + annotations=item.annotations) + + subsets[item.subset].items[item.id] = item + + # override with our items, fallback to existing images + if own_source is not None: + for item in own_source: + if dataset_filter and not dataset_filter(item): + continue + + if not item.has_image: + existing_item = subsets[item.subset].items.get(item.id) + if existing_item is not None: + image = None + if existing_item.has_image: + # TODO: think of image comparison + image = lambda: existing_item.image + item = DatasetItemWrapper(item=item, path=None, + annotations=item.annotations, image=image) + + subsets[item.subset].items[item.id] = item + + # TODO: implement subset remapping when needed + subsets_filter = config.subsets + if len(subsets_filter) != 0: + subsets = { k: v for k, v in subsets.items() if k in subsets_filter} + self._subsets = dict(subsets) + + self._length = None + + @staticmethod + def _merge_anno(a, b): + from itertools import chain + merged = [] + for item in chain(a, b): + found = False + for elem in merged: + if elem == item: + found = True + break + if not found: + merged.append(item) + + return merged + + def iterate_own(self): + return self.select(lambda item: not item.path) + + def __iter__(self): + for subset in self._subsets.values(): + for item in subset: + if self._filter and not self._filter(item): + continue + yield item + + def __len__(self): + if self._length is None: + self._length = reduce(lambda s, x: s + len(x), + self._subsets.values(), 0) + return self._length + + def get_subset(self, name): + return self._subsets[name] + + def subsets(self): + return list(self._subsets) + + def categories(self): + return self._categories + + def define_categories(self, categories): + assert not self._categories + self._categories = categories + + def get(self, item_id, subset=None, path=None): + if path: + source = path[0] + rest_path = path[1:] + return self._sources[source].get( + item_id=item_id, subset=subset, path=rest_path) + return self._subsets[subset].items[item_id] + + def put(self, item, item_id=None, subset=None, path=None): + if path is None: + path = item.path + if path: + source = path[0] + rest_path = path[1:] + # TODO: reverse remapping + self._sources[source].put(item, + item_id=item_id, subset=subset, path=rest_path) + + if item_id is None: + item_id = item.id + if subset is None: + subset = item.subset + + item = DatasetItemWrapper(item=item, path=path, + annotations=item.annotations) + if item.subset not in self._subsets: + self._subsets[item.subset] = Subset(self) + self._subsets[subset].items[item_id] = item + self._length = None + + return item + + def build(self, tasks=None): + pass + + def docs(self): + pass + + def transform(self, model_name, save_dir=None): + project = Project(self.config) + project.config.remove('sources') + + if save_dir is None: + save_dir = self.config.project_dir + project.config.project_dir = save_dir + + dataset = project.make_dataset() + launcher = self._project.make_executable_model(model_name) + inference = InferenceWrapper(self, launcher) + dataset.update(inference) + + dataset.save(merge=True) + + def export(self, save_dir, output_format, + filter_expr=None, **converter_kwargs): + save_dir = osp.abspath(save_dir) + os.makedirs(save_dir, exist_ok=True) + + dataset = self + if filter_expr: + dataset_filter = XPathDatasetFilter(filter_expr) + dataset = dataset.select(dataset_filter) + + converter = self.env.make_converter(output_format, **converter_kwargs) + converter(dataset, save_dir) + + def extract(self, save_dir, filter_expr=None): + project = Project(self.config) + if filter_expr: + XPathDatasetFilter(filter_expr) + project.set_filter(filter_expr) + project.save(save_dir) + + def update(self, items): + for item in items: + if self._filter and not self._filter(item): + continue + self.put(item) + return self + + def save(self, save_dir=None, merge=False, recursive=True, + save_images=False, apply_colormap=True): + if save_dir is None: + assert self.config.project_dir + save_dir = self.config.project_dir + project = self._project + else: + merge = True + + if merge: + project = Project(Config(self.config)) + project.config.remove('sources') + + save_dir = osp.abspath(save_dir) + os.makedirs(save_dir, exist_ok=True) + + dataset_save_dir = osp.join(save_dir, project.config.dataset_dir) + os.makedirs(dataset_save_dir, exist_ok=True) + + converter_kwargs = { + 'save_images': save_images, + 'apply_colormap': apply_colormap, + } + + if merge: + # merge and save the resulting dataset + converter = self.env.make_converter( + DEFAULT_FORMAT, **converter_kwargs) + converter(self, dataset_save_dir) + else: + if recursive: + # children items should already be updated + # so we just save them recursively + for source in self._sources.values(): + if isinstance(source, ProjectDataset): + source.save(**converter_kwargs) + + converter = self.env.make_converter( + DEFAULT_FORMAT, **converter_kwargs) + converter(self.iterate_own(), dataset_save_dir) + + project.save(save_dir) + + @property + def env(self): + return self._project.env + + @property + def config(self): + return self._project.config + + @property + def sources(self): + return self._sources + +class Project: + @staticmethod + def load(path): + path = osp.abspath(path) + if osp.isdir(path): + path = osp.join(path, PROJECT_DEFAULT_CONFIG.project_filename) + config = Config.parse(path) + config.project_dir = osp.dirname(path) + config.project_filename = osp.basename(path) + return Project(config) + + def save(self, save_dir=None): + config = self.config + if save_dir is None: + assert config.project_dir + save_dir = osp.abspath(config.project_dir) + config_path = osp.join(save_dir, config.project_filename) + + env_dir = osp.join(save_dir, config.env_dir) + os.makedirs(env_dir, exist_ok=True) + self.env.save(osp.join(env_dir, config.env_filename)) + + config.dump(config_path) + + @staticmethod + def generate(save_dir, config=None): + project = Project(config) + project.save(save_dir) + project.config.project_dir = save_dir + return project + + @staticmethod + def import_from(path, dataset_format, env=None, **kwargs): + if env is None: + env = Environment() + importer = env.make_importer(dataset_format) + return importer(path, **kwargs) + + def __init__(self, config=None): + self.config = Config(config, + fallback=PROJECT_DEFAULT_CONFIG, schema=PROJECT_SCHEMA) + self.env = Environment(self.config) + + def make_dataset(self): + return ProjectDataset(self) + + def add_source(self, name, value=Source()): + if isinstance(value, (dict, Config)): + value = Source(value) + self.config.sources[name] = value + self.env.sources.register(name, value) + + def remove_source(self, name): + self.config.sources.remove(name) + self.env.sources.unregister(name) + + def get_source(self, name): + return self.config.sources[name] + + def get_subsets(self): + return self.config.subsets + + def set_subsets(self, value): + if not value: + self.config.remove('subsets') + else: + self.config.subsets = value + + def add_model(self, name, value=Model()): + if isinstance(value, (dict, Config)): + value = Model(value) + self.env.register_model(name, value) + + def get_model(self, name): + return self.env.models.get(name) + + def remove_model(self, name): + self.env.unregister_model(name) + + def make_executable_model(self, name): + model = self.get_model(name) + model.model_dir = self.local_model_dir(name) + return self.env.make_launcher(model.launcher, + **model.options, model_dir=model.model_dir) + + def make_source_project(self, name): + source = self.get_source(name) + + config = Config(self.config) + config.remove('sources') + config.remove('subsets') + config.remove('filter') + project = Project(config) + project.add_source(name, source) + return project + + def get_filter(self): + if 'filter' in self.config: + return self.config.filter + return '' + + def set_filter(self, value=None): + if not value: + self.config.remove('filter') + else: + # check filter + XPathDatasetFilter(value) + self.config.filter = value + + def local_model_dir(self, model_name): + return osp.join( + self.config.env_dir, self.env.config.models_dir, model_name) + + def local_source_dir(self, source_name): + return osp.join(self.config.sources_dir, source_name) + +# pylint: disable=function-redefined +def load_project_as_dataset(url): + # implement the function declared above + return Project.load(url).make_dataset() +# pylint: enable=function-redefined \ No newline at end of file diff --git a/datumaro/datumaro/util/__init__.py b/datumaro/datumaro/util/__init__.py new file mode 100644 index 000000000000..87c800fe515c --- /dev/null +++ b/datumaro/datumaro/util/__init__.py @@ -0,0 +1,20 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import os + + +def find(iterable, pred=lambda x: True, default=None): + return next((x for x in iterable if pred(x)), default) + +def dir_items(path, ext, truncate_ext=False): + items = [] + for f in os.listdir(path): + ext_pos = f.rfind(ext) + if ext_pos != -1: + if truncate_ext: + f = f[:ext_pos] + items.append(f) + return items \ No newline at end of file diff --git a/datumaro/datumaro/util/command_targets.py b/datumaro/datumaro/util/command_targets.py new file mode 100644 index 000000000000..8c7c2dd92717 --- /dev/null +++ b/datumaro/datumaro/util/command_targets.py @@ -0,0 +1,110 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import argparse +import cv2 +from enum import Enum + +from datumaro.components.project import Project + + +TargetKinds = Enum('TargetKinds', + ['project', 'source', 'external_dataset', 'inference', 'image']) + +def is_project_name(value, project): + return value == project.config.project_name + +def is_project_path(value): + if value: + try: + Project.load(value) + return True + except Exception: + pass + return False + +def is_project(value, project=None): + if is_project_path(value): + return True + elif project is not None: + return is_project_name(value, project) + + return False + +def is_source(value, project=None): + if project is not None: + try: + project.get_source(value) + return True + except KeyError: + pass + + return False + +def is_external_source(value): + return False + +def is_inference_path(value): + return False + +def is_image_path(value): + return cv2.imread(value) is not None + + +class Target: + def __init__(self, kind, test, is_default=False, name=None): + self.kind = kind + self.test = test + self.is_default = is_default + self.name = name + + def _get_fields(self): + return [self.kind, self.test, self.is_default, self.name] + + def __str__(self): + return self.name or str(self.kind) + + def __len__(self): + return len(self._get_fields()) + + def __iter__(self): + return iter(self._get_fields()) + +def ProjectTarget(kind=TargetKinds.project, test=None, + is_default=False, name='project name or path', + project=None): + if test is None: + test = lambda v: is_project(v, project=project) + return Target(kind, test, is_default, name) + +def SourceTarget(kind=TargetKinds.source, test=None, + is_default=False, name='source name', + project=None): + if test is None: + test = lambda v: is_source(v, project=project) + return Target(kind, test, is_default, name) + +def ExternalDatasetTarget(kind=TargetKinds.external_dataset, + test=is_external_source, + is_default=False, name='external dataset path'): + return Target(kind, test, is_default, name) + +def InferenceTarget(kind=TargetKinds.inference, test=is_inference_path, + is_default=False, name='inference path'): + return Target(kind, test, is_default, name) + +def ImageTarget(kind=TargetKinds.image, test=is_image_path, + is_default=False, name='image path'): + return Target(kind, test, is_default, name) + + +def target_selector(*targets): + def selector(value): + for (kind, test, is_default, _) in targets: + if (is_default and (value == '' or value is None)) or test(value): + return (kind, value) + raise argparse.ArgumentTypeError('Value should be one of: %s' \ + % (', '.join([str(t) for t in targets]))) + return selector diff --git a/datumaro/datumaro/util/image.py b/datumaro/datumaro/util/image.py new file mode 100644 index 000000000000..8e1794aab333 --- /dev/null +++ b/datumaro/datumaro/util/image.py @@ -0,0 +1,30 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import cv2 +import numpy as np + + +def load_image(path): + """ + Reads an image in the HWC Grayscale/BGR(A) float [0; 255] format. + """ + image = cv2.imread(path) + image = image.astype(np.float32) + + assert len(image.shape) == 3 + assert image.shape[2] in [1, 3, 4] + return image + +class lazy_image: + def __init__(self, path, loader=load_image): + self.path = path + self.loader = loader + self.image = None + + def __call__(self): + if self.image is None: + self.image = self.loader(self.path) + return self.image \ No newline at end of file diff --git a/datumaro/datumaro/util/mask_tools.py b/datumaro/datumaro/util/mask_tools.py new file mode 100644 index 000000000000..d9c7fe9235d7 --- /dev/null +++ b/datumaro/datumaro/util/mask_tools.py @@ -0,0 +1,96 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from itertools import groupby +import numpy as np + +from datumaro.util.image import lazy_image, load_image + + +def generate_colormap(length=256): + def get_bit(number, index): + return (number >> index) & 1 + + colormap = np.zeros((length, 3), dtype=int) + indices = np.arange(length, dtype=int) + + for j in range(7, -1, -1): + for c in range(3): + colormap[:, c] |= get_bit(indices, c) << j + indices >>= 3 + + return { + id: tuple(color) for id, color in enumerate(colormap) + } + +def invert_colormap(colormap): + return { + tuple(a): index for index, a in colormap.items() + } + +_default_colormap = generate_colormap() +_default_unpaint_colormap = invert_colormap(_default_colormap) + +def _default_unpaint_colormap_fn(r, g, b): + return _default_unpaint_colormap[(r, g, b)] + +def unpaint_mask(painted_mask, colormap=None): + # expect HWC BGR [0; 255] image + # expect RGB->index colormap + assert len(painted_mask.shape) == 3 + if colormap is None: + colormap = _default_unpaint_colormap_fn + if callable(colormap): + map_fn = lambda a: colormap(int(a[2]), int(a[1]), int(a[0])) + else: + map_fn = lambda a: colormap[(int(a[2]), int(a[1]), int(a[0]))] + + unpainted_mask = np.apply_along_axis(map_fn, + 1, np.reshape(painted_mask, (-1, 3))) + unpainted_mask = np.reshape(unpainted_mask, (painted_mask.shape[:2])) + return unpainted_mask.astype(int) + + +def apply_colormap(mask, colormap=None): + # expect HW [0; max_index] mask + # expect index->RGB colormap + assert len(mask.shape) == 2 + + if colormap is None: + colormap = _default_colormap + if callable(colormap): + map_fn = lambda p: colormap(int(p[0]))[::-1] + else: + map_fn = lambda p: colormap[int(p[0])][::-1] + painted_mask = np.apply_along_axis(map_fn, 1, np.reshape(mask, (-1, 1))) + + painted_mask = np.reshape(painted_mask, (*mask.shape, 3)) + return painted_mask.astype(np.float32) + + +def load_mask(path, colormap=None): + mask = load_image(path) + if colormap is not None: + if len(mask.shape) == 3 and mask.shape[2] != 1: + mask = unpaint_mask(mask, colormap=colormap) + return mask + +def lazy_mask(path, colormap=None): + return lazy_image(path, lambda path: load_mask(path, colormap)) + + +def convert_mask_to_rle(binary_mask): + counts = [] + for i, (value, elements) in enumerate( + groupby(binary_mask.ravel(order='F'))): + # decoding starts from 0 + if i == 0 and value == 1: + counts.append(0) + counts.append(len(list(elements))) + + return { + 'counts': counts, + 'size': list(binary_mask.shape) + } \ No newline at end of file diff --git a/datumaro/datumaro/util/test_utils.py b/datumaro/datumaro/util/test_utils.py new file mode 100644 index 000000000000..9219f5cfa01b --- /dev/null +++ b/datumaro/datumaro/util/test_utils.py @@ -0,0 +1,39 @@ + +# Copyright (C) 2019 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import inspect +import os +import os.path as osp +import shutil + + +def current_function_name(depth=1): + return inspect.getouterframes(inspect.currentframe())[depth].function + +class FileRemover: + def __init__(self, path, is_dir=False, ignore_errors=False): + self.path = path + self.is_dir = is_dir + self.ignore_errors = ignore_errors + + def __enter__(self): + return self + + # pylint: disable=redefined-builtin + def __exit__(self, type=None, value=None, traceback=None): + if self.is_dir: + shutil.rmtree(self.path, ignore_errors=self.ignore_errors) + else: + os.remove(self.path) + # pylint: enable=redefined-builtin + +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 diff --git a/datumaro/docs/cli_design.mm b/datumaro/docs/cli_design.mm new file mode 100644 index 000000000000..4c7b188cf5fa --- /dev/null +++ b/datumaro/docs/cli_design.mm @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/datumaro/docs/design.md b/datumaro/docs/design.md new file mode 100644 index 000000000000..69d4d198816a --- /dev/null +++ b/datumaro/docs/design.md @@ -0,0 +1,228 @@ +# Datumaro + + + +## Table of contents + +- [Concept](#concept) +- [Design](#design) +- [RC 1 vision](#rc-1-vision) + +## Concept + +Datumaro is: +- a tool to build composite datasets and iterate over them +- a tool to create and maintain datasets + - Version control of annotations and images + - Publication (with removal of sensitive information) + - Editing + - Joining and splitting + - Exporting, format changing + - Image preprocessing +- a dataset storage +- a tool to debug datasets + - A network can be used to generate + informative data subsets (e.g. with false-positives) + to be analyzed further + +### Requirements + +- User interfaces + - a library + - a console tool with visualization means +- Targets: single datasets, composite datasets, single images / videos +- Built-in support for well-known annotation formats and datasets: + CVAT, COCO, PASCAL VOC, Cityscapes, ImageNet +- Extensibility with user-provided components +- Lightweightness - it should be easy to start working with Datumaro + - Minimal dependency on environment and configuration + - It should be easier to use Datumaro than writing own code + for computation of statistics or dataset manipulations + +### Functionality and ideas + +- Blur sensitive areas on dataset images +- Dataset annotation filters, relabelling etc. +- Dataset augmentation +- Calculation of statistics: + - Mean & std, custom stats +- "Edit" command to modify annotations +- Versioning (for images, annotations, subsets, sources etc., comparison) +- Documentation generation +- Provision of iterators for user code +- Dataset building (export in a specific format, indexation, statistics, documentation) +- Dataset exporting to other formats +- Dataset debugging (run inference, generate dataset slices, compute statistics) +- "Explainable AI" - highlight network attention areas ([paper](https://arxiv.org/abs/1901.04592)) + - Black-box approach + - Classification, Detection, Segmentation, Captioning + - White-box approach + +### Research topics + +- exploration of network prediction uncertainty (aka Bayessian approach) + Use case: explanation of network "quality", "stability", "certainty" +- adversarial attacks on networks +- dataset minification / reduction + Use case: removal of redundant information to reach the same network quality with lesser training time +- dataset expansion and filtration of additions + Use case: add only important data +- 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. +It should only consume data from CVAT. The collected dataset +can be downloaded by user to be operated on with Datumaro CLI. + + +``` + User + | + v + +------------------+ + | CVAT | + +--------v---------+ +------------------+ +--------------+ + | Datumaro module | ----> | Datumaro project | <---> | Datumaro CLI | <--- User + +------------------+ +------------------+ +--------------+ +``` + + +### Interfaces + +- [x] Python API for user code + - [ ] Installation as a package +- [x] A command-line tool for dataset manipulations + +### Features + +- Dataset format support (reading, exporting) + - [x] Own format + - [x] COCO + - [x] PASCAL VOC + - [ ] Cityscapes + - [ ] ImageNet + - [ ] CVAT + +- Dataset visualization (`show`) + - [ ] Ability to visualize a dataset + - [ ] with TensorBoard + +- Calculation of statistics for datasets + - [ ] Pixel mean, std + - [ ] Object counts (detection scenario) + - [ ] Image-Class distribution (classification scenario) + - [ ] Pixel-Class distribution (segmentation scenario) + - [ ] Image clusters + - [ ] Custom statistics + +- Dataset building + - [x] Composite dataset building + - [ ] Annotation remapping + - [ ] Subset splitting + - [x] Dataset filtering (`extract`) + - [x] Dataset merging (`merge`) + - [ ] Dataset item editing (`edit`) + +- Dataset comparison (`diff`) + - [x] Annotation-annotation comparison + - [x] Annotation-inference comparison + - [ ] Annotation quality estimation (for CVAT) + - Provide a simple method to check + annotation quality with a model and generate summary + +- Dataset and model debugging + - [x] Inference explanation (`explain`) + - [x] Black-box approach ([RISE paper](https://arxiv.org/abs/1806.07421)) + - [x] Ability to run a model on a dataset and read the results + +- CVAT-integration features + - [x] Task export + - [x] Datumaro project export + - [x] Dataset export + - [ ] Original raw data (images, a video file) can be downloaded (exported) + together with annotations or just have links + on CVAT server (in the future support S3, etc) + - [x] Be able to use local files instead of remote links + - [ ] Specify cache directory + - [x] Use case "annotate for model training" + - create a task + - annotate + - export the task + - convert to a training format + - train a DL model + - [ ] Use case "annotate and estimate quality" + - create a task + - annotate + - estimate quality of annotations + +### Optional features + +- Dataset publishing + - [ ] Versioning (for annotations, subsets, sources, etc.) + - [ ] Blur sensitive areas on images + - [ ] Tracking of legal information + - [ ] Documentation generation + +- Dataset building + - [ ] Dataset minification / Extraction of the most representative subset + - Use case: generate low-precision calibration dataset + +- Dataset and model debugging + - [ ] Training visualization + - [ ] Inference explanation (`explain`) + - [ ] White-box approach + +### Properties + +- Lightweightness +- Modularity +- Extensibility diff --git a/datumaro/docs/images/cli_design.png b/datumaro/docs/images/cli_design.png new file mode 100644 index 0000000000000000000000000000000000000000..702728c442a7938af24630916fcde41b14c66c33 GIT binary patch literal 93636 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 literal 0 HcmV?d00001 diff --git a/datumaro/docs/images/mvvm.png b/datumaro/docs/images/mvvm.png new file mode 100644 index 0000000000000000000000000000000000000000..88257123ac7db353ec1919203576b3b29d37ee91 GIT binary patch literal 30318 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 literal 0 HcmV?d00001 diff --git a/datumaro/docs/quickstart.md b/datumaro/docs/quickstart.md new file mode 100644 index 000000000000..d5fb98a6ab5a --- /dev/null +++ b/datumaro/docs/quickstart.md @@ -0,0 +1,325 @@ +# 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/requirements.txt b/datumaro/requirements.txt new file mode 100644 index 000000000000..652ec3eab366 --- /dev/null +++ b/datumaro/requirements.txt @@ -0,0 +1,11 @@ +Cython>=0.27.3 # include before pycocotools +GitPython>=2.1.11 +lxml>=4.4.1 +matplotlib<3.1 # 3.1+ requires python3.6, but we have 3.5 in cvat +opencv-python>=4.1.0.25 +Pillow>=6.1.0 +pycocotools>=2.0.0 +PyYAML>=5.1.1 +requests>=2.20.0 +tensorboard>=1.12.0 +tensorboardX>=1.8 diff --git a/datumaro/test.py b/datumaro/test.py new file mode 100644 index 000000000000..184bbff53262 --- /dev/null +++ b/datumaro/test.py @@ -0,0 +1,5 @@ +import unittest + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/datumaro/tests/__init__.py b/datumaro/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/datumaro/tests/test_RISE.py b/datumaro/tests/test_RISE.py new file mode 100644 index 000000000000..a7560f1ff0e3 --- /dev/null +++ b/datumaro/tests/test_RISE.py @@ -0,0 +1,230 @@ +from collections import namedtuple +import cv2 +import numpy as np + +from unittest import TestCase + +from datumaro.components.extractor import LabelObject, BboxObject +from datumaro.components.launcher import Launcher +from datumaro.components.algorithms.rise import RISE + + +class RiseTest(TestCase): + def test_rise_can_be_applied_to_classification_model(self): + class TestLauncher(Launcher): + def __init__(self, class_count, roi, **kwargs): + self.class_count = class_count + self.roi = roi + + def launch(self, inputs): + for inp in inputs: + yield self._process(inp) + + def _process(self, image): + roi = self.roi + roi_area = (roi[1] - roi[0]) * (roi[3] - roi[2]) + if 0.5 * roi_area < np.sum(image[roi[0]:roi[1], roi[2]:roi[3], 0]): + cls = 0 + else: + cls = 1 + + cls_conf = 0.5 + other_conf = (1.0 - cls_conf) / (self.class_count - 1) + + return [ + LabelObject(i, attributes={ + 'score': cls_conf if cls == i else other_conf }) \ + for i in range(self.class_count) + ] + + roi = [70, 90, 7, 90] + model = TestLauncher(class_count=3, roi=roi) + + rise = RISE(model, max_samples=(7 * 7) ** 2, mask_width=7, mask_height=7) + + image = np.ones((100, 100, 3)) + heatmaps = next(rise.apply(image)) + + self.assertEqual(1, len(heatmaps)) + + heatmap = heatmaps[0] + self.assertEqual(image.shape[:2], heatmap.shape) + + h_sum = np.sum(heatmap) + h_area = np.prod(heatmap.shape) + roi_sum = np.sum(heatmap[roi[0]:roi[1], roi[2]:roi[3]]) + roi_area = (roi[1] - roi[0]) * (roi[3] - roi[2]) + roi_den = roi_sum / roi_area + hrest_den = (h_sum - roi_sum) / (h_area - roi_area) + self.assertLess(hrest_den, roi_den) + + def test_rise_can_be_applied_to_detection_model(self): + ROI = namedtuple('ROI', + ['threshold', 'x', 'y', 'w', 'h', 'label']) + + class TestLauncher(Launcher): + def __init__(self, rois, class_count, fp_count=4, pixel_jitter=20, **kwargs): + self.rois = rois + self.roi_base_sums = [None, ] * len(rois) + self.class_count = class_count + self.fp_count = fp_count + self.pixel_jitter = pixel_jitter + + @staticmethod + def roi_value(roi, image): + return np.sum( + image[roi.y:roi.y + roi.h, roi.x:roi.x + roi.w, :]) + + def launch(self, inputs): + for inp in inputs: + yield self._process(inp) + + def _process(self, image): + detections = [] + for i, roi in enumerate(self.rois): + roi_sum = self.roi_value(roi, image) + roi_base_sum = self.roi_base_sums[i] + first_run = roi_base_sum is None + if first_run: + roi_base_sum = roi_sum + self.roi_base_sums[i] = roi_base_sum + + cls_conf = roi_sum / roi_base_sum + + if roi.threshold < roi_sum / roi_base_sum: + cls = roi.label + detections.append( + BboxObject(roi.x, roi.y, roi.w, roi.h, + label=cls, attributes={'score': cls_conf}) + ) + + if first_run: + continue + for j in range(self.fp_count): + if roi.threshold < cls_conf: + cls = roi.label + else: + cls = (i + j) % self.class_count + box = [roi.x, roi.y, roi.w, roi.h] + offset = (np.random.rand(4) - 0.5) * self.pixel_jitter + detections.append( + BboxObject(*(box + offset), + label=cls, attributes={'score': cls_conf}) + ) + + return detections + + rois = [ + ROI(0.3, 10, 40, 30, 10, 0), + ROI(0.5, 70, 90, 7, 10, 0), + ROI(0.7, 5, 20, 40, 60, 2), + ROI(0.9, 30, 20, 10, 40, 1), + ] + model = model = TestLauncher(class_count=3, rois=rois) + + rise = RISE(model, max_samples=(7 * 7) ** 2, mask_width=7, mask_height=7) + + image = np.ones((100, 100, 3)) + heatmaps = next(rise.apply(image)) + heatmaps_class_count = len(set([roi.label for roi in rois])) + self.assertEqual(heatmaps_class_count + len(rois), len(heatmaps)) + + # roi_image = image.copy() + # for i, roi in enumerate(rois): + # cv2.rectangle(roi_image, (roi.x, roi.y), (roi.x + roi.w, roi.y + roi.h), (32 * i) * 3) + # cv2.imshow('img', roi_image) + + for c in range(heatmaps_class_count): + class_roi = np.zeros(image.shape[:2]) + for i, roi in enumerate(rois): + if roi.label != c: + continue + class_roi[roi.y:roi.y + roi.h, roi.x:roi.x + roi.w] \ + += roi.threshold + + heatmap = heatmaps[c] + + roi_pixels = heatmap[class_roi != 0] + h_sum = np.sum(roi_pixels) + h_area = np.sum(roi_pixels != 0) + h_den = h_sum / h_area + + rest_pixels = heatmap[class_roi == 0] + r_sum = np.sum(rest_pixels) + r_area = np.sum(rest_pixels != 0) + r_den = r_sum / r_area + + # print(r_den, h_den) + # cv2.imshow('class %s' % c, heatmap) + self.assertLess(r_den, h_den) + + for i, roi in enumerate(rois): + heatmap = heatmaps[heatmaps_class_count + i] + h_sum = np.sum(heatmap) + h_area = np.prod(heatmap.shape) + roi_sum = np.sum(heatmap[roi.y:roi.y + roi.h, roi.x:roi.x + roi.w]) + roi_area = roi.h * roi.w + roi_den = roi_sum / roi_area + hrest_den = (h_sum - roi_sum) / (h_area - roi_area) + # print(hrest_den, h_den) + # cv2.imshow('roi %s' % i, heatmap) + self.assertLess(hrest_den, roi_den) + # cv2.waitKey(0) + + @staticmethod + def DISABLED_test_roi_nms(): + ROI = namedtuple('ROI', + ['conf', 'x', 'y', 'w', 'h', 'label']) + + class_count = 3 + noisy_count = 3 + rois = [ + ROI(0.3, 10, 40, 30, 10, 0), + ROI(0.5, 70, 90, 7, 10, 0), + ROI(0.7, 5, 20, 40, 60, 2), + ROI(0.9, 30, 20, 10, 40, 1), + ] + pixel_jitter = 10 + + detections = [] + for i, roi in enumerate(rois): + detections.append( + BboxObject(roi.x, roi.y, roi.w, roi.h, + label=roi.label, attributes={'score': roi.conf}) + ) + + for j in range(noisy_count): + cls_conf = roi.conf * j / noisy_count + cls = (i + j) % class_count + box = [roi.x, roi.y, roi.w, roi.h] + offset = (np.random.rand(4) - 0.5) * pixel_jitter + detections.append( + BboxObject(*(box + offset), + label=cls, attributes={'score': cls_conf}) + ) + + image = np.zeros((100, 100, 3)) + for i, det in enumerate(detections): + roi = ROI(det.attributes['score'], *det.get_bbox(), det.label) + p1 = (int(roi.x), int(roi.y)) + p2 = (int(roi.x + roi.w), int(roi.y + roi.h)) + c = (0, 1 * (i % (1 + noisy_count) == 0), 1) + cv2.rectangle(image, p1, p2, c) + cv2.putText(image, 'd%s-%s-%.2f' % (i, roi.label, roi.conf), + p1, cv2.FONT_HERSHEY_SIMPLEX, 0.25, c) + cv2.imshow('nms_image', image) + cv2.waitKey(0) + + nms_boxes = RISE.nms(detections, iou_thresh=0.25) + print(len(detections), len(nms_boxes)) + + for i, det in enumerate(nms_boxes): + roi = ROI(det.attributes['score'], *det.get_bbox(), det.label) + p1 = (int(roi.x), int(roi.y)) + p2 = (int(roi.x + roi.w), int(roi.y + roi.h)) + c = (0, 1, 0) + cv2.rectangle(image, p1, p2, c) + cv2.putText(image, 'p%s-%s-%.2f' % (i, roi.label, roi.conf), + p1, cv2.FONT_HERSHEY_SIMPLEX, 0.25, c) + cv2.imshow('nms_image', image) + cv2.waitKey(0) \ No newline at end of file diff --git a/datumaro/tests/test_coco_format.py b/datumaro/tests/test_coco_format.py new file mode 100644 index 000000000000..1d42bc77ce79 --- /dev/null +++ b/datumaro/tests/test_coco_format.py @@ -0,0 +1,389 @@ +import json +import numpy as np +import os +import os.path as osp +from PIL import Image + +from unittest import TestCase + +from datumaro.components.project import Project +from datumaro.components.extractor import ( + DEFAULT_SUBSET_NAME, + Extractor, DatasetItem, + AnnotationType, LabelObject, MaskObject, PointsObject, PolygonObject, + BboxObject, CaptionObject, + LabelCategories, PointsCategories +) +from datumaro.components.converters.ms_coco import ( + CocoConverter, + CocoImageInfoConverter, + CocoCaptionsConverter, + CocoInstancesConverter, + CocoPersonKeypointsConverter, + CocoLabelsConverter, +) +from datumaro.util import find +from datumaro.util.test_utils import TestDir + + +class CocoImporterTest(TestCase): + @staticmethod + def generate_annotation(): + annotation = { + 'licenses': [], + 'info': {}, + 'categories': [], + 'images': [], + 'annotations': [] + } + annotation['licenses'].append({ + 'name': '', + 'id': 0, + 'url': '' + }) + annotation['info'] = { + 'contributor': '', + 'date_created': '', + 'description': '', + 'url': '', + 'version': '', + 'year': '' + } + annotation['licenses'].append({ + 'name': '', + 'id': 0, + 'url': '' + }) + annotation['categories'].append({'id': 0, 'name': 'TEST', 'supercategory': ''}) + annotation['images'].append({ + "id": 0, + "width": 10, + "height": 5, + "file_name": '000000000001.jpg', + "license": 0, + "flickr_url": '', + "coco_url": '', + "date_captured": 0 + }) + annotation['annotations'].append({ + "id": 0, + "image_id": 0, + "category_id": 0, + "segmentation": [[0, 0, 1, 0, 1, 2, 0, 2]], + "area": 2, + "bbox": [0, 0, 1, 2], + "iscrowd": 0 + }) + annotation['annotations'].append({ + "id": 1, + "image_id": 0, + "category_id": 0, + "segmentation": { + "counts": [ + 0, 10, + 5, 5, + 5, 5, + 0, 10, + 10, 0], + "size": [10, 5]}, + "area": 30, + "bbox": [0, 0, 10, 4], + "iscrowd": 0 + }) + return annotation + + def COCO_dataset_generate(self, path): + img_dir = osp.join(path, 'images', 'val') + ann_dir = osp.join(path, 'annotations') + os.makedirs(img_dir) + os.makedirs(ann_dir) + a = np.random.rand(100, 100, 3) * 255 + im_out = Image.fromarray(a.astype('uint8')).convert('RGB') + im_out.save(osp.join(img_dir, '000000000001.jpg')) + annotation = self.generate_annotation() + with open(osp.join(ann_dir, 'instances_val.json'), 'w') as outfile: + json.dump(annotation, outfile) + + 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') + dataset = project.make_dataset() + + self.assertListEqual(['val'], sorted(dataset.subsets())) + self.assertEqual(1, len(dataset)) + + item = next(iter(dataset)) + self.assertTrue(item.has_image) + self.assertEqual(5, len(item.annotations)) + + ann_0 = find(item.annotations, lambda x: x.id == 0) + ann_0_poly = find(item.annotations, lambda x: \ + x.group == ann_0.id and x.type == AnnotationType.polygon) + ann_0_mask = find(item.annotations, lambda x: \ + x.group == ann_0.id and x.type == AnnotationType.mask) + self.assertFalse(ann_0 is None) + self.assertFalse(ann_0_poly is None) + self.assertFalse(ann_0_mask is None) + + ann_1 = find(item.annotations, lambda x: x.id == 1) + ann_1_mask = find(item.annotations, lambda x: \ + x.group == ann_1.id and x.type == AnnotationType.mask) + self.assertFalse(ann_1 is None) + self.assertFalse(ann_1_mask is None) + +class CocoConverterTest(TestCase): + def _test_save_and_load(self, source_dataset, converter_type, test_dir): + converter = converter_type() + converter(source_dataset, test_dir.path) + + project = Project.import_from(test_dir.path, 'ms_coco') + parsed_dataset = project.make_dataset() + + source_subsets = [s if s else DEFAULT_SUBSET_NAME + for s in source_dataset.subsets()] + self.assertListEqual( + sorted(source_subsets), + sorted(parsed_dataset.subsets()), + ) + + self.assertEqual(len(source_dataset), len(parsed_dataset)) + + for item_a in source_dataset: + item_b = find(parsed_dataset, lambda x: x.id == item_a.id) + self.assertFalse(item_b is None) + self.assertEqual(len(item_a.annotations), len(item_b.annotations)) + for ann_a in item_a.annotations: + ann_b = find(item_b.annotations, lambda x: \ + x.id == ann_a.id if ann_a.id else \ + x.type == ann_a.type and x.group == ann_a.group) + self.assertEqual(ann_a, ann_b) + + def test_can_save_and_load_captions(self): + class TestExtractor(Extractor): + def __iter__(self): + items = [ + DatasetItem(id=0, subset='train', + annotations=[ + CaptionObject('hello', id=1), + CaptionObject('world', id=2), + ]), + DatasetItem(id=1, subset='train', + annotations=[ + CaptionObject('test', id=3), + ]), + + DatasetItem(id=2, subset='val', + annotations=[ + CaptionObject('word', id=1), + ] + ), + ] + return iter(items) + + def subsets(self): + return ['train', 'val'] + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + CocoCaptionsConverter, test_dir) + + def test_can_save_and_load_instances(self): + class TestExtractor(Extractor): + def __iter__(self): + items = [ + DatasetItem(id=0, subset='train', image=np.ones((4, 4, 3)), + annotations=[ + BboxObject(0, 1, 2, 3, label=2, group=1, + attributes={ 'is_crowd': False }, id=1), + PolygonObject([0, 1, 2, 1, 2, 3, 0, 3], + label=2, group=1), + MaskObject(np.array([[0, 0, 0, 0], [1, 1, 0, 0], + [1, 1, 0, 0], [0, 0, 0, 0]], + # does not include lower row + dtype=np.bool), + label=2, group=1), + ]), + DatasetItem(id=1, subset='train', + annotations=[ + BboxObject(0, 1, 3, 3, label=4, group=3, + attributes={ 'is_crowd': True }, id=3), + MaskObject(np.array([[0, 0, 0, 0], [1, 0, 1, 0], + [1, 1, 0, 0], [0, 0, 1, 0]], + dtype=np.bool), + label=4, group=3), + ]), + + DatasetItem(id=2, subset='val', + annotations=[ + BboxObject(0, 1, 3, 2, label=4, group=3, + attributes={ 'is_crowd': True }, id=3), + MaskObject(np.array([[0, 0, 0, 0], [1, 0, 1, 0], + [1, 1, 0, 0], [0, 0, 0, 0]], + dtype=np.bool), + label=4, group=3), + ]), + ] + return iter(items) + + def subsets(self): + return ['train', 'val'] + + def categories(self): + label_categories = LabelCategories() + for i in range(10): + label_categories.add(str(i)) + return { + AnnotationType.label: label_categories, + } + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + CocoInstancesConverter, test_dir) + + def test_can_save_and_load_images(self): + class TestExtractor(Extractor): + def __iter__(self): + items = [ + DatasetItem(id=0, subset='train'), + DatasetItem(id=1, subset='train'), + + DatasetItem(id=2, subset='val'), + DatasetItem(id=3, subset='val'), + DatasetItem(id=4, subset='val'), + + DatasetItem(id=5, subset='test'), + ] + return iter(items) + + def subsets(self): + return ['train', 'val', 'test'] + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + CocoImageInfoConverter, test_dir) + + def test_can_save_and_load_labels(self): + class TestExtractor(Extractor): + def __iter__(self): + items = [ + DatasetItem(id=0, subset='train', + annotations=[ + LabelObject(4, id=1), + LabelObject(9, id=2), + ]), + DatasetItem(id=1, subset='train', + annotations=[ + LabelObject(4, id=4), + ]), + + DatasetItem(id=2, subset='val', + annotations=[ + LabelObject(2, id=1), + ]), + ] + return iter(items) + + def subsets(self): + return ['train', 'val'] + + def categories(self): + label_categories = LabelCategories() + for i in range(10): + label_categories.add(str(i)) + return { + AnnotationType.label: label_categories, + } + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + CocoLabelsConverter, test_dir) + + def test_can_save_and_load_keypoints(self): + class TestExtractor(Extractor): + def __iter__(self): + items = [ + DatasetItem(id=0, subset='train', + annotations=[ + PointsObject([1, 2, 0, 2, 4, 1], [0, 1, 2], + label=3, group=1, id=1), + BboxObject(1, 2, 3, 4, label=3, group=1), + PointsObject([5, 6, 0, 7], group=2, id=2), + BboxObject(1, 2, 3, 4, group=2), + ]), + DatasetItem(id=1, subset='train', + annotations=[ + PointsObject([1, 2, 0, 2, 4, 1], label=5, + group=3, id=3), + BboxObject(1, 2, 3, 4, label=5, group=3), + ]), + + DatasetItem(id=2, subset='val', + annotations=[ + PointsObject([0, 2, 0, 2, 4, 1], label=2, + group=3, id=3), + BboxObject(0, 2, 4, 4, label=2, group=3), + ]), + ] + return iter(items) + + def subsets(self): + return ['train', 'val'] + + def categories(self): + label_categories = LabelCategories() + points_categories = PointsCategories() + for i in range(10): + label_categories.add(str(i)) + points_categories.add(i, []) + + return { + AnnotationType.label: label_categories, + AnnotationType.points: points_categories, + } + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + CocoPersonKeypointsConverter, test_dir) + + def test_can_save_dataset_with_no_subsets(self): + class TestExtractor(Extractor): + def __iter__(self): + items = [ + DatasetItem(id=1, annotations=[ + LabelObject(2, id=1), + ]), + + DatasetItem(id=2, image=np.zeros((5, 5, 3)), annotations=[ + LabelObject(3, id=3), + BboxObject(0, 0, 5, 5, label=3, + attributes={ 'is_crowd': False }, id=4, group=4), + PolygonObject([0, 0, 4, 0, 4, 4], + label=3, group=4), + MaskObject(np.array([ + [0, 1, 1, 1, 0], + [0, 0, 1, 1, 0], + [0, 0, 0, 1, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0]], + # only internal fragment (without the border), + # but not everywhere... + dtype=np.bool), + label=3, group=4), + ]), + ] + + for item in items: + yield item + + def categories(self): + label_cat = LabelCategories() + for label in range(10): + label_cat.add('label_' + str(label)) + return { + AnnotationType.label: label_cat, + } + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + CocoConverter, test_dir) \ No newline at end of file diff --git a/datumaro/tests/test_command_targets.py b/datumaro/tests/test_command_targets.py new file mode 100644 index 000000000000..d029b92397a8 --- /dev/null +++ b/datumaro/tests/test_command_targets.py @@ -0,0 +1,131 @@ +import cv2 +import numpy as np +import os.path as osp + +from unittest import TestCase + +from datumaro.components.project import Project +from datumaro.util.command_targets import ProjectTarget, \ + ImageTarget, SourceTarget +from datumaro.util.test_utils import current_function_name, TestDir + + +class CommandTargetsTest(TestCase): + def test_image_false_when_no_file(self): + path = '%s.jpg' % current_function_name() + target = ImageTarget() + + status = target.test(path) + + self.assertFalse(status) + + def test_image_false_when_false(self): + with TestDir() as test_dir: + path = osp.join(test_dir.path, 'test.jpg') + with open(path, 'w+') as f: + f.write('qwerty123') + + target = ImageTarget() + + status = target.test(path) + + self.assertFalse(status) + + def test_image_true_when_true(self): + with TestDir() as test_dir: + path = osp.join(test_dir.path, 'test.jpg') + image = np.random.random_sample([10, 10, 3]) + cv2.imwrite(path, image) + + target = ImageTarget() + + status = target.test(path) + + self.assertTrue(status) + + def test_project_false_when_no_file(self): + path = '%s.jpg' % current_function_name() + target = ProjectTarget() + + status = target.test(path) + + self.assertFalse(status) + + def test_project_false_when_no_name(self): + target = ProjectTarget(project=Project()) + + status = target.test('') + + self.assertFalse(status) + + def test_project_true_when_project_file(self): + with TestDir() as test_dir: + path = osp.join(test_dir.path, 'test.jpg') + Project().save(path) + + target = ProjectTarget() + + status = target.test(path) + + self.assertTrue(status) + + def test_project_true_when_project_name(self): + project_name = 'qwerty' + project = Project({ + 'project_name': project_name + }) + target = ProjectTarget(project=project) + + status = target.test(project_name) + + self.assertTrue(status) + + def test_project_false_when_not_project_name(self): + project_name = 'qwerty' + project = Project({ + 'project_name': project_name + }) + target = ProjectTarget(project=project) + + status = target.test(project_name + '123') + + self.assertFalse(status) + + def test_project_true_when_not_project_file(self): + with TestDir() as test_dir: + path = osp.join(test_dir.path, 'test.jpg') + with open(path, 'w+') as f: + f.write('wqererw') + + target = ProjectTarget() + + status = target.test(path) + + self.assertFalse(status) + + def test_source_false_when_no_project(self): + target = SourceTarget() + + status = target.test('qwerty123') + + self.assertFalse(status) + + def test_source_true_when_source_exists(self): + source_name = 'qwerty' + project = Project() + project.add_source(source_name) + target = SourceTarget(project=project) + + status = target.test(source_name) + + self.assertTrue(status) + + def test_source_false_when_source_doesnt_exist(self): + source_name = 'qwerty' + project = Project() + project.add_source(source_name) + target = SourceTarget(project=project) + + status = target.test(source_name + '123') + + self.assertFalse(status) \ No newline at end of file diff --git a/datumaro/tests/test_datumaro_format.py b/datumaro/tests/test_datumaro_format.py new file mode 100644 index 000000000000..63907be6f449 --- /dev/null +++ b/datumaro/tests/test_datumaro_format.py @@ -0,0 +1,101 @@ +from itertools import zip_longest +import numpy as np + +from unittest import TestCase + +from datumaro.components.project import Project +from datumaro.components.extractor import (Extractor, DatasetItem, + AnnotationType, LabelObject, MaskObject, PointsObject, PolygonObject, + PolyLineObject, BboxObject, CaptionObject, + LabelCategories, MaskCategories, PointsCategories +) +from datumaro.components.converters.datumaro import DatumaroConverter +from datumaro.util.test_utils import TestDir +from datumaro.util.mask_tools import generate_colormap + + +class DatumaroConverterTest(TestCase): + class TestExtractor(Extractor): + def __iter__(self): + items = [ + DatasetItem(id=100, subset='train', + annotations=[ + CaptionObject('hello', id=1), + CaptionObject('world', id=2, group=5), + LabelObject(2, id=3, attributes={ + 'x': 1, + 'y': '2', + }), + BboxObject(1, 2, 3, 4, label=4, id=4, attributes={ + 'score': 10.0, + }), + BboxObject(5, 6, 7, 8, id=5, group=5), + PointsObject([1, 2, 2, 0, 1, 1], label=0, id=5), + MaskObject(label=3, id=5, image=np.ones((2, 3))), + ]), + DatasetItem(id=21, subset='train', + annotations=[ + CaptionObject('test'), + LabelObject(2), + BboxObject(1, 2, 3, 4, 5, id=42, group=42) + ]), + + DatasetItem(id=2, subset='val', + annotations=[ + PolyLineObject([1, 2, 3, 4, 5, 6, 7, 8], id=11), + PolygonObject([1, 2, 3, 4, 5, 6, 7, 8], id=12), + ]), + + DatasetItem(id=42, subset='test'), + ] + return iter(items) + + def subsets(self): + return ['train', 'val', 'test'] + + def categories(self): + label_categories = LabelCategories() + for i in range(5): + label_categories.add('cat' + str(i)) + + mask_categories = MaskCategories( + generate_colormap(len(label_categories.items))) + + points_categories = PointsCategories() + for index, _ in enumerate(label_categories.items): + points_categories.add(index, ['cat1', 'cat2'], adjacent=[0, 1]) + + return { + AnnotationType.label: label_categories, + AnnotationType.mask: mask_categories, + AnnotationType.points: points_categories, + } + + def test_can_save_and_load(self): + with TestDir() as test_dir: + source_dataset = self.TestExtractor() + + converter = DatumaroConverter( + save_images=True, apply_colormap=True) + converter(source_dataset, test_dir.path) + + project = Project.import_from(test_dir.path, 'datumaro') + 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) + for idx, (item_a, item_b) in enumerate( + zip_longest(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_diff.py b/datumaro/tests/test_diff.py new file mode 100644 index 000000000000..5f0655f1228f --- /dev/null +++ b/datumaro/tests/test_diff.py @@ -0,0 +1,142 @@ +from unittest import TestCase + +from datumaro.components.extractor import DatasetItem, LabelObject, BboxObject +from datumaro.components.comparator import Comparator + + +class DiffTest(TestCase): + def test_no_bbox_diff_with_same_item(self): + detections = 3 + anns = [ + BboxObject(i * 10, 10, 10, 10, label=i, + attributes={'score': (1.0 + i) / detections}) \ + for i in range(detections) + ] + item = DatasetItem(id=0, annotations=anns) + + iou_thresh = 0.5 + conf_thresh = 0.5 + comp = Comparator( + iou_threshold=iou_thresh, conf_threshold=conf_thresh) + + result = comp.compare_item_bboxes(item, item) + + matches, mispred, a_greater, b_greater = result + self.assertEqual(0, len(mispred)) + self.assertEqual(0, len(a_greater)) + self.assertEqual(0, len(b_greater)) + self.assertEqual(len([it for it in item.annotations \ + if conf_thresh < it.attributes['score']]), + len(matches)) + for a_bbox, b_bbox in matches: + self.assertLess(iou_thresh, a_bbox.iou(b_bbox)) + self.assertEqual(a_bbox.label, b_bbox.label) + self.assertLess(conf_thresh, a_bbox.attributes['score']) + self.assertLess(conf_thresh, b_bbox.attributes['score']) + + def test_can_find_bbox_with_wrong_label(self): + detections = 3 + class_count = 2 + item1 = DatasetItem(id=1, annotations=[ + BboxObject(i * 10, 10, 10, 10, label=i, + attributes={'score': (1.0 + i) / detections}) \ + for i in range(detections) + ]) + item2 = DatasetItem(id=2, annotations=[ + BboxObject(i * 10, 10, 10, 10, label=(i + 1) % class_count, + attributes={'score': (1.0 + i) / detections}) \ + for i in range(detections) + ]) + + iou_thresh = 0.5 + conf_thresh = 0.5 + comp = Comparator( + iou_threshold=iou_thresh, conf_threshold=conf_thresh) + + result = comp.compare_item_bboxes(item1, item2) + + matches, mispred, a_greater, b_greater = result + self.assertEqual(len([it for it in item1.annotations \ + if conf_thresh < it.attributes['score']]), + len(mispred)) + self.assertEqual(0, len(a_greater)) + self.assertEqual(0, len(b_greater)) + self.assertEqual(0, len(matches)) + for a_bbox, b_bbox in mispred: + self.assertLess(iou_thresh, a_bbox.iou(b_bbox)) + self.assertEqual((a_bbox.label + 1) % class_count, b_bbox.label) + self.assertLess(conf_thresh, a_bbox.attributes['score']) + self.assertLess(conf_thresh, b_bbox.attributes['score']) + + def test_can_find_missing_boxes(self): + detections = 3 + class_count = 2 + item1 = DatasetItem(id=1, annotations=[ + BboxObject(i * 10, 10, 10, 10, label=i, + attributes={'score': (1.0 + i) / detections}) \ + for i in range(detections) if i % 2 == 0 + ]) + item2 = DatasetItem(id=2, annotations=[ + BboxObject(i * 10, 10, 10, 10, label=(i + 1) % class_count, + attributes={'score': (1.0 + i) / detections}) \ + for i in range(detections) if i % 2 == 1 + ]) + + iou_thresh = 0.5 + conf_thresh = 0.5 + comp = Comparator( + iou_threshold=iou_thresh, conf_threshold=conf_thresh) + + result = comp.compare_item_bboxes(item1, item2) + + matches, mispred, a_greater, b_greater = result + self.assertEqual(0, len(mispred)) + self.assertEqual(len([it for it in item1.annotations \ + if conf_thresh < it.attributes['score']]), + len(a_greater)) + self.assertEqual(len([it for it in item2.annotations \ + if conf_thresh < it.attributes['score']]), + len(b_greater)) + self.assertEqual(0, len(matches)) + + def test_no_label_diff_with_same_item(self): + detections = 3 + anns = [ + LabelObject(i, attributes={'score': (1.0 + i) / detections}) \ + for i in range(detections) + ] + item = DatasetItem(id=1, annotations=anns) + + conf_thresh = 0.5 + comp = Comparator(conf_threshold=conf_thresh) + + result = comp.compare_item_labels(item, item) + + matches, a_greater, b_greater = result + self.assertEqual(0, len(a_greater)) + self.assertEqual(0, len(b_greater)) + self.assertEqual(len([it for it in item.annotations \ + if conf_thresh < it.attributes['score']]), + len(matches)) + + def test_can_find_wrong_label(self): + item1 = DatasetItem(id=1, annotations=[ + LabelObject(0), + LabelObject(1), + LabelObject(2), + ]) + item2 = DatasetItem(id=2, annotations=[ + LabelObject(2), + LabelObject(3), + LabelObject(4), + ]) + + conf_thresh = 0.5 + comp = Comparator(conf_threshold=conf_thresh) + + result = comp.compare_item_labels(item1, item2) + + matches, a_greater, b_greater = result + self.assertEqual(2, len(a_greater)) + self.assertEqual(2, len(b_greater)) + self.assertEqual(1, len(matches)) \ No newline at end of file diff --git a/datumaro/tests/test_project.py b/datumaro/tests/test_project.py new file mode 100644 index 000000000000..1d9df96f14ab --- /dev/null +++ b/datumaro/tests/test_project.py @@ -0,0 +1,450 @@ +import os +import os.path as osp + +from unittest import TestCase + +from datumaro.components.project import Project, Environment +from datumaro.components.project import Source, Model +from datumaro.components.launcher import Launcher, InferenceWrapper +from datumaro.components.converter import Converter +from datumaro.components.extractor import Extractor, DatasetItem, LabelObject +from datumaro.components.config import Config, DefaultConfig, SchemaBuilder +from datumaro.components.dataset_filter import XPathDatasetFilter +from datumaro.util.test_utils import TestDir + + +class ProjectTest(TestCase): + def test_project_generate(self): + src_config = Config({ + 'project_name': 'test_project', + 'format_version': 1, + }) + + with TestDir() as test_dir: + project_path = test_dir.path + Project.generate(project_path, src_config) + + self.assertTrue(osp.isdir(project_path)) + + result_config = Project.load(project_path).config + self.assertEqual( + src_config.project_name, result_config.project_name) + self.assertEqual( + src_config.format_version, result_config.format_version) + + @staticmethod + def test_default_ctor_is_ok(): + Project() + + @staticmethod + def test_empty_config_is_ok(): + Project(Config()) + + def test_add_source(self): + source_name = 'source' + origin = Source({ + 'url': 'path', + 'format': 'ext' + }) + project = Project() + + project.add_source(source_name, origin) + + added = project.get_source(source_name) + self.assertIsNotNone(added) + self.assertEqual(added, origin) + + def test_added_source_can_be_saved(self): + source_name = 'source' + origin = Source({ + 'url': 'path', + }) + project = Project() + project.add_source(source_name, origin) + + saved = project.config + + self.assertEqual(origin, saved.sources[source_name]) + + def test_added_source_can_be_dumped(self): + source_name = 'source' + origin = Source({ + 'url': 'path', + }) + project = Project() + project.add_source(source_name, origin) + + with TestDir() as test_dir: + project.save(test_dir.path) + + loaded = Project.load(test_dir.path) + loaded = loaded.get_source(source_name) + self.assertEqual(origin, loaded) + + def test_can_import_with_custom_importer(self): + class TestImporter: + def __call__(self, path, subset=None): + return Project({ + 'project_filename': path, + 'subsets': [ subset ] + }) + + path = 'path' + importer_name = 'test_importer' + + env = Environment() + env.importers.register(importer_name, TestImporter) + + project = Project.import_from(path, importer_name, env, + subset='train') + + self.assertEqual(path, project.config.project_filename) + self.assertListEqual(['train'], project.config.subsets) + + def test_can_dump_added_model(self): + model_name = 'model' + + project = Project() + saved = Model({ 'launcher': 'name' }) + project.add_model(model_name, saved) + + with TestDir() as test_dir: + project.save(test_dir.path) + + loaded = Project.load(test_dir.path) + loaded = loaded.get_model(model_name) + self.assertEqual(saved, loaded) + + def test_can_have_project_source(self): + with TestDir() as test_dir: + Project.generate(test_dir.path) + + project2 = Project() + project2.add_source('project1', { + 'url': test_dir.path, + }) + dataset = project2.make_dataset() + + self.assertTrue('project1' in dataset.sources) + + def test_can_batch_launch_custom_model(self): + class TestExtractor(Extractor): + def __init__(self, url, n=0): + super().__init__(length=n) + self.n = n + + def __iter__(self): + for i in range(self.n): + yield DatasetItem(id=i, subset='train', image=i) + + def subsets(self): + return ['train'] + + class TestLauncher(Launcher): + def __init__(self, **kwargs): + pass + + def launch(self, inputs): + for i, inp in enumerate(inputs): + yield [ LabelObject(attributes={'idx': i, 'data': inp}) ] + + model_name = 'model' + launcher_name = 'custom_launcher' + + project = Project() + project.env.launchers.register(launcher_name, TestLauncher) + project.add_model(model_name, { 'launcher': launcher_name }) + model = project.make_executable_model(model_name) + extractor = TestExtractor('', n=5) + + batch_size = 3 + executor = InferenceWrapper(extractor, model, batch_size=batch_size) + + for item in executor: + self.assertEqual(1, len(item.annotations)) + self.assertEqual(int(item.id) % batch_size, + item.annotations[0].attributes['idx']) + self.assertEqual(int(item.id), + item.annotations[0].attributes['data']) + + def test_can_do_transform_with_custom_model(self): + class TestExtractorSrc(Extractor): + def __init__(self, url, n=2): + super().__init__(length=n) + self.n = n + + def __iter__(self): + for i in range(self.n): + yield DatasetItem(id=i, subset='train', image=i, + annotations=[ LabelObject(i) ]) + + def subsets(self): + return ['train'] + + class TestLauncher(Launcher): + def __init__(self, **kwargs): + pass + + def launch(self, inputs): + for inp in inputs: + yield [ LabelObject(inp) ] + + class TestConverter(Converter): + def __call__(self, extractor, save_dir): + for item in extractor: + with open(osp.join(save_dir, '%s.txt' % item.id), 'w+') as f: + f.write(str(item.subset) + '\n') + f.write(str(item.annotations[0].label) + '\n') + + class TestExtractorDst(Extractor): + def __init__(self, url): + super().__init__() + self.items = [osp.join(url, p) for p in sorted(os.listdir(url))] + + def __iter__(self): + for path in self.items: + with open(path, 'r') as f: + index = osp.splitext(osp.basename(path))[0] + subset = f.readline()[:-1] + label = int(f.readline()[:-1]) + assert(subset == 'train') + yield DatasetItem(id=index, subset=subset, + annotations=[ LabelObject(label) ]) + + def __len__(self): + return len(self.items) + + def subsets(self): + return ['train'] + + + model_name = 'model' + launcher_name = 'custom_launcher' + extractor_name = 'custom_extractor' + + project = Project() + project.env.launchers.register(launcher_name, TestLauncher) + project.env.extractors.register(extractor_name, TestExtractorSrc) + project.env.converters.register(extractor_name, TestConverter) + project.add_model(model_name, { 'launcher': launcher_name }) + project.add_source('source', { 'format': extractor_name }) + + with TestDir() as test_dir: + project.make_dataset().transform(model_name, test_dir.path) + + result = Project.load(test_dir.path) + result.env.extractors.register(extractor_name, TestExtractorDst) + it = iter(result.make_dataset()) + item1 = next(it) + item2 = next(it) + self.assertEqual(0, item1.annotations[0].label) + self.assertEqual(1, item2.annotations[0].label) + + def test_source_datasets_can_be_merged(self): + class TestExtractor(Extractor): + def __init__(self, url, n=0, s=0): + super().__init__(length=n) + self.n = n + self.s = s + + def __iter__(self): + for i in range(self.n): + yield DatasetItem(id=self.s + i, subset='train') + + def subsets(self): + return ['train'] + + e_name1 = 'e1' + e_name2 = 'e2' + n1 = 2 + n2 = 4 + + project = Project() + project.env.extractors.register(e_name1, lambda p: TestExtractor(p, n=n1)) + project.env.extractors.register(e_name2, lambda p: TestExtractor(p, n=n2, s=n1)) + project.add_source('source1', { 'format': e_name1 }) + project.add_source('source2', { 'format': e_name2 }) + + dataset = project.make_dataset() + + self.assertEqual(n1 + n2, len(dataset)) + + def test_project_filter_can_be_applied(self): + class TestExtractor(Extractor): + def __init__(self, url, n=10): + super().__init__(length=n) + self.n = n + + def __iter__(self): + for i in range(self.n): + yield DatasetItem(id=i, subset='train') + + def subsets(self): + return ['train'] + + e_type = 'type' + project = Project() + project.env.extractors.register(e_type, TestExtractor) + project.add_source('source', { 'format': e_type }) + project.set_filter('/item[id < 5]') + + dataset = project.make_dataset() + + self.assertEqual(5, len(dataset)) + + def test_project_own_dataset_can_be_modified(self): + project = Project() + dataset = project.make_dataset() + + item = DatasetItem(id=1) + dataset.put(item) + + self.assertEqual(item, next(iter(dataset))) + + def test_project_compound_child_can_be_modified_recursively(self): + with TestDir() as test_dir: + child1 = Project({ + 'project_dir': osp.join(test_dir.path, 'child1'), + }) + child1.save() + + child2 = Project({ + 'project_dir': osp.join(test_dir.path, 'child2'), + }) + child2.save() + + parent = Project() + parent.add_source('child1', { + 'url': child1.config.project_dir + }) + parent.add_source('child2', { + 'url': child2.config.project_dir + }) + dataset = parent.make_dataset() + + item1 = DatasetItem(id='ch1', path=['child1']) + item2 = DatasetItem(id='ch2', path=['child2']) + dataset.put(item1) + dataset.put(item2) + + self.assertEqual(2, len(dataset)) + self.assertEqual(1, len(dataset.sources['child1'])) + self.assertEqual(1, len(dataset.sources['child2'])) + + def test_project_can_merge_item_annotations(self): + class TestExtractor(Extractor): + def __init__(self, url, v=None): + super().__init__() + self.v = v + + def __iter__(self): + v1_item = DatasetItem(id=1, subset='train', annotations=[ + LabelObject(2, id=3), + LabelObject(3, attributes={ 'x': 1 }), + ]) + + v2_item = DatasetItem(id=1, subset='train', annotations=[ + LabelObject(3, attributes={ 'x': 1 }), + LabelObject(4, id=4), + ]) + + if self.v == 1: + yield v1_item + else: + yield v2_item + + def subsets(self): + return ['train'] + + project = Project() + project.env.extractors.register('t1', lambda p: TestExtractor(p, v=1)) + project.env.extractors.register('t2', lambda p: TestExtractor(p, v=2)) + project.add_source('source1', { 'format': 't1' }) + project.add_source('source2', { 'format': 't2' }) + + merged = project.make_dataset() + + self.assertEqual(1, len(merged)) + + item = next(iter(merged)) + self.assertEqual(3, len(item.annotations)) + +class DatasetFilterTest(TestCase): + class TestExtractor(Extractor): + def __init__(self, url, n=0): + super().__init__(length=n) + self.n = n + + def __iter__(self): + for i in range(self.n): + yield DatasetItem(id=i, subset='train') + + def subsets(self): + return ['train'] + + def test_xpathfilter_can_be_applied(self): + extractor = self.TestExtractor('', n=4) + dataset_filter = XPathDatasetFilter('/item[id > 1]') + + filtered = extractor.select(dataset_filter) + + self.assertEqual(2, len(filtered)) + +class ConfigTest(TestCase): + def test_can_produce_multilayer_config_from_dict(self): + schema_low = SchemaBuilder() \ + .add('options', dict) \ + .build() + schema_mid = SchemaBuilder() \ + .add('desc', lambda: Config(schema=schema_low)) \ + .build() + schema_top = SchemaBuilder() \ + .add('container', lambda: DefaultConfig( + lambda v: Config(v, schema=schema_mid))) \ + .build() + + value = 1 + source = Config({ + 'container': { + 'elem': { + 'desc': { + 'options': { + 'k': value + } + } + } + } + }, schema=schema_top) + + self.assertEqual(value, source.container['elem'].desc.options['k']) + +class ExtractorTest(TestCase): + def test_custom_extractor_can_be_created(self): + class CustomExtractor(Extractor): + def __init__(self, url): + super().__init__() + + def __iter__(self): + return iter([ + DatasetItem(id=0, subset='train'), + DatasetItem(id=1, subset='train'), + DatasetItem(id=2, subset='train'), + + DatasetItem(id=3, subset='test'), + ]) + + def subsets(self): + return ['train', 'test'] + + extractor_name = 'ext1' + project = Project() + project.env.extractors.register(extractor_name, CustomExtractor) + project.add_source('src1', { + 'url': 'path', + 'format': extractor_name, + }) + project.set_subsets(['train']) + + dataset = project.make_dataset() + + self.assertEqual(3, len(dataset)) diff --git a/datumaro/tests/test_voc_format.py b/datumaro/tests/test_voc_format.py new file mode 100644 index 000000000000..841ce7264afb --- /dev/null +++ b/datumaro/tests/test_voc_format.py @@ -0,0 +1,487 @@ +import cv2 +from itertools import zip_longest +import numpy as np +import os +import os.path as osp +from xml.etree import ElementTree as ET +import shutil + +from unittest import TestCase + +from datumaro.components.extractor import (Extractor, DatasetItem, + AnnotationType, BboxObject, LabelCategories, +) +import datumaro.components.formats.voc as VOC +from datumaro.components.extractors.voc import ( + VocClassificationExtractor, + VocDetectionExtractor, + VocSegmentationExtractor, + VocLayoutExtractor, + VocActionExtractor, +) +from datumaro.components.converters.voc import ( + VocConverter, + VocClassificationConverter, + VocDetectionConverter, + VocLayoutConverter, + VocActionConverter, + VocSegmentationConverter, +) +from datumaro.components.importers.voc import VocImporter +from datumaro.util import find +from datumaro.util.test_utils import TestDir + + +class VocTest(TestCase): + def test_colormap_generator(self): + reference = [ + [ 0, 0, 0], + [128, 0, 0], + [ 0, 128, 0], + [128, 128, 0], + [ 0, 0, 128], + [128, 0, 128], + [ 0, 128, 128], + [128, 128, 128], + [ 64, 0, 0], + [192, 0, 0], + [ 64, 128, 0], + [192, 128, 0], + [ 64, 0, 128], + [192, 0, 128], + [ 64, 128, 128], + [192, 128, 128], + [ 0, 64, 0], + [128, 64, 0], + [ 0, 192, 0], + [128, 192, 0], + ] + + self.assertTrue(np.array_equal(reference, list(VOC.VocColormap.values()))) + +def get_label(extractor, label_id): + return extractor.categories()[AnnotationType.label].items[label_id].name + +def generate_dummy_voc(path): + cls_subsets_dir = osp.join(path, 'ImageSets', 'Main') + action_subsets_dir = osp.join(path, 'ImageSets', 'Action') + layout_subsets_dir = osp.join(path, 'ImageSets', 'Layout') + segm_subsets_dir = osp.join(path, 'ImageSets', 'Segmentation') + ann_dir = osp.join(path, 'Annotations') + img_dir = osp.join(path, 'JPEGImages') + segm_dir = osp.join(path, 'SegmentationClass') + inst_dir = osp.join(path, 'SegmentationObject') + + os.makedirs(cls_subsets_dir) + os.makedirs(ann_dir) + os.makedirs(img_dir) + os.makedirs(segm_dir) + os.makedirs(inst_dir) + + subsets = { + 'train': ['2007_000001'], + 'test': ['2007_000002'], + } + + # Subsets + for subset_name, subset in subsets.items(): + for item in subset: + with open(osp.join(cls_subsets_dir, subset_name + '.txt'), 'w') as f: + for item in subset: + f.write('%s\n' % item) + shutil.copytree(cls_subsets_dir, action_subsets_dir) + shutil.copytree(cls_subsets_dir, layout_subsets_dir) + shutil.copytree(cls_subsets_dir, segm_subsets_dir) + + # Classification + subset_name = 'train' + subset = subsets[subset_name] + for label in VOC.VocLabel: + with open(osp.join(cls_subsets_dir, '%s_%s.txt' % \ + (label.name, subset_name)), 'w') as f: + for item in subset: + presence = label.value % 2 + f.write('%s %2d\n' % (item, 1 if presence else -1)) + + # Detection + Action + Layout + subset_name = 'train' + subset = subsets[subset_name] + for item in subset: + root_elem = ET.Element('annotation') + ET.SubElement(root_elem, 'folder').text = 'VOC' + item.split('_')[0] + ET.SubElement(root_elem, 'filename').text = item + '.jpg' + + size_elem = ET.SubElement(root_elem, 'size') + ET.SubElement(size_elem, 'width').text = '10' + ET.SubElement(size_elem, 'height').text = '20' + ET.SubElement(size_elem, 'depth').text = '3' + + ET.SubElement(root_elem, 'segmented').text = '1' + + obj1_elem = ET.SubElement(root_elem, 'object') + ET.SubElement(obj1_elem, 'name').text = VOC.VocLabel(1).name + ET.SubElement(obj1_elem, 'pose').text = VOC.VocPose(1).name + ET.SubElement(obj1_elem, 'truncated').text = '1' + ET.SubElement(obj1_elem, 'difficult').text = '0' + obj1bb_elem = ET.SubElement(obj1_elem, 'bndbox') + ET.SubElement(obj1bb_elem, 'xmin').text = '1' + ET.SubElement(obj1bb_elem, 'ymin').text = '2' + ET.SubElement(obj1bb_elem, 'xmax').text = '3' + ET.SubElement(obj1bb_elem, 'ymax').text = '4' + + obj2_elem = ET.SubElement(root_elem, 'object') + ET.SubElement(obj2_elem, 'name').text = VOC.VocLabel.person.name + obj2bb_elem = ET.SubElement(obj2_elem, 'bndbox') + ET.SubElement(obj2bb_elem, 'xmin').text = '4' + ET.SubElement(obj2bb_elem, 'ymin').text = '5' + ET.SubElement(obj2bb_elem, 'xmax').text = '6' + ET.SubElement(obj2bb_elem, 'ymax').text = '7' + 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, 'ymin').text = '6' + ET.SubElement(obj2headbb_elem, 'xmax').text = '7' + ET.SubElement(obj2headbb_elem, 'ymax').text = '8' + obj2act_elem = ET.SubElement(obj2_elem, 'actions') + for act in VOC.VocAction: + ET.SubElement(obj2act_elem, act.name).text = '%s' % (act.value % 2) + + with open(osp.join(ann_dir, item + '.xml'), 'w') as f: + f.write(ET.tostring(root_elem, encoding='unicode')) + + # Segmentation + Instances + subset_name = 'train' + subset = subsets[subset_name] + for item in subset: + cv2.imwrite(osp.join(segm_dir, item + '.png'), + np.ones([10, 20, 3]) * VOC.VocColormap[2]) + cv2.imwrite(osp.join(inst_dir, item + '.png'), + np.ones([10, 20, 3]) * VOC.VocColormap[2]) + + # Test images + subset_name = 'test' + subset = subsets[subset_name] + for item in subset: + cv2.imwrite(osp.join(img_dir, item + '.jpg'), + np.ones([10, 20, 3])) + + return subsets + +class VocExtractorTest(TestCase): + def test_can_load_voc_cls(self): + with TestDir() as test_dir: + generated_subsets = generate_dummy_voc(test_dir.path) + + extractor = VocClassificationExtractor(test_dir.path) + + self.assertEqual(len(generated_subsets), len(extractor.subsets())) + + subset_name = 'train' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + count = 0 + for label in VOC.VocLabel: + if label.value % 2 == 1: + count += 1 + ann = find(item.annotations, + lambda x: x.type == AnnotationType.label and \ + x.label == label.value) + self.assertFalse(ann is None) + self.assertEqual(count, len(item.annotations)) + + subset_name = 'test' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + self.assertEqual(0, len(item.annotations)) + + def test_can_load_voc_det(self): + with TestDir() as test_dir: + generated_subsets = generate_dummy_voc(test_dir.path) + + extractor = VocDetectionExtractor(test_dir.path) + + self.assertEqual(len(generated_subsets), len(extractor.subsets())) + + subset_name = 'train' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + obj1 = find(item.annotations, + lambda x: x.type == AnnotationType.bbox and \ + get_label(extractor, x.label) == VOC.VocLabel(1).name) + self.assertFalse(obj1 is None) + self.assertListEqual([1, 2, 2, 2], obj1.get_bbox()) + self.assertDictEqual( + { + 'pose': VOC.VocPose(1).name, + 'truncated': True, + 'difficult': False, + }, + obj1.attributes) + + obj2 = find(item.annotations, + lambda x: x.type == AnnotationType.bbox and \ + get_label(extractor, x.label) == VOC.VocLabel.person.name) + self.assertFalse(obj2 is None) + self.assertListEqual([4, 5, 2, 2], obj2.get_bbox()) + + self.assertEqual(2, len(item.annotations)) + + subset_name = 'test' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + self.assertEqual(0, len(item.annotations)) + + def test_can_load_voc_segm(self): + with TestDir() as test_dir: + generated_subsets = generate_dummy_voc(test_dir.path) + + extractor = VocSegmentationExtractor(test_dir.path) + + self.assertEqual(len(generated_subsets), len(extractor.subsets())) + + subset_name = 'train' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + cls_mask = find(item.annotations, + lambda x: x.type == AnnotationType.mask and \ + x.attributes.get('class') == True) + self.assertFalse(cls_mask is None) + self.assertFalse(cls_mask.image is None) + + inst_mask = find(item.annotations, + lambda x: x.type == AnnotationType.mask and \ + x.attributes.get('instances') == True) + self.assertFalse(inst_mask is None) + self.assertFalse(inst_mask.image is None) + + self.assertEqual(2, len(item.annotations)) + + subset_name = 'test' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + self.assertEqual(0, len(item.annotations)) + + def test_can_load_voc_layout(self): + with TestDir() as test_dir: + generated_subsets = generate_dummy_voc(test_dir.path) + + extractor = VocLayoutExtractor(test_dir.path) + + self.assertEqual(len(generated_subsets), len(extractor.subsets())) + + subset_name = 'train' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + obj2 = find(item.annotations, + lambda x: x.type == AnnotationType.bbox and \ + get_label(extractor, x.label) == VOC.VocLabel.person.name) + self.assertFalse(obj2 is None) + self.assertListEqual([4, 5, 2, 2], obj2.get_bbox()) + + obj2head = find(item.annotations, + 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.assertEqual(2, len(item.annotations)) + + subset_name = 'test' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + self.assertEqual(0, len(item.annotations)) + + def test_can_load_voc_action(self): + with TestDir() as test_dir: + generated_subsets = generate_dummy_voc(test_dir.path) + + extractor = VocActionExtractor(test_dir.path) + + self.assertEqual(len(generated_subsets), len(extractor.subsets())) + + subset_name = 'train' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + obj2 = find(item.annotations, + lambda x: x.type == AnnotationType.bbox and \ + get_label(extractor, x.label) == VOC.VocLabel.person.name) + self.assertFalse(obj2 is None) + self.assertListEqual([4, 5, 2, 2], obj2.get_bbox()) + + count = 1 + for action in VOC.VocAction: + if action.value % 2 == 1: + count += 1 + ann = find(item.annotations, + lambda x: x.type == AnnotationType.label and \ + get_label(extractor, x.label) == action.name) + self.assertFalse(ann is None) + self.assertTrue(obj2.id == ann.group) + self.assertEqual(count, len(item.annotations)) + + subset_name = 'test' + generated_subset = generated_subsets[subset_name] + for id_ in generated_subset: + parsed_subset = extractor.get_subset(subset_name) + self.assertEqual(len(generated_subset), len(parsed_subset)) + + item = find(parsed_subset, lambda x: x.id == id_) + self.assertFalse(item is None) + + self.assertEqual(0, len(item.annotations)) + +class VocConverterTest(TestCase): + def _test_can_save_voc(self, extractor_type, converter_type, test_dir): + dummy_dir = osp.join(test_dir, 'dummy') + generate_dummy_voc(dummy_dir) + gen_extractor = extractor_type(dummy_dir) + + conv_dir = osp.join(test_dir, 'converted') + converter = converter_type() + converter(gen_extractor, conv_dir) + + conv_extractor = extractor_type(conv_dir) + for item_a, item_b in zip_longest(gen_extractor, conv_extractor): + self.assertEqual(item_a.id, item_b.id) + self.assertEqual(len(item_a.annotations), len(item_b.annotations)) + for ann_a, ann_b in zip(item_a.annotations, item_b.annotations): + self.assertEqual(ann_a.type, ann_b.type) + + def test_can_save_voc_cls(self): + with TestDir() as test_dir: + self._test_can_save_voc( + VocClassificationExtractor, VocClassificationConverter, + test_dir.path) + + def test_can_save_voc_det(self): + with TestDir() as test_dir: + self._test_can_save_voc( + VocDetectionExtractor, VocDetectionConverter, + test_dir.path) + + def test_can_save_voc_segm(self): + with TestDir() as test_dir: + self._test_can_save_voc( + VocSegmentationExtractor, VocSegmentationConverter, + test_dir.path) + + def test_can_save_voc_layout(self): + with TestDir() as test_dir: + self._test_can_save_voc( + VocLayoutExtractor, VocLayoutConverter, + test_dir.path) + + def test_can_save_voc_action(self): + with TestDir() as test_dir: + self._test_can_save_voc( + VocActionExtractor, VocActionConverter, + test_dir.path) + + def test_can_save_dataset_with_no_subsets(self): + class TestExtractor(Extractor): + def __iter__(self): + items = [ + DatasetItem(id=1, annotations=[ + BboxObject(2, 3, 4, 5, label=2, id=1), + BboxObject(2, 3, 4, 5, label=3, id=2), + ]), + + DatasetItem(id=2, annotations=[ + BboxObject(5, 4, 6, 5, label=3, id=1), + ]), + ] + + for item in items: + yield item + + def categories(self): + label_cat = LabelCategories() + for label in VOC.VocLabel: + label_cat.add(label.name) + return { + AnnotationType.label: label_cat, + } + + with TestDir() as test_dir: + src_extractor = TestExtractor() + converter = VocConverter() + + converter(src_extractor, test_dir.path) + + dst_extractor = VocImporter()(test_dir.path).make_dataset() + + self.assertEqual(len(src_extractor), len(dst_extractor)) + for item_a, item_b in zip_longest(src_extractor, dst_extractor): + self.assertEqual(item_a.id, item_b.id) + self.assertEqual(len(item_a.annotations), len(item_b.annotations)) + for ann_a, ann_b in zip(item_a.annotations, item_b.annotations): + self.assertEqual(ann_a.type, ann_b.type) + +class VocImporterTest(TestCase): + def test_can_import(self): + with TestDir() as test_dir: + dummy_dir = osp.join(test_dir.path, 'dummy') + subsets = generate_dummy_voc(dummy_dir) + + dataset = VocImporter()(dummy_dir).make_dataset() + + self.assertEqual(len(VOC.VocTask), len(dataset.sources)) + self.assertEqual(set(subsets), set(dataset.subsets())) + self.assertEqual( + sum([len(s) for _, s in subsets.items()]), + len(dataset)) \ No newline at end of file diff --git a/supervisord.conf b/supervisord.conf index 554ae6fa7fb7..d5e222d2a99b 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -41,6 +41,12 @@ command=%(ENV_HOME)s/wait-for-it.sh redis:6379 -t 0 -- bash -ic \ environment=SSH_AUTH_SOCK="/tmp/ssh-agent.sock" numprocs=1 +[program:rqscheduler] +command=%(ENV_HOME)s/wait-for-it.sh redis:6379 -t 0 -- bash -ic \ + "/usr/bin/python3 /usr/local/bin/rqscheduler --host redis -i 30" +environment=SSH_AUTH_SOCK="/tmp/ssh-agent.sock" +numprocs=1 + [program:runserver] ; Here need to run a couple of commands to initialize DB and copy static files. ; We cannot initialize DB on build because the DB should be online. Also some