diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 4f88139677..2930676be3 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -11,6 +11,7 @@ from __future__ import annotations +import warnings from abc import abstractmethod from collections.abc import Sequence from os.path import abspath @@ -27,9 +28,11 @@ dtype_numpy_to_torch, dtype_torch_to_numpy, ensure_tuple, + ensure_tuple_rep, optional_import, require_pkg, ) +from monai.utils.misc import ConvertUnits OpenSlide, _ = optional_import("openslide", name="OpenSlide") TiffFile, _ = optional_import("tifffile", name="TiffFile") @@ -42,7 +45,13 @@ class BaseWSIReader(ImageReader): An abstract class that defines APIs to load patches from whole slide image files. Args: - level: the whole slide image level at which the image is extracted. + level: the whole slide image level at which the patches are extracted. + mpp: the resolution in micron per pixel at which the patches are extracted. + mpp_rtol: the acceptable relative tolerance for resolution in micro per pixel. + mpp_atol: the acceptable absolute tolerance for resolution in micro per pixel. + power: the objective power at which the patches are extracted. + power_rtol: the acceptable relative tolerance for objective power. + power_atol: the acceptable absolute tolerance for objective power. channel_dim: the desired dimension for color channel. dtype: the data type of output image. device: target device to put the extracted patch. Note that if device is "cuda"", @@ -50,6 +59,11 @@ class BaseWSIReader(ImageReader): mode: the output image color mode, e.g., "RGB" or "RGBA". kwargs: additional args for the reader + Notes: + Only one of resolution parameters, `level`, `mpp`, or `power`, should be provided. + If such parameters are provided in `get_data` method, those will override the values provided here. + If none of them are provided here or in `get_data`, `level=0` will be used. + Typical usage of a concrete implementation of this class is: .. code-block:: python @@ -77,11 +91,17 @@ class BaseWSIReader(ImageReader): def __init__( self, - level: int, - channel_dim: int, - dtype: DtypeLike | torch.dtype, - device: torch.device | str | None, - mode: str, + level: int | None = None, + mpp: float | tuple[float, float] | None = None, + mpp_rtol: float = 0.05, + mpp_atol: float = 0.0, + power: int | None = None, + power_rtol: float = 0.05, + power_atol: float = 0.0, + channel_dim: int = 0, + dtype: DtypeLike | torch.dtype = np.uint8, + device: torch.device | str | None = None, + mode: str = "RGB", **kwargs, ): super().__init__() @@ -91,6 +111,12 @@ def __init__( self.set_device(device) self.mode = mode self.kwargs = kwargs + self.mpp: tuple[float, float] | None = ensure_tuple_rep(mpp, 2) if mpp is not None else None # type: ignore + self.power = power + self.mpp_rtol = mpp_rtol + self.mpp_atol = mpp_atol + self.power_rtol = power_rtol + self.power_atol = power_atol self.metadata: dict[Any, Any] = {} def set_dtype(self, dtype): @@ -107,37 +133,106 @@ def set_device(self, device): raise ValueError(f"`device` must be `torch.device`, `str` or `None` but {type(device)} is given.") @abstractmethod - def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: + def get_size(self, 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 + wsi: a whole slide image object loaded from a file. + level: the level number where the size is calculated. """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + def _find_closest_level( + self, name: str, value: tuple, value_at_levels: Sequence[tuple], atol: float, rtol: float + ) -> int: + """Find the level corresponding to the value of the quantity in the list of values at each level. + Args: + name: the name of the requested quantity + value: the value of requested quantity + value_at_levels: list of value of the quantity at each level + atol: the tolerance for the value + rtol: relative tolerance for the value + """ + if value in value_at_levels: + return value_at_levels.index(value) + + closest_value = min(value_at_levels, key=lambda a_value: sum([abs(x - y) for x, y in zip(a_value, value)])) # type: ignore + for i in range(len(value)): + if abs(closest_value[i] - value[i]) > atol + rtol * abs(value[i]): + raise ValueError( + f"The requested {name} < {value} > does not exist in this whole slide image " + f"(with {name}_rtol={rtol} and {name}_atol={atol}). " + f"Here is the list of available {name}: {value_at_levels}. " + f"The closest matching available {name} is {closest_value}." + f"Please consider changing the tolerances or use another {name}." + ) + return value_at_levels.index(closest_value) + + def get_valid_level( + self, wsi, level: int | None, mpp: float | tuple[float, float] | None, power: int | None + ) -> int: + """ + Returns the level associated to the resolution parameters in the whole slide image. + + Args: + wsi: a whole slide image object loaded from a file. + level: the level number. + mpp: the micron-per-pixel resolution. + power: the objective power. + + """ + + # Try instance parameters if no resolution is provided + if mpp is None and power is None and level is None: + mpp = self.mpp + power = self.power + level = self.level + + # Ensure that at most one resolution parameter is provided. + resolution = [val[0] for val in [("level", level), ("mpp", mpp), ("power", power)] if val[1] is not None] + if len(resolution) > 1: + raise ValueError(f"Only one of `level`, `mpp`, or `power` should be provided. {resolution} are provided.") + + n_levels = self.get_level_count(wsi) + + if mpp is not None: + mpp_ = ensure_tuple_rep(mpp, 2) + available_mpps = [self.get_mpp(wsi, level) for level in range(n_levels)] + level = self._find_closest_level("mpp", mpp_, available_mpps, self.mpp_atol, self.mpp_rtol) + elif power is not None: + power_ = ensure_tuple(power) + available_powers = [(self.get_power(wsi, level),) for level in range(n_levels)] + level = self._find_closest_level("power", power_, available_powers, self.power_atol, self.power_rtol) + else: + if level is None: + # Set the default value if no resolution parameter is provided. + level = 0 + if level >= n_levels: + raise ValueError(f"The maximum level of this image is {n_levels-1} while level={level} is requested)!") + + return level + @abstractmethod def get_level_count(self, wsi) -> int: """ Returns the number of levels in the whole slide image. Args: - wsi: a whole slide image object loaded from a file + wsi: a whole slide image object loaded from a file. """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @abstractmethod - def get_downsample_ratio(self, wsi, level: int | None = None) -> float: + def get_downsample_ratio(self, 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the downsample ratio is calculated. """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -148,13 +243,25 @@ def get_file_path(self, wsi) -> str: raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @abstractmethod - def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: + def get_mpp(self, wsi, level: int) -> tuple[float, float]: """ Returns the micro-per-pixel resolution 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 + wsi: a whole slide image object loaded from a file. + level: the level number where the mpp is calculated. + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + @abstractmethod + def get_power(self, wsi, level: int) -> float: + """ + Returns the objective power 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 objective power is calculated. """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -171,9 +278,9 @@ def _get_patch( 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' + level: the level number. + dtype: the data type of output image. + mode: the output image mode, 'RGB' or 'RGBA'. """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -185,12 +292,12 @@ def _get_metadata( Returns metadata of the extracted patch from the whole slide image. Args: - wsi: the whole slide image object, from which the patch is loaded - patch: extracted patch from whole slide image + wsi: the whole slide image object, from which the patch is loaded. + 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 + level: the level number. """ if self.channel_dim >= len(patch.shape) or self.channel_dim < -len(patch.shape): @@ -216,37 +323,44 @@ def get_data( location: tuple[int, int] = (0, 0), size: tuple[int, int] | None = None, level: int | None = None, + mpp: float | tuple[float, float] | None = None, + power: int | None = None, mode: str | None = None, ) -> tuple[np.ndarray, dict]: """ - Verifies inputs, extracts patches from WSI image and generates metadata, and return them. + Verifies inputs, extracts patches from WSI image and generates metadata. Args: - wsi: a whole slide image object loaded from a file or a list of such objects + wsi: a whole slide image object loaded from a file or a list 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 not provided or None, it is set to the full image size at the given level. - level: the level number. Defaults to 0 - mode: the output image color mode, "RGB" or "RGBA". If not provided the default of "RGB" is used. + level: the whole slide image level at which the patches are extracted. + mpp: the resolution in micron per pixel at which the patches are extracted. + power: the objective power at which the patches are extracted. + dtype: the data type of output image. + mode: the output image mode, 'RGB' or 'RGBA'. Returns: a tuples, where the first element is an image patch [CxHxW] or stack of patches, - and second element is a dictionary of metadata + and second element is a dictionary of metadata. + + Notes: + Only one of resolution parameters, `level`, `mpp`, or `power`, should be provided. + If none of them are provided, it uses the defaults that are set during class instantiation. + If none of them are set here or during class instantiation, `level=0` will be used. """ if mode is None: mode = self.mode patch_list: list = [] metadata_list: list = [] + # CuImage object is iterable, so ensure_tuple won't work on single object - if not isinstance(wsi, list): - wsi = [wsi] + if not isinstance(wsi, (list, tuple)): + wsi = (wsi,) for each_wsi in ensure_tuple(wsi): - # Verify magnification level - if level is None: - level = self.level - max_level = self.get_level_count(each_wsi) - 1 - if level > max_level: - raise ValueError(f"The maximum level of this image is {max_level} while level={level} is requested)!") + # get the valid level based on resolution info + level = self.get_valid_level(each_wsi, level, mpp, power) # Verify location if location is None: @@ -336,15 +450,26 @@ class WSIReader(BaseWSIReader): Args: backend: the name of backend whole slide image reader library, the default is cuCIM. - level: the level at which patches are extracted. + level: the whole slide image level at which the patches are extracted. + mpp: the resolution in micron per pixel at which the patches are extracted. + mpp_rtol: the acceptable relative tolerance for resolution in micro per pixel. + mpp_atol: the acceptable absolute tolerance for resolution in micro per pixel. + power: the objective power at which the patches are extracted. + power_rtol: the acceptable relative tolerance for objective power. + power_atol: the acceptable absolute tolerance for objective power. channel_dim: the desired dimension for color channel. Default to 0 (channel first). dtype: the data type of output image. Defaults to `np.uint8`. - mode: the output image color mode, "RGB" or "RGBA". Defaults to "RGB". device: target device to put the extracted patch. Note that if device is "cuda"", the output will be converted to torch tenor and sent to the gpu even if the dtype is numpy. + mode: the output image color mode, "RGB" or "RGBA". Defaults to "RGB". num_workers: number of workers for multi-thread image loading (cucim backend only). kwargs: additional arguments to be passed to the backend library + Notes: + Only one of resolution parameters, `level`, `mpp`, or `power`, should be provided. + If such parameters are provided in `get_data` method, those will override the values provided here. + If none of them are provided here or in `get_data`, `level=0` will be used. + """ supported_backends = ["cucim", "openslide", "tifffile"] @@ -352,7 +477,13 @@ class WSIReader(BaseWSIReader): def __init__( self, backend="cucim", - level: int = 0, + level: int | None = None, + mpp: float | tuple[float, float] | None = None, + mpp_rtol: float = 0.05, + mpp_atol: float = 0.0, + power: int | None = None, + power_rtol: float = 0.05, + power_atol: float = 0.0, channel_dim: int = 0, dtype: DtypeLike | torch.dtype = np.uint8, device: torch.device | str | None = None, @@ -363,15 +494,48 @@ def __init__( self.reader: CuCIMWSIReader | OpenSlideWSIReader | TiffFileWSIReader if self.backend == "cucim": self.reader = CuCIMWSIReader( - level=level, channel_dim=channel_dim, dtype=dtype, device=device, mode=mode, **kwargs + level=level, + mpp=mpp, + mpp_rtol=mpp_rtol, + mpp_atol=mpp_atol, + power=power, + power_rtol=power_rtol, + power_atol=power_atol, + channel_dim=channel_dim, + dtype=dtype, + device=device, + mode=mode, + **kwargs, ) elif self.backend == "openslide": self.reader = OpenSlideWSIReader( - level=level, channel_dim=channel_dim, dtype=dtype, device=device, mode=mode, **kwargs + level=level, + mpp=mpp, + mpp_rtol=mpp_rtol, + mpp_atol=mpp_atol, + power=power, + power_rtol=power_rtol, + power_atol=power_atol, + channel_dim=channel_dim, + dtype=dtype, + device=device, + mode=mode, + **kwargs, ) elif self.backend == "tifffile": self.reader = TiffFileWSIReader( - level=level, channel_dim=channel_dim, dtype=dtype, device=device, mode=mode, **kwargs + level=level, + mpp=mpp, + mpp_rtol=mpp_rtol, + mpp_atol=mpp_atol, + power=power, + power_rtol=power_rtol, + power_atol=power_atol, + channel_dim=channel_dim, + dtype=dtype, + device=device, + mode=mode, + **kwargs, ) else: raise ValueError( @@ -379,72 +543,77 @@ def __init__( ) self.supported_suffixes = self.reader.supported_suffixes self.level = self.reader.level + self.mpp_rtol = self.reader.mpp_rtol + self.mpp_atol = self.reader.mpp_atol + self.power = self.reader.power + self.power_rtol = self.reader.power_rtol + self.power_atol = self.reader.power_atol self.channel_dim = self.reader.channel_dim self.dtype = self.reader.dtype self.device = self.reader.device self.mode = self.reader.mode self.kwargs = self.reader.kwargs self.metadata = self.reader.metadata + self.mpp = self.reader.mpp def get_level_count(self, wsi) -> int: """ Returns the number of levels in the whole slide image. Args: - wsi: a whole slide image object loaded from a file + wsi: a whole slide image object loaded from a file. """ return self.reader.get_level_count(wsi) - def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: + def get_size(self, 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the size is calculated. """ - if level is None: - level = self.level - return self.reader.get_size(wsi, level) - def get_downsample_ratio(self, wsi, level: int | None = None) -> float: + def get_downsample_ratio(self, 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the downsample ratio is calculated. """ - if level is None: - level = self.level - return self.reader.get_downsample_ratio(wsi, level) def get_file_path(self, wsi) -> str: """Return the file path for the WSI object""" return self.reader.get_file_path(wsi) - def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: + def get_mpp(self, wsi, level: int) -> tuple[float, float]: """ Returns the micro-per-pixel resolution 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the mpp is calculated. """ - if level is None: - level = self.level - return self.reader.get_mpp(wsi, level) + def get_power(self, wsi, level: int) -> float: + """ + Returns the micro-per-pixel resolution 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 objective power is calculated. + + """ + return self.reader.get_power(wsi, level) + def _get_patch( self, wsi, location: tuple[int, int], size: tuple[int, int], level: int, dtype: DtypeLike, mode: str ) -> np.ndarray: @@ -452,13 +621,13 @@ def _get_patch( 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 + 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 + level: the level number. dtype: the data type of output image - mode: the output image mode, 'RGB' or 'RGBA' + mode: the output image mode, 'RGB' or 'RGBA'. """ return self.reader._get_patch(wsi=wsi, location=location, size=size, level=level, dtype=dtype, mode=mode) @@ -472,7 +641,7 @@ def read(self, data: Sequence[PathLike] | PathLike | np.ndarray, **kwargs): kwargs: additional args for the reader module (overrides `self.kwargs` for existing keys). Returns: - whole slide image object or list of such objects + whole slide image object or list of such objects. """ return self.reader.read(data=data, **kwargs) @@ -484,33 +653,34 @@ class CuCIMWSIReader(BaseWSIReader): 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) - This is overridden if the level argument is provided in `get_data`. + level: the whole slide image level at which the patches are extracted. + mpp: the resolution in micron per pixel at which the patches are extracted. + mpp_rtol: the acceptable relative tolerance for resolution in micro per pixel. + mpp_atol: the acceptable absolute tolerance for resolution in micro per pixel. + power: the objective power at which the patches are extracted. + power_rtol: the acceptable relative tolerance for objective power. + power_atol: the acceptable absolute tolerance for objective power. channel_dim: the desired dimension for color channel. Default to 0 (channel first). dtype: the data type of output image. Defaults to `np.uint8`. device: target device to put the extracted patch. Note that if device is "cuda"", the output will be converted to torch tenor and sent to the gpu even if the dtype is numpy. mode: the output image color mode, "RGB" or "RGBA". Defaults to "RGB". - num_workers: number of workers for multi-thread image loading + num_workers: number of workers for multi-thread image loading. kwargs: additional args for `cucim.CuImage` module: https://github.com/rapidsai/cucim/blob/main/cpp/include/cucim/cuimage.h + Notes: + Only one of resolution parameters, `level`, `mpp`, or `power`, should be provided. + If such parameters are provided in `get_data` method, those will override the values provided here. + If none of them are provided here or in `get_data`, `level=0` will be used. + """ supported_suffixes = ["tif", "tiff", "svs"] backend = "cucim" - def __init__( - self, - level: int = 0, - channel_dim: int = 0, - dtype: DtypeLike | torch.dtype = np.uint8, - device: torch.device | str | None = None, - mode: str = "RGB", - num_workers: int = 0, - **kwargs, - ): - super().__init__(level=level, channel_dim=channel_dim, dtype=dtype, device=device, mode=mode, **kwargs) + def __init__(self, num_workers: int = 0, **kwargs): + super().__init__(**kwargs) self.num_workers = num_workers @staticmethod @@ -519,61 +689,80 @@ 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 + wsi: a whole slide image object loaded from a file. """ return wsi.resolutions["level_count"] # type: ignore - def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: + def get_size(self, 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the size is calculated. """ - if level is None: - level = self.level - return (wsi.resolutions["level_dimensions"][level][1], wsi.resolutions["level_dimensions"][level][0]) - def get_downsample_ratio(self, wsi, level: int | None = None) -> float: + def get_downsample_ratio(self, 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the downsample ratio is calculated. """ - if level is None: - level = self.level - - return wsi.resolutions["level_downsamples"][level] # type: ignore + return float(wsi.resolutions["level_downsamples"][level]) @staticmethod def get_file_path(wsi) -> str: """Return the file path for the WSI object""" return str(abspath(wsi.path)) - def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: + def get_mpp(self, wsi, level: int) -> tuple[float, float]: """ Returns the micro-per-pixel resolution 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the mpp is calculated. """ - if level is None: - level = self.level + downsample_ratio = self.get_downsample_ratio(wsi, level) + + if "aperio" in wsi.metadata: + mpp_ = wsi.metadata["aperio"].get("MPP") + if mpp_: + return (downsample_ratio * float(mpp_),) * 2 + if "cucim" in wsi.metadata: + mpp_ = wsi.metadata["cucim"].get("spacing") + if mpp_ and isinstance(mpp_, Sequence) and len(mpp_) >= 2: + if mpp_[0] and mpp_[1]: + return (downsample_ratio * mpp_[1], downsample_ratio * mpp_[0]) + + raise ValueError("`mpp` cannot be obtained for this file. Please use `level` instead.") + + def get_power(self, wsi, level: int) -> float: + """ + Returns the objective power of the whole slide image at a given level. - factor = float(wsi.resolutions["level_downsamples"][level]) - return (wsi.metadata["cucim"]["spacing"][1] * factor, wsi.metadata["cucim"]["spacing"][0] * factor) + Args: + wsi: a whole slide image object loaded from a file. + level: the level number where the objective power is calculated. + + """ + if "aperio" in wsi.metadata: + objective_power = wsi.metadata["aperio"].get("AppMag") + if objective_power: + downsample_ratio = self.get_downsample_ratio(wsi, level) + return float(objective_power) / downsample_ratio + + raise ValueError( + "Currently, cuCIM backend can obtain the objective power only for Aperio images. " + "Please use `level` (or `mpp`) instead, or try OpenSlide backend." + ) def read(self, data: Sequence[PathLike] | PathLike | np.ndarray, **kwargs): """ @@ -585,7 +774,7 @@ def read(self, data: Sequence[PathLike] | PathLike | np.ndarray, **kwargs): For more details look at https://github.com/rapidsai/cucim/blob/main/cpp/include/cucim/cuimage.h Returns: - whole slide image object or list of such objects + whole slide image object or list of such objects. """ cuimage_cls, _ = optional_import("cucim", name="CuImage") @@ -607,13 +796,13 @@ def _get_patch( 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 + 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' + level: the level number. + dtype: the data type of output image. + mode: the output image mode, 'RGB' or 'RGBA'. """ # Extract a patch or the entire image @@ -646,8 +835,13 @@ 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`. + level: the whole slide image level at which the patches are extracted. + mpp: the resolution in micron per pixel at which the patches are extracted. + mpp_rtol: the acceptable relative tolerance for resolution in micro per pixel. + mpp_atol: the acceptable absolute tolerance for resolution in micro per pixel. + power: the objective power at which the patches are extracted. + power_rtol: the acceptable relative tolerance for objective power. + power_atol: the acceptable absolute tolerance for objective power. channel_dim: the desired dimension for color channel. Default to 0 (channel first). dtype: the data type of output image. Defaults to `np.uint8`. device: target device to put the extracted patch. Note that if device is "cuda"", @@ -655,21 +849,18 @@ class OpenSlideWSIReader(BaseWSIReader): mode: the output image color mode, "RGB" or "RGBA". Defaults to "RGB". kwargs: additional args for `openslide.OpenSlide` module. + Notes: + Only one of resolution parameters, `level`, `mpp`, or `power`, should be provided. + If such parameters are provided in `get_data` method, those will override the values provided here. + If none of them are provided here or in `get_data`, `level=0` will be used. + """ supported_suffixes = ["tif", "tiff", "svs"] backend = "openslide" - def __init__( - self, - level: int = 0, - channel_dim: int = 0, - dtype: DtypeLike | torch.dtype = np.uint8, - device: torch.device | str | None = None, - mode: str = "RGB", - **kwargs, - ): - super().__init__(level=level, channel_dim=channel_dim, dtype=dtype, device=device, mode=mode, **kwargs) + def __init__(self, **kwargs): + super().__init__(**kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -677,39 +868,31 @@ 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 + wsi: a whole slide image object loaded from a file. """ return wsi.level_count # type: ignore - def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: + def get_size(self, 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the size is calculated. """ - if level is None: - level = self.level - return (wsi.level_dimensions[level][1], wsi.level_dimensions[level][0]) - def get_downsample_ratio(self, wsi, level: int | None = None) -> float: + def get_downsample_ratio(self, 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the downsample ratio is calculated. """ - if level is None: - level = self.level - return wsi.level_downsamples[level] # type: ignore @staticmethod @@ -717,32 +900,61 @@ def get_file_path(wsi) -> str: """Return the file path for the WSI object""" return str(abspath(wsi._filename)) - def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: + def get_mpp(self, wsi, level: int) -> tuple[float, float]: """ Returns the micro-per-pixel resolution 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the mpp is calculated. + + """ + downsample_ratio = self.get_downsample_ratio(wsi, level) + if ( + "openslide.mpp-x" in wsi.properties + and "openslide.mpp-y" in wsi.properties + and wsi.properties["openslide.mpp-y"] + and wsi.properties["openslide.mpp-x"] + ): + return ( + downsample_ratio * float(wsi.properties["openslide.mpp-y"]), + downsample_ratio * float(wsi.properties["openslide.mpp-x"]), + ) + + if ( + "tiff.XResolution" in wsi.properties + and "tiff.YResolution" in wsi.properties + and wsi.properties["tiff.YResolution"] + and wsi.properties["tiff.XResolution"] + ): + unit = wsi.properties.get("tiff.ResolutionUnit") + if unit is None: + warnings.warn("The resolution unit is missing, `micrometer` will be used as default.") + unit = "micrometer" + + convert_to_micron = ConvertUnits(unit, "micrometer") + return ( + convert_to_micron(downsample_ratio / float(wsi.properties["tiff.YResolution"])), + convert_to_micron(downsample_ratio / float(wsi.properties["tiff.XResolution"])), + ) + raise ValueError("`mpp` cannot be obtained for this file. Please use `level` instead.") + + def get_power(self, wsi, level: int) -> float: """ - if level is None: - level = self.level - unit = wsi.properties["tiff.ResolutionUnit"] - if unit == "centimeter": - factor = 10000.0 - elif unit == "millimeter": - factor = 1000.0 - elif unit == "micrometer": - factor = 1.0 - elif unit == "inch": - factor = 25400.0 - else: - raise ValueError(f"The resolution unit is not a valid tiff resolution: {unit}") + Returns the objective power 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 objective power is calculated. + + """ + objective_power = wsi.properties.get("openslide.objective-power") + if objective_power: + downsample_ratio = self.get_downsample_ratio(wsi, level) + return float(objective_power) / downsample_ratio - factor *= wsi.level_downsamples[level] - return (factor / float(wsi.properties["tiff.YResolution"]), factor / float(wsi.properties["tiff.XResolution"])) + raise ValueError("Objective `power` cannot be obtained for this file. Please use `level` (or `mpp`) instead.") def read(self, data: Sequence[PathLike] | PathLike | np.ndarray, **kwargs): """ @@ -753,7 +965,7 @@ def read(self, data: Sequence[PathLike] | PathLike | np.ndarray, **kwargs): kwargs: additional args that overrides `self.kwargs` for existing keys. Returns: - whole slide image object or list of such objects + whole slide image object or list of such objects. """ wsi_list: list = [] @@ -778,9 +990,9 @@ def _get_patch( 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' + level: the level number. + dtype: the data type of output image. + mode: the output image mode, 'RGB' or 'RGBA'. """ # Extract a patch or the entire image @@ -805,8 +1017,10 @@ 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`. + level: the whole slide image level at which the patches are extracted. + mpp: the resolution in micron per pixel at which the patches are extracted. + mpp_rtol: the acceptable relative tolerance for resolution in micro per pixel. + mpp_atol: the acceptable absolute tolerance for resolution in micro per pixel. channel_dim: the desired dimension for color channel. Default to 0 (channel first). dtype: the data type of output image. Defaults to `np.uint8`. device: target device to put the extracted patch. Note that if device is "cuda"", @@ -814,21 +1028,19 @@ class TiffFileWSIReader(BaseWSIReader): mode: the output image color mode, "RGB" or "RGBA". Defaults to "RGB". kwargs: additional args for `tifffile.TiffFile` module. + Notes: + - Objective power cannot be obtained via TiffFile backend. + - Only one of resolution parameters, `level` or `mpp`, should be provided. + If such parameters are provided in `get_data` method, those will override the values provided here. + If none of them are provided here or in `get_data`, `level=0` will be used. + """ supported_suffixes = ["tif", "tiff", "svs"] backend = "tifffile" - def __init__( - self, - level: int = 0, - channel_dim: int = 0, - dtype: DtypeLike | torch.dtype = np.uint8, - device: torch.device | str | None = None, - mode: str = "RGB", - **kwargs, - ): - super().__init__(level=level, channel_dim=channel_dim, dtype=dtype, device=device, mode=mode, **kwargs) + def __init__(self, **kwargs): + super().__init__(**kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -836,39 +1048,31 @@ 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 + wsi: a whole slide image object loaded from a file. """ return len(wsi.pages) - def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: + def get_size(self, 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the size is calculated. """ - if level is None: - level = self.level - return (wsi.pages[level].imagelength, wsi.pages[level].imagewidth) - def get_downsample_ratio(self, wsi, level: int | None = None) -> float: + def get_downsample_ratio(self, 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the downsample ratio is calculated. """ - if level is None: - level = self.level - return float(wsi.pages[0].imagelength) / float(wsi.pages[level].imagelength) @staticmethod @@ -876,35 +1080,49 @@ def get_file_path(wsi) -> str: """Return the file path for the WSI object""" return str(abspath(wsi.filehandle.path)) - def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: + def get_mpp(self, wsi, level: int) -> tuple[float, float]: """ Returns the micro-per-pixel resolution 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. If not provided the default level (from `self.level`) - will be used. + wsi: a whole slide image object loaded from a file. + level: the level number where the mpp is calculated. + + """ + if ( + "XResolution" in wsi.pages[level].tags + and "YResolution" in wsi.pages[level].tags + and wsi.pages[level].tags["XResolution"].value + and wsi.pages[level].tags["YResolution"].value + ): + unit = wsi.pages[level].tags.get("ResolutionUnit") + if unit is not None: + unit = str(unit.value)[8:] + else: + warnings.warn("The resolution unit is missing. `micrometer` will be used as default.") + unit = "micrometer" + + convert_to_micron = ConvertUnits(unit, "micrometer") + # Here x and y resolutions are rational numbers so each of them is represented by a tuple. + yres = wsi.pages[level].tags["YResolution"].value + xres = wsi.pages[level].tags["XResolution"].value + return convert_to_micron(yres[1] / yres[0]), convert_to_micron(xres[1] / xres[0]) + raise ValueError("`mpp` cannot be obtained for this file. Please use `level` instead.") + + def get_power(self, wsi, level: int) -> float: """ - if level is None: - level = self.level + Returns the objective power of the whole slide image at a given level. - unit = wsi.pages[level].tags["ResolutionUnit"].value - if unit == unit.CENTIMETER: - factor = 10000.0 - elif unit == unit.MILLIMETER: - factor = 1000.0 - elif unit == unit.MICROMETER: - factor = 1.0 - elif unit == unit.INCH: - factor = 25400.0 - else: - raise ValueError(f"The resolution unit is not a valid tiff resolution or missing: {unit.name}") + Args: + wsi: a whole slide image object loaded from a file. + level: the level number where the objective power is calculated. - # Here x and y resolutions are rational numbers so each of them is represented by a tuple. - yres = wsi.pages[level].tags["YResolution"].value - xres = wsi.pages[level].tags["XResolution"].value - return (factor * yres[1] / yres[0], factor * xres[1] / xres[0]) + """ + raise ValueError( + "Currently, TiffFile does not provide a general API to obtain objective power." + "Please use `level` (or `mpp`) instead, or try other backends." + ) def read(self, data: Sequence[PathLike] | PathLike | np.ndarray, **kwargs): """ @@ -915,7 +1133,7 @@ def read(self, data: Sequence[PathLike] | PathLike | np.ndarray, **kwargs): kwargs: additional args that overrides `self.kwargs` for existing keys. Returns: - whole slide image object or list of such objects + whole slide image object or list of such objects. """ wsi_list: list = [] @@ -940,9 +1158,9 @@ def _get_patch( 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' + level: the level number. + dtype: the data type of output image. + mode: the output image mode, 'RGB' or 'RGBA'. """ # Load the entire image diff --git a/monai/utils/misc.py b/monai/utils/misc.py index 924c00c3ec..e0e3f8f46d 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -23,6 +23,7 @@ from ast import literal_eval from collections.abc import Callable, Iterable, Sequence from distutils.util import strtobool +from math import log10 from pathlib import Path from typing import TYPE_CHECKING, Any, TypeVar, cast, overload @@ -68,6 +69,9 @@ "label_union", "path_to_uri", "pprint_edges", + "check_key_duplicates", + "CheckKeyDuplicatesYamlLoader", + "ConvertUnits", ] _seed = None @@ -723,3 +727,76 @@ def construct_mapping(self, node, deep=False): warnings.warn(f"Duplicate key: `{key}`") mapping.add(key) return super().construct_mapping(node, deep) + + +class ConvertUnits: + """ + Convert the values from input unit to the target unit + + Args: + input_unit: the unit of the input quantity + target_unit: the unit of the target quantity + + """ + + imperial_unit_of_length = {"inch": 0.0254, "foot": 0.3048, "yard": 0.9144, "mile": 1609.344} + + unit_prefix = { + "peta": 15, + "tera": 12, + "giga": 9, + "mega": 6, + "kilo": 3, + "hecto": 2, + "deca": 1, + "deci": -1, + "centi": -2, + "milli": -3, + "micro": -6, + "nano": -9, + "pico": -12, + "femto": -15, + } + base_units = ["meter", "byte", "bit"] + + def __init__(self, input_unit: str, target_unit: str) -> None: + self.input_unit, input_base = self._get_valid_unit_and_base(input_unit) + self.target_unit, target_base = self._get_valid_unit_and_base(target_unit) + if input_base == target_base: + self.unit_base = input_base + else: + raise ValueError( + "Both input and target units should be from the same quantity. " + f"Input quantity is {input_base} while target quantity is {target_base}" + ) + self._calculate_conversion_factor() + + def _get_valid_unit_and_base(self, unit): + unit = str(unit).lower() + if unit in self.imperial_unit_of_length: + return unit, "meter" + for base_unit in self.base_units: + if unit.endswith(base_unit): + return unit, base_unit + raise ValueError(f"Currently, it only supports length conversion but `{unit}` is given.") + + def _get_unit_power(self, unit): + """Calculate the power of the unit factor with respect to the base unit""" + if unit in self.imperial_unit_of_length: + return log10(self.imperial_unit_of_length[unit]) + + prefix = unit[: len(self.unit_base)] + if prefix == "": + return 1.0 + return self.unit_prefix[prefix] + + def _calculate_conversion_factor(self): + """Calculate unit conversion factor with respect to the input unit""" + if self.input_unit == self.target_unit: + return 1.0 + input_power = self._get_unit_power(self.input_unit) + target_power = self._get_unit_power(self.target_unit) + self.conversion_factor = 10 ** (input_power - target_power) + + def __call__(self, value: int | float) -> Any: + return float(value) * self.conversion_factor diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py index 730ce97bdb..35509b32f6 100644 --- a/tests/test_masked_patch_wsi_dataset.py +++ b/tests/test_masked_patch_wsi_dataset.py @@ -32,10 +32,9 @@ _, has_codec = optional_import("imagecodecs") has_tiff = has_tiff and has_codec -FILE_KEY = "wsi_img" +FILE_KEY = "wsi_generic_tiff" FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{FILE_KEY}.tiff") TEST_CASE_0 = [ {"data": [{"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (2, 2)}], "mask_level": 8}, diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index 923528a2cd..cb9ebcf7e3 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -32,10 +32,9 @@ _, has_codec = optional_import("imagecodecs") has_tiff = has_tiff and has_codec -FILE_KEY = "wsi_img" +FILE_KEY = "wsi_generic_tiff" FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{FILE_KEY}.tiff") TEST_CASE_0 = [ { diff --git a/tests/test_sliding_patch_wsi_dataset.py b/tests/test_sliding_patch_wsi_dataset.py index e6d11de739..518e94552f 100644 --- a/tests/test_sliding_patch_wsi_dataset.py +++ b/tests/test_sliding_patch_wsi_dataset.py @@ -32,10 +32,9 @@ _, has_codec = optional_import("imagecodecs") has_tiff = has_tiff and has_codec -FILE_KEY = "wsi_img" +FILE_KEY = "wsi_generic_tiff" FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{FILE_KEY}.tiff") FILE_PATH_SMALL_0 = os.path.join(os.path.dirname(__file__), "testing_data", "temp_wsi_inference_0.tiff") FILE_PATH_SMALL_1 = os.path.join(os.path.dirname(__file__), "testing_data", "temp_wsi_inference_1.tiff") diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index c7ccf43126..893ad167ca 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -35,95 +35,208 @@ _, has_codec = optional_import("imagecodecs") has_tiff = has_tiff and has_codec -FILE_KEY = "wsi_img" -FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) +WSI_GENERIC_TIFF_KEY = "wsi_generic_tiff" +WSI_GENERIC_TIFF_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{WSI_GENERIC_TIFF_KEY}.tiff") -HEIGHT = 32914 -WIDTH = 46000 +WSI_APERIO_SVS_KEY = "wsi_aperio_svs" +WSI_APERIO_SVS_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{WSI_APERIO_SVS_KEY}.svs") -TEST_CASE_WHOLE_0 = [FILE_PATH, 2, (3, HEIGHT // 4, WIDTH // 4)] +WSI_GENERIC_TIFF_HEIGHT = 32914 +WSI_GENERIC_TIFF_WIDTH = 46000 -TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] +TEST_CASE_WHOLE_0 = [WSI_GENERIC_TIFF_PATH, 2, (3, WSI_GENERIC_TIFF_HEIGHT // 4, WSI_GENERIC_TIFF_WIDTH // 4)] + +TEST_CASE_TRANSFORM_0 = [ + WSI_GENERIC_TIFF_PATH, + 4, + (WSI_GENERIC_TIFF_HEIGHT // 16, WSI_GENERIC_TIFF_WIDTH // 16), + (1, 3, WSI_GENERIC_TIFF_HEIGHT // 16, WSI_GENERIC_TIFF_WIDTH // 16), +] # ---------------------------------------------------------------------------- # Test cases for reading patches # ---------------------------------------------------------------------------- TEST_CASE_0 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": None}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.float64), ] TEST_CASE_1 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + {"location": (WSI_GENERIC_TIFF_HEIGHT // 2, WSI_GENERIC_TIFF_WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]], dtype=np.uint8), ] TEST_CASE_2 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {}, {"location": (0, 0), "size": (2, 1), "level": 8}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), ] TEST_CASE_3 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"channel_dim": -1}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + {"location": (WSI_GENERIC_TIFF_HEIGHT // 2, WSI_GENERIC_TIFF_WIDTH // 2), "size": (2, 1), "level": 0}, np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]], dtype=np.uint8), 0, -1), ] TEST_CASE_4 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"channel_dim": 2}, {"location": (0, 0), "size": (2, 1), "level": 8}, np.moveaxis(np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), 0, -1), ] TEST_CASE_5 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), ] TEST_CASE_6 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": np.int32}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.int32), ] TEST_CASE_7 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": np.float32}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.float32), ] TEST_CASE_8 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": torch.uint8}, {"location": (0, 0), "size": (2, 1)}, torch.tensor([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=torch.uint8), ] TEST_CASE_9 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": torch.float32}, {"location": (0, 0), "size": (2, 1)}, torch.tensor([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=torch.float32), ] +# exact mpp in get_data +TEST_CASE_10_MPP = [ + WSI_GENERIC_TIFF_PATH, + {"mpp_atol": 0.0, "mpp_rtol": 0.0}, + {"location": (WSI_GENERIC_TIFF_HEIGHT // 2, WSI_GENERIC_TIFF_WIDTH // 2), "size": (2, 1), "mpp": 1000}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]], dtype=np.uint8), + {"level": 0}, +] + +# exact mpp as default +TEST_CASE_11_MPP = [ + WSI_GENERIC_TIFF_PATH, + {"mpp_atol": 0.0, "mpp_rtol": 0.0, "mpp": 1000}, + {"location": (WSI_GENERIC_TIFF_HEIGHT // 2, WSI_GENERIC_TIFF_WIDTH // 2), "size": (2, 1)}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]], dtype=np.uint8), + {"level": 0}, +] + +# exact mpp as default (Aperio SVS) +TEST_CASE_12_MPP = [ + WSI_APERIO_SVS_PATH, + {"mpp_atol": 0.0, "mpp_rtol": 0.0, "mpp": 0.499}, + {"location": (0, 0), "size": (2, 1)}, + np.array([[[239], [239]], [[239], [239]], [[239], [239]]], dtype=np.uint8), + {"level": 0}, +] +# acceptable mpp within default tolerances +TEST_CASE_13_MPP = [ + WSI_GENERIC_TIFF_PATH, + {}, + {"location": (0, 0), "size": (2, 1), "mpp": 256000}, + np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), + {"level": 8}, +] + +# acceptable mpp within default tolerances (Aperio SVS) +TEST_CASE_14_MPP = [ + WSI_APERIO_SVS_PATH, + {"mpp": 8.0}, + {"location": (0, 0), "size": (2, 1)}, + np.array([[[238], [240]], [[239], [241]], [[240], [241]]], dtype=np.uint8), + {"level": 2}, +] + +# acceptable mpp within absolute tolerance (Aperio SVS) +TEST_CASE_15_MPP = [ + WSI_APERIO_SVS_PATH, + {"mpp": 7.0, "mpp_atol": 1.0, "mpp_rtol": 0.0}, + {"location": (0, 0), "size": (2, 1)}, + np.array([[[238], [240]], [[239], [241]], [[240], [241]]], dtype=np.uint8), + {"level": 2}, +] + +# acceptable mpp within relative tolerance (Aperio SVS) +TEST_CASE_16_MPP = [ + WSI_APERIO_SVS_PATH, + {"mpp": 7.8, "mpp_atol": 0.0, "mpp_rtol": 0.1}, + {"location": (0, 0), "size": (2, 1)}, + np.array([[[238], [240]], [[239], [241]], [[240], [241]]], dtype=np.uint8), + {"level": 2}, +] + + +# exact power +TEST_CASE_17_POWER = [ + WSI_APERIO_SVS_PATH, + {"power_atol": 0.0, "power_rtol": 0.0}, + {"location": (0, 0), "size": (2, 1), "power": 20}, + np.array([[[239], [239]], [[239], [239]], [[239], [239]]], dtype=np.uint8), + {"level": 0}, +] + +# exact power +TEST_CASE_18_POWER = [ + WSI_APERIO_SVS_PATH, + {"power": 20, "power_atol": 0.0, "power_rtol": 0.0}, + {"location": (0, 0), "size": (2, 1)}, + np.array([[[239], [239]], [[239], [239]], [[239], [239]]], dtype=np.uint8), + {"level": 0}, +] + +# acceptable power within default tolerances (Aperio SVS) +TEST_CASE_19_POWER = [ + WSI_APERIO_SVS_PATH, + {}, + {"location": (0, 0), "size": (2, 1), "power": 1.25}, + np.array([[[238], [240]], [[239], [241]], [[240], [241]]], dtype=np.uint8), + {"level": 2}, +] + +# acceptable power within absolute tolerance (Aperio SVS) +TEST_CASE_20_POWER = [ + WSI_APERIO_SVS_PATH, + {"power_atol": 0.3, "power_rtol": 0.0}, + {"location": (0, 0), "size": (2, 1), "power": 1.0}, + np.array([[[238], [240]], [[239], [241]], [[240], [241]]], dtype=np.uint8), + {"level": 2}, +] + +# acceptable power within relative tolerance (Aperio SVS) +TEST_CASE_21_POWER = [ + WSI_APERIO_SVS_PATH, + {"power_atol": 0.0, "power_rtol": 0.3}, + {"location": (0, 0), "size": (2, 1), "power": 1.0}, + np.array([[[238], [240]], [[239], [241]], [[240], [241]]], dtype=np.uint8), + {"level": 2}, +] # device tests TEST_CASE_DEVICE_1 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": torch.float32, "device": "cpu"}, {"location": (0, 0), "size": (2, 1)}, torch.tensor([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=torch.float32), @@ -131,7 +244,7 @@ ] TEST_CASE_DEVICE_2 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": torch.float32, "device": "cuda"}, {"location": (0, 0), "size": (2, 1)}, torch.tensor([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=torch.float32), @@ -139,7 +252,7 @@ ] TEST_CASE_DEVICE_3 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": np.float32, "device": "cpu"}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.float32), @@ -147,7 +260,7 @@ ] TEST_CASE_DEVICE_4 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": np.float32, "device": "cuda"}, {"location": (0, 0), "size": (2, 1)}, torch.tensor([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=torch.float32), @@ -155,7 +268,7 @@ ] TEST_CASE_DEVICE_5 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "device": "cuda"}, {"location": (0, 0), "size": (2, 1)}, torch.tensor([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=torch.uint8), @@ -163,7 +276,7 @@ ] TEST_CASE_DEVICE_6 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), @@ -171,7 +284,7 @@ ] TEST_CASE_DEVICE_7 = [ - FILE_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8, "device": None}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), @@ -179,7 +292,7 @@ ] TEST_CASE_MULTI_WSI = [ - [FILE_PATH, FILE_PATH], + [WSI_GENERIC_TIFF_PATH, WSI_GENERIC_TIFF_PATH], {"location": (0, 0), "size": (2, 1), "level": 8}, np.concatenate( [ @@ -199,7 +312,34 @@ TEST_CASE_ERROR_2C = [np.ones((16, 16, 2), dtype=np.uint8)] # two color channels TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color -TEST_CASE_MPP_0 = [FILE_PATH, 0, (1000.0, 1000.0)] +# mpp not within default +TEST_CASE_ERROR_0_MPP = [ + WSI_GENERIC_TIFF_PATH, + {}, + {"location": (WSI_GENERIC_TIFF_HEIGHT // 2, WSI_GENERIC_TIFF_WIDTH // 2), "size": (2, 1), "mpp": 1200}, + ValueError, +] + +# mpp is not exact (no tolerance) +TEST_CASE_ERROR_1_MPP = [ + WSI_APERIO_SVS_PATH, + {"mpp_atol": 0.0, "mpp_rtol": 0.0}, + {"location": (0, 0), "size": (2, 1), "mpp": 8.0}, + ValueError, +] + +# power not within default +TEST_CASE_ERROR_2_POWER = [WSI_APERIO_SVS_PATH, {}, {"location": (0, 0), "size": (2, 1), "power": 40}, ValueError] + +# power is not exact (no tolerance) +TEST_CASE_ERROR_3_POWER = [ + WSI_APERIO_SVS_PATH, + {"power_atol": 0.0, "power_rtol": 0.0}, + {"location": (0, 0), "size": (2, 1), "power": 1.25}, + ValueError, +] + +TEST_CASE_MPP_0 = [WSI_GENERIC_TIFF_PATH, 0, (1000.0, 1000.0)] def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): @@ -236,9 +376,18 @@ def save_gray_tiff(array: np.ndarray, filename: str): @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") def setUpModule(): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + download_url_or_skip_test( + testing_data_config("images", WSI_GENERIC_TIFF_KEY, "url"), + WSI_GENERIC_TIFF_PATH, + hash_type=testing_data_config("images", WSI_GENERIC_TIFF_KEY, "hash_type"), + hash_val=testing_data_config("images", WSI_GENERIC_TIFF_KEY, "hash_val"), + ) + download_url_or_skip_test( + testing_data_config("images", WSI_APERIO_SVS_KEY, "url"), + WSI_APERIO_SVS_PATH, + hash_type=testing_data_config("images", WSI_APERIO_SVS_KEY, "hash_type"), + hash_val=testing_data_config("images", WSI_APERIO_SVS_KEY, "hash_val"), + ) class WSIReaderTests: @@ -269,13 +418,28 @@ def test_read_whole_image(self, file_path, level, expected_shape): TEST_CASE_7, TEST_CASE_8, TEST_CASE_9, + TEST_CASE_10_MPP, + TEST_CASE_11_MPP, + TEST_CASE_12_MPP, + TEST_CASE_13_MPP, + TEST_CASE_14_MPP, + TEST_CASE_15_MPP, + TEST_CASE_16_MPP, + TEST_CASE_17_POWER, + TEST_CASE_18_POWER, + TEST_CASE_19_POWER, + TEST_CASE_20_POWER, + TEST_CASE_21_POWER, ] ) - def test_read_region(self, file_path, kwargs, patch_info, expected_img): - reader = WSIReader(self.backend, **kwargs) - level = patch_info.get("level", kwargs.get("level")) - if self.backend == "tifffile" and level < 2: + def test_read_region(self, file_path, reader_kwargs, patch_info, expected_img, *args): + reader = WSIReader(self.backend, **reader_kwargs) + level = patch_info.get("level", reader_kwargs.get("level")) + # Skip mpp, power tests for TiffFile backend + if self.backend == "tifffile" and (level is None or level < 2 or file_path == WSI_APERIO_SVS_PATH): return + if level is None: + level = args[0].get("level") with reader.read(file_path) as img_obj: # Read twice to check multiple calls img, meta = reader.get_data(img_obj, **patch_info) @@ -453,6 +617,15 @@ def test_read_region_device(self, file_path, kwargs, patch_info, expected_img, d assert_allclose(meta[WSIPatchKeys.SIZE], patch_info["size"], type_test=False) assert_allclose(meta[WSIPatchKeys.LOCATION], patch_info["location"], type_test=False) + @parameterized.expand( + [TEST_CASE_ERROR_0_MPP, TEST_CASE_ERROR_1_MPP, TEST_CASE_ERROR_2_POWER, TEST_CASE_ERROR_3_POWER] + ) + def test_errors(self, file_path, reader_kwargs, patch_info, exception): + with self.assertRaises(exception): + reader = WSIReader(self.backend, **reader_kwargs) + with reader.read(file_path) as img_obj: + reader.get_data(img_obj, **patch_info) + @skipUnless(has_cucim, "Requires cucim") class TestCuCIM(WSIReaderTests.Tests): diff --git a/tests/testing_data/data_config.json b/tests/testing_data/data_config.json index c2d2ba9635..abda77f7eb 100644 --- a/tests/testing_data/data_config.json +++ b/tests/testing_data/data_config.json @@ -1,10 +1,15 @@ { "images": { - "wsi_img": { + "wsi_generic_tiff": { "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/CMU-1.tiff", "hash_type": "sha256", "hash_val": "73a7e89bc15576587c3d68e55d9bf92f09690280166240b48ff4b48230b13bcd" }, + "wsi_aperio_svs": { + "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/Aperio-CMU-1.svs", + "hash_type": "sha256", + "hash_val": "00a3d54482cd707abf254fe69dccc8d06b8ff757a1663f1290c23418c480eb30" + }, "favicon": { "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/favicon.ico.zip", "hash_type": "sha256",