Source code for spinningdiskanalyzer.frontend.viewer.images

"""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