Skip to content

A tiled image born on a placeholder rendered solid black - #62

Merged
CSSFrancis merged 1 commit into
mainfrom
fix/tile-band-degenerate-placeholder
Aug 25, 2026
Merged

A tiled image born on a placeholder rendered solid black#62
CSSFrancis merged 1 commit into
mainfrom
fix/tile-band-degenerate-placeholder

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Closes #60.

Why

A tiled imshow that entered tile mode on a flat placeholder (zeros, before real
data exists) derived the fixed quantisation band raw_min/raw_max from that
placeholder — a degenerate (0, 0) — and nothing re-derived it on a later
set_data. The two ends of the protocol then disagreed about what (0, 0) meant:

  • the Python encoder (_tile_quant_clim) treats a degenerate band as unset and
    quantises the tile bytes over the display window instead;
  • the JS LUT honoured it literally — hMin + raw/255 * (range||1) over a zero range
    puts all 256 codes in [0, 1], below any sane display floor.

Every code clamped to colormap[0]: a 256-entry black LUT, on the WebGPU shader path
and the Canvas2D blit alike. Stats and histograms bypass the LUT, so they looked
perfectly healthy next to a black pane, and nothing warned. Downstream, every 8k still
opened into a figure laid out on its zeros placeholder showed this.

What

Both ends, as the issue suggested:

  • Python — extract the guard enable_tile already had into _ensure_tile_band()
    and call it from update_tile_source, the single seam every data swap goes through,
    so set_data and the public update_tile_source are both covered. A band that is
    already valid is still never re-derived, so a contrast change keeps re-windowing in
    the LUT with no pixel re-encode.
  • JS — read the band through one _rawBand(), shared by _buildLut32 (both
    render paths) and by the colorbar tick placement, which read the band the same way
    and was equally wrong.

One correction to the issue's suggested JS rule: mirroring _tile_quant_clim
unconditionally is wrong, because a degenerate band means two different things. On
the plain path _normalize_image emits all-zero bytes and reports (c, c) — the band
names the constant value code 0 stands for, and the old reconstruction was correct.
The unconditional version turns every constant untiled image black; it broke seven
layer-blend tests. The fallback is gated on tile_enabled.

Also adds set_tile_band(vmin, vmax) — the issue's third ask. The fix alone does
not let a host drop its live-camera workaround, since that pins a known range rather
than a data-derived one, and a valid band is deliberately never re-derived.

Tests

12 new cases in test_tiled_imshow.py, including two Playwright ones. Verified to
fail without the fix — the browser test reproduces the exact symptom,
{'lo': 0, 'hi': 0, 'mean': 0}.

One existing test changed. test_set_data_stays_identical_between_plain_and_tile
asserted byte-identical plain/tiled payloads, but only passed because of this bug:
on unmodified main, giving it a real base instead of zeros fails at 161 differing
bytes, since a tiled plot quantises over its band and a plain one over the clim. It is
now parametrised over both bases and asserts what the viewer actually sees — identical
within one colormap step (measured: max |diff| = 1). The wire-byte guarantee at
construction is untouched and still passes.

2144 passed, 58 skipped.

Notes for review

  • The behaviour change is filed as api_change per AGENTS.md: a tiled plot born on a
    placeholder now quantises frames over the frame's own range rather than the display
    window, matching what imshow of a large frame has always done. Wire bytes can
    differ by a rounding step from the equivalent untiled plot; the displayed image does
    not.
  • FIGURE_ESM.md anchors were already 42 lines stale from an earlier commit, so all 55
    were re-measured against the source rather than blind-shifted, and the verified line
    count in AGENTS.md was corrected.

Tile mode entered on a flat placeholder frame -- `imshow` of zeros before real
data exists -- derives the fixed quantisation band `raw_min`/`raw_max` from that
placeholder, a degenerate `(0, 0)`, and nothing re-derived it on a later
`set_data`. The two ends of the protocol then disagreed about what `(0, 0)`
means:

  * the Python encoder (`_tile_quant_clim`) treats a degenerate band as UNSET
    and quantises the tile bytes over the display window instead;
  * the JS LUT honoured it literally -- `hMin + raw/255 * (range||1)` over a
    zero range put all 256 codes in `[0, 1]`, below any sane display floor.

Every code clamped to `colormap[0]`: a 256-entry black LUT, on the WebGPU shader
path and the Canvas2D blit alike, beside perfectly healthy stats and histograms,
with no warning anywhere. Downstream saw every 8k still open into a black pane.

Python: extract the guard `enable_tile` already had into `_ensure_tile_band()`
and call it from `update_tile_source` -- the single seam every data swap goes
through, so `set_data` and the public `update_tile_source` are both covered. A
band that is already VALID is still never re-derived, so a contrast change keeps
re-windowing in the LUT with no pixel re-encode.

JS: read the band through one `_rawBand()`, shared by `_buildLut32` (both render
paths) and by the colorbar tick placement, which read the band the same way and
was equally wrong. The fallback is gated on `tile_enabled`, because a degenerate
band means two DIFFERENT things: on the plain path `_normalize_image` emits
all-zero bytes and reports `(c, c)`, where the band names the constant value code
0 stands for and the old reconstruction was correct. Falling back unconditionally
turns every constant untiled image black -- it broke seven layer-blend tests.

`test_set_data_stays_identical_between_plain_and_tile` asserted byte-identical
plain/tiled payloads, but only passed BECAUSE of this bug: on unmodified HEAD,
giving it a real base instead of zeros fails at 161 differing bytes, since a
tiled plot quantises over its band and a plain one over the clim. It is now
parametrised over both bases and asserts what the viewer actually sees --
identical within one colormap step.

Also adds `set_tile_band(vmin, vmax)` to pin the band from a range the caller
already knows (a camera's bit depth, a detector's saturation point), so hosts
stop reaching into `_plot2d._state["raw_min"/"raw_max"]`.

FIGURE_ESM.md anchors were already 42 lines stale from an earlier commit, so all
55 were re-measured against the source rather than blind-shifted.

Closes #60

2144 passed, 58 skipped.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.90%. Comparing base (e538884) to head (c6626df).

Files with missing lines Patch % Lines
anyplotlib/plot2d/_plot2d.py 95.45% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #62      +/-   ##
==========================================
+ Coverage   90.88%   90.90%   +0.01%     
==========================================
  Files          40       40              
  Lines        4532     4550      +18     
==========================================
+ Hits         4119     4136      +17     
- Misses        413      414       +1     

☔ 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 2061195 into main Aug 25, 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

2 participants