Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Imviz: astrowidget API for center_on and offset_to #687

Merged
merged 6 commits into from
Jun 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions jdaviz/configs/imviz/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@
import re
from copy import deepcopy

from astropy.coordinates import SkyCoord
from astropy.utils.introspection import minversion
from astropy.wcs import NoConvergence
from astropy.wcs.wcsapi import BaseHighLevelWCS
from echo import delay_callback
from glue.core import BaseData

from jdaviz.core.events import SnackbarMessage
from jdaviz.core.helpers import ConfigHelper

__all__ = ['Imviz']

ASTROPY_LT_4_3 = not minversion('astropy', '4.3')


class Imviz(ConfigHelper):
"""Imviz Helper class"""
Expand Down Expand Up @@ -79,6 +89,102 @@ def load_data(self, data, parser_reference=None, **kwargs):
self.app.load_data(
data, parser_reference=parser_reference, **kwargs)

def center_on(self, point):
"""Centers the view on a particular point.

Parameters
----------
point : tuple or `~astropy.coordinates.SkyCoord`
If tuple of ``(X, Y)`` is given, it is assumed
to be in data coordinates and 0-indexed.

Raises
------
AttributeError
Sky coordinates are given but image does not have a valid WCS.

"""
viewer = self.app.get_viewer("viewer-1")

if isinstance(point, SkyCoord):
i_top = get_top_layer_index(viewer)
image = viewer.layers[i_top].layer
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should probably use image = viewer.reference_data as that is the one which is used to define the pixel space

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your suggestion does not seem to work: AttributeError: 'ImvizImageView' object has no attribute 'reference_data'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

viewer.state.reference_data exists but it causes my test to fail:

        # Blink to the one without WCS
        self.viewer.blink_once()
    
        with pytest.raises(AttributeError, match='does not have a valid WCS'):
>           self.imviz.center_on(sky)
E           Failed: DID NOT RAISE <class 'AttributeError'>

jdaviz/configs/imviz/tests/test_astrowidgets_api.py:83: Failed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears reference_data does not keep track of which layer is actually visible to the user after blinking.

if hasattr(image, 'coords') and isinstance(image.coords, BaseHighLevelWCS):
try:
pix = image.coords.world_to_pixel(point) # 0-indexed X, Y
except NoConvergence as e: # pragma: no cover
self.app.hub.broadcast(SnackbarMessage(
f'{point} is likely out of bounds: {repr(e)}',
color="warning", sender=self.app))
return
else:
raise AttributeError(f'{image.label} does not have a valid WCS')
else:
pix = point

# Disallow centering outside of display.
if (pix[0] < viewer.state.x_min or pix[0] > viewer.state.x_max
or pix[1] < viewer.state.y_min or pix[1] > viewer.state.y_max):
self.app.hub.broadcast(SnackbarMessage(
f'{pix} is out of bounds', color="warning", sender=self.app))
return

with delay_callback(viewer.state, 'x_min', 'x_max', 'y_min', 'y_max'):
width = viewer.state.x_max - viewer.state.x_min
height = viewer.state.y_max - viewer.state.y_min
viewer.state.x_min = pix[0] - (width * 0.5)
viewer.state.y_min = pix[1] - (height * 0.5)
viewer.state.x_max = viewer.state.x_min + width
viewer.state.y_max = viewer.state.y_min + height

def offset_to(self, dx, dy, skycoord_offset=False):
"""Move the center to a point that is given offset
away from the current center.

Parameters
----------
dx, dy : float or `~astropy.units.Quantity`
Offset value. The presence of unit depends on ``skycoord_offset``.

skycoord_offset : bool
If `True`, offset (lon, lat) must be given as ``Quantity``.
Otherwise, they are in pixel values (float).

Raises
------
AttributeError
Sky offset is given but image does not have a valid WCS.

"""
viewer = self.app.get_viewer("viewer-1")
width = viewer.state.x_max - viewer.state.x_min
height = viewer.state.y_max - viewer.state.y_min

if skycoord_offset:
i_top = get_top_layer_index(viewer)
image = viewer.layers[i_top].layer
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as above

