From 1cfd89aff8d8590ba9248ad167c602dcabab32bd Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 6 Nov 2019 13:26:07 -0500 Subject: [PATCH] Add a style option. This better handles high bit-depth images. The style option is an optional json-encoded parameter. It is either an object with "bands" followed by a list or a single entry as from that list. Each entry is an object with all optional parameters. These are: - band: either a number or a string. If -1 or None, unspecified, the same as "gray". If a number, a 1-based numerical index into the channels of the image. If a string, one of ('red', 'green', 'blue', 'gray', 'alpha'). Note that 'gray' on an RGB or RGBA image will use the green band, and all colors on a greyscale image will use the luminance band. - min: the value to map to the first palette value. Defaults to 0. 'auto' to use 0 if the reported minimum and maximum of the band are between [0, 255] or use the reported minimum otherwise. 'min' or 'max' to always uses the reported minimum or maximum. - max: the value to map to the last palette value. Defaults to 255. 'auto' to use 0 if the reported minimum and maximum of the band are between [0, 255] or use the reported maximum otherwise. 'min' or 'max' to always uses the reported minimum or maximum. - palette: a list of two or more color strings, where color strings are of the form #RRGGBB, #RRGGBBAA, #RGB, #RGBA (or anything else PIL can parse). - nodata: the value to use for missing data. null or unset to not use a nodata value. - composite: either 'lighten' or 'multiply'. Defaults to 'lighten' for all except the alpha band. Bands are composited in the order listed. - clamp: either True to clamp values outside of the [min, max] to the ends of the palette or False to make outside values transparent. TODO: - Check that sparse and missing levels with 16-bit output are correct. --- .../girder_large_image/girder_tilesource.py | 19 +- girder/girder_large_image/rest/tiles.py | 4 + .../test_annotation/test_annotations_rest.py | 3 - large_image/tilesource/base.py | 507 ++++++++++++++---- setup.py | 2 +- .../large_image_source_mapnik/__init__.py | 13 +- .../large_image_source_ometiff/__init__.py | 21 +- .../large_image_source_openjpeg/__init__.py | 17 +- .../large_image_source_openslide/__init__.py | 7 +- .../pil/large_image_source_pil/__init__.py | 8 +- .../large_image_source_pil/girder_source.py | 10 +- .../test/large_image_source_test/__init__.py | 4 +- .../tiff/large_image_source_tiff/__init__.py | 33 +- .../large_image_source_tiff/tiff_reader.py | 48 +- test/test_files/test_orient0.tif | Bin 7761 -> 7761 bytes test/test_files/test_orient2.tif | Bin 7761 -> 7761 bytes test/test_files/test_orient3.tif | Bin 7761 -> 7761 bytes test/test_files/test_orient4.tif | Bin 7761 -> 7761 bytes test/test_files/test_orient5.tif | Bin 7761 -> 7761 bytes test/test_files/test_orient6.tif | Bin 7761 -> 7761 bytes test/test_files/test_orient7.tif | Bin 7761 -> 7761 bytes test/test_files/test_orient8.tif | Bin 7761 -> 7761 bytes test/test_source_ometiff.py | 33 ++ test/test_source_openslide.py | 30 +- test/test_source_tiff.py | 108 +++- 25 files changed, 675 insertions(+), 192 deletions(-) diff --git a/girder/girder_large_image/girder_tilesource.py b/girder/girder_large_image/girder_tilesource.py index cca73f710..072386d51 100644 --- a/girder/girder_large_image/girder_tilesource.py +++ b/girder/girder_large_image/girder_tilesource.py @@ -37,21 +37,26 @@ def __init__(self, item, *args, **kwargs): @staticmethod def getLRUHash(*args, **kwargs): - return '%s,%s,%s,%s,%s,%s,%s' % ( - str(args[0]['largeImage']['fileId']), args[0]['updated'], - kwargs.get('encoding', 'JPEG'), kwargs.get('jpegQuality', 95), - kwargs.get('jpegSubsampling', 0), kwargs.get('tiffCompression', 'raw'), - kwargs.get('edge', False)) + return '%s,%s,%s,%s,%s,%s,%s,%s' % ( + args[0]['largeImage']['fileId'], + args[0]['updated'], + kwargs.get('encoding', 'JPEG'), + kwargs.get('jpegQuality', 95), + kwargs.get('jpegSubsampling', 0), + kwargs.get('tiffCompression', 'raw'), + kwargs.get('edge', False), + kwargs.get('style', None)) def getState(self): - return '%s,%s,%s,%s,%s,%s,%s' % ( + return '%s,%s,%s,%s,%s,%s,%s,%s' % ( self.item['largeImage']['fileId'], self.item['updated'], self.encoding, self.jpegQuality, self.jpegSubsampling, self.tiffCompression, - self.edge) + self.edge, + self._jsonstyle) def _getLargeImagePath(self): # If self.mayHaveAdjacentFiles is True, we try to use the girder diff --git a/girder/girder_large_image/rest/tiles.py b/girder/girder_large_image/rest/tiles.py index 6ea5ab301..7c4389c32 100644 --- a/girder/girder_large_image/rest/tiles.py +++ b/girder/girder_large_image/rest/tiles.py @@ -551,6 +551,7 @@ def getTilesThumbnail(self, item, params): ('jpegSubsampling', int), ('tiffCompression', str), ('encoding', str), + ('style', str), ('contentDisposition', str), ]) try: @@ -636,6 +637,7 @@ def getTilesThumbnail(self, item, params): .param('tiffCompression', 'Compression method when storing a TIFF ' 'image', required=False, enum=['raw', 'tiff_lzw', 'jpeg', 'tiff_adobe_deflate']) + .param('style', 'JSON-encoded style string', required=False) .param('contentDisposition', 'Specify the Content-Disposition response ' 'header disposition-type value.', required=False, enum=['inline', 'attachment']) @@ -669,6 +671,7 @@ def getTilesRegion(self, item, params): ('jpegQuality', int), ('jpegSubsampling', int), ('tiffCompression', str), + ('style', str), ('contentDisposition', str), ]) try: @@ -767,6 +770,7 @@ def getAssociatedImage(self, itemId, image, params): ('jpegSubsampling', int), ('tiffCompression', str), ('encoding', str), + ('style', str), ('contentDisposition', str), ]) try: diff --git a/girder_annotation/test_annotation/test_annotations_rest.py b/girder_annotation/test_annotation/test_annotations_rest.py index 6e7acc958..47a91d160 100644 --- a/girder_annotation/test_annotation/test_annotations_rest.py +++ b/girder_annotation/test_annotation/test_annotations_rest.py @@ -380,9 +380,6 @@ def testUpdateAnnotation(self, server, admin): publicFolder = utilities.namedFolder(admin, 'Public') item = Item().createItem('sample', admin, publicFolder) annot = Annotation().createAnnotation(item, admin, sampleAnnotation) - import sys - sys.stderr.write('%r\n' % [annot]) # ##DWM:: - sys.stderr.write('%r\n' % [sampleAnnotation]) resp = server.request(path='/annotation/%s' % annot['_id'], user=admin) assert utilities.respStatus(resp) == 200 annot = resp.json diff --git a/large_image/tilesource/base.py b/large_image/tilesource/base.py index 6e5057fa3..2a755e03b 100644 --- a/large_image/tilesource/base.py +++ b/large_image/tilesource/base.py @@ -1,12 +1,15 @@ # -*- coding: utf-8 -*- +import json import math import numpy import PIL import PIL.Image import PIL.ImageColor import PIL.ImageDraw +import random import six +import threading from collections import defaultdict from six import BytesIO @@ -26,7 +29,7 @@ def _encodeImage(image, encoding='JPEG', jpegQuality=95, jpegSubsampling=0, format=(TILE_FORMAT_IMAGE, ), tiffCompression='raw', **kwargs): """ - Convert a PIL image into the raw output bytes and a mime type. + 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 @@ -42,20 +45,21 @@ def _encodeImage(image, encoding='JPEG', jpegQuality=95, jpegSubsampling=0, TILE_FORMAT_IMAGE, or the format of the image data if it is anything else. """ - if not isinstance(format, tuple): + if not isinstance(format, (tuple, set, list)): format = (format, ) imageData = image imageFormatOrMimeType = TILE_FORMAT_PIL - if TILE_FORMAT_PIL in format: - # already in an acceptable format - pass - elif TILE_FORMAT_NUMPY in format: - imageData = numpy.asarray(image) + 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: @@ -63,7 +67,7 @@ def _encodeImage(image, encoding='JPEG', jpegQuality=95, jpegSubsampling=0, output = BytesIO() params = {} if encoding == 'JPEG' and image.mode not in ('L', 'RGB'): - image = image.convert('RGB') + image = image.convert('RGB' if image.mode != 'LA' else 'L') if encoding == 'JPEG': params['quality'] = jpegQuality params['subsampling'] = jpegSubsampling @@ -74,6 +78,57 @@ def _encodeImage(image, encoding='JPEG', jpegQuality=95, jpegSubsampling=0, 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: + 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) + 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(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(BytesIO(image)) + if image.mode not in ('L', 'LA', 'RGB', 'RGBA'): + 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] + 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 @@ -257,15 +312,23 @@ def _retileTile(self): for y in range(ymin, ymax): tileData = self.source.getTile( x, y, self.level, - pilImageAllowed=True, sparseFallback=True, frame=self.frame) - if not isinstance(tileData, PIL.Image.Image): - tileData = PIL.Image.open(BytesIO(tileData)) + numpyAllowed='always', sparseFallback=True, frame=self.frame) if retile is None: - retile = PIL.Image.new( - tileData.mode, (self.width, self.height)) - retile.paste(tileData, ( - int(x * self.metadata['tileWidth'] - self['x']), - int(y * self.metadata['tileHeight'] - self['y']))) + 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): @@ -284,44 +347,48 @@ def __getitem__(self, key, *args, **kwargs): if not self.retile: tileData = self.source.getTile( self.x, self.y, self.level, - pilImageAllowed=True, sparseFallback=True, frame=self.frame) + pilImageAllowed=True, numpyAllowed=True, + sparseFallback=True, frame=self.frame) else: tileData = self._retileTile() - tileFormat = TILE_FORMAT_PIL - # If the tile isn't in PIL format, and it is not in an image format - # that is the same as a desired output format and encoding, convert - # it to PIL format. - if not isinstance(tileData, PIL.Image.Image): - pilData = PIL.Image.open(BytesIO(tileData)) - if (self.format and TILE_FORMAT_IMAGE in self.format and - pilData.format == self.encoding): - tileFormat = TILE_FORMAT_IMAGE - else: - tileData = pilData - else: - pilData = tileData + if self.crop and not self.retile: - tileData = pilData.crop(self.crop) - tileFormat = TILE_FORMAT_PIL + tileData, _ = _imageToNumpy(tileData) + tileData = tileData[self.crop[1]:self.crop[3], self.crop[0]:self.crop[2]] + pilData = _imageToPIL(tileData) # resample if needed if self.resample not in (False, None) and self.requestedScale: self['width'] = max(1, int( - tileData.size[0] / self.requestedScale)) + pilData.size[0] / self.requestedScale)) self['height'] = max(1, int( - tileData.size[1] / self.requestedScale)) - tileData = tileData.resize( + 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: - if tileFormat in self.format: + 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 = numpy.asarray(tileData) + 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) @@ -330,6 +397,9 @@ def __getitem__(self, key, *args, **kwargs): 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 @@ -350,8 +420,8 @@ class TileSource(object): None: SourcePriority.FALLBACK } - def __init__(self, jpegQuality=95, jpegSubsampling=0, - encoding='JPEG', edge=False, tiffCompression='raw', *args, + def __init__(self, encoding='JPEG', jpegQuality=95, jpegSubsampling=0, + tiffCompression='raw', edge=False, style=None, *args, **kwargs): """ Initialize the tile class. @@ -364,6 +434,38 @@ def __init__(self, jpegQuality=95, jpegSubsampling=0, edge tiles, otherwise, an #rrggbb color to fill edges. :param tiffCompression: the compression format to use when encoding a TIFF. + :param style: if None, use the default style for the file. Otherwise, + this is a string with a json-encoded dictionary. The style can + contain the following keys: + band: if -1 or None, and if style is specified at all, the + greyscale value is used. Otherwise, a 1-based numerical + index into the channels of the image or a string that + matches the interpretation of the band ('red', 'green', + 'blue', 'gray', 'alpha'). Note that 'gray' on an RGB or + RGBA image will use the green band. + min: the value to map to the first palette value. Defaults to + 0. 'auto' to use 0 if the reported minimum and maximum of + the band are between [0, 255] or use the reported minimum + otherwise. 'min' or 'max' to always uses the reported + minimum or maximum. + max: the value to map to the last palette value. Defaults to + 255. 'auto' to use 0 if the reported minimum and maximum + of the band are between [0, 255] or use the reported + maximum otherwise. 'min' or 'max' to always uses the + reported minimum or maximum. + palette: a list of two or more color strings, where color + strings are of the form #RRGGBB, #RRGGBBAA, #RGB, #RGBA. + nodata: the value to use for missing data. null or unset to + not use a nodata value. + composite: either 'lighten' or 'multiply'. Defaults to + 'lighten' for all except the alpha band. + clamp: either True to clamp values outside of the [min, max] + to the ends of the palette or False to make outside values + transparent. + Alternately, the style object can contain a single key of 'bands', + which has a value which is a list of style dictionaries as above, + excepting that each must have a band that is not -1. Bands are + composited in the order listed. """ self.cache, self.cache_lock = getTileCache() @@ -372,6 +474,8 @@ def __init__(self, jpegQuality=95, jpegSubsampling=0, self.levels = None self.sizeX = None self.sizeY = None + self._styleLock = threading.RLock() + self._bandRanges = {} if encoding not in TileOutputMimeTypes: raise ValueError('Invalid encoding "%s"' % encoding) @@ -381,18 +485,30 @@ def __init__(self, jpegQuality=95, jpegSubsampling=0, self.jpegSubsampling = int(jpegSubsampling) self.tiffCompression = tiffCompression self.edge = edge + self._jsonstyle = style + if style: + try: + self.style = json.loads(style) + if not isinstance(self.style, dict): + raise TypeError + except TypeError: + raise exceptions.TileSourceException('Style is not a valid json object.') @staticmethod def getLRUHash(*args, **kwargs): return strhash( kwargs.get('encoding', 'JPEG'), kwargs.get('jpegQuality', 95), kwargs.get('jpegSubsampling', 0), kwargs.get('tiffCompression', 'raw'), - kwargs.get('edge', False)) + kwargs.get('edge', False), kwargs.get('style', None)) def getState(self): - return str(self.encoding) + ',' + str(self.jpegQuality) + ',' + \ - str(self.jpegSubsampling) + ',' + str(self.tiffCompression) + \ - ',' + str(self.edge) + return '%s,%s,%s,%s,%s,%s' % ( + self.encoding, + self.jpegQuality, + self.jpegSubsampling, + self.tiffCompression, + self.edge, + self._jsonstyle) def wrapKey(self, *args, **kwargs): return strhash(self.getState()) + strhash(*args, **kwargs) @@ -420,10 +536,10 @@ def _calculateWidthHeight(self, width, height, regionWidth, regionHeight): if width and not height: height = width * 16 if width * regionHeight > height * regionWidth: - scale = float(height) / regionHeight + scale = float(regionHeight) / height width = max(1, int(regionWidth * height / regionHeight)) else: - scale = float(width) / regionWidth + scale = float(regionWidth) / width height = max(1, int(regionHeight * width / regionWidth)) return width, height, scale @@ -751,8 +867,8 @@ def _tileIteratorInfo(self, **kwargs): # If we need to resample to make tiles at a non-native resolution, # adjust the tile size and tile overlap paramters appropriately. if resample is not False: - tile_size['width'] = int(round(tile_size['width'] * requestedScale)) - tile_size['height'] = int(round(tile_size['height'] * requestedScale)) + tile_size['width'] = max(1, int(round(tile_size['width'] * requestedScale))) + tile_size['height'] = max(1, int(round(tile_size['height'] * requestedScale))) tile_overlap['x'] = int(round(tile_overlap['x'] * requestedScale)) tile_overlap['y'] = int(round(tile_overlap['y'] * requestedScale)) @@ -809,10 +925,10 @@ def _tileIterator(self, iterInfo): 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. Always 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. + 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 @@ -1011,10 +1127,180 @@ def _pilFormatMatches(self, image, match=True, **kwargs): # compatibility could be an issue. return False - def _outputTile(self, tile, tileEncoding, x, y, z, pilImageAllowed=False, **kwargs): + def _scanForMinMax(self, dtype, frame=None, analysisSize=1024): """ - Convert a tile from a PIL image or image in memory to the desired - encoding. + Scan the image at a lower resolution to find the minimum and maximum + values. + + :param dtype: the numpy dtype. Used for guessing the range. + :param frame: the frame to use for auto-ranging. + :param analysisSize: the size of the image to use for analysis. + """ + self._skipStyle = True + # Divert the tile cache while querying unstyled tiles + classkey = self._classkey + self._classkey = 'nocache' + str(random.random) + try: + self._bandRanges[frame] = None + for tile in self.tileIterator( + output={'maxWidth': min(self.sizeX, analysisSize), + 'maxHeight': min(self.sizeY, analysisSize)}, + frame=frame): + tile = tile['tile'] + if tile.dtype != dtype: + if tile.dtype == numpy.uint8 and dtype == numpy.uint16: + tile = numpy.array(tile, dtype=numpy.uint16) * 257 + else: + continue + tilemin = numpy.array([ + numpy.amin(tile[:, :, idx]) for idx in range(tile.shape[2])], tile.dtype) + tilemax = numpy.array([ + numpy.amax(tile[:, :, idx]) for idx in range(tile.shape[2])], tile.dtype) + if self._bandRanges[frame] is None: + self._bandRanges[frame] = {'min': tilemin, 'max': tilemax} + self._bandRanges[frame]['min'] = numpy.minimum( + self._bandRanges[frame]['min'], tilemin) + self._bandRanges[frame]['max'] = numpy.maximum( + self._bandRanges[frame]['max'], tilemax) + if self._bandRanges[frame]: + config.getConfig('logger').info('Style range is %r' % self._bandRanges[frame]) + # Add histogram collection here + finally: + del self._skipStyle + self._classkey = classkey + + def _getMinMax(self, minmax, value, dtype, bandidx=None, frame=None): + """ + Get an appropriate minimum or maximum for a band. + + :param minmax: either 'min' or 'max'. + :param value: the specified value, 'auto', 'min', or 'max'. 'auto' + uses the parameter specified in 'minmax' or 0 or 255 if the + band's minimum is in the range [0, 254] and maximum is in the range + [2, 255]. + :param dtype: the numpy dtype. Used for guessing the range. + :param bandidx: the index of the channel that could be used for + determining the min or max. + :param frame: the frame to use for auto-ranging. + """ + frame = frame or 0 + if value not in {'min', 'max', 'auto'}: + try: + value = float(value) + except ValueError: + config.getConfig('logger').warn( + 'Style min/max value of %r is not valid; using "auto"', value) + value = 'auto' + if value in {'min', 'max', 'auto'} and frame not in self._bandRanges: + self._scanForMinMax(dtype, frame) + if value == 'auto': + if (self._bandRanges.get(frame) and + numpy.all(self._bandRanges[frame]['min'] >= 0) and + numpy.all(self._bandRanges[frame]['min'] <= 254) and + numpy.all(self._bandRanges[frame]['max'] >= 2) and + numpy.all(self._bandRanges[frame]['max'] <= 255)): + value = 0 if minmax == 'min' else 255 + else: + value = minmax + if value == 'min': + if bandidx is not None and self._bandRanges.get(frame): + value = self._bandRanges[frame]['min'][bandidx] + else: + value = 0 + elif value == 'max': + if bandidx is not None and self._bandRanges.get(frame): + value = self._bandRanges[frame]['max'][bandidx] + elif dtype == numpy.uint16: + value = 65535 + elif dtype == numpy.float: + value = 1 + else: + value = 255 + return float(value) + + def _applyStyle(self, image, style, frame=None): + """ + Apply a style to a numpy image. + + :param image: the image to modify. + :param style: a style object. + :param frame: the frame to use for auto ranging. + :returns: a styled image. + """ + style = style['bands'] if 'bands' in style else [style] + output = numpy.zeros((image.shape[0], image.shape[1], 4), numpy.float) + for entry in style: + bandidx = 0 if image.shape[2] <= 2 else 1 + band = image[:, :, 0] if image.shape[2] <= 2 else image[:, :, 1] + if (isinstance(entry.get('band'), six.integer_types) and + entry['band'] >= 1 and entry['band'] <= image.shape[2]): + band = image[:, :, entry['band'] - 1] + composite = entry.get('composite', 'lighten') + if entry.get('band') == 'red' and image.shape[2] > 2: + bandidx = 0 + band = image[:, :, 0] + elif entry.get('band') == 'blue' and image.shape[2] > 2: + bandidx = 2 + band = image[:, :, 2] + elif entry.get('band') == 'alpha': + bandidx = image.shape[2] - 1 if image.shape[2] in (2, 4) else None + band = (image[:, :, -1] if image.shape[2] in (2, 4) else + numpy.full(image.shape[:2], 255, numpy.uint8)) + composite = entry.get('composite', 'multiply') + palette = numpy.array([ + PIL.ImageColor.getcolor(clr, 'RGBA') for clr in entry.get( + 'palette', ['#000', '#FFF'] + if entry.get('band') != 'alpha' else ['#FFF0', '#FFFF'])]) + palettebase = numpy.linspace(0, 1, len(palette), endpoint=True) + nodata = entry.get('nodata') + min = self._getMinMax('min', entry.get('min', 'auto'), image.dtype, bandidx, frame) + max = self._getMinMax('max', entry.get('max', 'auto'), image.dtype, bandidx, frame) + clamp = entry.get('clamp', True) + delta = max - min if max != min else 1 + if nodata is not None: + keep = band != nodata + else: + keep = numpy.full(image.shape[:2], True) + band = (band - min) / delta + if not clamp: + keep = keep & (band >= 0) & (band <= 1) + for channel in range(4): + clrs = numpy.interp(band, palettebase, palette[:, channel]) + if composite == 'multiply': + output[:, :, channel] = numpy.multiply( + output[:, :, channel], numpy.where(keep, clrs, 1)) + else: + output[:, :, channel] = numpy.maximum( + output[:, :, channel], numpy.where(keep, clrs, 0)) + return output + + def _outputTileNumpyStyle(self, tile, applyStyle, frame=None): + """ + Convert a tile to a NUMPY array. Optionally apply the style to a tile. + Always returns a NUMPY tile. + + :param tile: the tile to convert. + :param applyStyle: if True and there is a style, apply it. + :param frame: the frame to use for auto-ranging. + :returns: a numpy array and a target PIL image mode. + """ + tile, mode = _imageToNumpy(tile) + if applyStyle and getattr(self, 'style', None): + with self._styleLock: + if not getattr(self, '_skipStyle', False): + tile = self._applyStyle(tile, self.style, frame) + if tile.shape[0] != self.tileHeight or tile.shape[1] != self.tileWidth: + extend = numpy.zeros((self.tileHeight, self.tileWidth, tile.shape[2])) + extend[:min(self.tileHeight, tile.shape[0]), + :min(self.tileWidth, tile.shape[1])] = tile + tile = extend + return tile, mode + + def _outputTile(self, tile, tileEncoding, x, y, z, pilImageAllowed=False, + numpyAllowed=False, applyStyle=True, **kwargs): + """ + Convert a tile from a numpy array, PIL image, or image in memory to the + desired encoding. :param tile: the tile to convert. :param tileEncoding: the current tile encoding. @@ -1022,7 +1308,11 @@ def _outputTile(self, tile, tileEncoding, x, y, z, pilImageAllowed=False, **kwar :param y: tile y value. Used for cropping or edge adjustment. :param z: tile z (level) value. Used for cropping or edge adjustment. :param pilImageAllowed: True if a PIL image may be returned. - :returns: either a PIL image or a memory object with an image file. + :param numpyAllowed: True if a NUMPY image may be returned. 'always' + to return a numpy array. + :param applyStyle: if True and there is a style, apply it. + :returns: either a numpy array, a PIL image, or a memory object with an + image file. """ isEdge = False if self.edge: @@ -1031,27 +1321,30 @@ def _outputTile(self, tile, tileEncoding, x, y, z, pilImageAllowed=False, **kwar maxX = (x + 1) * self.tileWidth maxY = (y + 1) * self.tileHeight isEdge = maxX > sizeX or maxY > sizeY - if tileEncoding != TILE_FORMAT_PIL: - if tileEncoding == self.encoding and not isEdge: - return tile - tile = PIL.Image.open(BytesIO(tile)) + if (tileEncoding not in (TILE_FORMAT_PIL, TILE_FORMAT_NUMPY) and + numpyAllowed != 'always' and tileEncoding == self.encoding and + not isEdge and (not applyStyle or not getattr(self, 'style', None))): + return tile + mode = None + if (numpyAllowed == 'always' or tileEncoding == TILE_FORMAT_NUMPY or + (applyStyle and getattr(self, 'style', None)) or isEdge): + tile, mode = self._outputTileNumpyStyle(tile, applyStyle, kwargs.get('frame')) if isEdge: contentWidth = min(self.tileWidth, sizeX - (maxX - self.tileWidth)) contentHeight = min(self.tileHeight, sizeY - (maxY - self.tileHeight)) + tile, mode = _imageToNumpy(tile) if self.edge in (True, 'crop'): - tile = tile.crop((0, 0, contentWidth, contentHeight)) + tile = tile[:contentHeight, :contentWidth] else: - color = PIL.ImageColor.getcolor(self.edge, tile.mode) - if contentWidth < self.tileWidth: - PIL.ImageDraw.Draw(tile).rectangle( - [(contentWidth, 0), (self.tileWidth, contentHeight)], - fill=color, outline=None) - if contentHeight < self.tileHeight: - PIL.ImageDraw.Draw(tile).rectangle( - [(0, contentHeight), (self.tileWidth, self.tileHeight)], - fill=color, outline=None) + color = PIL.ImageColor.getcolor(self.edge, mode) + tile = tile.copy() + tile[:, contentWidth:] = color + tile[contentHeight:] = color + if isinstance(tile, numpy.ndarray) and numpyAllowed: + return tile + tile = _imageToPIL(tile) if pilImageAllowed: return tile encoding = TileOutputPILFormat.get(self.encoding, self.encoding) @@ -1105,7 +1398,8 @@ def getMetadata(self): } @methodcache() - def getTile(self, x, y, z, pilImageAllowed=False, sparseFallback=False, frame=None): + def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, + sparseFallback=False, frame=None): raise NotImplementedError() def getTileMimeType(self): @@ -1143,7 +1437,7 @@ def getThumbnail(self, width=None, height=None, levelZero=False, **kwargs): return self.getRegion(**params) metadata = self.getMetadata() tileData = self.getTile(0, 0, 0) - image = PIL.Image.open(BytesIO(tileData)) + image = _imageToPIL(tileData) imageWidth = int(math.floor( metadata['sizeX'] * 2 ** -(metadata['levels'] - 1))) imageHeight = int(math.floor( @@ -1277,57 +1571,57 @@ def getRegion(self, format=(TILE_FORMAT_IMAGE, ), **kwargs): :returns: regionData, formatOrRegionMime: the image data and either the mime type, if the format is TILE_FORMAT_IMAGE, or the format. """ + if not isinstance(format, (tuple, set, list)): + format = (format, ) if 'tile_position' in kwargs: kwargs = kwargs.copy() kwargs.pop('tile_position', None) iterInfo = self._tileIteratorInfo(**kwargs) if iterInfo is None: - # In PIL 3.4.2, you can't directly create a 0 sized image. It was - # easier to do this before: - # image = PIL.Image.new('RGB', (0, 0)) - image = PIL.Image.new('RGB', (1, 1)).crop((0, 0, 0, 0)) + image = PIL.Image.new('RGB', (0, 0)) return _encodeImage(image, format=format, **kwargs) regionWidth = iterInfo['region']['width'] regionHeight = iterInfo['region']['height'] top = iterInfo['region']['top'] left = iterInfo['region']['left'] - mode = iterInfo['mode'] + mode = None if TILE_FORMAT_NUMPY in format else iterInfo['mode'] outWidth = iterInfo['output']['width'] outHeight = iterInfo['output']['height'] - # We can construct an image using PIL.Image.new: - # image = PIL.Image.new('RGB', (regionWidth, regionHeight)) - # but, for large images (larger than 4 Megapixels), PIL allocates one - # memory region per line. Although it frees this, the memory manager - # often fails to reuse these smallish pieces. By allocating the data - # memory ourselves in one block, the memory manager does a better job. - # Furthermore, if the source buffer isn't in RGBA format, the memory is - # still often inaccessible. - try: - image = PIL.Image.frombuffer( - mode, (regionWidth, regionHeight), - # PIL will reallocate buffers that aren't in 'raw', RGBA, 0, 1. - # See PIL documentation and code for more details. - b'\x00' * (regionWidth * regionHeight * 4), 'raw', 'RGBA', 0, 1) - except MemoryError: - raise exceptions.TileSourceException( - 'Insufficient memory to get region of %d x %d pixels.' % ( - regionWidth, regionHeight)) + image = None for tile in self._tileIterator(iterInfo): - # Add each tile to the image. PIL crops these if they are off the - # edge. - image.paste(tile['tile'], (tile['x'] - left, tile['y'] - top)) + # Add each tile to the image + subimage, _ = _imageToNumpy(tile['tile']) + if image is None: + try: + image = numpy.zeros( + (regionHeight, regionWidth, subimage.shape[2]), + dtype=subimage.dtype) + except MemoryError: + raise exceptions.TileSourceException( + 'Insufficient memory to get region of %d x %d pixels.' % ( + regionWidth, regionHeight)) + x0, y0 = tile['x'] - left, tile['y'] - top + if x0 < 0: + subimage = subimage[:, -x0:] + x0 = 0 + if y0 < 0: + subimage = subimage[-y0:, :] + y0 = 0 + subimage = subimage[:min(subimage.shape[0], regionHeight - y0), + :min(subimage.shape[1], regionWidth - x0)] + image[y0:y0 + subimage.shape[0], x0:x0 + subimage.shape[1]] = subimage # Scale if we need to outWidth = int(math.floor(outWidth)) outHeight = int(math.floor(outHeight)) if outWidth != regionWidth or outHeight != regionHeight: - image = image.resize( + image = _imageToPIL(image, mode).resize( (outWidth, outHeight), PIL.Image.BICUBIC if outWidth > regionWidth else PIL.Image.LANCZOS) maxWidth = kwargs.get('output', {}).get('maxWidth') maxHeight = kwargs.get('output', {}).get('maxHeight') if kwargs.get('fill') and maxWidth and maxHeight: - image = _letterboxImage(image, maxWidth, maxHeight, kwargs['fill']) + image = _letterboxImage(_imageToPIL(image, mode), maxWidth, maxHeight, kwargs['fill']) return _encodeImage(image, format=format, **kwargs) def getRegionAtAnotherScale(self, sourceRegion, sourceScale=None, @@ -1737,12 +2031,17 @@ def getLRUHash(*args, **kwargs): return strhash( args[0], kwargs.get('encoding', 'JPEG'), kwargs.get('jpegQuality', 95), kwargs.get('jpegSubsampling', 0), kwargs.get('tiffCompression', 'raw'), - kwargs.get('edge', False)) + kwargs.get('edge', False), kwargs.get('style', None)) def getState(self): - return self._getLargeImagePath() + ',' + str(self.encoding) + ',' + \ - str(self.jpegQuality) + ',' + str(self.jpegSubsampling) + ',' + \ - str(self.tiffCompression) + ',' + str(self.edge) + return '%s,%s,%s,%s,%s,%s,%s' % ( + self._getLargeImagePath(), + self.encoding, + self.jpegQuality, + self.jpegSubsampling, + self.tiffCompression, + self.edge, + self._jsonstyle) def _getLargeImagePath(self): return self.largeImagePath diff --git a/setup.py b/setup.py index e119a9c27..1de888730 100644 --- a/setup.py +++ b/setup.py @@ -63,7 +63,7 @@ def prerelease_local_scheme(version): ], install_requires=[ 'cachetools>=3.0.0', - 'Pillow>=3.2.0', + 'Pillow>=4.1.0', 'psutil>=4.2.0', # technically optional 'numpy>=1.10.4', 'six>=1.10.0', diff --git a/sources/mapnik/large_image_source_mapnik/__init__.py b/sources/mapnik/large_image_source_mapnik/__init__.py index 15a23a303..7fc1dc38b 100644 --- a/sources/mapnik/large_image_source_mapnik/__init__.py +++ b/sources/mapnik/large_image_source_mapnik/__init__.py @@ -33,9 +33,9 @@ from large_image import config from large_image.cache_util import LruCacheMetaclass, methodcache, CacheProperties -from large_image.constants import SourcePriority, TileInputUnits +from large_image.constants import SourcePriority, TileInputUnits, TILE_FORMAT_PIL from large_image.exceptions import TileSourceException -from large_image.tilesource import FileTileSource, TILE_FORMAT_PIL +from large_image.tilesource import FileTileSource try: @@ -758,7 +758,9 @@ def addStyle(self, m, layerSrs, extent=None): for styleBand in styleBands: styleBand = styleBand.copy() - styleBand['band'] = self._bandNumber(styleBand.get('band')) + # Default to band 1 -- perhaps we should default to gray or + # green instead. + styleBand['band'] = self._bandNumber(styleBand.get('band', 1)) style.append(styleBand) if not len(style): for interp in ('red', 'green', 'blue', 'gray', 'palette', 'alpha'): @@ -843,7 +845,8 @@ def getTile(self, x, y, z, **kwargs): if (xmin >= bounds['xmax'] or xmax <= bounds['xmin'] or ymin >= bounds['ymax'] or ymax <= bounds['ymin']): pilimg = PIL.Image.new('RGBA', (self.tileWidth, self.tileHeight)) - return self._outputTile(pilimg, TILE_FORMAT_PIL, x, y, z, **kwargs) + return self._outputTile( + pilimg, TILE_FORMAT_PIL, x, y, z, applyStyle=False, **kwargs) if overscan: pw = (xmax - xmin) / self.tileWidth py = (ymax - ymin) / self.tileHeight @@ -868,7 +871,7 @@ def getTile(self, x, y, z, **kwargs): pilimg = PIL.Image.frombytes('RGBA', (img.width(), img.height()), img.tostring()) if overscan: pilimg = pilimg.crop((1, 1, pilimg.width - overscan, pilimg.height - overscan)) - return self._outputTile(pilimg, TILE_FORMAT_PIL, x, y, z, **kwargs) + return self._outputTile(pilimg, TILE_FORMAT_PIL, x, y, z, applyStyle=False, **kwargs) @staticmethod def _proj4Proj(proj): diff --git a/sources/ometiff/large_image_source_ometiff/__init__.py b/sources/ometiff/large_image_source_ometiff/__init__.py index 42d014fae..201b484b4 100644 --- a/sources/ometiff/large_image_source_ometiff/__init__.py +++ b/sources/ometiff/large_image_source_ometiff/__init__.py @@ -17,15 +17,15 @@ ############################################################################## import math +import numpy import PIL.Image import six from pkg_resources import DistributionNotFound, get_distribution from six.moves import range from large_image.cache_util import LruCacheMetaclass, methodcache -from large_image.constants import SourcePriority +from large_image.constants import SourcePriority, TILE_FORMAT_PIL, TILE_FORMAT_NUMPY from large_image.exceptions import TileSourceException -from large_image.tilesource import TILE_FORMAT_PIL from large_image_source_tiff import TiffFileTileSource from large_image_source_tiff.tiff_reader import TiledTiffDirectory, \ @@ -238,12 +238,14 @@ def getNativeMagnification(self): return result @methodcache() - def getTile(self, x, y, z, pilImageAllowed=False, sparseFallback=False, - **kwargs): + def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, + sparseFallback=False, **kwargs): if (z < 0 or z >= len(self._omeLevels) or self._omeLevels[z] is None or kwargs.get('frame') in (None, 0, '0', '')): return super(OMETiffFileTileSource, self).getTile( - x, y, z, pilImageAllowed=pilImageAllowed, sparseFallback=sparseFallback, **kwargs) + x, y, z, pilImageAllowed=pilImageAllowed, + numpyAllowed=numpyAllowed, sparseFallback=sparseFallback, + **kwargs) frame = int(kwargs['frame']) if frame < 0 or frame >= len(self._omebase['TiffData']): raise TileSourceException('Frame does not exist') @@ -258,13 +260,16 @@ def getTile(self, x, y, z, pilImageAllowed=False, sparseFallback=False, try: tile = dir.getTile(x, y) format = 'JPEG' - if PIL and isinstance(tile, PIL.Image.Image): + if isinstance(tile, PIL.Image.Image): format = TILE_FORMAT_PIL + if isinstance(tile, numpy.ndarray): + format = TILE_FORMAT_NUMPY return self._outputTile(tile, format, x, y, z, pilImageAllowed, - **kwargs) + numpyAllowed, **kwargs) except InvalidOperationTiffException as e: raise TileSourceException(e.args[0]) except IOTiffException as e: return self.getTileIOTiffException( x, y, z, pilImageAllowed=pilImageAllowed, - sparseFallback=sparseFallback, exception=e, **kwargs) + numpyAllowed=numpyAllowed, sparseFallback=sparseFallback, + exception=e, **kwargs) diff --git a/sources/openjpeg/large_image_source_openjpeg/__init__.py b/sources/openjpeg/large_image_source_openjpeg/__init__.py index 2afcf09e0..7544602c2 100644 --- a/sources/openjpeg/large_image_source_openjpeg/__init__.py +++ b/sources/openjpeg/large_image_source_openjpeg/__init__.py @@ -29,7 +29,7 @@ from pkg_resources import DistributionNotFound, get_distribution from large_image.cache_util import LruCacheMetaclass, methodcache -from large_image.constants import SourcePriority, TILE_FORMAT_PIL +from large_image.constants import SourcePriority, TILE_FORMAT_NUMPY from large_image.exceptions import TileSourceException from large_image.tilesource import FileTileSource, etreeToDict @@ -211,7 +211,7 @@ def _readbox(self, box): pass @methodcache() - def getTile(self, x, y, z, pilImageAllowed=False, **kwargs): + def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): if z < 0 or z >= self.levels: raise TileSourceException('z layer does not exist') step = int(2 ** (self.levels - 1 - z)) @@ -242,14 +242,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, **kwargs): tile = openjpegHandle[y0:y1:step, x0:x1:step] finally: self._openjpegHandles.put(openjpegHandle) - mode = 'L' - if len(tile.shape) == 3: - mode = ['L', 'LA', 'RGB', 'RGBA'][tile.shape[2] - 1] - tile = PIL.Image.frombytes(mode, (tile.shape[1], tile.shape[0]), tile) if scale: - tile = tile.resize((tile.size[0] // scale, tile.size[1] // scale), PIL.Image.LANCZOS) - if tile.size != (self.tileWidth, self.tileHeight): - wrap = PIL.Image.new(mode, (self.tileWidth, self.tileHeight)) - wrap.paste(tile, (0, 0)) - tile = wrap - return self._outputTile(tile, TILE_FORMAT_PIL, x, y, z, pilImageAllowed, **kwargs) + tile = tile[::scale, ::scale] + return self._outputTile(tile, TILE_FORMAT_NUMPY, x, y, z, + pilImageAllowed, numpyAllowed, **kwargs) diff --git a/sources/openslide/large_image_source_openslide/__init__.py b/sources/openslide/large_image_source_openslide/__init__.py index 13a2d51f9..43d95e6bd 100644 --- a/sources/openslide/large_image_source_openslide/__init__.py +++ b/sources/openslide/large_image_source_openslide/__init__.py @@ -27,7 +27,7 @@ from large_image import config from large_image.cache_util import LruCacheMetaclass, methodcache -from large_image.constants import SourcePriority +from large_image.constants import SourcePriority, TILE_FORMAT_PIL from large_image.exceptions import TileSourceException from large_image.tilesource import FileTileSource, nearPowerOfTwo @@ -245,7 +245,7 @@ def getNativeMagnification(self): } @methodcache() - def getTile(self, x, y, z, pilImageAllowed=False, **kwargs): + def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): if z < 0: raise TileSourceException('z layer does not exist') try: @@ -278,7 +278,8 @@ def getTile(self, x, y, z, pilImageAllowed=False, **kwargs): if svslevel['scale'] != 1: tile = tile.resize((self.tileWidth, self.tileHeight), PIL.Image.LANCZOS) - return self._outputTile(tile, 'PIL', x, y, z, pilImageAllowed, **kwargs) + return self._outputTile(tile, TILE_FORMAT_PIL, x, y, z, pilImageAllowed, + numpyAllowed, **kwargs) def getPreferredLevel(self, level): """ diff --git a/sources/pil/large_image_source_pil/__init__.py b/sources/pil/large_image_source_pil/__init__.py index 0c19be4ec..fc9fa4367 100644 --- a/sources/pil/large_image_source_pil/__init__.py +++ b/sources/pil/large_image_source_pil/__init__.py @@ -27,6 +27,7 @@ from large_image import config from large_image.cache_util import LruCacheMetaclass, methodcache, strhash +from large_image.constants import TILE_FORMAT_PIL from large_image.exceptions import TileSourceException from large_image.tilesource import FileTileSource @@ -147,12 +148,13 @@ def getState(self): self._maxSize) @methodcache() - def getTile(self, x, y, z, pilImageAllowed=False, mayRedirect=False, **kwargs): + def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, + mayRedirect=False, **kwargs): if z != 0: raise TileSourceException('z layer does not exist') if x != 0: raise TileSourceException('x is outside layer') if y != 0: raise TileSourceException('y is outside layer') - return self._outputTile(self._pilImage, 'PIL', x, y, z, - pilImageAllowed, **kwargs) + return self._outputTile(self._pilImage, TILE_FORMAT_PIL, x, y, z, + pilImageAllowed, numpyAllowed, **kwargs) diff --git a/sources/pil/large_image_source_pil/girder_source.py b/sources/pil/large_image_source_pil/girder_source.py index 46c5a1946..339969670 100644 --- a/sources/pil/large_image_source_pil/girder_source.py +++ b/sources/pil/large_image_source_pil/girder_source.py @@ -21,6 +21,7 @@ from girder.models.setting import Setting from large_image.cache_util import methodcache +from large_image.constants import TILE_FORMAT_PIL from large_image.exceptions import TileSourceException from girder_large_image.constants import PluginSettings @@ -53,18 +54,19 @@ def getState(self): self._maxSize) @methodcache() - def getTile(self, x, y, z, pilImageAllowed=False, mayRedirect=False, **kwargs): + def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, + mayRedirect=False, **kwargs): if z != 0: raise TileSourceException('z layer does not exist') if x != 0: raise TileSourceException('x is outside layer') if y != 0: raise TileSourceException('y is outside layer') - if (mayRedirect and not pilImageAllowed and + if (mayRedirect and not pilImageAllowed and not numpyAllowed and cherrypy.request and self._pilFormatMatches(self._pilImage, mayRedirect, **kwargs)): url = '%s/api/v1/file/%s/download' % ( cherrypy.request.base, self.item['largeImage']['fileId']) raise cherrypy.HTTPRedirect(url) - return self._outputTile(self._pilImage, 'PIL', x, y, z, - pilImageAllowed, **kwargs) + return self._outputTile(self._pilImage, TILE_FORMAT_PIL, x, y, z, + pilImageAllowed, numpyAllowed, **kwargs) diff --git a/sources/test/large_image_source_test/__init__.py b/sources/test/large_image_source_test/__init__.py index 17f1403ce..452a6087c 100644 --- a/sources/test/large_image_source_test/__init__.py +++ b/sources/test/large_image_source_test/__init__.py @@ -21,7 +21,7 @@ from PIL import Image, ImageDraw, ImageFont from pkg_resources import DistributionNotFound, get_distribution -from large_image.constants import SourcePriority +from large_image.constants import SourcePriority, TILE_FORMAT_PIL from large_image.cache_util import strhash, methodcache, LruCacheMetaclass from large_image.exceptions import TileSourceException from large_image.tilesource import TileSource @@ -157,7 +157,7 @@ def getTile(self, x, y, z, *args, **kwargs): font=imageDrawFont ) _counters['tiles'] += 1 - return self._outputTile(image, 'PIL', x, y, z, **kwargs) + return self._outputTile(image, TILE_FORMAT_PIL, x, y, z, **kwargs) @staticmethod def getLRUHash(*args, **kwargs): diff --git a/sources/tiff/large_image_source_tiff/__init__.py b/sources/tiff/large_image_source_tiff/__init__.py index 4d2350e1a..96dc7e62d 100644 --- a/sources/tiff/large_image_source_tiff/__init__.py +++ b/sources/tiff/large_image_source_tiff/__init__.py @@ -19,6 +19,7 @@ import base64 import itertools import math +import numpy import PIL.Image import six from pkg_resources import DistributionNotFound, get_distribution @@ -27,9 +28,9 @@ from large_image import config from large_image.cache_util import LruCacheMetaclass, methodcache -from large_image.constants import SourcePriority +from large_image.constants import SourcePriority, TILE_FORMAT_PIL, TILE_FORMAT_NUMPY from large_image.exceptions import TileSourceException -from large_image.tilesource import FileTileSource, TILE_FORMAT_PIL, nearPowerOfTwo +from large_image.tilesource import FileTileSource, nearPowerOfTwo from .tiff_reader import TiledTiffDirectory, TiffException, \ InvalidOperationTiffException, IOTiffException, ValidationTiffException @@ -249,23 +250,27 @@ def getNativeMagnification(self): } @methodcache() - def getTile(self, x, y, z, pilImageAllowed=False, sparseFallback=False, - **kwargs): + def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, + sparseFallback=False, **kwargs): if z < 0: raise TileSourceException('z layer does not exist') try: + allowStyle = True if self._tiffDirectories[z] is None: if sparseFallback: raise IOTiffException('Missing z level %d' % z) tile = self.getTileFromEmptyDirectory(x, y, z, **kwargs) + allowStyle = False format = TILE_FORMAT_PIL else: tile = self._tiffDirectories[z].getTile(x, y) format = 'JPEG' if isinstance(tile, PIL.Image.Image): format = TILE_FORMAT_PIL + if isinstance(tile, numpy.ndarray): + format = TILE_FORMAT_NUMPY return self._outputTile(tile, format, x, y, z, pilImageAllowed, - **kwargs) + numpyAllowed, applyStyle=allowStyle, **kwargs) except IndexError: raise TileSourceException('z layer does not exist') except InvalidOperationTiffException as e: @@ -273,15 +278,17 @@ def getTile(self, x, y, z, pilImageAllowed=False, sparseFallback=False, except IOTiffException as e: return self.getTileIOTiffException( x, y, z, pilImageAllowed=pilImageAllowed, - sparseFallback=sparseFallback, exception=e, **kwargs) + numpyAllowed=numpyAllowed, sparseFallback=sparseFallback, + exception=e, **kwargs) def getTileIOTiffException(self, x, y, z, pilImageAllowed=False, - sparseFallback=False, exception=None, **kwargs): - if sparseFallback and z and PIL: + numpyAllowed=False, sparseFallback=False, + exception=None, **kwargs): + if sparseFallback and z: noedge = kwargs.copy() noedge.pop('edge', None) image = self.getTile( - x / 2, y / 2, z - 1, pilImageAllowed=True, + x / 2, y / 2, z - 1, pilImageAllowed=True, numpyAllowed=False, sparseFallback=sparseFallback, edge=False, **noedge) if not isinstance(image, PIL.Image.Image): image = PIL.Image.open(BytesIO(image)) @@ -291,8 +298,8 @@ def getTileIOTiffException(self, x, y, z, pilImageAllowed=False, self.tileWidth if x % 2 else self.tileWidth / 2, self.tileHeight if y % 2 else self.tileHeight / 2)) image = image.resize((self.tileWidth, self.tileHeight)) - return self._outputTile(image, 'PIL', x, y, z, pilImageAllowed, - **kwargs) + return self._outputTile(image, TILE_FORMAT_PIL, x, y, z, pilImageAllowed, + numpyAllowed, applyStyle=False, **kwargs) raise TileSourceException('Internal I/O failure: %s' % exception.args[0]) def getTileFromEmptyDirectory(self, x, y, z, **kwargs): @@ -320,8 +327,8 @@ def getTileFromEmptyDirectory(self, x, y, z, **kwargs): continue subtile = self.getTile( x * scale + newX, y * scale + newY, z, - pilImageAllowed=True, sparseFallback=True, edge=False, - frame=kwargs.get('frame')) + pilImageAllowed=True, numpyAllowed=False, + sparseFallback=True, edge=False, frame=kwargs.get('frame')) if not isinstance(subtile, PIL.Image.Image): subtile = PIL.Image.open(BytesIO(subtile)) tile.paste(subtile, (newX * self.tileWidth, diff --git a/sources/tiff/large_image_source_tiff/tiff_reader.py b/sources/tiff/large_image_source_tiff/tiff_reader.py index dbeff0172..91c556d4f 100644 --- a/sources/tiff/large_image_source_tiff/tiff_reader.py +++ b/sources/tiff/large_image_source_tiff/tiff_reader.py @@ -18,8 +18,9 @@ import ctypes import math -import PIL.Image +import numpy import os +import PIL.Image import six import threading @@ -572,14 +573,6 @@ def _getUncompressedTile(self, tileNum): readSize = tileSize if readSize < tileSize: raise IOTiffException('Read an unexpected number of bytes from an encoded tile') - if self._tiffInfo.get('samplesperpixel') == 1: - mode = 'L' - elif self._tiffInfo.get('samplesperpixel') == 3: - mode = ('YCbCr' if self._tiffInfo.get('photometric') == - libtiff_ctypes.PHOTOMETRIC_YCBCR else 'RGB') - if self._tiffInfo.get('bitspersample') == 16: - # Just take the high byte - imageBuffer = imageBuffer[1::2] tw, th = self._tileWidth, self._tileHeight if self._tiffInfo.get('orientation') in { libtiff_ctypes.ORIENTATION_LEFTTOP, @@ -587,7 +580,16 @@ def _getUncompressedTile(self, tileNum): libtiff_ctypes.ORIENTATION_RIGHTBOT, libtiff_ctypes.ORIENTATION_LEFTBOT}: tw, th = th, tw - image = PIL.Image.frombytes(mode, (tw, th), imageBuffer) + image = numpy.ctypeslib.as_array( + ctypes.cast(imageBuffer, ctypes.POINTER( + ctypes.c_uint16 if self._tiffInfo.get('bitspersample') == 16 else ctypes.c_uint8)), + (th, tw, self._tiffInfo.get('samplesperpixel'))) + if (self._tiffInfo.get('samplesperpixel') == 3 and + self._tiffInfo.get('photometric') == libtiff_ctypes.PHOTOMETRIC_YCBCR): + if self._tiffInfo.get('bitspersample') == 16: + image = numpy.floor_divide(image, 256).astype(numpy.uint8) + image = PIL.Image.fromarray(image, 'YCbCr') + image = numpy.array(image.convert('RGB')) return image def _getTileRotated(self, x, y): @@ -635,9 +637,23 @@ def _getTileRotated(self, x, y): for ty in range(max(0, ty0), max(0, ty1 + 1)): for tx in range(max(0, tx0), max(0, tx1 + 1)): subtile = self._getUncompressedTile(self._toTileNum(tx, ty, transpose)) - if not tile: - tile = PIL.Image.new(subtile.mode, (tw, th)) - tile.paste(subtile, (tx * tw - x0, ty * th - y0)) + if tile is None: + tile = numpy.zeros( + (th, tw) if len(subtile.shape) == 2 else + (th, tw, subtile.shape[2]), dtype=subtile.dtype) + stx, sty = tx * tw - x0, ty * th - y0 + if (stx >= tw or stx + subtile.shape[1] <= 0 or + sty >= th or sty + subtile.shape[0] <= 0): + continue + if stx < 0: + subtile = subtile[:, -stx:] + stx = 0 + if sty < 0: + subtile = subtile[-sty:, :] + sty = 0 + subtile = subtile[:min(subtile.shape[0], th - sty), + :min(subtile.shape[1], tw - stx)] + tile[sty:sty + subtile.shape[0], stx:stx + subtile.shape[1]] = subtile if tile is None: raise InvalidOperationTiffException( 'Tile x=%d, y=%d does not exist' % (x, y)) @@ -646,19 +662,19 @@ def _getTileRotated(self, x, y): libtiff_ctypes.ORIENTATION_BOTLEFT, libtiff_ctypes.ORIENTATION_RIGHTBOT, libtiff_ctypes.ORIENTATION_LEFTBOT}: - tile = tile.transpose(PIL.Image.FLIP_TOP_BOTTOM) + tile = tile[::-1, :] if self._tiffInfo.get('orientation') in { libtiff_ctypes.ORIENTATION_TOPRIGHT, libtiff_ctypes.ORIENTATION_BOTRIGHT, libtiff_ctypes.ORIENTATION_RIGHTTOP, libtiff_ctypes.ORIENTATION_RIGHTBOT}: - tile = tile.transpose(PIL.Image.FLIP_LEFT_RIGHT) + tile = tile[:, ::-1] if self._tiffInfo.get('orientation') in { libtiff_ctypes.ORIENTATION_LEFTTOP, libtiff_ctypes.ORIENTATION_RIGHTTOP, libtiff_ctypes.ORIENTATION_RIGHTBOT, libtiff_ctypes.ORIENTATION_LEFTBOT}: - tile = tile.transpose(PIL.Image.TRANSPOSE) + tile = tile.transpose((1, 0) if len(tile.shape) == 2 else (1, 0, 2)) return tile @property diff --git a/test/test_files/test_orient0.tif b/test/test_files/test_orient0.tif index ef20ecbcede731730edc5f3af62dd89d800e9ebd..07dcd685e9b364af9eacd43c2fc01339a411a3cf 100755 GIT binary patch delta 16 Ycmca;bJ1qQ0-4EHJQACC$joE}06!ZBnE(I) delta 14 Wcmca;bJ1qQ0vSfe%?o8VFaiKD4F#$I diff --git a/test/test_files/test_orient2.tif b/test/test_files/test_orient2.tif index a114c60163aa376f679500f3b59e6d5a856bf1c2..786853c9dd00b9c4086b9611b360fe027f5031c9 100755 GIT binary patch delta 16 Ycmca;bJ1qQ0-4EHJQACC$joE}06!ZBnE(I) delta 14 Wcmca;bJ1qQ0vSfe%?o8VFaiKD4F#$I diff --git a/test/test_files/test_orient3.tif b/test/test_files/test_orient3.tif index c75447783a4c1e12c1341f62b7147c3ffa3cc5ee..c27d08f8a57ab303c4b7c345fb393161efff3353 100755 GIT binary patch delta 16 Ycmca;bJ1qQ0-4EHJQACC$joE}06!ZBnE(I) delta 14 Wcmca;bJ1qQ0vSfe%?o8VFaiKD4F#$I diff --git a/test/test_files/test_orient4.tif b/test/test_files/test_orient4.tif index b04b0b7171b91a7c887edeef1b84f13a73f2a67c..ab0b6afa6dd6d75cdb8e54af026bc56e197f661a 100755 GIT binary patch delta 16 Ycmca;bJ1qQ0-4EHJQACC$joE}06!ZBnE(I) delta 14 Wcmca;bJ1qQ0vSfe%?o8VFaiKD4F#$I diff --git a/test/test_files/test_orient5.tif b/test/test_files/test_orient5.tif index 583404984a63f3f73cf809832b39cf505e8efc81..716ece533faa46ddb59a8e1bd249bc9aa5e23670 100755 GIT binary patch delta 16 Ycmca;bJ1qQ0-4EHJQACC$joE}06!ZBnE(I) delta 14 Wcmca;bJ1qQ0vSfe%?o8VFaiKD4F#$I diff --git a/test/test_files/test_orient6.tif b/test/test_files/test_orient6.tif index 2feafb7fa79fc33540fb9e1cb969e008bc3513c4..6097e1620e619af6bd395d428893d4ab4396b298 100755 GIT binary patch delta 16 Ycmca;bJ1qQ0-4EHJQACC$joE}06!ZBnE(I) delta 14 Wcmca;bJ1qQ0vSfe%?o8VFaiKD4F#$I diff --git a/test/test_files/test_orient7.tif b/test/test_files/test_orient7.tif index e0cc68b5a81dc7f91d5456b2c68e183118a7197c..1079d32d7c2d6c00c109f571a19a8c2f2faa3ae4 100755 GIT binary patch delta 16 Ycmca;bJ1qQ0-4EHJQACC$joE}06!ZBnE(I) delta 14 Wcmca;bJ1qQ0vSfe%?o8VFaiKD4F#$I diff --git a/test/test_files/test_orient8.tif b/test/test_files/test_orient8.tif index 848e749311787fa4d597367a38f61b3d4091a13d..3ca43ad3278f48c2063f3c81cd5a981d14cb385f 100755 GIT binary patch delta 16 Ycmca;bJ1qQ0-4EHJQACC$joE}06!ZBnE(I) delta 14 Wcmca;bJ1qQ0vSfe%?o8VFaiKD4F#$I diff --git a/test/test_source_ometiff.py b/test/test_source_ometiff.py index 6aac1c1a8..d11063a79 100644 --- a/test/test_source_ometiff.py +++ b/test/test_source_ometiff.py @@ -1,5 +1,9 @@ # -*- coding: utf-8 -*- +import json +import numpy + +from large_image.constants import TILE_FORMAT_NUMPY import large_image_source_ometiff from . import utilities @@ -31,3 +35,32 @@ def testTilesFromStripOMETiff(): assert tileMetadata['levels'] == 3 assert len(tileMetadata['frames']) == 145 utilities.checkTilesZXY(source, tileMetadata) + + +def testOMETiffAre16Bit(): + imagePath = utilities.externaldata('data/DDX58_AXL_EGFR_well2_XY01.ome.tif.sha512') + source = large_image_source_ometiff.OMETiffFileTileSource(imagePath) + tile = next(source.tileIterator(format=TILE_FORMAT_NUMPY))['tile'] + assert tile.dtype == numpy.uint16 + assert tile[15][15][0] == 17852 + + region, _ = source.getRegion(format=TILE_FORMAT_NUMPY) + assert region.dtype == numpy.uint16 + assert region[300][300][0] == 17816 + + +def testStyleAutoMinMax(): + imagePath = utilities.externaldata('data/DDX58_AXL_EGFR_well2_XY01.ome.tif.sha512') + source = large_image_source_ometiff.OMETiffFileTileSource(imagePath) + image, _ = source.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=TILE_FORMAT_NUMPY, frame=1) + sourceB = large_image_source_ometiff.OMETiffFileTileSource( + imagePath, style=json.dumps({'min': 'auto', 'max': 'auto'})) + imageB, _ = sourceB.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=TILE_FORMAT_NUMPY, frame=1) + imageB = imageB[:, :, :1] + assert numpy.any(image != imageB) + assert image.shape == imageB.shape + assert image[128][128][0] < imageB[128][128][0] + assert image[0][128][0] < imageB[0][128][0] + assert image[240][128][0] < imageB[240][128][0] diff --git a/test/test_source_openslide.py b/test/test_source_openslide.py index 675e07160..0c959ee5c 100644 --- a/test/test_source_openslide.py +++ b/test/test_source_openslide.py @@ -205,7 +205,7 @@ def testGetRegion(): # We should be able to get a PIL image image, imageFormat = source.getRegion( scale={'magnification': 2.5}, - format=(constants.TILE_FORMAT_PIL, constants.TILE_FORMAT_NUMPY)) + format=(constants.TILE_FORMAT_PIL, )) assert imageFormat == constants.TILE_FORMAT_PIL assert image.width == 1438 assert image.height == 1447 @@ -414,3 +414,31 @@ def testTilesFromSmallFile(): assert tileMetadata['sizeY'] == 1 assert tileMetadata['levels'] == 1 utilities.checkTilesZXY(source, tileMetadata) + + +def testEdgeOptions(): + imagePath = utilities.externaldata( + 'data/sample_svs_image.TCGA-DU-6399-01A-01-TS1.e8eb65de-d63e-42db-' + 'af6f-14fefbbdf7bd.svs.sha512') + image = large_image_source_openslide.OpenslideFileTileSource( + imagePath, format=constants.TILE_FORMAT_IMAGE, encoding='PNG', + edge='crop').getTile(0, 0, 0) + assert image[:len(utilities.PNGHeader)] == utilities.PNGHeader + (width, height) = struct.unpack('!LL', image[16:24]) + assert width == 124 + assert height == 54 + image = large_image_source_openslide.OpenslideFileTileSource( + imagePath, format=constants.TILE_FORMAT_IMAGE, encoding='PNG', + edge='#DDD').getTile(0, 0, 0) + assert image[:len(utilities.PNGHeader)] == utilities.PNGHeader + (width, height) = struct.unpack('!LL', image[16:24]) + assert width == 240 + assert height == 240 + imageB = large_image_source_openslide.OpenslideFileTileSource( + imagePath, format=constants.TILE_FORMAT_IMAGE, encoding='PNG', + edge='yellow').getTile(0, 0, 0) + assert imageB[:len(utilities.PNGHeader)] == utilities.PNGHeader + (width, height) = struct.unpack('!LL', imageB[16:24]) + assert width == 240 + assert height == 240 + assert imageB != image diff --git a/test/test_source_tiff.py b/test/test_source_tiff.py index 42cc1e391..9721bc88e 100644 --- a/test/test_source_tiff.py +++ b/test/test_source_tiff.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +import json +import numpy import os import pytest import struct @@ -39,6 +41,39 @@ def testTilesFromPTIF(): utilities.checkTilesZXY(source, tileMetadata) +def testTileIterator(): + imagePath = utilities.externaldata('data/sample_image.ptif.sha512') + source = large_image_source_tiff.TiffFileTileSource(imagePath) + + # Ask for JPEGS + tileCount = 0 + for tile in source.tileIterator( + scale={'magnification': 2.5}, + format=constants.TILE_FORMAT_IMAGE, + encoding='JPEG'): + tileCount += 1 + assert tile['tile'][:len(utilities.JPEGHeader)] == utilities.JPEGHeader + assert tileCount == 45 + # Ask for PNGs + tileCount = 0 + for tile in source.tileIterator( + scale={'magnification': 2.5}, + format=constants.TILE_FORMAT_IMAGE, + encoding='PNG'): + tileCount += 1 + assert tile['tile'][:len(utilities.PNGHeader)] == utilities.PNGHeader + assert tileCount == 45 + # Ask for TIFFS + tileCount = 0 + for tile in source.tileIterator( + scale={'magnification': 2.5}, + format=constants.TILE_FORMAT_IMAGE, + encoding='TIFF'): + tileCount += 1 + assert tile['tile'][:len(utilities.TIFFHeader)] == utilities.TIFFHeader + assert tileCount == 45 + + def testTileIteratorRetiling(): imagePath = utilities.externaldata('data/sample_image.ptif.sha512') source = large_image_source_tiff.TiffFileTileSource(imagePath) @@ -473,15 +508,15 @@ def testTilesFromSCN(): def testOrientations(): testDir = os.path.dirname(os.path.realpath(__file__)) testResults = { - 0: {'shape': (100, 66, 4), 'pixels': (0, 0, 0, 255, 0, 255, 0, 255)}, - 1: {'shape': (100, 66, 4), 'pixels': (0, 0, 0, 255, 0, 255, 0, 255)}, - 2: {'shape': (100, 66, 4), 'pixels': (0, 0, 133, 0, 0, 255, 255, 0)}, - 3: {'shape': (100, 66, 4), 'pixels': (255, 0, 143, 0, 255, 0, 0, 0)}, - 4: {'shape': (100, 66, 4), 'pixels': (0, 255, 0, 255, 255, 0, 0, 0)}, - 5: {'shape': (66, 100, 4), 'pixels': (0, 0, 0, 255, 0, 255, 0, 255)}, - 6: {'shape': (66, 100, 4), 'pixels': (0, 255, 0, 255, 141, 0, 0, 0)}, - 7: {'shape': (66, 100, 4), 'pixels': (255, 0, 255, 0, 143, 0, 0, 0)}, - 8: {'shape': (66, 100, 4), 'pixels': (0, 0, 255, 0, 0, 255, 255, 0)}, + 0: {'shape': (100, 66, 1), 'pixels': (0, 0, 0, 255, 0, 255, 0, 255)}, + 1: {'shape': (100, 66, 1), 'pixels': (0, 0, 0, 255, 0, 255, 0, 255)}, + 2: {'shape': (100, 66, 1), 'pixels': (0, 0, 133, 0, 0, 255, 255, 0)}, + 3: {'shape': (100, 66, 1), 'pixels': (255, 0, 143, 0, 255, 0, 0, 0)}, + 4: {'shape': (100, 66, 1), 'pixels': (0, 255, 0, 255, 255, 0, 0, 0)}, + 5: {'shape': (66, 100, 1), 'pixels': (0, 0, 0, 255, 0, 255, 0, 255)}, + 6: {'shape': (66, 100, 1), 'pixels': (0, 255, 0, 255, 141, 0, 0, 0)}, + 7: {'shape': (66, 100, 1), 'pixels': (255, 0, 255, 0, 143, 0, 0, 0)}, + 8: {'shape': (66, 100, 1), 'pixels': (0, 0, 255, 0, 0, 255, 255, 0)}, } for orient in range(9): imagePath = os.path.join(testDir, 'test_files', 'test_orient%d.tif' % orient) @@ -498,7 +533,7 @@ def testOrientations(): def testTilesFromMultipleTiledTIF(): - imagePath = utilities.externaldata('data//JK-kidney_H3_4C_1-500sec.tif.sha512') + imagePath = utilities.externaldata('data/JK-kidney_H3_4C_1-500sec.tif.sha512') source = large_image_source_tiff.TiffFileTileSource(imagePath) tileMetadata = source.getMetadata() assert tileMetadata['tileWidth'] == 256 @@ -508,3 +543,56 @@ def testTilesFromMultipleTiledTIF(): assert tileMetadata['levels'] == 7 assert tileMetadata['magnification'] == 40 utilities.checkTilesZXY(source, tileMetadata) + + +def testStyleSwapChannels(): + imagePath = utilities.externaldata('data/sample_image.ptif.sha512') + source = large_image_source_tiff.TiffFileTileSource(imagePath) + image, _ = source.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=constants.TILE_FORMAT_NUMPY) + # swap the green and blue channels + sourceB = large_image_source_tiff.TiffFileTileSource(imagePath, style=json.dumps({'bands': [ + {'band': 'red', 'palette': ['#000', '#f00']}, + {'band': 'green', 'palette': ['#000', '#00f']}, + {'band': 'blue', 'palette': ['#000', '#0f0']}, + ]})) + imageB, _ = sourceB.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=constants.TILE_FORMAT_NUMPY) + imageB = imageB[:, :, :3] + assert numpy.any(image != imageB) + assert numpy.all(image[:, :, 0] == imageB[:, :, 0]) + assert numpy.any(image[:, :, 1] != imageB[:, :, 1]) + assert numpy.all(image[:, :, 1] == imageB[:, :, 2]) + assert numpy.all(image[:, :, 2] == imageB[:, :, 1]) + + +def testStyleClamp(): + imagePath = utilities.externaldata('data/sample_image.ptif.sha512') + source = large_image_source_tiff.TiffFileTileSource( + imagePath, style=json.dumps({'min': 100, 'max': 200, 'clamp': True})) + image, _ = source.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=constants.TILE_FORMAT_NUMPY) + sourceB = large_image_source_tiff.TiffFileTileSource( + imagePath, style=json.dumps({'min': 100, 'max': 200, 'clamp': False})) + imageB, _ = sourceB.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=constants.TILE_FORMAT_NUMPY) + assert numpy.all(image[:, :, 3] == 255) + assert numpy.any(imageB[:, :, 3] != 255) + assert image[0][0][3] == 255 + assert imageB[0][0][3] == 0 + + +def testStyleNoData(): + imagePath = utilities.externaldata('data/sample_image.ptif.sha512') + source = large_image_source_tiff.TiffFileTileSource( + imagePath, style=json.dumps({'nodata': None})) + image, _ = source.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=constants.TILE_FORMAT_NUMPY) + sourceB = large_image_source_tiff.TiffFileTileSource( + imagePath, style=json.dumps({'nodata': 101})) + imageB, _ = sourceB.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=constants.TILE_FORMAT_NUMPY) + assert numpy.all(image[:, :, 3] == 255) + assert numpy.any(imageB[:, :, 3] != 255) + assert image[12][215][3] == 255 + assert imageB[12][215][3] != 255