Skip to content

Tiled 2-D image renders solid black when the plot tiled on a placeholder: degenerate quantisation band (0, 0) survives set_data, and the JS LUT honours it #60

Description

@TheDrOnos

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:

  1. 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.

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions