Source code for jdaviz.configs.imviz.plugins.orientation.orientation

from astropy import units as u
from astropy.wcs.wcsapi import BaseHighLevelWCS
from glue.core.link_helpers import LinkSame
from glue.core.message import (
    DataCollectionAddMessage, SubsetCreateMessage, SubsetDeleteMessage
)
from glue.core.subset import Subset
from glue.core.subset_group import GroupedSubset
from glue.core.component_link import ComponentLink
from glue.plugins.wcs_autolinking.wcs_autolinking import WCSLink, NoAffineApproximation
from glue.viewers.image.state import ImageSubsetLayerState
from traitlets import List, Unicode, Bool, Dict, observe

from jdaviz.configs.imviz.wcs_utils import (
    get_compass_info, _get_rotated_nddata_from_label
)
from jdaviz.configs.imviz.plugins.viewers import ImvizImageView
from jdaviz.core.custom_traitlets import FloatHandleEmpty
from jdaviz.core.events import (
    ExitBatchLoadMessage, ChangeRefDataMessage,
    AstrowidgetMarkersChangedMessage, MarkersPluginUpdate,
    SnackbarMessage, ViewerAddedMessage, AddDataMessage, LinkUpdatedMessage
)
from jdaviz.core.registries import tray_registry
from jdaviz.core.template_mixin import (
    PluginTemplateMixin, SelectPluginComponent, LayerSelect, ViewerSelectMixin, AutoTextField
)
from jdaviz.core.user_api import PluginUserApi
from jdaviz.utils import (get_wcs_only_layer_labels, get_reference_image_data,
                          layer_is_2d, _wcs_only_label)

__all__ = ['Orientation']

orientation_plugin_label = "Orientation"
base_wcs_layer_label = 'Default orientation'
align_by_msg_to_trait = {'pixels': 'Pixels', 'wcs': 'WCS'}


