Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
35bbaa1
Add failing test: initial display must use stored cuts
mwcraig Jul 6, 2026
6316cc6
Display newly loaded images via a single _refresh_display path
mwcraig Jul 6, 2026
71987db
Add failing test: default cuts should be the 30-96 percentile interval
mwcraig Jul 6, 2026
ca40db6
Use AsymmetricPercentileInterval(30, 96) as the fallback cuts
mwcraig Jul 6, 2026
20fcb72
Add failing test: default colormap should be Greys_r
mwcraig Jul 6, 2026
7abffd3
Make Greys_r the default colormap
mwcraig Jul 6, 2026
1ad5ad9
Add failing tests: changing cuts or stretch must keep the other setting
mwcraig Jul 6, 2026
c878c71
Refresh the display from stored settings when cuts or stretch change
mwcraig Jul 6, 2026
c10e61b
Add failing test: load_image must batch front-end updates
mwcraig Jul 6, 2026
02857b1
Batch front-end updates while loading an image to stop flicker
mwcraig Jul 6, 2026
0f22bc8
Add failing tests: load_image must keep the current display settings
mwcraig Jul 6, 2026
7944a4c
Display newly loaded images with the current cuts, stretch and colormap
mwcraig Jul 6, 2026
69395a7
Add failing test: reloading an existing label must keep its settings
mwcraig Jul 6, 2026
d8d9ea0
load_image: honor the passed label when it already has settings
mwcraig Jul 6, 2026
fa7afc6
Add failing test: load_image must keep settings without labels
mwcraig Jul 6, 2026
ba8a166
Keep display settings when loading without an image label
mwcraig Jul 6, 2026
644e78e
Add failing test: load must sync the image before the scales
mwcraig Jul 6, 2026
0e87a04
Sync the new image before the scales to stop the load flicker
mwcraig Jul 6, 2026
28272e4
Use non-default cuts in the stretch test so it can detect fallback
mwcraig Jul 7, 2026
10ce463
Use non-default cuts in the batching test's end-state check
mwcraig Jul 7, 2026
1447a69
Add regression test for mixing labeled and unlabeled images
mwcraig Jul 7, 2026
ee2380b
Add failing test: get_colormap must report the default before a load
mwcraig Jul 7, 2026
08f8d12
Set the default colormap through set_colormap in __init__
mwcraig Jul 7, 2026
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
135 changes: 117 additions & 18 deletions astrowidgets/bqplot.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from contextlib import contextmanager
from pathlib import Path

import numpy as np
Expand Down Expand Up @@ -157,19 +158,41 @@ 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, 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._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):
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):
Expand Down Expand Up @@ -364,8 +387,15 @@ 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._default_colormap = 'Greys_r'
# 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
Expand All @@ -377,12 +407,14 @@ 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()

self.marker = {'color': 'red', 'radius': 20, 'type': 'square'}
self._cuts = apviz.AsymmetricPercentileInterval(1, 99)

self._cursor = ipw.HTML('Coordinates show up here')

Expand Down Expand Up @@ -539,6 +571,31 @@ 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 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):
"""
Expand All @@ -550,26 +607,68 @@ 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):
return self._astro_im

# 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)
# 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 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. 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.
have_current = False

self._data = data.data if isinstance(data, NDData) else data
self._send_data()
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)
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():
# 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.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

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):
Expand Down
Loading
Loading