From 0ba80e66cadd0328130e5700431c21d5cd05f0fb Mon Sep 17 00:00:00 2001 From: ontdepart <343689010+ontdepart@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:56:58 +0100 Subject: [PATCH 1/3] Use pyramid levels instead of TIFF pages in TiffFileWSIReader TiffFileWSIReader read pixels with `wsi.asarray(level=...)`, which indexes the pyramid levels of the first image series, but took the level count, size, downsample ratio and resolution from `wsi.pages[level]`. The two only agree when every TIFF page is a pyramid level. In Aperio SVS files the second page is a thumbnail, followed by the reduced levels and the label/macro images, so for `level >= 1` patches were taken from the wrong location (or came back empty), whole-level reads were cropped to the thumbnail size, and the level count included non-pyramid pages. Take all level metadata from `wsi.series[0].levels`, the same levels used to read pixels. Generic pyramidal TIFFs, where pages and levels coincide, are unaffected. Signed-off-by: ontdepart <343689010+ontdepart@users.noreply.github.com> --- monai/data/wsi_reader.py | 45 ++++++++++++++++++++--------- tests/utils/enums/test_wsireader.py | 31 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 8a465c11979..6cb802ee18d 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -1319,6 +1319,21 @@ class TiffFileWSIReader(BaseWSIReader): def __init__(self, **kwargs): super().__init__(**kwargs) + @staticmethod + def _get_level_page(wsi, level: int): + """ + Returns the first TIFF page of the given pyramid level. + + Pyramid levels are taken from the first image series, the same levels used by `wsi.asarray(level=...)`. + They do not always map one-to-one to TIFF pages, e.g. Aperio SVS files store a thumbnail as the second page. + + Args: + wsi: a whole slide image object loaded from a file. + level: the level number. + + """ + return wsi.series[0].levels[level].keyframe + @staticmethod def get_level_count(wsi) -> int: """ @@ -1328,7 +1343,7 @@ def get_level_count(wsi) -> int: wsi: a whole slide image object loaded from a file. """ - return len(wsi.pages) + return len(wsi.series[0].levels) def get_size(self, wsi, level: int) -> tuple[int, int]: """ @@ -1339,7 +1354,8 @@ def get_size(self, wsi, level: int) -> tuple[int, int]: level: the level number where the size is calculated. """ - return (wsi.pages[level].imagelength, wsi.pages[level].imagewidth) + page = self._get_level_page(wsi, level) + return (page.imagelength, page.imagewidth) def get_downsample_ratio(self, wsi, level: int) -> float: """ @@ -1350,7 +1366,7 @@ def get_downsample_ratio(self, wsi, level: int) -> float: level: the level number where the downsample ratio is calculated. """ - return float(wsi.pages[0].imagelength) / float(wsi.pages[level].imagelength) + return float(self._get_level_page(wsi, 0).imagelength) / float(self._get_level_page(wsi, level).imagelength) @staticmethod def get_file_path(wsi) -> str: @@ -1366,13 +1382,14 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: level: the level number where the mpp is calculated. """ + page = self._get_level_page(wsi, level) 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 + "XResolution" in page.tags + and "YResolution" in page.tags + and page.tags["XResolution"].value + and page.tags["YResolution"].value ): - unit = wsi.pages[level].tags.get("ResolutionUnit") + unit = page.tags.get("ResolutionUnit") if unit is not None: unit = str(unit.value.name) if unit is None or len(unit) == 0: @@ -1382,8 +1399,8 @@ def get_mpp(self, wsi, level: int) -> tuple[float, float]: 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 + yres = page.tags["YResolution"].value + xres = page.tags["XResolution"].value if xres[0] and yres[0]: return convert_to_micron(yres[1] / yres[0]), convert_to_micron(xres[1] / xres[0]) else: @@ -1417,16 +1434,16 @@ def get_wsi_at_mpp( """ mpp = ensure_tuple_rep(mpp, 2) - mpp_list = [self.get_mpp(wsi, lvl) for lvl in range(len(wsi.pages))] # Fails for some Tifffiles + mpp_list = [self.get_mpp(wsi, lvl) for lvl in range(self.get_level_count(wsi))] # 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`). + # TiffFile does not expose `read_region`; read the whole level instead (consistent with `_get_patch`). pil_image, _ = optional_import("PIL", name="Image") - closest_lvl_wsi = pil_image.fromarray(wsi.pages[closest_lvl].asarray()) + closest_lvl_wsi = pil_image.fromarray(wsi.asarray(level=closest_lvl)) elif closest_level_is_bigger: # Otherwise, select the level closest to the desired mpp with a higher resolution and downsample it. @@ -1563,7 +1580,7 @@ def _resize_to_mpp_res(self, wsi, closest_lvl, mpp_list, user_mpp: tuple): 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()) + closest_lvl_wsi = pil_image.fromarray(wsi.asarray(level=closest_lvl)) # 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) diff --git a/tests/utils/enums/test_wsireader.py b/tests/utils/enums/test_wsireader.py index ee6462b5791..c209dc94498 100644 --- a/tests/utils/enums/test_wsireader.py +++ b/tests/utils/enums/test_wsireader.py @@ -318,6 +318,16 @@ TEST_CASE_SVS_MPP_SCALAR = [WSI_APERIO_SVS_PATH, {"mpp": 8.0}, {"openslide": (3, 2057, 2875), "cucim": (3, 2057, 2875)}] +# pyramid levels of Aperio SVS (the thumbnail, label, and macro images are not levels) +TEST_CASE_SVS_LEVELS = [WSI_APERIO_SVS_PATH, [(32914, 46000), (8228, 11500), (2057, 2875)]] + +# patch on tissue at the lowest resolution level of Aperio SVS +TEST_CASE_SVS_LEVEL_2 = [ + WSI_APERIO_SVS_PATH, + {"location": (5120, 36864), "size": (2, 1), "level": 2}, + np.array([[[141], [100]], [[43], [32]], [[75], [70]]], dtype=np.uint8), +] + TEST_CASE_DEVICE_2 = [ WSI_GENERIC_TIFF_PATH, {"level": 8, "dtype": torch.float32, "device": "cuda"}, @@ -699,6 +709,27 @@ def test_read_region_multi_thread(self, file_path, kwargs, patch_info, expected_ 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_SVS_LEVELS]) + def test_svs_levels(self, file_path, expected_sizes): + reader = WSIReader(self.backend) + with reader.read(file_path) as img_obj: + self.assertEqual(reader.get_level_count(img_obj), len(expected_sizes)) + for level, expected_size in enumerate(expected_sizes): + self.assertTupleEqual(tuple(reader.get_size(img_obj, level)), expected_size) + self.assertAlmostEqual( + reader.get_downsample_ratio(img_obj, level), expected_sizes[0][0] / expected_size[0], places=2 + ) + + @parameterized.expand([TEST_CASE_SVS_LEVEL_2]) + def test_read_region_svs_level(self, file_path, patch_info, expected_img): + reader = WSIReader(self.backend) + with reader.read(file_path) as img_obj: + img, meta = reader.get_data(img_obj, **patch_info) + self.assertTupleEqual(img.shape, expected_img.shape) + # JPEG decoding differs slightly between backends + assert_allclose(img, expected_img, type_test=False, atol=10) + self.assertEqual(meta[WSIPatchKeys.LEVEL], patch_info["level"]) + @parameterized.expand([TEST_CASE_MPP_0]) def test_resolution_mpp(self, file_path, level, expected_mpp): reader = WSIReader(self.backend, level=level) From d6fcf75208244c96702d39133d608e9961366960 Mon Sep 17 00:00:00 2001 From: ontdepart <343689010+ontdepart@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:07:53 +0000 Subject: [PATCH 2/3] Document return value and exception of TiffFileWSIReader._get_level_page Signed-off-by: ontdepart <343689010+ontdepart@users.noreply.github.com> --- monai/data/wsi_reader.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 6cb802ee18d..acea0872bb7 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -1331,6 +1331,12 @@ def _get_level_page(wsi, level: int): wsi: a whole slide image object loaded from a file. level: the level number. + Returns: + the TIFF page holding the metadata of that level. + + Raises: + IndexError: when ``level`` is not a valid pyramid level of the image. + """ return wsi.series[0].levels[level].keyframe From 22d36a2b2b9c2d6852d8026b1903add470e2ca42 Mon Sep 17 00:00:00 2001 From: ontdepart <343689010+ontdepart@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:21:26 +0000 Subject: [PATCH 3/3] Test reading a whole Aperio SVS level Signed-off-by: ontdepart <343689010+ontdepart@users.noreply.github.com> --- tests/utils/enums/test_wsireader.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/utils/enums/test_wsireader.py b/tests/utils/enums/test_wsireader.py index c209dc94498..86ef4204c6f 100644 --- a/tests/utils/enums/test_wsireader.py +++ b/tests/utils/enums/test_wsireader.py @@ -60,6 +60,8 @@ TEST_CASE_WHOLE_0 = [WSI_GENERIC_TIFF_PATH, 2, (3, WSI_GENERIC_TIFF_HEIGHT // 4, WSI_GENERIC_TIFF_WIDTH // 4)] +TEST_CASE_WHOLE_1 = [WSI_APERIO_SVS_PATH, 2, (3, 2057, 2875)] + TEST_CASE_TRANSFORM_0 = [ WSI_GENERIC_TIFF_PATH, 4, @@ -500,7 +502,7 @@ def tearDown(self): """ gc.collect() - @parameterized.expand([TEST_CASE_WHOLE_0]) + @parameterized.expand([TEST_CASE_WHOLE_0, TEST_CASE_WHOLE_1]) def test_read_whole_image(self, file_path, level, expected_shape): reader = WSIReader(self.backend, level=level) with reader.read(file_path) as img_obj: