From 62cb050d965c9004df1fb4a83e17609a1012a8ea Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 27 Sep 2019 09:27:24 -0400 Subject: [PATCH] Handle different tiff orientations. For a Tiled TIFF that is not in the standard top-left orientation, this uses a less-efficient code path that may composite up to four tiles to get a conceptual tile at the location desired. This is a backport of #390. Tests are done on the master branch. --- server/tilesource/tiff.py | 8 +- server/tilesource/tiff_reader.py | 140 ++++++++++++++++++++++++++----- 2 files changed, 124 insertions(+), 24 deletions(-) diff --git a/server/tilesource/tiff.py b/server/tilesource/tiff.py index 9bdd171a4..a27982612 100644 --- a/server/tilesource/tiff.py +++ b/server/tilesource/tiff.py @@ -182,9 +182,9 @@ def _addAssociatedImage(self, largeImagePath, directoryNum, mustBeTiled=False, t associated._pixelInfo['width'] <= 8192 and associated._pixelInfo['height'] <= 8192): image = associated._tiffFile.read_image() - # Optrascan scanners are store xml image descriptions in a - # "tiled image". Check if this is the case, and, if so, parse - # such data + # Optrascan scanners store xml image descriptions in a "tiled + # image". Check if this is the case, and, if so, parse such + # data if image.tobytes()[:6] == b'', 1)[0] + b'>', topImage) return @@ -256,7 +256,7 @@ def getTile(self, x, y, z, pilImageAllowed=False, sparseFallback=False, else: tile = self._tiffDirectories[z].getTile(x, y) format = 'JPEG' - if PIL and isinstance(tile, PIL.Image.Image): + if isinstance(tile, PIL.Image.Image): format = TILE_FORMAT_PIL return self._outputTile(tile, format, x, y, z, pilImageAllowed, **kwargs) diff --git a/server/tilesource/tiff_reader.py b/server/tilesource/tiff_reader.py index 06bfdd63f..048764dcf 100644 --- a/server/tilesource/tiff_reader.py +++ b/server/tilesource/tiff_reader.py @@ -18,8 +18,10 @@ ############################################################################### import ctypes +import PIL.Image import os import six +import threading from functools import partial from xml.etree import cElementTree @@ -44,10 +46,6 @@ # instead of failing to load the whole plugin. logger.warn('Failed to import libtiff; try upgrading the python module (%s)' % exc) raise ImportError(str(exc)) -try: - import PIL.Image -except ImportError: - PIL = None # This suppress warnings about unknown tags libtiff_ctypes.suppress_warnings() @@ -126,6 +124,7 @@ def __init__(self, filePath, directoryNum, mustBeTiled=True): self._mustBeTiled = mustBeTiled self._tiffFile = None + self._tileLock = threading.RLock() self._open(filePath, directoryNum) self._loadMetadata() @@ -228,9 +227,18 @@ def _validate(self): # noqa 'Only greyscale (black is 0), RGB, and YCbCr photometric ' 'interpretation TIFF files are supported') - if self._tiffInfo.get('orientation') not in {None, libtiff_ctypes.ORIENTATION_TOPLEFT}: + if self._tiffInfo.get('orientation') not in { + libtiff_ctypes.ORIENTATION_TOPLEFT, + libtiff_ctypes.ORIENTATION_TOPRIGHT, + libtiff_ctypes.ORIENTATION_BOTRIGHT, + libtiff_ctypes.ORIENTATION_BOTLEFT, + libtiff_ctypes.ORIENTATION_LEFTTOP, + libtiff_ctypes.ORIENTATION_RIGHTTOP, + libtiff_ctypes.ORIENTATION_RIGHTBOT, + libtiff_ctypes.ORIENTATION_LEFTBOT, + None}: raise ValidationTiffException( - 'Only top-left orientation TIFF files are supported') + 'Unsupported TIFF orientation') if self._tiffInfo.get('compression') not in { libtiff_ctypes.COMPRESSION_NONE, @@ -281,6 +289,13 @@ def _loadMetadata(self): self._tileHeight = info.get('tilelength') self._imageWidth = info.get('imagewidth') self._imageHeight = info.get('imagelength') + if info.get('orientation') in { + libtiff_ctypes.ORIENTATION_LEFTTOP, + libtiff_ctypes.ORIENTATION_RIGHTTOP, + libtiff_ctypes.ORIENTATION_RIGHTBOT, + libtiff_ctypes.ORIENTATION_LEFTBOT}: + self._imageWidth, self._imageHeight = self._imageHeight, self._imageWidth + self._tileWidth, self._tileHeight = self._tileHeight, self._tileWidth self.parse_image_description(info.get('imagedescription', '')) # From TIFF specification, tag 0x128, 2 is inches, 3 is centimeters. units = {2: 25.4, 3: 10} @@ -295,10 +310,10 @@ def _loadMetadata(self): units.get(info.get('resolutionunit')) and info.get('yresolution') >= 100): self._pixelInfo['mm_y'] = units[info['resolutionunit']] / info['yresolution'] - if not self._pixelInfo.get('width') and info.get('imagewidth'): - self._pixelInfo['width'] = info['imagewidth'] - if not self._pixelInfo.get('height') and info.get('imagelength'): - self._pixelInfo['height'] = info['imagelength'] + if not self._pixelInfo.get('width') and self._imageWidth: + self._pixelInfo['width'] = self._imageWidth + if not self._pixelInfo.get('height') and self._imageHeight: + self._pixelInfo['height'] = self._imageHeight @methodcache(key=partial(strhash, '_getJpegTables')) def _getJpegTables(self): @@ -351,7 +366,7 @@ def _getJpegTables(self): tableData = tableBuffer[2:tableSize - 2] return tableData - def _toTileNum(self, x, y): + def _toTileNum(self, x, y, transpose=False): """ Get the internal tile number of a tile, from its row and column index. @@ -359,17 +374,25 @@ def _toTileNum(self, x, y): :type x: int :param y: The row index of the desired tile. :type y: int + :param transpose: If true, transpose width and height + :type tranpose: boolean :return: The internal tile number of the desired tile. :rtype int :raises: InvalidOperationTiffException """ # TIFFCheckTile and TIFFComputeTile require pixel coordinates - pixelX = int(x * self._tileWidth) - pixelY = int(y * self._tileHeight) - - if pixelX >= self._imageWidth or pixelY >= self._imageHeight: - raise InvalidOperationTiffException( - 'Tile x=%d, y=%d does not exist' % (x, y)) + if not transpose: + pixelX = int(x * self._tileWidth) + pixelY = int(y * self._tileHeight) + if pixelX >= self._imageWidth or pixelY >= self._imageHeight: + raise InvalidOperationTiffException( + 'Tile x=%d, y=%d does not exist' % (x, y)) + else: + pixelX = int(x * self._tileHeight) + pixelY = int(y * self._tileWidth) + if pixelX >= self._imageHeight or pixelY >= self._imageWidth: + raise InvalidOperationTiffException( + 'Tile x=%d, y=%d does not exist' % (x, y)) if libtiff_ctypes.libtiff.TIFFCheckTile( self._tiffFile, pixelX, pixelY, 0, 0) == 0: raise InvalidOperationTiffException( @@ -510,11 +533,13 @@ def _getUncompressedTile(self, tileNum): :rtype: PIL.Image :raises: IOTiffException """ - tileSize = libtiff_ctypes.libtiff.TIFFTileSize(self._tiffFile).value + with self._tileLock: + tileSize = libtiff_ctypes.libtiff.TIFFTileSize(self._tiffFile).value imageBuffer = ctypes.create_string_buffer(tileSize) - readSize = libtiff_ctypes.libtiff.TIFFReadEncodedTile( - self._tiffFile, tileNum, imageBuffer, tileSize) + with self._tileLock: + readSize = libtiff_ctypes.libtiff.TIFFReadEncodedTile( + self._tiffFile, tileNum, imageBuffer, tileSize) if readSize < tileSize: raise IOTiffException('Read an unexpected number of bytes from an encoded tile') if self._tiffInfo.get('samplesperpixel') == 1: @@ -528,6 +553,77 @@ def _getUncompressedTile(self, tileNum): image = PIL.Image.frombytes(mode, (self._tileWidth, self._tileHeight), imageBuffer) return image + def _getTileRotated(self, x, y): + """ + Get a tile from a rotated TIF. This composites uncompressed tiles as + necessary and then rotates the result. + + :param x: The column index of the desired tile. + :param y: The row index of the desired tile. + :return: either a buffer with a JPEG or a PIL image. + """ + x0 = x * self._tileWidth + x1 = x0 + self._tileWidth + y0 = y * self._tileHeight + y1 = y0 + self._tileHeight + iw, ih = self._imageWidth, self._imageHeight + tw, th = self._tileWidth, self._tileHeight + transpose = False + if self._tiffInfo.get('orientation') in { + libtiff_ctypes.ORIENTATION_LEFTTOP, + libtiff_ctypes.ORIENTATION_RIGHTTOP, + libtiff_ctypes.ORIENTATION_RIGHTBOT, + libtiff_ctypes.ORIENTATION_LEFTBOT}: + x0, x1, y0, y1 = y0, y1, x0, x1 + iw, ih = ih, iw + tw, th = th, tw + transpose = True + if self._tiffInfo.get('orientation') in { + libtiff_ctypes.ORIENTATION_TOPRIGHT, + libtiff_ctypes.ORIENTATION_BOTRIGHT, + libtiff_ctypes.ORIENTATION_RIGHTTOP, + libtiff_ctypes.ORIENTATION_RIGHTBOT}: + x0, x1 = iw - x1, iw - x0 + if self._tiffInfo.get('orientation') in { + libtiff_ctypes.ORIENTATION_BOTRIGHT, + libtiff_ctypes.ORIENTATION_BOTLEFT, + libtiff_ctypes.ORIENTATION_RIGHTBOT, + libtiff_ctypes.ORIENTATION_LEFTBOT}: + y0, y1 = ih - y1, ih - y0 + tx0 = x0 // tw + tx1 = (x1 - 1) // tw + ty0 = y0 // th + ty1 = (y1 - 1) // th + tile = None + for ty in range(max(0, ty0), max(0, ty1 + 1)): + for tx in range(max(0, tx0), max(0, tx1 + 1)): + subtile = self._getUncompressedTile(self._toTileNum(tx, ty, transpose)) + if not tile: + tile = PIL.Image.new(subtile.mode, (tw, th)) + tile.paste(subtile, (tx * tw - x0, ty * th - y0)) + if tile is None: + raise InvalidOperationTiffException( + 'Tile x=%d, y=%d does not exist' % (x, y)) + if self._tiffInfo.get('orientation') in { + libtiff_ctypes.ORIENTATION_BOTRIGHT, + libtiff_ctypes.ORIENTATION_BOTLEFT, + libtiff_ctypes.ORIENTATION_RIGHTBOT, + libtiff_ctypes.ORIENTATION_LEFTBOT}: + tile = tile.transpose(PIL.Image.FLIP_TOP_BOTTOM) + if self._tiffInfo.get('orientation') in { + libtiff_ctypes.ORIENTATION_TOPRIGHT, + libtiff_ctypes.ORIENTATION_BOTRIGHT, + libtiff_ctypes.ORIENTATION_RIGHTTOP, + libtiff_ctypes.ORIENTATION_RIGHTBOT}: + tile = tile.transpose(PIL.Image.FLIP_LEFT_RIGHT) + if self._tiffInfo.get('orientation') in { + libtiff_ctypes.ORIENTATION_LEFTTOP, + libtiff_ctypes.ORIENTATION_RIGHTTOP, + libtiff_ctypes.ORIENTATION_RIGHTBOT, + libtiff_ctypes.ORIENTATION_LEFTBOT}: + tile = tile.transpose(PIL.Image.TRANSPOSE) + return tile + @property def tileWidth(self): """ @@ -572,6 +668,10 @@ def getTile(self, x, y): :rtype: bytes :raises: InvalidOperationTiffException or IOTiffException """ + if self._tiffInfo.get('orientation') not in { + libtiff_ctypes.ORIENTATION_TOPLEFT, + None}: + return self._getTileRotated(x, y) # This raises an InvalidOperationTiffException if the tile doesn't exist tileNum = self._toTileNum(x, y)