Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multi-frame PIL sources. #1088

Merged
merged 1 commit into from
Mar 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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