diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index e1ace56d..e3f8b90b 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -1,6 +1,6 @@ # FIGURE_ESM.md — Navigator for `figure_esm.js` -`figure_esm.js` is **~9,000 lines** and one big closure. Everything lives inside +`figure_esm.js` is **~9,200 lines** and one big closure. Everything lives inside `function render({ model, el })` so that all helpers share the same scope (`theme`, `PAD_*`, `panels` Map, etc.). This document is a section map so you can jump straight to the relevant code without reading the whole file. @@ -62,16 +62,16 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | `draw2d` | 2680 | | `drawScaleBar2d` / `drawColorbar2d` | 2875 / 2961 | | `_drawAxes2d` (ticks, labels, title) | 3016 | -| `drawOverlay2d` / `drawMarkers2d` | 3169 / 3287 | +| `drawOverlay2d` / `drawMarkers2d` | 3169 / 3333 | | **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2500 / 2524 / 2585 | | Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 | -| **3D drawing**: `draw3d` | 4623 | -| Event emission `_emitEvent` | 5270 | -| 3D event handlers `_attachEvents3d` | 5322 | -| **1D drawing**: `draw1d` | 5536 | -| `_drawLine` (1D series + markers) | 5689 | -| `drawOverlay1d` / `drawMarkers1d` | 5982 / 6066 | -| Marker hit-test `_markerHitTest2d` | 6334 | +| **3D drawing**: `draw3d` | 4669 | +| Event emission `_emitEvent` | 5316 | +| 3D event handlers `_attachEvents3d` | 5368 | +| **1D drawing**: `draw1d` | 5582 | +| `_drawLine` (1D series + markers) | 5735 | +| `drawOverlay1d` / `drawMarkers1d` | 6028 / 6112 | +| Marker hit-test `_markerHitTest2d` | 6380 | > **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'` > branch that blits a single RGBA image across data-coord `extent` (the fast @@ -80,15 +80,31 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: > redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on > the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block > clips it to a curved sector. -| Panel event dispatch `_attachPanelEvents` | 6591 | -| 2D events `_attachEvents2d` | 6615 | -| 1D events `_attachEvents1d` | 6962 | -| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 7229 / 7363 | -| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 7473 / 7546 | -| Shared-axis propagation `_getShareGroups` | 7617 | -| Figure resize `_applyFigResizeDOM` | 7681 | -| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 7872 / 7935 / 8311 | -| Generic redraw `_redrawPanel` | 8501 | +| Panel event dispatch `_attachPanelEvents` | 6637 | +| 2D events `_attachEvents2d` | 6661 | +| 1D events `_attachEvents1d` | 7036 | +| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 7308 / 7568 | +| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 7489 / 7503 / 7532 / 7559 | +| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 7691 / 7764 | +| Shared-axis propagation `_getShareGroups` | 7835 | +| Figure resize `_applyFigResizeDOM` | 7899 | +| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8090 / 8153 / 8529 | +| Generic redraw `_redrawPanel` | 8719 | + +> **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one +> that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods` +> argument and runs a FIRST PASS that claims the drag for an armed brush +> (`active !== false`) only when `mods.shift` is set — so a bare drag still pans +> and still drags other widgets, and a Shift-drag beats every widget regardless +> of z-order. The hit carries `silent:true`, which suppresses the per-move +> `pointer_move` emit, and `_doDrag2d` returns before its +> `_viewStateJson`/`save_changes()` tail. The in-progress stroke lives in +> `p._brushLive` (a per-panel scratch), NOT in `p.state.overlay_widgets`: the +> panel trait is deliberately stale for the whole stroke, so any unrelated +> `save_changes()` re-fires `change:panel__json` and would replace `p.state` +> — wiping a stroke held there (a `pointer_leave` at the panel edge is enough). +> `drawOverlay2d` prefers the scratch; the mouseup handler calls `_brushCommit` +> and the existing generic branch pushes + emits exactly ONCE per stroke. --- diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index baf4f545..fed3c613 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -17,7 +17,7 @@ from anyplotlib.markers import MarkerRegistry, MarkerGroup from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, - CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, + CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, BrushWidget, VLineWidget, HLineWidget, RangeWidget, PointWidget, PlaneWidget, ) @@ -46,6 +46,7 @@ def get_color_cycle() -> list[str]: "MarkerRegistry", "MarkerGroup", "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget", + "BrushWidget", "VLineWidget", "HLineWidget", "RangeWidget", "PointWidget", "PlaneWidget", "show_help", "get_color_cycle", "embed", diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index dc44d101..e8fd0b73 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3255,6 +3255,52 @@ function render({ model, el, onResize }) { ovCtx.closePath();ovCtx.stroke(); if(_handles) for(const v of verts){const[px,py]=_imgToCanvas2d(v[0],v[1],st,imgW,imgH);_drawHandle2d(ovCtx,px,py,w.color);} } + } else if(w.type==='brush'){ + // Freehand painted strokes (region labelling). Each stroke is a polyline + // of IMAGE-pixel points; `radius` is the brush RADIUS, so the band is + // 2*radius image px wide — the same disk the erase hit-test uses and a + // downstream rasteriser must use, so what is drawn is what is labelled. + // Round cap+join is what makes a polyline read as a brush stroke rather + // than a chain of mitred segments. + // A stroke IN PROGRESS lives in the panel scratch, not in the widget + // dict (see _brushLiveBegin) — prefer it, so the live stroke keeps + // drawing even through the redraw an unrelated model echo triggers. + const _bL=(p._brushLive&&p._brushLive.wid===w.id)?p._brushLive:null; + const _bs=(_bL?_bL.strokes:w.strokes)||[]; + const _bc=(_bL?_bL.classes:w.stroke_classes)||[]; + const _bcols=w.colors||[]; + const _brad=(w.radius!=null?w.radius:8); + const _blw=Math.max(1,_brad*2*scale); + ovCtx.lineCap='round'; ovCtx.lineJoin='round'; ovCtx.lineWidth=_blw; + ovCtx.globalAlpha=(w.alpha!=null?w.alpha:0.6); + const _bgeom=[]; + for(let s=0;s<_bs.length;s++){ + const pts=_bs[s]; if(!pts||!pts.length) continue; + const _col=_bcols[_bc[s]|0]||w.color||'#00e5ff'; + ovCtx.strokeStyle=_col; ovCtx.fillStyle=_col; + const cpts=[]; + for(let k=0;kcanvas TRANSFORM too + // (origin of image px (0,0) plus canvas-px-per-image-px), which is what + // a test actually needs to start a stroke at a known image coordinate. + if(!window._aplWidgetGeom) window._aplWidgetGeom={}; + if(!window._aplWidgetGeom[p.id]) window._aplWidgetGeom[p.id]={}; + const _borg=_imgToCanvas2d(0,0,st,imgW,imgH); + window._aplWidgetGeom[p.id][w.id]={type:'brush',ox:_borg[0],oy:_borg[1], + scale,radius_px:_blw/2,n_strokes:_bgeom.length,strokes:_bgeom}; } else if(w.type==='label'){ const [lx,ly]=_imgToCanvas2d(w.x,w.y,st,imgW,imgH); ovCtx.font=`${w.fontsize||14}px sans-serif`;ovCtx.fillStyle=w.color||'#00e5ff'; @@ -6662,10 +6708,25 @@ fn fs(in : VsOut) -> @location(0) vec4 { overlayCanvas.focus(); const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); - const hit=_ovHitTest2d(mx, my, p); + // Modifiers go in so an ARMED brush can claim a Shift-drag (and only a + // Shift-drag) before pan/click ever see it — see _ovHitTest2d's first pass. + const hit=_ovHitTest2d(mx, my, p, {shift:e.shiftKey}); if(hit){ p.ovDrag2d=hit; p.lastWidgetId=(st.overlay_widgets||[])[hit.idx]?.id||null; + if(hit.mode==='paint'||hit.mode==='erase'){ + // Open the stroke HERE, not on the first move: a Shift-click with no + // motion must still paint (or erase) a single dot. + const _bw=(st.overlay_widgets||[])[hit.idx]; + if(_bw){ + const _L=_brushLiveBegin(p,_bw); + const [_bix,_biy]=_canvasToImg2d(mx,my,st,imgW,imgH); + _brushPaintAt(_L,st,_bix,_biy,hit); + } + drawOverlay2d(p); + overlayCanvas.style.cursor='crosshair'; + e.preventDefault(); return; + } overlayCanvas.style.cursor='move'; e.preventDefault(); return; } @@ -6682,8 +6743,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { document.addEventListener('mousemove',(e)=>{ if(p.ovDrag2d){ _doDrag2d(e,p); - const _dw=(p.state.overlay_widgets||[])[p.ovDrag2d.idx]||{}; - _emitEvent(p.id,'pointer_move',_dw.id||null,{..._dw,..._pointerFields(e)}); + // A `silent` drag (brush stroke) emits nothing until release. Spreading + // a growing stroke list into an event_json every tick is exactly the + // O(n²) the local-accumulate design exists to avoid; the mouseup handler + // below ships the finished stroke once. + if(!p.ovDrag2d.silent){ + const _dw=(p.state.overlay_widgets||[])[p.ovDrag2d.idx]||{}; + _emitEvent(p.id,'pointer_move',_dw.id||null,{..._dw,..._pointerFields(e)}); + } return; } if(!p.isPanning) return; @@ -6705,6 +6772,10 @@ fn fs(in : VsOut) -> @location(0) vec4 { document.addEventListener('mouseup',(e)=>{ settled.clear(); if(p.ovDrag2d){ + // A brush stroke lived in the panel scratch for the whole drag (see + // _brushLiveBegin) — fold it into p.state FIRST, so the generic push + + // emit below is what ships the finished stroke to Python, exactly once. + if(p._brushLive) _brushCommit(p); const _idx=p.ovDrag2d.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; const _did=_dw.id||null; @@ -6763,11 +6834,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { if(p.ovDrag2d) return; // handled by document mousemove const st=p.state; if(!st) return; - // Update cursor based on widget hit - const whit=_ovHitTest2d(mx, my, p); + // Update cursor based on widget hit. Modifiers go in so holding Shift over + // an armed brush previews the paint cursor (and so the brush does not + // claim the cursor when it is NOT armed by the modifier). + const whit=_ovHitTest2d(mx, my, p, {shift:e.shiftKey}); if(whit){ const m=whit.mode; overlayCanvas.style.cursor = m==='move' ? 'move' + : (m==='paint'||m==='erase') ? 'crosshair' : (m==='resize_br'||m==='resize_tl') ? 'nwse-resize' : (m==='resize_bl'||m==='resize_tr') ? 'nesw-resize' : (m==='resize_r'||m==='resize_ir') ? 'ew-resize' @@ -7226,7 +7300,12 @@ fn fs(in : VsOut) -> @location(0) vec4 { // 'resize_tr' – rectangle top-right corner // 'resize_tl' – rectangle top-left corner // 'vertex_N' – polygon vertex N - function _ovHitTest2d(mx, my, p) { + // 'paint' – armed brush, Shift+drag lays down a stroke + // 'erase' – armed brush in erase mode + // + // `mods` (optional) carries the mousedown/hover modifier state, currently + // only `{shift}`. Callers that pass nothing can never get a brush hit. + function _ovHitTest2d(mx, my, p, mods) { const st = p.state; if (!st) return null; const imgW = p.imgW||Math.max(1, p.pw - PAD_L - PAD_R); const imgH = p.imgH||Math.max(1, p.ph - PAD_T - PAD_B); @@ -7234,6 +7313,27 @@ fn fs(in : VsOut) -> @location(0) vec4 { const scale = _imgScale2d(st, imgW, imgH); const HR = 9; // handle grab radius (px) + // ── First pass: an ARMED brush under a Shift-drag wins outright ────────── + // A brush's body is "anywhere in the image", so it must NOT be a hit in the + // ordinary sense — that would kill panning and click-to-select. Instead it + // takes the drag only when the caller reports Shift held, and then it takes + // it absolutely: painting is modal, and a scribble across the image must not + // be stolen half way by whatever widget happens to sit under the cursor + // (which is what a z-order-dependent hit in the main loop below would do). + // Nothing claims a brush hit without the modifier, so a BARE drag still pans + // and still drags every other widget. Mirrors the "first pass" precedent in + // _ovHitTest1d, where point widgets outrank the range band they sit in. + // `silent` tells the drag loop this gesture emits nothing until release. + if (mods && mods.shift) { + for (let i = widgets.length - 1; i >= 0; i--) { + const w = widgets[i]; + if (w.type !== 'brush') continue; + if (w.visible === false || w.active === false) continue; + return { idx:i, mode: w.erase ? 'erase' : 'paint', silent:true, _gap:true, + snapW:{...w}, startMX:mx, startMY:my }; + } + } + // iterate top-to-bottom (last drawn = topmost) for (let i = widgets.length - 1; i >= 0; i--) { const w = widgets[i]; @@ -7360,6 +7460,111 @@ fn fs(in : VsOut) -> @location(0) vec4 { return inside; } + // ── brush stroke accumulation ───────────────────────────────────────────── + // The stroke being drawn lives in a per-panel SCRATCH (`p._brushLive`), not in + // `p.state.overlay_widgets`, and reaches the model only on mouseup. Both halves + // of that are load-bearing: + // + // * PERF — widget drags here are 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²) over one stroke, + // which makes painting unusable. + // * CORRECTNESS — precisely because we do NOT write the panel trait during the + // stroke, the trait stays STALE for its whole duration. Any unrelated + // `save_changes()` (the `pointer_leave` emit as the cursor crosses the panel + // edge — routine when painting near an edge — a key event, a Python-side + // push) re-fires `change:panel__json`, which replaces `p.state` + // wholesale from that stale value. A stroke accumulated in `p.state` is + // silently wiped by it mid-drag. The scratch survives, and `drawOverlay2d` + // prefers it, so the stroke also survives the redraw that echo triggers. + // + // Same shape as the inset drag: `_doInsetDrag` mutates live, `_endInsetDrag` + // emits once (see `inset_geometry_change` in figure/_figure.py). + + // Snapshot the widget's committed strokes into the scratch. Each stroke array + // is copied so appends never touch p.state's own arrays (the point pairs are + // shared but only ever read). + function _brushLiveBegin(p, w) { + p._brushLive = { + wid: w.id, + radius: (w.radius != null ? w.radius : 8), + class_id: w.class_id | 0, + strokes: (w.strokes || []).map(s => s.slice()), + classes: (w.stroke_classes || []).slice(), + }; + return p._brushLive; + } + + // Fold the scratch back into p.state. Resolves the widget by ID, not by the + // drag's index, because an echo may have replaced p.state (and its widget + // objects) at any point during the stroke. + function _brushCommit(p) { + const L = p._brushLive; p._brushLive = null; + if (!L || !p.state) return; + const w = (p.state.overlay_widgets || []).find(x => x && x.id === L.wid); + if (w) { w.strokes = L.strokes; w.stroke_classes = L.classes; } + } + + function _brushBegin(L, ix, iy) { + L.strokes.push([[ix, iy]]); + L.classes.push(L.class_id); + } + + function _brushAppend(L, ix, iy) { + if (!L.strokes.length) { _brushBegin(L, ix, iy); return; } + const cur = L.strokes[L.strokes.length - 1]; + const last = cur[cur.length - 1]; + // Resample. A mousemove can fire many times per image pixel when zoomed in, + // and every point ends up on the wire on release — so drop points closer + // than a quarter of the brush radius. The round joins hide the coarser + // polyline; nothing visible is lost. + const step = Math.max(0.5, L.radius * 0.25); + if (last && Math.hypot(ix - last[0], iy - last[1]) < step) return; + cur.push([ix, iy]); + } + + // Erase = drop every stroke point within `radius` image px of (ix,iy), then + // keep each surviving RUN of consecutive points as its own stroke. Filtering + // a stroke in place instead would rejoin its two ends with one long straight + // segment straight across the gap the user just erased. + function _brushErase(L, ix, iy) { + const r2 = L.radius * L.radius; + const src = L.strokes, cls = L.classes; + const outS = [], outC = []; + for (let s = 0; s < src.length; s++) { + const pts = src[s] || [], ci = cls[s] | 0; + let run = []; + for (let k = 0; k < pts.length; k++) { + const dx = pts[k][0] - ix, dy = pts[k][1] - iy; + if (dx*dx + dy*dy <= r2) { + if (run.length) { outS.push(run); outC.push(ci); } + run = []; + } else run.push(pts[k]); + } + if (run.length) { outS.push(run); outC.push(ci); } + } + L.strokes = outS; L.classes = outC; + } + + // Extend (or start) the brush's stroke at image point (ix,iy). Shared by + // mousedown (so a Shift-CLICK with no motion still paints one dot) and the + // drag loop. Points OUTSIDE the image are dropped and re-entry starts a NEW + // stroke: a label coordinate outside the array is useless to a consumer (and + // a negative index silently wraps), while bridging the gap would draw a + // straight segment across ground the user never painted. `d._gap` is the + // per-drag "next in-bounds point starts a stroke" flag — the hit-test seeds + // it true, which is what makes mousedown open a fresh stroke. + function _brushPaintAt(L, st, ix, iy, d) { + if (!(ix >= 0 && ix < st.image_width && iy >= 0 && iy < st.image_height)) { + d._gap = true; return; + } + if (d.mode === 'erase') { _brushErase(L, ix, iy); return; } + if (d._gap) { _brushBegin(L, ix, iy); d._gap = false; return; } + _brushAppend(L, ix, iy); + } + function _doDrag2d(e, p) { const st = p.state; if (!st) return; const imgW = p.imgW||Math.max(1, p.pw - PAD_L - PAD_R); @@ -7376,6 +7581,19 @@ fn fs(in : VsOut) -> @location(0) vec4 { const [imgSX, imgSY] = _canvasToImg2d(d.startMX, d.startMY, st, imgW, imgH); const dix = imgMX - imgSX, diy = imgMY - imgSY; + // ── Brush: paint/erase into the panel scratch, publish NOTHING ─────────── + // Redraws the overlay locally and returns BEFORE the `_viewStateJson` + + // `save_changes()` tail below; the mouseup handler commits the scratch and + // ships the finished stroke once. See `_brushLiveBegin` for why. + // Gated on the drag MODE rather than the widget type: an echo can have + // swapped `p.state` (and therefore `w`) out from under us mid-stroke. + if (d.mode === 'paint' || d.mode === 'erase') { + if (p._brushLive) _brushPaintAt(p._brushLive, st, imgMX, imgMY, d); + drawOverlay2d(p); + e.preventDefault(); + return; + } + if (w.type === 'circle') { if (d.mode === 'move') { w.cx = s.cx + dix; w.cy = s.cy + diy; diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index b82e4c8e..8e9175a9 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -27,7 +27,7 @@ Widget, RectangleWidget, CircleWidget, AnnularWidget, CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, LineWidget, - VLineWidget, HLineWidget, + BrushWidget, VLineWidget, HLineWidget, ) from anyplotlib._utils import (_normalize_image, _build_colormap_lut, _build_tint_lut, _to_rgba_u8) @@ -1643,12 +1643,13 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: Dispatches to the dedicated ``add__widget`` method. Supported kinds: ``"circle"``, ``"rectangle"``, ``"annular"``, ``"polygon"``, ``"crosshair"``, ``"label"``, ``"arrow"``, ``"line"``, - ``"vline"``, ``"hline"``. + ``"brush"``, ``"vline"``, ``"hline"``. Every kind also accepts ``show_handles`` (default ``True``) to toggle the grab-handle dots without changing hit-testing / draggability. ``vline`` / ``hline`` have no handles — the whole line is the grab - target — so they ignore it. + target — so they ignore it, and neither does ``brush`` (a freehand + stroke has no grab point). """ dispatch = { "circle": self.add_circle_widget, @@ -1659,6 +1660,7 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: "label": self.add_label_widget, "arrow": self.add_arrow_widget, "line": self.add_line_widget, + "brush": self.add_brush_widget, "vline": self.add_vline_widget, "hline": self.add_hline_widget, } @@ -1757,6 +1759,69 @@ def add_polygon_widget(self, vertices=None, color: str = "#00e5ff", self._push() return widget + def add_brush_widget(self, radius: float | None = None, + color: str = "#00e5ff", colors=None, + class_id: int = 0, strokes=None, stroke_classes=None, + alpha: float = 0.6, active: bool = True, + erase: bool = False) -> BrushWidget: + """Add a freehand paint-brush overlay for labelling regions. + + **Shift** + drag paints a stroke; a *bare* drag still pans the image and + still drags other widgets. One brush can hold several label classes at + once — a stroke is tagged with the widget's current ``class_id`` and + drawn in ``colors[class_id]``. + + The stroke accumulates in the browser and reaches Python **once**, on + release, as a ``pointer_up`` event — ``pointer_move`` does not fire for + a brush. See :class:`~anyplotlib.BrushWidget`. + + Parameters + ---------- + radius : float, optional + Brush radius in image pixels; the painted band is ``2 * radius`` + wide. Defaults to 2 % of the smaller image dimension (at least 2), + so the default is visible on a 4096² image and usable on a 64² one. + color : str, optional + CSS colour for classes not covered by ``colors``. Default + ``"#00e5ff"``. + colors : list of str, optional + Per-class CSS colours, indexed by ``class_id``. + class_id : int, optional + Label class new strokes are tagged with. Default 0. + strokes : list, optional + Pre-existing strokes ``[[[x, y], ...], ...]`` in image pixels. + stroke_classes : list of int, optional + Class id per entry of ``strokes``; must be the same length. + alpha : float, optional + Stroke opacity. Default 0.6, so the labelled feature stays visible + underneath. + active : bool, optional + Accept Shift-drag painting. Default ``True``; ``False`` parks the + tool with its strokes still drawn. + erase : bool, optional + Armed drags remove stroke points within ``radius`` instead of + painting. Default ``False``. + + Returns + ------- + BrushWidget + Widget object. ``widget.strokes`` / ``widget.stroke_classes`` hold + the painted geometry; :meth:`~anyplotlib.BrushWidget.clear_strokes` + and :meth:`~anyplotlib.BrushWidget.strokes_for_class` manage it. + """ + iw, ih = self._state["image_width"], self._state["image_height"] + if radius is None: + radius = max(2.0, min(iw, ih) * 0.02) + widget = BrushWidget(lambda: None, + radius=radius, color=color, colors=colors, + class_id=class_id, strokes=strokes, + stroke_classes=stroke_classes, alpha=alpha, + active=active, erase=erase) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget + def add_crosshair_widget(self, cx: float | None = None, cy: float | None = None, color: str = "#00e5ff", linewidth: float = 2, show_handles: bool = True) -> CrosshairWidget: diff --git a/anyplotlib/tests/test_interactive/test_widget_brush.py b/anyplotlib/tests/test_interactive/test_widget_brush.py new file mode 100644 index 00000000..550b5236 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_widget_brush.py @@ -0,0 +1,777 @@ +""" +tests/test_interactive/test_widget_brush.py +============================================ + +``BrushWidget`` — freehand painted strokes on a 2-D image, for labelling +regions (painting training scribbles for a pixel classifier). + +Two things about this widget are load-bearing and neither is visible from +Python, so most of the coverage here drives a real browser: + +**Arming.** A brush's body is "anywhere in the image". The 2-D mousedown handler +gives an ``_ovHitTest2d`` hit ABSOLUTE priority over panning and over +click-to-select, so a brush that hit-tested as its body would kill both outright. +Painting is therefore gated twice: the widget must be ``active``, and the drag +must hold **Shift**. A bare drag still pans — ``test_bare_drag_pans_and_does_not_paint`` +is the single most important test in this file. + +**Emit on release only.** Widget drags in ``figure_esm.js`` are unthrottled: +every document mousemove ends ``_doDrag2d`` with a full ``_viewStateJson`` +serialise + ``save_changes()``, and the caller spreads the whole widget dict into +an ``event_json``. A stroke that grew by a point per tick would re-serialise and +re-diff the entire stroke list on EVERY tick — O(n²) over one stroke. So the +stroke accumulates in JS, redraws locally, and reaches Python exactly once, on +mouseup. ``TestEmitOncePerStroke`` counts the events that contract promises. + +Coordinates: all 2-D widgets in this library speak IMAGE PIXELS. The page +position of an image pixel depends on the panel's live zoom/centre, so the tests +derive it from the transform the draw path publishes to +``window._aplWidgetGeom`` — never from figure padding. +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.widgets import BrushWidget +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, _get_events, +) + +FIG_W, FIG_H = 400, 300 +IMG = 64 # image is IMG x IMG px in every browser test + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. Python API +# ═══════════════════════════════════════════════════════════════════════════ + +class TestBrushAttributes: + def test_defaults(self): + w = BrushWidget(lambda: None) + assert w._type == "brush" + assert w.radius == 8.0 + assert w.class_id == 0 + assert w.active is True + assert w.erase is False + assert w.strokes == [] and w.stroke_classes == [] and w.colors == [] + + def test_state_dict_carries_everything_js_reads(self): + """JS reads the widget dict, so every painted field has to survive + to_dict() — that is the whole wire format.""" + w = BrushWidget(lambda: None, radius=3, colors=["#f00", "#0f0"], + class_id=1, alpha=0.4, active=False, erase=True, + strokes=[[(1, 2), (3, 4)]], stroke_classes=[1]) + d = w.to_dict() + assert d["type"] == "brush" + assert d["radius"] == 3.0 + assert d["colors"] == ["#f00", "#0f0"] + assert d["class_id"] == 1 + assert d["alpha"] == 0.4 + assert d["active"] is False and d["erase"] is True + assert d["strokes"] == [[[1.0, 2.0], [3.0, 4.0]]] + assert d["stroke_classes"] == [1] + + def test_points_are_coerced_to_plain_floats(self): + """The dict is JSON-serialised onto a traitlet, so numpy must not + survive into it.""" + w = BrushWidget(lambda: None, + strokes=[np.array([[1, 2], [3, 4]], dtype=np.int16)]) + pts = w.strokes[0] + assert pts == [[1.0, 2.0], [3.0, 4.0]] + assert all(type(c) is float for pt in pts for c in pt) + + def test_stroke_classes_default_to_the_active_class(self): + w = BrushWidget(lambda: None, class_id=2, + strokes=[[(0, 0)], [(1, 1)]]) + assert w.stroke_classes == [2, 2] + + +class TestBrushValidation: + def test_zero_radius_raises(self): + with pytest.raises(ValueError, match="radius"): + BrushWidget(lambda: None, radius=0) + + def test_negative_radius_raises(self): + with pytest.raises(ValueError, match="radius"): + BrushWidget(lambda: None, radius=-4) + + def test_negative_class_id_raises(self): + with pytest.raises(ValueError, match="class_id"): + BrushWidget(lambda: None, class_id=-1) + + def test_alpha_out_of_range_raises(self): + with pytest.raises(ValueError, match="alpha"): + BrushWidget(lambda: None, alpha=1.5) + + def test_a_single_colour_string_raises(self): + """colors= is the per-CLASS list; a bare string is almost certainly a + confusion with color=, and iterating it would give one colour per + character.""" + with pytest.raises(ValueError, match="colors"): + BrushWidget(lambda: None, colors="#ff0000") + + def test_a_point_that_is_not_a_pair_raises(self): + with pytest.raises(ValueError, match=r"\(x, y\)"): + BrushWidget(lambda: None, strokes=[[(1, 2, 3)]]) + + def test_an_empty_stroke_raises(self): + with pytest.raises(ValueError, match="empty"): + BrushWidget(lambda: None, strokes=[[]]) + + def test_class_length_mismatch_raises(self): + with pytest.raises(ValueError, match="stroke_classes"): + BrushWidget(lambda: None, strokes=[[(0, 0)]], stroke_classes=[0, 1]) + + def test_negative_stroke_class_raises(self): + with pytest.raises(ValueError, match="stroke_classes"): + BrushWidget(lambda: None, strokes=[[(0, 0)]], stroke_classes=[-2]) + + +class TestStrokeManagement: + def test_add_stroke_uses_the_active_class(self): + w = BrushWidget(lambda: None, class_id=3) + w.add_stroke([(0, 0), (1, 1)]) + assert w.n_strokes == 1 and w.stroke_classes == [3] + + def test_add_stroke_explicit_class_overrides(self): + w = BrushWidget(lambda: None, class_id=3) + w.add_stroke([(0, 0)], class_id=1) + assert w.stroke_classes == [1] + + def test_add_stroke_rejects_an_empty_stroke(self): + w = BrushWidget(lambda: None) + with pytest.raises(ValueError, match="empty"): + w.add_stroke([]) + + def test_clear_strokes_empties_both_lists(self): + w = BrushWidget(lambda: None, strokes=[[(0, 0)], [(1, 1)]]) + w.clear_strokes() + assert w.strokes == [] and w.stroke_classes == [] + + def test_clear_strokes_keeps_the_active_class(self): + w = BrushWidget(lambda: None, class_id=2, strokes=[[(0, 0)]]) + w.clear_strokes() + assert w.class_id == 2 + + def test_set_strokes_replaces_and_keeps_classes_in_lockstep(self): + w = BrushWidget(lambda: None, strokes=[[(9, 9)]]) + w.set_strokes([[(0, 0)], [(1, 1)]], [4, 5]) + assert w.strokes == [[[0.0, 0.0]], [[1.0, 1.0]]] + assert w.stroke_classes == [4, 5] + + def test_set_strokes_rejects_a_class_length_mismatch(self): + w = BrushWidget(lambda: None) + with pytest.raises(ValueError, match="classes"): + w.set_strokes([[(0, 0)]], [0, 1]) + + def test_strokes_for_class_filters(self): + w = BrushWidget(lambda: None, + strokes=[[(0, 0)], [(1, 1)], [(2, 2)]], + stroke_classes=[0, 1, 0]) + assert w.strokes_for_class(0) == [[[0.0, 0.0]], [[2.0, 2.0]]] + assert w.strokes_for_class(1) == [[[1.0, 1.0]]] + assert w.strokes_for_class(7) == [] + + def test_pushes_reach_the_plot(self): + """A mutator that forgets _push() leaves the renderer showing stale + strokes, which is invisible from Python.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + w = plot.add_brush_widget() + w.add_stroke([(1, 1), (2, 2)]) + plot._push() # refresh overlay_widgets from _widgets + assert plot._state["overlay_widgets"][0]["strokes"] == \ + [[[1.0, 1.0], [2.0, 2.0]]] + + +class TestBrushFactory: + def test_add_brush_widget_registers_the_widget(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + w = plot.add_brush_widget() + assert isinstance(w, BrushWidget) + assert plot._widgets[w.id] is w + assert [x["type"] for x in plot._state["overlay_widgets"]] == ["brush"] + + def test_default_radius_scales_with_the_image(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((512, 512), dtype=np.float32)) + assert plot.add_brush_widget().radius == pytest.approx(512 * 0.02) + + def test_default_radius_has_a_floor_on_a_tiny_image(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((16, 16), dtype=np.float32)) + assert plot.add_brush_widget().radius == 2.0 + + def test_explicit_radius_wins(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((512, 512), dtype=np.float32)) + assert plot.add_brush_widget(radius=1.5).radius == 1.5 + + def test_add_widget_dispatches_brush(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + w = plot.add_widget("brush", radius=4, colors=["#f00"]) + assert isinstance(w, BrushWidget) and w.radius == 4.0 + + def test_seeded_strokes_reach_the_state(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.add_brush_widget(strokes=[[(1, 1), (5, 5)]], stroke_classes=[1]) + st = plot._state["overlay_widgets"][0] + assert st["strokes"] == [[[1.0, 1.0], [5.0, 5.0]]] + assert st["stroke_classes"] == [1] + + +class TestJsToPython: + """The JS ships the finished stroke as ONE pointer_up. Python has to absorb + it and fire the handler that hosts actually register on.""" + + def test_pointer_up_converges_the_strokes(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + brush = plot.add_brush_widget() + seen = [] + brush.add_event_handler(lambda ev: seen.append(ev), "pointer_up") + + # Exactly the payload figure_esm.js emits on mouseup: the whole widget + # dict plus the pointer envelope. + payload = dict(brush.to_dict()) + payload.update(strokes=[[[1.0, 2.0], [3.0, 4.0]]], stroke_classes=[0]) + payload.update(source="js", panel_id=plot._id, event_type="pointer_up", + widget_id=brush.id, time_stamp=1.0, modifiers=["shift"], + button=0, buttons=0) + fig._on_event({"new": json.dumps(payload)}) + + assert brush.strokes == [[[1.0, 2.0], [3.0, 4.0]]] + assert brush.n_strokes == 1 + assert len(seen) == 1 and seen[0].event_type == "pointer_up" + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. Real browser drags — the arming and emit-once logic lives entirely in +# figure_esm.js, so a Python-only test proves nothing about painting. +# ═══════════════════════════════════════════════════════════════════════════ + +def _setup(interact_page, **brush_kwargs): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.arange(IMG * IMG, dtype=np.float32).reshape(IMG, IMG)) + brush = plot.add_brush_widget(radius=2.0, **brush_kwargs) + page = interact_page(fig) + _collect_events(page) + return fig, plot, brush, page + + +def _brush_geom(page, plot_id, widget_id): + """The canvas-space geometry the draw path publishes for this brush. + + ``ox``/``oy``/``scale`` are the live image->canvas transform. A test cannot + derive a 2-D widget's page position from figure padding — the mapping depends + on the panel's zoom/centre state — and unlike a rectangle a brush has no + handle (and with zero strokes, nothing drawn at all) to aim at. + """ + return page.evaluate( + """([pid, wid]) => { + const g = window._aplWidgetGeom && window._aplWidgetGeom[pid]; + return g ? (g[wid] || null) : null; + }""", [str(plot_id), str(widget_id)]) + + +def _overlay_rect(page): + """Bounding rect of the OVERLAY canvas — the element ``_clientPos`` + measures mouse positions against (inline ``z-index:5``).""" + return page.evaluate( + """() => { + const c = [...document.querySelectorAll('canvas')] + .find(x => x.style.zIndex === '5'); + const r = c.getBoundingClientRect(); + return {left: r.left, top: r.top, + sfX: r.width ? c.width / r.width : 1, + sfY: r.height ? c.height / r.height : 1}; + }""") + + +def _img_to_page(geom, rect, ix, iy): + """Image pixel -> page coordinate, via the published transform.""" + return (rect["left"] + (geom["ox"] + ix * geom["scale"]) / rect["sfX"], + rect["top"] + (geom["oy"] + iy * geom["scale"]) / rect["sfY"]) + + +def _paint(page, geom, rect, pts, *, shift=True, steps=6): + """Drag through a list of IMAGE-pixel points, optionally holding Shift.""" + x0, y0 = _img_to_page(geom, rect, *pts[0]) + page.mouse.move(x0, y0) + if shift: + page.keyboard.down("Shift") + page.mouse.down() + for ix, iy in pts[1:]: + px, py = _img_to_page(geom, rect, ix, iy) + page.mouse.move(px, py, steps=steps) + page.mouse.up() + if shift: + page.keyboard.up("Shift") + page.wait_for_timeout(80) + + +def _widget(page, plot_id, idx=0): + """The live widget dict out of the panel's JS state.""" + return page.evaluate( + """([pid, i]) => { + const raw = window._aplModel.get('panel_' + pid + '_json'); + const st = raw ? JSON.parse(raw) : null; + const ws = st && st.overlay_widgets; + return ws && ws[i] ? ws[i] : null; + }""", [str(plot_id), idx]) + + +def _view(page, plot_id): + """zoom / centre of the panel — how the tests detect a pan.""" + return page.evaluate( + """(pid) => { + const raw = window._aplModel.get('panel_' + pid + '_json'); + const st = raw ? JSON.parse(raw) : null; + return st ? {zoom: st.zoom, cx: st.center_x, cy: st.center_y} : null; + }""", str(plot_id)) + + +def _count_overlay_color(page, rgb, tol=48): + """Non-transparent overlay pixels within ``tol`` of an RGB triple.""" + return page.evaluate( + """([r0, g0, b0, tol]) => { + const c = [...document.querySelectorAll('canvas')] + .find(x => x.style.zIndex === '5'); + const d = c.getContext('2d') + .getImageData(0, 0, c.width, c.height).data; + let n = 0; + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 8) continue; + if (Math.abs(d[i] - r0) <= tol && + Math.abs(d[i + 1] - g0) <= tol && + Math.abs(d[i + 2] - b0) <= tol) n++; + } + return n; + }""", [rgb[0], rgb[1], rgb[2], tol]) + + +@pytest.mark.usefixtures("interact_page") +class TestArming: + """THE constraint: a brush must not swallow the panel's other gestures.""" + + def test_bare_drag_pans_and_does_not_paint(self, interact_page): + """The most important test here. `_attachEvents2d`'s mousedown gives an + `_ovHitTest2d` hit absolute priority over panning, so a brush that + claimed a plain drag would kill panning for the whole panel. + + The pan is detected via ``center_x``, which the pan handler writes on + every move regardless of zoom (at zoom 1 the blit itself is pinned, but + the state still moves).""" + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + assert geom is not None, "brush geometry readback missing" + rect = _overlay_rect(page) + before = _view(page, plot._id) + + _paint(page, geom, rect, [(20, 32), (44, 32)], shift=False) + + after_w = _widget(page, plot._id) + assert after_w["strokes"] == [], \ + "a BARE drag painted — that kills pan and click for the panel" + after = _view(page, plot._id) + assert abs(after["cx"] - before["cx"]) > 0.01, \ + "a bare drag did not pan the image" + + def test_shift_drag_paints_and_does_not_pan(self, interact_page): + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + before = _view(page, plot._id) + + _paint(page, geom, rect, [(20, 32), (44, 32)]) + + w = _widget(page, plot._id) + assert len(w["strokes"]) == 1, "Shift+drag did not paint one stroke" + assert len(w["strokes"][0]) >= 3, \ + f"stroke has too few points: {w['strokes'][0]}" + after = _view(page, plot._id) + assert after["cx"] == pytest.approx(before["cx"], abs=1e-9), \ + "painting panned the image as well" + + def test_inactive_brush_is_ignored_and_still_pans(self, interact_page): + """`active=False` parks the tool: the strokes stay drawn, the input + goes back to the panel.""" + fig, plot, brush, page = _setup(interact_page, active=False) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + before = _view(page, plot._id) + + _paint(page, geom, rect, [(20, 32), (44, 32)]) + + assert _widget(page, plot._id)["strokes"] == [], \ + "an inactive brush painted" + assert abs(_view(page, plot._id)["cx"] - before["cx"]) > 0.01, \ + "an inactive brush swallowed the pan" + + def test_shift_drag_over_another_widget_still_paints(self, interact_page): + """Painting is modal: a scribble across the image must not be stolen + half way by whatever widget happens to sit under the cursor.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + brush = plot.add_brush_widget(radius=2.0) + # Added AFTER the brush, so it is on top in the hit-test's z-order. + plot.add_rectangle_widget(x=20, y=20, w=24, h=24) + page = interact_page(fig) + _collect_events(page) + + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + _paint(page, geom, rect, [(24, 32), (40, 32)]) # straight through it + + widgets = page.evaluate( + """(pid) => JSON.parse( + window._aplModel.get('panel_' + pid + '_json') + ).overlay_widgets""", str(plot._id)) + bw = next(w for w in widgets if w["type"] == "brush") + rw = next(w for w in widgets if w["type"] == "rectangle") + assert len(bw["strokes"]) == 1, "the rectangle stole the paint gesture" + assert (rw["x"], rw["y"], rw["w"], rw["h"]) == (20.0, 20.0, 24.0, 24.0), \ + "the rectangle moved or resized" + + def test_shift_click_paints_a_single_dot(self, interact_page): + """A tap with no motion is a legitimate one-point stroke — the stroke + opens on mousedown, not on the first move.""" + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + px, py = _img_to_page(geom, rect, 32, 32) + page.mouse.move(px, py) + page.keyboard.down("Shift") + page.mouse.down() + page.mouse.up() + page.keyboard.up("Shift") + page.wait_for_timeout(80) + + w = _widget(page, plot._id) + assert len(w["strokes"]) == 1 and len(w["strokes"][0]) == 1 + + +@pytest.mark.usefixtures("interact_page") +class TestPainting: + def test_stroke_points_are_image_pixels_along_the_drag(self, interact_page): + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + _paint(page, geom, rect, [(10, 40), (50, 40)]) + + pts = _widget(page, plot._id)["strokes"][0] + assert pts[0][0] == pytest.approx(10, abs=1.5) + assert pts[-1][0] == pytest.approx(50, abs=1.5) + assert all(abs(y - 40) < 1.5 for _x, y in pts), \ + "a horizontal drag wandered in y — coordinates are not image px" + assert all(0 <= x < IMG and 0 <= y < IMG for x, y in pts) + + def test_multiple_drags_accumulate_strokes(self, interact_page): + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + _paint(page, geom, rect, [(10, 16), (50, 16)]) + _paint(page, geom, rect, [(10, 32), (50, 32)]) + _paint(page, geom, rect, [(10, 48), (50, 48)]) + + w = _widget(page, plot._id) + assert len(w["strokes"]) == 3 + assert len(w["stroke_classes"]) == 3 + ys = [round(w["strokes"][i][0][1]) for i in range(3)] + assert ys == sorted(ys) and ys[0] != ys[-1] + + def test_the_stroke_draws_while_the_mouse_is_still_down(self, interact_page): + """A brush that only appeared on release would be unusable. Nothing is + pushed to the model until mouseup, so ``drawOverlay2d`` has to read the + in-progress stroke out of the panel scratch to paint it live.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + brush = plot.add_brush_widget(radius=3.0, alpha=1.0, colors=["#00ff00"]) + page = interact_page(fig) + _collect_events(page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + x0, y0 = _img_to_page(geom, rect, 12, 32) + x1, y1 = _img_to_page(geom, rect, 52, 32) + page.mouse.move(x0, y0) + page.keyboard.down("Shift") + page.mouse.down() + page.mouse.move(x1, y1, steps=8) + # STILL HELD DOWN — nothing has been committed or pushed yet. + assert _widget(page, plot._id)["strokes"] == [], \ + "the stroke reached the model before release" + mid_geom = _brush_geom(page, plot._id, brush.id) + assert mid_geom["n_strokes"] == 1, \ + "the in-progress stroke is not being drawn" + assert len(mid_geom["strokes"][0]) >= 3 + assert _count_overlay_color(page, (0, 255, 0)) > 200, \ + "nothing is painted on the overlay canvas mid-drag" + + page.mouse.up() + page.keyboard.up("Shift") + page.wait_for_timeout(80) + assert len(_widget(page, plot._id)["strokes"]) == 1 + + def test_a_model_echo_mid_stroke_does_not_wipe_it(self, interact_page): + """The panel trait stays STALE for the whole stroke — that is the point of + emitting once on release. So any unrelated ``save_changes()`` re-fires + ``change:panel__json`` and replaces ``p.state`` from that stale value. + The stroke therefore cannot live in ``p.state``; it lives in a per-panel + scratch. In normal use a ``pointer_leave`` emit (cursor crossing the panel + edge while painting) is enough to trigger this.""" + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + x0, y0 = _img_to_page(geom, rect, 10, 32) + xm, ym = _img_to_page(geom, rect, 32, 32) + x1, y1 = _img_to_page(geom, rect, 54, 32) + page.mouse.move(x0, y0) + page.keyboard.down("Shift") + page.mouse.down() + page.mouse.move(xm, ym, steps=8) + page.evaluate("() => window._aplModel.save_changes()") # the echo + page.mouse.move(x1, y1, steps=8) + page.mouse.up() + page.keyboard.up("Shift") + page.wait_for_timeout(80) + + w = _widget(page, plot._id) + assert len(w["strokes"]) == 1, "the echo split or dropped the stroke" + xs = [x for x, _y in w["strokes"][0]] + assert min(xs) == pytest.approx(10, abs=1.5), \ + "the echo wiped the first half of the stroke" + assert max(xs) == pytest.approx(54, abs=1.5) + + def test_points_outside_the_image_are_dropped(self, interact_page): + """A label coordinate outside the array is useless to a consumer, and a + negative index silently wraps.""" + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + # Start inside, drag well past the right edge of the image. + _paint(page, geom, rect, [(32, 32), (IMG + 40, 32)]) + + w = _widget(page, plot._id) + assert w["strokes"], "nothing was painted at all" + for stroke in w["strokes"]: + for x, y in stroke: + assert 0 <= x < IMG and 0 <= y < IMG, f"out-of-image point {x},{y}" + + +@pytest.mark.usefixtures("interact_page") +class TestClassColours: + def test_the_active_class_tags_and_colours_the_stroke(self, interact_page): + """One brush, several label classes: the stroke records ``class_id`` and + draws in ``colors[class_id]``.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + brush = plot.add_brush_widget(radius=3.0, class_id=1, alpha=1.0, + colors=["#ff0000", "#00ff00"]) + page = interact_page(fig) + _collect_events(page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + _paint(page, geom, rect, [(12, 32), (52, 32)]) + + w = _widget(page, plot._id) + assert w["stroke_classes"] == [1], "the stroke lost its class" + green = _count_overlay_color(page, (0, 255, 0)) + red = _count_overlay_color(page, (255, 0, 0)) + assert green > 200, f"class 1 did not draw in colors[1] (green={green})" + assert red == 0, f"class 1 drew in colors[0] as well (red={red})" + + def test_class_zero_uses_the_first_colour(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + brush = plot.add_brush_widget(radius=3.0, class_id=0, alpha=1.0, + colors=["#ff0000", "#00ff00"]) + page = interact_page(fig) + _collect_events(page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + _paint(page, geom, rect, [(12, 32), (52, 32)]) + + assert _widget(page, plot._id)["stroke_classes"] == [0] + assert _count_overlay_color(page, (255, 0, 0)) > 200 + assert _count_overlay_color(page, (0, 255, 0)) == 0 + + def test_switching_class_from_python_retags_the_next_stroke( + self, interact_page): + """The real multi-class workflow: the host flips ``class_id`` between + strokes (a class button in its UI). That arrives as the standard + Python->JS targeted widget update, so the NEXT stroke must pick up the + new class and its colour — and the stroke already painted must keep + its own.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + brush = plot.add_brush_widget(radius=3.0, alpha=1.0, + colors=["#ff0000", "#00ff00"]) + page = interact_page(fig) + _collect_events(page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + _paint(page, geom, rect, [(12, 20), (52, 20)]) # class 0 + + # The second stroke is driven by hand rather than through _paint, because + # the ORDER matters and it is not the brush's doing: Figure._push_widget + # deliberately never writes panel__json (see its docstring), so the + # panel trait stays stale after a targeted widget update — and the very + # next `save_changes()` from anything (here the Shift key_down emit) + # re-fires change:panel__json and reverts it. So press Shift FIRST, + # then push the class, then drag. This exposure is pre-existing and + # applies to every widget's targeted updates, not just this one. + x0, y0 = _img_to_page(geom, rect, 12, 44) + x1, y1 = _img_to_page(geom, rect, 52, 44) + page.mouse.move(x0, y0) + page.keyboard.down("Shift") + # Exactly what Figure._push_widget sends for `brush.class_id = 1`. + page.evaluate( + """([pid, wid]) => window._aplModel.set('event_json', JSON.stringify( + {source: 'python', panel_id: pid, widget_id: wid, + class_id: 1}))""", + [str(plot._id), str(brush.id)]) + page.mouse.down() + page.mouse.move(x1, y1, steps=6) + page.mouse.up() + page.keyboard.up("Shift") + page.wait_for_timeout(80) + + w = _widget(page, plot._id) + assert w["stroke_classes"] == [0, 1], \ + "the class switch did not retag the next stroke" + assert _count_overlay_color(page, (255, 0, 0)) > 200 + assert _count_overlay_color(page, (0, 255, 0)) > 200 + + def test_seeded_multiclass_strokes_draw_in_both_colours(self, interact_page): + """Both classes coexist in ONE widget — that is the point of the + ``colors`` list.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG, IMG), dtype=np.float32)) + plot.add_brush_widget(radius=3.0, alpha=1.0, + colors=["#ff0000", "#00ff00"], + strokes=[[(8, 20), (56, 20)], + [(8, 44), (56, 44)]], + stroke_classes=[0, 1]) + page = interact_page(fig) + assert _count_overlay_color(page, (255, 0, 0)) > 200 + assert _count_overlay_color(page, (0, 255, 0)) > 200 + + +@pytest.mark.usefixtures("interact_page") +class TestErase: + def test_erase_removes_points_under_the_brush(self, interact_page): + fig, plot, brush, page = _setup( + interact_page, erase=True, + strokes=[[(x, 32) for x in range(8, 57, 2)]]) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + before = len(_widget(page, plot._id)["strokes"][0]) + assert before >= 20 + + _paint(page, geom, rect, [(8, 32), (30, 32)]) # erase the left half + + after = _widget(page, plot._id) + kept = sum(len(s) for s in after["strokes"]) + assert kept < before, "erase removed nothing" + assert kept > 0, "erase wiped the whole stroke" + assert all(x > 30 for s in after["strokes"] for x, _y in s), \ + "points under the erase drag survived" + + def test_erasing_the_middle_splits_the_stroke(self, interact_page): + """A surviving RUN becomes its own stroke. Filtering in place instead + would rejoin the ends with one straight segment right across the gap the + user just erased.""" + fig, plot, brush, page = _setup( + interact_page, erase=True, + strokes=[[(x, 32) for x in range(8, 57, 2)]]) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + px, py = _img_to_page(geom, rect, 32, 32) + page.mouse.move(px, py) + page.keyboard.down("Shift") + page.mouse.down() + page.mouse.up() + page.keyboard.up("Shift") + page.wait_for_timeout(80) + + after = _widget(page, plot._id) + assert len(after["strokes"]) == 2, \ + f"expected two fragments, got {len(after['strokes'])}" + assert len(after["stroke_classes"]) == 2, "classes lost the split" + assert max(x for x, _ in after["strokes"][0]) < \ + min(x for x, _ in after["strokes"][1]) + + def test_erase_does_not_paint(self, interact_page): + fig, plot, brush, page = _setup(interact_page, erase=True) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + _paint(page, geom, rect, [(12, 32), (52, 32)]) + + assert _widget(page, plot._id)["strokes"] == [], \ + "an erase drag painted a stroke" + + +@pytest.mark.usefixtures("interact_page") +class TestEmitOncePerStroke: + """The performance contract. Widget drags in figure_esm.js are unthrottled: + without the local-accumulate design every mousemove would re-serialise the + whole (growing) stroke list into BOTH panel__json and event_json.""" + + def test_exactly_one_event_per_stroke(self, interact_page): + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + _paint(page, geom, rect, [(10, 32), (54, 32)], steps=20) + + mine = [e for e in _get_events(page) + if e.get("widget_id") == brush.id] + assert len(mine) == 1, \ + f"expected 1 event per stroke, got {len(mine)}: " \ + f"{[e['event_type'] for e in mine]}" + assert mine[0]["event_type"] == "pointer_up" + assert len(mine[0]["strokes"][0]) >= 5, \ + "the one event did not carry the finished stroke" + + def test_no_pointer_move_is_emitted_while_painting(self, interact_page): + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + _paint(page, geom, rect, [(10, 32), (54, 32)], steps=20) + + moves = _get_events(page, "pointer_move") + assert moves == [], \ + f"{len(moves)} pointer_move events fired during one stroke" + + def test_three_strokes_are_three_events(self, interact_page): + fig, plot, brush, page = _setup(interact_page) + geom = _brush_geom(page, plot._id, brush.id) + rect = _overlay_rect(page) + + for y in (16, 32, 48): + _paint(page, geom, rect, [(10, y), (54, y)], steps=12) + + mine = [e for e in _get_events(page) if e.get("widget_id") == brush.id] + assert len(mine) == 3, f"got {len(mine)} events for 3 strokes" + assert all(e["event_type"] == "pointer_up" for e in mine) + # The last event carries the cumulative stroke list, not just its own. + assert len(mine[-1]["strokes"]) == 3 diff --git a/anyplotlib/widgets/__init__.py b/anyplotlib/widgets/__init__.py index 1eeee94a..8f843752 100644 --- a/anyplotlib/widgets/__init__.py +++ b/anyplotlib/widgets/__init__.py @@ -3,6 +3,7 @@ from anyplotlib.widgets._widgets2d import ( RectangleWidget, CircleWidget, AnnularWidget, CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, LineWidget, + BrushWidget, ) from anyplotlib.widgets._widgets1d import ( VLineWidget, HLineWidget, RangeWidget, PointWidget, @@ -13,7 +14,7 @@ "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget", - "LineWidget", + "LineWidget", "BrushWidget", "VLineWidget", "HLineWidget", "RangeWidget", "PointWidget", "PlaneWidget", ] diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 5818fa1b..5f26d187 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -240,6 +240,239 @@ def __init__(self, push_fn, *, x, y, text="Label", fontsize=14, show_handles=bool(show_handles)) +def _coerce_strokes(strokes) -> list[list[list[float]]]: + """Validate/normalise a stroke list into plain ``[[[x, y], ...], ...]``. + + Accepts anything iterable-of-iterable-of-pairs (lists, tuples, numpy rows) + and returns nested plain ``float`` lists so the result is JSON-serialisable + for the wire. + + Raises + ------ + ValueError + If a stroke is empty or a point is not a 2-element ``(x, y)``. + """ + out: list[list[list[float]]] = [] + for si, stroke in enumerate(strokes): + pts = [] + for pt in stroke: + pair = list(pt) + if len(pair) != 2: + raise ValueError( + f"stroke {si}: every point must be (x, y); got {len(pair)} " + "values" + ) + pts.append([float(pair[0]), float(pair[1])]) + if not pts: + raise ValueError(f"stroke {si} is empty — a stroke needs >= 1 point") + out.append(pts) + return out + + +class BrushWidget(Widget): + """Freehand paint-brush overlay widget for 2-D plots. + + Shift-drag on the image to paint a stroke; every stroke is a polyline of + image-pixel points, stroked with round caps and joins at a width of + ``2 * radius`` image pixels. Built for labelling regions — painting + training scribbles for a pixel classifier, marking a defect, masking a + beam stop — where a polygon or a rectangle is the wrong shape. + + Two gates govern the painting so a brush can coexist with pan / click / + other widgets on the same panel: + + 1. ``active`` — Python-side arming. ``False`` keeps the strokes drawn but + ignores all input, which is how you park the tool without losing work. + 2. **Shift** — the drag modifier. A *bare* drag still pans the image and + still drags other widgets; only ``Shift`` + drag paints. A brush that + claimed a plain drag would hit-test as "anywhere in the image" and kill + panning and click-to-select outright. + + While the stroke is being drawn the points accumulate **in the browser** and + only the finished stroke reaches Python, once, as a ``pointer_up`` event. + So ``pointer_move`` does **not** fire for a brush stroke — register on + ``pointer_up``:: + + 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) + + Parameters + ---------- + push_fn : Callable + Update callback. + radius : float, optional + Brush radius in image pixels — the painted band is ``2 * radius`` wide, + and an erase drag removes stroke points within this distance. + Default 8. + color : str, optional + CSS colour used when ``colors`` has no entry for a stroke's class. + Default ``"#00e5ff"``. + colors : list of str, optional + Per-class CSS colours, indexed by ``class_id``. Lets one brush carry + several label classes at once. Default ``None`` (every class draws in + ``color``). + class_id : int, optional + Label class new strokes are tagged with. Default 0. + strokes : list, optional + Pre-existing strokes, ``[[[x, y], ...], ...]`` in image-pixel + coordinates. Default ``None`` (empty). + stroke_classes : list of int, optional + Class id per entry of ``strokes``; must be the same length. Default + ``None`` (every seeded stroke takes ``class_id``). + alpha : float, optional + Stroke opacity in ``[0, 1]``. Default 0.6 — a scribble you can see + the image through, since the point is to label what is underneath. + active : bool, optional + Accept Shift-drag painting. Default ``True``. + erase : bool, optional + When ``True`` an armed drag *removes* stroke points within ``radius`` + instead of painting. Default ``False``. + + Attributes + ---------- + strokes : list + Painted strokes, ``[[[x, y], ...], ...]`` in image pixels. Read-only + in practice — mutating the list in place does not reach the renderer; + use :meth:`add_stroke` / :meth:`set_strokes` / :meth:`clear_strokes`. + stroke_classes : list of int + Class id of each stroke, parallel to ``strokes``. + + Raises + ------ + ValueError + If ``radius <= 0``, ``class_id < 0``, ``alpha`` is outside ``[0, 1]``, + ``colors`` is not a sequence of strings, a stroke is malformed, or + ``stroke_classes`` does not match ``strokes`` in length. + + See Also + -------- + PolygonWidget : Closed straight-edged region with draggable vertices. + """ + + def __init__(self, push_fn, *, radius=8.0, color="#00e5ff", colors=None, + class_id=0, strokes=None, stroke_classes=None, alpha=0.6, + active=True, erase=False): + radius = float(radius) + if not radius > 0: + raise ValueError(f"radius must be > 0, got {radius}") + class_id = int(class_id) + if class_id < 0: + raise ValueError(f"class_id must be >= 0, got {class_id}") + alpha = float(alpha) + if not 0.0 <= alpha <= 1.0: + raise ValueError(f"alpha must be in [0, 1], got {alpha}") + if colors is None: + cols: list[str] = [] + elif isinstance(colors, str): + raise ValueError("colors must be a list of CSS colours, not a " + "single string — use color= for that") + else: + cols = [str(c) for c in colors] + strks = _coerce_strokes(strokes or []) + if stroke_classes is None: + classes = [class_id] * len(strks) + else: + classes = [int(c) for c in stroke_classes] + if len(classes) != len(strks): + raise ValueError( + f"stroke_classes has {len(classes)} entries but there are " + f"{len(strks)} strokes" + ) + if any(c < 0 for c in classes): + raise ValueError("stroke_classes must all be >= 0") + super().__init__("brush", push_fn, + radius=radius, color=color, colors=cols, + class_id=class_id, strokes=strks, + stroke_classes=classes, alpha=alpha, + active=bool(active), erase=bool(erase)) + + # ── stroke management ───────────────────────────────────────────────── + + @property + def n_strokes(self) -> int: + """Number of painted strokes.""" + return len(self._data["strokes"]) + + def clear_strokes(self) -> None: + """Discard every painted stroke. Does not change ``class_id``.""" + self.set(strokes=[], stroke_classes=[]) + + def add_stroke(self, points, class_id: int | None = None) -> None: + """Append one stroke. + + Parameters + ---------- + points : list of tuple + ``[(x, y), ...]`` in image-pixel coordinates; at least one point. + class_id : int, optional + Label class for this stroke. Defaults to the widget's current + ``class_id``. + + Raises + ------ + ValueError + If ``points`` is empty or a point is not an ``(x, y)`` pair. + """ + (stroke,) = _coerce_strokes([points]) + cid = int(self._data["class_id"] if class_id is None else class_id) + if cid < 0: + raise ValueError(f"class_id must be >= 0, got {cid}") + self.set(strokes=self._data["strokes"] + [stroke], + stroke_classes=self._data["stroke_classes"] + [cid]) + + def set_strokes(self, strokes, classes=None) -> None: + """Replace every stroke (and its class) in one push. + + This is the sanctioned way to write ``strokes``: it keeps the parallel + ``stroke_classes`` list in lockstep, which a bare + ``brush.strokes = ...`` assignment cannot do. + + Parameters + ---------- + strokes : list + ``[[[x, y], ...], ...]`` in image-pixel coordinates. + classes : list of int, optional + Class id per stroke. Defaults to the widget's current + ``class_id`` for every stroke. + + Raises + ------ + ValueError + If a stroke is malformed or ``classes`` has the wrong length. + """ + strks = _coerce_strokes(strokes) + if classes is None: + cls = [int(self._data["class_id"])] * len(strks) + else: + cls = [int(c) for c in classes] + if len(cls) != len(strks): + raise ValueError( + f"classes has {len(cls)} entries but there are " + f"{len(strks)} strokes" + ) + self.set(strokes=strks, stroke_classes=cls) + + def strokes_for_class(self, class_id: int) -> list: + """Return only the strokes tagged with ``class_id``. + + Parameters + ---------- + class_id : int + Label class to select. + + Returns + ------- + list + ``[[[x, y], ...], ...]`` — the matching strokes, in paint order. + """ + cid = int(class_id) + return [s for s, c in zip(self._data["strokes"], + self._data["stroke_classes"]) if c == cid] + + class ArrowWidget(Widget): """Draggable arrow overlay widget for 2-D plots. diff --git a/docs/api/widgets.rst b/docs/api/widgets.rst index a1875aac..97d76518 100644 --- a/docs/api/widgets.rst +++ b/docs/api/widgets.rst @@ -25,6 +25,7 @@ Interactive Widgets AnnularWidget CrosshairWidget PolygonWidget + BrushWidget LabelWidget VLineWidget HLineWidget @@ -68,6 +69,12 @@ Interactive Widgets :member-order: bysource :no-index: +.. autoclass:: anyplotlib.widgets.BrushWidget + :members: + :show-inheritance: + :member-order: bysource + :no-index: + .. autoclass:: anyplotlib.widgets.LabelWidget :members: :show-inheritance: diff --git a/upcoming_changes/+brush_widget.new_feature.rst b/upcoming_changes/+brush_widget.new_feature.rst new file mode 100644 index 00000000..11e1d18a --- /dev/null +++ b/upcoming_changes/+brush_widget.new_feature.rst @@ -0,0 +1,3 @@ +Added :meth:`~anyplotlib.Plot2D.add_brush_widget` for painting freehand +multi-class label strokes on a 2-D image with Shift-drag, leaving a bare drag to +pan as before.