From 3d987a64e337c9d72cdadd8816957b874c25087e Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 3 May 2023 15:23:07 -0400 Subject: [PATCH 001/155] Adding API for WCS-only layers for glue-based image rotation --- jdaviz/app.py | 105 ++++++++++++- jdaviz/components/viewer_data_select.vue | 6 + jdaviz/components/viewer_data_select_item.vue | 3 +- .../plugins/plot_options/plot_options.py | 6 +- jdaviz/configs/default/plugins/viewers.py | 3 +- jdaviz/configs/imviz/helper.py | 32 ++-- jdaviz/configs/imviz/plugins/parsers.py | 14 +- jdaviz/configs/imviz/wcs_utils.py | 141 ++++++++++++++++++ jdaviz/core/events.py | 33 +++- jdaviz/core/freezable_state.py | 2 + 10 files changed, 319 insertions(+), 26 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index 92b419e3ee..cd1a0cdf26 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -49,7 +49,8 @@ SnackbarMessage, RemoveDataMessage, AddDataToViewerMessage, RemoveDataFromViewerMessage, ViewerAddedMessage, ViewerRemovedMessage, - ViewerRenamedMessage) + ViewerRenamedMessage, ChangeRefDataMessage) +from jdaviz.core.style_widget import StyleWidget from jdaviz.core.registries import (tool_registry, tray_registry, viewer_registry, data_parser_registry) from jdaviz.core.tools import ICON_DIR @@ -358,6 +359,8 @@ def __init__(self, configuration=None, *args, **kwargs): handler=self._on_layers_changed) self.hub.subscribe(self, SubsetDeleteMessage, handler=self._on_layers_changed) + self.hub.subscribe(self, ChangeRefDataMessage, + handler=self._on_refdata_changed) @property def hub(self): @@ -465,14 +468,92 @@ def _color_to_level(color): def _on_layers_changed(self, msg): if hasattr(msg, 'data'): layer_name = msg.data.label + is_wcs_only = msg.data.meta.get('WCS-ONLY', False) + is_ref_data = getattr(msg._viewer.state.reference_data, 'label', '') == layer_name elif hasattr(msg, 'subset'): layer_name = msg.subset.label + is_wcs_only = is_ref_data = False else: raise NotImplementedError(f"cannot recognize new layer from {msg}") + wcs_only_refdata_icon = 'mdi-compass-outline' + wcs_only_not_refdata_icon = 'mdi-compass-off-outline' + n_wcs_layers = len([icon.startswith('mdi') for icon in self.state.layer_icons]) if layer_name not in self.state.layer_icons: - self.state.layer_icons = {**self.state.layer_icons, - layer_name: alpha_index(len(self.state.layer_icons))} + if is_wcs_only: + self.state.layer_icons = {**self.state.layer_icons, + layer_name: wcs_only_refdata_icon if is_ref_data + else wcs_only_not_refdata_icon} + else: + self.state.layer_icons = {**self.state.layer_icons, + layer_name: alpha_index(len(self.state.layer_icons) - n_wcs_layers)} + + def _on_refdata_changed(self, msg): + is_wcs_only = ( + msg.old.meta.get('WCS-ONLY', False) or + msg.new.meta.get('WCS-ONLY', False) + ) + + if not is_wcs_only: + return + + wcs_only_refdata_icon = 'mdi-compass-outline' + wcs_only_not_refdata_icon = 'mdi-compass-off-outline' + + def switch_icon(old_icon, new_icon): + if old_icon != new_icon: + return new_icon + return old_icon + + new_layer_icons = {} + for i, (layer_name, layer_icon) in enumerate(self.state.layer_icons.items()): + if layer_name == msg.old.label: + new_layer_icons[layer_name] = switch_icon( + layer_icon, wcs_only_not_refdata_icon + ) + elif layer_name == msg.new.label: + new_layer_icons[layer_name] = switch_icon( + layer_icon, wcs_only_refdata_icon + ) + else: + new_layer_icons[layer_name] = layer_icon + + self.state.layer_icons = new_layer_icons + + def _change_reference_data(self, new_refdata_label): + """ + Change reference data to Data with ``data_label`` + """ + if self.config != 'imviz': + # this method is only meant for imviz for now + return + + viewer_reference = self._get_first_viewer_reference_name() + viewer = self.get_viewer(viewer_reference) + old_refdata = viewer.state.reference_data + + if new_refdata_label == old_refdata.label: + # if there's no refdata change, don't do anything: + return + + [new_refdata] = [ + data for data in self.data_collection + if data.label == new_refdata_label + ] + + self._viewer_store[viewer_reference].state.reference_data = new_refdata + + change_refdata_message = ChangeRefDataMessage( + new_refdata, + viewer, + viewer_id=viewer_reference, + sender=self, + old=old_refdata, + new=new_refdata + ) + self.hub.broadcast(change_refdata_message) + + viewer.state.reset_limits() def _link_new_data(self, reference_data=None, data_to_be_linked=None): """ @@ -681,6 +762,7 @@ def get_viewer_by_id(self, vid): """ return self._viewer_store.get(vid) + def get_subsets(self, subset_name=None, spectral_only=False, spatial_only=False, object_only=False, simplify_spectral=True, use_display_units=False, @@ -1806,7 +1888,7 @@ def set_data_visibility(self, viewer_reference, data_label, visible=True, replac visible : bool Whether to set the layer(s) to visible. replace : bool - Whether to disable the visilility of all other layers in the viewer + Whether to disable the visibility of all other layers in the viewer """ viewer_item = self._get_viewer_item(viewer_reference) viewer_id = viewer_item['id'] @@ -1866,6 +1948,14 @@ def set_data_visibility(self, viewer_reference, data_label, visible=True, replac if id != data_id: selected_items[id] = 'hidden' + # remove wcs-only data from selected items, + # add to wcs_only_layers: + for layer in viewer.layers: + if layer.layer.data.label == data_label and layer.layer.meta.get('WCS-ONLY', False): + layer.visible = False + viewer_item['wcs_only_layers'].append(data_label) + selected_items.pop(data_id) + # Sets the plot axes labels to be the units of the most recently # active data. viewer_data_labels = [layer.layer.label for layer in viewer.layers] @@ -1983,11 +2073,15 @@ def _on_data_deleted(self, msg): def _create_data_item(self, data): ndims = len(data.shape) wcsaxes = data.meta.get('WCSAXES', None) + wcs_only = data.meta.get('WCS-ONLY', False) if wcsaxes is None: # then we'll need to determine type another way, we want to avoid # this when we can though since its not as cheap component_ids = [str(c) for c in data.component_ids()] - if data.label == 'MOS Table': + + if wcs_only: + typ = 'wcs-only' + elif data.label == 'MOS Table': typ = 'table' elif 'Trace' in data.meta: typ = 'trace' @@ -2119,6 +2213,7 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): 'viewer_options': "IPY_MODEL_" + viewer.viewer_options.model_id, 'selected_data_items': {}, # noqa data_id: visibility state (visible, hidden, mixed), READ-ONLY 'visible_layers': {}, # label: {color, label_suffix}, READ-ONLY + 'wcs_only_layers': viewer.state.wcs_only_layers, 'canvas_angle': 0, # canvas rotation clockwise rotation angle in deg 'canvas_flip_horizontal': False, # canvas rotation horizontal flip 'config': self.config, # give viewer access to app config/layout diff --git a/jdaviz/components/viewer_data_select.vue b/jdaviz/components/viewer_data_select.vue index 7946e1d965..dd8b97cebf 100644 --- a/jdaviz/components/viewer_data_select.vue +++ b/jdaviz/components/viewer_data_select.vue @@ -135,6 +135,10 @@ module.exports = { } return inViewer }, + wcsOnlyItemInViewer(item) { + const wcsOnly = Object.keys(this.$props.viewer.wcs_only_layers).includes(item.name) + return wcsOnly + }, itemIsVisible(item, returnExtraItems) { if (this.$props.viewer.config === 'mosviz') { if (this.$props.viewer.reference === 'spectrum-viewer' && item.type !== '1d spectrum') { @@ -181,6 +185,8 @@ module.exports = { return item.meta._LCVIZ_EPHEMERIS.ephemeris == viewer_ephem_comp && this.dataItemInViewer(item, returnExtraItems) } return this.dataItemInViewer(item, returnExtraItems) + } else if (this.$props.viewer.config === 'imviz') { + return this.dataItemInViewer(item, returnExtraItems) && !this.wcsOnlyItemInViewer(item) } // for any situation not covered above, default to showing the entry return this.dataItemInViewer(item, returnExtraItems) diff --git a/jdaviz/components/viewer_data_select_item.vue b/jdaviz/components/viewer_data_select_item.vue index e687705e85..c51fd6b1f5 100644 --- a/jdaviz/components/viewer_data_select_item.vue +++ b/jdaviz/components/viewer_data_select_item.vue @@ -13,7 +13,7 @@
- @@ -74,7 +74,6 @@ module.exports = { visible: prevVisibleState != 'visible' || (!this.multi_select && this.$props.item.type !== 'trace'), replace: !this.multi_select && this.$props.item.type !== 'trace' }) - } }, computed: { diff --git a/jdaviz/configs/default/plugins/plot_options/plot_options.py b/jdaviz/configs/default/plugins/plot_options/plot_options.py index 7855565200..c16ab79a4c 100644 --- a/jdaviz/configs/default/plugins/plot_options/plot_options.py +++ b/jdaviz/configs/default/plugins/plot_options/plot_options.py @@ -386,7 +386,11 @@ def supports_line(state): return is_profile(state) or is_scatter(state) def is_image(state): - return isinstance(state, BqplotImageLayerState) + wcs_only = ( + hasattr(state, 'wcs_only_layers') and + self.layer.selected in state.wcs_only_layers + ) + return isinstance(state, BqplotImageLayerState) and not wcs_only def not_image(state): return not is_image(state) diff --git a/jdaviz/configs/default/plugins/viewers.py b/jdaviz/configs/default/plugins/viewers.py index 589d589c52..fd0099a786 100644 --- a/jdaviz/configs/default/plugins/viewers.py +++ b/jdaviz/configs/default/plugins/viewers.py @@ -213,7 +213,8 @@ def _get_layer_info(layer): visible_layers = {} for layer in self.state.layers[::-1]: - if layer.visible: + layer_is_wcs_only = layer.layer.meta.get('WCS-ONLY', False) + if layer.visible and not layer_is_wcs_only: prefix_icon, suffix = _get_layer_info(layer) visible_layers[layer.layer.label] = {'color': _get_layer_color(layer), 'linewidth': _get_layer_linewidth(layer), diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index acdbb8e962..806441cc89 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -145,7 +145,6 @@ def load_data(self, data, data_label=None, show_in_viewer=True, **kwargs): kw['data_label'] = None else: kw['data_label'] = data_label - self.app.load_data(filepath, parser_reference='imviz-data-parser', **kw) elif isinstance(data, np.ndarray) and data.ndim >= 3: @@ -344,10 +343,20 @@ def split_filename_with_fits_ext(filename): return filepath, ext, data_label -def layer_is_image_data(layer): +def layer_is_2d(layer): + # returns True for subclasses of BaseData with ndim=2, both for + # layers that are WCS-only as well as images containing data: return isinstance(layer, BaseData) and layer.ndim == 2 +def layer_is_image_data(layer): + return layer_is_2d(layer) and not layer.meta.get('WCS-ONLY', False) + + +def layer_is_wcs_only(layer): + return layer_is_2d(layer) and layer.meta.get('WCS-ONLY', False) + + def layer_is_table_data(layer): return isinstance(layer, BaseData) and layer.ndim == 1 @@ -362,16 +371,13 @@ def get_top_layer_index(viewer): def get_reference_image_data(app): - """Return the first 2D image data in collection and its index to use as reference.""" - refdata = None - iref = 0 - for i, data in enumerate(app.data_collection): - if layer_is_image_data(data): - iref = i - refdata = data - break - if refdata is None: - raise ValueError(f'No valid reference data found in collection: {app.data_collection}') + """ + Return the reference data in the first image viewer and its index + """ + viewer_reference = app._get_first_viewer_reference_name(require_image_viewer=True) + viewer = app.get_viewer(viewer_reference) + refdata = viewer.state.reference_data + iref = app.data_collection.index(refdata) if refdata in app.data_collection else None return refdata, iref @@ -461,7 +467,7 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u continue # We are not touching any existing Subsets. They keep their own links. - if not layer_is_image_data(data): + if not layer_is_2d(data): continue if data in data_already_linked: diff --git a/jdaviz/configs/imviz/plugins/parsers.py b/jdaviz/configs/imviz/plugins/parsers.py index 067069014b..119c369ad6 100644 --- a/jdaviz/configs/imviz/plugins/parsers.py +++ b/jdaviz/configs/imviz/plugins/parsers.py @@ -416,13 +416,21 @@ def _nddata_to_glue_data(ndd, data_label): arr = getattr(ndd, attrib) if arr is None: continue - comp_label = attrib.upper() - cur_label = f'{data_label}[{comp_label}]' - cur_data = Data(label=cur_label) + cur_data = Data() cur_data.meta.update(standardize_metadata(ndd.meta)) if ndd.wcs is not None: cur_data.coords = ndd.wcs raw_arr = arr + + wcs_only = np.all(np.isnan(raw_arr)) + cur_data.meta.update({'WCS-ONLY': wcs_only}) + + cur_label = f'{data_label}' + comp_label = attrib.upper() + if not wcs_only: + cur_label += f'[{comp_label}]' + cur_data.label = cur_label + if attrib == 'data': bunit = ndd.unit or '' elif attrib == 'uncertainty': diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index fb671018bb..1bdbb1d59e 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -3,6 +3,7 @@ # """This module handles calculations based on world coordinate system (WCS).""" +import warnings import base64 import math from io import BytesIO @@ -10,7 +11,17 @@ import matplotlib.pyplot as plt import numpy as np from astropy import units as u +from astropy import coordinates as coord from astropy.coordinates import SkyCoord +from astropy.io import fits +from astropy.modeling import models +from astropy.nddata import NDDataArray +from astropy.wcs.utils import proj_plane_pixel_scales +from astropy.wcs import WCS as FitsWCS, FITSFixedWarning + +from gwcs import coordinate_frames as cf +from gwcs.wcs import WCS as GWCS + from matplotlib.patches import Polygon __all__ = ['get_compass_info', 'draw_compass_mpl'] @@ -275,3 +286,133 @@ def data_outside_gwcs_bounding_box(data, x, y): if not (bb_xmin <= x <= bb_xmax and bb_ymin <= y <= bb_ymax): outside_bounding_box = True # Has to be Python bool, not Numpy bool_ return outside_bounding_box + + +def get_fits_wcs_from_file(filename): + header = fits.getheader(filename) + with warnings.catch_warnings(): + # Ignore a warning on using DATE-OBS in place of MJD-OBS + warnings.filterwarnings('ignore', message="'datfix' made the change", + category=FITSFixedWarning) + wcs = FitsWCS(header) + + return wcs + + +def rotated_gwcs( + center_world_coord, + rotation_angle, + pixel_scales, + refdata_shape=(2, 2) +): + # based on ``gwcs_simple_imaging_units`` in gwcs: + # https://github.com/spacetelescope/gwcs/blob/ + # eec9a2b6de8356495f405de3dc6531538589ce5d/gwcs/tests/conftest.py#L165 + rho = rotation_angle + sin_rho = np.sin(rho.to_value(u.rad)) + cos_rho = np.cos(rho.to_value(u.rad)) + rotation_matrix = np.array([[cos_rho, -sin_rho], + [sin_rho, cos_rho]]) + + # "rescale" the pixel scales. Scaling constant was tuned so that the + # synthetic image is about the same size on the sky as the input image + rescale_pixel_scale = np.array(refdata_shape) / 1000 + + shift_by_crpix = ( + models.Shift(-refdata_shape[1] / 2 * u.pixel) & + models.Shift(-refdata_shape[0] / 2 * u.pixel) + ) + # multiplying by +/-1 can flip east/west + flip_east_west = models.Multiply(-1) & models.Multiply(1) + rotation = models.AffineTransformation2D( + rotation_matrix * u.deg, translation=[0, 0] * u.deg + ) + rotation.input_units_equivalencies = { + "x": u.pixel_scale(pixel_scales[0] * rescale_pixel_scale[0]), + "y": u.pixel_scale(pixel_scales[1] * rescale_pixel_scale[1]) + } + rotation.inverse = models.AffineTransformation2D( + np.linalg.inv(rotation_matrix) * u.pix, translation=[0, 0] * u.pix + ) + rotation.inverse.input_units_equivalencies = { + "x": u.pixel_scale(1 / (pixel_scales[0] * rescale_pixel_scale[0])), + "y": u.pixel_scale(1 / (pixel_scales[1] * rescale_pixel_scale[1])) + } + tan = models.Pix2Sky_TAN() + celestial_rotation = models.RotateNative2Celestial( + center_world_coord.ra, center_world_coord.dec, 180 * u.deg + ) + + det2sky = ( + shift_by_crpix | flip_east_west | rotation | + tan | celestial_rotation + ) + det2sky.name = "linear_transform" + + detector_frame = cf.Frame2D( + name="detector", + axes_names=("x", "y"), + unit=(u.pix, u.pix) + ) + sky_frame = cf.CelestialFrame( + reference_frame=coord.ICRS(), + name='icrs', + unit=(u.deg, u.deg) + ) + pipeline = [ + (detector_frame, det2sky), + (sky_frame, None) + ] + + return GWCS(pipeline) + + +def get_rotated_nddata_from_fits(filename, rotation_angle, refdata_shape=(2, 2)): + """ + Create a synthetic NDDataArray which stores GWCS that approximate + the FITS WCS in ``filename`` rotated by ``rotation_angle``. + + Parameters + ---------- + filename : path-like, str + FITS file to use as reference + rotation_angle : `~astropy.units.Quantity` + Angle to rotate the image counter-clockwise from its + original orientation + refdata_shape : tuple + Shape of the reference data array + + Returns + ------- + ndd : `~astropy.nddata.NDDataArray` + Data are all NaNs, wcs are rotated. + """ + # get the FITS WCS from the file: + wcs = get_fits_wcs_from_file(filename) + + # get the world coordinates of the central pixel + real_image_shape = np.array(wcs.array_shape) + central_pixel_coord = real_image_shape / 2 * u.pix + central_world_coord = wcs.pixel_to_world(*central_pixel_coord) + + # compute the x/y plate scales from the WCS: + pixel_scales = [ + value * unit / u.pix + for value, unit in zip( + proj_plane_pixel_scales(wcs), wcs.wcs.cunit + ) + ] + + # create a GWCS centered on ``filename``, + # and rotated by ``rotation_angle``: + new_rotated_gwcs = rotated_gwcs( + central_world_coord, rotation_angle, pixel_scales + ) + + # create an all-nan NDDataArray with the rotated GWCS: + ndd = NDDataArray( + data=np.nan * np.ones(refdata_shape), + wcs=new_rotated_gwcs, + ) + + return ndd diff --git a/jdaviz/core/events.py b/jdaviz/core/events.py index a69332acae..ec13f21c5b 100644 --- a/jdaviz/core/events.py +++ b/jdaviz/core/events.py @@ -8,7 +8,7 @@ 'SliceToolStateMessage', 'TableClickMessage', 'LinkUpdatedMessage', 'ExitBatchLoadMessage', 'MarkersChangedMessage', 'CanvasRotationChangedMessage', - 'GlobalDisplayUnitChanged'] + 'GlobalDisplayUnitChanged', 'ChangeDataMessage'] class NewViewerMessage(Message): @@ -129,6 +129,37 @@ def viewer_id(self): return self._viewer_id +class ChangeRefDataMessage(Message): + def __init__(self, data, viewer, viewer_id=None, old=None, new=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._data = data + self._viewer = viewer + self._viewer_id = viewer_id + self._old = old + self._new = new + + @property + def data(self): + return self._data + + @property + def viewer(self): + return self._viewer + + @property + def viewer_id(self): + return self._viewer_id + + @property + def old(self): + return self._old + + @property + def new(self): + return self._new + + class SnackbarMessage(Message): def __init__(self, text, color=None, timeout=5000, loading=False, *args, **kwargs): diff --git a/jdaviz/core/freezable_state.py b/jdaviz/core/freezable_state.py index 6405bf035b..04d687f166 100644 --- a/jdaviz/core/freezable_state.py +++ b/jdaviz/core/freezable_state.py @@ -1,6 +1,7 @@ from echo import delay_callback import numpy as np +from glue.core.data import BaseData from glue.viewers.profile.state import ProfileViewerState from glue_jupyter.bqplot.image.state import BqplotImageViewerState from glue.viewers.matplotlib.state import DeferredDrawCallbackProperty as DDCProperty @@ -56,6 +57,7 @@ class FreezableBqplotImageViewerState(BqplotImageViewerState, FreezableState): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.wcs_only_layers = [] def reset_limits(self, *event): if self.reference_data is None: # Nothing to do From f9ea33660e08919b256047213891abddbaa97aae Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 3 May 2023 15:26:34 -0400 Subject: [PATCH 002/155] style fixes --- jdaviz/app.py | 10 +++++++--- jdaviz/core/events.py | 2 +- jdaviz/core/freezable_state.py | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index cd1a0cdf26..6db0526d73 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -485,8 +485,10 @@ def _on_layers_changed(self, msg): layer_name: wcs_only_refdata_icon if is_ref_data else wcs_only_not_refdata_icon} else: - self.state.layer_icons = {**self.state.layer_icons, - layer_name: alpha_index(len(self.state.layer_icons) - n_wcs_layers)} + self.state.layer_icons = { + **self.state.layer_icons, + layer_name: alpha_index(len(self.state.layer_icons) - n_wcs_layers) + } def _on_refdata_changed(self, msg): is_wcs_only = ( @@ -2204,6 +2206,8 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): self.state.viewer_icons.setdefault(vid, len(self.state.viewer_icons)+1) + wcs_only_layers = getattr(viewer.state, 'wcs_only_layers', []) + return { 'id': vid, 'name': name or vid, @@ -2213,7 +2217,7 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): 'viewer_options': "IPY_MODEL_" + viewer.viewer_options.model_id, 'selected_data_items': {}, # noqa data_id: visibility state (visible, hidden, mixed), READ-ONLY 'visible_layers': {}, # label: {color, label_suffix}, READ-ONLY - 'wcs_only_layers': viewer.state.wcs_only_layers, + 'wcs_only_layers': wcs_only_layers, 'canvas_angle': 0, # canvas rotation clockwise rotation angle in deg 'canvas_flip_horizontal': False, # canvas rotation horizontal flip 'config': self.config, # give viewer access to app config/layout diff --git a/jdaviz/core/events.py b/jdaviz/core/events.py index ec13f21c5b..774a4a5fb2 100644 --- a/jdaviz/core/events.py +++ b/jdaviz/core/events.py @@ -8,7 +8,7 @@ 'SliceToolStateMessage', 'TableClickMessage', 'LinkUpdatedMessage', 'ExitBatchLoadMessage', 'MarkersChangedMessage', 'CanvasRotationChangedMessage', - 'GlobalDisplayUnitChanged', 'ChangeDataMessage'] + 'GlobalDisplayUnitChanged', 'ChangeRefDataMessage'] class NewViewerMessage(Message): diff --git a/jdaviz/core/freezable_state.py b/jdaviz/core/freezable_state.py index 04d687f166..21a42ecf75 100644 --- a/jdaviz/core/freezable_state.py +++ b/jdaviz/core/freezable_state.py @@ -1,7 +1,6 @@ from echo import delay_callback import numpy as np -from glue.core.data import BaseData from glue.viewers.profile.state import ProfileViewerState from glue_jupyter.bqplot.image.state import BqplotImageViewerState from glue.viewers.matplotlib.state import DeferredDrawCallbackProperty as DDCProperty From d6dec9796679f50da32ce6bd9ab3e0f05367c0ed Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 3 May 2023 15:55:00 -0400 Subject: [PATCH 003/155] adapting improvements to work in other helpers --- jdaviz/app.py | 9 ++++++--- .../metadata_viewer/tests/test_metadata_viewer.py | 6 ++++-- jdaviz/configs/default/plugins/viewers.py | 5 ++++- jdaviz/configs/imviz/helper.py | 14 +++++++++++++- jdaviz/configs/imviz/plugins/parsers.py | 11 +++++++++-- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index 6db0526d73..8a6216b865 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -478,7 +478,10 @@ def _on_layers_changed(self, msg): wcs_only_refdata_icon = 'mdi-compass-outline' wcs_only_not_refdata_icon = 'mdi-compass-off-outline' - n_wcs_layers = len([icon.startswith('mdi') for icon in self.state.layer_icons]) + n_wcs_layers = ( + len([icon.startswith('mdi') for icon in self.state.layer_icons]) + if is_wcs_only else 0 + ) if layer_name not in self.state.layer_icons: if is_wcs_only: self.state.layer_icons = {**self.state.layer_icons, @@ -764,7 +767,6 @@ def get_viewer_by_id(self, vid): """ return self._viewer_store.get(vid) - def get_subsets(self, subset_name=None, spectral_only=False, spatial_only=False, object_only=False, simplify_spectral=True, use_display_units=False, @@ -1953,7 +1955,8 @@ def set_data_visibility(self, viewer_reference, data_label, visible=True, replac # remove wcs-only data from selected items, # add to wcs_only_layers: for layer in viewer.layers: - if layer.layer.data.label == data_label and layer.layer.meta.get('WCS-ONLY', False): + is_wcs_only = getattr(layer.layer, 'meta', {}).get('WCS-ONLY', False) + if layer.layer.data.label == data_label and is_wcs_only: layer.visible = False viewer_item['wcs_only_layers'].append(data_label) selected_items.pop(data_id) diff --git a/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py b/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py index 33ec26697e..022163696b 100644 --- a/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py +++ b/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py @@ -37,7 +37,7 @@ def test_view_dict(imviz_helper): assert mv.has_metadata assert mv.metadata == [ ('BAR', '10.0', ''), ('BOZO', 'None', ''), ('EXTNAME', 'SCI', ''), - ('EXTVER', '1', ''), ('FOO', '', '')] + ('EXTVER', '1', ''), ('FOO', '', ''), ('WCS-ONLY', 'False', '')] mv.dataset_selected = 'has_nested_meta[DATA]' assert not mv.has_primary @@ -46,7 +46,9 @@ def test_view_dict(imviz_helper): assert mv.has_metadata assert mv.metadata == [ ('EXTNAME', 'ASDF', ''), ('REF.bar', '10.0', ''), - ('REF.foo.1', '', ''), ('REF.foo.2.0', '1', ''), ('REF.foo.2.1', '2', '')] + ('REF.foo.1', '', ''), ('REF.foo.2.0', '1', ''), ('REF.foo.2.1', '2', ''), + ('WCS-ONLY', 'False', '') + ] mv.dataset_selected = 'has_primary[DATA,1]' assert mv.has_primary diff --git a/jdaviz/configs/default/plugins/viewers.py b/jdaviz/configs/default/plugins/viewers.py index fd0099a786..1068ed7441 100644 --- a/jdaviz/configs/default/plugins/viewers.py +++ b/jdaviz/configs/default/plugins/viewers.py @@ -213,7 +213,10 @@ def _get_layer_info(layer): visible_layers = {} for layer in self.state.layers[::-1]: - layer_is_wcs_only = layer.layer.meta.get('WCS-ONLY', False) + layer_is_wcs_only = ( + hasattr(layer.layer, 'meta') and + layer.layer.meta.get('WCS-ONLY', False) + ) if layer.visible and not layer_is_wcs_only: prefix_icon, suffix = _get_layer_info(layer) visible_layers[layer.layer.label] = {'color': _get_layer_color(layer), diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 806441cc89..4759f43e74 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -377,7 +377,19 @@ def get_reference_image_data(app): viewer_reference = app._get_first_viewer_reference_name(require_image_viewer=True) viewer = app.get_viewer(viewer_reference) refdata = viewer.state.reference_data - iref = app.data_collection.index(refdata) if refdata in app.data_collection else None + + if refdata is not None: + iref = app.data_collection.index(refdata) + return refdata, iref + + # if reference data not found above, fall back on old method: + for i, data in enumerate(app.data_collection): + if layer_is_image_data(data): + iref = i + refdata = data + break + if refdata is None: + raise ValueError(f'No valid reference data found in collection: {app.data_collection}') return refdata, iref diff --git a/jdaviz/configs/imviz/plugins/parsers.py b/jdaviz/configs/imviz/plugins/parsers.py index 119c369ad6..c3e25b5ed7 100644 --- a/jdaviz/configs/imviz/plugins/parsers.py +++ b/jdaviz/configs/imviz/plugins/parsers.py @@ -412,7 +412,10 @@ def _nddata_to_glue_data(ndd, data_label): if ndd.data.ndim != 2: raise ValueError(f'Imviz cannot load this NDData with ndim={ndd.data.ndim}') - for attrib in ['data', 'mask', 'uncertainty']: + for attrib, sub_attrib in zip( + ['data', 'mask', 'uncertainty'], + ['data', None, 'array'] + ): arr = getattr(ndd, attrib) if arr is None: continue @@ -422,7 +425,11 @@ def _nddata_to_glue_data(ndd, data_label): cur_data.coords = ndd.wcs raw_arr = arr - wcs_only = np.all(np.isnan(raw_arr)) + if sub_attrib is not None: + base_arr = getattr(raw_arr, sub_attrib) + else: + base_arr = raw_arr + wcs_only = np.all(np.isnan(base_arr)) cur_data.meta.update({'WCS-ONLY': wcs_only}) cur_label = f'{data_label}' From 72a2dfdf62d1323dceda218937fae8a88cec6bf8 Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Mon, 8 May 2023 10:09:18 -0400 Subject: [PATCH 004/155] more general change refdata method --- jdaviz/app.py | 21 +++--- jdaviz/configs/imviz/wcs_utils.py | 107 +++++++++++++++++++++--------- 2 files changed, 85 insertions(+), 43 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index 8a6216b865..38ab4fe42c 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -494,13 +494,8 @@ def _on_layers_changed(self, msg): } def _on_refdata_changed(self, msg): - is_wcs_only = ( - msg.old.meta.get('WCS-ONLY', False) or - msg.new.meta.get('WCS-ONLY', False) - ) - - if not is_wcs_only: - return + old_is_wcs_only = msg.old.meta.get('WCS-ONLY', False) + new_is_wcs_only = msg.new.meta.get('WCS-ONLY', False) wcs_only_refdata_icon = 'mdi-compass-outline' wcs_only_not_refdata_icon = 'mdi-compass-off-outline' @@ -512,11 +507,11 @@ def switch_icon(old_icon, new_icon): new_layer_icons = {} for i, (layer_name, layer_icon) in enumerate(self.state.layer_icons.items()): - if layer_name == msg.old.label: + if layer_name == msg.old.label and old_is_wcs_only: new_layer_icons[layer_name] = switch_icon( layer_icon, wcs_only_not_refdata_icon ) - elif layer_name == msg.new.label: + elif layer_name == msg.new.label and new_is_wcs_only: new_layer_icons[layer_name] = switch_icon( layer_icon, wcs_only_refdata_icon ) @@ -535,7 +530,7 @@ def _change_reference_data(self, new_refdata_label): viewer_reference = self._get_first_viewer_reference_name() viewer = self.get_viewer(viewer_reference) - old_refdata = viewer.state.reference_data + old_refdata = self._viewer_store[viewer_reference].state.reference_data if new_refdata_label == old_refdata.label: # if there's no refdata change, don't do anything: @@ -546,8 +541,6 @@ def _change_reference_data(self, new_refdata_label): if data.label == new_refdata_label ] - self._viewer_store[viewer_reference].state.reference_data = new_refdata - change_refdata_message = ChangeRefDataMessage( new_refdata, viewer, @@ -556,9 +549,11 @@ def _change_reference_data(self, new_refdata_label): old=old_refdata, new=new_refdata ) + + self._viewer_store[viewer_reference].state.reference_data = new_refdata self.hub.broadcast(change_refdata_message) - viewer.state.reset_limits() + self._viewer_store[viewer_reference].state.reset_limits() def _link_new_data(self, reference_data=None, data_to_be_linked=None): """ diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index 1bdbb1d59e..eca0c99e9f 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -288,7 +288,7 @@ def data_outside_gwcs_bounding_box(data, x, y): return outside_bounding_box -def get_fits_wcs_from_file(filename): +def _get_fits_wcs_from_file(filename): header = fits.getheader(filename) with warnings.catch_warnings(): # Ignore a warning on using DATE-OBS in place of MJD-OBS @@ -303,6 +303,7 @@ def rotated_gwcs( center_world_coord, rotation_angle, pixel_scales, + cdelt_signs, refdata_shape=(2, 2) ): # based on ``gwcs_simple_imaging_units`` in gwcs: @@ -319,11 +320,12 @@ def rotated_gwcs( rescale_pixel_scale = np.array(refdata_shape) / 1000 shift_by_crpix = ( - models.Shift(-refdata_shape[1] / 2 * u.pixel) & - models.Shift(-refdata_shape[0] / 2 * u.pixel) + models.Shift(-refdata_shape[0] / 2 * u.pixel) & + models.Shift(-refdata_shape[1] / 2 * u.pixel) ) - # multiplying by +/-1 can flip east/west - flip_east_west = models.Multiply(-1) & models.Multiply(1) + + # multiplying by +/-1 can flip north/south or east/west + flip_axes = models.Multiply(cdelt_signs[0]) & models.Multiply(cdelt_signs[1]) rotation = models.AffineTransformation2D( rotation_matrix * u.deg, translation=[0, 0] * u.deg ) @@ -344,7 +346,7 @@ def rotated_gwcs( ) det2sky = ( - shift_by_crpix | flip_east_west | rotation | + flip_axes | shift_by_crpix | rotation | tan | celestial_rotation ) det2sky.name = "linear_transform" @@ -367,11 +369,46 @@ def rotated_gwcs( return GWCS(pipeline) -def get_rotated_nddata_from_fits(filename, rotation_angle, refdata_shape=(2, 2)): +def _prepare_rotated_nddata(wcs, rotation_angle, refdata_shape): + # get the world coordinates of the central pixel + real_image_shape = np.array(wcs.array_shape) + central_pixel_coord = real_image_shape / 2 * u.pix + central_world_coord = wcs.pixel_to_world(*central_pixel_coord) + rotation_angle = coord.Angle(rotation_angle).wrap_at(360 * u.deg) + + # compute the x/y plate scales from the WCS: + pixel_scales = [ + value * unit / u.pix + for value, unit in zip( + proj_plane_pixel_scales(wcs), wcs.wcs.cunit + ) + ] + + # flip e.g. RA or Dec axes? + cdelt_signs = np.sign(wcs.wcs.cdelt) + + # create a GWCS centered on ``filename``, + # and rotated by ``rotation_angle``: + new_rotated_gwcs = rotated_gwcs( + central_world_coord, rotation_angle, pixel_scales, cdelt_signs + ) + + # create an all-nan NDDataArray with the rotated GWCS: + ndd = NDDataArray( + data=np.nan * np.ones(refdata_shape), + wcs=new_rotated_gwcs, + ) + return ndd + + +def _get_rotated_nddata_from_fits(filename, rotation_angle, refdata_shape=(2, 2)): """ Create a synthetic NDDataArray which stores GWCS that approximate the FITS WCS in ``filename`` rotated by ``rotation_angle``. + This method is useful for ensuring that future datasets are loaded + in the correct orientation. + Parameters ---------- filename : path-like, str @@ -388,31 +425,41 @@ def get_rotated_nddata_from_fits(filename, rotation_angle, refdata_shape=(2, 2)) Data are all NaNs, wcs are rotated. """ # get the FITS WCS from the file: - wcs = get_fits_wcs_from_file(filename) + wcs = _get_fits_wcs_from_file(filename) - # get the world coordinates of the central pixel - real_image_shape = np.array(wcs.array_shape) - central_pixel_coord = real_image_shape / 2 * u.pix - central_world_coord = wcs.pixel_to_world(*central_pixel_coord) + return _prepare_rotated_nddata(wcs, rotation_angle, refdata_shape) - # compute the x/y plate scales from the WCS: - pixel_scales = [ - value * unit / u.pix - for value, unit in zip( - proj_plane_pixel_scales(wcs), wcs.wcs.cunit - ) - ] - # create a GWCS centered on ``filename``, - # and rotated by ``rotation_angle``: - new_rotated_gwcs = rotated_gwcs( - central_world_coord, rotation_angle, pixel_scales - ) +def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shape=(2, 2)): + """ + Create a synthetic NDDataArray which stores GWCS that approximate + the WCS in the coords attr of the Data object with label ``data_label`` + loaded into ``app``. - # create an all-nan NDDataArray with the rotated GWCS: - ndd = NDDataArray( - data=np.nan * np.ones(refdata_shape), - wcs=new_rotated_gwcs, - ) + This method is useful for rotating pre-loaded datasets when + combined with ``app._change_reference_data(data_label)``. - return ndd + Parameters + ---------- + app : `~jdaviz.Application` + App instance containing ``data_label`` + data_label : str + Data label for the Data to rotate + rotation_angle : `~astropy.units.Quantity` + Angle to rotate the image counter-clockwise from its + original orientation + refdata_shape : tuple + Shape of the reference data array + + Returns + ------- + ndd : `~astropy.nddata.NDDataArray` + Data are all NaNs, wcs are rotated. + """ + # get the WCS from the Data object's coords attribute: + [wcs] = [ + data.coords for data in app.data_collection + if data.label == data_label + ] + + return _prepare_rotated_nddata(wcs, rotation_angle, refdata_shape) From 91930b7309a18c47602f35e293539ff5229e27b6 Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 10 May 2023 12:22:22 -0400 Subject: [PATCH 005/155] some test coverage for wcs-based rotation via glue --- jdaviz/app.py | 2 +- jdaviz/configs/imviz/plugins/parsers.py | 9 ++- jdaviz/configs/imviz/tests/test_wcs_utils.py | 59 ++++++++++++++++++++ jdaviz/configs/imviz/wcs_utils.py | 24 ++++---- 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index 38ab4fe42c..dc3aaa9d10 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -1953,7 +1953,7 @@ def set_data_visibility(self, viewer_reference, data_label, visible=True, replac is_wcs_only = getattr(layer.layer, 'meta', {}).get('WCS-ONLY', False) if layer.layer.data.label == data_label and is_wcs_only: layer.visible = False - viewer_item['wcs_only_layers'].append(data_label) + viewer.state.wcs_only_layers.append(data_label) selected_items.pop(data_id) # Sets the plot axes labels to be the units of the most recently diff --git a/jdaviz/configs/imviz/plugins/parsers.py b/jdaviz/configs/imviz/plugins/parsers.py index c3e25b5ed7..b654e4b499 100644 --- a/jdaviz/configs/imviz/plugins/parsers.py +++ b/jdaviz/configs/imviz/plugins/parsers.py @@ -414,7 +414,7 @@ def _nddata_to_glue_data(ndd, data_label): for attrib, sub_attrib in zip( ['data', 'mask', 'uncertainty'], - ['data', None, 'array'] + [None, None, 'array'] ): arr = getattr(ndd, attrib) if arr is None: @@ -426,11 +426,16 @@ def _nddata_to_glue_data(ndd, data_label): raw_arr = arr if sub_attrib is not None: + # since NDDataArray.uncertainty may be an object like + # StdDevUncertainty, we need to take another attr + # like StdDevUncertainty.array: base_arr = getattr(raw_arr, sub_attrib) else: base_arr = raw_arr wcs_only = np.all(np.isnan(base_arr)) - cur_data.meta.update({'WCS-ONLY': wcs_only}) + + if 'WCS-ONLY' not in cur_data.meta or not cur_data.meta.get('WCS-ONLY'): + cur_data.meta.update({'WCS-ONLY': wcs_only}) cur_label = f'{data_label}' comp_label = attrib.upper() diff --git a/jdaviz/configs/imviz/tests/test_wcs_utils.py b/jdaviz/configs/imviz/tests/test_wcs_utils.py index bff80391aa..0cbdad7c02 100644 --- a/jdaviz/configs/imviz/tests/test_wcs_utils.py +++ b/jdaviz/configs/imviz/tests/test_wcs_utils.py @@ -1,7 +1,9 @@ +import pytest import gwcs import numpy as np from astropy import units as u from astropy.coordinates import ICRS +from astropy.utils.data import get_pkg_data_filename from astropy.modeling import models from astropy.wcs import WCS from gwcs import coordinate_frames as cf @@ -104,3 +106,60 @@ def test_simple_gwcs(): 1262.0057201165127, 606.2863901330095, 155.2870478938214, -86.89813081941797)) assert not result[-1] + + +@pytest.mark.remote_data +@pytest.mark.filterwarnings(r"ignore::astropy.wcs.wcs.FITSFixedWarning") +def test_non_wcs_layer_labels(imviz_helper): + # load a real image w/ WCS: + real_image_path = get_pkg_data_filename('tutorials/FITS-images/HorseHead.fits') + imviz_helper.load_data(real_image_path) + + # load a WCS-only layer: + ndd = wcs_utils._get_rotated_nddata_from_label( + app=imviz_helper.app, + data_label=imviz_helper.app.data_collection[0].label, + rotation_angle=0*u.deg + ) + imviz_helper.load_data(ndd) + + # confirm that only the image is labeled: + assert len(imviz_helper.app.state.layer_icons) == 2 + + # confirm the WCS-only layer is logged: + viewer = imviz_helper.app.get_viewer('imviz-0') + assert len(viewer.state.wcs_only_layers) == 1 + + # load a second WCS-only layer: + ndd2 = wcs_utils._get_rotated_nddata_from_label( + app=imviz_helper.app, + data_label=imviz_helper.app.data_collection[0].label, + rotation_angle=45*u.deg + ) + imviz_helper.load_data(ndd2) + assert len(imviz_helper.app.state.layer_icons) == 3 + assert len(viewer.state.wcs_only_layers) == 2 + + wcs_only_refdata_icon = 'mdi-compass-outline' + wcs_only_not_refdata_icon = 'mdi-compass-off-outline' + + for i, data in enumerate(imviz_helper.app.data_collection): + viewer = imviz_helper.app.get_viewer("imviz-0") + + if i == 0: + # first entry is image data: + assert imviz_helper.app.state.layer_icons[data.label] == 'a' + assert viewer.state.reference_data.label == data.label + else: + # icon before setting as refdata: + before_icon = imviz_helper.app.state.layer_icons[data.label] + + # set as refdata: + imviz_helper.app._change_reference_data(data.label) + assert viewer.state.reference_data.label == data.label + + # icon after setting as refdata: + after_icon = imviz_helper.app.state.layer_icons[data.label] + + assert before_icon == wcs_only_not_refdata_icon + assert after_icon == wcs_only_refdata_icon diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index eca0c99e9f..64acfa684b 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -288,8 +288,8 @@ def data_outside_gwcs_bounding_box(data, x, y): return outside_bounding_box -def _get_fits_wcs_from_file(filename): - header = fits.getheader(filename) +def _get_fits_wcs_from_file(filename, ext=None): + header = fits.getheader(filename, ext=ext) with warnings.catch_warnings(): # Ignore a warning on using DATE-OBS in place of MJD-OBS warnings.filterwarnings('ignore', message="'datfix' made the change", @@ -369,9 +369,9 @@ def rotated_gwcs( return GWCS(pipeline) -def _prepare_rotated_nddata(wcs, rotation_angle, refdata_shape): +def _prepare_rotated_nddata(image_data, wcs, rotation_angle, refdata_shape): # get the world coordinates of the central pixel - real_image_shape = np.array(wcs.array_shape) + real_image_shape = np.array(np.shape(image_data)) central_pixel_coord = real_image_shape / 2 * u.pix central_world_coord = wcs.pixel_to_world(*central_pixel_coord) rotation_angle = coord.Angle(rotation_angle).wrap_at(360 * u.deg) @@ -401,7 +401,7 @@ def _prepare_rotated_nddata(wcs, rotation_angle, refdata_shape): return ndd -def _get_rotated_nddata_from_fits(filename, rotation_angle, refdata_shape=(2, 2)): +def _get_rotated_nddata_from_fits(filename, rotation_angle, refdata_shape=(2, 2), ext=None): """ Create a synthetic NDDataArray which stores GWCS that approximate the FITS WCS in ``filename`` rotated by ``rotation_angle``. @@ -425,12 +425,14 @@ def _get_rotated_nddata_from_fits(filename, rotation_angle, refdata_shape=(2, 2) Data are all NaNs, wcs are rotated. """ # get the FITS WCS from the file: - wcs = _get_fits_wcs_from_file(filename) + wcs = _get_fits_wcs_from_file(filename, ext=ext) return _prepare_rotated_nddata(wcs, rotation_angle, refdata_shape) -def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shape=(2, 2)): +def _get_rotated_nddata_from_label( + app, data_label, rotation_angle, refdata_shape=(2, 2), main_component_idx=0 +): """ Create a synthetic NDDataArray which stores GWCS that approximate the WCS in the coords attr of the Data object with label ``data_label`` @@ -457,9 +459,9 @@ def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shap Data are all NaNs, wcs are rotated. """ # get the WCS from the Data object's coords attribute: - [wcs] = [ - data.coords for data in app.data_collection + [data] = [ + data for data in app.data_collection if data.label == data_label ] - - return _prepare_rotated_nddata(wcs, rotation_angle, refdata_shape) + image = data[data.main_components[main_component_idx]] + return _prepare_rotated_nddata(image, data.coords, rotation_angle, refdata_shape) From e20be3d3899a3c3448b365cb935be2ad08173bce Mon Sep 17 00:00:00 2001 From: "P. L. Lim" <2090236+pllim@users.noreply.github.com> Date: Mon, 22 May 2023 08:26:08 -0400 Subject: [PATCH 006/155] Proposed changes for PR 2179 (ENH: Image rotation via WCS-only layers) (#4) * Lim edits * Improve verbiage in docstring Co-authored-by: Brett M. Morris * Address review comments from bmorris3 * Add multi WCS-only test back * Update concept notebook * corrections to the pr to the pr to the pr (#10) * corrections to the pr to the pr to the pr * using app-level attr * Update jdaviz/configs/imviz/plugins/parsers.py Co-authored-by: P. L. Lim <2090236+pllim@users.noreply.github.com> * Update jdaviz/configs/imviz/plugins/parsers.py Co-authored-by: P. L. Lim <2090236+pllim@users.noreply.github.com> --------- Co-authored-by: P. L. Lim <2090236+pllim@users.noreply.github.com> * Follow up to PR to my PR to your PR * WCS-only must be loaded into viewer but not visible. Fix concept notebook --------- Co-authored-by: Brett M. Morris --- jdaviz/app.py | 51 +- .../tests/test_metadata_viewer.py | 6 +- jdaviz/configs/default/plugins/viewers.py | 2 +- jdaviz/configs/imviz/helper.py | 39 +- jdaviz/configs/imviz/plugins/parsers.py | 49 +- jdaviz/configs/imviz/plugins/viewers.py | 29 +- jdaviz/configs/imviz/tests/test_wcs_utils.py | 132 ++--- jdaviz/configs/imviz/wcs_utils.py | 103 ++-- jdaviz/core/events.py | 7 +- jdaviz/core/freezable_state.py | 2 +- .../imviz_rotation_by_hidden_layer.ipynb | 451 ++++++++++++++++++ 11 files changed, 657 insertions(+), 214 deletions(-) create mode 100644 notebooks/concepts/imviz_rotation_by_hidden_layer.ipynb diff --git a/jdaviz/app.py b/jdaviz/app.py index dc3aaa9d10..04f8416c30 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -307,6 +307,12 @@ def __init__(self, configuration=None, *args, **kwargs): # data loading self.auto_link = kwargs.pop('auto_link', True) + # Imviz linking + self._wcs_only_label = "_WCS_ONLY" + if self.config == "imviz": + self._link_type = None + self._wcs_use_affine = None + # Subscribe to messages indicating that a new viewer needs to be # created. When received, information is passed to the application # handler to generate the appropriate viewer instance. @@ -468,7 +474,7 @@ def _color_to_level(color): def _on_layers_changed(self, msg): if hasattr(msg, 'data'): layer_name = msg.data.label - is_wcs_only = msg.data.meta.get('WCS-ONLY', False) + is_wcs_only = msg.data.meta.get(self._wcs_only_label, False) is_ref_data = getattr(msg._viewer.state.reference_data, 'label', '') == layer_name elif hasattr(msg, 'subset'): layer_name = msg.subset.label @@ -494,8 +500,8 @@ def _on_layers_changed(self, msg): } def _on_refdata_changed(self, msg): - old_is_wcs_only = msg.old.meta.get('WCS-ONLY', False) - new_is_wcs_only = msg.new.meta.get('WCS-ONLY', False) + old_is_wcs_only = msg.old.meta.get(self._wcs_only_label, False) + new_is_wcs_only = msg.data.meta.get(self._wcs_only_label, False) wcs_only_refdata_icon = 'mdi-compass-outline' wcs_only_not_refdata_icon = 'mdi-compass-off-outline' @@ -511,7 +517,7 @@ def switch_icon(old_icon, new_icon): new_layer_icons[layer_name] = switch_icon( layer_icon, wcs_only_not_refdata_icon ) - elif layer_name == msg.new.label and new_is_wcs_only: + elif layer_name == msg.data.label and new_is_wcs_only: new_layer_icons[layer_name] = switch_icon( layer_icon, wcs_only_refdata_icon ) @@ -525,35 +531,32 @@ def _change_reference_data(self, new_refdata_label): Change reference data to Data with ``data_label`` """ if self.config != 'imviz': - # this method is only meant for imviz for now + # this method is only meant for Imviz for now return - viewer_reference = self._get_first_viewer_reference_name() - viewer = self.get_viewer(viewer_reference) - old_refdata = self._viewer_store[viewer_reference].state.reference_data + viewer_id = f'{self.config}-0' # Same as the ID in imviz.destroy_viewer() + viewer = self._jdaviz_helper.default_viewer + old_refdata = viewer.state.reference_data if new_refdata_label == old_refdata.label: # if there's no refdata change, don't do anything: return - [new_refdata] = [ - data for data in self.data_collection - if data.label == new_refdata_label - ] - - change_refdata_message = ChangeRefDataMessage( + new_refdata = self.data_collection[new_refdata_label] + viewer.state.reference_data = new_refdata + self.hub.broadcast(ChangeRefDataMessage( new_refdata, viewer, - viewer_id=viewer_reference, - sender=self, + viewer_id=viewer_id, old=old_refdata, - new=new_refdata - ) + sender=self)) - self._viewer_store[viewer_reference].state.reference_data = new_refdata - self.hub.broadcast(change_refdata_message) + # Re-link + self._jdaviz_helper.link_data(link_type=self._link_type, + wcs_use_affine=self._wcs_use_affine, + error_on_fail=True) - self._viewer_store[viewer_reference].state.reset_limits() + viewer.state.reset_limits() def _link_new_data(self, reference_data=None, data_to_be_linked=None): """ @@ -1947,10 +1950,10 @@ def set_data_visibility(self, viewer_reference, data_label, visible=True, replac if id != data_id: selected_items[id] = 'hidden' - # remove wcs-only data from selected items, + # remove WCS-only data from selected items, # add to wcs_only_layers: for layer in viewer.layers: - is_wcs_only = getattr(layer.layer, 'meta', {}).get('WCS-ONLY', False) + is_wcs_only = getattr(layer.layer, 'meta', {}).get(self._wcs_only_label, False) if layer.layer.data.label == data_label and is_wcs_only: layer.visible = False viewer.state.wcs_only_layers.append(data_label) @@ -2073,7 +2076,7 @@ def _on_data_deleted(self, msg): def _create_data_item(self, data): ndims = len(data.shape) wcsaxes = data.meta.get('WCSAXES', None) - wcs_only = data.meta.get('WCS-ONLY', False) + wcs_only = data.meta.get(self._wcs_only_label, False) if wcsaxes is None: # then we'll need to determine type another way, we want to avoid # this when we can though since its not as cheap diff --git a/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py b/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py index 022163696b..33ec26697e 100644 --- a/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py +++ b/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py @@ -37,7 +37,7 @@ def test_view_dict(imviz_helper): assert mv.has_metadata assert mv.metadata == [ ('BAR', '10.0', ''), ('BOZO', 'None', ''), ('EXTNAME', 'SCI', ''), - ('EXTVER', '1', ''), ('FOO', '', ''), ('WCS-ONLY', 'False', '')] + ('EXTVER', '1', ''), ('FOO', '', '')] mv.dataset_selected = 'has_nested_meta[DATA]' assert not mv.has_primary @@ -46,9 +46,7 @@ def test_view_dict(imviz_helper): assert mv.has_metadata assert mv.metadata == [ ('EXTNAME', 'ASDF', ''), ('REF.bar', '10.0', ''), - ('REF.foo.1', '', ''), ('REF.foo.2.0', '1', ''), ('REF.foo.2.1', '2', ''), - ('WCS-ONLY', 'False', '') - ] + ('REF.foo.1', '', ''), ('REF.foo.2.0', '1', ''), ('REF.foo.2.1', '2', '')] mv.dataset_selected = 'has_primary[DATA,1]' assert mv.has_primary diff --git a/jdaviz/configs/default/plugins/viewers.py b/jdaviz/configs/default/plugins/viewers.py index 1068ed7441..6fdfe84108 100644 --- a/jdaviz/configs/default/plugins/viewers.py +++ b/jdaviz/configs/default/plugins/viewers.py @@ -215,7 +215,7 @@ def _get_layer_info(layer): for layer in self.state.layers[::-1]: layer_is_wcs_only = ( hasattr(layer.layer, 'meta') and - layer.layer.meta.get('WCS-ONLY', False) + layer.layer.meta.get(self.jdaviz_app._wcs_only_label, False) ) if layer.visible and not layer_is_wcs_only: prefix_icon, suffix = _get_layer_info(layer) diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 4759f43e74..7cabb3fa65 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -19,11 +19,6 @@ class Imviz(ImageConfigHelper): _default_configuration = 'imviz' _default_viewer_reference_name = "image-viewer" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.app._link_type = None - self.app._wcs_use_affine = None - def create_image_viewer(self, viewer_name=None): """Create a new image viewer. @@ -174,11 +169,22 @@ def load_data(self, data, data_label=None, show_in_viewer=True, **kwargs): # find the current label(s) - TODO: replace this by calling default label functionality # above instead of having to refind it - applied_labels = [label for label in self.app.data_collection.labels if label not in prev_data_labels] # noqa + applied_labels = [] + applied_visible = [] + for data in self.app.data_collection: + label = data.label + if label not in prev_data_labels: + applied_labels.append(label) + if not data.meta.get(self.app._wcs_only_label, False): + applied_visible.append(True) + else: + applied_visible.append(False) if show_in_viewer is True: show_in_viewer = f"{self.app.config}-0" + # NOTE: We will never try to batch load WCS-only, but if we do, add extra logic + # in batch_load within core/helpers.py module. if self._in_batch_load and show_in_viewer: for applied_label in applied_labels: self._delayed_show_in_viewer_labels[applied_label] = show_in_viewer @@ -192,8 +198,8 @@ def load_data(self, data, data_label=None, show_in_viewer=True, **kwargs): # NOTE: If the batch_load context manager was used, it will # handle that logic instead. if show_in_viewer: - for applied_label in applied_labels: - self.app.add_data_to_viewer(show_in_viewer, applied_label) + for applied_label, visible in zip(applied_labels, applied_visible): + self.app.add_data_to_viewer(show_in_viewer, applied_label, visible=visible) def link_data(self, **kwargs): """(Re)link loaded data in Imviz with the desired link type. @@ -349,12 +355,14 @@ def layer_is_2d(layer): return isinstance(layer, BaseData) and layer.ndim == 2 +# NOTE: Sync with app._wcs_only_label as needed. def layer_is_image_data(layer): - return layer_is_2d(layer) and not layer.meta.get('WCS-ONLY', False) + return layer_is_2d(layer) and not layer.meta.get("_WCS_ONLY", False) +# NOTE: Sync with app._wcs_only_label as needed. def layer_is_wcs_only(layer): - return layer_is_2d(layer) and layer.meta.get('WCS-ONLY', False) + return layer_is_2d(layer) and layer.meta.get("_WCS_ONLY", False) def layer_is_table_data(layer): @@ -374,9 +382,7 @@ def get_reference_image_data(app): """ Return the reference data in the first image viewer and its index """ - viewer_reference = app._get_first_viewer_reference_name(require_image_viewer=True) - viewer = app.get_viewer(viewer_reference) - refdata = viewer.state.reference_data + refdata = app._jdaviz_helper.default_viewer.state.reference_data if refdata is not None: iref = app.data_collection.index(refdata) @@ -458,15 +464,17 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u else: link_plugin = None + data_already_linked = [] if link_type == app._link_type and wcs_use_affine == app._wcs_use_affine: - data_already_linked = [link.data2 for link in app.data_collection.external_links] + for link in app.data_collection.external_links: + if link.data1.label != app._wcs_only_label: + data_already_linked.append(link.data2) else: for viewer in app._viewer_store.values(): if len(viewer._marktags): raise ValueError(f"cannot change link_type (from '{app._link_type}' to " f"'{link_type}') when markers are present. " f" Clear markers with viewer.reset_markers() first") - data_already_linked = [] refdata, iref = get_reference_image_data(app) links_list = [] @@ -487,6 +495,7 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u continue ids1 = data.pixel_component_ids + new_links = [] try: if link_type == 'pixels': new_links = [LinkSame(ids0[i], ids1[i]) for i in ndim_range] diff --git a/jdaviz/configs/imviz/plugins/parsers.py b/jdaviz/configs/imviz/plugins/parsers.py index b654e4b499..a32edf4a57 100644 --- a/jdaviz/configs/imviz/plugins/parsers.py +++ b/jdaviz/configs/imviz/plugins/parsers.py @@ -146,7 +146,10 @@ def get_image_data_iterator(app, file_obj, data_label, ext=None): data_iter = _hdu_to_glue_data(file_obj, data_label) elif isinstance(file_obj, NDData): - data_iter = _nddata_to_glue_data(file_obj, data_label) + if file_obj.meta.get(app._wcs_only_label, False): + data_iter = _wcsonly_to_glue_data(file_obj, data_label) + else: + data_iter = _nddata_to_glue_data(file_obj, data_label) elif isinstance(file_obj, np.ndarray): data_iter = _ndarray_to_glue_data(file_obj, data_label) @@ -181,7 +184,8 @@ def _parse_image(app, file_obj, data_label, ext=None): # for outside_*_bounding_box should also be updated. data.coords._orig_bounding_box = data.coords.bounding_box data.coords.bounding_box = None - data_label = app.return_data_label(data_label, alt_name="image_data") + if not data.meta.get(app._wcs_only_label, False): + data_label = app.return_data_label(data_label, alt_name="image_data") app.add_data(data, data_label) # Do not run link_image_data here. We do it at the end in Imviz.load_data() @@ -412,37 +416,17 @@ def _nddata_to_glue_data(ndd, data_label): if ndd.data.ndim != 2: raise ValueError(f'Imviz cannot load this NDData with ndim={ndd.data.ndim}') - for attrib, sub_attrib in zip( - ['data', 'mask', 'uncertainty'], - [None, None, 'array'] - ): + for attrib in ('data', 'mask', 'uncertainty'): arr = getattr(ndd, attrib) if arr is None: continue - cur_data = Data() + comp_label = attrib.upper() + cur_label = f'{data_label}[{comp_label}]' + cur_data = Data(label=cur_label) cur_data.meta.update(standardize_metadata(ndd.meta)) if ndd.wcs is not None: cur_data.coords = ndd.wcs raw_arr = arr - - if sub_attrib is not None: - # since NDDataArray.uncertainty may be an object like - # StdDevUncertainty, we need to take another attr - # like StdDevUncertainty.array: - base_arr = getattr(raw_arr, sub_attrib) - else: - base_arr = raw_arr - wcs_only = np.all(np.isnan(base_arr)) - - if 'WCS-ONLY' not in cur_data.meta or not cur_data.meta.get('WCS-ONLY'): - cur_data.meta.update({'WCS-ONLY': wcs_only}) - - cur_label = f'{data_label}' - comp_label = attrib.upper() - if not wcs_only: - cur_label += f'[{comp_label}]' - cur_data.label = cur_label - if attrib == 'data': bunit = ndd.unit or '' elif attrib == 'uncertainty': @@ -463,3 +447,16 @@ def _ndarray_to_glue_data(arr, data_label): component = Component.autotyped(arr) data.add_component(component=component, label='DATA') yield (data, data_label) + + +# ---- Functions that handle WCS-only data ----- + +def _wcsonly_to_glue_data(ndd, data_label): + """Return Data given NDData containing WCS-only data.""" + arr = ndd.data + data = Data(label=data_label) + data.meta.update(standardize_metadata(ndd.meta)) + data.coords = ndd.wcs + component = Component.autotyped(arr, units="") + data.add_component(component=component, label="DATA") + yield (data, data_label) diff --git a/jdaviz/configs/imviz/plugins/viewers.py b/jdaviz/configs/imviz/plugins/viewers.py index 53121734c2..906c7c4c75 100644 --- a/jdaviz/configs/imviz/plugins/viewers.py +++ b/jdaviz/configs/imviz/plugins/viewers.py @@ -1,5 +1,6 @@ import numpy as np +from glue.core.link_helpers import LinkSame import astropy.units as u from astropy.wcs.utils import pixel_to_pixel from astropy.visualization import ImageNormalize, LinearStretch, PercentileInterval @@ -280,15 +281,25 @@ def get_link_type(self, data_label): if len(self.session.application.data_collection) == 0: raise ValueError('No reference data for link look-up') - # TODO: Brett Morris might want to look at this for - # https://github.com/spacetelescope/jdaviz/pull/2179 - # ref_label = self.state.reference_data ??? - # - # The original links were created against data_collection[0], not necessarily - # against the current viewer reference_data - ref_label = self.session.application.data_collection[0].label - - return self.jdaviz_helper.get_link_type(ref_label, data_label) + ref_label = self.state.reference_data.label + if data_label == ref_label: + return 'self' + + link_type = None + for elink in self.session.application.data_collection.external_links: + elink_labels = (elink.data1.label, elink.data2.label) + if (data_label in elink_labels and + (ref_label in elink_labels or ref_label == self.jdaviz_app._wcs_only_label)): + if isinstance(elink, LinkSame): # Assumes WCS link never uses LinkSame + link_type = 'pixels' + else: # If not pixels, must be WCS + link_type = 'wcs' + break # Might have duplicate, just grab first match + + if link_type is None: + raise ValueError(f'{data_label} not found in data collection external links') + + return link_type def _get_center_skycoord(self, data=None): if data is None: diff --git a/jdaviz/configs/imviz/tests/test_wcs_utils.py b/jdaviz/configs/imviz/tests/test_wcs_utils.py index 0cbdad7c02..aa9bd8a3de 100644 --- a/jdaviz/configs/imviz/tests/test_wcs_utils.py +++ b/jdaviz/configs/imviz/tests/test_wcs_utils.py @@ -1,15 +1,15 @@ -import pytest import gwcs import numpy as np +import pytest from astropy import units as u from astropy.coordinates import ICRS -from astropy.utils.data import get_pkg_data_filename from astropy.modeling import models from astropy.wcs import WCS from gwcs import coordinate_frames as cf from numpy.testing import assert_allclose from jdaviz.configs.imviz import wcs_utils +from jdaviz.configs.imviz.tests.utils import BaseImviz_WCS_GWCS def test_simple_fits_wcs(): @@ -108,58 +108,76 @@ def test_simple_gwcs(): assert not result[-1] -@pytest.mark.remote_data -@pytest.mark.filterwarnings(r"ignore::astropy.wcs.wcs.FITSFixedWarning") -def test_non_wcs_layer_labels(imviz_helper): - # load a real image w/ WCS: - real_image_path = get_pkg_data_filename('tutorials/FITS-images/HorseHead.fits') - imviz_helper.load_data(real_image_path) - - # load a WCS-only layer: - ndd = wcs_utils._get_rotated_nddata_from_label( - app=imviz_helper.app, - data_label=imviz_helper.app.data_collection[0].label, - rotation_angle=0*u.deg - ) - imviz_helper.load_data(ndd) - - # confirm that only the image is labeled: - assert len(imviz_helper.app.state.layer_icons) == 2 - - # confirm the WCS-only layer is logged: - viewer = imviz_helper.app.get_viewer('imviz-0') - assert len(viewer.state.wcs_only_layers) == 1 - - # load a second WCS-only layer: - ndd2 = wcs_utils._get_rotated_nddata_from_label( - app=imviz_helper.app, - data_label=imviz_helper.app.data_collection[0].label, - rotation_angle=45*u.deg - ) - imviz_helper.load_data(ndd2) - assert len(imviz_helper.app.state.layer_icons) == 3 - assert len(viewer.state.wcs_only_layers) == 2 - - wcs_only_refdata_icon = 'mdi-compass-outline' - wcs_only_not_refdata_icon = 'mdi-compass-off-outline' - - for i, data in enumerate(imviz_helper.app.data_collection): - viewer = imviz_helper.app.get_viewer("imviz-0") - - if i == 0: - # first entry is image data: - assert imviz_helper.app.state.layer_icons[data.label] == 'a' - assert viewer.state.reference_data.label == data.label - else: - # icon before setting as refdata: - before_icon = imviz_helper.app.state.layer_icons[data.label] - - # set as refdata: - imviz_helper.app._change_reference_data(data.label) - assert viewer.state.reference_data.label == data.label - - # icon after setting as refdata: - after_icon = imviz_helper.app.state.layer_icons[data.label] - - assert before_icon == wcs_only_not_refdata_icon - assert after_icon == wcs_only_refdata_icon +# TODO: Add more tests. +class TestWCSOnly(BaseImviz_WCS_GWCS): + + # TODO: Replace private API calls with more public ones when available. + def test_non_wcs_layer_labels(self): + self.imviz.link_data(link_type="wcs") + assert len(self.imviz.app.data_collection) == 3 + + # Load a WCS-only layer, bypassing normal labeling scheme. + ndd = wcs_utils._get_rotated_nddata_from_label( + app=self.imviz.app, + data_label="fits_wcs[DATA]", + rotation_angle=5 * u.deg + ) + self.imviz.load_data(ndd, data_label=self.imviz.app._wcs_only_label) + assert self.imviz.app.data_collection[3].label == self.imviz.app._wcs_only_label + + # Confirm that all data in collection are labeled. + assert len(self.imviz.app.state.layer_icons) == 4 # 3 + 1 + + # Confirm the WCS-only layer is logged. + assert len(self.viewer.state.wcs_only_layers) == 1 + + # Load a second WCS-only layer. + ndd2 = wcs_utils._get_rotated_nddata_from_label( + app=self.imviz.app, + data_label="fits_wcs[DATA]", + rotation_angle=45 * u.deg + ) + self.imviz.load_data(ndd2, data_label="rot: 45.00 deg") + assert self.imviz.app.data_collection[4].label == "rot: 45.00 deg" + + # Confirm that all data in collection are labeled. + assert len(self.imviz.app.data_collection) == 5 # 3 + 2 + assert len(self.imviz.app.state.layer_icons) == 5 + + # Confirm the WCS-only layer is logged. + assert len(self.viewer.state.wcs_only_layers) == 2 + + # First entry is image data and the default reference data. + assert self.imviz.app.state.layer_icons["fits_wcs[DATA]"] == "a" + assert self.viewer.state.reference_data.label == "fits_wcs[DATA]" + + wcs_only_refdata_icon = "mdi-compass-outline" + wcs_only_not_refdata_icon = "mdi-compass-off-outline" + + # Now we change the reference data. + for i in (3, 4): + data_label = self.imviz.app.data_collection[i].label + + # Icon before setting this WCS-only data as reference data. + assert self.imviz.app.state.layer_icons[data_label] == wcs_only_not_refdata_icon + + # Set it as reference data. + self.imviz.app._change_reference_data(data_label) + assert self.viewer.state.reference_data.label == data_label + + # Icon after setting it as reference data. + assert self.imviz.app.state.layer_icons[data_label] == wcs_only_refdata_icon + + # Change reference back to normal data. + self.imviz.app._change_reference_data("fits_wcs[DATA]") + assert self.viewer.state.reference_data.label == "fits_wcs[DATA]" + for i in (3, 4): + data_label = self.imviz.app.data_collection[i].label + assert self.imviz.app.state.layer_icons[data_label] == wcs_only_not_refdata_icon + + +def test_get_rotated_nddata_from_label_no_wcs(imviz_helper): + a = np.zeros((2, 2), dtype=np.int8) + imviz_helper.load_data(a, data_label="no_wcs") + with pytest.raises(ValueError, match=r".*has no WCS for rotation"): + wcs_utils._get_rotated_nddata_from_label(imviz_helper.app, "no_wcs", 0 * u.deg) diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index 64acfa684b..d791bdc602 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -3,7 +3,6 @@ # """This module handles calculations based on world coordinate system (WCS).""" -import warnings import base64 import math from io import BytesIO @@ -13,11 +12,9 @@ from astropy import units as u from astropy import coordinates as coord from astropy.coordinates import SkyCoord -from astropy.io import fits from astropy.modeling import models -from astropy.nddata import NDDataArray +from astropy.nddata import NDData from astropy.wcs.utils import proj_plane_pixel_scales -from astropy.wcs import WCS as FitsWCS, FITSFixedWarning from gwcs import coordinate_frames as cf from gwcs.wcs import WCS as GWCS @@ -288,18 +285,7 @@ def data_outside_gwcs_bounding_box(data, x, y): return outside_bounding_box -def _get_fits_wcs_from_file(filename, ext=None): - header = fits.getheader(filename, ext=ext) - with warnings.catch_warnings(): - # Ignore a warning on using DATE-OBS in place of MJD-OBS - warnings.filterwarnings('ignore', message="'datfix' made the change", - category=FITSFixedWarning) - wcs = FitsWCS(header) - - return wcs - - -def rotated_gwcs( +def _rotated_gwcs( center_world_coord, rotation_angle, pixel_scales, @@ -317,6 +303,7 @@ def rotated_gwcs( # "rescale" the pixel scales. Scaling constant was tuned so that the # synthetic image is about the same size on the sky as the input image + # for some arbitrary test data. If need re-tuning, ask Brett Morris. rescale_pixel_scale = np.array(refdata_shape) / 1000 shift_by_crpix = ( @@ -324,7 +311,7 @@ def rotated_gwcs( models.Shift(-refdata_shape[1] / 2 * u.pixel) ) - # multiplying by +/-1 can flip north/south or east/west + # Multiplying by +/-1 can flip north/south or east/west. flip_axes = models.Multiply(cdelt_signs[0]) & models.Multiply(cdelt_signs[1]) rotation = models.AffineTransformation2D( rotation_matrix * u.deg, translation=[0, 0] * u.deg @@ -369,16 +356,16 @@ def rotated_gwcs( return GWCS(pipeline) -def _prepare_rotated_nddata(image_data, wcs, rotation_angle, refdata_shape): +def _prepare_rotated_nddata(real_image_shape, wcs, rotation_angle, refdata_shape, + wcs_only_key="_WCS_ONLY"): # get the world coordinates of the central pixel - real_image_shape = np.array(np.shape(image_data)) - central_pixel_coord = real_image_shape / 2 * u.pix + central_pixel_coord = (np.array(real_image_shape) * 0.5) * u.pix central_world_coord = wcs.pixel_to_world(*central_pixel_coord) rotation_angle = coord.Angle(rotation_angle).wrap_at(360 * u.deg) # compute the x/y plate scales from the WCS: pixel_scales = [ - value * unit / u.pix + value * (unit / u.pix) for value, unit in zip( proj_plane_pixel_scales(wcs), wcs.wcs.cunit ) @@ -389,52 +376,23 @@ def _prepare_rotated_nddata(image_data, wcs, rotation_angle, refdata_shape): # create a GWCS centered on ``filename``, # and rotated by ``rotation_angle``: - new_rotated_gwcs = rotated_gwcs( + new_rotated_gwcs = _rotated_gwcs( central_world_coord, rotation_angle, pixel_scales, cdelt_signs ) - # create an all-nan NDDataArray with the rotated GWCS: - ndd = NDDataArray( - data=np.nan * np.ones(refdata_shape), + # create a fake NDData (we use arange so data boundaries show up in Imviz + # if it ever is accidentally exposed) with the rotated GWCS: + ndd = NDData( + data=np.arange(np.prod(refdata_shape), dtype=np.int8).reshape(refdata_shape), wcs=new_rotated_gwcs, + meta={wcs_only_key: True} ) return ndd -def _get_rotated_nddata_from_fits(filename, rotation_angle, refdata_shape=(2, 2), ext=None): +def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shape=(2, 2)): """ - Create a synthetic NDDataArray which stores GWCS that approximate - the FITS WCS in ``filename`` rotated by ``rotation_angle``. - - This method is useful for ensuring that future datasets are loaded - in the correct orientation. - - Parameters - ---------- - filename : path-like, str - FITS file to use as reference - rotation_angle : `~astropy.units.Quantity` - Angle to rotate the image counter-clockwise from its - original orientation - refdata_shape : tuple - Shape of the reference data array - - Returns - ------- - ndd : `~astropy.nddata.NDDataArray` - Data are all NaNs, wcs are rotated. - """ - # get the FITS WCS from the file: - wcs = _get_fits_wcs_from_file(filename, ext=ext) - - return _prepare_rotated_nddata(wcs, rotation_angle, refdata_shape) - - -def _get_rotated_nddata_from_label( - app, data_label, rotation_angle, refdata_shape=(2, 2), main_component_idx=0 -): - """ - Create a synthetic NDDataArray which stores GWCS that approximate + Create a synthetic NDData which stores GWCS that approximate the WCS in the coords attr of the Data object with label ``data_label`` loaded into ``app``. @@ -444,24 +402,27 @@ def _get_rotated_nddata_from_label( Parameters ---------- app : `~jdaviz.Application` - App instance containing ``data_label`` + App instance containing ``data_label``. data_label : str - Data label for the Data to rotate + Data label for the Data to rotate. rotation_angle : `~astropy.units.Quantity` Angle to rotate the image counter-clockwise from its - original orientation + original orientation. refdata_shape : tuple - Shape of the reference data array + Shape of the reference data array. Returns ------- - ndd : `~astropy.nddata.NDDataArray` - Data are all NaNs, wcs are rotated. + ndd : `~astropy.nddata.NDData` + Contains rotated WCS and meaningless data. + + Raises + ------ + ValueError + Data has no WCS. """ - # get the WCS from the Data object's coords attribute: - [data] = [ - data for data in app.data_collection - if data.label == data_label - ] - image = data[data.main_components[main_component_idx]] - return _prepare_rotated_nddata(image, data.coords, rotation_angle, refdata_shape) + data = app.data_collection[data_label] + if data.coords is None: + raise ValueError(f"{data_label} has no WCS for rotation.") + return _prepare_rotated_nddata(data.shape, data.coords, rotation_angle, refdata_shape, + wcs_only_key=app._wcs_only_label) diff --git a/jdaviz/core/events.py b/jdaviz/core/events.py index 774a4a5fb2..68e0e5a091 100644 --- a/jdaviz/core/events.py +++ b/jdaviz/core/events.py @@ -130,14 +130,13 @@ def viewer_id(self): class ChangeRefDataMessage(Message): - def __init__(self, data, viewer, viewer_id=None, old=None, new=None, *args, **kwargs): + def __init__(self, data, viewer, viewer_id=None, old=None, *args, **kwargs): super().__init__(*args, **kwargs) self._data = data self._viewer = viewer self._viewer_id = viewer_id self._old = old - self._new = new @property def data(self): @@ -155,10 +154,6 @@ def viewer_id(self): def old(self): return self._old - @property - def new(self): - return self._new - class SnackbarMessage(Message): def __init__(self, text, color=None, timeout=5000, loading=False, diff --git a/jdaviz/core/freezable_state.py b/jdaviz/core/freezable_state.py index 21a42ecf75..eb6bdf7b38 100644 --- a/jdaviz/core/freezable_state.py +++ b/jdaviz/core/freezable_state.py @@ -56,7 +56,7 @@ class FreezableBqplotImageViewerState(BqplotImageViewerState, FreezableState): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.wcs_only_layers = [] + self.wcs_only_layers = [] # For Imviz rotation use. def reset_limits(self, *event): if self.reference_data is None: # Nothing to do diff --git a/notebooks/concepts/imviz_rotation_by_hidden_layer.ipynb b/notebooks/concepts/imviz_rotation_by_hidden_layer.ipynb new file mode 100644 index 0000000000..a8fa1818c9 --- /dev/null +++ b/notebooks/concepts/imviz_rotation_by_hidden_layer.ipynb @@ -0,0 +1,451 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "dcc794dd", + "metadata": {}, + "source": [ + "This concept notebook is to showcase the underlying machinery that will be used for Imviz image rotation front-end in the future." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3ee8812", + "metadata": {}, + "outputs": [], + "source": [ + "import gwcs\n", + "import numpy as np\n", + "from astropy import units as u\n", + "from astropy.coordinates import ICRS\n", + "from astropy.modeling import models\n", + "from astropy.nddata import NDData\n", + "from astropy.wcs import WCS\n", + "from gwcs import coordinate_frames as cf\n", + "\n", + "from jdaviz import Imviz\n", + "from jdaviz.configs.imviz.wcs_utils import get_compass_info, _get_rotated_nddata_from_label" + ] + }, + { + "cell_type": "markdown", + "id": "f6d2f882", + "metadata": {}, + "source": [ + "These data are from `BaseImviz_WCS_GWCS` test class.\n", + "\n", + "Image without any WCS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12e67f95", + "metadata": {}, + "outputs": [], + "source": [ + "np.random.seed(42)\n", + "arr = np.random.random((10, 8)) # (ny, nx)\n", + "arr[0, 0] = 10 # Bright corner for sanity check" + ] + }, + { + "cell_type": "markdown", + "id": "934e4b33", + "metadata": {}, + "source": [ + "FITS WCS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4a41f91", + "metadata": {}, + "outputs": [], + "source": [ + "w_fits = WCS({'WCSAXES': 2, 'NAXIS1': 8, 'NAXIS2': 10,\n", + " 'CRPIX1': 5.0, 'CRPIX2': 5.0,\n", + " 'PC1_1': -1.14852e-05, 'PC1_2': 7.01477e-06,\n", + " 'PC2_1': 7.75765e-06, 'PC2_2': 1.20927e-05,\n", + " 'CDELT1': 1.0, 'CDELT2': 1.0,\n", + " 'CUNIT1': 'deg', 'CUNIT2': 'deg',\n", + " 'CTYPE1': 'RA---TAN', 'CTYPE2': 'DEC--TAN',\n", + " 'CRVAL1': 3.581704851882, 'CRVAL2': -30.39197867265,\n", + " 'LONPOLE': 180.0, 'LATPOLE': -30.39197867265,\n", + " 'MJDREF': 0.0, 'RADESYS': 'ICRS'})" + ] + }, + { + "cell_type": "markdown", + "id": "b27ab66d", + "metadata": {}, + "source": [ + "GWCS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57e7a476", + "metadata": {}, + "outputs": [], + "source": [ + "shift_by_crpix = models.Shift(-(5 - 1) * u.pix) & models.Shift(-(5 - 1) * u.pix)\n", + "matrix = np.array([[1.290551569736E-05, 5.9525007864732E-06],\n", + " [5.0226382102765E-06, -1.2644844123757E-05]])\n", + "rotation = models.AffineTransformation2D(matrix * u.deg, translation=[0, 0] * u.deg)\n", + "rotation.input_units_equivalencies = {\"x\": u.pixel_scale(1 * (u.deg / u.pix)),\n", + " \"y\": u.pixel_scale(1 * (u.deg / u.pix))}\n", + "rotation.inverse = models.AffineTransformation2D(np.linalg.inv(matrix) * u.pix,\n", + " translation=[0, 0] * u.pix)\n", + "rotation.inverse.input_units_equivalencies = {\"x\": u.pixel_scale(1 * (u.pix / u.deg)),\n", + " \"y\": u.pixel_scale(1 * (u.pix / u.deg))}\n", + "tan = models.Pix2Sky_TAN()\n", + "celestial_rotation = models.RotateNative2Celestial(\n", + " 3.581704851882 * u.deg, -30.39197867265 * u.deg, 180 * u.deg)\n", + "det2sky = shift_by_crpix | rotation | tan | celestial_rotation\n", + "det2sky.name = \"linear_transform\"\n", + "detector_frame = cf.Frame2D(name=\"detector\", axes_names=(\"x\", \"y\"), unit=(u.pix, u.pix))\n", + "sky_frame = cf.CelestialFrame(reference_frame=ICRS(), name='icrs', unit=(u.deg, u.deg))\n", + "pipeline = [(detector_frame, det2sky), (sky_frame, None)]\n", + "w_gwcs = gwcs.WCS(pipeline)\n", + "w_gwcs.bounding_box = ((0, 8), (0, 10)) * u.pix # x, y" + ] + }, + { + "cell_type": "markdown", + "id": "1f7c61f2", + "metadata": {}, + "source": [ + "Load data into Imviz:\n", + "\n", + "1. Data with FITS WCS and unit.\n", + "2. Data with GWCS (rotated w.r.t. FITS WCS) and no unit.\n", + "3. Data without WCS nor unit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4c0ef8db", + "metadata": {}, + "outputs": [], + "source": [ + "imviz = Imviz()\n", + "imviz.load_data(NDData(arr, wcs=w_fits, unit='electron/s'), data_label='fits_wcs')\n", + "imviz.load_data(NDData(arr, wcs=w_gwcs), data_label='gwcs')\n", + "imviz.load_data(arr, data_label='no_wcs')\n", + "imviz.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50abf5e3", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.default_viewer.zoom_level = \"fit\"" + ] + }, + { + "cell_type": "markdown", + "id": "b5790cbc", + "metadata": {}, + "source": [ + "Open up the Compass plugin to see where the celestial axes are, if any.\n", + "\n", + "Let's say we want N-up E-left orientation. We generate a fake data with the desired orientation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59bbfa67", + "metadata": {}, + "outputs": [], + "source": [ + "data = imviz.default_viewer.state.reference_data\n", + "print(data.label)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7ee76cb1", + "metadata": {}, + "outputs": [], + "source": [ + "degn = get_compass_info(data.coords, data.shape)[0] * u.deg\n", + "print(degn)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "609b67ec", + "metadata": {}, + "outputs": [], + "source": [ + "# FIXME: Is degn supposed to be pass-in as-is? How to make it N-up E-left?\n", + "# For the data in this notebook, pixels in viewer is flipped left-right compared\n", + "# to what you would expect from Compass plugin.\n", + "# Maybe we need some hardcoded WCS for N-up E-left/right instead of using _rotated_gwcs()\n", + "fake_ndd_rotated = _get_rotated_nddata_from_label(imviz.app, data.label, degn)" + ] + }, + { + "cell_type": "markdown", + "id": "200b334b", + "metadata": {}, + "source": [ + "Once we have made the Data object with the desired WCS, we can add it to the collection and also the viewer, but do not display it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5bc5add7", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.load_data(fake_ndd_rotated, data_label=imviz.app._wcs_only_label)" + ] + }, + { + "cell_type": "markdown", + "id": "840fb3ff", + "metadata": {}, + "source": [ + "Then, we make this Data object with the desired WCS a reference data. When link type is \"pixels\", you should not see any difference (no-op)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "caca4019", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app._change_reference_data(imviz.app._wcs_only_label)\n", + "print(imviz.default_viewer.state.reference_data.label)" + ] + }, + { + "cell_type": "markdown", + "id": "34bc933b", + "metadata": {}, + "source": [ + "If you want the other data with WCS to follow the orientation of this desired WCS, change the link type to \"wcs\". For data without any WCS, they will appear very weird because they are now linked by pixels to a Data with no real pixels.\n", + "\n", + "Due to the nature of linking, you have to reset the zoom to see the data again.\n", + "\n", + "(Dev note: Attempts to skip over Data without WCS for this step failed.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a5be30d", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "imviz.link_data(link_type=\"wcs\")\n", + "imviz.default_viewer.zoom_level = \"fit\"" + ] + }, + { + "cell_type": "markdown", + "id": "81d75b05", + "metadata": {}, + "source": [ + "You can run the following cell any time to inspect the current state of Imviz linking." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "367a5839", + "metadata": {}, + "outputs": [], + "source": [ + "for elink in imviz.app.data_collection.external_links:\n", + " elink_labels = (elink.data1.label, elink.data2.label)\n", + " print(elink_labels, elink.__class__.__name__, elink.cids1, elink.cids2)" + ] + }, + { + "cell_type": "markdown", + "id": "e86f8c3e", + "metadata": {}, + "source": [ + "Changing back to pixel linking should look as before we link with WCS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d701645", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.link_data(link_type=\"pixels\")\n", + "imviz.default_viewer.zoom_level = \"fit\"" + ] + }, + { + "cell_type": "markdown", + "id": "41075ed2", + "metadata": {}, + "source": [ + "Changing back to WCS linking and switching the reference data back to real data should look as if the fake data with WCS was never there." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59d5550f", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.link_data(link_type=\"wcs\")\n", + "imviz.app._change_reference_data(\"fits_wcs[DATA]\")" + ] + }, + { + "cell_type": "markdown", + "id": "d032b9e4", + "metadata": {}, + "source": [ + "In the multi-viewer case, the second viewer is allowed to have a different reference data than the default viewer.\n", + "\n", + "Additionally, the \"no_wcs\" case acts weird while blinking in the default viewer while \"_WCS_ONLY\" is set as the reference data in the default viewer and things are linked by WCS; i.e., the Compass would show you are seeing \"no_wcs\" but it no longer disappears from view (it disappeared in the single-viewer case above).\n", + "\n", + "(Dev note: Should we add a warning against using this kind of rotation with data without WCS and/or mult-viewer setup?)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f1095d82", + "metadata": {}, + "outputs": [], + "source": [ + "viewer_1 = imviz.create_image_viewer()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13dd91bf", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app.add_data_to_viewer(\"imviz-1\", \"fits_wcs[DATA]\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "908125dd", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app._change_reference_data(imviz.app._wcs_only_label)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e795d62e", + "metadata": {}, + "outputs": [], + "source": [ + "print(imviz.default_viewer.state.reference_data.label)\n", + "print(viewer_1.state.reference_data.label)" + ] + }, + { + "cell_type": "markdown", + "id": "77e1ca05", + "metadata": {}, + "source": [ + "Unlike the demo at https://gist.github.com/bmorris3/ee3af2e096fc869899280d645bb1b914, you would find it impossible to rotate the same data at two different angles at once. The best you could hope for is one viewer rotates the data to the desired WCS and the second viewer shows you the original data orientation. This is because that Gist was loading data at different angles as different data entries, which we would not do in production. (Though maybe this can be disproven as this feature is developed more; not sure.)" + ] + }, + { + "cell_type": "markdown", + "id": "df5e7458", + "metadata": {}, + "source": [ + "(Dev note: To avoid eating up precious links, when user wants a new rotation angle, we will overwrite the WCS in this fake data and re-link, if that is possible.)\n", + "\n", + "(Dev note: After this part, sometimes the viewer display looks very elongated though not always, but the Compass display seems fine. Not sure why.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1387e9d", + "metadata": {}, + "outputs": [], + "source": [ + "fake_ndd_rotated_2 = _get_rotated_nddata_from_label(imviz.app, \"fits_wcs[DATA]\", -90 * u.deg)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a52a1dc", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app.data_collection[imviz.app._wcs_only_label].coords = fake_ndd_rotated_2.wcs\n", + "imviz.link_data(link_type=imviz.app._link_type, wcs_use_affine=imviz.app._wcs_use_affine, error_on_fail=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f2d7671", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.default_viewer.zoom_level = \"fit\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a53d288f", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 3ff8e58b9c93f1c8cffceb06d154270fac448c7a Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Fri, 26 May 2023 15:31:37 -0400 Subject: [PATCH 007/155] click data menu items to set as refdata --- jdaviz/app.py | 39 +++++-------------- jdaviz/app.vue | 1 + jdaviz/components/viewer_data_select.vue | 17 +++++--- jdaviz/components/viewer_data_select_item.vue | 27 +++++++------ jdaviz/configs/imviz/helper.py | 4 ++ jdaviz/container.vue | 2 + 6 files changed, 44 insertions(+), 46 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index 04f8416c30..475972711d 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -475,15 +475,13 @@ def _on_layers_changed(self, msg): if hasattr(msg, 'data'): layer_name = msg.data.label is_wcs_only = msg.data.meta.get(self._wcs_only_label, False) - is_ref_data = getattr(msg._viewer.state.reference_data, 'label', '') == layer_name elif hasattr(msg, 'subset'): layer_name = msg.subset.label - is_wcs_only = is_ref_data = False + is_wcs_only = False else: raise NotImplementedError(f"cannot recognize new layer from {msg}") wcs_only_refdata_icon = 'mdi-compass-outline' - wcs_only_not_refdata_icon = 'mdi-compass-off-outline' n_wcs_layers = ( len([icon.startswith('mdi') for icon in self.state.layer_icons]) if is_wcs_only else 0 @@ -491,8 +489,7 @@ def _on_layers_changed(self, msg): if layer_name not in self.state.layer_icons: if is_wcs_only: self.state.layer_icons = {**self.state.layer_icons, - layer_name: wcs_only_refdata_icon if is_ref_data - else wcs_only_not_refdata_icon} + layer_name: wcs_only_refdata_icon} else: self.state.layer_icons = { **self.state.layer_icons, @@ -500,31 +497,7 @@ def _on_layers_changed(self, msg): } def _on_refdata_changed(self, msg): - old_is_wcs_only = msg.old.meta.get(self._wcs_only_label, False) - new_is_wcs_only = msg.data.meta.get(self._wcs_only_label, False) - - wcs_only_refdata_icon = 'mdi-compass-outline' - wcs_only_not_refdata_icon = 'mdi-compass-off-outline' - - def switch_icon(old_icon, new_icon): - if old_icon != new_icon: - return new_icon - return old_icon - - new_layer_icons = {} - for i, (layer_name, layer_icon) in enumerate(self.state.layer_icons.items()): - if layer_name == msg.old.label and old_is_wcs_only: - new_layer_icons[layer_name] = switch_icon( - layer_icon, wcs_only_not_refdata_icon - ) - elif layer_name == msg.data.label and new_is_wcs_only: - new_layer_icons[layer_name] = switch_icon( - layer_icon, wcs_only_refdata_icon - ) - else: - new_layer_icons[layer_name] = layer_icon - - self.state.layer_icons = new_layer_icons + pass def _change_reference_data(self, new_refdata_label): """ @@ -1876,6 +1849,11 @@ def vue_data_item_visibility(self, event): self._get_data_item_by_id(event['item_id'])['name'], visible=event['visible'], replace=event.get('replace', False)) + def vue_change_reference_data(self, event): + self._change_reference_data( + self._get_data_item_by_id(event['item_id'])['name'] + ) + def set_data_visibility(self, viewer_reference, data_label, visible=True, replace=False): """ Set the visibility of the layers corresponding to ``data_label`` in a given viewer. @@ -2219,6 +2197,7 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): 'selected_data_items': {}, # noqa data_id: visibility state (visible, hidden, mixed), READ-ONLY 'visible_layers': {}, # label: {color, label_suffix}, READ-ONLY 'wcs_only_layers': wcs_only_layers, + 'reference_data_label': getattr(viewer.state.reference_data, 'label', None), 'canvas_angle': 0, # canvas rotation clockwise rotation angle in deg 'canvas_flip_horizontal': False, # canvas rotation horizontal flip 'config': self.config, # give viewer access to app config/layout diff --git a/jdaviz/app.vue b/jdaviz/app.vue index 6a1e522a58..41f2638473 100644 --- a/jdaviz/app.vue +++ b/jdaviz/app.vue @@ -94,6 +94,7 @@ @data-item-unload="data_item_unload($event)" @data-item-remove="data_item_remove($event)" @call-viewer-method="call_viewer_method($event)" + @change-reference-data="change_reference_data($event)" > diff --git a/jdaviz/components/viewer_data_select.vue b/jdaviz/components/viewer_data_select.vue index dd8b97cebf..38825479a0 100644 --- a/jdaviz/components/viewer_data_select.vue +++ b/jdaviz/components/viewer_data_select.vue @@ -2,7 +2,7 @@ - @@ -54,6 +53,7 @@ @data-item-visibility="$emit('data-item-visibility', $event)" @data-item-unload="$emit('data-item-unload', $event)" @data-item-remove="$emit('data-item-remove', $event)" + @change-reference-data="$emit('change-reference-data', $event)" > @@ -83,12 +83,11 @@ :n_data_entries="nDataEntries" @data-item-visibility="$emit('data-item-visibility', $event)" @data-item-remove="$emit('data-item-remove', $event)" + @change-reference-data="$emit('change-reference-data', $event)" >
- - @@ -211,8 +210,16 @@ module.exports = { } } } - }, + isRefData() { + return this.$props.item.viewer.reference_data_label === this.$props.item.name + }, + selectRefData() { + this.$emit('change-reference-data', { + id: this.$props.viewer.id, + item_id: this.$props.item.id + }) + } }, computed: { viewerTitleCase() { diff --git a/jdaviz/components/viewer_data_select_item.vue b/jdaviz/components/viewer_data_select_item.vue index c51fd6b1f5..44a20a60ff 100644 --- a/jdaviz/components/viewer_data_select_item.vue +++ b/jdaviz/components/viewer_data_select_item.vue @@ -11,18 +11,10 @@ -
- - - mdi-plus - - -
- + + +
@@ -31,7 +23,11 @@ {{itemNameExtension}} + + {{"*"}} +
+
@@ -74,6 +70,15 @@ module.exports = { visible: prevVisibleState != 'visible' || (!this.multi_select && this.$props.item.type !== 'trace'), replace: !this.multi_select && this.$props.item.type !== 'trace' }) + }, + selectRefData() { + this.$emit('change-reference-data', { + id: this.$props.viewer.id, + item_id: this.$props.item.id + }) + }, + isRefData() { + return this.$props.viewer.reference_data_label == this.$props.item.name } }, computed: { diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 7cabb3fa65..00da457619 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -542,6 +542,10 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u app._link_type = link_type app._wcs_use_affine = wcs_use_affine + viewer_ref = app._jdaviz_helper.default_viewer.reference + viewer_item = app._get_viewer_item(viewer_ref) + + viewer_item['reference_data_label'] = refdata.label if link_plugin is not None: # Only broadcast after success. diff --git a/jdaviz/container.vue b/jdaviz/container.vue index 03171c4813..53d48f7539 100644 --- a/jdaviz/container.vue +++ b/jdaviz/container.vue @@ -15,6 +15,7 @@ @data-item-unload="$emit('data-item-unload', $event)" @data-item-remove="$emit('data-item-remove', $event)" @call-viewer-method="$emit('call-viewer-method', $event)" + @change-reference-data="$emit('change-reference-data', $event)" > From 072b79aa6d1df5eb953e68fe1c624fe4e49b52fd Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 31 May 2023 13:02:40 -0400 Subject: [PATCH 008/155] using badge to note refdata in dropdown --- jdaviz/app.py | 9 ++++--- jdaviz/components/layer_viewer_icon.vue | 17 +++++++++++- jdaviz/components/viewer_data_select_item.vue | 27 ++++++++++--------- jdaviz/configs/imviz/tests/test_wcs_utils.py | 9 +++---- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index 475972711d..1d9f72733c 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -481,9 +481,9 @@ def _on_layers_changed(self, msg): else: raise NotImplementedError(f"cannot recognize new layer from {msg}") - wcs_only_refdata_icon = 'mdi-compass-outline' + wcs_only_refdata_icon = 'mdi-rotate-left' n_wcs_layers = ( - len([icon.startswith('mdi') for icon in self.state.layer_icons]) + len([icon.startswith('mdi-rotate-left') for icon in self.state.layer_icons]) if is_wcs_only else 0 ) if layer_name not in self.state.layer_icons: @@ -2187,6 +2187,9 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): wcs_only_layers = getattr(viewer.state, 'wcs_only_layers', []) + reference_data = getattr(viewer.state, 'reference_data', None) + reference_data_label = getattr(reference_data, 'label', None) + return { 'id': vid, 'name': name or vid, @@ -2197,7 +2200,7 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): 'selected_data_items': {}, # noqa data_id: visibility state (visible, hidden, mixed), READ-ONLY 'visible_layers': {}, # label: {color, label_suffix}, READ-ONLY 'wcs_only_layers': wcs_only_layers, - 'reference_data_label': getattr(viewer.state.reference_data, 'label', None), + 'reference_data_label': reference_data_label, 'canvas_angle': 0, # canvas rotation clockwise rotation angle in deg 'canvas_flip_horizontal': False, # canvas rotation horizontal flip 'config': self.config, # give viewer access to app config/layout diff --git a/jdaviz/components/layer_viewer_icon.vue b/jdaviz/components/layer_viewer_icon.vue index a04190fe1f..764708d9b3 100644 --- a/jdaviz/components/layer_viewer_icon.vue +++ b/jdaviz/components/layer_viewer_icon.vue @@ -1,17 +1,23 @@ diff --git a/jdaviz/components/viewer_data_select_item.vue b/jdaviz/components/viewer_data_select_item.vue index 44a20a60ff..2846601a91 100644 --- a/jdaviz/components/viewer_data_select_item.vue +++ b/jdaviz/components/viewer_data_select_item.vue @@ -11,21 +11,22 @@
+ + + - - - - -
+
{{itemNamePrefix}} {{itemNameExtension}} - - {{"*"}} -
@@ -72,10 +73,12 @@ module.exports = { }) }, selectRefData() { - this.$emit('change-reference-data', { - id: this.$props.viewer.id, - item_id: this.$props.item.id - }) + if (!this.isRefData()) { + this.$emit('change-reference-data', { + id: this.$props.viewer.id, + item_id: this.$props.item.id + }) + } }, isRefData() { return this.$props.viewer.reference_data_label == this.$props.item.name diff --git a/jdaviz/configs/imviz/tests/test_wcs_utils.py b/jdaviz/configs/imviz/tests/test_wcs_utils.py index aa9bd8a3de..4b65358032 100644 --- a/jdaviz/configs/imviz/tests/test_wcs_utils.py +++ b/jdaviz/configs/imviz/tests/test_wcs_utils.py @@ -151,29 +151,28 @@ def test_non_wcs_layer_labels(self): assert self.imviz.app.state.layer_icons["fits_wcs[DATA]"] == "a" assert self.viewer.state.reference_data.label == "fits_wcs[DATA]" - wcs_only_refdata_icon = "mdi-compass-outline" - wcs_only_not_refdata_icon = "mdi-compass-off-outline" + wcs_only_icon = "mdi-rotate-left" # Now we change the reference data. for i in (3, 4): data_label = self.imviz.app.data_collection[i].label # Icon before setting this WCS-only data as reference data. - assert self.imviz.app.state.layer_icons[data_label] == wcs_only_not_refdata_icon + assert self.imviz.app.state.layer_icons[data_label] == wcs_only_icon # Set it as reference data. self.imviz.app._change_reference_data(data_label) assert self.viewer.state.reference_data.label == data_label # Icon after setting it as reference data. - assert self.imviz.app.state.layer_icons[data_label] == wcs_only_refdata_icon + assert self.imviz.app.state.layer_icons[data_label] == wcs_only_icon # Change reference back to normal data. self.imviz.app._change_reference_data("fits_wcs[DATA]") assert self.viewer.state.reference_data.label == "fits_wcs[DATA]" for i in (3, 4): data_label = self.imviz.app.data_collection[i].label - assert self.imviz.app.state.layer_icons[data_label] == wcs_only_not_refdata_icon + assert self.imviz.app.state.layer_icons[data_label] == wcs_only_icon def test_get_rotated_nddata_from_label_no_wcs(imviz_helper): From dfe785315de15b29ef1da073b73f0b1e1f9623b8 Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Tue, 13 Jun 2023 13:08:35 -0400 Subject: [PATCH 009/155] only add refdata tooltip for imviz, remove refdata references in tooltip --- jdaviz/components/viewer_data_select_item.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdaviz/components/viewer_data_select_item.vue b/jdaviz/components/viewer_data_select_item.vue index 2846601a91..857e2e62d1 100644 --- a/jdaviz/components/viewer_data_select_item.vue +++ b/jdaviz/components/viewer_data_select_item.vue @@ -12,7 +12,7 @@
Date: Tue, 13 Jun 2023 13:27:25 -0400 Subject: [PATCH 010/155] tooltip improvements --- jdaviz/components/viewer_data_select_item.vue | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/jdaviz/components/viewer_data_select_item.vue b/jdaviz/components/viewer_data_select_item.vue index 857e2e62d1..b33d115a78 100644 --- a/jdaviz/components/viewer_data_select_item.vue +++ b/jdaviz/components/viewer_data_select_item.vue @@ -12,7 +12,7 @@ Date: Tue, 13 Jun 2023 14:59:33 -0400 Subject: [PATCH 011/155] only show pointer cursor in imviz --- jdaviz/components/viewer_data_select_item.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdaviz/components/viewer_data_select_item.vue b/jdaviz/components/viewer_data_select_item.vue index b33d115a78..50c106fb7f 100644 --- a/jdaviz/components/viewer_data_select_item.vue +++ b/jdaviz/components/viewer_data_select_item.vue @@ -15,7 +15,7 @@ :tooltipcontent=dataMenuTooltip span_style="font-size: 12pt; padding-top: 6px; padding-left: 4px; padding-right: 16px; width: calc(100% - 80px); white-space: nowrap; cursor: default;"> From 5ac72e8b880881029909628045314e805b90f688 Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Fri, 23 Jun 2023 13:16:40 -0400 Subject: [PATCH 012/155] working towards default wcs base layer --- jdaviz/app.py | 11 +++--- jdaviz/components/plugin_dataset_select.vue | 10 ++++- jdaviz/configs/imviz/plugins/viewers.py | 6 ++- jdaviz/configs/imviz/wcs_utils.py | 41 ++++++++++++++++----- 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index 1d9f72733c..5cb4493500 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -517,6 +517,12 @@ def _change_reference_data(self, new_refdata_label): new_refdata = self.data_collection[new_refdata_label] viewer.state.reference_data = new_refdata + + # Re-link + self._jdaviz_helper.link_data(link_type=self._link_type, + wcs_use_affine=self._wcs_use_affine, + error_on_fail=True) + self.hub.broadcast(ChangeRefDataMessage( new_refdata, viewer, @@ -524,11 +530,6 @@ def _change_reference_data(self, new_refdata_label): old=old_refdata, sender=self)) - # Re-link - self._jdaviz_helper.link_data(link_type=self._link_type, - wcs_use_affine=self._wcs_use_affine, - error_on_fail=True) - viewer.state.reset_limits() def _link_new_data(self, reference_data=None, data_to_be_linked=None): diff --git a/jdaviz/components/plugin_dataset_select.vue b/jdaviz/components/plugin_dataset_select.vue index e13d4fe3aa..470f9fda15 100644 --- a/jdaviz/components/plugin_dataset_select.vue +++ b/jdaviz/components/plugin_dataset_select.vue @@ -20,7 +20,7 @@
- + {{ data.item.label }} @@ -63,7 +63,13 @@ diff --git a/jdaviz/configs/imviz/plugins/viewers.py b/jdaviz/configs/imviz/plugins/viewers.py index 906c7c4c75..1953938c9c 100644 --- a/jdaviz/configs/imviz/plugins/viewers.py +++ b/jdaviz/configs/imviz/plugins/viewers.py @@ -285,11 +285,13 @@ def get_link_type(self, data_label): if data_label == ref_label: return 'self' + if ref_label in self.state.wcs_only_layers: + return 'wcs' + link_type = None for elink in self.session.application.data_collection.external_links: elink_labels = (elink.data1.label, elink.data2.label) - if (data_label in elink_labels and - (ref_label in elink_labels or ref_label == self.jdaviz_app._wcs_only_label)): + if data_label in elink_labels and ref_label in elink_labels: if isinstance(elink, LinkSame): # Assumes WCS link never uses LinkSame link_type = 'pixels' else: # If not pixels, must be WCS diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index d791bdc602..4830ea07cb 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -357,22 +357,41 @@ def _rotated_gwcs( def _prepare_rotated_nddata(real_image_shape, wcs, rotation_angle, refdata_shape, - wcs_only_key="_WCS_ONLY"): + wcs_only_key="_WCS_ONLY", data=None, + cdelt_signs=None): # get the world coordinates of the central pixel central_pixel_coord = (np.array(real_image_shape) * 0.5) * u.pix central_world_coord = wcs.pixel_to_world(*central_pixel_coord) rotation_angle = coord.Angle(rotation_angle).wrap_at(360 * u.deg) # compute the x/y plate scales from the WCS: - pixel_scales = [ - value * (unit / u.pix) - for value, unit in zip( - proj_plane_pixel_scales(wcs), wcs.wcs.cunit - ) - ] + if hasattr(wcs, 'pixel_scale_matrix'): + pixel_scales = [ + value * (unit / u.pix) + for value, unit in zip( + proj_plane_pixel_scales(wcs), wcs.wcs.cunit + ) + ] + cdelt = wcs.wcs.cdelt + else: + # GWCS doesn't yet have a pixel scale attr, so approximate + # its behavior from the WCS keys in the header: + cdelt = [ + v for k, v in data.meta['wcsinfo'].items() + if k.lower().startswith('cdelt') + ] + pc = [ + v for k, v in data.meta['wcsinfo'].items() + if k.lower().startswith('pc') + ] + n = int(len(pc) ** 0.5) + pc = np.reshape(pc, (n, n)) + + pixel_scales = np.dot(cdelt, pc) * u.deg / u.pix # flip e.g. RA or Dec axes? - cdelt_signs = np.sign(wcs.wcs.cdelt) + if cdelt_signs is None: + cdelt_signs = np.sign(cdelt) # create a GWCS centered on ``filename``, # and rotated by ``rotation_angle``: @@ -390,7 +409,8 @@ def _prepare_rotated_nddata(real_image_shape, wcs, rotation_angle, refdata_shape return ndd -def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shape=(2, 2)): +def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shape=(2, 2), + cdelt_signs=None): """ Create a synthetic NDData which stores GWCS that approximate the WCS in the coords attr of the Data object with label ``data_label`` @@ -425,4 +445,5 @@ def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shap if data.coords is None: raise ValueError(f"{data_label} has no WCS for rotation.") return _prepare_rotated_nddata(data.shape, data.coords, rotation_angle, refdata_shape, - wcs_only_key=app._wcs_only_label) + wcs_only_key=app._wcs_only_label, data=data, + cdelt_signs=cdelt_signs) From c11c03b12f5f1308e906f2307d8f67f3f0f98cb5 Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 28 Jun 2023 14:55:53 -0400 Subject: [PATCH 013/155] wcs linking requires wcs-only base layer --- jdaviz/app.py | 27 +++- jdaviz/components/viewer_data_select_item.vue | 4 +- jdaviz/configs/imviz/helper.py | 29 +++- jdaviz/configs/imviz/plugins/viewers.py | 28 ++++ jdaviz/configs/imviz/wcs_utils.py | 126 ++++++++++++------ 5 files changed, 165 insertions(+), 49 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index 5cb4493500..b110fa459d 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -511,17 +511,24 @@ def _change_reference_data(self, new_refdata_label): viewer = self._jdaviz_helper.default_viewer old_refdata = viewer.state.reference_data + # locate the central coordinate of old refdata in this viewer: + sky_cen = viewer._get_center_skycoord(old_refdata) + + # estimate FOV in the viewer with old reference data: + fov_sky_init = viewer._get_fov(old_refdata) + if new_refdata_label == old_refdata.label: # if there's no refdata change, don't do anything: return + # set the new reference data in the viewer: new_refdata = self.data_collection[new_refdata_label] viewer.state.reference_data = new_refdata - # Re-link - self._jdaviz_helper.link_data(link_type=self._link_type, - wcs_use_affine=self._wcs_use_affine, - error_on_fail=True) + # also update the viewer item's reference data label: + viewer_ref = self._jdaviz_helper.default_viewer.reference + viewer_item = self._get_viewer_item(viewer_ref) + viewer_item['reference_data_label'] = new_refdata.label self.hub.broadcast(ChangeRefDataMessage( new_refdata, @@ -530,7 +537,17 @@ def _change_reference_data(self, new_refdata_label): old=old_refdata, sender=self)) - viewer.state.reset_limits() + if all('_WCS_ONLY' in refdata.meta for refdata in [old_refdata, new_refdata]): + # adjust zoom to account for new refdata if both the + # old and new refdata are WCS-only layers + # (which also ensures zoom_level is already determined): + fov_sky_final = viewer._get_fov(new_refdata) + viewer.zoom( + float(fov_sky_final / fov_sky_init) + ) + + # re-center the viewer on previous location + viewer.center_on(sky_cen) def _link_new_data(self, reference_data=None, data_to_be_linked=None): """ diff --git a/jdaviz/components/viewer_data_select_item.vue b/jdaviz/components/viewer_data_select_item.vue index 50c106fb7f..10f0dc9c96 100644 --- a/jdaviz/components/viewer_data_select_item.vue +++ b/jdaviz/components/viewer_data_select_item.vue @@ -73,7 +73,7 @@ module.exports = { }) }, selectRefData() { - if (!this.isRefData()) { + if (!this.isRefData() && this.$props.item.type === 'wcs-only') { this.$emit('change-reference-data', { id: this.$props.viewer.id, item_id: this.$props.item.id @@ -165,7 +165,7 @@ module.exports = { dataMenuTooltip() { if (this.$props.viewer.config === 'imviz' && this.isRefData()) { return 'Current viewer orientation' - } else if (this.$props.viewer.config === 'imviz') { + } else if (this.$props.viewer.config === 'imviz' && this.$props.item.type === 'wcs-only') { return 'Set viewer orientation' } else { return null diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 00da457619..a01f7dccf7 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -4,15 +4,21 @@ from copy import deepcopy import numpy as np +import astropy.units as u from glue.core import BaseData from glue.core.link_helpers import LinkSame from glue.plugins.wcs_autolinking.wcs_autolinking import WCSLink, NoAffineApproximation from jdaviz.core.events import SnackbarMessage, NewViewerMessage, LinkUpdatedMessage from jdaviz.core.helpers import ImageConfigHelper +from jdaviz.configs.imviz.wcs_utils import ( + _get_rotated_nddata_from_label, get_compass_info +) __all__ = ['Imviz', 'link_image_data'] +base_wcs_layer_label = 'Default orientation' + class Imviz(ImageConfigHelper): """Imviz Helper class.""" @@ -402,7 +408,6 @@ def get_reference_image_data(app): def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_use_affine=True, error_on_fail=False, update_plugin=True): """(Re)link loaded data in Imviz with the desired link type. - All existing links will be replaced. .. note:: @@ -477,6 +482,25 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u f" Clear markers with viewer.reset_markers() first") refdata, iref = get_reference_image_data(app) + + # if linking via WCS, add WCS-only reference data layer: + insert_base_wcs_layer = ( + link_type == 'wcs' and + base_wcs_layer_label not in [d.label for d in app.data_collection] and + not refdata.meta.get(app._wcs_only_label, False) + ) + + if insert_base_wcs_layer: + degn = get_compass_info(refdata.coords, refdata.shape)[-3] + # Default rotation is the same orientation as the original reference data: + rotation_angle = -degn * u.deg + ndd = _get_rotated_nddata_from_label( + app, refdata.label, rotation_angle + ) + app._jdaviz_helper.load_data(ndd, base_wcs_layer_label) + app._change_reference_data(base_wcs_layer_label) + refdata, iref = get_reference_image_data(app) + links_list = [] ids0 = refdata.pixel_component_ids ndim_range = range(refdata.ndim) @@ -553,6 +577,9 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u wcs_fallback_scheme == 'pixels', wcs_use_affine, sender=app)) + if insert_base_wcs_layer: + app._jdaviz_helper.default_viewer.state.reset_limits() + # reset the progress spinner link_plugin.linking_in_progress = False diff --git a/jdaviz/configs/imviz/plugins/viewers.py b/jdaviz/configs/imviz/plugins/viewers.py index 1953938c9c..0d6a069b9d 100644 --- a/jdaviz/configs/imviz/plugins/viewers.py +++ b/jdaviz/configs/imviz/plugins/viewers.py @@ -1,6 +1,9 @@ import numpy as np +<<<<<<< HEAD from glue.core.link_helpers import LinkSame +======= +>>>>>>> c96059e9 (wcs linking requires wcs-only base layer) import astropy.units as u from astropy.wcs.utils import pixel_to_pixel from astropy.visualization import ImageNormalize, LinearStretch, PercentileInterval @@ -303,6 +306,31 @@ def get_link_type(self, data_label): return link_type + def _get_fov(self, data): + # compute the mean of the height and width of the + # viewer's FOV on ``data`` in world units: + x_corners = [ + self.state.x_min, + self.state.x_max, + self.state.x_min, + self.state.x_max + ] + y_corners = [ + self.state.y_min, + self.state.y_min, + self.state.y_max, + self.state.y_max + ] + + y_corners, x_corners = self._get_real_xy( + data, x_corners, y_corners + )[:2] + sky_corners = data.coords.pixel_to_world(x_corners * u.pix, y_corners * u.pix) + height_sky = abs(sky_corners[0].separation(sky_corners[2])) + width_sky = abs(sky_corners[0].separation(sky_corners[1])) + fov_sky = u.Quantity([height_sky, width_sky]).mean() + return fov_sky + def _get_center_skycoord(self, data=None): if data is None: data = self.state.reference_data diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index 4830ea07cb..5dad4a00fe 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -290,7 +290,8 @@ def _rotated_gwcs( rotation_angle, pixel_scales, cdelt_signs, - refdata_shape=(2, 2) + refdata_shape=(10, 10), + image_shape=None ): # based on ``gwcs_simple_imaging_units`` in gwcs: # https://github.com/spacetelescope/gwcs/blob/ @@ -301,41 +302,34 @@ def _rotated_gwcs( rotation_matrix = np.array([[cos_rho, -sin_rho], [sin_rho, cos_rho]]) - # "rescale" the pixel scales. Scaling constant was tuned so that the - # synthetic image is about the same size on the sky as the input image - # for some arbitrary test data. If need re-tuning, ask Brett Morris. - rescale_pixel_scale = np.array(refdata_shape) / 1000 - - shift_by_crpix = ( - models.Shift(-refdata_shape[0] / 2 * u.pixel) & - models.Shift(-refdata_shape[1] / 2 * u.pixel) - ) + pixel_scale_factor = np.mean(image_shape) / np.mean(refdata_shape) + pixel_scales = u.Quantity(pixel_scales) * pixel_scale_factor # Multiplying by +/-1 can flip north/south or east/west. - flip_axes = models.Multiply(cdelt_signs[0]) & models.Multiply(cdelt_signs[1]) + flip_axes = ( + models.Multiply(cdelt_signs[0]) & + models.Multiply(cdelt_signs[1]) + ) rotation = models.AffineTransformation2D( rotation_matrix * u.deg, translation=[0, 0] * u.deg ) rotation.input_units_equivalencies = { - "x": u.pixel_scale(pixel_scales[0] * rescale_pixel_scale[0]), - "y": u.pixel_scale(pixel_scales[1] * rescale_pixel_scale[1]) + "x": u.pixel_scale(pixel_scales[0]), + "y": u.pixel_scale(pixel_scales[1]) } rotation.inverse = models.AffineTransformation2D( np.linalg.inv(rotation_matrix) * u.pix, translation=[0, 0] * u.pix ) rotation.inverse.input_units_equivalencies = { - "x": u.pixel_scale(1 / (pixel_scales[0] * rescale_pixel_scale[0])), - "y": u.pixel_scale(1 / (pixel_scales[1] * rescale_pixel_scale[1])) + "x": u.pixel_scale(1 / pixel_scales[0]), + "y": u.pixel_scale(1 / pixel_scales[1]) } tan = models.Pix2Sky_TAN() celestial_rotation = models.RotateNative2Celestial( center_world_coord.ra, center_world_coord.dec, 180 * u.deg ) - det2sky = ( - flip_axes | shift_by_crpix | rotation | - tan | celestial_rotation - ) + det2sky = flip_axes | rotation | tan | celestial_rotation det2sky.name = "linear_transform" detector_frame = cf.Frame2D( @@ -360,56 +354,74 @@ def _prepare_rotated_nddata(real_image_shape, wcs, rotation_angle, refdata_shape wcs_only_key="_WCS_ONLY", data=None, cdelt_signs=None): # get the world coordinates of the central pixel - central_pixel_coord = (np.array(real_image_shape) * 0.5) * u.pix - central_world_coord = wcs.pixel_to_world(*central_pixel_coord) + corner_pixel_coord = [0, 0] * u.pix + central_world_coord = wcs.pixel_to_world(*corner_pixel_coord) rotation_angle = coord.Angle(rotation_angle).wrap_at(360 * u.deg) - # compute the x/y plate scales from the WCS: + cdelt = None + # compute the x/y pixel scales from the WCS: if hasattr(wcs, 'pixel_scale_matrix'): - pixel_scales = [ + pixel_scales = u.Quantity([ value * (unit / u.pix) for value, unit in zip( proj_plane_pixel_scales(wcs), wcs.wcs.cunit ) - ] - cdelt = wcs.wcs.cdelt - else: + ]) + if getattr(wcs.wcs, 'cd', None) is not None: + cdelt = np.diag(wcs.wcs.cd) + else: + cdelt = wcs.wcs.cdelt + + elif data.meta.get(wcs_only_key, False): + # WCS-only layers have pixel scales in meta: + pixel_scales = u.Quantity(data.meta['_pixel_scales']) + + elif 'wcsinfo' in data.meta: # GWCS doesn't yet have a pixel scale attr, so approximate # its behavior from the WCS keys in the header: cdelt = [ v for k, v in data.meta['wcsinfo'].items() if k.lower().startswith('cdelt') ] - pc = [ - v for k, v in data.meta['wcsinfo'].items() - if k.lower().startswith('pc') - ] - n = int(len(pc) ** 0.5) - pc = np.reshape(pc, (n, n)) - pixel_scales = np.dot(cdelt, pc) * u.deg / u.pix + pixel_scales = cdelt * u.deg / u.pix # flip e.g. RA or Dec axes? - if cdelt_signs is None: + if cdelt_signs is None and cdelt is not None: cdelt_signs = np.sign(cdelt) + elif cdelt is None: + cdelt_signs = [1, -1] + + # pixel scale in x and y may be different, but here we + # take the mean pixel scale in either dimension and assume + # they're similar, since otherwise the aspect ratio of the + # rotated image will appear distorted. + pixel_scales = [pixel_scales.mean(), pixel_scales.mean()] # create a GWCS centered on ``filename``, # and rotated by ``rotation_angle``: new_rotated_gwcs = _rotated_gwcs( - central_world_coord, rotation_angle, pixel_scales, cdelt_signs + central_world_coord, rotation_angle, + pixel_scales, cdelt_signs, + refdata_shape=refdata_shape, + image_shape=real_image_shape ) # create a fake NDData (we use arange so data boundaries show up in Imviz # if it ever is accidentally exposed) with the rotated GWCS: + sequential_data = np.arange( + np.prod(refdata_shape), dtype=np.int8 + ).reshape(refdata_shape) + ndd = NDData( - data=np.arange(np.prod(refdata_shape), dtype=np.int8).reshape(refdata_shape), + data=sequential_data, wcs=new_rotated_gwcs, - meta={wcs_only_key: True} + meta={wcs_only_key: True, '_pixel_scales': pixel_scales} ) return ndd -def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shape=(2, 2), +def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shape=(10, 10), cdelt_signs=None): """ Create a synthetic NDData which stores GWCS that approximate @@ -444,6 +456,38 @@ def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shap data = app.data_collection[data_label] if data.coords is None: raise ValueError(f"{data_label} has no WCS for rotation.") - return _prepare_rotated_nddata(data.shape, data.coords, rotation_angle, refdata_shape, - wcs_only_key=app._wcs_only_label, data=data, - cdelt_signs=cdelt_signs) + + if cdelt_signs is None: + # check if East is to the left: + viewer = app.get_viewer('imviz-0') + wcs = viewer.state.reference_data.coords + if any(['lon' in name for name in wcs.world_axis_names]): + # works for gwcs: + [lon_axis] = [i for i, axis in enumerate(wcs.world_axis_names) if axis == 'lon'] + else: + # works for FITS WCS: + [lon_axis] = [i for i, axis in enumerate(wcs.wcs.ctype) if axis.startswith('RA')] + offset_coords = [1 if lon_axis == i else 0 for i in range(2)] + origin_coords = [0, 0] + east_left = ( + wcs.pixel_to_world_values(*offset_coords)[0] - + wcs.pixel_to_world_values(*origin_coords)[0] + ) < 0 + + if east_left: + cdelt_signs = None + else: + cdelt_signs = [-1 if lon_axis == i else 1 for i in range(2)] + if 'imviz-compass' in [item['name'] for item in app.state.tray_items]: + compass_plugin = app.get_tray_item_from_name('imviz-compass') + compass_plugin.canvas_flip_horizontal = not compass_plugin.canvas_flip_horizontal + + return _prepare_rotated_nddata( + data.shape, + data.coords, + rotation_angle, + refdata_shape, + wcs_only_key=app._wcs_only_label, + data=data, + cdelt_signs=cdelt_signs + ) From 5b2261c3e7ac37ca220603ce20f28a5dbea8c195 Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Fri, 26 May 2023 15:31:37 -0400 Subject: [PATCH 014/155] click data menu items to set as refdata --- jdaviz/app.py | 6 +++++- jdaviz/components/viewer_data_select_item.vue | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index b110fa459d..a4961e0ff1 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -481,7 +481,11 @@ def _on_layers_changed(self, msg): else: raise NotImplementedError(f"cannot recognize new layer from {msg}") +<<<<<<< HEAD wcs_only_refdata_icon = 'mdi-rotate-left' +======= + wcs_only_refdata_icon = 'mdi-compass-outline' +>>>>>>> 8021b45b (click data menu items to set as refdata) n_wcs_layers = ( len([icon.startswith('mdi-rotate-left') for icon in self.state.layer_icons]) if is_wcs_only else 0 @@ -2218,7 +2222,7 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): 'selected_data_items': {}, # noqa data_id: visibility state (visible, hidden, mixed), READ-ONLY 'visible_layers': {}, # label: {color, label_suffix}, READ-ONLY 'wcs_only_layers': wcs_only_layers, - 'reference_data_label': reference_data_label, + 'reference_data_label': getattr(viewer.state.reference_data, 'label', None), 'canvas_angle': 0, # canvas rotation clockwise rotation angle in deg 'canvas_flip_horizontal': False, # canvas rotation horizontal flip 'config': self.config, # give viewer access to app config/layout diff --git a/jdaviz/components/viewer_data_select_item.vue b/jdaviz/components/viewer_data_select_item.vue index 10f0dc9c96..73b323cfaa 100644 --- a/jdaviz/components/viewer_data_select_item.vue +++ b/jdaviz/components/viewer_data_select_item.vue @@ -27,6 +27,9 @@ {{itemNameExtension}} + + {{"*"}} +
From 4197efcb06278ae10a34f4e067a4b97775da31cd Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Fri, 7 Jul 2023 16:01:52 -0400 Subject: [PATCH 015/155] add data menu orientation options, handle linking changes --- jdaviz/app.py | 5 +- jdaviz/components/layer_viewer_icon.vue | 4 +- jdaviz/components/viewer_data_select.vue | 65 +++++++++++++++---- jdaviz/components/viewer_data_select_item.vue | 21 ++++-- jdaviz/configs/imviz/helper.py | 11 +++- jdaviz/container.vue | 6 +- 6 files changed, 88 insertions(+), 24 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index a4961e0ff1..39e2e253fc 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -2211,6 +2211,7 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): reference_data = getattr(viewer.state, 'reference_data', None) reference_data_label = getattr(reference_data, 'label', None) + linked_by_wcs = getattr(viewer.state, 'linked_by_wcs', False) return { 'id': vid, @@ -2228,7 +2229,9 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): 'config': self.config, # give viewer access to app config/layout 'data_open': False, 'collapse': True, - 'reference': reference} + 'reference': reference, + 'linked_by_wcs': linked_by_wcs, + } def _on_new_viewer(self, msg, vid=None, name=None): """ diff --git a/jdaviz/components/layer_viewer_icon.vue b/jdaviz/components/layer_viewer_icon.vue index 764708d9b3..3422aab0bf 100644 --- a/jdaviz/components/layer_viewer_icon.vue +++ b/jdaviz/components/layer_viewer_icon.vue @@ -17,7 +17,7 @@ diff --git a/jdaviz/components/viewer_data_select_item.vue b/jdaviz/components/viewer_data_select_item.vue index 73b323cfaa..d4c694445d 100644 --- a/jdaviz/components/viewer_data_select_item.vue +++ b/jdaviz/components/viewer_data_select_item.vue @@ -18,8 +18,7 @@ :style="dataMenuTooltip !== null ? 'cursor: pointer;' : 'cursor: default;'" @click="selectRefData" > - - +
{{itemNamePrefix}} @@ -76,7 +75,7 @@ module.exports = { }) }, selectRefData() { - if (!this.isRefData() && this.$props.item.type === 'wcs-only') { + if (this.linkedByWcs() && !this.isRefData() && this.isWCSOnly()) { this.$emit('change-reference-data', { id: this.$props.viewer.id, item_id: this.$props.item.id @@ -85,6 +84,12 @@ module.exports = { }, isRefData() { return this.$props.viewer.reference_data_label == this.$props.item.name + }, + linkedByWcs() { + return this.$props.viewer.linked_by_wcs + }, + isWCSOnly() { + return this.$props.item.type === 'wcs-only' } }, computed: { @@ -99,7 +104,7 @@ module.exports = { itemNameExtension() { if (this.$props.item.name.indexOf("[") !== -1) { // return the LAST [ and everything FOLLOWING - return '['+this.$props.item.name.split('[').slice(-1) + return '[' + this.$props.item.name.split('[').slice(-1) } else { return '' } @@ -132,7 +137,11 @@ module.exports = { } else if (this.$props.viewer.reference === 'mask-viewer') { return ['MASK', 'DQ'].indexOf(extension) !== -1 } else if (this.$props.viewer.reference === 'spectrum-viewer') { +<<<<<<< HEAD return ['SCI', 'FLUX'].indexOf(extension) !== -1 +======= + return this.$props.item.name.indexOf('[FLUX]') === -1 +>>>>>>> 0e855fcd (add data menu orientation options, handle linking changes) } } else if (this.$props.viewer.config === 'specviz2d') { if (this.$props.viewer.reference === 'spectrum-2d-viewer') { @@ -166,9 +175,9 @@ module.exports = { } }, dataMenuTooltip() { - if (this.$props.viewer.config === 'imviz' && this.isRefData()) { + if (this.linkedByWcs() && this.$props.viewer.config === 'imviz' && this.isRefData()) { return 'Current viewer orientation' - } else if (this.$props.viewer.config === 'imviz' && this.$props.item.type === 'wcs-only') { + } else if (this.linkedByWcs() && this.$props.viewer.config === 'imviz' && this.$props.item.type === 'wcs-only') { return 'Set viewer orientation' } else { return null diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index a01f7dccf7..ee0dce32ea 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -481,6 +481,7 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u f"'{link_type}') when markers are present. " f" Clear markers with viewer.reset_markers() first") + old_link_type = getattr(app, '_link_type', None) refdata, iref = get_reference_image_data(app) # if linking via WCS, add WCS-only reference data layer: @@ -584,5 +585,13 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u link_plugin.linking_in_progress = False for viewer in app._viewer_store.values(): + wcs_linked = link_type == 'wcs' # viewer-state needs to know link type for reset_limits behavior - viewer.state.linked_by_wcs = link_type == 'wcs' + viewer.state.linked_by_wcs = wcs_linked + # also need to store a copy in the viewer item for the data dropdown to access + viewer_item['linked_by_wcs'] = wcs_linked + + # if changing from one link type to another, reset the limits: + if old_link_type is not None and link_type != old_link_type: + viewer.state.reset_limits() + diff --git a/jdaviz/container.vue b/jdaviz/container.vue index 53d48f7539..0056126e3a 100644 --- a/jdaviz/container.vue +++ b/jdaviz/container.vue @@ -34,13 +34,13 @@ :app_settings="app_settings" :layer_icons="layer_icons" :icons="icons" + :linked_by_wcs="viewer.linked_by_wcs" @data-item-visibility="$emit('data-item-visibility', $event)" @data-item-unload="$emit('data-item-unload', $event)" @data-item-remove="$emit('data-item-remove', $event)" @change-reference-data="$emit('change-reference-data', $event)" > - @@ -64,14 +64,14 @@
- + {{viewer.reference || viewer.id}}
- + From 9c55f80e96934e50339fba8ee076f8461814b5dd Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Mon, 10 Jul 2023 16:45:50 -0400 Subject: [PATCH 016/155] fixing pixel scale calculation for gwcs w/ Roman --- jdaviz/configs/imviz/wcs_utils.py | 76 +++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index 5dad4a00fe..74a9403f5b 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -290,7 +290,8 @@ def _rotated_gwcs( rotation_angle, pixel_scales, cdelt_signs, - refdata_shape=(10, 10), + #refdata_shape=(10, 10), + refdata_shape=(2, 2), image_shape=None ): # based on ``gwcs_simple_imaging_units`` in gwcs: @@ -378,13 +379,13 @@ def _prepare_rotated_nddata(real_image_shape, wcs, rotation_angle, refdata_shape elif 'wcsinfo' in data.meta: # GWCS doesn't yet have a pixel scale attr, so approximate - # its behavior from the WCS keys in the header: - cdelt = [ - v for k, v in data.meta['wcsinfo'].items() - if k.lower().startswith('cdelt') - ] - - pixel_scales = cdelt * u.deg / u.pix + # its behavior using the pixel scale method from jwst: + pixel_scales = (2 * [compute_scale( + data.meta['wcs'], + (data.meta['wcsinfo']['ra_ref'], + data.meta['wcsinfo']['dec_ref']), + 1 + )]) * u.deg / u.pix # flip e.g. RA or Dec axes? if cdelt_signs is None and cdelt is not None: @@ -475,7 +476,7 @@ def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shap ) < 0 if east_left: - cdelt_signs = None + cdelt_signs = [1, 1] else: cdelt_signs = [-1 if lon_axis == i else 1 for i in range(2)] if 'imviz-compass' in [item['name'] for item in app.state.tray_items]: @@ -491,3 +492,60 @@ def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shap data=data, cdelt_signs=cdelt_signs ) + + +def compute_scale(wcs, fiducial, + disp_axis, pscale_ratio): + """ + Compute scaling transform. This method comes from the `jwst` package: + https://github.com/spacetelescope/jwst/blob/ + 95467186aca9784ece9451b33d437d80d550a795/jwst/assign_wcs/util.py#L103 + + Parameters + ---------- + wcs : `~gwcs.wcs.WCS` + Reference WCS object from which to compute a scaling factor. + + fiducial : tuple + Input fiducial of (RA, DEC) or (RA, DEC, Wavelength) used in calculating reference points. + + disp_axis : int + Dispersion axis integer. Assumes the same convention as `wcsinfo.dispersion_direction` + + pscale_ratio : int + Ratio of input to output pixel scale + + Returns + ------- + scale : float + Scaling factor for x and y or cross-dispersion direction. + + """ + spectral = 'SPECTRAL' in wcs.output_frame.axes_type + + if spectral and disp_axis is None: + raise ValueError('If input WCS is spectral, a disp_axis must be given') + + crpix = np.array(wcs.invert(*fiducial)) + + delta = np.zeros_like(crpix) + spatial_idx = np.where(np.array(wcs.output_frame.axes_type) == 'SPATIAL')[0] + delta[spatial_idx[0]] = 1 + + crpix_with_offsets = np.vstack((crpix, crpix + delta, crpix + np.roll(delta, 1))).T + crval_with_offsets = wcs(*crpix_with_offsets, with_bounding_box=False) + + coords = SkyCoord(ra=crval_with_offsets[spatial_idx[0]], dec=crval_with_offsets[spatial_idx[1]], unit="deg") + xscale = np.abs(coords[0].separation(coords[1]).value) + yscale = np.abs(coords[0].separation(coords[2]).value) + + if pscale_ratio is not None: + xscale *= pscale_ratio + yscale *= pscale_ratio + + if spectral: + # Assuming scale doesn't change with wavelength + # Assuming disp_axis is consistent with DataModel.meta.wcsinfo.dispersion.direction + return yscale if disp_axis == 1 else xscale + + return np.sqrt(xscale * yscale) From 75856187087ea6c786763e948d111dac88759726 Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Tue, 11 Jul 2023 12:51:52 -0400 Subject: [PATCH 017/155] refdata choice unavailable bugfix, better wcs fallbacks --- jdaviz/app.py | 10 +++++++++- jdaviz/configs/imviz/wcs_utils.py | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index 39e2e253fc..ab5e9cb7d9 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -491,6 +491,7 @@ def _on_layers_changed(self, msg): if is_wcs_only else 0 ) if layer_name not in self.state.layer_icons: + print('name', layer_name, 'is wcs', is_wcs_only) if is_wcs_only: self.state.layer_icons = {**self.state.layer_icons, layer_name: wcs_only_refdata_icon} @@ -525,8 +526,15 @@ def _change_reference_data(self, new_refdata_label): # if there's no refdata change, don't do anything: return - # set the new reference data in the viewer: new_refdata = self.data_collection[new_refdata_label] + + # make sure new refdata can be selected: + refdata_choices = [choice.label for choice in viewer.state.ref_data_helper.choices] + if new_refdata_label not in refdata_choices: + viewer.state.ref_data_helper.append_data(new_refdata) + viewer.state.ref_data_helper.refresh() + + # set the new reference data in the viewer: viewer.state.reference_data = new_refdata # also update the viewer item's reference data label: diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index 74a9403f5b..15e3d3e714 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -14,6 +14,7 @@ from astropy.coordinates import SkyCoord from astropy.modeling import models from astropy.nddata import NDData +from astropy.wcs import WCS from astropy.wcs.utils import proj_plane_pixel_scales from gwcs import coordinate_frames as cf @@ -377,7 +378,7 @@ def _prepare_rotated_nddata(real_image_shape, wcs, rotation_angle, refdata_shape # WCS-only layers have pixel scales in meta: pixel_scales = u.Quantity(data.meta['_pixel_scales']) - elif 'wcsinfo' in data.meta: + elif 'wcsinfo' in data.meta and 'wcs' in data.meta and 'ra_ref' in data.meta['wcsinfo']: # GWCS doesn't yet have a pixel scale attr, so approximate # its behavior using the pixel scale method from jwst: pixel_scales = (2 * [compute_scale( @@ -386,6 +387,22 @@ def _prepare_rotated_nddata(real_image_shape, wcs, rotation_angle, refdata_shape data.meta['wcsinfo']['dec_ref']), 1 )]) * u.deg / u.pix + else: + # fall back on CRVAL cards: + wcsinfo = data.meta['wcsinfo'] + crval1 = float(wcsinfo.get('CRVAL1', wcsinfo.get('crval1'))) + crval2 = float(wcsinfo.get('CRVAL2', wcsinfo.get('crval2'))) + cdelt = [ + float(wcsinfo.get('CDELT1', wcsinfo.get('cdelt1'))), + float(wcsinfo.get('CDELT2', wcsinfo.get('cdelt2'))) + ] + unit = u.Unit(wcsinfo.get('CUNIT1', wcsinfo.get('cunit1'))) + fiducial = [crval1, crval2] * unit + pixel_scales = (2 * [compute_scale( + WCS(data.meta['_primary_header']) + if 'wcs' not in data.meta else data.meta['wcs'], + fiducial, None, 1 + )]) * u.deg / u.pix # flip e.g. RA or Dec axes? if cdelt_signs is None and cdelt is not None: From acad2979b7c6e12a4e13d3edcbb3df287a8e9330 Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Tue, 11 Jul 2023 13:55:51 -0400 Subject: [PATCH 018/155] hide wcs-only layers from plugin dataset selectors --- jdaviz/core/template_mixin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index cc04dbf7ec..aced867599 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -1437,8 +1437,12 @@ def _update_layer_items(self, msg={}): # NOTE: _on_layers_changed is passed without a msg object during init # TODO: Handle changes to just one item without recompiling the whole thing manual_items = [{'label': label} for label in self.manual_options] - all_layers = [layer for viewer in self.viewer_objs - for layer in getattr(viewer, 'layers', [])] + all_layers = [ + layer for viewer in self.viewer_objs + for layer in getattr(viewer, 'layers', []) + # don't include WCS-only layers: + if not layer.layer.meta.get('_WCS_ONLY', False) + ] # remove duplicates - we'll loop back through all selected viewers to get a list of colors # and visibilities later within _layer_to_dict layer_labels = [layer.layer.label for layer in all_layers if self.app.state.layer_icons.get(layer.layer.label)] # noqa From a73bf493fb076650985a6a30d5f62aa9f2690821 Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 12 Jul 2023 13:57:51 -0400 Subject: [PATCH 019/155] making refdata selection specific to each viewer --- jdaviz/app.py | 37 +++-- jdaviz/configs/imviz/helper.py | 44 +++++- .../plugins/links_control/links_control.py | 131 ++++++++++++++++-- .../plugins/links_control/links_control.vue | 80 ++++++++--- jdaviz/core/template_mixin.py | 30 +++- 5 files changed, 271 insertions(+), 51 deletions(-) diff --git a/jdaviz/app.py b/jdaviz/app.py index ab5e9cb7d9..f2b4b260ea 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -491,7 +491,6 @@ def _on_layers_changed(self, msg): if is_wcs_only else 0 ) if layer_name not in self.state.layer_icons: - print('name', layer_name, 'is wcs', is_wcs_only) if is_wcs_only: self.state.layer_icons = {**self.state.layer_icons, layer_name: wcs_only_refdata_icon} @@ -504,7 +503,7 @@ def _on_layers_changed(self, msg): def _on_refdata_changed(self, msg): pass - def _change_reference_data(self, new_refdata_label): + def _change_reference_data(self, new_refdata_label, viewer_id=None): """ Change reference data to Data with ``data_label`` """ @@ -512,20 +511,23 @@ def _change_reference_data(self, new_refdata_label): # this method is only meant for Imviz for now return - viewer_id = f'{self.config}-0' # Same as the ID in imviz.destroy_viewer() - viewer = self._jdaviz_helper.default_viewer + if viewer_id is None: + viewer = self._jdaviz_helper.default_viewer + else: + viewer = self.get_viewer(viewer_id) + old_refdata = viewer.state.reference_data + if new_refdata_label == old_refdata.label: + # if there's no refdata change, don't do anything: + return + # locate the central coordinate of old refdata in this viewer: sky_cen = viewer._get_center_skycoord(old_refdata) # estimate FOV in the viewer with old reference data: fov_sky_init = viewer._get_fov(old_refdata) - if new_refdata_label == old_refdata.label: - # if there's no refdata change, don't do anything: - return - new_refdata = self.data_collection[new_refdata_label] # make sure new refdata can be selected: @@ -538,14 +540,14 @@ def _change_reference_data(self, new_refdata_label): viewer.state.reference_data = new_refdata # also update the viewer item's reference data label: - viewer_ref = self._jdaviz_helper.default_viewer.reference + viewer_ref = viewer.reference viewer_item = self._get_viewer_item(viewer_ref) viewer_item['reference_data_label'] = new_refdata.label self.hub.broadcast(ChangeRefDataMessage( new_refdata, viewer, - viewer_id=viewer_id, + viewer_id=viewer.reference, old=old_refdata, sender=self)) @@ -1881,7 +1883,8 @@ def vue_data_item_visibility(self, event): def vue_change_reference_data(self, event): self._change_reference_data( - self._get_data_item_by_id(event['item_id'])['name'] + self._get_data_item_by_id(event['item_id'])['name'], + viewer_id=self._get_viewer_item(event['id'])['name'] ) def set_data_visibility(self, viewer_reference, data_label, visible=True, replace=False): @@ -2241,7 +2244,7 @@ def _create_viewer_item(self, viewer, vid=None, name=None, reference=None): 'linked_by_wcs': linked_by_wcs, } - def _on_new_viewer(self, msg, vid=None, name=None): + def _on_new_viewer(self, msg, vid=None, name=None, add_layers_to_viewer=False): """ Callback for when the `~jdaviz.core.events.NewViewerMessage` message is raised. This method asks the application handler to generate a new @@ -2292,6 +2295,10 @@ def _on_new_viewer(self, msg, vid=None, name=None): viewer=viewer, vid=vid, name=name, reference=name ) + if add_layers_to_viewer: + ref_data = self._jdaviz_helper.default_viewer.state.reference_data + new_viewer_item['reference_data_label'] = ref_data.label + new_stack_item = self._create_stack_item( container='gl-stack', viewers=[new_viewer_item]) @@ -2309,6 +2316,12 @@ def _on_new_viewer(self, msg, vid=None, name=None): # Send out a toast message self.hub.broadcast(ViewerAddedMessage(vid, sender=self)) + if add_layers_to_viewer: + for layer_label in add_layers_to_viewer: + self.add_data_to_viewer(viewer.reference, layer_label) + + viewer.state.reference_data = ref_data + return viewer def load_configuration(self, path=None, config=None): diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index ee0dce32ea..ef4088fec9 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -52,9 +52,14 @@ def create_image_viewer(self, viewer_name=None): # Cannot assign data to real Data because it loads but it will # not update checkbox in Data menu. + + # add WCS-only layers from all viewers into the new viewer + add_layers_to_viewer = get_wcs_only_layer_labels(self.app) + return self.app._on_new_viewer( NewViewerMessage(ImvizImageView, data=None, sender=self.app), - vid=viewer_name, name=viewer_name) + vid=viewer_name, name=viewer_name, + add_layers_to_viewer=add_layers_to_viewer) def destroy_viewer(self, viewer_id): """Destroy a viewer associated with the given ID. @@ -375,6 +380,19 @@ def layer_is_table_data(layer): return isinstance(layer, BaseData) and layer.ndim == 1 +def get_bottom_layer(viewer): + """ + Get the first-loaded image layer in Imviz. + """ + return [lyr.layer for lyr in viewer.layers + if lyr.visible and layer_is_image_data(lyr.layer)][0] + + +def get_wcs_only_layer_labels(app): + return [data.label for data in app.data_collection + if layer_is_wcs_only(data)] + + def get_top_layer_index(viewer): """Get index of the top visible image layer in Imviz. This is because when blinked, first layer might not be top visible layer. @@ -384,11 +402,15 @@ def get_top_layer_index(viewer): if lyr.visible and layer_is_image_data(lyr.layer)][-1] -def get_reference_image_data(app): +def get_reference_image_data(app, viewer_id=None): """ Return the reference data in the first image viewer and its index """ - refdata = app._jdaviz_helper.default_viewer.state.reference_data + if viewer_id is None: + refdata = app._jdaviz_helper.default_viewer.state.reference_data + else: + viewer = app.get_viewer_by_id(viewer_id) + refdata = viewer.state.reference_data if refdata is not None: iref = app.data_collection.index(refdata) @@ -451,7 +473,7 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u Invalid inputs or reference data. """ - if len(app.data_collection) <= 1: # No need to link, we are done. + if len(app.data_collection) <= 1 and link_type != 'wcs': # No need to link, we are done. return if link_type not in ('pixels', 'wcs'): @@ -499,7 +521,13 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u app, refdata.label, rotation_angle ) app._jdaviz_helper.load_data(ndd, base_wcs_layer_label) - app._change_reference_data(base_wcs_layer_label) + + # set base layer to reference data in all viewers: + for viewer_id in app.get_viewer_ids(): + app._change_reference_data( + base_wcs_layer_label, viewer_id=viewer_id + ) + refdata, iref = get_reference_image_data(app) links_list = [] @@ -578,8 +606,12 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u wcs_fallback_scheme == 'pixels', wcs_use_affine, sender=app)) + if insert_base_wcs_layer: - app._jdaviz_helper.default_viewer.state.reset_limits() + # update all viewer items with reference data: + for viewer_id in app.get_viewer_ids(): + viewer_item = app._get_viewer_item(viewer_id) + viewer_item['reference_data_label'] = refdata.label # reset the progress spinner link_plugin.linking_in_progress = False diff --git a/jdaviz/configs/imviz/plugins/links_control/links_control.py b/jdaviz/configs/imviz/plugins/links_control/links_control.py index 4cf953f7eb..9db1527840 100644 --- a/jdaviz/configs/imviz/plugins/links_control/links_control.py +++ b/jdaviz/configs/imviz/plugins/links_control/links_control.py @@ -1,17 +1,23 @@ -from traitlets import List, Unicode, Bool, observe +from traitlets import List, Unicode, Bool, Float, observe from glue.core.message import DataCollectionAddMessage -from jdaviz.configs.imviz.helper import link_image_data -from jdaviz.core.events import LinkUpdatedMessage, ExitBatchLoadMessage, MarkersChangedMessage +import astropy.units as u +from jdaviz.configs.imviz.helper import link_image_data, get_bottom_layer +from jdaviz.configs.imviz.wcs_utils import get_compass_info, _get_rotated_nddata_from_label +from jdaviz.core.events import ( + LinkUpdatedMessage, ExitBatchLoadMessage, MarkersChangedMessage, ChangeRefDataMessage +) from jdaviz.core.registries import tray_registry -from jdaviz.core.template_mixin import PluginTemplateMixin, SelectPluginComponent +from jdaviz.core.template_mixin import PluginTemplateMixin, SelectPluginComponent, LayerSelect, ViewerSelect from jdaviz.core.user_api import PluginUserApi __all__ = ['LinksControl'] +link_type_msg_to_trait = {'pixels': 'Pixels', 'wcs': 'WCS'} -@tray_registry('imviz-links-control', label="Links Control") + +@tray_registry('imviz-links-control', label="Links Control", viewer_requirements="image") class LinksControl(PluginTemplateMixin): """ See the :ref:`Links Control Plugin Documentation ` for more details. @@ -42,6 +48,18 @@ class LinksControl(PluginTemplateMixin): need_clear_markers = Bool(False).tag(sync=True) linking_in_progress = Bool(False).tag(sync=True) + # rotation angle, counterclockwise [degrees] + rotation_angle = Unicode("0").tag(sync=True) + set_on_create = Bool(True).tag(sync=True) + relink = Bool(True).tag(sync=True) + + viewer_items = List().tag(sync=True) + viewer_selected = Unicode().tag(sync=True) + layer_items = List().tag(sync=True) + layer_selected = Unicode().tag(sync=True) + + multiselect = Bool(False).tag(sync=True) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -50,6 +68,10 @@ def __init__(self, *args, **kwargs): selected='link_type_selected', manual_options=['Pixels', 'WCS']) + self.viewer = ViewerSelect(self, 'viewer_items', 'viewer_selected', 'multiselect') + self.layer = LayerSelect(self, 'layer_items', 'layer_selected', 'viewer_selected', + 'multiselect', only_wcs_layers=True) # noqa + self.hub.subscribe(self, LinkUpdatedMessage, handler=self._on_link_updated) @@ -62,12 +84,20 @@ def __init__(self, *args, **kwargs): self.hub.subscribe(self, MarkersChangedMessage, handler=self._on_markers_changed) + self.hub.subscribe(self, ChangeRefDataMessage, + handler=self._on_refdata_change) + @property def user_api(self): - return PluginUserApi(self, expose=('link_type', 'wcs_use_affine')) + return PluginUserApi( + self, + expose=( + 'link_type', 'wcs_use_affine', 'viewer', 'layer' + ) + ) def _on_link_updated(self, msg): - self.link_type.selected = {'pixels': 'Pixels', 'wcs': 'WCS'}[msg.link_type] + self.link_type.selected = link_type_msg_to_trait[msg.link_type] self.linking_in_progress = True self.wcs_use_fallback = msg.wcs_use_fallback self.wcs_use_affine = msg.wcs_use_affine @@ -127,9 +157,94 @@ def _update_link(self, msg={}): self.wcs_use_affine = True self._link_image_data() - self.linking_in_progress = False def vue_reset_markers(self, *args): for viewer in self.app._viewer_store.values(): viewer.reset_markers() + + def _get_wcs_angles(self): + degn, dege, flip = get_compass_info(self.ref_data.coords, self.ref_data.shape)[-3:] + return degn, dege, flip + + @property + def rotation_angle_deg(self): + return float(self.rotation_angle) * u.deg + + def create_new_orientation_from_data(self, data): + # Default rotation is the same orientation as the original reference data: + degn = get_compass_info(data.coords, data.shape)[-3] + ndd = _get_rotated_nddata_from_label( + self.app, data.label, -degn * u.deg + self.rotation_angle_deg + ) + data_label = f'CCW {self.rotation_angle} deg' + self.app._jdaviz_helper.load_data( + ndd, data_label=data_label + ) + if self.relink: + # this will trigger linking by wcs if not already selected: + self.link_type_selected = 'WCS' + + # add orientation layer to all viewers: + self._add_data_to_all_viewers(data_label) + + if self.set_on_create: + # set orientation (reference data layer) to be the new option: + self.app._change_reference_data( + data_label, viewer_id=self.viewer.selected + ) + + def _add_data_to_all_viewers(self, data_label): + for viewer in self.viewer.choices: + self.app.add_data_to_viewer(viewer, data_label) + + def vue_create_new_orientation_from_data(self, *args, **kwargs): + if 'reference_data' not in kwargs: + # if not specified, use first-loaded image layer as the + # default rotation: + viewer = self.app.get_viewer(self.viewer.selected) + reference_data = get_bottom_layer(viewer) + self.create_new_orientation_from_data(reference_data) + + @observe('layer_selected') + def _change_reference_data(self, *args, **kwargs): + if self._refdata_change_available: + self.app._change_reference_data( + self.layer.selected, viewer_id=self.viewer.selected + ) + + def _on_refdata_change(self, msg={}): + # don't select until viewer is available: + if hasattr(self, 'viewer'): + ref_data = self.ref_data + viewer = self.app.get_viewer(self.viewer.selected) + + # don't select until reference data are available: + if ref_data is not None: + self.layer.selected = ref_data.label + link_type = viewer.get_link_type(ref_data.label) + if link_type != 'self': + self.link_type_selected = link_type_msg_to_trait[link_type] + elif not len(viewer.data()): + self.link_type_selected = link_type_msg_to_trait['pixels'] + + @property + def ref_data(self): + return self.app.get_viewer_by_id(self.viewer.selected).state.reference_data + + @property + def _refdata_change_available(self): + viewer = self.app.get_viewer(self.viewer.selected) + ref_data = self.ref_data + return ( + ref_data is not None and len(viewer.data()) and + len(self.layer.selected) and len(self.viewer.selected) + ) + + @observe('viewer_selected') + def _on_viewer_change(self, msg={}): + # don't update choices until viewer is available: + if hasattr(self, 'viewer'): + viewer = self.app.get_viewer(self.viewer.selected) + self.layer.choices = viewer.state.wcs_only_layers + self.layer.selected = self.ref_data.label diff --git a/jdaviz/configs/imviz/plugins/links_control/links_control.vue b/jdaviz/configs/imviz/plugins/links_control/links_control.vue index e5dcc29c87..919013256b 100644 --- a/jdaviz/configs/imviz/plugins/links_control/links_control.vue +++ b/jdaviz/configs/imviz/plugins/links_control/links_control.vue @@ -1,26 +1,34 @@ diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index aced867599..52ded7248f 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -1262,7 +1262,9 @@ class LayerSelect(SelectPluginComponent): def __init__(self, plugin, items, selected, viewer, multiselect=None, default_text=None, manual_options=[], - default_mode='first'): + default_mode='first', + include_wcs=False, + only_wcs_layers=False): """ Parameters ---------- @@ -1283,6 +1285,7 @@ def __init__(self, plugin, items, selected, viewer, ``default`` text is provided but not in ``manual_options`` it will still be included as the first item in the list. """ + super().__init__(plugin, items=items, selected=selected, @@ -1292,6 +1295,9 @@ def __init__(self, plugin, items, selected, viewer, manual_options=manual_options, default_mode=default_mode) + self.include_wcs = include_wcs + self.only_wcs_layers = only_wcs_layers + self.hub.subscribe(self, AddDataMessage, handler=self._on_data_added) self.hub.subscribe(self, RemoveDataMessage, @@ -1437,12 +1443,22 @@ def _update_layer_items(self, msg={}): # NOTE: _on_layers_changed is passed without a msg object during init # TODO: Handle changes to just one item without recompiling the whole thing manual_items = [{'label': label} for label in self.manual_options] - all_layers = [ - layer for viewer in self.viewer_objs - for layer in getattr(viewer, 'layers', []) - # don't include WCS-only layers: - if not layer.layer.meta.get('_WCS_ONLY', False) - ] + # use getattr so the super() call above doesn't try to access the attr before + # it is initialized: + if not getattr(self, 'only_wcs_layers', False): + all_layers = [ + layer for viewer in self.viewer_objs + for layer in getattr(viewer, 'layers', []) + # don't include WCS-only layers unless asked: + if not layer.layer.meta.get('_WCS_ONLY', False) or self.include_wcs + ] + else: + all_layers = [ + layer for viewer in self.viewer_objs + for layer in getattr(viewer, 'layers', []) + # only include WCS-only layers: + if layer.layer.meta.get('_WCS_ONLY', False) + ] # remove duplicates - we'll loop back through all selected viewers to get a list of colors # and visibilities later within _layer_to_dict layer_labels = [layer.layer.label for layer in all_layers if self.app.state.layer_icons.get(layer.layer.label)] # noqa From 982c584cc54617d81d50e9af4ca16ee0261b4f5d Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 12 Jul 2023 15:57:15 -0400 Subject: [PATCH 020/155] fix for subset support --- jdaviz/core/template_mixin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index 52ded7248f..27651918af 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -1450,14 +1450,15 @@ def _update_layer_items(self, msg={}): layer for viewer in self.viewer_objs for layer in getattr(viewer, 'layers', []) # don't include WCS-only layers unless asked: - if not layer.layer.meta.get('_WCS_ONLY', False) or self.include_wcs + if not hasattr(layer.layer, 'meta') or + (not layer.layer.meta.get('_WCS_ONLY', False) or self.include_wcs) ] else: all_layers = [ layer for viewer in self.viewer_objs for layer in getattr(viewer, 'layers', []) # only include WCS-only layers: - if layer.layer.meta.get('_WCS_ONLY', False) + if not hasattr(layer.layer, 'meta') or layer.layer.meta.get('_WCS_ONLY', False) ] # remove duplicates - we'll loop back through all selected viewers to get a list of colors # and visibilities later within _layer_to_dict From 25756d14b4b53efd059dc7a2ee77f8808ce2d8ed Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 12 Jul 2023 15:57:38 -0400 Subject: [PATCH 021/155] only match zoom level in viewers with common refdata --- jdaviz/configs/imviz/plugins/tools.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jdaviz/configs/imviz/plugins/tools.py b/jdaviz/configs/imviz/plugins/tools.py index 422eb1c3a7..6710864242 100644 --- a/jdaviz/configs/imviz/plugins/tools.py +++ b/jdaviz/configs/imviz/plugins/tools.py @@ -19,7 +19,11 @@ class _ImvizMatchedZoomMixin(_MatchedZoomMixin): disable_matched_zoom_in_other_viewer = False def _is_matched_viewer(self, viewer): - return isinstance(viewer, BqplotImageView) + # only match zooms in viewers that share reference data + return ( + isinstance(viewer, BqplotImageView) and + viewer.state.reference_data == self.viewer.state.reference_data + ) def _post_activate(self): # NOTE: For Imviz only. From 07fbcf8bdef27da09b5f8cc51db741fbdeea8eba Mon Sep 17 00:00:00 2001 From: "Brett M. Morris" Date: Wed, 12 Jul 2023 16:03:36 -0400 Subject: [PATCH 022/155] reorganizing features in vue for links plugin --- .../plugins/links_control/links_control.vue | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/jdaviz/configs/imviz/plugins/links_control/links_control.vue b/jdaviz/configs/imviz/plugins/links_control/links_control.vue index 919013256b..aa1ec3cd00 100644 --- a/jdaviz/configs/imviz/plugins/links_control/links_control.vue +++ b/jdaviz/configs/imviz/plugins/links_control/links_control.vue @@ -1,6 +1,6 @@