From 681738fb1630f47cbbc0389d519a0ebf5fff45ca Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 6 Jul 2022 09:32:00 -0400 Subject: [PATCH] Add a tifffile tile source. This better supports Leica SCN files than either the openslide or tiff sources. TODO: - When there is no S and C <= 3, should we auto-composite channels? We have appropriate data in self._channelInfo. - Currently, series and pages that aren't used for the image and are larger than a specified size aren't exposed in any manner (i.e., not as associated images). Should we expose them in a reduced resolution version? Or list those that aren't exposed in the internal metadata? --- .circleci/make_wheels.sh | 2 + .circleci/release_pypi.sh | 6 + README.rst | 2 + docs/index.rst | 1 + docs/make_docs.sh | 1 + requirements-dev-core.txt | 1 + requirements-dev.txt | 1 + requirements-worker.txt | 1 + setup.py | 1 + .../tiff/large_image_source_tiff/__init__.py | 1 - .../large_image_source_tifffile/__init__.py | 434 ++++++++++++++++++ .../girder_source.py | 28 ++ sources/tifffile/setup.py | 76 +++ test/test_source_base.py | 16 + 14 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 sources/tifffile/large_image_source_tifffile/__init__.py create mode 100644 sources/tifffile/large_image_source_tifffile/girder_source.py create mode 100644 sources/tifffile/setup.py diff --git a/.circleci/make_wheels.sh b/.circleci/make_wheels.sh index 3109bf2e0..079d8cd10 100755 --- a/.circleci/make_wheels.sh +++ b/.circleci/make_wheels.sh @@ -47,5 +47,7 @@ cd "$ROOTPATH/sources/test" pip wheel . --no-deps -w ~/wheels && rm -rf build cd "$ROOTPATH/sources/tiff" pip wheel . --no-deps -w ~/wheels && rm -rf build +cd "$ROOTPATH/sources/tifffile" +pip wheel . --no-deps -w ~/wheels && rm -rf build cd "$ROOTPATH/sources/vips" pip wheel . --no-deps -w ~/wheels && rm -rf build diff --git a/.circleci/release_pypi.sh b/.circleci/release_pypi.sh index 4fa18a576..84e5c5ac5 100755 --- a/.circleci/release_pypi.sh +++ b/.circleci/release_pypi.sh @@ -112,6 +112,12 @@ cp "$ROOTPATH/LICENSE" . python setup.py sdist pip wheel . --no-deps -w dist twine upload --verbose dist/* +cd "$ROOTPATH/sources/tifffile" +cp "$ROOTPATH/README.rst" . +cp "$ROOTPATH/LICENSE" . +python setup.py sdist +pip wheel . --no-deps -w dist +twine upload --verbose dist/* cd "$ROOTPATH/sources/vips" cp "$ROOTPATH/README.rst" . cp "$ROOTPATH/LICENSE" . diff --git a/README.rst b/README.rst index 92af276c7..20a923746 100644 --- a/README.rst +++ b/README.rst @@ -132,6 +132,8 @@ Large Image consists of several Python modules designed to work together. These - ``large-image-source-vips``: A tile source for reading any files handled by libvips. This also can be used for writing tiled images from numpy arrays. + - ``large-image-source-tifffile``: A tile source using the tifffile library that can handle a wide variety of tiff-like files. + - ``large-image-source-test``: A tile source that generates test tiles, including a simple fractal pattern. Useful for testing extreme zoom levels. - ``large-image-source-dummy``: A tile source that does nothing. diff --git a/docs/index.rst b/docs/index.rst index bc3d3ff5f..30be21c6c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,6 +30,7 @@ _build/large_image_source_pil/modules _build/large_image_source_test/modules _build/large_image_source_tiff/modules + _build/large_image_source_tifffile/modules _build/large_image_source_vips/modules _build/large_image_converter/modules _build/large_image_tasks/modules diff --git a/docs/make_docs.sh b/docs/make_docs.sh index 988e44bad..9ec59c2b1 100755 --- a/docs/make_docs.sh +++ b/docs/make_docs.sh @@ -31,6 +31,7 @@ sphinx-apidoc -f -o _build/large_image_source_openslide ../sources/openslide/lar sphinx-apidoc -f -o _build/large_image_source_pil ../sources/pil/large_image_source_pil sphinx-apidoc -f -o _build/large_image_source_test ../sources/test/large_image_source_test sphinx-apidoc -f -o _build/large_image_source_tiff ../sources/tiff/large_image_source_tiff +sphinx-apidoc -f -o _build/large_image_source_tifffile ../sources/tifffile/large_image_source_tifffile sphinx-apidoc -f -o _build/large_image_source_vips ../sources/vips/large_image_source_vips sphinx-apidoc -f -o _build/large_image_converter ../utilities/converter/large_image_converter sphinx-apidoc -f -o _build/large_image_tasks ../utilities/tasks/large_image_tasks diff --git a/requirements-dev-core.txt b/requirements-dev-core.txt index a6910fd23..bae506ce9 100644 --- a/requirements-dev-core.txt +++ b/requirements-dev-core.txt @@ -10,6 +10,7 @@ -e sources/pil -e sources/test -e sources/tiff +-e sources/tifffile -e sources/vips # must be after sources/tiff -e sources/ometiff diff --git a/requirements-dev.txt b/requirements-dev.txt index 2b24dbd06..7af379671 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,6 +13,7 @@ girder-jobs>=3.0.3 -e sources/pil -e sources/test -e sources/tiff +-e sources/tifffile ; python_version >= '3.7' -e sources/vips # must be after sources/tiff -e sources/ometiff diff --git a/requirements-worker.txt b/requirements-worker.txt index b6674137c..1f2823871 100644 --- a/requirements-worker.txt +++ b/requirements-worker.txt @@ -9,6 +9,7 @@ -e sources/pil -e sources/test -e sources/tiff +-e sources/tifffile -e sources/vips # must be after sources/tiff -e sources/ometiff diff --git a/setup.py b/setup.py index bbeae32d9..9cbebd0f0 100644 --- a/setup.py +++ b/setup.py @@ -56,6 +56,7 @@ def prerelease_local_scheme(version): 'pil': [f'large-image-source-pil{limit_version}'], 'test': [f'large-image-source-test{limit_version}'], 'tiff': [f'large-image-source-tiff{limit_version}'], + 'tifffile': [f'large-image-source-tifffile{limit_version}'], 'vips': [f'large-image-source-vips{limit_version}'], } if sys.version_info >= (3, 7): diff --git a/sources/tiff/large_image_source_tiff/__init__.py b/sources/tiff/large_image_source_tiff/__init__.py index 22fe68376..0b33afc36 100644 --- a/sources/tiff/large_image_source_tiff/__init__.py +++ b/sources/tiff/large_image_source_tiff/__init__.py @@ -712,7 +712,6 @@ def _getAssociatedImage(self, imageKey): return PIL.Image.open(io.BytesIO(base64.b64decode(td._embeddedImages[imageKey]))) if imageKey in self._associatedImages: return PIL.Image.fromarray(self._associatedImages[imageKey]) - return None def open(*args, **kwargs): diff --git a/sources/tifffile/large_image_source_tifffile/__init__.py b/sources/tifffile/large_image_source_tifffile/__init__.py new file mode 100644 index 000000000..8d5c67e43 --- /dev/null +++ b/sources/tifffile/large_image_source_tifffile/__init__.py @@ -0,0 +1,434 @@ +import math +import os +import threading + +import numpy +import zarr + +import large_image +from large_image.cache_util import LruCacheMetaclass, methodcache +from large_image.constants import TILE_FORMAT_NUMPY, SourcePriority +from large_image.exceptions import TileSourceError, TileSourceFileNotFoundError +from large_image.tilesource import FileTileSource + +tifffile = None + +try: + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as _importlib_version +except ImportError: + from importlib_metadata import PackageNotFoundError + from importlib_metadata import version as _importlib_version +try: + __version__ = _importlib_version(__name__) +except PackageNotFoundError: + # package is not installed + pass + + +def _lazyImport(): + """ + Import the tifffile module. This is done when needed rather than in the + module initialization because it is slow. + """ + global tifffile + + if tifffile is None: + try: + import tifffile + except ImportError: + raise TileSourceError('tifffile module not found.') + if not hasattr(tifffile.TiffTag, 'dtype_name') or not hasattr(tifffile.TiffPage, 'aszarr'): + tifffile = None + raise TileSourceError('tifffile module is too old.') + + +def et_findall(tag, text): + """ + Find all the child tags in an element tree that end with a specific string. + + :param tag: the tag to search. + :param text: the text to end with. + :returns: a list of tags. + """ + return [entry for entry in tag if entry.tag.endswith(text)] + + +class TifffileFileTileSource(FileTileSource, metaclass=LruCacheMetaclass): + """ + Provides tile access to files that the tifffile library can read. + """ + + cacheName = 'tilesource' + name = 'tifffile' + extensions = { + None: SourcePriority.LOW, + 'scn': SourcePriority.PREFERRED, + } + mimeTypes = { + None: SourcePriority.FALLBACK, + 'image/scn': SourcePriority.PREFERRED, + } + + # Fallback for non-tiled or oddly tiled sources + _tileSize = 512 + _minTileSize = 128 + _maxTileSize = 2048 + _maxAssociatedImageSize = 8192 + + def __init__(self, path, **kwargs): + """ + Initialize the tile class. See the base class for other available + parameters. + + :param path: a filesystem path for the tile source. + """ + super().__init__(path, **kwargs) + + self._largeImagePath = str(self._getLargeImagePath()) + + _lazyImport() + try: + self._tf = tifffile.TiffFile(self._largeImagePath) + except Exception: + if not os.path.isfile(self._largeImagePath): + raise TileSourceFileNotFoundError(self._largeImagePath) from None + raise TileSourceError('File cannot be opened via tifffile.') + # Find the series with the most pixels. Use all series that have the + # same dimensionality and resolution. They can differ in X, Y size. + maxseries = None + maxsamples = 0 + for idx, s in enumerate(self._tf.series): + samples = numpy.prod(s.shape) + if samples > maxsamples and 'X' in s.axes and 'Y' in s.axes: + maxseries = idx + maxsamples = samples + if maxseries is None: + raise TileSourceError('File cannot be opened via tifffile source.') + self.tileWidth = self.tileHeight = self._tileSize + s = self._tf.series[maxseries] + self._baseSeries = s + page = s.pages[0] + if ('TileWidth' in page.tags and + self._minTileSize <= page.tags['TileWidth'].value <= self._maxTileSize): + self.tileWidth = page.tags['TileWidth'].value + if ('TileLength' in page.tags and + self._minTileSize <= page.tags['TileLength'].value <= self._maxTileSize): + self.tileHeight = page.tags['TileLength'].value + self.sizeX = s.shape[s.axes.index('X')] + self.sizeY = s.shape[s.axes.index('Y')] + try: + unit = {2: 25.4, 3: 10}[page.tags['ResolutionUnit'].value.real] + + self._mm_x = (unit * page.tags['XResolution'].value[1] / + page.tags['XResolution'].value[0]) + self._mm_y = (unit * page.tags['YResolution'].value[1] / + page.tags['YResolution'].value[0]) + except Exception: + self._mm_x = self._mm_y = None + self._findMatchingSeries() + self.levels = int(max(1, math.ceil(math.log( + float(max(self.sizeX, self.sizeY)) / self.tileWidth) / math.log(2)) + 1)) + self._findAssociatedImages() + for key in dir(self._tf): + if (key.startswith('is_') and hasattr(self, '_handle_' + key[3:]) and + getattr(self._tf, key)): + getattr(self, '_handle_' + key[3:])() + + def _findMatchingSeries(self): + """ + Given a series in self._baseSeries, find other series that have the + same axes and shape except that they may different in width and height. + Store the results in self._series, _seriesShape, _framecount, and + _basis. + """ + base = self._baseSeries + page = base.pages[0] + self._series = [] + self._seriesShape = [] + for idx, s in enumerate(self._tf.series): + if s != base: + if 'P' in base.axes or s.axes != base.axes: + continue + if not all(base.axes[sidx] in 'YX' or sl == base.shape[sidx] + for sidx, sl in enumerate(s.shape)): + continue + skip = False + for tag in {'ResolutionUnit', 'XResolution', 'YResolution'}: + if (tag in page.tags) != (tag in s.pages[0].tags) or ( + tag in page.tags and + page.tags[tag].value != s.pages[0].tags[tag].value): + skip = True + if skip: + continue + self._series.append(idx) + self._seriesShape.append({ + 'sizeX': s.shape[s.axes.index('X')], 'sizeY': s.shape[s.axes.index('Y')]}) + self.sizeX = max(self.sizeX, s.shape[s.axes.index('X')]) + self.sizeY = max(self.sizeY, s.shape[s.axes.index('Y')]) + self._framecount = len(self._series) * numpy.prod(tuple( + 1 if base.axes[sidx] in 'YXS' else v for sidx, v in enumerate(base.shape))) + self._basis = {} + basis = 1 + if 'C' in base.axes: + self._basis['C'] = (1, base.axes.index('C'), base.shape[base.axes.index('C')]) + basis *= base.shape[base.axes.index('C')] + for axis in base.axes[::-1]: + if axis in 'CYXS': + continue + self._basis[axis] = (basis, base.axes.index(axis), base.shape[base.axes.index(axis)]) + basis *= base.shape[base.axes.index(axis)] + if len(self._series) > 1: + self._basis['P'] = (basis, -1, len(self._series)) + self._zarrlock = threading.RLock() + self._zarrcache = {} + + def _findAssociatedImages(self): + """ + Find associated images from unused pages and series. + """ + pagesInSeries = [p for s in self._tf.series for l in s.pages.levels for p in l.pages] + self._associatedImages = {} + for p in self._tf.pages: + if (p not in pagesInSeries and p.keyframe is not None and + not len(set(p.axes) - set('YXS'))): + id = 'image_%s' % p.index + entry = {'page': p.index} + entry['width'] = p.shape[p.axes.index('X')] + entry['height'] = p.shape[p.axes.index('Y')] + if (id not in self._associatedImages and + entry['width'] <= self._maxAssociatedImageSize and + entry['height'] <= self._maxAssociatedImageSize): + self._associatedImages[id] = entry + for sidx, s in enumerate(self._tf.series): + if sidx not in self._series and not len(set(s.axes) - set('YXS')): + id = 'series_%d' % sidx + if s.name and s.name.lower() not in self._associatedImages: + id = s.name.lower() + entry = {'series': sidx} + entry['width'] = s.shape[s.axes.index('X')] + entry['height'] = s.shape[s.axes.index('Y')] + if (id not in self._associatedImages and + entry['width'] <= self._maxAssociatedImageSize and + entry['height'] <= self._maxAssociatedImageSize): + self._associatedImages[id] = entry + + def _handle_scn(self): # noqa + """ + For SCN files, parse the xml and possibly adjust how associated images + are labelled. + """ + import xml.etree.ElementTree + + import large_image.tilesource.utilities + + root = xml.etree.ElementTree.fromstring(self._tf.pages[0].description) + self._xml = large_image.tilesource.utilities.etreeToDict(root) + for collection in et_findall(root, 'collection'): + sizeX = collection.attrib.get('sizeX') + sizeY = collection.attrib.get('sizeY') + for supplementalImage in et_findall(collection, 'supplementalImage'): + name = supplementalImage.attrib.get('type', '').lower() + ifd = supplementalImage.attrib.get('ifd', '') + oldname = 'image_%s' % ifd + if (name and ifd and oldname in self._associatedImages and + name not in self._associatedImages): + self._associatedImages[name] = self._associatedImages[oldname] + self._associatedImages.pop(oldname, None) + for image in et_findall(collection, 'image'): + name = image.attrib.get('name', 'Unknown') + for view in et_findall(image, 'view'): + if (sizeX and view.attrib.get('sizeX') == sizeX and + sizeY and view.attrib.get('sizeY') == sizeY and + not int(view.attrib.get('offsetX')) and + not int(view.attrib.get('offsetY')) and + name.lower() in self._associatedImages and + 'macro' not in self._associatedImages): + self._associatedImages['macro'] = self._associatedImages[name.lower()] + self._associatedImages.pop(name.lower(), None) + if name != self._baseSeries.name: + continue + for scanSettings in et_findall(image, 'scanSettings'): + for objectiveSettings in et_findall(scanSettings, 'objectiveSettings'): + for objective in et_findall(objectiveSettings, 'objective'): + if not hasattr(self, '_magnification') and float(objective.text) > 0: + self._magnification = float(objective.text) + for channelSettings in et_findall(scanSettings, 'channelSettings'): + channels = {} + for channel in et_findall(channelSettings, 'channel'): + channels[int(channel.attrib.get('index', 0))] = ( + large_image.tilesource.utilities.etreeToDict(channel)['channel']) + self._channelInfo = channels + try: + self._channels = [ + channels.get(idx)['name'] for idx in range(len(channels))] + except Exception: + pass + + def getNativeMagnification(self): + """ + Get the magnification at a particular level. + + :return: magnification, width of a pixel in mm, height of a pixel in mm. + """ + mm_x = self._mm_x + mm_y = self._mm_y + # Estimate the magnification; we don't have a direct value + mag = 0.01 / mm_x if mm_x else None + return { + 'magnification': getattr(self, '_magnification', mag), + 'mm_x': mm_x, + 'mm_y': mm_y, + } + + def getMetadata(self): + """ + Return a dictionary of metadata containing levels, sizeX, sizeY, + tileWidth, tileHeight, magnification, mm_x, mm_y, and frames. + + :returns: metadata dictionary. + """ + result = super().getMetadata() + if self._framecount > 1: + result['frames'] = frames = [] + for idx in range(self._framecount): + frame = {'Frame': idx} + for axis, (basis, _pos, count) in self._basis.items(): + frame['Index' + (axis.upper() if axis.upper() != 'P' else 'XY')] = ( + idx // basis) % count + frames.append(frame) + self._addMetadataFrameInformation(result, getattr(self, '_channels', None)) + if any(v != self._seriesShape[0] for v in self._seriesShape): + result['SizesXY'] = self._seriesShape + return result + + def getInternalMetadata(self, **kwargs): + """ + Return additional known metadata about the tile source. Data returned + from this method is not guaranteed to be in any particular format or + have specific values. + + :returns: a dictionary of data or None. + """ + result = {} + pages = [s.pages[0] for s in self._tf.series] + pagesInSeries = [p for s in self._tf.series for l in s.pages.levels for p in l.pages] + pages.extend([page for page in self._tf.pages if page not in pagesInSeries]) + for page in pages: + for tag in getattr(page, 'tags', []): + if tag.dtype_name == 'ASCII' and tag.value: + key = basekey = tag.name + suffix = 0 + while key in result: + if result[key] == tag.value: + break + suffix += 1 + key = '%s_%d' % (basekey, suffix) + result[key] = tag.value + if hasattr(self, '_xml') and 'xml' not in result: + result.pop('ImageDescription', None) + result['xml'] = self._xml + if hasattr(self, '_channelInfo'): + result['channelInfo'] = self._channelInfo + result['tifffileKind'] = self._baseSeries.kind + return result + + def getAssociatedImagesList(self): + """ + Get a list of all associated images. + + :return: the list of image keys. + """ + return sorted(self._associatedImages) + + def _getAssociatedImage(self, imageKey): + """ + Get an associated image in PIL format. + + :param imageKey: the key of the associated image. + :return: the image in PIL format or None. + """ + if imageKey in self._associatedImages: + entry = self._associatedImages[imageKey] + if 'page' in entry: + source = self._tf.pages[entry['page']] + else: + source = self._tf.series[entry['series']] + image = source.asarray() + axes = source.axes + if axes not in {'YXS', 'YX'}: + # rotate axes to YXS or YX + image = numpy.moveaxis(image, [ + source.axes.index(a) for a in 'YXS' if a in source.axes + ], range(len(source.axes))) + return large_image.tilesource.base._imageToPIL(image) + + @methodcache() + def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs): + frame = int(kwargs.get('frame') or 0) + self._xyzInRange(x, y, z, frame, self._framecount) + x0, y0, x1, y1, step = self._xyzToCorners(x, y, z) + if len(self._series) > 1: + sidx = frame // self._basis['P'][0] + else: + sidx = 0 + series = self._tf.series[self._series[sidx]] + with self._zarrlock: + if sidx not in self._zarrcache: + if len(self._zarrcache) > 10: + self._zarrcache = {} + self._zarrcache[sidx] = zarr.open(series.aszarr(), mode='r') + za = self._zarrcache[sidx] + xidx = series.axes.index('X') + yidx = series.axes.index('Y') + if hasattr(za[0], 'get_basic_selection'): + bza = za[0] + # we could cache this + for l in range(len(series.levels) - 1, 0, -1): + scale = round(max(za[0].shape[xidx] / za[l].shape[xidx], + za[0].shape[yidx] / za[l].shape[yidx])) + if scale <= step and step // scale == step / scale: + bza = za[l] + x0 //= scale + x1 //= scale + y0 //= scale + y1 //= scale + step //= scale + break + else: + bza = za + sel = [] + baxis = '' + for aidx, axis in enumerate(series.axes): + if axis == 'X': + sel.append(slice(x0, x1, step)) + baxis += 'X' + elif axis == 'Y': + sel.append(slice(y0, y1, step)) + baxis += 'Y' + elif axis == 'S': + sel.append(slice(series.shape[aidx])) + baxis += 'S' + else: + sel.append((frame // self._basis[axis][0]) % self._basis[axis][2]) + tile = bza[tuple(sel)] + # rotate + if baxis not in {'YXS', 'YX'}: + tile = numpy.moveaxis( + tile, [baxis.index(a) for a in 'YXS' if a in baxis], range(len(baxis))) + return self._outputTile(tile, TILE_FORMAT_NUMPY, x, y, z, + pilImageAllowed, numpyAllowed, **kwargs) + + +def open(*args, **kwargs): + """ + Create an instance of the module class. + """ + return TifffileFileTileSource(*args, **kwargs) + + +def canRead(*args, **kwargs): + """ + Check if an input can be read by the module class. + """ + return TifffileFileTileSource.canRead(*args, **kwargs) diff --git a/sources/tifffile/large_image_source_tifffile/girder_source.py b/sources/tifffile/large_image_source_tifffile/girder_source.py new file mode 100644 index 000000000..b7b0a1d98 --- /dev/null +++ b/sources/tifffile/large_image_source_tifffile/girder_source.py @@ -0,0 +1,28 @@ +############################################################################## +# 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. +############################################################################## + +from girder_large_image.girder_tilesource import GirderTileSource + +from . import TifffileFileTileSource + + +class TifffileGirderTileSource(TifffileFileTileSource, GirderTileSource): + """ + Provides tile access to Girder items with files that tifffile can read. + """ + + cacheName = 'tilesource' + name = 'tifffile' diff --git a/sources/tifffile/setup.py b/sources/tifffile/setup.py new file mode 100644 index 000000000..b0af4eb0b --- /dev/null +++ b/sources/tifffile/setup.py @@ -0,0 +1,76 @@ +import os + +from setuptools import find_packages, setup + +description = 'A tifffile tilesource for large_image.' +long_description = description + '\n\nSee the large-image package for more details.' + + +def prerelease_local_scheme(version): + """ + Return local scheme version unless building on master in CircleCI. + + This function returns the local scheme version number + (e.g. 0.0.0.dev+g) unless building on CircleCI for a + pre-release in which case it ignores the hash and produces a + PEP440 compliant pre-release version number (e.g. 0.0.0.dev). + """ + from setuptools_scm.version import get_local_node_and_date + + if os.getenv('CIRCLE_BRANCH') in ('master', ): + return '' + else: + return get_local_node_and_date(version) + + +try: + from setuptools_scm import get_version + + version = get_version(root='../..', local_scheme=prerelease_local_scheme) + limit_version = f'>={version}' if '+' not in version else '' +except (ImportError, LookupError): + limit_version = '' + +setup( + name='large-image-source-tifffile', + use_scm_version={'root': '../..', 'local_scheme': prerelease_local_scheme, + 'fallback_version': 'development'}, + setup_requires=['setuptools-scm'], + description=description, + long_description=long_description, + license='Apache Software License 2.0', + author='Kitware, Inc.', + author_email='kitware@kitware.com', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + ], + install_requires=[ + f'large-image{limit_version}', + 'dask[array]', + 'tifffile[all]', + 'zarr ; python_version >= "3.8"', + 'zarr<2.11 ; python_version < "3.8"', + 'importlib-metadata ; python_version < "3.8"', + ], + extras_require={ + 'girder': f'girder-large-image{limit_version}', + }, + keywords='large_image, tile source', + packages=find_packages(exclude=['test', 'test.*']), + url='https://github.com/girder/large_image', + python_requires='>=3.6', + entry_points={ + 'large_image.source': [ + 'tifffile = large_image_source_tifffile:TifffileFileTileSource' + ], + 'girder_large_image.source': [ + 'tifffile = large_image_source_tifffile.girder_source:TifffileGirderTileSource' + ] + }, +) diff --git a/test/test_source_base.py b/test/test_source_base.py index 609161cdb..97e465dbe 100644 --- a/test/test_source_base.py +++ b/test/test_source_base.py @@ -61,6 +61,10 @@ if sys.version_info >= (3, 7): SourceAndFiles.update({ 'nd2': {'read': r'\.(nd2)$'}, + 'tifffile': { + 'read': r'', + 'noread': r'\.(nc|nd2|yml|yaml|json|czi|png|jpeg|jp2)$', + }, }) else: # Python 3.6 has an older version of PIL that won't read some of the @@ -117,6 +121,9 @@ def testSourcesCanRead(source, filename): large_image.tilesource.loadTileSources() sourceClass = large_image.tilesource.AvailableTileSources[source] assert bool(sourceClass.canRead(imagePath)) is bool(canRead) + # Test module canRead method + mod = sys.modules[sourceClass.__module__] + assert bool(mod.canRead(imagePath)) is bool(canRead) @pytest.mark.parametrize('filename', registry) @@ -166,6 +173,15 @@ def testSourcesTilesAndMethods(source, filename): tsf = sourceClass(imagePath, frame=len(tileMetadata['frames']) - 1) tileMetadata = tsf.getMetadata() utilities.checkTilesZXY(tsf, tileMetadata) + # Test if we can fetch an associated image if any exist + assert ts.getAssociatedImagesList() is not None + if len(ts.getAssociatedImagesList()): + # This should be an image and a mime type + assert len(ts.getAssociatedImage(ts.getAssociatedImagesList()[0])) == 2 + assert ts.getAssociatedImage('nosuchimage') is None + # Test module open method + mod = sys.modules[sourceClass.__module__] + assert mod.open(imagePath) is not None @pytest.mark.parametrize('filename,isgeo', [