Repository: Project-MONAI/MONAI

Issue:
Specify required type in LoadPNG
**Is your feature request related to a problem? Please describe.**
My dataset consists of a series of images  part of which are saved in RGBA format, others are saved as simple 1 channel images. All of the images are gray-scale, however not of all of then are saved in such format. This causes a problems when I use `LoadPNG` transform since some images are loaded as arrays of size [1024, 1024] and others are of size [1024, 1024, 4]. 

Would it be possible to add argument to specify the expected img format (e.g. RGB)?

**Describe the solution you'd like**
Argument of the specified transformation to change images of all types to the specified format (eg. RGB) 

**Describe alternatives you've considered**
Loading images using PIL Image gives the effect I expect, however there is a problem with smoothly adding it to the Monai pipeline. 

```python
img = Image.open(PATH)
img = img.convert('RGB')
```


Discussion:

Resolving patch:
diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py
--- a/monai/data/image_reader.py
+++ b/monai/data/image_reader.py
@@ -10,7 +10,7 @@
 # limitations under the License.
 
 from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
 
 import numpy as np
 
@@ -25,11 +25,13 @@
     import nibabel as nib
     from itk import Image  # type: ignore
     from nibabel.nifti1 import Nifti1Image
+    from PIL import Image as PILImage
 else:
     itk, _ = optional_import("itk", allow_namespace_pkg=True)
     Image, _ = optional_import("itk", allow_namespace_pkg=True, name="Image")
     nib, _ = optional_import("nibabel")
     Nifti1Image, _ = optional_import("nibabel.nifti1", name="Nifti1Image")
+    PILImage, _ = optional_import("PIL.Image")
 
 
 class ImageReader(ABC):
@@ -356,7 +358,6 @@ class NumpyReader(ImageReader):
     Load NPY or NPZ format data based on Numpy library, they can be arrays or pickled objects.
     A typical usage is to load the `mask` data for classification task.
     It can load part of the npz file with specified `npz_keys`.
-
     Args:
         npz_keys: if loading npz file, only load the specified keys, if None, load all the items.
             stack the loaded items together to construct a new first dimension.
@@ -376,11 +377,9 @@ def __init__(self, npz_keys: Optional[KeysCollection] = None, **kwargs):
     def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool:
         """
         Verify whether the specified file or files format is supported by Numpy reader.
-
         Args:
             filename: file name or a list of file names to read.
                 if a list of files, verify all the subffixes.
-
         """
         suffixes: Sequence[str] = ["npz", "npy"]
         return is_supported_format(filename, suffixes)
@@ -389,8 +388,7 @@ def read(self, data: Union[Sequence[str], str, np.ndarray]):
         """
         Read image data from specified file or files, or set a Numpy array.
         Note that the returned object is Numpy array or list of Numpy arrays.
-        `self._img` is always a list, even only has 1 image.
-
+        `self._img` is always a list, even only has 1 image
         Args:
             data: file name or a list of file names to read.
 
@@ -420,7 +418,6 @@ def get_data(self):
         It constructs `spatial_shape=data.shape` and stores in meta dict if the data is numpy array.
         If loading a list of files, stack them together and add a new dimension as first dimension,
         and use the meta data of the first image to represent the stacked result.
-
         """
         img_array: List[np.ndarray] = list()
         compatible_meta: Dict = None
@@ -441,3 +438,104 @@ def get_data(self):
 
         img_array_ = np.stack(img_array, axis=0) if len(img_array) > 1 else img_array[0]
         return img_array_, compatible_meta
