Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion anyplotlib/plot2d/_plot2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down
135 changes: 135 additions & 0 deletions anyplotlib/tests/test_plot2d/test_display_window.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions upcoming_changes/61.new_feature.rst
Original file line number Diff line number Diff line change
@@ -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.
Loading