"""File parsers for images stored as zoom-level pyramids"""
from .errors import FileFormatError
from .errors import ZoomError
from bs4 import BeautifulSoup
from imageio.v3 import imread
from math import ceil
from pathlib import Path
[docs]
class DeepZoom:
"""
Represents a zoom-level image pyramid stored in DeepZoom format.
A DeepZoom image is a large image is subdivided into smaller tiles
for various zoom levels, creating a "pyramid of tiles". For given
zoom level and region of interest, we then know how to quickly look
up the data to be displayed, without having to rescale the original
image.
This class reads and returns the meta data for the image, provides
helper functions for working with dimensions, and loads and returns
tiles. It doesn't store pixel data in memory, but provides
`load_tile()` to return it.
Higher zoom means bigger picture. Zoom level 0 corresponds to the
smallest available picture, zoomed out the most. There is typically
only one tile at that level, though that depends on the settings
used when the image pyramid was generated.
"""
def __init__(self, file: Path = None):
# Default to .dzi file extension.
if not file.suffix:
file = file.with_suffix('.dzi')
# Initialize public attributes.
self.file = file
"""file with image meta data"""
self.image_width = 0
"""width of the full image"""
self.image_height = 0
"""height of the full image"""
self.tile_width = 0
"""width of a single tile"""
self.tile_height = 0
"""height of a single tile"""
self.overlap = 0
"""pixel overlap between tiles"""
self.tile_format = ''
"""file format of tile images"""
self.zoom_levels = []
"""available zoom levels, biggest to smallest"""
self.zoom_min = 0
"""minimum available zoom level (smallest number)"""
self.zoom_max = 0
"""maximum available zoom level (largest number)"""
# Initialize private attributes.
self.files = None
self.zoomed_width = {}
self.zoomed_height = {}
# Parse file to get meta data.
self.parse()
[docs]
def parse(self):
"""Parses the meta data for an image."""
xml = self.file.read_text(encoding='UTF-8')
soup = BeautifulSoup(xml, 'xml')
if soup.Image is None:
raise FileFormatError('No Image tag found in meta data.')
if 'Width' in soup.Image.attrs:
self.image_width = soup.Image['Width']
if 'Height' in soup.Image.attrs:
self.image_height = soup.Image['Height']
if 'TileSize' in soup.Image.attrs:
self.tile_width = soup.Image['TileSize']
self.tile_height = self.tile_width
else:
raise FileFormatError('Tile size not defined.')
if 'Overlap' in soup.Image.attrs:
self.overlap = soup.Image['Overlap']
else:
self.overlap = 0
if 'Format' in soup.Image.attrs:
self.tile_format = soup.Image['Format']
else:
raise FileFormatError('Image format not defined.')
if soup.Size is not None:
if 'Width' in soup.Size.attrs:
self.image_width = soup.Size['Width']
if 'Height' in soup.Size.attrs:
self.image_height = soup.Size['Height']
if self.image_width is None:
raise FileFormatError('Image width not defined.')
if self.image_height is None:
raise FileFormatError('Image height not defined')
try:
self.image_width = int(self.image_width)
except ValueError:
raise FileFormatError('Image width is not an integer.')
try:
self.image_height = int(self.image_height)
except ValueError:
raise FileFormatError('Image height is not an integer.')
try:
self.overlap = int(self.overlap)
except ValueError:
raise FileFormatError('Pixel overlap is not an integer.')
try:
self.tile_width = int(self.tile_width)
self.tile_height = self.tile_width
except ValueError:
raise FileFormatError('Tile size is not an integer')
# Calculate loop-up tables for zoom levels.
self.zoomed_width = {}
self.zoomed_height = {}
files = self.file.parent/f'{self.file.stem}_files'
if not files.is_dir():
raise FileFormatError(f'Folder "{files.name}" not found.')
max_num = 0
min_num = None
numbers = []
for item in files.iterdir():
if not item.is_dir():
continue
try:
number = int(item.name)
except ValueError:
continue
if number < 0:
continue
if number > max_num:
max_num = number
if min_num is None or number < min_num:
min_num = number
numbers.append(number)
zoomed_width = self.image_width
zoomed_height = self.image_height
numbers.sort(reverse=True)
for number in numbers:
self.zoomed_width[number] = zoomed_width
self.zoomed_height[number] = zoomed_height
zoomed_width = int(ceil(zoomed_width/2))
zoomed_height = int(ceil(zoomed_height/2))
self.zoom_levels = list(range(max_num, -1, -1))
self.zoom_max = max_num
self.zoom_min = min_num
# Update instance attributes.
self.files = files
[docs]
def tiles_i(self, zoom):
"""Returns the number of tiles in the x direction."""
tiles = int(ceil(self.level_width(zoom)/self.tile_width))
return max(tiles, 1)
[docs]
def tiles_j(self, zoom):
"""Returns the number of tiles in the y direction."""
tiles = int(ceil(self.level_height(zoom) / self.tile_height))
return max(tiles, 1)
[docs]
def x_to_i(self, x):
"""Returns tile number for given x-coordinate in the full image."""
i = int( x/self.tile_width + 0.0001 )
return i
[docs]
def y_to_j(self, y):
"""Returns tile number for given y-coordinate in the full image."""
j = int(y/self.tile_height + 0.0001)
return j
[docs]
def tile_x1(self, i, clip=False):
"""
Returns the x-coordinate of the left edge of tiles number `i`.
This includes the overlap pixels.
"""
x1 = i * self.tile_width
if clip and x1 < self.overlap:
return 0
overlap = self.overlap if i > 0 else 0
return x1 - overlap
[docs]
def tile_y1(self, j, clip=False):
"""
Returns the y-coordinate of top edge of tiles number `j`.
This includes the overlap pixels.
"""
y1 = j * self.tile_height
if clip and y1 < self.overlap:
return 0
overlap = self.overlap if j > 0 else 0
return y1 - overlap
[docs]
def tile_x2(self, i, zoom):
"""
Returns the x-coordinate of the right edge of tiles number `i`.
This includes the overlap pixels. The returned pixel index is
actually one pixel beyond the edge, in anticipation of
right-inclusive ranges.
"""
overlap = self.overlap_x1(i) + self.overlap_x2(i, zoom)
x2 = self.tile_x1(i) + self.tile_width + overlap
if x2 > self.zoomed_width[zoom]:
x2 = self.zoomed_width[zoom]
return x2
[docs]
def tile_y2(self, tile, zoom):
"""
Returns the y-coordinate of the bottom edge of tiles number `j`.
This includes the overlap pixels. The returned pixel index is
actually one pixel beyond the edge, in anticipation of
right-inclusive ranges.
"""
overlap = self.overlap_y1(tile) + self.overlap_y2(tile, zoom)
y2 = self.tile_y1(tile) + self.tile_height + overlap
if y2 > self.zoomed_height[zoom]:
y2 = self.zoomed_height[zoom]
return y2
[docs]
def overlap_x1(self, i):
"""Returns the pixel overlap for tiles number `i` at left edge."""
return self.overlap if i > 0 else 0
[docs]
def overlap_y1(self, j):
"""Returns the pixel overlap for tiles number `j` at top edge."""
return self.overlap if j > 0 else 0
[docs]
def overlap_x2(self, i, zoom):
"""Returns the pixel overlap for tiles number `i` at right edge."""
last_tile = self.tiles_i(zoom) - 1
return self.overlap if i < last_tile else 0
[docs]
def overlap_y2(self, j, zoom):
"""Returns the pixel overlap for tiles number `j` at bottom edge."""
last_tile = self.tiles_j(zoom) - 1
return self.overlap if j < last_tile else 0
[docs]
def level_width(self, zoom):
"""
Returns the width of the image at the given `zoom` level.
Raises `ZoomError` if the zoom level doesn't exist.
"""
if zoom in self.zoomed_width:
return self.zoomed_width[zoom]
raise ZoomError(f'Invalid zoom {zoom} requested.')
[docs]
def level_height(self, zoom):
"""
Returns the height of the image at the given `zoom` level.
Raises `ZoomError` if the zoom level doesn't exist.
"""
if zoom in self.zoomed_height:
return self.zoomed_height[zoom]
raise ZoomError(f'Invalid zoom {zoom} requested')
[docs]
def tile_file(self, i, j, zoom):
"""Returns the file path of tile `(i, j)` at given `zoom` level."""
file = self.files/str(zoom)/f'{i}_{j}.{self.tile_format}'
return file
[docs]
def load_tile(self, i, j, zoom):
"""
Loads tile `(i, j)` at given `zoom` level.
Returns the image data as a NumPy array. Raises `FileFormatError`
if the file format is not recognized.
"""
file = self.tile_file(i, j, zoom)
image = imread(file)
(height, width) = image.shape[:2]
expected_width = self.tile_x2(i, zoom) - self.tile_x1(i)
expected_height = self.tile_y2(j, zoom) - self.tile_y1(j)
if (width, height) != (expected_width, expected_height):
raise FileFormatError(
f'Pixel size {width}×{height} of tile ({i},{j}) '
f'does not match expected size '
f'{expected_width}×{expected_height}.'
)
return image