if hasattr(image, 'coords') and isinstance(image.coords, BaseHighLevelWCS):
# To avoid distortion headache, assume offset is relative to
# displayed center.
x_cen = viewer.state.x_min + (width * 0.5)
y_cen = viewer.state.y_min + (height * 0.5)
sky_cen = image.coords.pixel_to_world(x_cen, y_cen)
if ASTROPY_LT_4_3:
from astropy.coordinates import SkyOffsetFrame
new_sky_cen = sky_cen.__class__(
SkyOffsetFrame(dx, dy, origin=sky_cen.frame).transform_to(sky_cen))
else:
new_sky_cen = sky_cen.spherical_offsets_by(dx, dy)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This was added in astropy/astropy#11635

self.center_on(new_sky_cen)
else:
raise AttributeError(f'{image.label} does not have a valid WCS')
else:
with delay_callback(viewer.state, 'x_min', 'x_max', 'y_min', 'y_max'):
viewer.state.x_min += dx
viewer.state.y_min += dy
viewer.state.x_max = viewer.state.x_min + width
viewer.state.y_max = viewer.state.y_min + height


def split_filename_with_fits_ext(filename):
"""Split a ``filename[ext]`` input into filename and FITS extension.
Expand Down Expand Up @@ -125,3 +231,12 @@ def split_filename_with_fits_ext(filename):
data_label = os.path.basename(s[0])

return filepath, ext, data_label


def get_top_layer_index(viewer):
"""Get index of the top visible 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 isinstance(lyr.layer, BaseData)][-1]
8 changes: 3 additions & 5 deletions jdaviz/configs/imviz/plugins/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from echo import delay_callback

from glue.config import viewer_tool
from glue.core import BaseData
from glue_jupyter.bqplot.common.tools import Tool
from glue.viewers.common.tool import CheckableTool
from glue.plugins.wcs_autolinking.wcs_autolinking import wcs_autolink, WCSLink
Expand Down Expand Up @@ -119,6 +118,7 @@ def deactivate(self):
self.viewer.remove_event_callback(self.on_mouse_or_key_event)

def on_mouse_or_key_event(self, data):
from jdaviz.configs.imviz.helper import get_top_layer_index

event = data['event']

Expand All @@ -141,8 +141,7 @@ def on_mouse_or_key_event(self, data):
y = event_y / (self.viewer.state.y_max - self.viewer.state.y_min)

# When blinked, first layer might not be top layer
i_top = [i for i, lyr in enumerate(self.viewer.layers)
if lyr.visible and isinstance(lyr.layer, BaseData)][-1]
i_top = get_top_layer_index(self.viewer)
state = self.viewer.layers[i_top].state

# https://github.com/glue-viz/glue/blob/master/glue/viewers/image/qt/contrast_mouse_mode.py
Expand All @@ -154,8 +153,7 @@ def on_mouse_or_key_event(self, data):

elif event == 'dblclick':
# When blinked, first layer might not be top layer
i_top = [i for i, lyr in enumerate(self.viewer.layers)
if lyr.visible and isinstance(lyr.layer, BaseData)][-1]
i_top = get_top_layer_index(self.viewer)
state = self.viewer.layers[i_top].state

# Restore defaults that are applied on load
Expand Down
6 changes: 6 additions & 0 deletions jdaviz/configs/imviz/plugins/viewers.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ def on_mouse_or_key_event(self, data):
x = data['domain']['x']
y = data['domain']['y']

if x is None or y is None: # Out of bounds
self.label_mouseover.pixel = ""
self.label_mouseover.world = ""
self.label_mouseover.value = ""
return

maxsize = int(np.ceil(np.log10(np.max(image.shape)))) + 3

fmt = 'x={0:0' + str(maxsize) + '.1f} y={1:0' + str(maxsize) + '.1f}'
Expand Down
86 changes: 86 additions & 0 deletions jdaviz/configs/imviz/tests/test_astrowidgets_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import numpy as np
import pytest
from astropy import units as u
from astropy.io import fits
from astropy.wcs import WCS
from numpy.testing import assert_allclose


class TestAstrowidgetsAPI:
@pytest.fixture(autouse=True)
def setup_class(self, imviz_app):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This is basically the same test setup in #630 . When we get back to the other PR, will have to see how to consolidate this to avoid code duplication.

hdu = fits.ImageHDU(np.zeros((10, 10)), name='SCI')

