diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 62081d61d1..5ed5faa234 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -127,7 +127,7 @@ def set_dtype(self, dtype): self.dtype = np.dtype(dtype) def set_device(self, device): - if device is None or isinstance(device, (torch.device, str)): + if device is None or isinstance(device, torch.device | str): self.device = device else: raise ValueError(f"`device` must be `torch.device`, `str` or `None` but {type(device)} is given.") @@ -358,7 +358,7 @@ def get_data( metadata_list: list = [] # CuImage object is iterable, so ensure_tuple won't work on single object - if not isinstance(wsi, (list, tuple)): + if not isinstance(wsi, list | tuple): wsi = (wsi,) for each_wsi in ensure_tuple(wsi): # get the valid level based on resolution info @@ -433,6 +433,70 @@ def get_data( metadata[key] = [m[key] for m in metadata_list] return _stack_images(patch_list, metadata), metadata + def _compute_mpp_target_res(self, closest_lvl, closest_lvl_dim, mpp_list, mpp: tuple): + """ + Computes the target dimensions for resizing a whole slide image + to match a user-specified resolution in microns per pixel (MPP). + + Args: + closest_lvl: Whole slide image level closest to user-provided MPP resolution. + closest_lvl_dim: Dimensions (height, width) of the image at the closest level. + mpp_list: List of MPP values for all levels of the whole slide image. + mpp: The MPP resolution at which the whole slide image representation should be extracted. + + Returns: + Tuple of (target_res_x, target_res_y) representing the target pixel dimensions. + + """ + mpp_closest_lvl = mpp_list[closest_lvl] + mpp_closest_lvl_x, mpp_closest_lvl_y = mpp_closest_lvl + + ds_factor_x = mpp_closest_lvl_x / mpp[0] + ds_factor_y = mpp_closest_lvl_y / mpp[1] + + target_res_x = int(np.round(closest_lvl_dim[1] * ds_factor_x)) + target_res_y = int(np.round(closest_lvl_dim[0] * ds_factor_y)) + + return target_res_x, target_res_y + + def _compute_mpp_tolerances(self, closest_lvl, mpp_list, mpp, atol, rtol) -> tuple[bool, bool]: + """ + Determines if user-provided MPP values are within a specified tolerance of the closest + level's MPP and checks if the closest level has higher resolution than desired MPP. + + Args: + closest_lvl: Whole slide image level closest to user-provided MPP resolution. + mpp_list: List of MPP values for all levels of the whole slide image. + mpp: The MPP resolution at which the whole slide image representation should be extracted. + atol: Absolute tolerance for MPP comparison. + rtol: Relative tolerance for MPP comparison. + + Returns: + Tuple of (is_within_tolerance, closest_level_is_bigger) where first element indicates + if MPP is within tolerance and second indicates if closest level has higher resolution. + + """ + user_mpp_x, user_mpp_y = mpp + mpp_closest_lvl_x, mpp_closest_lvl_y = mpp_list[closest_lvl] + + # Define tolerance intervals for x and y of closest level + lower_bound_x = mpp_closest_lvl_x * (1 - rtol) - atol + upper_bound_x = mpp_closest_lvl_x * (1 + rtol) + atol + lower_bound_y = mpp_closest_lvl_y * (1 - rtol) - atol + upper_bound_y = mpp_closest_lvl_y * (1 + rtol) + atol + + # Check if user-provided mpp_x and mpp_y fall within the tolerance intervals for closest level + is_within_tolerance_x = (user_mpp_x >= lower_bound_x) and (user_mpp_x <= upper_bound_x) + is_within_tolerance_y = (user_mpp_y >= lower_bound_y) and (user_mpp_y <= upper_bound_y) + is_within_tolerance = is_within_tolerance_x and is_within_tolerance_y + + # If mpp_closest_level < mpp -> closest_level has higher res than img at mpp => downscale from closest_level to mpp + closest_level_is_bigger_x = mpp_closest_lvl_x < user_mpp_x + closest_level_is_bigger_y = mpp_closest_lvl_y < user_mpp_y + closest_level_is_bigger = closest_level_is_bigger_x and closest_level_is_bigger_y + + return is_within_tolerance, closest_level_is_bigger + def verify_suffix(self, filename: Sequence[PathLike] | PathLike) -> bool: """ Verify whether the specified file or files format is supported by WSI reader. @@ -605,6 +669,31 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: """ return self.reader.get_mpp(wsi, level) + def get_wsi_at_mpp( + self, wsi, mpp: float | tuple[float, float], atol: float = 0.00, rtol: float = 0.05 + ) -> np.ndarray: + """ + Returns the representation of the whole slide image at a given micro-per-pixel (mpp) resolution. + The optional tolerance parameters are considered at the level whose mpp value is closest to the one provided by the user. + If the user-provided mpp is larger than the mpp of the closest level, + the image is downscaled to a resolution that matches the user-provided mpp. + Otherwise, if the closest level's resolution is not sufficient to meet the user's requested resolution, + the next lower level (which has a higher resolution) is chosen. + The image from this level is then down-scaled to achieve a resolution at the user-provided mpp value. + + Args: + wsi: whole slide image object from WSIReader + mpp: the resolution in micron per pixel at which the representation of the whole slide image should be extracted. + atol: the acceptable absolute tolerance for resolution in micro per pixel. + rtol: the acceptable relative tolerance for resolution in micro per pixel. + + Returns: + Numpy array containing the whole slide image at the requested MPP resolution. + + """ + mpp = ensure_tuple_rep(mpp, 2) + return self.reader.get_wsi_at_mpp(wsi, mpp, atol, rtol) + def get_power(self, wsi, level: int) -> float: """ Returns the micro-per-pixel resolution of the whole slide image at a given level. @@ -746,6 +835,77 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: raise ValueError("`mpp` cannot be obtained for this file. Please use `level` instead.") + def get_wsi_at_mpp( + self, wsi, mpp: float | tuple[float, float], atol: float = 0.00, rtol: float = 0.05 + ) -> np.ndarray: + """ + Returns the representation of the whole slide image at a given micro-per-pixel (mpp) resolution. + The optional tolerance parameters are considered at the level whose mpp value is closest to the one provided by the user. + If the user-provided mpp is larger than the mpp of the closest level, + the image is downscaled to a resolution that matches the user-provided mpp. + Otherwise, if the closest level's resolution is not sufficient to meet the user's requested resolution, + the next lower level (which has a higher resolution) is chosen. + The image from this level is then down-scaled to achieve a resolution at the user-provided mpp value. + + Args: + wsi: whole slide image object from WSIReader + mpp: the resolution in micron per pixel at which the representation of the whole slide image should be extracted. + atol: the acceptable absolute tolerance for resolution in micro per pixel. + rtol: the acceptable relative tolerance for resolution in micro per pixel. + + Returns: + Numpy array containing the whole slide image at the requested MPP resolution. + + """ + cp, _ = optional_import("cupy") + mpp = ensure_tuple_rep(mpp, 2) + + mpp_list = [self.get_mpp(wsi, lvl) for lvl in range(wsi.resolutions["level_count"])] + closest_lvl = self._find_closest_level("mpp", mpp, mpp_list, 0, 5) + + within_tolerance, closest_level_is_bigger = self._compute_mpp_tolerances(closest_lvl, mpp_list, mpp, atol, rtol) + + if within_tolerance: + # If the image at the desired mpp resolution is within tolerances, return the image at closest_level. + closest_lvl_wsi = wsi.read_region( + (0, 0), + level=closest_lvl, + size=wsi.resolutions["level_dimensions"][closest_lvl], + num_workers=self.num_workers, + ) + + elif closest_level_is_bigger: + # Otherwise, select the level closest to the desired mpp with a higher resolution and downsample it. + closest_lvl_wsi = self._resize_to_mpp_res(wsi, closest_lvl, mpp_list, mpp) + + else: + # If both checks fail, increase resolution (i.e., decrement level) and then downsample it. + if closest_lvl == 0: + closest_lvl_wsi = self._resize_to_mpp_res(wsi, closest_lvl, mpp_list, mpp) + else: + closest_lvl = closest_lvl - 1 + closest_lvl_wsi = self._resize_to_mpp_res(wsi, closest_lvl, mpp_list, mpp) + + wsi_arr = cp.asnumpy(closest_lvl_wsi) + + # Ensure channel dimension exists for grayscale + if wsi_arr.ndim < 3: + wsi_arr = wsi_arr[..., None] + + # Apply channel dimension + wsi_arr = np.moveaxis(wsi_arr, -1, self.channel_dim) + + # Apply mode (RGB / RGBA) + if self.mode == "RGB": + if wsi_arr.shape[self.channel_dim] not in [3, 4]: + raise ValueError( + f"The image is expected to have three or four color channels in '{self.mode}' mode but has " + f"{wsi_arr.shape[self.channel_dim]}. " + ) + wsi_arr = np.take(wsi_arr, [0, 1, 2], self.channel_dim) + + return wsi_arr + def get_power(self, wsi, level: int) -> float: """ Returns the objective power of the whole slide image at a given level. @@ -830,6 +990,36 @@ def _get_patch( return patch + def _resize_to_mpp_res(self, wsi, closest_lvl, mpp_list, user_mpp: tuple): + """ + Resizes the whole slide image to the specified resolution in microns per pixel (mpp). + + Args: + wsi: whole slide image object from WSIReader + user_mpp: the resolution in microns per pixel at which the whole slide image representation should be extracted. + closest_lvl: the wsi level that is closest to the user-provided mpp resolution. + mpp_list: list of mpp values for all levels of a whole slide image. + + Returns: + Resized cupy image array at the target MPP resolution. + + """ + cucim_resize, _ = optional_import("cucim.skimage.transform", name="resize") + cp, _ = optional_import("cupy") + + closest_lvl_dim = wsi.resolutions["level_dimensions"][closest_lvl] + + target_res_x, target_res_y = self._compute_mpp_target_res(closest_lvl, closest_lvl_dim, mpp_list, user_mpp) + + wsi_arr = cp.array( + wsi.read_region((0, 0), level=closest_lvl, size=closest_lvl_dim, num_workers=self.num_workers) + ) + closest_lvl_wsi = cucim_resize( + wsi_arr, (target_res_x, target_res_y), order=1, preserve_range=True, anti_aliasing=False + ).astype(cp.uint8) + + return closest_lvl_wsi + @require_pkg(pkg_name="openslide") class OpenSlideWSIReader(BaseWSIReader): @@ -942,6 +1132,66 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: raise ValueError("`mpp` cannot be obtained for this file. Please use `level` instead.") + def get_wsi_at_mpp( + self, wsi, mpp: float | tuple[float, float], atol: float = 0.00, rtol: float = 0.05 + ) -> np.ndarray: + """ + Returns the representation of the whole slide image at a given micro-per-pixel (mpp) resolution. + The optional tolerance parameters are considered at the level whose mpp value is closest to the one provided by the user. + If the user-provided mpp is larger than the mpp of the closest level, + the image is downscaled to a resolution that matches the user-provided mpp. + Otherwise, if the closest level's resolution is not sufficient to meet the user's requested resolution, + the next lower level (which has a higher resolution) is chosen. + The image from this level is then down-scaled to achieve a resolution at the user-provided mpp value. + + Args: + wsi: whole slide image object from WSIReader + mpp: the resolution in micron per pixel at which the representation of the whole slide image should be extracted. + atol: the acceptable absolute tolerance for resolution in micro per pixel. + rtol: the acceptable relative tolerance for resolution in micro per pixel. + + Returns: + Numpy array containing the whole slide image at the requested MPP resolution. + + """ + mpp = ensure_tuple_rep(mpp, 2) + + mpp_list = [self.get_mpp(wsi, lvl) for lvl in range(wsi.level_count)] + closest_lvl = self._find_closest_level("mpp", mpp, mpp_list, 0, 5) + + within_tolerance, closest_level_is_bigger = self._compute_mpp_tolerances(closest_lvl, mpp_list, mpp, atol, rtol) + + if within_tolerance: + # If the image at the desired mpp resolution is within tolerances, return the image at closest_level. + closest_lvl_wsi = wsi.read_region((0, 0), level=closest_lvl, size=wsi.level_dimensions[closest_lvl]) + + elif closest_level_is_bigger: + # Otherwise, select the level closest to the desired mpp with a higher resolution and downsample it. + closest_lvl_wsi = self._resize_to_mpp_res(wsi, closest_lvl, mpp_list, mpp) + + else: + # If both checks fail, increase resolution (i.e., decrement level) and then downsample it. + if closest_lvl == 0: + closest_lvl_wsi = self._resize_to_mpp_res(wsi, closest_lvl, mpp_list, mpp) + else: + closest_lvl = closest_lvl - 1 + closest_lvl_wsi = self._resize_to_mpp_res(wsi, closest_lvl, mpp_list, mpp) + + # Convert to specified mode (e.g., RGBA to RGB) + closest_lvl_wsi = closest_lvl_wsi.convert(self.mode) + + wsi_arr = np.array(closest_lvl_wsi) + + # Ensure channel dimension exists for grayscale (defensive: PIL.convert + # to "L" produces a 2-D array without a trailing channel axis). + if wsi_arr.ndim < 3: + wsi_arr = wsi_arr[..., None] + + # Apply channel dimension + wsi_arr = np.moveaxis(wsi_arr, -1, self.channel_dim) + + return wsi_arr + def get_power(self, wsi, level: int) -> float: """ Returns the objective power of the whole slide image at a given level. @@ -1012,6 +1262,31 @@ def _get_patch( return patch + def _resize_to_mpp_res(self, wsi, closest_lvl, mpp_list, user_mpp: tuple): + """ + Resizes the whole slide image to the specified resolution in microns per pixel (mpp). + + Args: + wsi: whole slide image object from WSIReader + user_mpp: the resolution in microns per pixel at which the whole slide image representation should be extracted. + closest_lvl: the wsi level that is closest to the user-provided mpp resolution. + mpp_list: list of mpp values for all levels of a whole slide image. + + Returns: + PIL Image object resized to the target MPP resolution. + + """ + pil_image, _ = optional_import("PIL", name="Image") + + closest_lvl_dim = wsi.level_dimensions[closest_lvl] + + target_res_x, target_res_y = self._compute_mpp_target_res(closest_lvl, closest_lvl_dim, mpp_list, user_mpp) + + closest_lvl_wsi = wsi.read_region((0, 0), level=closest_lvl, size=closest_lvl_dim) + closest_lvl_wsi = closest_lvl_wsi.resize((target_res_y, target_res_x), pil_image.BILINEAR) # row, col order + + return closest_lvl_wsi + @require_pkg(pkg_name="tifffile") class TiffFileWSIReader(BaseWSIReader): @@ -1105,12 +1380,88 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: 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. + + # Here, x and y resolutions are rational numbers so each of them is represented by a tuple. yres = wsi.pages[level].tags["YResolution"].value xres = wsi.pages[level].tags["XResolution"].value - return convert_to_micron(yres[1] / yres[0]), convert_to_micron(xres[1] / xres[0]) + if xres[0] and yres[0]: + return convert_to_micron(yres[1] / yres[0]), convert_to_micron(xres[1] / xres[0]) + else: + raise ValueError( + "The `XResolution` and/or `YResolution` property of the image is zero, " + "which is needed to obtain `mpp` for this file. Please use `level` instead." + ) + 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_wsi_at_mpp( + self, wsi, mpp: float | tuple[float, float], atol: float = 0.00, rtol: float = 0.05 + ) -> np.ndarray: + """ + Returns the representation of the whole slide image at a given micro-per-pixel (mpp) resolution. + The optional tolerance parameters are considered at the level whose mpp value is closest to the one provided by the user. + If the user-provided mpp is larger than the mpp of the closest level, + the image is downscaled to a resolution that matches the user-provided mpp. + Otherwise, if the closest level's resolution is not sufficient to meet the user's requested resolution, + the next lower level (which has a higher resolution) is chosen. + The image from this level is then down-scaled to achieve a resolution at the user-provided mpp value. + + Args: + wsi: whole slide image object from WSIReader + mpp: the resolution in micron per pixel at which the representation of the whole slide image should be extracted. + atol: the acceptable absolute tolerance for resolution in micro per pixel. + rtol: the acceptable relative tolerance for resolution in micro per pixel. + + Returns: + Numpy array containing the whole slide image at the requested MPP resolution. + + """ + mpp = ensure_tuple_rep(mpp, 2) + + mpp_list = [self.get_mpp(wsi, lvl) for lvl in range(len(wsi.pages))] # Fails for some Tifffiles + closest_lvl = self._find_closest_level("mpp", mpp, mpp_list, 0, 5) + + within_tolerance, closest_level_is_bigger = self._compute_mpp_tolerances(closest_lvl, mpp_list, mpp, atol, rtol) + + if within_tolerance: + # If the image at the desired mpp resolution is within tolerances, return the image at closest_level. + # TiffFile does not expose `read_region`; read the whole page instead (consistent with `_get_patch`). + pil_image, _ = optional_import("PIL", name="Image") + closest_lvl_wsi = pil_image.fromarray(wsi.pages[closest_lvl].asarray()) + + elif closest_level_is_bigger: + # Otherwise, select the level closest to the desired mpp with a higher resolution and downsample it. + closest_lvl_wsi = self._resize_to_mpp_res(wsi, closest_lvl, mpp_list, mpp) + + else: + # If both checks fail, increase resolution (i.e., decrement level) and then downsample it. + if closest_lvl == 0: + closest_lvl_wsi = self._resize_to_mpp_res(wsi, closest_lvl, mpp_list, mpp) + else: + closest_lvl = closest_lvl - 1 + closest_lvl_wsi = self._resize_to_mpp_res(wsi, closest_lvl, mpp_list, mpp) + + # Convert to specified mode to normalize dtype + closest_lvl_wsi = closest_lvl_wsi.convert(self.mode) + + wsi_arr = np.array(closest_lvl_wsi) + + # Ensure channel dimension exists for grayscale + if wsi_arr.ndim < 3: + wsi_arr = wsi_arr[..., None] + + # Apply channel dimension + wsi_arr = np.moveaxis(wsi_arr, -1, self.channel_dim) + + # Apply mode (RGB / RGBA) + if self.mode == "RGB": + if wsi_arr.shape[self.channel_dim] not in [3, 4]: + raise ValueError( + f"The image is expected to have three or four color channels in '{self.mode}' mode but has " + f"{wsi_arr.shape[self.channel_dim]}. " + ) + wsi_arr = np.take(wsi_arr, [0, 1, 2], self.channel_dim) + + return wsi_arr def get_power(self, wsi, level: int) -> float: """ @@ -1156,7 +1507,7 @@ 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 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 None, it is set to the full image size at the given level. @@ -1188,3 +1539,32 @@ def _get_patch( patch = np.take(patch, [0, 1, 2], self.channel_dim) return patch + + def _resize_to_mpp_res(self, wsi, closest_lvl, mpp_list, user_mpp: tuple): + """ + Resizes the whole slide image to the specified resolution in microns per pixel (mpp). + + Args: + wsi: whole slide image object from WSIReader + user_mpp: the resolution in microns per pixel at which the whole slide image representation should be extracted. + closest_lvl: the wsi level that is closest to the user-provided mpp resolution. + mpp_list: list of mpp values for all levels of a whole slide image. + + Returns: + PIL Image object resized to the target MPP resolution. + + """ + pil_image, _ = optional_import("PIL", name="Image") + + # `_compute_mpp_target_res` expects (W, H) order, matching the OpenSlide / + # CuCIM convention; `self.get_size` returns (H, W), so swap before passing. + h, w = self.get_size(wsi, closest_lvl) + closest_lvl_dim = (w, h) + + target_res_x, target_res_y = self._compute_mpp_target_res(closest_lvl, closest_lvl_dim, mpp_list, user_mpp) + + closest_lvl_wsi = pil_image.fromarray(wsi.pages[closest_lvl].asarray()) + # PIL `resize` takes (W, H); use the same row/col order as OpenSlide. + closest_lvl_wsi = closest_lvl_wsi.resize((target_res_y, target_res_x), pil_image.BILINEAR) + + return closest_lvl_wsi diff --git a/tests/testing_data/data_config.json b/tests/testing_data/data_config.json index a6f4e90940..d061ec14a1 100644 --- a/tests/testing_data/data_config.json +++ b/tests/testing_data/data_config.json @@ -5,6 +5,11 @@ "hash_type": "sha256", "hash_val": "73a7e89bc15576587c3d68e55d9bf92f09690280166240b48ff4b48230b13bcd" }, + "wsi_generic_tiff_correct_mpp": { + "url": "https://huggingface.co/datasets/MONAI/testing_data/resolve/main/CMU-1_correct_mpp.tiff", + "hash_type": "sha256", + "hash_val": "65306e3f8f7f5282d19d942dadc525cd06a80d5fd8268053939751365226c65f" + }, "wsi_aperio_svs": { "url": "https://huggingface.co/datasets/MONAI/testing_data/resolve/main/Aperio-CMU-1.svs", "hash_type": "sha256", diff --git a/tests/utils/enums/test_wsireader.py b/tests/utils/enums/test_wsireader.py index 3b84af7345..74f305a54c 100644 --- a/tests/utils/enums/test_wsireader.py +++ b/tests/utils/enums/test_wsireader.py @@ -37,9 +37,14 @@ has_tiff = has_tiff and has_codec TESTS_PATH = Path(__file__).parents[2] -WSI_GENERIC_TIFF_KEY = "wsi_generic_tiff" +WSI_GENERIC_TIFF_KEY = "wsi_generic_tiff" # TIFF image with incorrect mpp values WSI_GENERIC_TIFF_PATH = os.path.join(TESTS_PATH, "testing_data", f"temp_{WSI_GENERIC_TIFF_KEY}.tiff") +WSI_GENERIC_TIFF_CORRECT_MPP_KEY = "wsi_generic_tiff_correct_mpp" +WSI_GENERIC_TIFF_CORRECT_MPP_PATH = os.path.join( + TESTS_PATH, "testing_data", f"temp_{WSI_GENERIC_TIFF_CORRECT_MPP_KEY}.tiff" +) + WSI_APERIO_SVS_KEY = "wsi_aperio_svs" WSI_APERIO_SVS_PATH = os.path.join(TESTS_PATH, "testing_data", f"temp_{WSI_APERIO_SVS_KEY}.svs") @@ -256,6 +261,56 @@ "cpu", ] +TEST_CASE_SVS_MPP_1 = [ + WSI_APERIO_SVS_PATH, + {"mpp": (4.0, 4.0), "atol": 0.0, "rtol": 0.1}, + {"openslide": (3, 4106, 5739), "cucim": (3, 4106, 5739)}, +] + +TEST_CASE_SVS_MPP_2 = [ + WSI_APERIO_SVS_PATH, + {"mpp": (8.0, 8.0)}, + {"openslide": (3, 2057, 2875), "cucim": (3, 2057, 2875)}, +] + +TEST_CASE_SVS_MPP_3 = [ + WSI_APERIO_SVS_PATH, + {"mpp": (3.0, 3.0)}, + {"openslide": (3, 5475, 7652), "cucim": (3, 5475, 7652)}, +] + +TEST_CASE_SVS_MPP_4 = [ + WSI_APERIO_SVS_PATH, + {"mpp": (1.5, 1.5)}, + {"openslide": (3, 10949, 15303), "cucim": (3, 10949, 15303)}, +] + +TEST_CASE_TIFF_MPP_1 = [ + WSI_GENERIC_TIFF_CORRECT_MPP_PATH, + {"mpp": (4.0, 4.0), "atol": 0.0, "rtol": 0.1}, + {"openslide": (3, 4114, 5750), "cucim": (3, 4114, 5750), "tifffile": (3, 4106, 5739)}, +] + +TEST_CASE_TIFF_MPP_2 = [ + WSI_GENERIC_TIFF_CORRECT_MPP_PATH, + {"mpp": (8.0, 8.0)}, + {"openslide": (3, 2057, 2875), "cucim": (3, 2057, 2875), "tifffile": (3, 2053, 2869)}, +] + +TEST_CASE_TIFF_MPP_3 = [ + WSI_GENERIC_TIFF_CORRECT_MPP_PATH, + {"mpp": (3.0, 3.0)}, + {"openslide": (3, 5475, 7652), "cucim": (3, 5475, 7652), "tifffile": (3, 5475, 7651)}, +] + +TEST_CASE_TIFF_MPP_4 = [ + WSI_GENERIC_TIFF_CORRECT_MPP_PATH, + {"mpp": (1.5, 1.5)}, + {"openslide": (3, 10949, 15303), "cucim": (3, 10949, 15303), "tifffile": (3, 10949, 15303)}, +] + +TEST_CASE_SVS_MPP_SCALAR = [WSI_APERIO_SVS_PATH, {"mpp": 8.0}, {"openslide": (3, 2057, 2875), "cucim": (3, 2057, 2875)}] + TEST_CASE_DEVICE_2 = [ WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": torch.float32, "device": "cuda"}, @@ -395,6 +450,12 @@ def setUpModule(): hash_type=testing_data_config("images", WSI_GENERIC_TIFF_KEY, "hash_type"), hash_val=testing_data_config("images", WSI_GENERIC_TIFF_KEY, "hash_val"), ) + download_url_or_skip_test( + testing_data_config("images", WSI_GENERIC_TIFF_CORRECT_MPP_KEY, "url"), + WSI_GENERIC_TIFF_CORRECT_MPP_PATH, + hash_type=testing_data_config("images", WSI_GENERIC_TIFF_CORRECT_MPP_KEY, "hash_type"), + hash_val=testing_data_config("images", WSI_GENERIC_TIFF_CORRECT_MPP_KEY, "hash_val"), + ) download_url_or_skip_test( testing_data_config("images", WSI_APERIO_SVS_KEY, "url"), WSI_APERIO_SVS_PATH, @@ -419,6 +480,36 @@ def test_read_whole_image(self, file_path, level, expected_shape): assert_allclose(meta[WSIPatchKeys.SIZE], expected_shape[1:], type_test=False) assert_allclose(meta[WSIPatchKeys.LOCATION], (0, 0), type_test=False) + @parameterized.expand( + [ + TEST_CASE_SVS_MPP_1, + TEST_CASE_SVS_MPP_2, + TEST_CASE_SVS_MPP_3, + TEST_CASE_SVS_MPP_4, + TEST_CASE_TIFF_MPP_1, + TEST_CASE_TIFF_MPP_2, + TEST_CASE_TIFF_MPP_3, + TEST_CASE_TIFF_MPP_4, + TEST_CASE_SVS_MPP_SCALAR, + ] + ) + def test_get_wsi_at_mpp(self, file_path, func_kwargs, expected_shape, reader_kwargs=None): + # Tifffile backend cannot read MPP from the SVS file, so skip. + if self.backend == "tifffile" and file_path == WSI_APERIO_SVS_PATH: + self.skipTest("TiffFileWSIReader cannot extract MPP from SVS files.") + + # Look up the expected shape for the current backend + if self.backend not in expected_shape: + self.skipTest(f"No expected shape defined for backend '{self.backend}' in this test case.") + expected_shape = expected_shape[self.backend] + + reader_kwargs = reader_kwargs or {} + reader = WSIReader(self.backend, **reader_kwargs) + with reader.read(file_path) as wsi: + wsi_arr = reader.get_wsi_at_mpp(wsi, **func_kwargs) + + self.assertTupleEqual(wsi_arr.shape, expected_shape) + @parameterized.expand( [ TEST_CASE_0,