[docs] @tray_registry('imviz-orientation', label=orientation_plugin_label, category='app:options') class Orientation(PluginTemplateMixin, ViewerSelectMixin): """ See the :ref:`Orientation Plugin Documentation <imviz-orientation>` for more details. .. note:: Changing linking after adding markers via `~jdaviz.core.astrowidgets_api.AstrowidgetsImageViewerMixin.add_markers` is unsupported and will raise an error requiring resetting the markers manually via `~jdaviz.core.astrowidgets_api.AstrowidgetsImageViewerMixin.add_markers` or clicking a button in the plugin first. Only the following attributes and methods are available through the :ref:`public plugin API <plugin-apis>`: * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.show` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.open_in_tray` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.close_in_tray` * ``align_by`` (`~jdaviz.core.template_mixin.SelectPluginComponent`) * ``wcs_fast_approximation`` * :meth:`delete_subsets` * ``viewer`` * ``orientation`` * ``rotation_angle`` * ``east_left`` * :meth:`add_orientation` * :meth:`set_north_up_east_left` * :meth:`set_north_up_east_right` """ template_file = __file__, "orientation.vue" # defined as traitlet in addition to global variable above to # allow access from UI - leave fixed base_wcs_layer_label = Unicode(base_wcs_layer_label).tag(sync=True) align_by_items = List().tag(sync=True) align_by_selected = Unicode().tag(sync=True) wcs_use_fallback = Bool(True).tag(sync=True) wcs_fast_approximation = Bool(True).tag(sync=True) wcs_linking_available = Bool(False).tag(sync=True) need_clear_astrowidget_markers = Bool(False).tag(sync=True) plugin_markers_exist = Bool(False).tag(sync=True) linking_in_progress = Bool(False).tag(sync=True) need_clear_subsets = Bool(False).tag(sync=True) # this `rotation_angle` traitlet contains the contents of # the orientation plugin's `Rotation angle` user input textbox, # it is *not* necessarily the current rotation state of the viewer rotation_angle = FloatHandleEmpty(0).tag(sync=True) east_left = Bool(True).tag(sync=True) # set convention for east left of north icons = Dict().tag(sync=True) orientation_layer_items = List().tag(sync=True) orientation_layer_selected = Unicode().tag(sync=True) new_layer_label = Unicode().tag(sync=True) new_layer_label_default = Unicode().tag(sync=True) new_layer_label_auto = Bool(True).tag(sync=True) multiselect = Bool(False).tag(sync=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # description displayed under plugin title in tray self._plugin_description = 'Rotate image viewer orientation and choose alignment (pixel or sky).' # noqa self.viewer._allow_multiselect = False self.viewer.add_filter('is_imviz_image_viewer', 'reference_has_wcs') self.icons = {k: v for k, v in self.app.state.icons.items()} self.align_by = SelectPluginComponent(self, items='align_by_items', selected='align_by_selected', manual_options=['Pixels', 'WCS']) self.orientation = LayerSelect( self, 'orientation_layer_items', 'orientation_layer_selected', 'viewer_selected', 'multiselect', only_wcs_layers=True ) self.orientation_layer_label = AutoTextField( self, 'new_layer_label', 'new_layer_label_default', 'new_layer_label_auto', None ) self.hub.subscribe(self, DataCollectionAddMessage, handler=self._on_new_app_data) self.hub.subscribe(self, ExitBatchLoadMessage, handler=self._on_new_app_data) self.hub.subscribe(self, AstrowidgetMarkersChangedMessage, handler=self._on_astrowidget_markers_changed) self.hub.subscribe(self, MarkersPluginUpdate, handler=self._on_markers_plugin_update) self.hub.subscribe(self, ChangeRefDataMessage, handler=self._on_refdata_change) self.hub.subscribe(self, SubsetCreateMessage, handler=self._on_subset_change) self.hub.subscribe(self, SubsetDeleteMessage, handler=self._on_subset_change) self.hub.subscribe(self, ViewerAddedMessage, handler=self._on_viewer_added) self.hub.subscribe(self, AddDataMessage, handler=self._on_data_add_to_viewer) self._update_layer_label_default() if self.app.config == 'deconfigged': self.observe_traitlets_for_relevancy(traitlets_to_observe=['viewer_items']) @property def user_api(self): return PluginUserApi( self, expose=( 'align_by', 'wcs_fast_approximation', 'delete_subsets', 'viewer', 'orientation', 'rotation_angle', 'east_left', 'add_orientation', 'set_north_up_east_left', 'set_north_up_east_right' ) ) def _link_image_data(self): if len(self.irrelevant_msg): return self.linking_in_progress = True try: align_by = self.align_by.selected.lower() link_image_data( self.app, align_by=align_by, wcs_fallback_scheme='pixels' if self.wcs_use_fallback else None, wcs_fast_approximation=self.wcs_fast_approximation, error_on_fail=False) except Exception: # pragma: no cover raise else: # Only broadcast after success. self.app.hub.broadcast(LinkUpdatedMessage( align_by, self.wcs_use_fallback, self.wcs_fast_approximation, sender=self.app)) self.orientation._update_items() finally: self.linking_in_progress = False def _check_if_data_with_wcs_exists(self): for data in self.app.data_collection: if hasattr(data.coords, 'pixel_to_world'): self.wcs_linking_available = True return self.wcs_linking_available = False def _on_new_app_data(self, msg): if self.app._jdaviz_helper._in_batch_load > 0: return if isinstance(msg, DataCollectionAddMessage): if msg.data.meta.get('_importer') != 'CatalogImporter': components = [str(comp) for comp in msg.data.main_components] if "ra" in components or "Lon" in components: # linking currently removes any markers, so we want to skip # linking immediately after new markers are added. Check if # data was added by the Catalog importer because these may have # columns called 'ra' or "Lon". Eventually we'll probably # want to support linking WITH markers, # at which point this # if-statement should be removed. return self._link_image_data() self._check_if_data_with_wcs_exists() def _on_astrowidget_markers_changed(self, msg): self.need_clear_astrowidget_markers = msg.has_markers def _on_markers_plugin_update(self, msg): self.plugin_markers_exist = msg.table_length > 0 @observe('align_by_selected', 'wcs_use_fallback', 'wcs_fast_approximation') def _update_link(self, msg={}): """Run link_image_data with the selected parameters.""" if not hasattr(self, 'align_by'): # could happen before plugin is fully initialized return if msg.get('name', None) == 'wcs_fast_approximation' and self.align_by.selected == 'Pixels': # noqa # approximation doesn't apply, avoid updating when not necessary! return if self.linking_in_progress: return self.linking_in_progress = True # Prevent recursion if self.need_clear_subsets: self.linking_in_progress = False raise ValueError("Link type can only be changed after existing subsets " f"are deleted, but {len(self.app.data_collection.subset_groups)} " f"subset(s) still exist. To delete them, you can use " f"`delete_subsets()` from the plugin API.") if self.need_clear_astrowidget_markers: setattr(self, msg.get('name'), msg.get('old')) self.linking_in_progress = False raise ValueError(f"cannot change linking with markers present (value reverted to " f"'{msg.get('old')}'), call viewer.reset_markers()") if self.align_by.selected == 'Pixels': self.wcs_fast_approximation = True self._link_image_data() # NOTE: _link_image_data will reset linking_in_progress to False # load data into the viewer that are now compatible with the # new link type, remove data from the viewer that are now # incompatible: wcs_linked = self.align_by.selected == 'WCS' viewer_selected = self.app.get_viewer(self.viewer.selected) if viewer_selected is None: self.linking_in_progress = False return data_in_viewer = self.app.get_viewer(viewer_selected.reference).data() for data in self.app.data_collection: is_wcs_only = data.meta.get(_wcs_only_label, False) has_wcs = hasattr(data.coords, 'pixel_to_world') if not is_wcs_only: if data in data_in_viewer and wcs_linked and not has_wcs: # data is in viewer but must be removed: self.app.remove_data_from_viewer(viewer_selected.reference, data.label) self.hub.broadcast(SnackbarMessage( f"Data '{data.label}' does not have a valid WCS - removing from viewer.", sender=self, color="warning")) if wcs_linked: self._send_wcs_layers_to_all_viewers() self._update_layer_label_default() # Clear previous zoom limits because they no longer mean anything. for v in self.app._viewer_store.values(): v._prev_limits = None def _on_subset_change(self, msg): self.need_clear_subsets = len(self.app.data_collection.subset_groups) > 0
[docs] def delete_subsets(self): """ Delete all subsets app-wide. Required before changing ``align_by``. """ # subsets will be deleted on changing link type: self.app.delete_subsets()
[docs] def vue_delete_subsets(self, *args): self.delete_subsets()
[docs] def vue_reset_astrowidget_markers(self, *args): for viewer in self.app._viewer_store.values(): viewer.reset_markers()
def _get_wcs_angles(self, first_loaded_image=None): if first_loaded_image is None: first_loaded_image = self.viewer.selected_obj.first_loaded_data if first_loaded_image is None: # These won't end up getting used in this case, but we need an actual number return 0, 0, 0 degn, dege, flip = get_compass_info( first_loaded_image.coords, first_loaded_image.shape )[-3:] return degn, dege, flip
[docs] def rotation_angle_deg(self, rotation_angle=None): if rotation_angle is None: rotation_angle = self.rotation_angle if rotation_angle is not None: if ( (isinstance(rotation_angle, str) and len(rotation_angle)) or isinstance(rotation_angle, (int, float)) ): return float(rotation_angle) * u.deg return 0 * u.deg
[docs] def add_orientation(self, rotation_angle=None, east_left=None, label=None, set_on_create=True, wrt_data=None): """ Add new orientation options. Parameters ---------- rotation_angle : float, optional Desired sky orientation angle in degrees. If `None`, the value will follow ``self.rotation_angle``. If nothing is set anywhere, it defaults to zero degrees. east_left : bool, optional Set to `True` if you want N-up E-left or `False` for N-up E-right. If `None`, the value will follow ``self.east_left``. label : str, optional Data label for this new orientation layer. If `None`, the value will follow ``self.new_layer_label``. set_on_create : bool, optional If `True`, this new orientation layer will become active on creation. Otherwise, it will be created but stay inactive in the background. wrt_data : str, optional Orientation calculations is done with respect to this data WCS. If `None`, it grabs the first loaded data in the selected viewer (may or may not be visible); If no data is loaded in the viewer, nothing will be done. """ self._add_orientation(rotation_angle=rotation_angle, east_left=east_left, label=label, set_on_create=set_on_create, wrt_data=wrt_data)
def _add_orientation(self, rotation_angle=None, east_left=None, label=None, set_on_create=True, wrt_data=None, from_ui=False): if self.align_by_selected != 'WCS': raise ValueError("must be aligned by WCS to add orientation options") if wrt_data is None: # if not specified, use first-loaded image layer as the # default rotation: wrt_data = self.viewer.selected_obj.first_loaded_data if wrt_data is None: # Nothing in viewer msg = "Viewer must have data loaded to add an orientation." if from_ui: self.hub.broadcast(SnackbarMessage(msg, color="error", timeout=6000, sender=self)) else: raise ValueError(msg) return rotation_angle_deg = self.rotation_angle_deg(rotation_angle) if east_left is None: east_left = self.east_left if label is None: label = self.new_layer_label # Default rotation is the same orientation as the original reference data: degn = self._get_wcs_angles(first_loaded_image=wrt_data)[0] if east_left: rotation_angle_deg = -degn * u.deg + rotation_angle_deg else: rotation_angle_deg = (180 - degn) * u.deg - rotation_angle_deg ndd = _get_rotated_nddata_from_label( app=self.app, data_label=wrt_data.label, rotation_angle=rotation_angle_deg, target_wcs_east_left=east_left, target_wcs_north_up=True, ) add_wcs_data_to_app(self.app, ndd, data_label=label) self.orientation._update_items() # add orientation layer to all viewers: for viewer_ref in self.app._viewer_store: self._add_data_to_viewer(label, viewer_ref) if set_on_create: self.rotation_angle = rotation_angle self.orientation.selected = label def _ensure_layer_icon_exists(self, data_label): if data_label in self.app.data_collection and data_label not in self.app.state.layer_icons: # ensure layer icon is created for the orientation layer since we bypassed # the viewer data menu self.app._on_layers_changed(AddDataMessage(self.app.data_collection[data_label], viewer=None, sender=self)) def _add_data_to_viewer(self, data_label, viewer_id): viewer = self.app.get_viewer_by_id(viewer_id) if not isinstance(viewer, ImvizImageView): return if data_label not in viewer.data_menu.orientation.choices: self.app.add_data_to_viewer(viewer_id, data_label) self._ensure_layer_icon_exists(data_label) def _on_viewer_added(self, msg): viewer = self.app.get_viewer_by_id(msg.viewer_id) if not isinstance(viewer, ImvizImageView): return self._send_wcs_layers_to_all_viewers(viewers_to_update=[msg.viewer_id]) @observe('viewer_items') def _send_wcs_layers_to_all_viewers(self, *args, **kwargs): if not hasattr(self, 'viewer') or not getattr(self.app, '_jdaviz_helper', None): return wcs_only_layers = get_wcs_only_layer_labels(self.app) # TODO: update to only image viewers viewers_to_update = kwargs.get( 'viewers_to_update', self.app._viewer_store.keys() ) for viewer_ref in viewers_to_update: viewer_dm = self.app._jdaviz_helper.viewers.get(viewer_ref).data_menu for wcs_layer in wcs_only_layers: if wcs_layer not in self.viewer.selected_obj.layers: self.app.add_data_to_viewer(viewer_ref, wcs_layer) if ( self.orientation.selected not in wcs_only_layers and self.align_by_selected == 'WCS' ): viewer_dm.orientation.selected = base_wcs_layer_label self._ensure_layer_icon_exists(base_wcs_layer_label) def _on_data_add_to_viewer(self, msg): if self.viewer.selected_obj is None: return all_wcs_only_layers = all( layer.layer.meta.get(_wcs_only_label) for layer in self.viewer.selected_obj.layers if hasattr(layer.layer, "meta") ) if all_wcs_only_layers and msg.data.meta.get(_wcs_only_label, False): # on adding first data layer, reset the limits: self.viewer.selected_obj.state.reset_limits()
[docs] def vue_add_orientation(self, *args, **kwargs): self._add_orientation(set_on_create=True, from_ui=True)
[docs] def set_orientation_for_viewer(self, orientation, viewer_id): if self._refdata_change_available: self.app._change_reference_data( orientation, viewer_id=viewer_id ) viewer_item = self.app._viewer_item_by_id(viewer_id) if viewer_item['reference_data_label'] != orientation: viewer_item['reference_data_label'] = orientation
@observe('orientation_layer_selected') def _change_reference_data(self, *args, **kwargs): self.set_orientation_for_viewer(self.orientation.selected, self.viewer.selected) def _on_refdata_change(self, msg): if hasattr(self, 'viewer'): ref_data = self.ref_data viewer = self.viewer.selected_obj # don't select until reference data are available: if ref_data is not None: align_by = viewer.get_alignment_method(ref_data.label) if align_by != 'self': self.align_by_selected = align_by_msg_to_trait[align_by] elif not len(viewer.data()): self.align_by_selected = align_by_msg_to_trait['pixels'] if msg.data.label not in self.orientation.choices: return if msg.viewer_id == self.viewer.selected: self.orientation.selected = msg.data.label # we never want to highlight subsets of pixels within WCS-only layers, # so if this layer is an ImageSubsetLayerState on a WCS-only layer, # ensure that it is never visible: for layer in viewer.state.layers: if ( isinstance(layer.layer, ImageSubsetLayerState) and layer.layer.data.meta.get("_WCS_ONLY", False) ): layer.visible = False @property def ref_data(self): if not hasattr(self, 'viewer'): return None viewer = self.app.get_viewer(self.viewer.selected) if not hasattr(viewer, 'state'): return None 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) selected_layer = [lyr.layer for lyr in viewer.layers if lyr.layer.label == self.orientation.selected] if len(selected_layer): is_subset = isinstance(selected_layer[0], (Subset, GroupedSubset)) else: is_subset = False return ( len(self.orientation.selected) and len(self.viewer.selected) and not is_subset ) @observe('viewer_selected') def _on_viewer_change(self, msg={}): # don't update choices until viewer is available: ref_data = self.ref_data if hasattr(self, 'viewer') and ref_data is not None: if ref_data.label in self.orientation.choices: self.orientation.selected = ref_data.label def _set_north_up_east_left(self, label="North-up, East-left", set_as_orientation=False, from_ui=False): if label not in self.orientation.choices: degn = self._get_wcs_angles()[-3] self._add_orientation(rotation_angle=degn, east_left=True, label=label, set_on_create=set_as_orientation, from_ui=from_ui) elif set_as_orientation: self.orientation.selected = label
[docs] def set_north_up_east_left(self, label="North-up, East-left"): """ Set (and create if necessary) the rotation angle and flip to achieve North up and East left according to the reference image WCS. Parameters ---------- label : str Data label for the orientation layer. If already exists, will be set as the current orientation layer according to ``set_as_orientation``. Otherwise, a new layer will be created with this label. """ self._set_north_up_east_left(label=label, set_as_orientation=True)
def _set_north_up_east_right(self, label="North-up, East-right", set_as_orientation=False, from_ui=False): if label not in self.orientation.choices: degn = self._get_wcs_angles()[-3] self._add_orientation(rotation_angle=180 - degn, east_left=False, label=label, set_on_create=set_as_orientation, from_ui=from_ui) elif set_as_orientation: self.orientation.selected = label
[docs] def set_north_up_east_right(self, label="North-up, East-right"): """ Set (and create, if necessary) the rotation angle and flip to achieve North up and East right according to the reference image WCS. Parameters ---------- label : str Data label for the orientation layer. If already exists, will be set as the current orientation layer according to ``set_as_orientation``. Otherwise, a new layer will be created with this label. """ self._set_north_up_east_right(label=label, set_as_orientation=True)
[docs] def vue_select_north_up_east_left(self, *args, **kwargs): self._set_north_up_east_left(set_as_orientation=True, from_ui=True)
[docs] def vue_select_north_up_east_right(self, *args, **kwargs): self._set_north_up_east_right(set_as_orientation=True, from_ui=True)
[docs] def vue_select_default_orientation(self, *args, **kwargs): self.orientation.selected = base_wcs_layer_label
@observe('east_left', 'rotation_angle') def _update_layer_label_default(self, event={}): self.new_layer_label_default = ( f'CCW {self.rotation_angle_deg():.2f} ' + ('(E-left)' if self.east_left else '(E-right)') )
def add_wcs_data_to_app(app, data, data_label=None): app._jdaviz_helper.load(data, format='Image', data_label=data_label, viewer=[]) # TODO: refactor logic to avoid having to send an AddDataMessage just to update icons # ensure that icons are updated by forcing a call to app._on_layers_changed image_viewer = app.get_viewers_of_cls(ImvizImageView)[0] app.hub.broadcast(AddDataMessage(data=app.data_collection[data_label], viewer=image_viewer, viewer_id=image_viewer.reference, sender=app)) def link_image_data(app, align_by='pixels', wcs_fallback_scheme=None, wcs_fast_approximation=True, error_on_fail=False): """(Re)link loaded data in Imviz with the desired link type. .. note:: Any markers added in Imviz will need to be removed manually before changing linking type. You can add back the markers using :meth:`~jdaviz.core.astrowidgets_api.AstrowidgetsImageViewerMixin.add_markers` for the relevant viewer(s). Parameters ---------- app : `~jdaviz.app.Application` Application associated with Imviz, e.g., ``imviz.app``. align_by : {'pixels', 'wcs'} Choose to link by pixels or WCS. wcs_fallback_scheme : {None, 'pixels'} If WCS linking failed, choose to fall back to linking by pixels or not at all. This is only used when ``align_by='wcs'``. Choosing `None` may result in some Imviz functionality not working properly. wcs_fast_approximation : bool Use an affine transform to represent the offset between images if possible (requires that the approximation is accurate to within 1 pixel with the full WCS transformations). If approximation fails, it will automatically fall back to full WCS transformation. This is only used when ``align_by='wcs'``. Affine approximation is much more performant at the cost of accuracy. error_on_fail : bool If `True`, any failure in linking will raise an exception. If `False`, warnings will be emitted as snackbar messages. When only warnings are emitted and no links are assigned, some Imviz functionality may not work properly. Raises ------ ValueError Invalid inputs or reference data. """ if len(app.data_collection) <= 1 and align_by != 'wcs': # No need to link, we are done. return if align_by not in ('pixels', 'wcs'): # pragma: no cover raise ValueError(f"align_by must be 'pixels' or 'wcs', got {align_by}") if align_by == 'wcs' and wcs_fallback_scheme not in (None, 'pixels'): # pragma: no cover raise ValueError("wcs_fallback_scheme must be None or 'pixels', " f"got {wcs_fallback_scheme}") if align_by == 'wcs': at_least_one_data_have_wcs = len([ hasattr(d, 'coords') and isinstance(d.coords, BaseHighLevelWCS) for d in app.data_collection ]) > 0 if not at_least_one_data_have_wcs: # pragma: no cover if wcs_fallback_scheme is None: if error_on_fail: raise ValueError("align_by can only be 'wcs' when wcs_fallback_scheme " "is 'None' if at least one image has a valid WCS.") else: return else: # fall back on pixel linking align_by = 'pixels' old_align_by = getattr(app, '_align_by', None) # In WCS linking, changing orientation layer is done within Orientation plugin, # so here we assume viewer.state.reference_data is already the desired # orientation by the time this function is called. # # In pixels linking, Affine approximation does not matter. # # data1 = reference, data2 = actual data data_already_linked = [] image_viewers = app.get_viewers_of_cls(ImvizImageView) if not len(image_viewers): return if (align_by == old_align_by and (align_by == "pixels" or wcs_fast_approximation == app._wcs_fast_approximation)): # We are only here to link new data with existing configuration, # so no need to relink existing data. for link in app.data_collection.external_links: if hasattr(link, 'data2'): data_already_linked.append(link.data2) if hasattr(link, 'comp_from'): # for Catalogs, which are linked by ComponentLink data_already_linked.append(link.comp_from.data) else: # pragma: no cover # Everything has to be relinked. for viewer in image_viewers: if len(viewer._marktags): raise ValueError(f"cannot change align_by (from '{app._align_by}' to " f"'{align_by}') when markers are present. " f" Clear markers with viewer.reset_markers() first") # set internal tracking of align_by before changing reference data for anything that is # subscribed to a change in reference data app._align_by = align_by app._wcs_fast_approximation = wcs_fast_approximation # wcs -> pixels: First loaded real data will be reference. if align_by == 'pixels' and old_align_by == 'wcs': # default reference layer is the first-loaded image in default viewer: refdata = image_viewers[0].first_loaded_data if refdata is None: # No data in viewer, just use first in collection # pragma: no cover iref = 0 refdata = app.data_collection[iref] else: iref = app.data_collection.index(refdata) # set default layer to reference data in all viewers: for viewer_id in app.get_viewer_ids(): app._change_reference_data(refdata.label, viewer_id=viewer_id) # pixels -> wcs: Always the default orientation elif align_by == 'wcs' and old_align_by == 'pixels': # Have to create the default orientation first. if base_wcs_layer_label not in app.data_collection.labels: default_reference_layer = (image_viewers[0].first_loaded_data or app.data_collection[0]) degn = get_compass_info(default_reference_layer.coords, default_reference_layer.shape)[-3] # noqa: E501 # Default rotation is the same orientation as the original reference data: rotation_angle = -degn * u.deg ndd = _get_rotated_nddata_from_label(app, default_reference_layer.label, rotation_angle) add_wcs_data_to_app(app, ndd, data_label=base_wcs_layer_label) # set default 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 = app.data_collection[base_wcs_layer_label] iref = app.data_collection.index(refdata) # Re-use current reference data. else: # If 'Default Orientation' is loaded, reference that image if base_wcs_layer_label in app.data_collection: refdata = app.data_collection[base_wcs_layer_label] iref = app.data_collection.index(refdata) # otherwise, re-use the current image. Note: this will degrade performance else: refdata, iref = get_reference_image_data(app) # App just loaded, nothing yet, so take first image. if refdata is None: refdata = image_viewers[0].first_loaded_data if refdata is None: # No data in viewer, just use first in collection iref = 0 refdata = app.data_collection[iref] else: # pragma: no cover iref = app.data_collection.index(refdata) # With reference data changed, if needed, now we relink as needed. links_list = [] ids0 = refdata.pixel_component_ids ndim_range = range(2) # We only support 2D for i, data in enumerate(app.data_collection): # Do not link with self or existing links. if i == iref or data in data_already_linked: continue # Special handling of Catalogs: only link RA/Dec to reference WCS, # and use ComponentLink instead of WCSLink because Catalogs do not # have coords (wcs) attribute. if data.meta.get('_importer') == 'CatalogImporter': comp_labels = [str(x) for x in data.component_ids()] if align_by == 'wcs': ra_col = data.meta.get('_jdaviz_loader_ra_col') dec_col = data.meta.get('_jdaviz_loader_dec_col') if ra_col in comp_labels and dec_col in comp_labels: ref_labels = [str(x) for x in refdata.component_ids()] try: # default orientation will have components named 'Lat' and 'Lon' ref_ra = refdata.components[ref_labels.index('Lon')] ref_dec = refdata.components[ref_labels.index('Lat')] except ValueError: # image data will have these components named differently ref_ra = refdata.components[ref_labels.index('Right Ascension')] ref_dec = refdata.components[ref_labels.index('Declination')] cat_ra = data.components[comp_labels.index(ra_col)] cat_dec = data.components[comp_labels.index(dec_col)] links_list += [ComponentLink([ref_ra], cat_ra), ComponentLink([ref_dec], cat_dec)] continue elif align_by == 'pixels': x_col = data.meta.get('_jdaviz_loader_x_col') y_col = data.meta.get('_jdaviz_loader_y_col') if x_col in comp_labels and y_col in comp_labels: # Image components should always be called 'Pixel Axis 1 [x]' # and 'Pixel Axis 0 [y]' If an error ever arises from trying # to access these directly, generalize it, but this should be safe. ref_labels = [str(x) for x in refdata.component_ids()] ref_x = refdata.components[ref_labels.index('Pixel Axis 1 [x]')] ref_y = refdata.components[ref_labels.index('Pixel Axis 0 [y]')] # source catalogs will always have X/Y components with # these exact labels, so this is safe to do with exact labels cat_x = data.components[comp_labels.index(x_col)] cat_y = data.components[comp_labels.index(y_col)] links_list += [ComponentLink([ref_x], cat_x), ComponentLink([ref_y], cat_y)] continue else: # 1. We are not touching any existing Subsets or Table. They keep their own links. # 2. We are not touching fake WCS layers in pixel linking. # 3. We are not touching data without WCS in WCS linking. if ((not layer_is_2d(data)) or (align_by == "pixels" and data.meta.get(_wcs_only_label)) or (align_by == "wcs" and not hasattr(data.coords, 'pixel_to_world'))): continue ids1 = data.pixel_component_ids new_links = [] try: if align_by == 'pixels': new_links = [LinkSame(ids0[i], ids1[i]) for i in ndim_range] else: # wcs wcslink = WCSLink(data1=refdata, data2=data, cids1=ids0, cids2=ids1) if wcs_fast_approximation: try: new_links = [wcslink.as_affine_link()] except NoAffineApproximation: # pragma: no cover new_links = [wcslink] else: new_links = [wcslink] except Exception as e: # pragma: no cover if align_by == 'wcs' and wcs_fallback_scheme == 'pixels': try: new_links = [LinkSame(ids0[i], ids1[i]) for i in ndim_range] except Exception as e: # pragma: no cover if error_on_fail: raise else: app.hub.broadcast(SnackbarMessage( f"Error linking '{data.label}' to '{refdata.label}': " f"{repr(e)}", color="warning", timeout=8000, sender=app, traceback=e)) continue else: # pragma: no cover if error_on_fail: raise else: app.hub.broadcast(SnackbarMessage( f"Error linking '{data.label}' to '{refdata.label}': " f"{repr(e)}", color="warning", timeout=8000, sender=app)) continue links_list += new_links if len(links_list) > 0: with app.data_collection.delay_link_manager_update(): if len(data_already_linked): app.data_collection.add_link(links_list) # Add to existing else: app.data_collection.set_links(links_list) # Redo all links app.hub.broadcast(SnackbarMessage( 'Images successfully relinked', color='success', timeout=8000, sender=app)) for viewer in image_viewers: wcs_linked = align_by == 'wcs' # viewer-state needs to know link type for reset_limits behavior 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 = app._get_viewer_item(viewer.reference) viewer_item['reference_data_label'] = refdata.label viewer_item['linked_by_wcs'] = wcs_linked # if changing from one link type to another, reset the limits: if align_by != old_align_by: viewer.state.reset_limits()