Skip to content

Commit

Permalink
Merge pull request #397 from girder/style-option
Browse files Browse the repository at this point in the history
Add a style option
  • Loading branch information
manthey authored Jan 10, 2020
2 parents ee805aa + a2e077a commit 9c753ba
Show file tree
Hide file tree
Showing 31 changed files with 1,136 additions and 258 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions docs/source/tilesource_options.rst
Original file line number Diff line number Diff line change
@@ -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"
]}
19 changes: 12 additions & 7 deletions girder/girder_large_image/girder_tilesource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
87 changes: 87 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 @@ -551,6 +553,7 @@ def getTilesThumbnail(self, item, params):
('jpegSubsampling', int),
('tiffCompression', str),
('encoding', str),
('style', str),
('contentDisposition', str),
])
try:
Expand Down Expand Up @@ -636,6 +639,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'])
Expand Down Expand Up @@ -669,6 +673,7 @@ def getTilesRegion(self, item, params):
('jpegQuality', int),
('jpegSubsampling', int),
('tiffCompression', str),
('style', str),
('contentDisposition', str),
])
try:
Expand Down Expand Up @@ -721,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 Expand Up @@ -767,6 +853,7 @@ def getAssociatedImage(self, itemId, image, params):
('jpegSubsampling', int),
('tiffCompression', str),
('encoding', str),
('style', str),
('contentDisposition', str),
])
try:
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
3 changes: 0 additions & 3 deletions girder_annotation/test_annotation/test_annotations_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 9c753ba

Please sign in to comment.