diff --git a/docs/source/_static/napari_aeon_regions.png b/docs/source/_static/napari_aeon_regions.png new file mode 100644 index 000000000..e0feac30a Binary files /dev/null and b/docs/source/_static/napari_aeon_regions.png differ diff --git a/docs/source/_static/napari_drawing_regions.png b/docs/source/_static/napari_drawing_regions.png index 78167797f..23c9d6515 100644 Binary files a/docs/source/_static/napari_drawing_regions.png and b/docs/source/_static/napari_drawing_regions.png differ diff --git a/docs/source/_static/napari_new_region_layer.png b/docs/source/_static/napari_new_region_layer.png index 13b55495b..7e2983176 100644 Binary files a/docs/source/_static/napari_new_region_layer.png and b/docs/source/_static/napari_new_region_layer.png differ diff --git a/docs/source/user_guide/gui.md b/docs/source/user_guide/gui.md index d206b3828..3255e1edd 100644 --- a/docs/source/user_guide/gui.md +++ b/docs/source/user_guide/gui.md @@ -326,6 +326,7 @@ You can find all the [keyboard shortcuts](napari:guides/preferences.html#shortcu ::: +(target-define-rois)= ## Define regions of interest The `Define regions of interest` menu allows you to draw shapes on the @@ -422,13 +423,77 @@ colour. ### Save and load regions -Save and load functionality for region layers is coming soon. -You will be able to save a region layer to a GeoJSON file, -and load it back later or share it with others. This will also allow you -to import drawn regions into your Python code using the +You can save a region layer to a [GeoJSON](https://geojson.org/) file +and load it back later—or share it with collaborators. This also lets you +import drawn regions into your Python code using the {func}`~movement.roi.load_rois` function. -**Stay tuned!** +To save a region layer: + +1. Select a non-empty region layer you want to save, + via the dropdown or the layer list. +2. Click `Save layer` and choose a destination file ending in a + `.geojson` (or just `.json`) extension. + All regions in the layer are written to the chosen file. + +To load a region layer: + +1. Click `Load layer` and select a GeoJSON file previously saved with + `movement` (or any file produced by {func}`~movement.roi.save_rois`). +2. A new region layer is created and automatically selected in the dropdown, + with its regions shown in the table. + +When regions are saved and reloaded, the underlying geometry is preserved +but the exact `napari` shape type may change (e.g. a `rectangle` reloads +as a `polygon`). Expand the dropdown below for details. + +::: {dropdown} Using saved regions in Python +:color: info +:icon: info + +Once you have saved a region layer to a GeoJSON file, you can load it +in Python with {func}`~movement.roi.load_rois`: + +```python +from movement.roi import load_rois + +rois = load_rois("path/to/regions.geojson") +``` + +This returns a list of {class}`~movement.roi.LineOfInterest` and/or +{class}`~movement.roi.PolygonOfInterest` objects, depending on the +shapes that were saved. The mapping between `napari` shape types and +`movement` RoI classes is as follows: + +| drawn napari shape | movement RoI | reloaded napari shape | +|---|---|---| +| `line` | {class}`~movement.roi.LineOfInterest` | `path` | +| `path` | {class}`~movement.roi.LineOfInterest` | `path` | +| `polygon` | {class}`~movement.roi.PolygonOfInterest` | `polygon` | +| `rectangle` | {class}`~movement.roi.PolygonOfInterest` | `polygon` | +| `ellipse` | {class}`~movement.roi.PolygonOfInterest`\* | `polygon` | + +\*Ellipses have no native GeoJSON representation and are approximated +as polygons. The approximation is accurate enough for most practical +purposes, but we encourage you to inspect the reloaded shape to ensure +it meets your needs. For more details on the shape-RoI conversion process, +refer to the {mod}`movement.napari.convert_roi` module documentation. + +You can use the loaded RoI objects for analysis, for example: + +- Pass a list of polygon regions to + {func}`~movement.roi.compute_region_occupancy`. +- Call methods such as + {meth}`~movement.roi.BaseRegionOfInterest.contains_point` or + {meth}`~movement.roi.BaseRegionOfInterest.compute_distance_to` + on individual RoIs. + +See the {mod}`movement.roi` API reference for the full list of +available methods, and +{ref}`this example in our gallery ` +for a complete walkthrough that loads GUI-drawn regions and uses them +for analysis. +::: ### Working with multiple region layers diff --git a/examples/boundary_angles.py b/examples/boundary_angles.py index ce86a393b..fb0b5e020 100644 --- a/examples/boundary_angles.py +++ b/examples/boundary_angles.py @@ -19,7 +19,7 @@ from movement import sample_data from movement.kinematics import compute_velocity from movement.plots import plot_centroid_trajectory -from movement.roi import PolygonOfInterest +from movement.roi import PolygonOfInterest, load_rois # %% # Load sample dataset @@ -60,28 +60,66 @@ # %% # Define regions of interest # -------------------------- -# In order to ask questions about the behaviour of our individuals with respect -# to the habitat, we first need to define the RoIs to represent the separate -# pieces of the habitat programmatically. Since each part of the habitat is -# two-dimensional, we will use :class:`movement.roi.PolygonOfInterest` -# to describe each of them. +# In order to ask questions about the behaviour of our individuals with +# respect to the habitat, we first need to define the RoIs to represent +# the separate pieces of the habitat. We can do this interactively by +# drawing regions in the :ref:`movement GUI` +# and saving them to a GeoJSON file. # -# In the future, the -# `movement plugin for napari <../user_guide/gui.md>`_ -# will support creating regions of interest by clicking points and drawing -# shapes in the napari GUI. For the time being, we can still define our RoIs -# by specifying the points that make up the interior and exterior boundaries. -# So first, let's define the boundary vertices of our various regions. - -# The centre of the habitat is located roughly here +# .. attention:: +# The following steps require ``napari`` to be installed. If you +# haven't already, install ``movement`` with the optional GUI +# dependencies by following the +# :ref:`installation instructions`. +# +# First, open the habitat frame in napari: +# +# .. code-block:: python +# +# import napari +# viewer = napari.Viewer() +# viewer.open(ds.frame_path) +# +# Then use the :ref:`Define regions of interest ` +# widget to create a region layer and draw **three polygons** representing +# the key sub-regions of the habitat: +# +# 1. The **Ring outer boundary**: trace the outer octadecagonal +# (18-sided) edge of the ring that surrounds the central area. +# 2. The **Central region**: trace the inner octadecagonal boundary +# enclosing the bright open area. +# 3. The **Nest region**: draw a rectangle around the cuboidal structure on +# the right-hand side. +# +# .. figure:: /_static/napari_aeon_regions.png +# :width: 800 +# +# Three polygons drawn over the Aeon habitat. Note that we've assigned +# custom colours to each polygon to make them easier to distinguish, +# but this is not necessary. +# +# Name each polygon as shown above, then click ``Save layer`` +# to export them to a GeoJSON file +# (e.g. ``habitat_regions.geojson``). +# +# .. note:: +# The ring is really a polygon with a hole (the central region). +# Since ``napari`` does not support drawing polygons with holes, we +# draw the outer and inner boundaries as separate polygons and +# combine them in code below. +# +# Once saved, load the regions in Python: + +# sphinx_gallery_start_ignore +import os # noqa: E402 +import tempfile # noqa: E402 + +from movement.roi import save_rois # noqa: E402 + centre = np.array([712.5, 541]) -# The "width" (distance between the inner and outer octadecagonal rings) is 40 -# pixels wide ring_width = 40.0 -# The distance between opposite vertices of the outer ring is 1090 pixels ring_extent = 1090.0 -# Create the vertices of a "unit" octadecagon, centred on (0,0) n_pts = 18 unit_shape = np.array( [ @@ -90,42 +128,55 @@ ], dtype=complex, ) -# Then stretch and translate the reference to match the habitat -ring_outer_boundary = ring_extent / 2.0 * unit_shape.copy() -ring_outer_boundary = ( - np.array([ring_outer_boundary.real, ring_outer_boundary.imag]).transpose() - + centre + +ring_outer_coords = ring_extent / 2.0 * unit_shape +ring_outer_coords = ( + np.array([ring_outer_coords.real, ring_outer_coords.imag]).T + centre ) -core_boundary = (ring_extent - ring_width) / 2.0 * unit_shape.copy() -core_boundary = ( - np.array([core_boundary.real, core_boundary.imag]).transpose() + centre +core_coords = (ring_extent - ring_width) / 2.0 * unit_shape +core_coords = np.array([core_coords.real, core_coords.imag]).T + centre +nest_corners = ((1245, 585), (1245, 475), (1330, 480), (1330, 580)) + +regions_file = os.path.join(tempfile.mkdtemp(), "habitat_regions.geojson") +save_rois( + [ + PolygonOfInterest(ring_outer_coords, name="Ring outer boundary"), + PolygonOfInterest(core_coords, name="Central region"), + PolygonOfInterest(nest_corners, name="Nest region"), + ], + regions_file, ) +# sphinx_gallery_end_ignore -nest_corners = ((1245, 585), (1245, 475), (1330, 480), (1330, 580)) +rois = load_rois(regions_file) +print(f"Loaded {len(rois)} regions: {[r.name for r in rois]}") # %% -# Our central region is a solid shape without any interior holes. -# To create the appropriate RoI, we just pass the coordinates in either -# clockwise or counter-clockwise order. +# We now have three +# :class:`~movement.roi.PolygonOfInterest` objects. Let's assign them +# to named variables for clarity. -central_region = PolygonOfInterest(core_boundary, name="Central region") +ring_outer = rois[0] +central_region = rois[1] +nest_region = rois[2] # %% -# Likewise, the nest is also just a solid shape without any holes. -# Note that we are only registering the "floor" of the nest here. -nest_region = PolygonOfInterest(nest_corners, name="Nest region") -# %% -# To create an RoI representing the ring region, we need to provide an interior -# boundary so that ``movement`` knows our ring region has a "hole". -# :class:`PolygonOfInterest` -# can actually support multiple (non-overlapping) holes, which is why the -# ``holes`` argument takes a ``list``. +# To represent the ring (with the central region as a hole), we +# create a new :class:`~movement.roi.PolygonOfInterest` using the +# outer boundary's coordinates and the central region as an interior +# boundary. The ``holes`` argument accepts a ``list`` because a polygon +# can have multiple non-overlapping holes. + ring_region = PolygonOfInterest( - ring_outer_boundary, holes=[core_boundary], name="Ring region" + ring_outer.coords, + holes=[central_region.coords], + name="Ring region", ) +# %% +# Let's visualise our three analysis regions overlaid on the habitat. + habitat_fig, habitat_ax = plt.subplots(1, 1) -# Overlay an image of the habitat habitat_ax.imshow(plt.imread(habitat_image)) central_region.plot(habitat_ax, facecolor="lightblue", alpha=0.25) @@ -135,22 +186,6 @@ # sphinx_gallery_thumbnail_number = 2 habitat_fig.show() -# %% -# .. note:: -# -# Once you have defined your RoIs, you can save them to a GeoJSON file -# using :func:`save_rois()` and load them back -# later with :func:`load_rois()`. This is useful -# for sharing RoI definitions with collaborators or reusing them across -# multiple analysis scripts. -# -# .. code-block:: python -# -# from movement.roi import save_rois, load_rois -# -# save_rois([central_region, nest_region, ring_region], "rois.geojson") -# loaded_rois = load_rois("rois.geojson") - # %% # View individual paths inside the habitat # ---------------------------------------- diff --git a/movement/napari/convert_roi.py b/movement/napari/convert_roi.py new file mode 100644 index 000000000..7dccd39e9 --- /dev/null +++ b/movement/napari/convert_roi.py @@ -0,0 +1,328 @@ +"""Conversion functions between ``napari`` shapes and ``movement`` RoIs. + +- For information on ``napari`` shapes, + see https://napari.org/stable/howtos/layers/shapes.html +- RoI: Region of Interest, as defined in :mod:`movement.roi`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, TypeAlias + +import numpy as np +import shapely +import shapely.affinity + +from movement.roi.line import LineOfInterest +from movement.roi.polygon import PolygonOfInterest +from movement.utils.logging import logger + +if TYPE_CHECKING: + from collections.abc import Sequence + + from napari.layers import Shapes + + from movement.roi.base import BaseRegionOfInterest + +NapariShapeType: TypeAlias = Literal[ + "line", "path", "polygon", "rectangle", "ellipse" +] + +NAPARI_SHAPE_TO_ROI_CLASS: dict[ + NapariShapeType, type[LineOfInterest] | type[PolygonOfInterest] +] = { + "line": LineOfInterest, + "path": LineOfInterest, + "polygon": PolygonOfInterest, + "rectangle": PolygonOfInterest, + "ellipse": PolygonOfInterest, # approximated as polygon +} + + +def roi_to_napari_shape( + roi: BaseRegionOfInterest, +) -> tuple[np.ndarray, NapariShapeType]: + """Convert a ``movement`` RegionOfInterest (RoI) to a ``napari`` shape. + + Parameters + ---------- + roi + A :class:`~movement.roi.LineOfInterest` or + :class:`~movement.roi.PolygonOfInterest` to convert. + + Returns + ------- + data : numpy.ndarray + Shape coordinates as an (N, 2) array in ``(y, x)`` order + (``napari`` convention), with no repeated closing vertex. + shape_type : NapariShapeType + The ``napari`` shape type string: ``"path"`` for + :class:`~movement.roi.LineOfInterest` and ``"polygon"`` for + :class:`~movement.roi.PolygonOfInterest`. + + Notes + ----- + The mapping from ``movement`` RoI classes to ``napari`` shape types is: + + .. list-table:: + :header-rows: 1 + + * - movement RoI class + - napari shape type + * - :class:`~movement.roi.LineOfInterest` + - ``"path"`` + * - :class:`~movement.roi.PolygonOfInterest` + - ``"polygon"`` + + This function is the inverse of :func:`napari_shape_to_roi`, but some + shape information is not preserved when converting back. Specifically, + ``"line"``, ``"rectangle"``, and ``"ellipse"`` shapes drawn in ``napari`` + are all returned as ``"path"`` or ``"polygon"``. + + A closed :class:`~movement.roi.LineOfInterest` (created with + ``loop=True``) is also affected: ``napari`` has no closed-path shape type, + so the segment connecting the last point back to the first is dropped + and a warning is emitted. + + See Also + -------- + napari_shape_to_roi : The inverse of this function. + rois_to_napari_shapes : Batch conversion of multiple RoIs. + + """ + xy = np.array(roi.coords) + + if isinstance(roi, PolygonOfInterest): + shape_type: NapariShapeType = "polygon" + xy = xy[:-1] # shapely Polygon exterior repeats the first vertex + else: + shape_type = "path" + if roi.is_closed: + xy = xy[:-1] # shapely LinearRing repeats the first vertex + logger.warning( + f"LineOfInterest '{roi.name}' is a closed loop, but napari " + f"has no closed-path shape type. Converting to 'path'; the " + f"closing segment will not be shown in napari." + ) + + # Swap (x, y) → (y, x) to match napari's coordinate convention + return xy[:, ::-1], shape_type + + +def rois_to_napari_shapes( + rois: Sequence[BaseRegionOfInterest], +) -> dict[str, Any]: + """Convert a sequence of ``movement`` RoIs to ``napari`` shapes. + + The returned dictionary can be passed directly to ``napari``'s Shapes layer + constructor to add all regions of interest (RoIs) in a single call. + + Parameters + ---------- + rois + Sequence of :class:`~movement.roi.LineOfInterest` or + :class:`~movement.roi.PolygonOfInterest` objects to convert. + + Returns + ------- + dict + A dictionary with the following keys: + + - ``"data"``: list of (N, 2) arrays in ``(y, x)`` order. + - ``"shape_type"``: list of ``napari`` shape type strings. + - ``"properties"``: dict with a ``"name"`` key containing the + RoI names. + + See Also + -------- + roi_to_napari_shape : The underlying per-shape conversion function. + napari_shapes_to_rois : The inverse of this function. + + """ + data, shape_types, names = [], [], [] + for roi in rois: + coords, shape_type = roi_to_napari_shape(roi) + data.append(coords) + shape_types.append(shape_type) + names.append(roi.name) + return { + "data": data, + "shape_type": shape_types, + "properties": {"name": names}, + } + + +def napari_shape_to_roi( + data: np.ndarray, + shape_type: NapariShapeType, + name: str | None = None, +) -> LineOfInterest | PolygonOfInterest: + """Convert a ``napari`` shape to a ``movement`` RegionOfInterest (RoI). + + This function only handles static 2D shapes with coordinates (y, x). + Shapes with additional dimensions will be rejected with an error. + + Parameters + ---------- + data + Shape coordinates as stored in ``layer.data[i]``, where ``i`` is the + index of the shape to convert. This should be an (N, 2) array where + rows are vertices and columns are (y, x) coordinates. + shape_type + One of the ``napari`` shape types. + name + Name to assign to the resulting RoI. If ``None``, the RoI + receives the default name defined by + :class:`~movement.roi.BaseRegionOfInterest`. + + Returns + ------- + LineOfInterest or PolygonOfInterest + A :class:`~movement.roi.LineOfInterest` or + :class:`~movement.roi.PolygonOfInterest`. + + Raises + ------ + ValueError + If ``data`` has more than 2 columns (dimensions other than y and x), + or if ``shape_type`` is not one of the recognised ``napari`` shape + types. + + Notes + ----- + The mapping from ``napari`` shape types to ``movement`` RoI classes is: + + .. list-table:: + :header-rows: 1 + + * - napari shape type + - movement RoI class + * - ``"line"``, ``"path"`` + - :class:`~movement.roi.LineOfInterest` + * - ``"polygon"``, ``"rectangle"`` + - :class:`~movement.roi.PolygonOfInterest` + * - ``"ellipse"`` + - :class:`~movement.roi.PolygonOfInterest` (approximation) + + Ellipses are approximated as polygons because neither ``movement`` nor + its underlying geometry library (``shapely``) has a native ellipse type. + The approximation uses :meth:`shapely.Point.buffer` scaled and rotated + to match the ellipse geometry. This approach was inspired by + https://gis.stackexchange.com/questions/243459/drawing-ellipse-with-shapely + + See Also + -------- + roi_to_napari_shape : The inverse of this function. + napari_shapes_to_rois : Batch conversion of an entire ``napari`` layer. + + """ + data = np.asarray(data, dtype=float) + + # Validate shape only has (y, x) coordinates + n_cols = data.shape[1] + if n_cols > 2: + raise logger.error( + ValueError( + f"Shape data has {n_cols} columns, but only 2D shapes with " + f"coordinates (y, x) are supported." + ) + ) + + # Validate shape_type is recognised + if shape_type not in NAPARI_SHAPE_TO_ROI_CLASS: + raise logger.error( + ValueError( + f"Unrecognized napari shape type '{shape_type}'. " + f"Expected one of: {list(NAPARI_SHAPE_TO_ROI_CLASS.keys())}." + ) + ) + + # Swap (y, x) → (x, y) to match movement's coordinate convention + xy = data[:, ::-1] + + roi_class = NAPARI_SHAPE_TO_ROI_CLASS[shape_type] + # Approximate ellipses as polygons if needed + if shape_type == "ellipse": + xy = _ellipse_to_polygon(xy) + logger.info( + f"Ellipse {name or ''} will be approximated as a " + f"PolygonOfInterest with {xy.shape[0]} vertices." + ) + + return roi_class(xy, name=name or None) + + +def napari_shapes_to_rois( + layer: Shapes, +) -> list[LineOfInterest | PolygonOfInterest]: + """Convert all shapes in a ``napari`` Shapes layer to ``movement`` RoIs. + + Parameters + ---------- + layer + The ``napari`` Shapes layer to be converted. Names are read from + ``layer.properties["name"]`` when available. Missing or blank names + receive the default name defined by + :class:`~movement.roi.BaseRegionOfInterest`. + + Returns + ------- + list[LineOfInterest | PolygonOfInterest] + One region of interest (RoI) per shape in the layer, in the same order. + + Raises + ------ + ValueError + If any shape has more than 2 coordinate columns, or has an + unrecognised shape type. See :func:`napari_shape_to_roi`. + + See Also + -------- + napari_shape_to_roi : The underlying per-shape conversion function. + rois_to_napari_shapes : The inverse of this function. + + """ + names = list(layer.properties.get("name", [])) + return [ + napari_shape_to_roi( + data, + shape_type, + name=names[i] if i < len(names) else None, + ) + for i, (data, shape_type) in enumerate( + zip(layer.data, layer.shape_type, strict=True) + ) + ] + + +def _ellipse_to_polygon(xy: np.ndarray) -> np.ndarray: + """Approximate a ``napari`` ellipse as a polygon, returning the vertices. + + ``napari`` stores an ellipse as the 4 corners of its bounding rectangle. + After the (y, x) → (x, y) swap has already been applied, these are + four corners such that ``xy[0]`` and ``xy[2]`` are diagonally opposite. + + The semi-axis lengths are derived from the side lengths of the bounding + rectangle (the ellipse is inscribed in it). A unit circle is created at + the centre, scaled to the semi-axis lengths, and rotated to match the + rectangle orientation. The resulting polygon's vertices are returned as + an (N, 2) array of (x, y), without repeating the first vertex at the + end. + """ + centre = (xy[0] + xy[2]) / 2 + side_a = xy[1] - xy[0] # one edge of the bounding rectangle + side_b = xy[3] - xy[0] # adjacent edge + semi_a = float(np.linalg.norm(side_a)) / 2 + semi_b = float(np.linalg.norm(side_b)) / 2 + angle = float(np.degrees(np.arctan2(side_a[1], side_a[0]))) + + ellipse_polygon = shapely.affinity.rotate( + shapely.affinity.scale( + shapely.Point(centre).buffer(1), + semi_a, + semi_b, + ), + angle, + ) + + return np.array(ellipse_polygon.exterior.coords)[:-1] diff --git a/movement/napari/regions_widget.py b/movement/napari/regions_widget.py index 323b1dde6..9f7483789 100644 --- a/movement/napari/regions_widget.py +++ b/movement/napari/regions_widget.py @@ -7,21 +7,30 @@ """ import re +from pathlib import Path from napari.layers import Shapes +from napari.utils.notifications import show_error from napari.viewer import Viewer from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt from qtpy.QtWidgets import ( QComboBox, + QFileDialog, + QGridLayout, QGroupBox, - QHBoxLayout, QPushButton, QTableView, QVBoxLayout, QWidget, ) +from movement.napari.convert_roi import ( + napari_shapes_to_rois, + rois_to_napari_shapes, +) from movement.napari.layer_styles import RegionsStyle, _sample_colormap +from movement.roi.io import load_rois, save_rois +from movement.utils.logging import logger DEFAULT_REGION_NAME = "region" DROPDOWN_PLACEHOLDER = "No region layers" @@ -96,25 +105,40 @@ def _setup_regions_ui(self): def _setup_region_layer_controls(self): """Create the region layer controls layout. - Returns a QHBoxLayout containing: + Returns a QGridLayout with two rows sharing the same column widths: - - Dropdown (QComboBox) for selecting region layers - - "Add new layer" button (QPushButton) + - Row 0: Dropdown (QComboBox) for selecting region layers and + an ``"Add new layer"`` button (QPushButton) + - Row 1: ``"Save layer"`` and ``"Load layer"`` buttons (QPushButton) """ - layer_controls_layout = QHBoxLayout() + grid = QGridLayout() self.layer_dropdown = QComboBox() self.layer_dropdown.setMinimumWidth(150) self.layer_dropdown.currentTextChanged.connect(self._on_layer_selected) self.add_layer_button = QPushButton("Add new layer") - self.add_layer_button.setEnabled(True) self.add_layer_button.clicked.connect(self._add_new_layer) - layer_controls_layout.addWidget(self.layer_dropdown) - layer_controls_layout.addWidget(self.add_layer_button) + self.save_layer_button = QPushButton("Save layer") + self.save_layer_button.setEnabled(False) + self.save_layer_button.setToolTip( + "Save all regions in this layer to a GeoJSON file." + ) + self.save_layer_button.clicked.connect(self._save_region_layer) + + self.load_layer_button = QPushButton("Load layer") + self.load_layer_button.setToolTip( + "Load regions from a GeoJSON file into a new region layer." + ) + self.load_layer_button.clicked.connect(self._load_region_layer) + + grid.addWidget(self.layer_dropdown, 0, 0) + grid.addWidget(self.add_layer_button, 0, 1) + grid.addWidget(self.save_layer_button, 1, 0) + grid.addWidget(self.load_layer_button, 1, 1) - return layer_controls_layout + return grid def _setup_regions_table(self): """Create the table view layout. @@ -235,6 +259,51 @@ def _add_new_layer(self): ) self.layer_dropdown.setCurrentText(new_layer.name) + def _save_region_layer(self): + """Save all regions in the current layer to a GeoJSON file.""" + file_path, _ = QFileDialog.getSaveFileName( + self, + "Save region layer", + "", + "GeoJSON files (*.geojson *.json)", + ) + if not file_path: + return + + try: + rois = napari_shapes_to_rois(self.region_table_model.layer) + save_rois(rois, file_path) + logger.info(f"Saved {len(rois)} regions to '{file_path}'.") + except Exception as e: + show_error(f"Failed to save regions to '{file_path}': {e}") + + def _load_region_layer(self): + """Open a GeoJSON file and load its regions into a new region layer.""" + file_path, _ = QFileDialog.getOpenFileName( + self, + "Load region layer", + "", + "GeoJSON files (*.geojson *.json)", + ) + if not file_path: + return + + try: + rois = load_rois(file_path) + except Exception as e: + show_error(f"Failed to load regions from '{file_path}': {e}") + return + + shapes_kwargs = rois_to_napari_shapes(rois) + new_layer = self.viewer.add_shapes( + **shapes_kwargs, + name=Path(file_path).stem, + metadata={REGIONS_LAYER_KEY: True}, + ) + # Select the new layer (which also triggers linking to the table model) + self.layer_dropdown.setCurrentText(new_layer.name) + logger.info(f"Loaded {len(rois)} regions from '{file_path}'.") + def _link_layer_to_model(self, region_layer: Shapes): """Link a regions layer to a new table model. @@ -343,6 +412,14 @@ def _clear_region_table_model(self): self.region_table_model = None self.region_table_view.setModel(None) + def _update_save_button_state(self): + """Enable the Save button only when the current layer has shapes.""" + has_shapes = ( + self.region_table_model is not None + and self.region_table_model.rowCount() > 0 + ) + self.save_layer_button.setEnabled(has_shapes) + def _update_table_tooltip(self): """Update the table tooltip based on current state. @@ -352,6 +429,8 @@ def _update_table_tooltip(self): - How to draw shapes when layer is empty - Usage tips when layer has shapes """ + self._update_save_button_state() + layer_name = self.layer_dropdown.currentText() if not layer_name or layer_name == DROPDOWN_PLACEHOLDER: diff --git a/tests/test_unit/test_napari_plugin/test_convert_roi.py b/tests/test_unit/test_napari_plugin/test_convert_roi.py new file mode 100644 index 000000000..655c973fb --- /dev/null +++ b/tests/test_unit/test_napari_plugin/test_convert_roi.py @@ -0,0 +1,423 @@ +"""Unit tests for conversion between napari shapes and movement RoIs.""" + +import numpy as np +import pytest +import shapely +from napari.layers import Shapes + +from movement.napari.convert_roi import ( + napari_shape_to_roi, + napari_shapes_to_rois, + roi_to_napari_shape, + rois_to_napari_shapes, +) +from movement.roi import LineOfInterest, PolygonOfInterest + +# --------------------------------------------------------------------------- +# Fixtures — napari (y, x) shape data +# +# Where possible, the spatial values are derived from the shared RoI fixtures +# (unit_square_pts) by swapping columns, so that converting back should +# reproduce those same point-sets. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def line_yx(): + """Two-point line in napari (y, x) convention.""" + return np.array([[2.0, 1.0], [4.0, 3.0]]) + + +@pytest.fixture +def path_yx(): + """Three-point path in napari (y, x) convention.""" + return np.array([[0.0, 0.0], [2.0, 1.0], [1.0, 3.0]]) + + +@pytest.fixture +def square_yx(unit_square_pts): + """Unit-square corners in napari (y, x) convention. + + Derived by swapping columns of the shared ``unit_square_pts`` fixture, + which is in movement (x, y) convention. + """ + return unit_square_pts[:, ::-1] + + +@pytest.fixture +def nonsquare_rect_yx(): + """Non-square rectangle in napari (y, x) convention.""" + return np.array([[0.0, 0.0], [0.0, 3.0], [1.0, 3.0], [1.0, 0.0]]) + + +@pytest.fixture +def ellipse_yx(): + """Axis-aligned ellipse in napari (y, x) convention. + + Centre (cy=5, cx=5), semi-axes ry=3, rx=2. + Napari stores ellipses as the 4 corners of the bounding rectangle. + Bounding box: y=[2, 8], x=[3, 7]. + """ + return np.array([[2.0, 3.0], [2.0, 7.0], [8.0, 7.0], [8.0, 3.0]]) + + +# =========================================================================== +# roi_to_napari_shape +# =========================================================================== + + +@pytest.mark.parametrize( + ["roi_fixture", "expected_shape_type"], + [ + pytest.param( + "segment_of_y_equals_x", "path", id="LineOfInterest → path" + ), + pytest.param( + "unit_square", "polygon", id="PolygonOfInterest → polygon" + ), + ], +) +def test_roi_to_napari_shape_type(roi_fixture, expected_shape_type, request): + """Each RoI class maps to the correct napari shape type.""" + roi = request.getfixturevalue(roi_fixture) + _, shape_type = roi_to_napari_shape(roi) + assert shape_type == expected_shape_type + + +@pytest.mark.parametrize( + "roi_fixture", + [ + pytest.param("segment_of_y_equals_x", id="LineOfInterest"), + pytest.param("unit_square", id="PolygonOfInterest (square)"), + pytest.param("triangle", id="PolygonOfInterest (triangle)"), + ], +) +def test_roi_to_napari_shape_output_array(roi_fixture, request): + """Output is an (N, 2) array with no repeated closing vertex.""" + roi = request.getfixturevalue(roi_fixture) + data, _ = roi_to_napari_shape(roi) + assert data.ndim == 2 + assert data.shape[1] == 2 + assert not np.array_equal(data[0], data[-1]) + + +def test_roi_to_napari_shape_coordinate_swap(unit_square): + """Coordinates are returned in (y, x) order (swapped from (x, y)).""" + data, _ = roi_to_napari_shape(unit_square) + # coords property returns (x, y); strip the shapely closing vertex + expected_xy = np.array(unit_square.coords)[:-1] + np.testing.assert_array_equal(data, expected_xy[:, ::-1]) + + +def test_roi_to_napari_shape_closed_line_warning(caplog): + """Converting a closed LineOfInterest emits a warning about the lost + closing segment. + """ + closed_line = LineOfInterest([(0, 0), (1, 0), (0, 1)], loop=True) + data, shape_type = roi_to_napari_shape(closed_line) + assert shape_type == "path" + assert len(data) == 3 # closing vertex stripped, 3 unique points remain + assert any("closing segment" in msg for msg in caplog.messages) + + +# =========================================================================== +# rois_to_napari_shapes +# =========================================================================== + + +@pytest.mark.parametrize( + ["roi_fixtures", "expected_shape_types"], + [ + pytest.param([], [], id="empty sequence"), + pytest.param( + ["segment_of_y_equals_x"], + ["path"], + id="single LineOfInterest", + ), + pytest.param( + ["unit_square"], + ["polygon"], + id="single PolygonOfInterest", + ), + pytest.param( + ["segment_of_y_equals_x", "unit_square", "triangle"], + ["path", "polygon", "polygon"], + id="mixed sequence", + ), + ], +) +def test_rois_to_napari_shapes_output( + roi_fixtures, expected_shape_types, request +): + """Output dict has the correct keys, shape types, and list lengths.""" + rois = [request.getfixturevalue(f) for f in roi_fixtures] + result = rois_to_napari_shapes(rois) + + assert set(result.keys()) == {"data", "shape_type", "properties"} + assert result["shape_type"] == expected_shape_types + assert len(result["data"]) == len(rois) + assert all(arr.shape[1] == 2 for arr in result["data"]) + assert len(result["properties"]["name"]) == len(rois) + + +def test_rois_to_napari_shapes_names(segment_of_y_equals_x, unit_square): + """RoI names, including the default, are preserved in the output.""" + result = rois_to_napari_shapes([segment_of_y_equals_x, unit_square]) + assert result["properties"]["name"] == [ + segment_of_y_equals_x.name, # "Un-named region" (no name assigned) + unit_square.name, # "Unit square" + ] + + +# =========================================================================== +# napari_shape_to_roi +# =========================================================================== + + +@pytest.mark.parametrize( + ["shape_type", "data_fixture", "expected_type"], + [ + pytest.param( + "line", "line_yx", LineOfInterest, id="line → LineOfInterest" + ), + pytest.param( + "path", "path_yx", LineOfInterest, id="path → LineOfInterest" + ), + pytest.param( + "polygon", + "square_yx", + PolygonOfInterest, + id="polygon → PolygonOfInterest", + ), + pytest.param( + "rectangle", + "square_yx", + PolygonOfInterest, + id="rectangle → PolygonOfInterest", + ), + pytest.param( + "ellipse", + "ellipse_yx", + PolygonOfInterest, + id="ellipse → PolygonOfInterest", + ), + ], +) +def test_napari_shape_to_roi_type( + shape_type, data_fixture, expected_type, request +): + """Each napari shape type maps to the correct movement RoI class.""" + data = request.getfixturevalue(data_fixture) + assert isinstance(napari_shape_to_roi(data, shape_type), expected_type) + + +@pytest.mark.parametrize( + ["name", "expected_name"], + [ + pytest.param("my boundary", "my boundary", id="explicit name"), + pytest.param(None, "Un-named region", id="None uses default name"), + pytest.param("", "Un-named region", id="empty str uses default name"), + ], +) +def test_napari_shape_to_roi_name(line_yx, name, expected_name): + """The ``name`` argument is assigned to the resulting RoI.""" + roi = napari_shape_to_roi(line_yx, "line", name=name) + assert roi.name == expected_name + + +@pytest.mark.parametrize( + ["shape_type", "data_fixture", "expected_geometry"], + [ + pytest.param( + "line", + "line_yx", + shapely.LineString([[1.0, 2.0], [3.0, 4.0]]), + id="line: (y,x) coords are swapped to (x,y)", + ), + pytest.param( + "polygon", + "square_yx", + shapely.Polygon([[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]), + id="polygon: (y,x) coords are swapped to (x,y)", + ), + pytest.param( + "rectangle", + "nonsquare_rect_yx", + shapely.Polygon([[0.0, 0.0], [3.0, 0.0], [3.0, 1.0], [0.0, 1.0]]), + id="rectangle: (y,x) coords are swapped to (x,y)", + ), + ], +) +def test_napari_shape_to_roi_coordinate_swap( + shape_type, data_fixture, expected_geometry, request +): + """Napari (y, x) coordinates are converted to movement (x, y).""" + data = request.getfixturevalue(data_fixture) + roi = napari_shape_to_roi(data, shape_type) + assert shapely.normalize(roi.region) == shapely.normalize( + expected_geometry + ) + + +def test_napari_shape_to_roi_ellipse_approximation(ellipse_yx): + """An ellipse is approximated as a polygon whose bounds and area match + the theoretical ellipse to within 1%. + """ + roi = napari_shape_to_roi(ellipse_yx, "ellipse") + # centre (5, 5), semi_x=2, semi_y=3 + # bounds: (5-2, 5-3, 5+2, 5+3) + assert roi.region.bounds == pytest.approx((3.0, 2.0, 7.0, 8.0), abs=0.1) + # area: π * semi_x * semi_y = π * 2 * 3 ≈ 18.85 + expected_area = np.pi * 2 * 3 + assert roi.region.area == pytest.approx(expected_area, rel=0.01) + + +@pytest.mark.parametrize( + ["data", "shape_type", "match"], + [ + pytest.param( + np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]), + "line", + "3 columns", + id="3-column data raises ValueError", + ), + pytest.param( + np.array([[0.0, 1.0], [2.0, 3.0]]), + "circle", # type: ignore[arg-type] + "Unrecognized napari shape type", + id="unknown shape_type raises ValueError", + ), + ], +) +def test_napari_shape_to_roi_invalid_input(data, shape_type, match): + """Invalid inputs raise ValueError with an informative message.""" + with pytest.raises(ValueError, match=match): + napari_shape_to_roi(data, shape_type) + + +# =========================================================================== +# napari_shapes_to_rois +# =========================================================================== + + +@pytest.mark.parametrize( + ["data_fixtures", "shape_types", "expected_roi_types"], + [ + pytest.param([], [], [], id="empty layer"), + pytest.param( + ["square_yx", "path_yx"], + ["polygon", "path"], + [PolygonOfInterest, LineOfInterest], + id="polygon and path", + ), + ], +) +def test_napari_shapes_to_rois_output( + data_fixtures, shape_types, expected_roi_types, request +): + """Returns one RoI per shape with the correct type, in order.""" + data = [request.getfixturevalue(f) for f in data_fixtures] + layer = Shapes(data, shape_type=shape_types) + rois = napari_shapes_to_rois(layer) + assert len(rois) == len(expected_roi_types) + for roi, expected_type in zip(rois, expected_roi_types, strict=True): + assert isinstance(roi, expected_type) + + +@pytest.mark.parametrize( + ["properties", "expected_names"], + [ + pytest.param( + {"name": ["sq", "pth"]}, + ["sq", "pth"], + id="explicit names are preserved", + ), + pytest.param( + {"name": ["sq", ""]}, + ["sq", "Un-named region"], + id="blank name uses default", + ), + pytest.param( + {}, + ["Un-named region", "Un-named region"], + id="absent name property uses default", + ), + ], +) +def test_napari_shapes_to_rois_names( + square_yx, path_yx, properties, expected_names +): + """Names are read from layer properties. + Missing or blank use the default. + """ + layer = Shapes( + [square_yx, path_yx], + shape_type=["polygon", "path"], + properties=properties, + ) + rois = napari_shapes_to_rois(layer) + assert [roi.name for roi in rois] == expected_names + + +# =========================================================================== +# Roundtrips +# =========================================================================== + + +@pytest.mark.parametrize( + "roi_fixture", + [ + pytest.param("segment_of_y_equals_x", id="LineOfInterest"), + pytest.param("unit_square", id="PolygonOfInterest (square)"), + pytest.param("triangle", id="PolygonOfInterest (triangle)"), + ], +) +def test_roundtrip_roi_to_napari_to_roi(roi_fixture, request): + """Converting a RoI to a napari shape and back preserves the geometry.""" + roi = request.getfixturevalue(roi_fixture) + data, shape_type = roi_to_napari_shape(roi) + roi2 = napari_shape_to_roi(data, shape_type) + assert shapely.normalize(roi.region) == shapely.normalize(roi2.region) + + +@pytest.mark.parametrize( + ["shape_type", "data_fixture", "expected_shape_type_back"], + [ + pytest.param("line", "line_yx", "path", id="line → path"), + pytest.param("path", "path_yx", "path", id="path → path"), + pytest.param( + "polygon", "square_yx", "polygon", id="polygon → polygon" + ), + pytest.param( + "rectangle", "square_yx", "polygon", id="rectangle → polygon" + ), + pytest.param( + "ellipse", "ellipse_yx", "polygon", id="ellipse → polygon" + ), + ], +) +def test_roundtrip_napari_to_roi_to_napari( + shape_type, data_fixture, expected_shape_type_back, request +): + """Converting a napari shape to a RoI and back gives a predictable shape + type: ``"line"`` and ``"path"`` both return as ``"path"``; + ``"polygon"``, ``"rectangle"``, and ``"ellipse"`` all return as + ``"polygon"``. + """ + data = request.getfixturevalue(data_fixture) + roi = napari_shape_to_roi(data, shape_type) + _, shape_type_back = roi_to_napari_shape(roi) + assert shape_type_back == expected_shape_type_back + + +def test_roundtrip_rois_to_shapes_layer_to_rois( + segment_of_y_equals_x, unit_square, triangle +): + """Converting RoIs to a layer and back preserves geometry and names.""" + rois = [segment_of_y_equals_x, unit_square, triangle] + layer = Shapes(**rois_to_napari_shapes(rois)) + rois2 = napari_shapes_to_rois(layer) + assert len(rois2) == len(rois) + for roi, roi2 in zip(rois, rois2, strict=True): + assert shapely.normalize(roi.region) == shapely.normalize(roi2.region) + assert roi.name == roi2.name diff --git a/tests/test_unit/test_napari_plugin/test_regions_widget.py b/tests/test_unit/test_napari_plugin/test_regions_widget.py index c40480a62..67c911051 100644 --- a/tests/test_unit/test_napari_plugin/test_regions_widget.py +++ b/tests/test_unit/test_napari_plugin/test_regions_widget.py @@ -81,9 +81,34 @@ def test_widget_has_expected_ui_elements(regions_widget): assert len(group_boxes) == 2 assert regions_widget.findChild(QComboBox) is not None assert regions_widget.add_layer_button is not None + assert regions_widget.save_layer_button is not None + assert regions_widget.load_layer_button is not None assert regions_widget.findChild(QTableView) is not None +@pytest.mark.parametrize( + "button_attr, expected_tooltip", + [ + pytest.param( + "save_layer_button", + "Save all regions in this layer to a GeoJSON file.", + id="save_button", + ), + pytest.param( + "load_layer_button", + "Load regions from a GeoJSON file into a new region layer.", + id="load_button", + ), + ], +) +def test_save_load_button_tooltips( + regions_widget, button_attr, expected_tooltip +): + """Test that Save and Load buttons have informative tooltips.""" + button = getattr(regions_widget, button_attr) + assert button.toolTip() == expected_tooltip + + def test_color_assignment_is_sequential_and_stable(make_napari_viewer_proxy): """Test that palette colors are assigned sequentially and stay stable. @@ -157,6 +182,31 @@ def test_add_layer_button_connected_to_handler( mock_method.assert_called_once() +def test_save_layer_button_connected_to_handler( + make_napari_viewer_proxy, mocker +): + """Test that clicking Save layer button calls the handler.""" + mock_method = mocker.patch( + "movement.napari.regions_widget.RegionsWidget._save_region_layer" + ) + widget = RegionsWidget(make_napari_viewer_proxy()) + widget.save_layer_button.setEnabled(True) + widget.save_layer_button.click() + mock_method.assert_called_once() + + +def test_load_layer_button_connected_to_handler( + make_napari_viewer_proxy, mocker +): + """Test that clicking Load layer button calls the handler.""" + mock_method = mocker.patch( + "movement.napari.regions_widget.RegionsWidget._load_region_layer" + ) + widget = RegionsWidget(make_napari_viewer_proxy()) + widget.load_layer_button.click() + mock_method.assert_called_once() + + def test_dropdown_connected_to_layer_selection_handler( make_napari_viewer_proxy, mocker ): @@ -251,6 +301,135 @@ def test_add_new_layer(regions_widget): assert regions_widget.layer_dropdown.currentText() == layer.name +@pytest.mark.parametrize( + "dialog_method, mocked_fn, widget_method", + [ + pytest.param( + "QFileDialog.getOpenFileName", + "load_rois", + "_load_region_layer", + id="load_cancel", + ), + pytest.param( + "QFileDialog.getSaveFileName", + "save_rois", + "_save_region_layer", + id="save_cancel", + ), + ], +) +def test_load_save_region_layer_cancel( + regions_widget_with_layer, mocker, dialog_method, mocked_fn, widget_method +): + """Test that cancelling the file dialog skips the operation.""" + mocker.patch( + f"movement.napari.regions_widget.{dialog_method}", + return_value=("", None), + ) + mock_fn = mocker.patch(f"movement.napari.regions_widget.{mocked_fn}") + widget, _ = regions_widget_with_layer + getattr(widget, widget_method)() + mock_fn.assert_not_called() + + +@pytest.mark.parametrize( + "rois_or_error, expected_n_layers, expected_layer_name", + [ + pytest.param( + [], + 1, + "my_regions", + id="valid_file_creates_layer", + ), + pytest.param( + ValueError("invalid GeoJSON"), + 0, + None, + id="invalid_file_logs_error", + ), + ], +) +def test_load_region_layer( + regions_widget, + mocker, + caplog, + rois_or_error, + expected_n_layers, + expected_layer_name, +): + """Test _load_region_layer with a valid file and with an invalid file.""" + mocker.patch( + "movement.napari.regions_widget.QFileDialog.getOpenFileName", + return_value=("/fake/my_regions.geojson", None), + ) + if isinstance(rois_or_error, Exception): + mocker.patch( + "movement.napari.regions_widget.load_rois", + side_effect=rois_or_error, + ) + else: + mocker.patch( + "movement.napari.regions_widget.load_rois", + return_value=rois_or_error, + ) + + mock_show_error = mocker.patch("movement.napari.regions_widget.show_error") + regions_widget._load_region_layer() + + assert len(regions_widget.viewer.layers) == expected_n_layers + if expected_layer_name is not None: + layer = regions_widget.viewer.layers[0] + assert layer.name == expected_layer_name + assert layer.metadata.get(REGIONS_LAYER_KEY) is True + assert ( + regions_widget.layer_dropdown.currentText() == expected_layer_name + ) + assert any("Loaded" in msg for msg in caplog.messages) + mock_show_error.assert_not_called() + else: + mock_show_error.assert_called_once() + + +@pytest.mark.parametrize( + "save_side_effect, expect_error", + [ + pytest.param(None, False, id="valid_save"), + pytest.param(OSError("permission denied"), True, id="save_error"), + ], +) +def test_save_region_layer( + regions_widget_with_layer, + mocker, + caplog, + save_side_effect, + expect_error, +): + """Test _save_region_layer: logs success info or shows error on failure.""" + widget, _ = regions_widget_with_layer + mocker.patch( + "movement.napari.regions_widget.QFileDialog.getSaveFileName", + return_value=("/fake/my_regions.geojson", None), + ) + mock_save = mocker.patch( + "movement.napari.regions_widget.save_rois", + side_effect=save_side_effect, + ) + mock_show_error = mocker.patch("movement.napari.regions_widget.show_error") + + widget._save_region_layer() + + if expect_error: + mock_save.assert_called_once() + mock_show_error.assert_called_once() + else: + assert any("Saved" in msg for msg in caplog.messages) + mock_save.assert_called_once() + mock_show_error.assert_not_called() + rois_arg, path_arg = mock_save.call_args.args + assert len(rois_arg) == 2 # regions_widget_with_layer has 2 shapes + assert path_arg == "/fake/my_regions.geojson" + + def test_add_multiple_layers_increments_name(regions_widget): """Test that multiple new layers get unique names.""" regions_widget._add_new_layer() @@ -749,12 +928,13 @@ def test_table_allows_name_editing(regions_widget_with_layer): # ------------------- Tests for tooltips -------------------------------------# @pytest.mark.parametrize( - "add_shapes_kwargs, expected_text", + "add_shapes_kwargs, expected_tooltip_text, expected_save_enabled", [ - pytest.param(None, "No region layers", id="no_layers"), + pytest.param(None, "No region layers", False, id="no_layers"), pytest.param( {"name": "regions"}, "No regions in this layer", + False, id="empty_layer", ), pytest.param( @@ -763,19 +943,35 @@ def test_table_allows_name_editing(regions_widget_with_layer): "data": [[[0, 0], [0, 10], [10, 10], [10, 0]]], }, "Click a row", + True, id="with_shapes", ), ], ) -def test_table_tooltip_reflects_state( - make_napari_viewer_proxy, add_shapes_kwargs, expected_text +def test_table_tooltip_and_save_button_reflect_state( + make_napari_viewer_proxy, + add_shapes_kwargs, + expected_tooltip_text, + expected_save_enabled, ): - """Test table tooltip text reflects current widget state.""" + """Test table tooltip text and Save button enabled state. + + The table tooltip and the save button should update based on the presence + and content of region layers: + + - When no region layers exist, tooltip should indicate this and + Save should be disabled. + - When a region layer exists but is empty, tooltip should indicate + this and Save should be disabled. + - When a region layer with shapes exists, tooltip should prompt user with + possible table interactions, and Save should be enabled. + """ viewer = make_napari_viewer_proxy() if add_shapes_kwargs is not None: add_regions_layer(viewer, **add_shapes_kwargs) widget = RegionsWidget(viewer) - assert expected_text in widget.region_table_view.toolTip() + assert expected_tooltip_text in widget.region_table_view.toolTip() + assert widget.save_layer_button.isEnabled() == expected_save_enabled # ------------------- Tests for edge cases -----------------------------------#