Source code for modelly.viz.animation

"""Export a space view as an animation (GIF or MP4), rendered headless.

The frames are models: the states of one run, one per frame. ``frames_from_run`` steps a
model and yields it after every ``every`` steps; ``frames_from_timeline`` yields the model
at each recorded step of a ``modelly.timetravel`` recording. ``save_animation`` draws each
one with ``modelly.viz.mpl.figure.space_figure`` and writes the file::

    from modelly.viz.animation import frames_from_run, save_animation

    save_animation(frames_from_run(model, steps=100), "run.mp4")

GIF needs only Pillow (a Matplotlib dependency). MP4 needs the ``video`` extra
(``pip install modelly-abm[video]``), whose ``imageio-ffmpeg`` ships an ffmpeg binary for
Windows, macOS and Linux: nothing to install system-wide.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

import numpy as np

from modelly.viz.mpl.figure import DEFAULT_DPI, figure_pixels, space_figure

if TYPE_CHECKING:
    from modelly.core.model import Model
    from modelly.timetravel import Timeline

DEFAULT_FPS = 10
MS_PER_SECOND = 1000
# Video codecs work on blocks of 16 pixels; frames are padded to a multiple of it.
MACRO_BLOCK = 16
WHITE = 255
FORMATS = (".gif", ".mp4")


[docs] @dataclass(frozen=True) class AnimationOptions: """How an animation is drawn and timed. Attributes: fps: Frames per second. figsize: Width and height of each frame, in inches. dpi: Pixels per inch. loop: For GIF, play in a loop (MP4 players decide for themselves). """ fps: int = DEFAULT_FPS figsize: tuple[float, float] = (5.0, 5.0) dpi: int = DEFAULT_DPI loop: bool = True
[docs] def frames_from_run(model: Model, steps: int, every: int = 1) -> Iterator[Model]: """Yield ``model`` now, then after every ``every`` steps, ``steps`` steps in all. The model is stepped in place; the run stops early when ``model.running`` is False. """ yield model for done in range(1, steps + 1): if not model.running: return model.step() if done % every == 0: yield model
[docs] def frames_from_timeline(timeline: Timeline) -> Iterator[Model]: """Yield the model at each recorded step of ``timeline``, oldest first.""" for step in timeline.steps: yield timeline.at(step)
def _pad(frame: np.ndarray) -> np.ndarray: """Pad a frame with white to a multiple of ``MACRO_BLOCK`` pixels each way.""" height, width = frame.shape[:2] pad_h, pad_w = (-height) % MACRO_BLOCK, (-width) % MACRO_BLOCK return np.pad(frame, ((0, pad_h), (0, pad_w), (0, 0)), constant_values=WHITE) def _write_gif(path: Path, frames: Iterator[np.ndarray], options) -> int: """Write the frames as a GIF with Pillow; the number of frames.""" from PIL import Image # noqa: PLC0415 (Pillow comes with Matplotlib) images = [Image.fromarray(frame) for frame in frames] if not images: return 0 # loop=0 repeats forever; without the key the GIF plays once. looping = {"loop": 0} if options.loop else {} images[0].save( path, save_all=True, append_images=images[1:], duration=MS_PER_SECOND // options.fps, **looping, ) return len(images) def _write_mp4(path: Path, frames: Iterator[np.ndarray], options) -> int: """Write the frames as an H.264 MP4 with imageio-ffmpeg; the number of frames.""" try: import imageio_ffmpeg # noqa: PLC0415 (optional: the video extra) except ImportError as error: raise ImportError( "MP4 export needs imageio-ffmpeg: pip install modelly-abm[video]" ) from error first = next(frames, None) if first is None: return 0 height, width = first.shape[:2] writer = imageio_ffmpeg.write_frames(str(path), (width, height), fps=options.fps) writer.send(None) # start ffmpeg count = 0 try: for frame in (first, *frames): writer.send(np.ascontiguousarray(frame).tobytes()) count += 1 finally: writer.close() return count
[docs] def save_animation( models: Iterable[Model], path: str | Path, options: AnimationOptions | None = None, agent_portrayal: Callable | None = None, ) -> int: """Draw each model's space as one frame and write a GIF or MP4 to ``path``. Args: models: The frames, such as ``frames_from_run(...)`` or ``frames_from_timeline(...)``. path: The file; its suffix, ``.gif`` or ``.mp4``, picks the format. options: Frame rate, frame size and looping. agent_portrayal: As for ``space_figure``; one colour per agent class by default. Returns: The number of frames written. Raises: ValueError: For a suffix other than ``.gif`` and ``.mp4``. """ path = Path(path) suffix = path.suffix.lower() if suffix not in FORMATS: raise ValueError(f"unknown animation format {path.suffix!r}; use .gif or .mp4") options = options or AnimationOptions() frames = ( _pad( figure_pixels( space_figure(model, agent_portrayal, options.figsize, options.dpi) ) ) for model in models ) write = _write_gif if suffix == ".gif" else _write_mp4 return write(path, frames, options)
__all__ = [ "AnimationOptions", "frames_from_run", "frames_from_timeline", "save_animation", ]