Skip to content

feat(widgets): BrushWidget — freehand multi-class label strokes on Plot2D - #47

Merged
CSSFrancis merged 1 commit into
mainfrom
feat/brush-widget
Jul 30, 2026
Merged

feat(widgets): BrushWidget — freehand multi-class label strokes on Plot2D#47
CSSFrancis merged 1 commit into
mainfrom
feat/brush-widget

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Adds Plot2D.add_brush_widget() — freehand painting of multi-class label strokes on a 2-D image. Written for labelling workflows where a polygon or rectangle is the wrong shape: painting training scribbles for a pixel classifier, marking a defect, masking a beam stop.

brush = plot.add_brush_widget(radius=6, colors=["#f44", "#4f4"])

@brush.add_event_handler("pointer_up")
def stroke_done(event):
    update_labels(brush.strokes, brush.stroke_classes)

clear_strokes() / add_stroke(points, class_id=None) / set_strokes(...) / strokes_for_class(id) / n_strokes, and plot.add_widget("brush", ...). radius=None defaults to 2% of the smaller image dimension, matching the other factories' proportional defaults.

Two decisions worth reviewing

A bare drag still pans. Only Shift+drag paints. _ovHitTest2d gives any hit absolute priority over panning, so a brush whose body hit-tests as "anywhere in the image" would kill panning and click-to-select outright. The hit test gained an optional mods argument plus a first pass that runs before the z-order loop and claims the drag only when mods.shift is set. Callers that pass no modifiers can never get a brush hit, so existing behaviour is bit-identical. The first pass is deliberately not a branch inside the loop: painting is modal, and a scribble must not be stolen mid-stroke by whatever widget happens to sit under the cursor. This mirrors the existing point-over-band pass in _ovHitTest1d.

Exactly one event per stroke. Widget drags in this library are unthrottled — each mousemove writes the panel trait and spreads the whole widget dict into event_json, two full serialisations plus two save_changes(). A growing stroke list under that would be re-serialised and re-diffed every move, which is quadratic in the stroke and would make painting unusable. So the stroke accumulates in the browser and only the finished polyline reaches Python, once, on pointer_up. Measured: 1 event per stroke, 0 pointer_move. The precedent is inset_geometry_change, which fires once on release for the same reason.

Consequence for callers: register on pointer_up, not pointer_move — documented in the class docstring.

What this forced beyond the four dispatch points

The in-progress stroke cannot live in p.state. Because the stroke deliberately never writes the panel trait, that trait stays stale for the whole stroke, and any unrelated save_changes() re-fires change:panel_<id>_json and replaces p.state wholesale — a pointer_leave as the cursor crosses the panel edge is enough to lose the stroke. This bit during development. The live stroke therefore lives on a per-panel scratch (p._brushLive), drawOverlay2d prefers the scratch when present, and the commit resolves its widget by ID rather than drag index. _doDrag2d's brush branch gates on the drag mode rather than w.type for the same reason: w can be swapped out underneath it.

Tests

anyplotlib/tests/test_interactive/test_widget_brush.py — 49 tests, following test_widget_max_extent.py's shape (Python-API classes plus Playwright classes over interact_page, geometry read from window._aplWidgetGeom rather than derived from padding).

  • anyplotlib/tests/test_interactive551 passed
  • rest of anyplotlib/tests1343 passed, 58 skipped
  • 502 non-brush interactive tests collect identically on main and on this branch, so nothing was shadowed or lost

Non-vacuity: 11 JS decisions were neutralised one at a time to confirm each is actually covered — hit-test first pass (13 tests fail), mousedown passing modifiers (13), mouseup commit (13), the drawOverlay2d branch (18), drag early-return (5), the silent-emit gate (3), per-class colour lookup (3), class snapshot (2), erase (2), out-of-image guard (1), draw-from-scratch (1).

That sweep found a real hole: nothing observed the stroke during the drag, so "the brush only appears once you let go" would have passed. test_the_stroke_draws_while_the_mouse_is_still_down covers it now.

Open questions for the reviewer

  1. radius means radius, so the painted band is 2 * radius wide. Erase removes points within radius, and a downstream rasteriser will label pixels within radius of the polyline — a band of width radius would draw a lie about what actually got labelled. Happy to flip it to mean stroke width if you'd rather; one line plus doc wording.
  2. strokes is [[[x, y], ...], ...] with a parallel stroke_classes, rather than [{"points": ..., "class_id": ...}, ...]. Keeps the simple case simple, at the cost of a desync risk if a host assigns brush.strokes directly. set_strokes() / add_stroke() are the sanctioned mutators and the JS defaults a missing class to 0. The dict-per-stroke form makes desync structurally impossible — say the word.
  3. alpha (default 0.6) is an addition not strictly required by the feature. An opaque scribble hides the thing you are labelling, so it seemed core rather than decorative.

