Skip to content

Add sdata.pl.annotate() — interactive region selection via anywidget#684

Draft
timtreis wants to merge 6 commits into
mainfrom
feature/interactive-annotate
Draft

Add sdata.pl.annotate() — interactive region selection via anywidget#684
timtreis wants to merge 6 commits into
mainfrom
feature/interactive-annotate

Conversation

@timtreis
Copy link
Copy Markdown
Member

@timtreis timtreis commented May 21, 2026

Summary

Adds sdata.pl.annotate(coordinate_system, element, *, persist=True) — an in-notebook widget for drawing regions on a spatialdata-plot canvas and persisting them back into the SpatialData object as a ShapesModel. Works over SSH to a remote/SLURM node.

  • New module src/spatialdata_plot/pl/interactive/ (canvas, render, commit, persist, session) backed by a custom anywidget with HTML5/SVG drawing tools (rectangle, polygon, lasso).
  • New optional extra pip install 'spatialdata-plot[interactive]' (anywidget, ipywidgets, ipykernel) — feature is gated behind a clear ImportError when missing.
  • Spec at plans/interactive-selection.md (v0 scope, Q1–Q4 locked).

Why anywidget (and not ipympl / plotly)

Both were prototyped and rejected:

  • ipympl streams PNG frames per mouse-move over websocket → unusable freehand latency over SSH.
  • plotly's FigureWidget has broken two-way shape sync in VSCode-Remote-SSH (different bugs in 5.x and 6.x).

The custom anywidget was the only architecture that worked reliably over SSH while staying responsive — image renders once via the existing render_images().show() pipeline, then all drawing happens client-side and only the final shape geometry round-trips to Python.

Drawing UX

  • Tools: rectangle (drag), polygon (click + snap-close / Enter), lasso (freehand drag)
  • Wheel zoom, shift-drag pan, alt-click shape to delete, Ctrl+Z undo, R/P/L tool shortcuts, F fit view
  • Multi-shape bundling: each Save commits all canvas shapes as one ShapesModel (multiple rows) under a single name

Coordinate-system safety

Session is bound to one CS; render is 1:1 in that CS; committed ShapesModel registers with {cs_name: Identity()} to avoid the classic double-applied-transform bug.

timtreis and others added 4 commits May 21, 2026 16:42
Add plans/interactive-selection.md documenting the v0 design for
sdata.pl.interactive(...): in-notebook selector widget that draws a
region on a spatialdata-plot canvas and persists it back into the
SpatialData object as a ShapesModel. Includes resolved Q1-Q4, coordinate-
system rules, downsampling strategy, persistence policy, and a 12-task
implementation queue.

Add a pixi `interactive` dep-group (ipympl, ipywidgets, squidpy) and a
new `dev-interactive-py313` environment for prototyping. Register a
dedicated `sdata-plot-interactive` kernel-install task to avoid the
existing `pixi-dev` kernel name collision.

Rewrite the broken [tool.pixi] inline-dotted block to explicit table
headers ([tool.pixi.workspace], etc.) so pixi 0.54.2 actually loads the
manifest.

This commit records the ipympl-based prototype iteration. The notebook
prototype (Sandbox.ipynb in lustre, not tracked here) revealed that
websocket-streamed PNG frames are too laggy over SSH for full-slide
interactive drawing; the next iteration switches to Plotly's client-side
draw tools while keeping the same spec and task queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add anywidget and plotly>=5.20,<6 to the pixi interactive dep-group so the
prototype notebook can render a custom HTML5/SVG drawing widget. anywidget
is the canonical path for traitlet-based widget sync in VSCode-Remote;
plotly is pinned to 5.x because its 6.0 anywidget-backed FigureWidget does
not relay client-side relayout events back to Python (so layout.shapes
never syncs there).

The Sandbox.ipynb prototype itself lives outside this repo
(/home/.../lustre/projects/spatialdata-plot/), but its current state
implements a working anywidget-based draw canvas: pure client-side SVG
drawing (rectangle drag, polygon click-then-Close-polygon, lasso freehand
drag), shapes pushed back via the `shapes` traitlet, pixel→CS coordinate
mapping that respects matplotlib's origin='upper' image axis, multi-shape
commit per Save, and an explicit "Write last to disk" button for
persistence. Sandbox.anywidget-v0.ipynb is preserved alongside as a
reference snapshot before optimization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Productionises the Sandbox.ipynb prototype as a user-facing method on
PlotAccessor. Public surface is a single function:

    sdata.pl.annotate(coordinate_system, element, *, persist=True) -> None

Both args are required positional. The function validates that the image
element is registered in the given coordinate system, renders it to a PNG,
constructs an internal _InteractiveSession with anywidget-driven drawing
tools (rectangle / polygon / lasso), and displays the widget. Drawn shapes
are written into sdata.shapes[name] on click of the Save button; the
optional "Write to disk" button persists via sdata.write_element.

