feat(widgets): BrushWidget — freehand multi-class label strokes on Plot2D - #47
Merged
Conversation
…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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.clear_strokes()/add_stroke(points, class_id=None)/set_strokes(...)/strokes_for_class(id)/n_strokes, andplot.add_widget("brush", ...).radius=Nonedefaults 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.
_ovHitTest2dgives 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 optionalmodsargument plus a first pass that runs before the z-order loop and claims the drag only whenmods.shiftis 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
mousemovewrites the panel trait and spreads the whole widget dict intoevent_json, two full serialisations plus twosave_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, onpointer_up. Measured: 1 event per stroke, 0pointer_move. The precedent isinset_geometry_change, which fires once on release for the same reason.Consequence for callers: register on
pointer_up, notpointer_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 unrelatedsave_changes()re-fireschange:panel_<id>_jsonand replacesp.statewholesale — apointer_leaveas 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),drawOverlay2dprefers 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 thanw.typefor the same reason:wcan be swapped out underneath it.Tests
anyplotlib/tests/test_interactive/test_widget_brush.py— 49 tests, followingtest_widget_max_extent.py's shape (Python-API classes plus Playwright classes overinteract_page, geometry read fromwindow._aplWidgetGeomrather than derived from padding).anyplotlib/tests/test_interactive— 551 passedanyplotlib/tests— 1343 passed, 58 skippedmainand on this branch, so nothing was shadowed or lostNon-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
drawOverlay2dbranch (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_downcovers it now.Open questions for the reviewer
radiusmeans radius, so the painted band is2 * radiuswide. Erase removes points withinradius, and a downstream rasteriser will label pixels withinradiusof the polyline — a band of widthradiuswould 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.strokesis[[[x, y], ...], ...]with a parallelstroke_classes, rather than[{"points": ..., "class_id": ...}, ...]. Keeps the simple case simple, at the cost of a desync risk if a host assignsbrush.strokesdirectly.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.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_widgetdeliberately never writespanel_<id>_json, so a targeted widget update —brush.class_id = 1,rect.x = ...— is reverted by the nextsave_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_strokehas 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.