Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions docs/source/data.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------
Expand Down Expand Up @@ -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:
2 changes: 1 addition & 1 deletion monai/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@
worker_init_fn,
zoom_affine,
)
from .wsi_reader import BaseWSIReader, CuCIMWSIReader, WSIReader
from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader
2 changes: 1 addition & 1 deletion monai/data/image_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
165 changes: 148 additions & 17 deletions monai/data/wsi_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
7 changes: 7 additions & 0 deletions tests/test_wsireader_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()