Skip to content

Commit

Permalink
Excerpt from spacetelescope#1340
Browse files Browse the repository at this point in the history
that still does not work
  • Loading branch information
pllim committed Jan 21, 2023
1 parent 8f8106b commit 734333b
Show file tree
Hide file tree
Showing 13 changed files with 780 additions and 5 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ Cubeviz
Imviz
^^^^^

- New Simple Image Rotation plugin to rotate the celestial axes of
images if they have valid WCS. [#1972]

Mosviz
^^^^^^
- Reliably retrieves identifier using each datasets' metadata entry. [#1851]
Expand Down
22 changes: 22 additions & 0 deletions docs/imviz/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,28 @@ results are displayed under the :guilabel:`CALCULATE` button.
:ref:`Export Photometry <imviz_export_photometry>`
Documentation on exporting photometry results.

.. _rotate-image-simple:

Simple Image Rotation
=====================

.. warning::

Distortion is ignored, so using this plugin on distorted data is
not recommended.

This plugins rotates image(s) by its celestial axes by the given angle.
You can select data but the option only shows 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.
Click on the :guilabel:`RESET` to restore the original WCS.

Linking is global across Imviz, regardless of the viewer.
Therefore, when WCS is rotated, it will propagate to all
the viewers when multiple viewers are open.

.. _imviz-catalogs:

Catalog Search
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,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:

Expand Down
1 change: 1 addition & 0 deletions jdaviz/configs/imviz/imviz.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ tray:
- imviz-compass
- imviz-line-profile-xy
- imviz-aper-phot-simple
- imviz-rotate-image
- imviz-catalogs
- g-export-plot
viewer_area:
Expand Down
3 changes: 2 additions & 1 deletion jdaviz/configs/imviz/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
from .compass import * # noqa
from .aper_phot_simple import * # noqa
from .line_profile_xy import * # noqa
from .catalogs import * # noqa
from .catalogs import * # noqa
from .rotate_image import * # noqa
1 change: 1 addition & 0 deletions jdaviz/configs/imviz/plugins/rotate_image/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .rotate_image import * # noqa
72 changes: 72 additions & 0 deletions jdaviz/configs/imviz/plugins/rotate_image/rotate_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import numpy as np
from traitlets import Any

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 PluginTemplateMixin, DatasetSelectMixin

__all__ = ['RotateImageSimple']


@tray_registry('imviz-rotate-image', label="Simple Image Rotation")
class RotateImageSimple(PluginTemplateMixin, DatasetSelectMixin):
template_file = __file__, "rotate_image.vue"

angle = Any(0).tag(sync=True)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._orig_wcs_key = '_orig_wcs'
self._theta = 0 # degrees, clockwise

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_shape = self.dataset.selected_dc_item.shape
w_in = generate_rotated_wcs(self._theta, shape=w_shape)

# TODO: How to make this more robust?
# Match with selected data.
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()

# FIXME: This does not work -- We did not rotate the data.
# Store the original WCS, if not already.
if self._orig_wcs_key not in self.dataset.selected_dc_item.meta:
self.dataset.selected_dc_item.meta[self._orig_wcs_key] = self.dataset.selected_dc_item.coords # noqa: E501

# Update the WCS.
self.dataset.selected_dc_item.coords = w_in

except Exception as err: # pragma: no cover
self.hub.broadcast(SnackbarMessage(
f"Image rotation failed: {repr(err)}", color='error', sender=self))

def vue_reset_image(self, *args, **kwargs):
w_data = self.dataset.selected_dc_item.coords
if w_data is None: # Nothing to do
return

try:
if self._orig_wcs_key in self.dataset.selected_dc_item.meta:
self.dataset.selected_dc_item.coords = self.dataset.selected_dc_item.meta[self._orig_wcs_key] # noqa: E501

except Exception as err: # pragma: no cover
self.hub.broadcast(SnackbarMessage(
f"Image rotation reset failed: {repr(err)}", color='error', sender=self))
32 changes: 32 additions & 0 deletions jdaviz/configs/imviz/plugins/rotate_image/rotate_image.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<template>
<j-tray-plugin>
<v-row>
<j-docs-link :link="'https://jdaviz.readthedocs.io/en/'+vdocs+'/'+config+'/plugins.html#simple-image-rotation'">Rotate image N-up/E-left.</j-docs-link>
</v-row>

<plugin-dataset-select
:items="dataset_items"
:selected.sync="dataset_selected"
:show_if_single_entry="false"
label="Data"
hint="Select the data to rotate."
/>

<v-row>
<v-col>
<v-text-field
v-model="angle"
type="number"
label="Angle"
hint="Rotation angle of N-axis in degrees clockwise (0 is N-up)"
></v-text-field>
</v-col>
</v-row>

<v-row justify="end">
<v-btn color="primary" text @click="rotate_image">Rotate</v-btn>
<v-btn color="primary" text @click="reset_image">Reset</v-btn>
</v-row>

</j-tray-plugin>
</template>
92 changes: 92 additions & 0 deletions jdaviz/configs/imviz/tests/test_simple_image_rotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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.plugins['Simple Image Rotation']._obj
plg.plugin_opened = True

# Select data.
assert plg.dataset.selected == 'fits_wcs[DATA]'

# Rotate with default settings.
plg.vue_rotate_image()
assert_allclose(plg._theta, 0)
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.
8 changes: 8 additions & 0 deletions jdaviz/configs/imviz/tests/test_wcs_utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
50 changes: 46 additions & 4 deletions jdaviz/configs/imviz/wcs_utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,10 +8,55 @@
import numpy as np
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.wcs import WCS
from matplotlib.patches import Polygon

__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).
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


# This is 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):
"""
Expand Down
28 changes: 28 additions & 0 deletions licenses/MPDAF_LICENSE.txt
Original file line number Diff line number Diff line change
@@ -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.
Loading

0 comments on commit 734333b

Please sign in to comment.