Repository: Project-MONAI/MONAI

Issue:
`WSIReader` does not support `TiffFile` backend
`image_reader.WSIReader` supports tifffile (https://github.com/Project-MONAI/MONAI/blob/e4624678cc15b5a5670776715c935279f7837c89/monai/data/image_reader.py#L1257), but it does not supported in `wsi_reader.WSIReader`. Can I ask what is the purpose here to remove the supports?

I'm not sure but it seems `tifffile` loads faster in some cases.
For example:
![Screen Shot 2022-08-12 at 6 18 58 PM](https://user-images.githubusercontent.com/68361391/184334999-e4aef331-12f0-47b2-b643-2422f86802d5.png)




Discussion:
May need @drbeh 's help here : )
Hi @yiheng-wang-nv, thank you very much to raise this issue. Could you please let me know what is the input image that you are using? and also your environment?

We have intentionally dropped tifffile after cucim reported to be faster than tifffile in reading the entire image to make the code base simpler and more maintainable. Here is the benchmarking code: https://gist.github.com/gigony/260d152a83519614ca8c46df551f0d57


Hi @drbeh , the input image is from: https://www.kaggle.com/competitions/mayo-clinic-strip-ai/data
Thanks, @yiheng-wang-nv. Can you also provide information on your environment?
```
python -c 'import monai; monai.config.print_debug_info()'
```
Hi @yiheng-wang-nv, I have tested the two backend to load `'006388_0.tif'` and the results were the other way around. Can you please check your experiment again? Also be aware of how your memory is being managed since the size of this single file is about 24GB on RAM. 
Here is what I get:

Tifffile:
```
CPU times: user 1min 4s, sys: 19.6 s, total: 1min 24s
Wall time: 27.5 s
```
CuCIM:
```
CPU times: user 15.3 s, sys: 8.89 s, total: 24.2 s
Wall time: 24.2 s
```
> Hi @yiheng-wang-nv, I have tested the two backend to load `'006388_0.tif'` and the results were the other way around. Can you please check your experiment again? Also be aware of how your memory is being managed since the size of this single file is about 24GB on RAM. Here is what I get:
> 
> Tifffile:
> 
> ```
> CPU times: user 1min 4s, sys: 19.6 s, total: 1min 24s
> Wall time: 27.5 s
> ```
> 
> CuCIM:
> 
> ```
> CPU times: user 15.3 s, sys: 8.89 s, total: 24.2 s
> Wall time: 24.2 s
> ```

Thanks @drbeh . I used a machine with high RAM, I'm sure my experiment is correct, but it may really depend on which machine is used. 
the version I used is: `0.9.1rc5+1.g6338ea4e`
@yiheng-wang-nv, can you please run the experiment again with the order of loading reversed? (first cucim and then tifffile)

also it would be helpful to provide your environment info. Thanks
```
python -c 'import monai; monai.config.print_debug_info()'
```
FWIW, `006388_0.tif` is not really a WSI, more a regular TIFF: no pyramid levels, wrong resolution metadata, small tiles, ZIP compression, horizontal predictor.
![Screen Shot 2022-08-15 at 1 09 05 PM](https://user-images.githubusercontent.com/68361391/184579837-b5ab5052-4903-4f0b-a388-48f20a51f280.png)
Hi @drbeh , I re-tested it with monai 0.9.1, and check cucim first.
Hi @gigony, @grlee77,
Do you know why Yiheng is experiencing slower loading of this tif file using cucim?
Thanks
Hi @yiheng-wang-nv and @drbeh 

Image information

```
❯ tiffinfo notebooks/input/006388_0.tif
TIFF Directory at offset 0x4e3268cc (1311926476)
  Image Width: 34007 Image Length: 60797
  Tile Width: 128 Tile Length: 128
  Resolution: 10, 10 pixels/cm
  Bits/Sample: 8
  Sample Format: unsigned integer
  Compression Scheme: AdobeDeflate
  Photometric Interpretation: RGB color
  Orientation: row 0 top, col 0 lhs
  Samples/Pixel: 3
  Planar Configuration: single image plane
  Predictor: horizontal differencing 2 (0x2)
```

Test code

```python
import os
from cucim import CuImage
from contextlib import ContextDecorator
from time import perf_counter
from tifffile import imread
import numpy as np
import torch

import monai
from monai.transforms import LoadImage

loader = LoadImage(reader=monai.data.image_reader.WSIReader,
                   backend="tifffile", image_only=True)
loader_cucim = LoadImage(
    reader=monai.data.wsi_reader.WSIReader, backend="cucim", image_only=True)


class Timer(ContextDecorator):
    def __init__(self, message):
        self.message = message
        self.end = None

    def elapsed_time(self):
        self.end = perf_counter()
        return self.end - self.start

    def __enter__(self):
        self.start = perf_counter()
        return self

    def __exit__(self, exc_type, exc, exc_tb):
        if not self.end:
            self.elapsed_time()
        print("{} : {}".format(self.message, self.end - self.start))


image_file = "/home/gbae/repo/cucim/notebooks/input/006388_0.tif"


with Timer("  Thread elapsed time (tifffile)") as timer:
    image = imread(image_file)
print(f"    type: {type(image)}, shape: {image.shape}, dtype: {image.dtype}")

del image

# https://github.com/rapidsai/cucim/wiki/release_notes_v22.02.00#example-api-usages

with Timer("  Thread elapsed time (cucim, num_workers=1)") as timer:
    img = CuImage(image_file)
    image = img.read_region()
    image = np.asarray(image)
print(f"    type: {type(image)}, shape: {image.shape}, dtype: {image.dtype}")

del image

with Timer("  Thread elapsed time (cucim, num_workers=1, specifying location/size)") as timer:
    img = CuImage(image_file)
    image = img.read_region((0, 0), (34007, 60797))
    image = np.asarray(image)
print(f"    type: {type(image)}, shape: {image.shape}, dtype: {image.dtype}")

del image

num_threads = os.cpu_count()
with Timer(f"  Thread elapsed time (cucim, num_workers={num_threads})") as timer:
    img = CuImage(image_file)
    image = img.read_region(num_workers=num_threads)
    image = np.asarray(image)
print(f"    type: {type(image)}, shape: {image.shape}, dtype: {image.dtype}")

del image

with Timer("  Thread elapsed time (tifffile loader)") as timer:
    image = loader(image_file)
print(f"    type: {type(image)}, shape: {image.shape}, dtype: {image.dtype}")

del image

with Timer("  Thread elapsed time (cucim loader)") as timer:
    image = loader_cucim(image_file)
print(f"    type: {type(image)}, shape: {image.shape}, dtype: {image.dtype}")

del image

with Timer("  Thread elapsed time (mimic monai cucim loader, num_workers=1)") as timer:
    img = CuImage(image_file)
    image = img.read_region(num_workers=1)
    image = np.moveaxis(image, -1, 0)
    image = np.ascontiguousarray(image)
    image = np.asarray(image, dtype=np.float32)
    image = torch.from_numpy(image)
print(f"    type: {type(image)}, shape: {image.shape}, dtype: {image.dtype}")

del image

with Timer(f"  Thread elapsed time (mimic monai cucim loader, num_workers={num_threads})") as timer:
    img = CuImage(image_file)
    image = img.read_region(num_workers=num_threads)
    image = np.moveaxis(image, -1, 0)
    image = np.ascontiguousarray(image)
    image = np.asarray(image, dtype=np.float32)
    image = torch.from_numpy(image)
print(f"    type: {type(image)}, shape: {image.shape}, dtype: {image.dtype}")
```

This is the result
```bash

  Thread elapsed time (tifffile) : 10.761376535985619
    type: <class 'numpy.ndarray'>, shape: (60797, 34007, 3), dtype: uint8
  Thread elapsed time (cucim, num_workers=1) : 6.1665689669316635
    type: <class 'numpy.ndarray'>, shape: (60797, 34007, 3), dtype: uint8
  Thread elapsed time (cucim, num_workers=1, specifying location/size) : 6.136442722985521
    type: <class 'numpy.ndarray'>, shape: (60797, 34007, 3), dtype: uint8
  Thread elapsed time (cucim, num_workers=32) : 1.1633656659396365
    type: <class 'numpy.ndarray'>, shape: (60797, 34007, 3), dtype: uint8
  Thread elapsed time (tifffile loader) : 15.5943678249605
    type: <class 'monai.data.meta_tensor.MetaTensor'>, shape: (3, 60797, 34007), dtype: torch.float32
  Thread elapsed time (cucim loader) : 14.059558755951002
    type: <class 'monai.data.meta_tensor.MetaTensor'>, shape: (3, 60797, 34007), dtype: torch.float32
  Thread elapsed time (mimic monai cucim loader, num_workers=1) : 10.625215215026401
    type: <class 'torch.Tensor'>, shape: torch.Size([3, 60797, 34007]), dtype: torch.float32
  Thread elapsed time (mimic monai cucim loader, num_workers=32) : 5.21964049898088
    type: <class 'torch.Tensor'>, shape: torch.Size([3, 60797, 34007]), dtype: torch.float32
```

@yiheng-wang-nv Since MONAI's image loader force to have `channel first` tensor, if your pipeline works good with `channel last` tensor, please use cuCIM directly (14 secs-> 6 secs for 1 thread, 14 -> 1 sec for 32 thread)

And you can also get similar data with the image loaded by MONAI if you directly use cuCIM. In this case, you can use multithreadeds to load the image faster (5 secs)
And if you use MONAI with the patch (https://github.com/Project-MONAI/MONAI/pull/4934) applied, monai cucim loader will get a similar performance with using cuCIM directly (14 secs -> 10 secs).

In current MONAI's implementation, cuCIM backend has a disadvantage over tifffile backend (For tifffile backend, converting to contiguous array is applied first before converting data type [uint8 -> float32] ).

I think that could be a reason for the differences in a different system.

With #4934 applied, I can see the following results (14 secs to 10 secs for monai cucim loader)

```bash
  Thread elapsed time (tifffile) : 11.544818989932537
    type: <class 'numpy.ndarray'>, shape: (60797, 34007, 3), dtype: uint8
  Thread elapsed time (cucim, num_workers=1) : 5.978483060956933
    type: <class 'numpy.ndarray'>, shape: (60797, 34007, 3), dtype: uint8
  Thread elapsed time (cucim, num_workers=1, specifying location/size) : 6.006091748946346
    type: <class 'numpy.ndarray'>, shape: (60797, 34007, 3), dtype: uint8
  Thread elapsed time (cucim, num_workers=32) : 1.165883683017455
    type: <class 'numpy.ndarray'>, shape: (60797, 34007, 3), dtype: uint8
  Thread elapsed time (tifffile loader) : 15.213442040025257
    type: <class 'monai.data.meta_tensor.MetaTensor'>, shape: (3, 60797, 34007), dtype: torch.float32
  Thread elapsed time (cucim loader) : 10.268190009985119
    type: <class 'monai.data.meta_tensor.MetaTensor'>, shape: (3, 60797, 34007), dtype: torch.float32
  Thread elapsed time (mimic monai cucim loader, num_workers=1) : 10.394060018006712
    type: <class 'torch.Tensor'>, shape: torch.Size([3, 60797, 34007]), dtype: torch.float32
  Thread elapsed time (mimic monai cucim loader, num_workers=32) : 5.20623895409517
    type: <class 'torch.Tensor'>, shape: torch.Size([3, 60797, 34007]), dtype: torch.float32
```

Since data type and tensor shape conversion takes time, it might be a good idea to do that in GPU.

cc @grlee77 @jakirkham


Resolving patch:
diff --git a/monai/data/__init__.py b/monai/data/__init__.py
--- a/monai/data/__init__.py
+++ b/monai/data/__init__.py
@@ -108,7 +108,7 @@
 )
 from .video_dataset import CameraDataset, VideoDataset, VideoFileDataset
 from .wsi_datasets import MaskedPatchWSIDataset, PatchWSIDataset, SlidingPatchWSIDataset
-from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader
+from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, TiffFileWSIReader, WSIReader
 
 with contextlib.suppress(BaseException):
     from multiprocessing.reduction import ForkingPickler
diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py
--- a/monai/data/wsi_reader.py
+++ b/monai/data/wsi_reader.py
@@ -22,8 +22,9 @@
 
 CuImage, _ = optional_import("cucim", name="CuImage")
 OpenSlide, _ = optional_import("openslide", name="OpenSlide")
+TiffFile, _ = optional_import("tifffile", name="TiffFile")
 
-__all__ = ["BaseWSIReader", "WSIReader", "CuCIMWSIReader", "OpenSlideWSIReader"]
+__all__ = ["BaseWSIReader", "WSIReader", "CuCIMWSIReader", "OpenSlideWSIReader", "TiffFileWSIReader"]
 
 
 class BaseWSIReader(ImageReader):
@@ -272,13 +273,17 @@ class WSIReader(BaseWSIReader):
     def __init__(self, backend="cucim", level: int = 0, channel_dim: int = 0, **kwargs):
         super().__init__(level, channel_dim, **kwargs)
         self.backend = backend.lower()
-        self.reader: Union[CuCIMWSIReader, OpenSlideWSIReader]
+        self.reader: Union[CuCIMWSIReader, OpenSlideWSIReader, TiffFileWSIReader]
         if self.backend == "cucim":
             self.reader = CuCIMWSIReader(level=level, channel_dim=channel_dim, **kwargs)
         elif self.backend == "openslide":
             self.reader = OpenSlideWSIReader(level=level, channel_dim=channel_dim, **kwargs)
+        elif self.backend == "tifffile":
+            self.reader = TiffFileWSIReader(level=level, channel_dim=channel_dim, **kwargs)
         else:
-            raise ValueError(f"The supported backends are cucim and openslide, '{self.backend}' was given.")
+            raise ValueError(
+                f"The supported backends are cucim, openslide, and tifffile but '{self.backend}' was given."
+            )
         self.supported_suffixes = self.reader.supported_suffixes
 
     def get_level_count(self, wsi) -> int:
@@ -586,3 +591,125 @@ def get_patch(
         patch = np.moveaxis(patch, -1, self.channel_dim)
 
         return patch
+
+
+@require_pkg(pkg_name="tifffile")
+class TiffFileWSIReader(BaseWSIReader):
+    """
+    Read whole slide images and extract patches using TiffFile library.
+
+    Args:
+        level: the whole slide image level at which the image is extracted. (default=0)
+            This is overridden if the level argument is provided in `get_data`.
+        channel_dim: the desired dimension for color channel. Default to 0 (channel first).
+        kwargs: additional args for `tifffile.TiffFile` module.
+
+    """
+
+    supported_suffixes = ["tif", "tiff", "svs"]
+    backend = "tifffile"
+
+    def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs):
+        super().__init__(level, channel_dim, **kwargs)
+
+    @staticmethod
+    def get_level_count(wsi) -> int:
+        """
+        Returns the number of levels in the whole slide image.
+
+        Args:
+            wsi: a whole slide image object loaded from a file
+
+        """
+        return len(wsi.pages)
+
+    @staticmethod
+    def get_size(wsi, level: int) -> Tuple[int, int]:
+        """
+        Returns the size (height, width) of the whole slide image at a given level.
+
+        Args:
+            wsi: a whole slide image object loaded from a file
+            level: the level number where the size is calculated
+
+        """
+        return (wsi.pages[level].imagelength, wsi.pages[level].imagewidth)
+
+    @staticmethod
+    def get_downsample_ratio(wsi, level: int) -> float:
+        """
+        Returns the down-sampling ratio of the whole slide image at a given level.
+
+        Args:
+            wsi: a whole slide image object loaded from a file
+            level: the level number where the size is calculated
+
+        """
+        return float(wsi.pages[0].imagelength) / float(wsi.pages[level].imagelength)
+
+    def get_file_path(self, wsi) -> str:
+        """Return the file path for the WSI object"""
+        return str(abspath(wsi.filehandle.path))
+
+    def read(self, data: Union[Sequence[PathLike], PathLike, np.ndarray], **kwargs):
+        """
+        Read whole slide image objects from given file or list of files.
+
+        Args:
+            data: file name or a list of file names to read.
+            kwargs: additional args that overrides `self.kwargs` for existing keys.
+
+        Returns:
+            whole slide image object or list of such objects
+
+        """
+        wsi_list: List = []
+
+        filenames: Sequence[PathLike] = ensure_tuple(data)
+        kwargs_ = self.kwargs.copy()
+        kwargs_.update(kwargs)
+        for filename in filenames:
+            wsi = TiffFile(filename, **kwargs_)
+            wsi_list.append(wsi)
+
+        return wsi_list if len(filenames) > 1 else wsi_list[0]
+
+    def get_patch(
+        self, wsi, location: Tuple[int, int], size: Tuple[int, int], level: int, dtype: DtypeLike, mode: str
+    ) -> np.ndarray:
+        """
+        Extracts and returns a patch image form the whole slide image.
+
+        Args:
+            wsi: a whole slide image object loaded from a file or a lis of such objects
+            location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0).
+            size: (height, width) tuple giving the patch size at the given level (`level`).
+                If None, it is set to the full image size at the given level.
+            level: the level number. Defaults to 0
+            dtype: the data type of output image
+            mode: the output image mode, 'RGB' or 'RGBA'
+
+        """
+        # Load the entire image
+        wsi_image: np.ndarray = wsi.asarray(level=level).astype(dtype)
+        if len(wsi_image.shape) < 3:
+            wsi_image = wsi_image[..., None]
+
+        # Extract patch
+        downsampling_ratio = self.get_downsample_ratio(wsi=wsi, level=level)
+        location_ = [round(location[i] / downsampling_ratio) for i in range(len(location))]
+        patch = wsi_image[location_[0] : location_[0] + size[0], location_[1] : location_[1] + size[1], :].copy()
+
+        # Make the channel to desired dimensions
+        patch = np.moveaxis(patch, -1, self.channel_dim)
+
+        # Check if the color channel is 3 (RGB) or 4 (RGBA)
+        if mode in "RGB":
+            if patch.shape[self.channel_dim] not in [3, 4]:
+                raise ValueError(
+                    f"The image is expected to have three or four color channels in '{mode}' mode but has "
+                    f"{patch.shape[self.channel_dim]}. "
+                )
+            patch = patch[:3]
+
+        return patch


Test patch:
diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py
--- a/tests/test_wsireader.py
+++ b/tests/test_wsireader.py
@@ -279,45 +279,41 @@ def test_read_whole_image(self, file_path, level, expected_shape):
         @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4])
         def test_read_region(self, file_path, kwargs, patch_info, expected_img):
             reader = WSIReader(self.backend, **kwargs)
+            if self.backend == "tifffile" and patch_info["level"] < 2:
+                return
             with reader.read(file_path) as img_obj:
-                if self.backend == "tifffile":
-                    with self.assertRaises(ValueError):
-                        reader.get_data(img_obj, **patch_info)[0]
-                else:
-                    # Read twice to check multiple calls
-                    img, meta = reader.get_data(img_obj, **patch_info)
-                    img2 = reader.get_data(img_obj, **patch_info)[0]
-                    self.assertTupleEqual(img.shape, img2.shape)
-                    self.assertIsNone(assert_array_equal(img, img2))
-                    self.assertTupleEqual(img.shape, expected_img.shape)
-                    self.assertIsNone(assert_array_equal(img, expected_img))
-                    self.assertEqual(meta["backend"], self.backend)
-                    self.assertEqual(meta["path"], str(os.path.abspath(file_path)))
-                    self.assertEqual(meta["patch_level"], patch_info["level"])
-                    assert_array_equal(meta["patch_size"], patch_info["size"])
-                    assert_array_equal(meta["patch_location"], patch_info["location"])
+                # Read twice to check multiple calls
+                img, meta = reader.get_data(img_obj, **patch_info)
+                img2 = reader.get_data(img_obj, **patch_info)[0]
+            self.assertTupleEqual(img.shape, img2.shape)
+            self.assertIsNone(assert_array_equal(img, img2))
+            self.assertTupleEqual(img.shape, expected_img.shape)
+            self.assertIsNone(assert_array_equal(img, expected_img))
+            self.assertEqual(meta["backend"], self.backend)
+            self.assertEqual(meta["path"], str(os.path.abspath(file_path)))
+            self.assertEqual(meta["patch_level"], patch_info["level"])
+            assert_array_equal(meta["patch_size"], patch_info["size"])
+            assert_array_equal(meta["patch_location"], patch_info["location"])
 
         @parameterized.expand([TEST_CASE_MULTI_WSI])
         def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img):
             kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {}
             reader = WSIReader(self.backend, **kwargs)
             img_obj_list = reader.read(file_path_list, **kwargs)
