diff --git a/src/histolab/slide.py b/src/histolab/slide.py index 63fb4499e..997629fd9 100644 --- a/src/histolab/slide.py +++ b/src/histolab/slide.py @@ -155,6 +155,7 @@ def level_dimensions(self, level: int = 0) -> Tuple[int, int]: ------- dimensions : tuple (width, height) """ + level = level if level >= 0 else self._remap_level(level) try: return self._wsi.level_dimensions[level] except IndexError: @@ -406,6 +407,31 @@ def _has_valid_coords(self, coords: CoordinatePair) -> bool: and 0 <= coords.y_br < self.dimensions[1] ) + def _remap_level(self, level: int) -> int: + """Remap negative index for the given level onto a positive one. + + Parameters + ---------- + level : int + the level index to remap + + Raises + ------ + LevelError + when the abs(level) is greater than the number of the levels. + + Returns + ------- + level : int + positive level index + """ + if len(self.levels) - abs(level) < 0: + raise LevelError( + f"Level {level} not available. Number of available levels: " + f"{len(self._wsi.level_dimensions)}" + ) + return len(self.levels) - abs(level) + def _resample(self, scale_factor: int = 32) -> Tuple[PIL.Image.Image, np.array]: """Converts a slide to a scaled-down PIL image. diff --git a/src/histolab/tiler.py b/src/histolab/tiler.py index 9668ced99..e0c269517 100644 --- a/src/histolab/tiler.py +++ b/src/histolab/tiler.py @@ -239,16 +239,6 @@ def extract(self, slide: Slide): print(f"{tiles_counter} Grid Tiles have been saved.") - @property - def level(self) -> int: - return self._valid_level - - @level.setter - def level(self, level_: int): - if level_ < 0: - raise LevelError(f"Level cannot be negative ({level_})") - self._valid_level = level_ - @property def tile_size(self) -> Tuple[int, int]: return self._valid_tile_size @@ -462,7 +452,7 @@ def extract(self, slide: Slide): LevelError If the level is not available for the slide """ - if self.level not in slide.levels: + if abs(self.level) not in slide.levels: raise LevelError( f"Level {self.level} not available. Number of available levels: " f"{len(slide.levels)}" @@ -482,16 +472,6 @@ def extract(self, slide: Slide): print(f"\t Tile {tiles_counter} saved: {tile_filename}") print(f"{tiles_counter+1} Random Tiles have been saved.") - @property - def level(self) -> int: - return self._valid_level - - @level.setter - def level(self, level_: int): - if level_ < 0: - raise LevelError(f"Level cannot be negative ({level_})") - self._valid_level = level_ - @property def max_iter(self) -> int: return self._valid_max_iter diff --git a/tests/integration/test_tiler.py b/tests/integration/test_tiler.py index e04f7ee82..285323196 100644 --- a/tests/integration/test_tiler.py +++ b/tests/integration/test_tiler.py @@ -15,37 +15,51 @@ class DescribeRandomTiler: @pytest.mark.parametrize( - "fixture_slide, check_tissue, expectation", + "fixture_slide, level, check_tissue, expectation", [ ( SVS.CMU_1_SMALL_REGION, + 0, False, "tiles-location-images/cmu-1-small-region-tl-random-false", ), ( SVS.TCGA_CR_7395_01A_01_TS1, + -2, + False, + "tiles-location-images/tcga-cr-7395-01a-01-ts1-tl-random-false", + ), + ( + SVS.TCGA_CR_7395_01A_01_TS1, + 0, False, "tiles-location-images/tcga-cr-7395-01a-01-ts1-tl-random-false", ), ( SVS.CMU_1_SMALL_REGION, + 0, True, "tiles-location-images/cmu-1-small-region-tl-random-true", ), ( SVS.TCGA_CR_7395_01A_01_TS1, + 0, True, "tiles-location-images/tcga-cr-7395-01a-01-ts1-tl-random-true", ), ], ) def it_locates_tiles_on_the_slide( - self, request, fixture_slide, check_tissue, expectation, tmpdir + self, request, fixture_slide, level, check_tissue, expectation, tmpdir ): slide = Slide(fixture_slide, os.path.join(tmpdir, "processed")) slide.save_scaled_image(10) random_tiles_extractor = RandomTiler( - tile_size=(512, 512), n_tiles=2, level=0, seed=42, check_tissue=check_tissue + tile_size=(512, 512), + n_tiles=2, + level=level, + seed=42, + check_tissue=check_tissue, ) expected_img = load_expectation( expectation, @@ -60,37 +74,47 @@ def it_locates_tiles_on_the_slide( class DescribeGridTiler: @pytest.mark.parametrize( - "fixture_slide, check_tissue, expectation", + "fixture_slide, level,check_tissue, expectation", [ ( SVS.CMU_1_SMALL_REGION, + 0, False, "tiles-location-images/cmu-1-small-region-tl-grid-false", ), ( SVS.TCGA_CR_7395_01A_01_TS1, + -2, + False, + "tiles-location-images/tcga-cr-7395-01a-01-ts1-tl-grid-false", + ), + ( + SVS.TCGA_CR_7395_01A_01_TS1, + 0, False, "tiles-location-images/tcga-cr-7395-01a-01-ts1-tl-grid-false", ), ( SVS.CMU_1_SMALL_REGION, + 0, True, "tiles-location-images/cmu-1-small-region-tl-grid-true", ), ( SVS.TCGA_CR_7395_01A_01_TS1, + 0, True, "tiles-location-images/tcga-cr-7395-01a-01-ts1-tl-grid-true", ), ], ) def it_locates_tiles_on_the_slide( - self, request, fixture_slide, check_tissue, expectation, tmpdir + self, request, fixture_slide, level, check_tissue, expectation, tmpdir ): slide = Slide(fixture_slide, os.path.join(tmpdir, "processed")) grid_tiles_extractor = GridTiler( tile_size=(512, 512), - level=0, + level=level, check_tissue=check_tissue, ) expected_img = load_expectation(expectation, type_="png") @@ -103,39 +127,49 @@ def it_locates_tiles_on_the_slide( class DescribeScoreTiler: @pytest.mark.parametrize( - "fixture_slide, check_tissue, expectation", + "fixture_slide, level, check_tissue, expectation", [ ( SVS.CMU_1_SMALL_REGION, + 0, False, "tiles-location-images/cmu-1-small-region-tl-scored-false", ), ( SVS.TCGA_CR_7395_01A_01_TS1, + -2, + False, + "tiles-location-images/tcga-cr-7395-01a-01-ts1-tl-scored-false", + ), + ( + SVS.TCGA_CR_7395_01A_01_TS1, + 0, False, "tiles-location-images/tcga-cr-7395-01a-01-ts1-tl-scored-false", ), ( SVS.CMU_1_SMALL_REGION, + 0, True, "tiles-location-images/cmu-1-small-region-tl-scored-true", ), ( SVS.TCGA_CR_7395_01A_01_TS1, + 0, True, "tiles-location-images/tcga-cr-7395-01a-01-ts1-tl-scored-true", ), ], ) def it_locates_tiles_on_the_slide( - self, request, fixture_slide, check_tissue, expectation, tmpdir + self, request, fixture_slide, level, check_tissue, expectation, tmpdir ): slide = Slide(fixture_slide, os.path.join(tmpdir, "processed")) scored_tiles_extractor = ScoreTiler( scorer=NucleiScorer(), tile_size=(512, 512), n_tiles=2, - level=0, + level=level, check_tissue=check_tissue, ) expected_img = load_expectation( diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index d1d369a5c..f0825a442 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -496,28 +496,35 @@ def but_it_raises_error_when_it_doesnt_exist(self): f"directory: {repr(os.path.join('processed', 'thumbnails', 'b.png'))}" ) - def it_knows_its_level_dimensions(self, tmpdir): + @pytest.mark.parametrize( + "level, expected_value", ((0, (500, 500)), (-1, (500, 500))) + ) + def it_knows_its_level_dimensions(self, level, expected_value, tmpdir): tmp_path_ = tmpdir.mkdir("myslide") image = PILIMG.RGBA_COLOR_500X500_155_249_240 image.save(os.path.join(tmp_path_, "mywsi.png"), "PNG") slide_path = os.path.join(tmp_path_, "mywsi.png") slide = Slide(slide_path, "processed") - level_dimensions = slide.level_dimensions(level=0) + level_dimensions = slide.level_dimensions(level=level) - assert level_dimensions == (500, 500) + assert level_dimensions == expected_value - def but_it_raises_expection_when_level_does_not_exist(self, tmpdir): + @pytest.mark.parametrize("level", (3, -3)) + def but_it_raises_expection_when_level_does_not_exist(self, level, tmpdir): tmp_path_ = tmpdir.mkdir("myslide") image = PILIMG.RGBA_COLOR_500X500_155_249_240 image.save(os.path.join(tmp_path_, "mywsi.png"), "PNG") slide_path = os.path.join(tmp_path_, "mywsi.png") slide = Slide(slide_path, "processed") with pytest.raises(LevelError) as err: - slide.level_dimensions(level=3) + slide.level_dimensions(level=level) assert isinstance(err.value, LevelError) - assert str(err.value) == "Level 3 not available. Number of available levels: 1" + assert ( + str(err.value) + == f"Level {level} not available. Number of available levels: 1" + ) @pytest.mark.parametrize( "coords, expected_result", @@ -558,8 +565,36 @@ def it_can_access_to_its_properties(self, request): assert slide.properties == {"foo": "bar"} + @pytest.mark.parametrize("level, expected_value", ((-1, 8), (-2, 7), (-9, 0))) + def it_can_remap_negative_level_indices(self, level, expected_value, levels_prop): + levels_prop.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + + slide = Slide("path", "processed") + + assert slide._remap_level(level) == expected_value + + def but_it_raises_a_level_error_when_it_cannot_be_mapped(self, tmpdir, levels_prop): + levels_prop.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + tmp_path_ = tmpdir.mkdir("myslide") + image = PILIMG.RGB_RANDOM_COLOR_500X500 + image.save(os.path.join(tmp_path_, "mywsi.png"), "PNG") + slide_path = os.path.join(tmp_path_, "mywsi.png") + slide = Slide(slide_path, "processed") + + with pytest.raises(LevelError) as err: + slide._remap_level(-10) + + assert isinstance(err.value, LevelError) + assert ( + str(err.value) == "Level -10 not available. Number of available levels: 1" + ) + # fixture components --------------------------------------------- + @pytest.fixture + def levels_prop(self, request): + return property_mock(request, Slide, "levels") + @pytest.fixture def resampled_dims_(self, request): return method_mock(request, Slide, "_resampled_dimensions") diff --git a/tests/unit/test_tiler.py b/tests/unit/test_tiler.py index cf84685c2..2b3a96a56 100644 --- a/tests/unit/test_tiler.py +++ b/tests/unit/test_tiler.py @@ -25,13 +25,14 @@ class Describe_RandomTiler: - def it_constructs_from_args(self, request): + @pytest.mark.parametrize("level", (2, -2)) + def it_constructs_from_args(self, level, request): _init = initializer_mock(request, RandomTiler) - random_tiler = RandomTiler((512, 512), 10, 2, 7, True, "", ".png", int(1e4)) + random_tiler = RandomTiler((512, 512), 10, level, 7, True, "", ".png", int(1e4)) _init.assert_called_once_with( - ANY, (512, 512), 10, 2, 7, True, "", ".png", int(1e4) + ANY, (512, 512), 10, level, 7, True, "", ".png", int(1e4) ) assert isinstance(random_tiler, RandomTiler) assert isinstance(random_tiler, Tiler) @@ -57,13 +58,6 @@ def or_it_has_not_available_level_value(self, tmpdir): assert isinstance(err.value, LevelError) assert str(err.value) == "Level 3 not available. Number of available levels: 1" - def or_it_has_negative_level_value(self): - with pytest.raises(LevelError) as err: - RandomTiler((512, 512), 10, -1) - - assert isinstance(err.value, LevelError) - assert str(err.value) == "Level cannot be negative (-1)" - def or_it_has_wrong_max_iter(self): with pytest.raises(ValueError) as err: RandomTiler((512, 512), 10, 0, max_iter=3) @@ -449,12 +443,13 @@ def _random_tile_coordinates(self, request): class Describe_GridTiler: - def it_constructs_from_args(self, request): + @pytest.mark.parametrize("level", (2, -2)) + def it_constructs_from_args(self, level, request): _init = initializer_mock(request, GridTiler) - grid_tiler = GridTiler((512, 512), 2, True, 0, "", ".png") + grid_tiler = GridTiler((512, 512), level, True, 0, "", ".png") - _init.assert_called_once_with(ANY, (512, 512), 2, True, 0, "", ".png") + _init.assert_called_once_with(ANY, (512, 512), level, True, 0, "", ".png") assert isinstance(grid_tiler, GridTiler) assert isinstance(grid_tiler, Tiler) @@ -479,13 +474,6 @@ def or_it_has_not_available_level_value(self, tmpdir): assert isinstance(err.value, LevelError) assert str(err.value) == "Level 3 not available. Number of available levels: 1" - def or_it_has_negative_level_value(self): - with pytest.raises(LevelError) as err: - GridTiler((512, 512), -1) - - assert isinstance(err.value, LevelError) - assert str(err.value) == "Level cannot be negative (-1)" - @pytest.mark.parametrize("tile_size", ((512, 512), (128, 128), (10, 10))) def it_knows_its_tile_size(self, tile_size): grid_tiler = GridTiler(tile_size, 10, True, 0)