Skip to content

Commit

Permalink
Merge b2cef23 into d5945e1
Browse files Browse the repository at this point in the history
  • Loading branch information
alessiamarcolini committed Aug 5, 2020
2 parents d5945e1 + b2cef23 commit b5e7915
Show file tree
Hide file tree
Showing 14 changed files with 168 additions and 170 deletions.
1 change: 0 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ def ascii_bytes_from(path, *paths):
"Pillow",
"scikit-image",
"scipy",
"sparse",
"openslide-wrapper",
"typing_extensions",
]
Expand Down
14 changes: 14 additions & 0 deletions src/histolab/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class LevelError(Exception):
"""Raised when a requested level is not available"""

def __init__(self, *args) -> None:
if args:
self.message = args[0]
else:
self.message = None

def __str__(self):
if self.message:
return self.message
else:
return ""
28 changes: 19 additions & 9 deletions src/histolab/slide.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
import numpy as np
import openslide
import PIL
import sparse

from .exceptions import LevelError
from .filters import image_filters as imf
from .filters import morphological_filters as mof
from .tile import Tile
Expand All @@ -42,7 +42,6 @@
polygon_to_mask_array,
region_coordinates,
regions_from_binary_mask,
resize_mask,
scale_coordinates,
)

Expand Down Expand Up @@ -97,20 +96,32 @@ def level_dimensions(self, level: int = 0) -> Tuple[int, int]:
try:
return self._wsi.level_dimensions[level]
except IndexError:
raise ValueError(
raise LevelError(
f"Level {level} not available. Number of available levels: "
f"{len(self._wsi.level_dimensions)}"
)

@lazyproperty
def levels(self) -> List[int]:
"""Return the slide's available levels
Returns
-------
List[int]
The levels available
"""
return list(range(len(self._wsi.level_dimensions)))

@lazyproperty
@lru_cache(maxsize=100)
def biggest_tissue_box_mask(self) -> sparse._coo.core.COO:
"""Return the binary mask of the box containing the max area of tissue.
def biggest_tissue_box_mask(self) -> np.ndarray:
"""Return the thumbnail binary mask of the box containing the max tissue area.
Returns
-------
mask: sparse._coo.core.COO
Binary mask of the box containing the max area of tissue.
mask: np.ndarray
Binary mask of the box containing the max area of tissue. The dimensions are
those of the thumbnail.
"""
thumb = self._wsi.get_thumbnail(self._thumbnail_size)
Expand All @@ -123,8 +134,7 @@ def biggest_tissue_box_mask(self) -> sparse._coo.core.COO:
thumb_bbox_mask = polygon_to_mask_array(
self._thumbnail_size, biggest_region_coordinates
)
thumb_bbox_mask_sparse = sparse.COO(thumb_bbox_mask)
return resize_mask(thumb_bbox_mask_sparse, self.dimensions)
return thumb_bbox_mask

def extract_tile(self, coords: CoordinatePair, level: int) -> Tile:
"""Extract a tile of the image at the selected level.
Expand Down
87 changes: 40 additions & 47 deletions src/histolab/tiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from typing import Tuple

import numpy as np
import sparse

from histolab.exceptions import LevelError

from .slide import Slide
from .tile import Tile
Expand All @@ -11,7 +12,6 @@
lru_cache,
region_coordinates,
regions_from_binary_mask,
resize_mask,
scale_coordinates,
)

Expand All @@ -28,8 +28,8 @@ class Tiler(Protocol):
tile_size: int

