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: Add zoom and zoom_level API #744

Merged
merged 5 commits into from
Aug 2, 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
74 changes: 74 additions & 0 deletions jdaviz/configs/imviz/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,80 @@ def offset_by(self, dx, dy):
viewer.state.x_max = viewer.state.x_min + width
viewer.state.y_max = viewer.state.y_min + height

@property
def zoom_level(self):
"""Zoom level:

* 1 means real-pixel-size.
pllim marked this conversation as resolved.
Show resolved Hide resolved
* 2 means zoomed in by a factor of 2.
* 0.5 means zoomed out by a factor of 2.
* 'fit' means zoomed to fit the whole image width into display.

"""
viewer = self.app.get_viewer("viewer-1")
if viewer.shape is None: # pragma: no cover
raise ValueError('Viewer is still loading, try again later')

screenx = viewer.shape[1]
screeny = viewer.shape[0]
zoom_x = screenx / (viewer.state.x_max - viewer.state.x_min)
zoom_y = screeny / (viewer.state.y_max - viewer.state.y_min)

return max(zoom_x, zoom_y) # Similar to Ginga get_scale()

# Loosely based on glue/viewers/image/state.py
@zoom_level.setter
def zoom_level(self, val):
if ((not isinstance(val, (int, float)) and val != 'fit') or
(isinstance(val, (int, float)) and val <= 0)):
raise ValueError(f'Unsupported zoom level: {val}')

viewer = self.app.get_viewer("viewer-1")
image = viewer.state.reference_data
if (image is None or viewer.shape is None or
viewer.state.x_att is None or viewer.state.y_att is None): # pragma: no cover
return

# Zoom on X and Y will auto-adjust.
if val == 'fit':
# Similar to ImageViewerState.reset_limits() in Glue.
new_x_min = 0
new_x_max = image.shape[viewer.state.x_att.axis]
else:
cur_xcen = (viewer.state.x_min + viewer.state.x_max) * 0.5
new_dx = viewer.shape[1] * 0.5 / val
new_x_min = cur_xcen - new_dx
new_x_max = cur_xcen + new_dx

with delay_callback(viewer.state, 'x_min', 'x_max'):
viewer.state.x_min = new_x_min - 0.5
viewer.state.x_max = new_x_max - 0.5

# We need to adjust the limits in here to avoid triggering all
# the update events then changing the limits again.
viewer.state._adjust_limits_aspect()

# Discussion on why we need two different ways to set zoom at
# https://github.com/astropy/astrowidgets/issues/144
def zoom(self, val):
"""Zoom in or out by the given factor.

Parameters
----------
val : int or float
The zoom level to zoom the image.
See `zoom_level`.

Raises
------
ValueError
Invalid zoom factor.

"""
if not isinstance(val, (int, float)):
raise ValueError(f"zoom only accepts int or float but got '{val}'")
self.zoom_level = self.zoom_level * val
Copy link
Collaborator

Choose a reason for hiding this comment

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

If this is simply a method to set the zoom_level, it would would be nice to accept "fit" as input here as well as when setting zoom_level directly. That was one of the first things I tried and I was surprised that it didn't work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That would go against the current astrowidgets API upstream though...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

zoom() was supposed to be relative zoom, so "fit" does not really fit (pun?) in that paradigm.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah I see, that's the context I was missing, I guess. I didn't fully appreciate that zoom_level is setting the absolute zoom relative to the image, whereas zoom is setting the zoom relative to the current display.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can't blame you. I didn't either until astropy/astrowidgets#144


