From 5b11289759b269dd3fa0c63af7e794977889f3d1 Mon Sep 17 00:00:00 2001 From: kheffah Date: Fri, 23 Jul 2021 23:38:26 -0500 Subject: [PATCH 01/47] [WIP] - use large_image as an optional dependency to handle a wide range of wsi formats beyond openslide and to accomodate fetching exact MPP resolutions rather than being restricted of internal resolutions of quantized levels within WSI multi-page image --- histolab/slide.py | 163 +++++++++++++++++++++++++++++++++++++++++----- histolab/tiler.py | 62 +++++++++++++++--- 2 files changed, 200 insertions(+), 25 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 88d4ed21c..ea45fb37f 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -27,6 +27,7 @@ from skimage.measure import find_contours from .exceptions import LevelError, SlidePropertyError +from .exceptions import HistolabException from .filters.compositions import FiltersComposition from .tile import Tile from .types import CoordinatePair @@ -36,7 +37,24 @@ from .masks import BinaryMask +# If possible, use large_image because it extends openslide to more formats +try: + import large_image + + USE_LARGEIMAGE = True +except ModuleNotFoundError: + USE_LARGEIMAGE = False + +if USE_LARGEIMAGE: + from io import BytesIO + + from PIL.Image import BICUBIC, LANCZOS + IMG_EXT = "png" +LARGEIMAGE_INSTALL_PROMPT = ( + "It maybe a good idea to install large_image to handle this. " + "See: https://github.com/girder/large_image" +) class Slide: @@ -72,6 +90,35 @@ def __repr__(self): # ---public interface methods and properties--- + @lazyproperty + def base_mpp(self) -> float: + """Get microns-per-pixel resolution at scan magnification.""" + if USE_LARGEIMAGE: + return self._metadata["mm_x"] * (10 ** 3) + + elif "openslide.mpp-x" in self.properties: + return float(self.properties["openslide.mpp-x"]) + elif "aperio.MPP" in self.properties: + return float(self.properties["aperio.MPP"]) + elif "tiff.XResolution" in self.properties: + resunit = self.properties["tiff.ResolutionUnit"] + if resunit == "centimeter": + return 1 / (float(self.properties["tiff.XResolution"]) * 1e-4) + else: + raise NotImplementedError( + f"Unimplemented tiff.ResolutionUnit {resunit}" + ) + else: + raise NotImplementedError( + "Unknown scan magnification! " + LARGEIMAGE_INSTALL_PROMPT + ) + + def get_mpp_at_level(self, level: int): + """Get microns-per-pixel resolution at a specific level.""" + # large_image tile source has different internal repr. of levels + lvl = self._tilesource.levels - level - 1 + return self._tilesource.getMagnificationForLevel(lvl)["mm_x"] * (10 ** 3) + @lazyproperty def dimensions(self) -> Tuple[int, int]: """Slide dimensions (w,h) at level 0. @@ -81,10 +128,17 @@ def dimensions(self) -> Tuple[int, int]: dimensions : Tuple[int, int] Slide dimensions (width, height) """ - return self._wsi.dimensions + if USE_LARGEIMAGE: + return self._metadata["sizeX"], self._metadata["sizeY"] + else: + return self._wsi.dimensions def extract_tile( - self, coords: CoordinatePair, level: int, tile_size: Tuple + self, + coords: CoordinatePair, + tile_size: Tuple, + level: int = None, + mpp: float = None, ) -> Tile: """Extract a tile of the image at the selected level. @@ -92,18 +146,25 @@ def extract_tile( ---------- coords : CoordinatePair Coordinates at level 0 from which to extract the tile. + tile_size: tuple + Final size of the extracted tile (x,y). level : int Level from which to extract the tile. - tile_size: tuple - Final size of the tile (x,y). + mpp : float + Micron per pixel resolution. Takes precedence over level. Returns ------- tile : Tile Image containing the selected tile. """ + assert (level is not None) or ( + mpp is not None + ), "either level or mpp must be provided!" + + if level is not None: + level = level if level >= 0 else self._remap_level(level) - level = level if level >= 0 else self._remap_level(level) if not self._has_valid_coords(coords): # OpenSlide doesn't complain if the coordinates for extraction are wrong, # but it returns an odd image. @@ -112,10 +173,36 @@ def extract_tile( f"{self.dimensions}" ) - image = self._wsi.read_region( - location=(coords.x_ul, coords.y_ul), level=level, size=tile_size - ) + if mpp is None: + image = self._wsi.read_region( + location=(coords.x_ul, coords.y_ul), level=level, size=tile_size + ) + else: + # only large_image support mpp + mm = mpp / 1000 + image, _ = self._tilesource.getRegion( + region=dict( + left=coords.x_ul, + top=coords.y_ul, + right=coords.x_br, + bottom=coords.y_br, + units="base_pixels", + ), + scale=dict(mm_x=mm, mm_y=mm), + format=large_image.tilesource.TILE_FORMAT_PIL, + jpegQuality=100, + ) + image = image.convert("RGB") + # Sometimes when mpp kwarg is used, the image size is off from + # what the user expects by a couple of pixels + asis = all(tile_size[i] == j for i, j in enumerate(image.size)) + if not asis: + image = image.resize( + tile_size, BICUBIC if tile_size[0] >= image.size[0] else LANCZOS + ) + tile = Tile(image, coords, level) + return tile def level_dimensions(self, level: int = 0) -> Tuple[int, int]: @@ -347,10 +434,25 @@ def thumbnail(self) -> PIL.Image.Image: PIL.Image.Image The slide thumbnail. """ - return self._wsi.get_thumbnail(self._thumbnail_size) + if USE_LARGEIMAGE: + thumb_bytes, _ = self._tilesource.getThumbnail(encoding="PNG") + thumbnail = self._bytes2pil(thumb_bytes).convert("RGB") + return thumbnail + else: + return self._wsi.get_thumbnail(self._thumbnail_size) # ------- implementation helpers ------- + @lazyproperty + def _metadata(self) -> dict: + return self._tilesource.getMetadata() + + @staticmethod + def _bytes2pil(bytesim): + image_content = BytesIO(bytesim) + image_content.seek(0) + return PIL.Image.open(image_content) + def _has_valid_coords(self, coords: CoordinatePair) -> bool: """Check if ``coords`` are valid 0-level coordinates. @@ -418,13 +520,22 @@ def _resample(self, scale_factor: int = 32) -> Tuple[PIL.Image.Image, np.array]: """ _, _, new_w, new_h = self._resampled_dimensions(scale_factor) - level = self._wsi.get_best_level_for_downsample(scale_factor) - whole_slide_image = self._wsi.read_region( - (0, 0), level, self._wsi.level_dimensions[level] - ) - # ---converts openslide read_region to an actual RGBA image--- - whole_slide_image = whole_slide_image.convert("RGB") - img = whole_slide_image.resize((new_w, new_h), PIL.Image.BILINEAR) + if USE_LARGEIMAGE: + img, _ = self._tilesource.getRegion( + scale=dict( + magnification=self._metadata["magnification"] / scale_factor + ), + format=large_image.tilesource.TILE_FORMAT_PIL, + ) + img = img.convert("RGB") + else: + level = self._wsi.get_best_level_for_downsample(scale_factor) + whole_slide_image = self._wsi.read_region( + (0, 0), level, self._wsi.level_dimensions[level] + ) + # ---converts openslide read_region to an actual RGBA image--- + whole_slide_image = whole_slide_image.convert("RGB") + img = whole_slide_image.resize((new_w, new_h), PIL.Image.BILINEAR) arr_img = np.asarray(img) return img, arr_img @@ -497,14 +608,32 @@ def _wsi(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: slide = openslide.open_slide(self._path) except PIL.UnidentifiedImageError: raise PIL.UnidentifiedImageError( - "Your wsi has something broken inside, a doctor is needed" + "Your wsi has something broken inside, a doctor is needed. " + + LARGEIMAGE_INSTALL_PROMPT ) except FileNotFoundError: raise FileNotFoundError( f"The wsi path resource doesn't exist: {self._path}" ) + except Exception as other_error: + msg = other_error.__repr__() + msg += f"\n{LARGEIMAGE_INSTALL_PROMPT}" + raise HistolabException(msg) return slide + @lazyproperty + def _tilesource(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: + """Open the slide and returns a large_image tile source object + + Returns + ------- + source : large_image TileSource object + An TileSource object representing a whole-slide image. + """ + assert USE_LARGEIMAGE, LARGEIMAGE_INSTALL_PROMPT + source = large_image.getTileSource(self._path) + return source + class SlideSet: """Slideset object. It is considered a collection of Slides.""" diff --git a/histolab/tiler.py b/histolab/tiler.py index 5ff803d1a..d39f6eb7d 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -57,6 +57,7 @@ class Tiler(Protocol): """General tiler object""" level: int + mpp: float # if provided, always takes precedence over level tile_size: Tuple[int, int] @abstractmethod @@ -247,6 +248,18 @@ def _validate_level(self, slide: Slide) -> None: f"{len(slide.levels)}" ) + def _fix_tile_size_if_mpp(self, base_mpp): + """ + Set tile size either to requested level or if MPP is requested, + set tile size relative to base mpp of slide instead. + """ + if self.mpp is None: + return + sf = self.mpp / base_mpp + self.tile_size = tuple(int(j * sf) for j in self.tile_size) + if hasattr(self, "pixel_overlap"): + self.pixel_overlap = int(self.pixel_overlap * sf) + def _validate_tile_size(self, slide: Slide) -> None: """Validate the tile size according to the Slide. @@ -276,6 +289,7 @@ class GridTiler(Tiler): (width, height) of the extracted tiles. level : int, optional Level from which extract the tiles. Default is 0. + Superceded by mpp if the mpp argument is provided. check_tissue : bool, optional Whether to check if the tile has enough tissue to be saved. Default is True. tissue_percent : float, optional @@ -290,6 +304,8 @@ class GridTiler(Tiler): Prefix to be added to the tile filename. Default is an empty string. suffix : str, optional Suffix to be added to the tile filename. Default is '.png' + mpp : float, optional + Micron per pixel resolution of extracted tiles. Takes precedence over level. """ def __init__( @@ -301,9 +317,12 @@ def __init__( pixel_overlap: int = 0, prefix: str = "", suffix: str = ".png", + mpp: float = None, ): self.tile_size = tile_size - self.level = level + self.final_tile_size = tile_size + self.level = level if mpp is None else 0 + self.mpp = mpp self.check_tissue = check_tissue self.tissue_percent = tissue_percent self.pixel_overlap = pixel_overlap @@ -340,6 +359,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) + self._fix_tile_size_if_mpp(slide.base_mpp) self._validate_tile_size(slide) grid_tiles = self._tiles_generator(slide, extraction_mask) @@ -364,8 +384,8 @@ def tile_size(self, tile_size_: Tuple[int, int]): # ------- implementation helpers ------- + @staticmethod def _are_coordinates_within_extraction_mask( - self, tile_thumb_coords: CoordinatePair, binary_mask_region: np.ndarray, ) -> bool: @@ -528,7 +548,12 @@ def _tiles_generator( ) for coords in grid_coordinates_generator: try: - tile = slide.extract_tile(coords, self.level, self.tile_size) + tile = slide.extract_tile( + coords, + tile_size=self.final_tile_size, + mpp=self.mpp, + level=self.level if self.mpp is None else None, + ) except ValueError: continue @@ -581,6 +606,7 @@ class RandomTiler(Tiler): Maximum number of tiles to extract. level : int, optional Level from which extract the tiles. Default is 0. + Superceded by mpp if the mpp argument is provided. seed : int, optional Seed for RandomState. Must be convertible to 32 bit unsigned integers. Default is 7. @@ -597,6 +623,8 @@ class RandomTiler(Tiler): max_iter : int, optional Maximum number of iterations performed when searching for eligible (if ``check_tissue=True``) tiles. Must be grater than or equal to ``n_tiles``. + mpp : float, optional + Micron per pixel resolution. If provided, takes precedence over level. """ def __init__( @@ -610,11 +638,14 @@ def __init__( prefix: str = "", suffix: str = ".png", max_iter: int = int(1e4), + mpp: float = None, ): self.tile_size = tile_size + self.final_tile_size = tile_size self.n_tiles = n_tiles self.max_iter = max_iter - self.level = level + self.level = level if mpp is None else 0 + self.mpp = mpp self.seed = seed self.check_tissue = check_tissue self.tissue_percent = tissue_percent @@ -650,6 +681,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) + self._fix_tile_size_if_mpp(slide.base_mpp) self._validate_tile_size(slide) random_tiles = self._tiles_generator(slide, extraction_mask) @@ -759,7 +791,12 @@ def _tiles_generator( while True: tile_wsi_coords = self._random_tile_coordinates(slide, extraction_mask) try: - tile = slide.extract_tile(tile_wsi_coords, self.level, self.tile_size) + tile = slide.extract_tile( + tile_wsi_coords, + tile_size=self.final_tile_size, + mpp=self.mpp, + level=self.level if self.mpp is None else None, + ) except ValueError: iteration -= 1 continue @@ -793,6 +830,7 @@ class ScoreTiler(GridTiler): will be saved (same exact behaviour of a GridTiler). Cannot be negative. level : int, optional Level from which extract the tiles. Default is 0. + Superceded by mpp if the mpp argument is provided. check_tissue : bool, optional Whether to check if the tile has enough tissue to be saved. Default is True. tissue_percent : float, optional @@ -807,6 +845,8 @@ class ScoreTiler(GridTiler): Prefix to be added to the tile filename. Default is an empty string. suffix : str, optional Suffix to be added to the tile filename. Default is '.png' + mpp : float, optional. + Micron per pixel resolution. If provided, takes precedence over level. """ def __init__( @@ -820,6 +860,7 @@ def __init__( pixel_overlap: int = 0, prefix: str = "", suffix: str = ".png", + mpp: float = None, ): self.scorer = scorer self.n_tiles = n_tiles @@ -832,6 +873,7 @@ def __init__( pixel_overlap, prefix, suffix, + mpp=mpp, ) def extract( @@ -869,6 +911,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) + self._fix_tile_size_if_mpp(slide.base_mpp) self._validate_tile_size(slide) highest_score_tiles, highest_scaled_score_tiles = self._tiles_generator( @@ -879,7 +922,12 @@ def extract( filenames = [] for tiles_counter, (score, tile_wsi_coords) in enumerate(highest_score_tiles): - tile = slide.extract_tile(tile_wsi_coords, self.level, self.tile_size) + tile = slide.extract_tile( + tile_wsi_coords, + tile_size=self.final_tile_size, + mpp=self.mpp, + level=self.level if self.mpp is None else None, + ) tile_filename = self._tile_filename(tile_wsi_coords, tiles_counter) tile.save(os.path.join(slide.processed_path, tile_filename)) filenames.append(tile_filename) @@ -961,8 +1009,6 @@ def _save_report( Parameters ---------- - slide : Slide - The slide to extract the tiles from. report_path : str Path to the report highest_score_tiles : List[Tuple[float, CoordinatePair]] From 11fbb4849534696ef64abe7cbf09c353a05a0069 Mon Sep 17 00:00:00 2001 From: kheffah Date: Fri, 23 Jul 2021 23:59:05 -0500 Subject: [PATCH 02/47] [WIP] - allow control of large_image usage for Slide even if large_image is installed --- histolab/slide.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index ea45fb37f..83dfd97a5 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -74,13 +74,19 @@ class Slide: """ def __init__( - self, path: Union[str, pathlib.Path], processed_path: Union[str, pathlib.Path] + self, + path: Union[str, pathlib.Path], + processed_path: Union[str, pathlib.Path], + use_largeimage=None, ) -> None: self._path = str(path) if isinstance(path, pathlib.Path) else path if processed_path is None: raise TypeError("processed_path cannot be None.") self._processed_path = processed_path + self._use_largeimage = ( + use_largeimage if use_largeimage is not None else USE_LARGEIMAGE + ) def __repr__(self): return ( @@ -93,7 +99,7 @@ def __repr__(self): @lazyproperty def base_mpp(self) -> float: """Get microns-per-pixel resolution at scan magnification.""" - if USE_LARGEIMAGE: + if self._use_largeimage: return self._metadata["mm_x"] * (10 ** 3) elif "openslide.mpp-x" in self.properties: @@ -128,7 +134,7 @@ def dimensions(self) -> Tuple[int, int]: dimensions : Tuple[int, int] Slide dimensions (width, height) """ - if USE_LARGEIMAGE: + if self._use_largeimage: return self._metadata["sizeX"], self._metadata["sizeY"] else: return self._wsi.dimensions @@ -434,7 +440,7 @@ def thumbnail(self) -> PIL.Image.Image: PIL.Image.Image The slide thumbnail. """ - if USE_LARGEIMAGE: + if self._use_largeimage: thumb_bytes, _ = self._tilesource.getThumbnail(encoding="PNG") thumbnail = self._bytes2pil(thumb_bytes).convert("RGB") return thumbnail @@ -520,7 +526,7 @@ def _resample(self, scale_factor: int = 32) -> Tuple[PIL.Image.Image, np.array]: """ _, _, new_w, new_h = self._resampled_dimensions(scale_factor) - if USE_LARGEIMAGE: + if self._use_largeimage: img, _ = self._tilesource.getRegion( scale=dict( magnification=self._metadata["magnification"] / scale_factor @@ -630,7 +636,7 @@ def _tilesource(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: source : large_image TileSource object An TileSource object representing a whole-slide image. """ - assert USE_LARGEIMAGE, LARGEIMAGE_INSTALL_PROMPT + assert self._use_largeimage, LARGEIMAGE_INSTALL_PROMPT source = large_image.getTileSource(self._path) return source From 9aa6721ab9b1dcdbf16f7bd44230aaa185de6376 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sat, 24 Jul 2021 00:04:09 -0500 Subject: [PATCH 03/47] [WIP] - allow control of large_image usage for Slide even if large_image is installed --- histolab/slide.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 83dfd97a5..14d6a21ff 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -77,16 +77,16 @@ def __init__( self, path: Union[str, pathlib.Path], processed_path: Union[str, pathlib.Path], - use_largeimage=None, + use_largeimage=USE_LARGEIMAGE, ) -> None: self._path = str(path) if isinstance(path, pathlib.Path) else path if processed_path is None: raise TypeError("processed_path cannot be None.") self._processed_path = processed_path - self._use_largeimage = ( - use_largeimage if use_largeimage is not None else USE_LARGEIMAGE - ) + if use_largeimage: + assert USE_LARGEIMAGE, "large_image module is not found!" + self._use_largeimage = use_largeimage def __repr__(self): return ( From 282f5a1a0aef2114aba49eac3014038de9fea32d Mon Sep 17 00:00:00 2001 From: kheffah Date: Sat, 24 Jul 2021 11:02:14 -0500 Subject: [PATCH 04/47] [WIP] - fix testing now that large_image usage is an option --- histolab/slide.py | 39 +++++++++++------- histolab/util.py | 16 +++++++ .../small-region-svs-resampled-array.npy | Bin 19172 -> 19172 bytes tests/integration/test_slide.py | 7 ++-- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 14d6a21ff..ea7136dfe 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -31,8 +31,13 @@ from .filters.compositions import FiltersComposition from .tile import Tile from .types import CoordinatePair -from .util import lazyproperty +from .util import ( + LARGEIMAGE_INSTALL_PROMPT, + _check_largeimage_installation, + lazyproperty, +) +LARGEIMAGE_IS_INSTALLED = _check_largeimage_installation() if TYPE_CHECKING: from .masks import BinaryMask @@ -51,10 +56,8 @@ from PIL.Image import BICUBIC, LANCZOS IMG_EXT = "png" -LARGEIMAGE_INSTALL_PROMPT = ( - "It maybe a good idea to install large_image to handle this. " - "See: https://github.com/girder/large_image" -) +IMG_UPSAMPLE_MODE = PIL.Image.BILINEAR # TODO: BICUBIC is often better +IMG_DOWNSAMPLE_MODE = PIL.Image.BILINEAR # TODO: LANCZOS is often better class Slide: @@ -77,7 +80,7 @@ def __init__( self, path: Union[str, pathlib.Path], processed_path: Union[str, pathlib.Path], - use_largeimage=USE_LARGEIMAGE, + use_largeimage=False, ) -> None: self._path = str(path) if isinstance(path, pathlib.Path) else path @@ -85,7 +88,7 @@ def __init__( raise TypeError("processed_path cannot be None.") self._processed_path = processed_path if use_largeimage: - assert USE_LARGEIMAGE, "large_image module is not found!" + assert LARGEIMAGE_IS_INSTALLED, "large_image module is not found!" self._use_largeimage = use_largeimage def __repr__(self): @@ -204,7 +207,10 @@ def extract_tile( asis = all(tile_size[i] == j for i, j in enumerate(image.size)) if not asis: image = image.resize( - tile_size, BICUBIC if tile_size[0] >= image.size[0] else LANCZOS + tile_size, + IMG_UPSAMPLE_MODE + if tile_size[0] >= image.size[0] + else IMG_DOWNSAMPLE_MODE, ) tile = Tile(image, coords, level) @@ -526,22 +532,25 @@ def _resample(self, scale_factor: int = 32) -> Tuple[PIL.Image.Image, np.array]: """ _, _, new_w, new_h = self._resampled_dimensions(scale_factor) - if self._use_largeimage: + if self._use_largeimage and self._metadata["magnification"] is not None: img, _ = self._tilesource.getRegion( - scale=dict( - magnification=self._metadata["magnification"] / scale_factor - ), format=large_image.tilesource.TILE_FORMAT_PIL, + scale={"magnification": self._metadata["magnification"] / scale_factor}, ) img = img.convert("RGB") else: level = self._wsi.get_best_level_for_downsample(scale_factor) - whole_slide_image = self._wsi.read_region( + wsi_image = self._wsi.read_region( (0, 0), level, self._wsi.level_dimensions[level] ) # ---converts openslide read_region to an actual RGBA image--- - whole_slide_image = whole_slide_image.convert("RGB") - img = whole_slide_image.resize((new_w, new_h), PIL.Image.BILINEAR) + wsi_image = wsi_image.convert("RGB") + img = wsi_image.resize( + (new_w, new_h), + IMG_UPSAMPLE_MODE + if new_w >= wsi_image.size[0] + else IMG_DOWNSAMPLE_MODE, + ) arr_img = np.asarray(img) return img, arr_img diff --git a/histolab/util.py b/histolab/util.py index ff834a752..fe6121220 100644 --- a/histolab/util.py +++ b/histolab/util.py @@ -30,6 +30,22 @@ warn = functools.partial(warnings.warn, stacklevel=2) +LARGEIMAGE_INSTALL_PROMPT = ( + "It maybe a good idea to install large_image to handle this. " + "See: https://github.com/girder/large_image" +) + + +def _check_largeimage_installation(): + try: + import large_image + + _ = large_image.__version__ # to avoid unused import linting error + + return True + except (ModuleNotFoundError, ImportError): + return False + def apply_mask_image(img: PIL.Image.Image, mask: np.ndarray) -> PIL.Image.Image: """Mask image with the provided binary mask. diff --git a/tests/expectations/svs-images/small-region-svs-resampled-array.npy b/tests/expectations/svs-images/small-region-svs-resampled-array.npy index 20ad0f749fbd1a5a4341175d4d1683ff790b0a23..35e9442a124b59b4b3f03f7276ff00937b9798f0 100644 GIT binary patch literal 19172 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

