###############################################################################
# Copyright Kitware Inc.
#
# Licensed under the Apache License, Version 2.0 ( the "License" );
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
###############################################################################
import contextlib
import ctypes
import io
import json
import math
import os
import threading
from functools import partial
from xml.etree import ElementTree
import cachetools
import numpy as np
import PIL.Image
from large_image import config
from large_image.cache_util import methodcache, strhash
from large_image.tilesource import etreeToDict
from .exceptions import InvalidOperationTiffError, IOOpenTiffError, IOTiffError, ValidationTiffError
try:
from libtiff import libtiff_ctypes
except ValueError as exc:
# If the python libtiff module doesn't contain a pregenerated module for
# the appropriate version of libtiff, it tries to generate a module from
# the libtiff header file. If it can't find this file (possibly because it
# is in a virtual environment), it raises a ValueError instead of an
# ImportError. We convert this to an ImportError, so that we will print a
# more lucid error message and just fail to load this one tile source
# instead of failing to load the whole plugin.
config.getLogger().warning(
'Failed to import libtiff; try upgrading the python module (%s)' % exc)
raise ImportError(str(exc))
# This suppress warnings about unknown tags
libtiff_ctypes.suppress_warnings()
# Suppress errors to stderr
libtiff_ctypes.suppress_errors()
_ctypesFormattbl = {
(8, libtiff_ctypes.SAMPLEFORMAT_UINT): np.uint8,
(8, libtiff_ctypes.SAMPLEFORMAT_INT): np.int8,
(16, libtiff_ctypes.SAMPLEFORMAT_UINT): np.uint16,
(16, libtiff_ctypes.SAMPLEFORMAT_INT): np.int16,
(16, libtiff_ctypes.SAMPLEFORMAT_IEEEFP): np.float16,
(32, libtiff_ctypes.SAMPLEFORMAT_UINT): np.uint32,
(32, libtiff_ctypes.SAMPLEFORMAT_INT): np.int32,
(32, libtiff_ctypes.SAMPLEFORMAT_IEEEFP): np.float32,
(64, libtiff_ctypes.SAMPLEFORMAT_UINT): np.uint64,
(64, libtiff_ctypes.SAMPLEFORMAT_INT): np.int64,
(64, libtiff_ctypes.SAMPLEFORMAT_IEEEFP): np.float64,
}
[docs]
def patchLibtiff():
libtiff_ctypes.libtiff.TIFFFieldWithTag.restype = \
ctypes.POINTER(libtiff_ctypes.TIFFFieldInfo)
libtiff_ctypes.libtiff.TIFFFieldWithTag.argtypes = \
(libtiff_ctypes.TIFF, libtiff_ctypes.c_ttag_t)
# BigTIFF 64-bit unsigned integer
libtiff_ctypes.TIFFDataType.TIFF_LONG8 = 16
# BigTIFF 64-bit signed integer
libtiff_ctypes.TIFFDataType.TIFF_SLONG8 = 17
# BigTIFF 64-bit unsigned integer (offset)
libtiff_ctypes.TIFFDataType.TIFF_IFD8 = 18
# Some versions of pylibtiff specify an argtypes where they shouldn't
libtiff_ctypes.libtiff.TIFFGetField.argtypes = None
patchLibtiff()
[docs]
class TiledTiffDirectory:
CoreFunctions = [
'SetDirectory', 'SetSubDirectory', 'GetField',
'LastDirectory', 'GetMode', 'IsTiled', 'IsByteSwapped', 'IsUpSampled',
'IsMSB2LSB', 'NumberOfStrips',
]
def __init__(self, filePath, directoryNum, mustBeTiled=True, subDirectoryNum=0, validate=True):
"""
Create a new reader for a tiled image file directory in a TIFF file.
:param filePath: A path to a TIFF file on disk.
:type filePath: str
:param directoryNum: The number of the TIFF image file directory to
open.
:type directoryNum: int
:param mustBeTiled: if True, only tiled images validate. If False,
only non-tiled images validate. None validates both.
:type mustBeTiled: bool
:param subDirectoryNum: if set, the number of the TIFF subdirectory.
:type subDirectoryNum: int
:param validate: if False, don't validate that images can be read.
:type mustBeTiled: bool
:raises: InvalidOperationTiffError or IOTiffError or
ValidationTiffError
"""
self.logger = config.getLogger()
# create local cache to store Jpeg tables and getTileByteCountsType
self.cache = cachetools.LRUCache(10)
self._mustBeTiled = mustBeTiled
self._tiffFile = None
self._tileLock = threading.RLock()
self._open(filePath, directoryNum, subDirectoryNum)
try:
self._loadMetadata()
except Exception:
self.logger.exception('Could not parse tiff metadata')
raise IOOpenTiffError(
'Could not open TIFF file: %s' % filePath)
self.logger.debug(
'TiffDirectory %d:%d Information %r',
directoryNum, subDirectoryNum or 0, self._tiffInfo)
try:
if validate:
self._validate()
except ValidationTiffError:
self._close()
raise
def __del__(self):
self._close()
def _open(self, filePath, directoryNum, subDirectoryNum=0):
"""
Open a TIFF file to a given file and IFD number.
:param filePath: A path to a TIFF file on disk.
:type filePath: str
:param directoryNum: The number of the TIFF IFD to be used.
:type directoryNum: int
:param subDirectoryNum: The number of the TIFF sub-IFD to be used.
:type subDirectoryNum: int
:raises: InvalidOperationTiffError or IOTiffError
"""
self._close()
if not os.path.isfile(filePath):
raise InvalidOperationTiffError(
'TIFF file does not exist: %s' % filePath)
try:
bytePath = filePath
if not isinstance(bytePath, bytes):
bytePath = filePath.encode()
self._tiffFile = libtiff_ctypes.TIFF.open(bytePath)
except TypeError:
raise IOOpenTiffError(
'Could not open TIFF file: %s' % filePath)
# pylibtiff changed the case of some functions between version 0.4 and
# the version that supports libtiff 4.0.6. To support both, ensure
# that the cased functions exist.
for func in self.CoreFunctions:
if (not hasattr(self._tiffFile, func) and
hasattr(self._tiffFile, func.lower())):
setattr(self._tiffFile, func, getattr(
self._tiffFile, func.lower()))
self._setDirectory(directoryNum, subDirectoryNum)
def _setDirectory(self, directoryNum, subDirectoryNum=0):
self._directoryNum = directoryNum
if self._tiffFile.SetDirectory(self._directoryNum) != 1:
self._tiffFile.close()
raise IOTiffError(
'Could not set TIFF directory to %d' % directoryNum)
self._subDirectoryNum = subDirectoryNum
if self._subDirectoryNum:
subifds = self._tiffFile.GetField('subifd')
if (subifds is None or self._subDirectoryNum < 1 or
self._subDirectoryNum > len(subifds)):
raise IOTiffError(
'Could not set TIFF subdirectory to %d' % subDirectoryNum)
subifd = subifds[self._subDirectoryNum - 1]
if self._tiffFile.SetSubDirectory(subifd) != 1:
self._tiffFile.close()
raise IOTiffError(
'Could not set TIFF subdirectory to %d' % subDirectoryNum)
def _close(self):
if self._tiffFile:
self._tiffFile.close()
self._tiffFile = None
def _validate(self): # noqa
"""
Validate that this TIFF file and directory are suitable for reading.
:raises: ValidationTiffError
"""
if not self._mustBeTiled:
if self._mustBeTiled is not None and self._tiffInfo.get('istiled'):
msg = 'Expected a non-tiled TIFF file'
raise ValidationTiffError(msg)
# For any non-supported file, we probably can add a conversion task in
# the create_image.py script, such as flatten or colourspace. These
# should only be done if necessary, which would require the conversion
# job to check output and perform subsequent processing as needed.
if (not self._tiffInfo.get('samplesperpixel', 1) or
self._tiffInfo.get('samplesperpixel', 1) < 1):
msg = 'Only RGB and greyscale TIFF files are supported'
raise ValidationTiffError(msg)
if self._tiffInfo.get('bitspersample') not in (8, 16, 32, 64):
msg = 'Only 8 and 16 bits-per-sample TIFF files are supported'
raise ValidationTiffError(msg)
if self._tiffInfo.get('sampleformat') not in {
None, # default is still SAMPLEFORMAT_UINT
libtiff_ctypes.SAMPLEFORMAT_UINT,
libtiff_ctypes.SAMPLEFORMAT_INT,
libtiff_ctypes.SAMPLEFORMAT_IEEEFP}:
msg = 'Only unsigned int sampled TIFF files are supported'
raise ValidationTiffError(msg)
if (self._tiffInfo.get('planarconfig') != libtiff_ctypes.PLANARCONFIG_CONTIG and
self._tiffInfo.get('photometric') not in {
libtiff_ctypes.PHOTOMETRIC_MINISBLACK}):
msg = 'Only contiguous planar configuration TIFF files are supported'
raise ValidationTiffError(msg)
if self._tiffInfo.get('photometric') not in {
libtiff_ctypes.PHOTOMETRIC_MINISBLACK,
libtiff_ctypes.PHOTOMETRIC_RGB,
libtiff_ctypes.PHOTOMETRIC_YCBCR}:
msg = ('Only greyscale (black is 0), RGB, and YCbCr photometric '
'interpretation TIFF files are supported')
raise ValidationTiffError(msg)
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}:
msg = 'Unsupported TIFF orientation'
raise ValidationTiffError(msg)
if self._mustBeTiled and (
not self._tiffInfo.get('istiled') or
not self._tiffInfo.get('tilewidth') or
not self._tiffInfo.get('tilelength')):
msg = 'A tiled TIFF is required.'
raise ValidationTiffError(msg)
if self._mustBeTiled is False and (
self._tiffInfo.get('istiled') or
not self._tiffInfo.get('rowsperstrip')):
msg = 'A non-tiled TIFF with strips is required.'
raise ValidationTiffError(msg)
if (self._tiffInfo.get('compression') == libtiff_ctypes.COMPRESSION_JPEG and
self._tiffInfo.get('jpegtablesmode') !=
libtiff_ctypes.JPEGTABLESMODE_QUANT |
libtiff_ctypes.JPEGTABLESMODE_HUFF):
msg = 'Only TIFF files with separate Huffman and quantization tables are supported'
raise ValidationTiffError(msg)
if self._tiffInfo.get('compression') == libtiff_ctypes.COMPRESSION_JPEG:
try:
self._getJpegTables()
except IOTiffError:
self._completeJpeg = True
def _loadMetadata(self):
fields = [key.split('_', 1)[1].lower() for key in
dir(libtiff_ctypes.tiff_h) if key.startswith('TIFFTAG_')]
info = {}
for field in fields:
try:
value = self._tiffFile.GetField(field)
if value is not None:
info[field] = value
except TypeError as err:
self.logger.debug(
'Loading field "%s" in directory number %d resulted in TypeError - "%s"',
field, self._directoryNum, err)
for func in self.CoreFunctions[3:]:
if hasattr(self._tiffFile, func):
value = getattr(self._tiffFile, func)()
if value:
info[func.lower()] = value
self._tiffInfo = info
self._tileWidth = info.get('tilewidth') or info.get('imagewidth')
self._tileHeight = info.get('tilelength') or info.get('rowsperstrip')
self._imageWidth = info.get('imagewidth')
self._imageHeight = info.get('imagelength')
self._tilesAcross = (self._imageWidth + self._tileWidth - 1) // self._tileWidth
if not info.get('tilelength'):
self._stripsPerTile = int(max(1, math.ceil(256.0 / self._tileHeight)))
self._stripHeight = self._tileHeight
self._tileHeight = self._stripHeight * self._stripsPerTile
self._stripCount = int(math.ceil(float(self._imageHeight) / self._stripHeight))
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}
# If the resolution value is less than a threshold (100), don't use it,
# as it is probably just an inaccurate default. Values like 72dpi and
# 96dpi are common defaults, but so are small metric values, too.
if (not self._pixelInfo.get('mm_x') and info.get('xresolution') and
units.get(info.get('resolutionunit')) and
info.get('xresolution') >= 100):
self._pixelInfo['mm_x'] = units[info['resolutionunit']] / info['xresolution']
if (not self._pixelInfo.get('mm_y') and info.get('yresolution') and
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 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):
"""
Get the common JPEG Huffman-coding and quantization tables.
See http://www.awaresystems.be/imaging/tiff/tifftags/jpegtables.html
for more information.
:return: All Huffman and quantization tables, with JPEG table start
markers.
:rtype: bytes
:raises: Exception
"""
# TIFFTAG_JPEGTABLES uses (uint32*, void**) output arguments
# http://www.remotesensing.org/libtiff/man/TIFFGetField.3tiff.html
tableSize = ctypes.c_uint32()
tableBuffer = ctypes.c_voidp()
# Some versions of pylibtiff set an explicit list of argtypes for
# TIFFGetField. When this is done, we need to adjust them to match
# what is needed for our specific call. Other versions do not set
# argtypes, allowing any types to be passed without validation, in
# which case we do not need to alter the list.
if libtiff_ctypes.libtiff.TIFFGetField.argtypes:
libtiff_ctypes.libtiff.TIFFGetField.argtypes = \
libtiff_ctypes.libtiff.TIFFGetField.argtypes[:2] + \
[ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_void_p)]
if libtiff_ctypes.libtiff.TIFFGetField(
self._tiffFile,
libtiff_ctypes.TIFFTAG_JPEGTABLES,
ctypes.byref(tableSize),
ctypes.byref(tableBuffer)) != 1:
msg = 'Could not get JPEG Huffman / quantization tables'
raise IOTiffError(msg)
tableSize = tableSize.value
tableBuffer = ctypes.cast(tableBuffer, ctypes.POINTER(ctypes.c_char))
if tableBuffer[:2] != b'\xff\xd8':
msg = 'Missing JPEG Start Of Image marker in tables'
raise IOTiffError(msg)
if tableBuffer[tableSize - 2:tableSize] != b'\xff\xd9':
msg = 'Missing JPEG End Of Image marker in tables'
raise IOTiffError(msg)
if tableBuffer[2:4] not in (b'\xff\xc4', b'\xff\xdb'):
msg = 'Missing JPEG Huffman or Quantization Table marker'
raise IOTiffError(msg)
# Strip the Start / End Of Image markers
tableData = tableBuffer[2:tableSize - 2]
return tableData
def _toTileNum(self, x, y, transpose=False):
"""
Get the internal tile number of a tile, from its row and column index.
:param x: The column index of the desired tile.
:type x: int
:param y: The row index of the desired tile.
:type y: int
:param transpose: If true, transpose width and height
:type transpose: boolean
:return: The internal tile number of the desired tile.
:rtype int
:raises: InvalidOperationTiffError
"""
# TIFFCheckTile and TIFFComputeTile require pixel coordinates
if not transpose:
pixelX = int(x * self._tileWidth)
pixelY = int(y * self._tileHeight)
if x < 0 or y < 0 or pixelX >= self._imageWidth or pixelY >= self._imageHeight:
raise InvalidOperationTiffError(
'Tile x=%d, y=%d does not exist' % (x, y))
else:
pixelX = int(x * self._tileHeight)
pixelY = int(y * self._tileWidth)
if x < 0 or y < 0 or pixelX >= self._imageHeight or pixelY >= self._imageWidth:
raise InvalidOperationTiffError(
'Tile x=%d, y=%d does not exist' % (x, y))
# We had been using TIFFCheckTile, but with z=0 and sample=0, this is
# just a check that x, y is within the image
# if libtiff_ctypes.libtiff.TIFFCheckTile(
# self._tiffFile, pixelX, pixelY, 0, 0) == 0:
# raise InvalidOperationTiffError(
# 'Tile x=%d, y=%d does not exist' % (x, y))
if self._tiffInfo.get('istiled'):
tileNum = pixelX // self._tileWidth + (pixelY // self._tileHeight) * self._tilesAcross
else:
# TIFFComputeStrip with sample=0 is just the row divided by the
# strip height
tileNum = pixelY // self._stripHeight
return tileNum
@methodcache(key=partial(strhash, '_getTileByteCountsType'))
def _getTileByteCountsType(self):
"""
Get data type of the elements in the TIFFTAG_TILEBYTECOUNTS array.
:return: The element type in TIFFTAG_TILEBYTECOUNTS.
:rtype: ctypes.c_uint64 or ctypes.c_uint16
:raises: IOTiffError
"""
tileByteCountsFieldInfo = libtiff_ctypes.libtiff.TIFFFieldWithTag(
self._tiffFile, libtiff_ctypes.TIFFTAG_TILEBYTECOUNTS).contents
tileByteCountsLibtiffType = tileByteCountsFieldInfo.field_type
if tileByteCountsLibtiffType == libtiff_ctypes.TIFFDataType.TIFF_LONG8:
return ctypes.c_uint64
if tileByteCountsLibtiffType == \
libtiff_ctypes.TIFFDataType.TIFF_SHORT:
return ctypes.c_uint16
raise IOTiffError(
'Invalid type for TIFFTAG_TILEBYTECOUNTS: %s' % tileByteCountsLibtiffType)
def _getJpegFrameSize(self, tileNum):
"""
Get the file size in bytes of the raw encoded JPEG frame for a tile.
:param tileNum: The internal tile number of the desired tile.
:type tileNum: int
:return: The size in bytes of the raw tile data for the desired tile.
:rtype: int
:raises: InvalidOperationTiffError or IOTiffError
"""
# TODO: is it worth it to memoize this?
# TODO: remove this check, for additional speed
totalTileCount = libtiff_ctypes.libtiff.TIFFNumberOfTiles(
self._tiffFile).value
if tileNum >= totalTileCount:
msg = 'Tile number out of range'
raise InvalidOperationTiffError(msg)
# pylibtiff treats the output of TIFFTAG_TILEBYTECOUNTS as a scalar
# uint32; libtiff's documentation specifies that the output will be an
# array of uint32; in reality and per the TIFF spec, the output is an
# array of either uint64 or unit16, so we need to call the ctypes
# interface directly to get this tag
# http://www.awaresystems.be/imaging/tiff/tifftags/tilebytecounts.html
rawTileSizesType = self._getTileByteCountsType()
rawTileSizes = ctypes.POINTER(rawTileSizesType)()
# Some versions of pylibtiff set an explicit list of argtypes for
# TIFFGetField. When this is done, we need to adjust them to match
# what is needed for our specific call. Other versions do not set
# argtypes, allowing any types to be passed without validation, in
# which case we do not need to alter the list.
if libtiff_ctypes.libtiff.TIFFGetField.argtypes:
libtiff_ctypes.libtiff.TIFFGetField.argtypes = \
libtiff_ctypes.libtiff.TIFFGetField.argtypes[:2] + \
[ctypes.POINTER(ctypes.POINTER(rawTileSizesType))]
if libtiff_ctypes.libtiff.TIFFGetField(
self._tiffFile,
libtiff_ctypes.TIFFTAG_TILEBYTECOUNTS,
ctypes.byref(rawTileSizes)) != 1:
msg = 'Could not get raw tile size'
raise IOTiffError(msg)
# In practice, this will never overflow, and it's simpler to convert the
# long to an int
return int(rawTileSizes[tileNum])
def _getJpegFrame(self, tileNum, entire=False): # noqa
"""
Get the raw encoded JPEG image frame from a tile.
:param tileNum: The internal tile number of the desired tile.
:type tileNum: int
:param entire: True to return the entire frame. False to strip off
container information.
:return: The JPEG image frame, including a JPEG Start Of Frame marker.
:rtype: bytes
:raises: InvalidOperationTiffError or IOTiffError
"""
# This raises an InvalidOperationTiffError if the tile doesn't exist
rawTileSize = self._getJpegFrameSize(tileNum)
if rawTileSize <= 0:
msg = 'No raw tile data'
raise IOTiffError(msg)
frameBuffer = ctypes.create_string_buffer(rawTileSize)
bytesRead = libtiff_ctypes.libtiff.TIFFReadRawTile(
self._tiffFile, tileNum,
frameBuffer, rawTileSize).value
if bytesRead == -1:
msg = 'Failed to read raw tile'
raise IOTiffError(msg)
if bytesRead < rawTileSize:
msg = 'Buffer underflow when reading tile'
raise IOTiffError(msg)
if bytesRead > rawTileSize:
# It's unlikely that this will ever occur, but incomplete reads will
# be checked for by looking for the JPEG end marker
msg = 'Buffer overflow when reading tile'
raise IOTiffError(msg)
if entire:
return frameBuffer.raw[:]
if frameBuffer.raw[:2] != b'\xff\xd8':
msg = 'Missing JPEG Start Of Image marker in frame'
raise IOTiffError(msg)
if frameBuffer.raw[-2:] != b'\xff\xd9':
msg = 'Missing JPEG End Of Image marker in frame'
raise IOTiffError(msg)
if frameBuffer.raw[2:4] in (b'\xff\xc0', b'\xff\xc2'):
frameStartPos = 2
else:
# VIPS may encode TIFFs with the quantization (but not Huffman)
# tables also at the start of every frame, so locate them for
# removal
# VIPS seems to prefer Baseline DCT, so search for that first
frameStartPos = frameBuffer.raw.find(b'\xff\xc0', 2, -2)
if frameStartPos == -1:
frameStartPos = frameBuffer.raw.find(b'\xff\xc2', 2, -2)
if frameStartPos == -1:
msg = 'Missing JPEG Start Of Frame marker'
raise IOTiffError(msg)
# If the photometric value is RGB and the JPEG component ids are just
# 0, 1, 2, change the component ids to R, G, B to ensure color space
# information is preserved.
if self._tiffInfo.get('photometric') == libtiff_ctypes.PHOTOMETRIC_RGB:
sof = frameBuffer.raw.find(b'\xff\xc0')
if sof == -1:
sof = frameBuffer.raw.find(b'\xff\xc2')
sos = frameBuffer.raw.find(b'\xff\xda')
if (sof >= frameStartPos and sos >= frameStartPos and
frameBuffer[sof + 2:sof + 4] == b'\x00\x11' and
frameBuffer[sof + 10:sof + 19:3] == b'\x00\x01\x02' and
frameBuffer[sos + 5:sos + 11:2] == b'\x00\x01\x02'):
for idx, val in enumerate(b'RGB'):
frameBuffer[sof + 10 + idx * 3] = val
frameBuffer[sos + 5 + idx * 2] = val
# Strip the Start / End Of Image markers
tileData = frameBuffer.raw[frameStartPos:-2]
return tileData
def _getUncompressedTile(self, tileNum):
"""
Get an uncompressed tile or strip.
:param tileNum: The internal tile or strip number of the desired tile
or strip.
:type tileNum: int
:return: the tile as a PIL 8-bit-per-channel images.
:rtype: PIL.Image
:raises: IOTiffError
"""
if self._tiffInfo.get('istiled'):
if not hasattr(self, '_uncompressedTileSize'):
with self._tileLock:
self._uncompressedTileSize = libtiff_ctypes.libtiff.TIFFTileSize(
self._tiffFile).value
tileSize = self._uncompressedTileSize
else:
with self._tileLock:
stripSize = libtiff_ctypes.libtiff.TIFFStripSize(
self._tiffFile).value
stripsCount = min(self._stripsPerTile, self._stripCount - tileNum)
tileSize = stripSize * self._stripsPerTile
tw, th = self._tileWidth, self._tileHeight
if self._tiffInfo.get('orientation') in {
libtiff_ctypes.ORIENTATION_LEFTTOP,
libtiff_ctypes.ORIENTATION_RIGHTTOP,
libtiff_ctypes.ORIENTATION_RIGHTBOT,
libtiff_ctypes.ORIENTATION_LEFTBOT}:
tw, th = th, tw
format = (
self._tiffInfo.get('bitspersample'),
self._tiffInfo.get('sampleformat') if self._tiffInfo.get(
'sampleformat') is not None else libtiff_ctypes.SAMPLEFORMAT_UINT)
image = np.empty((th, tw, self._tiffInfo.get('samplesperpixel', 1)),
dtype=_ctypesFormattbl[format])
imageBuffer = image.ctypes.data_as(ctypes.POINTER(ctypes.c_char))
if self._tiffInfo.get('istiled'):
with self._tileLock:
readSize = libtiff_ctypes.libtiff.TIFFReadEncodedTile(
self._tiffFile, tileNum, imageBuffer, tileSize)
else:
readSize = 0
imageBuffer = ctypes.cast(imageBuffer, ctypes.POINTER(ctypes.c_char * 2)).contents
for stripNum in range(stripsCount):
with self._tileLock:
chunkSize = libtiff_ctypes.libtiff.TIFFReadEncodedStrip(
self._tiffFile,
tileNum + stripNum,
ctypes.byref(imageBuffer, stripSize * stripNum),
stripSize).value
if chunkSize <= 0:
msg = 'Read an unexpected number of bytes from an encoded strip'
raise IOTiffError(msg)
readSize += chunkSize
if readSize < tileSize:
ctypes.memset(ctypes.byref(imageBuffer, readSize), 0, tileSize - readSize)
readSize = tileSize
if readSize < tileSize:
raise IOTiffError(
'Read an unexpected number of bytes from an encoded tile' if readSize >= 0 else
'Failed to read from an encoded tile')
if (self._tiffInfo.get('samplesperpixel', 1) == 3 and
self._tiffInfo.get('photometric') == libtiff_ctypes.PHOTOMETRIC_YCBCR):
if self._tiffInfo.get('bitspersample') == 16:
image = np.floor_divide(image, 256).astype(np.uint8)
image = PIL.Image.fromarray(image, 'YCbCr')
image = np.array(image.convert('RGB'))
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 tile is None:
tile = np.zeros(
(th, tw) if len(subtile.shape) == 2 else
(th, tw, subtile.shape[2]), dtype=subtile.dtype)
stx, sty = tx * tw - x0, ty * th - y0
if (stx >= tw or stx + subtile.shape[1] <= 0 or
sty >= th or sty + subtile.shape[0] <= 0):
continue
if stx < 0:
subtile = subtile[:, -stx:]
stx = 0
if sty < 0:
subtile = subtile[-sty:, :]
sty = 0
subtile = subtile[:min(subtile.shape[0], th - sty),
:min(subtile.shape[1], tw - stx)]
tile[sty:sty + subtile.shape[0], stx:stx + subtile.shape[1]] = subtile
if tile is None:
raise InvalidOperationTiffError(
'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[::-1, :]
if self._tiffInfo.get('orientation') in {
libtiff_ctypes.ORIENTATION_TOPRIGHT,
libtiff_ctypes.ORIENTATION_BOTRIGHT,
libtiff_ctypes.ORIENTATION_RIGHTTOP,
libtiff_ctypes.ORIENTATION_RIGHTBOT}:
tile = tile[:, ::-1]
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((1, 0) if len(tile.shape) == 2 else (1, 0, 2))
return tile
@property
def tileWidth(self):
"""
Get the pixel width of tiles.
:return: The tile width in pixels.
:rtype: int
"""
return self._tileWidth
@property
def tileHeight(self):
"""
Get the pixel height of tiles.
:return: The tile height in pixels.
:rtype: int
"""
return self._tileHeight
@property
def imageWidth(self):
return self._imageWidth
@property
def imageHeight(self):
return self._imageHeight
@property
def pixelInfo(self):
return self._pixelInfo
[docs]
def getTile(self, x, y, asarray=False):
"""
Get the complete JPEG image from a tile.
:param x: The column index of the desired tile.
:type x: int
:param y: The row index of the desired tile.
:type y: int
:param asarray: If True, read jpeg compressed images as arrays.
:type asarray: boolean
:return: either a buffer with a JPEG or a PIL image.
:rtype: bytes
:raises: InvalidOperationTiffError or IOTiffError
"""
if self._tiffInfo.get('orientation') not in {
libtiff_ctypes.ORIENTATION_TOPLEFT,
None}:
return self._getTileRotated(x, y)
# This raises an InvalidOperationTiffError if the tile doesn't exist
tileNum = self._toTileNum(x, y)
if (not self._tiffInfo.get('istiled') or
self._tiffInfo.get('compression') not in {
libtiff_ctypes.COMPRESSION_JPEG, 33003, 33004, 33005, 34712} or
self._tiffInfo.get('bitspersample') != 8 or
self._tiffInfo.get('sampleformat') not in {
None, libtiff_ctypes.SAMPLEFORMAT_UINT} or
(asarray and self._tiffInfo.get('compression') not in {
33003, 33004, 33005, 34712,
} and (
self._tiffInfo.get('compression') != libtiff_ctypes.COMPRESSION_JPEG or
self._tiffInfo.get('photometric') != libtiff_ctypes.PHOTOMETRIC_YCBCR))):
try:
return self._getUncompressedTile(tileNum)
except Exception:
pass
imageBuffer = io.BytesIO()
if (self._tiffInfo.get('compression') == libtiff_ctypes.COMPRESSION_JPEG and
not getattr(self, '_completeJpeg', False)):
# Write JPEG Start Of Image marker
imageBuffer.write(b'\xff\xd8')
imageBuffer.write(self._getJpegTables())
imageBuffer.write(self._getJpegFrame(tileNum))
# Write JPEG End Of Image marker
imageBuffer.write(b'\xff\xd9')
return imageBuffer.getvalue()
# Get the whole frame, which is in a JPEG or JPEG 2000 format
frame = self._getJpegFrame(tileNum, True)
# For JP2K, see if we can convert it faster than PIL
if self._tiffInfo.get('compression') in {33003, 33004, 33005, 34712}:
try:
import openjpeg
return openjpeg.decode(frame)
except Exception:
pass
# convert it to a PIL image
imageBuffer.write(frame)
image = PIL.Image.open(imageBuffer)
# Converting the image mode ensures that it gets loaded once and is in
# a form we expect. If this isn't done, then PIL can load the image
# multiple times, which sometimes throws an exception in PIL's JPEG
# 2000 module.
if image.mode != 'L':
image = image.convert('RGB')
else:
image.load()
return image
[docs]
def parse_image_description(self, meta=None): # noqa
self._pixelInfo = {}
self._embeddedImages = {}
if not meta:
return None
if not isinstance(meta, str):
meta = meta.decode(errors='ignore')
try:
parsed = json.loads(meta)
if isinstance(parsed, dict):
self._description_record = parsed
return True
except Exception:
pass
try:
xml = ElementTree.fromstring(meta)
except Exception:
if 'AppMag = ' in meta:
try:
self._pixelInfo = {
'magnification': float(meta.split('AppMag = ')[1].split('|')[0].strip()),
}
self._pixelInfo['mm_x'] = self._pixelInfo['mm_y'] = float(
meta.split('|MPP = ', 1)[1].split('|')[0].strip()) * 0.001
except Exception:
pass
return None
try:
image = xml.find(
".//DataObject[@ObjectType='DPScannedImage']")
columns = int(image.find(".//*[@Name='PIM_DP_IMAGE_COLUMNS']").text)
rows = int(image.find(".//*[@Name='PIM_DP_IMAGE_ROWS']").text)
spacing = [float(val.strip('"')) for val in image.find(
".//*[@Name='DICOM_PIXEL_SPACING']").text.split()]
self._pixelInfo = {
'width': columns,
'height': rows,
'mm_x': spacing[0],
'mm_y': spacing[1],
}
except Exception:
pass
# Extract macro and label images
for image in xml.findall(".//*[@ObjectType='DPScannedImage']"):
try:
typestr = image.find(".//*[@Name='PIM_DP_IMAGE_TYPE']").text
datastr = image.find(".//*[@Name='PIM_DP_IMAGE_DATA']").text
except Exception:
continue
if not typestr or not datastr:
continue
typemap = {
'LABELIMAGE': 'label',
'MACROIMAGE': 'macro',
'WSI': 'thumbnail',
}
self._embeddedImages[typemap.get(typestr, typestr.lower())] = datastr
with contextlib.suppress(Exception):
self._description_record = etreeToDict(xml)
return True
[docs]
def read_image(self):
"""
Use the underlying _tiffFile to read an image. But, if it is in a jp2k
encoding, read the raw data and convert it.
"""
if self._tiffInfo.get('compression') not in {33003, 33004, 33005, 34712}:
return self._tiffFile.read_image()
output = None
for yidx, y in enumerate(range(0, self.imageHeight, self.tileHeight)):
for xidx, x in enumerate(range(0, self.imageWidth, self.tileWidth)):
tile = self.getTile(xidx, yidx, asarray=True)
if len(tile.shape) == 2:
tile = tile[:, :, np.newaxis]
if output is None:
output = np.zeros(
(self.imageHeight, self.imageWidth, tile.shape[2]), dtype=tile.dtype)
if tile.shape[0] > self.imageHeight - y:
tile = tile[:self.imageHeight - y, :, :]
if tile.shape[1] > self.imageWidth - x:
tile = tile[:, :self.imageWidth - x, :]
output[y:y + tile.shape[0], x:x + tile.shape[1], :] = tile
return output