diff --git a/.gitignore b/.gitignore index e6e34d5..b803bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,8 @@ venv/ # 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 diff --git a/pyproject.toml b/pyproject.toml index df8d52c..6907ae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "bioio-imageio", "zarr>=3.1.3", # tifffile's ZarrTiffStore needs set_partial_values/supports_partial_writes "napari-plugin-manager>=0.1.7", # Renamed to PYPI from PIP for tool selection https://github.com/napari/napari-plugin-manager/pull/176 + "pooch", # for downloading sample data from remote URLs ] [project.optional-dependencies] @@ -87,6 +88,9 @@ xfail_strict = true # filterwarnings = ["error"] # recommended by PP309 log_cli_level = "INFO" testpaths = ["tests"] +markers = [ + "network: marks tests as requiring network access (deselect with '-m \"not network\"')", +] [tool.coverage] report.fail_under = 80 diff --git a/src/ndevio/napari.yaml b/src/ndevio/napari.yaml index a6befca..1ccbe41 100644 --- a/src/ndevio/napari.yaml +++ b/src/ndevio/napari.yaml @@ -3,7 +3,7 @@ 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: [] +categories: ["IO", "Dataset", "Utilities"] contributions: commands: - id: ndevio.get_reader @@ -15,6 +15,24 @@ contributions: - 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: false @@ -58,3 +76,22 @@ contributions: 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 023cec2..e8268ac 100644 --- a/src/ndevio/nimage.py +++ b/src/ndevio/nimage.py @@ -373,7 +373,9 @@ def _build_single_layer_tuple( channel_idx, n_channels ) meta["blending"] = ( - "additive" if n_channels > 1 else "translucent_no_depth" + "additive" + if channel_idx > 0 and n_channels > 1 + else "translucent_no_depth" ) # Apply per-channel overrides diff --git a/src/ndevio/sampledata/__init__.py b/src/ndevio/sampledata/__init__.py new file mode 100644 index 0000000..4b5f823 --- /dev/null +++ b/src/ndevio/sampledata/__init__.py @@ -0,0 +1,19 @@ +"""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", +] diff --git a/src/ndevio/sampledata/_sample_data.py b/src/ndevio/sampledata/_sample_data.py new file mode 100644 index 0000000..ba38257 --- /dev/null +++ b/src/ndevio/sampledata/_sample_data.py @@ -0,0 +1,116 @@ +""" +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( + in_memory=True, + 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(in_memory=True) + + +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( + in_memory=True, + 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( + in_memory=True, + 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( + in_memory=True, + layer_type="labels", + ) diff --git a/src/ndevio/sampledata/data/ndev-logo.png b/src/ndevio/sampledata/data/ndev-logo.png new file mode 100644 index 0000000..0cee921 Binary files /dev/null and b/src/ndevio/sampledata/data/ndev-logo.png differ diff --git a/src/ndevio/sampledata/data/neuron-4Ch_labels.tiff b/src/ndevio/sampledata/data/neuron-4Ch_labels.tiff new file mode 100644 index 0000000..57f53bd Binary files /dev/null and b/src/ndevio/sampledata/data/neuron-4Ch_labels.tiff differ diff --git a/src/ndevio/sampledata/data/neuron-4Ch_labels_processed.tiff b/src/ndevio/sampledata/data/neuron-4Ch_labels_processed.tiff new file mode 100644 index 0000000..55e377d Binary files /dev/null and b/src/ndevio/sampledata/data/neuron-4Ch_labels_processed.tiff differ diff --git a/src/ndevio/widgets/_utilities_container.py b/src/ndevio/widgets/_utilities_container.py index b93dde2..518a9f8 100644 --- a/src/ndevio/widgets/_utilities_container.py +++ b/src/ndevio/widgets/_utilities_container.py @@ -321,7 +321,7 @@ def _on_batch_complete(self): self._set_batch_button_state(running=False) self._results.value = ( "Batch concatenated files in directory." - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) def _on_batch_error(self, ctx, exception): @@ -702,7 +702,7 @@ def update_metadata_from_layer(self): except AttributeError: self._results.value = ( "Tried to update metadata, but no layer selected." - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) except KeyError: scale = selected_layer.scale @@ -714,7 +714,7 @@ def update_metadata_from_layer(self): self._results.value = ( "Tried to update metadata, but could only update scale" " because layer not opened with ndevio reader." - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) def open_images(self): @@ -855,7 +855,7 @@ def _on_concat_complete(self, save_path: Path) -> None: self._progress_bar.value = 1 self._results.value = ( f"Saved Concatenated Image: {save_path.name}" - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) def _on_concat_error(self, exception: Exception) -> None: @@ -865,7 +865,7 @@ def _on_concat_error(self, exception: Exception) -> None: self._progress_bar.value = 0 self._results.value = ( f"Error concatenating files: {exception}" - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) def _build_file_sets(self) -> list[tuple[list[Path], str]]: @@ -899,7 +899,7 @@ def batch_concatenate_files(self) -> None: if not file_sets: self._results.value = ( - f'No complete file sets found.\nAt {time.strftime("%H:%M:%S")}' + f"No complete file sets found.\nAt {time.strftime('%H:%M:%S')}" ) return @@ -974,7 +974,7 @@ def _on_scene_extracted(self, result: tuple[int, str]) -> None: self._progress_bar.value = self._progress_bar.value + 1 self._results.value = ( f"Extracted scene {scene_idx}: {scene_name}" - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) def _on_scenes_complete(self, scenes_list: list, _=None) -> None: @@ -982,7 +982,7 @@ def _on_scenes_complete(self, scenes_list: list, _=None) -> None: self._progress_bar.label = "" self._results.value = ( f"Saved extracted scenes: {scenes_list}" - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) def _on_scene_error(self, exc: Exception) -> None: @@ -991,7 +991,7 @@ def _on_scene_error(self, exc: Exception) -> None: self._progress_bar.max = 1 self._progress_bar.value = 0 self._results.value = ( - f'Error extracting scenes: {exc}\nAt {time.strftime("%H:%M:%S")}' + f"Error extracting scenes: {exc}\nAt {time.strftime('%H:%M:%S')}" ) def canvas_export_figure(self) -> None: @@ -1000,7 +1000,7 @@ def canvas_export_figure(self) -> None: self._results.value = ( "Exporting Figure only works in 2D mode." "\nUse Screenshot for 3D figures." - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) return @@ -1022,7 +1022,7 @@ def canvas_export_figure(self) -> None: f"Exported canvas figure to Figures directory." f"\nSaved as {save_name}" f"\nWith scale factor of {scale}" - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) return @@ -1051,7 +1051,7 @@ def canvas_screenshot(self) -> None: f"\nSaved as {save_name}" f"\nWith canvas dimensions of {canvas_size}" f"\nWith scale factor of {scale}" - f'\nAt {time.strftime("%H:%M:%S")}' + f"\nAt {time.strftime('%H:%M:%S')}" ) return @@ -1118,7 +1118,7 @@ def _on_layer_save_complete(self, result: None = None) -> None: self._results.value = ( f"Saved {self._layer_save_type}: " + str(self._save_name.value) - + f'\nAt {time.strftime("%H:%M:%S")}' + + f"\nAt {time.strftime('%H:%M:%S')}" ) def _on_layer_save_error(self, exc: Exception) -> None: @@ -1127,5 +1127,5 @@ def _on_layer_save_error(self, exc: Exception) -> None: self._progress_bar.max = 1 self._progress_bar.value = 0 self._results.value = ( - f'Error saving layers: {exc}\nAt {time.strftime("%H:%M:%S")}' + f"Error saving layers: {exc}\nAt {time.strftime('%H:%M:%S')}" ) diff --git a/tests/test_sampledata.py b/tests/test_sampledata.py new file mode 100644 index 0000000..33c9aba --- /dev/null +++ b/tests/test_sampledata.py @@ -0,0 +1,111 @@ +"""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 array-like with shape + assert hasattr(data, "shape") + assert len(data.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