Repository: ARM-DOE/ACT

Issue:
Function to create movies
The DQ Office creates many movies from static plots. Another researcher is working on a project that would benefit from a simple way to create moves from plots. We should create a function to make movies from a list of images. Finding a mostly Python way to do this would be best.


Discussion:
This is how we did it for gifs in the past during my internship:
https://github.com/zssherman/pyart_animation/blob/master/pyart_animation/pyart_animation.py
However for mp4s, in TINT I wrote an mp4 maker, which could probably be enhanced:
https://github.com/openradar/TINT/blob/d6cdf92317a08043b7d217f1dafbcb89e18ff84d/tint/visualization.py#L259
@kenkehoe @jrobrien91 Mentioned xmovie, which uses xarray and files to create movies, so this might be what would want to try and use if we are generating the plots using xarray:
https://xmovie.readthedocs.io/en/latest/examples/quickstart.html

From a list of images, there is moviepy and cv2 we can try

@zssherman I think we need to look at the use cases here and @kenkehoe can provide more but I think the main ones are radar data and images from cameras.  For the radar data use case, I think Py-ART would be more appropriate and that's where I would see xmovie come in.  I think a utility to take a list of files and create a movie would be more suited for ACT at the moment.  @zssherman I've also used ffmpeg in python and it was fairly straight forward.  

https://pypi.org/project/ffmpeg-python/
@AdamTheisen I suggested xmovie for xarray datasets for xradar instead, and we can always utilize that. And if we wanted to use ffmpeg here, I can always write up something similar to what I did for TINT.
@AdamTheisen @zssherman an additional use case would be plotting particle imagery from the AAF if we wanted to go that route. 
I agree with Adam that a utility to take images and string them together to form a movie is my original intention. We could provide an xarray option as well, but I don't have much experience with that. My concern is getting it to work consistently and understanding what is going wrong when it fails. It works more like a black box from my experience. Also, all our plotting calls are in matplotlib currently.
@kenkehoe @zssherman I just did a quick test with a library called MoviePy (https://zulko.github.io/moviepy/) and it was pretty easy to pass a list of images to it and create a mp4.    Granted, in the end, this still runs off ffmpeg from the looks of it but if they can handle that and make it easy and straight forward to produce a movie could we consider this an optional dependency?

https://github.com/ARM-DOE/ACT/assets/13421074/287472cd-b71d-49a1-9617-5f0dc13725ae


```
import os
import moviepy.video.io.ImageSequenceClip
image_folder='./images'
fps=10

image_files = [os.path.join(image_folder,img)
               for img in os.listdir(image_folder)
               if img.startswith("barrow_arm_noaa_")]
clip = moviepy.video.io.ImageSequenceClip.ImageSequenceClip(image_files, fps=fps)
clip.write_videofile('./images/my_video.mp4')
```
Well that is very promising. Worth investigating. I will try to find some time to test this out.
@kenkehoe  I've been testing this a little bit.  Can you guarantee that the images you would want to pass in to the movie create be all the same size?  I run into the following error when I try and do a random assortment of images.  I think this is generally what we could expect but wanted to check.

```
  File "/opt/anaconda3/lib/python3.11/site-packages/moviepy/video/io/ImageSequenceClip.py", line 91, in __init__
    raise Exception("Moviepy: ImageSequenceClip requires all images to be the same size")
Exception: Moviepy: ImageSequenceClip requires all images to be the same size
```
Hmm, OK. Do you mean the same dimensional size? I think they will all be the same size. They will not be the same file size.
Yeah, same dimensions 

Resolving patch:
diff --git a/act/utils/__init__.py b/act/utils/__init__.py
--- a/act/utils/__init__.py
+++ b/act/utils/__init__.py
@@ -49,7 +49,8 @@
                      'cleanup_files',
                      'is_gunzip_file',
                      'pack_gzip',
-                     'unpack_gzip'
+                     'unpack_gzip',
+                     'generate_movie'
         ],
     },
 )
