From 7d0c89f2a1530d76b29e052ba91554f229578260 Mon Sep 17 00:00:00 2001
From: David Manthey <david.manthey@kitware.com>
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.
---
 .travis.yml                      |   2 +-
 server/tilesource/tiff.py        |   8 +-
 server/tilesource/tiff_reader.py | 135 +++++++++++++++++++++++++++----
 3 files changed, 124 insertions(+), 21 deletions(-)

diff --git a/.travis.yml b/.travis.yml
index db36eb376..bd61dd0b0 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -225,7 +225,7 @@ install:
   - popd
   - girder-install plugin --symlink $large_image_path
   # Install all extras (since "girder-install plugin" does not provide a mechanism to specify them
-  - pip install glymur --find-links https://manthey.github.io/large_image_wheels
+  - pip install glymur --find-links https://girder.github.io/large_image_wheels
   # Trusty supports gdal 1.10.0; don't test mapnik on Python 3 (for now)
   - if [ -n "${PY3}" ]; then
       pip install -e $large_image_path[memcached,openslide] ;
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'<?xml ':
                     self._parseImageXml(image.tobytes().rsplit(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..fb0008744 100644
--- a/server/tilesource/tiff_reader.py
+++ b/server/tilesource/tiff_reader.py
@@ -20,6 +20,7 @@
 import ctypes
 import os
 import six
+import threading
 
 from functools import partial
 from xml.etree import cElementTree
@@ -126,6 +127,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 +230,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 +292,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 +313,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 +369,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 +377,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 +536,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 +556,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 +671,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)