Add interactive annotation ability via .pl.annotate() - #684

Merged
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate
May 27, 2026
Merged

Add interactive annotation ability via .pl.annotate()#684
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate

Conversation

@timtreis

@timtreistimtreis commented May 21, 2026

Copy link
Copy Markdown
Member

Adds sdata.pl.annotate(), a terminal step on a render chain that hands the rasterised RGB to anybioimage's BioImageViewer, then commits drawn shapes back to sdata.shapes as a ShapesModel in the chosen CS with Identity().

  • New optional extra: pip install 'spatialdata-plot[interactive]'.
  • Works over SSH; rectangle / polygon / point tools.
  • _copy() carries a _source_sdata ref so writeback lands on the user's sdata, not the chain copy.

timtreisand 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

codecov-commenter commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.75000% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.01%. Comparing base (8a6a33f) to head (e8c2d31).
⚠️ Report is 3 commits behind head on main.

Files with missing linesPatch %Lines
src/spatialdata_plot/pl/interactive/_session.py0.00%69 Missing ⚠️
src/spatialdata_plot/pl/basic.py5.40%35 Missing ⚠️
src/spatialdata_plot/pl/interactive/_commit.py96.15%2 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #684 +/- ##
==========================================
- Coverage 77.79% 76.01% -1.79% 
==========================================
Files 11 14 +3 Lines 3693 3869 +176 Branches 877 896 +19 ==========================================
+ Hits 2873 2941 +68 - Misses 490 597 +107 - Partials 330 331 +1 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/interactive/__init__.py100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_commit.py96.15% <96.15%> (ø)
src/spatialdata_plot/pl/basic.py78.44% <5.40%> (-7.77%)⬇️
src/spatialdata_plot/pl/interactive/_session.py0.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.

timtreisand 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>
@LucaMarconato

Copy link
Copy Markdown
Member

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

Replace the custom anywidget + JS drawing canvas with the external
`anybioimage` library's `BioImageViewer`. Removes ~1100 lines of bespoke
client-side drawing code in favour of a well-maintained widget that
already implements rectangle / polygon / point tools, wheel zoom + pan,
multi-channel display, and a stable kernel↔JS sync.
API:
- `sdata.pl.annotate()` now behaves as a terminal step on a render chain:
it rasterises the accumulated `plotting_tree` and hands the resulting
RGB to the viewer, so overlays composed upstream (`render_images` /
`render_shapes` / `render_points` / `render_labels`) appear on the
annotation canvas. Args are keyword-only: `coordinate_systems`,
`point_radius_frac`, `figsize`, `dpi`.
- The chain pattern `sdata.pl.render_images(...).pl.annotate()` writes
drawn shapes back into the user's original `sdata`. `_copy()` now
propagates a `_source_sdata` reference forward through chained
`render_*` calls so `annotate()` can resolve the real writeback target
via `getattr(self._sdata, "_source_sdata", self._sdata)`. Without this,
saves landed in the anonymous copy that the chain returns and the
user's `sdata.shapes` silently stayed empty.
- Points are stored as buffered circle polygons so the resulting
`ShapesModel` stays uniform-type (no `radius` column).
Module:
- `pl/interactive/_session.py` — `_InteractiveSession` wraps
`BioImageViewer` with an ipywidgets card (Name field, Save, Clear,
banner). RGB channels are pre-swapped to `[G, R, B]` so anybioimage's
default `CHANNEL_COLORS=[green, red, blue]` lands true RGB without
post-hoc `_channel_settings` mutation (which fights the text field's
focus).
- `pl/interactive/_commit.py` — pure functions that read the viewer's
`_rois_data` / `_polygons_data` / `_points_data` traits and map pixel
coords back to the chosen CS.
- Deleted: `_canvas.py`, `_render.py`, `_persist.py`,
`static/draw_canvas.js` and their test files.
Deps:
- Add `anybioimage>=0.3,<0.4` to the published `interactive` extra.
- Drop the `interactive-extras` dep-group (ipympl + plotly<6 + squidpy):
it existed only for the abandoned anywidget/plotly sandbox prototype
and is no longer referenced. Drop it from the `dev-interactive-py313`
pixi env too.
Tests: 21 pass against `dev-interactive-py313`. Full suite (644 tests)
still passes — the `_copy()` change is additive (attaches one private
attribute to chained copies) and does not affect render-only paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timtreistimtreis changed the title Add sdata.pl.annotate() — interactive region selection via anywidgetAdd interactive annotation ability via .pl.annotate()May 27, 2026
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
The widget pivoted from the custom anywidget+JS canvas to the
external `anybioimage` library's `BioImageViewer` (see
scverse/spatialdata-plot#684). Update the tutorial to match:
- Intro: replace the anywidget/SVG architecture blurb with a
description of the `BioImageViewer` backend.
- Launch section: rewrite the API signature from the old positional
`annotate(coordinate_system, element, persist=True)` to the new
keyword-only chain-step form `.pl.render_images(...).pl.annotate(
coordinate_systems=...)`. Drop the lasso tool (no longer present)
and the JS-canvas-specific shortcut list. Replace the "Write to
disk" button with a `sdata.write_element(<name>)` follow-up note.
- Polygon query cell: pass the actual shapely geometry rather than
the whole `GeoDataFrame`. spatialdata's `polygon_query` overload
for `DataArray`/`DataTree` calls `GeoDataFrame(geometry=[polygon])`
internally, which fails with `GeometryTypeError: Unknown geometry
type: 'featurecollection'` if passed a nested gdf.
- Watermark: drop `anywidget` (transitive dep that isn't imported by
any executable cell) to keep the watermark report honest.
- Add a `warnings.filterwarnings("ignore")` housekeeping cell ahead
of the squidpy dataset load so the notebook's recorded outputs
don't leak Zarr v3 deprecation chatter into the docs build.
- Add the recorded GIF (`_static/img/interactive_annotate.gif`) and
a placeholder PNG thumbnail (regenerate before merging).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The notebook drives the packaged `sdata.pl.annotate` chain on the
`squidpy.datasets.visium_hne_sdata` H&E section: render → annotate
(BioImageViewer widget) → name → Save → inspect `sdata.shapes`.
Useful as a manual smoke test alongside the automated test suite,
and as a kernel of the (re-executed) Sandbox runs we keep referring
to in PR discussion.
Local prototyping artefacts that don't belong in the repo — the
two prior architecture variants (`Sandbox.anywidget-v0.ipynb`,
`Sandbox.ipympl-v0.ipynb`), the SSH-rendering probe
(`verify_ssh_annotate.ipynb`), and the persisted zarr under
`sandbox_data/` — go into `.gitignore` so they stay locally
visible without sneaking into future commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

Both were scratch / planning artefacts that don't belong in the published
package. Sandbox.ipynb is added to .gitignore so it can stay locally as a
manual smoke notebook without re-entering the repo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis marked this pull request as ready for review May 27, 2026 14:20
@timtreis

Copy link
Copy Markdown
MemberAuthor

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

I've now refactored the feature to use anybioimage instead of directly shipping an equivalent widget inside the package, hopefully it'll keep stable 🤞