+
+
+class PILReader(ImageReader):
+    """
+    Load common 2D image format (supports PNG, JPG, BMP) file or files from provided path.
+    Args:
+        converter: additional function to convert the image data after `read()`.
+            for example, use `converter=lambda image: image.convert("LA")` to convert image format.
+        kwargs: additional args for `Image.open` API in `read()`, mode details about available args:
+            https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.open
+    """
+
+    def __init__(self, converter: Optional[Callable] = None, **kwargs):
+        super().__init__()
+        self._img: Optional[Sequence[PILImage.Image]] = None
+        self.converter = converter
+        self.kwargs = kwargs
+
+    def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool:
+        """
+        Verify whether the specified file or files format is supported by PIL reader.
+        Args:
+            filename: file name or a list of file names to read.
+                if a list of files, verify all the subffixes.
+        """
+        suffixes: Sequence[str] = ["png", "jpg", "bmp"]
+        return is_supported_format(filename, suffixes)
+
+    def read(self, data: Union[Sequence[str], str, np.ndarray]):
+        """
+        Read image data from specified file or files, or set a PIL image.
+        Note that the returned object is PIL image or list of PIL image.
+        `self._img` is always a list, even only has 1 image.
+        Args:
+            data: file name or a list of file names to read.
+        """
+        self._img = list()
+        if isinstance(data, PILImage.Image):
+            if callable(self.converter):
+                data = self.converter(data)
+            self._img.append(data)
+            return data
+
+        filenames: Sequence[str] = ensure_tuple(data)
+        for name in filenames:
+            img = PILImage.open(name, **self.kwargs)
+            if callable(self.converter):
+                img = self.converter(img)
+            self._img.append(img)
+
+        return self._img if len(filenames) > 1 else self._img[0]
+
+    def get_data(self):
+        """
+        Extract data array and meta data from loaded data and return them.
+        This function returns 2 objects, first is numpy array of image data, second is dict of meta data.
+        It constructs `spatial_shape` and stores in meta dict.
+        If loading a list of files, stack them together and add a new dimension as first dimension,
+        and use the meta data of the first image to represent the stacked result.
+        """
+        img_array: List[np.ndarray] = list()
+        compatible_meta: Dict = None
+        if self._img is None:
+            raise RuntimeError("please call read() first then use get_data().")
+
+        for img in self._img:
+            header = self._get_meta_dict(img)
+            header["spatial_shape"] = self._get_spatial_shape(img)
+            img_array.append(np.asarray(img))
+
+            if compatible_meta is None:
+                compatible_meta = header
+            else:
+                if not np.allclose(header["spatial_shape"], compatible_meta["spatial_shape"]):
+                    raise RuntimeError("spatial_shape of all images should be same.")
+
+        img_array_ = np.stack(img_array, axis=0) if len(img_array) > 1 else img_array[0]
+        return img_array_, compatible_meta
+
+    def _get_meta_dict(self, img: PILImage.Image) -> Dict:
+        """
+        Get the all the meta data of the image and convert to dict type.
+        Args:
+            img: a PIL Image object loaded from a image file.
+
+        """
+        meta = dict()
+        meta["format"] = img.format
+        meta["mode"] = img.mode
+        meta["width"] = img.width
+        meta["height"] = img.height
+        meta["info"] = img.info
+        return meta
+
+    def _get_spatial_shape(self, img: PILImage.Image) -> Sequence:
+        """
+        Get the spatial shape of image data, it doesn't contain the channel dim.
+        Args:
+            img: a PIL Image object loaded from a image file.
+        """
+        return [img.width, img.height]
diff --git a/monai/data/utils.py b/monai/data/utils.py
--- a/monai/data/utils.py
+++ b/monai/data/utils.py
@@ -13,6 +13,7 @@
 import os
 import warnings
 from itertools import product, starmap
+from pathlib import PurePath
 from typing import Dict, Generator, List, Optional, Sequence, Tuple, Union
 
 import numpy as np
