From c766c4705a420065514aced1bdb8484add187a5a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 12 Jul 2026 11:01:15 -0500 Subject: [PATCH] feat(plot2d): multi-image layers with per-layer cmap, clim, and alpha plot2d.add_layer(data, cmap=, alpha=, clim=, visible=) returns a Layer handle (set/set_data/remove); layers draw bottom-up above the base image with the base's exact fit-rect + zoom/pan transform, under axes, markers, and widgets, and composite in Canvas2D even over a WebGPU base. Per-layer LUT bitmaps are cached by (pixels, cmap, clim) so unchanged layers cost nothing on repaint; exportPNG captures layers for free. Heavy pixels ride dynamic geom keys (layer__b64): _GEOM_KEYS is now a property that unions the fixed base set with live layer keys, the Electron binary route ships each layer as its own PLOTBIN frame, and _spliceBinaryBytes scans by geom prefix instead of a hardcoded key list. resolve_pixel_tokens materialises layer base64 so save_html snapshots stay self-contained. Layers and tile mode exclude each other (enforced both directions). Layer data must match the base (H, W). 30 unit + 5 Playwright tests (incl. alpha-blend pixel assertions and the WebGPU-base + Canvas2D-layer readback case). --- anyplotlib/FIGURE_ESM.md | 78 +++++ anyplotlib/_electron.py | 17 +- anyplotlib/figure_esm.js | 179 ++++++++-- anyplotlib/plot2d/__init__.py | 5 +- anyplotlib/plot2d/_layer.py | 108 ++++++ anyplotlib/plot2d/_plot2d.py | 304 +++++++++++++++- anyplotlib/tests/test_plot2d/test_layers.py | 329 ++++++++++++++++++ .../test_plot2d/test_layers_playwright.py | 313 +++++++++++++++++ 8 files changed, 1300 insertions(+), 33 deletions(-) create mode 100644 anyplotlib/plot2d/_layer.py create mode 100644 anyplotlib/tests/test_plot2d/test_layers.py create mode 100644 anyplotlib/tests/test_plot2d/test_layers_playwright.py diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index 99c25bb8..ee99b848 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -63,6 +63,8 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | `drawScaleBar2d` / `drawColorbar2d` | 1360 / 1436 | | `_drawAxes2d` (ticks, labels, title) | 1491 | | `drawOverlay2d` / `drawMarkers2d` | 1629 / 1685 | +| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 1553 / 1577 / 1629 | +| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 675 / 706 | | **3D drawing**: `draw3d` | 1833 | | Event emission `_emitEvent` | 2031 | | 3D event handlers `_attachEvents3d` | 2059 | @@ -196,6 +198,82 @@ inverses of the blit geometry. --- +## Image layers (multi-image overlay) + +`Plot2D.add_layer(data, cmap=, alpha=, clim=, visible=)` composites a second +(third, …) scalar image OVER the base image in the same panel, each with its own +colormap / clim / alpha. Distinct from `set_overlay_mask` (single-colour boolean +mask). `Layer.set(...)`, `Layer.set_data(frame)`, `Layer.remove()`, +`Plot2D.layers`, `Plot2D.remove_layer(layer)`. **Layers and tile mode are mutually +exclusive** (guard raises in both directions: `add_layer` on a tiled plot, and +`enable_tile` / `set_data(tile=True)` / auto-tile on a layered plot). + +### State + transport (dynamic per-layer pixel keys) + +The layer *metadata* lives in `st.layers` (a list of small dicts on the light view +trait): + +``` +st.layers = [{ id, cmap, clim_min, clim_max, alpha, visible, + width, height, colormap_data, image_b64 }, …] # z-order +``` + +`image_b64` in each entry is the layer's pixels: a base64 string (Jupyter / +standalone / `save_html`) OR a `"\x00bin:"` change-token (Electron binary +transport). The JS reads pixels from this entry field on the base64 path. + +The *heavy pixel bytes* additionally ride a **DYNAMIC geometry key** +`layer__b64` — one per layer — mirroring how the base image `image_b64` rides +the geom channel. The dynamic-key mechanism: + +- **`Plot2D._GEOM_KEYS` is a PROPERTY** (not a plain frozenset): it returns the + fixed base set (`image_b64`, `colormap_data`, `overlay_mask_b64`, `detail_b64`) + UNION the current `layer__b64` keys. So `Figure._push` splits every layer's + pixels off the light view trait onto `panel__geom` and dedup-caches them + exactly like the base image; a removed layer's key drops out automatically. +- **`_electron._route_change`** ships each layer key as its own PLOTBIN frame: + `_is_binary_pixel_key(k)` matches `k in _BINARY_KEYS` OR `layer_*_b64`. The + binary frame header carries `{"geom": "panel__geom"}` and `key=layer__b64`, + so the receiver builds slot `panel__geom::layer__b64` (the same + `awi_state_binary` handler as the base image — already generic on `hdr.geom` + + `e.data.key`, no change needed there). +- **`resolve_pixel_tokens`** (cold path: `save_html` / standalone) materialises + real base64 for every `layer__b64` key AND the entry `image_b64` mirror, so a + snapshot is self-contained. +- **JS `_spliceBinaryBytes`** scans `__apl_pixbytes` by the `panel__geom::` + PREFIX (not the old hardcoded 3-key list) so it splices any `layer__b64_bytes` + into `p2._geomCache`. The per-slot binary listeners are registered only for the + fixed keys (`_registerBinaryPixelListeners`); dynamic layer bytes are consumed by + the **geom-JSON change handler**, which now also calls `_spliceBinaryBytes` — the + geom trait always re-pushes when layers change, so a layer's bytes converge into + the cache regardless of trait arrival order. + +### JS compositing (`_drawLayers2d`, called from `draw2d`) + +After the base image (Canvas2D blit OR WebGPU) and the overlay mask, and BEFORE +`_drawAxes2d` / markers / widgets, `_drawLayers2d(p, st, imgW, imgH, ctx, iw, ih)` +draws each **visible** layer bottom-up on `plotCanvas`: + +- `_layerBytes(st, layer)` prefers `layer__b64_bytes` (binary) over the entry + `image_b64` base64; +- `_layerBitmap(p, st, layer)` builds a LUT-colormapped RGBA `OffscreenCanvas`, + **cached per layer id** by `(pixel key, cmap, clim)` — rebuilt only when the + layer's data or appearance changes (a live scrub that only swaps one layer's + data rebuilds just that layer); +- it blits with the SAME fit-rect + zoom/pan transform as the base blit + (`_imgFitRect` + the `zoom>=1` window math) at `ctx.globalAlpha = layer.alpha`, + so zoom/pan track the base exactly. + +Because layers draw on `plotCanvas`, they sit UNDER `markersCanvas` / +`overlayCanvas` (z-order) and are captured by `exportPNG` for free (plotCanvas is +z1 in the composite). Over a WebGPU base the layers still composite in Canvas2D on +`plotCanvas` (which sits above the transparent `gpuCanvas`) — verified by +`test_layers_playwright.py::TestGpuBaseWithLayer`. Per-move perf: only the changed +layer's LUT bitmap is rebuilt (the box-loop is ~one pass over H×W uint8 → uint32, +comparable to the base image's `_buildLut32` blit). + +--- + ## 3D drawing (line ~1840) Orthographic projection; geometry b64-decoded and cached. `draw3d` sorts triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`). diff --git a/anyplotlib/_electron.py b/anyplotlib/_electron.py index 9f7edb5e..451d4921 100644 --- a/anyplotlib/_electron.py +++ b/anyplotlib/_electron.py @@ -32,9 +32,20 @@ # transport on its _encode_pixels "\x00bin:" token stays in the geom JSON # and the real bytes are never shipped, so the renderer can't decode it and the crisp # zoom tile never displays (you only ever see the downsampled overview base). +# +# Image LAYERS add DYNAMIC pixel keys ``layer__b64`` (one per layer) that also +# ride the geom channel and must ship as binary — they can't be enumerated in a +# fixed frozenset, so ``_is_binary_pixel_key`` matches them by name pattern. _BINARY_KEYS = frozenset({"image_b64", "overlay_mask_b64", "detail_b64"}) +def _is_binary_pixel_key(k: str) -> bool: + """True for a geom key whose value is raw image pixels worth shipping as a + PLOTBIN binary frame — the fixed base-image/mask/detail keys OR a dynamic + per-layer key ``layer__b64``.""" + return k in _BINARY_KEYS or (k.startswith("layer_") and k.endswith("_b64")) + + def _route_change(fig_id: str, name: str, value) -> None: """Forward ONE trait change to the host — as a raw PLOTBIN binary frame for a large image pixel trait (when binary transport is enabled), else as a @@ -61,13 +72,15 @@ def _route_change(fig_id: str, name: str, value) -> None: geom = json.loads(value) except Exception: geom = None - if isinstance(geom, dict) and any(k in geom for k in _BINARY_KEYS): + if isinstance(geom, dict) and any( + _is_binary_pixel_key(k) for k in geom): panel_id = name[len("panel_"):-len("_geom")] fig = _figures.get(fig_id) raw_tbl = getattr(fig, "_raw_pixels", None) sent_binary = False for k in list(geom.keys()): - if k not in _BINARY_KEYS or not isinstance(geom[k], str) or not geom[k]: + if (not _is_binary_pixel_key(k) + or not isinstance(geom[k], str) or not geom[k]): continue raw = None if geom[k].startswith("\x00bin:") and raw_tbl is not None: diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index dfebac16..03b13a62 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -676,8 +676,15 @@ function render({ model, el }) { const tbl = globalThis.__apl_pixbytes; if (!tbl) return false; let spliced = false; - for (const pixelKey of ['image_b64', 'overlay_mask_b64', 'detail_b64']) { - const b = tbl[`${geomTrait}::${pixelKey}`]; + // Fixed base-image / mask / detail keys PLUS every DYNAMIC layer pixel key + // (`layer__b64`) currently present in the side-table for this panel's + // geom trait — layer keys aren't enumerable ahead of time, so scan by the + // `::` prefix. Splices each into the cache as `_bytes`. + const prefix = `${geomTrait}::`; + for (const slot in tbl) { + if (!slot.startsWith(prefix)) continue; + const pixelKey = slot.slice(prefix.length); + const b = tbl[slot]; if (!b) continue; if (b.__aplSeq === undefined) { try { b.__aplSeq = ++_pixArrivalSeq; } catch (_) {} @@ -689,6 +696,25 @@ function render({ model, el }) { return spliced; } + // Register the per-pixel-key binary-bytes change listeners for a panel's geom + // trait. The FIXED base-image / mask / detail keys each get a dedicated + // listener (their slot fires `change:` when new bytes arrive). DYNAMIC + // layer keys (layer__b64) are NOT enumerable here — their bytes are picked + // up by the geom-JSON re-splice (the geom trait always re-pushes when layers + // change) and by _spliceBinaryBytes's prefix scan, so they need no per-slot + // listener. Shared by _createPanelDOM and _createInsetDOM. + function _registerBinaryPixelListeners(id, geomTrait) { + for (const pixelKey of ['image_b64', 'overlay_mask_b64', 'detail_b64']) { + const slot = `${geomTrait}::${pixelKey}`; + model.on(`change:${slot}`, () => { + const p2 = panels.get(id); + if (!p2) return; + _spliceBinaryBytes(p2, geomTrait); + if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } + }); + } + } + // ── layout application ─────────────────────────────────────────────────── function applyLayout() { if (_suppressLayoutUpdate) return; @@ -963,6 +989,11 @@ function render({ model, el }) { const rev = (p2.state && p2.state._geom_rev !== undefined) ? p2.state._geom_rev : ((p2._geomRev || 0) + 1); _loadGeom(p2, model.get(_geomTrait), rev); + // Re-splice any binary pixel bytes present for this panel — including + // DYNAMIC layer keys (layer__b64) that have no dedicated per-slot + // listener. The geom JSON always re-pushes when layers change, so this + // guarantees a layer's bytes are consumed regardless of arrival order. + _spliceBinaryBytes(p2, _geomTrait); if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } }); // Binary transport: raw pixel bytes for this panel arrive on a companion @@ -970,15 +1001,7 @@ function render({ model, el }) { // geomCache under `_bytes` so _imageBytes uses them (no atob), and // redraw. Fires INDEPENDENTLY of the geom JSON trait (which now carries only // the small LUT/flags), so pixels + LUT converge in the cache. - for (const pixelKey of ['image_b64', 'overlay_mask_b64', 'detail_b64']) { - const slot = `${_geomTrait}::${pixelKey}`; - model.on(`change:${slot}`, () => { - const p2 = panels.get(id); - if (!p2) return; - _spliceBinaryBytes(p2, _geomTrait); - if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } - }); - } + _registerBinaryPixelListeners(id, _geomTrait); } model.on(`change:panel_${id}_json`, () => { @@ -1119,6 +1142,8 @@ function render({ model, el }) { const rev = (p2.state && p2.state._geom_rev !== undefined) ? p2.state._geom_rev : ((p2._geomRev || 0) + 1); _loadGeom(p2, model.get(_geomTrait), rev); + // Re-splice binary bytes incl. dynamic layer keys — see _createPanelDOM. + _spliceBinaryBytes(p2, _geomTrait); if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } }); // Binary transport: raw pixel bytes for this panel arrive on a companion @@ -1126,15 +1151,7 @@ function render({ model, el }) { // geomCache under `_bytes` so _imageBytes uses them (no atob), and // redraw. Fires INDEPENDENTLY of the geom JSON trait (which now carries only // the small LUT/flags), so pixels + LUT converge in the cache. - for (const pixelKey of ['image_b64', 'overlay_mask_b64', 'detail_b64']) { - const slot = `${_geomTrait}::${pixelKey}`; - model.on(`change:${slot}`, () => { - const p2 = panels.get(id); - if (!p2) return; - _spliceBinaryBytes(p2, _geomTrait); - if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } - }); - } + _registerBinaryPixelListeners(id, _geomTrait); } model.on(`change:panel_${id}_json`, () => { @@ -1528,6 +1545,120 @@ function render({ model, el }) { return Math.max(0, Math.min(1, (now - b.t0) / _DETAIL_BLEND_MS)); } + // ── Image layers (multi-image overlay) ───────────────────────────────────── + // Raw single-channel pixel bytes for ONE layer. Prefers the BINARY bytes + // (`layer__b64_bytes`, spliced into the geomCache under `_bytes`) over + // the base64 string in the layer entry's `image_b64`. Returns {bytes, key} where + // `key` is a cheap identity for the "unchanged → skip rebuild" check. + function _layerBytes(st, layer) { + const pk = `layer_${layer.id}_b64`; + const raw = st[pk + '_bytes']; + if (raw && (raw instanceof Uint8Array || raw.byteLength !== undefined)) { + const u8 = raw instanceof Uint8Array ? raw : new Uint8Array(raw); + let key = layer.image_b64; // the "\x00bin:" content token + if (!key && u8.__aplSeq !== undefined) key = `binseq:${u8.__aplSeq}`; + if (!key) key = `layerbin:${u8.length}`; + return { bytes: u8, key }; + } + const b64 = layer.image_b64 || ''; + if (!b64 || b64.charCodeAt(0) === 0) return { bytes: null, key: '' }; // token, no bytes yet + try { + const bin = atob(b64); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return { bytes: u8, key: b64 }; + } catch (_) { return { bytes: null, key: '' }; } + } + + // Build (and cache on the panel) the LUT-colormapped RGBA OffscreenCanvas for + // one layer. Cached per layer id by (pixel key, cmap, clim) so a live scrub only + // rebuilds when the layer's data/appearance actually changes. Returns null when + // the bytes aren't available or the size is wrong. + function _layerBitmap(p, st, layer) { + const d = _layerBytes(st, layer); + const lw = layer.width || st.image_width; + const lh = layer.height || st.image_height; + if (!d.bytes || !lw || !lh || d.bytes.length < lw * lh) return null; + const cmap = layer.cmap || 'gray'; + const dMin = layer.clim_min, dMax = layer.clim_max; + const lutKey = [cmap, dMin, dMax].join('|'); + const cache = (p._layerBlit ||= {}); + const c = cache[layer.id]; + if (c && c.key === d.key && c.lutKey === lutKey && c.w === lw && c.h === lh) + return c.bitmap; + // Build a 256-entry uint32 LUT from the layer's own colormap + clim. The layer + // bytes are quantised over [clim_min, clim_max] (Python side), so code i maps + // linearly through that window; the LUT bakes clim→colour like the base path. + const cmapData = layer.colormap_data || st.colormap_data || []; + let cmapFlat = null; + if (cmapData.length === 256) { + cmapFlat = new Uint8Array(256 * 4); + for (let i = 0; i < 256; i++) { + cmapFlat[i*4] = cmapData[i][0]; cmapFlat[i*4+1] = cmapData[i][1]; + cmapFlat[i*4+2] = cmapData[i][2]; cmapFlat[i*4+3] = 255; + } + } + const lut = new Uint32Array(256); + const buf = new ArrayBuffer(4); const dv = new DataView(buf); + const u32 = new Uint32Array(buf); + for (let raw = 0; raw < 256; raw++) { + // Bytes are already the normalised index over the layer's clim (Python + // _normalize_image), so idx == raw — identity into the colormap. + const idx = raw; + if (cmapFlat) { + dv.setUint8(0, cmapFlat[idx*4]); dv.setUint8(1, cmapFlat[idx*4+1]); + dv.setUint8(2, cmapFlat[idx*4+2]); dv.setUint8(3, 255); + } else { dv.setUint8(0, idx); dv.setUint8(1, idx); dv.setUint8(2, idx); dv.setUint8(3, 255); } + lut[raw] = u32[0]; + } + const imgData = new ImageData(lw, lh); + const out32 = new Uint32Array(imgData.data.buffer); + for (let i = 0; i < lw * lh; i++) out32[i] = lut[d.bytes[i]]; + const oc = new OffscreenCanvas(lw, lh); + oc.getContext('2d').putImageData(imgData, 0, 0); + cache[layer.id] = { bitmap: oc, key: d.key, lutKey, w: lw, h: lh }; + return oc; + } + + // Composite every VISIBLE layer over the base image on plotCanvas, bottom-up, + // using the SAME zoom/pan/fit-rect transform as the base blit (so zoom/pan track + // exactly) at each layer's own globalAlpha. Layers draw AFTER the base + overlay + // mask and BEFORE axes/markers/widgets, so they sit under annotations + widgets. + // Works identically over a WebGPU base (which lives on the gpuCanvas beneath the + // transparent plotCanvas): the layers just paint onto plotCanvas above it. + function _drawLayers2d(p, st, imgW, imgH, ctx, iw, ih) { + const layers = st.layers; + if (!layers || !layers.length) { if (p._layerBlit) p._layerBlit = {}; return; } + // Drop cache entries for layers that were removed. + if (p._layerBlit) { + const live = new Set(layers.map(l => l.id)); + for (const k in p._layerBlit) if (!live.has(k)) delete p._layerBlit[k]; + } + const { x, y, w, h } = _imgFitRect(iw, ih, imgW, imgH); + const z = st.zoom, cx = st.center_x, cy = st.center_y; + for (const layer of layers) { + if (layer.visible === false) continue; + const bmp = _layerBitmap(p, st, layer); + if (!bmp) continue; + const alpha = layer.alpha != null ? layer.alpha : 1.0; + const bw = bmp.width, bh = bmp.height; + const kx = bw / iw, ky = bh / ih; // logical→layer-texel scale (==1 here) + ctx.save(); + ctx.globalAlpha = alpha; + ctx.imageSmoothingEnabled = false; + if (z >= 1.0) { + const visW = iw / z, visH = ih / z; + const sx = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2)); + const sy = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2)); + ctx.drawImage(bmp, sx * kx, sy * ky, visW * kx, visH * ky, x, y, w, h); + } else { + const dw = w * z, dh = h * z; + ctx.drawImage(bmp, 0, 0, bw, bh, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh); + } + ctx.restore(); + } + } + function _blit2d(p, bitmap, st, pw, ph, ctx, detailBitmap) { const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph); const zoom = st.zoom, cx = st.center_x, cy = st.center_y; @@ -1769,6 +1900,14 @@ function render({ model, el }) { ctx.restore(); } } + // ── Image layers ───────────────────────────────────────────────────────── + // Composited over the base image (+ overlay mask) at each layer's own + // colormap/clim/alpha, using the same zoom/pan transform, UNDER axes/markers/ + // widgets. For a WebGPU base the layers paint on the transparent plotCanvas + // that sits above the gpuCanvas. iw/ih == the logical image size here (tiling + // is forbidden when layers exist, so base_width is 0 and iw == image_width). + _drawLayers2d(p, st, imgW, imgH, ctx, iw, ih); + // Axes / scalebar / colorbar _drawAxes2d(p); drawScaleBar2d(p); diff --git a/anyplotlib/plot2d/__init__.py b/anyplotlib/plot2d/__init__.py index 27f2ee00..f4183b06 100644 --- a/anyplotlib/plot2d/__init__.py +++ b/anyplotlib/plot2d/__init__.py @@ -1,5 +1,6 @@ -"""anyplotlib.plot2d — 2-D image plot panel classes (Plot2D, PlotMesh).""" +"""anyplotlib.plot2d — 2-D image plot panel classes (Plot2D, PlotMesh, Layer).""" from anyplotlib.plot2d._plot2d import Plot2D from anyplotlib.plot2d._plotmesh import PlotMesh +from anyplotlib.plot2d._layer import Layer -__all__ = ["Plot2D", "PlotMesh"] +__all__ = ["Plot2D", "PlotMesh", "Layer"] diff --git a/anyplotlib/plot2d/_layer.py b/anyplotlib/plot2d/_layer.py new file mode 100644 index 00000000..eb248287 --- /dev/null +++ b/anyplotlib/plot2d/_layer.py @@ -0,0 +1,108 @@ +""" +plot2d/_layer.py +================ +Multi-image LAYER handle for :class:`~anyplotlib.plot2d.Plot2D`. + +A ``Layer`` is a second (third, …) scalar image drawn OVER the base image in the +same panel, each with its OWN colormap, clim (display range), and alpha. It is +composited client-side (JS ``draw2d``) with the exact same image→screen +transform as the base image, so zoom/pan track perfectly. This is distinct from +:meth:`Plot2D.set_overlay_mask`, which is a single-colour boolean mask. + +The pixel bytes travel exactly like the base image ``image_b64``: base64 inline +for Jupyter / standalone / ``save_html``, and a raw PLOTBIN frame under the +Electron binary transport (``APL_BINARY_TRANSPORT=1``). Each layer's pixels ride +a DYNAMIC geometry key ``layer__b64`` (see ``Plot2D._GEOM_KEYS`` / +``_electron._route_change`` / ``resolve_pixel_tokens``). + +A ``Layer`` is a thin handle: it owns no state itself. All state lives in the +parent plot's ``_state["layers"]`` list (metadata) plus the top-level +``layer__b64`` pixel key; the handle just forwards mutations to the plot. +""" +from __future__ import annotations + +import itertools + +_layer_counter = itertools.count(1) + + +def _next_layer_id() -> str: + return f"L{next(_layer_counter)}" + + +class Layer: + """Handle for one image layer on a :class:`Plot2D`. + + Do not construct directly — use :meth:`Plot2D.add_layer`. The handle stays + valid until :meth:`remove` (or :meth:`Plot2D.remove_layer`) is called. + + Attributes are read through the parent plot's state so they always reflect + the latest ``set``/``set_data``. + """ + + def __init__(self, plot, layer_id: str) -> None: + self._plot = plot + self._id = layer_id + self._removed = False + + # ── identity ────────────────────────────────────────────────────────────── + @property + def id(self) -> str: + return self._id + + def _entry(self) -> dict: + """Return this layer's metadata dict in the plot state (or raise).""" + if self._removed: + raise ValueError(f"layer {self._id!r} has been removed") + for lyr in self._plot._state.get("layers", []): + if lyr.get("id") == self._id: + return lyr + raise ValueError(f"layer {self._id!r} is no longer attached to its plot") + + # ── read-only views of the current state ────────────────────────────────── + @property + def cmap(self) -> str: + return self._entry().get("cmap", "gray") + + @property + def alpha(self) -> float: + return float(self._entry().get("alpha", 1.0)) + + @property + def visible(self) -> bool: + return bool(self._entry().get("visible", True)) + + @property + def clim(self): + e = self._entry() + return (e.get("clim_min"), e.get("clim_max")) + + # ── mutations (forwarded to the plot) ───────────────────────────────────── + def set(self, *, cmap=None, alpha=None, clim=None, visible=None) -> "Layer": + """Partial update of this layer's appearance (any subset of fields). + + ``cmap`` — colormap name. ``alpha`` — opacity in [0, 1]. ``clim`` — + ``(vmin, vmax)`` display range, or ``None`` to auto (data min/max). + ``visible`` — draw or hide. A pixel re-encode happens only when ``clim`` + changes (it re-quantises the cached frame); ``cmap``/``alpha``/``visible`` + are cheap LUT/compositor-only changes. + """ + self._plot._layer_set(self._id, cmap=cmap, alpha=alpha, clim=clim, + visible=visible) + return self + + def set_data(self, frame) -> "Layer": + """Replace this layer's image data (the live path — one push, no full + state rebuild). ``frame`` must match the base image ``(H, W)``.""" + self._plot._layer_set_data(self._id, frame) + return self + + def remove(self) -> None: + """Remove this layer from its plot.""" + if self._removed: + return + self._plot.remove_layer(self) + + def __repr__(self) -> str: + state = "removed" if self._removed else "active" + return f"Layer(id={self._id!r}, {state})" diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index b0f7e399..e4c5d109 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -60,8 +60,34 @@ class Plot2D(_BasePlot, _PanelMixin, _MarkerMixin): #: channel keys are eligible for the PLOTBIN binary route in _route_change — if it #: rode the light view trait instead, its "\x00bin:" token would never be resolved #: to bytes and the crisp zoom tile would never render (only the overview shows). - _GEOM_KEYS = frozenset({"image_b64", "colormap_data", "overlay_mask_b64", - "detail_b64"}) + #: + #: NB ``_GEOM_KEYS`` is a *property* (not a plain frozenset) because image LAYERS + #: add DYNAMIC pixel keys ``layer__b64`` — one per active layer — that must + #: also ride the geom channel (dedup-cached + PLOTBIN-eligible), exactly like the + #: base image. The property returns the fixed base set UNION the current layer + #: pixel keys, so ``Figure._push`` splits every layer's pixels off the light view + #: trait and the binary route recognises them. The consumers only ever iterate the + #: returned set, so a set-typed property is a drop-in for the old frozenset. + _BASE_GEOM_KEYS = frozenset({"image_b64", "colormap_data", "overlay_mask_b64", + "detail_b64"}) + + @property + def _GEOM_KEYS(self) -> frozenset: + layer_keys = self._layer_pixel_keys() + if not layer_keys: + return self._BASE_GEOM_KEYS + return self._BASE_GEOM_KEYS | layer_keys + + @staticmethod + def _layer_pixel_key(layer_id: str) -> str: + """The top-level state / geom key holding one layer's raw pixel bytes.""" + return f"layer_{layer_id}_b64" + + def _layer_pixel_keys(self) -> frozenset: + """The pixel geom keys for every currently-attached layer.""" + return frozenset( + self._layer_pixel_key(lyr["id"]) + for lyr in self._state.get("layers", [])) # Logical image bigger than this (either side) uses the tile backend under # tile="auto": it sends a downsampled OVERVIEW as the base + streams a hi-res @@ -267,6 +293,12 @@ def __init__(self, data, # a re-sampled tile even at the same size/region "overlay_widgets": [], "markers": [], + # Image LAYERS (see add_layer): each entry is a small metadata dict + # {id, cmap, clim_min, clim_max, alpha, visible, width, height, image_b64} + # drawn bottom-up OVER the base image. The heavy pixel bytes for a layer + # ride the DYNAMIC geom key layer__b64 (see _GEOM_KEYS); image_b64 in + # the entry is the base64 / "\x00bin:" TOKEN mirror the JS reads. + "layers": [], "pointer_settled_ms": 0, "pointer_settled_delta": 4, # Transparent mask overlay (set via set_overlay_mask) @@ -303,6 +335,13 @@ def __init__(self, data, # actual render path, not just the requested gpu_mode. self._gpu_active: bool = False + # Image layers (see add_layer). ``_layers`` holds the Layer handles in + # z-order; ``_layer_raw`` caches each layer's display-oriented raw frame + # (keyed by layer id) so a clim change can re-quantise without the caller + # re-supplying data — the layer analogue of ``self._data`` + ``set_clim``. + self._layers: list = [] + self._layer_raw: dict = {} + @property def gpu_active(self) -> bool: """True when this image is being rendered by the WebGPU path (reported by @@ -398,6 +437,12 @@ def enable_tile(self, backend=None, integration_method: str = "mean", (``"mean"|"subsample"|"max"``). ``None`` keeps the current setting (default ``"mean"`` from construction). Use ``"subsample"`` to restore the old fast nearest-neighbour path when a full-frame area-mean is too expensive.""" + if self._state.get("layers"): + raise RuntimeError( + "enable_tile is not supported on a plot with image layers — tile " + "mode streams detail tiles of a single image and cannot composite " + "independent layers. Remove all layers first (remove_layer), or " + "keep the plot untiled.") from anyplotlib.plot2d._tile_backend import as_tile_backend if backend is not None: self._tile_backend = as_tile_backend(backend, origin=self._origin) @@ -688,16 +733,45 @@ def resolve_pixel_tokens(self, d: dict) -> dict: base64 (materialised from the Figure's ``_raw_pixels`` side-table), in place, and return *d*. Used by the COLD paths (save_html / standalone) that have no PLOTBIN channel and need the pixels inline. A no-op for a - normal base64 string. See :meth:`to_state_dict`.""" + normal base64 string. See :meth:`to_state_dict`. + + Also materialises every LAYER's pixels: each layer has a top-level geom + key ``layer__b64`` AND a mirror ``image_b64`` field inside its + ``layers`` entry — both are resolved so a ``save_html`` snapshot of a + layered plot is self-contained.""" import base64 fig = getattr(self, "_fig", None) raw_tbl = getattr(fig, "_raw_pixels", None) - for key in ("image_b64", "overlay_mask_b64"): - val = d.get(key) + + def _resolve(store_key: str, val): if not (isinstance(val, str) and val.startswith("\x00bin:")): - continue - raw = raw_tbl.get((self._id, key)) if raw_tbl is not None else None - d[key] = base64.b64encode(raw).decode("ascii") if raw else "" + return val + raw = raw_tbl.get((self._id, store_key)) if raw_tbl is not None else None + return base64.b64encode(raw).decode("ascii") if raw else "" + + for key in ("image_b64", "overlay_mask_b64"): + if d.get(key) is not None: + d[key] = _resolve(key, d.get(key)) + # Layer pixels: top-level layer__b64 key + the mirror in the entry. + # ``d`` is a SHALLOW copy of ``_state`` (to_state_dict), so ``d["layers"]`` + # shares the entry dicts with the live state. Rebuild the list with COPIED + # entries before rewriting ``image_b64`` so materialising base64 for a cold + # snapshot never mutates a live "\x00bin:" token back into base64 (which + # would corrupt the binary-transport dedup on the next push). + layers = d.get("layers") + if layers: + new_layers = [] + for lyr in layers: + lid = lyr.get("id") + if lid is None: + new_layers.append(lyr) + continue + pk = self._layer_pixel_key(lid) + resolved = _resolve(pk, d.get(pk, lyr.get("image_b64"))) + if pk in d: + d[pk] = resolved + new_layers.append({**lyr, "image_b64": resolved}) + d["layers"] = new_layers return d # ------------------------------------------------------------------ @@ -766,7 +840,15 @@ def set_data(self, data: np.ndarray, # RGB frames don't tile — fall through to the plain path. # Per-call `tile` override wins; else the construction preference decides. eff_pref = self._tile_pref if tile is None else tile - want_tile = (not is_rgb) and ( + # Layers + tiling are mutually exclusive (a layer can't be tiled against + # the same streamed view). A plot with layers ALWAYS takes the plain path: + # honour an explicit tile=True by refusing, and never auto-enable. + _has_layers = bool(self._state.get("layers")) + if _has_layers and tile is True: + raise RuntimeError( + "set_data(tile=True) is not supported on a plot with image layers " + "— remove all layers (remove_layer) before enabling tile mode.") + want_tile = (not is_rgb) and (not _has_layers) and ( eff_pref is True or (eff_pref == "auto" and max(h, w) > self.TILE_THRESHOLD)) # tile=False FORCES the plain path even on an already-tiled plot (the caller is @@ -1010,6 +1092,210 @@ def set_overlay_mask(self, mask: "np.ndarray | None", self._state["overlay_mask_alpha"] = alpha self._push() + # ------------------------------------------------------------------ + # Image layers (multi-image overlay) + # ------------------------------------------------------------------ + @property + def layers(self) -> list: + """The list of :class:`~anyplotlib.plot2d._layer.Layer` handles, in + z-order (index 0 drawn first, above the base image).""" + return list(self._layers) + + def _norm_clim(self, clim): + """Parse a ``(vmin, vmax)`` clim → ``(lo, hi)`` floats, or ``(None, None)`` + for auto. A degenerate (non-increasing) range falls back to auto.""" + if clim is None: + return (None, None) + try: + lo, hi = float(clim[0]), float(clim[1]) + except (TypeError, ValueError, IndexError): + return (None, None) + if not (hi > lo): + return (None, None) + return (lo, hi) + + def _encode_layer_pixels(self, layer_id: str, frame: np.ndarray, clim): + """Quantise a 2-D layer frame → uint8, store the bytes under the layer's + geom key (base64 or PLOTBIN token via _encode_pixels), and return + ``(token, height, width, vmin, vmax)``. + + Requires ``frame.shape == (image_height, image_width)`` — raises + ``ValueError`` otherwise (a layer must line up pixel-for-pixel with the + base image, since it shares the base's image→screen transform).""" + arr = np.asarray(frame) + ih = self._state["image_height"] + iw = self._state["image_width"] + if arr.ndim != 2: + raise ValueError( + f"layer data must be 2-D (H x W), got shape {arr.shape}") + if arr.shape != (ih, iw): + raise ValueError( + f"layer data shape {arr.shape} does not match the base image " + f"({ih} x {iw}); a layer must be the same size as the base image") + # origin='lower' flips the base data for display; flip layers to match so + # they line up with the base pixels. + if self._origin == "lower": + arr = np.flipud(arr) + lo, hi = self._norm_clim(clim) + parsed = (lo, hi) if lo is not None else None + img_u8, vmin, vmax = _normalize_image(arr, clim=parsed) + pk = self._layer_pixel_key(layer_id) + token = self._encode_pixels(pk, np.ascontiguousarray(img_u8)) + # Cache the (display-oriented) raw frame so a later clim change can + # re-quantise without the caller re-supplying data (mirrors self._data + # for the base image + set_clim). + self._layer_raw[layer_id] = arr + return token, int(arr.shape[0]), int(arr.shape[1]), vmin, vmax + + def add_layer(self, data, *, cmap: str = "magma", alpha: float = 0.5, + clim=None, visible: bool = True): + """Add an image LAYER drawn over the base image. + + Each layer has its OWN colormap, display range (``clim``), and opacity + (``alpha``), and is composited on top of the base image (and previously + added layers) with the SAME zoom/pan transform, so it tracks the base + exactly. This is the multi-image overlay primitive; for a single-colour + boolean mask use :meth:`set_overlay_mask` instead. + + Parameters + ---------- + data : ndarray, shape (H, W) + 2-D scalar array, the same size as the base image. Normalised to + uint8 via ``clim`` → LUT exactly like the base image. A shape + mismatch raises ``ValueError``. + cmap : str, optional + Colormap name (default ``"magma"``). + alpha : float, optional + Opacity in [0, 1] (default ``0.5``). + clim : (vmin, vmax) or None, optional + Display range; ``None`` (default) auto-scales to the data min/max. + visible : bool, optional + Whether the layer is drawn (default ``True``). + + Returns + ------- + Layer + A handle with ``.set(...)`` / ``.set_data(frame)`` / ``.remove()``. + + Raises + ------ + RuntimeError + If the plot is in TILE mode — layers are incompatible with tiling + (a layer would have to be tiled independently against the same view). + Load the plot with ``tile=False`` to use layers. + ValueError + If ``data`` is not 2-D or does not match the base image size. + """ + if self._tile_on: + raise RuntimeError( + "add_layer is not supported in tile mode — a tiled plot streams " + "detail tiles of a single image and cannot composite independent " + "layers. Create the plot with tile=False to use layers.") + from anyplotlib.plot2d._layer import Layer, _next_layer_id + alpha = float(alpha) + if not (0.0 <= alpha <= 1.0): + raise ValueError(f"alpha must be in [0, 1], got {alpha!r}") + layer_id = _next_layer_id() + token, h, w, vmin, vmax = self._encode_layer_pixels(layer_id, data, clim) + entry = { + "id": layer_id, + "cmap": cmap, + "clim_min": vmin, + "clim_max": vmax, + "alpha": alpha, + "visible": bool(visible), + "width": w, + "height": h, + # The layer's colormap LUT (256 [r,g,b]); the JS composites via this. + "colormap_data": _build_colormap_lut(cmap), + # Mirror of the pixel token so the JS reads bytes for this layer even + # when it only sees the light `layers` list (the heavy bytes ride the + # geom key layer__b64, spliced into the panel state by the binary / + # geom machinery under the same key). + "image_b64": token, + } + # Also stash the pixels as a TOP-LEVEL geom key so Figure._push splits it + # onto the geom channel (dedup-cached) and the binary route ships it. + self._state[self._layer_pixel_key(layer_id)] = token + self._state.setdefault("layers", []).append(entry) + layer = Layer(self, layer_id) + self._layers.append(layer) + self._push() + return layer + + def _layer_entry(self, layer_id: str) -> dict: + for lyr in self._state.get("layers", []): + if lyr.get("id") == layer_id: + return lyr + raise ValueError(f"no layer {layer_id!r} on this plot") + + def _layer_set(self, layer_id: str, *, cmap=None, alpha=None, clim=None, + visible=None) -> None: + entry = self._layer_entry(layer_id) + if cmap is not None: + entry["cmap"] = cmap + entry["colormap_data"] = _build_colormap_lut(cmap) + if alpha is not None: + a = float(alpha) + if not (0.0 <= a <= 1.0): + raise ValueError(f"alpha must be in [0, 1], got {alpha!r}") + entry["alpha"] = a + if visible is not None: + entry["visible"] = bool(visible) + if clim is not None: + # A clim change must RE-QUANTISE the cached frame (like set_clim on the + # base), because the 8-bit codes are quantised over the old range and + # can't be re-windowed past it in the LUT alone. We keep the raw frame + # in _layer_raw for exactly this. + raw = self._layer_raw.get(layer_id) + lo, hi = self._norm_clim(clim) + if raw is not None: + parsed = (lo, hi) if lo is not None else None + img_u8, vmin, vmax = _normalize_image(raw, clim=parsed) + pk = self._layer_pixel_key(layer_id) + token = self._encode_pixels(pk, np.ascontiguousarray(img_u8)) + entry["clim_min"], entry["clim_max"] = vmin, vmax + entry["image_b64"] = token + self._state[pk] = token + else: + # No cached frame (unusual) — just record the requested endpoints. + entry["clim_min"], entry["clim_max"] = lo, hi + self._push() + + def _layer_set_data(self, layer_id: str, frame) -> None: + entry = self._layer_entry(layer_id) + # Re-quantise over the layer's CURRENT clim so a live frame keeps its + # contrast window (None endpoints → auto per-frame, matching the base). + cur_clim = (entry.get("clim_min"), entry.get("clim_max")) + clim = cur_clim if cur_clim[0] is not None else None + token, h, w, vmin, vmax = self._encode_layer_pixels(layer_id, frame, clim) + entry["width"], entry["height"] = w, h + entry["clim_min"], entry["clim_max"] = vmin, vmax + entry["image_b64"] = token + self._state[self._layer_pixel_key(layer_id)] = token + self._push() + + def remove_layer(self, layer) -> None: + """Remove *layer* (a :class:`Layer` handle or its id string).""" + from anyplotlib.plot2d._layer import Layer + layer_id = layer.id if isinstance(layer, Layer) else str(layer) + self._state["layers"] = [ + l for l in self._state.get("layers", []) if l.get("id") != layer_id] + pk = self._layer_pixel_key(layer_id) + self._state.pop(pk, None) + self._layer_raw.pop(layer_id, None) + # Drop the raw-pixel side-table entry so a removed layer's bytes aren't + # retained (and can't be re-shipped). + fig = getattr(self, "_fig", None) + raw_tbl = getattr(fig, "_raw_pixels", None) + if raw_tbl is not None: + raw_tbl.pop((self._id, pk), None) + for h in list(self._layers): + if h.id == layer_id: + h._removed = True + self._layers.remove(h) + self._push() + # ------------------------------------------------------------------ # Display settings # ------------------------------------------------------------------ diff --git a/anyplotlib/tests/test_plot2d/test_layers.py b/anyplotlib/tests/test_plot2d/test_layers.py new file mode 100644 index 00000000..ef426d1e --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_layers.py @@ -0,0 +1,329 @@ +"""Multi-image LAYERS for Plot2D — Python (no browser) contract. + +Covers the state schema after add/set/set_data/remove, z-order, shape +validation, tile-mode guards (both directions), the binary-transport wire route ++ ``resolve_pixel_tokens`` (cold path materialisation), and ``figure_state`` +inclusion. The browser-side compositing (blended pixels, exportPNG capture, +visibility toggle, live set_data) is covered by ``test_layers_playwright.py``. +""" +from __future__ import annotations + +import base64 +import json + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.plot2d import Layer + + +def _imshow(n=32, val=0.0): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((n, n), val, np.float32), cmap="gray", vmin=0, vmax=1, + gpu=False) + return fig, p + + +class TestAddLayerState: + def test_add_layer_returns_handle_and_records_state(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32), cmap="magma", + alpha=0.5, clim=(0, 1)) + assert isinstance(lyr, Layer) + assert p.layers == [lyr] + entries = p._state["layers"] + assert len(entries) == 1 + e = entries[0] + assert e["id"] == lyr.id + assert e["cmap"] == "magma" + assert e["alpha"] == 0.5 + assert e["visible"] is True + assert e["width"] == 32 and e["height"] == 32 + assert (e["clim_min"], e["clim_max"]) == (0.0, 1.0) + # Pixel bytes present: base64 in the entry (non-binary host). + assert e["image_b64"] and not e["image_b64"].startswith("\x00bin:") + # Colormap LUT baked in for the JS compositor. + assert len(e["colormap_data"]) == 256 + + def test_pixel_key_is_a_geom_key(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + pk = p._layer_pixel_key(lyr.id) + # The heavy layer pixels ride a dynamic geom key so Figure._push splits + # them off the light view trait and the binary route ships them. + assert pk in p._GEOM_KEYS + assert pk in p.to_state_dict() + + def test_default_cmap_and_alpha(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + assert lyr.cmap == "magma" + assert lyr.alpha == 0.5 + assert lyr.visible is True + + def test_auto_clim_when_none(self): + _fig, p = _imshow() + data = np.linspace(0, 4, 32 * 32, dtype=np.float32).reshape(32, 32) + lyr = p.add_layer(data, clim=None) + assert lyr.clim == (0.0, 4.0) + + def test_add_layer_alpha_out_of_range_raises(self): + _fig, p = _imshow() + with pytest.raises(ValueError): + p.add_layer(np.ones((32, 32), np.float32), alpha=1.5) + + +class TestZOrder: + def test_layers_kept_in_add_order(self): + _fig, p = _imshow() + a = p.add_layer(np.ones((32, 32), np.float32), cmap="magma") + b = p.add_layer(np.ones((32, 32), np.float32), cmap="viridis") + c = p.add_layer(np.ones((32, 32), np.float32), cmap="plasma") + assert [l.id for l in p.layers] == [a.id, b.id, c.id] + assert [e["id"] for e in p._state["layers"]] == [a.id, b.id, c.id] + + def test_ids_unique(self): + _fig, p = _imshow() + ids = {p.add_layer(np.ones((32, 32), np.float32)).id for _ in range(5)} + assert len(ids) == 5 + + +class TestSet: + def test_partial_update_each_field(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32), cmap="magma", + alpha=0.5, clim=(0, 1)) + lyr.set(cmap="viridis") + assert lyr.cmap == "viridis" + assert p._state["layers"][0]["colormap_data"][0] is not None + lyr.set(alpha=0.9) + assert lyr.alpha == 0.9 + lyr.set(visible=False) + assert lyr.visible is False + # unaffected fields unchanged + assert lyr.cmap == "viridis" and lyr.alpha == 0.9 + + def test_set_clim_requantises(self): + _fig, p = _imshow() + data = np.full((32, 32), 5.0, np.float32) + lyr = p.add_layer(data, clim=(0, 10)) + tok0 = p._state["layers"][0]["image_b64"] + lyr.set(clim=(0, 5)) + assert lyr.clim == (0.0, 5.0) + tok1 = p._state["layers"][0]["image_b64"] + # A value of 5 over [0,10] is code ~127; over [0,5] it's ~255 → the bytes + # (and thus the base64 / token) must change. + assert tok0 != tok1 + + def test_set_alpha_out_of_range_raises(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + with pytest.raises(ValueError): + lyr.set(alpha=-0.1) + + def test_set_returns_self_for_chaining(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + assert lyr.set(alpha=0.3).set(cmap="viridis") is lyr + + +class TestSetData: + def test_set_data_swaps_pixels_one_push(self): + _fig, p = _imshow() + lyr = p.add_layer(np.zeros((32, 32), np.float32), clim=(0, 1)) + tok0 = p._state["layers"][0]["image_b64"] + lyr.set_data(np.ones((32, 32), np.float32)) + tok1 = p._state["layers"][0]["image_b64"] + assert tok0 != tok1 + assert p._state["layers"][0]["width"] == 32 + + def test_set_data_keeps_clim_window(self): + _fig, p = _imshow() + lyr = p.add_layer(np.full((32, 32), 1.0, np.float32), clim=(0, 10)) + assert lyr.clim == (0.0, 10.0) + lyr.set_data(np.full((32, 32), 2.0, np.float32)) + # The layer keeps its clim window across a live frame swap. + assert lyr.clim == (0.0, 10.0) + + def test_set_data_auto_clim_tracks_frame(self): + _fig, p = _imshow() + lyr = p.add_layer(np.zeros((32, 32), np.float32), clim=None) + lyr.set_data(np.linspace(0, 8, 32 * 32, np.float32).reshape(32, 32)) + assert lyr.clim == (0.0, 8.0) + + def test_set_data_shape_mismatch_raises(self): + _fig, p = _imshow() + lyr = p.add_layer(np.zeros((32, 32), np.float32)) + with pytest.raises(ValueError): + lyr.set_data(np.zeros((16, 16), np.float32)) + + +class TestShapeValidation: + def test_wrong_shape_raises(self): + _fig, p = _imshow(n=32) + with pytest.raises(ValueError, match="does not match the base image"): + p.add_layer(np.ones((16, 16), np.float32)) + + def test_non_2d_raises(self): + _fig, p = _imshow(n=32) + with pytest.raises(ValueError): + p.add_layer(np.ones((32, 32, 3), np.float32)) + + +class TestRemove: + def test_remove_via_handle(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + pk = p._layer_pixel_key(lyr.id) + lyr.remove() + assert p.layers == [] + assert p._state["layers"] == [] + assert pk not in p.to_state_dict() + # A removed layer's key drops out of _GEOM_KEYS. + assert pk not in p._GEOM_KEYS + + def test_remove_via_plot(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + p.remove_layer(lyr) + assert p.layers == [] + + def test_remove_only_targeted_layer(self): + _fig, p = _imshow() + a = p.add_layer(np.ones((32, 32), np.float32), cmap="magma") + b = p.add_layer(np.ones((32, 32), np.float32), cmap="viridis") + p.remove_layer(a) + assert [l.id for l in p.layers] == [b.id] + assert p._layer_pixel_key(a.id) not in p.to_state_dict() + assert p._layer_pixel_key(b.id) in p.to_state_dict() + + def test_removed_handle_raises_on_use(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + lyr.remove() + with pytest.raises(ValueError): + lyr.set(alpha=0.5) + + def test_double_remove_is_noop(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + lyr.remove() + lyr.remove() # no crash + + +class TestTileGuards: + def test_add_layer_on_tiled_plot_raises(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((2048, 2048), np.float32), vmin=0, vmax=1, gpu=False) + assert p._tile_on + with pytest.raises(RuntimeError, match="tile mode"): + p.add_layer(np.ones((2048, 2048), np.float32)) + + def test_enable_tile_on_layered_plot_raises(self): + _fig, p = _imshow(n=64) + p.add_layer(np.ones((64, 64), np.float32)) + with pytest.raises(RuntimeError, match="image layers"): + p.enable_tile(np.zeros((2048, 2048), np.float32)) + + def test_set_data_tile_true_on_layered_raises(self): + _fig, p = _imshow(n=64) + p.add_layer(np.ones((64, 64), np.float32)) + with pytest.raises(RuntimeError, match="image layers"): + p.set_data(np.zeros((2048, 2048), np.float32), tile=True) + + def test_large_set_data_on_layered_stays_plain(self): + _fig, p = _imshow(n=64) + p.add_layer(np.ones((64, 64), np.float32)) + # A large frame that would normally auto-enable tiling must stay plain. + p.set_data(np.zeros((2048, 2048), np.float32)) + assert p._tile_on is False + + +class TestBinaryTransportRoute: + """The layer pixel bytes must travel over BOTH transports: base64-in-JSON and + the Electron PLOTBIN binary path (dynamic layer__b64 keys).""" + + def test_binary_route_ships_layer_frame(self, monkeypatch): + monkeypatch.setenv("APL_BINARY_TRANSPORT", "1") + from anyplotlib import _electron + + fig, ax = apl.subplots(1, 1, figsize=(200, 200)) + p = ax.imshow(np.zeros((16, 16), np.float32), cmap="gray", + vmin=0, vmax=1, gpu=False) + fid = _electron.register(fig) + + captured = {"bin": [], "txt": []} + monkeypatch.setattr(_electron, "emit", + lambda o: captured["txt"].append(o)) + monkeypatch.setattr(_electron, "emit_binary", + lambda f, k, h, pl: captured["bin"].append((k, h, len(pl)))) + + lyr = p.add_layer(np.full((16, 16), 0.7, np.float32), + cmap="magma", clim=(0, 1)) + pk = p._layer_pixel_key(lyr.id) + # In binary mode the layer entry / top-level key hold a bin TOKEN, not b64. + sd = p.to_state_dict() + assert sd[pk].startswith("\x00bin:") + assert (p._id, pk) in fig._raw_pixels + + # Drive the geom trait change the way Figure._push would, carrying the + # slimmed geom with the layer token. + gname = f"panel_{p._id}_geom" + _electron._route_change(fid, gname, json.dumps({pk: sd[pk]})) + keys = [k for (k, _h, _n) in captured["bin"]] + assert pk in keys, f"layer pixel frame not shipped as binary ({keys})" + # The binary frame header names the geom trait so the renderer routes it. + hdr = next(h for (k, h, _n) in captured["bin"] if k == pk) + assert hdr.get("geom") == gname + + def test_resolve_pixel_tokens_materialises_layers(self, monkeypatch): + monkeypatch.setenv("APL_BINARY_TRANSPORT", "1") + from anyplotlib import _electron + + fig, ax = apl.subplots(1, 1, figsize=(200, 200)) + p = ax.imshow(np.zeros((16, 16), np.float32), cmap="gray", + vmin=0, vmax=1, gpu=False) + _electron.register(fig) + lyr = p.add_layer(np.full((16, 16), 0.7, np.float32), clim=(0, 1)) + pk = p._layer_pixel_key(lyr.id) + + d = p.to_state_dict() + assert d[pk].startswith("\x00bin:") # token before resolve + p.resolve_pixel_tokens(d) + # After resolve the top-level key AND the entry mirror hold real base64. + assert not d[pk].startswith("\x00bin:") and len(d[pk]) > 0 + entry = d["layers"][0] + assert not entry["image_b64"].startswith("\x00bin:") + assert entry["image_b64"] == d[pk] + # And it decodes to 16*16 uint8 bytes. + assert len(base64.b64decode(d[pk])) == 16 * 16 + + +class TestFigureStateRoundTrip: + def test_figure_state_includes_layers(self): + from anyplotlib.embed import figure_state + fig, ax = apl.subplots(1, 1, figsize=(200, 200)) + p = ax.imshow(np.zeros((16, 16), np.float32), cmap="gray", + vmin=0, vmax=1, gpu=False) + lyr = p.add_layer(np.full((16, 16), 0.7, np.float32), + cmap="magma", alpha=0.5, clim=(0, 1)) + st = figure_state(fig) + pj = json.loads(st[f"panel_{p._id}_json"]) + assert "layers" in pj and len(pj["layers"]) == 1 + assert pj["layers"][0]["cmap"] == "magma" + # The layer's pixels are inline (base64) somewhere the JS can reach: either + # the geom trait's top-level key or the entry mirror. + gj = json.loads(st[f"panel_{p._id}_geom"]) + pk = p._layer_pixel_key(lyr.id) + assert pk in gj or len(pj["layers"][0]["image_b64"]) > 0 + + def test_save_html_self_contained(self, tmp_path): + fig, ax = apl.subplots(1, 1, figsize=(200, 200)) + p = ax.imshow(np.zeros((16, 16), np.float32), cmap="gray", + vmin=0, vmax=1, gpu=False) + p.add_layer(np.full((16, 16), 0.7, np.float32), clim=(0, 1)) + out = fig.save_html(tmp_path / "layered.html") + html = out.read_text(encoding="utf-8") + # No unresolved binary tokens leak into the standalone HTML. + assert "\x00bin:" not in html diff --git a/anyplotlib/tests/test_plot2d/test_layers_playwright.py b/anyplotlib/tests/test_plot2d/test_layers_playwright.py new file mode 100644 index 00000000..a86310a1 --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_layers_playwright.py @@ -0,0 +1,313 @@ +"""Browser (Playwright) tests for multi-image LAYERS. + +Exercised through the public ``mount()`` entry point exactly as an Electron / +SpyDE app would. We assert on real composited pixels via ``exportPNG`` (which +captures the plotCanvas where layers draw): + +* two constant-value images with distinct colormaps + ``alpha=0.5`` → the blended + colour ``base*(1-a) + layer*a`` (Canvas2D source-over), computed from the LUTs; +* ``visible=False`` removes the layer's contribution; +* ``layer.set_data`` swaps the layer pixels; +* a large image on the WebGPU base path with a Canvas2D layer over it (skips + cleanly without a WebGPU adapter); +* ``exportPNG`` includes the layer contribution. + +Bundled Chromium (the default ``_pw_browser``) has NO WebGPU, so the base image +renders on Canvas2D there; the GPU-base case uses ``_pw_gpu_browser``. +""" +from __future__ import annotations + +import base64 +import json +import pathlib +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.embed import esm_path, figure_state +from anyplotlib.tests._png_utils import decode_png + + +_MOUNT_PAGE = """ + + +
+ +""" + + +def _make_page(browser, fig): + html = (_MOUNT_PAGE + .replace("__STATE__", json.dumps(figure_state(fig))) + .replace("__ESM__", json.dumps(esm_path().read_text(encoding="utf-8")))) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + page = browser.new_page() + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=15_000) + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + return page, tmp + + +@pytest.fixture +def mount_page(_pw_browser): + pages, paths = [], [] + + def _open(fig): + page, tmp = _make_page(_pw_browser, fig) + pages.append(page) + paths.append(tmp) + return page + + yield _open + for p in pages: + try: + p.close() + except Exception: + pass + for f in paths: + f.unlink(missing_ok=True) + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _export(page, opts=None): + return page.evaluate( + """(opts) => window._handle.exportPNG(opts || {}) + .then(r => ({dataUrl: r.dataUrl, width: r.width, height: r.height})) + .catch(e => ({error: String(e && e.message || e)}))""", + opts or {}, + ) + + +def _decode(data_url): + assert data_url.startswith("data:image/png;base64,"), data_url[:40] + return decode_png(base64.b64decode(data_url.split(",", 1)[1])) + + +def _flush(page): + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + + +def _center_rgb(arr): + cy, cx = arr.shape[0] // 2, arr.shape[1] // 2 + return arr[cy, cx, :3].astype(np.int32) + + +def _count_near(arr, rgb, tol=14): + d = np.abs(arr[..., :3].astype(np.int32) - np.asarray(rgb, np.int32)) + return int(((d <= tol).all(axis=-1)).sum()) + + +def _lut_endpoint(cmap, idx=255): + """The [r,g,b] a constant image at the clim top/bottom maps to for *cmap*.""" + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((4, 4), np.float32), cmap=cmap, vmin=0, vmax=1, gpu=False) + return list(p.to_state_dict()["colormap_data"][idx]) + + +# ── tests ──────────────────────────────────────────────────────────────────── + +class TestLayerBlend: + def test_two_layer_alpha_blend_pixels(self, mount_page): + """A constant base at vmax (gray→white) with a constant magma layer at + vmax and alpha=0.5 must composite to base*(1-a) + layer*a at the centre.""" + base_cmap, layer_cmap, alpha = "gray", "magma", 0.5 + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((32, 32), 1.0, np.float32), + cmap=base_cmap, vmin=0.0, vmax=1.0, gpu=False) + p.add_layer(np.full((32, 32), 1.0, np.float32), + cmap=layer_cmap, alpha=alpha, clim=(0, 1)) + + base_hi = np.array(_lut_endpoint(base_cmap, 255), np.float64) # white + layer_hi = np.array(_lut_endpoint(layer_cmap, 255), np.float64) # pale yellow + expected = np.round(base_hi * (1 - alpha) + layer_hi * alpha) + + page = mount_page(fig) + res = _export(page) + assert "error" not in res, res.get("error") + arr = _decode(res["dataUrl"]) + + centre = _center_rgb(arr) + d = int(np.max(np.abs(centre - expected))) + assert d <= 16, ( + f"centre {centre.tolist()} != blend {expected.tolist()} (dmax={d}); " + f"base_hi={base_hi.tolist()} layer_hi={layer_hi.tolist()}" + ) + # The blend colour must NOT equal the bare base (proves the layer drew). + assert int(np.max(np.abs(centre - base_hi))) > 16, ( + "centre equals the bare base — layer contribution missing" + ) + + def test_layer_visible_false_removes_contribution(self, mount_page): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((32, 32), 1.0, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + lyr = p.add_layer(np.full((32, 32), 1.0, np.float32), + cmap="magma", alpha=0.5, clim=(0, 1)) + page = mount_page(fig) + + with_layer = _center_rgb(_decode(_export(page)["dataUrl"])) + + # Hide the layer → drive the state update the way a live app does → repaint. + lyr.set(visible=False) + _set_panel(page, p) + _flush(page) + without_layer = _center_rgb(_decode(_export(page)["dataUrl"])) + + base_hi = np.array(_lut_endpoint("gray", 255), np.int32) + assert int(np.max(np.abs(without_layer - base_hi))) <= 16, ( + f"hiding the layer did not revert to the base ({without_layer.tolist()})" + ) + assert int(np.max(np.abs(with_layer - without_layer))) > 16, ( + "visible toggle produced no pixel change" + ) + + def test_layer_set_data_swaps_pixels(self, mount_page): + """Swapping the layer's data (low→high value) changes the composited + colour (the magma layer goes from dark to pale).""" + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.zeros((32, 32), np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + lyr = p.add_layer(np.zeros((32, 32), np.float32), + cmap="magma", alpha=0.6, clim=(0, 1)) + page = mount_page(fig) + before = _center_rgb(_decode(_export(page)["dataUrl"])) + + lyr.set_data(np.full((32, 32), 1.0, np.float32)) + _set_panel(page, p) + _flush(page) + after = _center_rgb(_decode(_export(page)["dataUrl"])) + + assert int(np.max(np.abs(after - before))) > 16, ( + f"set_data did not change the composite ({before.tolist()} → {after.tolist()})" + ) + + def test_exportpng_includes_layer(self, mount_page): + """exportPNG must capture the layer (it draws into plotCanvas, so it is + free) — a large fraction of the frame carries the blended colour.""" + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((32, 32), 1.0, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + p.add_layer(np.full((32, 32), 1.0, np.float32), + cmap="magma", alpha=0.5, clim=(0, 1)) + base_hi = np.array(_lut_endpoint("gray", 255), np.float64) + layer_hi = np.array(_lut_endpoint("magma", 255), np.float64) + blend = np.round(base_hi * 0.5 + layer_hi * 0.5) + + page = mount_page(fig) + arr = _decode(_export(page)["dataUrl"]) + assert _count_near(arr, blend) > 0.15 * arr.shape[0] * arr.shape[1], ( + "blended colour does not fill the image region in the export" + ) + + +def _set_panel(page, plot): + """Push the plot's CURRENT state into the mounted figure, splitting the geom + channel exactly like ``Figure._push`` does (base64-resolved pixels, since the + mount path has no PLOTBIN channel). Drives a live update as the app would.""" + pid = plot._id + state = plot.to_state_dict() + if hasattr(plot, "resolve_pixel_tokens"): + plot.resolve_pixel_tokens(state) + geom_keys = set(plot._GEOM_KEYS) + geom = {k: state.pop(k) for k in list(geom_keys) if k in state} + state["_geom_rev"] = (state.get("_geom_rev") or 0) + 1 + page.evaluate( + """(args) => { + const [pid, geom, view] = args; + window._handle.set('panel_' + pid + '_geom', JSON.stringify(geom)); + window._handle.setPanelState(pid, view); + }""", + [pid, geom, state], + ) + + +# ── GPU base + Canvas2D layer ──────────────────────────────────────────────── + +GPU_IMG_N = 1200 # > GPU_IMAGE_THRESHOLD (1 Mpx) → WebGPU base in auto mode + + +@pytest.fixture +def gpu_mount_page(_pw_gpu_browser): + pages, paths = [], [] + + def _open(fig, expect_gpu=False): + page, tmp = _make_page(_pw_gpu_browser, fig) + pages.append(page) + paths.append(tmp) + if expect_gpu: + page.wait_for_function( + """() => { + const d = globalThis.__apl_gpu2d || {}; + const v = Object.values(d); + return v.length > 0 && v.every(x => x.active); + }""", + timeout=20_000, + ) + return page + + yield _open + for p in pages: + try: + p.close() + except Exception: + pass + for f in paths: + f.unlink(missing_ok=True) + + +class TestGpuBaseWithLayer: + def test_layer_over_gpu_base(self, gpu_mount_page): + """A large image on the WebGPU base path with a Canvas2D layer over it: + the layer (which composites on plotCanvas above gpuCanvas) must appear in + the export. Skips cleanly without a WebGPU adapter (via _pw_gpu_browser).""" + n = GPU_IMG_N + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + # Base engages the GPU path (large, gpu=True) but tile=False so the WHOLE + # frame goes to the GPU as one texture (layers + tiling are mutually + # exclusive). A constant magma layer composites on Canvas2D over it. + p = ax.imshow(np.full((n, n), 1.0, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=True, tile=False) + p.add_layer(np.full((n, n), 1.0, np.float32), + cmap="magma", alpha=0.5, clim=(0, 1)) + + base_hi = np.array(_lut_endpoint("gray", 255), np.float64) + layer_hi = np.array(_lut_endpoint("magma", 255), np.float64) + blend = np.round(base_hi * 0.5 + layer_hi * 0.5) + + page = gpu_mount_page(fig, expect_gpu=True) + # Confirm the GPU path is the one painting the base. + diag = page.evaluate("() => globalThis.__apl_gpu2d || {}") + assert diag and all(d.get("active") for d in diag.values()), ( + f"GPU base path not active: {diag}" + ) + + res = _export(page) + assert "error" not in res, res.get("error") + arr = _decode(res["dataUrl"]) + # The blended colour (base⊕layer) must be present — proves the Canvas2D + # layer composited over the WebGPU base and both are captured. + assert _count_near(arr, blend) > 500, ( + f"blend colour {blend.tolist()} missing over GPU base — layer not composited" + ) + # And it must differ from the bare GPU base colour. + assert int(np.max(np.abs(blend - base_hi))) > 16