From 35bbaa1ce82263f31efe968494fb39fa2f89bd0c Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:05:07 -0500 Subject: [PATCH 01/23] Add failing test: initial display must use stored cuts The bqplot widget renders a freshly loaded image with min/max scaling instead of the percentile cuts the API layer stores on load, so images with a few bright pixels display as all black. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/tests/test_widget_api_bqplot.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index baf23ff..6598baa 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -1,3 +1,4 @@ +import numpy as np import pytest from traitlets import TraitError @@ -30,6 +31,21 @@ def test_save(self, tmp_path): def test_save_overwrite(self, tmp_path): pass + def test_initial_display_uses_stored_cuts(self): + # A high-dynamic-range image: narrow uint16 background plus a single + # saturated pixel. Scaling to the full data range would render the + # background essentially black, so the initial display must use the + # stored cuts (a percentile interval), not min/max. + rng = np.random.default_rng(seed=42) + arr = rng.integers(1100, 1300, size=(50, 60)).astype(np.uint16) + arr[25, 30] = 65535 + + self.image.load_image(arr) + + displayed = np.asarray(self.image._astro_im._image.image) + expected = self.image.get_cuts()(arr) + np.testing.assert_allclose(displayed, expected) + def test_get_viewport_reflects_interactive_pan(self, data): # Panning in the browser shifts the bqplot scales directly. Simulate # that here and check that get_viewport reports the new center. From 6316cc6543e21c705e79f07aabc71b50e80da751 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:05:42 -0500 Subject: [PATCH 02/23] Display newly loaded images via a single _refresh_display path The API layer stores percentile cuts and a linear stretch while loading, but its set_cuts/set_stretch calls fire before self._data is assigned, so they never reach the screen. load_image then sent the data with no cuts, falling back to MinMaxInterval, which renders typical astronomical images (narrow background plus a few bright pixels) as all black. Add a _refresh_display method that recomputes the displayed array from the cuts and stretch stored for the image, and call it from load_image once the data is in place, so a display refresh works the same no matter what triggered it. Drop the self._cuts attribute that nothing ever read. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/bqplot.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index e8d1fdc..326cbb6 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -382,7 +382,6 @@ def __init__(self, *args, display_width=500, display_aspect_ratio=1): self._print_out = ipw.Output() self.marker = {'color': 'red', 'radius': 20, 'type': 'square'} - self._cuts = apviz.AsymmetricPercentileInterval(1, 99) self._cursor = ipw.HTML('Coordinates show up here') @@ -539,6 +538,18 @@ def _send_data(self, reset_view=True, stretch=None, cuts=None): self._astro_im.set_data(self._interval_and_stretch(stretch=stretch, cuts=cuts), reset_view=reset_view) + def _refresh_display(self, image_label=None, reset_view=False): + """ + Recompute the displayed array from the cuts and stretch stored + for the image and send it to the viewer. + """ + if self._data is None: + return + + self._send_data(cuts=self.get_cuts(image_label=image_label), + stretch=self.get_stretch(image_label=image_label), + reset_view=reset_view) + @property def _current_image_label(self): """ @@ -569,7 +580,7 @@ def load_image(self, image, image_label=None, **kwargs): data = self.get_image(image_label=image_label) self._data = data.data if isinstance(data, NDData) else data - self._send_data() + self._refresh_display(image_label=image_label, reset_view=True) # Saving contents of the view and accessing the view def save(self, filename, overwrite=False, **kwargs): From 71987db7a5bbcdd42fec0370189b4cd4997ed9f5 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:06:09 -0500 Subject: [PATCH 03/23] Add failing test: default cuts should be the 30-96 percentile interval The widget-level fallback cuts are MinMaxInterval, which renders a typical astronomical image (narrow background plus a few bright pixels) as nearly all black. The fallback should instead cut out the sky background at the bottom and clip only the brightest pixels at the top; the 30-96 percentile interval looks good in practice. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/tests/test_widget_api_bqplot.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index 6598baa..cdc2201 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -1,3 +1,4 @@ +import astropy.visualization as apviz import numpy as np import pytest @@ -46,6 +47,18 @@ def test_initial_display_uses_stored_cuts(self): expected = self.image.get_cuts()(arr) np.testing.assert_allclose(displayed, expected) + def test_default_cuts_are_30_96_percentile(self): + # When no cuts are stored or passed, the widget's fallback should + # be the 30-96 percentile interval, which cuts out the sky + # background and clips only the brightest pixels. + rng = np.random.default_rng(seed=42) + arr = rng.integers(1100, 1300, size=(50, 60)).astype(np.uint16) + arr[25, 30] = 65535 + + self.image._data = arr + expected = apviz.AsymmetricPercentileInterval(30, 96)(arr) + np.testing.assert_allclose(self.image._interval_and_stretch(), expected) + def test_get_viewport_reflects_interactive_pan(self, data): # Panning in the browser shifts the bqplot scales directly. Simulate # that here and check that get_viewport reports the new center. From ca40db6aafb520b10283f146afd334c4fd70f984 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:06:31 -0500 Subject: [PATCH 04/23] Use AsymmetricPercentileInterval(30, 96) as the fallback cuts Replace the MinMaxInterval fallback with cuts that remove the sky background below the 30th percentile and clip the brightest pixels above the 96th. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/bqplot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 326cbb6..1469130 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -364,7 +364,9 @@ def __init__(self, *args, display_width=500, display_aspect_ratio=1): self._astro_im = _AstroImage(display_width=display_width, viewer_aspect_ratio=display_aspect_ratio) - self._default_cuts = apviz.MinMaxInterval() + # Cut out the sky background at the bottom and clip only the + # brightest pixels at the top. + self._default_cuts = apviz.AsymmetricPercentileInterval(30, 96) self._default_stretch = None self._data = None From 20fcb7283be0a683c3eb39f4bb6be103fc5c5245 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:06:54 -0500 Subject: [PATCH 05/23] Add failing test: default colormap should be Greys_r Nothing currently sets a colormap: the API layer leaves the per-image colormap as None on load, and the bqplot ColorScale falls back to the d3 'Greys' scheme, which maps low values to white -- the inverse of the usual astronomical convention. Test that a fresh widget and a freshly loaded image both display with Greys_r and that get_colormap reports it. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/tests/test_widget_api_bqplot.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index cdc2201..2f47661 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -10,7 +10,7 @@ _ = pytest.importorskip("bqplot", reason="Package required for test is not " "available.") -from astrowidgets.bqplot import ImageWidget # noqa: E402 +from astrowidgets.bqplot import ImageWidget, bqcolors # noqa: E402 def test_instance(): @@ -59,6 +59,18 @@ def test_default_cuts_are_30_96_percentile(self): expected = apviz.AsymmetricPercentileInterval(30, 96)(arr) np.testing.assert_allclose(self.image._interval_and_stretch(), expected) + def test_default_colormap_is_greys_r(self, data): + # With no colormap explicitly set, the display should use Greys_r + # (low = black, high = white), the usual astronomical convention, + # both before and after an image is loaded, and get_colormap should + # report it. + greys_r = bqcolors('Greys_r') + assert self.image._astro_im._image.scales['image'].colors == greys_r + + self.image.load_image(data) + assert self.image.get_colormap() == 'Greys_r' + assert self.image._astro_im._image.scales['image'].colors == greys_r + def test_get_viewport_reflects_interactive_pan(self, data): # Panning in the browser shifts the bqplot scales directly. Simulate # that here and check that get_viewport reports the new center. From 7abffd377e6f580b107be6651aa00d165a2583e8 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:07:17 -0500 Subject: [PATCH 06/23] Make Greys_r the default colormap Apply Greys_r to the color scale when the widget is constructed so even an empty viewer starts with the conventional low-is-black mapping, and record it via set_colormap when a freshly loaded image has no colormap stored. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/bqplot.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 1469130..522513e 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -368,6 +368,8 @@ def __init__(self, *args, display_width=500, display_aspect_ratio=1): # brightest pixels at the top. self._default_cuts = apviz.AsymmetricPercentileInterval(30, 96) self._default_stretch = None + self._default_colormap = 'Greys_r' + self._astro_im.set_color(bqcolors(self._default_colormap)) self._data = None self._wcs = None @@ -584,6 +586,11 @@ def load_image(self, image, image_label=None, **kwargs): self._data = data.data if isinstance(data, NDData) else data self._refresh_display(image_label=image_label, reset_view=True) + # The API layer does not store a colormap on load, so record the + # default so that get_colormap reports what is displayed. + if self.get_colormap(image_label=image_label) is None: + self.set_colormap(self._default_colormap, image_label=image_label) + # Saving contents of the view and accessing the view def save(self, filename, overwrite=False, **kwargs): """ From 1ad5ad98230703d61dd657b8d9c66132c0fbc2d3 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:07:40 -0500 Subject: [PATCH 07/23] Add failing tests: changing cuts or stretch must keep the other setting set_stretch passes cuts=None to _send_data, so changing the stretch silently swaps the stored percentile cuts for the widget's fallback cuts -- the same class of bug load_image had before this branch fixed it there. set_cuts drops the stored stretch the same way. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/tests/test_widget_api_bqplot.py | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index 2f47661..c1c797b 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -71,6 +71,38 @@ def test_default_colormap_is_greys_r(self, data): assert self.image.get_colormap() == 'Greys_r' assert self.image._astro_im._image.scales['image'].colors == greys_r + def test_set_stretch_keeps_stored_cuts(self): + # Changing the stretch must re-display using the cuts stored for + # the image, not fall back to the widget's default cuts. + rng = np.random.default_rng(seed=42) + arr = rng.integers(1100, 1300, size=(50, 60)).astype(np.uint16) + arr[25, 30] = 65535 + + self.image.load_image(arr) + stretch = apviz.LogStretch() + self.image.set_stretch(stretch) + + displayed = np.asarray(self.image._astro_im._image.image) + expected = stretch(self.image.get_cuts()(arr)) + np.testing.assert_allclose(displayed, expected) + + def test_set_cuts_keeps_stored_stretch(self): + # Changing the cuts must re-display using the stretch stored for + # the image, not fall back to the widget's default stretch. + rng = np.random.default_rng(seed=42) + arr = rng.integers(1100, 1300, size=(50, 60)).astype(np.uint16) + arr[25, 30] = 65535 + + self.image.load_image(arr) + stretch = apviz.LogStretch() + self.image.set_stretch(stretch) + cuts = apviz.AsymmetricPercentileInterval(5, 90) + self.image.set_cuts(cuts) + + displayed = np.asarray(self.image._astro_im._image.image) + expected = stretch(cuts(arr)) + np.testing.assert_allclose(displayed, expected) + def test_get_viewport_reflects_interactive_pan(self, data): # Panning in the browser shifts the bqplot scales directly. Simulate # that here and check that get_viewport reports the new center. From c878c713f91ddb0b0ff5f8bb7186157aff655196 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:08:00 -0500 Subject: [PATCH 08/23] Refresh the display from stored settings when cuts or stretch change set_stretch sent the new stretch with cuts=None and set_cuts sent the new cuts with stretch=None, so changing one setting silently swapped the stored value of the other for the widget's defaults. Have both setters call _refresh_display, which reads the stored cuts and stretch itself, so a display refresh works the same no matter which setting changed. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/bqplot.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 522513e..222fe61 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -565,14 +565,13 @@ def set_stretch(self, value, image_label=None, **kwargs): super().set_stretch(value, image_label=image_label, **kwargs) # Changing the stretch only affects the color mapping, so leave the # current viewport (zoom/pan) untouched. - self._send_data(stretch=value, reset_view=False) + self._refresh_display(image_label=image_label) def set_cuts(self, value, image_label=None, **kwargs): super().set_cuts(value, image_label=image_label, **kwargs) # Changing the cuts only affects the color mapping, so leave the # current viewport (zoom/pan) untouched. - self._send_data(cuts=self.get_cuts(image_label=image_label), - reset_view=False) + self._refresh_display(image_label=image_label) @property def viewer(self): From c10e61b2cae99d9dd5773eed12b6fa47cb044ab4 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:08:28 -0500 Subject: [PATCH 09/23] Add failing test: load_image must batch front-end updates Loading an image assigns many traitlets in sequence -- the viewport initialization moves the scales while the old image is still shown, then the image data, x, and y extents update one message at a time. Each sync triggers a browser redraw, so switching images flickers through several visible intermediate states. Test that loading an image sends at most one state message per widget (the image mark and each scale) without changing the end state. The send_state spies come from pytest-mock's mocker fixture; declare pytest-mock in the test extras. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/tests/test_widget_api_bqplot.py | 33 ++++++++++++++++++++ setup.cfg | 1 + 2 files changed, 34 insertions(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index c1c797b..182721d 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -103,6 +103,39 @@ def test_set_cuts_keeps_stored_stretch(self): expected = stretch(cuts(arr)) np.testing.assert_allclose(displayed, expected) + def test_load_image_batches_frontend_updates(self, data, mocker): + # Every traitlet assignment is normally synced to the browser as + # its own message, each triggering a redraw, so loading an image + # produced a series of visible intermediate states (flicker). + # Loading should batch the updates so the image mark and each + # scale send at most one state message. + self.image.load_image(data, image_label='first') + + astro_im = self.image._astro_im + image_mark = astro_im._image + scale_x = astro_im._scales['x'] + scale_y = astro_im._scales['y'] + + # Use a different shape so the image extent and scales all change. + arr = np.arange(30 * 40, dtype=float).reshape(30, 40) + + spy_image = mocker.spy(image_mark, 'send_state') + spy_x = mocker.spy(scale_x, 'send_state') + spy_y = mocker.spy(scale_y, 'send_state') + + self.image.load_image(arr, image_label='second') + + assert spy_image.call_count <= 1 + assert spy_x.call_count <= 1 + assert spy_y.call_count <= 1 + + # Batching must not change the end state. + displayed = np.asarray(image_mark.image) + expected = self.image.get_cuts(image_label='second')(arr) + np.testing.assert_allclose(displayed, expected) + np.testing.assert_allclose(image_mark.x, [-0.5, arr.shape[1] - 0.5]) + np.testing.assert_allclose(image_mark.y, [-0.5, arr.shape[0] - 0.5]) + def test_get_viewport_reflects_interactive_pan(self, data): # Panning in the browser shifts the bqplot scales directly. Simulate # that here and check that get_viewport reports the new center. diff --git a/setup.cfg b/setup.cfg index 629afc0..70c12cc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,7 @@ python_requires >=3.10 test = pytest-astropy pytest-cov + pytest-mock docs = sphinx-astropy From 02857b180131cab5d56bc4c624b1adfc4881f47c Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:09:20 -0500 Subject: [PATCH 10/23] Batch front-end updates while loading an image to stop flicker Add a context manager to _AstroImage that holds the ipywidgets sync for the image mark and both scales, and use it in set_data and around the whole of load_image. Each widget now sends a single state message per load, so the browser no longer redraws the old image at the new viewport, then the new image at the old extent, on its way to the final state. The scale changes made by the API layer's viewport initialization (via the set_viewport override) land in the same batch as the new image data. Python-side observers are unaffected; hold_sync only defers messages to the front end, and it is re-entrant so the nested use in set_data is safe. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/bqplot.py | 57 ++++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 222fe61..8185f2e 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from pathlib import Path import numpy as np @@ -157,19 +158,32 @@ def _set_scale_aspect_ratio_to_match_viewer(self, self._scales[reset_scale].max = frozen_width[reset_scale] * scale_factor self.center = current_center + @contextmanager + def _hold_all_sync(self): + """ + Batch trait sync messages for the image mark and both scales so + that the front end receives a single state update per widget, + and hence redraws once, instead of redrawing after every trait + assignment. + """ + with self._image.hold_sync(), self._scales['x'].hold_sync(), \ + self._scales['y'].hold_sync(): + yield + def set_data(self, image_data, reset_view=True): self._image_shape = image_data.shape - if reset_view: - self.reset_scale_to_fit_image() + with self._hold_all_sync(): + if reset_view: + self.reset_scale_to_fit_image() - # Set the image data and map it to the bqplot figure so that - # cursor location corresponds to the underlying array index. - # The offset follows the convention that the index corresponds - # to the center of the pixel. - self._image.image = image_data - self._image.x = [-0.5, self._image_shape[1] - 0.5] - self._image.y = [-0.5, self._image_shape[0] - 0.5] + # Set the image data and map it to the bqplot figure so that + # cursor location corresponds to the underlying array index. + # The offset follows the convention that the index corresponds + # to the center of the pixel. + self._image.image = image_data + self._image.x = [-0.5, self._image_shape[1] - 0.5] + self._image.y = [-0.5, self._image_shape[0] - 0.5] @property def scale_widths(self): @@ -579,16 +593,21 @@ def viewer(self): # The methods, grouped loosely by purpose def load_image(self, image, image_label=None, **kwargs): - super().load_image(image, image_label=image_label, **kwargs) - data = self.get_image(image_label=image_label) - - self._data = data.data if isinstance(data, NDData) else data - self._refresh_display(image_label=image_label, reset_view=True) - - # The API layer does not store a colormap on load, so record the - # default so that get_colormap reports what is displayed. - if self.get_colormap(image_label=image_label) is None: - self.set_colormap(self._default_colormap, image_label=image_label) + # Hold the sync so the scale changes from the viewport + # initialization in the API layer arrive at the front end in the + # same batch as the new image data, avoiding flicker from + # intermediate redraws. + with self._astro_im._hold_all_sync(): + super().load_image(image, image_label=image_label, **kwargs) + data = self.get_image(image_label=image_label) + + self._data = data.data if isinstance(data, NDData) else data + self._refresh_display(image_label=image_label, reset_view=True) + + # The API layer does not store a colormap on load, so record the + # default so that get_colormap reports what is displayed. + if self.get_colormap(image_label=image_label) is None: + self.set_colormap(self._default_colormap, image_label=image_label) # Saving contents of the view and accessing the view def save(self, filename, overwrite=False, **kwargs): From 0f22bc88230478d21ee3df10793c3cf9639be110 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:09:42 -0500 Subject: [PATCH 11/23] Add failing tests: load_image must keep the current display settings Loading a new image should display it with whatever cuts, stretch and colormap are currently in effect (the widget defaults if nothing has been displayed yet) and store them for the new image, instead of letting the API layer reset cuts and stretch and resetting the colormap to the default. Only the viewport resets on load. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/tests/test_widget_api_bqplot.py | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index 182721d..f25b16d 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -71,6 +71,52 @@ def test_default_colormap_is_greys_r(self, data): assert self.image.get_colormap() == 'Greys_r' assert self.image._astro_im._image.scales['image'].colors == greys_r + def test_load_image_keeps_current_display_settings(self): + # Loading a new image should display it with the cuts, stretch and + # colormap that are currently in effect, carried forward from the + # previously displayed image, and store them for the new image so + # that the get_* methods agree with the display. Only the viewport + # resets on load. + rng = np.random.default_rng(seed=42) + arr = rng.integers(1100, 1300, size=(50, 60)).astype(np.uint16) + arr[25, 30] = 65535 + + self.image.load_image(arr, image_label='first') + cuts = apviz.AsymmetricPercentileInterval(5, 90) + stretch = apviz.LogStretch() + self.image.set_cuts(cuts, image_label='first') + self.image.set_stretch(stretch, image_label='first') + self.image.set_colormap('viridis', image_label='first') + + self.image.load_image(arr, image_label='second') + + assert self.image.get_cuts(image_label='second') is cuts + assert self.image.get_stretch(image_label='second') is stretch + assert self.image.get_colormap(image_label='second') == 'viridis' + assert self.image._astro_im._image.scales['image'].colors == bqcolors('viridis') + + displayed = np.asarray(self.image._astro_im._image.image) + np.testing.assert_allclose(displayed, stretch(cuts(arr))) + + def test_first_load_stores_widget_default_cuts(self): + # With nothing loaded yet there are no current settings to carry + # forward, so the first image should be displayed with the widget's + # default cuts, and those cuts should be stored so that get_cuts + # reports what is displayed. + rng = np.random.default_rng(seed=42) + arr = rng.integers(1100, 1300, size=(50, 60)).astype(np.uint16) + arr[25, 30] = 65535 + + self.image.load_image(arr) + + cuts = self.image.get_cuts() + assert isinstance(cuts, apviz.AsymmetricPercentileInterval) + assert cuts.lower_percentile == 30 + assert cuts.upper_percentile == 96 + + displayed = np.asarray(self.image._astro_im._image.image) + np.testing.assert_allclose(displayed, cuts(arr)) + def test_set_stretch_keeps_stored_cuts(self): # Changing the stretch must re-display using the cuts stored for # the image, not fall back to the widget's default cuts. From 7944a4cfdf384c099af376c3441ebc1999222814 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:10:32 -0500 Subject: [PATCH 12/23] Display newly loaded images with the current cuts, stretch and colormap Capture the settings in effect before the load, store them for the new image (overriding the defaults the API layer stores while loading) and display with them. On the first load, when nothing has been displayed yet, the widget defaults are used instead. Only the viewport resets when an image is loaded. The settings are stored through the widget's own set_cuts / set_stretch / set_colormap. A new _defer_refresh context manager suspends the display refresh the setters trigger, so the displayed array is still computed only once per load, in the single _refresh_display call at the end. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/bqplot.py | 60 +++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 8185f2e..1f72a68 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -395,6 +395,9 @@ def __init__(self, *args, display_width=500, display_aspect_ratio=1): # Guards re-entrancy while we programmatically update the viewport. self._updating_viewport = False + # While True, _refresh_display does nothing; set by _defer_refresh. + self._refresh_deferred = False + # Provide an Output widget to which prints can be directed for # debugging. self._print_out = ipw.Output() @@ -561,13 +564,26 @@ def _refresh_display(self, image_label=None, reset_view=False): Recompute the displayed array from the cuts and stretch stored for the image and send it to the viewer. """ - if self._data is None: + if self._data is None or self._refresh_deferred: return self._send_data(cuts=self.get_cuts(image_label=image_label), stretch=self.get_stretch(image_label=image_label), reset_view=reset_view) + @contextmanager + def _defer_refresh(self): + """ + Make _refresh_display a no-op inside the block so that several + settings can be stored with a single recomputation of the + displayed array. The caller refreshes after the block. + """ + self._refresh_deferred = True + try: + yield + finally: + self._refresh_deferred = False + @property def _current_image_label(self): """ @@ -593,21 +609,47 @@ def viewer(self): # The methods, grouped loosely by purpose def load_image(self, image, image_label=None, **kwargs): + # A newly loaded image is always displayed with the current cuts, + # stretch and colormap; only the viewport resets. Capture the + # current settings before the API layer replaces them with its own + # defaults during the load. + if self._data is not None: + # A new image: carry forward from the displayed image. + settings_label = self._current_image_label + else: + # Nothing has been displayed yet. + settings_label = None + + if settings_label is not None: + current_cuts = self.get_cuts(image_label=settings_label) + current_stretch = self.get_stretch(image_label=settings_label) + current_colormap = self.get_colormap(image_label=settings_label) + else: + current_cuts = self._default_cuts + # Leave the stretch the API layer stores on load (linear). + current_stretch = self._default_stretch + current_colormap = self._default_colormap + # Hold the sync so the scale changes from the viewport # initialization in the API layer arrive at the front end in the # same batch as the new image data, avoiding flicker from # intermediate redraws. with self._astro_im._hold_all_sync(): - super().load_image(image, image_label=image_label, **kwargs) - data = self.get_image(image_label=image_label) + # Store the settings for the new image so the get_* methods + # report what is displayed. Defer the refresh each setter + # triggers; one refresh at the end displays the image. + with self._defer_refresh(): + super().load_image(image, image_label=image_label, **kwargs) - self._data = data.data if isinstance(data, NDData) else data - self._refresh_display(image_label=image_label, reset_view=True) + self.set_cuts(current_cuts, image_label=image_label) + if current_stretch is not None: + self.set_stretch(current_stretch, image_label=image_label) + self.set_colormap(current_colormap, image_label=image_label) + + data = self.get_image(image_label=image_label) + self._data = data.data if isinstance(data, NDData) else data - # The API layer does not store a colormap on load, so record the - # default so that get_colormap reports what is displayed. - if self.get_colormap(image_label=image_label) is None: - self.set_colormap(self._default_colormap, image_label=image_label) + self._refresh_display(image_label=image_label, reset_view=True) # Saving contents of the view and accessing the view def save(self, filename, overwrite=False, **kwargs): From 69395a75947ffc0fdefe5505932ee0e78b415a9c Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:10:54 -0500 Subject: [PATCH 13/23] Add failing test: reloading an existing label must keep its settings load_image carries the settings forward from the most recently loaded image even when the caller passes an image_label that already has its own stored cuts, stretch and colormap. Those are the current settings for that image and are the ones that should be kept on reload. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/tests/test_widget_api_bqplot.py | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index f25b16d..3544455 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -98,6 +98,39 @@ def test_load_image_keeps_current_display_settings(self): displayed = np.asarray(self.image._astro_im._image.image) np.testing.assert_allclose(displayed, stretch(cuts(arr))) + def test_reload_existing_label_keeps_its_settings(self): + # When new data is loaded under an existing image label, that + # label's own stored settings are the current ones for the image + # and must be kept, even if a different image was loaded (and so + # displayed) more recently. + rng = np.random.default_rng(seed=42) + arr = rng.integers(1100, 1300, size=(50, 60)).astype(np.uint16) + arr[25, 30] = 65535 + + first_cuts = apviz.AsymmetricPercentileInterval(5, 90) + first_stretch = apviz.LogStretch() + self.image.load_image(arr, image_label='first') + self.image.set_cuts(first_cuts, image_label='first') + self.image.set_stretch(first_stretch, image_label='first') + self.image.set_colormap('viridis', image_label='first') + + self.image.load_image(arr, image_label='second') + self.image.set_cuts(apviz.ManualInterval(1100, 1300), + image_label='second') + self.image.set_stretch(apviz.SqrtStretch(), image_label='second') + self.image.set_colormap('plasma', image_label='second') + + new_arr = arr + 10 + self.image.load_image(new_arr, image_label='first') + + assert self.image.get_cuts(image_label='first') is first_cuts + assert self.image.get_stretch(image_label='first') is first_stretch + assert self.image.get_colormap(image_label='first') == 'viridis' + assert self.image._astro_im._image.scales['image'].colors == bqcolors('viridis') + + displayed = np.asarray(self.image._astro_im._image.image) + np.testing.assert_allclose(displayed, first_stretch(first_cuts(new_arr))) + def test_first_load_stores_widget_default_cuts(self): # With nothing loaded yet there are no current settings to carry # forward, so the first image should be displayed with the widget's From d8d9ea0c12f90d5d3743d19706aee219cc26c755 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 10:11:28 -0500 Subject: [PATCH 14/23] load_image: honor the passed label when it already has settings If the caller passes an image_label that already has stored settings, those are the current settings for that image and are the ones kept, instead of always carrying forward the most recently loaded image's settings. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D5TYrgNKZUP6nDV3ds99T7 --- astrowidgets/bqplot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 1f72a68..1407fe9 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -613,7 +613,10 @@ def load_image(self, image, image_label=None, **kwargs): # stretch and colormap; only the viewport resets. Capture the # current settings before the API layer replaces them with its own # defaults during the load. - if self._data is not None: + if image_label is not None and image_label in self._images: + # Re-loading an existing image: its own settings are current. + settings_label = image_label + elif self._data is not None: # A new image: carry forward from the displayed image. settings_label = self._current_image_label else: From fa7afc6e5e546667b92c2a37fb40a25a72890ef4 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 11:12:26 -0500 Subject: [PATCH 15/23] Add failing test: load_image must keep settings without labels The carry-forward of cuts/stretch/colormap was only tested with explicit image labels. In normal interactive use no label is passed, so everything lives under the API's default (None) label. This test exercises that path and fails today: the second load resets the cuts, stretch and colormap to the widget defaults. Co-written with Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015i9rprG92mvoK4JbLKZByA --- astrowidgets/tests/test_widget_api_bqplot.py | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index 3544455..7612575 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -131,6 +131,36 @@ def test_reload_existing_label_keeps_its_settings(self): displayed = np.asarray(self.image._astro_im._image.image) np.testing.assert_allclose(displayed, first_stretch(first_cuts(new_arr))) + def test_load_image_keeps_settings_without_labels(self): + # The carry-forward of cuts, stretch and colormap must work in the + # common interactive case where no image_label is ever passed, so + # every image and its settings live under the API's default (None) + # label. Loading a second image must keep the settings currently in + # effect, not fall back to the widget defaults. + rng = np.random.default_rng(seed=42) + arr = rng.integers(1100, 1300, size=(50, 60)).astype(np.uint16) + arr[25, 30] = 65535 + + self.image.load_image(arr) + cuts = apviz.AsymmetricPercentileInterval(5, 90) + stretch = apviz.LogStretch() + self.image.set_cuts(cuts) + self.image.set_stretch(stretch) + self.image.set_colormap('viridis') + + # A differently shaped second image, still with no label. + arr2 = rng.integers(1100, 1300, size=(40, 30)).astype(np.uint16) + arr2[10, 15] = 65535 + self.image.load_image(arr2) + + assert self.image.get_cuts() is cuts + assert self.image.get_stretch() is stretch + assert self.image.get_colormap() == 'viridis' + assert self.image._astro_im._image.scales['image'].colors == bqcolors('viridis') + + displayed = np.asarray(self.image._astro_im._image.image) + np.testing.assert_allclose(displayed, stretch(cuts(arr2))) + def test_first_load_stores_widget_default_cuts(self): # With nothing loaded yet there are no current settings to carry # forward, so the first image should be displayed with the widget's From ba8a166b8b7889d395384dc951dd1fa970ba90d6 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 11:13:05 -0500 Subject: [PATCH 16/23] Keep display settings when loading without an image label load_image captured the current cuts/stretch/colormap only when the settings label was not None, but the label is legitimately None in the common case where the caller never passes one (every image then lives under the API's default None key). In that case the capture fell through to the widget defaults, so loading a second image reset the settings. Guard the capture on whether an image is currently displayed (self._data is not None) rather than on the label's value, so a None-keyed current image's settings are carried forward like any other. Co-written with Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015i9rprG92mvoK4JbLKZByA --- astrowidgets/bqplot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 1407fe9..4d84e02 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -616,14 +616,18 @@ def load_image(self, image, image_label=None, **kwargs): if image_label is not None and image_label in self._images: # Re-loading an existing image: its own settings are current. settings_label = image_label + have_current = True elif self._data is not None: - # A new image: carry forward from the displayed image. + # A new image: carry forward from the displayed image. Its label + # is legitimately None when the caller never passed one, so guard + # on whether an image is displayed, not on the label's value. settings_label = self._current_image_label + have_current = True else: # Nothing has been displayed yet. - settings_label = None + have_current = False - if settings_label is not None: + if have_current: current_cuts = self.get_cuts(image_label=settings_label) current_stretch = self.get_stretch(image_label=settings_label) current_colormap = self.get_colormap(image_label=settings_label) From 644e78e3bb04b3605322b4439516ef9ff3fd85a0 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 11:14:37 -0500 Subject: [PATCH 17/23] Add failing test: load must sync the image before the scales The image mark and the two scales are separate widgets, each syncing its own state message the front end redraws on. Today the scales sync first (they release in reverse of their entry order in _hold_all_sync), so the front end draws the old image against the new scales before the new image data arrives -- the visible "refit to window" flash. This test pins the required order and fails today. Co-written with Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015i9rprG92mvoK4JbLKZByA --- astrowidgets/tests/test_widget_api_bqplot.py | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index 7612575..37512f9 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -245,6 +245,43 @@ def test_load_image_batches_frontend_updates(self, data, mocker): np.testing.assert_allclose(image_mark.x, [-0.5, arr.shape[1] - 0.5]) np.testing.assert_allclose(image_mark.y, [-0.5, arr.shape[0] - 0.5]) + def test_load_image_flushes_image_before_scales(self, data, mocker): + # The image mark and the two scales are separate widgets, each + # syncing its own state message that the front end redraws on. + # If a scale syncs before the image mark, the front end briefly + # draws the OLD image against the NEW scales (a visible refit). + # The new image data must reach the front end first. + self.image.load_image(data, image_label='first') + + astro_im = self.image._astro_im + image_mark = astro_im._image + scale_x = astro_im._scales['x'] + scale_y = astro_im._scales['y'] + + # A different shape so the image extent and both scales change. + arr = np.arange(30 * 40, dtype=float).reshape(30, 40) + + order = [] + + def record(name, widget): + real = widget.send_state + + def wrapper(*args, **kwargs): + order.append(name) + return real(*args, **kwargs) + + mocker.patch.object(widget, 'send_state', side_effect=wrapper) + + record('image', image_mark) + record('scale_x', scale_x) + record('scale_y', scale_y) + + self.image.load_image(arr, image_label='second') + + assert 'image' in order + assert order.index('image') < order.index('scale_x') + assert order.index('image') < order.index('scale_y') + def test_get_viewport_reflects_interactive_pan(self, data): # Panning in the browser shifts the bqplot scales directly. Simulate # that here and check that get_viewport reports the new center. From 0e87a0465bc65ad7a0522d9b2743a4c870038a13 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 11:15:06 -0500 Subject: [PATCH 18/23] Sync the new image before the scales to stop the load flicker _hold_all_sync batched the image mark and both scales, but since they are separate widgets each still syncs its own message and the front end redraws on each. The scales released before the image mark, so the front end drew the old image against the new scales -- the visible "refit to window" flash -- before the new image arrived. Enter the holds in reverse of the desired flush order so the image mark flushes first, then the color scale, then the scales; every intermediate frame then shows the new image. Also fold the color scale into the batch so a colormap change during a load can no longer sync out of band. Co-written with Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015i9rprG92mvoK4JbLKZByA --- astrowidgets/bqplot.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 4d84e02..a97e06a 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -161,13 +161,22 @@ def _set_scale_aspect_ratio_to_match_viewer(self, @contextmanager def _hold_all_sync(self): """ - Batch trait sync messages for the image mark and both scales so - that the front end receives a single state update per widget, - and hence redraws once, instead of redrawing after every trait - assignment. + Batch trait sync messages for the image mark, its color scale and + both axis scales so that the front end receives a single state + update per widget, and hence redraws once, instead of redrawing + after every trait assignment. + + These are separate widgets, so each still syncs its own message and + the front end redraws on each. On exit the contexts release -- and + each widget flushes -- in reverse of the order entered here, so the + entry order is chosen to make the image mark flush first, then the + color scale, then the scales. That way the front end never draws + the old image against the new scales (a "refit" flash) nor recolors + the old image before the new data arrives. """ - with self._image.hold_sync(), self._scales['x'].hold_sync(), \ - self._scales['y'].hold_sync(): + with self._scales['y'].hold_sync(), self._scales['x'].hold_sync(), \ + self._image.scales['image'].hold_sync(), \ + self._image.hold_sync(): yield def set_data(self, image_data, reset_view=True): From 28272e464e9c97de583918a42dba6a497f6d51b4 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 19:30:46 -0500 Subject: [PATCH 19/23] Use non-default cuts in the stretch test so it can detect fallback After a bare load the stored cuts are the widget defaults, so the test could not tell a refresh that used the stored cuts from one that fell back to the defaults. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_013c7zdgS4kAuWzJxmqtcE6X --- astrowidgets/tests/test_widget_api_bqplot.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index 37512f9..4016531 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -188,12 +188,15 @@ def test_set_stretch_keeps_stored_cuts(self): arr[25, 30] = 65535 self.image.load_image(arr) + # Cuts that differ from the widget default, so that a refresh + # falling back to the default cuts produces a different array. + cuts = apviz.AsymmetricPercentileInterval(5, 90) + self.image.set_cuts(cuts) stretch = apviz.LogStretch() self.image.set_stretch(stretch) displayed = np.asarray(self.image._astro_im._image.image) - expected = stretch(self.image.get_cuts()(arr)) - np.testing.assert_allclose(displayed, expected) + np.testing.assert_allclose(displayed, stretch(cuts(arr))) def test_set_cuts_keeps_stored_stretch(self): # Changing the cuts must re-display using the stretch stored for From 10ce463e624e499859208bac9c157f01265eabd8 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 19:31:06 -0500 Subject: [PATCH 20/23] Use non-default cuts in the batching test's end-state check With the default cuts the check also passed if the batched refresh fell back to the widget defaults instead of the carried settings. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_013c7zdgS4kAuWzJxmqtcE6X --- astrowidgets/tests/test_widget_api_bqplot.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index 4016531..c3969bd 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -222,6 +222,11 @@ def test_load_image_batches_frontend_updates(self, data, mocker): # Loading should batch the updates so the image mark and each # scale send at most one state message. self.image.load_image(data, image_label='first') + # Cuts that differ from the widget default, so the end-state check + # below can tell the carried-forward settings from a fallback to + # the defaults. + cuts = apviz.AsymmetricPercentileInterval(5, 90) + self.image.set_cuts(cuts, image_label='first') astro_im = self.image._astro_im image_mark = astro_im._image @@ -242,9 +247,9 @@ def test_load_image_batches_frontend_updates(self, data, mocker): assert spy_y.call_count <= 1 # Batching must not change the end state. + assert self.image.get_cuts(image_label='second') is cuts displayed = np.asarray(image_mark.image) - expected = self.image.get_cuts(image_label='second')(arr) - np.testing.assert_allclose(displayed, expected) + np.testing.assert_allclose(displayed, cuts(arr)) np.testing.assert_allclose(image_mark.x, [-0.5, arr.shape[1] - 0.5]) np.testing.assert_allclose(image_mark.y, [-0.5, arr.shape[0] - 0.5]) From 1447a699c883514158ef1e6ea25a646193d7615b Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 19:31:39 -0500 Subject: [PATCH 21/23] Add regression test for mixing labeled and unlabeled images Pin down what an unlabeled load does once labeled images exist: the API layer resolves the None label to the single user label, so the load replaces the labeled image, keeps that label's settings, leaves the image stored under None untouched, and raises when two or more labels make the target ambiguous. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_013c7zdgS4kAuWzJxmqtcE6X --- astrowidgets/tests/test_widget_api_bqplot.py | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index c3969bd..24a2436 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -161,6 +161,42 @@ def test_load_image_keeps_settings_without_labels(self): displayed = np.asarray(self.image._astro_im._image.image) np.testing.assert_allclose(displayed, stretch(cuts(arr2))) + def test_load_image_without_label_targets_single_labeled_image(self): + # An image label of None means "the caller did not specify a + # label". Once a labeled image exists, the API layer resolves None + # to that label, so an unlabeled load replaces the labeled image + # (keeping its settings) and never touches an image stored under + # the None label. With two or more labels the target is ambiguous + # and the load raises. + rng = np.random.default_rng(seed=42) + arr = rng.integers(1100, 1300, size=(50, 60)).astype(np.uint16) + arr[25, 30] = 65535 + + unlabeled_cuts = apviz.AsymmetricPercentileInterval(5, 90) + self.image.load_image(arr) + self.image.set_cuts(unlabeled_cuts) + + labeled_cuts = apviz.ManualInterval(1100, 1300) + self.image.load_image(arr + 1, image_label='labeled') + self.image.set_cuts(labeled_cuts, image_label='labeled') + + self.image.load_image(arr + 2) + + # The unlabeled load replaced the labeled image and kept its cuts. + np.testing.assert_array_equal( + np.asarray(self.image.get_image(image_label='labeled')), arr + 2) + assert self.image.get_cuts(image_label='labeled') is labeled_cuts + + # The image stored under the None label kept its own settings. + # Once a user label exists, get_cuts(image_label=None) resolves to + # that label, so look at the stored settings directly. + assert self.image._images[None].cuts is unlabeled_cuts + + # With two user-defined labels an unlabeled load is ambiguous. + self.image.load_image(arr, image_label='labeled2') + with pytest.raises(ValueError, match='Multiple image labels'): + self.image.load_image(arr) + def test_first_load_stores_widget_default_cuts(self): # With nothing loaded yet there are no current settings to carry # forward, so the first image should be displayed with the widget's From ee2380b902c817e108d3811f815a368debdd9ed9 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 19:32:04 -0500 Subject: [PATCH 22/23] Add failing test: get_colormap must report the default before a load Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_013c7zdgS4kAuWzJxmqtcE6X --- astrowidgets/tests/test_widget_api_bqplot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index 24a2436..0f46167 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -66,6 +66,7 @@ def test_default_colormap_is_greys_r(self, data): # report it. greys_r = bqcolors('Greys_r') assert self.image._astro_im._image.scales['image'].colors == greys_r + assert self.image.get_colormap() == 'Greys_r' self.image.load_image(data) assert self.image.get_colormap() == 'Greys_r' From 08f8d12765cb596128c0404953b844703c6e160d Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 6 Jul 2026 19:32:24 -0500 Subject: [PATCH 23/23] Set the default colormap through set_colormap in __init__ The API layer keeps settings for the None label even before an image is loaded, so the public method works here and additionally stores the default where get_colormap can report it. Co-written with Claude Fable 5 Claude-Session: https://claude.ai/code/session_013c7zdgS4kAuWzJxmqtcE6X --- astrowidgets/bqplot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index a97e06a..a06d6f5 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -392,7 +392,10 @@ def __init__(self, *args, display_width=500, display_aspect_ratio=1): self._default_cuts = apviz.AsymmetricPercentileInterval(30, 96) self._default_stretch = None self._default_colormap = 'Greys_r' - self._astro_im.set_color(bqcolors(self._default_colormap)) + # Store the default through the API layer (which keeps settings + # for the None label even before a load) so that get_colormap + # reports it; set_colormap also applies it to the front end. + self.set_colormap(self._default_colormap) self._data = None self._wcs = None