Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Read some untiled tiffs using the tiff source #1512

Merged
merged 1 commit into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
## 1.28.2

### Improvements
- Improve uint16 image scaling ([#1511](../../pull/1511))
- Improve uint16 image scaling ([#1511](../../pull/1511))
- Read some untiled tiffs using the tiff source ([#1512](../../pull/1512))

## 1.28.1

Expand Down
51 changes: 42 additions & 9 deletions sources/tiff/large_image_source_tiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class TiffFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
}

_maxAssociatedImageSize = 8192
_maxUntiledImage = 4096

def __init__(self, path, **kwargs): # noqa
"""
Expand All @@ -85,18 +86,18 @@ def __init__(self, path, **kwargs): # noqa

self._largeImagePath = str(self._getLargeImagePath())

lastException = None
try:
self._initWithTiffTools()
return
except Exception as exc:
self.logger.debug('Cannot read with tifftools route; %r', exc)
lastException = exc

alldir = []
try:
if hasattr(self, '_info'):
alldir = self._scanDirectories()
else:
lastException = 'Could not parse file with tifftools'
except IOOpenTiffError:
msg = 'File cannot be opened via tiff source.'
raise TileSourceError(msg)
Expand Down Expand Up @@ -157,7 +158,7 @@ def __init__(self, path, **kwargs): # noqa
tifftools.constants.SampleFormat[sampleformat or 1].name,
bitspersample,
))
self._bandCount = highest._tiffInfo.get('samplesperpixel')
self._bandCount = highest._tiffInfo.get('samplesperpixel', 1)
# Sort the directories so that the highest resolution is the last one;
# if a level is missing, put a None value in its place.
self._tiffDirectories = [directories.get(key) for key in
Expand Down Expand Up @@ -252,8 +253,13 @@ def _levelFromIfd(self, ifd, baseifd):
"""
sizeX = ifd['tags'][tifftools.Tag.ImageWidth.value]['data'][0]
sizeY = ifd['tags'][tifftools.Tag.ImageLength.value]['data'][0]
tileWidth = baseifd['tags'][tifftools.Tag.TileWidth.value]['data'][0]
tileHeight = baseifd['tags'][tifftools.Tag.TileLength.value]['data'][0]
if tifftools.Tag.TileWidth.value in baseifd['tags']:
tileWidth = baseifd['tags'][tifftools.Tag.TileWidth.value]['data'][0]
tileHeight = baseifd['tags'][tifftools.Tag.TileLength.value]['data'][0]
else:
tileWidth = sizeX
tileHeight = baseifd['tags'][tifftools.Tag.RowsPerStrip.value]['data'][0]

for tag in {
tifftools.Tag.SamplesPerPixel.value,
tifftools.Tag.BitsPerSample.value,
Expand Down Expand Up @@ -298,7 +304,7 @@ def _initWithTiffTools(self): # noqa
directories are the same size and format; all non-tiled directories are
treated as associated images.
"""
dir0 = self.getTiffDir(0)
dir0 = self.getTiffDir(0, mustBeTiled=None)
self.tileWidth = dir0.tileWidth
self.tileHeight = dir0.tileHeight
self.sizeX = dir0.imageWidth
Expand All @@ -312,12 +318,11 @@ def _initWithTiffTools(self): # noqa
tifftools.constants.SampleFormat[sampleformat or 1].name,
bitspersample,
))
self._bandCount = dir0._tiffInfo.get('samplesperpixel')
self._bandCount = dir0._tiffInfo.get('samplesperpixel', 1)
info = _cached_read_tiff(self._largeImagePath)
self._info = info
frames = []
associated = [] # for now, a list of directories
curframe = -1
for idx, ifd in enumerate(info['ifds']):
# if not tiles, add to associated images
if tifftools.Tag.tileWidth.value not in ifd['tags']:
Expand All @@ -326,7 +331,6 @@ def _initWithTiffTools(self): # noqa
level = self._levelFromIfd(ifd, info['ifds'][0])
# if the same resolution as the main image, add a frame
if level == self.levels - 1:
curframe += 1
frames.append({'dirs': [None] * self.levels})
frames[-1]['dirs'][-1] = (idx, 0)
try:
Expand Down Expand Up @@ -365,6 +369,35 @@ def _initWithTiffTools(self): # noqa
else:
msg = 'Tile layers are in a surprising order'
raise TileSourceError(msg)
# If we have a single untiled ifd that is "small", use it
if tifftools.Tag.tileWidth.value not in info['ifds'][0]['tags']:
if (
self.sizeX > self._maxUntiledImage or self.sizeY > self._maxUntiledImage or
(len(info['ifds']) != 1 or tifftools.Tag.SubIfd.value in ifd['tags']) or
(tifftools.Tag.ImageDescription.value in ifd['tags'] and
'ImageJ' in ifd['tags'][tifftools.Tag.ImageDescription.value]['data'])
):
msg = 'A tiled TIFF is required.'
raise ValidationTiffError(msg)
associated = []
level = self._levelFromIfd(ifd, info['ifds'][0])
frames.append({'dirs': [None] * self.levels})
frames[-1]['dirs'][-1] = (idx, 0)
try:
frameMetadata = json.loads(
ifd['tags'][tifftools.Tag.ImageDescription.value]['data'])
for key in {'channels', 'frame'}:
if key in frameMetadata:
frames[-1][key] = frameMetadata[key]
except Exception:
pass
if tifftools.Tag.ICCProfile.value in ifd['tags']:
if not hasattr(self, '_iccprofiles'):
self._iccprofiles = []
while len(self._iccprofiles) < len(frames) - 1:
self._iccprofiles.append(None)
self._iccprofiles.append(ifd['tags'][
tifftools.Tag.ICCProfile.value]['data'])
self._associatedImages = {}
for dirNum in associated:
self._addAssociatedImage(dirNum)
Expand Down
8 changes: 4 additions & 4 deletions sources/tiff/large_image_source_tiff/tiff_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ def _validate(self): # noqa
# 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') or
self._tiffInfo.get('samplesperpixel') < 1):
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)

Expand Down Expand Up @@ -607,7 +607,7 @@ def _getUncompressedTile(self, tileNum):
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['samplesperpixel']),
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'):
Expand Down Expand Up @@ -635,7 +635,7 @@ def _getUncompressedTile(self, tileNum):
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') == 3 and
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)
Expand Down
2 changes: 2 additions & 0 deletions test/datastore.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@
'a131592c-a069-4aa7-8031-398654aa8a3d.dcm': 'sha512:99bd3da4b8e11ce7b4f7ed8a294ed0c37437320667a06c40c383f4b29be85fe8e6094043e0600bee0ba879f2401de4c57285800a4a23da2caf2eb94e5b847ee0', # noqa
# Synthetic newer ndpi with binary data and nonblank image labelled as RGB
'synthetic_ndpi_2024.ndpi': 'sha512:192cdcf551a824277ef70358b8ed6225dca0c5b5d0817fe0d800b72638e0ad9416cd5bc40cb186219da60fd324b676d1b32cc05a276a33b71b485d665a31e42e', # noqa
# Synthetic uint16 untiled tiff that can be read with the tiff source
'synthetic_untiled_16.tiff': 'sha512:f4773fcfa749ba9c2db25319c9e8ad8586dd148de4366dae0393a3703906dace9f11233eafdb24418b598170d6372ef1ca861bf8d7a8212cac21a0eb8636ee77', # noqa
}


Expand Down
8 changes: 3 additions & 5 deletions test/test_source_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
'openjpeg': {'read': r'\.(jp2)$'},
'openslide': {
'read': r'\.(ptif|svs|ndpi|tif.*|qptiff|dcm)$',
'noread': r'(oahu|DDX58_AXL|huron\.image2_jpeg2k|landcover_sample|d042-353\.crop|US_Geo\.|extraoverview|imagej|bad_axes)', # noqa
'noread': r'(oahu|DDX58_AXL|huron\.image2_jpeg2k|landcover_sample|d042-353\.crop|US_Geo\.|extraoverview|imagej|bad_axes|synthetic_untiled)', # noqa
'skip': r'nokeyframe\.ome\.tiff$',
'skipTiles': r'one_layer_missing',
},
Expand All @@ -78,9 +78,7 @@
'test': {'any': True, 'skipTiles': r''},
'tiff': {
'read': r'(\.(ptif|scn|svs|tif.*|qptiff)|[-0-9a-f]{36}\.dcm)$',
'noread': r'(oahu|DDX58_AXL|G10-3_pelvis_crop|'
r'd042-353\.crop\.small\.float|landcover_sample|US_Geo\.|'
r'imagej|bad_axes|nokeyframe\.ome\.tiff$)',
'noread': r'(DDX58_AXL|G10-3_pelvis_crop|landcover_sample|US_Geo\.|imagej)',
'skipTiles': r'(sample_image\.ptif|one_layer_missing_tiles)'},
'tifffile': {
'read': r'',
Expand All @@ -91,7 +89,7 @@
'vips': {
'read': r'',
'noread': r'\.(nc|nd2|yml|yaml|json|czi|png|svs|scn|zarr\.db|zarr\.zip)$',
'skipTiles': r'(sample_image\.ptif|one_layer_missing_tiles|JK-kidney_B-gal_H3_4C_1-500sec\.jp2|extraoverview)' # noqa
'skipTiles': r'(sample_image\.ptif|one_layer_missing_tiles|JK-kidney_B-gal_H3_4C_1-500sec\.jp2|extraoverview|synthetic_untiled)' # noqa
},
'zarr': {'read': r'\.(zarr|zgroup|zattrs|db|zarr\.zip)$'},
}
Expand Down