Skip to content

Add conversion layer between napari Shapes and movement regions of interest (RoIs)#927

Draft
niksirbi wants to merge 15 commits intomainfrom
napari-save-load-regions
Draft

Add conversion layer between napari Shapes and movement regions of interest (RoIs)#927
niksirbi wants to merge 15 commits intomainfrom
napari-save-load-regions

Conversation

@niksirbi
Copy link
Copy Markdown
Member

@niksirbi niksirbi commented Mar 23, 2026

Description

What is this PR

  • Bug fix
  • Addition of a new feature
  • Other

Why is this PR needed?

Closes #676.

Users can draw Regions of Interest (RoIs) in the napari GUI (via #617), but there is no way to convert those shapes to movement RoI objects for analysis, or to load previously defined RoIs back into napari.

This PR adds the conversion layer that makes both directions possible.

What does this PR do?

  • Adds conversion functions between napari shapes and movement RoIs (movement/napari/convert_roi.py).
  • Adds "Save layer" and "Load layer" buttons to the regions widget introduced in Widget for drawing Regions of Interest (ROIs) as napari Shapes #617 and connects them (via the aforementioned conversion functions) to the existing movement.roi.io functions that read/write GeoJSON files.
  • Updates the "Define Regions of Interest" section of the GUI user guide to explain the new Save/Load functionality.
  • Update the boundary_angles.py example involving RoIs to refer to drawing regions in napari instead of defining them programmatically.
  • Adds an integration test that covers the full workflow of drawing some shapes, saving them, loading them in Python, using them in analysis, and then loading them back into the GUI.

A sneak-peek of the "Define regions of interest" widget as it looks with these additions:

napari_aeon_regions

Conversion functions (convert_roi.py)

The new module lives under movement/napari/ rather than movement/roi/. It makes sense to keep napari an optional dependency that the core RoI module never imports. Better to have the napari part of the code import from the core RoI module than the other way around.

The module provides four public functions:

  • roi_to_napari_shape: single movement RoI → napari shape (data + shape type)
  • rois_to_napari_shapes: sequence of RoIs → dict of kwargs ready for add_shapes
  • napari_shape_to_roi: single napari shape → movement RoI
  • napari_shapes_to_rois: entire napari Shapes layer → list of RoIs

Note

Question to the NIU reviewer:
Now that we have movement/napari/convert_roi.py, does it make sense to rename the older movement/napari/convert.py to movement/napari/convert_dataset.py for clarity?

Known conversion losses

Also documented in docstrings and user guide, but worth calling out here:

  • Shape type collapsing: "line" and "path" both become LineOfInterest; "polygon", "rectangle", and "ellipse" all become PolygonOfInterest. On the way back, LineOfInterest becomes "path" and PolygonOfInterest becomes "polygon". I think this is acceptable because the specific shape type is a GUI drawing concern, not an analytical one. What matters is that the geometry is preserved (true except for the following 2 cases) and users can roundtrip without redefining their RoIs by hand.
  • Ellipse approximation: Ellipses have no native shapely representation and are approximated as polygons (a logged info message is emitted). Introducing a custom EllipseOfInterest class would depart from the shapely/GeoJSON standard we otherwise follow for RoI geometry, and the polygon approximation is accurate enough for practical analysis purposes.
  • Closed lines: LineOfInterest objects with loop=True have no closed-path equivalent in napari; the closing segment is dropped and a warning is emitted. Loading them as polygons would be more surprising, since a closed line and a filled polygon have different semantics. This is a niche case anyway, as closed lines can only be created via the Python API, not by drawing in the GUI.

Save/Load buttons (regions_widget.py)

  • The Save button writes the current region layer to a GeoJSON file via napari_shapes_to_rois, followed by save_rois. It is disabled when the layer has no shapes.
  • The Load button reads a GeoJSON file via load_rois, followed by rois_to_napari_shapes, and adds its regions as a new layer named after the file stem, which is then selected in the dropdown.
  • Both buttons use napari.utils.notifications.show_error to surface errors in the napari GUI. This is consistent with loader_widgets.py and avoids double-logging, since the underlying I/O functions already log errors at the source.
  • Successful saves and loads are recorded with logger.info (also consistent with loader_widgets.py), including the number of regions and file path.

References

This is part of the larger #378 effort, and is the missing link that connects #617 with #675.

How has this PR been tested?

  • Unit tests for the four conversion functions are in tests/test_unit/test_napari_plugin/test_convert_roi.py, including roundtrip tests in both directions.
  • Unit tests for the Save/Load buttons are in tests/test_unit/test_napari_plugin/test_regions_widget.py, covering: button tooltips, save-button enable/disable state, handler connections, cancel (no-op when dialog is dismissed), valid load/save, and error paths.
  • Integration tests: pending...

How to review this PR?

This is mainly addressed to our collaborators at the Keshavarzi Lab, but all are welcome to review! This is how I recommend you start using the new funcitonality.

Quick start: set up a test environment

Clone the repo and check out this branch:

git clone https://github.com/neuroinformatics-unit/movement.git
cd movement
git fetch origin napari-save-load-regions
git checkout napari-save-load-regions

Create a virtual environment (with conda or uv) and install movement with GUI and docs dependencies:

conda:

conda create -n movement-review -c conda-forge python=3.13
conda activate movement-review
pip install -e ".[dev,docs]"

uv:

uv venv --python=3.13
source .venv/bin/activate    # macOS / Linux
.venv\Scripts\activate       # Windows PowerShell
uv pip install -e ".[dev,docs]"

Build and browse the docs

cd docs
make clean html

On Windows PowerShell, use .\make clean html instead.

Then open docs/build/html/index.html in your browser and navigate to User Guide > Graphical User Interface > Define regions of interest.

Things to try

  1. Follow the updated GUI guide. Launch the GUI with movement launch, draw some regions, save them to a GeoJSON file, close and reopen the GUI, and load them back.
  2. Use saved regions in Python. Follow the instructions in the Using saved regions in Python dropdown (also found in the guide) to load your saved GeoJSON file with load_rois and use the loaded regions in your Python code.
  3. Check the updated example. Navigate to Examples > Compute distances and angles to regions of interest in the built docs. This should serve as inspiration for how to use the drawn regions in Python analysis (in this case for spatial navigation applications).

Is this a breaking change?

No.

Does this PR require an update to the documentation?

  • API docs for the new conversion functions have been auto-generated and inspected.
  • The corresponding section of the GUI user guide has been updated.
  • Gallery example involving RoIs has been updated.

Checklist:

  • The code has been tested locally
  • Tests have been added to cover all new functionality
  • The documentation has been updated to reflect any changes
  • The code has been formatted with pre-commit

niksirbi and others added 8 commits March 23, 2026 10:57
- Replace the single-row HBoxLayout in the layer controls group with a
  QGridLayout so that the new Save/Load row aligns with the dropdown and
  Add buttons above it.
- Save button is disabled until the current layer has at least one shape
  (_update_save_button_state, called from _update_table_tooltip).
- Both buttons carry descriptive tooltips explaining the GeoJSON workflow.
- Update tests: check new buttons exist, verify tooltips, extend the
  tooltip/state parametrized test to also assert Save button enablement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Implement _load_region_layer: opens a GeoJSON file dialog, calls
  load_rois and rois_to_napari_shapes, then adds a new region layer
  named after the file stem. Errors are logged without crashing the
  widget.
- Add imports: Path, QFileDialog, load_rois, rois_to_napari_shapes,
  logger.
- Tests: connection test, cancel-does-nothing test, and parametrized
  test covering both valid file (layer created with correct name and
  metadata) and invalid file (error logged, no layer created).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Narrow return annotation from BaseRegionOfInterest to
LineOfInterest | PolygonOfInterest, which is what the function
always returns. This resolves the mypy list comprehension type
mismatch in napari_shapes_to_rois.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 23, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (3efc8ca) to head (19ff84b).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #927   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           39        40    +1     
  Lines         2678      2771   +93     
=========================================
+ Hits          2678      2771   +93     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

niksirbi and others added 7 commits March 24, 2026 10:13
- _save_region_layer: opens a file dialog, converts the current layer's
  shapes to RoIs via napari_shapes_to_rois, and writes them with
  save_rois; logs success or error.
- _load_region_layer: opens a file dialog, reads RoIs with load_rois,
  adds them as a new Shapes layer named after the file stem, and selects
  it in the dropdown; logs success or error.
- _update_save_button_state: enables the Save button only when the
  current layer has at least one shape; called from _update_table_tooltip.
- Tests cover button tooltips, save-button enable/disable state,
  handler connections, cancel (no-op), valid load/save, and error paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The underlying load_rois/save_rois functions already log errors at the
source. The widget callbacks only need to surface them in the napari GUI
via show_error, consistent with the pattern in loader_widgets.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_ellipse_to_polygon assumed napari stores ellipses as 4 cardinal
points (semi-axis endpoints), but napari actually stores the 4
corners of the bounding rectangle. The old code computed semi-axes
as centre-to-corner distances (the diagonal), producing polygons
~30% larger than the original ellipse. Fix by deriving semi-axes
from the side lengths of the bounding rectangle instead.

Also update the test fixture to use bounding box corners and add
an area check (π·a·b) to catch size discrepancies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement conversion function between napari Shapes layer and our ROI classes

1 participant