Version: anyplotlib 0.7.3, Electron host (APL_BINARY_TRANSPORT=1), Windows.
Summary
An imshow that enters tile mode on a placeholder frame (e.g. zeros, before
real data exists) derives the tile quantisation band raw_min/raw_max from
that placeholder — (0.0, 0.0) — and no later set_data ever re-derives it.
The Python encoder and the frontend LUT then disagree about the degenerate
band: the encoder falls back to the display window, but the LUT honours
(0, 0), so every quantised byte maps below the display floor and the panel
renders solid black — on the WebGPU and Canvas2D paths alike — while
stats/histograms (which bypass the LUT) look perfectly healthy. No warning or
error anywhere.
This presented downstream (de_groundcrew) as: every 8k still opened into a
figure that had been laid out on its zeros placeholder — camera acquisitions
and files alike — showed a black pane beside correct stats. The live-camera
path was unaffected only because it pins the band by hand
(_plot2d._state["raw_min"/"raw_max"] + update_tile_source()), which is
also the workaround we've shipped for stills.
Reproduction (Python side, no frontend needed)
import numpy as np
import anyplotlib as apl
fig, axes = apl.subplots(1, 1)
ax = axes[0][0] if isinstance(axes, list) else axes
plot = ax.imshow(np.zeros((2048, 2048), dtype=np.float32)) # > tile threshold
# → tiles on zeros
frame = np.random.default_rng(0).normal(140, 30, (2048, 2048)).astype(np.float32)
plot.set_data(frame, clim=(35.0, 240.0))
st = plot._state
print(st["tile_enabled"], st["raw_min"], st["raw_max"])
# → True 0.0 0.0 (band never re-derived from the real frame)
Why that renders black
Walking figure_esm.js _buildLut32 with this state
(display_min=35, display_max=240, raw_min=0, raw_max=0):
const hMin = st.raw_min != null ? st.raw_min : dMin; // 0 — zero passes the null check
const hMax = st.raw_max != null ? st.raw_max : dMax; // 0
const range = hMax - hMin || 1; // degenerate → 1
// for every u8 index:
const val = hMin + (raw / 255) * range; // ∈ [0, 1]
t = (val - dMin) / (dMax - dMin); // negative for all 256 indices
Every LUT entry clamps to colormap[0] → a 256-entry black LUT, identically
on the GPU shader path (the LUT texture) and the Canvas2D blit.
Meanwhile the Python encoder (_tile_quant_clim) treats the same degenerate
band as unset and quantises the overview over the display window — so the
bytes shipped to the frontend are already display-mapped, and correct. The two
fallbacks disagree; the black pane is the disagreement made visible.
Suggested fixes
Either one prevents the black pane; both together make the two ends of the
protocol consistent:
-
Python — derive the band on data swap. In _set_data_tiled (and/or the
plain→tiled swap in set_data), apply the same guard enable_tile already
has: if raw_min is None or not (raw_max > raw_min), derive the band from
the incoming frame. The frame is in hand at that point, so this also fixes
the placeholder case for free.
-
JS — treat a degenerate band as unset. In _buildLut32, mirror
_tile_quant_clim's rule: use raw_min/raw_max only when
raw_max > raw_min, else fall back to display_min/display_max (which
reduces the LUT to the identity window over the already-display-mapped
bytes).
A public band setter would also let hosts stop reaching into _plot2d._state
for the live-camera case (the existing downstream touch point notes this as
"worth an upstream API").
Happy to provide more detail or test a fix — the downstream workaround is
de_ground_crew a9db80d (pins the band in the shell's FigureView.show), and
we'd like to drop it once this lands so the next vendored-code sync doesn't
have to carry it.
Version: anyplotlib 0.7.3, Electron host (
APL_BINARY_TRANSPORT=1), Windows.Summary
An
imshowthat enters tile mode on a placeholder frame (e.g. zeros, beforereal data exists) derives the tile quantisation band
raw_min/raw_maxfromthat placeholder —
(0.0, 0.0)— and no laterset_dataever re-derives it.The Python encoder and the frontend LUT then disagree about the degenerate
band: the encoder falls back to the display window, but the LUT honours
(0, 0), so every quantised byte maps below the display floor and the panelrenders solid black — on the WebGPU and Canvas2D paths alike — while
stats/histograms (which bypass the LUT) look perfectly healthy. No warning or
error anywhere.
This presented downstream (de_groundcrew) as: every 8k still opened into a
figure that had been laid out on its zeros placeholder — camera acquisitions
and files alike — showed a black pane beside correct stats. The live-camera
path was unaffected only because it pins the band by hand
(
_plot2d._state["raw_min"/"raw_max"]+update_tile_source()), which isalso the workaround we've shipped for stills.
Reproduction (Python side, no frontend needed)
Why that renders black
Walking
figure_esm.js_buildLut32with this state(
display_min=35, display_max=240, raw_min=0, raw_max=0):Every LUT entry clamps to
colormap[0]→ a 256-entry black LUT, identicallyon the GPU shader path (the LUT texture) and the Canvas2D blit.
Meanwhile the Python encoder (
_tile_quant_clim) treats the same degenerateband as unset and quantises the overview over the display window — so the
bytes shipped to the frontend are already display-mapped, and correct. The two
fallbacks disagree; the black pane is the disagreement made visible.
Suggested fixes
Either one prevents the black pane; both together make the two ends of the
protocol consistent:
Python — derive the band on data swap. In
_set_data_tiled(and/or theplain→tiled swap in
set_data), apply the same guardenable_tilealreadyhas: if
raw_min is None or not (raw_max > raw_min), derive the band fromthe incoming frame. The frame is in hand at that point, so this also fixes
the placeholder case for free.
JS — treat a degenerate band as unset. In
_buildLut32, mirror_tile_quant_clim's rule: useraw_min/raw_maxonly whenraw_max > raw_min, else fall back todisplay_min/display_max(whichreduces the LUT to the identity window over the already-display-mapped
bytes).
A public band setter would also let hosts stop reaching into
_plot2d._statefor the live-camera case (the existing downstream touch point notes this as
"worth an upstream API").
Happy to provide more detail or test a fix — the downstream workaround is
de_ground_crew
a9db80d(pins the band in the shell'sFigureView.show), andwe'd like to drop it once this lands so the next vendored-code sync doesn't
have to carry it.