@lru_cache(maxsize=100)
def box_mask(self, slide: Slide) -> sparse._coo.core.COO:
"""Return binary mask at level 0 of the box to consider for tiles extraction.
def box_mask_thumb(self, slide: Slide) -> np.ndarray:
"""Return binary mask at thumbnail dimensions of the box for tiles extraction.
The mask pixels set to True will be the ones corresponding to the tissue box.
Expand All @@ -40,38 +40,12 @@ def box_mask(self, slide: Slide) -> sparse._coo.core.COO:
Returns
-------
sparse._coo.core.COO
Extraction mask at level 0
np.ndarray
Extraction mask at thumbnail dimensions
"""

return slide.biggest_tissue_box_mask

@lru_cache(maxsize=100)
def box_mask_lvl(self, slide: Slide) -> sparse._coo.core.COO:
"""Return binary mask at target level of the box to consider for the extraction.
The mask pixels set to True will be the ones corresponding to the tissue box.
Parameters
----------
slide : Slide
The Slide from which to extract the extraction mask
Returns
-------
sparse._coo.core.COO
Extraction mask at target level
"""

box_mask_wsi = self.box_mask(slide)

if self.level != 0:
return resize_mask(
box_mask_wsi, target_dimensions=slide.level_dimensions(self.level),
)
else:
return box_mask_wsi

def _tile_filename(
self, tile_wsi_coords: CoordinatePair, tiles_counter: int
) -> str:
Expand Down Expand Up @@ -159,7 +133,7 @@ def level(self) -> int:
@level.setter
def level(self, level_: int):
if level_ < 0:
raise ValueError(f"Level cannot be negative ({level_})")
raise LevelError(f"Level cannot be negative ({level_})")
self._valid_level = level_

def extract(self, slide: Slide):
Expand All @@ -172,6 +146,12 @@ def extract(self, slide: Slide):
slide : Slide
Slide from which to extract the tiles
"""
if self.level not in slide.levels:
raise LevelError(
f"Level {self.level} not available. Number of available levels: "
f"{len(slide.levels)}"
)

grid_tiles = self._grid_tiles_generator(slide)

tiles_counter = 0
Expand All @@ -181,7 +161,7 @@ def extract(self, slide: Slide):
tile.save(tile_filename)
print(f"\t Tile {tiles_counter} saved: {tile_filename}")

print(f"{tiles_counter+1} Grid Tiles have been saved.")
print(f"{tiles_counter} Grid Tiles have been saved.")

