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)