diff --git a/jdaviz/app.py b/jdaviz/app.py index ce110f3ab8..1f5b3b8d6d 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -284,6 +284,12 @@ def __init__(self, configuration=None, *args, **kwargs): # data loading self.auto_link = kwargs.pop('auto_link', True) + # Imviz linking + self._wcs_only_label = "_WCS_ONLY" + if self.config == "imviz": + self._link_type = None + self._wcs_use_affine = None + # Subscribe to messages indicating that a new viewer needs to be # created. When received, information is passed to the application # handler to generate the appropriate viewer instance. @@ -461,7 +467,7 @@ def _color_to_level(color): def _on_layers_changed(self, msg): if hasattr(msg, 'data'): layer_name = msg.data.label - is_wcs_only = msg.data.meta.get('WCS-ONLY', False) + is_wcs_only = msg.data.meta.get(self._wcs_only_label, False) is_ref_data = getattr(msg._viewer.state.reference_data, 'label', '') == layer_name elif hasattr(msg, 'subset'): layer_name = msg.subset.label @@ -487,8 +493,8 @@ def _on_layers_changed(self, msg): } def _on_refdata_changed(self, msg): - old_is_wcs_only = msg.old.meta.get('WCS-ONLY', False) - new_is_wcs_only = msg.new.meta.get('WCS-ONLY', False) + old_is_wcs_only = msg.old.meta.get(self._wcs_only_label, False) + new_is_wcs_only = msg.data.meta.get(self._wcs_only_label, False) wcs_only_refdata_icon = 'mdi-compass-outline' wcs_only_not_refdata_icon = 'mdi-compass-off-outline' @@ -504,7 +510,7 @@ def switch_icon(old_icon, new_icon): new_layer_icons[layer_name] = switch_icon( layer_icon, wcs_only_not_refdata_icon ) - elif layer_name == msg.new.label and new_is_wcs_only: + elif layer_name == msg.data.label and new_is_wcs_only: new_layer_icons[layer_name] = switch_icon( layer_icon, wcs_only_refdata_icon ) @@ -518,35 +524,32 @@ def _change_reference_data(self, new_refdata_label): Change reference data to Data with ``data_label`` """ if self.config != 'imviz': - # this method is only meant for imviz for now + # this method is only meant for Imviz for now return - viewer_reference = self._get_first_viewer_reference_name() - viewer = self.get_viewer(viewer_reference) - old_refdata = self._viewer_store[viewer_reference].state.reference_data + viewer_id = f'{self.config}-0' # Same as the ID in imviz.destroy_viewer() + viewer = self._jdaviz_helper.default_viewer + old_refdata = viewer.state.reference_data if new_refdata_label == old_refdata.label: # if there's no refdata change, don't do anything: return - [new_refdata] = [ - data for data in self.data_collection - if data.label == new_refdata_label - ] - - change_refdata_message = ChangeRefDataMessage( + new_refdata = self.data_collection[new_refdata_label] + viewer.state.reference_data = new_refdata + self.hub.broadcast(ChangeRefDataMessage( new_refdata, viewer, - viewer_id=viewer_reference, - sender=self, + viewer_id=viewer_id, old=old_refdata, - new=new_refdata - ) + sender=self)) - self._viewer_store[viewer_reference].state.reference_data = new_refdata - self.hub.broadcast(change_refdata_message) + # Re-link + self._jdaviz_helper.link_data(link_type=self._link_type, + wcs_use_affine=self._wcs_use_affine, + error_on_fail=True) - self._viewer_store[viewer_reference].state.reset_limits() + viewer.state.reset_limits() def _link_new_data(self, reference_data=None, data_to_be_linked=None): """ @@ -824,12 +827,7 @@ def get_data_from_viewer(self, viewer_reference, data_label=None, elif len(layer_data.shape) == 2: layer_data = layer_data.get_object(cls=CCDData) - is_wcs_only = ( - # then check if it's all NaNs: - np.all(np.isnan(layer_data.data)) and - # finally check that metadata confirms this is a WCS-ONLY object: - layer_data.meta.get('WCS-ONLY', False) - ) + is_wcs_only = layer_data.meta.get(self._wcs_only_label, False) if is_wcs_only: continue @@ -1908,10 +1906,10 @@ def set_data_visibility(self, viewer_reference, data_label, visible=True, replac if id != data_id: selected_items[id] = 'hidden' - # remove wcs-only data from selected items, + # remove WCS-only data from selected items, # add to wcs_only_layers: for layer in viewer.layers: - is_wcs_only = getattr(layer.layer, 'meta', {}).get('WCS-ONLY', False) + is_wcs_only = getattr(layer.layer, 'meta', {}).get(self._wcs_only_label, False) if layer.layer.data.label == data_label and is_wcs_only: layer.visible = False viewer.state.wcs_only_layers.append(data_label) @@ -1989,11 +1987,10 @@ def _on_data_deleted(self, msg): self._clear_object_cache(msg.data.label) - @staticmethod - def _create_data_item(data): + def _create_data_item(self, data): ndims = len(data.shape) wcsaxes = data.meta.get('WCSAXES', None) - wcs_only = data.meta.get('WCS-ONLY', False) + wcs_only = data.meta.get(self._wcs_only_label, False) if wcsaxes is None: # then we'll need to determine type another way, we want to avoid # this when we can though since its not as cheap diff --git a/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py b/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py index 022163696b..33ec26697e 100644 --- a/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py +++ b/jdaviz/configs/default/plugins/metadata_viewer/tests/test_metadata_viewer.py @@ -37,7 +37,7 @@ def test_view_dict(imviz_helper): assert mv.has_metadata assert mv.metadata == [ ('BAR', '10.0', ''), ('BOZO', 'None', ''), ('EXTNAME', 'SCI', ''), - ('EXTVER', '1', ''), ('FOO', '', ''), ('WCS-ONLY', 'False', '')] + ('EXTVER', '1', ''), ('FOO', '', '')] mv.dataset_selected = 'has_nested_meta[DATA]' assert not mv.has_primary @@ -46,9 +46,7 @@ def test_view_dict(imviz_helper): assert mv.has_metadata assert mv.metadata == [ ('EXTNAME', 'ASDF', ''), ('REF.bar', '10.0', ''), - ('REF.foo.1', '', ''), ('REF.foo.2.0', '1', ''), ('REF.foo.2.1', '2', ''), - ('WCS-ONLY', 'False', '') - ] + ('REF.foo.1', '', ''), ('REF.foo.2.0', '1', ''), ('REF.foo.2.1', '2', '')] mv.dataset_selected = 'has_primary[DATA,1]' assert mv.has_primary diff --git a/jdaviz/configs/default/plugins/viewers.py b/jdaviz/configs/default/plugins/viewers.py index b81695f2eb..0b6aa546dd 100644 --- a/jdaviz/configs/default/plugins/viewers.py +++ b/jdaviz/configs/default/plugins/viewers.py @@ -122,7 +122,7 @@ def _get_layer_info(layer): for layer in self.state.layers[::-1]: layer_is_wcs_only = ( hasattr(layer.layer, 'meta') and - layer.layer.meta.get('WCS-ONLY', False) + layer.layer.meta.get(self.jdaviz_app._wcs_only_label, False) ) if layer.visible and not layer_is_wcs_only: prefix_icon, suffix = _get_layer_info(layer) diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index e00352affd..0bd331afaa 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -20,11 +20,6 @@ class Imviz(ImageConfigHelper): _default_configuration = 'imviz' _default_viewer_reference_name = "image-viewer" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.app._link_type = None - self.app._wcs_use_affine = None - def create_image_viewer(self, viewer_name=None): """Create a new image viewer. @@ -180,11 +175,22 @@ def load_data(self, data, data_label=None, do_link=True, show_in_viewer=True, ** # find the current label(s) - TODO: replace this by calling default label functionality # above instead of having to refind it - applied_labels = [label for label in self.app.data_collection.labels if label not in prev_data_labels] # noqa + applied_labels = [] + applied_visible = [] + for data in self.app.data_collection: + label = data.label + if label not in prev_data_labels: + applied_labels.append(label) + if not data.meta.get(self.app._wcs_only_label, False): + applied_visible.append(True) + else: + applied_visible.append(False) if show_in_viewer is True: show_in_viewer = f"{self.app.config}-0" + # NOTE: We will never try to batch load WCS-only, but if we do, add extra logic + # in batch_load within core/helpers.py module. if self._in_batch_load and show_in_viewer: for applied_label in applied_labels: self._delayed_show_in_viewer_labels[applied_label] = show_in_viewer @@ -198,8 +204,8 @@ def load_data(self, data, data_label=None, do_link=True, show_in_viewer=True, ** # NOTE: this will not add entries that were skipped with do_link=False # but the batch_load context manager will handle that logic if show_in_viewer: - for applied_label in applied_labels: - self.app.add_data_to_viewer(show_in_viewer, applied_label) + for applied_label, visible in zip(applied_labels, applied_visible): + self.app.add_data_to_viewer(show_in_viewer, applied_label, visible=visible) else: warnings.warn(AstropyDeprecationWarning("do_link=False is deprecated in v3.1 and will " "be removed in a future release. Use with " @@ -314,12 +320,14 @@ def layer_is_2d(layer): return isinstance(layer, BaseData) and layer.ndim == 2 +# NOTE: Sync with app._wcs_only_label as needed. def layer_is_image_data(layer): - return layer_is_2d(layer) and not layer.meta.get('WCS-ONLY', False) + return layer_is_2d(layer) and not layer.meta.get("_WCS_ONLY", False) +# NOTE: Sync with app._wcs_only_label as needed. def layer_is_wcs_only(layer): - return layer_is_2d(layer) and layer.meta.get('WCS-ONLY', False) + return layer_is_2d(layer) and layer.meta.get("_WCS_ONLY", False) def layer_is_table_data(layer): @@ -339,9 +347,7 @@ def get_reference_image_data(app): """ Return the reference data in the first image viewer and its index """ - viewer_reference = app._get_first_viewer_reference_name(require_image_viewer=True) - viewer = app.get_viewer(viewer_reference) - refdata = viewer.state.reference_data + refdata = app._jdaviz_helper.default_viewer.state.reference_data if refdata is not None: iref = app.data_collection.index(refdata) @@ -423,15 +429,17 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u else: link_plugin = None + data_already_linked = [] if link_type == app._link_type and wcs_use_affine == app._wcs_use_affine: - data_already_linked = [link.data2 for link in app.data_collection.external_links] + for link in app.data_collection.external_links: + if link.data1.label != app._wcs_only_label: + data_already_linked.append(link.data2) else: for viewer in app._viewer_store.values(): if len(viewer._marktags): raise ValueError(f"cannot change link_type (from '{app._link_type}' to " f"'{link_type}') when markers are present. " f" Clear markers with viewer.reset_markers() first") - data_already_linked = [] refdata, iref = get_reference_image_data(app) links_list = [] @@ -452,6 +460,7 @@ def link_image_data(app, link_type='pixels', wcs_fallback_scheme='pixels', wcs_u continue ids1 = data.pixel_component_ids + new_links = [] try: if link_type == 'pixels': new_links = [LinkSame(ids0[i], ids1[i]) for i in ndim_range] diff --git a/jdaviz/configs/imviz/plugins/parsers.py b/jdaviz/configs/imviz/plugins/parsers.py index 85908db97d..c79a166112 100644 --- a/jdaviz/configs/imviz/plugins/parsers.py +++ b/jdaviz/configs/imviz/plugins/parsers.py @@ -143,7 +143,10 @@ def get_image_data_iterator(app, file_obj, data_label, ext=None): data_iter = _hdu_to_glue_data(file_obj, data_label) elif isinstance(file_obj, NDData): - data_iter = _nddata_to_glue_data(file_obj, data_label) + if file_obj.meta.get(app._wcs_only_label, False): + data_iter = _wcsonly_to_glue_data(file_obj, data_label) + else: + data_iter = _nddata_to_glue_data(file_obj, data_label) elif isinstance(file_obj, np.ndarray): data_iter = _ndarray_to_glue_data(file_obj, data_label) @@ -174,7 +177,8 @@ def _parse_image(app, file_obj, data_label, ext=None): # for outside_*_bounding_box should also be updated. data.coords._orig_bounding_box = data.coords.bounding_box data.coords.bounding_box = None - data_label = app.return_data_label(data_label, alt_name="image_data") + if not data.meta.get(app._wcs_only_label, False): + data_label = app.return_data_label(data_label, alt_name="image_data") app.add_data(data, data_label) # Do not run link_image_data here. We do it at the end in Imviz.load_data() @@ -376,37 +380,17 @@ def _nddata_to_glue_data(ndd, data_label): if ndd.data.ndim != 2: raise ValueError(f'Imviz cannot load this NDData with ndim={ndd.data.ndim}') - for attrib, sub_attrib in zip( - ['data', 'mask', 'uncertainty'], - [None, None, 'array'] - ): + for attrib in ('data', 'mask', 'uncertainty'): arr = getattr(ndd, attrib) if arr is None: continue - cur_data = Data() + comp_label = attrib.upper() + cur_label = f'{data_label}[{comp_label}]' + cur_data = Data(label=cur_label) cur_data.meta.update(standardize_metadata(ndd.meta)) if ndd.wcs is not None: cur_data.coords = ndd.wcs raw_arr = arr - - if sub_attrib is not None: - # since NDDataArray.uncertainty may be an object like - # StdDevUncertainty, we need to take another attr - # like StdDevUncertainty.array: - base_arr = getattr(raw_arr, sub_attrib) - else: - base_arr = raw_arr - wcs_only = np.all(np.isnan(base_arr)) - - if 'WCS-ONLY' not in cur_data.meta or not cur_data.meta.get('WCS-ONLY'): - cur_data.meta.update({'WCS-ONLY': wcs_only}) - - cur_label = f'{data_label}' - comp_label = attrib.upper() - if not wcs_only: - cur_label += f'[{comp_label}]' - cur_data.label = cur_label - if attrib == 'data': bunit = ndd.unit or '' elif attrib == 'uncertainty': @@ -427,3 +411,16 @@ def _ndarray_to_glue_data(arr, data_label): component = Component.autotyped(arr) data.add_component(component=component, label='DATA') yield (data, data_label) + + +# ---- Functions that handle WCS-only data ----- + +def _wcsonly_to_glue_data(ndd, data_label): + """Return Data given NDData containing WCS-only data.""" + arr = ndd.data + data = Data(label=data_label) + data.meta.update(standardize_metadata(ndd.meta)) + data.coords = ndd.wcs + component = Component.autotyped(arr, units="") + data.add_component(component=component, label="DATA") + yield (data, data_label) diff --git a/jdaviz/configs/imviz/plugins/viewers.py b/jdaviz/configs/imviz/plugins/viewers.py index 46467b92a4..2d561826c6 100644 --- a/jdaviz/configs/imviz/plugins/viewers.py +++ b/jdaviz/configs/imviz/plugins/viewers.py @@ -293,16 +293,15 @@ def get_link_type(self, data_label): if len(self.session.application.data_collection) == 0: raise ValueError('No reference data for link look-up') - # the original links were created against data_collection[0], not necessarily - # against the current viewer reference_data - ref_label = self.session.application.data_collection[0].label + ref_label = self.state.reference_data.label if data_label == ref_label: return 'self' link_type = None for elink in self.session.application.data_collection.external_links: elink_labels = (elink.data1.label, elink.data2.label) - if data_label in elink_labels and ref_label in elink_labels: + if (data_label in elink_labels and + (ref_label in elink_labels or ref_label == self.jdaviz_app._wcs_only_label)): if isinstance(elink, LinkSame): # Assumes WCS link never uses LinkSame link_type = 'pixels' else: # If not pixels, must be WCS diff --git a/jdaviz/configs/imviz/tests/test_wcs_utils.py b/jdaviz/configs/imviz/tests/test_wcs_utils.py index 0cbdad7c02..aa9bd8a3de 100644 --- a/jdaviz/configs/imviz/tests/test_wcs_utils.py +++ b/jdaviz/configs/imviz/tests/test_wcs_utils.py @@ -1,15 +1,15 @@ -import pytest import gwcs import numpy as np +import pytest from astropy import units as u from astropy.coordinates import ICRS -from astropy.utils.data import get_pkg_data_filename from astropy.modeling import models from astropy.wcs import WCS from gwcs import coordinate_frames as cf from numpy.testing import assert_allclose from jdaviz.configs.imviz import wcs_utils +from jdaviz.configs.imviz.tests.utils import BaseImviz_WCS_GWCS def test_simple_fits_wcs(): @@ -108,58 +108,76 @@ def test_simple_gwcs(): assert not result[-1] -@pytest.mark.remote_data -@pytest.mark.filterwarnings(r"ignore::astropy.wcs.wcs.FITSFixedWarning") -def test_non_wcs_layer_labels(imviz_helper): - # load a real image w/ WCS: - real_image_path = get_pkg_data_filename('tutorials/FITS-images/HorseHead.fits') - imviz_helper.load_data(real_image_path) - - # load a WCS-only layer: - ndd = wcs_utils._get_rotated_nddata_from_label( - app=imviz_helper.app, - data_label=imviz_helper.app.data_collection[0].label, - rotation_angle=0*u.deg - ) - imviz_helper.load_data(ndd) - - # confirm that only the image is labeled: - assert len(imviz_helper.app.state.layer_icons) == 2 - - # confirm the WCS-only layer is logged: - viewer = imviz_helper.app.get_viewer('imviz-0') - assert len(viewer.state.wcs_only_layers) == 1 - - # load a second WCS-only layer: - ndd2 = wcs_utils._get_rotated_nddata_from_label( - app=imviz_helper.app, - data_label=imviz_helper.app.data_collection[0].label, - rotation_angle=45*u.deg - ) - imviz_helper.load_data(ndd2) - assert len(imviz_helper.app.state.layer_icons) == 3 - assert len(viewer.state.wcs_only_layers) == 2 - - wcs_only_refdata_icon = 'mdi-compass-outline' - wcs_only_not_refdata_icon = 'mdi-compass-off-outline' - - for i, data in enumerate(imviz_helper.app.data_collection): - viewer = imviz_helper.app.get_viewer("imviz-0") - - if i == 0: - # first entry is image data: - assert imviz_helper.app.state.layer_icons[data.label] == 'a' - assert viewer.state.reference_data.label == data.label - else: - # icon before setting as refdata: - before_icon = imviz_helper.app.state.layer_icons[data.label] - - # set as refdata: - imviz_helper.app._change_reference_data(data.label) - assert viewer.state.reference_data.label == data.label - - # icon after setting as refdata: - after_icon = imviz_helper.app.state.layer_icons[data.label] - - assert before_icon == wcs_only_not_refdata_icon - assert after_icon == wcs_only_refdata_icon +# TODO: Add more tests. +class TestWCSOnly(BaseImviz_WCS_GWCS): + + # TODO: Replace private API calls with more public ones when available. + def test_non_wcs_layer_labels(self): + self.imviz.link_data(link_type="wcs") + assert len(self.imviz.app.data_collection) == 3 + + # Load a WCS-only layer, bypassing normal labeling scheme. + ndd = wcs_utils._get_rotated_nddata_from_label( + app=self.imviz.app, + data_label="fits_wcs[DATA]", + rotation_angle=5 * u.deg + ) + self.imviz.load_data(ndd, data_label=self.imviz.app._wcs_only_label) + assert self.imviz.app.data_collection[3].label == self.imviz.app._wcs_only_label + + # Confirm that all data in collection are labeled. + assert len(self.imviz.app.state.layer_icons) == 4 # 3 + 1 + + # Confirm the WCS-only layer is logged. + assert len(self.viewer.state.wcs_only_layers) == 1 + + # Load a second WCS-only layer. + ndd2 = wcs_utils._get_rotated_nddata_from_label( + app=self.imviz.app, + data_label="fits_wcs[DATA]", + rotation_angle=45 * u.deg + ) + self.imviz.load_data(ndd2, data_label="rot: 45.00 deg") + assert self.imviz.app.data_collection[4].label == "rot: 45.00 deg" + + # Confirm that all data in collection are labeled. + assert len(self.imviz.app.data_collection) == 5 # 3 + 2 + assert len(self.imviz.app.state.layer_icons) == 5 + + # Confirm the WCS-only layer is logged. + assert len(self.viewer.state.wcs_only_layers) == 2 + + # First entry is image data and the default reference data. + assert self.imviz.app.state.layer_icons["fits_wcs[DATA]"] == "a" + assert self.viewer.state.reference_data.label == "fits_wcs[DATA]" + + wcs_only_refdata_icon = "mdi-compass-outline" + wcs_only_not_refdata_icon = "mdi-compass-off-outline" + + # Now we change the reference data. + for i in (3, 4): + data_label = self.imviz.app.data_collection[i].label + + # Icon before setting this WCS-only data as reference data. + assert self.imviz.app.state.layer_icons[data_label] == wcs_only_not_refdata_icon + + # Set it as reference data. + self.imviz.app._change_reference_data(data_label) + assert self.viewer.state.reference_data.label == data_label + + # Icon after setting it as reference data. + assert self.imviz.app.state.layer_icons[data_label] == wcs_only_refdata_icon + + # Change reference back to normal data. + self.imviz.app._change_reference_data("fits_wcs[DATA]") + assert self.viewer.state.reference_data.label == "fits_wcs[DATA]" + for i in (3, 4): + data_label = self.imviz.app.data_collection[i].label + assert self.imviz.app.state.layer_icons[data_label] == wcs_only_not_refdata_icon + + +def test_get_rotated_nddata_from_label_no_wcs(imviz_helper): + a = np.zeros((2, 2), dtype=np.int8) + imviz_helper.load_data(a, data_label="no_wcs") + with pytest.raises(ValueError, match=r".*has no WCS for rotation"): + wcs_utils._get_rotated_nddata_from_label(imviz_helper.app, "no_wcs", 0 * u.deg) diff --git a/jdaviz/configs/imviz/wcs_utils.py b/jdaviz/configs/imviz/wcs_utils.py index 64acfa684b..d791bdc602 100644 --- a/jdaviz/configs/imviz/wcs_utils.py +++ b/jdaviz/configs/imviz/wcs_utils.py @@ -3,7 +3,6 @@ # """This module handles calculations based on world coordinate system (WCS).""" -import warnings import base64 import math from io import BytesIO @@ -13,11 +12,9 @@ from astropy import units as u from astropy import coordinates as coord from astropy.coordinates import SkyCoord -from astropy.io import fits from astropy.modeling import models -from astropy.nddata import NDDataArray +from astropy.nddata import NDData from astropy.wcs.utils import proj_plane_pixel_scales -from astropy.wcs import WCS as FitsWCS, FITSFixedWarning from gwcs import coordinate_frames as cf from gwcs.wcs import WCS as GWCS @@ -288,18 +285,7 @@ def data_outside_gwcs_bounding_box(data, x, y): return outside_bounding_box -def _get_fits_wcs_from_file(filename, ext=None): - header = fits.getheader(filename, ext=ext) - with warnings.catch_warnings(): - # Ignore a warning on using DATE-OBS in place of MJD-OBS - warnings.filterwarnings('ignore', message="'datfix' made the change", - category=FITSFixedWarning) - wcs = FitsWCS(header) - - return wcs - - -def rotated_gwcs( +def _rotated_gwcs( center_world_coord, rotation_angle, pixel_scales, @@ -317,6 +303,7 @@ def rotated_gwcs( # "rescale" the pixel scales. Scaling constant was tuned so that the # synthetic image is about the same size on the sky as the input image + # for some arbitrary test data. If need re-tuning, ask Brett Morris. rescale_pixel_scale = np.array(refdata_shape) / 1000 shift_by_crpix = ( @@ -324,7 +311,7 @@ def rotated_gwcs( models.Shift(-refdata_shape[1] / 2 * u.pixel) ) - # multiplying by +/-1 can flip north/south or east/west + # Multiplying by +/-1 can flip north/south or east/west. flip_axes = models.Multiply(cdelt_signs[0]) & models.Multiply(cdelt_signs[1]) rotation = models.AffineTransformation2D( rotation_matrix * u.deg, translation=[0, 0] * u.deg @@ -369,16 +356,16 @@ def rotated_gwcs( return GWCS(pipeline) -def _prepare_rotated_nddata(image_data, wcs, rotation_angle, refdata_shape): +def _prepare_rotated_nddata(real_image_shape, wcs, rotation_angle, refdata_shape, + wcs_only_key="_WCS_ONLY"): # get the world coordinates of the central pixel - real_image_shape = np.array(np.shape(image_data)) - central_pixel_coord = real_image_shape / 2 * u.pix + central_pixel_coord = (np.array(real_image_shape) * 0.5) * u.pix central_world_coord = wcs.pixel_to_world(*central_pixel_coord) rotation_angle = coord.Angle(rotation_angle).wrap_at(360 * u.deg) # compute the x/y plate scales from the WCS: pixel_scales = [ - value * unit / u.pix + value * (unit / u.pix) for value, unit in zip( proj_plane_pixel_scales(wcs), wcs.wcs.cunit ) @@ -389,52 +376,23 @@ def _prepare_rotated_nddata(image_data, wcs, rotation_angle, refdata_shape): # create a GWCS centered on ``filename``, # and rotated by ``rotation_angle``: - new_rotated_gwcs = rotated_gwcs( + new_rotated_gwcs = _rotated_gwcs( central_world_coord, rotation_angle, pixel_scales, cdelt_signs ) - # create an all-nan NDDataArray with the rotated GWCS: - ndd = NDDataArray( - data=np.nan * np.ones(refdata_shape), + # create a fake NDData (we use arange so data boundaries show up in Imviz + # if it ever is accidentally exposed) with the rotated GWCS: + ndd = NDData( + data=np.arange(np.prod(refdata_shape), dtype=np.int8).reshape(refdata_shape), wcs=new_rotated_gwcs, + meta={wcs_only_key: True} ) return ndd -def _get_rotated_nddata_from_fits(filename, rotation_angle, refdata_shape=(2, 2), ext=None): +def _get_rotated_nddata_from_label(app, data_label, rotation_angle, refdata_shape=(2, 2)): """ - Create a synthetic NDDataArray which stores GWCS that approximate - the FITS WCS in ``filename`` rotated by ``rotation_angle``. - - This method is useful for ensuring that future datasets are loaded - in the correct orientation. - - Parameters - ---------- - filename : path-like, str - FITS file to use as reference - rotation_angle : `~astropy.units.Quantity` - Angle to rotate the image counter-clockwise from its - original orientation - refdata_shape : tuple - Shape of the reference data array - - Returns - ------- - ndd : `~astropy.nddata.NDDataArray` - Data are all NaNs, wcs are rotated. - """ - # get the FITS WCS from the file: - wcs = _get_fits_wcs_from_file(filename, ext=ext) - - return _prepare_rotated_nddata(wcs, rotation_angle, refdata_shape) - - -def _get_rotated_nddata_from_label( - app, data_label, rotation_angle, refdata_shape=(2, 2), main_component_idx=0 -): - """ - Create a synthetic NDDataArray which stores GWCS that approximate + Create a synthetic NDData which stores GWCS that approximate the WCS in the coords attr of the Data object with label ``data_label`` loaded into ``app``. @@ -444,24 +402,27 @@ def _get_rotated_nddata_from_label( Parameters ---------- app : `~jdaviz.Application` - App instance containing ``data_label`` + App instance containing ``data_label``. data_label : str - Data label for the Data to rotate + Data label for the Data to rotate. rotation_angle : `~astropy.units.Quantity` Angle to rotate the image counter-clockwise from its - original orientation + original orientation. refdata_shape : tuple - Shape of the reference data array + Shape of the reference data array. Returns ------- - ndd : `~astropy.nddata.NDDataArray` - Data are all NaNs, wcs are rotated. + ndd : `~astropy.nddata.NDData` + Contains rotated WCS and meaningless data. + + Raises + ------ + ValueError + Data has no WCS. """ - # get the WCS from the Data object's coords attribute: - [data] = [ - data for data in app.data_collection - if data.label == data_label - ] - image = data[data.main_components[main_component_idx]] - return _prepare_rotated_nddata(image, data.coords, rotation_angle, refdata_shape) + data = app.data_collection[data_label] + if data.coords is None: + raise ValueError(f"{data_label} has no WCS for rotation.") + return _prepare_rotated_nddata(data.shape, data.coords, rotation_angle, refdata_shape, + wcs_only_key=app._wcs_only_label) diff --git a/jdaviz/core/events.py b/jdaviz/core/events.py index 34c410713d..9e4cb6fa61 100644 --- a/jdaviz/core/events.py +++ b/jdaviz/core/events.py @@ -113,14 +113,13 @@ def viewer_id(self): class ChangeRefDataMessage(Message): - def __init__(self, data, viewer, viewer_id=None, old=None, new=None, *args, **kwargs): + def __init__(self, data, viewer, viewer_id=None, old=None, *args, **kwargs): super().__init__(*args, **kwargs) self._data = data self._viewer = viewer self._viewer_id = viewer_id self._old = old - self._new = new @property def data(self): @@ -138,10 +137,6 @@ def viewer_id(self): def old(self): return self._old - @property - def new(self): - return self._new - class SnackbarMessage(Message): def __init__(self, text, color=None, timeout=5000, loading=False, diff --git a/jdaviz/core/freezable_state.py b/jdaviz/core/freezable_state.py index 21a42ecf75..eb6bdf7b38 100644 --- a/jdaviz/core/freezable_state.py +++ b/jdaviz/core/freezable_state.py @@ -56,7 +56,7 @@ class FreezableBqplotImageViewerState(BqplotImageViewerState, FreezableState): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.wcs_only_layers = [] + self.wcs_only_layers = [] # For Imviz rotation use. def reset_limits(self, *event): if self.reference_data is None: # Nothing to do diff --git a/notebooks/concepts/imviz_rotation_by_hidden_layer.ipynb b/notebooks/concepts/imviz_rotation_by_hidden_layer.ipynb new file mode 100644 index 0000000000..a8fa1818c9 --- /dev/null +++ b/notebooks/concepts/imviz_rotation_by_hidden_layer.ipynb @@ -0,0 +1,451 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "dcc794dd", + "metadata": {}, + "source": [ + "This concept notebook is to showcase the underlying machinery that will be used for Imviz image rotation front-end in the future." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3ee8812", + "metadata": {}, + "outputs": [], + "source": [ + "import gwcs\n", + "import numpy as np\n", + "from astropy import units as u\n", + "from astropy.coordinates import ICRS\n", + "from astropy.modeling import models\n", + "from astropy.nddata import NDData\n", + "from astropy.wcs import WCS\n", + "from gwcs import coordinate_frames as cf\n", + "\n", + "from jdaviz import Imviz\n", + "from jdaviz.configs.imviz.wcs_utils import get_compass_info, _get_rotated_nddata_from_label" + ] + }, + { + "cell_type": "markdown", + "id": "f6d2f882", + "metadata": {}, + "source": [ + "These data are from `BaseImviz_WCS_GWCS` test class.\n", + "\n", + "Image without any WCS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12e67f95", + "metadata": {}, + "outputs": [], + "source": [ + "np.random.seed(42)\n", + "arr = np.random.random((10, 8)) # (ny, nx)\n", + "arr[0, 0] = 10 # Bright corner for sanity check" + ] + }, + { + "cell_type": "markdown", + "id": "934e4b33", + "metadata": {}, + "source": [ + "FITS WCS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4a41f91", + "metadata": {}, + "outputs": [], + "source": [ + "w_fits = WCS({'WCSAXES': 2, 'NAXIS1': 8, 'NAXIS2': 10,\n", + " 'CRPIX1': 5.0, 'CRPIX2': 5.0,\n", + " 'PC1_1': -1.14852e-05, 'PC1_2': 7.01477e-06,\n", + " 'PC2_1': 7.75765e-06, 'PC2_2': 1.20927e-05,\n", + " 'CDELT1': 1.0, 'CDELT2': 1.0,\n", + " 'CUNIT1': 'deg', 'CUNIT2': 'deg',\n", + " 'CTYPE1': 'RA---TAN', 'CTYPE2': 'DEC--TAN',\n", + " 'CRVAL1': 3.581704851882, 'CRVAL2': -30.39197867265,\n", + " 'LONPOLE': 180.0, 'LATPOLE': -30.39197867265,\n", + " 'MJDREF': 0.0, 'RADESYS': 'ICRS'})" + ] + }, + { + "cell_type": "markdown", + "id": "b27ab66d", + "metadata": {}, + "source": [ + "GWCS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57e7a476", + "metadata": {}, + "outputs": [], + "source": [ + "shift_by_crpix = models.Shift(-(5 - 1) * u.pix) & models.Shift(-(5 - 1) * u.pix)\n", + "matrix = np.array([[1.290551569736E-05, 5.9525007864732E-06],\n", + " [5.0226382102765E-06, -1.2644844123757E-05]])\n", + "rotation = models.AffineTransformation2D(matrix * u.deg, translation=[0, 0] * u.deg)\n", + "rotation.input_units_equivalencies = {\"x\": u.pixel_scale(1 * (u.deg / u.pix)),\n", + " \"y\": u.pixel_scale(1 * (u.deg / u.pix))}\n", + "rotation.inverse = models.AffineTransformation2D(np.linalg.inv(matrix) * u.pix,\n", + " translation=[0, 0] * u.pix)\n", + "rotation.inverse.input_units_equivalencies = {\"x\": u.pixel_scale(1 * (u.pix / u.deg)),\n", + " \"y\": u.pixel_scale(1 * (u.pix / u.deg))}\n", + "tan = models.Pix2Sky_TAN()\n", + "celestial_rotation = models.RotateNative2Celestial(\n", + " 3.581704851882 * u.deg, -30.39197867265 * u.deg, 180 * u.deg)\n", + "det2sky = shift_by_crpix | rotation | tan | celestial_rotation\n", + "det2sky.name = \"linear_transform\"\n", + "detector_frame = cf.Frame2D(name=\"detector\", axes_names=(\"x\", \"y\"), unit=(u.pix, u.pix))\n", + "sky_frame = cf.CelestialFrame(reference_frame=ICRS(), name='icrs', unit=(u.deg, u.deg))\n", + "pipeline = [(detector_frame, det2sky), (sky_frame, None)]\n", + "w_gwcs = gwcs.WCS(pipeline)\n", + "w_gwcs.bounding_box = ((0, 8), (0, 10)) * u.pix # x, y" + ] + }, + { + "cell_type": "markdown", + "id": "1f7c61f2", + "metadata": {}, + "source": [ + "Load data into Imviz:\n", + "\n", + "1. Data with FITS WCS and unit.\n", + "2. Data with GWCS (rotated w.r.t. FITS WCS) and no unit.\n", + "3. Data without WCS nor unit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4c0ef8db", + "metadata": {}, + "outputs": [], + "source": [ + "imviz = Imviz()\n", + "imviz.load_data(NDData(arr, wcs=w_fits, unit='electron/s'), data_label='fits_wcs')\n", + "imviz.load_data(NDData(arr, wcs=w_gwcs), data_label='gwcs')\n", + "imviz.load_data(arr, data_label='no_wcs')\n", + "imviz.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50abf5e3", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.default_viewer.zoom_level = \"fit\"" + ] + }, + { + "cell_type": "markdown", + "id": "b5790cbc", + "metadata": {}, + "source": [ + "Open up the Compass plugin to see where the celestial axes are, if any.\n", + "\n", + "Let's say we want N-up E-left orientation. We generate a fake data with the desired orientation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59bbfa67", + "metadata": {}, + "outputs": [], + "source": [ + "data = imviz.default_viewer.state.reference_data\n", + "print(data.label)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7ee76cb1", + "metadata": {}, + "outputs": [], + "source": [ + "degn = get_compass_info(data.coords, data.shape)[0] * u.deg\n", + "print(degn)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "609b67ec", + "metadata": {}, + "outputs": [], + "source": [ + "# FIXME: Is degn supposed to be pass-in as-is? How to make it N-up E-left?\n", + "# For the data in this notebook, pixels in viewer is flipped left-right compared\n", + "# to what you would expect from Compass plugin.\n", + "# Maybe we need some hardcoded WCS for N-up E-left/right instead of using _rotated_gwcs()\n", + "fake_ndd_rotated = _get_rotated_nddata_from_label(imviz.app, data.label, degn)" + ] + }, + { + "cell_type": "markdown", + "id": "200b334b", + "metadata": {}, + "source": [ + "Once we have made the Data object with the desired WCS, we can add it to the collection and also the viewer, but do not display it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5bc5add7", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.load_data(fake_ndd_rotated, data_label=imviz.app._wcs_only_label)" + ] + }, + { + "cell_type": "markdown", + "id": "840fb3ff", + "metadata": {}, + "source": [ + "Then, we make this Data object with the desired WCS a reference data. When link type is \"pixels\", you should not see any difference (no-op)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "caca4019", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app._change_reference_data(imviz.app._wcs_only_label)\n", + "print(imviz.default_viewer.state.reference_data.label)" + ] + }, + { + "cell_type": "markdown", + "id": "34bc933b", + "metadata": {}, + "source": [ + "If you want the other data with WCS to follow the orientation of this desired WCS, change the link type to \"wcs\". For data without any WCS, they will appear very weird because they are now linked by pixels to a Data with no real pixels.\n", + "\n", + "Due to the nature of linking, you have to reset the zoom to see the data again.\n", + "\n", + "(Dev note: Attempts to skip over Data without WCS for this step failed.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a5be30d", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "imviz.link_data(link_type=\"wcs\")\n", + "imviz.default_viewer.zoom_level = \"fit\"" + ] + }, + { + "cell_type": "markdown", + "id": "81d75b05", + "metadata": {}, + "source": [ + "You can run the following cell any time to inspect the current state of Imviz linking." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "367a5839", + "metadata": {}, + "outputs": [], + "source": [ + "for elink in imviz.app.data_collection.external_links:\n", + " elink_labels = (elink.data1.label, elink.data2.label)\n", + " print(elink_labels, elink.__class__.__name__, elink.cids1, elink.cids2)" + ] + }, + { + "cell_type": "markdown", + "id": "e86f8c3e", + "metadata": {}, + "source": [ + "Changing back to pixel linking should look as before we link with WCS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d701645", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.link_data(link_type=\"pixels\")\n", + "imviz.default_viewer.zoom_level = \"fit\"" + ] + }, + { + "cell_type": "markdown", + "id": "41075ed2", + "metadata": {}, + "source": [ + "Changing back to WCS linking and switching the reference data back to real data should look as if the fake data with WCS was never there." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59d5550f", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.link_data(link_type=\"wcs\")\n", + "imviz.app._change_reference_data(\"fits_wcs[DATA]\")" + ] + }, + { + "cell_type": "markdown", + "id": "d032b9e4", + "metadata": {}, + "source": [ + "In the multi-viewer case, the second viewer is allowed to have a different reference data than the default viewer.\n", + "\n", + "Additionally, the \"no_wcs\" case acts weird while blinking in the default viewer while \"_WCS_ONLY\" is set as the reference data in the default viewer and things are linked by WCS; i.e., the Compass would show you are seeing \"no_wcs\" but it no longer disappears from view (it disappeared in the single-viewer case above).\n", + "\n", + "(Dev note: Should we add a warning against using this kind of rotation with data without WCS and/or mult-viewer setup?)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f1095d82", + "metadata": {}, + "outputs": [], + "source": [ + "viewer_1 = imviz.create_image_viewer()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13dd91bf", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app.add_data_to_viewer(\"imviz-1\", \"fits_wcs[DATA]\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "908125dd", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app._change_reference_data(imviz.app._wcs_only_label)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e795d62e", + "metadata": {}, + "outputs": [], + "source": [ + "print(imviz.default_viewer.state.reference_data.label)\n", + "print(viewer_1.state.reference_data.label)" + ] + }, + { + "cell_type": "markdown", + "id": "77e1ca05", + "metadata": {}, + "source": [ + "Unlike the demo at https://gist.github.com/bmorris3/ee3af2e096fc869899280d645bb1b914, you would find it impossible to rotate the same data at two different angles at once. The best you could hope for is one viewer rotates the data to the desired WCS and the second viewer shows you the original data orientation. This is because that Gist was loading data at different angles as different data entries, which we would not do in production. (Though maybe this can be disproven as this feature is developed more; not sure.)" + ] + }, + { + "cell_type": "markdown", + "id": "df5e7458", + "metadata": {}, + "source": [ + "(Dev note: To avoid eating up precious links, when user wants a new rotation angle, we will overwrite the WCS in this fake data and re-link, if that is possible.)\n", + "\n", + "(Dev note: After this part, sometimes the viewer display looks very elongated though not always, but the Compass display seems fine. Not sure why.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1387e9d", + "metadata": {}, + "outputs": [], + "source": [ + "fake_ndd_rotated_2 = _get_rotated_nddata_from_label(imviz.app, \"fits_wcs[DATA]\", -90 * u.deg)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a52a1dc", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.app.data_collection[imviz.app._wcs_only_label].coords = fake_ndd_rotated_2.wcs\n", + "imviz.link_data(link_type=imviz.app._link_type, wcs_use_affine=imviz.app._wcs_use_affine, error_on_fail=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f2d7671", + "metadata": {}, + "outputs": [], + "source": [ + "imviz.default_viewer.zoom_level = \"fit\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a53d288f", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}