@@ -519,20 +520,13 @@ def is_supported_format(filename: Union[Sequence[str], str], suffixes: Sequence[
     Args:
         filename: file name or a list of file names to read.
             if a list of files, verify all the subffixes.
-        suffixes: all the supported image subffixes of current reader.
+        suffixes: all the supported image subffixes of current reader, must be a list of lower case suffixes.
 
     """
-    supported_format: bool = True
     filenames: Sequence[str] = ensure_tuple(filename)
     for name in filenames:
-        tokens: Sequence[str] = name.split(".")
-        passed: bool = False
-        for i in range(len(tokens) - 1):
-            if ".".join(tokens[-(i + 2) : -1]) in suffixes:
-                passed = True
-                break
-        if not passed:
-            supported_format = False
-            break
-
-    return supported_format
+        tokens: Sequence[str] = PurePath(name).suffixes
+        if len(tokens) == 0 or not any(("." + s.lower()) == "".join(tokens) for s in suffixes):
+            return False
+
+    return True


Test patch:
diff --git a/tests/test_is_supported_format.py b/tests/test_is_supported_format.py
new file mode 100644
--- /dev/null
+++ b/tests/test_is_supported_format.py
@@ -0,0 +1,51 @@
+# Copyright 2020 MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#     http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+from parameterized import parameterized
+
+from monai.data import is_supported_format
+
+TEST_CASE_1 = [
+    {"filename": "testfile.nii.gz", "suffixes": ["nii", "nii.gz"]},
+    True,
+]
+
+TEST_CASE_2 = [
+    {"filename": "./testfile.nii.gz", "suffixes": ["nii", "nii.gz"]},
+    True,
+]
+
+TEST_CASE_3 = [
+    {"filename": "./test.data/file.nii.gz", "suffixes": ["nii", "nii.gz"]},
+    True,
+]
+
+TEST_CASE_4 = [
+    {"filename": "./test.data/file.nii", "suffixes": ["nii", "nii.gz"]},
+    True,
+]
+
+TEST_CASE_5 = [
+    {"filename": "C:\\documents\\testfile.nii.gz", "suffixes": ["nii", "nii.gz"]},
+    True,
+]
+
+
+class TestIsSupportedFormat(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5])
+    def test_value(self, input_param, result):
+        self.assertEqual(is_supported_format(**input_param), result)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_pil_reader.py b/tests/test_pil_reader.py
new file mode 100644
--- /dev/null
+++ b/tests/test_pil_reader.py
@@ -0,0 +1,77 @@
+# Copyright 2020 MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#     http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import tempfile
+import unittest
+
+import numpy as np
+from parameterized import parameterized
+from PIL import Image
+
+from monai.data import PILReader
+
+TEST_CASE_1 = [(128, 128), ["test_image.png"], (128, 128), (128, 128)]
+
+TEST_CASE_2 = [(128, 128, 3), ["test_image.png"], (128, 128, 3), (128, 128)]
+
+TEST_CASE_3 = [(128, 128, 4), ["test_image.png"], (128, 128, 4), (128, 128)]
+
+TEST_CASE_4 = [(128, 128), ["test_image1.png", "test_image2.png", "test_image3.png"], (3, 128, 128), (128, 128)]
+
+TEST_CASE_5 = [(128, 128, 3), ["test_image.jpg"], (128, 128, 3), (128, 128)]
+
+TEST_CASE_6 = [(128, 128, 3), ["test_image.bmp"], (128, 128, 3), (128, 128)]
+
+TEST_CASE_7 = [(128, 128, 3), ["test_image.png"], (128, 128, 2), (128, 128)]
+
+
+class TestPNGReader(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6])
+    def test_shape_value(self, data_shape, filenames, expected_shape, meta_shape):
+        test_image = np.random.randint(0, 256, size=data_shape)
+        with tempfile.TemporaryDirectory() as tempdir:
+            for i, name in enumerate(filenames):
+                filenames[i] = os.path.join(tempdir, name)
+                Image.fromarray(test_image.astype("uint8")).save(filenames[i])
+            reader = PILReader(mode="r")
+            reader.read(filenames)
+            result = reader.get_data()
+            # load image by PIL and compare the result
+            test_image = np.asarray(Image.open(filenames[0]))
+
+        self.assertTupleEqual(tuple(result[1]["spatial_shape"]), meta_shape)
+        self.assertTupleEqual(result[0].shape, expected_shape)
+        if result[0].shape == test_image.shape:
+            np.testing.assert_allclose(result[0], test_image)
+        else:
+            np.testing.assert_allclose(result[0], np.tile(test_image, [result[0].shape[0], 1, 1]))
+
+    @parameterized.expand([TEST_CASE_7])
+    def test_converter(self, data_shape, filenames, expected_shape, meta_shape):
+        test_image = np.random.randint(0, 256, size=data_shape)
+        with tempfile.TemporaryDirectory() as tempdir:
+            for i, name in enumerate(filenames):
+                filenames[i] = os.path.join(tempdir, name)
+                Image.fromarray(test_image.astype("uint8")).save(filenames[i])
+            reader = PILReader(converter=lambda image: image.convert("LA"), mode="r")
+            reader.read(filenames)
+            result = reader.get_data()
+            # load image by PIL and compare the result
+            test_image = np.asarray(Image.open(filenames[0]).convert("LA"))
+
+        self.assertTupleEqual(tuple(result[1]["spatial_shape"]), meta_shape)
+        self.assertTupleEqual(result[0].shape, expected_shape)
+        np.testing.assert_allclose(result[0], test_image)
+
+
+if __name__ == "__main__":
+    unittest.main()