From 2982671bfac8fff49b54ffc7659283e74f52a497 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Thu, 22 Jul 2021 12:12:15 -0400 Subject: [PATCH 1/5] Imviz: Add zoom and zoom_level API [ci skip] --- jdaviz/configs/imviz/helper.py | 78 ++++++++++++++++++++++++++++++++++ notebooks/ImvizExample.ipynb | 48 +++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 7315fc0f61..5aa5482523 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -209,6 +209,84 @@ 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' resets the zoom. + + """ + viewer = self.app.get_viewer("viewer-1") + i_top = get_top_layer_index(viewer) + image = viewer.layers[i_top].layer + nx = image.shape[viewer.state.x_att.axis] + ny = image.shape[viewer.state.y_att.axis] + zoom_x = nx / (viewer.state.x_max - viewer.state.x_min) + zoom_y = ny / (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") + if (viewer.state.reference_data is None or viewer.state.x_att is None + or viewer.state.y_att is None): + return + + # Want to zoom what is actually visible. + # Zoom on X and Y will auto-adjust. + i_top = get_top_layer_index(viewer) + image = viewer.layers[i_top].layer + nx = image.shape[viewer.state.x_att.axis] + + if val == 'fit': + # Similar to ImageViewerState.reset_limits() in Glue. + new_x_min = 0 + new_x_max = nx + else: + width = viewer.state.x_max - viewer.state.x_min + cur_xcen = viewer.state.x_min + (width * 0.5) + new_dx = nx * 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/notebooks/ImvizExample.ipynb b/notebooks/ImvizExample.ipynb index 1e1bb5cf52..88efbc66db 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' resets the zoom." + ] + }, + { + "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 = 2" + ] + }, + { + "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", From 94cde08c86db11adb4d62bb840aac4bb5c9b2423 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Thu, 22 Jul 2021 17:41:32 -0400 Subject: [PATCH 2/5] Simplify xcen logic [ci skip] --- jdaviz/configs/imviz/helper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 5aa5482523..938e80a64a 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -252,8 +252,7 @@ def zoom_level(self, val): new_x_min = 0 new_x_max = nx else: - width = viewer.state.x_max - viewer.state.x_min - cur_xcen = viewer.state.x_min + (width * 0.5) + cur_xcen = (viewer.state.x_min + viewer.state.x_max) * 0.5 new_dx = nx * 0.5 / val new_x_min = cur_xcen - new_dx new_x_max = cur_xcen + new_dx From f20e993db13d4bbf941054b306b086b2968eefa6 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Fri, 23 Jul 2021 15:03:58 -0400 Subject: [PATCH 3/5] Clarify fit, use reference_data [ci skip] --- jdaviz/configs/imviz/helper.py | 15 +++++++-------- notebooks/ImvizExample.ipynb | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 938e80a64a..264fc0588b 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -216,12 +216,13 @@ def zoom_level(self): * 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' resets the zoom. + * 'fit' means zoomed to fit the whole image width into display. """ viewer = self.app.get_viewer("viewer-1") - i_top = get_top_layer_index(viewer) - image = viewer.layers[i_top].layer + image = viewer.state.reference_data + + # TODO: Need to fix formula to use "real pixel size" nx = image.shape[viewer.state.x_att.axis] ny = image.shape[viewer.state.y_att.axis] zoom_x = nx / (viewer.state.x_max - viewer.state.x_min) @@ -237,14 +238,11 @@ def zoom_level(self, val): raise ValueError(f'Unsupported zoom level: {val}') viewer = self.app.get_viewer("viewer-1") - if (viewer.state.reference_data is None or viewer.state.x_att is None - or viewer.state.y_att is None): + image = viewer.state.reference_data + if image is None or viewer.state.x_att is None or viewer.state.y_att is None: return - # Want to zoom what is actually visible. # Zoom on X and Y will auto-adjust. - i_top = get_top_layer_index(viewer) - image = viewer.layers[i_top].layer nx = image.shape[viewer.state.x_att.axis] if val == 'fit': @@ -252,6 +250,7 @@ def zoom_level(self, val): new_x_min = 0 new_x_max = nx else: + # TODO: Need to fix formula to use "real pixel size" cur_xcen = (viewer.state.x_min + viewer.state.x_max) * 0.5 new_dx = nx * 0.5 / val new_x_min = cur_xcen - new_dx diff --git a/notebooks/ImvizExample.ipynb b/notebooks/ImvizExample.ipynb index 88efbc66db..ebe0a8c320 100644 --- a/notebooks/ImvizExample.ipynb +++ b/notebooks/ImvizExample.ipynb @@ -399,7 +399,7 @@ "* 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' resets the zoom." + "* 'fit' means zoomed to fit the whole image width into display." ] }, { From a39b66a45c3c51c3374b9678e6c1d5ad8f551941 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Mon, 26 Jul 2021 18:13:53 -0400 Subject: [PATCH 4/5] Fix zoom math --- jdaviz/configs/imviz/helper.py | 22 ++++++++++------------ notebooks/ImvizExample.ipynb | 2 +- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 264fc0588b..65fd8418f4 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -220,13 +220,13 @@ def zoom_level(self): """ viewer = self.app.get_viewer("viewer-1") - image = viewer.state.reference_data + if viewer.shape is None: + raise ValueError('Viewer is still loading, try again later') - # TODO: Need to fix formula to use "real pixel size" - nx = image.shape[viewer.state.x_att.axis] - ny = image.shape[viewer.state.y_att.axis] - zoom_x = nx / (viewer.state.x_max - viewer.state.x_min) - zoom_y = ny / (viewer.state.y_max - viewer.state.y_min) + 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() @@ -239,20 +239,18 @@ def zoom_level(self, val): viewer = self.app.get_viewer("viewer-1") image = viewer.state.reference_data - if image is None or viewer.state.x_att is None or viewer.state.y_att is None: + if (image is None or viewer.shape is None or + viewer.state.x_att is None or viewer.state.y_att is None): return # Zoom on X and Y will auto-adjust. - nx = image.shape[viewer.state.x_att.axis] - if val == 'fit': # Similar to ImageViewerState.reset_limits() in Glue. new_x_min = 0 - new_x_max = nx + new_x_max = image.shape[viewer.state.x_att.axis] else: - # TODO: Need to fix formula to use "real pixel size" cur_xcen = (viewer.state.x_min + viewer.state.x_max) * 0.5 - new_dx = nx * 0.5 / val + new_dx = viewer.shape[1] * 0.5 / val new_x_min = cur_xcen - new_dx new_x_max = cur_xcen + new_dx diff --git a/notebooks/ImvizExample.ipynb b/notebooks/ImvizExample.ipynb index ebe0a8c320..e8a98b3bd4 100644 --- a/notebooks/ImvizExample.ipynb +++ b/notebooks/ImvizExample.ipynb @@ -421,7 +421,7 @@ "outputs": [], "source": [ "# Set the zoom level directly.\n", - "imviz.zoom_level = 2" + "imviz.zoom_level = 1" ] }, { From d3c7f37a94f1bc3f3898b451e2f4d2d92d760456 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Tue, 27 Jul 2021 10:43:41 -0400 Subject: [PATCH 5/5] Add tests for zoom --- jdaviz/configs/imviz/helper.py | 4 +- .../imviz/tests/test_astrowidgets_api.py | 53 ++++++++++++ jdaviz/configs/imviz/tests/utils.py | 80 ++++++++++--------- 3 files changed, 97 insertions(+), 40 deletions(-) diff --git a/jdaviz/configs/imviz/helper.py b/jdaviz/configs/imviz/helper.py index 65fd8418f4..b1d293bbea 100644 --- a/jdaviz/configs/imviz/helper.py +++ b/jdaviz/configs/imviz/helper.py @@ -220,7 +220,7 @@ def zoom_level(self): """ viewer = self.app.get_viewer("viewer-1") - if viewer.shape is None: + if viewer.shape is None: # pragma: no cover raise ValueError('Viewer is still loading, try again later') screenx = viewer.shape[1] @@ -240,7 +240,7 @@ def zoom_level(self, 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): + 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. 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)