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

General handling of skipped levels #1373

Merged
merged 1 commit into from
Nov 15, 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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
- Configurable item list grid view ([#1363](../../pull/1363))
- Allow labels in item list view ([#1366](../../pull/1366))
- Improve cache key guard ([#1368](../../pull/1368))
- Improve handling dicom files in the working directory ([#1370](../../pull/137068))
- Improve handling dicom files in the working directory ([#1370](../../pull/1370))
- General handling of skipped levels ([#1373](../../pull/1373))

### Changes
- Update WsiDicomWebClient init call ([#1371](../../pull/1371))
- Rename DICOMweb AssetstoreImportView ([#1372](../../pull/1372))

### Bug Fixes
- Default to "None" for the DICOM assetstore limit ([#1359](../../pull/1359))
Expand Down
68 changes: 65 additions & 3 deletions large_image/tilesource/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ class TileSource(IPyLeafletMixin):

geospatial = False

# When getting tiles for otherwise empty levels (missing powers of two), we
# composite the tile from higher resolution levels. This can use excessive
# memory if there are too many missing levels. For instance, if there are
# six missing levels and the tile size is 1024 square RGBA, then 16 Gb are
# needed for the composited tile at a minimum. By setting
# _maxSkippedLevels, such large gaps are composited in stages.
_maxSkippedLevels = 3

def __init__(self, encoding='JPEG', jpegQuality=95, jpegSubsampling=0,
tiffCompression='raw', edge=False, style=None, noCache=None,
*args, **kwargs):
Expand Down Expand Up @@ -1924,6 +1932,54 @@ def _xyzToCorners(self, x, y, z):
y1 = min((y + 1) * step * self.tileHeight, self.sizeY)
return x0, y0, x1, y1, step

def _nonemptyLevelsList(self, frame=0):
"""
Return a list of one value per level where the value is None if the
level does not exist in the file and any other value if it does.

:param frame: the frame number.
:returns: a list of levels length.
"""
return [True] * self.levels

def _getTileFromEmptyLevel(self, x, y, z, **kwargs):
"""
Given the x, y, z tile location in an unpopulated level, get tiles from
higher resolution levels to make the lower-res tile.

:param x: location of tile within original level.
:param y: location of tile within original level.
:param z: original level.
:returns: tile in PIL format.
"""
basez = z
scale = 1
dirlist = self._nonemptyLevelsList(kwargs.get('frame'))
while dirlist[z] is None:
scale *= 2
z += 1
while z - basez > self._maxSkippedLevels:
z -= self._maxSkippedLevels
scale = int(scale / 2 ** self._maxSkippedLevels)
tile = PIL.Image.new('RGBA', (
min(self.sizeX, self.tileWidth * scale), min(self.sizeY, self.tileHeight * scale)))
maxX = 2.0 ** (z + 1 - self.levels) * self.sizeX / self.tileWidth
maxY = 2.0 ** (z + 1 - self.levels) * self.sizeY / self.tileHeight
for newX in range(scale):
for newY in range(scale):
if ((newX or newY) and ((x * scale + newX) >= maxX or
(y * scale + newY) >= maxY)):
continue
subtile = self.getTile(
x * scale + newX, y * scale + newY, z,
pilImageAllowed=True, numpyAllowed=False,
sparseFallback=True, edge=False, frame=kwargs.get('frame'))
subtile = _imageToPIL(subtile)
tile.paste(subtile, (newX * self.tileWidth,
newY * self.tileHeight))
return tile.resize((self.tileWidth, self.tileHeight),
getattr(PIL.Image, 'Resampling', PIL.Image).LANCZOS)

@methodcache()
def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False,
sparseFallback=False, frame=None):
Expand Down Expand Up @@ -1993,10 +2049,16 @@ def getPreferredLevel(self, level):
:param level: desired level
:returns level: a level with actual data that is no lower resolution.
"""
metadata = self.getMetadata()
if metadata['levels'] is None:
if self.levels is None:
return level
return max(0, min(level, metadata['levels'] - 1))
level = max(0, min(level, self.levels - 1))
baselevel = level
levelList = self._nonemptyLevelsList()
while levelList[level] is None and level < self.levels - 1:
level += 1
while level - baselevel >= self._maxSkippedLevels:
level -= self._maxSkippedLevels
return level

def convertRegionScale(
self, sourceRegion, sourceScale=None, targetScale=None,
Expand Down
43 changes: 14 additions & 29 deletions sources/bioformats/large_image_source_bioformats/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,19 @@ def _computeMagnification(self):
self._magnification['magnification'] = float(metadata[key])
break

def _nonemptyLevelsList(self, frame=0):
"""
Return a list of one value per level where the value is None if the
level does not exist in the file and any other value if it does.

:param frame: the frame number.
:returns: a list of levels length.
"""
nonempty = [True if v is not None else None
for v in self._metadata['frameSeries'][0]['series']][:self.levels]
nonempty += [None] * (self.levels - len(nonempty))
return nonempty[::-1]

def getNativeMagnification(self):
"""
Get the magnification at a particular level.
Expand Down Expand Up @@ -537,35 +550,6 @@ def getInternalMetadata(self, **kwargs):
"""
return self._metadata

def _getTileFromEmptyLevel(self, x, y, z, **kwargs):
"""
Composite tiles from missing levels from larger levels in pieces to
avoid using too much memory.
"""
fac = int(2 ** self._maxSkippedLevels)
z += self._maxSkippedLevels
scale = 2 ** (self.levels - 1 - z)
result = None
for tx in range(fac - 1, -1, -1):
if x * fac + tx >= int(math.ceil(self.sizeX / self.tileWidth / scale)):
continue
for ty in range(fac - 1, -1, -1):
if y * fac + ty >= int(math.ceil(self.sizeY / self.tileHeight / scale)):
continue
tile = self.getTile(
x * fac + tx, y * fac + ty, z, pilImageAllowed=False,
numpyAllowed=True, **kwargs)
if result is None:
result = np.zeros((
ty * fac + tile.shape[0],
tx * fac + tile.shape[1],
tile.shape[2]), dtype=tile.dtype)
result[
ty * fac:ty * fac + tile.shape[0],
tx * fac:tx * fac + tile.shape[1],
::] = tile
return result[::scale, ::scale, ::]

@methodcache()
def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs):
self._xyzInRange(x, y, z)
Expand Down Expand Up @@ -601,6 +585,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs):

if scale >= 2 ** self._maxSkippedLevels:
tile = self._getTileFromEmptyLevel(x, y, z, **kwargs)
tile = large_image.tilesource.base._imageToNumpy(tile)[0]
format = TILE_FORMAT_NUMPY
else:
with self._tileLock:
Expand Down
2 changes: 2 additions & 0 deletions sources/dicom/large_image_source_dicom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ def __init__(self, path, **kwargs):
self.levels = int(max(1, math.ceil(math.log(
max(self.sizeX / self.tileWidth, self.sizeY / self.tileHeight)) / math.log(2)) + 1))
self._populatedLevels = len(self._dicom.levels)
# We need to detect which levels are functionally present if we want to
# return a sensible _nonemptyLevelsList

def _open_wsi_dicom(self, path):
if isinstance(path, dict):
Expand Down
54 changes: 35 additions & 19 deletions sources/openjpeg/large_image_source_openjpeg/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from large_image.constants import TILE_FORMAT_NUMPY, SourcePriority
from large_image.exceptions import TileSourceError, TileSourceFileNotFoundError
from large_image.tilesource import FileTileSource, etreeToDict
from large_image.tilesource.utilities import _imageToNumpy

try:
__version__ = _importlib_version(__name__)
Expand Down Expand Up @@ -158,6 +159,17 @@
if getattr(subbox, 'icc_profile', None):
self._iccprofiles = [subbox.icc_profile]

def _nonemptyLevelsList(self, frame=0):
"""
Return a list of one value per level where the value is None if the
level does not exist in the file and any other value if it does.

:param frame: the frame number.
:returns: a list of levels length.
"""
return [True if self.levels - 1 - idx < self._populatedLevels else None
for idx in range(self.levels)]

def getNativeMagnification(self):
"""
Get the magnification at a particular level.
Expand Down Expand Up @@ -243,26 +255,30 @@
self._xyzInRange(x, y, z)
x0, y0, x1, y1, step = self._xyzToCorners(x, y, z)
scale = None
if z < self._minlevel:
scale = int(2 ** (self._minlevel - z))
step = int(2 ** (self.levels - 1 - self._minlevel))
# possibly open the file multiple times so multiple threads can access
# it concurrently.
while True:
if self._minlevel - z > self._maxSkippedLevels:
tile = self._getTileFromEmptyLevel(x, y, z, **kwargs)
tile = _imageToNumpy(tile)[0]

Check warning on line 260 in sources/openjpeg/large_image_source_openjpeg/__init__.py

View check run for this annotation

Codecov / codecov/patch

sources/openjpeg/large_image_source_openjpeg/__init__.py#L259-L260

Added lines #L259 - L260 were not covered by tests
else:
if z < self._minlevel:
scale = int(2 ** (self._minlevel - z))
step = int(2 ** (self.levels - 1 - self._minlevel))

Check warning on line 264 in sources/openjpeg/large_image_source_openjpeg/__init__.py

View check run for this annotation

Codecov / codecov/patch

sources/openjpeg/large_image_source_openjpeg/__init__.py#L263-L264

Added lines #L263 - L264 were not covered by tests
# possibly open the file multiple times so multiple threads can access
# it concurrently.
while True:
try:
# A timeout prevents uninterupptable waits on some platforms
openjpegHandle = self._openjpegHandles.get(timeout=1.0)
break
except queue.Empty:
continue

Check warning on line 273 in sources/openjpeg/large_image_source_openjpeg/__init__.py

View check run for this annotation

Codecov / codecov/patch

sources/openjpeg/large_image_source_openjpeg/__init__.py#L272-L273

Added lines #L272 - L273 were not covered by tests
if openjpegHandle is None:
openjpegHandle = glymur.Jp2k(self._largeImagePath)

Check warning on line 275 in sources/openjpeg/large_image_source_openjpeg/__init__.py

View check run for this annotation

Codecov / codecov/patch

sources/openjpeg/large_image_source_openjpeg/__init__.py#L275

Added line #L275 was not covered by tests
try:
# A timeout prevents uninterupptable waits on some platforms
openjpegHandle = self._openjpegHandles.get(timeout=1.0)
break
except queue.Empty:
continue
if openjpegHandle is None:
openjpegHandle = glymur.Jp2k(self._largeImagePath)
try:
tile = openjpegHandle[y0:y1:step, x0:x1:step]
finally:
self._openjpegHandles.put(openjpegHandle)
if scale:
tile = tile[::scale, ::scale]
tile = openjpegHandle[y0:y1:step, x0:x1:step]
finally:
self._openjpegHandles.put(openjpegHandle)
if scale:
tile = tile[::scale, ::scale]

Check warning on line 281 in sources/openjpeg/large_image_source_openjpeg/__init__.py

View check run for this annotation

Codecov / codecov/patch

sources/openjpeg/large_image_source_openjpeg/__init__.py#L281

Added line #L281 was not covered by tests
return self._outputTile(tile, TILE_FORMAT_NUMPY, x, y, z,
pilImageAllowed, numpyAllowed, **kwargs)

Expand Down
37 changes: 25 additions & 12 deletions sources/openslide/large_image_source_openslide/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@
nearPowerOfTwo(levels[0]['height'], entry['height'])]
return levels

def _nonemptyLevelsList(self, frame=0):
"""
Return a list of one value per level where the value is None if the
level does not exist in the file and any other value if it does.

:param frame: the frame number.
:returns: a list of levels length.
"""
return [True if l['scale'] == 1 else None for l in self._svslevels]

def getNativeMagnification(self):
"""
Get the magnification at a particular level.
Expand Down Expand Up @@ -284,18 +294,21 @@
# We ask to read an area that will cover the tile at the z level. The
# scale we computed in the __init__ process for this svs level tells
# how much larger a region we need to read.
try:
tile = self._openslide.read_region(
(offsetx, offsety), svslevel['svslevel'],
(self.tileWidth * svslevel['scale'],
self.tileHeight * svslevel['scale']))
except openslide.lowlevel.OpenSlideError as exc:
raise TileSourceError(
'Failed to get OpenSlide region (%r).' % exc)
# Always scale to the svs level 0 tile size.
if svslevel['scale'] != 1:
tile = tile.resize((self.tileWidth, self.tileHeight),
getattr(PIL.Image, 'Resampling', PIL.Image).LANCZOS)
if svslevel['scale'] > 2 ** self._maxSkippedLevels:
tile = self._getTileFromEmptyLevel(x, y, z, **kwargs)
else:
try:
tile = self._openslide.read_region(
(offsetx, offsety), svslevel['svslevel'],
(self.tileWidth * svslevel['scale'],
self.tileHeight * svslevel['scale']))
except openslide.lowlevel.OpenSlideError as exc:
raise TileSourceError(

Check warning on line 306 in sources/openslide/large_image_source_openslide/__init__.py

View check run for this annotation

Codecov / codecov/patch

sources/openslide/large_image_source_openslide/__init__.py#L305-L306

Added lines #L305 - L306 were not covered by tests
'Failed to get OpenSlide region (%r).' % exc)
# Always scale to the svs level 0 tile size.
if svslevel['scale'] != 1:
tile = tile.resize((self.tileWidth, self.tileHeight),
getattr(PIL.Image, 'Resampling', PIL.Image).LANCZOS)
return self._outputTile(tile, TILE_FORMAT_PIL, x, y, z, pilImageAllowed,
numpyAllowed, **kwargs)

Expand Down
69 changes: 8 additions & 61 deletions sources/tiff/large_image_source_tiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,6 @@ class TiffFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
'image/x-ptif': SourcePriority.PREFERRED,
}

# When getting tiles for otherwise empty directories (missing powers of
# two), we composite the tile from higher resolution levels. This can use
# excessive memory if there are too many missing levels. For instance, if
# there are six missing levels and the tile size is 1024 square RGBA, then
# 16 Gb are needed for the composited tile at a minimum. By setting
# _maxSkippedLevels, such large gaps are composited in stages.
_maxSkippedLevels = 3

_maxAssociatedImageSize = 8192

def __init__(self, path, **kwargs): # noqa
Expand Down Expand Up @@ -643,7 +635,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False,
if dir is None:
try:
if not kwargs.get('inSparseFallback'):
tile = self.getTileFromEmptyDirectory(x, y, z, **kwargs)
tile = self._getTileFromEmptyLevel(x, y, z, **kwargs)
else:
raise IOTiffError('Missing z level %d' % z)
except Exception:
Expand Down Expand Up @@ -713,64 +705,19 @@ def getTileIOTiffError(self, x, y, z, pilImageAllowed=False,
numpyAllowed, applyStyle=False, **kwargs)
raise TileSourceError('Internal I/O failure: %s' % exception.args[0])

def getTileFromEmptyDirectory(self, x, y, z, **kwargs):
def _nonemptyLevelsList(self, frame=0):
"""
Given the x, y, z tile location in an unpopulated level, get tiles from
higher resolution levels to make the lower-res tile.
Return a list of one value per level where the value is None if the
level does not exist in the file and any other value if it does.

:param x: location of tile within original level.
:param y: location of tile within original level.
:param z: original level.
:returns: tile in PIL format.
:param frame: the frame number.
:returns: a list of levels length.
"""
basez = z
scale = 1
dirlist = self._tiffDirectories
frame = self._getFrame(**kwargs)
frame = int(frame or 0)
if frame > 0 and hasattr(self, '_frames'):
dirlist = self._frames[frame]['dirs']
while dirlist[z] is None:
scale *= 2
z += 1
while z - basez > self._maxSkippedLevels:
z -= self._maxSkippedLevels
scale = int(scale / 2 ** self._maxSkippedLevels)
tile = PIL.Image.new('RGBA', (
min(self.sizeX, self.tileWidth * scale), min(self.sizeY, self.tileHeight * scale)))
maxX = 2.0 ** (z + 1 - self.levels) * self.sizeX / self.tileWidth
maxY = 2.0 ** (z + 1 - self.levels) * self.sizeY / self.tileHeight
for newX in range(scale):
for newY in range(scale):
if ((newX or newY) and ((x * scale + newX) >= maxX or
(y * scale + newY) >= maxY)):
continue
subtile = self.getTile(
x * scale + newX, y * scale + newY, z,
pilImageAllowed=True, numpyAllowed=False,
sparseFallback=True, edge=False, frame=frame)
if not isinstance(subtile, PIL.Image.Image):
subtile = PIL.Image.open(io.BytesIO(subtile))
tile.paste(subtile, (newX * self.tileWidth,
newY * self.tileHeight))
return tile.resize((self.tileWidth, self.tileHeight),
getattr(PIL.Image, 'Resampling', PIL.Image).LANCZOS)

def getPreferredLevel(self, level):
"""
Given a desired level (0 is minimum resolution, self.levels - 1 is max
resolution), return the level that contains actual data that is no
lower resolution.

:param level: desired level
:returns level: a level with actual data that is no lower resolution.
"""
level = max(0, min(level, self.levels - 1))
baselevel = level
while self._tiffDirectories[level] is None and level < self.levels - 1:
level += 1
while level - baselevel >= self._maxSkippedLevels:
level -= self._maxSkippedLevels
return level
return dirlist

def getAssociatedImagesList(self):
"""
Expand Down
Loading