Skip to content

Commit

Permalink
Merge pull request #1088 from girder/pil-frames
Browse files Browse the repository at this point in the history
Support multi-frame PIL sources.
  • Loading branch information
manthey authored Mar 22, 2023
2 parents f142128 + 0cc5227 commit 8e56869
Show file tree
Hide file tree
Showing 7 changed files with 97 additions and 16 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements-test-core.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
97 changes: 88 additions & 9 deletions sources/pil/large_image_source_pil/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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)


Expand Down
1 change: 1 addition & 0 deletions sources/pil/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 4 additions & 4 deletions test/test_source_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()):
Expand Down

0 comments on commit 8e56869

Please sign in to comment.