diff --git a/CHANGES.rst b/CHANGES.rst index f58b0a2ab7..fde0d357a3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -22,7 +22,8 @@ Imviz - vmin/vmax step size in the plot options plugin is now dynamic based on the full range of the image. [#2388] -- Footprints plugin for plotting overlays of instrument footprints in the image viewer. [#2341] +- Footprints plugin for plotting overlays of instrument footprints or custom regions in the image + viewer. [#2341, #2377] Mosviz ^^^^^^ diff --git a/docs/imviz/plugins.rst b/docs/imviz/plugins.rst index a0c7de9e2a..4d5d1b249d 100644 --- a/docs/imviz/plugins.rst +++ b/docs/imviz/plugins.rst @@ -317,7 +317,8 @@ Footprints This plugin supports loading and overplotting instrument footprint overlays on the image viewers. Any number of overlays can be plotted simultaneously from any number of the available -preset instruments. +preset instruments (requires pysiaf to be installed) or by loading an Astropy regions object from +a file. The top dropdown allows renaming, adding, and removing footprint overlays. To modify the display and input parameters for a given overlay, select it in the dropdown, and modify the choices @@ -325,6 +326,9 @@ in the plugin to change its color, opacity, visibilities in any image viewer in select between various preset instruments and change the input options (position on the sky, position angle, offsets, etc). +To import a file, choose "From File..." from the presets dropdown and select a valid file (must +be able to be parsed by `regions.Regions.read`). + .. _rotate-canvas: diff --git a/jdaviz/app.py b/jdaviz/app.py index e4b5153eda..b7318cd8e8 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -140,6 +140,7 @@ def to_unit(self, data, cid, values, original_units, target_units): 'plugin-editable-select': 'components/plugin_editable_select.vue', 'plugin-add-results': 'components/plugin_add_results.vue', 'plugin-auto-label': 'components/plugin_auto_label.vue', + 'plugin-file-import-select': 'components/plugin_file_import_select.vue', 'glue-state-sync-wrapper': 'components/glue_state_sync_wrapper.vue'} _verbosity_levels = ('debug', 'info', 'warning', 'error') diff --git a/jdaviz/app.vue b/jdaviz/app.vue index 6b5a10ad0f..73b6ddc75d 100644 --- a/jdaviz/app.vue +++ b/jdaviz/app.vue @@ -102,7 +102,7 @@ - + +
+ + + + + + {{from_file.split("/").slice(-1)[0]}} + + + + + + {{ dialog_title || "Import File" }} + + {{ dialog_hint }} + + + + + + + + {{from_file_message}} + + + Valid file + + + + + +
+ Cancel + Load +
+ +
+
+
+ + + + + diff --git a/jdaviz/configs/imviz/plugins/catalogs/catalogs.py b/jdaviz/configs/imviz/plugins/catalogs/catalogs.py index c1f72431d8..8c6b619c17 100644 --- a/jdaviz/configs/imviz/plugins/catalogs/catalogs.py +++ b/jdaviz/configs/imviz/plugins/catalogs/catalogs.py @@ -1,23 +1,20 @@ -import os - import numpy as np import numpy.ma as ma from astropy import units as u from astropy.table import QTable from astropy.coordinates import SkyCoord -from traitlets import List, Unicode, Bool, Int, observe +from traitlets import List, Unicode, Bool, Int -from jdaviz.configs.default.plugins.data_tools.file_chooser import FileChooser from jdaviz.core.events import SnackbarMessage from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import (PluginTemplateMixin, ViewerSelectMixin, - SelectPluginComponent) + FileImportSelectPluginComponent, HasFileImportSelect) __all__ = ['Catalogs'] @tray_registry('imviz-catalogs', label="Catalog Search") -class Catalogs(PluginTemplateMixin, ViewerSelectMixin): +class Catalogs(PluginTemplateMixin, ViewerSelectMixin, HasFileImportSelect): """ See the :ref:`Catalog Search Plugin Documentation ` for more details. @@ -30,65 +27,33 @@ class Catalogs(PluginTemplateMixin, ViewerSelectMixin): template_file = __file__, "catalogs.vue" catalog_items = List([]).tag(sync=True) catalog_selected = Unicode("").tag(sync=True) - from_file = Unicode().tag(sync=True) - from_file_message = Unicode().tag(sync=True) results_available = Bool(False).tag(sync=True) number_of_results = Int(0).tag(sync=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.catalog = SelectPluginComponent(self, - items='catalog_items', - selected='catalog_selected', - manual_options=['SDSS', 'From File...']) - - # file chooser for From File - start_path = os.environ.get('JDAVIZ_START_DIR', os.path.curdir) - self._file_upload = FileChooser(start_path) - self.components = {'g-file-import': self._file_upload} - self._file_upload.observe(self._on_file_path_changed, names='file_path') - self._cached_table_from_file = {} - self._marker_name = 'catalog_results' + self.catalog = FileImportSelectPluginComponent(self, + items='catalog_items', + selected='catalog_selected', + manual_options=['SDSS', 'From File...']) - def _on_file_path_changed(self, event): - self.from_file_message = 'Checking if file is valid' - path = event['new'] - if (path is not None - and not os.path.exists(path) - or not os.path.isfile(path)): - self.from_file_message = 'File path does not exist' - return + # set the custom file parser for importing catalogs + self.catalog._file_parser = self._file_parser + self._marker_name = 'catalog_results' + + @staticmethod + def _file_parser(path): try: table = QTable.read(path) except Exception: - self.from_file_message = 'Could not parse file with astropy.table.QTable.read' - return + return 'Could not parse file with astropy.table.QTable.read', {} if 'sky_centroid' not in table.colnames: - self.from_file_message = 'Table does not contain required sky_centroid column' - return + return 'Table does not contain required sky_centroid column', {} - # since we loaded the file already to check if its valid, we might as well cache the table - # so we don't have to re-load it when clicking search. We'll only keep the latest entry - # though, but store in a dict so we can catch if the file path was changed from the API - self._cached_table_from_file = {path: table} - self.from_file_message = '' - - @observe('from_file') - def _from_file_changed(self, event): - if len(event['new']): - if not os.path.exists(event['new']): - raise ValueError(f"{event['new']} does not exist") - self.catalog.selected = 'From File...' - else: - # NOTE: select_default will change the value even if the current value is valid - # (so will change from 'From File...' to the first entry in the dropdown) - self.catalog.select_default() - - def vue_set_file_from_dialog(self, *args, **kwargs): - self.from_file = self._file_upload.file_path + return '', {path: table} def search(self): """ @@ -165,7 +130,7 @@ def search(self): elif self.catalog_selected == 'From File...': # all exceptions when going through the UI should have prevented setting this path # but this exceptions might be raised here if setting from_file from the UI - table = self._cached_table_from_file.get(self.from_file, QTable.read(self.from_file)) + table = self.catalog.selected_obj self.app._catalog_source_table = table skycoord_table = table['sky_centroid'] @@ -208,6 +173,21 @@ def search(self): return skycoord_table + def import_catalog(self, catalog): + """ + Import a catalog from a file path. + + Parameters + ---------- + 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) + else: # pragma: no cover + raise ValueError("catalog must be a string (file path)") + def vue_do_search(self, *args, **kwargs): # calls self.search() which handles all of the searching logic self.search() diff --git a/jdaviz/configs/imviz/plugins/catalogs/catalogs.vue b/jdaviz/configs/imviz/plugins/catalogs/catalogs.vue index 3300ab9f72..ff747dd39a 100644 --- a/jdaviz/configs/imviz/plugins/catalogs/catalogs.vue +++ b/jdaviz/configs/imviz/plugins/catalogs/catalogs.vue @@ -1,92 +1,45 @@ - - + + diff --git a/jdaviz/configs/imviz/plugins/footprints/footprints.py b/jdaviz/configs/imviz/plugins/footprints/footprints.py index 2a9f289645..29bf25236d 100644 --- a/jdaviz/configs/imviz/plugins/footprints/footprints.py +++ b/jdaviz/configs/imviz/plugins/footprints/footprints.py @@ -1,4 +1,5 @@ from traitlets import Bool, List, Unicode, observe +import numpy as np import regions from glue.core.message import DataCollectionAddMessage, DataCollectionDeleteMessage @@ -6,9 +7,11 @@ from jdaviz.core.custom_traitlets import FloatHandleEmpty from jdaviz.core.events import LinkUpdatedMessage from jdaviz.core.marks import FootprintOverlay +from jdaviz.core.region_translators import regions2roi from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import (PluginTemplateMixin, ViewerSelectMixin, - SelectPluginComponent, EditableSelectPluginComponent) + EditableSelectPluginComponent, + FileImportSelectPluginComponent, HasFileImportSelect) from jdaviz.core.user_api import PluginUserApi from jdaviz.configs.imviz.plugins.footprints import preset_regions @@ -18,7 +21,7 @@ @tray_registry('imviz-footprints', label="Footprints") -class Footprints(PluginTemplateMixin, ViewerSelectMixin): +class Footprints(PluginTemplateMixin, ViewerSelectMixin, HasFileImportSelect): """ See the :ref:`Footprints Plugin Documentation ` for more details. @@ -45,6 +48,7 @@ class Footprints(PluginTemplateMixin, ViewerSelectMixin): opacity of the filled region of the currently selected overlay * ``preset`` (:class:`~jdaviz.core.template_mixin.SelectPluginComponent`): selected overlay preset + * :meth:``import_region`` * :meth:``center_on_viewer`` * ``ra`` central right ascension for the footprint overlay @@ -79,6 +83,7 @@ class Footprints(PluginTemplateMixin, ViewerSelectMixin): fill_opacity = FloatHandleEmpty(0.1).tag(sync=True) # PRESET OVERLAYS AND OPTIONS + has_pysiaf = Bool(preset_regions._has_pysiaf).tag(sync=True) preset_items = List().tag(sync=True) preset_selected = Unicode().tag(sync=True) @@ -90,12 +95,6 @@ class Footprints(PluginTemplateMixin, ViewerSelectMixin): # TODO: dithering/mosaic options? def __init__(self, *args, **kwargs): - if not preset_regions._has_pysiaf: # pragma: nocover - # NOTE: if we want to keep this as a soft-dependency and implement other - # footprint/region options later, we could just disable the JWST presets - # instead of the entire plugin - self.disabled_msg = 'this plugin requires pysiaf to be installed' - self._ignore_traitlet_change = False self._overlays = {} @@ -114,13 +113,18 @@ def __init__(self, *args, **kwargs): on_rename=self._on_overlay_rename, on_remove=self._on_overlay_remove) - # FUTURE IMPROVEMENT: could add 'From File...' entry here that works similar to that in - # the catalogs plugin, loads in a region file, and replaces any input UI elements with just - # a reference to the filename or some text - self.preset = SelectPluginComponent(self, - items='preset_items', - selected='preset_selected', - manual_options=preset_regions._instruments.keys()) + if self.has_pysiaf: + preset_options = list(preset_regions._instruments.keys()) + else: + preset_options = ['None'] + preset_options.append('From File...') + self.preset = FileImportSelectPluginComponent(self, + items='preset_items', + selected='preset_selected', + manual_options=preset_options) + + # set the custom file parser for importing catalogs + self.preset._file_parser = self._file_parser # disable if pixel-linked AND only a single item in the data collection self.hub.subscribe(self, LinkUpdatedMessage, handler=self._on_link_type_updated) @@ -130,14 +134,13 @@ def __init__(self, *args, **kwargs): @property def user_api(self): - if preset_regions._has_pysiaf: - return PluginUserApi(self, expose=('overlay', - 'rename_overlay', 'add_overlay', 'remove_overlay', - 'viewer', 'visible', 'color', 'fill_opacity', - 'preset', 'center_on_viewer', 'ra', 'dec', 'pa', - 'v2_offset', 'v3_offset', - 'overlay_regions')) - return PluginUserApi(self) + return PluginUserApi(self, expose=('overlay', + 'rename_overlay', 'add_overlay', 'remove_overlay', + 'viewer', 'visible', 'color', 'fill_opacity', + 'preset', 'import_region', + 'center_on_viewer', 'ra', 'dec', 'pa', + 'v2_offset', 'v3_offset', + 'overlay_regions')) def _get_marks(self, viewer, overlay=None): matches = [mark for mark in viewer.figure.marks @@ -153,6 +156,27 @@ def marks(self): if hasattr(viewer, 'figure')} for overlay in self.overlay.choices} + @staticmethod + def _file_parser(path): + def _ensure_sky(region): + if isinstance(region, regions.Regions): + return np.all([_ensure_sky(reg) for reg in region.regions]) + return hasattr(region, 'to_pixel') + + if isinstance(path, (regions.Region, regions.Regions)): + if not _ensure_sky(path): + return 'Region is not a SkyRegion', {} + from_file_string = f'API: {path.__class__.__name__} object' + return '', {from_file_string: path} + + try: + region = regions.Regions.read(path) + except Exception: + return 'Could not parse region from file', {} + if not _ensure_sky(region): + return 'Region is not a SkyRegion', {} + return '', {path: region} + def _on_link_type_updated(self, msg=None): self.is_pixel_linked = (getattr(self.app, '_link_type', None) == 'pixels' and len(self.app.data_collection) > 1) @@ -165,6 +189,13 @@ def vue_link_by_wcs(self, *args): # when pixel-linked is reintroduced. self.app._jdaviz_helper.plugins['Links Control'].link_type = 'WCS' + def _ensure_first_overlay(self): + if not len(self._overlays): + # create the first default overlay + self._change_overlay() + # update the marks + self._preset_args_changed() + def rename_overlay(self, old_lbl, new_lbl): """ Rename an existing overlay instance @@ -248,11 +279,7 @@ def _on_is_active_changed(self, *args): return if self.is_active: - if not len(self._overlays): - # create the first default overlay - self._change_overlay() - # update the marks - self._preset_args_changed() + self._ensure_first_overlay() for overlay, viewer_marks in self.marks.items(): for viewer_id, marks in viewer_marks.items(): @@ -297,6 +324,10 @@ def _change_overlay(self, *args): # create new entry with defaults (any defaults not provided here will be carried over # from the previous selection based on current traitlet values) self._overlays[self.overlay_selected] = {'color': '#c75109'} + if self.preset_selected == 'From File...': + # don't carry over the imported file/region to the next selection + self._overlays[self.overlay_selected]['from_file'] = '' + self._overlays[self.overlay_selected]['preset'] = self.preset.choices[0] if len(self._overlays) == 1 and len(self.viewer.selected): # default to the center of the current zoom limits of the first selected viewer self.center_on_viewer(self.viewer.selected[0]) @@ -307,7 +338,8 @@ def _change_overlay(self, *args): # traitlets simultaneously (and since we're only updating traitlets to a previously-set # overlay, we shouldn't have to update anything with the marks themselves) self._ignore_traitlet_change = True - for attr in ('preset_selected', 'visible', 'color', 'fill_opacity', 'viewer_selected', + for attr in ('from_file', 'preset_selected', + 'visible', 'color', 'fill_opacity', 'viewer_selected', 'ra', 'dec', 'pa', 'v2_offset', 'v3_offset'): key = attr.split('_selected')[0] @@ -321,6 +353,7 @@ def _change_overlay(self, *args): # dict above. fp[key] = getattr(self, attr) self._ignore_traitlet_change = False + self._preset_args_changed() def _mark_visible(self, viewer_id, overlay=None): if not self.is_active: @@ -365,6 +398,25 @@ def _display_args_changed(self, msg={}): mark.colors = [self.color] mark.fill_opacities = [self.fill_opacity] + def import_region(self, region): + """ + Import an Astropy regions object (or file). + + Parameters + ---------- + region : str or regions.Regions object + """ + self._ensure_first_overlay() + if isinstance(region, (regions.Region, regions.Regions)): + self.preset.import_obj(region) + elif isinstance(region, str): # TODO: support path objects? + self.preset.import_file(region) + else: + raise TypeError("region must be a regions.Regions object or string (file path)") + # _preset_args_changed was probably already triggered by from_file traitlet changing, but + # that may have been before the file was fully parsed and available from preset.selected_obj + self._preset_args_changed() + @property def overlay_regions(self): """ @@ -374,10 +426,20 @@ def overlay_regions(self): callable_kwargs = {k: getattr(self, k) for k in ('ra', 'dec', 'pa', 'v2_offset', 'v3_offset')} - regs = preset_regions.jwst_footprint(self.preset_selected, **callable_kwargs) + if self.preset_selected == 'From File...': + # we need to cache these locally in order to support multiple files/regions between + # different overlay entries all selecting From File... + overlay = self._overlays.get(self.overlay_selected, {}) + if 'regions' not in overlay and isinstance(self.preset.selected_obj, regions.Regions): + overlay['regions'] = self.preset.selected_obj + regs = overlay.get('regions', []) + elif self.has_pysiaf and self.preset_selected in preset_regions._instruments: + regs = preset_regions.jwst_footprint(self.preset_selected, **callable_kwargs) + else: # pragma: no cover + regs = [] return regs - @observe('preset_selected', 'ra', 'dec', 'pa', 'v2_offset', 'v3_offset') + @observe('preset_selected', 'from_file', 'ra', 'dec', 'pa', 'v2_offset', 'v3_offset') def _preset_args_changed(self, msg={}): if self._ignore_traitlet_change: return @@ -392,6 +454,10 @@ def _preset_args_changed(self, msg={}): if len(name): self._overlays[self.overlay_selected][name] = msg.get('new') + if name == 'from_file' and 'regions' in self._overlays[self.overlay_selected]: + # then the file may have been changed from the API, so we need to clear the cache + # the cache will then be re-populated on the call to self.overlay_regions below + del self._overlays[self.overlay_selected]['regions'] regs = self.overlay_regions @@ -404,7 +470,6 @@ def _preset_args_changed(self, msg={}): if wcs is None: continue existing_overlays = self._get_marks(viewer, self.overlay_selected) - regs = [r for r in regs if isinstance(r, regions.PolygonSkyRegion)] update_existing = len(existing_overlays) == len(regs) if not update_existing and len(existing_overlays): # clear any existing marks (length has changed, perhaps a new preset) @@ -415,21 +480,39 @@ def _preset_args_changed(self, msg={}): # https://github.com/spacetelescope/jwst_novt/blob/main/jwst_novt/interact/display.py new_marks = [] for i, reg in enumerate(regs): + if (not isinstance(reg, regions.Region) + or not hasattr(reg, 'to_pixel')): # pragma: no cover + # NOTE: this is pre-checked for API/file selection in the file-parser + # and built-in presets should be designed to never hit this error + # in the future we may support pixel regions as well, but need to decide how + # to properly handle those scenarios for both WCS and pixel-linking + raise NotImplementedError("regions must all be SkyRegions") + pixel_region = reg.to_pixel(wcs) - if not isinstance(reg, regions.PolygonSkyRegion): # pragma: nocover - # if we ever want to support plotting centers as well, - # see jwst_novt/interact/display.py - continue - x_coords = pixel_region.vertices.x - y_coords = pixel_region.vertices.y + if isinstance(pixel_region, regions.PolygonPixelRegion): + x_coords = pixel_region.vertices.x + y_coords = pixel_region.vertices.y + + # bqplot marker does not respect image pixel sizes, so need to render as polygon. + elif isinstance(pixel_region, regions.RectanglePixelRegion): + pixel_region = pixel_region.to_polygon() + x_coords = pixel_region.vertices.x + y_coords = pixel_region.vertices.y + elif isinstance(pixel_region, (regions.CirclePixelRegion, + regions.EllipsePixelRegion, + regions.CircleAnnulusPixelRegion)): + roi = regions2roi(pixel_region) + x_coords, y_coords = roi.to_polygon() + else: # pragma: no cover + raise NotImplementedError("could not parse coordinates from regions - please report this issue") # noqa + if update_existing: mark = existing_overlays[i] with mark.hold_sync(): mark.x = x_coords mark.y = y_coords else: - # instrument aperture regions mark = FootprintOverlay( viewer, self.overlay_selected, @@ -437,8 +520,7 @@ def _preset_args_changed(self, msg={}): y=y_coords, colors=[self.color], fill_opacities=[self.fill_opacity], - visible=visible - ) + visible=visible) new_marks.append(mark) if not update_existing and len(new_marks): diff --git a/jdaviz/configs/imviz/plugins/footprints/footprints.vue b/jdaviz/configs/imviz/plugins/footprints/footprints.vue index 8d9b648325..2807630cd8 100644 --- a/jdaviz/configs/imviz/plugins/footprints/footprints.vue +++ b/jdaviz/configs/imviz/plugins/footprints/footprints.vue @@ -78,86 +78,95 @@ Footprint Definition - - - + + To use JWST footprints, install pysiaf and restart jdaviz + - - Center RA/Dec - - - {{viewer_ref}} - - - + + + - - - +
+ + Center RA/Dec + + + {{viewer_ref}} + + + - - - + + + - - - + + + - - - - - - - + + + + + + + + + + + +
diff --git a/jdaviz/configs/imviz/plugins/footprints/preset_regions.py b/jdaviz/configs/imviz/plugins/footprints/preset_regions.py index 7fb5a25c5a..e885abd395 100644 --- a/jdaviz/configs/imviz/plugins/footprints/preset_regions.py +++ b/jdaviz/configs/imviz/plugins/footprints/preset_regions.py @@ -52,7 +52,7 @@ "NRCB1_FULL", "NRCB2_FULL", "NRCB3_FULL", - "NRCB4_FULL",], + "NRCB4_FULL"], 'NIRCam:long': ["NRCA5_FULL", "NRCB5_FULL"], 'NIRISS': ['NIS_AMIFULL'], 'MIRI': ['MIRIM_FULL'], @@ -92,8 +92,8 @@ def jwst_footprint(instrument, ra, dec, pa, v2_offset=0.0, v3_offset=0.0, apertu if not _has_pysiaf: raise ImportError('jwst_footprint requires pysiaf to be installed') - if instrument not in _instruments: - raise ValueError(f"instrument must be one of {[', '].join(_instruments.keys())}") + if instrument not in _instruments: # pragma: no cover + raise ValueError(f"instrument must be one of {', '.join(_instruments.keys())}") siaf_interface = pysiaf.Siaf(_instruments.get(instrument)) diff --git a/jdaviz/configs/imviz/tests/test_catalogs.py b/jdaviz/configs/imviz/tests/test_catalogs.py index 3927ac90ea..7465ea0d0e 100644 --- a/jdaviz/configs/imviz/tests/test_catalogs.py +++ b/jdaviz/configs/imviz/tests/test_catalogs.py @@ -130,13 +130,13 @@ def test_from_file_parsing(imviz_helper, tmp_path): catalogs_plugin = imviz_helper.app.get_tray_item_from_name('imviz-catalogs') # _on_file_path_changed is fired when changing the selection in the file dialog - catalogs_plugin._on_file_path_changed({'new': './invalid_path'}) + catalogs_plugin.catalog._on_file_path_changed({'new': './invalid_path'}) assert catalogs_plugin.from_file_message == 'File path does not exist' - # observe('from_file') is fired when setting from_file from the API (or after clicking - # select in the file dialog) - with pytest.raises(ValueError, match='./invalid_path does not exist'): - catalogs_plugin.from_file = './invalid_path' + # observe('from_file') is fired when setting from_file from the API or via import_file + # (or after clicking select in the file dialog) + with pytest.raises(ValueError, match='./invalid_path is not a valid file path'): + catalogs_plugin.import_catalog('./invalid_path') # setting to a blank string from the API resets the catalog selection to the # default/first entry @@ -145,13 +145,13 @@ def test_from_file_parsing(imviz_helper, tmp_path): not_table_file = tmp_path / 'not_table.tst' not_table_file.touch() - catalogs_plugin._on_file_path_changed({'new': not_table_file}) + catalogs_plugin.catalog._on_file_path_changed({'new': not_table_file}) assert catalogs_plugin.from_file_message == 'Could not parse file with astropy.table.QTable.read' # noqa qtable = QTable({'not_sky_centroid': [1, 2, 3]}) not_valid_table = tmp_path / 'not_valid_table.ecsv' qtable.write(not_valid_table, overwrite=True) - catalogs_plugin._on_file_path_changed({'new': not_valid_table}) + catalogs_plugin.catalog._on_file_path_changed({'new': not_valid_table}) assert catalogs_plugin.from_file_message == 'Table does not contain required sky_centroid column' # noqa diff --git a/jdaviz/configs/imviz/tests/test_footprints.py b/jdaviz/configs/imviz/tests/test_footprints.py index 20947d6e04..a313bce293 100644 --- a/jdaviz/configs/imviz/tests/test_footprints.py +++ b/jdaviz/configs/imviz/tests/test_footprints.py @@ -1,6 +1,9 @@ import numpy as np import pytest +import astropy.units as u from astropy.nddata import NDData +from astropy.coordinates import SkyCoord +from regions import PixCoord, CirclePixelRegion, CircleSkyRegion, RectangleSkyRegion from jdaviz.core.marks import FootprintOverlay from jdaviz.configs.imviz.plugins.footprints.preset_regions import _all_apertures @@ -12,7 +15,7 @@ def _get_markers_from_viewer(viewer): return [m for m in viewer.figure.marks if isinstance(m, FootprintOverlay)] -def test_user_api(imviz_helper, image_2d_wcs): +def test_user_api(imviz_helper, image_2d_wcs, tmp_path): arr = np.ones((10, 10)) ndd = NDData(arr, wcs=image_2d_wcs) # load the image twice to test linking @@ -26,7 +29,7 @@ def test_user_api(imviz_helper, image_2d_wcs): assert plugin._obj.is_pixel_linked is False # test that each of the supported instruments/presets work - for preset in plugin.preset.choices: + for preset in (preset for preset in plugin.preset.choices if preset != 'From File...'): plugin.preset = preset viewer_marks = _get_markers_from_viewer(imviz_helper.default_viewer) @@ -78,9 +81,49 @@ def test_user_api(imviz_helper, image_2d_wcs): plugin.viewer.select_all() assert viewer_marks[0].visible is True - # test centering logic + # test centering logic (for now just that it doesn't fail) plugin.center_on_viewer() + # test from file/API ability + reg = plugin.overlay_regions + plugin.import_region(reg) + assert plugin.preset.selected == 'From File...' + assert len(viewer_marks) == len(reg) + # clearing the file should default to the PREVIOUS preset (last from the for-loop above) + plugin._obj.vue_file_import_cancel() + assert plugin.preset.selected == preset + + tmp_file = str(tmp_path / 'test_region.reg') + reg.write(tmp_file, format='ds9') + plugin.import_region(tmp_file) + assert plugin.preset.selected == 'From File...' + assert plugin.preset.from_file == tmp_file + + # test single region (footprints contain multiple regions) + valid_region_sky = CircleSkyRegion(center=SkyCoord(42, 43, unit='deg', frame='fk5'), + radius=3 * u.deg) + plugin.import_region(valid_region_sky) + + # test RectangleSkyRegion -> RectanglePixelRegion + valid_region_sky = RectangleSkyRegion(center=SkyCoord(42, 43, unit='deg', frame='fk5'), + height=3 * u.deg, width=2 * u.deg) + plugin.import_region(valid_region_sky) + + with pytest.raises(ValueError): + plugin.import_region('./invalid_path.reg') + with pytest.raises(TypeError): + plugin.import_region(5) + + invalid_region = CirclePixelRegion(PixCoord(x=8, y=7), radius=3.5) + with pytest.raises(ValueError): + plugin.import_region(invalid_region) + tmp_invalid_file = str(tmp_path / 'test_invalid_region.reg') + invalid_region.write(tmp_invalid_file, format='ds9') + with pytest.raises(ValueError): + plugin.import_region(tmp_invalid_file) + assert plugin.preset.selected == preset + # with the plugin no longer active, marks should not be visible assert plugin._obj.is_active is False + viewer_marks = _get_markers_from_viewer(imviz_helper.default_viewer) assert viewer_marks[0].visible is False diff --git a/jdaviz/core/marks.py b/jdaviz/core/marks.py index f5a4bd4b6d..5ec1bc866b 100644 --- a/jdaviz/core/marks.py +++ b/jdaviz/core/marks.py @@ -59,7 +59,7 @@ def _update_counts(self, *args): self.right.text = [f'{oob_right} \u25b6' if oob_right > 0 else ''] -class PluginMark(): +class PluginMark: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.xunit = None diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index cb42a69590..47a462d613 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -5,6 +5,7 @@ import bqplot from contextlib import contextmanager import numpy as np +import os import threading import time @@ -45,6 +46,7 @@ 'ViewerSelect', 'ViewerSelectMixin', 'LayerSelect', 'LayerSelectMixin', 'DatasetSelect', 'DatasetSelectMixin', + 'FileImportSelectPluginComponent', 'HasFileImportSelect', 'Table', 'TableMixin', 'Plot', 'PlotMixin', 'AutoTextField', 'AutoTextFieldMixin', @@ -519,6 +521,7 @@ def __init__(self, *args, **kwargs): filters = kwargs.pop('filters', [])[:] # [:] needed to force copy from kwarg default super().__init__(*args, **kwargs) + self._selected_previous = None self._cached_properties = ["selected_obj", "selected_item"] self._default_mode = default_mode @@ -609,6 +612,15 @@ def select_next(self): self.selected = cycle[(curr_ind + 1) % len(cycle)] return self.selected + def select_previous(self): + """ + Apply and return the previous selection (or default option if no previous selection) + """ + if self._selected_previous is None: + return self.select_default() + self.selected = self._selected_previous + return self.selected + @property def default_text(self): return self._default_text @@ -730,6 +742,7 @@ def _multiselect_changed(self, event): self._apply_default_selection() def _selected_changed(self, event): + self._selected_previous = event['old'] self._clear_cache() if self.is_multiselect: if not isinstance(event['new'], list): @@ -786,6 +799,131 @@ def _selected_changed(self, event): self.selected = self.labels[ind] +class FileImportSelectPluginComponent(SelectPluginComponent): + """ + IMPORTANT: Always accompany with HasFileImportSelect + IMPORTANT: currently assumed only one instance per-plugin + + Example template (label and hint are optional):: + + + + + """ + def __init__(self, plugin, **kwargs): + self._cached_obj = {} + + if "From File..." not in kwargs['manual_options']: + kwargs['manual_options'] += ['From File...'] + + if not isinstance(plugin, HasFileImportSelect): # pragma: no cover + raise NotImplementedError("plugin must inherit from HasFileImportSelect") + + super().__init__(plugin, + from_file='from_file', from_file_message='from_file_message', + **kwargs) + + self.plugin._file_chooser.observe(self._on_file_path_changed, names='file_path') + # reference back here so the plugin can reset to default + self.plugin._file_chooser._select_component = self + + def _default_file_parser(path): + # by default, just return the file path itself (and allow all files) + return '', {path: path} + + self._file_parser = kwargs.pop('file_parser', _default_file_parser) + self.add_observe('from_file', self._from_file_changed) + + @property + def selected_obj(self): + if self.selected == 'From File...': + return self._cached_obj.get(self.from_file, self._file_parser(self.from_file)[1]) + return super().selected_obj + + def _from_file_changed(self, event): + if event['new'].startswith('API:'): + # object imported from the API: parsing is already handled + return + if len(event['new']): + if event['new'] != self.plugin._file_chooser.file_path: + # then need to run the parser or check for valid path + if not os.path.exists(event['new']): + if self.selected == 'From File...': + self.select_previous() + raise ValueError(f"{event['new']} is not a valid file path") + + # run through the parsers and check the validity + self._on_file_path_changed(event) + if self.from_file_message: + if self.selected == 'From File...': + self.select_previous() + raise ValueError(self.from_file_message) + + self.selected = 'From File...' + + elif self.selected == 'From File...': + self.select_previous() + + def _on_file_path_changed(self, event): + self.from_file_message = 'Checking if file is valid' + path = event['new'] + if (path is not None + and not os.path.exists(path) + or not os.path.isfile(path)): + self.from_file_message = 'File path does not exist' + return + + self.from_file_message, self._cached_obj = self._file_parser(path) + + def import_file(self, path): + """ + Select 'From File...' and set the path. + """ + # NOTE: this will trigger self._from_file_changed which in turn will + # pass through the parser, raise an error if necessary, and set + # self.selected accordingly + self.from_file = path + + def import_obj(self, obj): + """ + Import a supported object directly from the API. + """ + msg, self._cached_obj = self._file_parser(obj) + if msg: + raise ValueError(msg) + self.from_file = list(self._cached_obj.keys())[0] + self.selected = 'From File...' + + +class HasFileImportSelect(VuetifyTemplate, HubListener): + from_file = Unicode().tag(sync=True) + from_file_message = Unicode().tag(sync=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # imported here to avoid circular import + from jdaviz.configs.default.plugins.data_tools.file_chooser import FileChooser + + start_path = os.environ.get('JDAVIZ_START_DIR', os.path.curdir) + self._file_chooser = FileChooser(start_path) + self.components = {'g-file-import': self._file_chooser} + + def vue_file_import_accept(self, *args, **kwargs): + self.from_file = self._file_chooser.file_path + + def vue_file_import_cancel(self, *args, **kwargs): + self._file_chooser._select_component.select_previous() + self.from_file = '' + + class EditableSelectPluginComponent(SelectPluginComponent): """ Plugin select with support for renaming, adding, and deleting items (by the user).