diff --git a/CHANGELOG.md b/CHANGELOG.md index c31c6628f..14e750411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## Version 1.11.1 + +### Improvements +- Add options to get frame quad info from python ([#783](../../pull/783)) + ## Version 1.11.0 ### Improvements diff --git a/girder/girder_large_image/models/image_item.py b/girder/girder_large_image/models/image_item.py index 538437bcf..078d312e2 100644 --- a/girder/girder_large_image/models/image_item.py +++ b/girder/girder_large_image/models/image_item.py @@ -318,6 +318,26 @@ def getThumbnail(self, item, checkAndCreate=False, width=None, height=None, **kw def _getAndCacheImageOrData( self, item, imageFunc, checkAndCreate, keydict, pickleCache=False, **kwargs): + """ + Get a file associated with an image that can be generated by a + function. + + :param item: the idem to process. + :param imageFunc: the function to call to generate a file. + :param checkAndCreate: False to return the data, creating and caching + it if needed. True to return True if the data is already in cache, + or to create the data, cache, and return it if not. 'nosave' to + return data from the cache if present, or generate the data but do + not return it if not in the cache. 'check' to just return True or + False to report if it is in the cache. + :param keydict: a dictionary of values to use for the cache key. + :param pickleCache: if True, the results of the function are pickled to + preserver them. If Fales, the results can be saved as a file + directly. + :params **kwargs: passed to the tile source and to the imageFunc. May + contain contentDisposition to determine how results are returned. + :returns: + """ if 'fill' in keydict and (keydict['fill']).lower() == 'none': del keydict['fill'] keydict = {k: v for k, v in keydict.items() if v is not None and not k.startswith('_')} @@ -339,6 +359,8 @@ def _getAndCacheImageOrData( data = File().open(existing).read() return pickle.loads(data), 'application/octet-stream' return File().download(existing, contentDisposition=contentDisposition) + if checkAndCreate == 'check': + return False tileSource = self._loadTileSource(item, **kwargs) result = getattr(tileSource, imageFunc)(**kwargs) if result is None: @@ -539,3 +561,28 @@ def getAssociatedImage(self, item, imageKey, checkAndCreate=False, *args, **kwar keydict = dict(kwargs, imageKey=imageKey) return self._getAndCacheImageOrData( item, 'getAssociatedImage', checkAndCreate, keydict, imageKey=imageKey, **kwargs) + + def _scheduleTileFrames(self, item, tileFramesList, user): + """ + Schedule generating tile frames in a local job. + + :param item: the item. + :param tileFramesList: a list of dictionary of parameters to pass to + the tileFrames method. + :param user: the user owning the job. + """ + job = Job().createLocalJob( + module='large_image_tasks.tasks', + function='cache_tile_frames_job', + kwargs={ + 'itemId': str(item['_id']), + 'tileFramesList': tileFramesList, + }, + title='Cache tileFrames', + type='large_image_cache_tile_frames', + user=user, + public=True, + asynchronous=True, + ) + Job().scheduleJob(job) + return job diff --git a/girder/girder_large_image/rest/tiles.py b/girder/girder_large_image/rest/tiles.py index 9fcde3fbd..a9390f75f 100644 --- a/girder/girder_large_image/rest/tiles.py +++ b/girder/girder_large_image/rest/tiles.py @@ -23,6 +23,7 @@ import cherrypy +import large_image from girder.api import access, filter_logging from girder.api.describe import Description, autoDescribeRoute, describeRoute from girder.api.rest import filtermodel, loadmodel, setRawResponse, setResponseHeader @@ -106,6 +107,8 @@ def __init__(self, apiRoot): 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', 'tile_frames', 'quad_info'), + self.tileFramesQuadInfo) 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) @@ -1086,9 +1089,37 @@ def getAssociatedImageMetadata(self, item, image, params): result['info'] = pilImage.info return result + _tileFramesParams = [ + ('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) + ] + @describeRoute( - Description('Get any region of a large image item, optionally scaling ' - 'it.') + Description('Composite thumbnails of multiple frames into a single image.') .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) @@ -1180,34 +1211,7 @@ def tileFrames(self, item, params): 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) - ]) + params = self._parseParams(params, True, self._tileFramesParams) _handleETag('tileFrames', item, params) if 'frameList' in params: params['frameList'] = [ @@ -1244,3 +1248,86 @@ def stream(): return stream setRawResponse() return regionData + + @describeRoute( + Description('Get parameters for using tile_frames as background sprite images.') + .param('itemId', 'The ID of the item.', paramType='path') + .param('format', 'Optional format parameters, such as "encoding=JPEG&' + 'jpegQuality=85&jpegSubsampling=1". If specified, these ' + 'replace the defaults.', required=False) + .param('query', 'Addition query parameters that would be passed to ' + 'tile endpoints, such as style.', required=False) + .param('frameBase', 'Starting frame number (default 0)', + required=False, dataType='int') + .param('frameStride', 'Only use every frameStride frame of the image ' + '(default 1)', required=False, dataType='int') + .param('frameGroup', 'Group frames when using multiple textures to ' + 'keep boundaries at a multiple of the group size number.', + required=False, dataType='int') + .param('frameGroupFactor', 'Ignore grouping if the resultant images ' + 'would be more than this factor smaller than without grouping ' + '(default 4)', required=False, dataType='int') + .param('frameGroupStride', 'Reorder frames based on the to stride.', + required=False, dataType='int') + .param('maxTextureSize', 'Maximum texture size in either dimension. ' + 'This should be the smaller of a desired value and of the ' + 'intended graphics environment texture buffer (default 16384).', + required=False, dataType='int') + .param('maxTextures', 'Maximum number of textures to use (default 1).', + required=False, dataType='int') + .param('maxTotalTexturePixels', 'Limit the total area of all combined ' + 'textures (default 2**30).', + required=False, dataType='int') + .param('alignment', 'Individual frame alignment within a texture. ' + 'Used to avoid jpeg artifacts from crossing frames (default ' + '16).', + required=False, dataType='int') + .param('maxFrameSize', 'If specified, frames will never be larger ' + 'than this, even if the texture size allows it (default None).', + required=False, dataType='int') + .param('cache', 'Report on or request caching the resultant frames. ' + 'Scheduling creates a local job.', + required=False, + enum=['none', 'report', 'schedule']) + .errorResponse('ID was invalid.') + .errorResponse('Read access was denied for the item.', 403) + ) + @access.public(cookie=True) + @loadmodel(model='item', map={'itemId': 'item'}, level=AccessType.READ) + def tileFramesQuadInfo(self, item, params): + metadata = self.imageItemModel.getMetadata(item) + options = self._parseParams(params, False, [ + ('format', str), + ('query', str), + ('frameBase', int), + ('frameStride', int), + ('frameGroup', int), + ('frameGroupFactor', int), + ('frameGroupStride', int), + ('maxTextureSize', int), + ('maxTextures', int), + ('maxTotalTexturePixels', int), + ('alignment', int), + ('maxFrameSize', int), + ]) + for key in {'format', 'query'}: + if key in options: + options[key] = dict(urllib.parse.parse_qsl(options[key])) + result = large_image.tilesource.utilities.getTileFramesQuadInfo(metadata, options) + if params.get('cache') in {'report', 'schedule'}: + needed = [] + result['cached'] = [] + for src in result['src']: + tfParams = self._parseParams(src, False, self._tileFramesParams) + if 'frameList' in tfParams: + tfParams['frameList'] = [ + int(f.strip()) for f in str(tfParams['frameList']).lstrip( + '[').rstrip(']').split(',')] + result['cached'].append(self.imageItemModel.tileFrames( + item, checkAndCreate='check', **tfParams)) + if not result['cached'][-1]: + needed.append(tfParams) + if params.get('cache') == 'schedule' and not all(result['cached']): + result['scheduledJob'] = str(self.imageItemModel._scheduleTileFrames( + item, needed, self.getCurrentUser())['_id']) + return result diff --git a/girder/girder_large_image/web_client/views/imageViewerWidget/geojs.js b/girder/girder_large_image/web_client/views/imageViewerWidget/geojs.js index 0884ee58b..58b82edbf 100644 --- a/girder/girder_large_image/web_client/views/imageViewerWidget/geojs.js +++ b/girder/girder_large_image/web_client/views/imageViewerWidget/geojs.js @@ -85,6 +85,8 @@ var GeojsImageViewerWidget = ImageViewerWidget.extend({ // maxTextures: 16, // maxTotalTexturePixels: 256 * 1024 * 1024, baseUrl: this._getTileUrl('{z}', '{x}', '{y}').split('/tiles/')[0] + '/tiles', + restRequest: restRequest, + restUrl: 'item/' + this.itemId + '/tiles', query: 'cache=true' }); this._layer.setFrameQuad(0); diff --git a/girder/girder_large_image/web_client/views/imageViewerWidget/setFrameQuad.js b/girder/girder_large_image/web_client/views/imageViewerWidget/setFrameQuad.js index 69e4bba66..10fdc4c2c 100644 --- a/girder/girder_large_image/web_client/views/imageViewerWidget/setFrameQuad.js +++ b/girder/girder_large_image/web_client/views/imageViewerWidget/setFrameQuad.js @@ -15,6 +15,9 @@ * minimally include ``baseUrl``. * @param {string} options.baseUrl The reference to the tile endpoint, e.g., * /api/v1/item//tiles. + * @param {string} options.restRequest A backbone-like ajax handler function. + * @param {string} options.restUrl A reference to the tile endpoint as used by + * the restRequest function, e.g., item//tiles. * @param {string} [options.format='encoding=JPEG&jpegQuality=85&jpegSubsampling=1'] * The compression and format for the texture. * @param {string} [options.query] Additional query options to add to the @@ -55,6 +58,8 @@ * crossOrigin policy for images. * @param {string} [options.progress] If specified, a function to call whenever * a texture image is loaded. + * @param {boolean} [options.redrawOnFirstLoad=true] If truthy, redraw the + * layer after the base quad is first loaded if a frame value has been set. */ function setFrameQuad(tileinfo, layer, options) { layer.setFrameQuad = function () { }; @@ -65,176 +70,76 @@ function setFrameQuad(tileinfo, layer, options) { try { maxTextureSize = layer.renderer()._maxTextureSize || layer.renderer().constructor._maxTextureSize; } catch (err) { } - const w = tileinfo.sizeX, - h = tileinfo.sizeY, - maxTotalPixels = options.maxTotalTexturePixels || 1073741824, - alignment = options.alignment || 16; - let numFrames = (tileinfo.frames || []).length || 1, - texSize = maxTextureSize || 8192, - textures = options.maxTextures || 1; - const frames = []; - for (let fds = 0; fds < (options.frameGroupStride || 1); fds += 1) { - for (let fidx = (options.frameBase || 0) + fds * (options.frameStride || 1); - fidx < numFrames; - fidx += (options.frameStride || 1) * (options.frameGroupStride || 1)) { - frames.push(fidx); - } - } - numFrames = frames.length; - if (numFrames === 0 || !Object.getOwnPropertyDescriptor(layer, 'baseQuad')) { - return; - } - texSize = Math.min(texSize, options.maxTextureSize || texSize); - while (texSize ** 2 > maxTotalPixels) { - texSize /= 2; - } - while (textures && texSize ** 2 * textures > maxTotalPixels) { - textures -= 1; - } - let fw, fh, fhorz, fvert, fperframe; - /* Iterate in case we can reduce the number of textures or the texture - * size */ - while (true) { - let f = Math.ceil(numFrames / textures); // frames per texture - if ((options.frameGroup || 1) > 1) { - const fg = Math.ceil(f / options.frameGroup) * options.frameGroup; - if (fg / f <= (options.frameGroupFactor || 4)) { - f = fg; - } - } - const texScale2 = texSize ** 2 / f / w / h; - // frames across the texture - fhorz = Math.ceil(texSize / (Math.ceil(w * texScale2 ** 0.5 / alignment) * alignment)); - fvert = Math.ceil(texSize / (Math.ceil(h * texScale2 ** 0.5 / alignment) * alignment)); - // tile sizes - fw = Math.floor(texSize / fhorz / alignment) * alignment; - fvert = Math.max(Math.ceil(f / Math.floor(texSize / fw)), fvert); - fh = Math.floor(texSize / fvert / alignment) * alignment; - if (options.maxFrameSize) { - const maxFrameSize = Math.floor(options.maxFrameSize / alignment) * alignment; - fw = Math.min(fw, maxFrameSize); - fh = Math.min(fh, maxFrameSize); - } - if (fw > w) { - fw = Math.ceil(w / alignment) * alignment; - } - if (fh > h) { - fh = Math.ceil(h / alignment) * alignment; - } - // shrink one dimension to account for aspect ratio - fw = Math.min(Math.ceil(fh * w / h / alignment) * alignment, fw); - fh = Math.min(Math.ceil(fw * h / w / alignment) * alignment, fh); - // recompute frames across the texture - fhorz = Math.floor(texSize / fw); - fvert = Math.min(Math.floor(texSize / fh), Math.ceil(numFrames / fhorz)); - fperframe = fhorz * fvert; - if (textures > 1 && (options.frameGroup || 1) > 1) { - fperframe = Math.floor(fperframe / options.frameGroup) * options.frameGroup; - if (textures * fperframe < numFrames && fhorz * fvert * textures >= numFrames) { - fperframe = fhorz * fvert; - } - } - // check if we are not using all textures or are using less than a - // quarter of one texture. If not, stop, if so, reduce and recalculate - if (textures > 1 && numFrames <= fperframe * (textures - 1)) { - textures -= 1; - continue; - } - if (fhorz >= 2 && Math.ceil(f / Math.floor(fhorz / 2)) * fh <= texSize / 2) { - texSize /= 2; - continue; - } - break; - } - // used area of each tile - const usedw = Math.floor(w / Math.max(w / fw, h / fh)), - usedh = Math.floor(h / Math.max(w / fw, h / fh)); - // get the set of texture images + options = Object.assign({}, {maxTextureSize: Math.min(16384, maxTextureSize)}, options); const status = { tileinfo: tileinfo, options: options, images: [], src: [], quads: [], - frames: frames, + frames: ['placeholder'], framesToIdx: {}, loadedCount: 0 }; - if (tileinfo.tileWidth && tileinfo.tileHeight) { - // report that tiles below this level are not needed - status.minLevel = Math.ceil(Math.log(Math.min(usedw / tileinfo.tileWidth, usedh / tileinfo.tileHeight)) / Math.log(2)); - } - frames.forEach((frame, idx) => { status.framesToIdx[frame] = idx; }); - for (let idx = 0; idx < textures; idx += 1) { - const img = new Image(); - if (options.baseUrl.indexOf(':') >= 0 && options.baseUrl.indexOf('/') === options.baseUrl.indexOf(':') + 1) { - img.crossOrigin = options.crossOrigin || 'anonymous'; - } - const frameList = frames.slice(idx * fperframe, (idx + 1) * fperframe); - let src = `${options.baseUrl}/tile_frames?framesAcross=${fhorz}&width=${fw}&height=${fh}&fill=corner:black&exact=false`; - if (frameList.length !== (tileinfo.frames || []).length) { - src += `&frameList=${frameList.join(',')}`; - } - src += '&' + (options.format || 'encoding=JPEG&jpegQuality=85&jpegSubsampling=1').replace(/(^&|^\?|\?$|&$)/g, ''); - if (options.query) { - src += '&' + options.query.replace(/(^&|^\?|\?$|&$)/g, ''); - } - status.src.push(src); - if (idx === textures - 1) { - img.onload = function () { - status.loadedCount += 1; - status.loaded = true; - if (layer._options && layer._options.minLevel !== undefined && (options.adjustMinLevel === undefined || options.adjustMinLevel) && status.minLevel && status.minLevel > layer._options.minLevel) { - layer._options.minLevel = Math.min(layer._options.maxLevel, status.minLevel); - } - if (options.progress) { - try { - options.progress(status); - } catch (err) {} - } - if (status.frame !== undefined) { - layer.baseQuad = Object.assign({}, status.quads[status.framesToIdx[status.frame]]); + let qiOptions = Object.assign({}, options); + ['restRequest', 'restUrl', 'baseUrl', 'crossOrigin', 'progress', 'redrawOnFirstLoad'].forEach((k) => delete qiOptions[k]); + options.restRequest({ + type: 'GET', + url: `${options.restUrl}/tile_frames/quad_info`, + data: qiOptions + }).done((data) => { + status.quads = data.quads; + status.frames = data.frames; + status.framesToIdx = data.framesToIdx; + for (let idx = 0; idx < data.src.length; idx += 1) { + const img = new Image(); + for (let qidx = 0; qidx < data.quads.length; qidx += 1) { + if (data.quadsToIdx[qidx] === idx) { + status.quads[qidx].image = img; } - }; - } else { - ((idx) => { + } + if (options.baseUrl.indexOf(':') >= 0 && options.baseUrl.indexOf('/') === options.baseUrl.indexOf(':') + 1) { + img.crossOrigin = options.crossOrigin || 'anonymous'; + } + let params = Object.keys(data.src[idx]).map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(data.src[idx][k])).join('&'); + let src = `${options.baseUrl}/tile_frames?` + params; + status.src.push(src); + if (idx === data.src.length - 1) { img.onload = function () { status.loadedCount += 1; - status.images[idx + 1].src = status.src[idx + 1]; + status.loaded = true; + if (layer._options && layer._options.minLevel !== undefined && (options.adjustMinLevel === undefined || options.adjustMinLevel) && status.minLevel && status.minLevel > layer._options.minLevel) { + layer._options.minLevel = Math.min(layer._options.maxLevel, status.minLevel); + } if (options.progress) { try { options.progress(status); } catch (err) {} } + if (status.frame !== undefined) { + layer.baseQuad = Object.assign({}, status.quads[status.framesToIdx[status.frame]]); + if (options.redrawOnFirstLoad || options.redrawOnFirstLoad === undefined) { + layer.draw(); + } + } }; - })(idx); + } else { + ((idx) => { + img.onload = function () { + status.loadedCount += 1; + status.images[idx + 1].src = status.src[idx + 1]; + if (options.progress) { + try { + options.progress(status); + } catch (err) {} + } + }; + })(idx); + } + status.images.push(img); } - status.images.push(img); - // the last image can have fewer frames than the other images - const f = frameList.length; - const ivert = Math.ceil(f / fhorz), - ihorz = Math.min(f, fhorz); - frameList.forEach((frame, fidx) => { - const quad = { - // z = -1 to place under other tile layers - ul: {x: 0, y: 0, z: -1}, - // y coordinate is inverted - lr: {x: w, y: -h, z: -1}, - crop: { - x: w, - y: h, - left: (fidx % ihorz) * fw, - top: (ivert - Math.floor(fidx / ihorz)) * fh - usedh, - right: (fidx % ihorz) * fw + usedw, - bottom: (ivert - Math.floor(fidx / ihorz)) * fh - }, - image: img - }; - status.quads.push(quad); - }); - } - status.images[0].src = status.src[0]; - + status.images[0].src = status.src[0]; + }); layer.setFrameQuad = function (frame) { if (status.framesToIdx[frame] !== undefined && status.loaded) { layer.baseQuad = Object.assign({}, status.quads[status.framesToIdx[frame]]); diff --git a/girder/test_girder/test_tiles_rest.py b/girder/test_girder/test_tiles_rest.py index 52e630de5..5e8b7b8c7 100644 --- a/girder/test_girder/test_tiles_rest.py +++ b/girder/test_girder/test_tiles_rest.py @@ -1374,3 +1374,41 @@ def testTileFrames(server, admin, fsAssetstore): assert utilities.respStatus(resp) == 200 image = utilities.getBody(resp, text=False) assert image[:len(utilities.BigTIFFHeader)] == utilities.BigTIFFHeader + + +@pytest.mark.usefixtures('unbindLargeImage') +@pytest.mark.plugin('large_image') +def testTileFramesQuadInfo(server, admin, fsAssetstore): + file = utilities.uploadExternalFile( + 'sample.ome.tif', admin, fsAssetstore) + itemId = str(file['itemId']) + params = {'maxTextureSize': 2048} + resp = server.request(path='/item/%s/tiles/tile_frames/quad_info' % itemId, + user=admin, params=params) + assert utilities.respStatus(resp) == 200 + assert 'frames' in resp.json + + params = {'cache': 'report', 'maxTextureSize': 2048} + resp = server.request(path='/item/%s/tiles/tile_frames/quad_info' % itemId, + user=admin, params=params) + assert utilities.respStatus(resp) == 200 + assert 'cached' in resp.json + assert resp.json['cached'][0] is False + + params = {'cache': 'schedule', 'maxTextureSize': 2048} + resp = server.request(path='/item/%s/tiles/tile_frames/quad_info' % itemId, + user=admin, params=params) + assert utilities.respStatus(resp) == 200 + assert 'scheduledJob' in resp.json + + job = Job().load(resp.json['scheduledJob'], force=True) + while job['status'] not in (JobStatus.SUCCESS, JobStatus.ERROR, JobStatus.CANCELED): + time.sleep(0.1) + job = Job().load(job['_id'], force=True) + + params = {'cache': 'report', 'maxTextureSize': 2048} + resp = server.request(path='/item/%s/tiles/tile_frames/quad_info' % itemId, + user=admin, params=params) + assert utilities.respStatus(resp) == 200 + assert 'cached' in resp.json + assert resp.json['cached'][0] is True diff --git a/large_image/tilesource/utilities.py b/large_image/tilesource/utilities.py index 71bb11216..ead34e8d3 100644 --- a/large_image/tilesource/utilities.py +++ b/large_image/tilesource/utilities.py @@ -596,3 +596,201 @@ def _makeSameChannelDepth(arr1, arr2): newarr[:, :, :arr.shape[2]] = arr arrays[key] = newarr return arrays['arr1'], arrays['arr2'] + + +def _computeFramesPerTexture(opts, numFrames, sizeX, sizeY): + """ + Compute the number of frames for each tile_frames texture. + + :param opts: the options dictionary from getTileFramesQuadInfo. + :param numFrames: the number of frames that need to be included. + :param sizeX: the size of one frame of the image. + :param sizeY: the size of one frame of the image. + :returns: a tuple consisting of: + fw: the width of an individual frame in the texture. + fh: the height of an individual frame in the texture. + fhorz: the number of frames across the texture/ + fperframe: the number of frames per texture. The last texture may have + fewer frames. + textures: the number of textures to be used. This many calls will need + to be made to tileFrames. + """ + # defining fw, fh, fhorz, fvert, fperframe + alignment = opts['alignment'] or 16 + texSize = opts['maxTextureSize'] + textures = opts['maxTextures'] or 1 + while texSize ** 2 > opts['maxTotalTexturePixels']: + texSize //= 2 + while textures > 1 and texSize ** 2 * textures > opts['maxTotalTexturePixels']: + textures -= 1 + # Iterate in case we can reduce the number of textures or the texture size + while True: + f = int(math.ceil(numFrames / textures)) # frames per texture + if opts['frameGroup'] > 1: + fg = int(math.ceil(f / opts['frameGroup'])) * opts['frameGroup'] + if fg / f <= opts['frameGroupFactor']: + f = fg + texScale2 = texSize ** 2 / f / sizeX / sizeY + # frames across the texture + fhorz = int(math.ceil(texSize / (math.ceil( + sizeX * texScale2 ** 0.5 / alignment) * alignment))) + fvert = int(math.ceil(texSize / (math.ceil( + sizeY * texScale2 ** 0.5 / alignment) * alignment))) + # tile sizes + fw = int(math.floor(texSize / fhorz / alignment)) * alignment + fvert = int(max(math.ceil(f / (texSize // fw)), fvert)) + fh = int(math.floor(texSize / fvert / alignment) * alignment) + if opts['maxFrameSize']: + maxFrameSize = opts['maxFrameSize'] // alignment * alignment + fw = min(fw, maxFrameSize) + fh = min(fh, maxFrameSize) + if fw > sizeX: + fw = int(math.ceil(sizeX / alignment) * alignment) + if fh > sizeY: + fh = int(math.ceil(sizeY / alignment) * alignment) + # shrink one dimension to account for aspect ratio + fw = int(min(math.ceil(fh * sizeX / sizeY / alignment) * alignment, fw)) + fh = int(min(math.ceil(fw * sizeY / sizeX / alignment) * alignment, fh)) + # recompute frames across the texture + fhorz = texSize // fw + fvert = int(min(texSize // fh, math.ceil(numFrames / fhorz))) + fperframe = fhorz * fvert + if textures > 1 and opts['frameGroup'] > 1: + fperframe = int(fperframe // opts['frameGroup'] * opts['frameGroup']) + if textures * fperframe < numFrames and fhorz * fvert * textures >= numFrames: + fperframe = fhorz * fvert + # check if we are not using all textures or are using less than a + # quarter of one texture. If not, stop, if so, reduce and recalculate + if textures > 1 and numFrames <= fperframe * (textures - 1): + textures -= 1 + continue + if fhorz >= 2 and math.ceil(f / (fhorz // 2)) * fh <= texSize / 2: + texSize //= 2 + continue + return fw, fh, fhorz, fperframe, textures + + +def getTileFramesQuadInfo(metadata, options=None): + """ + Compute what tile_frames need to be requested for a particular condition. + + :param metadata: the tile source metadata. Needs to contain sizeX, sizeY, + tileWidth, tileHeight, and a list of frames. + :param options: dictionary of + format: The compression and format for the texture. Defaults to + {'encoding': 'JPEG', 'jpegQuality': 85, 'jpegSubsampling': 1}. + query: Additional query options to add to the tile source, such as + style. + frameBase: (default 0) Starting frame number used. + frameStride: (default 1) Only use every ``frameStride`` frame of the + image. + frameGroup: (default 1) If above 1 and multiple textures are used, each + texture will have an even multiple of the group size number of + frames. This helps control where texture loading transitions + occur. + frameGroupFactor: (default 4) If ``frameGroup`` would reduce the size + of the tile images beyond this factor, don't use it. + frameGroupStride: (default 1) If ``frameGroup`` is above 1 and multiple + textures are used, then the frames are reordered based on this + stride value. + maxTextureSize: Limit the maximum texture size to a square of this + size. + maxTextures: (default 1) If more than one, allow multiple textures to + increase the size of the individual frames. The number of textures + will be capped by ``maxTotalTexturePixels`` as well as this number. + maxTotalTexturePixels: (default 1073741824) Limit the maximum texture + size and maximum number of textures so that the combined set does + not exceed this number of pixels. + alignment: (default 16) Individual frames are buffered to an alignment + of this maxy pixels. If JPEG compression is used, this should + be 8 for monochrome images or jpegs without subsampling, or 16 for + jpegs with moderate subsampling to avoid compression artifacts from + leaking between frames. + maxFrameSize: If set, limit the maximum width and height of an + individual frame to this value. + :returns: a dictionary of values to use for making calls to tile_frames. + """ + defaultOptions = { + 'format': { + 'encoding': 'JPEG', + 'jpegQuality': 85, + 'jpegSubsampling': 1, + }, + 'query': {}, + 'frameBase': 0, + 'frameStride': 1, + 'frameGroup': 1, + 'frameGroupFactor': 4, + 'frameGroupStride': 1, + 'maxTextureSize': 16384, + 'maxTextures': 1, + 'maxTotalTexturePixels': 1024 * 1024 * 1024, + 'alignment': 16, + 'maxFrameSize': None, + } + opts = defaultOptions.copy() + opts.update(options or {}) + sizeX, sizeY = metadata['sizeX'], metadata['sizeY'] + numFrames = len(metadata.get('frames', [])) or 1 + frames = [] + for fds in range(opts['frameGroupStride']): + frames.extend(list(range( + opts['frameBase'] + fds * opts['frameStride'], numFrames, + opts['frameStride'] * opts['frameGroupStride']))) + numFrames = len(frames) + # check if numFrames zero and return early? + fw, fh, fhorz, fperframe, textures = _computeFramesPerTexture( + opts, numFrames, sizeX, sizeY) + # used area of each tile + usedw = int(math.floor(sizeX / max(sizeX / fw, sizeY / fh))) + usedh = int(math.floor(sizeY / max(sizeX / fw, sizeY / fh))) + # get the set of texture images + status = { + 'metadata': metadata, + 'options': opts, + 'src': [], + 'quads': [], + 'quadsToIdx': [], + 'frames': frames, + 'framesToIdx': {} + } + if metadata.get('tileWidth') and metadata.get('tileHeight'): + # report that tiles below this level are not needed + status['minLevel'] = int(math.ceil(math.log(min( + usedw / metadata['tileWidth'], usedh / metadata['tileHeight'])) / math.log(2))) + status['framesToIdx'] = {frame: idx for idx, frame in enumerate(frames)} + for idx in range(textures): + frameList = frames[idx * fperframe: (idx + 1) * fperframe] + tfparams = { + 'framesAcross': fhorz, + 'width': fw, + 'height': fh, + 'fill': 'corner:black', + 'exact': False, + } + if len(frameList) != len(metadata.get('frames', [])): + tfparams['frameList'] = frameList + tfparams.update(opts['format']) + tfparams.update(opts['query']) + status['src'].append(tfparams) + f = len(frameList) + ivert = int(math.ceil(f / fhorz)) + ihorz = int(min(f, fhorz)) + for fidx in range(f): + quad = { + # z = -1 to place under other tile layers + 'ul': {'x': 0, 'y': 0, 'z': -1}, + # y coordinate is inverted + 'lr': {'x': sizeX, 'y': -sizeY, 'z': -1}, + 'crop': { + 'x': sizeX, + 'y': sizeY, + 'left': (fidx % ihorz) * fw, + 'top': (ivert - (fidx // ihorz)) * fh - usedh, + 'right': (fidx % ihorz) * fw + usedw, + 'bottom': (ivert - (fidx // ihorz)) * fh, + }, + } + status['quads'].append(quad) + status['quadsToIdx'].append(idx) + return status diff --git a/test/test_source_base.py b/test/test_source_base.py index 17bf4d018..4ac9b4607 100644 --- a/test/test_source_base.py +++ b/test/test_source_base.py @@ -285,3 +285,174 @@ def testTileOverlapWithRegionOffset(): tile_overlap=dict(x=400, y=400)) firstTile = next(tileIter) assert firstTile['tile_overlap']['right'] == 200 + + +@pytest.mark.parametrize('options,lensrc,lenquads,frame10,src0,srclast,quads10', [ + ({}, 1, 250, 10, { + 'encoding': 'JPEG', + 'exact': False, + 'fill': 'corner:black', + 'framesAcross': 14, + 'height': 880, + 'jpegQuality': 85, + 'jpegSubsampling': 1, + 'width': 1168, + }, None, { + 'bottom': 15840, + 'left': 11680, + 'right': 12848, + 'top': 14964, + }), + + ({'format': {'encoding': 'PNG'}}, 1, 250, 10, { + 'encoding': 'PNG', + 'jpeqQuality': None, + }, None, None), + + ({'query': {'style': 'abc=def'}}, 1, 250, 10, { + 'style': 'abc=def', + }, None, None), + + ({'maxTextureSize': 4096}, 1, 250, 10, { + 'framesAcross': 14, + 'height': 224, + 'width': 288, + }, None, { + 'left': 2880, + 'top': 3816, + }), + + ({'maxTextures': 8}, 4, 250, 10, { + 'framesAcross': 7, + 'height': 1632, + 'width': 2176, + }, None, { + 'left': 6528, + 'top': 13056, + }), + + ({'maxTextures': 8, 'maxTextureSize': 4096}, 8, 250, 10, { + 'framesAcross': 5, + 'height': 576, + 'width': 768, + }, None, { + 'left': 0, + 'top': 2304, + }), + + ({'maxTextures': 8, 'maxTotalTexturePixels': 8 * 1024 ** 3}, 8, 250, 10, { + 'framesAcross': 5, + 'height': 2336, + 'width': 3120, + }, None, { + 'left': 0, + 'top': 9344, + }), + + ({'alignment': 32}, 1, 250, 10, { + 'framesAcross': 14, + 'height': 864, + 'width': 1152, + }, None, { + 'left': 11520, + 'top': 14688, + }), + + ({'frameBase': 100}, 1, 150, 110, { + 'framesAcross': 11, + 'height': 1088, + 'width': 1456, + }, None, { + 'left': 14560, + 'top': 14144, + }), + + ({'frameStride': 10}, 1, 25, 100, { + 'framesAcross': 5, + 'height': 2448, + 'width': 3264, + }, None, { + 'left': 0, + 'top': 4896, + }), + + ({'maxTextures': 8, 'maxTextureSize': 4096, 'frameGroup': 50}, 5, 250, 10, { + 'framesAcross': 7, + 'height': 432, + 'width': 576, + }, None, { + 'left': 1728, + 'top': 2592, + }), + + ({ + 'maxTextures': 8, + 'maxTextureSize': 4096, + 'frameGroup': 250, + 'frameGroupFactor': 4 + }, 8, 250, 10, { + 'framesAcross': 5, + 'height': 576, + 'width': 768, + }, None, { + 'left': 0, + 'top': 2304, + }), + + ({ + 'maxTextures': 8, + 'maxTextureSize': 4096, + 'frameGroup': 50, + 'frameGroupStride': 5 + }, 5, 250, 50, { + 'framesAcross': 7, + 'height': 432, + 'width': 576, + }, None, None), + + ({'maxFrameSize': 250}, 1, 250, 10, { + 'framesAcross': 17, + 'height': 192, + 'width': 240, + }, None, { + 'left': 2400, + 'top': 2700, + }), + + ({'maxTotalTexturePixels': 16 * 1024 ** 2}, 1, 250, 10, { + 'framesAcross': 14, + 'height': 224, + 'width': 288, + }, None, { + 'left': 2880, + 'top': 3816, + }), +]) +def testGetTileFramesQuadInfo(options, lensrc, lenquads, frame10, src0, srclast, quads10): + metadata = { + 'frames': [0] * 250, + 'levels': 10, + 'sizeX': 100000, + 'sizeY': 75000, + 'tileHeight': 256, + 'tileWidth': 256 + } + results = large_image.tilesource.utilities.getTileFramesQuadInfo(metadata, options) + import pprint + open('/tmp/junk.txt', 'a').write(pprint.pformat(results) + '\n\n') + assert len(results['src']) == lensrc + assert len(results['quads']) == lenquads + if len(results['frames']) > 10: + assert results['frames'][10] == frame10 + for key, value in src0.items(): + if value is not None: + assert results['src'][0][key] == value + else: + assert key not in results['src'][0] + if srclast is not None: + for key, value in srclast.items(): + assert results['src'][-1][key] == value + if len(results['quads']) > 10 and quads10 is not None: + crop10 = results['quads'][10]['crop'] + for key, value in quads10.items(): + assert crop10[key] == value diff --git a/utilities/tasks/large_image_tasks/tasks.py b/utilities/tasks/large_image_tasks/tasks.py index 1efd00e37..d87f6734c 100644 --- a/utilities/tasks/large_image_tasks/tasks.py +++ b/utilities/tasks/large_image_tasks/tasks.py @@ -146,3 +146,29 @@ def convert_image_job(job): job, log='Finished large image conversion\n', status=status) finally: logger.removeHandler(handler) + + +def cache_tile_frames_job(job): + from girder_jobs.constants import JobStatus + from girder_jobs.models.job import Job + from girder_large_image.models.image_item import ImageItem + + from girder import logger + + kwargs = job['kwargs'] + item = ImageItem().load(kwargs.pop('itemId'), force=True) + job = Job().updateJob( + job, log='Started caching tile frames\n', + status=JobStatus.RUNNING) + try: + for entry in kwargs.get('tileFramesList'): + job = Job().load(job['_id'], force=True) + if job['status'] == JobStatus.CANCELED: + return + job = Job().updateJob(job, log='Caching %r\n' % entry) + ImageItem().tileFrames(item, checkAndCreate=True, **entry) + job = Job().updateJob(job, log='Finished caching tile frames\n', status=JobStatus.SUCCESS) + except Exception as exc: + logger.exception('Failed caching tile frames') + job = Job().updateJob( + job, log='Failed caching tile frames (%s)\n' % exc, status=JobStatus.ERROR)