diff --git a/large_image/tilesource/base.py b/large_image/tilesource/base.py index b354f5600..e88d24ae7 100644 --- a/large_image/tilesource/base.py +++ b/large_image/tilesource/base.py @@ -5,8 +5,6 @@ import pathlib import tempfile import threading -import xml.etree.ElementTree -from collections import defaultdict import numpy import PIL @@ -19,596 +17,11 @@ from ..constants import (TILE_FORMAT_IMAGE, TILE_FORMAT_NUMPY, TILE_FORMAT_PIL, SourcePriority, TileInputUnits, TileOutputMimeTypes, TileOutputPILFormat, dtypeToGValue) - -# Turn off decompression warning check -PIL.Image.MAX_IMAGE_PIXELS = None - - -def _encodeImage(image, encoding='JPEG', jpegQuality=95, jpegSubsampling=0, - format=(TILE_FORMAT_IMAGE, ), tiffCompression='raw', - **kwargs): - """ - Convert a PIL or numpy image into raw output bytes and a mime type. - - :param image: a PIL image. - :param encoding: a valid PIL encoding (typically 'PNG' or 'JPEG'). Must - also be in the TileOutputMimeTypes map. - :param jpegQuality: the quality to use when encoding a JPEG. - :param jpegSubsampling: the subsampling level to use when encoding a JPEG. - :param format: the desired format or a tuple of allowed formats. Formats - are members of (TILE_FORMAT_PIL, TILE_FORMAT_NUMPY, TILE_FORMAT_IMAGE). - :param tiffCompression: the compression format to use when encoding a TIFF. - :returns: - :imageData: the image data in the specified format and encoding. - :imageFormatOrMimeType: the image mime type if the format is - TILE_FORMAT_IMAGE, or the format of the image data if it is - anything else. - """ - if not isinstance(format, (tuple, set, list)): - format = (format, ) - imageData = image - imageFormatOrMimeType = TILE_FORMAT_PIL - if TILE_FORMAT_NUMPY in format: - imageData, _ = _imageToNumpy(image) - imageFormatOrMimeType = TILE_FORMAT_NUMPY - elif TILE_FORMAT_PIL in format: - imageData = _imageToPIL(image) - imageFormatOrMimeType = TILE_FORMAT_PIL - elif TILE_FORMAT_IMAGE in format: - if encoding not in TileOutputMimeTypes: - raise ValueError('Invalid encoding "%s"' % encoding) - imageFormatOrMimeType = TileOutputMimeTypes[encoding] - image = _imageToPIL(image) - if image.width == 0 or image.height == 0: - imageData = b'' - else: - encoding = TileOutputPILFormat.get(encoding, encoding) - output = io.BytesIO() - params = {} - if encoding == 'JPEG' and image.mode not in ('L', 'RGB'): - image = image.convert('RGB' if image.mode != 'LA' else 'L') - if encoding == 'JPEG': - params['quality'] = jpegQuality - params['subsampling'] = jpegSubsampling - elif encoding in {'TIFF', 'TILED'}: - params['compression'] = { - 'none': 'raw', - 'lzw': 'tiff_lzw', - 'deflate': 'tiff_adobe_deflate', - }.get(tiffCompression, tiffCompression) - elif encoding == 'PNG': - params['compress_level'] = 2 - image.save(output, encoding, **params) - imageData = output.getvalue() - return imageData, imageFormatOrMimeType - - -def _imageToPIL(image, setMode=None): - """ - Convert an image in PIL, numpy, or image file format to a PIL image. - - :param image: input image. - :param setMode: if specified, the output image is converted to this mode. - :returns: a PIL image. - """ - if isinstance(image, numpy.ndarray): - mode = 'L' - if len(image.shape) == 3: - # Fallback for hyperspectral data to just use the first three bands - if image.shape[2] > 4: - image = image[:, :, :3] - mode = ['L', 'LA', 'RGB', 'RGBA'][image.shape[2] - 1] - if len(image.shape) == 3 and image.shape[2] == 1: - image = numpy.resize(image, image.shape[:2]) - if image.dtype == numpy.uint16: - image = numpy.floor_divide(image, 256).astype(numpy.uint8) - # TODO: The scaling of float data needs to be identical across all - # tiles of an image. This means that we need a reference to the parent - # tile source or some other way of regulating it. - # elif image.dtype.kind == 'f': - # if numpy.max(image) > 1: - # maxl2 = math.ceil(math.log(numpy.max(image) + 1) / math.log(2)) - # image = image / ((2 ** maxl2) - 1) - # image = (image * 255).astype(numpy.uint8) - elif image.dtype != numpy.uint8: - image = image.astype(numpy.uint8) - image = PIL.Image.fromarray(image, mode) - elif not isinstance(image, PIL.Image.Image): - image = PIL.Image.open(io.BytesIO(image)) - if setMode is not None and image.mode != setMode: - image = image.convert(setMode) - return image - - -def _imageToNumpy(image): - """ - Convert an image in PIL, numpy, or image file format to a numpy array. The - output numpy array always has three dimensions. - - :param image: input image. - :returns: a numpy array and a target PIL image mode. - """ - if not isinstance(image, numpy.ndarray): - if not isinstance(image, PIL.Image.Image): - image = PIL.Image.open(io.BytesIO(image)) - if image.mode not in ('L', 'LA', 'RGB', 'RGBA'): - image = image.convert('RGBA') - mode = image.mode - image = numpy.asarray(image) - else: - if len(image.shape) == 3: - mode = ['L', 'LA', 'RGB', 'RGBA'][(image.shape[2] - 1) if image.shape[2] <= 4 else 3] - else: - mode = 'L' - if len(image.shape) == 2: - image = numpy.resize(image, (image.shape[0], image.shape[1], 1)) - return image, mode - - -def _letterboxImage(image, width, height, fill): - """ - Given a PIL image, width, height, and fill color, letterbox or pillarbox - the image to make it the specified dimensions. The image is never - cropped. The original image will be returned if no action is needed. - - :param image: the source image. - :param width: the desired width in pixels. - :param height: the desired height in pixels. - :param fill: a fill color. - """ - if ((image.width >= width and image.height >= height) or - not fill or str(fill).lower() == 'none'): - return image - color = PIL.ImageColor.getcolor(fill, image.mode) - width = max(width, image.width) - height = max(height, image.height) - result = PIL.Image.new(image.mode, (width, height), color) - result.paste(image, (int((width - image.width) / 2), int((height - image.height) / 2))) - return result - - -def _vipsCast(image, mustBe8Bit=False, originalScale=None): - """ - Cast a vips image to a format we want. - - :param image: a vips image - :param mustBe9Bit: if True, then always cast to unsigned 8-bit. - :param originalScale: - :returns: a vips image - """ - import pyvips - - formats = { - pyvips.BandFormat.CHAR: (pyvips.BandFormat.UCHAR, 2**7, 1), - pyvips.BandFormat.COMPLEX: (pyvips.BandFormat.USHORT, 0, 65535), - pyvips.BandFormat.DOUBLE: (pyvips.BandFormat.USHORT, 0, 65535), - pyvips.BandFormat.DPCOMPLEX: (pyvips.BandFormat.USHORT, 0, 65535), - pyvips.BandFormat.FLOAT: (pyvips.BandFormat.USHORT, 0, 65535), - pyvips.BandFormat.INT: (pyvips.BandFormat.USHORT, 2**31, 2**-16), - pyvips.BandFormat.USHORT: (pyvips.BandFormat.UCHAR, 0, 2**-8), - pyvips.BandFormat.SHORT: (pyvips.BandFormat.USHORT, 2**15, 1), - pyvips.BandFormat.UINT: (pyvips.BandFormat.USHORT, 0, 2**-16), - } - if image.format not in formats or (image.format == pyvips.BandFormat.USHORT and not mustBe8Bit): - return image - target, offset, multiplier = formats[image.format] - if image.format == pyvips.BandFormat.DOUBLE: - maxVal = image.max() - # These thresholds are higher than 256 and 65536 because bicubic and - # other interpolations can cause value spikes - if maxVal >= 2 and maxVal < 2**9: - multiplier = 256 - elif maxVal >= 256 and maxVal < 2**17: - multiplier = 1 - if mustBe8Bit and target != pyvips.BandFormat.UCHAR: - target = pyvips.BandFormat.UCHAR - multiplier /= 256 - # logger.debug('Casting image from %r to %r', image.format, target) - image = ((image.cast(pyvips.BandFormat.DOUBLE) + offset) * multiplier).cast(target) - return image - - -def _gdalParameters(defaultCompression=None, **kwargs): - """ - Return an array of gdal translation parameters. - - :param defaultCompression: if not specified, use this value. - - Optional parameters that can be specified in kwargs: - - :param tileSize: the horizontal and vertical tile size. - :param compression: one of 'jpeg', 'deflate' (zip), 'lzw', 'packbits', - 'zstd', or 'none'. - :param quality: a jpeg quality passed to gdal. 0 is small, 100 is high - quality. 90 or above is recommended. - :param level: compression level for zstd, 1-22 (default is 10). - :param predictor: one of 'none', 'horizontal', or 'float' used for lzw and - deflate. - :returns: a dictionary of parameters. - """ - options = { - 'tileSize': 256, - 'compression': 'lzw', - 'quality': 90, - 'predictor': 'yes', - } - predictor = { - 'none': 'NO', - 'horizontal': 'STANDARD', - 'float': 'FLOATING_POINT', - 'yes': 'YES', - } - options.update({k: v for k, v in kwargs.items() if v not in (None, '')}) - cmdopt = ['-of', 'COG', '-co', 'BIGTIFF=IF_SAFER'] - cmdopt += ['-co', 'BLOCKSIZE=%d' % options['tileSize']] - cmdopt += ['-co', 'COMPRESS=%s' % options['compression'].upper()] - cmdopt += ['-co', 'QUALITY=%s' % options['quality']] - cmdopt += ['-co', 'PREDICTOR=%s' % predictor[options['predictor']]] - if 'level' in options: - cmdopt += ['-co', 'LEVEL=%s' % options['level']] - return cmdopt - - -def _vipsParameters(forTiled=True, defaultCompression=None, **kwargs): - """ - Return a dictionary of vips conversion parameters. - - :param forTiled: True if this is for a tiled image. False for an - associated image. - :param defaultCompression: if not specified, use this value. - - Optional parameters that can be specified in kwargs: - - :param tileSize: the horizontal and vertical tile size. - :param compression: one of 'jpeg', 'deflate' (zip), 'lzw', 'packbits', - 'zstd', or 'none'. - :param quality: a jpeg quality passed to vips. 0 is small, 100 is high - quality. 90 or above is recommended. - :param level: compression level for zstd, 1-22 (default is 10). - :param predictor: one of 'none', 'horizontal', or 'float' used for lzw and - deflate. - :returns: a dictionary of parameters. - """ - if not forTiled: - convertParams = { - 'compression': defaultCompression or 'jpeg', - 'Q': 90, - 'predictor': 'horizontal', - 'tile': False, - } - if 'mime' in kwargs and kwargs.get('mime') != 'image/jpeg': - convertParams['compression'] = 'lzw' - return convertParams - convertParams = { - 'tile': True, - 'tile_width': 256, - 'tile_height': 256, - 'pyramid': True, - 'bigtiff': True, - 'compression': defaultCompression or 'jpeg', - 'Q': 90, - 'predictor': 'horizontal', - } - for vkey, kwkeys in { - 'tile_width': {'tileSize'}, - 'tile_height': {'tileSize'}, - 'compression': {'compression', 'tiffCompression'}, - 'Q': {'quality', 'jpegQuality'}, - 'level': {'level'}, - 'predictor': {'predictor'}, - }.items(): - for kwkey in kwkeys: - if kwkey in kwargs and kwargs[kwkey] not in {None, ''}: - convertParams[vkey] = kwargs[kwkey] - if convertParams['compression'] == 'jp2k': - convertParams['compression'] = 'none' - if convertParams['compression'] == 'webp' and kwargs.get('quality') == 0: - convertParams['lossless'] = True - convertParams.pop('Q', None) - if convertParams['predictor'] == 'yes': - convertParams['predictor'] = 'horizontal' - if convertParams['compression'] == 'jpeg': - convertParams['rgbjpeg'] = True - return convertParams - - -def etreeToDict(t): - """ - Convert an xml etree to a nested dictionary without schema names in the - keys. If you have an xml string, this can be converted to a dictionary via - xml.etree.etreeToDict(ElementTree.fromstring(xml_string)). - - :param t: an etree. - :returns: a python dictionary with the results. - """ - # Remove schema - tag = t.tag.split('}', 1)[1] if t.tag.startswith('{') else t.tag - d = {tag: {}} - children = list(t) - if children: - entries = defaultdict(list) - for entry in map(etreeToDict, children): - for k, v in entry.items(): - entries[k].append(v) - d = {tag: {k: v[0] if len(v) == 1 else v - for k, v in entries.items()}} - - if t.attrib: - d[tag].update({(k.split('}', 1)[1] if k.startswith('{') else k): v - for k, v in t.attrib.items()}) - text = (t.text or '').strip() - if text and len(d[tag]): - d[tag]['text'] = text - elif text: - d[tag] = text - return d - - -def dictToEtree(d, root=None): - """ - Convert a dictionary in the style produced by etreeToDict back to an etree. - Make an xml string via xml.etree.ElementTree.tostring(dictToEtree( - dictionary), encoding='utf8', method='xml'). Note that this function and - etreeToDict are not perfect conversions; numerical values are quoted in - xml. Plain key-value pairs are ambiguous whether they should be attributes - or text values. Text fields are collected together. - - :param d: a dictionary. - :prarm root: the root node to attach this dictionary to. - :returns: an etree. - """ - if root is None: - if len(d) == 1: - k, v = next(iter(d.items())) - root = xml.etree.ElementTree.Element(k) - dictToEtree(v, root) - return root - root = xml.etree.ElementTree.Element('root') - for k, v in d.items(): - if isinstance(v, list): - for l in v: - elem = xml.etree.ElementTree.SubElement(root, k) - dictToEtree(l, elem) - elif isinstance(v, dict): - elem = xml.etree.ElementTree.SubElement(root, k) - dictToEtree(v, elem) - else: - if k == 'text': - root.text = v - else: - root.set(k, v) - return root - - -def nearPowerOfTwo(val1, val2, tolerance=0.02): - """ - Check if two values are different by nearly a power of two. - - :param val1: the first value to check. - :param val2: the second value to check. - :param tolerance: the maximum difference in the log2 ratio's mantissa. - :return: True if the valeus are nearly a power of two different from each - other; false otherwise. - """ - # If one or more of the values is zero or they have different signs, then - # return False - if val1 * val2 <= 0: - return False - log2ratio = math.log(float(val1) / float(val2)) / math.log(2) - # Compare the mantissa of the ratio's log2 value. - return abs(log2ratio - round(log2ratio)) < tolerance - - -class LazyTileDict(dict): - """ - Tiles returned from the tile iterator and dictionaries of information with - actual image data in the 'tile' key and the format in the 'format' key. - Since some applications need information about the tile but don't need the - image data, these two values are lazily computed. The LazyTileDict can be - treated like a regular dictionary, except that when either of those two - keys are first accessed, they will cause the image to be loaded and - possibly converted to a PIL image and cropped. - - Unless setFormat is called on the tile, tile images may always be returned - as PIL images. - """ - - def __init__(self, tileInfo, *args, **kwargs): - """ - Create a LazyTileDict dictionary where there is enough information to - load the tile image. ang and kwargs are as for the dict() class. - - :param tileInfo: a dictionary of x, y, level, format, encoding, crop, - and source, used for fetching the tile image. - """ - self.x = tileInfo['x'] - self.y = tileInfo['y'] - self.frame = tileInfo.get('frame') - self.level = tileInfo['level'] - self.format = tileInfo['format'] - self.encoding = tileInfo['encoding'] - self.crop = tileInfo['crop'] - self.source = tileInfo['source'] - self.resample = tileInfo.get('resample', False) - self.requestedScale = tileInfo.get('requestedScale') - self.metadata = tileInfo.get('metadata') - self.retile = tileInfo.get('retile') and self.metadata - - self.deferredKeys = ('tile', 'format') - self.alwaysAllowPIL = True - self.imageKwargs = {} - self.loaded = False - result = super().__init__(*args, **kwargs) - # We set this initially so that they are listed in known keys using the - # native dictionary methods - self['tile'] = None - self['format'] = None - self.width = self['width'] - self.height = self['height'] - return result - - def setFormat(self, format, resample=False, imageKwargs=None): - """ - Set a more restrictive output format for a tile, possibly also resizing - it via resampling. If this is not called, the tile may either be - returned as one of the specified formats or as a PIL image. - - :param format: a tuple or list 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). - :param resample: if not False or None, allow resampling. Once turned - on, this cannot be turned off on the tile. - :param imageKwargs: additional parameters that should be passed to - _encodeImage. - """ - # If any parameters are changed, mark the tile as not loaded, so that - # referring to a deferredKey will reload the image. - self.alwaysAllowPIL = False - if format is not None and format != self.format: - self.format = format - self.loaded = False - if (resample not in (False, None) and not self.resample and - self.requestedScale and round(self.requestedScale, 2) != 1.0): - self.resample = resample - self['scaled'] = 1.0 / self.requestedScale - self['tile_x'] = self.get('tile_x', self['x']) - self['tile_y'] = self.get('tile_y', self['y']) - self['tile_width'] = self.get('tile_width', self.width) - self['tile_height'] = self.get('tile_width', self.height) - if self.get('magnification', None): - self['tile_magnification'] = self.get('tile_magnification', self['magnification']) - self['tile_mm_x'] = self.get('mm_x') - self['tile_mm_y'] = self.get('mm_y') - self['x'] = float(self['tile_x']) - self['y'] = float(self['tile_y']) - # Add provisional width and height - if self.resample not in (False, None) and self.requestedScale: - self['width'] = max(1, int( - self['tile_width'] / self.requestedScale)) - self['height'] = max(1, int( - self['tile_height'] / self.requestedScale)) - if self.get('tile_magnification', None): - self['magnification'] = self['tile_magnification'] / self.requestedScale - if self.get('tile_mm_x', None): - self['mm_x'] = self['tile_mm_x'] * self.requestedScale - if self.get('tile_mm_y', None): - self['mm_y'] = self['tile_mm_y'] * self.requestedScale - # If we can resample the tile, many parameters may change once the - # image is loaded. Don't include width and height in this list; - # the provisional values are sufficient. - self.deferredKeys = ('tile', 'format') - self.loaded = False - if imageKwargs is not None: - self.imageKwargs = imageKwargs - self.loaded = False - - def _retileTile(self): - """ - Given the tile information, create a numpy array and merge multiple - tiles together to form a tile of a different size. - """ - retile = None - xmin = int(max(0, self['x'] // self.metadata['tileWidth'])) - xmax = int((self['x'] + self.width - 1) // self.metadata['tileWidth'] + 1) - ymin = int(max(0, self['y'] // self.metadata['tileHeight'])) - ymax = int((self['y'] + self.height - 1) // self.metadata['tileHeight'] + 1) - for x in range(xmin, xmax): - for y in range(ymin, ymax): - tileData = self.source.getTile( - x, y, self.level, - numpyAllowed='always', sparseFallback=True, frame=self.frame) - tileData, _ = _imageToNumpy(tileData) - if retile is None: - retile = numpy.zeros( - (self.height, self.width) if len(tileData.shape) == 2 else - (self.height, self.width, tileData.shape[2]), - dtype=tileData.dtype) - x0 = int(x * self.metadata['tileWidth'] - self['x']) - y0 = int(y * self.metadata['tileHeight'] - self['y']) - if x0 < 0: - tileData = tileData[:, -x0:] - x0 = 0 - if y0 < 0: - tileData = tileData[-y0:, :] - y0 = 0 - tileData = tileData[:min(tileData.shape[0], self.height - y0), - :min(tileData.shape[1], self.width - x0)] - retile[y0:y0 + tileData.shape[0], x0:x0 + tileData.shape[1]] = tileData - return retile - - def __getitem__(self, key, *args, **kwargs): - """ - If this is the first time either the tile or format key is requested, - load the tile image data. Otherwise, just return the internal - dictionary result. - - See the base dict class for function details. - """ - if not self.loaded and key in self.deferredKeys: - # Flag this immediately to avoid recursion if we refer to the - # tile's own values. - self.loaded = True - - if not self.retile: - tileData = self.source.getTile( - self.x, self.y, self.level, - pilImageAllowed=True, numpyAllowed=True, - sparseFallback=True, frame=self.frame) - if self.crop: - tileData, _ = _imageToNumpy(tileData) - tileData = tileData[self.crop[1]:self.crop[3], self.crop[0]:self.crop[2]] - else: - tileData = self._retileTile() - - pilData = _imageToPIL(tileData) - - # resample if needed - if self.resample not in (False, None) and self.requestedScale: - self['width'] = max(1, int( - pilData.size[0] / self.requestedScale)) - self['height'] = max(1, int( - pilData.size[1] / self.requestedScale)) - pilData = tileData = pilData.resize( - (self['width'], self['height']), - resample=PIL.Image.LANCZOS if self.resample is True else self.resample) - - tileFormat = (TILE_FORMAT_PIL if isinstance(tileData, PIL.Image.Image) - else (TILE_FORMAT_NUMPY if isinstance(tileData, numpy.ndarray) - else TILE_FORMAT_IMAGE)) - tileEncoding = None if tileFormat != TILE_FORMAT_IMAGE else ( - 'JPEG' if tileData[:3] == b'\xff\xd8\xff' else - 'PNG' if tileData[:4] == b'\x89PNG' else - 'TIFF' if tileData[:4] == b'II\x2a\x00' else - None) - # Reformat the image if required - if (not self.alwaysAllowPIL or - (TILE_FORMAT_NUMPY in self.format and isinstance(tileData, numpy.ndarray))): - if (tileFormat in self.format and (tileFormat != TILE_FORMAT_IMAGE or ( - tileEncoding and - tileEncoding == self.imageKwargs.get('encoding', self.encoding)))): - # already in an acceptable format - pass - elif TILE_FORMAT_NUMPY in self.format: - tileData, _ = _imageToNumpy(tileData) - tileFormat = TILE_FORMAT_NUMPY - elif TILE_FORMAT_PIL in self.format: - tileData = pilData - tileFormat = TILE_FORMAT_PIL - elif TILE_FORMAT_IMAGE in self.format: - tileData, mimeType = _encodeImage( - tileData, **self.imageKwargs) - tileFormat = TILE_FORMAT_IMAGE - if tileFormat not in self.format: - raise exceptions.TileSourceException( - 'Cannot yield tiles in desired format %r' % ( - self.format, )) - else: - tileData = pilData - tileFormat = TILE_FORMAT_PIL - - self['tile'] = tileData - self['format'] = tileFormat - return super().__getitem__(key, *args, **kwargs) +from .tiledict import LazyTileDict +from .utilities import (_encodeImage, _gdalParameters, # noqa: F401 + _imageToNumpy, _imageToPIL, _letterboxImage, _vipsCast, + _vipsParameters, dictToEtree, etreeToDict, + nearPowerOfTwo) class TileSource: diff --git a/large_image/tilesource/tiledict.py b/large_image/tilesource/tiledict.py new file mode 100644 index 000000000..c96c93926 --- /dev/null +++ b/large_image/tilesource/tiledict.py @@ -0,0 +1,221 @@ +import numpy +import PIL +import PIL.Image +import PIL.ImageColor +import PIL.ImageDraw + +from .. import exceptions +from ..constants import TILE_FORMAT_IMAGE, TILE_FORMAT_NUMPY, TILE_FORMAT_PIL +from .utilities import _encodeImage, _imageToNumpy, _imageToPIL + + +class LazyTileDict(dict): + """ + Tiles returned from the tile iterator and dictionaries of information with + actual image data in the 'tile' key and the format in the 'format' key. + Since some applications need information about the tile but don't need the + image data, these two values are lazily computed. The LazyTileDict can be + treated like a regular dictionary, except that when either of those two + keys are first accessed, they will cause the image to be loaded and + possibly converted to a PIL image and cropped. + + Unless setFormat is called on the tile, tile images may always be returned + as PIL images. + """ + + def __init__(self, tileInfo, *args, **kwargs): + """ + Create a LazyTileDict dictionary where there is enough information to + load the tile image. ang and kwargs are as for the dict() class. + + :param tileInfo: a dictionary of x, y, level, format, encoding, crop, + and source, used for fetching the tile image. + """ + self.x = tileInfo['x'] + self.y = tileInfo['y'] + self.frame = tileInfo.get('frame') + self.level = tileInfo['level'] + self.format = tileInfo['format'] + self.encoding = tileInfo['encoding'] + self.crop = tileInfo['crop'] + self.source = tileInfo['source'] + self.resample = tileInfo.get('resample', False) + self.requestedScale = tileInfo.get('requestedScale') + self.metadata = tileInfo.get('metadata') + self.retile = tileInfo.get('retile') and self.metadata + + self.deferredKeys = ('tile', 'format') + self.alwaysAllowPIL = True + self.imageKwargs = {} + self.loaded = False + result = super().__init__(*args, **kwargs) + # We set this initially so that they are listed in known keys using the + # native dictionary methods + self['tile'] = None + self['format'] = None + self.width = self['width'] + self.height = self['height'] + return result + + def setFormat(self, format, resample=False, imageKwargs=None): + """ + Set a more restrictive output format for a tile, possibly also resizing + it via resampling. If this is not called, the tile may either be + returned as one of the specified formats or as a PIL image. + + :param format: a tuple or list 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). + :param resample: if not False or None, allow resampling. Once turned + on, this cannot be turned off on the tile. + :param imageKwargs: additional parameters that should be passed to + _encodeImage. + """ + # If any parameters are changed, mark the tile as not loaded, so that + # referring to a deferredKey will reload the image. + self.alwaysAllowPIL = False + if format is not None and format != self.format: + self.format = format + self.loaded = False + if (resample not in (False, None) and not self.resample and + self.requestedScale and round(self.requestedScale, 2) != 1.0): + self.resample = resample + self['scaled'] = 1.0 / self.requestedScale + self['tile_x'] = self.get('tile_x', self['x']) + self['tile_y'] = self.get('tile_y', self['y']) + self['tile_width'] = self.get('tile_width', self.width) + self['tile_height'] = self.get('tile_width', self.height) + if self.get('magnification', None): + self['tile_magnification'] = self.get('tile_magnification', self['magnification']) + self['tile_mm_x'] = self.get('mm_x') + self['tile_mm_y'] = self.get('mm_y') + self['x'] = float(self['tile_x']) + self['y'] = float(self['tile_y']) + # Add provisional width and height + if self.resample not in (False, None) and self.requestedScale: + self['width'] = max(1, int( + self['tile_width'] / self.requestedScale)) + self['height'] = max(1, int( + self['tile_height'] / self.requestedScale)) + if self.get('tile_magnification', None): + self['magnification'] = self['tile_magnification'] / self.requestedScale + if self.get('tile_mm_x', None): + self['mm_x'] = self['tile_mm_x'] * self.requestedScale + if self.get('tile_mm_y', None): + self['mm_y'] = self['tile_mm_y'] * self.requestedScale + # If we can resample the tile, many parameters may change once the + # image is loaded. Don't include width and height in this list; + # the provisional values are sufficient. + self.deferredKeys = ('tile', 'format') + self.loaded = False + if imageKwargs is not None: + self.imageKwargs = imageKwargs + self.loaded = False + + def _retileTile(self): + """ + Given the tile information, create a numpy array and merge multiple + tiles together to form a tile of a different size. + """ + retile = None + xmin = int(max(0, self['x'] // self.metadata['tileWidth'])) + xmax = int((self['x'] + self.width - 1) // self.metadata['tileWidth'] + 1) + ymin = int(max(0, self['y'] // self.metadata['tileHeight'])) + ymax = int((self['y'] + self.height - 1) // self.metadata['tileHeight'] + 1) + for x in range(xmin, xmax): + for y in range(ymin, ymax): + tileData = self.source.getTile( + x, y, self.level, + numpyAllowed='always', sparseFallback=True, frame=self.frame) + tileData, _ = _imageToNumpy(tileData) + if retile is None: + retile = numpy.zeros( + (self.height, self.width) if len(tileData.shape) == 2 else + (self.height, self.width, tileData.shape[2]), + dtype=tileData.dtype) + x0 = int(x * self.metadata['tileWidth'] - self['x']) + y0 = int(y * self.metadata['tileHeight'] - self['y']) + if x0 < 0: + tileData = tileData[:, -x0:] + x0 = 0 + if y0 < 0: + tileData = tileData[-y0:, :] + y0 = 0 + tileData = tileData[:min(tileData.shape[0], self.height - y0), + :min(tileData.shape[1], self.width - x0)] + retile[y0:y0 + tileData.shape[0], x0:x0 + tileData.shape[1]] = tileData + return retile + + def __getitem__(self, key, *args, **kwargs): + """ + If this is the first time either the tile or format key is requested, + load the tile image data. Otherwise, just return the internal + dictionary result. + + See the base dict class for function details. + """ + if not self.loaded and key in self.deferredKeys: + # Flag this immediately to avoid recursion if we refer to the + # tile's own values. + self.loaded = True + + if not self.retile: + tileData = self.source.getTile( + self.x, self.y, self.level, + pilImageAllowed=True, numpyAllowed=True, + sparseFallback=True, frame=self.frame) + if self.crop: + tileData, _ = _imageToNumpy(tileData) + tileData = tileData[self.crop[1]:self.crop[3], self.crop[0]:self.crop[2]] + else: + tileData = self._retileTile() + + pilData = _imageToPIL(tileData) + + # resample if needed + if self.resample not in (False, None) and self.requestedScale: + self['width'] = max(1, int( + pilData.size[0] / self.requestedScale)) + self['height'] = max(1, int( + pilData.size[1] / self.requestedScale)) + pilData = tileData = pilData.resize( + (self['width'], self['height']), + resample=PIL.Image.LANCZOS if self.resample is True else self.resample) + + tileFormat = (TILE_FORMAT_PIL if isinstance(tileData, PIL.Image.Image) + else (TILE_FORMAT_NUMPY if isinstance(tileData, numpy.ndarray) + else TILE_FORMAT_IMAGE)) + tileEncoding = None if tileFormat != TILE_FORMAT_IMAGE else ( + 'JPEG' if tileData[:3] == b'\xff\xd8\xff' else + 'PNG' if tileData[:4] == b'\x89PNG' else + 'TIFF' if tileData[:4] == b'II\x2a\x00' else + None) + # Reformat the image if required + if (not self.alwaysAllowPIL or + (TILE_FORMAT_NUMPY in self.format and isinstance(tileData, numpy.ndarray))): + if (tileFormat in self.format and (tileFormat != TILE_FORMAT_IMAGE or ( + tileEncoding and + tileEncoding == self.imageKwargs.get('encoding', self.encoding)))): + # already in an acceptable format + pass + elif TILE_FORMAT_NUMPY in self.format: + tileData, _ = _imageToNumpy(tileData) + tileFormat = TILE_FORMAT_NUMPY + elif TILE_FORMAT_PIL in self.format: + tileData = pilData + tileFormat = TILE_FORMAT_PIL + elif TILE_FORMAT_IMAGE in self.format: + tileData, mimeType = _encodeImage( + tileData, **self.imageKwargs) + tileFormat = TILE_FORMAT_IMAGE + if tileFormat not in self.format: + raise exceptions.TileSourceException( + 'Cannot yield tiles in desired format %r' % ( + self.format, )) + else: + tileData = pilData + tileFormat = TILE_FORMAT_PIL + + self['tile'] = tileData + self['format'] = tileFormat + return super().__getitem__(key, *args, **kwargs) diff --git a/large_image/tilesource/utilities.py b/large_image/tilesource/utilities.py new file mode 100644 index 000000000..214b29edb --- /dev/null +++ b/large_image/tilesource/utilities.py @@ -0,0 +1,391 @@ +import io +import math +import xml.etree.ElementTree +from collections import defaultdict + +import numpy +import PIL +import PIL.Image +import PIL.ImageColor +import PIL.ImageDraw + +from ..constants import (TILE_FORMAT_IMAGE, TILE_FORMAT_NUMPY, TILE_FORMAT_PIL, + TileOutputMimeTypes, TileOutputPILFormat) + +# Turn off decompression warning check +PIL.Image.MAX_IMAGE_PIXELS = None + + +def _encodeImage(image, encoding='JPEG', jpegQuality=95, jpegSubsampling=0, + format=(TILE_FORMAT_IMAGE, ), tiffCompression='raw', + **kwargs): + """ + Convert a PIL or numpy image into raw output bytes and a mime type. + + :param image: a PIL image. + :param encoding: a valid PIL encoding (typically 'PNG' or 'JPEG'). Must + also be in the TileOutputMimeTypes map. + :param jpegQuality: the quality to use when encoding a JPEG. + :param jpegSubsampling: the subsampling level to use when encoding a JPEG. + :param format: the desired format or a tuple of allowed formats. Formats + are members of (TILE_FORMAT_PIL, TILE_FORMAT_NUMPY, TILE_FORMAT_IMAGE). + :param tiffCompression: the compression format to use when encoding a TIFF. + :returns: + :imageData: the image data in the specified format and encoding. + :imageFormatOrMimeType: the image mime type if the format is + TILE_FORMAT_IMAGE, or the format of the image data if it is + anything else. + """ + if not isinstance(format, (tuple, set, list)): + format = (format, ) + imageData = image + imageFormatOrMimeType = TILE_FORMAT_PIL + if TILE_FORMAT_NUMPY in format: + imageData, _ = _imageToNumpy(image) + imageFormatOrMimeType = TILE_FORMAT_NUMPY + elif TILE_FORMAT_PIL in format: + imageData = _imageToPIL(image) + imageFormatOrMimeType = TILE_FORMAT_PIL + elif TILE_FORMAT_IMAGE in format: + if encoding not in TileOutputMimeTypes: + raise ValueError('Invalid encoding "%s"' % encoding) + imageFormatOrMimeType = TileOutputMimeTypes[encoding] + image = _imageToPIL(image) + if image.width == 0 or image.height == 0: + imageData = b'' + else: + encoding = TileOutputPILFormat.get(encoding, encoding) + output = io.BytesIO() + params = {} + if encoding == 'JPEG' and image.mode not in ('L', 'RGB'): + image = image.convert('RGB' if image.mode != 'LA' else 'L') + if encoding == 'JPEG': + params['quality'] = jpegQuality + params['subsampling'] = jpegSubsampling + elif encoding in {'TIFF', 'TILED'}: + params['compression'] = { + 'none': 'raw', + 'lzw': 'tiff_lzw', + 'deflate': 'tiff_adobe_deflate', + }.get(tiffCompression, tiffCompression) + elif encoding == 'PNG': + params['compress_level'] = 2 + image.save(output, encoding, **params) + imageData = output.getvalue() + return imageData, imageFormatOrMimeType + + +def _imageToPIL(image, setMode=None): + """ + Convert an image in PIL, numpy, or image file format to a PIL image. + + :param image: input image. + :param setMode: if specified, the output image is converted to this mode. + :returns: a PIL image. + """ + if isinstance(image, numpy.ndarray): + mode = 'L' + if len(image.shape) == 3: + # Fallback for hyperspectral data to just use the first three bands + if image.shape[2] > 4: + image = image[:, :, :3] + mode = ['L', 'LA', 'RGB', 'RGBA'][image.shape[2] - 1] + if len(image.shape) == 3 and image.shape[2] == 1: + image = numpy.resize(image, image.shape[:2]) + if image.dtype == numpy.uint16: + image = numpy.floor_divide(image, 256).astype(numpy.uint8) + # TODO: The scaling of float data needs to be identical across all + # tiles of an image. This means that we need a reference to the parent + # tile source or some other way of regulating it. + # elif image.dtype.kind == 'f': + # if numpy.max(image) > 1: + # maxl2 = math.ceil(math.log(numpy.max(image) + 1) / math.log(2)) + # image = image / ((2 ** maxl2) - 1) + # image = (image * 255).astype(numpy.uint8) + elif image.dtype != numpy.uint8: + image = image.astype(numpy.uint8) + image = PIL.Image.fromarray(image, mode) + elif not isinstance(image, PIL.Image.Image): + image = PIL.Image.open(io.BytesIO(image)) + if setMode is not None and image.mode != setMode: + image = image.convert(setMode) + return image + + +def _imageToNumpy(image): + """ + Convert an image in PIL, numpy, or image file format to a numpy array. The + output numpy array always has three dimensions. + + :param image: input image. + :returns: a numpy array and a target PIL image mode. + """ + if not isinstance(image, numpy.ndarray): + if not isinstance(image, PIL.Image.Image): + image = PIL.Image.open(io.BytesIO(image)) + if image.mode not in ('L', 'LA', 'RGB', 'RGBA'): + image = image.convert('RGBA') + mode = image.mode + image = numpy.asarray(image) + else: + if len(image.shape) == 3: + mode = ['L', 'LA', 'RGB', 'RGBA'][(image.shape[2] - 1) if image.shape[2] <= 4 else 3] + else: + mode = 'L' + if len(image.shape) == 2: + image = numpy.resize(image, (image.shape[0], image.shape[1], 1)) + return image, mode + + +def _letterboxImage(image, width, height, fill): + """ + Given a PIL image, width, height, and fill color, letterbox or pillarbox + the image to make it the specified dimensions. The image is never + cropped. The original image will be returned if no action is needed. + + :param image: the source image. + :param width: the desired width in pixels. + :param height: the desired height in pixels. + :param fill: a fill color. + """ + if ((image.width >= width and image.height >= height) or + not fill or str(fill).lower() == 'none'): + return image + color = PIL.ImageColor.getcolor(fill, image.mode) + width = max(width, image.width) + height = max(height, image.height) + result = PIL.Image.new(image.mode, (width, height), color) + result.paste(image, (int((width - image.width) / 2), int((height - image.height) / 2))) + return result + + +def _vipsCast(image, mustBe8Bit=False, originalScale=None): + """ + Cast a vips image to a format we want. + + :param image: a vips image + :param mustBe9Bit: if True, then always cast to unsigned 8-bit. + :param originalScale: + :returns: a vips image + """ + import pyvips + + formats = { + pyvips.BandFormat.CHAR: (pyvips.BandFormat.UCHAR, 2**7, 1), + pyvips.BandFormat.COMPLEX: (pyvips.BandFormat.USHORT, 0, 65535), + pyvips.BandFormat.DOUBLE: (pyvips.BandFormat.USHORT, 0, 65535), + pyvips.BandFormat.DPCOMPLEX: (pyvips.BandFormat.USHORT, 0, 65535), + pyvips.BandFormat.FLOAT: (pyvips.BandFormat.USHORT, 0, 65535), + pyvips.BandFormat.INT: (pyvips.BandFormat.USHORT, 2**31, 2**-16), + pyvips.BandFormat.USHORT: (pyvips.BandFormat.UCHAR, 0, 2**-8), + pyvips.BandFormat.SHORT: (pyvips.BandFormat.USHORT, 2**15, 1), + pyvips.BandFormat.UINT: (pyvips.BandFormat.USHORT, 0, 2**-16), + } + if image.format not in formats or (image.format == pyvips.BandFormat.USHORT and not mustBe8Bit): + return image + target, offset, multiplier = formats[image.format] + if image.format == pyvips.BandFormat.DOUBLE: + maxVal = image.max() + # These thresholds are higher than 256 and 65536 because bicubic and + # other interpolations can cause value spikes + if maxVal >= 2 and maxVal < 2**9: + multiplier = 256 + elif maxVal >= 256 and maxVal < 2**17: + multiplier = 1 + if mustBe8Bit and target != pyvips.BandFormat.UCHAR: + target = pyvips.BandFormat.UCHAR + multiplier /= 256 + # logger.debug('Casting image from %r to %r', image.format, target) + image = ((image.cast(pyvips.BandFormat.DOUBLE) + offset) * multiplier).cast(target) + return image + + +def _gdalParameters(defaultCompression=None, **kwargs): + """ + Return an array of gdal translation parameters. + + :param defaultCompression: if not specified, use this value. + + Optional parameters that can be specified in kwargs: + + :param tileSize: the horizontal and vertical tile size. + :param compression: one of 'jpeg', 'deflate' (zip), 'lzw', 'packbits', + 'zstd', or 'none'. + :param quality: a jpeg quality passed to gdal. 0 is small, 100 is high + quality. 90 or above is recommended. + :param level: compression level for zstd, 1-22 (default is 10). + :param predictor: one of 'none', 'horizontal', or 'float' used for lzw and + deflate. + :returns: a dictionary of parameters. + """ + options = { + 'tileSize': 256, + 'compression': 'lzw', + 'quality': 90, + 'predictor': 'yes', + } + predictor = { + 'none': 'NO', + 'horizontal': 'STANDARD', + 'float': 'FLOATING_POINT', + 'yes': 'YES', + } + options.update({k: v for k, v in kwargs.items() if v not in (None, '')}) + cmdopt = ['-of', 'COG', '-co', 'BIGTIFF=IF_SAFER'] + cmdopt += ['-co', 'BLOCKSIZE=%d' % options['tileSize']] + cmdopt += ['-co', 'COMPRESS=%s' % options['compression'].upper()] + cmdopt += ['-co', 'QUALITY=%s' % options['quality']] + cmdopt += ['-co', 'PREDICTOR=%s' % predictor[options['predictor']]] + if 'level' in options: + cmdopt += ['-co', 'LEVEL=%s' % options['level']] + return cmdopt + + +def _vipsParameters(forTiled=True, defaultCompression=None, **kwargs): + """ + Return a dictionary of vips conversion parameters. + + :param forTiled: True if this is for a tiled image. False for an + associated image. + :param defaultCompression: if not specified, use this value. + + Optional parameters that can be specified in kwargs: + + :param tileSize: the horizontal and vertical tile size. + :param compression: one of 'jpeg', 'deflate' (zip), 'lzw', 'packbits', + 'zstd', or 'none'. + :param quality: a jpeg quality passed to vips. 0 is small, 100 is high + quality. 90 or above is recommended. + :param level: compression level for zstd, 1-22 (default is 10). + :param predictor: one of 'none', 'horizontal', or 'float' used for lzw and + deflate. + :returns: a dictionary of parameters. + """ + if not forTiled: + convertParams = { + 'compression': defaultCompression or 'jpeg', + 'Q': 90, + 'predictor': 'horizontal', + 'tile': False, + } + if 'mime' in kwargs and kwargs.get('mime') != 'image/jpeg': + convertParams['compression'] = 'lzw' + return convertParams + convertParams = { + 'tile': True, + 'tile_width': 256, + 'tile_height': 256, + 'pyramid': True, + 'bigtiff': True, + 'compression': defaultCompression or 'jpeg', + 'Q': 90, + 'predictor': 'horizontal', + } + for vkey, kwkeys in { + 'tile_width': {'tileSize'}, + 'tile_height': {'tileSize'}, + 'compression': {'compression', 'tiffCompression'}, + 'Q': {'quality', 'jpegQuality'}, + 'level': {'level'}, + 'predictor': {'predictor'}, + }.items(): + for kwkey in kwkeys: + if kwkey in kwargs and kwargs[kwkey] not in {None, ''}: + convertParams[vkey] = kwargs[kwkey] + if convertParams['compression'] == 'jp2k': + convertParams['compression'] = 'none' + if convertParams['compression'] == 'webp' and kwargs.get('quality') == 0: + convertParams['lossless'] = True + convertParams.pop('Q', None) + if convertParams['predictor'] == 'yes': + convertParams['predictor'] = 'horizontal' + if convertParams['compression'] == 'jpeg': + convertParams['rgbjpeg'] = True + return convertParams + + +def etreeToDict(t): + """ + Convert an xml etree to a nested dictionary without schema names in the + keys. If you have an xml string, this can be converted to a dictionary via + xml.etree.etreeToDict(ElementTree.fromstring(xml_string)). + + :param t: an etree. + :returns: a python dictionary with the results. + """ + # Remove schema + tag = t.tag.split('}', 1)[1] if t.tag.startswith('{') else t.tag + d = {tag: {}} + children = list(t) + if children: + entries = defaultdict(list) + for entry in map(etreeToDict, children): + for k, v in entry.items(): + entries[k].append(v) + d = {tag: {k: v[0] if len(v) == 1 else v + for k, v in entries.items()}} + + if t.attrib: + d[tag].update({(k.split('}', 1)[1] if k.startswith('{') else k): v + for k, v in t.attrib.items()}) + text = (t.text or '').strip() + if text and len(d[tag]): + d[tag]['text'] = text + elif text: + d[tag] = text + return d + + +def dictToEtree(d, root=None): + """ + Convert a dictionary in the style produced by etreeToDict back to an etree. + Make an xml string via xml.etree.ElementTree.tostring(dictToEtree( + dictionary), encoding='utf8', method='xml'). Note that this function and + etreeToDict are not perfect conversions; numerical values are quoted in + xml. Plain key-value pairs are ambiguous whether they should be attributes + or text values. Text fields are collected together. + + :param d: a dictionary. + :prarm root: the root node to attach this dictionary to. + :returns: an etree. + """ + if root is None: + if len(d) == 1: + k, v = next(iter(d.items())) + root = xml.etree.ElementTree.Element(k) + dictToEtree(v, root) + return root + root = xml.etree.ElementTree.Element('root') + for k, v in d.items(): + if isinstance(v, list): + for l in v: + elem = xml.etree.ElementTree.SubElement(root, k) + dictToEtree(l, elem) + elif isinstance(v, dict): + elem = xml.etree.ElementTree.SubElement(root, k) + dictToEtree(v, elem) + else: + if k == 'text': + root.text = v + else: + root.set(k, v) + return root + + +def nearPowerOfTwo(val1, val2, tolerance=0.02): + """ + Check if two values are different by nearly a power of two. + + :param val1: the first value to check. + :param val2: the second value to check. + :param tolerance: the maximum difference in the log2 ratio's mantissa. + :return: True if the valeus are nearly a power of two different from each + other; false otherwise. + """ + # If one or more of the values is zero or they have different signs, then + # return False + if val1 * val2 <= 0: + return False + log2ratio = math.log(float(val1) / float(val2)) / math.log(2) + # Compare the mantissa of the ratio's log2 value. + return abs(log2ratio - round(log2ratio)) < tolerance