Skip to content

Commit

Permalink
image viewer zoom-level/center (spacetelescope#2649)
Browse files Browse the repository at this point in the history
  • Loading branch information
kecnry authored and gibsongreen committed Feb 12, 2024
1 parent bff3360 commit aa8200f
Show file tree
Hide file tree
Showing 8 changed files with 306 additions and 117 deletions.
2 changes: 1 addition & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ New Features

- Stretch histogram shows a spinner when the histogram data is updating. [#2644]

- Spectrum viewer bounds can now be set through the Plot Options UI. [#2604]
- Spectrum and image viewer bounds can now be set through the Plot Options UI. [#2604, #2649]

- Opacity for spatial subsets is now adjustable from within Plot Options. [#2663]

Expand Down
6 changes: 5 additions & 1 deletion jdaviz/configs/cubeviz/plugins/viewers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
from jdaviz.configs.cubeviz.helper import layer_is_cube_image_data
from jdaviz.configs.default.plugins.viewers import JdavizViewerMixin
from jdaviz.configs.specviz.plugins.viewers import SpecvizProfileView
from jdaviz.utils import get_subset_type
from jdaviz.core.events import AddDataMessage, RemoveDataMessage
from jdaviz.core.freezable_state import FreezableBqplotImageViewerState
from jdaviz.utils import get_subset_type

__all__ = ['CubevizImageView', 'CubevizProfileView']

Expand All @@ -31,9 +32,12 @@ class CubevizImageView(JdavizViewerMixin, BqplotImageView):
]

default_class = None
_state_cls = FreezableBqplotImageViewerState

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# provide reference from state back to viewer to use for zoom syncing
self.state._viewer = self

self._subscribe_to_layers_update()
self.state.add_callback('reference_data', self._initial_x_axis)
Expand Down
181 changes: 115 additions & 66 deletions jdaviz/configs/default/plugins/plot_options/plot_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
from jdaviz.core.template_mixin import (PluginTemplateMixin, ViewerSelect, LayerSelect,
PlotOptionsSyncState, Plot,
skip_if_no_updates_since_last_active, with_spinner)
from jdaviz.core.events import ChangeRefDataMessage
from jdaviz.core.user_api import PluginUserApi
from jdaviz.core.tools import ICON_DIR
from jdaviz.core.custom_traitlets import IntHandleEmpty, FloatHandleEmpty
from jdaviz.core.custom_traitlets import IntHandleEmpty

from scipy.interpolate import PchipInterpolator

Expand Down Expand Up @@ -93,6 +94,16 @@ def update_knots(self, x, y):
stretches.add("spline", SplineStretch, display="Spline")


def _round_step(step):
# round the step for a float input
if step <= 0:
return 1e-6, 6
decimals = -int(np.log10(abs(step))) + 1 if step != 0 else 6
if decimals < 0:
decimals = 0
return np.round(step, decimals), decimals


@tray_registry('g-plot-options', label="Plot Options")
class PlotOptions(PluginTemplateMixin):
"""
Expand Down Expand Up @@ -176,6 +187,9 @@ class PlotOptions(PluginTemplateMixin):
template_file = __file__, "plot_options.vue"
uses_active_status = Bool(True).tag(sync=True)

# read-only display units
display_units = Dict().tag(sync=True)

viewer_multiselect = Bool(False).tag(sync=True)
viewer_items = List().tag(sync=True)
viewer_selected = Any().tag(sync=True) # Any needed for multiselect
Expand Down Expand Up @@ -207,26 +221,31 @@ class PlotOptions(PluginTemplateMixin):
uncertainty_visible_value = Int().tag(sync=True)
uncertainty_visible_sync = Dict().tag(sync=True)

viewer_x_min_value = FloatHandleEmpty().tag(sync=True)
viewer_x_min_sync = Dict().tag(sync=True)
x_min_value = Float().tag(sync=True)
x_min_sync = Dict().tag(sync=True)

viewer_x_max_value = FloatHandleEmpty().tag(sync=True)
viewer_x_max_sync = Dict().tag(sync=True)
x_max_value = Float().tag(sync=True)
x_max_sync = Dict().tag(sync=True)

viewer_x_unit_value = Unicode(allow_none=True).tag(sync=True)
viewer_x_unit_sync = Dict().tag(sync=True)
y_min_value = Float().tag(sync=True)
y_min_sync = Dict().tag(sync=True)

viewer_y_min_value = FloatHandleEmpty().tag(sync=True)
viewer_y_min_sync = Dict().tag(sync=True)
y_max_value = Float().tag(sync=True)
y_max_sync = Dict().tag(sync=True)

viewer_y_max_value = FloatHandleEmpty().tag(sync=True)
viewer_y_max_sync = Dict().tag(sync=True)
x_bound_step = Float(0.1).tag(sync=True) # dynamic based on maximum value
y_bound_step = Float(0.1).tag(sync=True) # dynamic based on maximum value

viewer_y_unit_value = Unicode(allow_none=True).tag(sync=True)
viewer_y_unit_sync = Dict().tag(sync=True)
zoom_center_x_value = Float().tag(sync=True)
zoom_center_x_sync = Dict().tag(sync=True)

viewer_x_bound_step = Float(0.1).tag(sync=True) # dynamic based on maximum value
viewer_y_bound_step = Float(0.1).tag(sync=True) # dynamic based on maximum value
zoom_center_y_value = Float().tag(sync=True)
zoom_center_y_sync = Dict().tag(sync=True)

zoom_radius_value = Float().tag(sync=True)
zoom_radius_sync = Dict().tag(sync=True)

zoom_step = Float(1).tag(sync=True)

# scatter/marker options
marker_visible_value = Bool().tag(sync=True)
Expand Down Expand Up @@ -357,7 +376,6 @@ class PlotOptions(PluginTemplateMixin):
icon_checktoradial = Unicode(read_icon(os.path.join(ICON_DIR, 'checktoradial.svg'), 'svg+xml')).tag(sync=True) # noqa

show_viewer_labels = Bool(True).tag(sync=True)
show_viewer_bounds = Bool(True).tag(sync=True)

cmap_samples = Dict().tag(sync=True)
swatches_palette = List().tag(sync=True)
Expand Down Expand Up @@ -444,24 +462,24 @@ def state_attr_for_line_visible(state):
'uncertainty_visible_value', 'uncertainty_visible_sync') # noqa

# Viewer bounds
self.viewer_x_min = PlotOptionsSyncState(self, self.viewer, self.layer, 'x_min',
'viewer_x_min_value', 'viewer_x_min_sync',
state_filter=not_image_viewer)
self.viewer_x_max = PlotOptionsSyncState(self, self.viewer, self.layer, 'x_max',
'viewer_x_max_value', 'viewer_x_max_sync',
state_filter=not_image_viewer)
self.viewer_x_unit = PlotOptionsSyncState(self, self.viewer, self.layer, 'x_display_unit',
'viewer_x_unit_value', 'viewer_x_unit_sync',
state_filter=not_image_viewer)
self.viewer_y_min = PlotOptionsSyncState(self, self.viewer, self.layer, 'y_min',
'viewer_y_min_value', 'viewer_y_min_sync',
state_filter=not_image)
self.viewer_y_max = PlotOptionsSyncState(self, self.viewer, self.layer, 'y_max',
'viewer_y_max_value', 'viewer_y_max_sync',
state_filter=not_image)
self.viewer_y_unit = PlotOptionsSyncState(self, self.viewer, self.layer, 'y_display_unit',
'viewer_y_unit_value', 'viewer_y_unit_sync',
state_filter=not_image_viewer)
self.x_min = PlotOptionsSyncState(self, self.viewer, self.layer, 'x_min',
'x_min_value', 'x_min_sync',
state_filter=not_image_viewer)
self.x_max = PlotOptionsSyncState(self, self.viewer, self.layer, 'x_max',
'x_max_value', 'x_max_sync',
state_filter=not_image_viewer)
self.y_min = PlotOptionsSyncState(self, self.viewer, self.layer, 'y_min',
'y_min_value', 'y_min_sync',
state_filter=not_image_viewer)
self.y_max = PlotOptionsSyncState(self, self.viewer, self.layer, 'y_max',
'y_max_value', 'y_max_sync',
state_filter=not_image_viewer)
self.zoom_center_x = PlotOptionsSyncState(self, self.viewer, self.layer, 'zoom_center_x',
'zoom_center_x_value', 'zoom_center_x_sync')
self.zoom_center_y = PlotOptionsSyncState(self, self.viewer, self.layer, 'zoom_center_y',
'zoom_center_y_value', 'zoom_center_y_sync')
self.zoom_radius = PlotOptionsSyncState(self, self.viewer, self.layer, 'zoom_radius',
'zoom_radius_value', 'zoom_radius_sync')

# Scatter/marker options:
# NOTE: marker_visible hides the entire layer (including the line)
Expand Down Expand Up @@ -621,6 +639,16 @@ def state_attr_for_line_visible(state):
self.show_viewer_labels = self.app.state.settings['viewer_labels']
self.app.state.add_callback('settings', self._on_app_settings_changed)

sv = self.spectrum_viewer
if sv is not None:
sv.state.add_callback('x_display_unit',
self._on_global_display_unit_changed)
sv.state.add_callback('y_display_unit',
self._on_global_display_unit_changed)

self.hub.subscribe(self, ChangeRefDataMessage,
handler=self._on_refdata_change)

# give UI access to sampled version of the available colormap choices
def hex_for_cmap(cmap):
N = 50
Expand All @@ -635,10 +663,12 @@ def user_api(self):
if self.config == "cubeviz":
expose += ['collapse_function', 'uncertainty_visible']
if self.config != "imviz":
expose += ['axes_visible', 'line_visible', 'line_color', 'line_width', 'line_opacity',
expose += ['x_min', 'x_max', 'y_min', 'y_max',
'axes_visible', 'line_visible', 'line_color', 'line_width', 'line_opacity',
'line_as_steps', 'uncertainty_visible']
if self.config != "specviz":
expose += ['subset_color', 'subset_opacity',
expose += ['zoom_center_x', 'zoom_center_y', 'zoom_radius',
'subset_color', 'subset_opacity',
'stretch_function', 'stretch_preset', 'stretch_vmin', 'stretch_vmax',
'stretch_hist_zoom_limits', 'stretch_hist_nbins',
'image_visible', 'image_color_mode',
Expand Down Expand Up @@ -687,6 +717,20 @@ def select_all(self, viewers=True, layers=True):
self.layer_multiselect = True
self.layer.select_all()

def _on_global_display_unit_changed(self, *args):
sv = self.spectrum_viewer
self.display_units['spectral'] = sv.state.x_display_unit
self.display_units['flux'] = sv.state.y_display_unit
self.send_state('display_units')

def _on_refdata_change(self, *args):
if self.app._link_type.lower() == 'wcs':
self.display_units['image'] = 'deg'
else:
self.display_units['image'] = 'pix'
self.send_state('display_units')
self._update_viewer_zoom_steps()

def vue_unmix_state(self, names):
if isinstance(names, str):
names = [names]
Expand Down Expand Up @@ -757,45 +801,50 @@ def apply_RGB_presets(self):
def vue_apply_RGB_presets(self, data):
self.apply_RGB_presets()

@observe('viewer_selected', 'viewer_x_max_value', 'viewer_x_min_value',
'viewer_y_max_value', 'viewer_y_min_value')
@observe('viewer_selected',
'x_min_value', 'x_max_value',
'y_min_value', 'y_max_value')
def _update_viewer_bound_steps(self, msg={}):
if not hasattr(self, 'viewer'): # pragma: no cover
# plugin hasn't been fully initialized yet
return

if not self.viewer.selected: # pragma: no cover
if not self.viewer.selected or not self.x_min_sync['in_subscribed_states']:
# nothing selected yet
return

if self.viewer_multiselect:
not_image = [not isinstance(v.state, ImageViewerState) for v in self.viewer.selected_obj] # noqa
if np.all(not_image):
self.show_viewer_bounds = True
else:
self.show_viewer_bounds = False
return
for ax in ('x', 'y'):
ax_min = getattr(self, f'{ax}_min_value')
ax_max = getattr(self, f'{ax}_max_value')
bound_step, decimals = _round_step((ax_max - ax_min) / 100.)
decimals = -int(np.log10(abs(bound_step))) + 1 if bound_step != 0 else 6
setattr(self, f'{ax}_bound_step', bound_step)
setattr(self, f'{ax}_min_value', np.round(ax_min, decimals=decimals))
setattr(self, f'{ax}_max_value', np.round(ax_max, decimals=decimals))

@observe('viewer_selected',
'zoom_center_x_value', 'zoom_center_y_value',
'zoom_radius_value')
def _update_viewer_zoom_steps(self, msg={}):
if not hasattr(self, 'viewer'): # pragma: no cover
# plugin hasn't been fully initialized yet
return

if not self.viewer.selected or not self.zoom_radius_sync['in_subscribed_states']:
# nothing selected yet
return

viewer = self.viewer.selected_obj[0] if self.viewer_multiselect else self.viewer.selected_obj # noqa
if not isinstance(viewer.state, ImageViewerState):
self.show_viewer_bounds = True
# We round these values to show, e.g., 7.15 instead of 7.1499999
if hasattr(viewer.state, "x_max") and viewer.state.x_max is not None:
bound_step = (viewer.state.x_max - viewer.state.x_min) / 100.
decimals = -int(np.log10(abs(bound_step))) + 1 if bound_step != 0 else 6
if decimals < 0:
decimals = 0
self.viewer_x_bound_step = np.round(bound_step, decimals=decimals)
self.viewer_x_max_value = np.round(self.viewer_x_max_value, decimals=decimals)
self.viewer_x_min_value = np.round(self.viewer_x_min_value, decimals=decimals)
if hasattr(viewer.state, "y_max") and viewer.state.y_max is not None:
bound_step = (viewer.state.y_max - viewer.state.y_min) / 100.
decimals = -int(np.log10(abs(bound_step))) + 1 if bound_step != 0 else 6
if decimals < 0:
decimals = 0
self.viewer_y_bound_step = np.round(bound_step, decimals=decimals)
self.viewer_y_max_value = np.round(self.viewer_y_max_value, decimals=decimals)
self.viewer_y_min_value = np.round(self.viewer_y_min_value, decimals=decimals)
# in the case of multiple viewers, calculate based on the first
# alternatively, we could find the most extreme by looping over all selected viewers
viewers = self.viewer.selected_obj if self.viewer_multiselect else [self.viewer.selected_obj] # noqa
for viewer in viewers:
if hasattr(viewer.state, '_get_reset_limits'):
break
else:
# no image viewer
return
x_min, x_max, y_min, y_max = viewer.state._get_reset_limits(return_as_world=True)
self.zoom_step, _ = _round_step(max(x_max-x_min, y_max-y_min) / 100.)

def vue_reset_viewer_bounds(self, _):
# This button is currently only exposed if only the spectrum viewer is selected
Expand Down
Loading

0 comments on commit aa8200f

Please sign in to comment.