diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 7315fc0f61..b1d293bbea 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -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. + * 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 + @property def marker(self): """Marker to use. diff --git a/jdaviz/configs/imviz/tests/test_astrowidgets_api.py b/jdaviz/configs/imviz/tests/test_astrowidgets_api.py index 7c534acea8..f5f8331301 100644 --- a/jdaviz/configs/imviz/tests/test_astrowidgets_api.py +++ b/jdaviz/configs/imviz/tests/test_astrowidgets_api.py @@ -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) + + 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) + + class TestMarkers(BaseImviz_WCS_NoWCS): def test_invalid_markers(self): diff --git a/jdaviz/configs/imviz/tests/utils.py b/jdaviz/configs/imviz/tests/utils.py index 28cf0531ad..a69bb386a8 100644 --- a/jdaviz/configs/imviz/tests/utils.py +++ b/jdaviz/configs/imviz/tests/utils.py @@ -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) + self.viewer.state._set_axes_aspect_ratio(1) diff --git a/notebooks/ImvizExample.ipynb b/notebooks/ImvizExample.ipynb index 1e1bb5cf52..e8a98b3bd4 100644 --- a/notebooks/ImvizExample.ipynb +++ b/notebooks/ImvizExample.ipynb @@ -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",