From d1290fd0be027cc42cf96e2753931ea8356accf7 Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Sat, 24 Feb 2024 15:42:00 +0100 Subject: [PATCH 01/25] Split tiff/imagej/ome reader at metadata level instead TODO: processing ome metadata --- .gitignore | 1 + napari_tiff/_tests/test_tiff_reader.py | 8 ++-- napari_tiff/napari_tiff_reader.py | 64 ++++++++++++++++++++------ pyproject.toml | 3 +- 4 files changed, 55 insertions(+), 21 deletions(-) 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..1fb1772 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): @@ -67,7 +66,6 @@ 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_tiff, np.random.randint(0, 255, size=(20, 20)).astype(np.uint8)), (zip_reader, example_data_zipped, np.random.random((20, 20))), ]) diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index 2957816..47b2018 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -11,8 +11,9 @@ """ from typing import List, Optional, Union, Any, Tuple, Dict, Callable +import dask.array as da import numpy -from tifffile import TiffFile, TiffSequence, TIFF +from tifffile import TiffFile, TiffSequence, TIFF, xml2dict from vispy.color import Colormap LayerData = Union[Tuple[Any], Tuple[Any, Dict], Tuple[Any, Dict, str]] @@ -50,13 +51,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 +70,30 @@ 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]) + if nlevels > 1: + data = [da.from_zarr(tif.aszarr(level=level)) for level in range(nlevels)] + else: + data = da.from_zarr(tif.aszarr()) + if tif.is_ome: + kwargs = get_ome_tiff_metadata(tif) + elif tif.is_imagej: + kwargs = get_imagej_metadata(tif) + else: + kwargs = get_tiff_metadata(tif) + return [(data, kwargs, 'image')] + + +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 +122,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 +205,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 +223,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 +277,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 +313,27 @@ def imagej_reader(tif): blending=blending, visible=visible, ) - return [(data, kwargs, 'image')] + return kwargs + + +def get_ome_tiff_metadata(tif): + metadata = xml2dict(tif.ome_metadata) + if 'OME' in metadata: + metadata = metadata['OME'] + + # TODO: process ome metadata + + kwargs = dict( + rgb=rgb, + channel_axis=channel_axis, + name=name, + scale=scale, + colormap=colormap, + contrast_limits=contrast_limits, + blending=blending, + visible=visible, + ) + return kwargs def imagecodecs_reader(path): diff --git a/pyproject.toml b/pyproject.toml index 1f90c6a..e33ecd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ requires-python = '>=3.10' dependencies = [ 'imagecodecs', 'numpy', - 'tifffile>=2020.5.7', + 'tifffile>=2023.9.26', + 'dask', 'vispy', ] From fecc64cc4cce86761049c16ebd06bd41adffe837 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Mon, 26 Feb 2024 15:42:21 +1100 Subject: [PATCH 02/25] Add zarr to dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e33ecd5..99f63bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ 'tifffile>=2023.9.26', 'dask', 'vispy', + 'zarr', ] [project.optional-dependencies] From da5651983af7c09ad4d922a3c0b60e3142f390df Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Mon, 26 Feb 2024 12:51:48 +0100 Subject: [PATCH 03/25] Restored imagej metadata test Minor: avoid variables with same name as functions (example_data_filepath) --- napari_tiff/_tests/test_tiff_reader.py | 13 ++++++++++--- napari_tiff/napari_tiff_reader.py | 5 +++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/napari_tiff/_tests/test_tiff_reader.py b/napari_tiff/_tests/test_tiff_reader.py index 1fb1772..8e4ef6a 100644 --- a/napari_tiff/_tests/test_tiff_reader.py +++ b/napari_tiff/_tests/test_tiff_reader.py @@ -17,9 +17,15 @@ 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_zipped(tmp_path, original_data): @@ -66,6 +72,7 @@ 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))), + (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)), (zip_reader, example_data_zipped, np.random.random((20, 20))), ]) diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index 47b2018..781ccad 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -11,7 +11,6 @@ """ from typing import List, Optional, Union, Any, Tuple, Dict, Callable -import dask.array as da import numpy from tifffile import TiffFile, TiffSequence, TIFF, xml2dict from vispy.color import Colormap @@ -73,11 +72,13 @@ def tifffile_reader(tif): """Return napari LayerData from image series in TIFF file.""" nlevels = len(tif.series[0]) if nlevels > 1: + import dask.array as da data = [da.from_zarr(tif.aszarr(level=level)) for level in range(nlevels)] else: - data = da.from_zarr(tif.aszarr()) + data = tif.asarray() if tif.is_ome: kwargs = get_ome_tiff_metadata(tif) + # TODO: combine interpretation of imagej and tags metadata?: elif tif.is_imagej: kwargs = get_imagej_metadata(tif) else: From ab4f521f58a119af48c25d1d34daff84f4953dc9 Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Wed, 28 Feb 2024 23:32:41 +0100 Subject: [PATCH 04/25] Implemented ome metadata processing --- napari_tiff/_tests/test_tiff_reader.py | 7 ++ napari_tiff/napari_tiff_reader.py | 110 +++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/napari_tiff/_tests/test_tiff_reader.py b/napari_tiff/_tests/test_tiff_reader.py index 8e4ef6a..a753bfc 100644 --- a/napari_tiff/_tests/test_tiff_reader.py +++ b/napari_tiff/_tests/test_tiff_reader.py @@ -28,6 +28,12 @@ def example_data_imagej(tmp_path, original_data): 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): example_tiff_filepath = str(tmp_path / "myfile.tif") tifffile.imwrite(example_tiff_filepath, original_data) @@ -74,6 +80,7 @@ def test_reader(tmp_path, data_fixture, original_data): (imagecodecs_reader, example_data_filepath, np.random.random((20, 20))), (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 781ccad..d52295a 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -12,7 +12,7 @@ from typing import List, Optional, Union, Any, Tuple, Dict, Callable import numpy -from tifffile import TiffFile, TiffSequence, TIFF, xml2dict +from tifffile import TiffFile, TiffSequence, TIFF, xml2dict, PHOTOMETRIC from vispy.color import Colormap LayerData = Union[Tuple[Any], Tuple[Any, Dict], Tuple[Any, Dict, str]] @@ -322,19 +322,88 @@ def get_ome_tiff_metadata(tif): if 'OME' in metadata: metadata = metadata['OME'] - # TODO: process ome metadata + 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 - kwargs = dict( - rgb=rgb, + 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)) + + names = [] + contrast_limits = [] + colormaps = [] + blendings = [] + visibles = [] + + scale = None + if pixel_size: + scale = pixel_size + + for channeli, channel in enumerate(channels): + 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 RGB channels + 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 + + if len(channels) > 1: + names.append(name) + blendings.append(blending) + contrast_limits.append(contrast_limit) + colormaps.append(colormap) + visibles.append(visible) + else: + names = name + blendings = blending + contrast_limits = contrast_limit + colormaps = colormap + visibles = visible + + meta = dict( + rgb=is_rgb, channel_axis=channel_axis, - name=name, + name=names, scale=scale, - colormap=colormap, + colormap=colormaps, contrast_limits=contrast_limits, - blending=blending, - visible=visible, + blending=blendings, + visible=visibles, ) - return kwargs + return meta def imagecodecs_reader(path): @@ -343,6 +412,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 @@ -394,6 +469,23 @@ 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: + conversions = {'nm': 1e-3, 'µm': 1, 'um': 1, 'micrometer': 1, 'mm': 1e3, 'cm': 1e4, 'm': 1e6} + if unit: + value_um = value * conversions.get(unit, 1) + else: + value_um = value + return value_um + + def log_warning(msg, *args, **kwargs): """Log message with level WARNING.""" import logging From ee9c216de2b24cb62d84f9150adc8957c820d656 Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Tue, 5 Mar 2024 13:31:42 +0100 Subject: [PATCH 05/25] Added metadata testing --- .../_tests/test_tiff_reader_metadata.py | 77 +++++++++++++++++++ napari_tiff/napari_tiff_reader.py | 5 +- 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 napari_tiff/_tests/test_tiff_reader_metadata.py diff --git a/napari_tiff/_tests/test_tiff_reader_metadata.py b/napari_tiff/_tests/test_tiff_reader_metadata.py new file mode 100644 index 0000000..e9fdc29 --- /dev/null +++ b/napari_tiff/_tests/test_tiff_reader_metadata.py @@ -0,0 +1,77 @@ +import napari +from napari.layers import 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 + layer_data = layer_datas[0] + assert isinstance(layer_data, tuple) and len(layer_data) > 0 + + # make sure it's the same as it started + data = layer_data[0] + if original_data is not None: + np.testing.assert_allclose(original_data, data) + + # test layer metadata + metadata = layer_data[1] + channel_axis = metadata.pop('channel_axis', None) + if isinstance(metadata.get('blending'), (list, tuple)): + pass + # unravel layered data + #for channeli, blending in enumerate(metadata.get('blending')): + # metadata1 = get_list_dict(metadata, channeli) + # layer = Image(data, **metadata1) + # assert isinstance(layer, Image) + else: + layer = Image(data, **metadata) + assert isinstance(layer, Image) + + +# make_napari_viewer is a pytest fixture that returns a napari viewer object +# you don't need to import it, as long as napari is installed +# in your testing environment +def test_reader_metadata(): + path = "D:/slides/EM04573_01small.ome.tif" + viewer = napari.Viewer() + layer_datas = napari_get_reader(path)(path) + for layer_data in layer_datas: + data = layer_data[0] + metadata = layer_data[1] + layer = viewer.add_image(data, **metadata) + assert layer is not None + + +def get_list_dict(dct, index): + dct1 = {} + for key, value in dct.items(): + if isinstance(value, (list, tuple)): + if index < len(value): + dct1[key] = value[index] + return dct1 diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index d52295a..d33a896 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -19,6 +19,8 @@ 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. @@ -478,9 +480,8 @@ def int_to_rgba(intrgba: int) -> tuple: def get_value_units_micrometer(value: float, unit: str = None) -> float: - conversions = {'nm': 1e-3, 'µm': 1, 'um': 1, 'micrometer': 1, 'mm': 1e3, 'cm': 1e4, 'm': 1e6} if unit: - value_um = value * conversions.get(unit, 1) + value_um = value * UNIT_CONVERSIONS.get(unit, 1) else: value_um = value return value_um From ccbc28f3f8fa347cc371551bacdbdb881ab2aaa9 Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Wed, 6 Mar 2024 00:41:49 +0100 Subject: [PATCH 06/25] Create split channel images, simplify testing --- .../_tests/test_tiff_reader_metadata.py | 37 +----------- napari_tiff/napari_tiff_reader.py | 58 +++++++------------ 2 files changed, 24 insertions(+), 71 deletions(-) diff --git a/napari_tiff/_tests/test_tiff_reader_metadata.py b/napari_tiff/_tests/test_tiff_reader_metadata.py index e9fdc29..0ee4bb4 100644 --- a/napari_tiff/_tests/test_tiff_reader_metadata.py +++ b/napari_tiff/_tests/test_tiff_reader_metadata.py @@ -1,4 +1,3 @@ -import napari from napari.layers import Image import numpy as np import pytest @@ -41,37 +40,5 @@ def test_reader(tmp_path, data_fixture, original_filename, original_data): # test layer metadata metadata = layer_data[1] - channel_axis = metadata.pop('channel_axis', None) - if isinstance(metadata.get('blending'), (list, tuple)): - pass - # unravel layered data - #for channeli, blending in enumerate(metadata.get('blending')): - # metadata1 = get_list_dict(metadata, channeli) - # layer = Image(data, **metadata1) - # assert isinstance(layer, Image) - else: - layer = Image(data, **metadata) - assert isinstance(layer, Image) - - -# make_napari_viewer is a pytest fixture that returns a napari viewer object -# you don't need to import it, as long as napari is installed -# in your testing environment -def test_reader_metadata(): - path = "D:/slides/EM04573_01small.ome.tif" - viewer = napari.Viewer() - layer_datas = napari_get_reader(path)(path) - for layer_data in layer_datas: - data = layer_data[0] - metadata = layer_data[1] - layer = viewer.add_image(data, **metadata) - assert layer is not None - - -def get_list_dict(dct, index): - dct1 = {} - for key, value in dct.items(): - if isinstance(value, (list, tuple)): - if index < len(value): - dct1[key] = value[index] - return dct1 + layer = Image(data, **metadata) + assert isinstance(layer, Image) diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index d33a896..16f3079 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -72,20 +72,20 @@ def zip_reader(path: PathLike) -> List[LayerData]: def tifffile_reader(tif): """Return napari LayerData from image series in TIFF file.""" - nlevels = len(tif.series[0]) + nlevels = len(tif.series[0].levels) if nlevels > 1: import dask.array as da data = [da.from_zarr(tif.aszarr(level=level)) for level in range(nlevels)] else: data = tif.asarray() if tif.is_ome: - kwargs = get_ome_tiff_metadata(tif) + layer_data = get_ome_tiff(tif, data) # TODO: combine interpretation of imagej and tags metadata?: elif tif.is_imagej: - kwargs = get_imagej_metadata(tif) + layer_data = [(data, get_imagej_metadata(tif), 'image')] else: - kwargs = get_tiff_metadata(tif) - return [(data, kwargs, 'image')] + layer_data = [(data, get_tiff_metadata(tif), 'image')] + return layer_data def get_tiff_metadata(tif): @@ -319,7 +319,8 @@ def get_imagej_metadata(tif): return kwargs -def get_ome_tiff_metadata(tif): +def get_ome_tiff(tif, data): + layer_data = [] metadata = xml2dict(tif.ome_metadata) if 'OME' in metadata: metadata = metadata['OME'] @@ -352,17 +353,15 @@ def get_ome_tiff_metadata(tif): is_rgb = (series.keyframe.photometric == PHOTOMETRIC.RGB and nchannels in (3, 4)) - names = [] - contrast_limits = [] - colormaps = [] - blendings = [] - visibles = [] - scale = None if pixel_size: scale = pixel_size for channeli, channel in enumerate(channels): + if not is_rgb and channel_axis is not None: + data1 = [numpy.take(level_data, indices=channeli, axis=channel_axis) for level_data in data] + else: + data1 = data name = channel.get('Name') color = channel.get('Color') colormap = None @@ -382,30 +381,17 @@ def get_ome_tiff_metadata(tif): blending = 'additive' visible = True - if len(channels) > 1: - names.append(name) - blendings.append(blending) - contrast_limits.append(contrast_limit) - colormaps.append(colormap) - visibles.append(visible) - else: - names = name - blendings = blending - contrast_limits = contrast_limit - colormaps = colormap - visibles = visible - - meta = dict( - rgb=is_rgb, - channel_axis=channel_axis, - name=names, - scale=scale, - colormap=colormaps, - contrast_limits=contrast_limits, - blending=blendings, - visible=visibles, - ) - return meta + 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): From 967d3e40cd5334e7f9d77a0ad9d22df08188c0d6 Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Wed, 6 Mar 2024 15:41:51 +0100 Subject: [PATCH 07/25] ome-tiff: Improved data and metadata creation, extended testing --- .../_tests/test_tiff_reader_metadata.py | 41 +++++++++++++------ napari_tiff/napari_tiff_reader.py | 15 +++++-- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/napari_tiff/_tests/test_tiff_reader_metadata.py b/napari_tiff/_tests/test_tiff_reader_metadata.py index 0ee4bb4..9fca31d 100644 --- a/napari_tiff/_tests/test_tiff_reader_metadata.py +++ b/napari_tiff/_tests/test_tiff_reader_metadata.py @@ -1,4 +1,4 @@ -from napari.layers import Image +from napari.layers import Layer, Image import numpy as np import pytest import tifffile @@ -30,15 +30,30 @@ def test_reader(tmp_path, data_fixture, original_filename, original_data): # make sure we're delivering the right format layer_datas = reader(test_file) assert isinstance(layer_datas, list) and len(layer_datas) > 0 - layer_data = layer_datas[0] - assert isinstance(layer_data, tuple) and len(layer_data) > 0 - - # make sure it's the same as it started - data = layer_data[0] - if original_data is not None: - np.testing.assert_allclose(original_data, data) - - # test layer metadata - metadata = layer_data[1] - layer = Image(data, **metadata) - assert isinstance(layer, Image) + + 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/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index 16f3079..3694a76 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -75,7 +75,12 @@ def tifffile_reader(tif): nlevels = len(tif.series[0].levels) if nlevels > 1: import dask.array as da - data = [da.from_zarr(tif.aszarr(level=level)) for level in range(nlevels)] + 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: @@ -359,7 +364,11 @@ def get_ome_tiff(tif, data): for channeli, channel in enumerate(channels): if not is_rgb and channel_axis is not None: - data1 = [numpy.take(level_data, indices=channeli, axis=channel_axis) for level_data in data] + # 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') @@ -368,7 +377,7 @@ def get_ome_tiff(tif, data): if color: colormap = int_to_rgba(int(color)) elif is_rgb and len(channels) > 1: - # separate RGB channels + # separate channels provided for RGB (with missing color) colormap = ['red', 'green', 'blue', 'alpha'][channeli] if not name: name = colormap From 17af7eb2c7fa34219636baacceb21b4883471515 Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Fri, 8 Mar 2024 15:21:52 +0100 Subject: [PATCH 08/25] Moved napari tests from auto-tests --- napari_tiff/more_tests/__init__.py | 0 napari_tiff/{_tests => more_tests}/test_tiff_reader_metadata.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 napari_tiff/more_tests/__init__.py rename napari_tiff/{_tests => more_tests}/test_tiff_reader_metadata.py (100%) diff --git a/napari_tiff/more_tests/__init__.py b/napari_tiff/more_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/napari_tiff/_tests/test_tiff_reader_metadata.py b/napari_tiff/more_tests/test_tiff_reader_metadata.py similarity index 100% rename from napari_tiff/_tests/test_tiff_reader_metadata.py rename to napari_tiff/more_tests/test_tiff_reader_metadata.py From 616a7ab510ee4b62774a829a17542d89dcf9adb2 Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Fri, 8 Mar 2024 16:13:03 +0100 Subject: [PATCH 09/25] Testing test --- napari_tiff/more_tests/__init__.py | 0 napari_tiff/{more_tests => testing}/test_tiff_reader_metadata.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 napari_tiff/more_tests/__init__.py rename napari_tiff/{more_tests => testing}/test_tiff_reader_metadata.py (100%) diff --git a/napari_tiff/more_tests/__init__.py b/napari_tiff/more_tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/napari_tiff/more_tests/test_tiff_reader_metadata.py b/napari_tiff/testing/test_tiff_reader_metadata.py similarity index 100% rename from napari_tiff/more_tests/test_tiff_reader_metadata.py rename to napari_tiff/testing/test_tiff_reader_metadata.py From 1b8891319ab7d8dfd4e15a1179dc93e637358e60 Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Fri, 15 Mar 2024 19:16:40 +0100 Subject: [PATCH 10/25] Reverted to using channel_axis, improved testing --- .../test_tiff_reader_metadata.py | 16 +++- napari_tiff/napari_tiff_reader.py | 96 +++++++++++-------- pyproject.toml | 3 +- 3 files changed, 70 insertions(+), 45 deletions(-) rename napari_tiff/{testing => _tests}/test_tiff_reader_metadata.py (76%) diff --git a/napari_tiff/testing/test_tiff_reader_metadata.py b/napari_tiff/_tests/test_tiff_reader_metadata.py similarity index 76% rename from napari_tiff/testing/test_tiff_reader_metadata.py rename to napari_tiff/_tests/test_tiff_reader_metadata.py index 9fca31d..6e9e4d2 100644 --- a/napari_tiff/testing/test_tiff_reader_metadata.py +++ b/napari_tiff/_tests/test_tiff_reader_metadata.py @@ -1,3 +1,4 @@ +from napari import Viewer from napari.layers import Layer, Image import numpy as np import pytest @@ -52,8 +53,15 @@ def test_reader(tmp_path, data_fixture, original_filename, original_data): assert value is not None and value.size > 0 # test layer metadata - layer = Layer.create(*layer_data) - assert isinstance(layer, Image) + if 'channel_axis' in metadata: + layers = Viewer().add_image(data, **metadata) + if not isinstance(layers, list): + layers = [layers] + for layer in layers: + assert isinstance(layer, Image) + else: + layer = Layer.create(*layer_data) # incompatible with channel_axis + assert isinstance(layer, Image) - layer = Image(data, **metadata) - assert isinstance(layer, Image) + layer = Image(data, **metadata) # incompatible with channel_axis + assert isinstance(layer, Image) diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index 3694a76..cb8419e 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -84,13 +84,13 @@ def tifffile_reader(tif): else: data = tif.asarray() if tif.is_ome: - layer_data = get_ome_tiff(tif, data) + kwargs = get_ome_tiff_metadata(tif) # TODO: combine interpretation of imagej and tags metadata?: elif tif.is_imagej: - layer_data = [(data, get_imagej_metadata(tif), 'image')] + kwargs = get_imagej_metadata(tif) else: - layer_data = [(data, get_tiff_metadata(tif), 'image')] - return layer_data + kwargs = get_tiff_metadata(tif) + return [(data, kwargs, 'image')] def get_tiff_metadata(tif): @@ -324,23 +324,11 @@ def get_imagej_metadata(tif): return kwargs -def get_ome_tiff(tif, data): - layer_data = [] +def get_ome_tiff_metadata(tif): 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', {}) @@ -352,25 +340,38 @@ def get_ome_tiff(tif, data): if size > 0: pixel_size.append(get_value_units_micrometer(size, pixels.get('PhysicalSizeYUnit'))) + 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 + 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)) + if is_rgb: + # channels_axis appears to be incompatible with RGB channels + channel_axis = None + + names = [] + contrast_limits = [] + colormaps = [] + blendings = [] + visibles = [] + 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 @@ -382,25 +383,40 @@ def get_ome_tiff(tif, data): if not name: name = colormap - contrast_limit = None - if dtype.kind != 'f': + blending = 'additive' + visible = True + + if dtype.kind == 'f': + contrast_limit = None + else: info = numpy.iinfo(dtype) contrast_limit = (info.min, info.max) - blending = 'additive' - visible = True + if len(channels) > 1: + names.append(name) + blendings.append(blending) + contrast_limits.append(contrast_limit) + colormaps.append(colormap) + visibles.append(visible) + else: + names = name + blendings = blending + contrast_limits = contrast_limit + colormaps = colormap + visibles = visible - 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 + kwargs = dict( + rgb=is_rgb, + channel_axis=channel_axis, + name=names, + scale=scale, + colormap=colormaps, + contrast_limits=contrast_limits, + blending=blendings, + visible=visibles, + metadata=metadata, + ) + return kwargs def imagecodecs_reader(path): diff --git a/pyproject.toml b/pyproject.toml index 99f63bc..684b156 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ 'imagecodecs', 'numpy', 'tifffile>=2023.9.26', - 'dask', + 'dask[array]', 'vispy', 'zarr', ] @@ -38,6 +38,7 @@ dependencies = [ [project.optional-dependencies] testing = [ 'pytest', + 'napari' ] dev = [ From 79ca5618bf4d98f5423ca7ff6df52ed6cad317de Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Sat, 16 Mar 2024 00:31:25 +0100 Subject: [PATCH 11/25] Expanded ome-tiff testing --- .../_tests/test_tiff_reader_metadata.py | 85 ++++++++++--------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/napari_tiff/_tests/test_tiff_reader_metadata.py b/napari_tiff/_tests/test_tiff_reader_metadata.py index 6e9e4d2..1b7b708 100644 --- a/napari_tiff/_tests/test_tiff_reader_metadata.py +++ b/napari_tiff/_tests/test_tiff_reader_metadata.py @@ -1,4 +1,4 @@ -from napari import Viewer +from glob import glob from napari.layers import Layer, Image import numpy as np import pytest @@ -7,6 +7,9 @@ from napari_tiff import napari_get_reader +viewer = None + + def generate_ometiff_file(tmp_path, filename, data): filepath = str(tmp_path / filename) tifffile.imwrite(filepath, data, ome=True) @@ -14,54 +17,60 @@ def generate_ometiff_file(tmp_path, filename, data): @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), + (generate_ometiff_file, "myfile.ome.tif", np.random.randint(0, 255, size=(16, 16)).astype(np.uint8)), + (generate_ometiff_file, "myfile.ome.tif", np.random.randint(0, 255, size=(16, 16, 3)).astype(np.uint8)), + (None, "D:/slides/test/*", None), ]) def test_reader(tmp_path, data_fixture, original_filename, original_data): + global viewer if data_fixture is not None: - test_file = data_fixture(tmp_path, original_filename, original_data) + test_files = [data_fixture(tmp_path, original_filename, original_data)] else: - test_file = original_filename + test_files = glob(original_filename) - # try to read it back in - reader = napari_get_reader(test_file) - assert callable(reader) + for test_file in test_files: + # 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 + # 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 + for layer_data in layer_datas: + assert isinstance(layer_data, tuple) and len(layer_data) > 0 - data = layer_data[0] - metadata = layer_data[1] + 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] + if original_data is not None: + # make sure the data is the same as it started + np.testing.assert_allclose(original_data, data) 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 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 - if 'channel_axis' in metadata: - layers = Viewer().add_image(data, **metadata) - if not isinstance(layers, list): - layers = [layers] - for layer in layers: + # test layer metadata + if 'channel_axis' in metadata: + if viewer is None: + from napari import Viewer + viewer = Viewer() + layers = viewer.add_image(data, **metadata) + if not isinstance(layers, list): + layers = [layers] + for layer in layers: + assert isinstance(layer, Image) + else: + layer = Layer.create(*layer_data) # incompatible with channel_axis assert isinstance(layer, Image) - else: - layer = Layer.create(*layer_data) # incompatible with channel_axis - assert isinstance(layer, Image) - layer = Image(data, **metadata) # incompatible with channel_axis - assert isinstance(layer, Image) + layer = Image(data, **metadata) # incompatible with channel_axis + assert isinstance(layer, Image) From 9ec84b478aed7ae134d71afe8ce29e8605f61a2f Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Tue, 19 Mar 2024 11:45:32 +0100 Subject: [PATCH 12/25] Added test case --- napari_tiff/_tests/test_tiff_reader_metadata.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/napari_tiff/_tests/test_tiff_reader_metadata.py b/napari_tiff/_tests/test_tiff_reader_metadata.py index 1b7b708..3a9799f 100644 --- a/napari_tiff/_tests/test_tiff_reader_metadata.py +++ b/napari_tiff/_tests/test_tiff_reader_metadata.py @@ -17,8 +17,9 @@ def generate_ometiff_file(tmp_path, filename, data): @pytest.mark.parametrize("data_fixture, original_filename, original_data", [ - (generate_ometiff_file, "myfile.ome.tif", np.random.randint(0, 255, size=(16, 16)).astype(np.uint8)), - (generate_ometiff_file, "myfile.ome.tif", np.random.randint(0, 255, size=(16, 16, 3)).astype(np.uint8)), + (generate_ometiff_file, "single_channel.ome.tif", np.random.randint(0, 255, size=(16, 16)).astype(np.uint8)), + (generate_ometiff_file, "multi_channel.ome.tif", np.random.randint(0, 255, size=(2, 16, 16)).astype(np.uint8)), + (generate_ometiff_file, "rgb.ome.tif", np.random.randint(0, 255, size=(16, 16, 3)).astype(np.uint8)), (None, "D:/slides/test/*", None), ]) def test_reader(tmp_path, data_fixture, original_filename, original_data): From 21237ac8cfbed15277cc0f7f3621479f479cb8f6 Mon Sep 17 00:00:00 2001 From: Joost de Folter Date: Tue, 19 Mar 2024 15:05:44 +0100 Subject: [PATCH 13/25] Added metadata to testing and generic file load function --- .../_tests/test_tiff_reader_metadata.py | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/napari_tiff/_tests/test_tiff_reader_metadata.py b/napari_tiff/_tests/test_tiff_reader_metadata.py index 3a9799f..d8c0df1 100644 --- a/napari_tiff/_tests/test_tiff_reader_metadata.py +++ b/napari_tiff/_tests/test_tiff_reader_metadata.py @@ -10,25 +10,31 @@ viewer = None -def generate_ometiff_file(tmp_path, filename, data): +def generate_ometiff_file(tmp_path, filename, data, metadata): filepath = str(tmp_path / filename) - tifffile.imwrite(filepath, data, ome=True) + tifffile.imwrite(filepath, data, ome=True, metadata=metadata) return filepath -@pytest.mark.parametrize("data_fixture, original_filename, original_data", [ - (generate_ometiff_file, "single_channel.ome.tif", np.random.randint(0, 255, size=(16, 16)).astype(np.uint8)), - (generate_ometiff_file, "multi_channel.ome.tif", np.random.randint(0, 255, size=(2, 16, 16)).astype(np.uint8)), - (generate_ometiff_file, "rgb.ome.tif", np.random.randint(0, 255, size=(16, 16, 3)).astype(np.uint8)), - (None, "D:/slides/test/*", None), +def get_files(tmp_path, path, data, metadata): + # TODO download files instead; path can be URL + filepaths = glob(path) + return filepaths + + +@pytest.mark.parametrize("data_fixture, original_filename, original_data, original_metadata", [ + (generate_ometiff_file, "single_channel.ome.tif", np.random.randint(0, 255, size=(16, 16)).astype(np.uint8), None), + (generate_ometiff_file, "multi_channel.ome.tif", np.random.randint(0, 65535, size=(2, 16, 16)).astype(np.uint16), + {'Channel': [{'Name': 'WF', 'Color': '-1'}, {'Name': 'Fluor', 'Color': '16711935'}]}), + (generate_ometiff_file, "rgb.ome.tif", np.random.randint(0, 255, size=(16, 16, 3)).astype(np.uint8), None), + (get_files, "D:/slides/test/*", None, None), ]) -def test_reader(tmp_path, data_fixture, original_filename, original_data): +def test_reader(data_fixture, original_filename, original_data, original_metadata, tmp_path): global viewer - if data_fixture is not None: - test_files = [data_fixture(tmp_path, original_filename, original_data)] - else: - test_files = glob(original_filename) + test_files = data_fixture(tmp_path, original_filename, original_data, original_metadata) + if not isinstance(test_files, list): + test_files = [test_files] for test_file in test_files: # try to read it back in From 75a51a543962865c33eaf8aaf3db106632d984d9 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Mon, 25 Mar 2024 15:30:59 +1100 Subject: [PATCH 14/25] Rearrange code --- napari_tiff/_tests/test_data.py | 95 ++++++ napari_tiff/_tests/test_pyramids.py | 49 +++ napari_tiff/_tests/test_tiff_reader.py | 72 ++--- napari_tiff/napari_tiff_colormaps.py | 61 ++++ napari_tiff/napari_tiff_metadata.py | 380 ++++++++++++++++++++++ napari_tiff/napari_tiff_reader.py | 415 +------------------------ 6 files changed, 618 insertions(+), 454 deletions(-) create mode 100644 napari_tiff/_tests/test_data.py create mode 100644 napari_tiff/_tests/test_pyramids.py create mode 100644 napari_tiff/napari_tiff_colormaps.py create mode 100644 napari_tiff/napari_tiff_metadata.py diff --git a/napari_tiff/_tests/test_data.py b/napari_tiff/_tests/test_data.py new file mode 100644 index 0000000..1158f51 --- /dev/null +++ b/napari_tiff/_tests/test_data.py @@ -0,0 +1,95 @@ +import os +import zipfile + +import numpy as np +import pytest +import tifffile + + +def example_data_filepath(tmp_path, original_data): + example_data_filepath = str(tmp_path / "example_data_filepath.tif") + tifffile.imwrite(example_data_filepath, original_data, imagej=False) + return example_data_filepath + + +def example_data_zipped_filepath(tmp_path, original_data): + example_tiff_filepath = str(tmp_path / "myfile.tif") + tifffile.imwrite(example_tiff_filepath, original_data, imagej=False) + example_zipped_filepath = str(tmp_path / "myfile.zip") + with zipfile.ZipFile(example_zipped_filepath, 'w') as myzip: + myzip.write(example_tiff_filepath) + os.remove(example_tiff_filepath) # not needed now the zip file is saved + return example_zipped_filepath + + +def example_data_tiff(tmp_path, original_data): + example_data_filepath = str(tmp_path / "example_data_tiff.tif") + tifffile.imwrite(example_data_filepath, original_data, imagej=False) + return tifffile.TiffFile(example_data_filepath) + + +def example_data_imagej(tmp_path, original_data): + example_data_filepath = str(tmp_path / "example_data_imagej.tif") + tifffile.imwrite(example_data_filepath, original_data, imagej=True) + return tifffile.TiffFile(example_data_filepath) + + +def example_data_ometiff(tmp_path, original_data): + example_data_filepath = str(tmp_path / "example_data_ometiff.ome.tif") + tifffile.imwrite(example_data_filepath, original_data, imagej=False) + return tifffile.TiffFile(example_data_filepath) + + +@pytest.fixture +def example_data_multiresolution(tmp_path): + """Example multi-resolution tiff file. + + Write a multi-dimensional, multi-resolution (pyramidal), multi-series OME-TIFF + file with metadata. Sub-resolution images are written to SubIFDs. Limit + parallel encoding to 2 threads. + + This example code reproduced from tifffile.py, see: + https://github.com/cgohlke/tifffile/blob/2b5a5208008594976d4627bcf01355fc08837592/tifffile/tifffile.py#L649-L688 + """ + example_data_filepath = str(tmp_path / "'test-pyramid.ome.tif'") + data = np.random.randint(0, 255, (8, 2, 512, 512, 3), 'uint8') + subresolutions = 2 # so 3 resolution levels in total + pixelsize = 0.29 # micrometer + with tifffile.TiffWriter(example_data_filepath, bigtiff=True) as tif: + metadata={ + 'axes': 'TCYXS', + 'SignificantBits': 8, + 'TimeIncrement': 0.1, + 'TimeIncrementUnit': 's', + 'PhysicalSizeX': pixelsize, + 'PhysicalSizeXUnit': 'µm', + 'PhysicalSizeY': pixelsize, + 'PhysicalSizeYUnit': 'µm', + 'Channel': {'Name': ['Channel 1', 'Channel 2']}, + 'Plane': {'PositionX': [0.0] * 16, 'PositionXUnit': ['µm'] * 16} + } + options = dict( + photometric='rgb', + tile=(128, 128), + compression='jpeg', + resolutionunit='CENTIMETER', + maxworkers=2 + ) + tif.write( + data, + subifds=subresolutions, + resolution=(1e4 / pixelsize, 1e4 / pixelsize), + metadata=metadata, + **options + ) + # write pyramid levels to the two subifds + # in production use resampling to generate sub-resolution images + for level in range(subresolutions): + mag = 2**(level + 1) + tif.write( + data[..., ::mag, ::mag, :], + subfiletype=1, + resolution=(1e4 / mag / pixelsize, 1e4 / mag / pixelsize), + **options + ) + return tifffile.TiffFile(example_data_filepath) diff --git a/napari_tiff/_tests/test_pyramids.py b/napari_tiff/_tests/test_pyramids.py new file mode 100644 index 0000000..dc9e2b7 --- /dev/null +++ b/napari_tiff/_tests/test_pyramids.py @@ -0,0 +1,49 @@ +import numpy as np +from tifffile import TiffWriter + + +data = np.random.randint(0, 255, (8, 2, 512, 512, 3), 'uint8') +subresolutions = 2 +pixelsize = 0.29 # micrometer +with TiffWriter('temp-pyramid.ome.tif', bigtiff=True) as tif: + metadata={ + 'axes': 'TCYXS', + 'SignificantBits': 8, + 'TimeIncrement': 0.1, + 'TimeIncrementUnit': 's', + 'PhysicalSizeX': pixelsize, + 'PhysicalSizeXUnit': 'µm', + 'PhysicalSizeY': pixelsize, + 'PhysicalSizeYUnit': 'µm', + 'Channel': {'Name': ['Channel 1', 'Channel 2']}, + 'Plane': {'PositionX': [0.0] * 16, 'PositionXUnit': ['µm'] * 16} + } + options = dict( + photometric='rgb', + tile=(128, 128), + compression='jpeg', + resolutionunit='CENTIMETER', + maxworkers=2 + ) + tif.write( + data, + subifds=subresolutions, + resolution=(1e4 / pixelsize, 1e4 / pixelsize), + metadata=metadata, + **options + ) + # write pyramid levels to the two subifds + # in production use resampling to generate sub-resolution images + for level in range(subresolutions): + mag = 2**(level + 1) + tif.write( + data[..., ::mag, ::mag, :], + subfiletype=1, + resolution=(1e4 / mag / pixelsize, 1e4 / mag / pixelsize), + **options + ) + # add a thumbnail image as a separate series + # it is recognized by QuPath as an associated image + thumbnail = (data[0, 0, ::8, ::8] >> 2).astype('uint8') + tif.write(thumbnail, metadata={'Name': 'thumbnail'}) + diff --git a/napari_tiff/_tests/test_tiff_reader.py b/napari_tiff/_tests/test_tiff_reader.py index a753bfc..92de805 100644 --- a/napari_tiff/_tests/test_tiff_reader.py +++ b/napari_tiff/_tests/test_tiff_reader.py @@ -1,47 +1,18 @@ +import dask.array as da import numpy as np -import os import pytest -import tifffile -import zipfile from napari_tiff import napari_get_reader from napari_tiff.napari_tiff_reader import (imagecodecs_reader, - tifffile_reader, - zip_reader) - - -def example_data_filepath(tmp_path, original_data): - example_data_filepath = str(tmp_path / "myfile.tif") - tifffile.imwrite(example_data_filepath, original_data) - return example_data_filepath - - -def example_data_tiff(tmp_path, original_data): - 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): - example_tiff_filepath = str(tmp_path / "myfile.tif") - tifffile.imwrite(example_tiff_filepath, original_data) - example_zipped_filepath = str(tmp_path / "myfile.zip") - with zipfile.ZipFile(example_zipped_filepath, 'w') as myzip: - myzip.write(example_tiff_filepath) - os.remove(example_tiff_filepath) # not needed now the zip file is saved - return example_zipped_filepath + tifffile_reader, zip_reader) +from napari_tiff._tests.test_data import ( + example_data_filepath, + example_data_zipped_filepath, + example_data_tiff, + example_data_imagej, + example_data_ometiff, + example_data_multiresolution, +) def test_get_reader_pass(): @@ -52,10 +23,10 @@ def test_get_reader_pass(): @pytest.mark.parametrize("data_fixture, original_data", [ (example_data_filepath, np.random.random((20, 20))), - (example_data_zipped, np.random.random((20, 20))), + (example_data_zipped_filepath, np.random.random((20, 20))), ]) def test_reader(tmp_path, data_fixture, original_data): - """An example of how you might test your plugin.""" + """Test tiff reader with example data filepaths.""" my_test_file = data_fixture(tmp_path, original_data) @@ -70,7 +41,7 @@ def test_reader(tmp_path, data_fixture, original_data): assert isinstance(layer_data_tuple, tuple) and len(layer_data_tuple) > 0 # make sure it's the same as it started - if data_fixture == example_data_zipped: # zipfile has unsqueezed dimension + if data_fixture == example_data_zipped_filepath: # zipfile has unsqueezed dimension np.testing.assert_allclose(original_data, layer_data_tuple[0][0]) else: np.testing.assert_allclose(original_data, layer_data_tuple[0]) @@ -81,7 +52,7 @@ def test_reader(tmp_path, data_fixture, original_data): (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))), + (zip_reader, example_data_zipped_filepath, np.random.random((20, 20))), ]) def test_all_readers(reader, data_fixture, original_data, tmp_path): """Test each individual reader.""" @@ -96,7 +67,20 @@ def test_all_readers(reader, data_fixture, original_data, tmp_path): assert isinstance(layer_data_tuple, tuple) and len(layer_data_tuple) > 0 # make sure it's the same as it started - if data_fixture == example_data_zipped: # zipfile has unsqueezed dimension + if data_fixture == example_data_zipped_filepath: # zipfile has unsqueezed dimension np.testing.assert_allclose(original_data, layer_data_tuple[0][0]) else: np.testing.assert_allclose(original_data, layer_data_tuple[0]) + + +def test_multiresolution_image(example_data_multiresolution): + """Test opening a multi-resolution image.""" + assert example_data_multiresolution.series[0].is_pyramidal + layer_data_list = tifffile_reader(example_data_multiresolution) + layer_data_tuple = layer_data_list[0] + layer_data = layer_data_tuple[0] + assert len(layer_data) == 3 + assert layer_data[0].shape == (16, 512, 512, 3) + assert layer_data[1].shape == (16, 256, 256, 3) + assert layer_data[2].shape == (16, 128, 128, 3) + assert all([isinstance(level, da.Array) for level in layer_data]) diff --git a/napari_tiff/napari_tiff_colormaps.py b/napari_tiff/napari_tiff_colormaps.py new file mode 100644 index 0000000..761bec0 --- /dev/null +++ b/napari_tiff/napari_tiff_colormaps.py @@ -0,0 +1,61 @@ +import numpy +from vispy.color import Colormap + + +def alpha_colormap(bitspersample=8, samples=4): + """Return Alpha colormap.""" + n = 2**bitspersample + ramp = numpy.linspace(0.0, 1.0, n).astype('float32') + a = numpy.zeros((n, samples), dtype='float32') + a[:, 3] = ramp[::-1] + return Colormap(a) + + +def rgb_colormaps(bitspersample=8, samples=3): + """Return RGB colormaps.""" + n = 2**bitspersample + ramp = numpy.linspace(0.0, 1.0, n).astype('float32') + r = numpy.zeros((n, samples), dtype='float32') + r[:, 0] = ramp + g = numpy.zeros((n, samples), dtype='float32') + g[:, 1] = ramp + b = numpy.zeros((n, samples), dtype='float32') + b[:, 2] = ramp + if samples > 3: + r[:, 3:] = 1.0 + g[:, 3:] = 1.0 + b[:, 3:] = 1.0 + return [Colormap(r), Colormap(g), Colormap(b)] + + +def cmyk_colormaps(bitspersample=8, samples=3): + """Return CMYK colormaps.""" + n = 2**bitspersample + ramp = numpy.linspace(1.0, 0.0, n).astype('float32') + c = numpy.zeros((n, samples), dtype='float32') + c[:, 1] = ramp + c[:, 2] = ramp + m = numpy.zeros((n, samples), dtype='float32') + m[:, 0] = ramp + m[:, 2] = ramp + y = numpy.zeros((n, samples), dtype='float32') + y[:, 0] = ramp + y[:, 1] = ramp + k = numpy.zeros((n, samples), dtype='float32') + k[:, 0] = ramp + k[:, 1] = ramp + k[:, 2] = ramp + if samples > 3: + c[:, 3:] = 1.0 + m[:, 3:] = 1.0 + y[:, 3:] = 1.0 + k[:, 3:] = 1.0 + 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) diff --git a/napari_tiff/napari_tiff_metadata.py b/napari_tiff/napari_tiff_metadata.py new file mode 100644 index 0000000..780798c --- /dev/null +++ b/napari_tiff/napari_tiff_metadata.py @@ -0,0 +1,380 @@ +import numpy +from tifffile import xml2dict, PHOTOMETRIC, TiffFile +from typing import Any +from vispy.color import Colormap + +from napari_tiff.napari_tiff_colormaps import alpha_colormap, cmyk_colormaps, int_to_rgba, rgb_colormaps + + +def get_metadata(tif: TiffFile) -> dict[str, Any]: + """Return metadata keyword argument dictionary for napari layer.""" + if tif.is_ome: + metadata_kwargs = get_ome_tiff_metadata(tif) + # TODO: combine interpretation of imagej and tags metadata?: + elif tif.is_imagej: + metadata_kwargs = get_imagej_metadata(tif) + else: + metadata_kwargs = get_tiff_metadata(tif) + + # napari does not use this this extra `metadata` layer attribute + # but storing the information on the layer next to the data + # will allow users to access it and use it themselves if they wish + metadata_kwargs['metadata'] = get_extra_metadata(tif) + + return metadata_kwargs + + +def get_extra_metadata(tif: TiffFile) -> dict[str, Any]: + """Return any non-empty tif metadata properties as a dictionary.""" + metadata_dict = {} + empty_metadata_values = [None, '', (), []] + for name in dir(tif.__class__): + obj = getattr(tif.__class__, name) + if 'metadata' in name: + metadata_value = obj.__get__(tif) + if metadata_value not in empty_metadata_values: + print(metadata_value) + if isinstance(metadata_value, str): + try: + metadata_value = xml2dict(metadata_value) + except Exception: + pass + metadata_dict[name] = metadata_value + return metadata_dict + + +def get_tiff_metadata(tif: TiffFile) -> dict[str, Any]: + """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 + dtype = series.dtype + axes = series.axes + shape = series.shape + page = next(p for p in series.pages if p is not None) + extrasamples = page.extrasamples + + rgb = page.photometric in (2, 6) and shape[-1] in (3, 4) + name = None + scale = None + colormap = None + contrast_limits = None + blending = None + channel_axis = None + visible = True + + if page.photometric == 5: + # CMYK + channel_axis = axes.find('S') + if channel_axis >= 0 and shape[channel_axis] >= 4: + colormap = cmyk_colormaps() + name = ['Cyan', 'Magenta', 'Yellow', 'Black'] + visible = [False, False, False, True] + blending = ['additive', 'additive', 'additive', 'additive'] + # TODO: use subtractive blending + else: + channel_axis = None + elif ( + page.photometric in (2, 6) and ( + page.planarconfig == 2 or + (page.bitspersample > 8 and dtype.kind in 'iu') or + (extrasamples and len(extrasamples) > 1) + ) + ): + # RGB >8-bit or planar, or with multiple extrasamples + channel_axis = axes.find('S') + if channel_axis >= 0 and shape[channel_axis] > 2: + rgb = False + visible = [True, True, True] + colormap = ['red', 'green', 'blue'] # rgb_colormaps() + name = ['Red', 'Green', 'Blue'] + blending = ['additive', 'additive', 'additive'] + else: + channel_axis = None + elif ( + page.photometric in (0, 1) and + extrasamples and + any(sample > 0 for sample in extrasamples) + ): + # Grayscale with alpha channel + channel_axis = axes.find('S') + if channel_axis >= 0: + visible = [True] + colormap = ['gray'] + name = ['Minisblack' if page.photometric == 1 else 'Miniswhite'] + blending = ['additive'] + else: + channel_axis = None + + if channel_axis is not None and extrasamples: + # add extrasamples + for sample in extrasamples: + if sample == 0: + # UNSPECIFIED + visible.append(False) # hide by default + colormap.append('gray') + name.append('Extrasample') + blending.append('additive') + else: + # alpha channel + # TODO: handle ASSOCALPHA and UNASSALPHA + visible.append(True) + colormap.append(alpha_colormap()) + name.append('Alpha') + blending.append('translucent') + + if channel_axis is None and page.photometric in (0, 1): + # separate up to 3 samples in grayscale images + channel_axis = axes.find('S') + if channel_axis >= 0 and 1 < shape[channel_axis] < 4: + n = shape[channel_axis] + colormap = ['red', 'green', 'blue', 'gray', + 'cyan', 'magenta', 'yellow'][:n] + name = [f'Sample {i}' for i in range(n)] + else: + channel_axis = None + + if channel_axis is None: + # separate up to 3 channels + channel_axis = axes.find('C') + if channel_axis > 0 and 1 < shape[channel_axis] < 4: + n = shape[channel_axis] + colormap = ['red', 'green', 'blue', 'gray', + 'cyan', 'magenta', 'yellow'][:n] + name = [f'Channel {i}' for i in range(n)] + else: + channel_axis = None + + if page.photometric == 3 and page.colormap is not None: + # PALETTE + colormap = page.colormap + if numpy.max(colormap) > 255: + colormap = colormap / 65535.0 + else: + colormap = colormap / 255.0 + colormap = Colormap(colormap.astype('float32').T) + + if colormap is None and page.photometric == 0: + # MINISBLACK + colormap = 'gray_r' + + if ( + contrast_limits is None and + dtype.kind == 'u' and + page.photometric != 3 and + page.bitspersample not in (8, 16, 32, 64) + ): + contrast_limits = (0, 2**page.bitspersample) + if channel_axis is not None and shape[channel_axis] > 1: + contrast_limits = [contrast_limits] * shape[channel_axis] + + kwargs = dict( + rgb=rgb, + channel_axis=channel_axis, + name=name, + scale=scale, + colormap=colormap, + contrast_limits=contrast_limits, + blending=blending, + visible=visible, + ) + return kwargs + + +def get_imagej_metadata(tif: TiffFile) -> dict[str, Any]: + """Return napari LayerData from ImageJ hyperstack.""" + # TODO: ROI overlays + ijmeta = tif.imagej_metadata + series = tif.series[0] + + dtype = series.dtype + axes = series.axes + shape = series.shape + page = series.pages[0] + rgb = page.photometric == 2 and shape[-1] in (3, 4) + mode = ijmeta.get('mode', None) + channels = ijmeta.get('channels', 1) + channel_axis = None + + name = None + scale = None + colormap = None + contrast_limits = None + blending = None + visible = True + + if mode in ('composite', 'color'): + channel_axis = axes.find('C') + if channel_axis < 0: + channel_axis = None + + if channel_axis is not None: + channels = shape[channel_axis] + channel_only = channels == ijmeta.get('images', 0) + + if 'LUTs' in ijmeta: + colormap = [Colormap(c.T / 255.0) for c in ijmeta['LUTs']] + elif mode == 'grayscale': + colormap = 'gray' + elif channels < 8: + colormap = ['red', 'green', 'blue', 'gray', + 'cyan', 'magenta', 'yellow'][:channels] + + if 'Ranges' in ijmeta: + contrast_limits = numpy.array(ijmeta['Ranges']).reshape(-1, 2) + contrast_limits = contrast_limits.tolist() + + if channel_only and 'Labels' in ijmeta: + name = ijmeta['Labels'] + elif channels > 1: + name = [f'Channel {i}' for i in range(channels)] + + if mode in ('color', 'grayscale'): + blending = 'opaque' + + 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): + rgb = False + n = shape[channel_axis] + visible = [True, True, True] + colormap = rgb_colormaps(samples=4)[:n] + name = ['Red', 'Green', 'Blue', 'Alpha'][:n] + blending = ['additive', 'additive', 'additive', 'translucent'][:n] + else: + channel_axis = None + + scale = {} + res = page.tags.get('XResolution') + if res is not None: + scale['X'] = res.value[1] / max(res.value[0], 1) + res = page.tags.get('YResolution') + if res is not None: + scale['Y'] = res.value[1] / max(res.value[0], 1) + scale['Z'] = abs(ijmeta.get('spacing', 1.0)) + if channel_axis is None: + scale = tuple(scale.get(x, 1.0) for x in axes if x != 'S') + else: + scale = tuple(scale.get(x, 1.0) for x in axes if x not in 'CS') + + kwargs = dict( + rgb=rgb, + channel_axis=channel_axis, + name=name, + scale=scale, + colormap=colormap, + contrast_limits=contrast_limits, + blending=blending, + visible=visible, + ) + return kwargs + + +def get_ome_tiff_metadata(tif: TiffFile) -> dict[str, Any]: + ome_metadata = xml2dict(tif.ome_metadata).get('OME') + image_metadata = ensure_list(ome_metadata.get('Image', {}))[0] + pixels = image_metadata.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'))) + + 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 + + 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)) + + if is_rgb: + # channels_axis appears to be incompatible with RGB channels + channel_axis = None + + names = [] + contrast_limits = [] + colormaps = [] + blendings = [] + visibles = [] + + scale = None + if pixel_size: + scale = pixel_size + + for channeli, channel in enumerate(channels): + 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 + + blending = 'additive' + visible = True + + if dtype.kind == 'f': + contrast_limit = None + else: + info = numpy.iinfo(dtype) + contrast_limit = (info.min, info.max) + + if len(channels) > 1: + names.append(name) + blendings.append(blending) + contrast_limits.append(contrast_limit) + colormaps.append(colormap) + visibles.append(visible) + else: + names = name + blendings = blending + contrast_limits = contrast_limit + colormaps = colormap + visibles = visible + + kwargs = dict( + rgb=is_rgb, + channel_axis=channel_axis, + name=names, + scale=scale, + colormap=colormaps, + contrast_limits=contrast_limits, + blending=blendings, + visible=visibles, + ) + return kwargs + + +def get_value_units_micrometer(value: float, unit: str = None) -> float: + unit_conversions = {'nm': 1e-3, 'µm': 1, 'um': 1, 'micrometer': 1, 'mm': 1e3, 'cm': 1e4, 'm': 1e6} + if unit: + value_um = value * unit_conversions.get(unit, 1) + else: + value_um = value + return value_um + + +def ensure_list(x): + if not isinstance(x, (list, tuple)): + x = [x] + return x diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index cb8419e..56bb72b 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -11,16 +11,14 @@ """ from typing import List, Optional, Union, Any, Tuple, Dict, Callable -import numpy -from tifffile import TiffFile, TiffSequence, TIFF, xml2dict, PHOTOMETRIC -from vispy.color import Colormap +from tifffile import TiffFile, TiffSequence, TIFF + +from napari_tiff.napari_tiff_metadata import get_metadata 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. @@ -83,340 +81,10 @@ def tifffile_reader(tif): data.append(level_data) else: data = tif.asarray() - if tif.is_ome: - kwargs = get_ome_tiff_metadata(tif) - # TODO: combine interpretation of imagej and tags metadata?: - elif tif.is_imagej: - kwargs = get_imagej_metadata(tif) - else: - kwargs = get_tiff_metadata(tif) - return [(data, kwargs, 'image')] - - -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 - dtype = series.dtype - axes = series.axes - shape = series.shape - page = next(p for p in series.pages if p is not None) - extrasamples = page.extrasamples - - rgb = page.photometric in (2, 6) and shape[-1] in (3, 4) - name = None - scale = None - colormap = None - contrast_limits = None - blending = None - channel_axis = None - visible = True - - if page.photometric == 5: - # CMYK - channel_axis = axes.find('S') - if channel_axis >= 0 and shape[channel_axis] >= 4: - colormap = cmyk_colormaps() - name = ['Cyan', 'Magenta', 'Yellow', 'Black'] - visible = [False, False, False, True] - blending = ['additive', 'additive', 'additive', 'additive'] - # TODO: use subtractive blending - else: - channel_axis = None - elif ( - page.photometric in (2, 6) and ( - page.planarconfig == 2 or - (page.bitspersample > 8 and dtype.kind in 'iu') or - (extrasamples and len(extrasamples) > 1) - ) - ): - # RGB >8-bit or planar, or with multiple extrasamples - channel_axis = axes.find('S') - if channel_axis >= 0 and shape[channel_axis] > 2: - rgb = False - visible = [True, True, True] - colormap = ['red', 'green', 'blue'] # rgb_colormaps() - name = ['Red', 'Green', 'Blue'] - blending = ['additive', 'additive', 'additive'] - else: - channel_axis = None - elif ( - page.photometric in (0, 1) and - extrasamples and - any(sample > 0 for sample in extrasamples) - ): - # Grayscale with alpha channel - channel_axis = axes.find('S') - if channel_axis >= 0: - visible = [True] - colormap = ['gray'] - name = ['Minisblack' if page.photometric == 1 else 'Miniswhite'] - blending = ['additive'] - else: - channel_axis = None - - if channel_axis is not None and extrasamples: - # add extrasamples - for sample in extrasamples: - if sample == 0: - # UNSPECIFIED - visible.append(False) # hide by default - colormap.append('gray') - name.append('Extrasample') - blending.append('additive') - else: - # alpha channel - # TODO: handle ASSOCALPHA and UNASSALPHA - visible.append(True) - colormap.append(alpha_colormap()) - name.append('Alpha') - blending.append('translucent') - - if channel_axis is None and page.photometric in (0, 1): - # separate up to 3 samples in grayscale images - channel_axis = axes.find('S') - if channel_axis >= 0 and 1 < shape[channel_axis] < 4: - n = shape[channel_axis] - colormap = ['red', 'green', 'blue', 'gray', - 'cyan', 'magenta', 'yellow'][:n] - name = [f'Sample {i}' for i in range(n)] - else: - channel_axis = None - - if channel_axis is None: - # separate up to 3 channels - channel_axis = axes.find('C') - if channel_axis > 0 and 1 < shape[channel_axis] < 4: - n = shape[channel_axis] - colormap = ['red', 'green', 'blue', 'gray', - 'cyan', 'magenta', 'yellow'][:n] - name = [f'Channel {i}' for i in range(n)] - else: - channel_axis = None - - if page.photometric == 3 and page.colormap is not None: - # PALETTE - colormap = page.colormap - if numpy.max(colormap) > 255: - colormap = colormap / 65535.0 - else: - colormap = colormap / 255.0 - colormap = Colormap(colormap.astype('float32').T) - - if colormap is None and page.photometric == 0: - # MINISBLACK - colormap = 'gray_r' - - if ( - contrast_limits is None and - dtype.kind == 'u' and - page.photometric != 3 and - page.bitspersample not in (8, 16, 32, 64) - ): - contrast_limits = (0, 2**page.bitspersample) - if channel_axis is not None and shape[channel_axis] > 1: - contrast_limits = [contrast_limits] * shape[channel_axis] - - kwargs = dict( - rgb=rgb, - channel_axis=channel_axis, - name=name, - scale=scale, - colormap=colormap, - contrast_limits=contrast_limits, - blending=blending, - visible=visible, - ) - return kwargs - - -def get_imagej_metadata(tif): - """Return napari LayerData from ImageJ hyperstack.""" - # TODO: ROI overlays - ijmeta = tif.imagej_metadata - series = tif.series[0] - - dtype = series.dtype - axes = series.axes - shape = series.shape - page = series.pages[0] - rgb = page.photometric == 2 and shape[-1] in (3, 4) - mode = ijmeta.get('mode', None) - channels = ijmeta.get('channels', 1) - channel_axis = None - - name = None - scale = None - colormap = None - contrast_limits = None - blending = None - visible = True - - if mode in ('composite', 'color'): - channel_axis = axes.find('C') - if channel_axis < 0: - channel_axis = None - - if channel_axis is not None: - channels = shape[channel_axis] - channel_only = channels == ijmeta.get('images', 0) - - if 'LUTs' in ijmeta: - colormap = [Colormap(c.T / 255.0) for c in ijmeta['LUTs']] - elif mode == 'grayscale': - colormap = 'gray' - elif channels < 8: - colormap = ['red', 'green', 'blue', 'gray', - 'cyan', 'magenta', 'yellow'][:channels] - - if 'Ranges' in ijmeta: - contrast_limits = numpy.array(ijmeta['Ranges']).reshape(-1, 2) - contrast_limits = contrast_limits.tolist() - - if channel_only and 'Labels' in ijmeta: - name = ijmeta['Labels'] - elif channels > 1: - name = [f'Channel {i}' for i in range(channels)] - - if mode in ('color', 'grayscale'): - blending = 'opaque' - - 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): - rgb = False - n = shape[channel_axis] - visible = [True, True, True] - colormap = rgb_colormaps(samples=4)[:n] - name = ['Red', 'Green', 'Blue', 'Alpha'][:n] - blending = ['additive', 'additive', 'additive', 'translucent'][:n] - else: - channel_axis = None - - scale = {} - res = page.tags.get('XResolution') - if res is not None: - scale['X'] = res.value[1] / max(res.value[0], 1) - res = page.tags.get('YResolution') - if res is not None: - scale['Y'] = res.value[1] / max(res.value[0], 1) - scale['Z'] = abs(ijmeta.get('spacing', 1.0)) - if channel_axis is None: - scale = tuple(scale.get(x, 1.0) for x in axes if x != 'S') - else: - scale = tuple(scale.get(x, 1.0) for x in axes if x not in 'CS') - - kwargs = dict( - rgb=rgb, - channel_axis=channel_axis, - name=name, - scale=scale, - colormap=colormap, - contrast_limits=contrast_limits, - blending=blending, - visible=visible, - ) - return kwargs + metadata_kwargs = get_metadata(tif) -def get_ome_tiff_metadata(tif): - metadata = xml2dict(tif.ome_metadata) - if 'OME' in metadata: - metadata = metadata['OME'] - - 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'))) - - 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 - - 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)) - - if is_rgb: - # channels_axis appears to be incompatible with RGB channels - channel_axis = None - - names = [] - contrast_limits = [] - colormaps = [] - blendings = [] - visibles = [] - - scale = None - if pixel_size: - scale = pixel_size - - for channeli, channel in enumerate(channels): - 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 - - blending = 'additive' - visible = True - - if dtype.kind == 'f': - contrast_limit = None - else: - info = numpy.iinfo(dtype) - contrast_limit = (info.min, info.max) - - if len(channels) > 1: - names.append(name) - blendings.append(blending) - contrast_limits.append(contrast_limit) - colormaps.append(colormap) - visibles.append(visible) - else: - names = name - blendings = blending - contrast_limits = contrast_limit - colormaps = colormap - visibles = visible - - kwargs = dict( - rgb=is_rgb, - channel_axis=channel_axis, - name=names, - scale=scale, - colormap=colormaps, - contrast_limits=contrast_limits, - blending=blendings, - visible=visibles, - metadata=metadata, - ) - return kwargs + return [(data, metadata_kwargs, 'image')] def imagecodecs_reader(path): @@ -425,79 +93,6 @@ 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 - ramp = numpy.linspace(0.0, 1.0, n).astype('float32') - a = numpy.zeros((n, samples), dtype='float32') - a[:, 3] = ramp[::-1] - return Colormap(a) - - -def rgb_colormaps(bitspersample=8, samples=3): - """Return RGB colormaps.""" - n = 2**bitspersample - ramp = numpy.linspace(0.0, 1.0, n).astype('float32') - r = numpy.zeros((n, samples), dtype='float32') - r[:, 0] = ramp - g = numpy.zeros((n, samples), dtype='float32') - g[:, 1] = ramp - b = numpy.zeros((n, samples), dtype='float32') - b[:, 2] = ramp - if samples > 3: - r[:, 3:] = 1.0 - g[:, 3:] = 1.0 - b[:, 3:] = 1.0 - return [Colormap(r), Colormap(g), Colormap(b)] - - -def cmyk_colormaps(bitspersample=8, samples=3): - """Return CMYK colormaps.""" - n = 2**bitspersample - ramp = numpy.linspace(1.0, 0.0, n).astype('float32') - c = numpy.zeros((n, samples), dtype='float32') - c[:, 1] = ramp - c[:, 2] = ramp - m = numpy.zeros((n, samples), dtype='float32') - m[:, 0] = ramp - m[:, 2] = ramp - y = numpy.zeros((n, samples), dtype='float32') - y[:, 0] = ramp - y[:, 1] = ramp - k = numpy.zeros((n, samples), dtype='float32') - k[:, 0] = ramp - k[:, 1] = ramp - k[:, 2] = ramp - if samples > 3: - c[:, 3:] = 1.0 - m[:, 3:] = 1.0 - y[:, 3:] = 1.0 - k[:, 3:] = 1.0 - 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 From 1d14fa9b9348d75daa7041721f1f219078fe2bc1 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Tue, 26 Mar 2024 15:19:10 +1100 Subject: [PATCH 15/25] Replace metadata test cases with simplified setup --- napari_tiff/_tests/test_tiff_metadata.py | 25 ++++++ .../_tests/test_tiff_reader_metadata.py | 83 ------------------- 2 files changed, 25 insertions(+), 83 deletions(-) create mode 100644 napari_tiff/_tests/test_tiff_metadata.py delete mode 100644 napari_tiff/_tests/test_tiff_reader_metadata.py diff --git a/napari_tiff/_tests/test_tiff_metadata.py b/napari_tiff/_tests/test_tiff_metadata.py new file mode 100644 index 0000000..4655fc7 --- /dev/null +++ b/napari_tiff/_tests/test_tiff_metadata.py @@ -0,0 +1,25 @@ +import numpy as np +import pytest +from tifffile import xml2dict + +from napari_tiff.napari_tiff_reader import tifffile_reader +from napari_tiff.napari_tiff_metadata import get_extra_metadata +from napari_tiff._tests.test_data import example_data_imagej, example_data_ometiff, example_data_multiresolution + + +@pytest.mark.parametrize("data_fixture, original_data, metadata_type", [ + (example_data_ometiff, np.random.randint(0, 255, size=(20, 20)).astype(np.uint8), 'ome_metadata'), + (example_data_imagej, np.random.randint(0, 255, size=(20, 20)).astype(np.uint8), 'imagej_metadata'), + ]) +def test_metadata_dict(tmp_path, data_fixture, original_data, metadata_type): + """Check the 'metadata' dict stored with the layer data contains expected values.""" + test_data = data_fixture(tmp_path, original_data) + result_metadata = tifffile_reader(test_data)[0][1] + # check metadata against TiffFile source metadata + expected_metadata = getattr(test_data, metadata_type) + if isinstance(expected_metadata, str): + expected_metadata = xml2dict(expected_metadata) + assert result_metadata.get('metadata').get(metadata_type) == expected_metadata + # check metadata in layer is identical to the extra metadata dictionary result + extra_metadata_dict = get_extra_metadata(test_data) + assert result_metadata.get('metadata') == extra_metadata_dict diff --git a/napari_tiff/_tests/test_tiff_reader_metadata.py b/napari_tiff/_tests/test_tiff_reader_metadata.py deleted file mode 100644 index d8c0df1..0000000 --- a/napari_tiff/_tests/test_tiff_reader_metadata.py +++ /dev/null @@ -1,83 +0,0 @@ -from glob import glob -from napari.layers import Layer, Image -import numpy as np -import pytest -import tifffile - -from napari_tiff import napari_get_reader - - -viewer = None - - -def generate_ometiff_file(tmp_path, filename, data, metadata): - filepath = str(tmp_path / filename) - tifffile.imwrite(filepath, data, ome=True, metadata=metadata) - return filepath - - -def get_files(tmp_path, path, data, metadata): - # TODO download files instead; path can be URL - filepaths = glob(path) - return filepaths - - -@pytest.mark.parametrize("data_fixture, original_filename, original_data, original_metadata", [ - (generate_ometiff_file, "single_channel.ome.tif", np.random.randint(0, 255, size=(16, 16)).astype(np.uint8), None), - (generate_ometiff_file, "multi_channel.ome.tif", np.random.randint(0, 65535, size=(2, 16, 16)).astype(np.uint16), - {'Channel': [{'Name': 'WF', 'Color': '-1'}, {'Name': 'Fluor', 'Color': '16711935'}]}), - (generate_ometiff_file, "rgb.ome.tif", np.random.randint(0, 255, size=(16, 16, 3)).astype(np.uint8), None), - (get_files, "D:/slides/test/*", None, None), - ]) -def test_reader(data_fixture, original_filename, original_data, original_metadata, tmp_path): - global viewer - - test_files = data_fixture(tmp_path, original_filename, original_data, original_metadata) - if not isinstance(test_files, list): - test_files = [test_files] - - for test_file in test_files: - # 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 - if 'channel_axis' in metadata: - if viewer is None: - from napari import Viewer - viewer = Viewer() - layers = viewer.add_image(data, **metadata) - if not isinstance(layers, list): - layers = [layers] - for layer in layers: - assert isinstance(layer, Image) - else: - layer = Layer.create(*layer_data) # incompatible with channel_axis - assert isinstance(layer, Image) - - layer = Image(data, **metadata) # incompatible with channel_axis - assert isinstance(layer, Image) From 381249e3066d15053024916e3eb9a308e1388752 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Tue, 26 Mar 2024 15:20:21 +1100 Subject: [PATCH 16/25] Ensure both zarr & dask could raise an ImportError if not installed here --- napari_tiff/napari_tiff_reader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index 56bb72b..fc469a6 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -73,6 +73,7 @@ def tifffile_reader(tif): nlevels = len(tif.series[0].levels) if nlevels > 1: import dask.array as da + import zarr data = [] for level in range(nlevels): level_data = da.from_zarr(tif.aszarr(level=level)) From 7cbd9be4b415a56c533ecc1f96a505486bf8bd7d Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Tue, 26 Mar 2024 15:57:27 +1100 Subject: [PATCH 17/25] Minor updates --- napari_tiff/_tests/test_data.py | 2 +- napari_tiff/_tests/test_tiff_reader.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/napari_tiff/_tests/test_data.py b/napari_tiff/_tests/test_data.py index 4b3c4dd..2959540 100644 --- a/napari_tiff/_tests/test_data.py +++ b/napari_tiff/_tests/test_data.py @@ -80,7 +80,7 @@ def example_data_multiresolution(tmp_path): This example code reproduced from tifffile.py, see: https://github.com/cgohlke/tifffile/blob/2b5a5208008594976d4627bcf01355fc08837592/tifffile/tifffile.py#L649-L688 """ - example_data_filepath = str(tmp_path / "'test-pyramid.ome.tif'") + example_data_filepath = str(tmp_path / "test-pyramid.ome.tif") data = np.random.randint(0, 255, (8, 2, 512, 512, 3), 'uint8') subresolutions = 2 # so 3 resolution levels in total pixelsize = 0.29 # micrometer diff --git a/napari_tiff/_tests/test_tiff_reader.py b/napari_tiff/_tests/test_tiff_reader.py index ae31c92..5a7b7b8 100644 --- a/napari_tiff/_tests/test_tiff_reader.py +++ b/napari_tiff/_tests/test_tiff_reader.py @@ -52,7 +52,7 @@ def test_reader(tmp_path, data_fixture, original_data): (imagecodecs_reader, example_data_filepath, np.random.random((20, 20))), (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)), + (tifffile_reader, example_data_ometiff, np.random.randint(0, 255, size=(20, 20)).astype(np.uint8)), (zip_reader, example_data_zipped_filepath, np.random.random((20, 20))), ]) def test_all_readers(reader, data_fixture, original_data, tmp_path): From 2a656101eefa3e3cb66775758972bdb596881180 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:02:30 +1100 Subject: [PATCH 18/25] Remove duplicate test file --- napari_tiff/_tests/test_pyramids.py | 49 ----------------------------- 1 file changed, 49 deletions(-) delete mode 100644 napari_tiff/_tests/test_pyramids.py diff --git a/napari_tiff/_tests/test_pyramids.py b/napari_tiff/_tests/test_pyramids.py deleted file mode 100644 index dc9e2b7..0000000 --- a/napari_tiff/_tests/test_pyramids.py +++ /dev/null @@ -1,49 +0,0 @@ -import numpy as np -from tifffile import TiffWriter - - -data = np.random.randint(0, 255, (8, 2, 512, 512, 3), 'uint8') -subresolutions = 2 -pixelsize = 0.29 # micrometer -with TiffWriter('temp-pyramid.ome.tif', bigtiff=True) as tif: - metadata={ - 'axes': 'TCYXS', - 'SignificantBits': 8, - 'TimeIncrement': 0.1, - 'TimeIncrementUnit': 's', - 'PhysicalSizeX': pixelsize, - 'PhysicalSizeXUnit': 'µm', - 'PhysicalSizeY': pixelsize, - 'PhysicalSizeYUnit': 'µm', - 'Channel': {'Name': ['Channel 1', 'Channel 2']}, - 'Plane': {'PositionX': [0.0] * 16, 'PositionXUnit': ['µm'] * 16} - } - options = dict( - photometric='rgb', - tile=(128, 128), - compression='jpeg', - resolutionunit='CENTIMETER', - maxworkers=2 - ) - tif.write( - data, - subifds=subresolutions, - resolution=(1e4 / pixelsize, 1e4 / pixelsize), - metadata=metadata, - **options - ) - # write pyramid levels to the two subifds - # in production use resampling to generate sub-resolution images - for level in range(subresolutions): - mag = 2**(level + 1) - tif.write( - data[..., ::mag, ::mag, :], - subfiletype=1, - resolution=(1e4 / mag / pixelsize, 1e4 / mag / pixelsize), - **options - ) - # add a thumbnail image as a separate series - # it is recognized by QuPath as an associated image - thumbnail = (data[0, 0, ::8, ::8] >> 2).astype('uint8') - tif.write(thumbnail, metadata={'Name': 'thumbnail'}) - From 9787318cb6b0f333cbb7a1110746718ee2a4aec8 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:04:16 +1100 Subject: [PATCH 19/25] isort imports (minor formatting fix) --- napari_tiff/__init__.py | 1 - napari_tiff/_tests/test_tiff_metadata.py | 8 +++++--- napari_tiff/_tests/test_tiff_reader.py | 14 ++++++-------- napari_tiff/napari_tiff_metadata.py | 8 +++++--- napari_tiff/napari_tiff_reader.py | 4 ++-- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/napari_tiff/__init__.py b/napari_tiff/__init__.py index 1ac1555..522b05b 100644 --- a/napari_tiff/__init__.py +++ b/napari_tiff/__init__.py @@ -6,5 +6,4 @@ # replace the asterisk with named imports from .napari_tiff_reader import napari_get_reader - __all__ = ["napari_get_reader"] diff --git a/napari_tiff/_tests/test_tiff_metadata.py b/napari_tiff/_tests/test_tiff_metadata.py index a7dd85a..2bfcfba 100644 --- a/napari_tiff/_tests/test_tiff_metadata.py +++ b/napari_tiff/_tests/test_tiff_metadata.py @@ -1,10 +1,12 @@ import numpy as np import pytest -from tifffile import xml2dict, imwrite, TiffFile +from tifffile import TiffFile, imwrite, xml2dict -from napari_tiff.napari_tiff_reader import tifffile_reader +from napari_tiff._tests.test_data import (example_data_imagej, + example_data_ometiff, + imagej_hyperstack_image) from napari_tiff.napari_tiff_metadata import get_extra_metadata -from napari_tiff._tests.test_data import example_data_imagej, example_data_ometiff, imagej_hyperstack_image +from napari_tiff.napari_tiff_reader import tifffile_reader @pytest.mark.parametrize("data_fixture, original_data, metadata_type", [ diff --git a/napari_tiff/_tests/test_tiff_reader.py b/napari_tiff/_tests/test_tiff_reader.py index 5a7b7b8..16761c2 100644 --- a/napari_tiff/_tests/test_tiff_reader.py +++ b/napari_tiff/_tests/test_tiff_reader.py @@ -4,16 +4,14 @@ import tifffile from napari_tiff import napari_get_reader +from napari_tiff._tests.test_data import (example_data_filepath, + example_data_imagej, + example_data_multiresolution, + example_data_ometiff, + example_data_tiff, + example_data_zipped_filepath) from napari_tiff.napari_tiff_reader import (imagecodecs_reader, tifffile_reader, zip_reader) -from napari_tiff._tests.test_data import ( - example_data_filepath, - example_data_zipped_filepath, - example_data_tiff, - example_data_imagej, - example_data_ometiff, - example_data_multiresolution, -) def test_get_reader_pass(): diff --git a/napari_tiff/napari_tiff_metadata.py b/napari_tiff/napari_tiff_metadata.py index 780798c..f93db2c 100644 --- a/napari_tiff/napari_tiff_metadata.py +++ b/napari_tiff/napari_tiff_metadata.py @@ -1,9 +1,11 @@ -import numpy -from tifffile import xml2dict, PHOTOMETRIC, TiffFile from typing import Any + +import numpy +from tifffile import PHOTOMETRIC, TiffFile, xml2dict from vispy.color import Colormap -from napari_tiff.napari_tiff_colormaps import alpha_colormap, cmyk_colormaps, int_to_rgba, rgb_colormaps +from napari_tiff.napari_tiff_colormaps import (alpha_colormap, cmyk_colormaps, + int_to_rgba, rgb_colormaps) def get_metadata(tif: TiffFile) -> dict[str, Any]: diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index b548a48..525bf38 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -9,10 +9,10 @@ Replace code below accordingly. For complete documentation see: https://napari.org/docs/plugins/for_plugin_developers.html """ -from typing import List, Optional, Union, Any, Tuple, Dict, Callable +from typing import Any, Callable, Dict, List, Optional, Tuple, Union import numpy -from tifffile import TiffFile, TiffSequence, TIFF, xml2dict +from tifffile import TIFF, TiffFile, TiffSequence, xml2dict from napari_tiff.napari_tiff_metadata import get_metadata From ebaa2d9bfabcc80b2eac4a71c8567ab4e5b13394 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:06:10 +1100 Subject: [PATCH 20/25] Formatting code with black --- napari_tiff/_tests/test_data.py | 60 +++---- napari_tiff/_tests/test_tiff_metadata.py | 56 +++++-- napari_tiff/_tests/test_tiff_reader.py | 61 ++++--- napari_tiff/napari_tiff_colormaps.py | 24 +-- napari_tiff/napari_tiff_metadata.py | 195 ++++++++++++----------- napari_tiff/napari_tiff_reader.py | 14 +- 6 files changed, 238 insertions(+), 172 deletions(-) diff --git a/napari_tiff/_tests/test_data.py b/napari_tiff/_tests/test_data.py index 2959540..36ab2db 100644 --- a/napari_tiff/_tests/test_data.py +++ b/napari_tiff/_tests/test_data.py @@ -16,7 +16,7 @@ def example_data_zipped_filepath(tmp_path, original_data): example_tiff_filepath = str(tmp_path / "myfile.tif") tifffile.imwrite(example_tiff_filepath, original_data, imagej=False) example_zipped_filepath = str(tmp_path / "myfile.zip") - with zipfile.ZipFile(example_zipped_filepath, 'w') as myzip: + with zipfile.ZipFile(example_zipped_filepath, "w") as myzip: myzip.write(example_tiff_filepath) os.remove(example_tiff_filepath) # not needed now the zip file is saved return example_zipped_filepath @@ -49,21 +49,21 @@ def imagej_hyperstack_image(tmp_path_factory): """ filename = tmp_path_factory.mktemp("data") / "imagej_hyperstack.tif" - volume = np.random.randn(6, 57, 256, 256).astype('float32') - image_labels = [f'{i}' for i in range(volume.shape[0] * volume.shape[1])] + volume = np.random.randn(6, 57, 256, 256).astype("float32") + image_labels = [f"{i}" for i in range(volume.shape[0] * volume.shape[1])] metadata = { - 'spacing': 3.947368, - 'unit': 'um', - 'finterval': 1/10, - 'fps': 10.0, - 'axes': 'TZYX', - 'Labels': image_labels, - } + "spacing": 3.947368, + "unit": "um", + "finterval": 1 / 10, + "fps": 10.0, + "axes": "TZYX", + "Labels": image_labels, + } tifffile.imwrite( filename, volume, imagej=True, - resolution=(1./2.6755, 1./2.6755), + resolution=(1.0 / 2.6755, 1.0 / 2.6755), metadata=metadata, ) return (filename, metadata) @@ -81,44 +81,44 @@ def example_data_multiresolution(tmp_path): https://github.com/cgohlke/tifffile/blob/2b5a5208008594976d4627bcf01355fc08837592/tifffile/tifffile.py#L649-L688 """ example_data_filepath = str(tmp_path / "test-pyramid.ome.tif") - data = np.random.randint(0, 255, (8, 2, 512, 512, 3), 'uint8') + data = np.random.randint(0, 255, (8, 2, 512, 512, 3), "uint8") subresolutions = 2 # so 3 resolution levels in total pixelsize = 0.29 # micrometer with tifffile.TiffWriter(example_data_filepath, bigtiff=True) as tif: - metadata={ - 'axes': 'TCYXS', - 'SignificantBits': 8, - 'TimeIncrement': 0.1, - 'TimeIncrementUnit': 's', - 'PhysicalSizeX': pixelsize, - 'PhysicalSizeXUnit': 'µm', - 'PhysicalSizeY': pixelsize, - 'PhysicalSizeYUnit': 'µm', - 'Channel': {'Name': ['Channel 1', 'Channel 2']}, - 'Plane': {'PositionX': [0.0] * 16, 'PositionXUnit': ['µm'] * 16} + metadata = { + "axes": "TCYXS", + "SignificantBits": 8, + "TimeIncrement": 0.1, + "TimeIncrementUnit": "s", + "PhysicalSizeX": pixelsize, + "PhysicalSizeXUnit": "µm", + "PhysicalSizeY": pixelsize, + "PhysicalSizeYUnit": "µm", + "Channel": {"Name": ["Channel 1", "Channel 2"]}, + "Plane": {"PositionX": [0.0] * 16, "PositionXUnit": ["µm"] * 16}, } options = dict( - photometric='rgb', + photometric="rgb", tile=(128, 128), - compression='jpeg', - resolutionunit='CENTIMETER', - maxworkers=2 + compression="jpeg", + resolutionunit="CENTIMETER", + maxworkers=2, ) tif.write( data, subifds=subresolutions, resolution=(1e4 / pixelsize, 1e4 / pixelsize), metadata=metadata, - **options + **options, ) # write pyramid levels to the two subifds # in production use resampling to generate sub-resolution images for level in range(subresolutions): - mag = 2**(level + 1) + mag = 2 ** (level + 1) tif.write( data[..., ::mag, ::mag, :], subfiletype=1, resolution=(1e4 / mag / pixelsize, 1e4 / mag / pixelsize), - **options + **options, ) return tifffile.TiffFile(example_data_filepath) diff --git a/napari_tiff/_tests/test_tiff_metadata.py b/napari_tiff/_tests/test_tiff_metadata.py index 2bfcfba..bfb0b2b 100644 --- a/napari_tiff/_tests/test_tiff_metadata.py +++ b/napari_tiff/_tests/test_tiff_metadata.py @@ -2,17 +2,30 @@ import pytest from tifffile import TiffFile, imwrite, xml2dict -from napari_tiff._tests.test_data import (example_data_imagej, - example_data_ometiff, - imagej_hyperstack_image) +from napari_tiff._tests.test_data import ( + example_data_imagej, + example_data_ometiff, + imagej_hyperstack_image, +) from napari_tiff.napari_tiff_metadata import get_extra_metadata from napari_tiff.napari_tiff_reader import tifffile_reader -@pytest.mark.parametrize("data_fixture, original_data, metadata_type", [ - (example_data_ometiff, np.random.randint(0, 255, size=(20, 20)).astype(np.uint8), 'ome_metadata'), - (example_data_imagej, np.random.randint(0, 255, size=(20, 20)).astype(np.uint8), 'imagej_metadata'), - ]) +@pytest.mark.parametrize( + "data_fixture, original_data, metadata_type", + [ + ( + example_data_ometiff, + np.random.randint(0, 255, size=(20, 20)).astype(np.uint8), + "ome_metadata", + ), + ( + example_data_imagej, + np.random.randint(0, 255, size=(20, 20)).astype(np.uint8), + "imagej_metadata", + ), + ], +) def test_metadata_dict(tmp_path, data_fixture, original_data, metadata_type): """Check the 'metadata' dict stored with the layer data contains expected values.""" test_data = data_fixture(tmp_path, original_data) @@ -21,10 +34,10 @@ def test_metadata_dict(tmp_path, data_fixture, original_data, metadata_type): expected_metadata = getattr(test_data, metadata_type) if isinstance(expected_metadata, str): expected_metadata = xml2dict(expected_metadata) - assert result_metadata.get('metadata').get(metadata_type) == expected_metadata + assert result_metadata.get("metadata").get(metadata_type) == expected_metadata # check metadata in layer is identical to the extra metadata dictionary result extra_metadata_dict = get_extra_metadata(test_data) - assert result_metadata.get('metadata') == extra_metadata_dict + assert result_metadata.get("metadata") == extra_metadata_dict def test_imagej_hyperstack_metadata(imagej_hyperstack_image): @@ -39,13 +52,26 @@ def test_imagej_hyperstack_metadata(imagej_hyperstack_image): assert isinstance(layer_data_tuple, tuple) and len(layer_data_tuple) == 3 napari_layer_metadata = layer_data_tuple[1] - assert napari_layer_metadata.get('scale') == (1.0, 3.947368, 2.675500000484335, 2.675500000484335) + assert napari_layer_metadata.get("scale") == ( + 1.0, + 3.947368, + 2.675500000484335, + 2.675500000484335, + ) assert layer_data_tuple[0].shape == (6, 57, 256, 256) # image volume shape - napari_layer_imagej_metadata = napari_layer_metadata.get('metadata').get('imagej_metadata') - assert napari_layer_imagej_metadata.get('slices') == 57 # calculated automatically when file is written - assert napari_layer_imagej_metadata.get('frames') == 6 # calculated automatically when file is written - expected_metadata.pop('axes') # 'axes' is stored as a tiff series attribute, not in the imagej_metadata property - for (key, val) in expected_metadata.items(): + napari_layer_imagej_metadata = napari_layer_metadata.get("metadata").get( + "imagej_metadata" + ) + assert ( + napari_layer_imagej_metadata.get("slices") == 57 + ) # calculated automatically when file is written + assert ( + napari_layer_imagej_metadata.get("frames") == 6 + ) # calculated automatically when file is written + expected_metadata.pop( + "axes" + ) # 'axes' is stored as a tiff series attribute, not in the imagej_metadata property + for key, val in expected_metadata.items(): assert key in napari_layer_imagej_metadata assert napari_layer_imagej_metadata.get(key) == val diff --git a/napari_tiff/_tests/test_tiff_reader.py b/napari_tiff/_tests/test_tiff_reader.py index 16761c2..bb9dfb1 100644 --- a/napari_tiff/_tests/test_tiff_reader.py +++ b/napari_tiff/_tests/test_tiff_reader.py @@ -4,14 +4,19 @@ import tifffile from napari_tiff import napari_get_reader -from napari_tiff._tests.test_data import (example_data_filepath, - example_data_imagej, - example_data_multiresolution, - example_data_ometiff, - example_data_tiff, - example_data_zipped_filepath) -from napari_tiff.napari_tiff_reader import (imagecodecs_reader, - tifffile_reader, zip_reader) +from napari_tiff._tests.test_data import ( + example_data_filepath, + example_data_imagej, + example_data_multiresolution, + example_data_ometiff, + example_data_tiff, + example_data_zipped_filepath, +) +from napari_tiff.napari_tiff_reader import ( + imagecodecs_reader, + tifffile_reader, + zip_reader, +) def test_get_reader_pass(): @@ -20,10 +25,13 @@ def test_get_reader_pass(): assert reader is None -@pytest.mark.parametrize("data_fixture, original_data", [ - (example_data_filepath, np.random.random((20, 20))), - (example_data_zipped_filepath, np.random.random((20, 20))), - ]) +@pytest.mark.parametrize( + "data_fixture, original_data", + [ + (example_data_filepath, np.random.random((20, 20))), + (example_data_zipped_filepath, np.random.random((20, 20))), + ], +) def test_reader(tmp_path, data_fixture, original_data): """Test tiff reader with example data filepaths.""" @@ -46,13 +54,28 @@ def test_reader(tmp_path, data_fixture, original_data): np.testing.assert_allclose(original_data, layer_data_tuple[0]) -@pytest.mark.parametrize("reader, data_fixture, original_data", [ - (imagecodecs_reader, example_data_filepath, np.random.random((20, 20))), - (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)).astype(np.uint8)), - (zip_reader, example_data_zipped_filepath, np.random.random((20, 20))), - ]) +@pytest.mark.parametrize( + "reader, data_fixture, original_data", + [ + (imagecodecs_reader, example_data_filepath, np.random.random((20, 20))), + ( + 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)).astype(np.uint8), + ), + (zip_reader, example_data_zipped_filepath, np.random.random((20, 20))), + ], +) def test_all_readers(reader, data_fixture, original_data, tmp_path): """Test each individual reader.""" assert callable(reader) diff --git a/napari_tiff/napari_tiff_colormaps.py b/napari_tiff/napari_tiff_colormaps.py index 761bec0..524d54a 100644 --- a/napari_tiff/napari_tiff_colormaps.py +++ b/napari_tiff/napari_tiff_colormaps.py @@ -5,8 +5,8 @@ def alpha_colormap(bitspersample=8, samples=4): """Return Alpha colormap.""" n = 2**bitspersample - ramp = numpy.linspace(0.0, 1.0, n).astype('float32') - a = numpy.zeros((n, samples), dtype='float32') + ramp = numpy.linspace(0.0, 1.0, n).astype("float32") + a = numpy.zeros((n, samples), dtype="float32") a[:, 3] = ramp[::-1] return Colormap(a) @@ -14,12 +14,12 @@ def alpha_colormap(bitspersample=8, samples=4): def rgb_colormaps(bitspersample=8, samples=3): """Return RGB colormaps.""" n = 2**bitspersample - ramp = numpy.linspace(0.0, 1.0, n).astype('float32') - r = numpy.zeros((n, samples), dtype='float32') + ramp = numpy.linspace(0.0, 1.0, n).astype("float32") + r = numpy.zeros((n, samples), dtype="float32") r[:, 0] = ramp - g = numpy.zeros((n, samples), dtype='float32') + g = numpy.zeros((n, samples), dtype="float32") g[:, 1] = ramp - b = numpy.zeros((n, samples), dtype='float32') + b = numpy.zeros((n, samples), dtype="float32") b[:, 2] = ramp if samples > 3: r[:, 3:] = 1.0 @@ -31,17 +31,17 @@ def rgb_colormaps(bitspersample=8, samples=3): def cmyk_colormaps(bitspersample=8, samples=3): """Return CMYK colormaps.""" n = 2**bitspersample - ramp = numpy.linspace(1.0, 0.0, n).astype('float32') - c = numpy.zeros((n, samples), dtype='float32') + ramp = numpy.linspace(1.0, 0.0, n).astype("float32") + c = numpy.zeros((n, samples), dtype="float32") c[:, 1] = ramp c[:, 2] = ramp - m = numpy.zeros((n, samples), dtype='float32') + m = numpy.zeros((n, samples), dtype="float32") m[:, 0] = ramp m[:, 2] = ramp - y = numpy.zeros((n, samples), dtype='float32') + y = numpy.zeros((n, samples), dtype="float32") y[:, 0] = ramp y[:, 1] = ramp - k = numpy.zeros((n, samples), dtype='float32') + k = numpy.zeros((n, samples), dtype="float32") k[:, 0] = ramp k[:, 1] = ramp k[:, 2] = ramp @@ -54,7 +54,7 @@ def cmyk_colormaps(bitspersample=8, samples=3): def int_to_rgba(intrgba: int) -> tuple: - signed = (intrgba < 0) + signed = intrgba < 0 rgba = [x / 255 for x in intrgba.to_bytes(4, signed=signed, byteorder="big")] if rgba[-1] == 0: rgba[-1] = 1 diff --git a/napari_tiff/napari_tiff_metadata.py b/napari_tiff/napari_tiff_metadata.py index f93db2c..7665414 100644 --- a/napari_tiff/napari_tiff_metadata.py +++ b/napari_tiff/napari_tiff_metadata.py @@ -4,8 +4,12 @@ from tifffile import PHOTOMETRIC, TiffFile, xml2dict from vispy.color import Colormap -from napari_tiff.napari_tiff_colormaps import (alpha_colormap, cmyk_colormaps, - int_to_rgba, rgb_colormaps) +from napari_tiff.napari_tiff_colormaps import ( + alpha_colormap, + cmyk_colormaps, + int_to_rgba, + rgb_colormaps, +) def get_metadata(tif: TiffFile) -> dict[str, Any]: @@ -21,7 +25,7 @@ def get_metadata(tif: TiffFile) -> dict[str, Any]: # napari does not use this this extra `metadata` layer attribute # but storing the information on the layer next to the data # will allow users to access it and use it themselves if they wish - metadata_kwargs['metadata'] = get_extra_metadata(tif) + metadata_kwargs["metadata"] = get_extra_metadata(tif) return metadata_kwargs @@ -29,10 +33,10 @@ def get_metadata(tif: TiffFile) -> dict[str, Any]: def get_extra_metadata(tif: TiffFile) -> dict[str, Any]: """Return any non-empty tif metadata properties as a dictionary.""" metadata_dict = {} - empty_metadata_values = [None, '', (), []] + empty_metadata_values = [None, "", (), []] for name in dir(tif.__class__): obj = getattr(tif.__class__, name) - if 'metadata' in name: + if "metadata" in name: metadata_value = obj.__get__(tif) if metadata_value not in empty_metadata_values: print(metadata_value) @@ -70,44 +74,42 @@ def get_tiff_metadata(tif: TiffFile) -> dict[str, Any]: if page.photometric == 5: # CMYK - channel_axis = axes.find('S') + channel_axis = axes.find("S") if channel_axis >= 0 and shape[channel_axis] >= 4: colormap = cmyk_colormaps() - name = ['Cyan', 'Magenta', 'Yellow', 'Black'] + name = ["Cyan", "Magenta", "Yellow", "Black"] visible = [False, False, False, True] - blending = ['additive', 'additive', 'additive', 'additive'] + blending = ["additive", "additive", "additive", "additive"] # TODO: use subtractive blending else: channel_axis = None - elif ( - page.photometric in (2, 6) and ( - page.planarconfig == 2 or - (page.bitspersample > 8 and dtype.kind in 'iu') or - (extrasamples and len(extrasamples) > 1) - ) + elif page.photometric in (2, 6) and ( + page.planarconfig == 2 + or (page.bitspersample > 8 and dtype.kind in "iu") + or (extrasamples and len(extrasamples) > 1) ): # RGB >8-bit or planar, or with multiple extrasamples - channel_axis = axes.find('S') + channel_axis = axes.find("S") if channel_axis >= 0 and shape[channel_axis] > 2: rgb = False visible = [True, True, True] - colormap = ['red', 'green', 'blue'] # rgb_colormaps() - name = ['Red', 'Green', 'Blue'] - blending = ['additive', 'additive', 'additive'] + colormap = ["red", "green", "blue"] # rgb_colormaps() + name = ["Red", "Green", "Blue"] + blending = ["additive", "additive", "additive"] else: channel_axis = None elif ( - page.photometric in (0, 1) and - extrasamples and - any(sample > 0 for sample in extrasamples) + page.photometric in (0, 1) + and extrasamples + and any(sample > 0 for sample in extrasamples) ): # Grayscale with alpha channel - channel_axis = axes.find('S') + channel_axis = axes.find("S") if channel_axis >= 0: visible = [True] - colormap = ['gray'] - name = ['Minisblack' if page.photometric == 1 else 'Miniswhite'] - blending = ['additive'] + colormap = ["gray"] + name = ["Minisblack" if page.photometric == 1 else "Miniswhite"] + blending = ["additive"] else: channel_axis = None @@ -117,36 +119,34 @@ def get_tiff_metadata(tif: TiffFile) -> dict[str, Any]: if sample == 0: # UNSPECIFIED visible.append(False) # hide by default - colormap.append('gray') - name.append('Extrasample') - blending.append('additive') + colormap.append("gray") + name.append("Extrasample") + blending.append("additive") else: # alpha channel # TODO: handle ASSOCALPHA and UNASSALPHA visible.append(True) colormap.append(alpha_colormap()) - name.append('Alpha') - blending.append('translucent') + name.append("Alpha") + blending.append("translucent") if channel_axis is None and page.photometric in (0, 1): # separate up to 3 samples in grayscale images - channel_axis = axes.find('S') + channel_axis = axes.find("S") if channel_axis >= 0 and 1 < shape[channel_axis] < 4: n = shape[channel_axis] - colormap = ['red', 'green', 'blue', 'gray', - 'cyan', 'magenta', 'yellow'][:n] - name = [f'Sample {i}' for i in range(n)] + colormap = ["red", "green", "blue", "gray", "cyan", "magenta", "yellow"][:n] + name = [f"Sample {i}" for i in range(n)] else: channel_axis = None if channel_axis is None: # separate up to 3 channels - channel_axis = axes.find('C') + channel_axis = axes.find("C") if channel_axis > 0 and 1 < shape[channel_axis] < 4: n = shape[channel_axis] - colormap = ['red', 'green', 'blue', 'gray', - 'cyan', 'magenta', 'yellow'][:n] - name = [f'Channel {i}' for i in range(n)] + colormap = ["red", "green", "blue", "gray", "cyan", "magenta", "yellow"][:n] + name = [f"Channel {i}" for i in range(n)] else: channel_axis = None @@ -157,17 +157,17 @@ def get_tiff_metadata(tif: TiffFile) -> dict[str, Any]: colormap = colormap / 65535.0 else: colormap = colormap / 255.0 - colormap = Colormap(colormap.astype('float32').T) + colormap = Colormap(colormap.astype("float32").T) if colormap is None and page.photometric == 0: # MINISBLACK - colormap = 'gray_r' + colormap = "gray_r" if ( - contrast_limits is None and - dtype.kind == 'u' and - page.photometric != 3 and - page.bitspersample not in (8, 16, 32, 64) + contrast_limits is None + and dtype.kind == "u" + and page.photometric != 3 + and page.bitspersample not in (8, 16, 32, 64) ): contrast_limits = (0, 2**page.bitspersample) if channel_axis is not None and shape[channel_axis] > 1: @@ -197,8 +197,8 @@ def get_imagej_metadata(tif: TiffFile) -> dict[str, Any]: shape = series.shape page = series.pages[0] rgb = page.photometric == 2 and shape[-1] in (3, 4) - mode = ijmeta.get('mode', None) - channels = ijmeta.get('channels', 1) + mode = ijmeta.get("mode", None) + channels = ijmeta.get("channels", 1) channel_axis = None name = None @@ -208,60 +208,61 @@ def get_imagej_metadata(tif: TiffFile) -> dict[str, Any]: blending = None visible = True - if mode in ('composite', 'color'): - channel_axis = axes.find('C') + if mode in ("composite", "color"): + channel_axis = axes.find("C") if channel_axis < 0: channel_axis = None if channel_axis is not None: channels = shape[channel_axis] - channel_only = channels == ijmeta.get('images', 0) + channel_only = channels == ijmeta.get("images", 0) - if 'LUTs' in ijmeta: - colormap = [Colormap(c.T / 255.0) for c in ijmeta['LUTs']] - elif mode == 'grayscale': - colormap = 'gray' + if "LUTs" in ijmeta: + colormap = [Colormap(c.T / 255.0) for c in ijmeta["LUTs"]] + elif mode == "grayscale": + colormap = "gray" elif channels < 8: - colormap = ['red', 'green', 'blue', 'gray', - 'cyan', 'magenta', 'yellow'][:channels] + colormap = ["red", "green", "blue", "gray", "cyan", "magenta", "yellow"][ + :channels + ] - if 'Ranges' in ijmeta: - contrast_limits = numpy.array(ijmeta['Ranges']).reshape(-1, 2) + if "Ranges" in ijmeta: + contrast_limits = numpy.array(ijmeta["Ranges"]).reshape(-1, 2) contrast_limits = contrast_limits.tolist() - if channel_only and 'Labels' in ijmeta: - name = ijmeta['Labels'] + if channel_only and "Labels" in ijmeta: + name = ijmeta["Labels"] elif channels > 1: - name = [f'Channel {i}' for i in range(channels)] + name = [f"Channel {i}" for i in range(channels)] - if mode in ('color', 'grayscale'): - blending = 'opaque' + if mode in ("color", "grayscale"): + blending = "opaque" - elif axes[-1] == 'S' and dtype == 'uint16': + elif axes[-1] == "S" and dtype == "uint16": # RGB >8-bit - channel_axis = axes.find('S') + channel_axis = axes.find("S") if channel_axis >= 0 and shape[channel_axis] in (3, 4): rgb = False n = shape[channel_axis] visible = [True, True, True] colormap = rgb_colormaps(samples=4)[:n] - name = ['Red', 'Green', 'Blue', 'Alpha'][:n] - blending = ['additive', 'additive', 'additive', 'translucent'][:n] + name = ["Red", "Green", "Blue", "Alpha"][:n] + blending = ["additive", "additive", "additive", "translucent"][:n] else: channel_axis = None scale = {} - res = page.tags.get('XResolution') + res = page.tags.get("XResolution") if res is not None: - scale['X'] = res.value[1] / max(res.value[0], 1) - res = page.tags.get('YResolution') + scale["X"] = res.value[1] / max(res.value[0], 1) + res = page.tags.get("YResolution") if res is not None: - scale['Y'] = res.value[1] / max(res.value[0], 1) - scale['Z'] = abs(ijmeta.get('spacing', 1.0)) + scale["Y"] = res.value[1] / max(res.value[0], 1) + scale["Z"] = abs(ijmeta.get("spacing", 1.0)) if channel_axis is None: - scale = tuple(scale.get(x, 1.0) for x in axes if x != 'S') + scale = tuple(scale.get(x, 1.0) for x in axes if x != "S") else: - scale = tuple(scale.get(x, 1.0) for x in axes if x not in 'CS') + scale = tuple(scale.get(x, 1.0) for x in axes if x not in "CS") kwargs = dict( rgb=rgb, @@ -277,34 +278,38 @@ def get_imagej_metadata(tif: TiffFile) -> dict[str, Any]: def get_ome_tiff_metadata(tif: TiffFile) -> dict[str, Any]: - ome_metadata = xml2dict(tif.ome_metadata).get('OME') - image_metadata = ensure_list(ome_metadata.get('Image', {}))[0] - pixels = image_metadata.get('Pixels', {}) + ome_metadata = xml2dict(tif.ome_metadata).get("OME") + image_metadata = ensure_list(ome_metadata.get("Image", {}))[0] + pixels = image_metadata.get("Pixels", {}) pixel_size = [] - size = float(pixels.get('PhysicalSizeX', 0)) + 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)) + 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'))) + pixel_size.append( + get_value_units_micrometer(size, pixels.get("PhysicalSizeYUnit")) + ) 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') + 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 - channels = ensure_list(pixels.get('Channel', [])) + 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)) + is_rgb = series.keyframe.photometric == PHOTOMETRIC.RGB and nchannels in (3, 4) if is_rgb: # channels_axis appears to be incompatible with RGB channels @@ -321,21 +326,21 @@ def get_ome_tiff_metadata(tif: TiffFile) -> dict[str, Any]: scale = pixel_size for channeli, channel in enumerate(channels): - name = channel.get('Name') - color = channel.get('Color') + 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] + colormap = ["red", "green", "blue", "alpha"][channeli] if not name: name = colormap - blending = 'additive' + blending = "additive" visible = True - if dtype.kind == 'f': + if dtype.kind == "f": contrast_limit = None else: info = numpy.iinfo(dtype) @@ -368,7 +373,15 @@ def get_ome_tiff_metadata(tif: TiffFile) -> dict[str, Any]: def get_value_units_micrometer(value: float, unit: str = None) -> float: - unit_conversions = {'nm': 1e-3, 'µm': 1, 'um': 1, 'micrometer': 1, 'mm': 1e3, 'cm': 1e4, 'm': 1e6} + unit_conversions = { + "nm": 1e-3, + "µm": 1, + "um": 1, + "micrometer": 1, + "mm": 1e3, + "cm": 1e4, + "m": 1e6, + } if unit: value_um = value * unit_conversions.get(unit, 1) else: diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index 525bf38..f504e8b 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -9,6 +9,7 @@ Replace code below accordingly. For complete documentation see: https://napari.org/docs/plugins/for_plugin_developers.html """ + from typing import Any, Callable, Dict, List, Optional, Tuple, Union import numpy @@ -41,7 +42,7 @@ def napari_get_reader(path: PathLike) -> Optional[ReaderFunction]: # so we are only going to look at the first file. path = path[0] path = path.lower() - if path.endswith('zip'): + if path.endswith("zip"): return zip_reader for ext in TIFF.FILE_EXTENSIONS: if path.endswith(ext): @@ -57,7 +58,7 @@ def reader_function(path: PathLike) -> List[LayerData]: layerdata = tifffile_reader(tif) except Exception as exc: # fallback to imagecodecs - log_warning(f'tifffile: {exc}') + log_warning(f"tifffile: {exc}") layerdata = imagecodecs_reader(path) return layerdata @@ -66,7 +67,7 @@ def zip_reader(path: PathLike) -> List[LayerData]: """Return napari LayerData from sequence of TIFF in ZIP file.""" with TiffSequence(container=path) as ims: data = ims.asarray() - return [(data, {}, 'image')] + return [(data, {}, "image")] def tifffile_reader(tif: TiffFile) -> List[LayerData]: @@ -75,6 +76,7 @@ def tifffile_reader(tif: TiffFile) -> List[LayerData]: if nlevels > 1: import dask.array as da import zarr + data = [] for level in range(nlevels): level_data = da.from_zarr(tif.aszarr(level=level)) @@ -86,16 +88,18 @@ def tifffile_reader(tif: TiffFile) -> List[LayerData]: metadata_kwargs = get_metadata(tif) - return [(data, metadata_kwargs, 'image')] + return [(data, metadata_kwargs, "image")] def imagecodecs_reader(path: PathLike): """Return napari LayerData from first page in TIFF file.""" from imagecodecs import imread - return [(imread(path), {}, 'image')] + + return [(imread(path), {}, "image")] def log_warning(msg, *args, **kwargs): """Log message with level WARNING.""" import logging + logging.getLogger(__name__).warning(msg, *args, **kwargs) From c05ea508814ab505d5df357fab8379522aa87c4f Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:10:43 +1100 Subject: [PATCH 21/25] napari not strictly required as a test dependency --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 684b156..801cbf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,6 @@ dependencies = [ [project.optional-dependencies] testing = [ 'pytest', - 'napari' ] dev = [ From 5e21cee6ee9919e66052b18fddd98c136f781ef9 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Wed, 17 Apr 2024 12:18:51 +1000 Subject: [PATCH 22/25] Remove dask dependency, rely only on zarr --- napari_tiff/_tests/test_tiff_reader.py | 5 ++--- napari_tiff/napari_tiff_reader.py | 15 ++++++--------- pyproject.toml | 1 - 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/napari_tiff/_tests/test_tiff_reader.py b/napari_tiff/_tests/test_tiff_reader.py index bb9dfb1..63553c3 100644 --- a/napari_tiff/_tests/test_tiff_reader.py +++ b/napari_tiff/_tests/test_tiff_reader.py @@ -1,7 +1,6 @@ -import dask.array as da import numpy as np import pytest -import tifffile +import zarr from napari_tiff import napari_get_reader from napari_tiff._tests.test_data import ( @@ -105,4 +104,4 @@ def test_multiresolution_image(example_data_multiresolution): assert layer_data[0].shape == (16, 512, 512, 3) assert layer_data[1].shape == (16, 256, 256, 3) assert layer_data[2].shape == (16, 128, 128, 3) - assert all([isinstance(level, da.Array) for level in layer_data]) + assert all([isinstance(level, zarr.Array) for level in layer_data]) diff --git a/napari_tiff/napari_tiff_reader.py b/napari_tiff/napari_tiff_reader.py index f504e8b..dcffb76 100644 --- a/napari_tiff/napari_tiff_reader.py +++ b/napari_tiff/napari_tiff_reader.py @@ -12,7 +12,6 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, Union -import numpy from tifffile import TIFF, TiffFile, TiffSequence, xml2dict from napari_tiff.napari_tiff_metadata import get_metadata @@ -74,15 +73,13 @@ def tifffile_reader(tif: TiffFile) -> List[LayerData]: """Return napari LayerData from image series in TIFF file.""" nlevels = len(tif.series[0].levels) if nlevels > 1: - import dask.array as da import zarr - - 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) + store = tif.aszarr(multiscales=True) + group = zarr.hierarchy.group(store=store) + data = [arr for _, arr in group.arrays()] # read-only zarr arrays + # assert array shapes are in descending order for napari multiscale image + shapes = [arr.shape for arr in data] + assert shapes == list(reversed(sorted(shapes))) else: data = tif.asarray() diff --git a/pyproject.toml b/pyproject.toml index 801cbf1..8611064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ dependencies = [ 'imagecodecs', 'numpy', 'tifffile>=2023.9.26', - 'dask[array]', 'vispy', 'zarr', ] From 3239a2074d660176cdbeac0a73f40f10542fab08 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:20:59 +1000 Subject: [PATCH 23/25] Simplify metadata getting --- napari_tiff/napari_tiff_metadata.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/napari_tiff/napari_tiff_metadata.py b/napari_tiff/napari_tiff_metadata.py index 7665414..3d43e62 100644 --- a/napari_tiff/napari_tiff_metadata.py +++ b/napari_tiff/napari_tiff_metadata.py @@ -35,9 +35,8 @@ def get_extra_metadata(tif: TiffFile) -> dict[str, Any]: metadata_dict = {} empty_metadata_values = [None, "", (), []] for name in dir(tif.__class__): - obj = getattr(tif.__class__, name) if "metadata" in name: - metadata_value = obj.__get__(tif) + metadata_value = getattr(tif.__class__, name).__get__(tif) if metadata_value not in empty_metadata_values: print(metadata_value) if isinstance(metadata_value, str): From 225d9a29f472cd3fb55e595e64ab529c531469f6 Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:27:38 +1000 Subject: [PATCH 24/25] Remove variable, is re-defined below Co-authored-by: Grzegorz Bokota --- napari_tiff/napari_tiff_metadata.py | 1 - 1 file changed, 1 deletion(-) diff --git a/napari_tiff/napari_tiff_metadata.py b/napari_tiff/napari_tiff_metadata.py index 3d43e62..2c09469 100644 --- a/napari_tiff/napari_tiff_metadata.py +++ b/napari_tiff/napari_tiff_metadata.py @@ -201,7 +201,6 @@ def get_imagej_metadata(tif: TiffFile) -> dict[str, Any]: channel_axis = None name = None - scale = None colormap = None contrast_limits = None blending = None From 6e2949e3986a50378b4b533e2145b8ad3da8189d Mon Sep 17 00:00:00 2001 From: Genevieve Buckley <30920819+GenevieveBuckley@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:24:34 +1000 Subject: [PATCH 25/25] Add unicode micro sign to metadata units list --- napari_tiff/napari_tiff_metadata.py | 1 + 1 file changed, 1 insertion(+) diff --git a/napari_tiff/napari_tiff_metadata.py b/napari_tiff/napari_tiff_metadata.py index 2c09469..2ddd2c0 100644 --- a/napari_tiff/napari_tiff_metadata.py +++ b/napari_tiff/napari_tiff_metadata.py @@ -374,6 +374,7 @@ def get_value_units_micrometer(value: float, unit: str = None) -> float: unit_conversions = { "nm": 1e-3, "µm": 1, + "\\u00B5m": 1, # Unicode 'MICRO SIGN' (U+00B5) "um": 1, "micrometer": 1, "mm": 1e3,