A pre-existing sharp edge this surfaced (not introduced here, not fixed here)

Figure._push_widget deliberately never writes panel_<id>_json, so a targeted widget update — brush.class_id = 1, rect.x = ... — is reverted by the next save_changes() from anything at all, because that re-fires the panel-json listener with the still-stale trait. test_switching_class_from_python_retags_the_next_stroke has to press Shift before pushing the class to dodge it, with a comment explaining why.

This affects every widget, not just the brush, and it directly breaks the obvious "click a class button, then paint" flow a host would build. Out of scope for this PR, but it wants its own issue.

…ot2D

Adds `BrushWidget` + `Plot2D.add_brush_widget(...)` (and the "brush" kind in
`add_widget`): Shift-drag on a 2-D image paints a freehand stroke, for labelling
regions — painting training scribbles for a pixel classifier, marking a defect.
Strokes are polylines of IMAGE-pixel points; one widget carries several label
classes at once (`class_id` tags each stroke, `colors[class_id]` draws it), plus
`radius` (the band is 2*radius image px wide), `alpha`, and an `erase` mode.

ARMING — Shift, not a bare drag. `_attachEvents2d`'s mousedown gives an
`_ovHitTest2d` hit ABSOLUTE priority over panning and click-to-select, so a
brush whose body hit-tested as "anywhere in the image" would kill both outright.
`_ovHitTest2d` therefore takes a `mods` argument and runs a first pass that
claims the drag for an armed brush (`active !== false`) only when Shift is held.
Callers that pass no modifiers can never get a brush hit, so a bare drag pans
exactly as before and no existing gesture changes. The first pass is deliberately
BEFORE the z-order loop rather than a branch inside it: painting is modal, and a
scribble across the image must not be stolen half way by whatever widget happens
to sit under the cursor. Mirrors `_ovHitTest1d`'s existing point-over-band pass.
Holding Shift over an armed brush previews the paint cursor.

EMIT ON RELEASE — one event per stroke, measured. Widget drags here are
completely unthrottled: every document mousemove ends `_doDrag2d` with a full
`_viewStateJson` serialise + `save_changes()`, and the caller then spreads the
whole widget dict into an `event_json`. A stroke growing by a point per tick
would re-serialise, re-transmit and re-diff the ENTIRE stroke list on every tick
— O(n^2) over one stroke, which makes painting unusable. So the brush hit carries
`silent:true` (suppressing the per-move emit) and the drag branch returns before
that tail; the existing mouseup branch does the one push + one emit. Same shape
as the inset drag (`_doInsetDrag` mutates live, `_endInsetDrag` emits once).

The in-progress stroke lives in a per-panel scratch (`p._brushLive`), NOT in
`p.state.overlay_widgets`. This is not tidiness: because the stroke deliberately
does not write the panel trait, that trait stays STALE for the stroke's whole
duration, so any unrelated `save_changes()` re-fires `change:panel_<id>_json` and
replaces `p.state` wholesale from the stale value — silently wiping a stroke held
there. A `pointer_leave` emit as the cursor crosses the panel edge is enough, and
it bit during development. `drawOverlay2d` prefers the scratch (so the live
stroke survives that echo's redraw) and `_brushCommit` resolves the widget by ID
on mouseup. Points outside the image are dropped and re-entry opens a new stroke
rather than bridging the gap; erase keeps each surviving run as its own stroke
instead of rejoining across the erased gap.

The draw path publishes canvas geometry to `window._aplWidgetGeom` like the
rectangle branch, plus the image->canvas transform (origin + scale) — a brush has
no handle to aim at and with zero strokes nothing is drawn, so a test needs the
transform to place a stroke at a known image coordinate.

49 tests. Verified non-vacuous by neutralising each of the eleven new JS
decisions in turn: the hit-test first pass (13 fail), passing modifiers from
mousedown (13), the mouseup commit (13), the drawOverlay2d branch (18), the drag
early-return (5), the silent emit gate (3), the class snapshot (2), the per-class
colour lookup (3), erase (2), the out-of-image guard (1), and drawing from the
scratch (1). The bare-drag-still-pans and one-event-per-stroke tests are the two
the design exists for.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.10145% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.43%. Comparing base (1ea6bac) to head (15d091e).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
anyplotlib/widgets/_widgets2d.py 96.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #47      +/-   ##
==========================================
+ Coverage   90.32%   90.43%   +0.10%     
==========================================
  Files          39       39              
  Lines        4185     4254      +69     
==========================================
+ Hits         3780     3847      +67     
- Misses        405      407       +2     

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@CSSFrancis
CSSFrancis merged commit f3acaaf into main Jul 30, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants