From 8ec0cc373657c652e3ef4ad465886bf4221e524b Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 24 Sep 2024 12:25:36 -0400 Subject: [PATCH] Better handle images without enough tile layers When an image is missing multiple layers, if enough are missing, we don't need to always read the whole image. Also, if the image is not uint8, we can more efficiently composite the lower resolution tiles. --- CHANGELOG.md | 6 +++ large_image/tilesource/base.py | 42 +++++++++++++++++-- large_image/tilesource/tileiterator.py | 10 ++--- .../large_image_source_bioformats/__init__.py | 2 +- .../large_image_source_openjpeg/__init__.py | 2 +- .../large_image_source_openslide/__init__.py | 5 ++- .../tiff/large_image_source_tiff/__init__.py | 3 +- .../large_image_source_tifffile/__init__.py | 13 +++++- .../zarr/large_image_source_zarr/__init__.py | 2 +- test/test_source_base.py | 2 +- 10 files changed, 70 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2af42cf..7d989cf1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## 1.29.12 + +### Improvements + +- Better handle images without enough tile layers ([#1648](../../pull/1648)) + ## 1.29.11 ### Changes diff --git a/large_image/tilesource/base.py b/large_image/tilesource/base.py index 37c881a17..aa3276246 100644 --- a/large_image/tilesource/base.py +++ b/large_image/tilesource/base.py @@ -1491,7 +1491,8 @@ def _nonemptyLevelsList(self, frame: Optional[int] = 0) -> List[bool]: """ return [True] * self.levels - def _getTileFromEmptyLevel(self, x: int, y: int, z: int, **kwargs) -> PIL.Image.Image: + def _getTileFromEmptyLevel(self, x: int, y: int, z: int, **kwargs) -> Tuple[ + Union[PIL.Image.Image, np.ndarray], str]: """ Given the x, y, z tile location in an unpopulated level, get tiles from higher resolution levels to make the lower-res tile. @@ -1508,6 +1509,39 @@ def _getTileFromEmptyLevel(self, x: int, y: int, z: int, **kwargs) -> PIL.Image. while dirlist[z] is None: scale *= 2 z += 1 + # if scale >= max(tileWidth, tileHeight), we can just get one tile per + # pixel at this point. If dtype is not uint8 or the number of bands is + # greater than 4, also just use nearest neighbor. + if (scale >= max(self.tileWidth, self.tileHeight) or + (self.dtype and self.dtype != np.uint8) or + (self.bandCount and self.bandCount > 4)): + nptile = np.zeros((self.tileHeight, self.tileWidth, cast(int, self.bandCount))) + maxX = 2.0 ** (z + 1 - self.levels) * self.sizeX / self.tileWidth + maxY = 2.0 ** (z + 1 - self.levels) * self.sizeY / self.tileHeight + for newY in range(scale): + sty = (y * scale + newY) * self.tileHeight + dy = sty % scale + ty = (newY * self.tileHeight) // scale + if (newY and y * scale + newY >= maxY) or dy >= self.tileHeight: + continue + for newX in range(scale): + stx = (x * scale + newX) * self.tileWidth + dx = stx % scale + if (newX and x * scale + newX >= maxX) or dx >= self.tileWidth: + continue + tx = (newX * self.tileWidth) // scale + if time.time() - lastlog > 10: + self.logger.info( + 'Compositing tile from higher resolution tiles x=%d y=%d z=%d', + x * scale + newX, y * scale + newY, z) + lastlog = time.time() + subtile = self.getTile( + x * scale + newX, y * scale + newY, z, + pilImageAllowed=False, numpyAllowed='always', + sparseFallback=True, edge=False, frame=kwargs.get('frame')) + subtile = subtile[dx::scale, dy::scale] + nptile[ty:ty + subtile.shape[0], tx:tx + subtile.shape[1]] = subtile + return nptile, TILE_FORMAT_NUMPY while z - basez > self._maxSkippedLevels: z -= self._maxSkippedLevels scale = int(scale / 2 ** self._maxSkippedLevels) @@ -1530,10 +1564,12 @@ def _getTileFromEmptyLevel(self, x: int, y: int, z: int, **kwargs) -> PIL.Image. pilImageAllowed=True, numpyAllowed=False, sparseFallback=True, edge=False, frame=kwargs.get('frame')) subtile = _imageToPIL(subtile) + mode = subtile.mode tile.paste(subtile, (newX * self.tileWidth, newY * self.tileHeight)) - return tile.resize((self.tileWidth, self.tileHeight), - getattr(PIL.Image, 'Resampling', PIL.Image).LANCZOS) + return tile.resize( + (self.tileWidth, self.tileHeight), + getattr(PIL.Image, 'Resampling', PIL.Image).LANCZOS).convert(mode), TILE_FORMAT_PIL @methodcache() def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, diff --git a/large_image/tilesource/tileiterator.py b/large_image/tilesource/tileiterator.py index fe1597e7f..121a6e1e3 100644 --- a/large_image/tilesource/tileiterator.py +++ b/large_image/tilesource/tileiterator.py @@ -234,18 +234,18 @@ def _tileIteratorInfo(self, **kwargs) -> Optional[Dict[str, Any]]: # noqa # If we are scaling the result, pick the tile level that is at least # the resolution we need and is preferred by the tile source. if outWidth != regionWidth or outHeight != regionHeight: - newLevel = source.getPreferredLevel(preferredLevel + int( - math.ceil(round(math.log(max(float(outWidth) / regionWidth, + newLevel = source.getPreferredLevel(max(0, preferredLevel + int( + math.ceil(round(math.log(min(float(outWidth) / regionWidth, float(outHeight) / regionHeight)) / - math.log(2), 4)))) + math.log(2), 4))))) if newLevel < preferredLevel: # scale the bounds to the level we will use factor = 2 ** (preferredLevel - newLevel) left = int(left / factor) - right = int(right / factor) + right = max(int(right / factor), left + 1) regionWidth = right - left top = int(top / factor) - bottom = int(bottom / factor) + bottom = max(int(bottom / factor), top + 1) regionHeight = bottom - top preferredLevel = newLevel requestedScale /= factor diff --git a/sources/bioformats/large_image_source_bioformats/__init__.py b/sources/bioformats/large_image_source_bioformats/__init__.py index 6fbfee33a..9d913da13 100644 --- a/sources/bioformats/large_image_source_bioformats/__init__.py +++ b/sources/bioformats/large_image_source_bioformats/__init__.py @@ -644,7 +644,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): height = min(height, sizeYAtScale - offsety) if scale >= 2 ** self._maxSkippedLevels: - tile = self._getTileFromEmptyLevel(x, y, z, **kwargs) + tile, _format = self._getTileFromEmptyLevel(x, y, z, **kwargs) tile = large_image.tilesource.base._imageToNumpy(tile)[0] format = TILE_FORMAT_NUMPY else: diff --git a/sources/openjpeg/large_image_source_openjpeg/__init__.py b/sources/openjpeg/large_image_source_openjpeg/__init__.py index 5a69bdf09..7539675fa 100644 --- a/sources/openjpeg/large_image_source_openjpeg/__init__.py +++ b/sources/openjpeg/large_image_source_openjpeg/__init__.py @@ -279,7 +279,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): x0, y0, x1, y1, step = self._xyzToCorners(x, y, z) scale = None if self._minlevel - z > self._maxSkippedLevels: - tile = self._getTileFromEmptyLevel(x, y, z, **kwargs) + tile, _format = self._getTileFromEmptyLevel(x, y, z, **kwargs) tile = _imageToNumpy(tile)[0] else: if z < self._minlevel: diff --git a/sources/openslide/large_image_source_openslide/__init__.py b/sources/openslide/large_image_source_openslide/__init__.py index f6210c18c..1750fd5b4 100644 --- a/sources/openslide/large_image_source_openslide/__init__.py +++ b/sources/openslide/large_image_source_openslide/__init__.py @@ -324,7 +324,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): # scale we computed in the __init__ process for this svs level tells # how much larger a region we need to read. if svslevel['scale'] > 2 ** self._maxSkippedLevels: - tile = self._getTileFromEmptyLevel(x, y, z, **kwargs) + tile, format = self._getTileFromEmptyLevel(x, y, z, **kwargs) else: retries = 3 while retries > 0: @@ -333,6 +333,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): (offsetx, offsety), svslevel['svslevel'], (self.tileWidth * svslevel['scale'], self.tileHeight * svslevel['scale'])) + format = TILE_FORMAT_PIL break except openslide.lowlevel.OpenSlideError as exc: self._largeImagePath = str(self._getLargeImagePath()) @@ -352,7 +353,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): 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, + return self._outputTile(tile, format, x, y, z, pilImageAllowed, numpyAllowed, **kwargs) def getPreferredLevel(self, level): diff --git a/sources/tiff/large_image_source_tiff/__init__.py b/sources/tiff/large_image_source_tiff/__init__.py index 2029b55dd..ca6c92ec7 100644 --- a/sources/tiff/large_image_source_tiff/__init__.py +++ b/sources/tiff/large_image_source_tiff/__init__.py @@ -668,7 +668,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, if dir is None: try: if not kwargs.get('inSparseFallback'): - tile = self._getTileFromEmptyLevel(x, y, z, **kwargs) + tile, format = self._getTileFromEmptyLevel(x, y, z, **kwargs) else: raise IOTiffError('Missing z level %d' % z) except Exception: @@ -677,7 +677,6 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, else: raise allowStyle = False - format = TILE_FORMAT_PIL else: tile = dir.getTile(x, y, asarray=numpyAllowed == 'always') format = 'JPEG' diff --git a/sources/tifffile/large_image_source_tifffile/__init__.py b/sources/tifffile/large_image_source_tifffile/__init__.py index 1945ad760..ab563f8b2 100644 --- a/sources/tifffile/large_image_source_tifffile/__init__.py +++ b/sources/tifffile/large_image_source_tifffile/__init__.py @@ -553,6 +553,17 @@ def _nonemptyLevelsList(self, frame=0): self._nonempty_levels_list[frame] = nonempty return nonempty + 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. + """ + return max(0, min(level, self.levels - 1)) + def _getZarrArray(self, series, sidx): with self._zarrlock: if sidx not in self._zarrcache: @@ -596,7 +607,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): else: bza = za if step > 2 ** self._maxSkippedLevels: - tile = self._getTileFromEmptyLevel(x, y, z, **kwargs) + tile, _format = self._getTileFromEmptyLevel(x, y, z, **kwargs) tile = large_image.tilesource.base._imageToNumpy(tile)[0] else: sel = [] diff --git a/sources/zarr/large_image_source_zarr/__init__.py b/sources/zarr/large_image_source_zarr/__init__.py index 1296f1287..a19b1698a 100644 --- a/sources/zarr/large_image_source_zarr/__init__.py +++ b/sources/zarr/large_image_source_zarr/__init__.py @@ -520,7 +520,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): y1 //= scale step //= scale if step > 2 ** self._maxSkippedLevels: - tile = self._getTileFromEmptyLevel(x, y, z, **kwargs) + tile, _format = self._getTileFromEmptyLevel(x, y, z, **kwargs) tile = large_image.tilesource.base._imageToNumpy(tile)[0] else: idx = [slice(None) for _ in arr.shape] diff --git a/test/test_source_base.py b/test/test_source_base.py index 252afbac6..569c82bb5 100644 --- a/test/test_source_base.py +++ b/test/test_source_base.py @@ -637,7 +637,7 @@ def testStyleFunctions(): region2, _ = sourceFunc2.getRegion( output=dict(maxWidth=50), format=large_image.constants.TILE_FORMAT_NUMPY) - assert np.any(region2 != region1) + assert np.any(region2[:, :, :3] != region1) sourceFunc3 = large_image.open(imagePath, style={ 'function': { 'name': 'large_image.tilesource.stylefuncs.maskPixelValues',