diff --git a/CHANGES.rst b/CHANGES.rst index 09f15959b1..c8bf57f547 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,8 @@ New Features - Live-preview of aperture selection in plugins. [#2664, #2684] +- "Export Plot" plugin is now replaced with the more general "Export" plugin. [#2722] + Cubeviz ^^^^^^^ diff --git a/docs/cubeviz/plugins.rst b/docs/cubeviz/plugins.rst index ee5e37de1e..98d004f375 100644 --- a/docs/cubeviz/plugins.rst +++ b/docs/cubeviz/plugins.rst @@ -327,8 +327,8 @@ have valid flux units. For 3D data, the current :ref:`slice` is used. .. _cubeviz-export-plot: -Export Plot -=========== +Export +====== This plugin allows exporting the plot in a given viewer to various image formats. diff --git a/docs/imviz/plugins.rst b/docs/imviz/plugins.rst index 6aa9f274c3..f354674c18 100644 --- a/docs/imviz/plugins.rst +++ b/docs/imviz/plugins.rst @@ -402,7 +402,7 @@ Due to browser limitations, Canvas Rotation is only available on Chromium-based .. _imviz-export-plot: -Export Plot -=========== +Export +====== This plugin allows exporting the plot in a given viewer to a PNG or SVG file. diff --git a/docs/mosviz/plugins.rst b/docs/mosviz/plugins.rst index 7ca0c9376f..c49ba83f67 100644 --- a/docs/mosviz/plugins.rst +++ b/docs/mosviz/plugins.rst @@ -22,8 +22,8 @@ display just the primary header metadata. .. _mosviz-export-plot: -Export Plot -=========== +Export +====== This plugin allows exporting the plot in a given viewer to various image formats. diff --git a/docs/reference/api_plugins.rst b/docs/reference/api_plugins.rst index f01958cd76..cd4956af7f 100644 --- a/docs/reference/api_plugins.rst +++ b/docs/reference/api_plugins.rst @@ -9,7 +9,7 @@ Plugins API .. automodapi:: jdaviz.configs.default.plugins.data_tools.data_tools :no-inheritance-diagram: -.. automodapi:: jdaviz.configs.default.plugins.export_plot.export_plot +.. automodapi:: jdaviz.configs.default.plugins.export.export :no-inheritance-diagram: .. automodapi:: jdaviz.configs.default.plugins.gaussian_smooth.gaussian_smooth diff --git a/docs/specviz/plugins.rst b/docs/specviz/plugins.rst index ca502d6d58..72d0c1ebd3 100644 --- a/docs/specviz/plugins.rst +++ b/docs/specviz/plugins.rst @@ -317,7 +317,7 @@ using the |icon-line-select| (line selector) tool in the spectrum viewer. .. _specviz-export-plot: -Export Plot -=========== +Export +====== This plugin allows a given viewer's plot to be exported to various image formats. diff --git a/docs/specviz2d/plugins.rst b/docs/specviz2d/plugins.rst index e4cdfc73c3..26bf220037 100644 --- a/docs/specviz2d/plugins.rst +++ b/docs/specviz2d/plugins.rst @@ -250,7 +250,7 @@ Line Analysis .. _specviz2d-export-plot: -Export Plot -=========== +Export +====== This plugin allows exporting the plot in a given viewer to various image formats. diff --git a/jdaviz/app.py b/jdaviz/app.py index cc4dc0baf6..2a462fc546 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -126,6 +126,7 @@ def to_unit(self, data, cid, values, original_units, target_units): 'j-plugin-section-header': 'components/plugin_section_header.vue', 'j-number-uncertainty': 'components/number_uncertainty.vue', 'j-plugin-popout': 'components/plugin_popout.vue', + 'j-multiselect-toggle': 'components/multiselect_toggle.vue', 'plugin-previews-temp-disabled': 'components/plugin_previews_temp_disabled.vue', # noqa 'plugin-table': 'components/plugin_table.vue', 'plugin-dataset-select': 'components/plugin_dataset_select.vue', @@ -134,6 +135,8 @@ def to_unit(self, data, cid, values, original_units, target_units): 'plugin-layer-select': 'components/plugin_layer_select.vue', 'plugin-layer-select-tabs': 'components/plugin_layer_select_tabs.vue', 'plugin-editable-select': 'components/plugin_editable_select.vue', + 'plugin-inline-select': 'components/plugin_inline_select.vue', + 'plugin-inline-select-item': 'components/plugin_inline_select_item.vue', 'plugin-action-button': 'components/plugin_action_button.vue', 'plugin-add-results': 'components/plugin_add_results.vue', 'plugin-auto-label': 'components/plugin_auto_label.vue', diff --git a/jdaviz/components/multiselect_toggle.vue b/jdaviz/components/multiselect_toggle.vue new file mode 100644 index 0000000000..644ec87069 --- /dev/null +++ b/jdaviz/components/multiselect_toggle.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/jdaviz/components/plugin_inline_select.vue b/jdaviz/components/plugin_inline_select.vue new file mode 100644 index 0000000000..1712d06626 --- /dev/null +++ b/jdaviz/components/plugin_inline_select.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/jdaviz/components/plugin_inline_select_item.vue b/jdaviz/components/plugin_inline_select_item.vue new file mode 100644 index 0000000000..fe6237a3b6 --- /dev/null +++ b/jdaviz/components/plugin_inline_select_item.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/jdaviz/configs/cubeviz/cubeviz.yaml b/jdaviz/configs/cubeviz/cubeviz.yaml index ade379f001..92a7ac1a4c 100644 --- a/jdaviz/configs/cubeviz/cubeviz.yaml +++ b/jdaviz/configs/cubeviz/cubeviz.yaml @@ -31,7 +31,7 @@ tray: - cubeviz-moment-maps - cubeviz-spectral-extraction - imviz-aper-phot-simple - - g-export-plot + - export viewer_area: - container: col children: diff --git a/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_helper.py b/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_helper.py index 0266dc52fa..37a359801f 100644 --- a/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_helper.py +++ b/jdaviz/configs/cubeviz/plugins/tests/test_cubeviz_helper.py @@ -86,8 +86,8 @@ def test_remote_server_disable_save_serverside(): cubeviz_app = Application(config) cubeviz_helper = Cubeviz(cubeviz_app) - ep = cubeviz_helper.plugins['Export Plot'] - assert ep._obj.movie_enabled is False + exp = cubeviz_helper.plugins['Export'] + assert 'mp4' not in exp.viewer_format.choices mm = cubeviz_helper.plugins['Moment Maps'] assert mm._obj.export_enabled is False diff --git a/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py b/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py index 2c4d3ebf28..9470f1ef1a 100644 --- a/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py +++ b/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py @@ -2,7 +2,7 @@ import pytest -from jdaviz.configs.default.plugins.export_plot.export_plot import HAS_OPENCV +from jdaviz.configs.default.plugins.export.export import HAS_OPENCV # TODO: Remove skip when https://github.com/bqplot/bqplot/pull/1397/files#r726500097 is resolved. @@ -13,12 +13,12 @@ def test_export_movie(cubeviz_helper, spectrum1d_cube, tmp_path): os.chdir(tmp_path) try: cubeviz_helper.load_data(spectrum1d_cube, data_label="test") - plugin = cubeviz_helper.plugins["Export Plot"] - assert plugin.i_start == 0 - assert plugin.i_end == 1 - assert plugin.movie_filename == "mymovie.mp4" + plugin = cubeviz_helper.plugins["Export"] + assert plugin._obj.i_start == 0 + assert plugin._obj.i_end == 1 - plugin._obj.vue_save_movie("mp4") + plugin.viewer_format = 'mp4' + plugin._obj.export() assert os.path.isfile("mymovie.mp4"), tmp_path finally: os.chdir(orig_path) @@ -27,22 +27,18 @@ def test_export_movie(cubeviz_helper, spectrum1d_cube, tmp_path): @pytest.mark.skipif(HAS_OPENCV, reason="opencv-python is installed") def test_no_opencv(cubeviz_helper, spectrum1d_cube): cubeviz_helper.load_data(spectrum1d_cube, data_label="test") - plugin = cubeviz_helper.plugins["Export Plot"] - assert plugin._obj.movie_msg != "" + plugin = cubeviz_helper.plugins["Export"] + assert 'mp4' in plugin.viewer_format.choices + assert not plugin._obj.movie_enabled + plugin.viewer_format = 'mp4' with pytest.raises(ImportError, match="Please install opencv-python"): - plugin.save_movie() + plugin.export() @pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed") def test_export_movie_not_cubeviz(imviz_helper): - plugin = imviz_helper.plugins["Export Plot"] - - with pytest.raises(NotImplementedError, match="save_movie is not available for config"): - plugin._obj.save_movie() - - # Also not available via plugin public API. - with pytest.raises(AttributeError): - plugin.save_movie() + plugin = imviz_helper.plugins["Export"] + assert 'mp4' not in plugin.viewer_format.choices @pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed") @@ -50,54 +46,45 @@ def test_export_movie_cubeviz_exceptions(cubeviz_helper, spectrum1d_cube): cubeviz_helper.load_data(spectrum1d_cube, data_label="test") cubeviz_helper.default_viewer._obj.shape = (100, 100) cubeviz_helper.app.get_viewer("uncert-viewer").shape = (100, 100) - plugin = cubeviz_helper.plugins["Export Plot"] - assert plugin._obj.movie_msg == "" - assert plugin.i_start == 0 - assert plugin.i_end == 1 - assert plugin.movie_filename == "mymovie.mp4" - - with pytest.raises(NotImplementedError, match="filetype"): - plugin.save_movie(filetype="gif") - - with pytest.raises(NotImplementedError, match="filetype"): - plugin.save_movie(filename="mymovie.gif", filetype=None) + plugin = cubeviz_helper.plugins["Export"] + assert plugin._obj.i_start == 0 + assert plugin._obj.i_end == 1 + plugin.viewer_format = 'mp4' + plugin._obj.i_end = 0 with pytest.raises(ValueError, match="No frames to write"): - plugin.save_movie(i_start=0, i_end=0) + plugin.export() + plugin._obj.i_end = 1 + plugin._obj.movie_fps = 0 with pytest.raises(ValueError, match="Invalid frame rate"): - plugin.save_movie(fps=0) + plugin.export() - plugin.movie_filename = "fake_path/mymovie.mp4" + plugin._obj.movie_fps = 5 + plugin.filename = "fake_path/mymovie.mp4" with pytest.raises(ValueError, match="Invalid path"): - plugin.save_movie() - - plugin.movie_filename = "mymovie.mp4" - plugin.viewer = 'spectrum-viewer' - with pytest.raises(TypeError, match=r"Movie for.*is not supported"): - plugin.save_movie() + plugin.export() - plugin.movie_filename = "" + plugin.filename = "" plugin.viewer = 'uncert-viewer' with pytest.raises(ValueError, match="Invalid filename"): - plugin.save_movie() + plugin.export() @pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed") def test_export_movie_cubeviz_empty(cubeviz_helper): - plugin = cubeviz_helper.plugins["Export Plot"] - assert plugin.i_start == 0 - assert plugin.i_end == 0 + plugin = cubeviz_helper.plugins["Export"] + assert plugin._obj.i_start == 0 + assert plugin._obj.i_end == 0 + plugin.viewer_format = 'mp4' with pytest.raises(ValueError, match="Selected viewer has no display shape"): - plugin.save_movie(i_start=0, i_end=1) + plugin.export() def test_export_plot_exceptions(cubeviz_helper): - plugin = cubeviz_helper.plugins["Export Plot"] - - with pytest.raises(NotImplementedError, match="filetype.*not supported"): - plugin.save_figure(filetype="gif") + plugin = cubeviz_helper.plugins["Export"] + plugin.filename = "/fake/path/image.png" with pytest.raises(ValueError, match="Invalid path"): - plugin.save_figure(filename="/fake/path/image.png") + plugin.export() diff --git a/jdaviz/configs/default/default.yaml b/jdaviz/configs/default/default.yaml index d5a7eba645..4dda8cbb79 100644 --- a/jdaviz/configs/default/default.yaml +++ b/jdaviz/configs/default/default.yaml @@ -15,4 +15,4 @@ toolbar: tray: - g-subset-plugin - g-gaussian-smooth - - g-export-plot + - export diff --git a/jdaviz/configs/default/plugins/__init__.py b/jdaviz/configs/default/plugins/__init__.py index f0fc504d4b..e516c2dd82 100644 --- a/jdaviz/configs/default/plugins/__init__.py +++ b/jdaviz/configs/default/plugins/__init__.py @@ -8,6 +8,6 @@ from .collapse.collapse import * # noqa from .line_lists.line_lists import * # noqa from .metadata_viewer.metadata_viewer import * # noqa -from .export_plot.export_plot import * # noqa +from .export.export import * # noqa from .plot_options.plot_options import * # noqa from .markers.markers import * # noqa diff --git a/jdaviz/configs/default/plugins/export/__init__.py b/jdaviz/configs/default/plugins/export/__init__.py new file mode 100644 index 0000000000..1e34ea082e --- /dev/null +++ b/jdaviz/configs/default/plugins/export/__init__.py @@ -0,0 +1 @@ +from .export import * # noqa diff --git a/jdaviz/configs/default/plugins/export/export.py b/jdaviz/configs/default/plugins/export/export.py new file mode 100644 index 0000000000..b08913c1b6 --- /dev/null +++ b/jdaviz/configs/default/plugins/export/export.py @@ -0,0 +1,399 @@ +import os +from pathlib import Path +from traitlets import Any, Bool, List, Unicode, observe +from glue_jupyter.bqplot.image import BqplotImageView + +from jdaviz.core.custom_traitlets import FloatHandleEmpty, IntHandleEmpty +from jdaviz.core.registries import tray_registry +from jdaviz.core.template_mixin import (PluginTemplateMixin, SelectPluginComponent, + ViewerSelectMixin, DatasetMultiSelectMixin, + SubsetSelectMixin, MultiselectMixin, with_spinner) +from jdaviz.core.events import AddDataMessage, SnackbarMessage +from jdaviz.core.user_api import PluginUserApi + +try: + import cv2 +except ImportError: + HAS_OPENCV = False +else: + import threading + import time + HAS_OPENCV = True + +__all__ = ['Export'] + + +@tray_registry('export', label="Export") +class Export(PluginTemplateMixin, ViewerSelectMixin, SubsetSelectMixin, + DatasetMultiSelectMixin, MultiselectMixin): + """ + See the :ref:`Export Plugin Documentation ` for more details. + + Only the following attributes and methods are available through the + :ref:`public plugin API `: + + * :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` + * ``viewer`` (:class:`~jdaviz.core.template_mixin.ViewerSelect`) + * ``viewer_format`` (:class:`~jdaviz.core.template_mixin.SelectPluginComponent`) + * ``filename`` + * :meth:`export` + """ + template_file = __file__, "export.vue" + + # 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_table_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 + + table_items = List().tag(sync=True) + table_selected = Any().tag(sync=True) + + plot_items = List().tag(sync=True) + plot_selected = Any().tag(sync=True) + + viewer_format_items = List().tag(sync=True) + viewer_format_selected = Unicode().tag(sync=True) + + filename = Unicode().tag(sync=True) + + # For Cubeviz movie. + movie_enabled = Bool(False).tag(sync=True) + i_start = IntHandleEmpty(0).tag(sync=True) + i_end = IntHandleEmpty(0).tag(sync=True) + movie_fps = FloatHandleEmpty(5.0).tag(sync=True) + movie_recording = Bool(False).tag(sync=True) + movie_interrupt = Bool(False).tag(sync=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.table = SelectPluginComponent(self, + items='table_items', + selected='table_selected', + multiselect='multiselect', + default_mode='empty', + manual_options=['table-tst1', 'table-tst2']) + + self.plot = SelectPluginComponent(self, + items='plot_items', + selected='plot_selected', + multiselect='multiselect', + default_mode='empty', + manual_options=['plot-tst1', 'plot-tst2']) + + viewer_format_options = ['png', 'svg'] + if self.config == 'cubeviz': + if not self.app.state.settings.get('server_is_remote'): + viewer_format_options += ['mp4'] + # still list mp4 as an option, but display a message (and raise an error) if + # opencv is not available + self.movie_enabled = HAS_OPENCV + + self.viewer_format = SelectPluginComponent(self, + items='viewer_format_items', + selected='viewer_format_selected', + manual_options=viewer_format_options) + + # default selection: + self.dataset._default_mode = 'empty' + self.viewer.select_default() + self.filename = f"{self.app.config}_export" + + if self.config == "cubeviz": + self.session.hub.subscribe(self, AddDataMessage, handler=self._on_cubeviz_data_added) # noqa: E501 + + @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', 'filename', 'export'] + + if self.dev_dataset_support: + expose += ['dataset'] + if self.dev_subset_support: + expose += ['subset'] + if self.dev_table_support: + expose += ['table'] + if self.dev_plot_support: + expose += ['plot'] + if self.dev_multi_support: + expose += ['multiselect'] + + return PluginUserApi(self, expose=expose) + + def _on_cubeviz_data_added(self, msg): + # NOTE: This needs revising if we allow loading more than one cube. + if isinstance(msg.viewer, BqplotImageView): + if len(msg.data.shape) == 3: + self.i_end = msg.data.shape[-1] - 1 + + @observe('multiselect', 'viewer_multiselect') + def _sync_multiselect_traitlets(self, event): + # ViewerSelectMixin brings viewer_multiselect, but we want a single traitlet to control + # all select inputs, so we'll keep them synced here and only expose multiselect through + # the user API + self.multiselect = event.get('new') + self.viewer_multiselect = event.get('new') + if not self.multiselect: + # default to just a single viewer + self._sync_singleselect({'name': 'viewer', 'new': self.viewer_selected}) + + @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 + # if multiselect is not enabled, only allow a single selection across all select components + if self.multiselect: + return + if event.get('new') in ('', []): + return + name = event.get('name') + for attr in ('viewer_selected', 'dataset_selected', 'subset_selected', + 'table_selected', 'plot_selected'): + if name != attr: + setattr(self, attr, '') + + @with_spinner() + def export(self, filename=None, show_dialog=None): + """ + Export selected item(s) + + Parameters + ---------- + filename : str, optional + If not provided, plugin value will be used. + """ + 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.table.selected is not None and len(self.table.selected): + raise NotImplementedError("table 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: + raise NotImplementedError("batch export not yet supported") + + if not len(self.viewer.selected): + raise ValueError("no viewers selected to export") + + viewer = self.viewer.selected_obj + filename = filename if filename is not None else self.filename + filetype = self.viewer_format.selected + + # at this point, we can assume only a single figure is selected + if len(filename): + if not filename.endswith(filetype): + filename += f".{filetype}" + filename = Path(filename).expanduser() + else: + filename = None + + if filetype == "mp4": + self.save_movie(viewer, filename, filetype) + else: + self.save_figure(viewer, filename, filetype, show_dialog=show_dialog) + + def vue_export_from_ui(self, *args, **kwargs): + try: + self.export(show_dialog=True) + except Exception as e: + self.hub.broadcast(SnackbarMessage( + f"Export failed with: {e}", sender=self, color="error")) + + 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) + else: + if not filename.parent.exists(): + raise ValueError(f"Invalid path={filename.parent}") + + # support writing without save dialog + # https://github.com/bqplot/bqplot/pull/1397 + def on_img_received(data): + try: + with filename.open(mode='bw') as f: + f.write(data) + except Exception as e: + self.hub.broadcast(SnackbarMessage( + f"{self.viewer.selected} failed to export to {str(filename)}: {e}", + sender=self, color="error")) + finally: + self.hub.broadcast(SnackbarMessage( + f"{self.viewer.selected} exported to {str(filename)}", + sender=self, color="success")) + + if viewer.figure._upload_png_callback is not None: + raise ValueError("previous png export is still in progress. Wait to complete " + "before making another call to save_figure") + + viewer.figure.get_png_data(on_img_received) + + elif filetype == "svg": + viewer.figure.save_svg(str(filename) if filename is not None else None) + + @with_spinner('movie_recording') + def _save_movie(self, viewer, i_start, i_end, fps, filename, rm_temp_files): + # NOTE: All the stuff here has to be in the same thread but + # separate from main app thread to work. + + if not self.movie_enabled: + if not HAS_OPENCV: + raise ImportError("Please install opencv-python") + raise ValueError("movie support disabled") + + slice_plg = self.app._jdaviz_helper.plugins["Slice"]._obj + orig_slice = viewer.slice + temp_png_files = [] + i = i_start + video = None + + # TODO: Expose to users? + i_step = 1 # Need n_frames check if we allow tweaking + + try: + while i <= i_end: + if self.movie_interrupt: + break + + slice_plg.vue_play_next() + cur_pngfile = Path(f"._cubeviz_movie_frame_{i}.png") + # TODO: skip success snackbars when exporting temp movie frames? + self.save_figure(viewer, filename=cur_pngfile, filetype="png", show_dialog=False) + temp_png_files.append(cur_pngfile) + i += i_step + + # Wait for the roundtrip to the frontend to complete. + while viewer.figure._upload_png_callback is not None: + time.sleep(0.05) + + if not self.movie_interrupt: + # Grab frame size. + frame_shape = cv2.imread(temp_png_files[0]).shape + frame_size = (frame_shape[1], frame_shape[0]) + + video = cv2.VideoWriter(filename, cv2.VideoWriter_fourcc(*'mp4v'), fps, frame_size, True) # noqa: E501 + for cur_pngfile in temp_png_files: + video.write(cv2.imread(cur_pngfile)) + except Exception as err: + self.hub.broadcast(SnackbarMessage( + f"Error saving {filename}: {err!r}", sender=self, color="error")) + finally: + cv2.destroyAllWindows() + if video: + video.release() + slice_plg._on_slider_updated({'new': orig_slice}) + + if rm_temp_files or self.movie_interrupt: + for cur_pngfile in temp_png_files: + if os.path.exists(cur_pngfile): + os.remove(cur_pngfile) + + if self.movie_interrupt: + if os.path.exists(filename): + os.remove(filename) + self.movie_interrupt = False + + def save_movie(self, viewer, filename, filetype, i_start=None, i_end=None, fps=None, + rm_temp_files=True): + """Save selected slices as a movie. + + This method creates a PNG file per frame (``._cubeviz_movie_frame_.png``) + in the working directory before stitching all the frames into a movie. + Please make sure you have sufficient memory for this operation. + PNG files are deleted after the movie is created unless otherwise specified. + If another PNG file with the same name already exists, it will be silently replaced. + + Parameters + ---------- + i_start, i_end : int or `None` + Slices to record; each slice will be a frame in the movie. + If not given, it is obtained from plugin inputs. + Unlike Python indexing, ``i_end`` is inclusive. + Wrapping and reverse indexing are not supported. + + fps : float or `None` + Frame rate in frames per second (FPS). + If not given, it is obtained from plugin inputs. + + filename : str or `None` + Filename for the movie to be recorded. Include path if necessary. + If not given, it is obtained from plugin inputs. + If another file with the same name already exists, it will be silently replaced. + + filetype : {'mp4', `None`} + Currently only MPEG-4 is supported. This keyword is reserved for future support + of other format(s). + + rm_temp_files : bool + Remove temporary PNG files after movie creation. Default is `True`. + + Returns + ------- + out_filename : str + The absolute path to the actual output file. + + """ + if self.config != "cubeviz": + raise NotImplementedError(f"save_movie is not available for config={self.config}") + + if not HAS_OPENCV: + raise ImportError("Please install opencv-python to save cube as movie.") + + if filetype != "mp4": + raise NotImplementedError(f"filetype={filetype} not supported") + + if viewer.shape is None: + raise ValueError("Selected viewer has no display shape.") + + if fps is None: + fps = float(self.movie_fps) + if fps <= 0: + raise ValueError("Invalid frame rate, must be positive non-zero value.") + + # Make sure file does not end up in weird places in standalone mode. + if filename is None: + raise ValueError("Invalid filename") + path = filename.parent + if path and not path.exists(): + raise ValueError(f"Invalid path={path}") + elif (not path or str(path).startswith("..")) and os.environ.get("JDAVIZ_START_DIR", ""): # noqa: E501 # pragma: no cover + filename = os.environ["JDAVIZ_START_DIR"] / filename + + if i_start is None: + i_start = int(self.i_start) + + if i_end is None: + i_end = int(self.i_end) + + # No wrapping. Forward only. + slice_plg = self.app._jdaviz_helper.plugins["Slice"]._obj + if i_start < 0: # pragma: no cover + i_start = 0 + max_slice = len(slice_plg.valid_values_sorted) - 1 + if i_end > max_slice: # pragma: no cover + i_end = max_slice + if i_end <= i_start: + raise ValueError(f"No frames to write: i_start={i_start}, i_end={i_end}") + + filename = str(filename.resolve()) + threading.Thread( + target=lambda: self._save_movie(viewer, i_start, i_end, fps, filename, rm_temp_files) + ).start() + + return filename + + def vue_interrupt_recording(self, *args): # pragma: no cover + self.movie_interrupt = True + # TODO: this will need updating when batch/multiselect support is added + self.hub.broadcast(SnackbarMessage( + f"Movie recording interrupted by user, {self.filename} will be deleted.", + sender=self, color="warning")) diff --git a/jdaviz/configs/default/plugins/export/export.vue b/jdaviz/configs/default/plugins/export/export.vue new file mode 100644 index 0000000000..749e5582f9 --- /dev/null +++ b/jdaviz/configs/default/plugins/export/export.vue @@ -0,0 +1,160 @@ + diff --git a/jdaviz/configs/default/plugins/export_plot/__init__.py b/jdaviz/configs/default/plugins/export_plot/__init__.py deleted file mode 100644 index 580f1fe95e..0000000000 --- a/jdaviz/configs/default/plugins/export_plot/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .export_plot import * # noqa diff --git a/jdaviz/configs/default/plugins/export_plot/export_plot.py b/jdaviz/configs/default/plugins/export_plot/export_plot.py deleted file mode 100644 index 176ad84a3c..0000000000 --- a/jdaviz/configs/default/plugins/export_plot/export_plot.py +++ /dev/null @@ -1,335 +0,0 @@ -import os -from pathlib import Path - -from glue_jupyter.bqplot.image import BqplotImageView -from traitlets import Any, Bool, Unicode - -from jdaviz.core.custom_traitlets import FloatHandleEmpty, IntHandleEmpty -from jdaviz.core.events import AddDataMessage, SnackbarMessage -from jdaviz.core.registries import tray_registry -from jdaviz.core.template_mixin import PluginTemplateMixin, ViewerSelectMixin, with_spinner -from jdaviz.core.user_api import PluginUserApi - -try: - import cv2 -except ImportError: - HAS_OPENCV = False -else: - import threading - import time - HAS_OPENCV = True - -__all__ = ['ExportViewer'] - - -@tray_registry('g-export-plot', label="Export Plot") -class ExportViewer(PluginTemplateMixin, ViewerSelectMixin): - """ - See the :ref:`Export Plot Plugin Documentation ` for more details. - - Only the following attributes and methods are available through the - :ref:`public plugin API `: - - * ``viewer`` (:class:`~jdaviz.core.template_mixin.ViewerSelect`): - Viewer to select for exporting the figure image. - * :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:`save_figure` - * :meth:`save_movie` (Cubeviz only) - * `i_start` (Cubeviz only) - * `i_end` (Cubeviz only) - * `movie_fps` (Cubeviz only) - * `movie_filename` (Cubeviz only) - """ - template_file = __file__, "export_plot.vue" - - # For Cubeviz movie. - i_start = IntHandleEmpty(0).tag(sync=True) - i_end = IntHandleEmpty(0).tag(sync=True) - - # movie_enabled controls whether movie support is enabled via the UI. This is a temporary - # measure to allow server-installations to disable support for saving movies but setting this - # traitlet until movie-support is refactored to be sent through the browser instead of saved - # server-side in python. - movie_enabled = Bool(True).tag(sync=True) - movie_fps = FloatHandleEmpty(5.0).tag(sync=True) - movie_filename = Any("mymovie.mp4").tag(sync=True) - movie_msg = Unicode("").tag(sync=True) - movie_recording = Bool(False).tag(sync=True) - movie_interrupt = Bool(False).tag(sync=True) - - @property - def user_api(self): - if self.config == "cubeviz": - return PluginUserApi(self, expose=('viewer', 'save_figure', 'save_movie', 'i_start', - 'i_end', 'movie_fps', 'movie_filename')) - return PluginUserApi(self, expose=('viewer', 'save_figure')) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.config == "cubeviz": - if HAS_OPENCV: - self.session.hub.subscribe(self, AddDataMessage, handler=self._on_cubeviz_data_added) # noqa: E501 - else: - # NOTE: HTML tags do not work here. - self.movie_msg = 'Please install opencv-python to use this feature.' - - if self.app.state.settings.get('server_is_remote', False): - # when the server is remote, saving thet movie in python would save on the server, not - # on the user's machine, so movie support in cubeviz should be disabled - self.movie_enabled = False - - def _on_cubeviz_data_added(self, msg): - # NOTE: This needs revising if we allow loading more than one cube. - if isinstance(msg.viewer, BqplotImageView): - if len(msg.data.shape) == 3: - self.i_end = msg.data.shape[-1] - 1 - - def save_figure(self, filename=None, filetype=None): - """ - Save the figure to an image with a provided filename or through an interactive save dialog. - - If ``filetype`` is 'png' (or defaults to 'png' based on ``filename``), the interactive save - dialog will be bypassed (this is not supported for 'svg'). - - Parameters - ---------- - filename : str or `None` - Filename to autopopulate the save dialog. - filetype : {'png', 'svg', `None`} - Filetype (PNG or SVG). If `None`, will default based on filename or to PNG. - - """ - if filename is not None: - filename = Path(filename).expanduser() - - if filetype is None: - if filename is not None and filename.suffix: - filetype = filename.suffix[1:].lower() - else: - filetype = "png" # default to png - - viewer = self.viewer.selected_obj - - if filetype == "png": - if filename is None: - viewer.figure.save_png() - else: - if not filename.parent.exists(): - raise ValueError(f"Invalid path={filename.parent}") - - # support writing without save dialog - # https://github.com/bqplot/bqplot/pull/1397 - def on_img_received(data): - with filename.open(mode='bw') as f: - f.write(data) - - if viewer.figure._upload_png_callback is not None: - raise ValueError("previous png export is still in progress. Wait to complete " - "before making another call to save_figure") - - viewer.figure.get_png_data(on_img_received) - - elif filetype == "svg": - if filename is not None: - filename = str(filename) - viewer.figure.save_svg(filename) - - else: - raise NotImplementedError(f"filetype={filetype} not supported") - - def vue_save_figure(self, filetype): - """ - Callback for save figure events in the front end viewer toolbars. Uses - the bqplot.Figure save methods. - """ - self.save_figure(filetype=filetype) - - @with_spinner('movie_recording') - def _save_movie(self, i_start, i_end, fps, filename, rm_temp_files): - # NOTE: All the stuff here has to be in the same thread but - # separate from main app thread to work. - - viewer = self.viewer.selected_obj - slice_plg = self.app._jdaviz_helper.plugins["Slice"]._obj - orig_slice = viewer.slice - temp_png_files = [] - i = i_start - video = None - - # TODO: Expose to users? - i_step = 1 # Need n_frames check if we allow tweaking - - try: - while i <= i_end: - if self.movie_interrupt: - break - - slice_plg.vue_play_next() - cur_pngfile = f"._cubeviz_movie_frame_{i}.png" - self.save_figure(filename=cur_pngfile, filetype="png") - temp_png_files.append(cur_pngfile) - i += i_step - - # Wait for the roundtrip to the frontend to complete. - while viewer.figure._upload_png_callback is not None: - time.sleep(0.05) - - if not self.movie_interrupt: - # Grab frame size. - frame_shape = cv2.imread(temp_png_files[0]).shape - frame_size = (frame_shape[1], frame_shape[0]) - - video = cv2.VideoWriter(filename, cv2.VideoWriter_fourcc(*'mp4v'), fps, frame_size, True) # noqa: E501 - for cur_pngfile in temp_png_files: - video.write(cv2.imread(cur_pngfile)) - finally: - cv2.destroyAllWindows() - if video: - video.release() - slice_plg._on_slider_updated({'new': orig_slice}) - - if rm_temp_files or self.movie_interrupt: - for cur_pngfile in temp_png_files: - if os.path.exists(cur_pngfile): - os.remove(cur_pngfile) - - if self.movie_interrupt: - if os.path.exists(filename): - os.remove(filename) - self.movie_interrupt = False - - def save_movie(self, i_start=None, i_end=None, fps=None, filename=None, filetype=None, - rm_temp_files=True): - """Save selected slices as a movie. - - This method creates a PNG file per frame (``._cubeviz_movie_frame_.png``) - in the working directory before stitching all the frames into a movie. - Please make sure you have sufficient memory for this operation. - PNG files are deleted after the movie is created unless otherwise specified. - If another PNG file with the same name already exists, it will be silently replaced. - - Parameters - ---------- - i_start, i_end : int or `None` - Slices to record; each slice will be a frame in the movie. - If not given, it is obtained from plugin inputs. - Unlike Python indexing, ``i_end`` is inclusive. - Wrapping and reverse indexing are not supported. - - fps : float or `None` - Frame rate in frames per second (FPS). - If not given, it is obtained from plugin inputs. - - filename : str or `None` - Filename for the movie to be recorded. Include path if necessary. - If not given, it is obtained from plugin inputs. - If another file with the same name already exists, it will be silently replaced. - - filetype : {'mp4', `None`} - Currently only MPEG-4 is supported. This keyword is reserved for future support - of other format(s). - - rm_temp_files : bool - Remove temporary PNG files after movie creation. Default is `True`. - - Returns - ------- - out_filename : str - The absolute path to the actual output file. - - """ - if self.config != "cubeviz": - raise NotImplementedError(f"save_movie is not available for config={self.config}") - - if not self.movie_enabled: - # this should never be triggered since this is intended for UI-disabling and the - # UI section is hidden, but would prevent any JS-hacking - raise ValueError("save_movie is currently disabled") - - if not HAS_OPENCV: - raise ImportError("Please install opencv-python to save cube as movie.") - - if filename is None: - if self.movie_filename: - filename = self.movie_filename - else: - raise ValueError("Invalid filename.") - - filename = Path(filename).expanduser() - - if filetype is None: - if filename.suffix: - filetype = filename.suffix[1:].lower() - else: - filetype = "mp4" # default to MPEG-4 - - if filetype != "mp4": - raise NotImplementedError(f"filetype={filetype} not supported") - - viewer = self.viewer.selected_obj - if not isinstance(viewer, BqplotImageView): # Profile viewer in glue-jupyter cannot do this - raise TypeError(f"Movie for {viewer.__class__.__name__} is not supported.") - if viewer.shape is None: - raise ValueError("Selected viewer has no display shape.") - - if fps is None: - fps = float(self.movie_fps) - if fps <= 0: - raise ValueError("Invalid frame rate, must be positive non-zero value.") - - # Make sure file does not end up in weird places in standalone mode. - path = filename.parent - if path and not path.exists(): - raise ValueError(f"Invalid path={path}") - elif (not path or str(path).startswith("..")) and os.environ.get("JDAVIZ_START_DIR", ""): # noqa: E501 # pragma: no cover - filename = os.environ["JDAVIZ_START_DIR"] / filename - - if i_start is None: - i_start = int(self.i_start) - - if i_end is None: - i_end = int(self.i_end) - - # No wrapping. Forward only. - slice_plg = self.app._jdaviz_helper.plugins["Slice"]._obj - if i_start < 0: # pragma: no cover - i_start = 0 - max_slice = len(slice_plg.valid_values_sorted) - 1 - if i_end > max_slice: # pragma: no cover - i_end = max_slice - if i_end <= i_start: - raise ValueError(f"No frames to write: i_start={i_start}, i_end={i_end}") - - filename = str(filename.resolve()) - threading.Thread( - target=lambda: self._save_movie(i_start, i_end, fps, filename, rm_temp_files) - ).start() - - return filename - - def vue_save_movie(self, filetype): # pragma: no cover - """ - Callback for save movie events in the front end viewer toolbars. Uses - the bqplot.Figure save methods. - """ - try: - filename = self.save_movie(filetype=filetype) - except Exception as err: # pragma: no cover - self.hub.broadcast(SnackbarMessage( - f"Error saving {self.movie_filename}: {err!r}", sender=self, color="error")) - else: - # Let the user know where we saved the file. - # NOTE: Because of threading, this will be emitted even as movie as recording. - self.hub.broadcast(SnackbarMessage( - f"Movie being saved to {filename} for slices {self.i_start} to {self.i_end}, " - f"inclusive, at {self.movie_fps} FPS.", - sender=self, color="success")) - - def vue_interrupt_recording(self, *args): # pragma: no cover - self.movie_interrupt = True - self.hub.broadcast(SnackbarMessage( - f"Movie recording interrupted by user, {self.movie_filename} will be deleted.", - sender=self, color="warning")) diff --git a/jdaviz/configs/default/plugins/export_plot/export_plot.vue b/jdaviz/configs/default/plugins/export_plot/export_plot.vue deleted file mode 100644 index a1a5540206..0000000000 --- a/jdaviz/configs/default/plugins/export_plot/export_plot.vue +++ /dev/null @@ -1,134 +0,0 @@ - diff --git a/jdaviz/configs/imviz/imviz.yaml b/jdaviz/configs/imviz/imviz.yaml index 27e21ebde0..4e7de049bb 100644 --- a/jdaviz/configs/imviz/imviz.yaml +++ b/jdaviz/configs/imviz/imviz.yaml @@ -30,7 +30,7 @@ tray: - imviz-catalogs - imviz-footprints - imviz-rotate-canvas - - g-export-plot + - export viewer_area: - container: col children: diff --git a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py index 62f8306346..54490b6d4e 100644 --- a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py +++ b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.py @@ -10,7 +10,6 @@ from astropy.modeling.models import Gaussian1D from astropy.time import Time from glue.core.message import SubsetUpdateMessage -from glue_jupyter.common.toolbar_vuetify import read_icon from ipywidgets import widget_serialization from packaging.version import Version from photutils.aperture import (ApertureStats, CircularAperture, EllipticalAperture, @@ -23,8 +22,7 @@ from jdaviz.core.registries import tray_registry from jdaviz.core.template_mixin import (PluginTemplateMixin, DatasetMultiSelectMixin, SubsetSelect, ApertureSubsetSelectMixin, - TableMixin, PlotMixin, with_spinner) -from jdaviz.core.tools import ICON_DIR + TableMixin, PlotMixin, MultiselectMixin, with_spinner) from jdaviz.utils import PRIHDR_KEY __all__ = ['SimpleAperturePhotometry'] @@ -34,7 +32,7 @@ @tray_registry('imviz-aper-phot-simple', label="Aperture Photometry") class SimpleAperturePhotometry(PluginTemplateMixin, ApertureSubsetSelectMixin, - DatasetMultiSelectMixin, TableMixin, PlotMixin): + DatasetMultiSelectMixin, TableMixin, PlotMixin, MultiselectMixin): """ The Aperture Photometry plugin performs aperture photometry for drawn regions. See the :ref:`Aperture Photometry Plugin Documentation ` for more details. @@ -48,7 +46,6 @@ class SimpleAperturePhotometry(PluginTemplateMixin, ApertureSubsetSelectMixin, """ template_file = __file__, "aper_phot_simple.vue" uses_active_status = Bool(True).tag(sync=True) - multiselect = Bool(False).tag(sync=True) aperture_area = Integer().tag(sync=True) background_items = List().tag(sync=True) @@ -74,9 +71,6 @@ class SimpleAperturePhotometry(PluginTemplateMixin, ApertureSubsetSelectMixin, cube_slice = Unicode("").tag(sync=True) is_cube = Bool(False).tag(sync=True) - icon_radialtocheck = Unicode(read_icon(os.path.join(ICON_DIR, 'radialtocheck.svg'), 'svg+xml')).tag(sync=True) # noqa - icon_checktoradial = Unicode(read_icon(os.path.join(ICON_DIR, 'checktoradial.svg'), 'svg+xml')).tag(sync=True) # noqa - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue index 5dd949473e..a2c4f90ef4 100644 --- a/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue +++ b/jdaviz/configs/imviz/plugins/aper_phot_simple/aper_phot_simple.vue @@ -7,21 +7,12 @@ :keep_active.sync="keep_active" :popout_button="popout_button"> - -
-
-
- - - - - -
-
+ + + """ + subset_items = List().tag(sync=True) + subset_selected = Any().tag(sync=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.subset = SubsetSelect(self, + 'subset_items', + 'subset_selected', + dataset='dataset' if hasattr(self, 'dataset') else None, + multiselect='multiselect' if hasattr(self, 'multiselect') else None) # noqa + + class SpectralSubsetSelectMixin(VuetifyTemplate, HubListener): """ Applies the SubsetSelect component as a mixin in the base plugin. This @@ -2602,9 +2647,7 @@ class ViewerSelect(SelectPluginComponent): * :meth:`~SelectPluginComponent.select_default` * :meth:`~SelectPluginComponent.select_all` (only if ``is_multiselect``) * :meth:`~SelectPluginComponent.select_none` (only if ``is_multiselect``) - """ - """ Traitlets (in the object, custom traitlets in the plugin): * ``items`` (list of dicts with keys: id, reference, label) diff --git a/jdaviz/core/tests/test_template_mixin.py b/jdaviz/core/tests/test_template_mixin.py index 6681605493..c6d01bb8a9 100644 --- a/jdaviz/core/tests/test_template_mixin.py +++ b/jdaviz/core/tests/test_template_mixin.py @@ -110,17 +110,17 @@ def test_viewer_select(cubeviz_helper, spectrum1d_cube): fv = app.get_viewer("flux-viewer") sv = app.get_viewer("spectrum-viewer") - # export plot uses the mixin - p = app.get_tray_item_from_name('g-export-plot') + # export plugin uses the mixin + p = cubeviz_helper.plugins['Export'] assert len(p.viewer.ids) == 3 assert len(p.viewer.references) == 3 assert len(p.viewer.labels) == 3 assert p.viewer.selected_obj == fv # set by reference - p.viewer_selected = 'spectrum-viewer' + p.viewer = 'spectrum-viewer' assert p.viewer.selected_obj == sv # try setting based on id instead of reference - p.viewer_selected = p.viewer.ids[0] - assert p.viewer_selected == p.viewer.labels[0] + p.viewer = p.viewer.ids[0] + assert p.viewer.selected == p.viewer.labels[0] diff --git a/jdaviz/core/tools.py b/jdaviz/core/tools.py index adc553129e..173fb8f1f3 100644 --- a/jdaviz/core/tools.py +++ b/jdaviz/core/tools.py @@ -484,12 +484,12 @@ class SidebarShortcutPlotOptions(_BaseSidebarShortcut): @viewer_tool class SidebarShortcutExportPlot(_BaseSidebarShortcut): - plugin_name = 'g-export-plot' + plugin_name = 'export' icon = os.path.join(ICON_DIR, 'image.svg') tool_id = 'jdaviz:sidebar_export' action_text = 'Export plot' - tool_tip = 'Open export plot plugin in sidebar' + tool_tip = 'Open export plugin in sidebar' @viewer_tool diff --git a/jdaviz/tests/test_app.py b/jdaviz/tests/test_app.py index 3b0166f032..adbcd29da3 100644 --- a/jdaviz/tests/test_app.py +++ b/jdaviz/tests/test_app.py @@ -36,7 +36,7 @@ def test_nonstandard_specviz_viewer_name(spectrum1d): 'g-unit-conversion', 'g-line-list', 'specviz-line-analysis', - 'g-export-plot'], + 'export'], 'viewer_area': [{'container': 'col', 'children': [{'container': 'row', 'viewers': [{'name': 'H',