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',