Skip to content

Commit

Permalink
Merge pull request #1373 from girder/generalize-level-skipping
Browse files Browse the repository at this point in the history
General handling of skipped levels
  • Loading branch information
manthey authored Nov 15, 2023
2 parents 806ffc1 + 033b5e6 commit 2c006e2
Show file tree
Hide file tree
Showing 10 changed files with 262 additions and 170 deletions.
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 @@ def _getAssociatedImages(self):
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 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs):
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]
else:
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:
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:
# 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]
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 @@ def _getAvailableLevels(self, path):
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 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs):
# 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(
'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

0 comments on commit 2c006e2

Please sign in to comment.