###############################################################################
# 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 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
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
elif tileByteCountsLibtiffType == \
libtiff_ctypes.TIFFDataType.TIFF_SHORT:
return ctypes.c_uint16
else:
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)
elif bytesRead < rawTileSize:
msg = 'Buffer underflow when reading tile'
raise IOTiffError(msg)
elif 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, 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, 33005, 34712} and (
self._tiffInfo.get('compression') != libtiff_ctypes.COMPRESSION_JPEG or
self._tiffInfo.get('photometric') != libtiff_ctypes.PHOTOMETRIC_YCBCR))):
return self._getUncompressedTile(tileNum)
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, 33005}:
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
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
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
try:
self._description_record = etreeToDict(xml)
except Exception:
pass
return True