From 8a14a99fe17f19d98595a2a4a74ab459051cc23b Mon Sep 17 00:00:00 2001 From: Kirill Sizov Date: Thu, 14 Sep 2023 12:51:15 +0300 Subject: [PATCH] Keep EXIF rotation during YOLO importing (#27) * keep exif rotation during yolo importing * remove unnecessary changes * sort imports * update license headers * mark tests as xfail * no sec * nosec * black linter * constraint for version of dvc dependencies * add test * fix linters --- datumaro/components/operations.py | 2 +- datumaro/plugins/camvid_format.py | 2 +- datumaro/plugins/imagenet_txt_format.py | 1 - datumaro/plugins/mpii_format/mpii_mat.py | 1 - .../samples/ssd_face_detection_interp.py | 1 - .../ssd_mobilenet_coco_detection_interp.py | 1 - .../samples/ssd_person_detection_interp.py | 1 - ...sd_person_vehicle_bike_detection_interp.py | 1 - .../samples/ssd_vehicle_detection_interp.py | 1 - datumaro/plugins/yolo_format/extractor.py | 20 +++++++++-- datumaro/util/image.py | 16 ++++++--- requirements-default.txt | 2 +- site/build_docs.py | 2 +- tests/cli/test_generator.py | 3 ++ tests/cli/test_image_zip_format.py | 2 +- tests/test_fractal_image_generator.py | 2 ++ tests/test_splitter.py | 3 -- tests/test_validator.py | 1 - tests/test_yolo_format.py | 35 ++++++++++++++++++- 19 files changed, 74 insertions(+), 23 deletions(-) diff --git a/datumaro/components/operations.py b/datumaro/components/operations.py index 93d8879b5c..d71f5b8199 100644 --- a/datumaro/components/operations.py +++ b/datumaro/components/operations.py @@ -527,7 +527,7 @@ def match_items(datasets): item_map = {} # id(item) -> (item, id(dataset)) matches = OrderedDict() - for (item_id, item_subset) in sorted(item_ids, key=lambda e: e[0]): + for item_id, item_subset in sorted(item_ids, key=lambda e: e[0]): items = {} for d in datasets: item = d.get(item_id, subset=item_subset) diff --git a/datumaro/plugins/camvid_format.py b/datumaro/plugins/camvid_format.py index 6bee7dff91..5e5230283a 100644 --- a/datumaro/plugins/camvid_format.py +++ b/datumaro/plugins/camvid_format.py @@ -360,7 +360,7 @@ def save_segm_lists(self, subset_name, segm_list): return with open(ann_file, "w", encoding="utf-8") as f: - for (image_path, mask_path) in segm_list.values(): + for image_path, mask_path in segm_list.values(): image_path = "/" + image_path.replace("\\", "/") mask_path = mask_path.replace("\\", "/") if 1 < len(image_path.split()) or 1 < len(mask_path.split()): diff --git a/datumaro/plugins/imagenet_txt_format.py b/datumaro/plugins/imagenet_txt_format.py index c3547cc64a..dd234d6bd9 100644 --- a/datumaro/plugins/imagenet_txt_format.py +++ b/datumaro/plugins/imagenet_txt_format.py @@ -171,7 +171,6 @@ def build_cmdline_parser(cls, **kwargs): @classmethod def find_sources_with_params(cls, path, **extra_params): if "labels" not in extra_params or extra_params["labels"] == _LabelsSource.file.name: - labels_file_name = osp.basename( extra_params.get("labels_file") or ImagenetTxtPath.LABELS_FILE ) diff --git a/datumaro/plugins/mpii_format/mpii_mat.py b/datumaro/plugins/mpii_format/mpii_mat.py index a74f9b61e7..1df1085777 100644 --- a/datumaro/plugins/mpii_format/mpii_mat.py +++ b/datumaro/plugins/mpii_format/mpii_mat.py @@ -110,7 +110,6 @@ def _load_items(self, path): ) if x1 is not None and x2 is not None and y1 is not None and y2 is not None: - annotations.append(Bbox(x1, y1, x2 - x1, y2 - y1, label=0, group=group_num)) group_num += 1 diff --git a/datumaro/plugins/openvino_plugin/samples/ssd_face_detection_interp.py b/datumaro/plugins/openvino_plugin/samples/ssd_face_detection_interp.py index 0312cf264d..d8be34a28f 100644 --- a/datumaro/plugins/openvino_plugin/samples/ssd_face_detection_interp.py +++ b/datumaro/plugins/openvino_plugin/samples/ssd_face_detection_interp.py @@ -41,7 +41,6 @@ def process_outputs(inputs, outputs): results = [] for input_, detections in zip(inputs, outputs["detection_out"]): - input_height, input_width = input_.shape[:2] confs = outputs["Softmax_189/Softmax_"] diff --git a/datumaro/plugins/openvino_plugin/samples/ssd_mobilenet_coco_detection_interp.py b/datumaro/plugins/openvino_plugin/samples/ssd_mobilenet_coco_detection_interp.py index e486d3df7e..4e74e5cb0a 100644 --- a/datumaro/plugins/openvino_plugin/samples/ssd_mobilenet_coco_detection_interp.py +++ b/datumaro/plugins/openvino_plugin/samples/ssd_mobilenet_coco_detection_interp.py @@ -44,7 +44,6 @@ def process_outputs(inputs, outputs): for input_, confs, detections in zip( inputs, outputs["do_ExpandDims_conf/sigmoid"], outputs["DetectionOutput"] ): - input_height, input_width = input_.shape[:2] confs = confs[0].reshape(-1, model_class_num) diff --git a/datumaro/plugins/openvino_plugin/samples/ssd_person_detection_interp.py b/datumaro/plugins/openvino_plugin/samples/ssd_person_detection_interp.py index d1f272313e..5d2b722b5a 100644 --- a/datumaro/plugins/openvino_plugin/samples/ssd_person_detection_interp.py +++ b/datumaro/plugins/openvino_plugin/samples/ssd_person_detection_interp.py @@ -41,7 +41,6 @@ def process_outputs(inputs, outputs): results = [] for input_, detections in zip(inputs, outputs["detection_out"]): - input_height, input_width = input_.shape[:2] confs = outputs["Softmax_189/Softmax_"] diff --git a/datumaro/plugins/openvino_plugin/samples/ssd_person_vehicle_bike_detection_interp.py b/datumaro/plugins/openvino_plugin/samples/ssd_person_vehicle_bike_detection_interp.py index 49d04c20de..0b5a199e14 100644 --- a/datumaro/plugins/openvino_plugin/samples/ssd_person_vehicle_bike_detection_interp.py +++ b/datumaro/plugins/openvino_plugin/samples/ssd_person_vehicle_bike_detection_interp.py @@ -41,7 +41,6 @@ def process_outputs(inputs, outputs): results = [] for input_, detections in zip(inputs, outputs["detection_out"]): - input_height, input_width = input_.shape[:2] confs = outputs["Softmax_189/Softmax_"] diff --git a/datumaro/plugins/openvino_plugin/samples/ssd_vehicle_detection_interp.py b/datumaro/plugins/openvino_plugin/samples/ssd_vehicle_detection_interp.py index 20ac78517b..2419e9f446 100644 --- a/datumaro/plugins/openvino_plugin/samples/ssd_vehicle_detection_interp.py +++ b/datumaro/plugins/openvino_plugin/samples/ssd_vehicle_detection_interp.py @@ -41,7 +41,6 @@ def process_outputs(inputs, outputs): results = [] for input_, detections in zip(inputs, outputs["detection_out"]): - input_height, input_width = input_.shape[:2] confs = outputs["Softmax_189/Softmax_"] diff --git a/datumaro/plugins/yolo_format/extractor.py b/datumaro/plugins/yolo_format/extractor.py index 1a7212eaee..dd07324b79 100644 --- a/datumaro/plugins/yolo_format/extractor.py +++ b/datumaro/plugins/yolo_format/extractor.py @@ -1,4 +1,5 @@ # Copyright (C) 2019-2022 Intel Corporation +# Copyright (C) 2023 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -18,7 +19,12 @@ from datumaro.components.extractor import DatasetItem, Extractor, Importer, SourceExtractor from datumaro.components.format_detection import FormatDetectionContext from datumaro.components.media import Image -from datumaro.util.image import DEFAULT_IMAGE_META_FILE_NAME, ImageMeta, load_image_meta_file +from datumaro.util.image import ( + DEFAULT_IMAGE_META_FILE_NAME, + ImageMeta, + load_image, + load_image_meta_file, +) from datumaro.util.meta_file_util import has_meta_file, parse_meta_file from datumaro.util.os_util import split_path @@ -156,6 +162,10 @@ def name_from_path(cls, path: str) -> str: return osp.splitext(path)[0] + @classmethod + def _image_loader(cls, *args, **kwargs): + return load_image(*args, **kwargs, keep_exif=True) + def _get(self, item_id: str, subset_name: str) -> Optional[DatasetItem]: subset = self._subsets[subset_name] item = subset.items[item_id] @@ -163,7 +173,12 @@ def _get(self, item_id: str, subset_name: str) -> Optional[DatasetItem]: if isinstance(item, str): try: image_size = self._image_info.get(item_id) - image = Image(path=osp.join(self._path, item), size=image_size) + image_path = osp.join(self._path, item) + + if image_size: + image = Image(path=image_path, size=image_size) + else: + image = Image(path=image_path, data=self._image_loader) anno_path = osp.splitext(image.path)[0] + ".txt" annotations = self._parse_annotations( @@ -228,6 +243,7 @@ def _parse_annotations( h = self._parse_field(h, float, "bbox height") x = self._parse_field(xc, float, "bbox center x") - w * 0.5 y = self._parse_field(yc, float, "bbox center y") - h * 0.5 + annotations.append( Bbox( x * image_width, diff --git a/datumaro/util/image.py b/datumaro/util/image.py index 4054156681..1fdab39c64 100644 --- a/datumaro/util/image.py +++ b/datumaro/util/image.py @@ -1,4 +1,5 @@ # Copyright (C) 2019-2021 Intel Corporation +# Copyright (C) 2023 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -56,7 +57,7 @@ def __getattr__(name: str): raise AttributeError(f"module {__name__} has no attribute {name}") -def load_image(path: str, dtype: DTypeLike = np.float32): +def load_image(path: str, dtype: DTypeLike = np.float32, **kwargs): """ Reads an image in the HWC Grayscale/BGR(A) float [0; 255] format. """ @@ -69,11 +70,18 @@ def load_image(path: str, dtype: DTypeLike = np.float32): with open(path, "rb") as f: image_bytes = f.read() + if kwargs.get("keep_exif"): + return decode_image(image_bytes, dtype=dtype, cv2_read_flag=1) + return decode_image(image_bytes, dtype=dtype) elif _IMAGE_BACKEND == _IMAGE_BACKENDS.PIL: - from PIL import Image + from PIL import Image, ImageOps image = Image.open(path) + + if kwargs.get("keep_exif"): + image = ImageOps.exif_transpose(image) + image = np.asarray(image, dtype=dtype) if len(image.shape) == 3 and image.shape[2] in {3, 4}: image[:, :, :3] = image[:, :, 2::-1] # RGB to BGR @@ -176,12 +184,12 @@ def encode_image(image: np.ndarray, ext: str, dtype: DTypeLike = np.uint8, **kwa raise NotImplementedError() -def decode_image(image_bytes: bytes, dtype: DTypeLike = np.float32) -> np.ndarray: +def decode_image(image_bytes: bytes, dtype: DTypeLike = np.float32, **kwargs) -> np.ndarray: if _IMAGE_BACKEND == _IMAGE_BACKENDS.cv2: import cv2 image = np.frombuffer(image_bytes, dtype=np.uint8) - image = cv2.imdecode(image, cv2.IMREAD_UNCHANGED) + image = cv2.imdecode(image, kwargs.get("cv2_read_flag", cv2.IMREAD_UNCHANGED)) image = image.astype(dtype) elif _IMAGE_BACKEND == _IMAGE_BACKENDS.PIL: from PIL import Image diff --git a/requirements-default.txt b/requirements-default.txt index a70036afd4..171aad9225 100644 --- a/requirements-default.txt +++ b/requirements-default.txt @@ -1,4 +1,4 @@ -dvc>=2.7.0 +dvc>=2.7.0,<3.0.0 fsspec<2023.1; python_version == '3.7' # remove after 3.7 EOL. https://stackoverflow.com/a/75197382 GitPython>=3.1.18,!=3.1.25 # https://github.com/openvinotoolkit/datumaro/issues/612 diff --git a/site/build_docs.py b/site/build_docs.py index 2e74f03b8f..b16e3d1da9 100755 --- a/site/build_docs.py +++ b/site/build_docs.py @@ -58,7 +58,7 @@ def git_checkout(tagname, repo, temp_dir): repo.git.archive(tagname, "--", subdir, output_stream=archive) archive.seek(0) with tarfile.open(fileobj=archive) as tar: - tar.extractall(temp_dir) + tar.extractall(temp_dir) # nosec def change_version_menu_toml(filename, version): diff --git a/tests/cli/test_generator.py b/tests/cli/test_generator.py index 8ba4ab5067..6492fd96b7 100644 --- a/tests/cli/test_generator.py +++ b/tests/cli/test_generator.py @@ -2,6 +2,8 @@ import os.path as osp from unittest import TestCase +import pytest + import datumaro.util.image as image_module from datumaro.util.test_utils import TestDir from datumaro.util.test_utils import run_datum as run @@ -9,6 +11,7 @@ from ..requirements import Requirements, mark_requirement +@pytest.mark.xfail(reason="Cannot download the model file from the source") class ImageGeneratorTest(TestCase): def check_images_shape(self, img_dir, expected_shape): exp_h, exp_w, exp_c = expected_shape diff --git a/tests/cli/test_image_zip_format.py b/tests/cli/test_image_zip_format.py index 24bdce0f7f..afb23d8e29 100644 --- a/tests/cli/test_image_zip_format.py +++ b/tests/cli/test_image_zip_format.py @@ -15,7 +15,7 @@ def make_zip_archive(src_path, dst_path): with ZipFile(dst_path, "w") as archive: - for (dirpath, _, filenames) in os.walk(src_path): + for dirpath, _, filenames in os.walk(src_path): for name in filenames: path = osp.join(dirpath, name) archive.write(path, osp.relpath(path, src_path)) diff --git a/tests/test_fractal_image_generator.py b/tests/test_fractal_image_generator.py index 6469a1d7bf..f0453a3b37 100644 --- a/tests/test_fractal_image_generator.py +++ b/tests/test_fractal_image_generator.py @@ -3,6 +3,7 @@ from unittest import TestCase import numpy as np +import pytest from datumaro.plugins.synthetic_data import FractalImageGenerator from datumaro.util.image import load_image @@ -11,6 +12,7 @@ from .requirements import Requirements, mark_requirement +@pytest.mark.xfail(reason="Cannot download the model file from the source") class FractalImageGeneratorTest(TestCase): @mark_requirement(Requirements.DATUM_677) def test_save_image_can_create_dir(self): diff --git a/tests/test_splitter.py b/tests/test_splitter.py index fde960fd6a..1b0d35439c 100644 --- a/tests/test_splitter.py +++ b/tests/test_splitter.py @@ -927,7 +927,6 @@ def test_no_subset_name_and_count_restriction(self): @mark_requirement(Requirements.DATUM_GENERAL_REQ) def test_split_for_segmentation(self): - with self.subTest("mask annotation"): dtypes = ["coco", "voc", "labelme", "mot"] task = splitter.SplitTask.segmentation.name @@ -1041,7 +1040,6 @@ def test_split_for_segmentation(self): @mark_requirement(Requirements.DATUM_GENERAL_REQ) def test_split_for_segmentation_with_unlabeled(self): - with self.subTest("mask annotation"): source, _ = self._generate_detection_segmentation_dataset( annotation_type=self._get_append_mask("coco"), @@ -1076,7 +1074,6 @@ def test_split_for_segmentation_with_unlabeled(self): @mark_requirement(Requirements.DATUM_GENERAL_REQ) def test_split_for_segmentation_gives_error(self): - with self.subTest("mask annotation"): source, _ = self._generate_detection_segmentation_dataset( annotation_type=self._get_append_mask("coco"), diff --git a/tests/test_validator.py b/tests/test_validator.py index 07908ce01b..c40e553676 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -817,7 +817,6 @@ def test_check_far_from_attr_mean(self): class TestValidateAnnotations(_TestValidatorBase): - extra_args = { "few_samples_thr": 1, "imbalance_ratio_thr": 50, diff --git a/tests/test_yolo_format.py b/tests/test_yolo_format.py index 420b510f63..be102c100e 100644 --- a/tests/test_yolo_format.py +++ b/tests/test_yolo_format.py @@ -1,9 +1,11 @@ import os import os.path as osp import pickle # nosec - disable B403:import_pickle check +import shutil from unittest import TestCase import numpy as np +from PIL import Image as PILImage from datumaro.components.annotation import Bbox from datumaro.components.dataset import Dataset @@ -289,7 +291,6 @@ def test_can_save_and_load_with_custom_subset_name(self): @mark_requirement(Requirements.DATUM_565) def test_cant_save_with_reserved_subset_name(self): for subset in ["backup", "classes"]: - dataset = Dataset.from_iterable( [ DatasetItem( @@ -366,6 +367,38 @@ def test_can_import(self): compare_datasets(self, expected_dataset, dataset) + @mark_requirement(Requirements.DATUM_GENERAL_REQ) + def test_can_import_with_exif_rotated_images(self): + expected_dataset = Dataset.from_iterable( + [ + DatasetItem( + id=1, + subset="train", + media=Image(data=np.ones((15, 10, 3))), + annotations=[ + Bbox(0, 3, 2.67, 3.0, label=2), + Bbox(2, 4.5, 1.33, 4.5, label=4), + ], + ), + ], + categories=["label_" + str(i) for i in range(10)], + ) + + with TestDir() as test_dir: + dataset_path = osp.join(test_dir, "dataset") + shutil.copytree(DUMMY_DATASET_DIR, dataset_path) + + # Add exif rotation for image + image_path = osp.join(dataset_path, "obj_train_data", "1.jpg") + img = PILImage.open(image_path) + exif = img.getexif() + exif.update([(296, 3), (282, 28.0), (531, 1), (274, 6), (283, 28.0)]) + img.save(image_path, exif=exif) + + dataset = Dataset.import_from(dataset_path, "yolo") + + compare_datasets(self, expected_dataset, dataset, require_media=True) + @mark_requirement(Requirements.DATUM_673) def test_can_pickle(self): source = Dataset.import_from(DUMMY_DATASET_DIR, format="yolo")