Skip to content

Commit 6ad2aca

Browse files
timtreisclaude
andcommitted
Spec interactive region selection + add pixi interactive env (ipympl v0)
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>
1 parent 8a6a33f commit 6ad2aca

2 files changed

Lines changed: 226 additions & 20 deletions

File tree

plans/interactive-selection.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Interactive region selection in spatialdata-plot
2+
3+
Status: spec (v0). Materialized from session handoff on 2026-05-21.
4+
5+
## Goal
6+
7+
A minimal, in-notebook (Jupyter / VSCode-Remote-SSH) widget that lets the user
8+
draw a region on a spatialdata-plot canvas and persist it back into the
9+
SpatialData object as a ShapesModel element. Works over an SSH bridge to a
10+
SLURM compute node. No napari, no desktop GUI.
11+
12+
## Confirmed design decisions
13+
14+
- Output: persisted ShapesModel written back to the on-disk zarr via
15+
`sdata.write_element`. Survives kernel restarts.
16+
- Selector shapes in v0: rectangle, polygon (click vertices), lasso (freehand).
17+
- Scale handling: auto-downsample on the fly. Pyramid-aware when available;
18+
`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.
24+
No bokeh/datashader.
25+
26+
## Resolved questions (locked 2026-05-21, task #1)
27+
28+
- **Q1 — Channel/contrast widgets**: **No live widgets in v0.** `channel=` and
29+
`clims=` remain optional kwargs that forward to `render_images`. No
30+
ipywidgets-driven controls. Widget toolbar deferred to v1.
31+
- **Q2 — Auto-redraw on zoom**: **v1.** v0 renders once at the chosen scale;
32+
`xlim_changed`/`ylim_changed` does not re-pick pyramid level. Static extent
33+
ships sooner.
34+
- **Q3 — Selector kind switching**: **One per call.** `selector=` is fixed at
35+
session construction; no mid-session switching. Switchable kinds deferred to
36+
v1.
37+
- **Q4 — `name=` default**: **Required.** No default; omitting `name=` raises.
38+
Keeps persisted element names intentional and zarr listings legible.
39+
40+
## Public API sketch
41+
42+
```python
43+
import spatialdata_plot # registers .pl
44+
45+
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,
55+
)
56+
session.show() # returns the ipympl Figure
57+
# user draws on canvas, double-click / release to commit
58+
sdata["tumor_region"] # ShapesModel
59+
sub = sdata.query.polygon(sdata, sdata["tumor_region"])
60+
```
61+
62+
## Module layout
63+
64+
```
65+
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
73+
74+
tests/test_interactive/
75+
test_commit.py
76+
test_downsample.py
77+
test_selectors_headless.py
78+
```
79+
80+
`sdata.pl.interactive(...)` becomes a method on `PlotAccessor` in
81+
`src/spatialdata_plot/_accessor.py`, returning an `InteractiveSession`.
82+
83+
## Coordinate-system rules (highest-risk surface)
84+
85+
1. Session is bound to ONE coordinate system at construction.
86+
2. Render is in that CS; axes coords on the canvas equal coords in the CS
87+
(1:1).
88+
3. On commit, vertices are already in the rendered CS — no transform needed
89+
for the selection itself.
90+
4. The committed ShapesModel is registered with `{cs_name: Identity()}`.
91+
5. Cross-CS selection is the user's job downstream. Not v0.
92+
93+
Avoids the classic double-applied-transform bug.
94+
95+
## Downsampling
96+
97+
`_downsample.pick_scale(image, bbox, max_pixels) -> (level_or_factor, array)`
98+
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.
103+
104+
## Selector adapters
105+
106+
| kind | matplotlib class | commit trigger |
107+
|-------------|-----------------------|-------------------------------|
108+
| rectangle | `RectangleSelector` | mouse release |
109+
| polygon | `PolygonSelector` | close (double-click / enter) |
110+
| lasso | `LassoSelector` | mouse release |
111+
112+
Lasso vertices simplified via `shapely.simplify(tolerance=0.5px)` before
113+
persist.
114+
115+
## Persistence policy
116+
117+
- `sdata.path` set → `sdata.write_element(name)` on every commit.
118+
- Not zarr-backed → warn once, keep in memory.
119+
- `overwrite=False` default. Collision → rename to `"<name>_<UTC-ISO>"`.
120+
- `session.commits` list tracks names committed this session.
121+
122+
## Risks (pre-mitigated)
123+
124+
1. CS mistakes → identity transform + unit tests.
125+
2. Image too large → `max_render_pixels` hard cap with clear error.
126+
3. ipympl flakiness in VSCode → documented fallback to browser-Jupyter via
127+
`ssh -L 8888:localhost:8888 node`.
128+
4. Walltime kill → auto-persist every commit.
129+
5. Lasso 10k vertices → `shapely.simplify`.
130+
6. Concurrent zarr writers → documented, no locking in v0.
131+
7. 3D / z-stacks → refuse with same error as static render (commit 3ebefe1).
132+
8. Auto-zoom redraw not in v0 → static extent ships first.
133+
134+
## Test strategy
135+
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.
141+
142+
## Dependencies
143+
144+
`[project.dependencies]`:
145+
146+
- `ipympl` (NEW)
147+
- `ipywidgets` (NEW or pin existing transitive)
148+
- `shapely` (already transitive via geopandas)
149+
- `geopandas` (already transitive via spatialdata)
150+
151+
Only `ipympl` is genuinely new.
152+
153+
## v1 roadmap (after v0 ships)
154+
155+
1. Auto-downsample on zoom (pyramid-aware redraw on `xlim_changed`).
156+
2. Channel + contrast widget controls in the figure toolbar.
157+
3. Labels overlay (segmentation visible during selection).
158+
4. Multiple selectors per session; switchable kinds.
159+
5. Datashader path for points-heavy elements.
160+
161+
## Task queue
162+
163+
1. Resolve spec open questions Q1–Q4
164+
2. Add ipympl dep + pixi interactive feature
165+
3. Scaffold `pl/interactive` submodule
166+
4. Wire `sdata.pl.interactive` entrypoint
167+
5. Implement `_commit`: vertices → ShapesModel
168+
6. Implement `_persist`: zarr write policy
169+
7. Implement `_downsample`: scale picker + warn
170+
8. Implement `_render`: image render to ax
171+
9. Implement `_selectors`: Rectangle/Polygon/Lasso adapters
172+
10. Wire `InteractiveSession` end-to-end
173+
11. Manual end-to-end test on cluster
174+
12. Document feature in module docstring + README
175+
176+
## Operating rules
177+
178+
- Repo CLAUDE.md rules apply: plan-first for multi-file work, no drive-by
179+
refactors, run pixi-defined tasks (lint/format/test) before commits, no
180+
pre-commit / no visual tests locally (CI only).
181+
- Pixi only. No venv/pip. `dev-py313` environment.
182+
- Don't stage with `-A`; stage only what's touched.
183+
- Human drives the actual ipympl canvas; agent cannot see it. Agent can
184+
drive a parallel headless kernel on the same node for non-UI checks.
185+
- If task #1 answers change the spec materially, update this file before
186+
starting #2.

pyproject.toml

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ doc = [
6161
"sphinxcontrib-katex",
6262
"sphinxext-opengraph",
6363
]
64+
interactive = [
65+
"ipykernel",
66+
"ipympl",
67+
"ipywidgets",
68+
"squidpy",
69+
]
6470

6571
[tool.hatch]
6672
build.hooks.vcs.version-file = "_version.py"
@@ -86,29 +92,43 @@ envs.hatch-test.scripts.cov-report = [ "coverage report", "coverage xml -o cover
8692
metadata.allow-direct-references = true
8793
version.source = "vcs"
8894

89-
[tool.pixi]
90-
workspace.channels = [ "conda-forge" ]
91-
workspace.platforms = [ "linux-64", "osx-arm64" ]
92-
dependencies.python = ">=3.11"
93-
pypi-dependencies.spatialdata-plot = { path = ".", editable = true }
94-
tasks.format = "ruff format ."
95-
tasks.kernel-install = 'python -m ipykernel install --user --name pixi-dev --display-name "sdata-plot (dev)"'
96-
tasks.lab = "jupyter lab"
97-
tasks.lint = "ruff check ."
98-
tasks.pre-commit-install = "pre-commit install"
99-
tasks.pre-commit-run = "pre-commit run --all-files"
100-
tasks.test = "pytest -v --color=yes --tb=short --durations=10"
95+
[tool.pixi.workspace]
96+
channels = [ "conda-forge" ]
97+
platforms = [ "linux-64", "osx-arm64" ]
98+
99+
[tool.pixi.dependencies]
100+
python = ">=3.11"
101+
102+
[tool.pixi.pypi-dependencies]
103+
spatialdata-plot = { path = ".", editable = true }
104+
105+
[tool.pixi.tasks]
106+
format = "ruff format ."
107+
kernel-install = 'python -m ipykernel install --user --name pixi-dev --display-name "sdata-plot (dev)"'
108+
kernel-install-interactive = 'python -m ipykernel install --user --name sdata-plot-interactive --display-name "sdata-plot (interactive)"'
109+
lab = "jupyter lab"
110+
lint = "ruff check ."
111+
pre-commit-install = "pre-commit install"
112+
pre-commit-run = "pre-commit run --all-files"
113+
test = "pytest -v --color=yes --tb=short --durations=10"
114+
101115
# for gh-actions
102-
feature.py311.dependencies.python = "3.11.*"
103-
feature.py313.dependencies.python = "3.13.*"
116+
[tool.pixi.feature.py311.dependencies]
117+
python = "3.11.*"
118+
119+
[tool.pixi.feature.py313.dependencies]
120+
python = "3.13.*"
121+
122+
[tool.pixi.environments]
104123
# 3.13 lane
105-
environments.default = { features = [ "py313" ], solve-group = "py313" }
124+
default = { features = [ "py313" ], solve-group = "py313" }
106125
# 3.11 lane (for gh-actions)
107-
environments.dev-py311 = { features = [ "dev", "test", "py311" ], solve-group = "py311" }
108-
environments.dev-py313 = { features = [ "dev", "test", "py313" ], solve-group = "py313" }
109-
environments.docs-py311 = { features = [ "doc", "py311" ], solve-group = "py311" }
110-
environments.docs-py313 = { features = [ "doc", "py313" ], solve-group = "py313" }
111-
environments.test-py313 = { features = [ "test", "py313" ], solve-group = "py313" }
126+
dev-py311 = { features = [ "dev", "test", "py311" ], solve-group = "py311" }
127+
dev-py313 = { features = [ "dev", "test", "py313" ], solve-group = "py313" }
128+
dev-interactive-py313 = { features = [ "dev", "test", "interactive", "py313" ], solve-group = "py313" }
129+
docs-py311 = { features = [ "doc", "py311" ], solve-group = "py311" }
130+
docs-py313 = { features = [ "doc", "py313" ], solve-group = "py313" }
131+
test-py313 = { features = [ "test", "py313" ], solve-group = "py313" }
112132

113133
[tool.ruff]
114134
line-length = 120

0 commit comments

Comments
 (0)