Repository: openvinotoolkit/datumaro

Issue:
Import for CelebA dataset format
We need to support CelebA dataset format.
This format has several types of annotations: 
- labels;
- bounding boxes;
- landmarks;
- attributes;

(each type is stored in a separate file)

CelebA format specification is available [here](https://mmlab.ie.cuhk.edu.hk/projects/CelebA.html).
The dataset can be downloaded [here](https://drive.google.com/drive/folders/0B7EVK8r0v71pWEZsZE9oNnFzTm8?resourcekey=0-5BR16BdXnb8hVj6CNHKzLg).



Discussion:

Resolving patch:
diff --git a/datumaro/plugins/align_celeba_format.py b/datumaro/plugins/align_celeba_format.py
new file mode 100644
--- /dev/null
+++ b/datumaro/plugins/align_celeba_format.py
@@ -0,0 +1,169 @@
+# Copyright (C) 2021 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import os.path as osp
+
+from datumaro.components.annotation import (
+    AnnotationType, Label, LabelCategories, Points, PointsCategories,
+)
+from datumaro.components.errors import DatasetImportError
+from datumaro.components.extractor import DatasetItem, Importer, SourceExtractor
+from datumaro.util.image import find_images
+
+
+class AlignCelebaPath:
+    IMAGES_DIR = 'Img/img_align_celeba'
+    LABELS_FILE = 'Anno/identity_CelebA.txt'
+    ATTRS_FILE = 'Anno/list_attr_celeba.txt'
+    LANDMARKS_FILE = 'Anno/list_landmarks_align_celeba.txt'
+    SUBSETS_FILE = 'Eval/list_eval_partition.txt'
+    SUBSETS = {'0': 'train', '1': 'val', '2': 'test'}
+    BBOXES_HEADER = 'image_id x_1 y_1 width height'
+    LANDMARKS_HEADER = 'lefteye_x lefteye_y righteye_x righteye_y ' \
+        'nose_x nose_y leftmouth_x leftmouth_y rightmouth_x rightmouth_y'
+
+class AlignCelebaExtractor(SourceExtractor):
+    def __init__(self, path):
+        if not osp.isdir(path):
+            raise FileNotFoundError("Can't read dataset directory '%s'" % path)
+
+        super().__init__()
+        self._anno_dir = osp.dirname(path)
+
+        self._categories = { AnnotationType.label: LabelCategories() }
+        self._items = list(self._load_items(path).values())
+
+    def _load_items(self, root_dir):
+        items = {}
+
+        image_dir = osp.join(root_dir, AlignCelebaPath.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 = {}
+
+        label_categories = self._categories[AnnotationType.label]
+
+        labels_path = osp.join(root_dir, AlignCelebaPath.LABELS_FILE)
+        if (not osp.isfile(labels_path)):
+            raise DatasetImportError("File '%s': was not found" % labels_path)
+
+        with open(labels_path, encoding='utf-8') as f:
+            for line in f:
+                item_id, item_ann = self.split_annotation(line)
+                label_ids = [int(id) for id in item_ann]
+                anno = []
+                for label in label_ids:
+                    while len(label_categories) <= label:
+                        label_categories.add('class-%d' % len(label_categories))
+                    anno.append(Label(label))
+
+                items[item_id] = DatasetItem(id=item_id,
+                    image=images.get(item_id), annotations=anno)
+
+        landmark_path = osp.join(root_dir, AlignCelebaPath.LANDMARKS_FILE)
+        if osp.isfile(landmark_path):
+            with open(landmark_path, encoding='utf-8') as f:
+                landmarks_number = int(f.readline().strip())
+
+                point_cat = PointsCategories()
+                for i, point_name in enumerate(f.readline().strip().split()):
+                    point_cat.add(i, [point_name])
+                self._categories[AnnotationType.points] = point_cat
+
+                counter = 0
+                for counter, line in enumerate(f):
+                    item_id, item_ann = self.split_annotation(line)
+                    landmarks = [float(id) for id in item_ann]
+
+                    if len(landmarks) != len(point_cat):
+                        raise DatasetImportError("File '%s', line %s: "
+                            "points do not match the header of this file" % \
+                            (landmark_path, line))
+
+                    if item_id not in items:
+                        raise DatasetImportError("File '%s', line %s: "
+                            "for this item are not label in %s " % \
+                            (landmark_path, line, AlignCelebaPath.LABELS_FILE))
+
+                    anno = items[item_id].annotations
+                    label = anno[0].label
+                    anno.append(Points(landmarks, label=label))
+
+                if landmarks_number - 1 != counter:
+                    raise DatasetImportError("File '%s': the number of "
+                        "landmarks does not match the specified number "
+                        "at the beginning of the file " % landmark_path)
+
+        attr_path = osp.join(root_dir, AlignCelebaPath.ATTRS_FILE)
+        if osp.isfile(attr_path):
+            with open(attr_path, encoding='utf-8') as f:
+                attr_number = int(f.readline().strip())
+                attr_names = f.readline().split()
+
+                counter = 0
+                for counter, line in enumerate(f):
+                    item_id, item_ann = self.split_annotation(line)
+                    if len(attr_names) != len(item_ann):
+                        raise DatasetImportError("File '%s', line %s: "
+                            "the number of attributes "
+                            "in the line does not match the number at the "
+                            "beginning of the file " % (attr_path, line))
+
+                    attrs = {name: 0 < int(ann)
+                        for name, ann in zip(attr_names, item_ann)}
+
+                    if item_id not in items:
+                        items[item_id] = DatasetItem(id=item_id,
+                            image=images.get(item_id))
+
+                    items[item_id].attributes = attrs
+
+                if attr_number - 1 != counter:
+                    raise DatasetImportError("File %s: the number of items "
+                        "with attributes does not match the specified number "
+                        "at the beginning of the file " % attr_path)
+
+        subset_path = osp.join(root_dir, AlignCelebaPath.SUBSETS_FILE)
+        if osp.isfile(subset_path):
+            with open(subset_path, encoding='utf-8') as f:
+                for line in f:
+                    item_id, item_ann = self.split_annotation(line)
+                    subset_id = item_ann[0]
+                    subset = AlignCelebaPath.SUBSETS[subset_id]
+
+                    if item_id not in items:
+                        items[item_id] = DatasetItem(id=item_id,
+                            image=images.get(item_id))
+
+                    items[item_id].subset = subset
+
+                    if 'default' in self._subsets:
+                        self._subsets.pop()
+                    self._subsets.append(subset)
+
+        return items
+
+    def split_annotation(self, line):
+        item = line.split('\"')
+        if 1 < len(item):
+            if len(item) == 3:
+                item_id = osp.splitext(item[1])[0]
+                item = item[2].split()
+            else:
+                raise DatasetImportError("Line %s: unexpected number "
+                    "of quotes in filename" % line)
+        else:
+            item = line.split()
+            item_id = osp.splitext(item[0])[0]
+        return item_id, item[1:]
+
+class AlignCelebaImporter(Importer):
+    @classmethod
+    def find_sources(cls, path):
+        return [{'url': path, 'format': 'align_celeba'}]
diff --git a/datumaro/plugins/celeba_format.py b/datumaro/plugins/celeba_format.py
new file mode 100644
--- /dev/null
+++ b/datumaro/plugins/celeba_format.py
@@ -0,0 +1,198 @@
+# Copyright (C) 2021 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import os.path as osp
+
+from datumaro.components.annotation import (
+    AnnotationType, Bbox, Label, LabelCategories, Points, PointsCategories,
+)
+from datumaro.components.errors import DatasetImportError
+from datumaro.components.extractor import DatasetItem, Importer, SourceExtractor
+from datumaro.util.image import find_images
+
+
+class CelebaPath:
+    IMAGES_DIR = 'Img/img_celeba'
+    LABELS_FILE = 'Anno/identity_CelebA.txt'
+    BBOXES_FILE = 'Anno/list_bbox_celeba.txt'
+    ATTRS_FILE = 'Anno/list_attr_celeba.txt'
+    LANDMARKS_FILE = 'Anno/list_landmarks_celeba.txt'
+    SUBSETS_FILE = 'Eval/list_eval_partition.txt'
+    SUBSETS = {'0': 'train', '1': 'val', '2': 'test'}
+    BBOXES_HEADER = 'image_id x_1 y_1 width height'
+
+class CelebaExtractor(SourceExtractor):
+    def __init__(self, path):
+        if not osp.isdir(path):
+            raise FileNotFoundError("Can't read dataset directory '%s'" % path)
+
+        super().__init__()
+
+        self._categories = { AnnotationType.label: LabelCategories() }
+        self._items = list(self._load_items(path).values())
+
+    def _load_items(self, root_dir):
+        items = {}
+
+        image_dir = osp.join(root_dir, CelebaPath.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 = {}
+
+        label_categories = self._categories[AnnotationType.label]
+
+        labels_path = osp.join(root_dir, CelebaPath.LABELS_FILE)
+        if (not osp.isfile(labels_path)):
+            raise DatasetImportError("File '%s': was not found" % labels_path)
+
+        with open(labels_path, encoding='utf-8') as f:
+            for line in f:
+                item_id, item_ann = self.split_annotation(line)
+                label_ids = [int(id) for id in item_ann]
+                anno = []
+                for label in label_ids:
+                    while len(label_categories) <= label:
+                        label_categories.add('class-%d' % len(label_categories))
+                    anno.append(Label(label))
+
+                items[item_id] = DatasetItem(id=item_id,
+                    image=images.get(item_id), annotations=anno)
+
+        landmark_path = osp.join(root_dir, CelebaPath.LANDMARKS_FILE)
+        if osp.isfile(landmark_path):
+            with open(landmark_path, encoding='utf-8') as f:
+                landmarks_number = int(f.readline().strip())
+
+                point_cat = PointsCategories()
+                for i, point_name in enumerate(f.readline().strip().split()):
+                    point_cat.add(i, [point_name])
+                self._categories[AnnotationType.points] = point_cat
+
+                counter = 0
+                for counter, line in enumerate(f):
+                    item_id, item_ann = self.split_annotation(line)
+                    landmarks = [float(id) for id in item_ann]
+
+                    if len(landmarks) != len(point_cat):
+                        raise DatasetImportError("File '%s', line %s: "
+                            "points do not match the header of this file" % \
+                            (landmark_path, line))
+
+                    if item_id not in items:
+                        raise DatasetImportError("File '%s', line %s: "
+                            "for this item are not label in %s " % \
+                            (landmark_path, line, CelebaPath.LABELS_FILE))
+
+                    anno = items[item_id].annotations
+                    label = anno[0].label
+                    anno.append(Points(landmarks, label=label))
+
+                if landmarks_number - 1 != counter:
+                    raise DatasetImportError("File '%s': the number of "
+                        "landmarks does not match the specified number "
+                        "at the beginning of the file " % landmark_path)
+
+        bbox_path = osp.join(root_dir, CelebaPath.BBOXES_FILE)
+        if osp.isfile(bbox_path):
+            with open(bbox_path, encoding='utf-8') as f:
+                bboxes_number = int(f.readline().strip())
+
+                if f.readline().strip() != CelebaPath.BBOXES_HEADER:
+                    raise DatasetImportError("File '%s': the header "
+                        "does not match the expected format '%s'" % \
+                        (bbox_path, CelebaPath.BBOXES_HEADER))
+
+                counter = 0
+                for counter, line in enumerate(f):
+                    item_id, item_ann = self.split_annotation(line)
+                    bbox = [float(id) for id in item_ann]
+
+                    if item_id not in items:
+                        raise DatasetImportError("File '%s', line %s: "
+                            "for this item are not label in %s " % \
+                            (bbox_path, line, CelebaPath.LABELS_FILE))
+
+                    anno = items[item_id].annotations
+                    label = anno[0].label
+                    anno.append(Bbox(
+                        bbox[0], bbox[1], bbox[2], bbox[3], label=label))
+
+                if bboxes_number - 1 != counter:
+                    raise DatasetImportError(
+                        "File '%s': the number of bounding "
+                        "boxes does not match the specified number "
+                        "at the beginning of the file " % bbox_path)
+
+        attr_path = osp.join(root_dir, CelebaPath.ATTRS_FILE)
+        if osp.isfile(attr_path):
+            with open(attr_path, encoding='utf-8') as f:
+                attr_number = int(f.readline().strip())
+                attr_names = f.readline().split()
+
+                counter = 0
+                for counter, line in enumerate(f):
+                    item_id, item_ann = self.split_annotation(line)
+                    if len(attr_names) != len(item_ann):
+                        raise DatasetImportError("File '%s', line %s: "
+                            "the number of attributes "
+                            "in the line does not match the number at the "
+                            "beginning of the file " % (attr_path, line))
+
+                    attrs = {name: 0 < int(ann)
+                        for name, ann in zip(attr_names, item_ann)}
+
+                    if item_id not in items:
+                        items[item_id] = DatasetItem(id=item_id,
+                            image=images.get(item_id))
+
+                    items[item_id].attributes = attrs
+
+                if attr_number - 1 != counter:
+                    raise DatasetImportError("File %s: the number of items "
+                        "with attributes does not match the specified number "
+                        "at the beginning of the file " % attr_path)
+
+        subset_path = osp.join(root_dir, CelebaPath.SUBSETS_FILE)
+        if osp.isfile(subset_path):
+            with open(subset_path, encoding='utf-8') as f:
+                for line in f:
+                    item_id, item_ann = self.split_annotation(line)
+                    subset_id = item_ann[0]
+                    subset = CelebaPath.SUBSETS[subset_id]
+
+                    if item_id not in items:
+                        items[item_id] = DatasetItem(id=item_id,
+                            image=images.get(item_id))
+
+                    items[item_id].subset = subset
+
+                    if 'default' in self._subsets:
+                        self._subsets.pop()
+                    self._subsets.append(subset)
+
+        return items
+
+    def split_annotation(self, line):
+        item = line.split('\"')
+        if 1 < len(item):
+            if len(item) == 3:
+                item_id = osp.splitext(item[1])[0]
+                item = item[2].split()
+            else:
+                raise DatasetImportError("Line %s: unexpected number "
+                    "of quotes in filename" % line)
+        else:
+            item = line.split()
+            item_id = osp.splitext(item[0])[0]
+        return item_id, item[1:]
+
+class CelebaImporter(Importer):
+    @classmethod
+    def find_sources(cls, path):
+        return [{'url': path, 'format': 'celeba'}]


Test patch:
diff --git a/tests/assets/align_celeba_dataset/Anno/identity_CelebA.txt b/tests/assets/align_celeba_dataset/Anno/identity_CelebA.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/align_celeba_dataset/Anno/identity_CelebA.txt
@@ -0,0 +1,5 @@
+000001.jpg 12
+000002.jpg 5
+000003.jpg 2
+000004.jpg 10
+000005.jpg 7
diff --git a/tests/assets/align_celeba_dataset/Anno/list_attr_celeba.txt b/tests/assets/align_celeba_dataset/Anno/list_attr_celeba.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/align_celeba_dataset/Anno/list_attr_celeba.txt
@@ -0,0 +1,4 @@
+2
+5_o_Clock_Shadow Arched_Eyebrows Attractive Bags_Under_Eyes Bald Bangs Big_Lips Big_Nose
+000001.jpg -1  1  1 -1 -1 -1 -1 -1
+000003.jpg -1 -1 -1  1 -1 -1 -1  1
diff --git a/tests/assets/align_celeba_dataset/Anno/list_landmarks_align_celeba.txt b/tests/assets/align_celeba_dataset/Anno/list_landmarks_align_celeba.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/align_celeba_dataset/Anno/list_landmarks_align_celeba.txt
@@ -0,0 +1,7 @@
+5
+lefteye_x lefteye_y righteye_x righteye_y nose_x nose_y leftmouth_x leftmouth_y rightmouth_x rightmouth_y
+000001.jpg 69  109  106  113   77  142   73  152  108  154
+000002.jpg 69  110  107  112   81  135   70  151  108  153
+000003.jpg 76  112  104  106  108  128   74  156   98  158
+000004.jpg 72  113  108  108  101  138   71  155  101  151
+000005.jpg 66  114  112  112   86  119   71  147  104  150
diff --git a/tests/assets/align_celeba_dataset/Eval/list_eval_partition.txt b/tests/assets/align_celeba_dataset/Eval/list_eval_partition.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/align_celeba_dataset/Eval/list_eval_partition.txt
@@ -0,0 +1,5 @@
+000001.jpg 0
+000002.jpg 0
+000003.jpg 1
+000004.jpg 2
+000005.jpg 2
diff --git a/tests/assets/align_celeba_dataset/Img/img_align_celeba/000001.jpg b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000001.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000001.jpg differ
diff --git a/tests/assets/align_celeba_dataset/Img/img_align_celeba/000002.jpg b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000002.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000002.jpg differ
diff --git a/tests/assets/align_celeba_dataset/Img/img_align_celeba/000003.jpg b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000003.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000003.jpg differ
diff --git a/tests/assets/align_celeba_dataset/Img/img_align_celeba/000004.jpg b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000004.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000004.jpg differ
diff --git a/tests/assets/align_celeba_dataset/Img/img_align_celeba/000005.jpg b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000005.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/align_celeba_dataset/Img/img_align_celeba/000005.jpg differ
diff --git a/tests/assets/celeba_dataset/Anno/identity_CelebA.txt b/tests/assets/celeba_dataset/Anno/identity_CelebA.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/celeba_dataset/Anno/identity_CelebA.txt
@@ -0,0 +1,5 @@
+000001.jpg 12
+000002.jpg 5
+000003.jpg 2
+000004.jpg 10
+000005.jpg 7
diff --git a/tests/assets/celeba_dataset/Anno/list_attr_celeba.txt b/tests/assets/celeba_dataset/Anno/list_attr_celeba.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/celeba_dataset/Anno/list_attr_celeba.txt
@@ -0,0 +1,4 @@
+2
+5_o_Clock_Shadow Arched_Eyebrows Attractive Bags_Under_Eyes Bald Bangs Big_Lips Big_Nose
+000001.jpg -1  1  1 -1 -1 -1 -1 -1
+000003.jpg -1 -1 -1  1 -1 -1 -1  1
diff --git a/tests/assets/celeba_dataset/Anno/list_bbox_celeba.txt b/tests/assets/celeba_dataset/Anno/list_bbox_celeba.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/celeba_dataset/Anno/list_bbox_celeba.txt
@@ -0,0 +1,7 @@
+5
+image_id x_1 y_1 width height
+000001.jpg    95  71 226 313
+000002.jpg    72  94 221 306
+000003.jpg   216  59  91 126
+000004.jpg   622 257 564 781
+000005.jpg   236 109 120 166
diff --git a/tests/assets/celeba_dataset/Anno/list_landmarks_celeba.txt b/tests/assets/celeba_dataset/Anno/list_landmarks_celeba.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/celeba_dataset/Anno/list_landmarks_celeba.txt
@@ -0,0 +1,7 @@
+5
+lefteye_x lefteye_y righteye_x righteye_y nose_x nose_y leftmouth_x leftmouth_y rightmouth_x rightmouth_y
+000001.jpg 165  184  244  176  196  249  194  271  266  260
+000002.jpg 140  204  220  204  168  254  146  289  226  289
+000003.jpg 244  104  264  105  263  121  235  134  251  140
+000004.jpg 796  539  984  539  930  687  762  756  915  756
+000005.jpg 273  169  328  161  298  172  283  208  323  207
diff --git a/tests/assets/celeba_dataset/Eval/list_eval_partition.txt b/tests/assets/celeba_dataset/Eval/list_eval_partition.txt
new file mode 100644
--- /dev/null
+++ b/tests/assets/celeba_dataset/Eval/list_eval_partition.txt
@@ -0,0 +1,5 @@
+000001.jpg 0
+000002.jpg 0
+000003.jpg 1
+000004.jpg 2
+000005.jpg 2
diff --git a/tests/assets/celeba_dataset/Img/img_celeba/000001.jpg b/tests/assets/celeba_dataset/Img/img_celeba/000001.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/celeba_dataset/Img/img_celeba/000001.jpg differ
diff --git a/tests/assets/celeba_dataset/Img/img_celeba/000002.jpg b/tests/assets/celeba_dataset/Img/img_celeba/000002.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/celeba_dataset/Img/img_celeba/000002.jpg differ
diff --git a/tests/assets/celeba_dataset/Img/img_celeba/000003.jpg b/tests/assets/celeba_dataset/Img/img_celeba/000003.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/celeba_dataset/Img/img_celeba/000003.jpg differ
diff --git a/tests/assets/celeba_dataset/Img/img_celeba/000004.jpg b/tests/assets/celeba_dataset/Img/img_celeba/000004.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/celeba_dataset/Img/img_celeba/000004.jpg differ
diff --git a/tests/assets/celeba_dataset/Img/img_celeba/000005.jpg b/tests/assets/celeba_dataset/Img/img_celeba/000005.jpg
new file mode 100644
Binary files /dev/null and b/tests/assets/celeba_dataset/Img/img_celeba/000005.jpg differ
diff --git a/tests/requirements.py b/tests/requirements.py
--- a/tests/requirements.py
+++ b/tests/requirements.py
@@ -26,6 +26,7 @@ class Requirements:
     DATUM_280 = "Support KITTI dataset formats"
     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"
 
     # GitHub issues (bugs)
     # https://github.com/openvinotoolkit/datumaro/issues
diff --git a/tests/test_align_celeba_format.py b/tests/test_align_celeba_format.py
new file mode 100644
--- /dev/null
+++ b/tests/test_align_celeba_format.py
@@ -0,0 +1,83 @@
+from unittest import TestCase
+import os.path as osp
+
+import numpy as np
+
+from datumaro.components.annotation import (
+    AnnotationType, Label, LabelCategories, Points, PointsCategories,
+)
+from datumaro.components.dataset import Dataset
+from datumaro.components.extractor import DatasetItem
+from datumaro.plugins.align_celeba_format import AlignCelebaImporter
+from datumaro.util.test_utils import compare_datasets
+
+from .requirements import Requirements, mark_requirement
+
+DUMMY_ALIGN_DATASET_DIR = osp.join(osp.dirname(__file__), 'assets',
+    'align_celeba_dataset',)
+
+class AlignCelebaImporterTest(TestCase):
+    def test_can_import(self):
+        expected_dataset = Dataset.from_iterable([
+            DatasetItem(id='000001', subset='train',
+                image=np.ones((3, 4, 3)),
+                annotations=[
+                    Label(12),
+                    Points([69, 109, 106, 113, 77, 142, 73, 152, 108, 154],
+                        label=12)
+                ],
+                attributes={'5_o_Clock_Shadow': False, 'Arched_Eyebrows': True,
+                    'Attractive': True, 'Bags_Under_Eyes': False, 'Bald': False,
+                    'Bangs': False, 'Big_Lips': False, 'Big_Nose': False}
+            ),
+            DatasetItem(id='000002', subset='train',
+                image=np.ones((3, 4, 3)),
+                annotations=[
+                    Label(5),
+                    Points([69, 110, 107, 112, 81, 135, 70, 151, 108, 153],
+                        label=5)
+                ]
+            ),
+            DatasetItem(id='000003', subset='val',
+                image=np.ones((3, 4, 3)),
+                annotations=[
+                    Label(2),
+                    Points([76, 112, 104, 106, 108, 128, 74, 156, 98, 158],
+                        label=2)
+                ],
+                attributes={'5_o_Clock_Shadow': False, 'Arched_Eyebrows': False,
+                    'Attractive': False, 'Bags_Under_Eyes': True, 'Bald': False,
+                    'Bangs': False, 'Big_Lips': False, 'Big_Nose': True}
+            ),
+            DatasetItem(id='000004', subset='test',
+                image=np.ones((3, 4, 3)),
+                annotations=[
+                    Label(10),
+                    Points([72, 113, 108, 108, 101, 138, 71, 155, 101, 151],
+                        label=10)
+                ]
+            ),
+            DatasetItem(id='000005', subset='test',
+                image=np.ones((3, 4, 3)),
+                annotations=[
+                    Label(7),
+                    Points([66, 114, 112, 112, 86, 119, 71, 147, 104, 150],
+                        label=7)
+                    ]
+            )
+        ], categories={
+            AnnotationType.label: LabelCategories.from_iterable(
+                f'class-{i}' for i in range(13)),
+            AnnotationType.points: PointsCategories.from_iterable(
+                [(0, ['lefteye_x']), (1, ['lefteye_y']), (2, ['righteye_x']), (3, ['righteye_y']),
+                 (4, ['nose_x']), (5, ['nose_y']), (6, ['leftmouth_x']), (7, ['leftmouth_y']),
+                 (8, ['rightmouth_x']), (9, ['rightmouth_y'])])
+        })
+
+        dataset = Dataset.import_from(DUMMY_ALIGN_DATASET_DIR, 'align_celeba')
+
+        compare_datasets(self, expected_dataset, dataset, require_images=True)
+
+    @mark_requirement(Requirements.DATUM_475)
+    def test_can_detect_align_dataset(self):
+        self.assertTrue(AlignCelebaImporter.detect(DUMMY_ALIGN_DATASET_DIR))
diff --git a/tests/test_celeba_format.py b/tests/test_celeba_format.py
new file mode 100644
--- /dev/null
+++ b/tests/test_celeba_format.py
@@ -0,0 +1,89 @@
+from unittest import TestCase
+import os.path as osp
+
+import numpy as np
+
+from datumaro.components.annotation import (
+    AnnotationType, Bbox, Label, LabelCategories, Points, PointsCategories,
+)
+from datumaro.components.dataset import Dataset
+from datumaro.components.extractor import DatasetItem
+from datumaro.plugins.celeba_format import CelebaImporter
+from datumaro.util.test_utils import compare_datasets
+
+from .requirements import Requirements, mark_requirement
+
+DUMMY_DATASET_DIR = osp.join(osp.dirname(__file__), 'assets',
+    'celeba_dataset')
+
+class CelebaImporterTest(TestCase):
+    @mark_requirement(Requirements.DATUM_475)
+    def test_can_import(self):
+        expected_dataset = Dataset.from_iterable([
+            DatasetItem(id='000001', subset='train',
+                image=np.ones((5, 5, 3)),
+                annotations=[
+                    Label(12),
+                    Bbox(95, 71, 226, 313, label=12),
+                    Points([165, 184, 244, 176, 196, 249, 194, 271, 266, 260],
+                        label=12)
+                ],
+                attributes={'5_o_Clock_Shadow': False, 'Arched_Eyebrows': True,
+                    'Attractive': True, 'Bags_Under_Eyes': False, 'Bald': False,
+                    'Bangs': False, 'Big_Lips': False, 'Big_Nose': False}
+            ),
+            DatasetItem(id='000002', subset='train',
+                image=np.ones((5, 5, 3)),
+                annotations=[
+                    Label(5),
+                    Bbox(72, 94, 221, 306, label=5),
+                    Points([140, 204, 220, 204, 168, 254, 146, 289, 226, 289],
+                        label=5)
+                ]
+            ),
+            DatasetItem(id='000003', subset='val',
+                image=np.ones((5, 5, 3)),
+                annotations=[
+                    Label(2),
+                    Bbox(216, 59, 91, 126, label=2),
+                    Points([244, 104, 264, 105, 263, 121, 235, 134, 251, 140],
+                        label=2)
+                ],
+                attributes={'5_o_Clock_Shadow': False, 'Arched_Eyebrows': False,
+                    'Attractive': False, 'Bags_Under_Eyes': True, 'Bald': False,
+                    'Bangs': False, 'Big_Lips': False, 'Big_Nose': True}
+            ),
+            DatasetItem(id='000004', subset='test',
+                image=np.ones((5, 5, 3)),
+                annotations=[
+                    Label(10),
+                    Bbox(622, 257, 564, 781, label=10),
+                    Points([796, 539, 984, 539, 930, 687, 762, 756, 915, 756],
+                        label=10)
+                    ]
+            ),
+            DatasetItem(id='000005', subset='test',
+                image=np.ones((5, 5, 3)),
+                annotations=[
+                    Label(7),
+                    Bbox(236, 109, 120, 166, label=7),
+                    Points([273, 169, 328, 161, 298, 172, 283, 208, 323, 207],
+                        label=7)
+                ]
+            )
+        ], categories={
+            AnnotationType.label: LabelCategories.from_iterable(
+                f'class-{i}' for i in range(13)),
+            AnnotationType.points: PointsCategories.from_iterable(
+                [(0, ['lefteye_x']), (1, ['lefteye_y']), (2, ['righteye_x']), (3, ['righteye_y']),
+                 (4, ['nose_x']), (5, ['nose_y']), (6, ['leftmouth_x']), (7, ['leftmouth_y']),
+                 (8, ['rightmouth_x']), (9, ['rightmouth_y'])])
+        })
+
+        dataset = Dataset.import_from(DUMMY_DATASET_DIR, 'celeba')
+
+        compare_datasets(self, expected_dataset, dataset, require_images=True)
+
+    @mark_requirement(Requirements.DATUM_475)
+    def test_can_detect(self):
+        self.assertTrue(CelebaImporter.detect(DUMMY_DATASET_DIR))