Skip to content

Commit a202fd0

Browse files
committed
feat(plot2d): set_display_window — re-window contrast without re-quantising
`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)
1 parent 729cb16 commit a202fd0

3 files changed

Lines changed: 178 additions & 1 deletion

File tree

anyplotlib/plot2d/_plot2d.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1470,7 +1470,11 @@ def set_clim(self, vmin=None, vmax=None) -> None:
14701470
raw frame, not merely re-windowing the existing codes (which are saturated
14711471
outside the previous band and so couldn't widen past it). For an RGB frame
14721472
(no scalar quantisation) or when no raw frame is cached, fall back to a pure
1473-
display-window update."""
1473+
display-window update.
1474+
1475+
See :meth:`set_display_window` for the non-destructive counterpart — the
1476+
one to reach for when the pixels must not move (a tiled plot, or a
1477+
serialised figure being re-windowed with no Python behind it)."""
14741478
new_min = float(vmin) if vmin is not None else self._state.get("display_min")
14751479
new_max = float(vmax) if vmax is not None else self._state.get("display_max")
14761480

@@ -1512,6 +1516,40 @@ def set_clim(self, vmin=None, vmax=None) -> None:
15121516
self._state["display_max"] = float(vmax)
15131517
self._push()
15141518

1519+
def set_display_window(self, vmin=None, vmax=None) -> None:
1520+
"""Move the display window WITHOUT re-quantising the pixels.
1521+
1522+
The non-destructive counterpart to :meth:`set_clim`. Both change the
1523+
contrast; they differ in what they do to the data behind it:
1524+
1525+
``set_clim``
1526+
re-encodes the cached raw frame over the new range, so the codes
1527+
always span exactly the visible band — maximum precision for what is
1528+
on screen, but the pixels are re-encoded and re-sent, and the old
1529+
band is gone.
1530+
``set_display_window``
1531+
leaves the codes and their ``raw_min``/``raw_max`` band alone and
1532+
moves only the window the LUT maps through it. Nothing is re-encoded
1533+
and nothing travels but two floats.
1534+
1535+
Use it when the pixels must stay put: a tiled plot, where re-quantising
1536+
would re-encode the full-res frame on every drag tick (``set_clim``
1537+
already routes there internally), or a figure that has been serialised
1538+
and is being re-windowed with no Python behind it — which is how a saved
1539+
page gets a working contrast control at all.
1540+
1541+
The trade is precision. Quantisation spans ``[raw_min, raw_max]``, so a
1542+
window much narrower than that band resolves in coarse steps, and one
1543+
WIDER than it recovers nothing: values outside the band were saturated
1544+
to 0/255 when the frame was encoded. Quantise over the range you want to
1545+
be able to reach.
1546+
"""
1547+
if vmin is not None:
1548+
self._state["display_min"] = float(vmin)
1549+
if vmax is not None:
1550+
self._state["display_max"] = float(vmax)
1551+
self._push()
1552+
15151553
def set_detail(self, tile=None, x0=None, x1=None, y0=None, y1=None) -> None:
15161554
"""Upload a HIGH-RES detail tile covering the LOGICAL image-pixel rectangle
15171555
``[x0:x1, y0:y1]`` of the base image (in the SAME orientation as the frame
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""
2+
tests/test_plot2d/test_display_window.py
3+
========================================
4+
``Plot2D.set_display_window`` — moving the contrast window WITHOUT re-quantising.
5+
6+
The distinction from ``set_clim`` is the whole point, so these pin it from both
7+
sides: ``set_clim`` re-encodes the frame and collapses ``raw_*`` onto the new
8+
band; ``set_display_window`` leaves the codes and the band alone and moves only
9+
the window the LUT maps through.
10+
11+
That difference is what decides whether a SERIALISED figure can have a working
12+
contrast control. A page saved after ``set_clim`` holds codes saturated outside
13+
the band it was saved with, so widening in JS recovers nothing; quantised over a
14+
wide band and windowed with ``set_display_window``, the same page can be
15+
re-windowed either way with no Python behind it.
16+
"""
17+
from __future__ import annotations
18+
19+
import numpy as np
20+
21+
import anyplotlib as apl
22+
23+
24+
def _plot(data=None):
25+
fig, ax = apl.subplots(1, 1)
26+
if data is None:
27+
data = np.arange(64, dtype=float).reshape(8, 8)
28+
return ax.imshow(data)
29+
30+
31+
class TestWindowMoves:
32+
def test_it_sets_both_ends(self):
33+
p = _plot()
34+
p.set_display_window(10.0, 40.0)
35+
assert p._state["display_min"] == 10.0
36+
assert p._state["display_max"] == 40.0
37+
38+
def test_either_end_alone_leaves_the_other(self):
39+
p = _plot()
40+
p.set_display_window(10.0, 40.0)
41+
p.set_display_window(vmax=25.0)
42+
assert p._state["display_min"] == 10.0
43+
assert p._state["display_max"] == 25.0
44+
45+
def test_it_pushes_so_the_change_reaches_js(self):
46+
# The _push() contract: a mutation that does not push never appears.
47+
p = _plot()
48+
seen = []
49+
p._push = lambda *a, **k: seen.append(1)
50+
p.set_display_window(1.0, 2.0)
51+
assert seen, "set_display_window did not push"
52+
53+
54+
class TestPixelsStayPut:
55+
"""The defining property. If the codes move, this is just a slow set_clim."""
56+
57+
def test_the_encoded_pixels_are_untouched(self):
58+
p = _plot()
59+
before = p._state["image_b64"]
60+
p.set_display_window(10.0, 40.0)
61+
assert p._state["image_b64"] == before
62+
63+
def test_the_quantisation_band_is_untouched(self):
64+
p = _plot()
65+
raw_before = (p._state["raw_min"], p._state["raw_max"])
66+
p.set_display_window(10.0, 40.0)
67+
assert (p._state["raw_min"], p._state["raw_max"]) == raw_before
68+
69+
def test_set_clim_by_contrast_re_encodes_and_collapses_the_band(self):
70+
# The counterpart, asserted here so the pair cannot silently converge.
71+
p = _plot()
72+
before = p._state["image_b64"]
73+
p.set_clim(10.0, 40.0)
74+
assert p._state["image_b64"] != before
75+
assert p._state["raw_min"] == p._state["display_min"] == 10.0
76+
assert p._state["raw_max"] == p._state["display_max"] == 40.0
77+
78+
79+
class TestHeadroomForASerialisedFigure:
80+
def test_a_wide_band_keeps_room_to_window_in_both_directions(self):
81+
# Quantise over the full range, then narrow: the codes still span the
82+
# whole range, so a reader can widen back out. This is exactly what an
83+
# exported page needs and what set_clim cannot give it.
84+
data = np.arange(256, dtype=float).reshape(16, 16)
85+
fig, ax = apl.subplots(1, 1)
86+
p = ax.imshow(data, vmin=0.0, vmax=255.0)
87+
88+
p.set_display_window(100.0, 150.0)
89+
assert p._state["raw_min"] == 0.0 and p._state["raw_max"] == 255.0
90+
assert (p._state["display_min"], p._state["display_max"]) == (100.0, 150.0)
91+
92+
# …and back out past the narrow window, still against the full band.
93+
p.set_display_window(0.0, 255.0)
94+
assert (p._state["display_min"], p._state["display_max"]) == (0.0, 255.0)
95+
assert p._state["raw_min"] == 0.0 and p._state["raw_max"] == 255.0
96+
97+
def test_set_clim_first_would_have_thrown_that_away(self):
98+
data = np.arange(256, dtype=float).reshape(16, 16)
99+
fig, ax = apl.subplots(1, 1)
100+
p = ax.imshow(data, vmin=0.0, vmax=255.0)
101+
102+
p.set_clim(100.0, 150.0)
103+
# Everything outside 100–150 is saturated in the codes now, so the band
104+
# a serialised page could re-window within has collapsed to the window.
105+
assert p._state["raw_min"] == 100.0 and p._state["raw_max"] == 150.0
106+
107+
108+
class TestRgbAndTile:
109+
def test_an_rgb_frame_windows_the_same_way(self):
110+
rgb = np.zeros((8, 8, 3), np.uint8)
111+
fig, ax = apl.subplots(1, 1)
112+
p = ax.imshow(rgb)
113+
p.set_display_window(0.2, 0.8)
114+
assert (p._state["display_min"], p._state["display_max"]) == (0.2, 0.8)
115+
116+
def test_it_matches_what_set_clim_already_does_in_tile_mode(self):
117+
# set_clim's tile branch is this method's behaviour, inlined. Pin that
118+
# they agree so the two cannot drift apart.
119+
data = np.arange(256, dtype=float).reshape(16, 16)
120+
fig, ax = apl.subplots(1, 1)
121+
p = ax.imshow(data, vmin=0.0, vmax=255.0)
122+
p._tile_on = True
123+
before = p._state["image_b64"]
124+
125+
p.set_clim(60.0, 90.0)
126+
via_clim = (p._state["display_min"], p._state["display_max"],
127+
p._state["raw_min"], p._state["raw_max"],
128+
p._state["image_b64"] == before)
129+
130+
p._tile_on = False
131+
p.set_display_window(60.0, 90.0)
132+
via_window = (p._state["display_min"], p._state["display_max"],
133+
p._state["raw_min"], p._state["raw_max"],
134+
p._state["image_b64"] == before)
135+
assert via_clim == via_window
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added :meth:`~anyplotlib.plot2d.Plot2D.set_display_window`, which moves the
2+
contrast window without re-quantising the pixels — the non-destructive
3+
counterpart to :meth:`~anyplotlib.plot2d.Plot2D.set_clim`, and what lets a
4+
saved page be re-windowed with no Python behind it.

0 commit comments

Comments
 (0)