diff --git a/.copier-answers.yml b/.copier-answers.yml index 80053eb..c27b77f 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -4,7 +4,7 @@ _src_path: https://github.com/napari/napari-plugin-template display_name: Cotcotcot email: romain.guiet@epfl.ch full_name: Romain Guiet -github_repository_url: provide later +github_repository_url: https://github.com/BIOP/napari-cotcotcot github_username_or_organization: romainGuiet include_reader_plugin: true include_sample_data_plugin: true diff --git a/pyproject.toml b/pyproject.toml index 605d781..031c831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,8 @@ dependencies = [ "pandas", "tqdm", "tifffile", - "napari", # Add this since your plugin is for napari - "torch", # See note below about PyTorch + "napari", + "torch", ] [project.optional-dependencies] @@ -56,7 +56,7 @@ dev = [ [project.entry-points."napari.manifest"] napari-cotcotcot = "napari_cotcotcot:napari.yaml" -# to enable command line interface +# to enable command line interface, starting rom terminal with naparicot [project.scripts] naparicot = "napari_cotcotcot:main" @@ -72,6 +72,8 @@ where = ["src"] [tool.setuptools.package-data] "*" = ["*.yaml"] +# to enable package data inclusion +"napari_cotcotcot" = ["/data/Gallus_gallus_domesticus/chicken-run.gif" , "/data/Gallus_gallus_domesticus/seed_Chicken1.csv" ] [tool.setuptools_scm] write_to = "src/napari_cotcotcot/_version.py" diff --git a/src/napari_cotcotcot/_reader.py b/src/napari_cotcotcot/_reader.py deleted file mode 100644 index 067961f..0000000 --- a/src/napari_cotcotcot/_reader.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -This module is an example of a barebones numpy reader plugin for napari. - -It implements the Reader specification, but your plugin may choose to -implement multiple readers or even other plugin contributions. see: -https://napari.org/stable/plugins/building_a_plugin/guides.html#readers -""" - -import numpy as np - - -def napari_get_reader(path): - """A basic implementation of a Reader contribution. - - Parameters - ---------- - path : str or list of str - Path to file, or list of paths. - - Returns - ------- - function or None - If the path is a recognized format, return a function that accepts the - same path or list of paths, and returns a list of layer data tuples. - """ - if isinstance(path, list): - # reader plugins may be handed single path, or a list of paths. - # if it is a list, it is assumed to be an image stack... - # so we are only going to look at the first file. - path = path[0] - - # if we know we cannot read the file, we immediately return None. - if not path.endswith(".npy"): - return None - - # otherwise we return the *function* that can read ``path``. - return reader_function - - -def reader_function(path): - """Take a path or list of paths and return a list of LayerData tuples. - - Readers are expected to return data as a list of tuples, where each tuple - is (data, [add_kwargs, [layer_type]]), "add_kwargs" and "layer_type" are - both optional. - - Parameters - ---------- - path : str or list of str - Path to file, or list of paths. - - Returns - ------- - layer_data : list of tuples - A list of LayerData tuples where each tuple in the list contains - (data, metadata, layer_type), where data is a numpy array, metadata is - a dict of keyword arguments for the corresponding viewer.add_* method - in napari, and layer_type is a lower-case string naming the type of - layer. Both "meta", and "layer_type" are optional. napari will - default to layer_type=="image" if not provided - """ - # handle both a string and a list of strings - paths = [path] if isinstance(path, str) else path - # load all files into array - arrays = [np.load(_path) for _path in paths] - # stack arrays into single array - data = np.squeeze(np.stack(arrays)) - - # optional kwargs for the corresponding viewer.add_* method - add_kwargs = {} - - layer_type = "image" # optional, default is "image" - return [(data, add_kwargs, layer_type)] diff --git a/src/napari_cotcotcot/_sample_data.py b/src/napari_cotcotcot/_sample_data.py index d5ef8a4..dee0f98 100644 --- a/src/napari_cotcotcot/_sample_data.py +++ b/src/napari_cotcotcot/_sample_data.py @@ -1,22 +1,85 @@ -""" -This module is an example of a barebones sample data provider for napari. -It implements the "sample data" specification. -see: https://napari.org/stable/plugins/building_a_plugin/guides.html#sample-data +from pathlib import Path +from typing import List -Replace code below according to your needs. -""" +import numpy as np +import pandas as pd +from skimage.io import imread +from napari.types import LayerData -from __future__ import annotations +def _load_seed_from_csv_standalone(csv_path: str) -> np.ndarray: + """ + Load seed points from a CSV file. -import numpy + Parameters + ---------- + csv_path : str + Path to the CSV file containing seed point coordinates. + Returns + ------- + np.ndarray + Array of shape (n_points, 3) containing seed point coordinates. -def make_sample_data(): - """Generates an image""" - # Return list of tuples - # [(data1, add_image_kwargs1), (data2, add_image_kwargs2)] - # Check the documentation for more information about the - # add_image_kwargs - # https://napari.org/stable/api/napari.Viewer.html#napari.Viewer.add_image - return [(numpy.random.rand(512, 512), {})] + Raises + ------ + ValueError + If the CSV format is invalid (missing required columns). + """ + df = pd.read_csv(csv_path) + + required_cols = ["axis-0", "axis-1", "axis-2"] + if not all(col in df.columns for col in required_cols): + if all(col in df.columns for col in ["t", "y", "x"]): + df = df.rename(columns={"t": "axis-0", "y": "axis-1", "x": "axis-2"}) + else: + raise ValueError("CSV must contain columns: axis-0, axis-1, axis-2 (or t, y, x)") + + return df[["axis-0", "axis-1", "axis-2"]].values + +def load_sample_data() -> List[LayerData]: + """ + Loads sample data for demonstration in napari. + + This function loads: + - A GIF image ("chicken-run.gif") from a remote URL: + https://raw.githubusercontent.com/BIOP/napari-cotcotcot/refs/heads/main/src/napari_cotcotcot/data/Gallus_gallus_domesticus/chicken-run.gif + - Seed points from a local CSV file ("seed_Chicken1.csv") if available at: + /data/Gallus_gallus_domesticus/seed_Chicken1.csv + + Returns + ------- + List[LayerData] + A list of tuples (data, metadata, layer_type) suitable for napari, where: + - The first tuple contains the image data, metadata with name "cotcotcot", and layer_type "image". + - The second tuple (if CSV is found and loaded) contains the seed points, metadata with name "seed_Chicken1", and layer_type "points". + + Data Sources + ----------- + - GIF image: downloaded from the above URL. + - Seed points: loaded from a local CSV file. + + Exceptions + ---------- + - If the CSV file is missing, empty, or malformed, an error is printed and only the image is returned. + - If the GIF image cannot be downloaded or read, an exception may be raised by skimage.io.imread. + """ + URL = "https://raw.githubusercontent.com/BIOP/napari-cotcotcot/refs/heads/main/src/napari_cotcotcot/data/Gallus_gallus_domesticus/chicken-run.gif" + print(f"Downloading sample data from {URL}...") + try: + gif_data = imread(URL) + print("Downloading completed!") + sample_datasets = [(gif_data, {"name": "cotcotcot"}, "image")] + except (IOError, ValueError, Exception) as e: + print(f"Error downloading or reading image from {URL}: {e}") + sample_datasets = [] + # Load CSV + csv_data_path = Path(__file__).parent / "data" / "Gallus_gallus_domesticus" / "seed_Chicken1.csv" + if csv_data_path.exists(): + try: + csv_data = _load_seed_from_csv_standalone(str(csv_data_path)) + sample_datasets.append((csv_data, {"name": "seed_Chicken1"}, "points")) + except (FileNotFoundError, ValueError, pd.errors.EmptyDataError, pd.errors.ParserError) as e: + print(f"Error loading CSV data: {e}") + + return sample_datasets diff --git a/src/napari_cotcotcot/_widget.py b/src/napari_cotcotcot/_widget.py index 2bac8f0..dc05609 100644 --- a/src/napari_cotcotcot/_widget.py +++ b/src/napari_cotcotcot/_widget.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING - +import napari import numpy as np import pandas as pd from magicgui.widgets import ( @@ -292,6 +292,24 @@ def __init__( # Auto-initialize if we have an image if self._get_image_layers(): self.image_selector.value = self._get_image_layers()[0] + + # Auto-initialize seed manager if seed layers are already present + self._check_and_auto_initialize() + + def _check_and_auto_initialize(self): + """Check for existing seed layers and auto-initialize if found.""" + if self.seed_manager is None: + seed_like_layers = [ + layer.name for layer in self.viewer.layers + if isinstance(layer, napari.layers.Points) and + (layer.name.startswith("seed_") or "seed" in layer.name.lower()) + ] + + if seed_like_layers: + print(f"Auto-initializing seed manager on startup due to detected seed layers: {seed_like_layers}") + self._ensure_seed_manager_initialized() + self._refresh_seed_list() + self._check_enable_tracking() def _pick_custom_color(self): """Open color picker dialog for custom color selection.""" @@ -389,6 +407,20 @@ def _disconnect_seed_layer_events(self, layer_name): def _on_layer_change(self, event): """Handle layer changes in viewer.""" self.image_selector.choices = self._get_image_layers() + + # Auto-initialize seed manager if we detect seed layers but manager is None + if self.seed_manager is None: + # Check if there are any layers that look like seed layers + seed_like_layers = [ + layer.name for layer in self.viewer.layers + if isinstance(layer, napari.layers.Points) and + (layer.name.startswith("seed_") or "seed" in layer.name.lower()) + ] + + if seed_like_layers: + print(f"Auto-initializing seed manager due to detected seed layers: {seed_like_layers}") + self._ensure_seed_manager_initialized() + if self.seed_manager: self._refresh_seed_list() # Check if tracking buttons should be enabled diff --git a/src/napari_cotcotcot/_writer.py b/src/napari_cotcotcot/_writer.py deleted file mode 100644 index 9741189..0000000 --- a/src/napari_cotcotcot/_writer.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -This module is an example of a barebones writer plugin for napari. - -It implements the Writer specification. -see: https://napari.org/stable/plugins/building_a_plugin/guides.html#writers - -Replace code below according to your needs. -""" - -from __future__ import annotations - -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Union - -if TYPE_CHECKING: - DataType = Union[Any, Sequence[Any]] - FullLayerData = tuple[DataType, dict, str] - - -def write_single_image(path: str, data: Any, meta: dict) -> list[str]: - """Writes a single image layer. - - Parameters - ---------- - path : str - A string path indicating where to save the image file. - data : The layer data - The `.data` attribute from the napari layer. - meta : dict - A dictionary containing all other attributes from the napari layer - (excluding the `.data` layer attribute). - - Returns - ------- - [path] : A list containing the string path to the saved file. - """ - - # implement your writer logic here ... - - # return path to any file(s) that were successfully written - return [path] - - -def write_multiple(path: str, data: list[FullLayerData]) -> list[str]: - """Writes multiple layers of different types. - - Parameters - ---------- - path : str - A string path indicating where to save the data file(s). - data : A list of layer tuples. - Tuples contain three elements: (data, meta, layer_type) - `data` is the layer data - `meta` is a dictionary containing all other metadata attributes - from the napari layer (excluding the `.data` layer attribute). - `layer_type` is a string, eg: "image", "labels", "surface", etc. - - Returns - ------- - [path] : A list containing (potentially multiple) string paths to the saved file(s). - """ - - # implement your writer logic here ... - - # return path to any file(s) that were successfully written - return [path] diff --git a/src/napari_cotcotcot/data/Gallus_gallus_domesticus/cotcotcot_output.gif b/src/napari_cotcotcot/data/Gallus_gallus_domesticus/cotcotcot_output.gif new file mode 100644 index 0000000..10d5a99 Binary files /dev/null and b/src/napari_cotcotcot/data/Gallus_gallus_domesticus/cotcotcot_output.gif differ diff --git a/src/napari_cotcotcot/data/Gallus_gallus_domesticus/seed_Chicken1.csv b/src/napari_cotcotcot/data/Gallus_gallus_domesticus/seed_Chicken1.csv new file mode 100644 index 0000000..385d008 --- /dev/null +++ b/src/napari_cotcotcot/data/Gallus_gallus_domesticus/seed_Chicken1.csv @@ -0,0 +1,5 @@ +index,axis-0,axis-1,axis-2 +0.0,0.0,92.83061471930142,59.76071319110557 +1.0,23.0,163.09295583101897,78.5809831317442 +2.0,10.0,93.00985538540276,73.9207258131099 +3.0,18.0,128.6787479395655,85.57136910969572 diff --git a/src/napari_cotcotcot/napari.yaml b/src/napari_cotcotcot/napari.yaml index 6a1b118..dff7771 100644 --- a/src/napari_cotcotcot/napari.yaml +++ b/src/napari_cotcotcot/napari.yaml @@ -6,31 +6,15 @@ visibility: public # categories: [] contributions: commands: - - id: napari-cotcotcot.get_reader - python_name: napari_cotcotcot._reader:napari_get_reader - title: Open data with CoTracker - - id: napari-cotcotcot.write_image - python_name: napari_cotcotcot._writer:napari_write_image - title: Save image data with CoTracker - - id: napari-cotcotcot.make_sample_data - python_name: napari_cotcotcot._sample_data:make_sample_data + - id: napari-cotcotcot.load_sample_data + python_name: napari_cotcotcot._sample_data:load_sample_data title: Load sample data from CoTracker - id: napari-cotcotcot.cotracker_widget python_name: napari_cotcotcot._widget:cotracker_widget title: CoTracker Control Panel - readers: - - command: napari-cotcotcot.get_reader - accepts_directories: false - filename_patterns: ['*.tif', '*.tiff'] - - writers: - - command: napari-cotcotcot.write_image - layer_types: ['image'] - filename_extensions: ['.tif', '.tiff'] - sample_data: - - command: napari-cotcotcot.make_sample_data + - command: napari-cotcotcot.load_sample_data display_name: CoTracker sample key: unique_id.1 diff --git a/tests/test_reader.py b/tests/test_reader.py deleted file mode 100644 index 741e019..0000000 --- a/tests/test_reader.py +++ /dev/null @@ -1,31 +0,0 @@ -import numpy as np - -from napari_cotcotcot import napari_get_reader - - -# tmp_path is a pytest fixture -def test_reader(tmp_path): - """An example of how you might test your plugin.""" - - # write some fake data using your supported file format - my_test_file = str(tmp_path / "myfile.npy") - original_data = np.random.rand(20, 20) - np.save(my_test_file, original_data) - - # try to read it back in - reader = napari_get_reader(my_test_file) - assert callable(reader) - - # make sure we're delivering the right format - layer_data_list = reader(my_test_file) - assert isinstance(layer_data_list, list) and len(layer_data_list) > 0 - layer_data_tuple = layer_data_list[0] - assert isinstance(layer_data_tuple, tuple) and len(layer_data_tuple) > 0 - - # make sure it's the same as it started - np.testing.assert_allclose(original_data, layer_data_tuple[0]) - - -def test_get_reader_pass(): - reader = napari_get_reader("fake.file") - assert reader is None diff --git a/tests/test_widget.py b/tests/test_widget.py index 4d1ce82..617c60b 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -1,66 +1,7 @@ -import numpy as np +# from napari_cotcotcot import CoTrackerWidget -from napari_cotcotcot._widget import ( - ExampleQWidget, - ImageThreshold, - threshold_autogenerate_widget, - threshold_magic_widget, -) +# add your tests here... -def test_threshold_autogenerate_widget(): - # because our "widget" is a pure function, we can call it and - # test it independently of napari - im_data = np.random.random((100, 100)) - thresholded = threshold_autogenerate_widget(im_data, 0.5) - assert thresholded.shape == im_data.shape - # etc. - - -# make_napari_viewer is a pytest fixture that returns a napari viewer object -# you don't need to import it, as long as napari is installed -# in your testing environment -def test_threshold_magic_widget(make_napari_viewer): - viewer = make_napari_viewer() - layer = viewer.add_image(np.random.random((100, 100))) - - # our widget will be a MagicFactory or FunctionGui instance - my_widget = threshold_magic_widget() - - # if we "call" this object, it'll execute our function - thresholded = my_widget(viewer.layers[0], 0.5) - assert thresholded.shape == layer.data.shape - # etc. - - -def test_image_threshold_widget(make_napari_viewer): - viewer = make_napari_viewer() - layer = viewer.add_image(np.random.random((100, 100))) - my_widget = ImageThreshold(viewer) - - # because we saved our widgets as attributes of the container - # we can set their values without having to "interact" with the viewer - my_widget._image_layer_combo.value = layer - my_widget._threshold_slider.value = 0.5 - - # this allows us to run our functions directly and ensure - # correct results - my_widget._threshold_im() - assert len(viewer.layers) == 2 - - -# capsys is a pytest fixture that captures stdout and stderr output streams -def test_example_q_widget(make_napari_viewer, capsys): - # make viewer and add an image layer using our fixture - viewer = make_napari_viewer() - viewer.add_image(np.random.random((100, 100))) - - # create our widget, passing in the viewer - my_widget = ExampleQWidget(viewer) - - # call our widget method - my_widget._on_click() - - # read captured output and check that it's as we expected - captured = capsys.readouterr() - assert captured.out == "napari has 1 layers\n" +def test_something(): + pass diff --git a/tests/test_writer.py b/tests/test_writer.py deleted file mode 100644 index 500da2f..0000000 --- a/tests/test_writer.py +++ /dev/null @@ -1,7 +0,0 @@ -# from napari_cotcotcot import write_single_image, write_multiple - -# add your tests here... - - -def test_something(): - pass