diff --git a/CHANGES.rst b/CHANGES.rst index cf2c16493d..0dd682df45 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -27,6 +27,11 @@ Imviz - Catalog Search plugin now exposes a maximum sources limit for all catalogs and resolves an edge case when loading a catalog from a file that only contains one source. [#3337] +- Catalog Search plugin ``zoom_to_selected`` is now in the public API. The default + zoom level changed from a fixed 50 pixels to a zoom window that is a fraction of + the image size (default 2%) to address and issue with zooming when using a small + image or WCS linked. [#3369] + Mosviz ^^^^^^ diff --git a/jdaviz/configs/imviz/plugins/catalogs/catalogs.py b/jdaviz/configs/imviz/plugins/catalogs/catalogs.py index 13ec7beda8..ec9a9020ef 100644 --- a/jdaviz/configs/imviz/plugins/catalogs/catalogs.py +++ b/jdaviz/configs/imviz/plugins/catalogs/catalogs.py @@ -29,6 +29,7 @@ class Catalogs(PluginTemplateMixin, ViewerSelectMixin, HasFileImportSelect, Tabl * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.show` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.open_in_tray` * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.close_in_tray` + * :meth:`zoom_to_selected` """ template_file = __file__, "catalogs.vue" uses_active_status = Bool(True).tag(sync=True) @@ -49,7 +50,8 @@ class Catalogs(PluginTemplateMixin, ViewerSelectMixin, HasFileImportSelect, Tabl @property def user_api(self): - return PluginUserApi(self, expose=('clear_table', 'export_table',)) + return PluginUserApi(self, expose=('clear_table', 'export_table', + 'zoom_to_selected')) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -324,23 +326,62 @@ def plot_selected_points(self): getattr(y, 'value', y)) def vue_zoom_in(self, *args, **kwargs): - """This function will zoom into the image based on the selected points""" + + self.zoom_to_selected() + + def zoom_to_selected(self, padding=0.02, return_bounding_box=False): + """ + Zoom on the default viewer to a region containing the currently selected + points in the catalog. + + Parameters + ---------- + padding : float, optional + A fractional value representing the padding around the bounding box + of the selected points. It is applied as a proportion of the largest + dimension of the current extent of loaded data. Defaults to 0.02. + return_bounding_box : bool, optional + If True, returns the bounding box of the zoomed region as + ((x_min, x_max), (y_min, y_max)). Defaults to False. + + Returns + ------- + list of float or None + If there are activley selected rows, and ``return_bounding_box`` is + True, returns a list containing the bounding + box coordinates: [x_min, x_max, y_min, y_max]. + Otherwise, returns None. + + """ + + viewer = self.app._jdaviz_helper._default_viewer + selected_rows = self.table.selected_rows + if not len(selected_rows): + return + + if padding <= 0 or padding > 1: + raise ValueError("`padding` must be between 0 and 1.") + x = [float(coord['x_coord']) for coord in selected_rows] y = [float(coord['y_coord']) for coord in selected_rows] + limits = viewer.state._get_reset_limits() + max_dim = max((limits[1] - limits[0]), (limits[3] - limits[2])) + padding = max_dim * padding + # this works with single selected points # zooming when the range is too large is not performing correctly - x_min = min(x) - 50 - x_max = max(x) + 50 - y_min = min(y) - 50 - y_max = max(y) + 50 + x_min = min(x) - padding + x_max = max(x) + padding + y_min = min(y) - padding + y_max = max(y) + padding - self.app._jdaviz_helper._default_viewer.set_limits( - x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) + viewer.set_limits(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max) - return (x_min, x_max), (y_min, y_max) + if return_bounding_box: + return [x_min, x_max, y_min, y_max] def import_catalog(self, catalog): """ diff --git a/jdaviz/configs/imviz/tests/test_catalogs.py b/jdaviz/configs/imviz/tests/test_catalogs.py index 0e6e960ddc..67a26b61dd 100644 --- a/jdaviz/configs/imviz/tests/test_catalogs.py +++ b/jdaviz/configs/imviz/tests/test_catalogs.py @@ -22,11 +22,12 @@ ''' import numpy as np +from numpy.testing import assert_allclose import pytest +from astropy.coordinates import SkyCoord from astropy.io import fits from astropy.nddata import NDData -from astropy.coordinates import SkyCoord from astropy.table import Table, QTable @@ -157,7 +158,9 @@ def test_plugin_image_with_result(self, imviz_helper, tmp_path): assert imviz_helper.viewers['imviz-0']._obj.state.y_min == -0.5 assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 1488.5 - catalogs_plugin.vue_zoom_in() + # set 'padding' to reproduce original hard-coded 50 pixel window + # so test results don't change + catalogs_plugin.zoom_to_selected(padding=50 / 2048) assert imviz_helper.viewers['imviz-0']._obj.state.x_min == 858.24969 assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 958.38461 @@ -251,9 +254,83 @@ def test_offline_ecsv_catalog(imviz_helper, image_2d_wcs, tmp_path): assert imviz_helper.viewers['imviz-0']._obj.state.y_min == -0.5 assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 9.5 - catalogs_plugin.vue_zoom_in() + # test the zooming using the default 'padding' of 2% of the viewer size + # around selected points + catalogs_plugin.zoom_to_selected() + assert imviz_helper.viewers['imviz-0']._obj.state.x_min == -0.19966 + assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 0.20034000000000002 + assert imviz_helper.viewers['imviz-0']._obj.state.y_min == 0.8000100000000001 + assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 1.20001 + + +def test_zoom_to_selected(imviz_helper, image_2d_wcs, tmp_path): + + arr = np.ones((500, 500)) + ndd = NDData(arr, wcs=image_2d_wcs) + imviz_helper.load_data(ndd) + + # write out catalog to file so we can read it back in + # todo: if tables can be loaded directly at some point, do that + + # sources at pixel coords ~(100, 100), ~(200, 200) + sky_coord = SkyCoord(ra=[337.49056532, 337.46086081], + dec=[-20.80555273, -20.7777673], unit='deg') + tbl = Table({'sky_centroid': [sky_coord], + 'label': ['Source_1', 'Source_2']}) + tbl_file = str(tmp_path / 'test_catalog.ecsv') + tbl.write(tbl_file, overwrite=True) + + catalogs_plugin = imviz_helper.plugins['Catalog Search'] + + catalogs_plugin._obj.from_file = tbl_file + + catalogs_plugin._obj.search() + + # select both sources + catalogs_plugin._obj.table.selected_rows = catalogs_plugin._obj.table.items + + # check viewer limits before zoom + xmin, xmax, ymin, ymax = imviz_helper.app._jdaviz_helper._default_viewer.get_limits() + assert xmin == ymin == -0.5 + assert xmax == ymax == 499.5 + + # zoom to selected sources + catalogs_plugin.zoom_to_selected() + + # make sure the viewer bounds reflect the zoom, which, in pixel coords, + # should be centered at roughly pixel coords (150, 150) + xmin, xmax, ymin, ymax = imviz_helper.app._jdaviz_helper._default_viewer.get_limits() + + assert_allclose((xmin + xmax) / 2, 150., atol=0.1) + assert_allclose((ymin + ymax) / 2, 150., atol=0.1) + + # and the zoom box size should reflect the default padding of 2% of the image + # size around the bounding box containing the source(s), which in this case is + # 10 pixels around + assert_allclose(xmin, 100 - 10, atol=0.1) # min x of selected sources minus pad + assert_allclose(xmax, 200 + 10, atol=0.1) # max x of selected sources plus pad + assert_allclose(ymin, 100 - 10, atol=0.1) # min y of selected sources minus pad + assert_allclose(ymax, 200 + 10, atol=0.1) # max y of selected sources plus pad + + # select one source now + catalogs_plugin._obj.table.selected_rows = catalogs_plugin._obj.table.items[0:1] + + # zoom to single selected source, using a new value for 'padding' + catalogs_plugin.zoom_to_selected(padding=0.05) + + # check that zoom window is centered correctly on the source at 100, 100 + xmin, xmax, ymin, ymax = imviz_helper.app._jdaviz_helper._default_viewer.get_limits() + assert_allclose((xmin + xmax) / 2, 100., atol=0.1) + assert_allclose((ymin + ymax) / 2, 100., atol=0.1) + + # and the zoom box size should reflect the default padding of 5% of the image + # size around the bounding box containing the source(s), which in this case is + # 25 pixels around + assert_allclose(xmin, 100 - 25, atol=0.1) # min x of selected source minus pad + assert_allclose(xmax, 100 + 25, atol=0.1) # max x of selected source plus pad + assert_allclose(ymin, 100 - 25, atol=0.1) # min y of selected source minus pad + assert_allclose(ymax, 100 + 25, atol=0.1) # max y of selected source plus pad - assert imviz_helper.viewers['imviz-0']._obj.state.x_min == -49.99966 - assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 50.00034 - assert imviz_helper.viewers['imviz-0']._obj.state.y_min == -48.99999 - assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 51.00001 + # test that appropriate error is raised when padding is not a valud percentage + with pytest.raises(ValueError, match="`padding` must be between 0 and 1."): + catalogs_plugin.zoom_to_selected(padding=5)