Skip to content

Commit

Permalink
FEAT: Catalog Search plugin to support import catalog
Browse files Browse the repository at this point in the history
  • Loading branch information
pllim committed Feb 1, 2025
1 parent aedb793 commit 70bd2b8
Show file tree
Hide file tree
Showing 4 changed files with 48 additions and 57 deletions.
4 changes: 3 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ Cubeviz
Imviz
^^^^^

- Catalog Search now supports importing Astropy table object via ``import_catalog`` method. [#3424]

- Enhance the Catalog Search plugin to support additional columns when loading catalog data from files. [#3359]

- Catalog Search ``clear_table`` now removes all associated markers from the viewer. [#3359]

Mosviz
Expand Down
8 changes: 7 additions & 1 deletion docs/imviz/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,19 @@ catalog dropdown menu.
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 <https://jwst-pipeline.readthedocs.io/en/latest/jwst/source_catalog/main.html#output-products>`_, choose "From File...".
The file must be able to be parsed by `astropy.table.Table.read` and contains the following columns:
The file must be able to be parsed by :meth:`astropy.table.Table.read` and contains the following columns:

* ``'sky_centroid'``: Column with `~astropy.coordinates.SkyCoord` sky coordinates of the sources.
* ``'label'``: (Optional) Column with string identifiers of the sources.
If not provided, unique string identifiers will be generated automatically.
If you have numerical identifiers, they will be recast as strings.

Alternately, if you already have the table object, you could load it in directly via API:

.. code-block:: python
imviz.plugins["Catalog Search"].import_catalog(table_object)
Clicking :guilabel:`SEARCH` will show markers for any entry within the filtered zoom window.

If you have multiple viewers open, you will see another dropdown menu to select the active
Expand Down
11 changes: 8 additions & 3 deletions jdaviz/configs/imviz/plugins/catalogs/catalogs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import numpy as np
import numpy.ma as ma
from astropy import units as u
from astropy.table import QTable
from astropy.table import Table, QTable
from astropy.coordinates import SkyCoord
from traitlets import List, Unicode, Bool, Int, observe

Expand Down Expand Up @@ -54,7 +54,7 @@ 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', 'import_catalog',
'zoom_to_selected', 'select_rows',
'select_all', 'select_none'))

Expand Down Expand Up @@ -90,6 +90,10 @@ def __init__(self, *args, **kwargs):

@staticmethod
def _file_parser(path):
if isinstance(path, Table): # includes QTable
from_file_string = f'API: {path.__class__.__name__} object'
return '', {from_file_string: path}

try:
table = QTable.read(path)
except Exception:
Expand Down Expand Up @@ -408,9 +412,10 @@ def import_catalog(self, catalog):
catalog : str
Path to a file that can be parsed by astropy QTable
"""
# TODO: self.catalog.import_obj for a QTable directly (see footprints implementation)
if isinstance(catalog, str):
self.catalog.import_file(catalog)
elif isinstance(catalog, Table): # includes QTable
self.catalog.import_obj(catalog)
else: # pragma: no cover
raise ValueError("catalog must be a string (file path)")

Expand Down
82 changes: 30 additions & 52 deletions jdaviz/configs/imviz/tests/test_catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,110 +208,97 @@ def test_from_file_parsing(imviz_helper, tmp_path):
assert catalogs_plugin.from_file_message == 'Table does not contain required sky_centroid column' # noqa


def test_offline_ecsv_catalog(imviz_helper, image_2d_wcs, tmp_path):
def test_offline_ecsv_catalog(imviz_helper, image_2d_wcs):
sky = SkyCoord(ra=[337.5202807, 337.51909197, 337.51760596],
dec=[-20.83305528, -20.83222194, -20.83083304], unit='deg')
tbl = QTable({'sky_centroid': sky}) # Table has no "Label" column
tbl_file = str(tmp_path / 'sky_centroid.ecsv')
tbl.write(tbl_file, overwrite=True)
n_entries = len(tbl)

ndd = NDData(np.ones((10, 10)), wcs=image_2d_wcs)
imviz_helper.load_data(ndd, data_label='data_with_wcs')
assert len(imviz_helper.app.data_collection) == 1

catalogs_plugin = imviz_helper.plugins['Catalog Search']._obj
catalogs_plugin.from_file = tbl_file
catalogs_plugin.catalog_selected = 'From File...'
out_tbl = catalogs_plugin.search(error_on_fail=True)
catalogs_plugin = imviz_helper.plugins['Catalog Search']
catalogs_plugin.import_catalog(tbl)
out_tbl = catalogs_plugin._obj.search(error_on_fail=True)
assert len(out_tbl) == n_entries
assert catalogs_plugin.number_of_results == n_entries
assert catalogs_plugin._obj.number_of_results == n_entries
# Assert that Object ID is set to index + 1 when the label column is absent
for idx, item in enumerate(catalogs_plugin.table.items):
for idx, item in enumerate(catalogs_plugin._obj.table.items):
assert item['Object ID'] == str(idx + 1)
assert len(imviz_helper.app.data_collection) == 2 # image + markers

catalogs_plugin.table.selected_rows = [catalogs_plugin.table.items[0]]
assert len(catalogs_plugin.table.selected_rows) == 1
catalogs_plugin._obj.table.selected_rows = [catalogs_plugin._obj.table.items[0]]
assert len(catalogs_plugin._obj.table.selected_rows) == 1

# test to ensure sources searched for respect the maximum sources traitlet
catalogs_plugin.max_sources = 1
catalogs_plugin.search(error_on_fail=True)
assert catalogs_plugin.number_of_results == catalogs_plugin.max_sources
catalogs_plugin._obj.max_sources = 1
catalogs_plugin._obj.search(error_on_fail=True)
assert catalogs_plugin._obj.number_of_results == catalogs_plugin._obj.max_sources

catalogs_plugin.clear_table()

# test single source edge case and docs recommended input file type
sky_coord = SkyCoord(ra=337.5202807, dec=-20.83305528, unit='deg')
tbl = Table({'sky_centroid': [sky_coord], 'label': ['Source_1']})
tbl_file = str(tmp_path / 'sky_centroid1.ecsv')
tbl.write(tbl_file, overwrite=True)
n_entries = len(tbl)

catalogs_plugin.from_file = tbl_file
out_tbl = catalogs_plugin.search()
catalogs_plugin.import_catalog(tbl)
out_tbl = catalogs_plugin._obj.search()
assert len([out_tbl]) == n_entries
assert catalogs_plugin.number_of_results == n_entries
assert catalogs_plugin._obj.number_of_results == n_entries
assert len(imviz_helper.app.data_collection) == 2 # image + markers

catalogs_plugin.clear_table()

assert not catalogs_plugin.results_available
assert not catalogs_plugin._obj.results_available
assert len(imviz_helper.app.data_collection) == 2 # markers still there, just hidden

catalogs_plugin.clear_table(hide_only=False)
assert not catalogs_plugin.results_available
assert not catalogs_plugin._obj.results_available
assert len(imviz_helper.app.data_collection) == 1 # markers gone for good

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
# Re-populate the table with a new search
out_tbl = catalogs_plugin.search()
out_tbl = catalogs_plugin._obj.search()
assert len(out_tbl) > 0
# Ensure at least one row is selected before zooming
catalogs_plugin.table.selected_rows = [catalogs_plugin.table.items[0]]
assert len(catalogs_plugin.table.selected_rows) > 0
catalogs_plugin._obj.table.selected_rows = [catalogs_plugin._obj.table.items[0]]
assert len(catalogs_plugin._obj.table.selected_rows) > 0

# test the zooming using the default 'padding' of 2% of the viewer size
# around selected points
catalogs_plugin.zoom_to_selected()
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.x_min, -0.19966, rtol=1e-4)
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.x_max,
0.20034000000000002, rtol=1e-4)
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.y_min, 0.8000100000000001, rtol=1e-4)
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.x_max, 0.20034, rtol=1e-4)
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.y_min, 0.80001, rtol=1e-4)
assert_allclose(imviz_helper.viewers['imviz-0']._obj.state.y_max, 1.20001, rtol=1e-4)


def test_zoom_to_selected(imviz_helper, image_2d_wcs, tmp_path):
def test_zoom_to_selected(imviz_helper, image_2d_wcs):

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.import_catalog(tbl)
catalogs_plugin._obj.search()

# select both sources
catalogs_plugin.select_all()

# check viewer limits before zoom
xmin, xmax, ymin, ymax = imviz_helper.app._jdaviz_helper._default_viewer.get_limits()
xmin, xmax, ymin, ymax = imviz_helper.default_viewer._obj.get_limits()
assert xmin == ymin == -0.5
assert xmax == ymax == 499.5

Expand All @@ -320,7 +307,7 @@ def test_zoom_to_selected(imviz_helper, image_2d_wcs, tmp_path):

# 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()
xmin, xmax, ymin, ymax = imviz_helper.default_viewer._obj.get_limits()

assert_allclose((xmin + xmax) / 2, 150., atol=0.1)
assert_allclose((ymin + ymax) / 2, 150., atol=0.1)
Expand All @@ -340,7 +327,7 @@ def test_zoom_to_selected(imviz_helper, image_2d_wcs, tmp_path):
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()
xmin, xmax, ymin, ymax = imviz_helper.default_viewer._obj.get_limits()
assert_allclose((xmin + xmax) / 2, 100., atol=0.1)
assert_allclose((ymin + ymax) / 2, 100., atol=0.1)

Expand All @@ -357,7 +344,7 @@ def test_zoom_to_selected(imviz_helper, image_2d_wcs, tmp_path):
catalogs_plugin.zoom_to_selected(padding=5)


def test_offline_ecsv_catalog_with_extra_columns(imviz_helper, image_2d_wcs, tmp_path):
def test_offline_ecsv_catalog_with_extra_columns(imviz_helper, image_2d_wcs):
# Create a table with additional columns
sky = SkyCoord(ra=[337.5202807, 337.51909197, 337.51760596],
dec=[-20.83305528, -20.83222194, -20.83083304], unit='deg')
Expand All @@ -369,16 +356,13 @@ def test_offline_ecsv_catalog_with_extra_columns(imviz_helper, image_2d_wcs, tmp
'roundness': [0.01, 0.02, 0.03],
'sharpness': [0.1, 0.2, 0.3]
})
tbl_file = str(tmp_path / 'extra_columns.ecsv')
tbl.write(tbl_file, overwrite=True)

ndd = NDData(np.ones((10, 10)), wcs=image_2d_wcs)
imviz_helper.load_data(ndd, data_label='data_with_wcs')
assert len(imviz_helper.app.data_collection) == 1

catalogs_plugin = imviz_helper.plugins['Catalog Search']._obj
catalogs_plugin.from_file = tbl_file
catalogs_plugin.catalog_selected = 'From File...'
catalogs_plugin.import_catalog(tbl)
catalogs_plugin.search(error_on_fail=True)

extra_columns = ['flux', 'flux_err', 'is_extended', 'roundness', 'sharpness']
Expand All @@ -394,31 +378,25 @@ def test_offline_ecsv_catalog_with_extra_columns(imviz_helper, image_2d_wcs, tmp
assert float(item['sharpness']) == tbl['sharpness'][idx]


def test_select_catalog_table_rows(imviz_helper, image_2d_wcs, tmp_path):

def test_select_catalog_table_rows(imviz_helper, image_2d_wcs):
"""Test the ``select_rows`` functionality on table in plugin."""

arr = np.ones((500, 500))
ndd = NDData(arr, wcs=image_2d_wcs)
imviz_helper.load_data(ndd)

# write out table to load back in
# NOTE: if we ever support loading Table obj directly, replace this and
# remove tmp_path
sky_coord = SkyCoord(ra=[337.49, 337.46, 337.47, 337.48, 337.49, 337.50],
dec=[-20.81, -20.78, -20.79, -20.80, -20.77, -20.76],
unit='deg')
tbl = Table({'sky_centroid': [sky_coord],
'label': ['Source_1', 'Source_2', 'Source_3', 'Source_4',
'Source_5', 'Source_6']})
tbl_file = str(tmp_path / 'test_catalog.ecsv')
tbl.write(tbl_file, overwrite=True)

catalogs_plugin = imviz_helper.plugins['Catalog Search']
plugin_table = catalogs_plugin._obj.table

# load catalog
catalogs_plugin._obj.from_file = tbl_file
catalogs_plugin.import_catalog(tbl)
catalogs_plugin._obj.search()

# select a single row:
Expand Down

0 comments on commit 70bd2b8

Please sign in to comment.