From 917c34ff0f8972aad2d642a441e50761937c314e Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Thu, 10 Oct 2024 15:09:47 +1100 Subject: [PATCH 01/16] Add shape selector widget --- plugin/napari_lattice/fields.py | 6 +- plugin/napari_lattice/shape_selector.py | 121 ++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 plugin/napari_lattice/shape_selector.py diff --git a/plugin/napari_lattice/fields.py b/plugin/napari_lattice/fields.py index 2360c69f..b2fc7a3b 100644 --- a/plugin/napari_lattice/fields.py +++ b/plugin/napari_lattice/fields.py @@ -20,7 +20,7 @@ from lls_core.models.deskew import DefinedPixelSizes from lls_core.models.output import SaveFileType from lls_core.workflow import workflow_from_path -from magicclass import FieldGroup, MagicTemplate, field, magicclass, set_design +from magicclass import FieldGroup, MagicTemplate, field, magicclass, set_design, vfield from magicclass.fields import MagicField from magicclass.widgets import ComboBox, Label, Widget from napari.layers import Image, Shapes @@ -32,6 +32,7 @@ from qtpy.QtWidgets import QTabWidget from strenum import StrEnum from napari_lattice.parent_connect import connect_parent +from plugin.napari_lattice.shape_selector import ShapeSelector if TYPE_CHECKING: from magicgui.widgets.bases import RangedWidget @@ -429,7 +430,8 @@ class CroppingFields(NapariFieldGroup): This is to support the workflow of performing a preview deskew and using that to calculate the cropping coordinates. """), widget_type="Label") fields_enabled = field(False, label="Enabled") - shapes= field(List[Shapes], widget_type="Select", label = "ROI Shape Layers").with_options(choices=lambda _x, _y: get_layers(Shapes)) + + shapes= vfield(ShapeSelector) z_range = field(Tuple[int, int]).with_options( label = "Z Range", value = (0, 1), diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py new file mode 100644 index 00000000..81e966e0 --- /dev/null +++ b/plugin/napari_lattice/shape_selector.py @@ -0,0 +1,121 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Iterator, Tuple, TYPE_CHECKING +from magicclass import field, magicclass +from magicgui.widgets import Select +from napari.layers import Shapes +from napari.components.layerlist import LayerList + +from plugin.napari_lattice.utils import get_viewer + +if TYPE_CHECKING: + from napari.utils.events.event import Event + from numpy.typing import NDArray + +@dataclass +class Shape: + """ + Holds data about a single shape within a Shapes layer + """ + layer: Shapes + index: int + + def __str__(self) -> str: + return f"{self.layer.name} {self.index}" + + def get_array(self) -> NDArray: + return self.layer.data[self.index] + +@magicclass +class ShapeSelector: + def __init__(self, *args, **kwargs) -> None: + # Needed to handle extra kwargs + pass + + def _get_shape_choices(self, widget: Select | None = None) -> Iterator[Tuple[str, Shape]]: + """ + Returns the choices to use for the Select box + """ + viewer = get_viewer() + for layer in viewer.layers: + if isinstance(layer, Shapes): + for index in layer.features.index: + result = Shape(layer=layer, index=index) + yield str(result), result + + def _on_selection_change(self, event: Event) -> None: + """ + Triggered when the user clicks on one or more shapes. + The widget is then updated to synchronise + """ + source: Shapes = event.source + selection: list[Shape] = [] + for index in source.selected_data: + selection.append(Shape(layer=source, index=index)) + self.shapes.value = selection + + def _connect_shapes(self, shapes: Shapes) -> None: + """ + Called on a newly discovered `Shapes` layer. + Listens to events on that layer that we are interested in. + """ + shapes.events.data.connect(self._on_shape_change) + shapes.events.highlight.connect(self._on_selection_change) + # shapes.events.current_properties.connect(self._on_selection_change) + + def _on_shape_change(self, event: Event) -> None: + """ + Triggered whenever a shape layer changes. + Resets the select box options + """ + if isinstance(event.source, Shapes): + self.shapes.reset_choices() + + def _on_layer_add(self, event: Event) -> None: + """ + Triggered whenever a new layer is inserted. + Ensures we listen for shape changes to that new layer + """ + if isinstance(event.source, LayerList): + for layer in event.source: + if isinstance(layer, Shapes): + self._connect_shapes(layer) + + def __post_init__(self) -> None: + """ + Whenever a new layer is inserted + """ + viewer = get_viewer() + + # Listen for new layers + viewer.layers.events.inserted.connect(self._on_layer_add) + + # Watch current layers + for layer in viewer.layers: + if isinstance(layer, Shapes): + self._connect_shapes(layer) + + shapes = field(Select, options={"choices": _get_shape_choices}) + + # values is a list[Shape], but if we use the correct annotation it breaks magicclass + @shapes.connect + def _widget_changed(self, values: list) -> None: + """ + Triggered when the plugin widget is changed. + We then synchronise the Napari shape selection with it. + """ + layers: set[Shapes] = set() + indices: set[int] = set() + value: Shape + + for value in values: + layers.add(value.layer) + indices.add(value.index) + + if len(layers) > 1: + raise Exception("Shapes from multiple layers selected. This shouldn't be possible") + + if layers: + layer = layers.pop() + layer.selected_data = indices + layer.refresh() From 6dd6be3a6c623ecbc71d124aeb0fa4e18daa443d Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Thu, 10 Oct 2024 16:31:47 +1100 Subject: [PATCH 02/16] Fixes for multiple shape layers --- plugin/napari_lattice/shape_selector.py | 71 +++++++++++++++++-------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index 81e966e0..732e2e53 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -5,6 +5,8 @@ from magicgui.widgets import Select from napari.layers import Shapes from napari.components.layerlist import LayerList +from collections import defaultdict +from contextlib import contextmanager from plugin.napari_lattice.utils import get_viewer @@ -12,7 +14,7 @@ from napari.utils.events.event import Event from numpy.typing import NDArray -@dataclass +@dataclass(frozen=True, eq=True) class Shape: """ Holds data about a single shape within a Shapes layer @@ -21,16 +23,32 @@ class Shape: index: int def __str__(self) -> str: - return f"{self.layer.name} {self.index}" + return f"{self.layer.name}: Shape {self.index}" def get_array(self) -> NDArray: return self.layer.data[self.index] @magicclass class ShapeSelector: + + _blocked: bool + def __init__(self, *args, **kwargs) -> None: # Needed to handle extra kwargs - pass + self._blocked = False + + @contextmanager + def _block(self): + """ + Context manager that prevents event handlers recursively calling each other. + Yields a boolean, which means functions should proceed if `True`, or return immediately if `False` + """ + if self._blocked: + yield False + else: + self._blocked = True + yield True + self._blocked = False def _get_shape_choices(self, widget: Select | None = None) -> Iterator[Tuple[str, Shape]]: """ @@ -48,11 +66,16 @@ def _on_selection_change(self, event: Event) -> None: Triggered when the user clicks on one or more shapes. The widget is then updated to synchronise """ - source: Shapes = event.source - selection: list[Shape] = [] - for index in source.selected_data: - selection.append(Shape(layer=source, index=index)) - self.shapes.value = selection + # Prevent recursion + with self._block() as execute: + if not execute: + return + + source: Shapes = event.source + selection: list[Shape] = [] + for index in source.selected_data: + selection.append(Shape(layer=source, index=index)) + self.shapes.value = selection def _connect_shapes(self, shapes: Shapes) -> None: """ @@ -104,18 +127,20 @@ def _widget_changed(self, values: list) -> None: Triggered when the plugin widget is changed. We then synchronise the Napari shape selection with it. """ - layers: set[Shapes] = set() - indices: set[int] = set() - value: Shape - - for value in values: - layers.add(value.layer) - indices.add(value.index) - - if len(layers) > 1: - raise Exception("Shapes from multiple layers selected. This shouldn't be possible") - - if layers: - layer = layers.pop() - layer.selected_data = indices - layer.refresh() + with self._block() as execute: + if not execute: + return + layers: dict[Shapes, list[int]] = {layer: [] for layer in get_viewer().layers if isinstance(layer, Shapes)} + value: Shape + + # Find the current selection for each layer + for value in values: + layers[value.layer].append(value.index) + + # For each layer, set the appropriate selection (this can't be done incrementally) + for layer, shapes in layers.items(): + layer.selected_data = shapes + + # Re-calculate the selections for all Shapes layers (since some have been deselected) + for layer in layers.keys(): + layer.refresh() From 056ad2ee7749c632b7a67b6766ff5ca3d3e7fe9d Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Thu, 10 Oct 2024 18:19:10 +1100 Subject: [PATCH 03/16] Better comments --- plugin/napari_lattice/shape_selector.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index 732e2e53..4acaf810 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -41,7 +41,7 @@ def __init__(self, *args, **kwargs) -> None: def _block(self): """ Context manager that prevents event handlers recursively calling each other. - Yields a boolean, which means functions should proceed if `True`, or return immediately if `False` + Yields a boolean which means functions should proceed if `True`, or return immediately if `False` """ if self._blocked: yield False @@ -83,8 +83,9 @@ def _connect_shapes(self, shapes: Shapes) -> None: Listens to events on that layer that we are interested in. """ shapes.events.data.connect(self._on_shape_change) + # There is no shape selection event. This is the closest thing. + # See: https://github.com/napari/napari/issues/6886 shapes.events.highlight.connect(self._on_selection_change) - # shapes.events.current_properties.connect(self._on_selection_change) def _on_shape_change(self, event: Event) -> None: """ From 02a0c5642285bf5ca8df8f9093e2a366dfd515a9 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Thu, 10 Oct 2024 18:20:44 +1100 Subject: [PATCH 04/16] Fix import --- plugin/napari_lattice/shape_selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index 4acaf810..d51e7fc8 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -8,7 +8,7 @@ from collections import defaultdict from contextlib import contextmanager -from plugin.napari_lattice.utils import get_viewer +from napari_lattice.utils import get_viewer if TYPE_CHECKING: from napari.utils.events.event import Event From a678a3d43ea205521fc83cb7da65fe795049976e Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Thu, 10 Oct 2024 18:44:07 +1100 Subject: [PATCH 05/16] Rename shapes field --- plugin/napari_lattice/shape_selector.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index d51e7fc8..e8362092 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -33,10 +33,10 @@ class ShapeSelector: _blocked: bool - def __init__(self, *args, **kwargs) -> None: - # Needed to handle extra kwargs + def __init__(self, enabled: bool, *args, **kwargs) -> None: self._blocked = False - + self.enabled = enabled + @contextmanager def _block(self): """ @@ -119,7 +119,7 @@ def __post_init__(self) -> None: if isinstance(layer, Shapes): self._connect_shapes(layer) - shapes = field(Select, options={"choices": _get_shape_choices}) + shapes = field(Select, options={"choices": _get_shape_choices, "label": "ROIs"}) # values is a list[Shape], but if we use the correct annotation it breaks magicclass @shapes.connect @@ -144,4 +144,4 @@ def _widget_changed(self, values: list) -> None: # Re-calculate the selections for all Shapes layers (since some have been deselected) for layer in layers.keys(): - layer.refresh() + layer.refresh() From 8146b9ac0d61de1c074bfbefe1af9a3e4b0000ce Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 11 Oct 2024 14:24:42 +1100 Subject: [PATCH 06/16] Integrate shape selector into rest of plugin --- core/lls_core/models/results.py | 36 ++++++++++++++++++------ plugin/napari_lattice/dock_widget.py | 15 +++++----- plugin/napari_lattice/fields.py | 24 ++++++++-------- plugin/napari_lattice/shape_selector.py | 37 +++++++++++++++---------- 4 files changed, 68 insertions(+), 44 deletions(-) diff --git a/core/lls_core/models/results.py b/core/lls_core/models/results.py index 2fb07a9b..3e7943d6 100644 --- a/core/lls_core/models/results.py +++ b/core/lls_core/models/results.py @@ -2,7 +2,7 @@ from itertools import groupby from pathlib import Path -from typing import Iterable, Optional, Tuple, Union, cast, TYPE_CHECKING, overload +from typing import Iterable, Iterator, Optional, Tuple, TypeAlias, Union, cast, TYPE_CHECKING, overload from typing_extensions import Generic, TypeVar from pydantic.v1 import BaseModel, NonNegativeInt, Field from lls_core.types import ArrayLike, is_arraylike @@ -75,6 +75,19 @@ class ImageSlices(ProcessedSlices[ArrayLike]): # This re-definition of the type is helpful for `mkdocs` slices: Iterable[ProcessedSlice[ArrayLike]] = Field(description="Iterable of result slices. For a given slice, you can access the image data through the `slice.data` property, which is a numpy-like array.") + def roi_previews(self) -> Iterable[ArrayLike]: + """ + Extracts a single 3D image for each ROI + """ + import numpy as np + def _preview(slices: Iterable[ProcessedSlice[ArrayLike]]) -> ArrayLike: + for slice in slices: + return slice.data + raise Exception("This ROI has no images. This shouldn't be possible") + + for roi_index, slices in groupby(self.slices, key=lambda slice: slice.roi_index): + yield _preview(slices) + def save_image(self): """ Saves result slices to disk @@ -96,7 +109,8 @@ def save_image(self): If a `DataFrame`, then it contains non-image data returned by your workflow. """ -class WorkflowSlices(ProcessedSlices[Union[Tuple[RawWorkflowOutput], RawWorkflowOutput]]): +MaybeTupleRawWorkflowOutput: TypeAlias = Union[Tuple[RawWorkflowOutput], RawWorkflowOutput] +class WorkflowSlices(ProcessedSlices[MaybeTupleRawWorkflowOutput]): """ The counterpart of `ImageSlices`, but for workflow outputs. This is needed because workflows have vastly different outputs that may include regular @@ -159,16 +173,20 @@ def process(self) -> Iterable[Tuple[RoiIndex, ProcessedWorkflowOutput]]: else: yield roi, pd.DataFrame(element) - def extract_preview(self) -> NDArray: + def roi_previews(self) -> Iterable[NDArray]: """ - Extracts a single 3D image for previewing purposes + Extracts a single 3D image for each ROI """ import numpy as np - for slice in self.slices: - for value in slice.as_tuple(): - if is_arraylike(value): - return np.asarray(value) - raise Exception("No image was returned from this workflow") + def _preview(slices: Iterable[ProcessedSlice[MaybeTupleRawWorkflowOutput]]) -> NDArray: + for slice in slices: + for value in slice.as_tuple(): + if is_arraylike(value): + return np.asarray(value) + raise Exception("This ROI has no images. This shouldn't be possible") + + for roi_index, slices in groupby(self.slices, key=lambda slice: slice.roi_index): + yield _preview(slices) def save(self) -> Iterable[Path]: """ diff --git a/plugin/napari_lattice/dock_widget.py b/plugin/napari_lattice/dock_widget.py index c57ef838..7cf0aa69 100644 --- a/plugin/napari_lattice/dock_widget.py +++ b/plugin/napari_lattice/dock_widget.py @@ -149,20 +149,19 @@ def preview(self, header: str, time: int, channel: int): lattice.dy, lattice.dx ) - preview: ArrayLike + previews: Iterable[ArrayLike] # We extract the first available image to use as a preview # This works differently for workflows and non-workflows if lattice.workflow is None: - for slice in lattice.process().slices: - preview = slice.data - break + previews = lattice.process().roi_previews() else: - preview = lattice.process_workflow().extract_preview() + previews = lattice.process_workflow().roi_previews() - self.parent_viewer.add_image(preview, scale=scale, name="Napari Lattice Preview") - max_z = np.argmax(np.sum(preview, axis=(1, 2))) - self.parent_viewer.dims.set_current_step(0, max_z) + for preview in previews: + self.parent_viewer.add_image(preview, scale=scale, name="Napari Lattice Preview") + max_z = np.argmax(np.sum(preview, axis=(1, 2))) + self.parent_viewer.dims.set_current_step(0, max_z) @set_design(text="Save") diff --git a/plugin/napari_lattice/fields.py b/plugin/napari_lattice/fields.py index b2fc7a3b..aa4e5df1 100644 --- a/plugin/napari_lattice/fields.py +++ b/plugin/napari_lattice/fields.py @@ -432,14 +432,6 @@ class CroppingFields(NapariFieldGroup): fields_enabled = field(False, label="Enabled") shapes= vfield(ShapeSelector) - z_range = field(Tuple[int, int]).with_options( - label = "Z Range", - value = (0, 1), - options = dict( - min = 0, - ), - ) - errors = field(Label).with_options(label="Errors") @set_design(text="Import ROI") def import_roi(self, path: Path): @@ -457,7 +449,16 @@ def new_crop_layer(self): from napari_lattice.utils import get_viewer shapes = get_viewer().add_shapes(name="Napari Lattice Crop") shapes.mode = "ADD_RECTANGLE" - self.shapes.value += [shapes] + # self.shapes.value += [shapes] + + z_range = field(Tuple[int, int]).with_options( + label = "Z Range", + value = (0, 1), + options = dict( + min = 0, + ), + ) + errors = field(Label).with_options(label="Errors") @connect_parent("deskew_fields.img_layer") def _on_image_changed(self, field: MagicField): @@ -488,9 +489,8 @@ def _make_model(self) -> Optional[CropParams]: if self.fields_enabled.value: deskew = self._get_deskew() rois = [] - for shape_layer in self.shapes.value: - for x in shape_layer.data: - rois.append(Roi.from_array(x / deskew.dy)) + for shape in self.shapes.shapes.value: + rois.append(Roi.from_array(shape.get_array() / deskew.dy)) return CropParams( # Convert from the input image space to the deskewed image space diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index e8362092..86258eec 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -1,8 +1,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Iterator, Tuple, TYPE_CHECKING -from magicclass import field, magicclass -from magicgui.widgets import Select +from magicclass import field, magicclass, set_design +from magicgui.widgets import Select, Button from napari.layers import Shapes from napari.components.layerlist import LayerList from collections import defaultdict @@ -31,7 +31,27 @@ def get_array(self) -> NDArray: @magicclass class ShapeSelector: + def _get_shape_choices(self, widget: Select | None = None) -> Iterator[Tuple[str, Shape]]: + """ + Returns the choices to use for the Select box + """ + viewer = get_viewer() + for layer in viewer.layers: + if isinstance(layer, Shapes): + for index in layer.features.index: + result = Shape(layer=layer, index=index) + yield str(result), result + _blocked: bool + shapes = field(Select, options={"choices": _get_shape_choices, "label": "ROIs"}) + + @set_design(text="Select All") + def select_all(self) -> None: + self.shapes.value = self.shapes.choices + + @set_design(text="Deselect All") + def deselect_all(self) -> None: + self.shapes.value = [] def __init__(self, enabled: bool, *args, **kwargs) -> None: self._blocked = False @@ -50,17 +70,6 @@ def _block(self): yield True self._blocked = False - def _get_shape_choices(self, widget: Select | None = None) -> Iterator[Tuple[str, Shape]]: - """ - Returns the choices to use for the Select box - """ - viewer = get_viewer() - for layer in viewer.layers: - if isinstance(layer, Shapes): - for index in layer.features.index: - result = Shape(layer=layer, index=index) - yield str(result), result - def _on_selection_change(self, event: Event) -> None: """ Triggered when the user clicks on one or more shapes. @@ -119,8 +128,6 @@ def __post_init__(self) -> None: if isinstance(layer, Shapes): self._connect_shapes(layer) - shapes = field(Select, options={"choices": _get_shape_choices, "label": "ROIs"}) - # values is a list[Shape], but if we use the correct annotation it breaks magicclass @shapes.connect def _widget_changed(self, values: list) -> None: From 17e9ff2c584a12c757c3cd9fc80e99081445aa06 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Mon, 14 Oct 2024 13:10:56 +1100 Subject: [PATCH 07/16] Fix plugin import --- plugin/napari_lattice/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/napari_lattice/fields.py b/plugin/napari_lattice/fields.py index aa4e5df1..3c8928b0 100644 --- a/plugin/napari_lattice/fields.py +++ b/plugin/napari_lattice/fields.py @@ -32,7 +32,7 @@ from qtpy.QtWidgets import QTabWidget from strenum import StrEnum from napari_lattice.parent_connect import connect_parent -from plugin.napari_lattice.shape_selector import ShapeSelector +from napari_lattice.shape_selector import ShapeSelector if TYPE_CHECKING: from magicgui.widgets.bases import RangedWidget From ea1957cd0493d2ec66f91d86265f6e11f3f9e417 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 18 Oct 2024 12:15:08 +1100 Subject: [PATCH 08/16] Fix for shapes with > 2 dimensions --- plugin/napari_lattice/fields.py | 7 ++++++- plugin/napari_lattice/shape_selector.py | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugin/napari_lattice/fields.py b/plugin/napari_lattice/fields.py index 3c8928b0..a6bbebfc 100644 --- a/plugin/napari_lattice/fields.py +++ b/plugin/napari_lattice/fields.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from magicgui.widgets.bases import RangedWidget + from numpy.typing import NDArray logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -490,7 +491,11 @@ def _make_model(self) -> Optional[CropParams]: deskew = self._get_deskew() rois = [] for shape in self.shapes.shapes.value: - rois.append(Roi.from_array(shape.get_array() / deskew.dy)) + # The Napari shape is an array with 2 dimensions. + # Each column is an axis and each row is a point defining the shape + # We drop all but the last two axes, giving us a 2D shape with XY coordinates + array: NDArray = shape.get_array()[..., -2:] / deskew.dy + rois.append(Roi.from_array(array)) return CropParams( # Convert from the input image space to the deskewed image space diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index 86258eec..522ea90e 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from typing import Iterator, Tuple, TYPE_CHECKING from magicclass import field, magicclass, set_design +from magicclass.fields._fields import MagicField from magicgui.widgets import Select, Button from napari.layers import Shapes from napari.components.layerlist import LayerList @@ -43,7 +44,7 @@ def _get_shape_choices(self, widget: Select | None = None) -> Iterator[Tuple[str yield str(result), result _blocked: bool - shapes = field(Select, options={"choices": _get_shape_choices, "label": "ROIs"}) + shapes: MagicField[Select] = field(Select, options={"choices": _get_shape_choices, "label": "ROIs"}) @set_design(text="Select All") def select_all(self) -> None: From 2f9b5eafccb88a317f46a99ce77ea0753793e922 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 18 Oct 2024 12:17:55 +1100 Subject: [PATCH 09/16] Fix preview test --- core/tests/test_workflows.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/tests/test_workflows.py b/core/tests/test_workflows.py index 9c1f2cfb..aceb5a96 100644 --- a/core/tests/test_workflows.py +++ b/core/tests/test_workflows.py @@ -107,8 +107,9 @@ def test_sum_preview(rbc_tiny: Path): workflow = "core/tests/workflows/binarisation/workflow.yml", save_dir = tmpdir ) - preview = params.process_workflow().extract_preview() - np.sum(preview, axis=(1, 2)) + previews = list(params.process_workflow().roi_previews()) + assert len(previews) == 1, "There should be 1 preview when cropping is disabled" + assert previews[0].ndim == 3, "A preview should be a 3D image" def test_crop_workflow(rbc_tiny: Path): # Tests that crop workflows only process each ROI lazily From 90cf7a2a5d5f2c62b8a806e9068491849824d1a9 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 18 Oct 2024 12:24:18 +1100 Subject: [PATCH 10/16] Import TypeAlias from typing_extensions --- core/lls_core/models/results.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/lls_core/models/results.py b/core/lls_core/models/results.py index 98d95b94..d8781646 100644 --- a/core/lls_core/models/results.py +++ b/core/lls_core/models/results.py @@ -2,8 +2,8 @@ from itertools import groupby from pathlib import Path -from typing import Iterable, Iterator, Optional, Tuple, TypeAlias, Union, cast, TYPE_CHECKING, overload -from typing_extensions import Generic, TypeVar +from typing import Iterable, Optional, Tuple, Union, cast, TYPE_CHECKING, overload +from typing_extensions import Generic, TypeVar, TypeAlias from pydantic.v1 import BaseModel, NonNegativeInt, Field from lls_core.types import ArrayLike, is_arraylike from lls_core.utils import make_filename_suffix From 69c68cbccb2cae21802145a16fe467465fd023c7 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 18 Oct 2024 13:20:00 +1100 Subject: [PATCH 11/16] Fix for ShapeSelector when no viewer is present (in tests) --- plugin/napari_lattice/shape_selector.py | 36 ++++++++++++++----------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index 522ea90e..d0b62c20 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -8,8 +8,7 @@ from napari.components.layerlist import LayerList from collections import defaultdict from contextlib import contextmanager - -from napari_lattice.utils import get_viewer +from napari.viewer import current_viewer if TYPE_CHECKING: from napari.utils.events.event import Event @@ -36,12 +35,13 @@ def _get_shape_choices(self, widget: Select | None = None) -> Iterator[Tuple[str """ Returns the choices to use for the Select box """ - viewer = get_viewer() - for layer in viewer.layers: - if isinstance(layer, Shapes): - for index in layer.features.index: - result = Shape(layer=layer, index=index) - yield str(result), result + viewer = current_viewer() + if viewer is not None: + for layer in viewer.layers: + if isinstance(layer, Shapes): + for index in layer.features.index: + result = Shape(layer=layer, index=index) + yield str(result), result _blocked: bool shapes: MagicField[Select] = field(Select, options={"choices": _get_shape_choices, "label": "ROIs"}) @@ -119,15 +119,17 @@ def __post_init__(self) -> None: """ Whenever a new layer is inserted """ - viewer = get_viewer() + viewer = current_viewer() + + if viewer is not None: - # Listen for new layers - viewer.layers.events.inserted.connect(self._on_layer_add) + # Listen for new layers + viewer.layers.events.inserted.connect(self._on_layer_add) - # Watch current layers - for layer in viewer.layers: - if isinstance(layer, Shapes): - self._connect_shapes(layer) + # Watch current layers + for layer in viewer.layers: + if isinstance(layer, Shapes): + self._connect_shapes(layer) # values is a list[Shape], but if we use the correct annotation it breaks magicclass @shapes.connect @@ -136,6 +138,10 @@ def _widget_changed(self, values: list) -> None: Triggered when the plugin widget is changed. We then synchronise the Napari shape selection with it. """ + viewer = current_viewer() + if viewer is None: + return + with self._block() as execute: if not execute: return From e9472f13d935f23222717ea820a33415ce4ca01e Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Fri, 18 Oct 2024 14:07:40 +1100 Subject: [PATCH 12/16] Fix type error --- plugin/napari_lattice/shape_selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index d0b62c20..3a1a5a05 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -145,7 +145,7 @@ def _widget_changed(self, values: list) -> None: with self._block() as execute: if not execute: return - layers: dict[Shapes, list[int]] = {layer: [] for layer in get_viewer().layers if isinstance(layer, Shapes)} + layers: dict[Shapes, list[int]] = {layer: [] for layer in viewer.layers if isinstance(layer, Shapes)} value: Shape # Find the current selection for each layer From 67a93cd9d4c9390fde1adf54357914cdb7683838 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Tue, 12 Nov 2024 16:31:22 +1100 Subject: [PATCH 13/16] Refactor shape event listener into separate class --- plugin/napari_lattice/shape_selection.py | 57 ++++++++++++++++++++++++ plugin/napari_lattice/shape_selector.py | 4 +- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 plugin/napari_lattice/shape_selection.py diff --git a/plugin/napari_lattice/shape_selection.py b/plugin/napari_lattice/shape_selection.py new file mode 100644 index 00000000..bf0d3eee --- /dev/null +++ b/plugin/napari_lattice/shape_selection.py @@ -0,0 +1,57 @@ +from napari.utils.events import EventEmitter, Event +from napari.layers import Shapes + +class ShapeLayerChangedEvent(Event): + """ + Event triggered when the shape layer selection changes. + """ + +class ShapeSelectionListener(EventEmitter): + """ + Manages shape selection events for a given Shapes layer. + + Examples: + This example code will open the viewer with an empty shape layer. + Any selection changes to that layer will trigger a notification popup. + >>> from napari import Viewer + >>> from napari.layers import Shapes + >>> viewer = Viewer() + >>> shapes = viewer.add_shapes() + >>> shape_selection = ShapeSelection(shapes) + >>> shape_selection.connect(lambda event: print("Shape selection changed!")) + """ + last_selection: set[int] + layer: Shapes + + def __init__(self, layer) -> None: + """ + Initializes the ShapeSelection with the given Shapes layer. + + Parameters: + layer: The Shapes layer to listen to. + """ + super().__init__(source=layer, event_class=ShapeLayerChangedEvent, type_name="shape_layer_selection_changed") + self.layer = layer + self.last_selection = set() + layer.events.highlight.connect(self._on_highlight) + + def _on_highlight(self, event) -> None: + new_selection = self.layer.selected_data + if new_selection != self.last_selection: + self() + self.last_selection = set(new_selection) + +def test_script(): + """ + Demo for testing the event behaviour. + """ + from napari import run, Viewer + from napari.utils.notifications import show_info + viewer = Viewer() + shapes = viewer.add_shapes() + event = ShapeSelectionListener(shapes) + event.connect(lambda x: show_info("Shape selection changed!")) + run() + +if __name__ == "__main__": + test_script() diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index 3a1a5a05..a87bf7d1 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -9,6 +9,7 @@ from collections import defaultdict from contextlib import contextmanager from napari.viewer import current_viewer +from napari_lattice.shape_selection import ShapeSelectionListener if TYPE_CHECKING: from napari.utils.events.event import Event @@ -93,8 +94,7 @@ def _connect_shapes(self, shapes: Shapes) -> None: Listens to events on that layer that we are interested in. """ shapes.events.data.connect(self._on_shape_change) - # There is no shape selection event. This is the closest thing. - # See: https://github.com/napari/napari/issues/6886 + ShapeSelectionListener(shapes).connect(self._on_selection_change) shapes.events.highlight.connect(self._on_selection_change) def _on_shape_change(self, event: Event) -> None: From 5d6f68f484f24aeaf7e7717c1175f517519c90d7 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Tue, 12 Nov 2024 17:05:33 +1100 Subject: [PATCH 14/16] Cache the ShapeSelectionListener objects --- plugin/napari_lattice/shape_selector.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index a87bf7d1..fee94b34 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -45,6 +45,7 @@ def _get_shape_choices(self, widget: Select | None = None) -> Iterator[Tuple[str yield str(result), result _blocked: bool + _listeners: list[ShapeSelectionListener] = [] shapes: MagicField[Select] = field(Select, options={"choices": _get_shape_choices, "label": "ROIs"}) @set_design(text="Select All") @@ -94,8 +95,9 @@ def _connect_shapes(self, shapes: Shapes) -> None: Listens to events on that layer that we are interested in. """ shapes.events.data.connect(self._on_shape_change) - ShapeSelectionListener(shapes).connect(self._on_selection_change) - shapes.events.highlight.connect(self._on_selection_change) + listener = ShapeSelectionListener(shapes) + self._listeners.append(listener) + listener.connect(self._on_selection_change) def _on_shape_change(self, event: Event) -> None: """ From b8d4440530283183c1f319a78835fd2d22505639 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Tue, 12 Nov 2024 17:16:07 +1100 Subject: [PATCH 15/16] Attempted fix for Windows --- plugin/napari_lattice/shape_selector.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugin/napari_lattice/shape_selector.py b/plugin/napari_lattice/shape_selector.py index fee94b34..1cd63084 100644 --- a/plugin/napari_lattice/shape_selector.py +++ b/plugin/napari_lattice/shape_selector.py @@ -86,7 +86,12 @@ def _on_selection_change(self, event: Event) -> None: source: Shapes = event.source selection: list[Shape] = [] for index in source.selected_data: - selection.append(Shape(layer=source, index=index)) + shape = Shape(layer=source, index=index) + if shape not in self.shapes.choices: + # If we ever encounter a shape that isn't a legal choice, we have to terminate to avoid an error + # This seems to happen on Windows only due to the order of events firing + return + selection.append(shape) self.shapes.value = selection def _connect_shapes(self, shapes: Shapes) -> None: From 523e2ba353366002ed02d4bb9137876238e3edd3 Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Wed, 13 Nov 2024 13:13:36 +1100 Subject: [PATCH 16/16] Use future annotations --- plugin/napari_lattice/shape_selection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/napari_lattice/shape_selection.py b/plugin/napari_lattice/shape_selection.py index bf0d3eee..0a1fa6e3 100644 --- a/plugin/napari_lattice/shape_selection.py +++ b/plugin/napari_lattice/shape_selection.py @@ -1,3 +1,4 @@ +from __future__ import annotations from napari.utils.events import EventEmitter, Event from napari.layers import Shapes