Skip to content

Commit 2e218c4

Browse files
timtreisclaude
andcommitted
Add sdata.pl.annotate() — interactive region selection via anywidget
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>
1 parent a002ffb commit 2e218c4

15 files changed

Lines changed: 1267 additions & 59 deletions

File tree

plans/interactive-selection.md

Lines changed: 98 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,29 @@ SLURM compute node. No napari, no desktop GUI.
1616
- Selector shapes in v0: rectangle, polygon (click vertices), lasso (freehand).
1717
- Scale handling: auto-downsample on the fly. Pyramid-aware when available;
1818
`dask.coarsen` fallback when not.
19-
- Layers beneath the selector in v0: images only. Selector attaches to the
20-
`Axes` returned by the existing `sdata.pl.render_images().pl.show()` pipeline
21-
— we reuse the existing canvas, no duplicate render path.
22-
- Backend: `%matplotlib widget` (ipympl) + `matplotlib.widgets.{Rectangle,
23-
Polygon,Lasso}Selector`. Pure server-side render, PNG frames over websocket.
19+
- Layers in v0: images only. The image is rendered once via the existing
20+
`sdata.pl.render_images().pl.show()` pipeline into a matplotlib figure,
21+
exported to PNG, and laid under a client-side drawing canvas.
22+
- Backend: **custom anywidget** with HTML5/SVG drawing tools (rectangle,
23+
polygon, freehand-lasso). All drawing happens in the browser; shape
24+
geometry is reported back to Python via traitlet sync. Image is sent
25+
once as a base64 data URL; mouse moves never round-trip the kernel.
2426
No bokeh/datashader.
2527

28+
### Why anywidget, not ipympl or plotly
29+
30+
The original spec called for `%matplotlib widget` (ipympl). The prototype
31+
revealed two showstoppers over SSH:
32+
1. **ipympl streams PNG frames per mouse-move** over websocket — every drag
33+
incurs SSH round-trip latency, making freehand drawing unusable.
34+
2. **plotly's `FigureWidget`** has broken two-way shape sync in
35+
VSCode-Remote-SSH (regardless of plotly 5 vs 6 — different bugs each).
36+
37+
A small (~250-line) anywidget with traitlet-synced shape geometry was the
38+
only architecture that worked reliably in VSCode-Remote and produced
39+
responsive drawing. The image render still uses sdata-plot's matplotlib
40+
pipeline; we just don't drive interaction through it.
41+
2642
## Resolved questions (locked 2026-05-21, task #1)
2743

2844
- **Q1 — Channel/contrast widgets**: **No live widgets in v0.** `channel=` and
@@ -43,42 +59,59 @@ SLURM compute node. No napari, no desktop GUI.
4359
import spatialdata_plot # registers .pl
4460

