From 0cc52276b73648676fe0772fd3aff3b2a2875296 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Mon, 20 Mar 2023 12:48:41 -0400 Subject: [PATCH] Support multi-frame PIL sources. Read some additional formats with the PIL source if extra modules are installed. --- CHANGELOG.md | 1 + requirements-dev.txt | 2 +- requirements-test-core.txt | 2 +- requirements-test.txt | 2 +- .../pil/large_image_source_pil/__init__.py | 97 +++++++++++++++++-- sources/pil/setup.py | 1 + test/test_source_base.py | 8 +- 7 files changed, 97 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d639c24..e91beef9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Speed up validating annotation colors ([#1080](../../pull/1080)) - Support more complex bands from the test source ([#1082](../../pull/1082)) - Improve error thrown for invalid schema with multi source ([#1083](../../pull/1083)) +- Support multi-frame PIL sources ([#1088](../../pull/1088)) ### Bug Fixes - The cache could reuse a class inappropriately ([#1070](../../pull/1070)) diff --git a/requirements-dev.txt b/requirements-dev.txt index 970cc88b6..bd581790c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,7 +11,7 @@ girder-jobs>=3.0.3 -e sources/nd2 -e sources/openjpeg -e sources/openslide --e sources/pil +-e sources/pil[all] -e sources/test -e sources/tiff -e sources/tifffile diff --git a/requirements-test-core.txt b/requirements-test-core.txt index ce2c3dce5..7570d2d6a 100644 --- a/requirements-test-core.txt +++ b/requirements-test-core.txt @@ -8,7 +8,7 @@ sources/multi sources/nd2 ; python_version >= '3.7' sources/openjpeg sources/openslide -sources/pil +sources/pil[all] sources/test sources/tiff sources/tifffile ; python_version >= '3.7' diff --git a/requirements-test.txt b/requirements-test.txt index b7b713a2c..d7f3e03ab 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -11,7 +11,7 @@ sources/multi sources/nd2 ; python_version >= '3.7' sources/openjpeg sources/openslide -sources/pil +sources/pil[all] sources/test sources/tiff sources/tifffile ; python_version >= '3.7' diff --git a/sources/pil/large_image_source_pil/__init__.py b/sources/pil/large_image_source_pil/__init__.py index bb8048861..6dc3ac6f1 100644 --- a/sources/pil/large_image_source_pil/__init__.py +++ b/sources/pil/large_image_source_pil/__init__.py @@ -17,16 +17,27 @@ import json import math import os +import threading import numpy import PIL.Image +import large_image from large_image import config from large_image.cache_util import LruCacheMetaclass, methodcache, strhash from large_image.constants import TILE_FORMAT_PIL, SourcePriority from large_image.exceptions import TileSourceError, TileSourceFileNotFoundError from large_image.tilesource import FileTileSource +# Optionally extend PIL with some additional formats +try: + from pillow_heif import register_heif_opener + register_heif_opener() + from pillow_heif import register_avif_opener + register_avif_opener() +except Exception: + pass + try: from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _importlib_version @@ -111,12 +122,15 @@ def __init__(self, path, maxSize=None, **kwargs): # such misses most of the data. self._ignoreSourceNames('pil', largeImagePath) - try: - self._pilImage = PIL.Image.open(largeImagePath) - except OSError: - if not os.path.isfile(largeImagePath): - raise TileSourceFileNotFoundError(largeImagePath) from None - raise TileSourceError('File cannot be opened via PIL.') + self._pilImage = None + self._fromRawpy(largeImagePath) + if self._pilImage is None: + try: + self._pilImage = PIL.Image.open(largeImagePath) + except OSError: + if not os.path.isfile(largeImagePath): + raise TileSourceFileNotFoundError(largeImagePath) from None + raise TileSourceError('File cannot be opened via PIL.') minwh = min(self._pilImage.width, self._pilImage.height) maxwh = max(self._pilImage.width, self._pilImage.height) # Throw an exception if too small or big before processing further @@ -125,6 +139,7 @@ def __init__(self, path, maxSize=None, **kwargs): maxWidth, maxHeight = getMaxSize(maxSize, self.defaultMaxSize()) if maxwh > max(maxWidth, maxHeight): raise TileSourceError('PIL tile size is too large.') + self._checkForFrames() if self._pilImage.info.get('icc_profile', None): self._iccprofiles = [self._pilImage.info.get('icc_profile')] # If the rotation flag exists, loading the image may change the width @@ -136,11 +151,13 @@ def __init__(self, path, maxSize=None, **kwargs): # maximum of 1, 2^8-1, 2^16-1, 2^24-1, or 2^32-1, and scales it to # [0, 255] pilImageMode = self._pilImage.mode.split(';')[0] + self._factor = None if pilImageMode in ('I', 'F'): imgdata = numpy.asarray(self._pilImage) maxval = 256 ** math.ceil(math.log(numpy.max(imgdata) + 1, 256)) - 1 + self._factor = 255.0 / maxval self._pilImage = PIL.Image.fromarray(numpy.uint8(numpy.multiply( - imgdata, 255.0 / maxval))) + imgdata, self._factor))) self.sizeX = self._pilImage.width self.sizeY = self._pilImage.height # We have just one tile which is the entire image. @@ -151,6 +168,40 @@ def __init__(self, path, maxSize=None, **kwargs): if self.tileWidth > maxWidth or self.tileHeight > maxHeight: raise TileSourceError('PIL tile size is too large.') + def _checkForFrames(self): + self._frames = None + self._frameCount = 1 + if hasattr(self._pilImage, 'seek'): + baseSize, baseMode = self._pilImage.size, self._pilImage.mode + self._frames = [ + idx for idx, frame in enumerate(PIL.ImageSequence.Iterator(self._pilImage)) + if frame.size == baseSize and frame.mode == baseMode] + self._pilImage.seek(0) + self._frameImage = self._pilImage + self._frameCount = len(self._frames) + self._tileLock = threading.RLock() + + def _fromRawpy(self, largeImagePath): + """ + Try to use rawpy to read an image. + """ + # if rawpy is present, try reading via that library first + try: + import rawpy + + rgb = rawpy.imread(largeImagePath).postprocess() + rgb = large_image.tilesource.utilities._imageToNumpy(rgb) + if rgb.shape[2] == 2: + rgb = rgb[:, :, :1] + elif rgb.shape[2] > 3: + rgb = rgb[:, :, :3] + self._pilImage = PIL.Image.fromarray( + rgb.astype(numpy.uint8) if rgb.dtype != numpy.uint16 else rgb, + ('RGB' if rgb.dtype != numpy.uint16 else 'RGB;16') if rgb.shape[2] == 3 else + ('L' if rgb.dtype != numpy.uint16 else 'L;16')) + except Exception: + pass + def defaultMaxSize(self): """ Get the default max size from the config settings. @@ -170,6 +221,19 @@ def getState(self): return super().getState() + ',' + str( self._maxSize) + def getMetadata(self): + """ + Return a dictionary of metadata containing levels, sizeX, sizeY, + tileWidth, tileHeight, magnification, mm_x, mm_y, and frames. + + :returns: metadata dictionary. + """ + result = super().getMetadata() + if getattr(self, '_frames', None) is not None and len(self._frames) > 1: + result['frames'] = [{} for idx in range(len(self._frames))] + self._addMetadataFrameInformation(result) + return result + def getInternalMetadata(self, **kwargs): """ Return additional known metadata about the tile source. Data returned @@ -189,8 +253,23 @@ def getInternalMetadata(self, **kwargs): @methodcache() def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, mayRedirect=False, **kwargs): - self._xyzInRange(x, y, z) - return self._outputTile(self._pilImage, TILE_FORMAT_PIL, x, y, z, + frame = self._getFrame(**kwargs) + self._xyzInRange(x, y, z, frame, self._frameCount) + if frame != 0: + with self._tileLock: + self._frameImage.seek(self._frames[frame]) + try: + img = self._frameImage.copy() + except Exception: + pass + self._frameImage.seek(0) + img.load() + if self._factor: + img = PIL.Image.fromarray(numpy.uint8(numpy.multiply( + numpy.asarray(img), self._factor))) + else: + img = self._pilImage + return self._outputTile(img, TILE_FORMAT_PIL, x, y, z, pilImageAllowed, numpyAllowed, **kwargs) diff --git a/sources/pil/setup.py b/sources/pil/setup.py index 69544989c..d01c08e22 100644 --- a/sources/pil/setup.py +++ b/sources/pil/setup.py @@ -57,6 +57,7 @@ def prerelease_local_scheme(version): 'importlib-metadata<5 ; python_version < "3.8"', ], extras_require={ + 'all': ['rawpy', 'pillow-heif'], 'girder': f'girder-large-image{limit_version}', }, keywords='large_image, tile source', diff --git a/test/test_source_base.py b/test/test_source_base.py index 863277a1a..4f3645a5e 100644 --- a/test/test_source_base.py +++ b/test/test_source_base.py @@ -193,11 +193,11 @@ def testSourcesTilesAndMethods(source, filename): # assert ts.histogram(onlyMinMax=True)['min'][0] is not None # Test multiple frames if they exist assert ts.frames >= 1 - if len(tileMetadata.get('frames', [])) > 1: + open('/tmp/junk.txt', 'a').write('%r\n' % ([source, filename, ts.frames], )) + if ts.frames > 1: assert ts.frames == len(tileMetadata['frames']) - tsf = sourceClass(imagePath, frame=len(tileMetadata['frames']) - 1) - tileMetadata = tsf.getMetadata() - utilities.checkTilesZXY(tsf, tileMetadata) + utilities.checkTilesZXY( + ts, tileMetadata, tileParams=dict(frame=ts.frames - 1)) # Test if we can fetch an associated image if any exist assert ts.getAssociatedImagesList() is not None if len(ts.getAssociatedImagesList()):