Skip to content

feat: WebGPU 2-D image rendering, tiled display, binary pixel transport#26

Merged
CSSFrancis merged 25 commits into
mainfrom
feat/webgpu-2d-images
Jul 10, 2026
Merged

feat: WebGPU 2-D image rendering, tiled display, binary pixel transport#26
CSSFrancis merged 25 commits into
mainfrom
feat/webgpu-2d-images

Conversation

@CSSFrancis

@CSSFrancis CSSFrancis commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

WebGPU rendering for large 2-D scalar images, tiled display for arbitrarily large frames, and a binary pixel transport — the anyplotlib half of the SpyDE in-situ movie viewer work (consumed by CSSFrancis/spyde#4, which pins this branch by sha). Targets smooth scrubbing/playback of 4k×4k+ image stacks.

WebGPU 2-D image path

  • Shader-LUT texture render for large scalar images (≥ ~1 Mpx): colormap applied on the GPU, decorations (axes/markers/widgets) stay on the Canvas2D layer composited above. plot.gpu_active echoes the real state; every failure (no adapter, device lost) falls back to the Canvas2D blit.
  • Zoom/pan honoured in the shader (real upsample on zoom-in), letterbox/aspect via _imgFitRect, review-hardened clim/zoom/leak fixes, offscreen-texture readback hook for correctness tests.

Tiled display

  • Tile mode for huge images: downsampled overview base + hi-res detail tile of the visible region, driven by the library's own debounced view_changed. imshow(huge, tile="auto") public API; pluggable TileBackend (numpy box-mean default).

Binary pixel transport

  • PLOTBIN framing + routing: raw-uint8 pixels ride a binary side-channel instead of base64 JSON (~2.2x faster per frame); first-paint race fixed by splicing side-table bytes at initial paint.

Perf + misc

  • Colormap LUT cached (was rebuilt every frame, ~100 ms); float32 rescale for small-range data (~60→27 ms/frame); rapid-frame freshness guard (the LAST frame must render); marker clip_display option; set_extent updates axes; pcolormesh rasterization for regular meshes; anyplotlib.__version__.

Testing

  • GPU-vs-CPU screenshot parity suite runs in headless Playwright (full Chromium channel + --enable-unsafe-webgpu). The session probe exercises the full render path (adapter → device → canvas context → render pass) and skips the GPU suites on runners without a usable WebGPU path (e.g. GitHub ubuntu runners), instead of timing out.

CSSFrancis added 25 commits July 5, 2026 23:00
…transport

Extends the existing WEBGPU_PLAN.md (3D points/voxels) to 2D large images: a WGSL
texture render path (reusing the 3D device singleton + canvas-fallback contract),
shader-LUT colormap + clim + mipmaps, and a binary pixel transport off base64.
Phased with decision gates; Canvas2D stays the default + fallback. Motivating
consumer: the SpyDE in-situ movie viewer. Not yet implemented.
…es (Phase 0)

Adds a WebGPU 2-D image path that replaces the Canvas2D atob+64M-iteration LUT loop
with one GPU draw: the normalized uint8 frame uploads to an R8 texture, the 256-
entry colormap to a 256x1 RGBA texture, and a fullscreen-quad WGSL fragment shader
re-stretches by clim (dmin/dmax uniform) and samples the LUT. Reuses the existing
3D _gpuDevice() singleton + device-lost fallback contract.

- New 2D gpuCanvas below plotCanvas (decorations keep drawing on plotCanvas over a
  now-transparent bg); _gpuWanted2d gate (scalar images >= ~1 Mpx, gpu_mode aware).
- imshow/Plot2D gain gpu='auto'|True|False -> gpu_mode state; async first-frame-
  canvas-then-swap; any GPU failure reverts to the Canvas2D blit for that frame.
- Verified in the SpyDE Electron app on a Pascal GPU: a 1366x1366 movie frame
  renders via the shader (gpu active, correct image + scale bar).

Diagnostic: globalThis.__apl_gpu2d / __apl_build for the Electron GPU test.
__apl_gpuReadback(panelId, N) re-renders the active image panel into an offscreen
RGBA texture and copies it back to CPU — the reliable way to verify the shader
output (the live swapchain canvas reads black under automation, per FIGURE_ESM.md).
Handles bgra/rgba formats + 256-byte row padding.

Verified on the Pascal GPU (SpyDE Electron app, real movie frame): min 0 / max 255
/ 96% non-black / gray-cmap values — the shader-LUT + clim produce a correct,
varied, properly-contrasted image.
…resolve

The 2D gpuCanvas was inserted as the FIRST child of plotWrap, so tests/callers
doing querySelector('canvas') got the (empty) gpuCanvas instead of the image
plotCanvas — the RGB image read back [0,0,0,0]. Fixed: append gpuCanvas AFTER
plotCanvas in DOM (plotCanvas stays first); z-index (plotCanvas:1 > gpuCanvas:0)
controls the visual stacking so decorations still composite over the GPU image.
Updated the one title test whose plotCanvas detection relied on 'no z-index'
(plotCanvas now has z-index:1) to use DOM-first-canvas instead.

anyplotlib suite: 162 2D/title tests pass; GPU path re-verified on Pascal
(offscreen readback min 0 / max 255 / 96% non-black).
Plot2D gains gpu_active (property) + _set_gpu_active, mirroring Plot3D: the JS
_reportGpu fires a gpu_status event → the Figure routes it to _set_gpu_active, so
plot.gpu_active reflects the ACTUAL render path (True only when the WebGPU image
path activated), not just the requested gpu_mode.

test_gpu_image.py (8 tests): gpu param → gpu_mode mapping, gpu_active echo +
event dispatch, and the Canvas2D FALLBACK contract — gpu=False and sub-threshold
images always render on canvas with gpu_active False (Playwright Chromium has no
real GPU device, so this is the fallback path; the GPU-active render is verified
on the Pascal GPU in the SpyDE electron test).
…d items

Updates LARGE_IMAGE_WEBGPU_PLAN.md status to Phase-0-done/hardware-verified and
records the two deferred refinements (mipmaps; binary transport — now incremental
since LOD already cut the shipped payload ~35x). Adds a towncrier new_feature
fragment for the imshow gpu= WebGPU path.
Review found two CRITICAL bugs masked by only testing the default-clim, unzoomed case:

- CRITICAL 1 (clim applied twice): _buildLut32 already bakes the display window
  (clim) AND scale_mode into a direct pixel->colour LUT; the shader then re-applied
  the window via a dmin/dmax uniform -> correct only at full-range clim, wrong for
  any narrowed contrast (and SpyDE's 2-98% nav levels). Fixed: the shader is now a
  plain IDENTITY LUT lookup (raw/255 -> LUT), uniform removed. This also fixes HIGH
  (log/symlog — the LUT already encodes the curve). Verified on the Pascal GPU: a
  narrowed clim [0.3,0.7] matches the numpy windowed-colormap to meanDiff 0.65/255.
- CRITICAL 2 (GPU quad ignored zoom/pan): the fullscreen quad drew the full extent
  while the axes/scale-bar/mask/markers on plotCanvas honor zoom/center -> the base
  image detached from its overlays when zoomed. Fixed: _gpuWanted2d falls back to
  Canvas2D whenever zoom != 1 or center != 0.5 (fast on a static view).
- MEDIUM: _gpuDisposeImagePanel frees the R8/LUT textures on panel removal, figure
  dispose, and device loss (was leaking tens of MB per large-image panel close).
- MEDIUM: async GPU-init guards on panels.has(p.id) (panel removed mid-await).
- LOW: gpu_active echoes False on device loss; sampler is nearest on both textures
  (pixel-faithful match to Canvas2D imageSmoothingEnabled=false).

anyplotlib 2D+title suite green (170); SpyDE movie scrub 5/5 + playback 5 frames.
… HIGH)

Re-review found the GPU fullscreen quad ignored _imgFitRect: a non-square image
(or any image in a non-square panel) rendered STRETCHED to fill the whole gpuCanvas
while the axes/scale-bar/mask/markers drew in the centered aspect-preserved
fit-rect — the base image detached + distorted (same failure class as the zoom
bug, via aspect mismatch). Square test images masked it.

Fix: a vec4 uniform carries the image's fit-rect in clip space (from _imgFitRect,
the same rect _blit2d + overlays use); the vertex shader maps a 0..1 quad into it,
and the render clears to theme.bgCanvas so the letterbox bars match Canvas2D. The
offscreen readback fills the full target (rect [-1,-1,1,1]) to sample the whole
image regardless of aspect. uniformBuf freed in dispose. Stale comment fixed.