4561
session = sdata.pl.interactive(
46-
element="he_image",
47-
coordinate_system="global",
48-
channel=[0, 1, 2], # optional
49-
clims=(0, 30000), # optional
50-
selector="polygon", # 'rectangle' | 'polygon' | 'lasso'
51-
name="tumor_region",
52-
overwrite=False,
53-
persist=True,
54-
max_render_pixels=2_000_000,
62+
coordinate_system=None, # optional pre-selection; None = let user pick in UI
63+
element=None, # optional pre-selection; None = let user pick in UI
64+
persist=True, # show "Write to disk" button (False = memory only)
5565
)
56-
session.show() # returns the ipympl Figure
57-
# user draws on canvas, double-click / release to commit
58-
sdata["tumor_region"] # ShapesModel
66+
session.show() # renders the ipywidgets controls + draw canvas
67+
68+
# User picks CS + image, clicks Render, draws shapes, names + Saves each set.
69+
# Each Save adds an entry to sdata.shapes (memory). Write to disk persists
70+
# the most recent commit via sdata.write_element.
71+
72+
sdata["tumor_region"] # ShapesModel
5973
sub = sdata.query.polygon(sdata, sdata["tumor_region"])
6074
```
6175

76+
Removed kwargs vs original spec:
77+
- `selector=` — UI has a tool toggle (rect/polygon/lasso); no need to bind one
78+
selector at construction (Q3 resolution).
79+
- `name=` — typed in the UI before each Save (Q4 resolution).
80+
- `channel=`, `clims=` — deferred to v1 (Q1 resolution).
81+
- `max_render_pixels=` — render is fixed at `figsize=(7,7), dpi=120` ≈ 840×840
82+
PNG; pyramid-aware downsampling deferred to v1.
83+
- `overwrite=` — collision handling is automatic: same name → append UTC
84+
timestamp.
85+
6286
## Module layout
6387

6488
```
6589
src/spatialdata_plot/pl/interactive/
66-
__init__.py # exports InteractiveSession
67-
_session.py # InteractiveSession class, public entrypoint
68-
_render.py # thin wrapper around existing render_images
69-
_downsample.py # pyramid-aware scale picker; in-memory coarsen
70-
_selectors.py # RectangleAdapter, PolygonAdapter, LassoAdapter
71-
_commit.py # vertices → CS-correct shapely → ShapesModel
72-
_persist.py # write_element + overwrite/timestamp policy
90+
__init__.py # exports interactive, InteractiveSession, DrawCanvas
91+
_session.py # InteractiveSession class — ipywidgets controls
92+
_canvas.py # DrawCanvas anywidget + traitlets
93+
_render.py # render_to_png helper (sdata.pl → PNG + extent)
94+
_commit.py # pixel-shape → CS-correct shapely Polygon → ShapesModel
95+
_persist.py # write_element + collision/timestamp policy
96+
static/
97+
draw_canvas.js # the ESM module; _esm = Path(...) reads at import
7398
7499
tests/test_interactive/
75-
test_commit.py
76-
test_downsample.py
77-
test_selectors_headless.py
100+
test_commit.py # pixel→CS conversion + ShapesModel correctness
101+
test_render.py # render_to_png returns valid PNG + extent
102+
test_persist.py # collision/timestamp policy
103+
test_canvas.py # smoke: instantiate widget, check traitlet defaults
78104
```
79105

80-
`sdata.pl.interactive(...)` becomes a method on `PlotAccessor` in
81-
`src/spatialdata_plot/_accessor.py`, returning an `InteractiveSession`.
106+
`sdata.pl.interactive(...)` is a method on `PlotAccessor` in
107+
`src/spatialdata_plot/_accessor.py`. It constructs an `InteractiveSession`
108+
and returns it; `session.show()` displays the controls + draw canvas.
109+
110+
Dropped from the original spec:
111+
- `_downsample.py` — pyramid-aware downsampling deferred to v1; v0 renders
112+
at a fixed dpi (`figsize=(7,7), dpi=120`).
113+
- `_selectors.py` — matplotlib selectors are replaced by the anywidget; the
114+
three drawing tools (rect/polygon/lasso) live in `static/draw_canvas.js`.
82115

83116
## Coordinate-system rules (highest-risk surface)
84117

@@ -92,25 +125,31 @@ tests/test_interactive/
92125

93126
Avoids the classic double-applied-transform bug.
94127

95-
## Downsampling
128+
## Rendering
129+
130+
`_render.render_to_png(sdata, element, coordinate_system) -> (png_bytes, image_w, image_h, xlim, ylim)`
96131

97-
`_downsample.pick_scale(image, bbox, max_pixels) -> (level_or_factor, array)`
132+
- Uses `sdata.pl.render_images(element=...).pl.show(coordinate_systems=..., ax=...)`.
133+
- Axes fills the figure (`ax.add_axes([0,0,1,1])`, `set_axis_off()`) so PNG pixel
134+
coordinates map exactly to data coordinates via `xlim`/`ylim`.
135+
- Fixed at `figsize=(7,7)` × `dpi=120` ≈ 840×840 PNG for v0. Pyramid-aware
136+
downsampling deferred to v1.
137+
- 3D / z-stacks: refused by `render_images` itself (commit 3ebefe1) — we
138+
propagate that error.
98139

99-
- `MultiscaleSpatialImage`: walk scales coarse→fine, pick finest within budget.
100-
- Single-scale: `dask.array.coarsen` with integer factor, warn once.
101-
- Static extent in v0. Auto-redraw on `xlim_changed` is v1.
102-
- Default `max_render_pixels ≈ 2M` (~1500×1500), tuned for ipympl PNG over SSH.
140+
## Drawing tools (in `static/draw_canvas.js`)
103141

104-
## Selector adapters
142+
| kind | gesture | commit trigger |
143+
|-------------|------------------------------------------------|-----------------------------------------------|
144+
| rectangle | left-drag corner → corner | mouse release |
145+
| polygon | click each vertex | snap-to-first-vertex (within 10 px) or Enter |
146+
| lasso | left-drag freehand | mouse release |
105147

106-
| kind | matplotlib class | commit trigger |
107-
|-------------|-----------------------|-------------------------------|
108-
| rectangle | `RectangleSelector` | mouse release |
109-
| polygon | `PolygonSelector` | close (double-click / enter) |
110-
| lasso | `LassoSelector` | mouse release |
148+
Plus client-side: wheel-zoom, shift-drag-pan, alt-click-shape-to-delete,
149+
hover-highlight, Ctrl+Z undo, Delete clear, R/P/L tool shortcuts, F fit.
111150

112-
Lasso vertices simplified via `shapely.simplify(tolerance=0.5px)` before
113-
persist.
151+
Lasso vertices are simplified server-side via `shapely.simplify(tolerance=0.5)`
152+
in `_commit` before persisting.
114153

115154
## Persistence policy
116155

@@ -133,22 +172,27 @@ persist.
133172

134173
## Test strategy
135174

136-
- Unit: `_commit` (synthetic vertices → ShapesModel correctness).
137-
- Unit: `_downsample` (scale picker correctness on synthetic arrays).
138-
- Headless: `_selectors` via programmatic `_press`/`_onmove`/`_release`.
139-
- NO visual tests in v0. CI does not need a live canvas.
140-
- Manual checklist in PR description for the canvas itself.
175+
- Unit: `_commit` (synthetic pixel-coord shapes → CS-coord ShapesModel correctness).
176+
- Unit: `_render` (returns valid PNG bytes + extent matching the axis limits).
177+
- Unit: `_persist` (collision-rename + timestamp policy).
178+
- Smoke: `_canvas` (instantiate `DrawCanvas`, check traitlet defaults).
179+
- NO visual / live-canvas tests in v0 — the JS widget can't be driven from Python.
180+
Manual checklist in PR description covers the canvas behaviour.
141181

142182
## Dependencies
143183

144-
`[project.dependencies]`:
184+
Exposed as `[project.optional-dependencies].interactive` so the feature is
185+
opt-in (`pip install spatialdata-plot[interactive]`). Mirrors the pixi
186+
`interactive` dep-group.
145187

146-
- `ipympl` (NEW)
147-
- `ipywidgets` (NEW or pin existing transitive)
148-
- `shapely` (already transitive via geopandas)
149-
- `geopandas` (already transitive via spatialdata)
188+
- `anywidget` (NEW) — the widget framework.
189+
- `ipywidgets` (NEW or pin existing transitive) — for the controls VBox.
190+
- `ipykernel` — needed by anywidget for comm channel.
191+
- `shapely`, `geopandas` already transitive via spatialdata.
150192

151-
Only `ipympl` is genuinely new.
193+
`ipympl` and `plotly` are NOT runtime deps of the new architecture (we tried
194+
both and rejected them). They remain in the prototype/pixi feature only for
195+
historical comparison and may be dropped from the interactive feature later.
152196

153197
## v1 roadmap (after v0 ships)
154198

pyproject.toml

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ dependencies = [
3131
"scikit-learn",
3232
"spatialdata>=0.3",
3333
]
34+
optional-dependencies.interactive = [
35+
"anywidget",
36+
"ipykernel",
37+
"ipywidgets",
38+
]
3439
urls.Documentation = "https://spatialdata.scverse.org/projects/plot/en/latest/index.html"
3540
urls.Home-page = "https://github.com/scverse/spatialdata-plot.git"
3641
urls.Source = "https://github.com/scverse/spatialdata-plot.git"
@@ -61,11 +66,11 @@ doc = [
6166
"sphinxcontrib-katex",
6267
"sphinxext-opengraph",
6368
]
64-
interactive = [
65-
"anywidget",
66-
"ipykernel",
69+
interactive-extras = [
70+
# Prototype-only helpers used by Sandbox.ipynb. The published runtime extra
71+
# is [project.optional-dependencies].interactive above (anywidget/ipykernel/
72+
# ipywidgets only) — these are kept here for the dev-interactive-py313 env.
6773
"ipympl",
68-
"ipywidgets",
6974
# pinned to 5.x: plotly 6's anywidget-backed FigureWidget doesn't relay
7075
# client-side draw events back to Python, so layout.shapes never syncs.
7176
"plotly>=5.20,<6",
@@ -106,6 +111,12 @@ python = ">=3.11"
106111
[tool.pixi.pypi-dependencies]
107112
spatialdata-plot = { path = ".", editable = true }
108113

114+
# When the `interactive` feature is active, install the package with the
115+
# `interactive` PyPI extra (anywidget, ipykernel, ipywidgets) so the pixi
116+
# env mirrors what `pip install spatialdata-plot[interactive]` would give.
117+
[tool.pixi.feature.interactive.pypi-dependencies]
118+
spatialdata-plot = { path = ".", editable = true, extras = [ "interactive" ] }
119+
109120
[tool.pixi.tasks]
110121
format = "ruff format ."
111122
kernel-install = 'python -m ipykernel install --user --name pixi-dev --display-name "sdata-plot (dev)"'
@@ -129,7 +140,7 @@ default = { features = [ "py313" ], solve-group = "py313" }
129140
# 3.11 lane (for gh-actions)
130141
dev-py311 = { features = [ "dev", "test", "py311" ], solve-group = "py311" }
131142
dev-py313 = { features = [ "dev", "test", "py313" ], solve-group = "py313" }
132-
dev-interactive-py313 = { features = [ "dev", "test", "interactive", "py313" ], solve-group = "py313" }
143+
dev-interactive-py313 = { features = [ "dev", "test", "interactive", "interactive-extras", "py313" ], solve-group = "py313" }
133144
docs-py311 = { features = [ "doc", "py311" ], solve-group = "py311" }
134145
docs-py313 = { features = [ "doc", "py313" ], solve-group = "py313" }
135146
test-py313 = { features = [ "test", "py313" ], solve-group = "py313" }

src/spatialdata_plot/pl/basic.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,74 @@ def _copy(
171171

172172
return sdata
173173

174+
def annotate(
175+
self,
176+
coordinate_system: str,
177+
element: str,
178+
*,
179+
persist: bool = True,
180+
) -> None:
181+
"""Draw and save regions interactively on an image element.
182+
183+
Renders the image element in the given coordinate system as a
184+
client-side drawing canvas (rectangle / polygon / lasso tools).
185+
Drawn shapes are saved into ``sdata.shapes`` under a user-typed name
186+
on click of the *Save* button — each save creates one ShapesModel
187+
with one row per drawn shape, registered with an ``Identity``
188+
transformation in the chosen coordinate system.
189+
190+
Requires the ``interactive`` extra: ``pip install 'spatialdata-plot[interactive]'``.
191+
192+
Parameters
193+
----------
194+
coordinate_system :
195+
Coordinate system to render and resolve drawn shapes against.
196+
Drawn polygons are stored with an ``Identity`` transformation
197+
in this CS.
198+
element :
199+
Name of the image element to render.
200+
persist :
201+
If ``True`` (default), show a *Write to disk* button that calls
202+
:meth:`SpatialData.write_element` for the most recent save.
203+
Set to ``False`` to limit the session to in-memory commits.
204+
205+
Returns
206+
-------
207+
None
208+
Displays the widget in the current notebook cell. Drawn and
209+
saved shapes appear in ``sdata.shapes``; inspect them there.
210+
211+
Raises
212+
------
213+
ValueError
214+
If ``coordinate_system`` is unknown, ``element`` is unknown,
215+
or ``element`` is not registered in ``coordinate_system``.
216+
ImportError
217+
If the ``interactive`` extra is not installed.
218+
219+
Examples
220+
--------
221+
>>> import spatialdata_plot # noqa: F401 registers .pl
222+
>>> sdata.pl.annotate("global", "he_image")
223+
>>> # ... user draws and clicks Save with name "tumor" ...
224+
>>> sdata.shapes["tumor"]
225+
"""
226+
try:
227+
from spatialdata_plot.pl.interactive._session import _InteractiveSession
228+
except ImportError as exc:
229+
raise ImportError(
230+
"sdata.pl.annotate() requires the `interactive` extra. "
231+
"Install with: pip install 'spatialdata-plot[interactive]'"
232+
) from exc
233+
234+
session = _InteractiveSession(
235+
self._sdata,
236+
coordinate_system=coordinate_system,
237+
element=element,
238+
persist=persist,
239+
)
240+
session.show()
241+
174242
@_deprecation_alias(elements="element", version="0.3.0")
175243
def render_shapes(
176244
self,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Interactive region selection on a SpatialData image.
2+
3+
Use via :meth:`spatialdata_plot.pl.basic.PlotAccessor.annotate`:
4+
5+
>>> import spatialdata_plot # noqa: F401 registers .pl
6+
>>> sdata.pl.annotate("global", "he_image")
7+
"""
8+
from __future__ import annotations
9+
10+
__all__: list[str] = []
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""anywidget wrapping a client-side SVG drawing canvas."""
2+
from __future__ import annotations
3+
4+
from pathlib import Path
5+
6+
import anywidget
7+
import traitlets
8+
9+
_ESM_PATH = Path(__file__).parent / "static" / "draw_canvas.js"
10+
11+
12+
class DrawCanvas(anywidget.AnyWidget):
13+
"""Client-side SVG drawing surface for interactive region selection.
14+
15+
The image (PNG data URL) is shown as a CSS-transformed background; an
16+
overlay SVG catches mouse events and emits committed shapes in image-
17+
pixel coordinates via the ``shapes`` traitlet.
18+
19+
Convert the pixel-coord shapes to data/CS coordinates with
20+
:func:`spatialdata_plot.pl.interactive._commit.pixel_shape_to_polygon`.
21+
22+
Traitlets
23+
---------
24+
image_url
25+
``data:image/png;base64,...`` for the rendered image.
26+
image_width, image_height
27+
Pixel dimensions of the PNG (used to set the SVG ``viewBox``).
28+
tool
29+
``"rectangle"``, ``"polygon"``, or ``"lasso"``.
30+
shapes
31+
List of ``{"type": "rect"|"polygon", "verts": [[x, y], ...]}`` in
32+
image-pixel coordinates. JS pushes to this on commit.
33+
clear_trigger, close_poly_trigger, undo_trigger, fit_trigger
34+
Integer counters. Increment from Python to invoke the corresponding
35+
JS action; JS observers are stateless w.r.t. the value, only the
36+
change event matters.
37+
"""
38+
39+
_esm = _ESM_PATH
40+
41+
image_url = traitlets.Unicode("").tag(sync=True)
42+
image_width = traitlets.Int(720).tag(sync=True)
43+
image_height = traitlets.Int(720).tag(sync=True)
44+
tool = traitlets.Unicode("rectangle").tag(sync=True)
45+
shapes = traitlets.List([]).tag(sync=True)
46+
clear_trigger = traitlets.Int(0).tag(sync=True)
47+
close_poly_trigger = traitlets.Int(0).tag(sync=True)
48+
undo_trigger = traitlets.Int(0).tag(sync=True)
49+
fit_trigger = traitlets.Int(0).tag(sync=True)

0 commit comments

Comments
 (0)