@property
def marker(self):
"""Marker to use.
Expand Down
53 changes: 53 additions & 0 deletions jdaviz/configs/imviz/tests/test_astrowidgets_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,59 @@ def test_center_offset_sky(self):
self.imviz.offset_by(dsky, dsky)


class TestZoom(BaseImviz_WCS_NoWCS):

@pytest.mark.parametrize('val', (0, -0.1, 'foo', [1, 2]))
def test_invalid_zoom_level(self, val):
with pytest.raises(ValueError, match='Unsupported zoom level'):
self.imviz.zoom_level = val

def test_invalid_zoom(self):
with pytest.raises(ValueError, match='zoom only accepts int or float'):
self.imviz.zoom('fit')

def assert_zoom_results(self, zoom_level, x_min, x_max, y_min, y_max, dpix):
assert_allclose(self.imviz.zoom_level, zoom_level)
assert_allclose((self.viewer.state.x_min, self.viewer.state.x_max,
self.viewer.state.y_min, self.viewer.state.y_max),
(x_min + dpix, x_max + dpix,
y_min + dpix, y_max + dpix))

@pytest.mark.parametrize('is_offcenter', (False, True))
def test_zoom(self, is_offcenter):
if is_offcenter:
self.imviz.center_on((0, 0))
dpix = -4.5
else:
self.imviz.center_on((4.5, 4.5))
dpix = 0

self.assert_zoom_results(10, -0.5, 9.5, -0.5, 9.5, dpix)

# NOTE: Not sure why X/Y min/max not exactly the same as aspect ratio 1
self.imviz.zoom_level = 1
self.assert_zoom_results(1, -46, 54, -45.5, 54.5, dpix)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does seem weird some x/y min/max not matching exactly for square data and display at aspect ratio one, but that adjustment is deep within glue core and I don't feel like digging no more. If @astrofrog has ideas, then great. Otherwise, I'll just leave it be.


self.imviz.zoom_level = 2
self.assert_zoom_results(2, -21.5, 28.5, -20.5, 29.5, dpix)

self.imviz.zoom(2)
self.assert_zoom_results(4, -9.5, 15.5, -8.0, 17.0, dpix)

self.imviz.zoom(0.5)
self.assert_zoom_results(2, -22.5, 27.5, -20.5, 29.5, dpix)

self.imviz.zoom_level = 0.5
self.assert_zoom_results(0.5, -98, 102, -95.5, 104.5, dpix)

# This fits the whole image on screen, regardless.
# NOTE: But somehow Y min/max auto-adjust does not work properly
# in the unit test when off-center. Works in notebook though.
if not is_offcenter:
self.imviz.zoom_level = 'fit'
self.assert_zoom_results(10, -0.5, 9.5, -0.5, 9.5, 0)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

For the off-center case, local testing showed that y_min and y_max was -5. , 5. instead of -0.5, 9.5 like X. Something not triggering here in the way I set up or hack around the viewer for unit testing. I swear to you it works properly in a notebook. If @astrofrog has ideas, then great. If not, I'll just leave this untested for the off-center case.



class TestMarkers(BaseImviz_WCS_NoWCS):

def test_invalid_markers(self):
Expand Down
80 changes: 42 additions & 38 deletions jdaviz/configs/imviz/tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,42 @@
import numpy as np
import pytest
from astropy.io import fits
from astropy.wcs import WCS

__all__ = ['BaseImviz_WCS_NoWCS']


class BaseImviz_WCS_NoWCS:
@pytest.fixture(autouse=True)
def setup_class(self, imviz_app):
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')
import numpy as np
import pytest
from astropy.io import fits
from astropy.wcs import WCS

__all__ = ['BaseImviz_WCS_NoWCS']


class BaseImviz_WCS_NoWCS:
@pytest.fixture(autouse=True)
def setup_class(self, imviz_app):
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')

# Since we are not really displaying, need this to test zoom.
self.viewer.shape = (100, 100)
pllim marked this conversation as resolved.
Show resolved Hide resolved
self.viewer.state._set_axes_aspect_ratio(1)
48 changes: 48 additions & 0 deletions notebooks/ImvizExample.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,54 @@
"imviz.offset_by(0.5 * u.arcsec, -1.5 * u.arcsec)"
]
},
{
"cell_type": "markdown",
"id": "e2744c0c-8721-480a-811e-87904373e66f",
"metadata": {},
"source": [
"You can programmatically zoom in and out.\n",
"\n",
"Zoom level:\n",
"\n",
"* 1 means real-pixel-size.\n",
"* 2 means zoomed in by a factor of 2.\n",
"* 0.5 means zoomed out by a factor of 2.\n",
"* 'fit' means zoomed to fit the whole image width into display."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "1bad9bdf-b959-4b74-9a77-07212095c2ce",
"metadata": {},
"outputs": [],
"source": [
"# Get the current zoom level.\n",
"imviz.zoom_level"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7a834bcf-7d5a-492b-bc54-740e1500a2f4",
"metadata": {},
"outputs": [],
"source": [
"# Set the zoom level directly.\n",
"imviz.zoom_level = 1"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "32288b81-1817-4549-b2e4-5e7f55b0ee3d",
"metadata": {},
"outputs": [],
"source": [
"# Set the relative zoom based on current zoom level.\n",
"imviz.zoom(2)"
]
},
{
"cell_type": "markdown",
"id": "11fab067-7428-4ce4-bc9b-f5462fe52e2a",
Expand Down