From 1fc9c98f1b316235cc42d26096bd21b66f5b75f3 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 30 Oct 2019 12:27:55 -0400 Subject: [PATCH 1/6] Support more styles of ometiff. This adds support for small OME TIFF images that aren't tiled. Strips are aggregated together to make a minimum size, as serving individual strips is inefficient. --- .../large_image_source_ometiff/__init__.py | 36 +++-- .../large_image_source_tiff/tiff_reader.py | 148 +++++++++++------- .../DDX58_AXL_EGFR_well2_XY01.ome.tif.sha512 | 1 + test/test_source_ometiff.py | 14 ++ 4 files changed, 132 insertions(+), 67 deletions(-) create mode 100755 test/data/DDX58_AXL_EGFR_well2_XY01.ome.tif.sha512 diff --git a/sources/ometiff/large_image_source_ometiff/__init__.py b/sources/ometiff/large_image_source_ometiff/__init__.py index cf525cd9e..ba66b58bd 100644 --- a/sources/ometiff/large_image_source_ometiff/__init__.py +++ b/sources/ometiff/large_image_source_ometiff/__init__.py @@ -92,11 +92,11 @@ def __init__(self, path, **kwargs): largeImagePath = self._getLargeImagePath() try: - base = TiledTiffDirectory(largeImagePath, 0) + base = TiledTiffDirectory(largeImagePath, 0, mustBeTiled=None) except TiffException: - raise TileSourceException('Not a tiled OME Tiff') + raise TileSourceException('Not a recognized OME Tiff') info = getattr(base, '_description_xml', None) - if not info.get('OME'): + if not info or not info.get('OME'): raise TileSourceException('Not an OME Tiff') self._omeinfo = info['OME'] if isinstance(self._omeinfo['Image'], dict): @@ -108,7 +108,13 @@ def __init__(self, path, **kwargs): img['Pixels']['Plane'] = [img['Pixels']['Plane']] try: self._omebase = self._omeinfo['Image'][0]['Pixels'] - if len({entry['UUID']['FileName'] for entry in self._omebase['TiffData']}) > 1: + if ((not len(self._omebase['TiffData']) or ( + len(self._omebase['TiffData']) == 1 and + self._omebase['TiffData'][0] == {})) and + len(self._omebase['Plane'])): + self._omebase['TiffData'] = self._omebase['Plane'] + if len({entry.get('UUID', {}).get('FileName', '') + for entry in self._omebase['TiffData']}) > 1: raise TileSourceException('OME Tiff references multiple files') if (len(self._omebase['TiffData']) != int(self._omebase['SizeC']) * int(self._omebase['SizeT']) * int(self._omebase['SizeZ']) or @@ -126,10 +132,16 @@ def __init__(self, path, **kwargs): for entry in omeimages] omebylevel = dict(zip(levels, omeimages)) self._omeLevels = [omebylevel.get(key) for key in range(max(omebylevel.keys()) + 1)] - self._tiffDirectories = [ - TiledTiffDirectory(largeImagePath, int(entry['TiffData'][0]['IFD'])) - if entry else None - for entry in self._omeLevels] + if base._tiffInfo.get('istiled'): + self._tiffDirectories = [ + TiledTiffDirectory(largeImagePath, int(entry['TiffData'][0]['IFD'])) + if entry else None + for entry in self._omeLevels] + else: + self._tiffDirectories = [ + TiledTiffDirectory(largeImagePath, 0, mustBeTiled=None) + if entry else None + for entry in self._omeLevels] self._directoryCache = {} self._directoryCacheMaxSize = max(20, len(self._omebase['TiffData']) * 3) self.tileWidth = base.tileWidth @@ -166,11 +178,11 @@ def getNativeMagnification(self): if result['mm_x'] is None and 'PhysicalSizeX' in self._omebase: result['mm_x'] = ( float(self._omebase['PhysicalSizeX']) * 1e3 * - _omeUnitsToMeters[self._omebase.get('PhysicalSizeXUnit', '\u00b5m')]) + _omeUnitsToMeters[self._omebase.get('PhysicalSizeXUnit', u'\u00b5m')]) if result['mm_y'] is None and 'PhysicalSizeY' in self._omebase: result['mm_y'] = ( float(self._omebase['PhysicalSizeY']) * 1e3 * - _omeUnitsToMeters[self._omebase.get('PhysicalSizeYUnit', '\u00b5m')]) + _omeUnitsToMeters[self._omebase.get('PhysicalSizeYUnit', u'\u00b5m')]) if not result.get('magnification') and result.get('mm_x'): result['magnification'] = 0.01 / result['mm_x'] return result @@ -185,13 +197,13 @@ def getTile(self, x, y, z, pilImageAllowed=False, sparseFallback=False, frame = int(kwargs['frame']) if frame < 0 or frame >= len(self._omebase['TiffData']): raise TileSourceException('Frame does not exist') - dirnum = int(self._omeLevels[z]['TiffData'][frame]['IFD']) + dirnum = int(self._omeLevels[z]['TiffData'][frame].get('IFD', frame)) if dirnum in self._directoryCache: dir = self._directoryCache[dirnum] else: if len(self._directoryCache) >= self._directoryCacheMaxSize: self._directoryCache = {} - dir = TiledTiffDirectory(self._getLargeImagePath(), dirnum) + dir = TiledTiffDirectory(self._getLargeImagePath(), dirnum, mustBeTiled=None) self._directoryCache[dirnum] = dir try: tile = dir.getTile(x, y) diff --git a/sources/tiff/large_image_source_tiff/tiff_reader.py b/sources/tiff/large_image_source_tiff/tiff_reader.py index 9bef36043..dbeff0172 100644 --- a/sources/tiff/large_image_source_tiff/tiff_reader.py +++ b/sources/tiff/large_image_source_tiff/tiff_reader.py @@ -17,6 +17,7 @@ ############################################################################### import ctypes +import math import PIL.Image import os import six @@ -189,7 +190,6 @@ def _validate(self): # noqa if not self._mustBeTiled: if self._mustBeTiled is not None and self._tiffInfo.get('istiled'): raise ValidationTiffException('Expected a non-tiled TIFF file') - return # For any non-supported file, we probably can add a conversion task in # the create_image.py script, such as flatten or colourspace. These # should only be done if necessary, which would require the conversion @@ -200,11 +200,9 @@ def _validate(self): # noqa raise ValidationTiffException( 'Only RGB and greyscale TIFF files are supported') - if (self._tiffInfo.get('bitspersample') != 8 and ( - self._tiffInfo.get('compression') != libtiff_ctypes.COMPRESSION_NONE or - self._tiffInfo.get('bitspersample') != 16)): + if self._tiffInfo.get('bitspersample') not in (8, 16): raise ValidationTiffException( - 'Only single-byte sampled TIFF files are supported') + 'Only 8 and 16 bits-per-sample TIFF files are supported') if self._tiffInfo.get('sampleformat') not in { None, # default is still SAMPLEFORMAT_UINT @@ -239,16 +237,16 @@ def _validate(self): # noqa raise ValidationTiffException( 'Unsupported TIFF orientation') - if self._tiffInfo.get('compression') not in { - libtiff_ctypes.COMPRESSION_NONE, - libtiff_ctypes.COMPRESSION_JPEG, - 33003, 33005}: - raise ValidationTiffException( - 'Only uncompressed and JPEG compressed TIFF files are supported') - if (not self._tiffInfo.get('istiled') or + if self._mustBeTiled and ( + not self._tiffInfo.get('istiled') or not self._tiffInfo.get('tilewidth') or not self._tiffInfo.get('tilelength')): - raise ValidationTiffException('Only tiled TIFF files are supported') + raise ValidationTiffException('A tiled TIFF is required.') + + if self._mustBeTiled is False and ( + self._tiffInfo.get('istiled') or + not self._tiffInfo.get('rowsperstrip')): + raise ValidationTiffException('A non-tiled TIFF with strips is required.') if (self._tiffInfo.get('compression') == libtiff_ctypes.COMPRESSION_JPEG and self._tiffInfo.get('jpegtablesmode') != @@ -284,10 +282,15 @@ def _loadMetadata(self): if value: info[func.lower()] = value self._tiffInfo = info - self._tileWidth = info.get('tilewidth') - self._tileHeight = info.get('tilelength') + self._tileWidth = info.get('tilewidth') or info.get('imagewidth') + self._tileHeight = info.get('tilelength') or info.get('rowsperstrip') self._imageWidth = info.get('imagewidth') self._imageHeight = info.get('imagelength') + if not info.get('tilelength'): + self._stripsPerTile = int(max(1, math.ceil(256.0 / self._tileHeight))) + self._stripHeight = self._tileHeight + self._tileHeight = self._stripHeight * self._stripsPerTile + self._stripCount = int(math.ceil(float(self._imageHeight) / self._stripHeight)) if info.get('orientation') in { libtiff_ctypes.ORIENTATION_LEFTTOP, libtiff_ctypes.ORIENTATION_RIGHTTOP, @@ -383,22 +386,28 @@ def _toTileNum(self, x, y, transpose=False): if not transpose: pixelX = int(x * self._tileWidth) pixelY = int(y * self._tileHeight) - if pixelX >= self._imageWidth or pixelY >= self._imageHeight: + if x < 0 or y < 0 or pixelX >= self._imageWidth or pixelY >= self._imageHeight: raise InvalidOperationTiffException( 'Tile x=%d, y=%d does not exist' % (x, y)) else: pixelX = int(x * self._tileHeight) pixelY = int(y * self._tileWidth) - if pixelX >= self._imageHeight or pixelY >= self._imageWidth: + if x < 0 or y < 0 or pixelX >= self._imageHeight or pixelY >= self._imageWidth: raise InvalidOperationTiffException( 'Tile x=%d, y=%d does not exist' % (x, y)) - if libtiff_ctypes.libtiff.TIFFCheckTile( - self._tiffFile, pixelX, pixelY, 0, 0) == 0: - raise InvalidOperationTiffException( - 'Tile x=%d, y=%d does not exist' % (x, y)) - - tileNum = libtiff_ctypes.libtiff.TIFFComputeTile( - self._tiffFile, pixelX, pixelY, 0, 0).value + # We had been using TIFFCheckTile, but with z=0 and sample=0, this is + # just a check that x, y is within the image + # if libtiff_ctypes.libtiff.TIFFCheckTile( + # self._tiffFile, pixelX, pixelY, 0, 0) == 0: + # raise InvalidOperationTiffException( + # 'Tile x=%d, y=%d does not exist' % (x, y)) + if self._tiffInfo.get('istiled'): + tileNum = libtiff_ctypes.libtiff.TIFFComputeTile( + self._tiffFile, pixelX, pixelY, 0, 0).value + else: + # TIFFComputeStrip with sample=0 is just the row divided by the + # strip height + tileNum = int(pixelY // self._stripHeight) return tileNum @methodcache(key=partial(strhash, '_getTileByteCountsType')) @@ -524,21 +533,43 @@ def _getJpegFrame(self, tileNum, entire=False): def _getUncompressedTile(self, tileNum): """ - Get an uncompressed tile. + Get an uncompressed tile or strip. - :param tileNum: The internal tile number of the desired tile. + :param tileNum: The internal tile or strip number of the desired tile + or strip. :type tileNum: int :return: the tile as a PIL 8-bit-per-channel images. :rtype: PIL.Image :raises: IOTiffException """ with self._tileLock: - tileSize = libtiff_ctypes.libtiff.TIFFTileSize(self._tiffFile).value + if self._tiffInfo.get('istiled'): + tileSize = libtiff_ctypes.libtiff.TIFFTileSize(self._tiffFile).value + else: + stripSize = libtiff_ctypes.libtiff.TIFFStripSize( + self._tiffFile).value + stripsCount = min(self._stripsPerTile, self._stripCount - tileNum) + tileSize = stripSize * self._stripsPerTile imageBuffer = ctypes.create_string_buffer(tileSize) - with self._tileLock: - readSize = libtiff_ctypes.libtiff.TIFFReadEncodedTile( - self._tiffFile, tileNum, imageBuffer, tileSize) + if self._tiffInfo.get('istiled'): + readSize = libtiff_ctypes.libtiff.TIFFReadEncodedTile( + self._tiffFile, tileNum, imageBuffer, tileSize) + else: + readSize = 0 + for stripNum in range(stripsCount): + chunkSize = libtiff_ctypes.libtiff.TIFFReadEncodedStrip( + self._tiffFile, + tileNum + stripNum, + ctypes.byref(imageBuffer, stripSize * stripNum), + stripSize).value + if chunkSize <= 0: + raise IOTiffException( + 'Read an unexpected number of bytes from an encoded strip') + readSize += chunkSize + if readSize < tileSize: + ctypes.memset(ctypes.byref(imageBuffer, readSize), 0, tileSize - readSize) + readSize = tileSize if readSize < tileSize: raise IOTiffException('Read an unexpected number of bytes from an encoded tile') if self._tiffInfo.get('samplesperpixel') == 1: @@ -549,7 +580,14 @@ def _getUncompressedTile(self, tileNum): if self._tiffInfo.get('bitspersample') == 16: # Just take the high byte imageBuffer = imageBuffer[1::2] - image = PIL.Image.frombytes(mode, (self._tileWidth, self._tileHeight), imageBuffer) + tw, th = self._tileWidth, self._tileHeight + if self._tiffInfo.get('orientation') in { + libtiff_ctypes.ORIENTATION_LEFTTOP, + libtiff_ctypes.ORIENTATION_RIGHTTOP, + libtiff_ctypes.ORIENTATION_RIGHTBOT, + libtiff_ctypes.ORIENTATION_LEFTBOT}: + tw, th = th, tw + image = PIL.Image.frombytes(mode, (tw, th), imageBuffer) return image def _getTileRotated(self, x, y): @@ -674,33 +712,33 @@ def getTile(self, x, y): # This raises an InvalidOperationTiffException if the tile doesn't exist tileNum = self._toTileNum(x, y) + if (not self._tiffInfo.get('istiled') or + self._tiffInfo.get('compression') not in ( + libtiff_ctypes.COMPRESSION_JPEG, 33003, 33005) or + self._tiffInfo.get('bitspersample') != 8): + return self._getUncompressedTile(tileNum) + imageBuffer = six.BytesIO() - if self._tiffInfo.get('compression') == libtiff_ctypes.COMPRESSION_JPEG: - if not getattr(self, '_completeJpeg', False): - # Write JPEG Start Of Image marker - imageBuffer.write(b'\xff\xd8') - imageBuffer.write(self._getJpegTables()) - imageBuffer.write(self._getJpegFrame(tileNum)) - # Write JPEG End Of Image marker - imageBuffer.write(b'\xff\xd9') - else: - imageBuffer.write(self._getJpegFrame(tileNum, True)) + if (self._tiffInfo.get('compression') == libtiff_ctypes.COMPRESSION_JPEG and + not getattr(self, '_completeJpeg', False)): + # Write JPEG Start Of Image marker + imageBuffer.write(b'\xff\xd8') + imageBuffer.write(self._getJpegTables()) + imageBuffer.write(self._getJpegFrame(tileNum)) + # Write JPEG End Of Image marker + imageBuffer.write(b'\xff\xd9') return imageBuffer.getvalue() - - if self._tiffInfo.get('compression') in (33003, 33005): - # Get the whole frame, which is JPEG 2000 format, and convert it to - # a PIL image - imageBuffer.write(self._getJpegFrame(tileNum, True)) - image = PIL.Image.open(imageBuffer) - # Converting the image mode ensures that it gets loaded once and is - # in a form we expect. IF this isn't done, then PIL can load the - # image multiple times, which sometimes throws an exception in - # PIL's JPEG 2000 module. - image = image.convert('RGB') - return image - - return self._getUncompressedTile(tileNum) + # Get the whole frame, which is in a JPEG or JPEG 2000 format, and + # convert it to a PIL image + imageBuffer.write(self._getJpegFrame(tileNum, True)) + image = PIL.Image.open(imageBuffer) + # Converting the image mode ensures that it gets loaded once and is in + # a form we expect. If this isn't done, then PIL can load the image + # multiple times, which sometimes throws an exception in PIL's JPEG + # 2000 module. + image = image.convert('RGB') + return image def parse_image_description(self, meta=None): # noqa self._pixelInfo = {} diff --git a/test/data/DDX58_AXL_EGFR_well2_XY01.ome.tif.sha512 b/test/data/DDX58_AXL_EGFR_well2_XY01.ome.tif.sha512 new file mode 100755 index 000000000..a6202bc46 --- /dev/null +++ b/test/data/DDX58_AXL_EGFR_well2_XY01.ome.tif.sha512 @@ -0,0 +1 @@ +d9f7b733fd758f13540a7416d1e22469e8e28a434a1acb7a50a5da0398eaa93cb5b00cc1d0bd02216da8ab9b648e82cfcb4c4bc084bd08216f6cb3c5f485f1b6 diff --git a/test/test_source_ometiff.py b/test/test_source_ometiff.py index fd8731158..56c43cd9e 100644 --- a/test/test_source_ometiff.py +++ b/test/test_source_ometiff.py @@ -17,3 +17,17 @@ def testTilesFromOMETiff(): assert tileMetadata['levels'] == 3 assert len(tileMetadata['frames']) == 3 utilities.checkTilesZXY(source, tileMetadata) + + +def testTilesFromStripOMETiff(): + imagePath = utilities.externaldata('data/DDX58_AXL_EGFR_well2_XY01.ome.tif.sha512') + source = large_image_source_ometiff.OMETiffFileTileSource(imagePath) + tileMetadata = source.getMetadata() + + assert tileMetadata['tileWidth'] == 1024 + assert tileMetadata['tileHeight'] == 256 + assert tileMetadata['sizeX'] == 1024 + assert tileMetadata['sizeY'] == 1022 + assert tileMetadata['levels'] == 3 + assert len(tileMetadata['frames']) == 5 + utilities.checkTilesZXY(source, tileMetadata) From 171a1b8fa3e02c4b2a0d45965fb0475bff35d9be Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 13 Dec 2019 12:07:13 -0500 Subject: [PATCH 2/6] Handle z-loops in OMETiff files. --- .../large_image_source_ometiff/__init__.py | 50 +++++++++++++++++++ test/test_source_ometiff.py | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/sources/ometiff/large_image_source_ometiff/__init__.py b/sources/ometiff/large_image_source_ometiff/__init__.py index ba66b58bd..657f67125 100644 --- a/sources/ometiff/large_image_source_ometiff/__init__.py +++ b/sources/ometiff/large_image_source_ometiff/__init__.py @@ -99,6 +99,7 @@ def __init__(self, path, **kwargs): if not info or not info.get('OME'): raise TileSourceException('Not an OME Tiff') self._omeinfo = info['OME'] + self._checkForOMEZLoop(largeImagePath) if isinstance(self._omeinfo['Image'], dict): self._omeinfo['Image'] = [self._omeinfo['Image']] for img in self._omeinfo['Image']: @@ -155,6 +156,55 @@ def __init__(self, path, **kwargs): # directories not mentioned by the ome list. self._associatedImages = {} + def _checkForOMEZLoop(self, largeImagePath): + """ + Check if the OME description lists a Z-loop that isn't references by + the frames or TiffData list and is present based on the number of tiff + directories. This can modify self._omeinfo. + + :param largeImagePath: used for checking for the maximum directory. + """ + info = self._omeinfo + try: + zloopinfo = info['Image']['Description'].split('Z Stack Loop: ')[1] + zloop = int(zloopinfo.split()[0]) + stepinfo = zloopinfo.split('Step: ')[1].split() + stepmm = float(stepinfo[0]) + stepmm *= {u'mm': 1, u'\xb5m': 0.001}[stepinfo[1]] + planes = len(info['Image']['Pixels']['Plane']) + for plane in info['Image']['Pixels']['Plane']: + if int(plane.get('TheZ', 0)) != 0: + return + if int(info['Image']['Pixels']['SizeZ']) != 1: + return + except Exception: + return + if zloop <= 1 or not stepmm or not planes: + return + if len(info['Image']['Pixels'].get('TiffData', {})): + return + expecteddir = planes * zloop + try: + lastdir = TiledTiffDirectory(largeImagePath, expecteddir - 1, mustBeTiled=None) + if not lastdir._tiffFile.lastdirectory(): + return + except Exception: + return + tiffdata = [] + for z in range(zloop): + for plane in info['Image']['Pixels']['Plane']: + td = plane.copy() + td['TheZ'] = str(z) + # This position is probably wrong -- it seems like the + # listed position is likely to be the center of the stack, not + # the bottom, but we'd have to confirm it. + td['PositionZ'] = str(float(td.get('PositionZ', 0)) + z * stepmm * 1000) + tiffdata.append(td) + info['Image']['Pixels']['TiffData'] = tiffdata + info['Image']['Pixels']['Plane'] = tiffdata + info['Image']['Pixels']['PlanesFromZloop'] = 'true' + info['Image']['Pixels']['SizeZ'] = str(zloop) + def getMetadata(self): """ Return a dictionary of metadata containing levels, sizeX, sizeY, diff --git a/test/test_source_ometiff.py b/test/test_source_ometiff.py index 56c43cd9e..6aac1c1a8 100644 --- a/test/test_source_ometiff.py +++ b/test/test_source_ometiff.py @@ -29,5 +29,5 @@ def testTilesFromStripOMETiff(): assert tileMetadata['sizeX'] == 1024 assert tileMetadata['sizeY'] == 1022 assert tileMetadata['levels'] == 3 - assert len(tileMetadata['frames']) == 5 + assert len(tileMetadata['frames']) == 145 utilities.checkTilesZXY(source, tileMetadata) From 917cf0b4e78800a84e5fa7ee9e94757ad87be29b Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 6 Nov 2019 13:26:07 -0500 Subject: [PATCH 3/6] 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 657f67125..52936c798 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 From 387399fc4d0cd434c781f13d619ba10711b8604c Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 14 Nov 2019 10:25:20 -0500 Subject: [PATCH 4/6] Add a histogram method and endpoint. --- .../girder_large_image/models/image_item.py | 12 +++ girder/girder_large_image/rest/tiles.py | 83 ++++++++++++++ girder/test_girder/test_tiles_rest.py | 15 +++ large_image/tilesource/base.py | 102 ++++++++++++++---- test/test_source_tiff.py | 24 +++++ 5 files changed, 214 insertions(+), 22 deletions(-) diff --git a/girder/girder_large_image/models/image_item.py b/girder/girder_large_image/models/image_item.py index cc6a786a9..e8f050f5b 100644 --- a/girder/girder_large_image/models/image_item.py +++ b/girder/girder_large_image/models/image_item.py @@ -361,6 +361,18 @@ def getPixel(self, item, **kwargs): tileSource = self._loadTileSource(item, **kwargs) return tileSource.getPixel(**kwargs) + def histogram(self, item, **kwargs): + """ + Using a tile source, get a histogram of the image. + + :param item: the item with the tile source. + :param **kwargs: optional arguments. See the tilesource histogram + method. + :returns: histogram object. + """ + tileSource = self._loadTileSource(item, **kwargs) + return tileSource.histogram(**kwargs) + def tileSource(self, item, **kwargs): """ Get a tile source for an item. diff --git a/girder/girder_large_image/rest/tiles.py b/girder/girder_large_image/rest/tiles.py index 7c4389c32..86955969d 100644 --- a/girder/girder_large_image/rest/tiles.py +++ b/girder/girder_large_image/rest/tiles.py @@ -86,6 +86,8 @@ def __init__(self, apiRoot): self.getTilesRegion) apiRoot.item.route('GET', (':itemId', 'tiles', 'pixel'), self.getTilesPixel) + apiRoot.item.route('GET', (':itemId', 'tiles', 'histogram'), + self.getHistogram) apiRoot.item.route('GET', (':itemId', 'tiles', 'zxy', ':z', ':x', ':y'), self.getTile) apiRoot.item.route('GET', (':itemId', 'tiles', 'fzxy', ':frame', ':z', ':x', ':y'), @@ -724,6 +726,87 @@ def getTilesPixel(self, item, params): raise RestException('Value Error: %s' % e.args[0]) return pixel + @describeRoute( + Description('Get a histogram for any region of a large image item.') + .notes('This can take all of the parameters as the region endpoint, ' + 'plus some histogram-specific parameters. Only typically used ' + 'parameters are listed. The returned result is a list with ' + 'one entry per channel (always one of L, LA, RGB, or RGBA ' + 'colorspace). Each entry has the histogram values, bin edges, ' + 'minimum and maximum values for the channel, and number of ' + 'samples (pixels) used in the computation.') + .param('itemId', 'The ID of the item.', paramType='path') + .param('width', 'The maximum width of the analyzed region in pixels.', + default=2048, required=False, dataType='int') + .param('height', 'The maximum height of the analyzed region in pixels.', + default=2048, required=False, dataType='int') + .param('resample', 'If false, an existing level of the image is used ' + 'for the histogram. If true, the internal values are ' + 'interpolated to match the specified size as needed.', + required=False, dataType='boolean', default=False) + .param('frame', 'For multiframe images, the 0-based frame number. ' + 'This is ignored on non-multiframe images.', required=False, + dataType='int') + .param('bins', 'The number of bins in the histogram.', + default=256, required=False, dataType='int') + .param('rangeMin', 'The minimum value in the histogram. Defaults to ' + 'the minimum value in the image.', + required=False, dataType='float') + .param('rangeMax', 'The maximum value in the histogram. Defaults to ' + 'the maximum value in the image.', + required=False, dataType='float') + .param('density', 'If true, scale the results by the number of ' + 'samples.', required=False, dataType='boolean', default=False) + .errorResponse('ID was invalid.') + .errorResponse('Read access was denied for the item.', 403) + ) + @access.public + @loadmodel(model='item', map={'itemId': 'item'}, level=AccessType.READ) + def getHistogram(self, item, params): + _adjustParams(params) + params = self._parseParams(params, True, [ + ('left', float, 'region', 'left'), + ('top', float, 'region', 'top'), + ('right', float, 'region', 'right'), + ('bottom', float, 'region', 'bottom'), + ('regionWidth', float, 'region', 'width'), + ('regionHeight', float, 'region', 'height'), + ('units', str, 'region', 'units'), + ('unitsWH', str, 'region', 'unitsWH'), + ('width', int, 'output', 'maxWidth'), + ('height', int, 'output', 'maxHeight'), + ('fill', str), + ('magnification', float, 'scale', 'magnification'), + ('mm_x', float, 'scale', 'mm_x'), + ('mm_y', float, 'scale', 'mm_y'), + ('exact', bool, 'scale', 'exact'), + ('frame', int), + ('encoding', str), + ('jpegQuality', int), + ('jpegSubsampling', int), + ('tiffCompression', str), + ('style', str), + ('resample', bool), + ('bins', int), + ('rangeMin', int), + ('rangeMax', int), + ('density', bool), + ]) + histRange = None + if 'rangeMin' in params or 'rangeMax' in params: + histRange = [params.pop('rangeMin', 0), params.pop('rangeMax', 256)] + result = self.imageItemModel.histogram(item, range=histRange, **params) + result = result['histogram'] + # Cast everything to lists and floats so json with encode properly + for entry in result: + for key in {'bin_edges', 'hist', 'range'}: + if key in entry: + entry[key] = [float(val) for val in list(entry[key])] + for key in {'min', 'max', 'samples'}: + if key in entry: + entry[key] = float(entry[key]) + return result + @describeRoute( Description('Get a list of additional images associated with a large image.') .param('itemId', 'The ID of the item.', paramType='path') diff --git a/girder/test_girder/test_tiles_rest.py b/girder/test_girder/test_tiles_rest.py index bf7f9ad23..ec76882c0 100644 --- a/girder/test_girder/test_tiles_rest.py +++ b/girder/test_girder/test_tiles_rest.py @@ -1104,3 +1104,18 @@ def testTilesWithFrameNumbers(server, admin, fsAssetstore): user=admin, isJson=False) assert utilities.respStatus(resp) == 200 assert utilities.getBody(resp, text=False) == image1 + + +@pytest.mark.usefixtures('unbindLargeImage') +@pytest.mark.plugin('large_image') +def testTilesHistogram(server, admin, fsAssetstore): + file = utilities.uploadExternalFile( + 'data/sample_image.ptif.sha512', admin, fsAssetstore) + itemId = str(file['itemId']) + resp = server.request( + path='/item/%s/tiles/histogram' % itemId, + params={'width': 2048, 'height': 2048, 'resample': False}) + assert len(resp.json) == 3 + assert len(resp.json[0]['hist']) == 256 + assert resp.json[1]['samples'] == 2801664 + assert resp.json[1]['hist'][128] == 176 diff --git a/large_image/tilesource/base.py b/large_image/tilesource/base.py index 2a755e03b..9cb2ac3f2 100644 --- a/large_image/tilesource/base.py +++ b/large_image/tilesource/base.py @@ -1127,6 +1127,79 @@ def _pilFormatMatches(self, image, match=True, **kwargs): # compatibility could be an issue. return False + def histogram(self, dtype=None, onlyMinMax=False, bins=256, + density=False, format=None, *args, **kwargs): + """ + Get a histogram for a region. + + :param dtype: if specified, the tiles must be this numpy.dtype. + :param onlyMinMax: if True, only return the minimum and maximum value + of the region. + :param bins: the number of bins in the histogram. This is passed to + numpy.histogram, but needs to produce teh same set of edges for + each tile. + :param density: if True, scale the results based on the number of + samples. + :param format: ignored. Used to override the format for the + tileIterator. + :param range: if None, use the computed min and (max + 1). Otherwise, + this is the range passed to numpy.histogram. Note this is only + accessible via kwargs as it otherwise overloads the range function. + :param *args: parameters to pass to the tileIterator. + :param **kwargs: parameters to pass to the tileIterator. + """ + kwargs = kwargs.copy() + histRange = kwargs.pop('range', None) + results = None + for tile in self.tileIterator(format=TILE_FORMAT_NUMPY, *args, **kwargs): + tile = tile['tile'] + if dtype is not None and 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 results is None: + results = {'min': tilemin, 'max': tilemax} + results['min'] = numpy.minimum(results['min'], tilemin) + results['max'] = numpy.maximum(results['max'], tilemax) + if results is None or onlyMinMax: + return results + results['histogram'] = [{ + 'min': results['min'][idx], + 'max': results['max'][idx], + 'range': ((results['min'][idx], results['max'][idx] + 1) + if histRange is None else histRange), + 'hist': None, + 'bin_edges': None + } for idx in range(len(results['min']))] + for tile in self.tileIterator(format=TILE_FORMAT_NUMPY, *args, **kwargs): + tile = tile['tile'] + if dtype is not None and tile.dtype != dtype: + if tile.dtype == numpy.uint8 and dtype == numpy.uint16: + tile = numpy.array(tile, dtype=numpy.uint16) * 257 + else: + continue + for idx in range(len(results['min'])): + entry = results['histogram'][idx] + hist, bin_edges = numpy.histogram( + tile[:, :, idx], bins, entry['range'], density=False) + if entry['hist'] is None: + entry['hist'] = hist + entry['bin_edges'] = bin_edges + else: + entry['hist'] += hist + for idx in range(len(results['min'])): + entry = results['histogram'][idx] + if entry['hist'] is not None: + entry['samples'] = numpy.sum(entry['hist']) + if density: + entry['hist'] = entry['hist'].astype(numpy.float) / entry['samples'] + return results + def _scanForMinMax(self, dtype, frame=None, analysisSize=1024): """ Scan the image at a lower resolution to find the minimum and maximum @@ -1141,30 +1214,15 @@ def _scanForMinMax(self, dtype, frame=None, analysisSize=1024): 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) + self._bandRanges[frame] = self.histogram( + dtype=dtype, + onlyMinMax=True, + output={'maxWidth': min(self.sizeX, analysisSize), + 'maxHeight': min(self.sizeY, analysisSize)}, + resample=False, + frame=frame) 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 diff --git a/test/test_source_tiff.py b/test/test_source_tiff.py index 9721bc88e..106a3ba9b 100644 --- a/test/test_source_tiff.py +++ b/test/test_source_tiff.py @@ -596,3 +596,27 @@ def testStyleNoData(): assert numpy.any(imageB[:, :, 3] != 255) assert image[12][215][3] == 255 assert imageB[12][215][3] != 255 + + +def testHistogram(): + imagePath = utilities.externaldata('data/sample_image.ptif.sha512') + source = large_image_source_tiff.TiffFileTileSource(imagePath) + hist = source.histogram(bins=8, output={'maxWidth': 1024}, resample=False) + assert len(hist['histogram']) == 3 + assert hist['histogram'][0]['range'] == (0, 256) + assert list(hist['histogram'][0]['hist']) == [182, 276, 639, 1426, 2123, 2580, 145758, 547432] + assert list(hist['histogram'][0]['bin_edges']) == [0, 32, 64, 96, 128, 160, 192, 224, 256] + assert hist['histogram'][0]['samples'] == 700416 + + hist = source.histogram(bins=256, output={'maxWidth': 1024}, + resample=False, density=True) + assert len(hist['histogram']) == 3 + assert hist['histogram'][0]['range'] == (0, 256) + assert len(list(hist['histogram'][0]['hist'])) == 256 + assert hist['histogram'][0]['hist'][128] == pytest.approx(5.43e-5, 0.01) + assert hist['histogram'][0]['samples'] == 700416 + + hist = source.histogram(bins=256, output={'maxWidth': 2048}, + density=True, resample=False) + assert hist['histogram'][0]['samples'] == 2801664 + assert hist['histogram'][0]['hist'][128] == pytest.approx(6.39e-5, 0.01) From b3b1eab300a26db8b09d3cf2a2e70c653812c92b Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 8 Jan 2020 15:53:16 -0500 Subject: [PATCH 5/6] Add some description documentation. --- docs/source/index.rst | 1 + docs/source/tilesource_options.rst | 82 ++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 docs/source/tilesource_options.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index 67b0199af..67e7f29c8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,6 +14,7 @@ large_image also works as a Girder plugin with optional annotation support. :maxdepth: 2 :caption: Contents: + tilesource_options large_image/modules large_image_source_dummy/modules large_image_source_mapnik/modules diff --git a/docs/source/tilesource_options.rst b/docs/source/tilesource_options.rst new file mode 100644 index 000000000..5d200c4ae --- /dev/null +++ b/docs/source/tilesource_options.rst @@ -0,0 +1,82 @@ +Tile Source Options +=================== + +Each tile source can have custom options that affect how tiles are generated from that tile source. All tile sources have a basic set of options: + +Format +------ + +Python tile functions can return tile data as images, numpy arrays, or PIL Image objects. The ``format`` parameter is one of the ``TILE_FORMAT_*`` constants. + +Encoding +-------- + +The ``encoding`` parameter can be one of ``JPEG``, ``PNG``, ``TIFF``, or ``JFIF``. When the tile is output as an image, this is the preferred format. Note that ``JFIF`` is a specific variant of ``JPEG`` that will always use either the Y or YCbCr color space as well as constraining other options. + +The ``encoding`` only affects output when ``format`` is ``TILE_FORMAT_IMAGE``. + +Associated with ``encoding``, some image formats have additional parameters. + +- ``JPEG`` and ``JFIF`` can specify ``jpegQuality``, a number from 0 to 100 where 0 is small and 100 is higher-quality, and ``jpegSubsampling``, where 0 is full chrominance data, 1 is half-resolution chrominance, and 2 is quarter-resolution chrominance. + +- ``TIFF`` can specify ``tiffCompression``, which is one of the ``libtiff_ctypes.COMPRESSION*`` options. + +Edges +----- + +When a tile is requested at the right or bottom edge of the image, the tile could extend past the boundary of the image. If the image is not an even multiple of the tile size, the ``edge`` parameter determines how the tile is generated. A value of ``None`` or ``False`` will generate a standard sized tile where the area outside of the image space could have pixels of any color. An ``edge`` value of ``'crop'`` or ``True`` will return a tile that is smaller than the standard size. A value if the form of a hexadecimal encoded 8-bit-per-channel color (e.g., ``#rrggbb``) will ensure that the area outside of the image space is all that color. + +Style +----- + +Often tiles are desired as 8-bit-per-sample images. However, if the tile source is more than 8 bits per sample or has more than 3 channels, some data will be lost. Similarly, if the data is returned as a numpy array, the range of values returned can vary by tile source. The ``style`` parameter can remap samples values and determine how channels are composited. + +If ``style`` is not specified or None, the default stype for the file is used. Otherwise, this is a json-encoded string that contains an object with a key of ``bands`` consisting of an array of band definitions. If only one band is needed, a json-encoded string of just the band definition can be used. + +A band definition is an object which can contain the following keys: + +- ``band``: if -1 or None, 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. The values between min and max are interpolated using a piecewise linear algorithm to map to the specified palette values. + +- ``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. + +Note that some tile sources add additional options to the ``style`` parameter. + +Examples +++++++++ + +Swap the red and green channels of a three color image +______________________________________________________ + +.. code-block:: + + style = {"bands": [ + {"band": 1, "palette": ["#000", "#0f0"]}, + {"band": 2, "palette": ["#000", "#f00"]}, + {"band": 3, "palette": ["#000", "#00f"]} + ]} + +Apply a gamma correction to the image +_____________________________________ + +This used a precomputed sixteen entry greyscale palette, computed as ``(value / 255) ** gamma * 255``, where ``value`` is one of [0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255] and gamma is ``0.5``. + +.. code-block:: + + style = {"palette": [ + "#000000", "#414141", "#5D5D5D", "#727272", + "#838383", "#939393", "#A1A1A1", "#AEAEAE", + "#BABABA", "#C5C5C5", "#D0D0D0", "#DADADA", + "#E4E4E4", "#EDEDED", "#F6F6F6", "#FFFFFF" + ]} + + From a2e077a53f8c6d3091732704e4287f40aeb54245 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 10 Jan 2020 12:59:11 -0500 Subject: [PATCH 6/6] Update the changelog. --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a22c4f0..1f7139027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,13 @@ ## Unreleased +### Features +- Add style options for all tile sources to remap channels and colors (#397) +- Better support for high bit-depth images (#397) + ### Improvements - Make it easier to load the annotation-enable web viewers (#402) +- Improved support for z-loops in OMETiff files (#397) ## Version 1.0.1