diff --git a/.gitignore b/.gitignore index 69e5c1b..084c409 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ target/ # written by setuptools_scm */_version.py +.idea/ diff --git a/napari_tiff/_tests/test_tiff_reader.py b/napari_tiff/_tests/test_tiff_reader.py index d81ddca..a753bfc 100644 --- a/napari_tiff/_tests/test_tiff_reader.py +++ b/napari_tiff/_tests/test_tiff_reader.py @@ -1,14 +1,13 @@ +import numpy as np import os +import pytest +import tifffile import zipfile -import numpy as np from napari_tiff import napari_get_reader from napari_tiff.napari_tiff_reader import (imagecodecs_reader, - imagej_reader, tifffile_reader, zip_reader) -import pytest -import tifffile def example_data_filepath(tmp_path, original_data): @@ -18,9 +17,21 @@ def example_data_filepath(tmp_path, original_data): def example_data_tiff(tmp_path, original_data): - example_data_filepath = str(tmp_path / "myfile.tif") - tifffile.imwrite(example_data_filepath, original_data, imagej=True) - return tifffile.TiffFile(example_data_filepath) + filepath = str(tmp_path / "myfile.tif") + tifffile.imwrite(filepath, original_data) + return tifffile.TiffFile(filepath) + + +def example_data_imagej(tmp_path, original_data): + filepath = str(tmp_path / "myfile.tif") + tifffile.imwrite(filepath, original_data, imagej=True) + return tifffile.TiffFile(filepath) + + +def example_data_ometiff(tmp_path, original_data): + filepath = str(tmp_path / "myfile.ome.tif") + tifffile.imwrite(filepath, original_data, ome=True) + return tifffile.TiffFile(filepath) def example_data_zipped(tmp_path, original_data): @@ -67,8 +78,9 @@ def test_reader(tmp_path, data_fixture, original_data): @pytest.mark.parametrize("reader, data_fixture, original_data", [ (imagecodecs_reader, example_data_filepath, np.random.random((20, 20))), - (imagej_reader, example_data_tiff, np.random.randint(0, 255, size=(20, 20)).astype(np.uint8)), + (tifffile_reader, example_data_imagej, np.random.randint(0, 255, size=(20, 20)).astype(np.uint8)), (tifffile_reader, example_data_tiff, np.random.randint(0, 255, size=(20, 20)).astype(np.uint8)), + (tifffile_reader, example_data_ometiff, np.random.randint(0, 255, size=(20, 20, 3)).astype(np.uint8)), (zip_reader, example_data_zipped, np.random.random((20, 20))), ]) def test_all_readers(reader, data_fixture, original_data, tmp_path): diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index 2957816..3694a76 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -12,13 +12,15 @@ from typing import List, Optional, Union, Any, Tuple, Dict, Callable import numpy -from tifffile import TiffFile, TiffSequence, TIFF +from tifffile import TiffFile, TiffSequence, TIFF, xml2dict, PHOTOMETRIC from vispy.color import Colormap LayerData = Union[Tuple[Any], Tuple[Any, Dict], Tuple[Any, Dict, str]] PathLike = Union[str, List[str]] ReaderFunction = Callable[[PathLike], List[LayerData]] +UNIT_CONVERSIONS = {'nm': 1e-3, 'µm': 1, 'um': 1, 'micrometer': 1, 'mm': 1e3, 'cm': 1e4, 'm': 1e6} + def napari_get_reader(path: PathLike) -> Optional[ReaderFunction]: """Implements napari_get_reader hook specification. @@ -50,13 +52,10 @@ def napari_get_reader(path: PathLike) -> Optional[ReaderFunction]: def reader_function(path: PathLike) -> List[LayerData]: """Return a list of LayerData tuples from path or list of paths.""" - # TODO: Pyramids, OME, LSM + # TODO: LSM with TiffFile(path) as tif: try: - if tif.is_imagej: - layerdata = imagej_reader(tif) - else: - layerdata = tifffile_reader(tif) + layerdata = tifffile_reader(tif) except Exception as exc: # fallback to imagecodecs log_warning(f'tifffile: {exc}') @@ -72,14 +71,37 @@ def zip_reader(path: PathLike) -> List[LayerData]: def tifffile_reader(tif): - """Return napari LayerData from largest image series in TIFF file.""" + """Return napari LayerData from image series in TIFF file.""" + nlevels = len(tif.series[0].levels) + if nlevels > 1: + import dask.array as da + data = [] + for level in range(nlevels): + level_data = da.from_zarr(tif.aszarr(level=level)) + if level_data.chunksize == level_data.shape: + level_data = level_data.rechunk() + data.append(level_data) + else: + data = tif.asarray() + if tif.is_ome: + layer_data = get_ome_tiff(tif, data) + # TODO: combine interpretation of imagej and tags metadata?: + elif tif.is_imagej: + layer_data = [(data, get_imagej_metadata(tif), 'image')] + else: + layer_data = [(data, get_tiff_metadata(tif), 'image')] + return layer_data + + +def get_tiff_metadata(tif): + """Return napari metadata from largest image series in TIFF file.""" # TODO: fix (u)int32/64 # TODO: handle complex series = tif.series[0] for s in tif.series: if s.size > series.size: series = s - data = series.asarray() + dtype = series.dtype axes = series.axes shape = series.shape page = next(p for p in series.pages if p is not None) @@ -108,7 +130,7 @@ def tifffile_reader(tif): elif ( page.photometric in (2, 6) and ( page.planarconfig == 2 or - (page.bitspersample > 8 and data.dtype.kind in 'iu') or + (page.bitspersample > 8 and dtype.kind in 'iu') or (extrasamples and len(extrasamples) > 1) ) ): @@ -191,7 +213,7 @@ def tifffile_reader(tif): if ( contrast_limits is None and - data.dtype.kind == 'u' and + dtype.kind == 'u' and page.photometric != 3 and page.bitspersample not in (8, 16, 32, 64) ): @@ -209,16 +231,16 @@ def tifffile_reader(tif): blending=blending, visible=visible, ) - return [(data, kwargs, 'image')] + return kwargs -def imagej_reader(tif): +def get_imagej_metadata(tif): """Return napari LayerData from ImageJ hyperstack.""" # TODO: ROI overlays ijmeta = tif.imagej_metadata series = tif.series[0] - data = series.asarray() + dtype = series.dtype axes = series.axes shape = series.shape page = series.pages[0] @@ -263,7 +285,7 @@ def imagej_reader(tif): if mode in ('color', 'grayscale'): blending = 'opaque' - elif axes[-1] == 'S' and data.dtype == 'uint16': + elif axes[-1] == 'S' and dtype == 'uint16': # RGB >8-bit channel_axis = axes.find('S') if channel_axis >= 0 and shape[channel_axis] in (3, 4): @@ -299,7 +321,86 @@ def imagej_reader(tif): blending=blending, visible=visible, ) - return [(data, kwargs, 'image')] + return kwargs + + +def get_ome_tiff(tif, data): + layer_data = [] + metadata = xml2dict(tif.ome_metadata) + if 'OME' in metadata: + metadata = metadata['OME'] + + series = tif.series[0] + shape = series.shape + dtype = series.dtype + axes = series.axes.lower().replace('s', 'c') + if 'c' in axes: + channel_axis = axes.index('c') + nchannels = shape[channel_axis] + else: + channel_axis = None + nchannels = 1 + + image = ensure_list(metadata.get('Image', {}))[0] + pixels = image.get('Pixels', {}) + + pixel_size = [] + size = float(pixels.get('PhysicalSizeX', 0)) + if size > 0: + pixel_size.append(get_value_units_micrometer(size, pixels.get('PhysicalSizeXUnit'))) + size = float(pixels.get('PhysicalSizeY', 0)) + if size > 0: + pixel_size.append(get_value_units_micrometer(size, pixels.get('PhysicalSizeYUnit'))) + + channels = ensure_list(pixels.get('Channel', [])) + if len(channels) > nchannels: + nchannels = len(channels) + + is_rgb = (series.keyframe.photometric == PHOTOMETRIC.RGB and nchannels in (3, 4)) + + scale = None + if pixel_size: + scale = pixel_size + + for channeli, channel in enumerate(channels): + if not is_rgb and channel_axis is not None: + # extract channel data + if isinstance(data, list): + data1 = [numpy.take(level_data, indices=channeli, axis=channel_axis) for level_data in data] + else: + data1 = numpy.take(data, indices=channeli, axis=channel_axis) + else: + data1 = data + name = channel.get('Name') + color = channel.get('Color') + colormap = None + if color: + colormap = int_to_rgba(int(color)) + elif is_rgb and len(channels) > 1: + # separate channels provided for RGB (with missing color) + colormap = ['red', 'green', 'blue', 'alpha'][channeli] + if not name: + name = colormap + + contrast_limit = None + if dtype.kind != 'f': + info = numpy.iinfo(dtype) + contrast_limit = (info.min, info.max) + + blending = 'additive' + visible = True + + meta = dict( + rgb=is_rgb, + name=name, + scale=scale, + colormap=colormap, + contrast_limits=contrast_limit, + blending=blending, + visible=visible, + ) + layer_data.append((data1, meta, 'image')) + return layer_data def imagecodecs_reader(path): @@ -308,6 +409,12 @@ def imagecodecs_reader(path): return [(imread(path), {}, 'image')] +def ensure_list(x): + if not isinstance(x, (list, tuple)): + x = [x] + return x + + def alpha_colormap(bitspersample=8, samples=4): """Return Alpha colormap.""" n = 2**bitspersample @@ -359,6 +466,22 @@ def cmyk_colormaps(bitspersample=8, samples=3): return [Colormap(c), Colormap(m), Colormap(y), Colormap(k)] +def int_to_rgba(intrgba: int) -> tuple: + signed = (intrgba < 0) + rgba = [x / 255 for x in intrgba.to_bytes(4, signed=signed, byteorder="big")] + if rgba[-1] == 0: + rgba[-1] = 1 + return tuple(rgba) + + +def get_value_units_micrometer(value: float, unit: str = None) -> float: + if unit: + value_um = value * UNIT_CONVERSIONS.get(unit, 1) + else: + value_um = value + return value_um + + def log_warning(msg, *args, **kwargs): """Log message with level WARNING.""" import logging diff --git a/napari_tiff/testing/test_tiff_reader_metadata.py b/napari_tiff/testing/test_tiff_reader_metadata.py new file mode 100644 index 0000000..9fca31d --- /dev/null +++ b/napari_tiff/testing/test_tiff_reader_metadata.py @@ -0,0 +1,59 @@ +from napari.layers import Layer, Image +import numpy as np +import pytest +import tifffile + +from napari_tiff import napari_get_reader + + +def generate_ometiff_file(tmp_path, filename, data): + filepath = str(tmp_path / filename) + tifffile.imwrite(filepath, data, ome=True) + return filepath + + +@pytest.mark.parametrize("data_fixture, original_filename, original_data", [ + (generate_ometiff_file, "myfile.ome.tif", np.random.randint(0, 255, size=(20, 20, 3)).astype(np.uint8)), + (None, "D:/slides/EM04573_01small.ome.tif", None), + ]) +def test_reader(tmp_path, data_fixture, original_filename, original_data): + + if data_fixture is not None: + test_file = data_fixture(tmp_path, original_filename, original_data) + else: + test_file = original_filename + + # try to read it back in + reader = napari_get_reader(test_file) + assert callable(reader) + + # make sure we're delivering the right format + layer_datas = reader(test_file) + assert isinstance(layer_datas, list) and len(layer_datas) > 0 + + for layer_data in layer_datas: + assert isinstance(layer_data, tuple) and len(layer_data) > 0 + + data = layer_data[0] + metadata = layer_data[1] + + if original_data is not None: + # make sure the data is the same as it started + np.testing.assert_allclose(original_data, data) + else: + # test pixel data + if isinstance(data, list): + data0 = data[0] + else: + data0 = data + assert data0.size > 0 + slicing = tuple([0] * data0.ndim) + value = np.array(data0[slicing]) + assert value is not None and value.size > 0 + + # test layer metadata + layer = Layer.create(*layer_data) + assert isinstance(layer, Image) + + layer = Image(data, **metadata) + assert isinstance(layer, Image) diff --git a/pyproject.toml b/pyproject.toml index 1f90c6a..99f63bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,10 @@ requires-python = '>=3.10' dependencies = [ 'imagecodecs', 'numpy', - 'tifffile>=2020.5.7', + 'tifffile>=2023.9.26', + 'dask', 'vispy', + 'zarr', ] [project.optional-dependencies]