diff --git a/AGENTS.md b/AGENTS.md index bf482b5d..455e8de7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ grep -nE '^\s*(function|const|let) [A-Za-z_]' anyplotlib/figure_esm.js ``` and reconcile against the two numbered tables (the section map near the top and -the 2-D function table). Both were last verified at 9,285 lines. +the 2-D function table). Both were last verified at 10,119 lines. Changelog entries: add a fragment file to `upcoming_changes/` (e.g. `123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time. diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index abb45776..492b551e 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -59,20 +59,20 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | `_createInsetDOM` / `_applyAllInsetStates` | 1129 / 1512 | | `_resizePanelDOM` | 2225 | | **2D drawing**: `_imgFitRect` | 2384 | -| `draw2d` | 2692 | -| `drawScaleBar2d` / `drawColorbar2d` | 2887 / 3125 | -| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 2986 / 3009 / 3022 | -| `_drawAxes2d` (ticks, labels, title) | 3180 | -| `drawOverlay2d` / `drawMarkers2d` | 3333 / 3497 | -| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2512 / 2536 / 2597 | +| `draw2d` | 2713 | +| `drawScaleBar2d` / `drawColorbar2d` | 2908 / 3188 | +| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3007 / 3030 / 3043 | +| `_drawAxes2d` (ticks, labels, title) | 3242 | +| `drawOverlay2d` / `drawMarkers2d` | 3395 / 3559 | +| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2533 / 2557 / 2618 | | Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 | -| **3D drawing**: `draw3d` | 5236 | -| Event emission `_emitEvent` | 6073 | -| 3D event handlers `_attachEvents3d` | 6125 | -| **1D drawing**: `draw1d` | 6346 | -| `_drawLine` (1D series + markers) | 6499 | -| `drawOverlay1d` / `drawMarkers1d` | 6792 / 6876 | -| Marker hit-test `_markerHitTest2d` | 7144 | +| **3D drawing**: `draw3d` | 5298 | +| Event emission `_emitEvent` | 6135 | +| 3D event handlers `_attachEvents3d` | 6187 | +| **1D drawing**: `draw1d` | 6408 | +| `_drawLine` (1D series + markers) | 6561 | +| `drawOverlay1d` / `drawMarkers1d` | 6854 / 6938 | +| Marker hit-test `_markerHitTest2d` | 7206 | > **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'` > branch that blits a single RGBA image across data-coord `extent` (the fast @@ -81,16 +81,16 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: > redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on > the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block > clips it to a curved sector. -| Panel event dispatch `_attachPanelEvents` | 7401 | -| 2D events `_attachEvents2d` | 7443 | -| 1D events `_attachEvents1d` | 7827 | -| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8099 / 8372 | -| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8285 / 8299 / 8328 / 8363 | -| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8495 / 8568 | -| Shared-axis propagation `_getShareGroups` | 8639 | -| Figure resize `_applyFigResizeDOM` | 8703 | -| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8894 / 8957 / 9333 | -| Generic redraw `_redrawPanel` | 9523 | +| Panel event dispatch `_attachPanelEvents` | 7463 | +| 2D events `_attachEvents2d` | 7505 | +| 1D events `_attachEvents1d` | 7889 | +| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8161 / 8440 | +| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8353 / 8367 / 8396 / 8431 | +| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8565 / 8638 | +| Shared-axis propagation `_getShareGroups` | 8709 | +| Figure resize `_applyFigResizeDOM` | 8773 | +| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8964 / 9027 / 9403 | +| Generic redraw `_redrawPanel` | 9593 | > **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one > that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods` @@ -269,12 +269,13 @@ st.colorbar_label_size (label font sizes; optional) | Function | Line | Purpose | |----------|------|---------| -| **`_imgFitRect(iw,ih,cw,ch)`** | **2372** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this | -| `draw2d(p)` | 2680 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers | -| `drawScaleBar2d(p)` | 2875 | Physical scale bar | -| `drawColorbar2d(p)` | 2961 | Gradient strip + min/max marks + rotated label centred in the `_cbWidth` gutter | -| `_drawAxes2d(p)` | 3016 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` | -| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3169 / 3333 | Widgets / marker groups | +| **`_imgFitRect(iw,ih,cw,ch)`** | **2384** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this | +| `draw2d(p)` | 2713 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers | +| `drawScaleBar2d(p)` | 2908 | Physical scale bar | +| `_rawBand(st)` / `_buildLut32(st)` | 2433 / 2440 | The quantisation band the u8 bytes were encoded over, then the 256-entry LUT built from it. `_rawBand` mirrors Python `_tile_quant_clim`: a DEGENERATE band (`raw_max <= raw_min`) is UNSET and falls back to `display_min/max`. Both render paths and the colorbar go through it — honouring a `(0, 0)` band paints solid black | +| `drawColorbar2d(p)` | 3188 | Gradient strip + min/max marks (band-relative, via `_rawBand`) + rotated label centred in the `_cbWidth` gutter | +| `_drawAxes2d(p)` | 3242 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` | +| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3395 / 3559 | Widgets / marker groups | Zoom model: at `zoom=1` the whole image fills the fit-rect; at `zoom=Z>1` a `1/Z` region fills it. `_imgToCanvas2d` / `_canvasToImg2d` must stay exact diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 9f743fc9..7dc302ef 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -2415,10 +2415,31 @@ function render({ model, el, onResize }) { }; } + // The quantisation band the uint8 image bytes were encoded over, as [lo, hi]. + // A DEGENERATE band (raw_max <= raw_min) means DIFFERENT things on the two + // encoder paths, and the LUT has to read it the way the bytes were written: + // + // plain (_normalize_image): a constant frame quantises to ALL-ZERO bytes and + // reports (c, c) — the band NAMES the single value code 0 stands for, and + // hMin + raw/255 * (range||1) reconstructs it. Keep honouring it. + // tile (_tile_quant_clim): a degenerate band is treated as UNSET and the + // bytes are quantised over the DISPLAY window instead — so they are already + // display-mapped and the LUT must be the identity window over them. + // + // Reading a tiled degenerate band the plain way is GH #60: a plot that entered + // tile mode on a flat placeholder carries the band (0, 0), every code maps to + // hMin + raw/255 * 1 in [0, 1], lands below any sane display floor, and the + // panel renders solid black beside perfectly healthy stats. + function _rawBand(st) { + const lo=st.raw_min, hi=st.raw_max; + if(lo==null||hi==null) return [st.display_min, st.display_max]; + if(hi>lo) return [lo,hi]; + return st.tile_enabled ? [st.display_min, st.display_max] : [lo,hi]; + } + function _buildLut32(st) { const dMin=st.display_min, dMax=st.display_max; - const hMin=st.raw_min!=null?st.raw_min:dMin; - const hMax=st.raw_max!=null?st.raw_max:dMax; + const [hMin,hMax]=_rawBand(st); const mode=st.scale_mode||'linear'; const range=hMax-hMin||1; const cmapData=st.colormap_data||[]; @@ -3198,8 +3219,7 @@ function render({ model, el, onResize }) { // display_min / display_max tick marks const dMin=st.display_min, dMax=st.display_max; - const hMin=st.raw_min!=null?st.raw_min:dMin; - const hMax=st.raw_max!=null?st.raw_max:dMax; + const [hMin,hMax]=_rawBand(st); const vRange=(hMax-hMin)||1; function _vToY(v){return imgH-1-((v-hMin)/vRange)*(imgH-1);} ctx.strokeStyle='rgba(255,255,255,0.85)'; ctx.lineWidth=1.5; diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index bc60bb93..cb866426 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -507,11 +507,7 @@ def enable_tile(self, backend=None, integration_method: str = "mean", # (raw_min/raw_max fixed, display_min/display_max move) with NO pixel re-encode # or re-transfer — set_clim just moves the display window (see set_clim). Set # once from the full-res range; keep any existing band on a re-enable. - if (self._state.get("raw_min") is None - or not (self._state.get("raw_max", 0) > self._state.get("raw_min", 0))): - rng = self._backend_display_range(self._tile_backend) - if rng is not None: - self._state["raw_min"], self._state["raw_max"] = rng + self._ensure_tile_band() _was_on = self._tile_on if not self._tile_on: self._tile_on = True @@ -561,6 +557,13 @@ def update_tile_source(self, array=None) -> None: setter = getattr(self._tile_backend, "set_array", None) if setter is not None: setter(array) + # The quantisation band is derived ONCE (enable_tile) and then held fixed, so + # a contrast change re-windows in the LUT with no pixel re-encode. But if it + # was never VALIDLY derived — tile mode started on a flat placeholder, so the + # probe refused and the band stayed degenerate — the new data in the backend + # is the first chance to get it right. Must run BEFORE the overview/detail are + # re-sampled below so they quantise over the corrected band in the SAME push. + self._ensure_tile_band() reg = self._state.get("detail_region") or [] has_detail = len(reg) == 4 # Refresh the overview base ONLY when no detail tile is shown (zoomed out) — @@ -622,6 +625,70 @@ def _refresh_overview(self) -> None: except Exception as e: _TLOG.warning("[TILEDBG] overview refresh FAILED: %s", e) + def _ensure_tile_band(self) -> bool: + """Derive the fixed quantisation band (raw_min/raw_max) from the CURRENT tile + backend when it is unset or DEGENERATE, by the same rule _tile_quant_clim + uses to read it back. Returns True iff the band was (re-)derived. + + The degenerate case is the one that bites: a plot that entered tile mode on a + flat placeholder (e.g. zeros before real data exists) probes a range of + (0, 0) — _backend_display_range refuses it, so the band keeps the + constructor's raw min/max, which for zeros is also (0, 0). Every later + set_data then re-sampled the overview against a band the encoder treated as + unset (quantising over the display window) while the frontend LUT honoured + (0, 0) literally and painted the panel solid black. Calling this on each data + swap re-derives the band as soon as a frame with real range arrives, so the + two ends of the protocol agree on what the bytes mean. + """ + lo, hi = self._state.get("raw_min"), self._state.get("raw_max") + if lo is not None and hi is not None and hi > lo: + return False # a valid band is kept, never re-derived + if self._tile_backend is None: + return False + rng = self._backend_display_range(self._tile_backend) + if rng is None: + # Still flat (an all-constant frame): leave the band unset/degenerate — + # _tile_quant_clim and the JS LUT both fall back to the display window. + return False + self._state["raw_min"], self._state["raw_max"] = rng + _TLOG.debug("[TILEDBG] tile band (re-)derived from backend → raw=(%s,%s)", + *rng) + return True + + def set_tile_band(self, vmin: float, vmax: float) -> None: + """PIN the fixed quantisation band the tile bytes are encoded over. + + Tile mode normally derives this band once, from the full-res data (see + ``enable_tile``), and then holds it: a contrast change re-windows in the LUT + with no pixel re-encode or re-transfer. A live consumer that already KNOWS the + honest range — a camera's bit depth, a detector's saturation point — can pin + it here instead of letting the first frame decide, so the contrast doesn't + shift when a later frame happens to contain a brighter pixel. + + Pinning also survives what auto-derivation cannot: a source whose first frames + are flat (a placeholder, a closed shutter) has no range to derive from, and + the band stays unset until a frame with real content arrives. + + Re-samples the overview (and any active detail tile) over the new band in a + single push, so the pixels and the band the renderer reads them with never + disagree. Raises if the plot is not in tile mode or the range is degenerate.""" + if not self._tile_on: + raise RuntimeError( + "set_tile_band requires tile mode — the band is the quantisation " + "range for the TILE bytes. Call enable_tile() first (or use " + "set_clim() to change the display window on a plain plot).") + lo, hi = float(vmin), float(vmax) + if not (hi > lo): + raise ValueError( + f"set_tile_band requires vmax > vmin, got ({lo}, {hi}). A degenerate " + f"band is what 'unset' means here — both the encoder and the " + f"renderer then fall back to the display window.") + self._state["raw_min"], self._state["raw_max"] = lo, hi + # Re-encode over the new band. update_tile_source() with no array re-samples + # from the backend in place (keeping zoom/center and the detail region) and + # pushes — so the band and the bytes it describes ship together. + self.update_tile_source() + def _tile_quant_clim(self): """The FIXED quantisation band (raw_min/raw_max) the tile bytes are encoded over, so a contrast change re-windows in the LUT without re-encoding pixels. diff --git a/anyplotlib/tests/test_plot2d/test_tiled_imshow.py b/anyplotlib/tests/test_plot2d/test_tiled_imshow.py index 6f720b8f..da33bc04 100644 --- a/anyplotlib/tests/test_plot2d/test_tiled_imshow.py +++ b/anyplotlib/tests/test_plot2d/test_tiled_imshow.py @@ -9,6 +9,8 @@ """ import json import base64 + +import pytest import numpy as np import anyplotlib as apl @@ -271,8 +273,195 @@ def test_tile_false_never_auto_enables(self): assert p._state["image_width"] == 4096 # plain full-res path +class TestTileBandOnPlaceholder: + """Tile mode entered on a FLAT placeholder (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 treats a + degenerate band as unset and quantises over the display window, while the JS LUT + honoured it literally, mapping every code below the display floor — a solid black + pane beside perfectly healthy stats, on the WebGPU and Canvas2D paths alike, with + no warning anywhere. GH #60.""" + + @staticmethod + def _placeholder(): + return apl.subplots(1, 1)[1].imshow(np.zeros((2048, 2048), np.float32)) + + @staticmethod + def _frame(mean=140.0, sd=30.0): + return np.random.RandomState(0).normal( + mean, sd, (2048, 2048)).astype(np.float32) + + def test_placeholder_band_starts_degenerate(self): + # The starting condition the bug needs — documents WHY the guard exists. + p = self._placeholder() + assert p._state["tile_enabled"] is True + assert p._state["raw_min"] == p._state["raw_max"] + + def test_set_data_rederives_degenerate_band(self): + p = self._placeholder() + p.set_data(self._frame(), clim=(35.0, 240.0)) + lo, hi = p._state["raw_min"], p._state["raw_max"] + assert hi > lo, "degenerate band survived set_data" + # Derived from the real FRAME (a native subsample of it), not from the clim. + assert lo < 140.0 < hi and (hi - lo) > 100.0 + assert (p._state["display_min"], p._state["display_max"]) == (35.0, 240.0) + + def test_update_tile_source_rederives_degenerate_band(self): + # The other live seam — a host swapping the backing array directly. This is + # what downstream had to work around by pinning _state["raw_min"/"raw_max"]. + p = self._placeholder() + p.update_tile_source(self._frame()) + assert p._state["raw_max"] > p._state["raw_min"] + + def test_valid_band_is_never_rederived(self): + # The band is fixed ON PURPOSE: a contrast change must re-window in the LUT + # with no pixel re-encode or re-transfer. Only a DEGENERATE band may be + # replaced — a frame with a different range must not move a healthy one. + p = apl.subplots(1, 1)[1].imshow(self._frame()) + band = (p._state["raw_min"], p._state["raw_max"]) + p.set_data(self._frame(mean=900.0, sd=5.0)) + assert (p._state["raw_min"], p._state["raw_max"]) == band + + def test_still_flat_frame_leaves_band_unset(self): + # No honest range to derive → the band stays degenerate and BOTH ends fall + # back to the display window. That agreement is the other half of the fix. + p = self._placeholder() + p.set_data(np.full((2048, 2048), 7.0, np.float32), clim=(0.0, 10.0)) + assert p._state["raw_min"] == p._state["raw_max"] + assert p._tile_quant_clim() == (0.0, 10.0) + + +class TestSetTileBand: + """`set_tile_band` — the public way to PIN the quantisation band, so a host with a + known honest range (camera bit depth, detector saturation) stops reaching into + `_plot2d._state["raw_min"/"raw_max"]` + `update_tile_source()`. GH #60.""" + + @staticmethod + def _tiled(): + return apl.subplots(1, 1)[1].imshow( + np.random.RandomState(0).rand(2048, 2048).astype(np.float32)) + + def test_pins_band_and_reencodes(self): + p = self._tiled() + before = base64.b64decode( + p.resolve_pixel_tokens(p.to_state_dict())["image_b64"]) + p.set_tile_band(-100.0, 1000.0) + assert (p._state["raw_min"], p._state["raw_max"]) == (-100.0, 1000.0) + after = base64.b64decode( + p.resolve_pixel_tokens(p.to_state_dict())["image_b64"]) + # The band the renderer reads the bytes with changed, so the bytes must too — + # a pinned band that didn't re-encode is exactly the disagreement of GH #60. + assert after != before, "band pinned but pixels not re-encoded" + + def test_pinned_band_survives_set_data(self): + # The point of pinning: a later frame must not move the contrast. + p = self._tiled() + p.set_tile_band(0.0, 4095.0) + p.set_data(np.random.RandomState(1).rand(2048, 2048).astype(np.float32)) + assert (p._state["raw_min"], p._state["raw_max"]) == (0.0, 4095.0) + + def test_pinning_recovers_a_placeholder_born_plot(self): + # A source whose frames are still flat has no range to auto-derive; pinning + # is the only way to get an honest band before real content arrives. + p = apl.subplots(1, 1)[1].imshow(np.zeros((2048, 2048), np.float32)) + assert p._state["raw_min"] == p._state["raw_max"] # nothing to derive + p.set_tile_band(0.0, 255.0) + assert p._tile_quant_clim() == (0.0, 255.0) + + def test_rejects_degenerate_range(self): + p = self._tiled() + band = (p._state["raw_min"], p._state["raw_max"]) + with pytest.raises(ValueError, match="vmax > vmin"): + p.set_tile_band(5.0, 5.0) + assert (p._state["raw_min"], p._state["raw_max"]) == band # unchanged + + def test_rejects_untiled_plot(self): + p = apl.subplots(1, 1)[1].imshow(np.zeros((64, 64), np.float32)) + with pytest.raises(RuntimeError, match="requires tile mode"): + p.set_tile_band(0.0, 1.0) + + +class TestTileBandRenderCanvas: + """The black-pane half of GH #60 in a real browser: a panel whose quantisation + band is degenerate must still render its image, not a solid black rectangle.""" + + @staticmethod + def _panel_px(page): + """min / max / mean red channel over the centre of the largest canvas.""" + return page.evaluate("""() => { + const cs = Array.from(document.querySelectorAll('canvas')); + const c = cs.sort((a,b)=>b.width*b.height-a.width*a.height)[0]; + const d = c.getContext('2d').getImageData(0,0,c.width,c.height).data; + let lo=255, hi=0, sum=0, n=0; + for(let y=(c.height*0.35)|0; y<(c.height*0.65)|0; y++) + for(let x=(c.width*0.35)|0; x<(c.width*0.65)|0; x++){ + const v=d[(y*c.width+x)*4]; + if(vhi) hi=v; sum+=v; n++; + } + return {lo, hi, mean: sum/n}; + }""") + + def test_degenerate_band_renders_display_mapped_bytes(self, interact_page): + # The band legitimately STAYS degenerate here (a flat frame has no honest + # range to derive), so this exercises the JS fallback on its own. The encoder + # quantises 7.0 over the display window (0, 10) → code 178; honouring (0, 0) + # in the LUT maps that to t≈0.07 → near-black, the fallback to ≈178 (mid gray). + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.zeros((2048, 2048), np.float32), cmap="gray", gpu=False) + p.set_data(np.full((2048, 2048), 7.0, np.float32), clim=(0.0, 10.0)) + page = interact_page(fig) + page.wait_for_timeout(300) + st = json.loads( + page.evaluate("(pid) => globalThis.__apl_viewStateJson(pid)", p._id)) + assert st["raw_min"] == st["raw_max"], "precondition: band must be degenerate" + px = self._panel_px(page) + assert px["mean"] > 120, f"degenerate band rendered near-black: {px}" + + def test_real_frame_after_placeholder_renders(self, interact_page): + # The reported end-to-end case: tile mode born on a zeros placeholder, then a + # real frame. A horizontal ramp survives the overview mean-downsample and the + # canvas rescale, so "shows structure" is testable, not just "isn't black". + ramp = np.tile(np.linspace(35, 240, 2048, dtype=np.float32), (2048, 1)) + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.zeros((2048, 2048), np.float32), cmap="gray", gpu=False) + p.set_data(ramp, clim=(35.0, 240.0)) + page = interact_page(fig) + page.wait_for_timeout(300) + px = self._panel_px(page) + assert px["mean"] > 60, f"panel rendered black: {px}" + assert px["hi"] - px["lo"] > 30, f"panel rendered flat, no structure: {px}" + + class TestTilePayloadParity: - """Parity guards for scenes where tile=True should be byte-identical to plain.""" + """Parity guards for scenes where tile=True should match plain. + + At CONSTRUCTION both paths quantise over the same range, so the payloads are + byte-identical. After ``set_data`` they need not be: the tiled path quantises + over the FIXED band (raw_min/raw_max, the full-res data range) so a contrast + change re-windows in the LUT with no pixel re-encode, while the plain path + quantises over the caller's clim. The bytes then carry different codes for the + same value and the LUT reconciles them — so the invariant to guard there is + what the viewer SEES, not the wire bytes.""" + + @staticmethod + def _display_idx(plot): + """Colormap index each pixel resolves to — the JS `_rawBand` + `_buildLut32` + (linear) pipeline in numpy, i.e. what actually reaches the screen.""" + st = plot._state + lo, hi = st["raw_min"], st["raw_max"] + if lo is None or hi is None: + lo, hi = st["display_min"], st["display_max"] + elif not hi > lo: + # Degenerate band: unset in tile mode (bytes are display-mapped), a + # constant-value marker on the plain path. Mirrors _rawBand exactly. + lo, hi = ((st["display_min"], st["display_max"]) + if st["tile_enabled"] else (lo, hi)) + dmin, dmax = st["display_min"], st["display_max"] + val = lo + (np.arange(256) / 255) * ((hi - lo) or 1) + t = (val - dmin) / ((dmax - dmin) or 1) + lut = np.clip(np.round(t * 255), 0, 255).astype(np.uint8) + return lut[TestTilePayloadParity._decoded_u8(plot)] @staticmethod def _decoded_u8(plot): @@ -297,8 +486,13 @@ def test_forced_tile_matches_plain_bytes_for_1024_frame(self): ok, msg = compare_arrays_exact(a, b) assert ok, f"plain vs tiled payload mismatch: {msg}" - def test_set_data_stays_identical_between_plain_and_tile(self): - base = np.zeros((1024, 1024), np.float32) + @pytest.mark.parametrize("base_kind", ["zeros-placeholder", "real-data"]) + def test_set_data_displays_the_same_plain_and_tiled(self, base_kind): + # Both bases matter: "real-data" gives the tiled plot a valid band up front, + # "zeros-placeholder" a degenerate one that set_data must re-derive (GH #60). + # Either way the two plots must SHOW the same image. + base = (np.zeros((1024, 1024), np.float32) if base_kind == "zeros-placeholder" + else np.random.RandomState(9).rand(1024, 1024).astype(np.float32)) nxt = np.random.RandomState(1).rand(1024, 1024).astype(np.float32) plain = apl.subplots(1, 1)[1].imshow( base, cmap="gray", vmin=0.0, vmax=1.0, tile=False, gpu=False @@ -308,10 +502,14 @@ def test_set_data_stays_identical_between_plain_and_tile(self): ) plain.set_data(nxt, clim=(0.0, 1.0), tile=False) tiled.set_data(nxt, clim=(0.0, 1.0), tile=True) - a = self._decoded_u8(plain) - b = self._decoded_u8(tiled) - ok, msg = compare_arrays_exact(a, b) - assert ok, f"set_data parity mismatch: {msg}" + # The tiled plot quantises over its band, the plain one over the clim, so the + # wire bytes may disagree by a rounding step — but once each is read back + # through its OWN band, the displayed intensities must agree. + d = np.abs(self._display_idx(plain).astype(np.int16) + - self._display_idx(tiled).astype(np.int16)) + assert d.max() <= 1, ( + f"plain vs tiled display mismatch: max |diff| = {d.max()} colormap " + f"steps over {d.size} px ({int((d > 1).sum())} px worse than rounding)") class TestTiledRenderCanvas: diff --git a/upcoming_changes/60.api_change.rst b/upcoming_changes/60.api_change.rst new file mode 100644 index 00000000..b4eaa6fb --- /dev/null +++ b/upcoming_changes/60.api_change.rst @@ -0,0 +1,9 @@ +In tile mode, ``set_data``/``update_tile_source`` now re-derive the quantisation band +``raw_min``/``raw_max`` from the incoming frame when the current band is unset or +degenerate (it previously stayed as first derived, even from a flat placeholder). 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. One visible consequence: a tiled plot +born on a placeholder now quantises subsequent frames over the frame's own range +rather than the display window, matching what ``imshow`` of a large frame has always +done — so its wire bytes can differ by a rounding step from the equivalent untiled +plot, while the displayed image is unchanged. diff --git a/upcoming_changes/60.bugfix.rst b/upcoming_changes/60.bugfix.rst new file mode 100644 index 00000000..7970ceea --- /dev/null +++ b/upcoming_changes/60.bugfix.rst @@ -0,0 +1,12 @@ +Fixed a tiled 2-D image rendering solid black when tile mode was entered on a flat +placeholder frame (e.g. ``imshow`` of zeros before real data exists). The fixed +quantisation band ``raw_min``/``raw_max`` was derived from that placeholder — a +degenerate ``(0, 0)`` — and no later ``set_data`` re-derived it, leaving the two ends +of the protocol disagreeing about what it meant: the Python encoder treats a +degenerate band as unset and quantises over the display window, while the renderer +honoured ``(0, 0)`` and mapped every code below the display floor. The panel rendered +black on the WebGPU and Canvas2D paths alike, beside perfectly healthy stats and +histograms, with no warning. ``set_data`` and ``update_tile_source`` now re-derive a +degenerate band from the incoming frame, and the renderer falls back to the display +window for a degenerate band in tile mode — in the image LUT and in the colorbar +tick placement, which read the band the same way. diff --git a/upcoming_changes/60.new_feature.rst b/upcoming_changes/60.new_feature.rst new file mode 100644 index 00000000..f6acfd26 --- /dev/null +++ b/upcoming_changes/60.new_feature.rst @@ -0,0 +1,6 @@ +Added :meth:`~anyplotlib.plot2d.Plot2D.set_tile_band` to pin the fixed quantisation +band tile bytes are encoded over, for a consumer that already knows the honest range +(a camera's bit depth, a detector's saturation point) or whose source starts flat and +so has no range to auto-derive. It re-samples the overview and any active detail tile +over the new band in a single push, replacing the practice of reaching into +``plot._state["raw_min"/"raw_max"]`` and calling ``update_tile_source()`` by hand.