diff --git a/CHANGES.rst b/CHANGES.rst index 9a8b4d4783..d23f107741 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,7 @@ New Features Cubeviz ^^^^^^^ + - Calculated moments can now be output in velocity units. [#2584, #2588, #2665] - Added functionality to Collapse and Spectral Extraction plugins to save results to FITS file. [#2586] @@ -24,6 +25,9 @@ Cubeviz Imviz ^^^^^ +- There is now option for image rotation in Orientation (was Links Control) plugin. + This feature requires WCS linking. [#2179] + Mosviz ^^^^^^ @@ -48,6 +52,16 @@ Cubeviz Imviz ^^^^^ +- Links Control plugin is now called Orientation. [#2179] + +- Linking by WCS will now always generate a hidden reference data layer + without distortion. As a result, when WCS linked, the first loaded data + is no longer the reference data. Additionally, if data is distorted, + its distortion will show when linked by WCS. If there is also data without WCS, + it can no longer be displayed when WCS linked. [#2179] + +- ``imviz.link_data()`` inputs and behaviors are now consistent with the Orientation plugin. [#2179] + Mosviz ^^^^^^ diff --git a/docs/imviz/displayimages.rst b/docs/imviz/displayimages.rst index 9016028ead..05a4f4dfe9 100644 --- a/docs/imviz/displayimages.rst +++ b/docs/imviz/displayimages.rst @@ -117,7 +117,7 @@ Pan/Zoom and Linked Pan/Zoom Linked Pan/Zoom is an Imviz-specific feature enabled only when there are multiple viewers that allows the user to pan and zoom images in multiple different viewers simultaneously. This works by matching images based on the way they are linked together. Images are linked by pixels on load time, -but you can re-link them via WCS using :ref:`imviz-link-control`. +but you can re-link them via WCS using :ref:`imviz-orientation`. Single-viewer Pan/Zoom is also available and is used in a similar way as in other Jdaviz tools. To access this option when there are multiple viewers, right-click on the @@ -216,7 +216,7 @@ section above in the same way you would with the other subset selection tools. When you have multiple images loaded and linked by WCS -(see :ref:`imviz-link-control`), the region defined is with respect to +(see :ref:`imviz-orientation`), the region defined is with respect to the reference image, which might not be the image you are viewing. .. warning:: diff --git a/docs/imviz/plugins.rst b/docs/imviz/plugins.rst index c4fd9ed45b..2dde99e291 100644 --- a/docs/imviz/plugins.rst +++ b/docs/imviz/plugins.rst @@ -99,13 +99,16 @@ To export the table into the notebook via the API, call :meth:`~jdaviz.core.template_mixin.TableMixin.export_table` (see :ref:`plugin-apis`). -.. _imviz-link-control: +.. _imviz-orientation: -Link Control -============ +Orientation +=========== + +.. note:: -This plugin is used to re-link images by pixels or WCS using -:func:`~jdaviz.configs.imviz.helper.link_image_data`. + This plugin was previous called "Links Control". + +This plugin is used to align image layers by pixels or sky (WCS). All images are automatically linked by pixels on load but you can use it to re-link by pixels or WCS as needed. @@ -115,6 +118,13 @@ performant at the cost of accuracy but should be accurate to within a pixel for most cases. If approximation fails, WCS linking still automatically falls back to full transformation. +Since Jdaviz v3.9, when linking by WCS, a hidden reference data layer +without distortion (labeled "Default orientation") will be created and all the data would be linked to +it instead of the first loaded data. As a result, working in pixel +space when linked by WCS is not recommended. Additionally, any data +with distorted WCS would show as distorted on the display. Furthermore, +any data without WCS can no longer be shown in WCS linking mode. + For the best experience, it is recommended that you decide what kind of link you want and set it at the beginning of your Imviz session, rather than later. @@ -130,6 +140,21 @@ From the API within the Jupyter notebook (if linking by WCS): imviz.link_data(link_type='wcs') +.. _imviz-orientation-rotation: + +Orientation: Image Rotation +=========================== + +When linked by WCS, sky rotation is also possible. You can choose from +presets (N-up, E-left/right) or provide your own sky angle. + +.. warning:: + + Each rotation request created a new reference data layer in the background. + Just as in :ref:`imviz-import-data`, the performance would be impacted by + the number of active rotation layers you have; Only keep the desired rotation layer. + Note that the "default orientation" layer cannot be removed. + .. _imviz-compass: Compass @@ -348,6 +373,11 @@ To import a regions file or object from the API: Canvas Rotation =============== +.. note:: + + This plugin is deprecated in favor of rotation via :ref:`imviz-orientation` and will be removed + in a future release. + The canvas rotation plugin allows rotating and horizontally flipping the image to any arbitrary value by rotating the canvas axes themselves. Note that this does not affect the underlying data, and exporting data to the notebook via the API will therefore not exhibit the same rotation. diff --git a/docs/reference/api_plugins.rst b/docs/reference/api_plugins.rst index ff16486760..f01958cd76 100644 --- a/docs/reference/api_plugins.rst +++ b/docs/reference/api_plugins.rst @@ -66,7 +66,7 @@ Plugins API .. automodapi:: jdaviz.configs.imviz.plugins.line_profile_xy.line_profile_xy :no-inheritance-diagram: -.. automodapi:: jdaviz.configs.imviz.plugins.links_control.links_control +.. automodapi:: jdaviz.configs.imviz.plugins.orientation.orientation :no-inheritance-diagram: .. automodapi:: jdaviz.configs.imviz.plugins.rotate_canvas.rotate_canvas diff --git a/jdaviz/app.py b/jdaviz/app.py index 92b419e3ee..78b642619e 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -1,13 +1,10 @@ +import operator import os import pathlib import re import uuid import warnings -import operator - -from ipywidgets import widget_serialization import ipyvue - from astropy import units as u from astropy.nddata import NDData from astropy.io import fits @@ -15,13 +12,9 @@ from echo import CallbackProperty, DictCallbackProperty, ListCallbackProperty from ipygoldenlayout import GoldenLayout from ipysplitpanes import SplitPanes -from traitlets import Dict, Bool, Unicode, Any -from specutils import Spectrum1D, SpectralRegion import matplotlib.cm as cm import numpy as np - -from glue.config import colormaps -from glue.config import settings as glue_settings +from glue.config import colormaps, settings as glue_settings from glue.core import HubListener from glue.core.link_helpers import LinkSame, LinkSameWithUnits from glue.core.message import (DataCollectionAddMessage, @@ -29,18 +22,22 @@ SubsetCreateMessage, SubsetUpdateMessage, SubsetDeleteMessage) +from glue.core.roi import CircularROI, CircularAnnulusROI, EllipticalROI, RectangularROI from glue.core.state_objects import State from glue.core.subset import (RangeSubsetState, RoiSubsetState, CompositeSubsetState, InvertState) -from glue.core.roi import CircularROI, CircularAnnulusROI, EllipticalROI, RectangularROI from glue.core.units import unit_converter from glue_astronomy.spectral_coordinates import SpectralCoordinates from glue_astronomy.translators.regions import roi_subset_state_to_region from glue_jupyter.app import JupyterApplication -from glue_jupyter.common.toolbar_vuetify import read_icon from glue_jupyter.bqplot.common.tools import TrueCircularROI +from glue_jupyter.common.toolbar_vuetify import read_icon from glue_jupyter.state_traitlets_helpers import GlueState +from ipypopout import PopoutButton from ipyvuetify import VuetifyTemplate +from ipywidgets import widget_serialization +from traitlets import Dict, Bool, Unicode, Any +from specutils import Spectrum1D, SpectralRegion from jdaviz import __version__ from jdaviz import style_registry @@ -49,12 +46,12 @@ SnackbarMessage, RemoveDataMessage, AddDataToViewerMessage, RemoveDataFromViewerMessage, ViewerAddedMessage, ViewerRemovedMessage, - ViewerRenamedMessage) + ViewerRenamedMessage, ChangeRefDataMessage) from jdaviz.core.registries import (tool_registry, tray_registry, viewer_registry, data_parser_registry) from jdaviz.core.tools import ICON_DIR -from jdaviz.utils import SnackbarQueue, alpha_index, MultiMaskSubsetState -from ipypopout import PopoutButton +from jdaviz.utils import (SnackbarQueue, alpha_index, data_has_valid_wcs, layer_is_table_data, + MultiMaskSubsetState) __all__ = ['Application', 'ALL_JDAVIZ_CONFIGS'] @@ -209,7 +206,9 @@ class ApplicationState(State): icons = DictCallbackProperty({ 'radialtocheck': read_icon(os.path.join(ICON_DIR, 'radialtocheck.svg'), 'svg+xml'), - 'checktoradial': read_icon(os.path.join(ICON_DIR, 'checktoradial.svg'), 'svg+xml') + 'checktoradial': read_icon(os.path.join(ICON_DIR, 'checktoradial.svg'), 'svg+xml'), + 'nuer': read_icon(os.path.join(ICON_DIR, 'right-east.svg'), 'svg+xml'), + 'nuel': read_icon(os.path.join(ICON_DIR, 'left-east.svg'), 'svg+xml') }, docstring="Custom application icons") viewer_icons = DictCallbackProperty({}, docstring="Indexed icons (numbers) for viewers across the app") # noqa @@ -306,6 +305,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" + self._link_type = 'pixels' + if self.config == "imviz": + 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. @@ -465,14 +470,101 @@ 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(self._wcs_only_label, False) elif hasattr(msg, 'subset'): layer_name = msg.subset.label + is_wcs_only = False else: raise NotImplementedError(f"cannot recognize new layer from {msg}") + wcs_only_refdata_icon = '' # blank - might be replaced with custom icon in the future + # any changes here should also be manually reflected in orientation.vue + orientation_icons = {'Default orientation': 'mdi-image-outline', + 'North-up, East-left': 'nuel', + 'North-up, East-right': 'nuer'} + 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: orientation_icons.get(layer_name, + wcs_only_refdata_icon)} + else: + self.state.layer_icons = { + **self.state.layer_icons, + layer_name: alpha_index(len([ln for ln, ic in self.state.layer_icons.items() + if not ic.startswith('mdi-')])) + } + + def _change_reference_data(self, new_refdata_label, viewer_id=None): + """ + Change reference data to Data with ``data_label``. + This does not work on data without WCS. + """ + if self.config != 'imviz': + # this method is only meant for Imviz for now + return + + if viewer_id is None: + viewer = self._jdaviz_helper.default_viewer._obj + else: + viewer = self.get_viewer(viewer_id) + + old_refdata = viewer.state.reference_data + + if old_refdata is not None and ((new_refdata_label == old_refdata.label) + or (old_refdata.coords is None)): + # if there's no refdata change nor WCS, don't do anything: + return + + if old_refdata is None: + return + + # locate the central coordinate of old refdata in this viewer: + sky_cen = viewer._get_center_skycoord() + + # estimate FOV in the viewer with old reference data: + fov_sky_init = viewer._get_fov() + + 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: + 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.reference, + old=old_refdata, + sender=self)) + + if ( + all('_WCS_ONLY' in refdata.meta for refdata in [old_refdata, new_refdata]) and + viewer.shape is not None + ): + # 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() + viewer.zoom( + float(fov_sky_final / fov_sky_init) + ) + + # only re-center the viewer if all data layers have WCS: + has_wcs_per_data = [data_has_valid_wcs(d) for d in viewer.data()] + if all(has_wcs_per_data): + # 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): """ @@ -681,6 +773,17 @@ def get_viewer_by_id(self, vid): """ return self._viewer_store.get(vid) + def _get_wcs_from_subset(self, subset_state): + """ Usually WCS is subset.parent.coords, except special cubeviz case.""" + + if self.config == 'cubeviz': + parent_data = subset_state.attributes[0].parent + wcs = parent_data.meta.get("_orig_spatial_wcs", None) + else: + wcs = subset_state.xatt.parent.coords + + return wcs + def get_subsets(self, subset_name=None, spectral_only=False, spatial_only=False, object_only=False, simplify_spectral=True, use_display_units=False, @@ -737,7 +840,6 @@ def get_subsets(self, subset_name=None, spectral_only=False, get_sky_regions=include_sky_region) elif isinstance(subset.subset_state, RoiSubsetState): - subset_region = self._get_roi_subset_definition(subset.subset_state, to_sky=include_sky_region) @@ -869,11 +971,7 @@ def _get_roi_subset_definition(self, subset_state, to_sky=False): wcs = None if to_sky: - if self.config == 'cubeviz': - parent_data = subset_state.attributes[0].parent - wcs = parent_data.meta.get("_orig_spatial_wcs", None) - else: - wcs = subset_state.xatt.parent.coords # imviz, try getting WCS from subset data + wcs = self._get_wcs_from_subset(subset_state) # if no spatial wcs on subset, we have to skip computing sky region for this subset # but want to do so without raising an error (since many subsets could be requested) @@ -1698,7 +1796,7 @@ def _reparent_subsets(self, old_parent, new_parent=None): # Translate bounds through WCS if needed if (self.config == "imviz" and - self._jdaviz_helper.plugins["Links Control"].link_type == "WCS"): + self._jdaviz_helper.plugins["Orientation"].link_type == "WCS"): # Get the correct link to use for translation roi = subset_state.roi if type(roi) in (CircularROI, CircularAnnulusROI, @@ -1792,6 +1890,12 @@ 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'], + viewer_id=self._get_viewer_item(event['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. @@ -1806,7 +1910,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'] @@ -1843,8 +1947,12 @@ def set_data_visibility(self, viewer_reference, data_label, visible=True, replac # set visibility state of all applicable layers for layer in viewer.layers: + layer_is_wcs_only = getattr(layer.layer, 'meta', {}).get(self._wcs_only_label, False) if layer.layer.data.label == data_label: - if visible and not layer.visible: + if layer_is_wcs_only: + layer.visible = False + layer.update() + elif visible and not layer.visible: layer.visible = True layer.update() else: @@ -1866,6 +1974,15 @@ 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: + layer_is_wcs_only = getattr(layer.layer, 'meta', {}).get(self._wcs_only_label, False) + if layer.layer.data.label == data_label and layer_is_wcs_only: + layer.visible = False + if data_label not in viewer.state.wcs_only_layers: + 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 # active data. viewer_data_labels = [layer.layer.label for layer in viewer.layers] @@ -1899,8 +2016,8 @@ def vue_data_item_remove(self, event): # the reference data (which would leave 0 external_links). if len(self.data_collection) > 1 and len(self.data_collection.external_links) == 0: if self.config == "imviz" and imviz_refdata: - link_type = self._jdaviz_helper.plugins["Links Control"].link_type.selected.lower() - self._jdaviz_helper.link_data(link_type=link_type, error_on_fail=True) + link_type = self._jdaviz_helper.plugins["Orientation"].link_type.selected.lower() + self._jdaviz_helper.link_data(link_type=link_type) # Hack to restore responsiveness to imviz layers for viewer_ref in self.get_viewer_reference_names(): viewer = self.get_viewer(viewer_ref) @@ -1983,11 +2100,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(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 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' @@ -2032,6 +2153,8 @@ def _expose_meta(key): 'locked': False, 'ndims': data.ndim, 'type': typ, + 'has_wcs': data_has_valid_wcs(data), + 'is_astrowidgets_markers_table': (self.config == "imviz") and layer_is_table_data(data), 'meta': {k: v for k, v in data.meta.items() if _expose_meta(k)}, 'children': []} @@ -2110,6 +2233,12 @@ 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', []) + + 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, 'name': name or vid, @@ -2119,14 +2248,18 @@ 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': wcs_only_layers, + '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 'data_open': False, 'collapse': True, - 'reference': reference} + 'reference': reference or name or vid, + '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 @@ -2150,21 +2283,29 @@ def _on_new_viewer(self, msg, vid=None, name=None): viewer : `~glue_jupyter.bqplot.common.BqplotBaseView` The new viewer instance. """ + viewer = self._application_handler.new_data_viewer( msg.cls, data=msg.data, show=False) viewer.figure_widget.layout.height = '100%' + linked_by_wcs = self._link_type == 'wcs' + if hasattr(viewer.state, 'linked_by_wcs'): - links_control_plugin = self._jdaviz_helper.plugins.get('Links Control', None) - if links_control_plugin is not None: - viewer.state.linked_by_wcs = links_control_plugin.link_type.selected == 'WCS' + orientation_plugin = self._jdaviz_helper.plugins.get('Orientation', None) + if orientation_plugin is not None: + linked_by_wcs = orientation_plugin.link_type.selected == 'WCS' elif len(self._viewer_store): # The plugin would only not exist for instances of Imviz where the user has - # intentionally removed the Links Control plugin, but in that case we will + # intentionally removed the Orientation plugin, but in that case we will # adopt "linked_by_wcs" from the first (assuming all are the same) # NOTE: deleting the default viewer is forbidden both by API and UI, but if # for some reason that was the case here, linked_by_wcs will default to False - viewer.state.linked_by_wcs = list(self._viewer_store.values())[0].state.linked_by_wcs # noqa + linked_by_wcs = self._jdaviz_helper.default_viewer._obj.state.linked_by_wcs + viewer.state.linked_by_wcs = linked_by_wcs + + if linked_by_wcs: + from jdaviz.configs.imviz.helper import get_wcs_only_layer_labels + viewer.state.wcs_only_layers = get_wcs_only_layer_labels(self) if msg.x_attr is not None: x = msg.data.id[msg.x_attr] @@ -2177,6 +2318,12 @@ def _on_new_viewer(self, msg, vid=None, name=None): viewer=viewer, vid=vid, name=name, reference=name ) + ref_data = self._jdaviz_helper.default_viewer._obj.state.reference_data + new_viewer_item['reference_data_label'] = getattr(ref_data, 'label', None) + + if hasattr(viewer, 'reference'): + viewer.state.reference_data = ref_data + new_stack_item = self._create_stack_item( container='gl-stack', viewers=[new_viewer_item]) @@ -2191,6 +2338,11 @@ def _on_new_viewer(self, msg, vid=None, name=None): self.session.application.viewers.append(viewer) + if add_layers_to_viewer: + for layer_label in add_layers_to_viewer: + if hasattr(viewer, 'reference'): + self.add_data_to_viewer(viewer.reference, layer_label) + # Send out a toast message self.hub.broadcast(ViewerAddedMessage(vid, sender=self)) diff --git a/jdaviz/app.vue b/jdaviz/app.vue index 6a1e522a58..ec9df5f866 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)" > @@ -112,11 +113,11 @@