Skip to content

Commit cd0076d

Browse files
authored
Merge pull request #207 from mwcraig/fix-initial-display-cuts
2 parents 0450ce0 + 08f8d12 commit cd0076d

3 files changed

Lines changed: 416 additions & 19 deletions

File tree

astrowidgets/bqplot.py

Lines changed: 117 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from contextlib import contextmanager
12
from pathlib import Path
23

34
import numpy as np
@@ -157,19 +158,41 @@ def _set_scale_aspect_ratio_to_match_viewer(self,
157158
self._scales[reset_scale].max = frozen_width[reset_scale] * scale_factor
158159
self.center = current_center
159160

161+
@contextmanager
162+
def _hold_all_sync(self):
163+
"""
164+
Batch trait sync messages for the image mark, its color scale and
165+
both axis scales so that the front end receives a single state
166+
update per widget, and hence redraws once, instead of redrawing
167+
after every trait assignment.
168+
169+
These are separate widgets, so each still syncs its own message and
170+
the front end redraws on each. On exit the contexts release -- and
171+
each widget flushes -- in reverse of the order entered here, so the
172+
entry order is chosen to make the image mark flush first, then the
173+
color scale, then the scales. That way the front end never draws
174+
the old image against the new scales (a "refit" flash) nor recolors
175+
the old image before the new data arrives.
176+
"""
177+
with self._scales['y'].hold_sync(), self._scales['x'].hold_sync(), \
178+
self._image.scales['image'].hold_sync(), \
179+
self._image.hold_sync():
180+
yield
181+
160182
def set_data(self, image_data, reset_view=True):
161183
self._image_shape = image_data.shape
162184

163-
if reset_view:
164-
self.reset_scale_to_fit_image()
185+
with self._hold_all_sync():
186+
if reset_view:
187+
self.reset_scale_to_fit_image()
165188

166-
# Set the image data and map it to the bqplot figure so that
167-
# cursor location corresponds to the underlying array index.
168-
# The offset follows the convention that the index corresponds
169-
# to the center of the pixel.
170-
self._image.image = image_data
171-
self._image.x = [-0.5, self._image_shape[1] - 0.5]
172-
self._image.y = [-0.5, self._image_shape[0] - 0.5]
189+
# Set the image data and map it to the bqplot figure so that
190+
# cursor location corresponds to the underlying array index.
191+
# The offset follows the convention that the index corresponds
192+
# to the center of the pixel.
193+
self._image.image = image_data
194+
self._image.x = [-0.5, self._image_shape[1] - 0.5]
195+
self._image.y = [-0.5, self._image_shape[0] - 0.5]
173196

174197
@property
175198
def scale_widths(self):
@@ -364,8 +387,15 @@ def __init__(self, *args, display_width=500, display_aspect_ratio=1):
364387

365388
self._astro_im = _AstroImage(display_width=display_width,
366389
viewer_aspect_ratio=display_aspect_ratio)
367-
self._default_cuts = apviz.MinMaxInterval()
390+
# Cut out the sky background at the bottom and clip only the
391+
# brightest pixels at the top.
392+
self._default_cuts = apviz.AsymmetricPercentileInterval(30, 96)
368393
self._default_stretch = None
394+
self._default_colormap = 'Greys_r'
395+
# Store the default through the API layer (which keeps settings
396+
# for the None label even before a load) so that get_colormap
397+
# reports it; set_colormap also applies it to the front end.
398+
self.set_colormap(self._default_colormap)
369399

370400
self._data = None
371401
self._wcs = None
@@ -377,12 +407,14 @@ def __init__(self, *args, display_width=500, display_aspect_ratio=1):
377407
# Guards re-entrancy while we programmatically update the viewport.
378408
self._updating_viewport = False
379409

410+
# While True, _refresh_display does nothing; set by _defer_refresh.
411+
self._refresh_deferred = False
412+
380413
# Provide an Output widget to which prints can be directed for
381414
# debugging.
382415
self._print_out = ipw.Output()
383416

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

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

@@ -539,6 +571,31 @@ def _send_data(self, reset_view=True, stretch=None, cuts=None):
539571
self._astro_im.set_data(self._interval_and_stretch(stretch=stretch, cuts=cuts),
540572
reset_view=reset_view)
541573

574+
def _refresh_display(self, image_label=None, reset_view=False):
575+
"""
576+
Recompute the displayed array from the cuts and stretch stored
577+
for the image and send it to the viewer.
578+
"""
579+
if self._data is None or self._refresh_deferred:
580+
return
581+
582+
self._send_data(cuts=self.get_cuts(image_label=image_label),
583+
stretch=self.get_stretch(image_label=image_label),
584+
reset_view=reset_view)
585+
586+
@contextmanager
587+
def _defer_refresh(self):
588+
"""
589+
Make _refresh_display a no-op inside the block so that several
590+
settings can be stored with a single recomputation of the
591+
displayed array. The caller refreshes after the block.
592+
"""
593+
self._refresh_deferred = True
594+
try:
595+
yield
596+
finally:
597+
self._refresh_deferred = False
598+
542599
@property
543600
def _current_image_label(self):
544601
"""
@@ -550,26 +607,68 @@ def set_stretch(self, value, image_label=None, **kwargs):
550607
super().set_stretch(value, image_label=image_label, **kwargs)
551608
# Changing the stretch only affects the color mapping, so leave the
552609
# current viewport (zoom/pan) untouched.
553-
self._send_data(stretch=value, reset_view=False)
610+
self._refresh_display(image_label=image_label)
554611

555612
def set_cuts(self, value, image_label=None, **kwargs):
556613
super().set_cuts(value, image_label=image_label, **kwargs)
557614
# Changing the cuts only affects the color mapping, so leave the
558615
# current viewport (zoom/pan) untouched.
559-
self._send_data(cuts=self.get_cuts(image_label=image_label),
560-
reset_view=False)
616+
self._refresh_display(image_label=image_label)
561617

562618
@property
563619
def viewer(self):
564620
return self._astro_im
565621

566622
# The methods, grouped loosely by purpose
567623
def load_image(self, image, image_label=None, **kwargs):
568-
super().load_image(image, image_label=image_label, **kwargs)
569-
data = self.get_image(image_label=image_label)
624+
# A newly loaded image is always displayed with the current cuts,
625+
# stretch and colormap; only the viewport resets. Capture the
626+
# current settings before the API layer replaces them with its own
627+
# defaults during the load.
628+
if image_label is not None and image_label in self._images:
629+
# Re-loading an existing image: its own settings are current.
630+
settings_label = image_label
631+
have_current = True
632+
elif self._data is not None:
633+
# A new image: carry forward from the displayed image. Its label
634+
# is legitimately None when the caller never passed one, so guard
635+
# on whether an image is displayed, not on the label's value.
636+
settings_label = self._current_image_label
637+
have_current = True
638+
else:
639+
# Nothing has been displayed yet.
640+
have_current = False
570641

571-
self._data = data.data if isinstance(data, NDData) else data
572-
self._send_data()
642+
if have_current:
643+
current_cuts = self.get_cuts(image_label=settings_label)
644+
current_stretch = self.get_stretch(image_label=settings_label)
645+
current_colormap = self.get_colormap(image_label=settings_label)
646+
else:
647+
current_cuts = self._default_cuts
648+
# Leave the stretch the API layer stores on load (linear).
649+
current_stretch = self._default_stretch
650+
current_colormap = self._default_colormap
651+
652+
# Hold the sync so the scale changes from the viewport
653+
# initialization in the API layer arrive at the front end in the
654+
# same batch as the new image data, avoiding flicker from
655+
# intermediate redraws.
656+
with self._astro_im._hold_all_sync():
657+
# Store the settings for the new image so the get_* methods
658+
# report what is displayed. Defer the refresh each setter
659+
# triggers; one refresh at the end displays the image.
660+
with self._defer_refresh():
661+
super().load_image(image, image_label=image_label, **kwargs)
662+
663+
self.set_cuts(current_cuts, image_label=image_label)
664+
if current_stretch is not None:
665+
self.set_stretch(current_stretch, image_label=image_label)
666+
self.set_colormap(current_colormap, image_label=image_label)
667+
668+
data = self.get_image(image_label=image_label)
669+
self._data = data.data if isinstance(data, NDData) else data
670+
671+
self._refresh_display(image_label=image_label, reset_view=True)
573672

574673
# Saving contents of the view and accessing the view
575674
def save(self, filename, overwrite=False, **kwargs):

0 commit comments

Comments
 (0)