Skip to content

Commit

Permalink
type the deepzoom generator
Browse files Browse the repository at this point in the history
Signed-off-by: Sam Maxwell <sam@groundtruthlabs.com>
  • Loading branch information
sammaxwellxyz committed Feb 26, 2024
1 parent 149f470 commit 0b6e742
Show file tree
Hide file tree
Showing 2 changed files with 98 additions and 64 deletions.
144 changes: 89 additions & 55 deletions openslide/deepzoom.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from io import BytesIO
import math
from typing import List, Tuple
from xml.etree.ElementTree import Element, ElementTree, SubElement

from PIL import Image
Expand All @@ -44,7 +45,13 @@ class DeepZoomGenerator:
openslide.PROPERTY_NAME_BOUNDS_HEIGHT,
)

def __init__(self, osr, tile_size=254, overlap=1, limit_bounds=False):
def __init__(
self,
osr: openslide.AbstractSlide,
tile_size: int = 254,
overlap: int = 1,
limit_bounds: bool = False,
):
"""Create a DeepZoomGenerator wrapping an OpenSlide object.
osr: a slide object.
Expand Down Expand Up @@ -79,13 +86,13 @@ def __init__(self, osr, tile_size=254, overlap=1, limit_bounds=False):
for prop, l0_lim in zip(self.BOUNDS_SIZE_PROPS, osr.dimensions)
)
# Dimensions of active area
self._l_dimensions = tuple(
tuple(
int(math.ceil(l_lim * scale))
for l_lim, scale in zip(l_size, size_scale)
self._l_dimensions = [
(
int(math.ceil(l_size[0] * size_scale[0])),
int(math.ceil(l_size[1] * size_scale[1])),
)
for l_size in osr.level_dimensions
)
]
else:
self._l_dimensions = osr.level_dimensions
self._l0_offset = (0, 0)
Expand All @@ -94,25 +101,28 @@ def __init__(self, osr, tile_size=254, overlap=1, limit_bounds=False):
z_size = self._l0_dimensions
z_dimensions = [z_size]
while z_size[0] > 1 or z_size[1] > 1:
z_size = tuple(max(1, int(math.ceil(z / 2))) for z in z_size)
z_size = (
max(1, int(math.ceil(z_size[0] / 2))),
max(1, int(math.ceil(z_size[1] / 2))),
)
z_dimensions.append(z_size)
self._z_dimensions = tuple(reversed(z_dimensions))
self._z_dimensions = list(reversed(z_dimensions))

# Tile
def tiles(z_lim):
def tiles(z_lim: int) -> int:
return int(math.ceil(z_lim / self._z_t_downsample))

self._t_dimensions = tuple(
self._t_dimensions = [
(tiles(z_w), tiles(z_h)) for z_w, z_h in self._z_dimensions
)
]

# Deep Zoom level count
self._dz_levels = len(self._z_dimensions)

# Total downsamples for each Deep Zoom level
l0_z_downsamples = tuple(
l0_z_downsamples: List[int] = [
2 ** (self._dz_levels - dz_level - 1) for dz_level in range(self._dz_levels)
)
]

# Preferred slide levels for each Deep Zoom level
self._slide_from_dz_level = tuple(
Expand All @@ -121,19 +131,19 @@ def tiles(z_lim):

# Piecewise downsamples
self._l0_l_downsamples = self._osr.level_downsamples
self._l_z_downsamples = tuple(
self._l_z_downsamples = [
l0_z_downsamples[dz_level]
/ self._l0_l_downsamples[self._slide_from_dz_level[dz_level]]
for dz_level in range(self._dz_levels)
)
]

# Slide background color
self._bg_color = '#' + self._osr.properties.get(
openslide.PROPERTY_NAME_BACKGROUND_COLOR, 'ffffff'
self._bg_color = "#" + self._osr.properties.get(
openslide.PROPERTY_NAME_BACKGROUND_COLOR, "ffffff"
)

def __repr__(self):
return '{}({!r}, tile_size={!r}, overlap={!r}, limit_bounds={!r})'.format(
def __repr__(self) -> str:
return "{}({!r}, tile_size={!r}, overlap={!r}, limit_bounds={!r})".format(
self.__class__.__name__,
self._osr,
self._z_t_downsample,
Expand All @@ -142,26 +152,26 @@ def __repr__(self):
)

@property
def level_count(self):
def level_count(self) -> int:
"""The number of Deep Zoom levels in the image."""
return self._dz_levels

@property
def level_tiles(self):
def level_tiles(self) -> List[Tuple[int, int]]:
"""A list of (tiles_x, tiles_y) tuples for each Deep Zoom level."""
return self._t_dimensions

@property
def level_dimensions(self):
def level_dimensions(self) -> List[Tuple[int, int]]:
"""A list of (pixels_x, pixels_y) tuples for each Deep Zoom level."""
return self._z_dimensions

@property
def tile_count(self):
def tile_count(self) -> int:
"""The total number of Deep Zoom tiles in the image."""
return sum(t_cols * t_rows for t_cols, t_rows in self._t_dimensions)

def get_tile(self, level, address):
def get_tile(self, level: int, address: Tuple[int, int]) -> Image.Image:
"""Return an RGB PIL.Image for a tile.
level: the Deep Zoom level.
Expand All @@ -171,25 +181,27 @@ def get_tile(self, level, address):
# Read tile
args, z_size = self._get_tile_info(level, address)
tile = self._osr.read_region(*args)
profile = tile.info.get('icc_profile')
profile = tile.info.get("icc_profile")