diff --git a/act/utils/io_utils.py b/act/utils/io_utils.py
--- a/act/utils/io_utils.py
+++ b/act/utils/io_utils.py
@@ -8,6 +8,12 @@
 import shutil
 import tempfile
 
+try:
+    import moviepy.video.io.ImageSequenceClip
+    MOVIEPY_AVAILABLE = True
+except ImportError:
+    MOVIEPY_AVAILABLE = False
+
 
 def pack_tar(filenames, write_filename=None, write_directory=None, remove=False):
     """
@@ -276,3 +282,55 @@ def unpack_gzip(filename, write_directory=None, remove=False):
         Path(filename).unlink()
 
     return str(write_filename)
+
+
+def generate_movie(images, write_directory=None, write_filename=None, fps=10, codec=None, threads=None):
+    """
+    Creates a movie from a list of images
+
+    ...
+
+    Parameters
+    ----------
+    images : list
+        List of images in the correct order to make into a movie
+    write_directory : str, pahtlib.Path, list, None
+        Path to directory to place newly created gunzip file.
+    write_filename : str, pathlib.Path, None
+        Movie output filename
+    fps: int
+        Frames per second
+    codec: int
+        Codec to use for image encoding
+    threads: int
+        Number of threads to use for ffmpeg
+
+
+    Returns
+    -------
+    write_filename : str
+        Full path name of created gunzip file
+
+    """
+    if not MOVIEPY_AVAILABLE:
+        raise ImportError(
+            'MoviePy needs to be installed on your system to make movies.'
+        )
+
+    if write_filename is None:
+        write_filename = 'movie.mp4'
+
+    if write_directory is not None:
+        write_directory = Path(write_directory)
+        write_directory.mkdir(parents=True, exist_ok=True)
+        write_filename = Path(write_filename).name
+    elif Path(write_filename).parent != Path('.'):
+        write_directory = Path(write_filename).parent
+    else:
+        write_directory = Path('.')
+
+    full_path = write_directory / write_filename
+    clip = moviepy.video.io.ImageSequenceClip.ImageSequenceClip(images, fps=fps)
+    clip.write_videofile(str(full_path), codec=codec, threads=threads)
+
+    return full_path


Test patch:
diff --git a/act/tests/utils/test_io_utils.py b/act/tests/utils/test_io_utils.py
--- a/act/tests/utils/test_io_utils.py
+++ b/act/tests/utils/test_io_utils.py
@@ -1,12 +1,21 @@
 import tempfile
 from pathlib import Path
-from os import PathLike
+from os import PathLike, getcwd
 from string import ascii_letters
 import random
+import glob
+import pytest
+import os
 
 import act
 from act.tests import sample_files
 
+try:
+    import moviepy.video.io.ImageSequenceClip
+    MOVIEPY_AVAILABLE = True
+except ImportError:
+    MOVIEPY_AVAILABLE = False
+
 
 def test_read_netcdf_gztarfiles():
     with tempfile.TemporaryDirectory() as tmpdirname:
@@ -203,3 +212,15 @@ def test_gunzip():
             assert file.endswith('.nc')
 
         assert Path(unpack_filename).is_file() is False
+
+
+@pytest.mark.skipif(not MOVIEPY_AVAILABLE, reason='MoviePy is not installed.')
+def test_generate_movie():
+    files = [
+        'https://github.com/ARM-DOE/ACT/blob/main/act/tests/plotting/baseline/test_contour.png?raw=true',
+        'https://github.com/ARM-DOE/ACT/blob/main/act/tests/plotting/baseline/test_contour2.png?raw=true',
+        'https://github.com/ARM-DOE/ACT/blob/main/act/tests/plotting/baseline/test_contourf.png?raw=true',
+        'https://github.com/ARM-DOE/ACT/blob/main/act/tests/plotting/baseline/test_contourf2.png?raw=true'
+    ]
+    result = act.utils.generate_movie(files, fps=5)
+    assert str(result) == 'movie.mp4'