Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Button to export Cubeviz movie #2264

Merged
merged 22 commits into from
Jul 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ New Features
Cubeviz
^^^^^^^

- Added the ability to export cube slices to video. User will need to install
``opencv-python`` separately or use ``[all]`` specifier when installing Jdaviz. [#2264]

Imviz
^^^^^

Expand Down Expand Up @@ -77,6 +80,9 @@ Bug Fixes
Cubeviz
^^^^^^^

- Moment Map plugin now writes FITS file to working directory if no path provided
in standalone mode. [#2264]

Imviz
^^^^^

Expand Down
25 changes: 25 additions & 0 deletions docs/cubeviz/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,28 @@ Export Plot
===========

This plugin allows exporting the plot in a given viewer to various image formats.

.. _cubeviz-export-video:

Movie
-----

.. note::

For MPEG-4, this feature needs ``opencv-python`` to be installed;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@camipacifici , is a note here sufficient?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we detect this in the plugin init, set a traitlet, and swap out the UI with a message instead? Right now the UI shows the option, but clicking "export movie" just doesn't do anything (although looking at the code - I'm not sure why the snackbar error message never showed 🤔 ).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, really? I saw a red snackbar asking me to install opencv-python when I ran it in a new env and forgot to install first.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, you want all errors go to this traitlet thingy instead of snackbar?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to see the note in the plugin itself at a minimum, together with the link to the docs.
I did see the red snackbar when I first used it without having installed the additional package.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but I think we can detect that the functionality is completely disabled because of a missing dependency and warn the user about that in advance. If there is a snackbar though, then that is probably sufficient for now and I can look into seeing why it didn't show on my install (or maybe I just missed it).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First thing first... do you not see this when opencv-python is not installed and you click "export to MP4"?

Screenshot 2023-06-29 120827

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this? Note that I cannot insert HTML links into the text, so I can just do plain text.

Screenshot 2023-06-29 124030

see [opencv-python on PyPI](https://pypi.org/project/opencv-python/).

Expand the "Export to video" section, then enter the desired starting and
ending slice indices (inclusive), the frame rate in frames per second (FPS),
and the filename.
If a path is not given, the file will be saved to current working
directory. Any existing file with the same name will be silently replaced.

When you are ready, click the :guilabel:`Export to MP4` button.
The movie will be recorded at the given FPS. While recording is in progress,
it is highly recommended that you leave the app alone until it is done.

While recording, there is an option to interrupt the recording when something
goes wrong (e.g., it is taking too long or you realized you entered the wrong inputs).
Click on the stop icon next to the :guilabel:`Export to MP4` button to interrupt it.
Doing so will result in no output video.
23 changes: 16 additions & 7 deletions jdaviz/configs/cubeviz/plugins/moment_maps/moment_maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,29 @@ def _write_moment_to_fits(self, overwrite=False, *args):
if self.moment is None or not self.filename: # pragma: no cover
return

path = Path(self.filename).resolve()
if path.exists():
# Make sure file does not end up in weird places in standalone mode.
path = os.path.dirname(self.filename)
if path and not os.path.exists(path):
raise ValueError(f"Invalid path={path}")
elif (not path or path.startswith("..")) and os.environ.get("JDAVIZ_START_DIR", ""): # noqa: E501 # pragma: no cover
filename = Path(os.environ["JDAVIZ_START_DIR"]) / self.filename
else:
filename = Path(self.filename).resolve()
Comment on lines +140 to +147
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd REALLY like to architect a pathlib-only solution here and avoid using os.path. I think we could make this codeblock much more elegant with a combination of path.exists(), path.parent and path.is_dir(), path.relative_to(), and path.is_absolute()

I'm generally a stickler for path handling and I admit I was tempted to leave a "changes requested" review here, but I'll bite my tongue and allow myself to get overruled here since we're at the end of the sprint

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I have the same sentiment but refactoring moment map is out of scope here. Moment map is already like that when I joined the project, so not sure what was the original intention of the non-pathlib design. Keep in mind also that astropy.io.fits predates pathlib, so sometimes using non-pathlib is unavoidable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless you mean specifically that you do not like path = os.path.dirname(self.filename)? That is just to check the string content of the path though. It is not used in the actual parsing, as you can see.


if filename.exists():
if overwrite:
# Try to delete the file
path.unlink()
if path.exists():
filename.unlink()
if filename.exists():
# Warn the user if the file still exists
raise FileExistsError(f"Unable to delete {path}. Check user permissions.")
raise FileExistsError(f"Unable to delete {filename}. Check user permissions.")
else:
self.overwrite_warn = True
return

self.moment.write(str(path))
filename = str(filename)
self.moment.write(filename)

# Let the user know where we saved the file.
self.hub.broadcast(SnackbarMessage(
f"Moment map saved to {os.path.abspath(self.filename)}", sender=self, color="success"))
f"Moment map saved to {os.path.abspath(filename)}", sender=self, color="success"))
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,7 @@ def test_write_momentmap(cubeviz_helper, spectrum1d_cube, tmp_path):
sky = w.pixel_to_world(0, 0)
assert_allclose(sky.ra.deg, 204.9998877673)
assert_allclose(sky.dec.deg, 27.0001)

plugin._obj.filename = "fake_path/test_file.fits"
with pytest.raises(ValueError, match="Invalid path"):
plugin._obj.vue_save_as_fits()
2 changes: 1 addition & 1 deletion jdaviz/configs/cubeviz/plugins/slice/slice.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def _watch_viewer(self, viewer, watch=True):
def _on_data_added(self, msg):
if isinstance(msg.viewer, BqplotImageView):
if len(msg.data.shape) == 3:
self.max_value = msg.data.shape[-1] - 1
self.max_value = msg.data.shape[-1] - 1 # Same as i_end in Export Plot plugin
self._watch_viewer(msg.viewer, True)
msg.viewer.state.slices = (0, 0, int(self.slice))

Expand Down
93 changes: 93 additions & 0 deletions jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import os

import pytest

from jdaviz.configs.default.plugins.export_plot.export_plot import HAS_OPENCV


# TODO: Remove skip when https://github.com/bqplot/bqplot/pull/1397/files#r726500097 is resolved.
@pytest.mark.skip(reason="Cannot test due to async JS callback")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same problem I encountered in #929

# @pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed")
def test_export_movie(cubeviz_helper, spectrum1d_cube, tmp_path):
orig_path = os.getcwd()
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._obj.vue_save_movie("mp4")
assert os.path.isfile("mymovie.mp4"), tmp_path
finally:
os.chdir(orig_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 != ""
with pytest.raises(ImportError, match="Please install opencv-python"):
plugin.save_movie()


@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()


@pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed")
def test_export_movie_cubeviz_exceptions(cubeviz_helper, spectrum1d_cube):
cubeviz_helper.load_data(spectrum1d_cube, data_label="test")
cubeviz_helper.default_viewer.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)

with pytest.raises(ValueError, match="No frames to write"):
plugin.save_movie(i_start=0, i_end=0)

with pytest.raises(ValueError, match="Invalid frame rate"):
plugin.save_movie(fps=0)

plugin.movie_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.movie_filename = ""
plugin.viewer = 'uncert-viewer'
with pytest.raises(ValueError, match="Invalid filename"):
plugin.save_movie()


@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

with pytest.raises(ValueError, match="Selected viewer has no display shape"):
plugin.save_movie(i_start=0, i_end=1)
Loading