Verified: anyplotlib 2D+title suite green (170); GPU render correct on the Pascal
(square movie frame fills panel, scale bar registered, readback min0/max255/96%).
…-in)

The GPU 2-D image path used to FALL BACK to Canvas2D on any zoom/pan (the quad
drew the full extent, so it would detach from the zoom-honouring overlays). Now the
shader honours zoom/center exactly like _blit2d: the uniform carries the on-screen
clip rect AND a texture uv sub-region. Zoom-in samples a window of the image over
the fit-rect (nearest sampling UPSAMPLES real texels); zoom-out shrinks the dest
rect. _imageDrawUniform computes both from st.zoom/center_x/center_y (mirrors the
Canvas2D _blit2d formula). Removed the zoom/center early-out from _gpuWanted2d so
GPU stays active at any zoom.

Uniform grew 16->32B (two vec4); the offscreen readback samples the full texture
(uv 0..1) regardless of live zoom. anyplotlib 2D+title suite green (170); GPU
render correct at zoom=1 on the Pascal (readback min0/max255/92%).
…(WIP render)

Opt-in binary transport for large image pixels over the Electron stdout channel,
to cut the ~200ms/frame base64+JSON+atob cost on a 4k movie. OFF by default
(APL_BINARY_TRANSPORT=1 to enable); the base64 path is untouched for
Jupyter/Pyodide/standalone.

