From 6b83d77a27579ba1c6c1d4272c3690f3fa3f2b12 Mon Sep 17 00:00:00 2001 From: kcarver1 Date: Fri, 13 Sep 2024 10:03:38 -0400 Subject: [PATCH] Make catalog tables interactive (#3099) * adding the table feature for the import from file option Resolved merge conflict in catalogs.py and catalogs.vue# Resolved merge conflict in catalogs.py and catalogs.vue adding new markers added x,y pixel coords to table; will be used for markers later small changes orginal markers are not being plotted Added user marks prep for rebase * fixing codestyle * added a function that zooms in on selected sources * adjusting zoom for single select case * Fix commented out code for style check * Fix bug and add zoom in button * Address review comments * Update change log * Add tests and update docs * Enable row selection and zoom for Gaia catalog * Address review comments * Move code to init --------- Co-authored-by: Katherine Carver Co-authored-by: Katherine Carver Co-authored-by: Jesse Averbukh --- CHANGES.rst | 2 +- docs/imviz/plugins.rst | 8 +- .../imviz/plugins/catalogs/catalogs.py | 130 ++++++++++++++---- .../imviz/plugins/catalogs/catalogs.vue | 14 +- jdaviz/configs/imviz/tests/test_catalogs.py | 31 +++++ jdaviz/core/marks.py | 10 +- jdaviz/core/template_mixin.py | 9 +- 7 files changed, 167 insertions(+), 37 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a0f2e924b5..aa9eabf67f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -39,7 +39,7 @@ Cubeviz Imviz ^^^^^ -- Added a table with catalog search results. [#2915, #3101] +- Added a table with catalog search results. [#2915, #3101, #3099] - "Imviz Line Profiles (XY)" plugin is renamed to "Image Profiles (XY)". [#3121] diff --git a/docs/imviz/plugins.rst b/docs/imviz/plugins.rst index b6a509b9a9..57801499f6 100644 --- a/docs/imviz/plugins.rst +++ b/docs/imviz/plugins.rst @@ -375,8 +375,8 @@ catalog dropdown menu. .. note:: - This plugin is still under active development. As a result, the search only uses the SDSS DR17 catalog - and works best when you only have a single image loaded in a viewer. + This plugin is still under active development. As a result, the search only uses the SDSS DR17 catalog and + the Gaia catalog and works best when you only have a single image loaded in a viewer. To load a catalog from a supported `JWST ECSV catalog file `_, choose "From File...". The file must be able to be parsed by `astropy.table.Table.read` and contains the following columns: @@ -403,7 +403,9 @@ are not stored. To save the current result before submitting a new query, you ca portion of the image. Additional steps will be needed to filter out these points, if necessary. Performing a search populates a table that contains the -right ascension, declination, and the object ID of the found sources. +right ascension, declination, and the object ID of the found sources. Checkboxes next to the rows +can be selected and the corresponding marks in the viewer will change to orange circles. When :guilabel:`Zoom to Selected`, +the viewer will zoom to encompass the selected rows in the table. .. _imviz-footprints: diff --git a/jdaviz/configs/imviz/plugins/catalogs/catalogs.py b/jdaviz/configs/imviz/plugins/catalogs/catalogs.py index 24a6ffd07e..e41d6f55a3 100644 --- a/jdaviz/configs/imviz/plugins/catalogs/catalogs.py +++ b/jdaviz/configs/imviz/plugins/catalogs/catalogs.py @@ -3,7 +3,7 @@ from astropy import units as u from astropy.table import QTable from astropy.coordinates import SkyCoord -from traitlets import List, Unicode, Bool, Int +from traitlets import List, Unicode, Bool, Int, observe from jdaviz.core.events import SnackbarMessage from jdaviz.core.registries import tray_registry @@ -12,8 +12,12 @@ with_spinner) from jdaviz.core.custom_traitlets import IntHandleEmpty +from jdaviz.core.marks import CatalogMark + from jdaviz.core.template_mixin import TableMixin from jdaviz.core.user_api import PluginUserApi +from echo import delay_callback + __all__ = ['Catalogs'] @@ -31,6 +35,7 @@ class Catalogs(PluginTemplateMixin, ViewerSelectMixin, HasFileImportSelect, Tabl * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.close_in_tray` """ template_file = __file__, "catalogs.vue" + uses_active_status = Bool(True).tag(sync=True) catalog_items = List([]).tag(sync=True) catalog_selected = Unicode("").tag(sync=True) results_available = Bool(False).tag(sync=True) @@ -41,7 +46,10 @@ class Catalogs(PluginTemplateMixin, ViewerSelectMixin, HasFileImportSelect, Tabl _default_table_values = { 'Right Ascension (degrees)': np.nan, 'Declination (degrees)': np.nan, - 'Object ID': np.nan} + 'Object ID': np.nan, + 'id': np.nan, + 'x_coord': np.nan, + 'y_coord': np.nan} @property def user_api(self): @@ -60,11 +68,15 @@ def __init__(self, *args, **kwargs): self._marker_name = 'catalog_results' # initializing the headers in the table that is displayed in the UI - headers = ['Right Ascension (degrees)', 'Declination (degrees)', 'Object ID'] + headers = ['Right Ascension (degrees)', 'Declination (degrees)', + 'Object ID', 'x_coord', 'y_coord'] self.table.headers_avail = headers self.table.headers_visible = headers self.table._default_values_by_colname = self._default_table_values + self.table._selected_rows_changed_callback = lambda msg: self.plot_selected_points() + self.table.item_key = 'id' + self.table.show_rowselect = True @staticmethod def _file_parser(path): @@ -163,15 +175,6 @@ def search(self, error_on_fail=False): query_region_result['dec'], unit='deg') - # adding in coords + Id's into table - # NOTE: If performance becomes a problem, see - # https://docs.astropy.org/en/stable/table/index.html#performance-tips - for row in self.app._catalog_source_table: - row_info = {'Right Ascension (degrees)': row['ra'], - 'Declination (degrees)': row['dec'], - 'Object ID': row['objid'].astype(str)} - self.table.add_item(row_info) - elif self.catalog_selected == 'Gaia': from astroquery.gaia import Gaia, conf @@ -180,12 +183,6 @@ def search(self, error_on_fail=False): columns=('source_id', 'ra', 'dec')) self.app._catalog_source_table = sources skycoord_table = SkyCoord(sources['ra'], sources['dec'], unit='deg') - # adding in coords + Id's into table - for row in sources: - row_info = {'Right Ascension (degrees)': row['ra'], - 'Declination (degrees)': row['dec'], - 'Source ID': row['SOURCE_ID']} - self.table.add_item(row_info) elif self.catalog_selected == 'From File...': # all exceptions when going through the UI should have prevented setting this path @@ -194,14 +191,6 @@ def search(self, error_on_fail=False): self.app._catalog_source_table = table skycoord_table = table['sky_centroid'] - # NOTE: If performance becomes a problem, see - # https://docs.astropy.org/en/stable/table/index.html#performance-tips - for row in self.app._catalog_source_table: - row_info = {'Right Ascension (degrees)': row['sky_centroid'].ra.deg, - 'Declination (degrees)': row['sky_centroid'].dec.deg, - 'Object ID': str(row.get('label', 'N/A'))} - self.table.add_item(row_info) - else: self.results_available = False self.number_of_results = 0 @@ -228,6 +217,38 @@ def search(self, error_on_fail=False): filtered_pair_pixel_table = np.array(np.hsplit(filtered_table, 2)) x_coordinates = np.squeeze(filtered_pair_pixel_table[0]) y_coordinates = np.squeeze(filtered_pair_pixel_table[1]) + + if self.catalog_selected in ["SDSS", "Gaia"]: + for row, x_coord, y_coord in zip(self.app._catalog_source_table, + x_coordinates, y_coordinates): + if self.catalog_selected == "SDSS": + row_id = row["objid"] + elif self.catalog_selected == "Gaia": + row_id = row["SOURCE_ID"] + # Check if the row contains the required keys + row_info = {'Right Ascension (degrees)': row['ra'], + 'Declination (degrees)': row['dec'], + 'Object ID': row_id.astype(str), + 'id': len(self.table), + 'x_coord': x_coord, + 'y_coord': y_coord} + self.table.add_item(row_info) + + # NOTE: If performance becomes a problem, see + # https://docs.astropy.org/en/stable/table/index.html#performance-tips + if self.catalog_selected == 'From File...': + for row, x_coord, y_coord in zip(self.app._catalog_source_table, + x_coordinates, y_coordinates): + # Check if the row contains the required keys + row_info = {'Right Ascension (degrees)': row['sky_centroid'].ra.deg, + 'Declination (degrees)': row['sky_centroid'].dec.deg, + 'Object ID': str(row.get('label', 'N/A')), + 'id': len(self.table), + 'x_coord': x_coord, + 'y_coord': y_coord} + + self.table.add_item(row_info) + filtered_skycoord_table = viewer.state.reference_data.coords.pixel_to_world(x_coordinates, y_coordinates) @@ -236,11 +257,64 @@ def search(self, error_on_fail=False): self.number_of_results = len(catalog_results) # markers are added to the viewer based on the table - viewer.marker = {'color': 'red', 'alpha': 0.8, 'markersize': 5, 'fill': False} + viewer.marker = {'color': 'blue', 'alpha': 0.8, 'markersize': 30, 'fill': False} viewer.add_markers(table=catalog_results, use_skycoord=True, marker_name=self._marker_name) - return skycoord_table + def _get_mark(self, viewer): + matches = [mark for mark in viewer.figure.marks if isinstance(mark, CatalogMark)] + if len(matches): + return matches[0] + mark = CatalogMark(viewer) + viewer.figure.marks = viewer.figure.marks + [mark] + return mark + + @property + def marks(self): + return {viewer_id: self._get_mark(viewer) + for viewer_id, viewer in self.app._viewer_store.items() + if hasattr(viewer, 'figure')} + + @observe('is_active') + def _on_is_active_changed(self, *args): + if self.disabled_msg: + return + + for mark in self.marks.values(): + mark.visible = self.is_active + + def plot_selected_points(self): + selected_rows = self.table.selected_rows + + x = [float(coord['x_coord']) for coord in selected_rows] + y = [float(coord['y_coord']) for coord in selected_rows] + self._get_mark(self.viewer.selected_obj).update_xy(getattr(x, 'value', x), + getattr(y, 'value', y)) + + def vue_zoom_in(self, *args, **kwargs): + """This function will zoom into the image based on the selected points""" + selected_rows = self.table.selected_rows + + x = [float(coord['x_coord']) for coord in selected_rows] + y = [float(coord['y_coord']) for coord in selected_rows] + + # 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 + + imview = self.app._jdaviz_helper._default_viewer + + with delay_callback(imview.state, 'x_min', 'x_max', 'y_min', 'y_max'): + imview.state.x_min = x_min + imview.state.x_max = x_max + imview.state.y_min = y_min + imview.state.y_max = y_max + + return (x_min, x_max), (y_min, y_max) + def import_catalog(self, catalog): """ Import a catalog from a file path. diff --git a/jdaviz/configs/imviz/plugins/catalogs/catalogs.vue b/jdaviz/configs/imviz/plugins/catalogs/catalogs.vue index 4f23c35247..096ecbc906 100644 --- a/jdaviz/configs/imviz/plugins/catalogs/catalogs.vue +++ b/jdaviz/configs/imviz/plugins/catalogs/catalogs.vue @@ -2,6 +2,9 @@ @@ -64,6 +67,14 @@ Search + + + Zoom to Selected + + @@ -71,8 +82,7 @@ {{number_of_results}} - - + \ No newline at end of file diff --git a/jdaviz/configs/imviz/tests/test_catalogs.py b/jdaviz/configs/imviz/tests/test_catalogs.py index 784cc60093..b36c56a7eb 100644 --- a/jdaviz/configs/imviz/tests/test_catalogs.py +++ b/jdaviz/configs/imviz/tests/test_catalogs.py @@ -117,6 +117,21 @@ def test_plugin_image_with_result(self, imviz_helper, tmp_path): assert catalogs_plugin.results_available assert catalogs_plugin.number_of_results == prev_results + catalogs_plugin.table.selected_rows = catalogs_plugin.table.items[0:2] + assert len(catalogs_plugin.table.selected_rows) == 2 + + assert imviz_helper.viewers['imviz-0']._obj.state.x_min == -0.5 + assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 2047.5 + 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() + + assert imviz_helper.viewers['imviz-0']._obj.state.x_min == 858.24969 + assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 958.38461 + assert imviz_helper.viewers['imviz-0']._obj.state.y_min == 278.86265 + assert imviz_helper.viewers['imviz-0']._obj.state.y_max == 378.8691 + def test_from_file_parsing(imviz_helper, tmp_path): catalogs_plugin = imviz_helper.plugins["Catalog Search"]._obj @@ -174,3 +189,19 @@ def test_offline_ecsv_catalog(imviz_helper, image_2d_wcs, tmp_path): catalogs_plugin.clear(hide_only=False) assert not catalogs_plugin.results_available assert len(imviz_helper.app.data_collection) == 1 # markers gone for good + + catalogs_plugin.table.selected_rows = [catalogs_plugin.table.items[0]] + + assert len(catalogs_plugin.table.selected_rows) == 1 + + assert imviz_helper.viewers['imviz-0']._obj.state.x_min == -0.5 + assert imviz_helper.viewers['imviz-0']._obj.state.x_max == 9.5 + 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() + + 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 diff --git a/jdaviz/core/marks.py b/jdaviz/core/marks.py index af21d275b2..3585369a90 100644 --- a/jdaviz/core/marks.py +++ b/jdaviz/core/marks.py @@ -17,8 +17,8 @@ 'PluginMark', 'LinesAutoUnit', 'PluginLine', 'PluginScatter', 'LineAnalysisContinuum', 'LineAnalysisContinuumCenter', 'LineAnalysisContinuumLeft', 'LineAnalysisContinuumRight', - 'LineUncertainties', 'ScatterMask', 'SelectedSpaxel', 'MarkersMark', 'FootprintOverlay', - 'ApertureMark'] + 'LineUncertainties', 'ScatterMask', 'SelectedSpaxel', 'MarkersMark', + 'CatalogMark', 'FootprintOverlay', 'ApertureMark'] accent_color = "#c75d2c" @@ -597,6 +597,12 @@ def __init__(self, viewer, **kwargs): super().__init__(viewer, **kwargs) +class CatalogMark(PluginScatter): + def __init__(self, viewer, **kwargs): + kwargs.setdefault('marker', 'circle') + super().__init__(viewer, **kwargs) + + class FootprintOverlay(PluginLine): def __init__(self, viewer, overlay, **kwargs): self._overlay = overlay diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index 7d1c27be61..92feb3bd70 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -4501,9 +4501,11 @@ class Table(PluginSubcomponent): item_key = Unicode().tag(sync=True) # Unique field to identify row for selection selected_rows = List().tag(sync=True) # List of selected rows - def __init__(self, plugin, name='table', *args, **kwargs): + def __init__(self, plugin, name='table', selected_rows_changed_callback=None, + *args, **kwargs): self._qtable = None self._table_name = name + self._selected_rows_changed_callback = selected_rows_changed_callback super().__init__(plugin, 'Table', *args, **kwargs) plugin.session.hub.broadcast(PluginTableAddedMessage(sender=self)) @@ -4527,6 +4529,11 @@ def default_value_for_column(self, colname=None, value=None): def _new_col_visible(colname): return True + @observe('selected_rows') + def _selected_rows_changed(self, msg): + if self._selected_rows_changed_callback is not None: + self._selected_rows_changed_callback(msg) + def add_item(self, item): """ Add an item/row to the table.