Skip to content
Open
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
16 changes: 16 additions & 0 deletions examples/parallel_sine_wave.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@
import matplotlib.pyplot as plt

from joblib import Parallel, delayed

# Either the environ var OR matplotlib rcPram *needs* to be set BEFORE
# importing matplotloom
import imageio_ffmpeg # python library that provides ffmpeg binary
Copy link
Owner

Choose a reason for hiding this comment

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

Nice, didn't know about this package!

FFMPEG_PATH: str = imageio_ffmpeg.get_ffmpeg_exe()

# Configure matplotlib rcPrams, if your global / project rcPrams file
# already sets this then you don't need to overload it.
plt.rcParams['animation.ffmpeg_path'] = FFMPEG_PATH

# Alternatively could set the environment variable however this *does not*
# inform matplotlib there is a valid ffmpeg binary so should be avoided.
# The intention is to allow any more complex toolchains the option if needed.
# import os
#os.environ["LOOM_FFMPEG_PATH"] = FFMPEG_PATH

from matplotloom import Loom

def plot_frame(phase, frame_number, loom):
Expand Down
18 changes: 12 additions & 6 deletions matplotloom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import subprocess
import shutil
import warnings
from multiprocessing import current_process

from .loom import Loom
from .loom import Loom, DEFAULT_FFMPEG_PATH, _LOOM_DEFAULT_ENVIRON_VAR

__version__ = "0.9.1"
__version__ = "0.9.2"
__all__ = ["Loom"]


def _check_ffmpeg_availability():
"""Check if ffmpeg is available on the system."""
Comment on lines 12 to 13
Copy link
Owner

Choose a reason for hiding this comment

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

Should the user-supplied ffmpeg_path be an input here? I guess it can't be since this is __init__.py. I guess in the instance that ffmpeg is not available on the system but the user-supplied path works this will still produce a warning, but maybe this is fine?

Copy link
Author

Choose a reason for hiding this comment

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

Yeah that was my thought as well but then I saw it was passed to __init__.py I couldn't figure out a way to make it stick. The hope was that by using matplotlib's rcPrams if a user updated the rcPrams before importing Loom it would pull the updated value.

This works but only in the context of the main thread, as soon as you parallelise Loom it re-imports matplotlib & its rcPrams seems to be reset to the file-based defaults. I'm yet to test it but in theory if you add an rcPrams file where matplotlib expects it those settings should be maintained in the mutli-threaded/process context.

try:
# more reliable cross-platform
if shutil.which("ffmpeg") is not None:
if shutil.which(DEFAULT_FFMPEG_PATH) is not None:
return True

