Repository: openvinotoolkit/datumaro

Issue:
Import for SYNTHIA dataset format
We need to support SYNTHIA dataset format. It is a segmentation task.
This format has several types of annotations:

- bounding boxes;
- masks;

SYNTHIA format specification is available [here](http://synthia-dataset.net/).


Discussion:

Resolving patch:
diff --git a/datumaro/plugins/synthia_format.py b/datumaro/plugins/synthia_format.py
new file mode 100644
--- /dev/null
+++ b/datumaro/plugins/synthia_format.py
@@ -0,0 +1,180 @@
+# Copyright (C) 2021 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+from collections import OrderedDict
+import os.path as osp
+
+import numpy as np
+
+from datumaro.components.annotation import (
+    AnnotationType, LabelCategories, Mask, MaskCategories,
+)
+from datumaro.components.extractor import DatasetItem, Importer, SourceExtractor
+from datumaro.components.format_detection import FormatDetectionContext
+from datumaro.util.image import find_images, load_image
+from datumaro.util.mask_tools import generate_colormap, lazy_mask
+
+
+class SynthiaPath:
+    IMAGES_DIR = 'RGB'
+    LABELS_SEGM_DIR = 'GT/LABELS'
+    SEMANTIC_SEGM_DIR = 'GT/COLOR'
+    LABELMAP_FILE = 'label_colors.txt'
+
+SYNTHIA_LABEL_MAP = OrderedDict([
+    ('Void', (0, 0, 0)),
+    ('Sky', (128, 128, 128)),
+    ('Building', (128, 0, 0)),
+    ('Road', (128, 64, 128)),
+    ('Sidewalk', (0, 0, 192)),
+    ('Fence', (64, 64, 128)),
+    ('Vegetation', (128, 128, 0)),
+    ('Pole', (192, 192, 128)),
+    ('Car', (64, 0, 128)),
+    ('TrafficSign', (192, 128, 128)),
+    ('Pedestrian', (64, 64, 0)),
+    ('Bicycle', (0, 128, 192)),
+    ('Lanemarking', (0, 172, 0)),
+    ('Reserved_1', (0, 0, 0)),
+    ('Reserved_2', (0, 0, 0)),
+    ('TrafficLight', (0, 128, 128)),
+])
+
+def make_categories(label_map=None):
+    if label_map is None:
+        label_map = SYNTHIA_LABEL_MAP
+
+    categories = {}
+    label_categories = LabelCategories()
+    for label in label_map:
+        label_categories.add(label)
+    categories[AnnotationType.label] = label_categories
+
+    has_colors = any(v is not None for v in label_map.values())
+    if not has_colors: # generate new colors
+        colormap = generate_colormap(len(label_map))
+    else: # only copy defined colors
+        colormap = { label_id: (desc[0], desc[1], desc[2])
+            for label_id, desc in enumerate(label_map.values()) }
+    mask_categories = MaskCategories(colormap)
+    mask_categories.inverse_colormap # pylint: disable=pointless-statement
+    categories[AnnotationType.mask] = mask_categories
+    return categories
+
+def parse_label_map(path):
+    label_map = OrderedDict()
+    with open(path, 'r', encoding='utf-8') as f:
+        for line in f:
+            # skip empty and commented lines
+            line = line.strip()
+            if not line or line[0] == '#':
+                continue
+
+            # color, name
+            label_desc = line.split()
+
+            if 2 < len(label_desc):
+                name = label_desc[3]
+                color = tuple([int(c) for c in label_desc[:3]])
+            else:
+                name = label_desc[0]
+                color = None
+
+            if name in label_map:
+                raise ValueError("Label '%s' is already defined" % name)
+
+            label_map[name] = color
+    return label_map
+
+class SynthiaExtractor(SourceExtractor):
+    def __init__(self, path):
+        if not osp.isdir(path):
+            raise FileNotFoundError("Can't read dataset directory '%s'" % path)
+
+        super().__init__()
+
+        self._categories = self._load_categories(path)
+        self._items = list(self._load_items(path).values())
+
+    def _load_categories(self, path):
+        label_map_path = osp.join(path, SynthiaPath.LABELMAP_FILE)
+        if osp.isfile(label_map_path):
+            label_map = parse_label_map(label_map_path)
+        else:
+            label_map = SYNTHIA_LABEL_MAP
+        return make_categories(label_map)
+
+    def _load_items(self, root_dir):
+        image_dir = osp.join(root_dir, SynthiaPath.IMAGES_DIR)
+        if osp.isdir(image_dir):
+            images = {
+                osp.splitext(osp.relpath(p, image_dir))[0].replace('\\', '/'): p
+                for p in find_images(image_dir, recursive=True)
+            }
+        else:
+            images = {}
+
+        items = {}
+
+        inst_dir = osp.join(root_dir, SynthiaPath.LABELS_SEGM_DIR)
+        if osp.isdir(inst_dir):
+            gt_images = find_images(inst_dir, recursive=True)
+            for gt_img in gt_images:
+                item_id = osp.splitext(osp.relpath(gt_img, inst_dir))[0].replace('\\', '/')
+
+                anno = []
+                labels_mask = load_image(gt_img, dtype=np.uint16)
+                dynamic_objects = np.unique(labels_mask[:,:,1])
+                labels_mask = labels_mask[:,:,2]
+                segm_ids = np.unique(labels_mask)
+                for segm_id in segm_ids:
+                    attr = { 'dynamic_object': False }
+                    if segm_id != 0 and segm_id in dynamic_objects:
+                        attr['dynamic_object'] = True
+                    anno.append(Mask(
+                        image=self._lazy_extract_mask(labels_mask, segm_id),
+                        label=segm_id, attributes=attr))
+
+                items[item_id] = DatasetItem(id=item_id, image=images[item_id],
+                    annotations=anno)
+
+        elif osp.isdir(osp.join(root_dir, SynthiaPath.SEMANTIC_SEGM_DIR)):
+            gt_dir = osp.join(root_dir, SynthiaPath.SEMANTIC_SEGM_DIR)
+            gt_images = find_images(gt_dir, recursive=True)
+            for gt_img in gt_images:
+                item_id = osp.splitext(osp.relpath(gt_img, gt_dir))[0].replace('\\', '/')
+
+                anno = []
+                inverse_cls_colormap = \
+                    self._categories[AnnotationType.mask].inverse_colormap
+                color_mask = lazy_mask(gt_img, inverse_cls_colormap)
+                color_mask = color_mask()
+                classes = np.unique(color_mask)
+                for label_id in classes:
+                    anno.append(Mask(image=self._lazy_extract_mask(color_mask, label_id),
+                        label=label_id))
+
+                items[item_id] = DatasetItem(id=item_id, image=images[item_id],
+                    annotations=anno)
+
+
+        return items
+
+    @staticmethod
+    def _lazy_extract_mask(mask, c):
+        return lambda: mask == c
+
+class SynthiaImporter(Importer):
+    @classmethod
+    def detect(cls, context: FormatDetectionContext) -> None:
+        with context.require_any():
+            for prefix in (
+                SynthiaPath.IMAGES_DIR, SynthiaPath.LABELS_SEGM_DIR, SynthiaPath.SEMANTIC_SEGM_DIR
+            ):
+                with context.alternative():
+                    context.require_file(f'{prefix}/**/*.png')
+
+    @classmethod
+    def find_sources(cls, path):
+        return [{'url': path, 'format': 'synthia'}]


Test patch:
diff --git a/tests/assets/synthia_dataset/color_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000000.png b/tests/assets/synthia_dataset/color_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/color_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000000.png differ
diff --git a/tests/assets/synthia_dataset/color_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000001.png b/tests/assets/synthia_dataset/color_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/color_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000001.png differ
diff --git a/tests/assets/synthia_dataset/color_segm_dataset/RGB/Stereo_Left/Omni_F/000000.png b/tests/assets/synthia_dataset/color_segm_dataset/RGB/Stereo_Left/Omni_F/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/color_segm_dataset/RGB/Stereo_Left/Omni_F/000000.png differ
diff --git a/tests/assets/synthia_dataset/color_segm_dataset/RGB/Stereo_Left/Omni_F/000001.png b/tests/assets/synthia_dataset/color_segm_dataset/RGB/Stereo_Left/Omni_F/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/color_segm_dataset/RGB/Stereo_Left/Omni_F/000001.png differ
diff --git a/tests/assets/synthia_dataset/dataset_with_custom_labelmap/GT/COLOR/Stereo_Left/Omni_F/000000.png b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/GT/COLOR/Stereo_Left/Omni_F/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/GT/COLOR/Stereo_Left/Omni_F/000000.png differ
diff --git a/tests/assets/synthia_dataset/dataset_with_custom_labelmap/GT/COLOR/Stereo_Left/Omni_F/000001.png b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/GT/COLOR/Stereo_Left/Omni_F/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/GT/COLOR/Stereo_Left/Omni_F/000001.png differ
diff --git a/tests/assets/synthia_dataset/dataset_with_custom_labelmap/RGB/Stereo_Left/Omni_F/000000.png b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/RGB/Stereo_Left/Omni_F/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/RGB/Stereo_Left/Omni_F/000000.png differ
diff --git a/tests/assets/synthia_dataset/dataset_with_custom_labelmap/RGB/Stereo_Left/Omni_F/000001.png b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/RGB/Stereo_Left/Omni_F/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/RGB/Stereo_Left/Omni_F/000001.png differ
diff --git a/tests/assets/synthia_dataset/dataset_with_custom_labelmap/label_colors.txt b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/label_colors.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/synthia_dataset/dataset_with_custom_labelmap/label_colors.txt
@@ -0,0 +1,5 @@
+0 0 0 background
+0 0 64 sky
+0 128 128 building
+128 0 64 person
+0 192 128 road
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_B/000000.png b/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_B/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_B/000000.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_B/000001.png b/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_B/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_B/000001.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000000.png b/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000000.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000001.png b/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/GT/COLOR/Stereo_Left/Omni_F/000001.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_B/000000.png b/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_B/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_B/000000.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_B/000001.png b/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_B/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_B/000001.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_F/000000.png b/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_F/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_F/000000.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_F/000001.png b/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_F/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/GT/LABELS/Stereo_Left/Omni_F/000001.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_B/000000.png b/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_B/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_B/000000.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_B/000001.png b/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_B/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_B/000001.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_F/000000.png b/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_F/000000.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_F/000000.png differ
diff --git a/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_F/000001.png b/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_F/000001.png
new file mode 100644
Binary files /dev/null and b/tests/assets/synthia_dataset/labels_segm_dataset/RGB/Stereo_Left/Omni_F/000001.png differ
diff --git a/tests/requirements.py b/tests/requirements.py
--- a/tests/requirements.py
+++ b/tests/requirements.py
@@ -27,6 +27,7 @@ class Requirements:
     DATUM_283 = "Create cli tests for testing convert command for VOC format"
     DATUM_399 = "Implement import for ADE20K dataset"
     DATUM_475 = "Support import for CelebA dataset"
+    DATUM_497 = "Support import for SYNTHIA dataset"
 
     # GitHub issues (bugs)
     # https://github.com/openvinotoolkit/datumaro/issues
diff --git a/tests/test_synthia_format.py b/tests/test_synthia_format.py
new file mode 100644
--- /dev/null
+++ b/tests/test_synthia_format.py
@@ -0,0 +1,150 @@
+
+from unittest import TestCase
+import os.path as osp
+
+import numpy as np
+
+from datumaro.components.annotation import (
+    AnnotationType, LabelCategories, Mask, MaskCategories,
+)
+from datumaro.components.dataset import Dataset
+from datumaro.components.environment import Environment
+from datumaro.components.extractor import DatasetItem
+from datumaro.util.test_utils import compare_datasets
+import datumaro.plugins.synthia_format as Synthia
+
+from .requirements import Requirements, mark_requirement
+
+DUMMY_LABELS_SEGM_DATASET_DIR = osp.join(osp.dirname(__file__),
+    'assets', 'synthia_dataset', 'labels_segm_dataset')
+
+DUMMY_COLOR_SEGM_DATASET_DIR = osp.join(osp.dirname(__file__),
+    'assets', 'synthia_dataset', 'color_segm_dataset')
+
+DUMMY_DATASET_DIR_CUSTOM_LABELMAP = osp.join(osp.dirname(__file__),
+    'assets', 'synthia_dataset', 'dataset_with_custom_labelmap')
+
+class SynthiaImporterTest(TestCase):
+    @mark_requirement(Requirements.DATUM_497)
+    def test_can_detect(self):
+        detected_formats = Environment().detect_dataset(DUMMY_LABELS_SEGM_DATASET_DIR)
+        self.assertEqual([Synthia.SynthiaImporter.NAME], detected_formats)
+
+    @mark_requirement(Requirements.DATUM_497)
+    def test_can_detect_with_colored_masks(self):
+        detected_formats = Environment().detect_dataset(DUMMY_COLOR_SEGM_DATASET_DIR)
+        self.assertEqual([Synthia.SynthiaImporter.NAME], detected_formats)
+
+    @mark_requirement(Requirements.DATUM_497)
+    def test_can_detect_with_custom_labelmap(self):
+        detected_formats = Environment().detect_dataset(DUMMY_DATASET_DIR_CUSTOM_LABELMAP)
+        self.assertEqual([Synthia.SynthiaImporter.NAME], detected_formats)
+
+    @mark_requirement(Requirements.DATUM_497)
+    def test_can_import(self):
+        expected_dataset = Dataset.from_iterable([
+            DatasetItem(id='Stereo_Left/Omni_B/000000',
+                image=np.ones((1, 5, 3)),
+                annotations=[
+                    Mask(np.array([[1, 1, 0, 0, 0]]), label=1,
+                        attributes={'dynamic_object': False}),
+                    Mask(np.array([[0, 0, 1, 1, 1]]), label=10,
+                        attributes={'dynamic_object': True}),
+                ],
+            ),
+            DatasetItem(id='Stereo_Left/Omni_B/000001',
+                image=np.ones((1, 5, 3)),
+                annotations=[
+                    Mask(np.array([[1, 0, 0, 0, 0]]), label=8,
+                        attributes={'dynamic_object': True}),
+                    Mask(np.array([[0, 1, 1, 0, 0]]), label=11,
+                        attributes={'dynamic_object': True}),
+                    Mask(np.array([[0, 0, 0, 1, 1]]), label=3,
+                        attributes={'dynamic_object': False}),
+                ],
+            ),
+            DatasetItem(id='Stereo_Left/Omni_F/000000',
+                image=np.ones((1, 5, 3)),
+                annotations=[
+                    Mask(np.array([[1, 1, 0, 0, 0]]), label=1,
+                        attributes={'dynamic_object': False}),
+                    Mask(np.array([[0, 0, 1, 1, 0]]), label=2,
+                        attributes={'dynamic_object': False}),
+                    Mask(np.array([[0, 0, 0, 0, 1]]), label=3,
+                        attributes={'dynamic_object': False}),
+                ],
+            ),
+            DatasetItem(id='Stereo_Left/Omni_F/000001',
+                image=np.ones((1, 5, 3)),
+                annotations=[
+                    Mask(np.array([[1, 0, 0, 0, 0]]), label=1,
+                        attributes={'dynamic_object': False}),
+                    Mask(np.array([[0, 1, 0, 0, 0]]), label=2,
+                        attributes={'dynamic_object': False}),
+                    Mask(np.array([[0, 0, 1, 1, 0]]), label=15,
+                        attributes={'dynamic_object': False}),
+                    Mask(np.array([[0, 0, 0, 0, 1]]), label=3,
+                        attributes={'dynamic_object': False}),
+                ],
+            )
+        ], categories=Synthia.make_categories())
+
+        dataset = Dataset.import_from(DUMMY_LABELS_SEGM_DATASET_DIR, 'synthia')
+
+        compare_datasets(self, expected_dataset, dataset, require_images=True)
+
+
+    @mark_requirement(Requirements.DATUM_497)
+    def test_can_import_with_colored_masks(self):
+        expected_dataset = Dataset.from_iterable([
+            DatasetItem(id='Stereo_Left/Omni_F/000000',
+                image=np.ones((1, 5, 3)),
+                annotations=[
+                    Mask(np.array([[1, 1, 0, 0, 0]]), label=1),
+                    Mask(np.array([[0, 0, 1, 1, 0]]), label=2),
+                    Mask(np.array([[0, 0, 0, 0, 1]]), label=3),
+                ],
+            ),
+            DatasetItem(id='Stereo_Left/Omni_F/000001',
+                image=np.ones((1, 5, 3)),
+                annotations=[
+                    Mask(np.array([[1, 0, 0, 0, 0]]), label=1),
+                    Mask(np.array([[0, 1, 0, 0, 0]]), label=2),
+                    Mask(np.array([[0, 0, 1, 1, 0]]), label=15),
+                    Mask(np.array([[0, 0, 0, 0, 1]]), label=3),
+                ],
+            )
+        ], categories=Synthia.make_categories())
+
+        dataset = Dataset.import_from(DUMMY_COLOR_SEGM_DATASET_DIR, 'synthia')
+
+        compare_datasets(self, expected_dataset, dataset, require_images=True)
+
+    @mark_requirement(Requirements.DATUM_497)
+    def test_can_import_with_custom_labelmap(self):
+        expected_dataset = Dataset.from_iterable([
+            DatasetItem(id='Stereo_Left/Omni_F/000000',
+                image=np.ones((1, 5, 3)),
+                annotations=[
+                    Mask(np.array([[1, 1, 1, 0, 0]]), label=1),
+                    Mask(np.array([[0, 0, 0, 1, 1]]), label=4),
+                ],
+            ),
+            DatasetItem(id='Stereo_Left/Omni_F/000001',
+                image=np.ones((1, 5, 3)),
+                annotations=[
+                    Mask(np.array([[1, 1, 0, 0, 0]]), label=2),
+                    Mask(np.array([[0, 0, 1, 1, 0]]), label=3),
+                    Mask(np.array([[0, 0, 0, 0, 1]]), label=4),
+                ],
+            )
+        ], categories={
+            AnnotationType.label: LabelCategories.from_iterable(
+                ['background', 'sky', 'building', 'person', 'road']),
+            AnnotationType.mask: MaskCategories({0: (0, 0, 0), 1: (0, 0, 64),
+                2: (0, 128, 128), 3: (128, 0, 64), 4: (0, 192, 128)})
+        })
+
+        dataset = Dataset.import_from(DUMMY_DATASET_DIR_CUSTOM_LABELMAP, 'synthia')
+
+        compare_datasets(self, expected_dataset, dataset, require_images=True)