Solid + tested (test_electron/test_binary_frame.py, 9 tests, CI matrix):
- _binary_frame.py: PLOTBIN:<hlen>:<plen>\n<hdr><payload> wire format, length-
  prefixed + binary-safe (payload may contain the marker/newlines); encode/decode
  round-trip + 2-frame concat.
- _electron.py: _route_change parses the panel_<id>_geom JSON, pulls image_b64/
  overlay_mask_b64 out as raw PLOTBIN frames (emit_binary → sys.stdout.buffer),
  sends the slimmed geom (LUT/flags) as normal JSON. Routing tested both directions.
- figure_esm.js: _imageBytes prefers raw bytes (image_b64_bytes) over atob;
  _gpuWanted2d + Canvas2D accept binary; _loadGeom preserves *_bytes across a geom
  refresh; per-panel binary-trait listeners merge bytes into the geomCache.
- _repr_utils.py: awi_state_binary handler routes bytes into the panel geom.

KNOWN ISSUE (why it's off): the render-side geom-merge doesn't paint the frame yet
in the real app (backend emits PLOTBIN correctly — verified marker — but the Signal
window stays blank). Framing + demux are proven; the paint wiring needs more work.
…de-table

Complete the binary pixel transport render wiring (works end-to-end now):
- Pixels are NESTED in the panel_<id>_geom JSON trait, not a top-level image_b64;
  _route_change parses the geom, pulls image_b64/overlay_mask_b64 out as PLOTBIN
  frames, sends the slimmed geom (LUT/flags) as JSON.
- The anywidget JS model does NOT round-trip a Uint8Array set on an ad-hoc trait
  (model.get returned undefined) — so the iframe stashes raw bytes in a global
  side-table (__apl_pixbytes) keyed by geom_trait::pixelKey and bumps a small token
  trait; the ESM's change: listener reads bytes from the side-table and merges them
  into the panel geomCache under <key>_bytes.
- _imageBytes/_gpuWanted2d/Canvas2D accept image_b64_bytes (no atob); _loadGeom
  preserves *_bytes across a geom refresh; a content fingerprint keys the cache so
  same-length frames don't wrongly skip.
- _repr_utils awi_state_binary handler + paint telemetry (__apl_paint_kind/_ms).

Removed the stderr marker from anyplotlib's emit_binary (the host owns it — see the
SpyDE ipc fix; a redirecting host MUST patch emit_binary). Verified: pixel-identical
render, ~12ms paint. test_electron 9 tests green (geom-nested routing both ways).
…100ms)

