From c6838bee9480ebc43d6d7a7df9971b1d646db0a6 Mon Sep 17 00:00:00 2001 From: "Pey Lian Lim (Github)" <2090236+pllim@users.noreply.github.com> Date: Mon, 23 May 2022 17:57:48 -0400 Subject: [PATCH] Simple Image Rotation: Working POC --- CHANGES.rst | 3 + docs/imviz/plugins.rst | 31 ++ docs/reference/api.rst | 3 + jdaviz/app.py | 17 +- jdaviz/components/viewer_data_select.vue | 5 +- jdaviz/configs/imviz/helper.py | 12 +- jdaviz/configs/imviz/imviz.yaml | 1 + jdaviz/configs/imviz/plugins/__init__.py | 1 + .../imviz/plugins/rotate_image/__init__.py | 1 + .../plugins/rotate_image/rotate_image.py | 108 ++++++ .../plugins/rotate_image/rotate_image.vue | 48 +++ jdaviz/configs/imviz/plugins/viewers.py | 104 ++++-- jdaviz/configs/imviz/wcs_utils.py | 51 ++- jdaviz/core/template_mixin.py | 3 + licenses/MPDAF_LICENSE.txt | 28 ++ .../imviz_link_north_up_east_left.ipynb | 351 ++++++++++++++++++ 16 files changed, 727 insertions(+), 40 deletions(-) create mode 100644 jdaviz/configs/imviz/plugins/rotate_image/__init__.py create mode 100644 jdaviz/configs/imviz/plugins/rotate_image/rotate_image.py create mode 100644 jdaviz/configs/imviz/plugins/rotate_image/rotate_image.vue create mode 100644 licenses/MPDAF_LICENSE.txt create mode 100644 notebooks/concepts/imviz_link_north_up_east_left.ipynb diff --git a/CHANGES.rst b/CHANGES.rst index 77f648c0d6..d3357bfe06 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -18,6 +18,9 @@ Cubeviz Imviz ^^^^^ +- New Simple Image Rotation plugin to rotate the celestial axes of + images if they have valid WCS. [#1340] + Mosviz ^^^^^^ diff --git a/docs/imviz/plugins.rst b/docs/imviz/plugins.rst index 66cd58cad5..e4354b88a5 100644 --- a/docs/imviz/plugins.rst +++ b/docs/imviz/plugins.rst @@ -251,3 +251,34 @@ The columns are as follow: Once you have the results in a table, you can further manipulated them as documented in :ref:`astropy:astropy-table`. + + +.. _rotate-image-simple: + +Simple Image Rotation +===================== + +.. warning:: + + Distortion is ignored, so using this plugin on distorted data is + not recommended. + +.. note:: + + Zoom box in :ref:`imviz-compass` will not show when rotation mode is on. + +This plugins rotates image(s) by its celestial axes by the given angle. +You can toggle the rotation mode on and off. You can select viewer and data +but those options only show when applicable. You can enter the desired +rotation angle in degrees. When the angle is zero, it will align image(s) to +N-up and E-left orientation. If an image does not have a valid WCS, it will +not be rotated. Click on the :guilabel:`ROTATE` button to finalize. + +Given that this plugin affects the linking of the entire data collection +in Imviz by introducing a hidden data layer with the desired WCS, +it is not recommended to keep toggling this on and off continuously. +You might also notice some lag when a lot of images are loaded. + +Furthermore, linking is global across Imviz, regardless of the viewer. +Therefore, when WCS is rotated in one viewer, it will propagate to +the other viewers when multiple viewers are open. diff --git a/docs/reference/api.rst b/docs/reference/api.rst index 46578f0031..42310108ce 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -126,6 +126,9 @@ Plugins .. automodapi:: jdaviz.configs.imviz.plugins.links_control.links_control :no-inheritance-diagram: +.. automodapi:: jdaviz.configs.imviz.plugins.rotate_image.rotate_image + :no-inheritance-diagram: + .. automodapi:: jdaviz.configs.mosviz.plugins.row_lock.row_lock :no-inheritance-diagram: diff --git a/jdaviz/app.py b/jdaviz/app.py index b23f9d34ad..a972f21186 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -1017,12 +1017,19 @@ def _viewer_by_reference(self, reference): Returns ------- - `~glue_jupyter.bqplot.common.BqplotBaseView` + viewer : `~glue_jupyter.bqplot.common.BqplotBaseView` The viewer class instance. """ viewer_item = self._viewer_item_by_reference(reference) - return self._viewer_store[viewer_item['id']] + # There is a degeneracy between reference and ID. + # Imviz falls back to ID for user-created viewers. + if viewer_item is not None: + viewer = self._viewer_store[viewer_item['id']] + else: # Maybe it is ID + viewer = self._viewer_store[reference] + + return viewer def _viewer_item_by_reference(self, reference): """ @@ -1218,10 +1225,12 @@ def _update_selected_data_items(self, viewer_id, selected_items): # Make everything visible again in Imviz. if self.config == 'imviz': - from jdaviz.configs.imviz.helper import layer_is_image_data + from jdaviz.configs.imviz.helper import layer_is_image_data, layer_is_hidden for lyr in viewer.state.layers: - if layer_is_image_data(lyr.layer): + if layer_is_hidden(lyr.layer): + lyr.visible = False + elif layer_is_image_data(lyr.layer): lyr.visible = True viewer.on_limits_change() # Trigger compass redraw diff --git a/jdaviz/components/viewer_data_select.vue b/jdaviz/components/viewer_data_select.vue index 3138ac9e40..f48f1cf1e4 100644 --- a/jdaviz/components/viewer_data_select.vue +++ b/jdaviz/components/viewer_data_select.vue @@ -114,7 +114,10 @@ module.exports = { return true }, itemIsVisible(item, mosvizExtraItems) { - if (this.$props.viewer.config === 'mosviz') { + if (item.meta.Plugin === 'Simple Image Rotation') { + // hide dummy image with fake WCS from that plugin + return false + } else if (this.$props.viewer.config === 'mosviz') { if (this.$props.viewer.reference === 'spectrum-viewer' && item.type !== '1d spectrum') { // filters out table, spectrum 2d, images return false diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index b19f0263cd..c0fd3f3068 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -481,13 +481,19 @@ def layer_is_image_data(layer): return isinstance(layer, BaseData) and layer.ndim == 2 +def layer_is_hidden(layer): + # This is fake image with rotated WCS from the plugin. + return layer.meta.get('Plugin', '') == 'Simple Image Rotation' + + 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. """ return [i for i, lyr in enumerate(viewer.layers) - if lyr.visible and layer_is_image_data(lyr.layer)][-1] + if (lyr.visible and layer_is_image_data(lyr.layer) and + not layer_is_hidden(lyr.layer))][-1] def get_reference_image_data(app): @@ -542,6 +548,10 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u When only warnings are emitted and no links are assigned, some Imviz functionality may not work properly. + ref_data_label : str or `None` + Label of the data object to use as reference. If `None`, it picks the + first valid 2D image from the data collection. + Raises ------ ValueError diff --git a/jdaviz/configs/imviz/imviz.yaml b/jdaviz/configs/imviz/imviz.yaml index d53dbd9736..b3826643c6 100644 --- a/jdaviz/configs/imviz/imviz.yaml +++ b/jdaviz/configs/imviz/imviz.yaml @@ -25,6 +25,7 @@ tray: - imviz-compass - imviz-line-profile-xy - imviz-aper-phot-simple + - imviz-rotate-image - g-export-plot viewer_area: - container: col diff --git a/jdaviz/configs/imviz/plugins/__init__.py b/jdaviz/configs/imviz/plugins/__init__.py index 0b0f93a12c..674fa02a5e 100644 --- a/jdaviz/configs/imviz/plugins/__init__.py +++ b/jdaviz/configs/imviz/plugins/__init__.py @@ -7,3 +7,4 @@ from .compass import * # noqa from .aper_phot_simple import * # noqa from .line_profile_xy import * # noqa +from .rotate_image import * # noqa diff --git a/jdaviz/configs/imviz/plugins/rotate_image/__init__.py b/jdaviz/configs/imviz/plugins/rotate_image/__init__.py new file mode 100644 index 0000000000..11ffcbe2ff --- /dev/null +++ b/jdaviz/configs/imviz/plugins/rotate_image/__init__.py @@ -0,0 +1 @@ +from .rotate_image import * # noqa diff --git a/jdaviz/configs/imviz/plugins/rotate_image/rotate_image.py b/jdaviz/configs/imviz/plugins/rotate_image/rotate_image.py new file mode 100644 index 0000000000..f5a5ba3eb1 --- /dev/null +++ b/jdaviz/configs/imviz/plugins/rotate_image/rotate_image.py @@ -0,0 +1,108 @@ +import numpy as np +from astropy.nddata import NDData +from traitlets import Any, Bool, observe + +from jdaviz.configs.imviz.helper import link_image_data +from jdaviz.configs.imviz.plugins.parsers import _nddata_to_glue_data +from jdaviz.configs.imviz.wcs_utils import generate_rotated_wcs +from jdaviz.core.events import SnackbarMessage +from jdaviz.core.registries import tray_registry +from jdaviz.core.template_mixin import TemplateMixin, ViewerSelectMixin, DatasetSelectMixin + +__all__ = ['RotateImageSimple'] + + +@tray_registry('imviz-rotate-image', label="Simple Image Rotation") +class RotateImageSimple(TemplateMixin, ViewerSelectMixin, DatasetSelectMixin): + template_file = __file__, "rotate_image.vue" + + rotate_mode_on = Bool(False).tag(sync=True) + angle = Any(0).tag(sync=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._theta = 0 # degrees, clockwise + + # Dummy array to go with the rotated WCS, modified as needed later. + ndd = NDData(np.zeros((2, 2), dtype=np.uint8), wcs=generate_rotated_wcs(0), + meta={'Plugin': 'Simple Image Rotation'}) + for cur_data, cur_label in _nddata_to_glue_data(ndd, '_simple_rotated_wcs_ref'): + self._data_ref = cur_data + self._data_ref_label = cur_label + break # Just the DATA + + def _handle_compass_zoom_box(self): + # Hide zoom box in Compass because it is all screwy unless + # N lines up with Y, which then you do not need this plugin anyway. + for viewer in self.app._viewer_store.values(): + viewer._compass_show_zoom = not self.rotate_mode_on + # Force redraw if the compass is visible. + if viewer.compass is not None and viewer.compass.plugin_opened: + viewer.on_limits_change() + viewer.update() # Force viewer to update. + + @observe('rotate_mode_on') + def vue_toggle_on_off(self, *args, **kwargs): + # It is hard to keep track what the previous reference data was or + # if it is still valid, so just brute force here. + if not self.rotate_mode_on: + for vid in self.app.get_viewer_ids(): + # This is silent no-op if it is not in that viewer. + try: + self.app.remove_data_from_viewer(vid, self._data_ref_label) + except Exception: + pass + + self._handle_compass_zoom_box() + + def vue_rotate_image(self, *args, **kwargs): + # We only grab the value here to avoid constantly updating as + # user is still entering or updating the value. + try: + self._theta = float(self.angle) + except Exception: + return + + w_data = self.dataset.selected_dc_item.coords + if w_data is None: # Nothing to do + return + + try: + # Adjust the fake WCS to data with desired orientation. + w_in = generate_rotated_wcs(self._theta) + + # TODO: How to make this more robust? + # Match with selected data. + w_shape = self.dataset.selected_dc_item.shape + sky0 = w_data.pixel_to_world(0, 0) + sky1 = w_data.pixel_to_world(w_shape[1] * 0.5, w_shape[0] * 0.5) + avg_cdelt = (abs(sky1.ra.deg - sky0.ra.deg) + abs(sky1.dec.deg - sky0.dec.deg)) * 0.5 + w_in.wcs.crval = np.array([sky1.ra.deg, sky1.dec.deg]) + w_in.wcs.cdelt = np.array([-avg_cdelt, avg_cdelt]) + w_in.wcs.set() + + # Add it into data collection if not already there. + if self._data_ref_label not in self.app.data_collection.labels: + self.app.add_data(self._data_ref, data_label=self._data_ref_label, + notify_done=False) + + # Update the WCS. + self.app.data_collection[self._data_ref_label].coords = w_in + + # Force link by WCS if not already. Otherwise, no point in rotating WCS. + # Default settings is enough since we already said distortions are ignored. + link_image_data(self.app, link_type='wcs') + + # Make it a reference. + self.app.add_data_to_viewer(self.viewer_selected, self._data_ref_label, + clear_other_data=False) + viewer = self.app.get_viewer(self.viewer_selected) + if viewer.state.reference_data.label != self._data_ref_label: + viewer.state.reference_data = self.app.data_collection[self._data_ref_label] + + # Hide Compass zoom box here too, for multi-viewer use case. + # And force all viewers to update. + self._handle_compass_zoom_box() + except Exception as err: + self.hub.broadcast(SnackbarMessage( + f"Image rotation failed: {repr(err)}", color='error', sender=self)) diff --git a/jdaviz/configs/imviz/plugins/rotate_image/rotate_image.vue b/jdaviz/configs/imviz/plugins/rotate_image/rotate_image.vue new file mode 100644 index 0000000000..e6afe55695 --- /dev/null +++ b/jdaviz/configs/imviz/plugins/rotate_image/rotate_image.vue @@ -0,0 +1,48 @@ + diff --git a/jdaviz/configs/imviz/plugins/viewers.py b/jdaviz/configs/imviz/plugins/viewers.py index 4a51e3ae9b..0c79f3f562 100644 --- a/jdaviz/configs/imviz/plugins/viewers.py +++ b/jdaviz/configs/imviz/plugins/viewers.py @@ -5,7 +5,9 @@ from glue_jupyter.bqplot.image import BqplotImageView from jdaviz.configs.imviz import wcs_utils -from jdaviz.configs.imviz.helper import data_has_valid_wcs, layer_is_image_data, get_top_layer_index +from jdaviz.configs.imviz.helper import ( + data_has_valid_wcs, layer_is_image_data, layer_is_hidden, get_top_layer_index, + get_reference_image_data) from jdaviz.core.astrowidgets_api import AstrowidgetsImageViewerMixin from jdaviz.core.events import SnackbarMessage from jdaviz.core.registries import viewer_registry @@ -46,6 +48,7 @@ def __init__(self, *args, **kwargs): self.label_mouseover = None self.compass = None + self._compass_show_zoom = True self.line_profile_xy = None self.add_event_callback(self.on_mouse_or_key_event, events=['mousemove', 'mouseenter', @@ -72,11 +75,15 @@ def __init__(self, *args, **kwargs): def on_mouse_or_key_event(self, data): # Find visible layers - visible_layers = [layer for layer in self.state.layers if layer.visible] + visible_layers = [layer for layer in self.state.layers + if (layer.visible and layer_is_image_data(layer.layer) and + not layer_is_hidden(layer.layer))] if len(visible_layers) == 0: return + active_layer = visible_layers[-1] + if self.label_mouseover is None: if 'g-coords-info' in self.session.application._tools: self.label_mouseover = self.session.application._tools['g-coords-info'] @@ -97,7 +104,7 @@ def on_mouse_or_key_event(self, data): # Extract first dataset from visible layers and use this for coordinates - the choice # of dataset shouldn't matter if the datasets are linked correctly - image = visible_layers[0].layer + image = active_layer.layer # Extract data coordinates - these are pixels in the reference image x = data['domain']['x'] @@ -125,8 +132,8 @@ def on_mouse_or_key_event(self, data): # of how to display values when multiple datasets are present. if (x > -0.5 and y > -0.5 and x < image.shape[1] - 0.5 and y < image.shape[0] - 0.5 - and hasattr(visible_layers[0], 'attribute')): - attribute = visible_layers[0].attribute + and hasattr(active_layer, 'attribute')): + attribute = active_layer.attribute value = image.get_data(attribute)[int(round(y)), int(round(x))] unit = image.get_component(attribute).units self.label_mouseover.value = f'{value:+10.5e} {unit}' @@ -151,7 +158,7 @@ def on_mouse_or_key_event(self, data): elif key_pressed == 'l': # Same data as mousemove above. - image = visible_layers[0].layer + image = active_layer.layer x = data['domain']['x'] y = data['domain']['y'] if x is None or y is None: # Out of bounds @@ -168,7 +175,7 @@ def blink_once(self): # Exclude Subsets: They are global valid = [ilayer for ilayer, layer in enumerate(self.state.layers) - if layer_is_image_data(layer.layer)] + if (layer_is_image_data(layer.layer) and not layer_is_hidden(layer.layer))] n_layers = len(valid) if n_layers == 1: @@ -224,6 +231,9 @@ def _get_real_xy(self, image, x, y): When `True`, it sets the coords, otherwise it resets. """ + coords_status = False + link_type = self.get_link_type(image.label) + if data_has_valid_wcs(image): # Convert these to a SkyCoord via WCS - note that for other datasets # we aren't actually guaranteed to get a SkyCoord out, just for images @@ -231,14 +241,16 @@ def _get_real_xy(self, image, x, y): try: # Convert X,Y from reference data to the one we are actually seeing. # world_to_pixel return scalar ndarray that we need to convert to float. - if self.get_link_type(image.label) == 'wcs': + if link_type == 'wcs': x, y = list(map(float, image.coords.world_to_pixel( self.state.reference_data.coords.pixel_to_world(x, y)))) coords_status = True except Exception: - coords_status = False - else: - coords_status = False + pass + elif link_type == 'wcs_via_app_ref': + app_ref = get_reference_image_data(self.session.jdaviz_app)[0] + x, y = list(map(float, app_ref.coords.world_to_pixel( + self.state.reference_data.coords.pixel_to_world(x, y)))) return x, y, coords_status @@ -248,7 +260,12 @@ def _get_zoom_limits(self, image): image, which can be inaccurate if given image is dithered and they are linked by WCS. """ - if data_has_valid_wcs(image) and self.get_link_type(image.label) == 'wcs': + try: + link_type = self.get_link_type(image.label) + except ValueError: + return None + + if data_has_valid_wcs(image) and link_type == 'wcs': # Convert X,Y from reference data to the one we are actually seeing. x = image.coords.world_to_pixel(self.state.reference_data.coords.pixel_to_world( (self.state.x_min, self.state.x_max), (self.state.y_min, self.state.y_max))) @@ -262,7 +279,10 @@ def set_compass(self, image): if self.compass is None: # Maybe another viewer has it return - zoom_limits = self._get_zoom_limits(image) + if self._compass_show_zoom: + zoom_limits = self._get_zoom_limits(image) + else: + zoom_limits = None # Downsample input data to about 400px (as per compass.vue) for performance. xstep = max(1, round(image.shape[1] / 400)) @@ -287,8 +307,16 @@ def set_plot_axes(self): def data(self, cls=None): return [layer_state.layer # .get_object(cls=cls or self.default_class) for layer_state in self.state.layers - if hasattr(layer_state, 'layer') and - layer_is_image_data(layer_state.layer)] + if (hasattr(layer_state, 'layer') and + layer_is_image_data(layer_state.layer) and + not layer_is_hidden(layer_state.layer))] + + def update(self): + """Force viewer to update all its layers. + This is useful when viewer does not update automatically on time. + """ + for lyr in self.layers: + lyr.update() def get_link_type(self, data_label): """Find the type of ``glue`` linking between the given @@ -301,9 +329,11 @@ def get_link_type(self, data_label): Returns ------- - link_type : {'pixels', 'wcs', 'self'} - One of the link types accepted by :func:`~jdaviz.configs.imviz.helper.link_image_data` - or ``'self'`` if the data label belongs to the reference data itself. + link_type : {'pixels', 'wcs', 'self', 'wcs_via_app_ref'} + One of the link types accepted by :func:`~jdaviz.configs.imviz.helper.link_image_data`, + ``'self'`` if the data label belongs to the reference data itself, or + ``'wcs_via_app_ref'`` when the data has no WCS but relies on WCS translation + between two other data (e.g., when using Simple Image Rotation plugin). Raises ------ @@ -314,20 +344,34 @@ def get_link_type(self, data_label): if self.state.reference_data is None: raise ValueError('No reference data for link look-up') - ref_label = self.state.reference_data.label - if data_label == ref_label: - return 'self' + def _search_links(ref_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: - 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 + 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: + 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 + return link_type + link_type = _search_links(self.state.reference_data.label) + + # Maybe viewer reference is not the one actually used for linking. + if link_type is None: + app_ref = get_reference_image_data(self.session.jdaviz_app)[0] + link_type = _search_links(app_ref.label) + # Data has no WCS but it needs transitive WCS look-up. + # This situation happens when someone is using the Simple Image Rotation plugin. + if (link_type == 'pixels' and app_ref.coords is not None + and self.state.reference_data.coords is not None): + link_type = 'wcs_via_app_ref' + + # Just no luck. if link_type is None: raise ValueError(f'{data_label} not found in data collection external links') diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index e244fae1a6..21407312b9 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -1,6 +1,3 @@ -# This is adapted from Ginga (ginga.util.wcs, ginga.trcalc, and ginga.Bindings.ImageViewBindings). -# Please see the file licenses/GINGA_LICENSE.txt for details. -# """This module handles calculations based on world coordinate system (WCS).""" import base64 @@ -10,10 +7,56 @@ import matplotlib.pyplot as plt import numpy as np from astropy.coordinates import SkyCoord +from astropy.wcs import WCS from matplotlib.patches import Rectangle -__all__ = ['get_compass_info', 'draw_compass_mpl'] +__all__ = ['generate_rotated_wcs', 'get_compass_info', 'draw_compass_mpl'] + + +# This is adapted from MPDAF lib/mpdaf/obj/coords.py module. +# Please see the file licenses/MPDAF_LICENSE.txt for details. + +def generate_rotated_wcs(theta, shape=(2, 2)): + """Create a FITS WCS with N-up, E-left, and the desired rotation. + This does not take distortions into account. + + Parameters + ---------- + theta : float + This can be used to specify a value for the rotation angle of the + image in degrees. This is the angle between celestial north and the Y + axis of the image (positive is clockwise). + + Returns + ------- + w_out : `astropy.wcs.WCS` + FITS WCS with rotation applied. + + References + ---------- + .. [1] Calabretta, M. R., & Greisen, E. W. 2002, A&A, 395, 1077-1122 + + """ + w_out = WCS({'CTYPE1': 'RA---TAN', 'CTYPE2': 'DEC--TAN', + 'CUNIT1': 'deg', 'CUNIT2': 'deg', + 'CDELT1': -1, 'CDELT2': 1, + 'CRPIX1': 1, 'CRPIX2': 1, + 'NAXIS1': shape[1], 'NAXIS2': shape[0]}) + if not np.allclose(theta, 0): + rho = math.radians(theta) + sin_rho = math.sin(rho) + cos_rho = math.cos(rho) + w_out.wcs.pc = np.array([[cos_rho, -sin_rho], + [sin_rho, cos_rho]]) + w_out.wcs.set() + + return w_out + + +# These below are adapted from Ginga (ginga.util.wcs, ginga.trcalc, +# and ginga.Bindings.ImageViewBindings). +# Please see the file licenses/GINGA_LICENSE.txt for details. def rotate_pt(x_arr, y_arr, theta_deg, xoff=0, yoff=0): """ diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index 009ae5c0db..8747c50f77 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -1087,6 +1087,9 @@ def layer_in_spectrum_viewer(data): def is_image(data): return len(data.shape) == 3 + if data.meta.get('Plugin', None) == 'Simple Image Rotation': + return False + return super()._is_valid_item(data, locals()) @observe('filters') diff --git a/licenses/MPDAF_LICENSE.txt b/licenses/MPDAF_LICENSE.txt new file mode 100644 index 0000000000..cece1eea5e --- /dev/null +++ b/licenses/MPDAF_LICENSE.txt @@ -0,0 +1,28 @@ +Copyright (c) 2010-2019 CNRS / Centre de Recherche Astrophysique de Lyon + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/notebooks/concepts/imviz_link_north_up_east_left.ipynb b/notebooks/concepts/imviz_link_north_up_east_left.ipynb new file mode 100644 index 0000000000..d18eb5034e --- /dev/null +++ b/notebooks/concepts/imviz_link_north_up_east_left.ipynb @@ -0,0 +1,351 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "206e73ce", + "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.io import fits\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 generate_rotated_wcs\n", + "\n", + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "id": "055fce4f", + "metadata": {}, + "source": [ + "# Plain array" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02a3f110", + "metadata": {}, + "outputs": [], + "source": [ + "a = np.random.random((2048, 4096))\n", + "a[:100, :400] = 1 # Bright corner for sanity check" + ] + }, + { + "cell_type": "markdown", + "id": "6f0e93c0", + "metadata": {}, + "source": [ + "# Data with FITS WCS and unit" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e816343", + "metadata": {}, + "outputs": [], + "source": [ + "w = WCS({'WCSAXES': 2,\n", + " 'CRPIX1': 2100.0,\n", + " 'CRPIX2': 1024.0,\n", + " 'PC1_1': -1.14852e-05,\n", + " 'PC1_2': 7.01477e-06,\n", + " 'PC2_1': 7.75765e-06,\n", + " 'PC2_2': 1.20927e-05,\n", + " 'CDELT1': 1.0,\n", + " 'CDELT2': 1.0,\n", + " 'CUNIT1': 'deg',\n", + " 'CUNIT2': 'deg',\n", + " 'CTYPE1': 'RA---TAN',\n", + " 'CTYPE2': 'DEC--TAN',\n", + " 'CRVAL1': 3.581704851882,\n", + " 'CRVAL2': -30.39197867265,\n", + " 'LONPOLE': 180.0,\n", + " 'LATPOLE': -30.39197867265,\n", + " 'MJDREF': 0.0,\n", + " 'RADESYS': 'ICRS'})\n", + "hdu = fits.ImageHDU(a, name='SCI')\n", + "hdu.header.update(w.to_header())\n", + "hdu.header['BUNIT'] = 'electron/s'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a69e6b9", + "metadata": {}, + "outputs": [], + "source": [ + "w.to_header()" + ] + }, + { + "cell_type": "markdown", + "id": "d7e31be2", + "metadata": {}, + "source": [ + "# Data with GWCS (no unit)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ddf0239", + "metadata": {}, + "outputs": [], + "source": [ + "shift_by_crpix = models.Shift(-(2048 - 1) * u.pix) & models.Shift(-(1024 - 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", + "w2 = gwcs.WCS(pipeline)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3ab762bf", + "metadata": {}, + "outputs": [], + "source": [ + "ndd_gwcs = NDData(a, wcs=w2)" + ] + }, + { + "cell_type": "markdown", + "id": "fef5b5f4", + "metadata": {}, + "source": [ + "This next cell does not work, see https://github.com/spacetelescope/gwcs/issues/408" + ] + }, + { + "cell_type": "raw", + "id": "30351eca", + "metadata": {}, + "source": [ + "w2.to_fits(bounding_box=([0, 100] * u.pix, [0, 100] * u.pix))" + ] + }, + { + "cell_type": "markdown", + "id": "eb2caebb", + "metadata": {}, + "source": [ + "# Fake data with desired orientation\n", + "\n", + "This is no longer needed with the Simple Image Rotation plugin." + ] + }, + { + "cell_type": "raw", + "id": "77bba25b", + "metadata": {}, + "source": [ + "# Just a dummy array to hold the WCS.\n", + "a_dummy = np.random.random((2, 2))\n", + "\n", + "# WCS with N-up/E-left\n", + "w_ref = generate_rotated_wcs(0)\n", + "\n", + "# Center it with data, more or less\n", + "w_ref.wcs.crval = w.wcs.crval\n", + "w_ref.wcs.cdelt = new_cdelt\n", + "w_ref.wcs.set()\n", + "\n", + "ndd = NDData(a_dummy, wcs=w_ref)" + ] + }, + { + "cell_type": "raw", + "id": "6af3d111", + "metadata": {}, + "source": [ + "w_ref.to_header()" + ] + }, + { + "cell_type": "markdown", + "id": "046ace36", + "metadata": {}, + "source": [ + "# Show them in Imviz" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93fcdb1b", + "metadata": {}, + "outputs": [], + "source": [ + "imviz = Imviz(verbosity='warning')" + ] + }, + { + "cell_type": "raw", + "id": "f9abec42", + "metadata": {}, + "source": [ + "# This is no longer needed with the Simple Image Rotation plugin.\n", + "imviz.load_data(ndd, data_label='wcs_ref')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa69eae0", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.load_data(hdu, data_label='jb5g05ubq_flt')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3585cda", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.load_data(ndd_gwcs, data_label='gwcs')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4a09eb6e-5a98-43b9-8e88-3c7879ac600c", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.load_data(a, data_label='no_wcs')" + ] + }, + { + "cell_type": "raw", + "id": "47548d13", + "metadata": {}, + "source": [ + "# This is no longer needed with the Simple Image Rotation plugin.\n", + "imviz.link_data(link_type='wcs')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "022cc29f", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app" + ] + }, + { + "cell_type": "raw", + "id": "cd4c2aca", + "metadata": {}, + "source": [ + "# To access the plugin directly.\n", + "plg = imviz.app.get_tray_item_from_name('imviz-rotate-image')" + ] + }, + { + "cell_type": "markdown", + "id": "21c20ee4", + "metadata": {}, + "source": [ + "# Back to basics\n", + "\n", + "This shows the low-level function to produce a WCS with desired rotation angle." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48424037", + "metadata": {}, + "outputs": [], + "source": [ + "from jdaviz.configs.imviz.wcs_utils import draw_compass_mpl" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acac6156", + "metadata": {}, + "outputs": [], + "source": [ + "aa = np.random.random((2, 2))\n", + "w1 = generate_rotated_wcs(0)\n", + "draw_compass_mpl(aa, wcs=w1);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cf2da96b", + "metadata": {}, + "outputs": [], + "source": [ + "w2 = generate_rotated_wcs(-90)\n", + "draw_compass_mpl(aa, wcs=w2);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1143f7cc", + "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.9.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}