Skip to content

Commit f1c8807

Browse files
authored
initial implementation of astro image display api (AIDA) (#3622)
* initial implementation of astro image display api (AIDA) * adapt to astropy/astro-image-display-api#56 * addressing comments on #3622 * use zoom_radius where possible * use zoom_radius in set_viewport * delay callback on set_viewport, remove changelog entry
1 parent 1e9288d commit f1c8807

File tree

5 files changed

+426
-1
lines changed

5 files changed

+426
-1
lines changed

CHANGES.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ New Features
3333

3434
- Allow custom resolutions when exporting viewers to png or mp4. [#3478]
3535

36-
3736
Cubeviz
3837
^^^^^^^
3938

jdaviz/configs/default/aida.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
from echo import delay_callback
2+
3+
import numpy as np
4+
import astropy.units as u
5+
from astropy.coordinates import SkyCoord, Angle
6+
from astropy.wcs import WCS
7+
from gwcs.wcs import WCS as GWCS
8+
9+
from jdaviz.utils import get_top_layer_index
10+
11+
12+
class AID:
13+
"""
14+
Common API methods for image viewers in astronomy, called
15+
the Astro Image Display API (AIDA)[1]_.
16+
17+
References
18+
----------
19+
.. [1] https://github.com/astropy/astro-image-display-api/
20+
21+
"""
22+
23+
def __init__(self, viewer):
24+
self.viewer = viewer
25+
self.app = viewer.jdaviz_app
26+
27+
def _get_image_glue_data(self, image_label):
28+
if image_label is None:
29+
i_top = get_top_layer_index(self.viewer)
30+
image = self.viewer.layers[i_top].layer
31+
32+
else:
33+
for lyr in self.viewer.layers:
34+
image = lyr.layer
35+
if image.label == image_label:
36+
break
37+
else:
38+
raise ValueError(f"No data with data_label `{image_label}` found in viewer.")
39+
40+
return image, image.label
41+
42+
def set_viewport(self, center=None, fov=None, image_label=None, **kwargs):
43+
"""
44+
Parameters
45+
----------
46+
center : `~astropy.coordinates.SkyCoord` or tuple of floats
47+
Center the viewer on this coordinate.
48+
49+
fov : `~astropy.units.Quantity` or tuple of floats
50+
Set the width of the viewport to span `field_of_view`.
51+
52+
image_label : str
53+
Set the viewport with respect to the image
54+
with the data label: ``image_label``.
55+
"""
56+
image, image_label = self._get_image_glue_data(image_label)
57+
imviz_aligned_by_wcs = self.app._align_by == 'wcs'
58+
59+
with delay_callback(
60+
self.viewer.state,
61+
'x_min', 'x_max', 'y_min', 'y_max',
62+
'zoom_center_x', 'zoom_center_y', 'zoom_radius'
63+
):
64+
if center is not None:
65+
if isinstance(center, SkyCoord):
66+
if imviz_aligned_by_wcs:
67+
(
68+
self.viewer.state.zoom_center_x,
69+
self.viewer.state.zoom_center_y
70+
) = center.ra.degree, center.dec.degree
71+
else:
72+
reference_wcs = self.viewer.state.reference_data.coords
73+
74+
if isinstance(reference_wcs, GWCS):
75+
reference_wcs = WCS(reference_wcs.to_fits_sip())
76+
77+
(
78+
self.viewer.state.zoom_center_x,
79+
self.viewer.state.zoom_center_y
80+
) = reference_wcs.world_to_pixel(center)
81+
82+
elif hasattr(center, '__len__') and isinstance(center[0], (float, int)):
83+
(
84+
self.viewer.state.zoom_center_x,
85+
self.viewer.state.zoom_center_y
86+
) = center
87+
else:
88+
raise ValueError(
89+
"The AID API supports `center` arguments as SkyCoords or as "
90+
f"a tuple of floats in pixel coordinates, got {center=}."
91+
)
92+
93+
if fov is not None:
94+
if isinstance(fov, (u.Quantity, Angle)):
95+
current_fov = self._get_current_fov('sky', image_label)
96+
scale_factor = float(fov / current_fov)
97+
98+
elif isinstance(fov, (float, int)):
99+
current_fov = self._get_current_fov('pixel', image_label)
100+
scale_factor = float(fov / current_fov)
101+
102+
else:
103+
raise ValueError(
104+
f"`fov` must be a Quantity or tuple of floats, got {fov=}"
105+
)
106+
107+
self.viewer.state.zoom_radius = self.viewer.state.zoom_radius * scale_factor
108+
109+
def _mean_pixel_scale(self, data):
110+
"""get the mean of the x and y pixel scales from the low level wcs"""
111+
wcs = data.coords
112+
113+
# for now, convert GWCS to FITS SIP so pixel to world
114+
# transformations can be done outside of the bounding box
115+
if isinstance(wcs, GWCS):
116+
wcs = WCS(wcs.to_fits_sip())
117+
118+
abs_cdelts = u.Quantity([
119+
abs(cdelt) * u.Unit(cunit)
120+
for cdelt, cunit in zip(wcs.wcs.cdelt, wcs.wcs.cunit)
121+
])
122+
return np.mean(abs_cdelts)
123+
124+
def _get_current_fov(self, sky_or_pixel, image_label):
125+
imviz_aligned_by_wcs = self.app._align_by == 'wcs'
126+
127+
# `zoom_radius` is the distance from the center of the viewer
128+
# to the nearest edge in units of pixels
129+
zoom_radius = self.viewer.state.zoom_radius
130+
131+
# default to 'sky' if sky/pixel not specified and WCS is available:
132+
if sky_or_pixel in (None, 'sky'):
133+
if not imviz_aligned_by_wcs:
134+
ref_data, _ = self._get_image_glue_data(image_label)
135+
pixel_scale = self._mean_pixel_scale(ref_data)
136+
return pixel_scale * 2 * zoom_radius * u.deg
137+
else:
138+
return 2 * zoom_radius * u.deg
139+
140+
return 2 * zoom_radius
141+
142+
def _get_current_center(self, sky_or_pixel, image_label=None):
143+
# center pixel coordinates on the reference data:
144+
center_x = self.viewer.state.zoom_center_x
145+
center_y = self.viewer.state.zoom_center_y
146+
147+
if self.app._align_by == 'wcs':
148+
reference_data = self.viewer.state.reference_data
149+
else:
150+
reference_data, image_label = self._get_image_glue_data(image_label)
151+
152+
reference_wcs = reference_data.coords
153+
154+
# # if the image data have WCS, get the center sky coordinate:
155+
if sky_or_pixel == 'sky':
156+
if self.app._align_by == 'wcs':
157+
center = self.viewer._get_center_skycoord()
158+
else:
159+
center = reference_wcs.pixel_to_world(center_x, center_y)
160+
else:
161+
center = (center_x, center_y)
162+
163+
return center
164+
165+
def get_viewport(self, sky_or_pixel=None, image_label=None, **kwargs):
166+
"""
167+
sky_or_pixel : str, optional
168+
If 'sky', the center will be returned as a `SkyCoord` object.
169+
If 'pixel', the center will be returned as a tuple of pixel coordinates.
170+
If `None`, the default behavior is to return the center as a `SkyCoord` if
171+
possible, or as a tuple of floats if the image is in pixel coordinates and has
172+
no WCS information.
173+
174+
image_label : str, optional
175+
The label of the image to get the viewport for. If not given and there is only one
176+
image loaded, the viewport for that image is returned. If there are multiple images
177+
and no label is provided, an error is raised.
178+
179+
Returns
180+
-------
181+
dict
182+
A dictionary containing the current viewport settings.
183+
The keys are 'center', 'fov', and 'image_label'.
184+
- 'center' is an `astropy.coordinates.SkyCoord` object or a tuple of floats.
185+
- 'fov' is an `astropy.units.Quantity` object or a float.
186+
- 'image_label' is a string representing the label of the image.
187+
"""
188+
image, image_label = self._get_image_glue_data(image_label)
189+
190+
return dict(
191+
center=self._get_current_center(sky_or_pixel=sky_or_pixel, image_label=image_label),
192+
fov=self._get_current_fov(sky_or_pixel=sky_or_pixel, image_label=image_label),
193+
image_label=image_label
194+
)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import pytest
2+
import numpy as np
3+
import astropy.units as u
4+
from astropy.coordinates import SkyCoord
5+
from astropy.wcs import WCS
6+
from astropy.tests.helper import assert_quantity_allclose
7+
8+
9+
def assert_coordinate_close(coord1, coord2, atol=1 * u.arcsec):
10+
# check that two coordinates are within some separation tolerance
11+
separation = coord1.separation(coord2)
12+
assert_quantity_allclose(separation, desired=0*u.arcsec, atol=atol)
13+
14+
15+
def assert_angle_close(angle1, angle2, atol=1 * u.arcsec):
16+
# check that two angles are within some separation tolerance
17+
difference = abs(angle1 - angle2)
18+
assert_quantity_allclose(difference, desired=0*u.arcsec, atol=atol)
19+
20+
21+
def test_get_viewport_sky(imviz_helper, image_hdu_wcs):
22+
imviz_helper.load_data(image_hdu_wcs)
23+
imviz_helper.plugins['Orientation'].align_by = 'WCS'
24+
25+
viewer = imviz_helper.app.get_viewer('imviz-0')
26+
viewport = viewer.aid.get_viewport('sky')
27+
28+
expected_center = SkyCoord(ra=337.51894337, dec=-20.83208305, unit='deg')
29+
expected_fov = 0.00277778 * u.deg
30+
expected_image_label = imviz_helper.app.data_collection[0].label
31+
32+
# by default, the viewer y-axis is the narrower axis, which is used to
33+
# define the FOV parameter. Use the WCS to find what the actual
34+
# viewport dimensions are in the x-axis, which maps onto RA in this case:
35+
wcs = WCS(image_hdu_wcs.header)
36+
dec_unit = u.Unit(wcs.wcs.cunit[1])
37+
delta_dec = abs(wcs.wcs.cdelt[1]) * dec_unit
38+
expected_fov = (
39+
abs(viewer.state.y_max - viewer.state.y_min) * delta_dec
40+
)
41+
assert_coordinate_close(viewport['center'], expected_center)
42+
assert_angle_close(viewport['fov'], expected_fov)
43+
44+
assert viewport['image_label'] == expected_image_label
45+
46+
47+
def test_set_viewport_sky(imviz_helper, image_hdu_wcs):
48+
imviz_helper.load_data(image_hdu_wcs)
49+
imviz_helper.plugins['Orientation'].align_by = 'WCS'
50+
viewer = imviz_helper.app.get_viewer('imviz-0')
51+
52+
# change only the center:
53+
new_viewport_settings = dict(
54+
center=SkyCoord(ra=337.5, dec=-20.8, unit='deg'),
55+
fov=0.01 * u.deg
56+
)
57+
viewer.aid.set_viewport(**new_viewport_settings)
58+
new_viewport = viewer.aid.get_viewport('sky')
59+
60+
assert_coordinate_close(new_viewport['center'], new_viewport_settings['center'])
61+
62+
# todo: investigate why this tolerance needs to be larger than expected:
63+
assert_angle_close(new_viewport['fov'], new_viewport_settings['fov'], atol=1 * u.arcsec)
64+
65+
with pytest.raises(ValueError, match='The AID API supports `center` arguments as'):
66+
viewer.aid.set_viewport(center=u.Quantity([0, 1]))
67+
68+
69+
def test_set_viewport_pixel(imviz_helper, image_hdu_wcs):
70+
imviz_helper.load_data(image_hdu_wcs)
71+
72+
viewer = imviz_helper.app.get_viewer('imviz-0')
73+
74+
# change only the center:
75+
new_viewport_settings = dict(
76+
center=[5, 5],
77+
fov=10
78+
)
79+
viewer.aid.set_viewport(**new_viewport_settings)
80+
new_viewport = viewer.aid.get_viewport('pixel')
81+
82+
np.testing.assert_allclose(new_viewport['center'], new_viewport_settings['center'])
83+
84+
# todo: investigate why this tolerance needs to be larger than expected:
85+
np.testing.assert_allclose(new_viewport['fov'], new_viewport_settings['fov'], atol=1e-4)

jdaviz/configs/imviz/plugins/viewers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from glue_jupyter.bqplot.image import BqplotImageView
88

99
from jdaviz.configs.imviz import wcs_utils
10+
from jdaviz.configs.default import aida
1011
from jdaviz.core.astrowidgets_api import AstrowidgetsImageViewerMixin
1112
from jdaviz.core.events import SnackbarMessage
1213
from jdaviz.core.registries import viewer_registry
@@ -66,6 +67,7 @@ def __init__(self, *args, **kwargs):
6667
self.state.image_external_padding = 0.5
6768

6869
self.data_menu._obj.dataset.add_filter('is_image')
70+
self.aid = aida.AID(self)
6971

7072
def on_mouse_or_key_event(self, data):
7173
active_image_layer = self.active_image_layer

0 commit comments

Comments
 (0)