# Fallback: try running ffmpeg with subprocess
subprocess.run(
["ffmpeg", "-version"],
[DEFAULT_FFMPEG_PATH, "-version"],
capture_output=True,
check=True,
timeout=5
Expand All @@ -27,12 +28,17 @@ def _check_ffmpeg_availability():
return False


if not _check_ffmpeg_availability():
if not _check_ffmpeg_availability() and current_process().name == 'MainProcess':
warnings.warn(
"ffmpeg is not available on your system. "
"matplotloom requires ffmpeg to create animations. "
"Please install ffmpeg to use this library. "
"Visit https://ffmpeg.org/download.html for installation instructions.",
"Visit https://ffmpeg.org/download.html for installation instructions. "
f"Optionally ensure the environment variable `{_LOOM_DEFAULT_ENVIRON_VAR}`"
" is set to a valid ffmpeg executable or configure "
"`matplotlib.pyplot.rcParams['animation.ffmpeg_path']` to point to an "
"ffmpeg executable. The current command used to run ffmpeg "
f"is: `{DEFAULT_FFMPEG_PATH}`",
UserWarning,
stacklevel=2
)
71 changes: 67 additions & 4 deletions matplotloom/loom.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import subprocess
import subprocess, os
import warnings

from pathlib import Path
from typing import Union, Optional, Dict, Type, List, Any
Expand All @@ -10,6 +11,18 @@

from IPython.display import Video, Image


# This should allow
_LOOM_DEFAULT_ENVIRON_VAR = "LOOM_FFMPEG_PATH"

if _LOOM_DEFAULT_ENVIRON_VAR in os.environ:
DEFAULT_FFMPEG_PATH = os.environ[_LOOM_DEFAULT_ENVIRON_VAR]
else:
DEFAULT_FFMPEG_PATH: str = plt.rcParams['animation.ffmpeg_path']
os.environ[_LOOM_DEFAULT_ENVIRON_VAR] = DEFAULT_FFMPEG_PATH

ACCEPTABLE_EXTENSIONS = ("mp4", "gif")
Copy link
Owner

Choose a reason for hiding this comment

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

Hmmm, I could be wrong but I thought Loom should still work if you specify another extension as long as ffmpeg supports it? So I was keeping it open and up to ffmpeg whether it supports the output extension. But do you think we need to limit them using ACCEPTABLE_EXTENSIONS?

Copy link
Author

Choose a reason for hiding this comment

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

I added the check as a bit of a bodge; it was something I was hopeing to clean up a bit later a work & wanted to seek comments on - I added it as I got an erorr from Loom when trying to create an .avi movie file. From memory the command list wasn't created as the conditional logic block didn't have a default else case that functioned.

Copy link
Owner

Choose a reason for hiding this comment

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

Now that's a format I haven't seen in a while haha.

It's true that we don't really test for any output format besides GIF and MP4. I would say if AVI output does not work, let's open an issue about it and hopefully it'll be an easy fix.


class Loom:
"""
A class for creating animations from matplotlib figures.
Expand Down Expand Up @@ -59,6 +72,9 @@ class Loom:
Whether to show ffmpeg output when saving the video. Default is False.
When True, the ffmpeg command and its stdout/stderr output will be printed
during video creation, regardless of the verbose setting.
ffmpeg_path : Union[Path, str, None], optional
Path to ffmpeg, if not provided will use the default path.
Default path is configured to use matplotlib's rcParams ffmpeg path

Raises
------
Expand All @@ -77,6 +93,8 @@ def __init__(
savefig_kwargs: Optional[Dict[str, Any]] = None,
verbose: bool = False,
show_ffmpeg_output: bool = False,
ffmpeg_path: Optional[Union[Path, str]] = None,
enable_ffmpeg_path_fallback: bool = True,
) -> None:
self.output_filepath: Path = Path(output_filepath)
self.fps: int = fps
Expand All @@ -86,14 +104,16 @@ def __init__(
self.parallel: bool = parallel
self.show_ffmpeg_output: bool = show_ffmpeg_output
self.savefig_kwargs: Dict[str, Any] = savefig_kwargs or {}

self.enable_ffmpeg_path_fallback = enable_ffmpeg_path_fallback

valid_odd_options = {"round_up", "round_down", "crop", "pad", "none"}
if odd_dimension_handling not in valid_odd_options:
raise ValueError(
f"odd_dimension_handling must be one of {valid_odd_options}, "
f"got {odd_dimension_handling}"
)
self.odd_dimension_handling: str = odd_dimension_handling
self._get_scale_filter() # Should throw value error if wrong

if self.output_filepath.exists() and not self.overwrite:
raise FileExistsError(
Expand All @@ -107,6 +127,38 @@ def __init__(
self.frames_directory = Path(self._temp_dir.name)
else:
self.frames_directory = Path(frames_directory)

# Allow providing of ffmpeg path to class instance
self.ffmpeg_path: str = DEFAULT_FFMPEG_PATH

# Only throws an error if a path was provided
if ffmpeg_path is not None:
_ffmpeg_path = Path(ffmpeg_path)

# If the path exists use it
if _ffmpeg_path.exists():
# Store the absolute path as things can get a bit funky with
# path enrolment & multiprocessing.
self.ffmpeg_path = str(_ffmpeg_path.absolute())
Comment on lines 132 to 142
Copy link
Owner

Choose a reason for hiding this comment

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

This is great but if the user provides an ffmpeg_path that doesn't exist, the code will silently fall back to the default. To alert the user and avoid confusing behavior, I think we should either produce an error and exit, or warn the user then switch to the default.


else:
# Otherwise check if path fallback is enabled & warn the user
if self.enable_ffmpeg_path_fallback:
warnings.warn(
f"Provided ffmpeg path of `{ffmpeg_path}` (resolving " +
f"to `{_ffmpeg_path}`) was not found! Using default " +
f"path of `{DEFAULT_FFMPEG_PATH}`"
)

# If path fallback is not enabled, raise an error
else:
raise FileNotFoundError(
f"Provided ffmpeg path of `{ffmpeg_path}` (resolving " +
f"to `{_ffmpeg_path}`) was not found!"
)

# In theory this should never fail.
assert isinstance(self.ffmpeg_path, str), "ffmpeg path is not a string?"
Comment on lines +160 to +161
Copy link
Owner

Choose a reason for hiding this comment

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

Hmmm, I'm thinking we will probably want self.ffmpeg_path to always be a Path object?


# We don't use the frame counter in parallel mode.
self.frame_counter: Optional[int] = 0 if not self.parallel else None
Expand All @@ -122,6 +174,11 @@ def __init__(
print(f"output_filepath: {self.output_filepath}")
print(f"frames_directory: {self.frames_directory}")

if self.file_format not in ACCEPTABLE_EXTENSIONS:
raise ValueError("File Extension not Valid! "
f"Must be one of: {ACCEPTABLE_EXTENSIONS}"
)

def __enter__(self) -> 'Loom':
"""
Enter the runtime context related to this object.
Expand Down Expand Up @@ -197,6 +254,8 @@ def save_frame(
raise ValueError("frame_number must be provided when parallel=True")

if not self.parallel:
assert self.frame_counter is not None

frame_filepath = self.frames_directory / f"frame_{self.frame_counter:06d}.png"
self.frame_counter += 1
else:
Expand Down Expand Up @@ -233,6 +292,8 @@ def _get_scale_filter(self) -> str:
return "crop='if(mod(iw,2),iw-1,iw)':'if(mod(ih,2),ih-1,ih)':0:0"
elif self.odd_dimension_handling == "pad":
return "pad='if(mod(iw,2),iw+1,iw)':'if(mod(ih,2),ih+1,ih)':0:0:color=white"
else:
raise ValueError("Scale Settings Incorrect!")
Copy link
Owner

Choose a reason for hiding this comment

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

Might be good to print the value of self.odd_dimension_handling and the possible values so the user can know what is wrong.


def save_video(self) -> None:
"""
Expand All @@ -247,7 +308,7 @@ def save_video(self) -> None:

if self.file_format == "mp4":
command = [
"ffmpeg",
self.ffmpeg_path,
"-y",
"-framerate", str(self.fps),
"-i", str(self.frames_directory / "frame_%06d.png"),
Expand All @@ -263,7 +324,7 @@ def save_video(self) -> None:
])
elif self.file_format == "gif":
command = [
"ffmpeg",
self.ffmpeg_path,
"-y",
"-framerate", str(self.fps),
"-f", "image2",
Expand All @@ -277,6 +338,8 @@ def save_video(self) -> None:
gif_filter = "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"

command.extend(["-vf", gif_filter, str(self.output_filepath)])
else:
raise ValueError("Export File Format Not Valid!")

PIPE = subprocess.PIPE
process = subprocess.Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE)
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "matplotloom"
version = "0.9.1"
version = "0.9.2"
description = "Weave your frames into matplotlib animations."
authors = [
{ name = "ali-ramadhan", email = "ali.hh.ramadhan@gmail.com" }
Expand All @@ -23,6 +23,7 @@ dev = [
"sphinx>=8.1.3",
]
examples = [
"imageio-ffmpeg>=0.6.0",
"cartopy>=0.24.1",
"cmocean>=4.0.3",
"joblib>=1.5.1",
Expand Down
Loading