-            if self.backend == "tifffile":
-                with self.assertRaises(ValueError):
-                    reader.get_data(img_obj_list, **patch_info)[0]
-            else:
-                # Read twice to check multiple calls
-                img, meta = reader.get_data(img_obj_list, **patch_info)
-                img2 = reader.get_data(img_obj_list, **patch_info)[0]
-                self.assertTupleEqual(img.shape, img2.shape)
-                self.assertIsNone(assert_array_equal(img, img2))
-                self.assertTupleEqual(img.shape, expected_img.shape)
-                self.assertIsNone(assert_array_equal(img, expected_img))
-                self.assertEqual(meta["backend"], self.backend)
-                self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0])))
-                self.assertEqual(meta["patch_level"][0], patch_info["level"])
-                assert_array_equal(meta["patch_size"][0], expected_img.shape[1:])
-                assert_array_equal(meta["patch_location"][0], patch_info["location"])
+            # Read twice to check multiple calls
+            img, meta = reader.get_data(img_obj_list, **patch_info)
+            img2 = reader.get_data(img_obj_list, **patch_info)[0]
+            for img_obj in img_obj_list:
+                img_obj.close()
+            self.assertTupleEqual(img.shape, img2.shape)
+            self.assertIsNone(assert_array_equal(img, img2))
+            self.assertTupleEqual(img.shape, expected_img.shape)
+            self.assertIsNone(assert_array_equal(img, expected_img))
+            self.assertEqual(meta["backend"], self.backend)
+            self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0])))
+            self.assertEqual(meta["patch_level"][0], patch_info["level"])
+            assert_array_equal(meta["patch_size"][0], expected_img.shape[1:])
+            assert_array_equal(meta["patch_location"][0], patch_info["location"])
 
         @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1])
         @skipUnless(has_tiff, "Requires tifffile.")
@@ -452,5 +448,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()