Repository: openvinotoolkit/datumaro

Issue:
transform with resize also changed the image extension from .jpg to .png
Hi,
thanks for the great tool.

I'm using version `0.2.2`, `Ubuntu 20.04.3 LTS on x86`.
I have a small testing `kitti` dataset, mixed with `.jpg` and `.jpeg` with size `1280x720` and `800x600` images files.
I've tried the steps to use `datum`:
```
datum --version
#0.2.2
mkdir datum_proj_real
cd datum_proj_real
datum create
datum import -f kitti ../my_small_kitti_dataset
```
can see:

> Source 'source-1' with format 'kitti' has been added to the project

then do the transform to unify image size:

`datum transform -t resize -- -dw 800 -dh 600`

now checking the subfolder: `source-1` in project folder: `datum_proj_real`, can see all images files are renamed with `.png` extension, and none of them can be opened by image viewer.

I also tried manually rename one of the file's extension from `png` to `jpg`, and still could not be opened with an error popup like not a valid jpg file.

could you help to check?


Discussion:
Hi, Datumaro should preserve the original image extension by default. The resulting `.png`s are segmentation annotations, and KITTI [uses](http://www.cvlibs.net/datasets/kitti/eval_semseg.php?benchmark=semantics2015) special 16-bit PNG encoding for these files ([they reuse the Cityscapes format](https://github.com/mcordts/cityscapesScripts#the-cityscapes-dataset)), which is not recognized by regular image viewers (as I understand, the purpose is just re-using PNG compression algorithm for storing a mask).

The dataset produced by the `transform` command currently doesn't include images, it is a CLI-only limitation. Some formats store image metainfo, which can be used by `convert` on a dataset produced by the `transform` command. KITTI doesn't have it, so to obtain the images as well, try to use Python API instead:

```python
from datumaro.components.dataset import Dataset

dataset = Dataset.import_from('path/', 'kitti')
dataset.transform('resize', width=800, height=600)
dataset.export('path2/', 'kitti', save_images=True)
```

If you need to work inside a Datumaro project, you can use the lazy transform application by providing the `--apply=False` flag. Then, you can use `export` to produce the resulting dataset.

Resolving patch:
diff --git a/datumaro/components/media.py b/datumaro/components/media.py
--- a/datumaro/components/media.py
+++ b/datumaro/components/media.py
@@ -28,7 +28,7 @@ def path(self) -> str:
 
     @property
     def ext(self) -> str:
-        """Media file extension"""
+        """Media file extension (with the leading dot)"""
         return osp.splitext(osp.basename(self.path))[1]
 
     def __eq__(self, other: object) -> bool:
@@ -42,6 +42,7 @@ def __init__(self,
             data: Union[np.ndarray, Callable[[str], np.ndarray], None] = None,
             *,
             path: Optional[str] = None,
+            ext: Optional[str] = None,
             size: Optional[Tuple[int, int]] = None):
         assert size is None or len(size) == 2, size
         if size is not None:
@@ -58,6 +59,17 @@ def __init__(self,
             path = osp.abspath(path).replace('\\', '/')
         self._path = path
 
+        assert ext is None or isinstance(ext, str), ext
+        if ext:
+            assert not path, "Can't specify both 'path' and 'ext' for image"
+
+            if not ext.startswith('.'):
+                ext = '.' + ext
+            ext = ext.lower()
+        else:
+            ext = None
+        self._ext = ext
+
         if not isinstance(data, np.ndarray):
             assert path or callable(data), "Image can not be empty"
             assert data is None or callable(data)
@@ -75,7 +87,7 @@ def data(self) -> np.ndarray:
             data = self._data
 
         if self._size is None and data is not None:
-            self._size =  tuple(map(int, data.shape[:2]))
+            self._size = tuple(map(int, data.shape[:2]))
         return data
 
     @property
@@ -99,6 +111,14 @@ def size(self) -> Optional[Tuple[int, int]]:
                 self._size = tuple(map(int, data.shape[:2]))
         return self._size
 
+    @property
+    def ext(self) -> str:
+        """Media file extension"""
+        if self._ext is not None:
+            return self._ext
+        else:
+            return osp.splitext(osp.basename(self.path))[1]
+
     def __eq__(self, other):
         if not isinstance(other, __class__):
             return False
@@ -141,7 +161,12 @@ def __init__(self,
             if path and osp.isfile(path) or data:
                 data = lazy_image(path, loader=data)
 
-        super().__init__(path=path, size=size,
+        self._bytes_data = data
+
+        if ext is None and path is None and isinstance(data, bytes):
+            ext = self._guess_ext(data)
+
+        super().__init__(path=path, ext=ext, size=size,
             data=lambda _: decode_image(self.get_bytes()))
         if data is None:
             # We don't expect decoder to produce images from nothing,
@@ -150,15 +175,6 @@ def __init__(self,
             # from the path, when no data is provided.
             self._data = None
 
-        self._bytes_data = data
-        if ext:
-            ext = ext.lower()
-            if not ext.startswith('.'):
-                ext = '.' + ext
-        elif path is None and isinstance(data, bytes):
-            ext = self._guess_ext(data)
-        self._ext = ext
-
     @classmethod
     def _guess_ext(cls, data: bytes) -> Optional[str]:
         return next(
@@ -172,12 +188,6 @@ def get_bytes(self):
             return self._bytes_data()
         return self._bytes_data
 
-    @property
-    def ext(self):
-        if self._ext:
-            return self._ext
-        return super().ext
-
     def save(self, path):
         cur_path = osp.abspath(self.path)
         path = osp.abspath(path)
diff --git a/datumaro/plugins/transforms.py b/datumaro/plugins/transforms.py
--- a/datumaro/plugins/transforms.py
+++ b/datumaro/plugins/transforms.py
@@ -21,9 +21,11 @@
     Points, PointsCategories, Polygon, PolyLine, RleMask,
 )
 from datumaro.components.cli_plugin import CliPlugin
+from datumaro.components.errors import DatumaroError
 from datumaro.components.extractor import (
     DEFAULT_SUBSET_NAME, IExtractor, ItemTransform, Transform,
 )
+from datumaro.components.media import Image
 from datumaro.util import NOTSET, parse_str_enum_value, take_by
 from datumaro.util.annotation_util import find_group_leader, find_instances
 import datumaro.util.mask_tools as mask_tools
@@ -763,21 +765,48 @@ def __init__(self, extractor: IExtractor, width: int, height: int) -> None:
         self._width = width
         self._height = height
 
+    @staticmethod
+    def _lazy_resize_image(image, new_size):
+        def _resize_image(_):
+            h, w = image.size
+            yscale = new_size[0] / float(h)
+            xscale = new_size[1] / float(w)
+
+            # LANCZOS4 is preferable for upscaling, but it works quite slow
+            method = cv2.INTER_AREA if (xscale * yscale) < 1 \
+                else cv2.INTER_CUBIC
+
+            resized_image = cv2.resize(image.data / 255.0, new_size[::-1],
+                interpolation=method)
+            resized_image *= 255.0
+            return resized_image
+
+        return Image(_resize_image, ext=image.ext, size=new_size)
+
+    @staticmethod
+    def _lazy_resize_mask(mask, new_size):
+        def _resize_image():
+            # Can use only NEAREST for masks,
+            # because we can't have interpolated values
+            rescaled_mask = cv2.resize(mask.image.astype(np.float32),
+                new_size[::-1], interpolation=cv2.INTER_NEAREST)
+            return rescaled_mask.astype(np.uint8)
+        return _resize_image
+
     def transform_item(self, item):
         if not item.has_image:
-            raise Exception("Image info is required for this transform")
+            raise DatumaroError("Item %s: image info is required for this "
+                "transform" % (item.id, ))
 
         h, w = item.image.size
         xscale = self._width / float(w)
         yscale = self._height / float(h)
 
+        new_size = (self._height, self._width)
+
+        resized_image = None
         if item.image.has_data:
-            # LANCZOS4 is preferable for upscaling, but it works quite slow
-            method = cv2.INTER_AREA if (xscale * yscale) < 1 \
-                else cv2.INTER_CUBIC
-            image = item.image.data / 255.0
-            resized_image = cv2.resize(image, (self._width, self._height),
-                interpolation=method)
+            resized_image = self._lazy_resize_image(item.image, new_size)
 
         resized_annotations = []
         for ann in item.annotations:
@@ -798,18 +827,8 @@ def transform_item(self, item):
                     ]
                 ))
             elif isinstance(ann, Mask):
-                # Can use only NEAREST for masks,
-                # because we can't have interpolated values
-                rescaled_mask = cv2.resize(ann.image.astype(np.float32),
-                    (self._width, self._height),
-                    interpolation=cv2.INTER_NEAREST).astype(np.uint8)
-
-                if isinstance(ann, RleMask):
-                    rle = mask_tools.mask_to_rle(rescaled_mask)
-                    resized_annotations.append(ann.wrap(
-                        rle=mask_utils.frPyObjects(rle, *rle['size'])))
-                else:
-                    resized_annotations.append(ann.wrap(image=rescaled_mask))
+                rescaled_mask = self._lazy_resize_mask(ann, new_size)
+                resized_annotations.append(ann.wrap(image=rescaled_mask))
             elif isinstance(ann, (Caption, Label)):
                 resized_annotations.append(ann)
             else:


Test patch:
diff --git a/tests/requirements.py b/tests/requirements.py
--- a/tests/requirements.py
+++ b/tests/requirements.py
@@ -13,6 +13,8 @@ class DatumaroComponent:
 
 
 class Requirements:
+    # Please, maintain the ordering when adding new lines
+
     # Exact requirements
     DATUM_GENERAL_REQ = "Datumaro general requirement"
     DATUM_TELEMETRY = "Datumaro telemetry requirement"
@@ -46,6 +48,8 @@ class Requirements:
     DATUM_BUG_560 = "Reading MOT dataset with seqinfo produces 0-based indexing in frames"
     DATUM_BUG_583 = "Empty lines in VOC subset lists are not ignored"
     DATUM_BUG_602 = "Patch command example error"
+    DATUM_BUG_606 = "transform with resize also changed the image extension from .jpg to .png"
+    DATUM_BUG_618 = "ResizeTransform returns broken image pixels"
 
 
 class SkipMessages:
diff --git a/tests/test_images.py b/tests/test_images.py
--- a/tests/test_images.py
+++ b/tests/test_images.py
@@ -82,8 +82,11 @@ def test_ctors(self):
                 { 'data': image },
                 { 'data': image, 'path': path },
                 { 'data': image, 'path': path, 'size': (2, 4) },
+                { 'data': image, 'ext': 'png' },
+                { 'data': image, 'ext': 'png', 'size': (2, 4) },
                 { 'data': lambda p: image },
                 { 'data': lambda p: image, 'path': 'somepath' },
+                { 'data': lambda p: image, 'ext': 'jpg' },
                 { 'path': path },
                 { 'path': path, 'data': load_image },
                 { 'path': path, 'data': load_image, 'size': (2, 4) },
@@ -95,6 +98,16 @@ def test_ctors(self):
                     np.testing.assert_array_equal(img.data, image)
                     self.assertEqual(img.size, tuple(image.shape[:2]))
 
+    @mark_requirement(Requirements.DATUM_GENERAL_REQ)
+    def test_ctor_errors(self):
+        with self.subTest('no data specified'):
+            with self.assertRaisesRegex(Exception, "can not be empty"):
+                Image(ext='jpg', size=(1, 2))
+
+        with self.subTest('either path or ext'):
+            with self.assertRaisesRegex(Exception, "both 'path' and 'ext'"):
+                Image(path='somepath', ext='someext')
+
 class BytesImageTest(TestCase):
     @mark_requirement(Requirements.DATUM_GENERAL_REQ)
     def test_lazy_image_shape(self):
diff --git a/tests/test_transforms.py b/tests/test_transforms.py
--- a/tests/test_transforms.py
+++ b/tests/test_transforms.py
@@ -8,12 +8,13 @@
     PointsCategories, Polygon, PolyLine,
 )
 from datumaro.components.extractor import DatasetItem
+from datumaro.components.media import Image
 from datumaro.components.project import Dataset
 from datumaro.util.test_utils import compare_datasets
 import datumaro.plugins.transforms as transforms
 import datumaro.util.mask_tools as mask_tools
 
-from .requirements import Requirements, mark_requirement
+from .requirements import Requirements, mark_bug, mark_requirement
 
 
 class TransformsTest(TestCase):
@@ -568,9 +569,10 @@ def test_bboxes_values_decrement_transform(self):
         compare_datasets(self, dst_dataset, actual)
 
     @mark_requirement(Requirements.DATUM_GENERAL_REQ)
+    @mark_bug(Requirements.DATUM_BUG_618)
     def test_can_resize(self):
         small_dataset = Dataset.from_iterable([
-            DatasetItem(id=1, image=np.zeros((4, 4)), annotations=[
+            DatasetItem(id=i, image=np.ones((4, 4)) * i, annotations=[
                 Label(1),
                 Bbox(1, 1, 2, 2, label=2),
                 Polygon([1, 1, 1, 2, 2, 2, 2, 1], label=1),
@@ -582,11 +584,11 @@ def test_can_resize(self):
                     [0, 1, 1, 0],
                     [1, 1, 0, 0],
                 ]))
-            ])
+            ]) for i in range(3)
         ], categories=['a', 'b', 'c'])
 
         big_dataset = Dataset.from_iterable([
-            DatasetItem(id=1, image=np.zeros((8, 8)), annotations=[
+            DatasetItem(id=i, image=np.ones((8, 8)) * i, annotations=[
                 Label(1),
                 Bbox(2, 2, 4, 4, label=2),
                 Polygon([2, 2, 2, 4, 4, 4, 4, 2], label=1),
@@ -602,7 +604,7 @@ def test_can_resize(self):
                     [1, 1, 1, 1, 0, 0, 0, 0],
                     [1, 1, 1, 1, 0, 0, 0, 0],
                 ]))
-            ])
+            ]) for i in range(3)
         ], categories=['a', 'b', 'c'])
 
         with self.subTest('upscale'):
@@ -612,3 +614,17 @@ def test_can_resize(self):
         with self.subTest('downscale'):
             actual = transforms.ResizeTransform(big_dataset, width=4, height=4)
             compare_datasets(self, small_dataset, actual)
+
+    @mark_bug(Requirements.DATUM_BUG_606)
+    def test_can_keep_image_ext_on_resize(self):
+        expected = Image(np.ones((8, 4)), ext='jpg')
+
+        dataset = Dataset.from_iterable([
+            DatasetItem(id=1, image=Image(np.ones((4, 2)), ext='jpg'))
+        ])
+
+        dataset.transform('resize', width=4, height=8)
+
+        actual = dataset.get('1').image
+        self.assertEqual(actual.ext, expected.ext)
+        self.assertTrue(np.array_equal(actual.data, expected.data))