From 008ed39336af0d3fee819a9e72bfa33c870c5370 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 9 Feb 2023 10:09:54 -0500 Subject: [PATCH 01/21] Implement read by magnification power and mpp Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 181 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 169 insertions(+), 12 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 616a306f58..ddc0d7dcd5 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -78,6 +78,74 @@ def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + def _get_valid_level( + self, + wsi, + level: int | None, + mpp: tuple[float, float] | None, + power: int | None, + mpp_rtol: float, + mpp_atol: float, + power_rtol: float, + power_atol: float, + ) -> int: + """ + Returns the level associated to the resolution parameter in the whole slide image. + + Args: + resolution: a dictionary containing resolution information: `level`, `mpp` or `power`. + + """ + + # Check if not more than 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: + if self.get_mpp(wsi, 0) is None: + raise ValueError( + "mpp is not defined in this whole slide image, please use `level` (or `power`) instead." + ) + available_mpps = [self.get_mpp(wsi, level) for level in range(n_levels)] + if mpp in available_mpps: + valid_mpp = mpp + else: + valid_mpp = min(available_mpps, key=lambda x: abs(x[0] - mpp[0]) + abs(x[1] - mpp[1])) # type: ignore + for i in range(2): + if abs(valid_mpp[i] - mpp[i]) > mpp_atol + mpp_rtol * abs(mpp[i]): + raise ValueError( + f"The requested mpp ({mpp}) does not exist in this whole slide image" + f"(with mpp_rtol={mpp_rtol} and mpp_atol={mpp_atol})." + f" The closest matching available mpp is {valid_mpp}." + "Please consider changing the tolerances or use another mpp." + ) + level = available_mpps.index(valid_mpp) + + elif power is not None: + available_powers = [self.get_power(wsi, level) for level in range(n_levels)] + if power in available_powers: + valid_power = power + else: + valid_power = min(available_powers, key=lambda x: abs(x - power)) # type: ignore + if abs(valid_power - power) > power_atol + power_rtol * abs(power): + raise ValueError( + f"The requested power ({power}) does not exist in this whole slide image" + f"(with power_rtol={power_rtol} and power_atol={power_atol})." + f" The closest matching available power is {valid_power}." + "Please consider changing the tolerances or use another power." + ) + level = available_powers.index(valid_power) + else: + if level is None: + level = self.level + 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: """ @@ -114,7 +182,19 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: Args: wsi: a whole slide image object loaded from a file - level: the level number where the size is calculated + level: the level number where mpp is calculated + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + @abstractmethod + def get_power(self, wsi, level: int | None = None) -> int: + """ + Returns the magnification 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 magnification power is calculated """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -176,6 +256,12 @@ def get_data( location: tuple[int, int] = (0, 0), size: tuple[int, int] | None = None, level: int | None = None, + mpp: tuple[float, float] | None = None, + power: int | None = None, + mpp_rtol: float = 0.1, + mpp_atol: float = 0.1, + power_rtol: float = 0.1, + power_atol: float = 0.1, dtype: DtypeLike = np.uint8, mode: str = "RGB", ) -> tuple[np.ndarray, dict]: @@ -197,16 +283,13 @@ def get_data( """ 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(wsi, level, mpp, power, mpp_rtol, mpp_atol, power_rtol, power_atol) # Verify location if location is None: @@ -352,8 +435,8 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: 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. + level: the level number where mpp calculated. + If not provided the default level (from `self.level`) will be used. """ if level is None: @@ -361,6 +444,21 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: return self.reader.get_mpp(wsi, level) + def get_power(self, wsi, level: int | None = None) -> int: + """ + 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 magnification power is calculated. + If not provided the default level (from `self.level`) will be used. + + """ + if level is None: + level = self.level + + 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: @@ -468,7 +566,7 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: 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`) + level: the level number where mpp is calculated. If not provided the default level (from `self.level`) will be used. """ @@ -478,6 +576,30 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: factor = float(wsi.resolutions["level_downsamples"][level]) return (wsi.metadata["cucim"]["spacing"][1] * factor, wsi.metadata["cucim"]["spacing"][0] * factor) + def get_power(self, wsi, level: int | None = None) -> int: + """ + Returns the magnification 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 magnification power is calculated. + If not provided the default level (from `self.level`) will be used. + + """ + if level is None: + level = self.level + + if "aperio" in wsi.metadata: + objective_power = wsi.metadata["aperio"].get("AppMag") + if objective_power: + downsample_ratio = self.get_downsample_ratio(wsi, level) + return round(float(objective_power) / downsample_ratio) + + raise ValueError( + "Objective power can only be obtained for Aperio images using CuCIM." + "Please use `level` (or `mpp`) instead, or try OpenSlide backend." + ) + def read(self, data: Sequence[PathLike] | PathLike | np.ndarray, **kwargs): """ Read whole slide image objects from given file or list of files. @@ -631,6 +753,26 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: factor *= wsi.level_downsamples[level] return (factor / float(wsi.properties["tiff.YResolution"]), factor / float(wsi.properties["tiff.XResolution"])) + def get_power(self, wsi, level: int | None = None) -> int: + """ + Returns the magnification 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 magnification power is calculated. + If not provided the default level (from `self.level`) will be used. + + """ + if level is None: + level = self.level + + objective_power = wsi.properties.get("openslide.objective-power") + if objective_power: + downsample_ratio = self.get_downsample_ratio(wsi, level) + return int(round(objective_power / downsample_ratio)) + + 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): """ Read whole slide image objects from given file or list of files. @@ -778,6 +920,21 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: xres = wsi.pages[level].tags["XResolution"].value return (factor * yres[1] / yres[0], factor * xres[1] / xres[0]) + def get_power(self, wsi, level: int | None = None) -> int: + """ + Returns the magnification 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 magnification power is calculated. + If not provided the default level (from `self.level`) will be used. + + """ + raise ValueError( + "Objective power cannot be obtained from TiffFile object." + "Please use `level` (or `mpp`) instead, or try other backends." + ) + def read(self, data: Sequence[PathLike] | PathLike | np.ndarray, **kwargs): """ Read whole slide image objects from given file or list of files. From 622b19a41f587b2f57a49fd48b8387aafec07051 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 14 Feb 2023 14:05:51 -0500 Subject: [PATCH 02/21] update defaults and comments Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 17 +++++++++-------- tests/test_wsireader.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index ddc0d7dcd5..bf2b23b861 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -109,7 +109,7 @@ def _get_valid_level( raise ValueError( "mpp is not defined in this whole slide image, please use `level` (or `power`) instead." ) - available_mpps = [self.get_mpp(wsi, level) for level in range(n_levels)] + available_mpps = [self.get_mpp(wsi, level) for level in range(n_levels)] # FIXME: not ordered maybe! if mpp in available_mpps: valid_mpp = mpp else: @@ -125,7 +125,7 @@ def _get_valid_level( level = available_mpps.index(valid_mpp) elif power is not None: - available_powers = [self.get_power(wsi, level) for level in range(n_levels)] + available_powers = [self.get_power(wsi, level) for level in range(n_levels)] # FIXME: not ordered maybe! if power in available_powers: valid_power = power else: @@ -188,7 +188,7 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @abstractmethod - def get_power(self, wsi, level: int | None = None) -> int: + def get_power(self, wsi, level: int | None = None) -> float: """ Returns the magnification power of the whole slide image at a given level. @@ -256,12 +256,12 @@ def get_data( location: tuple[int, int] = (0, 0), size: tuple[int, int] | None = None, level: int | None = None, - mpp: tuple[float, float] | None = None, + mpp: float | tuple[float, float] | None = None, power: int | None = None, - mpp_rtol: float = 0.1, - mpp_atol: float = 0.1, - power_rtol: float = 0.1, - power_atol: float = 0.1, + mpp_rtol: float = 0.05, + mpp_atol: float = 0.0, + power_rtol: float = 0.05, + power_atol: float = 0.0, dtype: DtypeLike = np.uint8, mode: str = "RGB", ) -> tuple[np.ndarray, dict]: @@ -274,6 +274,7 @@ def get_data( 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' diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index d435902c28..c3c2d706ef 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -48,6 +48,9 @@ TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] +# ---------------------------------------------------------------------------- +# Test cases for deprecated monai.data.image_reader.WSIReader +# ---------------------------------------------------------------------------- TEST_CASE_DEP_1 = [ FILE_PATH, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, @@ -83,6 +86,10 @@ np.array([[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[237, 237], [237, 237]]]), ] +# ---------------------------------------------------------------------------- +# Test cases for monai.data.wsi_reader.WSIReader +# ---------------------------------------------------------------------------- + TEST_CASE_1 = [ FILE_PATH, {}, @@ -118,6 +125,13 @@ np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), ] +TEST_CASE_6 = [ + FILE_PATH, + {}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "mpp": 0.25}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), +] + TEST_CASE_MULTI_WSI = [ [FILE_PATH, FILE_PATH], {"location": (0, 0), "size": (2, 1), "level": 2}, From 01e7687bc2623aa58d6ba3fcf43aeab835e518c2 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 22 Mar 2023 17:05:11 -0400 Subject: [PATCH 03/21] update defaults and add test cases Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 165 +++++++++++++++++++++++++-------------- tests/test_wsireader.py | 35 ++++++--- 2 files changed, 129 insertions(+), 71 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index b6a3fd51bd..470eae5090 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -27,6 +27,7 @@ dtype_numpy_to_torch, dtype_torch_to_numpy, ensure_tuple, + ensure_tuple_rep, optional_import, require_pkg, ) @@ -48,6 +49,12 @@ class BaseWSIReader(ImageReader): 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, e.g., "RGB" or "RGBA". + mpp: + power: + mpp_rtol: + mpp_atol: + power_rtol: + power_atol: kwargs: additional args for the reader Typical usage of a concrete implementation of this class is: @@ -77,11 +84,17 @@ class BaseWSIReader(ImageReader): def __init__( self, - level: int, + level: int | None, + mpp: float | tuple[float, float] | None, + power: int | None, channel_dim: int, dtype: DtypeLike | torch.dtype, device: torch.device | str | None, mode: str, + mpp_rtol: float, + mpp_atol: float, + power_rtol: float, + power_atol: float, **kwargs, ): super().__init__() @@ -91,6 +104,12 @@ def __init__( self.set_device(device) self.mode = mode self.kwargs = kwargs + self.mpp = mpp if mpp is None else ensure_tuple_rep(mpp, 2) + 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): @@ -124,65 +143,69 @@ def _get_valid_level( level: int | None, mpp: tuple[float, float] | None, power: int | None, - mpp_rtol: float, - mpp_atol: float, - power_rtol: float, - power_atol: float, ) -> int: """ Returns the level associated to the resolution parameter in the whole slide image. + """ - Args: - resolution: a dictionary containing resolution information: `level`, `mpp` or `power`. + if mpp is not None: + mpp = ensure_tuple_rep(mpp, 2) - """ + # 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 - # Check if not more than one resolution parameter is provided. resolution = [val[0] for val in [("level", level), ("mpp", mpp), ("power", power)] if val[1] is not None] + # Check if only one resolution parameter is provided if len(resolution) > 1: raise ValueError(f"Only one of `level`, `mpp`, or `power` should be provided. {resolution} are provided.") + # Set the default value if no resolution parameter is provided. + if len(resolution) < 1: + level = 0 n_levels = self.get_level_count(wsi) - if mpp is not None: + if level is not None: + if level >= n_levels: + raise ValueError(f"The maximum level of this image is {n_levels-1} while level={level} is requested)!") + + elif mpp is not None: if self.get_mpp(wsi, 0) is None: raise ValueError( "mpp is not defined in this whole slide image, please use `level` (or `power`) instead." ) - available_mpps = [self.get_mpp(wsi, level) for level in range(n_levels)] # FIXME: not ordered maybe! + available_mpps = [self.get_mpp(wsi, level) for level in range(n_levels)] if mpp in available_mpps: valid_mpp = mpp else: - valid_mpp = min(available_mpps, key=lambda x: abs(x[0] - mpp[0]) + abs(x[1] - mpp[1])) # type: ignore + valid_mpp = min(available_mpps, key=lambda x: abs(x[0] - mpp[0]) + abs(x[1] - mpp[1])) for i in range(2): - if abs(valid_mpp[i] - mpp[i]) > mpp_atol + mpp_rtol * abs(mpp[i]): + if abs(valid_mpp[i] - mpp[i]) > self.mpp_atol + self.mpp_rtol * abs(mpp[i]): raise ValueError( - f"The requested mpp ({mpp}) does not exist in this whole slide image" - f"(with mpp_rtol={mpp_rtol} and mpp_atol={mpp_atol})." - f" The closest matching available mpp is {valid_mpp}." + f"The requested mpp {mpp} does not exist in this whole slide image" + f"(with mpp_rtol={self.mpp_rtol} and mpp_atol={self.mpp_atol}). " + f"Here is the list of available mpps: {available_mpps}. " + f"The closest matching available mpp is {valid_mpp}." "Please consider changing the tolerances or use another mpp." ) level = available_mpps.index(valid_mpp) elif power is not None: - available_powers = [self.get_power(wsi, level) for level in range(n_levels)] # FIXME: not ordered maybe! + available_powers = [self.get_power(wsi, level) for level in range(n_levels)] if power in available_powers: valid_power = power else: valid_power = min(available_powers, key=lambda x: abs(x - power)) # type: ignore - if abs(valid_power - power) > power_atol + power_rtol * abs(power): + if abs(valid_power - power) > self.power_atol + self.power_rtol * abs(power): raise ValueError( f"The requested power ({power}) does not exist in this whole slide image" - f"(with power_rtol={power_rtol} and power_atol={power_atol})." + f"(with power_rtol={self.power_rtol} and power_atol={self.power_atol})." f" The closest matching available power is {valid_power}." "Please consider changing the tolerances or use another power." ) level = available_powers.index(valid_power) - else: - if level is None: - level = self.level - if level >= n_levels: - raise ValueError(f"The maximum level of this image is {n_levels-1} while level={level} is requested)!") return level @@ -298,10 +321,6 @@ def get_data( level: int | None = None, mpp: float | tuple[float, float] | None = None, power: int | None = None, - mpp_rtol: float = 0.05, - mpp_atol: float = 0.0, - power_rtol: float = 0.05, - power_atol: float = 0.0, mode: str | None = None, ) -> tuple[np.ndarray, dict]: """ @@ -313,7 +332,9 @@ def get_data( 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 - ==: the data type of output image + mpp: micron per pixel + power: objective power + dtype: the data type of output image mode: the output image mode, 'RGB' or 'RGBA' @@ -331,7 +352,7 @@ def get_data( wsi = (wsi,) for each_wsi in ensure_tuple(wsi): # get the valid level based on resolution info - level = self._get_valid_level(wsi, level, mpp, power, mpp_rtol, mpp_atol, power_rtol, power_atol) + level = self._get_valid_level(each_wsi, level, mpp, power) # Verify location if location is None: @@ -437,26 +458,65 @@ class WSIReader(BaseWSIReader): def __init__( self, backend="cucim", - level: int = 0, + level: int | None = None, + mpp: float | tuple[float, float] | None = None, + power: int | None = None, channel_dim: int = 0, dtype: DtypeLike | torch.dtype = np.uint8, device: torch.device | str | None = None, mode: str = "RGB", + mpp_rtol: float = 0.05, + mpp_atol: float = 0.0, + power_rtol: float = 0.05, + power_atol: float = 0.0, **kwargs, ): self.backend = backend.lower() 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, + power=power, + channel_dim=channel_dim, + dtype=dtype, + device=device, + mode=mode, + mpp_rtol=mpp_rtol, + mpp_atol=mpp_atol, + power_rtol=power_rtol, + power_atol=power_atol, + **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, + power=power, + channel_dim=channel_dim, + dtype=dtype, + device=device, + mode=mode, + mpp_rtol=mpp_rtol, + mpp_atol=mpp_atol, + power_rtol=power_rtol, + power_atol=power_atol, + **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, + power=power, + channel_dim=channel_dim, + dtype=dtype, + device=device, + mode=mode, + mpp_rtol=mpp_rtol, + mpp_atol=mpp_atol, + power_rtol=power_rtol, + power_atol=power_atol, + **kwargs, ) else: raise ValueError( @@ -470,6 +530,12 @@ def __init__( self.mode = self.reader.mode self.kwargs = self.reader.kwargs self.metadata = self.reader.metadata + self.mpp = self.reader.mpp + self.power = self.reader.power + self.mpp_rtol = self.reader.mpp_rtol + self.mpp_atol = self.reader.mpp_atol + self.power_rtol = self.reader.power_rtol + self.power_atol = self.reader.power_atol def get_level_count(self, wsi) -> int: """ @@ -602,15 +668,10 @@ class CuCIMWSIReader(BaseWSIReader): 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) + super().__init__(**kwargs) self.num_workers = num_workers @staticmethod @@ -784,16 +845,8 @@ class OpenSlideWSIReader(BaseWSIReader): 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: @@ -963,16 +1016,8 @@ class TiffFileWSIReader(BaseWSIReader): 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: diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index ceb4896485..e89f1ee95c 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -161,6 +161,22 @@ torch.tensor([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=torch.float32), ] +TEST_CASE_10_MPP = [ + FILE_PATH, + {}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "mpp": 1000}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]], dtype=np.uint8), + {"level": 0}, +] + +TEST_CASE_11_MPP = [ + FILE_PATH, + {}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "mpp": 256000}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]], dtype=np.uint8), + {"level": 8}, +] + # device tests TEST_CASE_DEVICE_1 = [ FILE_PATH, @@ -218,13 +234,6 @@ "cpu", ] -TEST_CASE_6 = [ - FILE_PATH, - {}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "mpp": 0.25}, - np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), -] - TEST_CASE_MULTI_WSI = [ [FILE_PATH, FILE_PATH], {"location": (0, 0), "size": (2, 1), "level": 8}, @@ -410,12 +419,16 @@ 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, ] ) - 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")) + if level is None: + level = args[0].get("level") + if (self.backend == "tifffile" and level < 2) or patch_info.get("mpp", reader_kwargs.get("mpp")): return with reader.read(file_path) as img_obj: # Read twice to check multiple calls From 18143964515d44cd118b4c5fe6bb2160018ff655 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 22 Mar 2023 17:10:20 -0400 Subject: [PATCH 04/21] update error msg: Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 470eae5090..cef4dfddce 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -202,6 +202,7 @@ def _get_valid_level( raise ValueError( f"The requested power ({power}) does not exist in this whole slide image" f"(with power_rtol={self.power_rtol} and power_atol={self.power_atol})." + f"Here is the list of available objective powers: {available_powers}. " f" The closest matching available power is {valid_power}." "Please consider changing the tolerances or use another power." ) From 67ce0cbc942028c595601aa4318a99d4db65b2fc Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 27 Mar 2023 14:02:27 -0400 Subject: [PATCH 05/21] formatting Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 44 ++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index cef4dfddce..2e349941d6 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -104,7 +104,7 @@ def __init__( self.set_device(device) self.mode = mode self.kwargs = kwargs - self.mpp = mpp if mpp is None else ensure_tuple_rep(mpp, 2) + 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 @@ -138,53 +138,41 @@ def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") def _get_valid_level( - self, - wsi, - level: int | None, - mpp: tuple[float, float] | None, - power: int | None, + self, wsi, level: int | None, mpp: float | tuple[float, float] | None, power: int | None ) -> int: """ Returns the level associated to the resolution parameter in the whole slide image. """ - if mpp is not None: - mpp = ensure_tuple_rep(mpp, 2) - # 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] - # Check if only one resolution parameter is provided if len(resolution) > 1: raise ValueError(f"Only one of `level`, `mpp`, or `power` should be provided. {resolution} are provided.") - # Set the default value if no resolution parameter is provided. - if len(resolution) < 1: - level = 0 n_levels = self.get_level_count(wsi) - if level is not None: - if level >= n_levels: - raise ValueError(f"The maximum level of this image is {n_levels-1} while level={level} is requested)!") + if mpp is not None: + mpp_: tuple[float, float] = ensure_tuple_rep(mpp, 2) # type: ignore - elif mpp is not None: if self.get_mpp(wsi, 0) is None: raise ValueError( "mpp is not defined in this whole slide image, please use `level` (or `power`) instead." ) available_mpps = [self.get_mpp(wsi, level) for level in range(n_levels)] - if mpp in available_mpps: - valid_mpp = mpp + if mpp_ in available_mpps: + valid_mpp = mpp_ else: - valid_mpp = min(available_mpps, key=lambda x: abs(x[0] - mpp[0]) + abs(x[1] - mpp[1])) + valid_mpp = min(available_mpps, key=lambda x: abs(x[0] - mpp_[0]) + abs(x[1] - mpp_[1])) for i in range(2): - if abs(valid_mpp[i] - mpp[i]) > self.mpp_atol + self.mpp_rtol * abs(mpp[i]): + if abs(valid_mpp[i] - mpp_[i]) > self.mpp_atol + self.mpp_rtol * abs(mpp_[i]): raise ValueError( - f"The requested mpp {mpp} does not exist in this whole slide image" + f"The requested mpp {mpp_} does not exist in this whole slide image" f"(with mpp_rtol={self.mpp_rtol} and mpp_atol={self.mpp_atol}). " f"Here is the list of available mpps: {available_mpps}. " f"The closest matching available mpp is {valid_mpp}." @@ -207,6 +195,12 @@ def _get_valid_level( "Please consider changing the tolerances or use another power." ) level = available_powers.index(valid_power) + 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 @@ -667,11 +661,7 @@ class CuCIMWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "cucim" - def __init__( - self, - num_workers: int = 0, - **kwargs, - ): + def __init__(self, num_workers: int = 0, **kwargs): super().__init__(**kwargs) self.num_workers = num_workers From c5bf655b5eedf995e4bbcd1ace66e96f20d3d4e6 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 27 Mar 2023 14:21:50 -0400 Subject: [PATCH 06/21] arrange params Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 80 ++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 2e349941d6..8b744a0201 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -43,18 +43,18 @@ 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 image should be extracted. + mpp: the resolution in micron per pixel at which the image should be 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: objective power at which the image should be 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"", 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, e.g., "RGB" or "RGBA". - mpp: - power: - mpp_rtol: - mpp_atol: - power_rtol: - power_atol: kwargs: additional args for the reader Typical usage of a concrete implementation of this class is: @@ -86,15 +86,15 @@ def __init__( self, level: int | None, mpp: float | tuple[float, float] | None, + mpp_rtol: float, + mpp_atol: float, power: int | None, + power_rtol: float, + power_atol: float, channel_dim: int, dtype: DtypeLike | torch.dtype, device: torch.device | str | None, mode: str, - mpp_rtol: float, - mpp_atol: float, - power_rtol: float, - power_atol: float, **kwargs, ): super().__init__() @@ -319,23 +319,23 @@ def get_data( 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 - mpp: micron per pixel - power: objective power - dtype: the data type of output image - mode: the output image mode, 'RGB' or 'RGBA' + level: the whole slide image level at which the image should be extracted. Defaults to 0. + mpp: the resolution in micron per pixel at which the image should be extracted. + power: objective power at which the image should be 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. """ if mode is None: mode = self.mode @@ -455,15 +455,15 @@ def __init__( backend="cucim", 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", - mpp_rtol: float = 0.05, - mpp_atol: float = 0.0, - power_rtol: float = 0.05, - power_atol: float = 0.0, **kwargs, ): self.backend = backend.lower() @@ -472,45 +472,45 @@ def __init__( self.reader = CuCIMWSIReader( 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, - mpp_rtol=mpp_rtol, - mpp_atol=mpp_atol, - power_rtol=power_rtol, - power_atol=power_atol, **kwargs, ) elif self.backend == "openslide": self.reader = OpenSlideWSIReader( 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, - mpp_rtol=mpp_rtol, - mpp_atol=mpp_atol, - power_rtol=power_rtol, - power_atol=power_atol, **kwargs, ) elif self.backend == "tifffile": self.reader = TiffFileWSIReader( 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, - mpp_rtol=mpp_rtol, - mpp_atol=mpp_atol, - power_rtol=power_rtol, - power_atol=power_atol, **kwargs, ) else: @@ -519,6 +519,11 @@ 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 @@ -526,11 +531,6 @@ def __init__( self.kwargs = self.reader.kwargs self.metadata = self.reader.metadata self.mpp = self.reader.mpp - self.power = self.reader.power - self.mpp_rtol = self.reader.mpp_rtol - self.mpp_atol = self.reader.mpp_atol - self.power_rtol = self.reader.power_rtol - self.power_atol = self.reader.power_atol def get_level_count(self, wsi) -> int: """ From 82da62ed79f4305d1b64cc8f4f113e9834e96689 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 27 Mar 2023 14:39:22 -0400 Subject: [PATCH 07/21] micor docstring update Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 8b744a0201..5f80b21cd9 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -43,11 +43,11 @@ 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 should be extracted. - mpp: the resolution in micron per pixel at which the image should be extracted. + level: the whole slide image level at which the image is extracted. + mpp: the resolution in micron per pixel at which the image is 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: objective power at which the image should be extracted. + power: objective power at which the image is 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. @@ -326,9 +326,9 @@ def get_data( 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 whole slide image level at which the image should be extracted. Defaults to 0. - mpp: the resolution in micron per pixel at which the image should be extracted. - power: objective power at which the image should be extracted. + level: the whole slide image level at which the image is extracted. Defaults to 0. + mpp: the resolution in micron per pixel at which the image is extracted. + power: objective power at which the image is extracted. dtype: the data type of output image. mode: the output image mode, 'RGB' or 'RGBA'. From 008f9dc85855722fe5ad95ca2422f4f2c57fc22b Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 27 Mar 2023 14:54:40 -0400 Subject: [PATCH 08/21] update docstring Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 61 ++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 5f80b21cd9..4a234a54c5 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -43,11 +43,11 @@ 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. - mpp: the resolution in micron per pixel 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: objective power at which the image is extracted. + power: 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. @@ -57,6 +57,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 @@ -326,9 +331,9 @@ def get_data( 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 whole slide image level at which the image is extracted. Defaults to 0. - mpp: the resolution in micron per pixel at which the image is extracted. - power: objective power 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. + power: objective power at which the patches are extracted. dtype: the data type of output image. mode: the output image mode, 'RGB' or 'RGBA'. @@ -336,6 +341,11 @@ def get_data( Returns: a tuples, where the first element is an image patch [CxHxW] or stack of patches, and second element is a dictionary of metadata. + + Notes: + Only one of resolution parameters, `level`, `mpp`, or `power`, should be provided. + If none of the are provided, it uses the defaults that are set during class instantiation. + If none of the are set here or during class instantiation, `level=0` will be used. """ if mode is None: mode = self.mode @@ -437,12 +447,18 @@ 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: 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 @@ -645,14 +661,19 @@ 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: 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 @@ -822,8 +843,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: 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"", @@ -993,8 +1019,13 @@ 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. + power: 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"", From cfd20e151ea47e7f7d45f4748a24f2a96b2722cb Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 27 Mar 2023 16:05:22 -0400 Subject: [PATCH 09/21] add defaults Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 4a234a54c5..47acf6bd51 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -89,17 +89,17 @@ class BaseWSIReader(ImageReader): def __init__( self, - level: int | None, - mpp: float | tuple[float, float] | None, - mpp_rtol: float, - mpp_atol: float, - power: int | None, - power_rtol: float, - power_atol: float, - 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__() From b4c2dd14068012bc9b1b79e13b454e4e4a485659 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 29 Mar 2023 10:06:27 -0400 Subject: [PATCH 10/21] update mpp, power and add several unittests to cover them Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 196 +++++++++++------------- tests/test_wsireader.py | 230 +++++++++++++++++++++++----- tests/testing_data/data_config.json | 9 +- 3 files changed, 288 insertions(+), 147 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 47acf6bd51..71f1df5711 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 @@ -131,7 +132,7 @@ 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. @@ -221,7 +222,7 @@ def get_level_count(self, wsi) -> int: 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. @@ -239,7 +240,7 @@ 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. @@ -251,13 +252,13 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @abstractmethod - def get_power(self, wsi, level: int | None = None) -> float: + def get_power(self, wsi, level: int) -> float: """ - Returns the magnification power of the whole slide image at a given level. + 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 magnification power is calculated + level: the level number where objective power is calculated """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -558,7 +559,7 @@ def get_level_count(self, wsi) -> int: """ 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. @@ -568,12 +569,9 @@ def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: will be used. """ - 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. @@ -583,16 +581,13 @@ def get_downsample_ratio(self, wsi, level: int | None = None) -> float: will be used. """ - 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. @@ -602,24 +597,18 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: If not provided the default level (from `self.level`) will be used. """ - if level is None: - level = self.level - return self.reader.get_mpp(wsi, level) - def get_power(self, wsi, level: int | None = None) -> int: + 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 magnification power is calculated. + level: the level number where the objective power is calculated. If not provided the default level (from `self.level`) will be used. """ - if level is None: - level = self.level - return self.reader.get_power(wsi, level) def _get_patch( @@ -697,7 +686,7 @@ def get_level_count(wsi) -> int: """ 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. @@ -707,12 +696,9 @@ def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: will be used. """ - 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. @@ -722,9 +708,6 @@ def get_downsample_ratio(self, wsi, level: int | None = None) -> float: will be used. """ - if level is None: - level = self.level - return wsi.resolutions["level_downsamples"][level] # type: ignore @staticmethod @@ -732,7 +715,7 @@ 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. @@ -742,30 +725,24 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: will be used. """ - if level is None: - level = self.level - factor = float(wsi.resolutions["level_downsamples"][level]) return (wsi.metadata["cucim"]["spacing"][1] * factor, wsi.metadata["cucim"]["spacing"][0] * factor) - def get_power(self, wsi, level: int | None = None) -> int: + def get_power(self, wsi, level: int) -> float: """ - Returns the magnification power of the whole slide image at a given level. + 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 magnification power is calculated. + level: the level number where objective power is calculated. If not provided the default level (from `self.level`) will be used. """ - if level is None: - level = self.level - if "aperio" in wsi.metadata: - objective_power = wsi.metadata["aperio"].get("AppMag") + objective_power = float(wsi.metadata["aperio"].get("AppMag")) if objective_power: downsample_ratio = self.get_downsample_ratio(wsi, level) - return round(float(objective_power) / downsample_ratio) + return objective_power / downsample_ratio raise ValueError( "Objective power can only be obtained for Aperio images using CuCIM." @@ -876,7 +853,7 @@ def get_level_count(wsi) -> int: """ 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. @@ -886,12 +863,9 @@ def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: will be used. """ - 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. @@ -901,9 +875,6 @@ def get_downsample_ratio(self, wsi, level: int | None = None) -> float: will be used. """ - if level is None: - level = self.level - return wsi.level_downsamples[level] # type: ignore @staticmethod @@ -911,7 +882,7 @@ 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. @@ -921,42 +892,54 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: will be used. """ - 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}") + if "openslide.mpp-x" in wsi.properties and "openslide.mpp-y" in wsi.properties: + factor = float(wsi.level_downsamples[level]) + return ( + factor * float(wsi.properties["openslide.mpp-y"]), + factor * float(wsi.properties["openslide.mpp-x"]), + ) + + if "tiff.XResolution" in wsi.properties and "tiff.YResolution" in wsi.properties: + unit = wsi.properties.get("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: + warnings.warn( + f"The resolution unit is not a valid tiff resolution or missing, unit={unit}." + " `micrometer` will be used as default." + ) + factor = 1.0 + + factor *= float(wsi.level_downsamples[level]) + return ( + factor / float(wsi.properties["tiff.YResolution"]), + factor / float(wsi.properties["tiff.XResolution"]), + ) - factor *= wsi.level_downsamples[level] - return (factor / float(wsi.properties["tiff.YResolution"]), factor / 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 | None = None) -> int: + def get_power(self, wsi, level: int) -> float: """ - Returns the magnification power of the whole slide image at a given level. + 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 magnification power is calculated. + level: the level number where objective power is calculated. If not provided the default level (from `self.level`) will be used. """ - if level is None: - level = self.level - - objective_power = wsi.properties.get("openslide.objective-power") + objective_power = float(wsi.properties.get("openslide.objective-power")) if objective_power: downsample_ratio = self.get_downsample_ratio(wsi, level) - return int(round(objective_power / downsample_ratio)) + return objective_power / downsample_ratio - raise ValueError("Objective power cannot be obtained for this file. Please use `level` (or `mpp`) instead.") + 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): """ @@ -1052,7 +1035,7 @@ def get_level_count(wsi) -> int: """ 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. @@ -1062,12 +1045,9 @@ def get_size(self, wsi, level: int | None = None) -> tuple[int, int]: will be used. """ - 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. @@ -1077,9 +1057,6 @@ def get_downsample_ratio(self, wsi, level: int | None = None) -> float: will be used. """ - if level is None: - level = self.level - return float(wsi.pages[0].imagelength) / float(wsi.pages[level].imagelength) @staticmethod @@ -1087,7 +1064,7 @@ 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. @@ -1097,33 +1074,42 @@ def get_mpp(self, wsi, level: int | None = None) -> tuple[float, float]: will be used. """ - if level is None: - level = self.level + if "XResolution" in wsi.pages[level].tags and "YResolution" in wsi.pages[level].tags: + unit = wsi.pages[level].tags.get("ResolutionUnit") + if unit is not None: + unit = unit.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: + warnings.warn( + f"The resolution unit is not a valid tiff resolution, unit={unit}." + " `micrometer` will be used as default." + ) + factor = 1.0 + else: + warnings.warn("The resolution unit is missing. `micrometer` will be used as default.") + factor = 1.0 - 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}") + # 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]) - # 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("`mpp` cannot be obtained for this file. Please use `level` instead.") - def get_power(self, wsi, level: int | None = None) -> int: + def get_power(self, wsi, level: int) -> float: """ - Returns the magnification power of the whole slide image at a given level. + 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 magnification power is calculated. + level: the level number where objective power is calculated. If not provided the default level (from `self.level`) will be used. """ diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index e89f1ee95c..60ad744af0 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -37,35 +37,38 @@ _, 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) +TIFF_KEY = "wsi_generic_tiff" +TIFF_URL = testing_data_config("images", TIFF_KEY, "url") +TIFF_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{TIFF_KEY}.tiff") + +SVS_KEY = "wsi_aperio_svs" +SVS_URL = testing_data_config("images", SVS_KEY, "url") +SVS_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{SVS_KEY}.svs") HEIGHT = 32914 WIDTH = 46000 -TEST_CASE_WHOLE_0 = [FILE_PATH, 2, (3, HEIGHT // 4, WIDTH // 4)] +TEST_CASE_WHOLE_0 = [TIFF_PATH, 2, (3, HEIGHT // 4, WIDTH // 4)] -TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] +TEST_CASE_TRANSFORM_0 = [TIFF_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] # ---------------------------------------------------------------------------- # Test cases for *deprecated* monai.data.image_reader.WSIReader # ---------------------------------------------------------------------------- TEST_CASE_DEP_1 = [ - FILE_PATH, + TIFF_PATH, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), ] TEST_CASE_DEP_2 = [ - FILE_PATH, + TIFF_PATH, {"location": (0, 0), "size": (2, 1), "level": 8}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]]), ] TEST_CASE_DEP_3 = [ - FILE_PATH, + TIFF_PATH, {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 2}, np.array( [ @@ -76,13 +79,13 @@ ] TEST_CASE_DEP_4 = [ - FILE_PATH, + TIFF_PATH, {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 1}, np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]), ] TEST_CASE_DEP_5 = [ - FILE_PATH, + TIFF_PATH, {"location": (HEIGHT - 2, WIDTH - 2), "level": 0, "grid_shape": (1, 1)}, np.array([[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[237, 237], [237, 237]]]), ] @@ -92,94 +95,185 @@ # ---------------------------------------------------------------------------- TEST_CASE_0 = [ - FILE_PATH, + 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, + TIFF_PATH, {}, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]], dtype=np.uint8), ] TEST_CASE_2 = [ - FILE_PATH, + 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, + TIFF_PATH, {"channel_dim": -1}, {"location": (HEIGHT // 2, 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, + 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, + 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, + 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, + 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, + 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, + 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 = [ - FILE_PATH, - {}, + TIFF_PATH, + {"mpp_atol": 0.0, "mpp_rtol": 0.0}, {"location": (HEIGHT // 2, 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 = [ - FILE_PATH, - {}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "mpp": 256000}, + TIFF_PATH, + {"mpp_atol": 0.0, "mpp_rtol": 0.0, "mpp": 1000}, + {"location": (HEIGHT // 2, 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 = [ + 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 = [ + 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 = [ + 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 = [ + 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 = [ + 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 = [ + 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 = [ + 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 = [ + 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 = [ + 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 = [ + 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, + 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), @@ -187,7 +281,7 @@ ] TEST_CASE_DEVICE_2 = [ - FILE_PATH, + 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), @@ -195,7 +289,7 @@ ] TEST_CASE_DEVICE_3 = [ - FILE_PATH, + 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), @@ -203,7 +297,7 @@ ] TEST_CASE_DEVICE_4 = [ - FILE_PATH, + 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), @@ -211,7 +305,7 @@ ] TEST_CASE_DEVICE_5 = [ - FILE_PATH, + TIFF_PATH, {"level": 8, "device": "cuda"}, {"location": (0, 0), "size": (2, 1)}, torch.tensor([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=torch.uint8), @@ -219,7 +313,7 @@ ] TEST_CASE_DEVICE_6 = [ - FILE_PATH, + TIFF_PATH, {"level": 8}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), @@ -227,7 +321,7 @@ ] TEST_CASE_DEVICE_7 = [ - FILE_PATH, + TIFF_PATH, {"level": 8, "device": None}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), @@ -235,7 +329,7 @@ ] TEST_CASE_MULTI_WSI = [ - [FILE_PATH, FILE_PATH], + [TIFF_PATH, TIFF_PATH], {"location": (0, 0), "size": (2, 1), "level": 8}, np.concatenate( [ @@ -255,7 +349,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 = [ + TIFF_PATH, + {}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "mpp": 1200}, + ValueError, +] + +# mpp is not exact (no tolerance) +TEST_CASE_ERROR_1_MPP = [ + 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 = [SVS_PATH, {}, {"location": (0, 0), "size": (2, 1), "power": 40}, ValueError] + +# power is not exact (no tolerance) +TEST_CASE_ERROR_3_POWER = [ + SVS_PATH, + {"power_atol": 0.0, "power_rtol": 0.0}, + {"location": (0, 0), "size": (2, 1), "power": 1.25}, + ValueError, +] + +TEST_CASE_MPP_0 = [TIFF_PATH, 0, (1000.0, 1000.0)] def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): @@ -292,9 +413,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( + TIFF_URL, + TIFF_PATH, + hash_type=testing_data_config("images", TIFF_KEY, "hash_type"), + hash_val=testing_data_config("images", TIFF_KEY, "hash_val"), + ) + download_url_or_skip_test( + SVS_URL, + SVS_PATH, + hash_type=testing_data_config("images", SVS_KEY, "hash_type"), + hash_val=testing_data_config("images", SVS_KEY, "hash_val"), + ) @deprecated(since="0.8", msg_suffix="use tests for `monai.wsi_reader.WSIReader` instead, `WSIReaderTests`.") @@ -421,15 +551,26 @@ def test_read_whole_image(self, file_path, level, expected_shape): 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, 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 == SVS_PATH): + return if level is None: level = args[0].get("level") - if (self.backend == "tifffile" and level < 2) or patch_info.get("mpp", reader_kwargs.get("mpp")): - return with reader.read(file_path) as img_obj: # Read twice to check multiple calls img, meta = reader.get_data(img_obj, **patch_info) @@ -607,6 +748,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 TestCuCIMDeprecated(WSIReaderDeprecatedTests.Tests): diff --git a/tests/testing_data/data_config.json b/tests/testing_data/data_config.json index c2d2ba9635..dd167344cc 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://openslide.cs.cmu.edu/download/openslide-testdata/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", @@ -127,4 +132,4 @@ "hash_val": "662135097106b71067cd1fc657f8720f" } } -} +} \ No newline at end of file From 3d2ad49562ec7fc01605573a3aa8befe27b18db1 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 29 Mar 2023 10:08:45 -0400 Subject: [PATCH 11/21] remove redundant check Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 71f1df5711..c457d0a39d 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -165,11 +165,6 @@ def _get_valid_level( if mpp is not None: mpp_: tuple[float, float] = ensure_tuple_rep(mpp, 2) # type: ignore - - if self.get_mpp(wsi, 0) is None: - raise ValueError( - "mpp is not defined in this whole slide image, please use `level` (or `power`) instead." - ) available_mpps = [self.get_mpp(wsi, level) for level in range(n_levels)] if mpp_ in available_mpps: valid_mpp = mpp_ From 45145aa258e1c3a3d9803b56e1dc70bb518f7426 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Mar 2023 14:10:26 +0000 Subject: [PATCH 12/21] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/testing_data/data_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testing_data/data_config.json b/tests/testing_data/data_config.json index dd167344cc..cad83409e4 100644 --- a/tests/testing_data/data_config.json +++ b/tests/testing_data/data_config.json @@ -132,4 +132,4 @@ "hash_val": "662135097106b71067cd1fc657f8720f" } } -} \ No newline at end of file +} From 91da7d70fcfb7bf7df38584d6ef6ed0194671889 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 29 Mar 2023 12:36:21 -0400 Subject: [PATCH 13/21] update unit tests with new wsi key Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_masked_inference_wsi_dataset.py | 7 +++---- tests/test_masked_patch_wsi_dataset.py | 5 ++--- tests/test_patch_wsi_dataset.py | 5 ++--- tests/test_sliding_patch_wsi_dataset.py | 5 ++--- tests/test_smartcache_patch_wsi_dataset.py | 5 ++--- tests/testing_data/data_config.json | 2 +- 6 files changed, 12 insertions(+), 17 deletions(-) diff --git a/tests/test_masked_inference_wsi_dataset.py b/tests/test_masked_inference_wsi_dataset.py index bb90f7900b..c5667caafd 100644 --- a/tests/test_masked_inference_wsi_dataset.py +++ b/tests/test_masked_inference_wsi_dataset.py @@ -27,11 +27,10 @@ _, has_cim = optional_import("cucim", name="CuImage") _, has_osl = optional_import("openslide") -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_NAME = f"temp_{base_name}" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", FILE_NAME + extension) +FILE_NAME = f"temp_{FILE_KEY}" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", FILE_NAME + ".tiff") MASK1, MASK2, MASK4 = "mask1.npy", "mask2.npy", "mask4.npy" 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 d2cc139ebc..c7dc1356f0 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -33,10 +33,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_DEP_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_smartcache_patch_wsi_dataset.py b/tests/test_smartcache_patch_wsi_dataset.py index e51033bb4e..4ded55f6ae 100644 --- a/tests/test_smartcache_patch_wsi_dataset.py +++ b/tests/test_smartcache_patch_wsi_dataset.py @@ -26,10 +26,9 @@ _cucim, has_cim = optional_import("cucim") has_cim = has_cim and hasattr(_cucim, "CuImage") -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/testing_data/data_config.json b/tests/testing_data/data_config.json index dd167344cc..e0686e8fcf 100644 --- a/tests/testing_data/data_config.json +++ b/tests/testing_data/data_config.json @@ -6,7 +6,7 @@ "hash_val": "73a7e89bc15576587c3d68e55d9bf92f09690280166240b48ff4b48230b13bcd" }, "wsi_aperio_svs": { - "url": "https://openslide.cs.cmu.edu/download/openslide-testdata/Aperio/CMU-1.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" }, From db809dc3be96c046c247581a6ad4b89f38e3acec Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 29 Mar 2023 15:44:13 -0400 Subject: [PATCH 14/21] update docsting and mmp for cucim Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 80 ++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index c457d0a39d..182fbbddfb 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -173,7 +173,7 @@ def _get_valid_level( for i in range(2): if abs(valid_mpp[i] - mpp_[i]) > self.mpp_atol + self.mpp_rtol * abs(mpp_[i]): raise ValueError( - f"The requested mpp {mpp_} does not exist in this whole slide image" + f"The requested mpp {mpp_} does not exist in this whole slide image " f"(with mpp_rtol={self.mpp_rtol} and mpp_atol={self.mpp_atol}). " f"Here is the list of available mpps: {available_mpps}. " f"The closest matching available mpp is {valid_mpp}." @@ -189,7 +189,7 @@ def _get_valid_level( valid_power = min(available_powers, key=lambda x: abs(x - power)) # type: ignore if abs(valid_power - power) > self.power_atol + self.power_rtol * abs(power): raise ValueError( - f"The requested power ({power}) does not exist in this whole slide image" + f"The requested power ({power}) does not exist in this whole slide image " f"(with power_rtol={self.power_rtol} and power_atol={self.power_atol})." f"Here is the list of available objective powers: {available_powers}. " f" The closest matching available power is {valid_power}." @@ -223,8 +223,7 @@ def get_downsample_ratio(self, wsi, level: int) -> float: 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. + level: the level number where the size is calculated. """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -560,8 +559,7 @@ def get_size(self, wsi, level: int) -> tuple[int, int]: 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. + level: the level number where the size is calculated. """ return self.reader.get_size(wsi, level) @@ -572,8 +570,7 @@ def get_downsample_ratio(self, wsi, level: int) -> float: 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. + level: the level number where the size is calculated. """ return self.reader.get_downsample_ratio(wsi, level) @@ -589,7 +586,6 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: Args: wsi: a whole slide image object loaded from a file level: the level number where mpp calculated. - If not provided the default level (from `self.level`) will be used. """ return self.reader.get_mpp(wsi, level) @@ -601,7 +597,6 @@ def get_power(self, wsi, level: int) -> float: Args: wsi: a whole slide image object loaded from a file level: the level number where the objective power is calculated. - If not provided the default level (from `self.level`) will be used. """ return self.reader.get_power(wsi, level) @@ -687,8 +682,7 @@ def get_size(self, wsi, level: int) -> tuple[int, int]: 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. + level: the level number where the size is calculated. """ return (wsi.resolutions["level_dimensions"][level][1], wsi.resolutions["level_dimensions"][level][0]) @@ -699,11 +693,10 @@ def get_downsample_ratio(self, wsi, level: int) -> float: 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. + level: the level number where the size is calculated. """ - return wsi.resolutions["level_downsamples"][level] # type: ignore + return float(wsi.resolutions["level_downsamples"][level]) @staticmethod def get_file_path(wsi) -> str: @@ -716,12 +709,19 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: Args: wsi: a whole slide image object loaded from a file - level: the level number where mpp is calculated. If not provided the default level (from `self.level`) - will be used. + level: the level number where mpp is calculated. """ - factor = float(wsi.resolutions["level_downsamples"][level]) - return (wsi.metadata["cucim"]["spacing"][1] * factor, wsi.metadata["cucim"]["spacing"][0] * factor) + downsample_ratio = self.get_downsample_ratio(wsi, level) + if "aperio" in wsi.metadata: + mpp_ = float(wsi.metadata["aperio"].get("MPP")) + if mpp_: + return (downsample_ratio * mpp_, downsample_ratio * mpp_) + + return ( + downsample_ratio * wsi.metadata["cucim"]["spacing"][1], + downsample_ratio * wsi.metadata["cucim"]["spacing"][0], + ) def get_power(self, wsi, level: int) -> float: """ @@ -730,7 +730,6 @@ def get_power(self, wsi, level: int) -> float: Args: wsi: a whole slide image object loaded from a file level: the level number where objective power is calculated. - If not provided the default level (from `self.level`) will be used. """ if "aperio" in wsi.metadata: @@ -854,8 +853,7 @@ def get_size(self, wsi, level: int) -> tuple[int, int]: 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. + level: the level number where the size is calculated. """ return (wsi.level_dimensions[level][1], wsi.level_dimensions[level][0]) @@ -866,8 +864,7 @@ def get_downsample_ratio(self, wsi, level: int) -> float: 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. + level: the level number where the size is calculated. """ return wsi.level_downsamples[level] # type: ignore @@ -883,38 +880,36 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: 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. + level: the level number where the size is calculated. """ + downsample_ratio = self.get_downsample_ratio(wsi, level) if "openslide.mpp-x" in wsi.properties and "openslide.mpp-y" in wsi.properties: - factor = float(wsi.level_downsamples[level]) return ( - factor * float(wsi.properties["openslide.mpp-y"]), - factor * float(wsi.properties["openslide.mpp-x"]), + 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: unit = wsi.properties.get("tiff.ResolutionUnit") if unit == "centimeter": - factor = 10000.0 + unit_factor = 10000.0 elif unit == "millimeter": - factor = 1000.0 + unit_factor = 1000.0 elif unit == "micrometer": - factor = 1.0 + unit_factor = 1.0 elif unit == "inch": - factor = 25400.0 + unit_factor = 25400.0 else: warnings.warn( f"The resolution unit is not a valid tiff resolution or missing, unit={unit}." " `micrometer` will be used as default." ) - factor = 1.0 + unit_factor = 1.0 - factor *= float(wsi.level_downsamples[level]) return ( - factor / float(wsi.properties["tiff.YResolution"]), - factor / float(wsi.properties["tiff.XResolution"]), + unit_factor * downsample_ratio / float(wsi.properties["tiff.YResolution"]), + unit_factor * downsample_ratio / float(wsi.properties["tiff.XResolution"]), ) raise ValueError("`mpp` cannot be obtained for this file. Please use `level` instead.") @@ -926,7 +921,6 @@ def get_power(self, wsi, level: int) -> float: Args: wsi: a whole slide image object loaded from a file level: the level number where objective power is calculated. - If not provided the default level (from `self.level`) will be used. """ objective_power = float(wsi.properties.get("openslide.objective-power")) @@ -1036,8 +1030,7 @@ def get_size(self, wsi, level: int) -> tuple[int, int]: 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. + level: the level number where the size is calculated. """ return (wsi.pages[level].imagelength, wsi.pages[level].imagewidth) @@ -1048,8 +1041,7 @@ def get_downsample_ratio(self, wsi, level: int) -> float: 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. + level: the level number where the size is calculated. """ return float(wsi.pages[0].imagelength) / float(wsi.pages[level].imagelength) @@ -1065,8 +1057,7 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: 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. + level: the level number where the size is calculated. """ if "XResolution" in wsi.pages[level].tags and "YResolution" in wsi.pages[level].tags: @@ -1105,7 +1096,6 @@ def get_power(self, wsi, level: int) -> float: Args: wsi: a whole slide image object loaded from a file level: the level number where objective power is calculated. - If not provided the default level (from `self.level`) will be used. """ raise ValueError( From 20180dc9cc8e5c698162b58410dcb39fd647577e Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 29 Mar 2023 15:51:15 -0400 Subject: [PATCH 15/21] update docstring Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 182fbbddfb..88123efbf5 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -269,7 +269,7 @@ 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 + level: the level number. dtype: the data type of output image mode: the output image mode, 'RGB' or 'RGBA' @@ -288,7 +288,7 @@ def _get_metadata( 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): @@ -612,7 +612,7 @@ 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 + level: the level number. dtype: the data type of output image mode: the output image mode, 'RGB' or 'RGBA' @@ -779,7 +779,7 @@ 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 + level: the level number. dtype: the data type of output image mode: the output image mode, 'RGB' or 'RGBA' @@ -964,7 +964,7 @@ 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 + level: the level number. dtype: the data type of output image mode: the output image mode, 'RGB' or 'RGBA' @@ -1137,7 +1137,7 @@ 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 + level: the level number. dtype: the data type of output image mode: the output image mode, 'RGB' or 'RGBA' From d757ee836eaf5d4017430787c6e2d0a4696ee65a Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 30 Mar 2023 10:25:24 -0400 Subject: [PATCH 16/21] address comments Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 137 +++++++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 50 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 88123efbf5..e533e1ab38 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -61,7 +61,7 @@ class BaseWSIReader(ImageReader): 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. + If none of them are provided here nor in `get_data`, `level=0` will be used. Typical usage of a concrete implementation of this class is: @@ -223,7 +223,7 @@ def get_downsample_ratio(self, wsi, level: int) -> float: Args: wsi: a whole slide image object loaded from a file - level: the level number where the size is calculated. + level: the level number where the downsample ratio is calculated. """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -239,8 +239,8 @@ 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 mpp 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.") @@ -251,8 +251,8 @@ 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 objective power is calculated + 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.") @@ -332,15 +332,14 @@ def get_data( 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. Notes: Only one of resolution parameters, `level`, `mpp`, or `power`, should be provided. - If none of the are provided, it uses the defaults that are set during class instantiation. - If none of the are set here or during class instantiation, `level=0` will be used. + If none of them are provided, it uses the defaults that are set during class instantiation. + If none of them are set here nor during class instantiation, `level=0` will be used. """ if mode is None: mode = self.mode @@ -457,6 +456,11 @@ class WSIReader(BaseWSIReader): 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 nor in `get_data`, `level=0` will be used. + """ supported_backends = ["cucim", "openslide", "tifffile"] @@ -570,7 +574,7 @@ def get_downsample_ratio(self, wsi, level: int) -> float: Args: wsi: a whole slide image object loaded from a file - level: the level number where the size is calculated. + level: the level number where the downsample ratio is calculated. """ return self.reader.get_downsample_ratio(wsi, level) @@ -584,8 +588,8 @@ 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 mpp calculated. + wsi: a whole slide image object loaded from a file. + level: the level number where the mpp is calculated. """ return self.reader.get_mpp(wsi, level) @@ -595,7 +599,7 @@ 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 + wsi: a whole slide image object loaded from a file. level: the level number where the objective power is calculated. """ @@ -656,6 +660,11 @@ class CuCIMWSIReader(BaseWSIReader): 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 nor in `get_data`, `level=0` will be used. + """ supported_suffixes = ["tif", "tiff", "svs"] @@ -692,8 +701,8 @@ 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. + wsi: a whole slide image object loaded from a file. + level: the level number where the downsample ratio is calculated. """ return float(wsi.resolutions["level_downsamples"][level]) @@ -708,35 +717,38 @@ 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 mpp is calculated. + 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 "aperio" in wsi.metadata: - mpp_ = float(wsi.metadata["aperio"].get("MPP")) + mpp_ = wsi.metadata["aperio"].get("MPP") if mpp_: - return (downsample_ratio * mpp_, downsample_ratio * 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]) - return ( - downsample_ratio * wsi.metadata["cucim"]["spacing"][1], - downsample_ratio * wsi.metadata["cucim"]["spacing"][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. Args: - wsi: a whole slide image object loaded from a file - level: the level number where objective power is calculated. + 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 = float(wsi.metadata["aperio"].get("AppMag")) + objective_power = wsi.metadata["aperio"].get("AppMag") if objective_power: downsample_ratio = self.get_downsample_ratio(wsi, level) - return objective_power / downsample_ratio + return float(objective_power) / downsample_ratio raise ValueError( "Objective power can only be obtained for Aperio images using CuCIM." @@ -828,6 +840,11 @@ 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 nor in `get_data`, `level=0` will be used. + """ supported_suffixes = ["tif", "tiff", "svs"] @@ -864,7 +881,7 @@ def get_downsample_ratio(self, wsi, level: int) -> float: Args: wsi: a whole slide image object loaded from a file - level: the level number where the size is calculated. + level: the level number where the downsample ratio is calculated. """ return wsi.level_downsamples[level] # type: ignore @@ -879,54 +896,64 @@ 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. """ downsample_ratio = self.get_downsample_ratio(wsi, level) - if "openslide.mpp-x" in wsi.properties and "openslide.mpp-y" in wsi.properties: + 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: + 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 == "centimeter": - unit_factor = 10000.0 + factor = 10000.0 elif unit == "millimeter": - unit_factor = 1000.0 + factor = 1000.0 elif unit == "micrometer": - unit_factor = 1.0 + factor = 1.0 elif unit == "inch": - unit_factor = 25400.0 + factor = 25400.0 else: warnings.warn( f"The resolution unit is not a valid tiff resolution or missing, unit={unit}." " `micrometer` will be used as default." ) - unit_factor = 1.0 + factor = 1.0 return ( - unit_factor * downsample_ratio / float(wsi.properties["tiff.YResolution"]), - unit_factor * downsample_ratio / float(wsi.properties["tiff.XResolution"]), + factor * downsample_ratio / float(wsi.properties["tiff.YResolution"]), + factor * downsample_ratio / float(wsi.properties["tiff.XResolution"]), ) - raise ValueError("`mpp` cannot be obtained for this file. Please use `level` instead.") + 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. Args: - wsi: a whole slide image object loaded from a file - level: the level number where objective power is calculated. + wsi: a whole slide image object loaded from a file. + level: the level number where the objective power is calculated. """ - objective_power = float(wsi.properties.get("openslide.objective-power")) + objective_power = wsi.properties.get("openslide.objective-power") if objective_power: downsample_ratio = self.get_downsample_ratio(wsi, level) - return objective_power / downsample_ratio + return float(objective_power) / downsample_ratio raise ValueError("Objective `power` cannot be obtained for this file. Please use `level` (or `mpp`) instead.") @@ -1005,6 +1032,11 @@ class TiffFileWSIReader(BaseWSIReader): mode: the output image color mode, "RGB" or "RGBA". Defaults to "RGB". kwargs: additional args for `tifffile.TiffFile` 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 nor in `get_data`, `level=0` will be used. + """ supported_suffixes = ["tif", "tiff", "svs"] @@ -1041,7 +1073,7 @@ def get_downsample_ratio(self, wsi, level: int) -> float: Args: wsi: a whole slide image object loaded from a file - level: the level number where the size is calculated. + level: the level number where the downsample ratio is calculated. """ return float(wsi.pages[0].imagelength) / float(wsi.pages[level].imagelength) @@ -1056,11 +1088,16 @@ 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. """ - if "XResolution" in wsi.pages[level].tags and "YResolution" in wsi.pages[level].tags: + 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 = unit.value @@ -1094,8 +1131,8 @@ 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 objective power is calculated. + wsi: a whole slide image object loaded from a file. + level: the level number where the objective power is calculated. """ raise ValueError( From 5221ddccba4232488b1e1e281aaa9d3589754152 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 30 Mar 2023 10:51:19 -0400 Subject: [PATCH 17/21] update error msg Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index e533e1ab38..6ff158b810 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -751,7 +751,7 @@ def get_power(self, wsi, level: int) -> float: return float(objective_power) / downsample_ratio raise ValueError( - "Objective power can only be obtained for Aperio images using CuCIM." + "Currently, cuCIM backend can obtain the objective power only for Aperio images." "Please use `level` (or `mpp`) instead, or try OpenSlide backend." ) @@ -1022,9 +1022,6 @@ class TiffFileWSIReader(BaseWSIReader): 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: 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"", @@ -1033,9 +1030,10 @@ class TiffFileWSIReader(BaseWSIReader): kwargs: additional args for `tifffile.TiffFile` 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 nor in `get_data`, `level=0` will be used. + - 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 nor in `get_data`, `level=0` will be used. """ @@ -1136,7 +1134,7 @@ def get_power(self, wsi, level: int) -> float: """ raise ValueError( - "Objective power cannot be obtained from TiffFile object." + "Currently, TiffFile does not provide a general API to obtain objective power." "Please use `level` (or `mpp`) instead, or try other backends." ) From 9c52f65e15f1e7dfb00c4a0771b501ec17c2f617 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 30 Mar 2023 12:45:08 -0400 Subject: [PATCH 18/21] make get_valid_level public and upate docstrings Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 89 ++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 6ff158b810..7f0024b2d5 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -48,7 +48,7 @@ class BaseWSIReader(ImageReader): 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: objective power at which the patches are extracted. + 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. @@ -137,17 +137,24 @@ 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 _get_valid_level( + 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 parameter in the whole slide image. + 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 @@ -211,7 +218,7 @@ 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.") @@ -222,7 +229,7 @@ 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 + wsi: a whole slide image object loaded from a file. level: the level number where the downsample ratio is calculated. """ @@ -270,8 +277,8 @@ def _get_patch( 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. - dtype: the data type of output image - mode: the output image mode, 'RGB' or 'RGBA' + 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.") @@ -283,8 +290,8 @@ 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. @@ -328,7 +335,7 @@ def get_data( If not provided or None, it is set to the full image size at the given level. 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: objective power 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'. @@ -351,7 +358,7 @@ def get_data( wsi = (wsi,) for each_wsi in ensure_tuple(wsi): # get the valid level based on resolution info - level = self._get_valid_level(each_wsi, level, mpp, power) + level = self.get_valid_level(each_wsi, level, mpp, power) # Verify location if location is None: @@ -445,7 +452,7 @@ class WSIReader(BaseWSIReader): 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: objective power at which the patches are extracted. + 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). @@ -552,7 +559,7 @@ 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) @@ -562,7 +569,7 @@ 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 + wsi: a whole slide image object loaded from a file. level: the level number where the size is calculated. """ @@ -573,7 +580,7 @@ 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 + wsi: a whole slide image object loaded from a file. level: the level number where the downsample ratio is calculated. """ @@ -612,13 +619,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. 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) @@ -632,7 +639,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) @@ -648,7 +655,7 @@ class CuCIMWSIReader(BaseWSIReader): 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: objective power at which the patches are extracted. + 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). @@ -680,7 +687,7 @@ 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 @@ -690,7 +697,7 @@ 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 + wsi: a whole slide image object loaded from a file. level: the level number where the size is calculated. """ @@ -751,7 +758,7 @@ def get_power(self, wsi, level: int) -> float: return float(objective_power) / downsample_ratio raise ValueError( - "Currently, cuCIM backend can obtain the objective power only for Aperio images." + "Currently, cuCIM backend can obtain the objective power only for Aperio images. " "Please use `level` (or `mpp`) instead, or try OpenSlide backend." ) @@ -765,7 +772,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") @@ -787,13 +794,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. - dtype: the data type of output image - mode: the output image mode, 'RGB' or 'RGBA' + dtype: the data type of output image. + mode: the output image mode, 'RGB' or 'RGBA'. """ # Extract a patch or the entire image @@ -830,7 +837,7 @@ class OpenSlideWSIReader(BaseWSIReader): 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: objective power at which the patches are extracted. + 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). @@ -859,7 +866,7 @@ 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 @@ -869,7 +876,7 @@ 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 + wsi: a whole slide image object loaded from a file. level: the level number where the size is calculated. """ @@ -880,7 +887,7 @@ 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 + wsi: a whole slide image object loaded from a file. level: the level number where the downsample ratio is calculated. """ @@ -966,7 +973,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 = [] @@ -992,8 +999,8 @@ def _get_patch( 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. - dtype: the data type of output image - mode: the output image mode, 'RGB' or 'RGBA' + dtype: the data type of output image. + mode: the output image mode, 'RGB' or 'RGBA'. """ # Extract a patch or the entire image @@ -1049,7 +1056,7 @@ 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) @@ -1059,7 +1066,7 @@ 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 + wsi: a whole slide image object loaded from a file. level: the level number where the size is calculated. """ @@ -1070,7 +1077,7 @@ 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 + wsi: a whole slide image object loaded from a file. level: the level number where the downsample ratio is calculated. """ @@ -1147,7 +1154,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 = [] @@ -1173,8 +1180,8 @@ def _get_patch( 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. - dtype: the data type of output image - mode: the output image mode, 'RGB' or 'RGBA' + dtype: the data type of output image. + mode: the output image mode, 'RGB' or 'RGBA'. """ # Load the entire image From 14b6df43975849d844a9acf94bcfe34e39ce08e8 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 3 Apr 2023 16:26:31 -0400 Subject: [PATCH 19/21] address comments and implement ConverUnits Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 56 +++++----------- monai/utils/misc.py | 77 ++++++++++++++++++++++ tests/test_wsireader.py | 135 ++++++++++++++++++++------------------- 3 files changed, 163 insertions(+), 105 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 7f0024b2d5..cc48735091 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -32,6 +32,7 @@ optional_import, require_pkg, ) +from monai.utils.misc import ConvertUnits OpenSlide, _ = optional_import("openslide", name="OpenSlide") TiffFile, _ = optional_import("tifffile", name="TiffFile") @@ -61,7 +62,7 @@ class BaseWSIReader(ImageReader): 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 nor in `get_data`, `level=0` will be used. + 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: @@ -346,7 +347,7 @@ def get_data( 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 nor during class instantiation, `level=0` will be used. + If none of them are set here or during class instantiation, `level=0` will be used. """ if mode is None: mode = self.mode @@ -466,7 +467,7 @@ class WSIReader(BaseWSIReader): 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 nor in `get_data`, `level=0` will be used. + If none of them are provided here or in `get_data`, `level=0` will be used. """ @@ -670,7 +671,7 @@ class CuCIMWSIReader(BaseWSIReader): 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 nor in `get_data`, `level=0` will be used. + If none of them are provided here or in `get_data`, `level=0` will be used. """ @@ -850,7 +851,7 @@ class OpenSlideWSIReader(BaseWSIReader): 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 nor in `get_data`, `level=0` will be used. + If none of them are provided here or in `get_data`, `level=0` will be used. """ @@ -926,24 +927,14 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: and wsi.properties["tiff.XResolution"] ): unit = wsi.properties.get("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: - warnings.warn( - f"The resolution unit is not a valid tiff resolution or missing, unit={unit}." - " `micrometer` will be used as default." - ) - factor = 1.0 + 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 ( - factor * downsample_ratio / float(wsi.properties["tiff.YResolution"]), - factor * downsample_ratio / float(wsi.properties["tiff.XResolution"]), + 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.") @@ -1040,7 +1031,7 @@ class TiffFileWSIReader(BaseWSIReader): - 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 nor in `get_data`, `level=0` will be used. + If none of them are provided here or in `get_data`, `level=0` will be used. """ @@ -1105,29 +1096,16 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: ): unit = wsi.pages[level].tags.get("ResolutionUnit") if unit is not None: - unit = unit.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: - warnings.warn( - f"The resolution unit is not a valid tiff resolution, unit={unit}." - " `micrometer` will be used as default." - ) - factor = 1.0 + unit = str(unit.value)[8:] else: warnings.warn("The resolution unit is missing. `micrometer` will be used as default.") - factor = 1.0 + 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 (factor * yres[1] / yres[0], factor * xres[1] / xres[0]) + 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.") 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_wsireader.py b/tests/test_wsireader.py index 60ad744af0..322403a3d4 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -37,38 +37,41 @@ _, has_codec = optional_import("imagecodecs") has_tiff = has_tiff and has_codec -TIFF_KEY = "wsi_generic_tiff" -TIFF_URL = testing_data_config("images", TIFF_KEY, "url") -TIFF_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{TIFF_KEY}.tiff") +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") -SVS_KEY = "wsi_aperio_svs" -SVS_URL = testing_data_config("images", SVS_KEY, "url") -SVS_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{SVS_KEY}.svs") +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") -HEIGHT = 32914 -WIDTH = 46000 +WSI_GENERIC_TIFF_HEIGHT = 32914 +WSI_GENERIC_TIFF_WIDTH = 46000 -TEST_CASE_WHOLE_0 = [TIFF_PATH, 2, (3, HEIGHT // 4, WIDTH // 4)] +TEST_CASE_WHOLE_0 = [WSI_GENERIC_TIFF_PATH, 2, (3, WSI_GENERIC_TIFF_HEIGHT // 4, WSI_GENERIC_TIFF_WIDTH // 4)] -TEST_CASE_TRANSFORM_0 = [TIFF_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] +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 *deprecated* monai.data.image_reader.WSIReader # ---------------------------------------------------------------------------- TEST_CASE_DEP_1 = [ - TIFF_PATH, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + WSI_GENERIC_TIFF_PATH, + {"location": (WSI_GENERIC_TIFF_HEIGHT // 2, WSI_GENERIC_TIFF_WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), ] TEST_CASE_DEP_2 = [ - TIFF_PATH, + WSI_GENERIC_TIFF_PATH, {"location": (0, 0), "size": (2, 1), "level": 8}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]]), ] TEST_CASE_DEP_3 = [ - TIFF_PATH, + WSI_GENERIC_TIFF_PATH, {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 2}, np.array( [ @@ -79,14 +82,14 @@ ] TEST_CASE_DEP_4 = [ - TIFF_PATH, + WSI_GENERIC_TIFF_PATH, {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 1}, np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]), ] TEST_CASE_DEP_5 = [ - TIFF_PATH, - {"location": (HEIGHT - 2, WIDTH - 2), "level": 0, "grid_shape": (1, 1)}, + WSI_GENERIC_TIFF_PATH, + {"location": (WSI_GENERIC_TIFF_HEIGHT - 2, WSI_GENERIC_TIFF_WIDTH - 2), "level": 0, "grid_shape": (1, 1)}, np.array([[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[237, 237], [237, 237]]]), ] @@ -95,70 +98,70 @@ # ---------------------------------------------------------------------------- TEST_CASE_0 = [ - TIFF_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 = [ - TIFF_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 = [ - TIFF_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 = [ - TIFF_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 = [ - TIFF_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 = [ - TIFF_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 = [ - TIFF_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 = [ - TIFF_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 = [ - TIFF_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 = [ - TIFF_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), @@ -166,25 +169,25 @@ # exact mpp in get_data TEST_CASE_10_MPP = [ - TIFF_PATH, + WSI_GENERIC_TIFF_PATH, {"mpp_atol": 0.0, "mpp_rtol": 0.0}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "mpp": 1000}, + {"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 = [ - TIFF_PATH, + WSI_GENERIC_TIFF_PATH, {"mpp_atol": 0.0, "mpp_rtol": 0.0, "mpp": 1000}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1)}, + {"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 = [ - SVS_PATH, + 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), @@ -192,7 +195,7 @@ ] # acceptable mpp within default tolerances TEST_CASE_13_MPP = [ - TIFF_PATH, + WSI_GENERIC_TIFF_PATH, {}, {"location": (0, 0), "size": (2, 1), "mpp": 256000}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), @@ -201,7 +204,7 @@ # acceptable mpp within default tolerances (Aperio SVS) TEST_CASE_14_MPP = [ - SVS_PATH, + WSI_APERIO_SVS_PATH, {"mpp": 8.0}, {"location": (0, 0), "size": (2, 1)}, np.array([[[238], [240]], [[239], [241]], [[240], [241]]], dtype=np.uint8), @@ -210,7 +213,7 @@ # acceptable mpp within absolute tolerance (Aperio SVS) TEST_CASE_15_MPP = [ - SVS_PATH, + 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), @@ -219,7 +222,7 @@ # acceptable mpp within relative tolerance (Aperio SVS) TEST_CASE_16_MPP = [ - SVS_PATH, + 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), @@ -229,7 +232,7 @@ # exact power TEST_CASE_17_POWER = [ - SVS_PATH, + 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), @@ -238,7 +241,7 @@ # exact power TEST_CASE_18_POWER = [ - SVS_PATH, + 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), @@ -247,7 +250,7 @@ # acceptable power within default tolerances (Aperio SVS) TEST_CASE_19_POWER = [ - SVS_PATH, + WSI_APERIO_SVS_PATH, {}, {"location": (0, 0), "size": (2, 1), "power": 1.25}, np.array([[[238], [240]], [[239], [241]], [[240], [241]]], dtype=np.uint8), @@ -256,7 +259,7 @@ # acceptable power within absolute tolerance (Aperio SVS) TEST_CASE_20_POWER = [ - SVS_PATH, + 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), @@ -265,7 +268,7 @@ # acceptable power within relative tolerance (Aperio SVS) TEST_CASE_21_POWER = [ - SVS_PATH, + 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), @@ -273,7 +276,7 @@ ] # device tests TEST_CASE_DEVICE_1 = [ - TIFF_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), @@ -281,7 +284,7 @@ ] TEST_CASE_DEVICE_2 = [ - TIFF_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), @@ -289,7 +292,7 @@ ] TEST_CASE_DEVICE_3 = [ - TIFF_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), @@ -297,7 +300,7 @@ ] TEST_CASE_DEVICE_4 = [ - TIFF_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), @@ -305,7 +308,7 @@ ] TEST_CASE_DEVICE_5 = [ - TIFF_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), @@ -313,7 +316,7 @@ ] TEST_CASE_DEVICE_6 = [ - TIFF_PATH, + WSI_GENERIC_TIFF_PATH, {"level": 8}, {"location": (0, 0), "size": (2, 1)}, np.array([[[242], [242]], [[242], [242]], [[242], [242]]], dtype=np.uint8), @@ -321,7 +324,7 @@ ] TEST_CASE_DEVICE_7 = [ - TIFF_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), @@ -329,7 +332,7 @@ ] TEST_CASE_MULTI_WSI = [ - [TIFF_PATH, TIFF_PATH], + [WSI_GENERIC_TIFF_PATH, WSI_GENERIC_TIFF_PATH], {"location": (0, 0), "size": (2, 1), "level": 8}, np.concatenate( [ @@ -351,32 +354,32 @@ # mpp not within default TEST_CASE_ERROR_0_MPP = [ - TIFF_PATH, + WSI_GENERIC_TIFF_PATH, {}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "mpp": 1200}, + {"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 = [ - SVS_PATH, + 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 = [SVS_PATH, {}, {"location": (0, 0), "size": (2, 1), "power": 40}, ValueError] +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 = [ - SVS_PATH, + 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 = [TIFF_PATH, 0, (1000.0, 1000.0)] +TEST_CASE_MPP_0 = [WSI_GENERIC_TIFF_PATH, 0, (1000.0, 1000.0)] def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): @@ -414,16 +417,16 @@ 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(): download_url_or_skip_test( - TIFF_URL, - TIFF_PATH, - hash_type=testing_data_config("images", TIFF_KEY, "hash_type"), - hash_val=testing_data_config("images", TIFF_KEY, "hash_val"), + 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( - SVS_URL, - SVS_PATH, - hash_type=testing_data_config("images", SVS_KEY, "hash_type"), - hash_val=testing_data_config("images", SVS_KEY, "hash_val"), + 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"), ) @@ -567,7 +570,7 @@ def test_read_region(self, file_path, reader_kwargs, patch_info, expected_img, * 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 == SVS_PATH): + 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") From 0a4690807e562861e92f89d73a290c1501f38700 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 3 Apr 2023 16:47:32 -0400 Subject: [PATCH 20/21] correct removed files Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_masked_inference_wsi_dataset.py | 197 --------------------- tests/test_smartcache_patch_wsi_dataset.py | 172 ------------------ 2 files changed, 369 deletions(-) delete mode 100644 tests/test_masked_inference_wsi_dataset.py delete mode 100644 tests/test_smartcache_patch_wsi_dataset.py diff --git a/tests/test_masked_inference_wsi_dataset.py b/tests/test_masked_inference_wsi_dataset.py deleted file mode 100644 index c5667caafd..0000000000 --- a/tests/test_masked_inference_wsi_dataset.py +++ /dev/null @@ -1,197 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import os -import tempfile -import unittest -from unittest import skipUnless - -import numpy as np -from numpy.testing import assert_array_equal -from parameterized import parameterized - -from monai.apps.pathology.data import MaskedInferenceWSIDataset -from monai.utils import optional_import -from tests.utils import download_url_or_skip_test, skip_if_quick, testing_data_config - -_, has_cim = optional_import("cucim", name="CuImage") -_, has_osl = optional_import("openslide") - -FILE_KEY = "wsi_generic_tiff" -FILE_URL = testing_data_config("images", FILE_KEY, "url") -FILE_NAME = f"temp_{FILE_KEY}" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", FILE_NAME + ".tiff") - -MASK1, MASK2, MASK4 = "mask1.npy", "mask2.npy", "mask4.npy" - -HEIGHT = 32914 -WIDTH = 46000 - - -def prepare_data(*masks): - mask = np.zeros((HEIGHT // 2, WIDTH // 2)) - mask[100, 100] = 1 - np.save(masks[0], mask) - mask[100, 101] = 1 - np.save(masks[1], mask) - mask[100:102, 100:102] = 1 - np.save(masks[2], mask) - - -TEST_CASE_0 = [ - {"data": [{"image": FILE_PATH, "mask": MASK1}], "patch_size": 1, "image_reader_name": "cuCIM"}, - [{"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "name": FILE_NAME, "mask_location": [100, 100]}], -] - -TEST_CASE_1 = [ - {"data": [{"image": FILE_PATH, "mask": MASK2}], "patch_size": 1, "image_reader_name": "cuCIM"}, - [ - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [100, 100], - }, - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [100, 101], - }, - ], -] - -TEST_CASE_2 = [ - {"data": [{"image": FILE_PATH, "mask": MASK4}], "patch_size": 1, "image_reader_name": "cuCIM"}, - [ - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [100, 100], - }, - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [100, 101], - }, - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [101, 100], - }, - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [101, 101], - }, - ], -] - -TEST_CASE_3 = [ - {"data": [{"image": FILE_PATH, "mask": MASK1}], "patch_size": 2, "image_reader_name": "cuCIM"}, - [ - { - "image": np.array( - [[[243, 243], [243, 243]], [[243, 243], [243, 243]], [[243, 243], [243, 243]]], dtype=np.uint8 - ), - "name": FILE_NAME, - "mask_location": [100, 100], - } - ], -] - -TEST_CASE_4 = [ - { - "data": [{"image": FILE_PATH, "mask": MASK1}, {"image": FILE_PATH, "mask": MASK2}], - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [ - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [100, 100], - }, - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [100, 100], - }, - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [100, 101], - }, - ], -] - -TEST_CASE_OPENSLIDE_0 = [ - {"data": [{"image": FILE_PATH, "mask": MASK1}], "patch_size": 1, "image_reader_name": "OpenSlide"}, - [{"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "name": FILE_NAME, "mask_location": [100, 100]}], -] - -TEST_CASE_OPENSLIDE_1 = [ - {"data": [{"image": FILE_PATH, "mask": MASK2}], "patch_size": 1, "image_reader_name": "OpenSlide"}, - [ - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [100, 100], - }, - { - "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": FILE_NAME, - "mask_location": [100, 101], - }, - ], -] - - -@skip_if_quick -class TestMaskedInferenceWSIDataset(unittest.TestCase): - def setUp(self): - self.base_dir = tempfile.TemporaryDirectory() - prepare_data(*[os.path.join(self.base_dir.name, m) for m in [MASK1, MASK2, MASK4]]) - 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) - - def tearDown(self): - self.base_dir.cleanup() - - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) - @skipUnless(has_cim, "Requires CuCIM") - @skip_if_quick - def test_read_patches_cucim(self, input_parameters, expected): - for m in input_parameters["data"]: - m["mask"] = os.path.join(self.base_dir.name, m["mask"]) - dataset = MaskedInferenceWSIDataset(**input_parameters) - self.compare_samples_expected(dataset, expected) - - @parameterized.expand([TEST_CASE_OPENSLIDE_0, TEST_CASE_OPENSLIDE_1]) - @skipUnless(has_osl, "Requires OpenSlide") - @skip_if_quick - def test_read_patches_openslide(self, input_parameters, expected): - for m in input_parameters["data"]: - m["mask"] = os.path.join(self.base_dir.name, m["mask"]) - dataset = MaskedInferenceWSIDataset(**input_parameters) - self.compare_samples_expected(dataset, expected) - - def compare_samples_expected(self, dataset, expected): - for i, item in enumerate(dataset): - self.assertTupleEqual(item[0]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(item[0]["image"], expected[i]["image"])) - self.assertEqual(item[0]["name"], expected[i]["name"]) - self.assertListEqual(item[0]["mask_location"], expected[i]["mask_location"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_smartcache_patch_wsi_dataset.py b/tests/test_smartcache_patch_wsi_dataset.py deleted file mode 100644 index 4ded55f6ae..0000000000 --- a/tests/test_smartcache_patch_wsi_dataset.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import os -import unittest -from unittest import skipUnless - -import numpy as np -from numpy.testing import assert_array_equal -from parameterized import parameterized - -from monai.apps.pathology.data import SmartCachePatchWSIDataset -from monai.utils import optional_import -from tests.utils import download_url_or_skip_test, testing_data_config - -_cucim, has_cim = optional_import("cucim") -has_cim = has_cim and hasattr(_cucim, "CuImage") - -FILE_KEY = "wsi_generic_tiff" -FILE_URL = testing_data_config("images", FILE_KEY, "url") -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"temp_{FILE_KEY}.tiff") - -TEST_CASE_0 = [ - { - "data": [ - {"image": FILE_PATH, "location": [0, 0], "label": [0]}, - {"image": FILE_PATH, "location": [0, 0], "label": [1]}, - {"image": FILE_PATH, "location": [0, 0], "label": [2]}, - {"image": FILE_PATH, "location": [0, 0], "label": [3]}, - ], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "transform": lambda x: x, - "image_reader_name": "cuCIM", - "replace_rate": 0.5, - "cache_num": 2, - "num_init_workers": 1, - "num_replace_workers": 1, - "copy_cache": False, - }, - [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[3]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[3]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0]]])}, - ], -] - -TEST_CASE_1 = [ - { - "data": [ - {"image": FILE_PATH, "location": [0, 0], "label": [[0, 0]]}, - {"image": FILE_PATH, "location": [0, 0], "label": [[1, 1]]}, - {"image": FILE_PATH, "location": [0, 0], "label": [[2, 2]]}, - ], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "transform": lambda x: x, - "image_reader_name": "cuCIM", - "replace_rate": 0.5, - "cache_num": 2, - "num_init_workers": 1, - "num_replace_workers": 1, - }, - [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 0]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 1]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 1]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[2, 2]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[2, 2]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 0]]])}, - ], -] - -TEST_CASE_2 = [ - { - "data": [ - {"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 0]}, - {"image": FILE_PATH, "location": [10004, 20004], "label": [1, 1, 1, 1]}, - {"image": FILE_PATH, "location": [10004, 20004], "label": [2, 2, 2, 2]}, - ], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "transform": lambda x: x, - "image_reader_name": "cuCIM", - "replace_rate": 0.5, - "cache_num": 2, - "num_init_workers": 1, - "num_replace_workers": 1, - }, - [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[2]]])}, - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - ], -] - - -class TestSmartCachePatchWSIDataset(unittest.TestCase): - def setUp(self): - 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) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2]) - @skipUnless(has_cim, "Requires CuCIM") - def test_read_patches(self, input_parameters, expected): - dataset = SmartCachePatchWSIDataset(**input_parameters) - self.assertEqual(len(dataset), input_parameters["cache_num"]) - total_num_samples = len(input_parameters["data"]) - num_epochs = int( - np.ceil(total_num_samples / (input_parameters["cache_num"] * input_parameters["replace_rate"])) - ) - - dataset.start() - i = 0 - for _ in range(num_epochs): - for samples in dataset: - n_patches = len(samples) - self.assert_samples_expected(samples, expected[i : i + n_patches]) - i += n_patches - dataset.update_cache() - dataset.shutdown() - - def assert_samples_expected(self, samples, expected): - for i, item in enumerate(samples): - self.assertTupleEqual(item["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(item["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(item["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(item["image"], expected[i]["image"])) - - -if __name__ == "__main__": - unittest.main() From ce8c49a108f4cdb2a9251b102970ffb2ea2e55fe Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 4 Apr 2023 14:43:27 -0400 Subject: [PATCH 21/21] simplify find closest level Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 61 ++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index cc48735091..2930676be3 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -144,6 +144,32 @@ def get_size(self, wsi, level: int) -> tuple[int, int]: """ 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: @@ -172,38 +198,13 @@ def get_valid_level( n_levels = self.get_level_count(wsi) if mpp is not None: - mpp_: tuple[float, float] = ensure_tuple_rep(mpp, 2) # type: ignore + mpp_ = ensure_tuple_rep(mpp, 2) available_mpps = [self.get_mpp(wsi, level) for level in range(n_levels)] - if mpp_ in available_mpps: - valid_mpp = mpp_ - else: - valid_mpp = min(available_mpps, key=lambda x: abs(x[0] - mpp_[0]) + abs(x[1] - mpp_[1])) - for i in range(2): - if abs(valid_mpp[i] - mpp_[i]) > self.mpp_atol + self.mpp_rtol * abs(mpp_[i]): - raise ValueError( - f"The requested mpp {mpp_} does not exist in this whole slide image " - f"(with mpp_rtol={self.mpp_rtol} and mpp_atol={self.mpp_atol}). " - f"Here is the list of available mpps: {available_mpps}. " - f"The closest matching available mpp is {valid_mpp}." - "Please consider changing the tolerances or use another mpp." - ) - level = available_mpps.index(valid_mpp) - + level = self._find_closest_level("mpp", mpp_, available_mpps, self.mpp_atol, self.mpp_rtol) elif power is not None: - available_powers = [self.get_power(wsi, level) for level in range(n_levels)] - if power in available_powers: - valid_power = power - else: - valid_power = min(available_powers, key=lambda x: abs(x - power)) # type: ignore - if abs(valid_power - power) > self.power_atol + self.power_rtol * abs(power): - raise ValueError( - f"The requested power ({power}) does not exist in this whole slide image " - f"(with power_rtol={self.power_rtol} and power_atol={self.power_atol})." - f"Here is the list of available objective powers: {available_powers}. " - f" The closest matching available power is {valid_power}." - "Please consider changing the tolerances or use another power." - ) - level = available_powers.index(valid_power) + 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.