diff --git a/CHANGELOG.md b/CHANGELOG.md index a256c4877c..e85315d9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for escaping in attribiute values in LabelMe format () - Support for Segmentation Splitting () - Support for CIFAR-10/100 dataset format (, ) -- Support COCO panoptic and stuff format () +- Support for COCO panoptic and stuff format () - Documentation file and integration tests for Pascal VOC format () - Support for MNIST and MNIST in CSV dataset formats () - Documentation file for COCO format () - Documentation file and integration tests for YOLO format () +- Support for Cityscapes dataset format () ### Changed - LabelMe format saves dataset items with their relative paths by subsets without changing names () diff --git a/README.md b/README.md index 7bd9d6252b..2c5957f522 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ CVAT annotations ---> Publication, statistics etc. - [MNIST](http://yann.lecun.com/exdb/mnist/) (`classification`) - [MNIST in CSV](https://pjreddie.com/projects/mnist-in-csv/) (`classification`) - [CamVid](http://mi.eng.cam.ac.uk/research/projects/VideoRec/CamVid/) + - [Cityscapes](https://www.cityscapes-dataset.com/) - [CVAT](https://github.com/opencv/cvat/blob/develop/cvat/apps/documentation/xml_format.md) - [LabelMe](http://labelme.csail.mit.edu/Release3.0) - [ICDAR13/15](https://rrc.cvc.uab.es/?ch=2) (`word_recognition`, `text_localization`, `text_segmentation`) diff --git a/datumaro/components/extractor.py b/datumaro/components/extractor.py index b913dece13..ebeaf01ecc 100644 --- a/datumaro/components/extractor.py +++ b/datumaro/components/extractor.py @@ -7,6 +7,7 @@ from glob import iglob from typing import Iterable, List, Dict, Optional import numpy as np +import os import os.path as osp import attr @@ -236,7 +237,7 @@ def __eq__(self, other): class CompiledMask: @staticmethod def from_instance_masks(instance_masks, - instance_ids=None, instance_labels=None): + instance_ids=None, instance_labels=None, dtype=None): from datumaro.util.mask_tools import make_index_mask if instance_ids is not None: @@ -266,7 +267,7 @@ def from_instance_masks(instance_masks, m, idx, instance_id, class_id = next(it) if not class_id: idx = 0 - index_mask = make_index_mask(m, idx) + index_mask = make_index_mask(m, idx, dtype=dtype) instance_map.append(instance_id) class_map.append(class_id) @@ -282,8 +283,8 @@ def from_instance_masks(instance_masks, else: merged_instance_mask = np.array(instance_map, dtype=np.min_scalar_type(instance_map))[index_mask] - merged_class_mask = np.array(class_map, - dtype=np.min_scalar_type(class_map))[index_mask] + dtype_mask = dtype if dtype else np.min_scalar_type(class_map) + merged_class_mask = np.array(class_map, dtype=dtype_mask)[index_mask] return __class__(class_mask=merged_class_mask, instance_mask=merged_instance_mask) @@ -673,7 +674,11 @@ def __call__(self, path, **extra_params): @classmethod def _find_sources_recursive(cls, path, ext, extractor_name, filename='*', dirname='', file_filter=None, max_depth=3): - if path.endswith(ext) and osp.isfile(path): + + if (path.endswith(ext) and osp.isfile(path)) or \ + (not ext and osp.isdir(path) and dirname and \ + os.sep + osp.normpath(dirname) + os.sep in \ + osp.abspath(path) + os.sep): sources = [{'url': path, 'format': extractor_name}] else: sources = [] diff --git a/datumaro/plugins/cityscapes_format.py b/datumaro/plugins/cityscapes_format.py new file mode 100644 index 0000000000..34aca8bd1c --- /dev/null +++ b/datumaro/plugins/cityscapes_format.py @@ -0,0 +1,357 @@ + +# Copyright (C) 2020 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import logging as log +import os +import os.path as osp +from collections import OrderedDict +from enum import Enum +from glob import iglob + +import numpy as np + +from datumaro.components.converter import Converter +from datumaro.components.extractor import (AnnotationType, CompiledMask, + DatasetItem, Importer, LabelCategories, Mask, + MaskCategories, SourceExtractor) +from datumaro.util import str_to_bool +from datumaro.util.annotation_util import make_label_id_mapping +from datumaro.util.image import save_image, load_image +from datumaro.util.mask_tools import generate_colormap, paint_mask + + +CityscapesLabelMap = OrderedDict([ + ('unlabeled', (0, 0, 0)), + ('egovehicle', (0, 0, 0)), + ('rectificationborder', (0, 0, 0)), + ('outofroi', (0, 0, 0)), + ('static', (0, 0, 0)), + ('dynamic', (111, 74, 0)), + ('ground', (81, 0, 81)), + ('road', (128, 64, 128)), + ('sidewalk', (244, 35, 232)), + ('parking', (250, 170, 160)), + ('railtrack', (230, 150, 140)), + ('building', (70, 70, 70)), + ('wall', (102, 102, 156)), + ('fence', (190, 153, 153)), + ('guardrail', (180, 165, 180)), + ('bridge', (150, 100, 100)), + ('tunnel', (150, 120, 90)), + ('pole', (153, 153, 153)), + ('polegroup', (153, 153, 153)), + ('trafficlight', (250, 170, 30)), + ('trafficsign', (220, 220, 0)), + ('vegetation', (107, 142, 35)), + ('terrain', (152, 251, 152)), + ('sky', (70, 130, 180)), + ('person', (220, 20, 60)), + ('rider', (255, 0, 0)), + ('car', (0, 0, 142)), + ('truck', (0, 0, 70)), + ('bus', (0, 60, 100)), + ('caravan', (0, 0, 90)), + ('trailer', (0, 0, 110)), + ('train', (0, 80, 100)), + ('motorcycle', (0, 0, 230)), + ('bicycle', (119, 11, 32)), + ('licenseplate', (0, 0, 142)), +]) + +class CityscapesPath: + GT_FINE_DIR = 'gtFine' + IMGS_FINE_DIR = 'imgsFine' + ORIGINAL_IMAGE_DIR = 'leftImg8bit' + ORIGINAL_IMAGE = '_%s.png' % ORIGINAL_IMAGE_DIR + INSTANCES_IMAGE = '_instanceIds.png' + COLOR_IMAGE = '_color.png' + LABELIDS_IMAGE = '_labelIds.png' + + LABELMAP_FILE = 'label_colors.txt' + +def make_cityscapes_categories(label_map=None): + if label_map is None: + label_map = CityscapesLabelMap + + 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 + label_id = lambda label: label_categories.find(label)[0] + colormap = { label_id(name): (desc[0], desc[1], desc[2]) + for name, desc in label_map.items() } + mask_categories = MaskCategories(colormap) + mask_categories.inverse_colormap # pylint: disable=pointless-statement + categories[AnnotationType.mask] = mask_categories + return categories + +def parse_label_map(path): + if not path: + return None + + label_map = OrderedDict() + with open(path, 'r') as f: + for line in f: + # skip empty and commented lines + line = line.strip() + if not line or line and line[0] == '#': + continue + + # color, name + label_desc = line.strip().split() + + if 2 < len(label_desc): + name = label_desc[3] + color = tuple([int(c) for c in label_desc[:-1]]) + 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 + +def write_label_map(path, label_map): + with open(path, 'w') as f: + for label_name, label_desc in label_map.items(): + if label_desc: + color_rgb = ' '.join(str(c) for c in label_desc) + else: + color_rgb = '' + f.write('%s %s\n' % (color_rgb, label_name)) + +class CityscapesExtractor(SourceExtractor): + def __init__(self, path, subset=None): + assert osp.isdir(path), path + self._path = path + + if not subset: + subset = osp.splitext(osp.basename(path))[0] + self._subset = subset + super().__init__(subset=subset) + + self._categories = self._load_categories(osp.join(self._path, '../../../')) + self._items = list(self._load_items().values()) + + def _load_categories(self, path): + label_map = None + label_map_path = osp.join(path, CityscapesPath.LABELMAP_FILE) + if osp.isfile(label_map_path): + label_map = parse_label_map(label_map_path) + else: + label_map = CityscapesLabelMap + self._labels = [label for label in label_map] + return make_cityscapes_categories(label_map) + + def _load_items(self): + items = {} + annotations_path = osp.normpath(osp.join(self._path, '../../../', + CityscapesPath.GT_FINE_DIR, self._subset)) + + for image_path in iglob( + osp.join(self._path, '**', '*' + CityscapesPath.ORIGINAL_IMAGE), + recursive=True): + sample_id = osp.relpath(image_path, self._path) \ + .replace(CityscapesPath.ORIGINAL_IMAGE, '') + anns = [] + instances_path = osp.join(annotations_path, sample_id + '_' + + CityscapesPath.GT_FINE_DIR + CityscapesPath.INSTANCES_IMAGE) + if osp.isfile(instances_path): + instances_mask = load_image(instances_path, dtype=np.int32) + segm_ids = np.unique(instances_mask) + for segm_id in segm_ids: + if segm_id < 1000: + semanticId = segm_id + isCrowd = True + ann_id = segm_id + else: + semanticId = segm_id // 1000 + isCrowd = False + ann_id = segm_id % 1000 + anns.append(Mask( + image=self._lazy_extract_mask(instances_mask, segm_id), + label=semanticId, id=ann_id, + attributes = { 'is_crowd': isCrowd })) + items[sample_id] = DatasetItem(id=sample_id, subset=self._subset, + image=image_path, annotations=anns) + return items + + @staticmethod + def _lazy_extract_mask(mask, c): + return lambda: mask == c + + +class CityscapesImporter(Importer): + @classmethod + def find_sources(cls, path): + return cls._find_sources_recursive(path, '', 'cityscapes', + dirname=osp.join(CityscapesPath.IMGS_FINE_DIR, + CityscapesPath.ORIGINAL_IMAGE_DIR), + max_depth=1) + + +LabelmapType = Enum('LabelmapType', ['cityscapes', 'source']) + +class CityscapesConverter(Converter): + DEFAULT_IMAGE_EXT = '.png' + + @staticmethod + def _get_labelmap(s): + if osp.isfile(s): + return s + try: + return LabelmapType[s].name + except KeyError: + import argparse + raise argparse.ArgumentTypeError() + + @classmethod + def build_cmdline_parser(cls, **kwargs): + parser = super().build_cmdline_parser(**kwargs) + + parser.add_argument('--apply-colormap', type=str_to_bool, default=True, + help="Use colormap for class masks (default: %(default)s)") + parser.add_argument('--label-map', type=cls._get_labelmap, default=None, + help="Labelmap file path or one of %s" % \ + ', '.join(t.name for t in LabelmapType)) + return parser + + def __init__(self, extractor, save_dir, + apply_colormap=True, label_map=None, **kwargs): + super().__init__(extractor, save_dir, **kwargs) + + self._apply_colormap = apply_colormap + + if label_map is None: + label_map = LabelmapType.source.name + self._load_categories(label_map) + + def apply(self): + os.makedirs(self._save_dir, exist_ok=True) + + for subset_name, subset in self._extractor.subsets().items(): + for item in subset: + image_path = osp.join(CityscapesPath.IMGS_FINE_DIR, + CityscapesPath.ORIGINAL_IMAGE_DIR, subset_name, + item.id + CityscapesPath.ORIGINAL_IMAGE) + if self._save_images: + self._save_image(item, osp.join(self._save_dir, image_path)) + + common_folder_path = osp.join(CityscapesPath.GT_FINE_DIR, + subset_name) + + masks = [a for a in item.annotations + if a.type == AnnotationType.mask] + if not masks: + continue + + common_image_name = item.id + '_' + CityscapesPath.GT_FINE_DIR + + compiled_class_mask = CompiledMask.from_instance_masks(masks, + instance_labels=[self._label_id_mapping(m.label) + for m in masks]) + color_mask_path = osp.join(common_folder_path, + common_image_name + CityscapesPath.COLOR_IMAGE) + self.save_mask(osp.join(self._save_dir, color_mask_path), + compiled_class_mask.class_mask) + + labelids_mask_path = osp.join(common_folder_path, + common_image_name + CityscapesPath.LABELIDS_IMAGE) + self.save_mask(osp.join(self._save_dir, labelids_mask_path), + compiled_class_mask.class_mask, apply_colormap=False, + dtype=np.int32) + + compiled_instance_mask = CompiledMask.from_instance_masks(masks, + instance_labels=[m.id if m.attributes.get('is_crowd', True) + else m.label * 1000 + m.id for m in masks]) + inst_path = osp.join(common_folder_path, + common_image_name + CityscapesPath.INSTANCES_IMAGE) + self.save_mask(osp.join(self._save_dir, inst_path), + compiled_instance_mask.class_mask, apply_colormap=False, + dtype=np.int32) + self.save_label_map() + + def save_label_map(self): + path = osp.join(self._save_dir, CityscapesPath.LABELMAP_FILE) + write_label_map(path, self._label_map) + + def _load_categories(self, label_map_source): + if label_map_source == LabelmapType.cityscapes.name: + # use the default Cityscapes colormap + label_map = CityscapesLabelMap + + elif label_map_source == LabelmapType.source.name and \ + AnnotationType.mask not in self._extractor.categories(): + # generate colormap for input labels + labels = self._extractor.categories() \ + .get(AnnotationType.label, LabelCategories()) + label_map = OrderedDict((item.name, None) + for item in labels.items) + + elif label_map_source == LabelmapType.source.name and \ + AnnotationType.mask in self._extractor.categories(): + # use source colormap + labels = self._extractor.categories()[AnnotationType.label] + colors = self._extractor.categories()[AnnotationType.mask] + label_map = OrderedDict() + for idx, item in enumerate(labels.items): + color = colors.colormap.get(idx) + if color is not None: + label_map[item.name] = color + + elif isinstance(label_map_source, dict): + label_map = OrderedDict( + sorted(label_map_source.items(), key=lambda e: e[0])) + + elif isinstance(label_map_source, str) and osp.isfile(label_map_source): + label_map = parse_label_map(label_map_source) + + else: + raise Exception("Wrong labelmap specified, " + "expected one of %s or a file path" % \ + ', '.join(t.name for t in LabelmapType)) + + self._categories = make_cityscapes_categories(label_map) + self._label_map = label_map + self._label_id_mapping = self._make_label_id_map() + + def _make_label_id_map(self): + map_id, id_mapping, src_labels, dst_labels = make_label_id_mapping( + self._extractor.categories().get(AnnotationType.label), + self._categories[AnnotationType.label]) + + void_labels = [src_label for src_id, src_label in src_labels.items() + if src_label not in dst_labels] + if void_labels: + log.warning("The following labels are remapped to background: %s" % + ', '.join(void_labels)) + log.debug("Saving segmentations with the following label mapping: \n%s" % + '\n'.join(["#%s '%s' -> #%s '%s'" % + ( + src_id, src_label, id_mapping[src_id], + self._categories[AnnotationType.label] \ + .items[id_mapping[src_id]].name + ) + for src_id, src_label in src_labels.items() + ]) + ) + + return map_id + + def save_mask(self, path, mask, colormap=None, apply_colormap=True, + dtype=np.uint8): + if self._apply_colormap and apply_colormap: + if colormap is None: + colormap = self._categories[AnnotationType.mask].colormap + mask = paint_mask(mask, colormap) + save_image(path, mask, create_dir=True, dtype=dtype) diff --git a/datumaro/util/image.py b/datumaro/util/image.py index 17e2a0d0ba..e1acd4792d 100644 --- a/datumaro/util/image.py +++ b/datumaro/util/image.py @@ -65,7 +65,13 @@ def save_image(path, image, create_dir=False, dtype=np.uint8, **kwargs): if not kwargs: kwargs = {} - if _IMAGE_BACKEND == _IMAGE_BACKENDS.cv2: + # NOTE: OpenCV documentation says "If the image format is not supported, + # the image will be converted to 8-bit unsigned and saved that way". + # Conversion from np.int32 to np.uint8 is not working properly + backend = _IMAGE_BACKEND + if dtype == np.int32: + backend = _IMAGE_BACKENDS.PIL + if backend == _IMAGE_BACKENDS.cv2: import cv2 params = [] @@ -78,7 +84,7 @@ def save_image(path, image, create_dir=False, dtype=np.uint8, **kwargs): image = image.astype(dtype) cv2.imwrite(path, image, params=params) - elif _IMAGE_BACKEND == _IMAGE_BACKENDS.PIL: + elif backend == _IMAGE_BACKENDS.PIL: from PIL import Image params = {} diff --git a/docs/design.md b/docs/design.md index 1e520400c0..b24a57a595 100644 --- a/docs/design.md +++ b/docs/design.md @@ -108,7 +108,7 @@ It should be capable of downloading and processing data from CVAT. - [x] PASCAL VOC - [x] YOLO - [x] TF Detection API - - [ ] Cityscapes + - [x] Cityscapes - [x] ImageNet - Dataset visualization (`show`) diff --git a/docs/formats/cityscapes_user_manual.md b/docs/formats/cityscapes_user_manual.md new file mode 100644 index 0000000000..f8e98b71b3 --- /dev/null +++ b/docs/formats/cityscapes_user_manual.md @@ -0,0 +1,176 @@ +# Cityscapes user manual + +## Contents + +- [Format specification](#format-specification) +- [Load Cityscapes dataset](#load-Cityscapes-dataset) +- [Export to other formats](#export-to-other-formats) +- [Export to Cityscapes](#export-to-Cityscapes) +- [Particular use cases](#particular-use-cases) + +## Format specification + +Cityscapes format overview available [here](https://www.cityscapes-dataset.com/dataset-overview/). +Cityscapes format specification available [here](https://github.com/mcordts/cityscapesScripts#the-cityscapes-dataset). + +Cityscapes dataset format supports `Masks` (segmentations tasks) annotations. + +## Load Cityscapes dataset + +The Cityscapes dataset is available for free [download](https://www.cityscapes-dataset.com/downloads/). + +There are two ways to create Datumaro project and add Cityscapes dataset to it: + +``` bash +datum import --format cityscapes --input-path +# or +datum create +datum add path -f cityscapes +``` + +It is possible to specify project name and project directory run +`datum create --help` for more information. + +Cityscapes dataset directory should have the following structure: + + +``` +└─ Dataset/ + ├── imgsFine/ + │ ├── leftImg8bit + │ │ ├── + │ │ | ├── {city1} + │ │ │ | ├── {city1}_{seq:[0...6]}_{frame:[0...6]}_leftImg8bit.png + │ │ │ │ └── ... + │ │ | ├── {city2} + │ │ │ └── ... + │ │ └── ... + ├── gtFine/ + │ ├── + │ │ ├── {city1} + │ │ | ├── {city1}_{seq:[0...6]}_{frame:[0...6]}_gtFine_color.png + │ │ | ├── {city1}_{seq:[0...6]}_{frame:[0...6]}_gtFine_instanceIds.png + │ │ | ├── {city1}_{seq:[0...6]}_{frame:[0...6]}_gtFine_labelIds.png + │ │ │ └── ... + │ │ ├── {city2} + │ │ └── ... + │ └── ... +``` + +Annotated files description: +1. *leftImg8bit.png - left images in 8-bit LDR format +1. *color.png - class labels are encoded by its color +1. *instanceIds.png - class and instance labels are encoded by an instance ID. + The pixel values encode class and the individual instance: the integer part + of a division by 1000 of each ID provides class ID, the remainder + is the instance ID. If a certain annotation describes multiple instances, + then the pixels have the regular ID of that class +1. *labelIds.png - class labels are encoded by its ID + +To make sure that the selected dataset has been added to the project, you can run +`datum info`, which will display the project and dataset information. + +## Export to other formats + +Datumaro can convert Cityscapes dataset into any other format [Datumaro supports](../user_manual.md#supported-formats). +To get the expected result, the dataset needs to be converted to formats +that support the segmentation task (e.g. PascalVOC, CamVID, etc.) +There are few ways to convert Cityscapes dataset to other dataset format: + +``` bash +datum project import -f cityscapes -i +datum export -f voc -o +# or +datum convert -if cityscapes -i -f voc -o +``` + +Some formats provide extra options for conversion. +These options are passed after double dash (`--`) in the command line. +To get information about them, run + +`datum export -f -- -h` + +## Export to Cityscapes + +There are few ways to convert dataset to Cityscapes format: + +``` bash +# export dataset into Cityscapes format from existing project +datum export -p -f cityscapes -o \ + -- --save-images +# converting to Cityscapes format from other format +datum convert -if voc -i \ + -f cityscapes -o -- --save-images +``` + +Extra options for export to cityscapes format: +- `--save-images` allow to export dataset with saving images +(by default `False`); +- `--image-ext IMAGE_EXT` allow to specify image extension +for exporting dataset (by default - keep original or use `.png`, if none). +- `--apply-colormap APPLY_COLORMAP` allow to use colormap for class masks +(`*color.png` files, by default `True`); +- `--label_map` allow to define a custom colormap. Example + +``` bash +# mycolormap.txt : +# 0 0 255 sky +# 255 0 0 person +#... +datum export -f cityscapes -- --label-map mycolormap.txt + +# or you can use original cityscapes colomap: +datum export -f cityscapes -- --label-map cityscapes +``` + +## Particular use cases + +Datumaro supports filtering, transformation, merging etc. for all formats +and for the Cityscapes format in particular. Follow +[user manual](../user_manual.md) +to get more information about these operations. + +There are few examples of using Datumaro operations to solve +particular problems with Cityscapes dataset: + +### Example 1. How to load an original Cityscapes dataset ans convert to Pascal VOC + +```bash +datum create -o project +datum add path -p project -f cityscapes ./Cityscapes/ +datum stats -p project +datum export -p final_project -o dataset -f voc --overwrite -- --save-images +``` + +### Example 2. How to create custom Cityscapes-like dataset + +```python +import numpy as np +from datumaro.components.dataset import Dataset +from datumaro.components.extractor import Mask, DatasetItem + +import datumaro.plugins.cityscapes_format as Cityscapes + +label_map = OrderedDict() +label_map['background'] = (0, 0, 0) +label_map['label_1'] = (1, 2, 3) +label_map['label_2'] = (3, 2, 1) +categories = Cityscapes.make_cityscapes_categories(label_map) + +dataset = Dataset.from_iterable([ + DatasetItem(id=1, + image=np.ones((1, 5, 3)), + annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 1]]), label=1, id=1, + attributes={'is_crowd': False}), + Mask(image=np.array([[0, 1, 1, 0, 0]]), label=2, id=2, + attributes={'is_crowd': False}), + ] + ), + ], categories=categories) + +dataset.export('./dataset', format='cityscapes') +``` + +More examples of working with Cityscapes dataset from code can be found in +[tests](../../tests/test_cityscapes_format.py) diff --git a/docs/user_manual.md b/docs/user_manual.md index 5e5a5e22c0..b757c4b1ad 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -131,6 +131,10 @@ List of supported formats: - CamVid (`segmentation`) - [Format specification](http://mi.eng.cam.ac.uk/research/projects/VideoRec/CamVid/) - [Dataset example](../tests/assets/camvid_dataset) +- Cityscapes (`segmentation`) + - [Format specification](https://www.cityscapes-dataset.com/dataset-overview/) + - [Dataset example](../tests/assets/cityscapes_dataset) + - [Format documentation](./formats/cityscapes_user_manual.md) - CVAT - [Format specification](https://github.com/opencv/cvat/blob/develop/cvat/apps/documentation/xml_format.md) - [Dataset example](../tests/assets/cvat_dataset) diff --git a/tests/assets/cityscapes_dataset/gtFine/test/defaultcity/defaultcity_000001_000031_gtFine_instanceIds.png b/tests/assets/cityscapes_dataset/gtFine/test/defaultcity/defaultcity_000001_000031_gtFine_instanceIds.png new file mode 100644 index 0000000000..9a2cb23ffa Binary files /dev/null and b/tests/assets/cityscapes_dataset/gtFine/test/defaultcity/defaultcity_000001_000031_gtFine_instanceIds.png differ diff --git a/tests/assets/cityscapes_dataset/gtFine/test/defaultcity/defaultcity_000001_000032_gtFine_instanceIds.png b/tests/assets/cityscapes_dataset/gtFine/test/defaultcity/defaultcity_000001_000032_gtFine_instanceIds.png new file mode 100644 index 0000000000..56c008eac1 Binary files /dev/null and b/tests/assets/cityscapes_dataset/gtFine/test/defaultcity/defaultcity_000001_000032_gtFine_instanceIds.png differ diff --git a/tests/assets/cityscapes_dataset/gtFine/train/defaultcity/defaultcity_000002_000045_gtFine_instanceIds.png b/tests/assets/cityscapes_dataset/gtFine/train/defaultcity/defaultcity_000002_000045_gtFine_instanceIds.png new file mode 100644 index 0000000000..e658ec33cd Binary files /dev/null and b/tests/assets/cityscapes_dataset/gtFine/train/defaultcity/defaultcity_000002_000045_gtFine_instanceIds.png differ diff --git a/tests/assets/cityscapes_dataset/gtFine/val/defaultcity/defaultcity_000001_000019_gtFine_instanceIds.png b/tests/assets/cityscapes_dataset/gtFine/val/defaultcity/defaultcity_000001_000019_gtFine_instanceIds.png new file mode 100644 index 0000000000..d2ccdd1f1a Binary files /dev/null and b/tests/assets/cityscapes_dataset/gtFine/val/defaultcity/defaultcity_000001_000019_gtFine_instanceIds.png differ diff --git a/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/test/defaultcity/defaultcity_000001_000031_leftImg8bit.png b/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/test/defaultcity/defaultcity_000001_000031_leftImg8bit.png new file mode 100644 index 0000000000..528f105467 Binary files /dev/null and b/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/test/defaultcity/defaultcity_000001_000031_leftImg8bit.png differ diff --git a/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/test/defaultcity/defaultcity_000001_000032_leftImg8bit.png b/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/test/defaultcity/defaultcity_000001_000032_leftImg8bit.png new file mode 100644 index 0000000000..528f105467 Binary files /dev/null and b/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/test/defaultcity/defaultcity_000001_000032_leftImg8bit.png differ diff --git a/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/train/defaultcity/defaultcity_000002_000045_leftImg8bit.png b/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/train/defaultcity/defaultcity_000002_000045_leftImg8bit.png new file mode 100644 index 0000000000..528f105467 Binary files /dev/null and b/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/train/defaultcity/defaultcity_000002_000045_leftImg8bit.png differ diff --git a/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/val/defaultcity/defaultcity_000001_000019_leftImg8bit.png b/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/val/defaultcity/defaultcity_000001_000019_leftImg8bit.png new file mode 100644 index 0000000000..528f105467 Binary files /dev/null and b/tests/assets/cityscapes_dataset/imgsFine/leftImg8bit/val/defaultcity/defaultcity_000001_000019_leftImg8bit.png differ diff --git a/tests/test_cityscapes_format.py b/tests/test_cityscapes_format.py new file mode 100644 index 0000000000..fd23de9d76 --- /dev/null +++ b/tests/test_cityscapes_format.py @@ -0,0 +1,350 @@ +import os.path as osp +from collections import OrderedDict +from functools import partial +from unittest import TestCase + +import datumaro.plugins.cityscapes_format as Cityscapes +import numpy as np +from datumaro.components.extractor import (AnnotationType, DatasetItem, + Extractor, LabelCategories, Mask) +from datumaro.components.dataset import Dataset +from datumaro.plugins.cityscapes_format import (CityscapesImporter, + CityscapesConverter) +from datumaro.util.image import Image +from datumaro.util.test_utils import (TestDir, compare_datasets, + test_save_and_load) + +DUMMY_DATASET_DIR = osp.join(osp.dirname(__file__), 'assets', + 'cityscapes_dataset') + +class CityscapesFormatTest(TestCase): + def test_can_write_and_parse_labelmap(self): + src_label_map = Cityscapes.CityscapesLabelMap + + with TestDir() as test_dir: + file_path = osp.join(test_dir, 'label_colors.txt') + + Cityscapes.write_label_map(file_path, src_label_map) + dst_label_map = Cityscapes.parse_label_map(file_path) + + self.assertEqual(src_label_map, dst_label_map) + +class CityscapesImportTest(TestCase): + def test_can_import(self): + source_dataset = Dataset.from_iterable([ + DatasetItem(id='defaultcity/defaultcity_000001_000031', + subset='test', + image=np.ones((1, 5, 3)), + annotations=[ + Mask(image=np.array([[1, 1, 0, 0, 0]]), id=3, label=3, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 0, 1, 0, 0]]), id=1, label=27, + attributes={'is_crowd': False}), + Mask(image=np.array([[0, 0, 0, 1, 1]]), id=2, label=27, + attributes={'is_crowd': False}), + ] + ), + DatasetItem(id='defaultcity/defaultcity_000001_000032', + subset='test', + image=np.ones((1, 5, 3)), + annotations=[ + Mask(image=np.array([[1, 1, 0, 0, 0]]), id=1, label=31, + attributes={'is_crowd': False}), + Mask(image=np.array([[0, 0, 1, 0, 0]]), id=12, label=12, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 0, 0, 1, 1]]), id=3, label=3, + attributes={'is_crowd': True}), + ] + ), + DatasetItem(id='defaultcity/defaultcity_000002_000045', + subset='train', + image=np.ones((1, 5, 3)), + annotations=[ + Mask(image=np.array([[1, 1, 0, 1, 1]]), id=3, label=3, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 0, 1, 0, 0]]), id=1, label=24, + attributes={'is_crowd': False}), + ] + ), + DatasetItem(id='defaultcity/defaultcity_000001_000019', + subset = 'val', + image=np.ones((1, 5, 3)), + annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 1]]), id=3, label=3, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 1, 1, 0, 0]]), id=24, label=1, + attributes={'is_crowd': False}), + ] + ), + ], categories=Cityscapes.make_cityscapes_categories()) + + parsed_dataset = Dataset.import_from(DUMMY_DATASET_DIR, 'cityscapes') + + compare_datasets(self, source_dataset, parsed_dataset) + + def test_can_detect_cityscapes(self): + self.assertTrue(CityscapesImporter.detect(DUMMY_DATASET_DIR)) + + +class TestExtractorBase(Extractor): + def _label(self, cityscapes_label): + return self.categories()[AnnotationType.label].find(cityscapes_label)[0] + + def categories(self): + return Cityscapes.make_cityscapes_categories() + +class CityscapesConverterTest(TestCase): + def _test_save_and_load(self, source_dataset, converter, test_dir, + target_dataset=None, importer_args=None, **kwargs): + return test_save_and_load(self, source_dataset, converter, test_dir, + importer='cityscapes', + target_dataset=target_dataset, importer_args=importer_args, **kwargs) + + def test_can_save_cityscapes_segm(self): + class TestExtractor(TestExtractorBase): + def __iter__(self): + return iter([ + DatasetItem(id='defaultcity_1_2', subset='test', + image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[0, 0, 0, 1, 0]]), label=3, id=3, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 1, 1, 0, 0]]), label=24, id=1, + attributes={'is_crowd': False}), + Mask(image=np.array([[1, 0, 0, 0, 1]]), label=15, id=15, + attributes={'is_crowd': True}), + ]), + DatasetItem(id='defaultcity_3', subset='val', + image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 1, 0, 1, 1]]), label=3, id=3, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 0, 1, 0, 0]]), label=5, id=5, + attributes={'is_crowd': True}), + ]), + ]) + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + partial(CityscapesConverter.convert, label_map='cityscapes', + save_images=True), test_dir) + + def test_can_save_cityscapes_segm_unpainted(self): + class TestExtractor(TestExtractorBase): + def __iter__(self): + return iter([ + DatasetItem(id='defaultcity_1_2', subset='test', + image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[0, 0, 0, 1, 0]]), label=3, id=3, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 1, 1, 0, 0]]), label=24, id=1, + attributes={'is_crowd': False}), + Mask(image=np.array([[1, 0, 0, 0, 1]]), label=15, id=15, + attributes={'is_crowd': True}), + ]), + ]) + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + partial(CityscapesConverter.convert, label_map='cityscapes', + save_images=True, apply_colormap=False), test_dir) + + def test_can_save_cityscapes_dataset_with_no_subsets(self): + class TestExtractor(TestExtractorBase): + def __iter__(self): + return iter([ + DatasetItem(id='defaultcity_1_2', + image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 0]]), label=0, id=0, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 1, 1, 0, 1]]), label=3, id=3, + attributes={'is_crowd': True}), + ]), + + DatasetItem(id='defaultcity_1_3', + image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 1, 0, 1, 0]]), label=1, id=1, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 0, 1, 0, 1]]), label=2, id=2, + attributes={'is_crowd': True}), + ]), + ]) + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + partial(CityscapesConverter.convert, label_map='cityscapes', + save_images=True), test_dir) + + def test_can_save_cityscapes_dataset_without_frame_and_sequence(self): + class TestExtractor(TestExtractorBase): + def __iter__(self): + return iter([ + DatasetItem(id='justcity', subset='test', + image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 1]]), label=3, id=3, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 1, 1, 0, 0]]), label=24, id=1, + attributes={'is_crowd': False}), + ]), + ]) + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + partial(CityscapesConverter.convert, label_map='cityscapes', + save_images=True), test_dir) + + def test_can_save_dataset_with_cyrillic_and_spaces_in_filename(self): + class TestExtractor(TestExtractorBase): + def __iter__(self): + return iter([ + DatasetItem(id='кириллица с пробелом', + image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 1]]), label=3, id=3, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 1, 1, 0, 0]]), label=24, id=1, + attributes={'is_crowd': False}), + ]), + ]) + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + partial(CityscapesConverter.convert, label_map='cityscapes', + save_images=True), test_dir) + + def test_can_save_cityscapes_dataset_with_strange_id(self): + class TestExtractor(TestExtractorBase): + def __iter__(self): + return iter([ + DatasetItem(id='a/b/1', subset='test', + image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 1]]), label=3, id=3, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 1, 1, 0, 0]]), label=24, id=1, + attributes={'is_crowd': False}), + ]), + ]) + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + partial(CityscapesConverter.convert, label_map='cityscapes', + save_images=True), test_dir) + + def test_can_save_with_no_masks(self): + class TestExtractor(TestExtractorBase): + def __iter__(self): + return iter([ + DatasetItem(id='city_1_2', subset='test', + image=np.ones((2, 5, 3)), + ), + ]) + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + partial(CityscapesConverter.convert, label_map='cityscapes', + save_images=True), test_dir) + + def test_dataset_with_source_labelmap_undefined(self): + class SrcExtractor(TestExtractorBase): + def __iter__(self): + yield DatasetItem(id=1, image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 1]]), label=1, id=1, + attributes={'is_crowd': False}), + Mask(image=np.array([[0, 1, 1, 0, 0]]), label=2, id=2, + attributes={'is_crowd': False}), + ]) + + def categories(self): + label_cat = LabelCategories() + label_cat.add('background') + label_cat.add('Label_1') + label_cat.add('label_2') + return { + AnnotationType.label: label_cat, + } + + class DstExtractor(TestExtractorBase): + def __iter__(self): + yield DatasetItem(id=1, image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 1]]), + attributes={'is_crowd': False}, id=1, + label=self._label('Label_1')), + Mask(image=np.array([[0, 1, 1, 0, 0]]), + attributes={'is_crowd': False}, id=2, + label=self._label('label_2')), + ]) + + def categories(self): + label_map = OrderedDict() + label_map['background'] = None + label_map['Label_1'] = None + label_map['label_2'] = None + return Cityscapes.make_cityscapes_categories(label_map) + + with TestDir() as test_dir: + self._test_save_and_load(SrcExtractor(), + partial(CityscapesConverter.convert, label_map='source', + save_images=True), test_dir, target_dataset=DstExtractor()) + + def test_dataset_with_source_labelmap_defined(self): + class SrcExtractor(TestExtractorBase): + def __iter__(self): + yield DatasetItem(id=1, image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 1]]), label=1, id=1, + attributes={'is_crowd': False}), + Mask(image=np.array([[0, 1, 1, 0, 0]]), label=2, id=2, + attributes={'is_crowd': False}), + ]) + + def categories(self): + label_map = OrderedDict() + label_map['background'] = (0, 0, 0) + label_map['label_1'] = (1, 2, 3) + label_map['label_2'] = (3, 2, 1) + return Cityscapes.make_cityscapes_categories(label_map) + + class DstExtractor(TestExtractorBase): + def __iter__(self): + yield DatasetItem(id=1, image=np.ones((1, 5, 3)), annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 1]]), + attributes={'is_crowd': False}, id=1, + label=self._label('label_1')), + Mask(image=np.array([[0, 1, 1, 0, 0]]), + attributes={'is_crowd': False}, id=2, + label=self._label('label_2')), + ]) + + def categories(self): + label_map = OrderedDict() + label_map['background'] = (0, 0, 0) + label_map['label_1'] = (1, 2, 3) + label_map['label_2'] = (3, 2, 1) + return Cityscapes.make_cityscapes_categories(label_map) + + with TestDir() as test_dir: + self._test_save_and_load(SrcExtractor(), + partial(CityscapesConverter.convert, label_map='source', + save_images=True), test_dir, target_dataset=DstExtractor()) + + def test_can_save_and_load_image_with_arbitrary_extension(self): + class TestExtractor(TestExtractorBase): + def __iter__(self): + return iter([ + DatasetItem(id='q/1', image=Image(path='q/1.JPEG', + data=np.zeros((4, 3, 3)))), + + DatasetItem(id='a/b/c/2', image=Image( + path='a/b/c/2.bmp', data=np.ones((1, 5, 3)) + ), annotations=[ + Mask(image=np.array([[1, 0, 0, 1, 0]]), label=0, id=0, + attributes={'is_crowd': True}), + Mask(image=np.array([[0, 1, 1, 0, 1]]), label=1, id=1, + attributes={'is_crowd': True}), + ]), + ]) + + def categories(self): + label_map = OrderedDict() + label_map['a'] = None + label_map['b'] = None + return Cityscapes.make_cityscapes_categories(label_map) + + with TestDir() as test_dir: + self._test_save_and_load(TestExtractor(), + partial(CityscapesConverter.convert, save_images=True), + test_dir, require_images=True)