diff --git a/CHANGES.rst b/CHANGES.rst index cb3ab665b9..a9406c231e 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..4d71cd68f2 100644 --- a/docs/imviz/plugins.rst +++ b/docs/imviz/plugins.rst @@ -251,3 +251,39 @@ 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. + +Toggling the rotation mode off would re-orient the viewer to have +Y-up and X-right for the original reference data but the other data +would still be linked to it by WCS. To change the linking back to pixels, +use the :ref:`imviz-link-control` plugin. 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..81ee88a727 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 hasattr(layer, 'meta') and 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): 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/line_profile_xy/line_profile_xy.py b/jdaviz/configs/imviz/plugins/line_profile_xy/line_profile_xy.py index e934eabc0e..28baeffa02 100644 --- a/jdaviz/configs/imviz/plugins/line_profile_xy/line_profile_xy.py +++ b/jdaviz/configs/imviz/plugins/line_profile_xy/line_profile_xy.py @@ -67,7 +67,14 @@ def vue_draw_plot(self, *args, **kwargs): self.reset_results() return - x_min, y_min, x_max, y_max = viewer._get_zoom_limits(data) + xy_limits = viewer._get_zoom_limits(data) + x_limits = xy_limits[:, 0] + y_limits = xy_limits[:, 1] + x_min = x_limits.min() + x_max = x_limits.max() + y_min = y_limits.min() + y_max = y_limits.max() + comp = data.get_component(data.main_components[0]) if comp.units: y_label = comp.units @@ -101,8 +108,9 @@ def vue_draw_plot(self, *args, **kwargs): y_min = max(int(y_min), 0) y_max = min(int(y_max), ny) zoomed_data_x = comp.data[y_min:y_max, x] - line_x.scales['y'].min = zoomed_data_x.min() * 0.95 - line_x.scales['y'].max = zoomed_data_x.max() * 1.05 + if zoomed_data_x.size > 0: + line_x.scales['y'].min = zoomed_data_x.min() * 0.95 + line_x.scales['y'].max = zoomed_data_x.max() * 1.05 fig_y.title = f'Y={y}' fig_y.title_style = {'font-size': '12px'} @@ -119,8 +127,9 @@ def vue_draw_plot(self, *args, **kwargs): x_min = max(int(x_min), 0) x_max = min(int(x_max), nx) zoomed_data_y = comp.data[y, x_min:x_max] - line_y.scales['y'].min = zoomed_data_y.min() * 0.95 - line_y.scales['y'].max = zoomed_data_y.max() * 1.05 + if zoomed_data_y.size > 0: + line_y.scales['y'].min = zoomed_data_y.min() * 0.95 + line_y.scales['y'].max = zoomed_data_y.max() * 1.05 self.line_plot_across_x = fig_x self.line_plot_across_y = fig_y 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..78117ee466 --- /dev/null +++ b/jdaviz/configs/imviz/plugins/rotate_image/rotate_image.py @@ -0,0 +1,101 @@ +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'] + + +# TODO: Refactor to use Kyle's CSS rotation? +# TODO: Maybe we do not need DataSelectMixin. +@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 _update_all_viewers(self): + for viewer in self.app._viewer_store.values(): + 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: # nosec B110 + pass + + 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] + + # Force all viewers to update. + self._update_all_viewers() + except Exception as err: # pragma: no cover + 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..e86becd058 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 @@ -72,11 +74,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 +103,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 +131,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 +157,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 +174,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 +230,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,30 +240,47 @@ 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 + except Exception: # nosec: B110 + 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 def _get_zoom_limits(self, image): - """Return ``(x_min, y_min, x_max, y_max)`` for given image. + """Return a list of ``(x, y)`` that defines four corners of + the zoom box for a given image. This is needed because viewer values are only based on reference image, which can be inaccurate if given image is dithered and - they are linked by WCS. + they are linked by WCS. When not applicable, return `None` instead. """ - 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') or (link_type == 'wcs_via_app_ref'): # 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))) - zoom_limits = (x[0][0], x[1][0], x[0][1], x[1][1]) + if link_type == 'wcs_via_app_ref': + data_wcs = get_reference_image_data(self.session.jdaviz_app)[0].coords + else: + data_wcs = image.coords + x = data_wcs.world_to_pixel(self.state.reference_data.coords.pixel_to_world( + (self.state.x_min, self.state.x_min, self.state.x_max, self.state.x_max), + (self.state.y_min, self.state.y_max, self.state.y_max, self.state.y_min))) + zoom_limits = np.array(list(zip(x[0], x[1]))) else: - zoom_limits = (self.state.x_min, self.state.y_min, self.state.x_max, self.state.y_max) + zoom_limits = np.array(((self.state.x_min, self.state.y_min), + (self.state.x_min, self.state.y_max), + (self.state.x_max, self.state.y_max), + (self.state.x_max, self.state.y_min))) + return zoom_limits def set_compass(self, image): @@ -287,8 +313,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 +335,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 +350,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/tests/test_linking.py b/jdaviz/configs/imviz/tests/test_linking.py index cacca50de1..0a82fd99a3 100644 --- a/jdaviz/configs/imviz/tests/test_linking.py +++ b/jdaviz/configs/imviz/tests/test_linking.py @@ -30,24 +30,20 @@ def test_wcslink_fallback_pixels(self): assert self.viewer.get_link_type('has_wcs[SCI,1]') == 'self' assert self.viewer.get_link_type('no_wcs[SCI,1]') == 'pixels' - # Also check the coordinates display + # Also check the coordinates display: Last loaded is on top. self.viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 0, 'y': 0}}) assert self.viewer.label_mouseover.pixel == 'x=00.0 y=00.0' assert self.viewer.label_mouseover.value == '+0.00000e+00 ' - assert self.viewer.label_mouseover.world_ra_deg == '337.5202808000' - assert self.viewer.label_mouseover.world_dec_deg == '-20.8333330600' - - # Not sure why but need one extra blink to work properly. - # This does not happen when we load real data from files. - self.viewer.blink_once() + assert self.viewer.label_mouseover.world_ra_deg == '' + assert self.viewer.label_mouseover.world_dec_deg == '' self.viewer.on_mouse_or_key_event({'event': 'keydown', 'key': 'b', 'domain': {'x': 0, 'y': 0}}) assert self.viewer.label_mouseover.pixel == 'x=00.0 y=00.0' assert self.viewer.label_mouseover.value == '+0.00000e+00 ' - assert self.viewer.label_mouseover.world_ra_deg == '' - assert self.viewer.label_mouseover.world_dec_deg == '' + assert self.viewer.label_mouseover.world_ra_deg == '337.5202808000' + assert self.viewer.label_mouseover.world_dec_deg == '-20.8333330600' def test_wcslink_nofallback_noerror(self): self.imviz.link_data(link_type='wcs', wcs_fallback_scheme=None) @@ -112,23 +108,19 @@ def test_wcslink_affine_with_extras(self): # Ensure pan/zoom does not change when markers are not present. assert_allclose((self.viewer.state.x_min, self.viewer.state.y_min, - self.viewer.state.x_max, self.viewer.state.y_max), ans) + self.viewer.state.x_max, self.viewer.state.y_max), ans) - # Also check the coordinates display + # Also check the coordinates display: Last loaded is on top. self.viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 0, 'y': 0}}) - assert self.viewer.label_mouseover.pixel == 'x=00.0 y=00.0' + assert self.viewer.label_mouseover.pixel == 'x=01.0 y=-0.0' assert self.viewer.label_mouseover.value == '+1.00000e+00 ' assert self.viewer.label_mouseover.world_ra_deg == '337.5202808000' assert self.viewer.label_mouseover.world_dec_deg == '-20.8333330600' - # Not sure why but need one extra blink to work properly. - # This does not happen when we load real data from files. - self.viewer.blink_once() - self.viewer.on_mouse_or_key_event({'event': 'keydown', 'key': 'b', 'domain': {'x': 0, 'y': 0}}) - assert self.viewer.label_mouseover.pixel == 'x=01.0 y=-0.0' + assert self.viewer.label_mouseover.pixel == 'x=00.0 y=00.0' assert self.viewer.label_mouseover.value == '+1.00000e+00 ' assert self.viewer.label_mouseover.world_ra_deg == '337.5202808000' assert self.viewer.label_mouseover.world_dec_deg == '-20.8333330600' diff --git a/jdaviz/configs/imviz/tests/test_simple_image_rotation.py b/jdaviz/configs/imviz/tests/test_simple_image_rotation.py new file mode 100644 index 0000000000..2ca6bf3622 --- /dev/null +++ b/jdaviz/configs/imviz/tests/test_simple_image_rotation.py @@ -0,0 +1,97 @@ +import gwcs +import numpy as np +from astropy import units as u +from astropy.coordinates import ICRS +from astropy.modeling import models +from astropy.nddata import NDData +from astropy.wcs import WCS +from gwcs import coordinate_frames as cf +from numpy.testing import assert_allclose + + +def test_simple_image_rotation_plugin(imviz_helper): + # Mimic interactive mode where viewer is displayed. + imviz_helper.default_viewer.shape = (100, 100) + imviz_helper.default_viewer.state._set_axes_aspect_ratio(1) + + a = np.zeros((10, 8)) + a[0, 0] = 1 # Bright corner for sanity check. + + # Adapted from HST/ACS FITS WCS without the distortion. + w_fits = WCS({'WCSAXES': 2, 'NAXIS1': 8, 'NAXIS2': 10, + 'CRPIX1': 5.0, 'CRPIX2': 5.0, + 'PC1_1': -1.14852e-05, 'PC1_2': 7.01477e-06, + 'PC2_1': 7.75765e-06, 'PC2_2': 1.20927e-05, + 'CDELT1': 1.0, 'CDELT2': 1.0, + 'CUNIT1': 'deg', 'CUNIT2': 'deg', + 'CTYPE1': 'RA---TAN', 'CTYPE2': 'DEC--TAN', + 'CRVAL1': 3.581704851882, 'CRVAL2': -30.39197867265, + 'LONPOLE': 180.0, 'LATPOLE': -30.39197867265, + 'MJDREF': 0.0, 'RADESYS': 'ICRS'}) + + # Adapted from GWCS example. + shift_by_crpix = models.Shift(-(5 - 1) * u.pix) & models.Shift(-(5 - 1) * u.pix) + matrix = np.array([[1.290551569736E-05, 5.9525007864732E-06], + [5.0226382102765E-06, -1.2644844123757E-05]]) + rotation = models.AffineTransformation2D(matrix * u.deg, translation=[0, 0] * u.deg) + rotation.input_units_equivalencies = {"x": u.pixel_scale(1 * (u.deg / u.pix)), + "y": u.pixel_scale(1 * (u.deg / u.pix))} + rotation.inverse = models.AffineTransformation2D(np.linalg.inv(matrix) * u.pix, + translation=[0, 0] * u.pix) + rotation.inverse.input_units_equivalencies = {"x": u.pixel_scale(1 * (u.pix / u.deg)), + "y": u.pixel_scale(1 * (u.pix / u.deg))} + tan = models.Pix2Sky_TAN() + celestial_rotation = models.RotateNative2Celestial( + 3.581704851882 * u.deg, -30.39197867265 * u.deg, 180 * u.deg) + det2sky = shift_by_crpix | 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=ICRS(), name='icrs', unit=(u.deg, u.deg)) + pipeline = [(detector_frame, det2sky), (sky_frame, None)] + w_gwcs = gwcs.WCS(pipeline) + + # Load data into Imviz. + imviz_helper.load_data(NDData(a, wcs=w_fits, unit='electron/s'), data_label='fits_wcs') + imviz_helper.load_data(NDData(a, wcs=w_gwcs), data_label='gwcs') + imviz_helper.load_data(a, data_label='no_wcs') + + plg = imviz_helper.app.get_tray_item_from_name('imviz-rotate-image') + plg.plugin_opened = True + + # Toggle it on. + plg.rotate_mode_on = True + assert plg.dataset.selected == 'fits_wcs[DATA]' + + # Rotate with default settings. + plg.vue_rotate_image() + assert_allclose(plg._theta, 0) + + # Dummy data with desired WCS is now in data collection but user cannot see it. + assert imviz_helper.app.data_collection.labels == [ + 'fits_wcs[DATA]', 'gwcs[DATA]', 'no_wcs', '_simple_rotated_wcs_ref[DATA]'] + assert plg.dataset.labels == ['fits_wcs[DATA]', 'gwcs[DATA]', 'no_wcs'] + + # The zoom box is now a rotated rombus. + # no_wcs is the same as fits_wcs because they are linked by pixel, but gwcs is different. + fits_wcs_zoom_limits = imviz_helper.default_viewer._get_zoom_limits( + imviz_helper.app.data_collection['fits_wcs[DATA]']) + assert_allclose(fits_wcs_zoom_limits, ((-0.37873422, 5.62910616), (3.42315751, 11.85389963), + (13.52329142, 5.37451188), (9.72139968, -0.85028158))) + assert_allclose(fits_wcs_zoom_limits, imviz_helper.default_viewer._get_zoom_limits( + imviz_helper.app.data_collection['no_wcs'])) + assert_allclose(imviz_helper.default_viewer._get_zoom_limits('gwcs[DATA]'), ( + (7.60196643, 6.55912761), (10.83179746, -0.44341285), + (0.25848145, -4.64322363), (-2.97134959, 2.35931683))) + + # TODO: Test the plugin. + # 2. Try positive angle + # 3. Add another viewer. + # 4. Try negative angle on second viewer. + # 5. Cross-test with Compass/Zoom-box, Line Profile, Coord Info? + # 6. Untoggle and check state. + # 7. Retoggle and check state. + # 8. Make sure we also cover engine code not mentioned above but changed in the PR. + + # Second test function: Load just data without WCS, toggle on, should be no-op. + + # Also test function to make sure invalid angle does not crash plugin. diff --git a/jdaviz/configs/imviz/tests/test_wcs_utils.py b/jdaviz/configs/imviz/tests/test_wcs_utils.py index bff80391aa..08c24a1449 100644 --- a/jdaviz/configs/imviz/tests/test_wcs_utils.py +++ b/jdaviz/configs/imviz/tests/test_wcs_utils.py @@ -1,5 +1,6 @@ import gwcs import numpy as np +import pytest from astropy import units as u from astropy.coordinates import ICRS from astropy.modeling import models @@ -104,3 +105,10 @@ def test_simple_gwcs(): 1262.0057201165127, 606.2863901330095, 155.2870478938214, -86.89813081941797)) assert not result[-1] + + +@pytest.mark.parametrize(('angle', 'ans'), [(0, 0), (45, 45), (-45, -45), (360, 0)]) +def test_rotate_wcs(angle, ans): + w = wcs_utils.generate_rotated_wcs(angle) + degn = wcs_utils.get_compass_info(w, (2, 2))[6] + assert_allclose(degn, ans, atol=1e-7) diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index e244fae1a6..b0bc3debf2 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,59 @@ import matplotlib.pyplot as plt import numpy as np from astropy.coordinates import SkyCoord -from matplotlib.patches import Rectangle +from astropy.wcs import WCS +from matplotlib.patches import Polygon + +__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. -__all__ = ['get_compass_info', 'draw_compass_mpl'] + 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). + + shape : tuple of int + Image shape that the WCS corresponds to in the form of ``(ny, nx)``. + 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): """ @@ -182,9 +228,10 @@ def draw_compass_mpl(image, orig_shape=None, wcs=None, show=True, zoom_limits=No show : bool Display the plot. - zoom_limits : tuple of float or None + zoom_limits : ndarray or None If not `None`, also draw a rectangle to represent the - current zoom limits in the form of ``(x1, y1, x2, y2)``. + current zoom limits in the form of list of ``(x, y)`` + representing the four corners of the zoom box. kwargs : dict Keywords for ``matplotlib.pyplot.imshow``. @@ -238,10 +285,8 @@ def draw_compass_mpl(image, orig_shape=None, wcs=None, show=True, zoom_limits=No color='yellow', fontsize=16, va='center', ha='center') if zoom_limits is not None: - zx1, zy1, zx2, zy2 = zoom_limits - rect = Rectangle((zx1, zy1), zx2 - zx1, zy2 - zy1, - linewidth=1.5, edgecolor='r', facecolor='none') - ax.add_patch(rect) + ax.add_patch(Polygon( + zoom_limits, closed=True, linewidth=1.5, edgecolor='r', facecolor='none')) if show: plt.draw() 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..b5b2dffe3e --- /dev/null +++ b/notebooks/concepts/imviz_link_north_up_east_left.ipynb @@ -0,0 +1,533 @@ +{ + "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": "code", + "execution_count": null, + "id": "e1904888", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app.data_collection" + ] + }, + { + "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": "markdown", + "id": "1fa8e146", + "metadata": {}, + "source": [ + "# Testing the unit tests\n", + "\n", + "This setup was used to build unit tests for the new plugin in https://github.com/spacetelescope/jdaviz/pull/1340 ." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37bccf94", + "metadata": {}, + "outputs": [], + "source": [ + "imviz_helper = Imviz(verbosity='warning')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc078718", + "metadata": {}, + "outputs": [], + "source": [ + "# Makes the display ugly but used for testing.\n", + "imviz_helper.default_viewer.shape = (100, 100)\n", + "imviz_helper.default_viewer.state._set_axes_aspect_ratio(1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d54f624", + "metadata": {}, + "outputs": [], + "source": [ + "a = np.random.random((10, 8)) # All zeroes in test but we need to see here.\n", + "a[0, 0] = 1 # Bright corner for sanity check." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9b78c484", + "metadata": {}, + "outputs": [], + "source": [ + "# Adapted from HST/ACS FITS WCS without the distortion.\n", + "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": "code", + "execution_count": null, + "id": "0a1f44e5", + "metadata": {}, + "outputs": [], + "source": [ + "# Adapted from GWCS example.\n", + "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)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f289953a", + "metadata": {}, + "outputs": [], + "source": [ + "# Load data into Imviz.\n", + "imviz_helper.load_data(NDData(a, wcs=w_fits, unit='electron/s'), data_label='fits_wcs')\n", + "imviz_helper.load_data(NDData(a, wcs=w_gwcs), data_label='gwcs')\n", + "imviz_helper.load_data(a, data_label='no_wcs')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4c7e9fb", + "metadata": {}, + "outputs": [], + "source": [ + "# This makes it interactive: Not in test but we need to see here.\n", + "imviz_helper.app" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a5419b9", + "metadata": {}, + "outputs": [], + "source": [ + "plg = imviz_helper.app.get_tray_item_from_name('imviz-rotate-image')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c88d8259", + "metadata": {}, + "outputs": [], + "source": [ + "# Also check the coordinates display: Last loaded is on top.\n", + "\n", + "imviz_helper.default_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 0, 'y': 0}})\n", + "print(imviz_helper.default_viewer.label_mouseover.pixel) # 'x=00.0 y=00.0'\n", + "print(imviz_helper.default_viewer.label_mouseover.value) # 1\n", + "print(imviz_helper.default_viewer.label_mouseover.world_ra_deg) # ''\n", + "print(imviz_helper.default_viewer.label_mouseover.world_dec_deg) # ''" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cefcea98", + "metadata": {}, + "outputs": [], + "source": [ + "imviz_helper.default_viewer._get_zoom_limits(imviz_helper.app.data_collection['no_wcs'])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3be7af7", + "metadata": {}, + "outputs": [], + "source": [ + "imviz_helper.default_viewer._get_zoom_limits(imviz_helper.app.data_collection['fits_wcs[DATA]'])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e8b7636c", + "metadata": {}, + "outputs": [], + "source": [ + "imviz_helper.default_viewer._get_zoom_limits(imviz_helper.app.data_collection['gwcs[DATA]'])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15f7877a", + "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 +}