From a202fd060e5f50a66fab7d166300703077e9a6a5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 25 Aug 2026 05:59:08 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(plot2d):=20set=5Fdisplay=5Fwindow=20?= =?UTF-8?q?=E2=80=94=20re-window=20contrast=20without=20re-quantising?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set_clim` re-encodes the cached raw frame over the new range. That is the right default: the codes then span exactly the visible band, so the contrast on screen gets all 8 bits. But it means the pixels move, and the previous band is gone — everything outside the new range is saturated to 0/255 in the codes that get stored. For a figure that has been SERIALISED that is the difference between having a contrast control and not having one. `build_standalone_html` writes the codes plus `raw_min`/`raw_max`, and the JS rebuilds its LUT from `display_min`/ `display_max` over that band (`_buildLut32`) — so a saved page can re-window freely inside the band it was encoded with, and not at all outside it. Encode with `set_clim` at the display window and the band IS the window: the identity LUT, nothing to move. The non-destructive path already existed, inlined in `set_clim`'s tile branch, where re-quantising would re-encode a full-res frame on every drag tick. This promotes it to a documented public method so a caller can choose the trade deliberately: p = ax.imshow(frame, vmin=lo, vmax=hi) # quantise over a WIDE band p.set_display_window(black, white) # window inside it, no re-encode The cost is precision — a window much narrower than the encoding band resolves in coarser steps — so the docstring says to quantise over the range you want to be able to reach. Tests pin the distinction from both sides, including that `set_clim` in tile mode and `set_display_window` produce identical state, so the two cannot quietly diverge. Assisted-by: Claude Opus 5 (1M context) --- anyplotlib/plot2d/_plot2d.py | 40 +++++- .../tests/test_plot2d/test_display_window.py | 135 ++++++++++++++++++ .../+display-window.new_feature.rst | 4 + 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 anyplotlib/tests/test_plot2d/test_display_window.py create mode 100644 upcoming_changes/+display-window.new_feature.rst diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 435860214..ee51668be 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -1470,7 +1470,11 @@ def set_clim(self, vmin=None, vmax=None) -> None: raw frame, not merely re-windowing the existing codes (which are saturated outside the previous band and so couldn't widen past it). For an RGB frame (no scalar quantisation) or when no raw frame is cached, fall back to a pure - display-window update.""" + display-window update. + + See :meth:`set_display_window` for the non-destructive counterpart — the + one to reach for when the pixels must not move (a tiled plot, or a + serialised figure being re-windowed with no Python behind it).""" new_min = float(vmin) if vmin is not None else self._state.get("display_min") new_max = float(vmax) if vmax is not None else self._state.get("display_max") @@ -1512,6 +1516,40 @@ def set_clim(self, vmin=None, vmax=None) -> None: self._state["display_max"] = float(vmax) self._push() + def set_display_window(self, vmin=None, vmax=None) -> None: + """Move the display window WITHOUT re-quantising the pixels. + + The non-destructive counterpart to :meth:`set_clim`. Both change the + contrast; they differ in what they do to the data behind it: + + ``set_clim`` + re-encodes the cached raw frame over the new range, so the codes + always span exactly the visible band — maximum precision for what is + on screen, but the pixels are re-encoded and re-sent, and the old + band is gone. + ``set_display_window`` + leaves the codes and their ``raw_min``/``raw_max`` band alone and + moves only the window the LUT maps through it. Nothing is re-encoded + and nothing travels but two floats. + + Use it when the pixels must stay put: a tiled plot, where re-quantising + would re-encode the full-res frame on every drag tick (``set_clim`` + already routes there internally), or a figure that has been serialised + and is being re-windowed with no Python behind it — which is how a saved + page gets a working contrast control at all. + + The trade is precision. Quantisation spans ``[raw_min, raw_max]``, so a + window much narrower than that band resolves in coarse steps, and one + WIDER than it recovers nothing: values outside the band were saturated + to 0/255 when the frame was encoded. Quantise over the range you want to + be able to reach. + """ + if vmin is not None: + self._state["display_min"] = float(vmin) + if vmax is not None: + self._state["display_max"] = float(vmax) + self._push() + def set_detail(self, tile=None, x0=None, x1=None, y0=None, y1=None) -> None: """Upload a HIGH-RES detail tile covering the LOGICAL image-pixel rectangle ``[x0:x1, y0:y1]`` of the base image (in the SAME orientation as the frame diff --git a/anyplotlib/tests/test_plot2d/test_display_window.py b/anyplotlib/tests/test_plot2d/test_display_window.py new file mode 100644 index 000000000..e8134a6bb --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_display_window.py @@ -0,0 +1,135 @@ +""" +tests/test_plot2d/test_display_window.py +======================================== +``Plot2D.set_display_window`` — moving the contrast window WITHOUT re-quantising. + +The distinction from ``set_clim`` is the whole point, so these pin it from both +sides: ``set_clim`` re-encodes the frame and collapses ``raw_*`` onto the new +band; ``set_display_window`` leaves the codes and the band alone and moves only +the window the LUT maps through. + +That difference is what decides whether a SERIALISED figure can have a working +contrast control. A page saved after ``set_clim`` holds codes saturated outside +the band it was saved with, so widening in JS recovers nothing; quantised over a +wide band and windowed with ``set_display_window``, the same page can be +re-windowed either way with no Python behind it. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + + +def _plot(data=None): + fig, ax = apl.subplots(1, 1) + if data is None: + data = np.arange(64, dtype=float).reshape(8, 8) + return ax.imshow(data) + + +class TestWindowMoves: + def test_it_sets_both_ends(self): + p = _plot() + p.set_display_window(10.0, 40.0) + assert p._state["display_min"] == 10.0 + assert p._state["display_max"] == 40.0 + + def test_either_end_alone_leaves_the_other(self): + p = _plot() + p.set_display_window(10.0, 40.0) + p.set_display_window(vmax=25.0) + assert p._state["display_min"] == 10.0 + assert p._state["display_max"] == 25.0 + + def test_it_pushes_so_the_change_reaches_js(self): + # The _push() contract: a mutation that does not push never appears. + p = _plot() + seen = [] + p._push = lambda *a, **k: seen.append(1) + p.set_display_window(1.0, 2.0) + assert seen, "set_display_window did not push" + + +class TestPixelsStayPut: + """The defining property. If the codes move, this is just a slow set_clim.""" + + def test_the_encoded_pixels_are_untouched(self): + p = _plot() + before = p._state["image_b64"] + p.set_display_window(10.0, 40.0) + assert p._state["image_b64"] == before + + def test_the_quantisation_band_is_untouched(self): + p = _plot() + raw_before = (p._state["raw_min"], p._state["raw_max"]) + p.set_display_window(10.0, 40.0) + assert (p._state["raw_min"], p._state["raw_max"]) == raw_before + + def test_set_clim_by_contrast_re_encodes_and_collapses_the_band(self): + # The counterpart, asserted here so the pair cannot silently converge. + p = _plot() + before = p._state["image_b64"] + p.set_clim(10.0, 40.0) + assert p._state["image_b64"] != before + assert p._state["raw_min"] == p._state["display_min"] == 10.0 + assert p._state["raw_max"] == p._state["display_max"] == 40.0 + + +class TestHeadroomForASerialisedFigure: + def test_a_wide_band_keeps_room_to_window_in_both_directions(self): + # Quantise over the full range, then narrow: the codes still span the + # whole range, so a reader can widen back out. This is exactly what an + # exported page needs and what set_clim cannot give it. + data = np.arange(256, dtype=float).reshape(16, 16) + fig, ax = apl.subplots(1, 1) + p = ax.imshow(data, vmin=0.0, vmax=255.0) + + p.set_display_window(100.0, 150.0) + assert p._state["raw_min"] == 0.0 and p._state["raw_max"] == 255.0 + assert (p._state["display_min"], p._state["display_max"]) == (100.0, 150.0) + + # …and back out past the narrow window, still against the full band. + p.set_display_window(0.0, 255.0) + assert (p._state["display_min"], p._state["display_max"]) == (0.0, 255.0) + assert p._state["raw_min"] == 0.0 and p._state["raw_max"] == 255.0 + + def test_set_clim_first_would_have_thrown_that_away(self): + data = np.arange(256, dtype=float).reshape(16, 16) + fig, ax = apl.subplots(1, 1) + p = ax.imshow(data, vmin=0.0, vmax=255.0) + + p.set_clim(100.0, 150.0) + # Everything outside 100–150 is saturated in the codes now, so the band + # a serialised page could re-window within has collapsed to the window. + assert p._state["raw_min"] == 100.0 and p._state["raw_max"] == 150.0 + + +class TestRgbAndTile: + def test_an_rgb_frame_windows_the_same_way(self): + rgb = np.zeros((8, 8, 3), np.uint8) + fig, ax = apl.subplots(1, 1) + p = ax.imshow(rgb) + p.set_display_window(0.2, 0.8) + assert (p._state["display_min"], p._state["display_max"]) == (0.2, 0.8) + + def test_it_matches_what_set_clim_already_does_in_tile_mode(self): + # set_clim's tile branch is this method's behaviour, inlined. Pin that + # they agree so the two cannot drift apart. + data = np.arange(256, dtype=float).reshape(16, 16) + fig, ax = apl.subplots(1, 1) + p = ax.imshow(data, vmin=0.0, vmax=255.0) + p._tile_on = True + before = p._state["image_b64"] + + p.set_clim(60.0, 90.0) + via_clim = (p._state["display_min"], p._state["display_max"], + p._state["raw_min"], p._state["raw_max"], + p._state["image_b64"] == before) + + p._tile_on = False + p.set_display_window(60.0, 90.0) + via_window = (p._state["display_min"], p._state["display_max"], + p._state["raw_min"], p._state["raw_max"], + p._state["image_b64"] == before) + assert via_clim == via_window diff --git a/upcoming_changes/+display-window.new_feature.rst b/upcoming_changes/+display-window.new_feature.rst new file mode 100644 index 000000000..4853cd82c --- /dev/null +++ b/upcoming_changes/+display-window.new_feature.rst @@ -0,0 +1,4 @@ +Added :meth:`~anyplotlib.plot2d.Plot2D.set_display_window`, which moves the +contrast window without re-quantising the pixels — the non-destructive +counterpart to :meth:`~anyplotlib.plot2d.Plot2D.set_clim`, and what lets a +saved page be re-windowed with no Python behind it. From 4ef7b6800eb036e3ddcd13707bd65705575a4728 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 25 Aug 2026 06:00:28 -0500 Subject: [PATCH 2/2] chore: name the changelog fragment for PR 61 The orphan '+' form is for a change with no PR number; this one has one now, so towncrier can render the link. Assisted-by: Claude Opus 5 (1M context) --- .../{+display-window.new_feature.rst => 61.new_feature.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename upcoming_changes/{+display-window.new_feature.rst => 61.new_feature.rst} (100%) diff --git a/upcoming_changes/+display-window.new_feature.rst b/upcoming_changes/61.new_feature.rst similarity index 100% rename from upcoming_changes/+display-window.new_feature.rst rename to upcoming_changes/61.new_feature.rst