From fb380d985528409483cfa25550126f9c50a65de4 Mon Sep 17 00:00:00 2001 From: zhiltsov-max Date: Sun, 17 May 2020 08:35:39 +0300 Subject: [PATCH] Include empty images in exported annotations (#1479) --- CHANGELOG.md | 3 +- cvat/apps/dataset_manager/bindings.py | 2 +- cvat/apps/dataset_manager/formats/cvat.py | 2 +- cvat/apps/dataset_manager/formats/mot.py | 1 + .../dataset_manager/tests/_test_formats.py | 192 +++++++++++------- datumaro/datumaro/components/project.py | 2 +- .../datumaro/plugins/yolo_format/extractor.py | 44 ++-- 7 files changed, 150 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f341fba1..ed2f3801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Downloaded file name in annotations export became more informative (https://github.com/opencv/cvat/pull/1352) -- Added auto trimming for trailing whitespaces style enforsement (https://github.com/opencv/cvat/pull/1352) +- Added auto trimming for trailing whitespaces style enforcement (https://github.com/opencv/cvat/pull/1352) - REST API: updated `GET /task//annotations`: parameters are `format`, `filename` (now optional), `action` (optional) (https://github.com/opencv/cvat/pull/1352) - REST API: removed `dataset/formats`, changed format of `annotation/formats` (https://github.com/opencv/cvat/pull/1352) - Exported annotations are stored for N hours instead of indefinitely (https://github.com/opencv/cvat/pull/1352) @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Formats: most of formats renamed, no extension in title (https://github.com/opencv/cvat/pull/1352) - Formats: definitions are changed, are not stored in DB anymore (https://github.com/opencv/cvat/pull/1352) - cvat-core: session.annotations.put() now returns identificators of added objects (https://github.com/opencv/cvat/pull/1493) +- Images without annotations now also included in dataset/annotations export (https://github.com/opencv/cvat/issues/525) ### Deprecated - diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index b598cac3..04a4be04 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -413,7 +413,7 @@ class CvatTaskDataExtractor(datumaro.SourceExtractor): if include_images: frame_provider = FrameProvider(task_data.db_task.data) - for frame_data in task_data.group_by_frame(include_empty=include_images): + for frame_data in task_data.group_by_frame(include_empty=True): loader = None if include_images: loader = lambda p, i=frame_data.idx: frame_provider.get_frame(i, diff --git a/cvat/apps/dataset_manager/formats/cvat.py b/cvat/apps/dataset_manager/formats/cvat.py index e2fa3d80..42e0d6cc 100644 --- a/cvat/apps/dataset_manager/formats/cvat.py +++ b/cvat/apps/dataset_manager/formats/cvat.py @@ -175,7 +175,7 @@ def dump_as_cvat_annotation(file_object, annotations): dumper.open_root() dumper.add_meta(annotations.meta) - for frame_annotation in annotations.group_by_frame(): + for frame_annotation in annotations.group_by_frame(include_empty=True): frame_id = frame_annotation.frame dumper.open_image(OrderedDict([ ("id", str(frame_id)), diff --git a/cvat/apps/dataset_manager/formats/mot.py b/cvat/apps/dataset_manager/formats/mot.py index e8b2ea82..4731284a 100644 --- a/cvat/apps/dataset_manager/formats/mot.py +++ b/cvat/apps/dataset_manager/formats/mot.py @@ -39,6 +39,7 @@ def _import(src_file, task_data): label_cat = dataset.categories()[datumaro.AnnotationType.label] for item in dataset: + item = item.wrap(id=int(item.id) - 1) # NOTE: MOT frames start from 1 frame_id = match_frame(item, task_data) for ann in item.annotations: diff --git a/cvat/apps/dataset_manager/tests/_test_formats.py b/cvat/apps/dataset_manager/tests/_test_formats.py index 1a001a12..bfea13af 100644 --- a/cvat/apps/dataset_manager/tests/_test_formats.py +++ b/cvat/apps/dataset_manager/tests/_test_formats.py @@ -51,6 +51,9 @@ def _setUpModule(): import cvat.apps.dataset_manager as dm globals()['dm'] = dm + import datumaro + globals()['datumaro'] = datumaro + import sys sys.path.insert(0, __file__[:__file__.rfind('/dataset_manager/')]) @@ -61,6 +64,7 @@ from io import BytesIO import os.path as osp import random import tempfile +import zipfile from PIL import Image from django.contrib.auth.models import User, Group @@ -113,38 +117,7 @@ class TaskExportTest(APITestCase): def setUpTestData(cls): create_db_users(cls) - def _generate_task(self): - task = { - "name": "my task #1", - "owner": '', - "assignee": '', - "overlap": 0, - "segment_size": 100, - "z_order": False, - "labels": [ - { - "name": "car", - "attributes": [ - { - "name": "model", - "mutable": False, - "input_type": "select", - "default_value": "mazda", - "values": ["bmw", "mazda", "renault"] - }, - { - "name": "parked", - "mutable": True, - "input_type": "checkbox", - "default_value": False - }, - ] - }, - {"name": "person"}, - ] - } - task = self._create_task(task, 3) - + def _generate_annotations(self, task): annotations = { "version": 0, "tags": [ @@ -256,8 +229,39 @@ class TaskExportTest(APITestCase): ] } self._put_api_v1_task_id_annotations(task["id"], annotations) + return annotations - return task, annotations + def _generate_task(self): + task = { + "name": "my task #1", + "owner": '', + "assignee": '', + "overlap": 0, + "segment_size": 100, + "z_order": False, + "labels": [ + { + "name": "car", + "attributes": [ + { + "name": "model", + "mutable": False, + "input_type": "select", + "default_value": "mazda", + "values": ["bmw", "mazda", "renault"] + }, + { + "name": "parked", + "mutable": True, + "input_type": "checkbox", + "default_value": False + }, + ] + }, + {"name": "person"}, + ] + } + return self._create_task(task, 3) def _create_task(self, data, size): with ForceLogin(self.user, self.client): @@ -285,53 +289,99 @@ class TaskExportTest(APITestCase): return response - def _test_export(self, format_name, save_images=False): - task, _ = self._generate_task() - + def _test_export(self, check, task, format_name, **export_args): with tempfile.TemporaryDirectory() as temp_dir: file_path = osp.join(temp_dir, format_name) dm.task.export_task(task["id"], file_path, - format_name, save_images=save_images) - - with open(file_path, 'rb') as f: - self.assertTrue(len(f.read()) != 0) - - def test_datumaro(self): - self._test_export('Datumaro 1.0', save_images=False) - - def test_coco(self): - self._test_export('COCO 1.0', save_images=True) - - def test_voc(self): - self._test_export('PASCAL VOC 1.1', save_images=True) - - def test_tf_record(self): - self._test_export('TFRecord 1.0', save_images=True) + format_name, **export_args) - def test_yolo(self): - self._test_export('YOLO 1.1', save_images=True) - - def test_mot(self): - self._test_export('MOT 1.1', save_images=True) - - def test_labelme(self): - self._test_export('LabelMe 3.0', save_images=True) - - def test_mask(self): - self._test_export('Segmentation mask 1.1', save_images=True) - - def test_cvat_video(self): - self._test_export('CVAT for video 1.1', save_images=True) - - def test_cvat_images(self): - self._test_export('CVAT for images 1.1', save_images=True) + check(file_path) def test_export_formats_query(self): formats = dm.views.get_export_formats() - self.assertEqual(len(formats), 10) + self.assertEqual({f.DISPLAY_NAME for f in formats}, + { + 'COCO 1.0', + 'CVAT for images 1.1', + 'CVAT for video 1.1', + 'Datumaro 1.0', + 'LabelMe 3.0', + 'MOT 1.1', + 'PASCAL VOC 1.1', + 'Segmentation mask 1.1', + 'TFRecord 1.0', + 'YOLO 1.1', + }) def test_import_formats_query(self): formats = dm.views.get_import_formats() - self.assertEqual(len(formats), 8) + self.assertEqual({f.DISPLAY_NAME for f in formats}, + { + 'COCO 1.0', + 'CVAT 1.1', + 'LabelMe 3.0', + 'MOT 1.1', + 'PASCAL VOC 1.1', + 'Segmentation mask 1.1', + 'TFRecord 1.0', + 'YOLO 1.1', + }) + + def test_exports(self): + def check(file_path): + with open(file_path, 'rb') as f: + self.assertTrue(len(f.read()) != 0) + + for f in dm.views.get_export_formats(): + format_name = f.DISPLAY_NAME + for save_images in { True, False }: + with self.subTest(format=format_name, save_images=save_images): + task = self._generate_task() + self._generate_annotations(task) + self._test_export(check, task, + format_name, save_images=save_images) + + def test_empty_images_are_exported(self): + dm_env = dm.formats.registry.dm_env + + for format_name, importer_name in [ + ('COCO 1.0', 'coco'), + ('CVAT for images 1.1', 'cvat'), + # ('CVAT for video 1.1', 'cvat'), # does not support + ('Datumaro 1.0', 'datumaro_project'), + ('LabelMe 3.0', 'label_me'), + # ('MOT 1.1', 'mot_seq'), # does not support + ('PASCAL VOC 1.1', 'voc'), + ('Segmentation mask 1.1', 'voc'), + ('TFRecord 1.0', 'tf_detection_api'), + ('YOLO 1.1', 'yolo'), + ]: + with self.subTest(format=format_name): + task = self._generate_task() + + def check(file_path): + def load_dataset(src): + if importer_name == 'datumaro_project': + project = datumaro.components.project. \ + Project.load(src) + + # NOTE: can't import cvat.utils.cli + # for whatever reason, so remove the dependency + project.config.remove('sources') + + return project.make_dataset() + return dm_env.make_importer(importer_name)(src) \ + .make_dataset() + + if zipfile.is_zipfile(file_path): + with tempfile.TemporaryDirectory() as tmp_dir: + zipfile.ZipFile(file_path).extractall(tmp_dir) + dataset = load_dataset(tmp_dir) + else: + dataset = load_dataset(file_path) + + self.assertEqual(len(dataset), task["size"]) + self._test_export(check, task, format_name, save_images=False) + diff --git a/datumaro/datumaro/components/project.py b/datumaro/datumaro/components/project.py index 84d282c9..d4468edd 100644 --- a/datumaro/datumaro/components/project.py +++ b/datumaro/datumaro/components/project.py @@ -142,7 +142,7 @@ def load_project_as_dataset(url): class Environment: _builtin_plugins = None - PROJECT_EXTRACTOR_NAME = 'project' + PROJECT_EXTRACTOR_NAME = 'datumaro_project' def __init__(self, config=None): config = Config(config, diff --git a/datumaro/datumaro/plugins/yolo_format/extractor.py b/datumaro/datumaro/plugins/yolo_format/extractor.py index 11e829d4..5e2c61b3 100644 --- a/datumaro/datumaro/plugins/yolo_format/extractor.py +++ b/datumaro/datumaro/plugins/yolo_format/extractor.py @@ -93,12 +93,6 @@ class YoloExtractor(SourceExtractor): (osp.splitext(osp.basename(p.strip()))[0], p.strip()) for p in f ) - - for item_id, image_path in subset.items.items(): - image_path = self._make_local_path(image_path) - if not osp.isfile(image_path) and item_id not in image_info: - raise Exception("Can't find image '%s'" % item_id) - subsets[subset_name] = subset self._subsets = subsets @@ -122,10 +116,9 @@ class YoloExtractor(SourceExtractor): image_path = self._make_local_path(item) image_size = self._image_info.get(item_id) image = Image(path=image_path, size=image_size) - h, w = image.size anno_path = osp.splitext(image_path)[0] + '.txt' - annotations = self._parse_annotations(anno_path, w, h) + annotations = self._parse_annotations(anno_path, image) item = DatasetItem(id=item_id, subset=subset_name, image=image, annotations=annotations) @@ -134,21 +127,30 @@ class YoloExtractor(SourceExtractor): return item @staticmethod - def _parse_annotations(anno_path, image_width, image_height): + def _parse_annotations(anno_path, image): + lines = [] with open(anno_path, 'r') as f: - annotations = [] for line in f: - label_id, xc, yc, w, h = line.strip().split() - label_id = int(label_id) - w = float(w) - h = float(h) - x = float(xc) - w * 0.5 - y = float(yc) - h * 0.5 - annotations.append(Bbox( - round(x * image_width, 1), round(y * image_height, 1), - round(w * image_width, 1), round(h * image_height, 1), - label=label_id - )) + line = line.strip() + if line: + lines.append(line) + + annotations = [] + if lines: + image_height, image_width = image.size # use image info late + for line in lines: + label_id, xc, yc, w, h = line.split() + label_id = int(label_id) + w = float(w) + h = float(h) + x = float(xc) - w * 0.5 + y = float(yc) - h * 0.5 + annotations.append(Bbox( + round(x * image_width, 1), round(y * image_height, 1), + round(w * image_width, 1), round(h * image_height, 1), + label=label_id + )) + return annotations @staticmethod