Repository: Project-MONAI/MONAI

Issue:
`tifffile` support in WSIReader
**Is your feature request related to a problem? Please describe.**
WSIReader currently supports `cuCIM` and `OpenSlide`, which can read patches from very large whole slide images without loading the entire image into memory. However, `tifffile` is another popular library to load TIFF files (which the most common format for whole slide imaging). Although it does not support extraction without loading the whole image, there are use cases that loading the whole slide image is desirable and `tifffile` can do it faster than `cuCIM` and `OpenSlide`.Thus supporting `tifffile` in `WSIReader` would be helpful.


Related issue: https://github.com/Project-MONAI/MONAI/issues/3161


Discussion:

Resolving patch:
diff --git a/monai/apps/pathology/transforms/spatial/array.py b/monai/apps/pathology/transforms/spatial/array.py
--- a/monai/apps/pathology/transforms/spatial/array.py
+++ b/monai/apps/pathology/transforms/spatial/array.py
@@ -28,9 +28,9 @@ class SplitOnGrid(Transform):
             If it's an integer, the value will be repeated for each dimension. Default is 2x2
         patch_size: a tuple or an integer that defines the output patch sizes.
             If it's an integer, the value will be repeated for each dimension.
-            The default is (0, 0), where the patch size will be infered from the grid shape.
+            The default is (0, 0), where the patch size will be inferred from the grid shape.
 
-    Note: the shape of the input image is infered based on the first image used.
+    Note: the shape of the input image is inferred based on the first image used.
     """
 
     def __init__(
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
@@ -676,10 +676,15 @@ class WSIReader(ImageReader):
     Read whole slide images and extract patches.
 
     Args:
-        backend: backend library to load the images, available options: "OpenSlide" or "cuCIM".
+        backend: backend library to load the images, available options: "cuCIM", "OpenSlide" and "Tifffile".
         level: the whole slide image level at which the image is extracted. (default=0)
-            Note that this is overridden by the level argument in `get_data`.
+            This is overridden if the level argument is provided in `get_data`.
 
+    Note:
+        While "cucim" and "OpenSlide" backends both can load patches from large whole slide images
+        without loading the entire image into memory, "Tifffile" backend needs to load the entire image into memory
+        before extracting any patch; thus, memory consideration is needed when using "Tifffile" backend for
+        patch extraction.
     """
 
     def __init__(self, backend: str = "OpenSlide", level: int = 0):
@@ -689,8 +694,10 @@ def __init__(self, backend: str = "OpenSlide", level: int = 0):
             self.wsi_reader, *_ = optional_import("openslide", name="OpenSlide")
         elif self.backend == "cucim":
             self.wsi_reader, *_ = optional_import("cucim", name="CuImage")
+        elif self.backend == "tifffile":
+            self.wsi_reader, *_ = optional_import("tifffile", name="TiffFile")
         else:
-            raise ValueError('`backend` should be either "cuCIM" or "OpenSlide"')
+            raise ValueError('`backend` should be "cuCIM", "OpenSlide", or "TiffFile')
         self.level = level
 
     def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool:
@@ -780,13 +787,22 @@ def _extract_region(
         level: int = 0,
         dtype: DtypeLike = np.uint8,
     ):
-        # reverse the order of dimensions for size and location to be compatible with image shape
-        location = location[::-1]
-        if size is None:
-            region = img_obj.read_region(location=location, level=level)
+        if self.backend == "tifffile":
+            with img_obj:
+                region = img_obj.asarray(level=level)
+            if size is None:
+                region = region[location[0] :, location[1] :]
+            else:
+                region = region[location[0] : location[0] + size[0], location[1] : location[1] + size[1]]
+
         else:
-            size = size[::-1]
-            region = img_obj.read_region(location=location, size=size, level=level)
+            # reverse the order of dimensions for size and location to be compatible with image shape
+            location = location[::-1]
+            if size is None:
+                region = img_obj.read_region(location=location, level=level)
+            else:
+                size = size[::-1]
+                region = img_obj.read_region(location=location, size=size, level=level)
 
         region = self.convert_to_rgb_array(region, dtype)
         return region
@@ -796,15 +812,12 @@ def convert_to_rgb_array(self, raw_region, dtype: DtypeLike = np.uint8):
         if self.backend == "openslide":
             # convert to RGB
             raw_region = raw_region.convert("RGB")
-            # convert to numpy
-            raw_region = np.asarray(raw_region, dtype=dtype)
-        else:
-            num_channels = len(raw_region.channel_names)
-            # convert to numpy
-            raw_region = np.asarray(raw_region, dtype=dtype)
-            # remove alpha channel if exist (RGBA)
-            if num_channels > 3:
-                raw_region = raw_region[:, :, :3]
+
+        # convert to numpy (if not already in numpy)
+        raw_region = np.asarray(raw_region, dtype=dtype)
+        # remove alpha channel if exist (RGBA)
+        if raw_region.shape[-1] > 3:
+            raw_region = raw_region[..., :3]
 
         return raw_region
 


Test patch:
diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py
--- a/tests/test_wsireader.py
+++ b/tests/test_wsireader.py
@@ -112,6 +112,8 @@ def test_read_whole_image(self, file_path, level, expected_shape):
         def test_read_region(self, file_path, patch_info, expected_img):
             reader = WSIReader(self.backend)
             img_obj = reader.read(file_path)
+            # Read twice to check multiple calls
+            img = reader.get_data(img_obj, **patch_info)[0]
             img = reader.get_data(img_obj, **patch_info)[0]
             self.assertTupleEqual(img.shape, expected_img.shape)
             self.assertIsNone(assert_array_equal(img, expected_img))
@@ -171,5 +173,12 @@ def setUpClass(cls):
         cls.backend = "openslide"
 
 
+@skipUnless(has_tiff, "Requires TiffFile")
+class TestTiffFile(WSIReaderTests.Tests):
+    @classmethod
+    def setUpClass(cls):
+        cls.backend = "tifffile"
+
+
 if __name__ == "__main__":
     unittest.main()