From 1e245ed88df88237932f042cbe890979312cebfa Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 6 Jul 2021 14:06:32 -0400 Subject: [PATCH] Add getBandInformation for all tile sources. --- CHANGELOG.md | 5 ++ .../girder_large_image/models/image_item.py | 13 +++ girder/girder_large_image/rest/tiles.py | 45 ++++++---- girder/test_girder/test_tiles_rest.py | 13 +++ large_image/tilesource/base.py | 85 ++++++++++++++++++- .../gdal/large_image_source_gdal/__init__.py | 15 +++- .../large_image_source_mapnik/__init__.py | 2 +- test/test_source_pil.py | 12 +++ 8 files changed, 166 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2fc09ea..509437983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## Unreleased + +### Features +- Provide band information on all tile sources (#622) + ## Version 1.6.2 ### Improvements diff --git a/girder/girder_large_image/models/image_item.py b/girder/girder_large_image/models/image_item.py index d27b8bb9d..61a5d1478 100644 --- a/girder/girder_large_image/models/image_item.py +++ b/girder/girder_large_image/models/image_item.py @@ -454,6 +454,19 @@ def histogram(self, item, **kwargs): imageKey=imageKey, pickleCache=True, **kwargs)[0] return result + def getBandInformation(self, item, statistics=True, **kwargs): + """ + Using a tile source, get band information of the image. + + :param item: the item with the tile source. + :param kwargs: optional arguments. See the tilesource + getBandInformation method. + :returns: band information. + """ + tileSource = self._loadTileSource(item, **kwargs) + result = tileSource.getBandInformation(statistics=statistics) + return result + 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 f39ac09dd..08c07d378 100644 --- a/girder/girder_large_image/rest/tiles.py +++ b/girder/girder_large_image/rest/tiles.py @@ -103,29 +103,22 @@ def __init__(self, apiRoot): apiRoot.item.route('POST', (':itemId', 'tiles', 'convert'), self.convertImage) apiRoot.item.route('GET', (':itemId', 'tiles'), self.getTilesInfo) apiRoot.item.route('DELETE', (':itemId', 'tiles'), self.deleteTiles) - apiRoot.item.route('GET', (':itemId', 'tiles', 'thumbnail'), - self.getTilesThumbnail) - apiRoot.item.route('GET', (':itemId', 'tiles', 'region'), - 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', 'thumbnail'), self.getTilesThumbnail) + apiRoot.item.route('GET', (':itemId', 'tiles', 'region'), 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', 'bands'), self.getBandInformation) + apiRoot.item.route('GET', (':itemId', 'tiles', 'zxy', ':z', ':x', ':y'), self.getTile) apiRoot.item.route('GET', (':itemId', 'tiles', 'fzxy', ':frame', ':z', ':x', ':y'), self.getTileWithFrame) - apiRoot.item.route('GET', (':itemId', 'tiles', 'images'), - self.getAssociatedImagesList) + apiRoot.item.route('GET', (':itemId', 'tiles', 'images'), self.getAssociatedImagesList) apiRoot.item.route('GET', (':itemId', 'tiles', 'images', ':image'), self.getAssociatedImage) apiRoot.item.route('GET', (':itemId', 'tiles', 'images', ':image', 'metadata'), self.getAssociatedImageMetadata) apiRoot.item.route('GET', ('test', 'tiles'), self.getTestTilesInfo) - apiRoot.item.route('GET', ('test', 'tiles', 'zxy', ':z', ':x', ':y'), - self.getTestTile) - apiRoot.item.route('GET', (':itemId', 'tiles', 'dzi.dzi'), - self.getDZIInfo) + apiRoot.item.route('GET', ('test', 'tiles', 'zxy', ':z', ':x', ':y'), self.getTestTile) + apiRoot.item.route('GET', (':itemId', 'tiles', 'dzi.dzi'), self.getDZIInfo) apiRoot.item.route('GET', (':itemId', 'tiles', 'dzi_files', ':level', ':xandy'), self.getDZITile) apiRoot.item.route('GET', (':itemId', 'tiles', 'internal_metadata'), @@ -977,6 +970,26 @@ def getHistogram(self, item, params): entry[key] = float(entry[key]) return result + @describeRoute( + Description('Get band information for a large image item.') + .param('itemId', 'The ID of the item.', paramType='path') + .param('frame', 'For multiframe images, the 0-based frame number. ' + 'This is ignored on non-multiframe images.', required=False, + dataType='int') + .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 getBandInformation(self, item, params): + _adjustParams(params) + params = self._parseParams(params, True, [ + ('frame', int), + ]) + _handleETag('getBandInformation', item, params) + result = self.imageItemModel.getBandInformation(item) + 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 503a5664e..671e51b7f 100644 --- a/girder/test_girder/test_tiles_rest.py +++ b/girder/test_girder/test_tiles_rest.py @@ -1186,6 +1186,19 @@ def testTilesInternalMetadata(server, admin, fsAssetstore): assert resp.json['tilesource'] == 'tiff' +@pytest.mark.usefixtures('unbindLargeImage') +@pytest.mark.plugin('large_image') +def testTilesBandInformation(server, admin, fsAssetstore): + file = utilities.uploadExternalFile( + 'sample_Easy1.png', admin, fsAssetstore) + itemId = str(file['itemId']) + server.request(path='/item/%s/tiles' % itemId, method='POST', user=admin) + resp = server.request(path='/item/%s/tiles/bands' % itemId) + assert len(resp.json) == 4 + assert resp.json[0]['interpretation'] == 'red' + assert 'mean' in resp.json[0] + + @pytest.mark.usefixtures('unbindLargeImage') @pytest.mark.plugin('large_image') def testTilesFromMultipleDotName(boundServer, admin, fsAssetstore, girderWorker): diff --git a/large_image/tilesource/base.py b/large_image/tilesource/base.py index b92b5468b..b354f5600 100644 --- a/large_image/tilesource/base.py +++ b/large_image/tilesource/base.py @@ -1417,15 +1417,40 @@ def histogram(self, dtype=None, onlyMinMax=False, bins=256, 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) + tilesum = numpy.array([ + numpy.sum(tile[:, :, idx]) for idx in range(tile.shape[2])], float) + tilesum2 = numpy.array([ + numpy.sum(numpy.array(tile[:, :, idx], float) ** 2) + for idx in range(tile.shape[2])], float) + tilecount = tile.shape[0] * tile.shape[1] if results is None: - results = {'min': tilemin, 'max': tilemax} - results['min'] = numpy.minimum(results['min'], tilemin) - results['max'] = numpy.maximum(results['max'], tilemax) + results = { + 'min': tilemin, + 'max': tilemax, + 'sum': tilesum, + 'sum2': tilesum2, + 'count': tilecount + } + else: + results['min'] = numpy.minimum(results['min'], tilemin) + results['max'] = numpy.maximum(results['max'], tilemax) + results['sum'] += tilesum + results['sum2'] += tilesum2 + results['count'] += tilecount + results['mean'] = results['sum'] / results['count'] + results['stdev'] = numpy.maximum( + results['sum2'] / results['count'] - results['mean'] ** 2, + [0] * results['sum2'].shape[0]) ** 0.5 + results.pop('sum', None) + results.pop('sum2', None) + results.pop('count', None) if results is None or onlyMinMax: return results results['histogram'] = [{ 'min': results['min'][idx], 'max': results['max'][idx], + 'mean': results['mean'][idx], + 'stdev': results['stdev'][idx], 'range': ((results['min'][idx], results['max'][idx] + 1) if histRange is None else histRange), 'hist': None, @@ -1788,6 +1813,9 @@ def getMetadata(self): :channels: optional. If known, a list of channel names :channelmap: optional. If known, a dictionary of channel names with their offset into the channel list. + + Note that this does nto include band information, though some tile + sources may do so. """ mag = self.getNativeMagnification() return { @@ -1851,6 +1879,57 @@ def getInternalMetadata(self, **kwargs): """ return None + def getOneBandInformation(self, band): + """ + Get band information for a single band. + + :param band: a 0-based band. + :returns: a dictionary of band information. See getBandInformation. + """ + return self.getBandInformation()[band] + + def getBandInformation(self, statistics=False, **kwargs): + """ + Get information about each band in the image. + + :param statistics: if True, compute statistics if they don't already + exist. + :returns: a list of one dictionary per band. Each dictionary contains + known values such as interpretation, min, max, mean, stdev. + """ + if not getattr(self, '_bandInfo', None): + bandInterp = { + 1: ['gray'], + 2: ['gray', 'alpha'], + 3: ['red', 'green', 'blue'], + 4: ['red', 'green', 'blue', 'alpha']} + if not statistics: + if not getattr(self, '_bandInfoNoStats', None): + tile = self.getSingleTile()['tile'] + bands = tile.shape[2] if len(tile.shape) > 2 else 1 + interp = bandInterp.get(bands, 3) + bandInfo = [{'interpretation': interp[idx] if idx < len(interp) else 'unknown'} + for idx in range(bands)] + self._bandInfoNoStats = bandInfo + return self._bandInfoNoStats + analysisSize = 2048 + histogram = self.histogram( + onlyMinMax=True, + output={'maxWidth': min(self.sizeX, analysisSize), + 'maxHeight': min(self.sizeY, analysisSize)}, + resample=False, + **kwargs) + bands = histogram['min'].shape[0] + interp = bandInterp.get(bands, 3) + bandInfo = [{'interpretation': interp[idx] if idx < len(interp) else 'unknown'} + for idx in range(bands)] + for key in {'min', 'max', 'mean', 'stdev'}: + if key in histogram: + for idx in range(bands): + bandInfo[idx][key] = histogram[key][idx] + self._bandInfo = bandInfo + return self._bandInfo + def _xyzInRange(self, x, y, z, frame=None, numFrames=None): """ Check if a tile at x, y, z is in range based on self.levels, diff --git a/sources/gdal/large_image_source_gdal/__init__.py b/sources/gdal/large_image_source_gdal/__init__.py index e8c66cc41..4c00ae4a0 100644 --- a/sources/gdal/large_image_source_gdal/__init__.py +++ b/sources/gdal/large_image_source_gdal/__init__.py @@ -480,7 +480,17 @@ def getBounds(self, srs=None): self._bounds[srs] = bounds return self._bounds[srs] - def getBandInformation(self, dataset=None): + def getBandInformation(self, statistics=True, dataset=None, **kwargs): + """ + Get information about each band in the image. + + :param statistics: if True, compute statistics if they don't already + exist. Ignored: always treated as True. + :param dataset: the dataset. If None, use the main dataset. + :returns: a list of one dictionary per band. Each dictionary contains + known values such as interpretation, min, max, mean, stdev, nodata, + scale, offset, units, categories, colortable, maskband. + """ if not getattr(self, '_bandInfo', None) or dataset: with self._getDatasetLock: cache = not dataset @@ -533,9 +543,6 @@ def getBandInformation(self, dataset=None): self._bandInfo = infoSet return self._bandInfo - def getOneBandInformation(self, band): - return self.getBandInformation()[band] - def getMetadata(self): with self._getDatasetLock: metadata = { diff --git a/sources/mapnik/large_image_source_mapnik/__init__.py b/sources/mapnik/large_image_source_mapnik/__init__.py index 371052749..98197e495 100644 --- a/sources/mapnik/large_image_source_mapnik/__init__.py +++ b/sources/mapnik/large_image_source_mapnik/__init__.py @@ -226,7 +226,7 @@ def getOneBandInformation(self, band): if not dataset.get('bands'): if not dataset.get('dataset'): dataset['dataset'] = gdal.Open(dataset['name'], gdalconst.GA_ReadOnly) - dataset['bands'] = self.getBandInformation(dataset['dataset']) + dataset['bands'] = self.getBandInformation(dataset=dataset['dataset']) bandInfo = dataset['bands'][band[1]] return bandInfo diff --git a/test/test_source_pil.py b/test/test_source_pil.py index 5bea237fb..0bde1b9e3 100644 --- a/test/test_source_pil.py +++ b/test/test_source_pil.py @@ -71,3 +71,15 @@ def testInternalMetadata(): source = large_image_source_pil.open(imagePath) metadata = source.getInternalMetadata() assert 'pil' in metadata + + +def testGetBandInformation(): + imagePath = datastore.fetch('sample_Easy1.png') + source = large_image_source_pil.open(imagePath) + bandInfo = source.getBandInformation(False) + assert len(bandInfo) == 4 + assert bandInfo[0] == {'interpretation': 'red'} + + bandInfo = source.getBandInformation(True) + assert len(bandInfo) == 4 + assert 'mean' in bandInfo[0]