Repository: Project-MONAI/MONAI

Issue:
Add `OpenSlide` support for new `WSIReader`
**Is your feature request related to a problem? Please describe.**
The new `WSIReader` is currently supporting `cuCIM` as its backend. It would be useful to have `OpenSlide` support as well for the image formats that `cuCIM` is not supporting yet.



Discussion:

Resolving patch:
diff --git a/monai/data/__init__.py b/monai/data/__init__.py
--- a/monai/data/__init__.py
+++ b/monai/data/__init__.py
@@ -87,4 +87,4 @@
     worker_init_fn,
     zoom_affine,
 )
-from .wsi_reader import BaseWSIReader, CuCIMWSIReader, WSIReader
+from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader
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
@@ -809,7 +809,7 @@ def get_data(
 
         Args:
             img: a WSIReader image object loaded from a file, or list of CuImage objects
-            location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame,
+            location: (top, left) tuple giving the top left pixel in the level 0 reference frame,
             or list of tuples (default=(0, 0))
             size: (height, width) tuple giving the region size, or list of tuples (default to full image size)
             This is the size of image at the given level (`level`)
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
@@ -17,12 +17,13 @@
 from monai.config import DtypeLike, PathLike
 from monai.data.image_reader import ImageReader, _stack_images
 from monai.data.utils import is_supported_format
-from monai.transforms.utility.array import EnsureChannelFirst
+from monai.transforms.utility.array import AsChannelFirst
 from monai.utils import ensure_tuple, optional_import, require_pkg
 
 CuImage, _ = optional_import("cucim", name="CuImage")
+OpenSlide, _ = optional_import("openslide", name="OpenSlide")
 
-__all__ = ["BaseWSIReader", "WSIReader", "CuCIMWSIReader"]
+__all__ = ["BaseWSIReader", "WSIReader", "CuCIMWSIReader", "OpenSlideWSIReader"]
 
 
 class BaseWSIReader(ImageReader):
@@ -91,7 +92,7 @@ def get_patch(
 
         Args:
             wsi: a whole slide image object loaded from a file or a lis of such objects
-            location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0).
+            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
@@ -104,11 +105,11 @@ def get_patch(
     @abstractmethod
     def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict:
         """
-        Extracts and returns metadata form the whole slide image.
+        Returns metadata of the extracted patch from the whole slide image.
 
         Args:
             patch: extracted patch from whole slide image
-            location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0).
+            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
@@ -130,7 +131,7 @@ def get_data(
 
         Args:
             wsi: a whole slide image object loaded from a file or a list of such objects
-            location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0).
+            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
@@ -216,9 +217,11 @@ class WSIReader(BaseWSIReader):
     def __init__(self, backend="cucim", level: int = 0, **kwargs):
         super().__init__(level, **kwargs)
         self.backend = backend.lower()
-        # Any new backend can be added below
+        self.reader: Union[CuCIMWSIReader, OpenSlideWSIReader]
         if self.backend == "cucim":
             self.reader = CuCIMWSIReader(level=level, **kwargs)
+        elif self.backend == "openslide":
+            self.reader = OpenSlideWSIReader(level=level, **kwargs)
         else:
             raise ValueError("The supported backends are: cucim")
         self.supported_suffixes = self.reader.supported_suffixes
@@ -233,7 +236,7 @@ def get_level_count(self, wsi) -> int:
         """
         return self.reader.get_level_count(wsi)
 
-    def get_size(self, wsi, level) -> Tuple[int, int]:
+    def get_size(self, wsi, level: int) -> Tuple[int, int]:
         """
         Returns the size of the whole slide image at a given level.
 
@@ -246,11 +249,11 @@ def get_size(self, wsi, level) -> Tuple[int, int]:
 
     def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict:
         """
-        Extracts and returns metadata form the whole slide image.
+        Returns metadata of the extracted patch from the whole slide image.
 
         Args:
             patch: extracted patch from whole slide image
-            location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0).
+            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
@@ -266,7 +269,7 @@ def get_patch(
 
         Args:
             wsi: a whole slide image object loaded from a file or a lis of such objects
-            location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0).
+            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
@@ -294,7 +297,7 @@ def read(self, data: Union[Sequence[PathLike], PathLike, np.ndarray], **kwargs):
 @require_pkg(pkg_name="cucim")
 class CuCIMWSIReader(BaseWSIReader):
     """
-    Read whole slide images and extract patches without loading the whole slide image into the memory.
+    Read whole slide images and extract patches using cuCIM library.
 
     Args:
         level: the whole slide image level at which the image is extracted. (default=0)
@@ -321,7 +324,7 @@ def get_level_count(wsi) -> int:
         return wsi.resolutions["level_count"]  # type: ignore
 
     @staticmethod
-    def get_size(wsi, level) -> Tuple[int, int]:
+    def get_size(wsi, level: int) -> Tuple[int, int]:
         """
         Returns the size of the whole slide image at a given level.
 
@@ -334,11 +337,11 @@ def get_size(wsi, level) -> Tuple[int, int]:
 
     def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict:
         """
-        Extracts and returns metadata form the whole slide image.
+        Returns metadata of the extracted patch from the whole slide image.
 
         Args:
             patch: extracted patch from whole slide image
-            location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0).
+            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
@@ -386,7 +389,7 @@ def get_patch(
 
         Args:
             wsi: a whole slide image object loaded from a file or a lis of such objects
-            location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0).
+            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
@@ -402,7 +405,7 @@ def get_patch(
         patch = np.asarray(patch, dtype=dtype)
 
         # Make it channel first
-        patch = EnsureChannelFirst()(patch, {"original_channel_dim": -1})  # type: ignore
+        patch = AsChannelFirst()(patch)  # type: ignore
 
         # Check if the color channel is 3 (RGB) or 4 (RGBA)
         if mode == "RGBA" and patch.shape[0] != 4:
@@ -418,3 +421,131 @@ def get_patch(
             patch = patch[:3]
 
         return patch
+
+
+@require_pkg(pkg_name="openslide")
+class OpenSlideWSIReader(BaseWSIReader):
+    """
+    Read whole slide images and extract patches using OpenSlide 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`.
+        kwargs: additional args for `openslide.OpenSlide` module.
+
+    """
+
+    supported_suffixes = ["tif", "tiff", "svs"]
+
+    def __init__(self, level: int = 0, **kwargs):
+        super().__init__(level, **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 wsi.level_count  # type: ignore
+
+    @staticmethod
+    def get_size(wsi, level: int) -> Tuple[int, int]:
+        """
+        Returns the size 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.level_dimensions[level][1], wsi.level_dimensions[level][0])
+
+    def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict:
+        """
+        Returns metadata of the extracted patch from the whole slide image.
+
+        Args:
+            patch: extracted patch from whole slide image
+            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
+
+        """
+        metadata: Dict = {
+            "backend": "openslide",
+            "spatial_shape": np.asarray(patch.shape[1:]),
+            "original_channel_dim": 0,
+            "location": location,
+            "size": size,
+            "level": level,
+        }
+        return metadata
+
+    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 = OpenSlide(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'
+
+        """
+        # Extract a patch or the entire image
+        # (reverse the order of location and size to become WxH for OpenSlide)
+        pil_patch = wsi.read_region(location=location[::-1], size=size[::-1], level=level)
+
+        # convert to RGB/RGBA
+        pil_patch = pil_patch.convert(mode)
+
+        # Convert to numpy
+        patch = np.asarray(pil_patch, dtype=dtype)
+
+        # Make it channel first
+        patch = AsChannelFirst()(patch)  # type: ignore
+
+        # Check if the color channel is 3 (RGB) or 4 (RGBA)
+        if mode == "RGBA" and patch.shape[0] != 4:
+            raise ValueError(
+                f"The image is expected to have four color channels in '{mode}' mode but has {patch.shape[0]}."
+            )
+
+        elif mode in "RGB" and patch.shape[0] != 3:
+            raise ValueError(
+                f"The image is expected to have three color channels in '{mode}' mode but has {patch.shape[0]}. "
+            )
+
+        return patch


Test patch:
diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py
--- a/tests/test_wsireader_new.py
+++ b/tests/test_wsireader_new.py
@@ -214,5 +214,12 @@ def setUpClass(cls):
         cls.backend = "cucim"
 
 
+@skipUnless(has_osl, "Requires openslide")
+class TestOpenSlide(WSIReaderTests.Tests):
+    @classmethod
+    def setUpClass(cls):
+        cls.backend = "openslide"
+
+
 if __name__ == "__main__":
     unittest.main()