diff --git a/docs/source/data.rst b/docs/source/data.rst index c968d72945..f92c390815 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -152,23 +152,6 @@ PILReader .. autoclass:: PILReader :members: -Whole slide image reader ------------------------- - -BaseWSIReader -~~~~~~~~~~~~~ -.. autoclass:: BaseWSIReader - :members: - -WSIReader -~~~~~~~~~ -.. autoclass:: WSIReader - :members: - -CuCIMWSIReader -~~~~~~~~~~~~~~ -.. autoclass:: CuCIMWSIReader - :members: Image writer ------------ @@ -295,3 +278,28 @@ MetaTensor ---------- .. autoclass:: monai.data.MetaTensor :members: + + + +Whole slide image reader +------------------------ + +BaseWSIReader +~~~~~~~~~~~~~ +.. autoclass:: monai.data.BaseWSIReader + :members: + +WSIReader +~~~~~~~~~ +.. autoclass:: monai.data.WSIReader + :members: + +CuCIMWSIReader +~~~~~~~~~~~~~~ +.. autoclass:: monai.data.CuCIMWSIReader + :members: + +OpenSlideWSIReader +~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.OpenSlideWSIReader + :members: diff --git a/monai/data/__init__.py b/monai/data/__init__.py index ca4be87ef6..b10f0e034e 100644 --- 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 index ca77178e0b..7e1db7ef7d 100644 --- 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 index 4899fb8830..ad5141787c 100644 --- 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 diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 7b288f6040..456f5a9453 100644 --- 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()