def _grid_coordinates_from_bbox_coordinates(
self, bbox_coordinates: CoordinatePair, slide: Slide
Expand Down Expand Up @@ -242,16 +222,21 @@ def _grid_coordinates_generator(self, slide: Slide) -> CoordinatePair:
Iterator[CoordinatePair]
Iterator of tiles' CoordinatePair
"""
box_mask_lvl = self.box_mask_lvl(slide)
box_mask_thumb = self.box_mask_thumb(slide)

regions = regions_from_binary_mask(box_mask_lvl.todense())
regions = regions_from_binary_mask(box_mask_thumb)
for region in regions: # at the moment there is only one region
bbox_coordinates = region_coordinates(region)
bbox_coordinates_thumb = region_coordinates(region)
bbox_coordinates = scale_coordinates(
bbox_coordinates_thumb,
box_mask_thumb.shape[::-1],
slide.level_dimensions(self.level),
)
yield from self._grid_coordinates_from_bbox_coordinates(
bbox_coordinates, slide
)

def _grid_tiles_generator(self, slide: Slide) -> (Tile, CoordinatePair):
def _grid_tiles_generator(self, slide: Slide) -> Tuple[Tile, CoordinatePair]:
"""Generator of tiles arranged in a grid.
Parameters
Expand Down Expand Up @@ -377,7 +362,7 @@ def level(self) -> int:
@level.setter
def level(self, level_: int):
if level_ < 0:
raise ValueError(f"Level cannot be negative ({level_})")
raise LevelError(f"Level cannot be negative ({level_})")
self._valid_level = level_

@property
Expand Down Expand Up @@ -427,24 +412,32 @@ def _random_tile_coordinates(self, slide: Slide) -> CoordinatePair:
CoordinatePair
Random tile Coordinates at level 0
"""
box_mask_lvl = self.box_mask_lvl(slide)
box_mask_thumb = self.box_mask_thumb(slide)
tile_w_lvl, tile_h_lvl = self.tile_size

x_ul_lvl = np.random.choice(sparse.where(box_mask_lvl)[1])
y_ul_lvl = np.random.choice(sparse.where(box_mask_lvl)[0])
x_ul_lvl = np.random.choice(np.where(box_mask_thumb)[1])
y_ul_lvl = np.random.choice(np.where(box_mask_thumb)[0])

# Scale tile dimensions to thumbnail dimensions
tile_w_thumb = (
tile_w_lvl * box_mask_thumb.shape[1] / slide.level_dimensions(self.level)[0]
)
tile_h_thumn = (
tile_h_lvl * box_mask_thumb.shape[0] / slide.level_dimensions(self.level)[1]
)

x_br_lvl = x_ul_lvl + tile_w_lvl
y_br_lvl = y_ul_lvl + tile_h_lvl
x_br_lvl = x_ul_lvl + tile_w_thumb
y_br_lvl = y_ul_lvl + tile_h_thumn

tile_wsi_coords = scale_coordinates(
reference_coords=CoordinatePair(x_ul_lvl, y_ul_lvl, x_br_lvl, y_br_lvl),
reference_size=slide.level_dimensions(level=self.level),
target_size=slide.level_dimensions(level=0),
reference_size=box_mask_thumb.shape[::-1],
target_size=slide.dimensions,
)

return tile_wsi_coords

def _random_tiles_generator(self, slide: Slide) -> (Tile, CoordinatePair):
def _random_tiles_generator(self, slide: Slide) -> Tuple[Tile, CoordinatePair]:
"""Generate Random Tiles within a slide box.
Stops if:
Expand Down
24 changes: 0 additions & 24 deletions src/histolab/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
import numpy as np
import PIL
import PIL.ImageDraw
import sparse
from skimage.measure import label, regionprops

from .types import CoordinatePair, Region
Expand Down Expand Up @@ -182,29 +181,6 @@ def region_coordinates(region: Region) -> CoordinatePair:
return CoordinatePair(x_ul, y_ul, x_br, y_br)


def resize_mask(
input_mask: sparse._coo.core.COO, target_dimensions: Tuple[int, int]
) -> sparse._coo.core.COO:
"""Resize mask to ``target_dimensions``.
Parameters
----------
input_mask : sparse.COO
Input mask
target_dimensions : List[int, int]
Dimensions of the resized mask
Returns
-------
sparse._coo.core.COO
Resized mask
"""
input_mask_img = PIL.Image.fromarray(input_mask.todense())
resized_mask_img = input_mask_img.resize(target_dimensions)
resized_mask_arr = np.array(resized_mask_img).astype(bool)
return sparse.COO(resized_mask_arr)


def apply_mask_image(img: PIL.Image.Image, mask: np.ndarray) -> PIL.Image.Image:
"""Mask image with the provided binary mask.
Expand Down
4 changes: 0 additions & 4 deletions tests/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import numpy as np
import sparse

# ============== IMAGES ==============
IMAGE1_GREY = np.array(
Expand Down Expand Up @@ -236,6 +235,3 @@
[True, True, False, False, False, True, True, False, True, False],
]
)

SPARSE_BASE_MASK = sparse.COO(BASE_MASK)
SPARSE_COMPLEX_MASK = sparse.COO(COMPLEX_MASK)
Binary file not shown.
Binary file not shown.
56 changes: 56 additions & 0 deletions tests/unit/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import pytest

from histolab import exceptions as exp

from ..unitutil import ANY, initializer_mock


class DescribeExceptions(object):
@pytest.mark.parametrize("args", ("error", ["hello", "error"], None))
def it_can_construct_from_args_level_error(self, request, args):
_init = initializer_mock(request, exp.LevelError)

if type(args) == list:
level_error = exp.LevelError(*args)
else:
level_error = exp.LevelError(args)

if type(args) == list:
_init.assert_called_once_with(ANY, *args)
else:
_init.assert_called_once_with(ANY, args)
assert isinstance(level_error, exp.LevelError)
assert isinstance(level_error, Exception)

@pytest.mark.parametrize("args", ("error", ["hello", "error"], None))
def it_knows_its_message_level_error(self, request, args):
if type(args) == list:
level_error = exp.LevelError(*args)
else:
level_error = exp.LevelError(args)

message = level_error.message

if args:
if type(args) == str:
assert type(message) == str
assert message == args
if type(args) == list:
assert type(message) == str
assert message == list(args)[0]
else:
assert message is None

@pytest.mark.parametrize("args", ("error", ["hello", "error"], None))
def it_knows_its_str_level_error(self, request, args):
if type(args) == list:
level_error = exp.LevelError(*args)
else:
level_error = exp.LevelError(args)

s = str(level_error)

if not args:
assert s == ""
else:
assert s == level_error.message

0 comments on commit b5e7915

Please sign in to comment.