From 9cc4a1417cf5a86140aa7d375b5f1a72aa955750 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 17 Jan 2025 11:19:26 -0500 Subject: [PATCH] Add utility functions for converting between frame and axes --- CHANGELOG.md | 4 +++ docs/getting_started.rst | 3 +++ large_image/exceptions.py | 6 ++++- large_image/tilesource/base.py | 46 ++++++++++++++++++++++++++++++++++ test/test_source_multi.py | 26 +++++++++++++++++++ 5 files changed, 84 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eecd6f558..f70309489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 1.30.7 +### Features + +- Add utility functions for converting between frame and axes ([#1777](../../pull/1777)) + ### Improvements - Better report if rasterized vector files are geospatial ([#1769](../../pull/1769)) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index c5d31da59..b891a752d 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -296,6 +296,9 @@ Many image formats (such as TIFF) can contain multiple images within a single fi By default, the ``getTile``, ``getRegion``, and ``tileIterator`` methods will return all of the bands of a single frame. The specific bands returned can be modified using the ``style`` parameter. The specific frame, including any channel or other axes, is specified with the ``frame`` parameter. +Since if can be useful to ask for a specific frame based on the axes values there are ``frameFromAxes`` and ``axesFromFrame`` utility functions. + + Styles - Changing colors, scales, and other properties ------------------------------------------------------ diff --git a/large_image/exceptions.py b/large_image/exceptions.py index bf097c5e7..5a43e2943 100644 --- a/large_image/exceptions.py +++ b/large_image/exceptions.py @@ -13,7 +13,11 @@ class TileSourceAssetstoreError(TileSourceError): pass -class TileSourceXYZRangeError(TileSourceError): +class TileSourceRangeError(TileSourceError): + pass + + +class TileSourceXYZRangeError(TileSourceRangeError): pass diff --git a/large_image/tilesource/base.py b/large_image/tilesource/base.py index 9d0f7fba4..96c6d9b3f 100644 --- a/large_image/tilesource/base.py +++ b/large_image/tilesource/base.py @@ -2540,6 +2540,52 @@ def frames(self) -> int: self._frameCount = len(self.getMetadata().get('frames', [])) or 1 return self._frameCount + def frameToAxes(self, frame: int) -> Dict[str, int]: + """ + Given a frame number, return a dictionary of axes values. If unknown, + this is just 'frame': frame. + + :param frame: a frame number. + :returns: a dictionary of axes that specify the frame. + """ + if frame >= self.frames: + msg = 'frame is outside of range' + raise exceptions.TileSourceRangeError(msg) + meta = self.metadata + if self.frames == 1 or 'IndexStride' not in meta: + return {'frame': frame} + axes = { + key[5:].lower(): (frame // stride) % meta['IndexRange'][key] + for key, stride in meta['IndexStride'].items()} + return axes + + def axesToFrame(self, **kwargs: int) -> int: + """ + Given values on some or all of the axes, return the corresponding frame + number. Any unspecified axis is 0. If one of the specified axes is + 'frame', this is just returned and the other values are ignored. + + :param kwargs: axes with position values. + :returns: a frame number. + """ + meta = self.metadata + frame = 0 + for key, value in kwargs.items(): + if key.lower() == 'frame': + if value < 0 or value >= self.frames: + msg = f'{value} is out of range for frame' + raise exceptions.TileSourceRangeError(msg) + return value + ikey = 'Index' + key.upper() + if ikey not in meta.get('IndexStride', {}): + msg = f'{key} is not a known axis' + raise exceptions.TileSourceRangeError(msg) + if value < 0 or value >= meta['IndexRange'][ikey]: + msg = f'{value} is out of range for axis {key}' + raise exceptions.TileSourceRangeError(msg) + frame += value * meta['IndexStride'][ikey] + return frame + class FileTileSource(TileSource): diff --git a/test/test_source_multi.py b/test/test_source_multi.py index 8f856b11c..345e5c16c 100644 --- a/test/test_source_multi.py +++ b/test/test_source_multi.py @@ -342,3 +342,29 @@ def testTilesWithSampleScaling(): assert tileMetadata['sizeX'] == 2000 assert tileMetadata['sizeY'] == 1250 utilities.checkTilesZXY(source, tileMetadata) + + +def testAxesToFrameAndFrameToAxes(): + asAxesSource1 = {'sources': [{ + 'sourceName': 'test', 'path': '__none__', 'params': { + 'sizeX': 1000, 'sizeY': 1000, 'frames': 60}, + 'framesAsAxes': {'c': 1, 'z': 5}}]} + source = large_image_source_multi.open(json.dumps(asAxesSource1)) + assert source.frameToAxes(7) == {'c': 2, 'z': 1} + with pytest.raises(large_image.exceptions.TileSourceRangeError): + source.frameToAxes(60) + assert source.axesToFrame(c=2, z=1) == 7 + assert source.axesToFrame(C=2, Z=1) == 7 + assert source.axesToFrame(frame=7) == 7 + assert source.axesToFrame(z=1) == 5 + with pytest.raises(large_image.exceptions.TileSourceRangeError): + source.axesToFrame(c=5, z=1) + with pytest.raises(large_image.exceptions.TileSourceRangeError): + source.axesToFrame(frame=-1) + with pytest.raises(large_image.exceptions.TileSourceRangeError): + source.axesToFrame(x=5) + asAxesSource2 = {'sources': [{ + 'sourceName': 'test', 'path': '__none__', 'params': { + 'sizeX': 1000, 'sizeY': 1000, 'frames': 60}}]} + source = large_image_source_multi.open(json.dumps(asAxesSource2)) + assert source.frameToAxes(0) == {'frame': 0}