Module layout (src/spatialdata_plot/pl/interactive/):
  - _canvas.py             DrawCanvas anywidget class
  - static/draw_canvas.js  ESM module read from disk by anywidget (HMR-friendly)
  - _render.py             render_to_png: sdata.pl → PNG + ax extent
  - _commit.py             pixel-coord shape → CS-coord shapely Polygon → ShapesModel
  - _persist.py            commit_to_memory + persist_to_disk (collision policy)
  - _session.py            _InteractiveSession orchestrating the widget

The new optional extra `interactive` (anywidget, ipykernel, ipywidgets)
gates this feature behind a clear ImportError when missing:

    pip install 'spatialdata-plot[interactive]'

The prototype iteration explored ipympl (rejected: PNG-over-websocket
latency unusable over SSH) and plotly's FigureWidget (rejected: client-
side relayout events don't sync back to Python in VSCode-Remote, plus
plotly 6's anywidget-backed FigureWidget broke the comm path entirely).
The custom anywidget approach was the only architecture that worked
reliably over SSH while staying responsive.

Drawing UX:
  - Tools: rect (drag), polygon (click + snap-close), lasso (drag freehand)
  - Wheel zoom, shift-drag pan, alt-click shape to delete
  - Ctrl+Z undo, R/P/L tool shortcuts, F fit view, Enter close polygon
  - Multi-shape bundling: each Save commits all canvas shapes as one
    ShapesModel with multiple rows under a single name

Tests cover the unit surface (pixel→CS conversion, ShapesModel transform
registration, render-to-PNG correctness, commit/persist policy, widget
smoke).

Spec at plans/interactive-selection.md updated to document the
architectural pivot from the original ipympl approach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Tests: import `DrawCanvas` from `._canvas` (internal class is not
  re-exported), and gate `test_canvas` with `pytest.importorskip` so
  CI envs without the `interactive` extra skip rather than fail.
- `_persist.py`: replace deprecated `datetime.utcnow()` with
  timezone-aware `datetime.now(timezone.utc)`. Document on-disk
  overwrite behaviour (asymmetric with in-memory rename-on-collision).
- `_render.py`: wrap render in `try/finally` so figures don't leak if
  `render_images().show()` or `savefig` raises.
- `draw_canvas.js`: Delete/Backspace now removes the most recent shape
  (matches Ctrl+Z) instead of wiping the whole canvas — the toolbar
  Clear button covers the wipe case.
- `basic.py` docstring: note that the canvas clears on every Save and
  that the Write-to-disk button overwrites same-named on-disk elements.
- Add `tests/test_interactive/test_annotate.py` covering the three
  validation paths (`unknown CS`, `unknown element`, `element not in CS`)
  by stubbing `_InteractiveSession.show`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 21, 2026

Codecov Report

❌ Patch coverage is 26.80851% with 172 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.79%. Comparing base (8a6a33f) to head (26b628b).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/spatialdata_plot/pl/interactive/_session.py 0.00% 146 Missing ⚠️
src/spatialdata_plot/pl/interactive/_canvas.py 0.00% 18 Missing ⚠️
src/spatialdata_plot/pl/basic.py 14.28% 6 Missing ⚠️
src/spatialdata_plot/pl/interactive/_commit.py 92.85% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #684      +/-   ##
==========================================
- Coverage   77.79%   74.79%   -3.00%     
==========================================
  Files          11       17       +6     
  Lines        3693     3944     +251     
  Branches      877      901      +24     
==========================================
+ Hits         2873     2950      +77     
- Misses        490      662     +172     
- Partials      330      332       +2     
Files with missing lines Coverage Δ
src/spatialdata_plot/pl/interactive/__init__.py 100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_persist.py 100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_render.py 100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_commit.py 92.85% <92.85%> (ø)
src/spatialdata_plot/pl/basic.py 84.78% <14.28%> (-1.42%) ⬇️
src/spatialdata_plot/pl/interactive/_canvas.py 0.00% <0.00%> (ø)
src/spatialdata_plot/pl/interactive/_session.py 0.00% <0.00%> (ø)

... and 1 file with indirect coverage changes

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

timtreis and others added 2 commits May 21, 2026 23:16
Reuse / convention fixes:
- Delete `_persist.persist_to_disk` — `SpatialData.write_element` already
  raises `ValueError` when `path is None`. Inline `write_element(name,
  overwrite=True)` in `_on_persist` and fix the docstring claim about
  overwrite semantics.
- Match project import convention: `from spatialdata.transformations.
  operations import get_transformation` and `...transformations import
  Identity`, replacing `sd.transformations.*` references in `_session`,
  `_commit`, and tests.
- `_validate` uses `sdata[element]` indexing + `get_transformation` to
  match the established pattern in `pl/utils.py` / `pl/render.py`.

