From b73046c8561ac7d50a907dcea57a10aac37daf06 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 28 Apr 2026 12:10:45 -0500 Subject: [PATCH 1/3] Add overlay mask functionality for interactive image segmentation --- .../Interactive/plot_segment_by_contrast.py | 237 ++++++++++++++++++ anyplotlib/figure_esm.js | 81 ++++++ anyplotlib/figure_plots.py | 43 ++++ test_figure.py | 58 ----- test_pcolormesh.py | 99 -------- 5 files changed, 361 insertions(+), 157 deletions(-) create mode 100644 Examples/Interactive/plot_segment_by_contrast.py delete mode 100644 test_figure.py delete mode 100644 test_pcolormesh.py diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py new file mode 100644 index 0000000..f2420f4 --- /dev/null +++ b/Examples/Interactive/plot_segment_by_contrast.py @@ -0,0 +1,237 @@ +""" +Interactive Contrast Segmentation +=================================== + +Click on any region of the image to flood-fill all pixels of similar +intensity — the union of all seeded regions is shown as a live +semi-transparent overlay on the original image. + +**Interaction** + ++-----------------------------------+-----------------------------------------+ +| Action | Effect | ++===================================+=========================================+ +| **Left-click** | Add a *positive* seed (green dot). | +| | Flood-fill grows from that pixel. | ++-----------------------------------+-----------------------------------------+ +| **Shift + left-click** | Add a *negative* seed (red dot). | +| | Subtracts that connected region from | +| | the current mask. | ++-----------------------------------+-----------------------------------------+ +| **Hover + Delete / Backspace** | Remove the nearest seed within | +| | 12 image-px of the cursor. | ++-----------------------------------+-----------------------------------------+ +| **+** / **=** | Increase tolerance (grow regions). | ++-----------------------------------+-----------------------------------------+ +| **-** | Decrease tolerance (shrink regions). | ++-----------------------------------+-----------------------------------------+ +| **c** (while focused) | Clear all seeds and reset mask. | ++-----------------------------------+-----------------------------------------+ + +The current boolean mask numpy array is always accessible as ``mask``. + +.. note:: + Move the cursor over the plot so it receives keyboard focus before + pressing keys. The tolerance is shown in the plot title. +""" + +import numpy as np +import anyplotlib as vw + +# ── Synthetic multi-region image ────────────────────────────────────────────── +# Five Gaussian blobs at different intensity levels on a smooth background, +# plus mild Poisson-like noise — gives interesting connected regions to segment. + +N = 256 +rng = np.random.default_rng(7) + +xx, yy = np.meshgrid(np.arange(N), np.arange(N)) + +def _gauss(cx, cy, sigma, amplitude): + return amplitude * np.exp(-((xx - cx)**2 + (yy - cy)**2) / (2 * sigma**2)) + +image = ( + _gauss( 64, 72, 28, 0.85) # bright top-left blob + + _gauss(190, 60, 22, 0.70) # mid top-right blob + + _gauss(128, 128, 40, 0.55) # dim centre blob (large) + + _gauss( 55, 195, 20, 0.90) # bright bottom-left blob + + _gauss(200, 185, 30, 0.60) # mid bottom-right blob + + 0.08 * rng.standard_normal((N, N)) # noise +) +# Normalise to [0, 1] +image = (image - image.min()) / (image.max() - image.min()) + +# ── Segmentation: pure-numpy BFS flood-fill ─────────────────────────────────── + +def _bfs_region(img, row: int, col: int, tol: float) -> np.ndarray: + """Return a boolean mask for the connected region reachable from (row, col). + + Connectivity is 4-connected. A neighbour is accepted when + ``|img[neighbour] - centre_value| <= tol``, where *centre_value* is the + intensity of the seed pixel (fixed, not growing). + """ + H, W = img.shape + seed_val = img[row, col] + visited = np.zeros((H, W), dtype=bool) + visited[row, col] = True + stack = [(row, col)] + while stack: + r, c = stack.pop() + for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)): + nr, nc = r + dr, c + dc + if 0 <= nr < H and 0 <= nc < W and not visited[nr, nc]: + if abs(float(img[nr, nc]) - float(seed_val)) <= tol: + visited[nr, nc] = True + stack.append((nr, nc)) + return visited + + +def _compute_mask(img, pos_seeds, neg_seeds, tol): + """Union of positive-seed BFS regions minus any negative-seed regions.""" + if not pos_seeds: + return np.zeros(img.shape, dtype=bool) + combined = np.zeros(img.shape, dtype=bool) + for r, c in pos_seeds: + combined |= _bfs_region(img, r, c, tol) + for r, c in neg_seeds: + combined &= ~_bfs_region(img, r, c, tol) + return combined + + +# ── State ───────────────────────────────────────────────────────────────────── + +pos_seeds: list[tuple[int, int]] = [] # (row, col) +neg_seeds: list[tuple[int, int]] = [] # (row, col) +tolerance: float = 0.08 +mask = np.zeros((N, N), dtype=bool) # exposed numpy array + +TOL_STEP = 0.01 +TOL_MIN = 0.005 +TOL_MAX = 0.40 +SEED_RADIUS_PIXELS = 5 # marker radius for seed dots + +# ── Figure ──────────────────────────────────────────────────────────────────── + +fig, ax = vw.subplots(figsize=(520, 520), + help="Left-click → add positive seed (grow mask)\n" + "Shift + Left-click → add negative seed (shrink mask)\n" + "Hover + Delete → remove nearest seed\n" + "+ / - → increase / decrease tolerance\n" + "c → clear all seeds") + +plot = ax.imshow(image) +plot.set_colormap("gray") + +# ── Persistent marker groups ────────────────────────────────────────────────── +# Create named groups once so _refresh() can update them with .set() instead of +# clear_markers() + add_circles(). Placing the placeholder far off-screen means +# empty groups render nothing without needing a special empty-list code path. +_HIDDEN = [[-9999.0, -9999.0]] # off-screen placeholder for an empty group + +plot.add_circles(_HIDDEN, name="pos", + facecolors="#69f0ae", edgecolors="#ffffff", + radius=SEED_RADIUS_PIXELS) +plot.add_circles(_HIDDEN, name="neg", + facecolors="#ff5252", edgecolors="#ffffff", + radius=SEED_RADIUS_PIXELS) + +# ── Helpers: marker refresh and mask push ──────────────────────────────────── + +def _refresh(): + """Recompute mask and push updated markers + overlay in one go. + + Updates the two persistent marker groups in-place (no clear → blank → add + cycle) so there is no visible flicker when a seed is removed. + Each group has its own fixed colour string so the JS fill_color field + always receives a valid CSS colour (not a mixed list). + """ + global mask + mask = _compute_mask(image, pos_seeds, neg_seeds, tolerance) + + # Update offsets for each group; fall back to off-screen placeholder when empty. + pos_offsets = [(c, r) for r, c in pos_seeds] or _HIDDEN + neg_offsets = [(c, r) for r, c in neg_seeds] or _HIDDEN + plot.markers["circles"]["pos"].set(offsets=pos_offsets) + plot.markers["circles"]["neg"].set(offsets=neg_offsets) + + # Transparent overlay — teal for positive mask regions. + plot.set_overlay_mask(mask, color="#00e5ff", alpha=0.38) + + +# ── Click handler ───────────────────────────────────────────────────────────── + +@plot.on_click +def _on_click(event): + """Left-click → positive seed; Shift+Left-click → negative seed.""" + # img_x = column, img_y = row (image-pixel coordinates) + col = int(round(float(event.img_x))) + row = int(round(float(event.img_y))) + # Clamp to image bounds + col = max(0, min(N - 1, col)) + row = max(0, min(N - 1, row)) + + if getattr(event, "shift_key", False): + neg_seeds.append((row, col)) + else: + pos_seeds.append((row, col)) + + _refresh() + + +# ── Key bindings ────────────────────────────────────────────────────────────── + +@plot.on_key('+') +@plot.on_key('=') # '+' on most keyboards requires Shift; '=' is the unshifted key +def _tol_up(event): + """Increase tolerance → flood-fill grows to wider intensity range.""" + global tolerance + tolerance = min(TOL_MAX, round(tolerance + TOL_STEP, 4)) + _refresh() + print(f" tolerance = {tolerance:.3f}", end="\r") + + +@plot.on_key('-') +def _tol_down(event): + """Decrease tolerance → flood-fill shrinks to narrower range.""" + global tolerance + tolerance = max(TOL_MIN, round(tolerance - TOL_STEP, 4)) + _refresh() + print(f" tolerance = {tolerance:.3f}", end="\r") + + +@plot.on_key('c') +def _clear(event): + """Clear all seeds and reset the mask.""" + pos_seeds.clear() + neg_seeds.clear() + _refresh() + print(" seeds cleared", end="\r") + + +@plot.on_key('Delete') +@plot.on_key('Backspace') +def _delete_nearest(event): + """Remove the seed (positive or negative) nearest to the cursor.""" + cx = float(event.img_x) + cy = float(event.img_y) # img_y = row + + best_dist = float("inf") + best_list = None + best_idx = -1 + + for lst in (pos_seeds, neg_seeds): + for i, (r, c) in enumerate(lst): + d = (c - cx) ** 2 + (r - cy) ** 2 + if d < best_dist: + best_dist = d + best_list = lst + best_idx = i + + if best_list is not None and best_dist <= (12 ** 2): + best_list.pop(best_idx) + _refresh() + + +fig + + diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 5ecc384..9535190 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -995,6 +995,56 @@ function render({ model, el }) { blitCache.w=iw; blitCache.h=ih; _blit2d(oc, st, imgW, imgH, ctx); } + + // ── Overlay mask compositing ───────────────────────────────────────────── + // overlay_mask_b64: base64 uint8 bytes (0|255), same iw×ih as image. + // Rendered at overlay_mask_alpha on top of the base image without clearing. + const mob64=st.overlay_mask_b64||''; + if(mob64){ + const mColor=st.overlay_mask_color||'#ff4444'; + const mAlpha=st.overlay_mask_alpha!=null?st.overlay_mask_alpha:0.4; + const mKey=mob64+'|'+mColor+'|'+mAlpha; + if(!p.maskCache||p.maskCache.key!==mKey){ + // Parse hex colour → r,g,b + let mr=255,mg=68,mb=68; + if(mColor.startsWith('#')&&mColor.length===7){ + mr=parseInt(mColor.slice(1,3),16); + mg=parseInt(mColor.slice(3,5),16); + mb=parseInt(mColor.slice(5,7),16); + } + let mBytes; + try{const bin=atob(mob64);mBytes=new Uint8Array(bin.length);for(let i=0;i=1.0){ + const _vw=iw/_mz,_vh=ih/_mz; + const _sx=Math.max(0,Math.min(iw-_vw,_mcx*iw-_vw/2)); + const _sy=Math.max(0,Math.min(ih-_vh,_mcy*ih-_vh/2)); + ctx.drawImage(p.maskCache.bitmap,_sx,_sy,_vw,_vh,_mx,_my,_mw,_mh); + }else{ + const _dw=_mw*_mz,_dh=_mh*_mz; + ctx.drawImage(p.maskCache.bitmap,0,0,iw,ih,_mx+(_mw-_dw)/2,_my+(_mh-_dh)/2,_dw,_dh); + } + ctx.restore(); + } + } // Axes / scalebar / colorbar _drawAxes2d(p); drawScaleBar2d(p); @@ -2389,6 +2439,8 @@ function render({ model, el }) { // Store pan start in canvas-pixel coords so the drag delta is also // in canvas-pixel space and matches fr.w/fr.h (both canvas-pixel). panStart={mx,my,cx:st.center_x,cy:st.center_y}; + // Track potential click: distance + time guards distinguish click from pan. + p.clickCandidate={mx,my,t:Date.now(),shiftKey:e.shiftKey}; p.isPanning=true; overlayCanvas.style.cursor='grabbing'; e.preventDefault(); }); document.addEventListener('mousemove',(e)=>{ @@ -2404,6 +2456,8 @@ function render({ model, el }) { const fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH); const z=st.zoom; const {mx:cmx,my:cmy}=_clientPos(e,overlayCanvas,imgW,imgH); + // Invalidate click candidate once the cursor has clearly moved (>4 px). + if(p.clickCandidate){const _dx=cmx-p.clickCandidate.mx,_dy=cmy-p.clickCandidate.my;if(_dx*_dx+_dy*_dy>16)p.clickCandidate=null;} localOnly=true; st.center_x=Math.max(0,Math.min(1,panStart.cx-(cmx-panStart.mx)/fr.w/z)); st.center_y=Math.max(0,Math.min(1,panStart.cy-(cmy-panStart.my)/fr.h/z)); @@ -2428,6 +2482,33 @@ function render({ model, el }) { 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 fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH); const {mx:cmx,my:cmy}=_clientPos(e,overlayCanvas,imgW,imgH); + // ── Click detection: short-duration + small-movement mousedown/up ──────── + // Criteria: candidate still alive (not cleared by mousemove) AND ≤300 ms. + // We also re-check final distance as a safety net for document-level moves + // that didn't fire our mousemove guard (e.g. rapid trackpad flicks). + if(p.clickCandidate){ + const _cc=p.clickCandidate; p.clickCandidate=null; + const _dx=cmx-_cc.mx, _dy=cmy-_cc.my; + const _dist2=_dx*_dx+_dy*_dy; + const _dt=Date.now()-_cc.t; + if(_dist2<=25&&_dt<=350){ + // Genuine click — skip pan-settle, emit on_click with image coords. + const [imgX,imgY]=_canvasToImg2d(_cc.mx,_cc.my,st,imgW,imgH); + const xArr=st.x_axis||[], yArr=st.y_axis||[]; + const _iw=st.image_width||1, _ih=st.image_height||1; + const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/_iw):imgX; + const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/_ih):imgY; + _emitEvent(p.id,'on_click',null,{ + img_x:imgX, img_y:imgY, + phys_x:physX, phys_y:physY, + shift_key:_cc.shiftKey, + mouse_x:_cc.mx, mouse_y:_cc.my, + }); + model.save_changes(); + return; + } + } + // ── Normal pan settle ─────────────────────────────────────────────────── st.center_x=Math.max(0,Math.min(1,panStart.cx-(cmx-panStart.mx)/fr.w/st.zoom)); st.center_y=Math.max(0,Math.min(1,panStart.cy-(cmy-panStart.my)/fr.h/st.zoom)); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 54609d2..b63ec1a 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -755,6 +755,10 @@ def __init__(self, data: np.ndarray, "overlay_widgets": [], "markers": [], "registered_keys": [], + # Transparent mask overlay (set via set_overlay_mask) + "overlay_mask_b64": "", + "overlay_mask_color": "#ff4444", + "overlay_mask_alpha": 0.4, } self.markers = MarkerRegistry(self._push_markers, @@ -847,6 +851,45 @@ def set_data(self, data: np.ndarray, }) self._push() + def set_overlay_mask(self, mask: "np.ndarray | None", + color: str = "#ff4444", + alpha: float = 0.4) -> None: + """Set (or clear) a transparent boolean mask drawn over the image. + + The mask is composited client-side in the browser at *alpha* opacity + using *color* for all ``True`` pixels. Call with ``mask=None`` to + remove any existing overlay. + + Parameters + ---------- + mask : ndarray of shape (H, W), bool or uint8, or None + Boolean array aligned to the image data. ``True`` / non-zero + pixels are filled with *color* at transparency *alpha*. + Pass ``None`` to clear the overlay. + color : str, optional + CSS hex colour for the overlay, e.g. ``"#ff4444"``. Default red. + alpha : float, optional + Opacity in [0, 1]. Default 0.4 (40 % opaque). + """ + import base64 + if mask is None: + self._state["overlay_mask_b64"] = "" + self._state["overlay_mask_color"] = color + self._state["overlay_mask_alpha"] = float(alpha) + else: + arr = np.asarray(mask) + if arr.shape != (self._state["image_height"], self._state["image_width"]): + raise ValueError( + f"mask shape {arr.shape} does not match image " + f"({self._state['image_height']} x {self._state['image_width']})" + ) + # Convert to uint8: True/non-zero → 255, False/zero → 0 + u8 = (np.asarray(arr, dtype=bool).view(np.uint8) * 255).astype(np.uint8) + self._state["overlay_mask_b64"] = base64.b64encode(u8.tobytes()).decode("ascii") + self._state["overlay_mask_color"] = color + self._state["overlay_mask_alpha"] = float(alpha) + self._push() + # ------------------------------------------------------------------ # Display settings # ------------------------------------------------------------------ diff --git a/test_figure.py b/test_figure.py deleted file mode 100644 index 4180aa6..0000000 --- a/test_figure.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Smoke test — run with: python3 test_figure.py""" -import numpy as np -import anyplotlib as vw -import json - -# --- subplots 2x1 --- -fig, axs = vw.subplots(2, 1, figsize=(400, 300)) -assert axs.shape == (2,), f"Expected shape (2,), got {axs.shape}" - -v2d = axs[0].imshow(np.random.rand(64, 64)) -v1d = axs[1].plot(np.sin(np.linspace(0, 6, 128))) - -# layout_json sanity -L = json.loads(fig.layout_json) -assert L["nrows"] == 2, f"nrows={L['nrows']}" -assert len(L["panel_specs"]) == 2, f"panel_specs={L['panel_specs']}" - -# panel trait exists -assert fig.has_trait(f"panel_{v2d._id}_json"), "missing panel trait for v2d" -assert fig.has_trait(f"panel_{v1d._id}_json"), "missing panel trait for v1d" - -# marker add -mg = v2d.add_circles(np.array([[16., 16.], [32., 32.]]), - name="g1", facecolors="red", radius=5) -assert v2d.markers["circles"]["g1"]._data["radius"] == 5 - -# marker live update -v2d.markers["circles"]["g1"].set(radius=8) -assert v2d.markers["circles"]["g1"]._data["radius"] == 8 - -# auto-name -mg2 = v2d.add_circles(np.array([[48., 48.]]), radius=3) -assert "circles_1" in v2d.markers["circles"], \ - f"auto-name failed: {list(v2d.markers['circles'].keys())}" - -# 1D markers -v1d.add_vlines([1.0, 2.0, 3.0], name="peaks") -assert "peaks" in v1d.markers["vlines"] - -# GridSpec -gs = vw.GridSpec(2, 3, width_ratios=[2, 1, 1]) -s = gs[0, :] -assert s.col_start == 0 and s.col_stop == 3 -s2 = gs[1, 1] -assert s2.row_start == 1 and s2.col_start == 1 - -# subplots squeeze shapes -fig1, ax1 = vw.subplots(1, 1) -assert not hasattr(ax1, 'shape'), "1x1 should return scalar Axes" - -fig2, axs2 = vw.subplots(1, 3) -assert axs2.shape == (3,), f"1x3 should be 1-D, got {axs2.shape}" - -fig3, axs3 = vw.subplots(2, 2) -assert axs3.shape == (2, 2), f"2x2 should be 2-D, got {axs3.shape}" - -print("ALL TESTS PASSED") - diff --git a/test_pcolormesh.py b/test_pcolormesh.py deleted file mode 100644 index e46de48..0000000 --- a/test_pcolormesh.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Quick smoke-test for PlotMesh / pcolormesh support.""" -import numpy as np -from anyplotlib.figure_plots import PlotMesh, _resample_mesh -from anyplotlib.markers import MarkerRegistry - -# ── _resample_mesh ──────────────────────────────────────────────────────────── -x_edges = np.logspace(-1, 2, 13) # 12 cols, 13 edges -y_edges = np.linspace(0, 10, 9) # 8 rows, 9 edges -data = np.arange(8 * 12, dtype=float).reshape(8, 12) - -out = _resample_mesh(data, x_edges, y_edges) -assert out.shape == (8, 12), f"wrong shape {out.shape}" -print("_resample_mesh OK:", out.shape) - -# Uniform edges → identity (each output cell maps to same input cell) -x_uni = np.arange(13, dtype=float) -y_uni = np.arange(9, dtype=float) -out_uni = _resample_mesh(data, x_uni, y_uni) -assert (out_uni == data).all(), "uniform resample should be identity" -print("_resample_mesh uniform identity OK") - -# ── PlotMesh construction ───────────────────────────────────────────────────── -mesh = PlotMesh(data, x_edges=x_edges, y_edges=y_edges, units="nm") -assert mesh._state["is_mesh"] is True -assert mesh._state["kind"] == "2d" -assert len(mesh._state["x_axis"]) == 13 # edges, not centres -assert len(mesh._state["y_axis"]) == 9 -assert mesh._state["image_width"] == 12 -assert mesh._state["image_height"] == 8 -assert "scale_x" not in mesh._state -assert "scale_y" not in mesh._state -print("PlotMesh state OK") - -# ── Default edges ───────────────────────────────────────────────────────────── -m2 = PlotMesh(data) -assert m2._state["x_axis"] == list(np.arange(13, dtype=float)) -assert m2._state["y_axis"] == list(np.arange(9, dtype=float)) -print("Default edges OK") - -# ── Edge-length validation ──────────────────────────────────────────────────── -try: - PlotMesh(data, x_edges=np.arange(10)) # should be 13 - raise AssertionError("should have raised") -except ValueError as e: - print(f"Edge validation OK: {e}") - -# ── Marker restriction — only circles and lines ─────────────────────────────── -try: - mesh.markers.add("rectangles", "r1", offsets=[[1, 1]], widths=[5], heights=[5]) - raise AssertionError("rectangles should have been rejected") -except ValueError as e: - print(f"Marker restriction OK (rectangles): {e}") - -try: - mesh.markers.add("arrows", "a1", offsets=[[1, 1]], U=[1], V=[0]) - raise AssertionError("arrows should have been rejected") -except ValueError as e: - print(f"Marker restriction OK (arrows): {e}") - -# Circles and lines should work -mesh.add_circles([[1.0, 2.0], [5.0, 7.0]], name="pts", radius=2) -mesh.add_lines([[[1.0, 2.0], [5.0, 7.0]]], name="segs") -print("add_circles + add_lines OK") - -# ── to_state_dict ───────────────────────────────────────────────────────────── -sd = mesh.to_state_dict() -assert sd["is_mesh"] is True -assert sd["x_axis"] == x_edges.tolist() -assert len(sd["markers"]) == 2 -print("to_state_dict OK") - -# ── update() ───────────────────────────────────────────────────────────────── -mesh.update(data * 2) -print("update() (no push, fig=None) OK") - -# update with new edges -new_x = np.linspace(0, 1, 13) -mesh.update(data, x_edges=new_x) -assert mesh._state["x_axis"] == new_x.tolist() -print("update() with new x_edges OK") - -# ── Axes.pcolormesh integration ─────────────────────────────────────────────── -import anyplotlib as vw -fig, ax = vw.subplots(1, 1, figsize=(400, 400)) -m = ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges, units="nm") -assert isinstance(m, PlotMesh) -assert m._fig is fig -print("Axes.pcolormesh integration OK") - -# ── layout kind is '2d' for PlotMesh ───────────────────────────────────────── -import json -layout = json.loads(fig.layout_json) -panel = layout["panel_specs"][0] -assert panel["kind"] == "2d" -print("layout kind='2d' for PlotMesh OK") - -print() -print("All checks passed!") - From beb2657611b8326f35c228b9f75f5d96a97e0832 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 2 May 2026 14:52:20 -0500 Subject: [PATCH 2/3] Enhance UI elements and preserve view state in 2D panels --- .../Interactive/plot_segment_by_contrast.py | 4 +-- anyplotlib/figure_esm.js | 36 ++++++++++++++----- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py index f2420f4..fe559c3 100644 --- a/Examples/Interactive/plot_segment_by_contrast.py +++ b/Examples/Interactive/plot_segment_by_contrast.py @@ -129,10 +129,10 @@ def _compute_mask(img, pos_seeds, neg_seeds, tol): _HIDDEN = [[-9999.0, -9999.0]] # off-screen placeholder for an empty group plot.add_circles(_HIDDEN, name="pos", - facecolors="#69f0ae", edgecolors="#ffffff", + facecolors="#00c853", edgecolors="#ffffff", radius=SEED_RADIUS_PIXELS) plot.add_circles(_HIDDEN, name="neg", - facecolors="#ff5252", edgecolors="#ffffff", + facecolors="#b71c1c", edgecolors="#ffffff", radius=SEED_RADIUS_PIXELS) # ── Helpers: marker refresh and mask push ──────────────────────────────────── diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 9535190..60fce2b 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -198,8 +198,8 @@ function render({ model, el }) { const sizeLabel = document.createElement('div'); sizeLabel.style.cssText = - 'position:absolute;bottom:22px;right:22px;padding:3px 7px;background:rgba(0,0,0,0.7);' + - 'color:white;font-size:11px;border-radius:4px;display:none;pointer-events:none;z-index:21;'; + 'position:absolute;bottom:22px;right:22px;padding:7px 14px;background:rgba(0,0,0,0.65);' + + 'color:white;font-size:12px;font-family:monospace;border-radius:5px;display:none;pointer-events:none;z-index:21;'; outerDiv.appendChild(sizeLabel); // ── Help badge (figure-level) ───────────────────────────────────────────── @@ -407,8 +407,8 @@ function render({ model, el }) { scaleBar.style.cssText = 'position:absolute;pointer-events:none;display:none;z-index:7;'; statusBar = document.createElement('div'); statusBar.style.cssText = - 'position:absolute;padding:2px 6px;background:rgba(0,0,0,0.55);color:white;' + - 'font-size:10px;font-family:monospace;border-radius:4px;pointer-events:none;' + + 'position:absolute;padding:7px 14px;background:rgba(0,0,0,0.65);color:white;' + + 'font-size:12px;font-family:monospace;border-radius:5px;pointer-events:none;' + 'white-space:nowrap;display:none;z-index:9;'; yAxisCanvas = document.createElement('canvas'); yAxisCanvas.style.cssText = @@ -474,9 +474,9 @@ function render({ model, el }) { wrap.appendChild(markersCanvas); statusBar = document.createElement('div'); statusBar.style.cssText = - 'position:absolute;bottom:4px;right:4px;padding:2px 6px;' + - 'background:rgba(0,0,0,0.55);color:white;font-size:10px;font-family:monospace;' + - 'border-radius:4px;pointer-events:none;white-space:nowrap;display:none;z-index:9;'; + 'position:absolute;bottom:4px;right:4px;padding:7px 14px;' + + 'background:rgba(0,0,0,0.65);color:white;font-size:12px;font-family:monospace;' + + 'border-radius:5px;pointer-events:none;white-space:nowrap;display:none;z-index:9;'; wrap.appendChild(statusBar); wrapNode = wrap; } @@ -548,7 +548,16 @@ function render({ model, el }) { model.on(`change:panel_${id}_json`, () => { const p2 = panels.get(id); if (!p2) return; - try { p2.state = JSON.parse(model.get(`panel_${id}_json`)); } + try { + const newState = JSON.parse(model.get(`panel_${id}_json`)); + // Preserve the current view (zoom/pan) so Python pushes don't reset it + if (p2.state && p2.kind === '2d') { + newState.zoom = p2.state.zoom; + newState.center_x = p2.state.center_x; + newState.center_y = p2.state.center_y; + } + p2.state = newState; + } catch(_) { return; } p2._hoverSi = -1; p2._hoverI = -1; _redrawPanel(p2); @@ -659,7 +668,16 @@ function render({ model, el }) { model.on(`change:panel_${id}_json`, () => { const p2 = panels.get(id); if (!p2) return; - try { p2.state = JSON.parse(model.get(`panel_${id}_json`)); } + try { + const newState = JSON.parse(model.get(`panel_${id}_json`)); + // Preserve the current view (zoom/pan) so Python pushes don't reset it + if (p2.state && p2.kind === '2d') { + newState.zoom = p2.state.zoom; + newState.center_x = p2.state.center_x; + newState.center_y = p2.state.center_y; + } + p2.state = newState; + } catch(_) { return; } p2._hoverSi = -1; p2._hoverI = -1; _redrawPanel(p2); From a6b719e27d79fb509698f484d7e9c9e1c9569a71 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 2 May 2026 15:52:57 -0500 Subject: [PATCH 3/3] Enhance overlay mask functionality and view state preservation in 2D panels --- anyplotlib/figure_esm.js | 26 ++++++--- anyplotlib/figure_plots.py | 26 ++++++++- tests/test_interaction.py | 64 ++++++++++++++++++++++ tests/test_plot2d_polish.py | 106 ++++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 12 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 60fce2b..0e77375 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -550,8 +550,10 @@ function render({ model, el }) { if (!p2) return; try { const newState = JSON.parse(model.get(`panel_${id}_json`)); - // Preserve the current view (zoom/pan) so Python pushes don't reset it - if (p2.state && p2.kind === '2d') { + // Preserve the current view (zoom/pan) so Python data pushes don't + // reset it — but only when Python has NOT explicitly requested a view + // change (set_view / reset_view set _view_from_python: true). + if (p2.state && p2.kind === '2d' && !newState._view_from_python) { newState.zoom = p2.state.zoom; newState.center_x = p2.state.center_x; newState.center_y = p2.state.center_y; @@ -670,8 +672,10 @@ function render({ model, el }) { if (!p2) return; try { const newState = JSON.parse(model.get(`panel_${id}_json`)); - // Preserve the current view (zoom/pan) so Python pushes don't reset it - if (p2.state && p2.kind === '2d') { + // Preserve the current view (zoom/pan) so Python data pushes don't + // reset it — but only when Python has NOT explicitly requested a view + // change (set_view / reset_view set _view_from_python: true). + if (p2.state && p2.kind === '2d' && !newState._view_from_python) { newState.zoom = p2.state.zoom; newState.center_x = p2.state.center_x; newState.center_y = p2.state.center_y; @@ -1018,11 +1022,15 @@ function render({ model, el }) { // overlay_mask_b64: base64 uint8 bytes (0|255), same iw×ih as image. // Rendered at overlay_mask_alpha on top of the base image without clearing. const mob64=st.overlay_mask_b64||''; - if(mob64){ + if(!mob64){ + // Mask cleared — release cached bitmap so memory can be reclaimed. + if(p.maskCache) p.maskCache=null; + } else { const mColor=st.overlay_mask_color||'#ff4444'; const mAlpha=st.overlay_mask_alpha!=null?st.overlay_mask_alpha:0.4; - const mKey=mob64+'|'+mColor+'|'+mAlpha; - if(!p.maskCache||p.maskCache.key!==mKey){ + // Compare fields individually to avoid building a large concatenated key + // on every redraw during pan/zoom (mob64 can be very large). + if(!p.maskCache||p.maskCache.b64!==mob64||p.maskCache.color!==mColor||p.maskCache.alpha!==mAlpha){ // Parse hex colour → r,g,b let mr=255,mg=68,mb=68; if(mColor.startsWith('#')&&mColor.length===7){ @@ -1043,7 +1051,7 @@ function render({ model, el }) { for(let i=0;i None: """Reset pan and zoom to show the full image.""" self._state["zoom"] = 1.0 self._state["center_x"] = 0.5 self._state["center_y"] = 0.5 + self._state["_view_from_python"] = True self._push() + self._state["_view_from_python"] = False # ------------------------------------------------------------------ # Marker API (matplotlib-style kwargs → MarkerRegistry) diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 33fcb1c..2083f2b 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -1091,3 +1091,67 @@ def test_bar_click_under_scale(self, interact_page): ) +# ═══════════════════════════════════════════════════════════════════════════ +# 2D imshow click vs drag tests +# ═══════════════════════════════════════════════════════════════════════════ + +class TestImshow2DClickVsDrag: + """Verify that a short tap on a 2D imshow panel emits ``on_click`` while a + longer drag emits only a pan ``on_release`` — and not an ``on_click``.""" + + def _make_fig(self): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + data = np.arange(64 * 64, dtype=np.float32).reshape(64, 64) + plot = ax.imshow(data) + return fig, plot + + def _img_center_page(self) -> tuple[int, int]: + """Page coordinates of the centre of the image area.""" + # For a 2D panel the canvas fills the full cell; the image is letterboxed + # inside the PAD region. The centre of the drawable area: + cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 + cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 + return _to_page(cx, cy) + + def test_short_click_emits_on_click(self, interact_page): + """A short mousedown/up without movement fires an ``on_click`` event.""" + fig, plot = self._make_fig() + panel_id = plot._id + + page = interact_page(fig) + px, py = self._img_center_page() + + # Single click (no movement) — Playwright's click() is a + # mousedown + mouseup without intermediate moves, so _dist2 == 0 + # and _dt is well within the 350 ms threshold. + page.mouse.click(px, py) + _rafter(page) + + ev = _event(page) + assert ev.get("event_type") == "on_click", ( + f"Expected on_click from a short tap; got {ev.get('event_type')!r}" + ) + assert "img_x" in ev and "img_y" in ev, ( + "on_click event must include img_x and img_y coordinates" + ) + + def test_drag_does_not_emit_on_click(self, interact_page): + """A visible drag (> 5 px) pans the image and must NOT fire ``on_click``.""" + fig, plot = self._make_fig() + panel_id = plot._id + + page = interact_page(fig) + px, py = self._img_center_page() + + # Move mouse to start, press, drag 40 px right, release. + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 40, py, steps=10) + page.mouse.up() + _rafter(page) + + ev = _event(page) + assert ev.get("event_type") != "on_click", ( + f"Expected pan (on_release), not on_click after a drag; " + f"got {ev.get('event_type')!r}" + ) diff --git a/tests/test_plot2d_polish.py b/tests/test_plot2d_polish.py index 7a26434..56c7117 100644 --- a/tests/test_plot2d_polish.py +++ b/tests/test_plot2d_polish.py @@ -271,3 +271,109 @@ def test_no_debug_print_in_on_event(capsys): captured = capsys.readouterr() assert captured.out == "", f"Unexpected stdout: {captured.out!r}" + +# ───────────────────────────────────────────────────────────────────────────── +# 10. set_overlay_mask() +# ───────────────────────────────────────────────────────────────────────────── + +def test_set_overlay_mask_sets_state(): + """set_overlay_mask with a valid mask populates overlay_mask_b64.""" + plot = _make_plot2d((16, 16)) + mask = np.zeros((16, 16), dtype=bool) + mask[4:12, 4:12] = True + plot.set_overlay_mask(mask) + assert plot._state["overlay_mask_b64"] != "" + assert plot._state["overlay_mask_color"] == "#ff4444" + assert plot._state["overlay_mask_alpha"] == 0.4 + + +def test_set_overlay_mask_clear(): + """set_overlay_mask(None) clears the overlay.""" + plot = _make_plot2d((16, 16)) + mask = np.ones((16, 16), dtype=bool) + plot.set_overlay_mask(mask) + assert plot._state["overlay_mask_b64"] != "" + + plot.set_overlay_mask(None) + assert plot._state["overlay_mask_b64"] == "" + + +def test_set_overlay_mask_shape_mismatch(): + """set_overlay_mask with wrong shape raises ValueError.""" + plot = _make_plot2d((16, 32)) + bad_mask = np.zeros((8, 8), dtype=bool) + with pytest.raises(ValueError, match="mask shape"): + plot.set_overlay_mask(bad_mask) + + +def test_set_overlay_mask_alpha_validation(): + """set_overlay_mask clamps alpha to [0, 1]; out-of-range raises ValueError.""" + plot = _make_plot2d((16, 16)) + mask = np.zeros((16, 16), dtype=bool) + # Valid boundary values should work + plot.set_overlay_mask(mask, alpha=0.0) + assert plot._state["overlay_mask_alpha"] == 0.0 + plot.set_overlay_mask(mask, alpha=1.0) + assert plot._state["overlay_mask_alpha"] == 1.0 + # Out-of-range should raise + with pytest.raises(ValueError, match="alpha"): + plot.set_overlay_mask(mask, alpha=1.5) + with pytest.raises(ValueError, match="alpha"): + plot.set_overlay_mask(mask, alpha=-0.1) + + +def test_set_overlay_mask_color_validation(): + """set_overlay_mask raises ValueError for non-#RRGGBB color strings.""" + plot = _make_plot2d((16, 16)) + mask = np.zeros((16, 16), dtype=bool) + # Valid color should work + plot.set_overlay_mask(mask, color="#aabbcc") + assert plot._state["overlay_mask_color"] == "#aabbcc" + # Short hex, named colors, or malformed should raise + with pytest.raises(ValueError, match="color"): + plot.set_overlay_mask(mask, color="red") + with pytest.raises(ValueError, match="color"): + plot.set_overlay_mask(mask, color="#fff") + with pytest.raises(ValueError, match="color"): + plot.set_overlay_mask(mask, color="#GGGGGG") + + +def test_set_overlay_mask_origin_lower_flips(): + """For origin='lower' the mask is flipped to match the internally-flipped image.""" + import base64 + fig, ax = apl.subplots(1, 1) + data = np.zeros((4, 4)) + plot = ax.imshow(data, origin="lower") + + # Mask with only the top row (row 0) set True + mask = np.zeros((4, 4), dtype=bool) + mask[0, :] = True + + plot.set_overlay_mask(mask) + # Decode the stored bytes + raw = base64.b64decode(plot._state["overlay_mask_b64"]) + stored = np.frombuffer(raw, dtype=np.uint8).reshape(4, 4) + # After flipud the True row should be at the last row (index 3), not row 0 + assert stored[3, 0] == 255 + assert stored[0, 0] == 0 + + +def test_view_from_python_flag_set_view(): + """set_view() sets _view_from_python briefly; it is False after push.""" + data = np.zeros((32, 32)) + x_axis = np.linspace(0.0, 32.0, 32) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(data, axes=[x_axis, None]) + + plot.set_view(x0=8.0, x1=24.0) + # After the push completes _view_from_python must be reset + assert plot._state["_view_from_python"] is False + + +def test_view_from_python_flag_reset_view(): + """reset_view() sets _view_from_python briefly; it is False after push.""" + plot = _make_plot2d() + plot.reset_view() + assert plot._state["_view_from_python"] is False + +