From e9ee4feb2b923b6af5badc27d126a82400cff80e Mon Sep 17 00:00:00 2001 From: Kyle Conroy Date: Wed, 13 Mar 2024 15:56:43 -0400 Subject: [PATCH 1/4] export subsets --- CHANGES.rst | 2 +- docs/cubeviz/plugins.rst | 6 +- docs/imviz/plugins.rst | 1 + .../configs/default/plugins/export/export.py | 88 ++++++++- .../configs/default/plugins/export/export.vue | 46 +++-- .../plugins/export/tests/test_export.py | 185 ++++++++++++++++++ 6 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 jdaviz/configs/default/plugins/export/tests/test_export.py diff --git a/CHANGES.rst b/CHANGES.rst index 1efde91020..379169df48 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -15,7 +15,7 @@ New Features - "Export Plot" plugin is now replaced with the more general "Export" plugin. [#2722] -- "Export" plugin supports exporting plugin tables. [#2755] +- "Export" plugin supports exporting plugin tables and non non-composite spatial subsets.[#2755, #2760] Cubeviz ^^^^^^^ diff --git a/docs/cubeviz/plugins.rst b/docs/cubeviz/plugins.rst index 1d49650565..06cf9087a2 100644 --- a/docs/cubeviz/plugins.rst +++ b/docs/cubeviz/plugins.rst @@ -330,7 +330,11 @@ have valid flux units. For 3D data, the current :ref:`slice` is used. Export ====== -This plugin allows exporting the plot in a given viewer to various image formats. +This plugin allows exporting: + +* the plot in a given viewer to a PNG or SVG file, +* a table in a plugin to ecsv +* subsets as a region to .fits or .reg file. .. _cubeviz-export-video: diff --git a/docs/imviz/plugins.rst b/docs/imviz/plugins.rst index 43dea59964..0bccb40be5 100644 --- a/docs/imviz/plugins.rst +++ b/docs/imviz/plugins.rst @@ -409,3 +409,4 @@ This plugin allows exporting: * the plot in a given viewer to a PNG or SVG file, * a table in a plugin to ecsv +* subsets as a region to .fits or .reg file. diff --git a/jdaviz/configs/default/plugins/export/export.py b/jdaviz/configs/default/plugins/export/export.py index 2c3f876e4c..f3b364f48a 100644 --- a/jdaviz/configs/default/plugins/export/export.py +++ b/jdaviz/configs/default/plugins/export/export.py @@ -9,6 +9,8 @@ ViewerSelectMixin, DatasetMultiSelectMixin, SubsetSelectMixin, PluginTableSelectMixin, MultiselectMixin, with_spinner) +from glue.core.message import SubsetCreateMessage, SubsetDeleteMessage, SubsetUpdateMessage + from jdaviz.core.events import AddDataMessage, SnackbarMessage from jdaviz.core.user_api import PluginUserApi @@ -46,7 +48,7 @@ class Export(PluginTemplateMixin, ViewerSelectMixin, SubsetSelectMixin, # feature flag for cone support dev_dataset_support = Bool(False).tag(sync=True) # when enabling: add entries to docstring - dev_subset_support = Bool(False).tag(sync=True) # when enabling: add entries to docstring + dev_plot_support = Bool(False).tag(sync=True) # when enabling: add entries to docstring dev_multi_support = Bool(False).tag(sync=True) # when enabling: add entries to docstring @@ -59,8 +61,14 @@ class Export(PluginTemplateMixin, ViewerSelectMixin, SubsetSelectMixin, table_format_items = List().tag(sync=True) table_format_selected = Unicode().tag(sync=True) + subset_format_items = List().tag(sync=True) + subset_format_selected = Unicode().tag(sync=True) + filename = Unicode().tag(sync=True) + # if selected subset is spectral or composite, display message and disable export + subset_invalid_msg = Unicode().tag(sync=True) + # For Cubeviz movie. movie_enabled = Bool(False).tag(sync=True) i_start = IntHandleEmpty(0).tag(sync=True) @@ -100,6 +108,12 @@ def __init__(self, *args, **kwargs): selected='table_format_selected', manual_options=table_format_options) + subset_format_options = ['fits', 'reg'] + self.subset_format = SelectPluginComponent(self, + items='subset_format_items', + selected='subset_format_selected', + manual_options=subset_format_options) + # default selection: self.dataset._default_mode = 'empty' self.table._default_mode = 'empty' @@ -110,19 +124,26 @@ def __init__(self, *args, **kwargs): if self.config == "cubeviz": self.session.hub.subscribe(self, AddDataMessage, handler=self._on_cubeviz_data_added) # noqa: E501 + self.session.hub.subscribe(self, SubsetCreateMessage, + handler=self._set_subset_not_supported_msg) + self.session.hub.subscribe(self, SubsetUpdateMessage, + handler=self._set_subset_not_supported_msg) + self.session.hub.subscribe(self, SubsetDeleteMessage, + handler=self._set_subset_not_supported_msg) + @property def user_api(self): # TODO: backwards compat for save_figure, save_movie, # i_start, i_end, movie_fps, movie_filename # TODO: expose export method once API is finalized + expose = ['viewer', 'viewer_format', + 'subset', 'subset_format', 'table', 'table_format', 'filename', 'export'] if self.dev_dataset_support: expose += ['dataset'] - if self.dev_subset_support: - expose += ['subset'] if self.dev_plot_support: expose += ['plot'] if self.dev_multi_support: @@ -150,6 +171,7 @@ def _sync_multiselect_traitlets(self, event): @observe('viewer_selected', 'dataset_selected', 'subset_selected', 'table_selected', 'plot_selected') def _sync_singleselect(self, event): + if not hasattr(self, 'dataset') or not hasattr(self, 'viewer'): # plugin not fully intialized return @@ -163,6 +185,27 @@ def _sync_singleselect(self, event): 'table_selected', 'plot_selected'): if name != attr: setattr(self, attr, '') + if attr == 'subset_selected': + self._set_subset_not_supported_msg() + + def _set_subset_not_supported_msg(self, msg=None): + """ + Check if selected subset is spectral or composite, and warn and + disable Export button until these are supported. + """ + + if self.subset.selected is not None: + subset = self.app.get_subsets(self.subset.selected) + if self.subset.selected == '': + self.subset_invalid_msg = '' + elif self.app._is_subset_spectral(subset[0]): + self.subset_invalid_msg = 'Export for spectral subsets not supported.' + elif len(subset) > 1: + self.subset_invalid_msg = 'Export for composite subsets not supported.' + else: + self.subset_invalid_msg = '' + else: # no subset selected (can be '' instead of None if previous selection made) + self.subset_invalid_msg = '' @with_spinner() def export(self, filename=None, show_dialog=None): @@ -176,8 +219,7 @@ def export(self, filename=None, show_dialog=None): """ if self.dataset.selected is not None and len(self.dataset.selected): raise NotImplementedError("dataset export not yet supported") - if self.subset.selected is not None and len(self.subset.selected): - raise NotImplementedError("subset export not yet supported") + if self.plot.selected is not None and len(self.plot.selected): raise NotImplementedError("plot export not yet supported") if self.multiselect: @@ -200,11 +242,21 @@ def export(self, filename=None, show_dialog=None): self.save_movie(viewer, filename, filetype) else: self.save_figure(viewer, filename, filetype, show_dialog=show_dialog) + elif len(self.table.selected): filetype = self.table_format.selected if not filename.endswith(filetype): filename += f".{filetype}" self.table.selected_obj.export_table(filename, overwrite=True) + + elif len(self.subset.selected): + selected_subset_label = self.subset.selected + filetype = self.subset_format.selected + if len(filename): + if not filename.endswith(filetype): + filename += f".{filetype}" + self.save_subset_as_region(selected_subset_label, filename) + else: raise ValueError("nothing selected for export") return filename @@ -221,6 +273,7 @@ def vue_export_from_ui(self, *args, **kwargs): f"Exported to {filename}", sender=self, color="success")) def save_figure(self, viewer, filename=None, filetype="png", show_dialog=False): + if filetype == "png": if filename is None or show_dialog: viewer.figure.save_png(str(filename) if filename is not None else None) @@ -403,6 +456,31 @@ def save_movie(self, viewer, filename, filetype, i_start=None, i_end=None, fps=N return filename + def save_subset_as_region(self, selected_subset_label, filename): + """ + Save a subset to file as a Region object in the working directory. + Currently only enabled for non-composite spatial subsets. Can be saved + as a .fits or .reg file. If link type is currently set to 'pixel', + then a pixel region will be saved. If link type is 'wcs', then a sky + region will be saved. If a file with the same name already exists in the + working directory, it will be overwriten. + """ + + # type of region saved depends on link type + link_type = getattr(self.app, '_link_type', None) + + region = self.app.get_subsets(subset_name=selected_subset_label, + include_sky_region=link_type == 'wcs') + + # warn when trying to export a composite subset + if len(region) > 1: + self.not_supported_warn = True + raise NotImplementedError("Export not yet supported for composite subsets.") + + region = region[0][f'{"sky_" if link_type == "wcs" else ""}region'] + + region.write(filename, overwrite=True) + def vue_interrupt_recording(self, *args): # pragma: no cover self.movie_interrupt = True # TODO: this will need updating when batch/multiselect support is added diff --git a/jdaviz/configs/default/plugins/export/export.vue b/jdaviz/configs/default/plugins/export/export.vue index 49397a1fe9..2d6320e705 100644 --- a/jdaviz/configs/default/plugins/export/export.vue +++ b/jdaviz/configs/default/plugins/export/export.vue @@ -89,16 +89,37 @@ -
- Subsets - - -
+
+ Subsets + Export subset as astropy region. +
+ + +
+ + + + {{subset_invalid_msg}} + + + + + + +
Plugin Tables @@ -152,7 +173,6 @@ :results_isolated_to_plugin="true" @click="interrupt_recording" :disabled="!movie_recording" - > stop @@ -162,13 +182,13 @@ @click="export_from_ui" :spinner="spinner" :disabled="filename.length === 0 || - movie_recording || + movie_recording || + subset_invalid_msg.length > 0 || (viewer_selected.length > 0 && viewer_format_selected == 'mp4' && !movie_enabled)" > Export - diff --git a/jdaviz/configs/default/plugins/export/tests/test_export.py b/jdaviz/configs/default/plugins/export/tests/test_export.py new file mode 100644 index 0000000000..fedb36fc26 --- /dev/null +++ b/jdaviz/configs/default/plugins/export/tests/test_export.py @@ -0,0 +1,185 @@ +import numpy as np +import os +import pytest +import re + +from astropy.io import fits +from astropy.nddata import NDData +import astropy.units as u +from glue.core.edit_subset_mode import AndMode, NewMode +from glue.core.roi import CircularROI, XRangeROI +from regions import Regions, CircleSkyRegion +from specutils import Spectrum1D + + +class TestExportSubsets(): + """ + Tests for exporting subsets. Currently limited to non-composite spatial + subsets. + """ + + def test_basic_export_subsets_imviz(self, tmp_path, imviz_helper): + + data = NDData(np.ones((500, 500)) * u.nJy) + + imviz_helper.load_data(data) + + imviz_helper.app.get_viewer('imviz-0').apply_roi(CircularROI(xc=250, + yc=250, + radius=100)) + export_plugin = imviz_helper.plugins['Export']._obj + export_plugin.subset.selected = 'Subset 1' + + assert export_plugin.subset_format.selected == 'fits' # default format + assert export_plugin.subset_invalid_msg == '' # for non-composite spatial + + export_plugin.export() + assert os.path.isfile('imviz_export.fits') + + # read exported file back in + with fits.open('imviz_export.fits') as hdu: + fits_region = hdu[1].data[0] + + assert fits_region[0] == 'circle' + assert fits_region[1] == fits_region[2] == 250.0 + assert fits_region[3] == 100.0 + assert fits_region[4] == 0.0 + + # now test changing file format + export_plugin.subset_format.selected = 'reg' + export_plugin.export() + assert os.path.isfile('imviz_export.reg') + + # read exported file back in + region = Regions.read('imviz_export.reg')[0] + assert region.center.x == 250.0 + assert region.center.y == 250.0 + assert region.radius == 100.0 + + # changing file name + export_plugin.filename = 'test' + export_plugin.export() + assert os.path.isfile('test.reg') + + # test that invalid file extension raises an error + with pytest.raises(ValueError, + match=re.escape("x not one of ['fits', 'reg'], reverting selection to reg")): # noqa + export_plugin.subset_format.selected = 'x' + + def test_not_implemented(self, cubeviz_helper, spectral_cube_wcs): + """ + Test that trying to export non-supported subsets + (spectral and composite) produces + the correct warning message to display in UI). + """ + + data = Spectrum1D(flux=np.ones((500, 500, 2)) * u.nJy, + wcs=spectral_cube_wcs) + cubeviz_helper.load_data(data) + + cubeviz_helper.app.get_viewer('flux-viewer').apply_roi(CircularROI(xc=255, + yc=255, + radius=50)) + cubeviz_helper.app.session.edit_subset_mode.mode = AndMode + cubeviz_helper.app.get_viewer('flux-viewer').apply_roi(CircularROI(xc=200, + yc=250, + radius=50)) + + export_plugin = cubeviz_helper.plugins['Export']._obj + export_plugin.subset.selected = 'Subset 1' + + assert export_plugin.subset_invalid_msg == 'Export for composite subsets not supported.' + + cubeviz_helper.app.session.edit_subset_mode.mode = NewMode + cubeviz_helper.app.get_viewer("spectrum-viewer").apply_roi(XRangeROI(5, 15.5)) + export_plugin.subset.selected = 'Subset 2' + + assert export_plugin.subset_invalid_msg == 'Export for spectral subsets not supported.' + + def test_export_subsets_wcs(self, tmp_path, imviz_helper, spectral_cube_wcs): + + # using cube WCS instead of 2d imaging wcs for consistancy with + # cubeviz test. accessing just the spatial part of this. + wcs = spectral_cube_wcs.celestial + + data = NDData(np.ones((500, 500)) * u.nJy, wcs=wcs) + + imviz_helper.load_data(data) # load data twice so we can link them + imviz_helper.load_data(data) + + imviz_helper.link_data(link_type='wcs') + + imviz_helper.app.get_viewer('imviz-0').apply_roi(CircularROI(xc=8, + yc=6, + radius=.2)) + + export_plugin = imviz_helper.plugins['Export']._obj + export_plugin.subset.selected = 'Subset 1' + + assert export_plugin.subset_invalid_msg == '' # for non-composite spatial + + # test changing link type results in an output file with a sky region + export_plugin.filename = 'sky_region' + export_plugin.subset_format.selected = 'reg' + export_plugin.export() + + assert os.path.isfile('sky_region.reg') + + assert isinstance(Regions.read('sky_region.reg')[0], CircleSkyRegion) + + def test_basic_export_subsets_cubeviz(self, tmp_path, cubeviz_helper, spectral_cube_wcs): + + data = Spectrum1D(flux=np.ones((128, 128, 256)) * u.nJy, wcs=spectral_cube_wcs) + + cubeviz_helper.load_data(data) + + cubeviz_helper.app.get_viewer('flux-viewer').apply_roi(CircularROI(xc=50, + yc=50, + radius=10)) + + export_plugin = cubeviz_helper.plugins['Export']._obj + export_plugin.subset.selected = 'Subset 1' + + assert export_plugin.subset_format.selected == 'fits' # default format + + export_plugin.export() + assert os.path.isfile('cubeviz_export.fits') + + # read exported file back in + with fits.open('cubeviz_export.fits') as hdu: + fits_region = hdu[1].data[0] + + assert fits_region[0] == 'circle' + assert fits_region[1] == fits_region[2] == 50.0 + assert fits_region[3] == 10.0 + assert fits_region[4] == 0.0 + + # now test changing file format + export_plugin.subset_format.selected = 'reg' + export_plugin.export() + assert os.path.isfile('cubeviz_export.reg') + + # read exported file back in + region = Regions.read('cubeviz_export.reg')[0] + assert region.center.x == 50.0 + assert region.center.y == 50.0 + assert region.radius == 10.0 + + # changing file name + export_plugin.filename = 'test' + export_plugin.export() + assert os.path.isfile('test.reg') + + # test that invalid file extension raises an error + with pytest.raises(ValueError, + match=re.escape("x not one of ['fits', 'reg'], reverting selection to reg")): # noqa + export_plugin.subset_format.selected = 'x' + + # test that attempting to save a composite subset raises an error + cubeviz_helper.app.session.edit_subset_mode.mode = AndMode + cubeviz_helper.app.get_viewer('flux-viewer').apply_roi(CircularROI(xc=25, yc=25, radius=5)) + cubeviz_helper.app.get_viewer('flux-viewer').apply_roi(CircularROI(xc=20, yc=25, radius=5)) + + with pytest.raises(NotImplementedError, + match='Export not yet supported for composite subsets.'): + export_plugin.export() From e814559a0f5e5ec2a59608199c7fdfadba6ee666 Mon Sep 17 00:00:00 2001 From: Clare Shanahan Date: Tue, 26 Mar 2024 10:16:50 -0400 Subject: [PATCH 2/4] more review comments --- jdaviz/configs/default/plugins/export/export.py | 6 ++---- jdaviz/configs/default/plugins/export/export.vue | 1 + jdaviz/configs/default/plugins/export/tests/test_export.py | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/jdaviz/configs/default/plugins/export/export.py b/jdaviz/configs/default/plugins/export/export.py index f3b364f48a..1c01eafdcf 100644 --- a/jdaviz/configs/default/plugins/export/export.py +++ b/jdaviz/configs/default/plugins/export/export.py @@ -255,6 +255,8 @@ def export(self, filename=None, show_dialog=None): if len(filename): if not filename.endswith(filetype): filename += f".{filetype}" + if self.subset_invalid_msg != '': + raise NotImplementedError(f'Subset can not be exported - {self.subset_invalid_msg}') self.save_subset_as_region(selected_subset_label, filename) else: @@ -472,10 +474,6 @@ def save_subset_as_region(self, selected_subset_label, filename): region = self.app.get_subsets(subset_name=selected_subset_label, include_sky_region=link_type == 'wcs') - # warn when trying to export a composite subset - if len(region) > 1: - self.not_supported_warn = True - raise NotImplementedError("Export not yet supported for composite subsets.") region = region[0][f'{"sky_" if link_type == "wcs" else ""}region'] diff --git a/jdaviz/configs/default/plugins/export/export.vue b/jdaviz/configs/default/plugins/export/export.vue index 2d6320e705..3bc9499e49 100644 --- a/jdaviz/configs/default/plugins/export/export.vue +++ b/jdaviz/configs/default/plugins/export/export.vue @@ -173,6 +173,7 @@ :results_isolated_to_plugin="true" @click="interrupt_recording" :disabled="!movie_recording" + > stop diff --git a/jdaviz/configs/default/plugins/export/tests/test_export.py b/jdaviz/configs/default/plugins/export/tests/test_export.py index fedb36fc26..65f0b39c89 100644 --- a/jdaviz/configs/default/plugins/export/tests/test_export.py +++ b/jdaviz/configs/default/plugins/export/tests/test_export.py @@ -181,5 +181,5 @@ def test_basic_export_subsets_cubeviz(self, tmp_path, cubeviz_helper, spectral_c cubeviz_helper.app.get_viewer('flux-viewer').apply_roi(CircularROI(xc=20, yc=25, radius=5)) with pytest.raises(NotImplementedError, - match='Export not yet supported for composite subsets.'): + match='Subset can not be exported - Export for composite subsets not supported.'): export_plugin.export() From e3bc19f27c0e90146fa58c8689fef3d31df404f0 Mon Sep 17 00:00:00 2001 From: Clare Shanahan Date: Tue, 26 Mar 2024 10:20:23 -0400 Subject: [PATCH 3/4] code style --- jdaviz/configs/default/plugins/export/export.py | 1 - jdaviz/configs/default/plugins/export/tests/test_export.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/jdaviz/configs/default/plugins/export/export.py b/jdaviz/configs/default/plugins/export/export.py index 1c01eafdcf..17e5ebde60 100644 --- a/jdaviz/configs/default/plugins/export/export.py +++ b/jdaviz/configs/default/plugins/export/export.py @@ -474,7 +474,6 @@ def save_subset_as_region(self, selected_subset_label, filename): region = self.app.get_subsets(subset_name=selected_subset_label, include_sky_region=link_type == 'wcs') - region = region[0][f'{"sky_" if link_type == "wcs" else ""}region'] region.write(filename, overwrite=True) diff --git a/jdaviz/configs/default/plugins/export/tests/test_export.py b/jdaviz/configs/default/plugins/export/tests/test_export.py index 65f0b39c89..6f5d6b65a3 100644 --- a/jdaviz/configs/default/plugins/export/tests/test_export.py +++ b/jdaviz/configs/default/plugins/export/tests/test_export.py @@ -181,5 +181,5 @@ def test_basic_export_subsets_cubeviz(self, tmp_path, cubeviz_helper, spectral_c cubeviz_helper.app.get_viewer('flux-viewer').apply_roi(CircularROI(xc=20, yc=25, radius=5)) with pytest.raises(NotImplementedError, - match='Subset can not be exported - Export for composite subsets not supported.'): + match='Subset can not be exported - Export for composite subsets not supported.'): # noqa export_plugin.export() From 86799ee031af2d797e8cf9296defd16890bc7328 Mon Sep 17 00:00:00 2001 From: Clare Shanahan Date: Tue, 26 Mar 2024 13:30:04 -0400 Subject: [PATCH 4/4] fix text --- jdaviz/configs/default/plugins/export/export.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jdaviz/configs/default/plugins/export/export.vue b/jdaviz/configs/default/plugins/export/export.vue index 3bc9499e49..5e64fee912 100644 --- a/jdaviz/configs/default/plugins/export/export.vue +++ b/jdaviz/configs/default/plugins/export/export.vue @@ -91,7 +91,9 @@
Subsets - Export subset as astropy region. + + Save subset as astropy region. +