3ZjKDz(SQ<147dRtK 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 Date: Sat, 24 Jul 2021 11:09:50 -0500 Subject: [PATCH 05/47] revert tests/expectations/svs-images/small-region-svs-resampled-array.npy -- let's change the default up and downsampling method in a future pull request instead of doign two things at once. Turns out the up/downsampling affects many, many of the testing expectations and deserves its own PR with full proper testing sometime in the future --- .../small-region-svs-resampled-array.npy | Bin 19172 -> 19172 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/expectations/svs-images/small-region-svs-resampled-array.npy b/tests/expectations/svs-images/small-region-svs-resampled-array.npy index 35e9442a124b59b4b3f03f7276ff00937b9798f0..20ad0f749fbd1a5a4341175d4d1683ff790b0a23 100644 GIT binary patch literal 19172 zcmbVz_gh<8n&#}U`zP$~%+#|}J=HVa)4fyGUD;*3>?((H&NjxFoHGJtlqDo2Kp=ro zKoMmSS!96-LSzF5Y-8hyjZHAN>*xLMJ<>(S?&_V@bDzU^&OP^>_dQ=Y;a)0FT|9Z_ z>VNx(!hfKBmXMeqpZD3fpL|9q{pV+Y{N%Hw?7V`!*sPfByo5xo{O8z={6r|vPl?S* zg#7Qn`TUQceEH2EKl$R{{`d*wlmDOJe}DUW=hruHetrA)-P%~@p^mv9S#8Xg44l!ghFTyT_J>#bZ}A_PMi*c2i34Fe&Iv$ z?lm?_I2S}J#A}xhFhUm~Bm@j?B-03j#I0QcxZrf&y{)OTuH1Y07KUqg*x$ZGNH8LA zB22Fu?B!B5(_P_8surUf*~!d-v(`>YJ@?j456-lw#Gw6hI*G6Ec)U z${%KT86oa(eV+d9jV+wcTa;qsh7T{MvX9ciP$3oI0}j;?Nc^c?Ys3rJSDp+jo9gqc z=DUJ#U%y5*L#lyB5bF@B2zCUu5MX@p3Gk5|2^nt$oCR25L~w+lBVYx+02fS0yBFe! zjEzXc`oc$G>*dDA!?o8hUIFHRiPM@uNK-21>Y8vuu1{9tLQ%mbBk3%Mnk%h-G z4Zf!%L~SpfuzJ+g(fmrI%%Uk!nDP`}LA}4!=`N@lwe&n+d9o6izc;z~WMO6V**X@5LT%!@Mnod% z>|#dRfv|usWLgm%2|7DC9q?AXWuS*C3yW`S6Io3S%;^A7**3%`Z8&oy)>@FJTJ%yN~VX8-w85)ETipi*P4FCJ~hQ=^Vgs2Agx&8WX*ZAF@iMLy?Up-n= zRoE)>Diz66GfUTJ?+=tX{d8kbZOdfqP&ePGi{+^DD>}HQ<-r-~9T;J0sI{=~6PgN9 zLxUN{Pu#_5qDT%8;&mcWh1~5Xz4D}2Klyt(Au3@5y(;WrV*AC$n@A^m4I^Q8Evzg*3w6k7wV&fXhPG#hD}p|ZvimZhyw-DmH8_TcH<@T^za=-0H(xd-~mDra8R zu)e*q#5@~@QK&IdHRJ7LFe7X{TL<+)lwR@8cv-sIn}YIqp|8>RdTV#ao^kHQ1%L%0hUJ{Ro3G~`maj0}yn)D~zPT?#I4zuF2hilBov z7{U{wn)l`rJSL#H0T1GXysM_jl5i-%LE_#KK@DbJ93G@_3lF(FZ#EyT-S^EbbOfzb zO^3khSGM*G9d+5ticI;f!2H{t9sH3Hsb`O#Jzag$q@Ym53mY49Wa1HgzkrPi{wy>VK?jk-8^^oD)r1+?i@T3k0=GLV z9qyc}{>mo5%Qv8FA1-Sc<=c7%w$jwnPJQR=?biey)aL8W?M_$=>Ai#XJ@fuqh;`Ts zJUnPv<mwvF}%o;BUX|SSONpnlOyx5VJC$vD2xGj z9|Sm*;yxIGHWE4B*T=cQEok%AgQq<**Xq#RoTR>3$=D~@929xtGuBuj4y$}jUo z=ZYmaMGx*i1Y>JZHyewMO(hPS*h)H2HW`~>h6)WDvdIu5%yH1FI2_sDz%FBF8@S-E z1onmjMqDD`Avb^D*(2!S`ifv09uwpaSPC}Q#jl^J^vu-vX|A$zf67hTm!7phi+m`r zHnB`}nkGEUtRhveKYs}ZGtx5JT-u;x);H02^3adN^9{o-_CM${RGv5>svs>nuHW zUqnigUc^9as|NT+;L?*RzW!L$<* z*}_@mH7FDyI#`SX`Uo&$1Y<&ELjnOC=eT`4Jm%;gk8ufj`)dQ*3blu(Adj^&^)g|S0+2)=_W1*3zm-7$v}Jg|gI!pb5#A}H@+G}jaKa!ix9UOh=smmqd# zE8S^T{PV1&@6%(yO3FEyop&gg7gM65>G<&_c}Mb#&(IBdwTc93_Mx1#@6+OrW-iX% zf#CM^#Y>o0LcL%L!kq}fgNzjPVH)!25SK`(XgVe^6AIc>LX^X^fB~*q*T7IsTX$8H zC8w%6U2ZF=HD)R)C#VVEq^9l9$~%-tJx+FW%-kqW?3an;BL#|hnS-PiUMNmEnV-Bb zjZc=}SzLj3ApByd7GcN4I>JbOKnL@h5J9L+-~oZi;?JUaC#;=tO`<-kye9=1Hn+}wg@_guIkMd&C@A#7;u5wxj86D80H^jIn6U9< z!0@v~n4Pybw>MvIZEb9CZ()`Ua|&!pAS@FP5&}H6HnrHDD(jM1J7msAiq=J{H593o z8FKbTj_8U&e1Thhl)^qnkz8PtkC0NnOV8Sune#)|g}=lc`Q-Rd|9JS)@6Tr)NT*$7 z%#O~D_K(}NEilg_I-vmsI?&(i9c)oY1O&pN@Y7BVJbnRqn4t zO^)7bRX6Jdre_bIVdu0sJm7)s;pG}0@xpY_f(K8-MDRzpHn(@ScVHC+DgZ^m#_HAD z%cYUIKz+Ag;&dcR4XNeE%qn}9!j`RYXUdx>if(~v+8fx0HUIYO*Dp6#2WMJ1b@RUQ z=As%$dW9oTp)af{OO}$(Pz6x}d5pB|icox2Ad4y~xxi(eWTbtaeE#3gU;QE~eShlt z-<>}8Pe-qRalJIJs?$B7V$2$BX8(u5e{HOUt7r<E|>5ax(33y1KZoqsebFw?BOFWUw#TYW5C$1JlE^^V7@o zx9&cBycY5x6bC}y6pG%U!5uh>g^lF1m+Uu(MFvEwMX`YK;Xo zRrv}%Q(uu%&WYj)t_fr@67G2p{Wvx5i}>UJeB}IR=g$4%!nOar67~DDiQmP^Xi9&_ z2x!D(Zl4Y=jE&FNo7)>)Jp)5i1HHlZwRPA@y%*Uq$Jq^ZKxgaa=IX8c4@MRq49%^M z-M&A*v@$$D-4%Si_+b0RD*`su{970DR!FyBZQg1anzj1|X}VyE&Brntz}Qk`b!2rt zRmZy_5M3{kM3pdpp=IyOIPuA`s6Smu-k%ixWpvSr{L}*}v;>~HwEq6+{L1p`VCPt? zvu}BM)ju?Cvw9ysS$nVh=>Nz z&XRY0w64=9w2$#D1H~p=QLTZYbMfkJEFy~Lp%DLnhTQ)mD67kjC-GPffA zrwr;zQuG(s?@Vr9+ zChCW%;Tj?Cev}Io5_CXV>KgA6+J?lA36X7>X>6istyHa>Yf)y)nO8U^*M!Uyw8X!} zpZetIlyBqec?NL;pK^|pb2O(kue7V#7jq)bD>MlwY*!3e_jtfswzrtKrE2Dp|^j-^}Z zXv$HwbFHQ#P4P80OgDM^vd({cf_#x=;OlFtDoJKZ!Lf`hf4HFHX_+a)a)PIa z4O0iq;CFme{Su3<+Eu1&?CKk{+B;UCJcC#5VMbwN!?gob52mb-UHH_4#jeP^kOkz3 z0+LY{(SefJn_E-rc1@Iv_px))DnN& zaJ#N;E-+`WvPm+;^)km;|K!H2O>h%5Z-OVT%&mA7jU95Q%i3Aq+LOSPkupVtgOd*) zJ%a^z*n`5B2Bh8xOk@enAVd~M(jn>~I*@KYc@b1JH<2~|B27<`rh{o1R(pHsh9;86 zR8UJfO)WT>7yYMe#h0n2DWy#+PqU)2zSLUH)z1vyvX{C#>w91+g-_{kU@DyK881th zWgX2nh|Pm0ZyB$)yv9n4=b2Rv!y_}HSv4Hrko!cUjf4&5dyvCj0zV<3Is_d!z!@oT z?4s!h^Hsf^dP{cYkkH;qscU2yWJ#sGC~n@tyoA5rEILcBAS=f@gWaZX4O?F=GR}_8 z_d0zI3eW1wBSdFz2Z=jcJ7;fmB3@%Lgrd~c<51|dw*}LB% zxF7+}oIkqGB#0n^AOaX*I({5n>Jiw$Wjfg=UxlZYR5Ky4w^Q|YijEClf014Eb78`l zvAiUaUESoZ?`|@5nB>m6soS@wmwTLj&f2EA$=h3-ThAY_>A7`TKWCo$-HF4nG(D2A zIJLCcGP2k*1U9y@wS_G~k=YOt8+RXs2Vsk31hdb7jn2-_tJSpy-*f{-;}bhN@>PA6 zjV*<>ezLZiQpdf@CY__e!Y1cnPTaR~ZgtaePY`U(rEakZZC&nuHP7fZb>CfBfwv5v zx;B%vo)yhb{4Vyu|2%l&&zJN{+vA5%v4u3uq#@%Exe10GG5UZ`xF+T-h#*Qs`oas5 zbrE;bJ^~8ioP6`?OE^?Fq*gTLSN9ehx~0y}B2^n42wY)PPE(|*vchBJtRFJ6j%QE! zXCAIRE~Qpi(Q4IXjfSJQ$Xspa?qL6vl2w;;jw&X}uYMPO;D7yiegU1KA#MQZ17F+$oT?jm-edY}VedbF3?by-!d)VjW6eLvF>kT@%2#relbv~zUE z1!mDPa>8F?OEOCD&EMbL*z%Y%jjyDW6QB~R5? zpz=|*Lp-bKDmUj)9_g1N((xklQSyyHU$s^_U{m(u*?Q17HPRNC^3P55O*Lp*I$gd> zwk~>K{PEAuMt>DgxyGIxo5M~&!mADduzUSI?Vxcc>Jv~wz=%Yg3P#={`a(j#p~FV2 zGv_FJvXwrvx|dhay-=M0QvvM^je3%n{(bt*uWvSJTOTYx+S zb_epX$!mp!0*c0)qa0xB8>w3Q89M1GiFu7(bhIeu&)1WGOm8uC`P%~z?>)Y=u(CL_ zyfD2qH?i2$;1|;sr~Y&~d4FcwMXI63_3X)WG?^0ELhz7YLN|B zj6S^kAycS>F6e#E-tT1?+Vg6BWX*7~Nu5@fdpK7dC!}94&N`5N{qyV0WO1w0XRdOa zg!S`dx9{A#H}0Qlclqo}Ptxg}xbM>oud@Y36&?DPm+P;HmZ4LKIrtGl2yl2k5aB+U zc0vbJ2sTP10q-gKZ3RJhQ#x;Nk*+0M0dxj<^(E0fX`IMhSX+3KoUsokkyFL7T)oo6 zO)VEBNE`JX@Uoz%#c!@{Dr2cG|1~D{a2~v*YoKf4#Sozyf)t9PXtYD?XG}-oWgBjC zgckN7k0kX`7LgC49$`y9S<%=-(G9ZAZ3Q)Ms+N6;sV!2~<|;A{=Vttnb@F$ofBx6w zX{U<#iKX>2m#eOQw0GQXX_E0uPziKkn}Aw|z6c>i2lHsm zckoFE$#@nwHdGnXNEVSTM3xgWQ6G)u+O2y&hQ3o|4T$Umq*`@OrR=8A&9kTrs&kK! z3VtfQ`IopK|HuBbf4p47)7exFTAB6E+&z=hm2!a;w?8fY$6Vf7YOlH#KADKbL-Ydu zMsUHEf=`{8g5VaU;ZOiIi54OsC*(Z>ktH64SUqR%8e$rQuyNy9+!=EEC2Ae5wuNue zWLD-MA(4+!at`L6`1I7z|9Cj_94YNWex2Of(=;f^D~A^fdB@2`$4H$e*42q+!pu

3ZjKDz(SQ<147dRtK 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 Date: Sat, 24 Jul 2021 11:16:11 -0500 Subject: [PATCH 06/47] OK now all tests pass without large_image usage. next, we'll incorporate tests for large_image usage --- tests/unit/test_slide.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 0f4f534b9..a65d27526 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -14,6 +14,7 @@ from histolab.exceptions import LevelError, SlidePropertyError from histolab.slide import Slide, SlideSet from histolab.types import CP +from histolab.util import LARGEIMAGE_INSTALL_PROMPT from ..unitutil import ( ANY, @@ -173,9 +174,9 @@ def or_it_raises_an_PIL_exception(self, tmpdir): slide._wsi assert isinstance(err.value, PIL.UnidentifiedImageError) - assert ( - str(err.value) == "Your wsi has something broken inside, a doctor is needed" - ) + broken_err = "Your wsi has something broken inside, a doctor is needed" + broken_err += ". " + LARGEIMAGE_INSTALL_PROMPT + assert str(err.value) == broken_err def it_can_resample_itself(self, tmpdir, resampled_dims_): slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240) From d5d12ffc1eca78750333ced0995ad3562d2483d3 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sat, 24 Jul 2021 22:11:02 -0500 Subject: [PATCH 07/47] Unit tests for Slide module with large_image usage --- histolab/slide.py | 63 +++++++++++--------------- histolab/util.py | 6 +-- tests/integration/test_slide.py | 59 +++++++++++++++++++++--- tests/unit/test_slide.py | 80 ++++++++++++++++++++++++++++----- tests/unitutil.py | 4 +- 5 files changed, 156 insertions(+), 56 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index ea7136dfe..19cf564c1 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -31,13 +31,10 @@ from .filters.compositions import FiltersComposition from .tile import Tile from .types import CoordinatePair -from .util import ( - LARGEIMAGE_INSTALL_PROMPT, - _check_largeimage_installation, - lazyproperty, -) +from .util import _check_largeimage, lazyproperty + +LARGEIMAGE_IS_INSTALLED, LARGEIMAGE_INSTALL_PROMPT = _check_largeimage() -LARGEIMAGE_IS_INSTALLED = _check_largeimage_installation() if TYPE_CHECKING: from .masks import BinaryMask @@ -109,24 +106,14 @@ def base_mpp(self) -> float: return float(self.properties["openslide.mpp-x"]) elif "aperio.MPP" in self.properties: return float(self.properties["aperio.MPP"]) - elif "tiff.XResolution" in self.properties: - resunit = self.properties["tiff.ResolutionUnit"] - if resunit == "centimeter": - return 1 / (float(self.properties["tiff.XResolution"]) * 1e-4) - else: - raise NotImplementedError( - f"Unimplemented tiff.ResolutionUnit {resunit}" - ) - else: - raise NotImplementedError( - "Unknown scan magnification! " + LARGEIMAGE_INSTALL_PROMPT - ) - - def get_mpp_at_level(self, level: int): - """Get microns-per-pixel resolution at a specific level.""" - # large_image tile source has different internal repr. of levels - lvl = self._tilesource.levels - level - 1 - return self._tilesource.getMagnificationForLevel(lvl)["mm_x"] * (10 ** 3) + elif ( + "tiff.XResolution" in self.properties + and self.properties["tiff.ResolutionUnit"] == "centimeter" + ): + return 1e4 / float(self.properties["tiff.XResolution"]) + raise NotImplementedError( + "Unknown scan magnification! " + LARGEIMAGE_INSTALL_PROMPT + ) @lazyproperty def dimensions(self) -> Tuple[int, int]: @@ -532,25 +519,28 @@ def _resample(self, scale_factor: int = 32) -> Tuple[PIL.Image.Image, np.array]: """ _, _, new_w, new_h = self._resampled_dimensions(scale_factor) - if self._use_largeimage and self._metadata["magnification"] is not None: - img, _ = self._tilesource.getRegion( + if self._use_largeimage: + mg = "magnification" + kws = ( + {"scale": {mg: self._metadata[mg] / scale_factor}} + if self._metadata[mg] is not None + else {} + ) + wsi_image, _ = self._tilesource.getRegion( format=large_image.tilesource.TILE_FORMAT_PIL, - scale={"magnification": self._metadata["magnification"] / scale_factor}, + **kws, ) - img = img.convert("RGB") else: level = self._wsi.get_best_level_for_downsample(scale_factor) wsi_image = self._wsi.read_region( (0, 0), level, self._wsi.level_dimensions[level] ) - # ---converts openslide read_region to an actual RGBA image--- - wsi_image = wsi_image.convert("RGB") - img = wsi_image.resize( - (new_w, new_h), - IMG_UPSAMPLE_MODE - if new_w >= wsi_image.size[0] - else IMG_DOWNSAMPLE_MODE, - ) + # ---converts openslide read_region to an actual RGBA image--- + wsi_image = wsi_image.convert("RGB") + img = wsi_image.resize( + (new_w, new_h), + IMG_UPSAMPLE_MODE if new_w >= wsi_image.size[0] else IMG_DOWNSAMPLE_MODE, + ) arr_img = np.asarray(img) return img, arr_img @@ -603,6 +593,7 @@ def _thumbnail_size(self) -> Tuple[int, int]: Tuple[int, int] Thumbnail size """ + assert not self._use_largeimage, "Please use thumbnail.size instead" return tuple( [ int(s / np.power(10, math.ceil(math.log10(s)) - 3)) diff --git a/histolab/util.py b/histolab/util.py index fe6121220..f45231c28 100644 --- a/histolab/util.py +++ b/histolab/util.py @@ -36,15 +36,15 @@ ) -def _check_largeimage_installation(): +def _check_largeimage(): try: import large_image _ = large_image.__version__ # to avoid unused import linting error - return True + return True, "" except (ModuleNotFoundError, ImportError): - return False + return False, LARGEIMAGE_INSTALL_PROMPT def apply_mask_image(img: PIL.Image.Image, mask: np.ndarray) -> PIL.Image.Image: diff --git a/tests/integration/test_slide.py b/tests/integration/test_slide.py index d31508f8b..8afb8fe6c 100644 --- a/tests/integration/test_slide.py +++ b/tests/integration/test_slide.py @@ -10,12 +10,14 @@ from histolab.exceptions import LevelError, SlidePropertyError from histolab.masks import BiggestTissueBoxMask, TissueMask from histolab.slide import Slide -from histolab.util import LARGEIMAGE_INSTALL_PROMPT +from histolab.util import _check_largeimage from ..fixtures import EXTERNAL_SVS, SVS from ..unitutil import on_ci from ..util import load_expectation, load_python_expression +LARGEIMAGE_IS_INSTALLED, LARGEIMAGE_INSTALL_PROMPT = _check_largeimage() + class Describe_Slide: def it_knows_its_name(self): @@ -27,6 +29,40 @@ def it_knows_its_name(self): assert name == ntpath.basename(SVS.CMU_1_SMALL_REGION).split(".")[0] + @pytest.mark.parametrize( + "use_largeimage, fake_props", + [ + (True, None), + (False, None), + (False, {"aperio.MPP": 0.499}), + ( + False, + { + "tiff.XResolution": 20040.080160320642, + "tiff.ResolutionUnit": "centimeter", + }, + ), + ], + ) + def it_knows_its_base_mpp(self, use_largeimage, fake_props): + + if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): + return + + slide = Slide( + SVS.CMU_1_SMALL_REGION, + os.path.join(SVS.CMU_1_SMALL_REGION, "processed"), + use_largeimage=use_largeimage, + ) + if fake_props: + del slide.properties["openslide.mpp-x"] + del slide.properties["aperio.MPP"] + slide.properties.update(fake_props) + + mpp = slide.base_mpp + + np.testing.assert_almost_equal(mpp, 0.499) + def it_calculate_resampled_nparray_from_small_region_svs_image(self): slide = Slide( SVS.CMU_1_SMALL_REGION, os.path.join(SVS.CMU_1_SMALL_REGION, "processed") @@ -39,9 +75,22 @@ def it_calculate_resampled_nparray_from_small_region_svs_image(self): ) np.testing.assert_almost_equal(resampled_array, expected_value) - def it_knows_the_right_slide_dimension(self): + @pytest.mark.parametrize( + "use_largeimage", + [ + (False,), + (True,), + ], + ) + def it_knows_the_right_slide_dimension(self, use_largeimage): + + if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): + return + slide = Slide( - SVS.CMU_1_SMALL_REGION, os.path.join(SVS.CMU_1_SMALL_REGION, "processed") + SVS.CMU_1_SMALL_REGION, + os.path.join(SVS.CMU_1_SMALL_REGION, "processed"), + use_largeimage=use_largeimage, ) image = PIL.Image.open(SVS.CMU_1_SMALL_REGION) @@ -133,8 +182,8 @@ def it_raises_openslideerror_with_broken_wsi(self): slide._wsi assert isinstance(err.value, PIL.UnidentifiedImageError) - broken_err = "Your wsi has something broken inside, a doctor is needed" - broken_err += ". " + LARGEIMAGE_INSTALL_PROMPT + broken_err = "Your wsi has something broken inside, a doctor is needed. " + broken_err += LARGEIMAGE_INSTALL_PROMPT assert str(err.value) == broken_err @pytest.mark.parametrize( diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index a65d27526..232f6b1f1 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -15,6 +15,7 @@ from histolab.slide import Slide, SlideSet from histolab.types import CP from histolab.util import LARGEIMAGE_INSTALL_PROMPT +from histolab.util import _check_largeimage from ..unitutil import ( ANY, @@ -31,21 +32,30 @@ property_mock, ) +LARGEIMAGE_IS_INSTALLED, LARGEIMAGE_INSTALL_PROMPT = _check_largeimage() + class Describe_Slide: @pytest.mark.parametrize( - "slide_path, processed_path", + "slide_path, processed_path, use_largeimage", [ - ("/foo/bar/myslide.svs", "/foo/bar/myslide/processed"), - (Path("/foo/bar/myslide.svs"), Path("/foo/bar/myslide/processed")), + ("/foo/bar/myslide.svs", "/foo/bar/myslide/processed", False), + ("/foo/bar/myslide.svs", "/foo/bar/myslide/processed", True), + (Path("/foo/bar/myslide.svs"), Path("/foo/bar/myslide/processed"), False), ], ) - def it_constructs_from_args(self, request, slide_path, processed_path): + def it_constructs_from_args( + self, request, slide_path, processed_path, use_largeimage + ): + + if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): + return + _init_ = initializer_mock(request, Slide) - slide = Slide(slide_path, processed_path) + slide = Slide(slide_path, processed_path, use_largeimage=use_largeimage) - _init_.assert_called_once_with(ANY, slide_path, processed_path) + _init_.assert_called_once_with(ANY, slide_path, processed_path, use_largeimage) assert isinstance(slide, Slide) def but_it_has_wrong_slide_path_type(self): @@ -98,6 +108,28 @@ def it_knows_its_name(self, slide_path, expected_value): assert name == expected_value + def it_raises_error_with_unknown_mpp(self, tmpdir): + slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240) + + with pytest.raises(NotImplementedError) as err: + _ = slide.base_mpp + + assert isinstance(err.value, NotImplementedError) + assert str(err.value) == ( + "Unknown scan magnification! " + LARGEIMAGE_INSTALL_PROMPT + ) + + def it_has_largeimage_tilesource(self, tmpdir): + + if not LARGEIMAGE_IS_INSTALLED: + return + + slide, _ = base_test_slide( + tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=True + ) + + assert slide._tilesource.name == "pilfile" + def it_knows_its_dimensions(self, tmpdir): slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240) @@ -178,8 +210,21 @@ def or_it_raises_an_PIL_exception(self, tmpdir): broken_err += ". " + LARGEIMAGE_INSTALL_PROMPT assert str(err.value) == broken_err - def it_can_resample_itself(self, tmpdir, resampled_dims_): - slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240) + @pytest.mark.parametrize( + "use_largeimage", + [ + (False,), + (True,), + ], + ) + def it_can_resample_itself(self, tmpdir, resampled_dims_, use_largeimage): + + if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): + return + + slide, _ = base_test_slide( + tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=use_largeimage + ) resampled_dims_.return_value = (100, 200, 300, 400) _resample = slide._resample(32) @@ -223,12 +268,27 @@ def it_knows_its_scaled_image(self, tmpdir, resampled_dims_): assert type(scaled_image) == PIL.Image.Image - def it_knows_its_thumbnail(self, tmpdir, resampled_dims_): + @pytest.mark.parametrize( + "use_largeimage", + [ + (False,), + (True,), + ], + ) + def it_knows_its_thumbnail(self, tmpdir, resampled_dims_, use_largeimage): + + if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): + return + 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, os.path.join(tmp_path_, "processed")) + slide = Slide( + slide_path, + os.path.join(tmp_path_, "processed"), + use_largeimage=use_largeimage, + ) resampled_dims_.return_value = (100, 200, 300, 400) thumb = slide.thumbnail diff --git a/tests/unitutil.py b/tests/unitutil.py index ee0cf034b..f4783cf1c 100644 --- a/tests/unitutil.py +++ b/tests/unitutil.py @@ -16,11 +16,11 @@ from unittest.mock import create_autospec, patch, PropertyMock # isort:skip -def base_test_slide(tmpdir, image): +def base_test_slide(tmpdir, image, use_largeimage=False): tmp_path_ = tmpdir.mkdir("myslide") image.save(os.path.join(tmp_path_, "mywsi.png"), "PNG") slide_path = os.path.join(tmp_path_, "mywsi.png") - slide = Slide(slide_path, "processed") + slide = Slide(slide_path, "processed", use_largeimage=use_largeimage) return slide, tmp_path_ From dabc2b508ff9e8c2a1c009786d818e3e897e1a26 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sun, 25 Jul 2021 00:29:36 -0500 Subject: [PATCH 08/47] [WIP] - Unit tests for tiler modules with large_image usage --- histolab/tiler.py | 10 +++++----- tests/unit/test_tiler.py | 37 +++++++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/histolab/tiler.py b/histolab/tiler.py index d39f6eb7d..4e248f034 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -248,14 +248,14 @@ def _validate_level(self, slide: Slide) -> None: f"{len(slide.levels)}" ) - def _fix_tile_size_if_mpp(self, base_mpp): + def _fix_tile_size_if_mpp(self, slide): """ Set tile size either to requested level or if MPP is requested, set tile size relative to base mpp of slide instead. """ if self.mpp is None: return - sf = self.mpp / base_mpp + sf = self.mpp / slide.base_mpp self.tile_size = tuple(int(j * sf) for j in self.tile_size) if hasattr(self, "pixel_overlap"): self.pixel_overlap = int(self.pixel_overlap * sf) @@ -359,7 +359,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) - self._fix_tile_size_if_mpp(slide.base_mpp) + self._fix_tile_size_if_mpp(slide) self._validate_tile_size(slide) grid_tiles = self._tiles_generator(slide, extraction_mask) @@ -681,7 +681,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) - self._fix_tile_size_if_mpp(slide.base_mpp) + self._fix_tile_size_if_mpp(slide) self._validate_tile_size(slide) random_tiles = self._tiles_generator(slide, extraction_mask) @@ -911,7 +911,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) - self._fix_tile_size_if_mpp(slide.base_mpp) + self._fix_tile_size_if_mpp(slide) self._validate_tile_size(slide) highest_score_tiles, highest_scaled_score_tiles = self._tiles_generator( diff --git a/tests/unit/test_tiler.py b/tests/unit/test_tiler.py index beb9d586a..6efdcf282 100644 --- a/tests/unit/test_tiler.py +++ b/tests/unit/test_tiler.py @@ -30,18 +30,31 @@ class Describe_RandomTiler: - @pytest.mark.parametrize("level", (2, -2)) - def it_constructs_from_args(self, level, request): + @pytest.mark.parametrize("level, mpp", ((2, None), (-2, None), (2, 0.5))) + def it_constructs_from_args(self, level, mpp, request): _init = initializer_mock(request, RandomTiler) - random_tiler = RandomTiler((512, 512), 10, level, 7, True, "", ".png", int(1e4)) + random_tiler = RandomTiler( + (512, 512), 10, level, 7, True, "", ".png", int(1e4), mpp=mpp + ) _init.assert_called_once_with( - ANY, (512, 512), 10, level, 7, True, "", ".png", int(1e4) + ANY, (512, 512), 10, level, 7, True, "", ".png", int(1e4), mpp=mpp ) assert isinstance(random_tiler, RandomTiler) assert isinstance(random_tiler, Tiler) + @pytest.mark.parametrize( + "level, mpp, expected_level, expected_mpp", + ((2, None, 2, None), (None, 0.5, 0, 0.5), (2, 0.5, 0, 0.5)), + ) + def mpp_supercedes_level(self, level, mpp, expected_level, expected_mpp): + + tiler = RandomTiler((512, 512), 10, level=level, mpp=mpp) + + assert tiler.level == expected_level + assert tiler.mpp == expected_mpp + def but_it_has_wrong_tile_size_value(self): with pytest.raises(ValueError) as err: RandomTiler((512, -1), 10, 0) @@ -410,13 +423,15 @@ def _random_tile_coordinates(self, request): class Describe_GridTiler: - @pytest.mark.parametrize("level", (2, -2)) - def it_constructs_from_args(self, level, request): + @pytest.mark.parametrize("level, mpp", ((2, None), (-2, None), (2, 0.5))) + def it_constructs_from_args(self, level, mpp, request): _init = initializer_mock(request, GridTiler) - grid_tiler = GridTiler((512, 512), level, True, 80, 0, "", ".png") + grid_tiler = GridTiler((512, 512), level, True, 80, 0, "", ".png", mpp=mpp) - _init.assert_called_once_with(ANY, (512, 512), level, True, 80, 0, "", ".png") + _init.assert_called_once_with( + ANY, (512, 512), level, True, 80, 0, "", ".png", mpp=mpp + ) assert isinstance(grid_tiler, GridTiler) assert isinstance(grid_tiler, Tiler) @@ -846,10 +861,12 @@ class Describe_ScoreTiler: def it_constructs_from_args(self, request): _init = initializer_mock(request, ScoreTiler) rs = RandomScorer() - score_tiler = ScoreTiler(rs, (512, 512), 4, 2, True, 80, 0, "", ".png") + score_tiler = ScoreTiler( + rs, (512, 512), 4, 2, True, 80, 0, "", ".png", mpp=None + ) _init.assert_called_once_with( - ANY, rs, (512, 512), 4, 2, True, 80, 0, "", ".png" + ANY, rs, (512, 512), 4, 2, True, 80, 0, "", ".png", mpp=None ) assert isinstance(score_tiler, ScoreTiler) From 33e6373127fb56629e31041ff21ef8c93c9e3504 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sun, 25 Jul 2021 16:20:52 -0500 Subject: [PATCH 09/47] Unit tests for tiler modules with large_image usage --- tests/integration/test_tiler.py | 41 +++++++++++++++++----------- tests/unit/test_tiler.py | 48 +++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/tests/integration/test_tiler.py b/tests/integration/test_tiler.py index a39b4d47b..38df40760 100644 --- a/tests/integration/test_tiler.py +++ b/tests/integration/test_tiler.py @@ -10,11 +10,14 @@ from histolab.scorer import NucleiScorer from histolab.slide import Slide from histolab.tiler import GridTiler, RandomTiler, ScoreTiler +from histolab.util import _check_largeimage from ..fixtures import EXTERNAL_SVS, SVS, TIFF from ..unitutil import on_ci from ..util import expand_tests_report, load_expectation +LARGEIMAGE_IS_INSTALLED, LARGEIMAGE_INSTALL_PROMPT = _check_largeimage() + class DescribeRandomTiler: @pytest.mark.parametrize( @@ -146,35 +149,43 @@ def it_locates_tiles_on_the_slide( np.testing.assert_array_almost_equal(tiles_location_img, expected_img) @pytest.mark.parametrize( - "fixture_slide, tile_size, level, seed, n_tiles", + "fixture_slide, tile_size, level, seed, n_tiles, mpp", ( # Squared tile size - (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 1, 42, 20), - (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 42, 10), - (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 1, 2, 20), - (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 2, 10), - (TIFF.KIDNEY_48_5, (10, 10), 0, 20, 20), - (TIFF.KIDNEY_48_5, (20, 20), 0, 20, 10), + (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 1, 42, 20, None), + (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 42, 10, None), + (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 1, 2, 20, None), + (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 2, 10, None), + (SVS.CMU_1_SMALL_REGION, (128, 128), 0, 2, 10, 0.5), + (TIFF.KIDNEY_48_5, (10, 10), 0, 20, 20, None), + (TIFF.KIDNEY_48_5, (20, 20), 0, 20, 10, None), # Not squared tile size - (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 42, 20), - (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 0, 42, 10), - (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 2, 20), - (TIFF.KIDNEY_48_5, (10, 20), 0, 2, 10), - (TIFF.KIDNEY_48_5, (20, 10), 0, 20, 20), - (TIFF.KIDNEY_48_5, (10, 15), 0, 20, 10), + (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 42, 20, None), + (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 0, 42, 10, None), + (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 2, 20, None), + (SVS.CMU_1_SMALL_REGION, (135, 128), 0, 2, 10, 0.5), + (TIFF.KIDNEY_48_5, (10, 20), 0, 2, 10, None), + (TIFF.KIDNEY_48_5, (20, 10), 0, 20, 20, None), + (TIFF.KIDNEY_48_5, (10, 15), 0, 20, 10, None), ), ) def test_extract_tiles_respecting_the_given_tile_size( - self, tmpdir, fixture_slide, tile_size, level, seed, n_tiles + self, tmpdir, fixture_slide, tile_size, level, seed, n_tiles, mpp ): + + use_largeimage = mpp is not None + if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): + return + processed_path = os.path.join(tmpdir, "processed") - slide = Slide(fixture_slide, processed_path) + slide = Slide(fixture_slide, processed_path, use_largeimage=use_largeimage) random_tiles_extractor = RandomTiler( tile_size=tile_size, n_tiles=n_tiles, level=level, seed=seed, check_tissue=True, + mpp=mpp, ) binary_mask = BiggestTissueBoxMask() diff --git a/tests/unit/test_tiler.py b/tests/unit/test_tiler.py index 6efdcf282..5105fd7bd 100644 --- a/tests/unit/test_tiler.py +++ b/tests/unit/test_tiler.py @@ -2,6 +2,7 @@ import logging import os import re +from collections import namedtuple from unittest.mock import call import numpy as np @@ -55,6 +56,16 @@ def mpp_supercedes_level(self, level, mpp, expected_level, expected_mpp): assert tiler.level == expected_level assert tiler.mpp == expected_mpp + @pytest.mark.parametrize( + "mpp, fixed_tile_size", ((None, (512, 512)), (0.5, (1024, 1024))) + ) + def it_can_fix_tile_size_if_mpp(self, mpp, fixed_tile_size): + fake_slide = namedtuple("fake_slide", ["base_mpp"]) + tiler = RandomTiler((512, 512), 0, True, 80, 0, "", ".png", mpp=mpp) + tiler._fix_tile_size_if_mpp(fake_slide(0.25)) + + assert tiler.tile_size == fixed_tile_size + def but_it_has_wrong_tile_size_value(self): with pytest.raises(ValueError) as err: RandomTiler((512, -1), 10, 0) @@ -435,6 +446,27 @@ def it_constructs_from_args(self, level, mpp, request): assert isinstance(grid_tiler, GridTiler) assert isinstance(grid_tiler, Tiler) + @pytest.mark.parametrize( + "level, mpp, expected_level, expected_mpp", + ((2, None, 2, None), (None, 0.5, 0, 0.5), (2, 0.5, 0, 0.5)), + ) + def mpp_supercedes_level(self, level, mpp, expected_level, expected_mpp): + + tiler = GridTiler((512, 512), level=level, mpp=mpp) + + assert tiler.level == expected_level + assert tiler.mpp == expected_mpp + + @pytest.mark.parametrize( + "mpp, fixed_tile_size", ((None, (512, 512)), (0.5, (1024, 1024))) + ) + def it_can_fix_tile_size_if_mpp(self, mpp, fixed_tile_size): + fake_slide = namedtuple("fake_slide", ["base_mpp"]) + tiler = GridTiler((512, 512), 0, True, 80, 0, "", ".png", mpp=mpp) + tiler._fix_tile_size_if_mpp(fake_slide(0.25)) + + assert tiler.tile_size == fixed_tile_size + def but_it_has_wrong_tile_size_value(self): with pytest.raises(ValueError) as err: GridTiler((512, -1)) @@ -597,8 +629,8 @@ def it_can_generate_grid_tiles_with_check_tissue( assert _extract_tile.call_args_list == ( [ - call(slide, CP(0, 0, 10, 10), 0, (10, 10)), - call(slide, CP(0, 0, 10, 10), 0, (10, 10)), + call(slide, CP(0, 0, 10, 10), tile_size=(10, 10), level=0, mpp=None), + call(slide, CP(0, 0, 10, 10), tile_size=(10, 10), level=0, mpp=None), ] ) assert _has_enough_tissue.call_args_list == [call(tile1, 60), call(tile2, 60)] @@ -653,8 +685,8 @@ def it_can_generate_grid_tiles_with_no_check_tissue( assert _extract_tile.call_args_list == ( [ - call(slide, CP(0, 0, 10, 10), 0, (10, 10)), - call(slide, CP(0, 0, 10, 10), 0, (10, 10)), + call(slide, CP(0, 0, 10, 10), tile_size=(10, 10), level=0, mpp=None), + call(slide, CP(0, 0, 10, 10), tile_size=(10, 10), level=0, mpp=None), ] ) _has_enough_tissue.assert_not_called() @@ -1098,8 +1130,8 @@ def it_can_extract_score_tiles(self, request, tmpdir): score_tiler.extract(slide, binary_mask) assert _extract_tile.call_args_list == [ - call(slide, coords, 0, (10, 10)), - call(slide, coords, 0, (10, 10)), + call(slide, coords, tile_size=(10, 10), level=0, mpp=None), + call(slide, coords, tile_size=(10, 10), level=0, mpp=None), ] _tiles_generator.assert_called_with(score_tiler, slide, binary_mask) assert _tile_filename.call_args_list == [ @@ -1202,8 +1234,8 @@ def it_can_extract_score_tiles_and_save_report(self, request, tmpdir): score_tiler.extract(slide, binary_mask, "report.csv") assert _extract_tile.call_args_list == [ - call(slide, coords, 0, (10, 10)), - call(slide, coords, 0, (10, 10)), + call(slide, coords, tile_size=(10, 10), level=0, mpp=None), + call(slide, coords, tile_size=(10, 10), level=0, mpp=None), ] _tiles_generator.assert_called_with(score_tiler, slide, binary_mask) assert _tile_filename.call_args_list == [ From 5eeb37e6976d8d200fde1f14539f86f920d10c04 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sun, 25 Jul 2021 16:35:45 -0500 Subject: [PATCH 10/47] minor style change for linting --- histolab/slide.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 19cf564c1..7b976337d 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -102,7 +102,7 @@ def base_mpp(self) -> float: if self._use_largeimage: return self._metadata["mm_x"] * (10 ** 3) - elif "openslide.mpp-x" in self.properties: + if "openslide.mpp-x" in self.properties: return float(self.properties["openslide.mpp-x"]) elif "aperio.MPP" in self.properties: return float(self.properties["aperio.MPP"]) @@ -126,8 +126,8 @@ def dimensions(self) -> Tuple[int, int]: """ if self._use_largeimage: return self._metadata["sizeX"], self._metadata["sizeY"] - else: - return self._wsi.dimensions + + return self._wsi.dimensions def extract_tile( self, @@ -437,8 +437,8 @@ def thumbnail(self) -> PIL.Image.Image: thumb_bytes, _ = self._tilesource.getThumbnail(encoding="PNG") thumbnail = self._bytes2pil(thumb_bytes).convert("RGB") return thumbnail - else: - return self._wsi.get_thumbnail(self._thumbnail_size) + + return self._wsi.get_thumbnail(self._thumbnail_size) # ------- implementation helpers ------- From 8075a7500699e0925f735fdef67e9b7f780fffd3 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sun, 25 Jul 2021 16:36:53 -0500 Subject: [PATCH 11/47] minor style change for linting --- histolab/slide.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 7b976337d..06b20018c 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -104,13 +104,16 @@ def base_mpp(self) -> float: if "openslide.mpp-x" in self.properties: return float(self.properties["openslide.mpp-x"]) - elif "aperio.MPP" in self.properties: + + if "aperio.MPP" in self.properties: return float(self.properties["aperio.MPP"]) - elif ( + + if ( "tiff.XResolution" in self.properties and self.properties["tiff.ResolutionUnit"] == "centimeter" ): return 1e4 / float(self.properties["tiff.XResolution"]) + raise NotImplementedError( "Unknown scan magnification! " + LARGEIMAGE_INSTALL_PROMPT ) From a2381c3170f426dde0d36d9a095735d8e34ea265 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sun, 25 Jul 2021 16:37:44 -0500 Subject: [PATCH 12/47] minor style change for linting --- histolab/slide.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 06b20018c..77878de88 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -53,8 +53,8 @@ from PIL.Image import BICUBIC, LANCZOS IMG_EXT = "png" -IMG_UPSAMPLE_MODE = PIL.Image.BILINEAR # TODO: BICUBIC is often better -IMG_DOWNSAMPLE_MODE = PIL.Image.BILINEAR # TODO: LANCZOS is often better +IMG_UPSAMPLE_MODE = PIL.Image.BILINEAR +IMG_DOWNSAMPLE_MODE = PIL.Image.BILINEAR class Slide: From 52480f60cef148617d7fcf5af6fa7a2adf4d1c83 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sun, 25 Jul 2021 18:04:10 -0500 Subject: [PATCH 13/47] catch miscellaneous errors from openslide other than PIL.UnidentifiedImageError --- tests/integration/test_slide.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integration/test_slide.py b/tests/integration/test_slide.py index 8afb8fe6c..1524db5af 100644 --- a/tests/integration/test_slide.py +++ b/tests/integration/test_slide.py @@ -8,6 +8,7 @@ import pytest from histolab.exceptions import LevelError, SlidePropertyError +from histolab.exceptions import HistolabException from histolab.masks import BiggestTissueBoxMask, TissueMask from histolab.slide import Slide from histolab.util import _check_largeimage @@ -186,6 +187,20 @@ def it_raises_openslideerror_with_broken_wsi(self): broken_err += LARGEIMAGE_INSTALL_PROMPT assert str(err.value) == broken_err + def it_raises_miscellaneous_error(self): + slide = Slide(SVS.BROKEN, os.path.join(SVS.BROKEN, "processed")) + + with pytest.raises(HistolabException) as err: + slide._path = None + slide._wsi + + assert isinstance(err.value, HistolabException) + broken_err = ( + "ArgumentError(\"argument 1: : Incorrect type\")" + ) + broken_err += f"\n{LARGEIMAGE_INSTALL_PROMPT}" + assert str(err.value) == broken_err + @pytest.mark.parametrize( "slide_fixture, tissue_mask, binary_mask, expectation", [ From 9927986a042b9309a13d67da7dddbfd89db87133 Mon Sep 17 00:00:00 2001 From: kheffah Date: Wed, 4 Aug 2021 16:48:17 -0500 Subject: [PATCH 14/47] unit tests assume large_image is installed to make sure any future development triggers all tests --- histolab/slide.py | 21 +++++++++++---------- histolab/util.py | 16 ---------------- tests/integration/test_slide.py | 13 +------------ tests/integration/test_tiler.py | 7 ------- tests/unit/test_slide.py | 22 +--------------------- 5 files changed, 13 insertions(+), 66 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 77878de88..bcda83667 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -31,10 +31,9 @@ from .filters.compositions import FiltersComposition from .tile import Tile from .types import CoordinatePair -from .util import _check_largeimage, lazyproperty - -LARGEIMAGE_IS_INSTALLED, LARGEIMAGE_INSTALL_PROMPT = _check_largeimage() +from .util import lazyproperty +try: if TYPE_CHECKING: from .masks import BinaryMask @@ -42,15 +41,17 @@ # If possible, use large_image because it extends openslide to more formats try: import large_image - - USE_LARGEIMAGE = True -except ModuleNotFoundError: - USE_LARGEIMAGE = False - -if USE_LARGEIMAGE: from io import BytesIO - from PIL.Image import BICUBIC, LANCZOS + LARGEIMAGE_IS_INSTALLED = True + LARGEIMAGE_INSTALL_PROMPT = "" + +except (ModuleNotFoundError, ImportError): + LARGEIMAGE_IS_INSTALLED = False + LARGEIMAGE_INSTALL_PROMPT = ( + "It maybe a good idea to install large_image to handle this. " + "See: https://github.com/girder/large_image" + ) IMG_EXT = "png" IMG_UPSAMPLE_MODE = PIL.Image.BILINEAR diff --git a/histolab/util.py b/histolab/util.py index f45231c28..ff834a752 100644 --- a/histolab/util.py +++ b/histolab/util.py @@ -30,22 +30,6 @@ warn = functools.partial(warnings.warn, stacklevel=2) -LARGEIMAGE_INSTALL_PROMPT = ( - "It maybe a good idea to install large_image to handle this. " - "See: https://github.com/girder/large_image" -) - - -def _check_largeimage(): - try: - import large_image - - _ = large_image.__version__ # to avoid unused import linting error - - return True, "" - except (ModuleNotFoundError, ImportError): - return False, LARGEIMAGE_INSTALL_PROMPT - def apply_mask_image(img: PIL.Image.Image, mask: np.ndarray) -> PIL.Image.Image: """Mask image with the provided binary mask. diff --git a/tests/integration/test_slide.py b/tests/integration/test_slide.py index 1524db5af..5098e1a26 100644 --- a/tests/integration/test_slide.py +++ b/tests/integration/test_slide.py @@ -10,15 +10,12 @@ from histolab.exceptions import LevelError, SlidePropertyError from histolab.exceptions import HistolabException from histolab.masks import BiggestTissueBoxMask, TissueMask -from histolab.slide import Slide -from histolab.util import _check_largeimage +from histolab.slide import Slide, LARGEIMAGE_INSTALL_PROMPT from ..fixtures import EXTERNAL_SVS, SVS from ..unitutil import on_ci from ..util import load_expectation, load_python_expression -LARGEIMAGE_IS_INSTALLED, LARGEIMAGE_INSTALL_PROMPT = _check_largeimage() - class Describe_Slide: def it_knows_its_name(self): @@ -46,10 +43,6 @@ def it_knows_its_name(self): ], ) def it_knows_its_base_mpp(self, use_largeimage, fake_props): - - if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): - return - slide = Slide( SVS.CMU_1_SMALL_REGION, os.path.join(SVS.CMU_1_SMALL_REGION, "processed"), @@ -84,10 +77,6 @@ def it_calculate_resampled_nparray_from_small_region_svs_image(self): ], ) def it_knows_the_right_slide_dimension(self, use_largeimage): - - if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): - return - slide = Slide( SVS.CMU_1_SMALL_REGION, os.path.join(SVS.CMU_1_SMALL_REGION, "processed"), diff --git a/tests/integration/test_tiler.py b/tests/integration/test_tiler.py index 38df40760..23fb23051 100644 --- a/tests/integration/test_tiler.py +++ b/tests/integration/test_tiler.py @@ -10,14 +10,11 @@ from histolab.scorer import NucleiScorer from histolab.slide import Slide from histolab.tiler import GridTiler, RandomTiler, ScoreTiler -from histolab.util import _check_largeimage from ..fixtures import EXTERNAL_SVS, SVS, TIFF from ..unitutil import on_ci from ..util import expand_tests_report, load_expectation -LARGEIMAGE_IS_INSTALLED, LARGEIMAGE_INSTALL_PROMPT = _check_largeimage() - class DescribeRandomTiler: @pytest.mark.parametrize( @@ -172,11 +169,7 @@ def it_locates_tiles_on_the_slide( def test_extract_tiles_respecting_the_given_tile_size( self, tmpdir, fixture_slide, tile_size, level, seed, n_tiles, mpp ): - use_largeimage = mpp is not None - if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): - return - processed_path = os.path.join(tmpdir, "processed") slide = Slide(fixture_slide, processed_path, use_largeimage=use_largeimage) random_tiles_extractor = RandomTiler( diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 232f6b1f1..7b407a820 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -12,10 +12,8 @@ from PIL import ImageShow from histolab.exceptions import LevelError, SlidePropertyError -from histolab.slide import Slide, SlideSet from histolab.types import CP -from histolab.util import LARGEIMAGE_INSTALL_PROMPT -from histolab.util import _check_largeimage +from histolab.slide import Slide, SlideSet, LARGEIMAGE_INSTALL_PROMPT from ..unitutil import ( ANY, @@ -32,8 +30,6 @@ property_mock, ) -LARGEIMAGE_IS_INSTALLED, LARGEIMAGE_INSTALL_PROMPT = _check_largeimage() - class Describe_Slide: @pytest.mark.parametrize( @@ -47,10 +43,6 @@ class Describe_Slide: def it_constructs_from_args( self, request, slide_path, processed_path, use_largeimage ): - - if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): - return - _init_ = initializer_mock(request, Slide) slide = Slide(slide_path, processed_path, use_largeimage=use_largeimage) @@ -120,10 +112,6 @@ def it_raises_error_with_unknown_mpp(self, tmpdir): ) def it_has_largeimage_tilesource(self, tmpdir): - - if not LARGEIMAGE_IS_INSTALLED: - return - slide, _ = base_test_slide( tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=True ) @@ -218,10 +206,6 @@ def or_it_raises_an_PIL_exception(self, tmpdir): ], ) def it_can_resample_itself(self, tmpdir, resampled_dims_, use_largeimage): - - if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): - return - slide, _ = base_test_slide( tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=use_largeimage ) @@ -276,10 +260,6 @@ def it_knows_its_scaled_image(self, tmpdir, resampled_dims_): ], ) def it_knows_its_thumbnail(self, tmpdir, resampled_dims_, use_largeimage): - - if use_largeimage and (not LARGEIMAGE_IS_INSTALLED): - return - tmp_path_ = tmpdir.mkdir("myslide") image = PILIMG.RGBA_COLOR_500X500_155_249_240 image.save(os.path.join(tmp_path_, "mywsi.png"), "PNG") From 80456e92d9ecc4a322ee0d1b18a69c48aacfc7bb Mon Sep 17 00:00:00 2001 From: kheffah Date: Wed, 4 Aug 2021 16:54:44 -0500 Subject: [PATCH 15/47] reorder slide and tiler methods alphabetically for consistency --- histolab/slide.py | 34 +++++++++++++++++----------------- histolab/tiler.py | 24 ++++++++++++------------ 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index bcda83667..1389d8e2c 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -446,10 +446,6 @@ def thumbnail(self) -> PIL.Image.Image: # ------- implementation helpers ------- - @lazyproperty - def _metadata(self) -> dict: - return self._tilesource.getMetadata() - @staticmethod def _bytes2pil(bytesim): image_content = BytesIO(bytesim) @@ -476,6 +472,10 @@ def _has_valid_coords(self, coords: CoordinatePair) -> bool: and 0 <= coords.y_br < self.dimensions[1] ) + @lazyproperty + def _metadata(self) -> dict: + return self._tilesource.getMetadata() + def _remap_level(self, level: int) -> int: """Remap negative index for the given level onto a positive one. @@ -605,6 +605,19 @@ def _thumbnail_size(self) -> Tuple[int, int]: ] ) + @lazyproperty + def _tilesource(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: + """Open the slide and returns a large_image tile source object + + Returns + ------- + source : large_image TileSource object + An TileSource object representing a whole-slide image. + """ + assert self._use_largeimage, LARGEIMAGE_INSTALL_PROMPT + source = large_image.getTileSource(self._path) + return source + @lazyproperty def _wsi(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: """Open the slide and returns an openslide object @@ -631,19 +644,6 @@ def _wsi(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: raise HistolabException(msg) return slide - @lazyproperty - def _tilesource(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: - """Open the slide and returns a large_image tile source object - - Returns - ------- - source : large_image TileSource object - An TileSource object representing a whole-slide image. - """ - assert self._use_largeimage, LARGEIMAGE_INSTALL_PROMPT - source = large_image.getTileSource(self._path) - return source - class SlideSet: """Slideset object. It is considered a collection of Slides.""" diff --git a/histolab/tiler.py b/histolab/tiler.py index 4e248f034..01c67f997 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -139,6 +139,18 @@ def locate_tiles( # ------- implementation helpers ------- + def _fix_tile_size_if_mpp(self, slide): + """ + Set tile size either to requested level or if MPP is requested, + set tile size relative to base mpp of slide instead. + """ + if self.mpp is None: + return + sf = self.mpp / slide.base_mpp + self.tile_size = tuple(int(j * sf) for j in self.tile_size) + if hasattr(self, "pixel_overlap"): + self.pixel_overlap = int(self.pixel_overlap * sf) + def _has_valid_tile_size(self, slide: Slide) -> bool: """Return True if the tile size is smaller or equal than the ``slide`` size. @@ -248,18 +260,6 @@ def _validate_level(self, slide: Slide) -> None: f"{len(slide.levels)}" ) - def _fix_tile_size_if_mpp(self, slide): - """ - Set tile size either to requested level or if MPP is requested, - set tile size relative to base mpp of slide instead. - """ - if self.mpp is None: - return - sf = self.mpp / slide.base_mpp - self.tile_size = tuple(int(j * sf) for j in self.tile_size) - if hasattr(self, "pixel_overlap"): - self.pixel_overlap = int(self.pixel_overlap * sf) - def _validate_tile_size(self, slide: Slide) -> None: """Validate the tile size according to the Slide. From 3f50a452477abeb706b0ef1bfdcbe83645ecca39 Mon Sep 17 00:00:00 2001 From: kheffah Date: Thu, 5 Aug 2021 22:23:11 -0500 Subject: [PATCH 16/47] address comments from code review --- histolab/slide.py | 74 +++++++++++++++++++++------------ tests/integration/test_slide.py | 22 +++++----- tests/unit/test_slide.py | 12 +++--- 3 files changed, 66 insertions(+), 42 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 1389d8e2c..74cf1072f 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -44,14 +44,9 @@ from io import BytesIO LARGEIMAGE_IS_INSTALLED = True - LARGEIMAGE_INSTALL_PROMPT = "" except (ModuleNotFoundError, ImportError): LARGEIMAGE_IS_INSTALLED = False - LARGEIMAGE_INSTALL_PROMPT = ( - "It maybe a good idea to install large_image to handle this. " - "See: https://github.com/girder/large_image" - ) IMG_EXT = "png" IMG_UPSAMPLE_MODE = PIL.Image.BILINEAR @@ -85,9 +80,16 @@ def __init__( if processed_path is None: raise TypeError("processed_path cannot be None.") self._processed_path = processed_path - if use_largeimage: - assert LARGEIMAGE_IS_INSTALLED, "large_image module is not found!" + if use_largeimage and not LARGEIMAGE_IS_INSTALLED: + raise ModuleNotFoundError( + "Setting use_large_image to True requires installation " + "of the large_image module. Please visit: " + "https://github.com/girder/large_image for instructions." + ) self._use_largeimage = use_largeimage + self._usage_requires_largeimage = ( + "Please set use_largeimage to True when instantiating Slide." + ) def __repr__(self): return ( @@ -116,7 +118,7 @@ def base_mpp(self) -> float: return 1e4 / float(self.properties["tiff.XResolution"]) raise NotImplementedError( - "Unknown scan magnification! " + LARGEIMAGE_INSTALL_PROMPT + "Unknown scan magnification! " + self._usage_requires_largeimage ) @lazyproperty @@ -158,9 +160,9 @@ def extract_tile( tile : Tile Image containing the selected tile. """ - assert (level is not None) or ( - mpp is not None - ), "either level or mpp must be provided!" + if level is None and mpp is None: + # TODO: this should be covered by a test!! + raise ValueError("either level or mpp must be provided!") if level is not None: level = level if level >= 0 else self._remap_level(level) @@ -178,7 +180,6 @@ def extract_tile( location=(coords.x_ul, coords.y_ul), level=level, size=tile_size ) else: - # only large_image support mpp mm = mpp / 1000 image, _ = self._tilesource.getRegion( region=dict( @@ -195,8 +196,7 @@ def extract_tile( image = image.convert("RGB") # Sometimes when mpp kwarg is used, the image size is off from # what the user expects by a couple of pixels - asis = all(tile_size[i] == j for i, j in enumerate(image.size)) - if not asis: + if not all(tile_size[i] == j for i, j in enumerate(image.size)): image = image.resize( tile_size, IMG_UPSAMPLE_MODE @@ -204,9 +204,7 @@ def extract_tile( else IMG_DOWNSAMPLE_MODE, ) - tile = Tile(image, coords, level) - - return tile + return Tile(image, coords, level) def level_dimensions(self, level: int = 0) -> Tuple[int, int]: """Return the slide dimensions (w,h) at the specified level @@ -448,6 +446,17 @@ def thumbnail(self) -> PIL.Image.Image: @staticmethod def _bytes2pil(bytesim): + """Convert a bytes image to a PIL image object. + + Parameters + ---------- + bytesim : A bytes object. + + Returns + ------- + PIL.Image + A PIL Image object converted from the Bytes input. + """ image_content = BytesIO(bytesim) image_content.seek(0) return PIL.Image.open(image_content) @@ -474,6 +483,15 @@ def _has_valid_coords(self, coords: CoordinatePair) -> bool: @lazyproperty def _metadata(self) -> dict: + """Get metadata about this slide, including magnification. + + Returns + ------- + dict + This function is a wrapper. Please read the documentation for + ``large_image.TileSource.getMetadata()`` for details on the return + keys and data types. + """ return self._tilesource.getMetadata() def _remap_level(self, level: int) -> int: @@ -524,15 +542,15 @@ def _resample(self, scale_factor: int = 32) -> Tuple[PIL.Image.Image, np.array]: _, _, new_w, new_h = self._resampled_dimensions(scale_factor) if self._use_largeimage: - mg = "magnification" - kws = ( - {"scale": {mg: self._metadata[mg] / scale_factor}} - if self._metadata[mg] is not None + magnif = "magnification" + kwargs = ( + {"scale": {magnif: self._metadata[magnif] / scale_factor}} + if self._metadata[magnif] is not None else {} ) wsi_image, _ = self._tilesource.getRegion( format=large_image.tilesource.TILE_FORMAT_PIL, - **kws, + **kwargs, ) else: level = self._wsi.get_best_level_for_downsample(scale_factor) @@ -597,7 +615,9 @@ def _thumbnail_size(self) -> Tuple[int, int]: Tuple[int, int] Thumbnail size """ - assert not self._use_largeimage, "Please use thumbnail.size instead" + if self._use_largeimage: + raise ValueError("Please use thumbnail.size instead") + return tuple( [ int(s / np.power(10, math.ceil(math.log10(s)) - 3)) @@ -614,7 +634,9 @@ def _tilesource(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: source : large_image TileSource object An TileSource object representing a whole-slide image. """ - assert self._use_largeimage, LARGEIMAGE_INSTALL_PROMPT + if not self._use_largeimage: + raise ValueError(self._usage_requires_largeimage) + source = large_image.getTileSource(self._path) return source @@ -632,7 +654,7 @@ def _wsi(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: except PIL.UnidentifiedImageError: raise PIL.UnidentifiedImageError( "Your wsi has something broken inside, a doctor is needed. " - + LARGEIMAGE_INSTALL_PROMPT + + self._usage_requires_largeimage ) except FileNotFoundError: raise FileNotFoundError( @@ -640,7 +662,7 @@ def _wsi(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: ) except Exception as other_error: msg = other_error.__repr__() - msg += f"\n{LARGEIMAGE_INSTALL_PROMPT}" + msg += f"\n{self._usage_requires_largeimage}" raise HistolabException(msg) return slide diff --git a/tests/integration/test_slide.py b/tests/integration/test_slide.py index 5098e1a26..d0b32fecf 100644 --- a/tests/integration/test_slide.py +++ b/tests/integration/test_slide.py @@ -10,7 +10,7 @@ from histolab.exceptions import LevelError, SlidePropertyError from histolab.exceptions import HistolabException from histolab.masks import BiggestTissueBoxMask, TissueMask -from histolab.slide import Slide, LARGEIMAGE_INSTALL_PROMPT +from histolab.slide import Slide from ..fixtures import EXTERNAL_SVS, SVS from ..unitutil import on_ci @@ -28,7 +28,7 @@ def it_knows_its_name(self): assert name == ntpath.basename(SVS.CMU_1_SMALL_REGION).split(".")[0] @pytest.mark.parametrize( - "use_largeimage, fake_props", + "use_largeimage, slide_props", [ (True, None), (False, None), @@ -42,16 +42,16 @@ def it_knows_its_name(self): ), ], ) - def it_knows_its_base_mpp(self, use_largeimage, fake_props): + def it_knows_its_base_mpp(self, use_largeimage, slide_props): slide = Slide( SVS.CMU_1_SMALL_REGION, os.path.join(SVS.CMU_1_SMALL_REGION, "processed"), use_largeimage=use_largeimage, ) - if fake_props: + if slide_props: del slide.properties["openslide.mpp-x"] del slide.properties["aperio.MPP"] - slide.properties.update(fake_props) + slide.properties.update(slide_props) mpp = slide.base_mpp @@ -172,9 +172,10 @@ def it_raises_openslideerror_with_broken_wsi(self): slide._wsi assert isinstance(err.value, PIL.UnidentifiedImageError) - broken_err = "Your wsi has something broken inside, a doctor is needed. " - broken_err += LARGEIMAGE_INSTALL_PROMPT - assert str(err.value) == broken_err + assert str(err.value) == ( + "Your wsi has something broken inside, a doctor is needed. " + "Please set use_largeimage to True when instantiating Slide." + ) def it_raises_miscellaneous_error(self): slide = Slide(SVS.BROKEN, os.path.join(SVS.BROKEN, "processed")) @@ -184,11 +185,10 @@ def it_raises_miscellaneous_error(self): slide._wsi assert isinstance(err.value, HistolabException) - broken_err = ( + assert str(err.value) == ( "ArgumentError(\"argument 1: : Incorrect type\")" + "\nPlease set use_largeimage to True when instantiating Slide." ) - broken_err += f"\n{LARGEIMAGE_INSTALL_PROMPT}" - assert str(err.value) == broken_err @pytest.mark.parametrize( "slide_fixture, tissue_mask, binary_mask, expectation", diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 7b407a820..f6c1146fc 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -13,7 +13,7 @@ from histolab.exceptions import LevelError, SlidePropertyError from histolab.types import CP -from histolab.slide import Slide, SlideSet, LARGEIMAGE_INSTALL_PROMPT +from histolab.slide import Slide, SlideSet from ..unitutil import ( ANY, @@ -108,7 +108,8 @@ def it_raises_error_with_unknown_mpp(self, tmpdir): assert isinstance(err.value, NotImplementedError) assert str(err.value) == ( - "Unknown scan magnification! " + LARGEIMAGE_INSTALL_PROMPT + "Unknown scan magnification! " + "Please set use_largeimage to True when instantiating Slide." ) def it_has_largeimage_tilesource(self, tmpdir): @@ -194,9 +195,10 @@ def or_it_raises_an_PIL_exception(self, tmpdir): slide._wsi assert isinstance(err.value, PIL.UnidentifiedImageError) - broken_err = "Your wsi has something broken inside, a doctor is needed" - broken_err += ". " + LARGEIMAGE_INSTALL_PROMPT - assert str(err.value) == broken_err + assert str(err.value) == ( + "Your wsi has something broken inside, a doctor is needed. " + "Please set use_largeimage to True when instantiating Slide." + ) @pytest.mark.parametrize( "use_largeimage", From a88f4bce1171bcf383d7c9056dab069dca4886d6 Mon Sep 17 00:00:00 2001 From: kheffah Date: Thu, 5 Aug 2021 23:40:05 -0500 Subject: [PATCH 17/47] additional unit tests for slide --- histolab/slide.py | 5 ++--- tests/unit/test_slide.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 74cf1072f..260074b71 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -45,7 +45,7 @@ LARGEIMAGE_IS_INSTALLED = True -except (ModuleNotFoundError, ImportError): +except (ModuleNotFoundError, ImportError): # pragma: no cover LARGEIMAGE_IS_INSTALLED = False IMG_EXT = "png" @@ -80,7 +80,7 @@ def __init__( if processed_path is None: raise TypeError("processed_path cannot be None.") self._processed_path = processed_path - if use_largeimage and not LARGEIMAGE_IS_INSTALLED: + if use_largeimage and not LARGEIMAGE_IS_INSTALLED: # pragma: no cover raise ModuleNotFoundError( "Setting use_large_image to True requires installation " "of the large_image module. Please visit: " @@ -161,7 +161,6 @@ def extract_tile( Image containing the selected tile. """ if level is None and mpp is None: - # TODO: this should be covered by a test!! raise ValueError("either level or mpp must be provided!") if level is not None: diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index f6c1146fc..46a1537ad 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -119,6 +119,17 @@ def it_has_largeimage_tilesource(self, tmpdir): assert slide._tilesource.name == "pilfile" + def it_raises_error_if_tilesource_and_not_use_largeimage(self): + slide = Slide("/a/b/foo", "processed", use_largeimage=True) + + with pytest.raises(ValueError) as err: + _ = slide._tilesource + + assert isinstance(err.value, ValueError) + assert str(err.value) == ( + "Please set use_largeimage to True when instantiating Slide." + ) + def it_knows_its_dimensions(self, tmpdir): slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240) @@ -126,6 +137,16 @@ def it_knows_its_dimensions(self, tmpdir): assert slide_dims == (500, 500) + @pytest.mark.parametrize("level, mpp", [(0, None), (None, 0.5)]) + def it_raises_error_if_bad_args_for_extract_tile(self, level, mpp): + slide = Slide("/a/b/foo", "processed") + + with pytest.raises(ValueError) as err: + slide.extract_tile(CP(0, 10, 0, 10), (10, 10), level=level, mpp=mpp) + + assert isinstance(err.value, ValueError) + assert str(err.value) == "either level or mpp must be provided!" + def it_knows_its_resampled_dimensions(self, dimensions_): """This test prove that given the dimensions (mock object here), it does the correct maths operations: @@ -170,6 +191,15 @@ def it_knows_its_thumbnail_size(self, tmpdir): assert thumb_size == (500, 500) + def it_raises_error_if_thumbnail_size_and_use_largeimage(self): + slide = Slide("/a/b/foo", "processed", use_largeimage=True) + + with pytest.raises(ValueError) as err: + _ = slide._thumbnail_size + + assert isinstance(err.value, ValueError) + assert str(err.value) == "Please use thumbnail.size instead" + def it_creates_a_correct_slide_object(self, tmpdir): slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_50X50_155_0_0) From b6f73a4bc4877116f200d994f7d790c0b4acb70f Mon Sep 17 00:00:00 2001 From: kheffah Date: Fri, 6 Aug 2021 00:03:17 -0500 Subject: [PATCH 18/47] fix additional unit tests for slide --- tests/unit/test_slide.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 46a1537ad..7069a41e0 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -104,7 +104,7 @@ def it_raises_error_with_unknown_mpp(self, tmpdir): slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240) with pytest.raises(NotImplementedError) as err: - _ = slide.base_mpp + slide.base_mpp assert isinstance(err.value, NotImplementedError) assert str(err.value) == ( @@ -120,10 +120,10 @@ def it_has_largeimage_tilesource(self, tmpdir): assert slide._tilesource.name == "pilfile" def it_raises_error_if_tilesource_and_not_use_largeimage(self): - slide = Slide("/a/b/foo", "processed", use_largeimage=True) + slide = Slide("/a/b/foo", "processed") with pytest.raises(ValueError) as err: - _ = slide._tilesource + slide._tilesource assert isinstance(err.value, ValueError) assert str(err.value) == ( @@ -137,12 +137,15 @@ def it_knows_its_dimensions(self, tmpdir): assert slide_dims == (500, 500) - @pytest.mark.parametrize("level, mpp", [(0, None), (None, 0.5)]) - def it_raises_error_if_bad_args_for_extract_tile(self, level, mpp): - slide = Slide("/a/b/foo", "processed") + def it_raises_error_if_bad_args_for_extract_tile(self, tmpdir): + slide, _ = base_test_slide( + tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=True + ) with pytest.raises(ValueError) as err: - slide.extract_tile(CP(0, 10, 0, 10), (10, 10), level=level, mpp=mpp) + slide.extract_tile( + CP(0, 10, 0, 10), (10, 10), level=None, mpp=None + ) assert isinstance(err.value, ValueError) assert str(err.value) == "either level or mpp must be provided!" @@ -195,7 +198,7 @@ def it_raises_error_if_thumbnail_size_and_use_largeimage(self): slide = Slide("/a/b/foo", "processed", use_largeimage=True) with pytest.raises(ValueError) as err: - _ = slide._thumbnail_size + slide._thumbnail_size assert isinstance(err.value, ValueError) assert str(err.value) == "Please use thumbnail.size instead" From 1f325f9e6c4df94ac1d48245758a90f2d709193c Mon Sep 17 00:00:00 2001 From: kheffah Date: Fri, 6 Aug 2021 09:48:24 -0500 Subject: [PATCH 19/47] better exception messages when large_image is needed for slide module --- histolab/slide.py | 31 ++++++++++++++++++------------- tests/integration/test_slide.py | 9 ++++++--- tests/unit/test_slide.py | 26 +++++++++++++++----------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 260074b71..2df5606f4 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -87,9 +87,6 @@ def __init__( "https://github.com/girder/large_image for instructions." ) self._use_largeimage = use_largeimage - self._usage_requires_largeimage = ( - "Please set use_largeimage to True when instantiating Slide." - ) def __repr__(self): return ( @@ -118,7 +115,9 @@ def base_mpp(self) -> float: return 1e4 / float(self.properties["tiff.XResolution"]) raise NotImplementedError( - "Unknown scan magnification! " + self._usage_requires_largeimage + "Unknown scan magnification! This slide format may be best " + "handled using the large_image module. Consider setting " + "use_largeimage to True when instantiating this Slide." ) @lazyproperty @@ -615,7 +614,10 @@ def _thumbnail_size(self) -> Tuple[int, int]: Thumbnail size """ if self._use_largeimage: - raise ValueError("Please use thumbnail.size instead") + raise NotImplementedError( + "When use_largeimage is set to True, the thumbnail is fetched " + "by the large_image module. Please use thumbnail.size instead." + ) return tuple( [ @@ -634,7 +636,10 @@ def _tilesource(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: An TileSource object representing a whole-slide image. """ if not self._use_largeimage: - raise ValueError(self._usage_requires_largeimage) + raise ValueError( + "This property uses the large_image module. Please set " + "use_largeimage to True when instantiating this Slide." + ) source = large_image.getTileSource(self._path) return source @@ -648,21 +653,21 @@ def _wsi(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: slide : OpenSlide object An OpenSlide object representing a whole-slide image. """ + bad_format_error = ( + "This slide may be corrupt or have a non-standard format not " + "handled by the openslide and PIL libraries. Consider setting " + "use_largeimage to True when instantiating this Slide." + ) try: slide = openslide.open_slide(self._path) except PIL.UnidentifiedImageError: - raise PIL.UnidentifiedImageError( - "Your wsi has something broken inside, a doctor is needed. " - + self._usage_requires_largeimage - ) + raise PIL.UnidentifiedImageError(bad_format_error) except FileNotFoundError: raise FileNotFoundError( f"The wsi path resource doesn't exist: {self._path}" ) except Exception as other_error: - msg = other_error.__repr__() - msg += f"\n{self._usage_requires_largeimage}" - raise HistolabException(msg) + raise HistolabException(other_error.__repr__() + f". {bad_format_error}") return slide diff --git a/tests/integration/test_slide.py b/tests/integration/test_slide.py index d0b32fecf..703a3f5fc 100644 --- a/tests/integration/test_slide.py +++ b/tests/integration/test_slide.py @@ -173,8 +173,9 @@ def it_raises_openslideerror_with_broken_wsi(self): assert isinstance(err.value, PIL.UnidentifiedImageError) assert str(err.value) == ( - "Your wsi has something broken inside, a doctor is needed. " - "Please set use_largeimage to True when instantiating Slide." + "This slide may be corrupt or have a non-standard format not " + "handled by the openslide and PIL libraries. Consider setting " + "use_largeimage to True when instantiating this Slide." ) def it_raises_miscellaneous_error(self): @@ -187,7 +188,9 @@ def it_raises_miscellaneous_error(self): assert isinstance(err.value, HistolabException) assert str(err.value) == ( "ArgumentError(\"argument 1: : Incorrect type\")" - "\nPlease set use_largeimage to True when instantiating Slide." + ". This slide may be corrupt or have a non-standard format not " + "handled by the openslide and PIL libraries. Consider setting " + "use_largeimage to True when instantiating this Slide." ) @pytest.mark.parametrize( diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 7069a41e0..4ac635ec5 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -108,8 +108,9 @@ def it_raises_error_with_unknown_mpp(self, tmpdir): assert isinstance(err.value, NotImplementedError) assert str(err.value) == ( - "Unknown scan magnification! " - "Please set use_largeimage to True when instantiating Slide." + "Unknown scan magnification! This slide format may be best " + "handled using the large_image module. Consider setting " + "use_largeimage to True when instantiating this Slide." ) def it_has_largeimage_tilesource(self, tmpdir): @@ -127,7 +128,8 @@ def it_raises_error_if_tilesource_and_not_use_largeimage(self): assert isinstance(err.value, ValueError) assert str(err.value) == ( - "Please set use_largeimage to True when instantiating Slide." + "This property uses the large_image module. Please set " + "use_largeimage to True when instantiating this Slide." ) def it_knows_its_dimensions(self, tmpdir): @@ -143,9 +145,7 @@ def it_raises_error_if_bad_args_for_extract_tile(self, tmpdir): ) with pytest.raises(ValueError) as err: - slide.extract_tile( - CP(0, 10, 0, 10), (10, 10), level=None, mpp=None - ) + slide.extract_tile(CP(0, 10, 0, 10), (10, 10), level=None, mpp=None) assert isinstance(err.value, ValueError) assert str(err.value) == "either level or mpp must be provided!" @@ -197,11 +197,14 @@ def it_knows_its_thumbnail_size(self, tmpdir): def it_raises_error_if_thumbnail_size_and_use_largeimage(self): slide = Slide("/a/b/foo", "processed", use_largeimage=True) - with pytest.raises(ValueError) as err: + with pytest.raises(NotImplementedError) as err: slide._thumbnail_size - assert isinstance(err.value, ValueError) - assert str(err.value) == "Please use thumbnail.size instead" + assert isinstance(err.value, NotImplementedError) + assert str(err.value) == ( + "When use_largeimage is set to True, the thumbnail is fetched " + "by the large_image module. Please use thumbnail.size instead." + ) def it_creates_a_correct_slide_object(self, tmpdir): slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_50X50_155_0_0) @@ -229,8 +232,9 @@ def or_it_raises_an_PIL_exception(self, tmpdir): assert isinstance(err.value, PIL.UnidentifiedImageError) assert str(err.value) == ( - "Your wsi has something broken inside, a doctor is needed. " - "Please set use_largeimage to True when instantiating Slide." + "This slide may be corrupt or have a non-standard format not " + "handled by the openslide and PIL libraries. Consider setting " + "use_largeimage to True when instantiating this Slide." ) @pytest.mark.parametrize( From 04472c8b3e02f7202f474f154f005ec4390b02b9 Mon Sep 17 00:00:00 2001 From: kheffah Date: Fri, 6 Aug 2021 13:12:05 -0500 Subject: [PATCH 20/47] use getters and setters for updating tile size depening on slide if mpp --- histolab/tiler.py | 53 +++++++++++++++++++++++++++++++--------- tests/unit/test_tiler.py | 12 +++++---- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/histolab/tiler.py b/histolab/tiler.py index 01c67f997..cc85eab03 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -139,17 +139,27 @@ def locate_tiles( # ------- implementation helpers ------- - def _fix_tile_size_if_mpp(self, slide): - """ - Set tile size either to requested level or if MPP is requested, - set tile size relative to base mpp of slide instead. + def _get_proper_tile_size(self, slide) -> Tuple[Tuple[int, int], float]: + """Get the proper tile size for level or mpp requested. + + Parameters + ---------- + slide: Slide + The slide we want to tile. + + Returns + ------- + Tuple[int, int] + Proper tile size at desired level or MPP resolution. + float + Scale factor that maps the original self.tile_size to proper one. + """ if self.mpp is None: - return + return self.tile_size, 1.0 + sf = self.mpp / slide.base_mpp - self.tile_size = tuple(int(j * sf) for j in self.tile_size) - if hasattr(self, "pixel_overlap"): - self.pixel_overlap = int(self.pixel_overlap * sf) + return tuple(int(j * sf) for j in self.tile_size), sf def _has_valid_tile_size(self, slide: Slide) -> bool: """Return True if the tile size is smaller or equal than the ``slide`` size. @@ -359,7 +369,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) - self._fix_tile_size_if_mpp(slide) + self._set_proper_tile_size_and_overlap(slide) self._validate_tile_size(slide) grid_tiles = self._tiles_generator(slide, extraction_mask) @@ -594,6 +604,17 @@ def _n_tiles_row(self, bbox_coordinates: CoordinatePair) -> int: self.tile_size[0] - self.pixel_overlap ) + def _set_proper_tile_size_and_overlap(self, slide) -> None: + """Set the proper tile size and overlap for level or mpp requested. + + Parameters + ---------- + slide: Slide + The slide we want to tile. + """ + self.tile_size, sf = self._get_proper_tile_size(slide) + self.pixel_overlap = int(sf * self.pixel_overlap) + class RandomTiler(Tiler): """Extractor of random tiles from a Slide, at the given level, with the given size. @@ -681,7 +702,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) - self._fix_tile_size_if_mpp(slide) + self._set_proper_tile_size(slide) self._validate_tile_size(slide) random_tiles = self._tiles_generator(slide, extraction_mask) @@ -761,6 +782,16 @@ def _random_tile_coordinates( return tile_wsi_coords + def _set_proper_tile_size(self, slide) -> None: + """Set the proper tile size for level or mpp requested. + + Parameters + ---------- + slide: Slide + The slide we want to tile. + """ + self.tile_size, _ = self._get_proper_tile_size(slide) + def _tiles_generator( self, slide: Slide, extraction_mask: BinaryMask = BiggestTissueBoxMask() ) -> Tuple[Tile, CoordinatePair]: @@ -911,7 +942,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) - self._fix_tile_size_if_mpp(slide) + self._set_proper_tile_size_and_overlap(slide) self._validate_tile_size(slide) highest_score_tiles, highest_scaled_score_tiles = self._tiles_generator( diff --git a/tests/unit/test_tiler.py b/tests/unit/test_tiler.py index 5105fd7bd..6e47ba73e 100644 --- a/tests/unit/test_tiler.py +++ b/tests/unit/test_tiler.py @@ -62,7 +62,7 @@ def mpp_supercedes_level(self, level, mpp, expected_level, expected_mpp): def it_can_fix_tile_size_if_mpp(self, mpp, fixed_tile_size): fake_slide = namedtuple("fake_slide", ["base_mpp"]) tiler = RandomTiler((512, 512), 0, True, 80, 0, "", ".png", mpp=mpp) - tiler._fix_tile_size_if_mpp(fake_slide(0.25)) + tiler._set_proper_tile_size(fake_slide(0.25)) assert tiler.tile_size == fixed_tile_size @@ -458,14 +458,16 @@ def mpp_supercedes_level(self, level, mpp, expected_level, expected_mpp): assert tiler.mpp == expected_mpp @pytest.mark.parametrize( - "mpp, fixed_tile_size", ((None, (512, 512)), (0.5, (1024, 1024))) + "mpp, fixed_tile_size, fixed_overlap", + ((None, (512, 512), 32), (0.5, (1024, 1024), 64)), ) - def it_can_fix_tile_size_if_mpp(self, mpp, fixed_tile_size): + def it_can_fix_tile_size_if_mpp(self, mpp, fixed_tile_size, fixed_overlap): fake_slide = namedtuple("fake_slide", ["base_mpp"]) - tiler = GridTiler((512, 512), 0, True, 80, 0, "", ".png", mpp=mpp) - tiler._fix_tile_size_if_mpp(fake_slide(0.25)) + tiler = GridTiler((512, 512), pixel_overlap=32, mpp=mpp) + tiler._set_proper_tile_size_and_overlap(fake_slide(0.25)) assert tiler.tile_size == fixed_tile_size + assert tiler.pixel_overlap == fixed_overlap def but_it_has_wrong_tile_size_value(self): with pytest.raises(ValueError) as err: From 10f783c4eba24bb64b757cede074456290e5004e Mon Sep 17 00:00:00 2001 From: kheffah Date: Fri, 6 Aug 2021 13:39:07 -0500 Subject: [PATCH 21/47] linting and better naming --- histolab/tiler.py | 2 +- tests/unit/test_tiler.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/histolab/tiler.py b/histolab/tiler.py index cc85eab03..43f3438c1 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -139,7 +139,7 @@ def locate_tiles( # ------- implementation helpers ------- - def _get_proper_tile_size(self, slide) -> Tuple[Tuple[int, int], float]: + def _get_proper_tile_size(self, slide: Slide) -> Tuple[Tuple[int, int], float]: """Get the proper tile size for level or mpp requested. Parameters diff --git a/tests/unit/test_tiler.py b/tests/unit/test_tiler.py index 6e47ba73e..350227e53 100644 --- a/tests/unit/test_tiler.py +++ b/tests/unit/test_tiler.py @@ -461,7 +461,9 @@ def mpp_supercedes_level(self, level, mpp, expected_level, expected_mpp): "mpp, fixed_tile_size, fixed_overlap", ((None, (512, 512), 32), (0.5, (1024, 1024), 64)), ) - def it_can_fix_tile_size_if_mpp(self, mpp, fixed_tile_size, fixed_overlap): + def it_can_fix_tile_size_and_overlap_if_mpp( + self, mpp, fixed_tile_size, fixed_overlap + ): fake_slide = namedtuple("fake_slide", ["base_mpp"]) tiler = GridTiler((512, 512), pixel_overlap=32, mpp=mpp) tiler._set_proper_tile_size_and_overlap(fake_slide(0.25)) From 0a3d1581aa2eec3e3dba4a8152d3cc01e8859f2b Mon Sep 17 00:00:00 2001 From: kheffah Date: Sat, 7 Aug 2021 13:09:46 -0500 Subject: [PATCH 22/47] reorder exceptions alphabetically --- histolab/exceptions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/histolab/exceptions.py b/histolab/exceptions.py index b876cf557..394885f4b 100644 --- a/histolab/exceptions.py +++ b/histolab/exceptions.py @@ -33,6 +33,10 @@ def __str__(self): return "" +class FilterCompositionError(HistolabException): + """Raised when a filter composition for the class is not available""" + + class LevelError(HistolabException): """Raised when a requested level is not available""" @@ -41,9 +45,5 @@ class SlidePropertyError(HistolabException): """Raised when a requested slide property is not available""" -class FilterCompositionError(HistolabException): - """Raised when a filter composition for the class is not available""" - - class TileSizeError(HistolabException): """Raised when the tile size is larger than the slide size""" From 7be2c015ba6fd5eab0c2abf8f06ff574ce144a95 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sat, 7 Aug 2021 13:25:18 -0500 Subject: [PATCH 23/47] instead of using exception types that dont really fit, create a MayneedLargeImage exception to use with slide whenever large_image may be needed. This is also a bug fix, because it prevents ignoring the exception when the catch-call except ValueError is invoked in like 503 in GridTiler --- histolab/exceptions.py | 4 ++++ histolab/slide.py | 9 ++++----- tests/unit/test_slide.py | 14 +++++++------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/histolab/exceptions.py b/histolab/exceptions.py index 394885f4b..bc2c03eec 100644 --- a/histolab/exceptions.py +++ b/histolab/exceptions.py @@ -41,6 +41,10 @@ class LevelError(HistolabException): """Raised when a requested level is not available""" +class MayNeedLargeImageError(HistolabException): + """Raised when a method likely requires usage of large_image module""" + + class SlidePropertyError(HistolabException): """Raised when a requested slide property is not available""" diff --git a/histolab/slide.py b/histolab/slide.py index 2df5606f4..2615e50fe 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -27,13 +27,12 @@ from skimage.measure import find_contours from .exceptions import LevelError, SlidePropertyError -from .exceptions import HistolabException +from .exceptions import HistolabException, MayNeedLargeImageError from .filters.compositions import FiltersComposition from .tile import Tile from .types import CoordinatePair from .util import lazyproperty -try: if TYPE_CHECKING: from .masks import BinaryMask @@ -114,7 +113,7 @@ def base_mpp(self) -> float: ): return 1e4 / float(self.properties["tiff.XResolution"]) - raise NotImplementedError( + raise MayNeedLargeImageError( "Unknown scan magnification! This slide format may be best " "handled using the large_image module. Consider setting " "use_largeimage to True when instantiating this Slide." @@ -614,7 +613,7 @@ def _thumbnail_size(self) -> Tuple[int, int]: Thumbnail size """ if self._use_largeimage: - raise NotImplementedError( + raise MayNeedLargeImageError( "When use_largeimage is set to True, the thumbnail is fetched " "by the large_image module. Please use thumbnail.size instead." ) @@ -636,7 +635,7 @@ def _tilesource(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: An TileSource object representing a whole-slide image. """ if not self._use_largeimage: - raise ValueError( + raise MayNeedLargeImageError( "This property uses the large_image module. Please set " "use_largeimage to True when instantiating this Slide." ) diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 4ac635ec5..217bb3573 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -11,8 +11,8 @@ import pytest from PIL import ImageShow -from histolab.exceptions import LevelError, SlidePropertyError from histolab.types import CP +from histolab.exceptions import LevelError, MayNeedLargeImageError, SlidePropertyError from histolab.slide import Slide, SlideSet from ..unitutil import ( @@ -103,10 +103,10 @@ def it_knows_its_name(self, slide_path, expected_value): def it_raises_error_with_unknown_mpp(self, tmpdir): slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240) - with pytest.raises(NotImplementedError) as err: + with pytest.raises(MayNeedLargeImageError) as err: slide.base_mpp - assert isinstance(err.value, NotImplementedError) + assert isinstance(err.value, MayNeedLargeImageError) assert str(err.value) == ( "Unknown scan magnification! This slide format may be best " "handled using the large_image module. Consider setting " @@ -123,10 +123,10 @@ def it_has_largeimage_tilesource(self, tmpdir): def it_raises_error_if_tilesource_and_not_use_largeimage(self): slide = Slide("/a/b/foo", "processed") - with pytest.raises(ValueError) as err: + with pytest.raises(MayNeedLargeImageError) as err: slide._tilesource - assert isinstance(err.value, ValueError) + assert isinstance(err.value, MayNeedLargeImageError) assert str(err.value) == ( "This property uses the large_image module. Please set " "use_largeimage to True when instantiating this Slide." @@ -197,10 +197,10 @@ def it_knows_its_thumbnail_size(self, tmpdir): def it_raises_error_if_thumbnail_size_and_use_largeimage(self): slide = Slide("/a/b/foo", "processed", use_largeimage=True) - with pytest.raises(NotImplementedError) as err: + with pytest.raises(MayNeedLargeImageError) as err: slide._thumbnail_size - assert isinstance(err.value, NotImplementedError) + assert isinstance(err.value, MayNeedLargeImageError) assert str(err.value) == ( "When use_largeimage is set to True, the thumbnail is fetched " "by the large_image module. Please use thumbnail.size instead." From e71efbceb4dc8854b75ade95d6d090b20ce502e9 Mon Sep 17 00:00:00 2001 From: kheffah Date: Sat, 7 Aug 2021 13:44:49 -0500 Subject: [PATCH 24/47] bug fix: instead of catching all ValueErrors in _tiles_generator when tries slide.extract_tile, only catch the specific TileSizeOrCoordinatesError class. --- histolab/exceptions.py | 4 ++-- histolab/slide.py | 14 ++++++++++---- histolab/tiler.py | 8 ++++---- tests/unit/test_tiler.py | 14 +++++++------- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/histolab/exceptions.py b/histolab/exceptions.py index bc2c03eec..1bcbc873a 100644 --- a/histolab/exceptions.py +++ b/histolab/exceptions.py @@ -49,5 +49,5 @@ class SlidePropertyError(HistolabException): """Raised when a requested slide property is not available""" -class TileSizeError(HistolabException): - """Raised when the tile size is larger than the slide size""" +class TileSizeOrCoordinatesError(HistolabException): + """Raised when the tile size or coordinates are incorrect relative to slide.""" diff --git a/histolab/slide.py b/histolab/slide.py index 2615e50fe..5bb7361a8 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -26,8 +26,13 @@ import PIL from skimage.measure import find_contours -from .exceptions import LevelError, SlidePropertyError -from .exceptions import HistolabException, MayNeedLargeImageError +from .exceptions import ( + HistolabException, + LevelError, + MayNeedLargeImageError, + SlidePropertyError, + TileSizeOrCoordinatesError, +) from .filters.compositions import FiltersComposition from .tile import Tile from .types import CoordinatePair @@ -39,9 +44,10 @@ # If possible, use large_image because it extends openslide to more formats try: - import large_image from io import BytesIO + import large_image + LARGEIMAGE_IS_INSTALLED = True except (ModuleNotFoundError, ImportError): # pragma: no cover @@ -167,7 +173,7 @@ def extract_tile( if not self._has_valid_coords(coords): # OpenSlide doesn't complain if the coordinates for extraction are wrong, # but it returns an odd image. - raise ValueError( + raise TileSizeOrCoordinatesError( f"Extraction Coordinates {coords} not valid for slide with dimensions " f"{self.dimensions}" ) diff --git a/histolab/tiler.py b/histolab/tiler.py index 43f3438c1..9bcea21c1 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -26,7 +26,7 @@ import numpy as np import PIL -from .exceptions import LevelError, TileSizeError +from .exceptions import LevelError, TileSizeOrCoordinatesError from .masks import BiggestTissueBoxMask, BinaryMask from .scorer import Scorer from .slide import Slide @@ -284,7 +284,7 @@ def _validate_tile_size(self, slide: Slide) -> None: If the tile size is larger than the slide size """ if not self._has_valid_tile_size(slide): - raise TileSizeError( + raise TileSizeOrCoordinatesError( f"Tile size {self.tile_size} is larger than slide size " f"{slide.level_dimensions(self.level)} at level {self.level}" ) @@ -564,7 +564,7 @@ def _tiles_generator( mpp=self.mpp, level=self.level if self.mpp is None else None, ) - except ValueError: + except TileSizeOrCoordinatesError: continue if not self.check_tissue or tile.has_enough_tissue(self.tissue_percent): @@ -828,7 +828,7 @@ def _tiles_generator( mpp=self.mpp, level=self.level if self.mpp is None else None, ) - except ValueError: + except TileSizeOrCoordinatesError: iteration -= 1 continue diff --git a/tests/unit/test_tiler.py b/tests/unit/test_tiler.py index 350227e53..662fd48a8 100644 --- a/tests/unit/test_tiler.py +++ b/tests/unit/test_tiler.py @@ -8,7 +8,7 @@ import numpy as np import pytest -from histolab.exceptions import LevelError, TileSizeError +from histolab.exceptions import LevelError, TileSizeOrCoordinatesError from histolab.masks import BiggestTissueBoxMask from histolab.scorer import RandomScorer from histolab.slide import Slide @@ -416,10 +416,10 @@ def but_it_raises_tilesizeerror_if_tilesize_larger_than_slidesize( random_tiler = RandomTiler((50, 52), n_tiles=10, level=0) binary_mask = BiggestTissueBoxMask() - with pytest.raises(TileSizeError) as err: + with pytest.raises(TileSizeOrCoordinatesError) as err: random_tiler.extract(slide, binary_mask) - assert isinstance(err.value, TileSizeError) + assert isinstance(err.value, TileSizeOrCoordinatesError) assert ( str(err.value) == f"Tile size (50, 52) is larger than slide size {size} at level 0" @@ -798,10 +798,10 @@ def but_it_raises_tilesizeerror_if_tilesize_larger_than_slidesize( grid_tiler = GridTiler((50, 52), level=0) binary_mask = BiggestTissueBoxMask() - with pytest.raises(TileSizeError) as err: + with pytest.raises(TileSizeOrCoordinatesError) as err: grid_tiler.extract(slide, binary_mask) - assert isinstance(err.value, TileSizeError) + assert isinstance(err.value, TileSizeOrCoordinatesError) assert ( str(err.value) == f"Tile size (50, 52) is larger than slide size {size} at level 0" @@ -1170,10 +1170,10 @@ def but_it_raises_tilesizeerror_if_tilesize_larger_than_slidesize( score_tiler = ScoreTiler(None, (50, 52), 2, 0) binary_mask = BiggestTissueBoxMask() - with pytest.raises(TileSizeError) as err: + with pytest.raises(TileSizeOrCoordinatesError) as err: score_tiler.extract(slide, binary_mask) - assert isinstance(err.value, TileSizeError) + assert isinstance(err.value, TileSizeOrCoordinatesError) assert ( str(err.value) == f"Tile size (50, 52) is larger than slide size {size} at level 0" From 077e22aa54467845d03e3783e763231f3008d882 Mon Sep 17 00:00:00 2001 From: kheffah Date: Mon, 9 Aug 2021 08:58:21 -0500 Subject: [PATCH 25/47] add docstring for base_mpp --- histolab/slide.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/histolab/slide.py b/histolab/slide.py index 5bb7361a8..619585c76 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -103,7 +103,14 @@ def __repr__(self): @lazyproperty def base_mpp(self) -> float: - """Get microns-per-pixel resolution at scan magnification.""" + """Get microns-per-pixel resolution at scan magnification. + + Returns + ------- + float + Microns-per-pixel resolution at scan (base) magnification. + + """ if self._use_largeimage: return self._metadata["mm_x"] * (10 ** 3) From 239eb30d7e0b774bba7aba5cd3f2c6be8f5af39a Mon Sep 17 00:00:00 2001 From: kheffah Date: Mon, 9 Aug 2021 09:00:59 -0500 Subject: [PATCH 26/47] get rid of extra magnif variable in _resample --- histolab/slide.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 619585c76..0550a9879 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -552,10 +552,13 @@ def _resample(self, scale_factor: int = 32) -> Tuple[PIL.Image.Image, np.array]: _, _, new_w, new_h = self._resampled_dimensions(scale_factor) if self._use_largeimage: - magnif = "magnification" kwargs = ( - {"scale": {magnif: self._metadata[magnif] / scale_factor}} - if self._metadata[magnif] is not None + { + "scale": { + "magnification": self._metadata["magnification"] / scale_factor + } + } + if self._metadata["magnification"] is not None else {} ) wsi_image, _ = self._tilesource.getRegion( From dcae08ab2274cb457a728fe0b20654c4f0741b3a Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Mon, 9 Aug 2021 17:30:19 +0200 Subject: [PATCH 27/47] fix extract signature to be compliant with the abstractmethod --- histolab/tiler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/histolab/tiler.py b/histolab/tiler.py index 9bcea21c1..0c2fae77b 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -64,9 +64,9 @@ class Tiler(Protocol): def extract( self, slide: Slide, - log_level: str, extraction_mask: BinaryMask = BiggestTissueBoxMask(), - ): + log_level: str = "INFO", + ) -> None: pass # pragma: no cover def locate_tiles( From edfd0671c7318a17b0f19a8a3578207a7164b510 Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Mon, 9 Aug 2021 17:30:33 +0200 Subject: [PATCH 28/47] add large-image in CI --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index cd041c01f..bec3ab8b5 100644 --- a/setup.py +++ b/setup.py @@ -36,8 +36,12 @@ def parse_requirements(filename): install_requires = parse_requirements("requirements.txt") test_requires = [ + "large-image", + "large-image-source-openslide", + "large-image-source-pil", "pytest", "pytest-xdist", + "pooch", "coverage", "pytest-cov", "pytest-benchmark", From bbb240d5b3ab890c201f9d2c9ab6bee023e65736 Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Mon, 9 Aug 2021 18:25:08 +0200 Subject: [PATCH 29/47] test fix python3.6 on the CI --- tests/integration/test_slide.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_slide.py b/tests/integration/test_slide.py index 703a3f5fc..da38f6910 100644 --- a/tests/integration/test_slide.py +++ b/tests/integration/test_slide.py @@ -179,14 +179,13 @@ def it_raises_openslideerror_with_broken_wsi(self): ) def it_raises_miscellaneous_error(self): - slide = Slide(SVS.BROKEN, os.path.join(SVS.BROKEN, "processed")) + slide = Slide(path=None, processed_path=os.path.join(SVS.BROKEN, "processed")) with pytest.raises(HistolabException) as err: - slide._path = None slide._wsi assert isinstance(err.value, HistolabException) - assert str(err.value) == ( + assert str(err.value).replace(",", "") == ( "ArgumentError(\"argument 1: : Incorrect type\")" ". This slide may be corrupt or have a non-standard format not " "handled by the openslide and PIL libraries. Consider setting " From c64749df5a46d2d5fbfbf24f5bfb5e723c1f04ef Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Mon, 9 Aug 2021 19:13:45 +0200 Subject: [PATCH 30/47] simplyfy all(tile_size[i] == j for i, j in enumerate(image.size)) --- histolab/slide.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/histolab/slide.py b/histolab/slide.py index 0550a9879..35005e10d 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -206,7 +206,7 @@ def extract_tile( image = image.convert("RGB") # Sometimes when mpp kwarg is used, the image size is off from # what the user expects by a couple of pixels - if not all(tile_size[i] == j for i, j in enumerate(image.size)): + if not tile_size == image.size: image = image.resize( tile_size, IMG_UPSAMPLE_MODE From 4d59c1529b4ee42b60338b497da2dd2aeaa0a618 Mon Sep 17 00:00:00 2001 From: kheffah Date: Mon, 9 Aug 2021 19:40:58 -0500 Subject: [PATCH 31/47] add use_largeimage param documentation --- histolab/slide.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/histolab/slide.py b/histolab/slide.py index 35005e10d..4cdde476d 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -67,6 +67,20 @@ class Slide: Path where the WSI is saved. processed_path : Union[str, pathlib.Path] Path where the tiles will be saved to. + use_largeimage : bool + Whether or not to use the ``large_image`` package for accessing the + slide and extracting or calculating various metadata. If this is False, + ``openslide`` is used. If it is True, ``large_image`` will try from the + various installed tile sources. For example, if you installed it using + ``large_image[all]``, it will try ``openslide`` first, then ``PIL`` and + so on, depending on the slide format and metadata. ``large_image`` also + handles a lot of internal logic like allowing fetching exact + micro-per-pixel resolution tiles by interpolating between the internal + levels of the slide. If you don't mind installing an extra dependency, + we recommend setting this to True and fetching Tiles at exact + resolutions as opposed to levels. Different scanners have different + specifications, and the same level may not always encode the same + magnification in different scanners and slide formats. Raises ------ From 97d1dfea6bfcac59806d76be166d959bddd5422b Mon Sep 17 00:00:00 2001 From: kheffah Date: Mon, 9 Aug 2021 20:17:36 -0500 Subject: [PATCH 32/47] handle if unknown mpp despite using large_image --- histolab/slide.py | 9 ++++++++- tests/unit/test_slide.py | 20 +++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 4cdde476d..924180359 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -126,7 +126,14 @@ def base_mpp(self) -> float: """ if self._use_largeimage: - return self._metadata["mm_x"] * (10 ** 3) + if self._metadata["mm_x"] is not None: + return self._metadata["mm_x"] * (10 ** 3) + raise ValueError( + "Unknown scan resolution! This slide is missing metadata " + "needed for calculating the scanning resolution. Without " + "this information, you can only ask for a tile by level, " + "not mpp resolution." + ) if "openslide.mpp-x" in self.properties: return float(self.properties["openslide.mpp-x"]) diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 217bb3573..ee78d178c 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -100,7 +100,7 @@ def it_knows_its_name(self, slide_path, expected_value): assert name == expected_value - def it_raises_error_with_unknown_mpp(self, tmpdir): + def it_raises_error_with_unknown_mpp_without_largeimage(self, tmpdir): slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240) with pytest.raises(MayNeedLargeImageError) as err: @@ -113,6 +113,24 @@ def it_raises_error_with_unknown_mpp(self, tmpdir): "use_largeimage to True when instantiating this Slide." ) + def it_raises_error_with_unknown_mpp_with_largeimage(self, tmpdir): + slide, _ = base_test_slide( + tmpdir, + PILIMG.RGBA_COLOR_500X500_155_249_240, + use_largeimage=True + ) + + with pytest.raises(ValueError) as err: + slide.base_mpp + + assert isinstance(err.value, ValueError) + assert str(err.value) == ( + "Unknown scan resolution! This slide is missing metadata " + "needed for calculating the scanning resolution. Without " + "this information, you can only ask for a tile by level, " + "not mpp resolution." + ) + def it_has_largeimage_tilesource(self, tmpdir): slide, _ = base_test_slide( tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=True From 891f6025f1c9305dbe22458e49f972364f82d88b Mon Sep 17 00:00:00 2001 From: kheffah Date: Mon, 9 Aug 2021 20:29:53 -0500 Subject: [PATCH 33/47] linting and more descriptive docs --- histolab/slide.py | 27 ++++++++++++++++----------- tests/unit/test_slide.py | 4 +--- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 924180359..f855455f6 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -68,15 +68,15 @@ class Slide: processed_path : Union[str, pathlib.Path] Path where the tiles will be saved to. use_largeimage : bool - Whether or not to use the ``large_image`` package for accessing the - slide and extracting or calculating various metadata. If this is False, - ``openslide`` is used. If it is True, ``large_image`` will try from the - various installed tile sources. For example, if you installed it using - ``large_image[all]``, it will try ``openslide`` first, then ``PIL`` and - so on, depending on the slide format and metadata. ``large_image`` also - handles a lot of internal logic like allowing fetching exact - micro-per-pixel resolution tiles by interpolating between the internal - levels of the slide. If you don't mind installing an extra dependency, + Whether or not to use the `large_image` package for accessing the + slide and extracting or calculating various metadata. If this is + `False`, `openslide` is used. If it is `True`, `large_image` will try + from the various installed tile sources. For example, if you installed + it using `large_image[all]`, it will try `openslide` first, then `PIL`, + and so on, depending on the slide format and metadata. `large_image` + also handles internal logic to enable fetching exact micro-per-pixel + resolution tiles by interpolating between the internal levels of the + slide. If you don't mind installing an extra dependency, we recommend setting this to True and fetching Tiles at exact resolutions as opposed to levels. Different scanners have different specifications, and the same level may not always encode the same @@ -183,9 +183,14 @@ def extract_tile( tile_size: tuple Final size of the extracted tile (x,y). level : int - Level from which to extract the tile. + Level from which to extract the tile. If you specify this, and + `mpp` is None, `openslide` will be used to fetch tiles from this + level from the slide. `openslide` is used for fetching tiles by + level, regardless of `self.use_largeimage`. mpp : float - Micron per pixel resolution. Takes precedence over level. + Micron per pixel resolution. Takes precedence over level. If this + is not None, `large_image` will be used to fetch tiles at the exact + microns-per-pixel resolution requested. Returns ------- diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index ee78d178c..2e1774e16 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -115,9 +115,7 @@ def it_raises_error_with_unknown_mpp_without_largeimage(self, tmpdir): def it_raises_error_with_unknown_mpp_with_largeimage(self, tmpdir): slide, _ = base_test_slide( - tmpdir, - PILIMG.RGBA_COLOR_500X500_155_249_240, - use_largeimage=True + tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=True ) with pytest.raises(ValueError) as err: From 8a8d0d72960a6ae4d526c2bf5a38def846208005 Mon Sep 17 00:00:00 2001 From: kheffah Date: Mon, 9 Aug 2021 22:23:04 -0500 Subject: [PATCH 34/47] Only resize tiles fetched by mpp if within certain tolerance of requested tile_size. Testing not yet complete --- histolab/slide.py | 18 ++++++++++++++++-- tests/integration/test_tiler.py | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index f855455f6..6499c0c96 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -56,6 +56,7 @@ IMG_EXT = "png" IMG_UPSAMPLE_MODE = PIL.Image.BILINEAR IMG_DOWNSAMPLE_MODE = PIL.Image.BILINEAR +TILE_SIZE_PIXEL_TOLERANCE = 5 class Slide: @@ -233,10 +234,23 @@ def extract_tile( # Sometimes when mpp kwarg is used, the image size is off from # what the user expects by a couple of pixels if not tile_size == image.size: + if any( + np.abs(tile_size[i] - j) > TILE_SIZE_PIXEL_TOLERANCE + for i, j in enumerate(image.size) + ): + raise RuntimeError( + f"The tile you requested at a resolution of {mpp} MPP " + f"has a size of {image.size}, yet you specified a " + f"final `tile_size` of {tile_size}, which is a very " + "different value. When you set `mpp`, the `tile_size` " + "parameter is used to resize fetched tiles if they " + f"are off by just {TILE_SIZE_PIXEL_TOLERANCE} pixels " + "due to rounding differences etc. Please check if you " + "requested the right `mpp` and/or `tile_size`." + ) image = image.resize( tile_size, - IMG_UPSAMPLE_MODE - if tile_size[0] >= image.size[0] + IMG_UPSAMPLE_MODE if tile_size[0] >= image.size[0] else IMG_DOWNSAMPLE_MODE, ) diff --git a/tests/integration/test_tiler.py b/tests/integration/test_tiler.py index 23fb23051..275401111 100644 --- a/tests/integration/test_tiler.py +++ b/tests/integration/test_tiler.py @@ -160,7 +160,8 @@ def it_locates_tiles_on_the_slide( (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 42, 20, None), (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 0, 42, 10, None), (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 2, 20, None), - (SVS.CMU_1_SMALL_REGION, (135, 128), 0, 2, 10, 0.5), + (SVS.CMU_1_SMALL_REGION, (135, 128), 0, 2, 10, 0.5), # resized + (SVS.CMU_1_SMALL_REGION, (128, 126), 0, 2, 10, 0.5), # not resized (TIFF.KIDNEY_48_5, (10, 20), 0, 2, 10, None), (TIFF.KIDNEY_48_5, (20, 10), 0, 20, 20, None), (TIFF.KIDNEY_48_5, (10, 15), 0, 20, 10, None), From c59d1e957b10f223974563dc32cefbe5ac084b20 Mon Sep 17 00:00:00 2001 From: kheffah Date: Tue, 10 Aug 2021 01:20:32 -0500 Subject: [PATCH 35/47] Try to reach full test coverage. Add test_extract_tiles_at_mpp_not_respecting_given_tile_size and test_raise_error_if_mpp_and_discordant_tile_size. --- histolab/slide.py | 11 +++-- tests/integration/test_tiler.py | 84 +++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 6499c0c96..ff1a8a2a3 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -182,7 +182,11 @@ def extract_tile( coords : CoordinatePair Coordinates at level 0 from which to extract the tile. tile_size: tuple - Final size of the extracted tile (x,y). + Final size of the extracted tile (x,y). If you choose to specify + the `mpp` argument, you may elect to set this as `None` to return + the tile as-is from `large_image` without any resizing. This is not + recommended, as tile size may be off by a couple of pixels when + coordinates are mapped to the exact mpp you request. level : int Level from which to extract the tile. If you specify this, and `mpp` is None, `openslide` will be used to fetch tiles from this @@ -233,7 +237,7 @@ def extract_tile( image = image.convert("RGB") # Sometimes when mpp kwarg is used, the image size is off from # what the user expects by a couple of pixels - if not tile_size == image.size: + if tile_size is not None and not tile_size == image.size: if any( np.abs(tile_size[i] - j) > TILE_SIZE_PIXEL_TOLERANCE for i, j in enumerate(image.size) @@ -250,7 +254,8 @@ def extract_tile( ) image = image.resize( tile_size, - IMG_UPSAMPLE_MODE if tile_size[0] >= image.size[0] + IMG_UPSAMPLE_MODE + if tile_size[0] >= image.size[0] else IMG_DOWNSAMPLE_MODE, ) diff --git a/tests/integration/test_tiler.py b/tests/integration/test_tiler.py index 275401111..23fdd14b4 100644 --- a/tests/integration/test_tiler.py +++ b/tests/integration/test_tiler.py @@ -8,7 +8,7 @@ from histolab.masks import BiggestTissueBoxMask, TissueMask from histolab.scorer import NucleiScorer -from histolab.slide import Slide +from histolab.slide import Slide, TILE_SIZE_PIXEL_TOLERANCE from histolab.tiler import GridTiler, RandomTiler, ScoreTiler from ..fixtures import EXTERNAL_SVS, SVS, TIFF @@ -153,15 +153,14 @@ def it_locates_tiles_on_the_slide( (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 42, 10, None), (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 1, 2, 20, None), (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 2, 10, None), - (SVS.CMU_1_SMALL_REGION, (128, 128), 0, 2, 10, 0.5), + (SVS.CMU_1_SMALL_REGION, (128, 128), 0, 2, 5, 0.5), (TIFF.KIDNEY_48_5, (10, 10), 0, 20, 20, None), (TIFF.KIDNEY_48_5, (20, 20), 0, 20, 10, None), # Not squared tile size (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 42, 20, None), (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 0, 42, 10, None), (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 2, 20, None), - (SVS.CMU_1_SMALL_REGION, (135, 128), 0, 2, 10, 0.5), # resized - (SVS.CMU_1_SMALL_REGION, (128, 126), 0, 2, 10, 0.5), # not resized + (SVS.CMU_1_SMALL_REGION, (126, 128), 0, 10, 5, 0.5), (TIFF.KIDNEY_48_5, (10, 20), 0, 2, 10, None), (TIFF.KIDNEY_48_5, (20, 10), 0, 20, 20, None), (TIFF.KIDNEY_48_5, (10, 15), 0, 20, 10, None), @@ -188,6 +187,83 @@ def test_extract_tiles_respecting_the_given_tile_size( for tile in os.listdir(processed_path): assert Image.open(os.path.join(processed_path, tile)).size == tile_size + @pytest.mark.parametrize( + "fixture_slide, n_tiles, mpp, expected_tile_sizes", + ( + ( + SVS.CMU_1_SMALL_REGION, + 3, + 0.5, + ((127, 127), (127, 127), (127, 127)), + ), + ( + SVS.CMU_1_SMALL_REGION, + 3, + 0.25, + ((127, 127), (129, 127), (127, 127)), + ), + ), + ) + def test_extract_tiles_at_mpp_not_respecting_given_tile_size( + self, tmpdir, fixture_slide, n_tiles, mpp, expected_tile_sizes + ): + processed_path = os.path.join(tmpdir, "processed") + slide = Slide(fixture_slide, processed_path, use_largeimage=True) + random_tiles_extractor = RandomTiler( + tile_size=(128, 128), + n_tiles=n_tiles, + seed=0, + check_tissue=True, + mpp=mpp, + ) + binary_mask = BiggestTissueBoxMask() + + random_tiles_extractor.final_tile_size = None + random_tiles_extractor.extract(slide, binary_mask) + + for tidx, tile in enumerate(os.listdir(processed_path)): + assert ( + Image.open(os.path.join(processed_path, tile)).size + == expected_tile_sizes[tidx] + ) + + @pytest.mark.parametrize( + "fixture_slide, tile_size", + ( + (SVS.CMU_1_SMALL_REGION, (300, 300)), + (SVS.CMU_1_SMALL_REGION, (20, 15)), + ), + ) + def test_raise_error_if_mpp_and_discordant_tile_size( + self, tmpdir, fixture_slide, tile_size + ): + processed_path = os.path.join(tmpdir, "processed") + slide = Slide(fixture_slide, processed_path, use_largeimage=True) + random_tiles_extractor = RandomTiler( + tile_size=(128, 128), + n_tiles=3, + seed=0, + check_tissue=True, + mpp=0.5, + ) + binary_mask = BiggestTissueBoxMask() + + with pytest.raises(RuntimeError) as err: + random_tiles_extractor.final_tile_size = tile_size + random_tiles_extractor.extract(slide, binary_mask) + + assert isinstance(err.value, RuntimeError) + assert str(err.value) == ( + f"The tile you requested at a resolution of 0.5 MPP " + f"has a size of (128, 126), yet you specified a " + f"final `tile_size` of {tile_size}, which is a very " + "different value. When you set `mpp`, the `tile_size` " + "parameter is used to resize fetched tiles if they " + f"are off by just {TILE_SIZE_PIXEL_TOLERANCE} pixels " + "due to rounding differences etc. Please check if you " + "requested the right `mpp` and/or `tile_size`." + ) + class DescribeGridTiler: @pytest.mark.parametrize( From 9f8a3c6b0b940cc2c82c3975a0448d2507b752aa Mon Sep 17 00:00:00 2001 From: Mohamed Amgad Tageldin Date: Wed, 11 Aug 2021 08:30:54 -0500 Subject: [PATCH 36/47] Apply suggestions from code review Co-authored-by: Alessia Marcolini <98marcolini@gmail.com> --- histolab/slide.py | 18 +++++++++--------- histolab/tiler.py | 26 +++++++++++++------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index ff1a8a2a3..b278411a9 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -68,14 +68,14 @@ class Slide: Path where the WSI is saved. processed_path : Union[str, pathlib.Path] Path where the tiles will be saved to. - use_largeimage : bool + use_largeimage : bool, optional Whether or not to use the `large_image` package for accessing the slide and extracting or calculating various metadata. If this is `False`, `openslide` is used. If it is `True`, `large_image` will try from the various installed tile sources. For example, if you installed it using `large_image[all]`, it will try `openslide` first, then `PIL`, and so on, depending on the slide format and metadata. `large_image` - also handles internal logic to enable fetching exact micro-per-pixel + also handles internal logic to enable fetching exact micron-per-pixel resolution tiles by interpolating between the internal levels of the slide. If you don't mind installing an extra dependency, we recommend setting this to True and fetching Tiles at exact @@ -93,7 +93,7 @@ def __init__( self, path: Union[str, pathlib.Path], processed_path: Union[str, pathlib.Path], - use_largeimage=False, + use_largeimage: bool = False, ) -> None: self._path = str(path) if isinstance(path, pathlib.Path) else path @@ -171,7 +171,7 @@ def dimensions(self) -> Tuple[int, int]: def extract_tile( self, coords: CoordinatePair, - tile_size: Tuple, + tile_size: Tuple[int, int], level: int = None, mpp: float = None, ) -> Tile: @@ -181,7 +181,7 @@ def extract_tile( ---------- coords : CoordinatePair Coordinates at level 0 from which to extract the tile. - tile_size: tuple + tile_size : Tuple[int, int] Final size of the extracted tile (x,y). If you choose to specify the `mpp` argument, you may elect to set this as `None` to return the tile as-is from `large_image` without any resizing. This is not @@ -203,7 +203,7 @@ def extract_tile( Image containing the selected tile. """ if level is None and mpp is None: - raise ValueError("either level or mpp must be provided!") + raise ValueError("Either level or mpp must be provided!") if level is not None: level = level if level >= 0 else self._remap_level(level) @@ -509,7 +509,7 @@ def _bytes2pil(bytesim): Returns ------- - PIL.Image + PIL.Image.Image A PIL Image object converted from the Bytes input. """ image_content = BytesIO(bytesim) @@ -687,7 +687,7 @@ def _thumbnail_size(self) -> Tuple[int, int]: ) @lazyproperty - def _tilesource(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: + def _tile_source(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: """Open the slide and returns a large_image tile source object Returns @@ -714,7 +714,7 @@ def _wsi(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: An OpenSlide object representing a whole-slide image. """ bad_format_error = ( - "This slide may be corrupt or have a non-standard format not " + "This slide may be corrupted or have a non-standard format not " "handled by the openslide and PIL libraries. Consider setting " "use_largeimage to True when instantiating this Slide." ) diff --git a/histolab/tiler.py b/histolab/tiler.py index 0c2fae77b..8d00ef673 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -144,8 +144,8 @@ def _get_proper_tile_size(self, slide: Slide) -> Tuple[Tuple[int, int], float]: Parameters ---------- - slide: Slide - The slide we want to tile. + slide : Slide + The slide to tile. Returns ------- @@ -158,8 +158,8 @@ def _get_proper_tile_size(self, slide: Slide) -> Tuple[Tuple[int, int], float]: if self.mpp is None: return self.tile_size, 1.0 - sf = self.mpp / slide.base_mpp - return tuple(int(j * sf) for j in self.tile_size), sf + scale_factor = self.mpp / slide.base_mpp + return tuple(int(j * scale_factor) for j in self.tile_size), scale_factor def _has_valid_tile_size(self, slide: Slide) -> bool: """Return True if the tile size is smaller or equal than the ``slide`` size. @@ -315,7 +315,7 @@ class GridTiler(Tiler): suffix : str, optional Suffix to be added to the tile filename. Default is '.png' mpp : float, optional - Micron per pixel resolution of extracted tiles. Takes precedence over level. + Micron per pixel resolution of extracted tiles. Takes precedence over level. Default is None. """ def __init__( @@ -609,11 +609,11 @@ def _set_proper_tile_size_and_overlap(self, slide) -> None: Parameters ---------- - slide: Slide - The slide we want to tile. + slide : Slide + The slide to tile. """ - self.tile_size, sf = self._get_proper_tile_size(slide) - self.pixel_overlap = int(sf * self.pixel_overlap) + self.tile_size, scale_factor = self._get_proper_tile_size(slide) + self.pixel_overlap = int(scale_factor * self.pixel_overlap) class RandomTiler(Tiler): @@ -645,7 +645,7 @@ class RandomTiler(Tiler): Maximum number of iterations performed when searching for eligible (if ``check_tissue=True``) tiles. Must be grater than or equal to ``n_tiles``. mpp : float, optional - Micron per pixel resolution. If provided, takes precedence over level. + Micron per pixel resolution. If provided, takes precedence over level. Default is None. """ def __init__( @@ -787,8 +787,8 @@ def _set_proper_tile_size(self, slide) -> None: Parameters ---------- - slide: Slide - The slide we want to tile. + slide : Slide + The slide to tile. """ self.tile_size, _ = self._get_proper_tile_size(slide) @@ -877,7 +877,7 @@ class ScoreTiler(GridTiler): suffix : str, optional Suffix to be added to the tile filename. Default is '.png' mpp : float, optional. - Micron per pixel resolution. If provided, takes precedence over level. + Micron per pixel resolution. If provided, takes precedence over level. Default is None. """ def __init__( From 00a7cd704ee52aa7bdcaf8256e41fcd25606de9a Mon Sep 17 00:00:00 2001 From: kheffah Date: Wed, 11 Aug 2021 08:53:42 -0500 Subject: [PATCH 37/47] improve docstrings --- histolab/slide.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index b278411a9..d29d167b8 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -87,6 +87,9 @@ class Slide: ------ TypeError If the processed path is not specified. + ModuleNotFoundError + when `use_largeimage` is set to True and `large_image` module is not + installed. """ def __init__( @@ -125,6 +128,13 @@ def base_mpp(self) -> float: float Microns-per-pixel resolution at scan (base) magnification. + Raises + ------ + ValueError + If `large_image` cannot detemine the slide magnification. + MayNeedLargeImageError + If `use_largeimage` was set to False when slide was initialized, + and we cannot determine the magnification otherwise. """ if self._use_largeimage: if self._metadata["mm_x"] is not None: @@ -500,12 +510,13 @@ def thumbnail(self) -> PIL.Image.Image: # ------- implementation helpers ------- @staticmethod - def _bytes2pil(bytesim): + def _bytes2pil(bytesim: bytearray): """Convert a bytes image to a PIL image object. Parameters ---------- - bytesim : A bytes object. + bytesim : bytearray + A bytes object representation of an image. Returns ------- @@ -672,6 +683,11 @@ def _thumbnail_size(self) -> Tuple[int, int]: ------- Tuple[int, int] Thumbnail size + + Raises + ------ + MayNeedLargeImageError + If `use_largeimage` was set to False when slide was initialized. """ if self._use_largeimage: raise MayNeedLargeImageError( @@ -694,6 +710,11 @@ def _tile_source(self) -> Union[openslide.OpenSlide, openslide.ImageSlide]: ------- source : large_image TileSource object An TileSource object representing a whole-slide image. + + Raises + ------ + MayNeedLargeImageError + If `use_largeimage` was set to False when slide was initialized. """ if not self._use_largeimage: raise MayNeedLargeImageError( From fe8e9d92d1965df033ec5125da221ddbf657b8bd Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Sun, 29 Aug 2021 10:14:50 +0200 Subject: [PATCH 38/47] fix broken tests --- histolab/slide.py | 18 ++++-------------- tests/integration/test_slide.py | 4 ++-- tests/unit/test_slide.py | 19 ++++++++----------- 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index d29d167b8..bd6c09e3e 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -232,7 +232,7 @@ def extract_tile( ) else: mm = mpp / 1000 - image, _ = self._tilesource.getRegion( + image, _ = self._tile_source.getRegion( region=dict( left=coords.x_ul, top=coords.y_ul, @@ -501,7 +501,7 @@ def thumbnail(self) -> PIL.Image.Image: The slide thumbnail. """ if self._use_largeimage: - thumb_bytes, _ = self._tilesource.getThumbnail(encoding="PNG") + thumb_bytes, _ = self._tile_source.getThumbnail(encoding="PNG") thumbnail = self._bytes2pil(thumb_bytes).convert("RGB") return thumbnail @@ -558,7 +558,7 @@ def _metadata(self) -> dict: ``large_image.TileSource.getMetadata()`` for details on the return keys and data types. """ - return self._tilesource.getMetadata() + return self._tile_source.getMetadata() def _remap_level(self, level: int) -> int: """Remap negative index for the given level onto a positive one. @@ -617,7 +617,7 @@ def _resample(self, scale_factor: int = 32) -> Tuple[PIL.Image.Image, np.array]: if self._metadata["magnification"] is not None else {} ) - wsi_image, _ = self._tilesource.getRegion( + wsi_image, _ = self._tile_source.getRegion( format=large_image.tilesource.TILE_FORMAT_PIL, **kwargs, ) @@ -683,17 +683,7 @@ def _thumbnail_size(self) -> Tuple[int, int]: ------- Tuple[int, int] Thumbnail size - - Raises - ------ - MayNeedLargeImageError - If `use_largeimage` was set to False when slide was initialized. """ - if self._use_largeimage: - raise MayNeedLargeImageError( - "When use_largeimage is set to True, the thumbnail is fetched " - "by the large_image module. Please use thumbnail.size instead." - ) return tuple( [ diff --git a/tests/integration/test_slide.py b/tests/integration/test_slide.py index da38f6910..8bbcb2774 100644 --- a/tests/integration/test_slide.py +++ b/tests/integration/test_slide.py @@ -173,7 +173,7 @@ def it_raises_openslideerror_with_broken_wsi(self): assert isinstance(err.value, PIL.UnidentifiedImageError) assert str(err.value) == ( - "This slide may be corrupt or have a non-standard format not " + "This slide may be corrupted or have a non-standard format not " "handled by the openslide and PIL libraries. Consider setting " "use_largeimage to True when instantiating this Slide." ) @@ -187,7 +187,7 @@ def it_raises_miscellaneous_error(self): assert isinstance(err.value, HistolabException) assert str(err.value).replace(",", "") == ( "ArgumentError(\"argument 1: : Incorrect type\")" - ". This slide may be corrupt or have a non-standard format not " + ". This slide may be corrupted or have a non-standard format not " "handled by the openslide and PIL libraries. Consider setting " "use_largeimage to True when instantiating this Slide." ) diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 2e1774e16..359ae9055 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -9,6 +9,7 @@ import openslide import PIL import pytest +from large_image.exceptions import TileSourceException from PIL import ImageShow from histolab.types import CP @@ -134,13 +135,13 @@ def it_has_largeimage_tilesource(self, tmpdir): tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=True ) - assert slide._tilesource.name == "pilfile" + assert slide._tile_source.name == "pilfile" def it_raises_error_if_tilesource_and_not_use_largeimage(self): slide = Slide("/a/b/foo", "processed") with pytest.raises(MayNeedLargeImageError) as err: - slide._tilesource + slide._tile_source assert isinstance(err.value, MayNeedLargeImageError) assert str(err.value) == ( @@ -164,7 +165,7 @@ def it_raises_error_if_bad_args_for_extract_tile(self, tmpdir): slide.extract_tile(CP(0, 10, 0, 10), (10, 10), level=None, mpp=None) assert isinstance(err.value, ValueError) - assert str(err.value) == "either level or mpp must be provided!" + assert str(err.value) == "Either level or mpp must be provided!" def it_knows_its_resampled_dimensions(self, dimensions_): """This test prove that given the dimensions (mock object here), it does @@ -212,15 +213,11 @@ def it_knows_its_thumbnail_size(self, tmpdir): def it_raises_error_if_thumbnail_size_and_use_largeimage(self): slide = Slide("/a/b/foo", "processed", use_largeimage=True) - - with pytest.raises(MayNeedLargeImageError) as err: + with pytest.raises(TileSourceException) as err: slide._thumbnail_size - assert isinstance(err.value, MayNeedLargeImageError) - assert str(err.value) == ( - "When use_largeimage is set to True, the thumbnail is fetched " - "by the large_image module. Please use thumbnail.size instead." - ) + assert isinstance(err.value, TileSourceException) + assert str(err.value) == "No available tilesource for /a/b/foo" def it_creates_a_correct_slide_object(self, tmpdir): slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_50X50_155_0_0) @@ -248,7 +245,7 @@ def or_it_raises_an_PIL_exception(self, tmpdir): assert isinstance(err.value, PIL.UnidentifiedImageError) assert str(err.value) == ( - "This slide may be corrupt or have a non-standard format not " + "This slide may be corrupted or have a non-standard format not " "handled by the openslide and PIL libraries. Consider setting " "use_largeimage to True when instantiating this Slide." ) From b466ee6eb32b89cc2c549635874767f459a40a58 Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Sun, 29 Aug 2021 21:11:13 +0200 Subject: [PATCH 39/47] address alessia's comments and fix dirty tests --- tests/integration/test_slide.py | 11 +++----- tests/integration/test_tiler.py | 45 +++++++++++++++++++-------------- tests/unit/test_tiler.py | 22 ++++++++-------- 3 files changed, 42 insertions(+), 36 deletions(-) diff --git a/tests/integration/test_slide.py b/tests/integration/test_slide.py index 8bbcb2774..cb4299752 100644 --- a/tests/integration/test_slide.py +++ b/tests/integration/test_slide.py @@ -30,8 +30,8 @@ def it_knows_its_name(self): @pytest.mark.parametrize( "use_largeimage, slide_props", [ - (True, None), - (False, None), + (True, {}), + (False, {}), (False, {"aperio.MPP": 0.499}), ( False, @@ -48,14 +48,11 @@ def it_knows_its_base_mpp(self, use_largeimage, slide_props): os.path.join(SVS.CMU_1_SMALL_REGION, "processed"), use_largeimage=use_largeimage, ) - if slide_props: - del slide.properties["openslide.mpp-x"] - del slide.properties["aperio.MPP"] - slide.properties.update(slide_props) + slide.properties.update(slide_props) mpp = slide.base_mpp - np.testing.assert_almost_equal(mpp, 0.499) + assert mpp == 0.499 def it_calculate_resampled_nparray_from_small_region_svs_image(self): slide = Slide( diff --git a/tests/integration/test_tiler.py b/tests/integration/test_tiler.py index 23fdd14b4..15ac0098a 100644 --- a/tests/integration/test_tiler.py +++ b/tests/integration/test_tiler.py @@ -8,7 +8,7 @@ from histolab.masks import BiggestTissueBoxMask, TissueMask from histolab.scorer import NucleiScorer -from histolab.slide import Slide, TILE_SIZE_PIXEL_TOLERANCE +from histolab.slide import TILE_SIZE_PIXEL_TOLERANCE, Slide from histolab.tiler import GridTiler, RandomTiler, ScoreTiler from ..fixtures import EXTERNAL_SVS, SVS, TIFF @@ -146,30 +146,37 @@ def it_locates_tiles_on_the_slide( np.testing.assert_array_almost_equal(tiles_location_img, expected_img) @pytest.mark.parametrize( - "fixture_slide, tile_size, level, seed, n_tiles, mpp", + "fixture_slide, tile_size, level, seed, n_tiles, mpp, use_largeimage", ( # Squared tile size - (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 1, 42, 20, None), - (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 42, 10, None), - (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 1, 2, 20, None), - (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 2, 10, None), - (SVS.CMU_1_SMALL_REGION, (128, 128), 0, 2, 5, 0.5), - (TIFF.KIDNEY_48_5, (10, 10), 0, 20, 20, None), - (TIFF.KIDNEY_48_5, (20, 20), 0, 20, 10, None), + (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 1, 42, 20, None, False), + (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 42, 10, None, False), + (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 1, 2, 20, None, False), + (SVS.TCGA_CR_7395_01A_01_TS1, (128, 128), 0, 2, 10, None, False), + (SVS.CMU_1_SMALL_REGION, (128, 128), 0, 2, 5, 0.5, True), + (TIFF.KIDNEY_48_5, (10, 10), 0, 20, 20, None, False), + (TIFF.KIDNEY_48_5, (20, 20), 0, 20, 10, None, False), # Not squared tile size - (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 42, 20, None), - (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 0, 42, 10, None), - (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 2, 20, None), - (SVS.CMU_1_SMALL_REGION, (126, 128), 0, 10, 5, 0.5), - (TIFF.KIDNEY_48_5, (10, 20), 0, 2, 10, None), - (TIFF.KIDNEY_48_5, (20, 10), 0, 20, 20, None), - (TIFF.KIDNEY_48_5, (10, 15), 0, 20, 10, None), + (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 42, 20, None, False), + (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 0, 42, 10, None, False), + (SVS.TCGA_CR_7395_01A_01_TS1, (135, 128), 1, 2, 20, None, False), + (SVS.CMU_1_SMALL_REGION, (126, 128), 0, 10, 5, 0.5, True), + (TIFF.KIDNEY_48_5, (10, 20), 0, 2, 10, None, False), + (TIFF.KIDNEY_48_5, (20, 10), 0, 20, 20, None, False), + (TIFF.KIDNEY_48_5, (10, 15), 0, 20, 10, None, False), ), ) def test_extract_tiles_respecting_the_given_tile_size( - self, tmpdir, fixture_slide, tile_size, level, seed, n_tiles, mpp + self, + tmpdir, + fixture_slide, + tile_size, + level, + seed, + n_tiles, + mpp, + use_largeimage, ): - use_largeimage = mpp is not None processed_path = os.path.join(tmpdir, "processed") slide = Slide(fixture_slide, processed_path, use_largeimage=use_largeimage) random_tiles_extractor = RandomTiler( @@ -246,10 +253,10 @@ def test_raise_error_if_mpp_and_discordant_tile_size( check_tissue=True, mpp=0.5, ) + random_tiles_extractor.final_tile_size = tile_size binary_mask = BiggestTissueBoxMask() with pytest.raises(RuntimeError) as err: - random_tiles_extractor.final_tile_size = tile_size random_tiles_extractor.extract(slide, binary_mask) assert isinstance(err.value, RuntimeError) diff --git a/tests/unit/test_tiler.py b/tests/unit/test_tiler.py index 662fd48a8..5a9cb4cb5 100644 --- a/tests/unit/test_tiler.py +++ b/tests/unit/test_tiler.py @@ -36,11 +36,11 @@ def it_constructs_from_args(self, level, mpp, request): _init = initializer_mock(request, RandomTiler) random_tiler = RandomTiler( - (512, 512), 10, level, 7, True, "", ".png", int(1e4), mpp=mpp + tile_size=(512, 512), n_tiles=10, level=level, mpp=mpp ) _init.assert_called_once_with( - ANY, (512, 512), 10, level, 7, True, "", ".png", int(1e4), mpp=mpp + random_tiler, tile_size=(512, 512), n_tiles=10, level=level, mpp=mpp ) assert isinstance(random_tiler, RandomTiler) assert isinstance(random_tiler, Tiler) @@ -49,22 +49,24 @@ def it_constructs_from_args(self, level, mpp, request): "level, mpp, expected_level, expected_mpp", ((2, None, 2, None), (None, 0.5, 0, 0.5), (2, 0.5, 0, 0.5)), ) - def mpp_supercedes_level(self, level, mpp, expected_level, expected_mpp): + def it_knows_when_mpp_supercedes_level( + self, level, mpp, expected_level, expected_mpp + ): - tiler = RandomTiler((512, 512), 10, level=level, mpp=mpp) + random_tiler = RandomTiler((512, 512), 10, level=level, mpp=mpp) - assert tiler.level == expected_level - assert tiler.mpp == expected_mpp + assert random_tiler.level == expected_level + assert random_tiler.mpp == expected_mpp @pytest.mark.parametrize( "mpp, fixed_tile_size", ((None, (512, 512)), (0.5, (1024, 1024))) ) def it_can_fix_tile_size_if_mpp(self, mpp, fixed_tile_size): fake_slide = namedtuple("fake_slide", ["base_mpp"]) - tiler = RandomTiler((512, 512), 0, True, 80, 0, "", ".png", mpp=mpp) - tiler._set_proper_tile_size(fake_slide(0.25)) + random_tiler = RandomTiler((512, 512), 10, mpp=mpp) + random_tiler._set_proper_tile_size(fake_slide(0.25)) - assert tiler.tile_size == fixed_tile_size + assert random_tiler.tile_size == fixed_tile_size def but_it_has_wrong_tile_size_value(self): with pytest.raises(ValueError) as err: @@ -75,7 +77,7 @@ def but_it_has_wrong_tile_size_value(self): def or_it_has_not_available_level_value(self, tmpdir): slide, _ = base_test_slide(tmpdir, PILIMG.RGB_RANDOM_COLOR_500X500) - random_tiler = RandomTiler((128, 128), 10, 3) + random_tiler = RandomTiler(tile_size=(128, 128), n_tiles=10, level=3) binary_mask = BiggestTissueBoxMask() with pytest.raises(LevelError) as err: From 1637e97a066c86e758be81214d929fc0487cbb3e Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Sun, 29 Aug 2021 22:46:43 +0200 Subject: [PATCH 40/47] refactor tiler tile_size and pixel_overlap --- histolab/tiler.py | 147 +++++++++++++++----------------- tests/integration/test_tiler.py | 26 ++++++ tests/unit/test_tiler.py | 25 ------ 3 files changed, 96 insertions(+), 102 deletions(-) diff --git a/histolab/tiler.py b/histolab/tiler.py index 8d00ef673..f1202736f 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -139,28 +139,6 @@ def locate_tiles( # ------- implementation helpers ------- - def _get_proper_tile_size(self, slide: Slide) -> Tuple[Tuple[int, int], float]: - """Get the proper tile size for level or mpp requested. - - Parameters - ---------- - slide : Slide - The slide to tile. - - Returns - ------- - Tuple[int, int] - Proper tile size at desired level or MPP resolution. - float - Scale factor that maps the original self.tile_size to proper one. - - """ - if self.mpp is None: - return self.tile_size, 1.0 - - scale_factor = self.mpp / slide.base_mpp - return tuple(int(j * scale_factor) for j in self.tile_size), scale_factor - def _has_valid_tile_size(self, slide: Slide) -> bool: """Return True if the tile size is smaller or equal than the ``slide`` size. @@ -180,6 +158,23 @@ def _has_valid_tile_size(self, slide: Slide) -> bool: and self.tile_size[1] <= slide.level_dimensions(self.level)[1] ) + def _scale_factor(self, slide: Slide) -> float: + """Retrieve the scale factor that maps the original tile_size to proper one. + + Parameters + ---------- + slide : Slide + The slide to tile. + + Returns + ------- + float + Scale factor that maps the original self.tile_size to proper one. + """ + if self.mpp is None: + return 1.0 + return self.mpp / slide.base_mpp + @staticmethod def _tile_coords_and_outline_generator( tiles_coords: Iterable[CoordinatePair], @@ -251,6 +246,23 @@ def _tiles_generator( ) -> Tuple[Tile, CoordinatePair]: pass # pragma: no cover + def _tile_size(self, slide: Slide) -> Tuple[int, int]: + """Get the proper tile size for level or mpp requested. + + Parameters + ---------- + slide : Slide + The slide to tile. + + Returns + ------- + Tuple[int, int] + Proper tile size at desired level or MPP resolution. + """ + if self.mpp is None: + return self.tile_size + return tuple(int(j * self._scale_factor(slide)) for j in self.tile_size) + def _validate_level(self, slide: Slide) -> None: """Validate the Tiler's level according to the Slide. @@ -369,7 +381,8 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) - self._set_proper_tile_size_and_overlap(slide) + self.tile_size = self._tile_size(slide) + self.pixel_overlap = int(self._scale_factor(slide) * self.pixel_overlap) self._validate_tile_size(slide) grid_tiles = self._tiles_generator(slide, extraction_mask) @@ -533,43 +546,6 @@ def _grid_coordinates_generator( bbox_coordinates_lvl, slide, binary_mask_region ) - def _tiles_generator( - self, slide: Slide, extraction_mask: BinaryMask = BiggestTissueBoxMask() - ) -> Tuple[Tile, CoordinatePair]: - """Generator of tiles arranged in a grid. - - Parameters - ---------- - slide : Slide - Slide from which to extract the tiles - extraction_mask : BinaryMask, optional - BinaryMask object defining how to compute a binary mask from a Slide. - Default `BiggestTissueBoxMask`. - - Yields - ------- - Tile - Extracted tile - CoordinatePair - Coordinates of the slide at level 0 from which the tile has been extracted - """ - grid_coordinates_generator = self._grid_coordinates_generator( - slide, extraction_mask - ) - for coords in grid_coordinates_generator: - try: - tile = slide.extract_tile( - coords, - tile_size=self.final_tile_size, - mpp=self.mpp, - level=self.level if self.mpp is None else None, - ) - except TileSizeOrCoordinatesError: - continue - - if not self.check_tissue or tile.has_enough_tissue(self.tissue_percent): - yield tile, coords - def _n_tiles_column(self, bbox_coordinates: CoordinatePair) -> int: """Return the number of tiles which can be extracted in a column. @@ -604,16 +580,42 @@ def _n_tiles_row(self, bbox_coordinates: CoordinatePair) -> int: self.tile_size[0] - self.pixel_overlap ) - def _set_proper_tile_size_and_overlap(self, slide) -> None: - """Set the proper tile size and overlap for level or mpp requested. + def _tiles_generator( + self, slide: Slide, extraction_mask: BinaryMask = BiggestTissueBoxMask() + ) -> Tuple[Tile, CoordinatePair]: + """Generator of tiles arranged in a grid. Parameters ---------- slide : Slide - The slide to tile. + Slide from which to extract the tiles + extraction_mask : BinaryMask, optional + BinaryMask object defining how to compute a binary mask from a Slide. + Default `BiggestTissueBoxMask`. + + Yields + ------- + Tile + Extracted tile + CoordinatePair + Coordinates of the slide at level 0 from which the tile has been extracted """ - self.tile_size, scale_factor = self._get_proper_tile_size(slide) - self.pixel_overlap = int(scale_factor * self.pixel_overlap) + grid_coordinates_generator = self._grid_coordinates_generator( + slide, extraction_mask + ) + for coords in grid_coordinates_generator: + try: + tile = slide.extract_tile( + coords, + tile_size=self.final_tile_size, + mpp=self.mpp, + level=self.level if self.mpp is None else None, + ) + except TileSizeOrCoordinatesError: + continue + + if not self.check_tissue or tile.has_enough_tissue(self.tissue_percent): + yield tile, coords class RandomTiler(Tiler): @@ -702,7 +704,7 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) - self._set_proper_tile_size(slide) + self.tile_size = self._tile_size(slide) self._validate_tile_size(slide) random_tiles = self._tiles_generator(slide, extraction_mask) @@ -782,16 +784,6 @@ def _random_tile_coordinates( return tile_wsi_coords - def _set_proper_tile_size(self, slide) -> None: - """Set the proper tile size for level or mpp requested. - - Parameters - ---------- - slide : Slide - The slide to tile. - """ - self.tile_size, _ = self._get_proper_tile_size(slide) - def _tiles_generator( self, slide: Slide, extraction_mask: BinaryMask = BiggestTissueBoxMask() ) -> Tuple[Tile, CoordinatePair]: @@ -942,7 +934,8 @@ def extract( level = logging.getLevelName(log_level) logger.setLevel(level) self._validate_level(slide) - self._set_proper_tile_size_and_overlap(slide) + self.tile_size = self._tile_size(slide) + self.pixel_overlap = int(self._scale_factor(slide) * self.pixel_overlap) self._validate_tile_size(slide) highest_score_tiles, highest_scaled_score_tiles = self._tiles_generator( diff --git a/tests/integration/test_tiler.py b/tests/integration/test_tiler.py index 15ac0098a..776e4efaa 100644 --- a/tests/integration/test_tiler.py +++ b/tests/integration/test_tiler.py @@ -271,6 +271,17 @@ def test_raise_error_if_mpp_and_discordant_tile_size( "requested the right `mpp` and/or `tile_size`." ) + @pytest.mark.parametrize( + "mpp, fixed_tile_size", ((None, (512, 512)), (0.75, (769, 769))) + ) + def it_sets_tile_size_in_extract_if_mpp(self, mpp, fixed_tile_size, tmpdir): + slide = Slide(SVS.CMU_1_SMALL_REGION, tmpdir, use_largeimage=True) + tiler = RandomTiler((512, 512), 10, mpp=mpp) + + tiler.extract(slide) + + assert tiler.tile_size == fixed_tile_size + class DescribeGridTiler: @pytest.mark.parametrize( @@ -387,6 +398,21 @@ def it_locates_tiles_on_the_slide( np.testing.assert_array_almost_equal(tiles_location_img, expected_img) + @pytest.mark.parametrize( + "mpp, fixed_tile_size, fixed_overlap", + ((None, (512, 512), 32), (0.75, (769, 769), 48)), + ) + def it_sets_tile_size_and_overlap_in_extract_if_mpp( + self, mpp, fixed_tile_size, fixed_overlap, tmpdir + ): + slide = Slide(SVS.CMU_1_SMALL_REGION, tmpdir, use_largeimage=True) + tiler = GridTiler((512, 512), pixel_overlap=32, mpp=mpp) + + tiler.extract(slide) + + assert tiler.tile_size == fixed_tile_size + assert tiler.pixel_overlap == fixed_overlap + class DescribeScoreTiler: @pytest.mark.parametrize( diff --git a/tests/unit/test_tiler.py b/tests/unit/test_tiler.py index 5a9cb4cb5..c179b1c67 100644 --- a/tests/unit/test_tiler.py +++ b/tests/unit/test_tiler.py @@ -2,7 +2,6 @@ import logging import os import re -from collections import namedtuple from unittest.mock import call import numpy as np @@ -58,16 +57,6 @@ def it_knows_when_mpp_supercedes_level( assert random_tiler.level == expected_level assert random_tiler.mpp == expected_mpp - @pytest.mark.parametrize( - "mpp, fixed_tile_size", ((None, (512, 512)), (0.5, (1024, 1024))) - ) - def it_can_fix_tile_size_if_mpp(self, mpp, fixed_tile_size): - fake_slide = namedtuple("fake_slide", ["base_mpp"]) - random_tiler = RandomTiler((512, 512), 10, mpp=mpp) - random_tiler._set_proper_tile_size(fake_slide(0.25)) - - assert random_tiler.tile_size == fixed_tile_size - def but_it_has_wrong_tile_size_value(self): with pytest.raises(ValueError) as err: RandomTiler((512, -1), 10, 0) @@ -459,20 +448,6 @@ def mpp_supercedes_level(self, level, mpp, expected_level, expected_mpp): assert tiler.level == expected_level assert tiler.mpp == expected_mpp - @pytest.mark.parametrize( - "mpp, fixed_tile_size, fixed_overlap", - ((None, (512, 512), 32), (0.5, (1024, 1024), 64)), - ) - def it_can_fix_tile_size_and_overlap_if_mpp( - self, mpp, fixed_tile_size, fixed_overlap - ): - fake_slide = namedtuple("fake_slide", ["base_mpp"]) - tiler = GridTiler((512, 512), pixel_overlap=32, mpp=mpp) - tiler._set_proper_tile_size_and_overlap(fake_slide(0.25)) - - assert tiler.tile_size == fixed_tile_size - assert tiler.pixel_overlap == fixed_overlap - def but_it_has_wrong_tile_size_value(self): with pytest.raises(ValueError) as err: GridTiler((512, -1)) From cc787d07f9508cba95bc6d183a9766d814cba147 Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Sun, 29 Aug 2021 23:02:24 +0200 Subject: [PATCH 41/47] tiny slide.py reformat --- histolab/slide.py | 1 - 1 file changed, 1 deletion(-) diff --git a/histolab/slide.py b/histolab/slide.py index bd6c09e3e..16e4ec5e4 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -49,7 +49,6 @@ import large_image LARGEIMAGE_IS_INSTALLED = True - except (ModuleNotFoundError, ImportError): # pragma: no cover LARGEIMAGE_IS_INSTALLED = False From 419402bffe9da3b464f0b4338b85965438ace69e Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Sun, 29 Aug 2021 23:08:10 +0200 Subject: [PATCH 42/47] fix flake8 failures --- histolab/slide.py | 2 -- histolab/tiler.py | 9 ++++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 16e4ec5e4..bed3a8eae 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -41,8 +41,6 @@ if TYPE_CHECKING: from .masks import BinaryMask - -# If possible, use large_image because it extends openslide to more formats try: from io import BytesIO diff --git a/histolab/tiler.py b/histolab/tiler.py index f1202736f..6ae77919e 100644 --- a/histolab/tiler.py +++ b/histolab/tiler.py @@ -327,7 +327,8 @@ class GridTiler(Tiler): suffix : str, optional Suffix to be added to the tile filename. Default is '.png' mpp : float, optional - Micron per pixel resolution of extracted tiles. Takes precedence over level. Default is None. + Micron per pixel resolution of extracted tiles. Takes precedence over level. + Default is None. """ def __init__( @@ -647,7 +648,8 @@ class RandomTiler(Tiler): Maximum number of iterations performed when searching for eligible (if ``check_tissue=True``) tiles. Must be grater than or equal to ``n_tiles``. mpp : float, optional - Micron per pixel resolution. If provided, takes precedence over level. Default is None. + Micron per pixel resolution. If provided, takes precedence over level. + Default is None. """ def __init__( @@ -869,7 +871,8 @@ class ScoreTiler(GridTiler): suffix : str, optional Suffix to be added to the tile filename. Default is '.png' mpp : float, optional. - Micron per pixel resolution. If provided, takes precedence over level. Default is None. + Micron per pixel resolution. If provided, takes precedence over level. + Default is None. """ def __init__( From 434da279209b5e041916afbff351bdfb4f6e3c71 Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Mon, 30 Aug 2021 01:46:20 +0200 Subject: [PATCH 43/47] fix other weird tests --- histolab/slide.py | 4 +- tests/integration/test_slide.py | 29 ++----------- tests/integration/test_tiler.py | 40 ------------------ tests/unit/test_slide.py | 72 +++++++++++++++++++++++++++------ 4 files changed, 66 insertions(+), 79 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index bed3a8eae..5ed82c6b4 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -134,7 +134,7 @@ def base_mpp(self) -> float: and we cannot determine the magnification otherwise. """ if self._use_largeimage: - if self._metadata["mm_x"] is not None: + if self._metadata.get("mm_x") is not None: return self._metadata["mm_x"] * (10 ** 3) raise ValueError( "Unknown scan resolution! This slide is missing metadata " @@ -151,7 +151,7 @@ def base_mpp(self) -> float: if ( "tiff.XResolution" in self.properties - and self.properties["tiff.ResolutionUnit"] == "centimeter" + and self.properties.get("tiff.ResolutionUnit") == "centimeter" ): return 1e4 / float(self.properties["tiff.XResolution"]) diff --git a/tests/integration/test_slide.py b/tests/integration/test_slide.py index cb4299752..9b64c45b4 100644 --- a/tests/integration/test_slide.py +++ b/tests/integration/test_slide.py @@ -27,32 +27,11 @@ def it_knows_its_name(self): assert name == ntpath.basename(SVS.CMU_1_SMALL_REGION).split(".")[0] - @pytest.mark.parametrize( - "use_largeimage, slide_props", - [ - (True, {}), - (False, {}), - (False, {"aperio.MPP": 0.499}), - ( - False, - { - "tiff.XResolution": 20040.080160320642, - "tiff.ResolutionUnit": "centimeter", - }, - ), - ], - ) - def it_knows_its_base_mpp(self, use_largeimage, slide_props): - slide = Slide( - SVS.CMU_1_SMALL_REGION, - os.path.join(SVS.CMU_1_SMALL_REGION, "processed"), - use_largeimage=use_largeimage, - ) - slide.properties.update(slide_props) - - mpp = slide.base_mpp + @pytest.mark.parametrize("use_largeimage", (True, False)) + def it_knows_its_base_mpp(self, use_largeimage, tmpdir): + slide = Slide(SVS.CMU_1_SMALL_REGION, tmpdir, use_largeimage=use_largeimage) - assert mpp == 0.499 + assert slide.base_mpp == 0.499 def it_calculate_resampled_nparray_from_small_region_svs_image(self): slide = Slide( diff --git a/tests/integration/test_tiler.py b/tests/integration/test_tiler.py index 776e4efaa..dfa020016 100644 --- a/tests/integration/test_tiler.py +++ b/tests/integration/test_tiler.py @@ -194,46 +194,6 @@ def test_extract_tiles_respecting_the_given_tile_size( for tile in os.listdir(processed_path): assert Image.open(os.path.join(processed_path, tile)).size == tile_size - @pytest.mark.parametrize( - "fixture_slide, n_tiles, mpp, expected_tile_sizes", - ( - ( - SVS.CMU_1_SMALL_REGION, - 3, - 0.5, - ((127, 127), (127, 127), (127, 127)), - ), - ( - SVS.CMU_1_SMALL_REGION, - 3, - 0.25, - ((127, 127), (129, 127), (127, 127)), - ), - ), - ) - def test_extract_tiles_at_mpp_not_respecting_given_tile_size( - self, tmpdir, fixture_slide, n_tiles, mpp, expected_tile_sizes - ): - processed_path = os.path.join(tmpdir, "processed") - slide = Slide(fixture_slide, processed_path, use_largeimage=True) - random_tiles_extractor = RandomTiler( - tile_size=(128, 128), - n_tiles=n_tiles, - seed=0, - check_tissue=True, - mpp=mpp, - ) - binary_mask = BiggestTissueBoxMask() - - random_tiles_extractor.final_tile_size = None - random_tiles_extractor.extract(slide, binary_mask) - - for tidx, tile in enumerate(os.listdir(processed_path)): - assert ( - Image.open(os.path.join(processed_path, tile)).size - == expected_tile_sizes[tidx] - ) - @pytest.mark.parametrize( "fixture_slide, tile_size", ( diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 359ae9055..47a48f088 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -16,6 +16,7 @@ from histolab.exceptions import LevelError, MayNeedLargeImageError, SlidePropertyError from histolab.slide import Slide, SlideSet +from ..fixtures import SVS from ..unitutil import ( ANY, PILIMG, @@ -101,28 +102,51 @@ def it_knows_its_name(self, slide_path, expected_value): assert name == expected_value - def it_raises_error_with_unknown_mpp_without_largeimage(self, tmpdir): - slide, _ = base_test_slide(tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240) + @pytest.mark.parametrize( + "use_largeimage, properties, metadata, expected_value", + ( + (True, {}, {"mm_x": 1}, 1000.0), + (False, {"openslide.mpp-x": 33}, None, 33.0), + (False, {"aperio.MPP": 33}, None, 33.0), + ( + False, + {"tiff.XResolution": 1000, "tiff.ResolutionUnit": "centimeter"}, + None, + 10.0, + ), + ), + ) + def it_knows_its_base_mpp( + self, request, use_largeimage, properties, metadata, expected_value + ): + slide = Slide("foo", "bar", use_largeimage=use_largeimage) + property_mock(request, Slide, "properties", return_value=properties) + property_mock(request, Slide, "_metadata", return_value=metadata) + + assert slide.base_mpp == expected_value + + def but_it_raises_large_image_error_with_unknown_mpp_without_largeimage( + self, request + ): + slide = Slide("foo", "bar", use_largeimage=False) + property_mock(request, Slide, "properties", return_value={}) with pytest.raises(MayNeedLargeImageError) as err: slide.base_mpp - assert isinstance(err.value, MayNeedLargeImageError) assert str(err.value) == ( "Unknown scan magnification! This slide format may be best " "handled using the large_image module. Consider setting " "use_largeimage to True when instantiating this Slide." ) - def it_raises_error_with_unknown_mpp_with_largeimage(self, tmpdir): - slide, _ = base_test_slide( - tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=True - ) + def and_it_raises_value_error_with_unknown_mpp_with_largeimage(self, request): + slide = Slide("foo", "bar", use_largeimage=True) + property_mock(request, Slide, "_metadata", return_value={}) with pytest.raises(ValueError) as err: slide.base_mpp - assert isinstance(err.value, ValueError) assert str(err.value) == ( "Unknown scan resolution! This slide is missing metadata " "needed for calculating the scanning resolution. Without " @@ -156,15 +180,39 @@ def it_knows_its_dimensions(self, tmpdir): assert slide_dims == (500, 500) - def it_raises_error_if_bad_args_for_extract_tile(self, tmpdir): - slide, _ = base_test_slide( - tmpdir, PILIMG.RGBA_COLOR_500X500_155_249_240, use_largeimage=True + def it_extracts_tile_with_mpp_without_image_resizing(self, tmpdir, request): + slide = Slide(SVS.CMU_1_SMALL_REGION, tmpdir, use_largeimage=True) + property_mock(request, PIL.Image.Image, "size", return_value=(128, 128)) + + tile = slide.extract_tile(CP(0, 10, 0, 10), (128, 128), level=None, mpp=0.25) + + assert tile.image.size == (128, 128) + + def but_it_raises_a_runtime_error_when_tile_size_and_mpp_are_not_compatible( + self, tmpdir, request + ): + slide = Slide(SVS.CMU_1_SMALL_REGION, tmpdir, use_largeimage=True) + property_mock(request, PIL.Image.Image, "size", return_value=(100, 100)) + + with pytest.raises(RuntimeError) as err: + slide.extract_tile(CP(0, 10, 0, 10), (128, 128), level=None, mpp=0.25) + + assert ( + str(err.value) + == "The tile you requested at a resolution of 0.25 MPP has a size of " + "(100, 100), yet you specified a final `tile_size` of (128, 128), " + "which is a very different value. When you set `mpp`, " + "the `tile_size` parameter is used to resize fetched tiles if they are " + "off by just 5 pixels due to rounding differences etc. Please check if " + "you requested the right `mpp` and/or `tile_size`." ) + def it_raises_error_if_bad_args_for_extract_tile(self): + slide = Slide("foo", "bar", use_largeimage=True) + with pytest.raises(ValueError) as err: slide.extract_tile(CP(0, 10, 0, 10), (10, 10), level=None, mpp=None) - assert isinstance(err.value, ValueError) assert str(err.value) == "Either level or mpp must be provided!" def it_knows_its_resampled_dimensions(self, dimensions_): From dbe52f1256a91ec05a7f86160b525b90541edf75 Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Mon, 30 Aug 2021 01:51:51 +0200 Subject: [PATCH 44/47] fix lgtm alert --- histolab/data/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/histolab/data/__init__.py b/histolab/data/__init__.py index be87725a3..dc4bfdf81 100644 --- a/histolab/data/__init__.py +++ b/histolab/data/__init__.py @@ -67,7 +67,7 @@ def file_hash(fname: str, alg: str = "sha256") -> str: def _create_image_fetcher(): try: - import pooch + from pooch import create, os_cache except ImportError: # Without pooch, fallback on the standard data directory # which for now, includes a few limited data samples @@ -77,14 +77,14 @@ def _create_image_fetcher(): url = "https://github.com/histolab/histolab/raw/{version}/histolab/" # Create a new friend to manage your sample data storage - image_fetcher = pooch.create( + image_fetcher = create( # Pooch uses appdirs to select an appropriate directory for the cache # on each platform. # https://github.com/ActiveState/appdirs # On linux this converges to # '$HOME/.cache/histolab-image' # With a version qualifier - path=pooch.os_cache("histolab-images"), + path=os_cache("histolab-images"), base_url=url, version=pooch_version, env="HISTOLAB_DATADIR", From bf8485c84342f59351394f128754877f913ec7ba Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Mon, 30 Aug 2021 16:34:35 +0200 Subject: [PATCH 45/47] address alessia's comments pt 2 --- tests/unit/test_slide.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_slide.py b/tests/unit/test_slide.py index 47a48f088..db6d96a1c 100644 --- a/tests/unit/test_slide.py +++ b/tests/unit/test_slide.py @@ -261,10 +261,10 @@ def it_knows_its_thumbnail_size(self, tmpdir): def it_raises_error_if_thumbnail_size_and_use_largeimage(self): slide = Slide("/a/b/foo", "processed", use_largeimage=True) + with pytest.raises(TileSourceException) as err: slide._thumbnail_size - assert isinstance(err.value, TileSourceException) assert str(err.value) == "No available tilesource for /a/b/foo" def it_creates_a_correct_slide_object(self, tmpdir): From 6e085ca063319491906af4e0004d4b957d05a601 Mon Sep 17 00:00:00 2001 From: Arbitrio Date: Mon, 30 Aug 2021 22:42:27 +0200 Subject: [PATCH 46/47] address alessia's comments pt3 --- histolab/slide.py | 1 - tests/unit/test_tiler.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/histolab/slide.py b/histolab/slide.py index 5ed82c6b4..6a3533980 100644 --- a/histolab/slide.py +++ b/histolab/slide.py @@ -241,7 +241,6 @@ def extract_tile( format=large_image.tilesource.TILE_FORMAT_PIL, jpegQuality=100, ) - image = image.convert("RGB") # Sometimes when mpp kwarg is used, the image size is off from # what the user expects by a couple of pixels if tile_size is not None and not tile_size == image.size: diff --git a/tests/unit/test_tiler.py b/tests/unit/test_tiler.py index c179b1c67..488644fc8 100644 --- a/tests/unit/test_tiler.py +++ b/tests/unit/test_tiler.py @@ -441,7 +441,9 @@ def it_constructs_from_args(self, level, mpp, request): "level, mpp, expected_level, expected_mpp", ((2, None, 2, None), (None, 0.5, 0, 0.5), (2, 0.5, 0, 0.5)), ) - def mpp_supercedes_level(self, level, mpp, expected_level, expected_mpp): + def it_knows_when_mpp_supercedes_level( + self, level, mpp, expected_level, expected_mpp + ): tiler = GridTiler((512, 512), level=level, mpp=mpp) From 30c19d4ee6847d544d6708dad2a9e235b205ea4c Mon Sep 17 00:00:00 2001 From: alessiamarcolini <98marcolini@gmail.com> Date: Tue, 16 Nov 2021 14:39:09 +0100 Subject: [PATCH 47/47] Fix error message --- tests/integration/test_tiler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_tiler.py b/tests/integration/test_tiler.py index dfa020016..5bc0114b7 100644 --- a/tests/integration/test_tiler.py +++ b/tests/integration/test_tiler.py @@ -222,7 +222,7 @@ def test_raise_error_if_mpp_and_discordant_tile_size( assert isinstance(err.value, RuntimeError) assert str(err.value) == ( f"The tile you requested at a resolution of 0.5 MPP " - f"has a size of (128, 126), yet you specified a " + f"has a size of (127, 127), yet you specified a " f"final `tile_size` of {tile_size}, which is a very " "different value. When you set `mpp`, the `tile_size` " "parameter is used to resize fetched tiles if they "