From ab843b3317e9045fe9ca7f83fad1e9bd2926bc66 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Wed, 25 Mar 2026 15:22:56 -0500 Subject: [PATCH 1/3] clean up imports and use full paths --- src/ndevio/__init__.py | 58 +- src/ndevio/_napari_reader.py | 368 ++--- src/ndevio/napari.yaml | 170 +-- src/ndevio/nimage.py | 1244 +++++++++-------- src/ndevio/sampledata/__init__.py | 20 +- src/ndevio/sampledata/_sample_data.py | 245 ++-- src/ndevio/widgets/__init__.py | 15 +- tests/test_nimage.py | 1566 +++++++++++----------- tests/test_sampledata/test_sampledata.py | 226 ++-- 9 files changed, 1969 insertions(+), 1943 deletions(-) diff --git a/src/ndevio/__init__.py b/src/ndevio/__init__.py index 0442a07..7d19d15 100644 --- a/src/ndevio/__init__.py +++ b/src/ndevio/__init__.py @@ -1,28 +1,30 @@ -from typing import TYPE_CHECKING - -try: # noqa: D104 - from ._version import version as __version__ -except ImportError: - __version__ = 'unknown' - -from .utils import helpers - -# Type stub for lazy import - lets type checkers know nImage exists -if TYPE_CHECKING: - from .nimage import nImage as nImage - - -def __getattr__(name: str): - """Lazily import nImage to speed up package import.""" - if name == 'nImage': - from .nimage import nImage - - return nImage - raise AttributeError(f'module {__name__!r} has no attribute {name!r}') - - -__all__ = [ - '__version__', - 'helpers', - 'nImage', -] +from typing import TYPE_CHECKING + +try: # noqa: D104 + from ._version import version as __version__ +except ImportError: + __version__ = 'unknown' + +if TYPE_CHECKING: + from .nimage import nImage as nImage + from .utils import helpers as helpers + + +def __getattr__(name: str) -> object: + """Lazily import heavy submodules to speed up package import.""" + if name == 'nImage': + from .nimage import nImage + + return nImage + if name == 'helpers': + from .utils import helpers + + return helpers + raise AttributeError(f'module {__name__!r} has no attribute {name!r}') + + +__all__ = [ + '__version__', + 'helpers', + 'nImage', +] diff --git a/src/ndevio/_napari_reader.py b/src/ndevio/_napari_reader.py index a19e0f8..1755fc1 100644 --- a/src/ndevio/_napari_reader.py +++ b/src/ndevio/_napari_reader.py @@ -1,183 +1,185 @@ -from __future__ import annotations - -import logging -from functools import partial -from typing import TYPE_CHECKING - -from ndev_settings import get_settings - -from .nimage import nImage - -if TYPE_CHECKING: - from napari.types import LayerDataTuple, PathLike, ReaderFunction - -logger = logging.getLogger(__name__) - - -def napari_get_reader( - path: PathLike, - open_first_scene_only: bool | None = None, - open_all_scenes: bool | None = None, -) -> ReaderFunction | None: - """Get the appropriate reader function for a single given path. - - Parameters - ---------- - path : PathLike - Path to the file to be read - open_first_scene_only : bool, optional - Whether to ignore multi-scene files and just open the first scene, - by default None, which uses the setting - open_all_scenes : bool, optional - Whether to open all scenes in a multi-scene file, by default None - which uses the setting - Ignored if open_first_scene_only is True - - - Returns - ------- - ReaderFunction - The reader function for the given path - """ - - settings = get_settings() - - open_first_scene_only = ( - open_first_scene_only - if open_first_scene_only is not None - else settings.ndevio_reader.scene_handling == 'View First Scene Only' # type: ignore - ) or False - - open_all_scenes = ( - open_all_scenes - if open_all_scenes is not None - else settings.ndevio_reader.scene_handling == 'View All Scenes' # type: ignore - ) or False - - # Return reader function; actual format validation happens in - # napari_reader_function via nImage initialization. - return partial( - napari_reader_function, - open_first_scene_only=open_first_scene_only, - open_all_scenes=open_all_scenes, - ) - - -def napari_reader_function( - path: PathLike, - open_first_scene_only: bool = False, - open_all_scenes: bool = False, -) -> list[LayerDataTuple] | None: - """ - Read a file using bioio. - - nImage handles reader selection: if a preferred_reader is set in settings, - it's tried first with automatic fallback to bioio's default plugin ordering. - - Parameters - ---------- - path : PathLike - Path to the file to be read - open_first_scene_only : bool, optional - Whether to ignore multi-scene files and just open the first scene, - by default False. - open_all_scenes : bool, optional - Whether to open all scenes in a multi-scene file, by default False. - Ignored if open_first_scene_only is True. - - Returns - ------- - list - List containing image data, metadata, and layer type - - """ - from bioio_base.exceptions import UnsupportedFileFormatError - - try: - img = nImage(path) # nImage handles preferred reader and fallback - except UnsupportedFileFormatError: - # Try to open plugin installer widget - # If no viewer available, this will re-raise - _open_plugin_installer(path) - return None - - logger.info('Bioio: Reading file with %d scenes', len(img.scenes)) - - # open first scene only - if len(img.scenes) == 1 or open_first_scene_only: - return img.get_layer_data_tuples() - - # open all scenes as layers - if open_all_scenes: - layer_list = [] - for scene in img.scenes: - img.set_scene(scene) - layer_list.extend(img.get_layer_data_tuples()) - return layer_list - - # else: open scene widget - _open_scene_container(path=path, img=img) - return [(None,)] # type: ignore[return-value] - - -def _open_scene_container(path: PathLike, img: nImage) -> None: - from pathlib import Path - - import napari - - from .widgets import DELIMITER, nImageSceneWidget - - viewer = napari.current_viewer() - viewer.window.add_dock_widget( - nImageSceneWidget(viewer, path, img), - area='right', - name=f'{Path(path).stem}{DELIMITER}Scenes', - ) - - -def _open_plugin_installer(path: PathLike) -> None: - """Open the plugin installer widget for an unsupported file. - - If no napari viewer is available, re-raises the UnsupportedFileFormatError - with installation suggestions so programmatic users get a helpful message. - - Parameters - ---------- - path : PathLike - Path to the file that couldn't be read - - Raises - ------ - UnsupportedFileFormatError - If no napari viewer is available (programmatic usage) - """ - import napari - from bioio_base.exceptions import UnsupportedFileFormatError - - from .bioio_plugins._manager import ReaderPluginManager - from .widgets import PluginInstallerWidget - - # Get viewer, handle case where no viewer available - viewer = napari.current_viewer() - - # If no viewer, re-raise with helpful message for programmatic users - if viewer is None: - logger.debug( - 'No napari viewer available, raising exception with suggestions' - ) - manager = ReaderPluginManager(path) - raise UnsupportedFileFormatError( - reader_name='ndevio', - path=str(path), - msg_extra=manager.get_installation_message(), - ) - - # Create plugin manager for this file - manager = ReaderPluginManager(path) - - widget = PluginInstallerWidget(plugin_manager=manager) - viewer.window.add_dock_widget( - widget, - area='right', - name='Install BioIO Plugin', - ) +from __future__ import annotations + +import logging +from functools import partial +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from napari.types import LayerDataTuple, PathLike, ReaderFunction + + from .nimage import nImage + +logger = logging.getLogger(__name__) + + +def napari_get_reader( + path: PathLike, + open_first_scene_only: bool | None = None, + open_all_scenes: bool | None = None, +) -> ReaderFunction | None: + """Get the appropriate reader function for a single given path. + + Parameters + ---------- + path : PathLike + Path to the file to be read + open_first_scene_only : bool, optional + Whether to ignore multi-scene files and just open the first scene, + by default None, which uses the setting + open_all_scenes : bool, optional + Whether to open all scenes in a multi-scene file, by default None + which uses the setting + Ignored if open_first_scene_only is True + + + Returns + ------- + ReaderFunction + The reader function for the given path + """ + + from ndev_settings import get_settings + + settings = get_settings() + + open_first_scene_only = ( + open_first_scene_only + if open_first_scene_only is not None + else settings.ndevio_reader.scene_handling == 'View First Scene Only' # type: ignore + ) or False + + open_all_scenes = ( + open_all_scenes + if open_all_scenes is not None + else settings.ndevio_reader.scene_handling == 'View All Scenes' # type: ignore + ) or False + + # Return reader function; actual format validation happens in + # napari_reader_function via nImage initialization. + return partial( + napari_reader_function, + open_first_scene_only=open_first_scene_only, + open_all_scenes=open_all_scenes, + ) + + +def napari_reader_function( + path: PathLike, + open_first_scene_only: bool = False, + open_all_scenes: bool = False, +) -> list[LayerDataTuple] | None: + """ + Read a file using bioio. + + nImage handles reader selection: if a preferred_reader is set in settings, + it's tried first with automatic fallback to bioio's default plugin ordering. + + Parameters + ---------- + path : PathLike + Path to the file to be read + open_first_scene_only : bool, optional + Whether to ignore multi-scene files and just open the first scene, + by default False. + open_all_scenes : bool, optional + Whether to open all scenes in a multi-scene file, by default False. + Ignored if open_first_scene_only is True. + + Returns + ------- + list + List containing image data, metadata, and layer type + + """ + from bioio_base.exceptions import UnsupportedFileFormatError + + from .nimage import nImage + + try: + img = nImage(path) # nImage handles preferred reader and fallback + except UnsupportedFileFormatError: + # Try to open plugin installer widget + # If no viewer available, this will re-raise + _open_plugin_installer(path) + return None + + logger.info('Bioio: Reading file with %d scenes', len(img.scenes)) + + # open first scene only + if len(img.scenes) == 1 or open_first_scene_only: + return img.get_layer_data_tuples() + + # open all scenes as layers + if open_all_scenes: + layer_list = [] + for scene in img.scenes: + img.set_scene(scene) + layer_list.extend(img.get_layer_data_tuples()) + return layer_list + + # else: open scene widget + _open_scene_container(path=path, img=img) + return [(None,)] # type: ignore[return-value] + + +def _open_scene_container(path: PathLike, img: nImage) -> None: + from pathlib import Path + + import napari + + from .widgets._scene_widget import DELIMITER, nImageSceneWidget + + viewer = napari.current_viewer() + viewer.window.add_dock_widget( + nImageSceneWidget(viewer, path, img), + area='right', + name=f'{Path(path).stem}{DELIMITER}Scenes', + ) + + +def _open_plugin_installer(path: PathLike) -> None: + """Open the plugin installer widget for an unsupported file. + + If no napari viewer is available, re-raises the UnsupportedFileFormatError + with installation suggestions so programmatic users get a helpful message. + + Parameters + ---------- + path : PathLike + Path to the file that couldn't be read + + Raises + ------ + UnsupportedFileFormatError + If no napari viewer is available (programmatic usage) + """ + import napari + from bioio_base.exceptions import UnsupportedFileFormatError + + from .bioio_plugins._manager import ReaderPluginManager + from .widgets._plugin_install_widget import PluginInstallerWidget + + # Get viewer, handle case where no viewer available + viewer = napari.current_viewer() + + # If no viewer, re-raise with helpful message for programmatic users + if viewer is None: + logger.debug( + 'No napari viewer available, raising exception with suggestions' + ) + manager = ReaderPluginManager(path) + raise UnsupportedFileFormatError( + reader_name='ndevio', + path=str(path), + msg_extra=manager.get_installation_message(), + ) + + # Create plugin manager for this file + manager = ReaderPluginManager(path) + + widget = PluginInstallerWidget(plugin_manager=manager) + viewer.window.add_dock_widget( + widget, + area='right', + name='Install BioIO Plugin', + ) diff --git a/src/ndevio/napari.yaml b/src/ndevio/napari.yaml index da3ccdc..1b2f549 100644 --- a/src/ndevio/napari.yaml +++ b/src/ndevio/napari.yaml @@ -1,85 +1,85 @@ -name: ndevio -display_name: ndevio -# use 'hidden' to remove plugin from napari hub search results -visibility: public -# see https://napari.org/stable/plugins/technical_references/manifest.html#fields for valid categories -categories: ["IO", "Dataset", "Utilities"] -contributions: - commands: - - id: ndevio.get_reader - python_name: ndevio._napari_reader:napari_get_reader - title: Open file with ndevio - - id: ndevio.make_plugin_installer_widget - python_name: ndevio.widgets:PluginInstallerWidget - title: Install BioIO Reader Plugins - - id: ndevio.make_utilities_widget - python_name: ndevio.widgets:UtilitiesContainer - title: I/O Utilities - - id: ndevio.make_ndev_logo - python_name: ndevio.sampledata:ndev_logo - title: Load ndev logo - - id: ndevio.make_scratch_assay - python_name: ndevio.sampledata:scratch_assay - title: Load scratch assay data - - id: ndevio.make_neocortex - python_name: ndevio.sampledata:neocortex - title: Load neocortex data - - id: ndevio.make_neuron_raw - python_name: ndevio.sampledata:neuron_raw - title: Load raw neuron data - - id: ndevio.make_neuron_labels - python_name: ndevio.sampledata:neuron_labels - title: Load neuron labels data - - id: ndevio.make_neuron_labels_processed - python_name: ndevio.sampledata:neuron_labels_processed - title: Load processed neuron labels data - readers: - - command: ndevio.get_reader - accepts_directories: true - filename_patterns: [ - '*.1sc', '*.264', '*.265', '*.2fl', '*.3fr', '*.3g2', '*.a64', '*.acff', '*.adp', '*.afi', - '*.afm', '*.aim', '*.al3d', '*.am', '*.amiramesh', '*.amr', '*.amv', '*.apl', '*.apng', '*.arf', - '*.arw', '*.asf', '*.avc', '*.avi', '*.avs', '*.avs2', '*.bay', '*.bif', '*.bin', '*.bip', - '*.bmp', '*.btf', '*.c01', '*.cdg', '*.cfg', '*.cgi', '*.ch5', '*.cif', '*.cr2', '*.crw', - '*.csv', '*.ct', '*.cxd', '*.czi', '*.dat', '*.dcm', '*.dcr', '*.dib', '*.dip', '*.dir', - '*.dm2', '*.dm3', '*.dm4', '*.dng', '*.dnxhd', '*.dti', '*.dv', '*.dvd', '*.eps', '*.erf', - '*.exp', '*.exr', '*.fdf', '*.fff', '*.ffr', '*.fits', '*.flex', '*.fli', '*.frm', '*.gel', - '*.gif', '*.grey', '*.hdf', '*.hdr', '*.hed', '*.his', '*.htd', '*.html', '*.hx', '*.i2i', - '*.icb', '*.ics', '*.ids', '*.if', '*.iiq', '*.im3', '*.img', '*.ims', '*.imt', '*.inr', - '*.ipl', '*.ipm', '*.ipw', '*.ism', '*.jfif', '*.jif', '*.jng', '*.jp2', '*.jpg', '*.jpk', - '*.jpx', '*.l2d', '*.labels', '*.lei', '*.lif', '*.liff', '*.lim', '*.lms', '*.lsm', '*.mcidas', - '*.mdb', '*.mnc', '*.mng', '*.mod', '*.mov', '*.mp4', '*.mpo', '*.mrc', '*.mrw', '*.msp', - '*.msr', '*.mtb', '*.mvd2', '*.naf', '*.nd', '*.nd2', '*.ndpi', '*.ndpis', '*.nef', '*.nhdr', - '*.nii', '*.nii.gz', '*.nrrd', '*.obf', '*.obsep', '*.oib', '*.oif', '*.oir', '*.ome', '*.ome.btf', - '*.ome.tf2', '*.ome.tf8', '*.ome.tif', '*.ome.tiff', '*.ome.xml', '*.par', '*.pbm', '*.pcoraw', '*.pcx', '*.pdf', - '*.pds', '*.pgm', '*.pic', '*.pict', '*.png', '*.pnl', '*.ppm', '*.pr3', '*.ps', '*.psd', - '*.qptiff', '*.r3d', '*.raw', '*.rcpnl', '*.rec', '*.scn', '*.sdt', '*.seq', '*.sif', '*.sld', - '*.sldy', '*.sm2', '*.sm3', '*.spc', '*.spe', '*.spi', '*.spider', '*.stk', '*.stp', '*.svs', - '*.sxm', '*.tf2', '*.tf8', '*.tfr', '*.tga', '*.tif', '*.tiff', '*.tiles.ome.tif', '*.tnb', '*.top', - '*.txt', '*.v', '*.vms', '*.vsi', '*.vws', '*.wat', '*.wlz', '*.xdce', '*.xml', '*.xqd', - '*.xqf', '*.xv', '*.xvthumb', '*.xys', '*.zarr', '*.zarr*', '*.zfp', '*.zfr', '*.zif', '*.zvi', - ] - widgets: - - command: ndevio.make_plugin_installer_widget - display_name: Install BioIO Reader Plugins - - command: ndevio.make_utilities_widget - display_name: I/O Utilities - sample_data: - - command: ndevio.make_ndev_logo - display_name: ndev logo - key: ndevio.ndev_logo - - command: ndevio.make_scratch_assay - display_name: Scratch Assay Labeled (10T+2Ch) (4MB) - key: ndevio.scratch_assay - - command: ndevio.make_neocortex - display_name: Neocortex (3Ch) (2MB) - key: ndevio.neocortex - - command: ndevio.make_neuron_raw - display_name: Neuron Raw (2D+4Ch) (32MB) - key: ndevio.neuron_raw - - command: ndevio.make_neuron_labels - display_name: Neuron Labels (2D+4Ch) - key: ndevio.neuron_labels - - command: ndevio.make_neuron_labels_processed - display_name: Neuron Labels Processed (2D+4Ch) - key: ndevio.neuron_labels_processed +name: ndevio +display_name: ndevio +# use 'hidden' to remove plugin from napari hub search results +visibility: public +# see https://napari.org/stable/plugins/technical_references/manifest.html#fields for valid categories +categories: ["IO", "Dataset", "Utilities"] +contributions: + commands: + - id: ndevio.get_reader + python_name: ndevio._napari_reader:napari_get_reader + title: Open file with ndevio + - id: ndevio.make_plugin_installer_widget + python_name: ndevio.widgets._plugin_install_widget:PluginInstallerWidget + title: Install BioIO Reader Plugins + - id: ndevio.make_utilities_widget + python_name: ndevio.widgets._utilities_container:UtilitiesContainer + title: I/O Utilities + - id: ndevio.make_ndev_logo + python_name: ndevio.sampledata._sample_data:ndev_logo + title: Load ndev logo + - id: ndevio.make_scratch_assay + python_name: ndevio.sampledata._sample_data:scratch_assay + title: Load scratch assay data + - id: ndevio.make_neocortex + python_name: ndevio.sampledata._sample_data:neocortex + title: Load neocortex data + - id: ndevio.make_neuron_raw + python_name: ndevio.sampledata._sample_data:neuron_raw + title: Load raw neuron data + - id: ndevio.make_neuron_labels + python_name: ndevio.sampledata._sample_data:neuron_labels + title: Load neuron labels data + - id: ndevio.make_neuron_labels_processed + python_name: ndevio.sampledata._sample_data:neuron_labels_processed + title: Load processed neuron labels data + readers: + - command: ndevio.get_reader + accepts_directories: true + filename_patterns: [ + '*.1sc', '*.264', '*.265', '*.2fl', '*.3fr', '*.3g2', '*.a64', '*.acff', '*.adp', '*.afi', + '*.afm', '*.aim', '*.al3d', '*.am', '*.amiramesh', '*.amr', '*.amv', '*.apl', '*.apng', '*.arf', + '*.arw', '*.asf', '*.avc', '*.avi', '*.avs', '*.avs2', '*.bay', '*.bif', '*.bin', '*.bip', + '*.bmp', '*.btf', '*.c01', '*.cdg', '*.cfg', '*.cgi', '*.ch5', '*.cif', '*.cr2', '*.crw', + '*.csv', '*.ct', '*.cxd', '*.czi', '*.dat', '*.dcm', '*.dcr', '*.dib', '*.dip', '*.dir', + '*.dm2', '*.dm3', '*.dm4', '*.dng', '*.dnxhd', '*.dti', '*.dv', '*.dvd', '*.eps', '*.erf', + '*.exp', '*.exr', '*.fdf', '*.fff', '*.ffr', '*.fits', '*.flex', '*.fli', '*.frm', '*.gel', + '*.gif', '*.grey', '*.hdf', '*.hdr', '*.hed', '*.his', '*.htd', '*.html', '*.hx', '*.i2i', + '*.icb', '*.ics', '*.ids', '*.if', '*.iiq', '*.im3', '*.img', '*.ims', '*.imt', '*.inr', + '*.ipl', '*.ipm', '*.ipw', '*.ism', '*.jfif', '*.jif', '*.jng', '*.jp2', '*.jpg', '*.jpk', + '*.jpx', '*.l2d', '*.labels', '*.lei', '*.lif', '*.liff', '*.lim', '*.lms', '*.lsm', '*.mcidas', + '*.mdb', '*.mnc', '*.mng', '*.mod', '*.mov', '*.mp4', '*.mpo', '*.mrc', '*.mrw', '*.msp', + '*.msr', '*.mtb', '*.mvd2', '*.naf', '*.nd', '*.nd2', '*.ndpi', '*.ndpis', '*.nef', '*.nhdr', + '*.nii', '*.nii.gz', '*.nrrd', '*.obf', '*.obsep', '*.oib', '*.oif', '*.oir', '*.ome', '*.ome.btf', + '*.ome.tf2', '*.ome.tf8', '*.ome.tif', '*.ome.tiff', '*.ome.xml', '*.par', '*.pbm', '*.pcoraw', '*.pcx', '*.pdf', + '*.pds', '*.pgm', '*.pic', '*.pict', '*.png', '*.pnl', '*.ppm', '*.pr3', '*.ps', '*.psd', + '*.qptiff', '*.r3d', '*.raw', '*.rcpnl', '*.rec', '*.scn', '*.sdt', '*.seq', '*.sif', '*.sld', + '*.sldy', '*.sm2', '*.sm3', '*.spc', '*.spe', '*.spi', '*.spider', '*.stk', '*.stp', '*.svs', + '*.sxm', '*.tf2', '*.tf8', '*.tfr', '*.tga', '*.tif', '*.tiff', '*.tiles.ome.tif', '*.tnb', '*.top', + '*.txt', '*.v', '*.vms', '*.vsi', '*.vws', '*.wat', '*.wlz', '*.xdce', '*.xml', '*.xqd', + '*.xqf', '*.xv', '*.xvthumb', '*.xys', '*.zarr', '*.zarr*', '*.zfp', '*.zfr', '*.zif', '*.zvi', + ] + widgets: + - command: ndevio.make_plugin_installer_widget + display_name: Install BioIO Reader Plugins + - command: ndevio.make_utilities_widget + display_name: I/O Utilities + sample_data: + - command: ndevio.make_ndev_logo + display_name: ndev logo + key: ndevio.ndev_logo + - command: ndevio.make_scratch_assay + display_name: Scratch Assay Labeled (10T+2Ch) (4MB) + key: ndevio.scratch_assay + - command: ndevio.make_neocortex + display_name: Neocortex (3Ch) (2MB) + key: ndevio.neocortex + - command: ndevio.make_neuron_raw + display_name: Neuron Raw (2D+4Ch) (32MB) + key: ndevio.neuron_raw + - command: ndevio.make_neuron_labels + display_name: Neuron Labels (2D+4Ch) + key: ndevio.neuron_labels + - command: ndevio.make_neuron_labels_processed + display_name: Neuron Labels Processed (2D+4Ch) + key: ndevio.neuron_labels_processed diff --git a/src/ndevio/nimage.py b/src/ndevio/nimage.py index c1f54f6..a2e0ad0 100644 --- a/src/ndevio/nimage.py +++ b/src/ndevio/nimage.py @@ -1,623 +1,621 @@ -"""Additional functionality for BioImage objects to be used in napari-ndev.""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import TYPE_CHECKING - -from bioio import BioImage -from bioio_base.reader import Reader -from bioio_base.types import ImageLike - -from .bioio_plugins._manager import raise_unsupported_with_suggestions -from .utils._layer_utils import ( - build_layer_tuple, - determine_in_memory, - resolve_layer_type, -) - -if TYPE_CHECKING: - from collections.abc import Sequence - - import xarray as xr - from bioio_base.reader import Reader - from bioio_base.types import ImageLike - from napari.types import LayerDataTuple - -logger = logging.getLogger(__name__) - - -def _resolve_reader( - image: ImageLike, - explicit_reader: type[Reader] | Sequence[type[Reader]] | None, -) -> type[Reader] | Sequence[type[Reader]] | None: - """Resolve the reader to use for an image. - - Priority: - 1. Explicit reader (passed to __init__) - 2. Preferred reader from settings (if file path and installed) - 3. None (let bioio determine) - - Parameters - ---------- - image : ImageLike - The image to resolve a reader for. - explicit_reader : type[Reader] | Sequence[type[Reader]] | None - Explicit reader class(es) passed by user. - - Returns - ------- - type[Reader] | Sequence[type[Reader]] | None - The reader to use, or None to let bioio choose. - - """ - if explicit_reader is not None: - return explicit_reader - - # Only check preferred reader for file paths - if not isinstance(image, str | Path): - return None - - # Get preferred reader from settings - from ndev_settings import get_settings - - from .bioio_plugins._utils import get_installed_plugins, get_reader_by_name - - settings = get_settings() - preferred = settings.ndevio_reader.preferred_reader # type: ignore - - if not preferred: - return None - - if preferred not in get_installed_plugins(): - logger.debug('Preferred reader %s not installed', preferred) - return None - - return get_reader_by_name(preferred) - - -class nImage(BioImage): - """ - An nImage is a BioImage with additional functionality for napari. - - Extends BioImage to provide napari-ready layer data with proper scale, - axis labels, and units derived from bioimaging metadata. - - Parameters - ---------- - image : ImageLike - Image to be loaded. Can be a path to an image file, a numpy array, - or an xarray DataArray. - reader : type[Reader] | Sequence[type[Reader]], optional - Reader class or priority list of readers. If not provided, checks - settings for preferred_reader and tries that first, then falls back - to bioio's default deterministic priority. - **kwargs - Additional arguments passed to BioImage. - - Attributes - ---------- - path : str | None - Path or URI to the source file, or None if created from array data. - Always a plain string — local paths are stored as-is, ``file://`` URIs - are normalised to their path component, and remote URIs (``s3://``, - ``https://``, …) are kept verbatim. Use ``_is_remote`` to distinguish - local from remote. - - Examples - -------- - Basic usage with file path: - - >>> img = nImage("path/to/image.tiff") - >>> for layer_tuple in img.get_layer_data_tuples(): - ... layer = Layer.create(*layer_tuple) - ... viewer.add_layer(layer) - - Access layer properties: - - >>> img.layer_scale # (1.0, 0.2, 0.2) - physical scale per dim - >>> img.layer_axis_labels # ('Z', 'Y', 'X') - >>> img.layer_units # ('µm', 'µm', 'µm') - - """ - - # Class-level type hints for instance attributes - path: str | None - _is_remote: bool - _reference_xarray: xr.DataArray | None - _layer_data: list | None - - def __init__( - self, - image: ImageLike, - reader: type[Reader] | Sequence[type[Reader]] | None = None, - **kwargs, - ) -> None: - """Initialize an nImage with an image, and optionally a reader.""" - from bioio_base.exceptions import UnsupportedFileFormatError - - # Strip trailing slashes from string paths/URLs (e.g. `store.zarr/`) - # so that bioio's extension-based reader detection works correctly. - if isinstance(image, str): - image = image.rstrip('/') - - resolved_reader = _resolve_reader(image, reader) - - # Try preferred/explicit reader first, fall back to bioio default - if resolved_reader is not None: - try: - super().__init__(image=image, reader=resolved_reader, **kwargs) - except UnsupportedFileFormatError: - # Preferred reader failed, fall back to bioio's default - try: - super().__init__(image=image, reader=None, **kwargs) - except UnsupportedFileFormatError: - if isinstance(image, str | Path): - raise_unsupported_with_suggestions(image) - raise - else: - try: - super().__init__(image=image, reader=None, **kwargs) - except UnsupportedFileFormatError: - if isinstance(image, str | Path): - raise_unsupported_with_suggestions(image) - raise - - # Instance state - self._reference_xarray = None - self._layer_data = None - if isinstance(image, str | Path): - import fsspec - from fsspec.implementations.local import LocalFileSystem - - s = str(image) - fs, resolved = fsspec.url_to_fs(s) - if isinstance(fs, LocalFileSystem): - # Normalise file:// URIs and any platform variations to an - # OS-native path string so Path(self.path) always round-trips. - self.path = str(Path(resolved)) - self._is_remote = False - else: - # Remote URI (s3://, https://, gc://, …) — keep verbatim. - self.path = s - self._is_remote = True - else: - self.path = None - self._is_remote = False - - # Any compatibility warnings for old formats should be emitted at this point - # Cheaply check without imports by looking at the reader's module name - if self.reader.__module__.startswith('bioio_ome_zarr'): - from .bioio_plugins._compatibility import ( - apply_ome_zarr_compat_patches, - ) - - apply_ome_zarr_compat_patches(self.reader) - - @property - def reference_xarray(self) -> xr.DataArray: - """Image data as xarray DataArray for metadata determination. - - Lazily loads xarray on first access. Uses in-memory or dask array - based on file size (determined automatically). - - Returns - ------- - xr.DataArray - Squeezed xarray for napari dimensions. - - Notes - ----- - BioImage.xarray_data and BioImage.xarray_dask_data automatically - handle mosaic tile reconstruction when reconstruct_mosaic=True - (the default). No special mosaic handling needed here. - - """ - if self._reference_xarray is None: - # Ensure we're at the highest-res level for metadata consistency - current_res = self.current_resolution_level - self.set_resolution_level(0) - if self._is_remote or not determine_in_memory(self.path): - self._reference_xarray = self.xarray_dask_data.squeeze() - else: - self._reference_xarray = self.xarray_data.squeeze() - self.set_resolution_level(current_res) - return self._reference_xarray - - @property - def layer_data(self) -> list: - """Image data arrays shaped for napari, one per resolution level. - - Returns a list of arrays ordered from highest to lowest resolution. - For single-resolution images the list has one element. - napari automatically treats multi-element lists as multiscale data. - - Multiscale images are always dask-backed for memory efficiency. - Single-resolution images use numpy or dask based on file size. - - Returns - ------- - list[ArrayLike] - Squeezed image arrays (C dim retained for multichannel split - in :meth:`get_layer_data_tuples`). - - """ - if self._layer_data is None: - self._layer_data = self._build_layer_data() - return self._layer_data - - def _build_layer_data(self) -> list: - """Build the list of arrays for all resolution levels.""" - current_res = self.current_resolution_level - levels = self.resolution_levels - multiscale = len(levels) > 1 - - # Determine which dims to keep from level 0's squeezed metadata. - # Using isel instead of squeeze ensures all levels have - # consistent ndim (lower levels may have extra singleton spatial dims - # that squeeze would incorrectly remove). - ref = self.reference_xarray - keep_dims = set(ref.dims) - - arrays: list = [] - for level in levels: - self.set_resolution_level(level) - if ( - multiscale - or self._is_remote - or not determine_in_memory(self.path) - ): - xr_data = self.xarray_dask_data - else: - xr_data = self.xarray_data - - indexer = {d: 0 for d in xr_data.dims if d not in keep_dims} - arrays.append(xr_data.isel(indexer).data) - - self.set_resolution_level(current_res) - return arrays - - @property - def path_stem(self) -> str: - """Filename stem derived from path or URI, used in layer names. - - Returns - ------- - str - The stem of the filename (no extension, no parent path), or - ``'unknown'`` when the image was created from array data. - - Examples - -------- - >>> nImage("/data/cells.ome.tiff").path_stem - 'cells.ome' - >>> nImage("s3://bucket/experiment/image.zarr").path_stem - 'image' - - """ - if self.path is None: - return 'unknown' - if self._is_remote: - from pathlib import PurePosixPath - from urllib.parse import urlparse - - return PurePosixPath(urlparse(self.path).path).stem - return Path(self.path).stem - - @property - def layer_names(self) -> list[str]: - """Per-channel layer names for napari. - - Returns one name per output layer — the same count as - :meth:`get_layer_data_tuples` returns tuples. The base name is the - scene-qualified :attr:`path_stem`; channel names are prepended using - ``' :: '`` as a delimiter when present. - - Returns - ------- - list[str] - e.g. ``['membrane :: cells.ome', 'nuclei :: cells.ome']`` - for a 2-channel file, or ``['0 :: cells.ome']`` for a - single-channel OME image with a default ``C`` coordinate. - Only when no ``C`` dimension is present at all will the name - be just ``['cells.ome']``. - - Examples - -------- - >>> nImage("cells.ome.tiff").layer_names - ['0 :: cells.ome'] - - """ - # Build scene-qualified base name - delim = ' :: ' - parts: list[str] = [] - if len(self.scenes) > 1 or self.current_scene != 'Image:0': - parts.extend([str(self.current_scene_index), self.current_scene]) - parts.append(self.path_stem) - base_name = delim.join(parts) - - # RGB (Samples dim): single layer, no channel prefix - if 'S' in self.dims.order: - return [base_name] - - # Use BioImage channel_names — metadata only, no data load - channel_names = self.channel_names - - # Single channel (C=1 is squeezed out of layer_data) - if self.dims.C == 1: - ch_name = channel_names[0] - return [f'{ch_name} :: {base_name}' if ch_name else base_name] - - # Multichannel - return [f'{ch} :: {base_name}' for ch in channel_names] - - @property - def layer_scale(self) -> tuple[float, ...]: - """Physical scale for dimensions in layer data. - - Uses layer_axis_labels to determine which dimensions are present, - then extracts scale values from BioImage.scale. - Defaults to 1.0 for dimensions without scale metadata. - - Returns - ------- - tuple[float, ...] - Scale tuple matching layer_axis_labels. - - Examples - -------- - >>> img = nImage("timelapse.tiff") # T=3, Z=1, Y=10, X=10 - >>> img.layer_axis_labels - ('T', 'Y', 'X') - >>> img.layer_scale - (2.0, 0.2, 0.2) - - """ - axis_labels = self.layer_axis_labels - - # Try to get scale from BioImage - may fail for array-like inputs - # where physical_pixel_sizes is None (AttributeError), old OME-Zarr - # v0.1/v0.2 missing 'coordinateTransformations' (KeyError), or - # v0.3 string-axes that weren't normalised (TypeError). - try: - bio_scale = self.scale - except (AttributeError, KeyError, TypeError): - return tuple(1.0 for _ in axis_labels) - return tuple( - getattr(bio_scale, dim, None) or 1.0 for dim in axis_labels - ) - - @property - def layer_axis_labels(self) -> tuple[str, ...]: - """Dimension names for napari layers (excludes Channel and Samples). - - Returns - ------- - tuple[str, ...] - Dimension names (e.g., ('Z', 'Y', 'X')). - - Examples - -------- - >>> img = nImage("multichannel.tiff") # Shape (C=2, Z=10, Y=100, X=100) - >>> img.layer_axis_labels - ('Z', 'Y', 'X') - - """ - layer_data = self.reference_xarray - - # Exclude Channel and Samples dimensions (RGB/multichannel handled separately) - return tuple( - str(dim) for dim in layer_data.dims if dim not in ('C', 'S') - ) - - @property - def layer_units(self) -> tuple[str | None, ...]: - """Physical units for dimensions in layer data. - - Returns - ------- - tuple[str | None, ...] - Unit strings matching layer_axis_labels. None for dims without units. - - Examples - -------- - >>> img = nImage("timelapse.tiff") # T=3, Z=1, Y=10, X=10 - >>> # After squeezing, Z is removed - >>> img.layer_axis_labels - ('T', 'Y', 'X') - >>> img.layer_units - ('s', 'µm', 'µm') - - """ - axis_labels = self.layer_axis_labels - - try: - dim_props = self.dimension_properties - # Old OME-Zarr v0.1/v0.2 (KeyError), v0.3 string-axes (TypeError), - # or array-like inputs without dimension metadata (AttributeError). - except (AttributeError, KeyError, TypeError): - return tuple(None for _ in axis_labels) - - def _get_unit(dim: str) -> str | None: - prop = getattr(dim_props, dim, None) - return prop.unit if prop else None - - return tuple(_get_unit(dim) for dim in axis_labels) - - @property - def layer_metadata(self) -> dict: - """Base metadata dict for napari layers. - - Contains bioimage reference, raw metadata, and OME metadata if available. - - Returns - ------- - dict - Keys: 'bioimage', 'raw_image_metadata', and optionally 'ome_metadata'. - - """ - meta: dict = { - 'bioimage': self, - 'raw_image_metadata': self.metadata, - } - - try: - meta['ome_metadata'] = self.ome_metadata - except NotImplementedError: - pass # Reader doesn't support OME metadata - except (ValueError, TypeError, KeyError) as e: - # Some files have metadata that doesn't conform to OME schema, despite bioio attempting to parse it - # (e.g., CZI files with LatticeLightsheet acquisition mode) - # As such, when accessing ome_metadata, we may get various exceptions - # Log warning but continue - raw metadata is still available - logger.warning( - 'Could not parse OME metadata: %s. ' - "Raw metadata is still available in 'raw_image_metadata'.", - e, - ) - - return meta - - def get_layer_data_tuples( - self, - layer_type: str | None = None, - channel_types: dict[str, str] | None = None, - channel_kwargs: dict[str, dict] | None = None, - ) -> list[LayerDataTuple]: - """Build layer data tuples for napari. - - Splits multichannel data into separate layers, each with appropriate - metadata. Automatically detects label layers from channel names - containing keywords like 'label', 'mask', 'segmentation'. - - Parameters - ---------- - layer_type : str, optional - Override layer type for ALL channels. Valid values: 'image', - 'labels', 'shapes', 'points', 'surface', 'tracks', 'vectors'. - If None, auto-detection is used (based on channel names). - Takes precedence over channel_types. - channel_types : dict[str, str], optional - Per-channel layer type overrides. - e.g., {"DAPI": "image", "nuclei_mask": "labels"} - Ignored if layer_type is set. - channel_kwargs : dict[str, dict], optional - Per-channel napari kwargs overrides. - e.g., {"DAPI": {"colormap": "blue", "contrast_limits": (0, 1000)}} - These override the automatically generated metadata. - - Returns - ------- - list[LayerDataTuple] - List of (data, metadata, layer_type) tuples. - - Examples - -------- - Add layers to napari: - - >>> from napari.layers import Layer - >>> img = nImage("path/to/image.tiff") - >>> for ldt in img.get_layer_data_tuples(): - ... viewer.add_layer(Layer.create(*ldt)) - - Mixed image/labels: - - >>> img.get_layer_data_tuples( - ... channel_types={"DAPI": "image", "nuclei_mask": "labels"} - ... ) - - See Also - -------- - napari.layers.Layer.create : Creates a layer from a LayerDataTuple. - https://napari.org/dev/plugins/building_a_plugin/guides.html - - """ - ref = self.reference_xarray - data = self.layer_data - if layer_type is not None: - channel_types = None # Global override ignores per-channel - names = self.layer_names - base_metadata = self.layer_metadata - scale = self.layer_scale - axis_labels = self.layer_axis_labels - units = self.layer_units - - # Handle RGB images (Samples dimension 'S') - if 'S' in self.dims.order: - return [ - build_layer_tuple( - data, - layer_type='image', - name=names[0], - metadata=base_metadata, - scale=scale, - axis_labels=axis_labels, - units=units, - rgb=True, - ) - ] - - channel_dim = 'C' - - # Single channel (no C dimension to split) - if channel_dim not in ref.dims: - channel_name = self.channel_names[0] - effective_type = resolve_layer_type( - channel_name or '', layer_type, channel_types - ) - extra_kwargs = ( - channel_kwargs.get(channel_name) - if channel_kwargs and channel_name - else None - ) - return [ - build_layer_tuple( - data, - layer_type=effective_type, - name=names[0], - metadata=base_metadata, - scale=scale, - axis_labels=axis_labels, - units=units, - extra_kwargs=extra_kwargs, - ) - ] - - # Multichannel - split into separate layers - channel_names = self.channel_names - channel_axis = ref.dims.index(channel_dim) - total_channels = ref.shape[channel_axis] - - tuples: list[LayerDataTuple] = [] - for i in range(total_channels): - channel_name = channel_names[i] - effective_type = resolve_layer_type( - channel_name, layer_type, channel_types - ) - - # Slice along channel axis for each resolution level - slices: list[slice | int] = [slice(None)] * ref.ndim - slices[channel_axis] = i - channel_data = [arr[tuple(slices)] for arr in data] - - extra_kwargs = ( - channel_kwargs.get(channel_name) if channel_kwargs else None - ) - - tuples.append( - build_layer_tuple( - channel_data, - layer_type=effective_type, - name=names[i], - metadata=base_metadata, - scale=scale, - axis_labels=axis_labels, - units=units, - channel_idx=i, - total_channels=total_channels, - extra_kwargs=extra_kwargs, - ) - ) - - return tuples +"""Additional functionality for BioImage objects to be used in napari-ndev.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from bioio import BioImage + +from .bioio_plugins._manager import raise_unsupported_with_suggestions +from .utils._layer_utils import ( + build_layer_tuple, + determine_in_memory, + resolve_layer_type, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + import xarray as xr + from bioio_base.reader import Reader + from bioio_base.types import ImageLike + from napari.types import LayerDataTuple + +logger = logging.getLogger(__name__) + + +def _resolve_reader( + image: ImageLike, + explicit_reader: type[Reader] | Sequence[type[Reader]] | None, +) -> type[Reader] | Sequence[type[Reader]] | None: + """Resolve the reader to use for an image. + + Priority: + 1. Explicit reader (passed to __init__) + 2. Preferred reader from settings (if file path and installed) + 3. None (let bioio determine) + + Parameters + ---------- + image : ImageLike + The image to resolve a reader for. + explicit_reader : type[Reader] | Sequence[type[Reader]] | None + Explicit reader class(es) passed by user. + + Returns + ------- + type[Reader] | Sequence[type[Reader]] | None + The reader to use, or None to let bioio choose. + + """ + if explicit_reader is not None: + return explicit_reader + + # Only check preferred reader for file paths + if not isinstance(image, str | Path): + return None + + # Get preferred reader from settings + from ndev_settings import get_settings + + from .bioio_plugins._utils import get_installed_plugins, get_reader_by_name + + settings = get_settings() + preferred = settings.ndevio_reader.preferred_reader # type: ignore + + if not preferred: + return None + + if preferred not in get_installed_plugins(): + logger.debug('Preferred reader %s not installed', preferred) + return None + + return get_reader_by_name(preferred) + + +class nImage(BioImage): + """ + An nImage is a BioImage with additional functionality for napari. + + Extends BioImage to provide napari-ready layer data with proper scale, + axis labels, and units derived from bioimaging metadata. + + Parameters + ---------- + image : ImageLike + Image to be loaded. Can be a path to an image file, a numpy array, + or an xarray DataArray. + reader : type[Reader] | Sequence[type[Reader]], optional + Reader class or priority list of readers. If not provided, checks + settings for preferred_reader and tries that first, then falls back + to bioio's default deterministic priority. + **kwargs + Additional arguments passed to BioImage. + + Attributes + ---------- + path : str | None + Path or URI to the source file, or None if created from array data. + Always a plain string — local paths are stored as-is, ``file://`` URIs + are normalised to their path component, and remote URIs (``s3://``, + ``https://``, …) are kept verbatim. Use ``_is_remote`` to distinguish + local from remote. + + Examples + -------- + Basic usage with file path: + + >>> img = nImage("path/to/image.tiff") + >>> for layer_tuple in img.get_layer_data_tuples(): + ... layer = Layer.create(*layer_tuple) + ... viewer.add_layer(layer) + + Access layer properties: + + >>> img.layer_scale # (1.0, 0.2, 0.2) - physical scale per dim + >>> img.layer_axis_labels # ('Z', 'Y', 'X') + >>> img.layer_units # ('µm', 'µm', 'µm') + + """ + + # Class-level type hints for instance attributes + path: str | None + _is_remote: bool + _reference_xarray: xr.DataArray | None + _layer_data: list | None + + def __init__( + self, + image: ImageLike, + reader: type[Reader] | Sequence[type[Reader]] | None = None, + **kwargs, + ) -> None: + """Initialize an nImage with an image, and optionally a reader.""" + from bioio_base.exceptions import UnsupportedFileFormatError + + # Strip trailing slashes from string paths/URLs (e.g. `store.zarr/`) + # so that bioio's extension-based reader detection works correctly. + if isinstance(image, str): + image = image.rstrip('/') + + resolved_reader = _resolve_reader(image, reader) + + # Try preferred/explicit reader first, fall back to bioio default + if resolved_reader is not None: + try: + super().__init__(image=image, reader=resolved_reader, **kwargs) + except UnsupportedFileFormatError: + # Preferred reader failed, fall back to bioio's default + try: + super().__init__(image=image, reader=None, **kwargs) + except UnsupportedFileFormatError: + if isinstance(image, str | Path): + raise_unsupported_with_suggestions(image) + raise + else: + try: + super().__init__(image=image, reader=None, **kwargs) + except UnsupportedFileFormatError: + if isinstance(image, str | Path): + raise_unsupported_with_suggestions(image) + raise + + # Instance state + self._reference_xarray = None + self._layer_data = None + if isinstance(image, str | Path): + import fsspec + from fsspec.implementations.local import LocalFileSystem + + s = str(image) + fs, resolved = fsspec.url_to_fs(s) + if isinstance(fs, LocalFileSystem): + # Normalise file:// URIs and any platform variations to an + # OS-native path string so Path(self.path) always round-trips. + self.path = str(Path(resolved)) + self._is_remote = False + else: + # Remote URI (s3://, https://, gc://, …) — keep verbatim. + self.path = s + self._is_remote = True + else: + self.path = None + self._is_remote = False + + # Any compatibility warnings for old formats should be emitted at this point + # Cheaply check without imports by looking at the reader's module name + if self.reader.__module__.startswith('bioio_ome_zarr'): + from .bioio_plugins._compatibility import ( + apply_ome_zarr_compat_patches, + ) + + apply_ome_zarr_compat_patches(self.reader) + + @property + def reference_xarray(self) -> xr.DataArray: + """Image data as xarray DataArray for metadata determination. + + Lazily loads xarray on first access. Uses in-memory or dask array + based on file size (determined automatically). + + Returns + ------- + xr.DataArray + Squeezed xarray for napari dimensions. + + Notes + ----- + BioImage.xarray_data and BioImage.xarray_dask_data automatically + handle mosaic tile reconstruction when reconstruct_mosaic=True + (the default). No special mosaic handling needed here. + + """ + if self._reference_xarray is None: + # Ensure we're at the highest-res level for metadata consistency + current_res = self.current_resolution_level + self.set_resolution_level(0) + if self._is_remote or not determine_in_memory(self.path): + self._reference_xarray = self.xarray_dask_data.squeeze() + else: + self._reference_xarray = self.xarray_data.squeeze() + self.set_resolution_level(current_res) + return self._reference_xarray + + @property + def layer_data(self) -> list: + """Image data arrays shaped for napari, one per resolution level. + + Returns a list of arrays ordered from highest to lowest resolution. + For single-resolution images the list has one element. + napari automatically treats multi-element lists as multiscale data. + + Multiscale images are always dask-backed for memory efficiency. + Single-resolution images use numpy or dask based on file size. + + Returns + ------- + list[ArrayLike] + Squeezed image arrays (C dim retained for multichannel split + in :meth:`get_layer_data_tuples`). + + """ + if self._layer_data is None: + self._layer_data = self._build_layer_data() + return self._layer_data + + def _build_layer_data(self) -> list: + """Build the list of arrays for all resolution levels.""" + current_res = self.current_resolution_level + levels = self.resolution_levels + multiscale = len(levels) > 1 + + # Determine which dims to keep from level 0's squeezed metadata. + # Using isel instead of squeeze ensures all levels have + # consistent ndim (lower levels may have extra singleton spatial dims + # that squeeze would incorrectly remove). + ref = self.reference_xarray + keep_dims = set(ref.dims) + + arrays: list = [] + for level in levels: + self.set_resolution_level(level) + if ( + multiscale + or self._is_remote + or not determine_in_memory(self.path) + ): + xr_data = self.xarray_dask_data + else: + xr_data = self.xarray_data + + indexer = {d: 0 for d in xr_data.dims if d not in keep_dims} + arrays.append(xr_data.isel(indexer).data) + + self.set_resolution_level(current_res) + return arrays + + @property + def path_stem(self) -> str: + """Filename stem derived from path or URI, used in layer names. + + Returns + ------- + str + The stem of the filename (no extension, no parent path), or + ``'unknown'`` when the image was created from array data. + + Examples + -------- + >>> nImage("/data/cells.ome.tiff").path_stem + 'cells.ome' + >>> nImage("s3://bucket/experiment/image.zarr").path_stem + 'image' + + """ + if self.path is None: + return 'unknown' + if self._is_remote: + from pathlib import PurePosixPath + from urllib.parse import urlparse + + return PurePosixPath(urlparse(self.path).path).stem + return Path(self.path).stem + + @property + def layer_names(self) -> list[str]: + """Per-channel layer names for napari. + + Returns one name per output layer — the same count as + :meth:`get_layer_data_tuples` returns tuples. The base name is the + scene-qualified :attr:`path_stem`; channel names are prepended using + ``' :: '`` as a delimiter when present. + + Returns + ------- + list[str] + e.g. ``['membrane :: cells.ome', 'nuclei :: cells.ome']`` + for a 2-channel file, or ``['0 :: cells.ome']`` for a + single-channel OME image with a default ``C`` coordinate. + Only when no ``C`` dimension is present at all will the name + be just ``['cells.ome']``. + + Examples + -------- + >>> nImage("cells.ome.tiff").layer_names + ['0 :: cells.ome'] + + """ + # Build scene-qualified base name + delim = ' :: ' + parts: list[str] = [] + if len(self.scenes) > 1 or self.current_scene != 'Image:0': + parts.extend([str(self.current_scene_index), self.current_scene]) + parts.append(self.path_stem) + base_name = delim.join(parts) + + # RGB (Samples dim): single layer, no channel prefix + if 'S' in self.dims.order: + return [base_name] + + # Use BioImage channel_names — metadata only, no data load + channel_names = self.channel_names + + # Single channel (C=1 is squeezed out of layer_data) + if self.dims.C == 1: + ch_name = channel_names[0] + return [f'{ch_name} :: {base_name}' if ch_name else base_name] + + # Multichannel + return [f'{ch} :: {base_name}' for ch in channel_names] + + @property + def layer_scale(self) -> tuple[float, ...]: + """Physical scale for dimensions in layer data. + + Uses layer_axis_labels to determine which dimensions are present, + then extracts scale values from BioImage.scale. + Defaults to 1.0 for dimensions without scale metadata. + + Returns + ------- + tuple[float, ...] + Scale tuple matching layer_axis_labels. + + Examples + -------- + >>> img = nImage("timelapse.tiff") # T=3, Z=1, Y=10, X=10 + >>> img.layer_axis_labels + ('T', 'Y', 'X') + >>> img.layer_scale + (2.0, 0.2, 0.2) + + """ + axis_labels = self.layer_axis_labels + + # Try to get scale from BioImage - may fail for array-like inputs + # where physical_pixel_sizes is None (AttributeError), old OME-Zarr + # v0.1/v0.2 missing 'coordinateTransformations' (KeyError), or + # v0.3 string-axes that weren't normalised (TypeError). + try: + bio_scale = self.scale + except (AttributeError, KeyError, TypeError): + return tuple(1.0 for _ in axis_labels) + return tuple( + getattr(bio_scale, dim, None) or 1.0 for dim in axis_labels + ) + + @property + def layer_axis_labels(self) -> tuple[str, ...]: + """Dimension names for napari layers (excludes Channel and Samples). + + Returns + ------- + tuple[str, ...] + Dimension names (e.g., ('Z', 'Y', 'X')). + + Examples + -------- + >>> img = nImage("multichannel.tiff") # Shape (C=2, Z=10, Y=100, X=100) + >>> img.layer_axis_labels + ('Z', 'Y', 'X') + + """ + layer_data = self.reference_xarray + + # Exclude Channel and Samples dimensions (RGB/multichannel handled separately) + return tuple( + str(dim) for dim in layer_data.dims if dim not in ('C', 'S') + ) + + @property + def layer_units(self) -> tuple[str | None, ...]: + """Physical units for dimensions in layer data. + + Returns + ------- + tuple[str | None, ...] + Unit strings matching layer_axis_labels. None for dims without units. + + Examples + -------- + >>> img = nImage("timelapse.tiff") # T=3, Z=1, Y=10, X=10 + >>> # After squeezing, Z is removed + >>> img.layer_axis_labels + ('T', 'Y', 'X') + >>> img.layer_units + ('s', 'µm', 'µm') + + """ + axis_labels = self.layer_axis_labels + + try: + dim_props = self.dimension_properties + # Old OME-Zarr v0.1/v0.2 (KeyError), v0.3 string-axes (TypeError), + # or array-like inputs without dimension metadata (AttributeError). + except (AttributeError, KeyError, TypeError): + return tuple(None for _ in axis_labels) + + def _get_unit(dim: str) -> str | None: + prop = getattr(dim_props, dim, None) + return prop.unit if prop else None + + return tuple(_get_unit(dim) for dim in axis_labels) + + @property + def layer_metadata(self) -> dict: + """Base metadata dict for napari layers. + + Contains bioimage reference, raw metadata, and OME metadata if available. + + Returns + ------- + dict + Keys: 'bioimage', 'raw_image_metadata', and optionally 'ome_metadata'. + + """ + meta: dict = { + 'bioimage': self, + 'raw_image_metadata': self.metadata, + } + + try: + meta['ome_metadata'] = self.ome_metadata + except NotImplementedError: + pass # Reader doesn't support OME metadata + except (ValueError, TypeError, KeyError) as e: + # Some files have metadata that doesn't conform to OME schema, despite bioio attempting to parse it + # (e.g., CZI files with LatticeLightsheet acquisition mode) + # As such, when accessing ome_metadata, we may get various exceptions + # Log warning but continue - raw metadata is still available + logger.warning( + 'Could not parse OME metadata: %s. ' + "Raw metadata is still available in 'raw_image_metadata'.", + e, + ) + + return meta + + def get_layer_data_tuples( + self, + layer_type: str | None = None, + channel_types: dict[str, str] | None = None, + channel_kwargs: dict[str, dict] | None = None, + ) -> list[LayerDataTuple]: + """Build layer data tuples for napari. + + Splits multichannel data into separate layers, each with appropriate + metadata. Automatically detects label layers from channel names + containing keywords like 'label', 'mask', 'segmentation'. + + Parameters + ---------- + layer_type : str, optional + Override layer type for ALL channels. Valid values: 'image', + 'labels', 'shapes', 'points', 'surface', 'tracks', 'vectors'. + If None, auto-detection is used (based on channel names). + Takes precedence over channel_types. + channel_types : dict[str, str], optional + Per-channel layer type overrides. + e.g., {"DAPI": "image", "nuclei_mask": "labels"} + Ignored if layer_type is set. + channel_kwargs : dict[str, dict], optional + Per-channel napari kwargs overrides. + e.g., {"DAPI": {"colormap": "blue", "contrast_limits": (0, 1000)}} + These override the automatically generated metadata. + + Returns + ------- + list[LayerDataTuple] + List of (data, metadata, layer_type) tuples. + + Examples + -------- + Add layers to napari: + + >>> from napari.layers import Layer + >>> img = nImage("path/to/image.tiff") + >>> for ldt in img.get_layer_data_tuples(): + ... viewer.add_layer(Layer.create(*ldt)) + + Mixed image/labels: + + >>> img.get_layer_data_tuples( + ... channel_types={"DAPI": "image", "nuclei_mask": "labels"} + ... ) + + See Also + -------- + napari.layers.Layer.create : Creates a layer from a LayerDataTuple. + https://napari.org/dev/plugins/building_a_plugin/guides.html + + """ + ref = self.reference_xarray + data = self.layer_data + if layer_type is not None: + channel_types = None # Global override ignores per-channel + names = self.layer_names + base_metadata = self.layer_metadata + scale = self.layer_scale + axis_labels = self.layer_axis_labels + units = self.layer_units + + # Handle RGB images (Samples dimension 'S') + if 'S' in self.dims.order: + return [ + build_layer_tuple( + data, + layer_type='image', + name=names[0], + metadata=base_metadata, + scale=scale, + axis_labels=axis_labels, + units=units, + rgb=True, + ) + ] + + channel_dim = 'C' + + # Single channel (no C dimension to split) + if channel_dim not in ref.dims: + channel_name = self.channel_names[0] + effective_type = resolve_layer_type( + channel_name or '', layer_type, channel_types + ) + extra_kwargs = ( + channel_kwargs.get(channel_name) + if channel_kwargs and channel_name + else None + ) + return [ + build_layer_tuple( + data, + layer_type=effective_type, + name=names[0], + metadata=base_metadata, + scale=scale, + axis_labels=axis_labels, + units=units, + extra_kwargs=extra_kwargs, + ) + ] + + # Multichannel - split into separate layers + channel_names = self.channel_names + channel_axis = ref.dims.index(channel_dim) + total_channels = ref.shape[channel_axis] + + tuples: list[LayerDataTuple] = [] + for i in range(total_channels): + channel_name = channel_names[i] + effective_type = resolve_layer_type( + channel_name, layer_type, channel_types + ) + + # Slice along channel axis for each resolution level + slices: list[slice | int] = [slice(None)] * ref.ndim + slices[channel_axis] = i + channel_data = [arr[tuple(slices)] for arr in data] + + extra_kwargs = ( + channel_kwargs.get(channel_name) if channel_kwargs else None + ) + + tuples.append( + build_layer_tuple( + channel_data, + layer_type=effective_type, + name=names[i], + metadata=base_metadata, + scale=scale, + axis_labels=axis_labels, + units=units, + channel_idx=i, + total_channels=total_channels, + extra_kwargs=extra_kwargs, + ) + ) + + return tuples diff --git a/src/ndevio/sampledata/__init__.py b/src/ndevio/sampledata/__init__.py index 654872d..28e63d6 100644 --- a/src/ndevio/sampledata/__init__.py +++ b/src/ndevio/sampledata/__init__.py @@ -1,19 +1 @@ -"""Sample data for ndevio and the ndev-kit ecosystem.""" - -from ndevio.sampledata._sample_data import ( - ndev_logo, - neocortex, - neuron_labels, - neuron_labels_processed, - neuron_raw, - scratch_assay, -) - -__all__ = [ - 'ndev_logo', - 'neocortex', - 'neuron_labels', - 'neuron_labels_processed', - 'neuron_raw', - 'scratch_assay', -] +"""Sample data for ndevio and the ndev-kit ecosystem.""" diff --git a/src/ndevio/sampledata/_sample_data.py b/src/ndevio/sampledata/_sample_data.py index 433c4fd..0bfb049 100644 --- a/src/ndevio/sampledata/_sample_data.py +++ b/src/ndevio/sampledata/_sample_data.py @@ -1,112 +1,133 @@ -""" -Sample data providers for napari. - -This module implements the "sample data" specification. -see: https://napari.org/stable/plugins/building_a_plugin/guides.html#sample-data -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -import pooch -from bioio_imageio import Reader as ImageIOReader -from bioio_ome_tiff import Reader as OmeTiffReader - -from ndevio import nImage - -if TYPE_CHECKING: - from napari.types import LayerDataTuple - -SAMPLE_DIR = Path(__file__).parent / 'data' - - -def ndev_logo() -> list[LayerDataTuple]: - """Load the ndev logo image.""" - return nImage( - SAMPLE_DIR / 'ndev-logo.png', - reader=ImageIOReader, - ).get_layer_data_tuples() - - -def scratch_assay() -> list[LayerDataTuple]: - """Load scratch assay data with labeled nuclei and cytoplasm.""" - scratch_assay_raw_path = pooch.retrieve( - url='doi:10.5281/zenodo.17845346/scratch-assay-labeled-10T-2Ch.tiff', - known_hash='md5:2b98c4ea18cd741a1545e59855348a2f', - fname='scratch-assay-labeled-10T-2Ch.tiff', - path=SAMPLE_DIR, - ) - img = nImage( - scratch_assay_raw_path, - reader=OmeTiffReader, - ) - return img.get_layer_data_tuples( - channel_types={ - 'H3342': 'image', - 'oblique': 'image', - 'nuclei': 'labels', - 'cyto': 'labels', - }, - channel_kwargs={ - 'H3342': {'colormap': 'cyan'}, - 'oblique': {'colormap': 'gray'}, - }, - ) - - -def neocortex() -> list[LayerDataTuple]: - """Load neocortex 3-channel image data.""" - neocortex_raw_path = pooch.retrieve( - url='doi:10.5281/zenodo.17845346/neocortex-3Ch.tiff', - known_hash='md5:eadc3fac751052461fb2e5f3c6716afa', - fname='neocortex-3Ch.tiff', - path=SAMPLE_DIR, - ) - return nImage( - neocortex_raw_path, - reader=OmeTiffReader, - ).get_layer_data_tuples() - - -def neuron_raw() -> list[LayerDataTuple]: - """Load raw neuron 4-channel image data. - - This sample is downloaded from Zenodo if not present locally. - """ - neuron_raw_path = pooch.retrieve( - url='doi:10.5281/zenodo.17845346/neuron-4Ch_raw.tiff', - known_hash='md5:5d3e42bca2085e8588b6f23cf89ba87c', - fname='neuron-4Ch_raw.tiff', - path=SAMPLE_DIR, - ) - return nImage( - neuron_raw_path, - reader=OmeTiffReader, - ).get_layer_data_tuples( - layer_type='image', - channel_kwargs={ - 'PHALL': {'colormap': 'gray'}, - }, - ) - - -def neuron_labels() -> list[LayerDataTuple]: - """Load neuron labels data.""" - return nImage( - SAMPLE_DIR / 'neuron-4Ch_labels.tiff', - reader=OmeTiffReader, - ).get_layer_data_tuples( - layer_type='labels', - ) - - -def neuron_labels_processed() -> list[LayerDataTuple]: - """Load processed neuron labels data.""" - return nImage( - SAMPLE_DIR / 'neuron-4Ch_labels_processed.tiff', - reader=OmeTiffReader, - ).get_layer_data_tuples( - layer_type='labels', - ) +""" +Sample data providers for napari. + +This module implements the "sample data" specification. +see: https://napari.org/stable/plugins/building_a_plugin/guides.html#sample-data +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from napari.types import LayerDataTuple + +SAMPLE_DIR = Path(__file__).parent / 'data' + + +def ndev_logo() -> list[LayerDataTuple]: + """Load the ndev logo image.""" + from bioio_imageio import Reader as ImageIOReader + + from ..nimage import nImage + + return nImage( + SAMPLE_DIR / 'ndev-logo.png', + reader=ImageIOReader, + ).get_layer_data_tuples() + + +def scratch_assay() -> list[LayerDataTuple]: + """Load scratch assay data with labeled nuclei and cytoplasm.""" + import pooch + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + scratch_assay_raw_path = pooch.retrieve( + url='doi:10.5281/zenodo.17845346/scratch-assay-labeled-10T-2Ch.tiff', + known_hash='md5:2b98c4ea18cd741a1545e59855348a2f', + fname='scratch-assay-labeled-10T-2Ch.tiff', + path=SAMPLE_DIR, + ) + img = nImage( + scratch_assay_raw_path, + reader=OmeTiffReader, + ) + return img.get_layer_data_tuples( + channel_types={ + 'H3342': 'image', + 'oblique': 'image', + 'nuclei': 'labels', + 'cyto': 'labels', + }, + channel_kwargs={ + 'H3342': {'colormap': 'cyan'}, + 'oblique': {'colormap': 'gray'}, + }, + ) + + +def neocortex() -> list[LayerDataTuple]: + """Load neocortex 3-channel image data.""" + import pooch + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + neocortex_raw_path = pooch.retrieve( + url='doi:10.5281/zenodo.17845346/neocortex-3Ch.tiff', + known_hash='md5:eadc3fac751052461fb2e5f3c6716afa', + fname='neocortex-3Ch.tiff', + path=SAMPLE_DIR, + ) + return nImage( + neocortex_raw_path, + reader=OmeTiffReader, + ).get_layer_data_tuples() + + +def neuron_raw() -> list[LayerDataTuple]: + """Load raw neuron 4-channel image data. + + This sample is downloaded from Zenodo if not present locally. + """ + import pooch + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + neuron_raw_path = pooch.retrieve( + url='doi:10.5281/zenodo.17845346/neuron-4Ch_raw.tiff', + known_hash='md5:5d3e42bca2085e8588b6f23cf89ba87c', + fname='neuron-4Ch_raw.tiff', + path=SAMPLE_DIR, + ) + return nImage( + neuron_raw_path, + reader=OmeTiffReader, + ).get_layer_data_tuples( + layer_type='image', + channel_kwargs={ + 'PHALL': {'colormap': 'gray'}, + }, + ) + + +def neuron_labels() -> list[LayerDataTuple]: + """Load neuron labels data.""" + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + return nImage( + SAMPLE_DIR / 'neuron-4Ch_labels.tiff', + reader=OmeTiffReader, + ).get_layer_data_tuples( + layer_type='labels', + ) + + +def neuron_labels_processed() -> list[LayerDataTuple]: + """Load processed neuron labels data.""" + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + return nImage( + SAMPLE_DIR / 'neuron-4Ch_labels_processed.tiff', + reader=OmeTiffReader, + ).get_layer_data_tuples( + layer_type='labels', + ) diff --git a/src/ndevio/widgets/__init__.py b/src/ndevio/widgets/__init__.py index 0531491..a7d584e 100644 --- a/src/ndevio/widgets/__init__.py +++ b/src/ndevio/widgets/__init__.py @@ -1,14 +1 @@ -"""Widgets for ndevio package.""" - -from ..bioio_plugins._manager import ReaderPluginManager -from ._plugin_install_widget import PluginInstallerWidget -from ._scene_widget import DELIMITER, nImageSceneWidget -from ._utilities_container import UtilitiesContainer - -__all__ = [ - 'PluginInstallerWidget', - 'nImageSceneWidget', - 'UtilitiesContainer', - 'DELIMITER', - 'ReaderPluginManager', -] +"""Widgets for ndevio package.""" diff --git a/tests/test_nimage.py b/tests/test_nimage.py index dd6817e..7ecbd85 100644 --- a/tests/test_nimage.py +++ b/tests/test_nimage.py @@ -1,766 +1,800 @@ -"""Tests for ndevio.nImage class.""" - -from __future__ import annotations - -from pathlib import Path -from unittest import mock -from unittest.mock import patch - -import pytest -from bioio_base.exceptions import UnsupportedFileFormatError - -from ndevio import nImage - -RGB_TIFF = ( - 'RGB_bad_metadata.tiff' # has two scenes, with really difficult metadata -) -CELLS3D2CH_OME_TIFF = 'cells3d2ch_legacy.tiff' # 2 channel, 3D OME-TIFF, from old napari-ndev saving -LOGO_PNG = 'nDev-logo-small.png' # small PNG file (fix typo) -CZI_FILE = '0T-4C-0Z-7pos.czi' # multi-scene CZI file -ND2_FILE = 'ND2_dims_rgb.nd2' # ND2 file requiring bioio-nd2 -ZARR = 'dimension_handling_zyx_V3.zarr' - - -def test_nImage_init(resources_dir: Path): - """Test nImage initialization with a file that should work.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - assert img.path == str(resources_dir / CELLS3D2CH_OME_TIFF) - assert img.reader is not None - # Shape is (T, C, Z, Y, X) = (1, 2, 60, 66, 85) - assert img.data.shape == (1, 2, 60, 66, 85) - # layer_data should not be loaded until accessed - assert img._reference_xarray is None - # Accessing the property triggers lazy loading - assert img.reference_xarray is not None - - -def test_nImage_zarr(resources_dir: Path): - """Test that nImage can read a Zarr file.""" - img = nImage(resources_dir / ZARR) - assert img.data is not None - assert img.path == str(resources_dir / ZARR) - assert img.data.shape == (1, 1, 2, 4, 4) - - -@pytest.mark.network -def test_nImage_remote_zarr(): - """Test that nImage can read a remote Zarr file.""" - remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/ebi-ngff-challenge-2024/4ffaeed2-fa70-4907-820f-8a96ef683095.zarr' # from https://github.com/bioio-devs/bioio-ome-zarr/blob/main/bioio_ome_zarr/tests/test_remote_read_zarrV3.py - img = nImage(remote_zarr) - assert img.path == remote_zarr - assert img._is_remote - # original shape is (1, 2, 1, 512, 512) but layer_data is squeezed - assert img.reference_xarray.shape == (2, 512, 512) - - -@pytest.mark.network -def test_nImage_remote_zarr_v01v02_format(caplog): - """Test that nImage emits a warning for old OME-Zarr formats when reading remotely.""" - remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836841.zarr' # from https://github.com/ndev-kit/ndevio/issues/50 - with caplog.at_level( - 'WARNING', logger='ndevio.bioio_plugins._compatibility' - ): - img = nImage(remote_zarr) - assert img.path == remote_zarr - # should catch a key error due to old format - # but still quietly create a scale with no units - assert img.layer_scale == (1.0, 1.0) - assert img.layer_units == (None, None) - - -@pytest.mark.network -def test_nimage_remote_v03_zarr(): - """Test that nImage can read a real remote OME-Zarr v0.3 store.""" - remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.3/9836842.zarr' - img = nImage(remote_zarr) - assert img.path == remote_zarr - assert img._is_remote - assert img.reference_xarray is not None - tuples = img.get_layer_data_tuples() - assert len(tuples) > 0 - - -def test_nImage_ome_reader(resources_dir: Path): - """ - Test that the OME-TIFF reader is used for OME-TIFF files. - - This test is in response to https://github.com/bioio-devs/bioio/issues/79 - whereby images saved with bioio.writers.OmeTiffWriter are not being read with - bioio_ome_tiff.Reader, but instead with bioio_tifffile.Reader. - - The example here was saved with aicsimageio.writers.OmeTiffWriter. nImage - has an __init__ function that should override the reader determined by - bioio.BioImage.determine_plugin() with bioio_ome_tiff if the image is an - OME-TIFF. - """ - - img_path = resources_dir / CELLS3D2CH_OME_TIFF - - nimg = nImage(img_path) - # assert nimg.settings.ndevio_reader.preferred_reader == 'bioio-ome-tiff' # this was the old methodology before bioio#162 - assert nimg.reader.name == 'bioio_ome_tiff' - # the below only exists if 'bioio-ome-tiff' is used - assert hasattr(nimg, 'ome_metadata') - assert nimg.channel_names == ['membrane', 'nuclei'] - - -def test_nImage_save_read(resources_dir: Path, tmp_path: Path): - """ - Test saving and reading an image with OmeTiffWriter and nImage. - - Confirm that the image is saved with the correct physical pixel sizes and - channel names, and that it is read back with the same physical pixel sizes - and channel names because it is an OME-TIFF. See the above test for - the need of this and to ensure not being read by bioio_tifffile.Reader. - """ - from bioio_base.types import PhysicalPixelSizes - from bioio_ome_tiff.writers import OmeTiffWriter - - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - assert img.physical_pixel_sizes.X == 1 - - img_data = img.get_image_data('CZYX') - OmeTiffWriter.save( - img_data, - tmp_path / 'test_save_read.tiff', - dim_order='CZYX', - physical_pixel_sizes=PhysicalPixelSizes(1, 2, 3), # ZYX - channel_names=['test1', 'test2'], - ) - assert (tmp_path / 'test_save_read.tiff').exists() - - new_img = nImage(tmp_path / 'test_save_read.tiff') - - # having the below features means it is properly read as OME-TIFF - assert new_img.physical_pixel_sizes.Z == 1 - assert new_img.physical_pixel_sizes.Y == 2 - assert new_img.physical_pixel_sizes.X == 3 - assert new_img.channel_names == ['test1', 'test2'] - - -def test_get_layer_data(resources_dir: Path): - """Test loading napari layer data in memory.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - # Access layer_data property to trigger loading - data = img.reference_xarray - # layer_data will be squeezed - # Original shape (1, 2, 60, 66, 85) -> (2, 60, 66, 85) - assert data.shape == (2, 60, 66, 85) - assert data.dims == ('C', 'Z', 'Y', 'X') - - -def test_get_layer_data_tuples_basic(resources_dir: Path): - """Test layer data tuple generation.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - # With 2 channels, should get 2 tuples (one per channel) - assert len(layer_tuples) == 2 - for _data, meta, layer_type in layer_tuples: - assert 'cells3d2ch_legacy' in meta['name'] - assert meta['scale'] is not None - assert layer_type == 'image' # default layer type - - -def test_get_layer_data_tuples_ome_validation_error_logged( - resources_dir: Path, - caplog: pytest.LogCaptureFixture, -): - """Test that OME metadata validation errors are logged but don't crash. - - Some files (e.g., CZI files with LatticeLightsheet acquisition mode) have - metadata that doesn't conform to the OME schema, causing ValidationError - when accessing ome_metadata. This should be logged as a warning but not - prevent the image from loading. - """ - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - - # Mock ome_metadata to raise a ValidationError (which inherits from ValueError) - with mock.patch.object( - type(img), - 'ome_metadata', - new_callable=mock.PropertyMock, - side_effect=ValueError('Invalid acquisition_mode: LatticeLightsheet'), - ): - caplog.clear() - layer_tuples = img.get_layer_data_tuples() - - # Should still return valid layer tuples - assert layer_tuples is not None - assert len(layer_tuples) > 0 - - # Check that metadata dict exists in each tuple - for _, meta, _ in layer_tuples: - assert 'name' in meta - assert 'metadata' in meta - # ome_metadata should NOT be in the nested metadata dict - assert 'ome_metadata' not in meta['metadata'] - # raw_image_metadata should still be available - assert 'raw_image_metadata' in meta['metadata'] - - # Warning should be logged - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == 'WARNING' - assert 'Could not parse OME metadata' in caplog.records[0].message - assert 'LatticeLightsheet' in caplog.records[0].message - - -def test_get_layer_data_tuples_ome_not_implemented_silent( - resources_dir: Path, - caplog: pytest.LogCaptureFixture, -): - """Test that NotImplementedError for ome_metadata is silently ignored. - - Some readers don't support OME metadata at all. This should be silently - ignored without logging. - """ - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - - # Mock ome_metadata to raise NotImplementedError - with mock.patch.object( - type(img), - 'ome_metadata', - new_callable=mock.PropertyMock, - side_effect=NotImplementedError( - 'Reader does not support OME metadata' - ), - ): - caplog.clear() - layer_tuples = img.get_layer_data_tuples() - - # Should still return valid layer tuples - assert layer_tuples is not None - assert len(layer_tuples) > 0 - - for _, meta, _ in layer_tuples: - assert 'ome_metadata' not in meta['metadata'] - - # No warning should be logged for NotImplementedError - assert len(caplog.records) == 0 - - -@pytest.mark.parametrize( - ('filename', 'should_work', 'expected_error_contains'), - [ - (LOGO_PNG, True, None), - (CELLS3D2CH_OME_TIFF, True, None), - (CZI_FILE, True, None), - (ND2_FILE, False, ['bioio-nd2', 'pip install']), - (RGB_TIFF, True, None), - ], -) -def test_nimage_init_with_various_formats( - resources_dir: Path, - filename: str, - should_work: bool | str, - expected_error_contains: list[str] | None, -): - """Test nImage initialization with various file formats. - - This tests the complete workflow: file → get_reader_priority → nImage init - """ - if should_work is True: - # Must successfully initialize - img = nImage(resources_dir / filename) - assert img.data is not None - assert img.path == str(resources_dir / filename) - elif should_work is False: - # Must fail with helpful error - with pytest.raises(UnsupportedFileFormatError) as exc_info: - nImage(resources_dir / filename) - - error_msg = str(exc_info.value) - if expected_error_contains: - for expected_text in expected_error_contains: - assert expected_text in error_msg - else: # "maybe" - # Can succeed or fail - try: - img = nImage(resources_dir / filename) - assert img.data is not None - except UnsupportedFileFormatError as e: - error_msg = str(e) - # Should contain at least one of the expected error texts - if expected_error_contains: - assert any( - text in error_msg for text in expected_error_contains - ) - - -# ============================================================================= -# Tests for get_layer_data_tuples -# ============================================================================= - - -class TestGetLayerDataTuples: - """Tests for nImage.get_layer_data_tuples method.""" - - def test_multichannel_returns_tuple_per_channel(self, resources_dir: Path): - """Test that multichannel images return one tuple per channel. - - The new API always splits channels, returning separate tuples for each. - """ - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - # Should return one tuple per channel (2 channels) - assert len(layer_tuples) == 2 - - for data, meta, layer_type in layer_tuples: - # channel_axis should NOT be in metadata (we split ourselves) - assert 'channel_axis' not in meta - - # name should be a string (not a list) - assert isinstance(meta['name'], str) - - # Data should be a list of arrays (multiscale-ready) - assert isinstance(data, list) - assert len(data) == 1 # single resolution level - # Shape should NOT include channel dimension - assert data[0].shape == (60, 66, 85) # ZYX only - - # Default layer type is "image" (channel names don't match label keywords) - assert layer_type == 'image' - - def test_layer_names_include_channel_names(self, resources_dir: Path): - """Test that layer names include channel names from the file.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - # Extract names from the tuples - names = [meta['name'] for _, meta, _ in layer_tuples] - - # Channel names from the file are "membrane" and "nuclei" - assert 'membrane' in names[0] - assert 'nuclei' in names[1] - - def test_layer_names_matches_tuple_names(self, resources_dir: Path): - """Test that layer_names property matches names in get_layer_data_tuples.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - # layer_names should match names baked into the tuples - assert img.layer_names == [meta['name'] for _, meta, _ in layer_tuples] - assert len(img.layer_names) == 2 - assert 'membrane' in img.layer_names[0] - assert 'nuclei' in img.layer_names[1] - - def test_layer_names_single_channel(self, resources_dir: Path): - """Test layer_names for a single-channel image.""" - img = nImage(resources_dir / LOGO_PNG) - assert len(img.layer_names) == 1 - assert img.layer_names[0].endswith(img.path_stem) - - def test_single_channel_image_returns_single_tuple( - self, resources_dir: Path - ): - """Test that single channel images return single tuple.""" - # PNG is single channel (or RGB treated as single layer) - img = nImage(resources_dir / LOGO_PNG) - layer_tuples = img.get_layer_data_tuples() - - # Single channel should return single tuple - assert len(layer_tuples) == 1 - - data, meta, layer_type = layer_tuples[0] - assert 'channel_axis' not in meta - assert layer_type == 'image' - - def test_scale_preserved_in_tuples(self, resources_dir: Path): - """Test that scale metadata is preserved in each tuple.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - for _, meta, _ in layer_tuples: - # Scale should be preserved in each layer - assert 'scale' in meta - # Original has physical pixel sizes, so scale should have values - assert len(meta['scale']) > 0 - - def test_colormap_cycling_for_images(self, resources_dir: Path): - """Test that image layers get colormaps based on napari's defaults. - - - 1 channel → gray - - 2 channels → magenta, green (MAGENTA_GREEN) - - 3+ channels → cycles through CYMRGB - """ - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - # Extract colormaps from the tuples - colormaps = [meta.get('colormap') for _, meta, _ in layer_tuples] - - # 2 channels should use MAGENTA_GREEN - assert colormaps[0] == 'magenta' - assert colormaps[1] == 'green' - - def test_colormap_single_channel_is_gray(self, resources_dir: Path): - """Test that single channel images get gray colormap.""" - import numpy as np - import xarray as xr - - # Create nImage directly with single channel data (no Channel dimension) - mock_data = xr.DataArray( - np.zeros((10, 10)), - dims=['Y', 'X'], - ) - img = nImage(mock_data) - - layer_tuples = img.get_layer_data_tuples() - assert len(layer_tuples) == 1 - assert layer_tuples[0][1]['colormap'] == 'gray' - - def test_colormap_three_plus_channels_uses_multi_channel_cycle( - self, resources_dir: Path - ): - """Test that 3+ channel images cycle through MULTI_CHANNEL_CYCLE.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - from ndevio.utils._colormap_utils import MULTI_CHANNEL_CYCLE - - # Create nImage directly with 4 channel data - mock_data = xr.DataArray( - np.zeros((4, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['ch0', 'ch1', 'ch2', 'ch3']}, - ) - img = nImage(mock_data) - - layer_tuples = img.get_layer_data_tuples() - colormaps = [meta.get('colormap') for _, meta, _ in layer_tuples] - - # Should cycle through MULTI_CHANNEL_CYCLE (CMYBGR) - assert colormaps[0] == MULTI_CHANNEL_CYCLE[0] # cyan - assert colormaps[1] == MULTI_CHANNEL_CYCLE[1] # magenta - assert colormaps[2] == MULTI_CHANNEL_CYCLE[2] # yellow - assert colormaps[3] == MULTI_CHANNEL_CYCLE[3] # blue - - def test_auto_detect_labels_from_channel_name(self, resources_dir: Path): - """Test that channels with label-like names are detected as labels.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - # Create nImage directly with a channel named "mask" - mock_data = xr.DataArray( - np.zeros((2, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['intensity', 'mask']}, - ) - img = nImage(mock_data) - - layer_tuples = img.get_layer_data_tuples() - - # First channel "intensity" should be image - assert layer_tuples[0][2] == 'image' - # Second channel "mask" should be labels (keyword match) - assert layer_tuples[1][2] == 'labels' - - def test_channel_types_override_auto_detection(self, resources_dir: Path): - """Test that channel_types parameter overrides auto-detection.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - # Create nImage directly with mock data - mock_data = xr.DataArray( - np.zeros((2, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['intensity', 'mask']}, - ) - img = nImage(mock_data) - - # Override: set both channels to labels - layer_tuples = img.get_layer_data_tuples( - channel_types={'intensity': 'labels', 'mask': 'labels'} - ) - - # Both should be labels due to override - assert layer_tuples[0][2] == 'labels' - assert layer_tuples[1][2] == 'labels' - - def test_labels_do_not_get_colormap(self, resources_dir: Path): - """Test that labels layers don't get colormap metadata.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - # Create nImage directly with a labels channel - mock_data = xr.DataArray( - np.zeros((1, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['segmentation']}, - ) - img = nImage(mock_data) - - layer_tuples = img.get_layer_data_tuples() - - # "segmentation" matches label keyword - assert layer_tuples[0][2] == 'labels' - # Labels should not have colormap - assert 'colormap' not in layer_tuples[0][1] - - def test_layer_type_override_all_channels(self, resources_dir: Path): - """Test that layer_type parameter overrides all channels.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples(layer_type='labels') - - # All channels should be labels due to override - assert len(layer_tuples) == 2 - for _, meta, layer_type in layer_tuples: - assert layer_type == 'labels' - # Labels should not have colormap - assert 'colormap' not in meta - - def test_layer_type_overrides_channel_types(self, resources_dir: Path): - """Test that layer_type takes precedence over channel_types.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - # Create nImage directly with mock data - mock_data = xr.DataArray( - np.zeros((2, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['intensity', 'mask']}, - ) - img = nImage(mock_data) - - # Even though channel_types says "intensity" should be image, - # layer_type="labels" should override everything - layer_tuples = img.get_layer_data_tuples( - layer_type='labels', - channel_types={'intensity': 'image', 'mask': 'image'}, - ) - - # Both should be labels due to layer_type override - assert layer_tuples[0][2] == 'labels' - - def test_channel_kwargs_override_metadata(self, resources_dir: Path): - """Test that channel_kwargs overrides default metadata.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples( - channel_kwargs={ - img.channel_names[0]: { - 'colormap': 'blue', - 'contrast_limits': (0, 1000), - }, - img.channel_names[1]: { - 'opacity': 0.5, - }, - } - ) - - assert len(layer_tuples) == 2 - # First channel should have overridden colormap and contrast_limits - assert layer_tuples[0][1]['colormap'] == 'blue' - assert layer_tuples[0][1]['contrast_limits'] == (0, 1000) - # Second channel should have opacity override but default colormap - assert layer_tuples[1][1]['opacity'] == 0.5 - assert ( - layer_tuples[1][1]['colormap'] == 'green' - ) # default for 2-channel - - -class TestPreferredReaderFallback: - """Tests for preferred reader fallback logic in nImage.__init__.""" - - def test_preferred_reader_success(self, resources_dir: Path): - """Test that preferred reader is used when it works.""" - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - # Mock returning a valid reader - from bioio_tifffile import Reader - - mock_resolve.return_value = Reader - - img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) - - # Verify _resolve_reader was called - mock_resolve.assert_called_once() - assert img is not None - assert img.reader.name == 'bioio_tifffile' - - def test_preferred_reader_fallback(self, resources_dir: Path): - """Test that failed preferred reader will fallback""" - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - # Mock returning a reader that won't work for this file - from bioio_czi import Reader - - mock_resolve.return_value = Reader - - img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) - - # Verify _resolve_reader was called - mock_resolve.assert_called_once() - assert img is not None - # Should have fallen back to bioio's default (ome-tiff) - assert img.reader.name == 'bioio_ome_tiff' - - def test_no_preferred_reader_uses_default(self, resources_dir: Path): - """Test that no preferred reader uses bioio's default priority.""" - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - mock_resolve.return_value = None # No preferred reader - - img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) - assert img is not None - mock_resolve.assert_called_once() - assert img.reader.name == 'bioio_ome_tiff' - - -class TestResolveReaderFunction: - """Tests for _resolve_reader function.""" - - def test_returns_none_when_no_preferred_reader(self): - """Test returns None when preferred_reader is not set.""" - from ndevio.nimage import _resolve_reader - - with patch('ndev_settings.get_settings') as mock_get_settings: - mock_get_settings.return_value.ndevio_reader.preferred_reader = ( - None - ) - - result = _resolve_reader('test.tiff', None) - assert result is None - - def test_returns_none_when_preferred_not_installed(self): - """Test returns None when preferred reader is not installed.""" - from ndevio.nimage import _resolve_reader - - with ( - patch('ndev_settings.get_settings') as mock_get_settings, - patch( - 'ndevio.bioio_plugins._utils.get_installed_plugins', - return_value={'bioio-ome-tiff', 'bioio-tifffile'}, - ), - ): - mock_get_settings.return_value.ndevio_reader.preferred_reader = ( - 'bioio-czi' - ) - - result = _resolve_reader('test.tiff', None) - assert result is None - - def test_returns_reader_when_preferred_installed(self): - """Test returns reader class when preferred reader is installed.""" - from ndevio.nimage import _resolve_reader - - with ( - patch('ndev_settings.get_settings') as mock_get_settings, - patch( - 'ndevio.bioio_plugins._utils.get_installed_plugins', - return_value={'bioio-ome-tiff'}, - ), - patch( - 'ndevio.bioio_plugins._utils.get_reader_by_name' - ) as mock_get_reader, - ): - from bioio_ome_tiff import Reader as OmeTiffReader - - mock_get_reader.return_value = OmeTiffReader - mock_get_settings.return_value.ndevio_reader.preferred_reader = ( - 'bioio-ome-tiff' - ) - - result = _resolve_reader('test.tiff', None) - assert result == OmeTiffReader - mock_get_reader.assert_called_once_with('bioio-ome-tiff') - - def test_explicit_reader_bypasses_settings(self): - """Test that explicit reader bypasses settings lookup.""" - from bioio_tifffile import Reader as TifffileReader - - from ndevio.nimage import _resolve_reader - - with patch('ndev_settings.get_settings') as mock_get_settings: - result = _resolve_reader('test.tiff', TifffileReader) - - # Should return explicit reader without checking settings - assert result == TifffileReader - mock_get_settings.assert_not_called() - - def test_array_input_returns_none(self): - """Test that array inputs don't trigger preferred reader lookup.""" - import numpy as np - - from ndevio.nimage import _resolve_reader - - with patch('ndev_settings.get_settings') as mock_get_settings: - arr = np.zeros((10, 10), dtype=np.uint8) - result = _resolve_reader(arr, None) - - # Should return None without checking settings for arrays - assert result is None - mock_get_settings.assert_not_called() - - -class TestNonPathImageHandling: - """Tests for handling non-path inputs (arrays).""" - - def test_array_input_no_preferred_reader_check(self): - """Test that arrays don't trigger preferred reader logic.""" - import numpy as np - - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - # Create a simple array - arr = np.zeros((10, 10), dtype=np.uint8) - - # This should work - img = nImage(arr) - assert img is not None - - # _resolve_reader should have been called but returned None - mock_resolve.assert_called_once() - # First arg is the image, second is explicit_reader (None) - call_args = mock_resolve.call_args - assert call_args[0][1] is None # explicit_reader is None - - def test_unsupported_array_raises_without_suggestions(self): - """Test that unsupported arrays raise error without plugin suggestions.""" - # Create something that will fail - with pytest.raises(UnsupportedFileFormatError) as exc_info: - # Pass an invalid object - nImage('this_is_not_a_valid_input.fake') - - # Error should be raised but without custom suggestions since it's not a path - error_msg = str(exc_info.value) - assert ( - 'fake' in error_msg.lower() or 'unsupported' in error_msg.lower() - ) - - -class TestExplicitReaderParameter: - """Tests for when reader is explicitly provided.""" - - def test_explicit_reader_bypasses_preferred(self, resources_dir: Path): - """Test that explicit reader parameter bypasses preferred reader.""" - from bioio_tifffile import Reader as TifffileReader - - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - mock_resolve.return_value = TifffileReader - - # Explicit reader should be used directly - img = nImage( - str(resources_dir / 'cells3d2ch_legacy.tiff'), - reader=TifffileReader, - ) - - assert img is not None - # _resolve_reader should return the explicit reader - mock_resolve.assert_called_once() - call_args = mock_resolve.call_args - assert call_args[0][1] == TifffileReader # explicit_reader - - def test_explicit_reader_fails_falls_back(self, resources_dir: Path): - """Test explicit reader that fails falls back to default.""" - from bioio_czi import Reader as CziReader - - # Use CZI reader on a TIFF file - it should fail and fall back - img = nImage( - str(resources_dir / 'cells3d2ch_legacy.tiff'), - reader=CziReader, - ) - - assert img is not None - # Should have fallen back to bioio's default - assert img.reader.name == 'bioio_ome_tiff' +"""Tests for ndevio.nImage class.""" + +from __future__ import annotations + +from pathlib import Path +from unittest import mock +from unittest.mock import patch + +import pytest +from bioio_base.exceptions import UnsupportedFileFormatError + +from ndevio import nImage + +RGB_TIFF = ( + 'RGB_bad_metadata.tiff' # has two scenes, with really difficult metadata +) +CELLS3D2CH_OME_TIFF = 'cells3d2ch_legacy.tiff' # 2 channel, 3D OME-TIFF, from old napari-ndev saving +LOGO_PNG = 'nDev-logo-small.png' # small PNG file (fix typo) +CZI_FILE = '0T-4C-0Z-7pos.czi' # multi-scene CZI file +ND2_FILE = 'ND2_dims_rgb.nd2' # ND2 file requiring bioio-nd2 +ZARR = 'dimension_handling_zyx_V3.zarr' + + +def test_nImage_init(resources_dir: Path): + """Test nImage initialization with a file that should work.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + assert img.path == str(resources_dir / CELLS3D2CH_OME_TIFF) + assert img.reader is not None + # Shape is (T, C, Z, Y, X) = (1, 2, 60, 66, 85) + assert img.data.shape == (1, 2, 60, 66, 85) + # layer_data should not be loaded until accessed + assert img._reference_xarray is None + # Accessing the property triggers lazy loading + assert img.reference_xarray is not None + + +def test_nImage_zarr(resources_dir: Path): + """Test that nImage can read a Zarr file.""" + img = nImage(resources_dir / ZARR) + assert img.data is not None + assert img.path == str(resources_dir / ZARR) + assert img.data.shape == (1, 1, 2, 4, 4) + + +def test_nImage_zarr_trailing_slash(resources_dir: Path): + """Test that a string path with a trailing slash is handled correctly. + + Regression test: bioio's extension-based reader detection fails when the + path ends with '/', e.g. 'store.zarr/'. nImage strips the slash on init. + See https://github.com/ndev-kit/ndevio/issues/XX + """ + path_with_slash = str(resources_dir / ZARR) + '/' + img = nImage(path_with_slash) + assert img.data is not None + # path stored without the trailing slash + assert not img.path.endswith('/') + assert img.path == str(resources_dir / ZARR) + assert img.data.shape == (1, 1, 2, 4, 4) + + +@pytest.mark.network +def test_nImage_remote_zarr_trailing_slash(): + """Test that a remote Zarr URL with a trailing slash is read correctly. + + Regression test: 'https://...9846152.zarr/' crashed with + 'Reader ndevio returned no data' because the trailing slash prevented + bioio from matching the '*.zarr' extension pattern. + """ + remote_zarr = ( + 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr/' + ) + img = nImage(remote_zarr) + assert img._is_remote + assert not img.path.endswith('/') + assert img.path == remote_zarr.rstrip('/') + assert img.data is not None + + +@pytest.mark.network +def test_nImage_remote_zarr(): + """Test that nImage can read a remote Zarr file.""" + remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/ebi-ngff-challenge-2024/4ffaeed2-fa70-4907-820f-8a96ef683095.zarr' # from https://github.com/bioio-devs/bioio-ome-zarr/blob/main/bioio_ome_zarr/tests/test_remote_read_zarrV3.py + img = nImage(remote_zarr) + assert img.path == remote_zarr + assert img._is_remote + # original shape is (1, 2, 1, 512, 512) but layer_data is squeezed + assert img.reference_xarray.shape == (2, 512, 512) + + +@pytest.mark.network +def test_nImage_remote_zarr_v01v02_format(caplog): + """Test that nImage emits a warning for old OME-Zarr formats when reading remotely.""" + remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836841.zarr' # from https://github.com/ndev-kit/ndevio/issues/50 + with caplog.at_level( + 'WARNING', logger='ndevio.bioio_plugins._compatibility' + ): + img = nImage(remote_zarr) + assert img.path == remote_zarr + # should catch a key error due to old format + # but still quietly create a scale with no units + assert img.layer_scale == (1.0, 1.0) + assert img.layer_units == (None, None) + + +@pytest.mark.network +def test_nimage_remote_v03_zarr(): + """Test that nImage can read a real remote OME-Zarr v0.3 store.""" + remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.3/9836842.zarr' + img = nImage(remote_zarr) + assert img.path == remote_zarr + assert img._is_remote + assert img.reference_xarray is not None + tuples = img.get_layer_data_tuples() + assert len(tuples) > 0 + + +def test_nImage_ome_reader(resources_dir: Path): + """ + Test that the OME-TIFF reader is used for OME-TIFF files. + + This test is in response to https://github.com/bioio-devs/bioio/issues/79 + whereby images saved with bioio.writers.OmeTiffWriter are not being read with + bioio_ome_tiff.Reader, but instead with bioio_tifffile.Reader. + + The example here was saved with aicsimageio.writers.OmeTiffWriter. nImage + has an __init__ function that should override the reader determined by + bioio.BioImage.determine_plugin() with bioio_ome_tiff if the image is an + OME-TIFF. + """ + + img_path = resources_dir / CELLS3D2CH_OME_TIFF + + nimg = nImage(img_path) + # assert nimg.settings.ndevio_reader.preferred_reader == 'bioio-ome-tiff' # this was the old methodology before bioio#162 + assert nimg.reader.name == 'bioio_ome_tiff' + # the below only exists if 'bioio-ome-tiff' is used + assert hasattr(nimg, 'ome_metadata') + assert nimg.channel_names == ['membrane', 'nuclei'] + + +def test_nImage_save_read(resources_dir: Path, tmp_path: Path): + """ + Test saving and reading an image with OmeTiffWriter and nImage. + + Confirm that the image is saved with the correct physical pixel sizes and + channel names, and that it is read back with the same physical pixel sizes + and channel names because it is an OME-TIFF. See the above test for + the need of this and to ensure not being read by bioio_tifffile.Reader. + """ + from bioio_base.types import PhysicalPixelSizes + from bioio_ome_tiff.writers import OmeTiffWriter + + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + assert img.physical_pixel_sizes.X == 1 + + img_data = img.get_image_data('CZYX') + OmeTiffWriter.save( + img_data, + tmp_path / 'test_save_read.tiff', + dim_order='CZYX', + physical_pixel_sizes=PhysicalPixelSizes(1, 2, 3), # ZYX + channel_names=['test1', 'test2'], + ) + assert (tmp_path / 'test_save_read.tiff').exists() + + new_img = nImage(tmp_path / 'test_save_read.tiff') + + # having the below features means it is properly read as OME-TIFF + assert new_img.physical_pixel_sizes.Z == 1 + assert new_img.physical_pixel_sizes.Y == 2 + assert new_img.physical_pixel_sizes.X == 3 + assert new_img.channel_names == ['test1', 'test2'] + + +def test_get_layer_data(resources_dir: Path): + """Test loading napari layer data in memory.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + # Access layer_data property to trigger loading + data = img.reference_xarray + # layer_data will be squeezed + # Original shape (1, 2, 60, 66, 85) -> (2, 60, 66, 85) + assert data.shape == (2, 60, 66, 85) + assert data.dims == ('C', 'Z', 'Y', 'X') + + +def test_get_layer_data_tuples_basic(resources_dir: Path): + """Test layer data tuple generation.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + # With 2 channels, should get 2 tuples (one per channel) + assert len(layer_tuples) == 2 + for _data, meta, layer_type in layer_tuples: + assert 'cells3d2ch_legacy' in meta['name'] + assert meta['scale'] is not None + assert layer_type == 'image' # default layer type + + +def test_get_layer_data_tuples_ome_validation_error_logged( + resources_dir: Path, + caplog: pytest.LogCaptureFixture, +): + """Test that OME metadata validation errors are logged but don't crash. + + Some files (e.g., CZI files with LatticeLightsheet acquisition mode) have + metadata that doesn't conform to the OME schema, causing ValidationError + when accessing ome_metadata. This should be logged as a warning but not + prevent the image from loading. + """ + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + + # Mock ome_metadata to raise a ValidationError (which inherits from ValueError) + with mock.patch.object( + type(img), + 'ome_metadata', + new_callable=mock.PropertyMock, + side_effect=ValueError('Invalid acquisition_mode: LatticeLightsheet'), + ): + caplog.clear() + layer_tuples = img.get_layer_data_tuples() + + # Should still return valid layer tuples + assert layer_tuples is not None + assert len(layer_tuples) > 0 + + # Check that metadata dict exists in each tuple + for _, meta, _ in layer_tuples: + assert 'name' in meta + assert 'metadata' in meta + # ome_metadata should NOT be in the nested metadata dict + assert 'ome_metadata' not in meta['metadata'] + # raw_image_metadata should still be available + assert 'raw_image_metadata' in meta['metadata'] + + # Warning should be logged + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == 'WARNING' + assert 'Could not parse OME metadata' in caplog.records[0].message + assert 'LatticeLightsheet' in caplog.records[0].message + + +def test_get_layer_data_tuples_ome_not_implemented_silent( + resources_dir: Path, + caplog: pytest.LogCaptureFixture, +): + """Test that NotImplementedError for ome_metadata is silently ignored. + + Some readers don't support OME metadata at all. This should be silently + ignored without logging. + """ + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + + # Mock ome_metadata to raise NotImplementedError + with mock.patch.object( + type(img), + 'ome_metadata', + new_callable=mock.PropertyMock, + side_effect=NotImplementedError( + 'Reader does not support OME metadata' + ), + ): + caplog.clear() + layer_tuples = img.get_layer_data_tuples() + + # Should still return valid layer tuples + assert layer_tuples is not None + assert len(layer_tuples) > 0 + + for _, meta, _ in layer_tuples: + assert 'ome_metadata' not in meta['metadata'] + + # No warning should be logged for NotImplementedError + assert len(caplog.records) == 0 + + +@pytest.mark.parametrize( + ('filename', 'should_work', 'expected_error_contains'), + [ + (LOGO_PNG, True, None), + (CELLS3D2CH_OME_TIFF, True, None), + (CZI_FILE, True, None), + (ND2_FILE, False, ['bioio-nd2', 'pip install']), + (RGB_TIFF, True, None), + ], +) +def test_nimage_init_with_various_formats( + resources_dir: Path, + filename: str, + should_work: bool | str, + expected_error_contains: list[str] | None, +): + """Test nImage initialization with various file formats. + + This tests the complete workflow: file → get_reader_priority → nImage init + """ + if should_work is True: + # Must successfully initialize + img = nImage(resources_dir / filename) + assert img.data is not None + assert img.path == str(resources_dir / filename) + elif should_work is False: + # Must fail with helpful error + with pytest.raises(UnsupportedFileFormatError) as exc_info: + nImage(resources_dir / filename) + + error_msg = str(exc_info.value) + if expected_error_contains: + for expected_text in expected_error_contains: + assert expected_text in error_msg + else: # "maybe" + # Can succeed or fail + try: + img = nImage(resources_dir / filename) + assert img.data is not None + except UnsupportedFileFormatError as e: + error_msg = str(e) + # Should contain at least one of the expected error texts + if expected_error_contains: + assert any( + text in error_msg for text in expected_error_contains + ) + + +# ============================================================================= +# Tests for get_layer_data_tuples +# ============================================================================= + + +class TestGetLayerDataTuples: + """Tests for nImage.get_layer_data_tuples method.""" + + def test_multichannel_returns_tuple_per_channel(self, resources_dir: Path): + """Test that multichannel images return one tuple per channel. + + The new API always splits channels, returning separate tuples for each. + """ + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + # Should return one tuple per channel (2 channels) + assert len(layer_tuples) == 2 + + for data, meta, layer_type in layer_tuples: + # channel_axis should NOT be in metadata (we split ourselves) + assert 'channel_axis' not in meta + + # name should be a string (not a list) + assert isinstance(meta['name'], str) + + # Data should be a list of arrays (multiscale-ready) + assert isinstance(data, list) + assert len(data) == 1 # single resolution level + # Shape should NOT include channel dimension + assert data[0].shape == (60, 66, 85) # ZYX only + + # Default layer type is "image" (channel names don't match label keywords) + assert layer_type == 'image' + + def test_layer_names_include_channel_names(self, resources_dir: Path): + """Test that layer names include channel names from the file.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + # Extract names from the tuples + names = [meta['name'] for _, meta, _ in layer_tuples] + + # Channel names from the file are "membrane" and "nuclei" + assert 'membrane' in names[0] + assert 'nuclei' in names[1] + + def test_layer_names_matches_tuple_names(self, resources_dir: Path): + """Test that layer_names property matches names in get_layer_data_tuples.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + # layer_names should match names baked into the tuples + assert img.layer_names == [meta['name'] for _, meta, _ in layer_tuples] + assert len(img.layer_names) == 2 + assert 'membrane' in img.layer_names[0] + assert 'nuclei' in img.layer_names[1] + + def test_layer_names_single_channel(self, resources_dir: Path): + """Test layer_names for a single-channel image.""" + img = nImage(resources_dir / LOGO_PNG) + assert len(img.layer_names) == 1 + assert img.layer_names[0].endswith(img.path_stem) + + def test_single_channel_image_returns_single_tuple( + self, resources_dir: Path + ): + """Test that single channel images return single tuple.""" + # PNG is single channel (or RGB treated as single layer) + img = nImage(resources_dir / LOGO_PNG) + layer_tuples = img.get_layer_data_tuples() + + # Single channel should return single tuple + assert len(layer_tuples) == 1 + + data, meta, layer_type = layer_tuples[0] + assert 'channel_axis' not in meta + assert layer_type == 'image' + + def test_scale_preserved_in_tuples(self, resources_dir: Path): + """Test that scale metadata is preserved in each tuple.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + for _, meta, _ in layer_tuples: + # Scale should be preserved in each layer + assert 'scale' in meta + # Original has physical pixel sizes, so scale should have values + assert len(meta['scale']) > 0 + + def test_colormap_cycling_for_images(self, resources_dir: Path): + """Test that image layers get colormaps based on napari's defaults. + + - 1 channel → gray + - 2 channels → magenta, green (MAGENTA_GREEN) + - 3+ channels → cycles through CYMRGB + """ + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + # Extract colormaps from the tuples + colormaps = [meta.get('colormap') for _, meta, _ in layer_tuples] + + # 2 channels should use MAGENTA_GREEN + assert colormaps[0] == 'magenta' + assert colormaps[1] == 'green' + + def test_colormap_single_channel_is_gray(self, resources_dir: Path): + """Test that single channel images get gray colormap.""" + import numpy as np + import xarray as xr + + # Create nImage directly with single channel data (no Channel dimension) + mock_data = xr.DataArray( + np.zeros((10, 10)), + dims=['Y', 'X'], + ) + img = nImage(mock_data) + + layer_tuples = img.get_layer_data_tuples() + assert len(layer_tuples) == 1 + assert layer_tuples[0][1]['colormap'] == 'gray' + + def test_colormap_three_plus_channels_uses_multi_channel_cycle( + self, resources_dir: Path + ): + """Test that 3+ channel images cycle through MULTI_CHANNEL_CYCLE.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + from ndevio.utils._colormap_utils import MULTI_CHANNEL_CYCLE + + # Create nImage directly with 4 channel data + mock_data = xr.DataArray( + np.zeros((4, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['ch0', 'ch1', 'ch2', 'ch3']}, + ) + img = nImage(mock_data) + + layer_tuples = img.get_layer_data_tuples() + colormaps = [meta.get('colormap') for _, meta, _ in layer_tuples] + + # Should cycle through MULTI_CHANNEL_CYCLE (CMYBGR) + assert colormaps[0] == MULTI_CHANNEL_CYCLE[0] # cyan + assert colormaps[1] == MULTI_CHANNEL_CYCLE[1] # magenta + assert colormaps[2] == MULTI_CHANNEL_CYCLE[2] # yellow + assert colormaps[3] == MULTI_CHANNEL_CYCLE[3] # blue + + def test_auto_detect_labels_from_channel_name(self, resources_dir: Path): + """Test that channels with label-like names are detected as labels.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + # Create nImage directly with a channel named "mask" + mock_data = xr.DataArray( + np.zeros((2, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['intensity', 'mask']}, + ) + img = nImage(mock_data) + + layer_tuples = img.get_layer_data_tuples() + + # First channel "intensity" should be image + assert layer_tuples[0][2] == 'image' + # Second channel "mask" should be labels (keyword match) + assert layer_tuples[1][2] == 'labels' + + def test_channel_types_override_auto_detection(self, resources_dir: Path): + """Test that channel_types parameter overrides auto-detection.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + # Create nImage directly with mock data + mock_data = xr.DataArray( + np.zeros((2, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['intensity', 'mask']}, + ) + img = nImage(mock_data) + + # Override: set both channels to labels + layer_tuples = img.get_layer_data_tuples( + channel_types={'intensity': 'labels', 'mask': 'labels'} + ) + + # Both should be labels due to override + assert layer_tuples[0][2] == 'labels' + assert layer_tuples[1][2] == 'labels' + + def test_labels_do_not_get_colormap(self, resources_dir: Path): + """Test that labels layers don't get colormap metadata.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + # Create nImage directly with a labels channel + mock_data = xr.DataArray( + np.zeros((1, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['segmentation']}, + ) + img = nImage(mock_data) + + layer_tuples = img.get_layer_data_tuples() + + # "segmentation" matches label keyword + assert layer_tuples[0][2] == 'labels' + # Labels should not have colormap + assert 'colormap' not in layer_tuples[0][1] + + def test_layer_type_override_all_channels(self, resources_dir: Path): + """Test that layer_type parameter overrides all channels.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples(layer_type='labels') + + # All channels should be labels due to override + assert len(layer_tuples) == 2 + for _, meta, layer_type in layer_tuples: + assert layer_type == 'labels' + # Labels should not have colormap + assert 'colormap' not in meta + + def test_layer_type_overrides_channel_types(self, resources_dir: Path): + """Test that layer_type takes precedence over channel_types.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + # Create nImage directly with mock data + mock_data = xr.DataArray( + np.zeros((2, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['intensity', 'mask']}, + ) + img = nImage(mock_data) + + # Even though channel_types says "intensity" should be image, + # layer_type="labels" should override everything + layer_tuples = img.get_layer_data_tuples( + layer_type='labels', + channel_types={'intensity': 'image', 'mask': 'image'}, + ) + + # Both should be labels due to layer_type override + assert layer_tuples[0][2] == 'labels' + + def test_channel_kwargs_override_metadata(self, resources_dir: Path): + """Test that channel_kwargs overrides default metadata.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples( + channel_kwargs={ + img.channel_names[0]: { + 'colormap': 'blue', + 'contrast_limits': (0, 1000), + }, + img.channel_names[1]: { + 'opacity': 0.5, + }, + } + ) + + assert len(layer_tuples) == 2 + # First channel should have overridden colormap and contrast_limits + assert layer_tuples[0][1]['colormap'] == 'blue' + assert layer_tuples[0][1]['contrast_limits'] == (0, 1000) + # Second channel should have opacity override but default colormap + assert layer_tuples[1][1]['opacity'] == 0.5 + assert ( + layer_tuples[1][1]['colormap'] == 'green' + ) # default for 2-channel + + +class TestPreferredReaderFallback: + """Tests for preferred reader fallback logic in nImage.__init__.""" + + def test_preferred_reader_success(self, resources_dir: Path): + """Test that preferred reader is used when it works.""" + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + # Mock returning a valid reader + from bioio_tifffile import Reader + + mock_resolve.return_value = Reader + + img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) + + # Verify _resolve_reader was called + mock_resolve.assert_called_once() + assert img is not None + assert img.reader.name == 'bioio_tifffile' + + def test_preferred_reader_fallback(self, resources_dir: Path): + """Test that failed preferred reader will fallback""" + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + # Mock returning a reader that won't work for this file + from bioio_czi import Reader + + mock_resolve.return_value = Reader + + img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) + + # Verify _resolve_reader was called + mock_resolve.assert_called_once() + assert img is not None + # Should have fallen back to bioio's default (ome-tiff) + assert img.reader.name == 'bioio_ome_tiff' + + def test_no_preferred_reader_uses_default(self, resources_dir: Path): + """Test that no preferred reader uses bioio's default priority.""" + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + mock_resolve.return_value = None # No preferred reader + + img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) + assert img is not None + mock_resolve.assert_called_once() + assert img.reader.name == 'bioio_ome_tiff' + + +class TestResolveReaderFunction: + """Tests for _resolve_reader function.""" + + def test_returns_none_when_no_preferred_reader(self): + """Test returns None when preferred_reader is not set.""" + from ndevio.nimage import _resolve_reader + + with patch('ndev_settings.get_settings') as mock_get_settings: + mock_get_settings.return_value.ndevio_reader.preferred_reader = ( + None + ) + + result = _resolve_reader('test.tiff', None) + assert result is None + + def test_returns_none_when_preferred_not_installed(self): + """Test returns None when preferred reader is not installed.""" + from ndevio.nimage import _resolve_reader + + with ( + patch('ndev_settings.get_settings') as mock_get_settings, + patch( + 'ndevio.bioio_plugins._utils.get_installed_plugins', + return_value={'bioio-ome-tiff', 'bioio-tifffile'}, + ), + ): + mock_get_settings.return_value.ndevio_reader.preferred_reader = ( + 'bioio-czi' + ) + + result = _resolve_reader('test.tiff', None) + assert result is None + + def test_returns_reader_when_preferred_installed(self): + """Test returns reader class when preferred reader is installed.""" + from ndevio.nimage import _resolve_reader + + with ( + patch('ndev_settings.get_settings') as mock_get_settings, + patch( + 'ndevio.bioio_plugins._utils.get_installed_plugins', + return_value={'bioio-ome-tiff'}, + ), + patch( + 'ndevio.bioio_plugins._utils.get_reader_by_name' + ) as mock_get_reader, + ): + from bioio_ome_tiff import Reader as OmeTiffReader + + mock_get_reader.return_value = OmeTiffReader + mock_get_settings.return_value.ndevio_reader.preferred_reader = ( + 'bioio-ome-tiff' + ) + + result = _resolve_reader('test.tiff', None) + assert result == OmeTiffReader + mock_get_reader.assert_called_once_with('bioio-ome-tiff') + + def test_explicit_reader_bypasses_settings(self): + """Test that explicit reader bypasses settings lookup.""" + from bioio_tifffile import Reader as TifffileReader + + from ndevio.nimage import _resolve_reader + + with patch('ndev_settings.get_settings') as mock_get_settings: + result = _resolve_reader('test.tiff', TifffileReader) + + # Should return explicit reader without checking settings + assert result == TifffileReader + mock_get_settings.assert_not_called() + + def test_array_input_returns_none(self): + """Test that array inputs don't trigger preferred reader lookup.""" + import numpy as np + + from ndevio.nimage import _resolve_reader + + with patch('ndev_settings.get_settings') as mock_get_settings: + arr = np.zeros((10, 10), dtype=np.uint8) + result = _resolve_reader(arr, None) + + # Should return None without checking settings for arrays + assert result is None + mock_get_settings.assert_not_called() + + +class TestNonPathImageHandling: + """Tests for handling non-path inputs (arrays).""" + + def test_array_input_no_preferred_reader_check(self): + """Test that arrays don't trigger preferred reader logic.""" + import numpy as np + + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + # Create a simple array + arr = np.zeros((10, 10), dtype=np.uint8) + + # This should work + img = nImage(arr) + assert img is not None + + # _resolve_reader should have been called but returned None + mock_resolve.assert_called_once() + # First arg is the image, second is explicit_reader (None) + call_args = mock_resolve.call_args + assert call_args[0][1] is None # explicit_reader is None + + def test_unsupported_array_raises_without_suggestions(self): + """Test that unsupported arrays raise error without plugin suggestions.""" + # Create something that will fail + with pytest.raises(UnsupportedFileFormatError) as exc_info: + # Pass an invalid object + nImage('this_is_not_a_valid_input.fake') + + # Error should be raised but without custom suggestions since it's not a path + error_msg = str(exc_info.value) + assert ( + 'fake' in error_msg.lower() or 'unsupported' in error_msg.lower() + ) + + +class TestExplicitReaderParameter: + """Tests for when reader is explicitly provided.""" + + def test_explicit_reader_bypasses_preferred(self, resources_dir: Path): + """Test that explicit reader parameter bypasses preferred reader.""" + from bioio_tifffile import Reader as TifffileReader + + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + mock_resolve.return_value = TifffileReader + + # Explicit reader should be used directly + img = nImage( + str(resources_dir / 'cells3d2ch_legacy.tiff'), + reader=TifffileReader, + ) + + assert img is not None + # _resolve_reader should return the explicit reader + mock_resolve.assert_called_once() + call_args = mock_resolve.call_args + assert call_args[0][1] == TifffileReader # explicit_reader + + def test_explicit_reader_fails_falls_back(self, resources_dir: Path): + """Test explicit reader that fails falls back to default.""" + from bioio_czi import Reader as CziReader + + # Use CZI reader on a TIFF file - it should fail and fall back + img = nImage( + str(resources_dir / 'cells3d2ch_legacy.tiff'), + reader=CziReader, + ) + + assert img is not None + # Should have fallen back to bioio's default + assert img.reader.name == 'bioio_ome_tiff' diff --git a/tests/test_sampledata/test_sampledata.py b/tests/test_sampledata/test_sampledata.py index f90f149..5685e88 100644 --- a/tests/test_sampledata/test_sampledata.py +++ b/tests/test_sampledata/test_sampledata.py @@ -1,113 +1,113 @@ -"""Tests for ndevio.sampledata module.""" - -from __future__ import annotations - -import pytest - -from ndevio.sampledata import ( - ndev_logo, - neocortex, - neuron_labels, - neuron_labels_processed, - neuron_raw, - scratch_assay, -) - - -def _validate_layer_data_tuples( - result: list, expected_layer_type: str | None = None -): - """Helper to validate LayerDataTuple structure. - - Parameters - ---------- - result : list - List of LayerDataTuple from sample data function - expected_layer_type : str, optional - If provided, assert all layers are this type - """ - assert isinstance(result, list) - assert len(result) > 0 - - for layer_tuple in result: - # LayerDataTuple is (data, kwargs, layer_type) - assert isinstance(layer_tuple, tuple) - assert len(layer_tuple) == 3 - - data, kwargs, layer_type = layer_tuple - - # Data should be a list of array-like objects (multiscale-ready) - assert isinstance(data, list) - assert len(data) >= 1 - assert hasattr(data[0], 'shape') - assert len(data[0].shape) >= 2 # At minimum 2D - - # kwargs should be a dict - assert isinstance(kwargs, dict) - - # layer_type should be a string - assert isinstance(layer_type, str) - assert layer_type in ('image', 'labels') - - if expected_layer_type: - assert layer_type == expected_layer_type - - -class TestLocalSampleData: - """Tests for sample data that loads from local files (no network).""" - - def test_ndev_logo(self): - """Test loading ndev logo returns valid LayerDataTuples.""" - result = ndev_logo() - _validate_layer_data_tuples(result, expected_layer_type='image') - # Logo should be a single image layer - assert len(result) == 1 - - def test_neuron_labels(self): - """Test loading neuron labels returns valid LayerDataTuples.""" - result = neuron_labels() - _validate_layer_data_tuples(result, expected_layer_type='labels') - # Should have 4 channels as separate label layers - assert len(result) == 4 - - def test_neuron_labels_processed(self): - """Test loading processed neuron labels returns valid LayerDataTuples.""" - result = neuron_labels_processed() - _validate_layer_data_tuples(result, expected_layer_type='labels') - # Should have 4 channels as separate label layers - assert len(result) == 4 - - -@pytest.mark.network -class TestNetworkSampleData: - """Tests for sample data that requires network download via pooch. - - These tests are marked with @pytest.mark.network and can be skipped - in CI environments without network access using: - pytest -m "not network" - """ - - def test_scratch_assay(self): - """Test loading scratch assay returns valid LayerDataTuples.""" - result = scratch_assay() - _validate_layer_data_tuples(result) - # Should have 4 layers: 2 images + 2 labels - assert len(result) == 4 - # Check we have both image and labels types - layer_types = [t[2] for t in result] - assert 'image' in layer_types - assert 'labels' in layer_types - - def test_neocortex(self): - """Test loading neocortex returns valid LayerDataTuples.""" - result = neocortex() - _validate_layer_data_tuples(result, expected_layer_type='image') - # Should have 3 channels as separate image layers - assert len(result) == 3 - - def test_neuron_raw(self): - """Test loading neuron raw returns valid LayerDataTuples.""" - result = neuron_raw() - _validate_layer_data_tuples(result, expected_layer_type='image') - # Should have 4 channels as separate image layers - assert len(result) == 4 +"""Tests for ndevio.sampledata module.""" + +from __future__ import annotations + +import pytest + +from ndevio.sampledata._sample_data import ( + ndev_logo, + neocortex, + neuron_labels, + neuron_labels_processed, + neuron_raw, + scratch_assay, +) + + +def _validate_layer_data_tuples( + result: list, expected_layer_type: str | None = None +): + """Helper to validate LayerDataTuple structure. + + Parameters + ---------- + result : list + List of LayerDataTuple from sample data function + expected_layer_type : str, optional + If provided, assert all layers are this type + """ + assert isinstance(result, list) + assert len(result) > 0 + + for layer_tuple in result: + # LayerDataTuple is (data, kwargs, layer_type) + assert isinstance(layer_tuple, tuple) + assert len(layer_tuple) == 3 + + data, kwargs, layer_type = layer_tuple + + # Data should be a list of array-like objects (multiscale-ready) + assert isinstance(data, list) + assert len(data) >= 1 + assert hasattr(data[0], 'shape') + assert len(data[0].shape) >= 2 # At minimum 2D + + # kwargs should be a dict + assert isinstance(kwargs, dict) + + # layer_type should be a string + assert isinstance(layer_type, str) + assert layer_type in ('image', 'labels') + + if expected_layer_type: + assert layer_type == expected_layer_type + + +class TestLocalSampleData: + """Tests for sample data that loads from local files (no network).""" + + def test_ndev_logo(self): + """Test loading ndev logo returns valid LayerDataTuples.""" + result = ndev_logo() + _validate_layer_data_tuples(result, expected_layer_type='image') + # Logo should be a single image layer + assert len(result) == 1 + + def test_neuron_labels(self): + """Test loading neuron labels returns valid LayerDataTuples.""" + result = neuron_labels() + _validate_layer_data_tuples(result, expected_layer_type='labels') + # Should have 4 channels as separate label layers + assert len(result) == 4 + + def test_neuron_labels_processed(self): + """Test loading processed neuron labels returns valid LayerDataTuples.""" + result = neuron_labels_processed() + _validate_layer_data_tuples(result, expected_layer_type='labels') + # Should have 4 channels as separate label layers + assert len(result) == 4 + + +@pytest.mark.network +class TestNetworkSampleData: + """Tests for sample data that requires network download via pooch. + + These tests are marked with @pytest.mark.network and can be skipped + in CI environments without network access using: + pytest -m "not network" + """ + + def test_scratch_assay(self): + """Test loading scratch assay returns valid LayerDataTuples.""" + result = scratch_assay() + _validate_layer_data_tuples(result) + # Should have 4 layers: 2 images + 2 labels + assert len(result) == 4 + # Check we have both image and labels types + layer_types = [t[2] for t in result] + assert 'image' in layer_types + assert 'labels' in layer_types + + def test_neocortex(self): + """Test loading neocortex returns valid LayerDataTuples.""" + result = neocortex() + _validate_layer_data_tuples(result, expected_layer_type='image') + # Should have 3 channels as separate image layers + assert len(result) == 3 + + def test_neuron_raw(self): + """Test loading neuron raw returns valid LayerDataTuples.""" + result = neuron_raw() + _validate_layer_data_tuples(result, expected_layer_type='image') + # Should have 4 channels as separate image layers + assert len(result) == 4 From 79b6d993cfe84752b78da1aa139a3843e75508ae Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Wed, 25 Mar 2026 15:30:40 -0500 Subject: [PATCH 2/3] fix test imports --- .gitignore | 197 +++++++-------- tests/test_nimage.py | 2 +- .../test_plugin_installer_widget.py | 224 +++++++++--------- 3 files changed, 212 insertions(+), 211 deletions(-) diff --git a/.gitignore b/.gitignore index 75fb276..08c0ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,98 +1,99 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ -.napari_cache - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask instance folder -instance/ - -# Sphinx documentation -docs/_build/ - -# MkDocs documentation -/site/ - -# PyBuilder -target/ - -# Pycharm and VSCode -.idea/ -venv/ -.vscode/ - -# IPython Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# OS -.DS_Store - -# written by setuptools_scm -**/_version.py -# pixi environments -.pixi/* -!.pixi/config.toml - -# ndevio sampledata that is hosted on Zenodo -src/ndevio/sampledata/data/neocortex-3Ch.tiff -src/ndevio/sampledata/data/scratch-assay-labeled-10T-2Ch.tiff -src/ndevio/sampledata/data/neuron-4Ch_raw.tiff - -# LLMs -AGENTS.md - -# Scripts -scripts/nimage_testing.py +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.napari_cache + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask instance folder +instance/ + +# Sphinx documentation +docs/_build/ + +# MkDocs documentation +/site/ + +# PyBuilder +target/ + +# Pycharm and VSCode +.idea/ +venv/ +.vscode/ +.venv*/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# OS +.DS_Store + +# written by setuptools_scm +**/_version.py +# pixi environments +.pixi/* +!.pixi/config.toml + +# ndevio sampledata that is hosted on Zenodo +src/ndevio/sampledata/data/neocortex-3Ch.tiff +src/ndevio/sampledata/data/scratch-assay-labeled-10T-2Ch.tiff +src/ndevio/sampledata/data/neuron-4Ch_raw.tiff + +# LLMs +AGENTS.md + +# Scripts +scripts/nimage_testing.py diff --git a/tests/test_nimage.py b/tests/test_nimage.py index 7ecbd85..d8c3b0e 100644 --- a/tests/test_nimage.py +++ b/tests/test_nimage.py @@ -73,7 +73,7 @@ def test_nImage_remote_zarr_trailing_slash(): assert img._is_remote assert not img.path.endswith('/') assert img.path == remote_zarr.rstrip('/') - assert img.data is not None + assert img.xarray_dask_data is not None @pytest.mark.network diff --git a/tests/test_widgets/test_plugin_installer_widget.py b/tests/test_widgets/test_plugin_installer_widget.py index 2087b4d..9c24743 100644 --- a/tests/test_widgets/test_plugin_installer_widget.py +++ b/tests/test_widgets/test_plugin_installer_widget.py @@ -1,112 +1,112 @@ -"""Tests for PluginInstallerWidget. - -This module tests: -- PluginInstallerWidget behavior (unit tests, no viewer needed) -- _open_plugin_installer integration with napari viewer (needs viewer) -""" - -from pathlib import Path -from unittest.mock import patch - -import pytest - - -class TestPluginInstallerWidget: - """Tests for PluginInstallerWidget behavior.""" - - def test_standalone_mode(self): - """Test widget in standalone mode - no path, shows generic title.""" - from ndevio.widgets import PluginInstallerWidget - - widget = PluginInstallerWidget() - - # Standalone mode: no path, generic title - assert widget.manager.path is None - assert 'Install BioIO Reader Plugin' in widget._title_label.value - - def test_error_mode_with_path(self): - """Test widget in error mode - has path, preselects suggested plugin.""" - from ndevio.bioio_plugins._manager import ReaderPluginManager - from ndevio.widgets import PluginInstallerWidget - - # Mock installed plugins to NOT include bioio-czi - # This simulates the error case where file can't be read - with patch( - 'ndevio.bioio_plugins._manager.get_installed_plugins', - return_value={'bioio-ome-tiff'}, - ): - manager = ReaderPluginManager('test.czi') - widget = PluginInstallerWidget(plugin_manager=manager) - - # Error mode: has path, shows filename, preselects installable plugin - assert 'test.czi' in widget._title_label.value - assert 'bioio-czi' in manager.suggested_plugins - # bioio-czi should be in installable since it's not installed - assert 'bioio-czi' in manager.installable_plugins - # Value should be set to first installable plugin - assert widget._plugin_select.value is not None - - def test_install_button_behavior(self): - """Test install button: queues installation and updates status.""" - from ndevio.widgets import PluginInstallerWidget - - widget = PluginInstallerWidget() - widget._plugin_select.value = 'bioio-imageio' - - with patch( - 'ndevio.bioio_plugins._installer.install_plugin' - ) as mock_install: - mock_install.return_value = 123 - widget._on_install_clicked() - - mock_install.assert_called_once_with('bioio-imageio') - assert 'Installing' in widget._status_label.value - - def test_install_without_selection_shows_error(self): - """Test that clicking install with no selection shows error.""" - from ndevio.widgets import PluginInstallerWidget - - widget = PluginInstallerWidget() - widget._plugin_select.value = None - - widget._on_install_clicked() - - assert 'No plugin selected' in widget._status_label.value - - -class TestOpenPluginInstallerIntegration: - """Integration tests for _open_plugin_installer with napari viewer.""" - - @pytest.fixture - def viewer_with_plugin_installer(self, make_napari_viewer): - """Fixture that creates viewer and opens plugin installer for .czi.""" - import ndevio._napari_reader as reader_module - - viewer = make_napari_viewer() - test_path = Path('path/to/test.czi') - - reader_module._open_plugin_installer(test_path) - - # Find the widget - widget = None - for name, w in viewer.window.dock_widgets.items(): - if 'Install BioIO Plugin' in name: - widget = w - break - - return viewer, widget, test_path - - def test_docks_widget_with_correct_state( - self, viewer_with_plugin_installer - ): - """Test that _open_plugin_installer docks widget with correct state.""" - viewer, widget, test_path = viewer_with_plugin_installer - - # Widget is docked - assert len(viewer.window.dock_widgets) > 0 - assert widget is not None - - # Widget has correct path and suggestions - assert widget.manager.path == test_path - assert test_path.name in widget._title_label.value - assert 'bioio-czi' in widget.manager.suggested_plugins +"""Tests for PluginInstallerWidget. + +This module tests: +- PluginInstallerWidget behavior (unit tests, no viewer needed) +- _open_plugin_installer integration with napari viewer (needs viewer) +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + + +class TestPluginInstallerWidget: + """Tests for PluginInstallerWidget behavior.""" + + def test_standalone_mode(self): + """Test widget in standalone mode - no path, shows generic title.""" + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget + + widget = PluginInstallerWidget() + + # Standalone mode: no path, generic title + assert widget.manager.path is None + assert 'Install BioIO Reader Plugin' in widget._title_label.value + + def test_error_mode_with_path(self): + """Test widget in error mode - has path, preselects suggested plugin.""" + from ndevio.bioio_plugins._manager import ReaderPluginManager + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget + + # Mock installed plugins to NOT include bioio-czi + # This simulates the error case where file can't be read + with patch( + 'ndevio.bioio_plugins._manager.get_installed_plugins', + return_value={'bioio-ome-tiff'}, + ): + manager = ReaderPluginManager('test.czi') + widget = PluginInstallerWidget(plugin_manager=manager) + + # Error mode: has path, shows filename, preselects installable plugin + assert 'test.czi' in widget._title_label.value + assert 'bioio-czi' in manager.suggested_plugins + # bioio-czi should be in installable since it's not installed + assert 'bioio-czi' in manager.installable_plugins + # Value should be set to first installable plugin + assert widget._plugin_select.value is not None + + def test_install_button_behavior(self): + """Test install button: queues installation and updates status.""" + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget + + widget = PluginInstallerWidget() + widget._plugin_select.value = 'bioio-imageio' + + with patch( + 'ndevio.bioio_plugins._installer.install_plugin' + ) as mock_install: + mock_install.return_value = 123 + widget._on_install_clicked() + + mock_install.assert_called_once_with('bioio-imageio') + assert 'Installing' in widget._status_label.value + + def test_install_without_selection_shows_error(self): + """Test that clicking install with no selection shows error.""" + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget + + widget = PluginInstallerWidget() + widget._plugin_select.value = None + + widget._on_install_clicked() + + assert 'No plugin selected' in widget._status_label.value + + +class TestOpenPluginInstallerIntegration: + """Integration tests for _open_plugin_installer with napari viewer.""" + + @pytest.fixture + def viewer_with_plugin_installer(self, make_napari_viewer): + """Fixture that creates viewer and opens plugin installer for .czi.""" + import ndevio._napari_reader as reader_module + + viewer = make_napari_viewer() + test_path = Path('path/to/test.czi') + + reader_module._open_plugin_installer(test_path) + + # Find the widget + widget = None + for name, w in viewer.window.dock_widgets.items(): + if 'Install BioIO Plugin' in name: + widget = w + break + + return viewer, widget, test_path + + def test_docks_widget_with_correct_state( + self, viewer_with_plugin_installer + ): + """Test that _open_plugin_installer docks widget with correct state.""" + viewer, widget, test_path = viewer_with_plugin_installer + + # Widget is docked + assert len(viewer.window.dock_widgets) > 0 + assert widget is not None + + # Widget has correct path and suggestions + assert widget.manager.path == test_path + assert test_path.name in widget._title_label.value + assert 'bioio-czi' in widget.manager.suggested_plugins From f667b9b2d9794a6e0b7722de59aa39d2dfbfa9d2 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Wed, 25 Mar 2026 15:35:26 -0500 Subject: [PATCH 3/3] renormalize line endings and enforce lf --- .gitattributes | 3 + .gitignore | 198 +- src/ndevio/__init__.py | 60 +- src/ndevio/_napari_reader.py | 370 ++-- src/ndevio/napari.yaml | 170 +- src/ndevio/nimage.py | 1242 ++++++------- src/ndevio/sampledata/__init__.py | 2 +- src/ndevio/sampledata/_sample_data.py | 266 +-- src/ndevio/widgets/__init__.py | 2 +- tests/test_nimage.py | 1600 ++++++++--------- tests/test_sampledata/test_sampledata.py | 226 +-- .../test_plugin_installer_widget.py | 224 +-- 12 files changed, 2183 insertions(+), 2180 deletions(-) diff --git a/.gitattributes b/.gitattributes index 887a2c1..b5c1db7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ +# Normalize all text files to LF on commit +* text=auto eol=lf + # SCM syntax highlighting & preventing 3-way merges pixi.lock merge=binary linguist-language=YAML linguist-generated=true diff --git a/.gitignore b/.gitignore index 08c0ec6..1f0dd19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,99 +1,99 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ -.napari_cache - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask instance folder -instance/ - -# Sphinx documentation -docs/_build/ - -# MkDocs documentation -/site/ - -# PyBuilder -target/ - -# Pycharm and VSCode -.idea/ -venv/ -.vscode/ -.venv*/ - -# IPython Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# OS -.DS_Store - -# written by setuptools_scm -**/_version.py -# pixi environments -.pixi/* -!.pixi/config.toml - -# ndevio sampledata that is hosted on Zenodo -src/ndevio/sampledata/data/neocortex-3Ch.tiff -src/ndevio/sampledata/data/scratch-assay-labeled-10T-2Ch.tiff -src/ndevio/sampledata/data/neuron-4Ch_raw.tiff - -# LLMs -AGENTS.md - -# Scripts -scripts/nimage_testing.py +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.napari_cache + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask instance folder +instance/ + +# Sphinx documentation +docs/_build/ + +# MkDocs documentation +/site/ + +# PyBuilder +target/ + +# Pycharm and VSCode +.idea/ +venv/ +.vscode/ +.venv*/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# OS +.DS_Store + +# written by setuptools_scm +**/_version.py +# pixi environments +.pixi/* +!.pixi/config.toml + +# ndevio sampledata that is hosted on Zenodo +src/ndevio/sampledata/data/neocortex-3Ch.tiff +src/ndevio/sampledata/data/scratch-assay-labeled-10T-2Ch.tiff +src/ndevio/sampledata/data/neuron-4Ch_raw.tiff + +# LLMs +AGENTS.md + +# Scripts +scripts/nimage_testing.py diff --git a/src/ndevio/__init__.py b/src/ndevio/__init__.py index 7d19d15..6449e16 100644 --- a/src/ndevio/__init__.py +++ b/src/ndevio/__init__.py @@ -1,30 +1,30 @@ -from typing import TYPE_CHECKING - -try: # noqa: D104 - from ._version import version as __version__ -except ImportError: - __version__ = 'unknown' - -if TYPE_CHECKING: - from .nimage import nImage as nImage - from .utils import helpers as helpers - - -def __getattr__(name: str) -> object: - """Lazily import heavy submodules to speed up package import.""" - if name == 'nImage': - from .nimage import nImage - - return nImage - if name == 'helpers': - from .utils import helpers - - return helpers - raise AttributeError(f'module {__name__!r} has no attribute {name!r}') - - -__all__ = [ - '__version__', - 'helpers', - 'nImage', -] +from typing import TYPE_CHECKING + +try: # noqa: D104 + from ._version import version as __version__ +except ImportError: + __version__ = 'unknown' + +if TYPE_CHECKING: + from .nimage import nImage as nImage + from .utils import helpers as helpers + + +def __getattr__(name: str) -> object: + """Lazily import heavy submodules to speed up package import.""" + if name == 'nImage': + from .nimage import nImage + + return nImage + if name == 'helpers': + from .utils import helpers + + return helpers + raise AttributeError(f'module {__name__!r} has no attribute {name!r}') + + +__all__ = [ + '__version__', + 'helpers', + 'nImage', +] diff --git a/src/ndevio/_napari_reader.py b/src/ndevio/_napari_reader.py index 1755fc1..783cf2f 100644 --- a/src/ndevio/_napari_reader.py +++ b/src/ndevio/_napari_reader.py @@ -1,185 +1,185 @@ -from __future__ import annotations - -import logging -from functools import partial -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from napari.types import LayerDataTuple, PathLike, ReaderFunction - - from .nimage import nImage - -logger = logging.getLogger(__name__) - - -def napari_get_reader( - path: PathLike, - open_first_scene_only: bool | None = None, - open_all_scenes: bool | None = None, -) -> ReaderFunction | None: - """Get the appropriate reader function for a single given path. - - Parameters - ---------- - path : PathLike - Path to the file to be read - open_first_scene_only : bool, optional - Whether to ignore multi-scene files and just open the first scene, - by default None, which uses the setting - open_all_scenes : bool, optional - Whether to open all scenes in a multi-scene file, by default None - which uses the setting - Ignored if open_first_scene_only is True - - - Returns - ------- - ReaderFunction - The reader function for the given path - """ - - from ndev_settings import get_settings - - settings = get_settings() - - open_first_scene_only = ( - open_first_scene_only - if open_first_scene_only is not None - else settings.ndevio_reader.scene_handling == 'View First Scene Only' # type: ignore - ) or False - - open_all_scenes = ( - open_all_scenes - if open_all_scenes is not None - else settings.ndevio_reader.scene_handling == 'View All Scenes' # type: ignore - ) or False - - # Return reader function; actual format validation happens in - # napari_reader_function via nImage initialization. - return partial( - napari_reader_function, - open_first_scene_only=open_first_scene_only, - open_all_scenes=open_all_scenes, - ) - - -def napari_reader_function( - path: PathLike, - open_first_scene_only: bool = False, - open_all_scenes: bool = False, -) -> list[LayerDataTuple] | None: - """ - Read a file using bioio. - - nImage handles reader selection: if a preferred_reader is set in settings, - it's tried first with automatic fallback to bioio's default plugin ordering. - - Parameters - ---------- - path : PathLike - Path to the file to be read - open_first_scene_only : bool, optional - Whether to ignore multi-scene files and just open the first scene, - by default False. - open_all_scenes : bool, optional - Whether to open all scenes in a multi-scene file, by default False. - Ignored if open_first_scene_only is True. - - Returns - ------- - list - List containing image data, metadata, and layer type - - """ - from bioio_base.exceptions import UnsupportedFileFormatError - - from .nimage import nImage - - try: - img = nImage(path) # nImage handles preferred reader and fallback - except UnsupportedFileFormatError: - # Try to open plugin installer widget - # If no viewer available, this will re-raise - _open_plugin_installer(path) - return None - - logger.info('Bioio: Reading file with %d scenes', len(img.scenes)) - - # open first scene only - if len(img.scenes) == 1 or open_first_scene_only: - return img.get_layer_data_tuples() - - # open all scenes as layers - if open_all_scenes: - layer_list = [] - for scene in img.scenes: - img.set_scene(scene) - layer_list.extend(img.get_layer_data_tuples()) - return layer_list - - # else: open scene widget - _open_scene_container(path=path, img=img) - return [(None,)] # type: ignore[return-value] - - -def _open_scene_container(path: PathLike, img: nImage) -> None: - from pathlib import Path - - import napari - - from .widgets._scene_widget import DELIMITER, nImageSceneWidget - - viewer = napari.current_viewer() - viewer.window.add_dock_widget( - nImageSceneWidget(viewer, path, img), - area='right', - name=f'{Path(path).stem}{DELIMITER}Scenes', - ) - - -def _open_plugin_installer(path: PathLike) -> None: - """Open the plugin installer widget for an unsupported file. - - If no napari viewer is available, re-raises the UnsupportedFileFormatError - with installation suggestions so programmatic users get a helpful message. - - Parameters - ---------- - path : PathLike - Path to the file that couldn't be read - - Raises - ------ - UnsupportedFileFormatError - If no napari viewer is available (programmatic usage) - """ - import napari - from bioio_base.exceptions import UnsupportedFileFormatError - - from .bioio_plugins._manager import ReaderPluginManager - from .widgets._plugin_install_widget import PluginInstallerWidget - - # Get viewer, handle case where no viewer available - viewer = napari.current_viewer() - - # If no viewer, re-raise with helpful message for programmatic users - if viewer is None: - logger.debug( - 'No napari viewer available, raising exception with suggestions' - ) - manager = ReaderPluginManager(path) - raise UnsupportedFileFormatError( - reader_name='ndevio', - path=str(path), - msg_extra=manager.get_installation_message(), - ) - - # Create plugin manager for this file - manager = ReaderPluginManager(path) - - widget = PluginInstallerWidget(plugin_manager=manager) - viewer.window.add_dock_widget( - widget, - area='right', - name='Install BioIO Plugin', - ) +from __future__ import annotations + +import logging +from functools import partial +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from napari.types import LayerDataTuple, PathLike, ReaderFunction + + from .nimage import nImage + +logger = logging.getLogger(__name__) + + +def napari_get_reader( + path: PathLike, + open_first_scene_only: bool | None = None, + open_all_scenes: bool | None = None, +) -> ReaderFunction | None: + """Get the appropriate reader function for a single given path. + + Parameters + ---------- + path : PathLike + Path to the file to be read + open_first_scene_only : bool, optional + Whether to ignore multi-scene files and just open the first scene, + by default None, which uses the setting + open_all_scenes : bool, optional + Whether to open all scenes in a multi-scene file, by default None + which uses the setting + Ignored if open_first_scene_only is True + + + Returns + ------- + ReaderFunction + The reader function for the given path + """ + + from ndev_settings import get_settings + + settings = get_settings() + + open_first_scene_only = ( + open_first_scene_only + if open_first_scene_only is not None + else settings.ndevio_reader.scene_handling == 'View First Scene Only' # type: ignore + ) or False + + open_all_scenes = ( + open_all_scenes + if open_all_scenes is not None + else settings.ndevio_reader.scene_handling == 'View All Scenes' # type: ignore + ) or False + + # Return reader function; actual format validation happens in + # napari_reader_function via nImage initialization. + return partial( + napari_reader_function, + open_first_scene_only=open_first_scene_only, + open_all_scenes=open_all_scenes, + ) + + +def napari_reader_function( + path: PathLike, + open_first_scene_only: bool = False, + open_all_scenes: bool = False, +) -> list[LayerDataTuple] | None: + """ + Read a file using bioio. + + nImage handles reader selection: if a preferred_reader is set in settings, + it's tried first with automatic fallback to bioio's default plugin ordering. + + Parameters + ---------- + path : PathLike + Path to the file to be read + open_first_scene_only : bool, optional + Whether to ignore multi-scene files and just open the first scene, + by default False. + open_all_scenes : bool, optional + Whether to open all scenes in a multi-scene file, by default False. + Ignored if open_first_scene_only is True. + + Returns + ------- + list + List containing image data, metadata, and layer type + + """ + from bioio_base.exceptions import UnsupportedFileFormatError + + from .nimage import nImage + + try: + img = nImage(path) # nImage handles preferred reader and fallback + except UnsupportedFileFormatError: + # Try to open plugin installer widget + # If no viewer available, this will re-raise + _open_plugin_installer(path) + return None + + logger.info('Bioio: Reading file with %d scenes', len(img.scenes)) + + # open first scene only + if len(img.scenes) == 1 or open_first_scene_only: + return img.get_layer_data_tuples() + + # open all scenes as layers + if open_all_scenes: + layer_list = [] + for scene in img.scenes: + img.set_scene(scene) + layer_list.extend(img.get_layer_data_tuples()) + return layer_list + + # else: open scene widget + _open_scene_container(path=path, img=img) + return [(None,)] # type: ignore[return-value] + + +def _open_scene_container(path: PathLike, img: nImage) -> None: + from pathlib import Path + + import napari + + from .widgets._scene_widget import DELIMITER, nImageSceneWidget + + viewer = napari.current_viewer() + viewer.window.add_dock_widget( + nImageSceneWidget(viewer, path, img), + area='right', + name=f'{Path(path).stem}{DELIMITER}Scenes', + ) + + +def _open_plugin_installer(path: PathLike) -> None: + """Open the plugin installer widget for an unsupported file. + + If no napari viewer is available, re-raises the UnsupportedFileFormatError + with installation suggestions so programmatic users get a helpful message. + + Parameters + ---------- + path : PathLike + Path to the file that couldn't be read + + Raises + ------ + UnsupportedFileFormatError + If no napari viewer is available (programmatic usage) + """ + import napari + from bioio_base.exceptions import UnsupportedFileFormatError + + from .bioio_plugins._manager import ReaderPluginManager + from .widgets._plugin_install_widget import PluginInstallerWidget + + # Get viewer, handle case where no viewer available + viewer = napari.current_viewer() + + # If no viewer, re-raise with helpful message for programmatic users + if viewer is None: + logger.debug( + 'No napari viewer available, raising exception with suggestions' + ) + manager = ReaderPluginManager(path) + raise UnsupportedFileFormatError( + reader_name='ndevio', + path=str(path), + msg_extra=manager.get_installation_message(), + ) + + # Create plugin manager for this file + manager = ReaderPluginManager(path) + + widget = PluginInstallerWidget(plugin_manager=manager) + viewer.window.add_dock_widget( + widget, + area='right', + name='Install BioIO Plugin', + ) diff --git a/src/ndevio/napari.yaml b/src/ndevio/napari.yaml index 1b2f549..caad56c 100644 --- a/src/ndevio/napari.yaml +++ b/src/ndevio/napari.yaml @@ -1,85 +1,85 @@ -name: ndevio -display_name: ndevio -# use 'hidden' to remove plugin from napari hub search results -visibility: public -# see https://napari.org/stable/plugins/technical_references/manifest.html#fields for valid categories -categories: ["IO", "Dataset", "Utilities"] -contributions: - commands: - - id: ndevio.get_reader - python_name: ndevio._napari_reader:napari_get_reader - title: Open file with ndevio - - id: ndevio.make_plugin_installer_widget - python_name: ndevio.widgets._plugin_install_widget:PluginInstallerWidget - title: Install BioIO Reader Plugins - - id: ndevio.make_utilities_widget - python_name: ndevio.widgets._utilities_container:UtilitiesContainer - title: I/O Utilities - - id: ndevio.make_ndev_logo - python_name: ndevio.sampledata._sample_data:ndev_logo - title: Load ndev logo - - id: ndevio.make_scratch_assay - python_name: ndevio.sampledata._sample_data:scratch_assay - title: Load scratch assay data - - id: ndevio.make_neocortex - python_name: ndevio.sampledata._sample_data:neocortex - title: Load neocortex data - - id: ndevio.make_neuron_raw - python_name: ndevio.sampledata._sample_data:neuron_raw - title: Load raw neuron data - - id: ndevio.make_neuron_labels - python_name: ndevio.sampledata._sample_data:neuron_labels - title: Load neuron labels data - - id: ndevio.make_neuron_labels_processed - python_name: ndevio.sampledata._sample_data:neuron_labels_processed - title: Load processed neuron labels data - readers: - - command: ndevio.get_reader - accepts_directories: true - filename_patterns: [ - '*.1sc', '*.264', '*.265', '*.2fl', '*.3fr', '*.3g2', '*.a64', '*.acff', '*.adp', '*.afi', - '*.afm', '*.aim', '*.al3d', '*.am', '*.amiramesh', '*.amr', '*.amv', '*.apl', '*.apng', '*.arf', - '*.arw', '*.asf', '*.avc', '*.avi', '*.avs', '*.avs2', '*.bay', '*.bif', '*.bin', '*.bip', - '*.bmp', '*.btf', '*.c01', '*.cdg', '*.cfg', '*.cgi', '*.ch5', '*.cif', '*.cr2', '*.crw', - '*.csv', '*.ct', '*.cxd', '*.czi', '*.dat', '*.dcm', '*.dcr', '*.dib', '*.dip', '*.dir', - '*.dm2', '*.dm3', '*.dm4', '*.dng', '*.dnxhd', '*.dti', '*.dv', '*.dvd', '*.eps', '*.erf', - '*.exp', '*.exr', '*.fdf', '*.fff', '*.ffr', '*.fits', '*.flex', '*.fli', '*.frm', '*.gel', - '*.gif', '*.grey', '*.hdf', '*.hdr', '*.hed', '*.his', '*.htd', '*.html', '*.hx', '*.i2i', - '*.icb', '*.ics', '*.ids', '*.if', '*.iiq', '*.im3', '*.img', '*.ims', '*.imt', '*.inr', - '*.ipl', '*.ipm', '*.ipw', '*.ism', '*.jfif', '*.jif', '*.jng', '*.jp2', '*.jpg', '*.jpk', - '*.jpx', '*.l2d', '*.labels', '*.lei', '*.lif', '*.liff', '*.lim', '*.lms', '*.lsm', '*.mcidas', - '*.mdb', '*.mnc', '*.mng', '*.mod', '*.mov', '*.mp4', '*.mpo', '*.mrc', '*.mrw', '*.msp', - '*.msr', '*.mtb', '*.mvd2', '*.naf', '*.nd', '*.nd2', '*.ndpi', '*.ndpis', '*.nef', '*.nhdr', - '*.nii', '*.nii.gz', '*.nrrd', '*.obf', '*.obsep', '*.oib', '*.oif', '*.oir', '*.ome', '*.ome.btf', - '*.ome.tf2', '*.ome.tf8', '*.ome.tif', '*.ome.tiff', '*.ome.xml', '*.par', '*.pbm', '*.pcoraw', '*.pcx', '*.pdf', - '*.pds', '*.pgm', '*.pic', '*.pict', '*.png', '*.pnl', '*.ppm', '*.pr3', '*.ps', '*.psd', - '*.qptiff', '*.r3d', '*.raw', '*.rcpnl', '*.rec', '*.scn', '*.sdt', '*.seq', '*.sif', '*.sld', - '*.sldy', '*.sm2', '*.sm3', '*.spc', '*.spe', '*.spi', '*.spider', '*.stk', '*.stp', '*.svs', - '*.sxm', '*.tf2', '*.tf8', '*.tfr', '*.tga', '*.tif', '*.tiff', '*.tiles.ome.tif', '*.tnb', '*.top', - '*.txt', '*.v', '*.vms', '*.vsi', '*.vws', '*.wat', '*.wlz', '*.xdce', '*.xml', '*.xqd', - '*.xqf', '*.xv', '*.xvthumb', '*.xys', '*.zarr', '*.zarr*', '*.zfp', '*.zfr', '*.zif', '*.zvi', - ] - widgets: - - command: ndevio.make_plugin_installer_widget - display_name: Install BioIO Reader Plugins - - command: ndevio.make_utilities_widget - display_name: I/O Utilities - sample_data: - - command: ndevio.make_ndev_logo - display_name: ndev logo - key: ndevio.ndev_logo - - command: ndevio.make_scratch_assay - display_name: Scratch Assay Labeled (10T+2Ch) (4MB) - key: ndevio.scratch_assay - - command: ndevio.make_neocortex - display_name: Neocortex (3Ch) (2MB) - key: ndevio.neocortex - - command: ndevio.make_neuron_raw - display_name: Neuron Raw (2D+4Ch) (32MB) - key: ndevio.neuron_raw - - command: ndevio.make_neuron_labels - display_name: Neuron Labels (2D+4Ch) - key: ndevio.neuron_labels - - command: ndevio.make_neuron_labels_processed - display_name: Neuron Labels Processed (2D+4Ch) - key: ndevio.neuron_labels_processed +name: ndevio +display_name: ndevio +# use 'hidden' to remove plugin from napari hub search results +visibility: public +# see https://napari.org/stable/plugins/technical_references/manifest.html#fields for valid categories +categories: ["IO", "Dataset", "Utilities"] +contributions: + commands: + - id: ndevio.get_reader + python_name: ndevio._napari_reader:napari_get_reader + title: Open file with ndevio + - id: ndevio.make_plugin_installer_widget + python_name: ndevio.widgets._plugin_install_widget:PluginInstallerWidget + title: Install BioIO Reader Plugins + - id: ndevio.make_utilities_widget + python_name: ndevio.widgets._utilities_container:UtilitiesContainer + title: I/O Utilities + - id: ndevio.make_ndev_logo + python_name: ndevio.sampledata._sample_data:ndev_logo + title: Load ndev logo + - id: ndevio.make_scratch_assay + python_name: ndevio.sampledata._sample_data:scratch_assay + title: Load scratch assay data + - id: ndevio.make_neocortex + python_name: ndevio.sampledata._sample_data:neocortex + title: Load neocortex data + - id: ndevio.make_neuron_raw + python_name: ndevio.sampledata._sample_data:neuron_raw + title: Load raw neuron data + - id: ndevio.make_neuron_labels + python_name: ndevio.sampledata._sample_data:neuron_labels + title: Load neuron labels data + - id: ndevio.make_neuron_labels_processed + python_name: ndevio.sampledata._sample_data:neuron_labels_processed + title: Load processed neuron labels data + readers: + - command: ndevio.get_reader + accepts_directories: true + filename_patterns: [ + '*.1sc', '*.264', '*.265', '*.2fl', '*.3fr', '*.3g2', '*.a64', '*.acff', '*.adp', '*.afi', + '*.afm', '*.aim', '*.al3d', '*.am', '*.amiramesh', '*.amr', '*.amv', '*.apl', '*.apng', '*.arf', + '*.arw', '*.asf', '*.avc', '*.avi', '*.avs', '*.avs2', '*.bay', '*.bif', '*.bin', '*.bip', + '*.bmp', '*.btf', '*.c01', '*.cdg', '*.cfg', '*.cgi', '*.ch5', '*.cif', '*.cr2', '*.crw', + '*.csv', '*.ct', '*.cxd', '*.czi', '*.dat', '*.dcm', '*.dcr', '*.dib', '*.dip', '*.dir', + '*.dm2', '*.dm3', '*.dm4', '*.dng', '*.dnxhd', '*.dti', '*.dv', '*.dvd', '*.eps', '*.erf', + '*.exp', '*.exr', '*.fdf', '*.fff', '*.ffr', '*.fits', '*.flex', '*.fli', '*.frm', '*.gel', + '*.gif', '*.grey', '*.hdf', '*.hdr', '*.hed', '*.his', '*.htd', '*.html', '*.hx', '*.i2i', + '*.icb', '*.ics', '*.ids', '*.if', '*.iiq', '*.im3', '*.img', '*.ims', '*.imt', '*.inr', + '*.ipl', '*.ipm', '*.ipw', '*.ism', '*.jfif', '*.jif', '*.jng', '*.jp2', '*.jpg', '*.jpk', + '*.jpx', '*.l2d', '*.labels', '*.lei', '*.lif', '*.liff', '*.lim', '*.lms', '*.lsm', '*.mcidas', + '*.mdb', '*.mnc', '*.mng', '*.mod', '*.mov', '*.mp4', '*.mpo', '*.mrc', '*.mrw', '*.msp', + '*.msr', '*.mtb', '*.mvd2', '*.naf', '*.nd', '*.nd2', '*.ndpi', '*.ndpis', '*.nef', '*.nhdr', + '*.nii', '*.nii.gz', '*.nrrd', '*.obf', '*.obsep', '*.oib', '*.oif', '*.oir', '*.ome', '*.ome.btf', + '*.ome.tf2', '*.ome.tf8', '*.ome.tif', '*.ome.tiff', '*.ome.xml', '*.par', '*.pbm', '*.pcoraw', '*.pcx', '*.pdf', + '*.pds', '*.pgm', '*.pic', '*.pict', '*.png', '*.pnl', '*.ppm', '*.pr3', '*.ps', '*.psd', + '*.qptiff', '*.r3d', '*.raw', '*.rcpnl', '*.rec', '*.scn', '*.sdt', '*.seq', '*.sif', '*.sld', + '*.sldy', '*.sm2', '*.sm3', '*.spc', '*.spe', '*.spi', '*.spider', '*.stk', '*.stp', '*.svs', + '*.sxm', '*.tf2', '*.tf8', '*.tfr', '*.tga', '*.tif', '*.tiff', '*.tiles.ome.tif', '*.tnb', '*.top', + '*.txt', '*.v', '*.vms', '*.vsi', '*.vws', '*.wat', '*.wlz', '*.xdce', '*.xml', '*.xqd', + '*.xqf', '*.xv', '*.xvthumb', '*.xys', '*.zarr', '*.zarr*', '*.zfp', '*.zfr', '*.zif', '*.zvi', + ] + widgets: + - command: ndevio.make_plugin_installer_widget + display_name: Install BioIO Reader Plugins + - command: ndevio.make_utilities_widget + display_name: I/O Utilities + sample_data: + - command: ndevio.make_ndev_logo + display_name: ndev logo + key: ndevio.ndev_logo + - command: ndevio.make_scratch_assay + display_name: Scratch Assay Labeled (10T+2Ch) (4MB) + key: ndevio.scratch_assay + - command: ndevio.make_neocortex + display_name: Neocortex (3Ch) (2MB) + key: ndevio.neocortex + - command: ndevio.make_neuron_raw + display_name: Neuron Raw (2D+4Ch) (32MB) + key: ndevio.neuron_raw + - command: ndevio.make_neuron_labels + display_name: Neuron Labels (2D+4Ch) + key: ndevio.neuron_labels + - command: ndevio.make_neuron_labels_processed + display_name: Neuron Labels Processed (2D+4Ch) + key: ndevio.neuron_labels_processed diff --git a/src/ndevio/nimage.py b/src/ndevio/nimage.py index a2e0ad0..38616d3 100644 --- a/src/ndevio/nimage.py +++ b/src/ndevio/nimage.py @@ -1,621 +1,621 @@ -"""Additional functionality for BioImage objects to be used in napari-ndev.""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import TYPE_CHECKING - -from bioio import BioImage - -from .bioio_plugins._manager import raise_unsupported_with_suggestions -from .utils._layer_utils import ( - build_layer_tuple, - determine_in_memory, - resolve_layer_type, -) - -if TYPE_CHECKING: - from collections.abc import Sequence - - import xarray as xr - from bioio_base.reader import Reader - from bioio_base.types import ImageLike - from napari.types import LayerDataTuple - -logger = logging.getLogger(__name__) - - -def _resolve_reader( - image: ImageLike, - explicit_reader: type[Reader] | Sequence[type[Reader]] | None, -) -> type[Reader] | Sequence[type[Reader]] | None: - """Resolve the reader to use for an image. - - Priority: - 1. Explicit reader (passed to __init__) - 2. Preferred reader from settings (if file path and installed) - 3. None (let bioio determine) - - Parameters - ---------- - image : ImageLike - The image to resolve a reader for. - explicit_reader : type[Reader] | Sequence[type[Reader]] | None - Explicit reader class(es) passed by user. - - Returns - ------- - type[Reader] | Sequence[type[Reader]] | None - The reader to use, or None to let bioio choose. - - """ - if explicit_reader is not None: - return explicit_reader - - # Only check preferred reader for file paths - if not isinstance(image, str | Path): - return None - - # Get preferred reader from settings - from ndev_settings import get_settings - - from .bioio_plugins._utils import get_installed_plugins, get_reader_by_name - - settings = get_settings() - preferred = settings.ndevio_reader.preferred_reader # type: ignore - - if not preferred: - return None - - if preferred not in get_installed_plugins(): - logger.debug('Preferred reader %s not installed', preferred) - return None - - return get_reader_by_name(preferred) - - -class nImage(BioImage): - """ - An nImage is a BioImage with additional functionality for napari. - - Extends BioImage to provide napari-ready layer data with proper scale, - axis labels, and units derived from bioimaging metadata. - - Parameters - ---------- - image : ImageLike - Image to be loaded. Can be a path to an image file, a numpy array, - or an xarray DataArray. - reader : type[Reader] | Sequence[type[Reader]], optional - Reader class or priority list of readers. If not provided, checks - settings for preferred_reader and tries that first, then falls back - to bioio's default deterministic priority. - **kwargs - Additional arguments passed to BioImage. - - Attributes - ---------- - path : str | None - Path or URI to the source file, or None if created from array data. - Always a plain string — local paths are stored as-is, ``file://`` URIs - are normalised to their path component, and remote URIs (``s3://``, - ``https://``, …) are kept verbatim. Use ``_is_remote`` to distinguish - local from remote. - - Examples - -------- - Basic usage with file path: - - >>> img = nImage("path/to/image.tiff") - >>> for layer_tuple in img.get_layer_data_tuples(): - ... layer = Layer.create(*layer_tuple) - ... viewer.add_layer(layer) - - Access layer properties: - - >>> img.layer_scale # (1.0, 0.2, 0.2) - physical scale per dim - >>> img.layer_axis_labels # ('Z', 'Y', 'X') - >>> img.layer_units # ('µm', 'µm', 'µm') - - """ - - # Class-level type hints for instance attributes - path: str | None - _is_remote: bool - _reference_xarray: xr.DataArray | None - _layer_data: list | None - - def __init__( - self, - image: ImageLike, - reader: type[Reader] | Sequence[type[Reader]] | None = None, - **kwargs, - ) -> None: - """Initialize an nImage with an image, and optionally a reader.""" - from bioio_base.exceptions import UnsupportedFileFormatError - - # Strip trailing slashes from string paths/URLs (e.g. `store.zarr/`) - # so that bioio's extension-based reader detection works correctly. - if isinstance(image, str): - image = image.rstrip('/') - - resolved_reader = _resolve_reader(image, reader) - - # Try preferred/explicit reader first, fall back to bioio default - if resolved_reader is not None: - try: - super().__init__(image=image, reader=resolved_reader, **kwargs) - except UnsupportedFileFormatError: - # Preferred reader failed, fall back to bioio's default - try: - super().__init__(image=image, reader=None, **kwargs) - except UnsupportedFileFormatError: - if isinstance(image, str | Path): - raise_unsupported_with_suggestions(image) - raise - else: - try: - super().__init__(image=image, reader=None, **kwargs) - except UnsupportedFileFormatError: - if isinstance(image, str | Path): - raise_unsupported_with_suggestions(image) - raise - - # Instance state - self._reference_xarray = None - self._layer_data = None - if isinstance(image, str | Path): - import fsspec - from fsspec.implementations.local import LocalFileSystem - - s = str(image) - fs, resolved = fsspec.url_to_fs(s) - if isinstance(fs, LocalFileSystem): - # Normalise file:// URIs and any platform variations to an - # OS-native path string so Path(self.path) always round-trips. - self.path = str(Path(resolved)) - self._is_remote = False - else: - # Remote URI (s3://, https://, gc://, …) — keep verbatim. - self.path = s - self._is_remote = True - else: - self.path = None - self._is_remote = False - - # Any compatibility warnings for old formats should be emitted at this point - # Cheaply check without imports by looking at the reader's module name - if self.reader.__module__.startswith('bioio_ome_zarr'): - from .bioio_plugins._compatibility import ( - apply_ome_zarr_compat_patches, - ) - - apply_ome_zarr_compat_patches(self.reader) - - @property - def reference_xarray(self) -> xr.DataArray: - """Image data as xarray DataArray for metadata determination. - - Lazily loads xarray on first access. Uses in-memory or dask array - based on file size (determined automatically). - - Returns - ------- - xr.DataArray - Squeezed xarray for napari dimensions. - - Notes - ----- - BioImage.xarray_data and BioImage.xarray_dask_data automatically - handle mosaic tile reconstruction when reconstruct_mosaic=True - (the default). No special mosaic handling needed here. - - """ - if self._reference_xarray is None: - # Ensure we're at the highest-res level for metadata consistency - current_res = self.current_resolution_level - self.set_resolution_level(0) - if self._is_remote or not determine_in_memory(self.path): - self._reference_xarray = self.xarray_dask_data.squeeze() - else: - self._reference_xarray = self.xarray_data.squeeze() - self.set_resolution_level(current_res) - return self._reference_xarray - - @property - def layer_data(self) -> list: - """Image data arrays shaped for napari, one per resolution level. - - Returns a list of arrays ordered from highest to lowest resolution. - For single-resolution images the list has one element. - napari automatically treats multi-element lists as multiscale data. - - Multiscale images are always dask-backed for memory efficiency. - Single-resolution images use numpy or dask based on file size. - - Returns - ------- - list[ArrayLike] - Squeezed image arrays (C dim retained for multichannel split - in :meth:`get_layer_data_tuples`). - - """ - if self._layer_data is None: - self._layer_data = self._build_layer_data() - return self._layer_data - - def _build_layer_data(self) -> list: - """Build the list of arrays for all resolution levels.""" - current_res = self.current_resolution_level - levels = self.resolution_levels - multiscale = len(levels) > 1 - - # Determine which dims to keep from level 0's squeezed metadata. - # Using isel instead of squeeze ensures all levels have - # consistent ndim (lower levels may have extra singleton spatial dims - # that squeeze would incorrectly remove). - ref = self.reference_xarray - keep_dims = set(ref.dims) - - arrays: list = [] - for level in levels: - self.set_resolution_level(level) - if ( - multiscale - or self._is_remote - or not determine_in_memory(self.path) - ): - xr_data = self.xarray_dask_data - else: - xr_data = self.xarray_data - - indexer = {d: 0 for d in xr_data.dims if d not in keep_dims} - arrays.append(xr_data.isel(indexer).data) - - self.set_resolution_level(current_res) - return arrays - - @property - def path_stem(self) -> str: - """Filename stem derived from path or URI, used in layer names. - - Returns - ------- - str - The stem of the filename (no extension, no parent path), or - ``'unknown'`` when the image was created from array data. - - Examples - -------- - >>> nImage("/data/cells.ome.tiff").path_stem - 'cells.ome' - >>> nImage("s3://bucket/experiment/image.zarr").path_stem - 'image' - - """ - if self.path is None: - return 'unknown' - if self._is_remote: - from pathlib import PurePosixPath - from urllib.parse import urlparse - - return PurePosixPath(urlparse(self.path).path).stem - return Path(self.path).stem - - @property - def layer_names(self) -> list[str]: - """Per-channel layer names for napari. - - Returns one name per output layer — the same count as - :meth:`get_layer_data_tuples` returns tuples. The base name is the - scene-qualified :attr:`path_stem`; channel names are prepended using - ``' :: '`` as a delimiter when present. - - Returns - ------- - list[str] - e.g. ``['membrane :: cells.ome', 'nuclei :: cells.ome']`` - for a 2-channel file, or ``['0 :: cells.ome']`` for a - single-channel OME image with a default ``C`` coordinate. - Only when no ``C`` dimension is present at all will the name - be just ``['cells.ome']``. - - Examples - -------- - >>> nImage("cells.ome.tiff").layer_names - ['0 :: cells.ome'] - - """ - # Build scene-qualified base name - delim = ' :: ' - parts: list[str] = [] - if len(self.scenes) > 1 or self.current_scene != 'Image:0': - parts.extend([str(self.current_scene_index), self.current_scene]) - parts.append(self.path_stem) - base_name = delim.join(parts) - - # RGB (Samples dim): single layer, no channel prefix - if 'S' in self.dims.order: - return [base_name] - - # Use BioImage channel_names — metadata only, no data load - channel_names = self.channel_names - - # Single channel (C=1 is squeezed out of layer_data) - if self.dims.C == 1: - ch_name = channel_names[0] - return [f'{ch_name} :: {base_name}' if ch_name else base_name] - - # Multichannel - return [f'{ch} :: {base_name}' for ch in channel_names] - - @property - def layer_scale(self) -> tuple[float, ...]: - """Physical scale for dimensions in layer data. - - Uses layer_axis_labels to determine which dimensions are present, - then extracts scale values from BioImage.scale. - Defaults to 1.0 for dimensions without scale metadata. - - Returns - ------- - tuple[float, ...] - Scale tuple matching layer_axis_labels. - - Examples - -------- - >>> img = nImage("timelapse.tiff") # T=3, Z=1, Y=10, X=10 - >>> img.layer_axis_labels - ('T', 'Y', 'X') - >>> img.layer_scale - (2.0, 0.2, 0.2) - - """ - axis_labels = self.layer_axis_labels - - # Try to get scale from BioImage - may fail for array-like inputs - # where physical_pixel_sizes is None (AttributeError), old OME-Zarr - # v0.1/v0.2 missing 'coordinateTransformations' (KeyError), or - # v0.3 string-axes that weren't normalised (TypeError). - try: - bio_scale = self.scale - except (AttributeError, KeyError, TypeError): - return tuple(1.0 for _ in axis_labels) - return tuple( - getattr(bio_scale, dim, None) or 1.0 for dim in axis_labels - ) - - @property - def layer_axis_labels(self) -> tuple[str, ...]: - """Dimension names for napari layers (excludes Channel and Samples). - - Returns - ------- - tuple[str, ...] - Dimension names (e.g., ('Z', 'Y', 'X')). - - Examples - -------- - >>> img = nImage("multichannel.tiff") # Shape (C=2, Z=10, Y=100, X=100) - >>> img.layer_axis_labels - ('Z', 'Y', 'X') - - """ - layer_data = self.reference_xarray - - # Exclude Channel and Samples dimensions (RGB/multichannel handled separately) - return tuple( - str(dim) for dim in layer_data.dims if dim not in ('C', 'S') - ) - - @property - def layer_units(self) -> tuple[str | None, ...]: - """Physical units for dimensions in layer data. - - Returns - ------- - tuple[str | None, ...] - Unit strings matching layer_axis_labels. None for dims without units. - - Examples - -------- - >>> img = nImage("timelapse.tiff") # T=3, Z=1, Y=10, X=10 - >>> # After squeezing, Z is removed - >>> img.layer_axis_labels - ('T', 'Y', 'X') - >>> img.layer_units - ('s', 'µm', 'µm') - - """ - axis_labels = self.layer_axis_labels - - try: - dim_props = self.dimension_properties - # Old OME-Zarr v0.1/v0.2 (KeyError), v0.3 string-axes (TypeError), - # or array-like inputs without dimension metadata (AttributeError). - except (AttributeError, KeyError, TypeError): - return tuple(None for _ in axis_labels) - - def _get_unit(dim: str) -> str | None: - prop = getattr(dim_props, dim, None) - return prop.unit if prop else None - - return tuple(_get_unit(dim) for dim in axis_labels) - - @property - def layer_metadata(self) -> dict: - """Base metadata dict for napari layers. - - Contains bioimage reference, raw metadata, and OME metadata if available. - - Returns - ------- - dict - Keys: 'bioimage', 'raw_image_metadata', and optionally 'ome_metadata'. - - """ - meta: dict = { - 'bioimage': self, - 'raw_image_metadata': self.metadata, - } - - try: - meta['ome_metadata'] = self.ome_metadata - except NotImplementedError: - pass # Reader doesn't support OME metadata - except (ValueError, TypeError, KeyError) as e: - # Some files have metadata that doesn't conform to OME schema, despite bioio attempting to parse it - # (e.g., CZI files with LatticeLightsheet acquisition mode) - # As such, when accessing ome_metadata, we may get various exceptions - # Log warning but continue - raw metadata is still available - logger.warning( - 'Could not parse OME metadata: %s. ' - "Raw metadata is still available in 'raw_image_metadata'.", - e, - ) - - return meta - - def get_layer_data_tuples( - self, - layer_type: str | None = None, - channel_types: dict[str, str] | None = None, - channel_kwargs: dict[str, dict] | None = None, - ) -> list[LayerDataTuple]: - """Build layer data tuples for napari. - - Splits multichannel data into separate layers, each with appropriate - metadata. Automatically detects label layers from channel names - containing keywords like 'label', 'mask', 'segmentation'. - - Parameters - ---------- - layer_type : str, optional - Override layer type for ALL channels. Valid values: 'image', - 'labels', 'shapes', 'points', 'surface', 'tracks', 'vectors'. - If None, auto-detection is used (based on channel names). - Takes precedence over channel_types. - channel_types : dict[str, str], optional - Per-channel layer type overrides. - e.g., {"DAPI": "image", "nuclei_mask": "labels"} - Ignored if layer_type is set. - channel_kwargs : dict[str, dict], optional - Per-channel napari kwargs overrides. - e.g., {"DAPI": {"colormap": "blue", "contrast_limits": (0, 1000)}} - These override the automatically generated metadata. - - Returns - ------- - list[LayerDataTuple] - List of (data, metadata, layer_type) tuples. - - Examples - -------- - Add layers to napari: - - >>> from napari.layers import Layer - >>> img = nImage("path/to/image.tiff") - >>> for ldt in img.get_layer_data_tuples(): - ... viewer.add_layer(Layer.create(*ldt)) - - Mixed image/labels: - - >>> img.get_layer_data_tuples( - ... channel_types={"DAPI": "image", "nuclei_mask": "labels"} - ... ) - - See Also - -------- - napari.layers.Layer.create : Creates a layer from a LayerDataTuple. - https://napari.org/dev/plugins/building_a_plugin/guides.html - - """ - ref = self.reference_xarray - data = self.layer_data - if layer_type is not None: - channel_types = None # Global override ignores per-channel - names = self.layer_names - base_metadata = self.layer_metadata - scale = self.layer_scale - axis_labels = self.layer_axis_labels - units = self.layer_units - - # Handle RGB images (Samples dimension 'S') - if 'S' in self.dims.order: - return [ - build_layer_tuple( - data, - layer_type='image', - name=names[0], - metadata=base_metadata, - scale=scale, - axis_labels=axis_labels, - units=units, - rgb=True, - ) - ] - - channel_dim = 'C' - - # Single channel (no C dimension to split) - if channel_dim not in ref.dims: - channel_name = self.channel_names[0] - effective_type = resolve_layer_type( - channel_name or '', layer_type, channel_types - ) - extra_kwargs = ( - channel_kwargs.get(channel_name) - if channel_kwargs and channel_name - else None - ) - return [ - build_layer_tuple( - data, - layer_type=effective_type, - name=names[0], - metadata=base_metadata, - scale=scale, - axis_labels=axis_labels, - units=units, - extra_kwargs=extra_kwargs, - ) - ] - - # Multichannel - split into separate layers - channel_names = self.channel_names - channel_axis = ref.dims.index(channel_dim) - total_channels = ref.shape[channel_axis] - - tuples: list[LayerDataTuple] = [] - for i in range(total_channels): - channel_name = channel_names[i] - effective_type = resolve_layer_type( - channel_name, layer_type, channel_types - ) - - # Slice along channel axis for each resolution level - slices: list[slice | int] = [slice(None)] * ref.ndim - slices[channel_axis] = i - channel_data = [arr[tuple(slices)] for arr in data] - - extra_kwargs = ( - channel_kwargs.get(channel_name) if channel_kwargs else None - ) - - tuples.append( - build_layer_tuple( - channel_data, - layer_type=effective_type, - name=names[i], - metadata=base_metadata, - scale=scale, - axis_labels=axis_labels, - units=units, - channel_idx=i, - total_channels=total_channels, - extra_kwargs=extra_kwargs, - ) - ) - - return tuples +"""Additional functionality for BioImage objects to be used in napari-ndev.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from bioio import BioImage + +from .bioio_plugins._manager import raise_unsupported_with_suggestions +from .utils._layer_utils import ( + build_layer_tuple, + determine_in_memory, + resolve_layer_type, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + import xarray as xr + from bioio_base.reader import Reader + from bioio_base.types import ImageLike + from napari.types import LayerDataTuple + +logger = logging.getLogger(__name__) + + +def _resolve_reader( + image: ImageLike, + explicit_reader: type[Reader] | Sequence[type[Reader]] | None, +) -> type[Reader] | Sequence[type[Reader]] | None: + """Resolve the reader to use for an image. + + Priority: + 1. Explicit reader (passed to __init__) + 2. Preferred reader from settings (if file path and installed) + 3. None (let bioio determine) + + Parameters + ---------- + image : ImageLike + The image to resolve a reader for. + explicit_reader : type[Reader] | Sequence[type[Reader]] | None + Explicit reader class(es) passed by user. + + Returns + ------- + type[Reader] | Sequence[type[Reader]] | None + The reader to use, or None to let bioio choose. + + """ + if explicit_reader is not None: + return explicit_reader + + # Only check preferred reader for file paths + if not isinstance(image, str | Path): + return None + + # Get preferred reader from settings + from ndev_settings import get_settings + + from .bioio_plugins._utils import get_installed_plugins, get_reader_by_name + + settings = get_settings() + preferred = settings.ndevio_reader.preferred_reader # type: ignore + + if not preferred: + return None + + if preferred not in get_installed_plugins(): + logger.debug('Preferred reader %s not installed', preferred) + return None + + return get_reader_by_name(preferred) + + +class nImage(BioImage): + """ + An nImage is a BioImage with additional functionality for napari. + + Extends BioImage to provide napari-ready layer data with proper scale, + axis labels, and units derived from bioimaging metadata. + + Parameters + ---------- + image : ImageLike + Image to be loaded. Can be a path to an image file, a numpy array, + or an xarray DataArray. + reader : type[Reader] | Sequence[type[Reader]], optional + Reader class or priority list of readers. If not provided, checks + settings for preferred_reader and tries that first, then falls back + to bioio's default deterministic priority. + **kwargs + Additional arguments passed to BioImage. + + Attributes + ---------- + path : str | None + Path or URI to the source file, or None if created from array data. + Always a plain string — local paths are stored as-is, ``file://`` URIs + are normalised to their path component, and remote URIs (``s3://``, + ``https://``, …) are kept verbatim. Use ``_is_remote`` to distinguish + local from remote. + + Examples + -------- + Basic usage with file path: + + >>> img = nImage("path/to/image.tiff") + >>> for layer_tuple in img.get_layer_data_tuples(): + ... layer = Layer.create(*layer_tuple) + ... viewer.add_layer(layer) + + Access layer properties: + + >>> img.layer_scale # (1.0, 0.2, 0.2) - physical scale per dim + >>> img.layer_axis_labels # ('Z', 'Y', 'X') + >>> img.layer_units # ('µm', 'µm', 'µm') + + """ + + # Class-level type hints for instance attributes + path: str | None + _is_remote: bool + _reference_xarray: xr.DataArray | None + _layer_data: list | None + + def __init__( + self, + image: ImageLike, + reader: type[Reader] | Sequence[type[Reader]] | None = None, + **kwargs, + ) -> None: + """Initialize an nImage with an image, and optionally a reader.""" + from bioio_base.exceptions import UnsupportedFileFormatError + + # Strip trailing slashes from string paths/URLs (e.g. `store.zarr/`) + # so that bioio's extension-based reader detection works correctly. + if isinstance(image, str): + image = image.rstrip('/') + + resolved_reader = _resolve_reader(image, reader) + + # Try preferred/explicit reader first, fall back to bioio default + if resolved_reader is not None: + try: + super().__init__(image=image, reader=resolved_reader, **kwargs) + except UnsupportedFileFormatError: + # Preferred reader failed, fall back to bioio's default + try: + super().__init__(image=image, reader=None, **kwargs) + except UnsupportedFileFormatError: + if isinstance(image, str | Path): + raise_unsupported_with_suggestions(image) + raise + else: + try: + super().__init__(image=image, reader=None, **kwargs) + except UnsupportedFileFormatError: + if isinstance(image, str | Path): + raise_unsupported_with_suggestions(image) + raise + + # Instance state + self._reference_xarray = None + self._layer_data = None + if isinstance(image, str | Path): + import fsspec + from fsspec.implementations.local import LocalFileSystem + + s = str(image) + fs, resolved = fsspec.url_to_fs(s) + if isinstance(fs, LocalFileSystem): + # Normalise file:// URIs and any platform variations to an + # OS-native path string so Path(self.path) always round-trips. + self.path = str(Path(resolved)) + self._is_remote = False + else: + # Remote URI (s3://, https://, gc://, …) — keep verbatim. + self.path = s + self._is_remote = True + else: + self.path = None + self._is_remote = False + + # Any compatibility warnings for old formats should be emitted at this point + # Cheaply check without imports by looking at the reader's module name + if self.reader.__module__.startswith('bioio_ome_zarr'): + from .bioio_plugins._compatibility import ( + apply_ome_zarr_compat_patches, + ) + + apply_ome_zarr_compat_patches(self.reader) + + @property + def reference_xarray(self) -> xr.DataArray: + """Image data as xarray DataArray for metadata determination. + + Lazily loads xarray on first access. Uses in-memory or dask array + based on file size (determined automatically). + + Returns + ------- + xr.DataArray + Squeezed xarray for napari dimensions. + + Notes + ----- + BioImage.xarray_data and BioImage.xarray_dask_data automatically + handle mosaic tile reconstruction when reconstruct_mosaic=True + (the default). No special mosaic handling needed here. + + """ + if self._reference_xarray is None: + # Ensure we're at the highest-res level for metadata consistency + current_res = self.current_resolution_level + self.set_resolution_level(0) + if self._is_remote or not determine_in_memory(self.path): + self._reference_xarray = self.xarray_dask_data.squeeze() + else: + self._reference_xarray = self.xarray_data.squeeze() + self.set_resolution_level(current_res) + return self._reference_xarray + + @property + def layer_data(self) -> list: + """Image data arrays shaped for napari, one per resolution level. + + Returns a list of arrays ordered from highest to lowest resolution. + For single-resolution images the list has one element. + napari automatically treats multi-element lists as multiscale data. + + Multiscale images are always dask-backed for memory efficiency. + Single-resolution images use numpy or dask based on file size. + + Returns + ------- + list[ArrayLike] + Squeezed image arrays (C dim retained for multichannel split + in :meth:`get_layer_data_tuples`). + + """ + if self._layer_data is None: + self._layer_data = self._build_layer_data() + return self._layer_data + + def _build_layer_data(self) -> list: + """Build the list of arrays for all resolution levels.""" + current_res = self.current_resolution_level + levels = self.resolution_levels + multiscale = len(levels) > 1 + + # Determine which dims to keep from level 0's squeezed metadata. + # Using isel instead of squeeze ensures all levels have + # consistent ndim (lower levels may have extra singleton spatial dims + # that squeeze would incorrectly remove). + ref = self.reference_xarray + keep_dims = set(ref.dims) + + arrays: list = [] + for level in levels: + self.set_resolution_level(level) + if ( + multiscale + or self._is_remote + or not determine_in_memory(self.path) + ): + xr_data = self.xarray_dask_data + else: + xr_data = self.xarray_data + + indexer = {d: 0 for d in xr_data.dims if d not in keep_dims} + arrays.append(xr_data.isel(indexer).data) + + self.set_resolution_level(current_res) + return arrays + + @property + def path_stem(self) -> str: + """Filename stem derived from path or URI, used in layer names. + + Returns + ------- + str + The stem of the filename (no extension, no parent path), or + ``'unknown'`` when the image was created from array data. + + Examples + -------- + >>> nImage("/data/cells.ome.tiff").path_stem + 'cells.ome' + >>> nImage("s3://bucket/experiment/image.zarr").path_stem + 'image' + + """ + if self.path is None: + return 'unknown' + if self._is_remote: + from pathlib import PurePosixPath + from urllib.parse import urlparse + + return PurePosixPath(urlparse(self.path).path).stem + return Path(self.path).stem + + @property + def layer_names(self) -> list[str]: + """Per-channel layer names for napari. + + Returns one name per output layer — the same count as + :meth:`get_layer_data_tuples` returns tuples. The base name is the + scene-qualified :attr:`path_stem`; channel names are prepended using + ``' :: '`` as a delimiter when present. + + Returns + ------- + list[str] + e.g. ``['membrane :: cells.ome', 'nuclei :: cells.ome']`` + for a 2-channel file, or ``['0 :: cells.ome']`` for a + single-channel OME image with a default ``C`` coordinate. + Only when no ``C`` dimension is present at all will the name + be just ``['cells.ome']``. + + Examples + -------- + >>> nImage("cells.ome.tiff").layer_names + ['0 :: cells.ome'] + + """ + # Build scene-qualified base name + delim = ' :: ' + parts: list[str] = [] + if len(self.scenes) > 1 or self.current_scene != 'Image:0': + parts.extend([str(self.current_scene_index), self.current_scene]) + parts.append(self.path_stem) + base_name = delim.join(parts) + + # RGB (Samples dim): single layer, no channel prefix + if 'S' in self.dims.order: + return [base_name] + + # Use BioImage channel_names — metadata only, no data load + channel_names = self.channel_names + + # Single channel (C=1 is squeezed out of layer_data) + if self.dims.C == 1: + ch_name = channel_names[0] + return [f'{ch_name} :: {base_name}' if ch_name else base_name] + + # Multichannel + return [f'{ch} :: {base_name}' for ch in channel_names] + + @property + def layer_scale(self) -> tuple[float, ...]: + """Physical scale for dimensions in layer data. + + Uses layer_axis_labels to determine which dimensions are present, + then extracts scale values from BioImage.scale. + Defaults to 1.0 for dimensions without scale metadata. + + Returns + ------- + tuple[float, ...] + Scale tuple matching layer_axis_labels. + + Examples + -------- + >>> img = nImage("timelapse.tiff") # T=3, Z=1, Y=10, X=10 + >>> img.layer_axis_labels + ('T', 'Y', 'X') + >>> img.layer_scale + (2.0, 0.2, 0.2) + + """ + axis_labels = self.layer_axis_labels + + # Try to get scale from BioImage - may fail for array-like inputs + # where physical_pixel_sizes is None (AttributeError), old OME-Zarr + # v0.1/v0.2 missing 'coordinateTransformations' (KeyError), or + # v0.3 string-axes that weren't normalised (TypeError). + try: + bio_scale = self.scale + except (AttributeError, KeyError, TypeError): + return tuple(1.0 for _ in axis_labels) + return tuple( + getattr(bio_scale, dim, None) or 1.0 for dim in axis_labels + ) + + @property + def layer_axis_labels(self) -> tuple[str, ...]: + """Dimension names for napari layers (excludes Channel and Samples). + + Returns + ------- + tuple[str, ...] + Dimension names (e.g., ('Z', 'Y', 'X')). + + Examples + -------- + >>> img = nImage("multichannel.tiff") # Shape (C=2, Z=10, Y=100, X=100) + >>> img.layer_axis_labels + ('Z', 'Y', 'X') + + """ + layer_data = self.reference_xarray + + # Exclude Channel and Samples dimensions (RGB/multichannel handled separately) + return tuple( + str(dim) for dim in layer_data.dims if dim not in ('C', 'S') + ) + + @property + def layer_units(self) -> tuple[str | None, ...]: + """Physical units for dimensions in layer data. + + Returns + ------- + tuple[str | None, ...] + Unit strings matching layer_axis_labels. None for dims without units. + + Examples + -------- + >>> img = nImage("timelapse.tiff") # T=3, Z=1, Y=10, X=10 + >>> # After squeezing, Z is removed + >>> img.layer_axis_labels + ('T', 'Y', 'X') + >>> img.layer_units + ('s', 'µm', 'µm') + + """ + axis_labels = self.layer_axis_labels + + try: + dim_props = self.dimension_properties + # Old OME-Zarr v0.1/v0.2 (KeyError), v0.3 string-axes (TypeError), + # or array-like inputs without dimension metadata (AttributeError). + except (AttributeError, KeyError, TypeError): + return tuple(None for _ in axis_labels) + + def _get_unit(dim: str) -> str | None: + prop = getattr(dim_props, dim, None) + return prop.unit if prop else None + + return tuple(_get_unit(dim) for dim in axis_labels) + + @property + def layer_metadata(self) -> dict: + """Base metadata dict for napari layers. + + Contains bioimage reference, raw metadata, and OME metadata if available. + + Returns + ------- + dict + Keys: 'bioimage', 'raw_image_metadata', and optionally 'ome_metadata'. + + """ + meta: dict = { + 'bioimage': self, + 'raw_image_metadata': self.metadata, + } + + try: + meta['ome_metadata'] = self.ome_metadata + except NotImplementedError: + pass # Reader doesn't support OME metadata + except (ValueError, TypeError, KeyError) as e: + # Some files have metadata that doesn't conform to OME schema, despite bioio attempting to parse it + # (e.g., CZI files with LatticeLightsheet acquisition mode) + # As such, when accessing ome_metadata, we may get various exceptions + # Log warning but continue - raw metadata is still available + logger.warning( + 'Could not parse OME metadata: %s. ' + "Raw metadata is still available in 'raw_image_metadata'.", + e, + ) + + return meta + + def get_layer_data_tuples( + self, + layer_type: str | None = None, + channel_types: dict[str, str] | None = None, + channel_kwargs: dict[str, dict] | None = None, + ) -> list[LayerDataTuple]: + """Build layer data tuples for napari. + + Splits multichannel data into separate layers, each with appropriate + metadata. Automatically detects label layers from channel names + containing keywords like 'label', 'mask', 'segmentation'. + + Parameters + ---------- + layer_type : str, optional + Override layer type for ALL channels. Valid values: 'image', + 'labels', 'shapes', 'points', 'surface', 'tracks', 'vectors'. + If None, auto-detection is used (based on channel names). + Takes precedence over channel_types. + channel_types : dict[str, str], optional + Per-channel layer type overrides. + e.g., {"DAPI": "image", "nuclei_mask": "labels"} + Ignored if layer_type is set. + channel_kwargs : dict[str, dict], optional + Per-channel napari kwargs overrides. + e.g., {"DAPI": {"colormap": "blue", "contrast_limits": (0, 1000)}} + These override the automatically generated metadata. + + Returns + ------- + list[LayerDataTuple] + List of (data, metadata, layer_type) tuples. + + Examples + -------- + Add layers to napari: + + >>> from napari.layers import Layer + >>> img = nImage("path/to/image.tiff") + >>> for ldt in img.get_layer_data_tuples(): + ... viewer.add_layer(Layer.create(*ldt)) + + Mixed image/labels: + + >>> img.get_layer_data_tuples( + ... channel_types={"DAPI": "image", "nuclei_mask": "labels"} + ... ) + + See Also + -------- + napari.layers.Layer.create : Creates a layer from a LayerDataTuple. + https://napari.org/dev/plugins/building_a_plugin/guides.html + + """ + ref = self.reference_xarray + data = self.layer_data + if layer_type is not None: + channel_types = None # Global override ignores per-channel + names = self.layer_names + base_metadata = self.layer_metadata + scale = self.layer_scale + axis_labels = self.layer_axis_labels + units = self.layer_units + + # Handle RGB images (Samples dimension 'S') + if 'S' in self.dims.order: + return [ + build_layer_tuple( + data, + layer_type='image', + name=names[0], + metadata=base_metadata, + scale=scale, + axis_labels=axis_labels, + units=units, + rgb=True, + ) + ] + + channel_dim = 'C' + + # Single channel (no C dimension to split) + if channel_dim not in ref.dims: + channel_name = self.channel_names[0] + effective_type = resolve_layer_type( + channel_name or '', layer_type, channel_types + ) + extra_kwargs = ( + channel_kwargs.get(channel_name) + if channel_kwargs and channel_name + else None + ) + return [ + build_layer_tuple( + data, + layer_type=effective_type, + name=names[0], + metadata=base_metadata, + scale=scale, + axis_labels=axis_labels, + units=units, + extra_kwargs=extra_kwargs, + ) + ] + + # Multichannel - split into separate layers + channel_names = self.channel_names + channel_axis = ref.dims.index(channel_dim) + total_channels = ref.shape[channel_axis] + + tuples: list[LayerDataTuple] = [] + for i in range(total_channels): + channel_name = channel_names[i] + effective_type = resolve_layer_type( + channel_name, layer_type, channel_types + ) + + # Slice along channel axis for each resolution level + slices: list[slice | int] = [slice(None)] * ref.ndim + slices[channel_axis] = i + channel_data = [arr[tuple(slices)] for arr in data] + + extra_kwargs = ( + channel_kwargs.get(channel_name) if channel_kwargs else None + ) + + tuples.append( + build_layer_tuple( + channel_data, + layer_type=effective_type, + name=names[i], + metadata=base_metadata, + scale=scale, + axis_labels=axis_labels, + units=units, + channel_idx=i, + total_channels=total_channels, + extra_kwargs=extra_kwargs, + ) + ) + + return tuples diff --git a/src/ndevio/sampledata/__init__.py b/src/ndevio/sampledata/__init__.py index 28e63d6..9746753 100644 --- a/src/ndevio/sampledata/__init__.py +++ b/src/ndevio/sampledata/__init__.py @@ -1 +1 @@ -"""Sample data for ndevio and the ndev-kit ecosystem.""" +"""Sample data for ndevio and the ndev-kit ecosystem.""" diff --git a/src/ndevio/sampledata/_sample_data.py b/src/ndevio/sampledata/_sample_data.py index 0bfb049..74cd289 100644 --- a/src/ndevio/sampledata/_sample_data.py +++ b/src/ndevio/sampledata/_sample_data.py @@ -1,133 +1,133 @@ -""" -Sample data providers for napari. - -This module implements the "sample data" specification. -see: https://napari.org/stable/plugins/building_a_plugin/guides.html#sample-data -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from napari.types import LayerDataTuple - -SAMPLE_DIR = Path(__file__).parent / 'data' - - -def ndev_logo() -> list[LayerDataTuple]: - """Load the ndev logo image.""" - from bioio_imageio import Reader as ImageIOReader - - from ..nimage import nImage - - return nImage( - SAMPLE_DIR / 'ndev-logo.png', - reader=ImageIOReader, - ).get_layer_data_tuples() - - -def scratch_assay() -> list[LayerDataTuple]: - """Load scratch assay data with labeled nuclei and cytoplasm.""" - import pooch - from bioio_ome_tiff import Reader as OmeTiffReader - - from ..nimage import nImage - - scratch_assay_raw_path = pooch.retrieve( - url='doi:10.5281/zenodo.17845346/scratch-assay-labeled-10T-2Ch.tiff', - known_hash='md5:2b98c4ea18cd741a1545e59855348a2f', - fname='scratch-assay-labeled-10T-2Ch.tiff', - path=SAMPLE_DIR, - ) - img = nImage( - scratch_assay_raw_path, - reader=OmeTiffReader, - ) - return img.get_layer_data_tuples( - channel_types={ - 'H3342': 'image', - 'oblique': 'image', - 'nuclei': 'labels', - 'cyto': 'labels', - }, - channel_kwargs={ - 'H3342': {'colormap': 'cyan'}, - 'oblique': {'colormap': 'gray'}, - }, - ) - - -def neocortex() -> list[LayerDataTuple]: - """Load neocortex 3-channel image data.""" - import pooch - from bioio_ome_tiff import Reader as OmeTiffReader - - from ..nimage import nImage - - neocortex_raw_path = pooch.retrieve( - url='doi:10.5281/zenodo.17845346/neocortex-3Ch.tiff', - known_hash='md5:eadc3fac751052461fb2e5f3c6716afa', - fname='neocortex-3Ch.tiff', - path=SAMPLE_DIR, - ) - return nImage( - neocortex_raw_path, - reader=OmeTiffReader, - ).get_layer_data_tuples() - - -def neuron_raw() -> list[LayerDataTuple]: - """Load raw neuron 4-channel image data. - - This sample is downloaded from Zenodo if not present locally. - """ - import pooch - from bioio_ome_tiff import Reader as OmeTiffReader - - from ..nimage import nImage - - neuron_raw_path = pooch.retrieve( - url='doi:10.5281/zenodo.17845346/neuron-4Ch_raw.tiff', - known_hash='md5:5d3e42bca2085e8588b6f23cf89ba87c', - fname='neuron-4Ch_raw.tiff', - path=SAMPLE_DIR, - ) - return nImage( - neuron_raw_path, - reader=OmeTiffReader, - ).get_layer_data_tuples( - layer_type='image', - channel_kwargs={ - 'PHALL': {'colormap': 'gray'}, - }, - ) - - -def neuron_labels() -> list[LayerDataTuple]: - """Load neuron labels data.""" - from bioio_ome_tiff import Reader as OmeTiffReader - - from ..nimage import nImage - - return nImage( - SAMPLE_DIR / 'neuron-4Ch_labels.tiff', - reader=OmeTiffReader, - ).get_layer_data_tuples( - layer_type='labels', - ) - - -def neuron_labels_processed() -> list[LayerDataTuple]: - """Load processed neuron labels data.""" - from bioio_ome_tiff import Reader as OmeTiffReader - - from ..nimage import nImage - - return nImage( - SAMPLE_DIR / 'neuron-4Ch_labels_processed.tiff', - reader=OmeTiffReader, - ).get_layer_data_tuples( - layer_type='labels', - ) +""" +Sample data providers for napari. + +This module implements the "sample data" specification. +see: https://napari.org/stable/plugins/building_a_plugin/guides.html#sample-data +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from napari.types import LayerDataTuple + +SAMPLE_DIR = Path(__file__).parent / 'data' + + +def ndev_logo() -> list[LayerDataTuple]: + """Load the ndev logo image.""" + from bioio_imageio import Reader as ImageIOReader + + from ..nimage import nImage + + return nImage( + SAMPLE_DIR / 'ndev-logo.png', + reader=ImageIOReader, + ).get_layer_data_tuples() + + +def scratch_assay() -> list[LayerDataTuple]: + """Load scratch assay data with labeled nuclei and cytoplasm.""" + import pooch + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + scratch_assay_raw_path = pooch.retrieve( + url='doi:10.5281/zenodo.17845346/scratch-assay-labeled-10T-2Ch.tiff', + known_hash='md5:2b98c4ea18cd741a1545e59855348a2f', + fname='scratch-assay-labeled-10T-2Ch.tiff', + path=SAMPLE_DIR, + ) + img = nImage( + scratch_assay_raw_path, + reader=OmeTiffReader, + ) + return img.get_layer_data_tuples( + channel_types={ + 'H3342': 'image', + 'oblique': 'image', + 'nuclei': 'labels', + 'cyto': 'labels', + }, + channel_kwargs={ + 'H3342': {'colormap': 'cyan'}, + 'oblique': {'colormap': 'gray'}, + }, + ) + + +def neocortex() -> list[LayerDataTuple]: + """Load neocortex 3-channel image data.""" + import pooch + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + neocortex_raw_path = pooch.retrieve( + url='doi:10.5281/zenodo.17845346/neocortex-3Ch.tiff', + known_hash='md5:eadc3fac751052461fb2e5f3c6716afa', + fname='neocortex-3Ch.tiff', + path=SAMPLE_DIR, + ) + return nImage( + neocortex_raw_path, + reader=OmeTiffReader, + ).get_layer_data_tuples() + + +def neuron_raw() -> list[LayerDataTuple]: + """Load raw neuron 4-channel image data. + + This sample is downloaded from Zenodo if not present locally. + """ + import pooch + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + neuron_raw_path = pooch.retrieve( + url='doi:10.5281/zenodo.17845346/neuron-4Ch_raw.tiff', + known_hash='md5:5d3e42bca2085e8588b6f23cf89ba87c', + fname='neuron-4Ch_raw.tiff', + path=SAMPLE_DIR, + ) + return nImage( + neuron_raw_path, + reader=OmeTiffReader, + ).get_layer_data_tuples( + layer_type='image', + channel_kwargs={ + 'PHALL': {'colormap': 'gray'}, + }, + ) + + +def neuron_labels() -> list[LayerDataTuple]: + """Load neuron labels data.""" + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + return nImage( + SAMPLE_DIR / 'neuron-4Ch_labels.tiff', + reader=OmeTiffReader, + ).get_layer_data_tuples( + layer_type='labels', + ) + + +def neuron_labels_processed() -> list[LayerDataTuple]: + """Load processed neuron labels data.""" + from bioio_ome_tiff import Reader as OmeTiffReader + + from ..nimage import nImage + + return nImage( + SAMPLE_DIR / 'neuron-4Ch_labels_processed.tiff', + reader=OmeTiffReader, + ).get_layer_data_tuples( + layer_type='labels', + ) diff --git a/src/ndevio/widgets/__init__.py b/src/ndevio/widgets/__init__.py index a7d584e..56f8619 100644 --- a/src/ndevio/widgets/__init__.py +++ b/src/ndevio/widgets/__init__.py @@ -1 +1 @@ -"""Widgets for ndevio package.""" +"""Widgets for ndevio package.""" diff --git a/tests/test_nimage.py b/tests/test_nimage.py index d8c3b0e..e2ea657 100644 --- a/tests/test_nimage.py +++ b/tests/test_nimage.py @@ -1,800 +1,800 @@ -"""Tests for ndevio.nImage class.""" - -from __future__ import annotations - -from pathlib import Path -from unittest import mock -from unittest.mock import patch - -import pytest -from bioio_base.exceptions import UnsupportedFileFormatError - -from ndevio import nImage - -RGB_TIFF = ( - 'RGB_bad_metadata.tiff' # has two scenes, with really difficult metadata -) -CELLS3D2CH_OME_TIFF = 'cells3d2ch_legacy.tiff' # 2 channel, 3D OME-TIFF, from old napari-ndev saving -LOGO_PNG = 'nDev-logo-small.png' # small PNG file (fix typo) -CZI_FILE = '0T-4C-0Z-7pos.czi' # multi-scene CZI file -ND2_FILE = 'ND2_dims_rgb.nd2' # ND2 file requiring bioio-nd2 -ZARR = 'dimension_handling_zyx_V3.zarr' - - -def test_nImage_init(resources_dir: Path): - """Test nImage initialization with a file that should work.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - assert img.path == str(resources_dir / CELLS3D2CH_OME_TIFF) - assert img.reader is not None - # Shape is (T, C, Z, Y, X) = (1, 2, 60, 66, 85) - assert img.data.shape == (1, 2, 60, 66, 85) - # layer_data should not be loaded until accessed - assert img._reference_xarray is None - # Accessing the property triggers lazy loading - assert img.reference_xarray is not None - - -def test_nImage_zarr(resources_dir: Path): - """Test that nImage can read a Zarr file.""" - img = nImage(resources_dir / ZARR) - assert img.data is not None - assert img.path == str(resources_dir / ZARR) - assert img.data.shape == (1, 1, 2, 4, 4) - - -def test_nImage_zarr_trailing_slash(resources_dir: Path): - """Test that a string path with a trailing slash is handled correctly. - - Regression test: bioio's extension-based reader detection fails when the - path ends with '/', e.g. 'store.zarr/'. nImage strips the slash on init. - See https://github.com/ndev-kit/ndevio/issues/XX - """ - path_with_slash = str(resources_dir / ZARR) + '/' - img = nImage(path_with_slash) - assert img.data is not None - # path stored without the trailing slash - assert not img.path.endswith('/') - assert img.path == str(resources_dir / ZARR) - assert img.data.shape == (1, 1, 2, 4, 4) - - -@pytest.mark.network -def test_nImage_remote_zarr_trailing_slash(): - """Test that a remote Zarr URL with a trailing slash is read correctly. - - Regression test: 'https://...9846152.zarr/' crashed with - 'Reader ndevio returned no data' because the trailing slash prevented - bioio from matching the '*.zarr' extension pattern. - """ - remote_zarr = ( - 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr/' - ) - img = nImage(remote_zarr) - assert img._is_remote - assert not img.path.endswith('/') - assert img.path == remote_zarr.rstrip('/') - assert img.xarray_dask_data is not None - - -@pytest.mark.network -def test_nImage_remote_zarr(): - """Test that nImage can read a remote Zarr file.""" - remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/ebi-ngff-challenge-2024/4ffaeed2-fa70-4907-820f-8a96ef683095.zarr' # from https://github.com/bioio-devs/bioio-ome-zarr/blob/main/bioio_ome_zarr/tests/test_remote_read_zarrV3.py - img = nImage(remote_zarr) - assert img.path == remote_zarr - assert img._is_remote - # original shape is (1, 2, 1, 512, 512) but layer_data is squeezed - assert img.reference_xarray.shape == (2, 512, 512) - - -@pytest.mark.network -def test_nImage_remote_zarr_v01v02_format(caplog): - """Test that nImage emits a warning for old OME-Zarr formats when reading remotely.""" - remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836841.zarr' # from https://github.com/ndev-kit/ndevio/issues/50 - with caplog.at_level( - 'WARNING', logger='ndevio.bioio_plugins._compatibility' - ): - img = nImage(remote_zarr) - assert img.path == remote_zarr - # should catch a key error due to old format - # but still quietly create a scale with no units - assert img.layer_scale == (1.0, 1.0) - assert img.layer_units == (None, None) - - -@pytest.mark.network -def test_nimage_remote_v03_zarr(): - """Test that nImage can read a real remote OME-Zarr v0.3 store.""" - remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.3/9836842.zarr' - img = nImage(remote_zarr) - assert img.path == remote_zarr - assert img._is_remote - assert img.reference_xarray is not None - tuples = img.get_layer_data_tuples() - assert len(tuples) > 0 - - -def test_nImage_ome_reader(resources_dir: Path): - """ - Test that the OME-TIFF reader is used for OME-TIFF files. - - This test is in response to https://github.com/bioio-devs/bioio/issues/79 - whereby images saved with bioio.writers.OmeTiffWriter are not being read with - bioio_ome_tiff.Reader, but instead with bioio_tifffile.Reader. - - The example here was saved with aicsimageio.writers.OmeTiffWriter. nImage - has an __init__ function that should override the reader determined by - bioio.BioImage.determine_plugin() with bioio_ome_tiff if the image is an - OME-TIFF. - """ - - img_path = resources_dir / CELLS3D2CH_OME_TIFF - - nimg = nImage(img_path) - # assert nimg.settings.ndevio_reader.preferred_reader == 'bioio-ome-tiff' # this was the old methodology before bioio#162 - assert nimg.reader.name == 'bioio_ome_tiff' - # the below only exists if 'bioio-ome-tiff' is used - assert hasattr(nimg, 'ome_metadata') - assert nimg.channel_names == ['membrane', 'nuclei'] - - -def test_nImage_save_read(resources_dir: Path, tmp_path: Path): - """ - Test saving and reading an image with OmeTiffWriter and nImage. - - Confirm that the image is saved with the correct physical pixel sizes and - channel names, and that it is read back with the same physical pixel sizes - and channel names because it is an OME-TIFF. See the above test for - the need of this and to ensure not being read by bioio_tifffile.Reader. - """ - from bioio_base.types import PhysicalPixelSizes - from bioio_ome_tiff.writers import OmeTiffWriter - - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - assert img.physical_pixel_sizes.X == 1 - - img_data = img.get_image_data('CZYX') - OmeTiffWriter.save( - img_data, - tmp_path / 'test_save_read.tiff', - dim_order='CZYX', - physical_pixel_sizes=PhysicalPixelSizes(1, 2, 3), # ZYX - channel_names=['test1', 'test2'], - ) - assert (tmp_path / 'test_save_read.tiff').exists() - - new_img = nImage(tmp_path / 'test_save_read.tiff') - - # having the below features means it is properly read as OME-TIFF - assert new_img.physical_pixel_sizes.Z == 1 - assert new_img.physical_pixel_sizes.Y == 2 - assert new_img.physical_pixel_sizes.X == 3 - assert new_img.channel_names == ['test1', 'test2'] - - -def test_get_layer_data(resources_dir: Path): - """Test loading napari layer data in memory.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - # Access layer_data property to trigger loading - data = img.reference_xarray - # layer_data will be squeezed - # Original shape (1, 2, 60, 66, 85) -> (2, 60, 66, 85) - assert data.shape == (2, 60, 66, 85) - assert data.dims == ('C', 'Z', 'Y', 'X') - - -def test_get_layer_data_tuples_basic(resources_dir: Path): - """Test layer data tuple generation.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - # With 2 channels, should get 2 tuples (one per channel) - assert len(layer_tuples) == 2 - for _data, meta, layer_type in layer_tuples: - assert 'cells3d2ch_legacy' in meta['name'] - assert meta['scale'] is not None - assert layer_type == 'image' # default layer type - - -def test_get_layer_data_tuples_ome_validation_error_logged( - resources_dir: Path, - caplog: pytest.LogCaptureFixture, -): - """Test that OME metadata validation errors are logged but don't crash. - - Some files (e.g., CZI files with LatticeLightsheet acquisition mode) have - metadata that doesn't conform to the OME schema, causing ValidationError - when accessing ome_metadata. This should be logged as a warning but not - prevent the image from loading. - """ - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - - # Mock ome_metadata to raise a ValidationError (which inherits from ValueError) - with mock.patch.object( - type(img), - 'ome_metadata', - new_callable=mock.PropertyMock, - side_effect=ValueError('Invalid acquisition_mode: LatticeLightsheet'), - ): - caplog.clear() - layer_tuples = img.get_layer_data_tuples() - - # Should still return valid layer tuples - assert layer_tuples is not None - assert len(layer_tuples) > 0 - - # Check that metadata dict exists in each tuple - for _, meta, _ in layer_tuples: - assert 'name' in meta - assert 'metadata' in meta - # ome_metadata should NOT be in the nested metadata dict - assert 'ome_metadata' not in meta['metadata'] - # raw_image_metadata should still be available - assert 'raw_image_metadata' in meta['metadata'] - - # Warning should be logged - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == 'WARNING' - assert 'Could not parse OME metadata' in caplog.records[0].message - assert 'LatticeLightsheet' in caplog.records[0].message - - -def test_get_layer_data_tuples_ome_not_implemented_silent( - resources_dir: Path, - caplog: pytest.LogCaptureFixture, -): - """Test that NotImplementedError for ome_metadata is silently ignored. - - Some readers don't support OME metadata at all. This should be silently - ignored without logging. - """ - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - - # Mock ome_metadata to raise NotImplementedError - with mock.patch.object( - type(img), - 'ome_metadata', - new_callable=mock.PropertyMock, - side_effect=NotImplementedError( - 'Reader does not support OME metadata' - ), - ): - caplog.clear() - layer_tuples = img.get_layer_data_tuples() - - # Should still return valid layer tuples - assert layer_tuples is not None - assert len(layer_tuples) > 0 - - for _, meta, _ in layer_tuples: - assert 'ome_metadata' not in meta['metadata'] - - # No warning should be logged for NotImplementedError - assert len(caplog.records) == 0 - - -@pytest.mark.parametrize( - ('filename', 'should_work', 'expected_error_contains'), - [ - (LOGO_PNG, True, None), - (CELLS3D2CH_OME_TIFF, True, None), - (CZI_FILE, True, None), - (ND2_FILE, False, ['bioio-nd2', 'pip install']), - (RGB_TIFF, True, None), - ], -) -def test_nimage_init_with_various_formats( - resources_dir: Path, - filename: str, - should_work: bool | str, - expected_error_contains: list[str] | None, -): - """Test nImage initialization with various file formats. - - This tests the complete workflow: file → get_reader_priority → nImage init - """ - if should_work is True: - # Must successfully initialize - img = nImage(resources_dir / filename) - assert img.data is not None - assert img.path == str(resources_dir / filename) - elif should_work is False: - # Must fail with helpful error - with pytest.raises(UnsupportedFileFormatError) as exc_info: - nImage(resources_dir / filename) - - error_msg = str(exc_info.value) - if expected_error_contains: - for expected_text in expected_error_contains: - assert expected_text in error_msg - else: # "maybe" - # Can succeed or fail - try: - img = nImage(resources_dir / filename) - assert img.data is not None - except UnsupportedFileFormatError as e: - error_msg = str(e) - # Should contain at least one of the expected error texts - if expected_error_contains: - assert any( - text in error_msg for text in expected_error_contains - ) - - -# ============================================================================= -# Tests for get_layer_data_tuples -# ============================================================================= - - -class TestGetLayerDataTuples: - """Tests for nImage.get_layer_data_tuples method.""" - - def test_multichannel_returns_tuple_per_channel(self, resources_dir: Path): - """Test that multichannel images return one tuple per channel. - - The new API always splits channels, returning separate tuples for each. - """ - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - # Should return one tuple per channel (2 channels) - assert len(layer_tuples) == 2 - - for data, meta, layer_type in layer_tuples: - # channel_axis should NOT be in metadata (we split ourselves) - assert 'channel_axis' not in meta - - # name should be a string (not a list) - assert isinstance(meta['name'], str) - - # Data should be a list of arrays (multiscale-ready) - assert isinstance(data, list) - assert len(data) == 1 # single resolution level - # Shape should NOT include channel dimension - assert data[0].shape == (60, 66, 85) # ZYX only - - # Default layer type is "image" (channel names don't match label keywords) - assert layer_type == 'image' - - def test_layer_names_include_channel_names(self, resources_dir: Path): - """Test that layer names include channel names from the file.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - # Extract names from the tuples - names = [meta['name'] for _, meta, _ in layer_tuples] - - # Channel names from the file are "membrane" and "nuclei" - assert 'membrane' in names[0] - assert 'nuclei' in names[1] - - def test_layer_names_matches_tuple_names(self, resources_dir: Path): - """Test that layer_names property matches names in get_layer_data_tuples.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - # layer_names should match names baked into the tuples - assert img.layer_names == [meta['name'] for _, meta, _ in layer_tuples] - assert len(img.layer_names) == 2 - assert 'membrane' in img.layer_names[0] - assert 'nuclei' in img.layer_names[1] - - def test_layer_names_single_channel(self, resources_dir: Path): - """Test layer_names for a single-channel image.""" - img = nImage(resources_dir / LOGO_PNG) - assert len(img.layer_names) == 1 - assert img.layer_names[0].endswith(img.path_stem) - - def test_single_channel_image_returns_single_tuple( - self, resources_dir: Path - ): - """Test that single channel images return single tuple.""" - # PNG is single channel (or RGB treated as single layer) - img = nImage(resources_dir / LOGO_PNG) - layer_tuples = img.get_layer_data_tuples() - - # Single channel should return single tuple - assert len(layer_tuples) == 1 - - data, meta, layer_type = layer_tuples[0] - assert 'channel_axis' not in meta - assert layer_type == 'image' - - def test_scale_preserved_in_tuples(self, resources_dir: Path): - """Test that scale metadata is preserved in each tuple.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - for _, meta, _ in layer_tuples: - # Scale should be preserved in each layer - assert 'scale' in meta - # Original has physical pixel sizes, so scale should have values - assert len(meta['scale']) > 0 - - def test_colormap_cycling_for_images(self, resources_dir: Path): - """Test that image layers get colormaps based on napari's defaults. - - - 1 channel → gray - - 2 channels → magenta, green (MAGENTA_GREEN) - - 3+ channels → cycles through CYMRGB - """ - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples() - - # Extract colormaps from the tuples - colormaps = [meta.get('colormap') for _, meta, _ in layer_tuples] - - # 2 channels should use MAGENTA_GREEN - assert colormaps[0] == 'magenta' - assert colormaps[1] == 'green' - - def test_colormap_single_channel_is_gray(self, resources_dir: Path): - """Test that single channel images get gray colormap.""" - import numpy as np - import xarray as xr - - # Create nImage directly with single channel data (no Channel dimension) - mock_data = xr.DataArray( - np.zeros((10, 10)), - dims=['Y', 'X'], - ) - img = nImage(mock_data) - - layer_tuples = img.get_layer_data_tuples() - assert len(layer_tuples) == 1 - assert layer_tuples[0][1]['colormap'] == 'gray' - - def test_colormap_three_plus_channels_uses_multi_channel_cycle( - self, resources_dir: Path - ): - """Test that 3+ channel images cycle through MULTI_CHANNEL_CYCLE.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - from ndevio.utils._colormap_utils import MULTI_CHANNEL_CYCLE - - # Create nImage directly with 4 channel data - mock_data = xr.DataArray( - np.zeros((4, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['ch0', 'ch1', 'ch2', 'ch3']}, - ) - img = nImage(mock_data) - - layer_tuples = img.get_layer_data_tuples() - colormaps = [meta.get('colormap') for _, meta, _ in layer_tuples] - - # Should cycle through MULTI_CHANNEL_CYCLE (CMYBGR) - assert colormaps[0] == MULTI_CHANNEL_CYCLE[0] # cyan - assert colormaps[1] == MULTI_CHANNEL_CYCLE[1] # magenta - assert colormaps[2] == MULTI_CHANNEL_CYCLE[2] # yellow - assert colormaps[3] == MULTI_CHANNEL_CYCLE[3] # blue - - def test_auto_detect_labels_from_channel_name(self, resources_dir: Path): - """Test that channels with label-like names are detected as labels.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - # Create nImage directly with a channel named "mask" - mock_data = xr.DataArray( - np.zeros((2, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['intensity', 'mask']}, - ) - img = nImage(mock_data) - - layer_tuples = img.get_layer_data_tuples() - - # First channel "intensity" should be image - assert layer_tuples[0][2] == 'image' - # Second channel "mask" should be labels (keyword match) - assert layer_tuples[1][2] == 'labels' - - def test_channel_types_override_auto_detection(self, resources_dir: Path): - """Test that channel_types parameter overrides auto-detection.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - # Create nImage directly with mock data - mock_data = xr.DataArray( - np.zeros((2, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['intensity', 'mask']}, - ) - img = nImage(mock_data) - - # Override: set both channels to labels - layer_tuples = img.get_layer_data_tuples( - channel_types={'intensity': 'labels', 'mask': 'labels'} - ) - - # Both should be labels due to override - assert layer_tuples[0][2] == 'labels' - assert layer_tuples[1][2] == 'labels' - - def test_labels_do_not_get_colormap(self, resources_dir: Path): - """Test that labels layers don't get colormap metadata.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - # Create nImage directly with a labels channel - mock_data = xr.DataArray( - np.zeros((1, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['segmentation']}, - ) - img = nImage(mock_data) - - layer_tuples = img.get_layer_data_tuples() - - # "segmentation" matches label keyword - assert layer_tuples[0][2] == 'labels' - # Labels should not have colormap - assert 'colormap' not in layer_tuples[0][1] - - def test_layer_type_override_all_channels(self, resources_dir: Path): - """Test that layer_type parameter overrides all channels.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples(layer_type='labels') - - # All channels should be labels due to override - assert len(layer_tuples) == 2 - for _, meta, layer_type in layer_tuples: - assert layer_type == 'labels' - # Labels should not have colormap - assert 'colormap' not in meta - - def test_layer_type_overrides_channel_types(self, resources_dir: Path): - """Test that layer_type takes precedence over channel_types.""" - import numpy as np - import xarray as xr - from bioio_base.dimensions import DimensionNames - - # Create nImage directly with mock data - mock_data = xr.DataArray( - np.zeros((2, 10, 10)), - dims=[DimensionNames.Channel, 'Y', 'X'], - coords={DimensionNames.Channel: ['intensity', 'mask']}, - ) - img = nImage(mock_data) - - # Even though channel_types says "intensity" should be image, - # layer_type="labels" should override everything - layer_tuples = img.get_layer_data_tuples( - layer_type='labels', - channel_types={'intensity': 'image', 'mask': 'image'}, - ) - - # Both should be labels due to layer_type override - assert layer_tuples[0][2] == 'labels' - - def test_channel_kwargs_override_metadata(self, resources_dir: Path): - """Test that channel_kwargs overrides default metadata.""" - img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) - layer_tuples = img.get_layer_data_tuples( - channel_kwargs={ - img.channel_names[0]: { - 'colormap': 'blue', - 'contrast_limits': (0, 1000), - }, - img.channel_names[1]: { - 'opacity': 0.5, - }, - } - ) - - assert len(layer_tuples) == 2 - # First channel should have overridden colormap and contrast_limits - assert layer_tuples[0][1]['colormap'] == 'blue' - assert layer_tuples[0][1]['contrast_limits'] == (0, 1000) - # Second channel should have opacity override but default colormap - assert layer_tuples[1][1]['opacity'] == 0.5 - assert ( - layer_tuples[1][1]['colormap'] == 'green' - ) # default for 2-channel - - -class TestPreferredReaderFallback: - """Tests for preferred reader fallback logic in nImage.__init__.""" - - def test_preferred_reader_success(self, resources_dir: Path): - """Test that preferred reader is used when it works.""" - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - # Mock returning a valid reader - from bioio_tifffile import Reader - - mock_resolve.return_value = Reader - - img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) - - # Verify _resolve_reader was called - mock_resolve.assert_called_once() - assert img is not None - assert img.reader.name == 'bioio_tifffile' - - def test_preferred_reader_fallback(self, resources_dir: Path): - """Test that failed preferred reader will fallback""" - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - # Mock returning a reader that won't work for this file - from bioio_czi import Reader - - mock_resolve.return_value = Reader - - img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) - - # Verify _resolve_reader was called - mock_resolve.assert_called_once() - assert img is not None - # Should have fallen back to bioio's default (ome-tiff) - assert img.reader.name == 'bioio_ome_tiff' - - def test_no_preferred_reader_uses_default(self, resources_dir: Path): - """Test that no preferred reader uses bioio's default priority.""" - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - mock_resolve.return_value = None # No preferred reader - - img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) - assert img is not None - mock_resolve.assert_called_once() - assert img.reader.name == 'bioio_ome_tiff' - - -class TestResolveReaderFunction: - """Tests for _resolve_reader function.""" - - def test_returns_none_when_no_preferred_reader(self): - """Test returns None when preferred_reader is not set.""" - from ndevio.nimage import _resolve_reader - - with patch('ndev_settings.get_settings') as mock_get_settings: - mock_get_settings.return_value.ndevio_reader.preferred_reader = ( - None - ) - - result = _resolve_reader('test.tiff', None) - assert result is None - - def test_returns_none_when_preferred_not_installed(self): - """Test returns None when preferred reader is not installed.""" - from ndevio.nimage import _resolve_reader - - with ( - patch('ndev_settings.get_settings') as mock_get_settings, - patch( - 'ndevio.bioio_plugins._utils.get_installed_plugins', - return_value={'bioio-ome-tiff', 'bioio-tifffile'}, - ), - ): - mock_get_settings.return_value.ndevio_reader.preferred_reader = ( - 'bioio-czi' - ) - - result = _resolve_reader('test.tiff', None) - assert result is None - - def test_returns_reader_when_preferred_installed(self): - """Test returns reader class when preferred reader is installed.""" - from ndevio.nimage import _resolve_reader - - with ( - patch('ndev_settings.get_settings') as mock_get_settings, - patch( - 'ndevio.bioio_plugins._utils.get_installed_plugins', - return_value={'bioio-ome-tiff'}, - ), - patch( - 'ndevio.bioio_plugins._utils.get_reader_by_name' - ) as mock_get_reader, - ): - from bioio_ome_tiff import Reader as OmeTiffReader - - mock_get_reader.return_value = OmeTiffReader - mock_get_settings.return_value.ndevio_reader.preferred_reader = ( - 'bioio-ome-tiff' - ) - - result = _resolve_reader('test.tiff', None) - assert result == OmeTiffReader - mock_get_reader.assert_called_once_with('bioio-ome-tiff') - - def test_explicit_reader_bypasses_settings(self): - """Test that explicit reader bypasses settings lookup.""" - from bioio_tifffile import Reader as TifffileReader - - from ndevio.nimage import _resolve_reader - - with patch('ndev_settings.get_settings') as mock_get_settings: - result = _resolve_reader('test.tiff', TifffileReader) - - # Should return explicit reader without checking settings - assert result == TifffileReader - mock_get_settings.assert_not_called() - - def test_array_input_returns_none(self): - """Test that array inputs don't trigger preferred reader lookup.""" - import numpy as np - - from ndevio.nimage import _resolve_reader - - with patch('ndev_settings.get_settings') as mock_get_settings: - arr = np.zeros((10, 10), dtype=np.uint8) - result = _resolve_reader(arr, None) - - # Should return None without checking settings for arrays - assert result is None - mock_get_settings.assert_not_called() - - -class TestNonPathImageHandling: - """Tests for handling non-path inputs (arrays).""" - - def test_array_input_no_preferred_reader_check(self): - """Test that arrays don't trigger preferred reader logic.""" - import numpy as np - - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - # Create a simple array - arr = np.zeros((10, 10), dtype=np.uint8) - - # This should work - img = nImage(arr) - assert img is not None - - # _resolve_reader should have been called but returned None - mock_resolve.assert_called_once() - # First arg is the image, second is explicit_reader (None) - call_args = mock_resolve.call_args - assert call_args[0][1] is None # explicit_reader is None - - def test_unsupported_array_raises_without_suggestions(self): - """Test that unsupported arrays raise error without plugin suggestions.""" - # Create something that will fail - with pytest.raises(UnsupportedFileFormatError) as exc_info: - # Pass an invalid object - nImage('this_is_not_a_valid_input.fake') - - # Error should be raised but without custom suggestions since it's not a path - error_msg = str(exc_info.value) - assert ( - 'fake' in error_msg.lower() or 'unsupported' in error_msg.lower() - ) - - -class TestExplicitReaderParameter: - """Tests for when reader is explicitly provided.""" - - def test_explicit_reader_bypasses_preferred(self, resources_dir: Path): - """Test that explicit reader parameter bypasses preferred reader.""" - from bioio_tifffile import Reader as TifffileReader - - with patch('ndevio.nimage._resolve_reader') as mock_resolve: - mock_resolve.return_value = TifffileReader - - # Explicit reader should be used directly - img = nImage( - str(resources_dir / 'cells3d2ch_legacy.tiff'), - reader=TifffileReader, - ) - - assert img is not None - # _resolve_reader should return the explicit reader - mock_resolve.assert_called_once() - call_args = mock_resolve.call_args - assert call_args[0][1] == TifffileReader # explicit_reader - - def test_explicit_reader_fails_falls_back(self, resources_dir: Path): - """Test explicit reader that fails falls back to default.""" - from bioio_czi import Reader as CziReader - - # Use CZI reader on a TIFF file - it should fail and fall back - img = nImage( - str(resources_dir / 'cells3d2ch_legacy.tiff'), - reader=CziReader, - ) - - assert img is not None - # Should have fallen back to bioio's default - assert img.reader.name == 'bioio_ome_tiff' +"""Tests for ndevio.nImage class.""" + +from __future__ import annotations + +from pathlib import Path +from unittest import mock +from unittest.mock import patch + +import pytest +from bioio_base.exceptions import UnsupportedFileFormatError + +from ndevio import nImage + +RGB_TIFF = ( + 'RGB_bad_metadata.tiff' # has two scenes, with really difficult metadata +) +CELLS3D2CH_OME_TIFF = 'cells3d2ch_legacy.tiff' # 2 channel, 3D OME-TIFF, from old napari-ndev saving +LOGO_PNG = 'nDev-logo-small.png' # small PNG file (fix typo) +CZI_FILE = '0T-4C-0Z-7pos.czi' # multi-scene CZI file +ND2_FILE = 'ND2_dims_rgb.nd2' # ND2 file requiring bioio-nd2 +ZARR = 'dimension_handling_zyx_V3.zarr' + + +def test_nImage_init(resources_dir: Path): + """Test nImage initialization with a file that should work.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + assert img.path == str(resources_dir / CELLS3D2CH_OME_TIFF) + assert img.reader is not None + # Shape is (T, C, Z, Y, X) = (1, 2, 60, 66, 85) + assert img.data.shape == (1, 2, 60, 66, 85) + # layer_data should not be loaded until accessed + assert img._reference_xarray is None + # Accessing the property triggers lazy loading + assert img.reference_xarray is not None + + +def test_nImage_zarr(resources_dir: Path): + """Test that nImage can read a Zarr file.""" + img = nImage(resources_dir / ZARR) + assert img.data is not None + assert img.path == str(resources_dir / ZARR) + assert img.data.shape == (1, 1, 2, 4, 4) + + +def test_nImage_zarr_trailing_slash(resources_dir: Path): + """Test that a string path with a trailing slash is handled correctly. + + Regression test: bioio's extension-based reader detection fails when the + path ends with '/', e.g. 'store.zarr/'. nImage strips the slash on init. + See https://github.com/ndev-kit/ndevio/issues/XX + """ + path_with_slash = str(resources_dir / ZARR) + '/' + img = nImage(path_with_slash) + assert img.data is not None + # path stored without the trailing slash + assert not img.path.endswith('/') + assert img.path == str(resources_dir / ZARR) + assert img.data.shape == (1, 1, 2, 4, 4) + + +@pytest.mark.network +def test_nImage_remote_zarr_trailing_slash(): + """Test that a remote Zarr URL with a trailing slash is read correctly. + + Regression test: 'https://...9846152.zarr/' crashed with + 'Reader ndevio returned no data' because the trailing slash prevented + bioio from matching the '*.zarr' extension pattern. + """ + remote_zarr = ( + 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr/' + ) + img = nImage(remote_zarr) + assert img._is_remote + assert not img.path.endswith('/') + assert img.path == remote_zarr.rstrip('/') + assert img.xarray_dask_data is not None + + +@pytest.mark.network +def test_nImage_remote_zarr(): + """Test that nImage can read a remote Zarr file.""" + remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/ebi-ngff-challenge-2024/4ffaeed2-fa70-4907-820f-8a96ef683095.zarr' # from https://github.com/bioio-devs/bioio-ome-zarr/blob/main/bioio_ome_zarr/tests/test_remote_read_zarrV3.py + img = nImage(remote_zarr) + assert img.path == remote_zarr + assert img._is_remote + # original shape is (1, 2, 1, 512, 512) but layer_data is squeezed + assert img.reference_xarray.shape == (2, 512, 512) + + +@pytest.mark.network +def test_nImage_remote_zarr_v01v02_format(caplog): + """Test that nImage emits a warning for old OME-Zarr formats when reading remotely.""" + remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836841.zarr' # from https://github.com/ndev-kit/ndevio/issues/50 + with caplog.at_level( + 'WARNING', logger='ndevio.bioio_plugins._compatibility' + ): + img = nImage(remote_zarr) + assert img.path == remote_zarr + # should catch a key error due to old format + # but still quietly create a scale with no units + assert img.layer_scale == (1.0, 1.0) + assert img.layer_units == (None, None) + + +@pytest.mark.network +def test_nimage_remote_v03_zarr(): + """Test that nImage can read a real remote OME-Zarr v0.3 store.""" + remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.3/9836842.zarr' + img = nImage(remote_zarr) + assert img.path == remote_zarr + assert img._is_remote + assert img.reference_xarray is not None + tuples = img.get_layer_data_tuples() + assert len(tuples) > 0 + + +def test_nImage_ome_reader(resources_dir: Path): + """ + Test that the OME-TIFF reader is used for OME-TIFF files. + + This test is in response to https://github.com/bioio-devs/bioio/issues/79 + whereby images saved with bioio.writers.OmeTiffWriter are not being read with + bioio_ome_tiff.Reader, but instead with bioio_tifffile.Reader. + + The example here was saved with aicsimageio.writers.OmeTiffWriter. nImage + has an __init__ function that should override the reader determined by + bioio.BioImage.determine_plugin() with bioio_ome_tiff if the image is an + OME-TIFF. + """ + + img_path = resources_dir / CELLS3D2CH_OME_TIFF + + nimg = nImage(img_path) + # assert nimg.settings.ndevio_reader.preferred_reader == 'bioio-ome-tiff' # this was the old methodology before bioio#162 + assert nimg.reader.name == 'bioio_ome_tiff' + # the below only exists if 'bioio-ome-tiff' is used + assert hasattr(nimg, 'ome_metadata') + assert nimg.channel_names == ['membrane', 'nuclei'] + + +def test_nImage_save_read(resources_dir: Path, tmp_path: Path): + """ + Test saving and reading an image with OmeTiffWriter and nImage. + + Confirm that the image is saved with the correct physical pixel sizes and + channel names, and that it is read back with the same physical pixel sizes + and channel names because it is an OME-TIFF. See the above test for + the need of this and to ensure not being read by bioio_tifffile.Reader. + """ + from bioio_base.types import PhysicalPixelSizes + from bioio_ome_tiff.writers import OmeTiffWriter + + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + assert img.physical_pixel_sizes.X == 1 + + img_data = img.get_image_data('CZYX') + OmeTiffWriter.save( + img_data, + tmp_path / 'test_save_read.tiff', + dim_order='CZYX', + physical_pixel_sizes=PhysicalPixelSizes(1, 2, 3), # ZYX + channel_names=['test1', 'test2'], + ) + assert (tmp_path / 'test_save_read.tiff').exists() + + new_img = nImage(tmp_path / 'test_save_read.tiff') + + # having the below features means it is properly read as OME-TIFF + assert new_img.physical_pixel_sizes.Z == 1 + assert new_img.physical_pixel_sizes.Y == 2 + assert new_img.physical_pixel_sizes.X == 3 + assert new_img.channel_names == ['test1', 'test2'] + + +def test_get_layer_data(resources_dir: Path): + """Test loading napari layer data in memory.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + # Access layer_data property to trigger loading + data = img.reference_xarray + # layer_data will be squeezed + # Original shape (1, 2, 60, 66, 85) -> (2, 60, 66, 85) + assert data.shape == (2, 60, 66, 85) + assert data.dims == ('C', 'Z', 'Y', 'X') + + +def test_get_layer_data_tuples_basic(resources_dir: Path): + """Test layer data tuple generation.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + # With 2 channels, should get 2 tuples (one per channel) + assert len(layer_tuples) == 2 + for _data, meta, layer_type in layer_tuples: + assert 'cells3d2ch_legacy' in meta['name'] + assert meta['scale'] is not None + assert layer_type == 'image' # default layer type + + +def test_get_layer_data_tuples_ome_validation_error_logged( + resources_dir: Path, + caplog: pytest.LogCaptureFixture, +): + """Test that OME metadata validation errors are logged but don't crash. + + Some files (e.g., CZI files with LatticeLightsheet acquisition mode) have + metadata that doesn't conform to the OME schema, causing ValidationError + when accessing ome_metadata. This should be logged as a warning but not + prevent the image from loading. + """ + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + + # Mock ome_metadata to raise a ValidationError (which inherits from ValueError) + with mock.patch.object( + type(img), + 'ome_metadata', + new_callable=mock.PropertyMock, + side_effect=ValueError('Invalid acquisition_mode: LatticeLightsheet'), + ): + caplog.clear() + layer_tuples = img.get_layer_data_tuples() + + # Should still return valid layer tuples + assert layer_tuples is not None + assert len(layer_tuples) > 0 + + # Check that metadata dict exists in each tuple + for _, meta, _ in layer_tuples: + assert 'name' in meta + assert 'metadata' in meta + # ome_metadata should NOT be in the nested metadata dict + assert 'ome_metadata' not in meta['metadata'] + # raw_image_metadata should still be available + assert 'raw_image_metadata' in meta['metadata'] + + # Warning should be logged + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == 'WARNING' + assert 'Could not parse OME metadata' in caplog.records[0].message + assert 'LatticeLightsheet' in caplog.records[0].message + + +def test_get_layer_data_tuples_ome_not_implemented_silent( + resources_dir: Path, + caplog: pytest.LogCaptureFixture, +): + """Test that NotImplementedError for ome_metadata is silently ignored. + + Some readers don't support OME metadata at all. This should be silently + ignored without logging. + """ + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + + # Mock ome_metadata to raise NotImplementedError + with mock.patch.object( + type(img), + 'ome_metadata', + new_callable=mock.PropertyMock, + side_effect=NotImplementedError( + 'Reader does not support OME metadata' + ), + ): + caplog.clear() + layer_tuples = img.get_layer_data_tuples() + + # Should still return valid layer tuples + assert layer_tuples is not None + assert len(layer_tuples) > 0 + + for _, meta, _ in layer_tuples: + assert 'ome_metadata' not in meta['metadata'] + + # No warning should be logged for NotImplementedError + assert len(caplog.records) == 0 + + +@pytest.mark.parametrize( + ('filename', 'should_work', 'expected_error_contains'), + [ + (LOGO_PNG, True, None), + (CELLS3D2CH_OME_TIFF, True, None), + (CZI_FILE, True, None), + (ND2_FILE, False, ['bioio-nd2', 'pip install']), + (RGB_TIFF, True, None), + ], +) +def test_nimage_init_with_various_formats( + resources_dir: Path, + filename: str, + should_work: bool | str, + expected_error_contains: list[str] | None, +): + """Test nImage initialization with various file formats. + + This tests the complete workflow: file → get_reader_priority → nImage init + """ + if should_work is True: + # Must successfully initialize + img = nImage(resources_dir / filename) + assert img.data is not None + assert img.path == str(resources_dir / filename) + elif should_work is False: + # Must fail with helpful error + with pytest.raises(UnsupportedFileFormatError) as exc_info: + nImage(resources_dir / filename) + + error_msg = str(exc_info.value) + if expected_error_contains: + for expected_text in expected_error_contains: + assert expected_text in error_msg + else: # "maybe" + # Can succeed or fail + try: + img = nImage(resources_dir / filename) + assert img.data is not None + except UnsupportedFileFormatError as e: + error_msg = str(e) + # Should contain at least one of the expected error texts + if expected_error_contains: + assert any( + text in error_msg for text in expected_error_contains + ) + + +# ============================================================================= +# Tests for get_layer_data_tuples +# ============================================================================= + + +class TestGetLayerDataTuples: + """Tests for nImage.get_layer_data_tuples method.""" + + def test_multichannel_returns_tuple_per_channel(self, resources_dir: Path): + """Test that multichannel images return one tuple per channel. + + The new API always splits channels, returning separate tuples for each. + """ + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + # Should return one tuple per channel (2 channels) + assert len(layer_tuples) == 2 + + for data, meta, layer_type in layer_tuples: + # channel_axis should NOT be in metadata (we split ourselves) + assert 'channel_axis' not in meta + + # name should be a string (not a list) + assert isinstance(meta['name'], str) + + # Data should be a list of arrays (multiscale-ready) + assert isinstance(data, list) + assert len(data) == 1 # single resolution level + # Shape should NOT include channel dimension + assert data[0].shape == (60, 66, 85) # ZYX only + + # Default layer type is "image" (channel names don't match label keywords) + assert layer_type == 'image' + + def test_layer_names_include_channel_names(self, resources_dir: Path): + """Test that layer names include channel names from the file.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + # Extract names from the tuples + names = [meta['name'] for _, meta, _ in layer_tuples] + + # Channel names from the file are "membrane" and "nuclei" + assert 'membrane' in names[0] + assert 'nuclei' in names[1] + + def test_layer_names_matches_tuple_names(self, resources_dir: Path): + """Test that layer_names property matches names in get_layer_data_tuples.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + # layer_names should match names baked into the tuples + assert img.layer_names == [meta['name'] for _, meta, _ in layer_tuples] + assert len(img.layer_names) == 2 + assert 'membrane' in img.layer_names[0] + assert 'nuclei' in img.layer_names[1] + + def test_layer_names_single_channel(self, resources_dir: Path): + """Test layer_names for a single-channel image.""" + img = nImage(resources_dir / LOGO_PNG) + assert len(img.layer_names) == 1 + assert img.layer_names[0].endswith(img.path_stem) + + def test_single_channel_image_returns_single_tuple( + self, resources_dir: Path + ): + """Test that single channel images return single tuple.""" + # PNG is single channel (or RGB treated as single layer) + img = nImage(resources_dir / LOGO_PNG) + layer_tuples = img.get_layer_data_tuples() + + # Single channel should return single tuple + assert len(layer_tuples) == 1 + + data, meta, layer_type = layer_tuples[0] + assert 'channel_axis' not in meta + assert layer_type == 'image' + + def test_scale_preserved_in_tuples(self, resources_dir: Path): + """Test that scale metadata is preserved in each tuple.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + for _, meta, _ in layer_tuples: + # Scale should be preserved in each layer + assert 'scale' in meta + # Original has physical pixel sizes, so scale should have values + assert len(meta['scale']) > 0 + + def test_colormap_cycling_for_images(self, resources_dir: Path): + """Test that image layers get colormaps based on napari's defaults. + + - 1 channel → gray + - 2 channels → magenta, green (MAGENTA_GREEN) + - 3+ channels → cycles through CYMRGB + """ + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples() + + # Extract colormaps from the tuples + colormaps = [meta.get('colormap') for _, meta, _ in layer_tuples] + + # 2 channels should use MAGENTA_GREEN + assert colormaps[0] == 'magenta' + assert colormaps[1] == 'green' + + def test_colormap_single_channel_is_gray(self, resources_dir: Path): + """Test that single channel images get gray colormap.""" + import numpy as np + import xarray as xr + + # Create nImage directly with single channel data (no Channel dimension) + mock_data = xr.DataArray( + np.zeros((10, 10)), + dims=['Y', 'X'], + ) + img = nImage(mock_data) + + layer_tuples = img.get_layer_data_tuples() + assert len(layer_tuples) == 1 + assert layer_tuples[0][1]['colormap'] == 'gray' + + def test_colormap_three_plus_channels_uses_multi_channel_cycle( + self, resources_dir: Path + ): + """Test that 3+ channel images cycle through MULTI_CHANNEL_CYCLE.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + from ndevio.utils._colormap_utils import MULTI_CHANNEL_CYCLE + + # Create nImage directly with 4 channel data + mock_data = xr.DataArray( + np.zeros((4, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['ch0', 'ch1', 'ch2', 'ch3']}, + ) + img = nImage(mock_data) + + layer_tuples = img.get_layer_data_tuples() + colormaps = [meta.get('colormap') for _, meta, _ in layer_tuples] + + # Should cycle through MULTI_CHANNEL_CYCLE (CMYBGR) + assert colormaps[0] == MULTI_CHANNEL_CYCLE[0] # cyan + assert colormaps[1] == MULTI_CHANNEL_CYCLE[1] # magenta + assert colormaps[2] == MULTI_CHANNEL_CYCLE[2] # yellow + assert colormaps[3] == MULTI_CHANNEL_CYCLE[3] # blue + + def test_auto_detect_labels_from_channel_name(self, resources_dir: Path): + """Test that channels with label-like names are detected as labels.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + # Create nImage directly with a channel named "mask" + mock_data = xr.DataArray( + np.zeros((2, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['intensity', 'mask']}, + ) + img = nImage(mock_data) + + layer_tuples = img.get_layer_data_tuples() + + # First channel "intensity" should be image + assert layer_tuples[0][2] == 'image' + # Second channel "mask" should be labels (keyword match) + assert layer_tuples[1][2] == 'labels' + + def test_channel_types_override_auto_detection(self, resources_dir: Path): + """Test that channel_types parameter overrides auto-detection.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + # Create nImage directly with mock data + mock_data = xr.DataArray( + np.zeros((2, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['intensity', 'mask']}, + ) + img = nImage(mock_data) + + # Override: set both channels to labels + layer_tuples = img.get_layer_data_tuples( + channel_types={'intensity': 'labels', 'mask': 'labels'} + ) + + # Both should be labels due to override + assert layer_tuples[0][2] == 'labels' + assert layer_tuples[1][2] == 'labels' + + def test_labels_do_not_get_colormap(self, resources_dir: Path): + """Test that labels layers don't get colormap metadata.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + # Create nImage directly with a labels channel + mock_data = xr.DataArray( + np.zeros((1, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['segmentation']}, + ) + img = nImage(mock_data) + + layer_tuples = img.get_layer_data_tuples() + + # "segmentation" matches label keyword + assert layer_tuples[0][2] == 'labels' + # Labels should not have colormap + assert 'colormap' not in layer_tuples[0][1] + + def test_layer_type_override_all_channels(self, resources_dir: Path): + """Test that layer_type parameter overrides all channels.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples(layer_type='labels') + + # All channels should be labels due to override + assert len(layer_tuples) == 2 + for _, meta, layer_type in layer_tuples: + assert layer_type == 'labels' + # Labels should not have colormap + assert 'colormap' not in meta + + def test_layer_type_overrides_channel_types(self, resources_dir: Path): + """Test that layer_type takes precedence over channel_types.""" + import numpy as np + import xarray as xr + from bioio_base.dimensions import DimensionNames + + # Create nImage directly with mock data + mock_data = xr.DataArray( + np.zeros((2, 10, 10)), + dims=[DimensionNames.Channel, 'Y', 'X'], + coords={DimensionNames.Channel: ['intensity', 'mask']}, + ) + img = nImage(mock_data) + + # Even though channel_types says "intensity" should be image, + # layer_type="labels" should override everything + layer_tuples = img.get_layer_data_tuples( + layer_type='labels', + channel_types={'intensity': 'image', 'mask': 'image'}, + ) + + # Both should be labels due to layer_type override + assert layer_tuples[0][2] == 'labels' + + def test_channel_kwargs_override_metadata(self, resources_dir: Path): + """Test that channel_kwargs overrides default metadata.""" + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + layer_tuples = img.get_layer_data_tuples( + channel_kwargs={ + img.channel_names[0]: { + 'colormap': 'blue', + 'contrast_limits': (0, 1000), + }, + img.channel_names[1]: { + 'opacity': 0.5, + }, + } + ) + + assert len(layer_tuples) == 2 + # First channel should have overridden colormap and contrast_limits + assert layer_tuples[0][1]['colormap'] == 'blue' + assert layer_tuples[0][1]['contrast_limits'] == (0, 1000) + # Second channel should have opacity override but default colormap + assert layer_tuples[1][1]['opacity'] == 0.5 + assert ( + layer_tuples[1][1]['colormap'] == 'green' + ) # default for 2-channel + + +class TestPreferredReaderFallback: + """Tests for preferred reader fallback logic in nImage.__init__.""" + + def test_preferred_reader_success(self, resources_dir: Path): + """Test that preferred reader is used when it works.""" + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + # Mock returning a valid reader + from bioio_tifffile import Reader + + mock_resolve.return_value = Reader + + img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) + + # Verify _resolve_reader was called + mock_resolve.assert_called_once() + assert img is not None + assert img.reader.name == 'bioio_tifffile' + + def test_preferred_reader_fallback(self, resources_dir: Path): + """Test that failed preferred reader will fallback""" + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + # Mock returning a reader that won't work for this file + from bioio_czi import Reader + + mock_resolve.return_value = Reader + + img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) + + # Verify _resolve_reader was called + mock_resolve.assert_called_once() + assert img is not None + # Should have fallen back to bioio's default (ome-tiff) + assert img.reader.name == 'bioio_ome_tiff' + + def test_no_preferred_reader_uses_default(self, resources_dir: Path): + """Test that no preferred reader uses bioio's default priority.""" + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + mock_resolve.return_value = None # No preferred reader + + img = nImage(str(resources_dir / 'cells3d2ch_legacy.tiff')) + assert img is not None + mock_resolve.assert_called_once() + assert img.reader.name == 'bioio_ome_tiff' + + +class TestResolveReaderFunction: + """Tests for _resolve_reader function.""" + + def test_returns_none_when_no_preferred_reader(self): + """Test returns None when preferred_reader is not set.""" + from ndevio.nimage import _resolve_reader + + with patch('ndev_settings.get_settings') as mock_get_settings: + mock_get_settings.return_value.ndevio_reader.preferred_reader = ( + None + ) + + result = _resolve_reader('test.tiff', None) + assert result is None + + def test_returns_none_when_preferred_not_installed(self): + """Test returns None when preferred reader is not installed.""" + from ndevio.nimage import _resolve_reader + + with ( + patch('ndev_settings.get_settings') as mock_get_settings, + patch( + 'ndevio.bioio_plugins._utils.get_installed_plugins', + return_value={'bioio-ome-tiff', 'bioio-tifffile'}, + ), + ): + mock_get_settings.return_value.ndevio_reader.preferred_reader = ( + 'bioio-czi' + ) + + result = _resolve_reader('test.tiff', None) + assert result is None + + def test_returns_reader_when_preferred_installed(self): + """Test returns reader class when preferred reader is installed.""" + from ndevio.nimage import _resolve_reader + + with ( + patch('ndev_settings.get_settings') as mock_get_settings, + patch( + 'ndevio.bioio_plugins._utils.get_installed_plugins', + return_value={'bioio-ome-tiff'}, + ), + patch( + 'ndevio.bioio_plugins._utils.get_reader_by_name' + ) as mock_get_reader, + ): + from bioio_ome_tiff import Reader as OmeTiffReader + + mock_get_reader.return_value = OmeTiffReader + mock_get_settings.return_value.ndevio_reader.preferred_reader = ( + 'bioio-ome-tiff' + ) + + result = _resolve_reader('test.tiff', None) + assert result == OmeTiffReader + mock_get_reader.assert_called_once_with('bioio-ome-tiff') + + def test_explicit_reader_bypasses_settings(self): + """Test that explicit reader bypasses settings lookup.""" + from bioio_tifffile import Reader as TifffileReader + + from ndevio.nimage import _resolve_reader + + with patch('ndev_settings.get_settings') as mock_get_settings: + result = _resolve_reader('test.tiff', TifffileReader) + + # Should return explicit reader without checking settings + assert result == TifffileReader + mock_get_settings.assert_not_called() + + def test_array_input_returns_none(self): + """Test that array inputs don't trigger preferred reader lookup.""" + import numpy as np + + from ndevio.nimage import _resolve_reader + + with patch('ndev_settings.get_settings') as mock_get_settings: + arr = np.zeros((10, 10), dtype=np.uint8) + result = _resolve_reader(arr, None) + + # Should return None without checking settings for arrays + assert result is None + mock_get_settings.assert_not_called() + + +class TestNonPathImageHandling: + """Tests for handling non-path inputs (arrays).""" + + def test_array_input_no_preferred_reader_check(self): + """Test that arrays don't trigger preferred reader logic.""" + import numpy as np + + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + # Create a simple array + arr = np.zeros((10, 10), dtype=np.uint8) + + # This should work + img = nImage(arr) + assert img is not None + + # _resolve_reader should have been called but returned None + mock_resolve.assert_called_once() + # First arg is the image, second is explicit_reader (None) + call_args = mock_resolve.call_args + assert call_args[0][1] is None # explicit_reader is None + + def test_unsupported_array_raises_without_suggestions(self): + """Test that unsupported arrays raise error without plugin suggestions.""" + # Create something that will fail + with pytest.raises(UnsupportedFileFormatError) as exc_info: + # Pass an invalid object + nImage('this_is_not_a_valid_input.fake') + + # Error should be raised but without custom suggestions since it's not a path + error_msg = str(exc_info.value) + assert ( + 'fake' in error_msg.lower() or 'unsupported' in error_msg.lower() + ) + + +class TestExplicitReaderParameter: + """Tests for when reader is explicitly provided.""" + + def test_explicit_reader_bypasses_preferred(self, resources_dir: Path): + """Test that explicit reader parameter bypasses preferred reader.""" + from bioio_tifffile import Reader as TifffileReader + + with patch('ndevio.nimage._resolve_reader') as mock_resolve: + mock_resolve.return_value = TifffileReader + + # Explicit reader should be used directly + img = nImage( + str(resources_dir / 'cells3d2ch_legacy.tiff'), + reader=TifffileReader, + ) + + assert img is not None + # _resolve_reader should return the explicit reader + mock_resolve.assert_called_once() + call_args = mock_resolve.call_args + assert call_args[0][1] == TifffileReader # explicit_reader + + def test_explicit_reader_fails_falls_back(self, resources_dir: Path): + """Test explicit reader that fails falls back to default.""" + from bioio_czi import Reader as CziReader + + # Use CZI reader on a TIFF file - it should fail and fall back + img = nImage( + str(resources_dir / 'cells3d2ch_legacy.tiff'), + reader=CziReader, + ) + + assert img is not None + # Should have fallen back to bioio's default + assert img.reader.name == 'bioio_ome_tiff' diff --git a/tests/test_sampledata/test_sampledata.py b/tests/test_sampledata/test_sampledata.py index 5685e88..41cb358 100644 --- a/tests/test_sampledata/test_sampledata.py +++ b/tests/test_sampledata/test_sampledata.py @@ -1,113 +1,113 @@ -"""Tests for ndevio.sampledata module.""" - -from __future__ import annotations - -import pytest - -from ndevio.sampledata._sample_data import ( - ndev_logo, - neocortex, - neuron_labels, - neuron_labels_processed, - neuron_raw, - scratch_assay, -) - - -def _validate_layer_data_tuples( - result: list, expected_layer_type: str | None = None -): - """Helper to validate LayerDataTuple structure. - - Parameters - ---------- - result : list - List of LayerDataTuple from sample data function - expected_layer_type : str, optional - If provided, assert all layers are this type - """ - assert isinstance(result, list) - assert len(result) > 0 - - for layer_tuple in result: - # LayerDataTuple is (data, kwargs, layer_type) - assert isinstance(layer_tuple, tuple) - assert len(layer_tuple) == 3 - - data, kwargs, layer_type = layer_tuple - - # Data should be a list of array-like objects (multiscale-ready) - assert isinstance(data, list) - assert len(data) >= 1 - assert hasattr(data[0], 'shape') - assert len(data[0].shape) >= 2 # At minimum 2D - - # kwargs should be a dict - assert isinstance(kwargs, dict) - - # layer_type should be a string - assert isinstance(layer_type, str) - assert layer_type in ('image', 'labels') - - if expected_layer_type: - assert layer_type == expected_layer_type - - -class TestLocalSampleData: - """Tests for sample data that loads from local files (no network).""" - - def test_ndev_logo(self): - """Test loading ndev logo returns valid LayerDataTuples.""" - result = ndev_logo() - _validate_layer_data_tuples(result, expected_layer_type='image') - # Logo should be a single image layer - assert len(result) == 1 - - def test_neuron_labels(self): - """Test loading neuron labels returns valid LayerDataTuples.""" - result = neuron_labels() - _validate_layer_data_tuples(result, expected_layer_type='labels') - # Should have 4 channels as separate label layers - assert len(result) == 4 - - def test_neuron_labels_processed(self): - """Test loading processed neuron labels returns valid LayerDataTuples.""" - result = neuron_labels_processed() - _validate_layer_data_tuples(result, expected_layer_type='labels') - # Should have 4 channels as separate label layers - assert len(result) == 4 - - -@pytest.mark.network -class TestNetworkSampleData: - """Tests for sample data that requires network download via pooch. - - These tests are marked with @pytest.mark.network and can be skipped - in CI environments without network access using: - pytest -m "not network" - """ - - def test_scratch_assay(self): - """Test loading scratch assay returns valid LayerDataTuples.""" - result = scratch_assay() - _validate_layer_data_tuples(result) - # Should have 4 layers: 2 images + 2 labels - assert len(result) == 4 - # Check we have both image and labels types - layer_types = [t[2] for t in result] - assert 'image' in layer_types - assert 'labels' in layer_types - - def test_neocortex(self): - """Test loading neocortex returns valid LayerDataTuples.""" - result = neocortex() - _validate_layer_data_tuples(result, expected_layer_type='image') - # Should have 3 channels as separate image layers - assert len(result) == 3 - - def test_neuron_raw(self): - """Test loading neuron raw returns valid LayerDataTuples.""" - result = neuron_raw() - _validate_layer_data_tuples(result, expected_layer_type='image') - # Should have 4 channels as separate image layers - assert len(result) == 4 +"""Tests for ndevio.sampledata module.""" + +from __future__ import annotations + +import pytest + +from ndevio.sampledata._sample_data import ( + ndev_logo, + neocortex, + neuron_labels, + neuron_labels_processed, + neuron_raw, + scratch_assay, +) + + +def _validate_layer_data_tuples( + result: list, expected_layer_type: str | None = None +): + """Helper to validate LayerDataTuple structure. + + Parameters + ---------- + result : list + List of LayerDataTuple from sample data function + expected_layer_type : str, optional + If provided, assert all layers are this type + """ + assert isinstance(result, list) + assert len(result) > 0 + + for layer_tuple in result: + # LayerDataTuple is (data, kwargs, layer_type) + assert isinstance(layer_tuple, tuple) + assert len(layer_tuple) == 3 + + data, kwargs, layer_type = layer_tuple + + # Data should be a list of array-like objects (multiscale-ready) + assert isinstance(data, list) + assert len(data) >= 1 + assert hasattr(data[0], 'shape') + assert len(data[0].shape) >= 2 # At minimum 2D + + # kwargs should be a dict + assert isinstance(kwargs, dict) + + # layer_type should be a string + assert isinstance(layer_type, str) + assert layer_type in ('image', 'labels') + + if expected_layer_type: + assert layer_type == expected_layer_type + + +class TestLocalSampleData: + """Tests for sample data that loads from local files (no network).""" + + def test_ndev_logo(self): + """Test loading ndev logo returns valid LayerDataTuples.""" + result = ndev_logo() + _validate_layer_data_tuples(result, expected_layer_type='image') + # Logo should be a single image layer + assert len(result) == 1 + + def test_neuron_labels(self): + """Test loading neuron labels returns valid LayerDataTuples.""" + result = neuron_labels() + _validate_layer_data_tuples(result, expected_layer_type='labels') + # Should have 4 channels as separate label layers + assert len(result) == 4 + + def test_neuron_labels_processed(self): + """Test loading processed neuron labels returns valid LayerDataTuples.""" + result = neuron_labels_processed() + _validate_layer_data_tuples(result, expected_layer_type='labels') + # Should have 4 channels as separate label layers + assert len(result) == 4 + + +@pytest.mark.network +class TestNetworkSampleData: + """Tests for sample data that requires network download via pooch. + + These tests are marked with @pytest.mark.network and can be skipped + in CI environments without network access using: + pytest -m "not network" + """ + + def test_scratch_assay(self): + """Test loading scratch assay returns valid LayerDataTuples.""" + result = scratch_assay() + _validate_layer_data_tuples(result) + # Should have 4 layers: 2 images + 2 labels + assert len(result) == 4 + # Check we have both image and labels types + layer_types = [t[2] for t in result] + assert 'image' in layer_types + assert 'labels' in layer_types + + def test_neocortex(self): + """Test loading neocortex returns valid LayerDataTuples.""" + result = neocortex() + _validate_layer_data_tuples(result, expected_layer_type='image') + # Should have 3 channels as separate image layers + assert len(result) == 3 + + def test_neuron_raw(self): + """Test loading neuron raw returns valid LayerDataTuples.""" + result = neuron_raw() + _validate_layer_data_tuples(result, expected_layer_type='image') + # Should have 4 channels as separate image layers + assert len(result) == 4 diff --git a/tests/test_widgets/test_plugin_installer_widget.py b/tests/test_widgets/test_plugin_installer_widget.py index 9c24743..0889058 100644 --- a/tests/test_widgets/test_plugin_installer_widget.py +++ b/tests/test_widgets/test_plugin_installer_widget.py @@ -1,112 +1,112 @@ -"""Tests for PluginInstallerWidget. - -This module tests: -- PluginInstallerWidget behavior (unit tests, no viewer needed) -- _open_plugin_installer integration with napari viewer (needs viewer) -""" - -from pathlib import Path -from unittest.mock import patch - -import pytest - - -class TestPluginInstallerWidget: - """Tests for PluginInstallerWidget behavior.""" - - def test_standalone_mode(self): - """Test widget in standalone mode - no path, shows generic title.""" - from ndevio.widgets._plugin_install_widget import PluginInstallerWidget - - widget = PluginInstallerWidget() - - # Standalone mode: no path, generic title - assert widget.manager.path is None - assert 'Install BioIO Reader Plugin' in widget._title_label.value - - def test_error_mode_with_path(self): - """Test widget in error mode - has path, preselects suggested plugin.""" - from ndevio.bioio_plugins._manager import ReaderPluginManager - from ndevio.widgets._plugin_install_widget import PluginInstallerWidget - - # Mock installed plugins to NOT include bioio-czi - # This simulates the error case where file can't be read - with patch( - 'ndevio.bioio_plugins._manager.get_installed_plugins', - return_value={'bioio-ome-tiff'}, - ): - manager = ReaderPluginManager('test.czi') - widget = PluginInstallerWidget(plugin_manager=manager) - - # Error mode: has path, shows filename, preselects installable plugin - assert 'test.czi' in widget._title_label.value - assert 'bioio-czi' in manager.suggested_plugins - # bioio-czi should be in installable since it's not installed - assert 'bioio-czi' in manager.installable_plugins - # Value should be set to first installable plugin - assert widget._plugin_select.value is not None - - def test_install_button_behavior(self): - """Test install button: queues installation and updates status.""" - from ndevio.widgets._plugin_install_widget import PluginInstallerWidget - - widget = PluginInstallerWidget() - widget._plugin_select.value = 'bioio-imageio' - - with patch( - 'ndevio.bioio_plugins._installer.install_plugin' - ) as mock_install: - mock_install.return_value = 123 - widget._on_install_clicked() - - mock_install.assert_called_once_with('bioio-imageio') - assert 'Installing' in widget._status_label.value - - def test_install_without_selection_shows_error(self): - """Test that clicking install with no selection shows error.""" - from ndevio.widgets._plugin_install_widget import PluginInstallerWidget - - widget = PluginInstallerWidget() - widget._plugin_select.value = None - - widget._on_install_clicked() - - assert 'No plugin selected' in widget._status_label.value - - -class TestOpenPluginInstallerIntegration: - """Integration tests for _open_plugin_installer with napari viewer.""" - - @pytest.fixture - def viewer_with_plugin_installer(self, make_napari_viewer): - """Fixture that creates viewer and opens plugin installer for .czi.""" - import ndevio._napari_reader as reader_module - - viewer = make_napari_viewer() - test_path = Path('path/to/test.czi') - - reader_module._open_plugin_installer(test_path) - - # Find the widget - widget = None - for name, w in viewer.window.dock_widgets.items(): - if 'Install BioIO Plugin' in name: - widget = w - break - - return viewer, widget, test_path - - def test_docks_widget_with_correct_state( - self, viewer_with_plugin_installer - ): - """Test that _open_plugin_installer docks widget with correct state.""" - viewer, widget, test_path = viewer_with_plugin_installer - - # Widget is docked - assert len(viewer.window.dock_widgets) > 0 - assert widget is not None - - # Widget has correct path and suggestions - assert widget.manager.path == test_path - assert test_path.name in widget._title_label.value - assert 'bioio-czi' in widget.manager.suggested_plugins +"""Tests for PluginInstallerWidget. + +This module tests: +- PluginInstallerWidget behavior (unit tests, no viewer needed) +- _open_plugin_installer integration with napari viewer (needs viewer) +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + + +class TestPluginInstallerWidget: + """Tests for PluginInstallerWidget behavior.""" + + def test_standalone_mode(self): + """Test widget in standalone mode - no path, shows generic title.""" + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget + + widget = PluginInstallerWidget() + + # Standalone mode: no path, generic title + assert widget.manager.path is None + assert 'Install BioIO Reader Plugin' in widget._title_label.value + + def test_error_mode_with_path(self): + """Test widget in error mode - has path, preselects suggested plugin.""" + from ndevio.bioio_plugins._manager import ReaderPluginManager + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget + + # Mock installed plugins to NOT include bioio-czi + # This simulates the error case where file can't be read + with patch( + 'ndevio.bioio_plugins._manager.get_installed_plugins', + return_value={'bioio-ome-tiff'}, + ): + manager = ReaderPluginManager('test.czi') + widget = PluginInstallerWidget(plugin_manager=manager) + + # Error mode: has path, shows filename, preselects installable plugin + assert 'test.czi' in widget._title_label.value + assert 'bioio-czi' in manager.suggested_plugins + # bioio-czi should be in installable since it's not installed + assert 'bioio-czi' in manager.installable_plugins + # Value should be set to first installable plugin + assert widget._plugin_select.value is not None + + def test_install_button_behavior(self): + """Test install button: queues installation and updates status.""" + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget + + widget = PluginInstallerWidget() + widget._plugin_select.value = 'bioio-imageio' + + with patch( + 'ndevio.bioio_plugins._installer.install_plugin' + ) as mock_install: + mock_install.return_value = 123 + widget._on_install_clicked() + + mock_install.assert_called_once_with('bioio-imageio') + assert 'Installing' in widget._status_label.value + + def test_install_without_selection_shows_error(self): + """Test that clicking install with no selection shows error.""" + from ndevio.widgets._plugin_install_widget import PluginInstallerWidget + + widget = PluginInstallerWidget() + widget._plugin_select.value = None + + widget._on_install_clicked() + + assert 'No plugin selected' in widget._status_label.value + + +class TestOpenPluginInstallerIntegration: + """Integration tests for _open_plugin_installer with napari viewer.""" + + @pytest.fixture + def viewer_with_plugin_installer(self, make_napari_viewer): + """Fixture that creates viewer and opens plugin installer for .czi.""" + import ndevio._napari_reader as reader_module + + viewer = make_napari_viewer() + test_path = Path('path/to/test.czi') + + reader_module._open_plugin_installer(test_path) + + # Find the widget + widget = None + for name, w in viewer.window.dock_widgets.items(): + if 'Install BioIO Plugin' in name: + widget = w + break + + return viewer, widget, test_path + + def test_docks_widget_with_correct_state( + self, viewer_with_plugin_installer + ): + """Test that _open_plugin_installer docks widget with correct state.""" + viewer, widget, test_path = viewer_with_plugin_installer + + # Widget is docked + assert len(viewer.window.dock_widgets) > 0 + assert widget is not None + + # Widget has correct path and suggestions + assert widget.manager.path == test_path + assert test_path.name in widget._title_label.value + assert 'bioio-czi' in widget.manager.suggested_plugins