Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
wm: herbstluftwm

- name: Run tests
run: uv run --dev pytest -v --color=yes -m "not network" --cov=ndevio --cov-report=xml
run: uv run --dev pytest -v --color=yes --cov=ndevio --cov-report=xml

- name: Coverage
uses: codecov/codecov-action@v5
Expand Down
67 changes: 57 additions & 10 deletions src/ndevio/nimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,12 @@ class nImage(BioImage):

Attributes
----------
path : Path | None
Path to the source file, or None if created from array data.
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
--------
Expand All @@ -120,7 +124,8 @@ class nImage(BioImage):
"""

# Class-level type hints for instance attributes
path: Path | None
path: str | None
_is_remote: bool
_layer_data: xr.DataArray | None

def __init__(
Expand Down Expand Up @@ -156,7 +161,24 @@ def __init__(

# Instance state
self._layer_data = None
self.path = Path(image) if isinstance(image, str | Path) else 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

@property
def layer_data(self) -> xr.DataArray:
Expand All @@ -179,13 +201,39 @@ def layer_data(self) -> xr.DataArray:

"""
if self._layer_data is None:
in_memory = determine_in_memory(self.path)
if in_memory:
self._layer_data = self.xarray_data.squeeze()
else:
if self._is_remote or not determine_in_memory(self.path):
self._layer_data = self.xarray_dask_data.squeeze()
else:
self._layer_data = self.xarray_data.squeeze()
return self._layer_data

@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_scale(self) -> tuple[float, ...]:
"""Physical scale for dimensions in layer data.
Expand Down Expand Up @@ -334,8 +382,7 @@ def _build_layer_name(self, channel_name: str | None = None) -> str:
if len(self.scenes) > 1 or self.current_scene != 'Image:0':
parts.extend([str(self.current_scene_index), self.current_scene])

path_stem = self.path.stem if self.path is not None else 'unknown path'
parts.append(path_stem)
parts.append(self.path_stem)

return delim.join(parts)

Expand Down
7 changes: 3 additions & 4 deletions src/ndevio/utils/_layer_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
Expand Down Expand Up @@ -109,16 +108,16 @@ def resolve_layer_type(


def determine_in_memory(
path: Path | None,
path: str | None,
max_in_mem_bytes: float = 4e9,
max_in_mem_percent: float = 0.3,
) -> bool:
"""Determine whether to load image data in memory or as dask array.

Parameters
----------
path : Path | None
Path to the image file. If None (array data), returns True.
path : str | None
Path to the image file as a string. If None (array data), returns True.
max_in_mem_bytes : float
Maximum file size in bytes for in-memory loading.
Default is 4 GB (4e9 bytes).
Expand Down
10 changes: 10 additions & 0 deletions tests/test_napari_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
PNG_FILE = 'nDev-logo-small.png'
ND2_FILE = 'ND2_dims_rgb.nd2'
OME_TIFF = 'cells3d2ch_legacy.tiff'
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

###############################################################################

Expand Down Expand Up @@ -48,6 +49,15 @@ def test_napari_viewer_open_directory(
assert viewer.layers[0].data.shape == (2, 4, 4)


@pytest.mark.network
def test_napari_viewer_open_remote(make_napari_viewer) -> None:
viewer = make_napari_viewer()
viewer.open(REMOTE_ZARR, plugin='ndevio')

Comment thread
TimMonko marked this conversation as resolved.
assert len(viewer.layers) == 2
assert viewer.layers[0].data.shape == (512, 512)


@pytest.mark.parametrize(
(
'filename',
Expand Down
17 changes: 14 additions & 3 deletions tests/test_nimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
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 == 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)
Expand All @@ -38,10 +38,21 @@ 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 == resources_dir / ZARR
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.layer_data.shape == (2, 512, 512)


def test_nImage_ome_reader(resources_dir: Path):
"""
Test that the OME-TIFF reader is used for OME-TIFF files.
Expand Down Expand Up @@ -224,7 +235,7 @@ def test_nimage_init_with_various_formats(
# Must successfully initialize
img = nImage(resources_dir / filename)
assert img.data is not None
assert img.path == resources_dir / filename
assert img.path == str(resources_dir / filename)
elif should_work is False:
# Must fail with helpful error
with pytest.raises(UnsupportedFileFormatError) as exc_info:
Expand Down