import math
from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional, Tuple, Union, cast
from ..constants import TILE_FORMAT_IMAGE, TILE_FORMAT_NUMPY, TILE_FORMAT_PIL, TileOutputMimeTypes
from . import utilities
from .tiledict import LazyTileDict
if TYPE_CHECKING:
from .. import tilesource
[docs]
class TileIterator:
"""
A tile iterator on a TileSource. Details about the iterator can be read
via the `info` attribute on the iterator.
"""
def __init__(
self, source: 'tilesource.TileSource',
format: Union[str, Tuple[str]] = (TILE_FORMAT_NUMPY, ),
resample: Optional[bool] = True, **kwargs) -> None:
self.source = source
self._kwargs = kwargs
if not isinstance(format, tuple):
format = (format, )
if TILE_FORMAT_IMAGE in format:
encoding = kwargs.get('encoding')
if encoding not in TileOutputMimeTypes:
raise ValueError('Invalid encoding "%s"' % encoding)
self.format = format
self.resample = resample
iterFormat = format if resample in (False, None) else (TILE_FORMAT_PIL, )
self.info = self._tileIteratorInfo(format=iterFormat, resample=resample, **kwargs)
if self.info is None:
self._iter = None
return
if resample in (False, None) or round(self.info['requestedScale'], 2) == 1.0:
self.resample = False
self._iter = self._tileIterator(self.info)
def __iter__(self) -> Iterator[LazyTileDict]:
return self
def __next__(self) -> LazyTileDict:
if self._iter is None:
raise StopIteration
try:
tile = next(self._iter)
tile.setFormat(self.format, bool(self.resample), self._kwargs)
return tile
except StopIteration:
raise
def __repr__(self) -> str:
repr = f'TileIterator<{self.source}'
if self.info:
repr += (
f': {self.info["output"]["width"]} x {self.info["output"]["height"]}'
f'; tiles: {self.info["tile_count"]}'
f'; region: {self.info["region"]}')
if self.info['frame'] is not None:
repr += f'; frame: {self.info["frame"]}>'
repr += '>'
return repr
def _repr_json_(self) -> Dict:
if self.info:
return self.info
return {}
def _tileIteratorInfo(self, **kwargs) -> Optional[Dict[str, Any]]: # noqa
"""
Get information necessary to construct a tile iterator.
If one of width or height is specified, the other is determined by
preserving aspect ratio. If both are specified, the result may not be
that size, as aspect ratio is always preserved. If neither are
specified, magnification, mm_x, and/or mm_y are used to determine the
size. If none of those are specified, the original maximum resolution
is returned.
:param format: a tuple of allowed formats. Formats are members of
TILE_FORMAT_*. This will avoid converting images if they are
in the desired output encoding (regardless of subparameters).
Otherwise, TILE_FORMAT_NUMPY is returned.
:param region: a dictionary of optional values which specify the part
of the image to process.
:left: the left edge (inclusive) of the region to process.
:top: the top edge (inclusive) of the region to process.
:right: the right edge (exclusive) of the region to process.
:bottom: the bottom edge (exclusive) of the region to process.
:width: the width of the region to process.
:height: the height of the region to process.
:units: either 'base_pixels' (default), 'pixels', 'mm', or
'fraction'. base_pixels are in maximum resolution pixels.
pixels is in the specified magnification pixels. mm is in the
specified magnification scale. fraction is a scale of 0 to 1.
pixels and mm are only available if the magnification and mm
per pixel are defined for the image.
:unitsWH: if not specified, this is the same as `units`.
Otherwise, these units will be used for the width and height if
specified.
:param output: a dictionary of optional values which specify the size
of the output.
:maxWidth: maximum width in pixels.
:maxHeight: maximum height in pixels.
:param scale: a dictionary of optional values which specify the scale
of the region and / or output. This applies to region if
pixels or mm are used for units. It applies to output if
neither output maxWidth nor maxHeight is specified.
:magnification: the magnification ratio.
:mm_x: the horizontal size of a pixel in millimeters.
:mm_y: the vertical size of a pixel in millimeters.
:exact: if True, only a level that matches exactly will be
returned. This is only applied if magnification, mm_x, or mm_y
is used.
:param tile_position: if present, either a number to only yield the
(tile_position)th tile [0 to (xmax - min) * (ymax - ymin)) that the
iterator would yield, or a dictionary of {region_x, region_y} to
yield that tile, where 0, 0 is the first tile yielded, and
xmax - xmin - 1, ymax - ymin - 1 is the last tile yielded, or a
dictionary of {level_x, level_y} to yield that specific tile if it
is in the region.
:param tile_size: if present, retile the output to the specified tile
size. If only width or only height is specified, the resultant
tiles will be square. This is a dictionary containing at least
one of:
:width: the desired tile width.
:height: the desired tile height.
:param tile_overlap: if present, retile the output adding a symmetric
overlap to the tiles. If either x or y is not specified, it
defaults to zero. The overlap does not change the tile size,
only the stride of the tiles. This is a dictionary containing:
:x: the horizontal overlap in pixels.
:y: the vertical overlap in pixels.
:edges: if True, then the edge tiles will exclude the overlap
distance. If unset or False, the edge tiles are full size.
:param tile_offset: if present, adjust tile positions so that the
corner of one tile is at the specified location.
:left: the left offset in pixels.
:top: the top offset in pixels.
:auto: a boolean, if True, automatically set the offset to align
with the region's left and top.
:param kwargs: optional arguments. Some options are encoding,
jpegQuality, jpegSubsampling, tiffCompression, frame.
:returns: a dictionary of information needed for the tile iterator.
This is None if no tiles will be returned. Otherwise, this
contains:
:region: a dictionary of the source region information:
:width, height: the total output of the iterator in pixels.
This may be larger than the requested resolution (given by
output width and output height) if there isn't an exact
match between the requested resolution and available native
tiles.
:left, top, right, bottom: the coordinates within the image of
the region returned in the level pixel space.
:xmin, ymin, xmax, ymax: the tiles that will be included during the
iteration: [xmin, xmax) and [ymin, ymax).
:mode: either 'RGB' or 'RGBA'. This determines the color space
used for tiles.
:level: the tile level used for iteration.
:metadata: tile source metadata (from getMetadata)
:output: a dictionary of the output resolution information.
:width, height: the requested output resolution in pixels. If
this is different that region width and region height, then
the original request was asking for a different scale than
is being delivered.
:frame: the frame value for the base image.
:format: a tuple of allowed output formats.
:encoding: if the output format is TILE_FORMAT_IMAGE, the desired
encoding.
:requestedScale: the scale needed to convert from the region width
and height to the output width and height.
"""
source = self.source
maxWidth = kwargs.get('output', {}).get('maxWidth')
maxHeight = kwargs.get('output', {}).get('maxHeight')
if ((maxWidth is not None and
(not isinstance(maxWidth, int) or maxWidth < 0)) or
(maxHeight is not None and
(not isinstance(maxHeight, int) or maxHeight < 0))):
msg = 'Invalid output width or height. Minimum value is 0.'
raise ValueError(msg)
magLevel = None
mag = None
if maxWidth is None and maxHeight is None:
# If neither width nor height as specified, see if magnification,
# mm_x, or mm_y are requested.
magArgs = (kwargs.get('scale') or {}).copy()
magArgs['rounding'] = None
magLevel = source.getLevelForMagnification(**magArgs)
if magLevel is None and kwargs.get('scale', {}).get('exact'):
return None
mag = source.getMagnificationForLevel(magLevel)
metadata = source.metadata
left, top, right, bottom = source._getRegionBounds(
metadata, desiredMagnification=mag, **kwargs.get('region', {}))
regionWidth = right - left
regionHeight = bottom - top
magRequestedScale: Optional[float] = None
if maxWidth is None and maxHeight is None and mag:
if mag.get('scale') in (1.0, None):
maxWidth, maxHeight = regionWidth, regionHeight
magRequestedScale = 1
else:
maxWidth = regionWidth / cast(float, mag['scale'])
maxHeight = regionHeight / cast(float, mag['scale'])
magRequestedScale = cast(float, mag['scale'])
outWidth, outHeight, calcScale = utilities._calculateWidthHeight(
maxWidth, maxHeight, regionWidth, regionHeight)
requestedScale = calcScale if magRequestedScale is None else magRequestedScale
if (regionWidth < 0 or regionHeight < 0 or outWidth == 0 or
outHeight == 0):
return None
preferredLevel = metadata['levels'] - 1
# If we are scaling the result, pick the tile level that is at least
# the resolution we need and is preferred by the tile source.
if outWidth != regionWidth or outHeight != regionHeight:
newLevel = source.getPreferredLevel(preferredLevel + int(
math.ceil(round(math.log(max(float(outWidth) / regionWidth,
float(outHeight) / regionHeight)) /
math.log(2), 4))))
if newLevel < preferredLevel:
# scale the bounds to the level we will use
factor = 2 ** (preferredLevel - newLevel)
left = int(left / factor)
right = int(right / factor)
regionWidth = right - left
top = int(top / factor)
bottom = int(bottom / factor)
regionHeight = bottom - top
preferredLevel = newLevel
requestedScale /= factor
# If an exact magnification was requested and this tile source doesn't
# have tiles at the appropriate level, indicate that we won't return
# anything.
if (magLevel is not None and magLevel != preferredLevel and
kwargs.get('scale', {}).get('exact')):
return None
tile_size = {
'width': metadata['tileWidth'],
'height': metadata['tileHeight'],
}
tile_overlap = {
'x': int(kwargs.get('tile_overlap', {}).get('x', 0) or 0),
'y': int(kwargs.get('tile_overlap', {}).get('y', 0) or 0),
'edges': kwargs.get('tile_overlap', {}).get('edges', False),
'offset_x': 0,
'offset_y': 0,
'range_x': 0,
'range_y': 0,
}
if not tile_overlap['edges']:
# offset by half the overlap
tile_overlap['offset_x'] = tile_overlap['x'] // 2
tile_overlap['offset_y'] = tile_overlap['y'] // 2
tile_overlap['range_x'] = tile_overlap['x']
tile_overlap['range_y'] = tile_overlap['y']
if 'tile_size' in kwargs:
tile_size['width'] = int(kwargs['tile_size'].get(
'width', kwargs['tile_size'].get('height', tile_size['width'])))
tile_size['height'] = int(kwargs['tile_size'].get(
'height', kwargs['tile_size'].get('width', tile_size['height'])))
# Tile size includes the overlap
tile_size['width'] -= tile_overlap['x']
tile_size['height'] -= tile_overlap['y']
if tile_size['width'] <= 0 or tile_size['height'] <= 0:
msg = 'Invalid tile_size or tile_overlap.'
raise ValueError(msg)
resample = (
False if round(requestedScale, 2) == 1.0 or
kwargs.get('resample') in (None, False) else kwargs.get('resample'))
# If we need to resample to make tiles at a non-native resolution,
# adjust the tile size and tile overlap parameters appropriately.
if resample is not False:
tile_size['width'] = max(1, int(math.ceil(tile_size['width'] * requestedScale)))
tile_size['height'] = max(1, int(math.ceil(tile_size['height'] * requestedScale)))
tile_overlap['x'] = int(math.ceil(tile_overlap['x'] * requestedScale))
tile_overlap['y'] = int(math.ceil(tile_overlap['y'] * requestedScale))
offset_x = kwargs.get('tile_offset', {}).get('left', 0)
offset_y = kwargs.get('tile_offset', {}).get('top', 0)
if kwargs.get('tile_offset', {}).get('auto'):
offset_x = left
offset_y = top
offset_x = (left - left % tile_size['width']) if offset_x > left else offset_x
offset_y = (top - top % tile_size['height']) if offset_y > top else offset_y
# If the overlapped tiles don't run over the edge, then the functional
# size of the region is reduced by the overlap. This factor is stored
# in the overlap offset_*.
xmin = int((left - offset_x) / tile_size['width'])
xmax = max(int(math.ceil((float(right - offset_x) - tile_overlap['range_x']) /
tile_size['width'])), xmin + 1)
ymin = int((top - offset_y) / tile_size['height'])
ymax = max(int(math.ceil((float(bottom - offset_y) - tile_overlap['range_y']) /
tile_size['height'])), ymin + 1)
tile_overlap.update({'xmin': xmin, 'xmax': xmax,
'ymin': ymin, 'ymax': ymax})
tile_overlap['offset_x'] += offset_x
tile_overlap['offset_y'] += offset_y
# Use RGB for JPEG, RGBA for PNG
mode = 'RGBA' if kwargs.get('encoding') in {'PNG', 'TIFF', 'TILED'} else 'RGB'
info = {
'region': {
'top': top,
'left': left,
'bottom': bottom,
'right': right,
'width': regionWidth,
'height': regionHeight,
},
'xmin': xmin,
'ymin': ymin,
'xmax': xmax,
'ymax': ymax,
'mode': mode,
'level': preferredLevel,
'metadata': metadata,
'output': {
'width': outWidth,
'height': outHeight,
},
'frame': kwargs.get('frame'),
'format': kwargs.get('format', (TILE_FORMAT_NUMPY, )),
'encoding': kwargs.get('encoding'),
'requestedScale': requestedScale,
'resample': resample,
'tile_count': (xmax - xmin) * (ymax - ymin),
'tile_overlap': tile_overlap,
'tile_position': kwargs.get('tile_position'),
'tile_size': tile_size,
}
return info
def _tileIterator(self, iterInfo: Dict[str, Any]) -> Iterator[LazyTileDict]:
"""
Given tile iterator information, iterate through the tiles.
Each tile is returned as part of a dictionary that includes
:x, y: (left, top) coordinate in current magnification pixels
:width, height: size of current tile in current magnification
pixels
:tile: cropped tile image
:format: format of the tile. One of TILE_FORMAT_NUMPY,
TILE_FORMAT_PIL, or TILE_FORMAT_IMAGE. TILE_FORMAT_IMAGE is
only returned if it was explicitly allowed and the tile is
already in the correct image encoding.
:level: level of the current tile
:level_x, level_y: the tile reference number within the level.
Tiles are numbered (0, 0), (1, 0), (2, 0), etc. The 0th tile
yielded may not be (0, 0) if a region is specified.
:tile_position: a dictionary of the tile position within the
iterator, containing:
:level_x, level_y: the tile reference number within the level.
:region_x, region_y: 0, 0 is the first tile in the full
iteration (when not restricting the iteration to a single
tile).
:position: a 0-based value for the tile within the full
iteration.
:iterator_range: a dictionary of the output range of the iterator:
:level_x_min, level_x_max: the tiles that are be included
during the full iteration: [layer_x_min, layer_x_max).
:level_y_min, level_y_max: the tiles that are be included
during the full iteration: [layer_y_min, layer_y_max).
:region_x_max, region_y_max: the number of tiles included during
the full iteration. This is layer_x_max - layer_x_min,
layer_y_max - layer_y_min.
:position: the total number of tiles included in the full
iteration. This is region_x_max * region_y_max.
:magnification: magnification of the current tile
:mm_x, mm_y: size of the current tile pixel in millimeters.
:gx, gy: (left, top) coordinates in maximum-resolution pixels
:gwidth, gheight: size of of the current tile in maximum-resolution
pixels.
:tile_overlap: the amount of overlap with neighboring tiles (left,
top, right, and bottom). Overlap never extends outside of the
requested region.
If a region that includes partial tiles is requested, those tiles are
cropped appropriately. Most images will have tiles that get cropped
along the right and bottom edges in any case.
:param iterInfo: tile iterator information. See _tileIteratorInfo.
:yields: an iterator that returns a dictionary as listed above.
"""
source = self.source
regionWidth = iterInfo['region']['width']
regionHeight = iterInfo['region']['height']
left = iterInfo['region']['left']
top = iterInfo['region']['top']
xmin = iterInfo['xmin']
ymin = iterInfo['ymin']
xmax = iterInfo['xmax']
ymax = iterInfo['ymax']
level = iterInfo['level']
metadata = iterInfo['metadata']
tileSize = iterInfo['tile_size']
tileOverlap = iterInfo['tile_overlap']
format = iterInfo['format']
encoding = iterInfo['encoding']
source.logger.debug(
'Fetching region of an image with a source size of %d x %d; '
'getting %d tile%s',
regionWidth, regionHeight, (xmax - xmin) * (ymax - ymin),
'' if (xmax - xmin) * (ymax - ymin) == 1 else 's')
# If tile is specified, return at most one tile
if iterInfo.get('tile_position') is not None:
tilePos = iterInfo.get('tile_position')
if isinstance(tilePos, dict):
if tilePos.get('position') is not None:
tilePos = tilePos['position']
elif 'region_x' in tilePos and 'region_y' in tilePos:
tilePos = (tilePos['region_x'] +
tilePos['region_y'] * (xmax - xmin))
elif 'level_x' in tilePos and 'level_y' in tilePos:
tilePos = ((tilePos['level_x'] - xmin) +
(tilePos['level_y'] - ymin) * (xmax - xmin))
if tilePos < 0 or tilePos >= (ymax - ymin) * (xmax - xmin):
xmax = xmin
else:
ymin += int(tilePos / (xmax - xmin))
ymax = ymin + 1
xmin += int(tilePos % (xmax - xmin))
xmax = xmin + 1
mag = source.getMagnificationForLevel(level)
scale = mag.get('scale', 1.0)
retile = (tileSize['width'] != metadata['tileWidth'] or
tileSize['height'] != metadata['tileHeight'] or
tileOverlap['x'] or tileOverlap['y'])
for y in range(ymin, ymax):
for x in range(xmin, xmax):
crop = None
posX = int(x * tileSize['width'] - tileOverlap['x'] // 2 +
tileOverlap['offset_x'] - left)
posY = int(y * tileSize['height'] - tileOverlap['y'] // 2 +
tileOverlap['offset_y'] - top)
tileWidth = tileSize['width'] + tileOverlap['x']
tileHeight = tileSize['height'] + tileOverlap['y']
# crop as needed
if (posX < 0 or posY < 0 or posX + tileWidth > regionWidth or
posY + tileHeight > regionHeight):
crop = (max(0, -posX),
max(0, -posY),
int(min(tileWidth, regionWidth - posX)),
int(min(tileHeight, regionHeight - posY)))
posX += crop[0]
posY += crop[1]
tileWidth = crop[2] - crop[0]
tileHeight = crop[3] - crop[1]
overlap = {
'left': max(0, x * tileSize['width'] + tileOverlap['offset_x'] - left - posX),
'top': max(0, y * tileSize['height'] + tileOverlap['offset_y'] - top - posY),
}
overlap['right'] = (
max(0, tileWidth - tileSize['width'] - overlap['left'])
if x != xmin or not tileOverlap['range_x'] else
min(tileWidth, tileOverlap['range_x'] - tileOverlap['offset_x']))
overlap['bottom'] = (
max(0, tileHeight - tileSize['height'] - overlap['top'])
if y != ymin or not tileOverlap['range_y'] else
min(tileHeight, tileOverlap['range_y'] - tileOverlap['offset_y']))
if tileOverlap['range_x']:
overlap['left'] = 0 if x == tileOverlap['xmin'] else overlap['left']
overlap['right'] = 0 if x + 1 == tileOverlap['xmax'] else overlap['right']
if tileOverlap['range_y']:
overlap['top'] = 0 if y == tileOverlap['ymin'] else overlap['top']
overlap['bottom'] = 0 if y + 1 == tileOverlap['ymax'] else overlap['bottom']
tile = LazyTileDict({
'x': x,
'y': y,
'frame': iterInfo.get('frame'),
'level': level,
'format': format,
'encoding': encoding,
'crop': crop,
'requestedScale': iterInfo['requestedScale'],
'retile': retile,
'metadata': metadata,
'source': source,
}, {
'x': posX + left,
'y': posY + top,
'width': tileWidth,
'height': tileHeight,
'level': level,
'level_x': x,
'level_y': y,
'magnification': mag['magnification'],
'mm_x': mag['mm_x'],
'mm_y': mag['mm_y'],
'tile_position': {
'level_x': x,
'level_y': y,
'region_x': x - iterInfo['xmin'],
'region_y': y - iterInfo['ymin'],
'position': ((x - iterInfo['xmin']) +
(y - iterInfo['ymin']) *
(iterInfo['xmax'] - iterInfo['xmin'])),
},
'iterator_range': {
'level_x_min': iterInfo['xmin'],
'level_y_min': iterInfo['ymin'],
'level_x_max': iterInfo['xmax'],
'level_y_max': iterInfo['ymax'],
'region_x_max': iterInfo['xmax'] - iterInfo['xmin'],
'region_y_max': iterInfo['ymax'] - iterInfo['ymin'],
'position': ((iterInfo['xmax'] - iterInfo['xmin']) *
(iterInfo['ymax'] - iterInfo['ymin'])),
},
'tile_overlap': overlap,
})
tile['gx'] = tile['x'] * scale
tile['gy'] = tile['y'] * scale
tile['gwidth'] = tile['width'] * scale
tile['gheight'] = tile['height'] * scale
yield tile