Skip to content

Commit

Permalink
Add a histogram method and endpoint.
Browse files Browse the repository at this point in the history
  • Loading branch information
manthey committed Dec 13, 2019
1 parent 8b1135b commit 163ba50
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 22 deletions.
12 changes: 12 additions & 0 deletions girder/girder_large_image/models/image_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions girder/girder_large_image/rest/tiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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')
Expand Down
15 changes: 15 additions & 0 deletions girder/test_girder/test_tiles_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
102 changes: 80 additions & 22 deletions large_image/tilesource/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions test/test_source_tiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit 163ba50

Please sign in to comment.