diff --git a/CHANGELOG.md b/CHANGELOG.md index acff7696c..96914c1cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sources/tiff/large_image_source_tiff/__init__.py b/sources/tiff/large_image_source_tiff/__init__.py index d9d2fe526..2029b55dd 100644 --- a/sources/tiff/large_image_source_tiff/__init__.py +++ b/sources/tiff/large_image_source_tiff/__init__.py @@ -73,6 +73,7 @@ class TiffFileTileSource(FileTileSource, metaclass=LruCacheMetaclass): } _maxAssociatedImageSize = 8192 + _maxUntiledImage = 4096 def __init__(self, path, **kwargs): # noqa """ @@ -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) @@ -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 @@ -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, @@ -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 @@ -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']: @@ -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: @@ -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) diff --git a/sources/tiff/large_image_source_tiff/tiff_reader.py b/sources/tiff/large_image_source_tiff/tiff_reader.py index 42dca6e8d..5e603e7f5 100644 --- a/sources/tiff/large_image_source_tiff/tiff_reader.py +++ b/sources/tiff/large_image_source_tiff/tiff_reader.py @@ -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) @@ -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'): @@ -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) diff --git a/test/datastore.py b/test/datastore.py index b653917a4..b0789542c 100644 --- a/test/datastore.py +++ b/test/datastore.py @@ -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 } diff --git a/test/test_source_base.py b/test/test_source_base.py index b3d980aef..252afbac6 100644 --- a/test/test_source_base.py +++ b/test/test_source_base.py @@ -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', }, @@ -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'', @@ -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)$'}, }