Repository: Project-MONAI/MONAI

Issue:
Raise an error for tiff files without color channel
**Is your feature request related to a problem? Please describe.**
`WSIReader` is intended to be used for "Whole Slide Images" as its name shows and not to load arbitrary tiff files.
When a tiff file without the color channel is being loaded using `WSIReader`, currently the different backends handles is differently. Refer to #3858 

 To have a unified interface, all those backends should raise an error in this situations. For different backends:
- [x] OpenSlide: already throw an error
- [ ] CuCIM: repeat the values to create an RGBA image. An issue is open to fix this: https://github.com/rapidsai/cucim/issues/230
- [x] Tiffile: it load a 2D array. This needs to be addressed in WSIReader.



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
@@ -924,6 +924,20 @@ def convert_to_rgb_array(self, raw_region, dtype: DtypeLike = np.uint8):
         # convert to numpy (if not already in numpy)
         raw_region = np.asarray(raw_region, dtype=dtype)
 
+        # check if the image has three dimensions (2D + color)
+        if raw_region.ndim != 3:
+            raise ValueError(
+                f"The input image dimension should be 3 but {raw_region.ndim} is given. "
+                "`WSIReader` is designed to work only with 2D colored images."
+            )
+
+        # check if the color channel is 3 (RGB) or 4 (RGBA)
+        if raw_region.shape[-1] not in [3, 4]:
+            raise ValueError(
+                f"There should be three or four color channels but {raw_region.shape[-1]} is given. "
+                "`WSIReader` is designed to work only with 2D colored images."
+            )
+
         # remove alpha channel if exist (RGBA)
         if raw_region.shape[-1] > 3:
             raw_region = raw_region[..., :3]


Test patch:
diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py
--- a/tests/test_wsireader.py
+++ b/tests/test_wsireader.py
@@ -27,8 +27,8 @@
 
 cucim, has_cucim = optional_import("cucim")
 has_cucim = has_cucim and hasattr(cucim, "CuImage")
-_, has_osl = optional_import("openslide")
-imsave, has_tiff = optional_import("tifffile", name="imsave")
+openslide, has_osl = optional_import("openslide")
+imwrite, has_tiff = optional_import("tifffile", name="imwrite")
 _, has_codec = optional_import("imagecodecs")
 has_tiff = has_tiff and has_codec
 
@@ -84,6 +84,9 @@
 
 TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)]  # CHW
 
+TEST_CASE_ERROR_GRAY = [np.ones((16, 16), dtype=np.uint8)]  # no color channel
+TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)]  # 3D + color
+
 
 def save_rgba_tiff(array: np.ndarray, filename: str, mode: str):
     """
@@ -91,14 +94,28 @@ def save_rgba_tiff(array: np.ndarray, filename: str, mode: str):
 
     Args:
         array: numpy ndarray with the shape of CxHxW and C==3 representing a RGB image
-        file_prefix: the filename to be used for the tiff file. '_RGB.tiff' or '_RGBA.tiff' will be appended to this filename.
+        filename: the filename to be used for the tiff file. '_RGB.tiff' or '_RGBA.tiff' will be appended to this filename.
         mode: RGB or RGBA
     """
     if mode == "RGBA":
         array = np.concatenate([array, 255 * np.ones_like(array[0])[np.newaxis]]).astype(np.uint8)
 
     img_rgb = array.transpose(1, 2, 0)
-    imsave(filename, img_rgb, shape=img_rgb.shape, tile=(16, 16))
+    imwrite(filename, img_rgb, shape=img_rgb.shape, tile=(16, 16))
+
+    return filename
+
+
+def save_gray_tiff(array: np.ndarray, filename: str):
+    """
+    Save numpy array into a TIFF file
+
+    Args:
+        array: numpy ndarray with any shape
+        filename: the filename to be used for the tiff file.
+    """
+    img_gray = array
+    imwrite(filename, img_gray, shape=img_gray.shape, photometric="rgb")
 
     return filename
 
@@ -170,6 +187,17 @@ def test_read_rgba(self, img_expected):
             self.assertIsNone(assert_array_equal(image["RGB"], img_expected))
             self.assertIsNone(assert_array_equal(image["RGBA"], img_expected))
 
+        @parameterized.expand([TEST_CASE_ERROR_GRAY, TEST_CASE_ERROR_3D])
+        @skipUnless(has_tiff, "Requires tifffile.")
+        def test_read_malformats(self, img_expected):
+            reader = WSIReader(self.backend)
+            file_path = save_gray_tiff(
+                img_expected, os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff")
+            )
+            with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)):
+                with reader.read(file_path) as img_obj:
+                    reader.get_data(img_obj)
+
         @parameterized.expand([TEST_CASE_TRANSFORM_0])
         def test_with_dataloader(self, file_path, level, expected_spatial_shape, expected_shape):
             train_transform = Compose(