`set_aspect("equal")` inside `show()` shrinks the axes box so the figure
has blank padding around the data when the figure aspect doesn't match
the data extent. The previous `savefig` wrote the whole figure (padding
included), but `_make_px_to_cs` assumed PNG dims == axes dims — so drawn
shapes got squeezed toward the center along the padded axis (zero at
center, max offset at edges).
Crop the saved PNG to the axes bbox via `bbox_inches=` so PNG pixels map
1:1 to (xlim, ylim) and the existing px→cs transform stays correct.
Add a regression test that drives the full annotate → save loop on a
non-square (32 wide × 64 tall) image with a square figsize=(5, 5) and
asserts the round-tripped corner polygon matches the CS extent.
Also annotate the previously untyped `_commit.py` helpers with a shared
`PxToCs` callable alias so pre-commit mypy passes on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis merged commit 55eab64 into mainMay 27, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the feature/interactive-annotate branch May 27, 2026 15:10
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
0.4.0 ships `sdata.pl.annotate()` (scverse/spatialdata-plot#684), so the
tutorial can require the published wheel instead of an editable path.
Re-executed the notebook to refresh the watermark output to 0.4.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@timtreis@codecov-commenter@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Add interactive annotation ability via .pl.annotate() - #684

Merged
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate
May 27, 2026
Merged

Add interactive annotation ability via .pl.annotate()#684
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate

Conversation

@timtreis

@timtreistimtreis commented May 21, 2026

Copy link
Copy Markdown
Member

Adds sdata.pl.annotate(), a terminal step on a render chain that hands the rasterised RGB to anybioimage's BioImageViewer, then commits drawn shapes back to sdata.shapes as a ShapesModel in the chosen CS with Identity().

  • New optional extra: pip install 'spatialdata-plot[interactive]'.
  • Works over SSH; rectangle / polygon / point tools.
  • _copy() carries a _source_sdata ref so writeback lands on the user's sdata, not the chain copy.

timtreisand 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

codecov-commenter commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.75000% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.01%. Comparing base (8a6a33f) to head (e8c2d31).
⚠️ Report is 3 commits behind head on main.

Files with missing linesPatch %Lines
src/spatialdata_plot/pl/interactive/_session.py0.00%69 Missing ⚠️
src/spatialdata_plot/pl/basic.py5.40%35 Missing ⚠️
src/spatialdata_plot/pl/interactive/_commit.py96.15%2 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #684 +/- ##
==========================================
- Coverage 77.79% 76.01% -1.79% 
==========================================
Files 11 14 +3 Lines 3693 3869 +176 Branches 877 896 +19 ==========================================
+ Hits 2873 2941 +68 - Misses 490 597 +107 - Partials 330 331 +1 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/interactive/__init__.py100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_commit.py96.15% <96.15%> (ø)
src/spatialdata_plot/pl/basic.py78.44% <5.40%> (-7.77%)⬇️
src/spatialdata_plot/pl/interactive/_session.py0.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.

timtreisand 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>
@LucaMarconato

Copy link
Copy Markdown
Member

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

Replace the custom anywidget + JS drawing canvas with the external
`anybioimage` library's `BioImageViewer`. Removes ~1100 lines of bespoke
client-side drawing code in favour of a well-maintained widget that
already implements rectangle / polygon / point tools, wheel zoom + pan,
multi-channel display, and a stable kernel↔JS sync.
API:
- `sdata.pl.annotate()` now behaves as a terminal step on a render chain:
it rasterises the accumulated `plotting_tree` and hands the resulting
RGB to the viewer, so overlays composed upstream (`render_images` /
`render_shapes` / `render_points` / `render_labels`) appear on the
annotation canvas. Args are keyword-only: `coordinate_systems`,
`point_radius_frac`, `figsize`, `dpi`.
- The chain pattern `sdata.pl.render_images(...).pl.annotate()` writes
drawn shapes back into the user's original `sdata`. `_copy()` now
propagates a `_source_sdata` reference forward through chained
`render_*` calls so `annotate()` can resolve the real writeback target
via `getattr(self._sdata, "_source_sdata", self._sdata)`. Without this,
saves landed in the anonymous copy that the chain returns and the
user's `sdata.shapes` silently stayed empty.
- Points are stored as buffered circle polygons so the resulting
`ShapesModel` stays uniform-type (no `radius` column).
Module:
- `pl/interactive/_session.py` — `_InteractiveSession` wraps
`BioImageViewer` with an ipywidgets card (Name field, Save, Clear,
banner). RGB channels are pre-swapped to `[G, R, B]` so anybioimage's
default `CHANNEL_COLORS=[green, red, blue]` lands true RGB without
post-hoc `_channel_settings` mutation (which fights the text field's
focus).
- `pl/interactive/_commit.py` — pure functions that read the viewer's
`_rois_data` / `_polygons_data` / `_points_data` traits and map pixel
coords back to the chosen CS.
- Deleted: `_canvas.py`, `_render.py`, `_persist.py`,
`static/draw_canvas.js` and their test files.
Deps:
- Add `anybioimage>=0.3,<0.4` to the published `interactive` extra.
- Drop the `interactive-extras` dep-group (ipympl + plotly<6 + squidpy):
it existed only for the abandoned anywidget/plotly sandbox prototype
and is no longer referenced. Drop it from the `dev-interactive-py313`
pixi env too.
Tests: 21 pass against `dev-interactive-py313`. Full suite (644 tests)
still passes — the `_copy()` change is additive (attaches one private
attribute to chained copies) and does not affect render-only paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timtreistimtreis changed the title Add sdata.pl.annotate() — interactive region selection via anywidgetAdd interactive annotation ability via .pl.annotate()May 27, 2026
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
The widget pivoted from the custom anywidget+JS canvas to the
external `anybioimage` library's `BioImageViewer` (see
scverse/spatialdata-plot#684). Update the tutorial to match:
- Intro: replace the anywidget/SVG architecture blurb with a
description of the `BioImageViewer` backend.
- Launch section: rewrite the API signature from the old positional
`annotate(coordinate_system, element, persist=True)` to the new
keyword-only chain-step form `.pl.render_images(...).pl.annotate(
coordinate_systems=...)`. Drop the lasso tool (no longer present)
and the JS-canvas-specific shortcut list. Replace the "Write to
disk" button with a `sdata.write_element(<name>)` follow-up note.
- Polygon query cell: pass the actual shapely geometry rather than
the whole `GeoDataFrame`. spatialdata's `polygon_query` overload
for `DataArray`/`DataTree` calls `GeoDataFrame(geometry=[polygon])`
internally, which fails with `GeometryTypeError: Unknown geometry
type: 'featurecollection'` if passed a nested gdf.
- Watermark: drop `anywidget` (transitive dep that isn't imported by
any executable cell) to keep the watermark report honest.
- Add a `warnings.filterwarnings("ignore")` housekeeping cell ahead
of the squidpy dataset load so the notebook's recorded outputs
don't leak Zarr v3 deprecation chatter into the docs build.
- Add the recorded GIF (`_static/img/interactive_annotate.gif`) and
a placeholder PNG thumbnail (regenerate before merging).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The notebook drives the packaged `sdata.pl.annotate` chain on the
`squidpy.datasets.visium_hne_sdata` H&E section: render → annotate
(BioImageViewer widget) → name → Save → inspect `sdata.shapes`.
Useful as a manual smoke test alongside the automated test suite,
and as a kernel of the (re-executed) Sandbox runs we keep referring
to in PR discussion.
Local prototyping artefacts that don't belong in the repo — the
two prior architecture variants (`Sandbox.anywidget-v0.ipynb`,
`Sandbox.ipympl-v0.ipynb`), the SSH-rendering probe
(`verify_ssh_annotate.ipynb`), and the persisted zarr under
`sandbox_data/` — go into `.gitignore` so they stay locally
visible without sneaking into future commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

Both were scratch / planning artefacts that don't belong in the published
package. Sandbox.ipynb is added to .gitignore so it can stay locally as a
manual smoke notebook without re-entering the repo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis marked this pull request as ready for review May 27, 2026 14:20
@timtreis

Copy link
Copy Markdown
MemberAuthor

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

I've now refactored the feature to use anybioimage instead of directly shipping an equivalent widget inside the package, hopefully it'll keep stable 🤞

`set_aspect("equal")` inside `show()` shrinks the axes box so the figure
has blank padding around the data when the figure aspect doesn't match
the data extent. The previous `savefig` wrote the whole figure (padding
included), but `_make_px_to_cs` assumed PNG dims == axes dims — so drawn
shapes got squeezed toward the center along the padded axis (zero at
center, max offset at edges).
Crop the saved PNG to the axes bbox via `bbox_inches=` so PNG pixels map
1:1 to (xlim, ylim) and the existing px→cs transform stays correct.
Add a regression test that drives the full annotate → save loop on a
non-square (32 wide × 64 tall) image with a square figsize=(5, 5) and
asserts the round-tripped corner polygon matches the CS extent.
Also annotate the previously untyped `_commit.py` helpers with a shared
`PxToCs` callable alias so pre-commit mypy passes on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis merged commit 55eab64 into mainMay 27, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the feature/interactive-annotate branch May 27, 2026 15:10
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
0.4.0 ships `sdata.pl.annotate()` (scverse/spatialdata-plot#684), so the
tutorial can require the published wheel instead of an editable path.
Re-executed the notebook to refresh the watermark output to 0.4.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@timtreis@codecov-commenter@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add interactive annotation ability via .pl.annotate() - #684

Merged
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate
May 27, 2026
Merged

Add interactive annotation ability via .pl.annotate()#684
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate

Conversation

@timtreis

@timtreistimtreis commented May 21, 2026

Copy link
Copy Markdown
Member

Adds sdata.pl.annotate(), a terminal step on a render chain that hands the rasterised RGB to anybioimage's BioImageViewer, then commits drawn shapes back to sdata.shapes as a ShapesModel in the chosen CS with Identity().

  • New optional extra: pip install 'spatialdata-plot[interactive]'.
  • Works over SSH; rectangle / polygon / point tools.
  • _copy() carries a _source_sdata ref so writeback lands on the user's sdata, not the chain copy.

timtreisand 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

codecov-commenter commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.75000% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.01%. Comparing base (8a6a33f) to head (e8c2d31).
⚠️ Report is 3 commits behind head on main.

Files with missing linesPatch %Lines
src/spatialdata_plot/pl/interactive/_session.py0.00%69 Missing ⚠️
src/spatialdata_plot/pl/basic.py5.40%35 Missing ⚠️
src/spatialdata_plot/pl/interactive/_commit.py96.15%2 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #684 +/- ##
==========================================
- Coverage 77.79% 76.01% -1.79% 
==========================================
Files 11 14 +3 Lines 3693 3869 +176 Branches 877 896 +19 ==========================================
+ Hits 2873 2941 +68 - Misses 490 597 +107 - Partials 330 331 +1 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/interactive/__init__.py100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_commit.py96.15% <96.15%> (ø)
src/spatialdata_plot/pl/basic.py78.44% <5.40%> (-7.77%)⬇️
src/spatialdata_plot/pl/interactive/_session.py0.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.

timtreisand 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>
@LucaMarconato

Copy link
Copy Markdown
Member

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

Replace the custom anywidget + JS drawing canvas with the external
`anybioimage` library's `BioImageViewer`. Removes ~1100 lines of bespoke
client-side drawing code in favour of a well-maintained widget that
already implements rectangle / polygon / point tools, wheel zoom + pan,
multi-channel display, and a stable kernel↔JS sync.
API:
- `sdata.pl.annotate()` now behaves as a terminal step on a render chain:
it rasterises the accumulated `plotting_tree` and hands the resulting
RGB to the viewer, so overlays composed upstream (`render_images` /
`render_shapes` / `render_points` / `render_labels`) appear on the
annotation canvas. Args are keyword-only: `coordinate_systems`,
`point_radius_frac`, `figsize`, `dpi`.
- The chain pattern `sdata.pl.render_images(...).pl.annotate()` writes
drawn shapes back into the user's original `sdata`. `_copy()` now
propagates a `_source_sdata` reference forward through chained
`render_*` calls so `annotate()` can resolve the real writeback target
via `getattr(self._sdata, "_source_sdata", self._sdata)`. Without this,
saves landed in the anonymous copy that the chain returns and the
user's `sdata.shapes` silently stayed empty.
- Points are stored as buffered circle polygons so the resulting
`ShapesModel` stays uniform-type (no `radius` column).
Module:
- `pl/interactive/_session.py` — `_InteractiveSession` wraps
`BioImageViewer` with an ipywidgets card (Name field, Save, Clear,
banner). RGB channels are pre-swapped to `[G, R, B]` so anybioimage's
default `CHANNEL_COLORS=[green, red, blue]` lands true RGB without
post-hoc `_channel_settings` mutation (which fights the text field's
focus).
- `pl/interactive/_commit.py` — pure functions that read the viewer's
`_rois_data` / `_polygons_data` / `_points_data` traits and map pixel
coords back to the chosen CS.
- Deleted: `_canvas.py`, `_render.py`, `_persist.py`,
`static/draw_canvas.js` and their test files.
Deps:
- Add `anybioimage>=0.3,<0.4` to the published `interactive` extra.
- Drop the `interactive-extras` dep-group (ipympl + plotly<6 + squidpy):
it existed only for the abandoned anywidget/plotly sandbox prototype
and is no longer referenced. Drop it from the `dev-interactive-py313`
pixi env too.
Tests: 21 pass against `dev-interactive-py313`. Full suite (644 tests)
still passes — the `_copy()` change is additive (attaches one private
attribute to chained copies) and does not affect render-only paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timtreistimtreis changed the title Add sdata.pl.annotate() — interactive region selection via anywidgetAdd interactive annotation ability via .pl.annotate()May 27, 2026
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
The widget pivoted from the custom anywidget+JS canvas to the
external `anybioimage` library's `BioImageViewer` (see
scverse/spatialdata-plot#684). Update the tutorial to match:
- Intro: replace the anywidget/SVG architecture blurb with a
description of the `BioImageViewer` backend.
- Launch section: rewrite the API signature from the old positional
`annotate(coordinate_system, element, persist=True)` to the new
keyword-only chain-step form `.pl.render_images(...).pl.annotate(
coordinate_systems=...)`. Drop the lasso tool (no longer present)
and the JS-canvas-specific shortcut list. Replace the "Write to
disk" button with a `sdata.write_element(<name>)` follow-up note.
- Polygon query cell: pass the actual shapely geometry rather than
the whole `GeoDataFrame`. spatialdata's `polygon_query` overload
for `DataArray`/`DataTree` calls `GeoDataFrame(geometry=[polygon])`
internally, which fails with `GeometryTypeError: Unknown geometry
type: 'featurecollection'` if passed a nested gdf.
- Watermark: drop `anywidget` (transitive dep that isn't imported by
any executable cell) to keep the watermark report honest.
- Add a `warnings.filterwarnings("ignore")` housekeeping cell ahead
of the squidpy dataset load so the notebook's recorded outputs
don't leak Zarr v3 deprecation chatter into the docs build.
- Add the recorded GIF (`_static/img/interactive_annotate.gif`) and
a placeholder PNG thumbnail (regenerate before merging).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The notebook drives the packaged `sdata.pl.annotate` chain on the
`squidpy.datasets.visium_hne_sdata` H&E section: render → annotate
(BioImageViewer widget) → name → Save → inspect `sdata.shapes`.
Useful as a manual smoke test alongside the automated test suite,
and as a kernel of the (re-executed) Sandbox runs we keep referring
to in PR discussion.
Local prototyping artefacts that don't belong in the repo — the
two prior architecture variants (`Sandbox.anywidget-v0.ipynb`,
`Sandbox.ipympl-v0.ipynb`), the SSH-rendering probe
(`verify_ssh_annotate.ipynb`), and the persisted zarr under
`sandbox_data/` — go into `.gitignore` so they stay locally
visible without sneaking into future commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

Both were scratch / planning artefacts that don't belong in the published
package. Sandbox.ipynb is added to .gitignore so it can stay locally as a
manual smoke notebook without re-entering the repo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis marked this pull request as ready for review May 27, 2026 14:20
@timtreis

Copy link
Copy Markdown
MemberAuthor

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

I've now refactored the feature to use anybioimage instead of directly shipping an equivalent widget inside the package, hopefully it'll keep stable 🤞

`set_aspect("equal")` inside `show()` shrinks the axes box so the figure
has blank padding around the data when the figure aspect doesn't match
the data extent. The previous `savefig` wrote the whole figure (padding
included), but `_make_px_to_cs` assumed PNG dims == axes dims — so drawn
shapes got squeezed toward the center along the padded axis (zero at
center, max offset at edges).
Crop the saved PNG to the axes bbox via `bbox_inches=` so PNG pixels map
1:1 to (xlim, ylim) and the existing px→cs transform stays correct.
Add a regression test that drives the full annotate → save loop on a
non-square (32 wide × 64 tall) image with a square figsize=(5, 5) and
asserts the round-tripped corner polygon matches the CS extent.
Also annotate the previously untyped `_commit.py` helpers with a shared
`PxToCs` callable alias so pre-commit mypy passes on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis merged commit 55eab64 into mainMay 27, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the feature/interactive-annotate branch May 27, 2026 15:10
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
0.4.0 ships `sdata.pl.annotate()` (scverse/spatialdata-plot#684), so the
tutorial can require the published wheel instead of an editable path.
Re-executed the notebook to refresh the watermark output to 0.4.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@timtreis@codecov-commenter@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add interactive annotation ability via .pl.annotate() - #684

Merged
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate
May 27, 2026
Merged

Add interactive annotation ability via .pl.annotate()#684
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate

Conversation

@timtreis

@timtreistimtreis commented May 21, 2026

Copy link
Copy Markdown
Member

Adds sdata.pl.annotate(), a terminal step on a render chain that hands the rasterised RGB to anybioimage's BioImageViewer, then commits drawn shapes back to sdata.shapes as a ShapesModel in the chosen CS with Identity().

  • New optional extra: pip install 'spatialdata-plot[interactive]'.
  • Works over SSH; rectangle / polygon / point tools.
  • _copy() carries a _source_sdata ref so writeback lands on the user's sdata, not the chain copy.

timtreisand 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

codecov-commenter commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.75000% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.01%. Comparing base (8a6a33f) to head (e8c2d31).
⚠️ Report is 3 commits behind head on main.

Files with missing linesPatch %Lines
src/spatialdata_plot/pl/interactive/_session.py0.00%69 Missing ⚠️
src/spatialdata_plot/pl/basic.py5.40%35 Missing ⚠️
src/spatialdata_plot/pl/interactive/_commit.py96.15%2 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #684 +/- ##
==========================================
- Coverage 77.79% 76.01% -1.79% 
==========================================
Files 11 14 +3 Lines 3693 3869 +176 Branches 877 896 +19 ==========================================
+ Hits 2873 2941 +68 - Misses 490 597 +107 - Partials 330 331 +1 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/interactive/__init__.py100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_commit.py96.15% <96.15%> (ø)
src/spatialdata_plot/pl/basic.py78.44% <5.40%> (-7.77%)⬇️
src/spatialdata_plot/pl/interactive/_session.py0.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.

timtreisand 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>
@LucaMarconato

Copy link
Copy Markdown
Member

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

Replace the custom anywidget + JS drawing canvas with the external
`anybioimage` library's `BioImageViewer`. Removes ~1100 lines of bespoke
client-side drawing code in favour of a well-maintained widget that
already implements rectangle / polygon / point tools, wheel zoom + pan,
multi-channel display, and a stable kernel↔JS sync.
API:
- `sdata.pl.annotate()` now behaves as a terminal step on a render chain:
it rasterises the accumulated `plotting_tree` and hands the resulting
RGB to the viewer, so overlays composed upstream (`render_images` /
`render_shapes` / `render_points` / `render_labels`) appear on the
annotation canvas. Args are keyword-only: `coordinate_systems`,
`point_radius_frac`, `figsize`, `dpi`.
- The chain pattern `sdata.pl.render_images(...).pl.annotate()` writes
drawn shapes back into the user's original `sdata`. `_copy()` now
propagates a `_source_sdata` reference forward through chained
`render_*` calls so `annotate()` can resolve the real writeback target
via `getattr(self._sdata, "_source_sdata", self._sdata)`. Without this,
saves landed in the anonymous copy that the chain returns and the
user's `sdata.shapes` silently stayed empty.
- Points are stored as buffered circle polygons so the resulting
`ShapesModel` stays uniform-type (no `radius` column).
Module:
- `pl/interactive/_session.py` — `_InteractiveSession` wraps
`BioImageViewer` with an ipywidgets card (Name field, Save, Clear,
banner). RGB channels are pre-swapped to `[G, R, B]` so anybioimage's
default `CHANNEL_COLORS=[green, red, blue]` lands true RGB without
post-hoc `_channel_settings` mutation (which fights the text field's
focus).
- `pl/interactive/_commit.py` — pure functions that read the viewer's
`_rois_data` / `_polygons_data` / `_points_data` traits and map pixel
coords back to the chosen CS.
- Deleted: `_canvas.py`, `_render.py`, `_persist.py`,
`static/draw_canvas.js` and their test files.
Deps:
- Add `anybioimage>=0.3,<0.4` to the published `interactive` extra.
- Drop the `interactive-extras` dep-group (ipympl + plotly<6 + squidpy):
it existed only for the abandoned anywidget/plotly sandbox prototype
and is no longer referenced. Drop it from the `dev-interactive-py313`
pixi env too.
Tests: 21 pass against `dev-interactive-py313`. Full suite (644 tests)
still passes — the `_copy()` change is additive (attaches one private
attribute to chained copies) and does not affect render-only paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timtreistimtreis changed the title Add sdata.pl.annotate() — interactive region selection via anywidgetAdd interactive annotation ability via .pl.annotate()May 27, 2026
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
The widget pivoted from the custom anywidget+JS canvas to the
external `anybioimage` library's `BioImageViewer` (see
scverse/spatialdata-plot#684). Update the tutorial to match:
- Intro: replace the anywidget/SVG architecture blurb with a
description of the `BioImageViewer` backend.
- Launch section: rewrite the API signature from the old positional
`annotate(coordinate_system, element, persist=True)` to the new
keyword-only chain-step form `.pl.render_images(...).pl.annotate(
coordinate_systems=...)`. Drop the lasso tool (no longer present)
and the JS-canvas-specific shortcut list. Replace the "Write to
disk" button with a `sdata.write_element(<name>)` follow-up note.
- Polygon query cell: pass the actual shapely geometry rather than
the whole `GeoDataFrame`. spatialdata's `polygon_query` overload
for `DataArray`/`DataTree` calls `GeoDataFrame(geometry=[polygon])`
internally, which fails with `GeometryTypeError: Unknown geometry
type: 'featurecollection'` if passed a nested gdf.
- Watermark: drop `anywidget` (transitive dep that isn't imported by
any executable cell) to keep the watermark report honest.
- Add a `warnings.filterwarnings("ignore")` housekeeping cell ahead
of the squidpy dataset load so the notebook's recorded outputs
don't leak Zarr v3 deprecation chatter into the docs build.
- Add the recorded GIF (`_static/img/interactive_annotate.gif`) and
a placeholder PNG thumbnail (regenerate before merging).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The notebook drives the packaged `sdata.pl.annotate` chain on the
`squidpy.datasets.visium_hne_sdata` H&E section: render → annotate
(BioImageViewer widget) → name → Save → inspect `sdata.shapes`.
Useful as a manual smoke test alongside the automated test suite,
and as a kernel of the (re-executed) Sandbox runs we keep referring
to in PR discussion.
Local prototyping artefacts that don't belong in the repo — the
two prior architecture variants (`Sandbox.anywidget-v0.ipynb`,
`Sandbox.ipympl-v0.ipynb`), the SSH-rendering probe
(`verify_ssh_annotate.ipynb`), and the persisted zarr under
`sandbox_data/` — go into `.gitignore` so they stay locally
visible without sneaking into future commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

Both were scratch / planning artefacts that don't belong in the published
package. Sandbox.ipynb is added to .gitignore so it can stay locally as a
manual smoke notebook without re-entering the repo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis marked this pull request as ready for review May 27, 2026 14:20
@timtreis

Copy link
Copy Markdown
MemberAuthor

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

I've now refactored the feature to use anybioimage instead of directly shipping an equivalent widget inside the package, hopefully it'll keep stable 🤞

`set_aspect("equal")` inside `show()` shrinks the axes box so the figure
has blank padding around the data when the figure aspect doesn't match
the data extent. The previous `savefig` wrote the whole figure (padding
included), but `_make_px_to_cs` assumed PNG dims == axes dims — so drawn
shapes got squeezed toward the center along the padded axis (zero at
center, max offset at edges).
Crop the saved PNG to the axes bbox via `bbox_inches=` so PNG pixels map
1:1 to (xlim, ylim) and the existing px→cs transform stays correct.
Add a regression test that drives the full annotate → save loop on a
non-square (32 wide × 64 tall) image with a square figsize=(5, 5) and
asserts the round-tripped corner polygon matches the CS extent.
Also annotate the previously untyped `_commit.py` helpers with a shared
`PxToCs` callable alias so pre-commit mypy passes on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis merged commit 55eab64 into mainMay 27, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the feature/interactive-annotate branch May 27, 2026 15:10
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
0.4.0 ships `sdata.pl.annotate()` (scverse/spatialdata-plot#684), so the
tutorial can require the published wheel instead of an editable path.
Re-executed the notebook to refresh the watermark output to 0.4.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@timtreis@codecov-commenter@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Add interactive annotation ability via .pl.annotate() - #684

Merged
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate
May 27, 2026
Merged

Add interactive annotation ability via .pl.annotate()#684
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate

Conversation

@timtreis

@timtreistimtreis commented May 21, 2026

Copy link
Copy Markdown
Member

Adds sdata.pl.annotate(), a terminal step on a render chain that hands the rasterised RGB to anybioimage's BioImageViewer, then commits drawn shapes back to sdata.shapes as a ShapesModel in the chosen CS with Identity().

  • New optional extra: pip install 'spatialdata-plot[interactive]'.
  • Works over SSH; rectangle / polygon / point tools.
  • _copy() carries a _source_sdata ref so writeback lands on the user's sdata, not the chain copy.

timtreisand 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

codecov-commenter commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.75000% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.01%. Comparing base (8a6a33f) to head (e8c2d31).
⚠️ Report is 3 commits behind head on main.

Files with missing linesPatch %Lines
src/spatialdata_plot/pl/interactive/_session.py0.00%69 Missing ⚠️
src/spatialdata_plot/pl/basic.py5.40%35 Missing ⚠️
src/spatialdata_plot/pl/interactive/_commit.py96.15%2 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #684 +/- ##
==========================================
- Coverage 77.79% 76.01% -1.79% 
==========================================
Files 11 14 +3 Lines 3693 3869 +176 Branches 877 896 +19 ==========================================
+ Hits 2873 2941 +68 - Misses 490 597 +107 - Partials 330 331 +1 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/interactive/__init__.py100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_commit.py96.15% <96.15%> (ø)
src/spatialdata_plot/pl/basic.py78.44% <5.40%> (-7.77%)⬇️
src/spatialdata_plot/pl/interactive/_session.py0.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.

timtreisand 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>
@LucaMarconato

Copy link
Copy Markdown
Member

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

Replace the custom anywidget + JS drawing canvas with the external
`anybioimage` library's `BioImageViewer`. Removes ~1100 lines of bespoke
client-side drawing code in favour of a well-maintained widget that
already implements rectangle / polygon / point tools, wheel zoom + pan,
multi-channel display, and a stable kernel↔JS sync.
API:
- `sdata.pl.annotate()` now behaves as a terminal step on a render chain:
it rasterises the accumulated `plotting_tree` and hands the resulting
RGB to the viewer, so overlays composed upstream (`render_images` /
`render_shapes` / `render_points` / `render_labels`) appear on the
annotation canvas. Args are keyword-only: `coordinate_systems`,
`point_radius_frac`, `figsize`, `dpi`.
- The chain pattern `sdata.pl.render_images(...).pl.annotate()` writes
drawn shapes back into the user's original `sdata`. `_copy()` now
propagates a `_source_sdata` reference forward through chained
`render_*` calls so `annotate()` can resolve the real writeback target
via `getattr(self._sdata, "_source_sdata", self._sdata)`. Without this,
saves landed in the anonymous copy that the chain returns and the
user's `sdata.shapes` silently stayed empty.
- Points are stored as buffered circle polygons so the resulting
`ShapesModel` stays uniform-type (no `radius` column).
Module:
- `pl/interactive/_session.py` — `_InteractiveSession` wraps
`BioImageViewer` with an ipywidgets card (Name field, Save, Clear,
banner). RGB channels are pre-swapped to `[G, R, B]` so anybioimage's
default `CHANNEL_COLORS=[green, red, blue]` lands true RGB without
post-hoc `_channel_settings` mutation (which fights the text field's
focus).
- `pl/interactive/_commit.py` — pure functions that read the viewer's
`_rois_data` / `_polygons_data` / `_points_data` traits and map pixel
coords back to the chosen CS.
- Deleted: `_canvas.py`, `_render.py`, `_persist.py`,
`static/draw_canvas.js` and their test files.
Deps:
- Add `anybioimage>=0.3,<0.4` to the published `interactive` extra.
- Drop the `interactive-extras` dep-group (ipympl + plotly<6 + squidpy):
it existed only for the abandoned anywidget/plotly sandbox prototype
and is no longer referenced. Drop it from the `dev-interactive-py313`
pixi env too.
Tests: 21 pass against `dev-interactive-py313`. Full suite (644 tests)
still passes — the `_copy()` change is additive (attaches one private
attribute to chained copies) and does not affect render-only paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timtreistimtreis changed the title Add sdata.pl.annotate() — interactive region selection via anywidgetAdd interactive annotation ability via .pl.annotate()May 27, 2026
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
The widget pivoted from the custom anywidget+JS canvas to the
external `anybioimage` library's `BioImageViewer` (see
scverse/spatialdata-plot#684). Update the tutorial to match:
- Intro: replace the anywidget/SVG architecture blurb with a
description of the `BioImageViewer` backend.
- Launch section: rewrite the API signature from the old positional
`annotate(coordinate_system, element, persist=True)` to the new
keyword-only chain-step form `.pl.render_images(...).pl.annotate(
coordinate_systems=...)`. Drop the lasso tool (no longer present)
and the JS-canvas-specific shortcut list. Replace the "Write to
disk" button with a `sdata.write_element(<name>)` follow-up note.
- Polygon query cell: pass the actual shapely geometry rather than
the whole `GeoDataFrame`. spatialdata's `polygon_query` overload
for `DataArray`/`DataTree` calls `GeoDataFrame(geometry=[polygon])`
internally, which fails with `GeometryTypeError: Unknown geometry
type: 'featurecollection'` if passed a nested gdf.
- Watermark: drop `anywidget` (transitive dep that isn't imported by
any executable cell) to keep the watermark report honest.
- Add a `warnings.filterwarnings("ignore")` housekeeping cell ahead
of the squidpy dataset load so the notebook's recorded outputs
don't leak Zarr v3 deprecation chatter into the docs build.
- Add the recorded GIF (`_static/img/interactive_annotate.gif`) and
a placeholder PNG thumbnail (regenerate before merging).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The notebook drives the packaged `sdata.pl.annotate` chain on the
`squidpy.datasets.visium_hne_sdata` H&E section: render → annotate
(BioImageViewer widget) → name → Save → inspect `sdata.shapes`.
Useful as a manual smoke test alongside the automated test suite,
and as a kernel of the (re-executed) Sandbox runs we keep referring
to in PR discussion.
Local prototyping artefacts that don't belong in the repo — the
two prior architecture variants (`Sandbox.anywidget-v0.ipynb`,
`Sandbox.ipympl-v0.ipynb`), the SSH-rendering probe
(`verify_ssh_annotate.ipynb`), and the persisted zarr under
`sandbox_data/` — go into `.gitignore` so they stay locally
visible without sneaking into future commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

Both were scratch / planning artefacts that don't belong in the published
package. Sandbox.ipynb is added to .gitignore so it can stay locally as a
manual smoke notebook without re-entering the repo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis marked this pull request as ready for review May 27, 2026 14:20
@timtreis

Copy link
Copy Markdown
MemberAuthor

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

I've now refactored the feature to use anybioimage instead of directly shipping an equivalent widget inside the package, hopefully it'll keep stable 🤞

`set_aspect("equal")` inside `show()` shrinks the axes box so the figure
has blank padding around the data when the figure aspect doesn't match
the data extent. The previous `savefig` wrote the whole figure (padding
included), but `_make_px_to_cs` assumed PNG dims == axes dims — so drawn
shapes got squeezed toward the center along the padded axis (zero at
center, max offset at edges).
Crop the saved PNG to the axes bbox via `bbox_inches=` so PNG pixels map
1:1 to (xlim, ylim) and the existing px→cs transform stays correct.
Add a regression test that drives the full annotate → save loop on a
non-square (32 wide × 64 tall) image with a square figsize=(5, 5) and
asserts the round-tripped corner polygon matches the CS extent.
Also annotate the previously untyped `_commit.py` helpers with a shared
`PxToCs` callable alias so pre-commit mypy passes on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis merged commit 55eab64 into mainMay 27, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the feature/interactive-annotate branch May 27, 2026 15:10
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
0.4.0 ships `sdata.pl.annotate()` (scverse/spatialdata-plot#684), so the
tutorial can require the published wheel instead of an editable path.
Re-executed the notebook to refresh the watermark output to 0.4.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@timtreis@codecov-commenter@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add interactive annotation ability via .pl.annotate() - #684

Merged
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate
May 27, 2026
Merged

Add interactive annotation ability via .pl.annotate()#684
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate

Conversation

@timtreis

@timtreistimtreis commented May 21, 2026

Copy link
Copy Markdown
Member

Adds sdata.pl.annotate(), a terminal step on a render chain that hands the rasterised RGB to anybioimage's BioImageViewer, then commits drawn shapes back to sdata.shapes as a ShapesModel in the chosen CS with Identity().

  • New optional extra: pip install 'spatialdata-plot[interactive]'.
  • Works over SSH; rectangle / polygon / point tools.
  • _copy() carries a _source_sdata ref so writeback lands on the user's sdata, not the chain copy.

timtreisand 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

codecov-commenter commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.75000% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.01%. Comparing base (8a6a33f) to head (e8c2d31).
⚠️ Report is 3 commits behind head on main.

Files with missing linesPatch %Lines
src/spatialdata_plot/pl/interactive/_session.py0.00%69 Missing ⚠️
src/spatialdata_plot/pl/basic.py5.40%35 Missing ⚠️
src/spatialdata_plot/pl/interactive/_commit.py96.15%2 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #684 +/- ##
==========================================
- Coverage 77.79% 76.01% -1.79% 
==========================================
Files 11 14 +3 Lines 3693 3869 +176 Branches 877 896 +19 ==========================================
+ Hits 2873 2941 +68 - Misses 490 597 +107 - Partials 330 331 +1 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/interactive/__init__.py100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_commit.py96.15% <96.15%> (ø)
src/spatialdata_plot/pl/basic.py78.44% <5.40%> (-7.77%)⬇️
src/spatialdata_plot/pl/interactive/_session.py0.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.

timtreisand 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>
@LucaMarconato

Copy link
Copy Markdown
Member

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

Replace the custom anywidget + JS drawing canvas with the external
`anybioimage` library's `BioImageViewer`. Removes ~1100 lines of bespoke
client-side drawing code in favour of a well-maintained widget that
already implements rectangle / polygon / point tools, wheel zoom + pan,
multi-channel display, and a stable kernel↔JS sync.
API:
- `sdata.pl.annotate()` now behaves as a terminal step on a render chain:
it rasterises the accumulated `plotting_tree` and hands the resulting
RGB to the viewer, so overlays composed upstream (`render_images` /
`render_shapes` / `render_points` / `render_labels`) appear on the
annotation canvas. Args are keyword-only: `coordinate_systems`,
`point_radius_frac`, `figsize`, `dpi`.
- The chain pattern `sdata.pl.render_images(...).pl.annotate()` writes
drawn shapes back into the user's original `sdata`. `_copy()` now
propagates a `_source_sdata` reference forward through chained
`render_*` calls so `annotate()` can resolve the real writeback target
via `getattr(self._sdata, "_source_sdata", self._sdata)`. Without this,
saves landed in the anonymous copy that the chain returns and the
user's `sdata.shapes` silently stayed empty.
- Points are stored as buffered circle polygons so the resulting
`ShapesModel` stays uniform-type (no `radius` column).
Module:
- `pl/interactive/_session.py` — `_InteractiveSession` wraps
`BioImageViewer` with an ipywidgets card (Name field, Save, Clear,
banner). RGB channels are pre-swapped to `[G, R, B]` so anybioimage's
default `CHANNEL_COLORS=[green, red, blue]` lands true RGB without
post-hoc `_channel_settings` mutation (which fights the text field's
focus).
- `pl/interactive/_commit.py` — pure functions that read the viewer's
`_rois_data` / `_polygons_data` / `_points_data` traits and map pixel
coords back to the chosen CS.
- Deleted: `_canvas.py`, `_render.py`, `_persist.py`,
`static/draw_canvas.js` and their test files.
Deps:
- Add `anybioimage>=0.3,<0.4` to the published `interactive` extra.
- Drop the `interactive-extras` dep-group (ipympl + plotly<6 + squidpy):
it existed only for the abandoned anywidget/plotly sandbox prototype
and is no longer referenced. Drop it from the `dev-interactive-py313`
pixi env too.
Tests: 21 pass against `dev-interactive-py313`. Full suite (644 tests)
still passes — the `_copy()` change is additive (attaches one private
attribute to chained copies) and does not affect render-only paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timtreistimtreis changed the title Add sdata.pl.annotate() — interactive region selection via anywidgetAdd interactive annotation ability via .pl.annotate()May 27, 2026
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
The widget pivoted from the custom anywidget+JS canvas to the
external `anybioimage` library's `BioImageViewer` (see
scverse/spatialdata-plot#684). Update the tutorial to match:
- Intro: replace the anywidget/SVG architecture blurb with a
description of the `BioImageViewer` backend.
- Launch section: rewrite the API signature from the old positional
`annotate(coordinate_system, element, persist=True)` to the new
keyword-only chain-step form `.pl.render_images(...).pl.annotate(
coordinate_systems=...)`. Drop the lasso tool (no longer present)
and the JS-canvas-specific shortcut list. Replace the "Write to
disk" button with a `sdata.write_element(<name>)` follow-up note.
- Polygon query cell: pass the actual shapely geometry rather than
the whole `GeoDataFrame`. spatialdata's `polygon_query` overload
for `DataArray`/`DataTree` calls `GeoDataFrame(geometry=[polygon])`
internally, which fails with `GeometryTypeError: Unknown geometry
type: 'featurecollection'` if passed a nested gdf.
- Watermark: drop `anywidget` (transitive dep that isn't imported by
any executable cell) to keep the watermark report honest.
- Add a `warnings.filterwarnings("ignore")` housekeeping cell ahead
of the squidpy dataset load so the notebook's recorded outputs
don't leak Zarr v3 deprecation chatter into the docs build.
- Add the recorded GIF (`_static/img/interactive_annotate.gif`) and
a placeholder PNG thumbnail (regenerate before merging).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The notebook drives the packaged `sdata.pl.annotate` chain on the
`squidpy.datasets.visium_hne_sdata` H&E section: render → annotate
(BioImageViewer widget) → name → Save → inspect `sdata.shapes`.
Useful as a manual smoke test alongside the automated test suite,
and as a kernel of the (re-executed) Sandbox runs we keep referring
to in PR discussion.
Local prototyping artefacts that don't belong in the repo — the
two prior architecture variants (`Sandbox.anywidget-v0.ipynb`,
`Sandbox.ipympl-v0.ipynb`), the SSH-rendering probe
(`verify_ssh_annotate.ipynb`), and the persisted zarr under
`sandbox_data/` — go into `.gitignore` so they stay locally
visible without sneaking into future commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

Both were scratch / planning artefacts that don't belong in the published
package. Sandbox.ipynb is added to .gitignore so it can stay locally as a
manual smoke notebook without re-entering the repo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis marked this pull request as ready for review May 27, 2026 14:20
@timtreis

Copy link
Copy Markdown
MemberAuthor

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

I've now refactored the feature to use anybioimage instead of directly shipping an equivalent widget inside the package, hopefully it'll keep stable 🤞

`set_aspect("equal")` inside `show()` shrinks the axes box so the figure
has blank padding around the data when the figure aspect doesn't match
the data extent. The previous `savefig` wrote the whole figure (padding
included), but `_make_px_to_cs` assumed PNG dims == axes dims — so drawn
shapes got squeezed toward the center along the padded axis (zero at
center, max offset at edges).
Crop the saved PNG to the axes bbox via `bbox_inches=` so PNG pixels map
1:1 to (xlim, ylim) and the existing px→cs transform stays correct.
Add a regression test that drives the full annotate → save loop on a
non-square (32 wide × 64 tall) image with a square figsize=(5, 5) and
asserts the round-tripped corner polygon matches the CS extent.
Also annotate the previously untyped `_commit.py` helpers with a shared
`PxToCs` callable alias so pre-commit mypy passes on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis merged commit 55eab64 into mainMay 27, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the feature/interactive-annotate branch May 27, 2026 15:10
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
0.4.0 ships `sdata.pl.annotate()` (scverse/spatialdata-plot#684), so the
tutorial can require the published wheel instead of an editable path.
Re-executed the notebook to refresh the watermark output to 0.4.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@timtreis@codecov-commenter@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add interactive annotation ability via .pl.annotate() - #684

Merged
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate
May 27, 2026
Merged

Add interactive annotation ability via .pl.annotate()#684
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate

Conversation

@timtreis

@timtreistimtreis commented May 21, 2026

Copy link
Copy Markdown
Member

Adds sdata.pl.annotate(), a terminal step on a render chain that hands the rasterised RGB to anybioimage's BioImageViewer, then commits drawn shapes back to sdata.shapes as a ShapesModel in the chosen CS with Identity().

  • New optional extra: pip install 'spatialdata-plot[interactive]'.
  • Works over SSH; rectangle / polygon / point tools.
  • _copy() carries a _source_sdata ref so writeback lands on the user's sdata, not the chain copy.

timtreisand 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

codecov-commenter commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.75000% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.01%. Comparing base (8a6a33f) to head (e8c2d31).
⚠️ Report is 3 commits behind head on main.

Files with missing linesPatch %Lines
src/spatialdata_plot/pl/interactive/_session.py0.00%69 Missing ⚠️
src/spatialdata_plot/pl/basic.py5.40%35 Missing ⚠️
src/spatialdata_plot/pl/interactive/_commit.py96.15%2 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #684 +/- ##
==========================================
- Coverage 77.79% 76.01% -1.79% 
==========================================
Files 11 14 +3 Lines 3693 3869 +176 Branches 877 896 +19 ==========================================
+ Hits 2873 2941 +68 - Misses 490 597 +107 - Partials 330 331 +1 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/interactive/__init__.py100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_commit.py96.15% <96.15%> (ø)
src/spatialdata_plot/pl/basic.py78.44% <5.40%> (-7.77%)⬇️
src/spatialdata_plot/pl/interactive/_session.py0.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.

timtreisand 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>
@LucaMarconato

Copy link
Copy Markdown
Member

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

Replace the custom anywidget + JS drawing canvas with the external
`anybioimage` library's `BioImageViewer`. Removes ~1100 lines of bespoke
client-side drawing code in favour of a well-maintained widget that
already implements rectangle / polygon / point tools, wheel zoom + pan,
multi-channel display, and a stable kernel↔JS sync.
API:
- `sdata.pl.annotate()` now behaves as a terminal step on a render chain:
it rasterises the accumulated `plotting_tree` and hands the resulting
RGB to the viewer, so overlays composed upstream (`render_images` /
`render_shapes` / `render_points` / `render_labels`) appear on the
annotation canvas. Args are keyword-only: `coordinate_systems`,
`point_radius_frac`, `figsize`, `dpi`.
- The chain pattern `sdata.pl.render_images(...).pl.annotate()` writes
drawn shapes back into the user's original `sdata`. `_copy()` now
propagates a `_source_sdata` reference forward through chained
`render_*` calls so `annotate()` can resolve the real writeback target
via `getattr(self._sdata, "_source_sdata", self._sdata)`. Without this,
saves landed in the anonymous copy that the chain returns and the
user's `sdata.shapes` silently stayed empty.
- Points are stored as buffered circle polygons so the resulting
`ShapesModel` stays uniform-type (no `radius` column).
Module:
- `pl/interactive/_session.py` — `_InteractiveSession` wraps
`BioImageViewer` with an ipywidgets card (Name field, Save, Clear,
banner). RGB channels are pre-swapped to `[G, R, B]` so anybioimage's
default `CHANNEL_COLORS=[green, red, blue]` lands true RGB without
post-hoc `_channel_settings` mutation (which fights the text field's
focus).
- `pl/interactive/_commit.py` — pure functions that read the viewer's
`_rois_data` / `_polygons_data` / `_points_data` traits and map pixel
coords back to the chosen CS.
- Deleted: `_canvas.py`, `_render.py`, `_persist.py`,
`static/draw_canvas.js` and their test files.
Deps:
- Add `anybioimage>=0.3,<0.4` to the published `interactive` extra.
- Drop the `interactive-extras` dep-group (ipympl + plotly<6 + squidpy):
it existed only for the abandoned anywidget/plotly sandbox prototype
and is no longer referenced. Drop it from the `dev-interactive-py313`
pixi env too.
Tests: 21 pass against `dev-interactive-py313`. Full suite (644 tests)
still passes — the `_copy()` change is additive (attaches one private
attribute to chained copies) and does not affect render-only paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timtreistimtreis changed the title Add sdata.pl.annotate() — interactive region selection via anywidgetAdd interactive annotation ability via .pl.annotate()May 27, 2026
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
The widget pivoted from the custom anywidget+JS canvas to the
external `anybioimage` library's `BioImageViewer` (see
scverse/spatialdata-plot#684). Update the tutorial to match:
- Intro: replace the anywidget/SVG architecture blurb with a
description of the `BioImageViewer` backend.
- Launch section: rewrite the API signature from the old positional
`annotate(coordinate_system, element, persist=True)` to the new
keyword-only chain-step form `.pl.render_images(...).pl.annotate(
coordinate_systems=...)`. Drop the lasso tool (no longer present)
and the JS-canvas-specific shortcut list. Replace the "Write to
disk" button with a `sdata.write_element(<name>)` follow-up note.
- Polygon query cell: pass the actual shapely geometry rather than
the whole `GeoDataFrame`. spatialdata's `polygon_query` overload
for `DataArray`/`DataTree` calls `GeoDataFrame(geometry=[polygon])`
internally, which fails with `GeometryTypeError: Unknown geometry
type: 'featurecollection'` if passed a nested gdf.
- Watermark: drop `anywidget` (transitive dep that isn't imported by
any executable cell) to keep the watermark report honest.
- Add a `warnings.filterwarnings("ignore")` housekeeping cell ahead
of the squidpy dataset load so the notebook's recorded outputs
don't leak Zarr v3 deprecation chatter into the docs build.
- Add the recorded GIF (`_static/img/interactive_annotate.gif`) and
a placeholder PNG thumbnail (regenerate before merging).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The notebook drives the packaged `sdata.pl.annotate` chain on the
`squidpy.datasets.visium_hne_sdata` H&E section: render → annotate
(BioImageViewer widget) → name → Save → inspect `sdata.shapes`.
Useful as a manual smoke test alongside the automated test suite,
and as a kernel of the (re-executed) Sandbox runs we keep referring
to in PR discussion.
Local prototyping artefacts that don't belong in the repo — the
two prior architecture variants (`Sandbox.anywidget-v0.ipynb`,
`Sandbox.ipympl-v0.ipynb`), the SSH-rendering probe
(`verify_ssh_annotate.ipynb`), and the persisted zarr under
`sandbox_data/` — go into `.gitignore` so they stay locally
visible without sneaking into future commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

Both were scratch / planning artefacts that don't belong in the published
package. Sandbox.ipynb is added to .gitignore so it can stay locally as a
manual smoke notebook without re-entering the repo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis marked this pull request as ready for review May 27, 2026 14:20
@timtreis

Copy link
Copy Markdown
MemberAuthor

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

I've now refactored the feature to use anybioimage instead of directly shipping an equivalent widget inside the package, hopefully it'll keep stable 🤞

`set_aspect("equal")` inside `show()` shrinks the axes box so the figure
has blank padding around the data when the figure aspect doesn't match
the data extent. The previous `savefig` wrote the whole figure (padding
included), but `_make_px_to_cs` assumed PNG dims == axes dims — so drawn
shapes got squeezed toward the center along the padded axis (zero at
center, max offset at edges).
Crop the saved PNG to the axes bbox via `bbox_inches=` so PNG pixels map
1:1 to (xlim, ylim) and the existing px→cs transform stays correct.
Add a regression test that drives the full annotate → save loop on a
non-square (32 wide × 64 tall) image with a square figsize=(5, 5) and
asserts the round-tripped corner polygon matches the CS extent.
Also annotate the previously untyped `_commit.py` helpers with a shared
`PxToCs` callable alias so pre-commit mypy passes on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis merged commit 55eab64 into mainMay 27, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the feature/interactive-annotate branch May 27, 2026 15:10
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
0.4.0 ships `sdata.pl.annotate()` (scverse/spatialdata-plot#684), so the
tutorial can require the published wheel instead of an editable path.
Re-executed the notebook to refresh the watermark output to 0.4.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@timtreis@codecov-commenter@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Add interactive annotation ability via .pl.annotate() - #684

Merged
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate
May 27, 2026
Merged

Add interactive annotation ability via .pl.annotate()#684
timtreis merged 10 commits into
mainfrom
feature/interactive-annotate

Conversation

@timtreis

@timtreistimtreis commented May 21, 2026

Copy link
Copy Markdown
Member

Adds sdata.pl.annotate(), a terminal step on a render chain that hands the rasterised RGB to anybioimage's BioImageViewer, then commits drawn shapes back to sdata.shapes as a ShapesModel in the chosen CS with Identity().

  • New optional extra: pip install 'spatialdata-plot[interactive]'.
  • Works over SSH; rectangle / polygon / point tools.
  • _copy() carries a _source_sdata ref so writeback lands on the user's sdata, not the chain copy.

timtreisand 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

codecov-commenter commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.75000% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.01%. Comparing base (8a6a33f) to head (e8c2d31).
⚠️ Report is 3 commits behind head on main.

Files with missing linesPatch %Lines
src/spatialdata_plot/pl/interactive/_session.py0.00%69 Missing ⚠️
src/spatialdata_plot/pl/basic.py5.40%35 Missing ⚠️
src/spatialdata_plot/pl/interactive/_commit.py96.15%2 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #684 +/- ##
==========================================
- Coverage 77.79% 76.01% -1.79% 
==========================================
Files 11 14 +3 Lines 3693 3869 +176 Branches 877 896 +19 ==========================================
+ Hits 2873 2941 +68 - Misses 490 597 +107 - Partials 330 331 +1 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/interactive/__init__.py100.00% <100.00%> (ø)
src/spatialdata_plot/pl/interactive/_commit.py96.15% <96.15%> (ø)
src/spatialdata_plot/pl/basic.py78.44% <5.40%> (-7.77%)⬇️
src/spatialdata_plot/pl/interactive/_session.py0.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.

timtreisand 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>
@LucaMarconato

Copy link
Copy Markdown
Member

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

Replace the custom anywidget + JS drawing canvas with the external
`anybioimage` library's `BioImageViewer`. Removes ~1100 lines of bespoke
client-side drawing code in favour of a well-maintained widget that
already implements rectangle / polygon / point tools, wheel zoom + pan,
multi-channel display, and a stable kernel↔JS sync.
API:
- `sdata.pl.annotate()` now behaves as a terminal step on a render chain:
it rasterises the accumulated `plotting_tree` and hands the resulting
RGB to the viewer, so overlays composed upstream (`render_images` /
`render_shapes` / `render_points` / `render_labels`) appear on the
annotation canvas. Args are keyword-only: `coordinate_systems`,
`point_radius_frac`, `figsize`, `dpi`.
- The chain pattern `sdata.pl.render_images(...).pl.annotate()` writes
drawn shapes back into the user's original `sdata`. `_copy()` now
propagates a `_source_sdata` reference forward through chained
`render_*` calls so `annotate()` can resolve the real writeback target
via `getattr(self._sdata, "_source_sdata", self._sdata)`. Without this,
saves landed in the anonymous copy that the chain returns and the
user's `sdata.shapes` silently stayed empty.
- Points are stored as buffered circle polygons so the resulting
`ShapesModel` stays uniform-type (no `radius` column).
Module:
- `pl/interactive/_session.py` — `_InteractiveSession` wraps
`BioImageViewer` with an ipywidgets card (Name field, Save, Clear,
banner). RGB channels are pre-swapped to `[G, R, B]` so anybioimage's
default `CHANNEL_COLORS=[green, red, blue]` lands true RGB without
post-hoc `_channel_settings` mutation (which fights the text field's
focus).
- `pl/interactive/_commit.py` — pure functions that read the viewer's
`_rois_data` / `_polygons_data` / `_points_data` traits and map pixel
coords back to the chosen CS.
- Deleted: `_canvas.py`, `_render.py`, `_persist.py`,
`static/draw_canvas.js` and their test files.
Deps:
- Add `anybioimage>=0.3,<0.4` to the published `interactive` extra.
- Drop the `interactive-extras` dep-group (ipympl + plotly<6 + squidpy):
it existed only for the abandoned anywidget/plotly sandbox prototype
and is no longer referenced. Drop it from the `dev-interactive-py313`
pixi env too.
Tests: 21 pass against `dev-interactive-py313`. Full suite (644 tests)
still passes — the `_copy()` change is additive (attaches one private
attribute to chained copies) and does not affect render-only paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timtreistimtreis changed the title Add sdata.pl.annotate() — interactive region selection via anywidgetAdd interactive annotation ability via .pl.annotate()May 27, 2026
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
The widget pivoted from the custom anywidget+JS canvas to the
external `anybioimage` library's `BioImageViewer` (see
scverse/spatialdata-plot#684). Update the tutorial to match:
- Intro: replace the anywidget/SVG architecture blurb with a
description of the `BioImageViewer` backend.
- Launch section: rewrite the API signature from the old positional
`annotate(coordinate_system, element, persist=True)` to the new
keyword-only chain-step form `.pl.render_images(...).pl.annotate(
coordinate_systems=...)`. Drop the lasso tool (no longer present)
and the JS-canvas-specific shortcut list. Replace the "Write to
disk" button with a `sdata.write_element(<name>)` follow-up note.
- Polygon query cell: pass the actual shapely geometry rather than
the whole `GeoDataFrame`. spatialdata's `polygon_query` overload
for `DataArray`/`DataTree` calls `GeoDataFrame(geometry=[polygon])`
internally, which fails with `GeometryTypeError: Unknown geometry
type: 'featurecollection'` if passed a nested gdf.
- Watermark: drop `anywidget` (transitive dep that isn't imported by
any executable cell) to keep the watermark report honest.
- Add a `warnings.filterwarnings("ignore")` housekeeping cell ahead
of the squidpy dataset load so the notebook's recorded outputs
don't leak Zarr v3 deprecation chatter into the docs build.
- Add the recorded GIF (`_static/img/interactive_annotate.gif`) and
a placeholder PNG thumbnail (regenerate before merging).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The notebook drives the packaged `sdata.pl.annotate` chain on the
`squidpy.datasets.visium_hne_sdata` H&E section: render → annotate
(BioImageViewer widget) → name → Save → inspect `sdata.shapes`.
Useful as a manual smoke test alongside the automated test suite,
and as a kernel of the (re-executed) Sandbox runs we keep referring
to in PR discussion.
Local prototyping artefacts that don't belong in the repo — the
two prior architecture variants (`Sandbox.anywidget-v0.ipynb`,
`Sandbox.ipympl-v0.ipynb`), the SSH-rendering probe
(`verify_ssh_annotate.ipynb`), and the persisted zarr under
`sandbox_data/` — go into `.gitignore` so they stay locally
visible without sneaking into future commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

Both were scratch / planning artefacts that don't belong in the published
package. Sandbox.ipynb is added to .gitignore so it can stay locally as a
manual smoke notebook without re-entering the repo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis marked this pull request as ready for review May 27, 2026 14:20
@timtreis

Copy link
Copy Markdown
MemberAuthor

Hi, really useful feature! What's the relationship to this scverse/2026_04_hackathon_padua#22?

I've now refactored the feature to use anybioimage instead of directly shipping an equivalent widget inside the package, hopefully it'll keep stable 🤞

`set_aspect("equal")` inside `show()` shrinks the axes box so the figure
has blank padding around the data when the figure aspect doesn't match
the data extent. The previous `savefig` wrote the whole figure (padding
included), but `_make_px_to_cs` assumed PNG dims == axes dims — so drawn
shapes got squeezed toward the center along the padded axis (zero at
center, max offset at edges).
Crop the saved PNG to the axes bbox via `bbox_inches=` so PNG pixels map
1:1 to (xlim, ylim) and the existing px→cs transform stays correct.
Add a regression test that drives the full annotate → save loop on a
non-square (32 wide × 64 tall) image with a square figsize=(5, 5) and
asserts the round-tripped corner polygon matches the CS extent.
Also annotate the previously untyped `_commit.py` helpers with a shared
`PxToCs` callable alias so pre-commit mypy passes on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@timtreis
timtreis merged commit 55eab64 into mainMay 27, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the feature/interactive-annotate branch May 27, 2026 15:10
timtreis added a commit to scverse/spatialdata-plot-tutorials that referenced this pull request May 27, 2026
0.4.0 ships `sdata.pl.annotate()` (scverse/spatialdata-plot#684), so the
tutorial can require the published wheel instead of an editable path.
Re-executed the notebook to refresh the watermark output to 0.4.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@timtreis@codecov-commenter@LucaMarconato