# Apply some celestial WCS from
# https://learn.astropy.org/rst-tutorials/celestial_coords1.html
hdu.header.update({'CTYPE1': 'RA---TAN',
'CUNIT1': 'deg',
'CDELT1': -0.0002777777778,
'CRPIX1': 1,
'CRVAL1': 337.5202808,
'NAXIS1': 10,
'CTYPE2': 'DEC--TAN',
'CUNIT2': 'deg',
'CDELT2': 0.0002777777778,
'CRPIX2': 1,
'CRVAL2': -20.833333059999998,
'NAXIS2': 10})

# Data with WCS
imviz_app.load_data(hdu, data_label='has_wcs')

# Data without WCS
imviz_app.load_data(hdu, data_label='no_wcs')
imviz_app.app.data_collection[1].coords = None

self.wcs = WCS(hdu.header)
self.imviz = imviz_app
self.viewer = imviz_app.app.get_viewer('viewer-1')

def test_center_offset_pixel(self):
self.imviz.center_on((0, 1))
assert_allclose(self.viewer.state.x_min, -5)
assert_allclose(self.viewer.state.y_min, -4)
assert_allclose(self.viewer.state.x_max, 5)
assert_allclose(self.viewer.state.y_max, 6)

self.imviz.offset_to(1, -1)
assert_allclose(self.viewer.state.x_min, -4)
assert_allclose(self.viewer.state.y_min, -5)
assert_allclose(self.viewer.state.x_max, 6)
assert_allclose(self.viewer.state.y_max, 5)

def test_center_offset_sky(self):
# Blink to the one with WCS because the last loaded data is shown.
self.viewer.blink_once()

sky = self.wcs.pixel_to_world(0, 1)
self.imviz.center_on(sky)
assert_allclose(self.viewer.state.x_min, -5)
assert_allclose(self.viewer.state.y_min, -4)
assert_allclose(self.viewer.state.x_max, 5)
assert_allclose(self.viewer.state.y_max, 6)

dsky = 0.1 * u.arcsec
self.imviz.offset_to(dsky, dsky, skycoord_offset=True)
assert_allclose(self.viewer.state.x_min, -5.100000000142565)
assert_allclose(self.viewer.state.y_min, -3.90000000002971)
assert_allclose(self.viewer.state.x_max, 4.899999999857435)
assert_allclose(self.viewer.state.y_max, 6.09999999997029)

# astropy requires Quantity
with pytest.raises(u.UnitTypeError):
self.imviz.offset_to(0.1, 0.1, skycoord_offset=True)

# Cannot pass Quantity without specifying skycoord_offset=True
with pytest.raises(u.UnitConversionError):
self.imviz.offset_to(dsky, dsky)

# Blink to the one without WCS
self.viewer.blink_once()

with pytest.raises(AttributeError, match='does not have a valid WCS'):
self.imviz.center_on(sky)

with pytest.raises(AttributeError, match='does not have a valid WCS'):
self.imviz.offset_to(dsky, dsky, skycoord_offset=True)
7 changes: 1 addition & 6 deletions jdaviz/configs/imviz/tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from astropy.utils.data import download_file
from astropy.wcs import WCS

from jdaviz.configs.imviz.helper import Imviz, split_filename_with_fits_ext
from jdaviz.configs.imviz.helper import split_filename_with_fits_ext
from jdaviz.configs.imviz.plugins.parsers import (
HAS_JWST_ASDF, parse_data, _validate_fits_image2d, _validate_bunit,
_parse_image)
Expand All @@ -18,11 +18,6 @@
HAS_SKIMAGE = False


@pytest.fixture
def imviz_app():
return Imviz()


@pytest.mark.parametrize(
('filename', 'ans'),
[('/path/to/cache/contents', ['/path/to/cache/contents', None, 'contents']),
Expand Down
7 changes: 6 additions & 1 deletion jdaviz/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import numpy as np
from specutils import Spectrum1D
from jdaviz.configs.specviz.helper import SpecViz

from jdaviz.configs.imviz.helper import Imviz

SPECTRUM_SIZE = 10 # length of spectrum

Expand Down Expand Up @@ -45,6 +45,11 @@ def spectrum1d():
return Spectrum1D(spectral_axis=spec_axis, flux=flux, uncertainty=uncertainty)


@pytest.fixture
def imviz_app():
return Imviz()


try:
from pytest_astropy_header.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS
except ImportError:
Expand Down
Loading