Source code for spinningdiskanalyzer.frontend.viewer.tiles

"""Caching of tiles loaded from a zoom-level image pyramid."""

from .images import DeepZoom

from numpy   import zeros
from math    import ceil


[docs] class TileCache: """ Pixel cache for tiles of a zoom-level image pyramid. The pixels that are loaded include some tiles on each side that are intended to be outside the displayed area. This means that when the user moves the viewport by a small amount, image tiles are loaded outside the displayed area, not inside. This can be leveraged by the GUI framework, where the displayed image would only be the viewport, but the framework is aware of the entire canvas, which can then be updated at a slower pace without affecting the user experience. ``` (canvas_x, canvas_y) +-----------------------------------------------+ | | | | | (viewport_x, viewport_y)     | | +---------------------+ | | | | | | | | | | | | | | | | | | | | | | +---------------------+ | | (viewport_x + viewport_w, | | viewport_y + viewport_h) | +-----------------------------------------------+ (canvas_x + canvas_w, canvas_y + canvas_h) ``` Pixel indices are denoted `(x, y)`, tile indices as `(i, j)`. The coordinates of a top-left corner are denoted `(x1, y1)` or `(i1, i1)` respectively. A bottom-right corners is `(x2, y2)` for pixels, `(i2, j2)` for tiles. Ranges are left-inclusive, right-exclusive. Width and height of the corresponding rectangles are typically abbreviated as `w` and `h`. Pixel indices into the full image (not the canvas) are denoted with capital letters, like `(X, Y)`. """ def __init__(self, image: DeepZoom, extra_tiles=2): """ Loads the tiled `image` pyramid. `extra_tiles` specifies the number of extra tiles on each side of the viewport to preload. """ # Initialize public attributes. self.image = image """tiled image pyramid to retrieve pixel data from""" self.canvas = None """array holding all pixel data currently cached""" self.extra_tiles = extra_tiles """number of extra tiles to cache outside the viewport""" self.zoom_level = 0 """the current zoom level""" self.zoom_min = image.zoom_min """minimum available zoom level (smallest number)""" self.zoom_max = image.zoom_max """maximum available zoom level (largest number)""" # Initialize private attributes. self.canvas_x = 0 self.canvas_y = 0 self.canvas_w = 0 self.canvas_h = 0 self.tile_i1 = 0 self.tile_j1 = 0 self.tile_i2 = 0 self.tile_j2 = 0 self.tiles_i = 0 self.tiles_j = 0 self.viewport_x = 0 self.viewport_y = 0 self.viewport_w = 0 self.viewport_h = 0 self.viewport_X = 0 self.viewport_Y = 0
[docs] def viewport_size(self, width, height): """Changes the viewport size to given `width` and `height`.""" self.viewport_w = width self.viewport_h = height
[docs] def viewport_rect(self): """Returns the viewport rectangle inside the canvas.""" width = min(self.viewport_w, self.canvas_w) height = min(self.viewport_h, self.canvas_h) x0 = self.viewport_x y0 = self.viewport_y return (x0, y0, width, height)
[docs] def zoom_to(self, level, x, y): """ Zooms to the given `level` towards viewport coordinates (x, y). Returns `True` if image needs to be reloaded because tiles have been added, `False` otherwise. """ stale = False if level < self.zoom_min or level > self.zoom_max: return stale if level == self.zoom_level: return stale # Convert viewport coordinates to full-image coordinates. X = x + self.viewport_X Y = y + self.viewport_Y levels = level - self.zoom_level if levels > 0: X = X << levels Y = Y << levels else: X = X >> -levels Y = Y >> -levels self.zoom_level = level self.update(X, Y) stale = True return stale
[docs] def move_by(self, Δx: int, Δy: int) -> bool: """ Moves viewport position by `(Δx, Δy)`. Returns `True` if image needs to be reloaded because tiles have been added, `False` otherwise. """ x1 = self.viewport_X - Δx y1 = self.viewport_Y - Δy return self.move_to(x1, y1)
[docs] def move_to(self, X1: int, Y1: int) -> bool: """ Moves the viewport within the current zoom level. Loads new tiles if needed and updates the viewport. `(X1, Y1)` are the coordinates of the top-left corner in the full image at the current zoom level. Returns `True` if image needs to be reloaded because tiles have been added, `False` otherwise. """ # Signal to caller if an update is in order. stale = False # Shorten some expressions further down. level_w = self.image.level_width(self.zoom_level) level_h = self.image.level_height(self.zoom_level) tile_w = self.image.tile_width tile_h = self.image.tile_height # Make sure we stay within the image boundaries at this zoom level. X1 = clamp(X1, 0, level_w - self.viewport_w) Y1 = clamp(Y1, 0, level_h - self.viewport_h) Δx = self.viewport_X - X1 Δy = self.viewport_Y - Y1 if Δx == 0 and Δy == 0: return stale # Update the viewport position relative to the canvas. self.viewport_x -= Δx self.viewport_y -= Δy # Update the viewport position into the full image. self.viewport_X = X1 self.viewport_Y = Y1 # Clip viewport on full image. Clip viewport on canvas later. clip_x1 = clip_x2 = clip_y1 = clip_y2 = 0 if self.viewport_X + self.viewport_w > level_w: clip_x2 = self.viewport_X + self.viewport_w - level_w self.viewport_X = level_w - self.viewport_w if self.viewport_Y + self.viewport_h > level_h: clip_y2 = self.viewport_Y + self.viewport_h - level_h self.viewport_Y = level_h - self.viewport_h if self.viewport_X < 0: clip_x1 = -self.viewport_X self.viewport_X = 0 clip_x2 = 0 if self.viewport_Y < 0: clip_y1 = -self.viewport_Y self.viewport_Y = 0 clip_y2 = 0 # Check if new tiles have to be loaded. i1_old = self.tile_i1 j1_old = self.tile_j1 i2_old = self.tile_i2 j2_old = self.tile_j2 i1_new = self.image.x_to_i(self.viewport_X) - self.extra_tiles j1_new = self.image.y_to_j(self.viewport_Y) - self.extra_tiles i2_new = i1_new + self.tiles_i j2_new = j1_new + self.tiles_j tiles_old = (i1_old, j1_old, i2_old, j2_old) tiles_new = (i1_new, j1_new, i2_new, j2_new) if tiles_new != tiles_old: stale = True extra_tiles_i1 = max(0, i2_old - i2_new) extra_tiles_j1 = max(0, j2_old - j2_new) extra_tiles_i2 = max(0, i1_new - i1_old) extra_tiles_j2 = max(0, j1_new - j1_old) # Move pixels within NumPy array. x1_old = y1_old = x1_new = y1_new = 0 if extra_tiles_i1 > 0: x1_new = tile_w * extra_tiles_i1 self.viewport_x += tile_w * extra_tiles_i1 if extra_tiles_i2 > 0: x1_old = tile_w * extra_tiles_i2 self.viewport_x -= tile_w * extra_tiles_i2 if extra_tiles_j1 > 0: y1_new = tile_h * extra_tiles_j1 self.viewport_y += tile_h * extra_tiles_j1 if extra_tiles_j2 > 0: y1_old = tile_h * extra_tiles_j2 self.viewport_y -= tile_h * extra_tiles_j2 Δi = extra_tiles_i1 + extra_tiles_i2 Δj = extra_tiles_j1 + extra_tiles_j2 Δx = self.canvas_w - Δi * tile_w Δy = self.canvas_h - Δj * tile_h x2_old = x1_old + Δx y2_old = y1_old + Δy x2_new = x1_new + Δx y2_new = y1_new + Δy self.canvas[y1_new:y2_new, x1_new:x2_new] = \ self.canvas[y1_old:y2_old, x1_old:x2_old] # Load new tiles. self.tile_i1 = i1_new self.tile_i2 = i2_new self.tile_j1 = j1_new self.tile_j2 = j2_new self.fetch(init_matrix=False, skip=(i1_old, j1_old, i2_old, j2_old)) # Clip viewport on canvas. if clip_x1 > 0: self.viewport_x += clip_x1 elif clip_x2 > 0: self.viewport_x -= clip_x2 if clip_y1 > 0: self.viewport_y += clip_y1 elif clip_y2 > 0: self.viewport_y -= clip_y2 return stale
[docs] def update(self, X=None, Y=None): """Updates the canvas, centering at `(X, Y)` of the full image.""" # Don't do anything before we have a viewport. if self.viewport_w == 0 or self.viewport_h == 0: return # Shorten some expressions further down. level_w = self.image.level_width(self.zoom_level) level_h = self.image.level_height(self.zoom_level) tile_w = self.image.tile_width tile_h = self.image.tile_height # If we have requested the image to be centered at a given pixel, # determine the top left corner of the image on the viewport from it, # then adjust so that the viewport is not out of range. if X is not None and Y is not None: self.viewport_X = X - int(self.viewport_w/2) self.viewport_Y = Y - int(self.viewport_h/2) self.viewport_X = clamp(self.viewport_X, 0, level_w - self.viewport_w) self.viewport_Y = clamp(self.viewport_Y, 0, level_h - self.viewport_h) # Get how many tiles are in the canvas. # This may be more than the actual image. self.tiles_i = ceil( self.viewport_w / tile_w ) self.tiles_j = ceil( self.viewport_h / tile_h ) # Add extra tiles on each side, whether or not this goes beyond the # image. self.tiles_i += 2*self.extra_tiles self.tiles_j += 2*self.extra_tiles # Get starting tile based on position in full image minus extra files. # This may be beyond image boundary. self.tile_i1 = self.image.x_to_i(self.viewport_X) self.tile_j1 = self.image.y_to_j(self.viewport_Y) self.tile_i1 -= self.extra_tiles self.tile_j1 -= self.extra_tiles # Get end tile based on start tile and number of tiles in canvas # (including extra files). self.tile_i2 = self.tile_i1 + self.tiles_i self.tile_j2 = self.tile_j1 + self.tiles_j # Get size of canvas. self.canvas_w = self.tiles_i * tile_w self.canvas_h = self.tiles_j * tile_h self.canvas_x = self.image.tile_x1(self.tile_i1) self.canvas_y = self.image.tile_y1(self.tile_j1) # Load tiles. self.fetch(init_matrix=True) # Get viewport corner on canvas from viewport corner on full image. x1 = self.image.tile_x1(self.tile_i1) y1 = self.image.tile_y1(self.tile_j1) self.viewport_x = self.viewport_X - x1 self.viewport_y = self.viewport_Y - y1
[docs] def fetch(self, init_matrix=False, skip=False): """ Fetches tiles from disk as needed to fill in the canvas. If `init_matrix` is `True`, reinitialize the canvas array. If `skip` is set to `(i1, j1, i2, j2)` instead of the default, tiles within that range are skipped because they were previously loaded. """ tiles_i = self.image.tiles_i(self.zoom_level) tiles_j = self.image.tiles_j(self.zoom_level) tile_w = self.image.tile_width tile_h = self.image.tile_height zoom = self.zoom_level first = True for j in range(self.tile_j1, self.tile_j2): if j < 0 or j >= tiles_j: continue overlap_y1 = self.image.overlap_y1(j) overlap_y2 = self.image.overlap_y2(j, zoom) Y1 = tile_h * (j - self.tile_j1) for i in range(self.tile_i1, self.tile_i2): if i < 0 or i >= tiles_i: continue if skip: (i1, j1, i2, j2) = skip if i1 <= i < i2 and j1 <= j < j2: continue overlap_x1 = self.image.overlap_x1(i) overlap_x2 = self.image.overlap_x2(i, zoom) X1 = tile_w * (i - self.tile_i1) tile = self.image.load_tile(i, j, zoom) if init_matrix and first: shape = list(tile.shape) shape[0] = self.canvas_h shape[1] = self.canvas_w self.canvas = zeros(shape, tile.dtype) first = False (h, w) = tile.shape[:2] x1 = overlap_x1 y1 = overlap_y1 x2 = w - overlap_x2 y2 = h - overlap_y2 X2 = X1 + x2 - overlap_x1 Y2 = Y1 + y2 - overlap_y1 self.canvas[Y1:Y2, X1:X2] = tile[y1:y2, x1:x2]
[docs] def clamp(value, minimum, maximum): """Returns the `value` coerced to the interval `[minimum, maximum]`.""" clamped = max(minimum, min(value, maximum)) return clamped