From b9258dcef6a81b8c3b43f82f1ef109a36682c3df Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 24 Feb 2021 09:41:22 -0500 Subject: [PATCH] Support compositing multiple frames together. The style option band parameter can specify a frame value to combine multiple frames into a single output. --- CHANGELOG.md | 13 +++++++--- docs/source/tilesource_options.rst | 2 ++ large_image/tilesource/base.py | 39 +++++++++++++++++++++++++----- test/test_source_ometiff.py | 34 ++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d60a80a42..bdafcf777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,11 @@ # Change Log -## Version 1.4.0 +## Unreleased -### Changes -- The image conversion task has been split into two packages, large_image_converter and large_image_tasks. The tasks module is used with Girder and Girder Worker for converting images and depends on the converter package. The converter package can be used as a stand-alone command line tool (#518) +### Features +- Multiple frames can be composited together with the style option + +## Version 1.4.0 ### Features - Added a `canRead` method to the core module (#512) @@ -11,7 +13,7 @@ - Image conversion can now convert images readable by large_image sources but not by vips (#529) - Added an `open` method to the core module as an alias to `getTileSource` (#550) - Added an `open` method to each file source module (#550) -- Numerous improvement to image converversion (#533, #535, #537, #541, #544, #545, #546, #549) +- Numerous improvement to image conversion (#533, #535, #537, #541, #544, #545, #546, #549) ### Improvements - Better release bioformats resources (#502) @@ -21,6 +23,9 @@ - Support decoding JP2k compressed tiles in the tiff tile source (#514) - Hardened tests against transient timing issues (#532, #536) +### Changes +- The image conversion task has been split into two packages, large_image_converter and large_image_tasks. The tasks module is used with Girder and Girder Worker for converting images and depends on the converter package. The converter package can be used as a stand-alone command line tool (#518) + ### Bug Fixes - Harden updates of the item view after making a large image (#508, #515) - Tiles in an unexpected color mode weren't consistently adjusted (#510) diff --git a/docs/source/tilesource_options.rst b/docs/source/tilesource_options.rst index 5d200c4ae..ad247d932 100644 --- a/docs/source/tilesource_options.rst +++ b/docs/source/tilesource_options.rst @@ -37,6 +37,8 @@ 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. +- ``frame``: if specified, override the frame parameter used in the tile query for this band. Note that it is more efficient to have at least one band not specify a frame parameter or use the same value as the basic query. Defaults to the frame value of the core query. + - ``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. diff --git a/large_image/tilesource/base.py b/large_image/tilesource/base.py index 4d68eaa0f..e1f4158ac 100644 --- a/large_image/tilesource/base.py +++ b/large_image/tilesource/base.py @@ -6,7 +6,6 @@ import PIL.Image import PIL.ImageColor import PIL.ImageDraw -import random import threading import xml.etree.ElementTree from collections import defaultdict @@ -500,6 +499,12 @@ def __init__(self, encoding='JPEG', jpegQuality=95, jpegSubsampling=0, 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. + frame: if specified, override the frame value for this band. + When used as part of a bands list, this can be used to + composite multiple frames together. It is most efficient + if at least one band either doesn't specify a frame + parameter or specifies the same frame value as the primary + query. 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 @@ -1269,7 +1274,7 @@ def _scanForMinMax(self, dtype, frame=None, analysisSize=1024, **kwargs): self._skipStyle = True # Divert the tile cache while querying unstyled tiles classkey = self._classkey - self._classkey = 'nocache' + str(random.random()) + self._classkey = self._classkey + '__unstyled' try: self._bandRanges[frame] = self.histogram( dtype=dtype, @@ -1333,20 +1338,39 @@ def _getMinMax(self, minmax, value, dtype, bandidx=None, frame=None): value = 255 return float(value) - def _applyStyle(self, image, style, frame=None): + def _applyStyle(self, image, style, x, y, z, frame=None): """ Apply a style to a numpy image. :param image: the image to modify. :param style: a style object. + :param x: the x tile position; used for multi-frame styles. + :param y: the y tile position; used for multi-frame styles. + :param z: the z tile position; used for multi-frame styles. :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) + mainImage = image + mainFrame = frame for entry in style: bandidx = 0 if image.shape[2] <= 2 else 1 band = None + if entry.get('frame') is None or entry.get('frame') == frame: + image = mainImage + frame = mainFrame + else: + frame = entry['frame'] + self._skipStyle = True + # Divert the tile cache while querying unstyled tiles + classkey = self._classkey + self._classkey = self._classkey + '__unstyled' + try: + image = self.getTile(x, y, z, frame=frame, numpyAllowed=True) + finally: + del self._skipStyle + self._classkey = classkey if (isinstance(entry.get('band'), int) and entry['band'] >= 1 and entry['band'] <= image.shape[2]): bandidx = entry['band'] - 1 @@ -1394,13 +1418,16 @@ def _applyStyle(self, image, style, frame=None): output[:, :, channel], numpy.where(keep, clrs, 0)) return output - def _outputTileNumpyStyle(self, tile, applyStyle, frame=None): + def _outputTileNumpyStyle(self, tile, applyStyle, x, y, z, 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 x: the x tile position; used for multi-frame styles. + :param y: the y tile position; used for multi-frame styles. + :param z: the z tile position; used for multi-frame styles. :param frame: the frame to use for auto-ranging. :returns: a numpy array and a target PIL image mode. """ @@ -1408,7 +1435,7 @@ def _outputTileNumpyStyle(self, tile, applyStyle, frame=None): if applyStyle and getattr(self, 'style', None): with self._styleLock: if not getattr(self, '_skipStyle', False): - tile = self._applyStyle(tile, self.style, frame) + tile = self._applyStyle(tile, self.style, x, y, z, frame) if tile.shape[0] != self.tileHeight or tile.shape[1] != self.tileWidth: extend = numpy.zeros( (self.tileHeight, self.tileWidth, tile.shape[2]), @@ -1450,7 +1477,7 @@ def _outputTile(self, tile, tileEncoding, x, y, z, pilImageAllowed=False, 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')) + tile, mode = self._outputTileNumpyStyle(tile, applyStyle, x, y, z, kwargs.get('frame')) if isEdge: contentWidth = min(self.tileWidth, sizeX - (maxX - self.tileWidth)) diff --git a/test/test_source_ometiff.py b/test/test_source_ometiff.py index 9d6112697..609b41bcb 100644 --- a/test/test_source_ometiff.py +++ b/test/test_source_ometiff.py @@ -94,6 +94,40 @@ def testStyleAutoMinMax(): assert image[240][128][0] < imageB[240][128][0] +def testStyleFrame(): + imagePath = utilities.externaldata('data/sample.ome.tif.sha512') + source = large_image_source_ometiff.open( + imagePath, style=json.dumps({'bands': [{ + 'palette': ['#000000', '#0000ff'], + }, { + 'palette': ['#000000', '#00ff00'], + }]})) + image, _ = source.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=TILE_FORMAT_NUMPY, frame=1) + sourceB = large_image_source_ometiff.open( + imagePath, style=json.dumps({'bands': [{ + 'palette': ['#000000', '#0000ff'], + }, { + 'frame': 1, + 'palette': ['#000000', '#00ff00'], + }]})) + imageB, _ = sourceB.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=TILE_FORMAT_NUMPY, frame=1) + assert numpy.all(image == imageB) + assert image.shape == imageB.shape + sourceC = large_image_source_ometiff.open( + imagePath, style=json.dumps({'bands': [{ + 'palette': ['#000000', '#0000ff'], + }, { + 'frame': 2, + 'palette': ['#000000', '#00ff00'], + }]})) + imageC, _ = sourceC.getRegion( + output={'maxWidth': 256, 'maxHeight': 256}, format=TILE_FORMAT_NUMPY, frame=1) + assert numpy.any(image != imageC) + assert image.shape == imageC.shape + + def testInternalMetadata(): imagePath = utilities.externaldata('data/sample.ome.tif.sha512') source = large_image_source_ometiff.open(imagePath)