Skip to content

Commit

Permalink
Merge pull request #631 from girder/tile-frames
Browse files Browse the repository at this point in the history
Tile frames
  • Loading branch information
manthey authored Aug 10, 2021
2 parents 1091eba + bc076aa commit fcd69ab
Show file tree
Hide file tree
Showing 6 changed files with 352 additions and 6 deletions.
30 changes: 28 additions & 2 deletions girder/girder_large_image/models/image_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ def getThumbnail(self, item, checkAndCreate=False, width=None, height=None, **kw
size.
:param item: the item with the tile source.
:param checkAndCreate: if the thumbnail is already cached, just return
True. If it does not, create, cache, and return it. If 'nosave',
return values from the cache, but do not store new results in the
cache.
:param width: maximum width in pixels.
:param height: maximum height in pixels.
:param kwargs: optional arguments. Some options are encoding,
Expand All @@ -325,7 +329,7 @@ def _getAndCacheImageOrData(
'thumbnailKey': key
})
if existing:
if checkAndCreate:
if checkAndCreate and checkAndCreate != 'nosave':
return True
if kwargs.get('contentDisposition') != 'attachment':
contentDisposition = 'inline'
Expand All @@ -351,7 +355,8 @@ def _getAndCacheImageOrData(
saveFile = maxThumbnailFiles > 0
# Make sure we don't exceed the desired number of thumbnails
self.removeThumbnailFiles(item, maxThumbnailFiles - 1)
if saveFile:
if (saveFile and checkAndCreate != 'nosave' and (
pickleCache or isinstance(imageData, bytes))):
dataStored = imageData if not pickleCache else pickle.dumps(imageData, protocol=4)
# Save the data as a file
datafile = Upload().uploadFromFile(
Expand Down Expand Up @@ -423,6 +428,27 @@ def getRegion(self, item, **kwargs):
regionData, regionMime = tileSource.getRegion(**kwargs)
return regionData, regionMime

def tileFrames(self, item, checkAndCreate='nosave', **kwargs):
"""
Given the parameters for getRegion, plus a list of frames and the
number of frames across, make a larger image composed of a region from
each listed frame composited together.
:param item: the item with the tile source.
:param checkAndCreate: if False, use the cache. If True and the result
is already cached, just return True. If it does not, create,
cache, and return it. If 'nosave', return values from the cache,
but do not store new results in the cache.
:param kwargs: optional arguments. Some options are left, top,
right, bottom, regionWidth, regionHeight, units, width, height,
encoding, jpegQuality, jpegSubsampling, and tiffCompression. This
is also passed to the tile source. These also include frameList
and framesAcross.
:returns: regionData, regionMime: the image data and the mime type.
"""
return self._getAndCacheImageOrData(
item, 'tileFrames', checkAndCreate, dict(kwargs), **kwargs)

def getPixel(self, item, **kwargs):
"""
Using a tile source, get a single pixel from the image.
Expand Down
160 changes: 160 additions & 0 deletions girder/girder_large_image/rest/tiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def __init__(self, apiRoot):
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', 'tile_frames'), self.tileFrames)
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)
Expand Down Expand Up @@ -1081,3 +1082,162 @@ def getAssociatedImageMetadata(self, item, image, params):
if pilImage.info:
result['info'] = pilImage.info
return result

@describeRoute(
Description('Get any region of a large image item, optionally scaling '
'it.')
.param('itemId', 'The ID of the item.', paramType='path')
.param('framesAcross', 'How many frames across', required=False, dataType='int')
.param('frameList', 'Comma-separated list of frames', required=False)
.param('cache', 'Cache the results for future use', required=False,
dataType='boolean', default=False)
.param('left', 'The left column (0-based) of the region to process. '
'Negative values are offsets from the right edge.',
required=False, dataType='float')
.param('top', 'The top row (0-based) of the region to process. '
'Negative values are offsets from the bottom edge.',
required=False, dataType='float')
.param('right', 'The right column (0-based from the left) of the '
'region to process. The region will not include this column. '
'Negative values are offsets from the right edge.',
required=False, dataType='float')
.param('bottom', 'The bottom row (0-based from the top) of the region '
'to process. The region will not include this row. Negative '
'values are offsets from the bottom edge.',
required=False, dataType='float')
.param('regionWidth', 'The width of the region to process.',
required=False, dataType='float')
.param('regionHeight', 'The height of the region to process.',
required=False, dataType='float')
.param('units', 'Units used for left, top, right, bottom, '
'regionWidth, and regionHeight. base_pixels are pixels at the '
'maximum resolution, pixels and mm are at the specified '
'magnfication, fraction is a scale of [0-1].', required=False,
enum=sorted(set(TileInputUnits.values())),
default='base_pixels')
.param('width', 'The maximum width of the output image in pixels.',
required=False, dataType='int')
.param('height', 'The maximum height of the output image in pixels.',
required=False, dataType='int')
.param('fill', 'A fill color. If output dimensions are specified and '
'fill is specified and not "none", the output image is padded '
'on either the sides or the top and bottom to the requested '
'output size. Most css colors are accepted.', required=False)
.param('magnification', 'Magnification of the output image. If '
'neither width for height is specified, the magnification, '
'mm_x, and mm_y parameters are used to select the output size.',
required=False, dataType='float')
.param('mm_x', 'The size of the output pixels in millimeters',
required=False, dataType='float')
.param('mm_y', 'The size of the output pixels in millimeters',
required=False, dataType='float')
.param('exact', 'If magnification, mm_x, or mm_y are specified, they '
'must match an existing level of the image exactly.',
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('encoding', 'Output image encoding. TILED generates a tiled '
'tiff without the upper limit on image size the other options '
'have. For geospatial sources, TILED will also have '
'appropriate tagging.', required=False,
enum=['JPEG', 'PNG', 'TIFF', 'TILED'], default='JPEG')
.param('jpegQuality', 'Quality used for generating JPEG images',
required=False, dataType='int', default=95)
.param('jpegSubsampling', 'Chroma subsampling used for generating '
'JPEG images. 0, 1, and 2 are full, half, and quarter '
'resolution chroma respectively.', required=False,
enum=['0', '1', '2'], dataType='int', default='0')
.param('tiffCompression', 'Compression method when storing a TIFF '
'image', required=False,
enum=['none', 'raw', 'lzw', 'tiff_lzw', 'jpeg', 'deflate',
'tiff_adobe_deflate'])
.param('style', 'JSON-encoded style string', required=False)
.param('resample', 'If false, an existing level of the image is used '
'for the region. If true, the internal values are '
'interpolated to match the specified size as needed. 0-3 for '
'a specific interpolation method (0-nearest, 1-lanczos, '
'2-bilinear, 3-bicubic)', required=False,
enum=['false', 'true', '0', '1', '2', '3'])
.param('contentDisposition', 'Specify the Content-Disposition response '
'header disposition-type value.', required=False,
enum=['inline', 'attachment'])
.param('contentDispositionFilename', 'Specify the filename used in '
'the Content-Disposition response header.', required=False)
.produces(ImageMimeTypes)
.errorResponse('ID was invalid.')
.errorResponse('Read access was denied for the item.', 403)
.errorResponse('Insufficient memory.')
)
@access.public(cookie=True)
@loadmodel(model='item', map={'itemId': 'item'}, level=AccessType.READ)
def tileFrames(self, item, params):
cache = params.pop('cache', False)
checkAndCreate = False if cache else 'nosave'
_adjustParams(params)

params = self._parseParams(params, True, [
('framesAcross', int),
('frameList', str),
('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', 'boolOrInt'),
('contentDisposition', str),
('contentDispositionFileName', str)
])
_handleETag('tileFrames', item, params)
if 'frameList' in params:
params['frameList'] = [
int(f.strip()) for f in str(params['frameList']).lstrip(
'[').rstrip(']').split(',')]
setResponseTimeLimit(86400)
try:
result = self.imageItemModel.tileFrames(
item, checkAndCreate=checkAndCreate, **params)
except TileGeneralException as e:
raise RestException(e.args[0])
except ValueError as e:
raise RestException('Value Error: %s' % e.args[0])
if not isinstance(result, tuple):
return result
regionData, regionMime = result
self._setContentDisposition(
item, params.get('contentDisposition'), regionMime, 'tileframes',
params.get('contentDispositionFilename'))
setResponseHeader('Content-Type', regionMime)
if isinstance(regionData, pathlib.Path):
BUF_SIZE = 65536

def stream():
try:
with regionData.open('rb') as f:
while True:
data = f.read(BUF_SIZE)
if not data:
break
yield data
finally:
regionData.unlink()
return stream
setRawResponse()
return regionData
33 changes: 32 additions & 1 deletion girder/test_girder/test_tiles_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@ def testRegions(server, admin, fsAssetstore):
resp = server.request(path='/item/%s/tiles/region' % itemId,
user=admin, isJson=False, params=params)
assert utilities.respStatus(resp) == 200
image = origImage = utilities.getBody(resp, text=False)
image = utilities.getBody(resp, text=False)
assert image[:len(utilities.BigTIFFHeader)] == utilities.BigTIFFHeader


Expand Down Expand Up @@ -1323,3 +1323,34 @@ def testTilesConvertRemote(boundServer, admin, fsAssetstore, girderWorker):
assert tileMetadata['mm_x'] is None
assert tileMetadata['mm_y'] is None
_testTilesZXY(boundServer, admin, itemId, tileMetadata)


@pytest.mark.usefixtures('unbindLargeImage')
@pytest.mark.plugin('large_image')
def testTileFrames(server, admin, fsAssetstore):
file = utilities.uploadExternalFile(
'sample.ome.tif', admin, fsAssetstore)
itemId = str(file['itemId'])
params = {
'width': 200,
'height': 200}
resp = server.request(path='/item/%s/tiles/tile_frames' % itemId,
user=admin, isJson=False, params=params)
assert utilities.respStatus(resp) == 200

params['cache'] = 'true'
resp = server.request(path='/item/%s/tiles/tile_frames' % itemId,
user=admin, isJson=False, params=params)
assert utilities.respStatus(resp) == 200

resp = server.request(path='/item/%s/tiles/tile_frames' % itemId,
user=admin, isJson=False, params=params)
assert utilities.respStatus(resp) == 200

params['encoding'] = 'TILED'
params['frameList'] = '0,2'
resp = server.request(path='/item/%s/tiles/tile_frames' % itemId,
user=admin, isJson=False, params=params)
assert utilities.respStatus(resp) == 200
image = utilities.getBody(resp, text=False)
assert image[:len(utilities.BigTIFFHeader)] == utilities.BigTIFFHeader
75 changes: 73 additions & 2 deletions large_image/tilesource/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1735,15 +1735,22 @@ def _encodeTiledImageFromVips(self, vimg, iterInfo, image, **kwargs):
if (kwargs.get('fill') and str(kwargs.get('fill')).lower() != 'none' and
maxWidth and maxHeight and
(maxWidth > image['width'] or maxHeight > image['height'])):
corner, fill = False, kwargs.get('fill')
if fill.lower().startswith('corner:'):
corner, fill = True, fill.split(':', 1)[1]
color = PIL.ImageColor.getcolor(
kwargs.get('fill'), ['L', 'LA', 'RGB', 'RGBA'][vimg.bands - 1])
fill, ['L', 'LA', 'RGB', 'RGBA'][vimg.bands - 1])
if isinstance(color, int):
color = [color]
lbimage = pyvips.Image.black(maxWidth, maxHeight, bands=vimg.bands)
lbimage = lbimage.cast(vimg.format)
lbimage = lbimage.draw_rect(
[c * (257 if vimg.format == pyvips.BandFormat.USHORT else 1) for c in color],
0, 0, maxWidth, maxHeight, fill=True)
vimg = lbimage.insert(
vimg, (maxWidth - image['width']) // 2, (maxHeight - image['height']) // 2)
vimg,
(maxWidth - image['width']) // 2 if not corner else 0,
(maxHeight - image['height']) // 2 if not corner else 0)
if image['mm_x'] and image['mm_y']:
vimg = vimg.copy(xres=1 / image['mm_x'], yres=1 / image['mm_y'])
fd, outputPath = tempfile.mkstemp('.tiff', 'tiledRegion_')
Expand All @@ -1758,6 +1765,70 @@ def _encodeTiledImageFromVips(self, vimg, iterInfo, image, **kwargs):
pass
raise exc

def tileFrames(self, format=(TILE_FORMAT_IMAGE, ), frameList=None,
framesAcross=None, **kwargs):
"""
Given the parameters for getRegion, plus a list of frames and the
number of frames across, make a larger image composed of a region from
each listed frame composited together.
:param format: the desired format or a tuple of allowed formats.
Formats are members of (TILE_FORMAT_PIL, TILE_FORMAT_NUMPY,
TILE_FORMAT_IMAGE). If TILE_FORMAT_IMAGE, encoding may be
specified.
:param frameList: None for all frames, or a list of 0-based integers.
:param framesAcross: the number of frames across the final image. If
unspecified, this is the ceiling of sqrt(number of frames in frame
list).
:param kwargs: optional arguments. Some options are region, output,
encoding, jpegQuality, jpegSubsampling, tiffCompression, fill. See
tileIterator.
:returns: regionData, formatOrRegionMime: the image data and either the
mime type, if the format is TILE_FORMAT_IMAGE, or the format.
"""
kwargs = kwargs.copy()
kwargs.pop('tile_position', None)
kwargs.pop('frame', None)
numFrames = len(self.getMetadata().get('frames', [0]))
if frameList:
frameList = [f for f in frameList if f >= 0 and f < numFrames]
if not frameList:
frameList = list(range(numFrames))
if len(frameList) == 1:
return self.getRegion(format=format, frame=frameList[0], **kwargs)
if not framesAcross:
framesAcross = int(math.ceil(len(frameList) ** 0.5))
framesAcross = min(len(frameList), framesAcross)
framesHigh = int(math.ceil(len(frameList) / framesAcross))
if not isinstance(format, (tuple, set, list)):
format = (format, )
tiled = TILE_FORMAT_IMAGE in format and kwargs.get('encoding') == 'TILED'
iterInfo = self._tileIteratorInfo(frame=frameList[0], **kwargs)
if iterInfo is None:
image = PIL.Image.new('RGB', (0, 0))
return _encodeImage(image, format=format, **kwargs)
frameWidth = iterInfo['output']['width']
frameHeight = iterInfo['output']['height']
maxWidth = kwargs.get('output', {}).get('maxWidth')
maxHeight = kwargs.get('output', {}).get('maxHeight')
if kwargs.get('fill') and maxWidth and maxHeight:
frameWidth, frameHeight = maxWidth, maxHeight
outWidth = frameWidth * framesAcross
outHeight = frameHeight * framesHigh
tile = next(self._tileIterator(iterInfo))
image = None
for idx, frame in enumerate(frameList):
subimage, _ = self.getRegion(format=TILE_FORMAT_NUMPY, frame=frame, **kwargs)
offsetX = (idx % framesAcross) * frameWidth
offsetY = (idx // framesAcross) * frameHeight
self.logger.debug('Tiling frame %r', [idx, frame, offsetX, offsetY])
image = self._addRegionTileToImage(
image, subimage, offsetX, offsetY, outWidth, outHeight, tiled,
tile=tile, **kwargs)
if tiled:
return self._encodeTiledImage(image, outWidth, outHeight, iterInfo, **kwargs)
return _encodeImage(image, format=format, **kwargs)

def getRegionAtAnotherScale(self, sourceRegion, sourceScale=None,
targetScale=None, targetUnits=None, **kwargs):
"""
Expand Down
Loading

0 comments on commit fcd69ab

Please sign in to comment.