Profiling a movie scrub showed _build_colormap_lut was the single biggest paint
cost (~100ms/frame) — and it was rebuilt from scratch on EVERY frame (colorcet
lookup + 256 hex parses) even though the colormap never changes during a scrub.
It's a pure function of the colormap name, so lru_cache it: cold ~330ms -> warm
~0ms. Helps base64 AND binary paths. The returned list is read-only to callers
(they JSON-serialise it). anyplotlib 2D+electron suite green (173).
…ame)

_normalize_image rescaled through float64 every frame (a full uint8->float64->
uint8 round trip over 4M px = ~60ms on a 2048 frame, every movie frame). Use
float32 when the value magnitude/span is within its exact range (uint8/uint16
movies — the common case), keeping float64 for high-magnitude float sources where
float32 subtraction would band. Byte-identical result; vmin/vmax still full-
precision. anyplotlib 2D+electron suite green (173).
Pushes 8 byte-distinct frames (moving bright band) back-to-back via rAF
(the coalescing regime) and asserts the rendered band matches a settled
single-frame reference of the LAST frame, not the first — plain + tiled,
GPU + Canvas2D. Newest-wins coalescing may drop intermediates; freezing on
an early frame is the bug class this catches (all 4 tests fail if the
frame dedup key is forced constant).
…nitial paint

On binary transport the slimmed geom JSON carries only LUT/flags; the
pixel bytes land in the __apl_pixbytes side-table and were merged into
_geomCache[<key>_bytes] ONLY by the change listener. The iframe registers
its message handler synchronously but render() runs after an async
import(blobUrl), so a push landing in that gap updated the model with no
listener to consume it: the initial paint parsed the slim JSON, the bytes
sat stranded, and the panel stayed permanently blank until an organic
re-push (base64 was unaffected — those pixels ride inside the geom JSON
the initial paint reads). Fix: factor the listener's merge logic into
_spliceBinaryBytes (image / overlay-mask / detail keys, preserves __aplSeq
so the dedup key and newest-wins coalescing are unchanged) and call it at
initial paint in _createPanelDOM and _createInsetDOM. No-op when the
side-table is empty, so the normal path is untouched.

test_first_paint_race.py forces the ordering deterministically (gates
renderFn behind a promise, delivers the awi_state/awi_state_binary message
first): the binary case fails without the fix, base64 guards the
already-working path.
On GPU-less CI runners (GitHub ubuntu) requestAdapter() resolves a
SwiftShader adapter, so the adapter-only probe passed and every GPU
parity / rapid-frame test then timed out (20s each) waiting for the
__apl_gpu2d activation that can never happen. Probe what the figure ESM
actually needs — adapter, device, canvas 'webgpu' context, configure,
one cleared render pass — each step raced against a timeout, and skip
the suite when any step fails.
@CSSFrancis CSSFrancis changed the title Feat/webgpu 2d images feat: WebGPU 2-D image rendering, tiled display, binary pixel transport Jul 10, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.64103% with 84 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.97%. Comparing base (7974edd) to head (a2fdb4e).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
anyplotlib/plot2d/_plot2d.py 88.82% 39 Missing ⚠️
anyplotlib/_electron.py 63.79% 21 Missing ⚠️
anyplotlib/plot2d/_tile_backend.py 84.31% 16 Missing ⚠️
anyplotlib/figure/_figure.py 58.33% 5 Missing ⚠️
anyplotlib/_binary_frame.py 90.62% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #26      +/-   ##
==========================================
- Coverage   89.29%   88.97%   -0.33%     
==========================================
  Files          36       38       +2     
  Lines        2962     3511     +549     
==========================================
+ Hits         2645     3124     +479     
- Misses        317      387      +70     

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

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants