Source code for tracktable.render.backends.ffmpeg_backend

#
# Copyright (c) 2014-2023 National Technology and Engineering
# Solutions of Sandia, LLC. Under the terms of Contract DE-NA0003525
# with National Technology and Engineering Solutions of Sandia, LLC,
# the U.S. Government retains certain rights in this software.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""ffmpeg_backend.py - Render a movie of trajectories using ffmpeg

Note:
    Cartopy v0.18.0 is required to successfully render maps and pass
    our internal tests.

"""

import datetime
import itertools
import logging
import multiprocessing
import os
import platform
import subprocess
import tempfile

import matplotlib
import matplotlib.animation
from matplotlib import pyplot
from tracktable.core import geomath
from tracktable.render import render_map
from tracktable.render.map_processing.movies import (
    clip_trajectories_to_interval, compute_movie_time_bounds,
    initialize_canvas, map_extent_as_bounding_box,
    render_annotated_trajectories, setup_encoder, trajectories_inside_box)
from tracktable.render.map_processing.parallel_movies import (
    BatchMovieRenderer, concatenate_movie_chunks, encode_final_movie,
    remove_movie_chunks)

matplotlib.use('Agg')

logger = logging.getLogger(__name__)

# In order to keep ffmpeg a soft dependency we check that ffmpeg is installed and fail accordingly.
# Also need to make sure that we don't internally call the render_movie functions.
try:
    if platform.system() == "Windows":
        subprocess.check_output(['where', 'ffmpeg'])
    if platform.system() == "Darwin" or platform.system() == "Linux":
        subprocess.check_output(['which', 'ffmpeg'])
except Exception as e:
    logger.warning(("ffmpeg is not installed on this system or not "
                    "accessible on the path.  Attempts to render a "
                    "movie will fail."))


try:
    from tqdm import tqdm
    tqdm_installed = True
except ImportError:
    tqdm_installed = False


def _ffmpeg_available() -> bool:
    """Check whether ffmpeg is available on the path

    No arguments.

    Returns:
        bool: True if ffmpeg is on the path, False otherwise
    """

    try:
        if platform.system() == "Windows":
            subprocess.check_output(['where', 'ffmpeg'])
        if platform.system() == "Darwin" or platform.system() == "Linux":
            subprocess.check_output(['which', 'ffmpeg'])
        return True
    except subprocess.CalledProcessError:
        return False

# ---------------------------------------------------------------------

[docs] def render_trajectory_movie(trajectories, # Mapmaker kwargs domain='terrestrial', map_name="region:world", draw_coastlines=True, draw_countries=True, draw_states=True, draw_lonlat=True, map_bbox=[], map_projection = None, map_canvas = None, figure=None, fill_land=True, fill_water=True, tiles=None, # Trajectory Rendering kwargs trajectory_color_type="scalar", trajectory_color="progress", trajectory_colormap="gist_heat", trajectory_zorder=10, decorate_trajectory_head=False, trajectory_head_dot_size=2, trajectory_head_color="white", trajectory_linewidth_style="constant", trajectory_linewidth=0.5, trajectory_initial_linewidth=0.5, trajectory_final_linewidth=0.01, scalar_min=0, scalar_max=1, trail_duration=datetime.timedelta(seconds=300), # Movie kwargs movie_writer=None, codec=None, encoder="ffmpeg", encoder_args="-c:v mpeg4 -q:v 5", duration=60, fps=30, resolution=[800, 600], dpi=100, start_time=None, end_time=None, filename='movie.mp4', movie_title='Tracktable Movie', movie_artist='Tracktable Trajectory Toolkit', movie_comment='', utc_offset=None, timezone_label=None, # SaveFig kwargs savefig_kwargs=None, # Additional args for Render Map **kwargs): """Render a list of trajectories into a movie For documentation on the parameters, please see render_movie """ if not _ffmpeg_available(): raise RuntimeError("FFMPEG is not available on the path. Cannot render movie.") # Steps: # 1. Cull trajectories that are entirely outside the map # 2. Annotate trajectories with scalars needed for color # 3. Compute frame duration # 4. Add clock to map: TODO: I still need arguments to control whether the clock is # included and, if so, where and how it's rendered. # 5. Loop through frames #tiles override cartopy map features if tiles != None: fill_land=False fill_water=False draw_coastlines=False draw_countries=False draw_states=False num_frames = fps * duration # We can compute the bounding box for Cartesian data automatically. # We don't need to do so for terrestrial data because the map will # default to the whole world. if (domain == 'cartesian2d' and (map_bbox is None or len(map_bbox) == 0)): map_bbox = geomath.compute_bounding_box(itertools.chain(*trajectories)) # Set up the map. logger.info('Initializing map canvas for rendering.') (figure, axes) = initialize_canvas(resolution, dpi) if map_canvas == None: (map_canvas, map_actors) = render_map.render_map(domain=domain, map_name=map_name, map_bbox=map_bbox, map_projection=map_projection, draw_lonlat=draw_lonlat, draw_coastlines=draw_coastlines, draw_countries=draw_countries, draw_states=draw_states, fill_land=fill_land, fill_water=fill_water, tiles=tiles, **kwargs) map_bbox = map_extent_as_bounding_box(map_canvas, domain=domain) # Set up the video encoder. if movie_writer is None: movie_writer = setup_encoder(encoder=encoder, codec=codec, encoder_args=encoder_args, movie_title=movie_title, movie_artist=movie_artist, movie_comment=movie_comment, fps=fps, **kwargs) # Setup trajectory render style if trajectory_linewidth == 'taper': linewidth_style = 'taper' linewidth = trajectory_initial_linewidth final_linewidth = trajectory_final_linewidth else: linewidth_style = 'constant' linewidth = trajectory_linewidth final_linewidth = linewidth # This set of arguments will be passed to the savefig() call that # grabs the latest movie frame. This is the place to put things # like background color, tight layout and friends. if savefig_kwargs == None: savefig_kwargs = {'facecolor': figure.get_facecolor()} # This a known issue in matplotlib that was never fixed so we're on the # hook to ensure that we don't pass the bbox_inches param to ffmpeg if "bbox_inches" in savefig_kwargs and encoder == 'ffmpeg': logger.warn('The `bbox_inches` save argument is incompatiable with ffmpeg, argument will be removed from `savefig_kwargs`.') savefig_kwargs.pop("bbox_inches") (movie_start_time, movie_end_time) = compute_movie_time_bounds( trajectories, start_time, end_time) # Cull out trajectories that do not overlap the map. We do not # clip them (at least not now) since that would affect measures # like progress along the path. trajectories_on_map = list(trajectories_inside_box(trajectories, map_bbox)) if len(trajectories_on_map) == 0: raise ValueError( ('No trajectories intersect the map bounding box ' '(({} {}) - ({} {})). Is the ' 'bounding box correct?').format(map_bbox.min_corner[0], map_bbox.min_corner[1], map_bbox.max_corner[0], map_bbox.max_corner[1])) logger.info('Movie covers time span from {} to {}'.format( movie_start_time.strftime("%Y-%m-%d %H:%M:%S"), movie_end_time.strftime("%Y-%m-%d %H:%M:%S"))) frame_duration = ((movie_end_time - movie_start_time) / num_frames) first_frame_time = movie_start_time + trail_duration def frame_time(which_frame): return first_frame_time + which_frame * frame_duration if figure is None: figure = pyplot.gcf() counter = 0 with movie_writer.saving(figure, filename, dpi): if tqdm_installed: for i in tqdm(range(int(num_frames)), desc="Rendering Frames", unit='frame'): current_time = frame_time(i) trail_start_time = frame_time(i) - trail_duration frame_trajectories = clip_trajectories_to_interval( trajectories_on_map, start_time=trail_start_time, end_time=current_time ) # TODO: Add in scalar accessor trajectory_artists = render_annotated_trajectories( frame_trajectories, map_canvas, color_map=trajectory_colormap, decorate_head=decorate_trajectory_head, head_size=trajectory_head_dot_size, head_color=trajectory_head_color, linewidth_style=linewidth_style, linewidth=linewidth, final_linewidth=final_linewidth, scalar=trajectory_color, scalar_min=scalar_min, scalar_max=scalar_max, zorder=trajectory_zorder ) # pyplot.savefig('image_'+str(counter)+'.jpg', **savefig_kwargs) counter+=1 # TODO: here we could also render the clock movie_writer.grab_frame(**savefig_kwargs) # Clean up the figure for the next time around for artist in trajectory_artists: artist.remove() else: for i in range(0, num_frames): current_time = frame_time(i) trail_start_time = frame_time(i) - trail_duration logger.info( ('Rendering frame {}: current_time {}, ' 'trail_start_time {}').format( i, current_time.strftime("%Y-%m-%d %H:%M:%S"), trail_start_time.strftime("%Y-%m-%d %H:%M:%S"))) frame_trajectories = clip_trajectories_to_interval( trajectories_on_map, start_time=trail_start_time, end_time=current_time ) # TODO: Add in scalar accessor trajectory_artists = render_annotated_trajectories( frame_trajectories, map_canvas, color_map=trajectory_colormap, decorate_head=decorate_trajectory_head, head_size=trajectory_head_dot_size, head_color=trajectory_head_color, linewidth_style=linewidth_style, linewidth=trajectory_linewidth, final_linewidth=final_linewidth, scalar=trajectory_color, scalar_min=scalar_min, scalar_max=scalar_max, zorder=trajectory_zorder ) # TODO: here we could also render the clock movie_writer.grab_frame(**savefig_kwargs) # Clean up the figure for the next time around for artist in trajectory_artists: artist.remove()
# --------------------------------------------------------------------
[docs] def render_trajectory_movie_parallel(trajectories, # Mapmaker kwargs domain='terrestrial', map_name="region:world", draw_coastlines=True, draw_countries=True, draw_states=True, draw_lonlat=True, map_bbox=[], map_projection = None, map_canvas = None, figure=None, fill_land=True, fill_water=True, tiles=None, # Trajectory Rendering kwargs trajectory_color_type="scalar", trajectory_color="progress", trajectory_colormap="gist_heat", trajectory_zorder=10, decorate_trajectory_head=False, trajectory_head_dot_size=2, trajectory_head_color="white", trajectory_linewidth_style="constant", trajectory_linewidth=0.5, trajectory_initial_linewidth=0.5, trajectory_final_linewidth=0.01, scalar_min=0, scalar_max=1, trail_duration=datetime.timedelta(seconds=300), # Movie kwargs codec="ffv1", encoder="ffmpeg", encoder_args="-c:v mpeg4 -q:v 5", duration=60, fps=30, resolution=[800, 600], dpi=100, start_time=None, end_time=None, filename='movie.mp4', movie_title='Tracktable Movie', movie_artist='Tracktable Trajectory Toolkit', movie_comment='', utc_offset=None, timezone_label=None, frame_batch_size = 500, # SaveFig kwargs savefig_kwargs=None, # Parallel kwargs processors=0, # Additional args for Render Map **kwargs): """Render a list of trajectories into a movie in parallel For documentation on the parameters, please see render_movie """ if not _ffmpeg_available(): raise RuntimeError("FFMPEG is not available on the path. Cannot render movie.") # Steps: # 1. Cull trajectories that are entirely outside the map # 2. Annotate trajectories with scalars needed for color # 3. Split trajectories into equal sized batches # 4. Add clock to map: TODO: I still need arguments to control whether the clock is # included and, if so, where and how it's rendered. # 5. Generate a movie for each batch of trajectories using multiprocessing pool # 6. Splice movies together into a single movie # Configure the batch renderer renderer = BatchMovieRenderer() global BATCH_RENDERER BATCH_RENDERER = renderer #tiles override cartopy map features if tiles != None: fill_land=False fill_water=False draw_coastlines=False draw_countries=False draw_states=False num_frames = fps * duration # We can compute the bounding box for Cartesian data automatically. # We don't need to do so for terrestrial data because the map will # default to the whole world. if (domain == 'cartesian2d' and (map_bbox is None or len(map_bbox) == 0)): map_bbox = geomath.compute_bounding_box(itertools.chain(*trajectories)) # Set up the map. logger.info('Initializing map canvas for rendering.') (figure, axes) = initialize_canvas(resolution, dpi) if map_canvas == None: (map_canvas, map_actors) = render_map.render_map(domain=domain, map_name=map_name, map_bbox=map_bbox, map_projection=map_projection, draw_lonlat=draw_lonlat, draw_coastlines=draw_coastlines, draw_countries=draw_countries, draw_states=draw_states, fill_land=fill_land, fill_water=fill_water, tiles=tiles, **kwargs) map_bbox = map_extent_as_bounding_box(map_canvas, domain=domain) # Setup trajectory render style if trajectory_linewidth == 'taper': linewidth_style = 'taper' linewidth = trajectory_initial_linewidth final_linewidth = trajectory_final_linewidth else: linewidth_style = 'constant' linewidth = trajectory_linewidth final_linewidth = linewidth # This set of arguments will be passed to the savefig() call that # grabs the latest movie frame. This is the place to put things # like background color, tight layout and friends. if savefig_kwargs == None: savefig_kwargs = {'facecolor': figure.get_facecolor()} # This a known issue in matplotlib that was never fixed so we're on the # hook to ensure that we don't pass the bbox_inches param to ffmpeg if "bbox_inches" in savefig_kwargs and encoder == 'ffmpeg': logger.warn('The `bbox_inches` save argument is incompatiable with ffmpeg, argument will be removed from `savefig_kwargs`.') savefig_kwargs.pop("bbox_inches") (movie_start_time, movie_end_time) = compute_movie_time_bounds( trajectories, start_time, end_time) # Cull out trajectories that do not overlap the map. We do not # clip them (at least not now) since that would affect measures # like progress along the path. trajectories_on_map = list(trajectories_inside_box(trajectories, map_bbox)) if len(trajectories_on_map) == 0: raise ValueError( ('No trajectories intersect the map bounding box ' '(({} {}) - ({} {})). Is the ' 'bounding box correct?').format(map_bbox.min_corner[0], map_bbox.min_corner[1], map_bbox.max_corner[0], map_bbox.max_corner[1])) logger.info('Movie covers time span from {} to {}'.format( movie_start_time.strftime("%Y-%m-%d %H:%M:%S"), movie_end_time.strftime("%Y-%m-%d %H:%M:%S"))) frame_duration = ((movie_end_time - movie_start_time) / num_frames) first_frame_time = movie_start_time + trail_duration total_frame_count = num_frames if figure is None: figure = pyplot.gcf() # Setup the temp dir needed to store the movie parts tmpdir = tempfile.mkdtemp(prefix='movie_parts') # Setup the renderers args # Common Args renderer.trajectories = trajectories_on_map renderer.trail_duration = trail_duration # Mapmaker Args renderer.domain = domain renderer.map_canvas = map_canvas renderer.figure = figure # Movie Args renderer.savefig_kwargs = savefig_kwargs renderer.dpi = dpi renderer.axes = axes renderer.fps = fps renderer.temp_directory = tmpdir if utc_offset: renderer.utc_offset = int(utc_offset) if timezone_label: renderer.timezone_label = timezone_label renderer.frame_duration = frame_duration renderer.first_frame_time = first_frame_time renderer.codec = codec renderer.encoder = encoder renderer.color_map=trajectory_colormap renderer.decorate_head=decorate_trajectory_head renderer.head_size=trajectory_head_dot_size renderer.head_color=trajectory_head_color renderer.linewidth_style=linewidth_style renderer.linewidth=linewidth renderer.final_linewidth=final_linewidth renderer.scalar=trajectory_color renderer.scalar_min=scalar_min renderer.scalar_max=scalar_max renderer.zorder=trajectory_zorder # Figure out how many batches we're going to need start_frame = 0 batch_id = 0 frame_batches = [] while start_frame < total_frame_count: last_frame = min(total_frame_count, start_frame + frame_batch_size) num_frames = (last_frame - start_frame) + 1 frame_batches.append(( batch_id, start_frame, num_frames, tmpdir )) start_frame += frame_batch_size batch_id += 1 # Setup the number of thread processors if processors == 0: processors = None pool = multiprocessing.Pool(processes=processors) result = pool.map_async(renderer.render_frame_batch, frame_batches) batch_result = result.get() logger.info("Combining movie parts into raw footage file") concatenate_movie_chunks(batch_result, tmpdir) logger.info("Encoding raw footage file to final movie") encode_final_movie(filename, tmpdir, encoder_args) logger.info("Cleaning up temporary files") remove_movie_chunks(tmpdir, batch_result) os.rmdir(tmpdir)