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