# Apply on solid background
bg = Image.new('RGB', tile.size, self._bg_color)
bg = Image.new("RGB", tile.size, self._bg_color)
tile = Image.composite(tile, bg, tile)

# Scale to the correct size
if tile.size != z_size:
# Image.Resampling added in Pillow 9.1.0
# Image.LANCZOS removed in Pillow 10
tile.thumbnail(z_size, getattr(Image, 'Resampling', Image).LANCZOS)
tile.thumbnail(z_size, getattr(Image, "Resampling", Image).LANCZOS)

# Reference ICC profile
if profile is not None:
tile.info['icc_profile'] = profile
tile.info["icc_profile"] = profile

return tile

def _get_tile_info(self, dz_level, t_location):
def _get_tile_info(
self, dz_level: int, t_location: Tuple[int, int]
) -> Tuple[Tuple[Tuple[int, int], int, Tuple[int, int]], Tuple[int, int]]:
# Check parameters
if dz_level < 0 or dz_level >= self._dz_levels:
raise ValueError("Invalid level")
Expand All @@ -208,42 +220,62 @@ def _get_tile_info(self, dz_level, t_location):
)

# Get final size of the tile
z_size = tuple(
min(self._z_t_downsample, z_lim - self._z_t_downsample * t) + z_tl + z_br
for t, z_lim, z_tl, z_br in zip(
t_location, self._z_dimensions[dz_level], z_overlap_tl, z_overlap_br
z_size = (
min(
self._z_t_downsample,
self._z_dimensions[dz_level][0] - self._z_t_downsample * t_location[0],
)
+ z_overlap_tl[0]
+ z_overlap_br[0],
min(
self._z_t_downsample,
self._z_dimensions[dz_level][1] - self._z_t_downsample * t_location[1],
)
+ z_overlap_tl[1]
+ z_overlap_br[1],
)

# Obtain the region coordinates
z_location = [self._z_from_t(t) for t in t_location]
l_location = [
self._l_from_z(dz_level, z - z_tl)
for z, z_tl in zip(z_location, z_overlap_tl)
]
z_location = (self._z_from_t(t_location[0]), self._z_from_t(t_location[1]))
l_location = (
self._l_from_z(dz_level, z_location[0] - z_overlap_tl[0]),
self._l_from_z(dz_level, z_location[1] - z_overlap_tl[1]),
)
# Round location down and size up, and add offset of active area
l0_location = tuple(
int(self._l0_from_l(slide_level, l) + l0_off)
for l, l0_off in zip(l_location, self._l0_offset)
l0_location = (
int(self._l0_from_l(slide_level, l_location[0]) + self._l0_offset[0]),
int(self._l0_from_l(slide_level, l_location[1]) + self._l0_offset[1]),
)
l_size = tuple(
int(min(math.ceil(self._l_from_z(dz_level, dz)), l_lim - math.ceil(l)))
for l, dz, l_lim in zip(l_location, z_size, self._l_dimensions[slide_level])
l_size = (
int(
min(
math.ceil(self._l_from_z(dz_level, z_size[0])),
self._l_dimensions[slide_level][0] - math.ceil(l_location[0]),
)
),
int(
min(
math.ceil(self._l_from_z(dz_level, z_size[1])),
self._l_dimensions[slide_level][1] - math.ceil(l_location[1]),
)
),
)

# Return read_region() parameters plus tile size for final scaling
return ((l0_location, slide_level, l_size), z_size)

def _l0_from_l(self, slide_level, l):
def _l0_from_l(self, slide_level: int, l: float) -> float:
return self._l0_l_downsamples[slide_level] * l

def _l_from_z(self, dz_level, z):
def _l_from_z(self, dz_level: int, z: int) -> float:
return self._l_z_downsamples[dz_level] * z

def _z_from_t(self, t):
def _z_from_t(self, t: int) -> int:
return self._z_t_downsample * t

def get_tile_coordinates(self, level, address):
def get_tile_coordinates(
self, level: int, address: Tuple[int, int]
) -> Tuple[Tuple[int, int], int, Tuple[int, int]]:
"""Return the OpenSlide.read_region() arguments for the specified tile.
Most users should call get_tile() rather than calling
Expand All @@ -254,28 +286,30 @@ def get_tile_coordinates(self, level, address):
tuple."""
return self._get_tile_info(level, address)[0]

def get_tile_dimensions(self, level, address):
def get_tile_dimensions(
self, level: int, address: Tuple[int, int]
) -> Tuple[int, int]:
"""Return a (pixels_x, pixels_y) tuple for the specified tile.
level: the Deep Zoom level.
address: the address of the tile within the level as a (col, row)
tuple."""
return self._get_tile_info(level, address)[1]

def get_dzi(self, format):
def get_dzi(self, format: str) -> str:
"""Return a string containing the XML metadata for the .dzi file.
format: the format of the individual tiles ('png' or 'jpeg')"""
image = Element(
'Image',
"Image",
TileSize=str(self._z_t_downsample),
Overlap=str(self._z_overlap),
Format=format,
xmlns='http://schemas.microsoft.com/deepzoom/2008',
xmlns="http://schemas.microsoft.com/deepzoom/2008",
)
w, h = self._l0_dimensions
SubElement(image, 'Size', Width=str(w), Height=str(h))
SubElement(image, "Size", Width=str(w), Height=str(h))
tree = ElementTree(element=image)
buf = BytesIO()
tree.write(buf, encoding='UTF-8')
return buf.getvalue().decode('UTF-8')
tree.write(buf, encoding="UTF-8")
return buf.getvalue().decode("UTF-8")
18 changes: 9 additions & 9 deletions tests/test_deepzoom.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def tearDown(self):
def test_repr(self):
self.assertEqual(
repr(self.dz),
('DeepZoomGenerator(%r, tile_size=254, overlap=1, ' + 'limit_bounds=False)')
("DeepZoomGenerator(%r, tile_size=254, overlap=1, " + "limit_bounds=False)")
% self.osr,
)

Expand All @@ -45,7 +45,7 @@ def test_metadata(self):
self.assertEqual(self.dz.tile_count, 11)
self.assertEqual(
self.dz.level_tiles,
(
[
(1, 1),
(1, 1),
(1, 1),
Expand All @@ -56,11 +56,11 @@ def test_metadata(self):
(1, 1),
(1, 1),
(2, 1),
),
],
)
self.assertEqual(
self.dz.level_dimensions,
(
[
(1, 1),
(2, 1),
(3, 2),
Expand All @@ -71,7 +71,7 @@ def test_metadata(self):
(75, 63),
(150, 125),
(300, 250),
),
],
)

def test_get_tile(self):
Expand All @@ -80,7 +80,7 @@ def test_get_tile(self):
def test_tile_color_profile(self):
if self.CLASS is OpenSlide and not lowlevel.read_icc_profile.available:
self.skipTest("requires OpenSlide 4.0.0")
self.assertEqual(len(self.dz.get_tile(9, (1, 0)).info['icc_profile']), 588)
self.assertEqual(len(self.dz.get_tile(9, (1, 0)).info["icc_profile"]), 588)

def test_get_tile_bad_level(self):
self.assertRaises(ValueError, lambda: self.dz.get_tile(-1, (0, 0)))
Expand All @@ -100,15 +100,15 @@ def test_get_tile_dimensions(self):

def test_get_dzi(self):
self.assertTrue(
'http://schemas.microsoft.com/deepzoom/2008' in self.dz.get_dzi('jpeg')
"http://schemas.microsoft.com/deepzoom/2008" in self.dz.get_dzi("jpeg")
)


class TestSlideDeepZoom(_BoxesDeepZoomTest, unittest.TestCase):
CLASS = OpenSlide
FILENAME = 'boxes.tiff'
FILENAME = "boxes.tiff"


class TestImageDeepZoom(_BoxesDeepZoomTest, unittest.TestCase):
CLASS = ImageSlide
FILENAME = 'boxes.png'
FILENAME = "boxes.png"

0 comments on commit 0b6e742

Please sign in to comment.