Quality:
- Introduce frozen `RenderExtent` dataclass returned by `render_to_png`;
  collapses the 5-tuple return + 4 cached attrs on `_InteractiveSession`
  into one object. `pixel_shape_to_polygon(shape, extent)` drops 4 args.
- `traitlets.Enum(TOOLS, ...)` for `DrawCanvas.tool` so a typo raises.
- `BannerKind = Literal["info","success","error","hint"]`; drop the
  silent `.get(..., default)` fallback so a banner-kind typo raises.
- Factor `_trigger_btn(description, icon, trait_name, after=...)` —
  replaces 4 near-identical `_on_close_polygon` / `_on_undo` / `_on_clear`
  / `_on_fit` methods.
- Split `_on_save` into `_collect_polygons` / `_commit_polygons` /
  `_reset_canvas_state`; orchestrator stays ~10 lines.
- Drop redundant `spine.set_visible(False)` loop after `set_axis_off()`.
- Guard `persist_btn` construction behind `persist=True`; `_on_persist`
  early-returns if disabled.
- Strip restate-the-code comments (`_canvas` module docstring,
  `_render` v0/v1 narration, `_commit` lasso-restating comment).
- Underscore truly-private attrs (`_sdata`, `_commits`); keep `canvas`
  un-underscored since `_validate` callers in tests still need a way in.

Efficiency (JS):
- Incremental in-progress shape update during rect/lasso drag — keep a
  stable reference to the in-progress SVG node; mutate its attributes in
  `onMouseMove` instead of full `redraw()` (60 Hz × O(N) DOM ops → O(1)).
- Lasso vert-push gated on viewbox-px ≥ 1 from the last vert; cuts
  vertex count ~5-10× for typical drags and the kernel-side traitlet
  payload on commit.
- `setShapes` early-returns on `next === shapes` or both-empty; the
  `clear_trigger` handler routes through `setShapes([])` and skips when
  there is nothing to clear or cancel.
- `zoomAt` / `panBy` / `fitView` snapshot the vbox pre-clamp and skip
  `applyViewbox` + `redraw` if the clamped vbox is unchanged.
- `change:tool` only redraws if there was an in-progress shape to clear.
- Dedup: `popLastShape` helper used by Ctrl+Z, Delete/Backspace, and
  `change:undo_trigger`. `shapeNode` extracts the common stroke/fill
  attrs and uses a single `pointsAttr` formatter for polygon/polyline.

Tests:
- Pytest `no_display` fixture replaces three duplicate `monkeypatch.
  setattr(...)` calls in `test_annotate.py`.
- `pixel_shape_to_polygon` tests updated to the `RenderExtent` signature
  via a small `_extent(...)` helper.
- `test_render` reads `extent.image_w` / `extent.xlim` from the
  dataclass instead of unpacking a 5-tuple.
- Drop the two `test_persist` tests for the deleted `persist_to_disk`
  wrapper; `commit_to_memory` policy tests remain.

All 18 interactive tests pass in dev-interactive-py313.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UX:
- Responsive canvas via `width: 100%` + `max-width: Npx` + CSS
  `aspect-ratio` on the wrap/container divs. Removes the fixed
  `DISP_MAX = 760` pixel box. Below `max_width` the canvas scales with
  the surrounding column; above it the canvas caps and centers.
- New `max_width: int = 880` kwarg on `sdata.pl.annotate(...)` plumbed
  through `_InteractiveSession.__init__` and a new `max_display_width`
  `Int` traitlet on `DrawCanvas`. Pure display hint; underlying PNG
  render is unchanged (840 × 840).
- Toolbar reflow: replace `HBox` with `Box(layout=Layout(flex_flow=
  "row wrap", ...))` so the tool toggle + icon buttons wrap onto a
  second row under narrow notebook widths instead of overflowing.
- Icon-only auxiliary buttons (Close polygon / Undo / Clear / Fit /
  Write to disk) — `description=""`, 36px square, tooltip carries the
  affordance. Save button keeps its text label.
- Drop the standalone "0 shape(s) on canvas" row and the `description=
  "Tool:"` / `description="Name:"` widget-side labels — none of them
  added information, all cost vertical space.

Behaviour:
- Remove the UTC-timestamp collision rename in `commit_to_memory`. Same
  name now overwrites in-memory (and on-disk via `write_element`,
  which we already pass `overwrite=True`). Drops the `datetime` import
  and the rename-on-collision banner branch. Reviewer flagged the
  prior rename as off-convention vs upstream spatialdata.
- Update `test_commit_to_memory_renames_on_collision` →
  `test_commit_to_memory_overwrites_on_collision`. Other tests
  unchanged.

Lint:
- Pre-commit pass: `_dt.timezone.utc` → `_dt.UTC` (UP017) before the
  whole datetime block was dropped; ruff PT018 split assertion into two
  lines in `_collect_polygons`; biome + ruff-format normalised the
  changed Python and JS.

All 18 interactive tests pass in dev-interactive-py313.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants