matthewplotlib

matthewplotlib.animations

Animations: a sequence of plots, and the terminal session that shows them.

There are two things in this module and they are two halves of one loop.

  • tstack is an animation as a value: a sequence of plots with a frame rate. It has no terminal and no clock, and like any other plot expression it composes -- slice it, map a combinator over its frames, save it as a gif. animation builds one straight from an array with a time axis, as image does from an array without one.

  • animate is an animation as a session: a context manager that owns the terminal for the duration of a with block. Inside it, anim.update(plot) writes one frame, the previous-frame bookkeeping of print(plot - prev) disappears, frames are paced, and anim.print(...) can log a line without corrupting the plot.

Each produces the other, so there is one code path rather than two:

# push a live animation into the terminal, keeping the frames
with mp.animate(fps=20, record=True) as anim:
    while running:
        anim.update(compute_frame())
anim.frames.savegif("out.gif")

# pull a finished animation back out of a value
mp.tstack(*frames).play(fps=20)

Note that neither is required. Animation is a loop of print(plot - prev) and that stays fully supported; animate is there to own the parts of it that are about terminals rather than about plots.

class tstack:

Temporally stack plots into an animation.

The third stacking operation, alongside hstack (+) and vstack (/): where those lay plots out across the screen, this one lays them out in time. The result is a value, not an action -- nothing is printed until you play it or savegif it.

Inputs:

  • *plots : plot | tstack. The frames, in order. Any tstack among them is spliced in frame by frame, so tstack(a, b) concatenates two animations and tstack(a, still) appends a frame to one.
  • fps : optional float. The rate this animation is meant to play at, used as the default by both play and savegif. An upper bound rather than a promise: see animate. Defaults to the frame rate of the first tstack among the inputs, so concatenating and slicing preserve it, or to 12.0 if there is none.
  • durations : optional sequence of float. How long each frame was on screen, in milliseconds, if this animation was recorded from a live run. Supplied by animate.frames, which is the only thing that can know it, and read back by savegif(fps="achieved"). Must have one entry per frame.

Examples:

# build one frame at a time
a = mp.tstack(*[
    mp.image(field(t)) for t in np.linspace(0, 1, 60)
], fps=30)

# 60 frames, 20 rows, 40 columns
len(a), a.height, a.width

# every frame, in a titled border
a = a.map(lambda p: mp.border(p, title=" diffusion "))

# the second half, backwards
a[len(a)//2:][::-1].play()

Notes:

  • Frames do not have to be the same size going in: they are padded with blank space to the largest, aligned at the top left, so an animation never changes shape while it plays. Differential redraw makes that free -- the padding is blank in every frame, so no cell of it is ever sent twice.
tstack( *plots: matthewplotlib.plots.plot | tstack, fps: float | None = None, durations: Optional[Sequence[float]] = None)
plots: tuple[matthewplotlib.plots.plot, ...]
durations: tuple[float, ...] | None
height: int

Number of character rows in the tallest frame (0 if there are none).

width: int

Number of character columns in the widest frame (0 if there are none).

def map( self, f: Callable[[matthewplotlib.plots.plot], matthewplotlib.plots.plot]) -> tstack:

Apply a function to every frame, giving a new animation.

This is how an animation composes: any of the combinators in matthewplotlib.plots can be lifted over the time axis by passing it through here, which is usually how a static furnishing gets wrapped around a moving interior.

a.map(lambda p: mp.border(p, title=" gen 0 "))   # a border on each
a.map(lambda p: p + legend)                      # a panel beside each

The frame rate and recorded durations carry over unchanged, since neither depends on what the frames contain.

For combining two animations frame by frame there is no method: zip them and rebuild, as in mp.tstack(*[x + y for x, y in zip(a, b)]).

def play( self, fps: float | None = None, loop: bool = False, stop_on_interrupt: bool = True) -> None:

Print the frames to the terminal, in order, one frame at a time.

A thin wrapper around animate: this pushes the animation's own frames through a session, so playing a finished animation and driving a live one go down exactly the same path.

Inputs:

  • fps : optional float. Frame rate to play at, defaulting to the animation's own fps.
  • loop : bool (default False). If true, start again from the first frame instead of returning, forever. Interrupt to stop.
  • stop_on_interrupt : bool (default True). Whether Ctrl-C ends playback quietly rather than raising KeyboardInterrupt. Unlike animate, this defaults to true: there is no caller loop here whose control flow could be taken over, and interrupting playback is the only way to end loop=True.
def savegif( self, filename: str, fps: Union[float, Literal['achieved'], NoneType] = None, upscale: int = 1, downscale: int = 1, bgcolor: ColorLike | None = None, repeat: bool = True) -> None:

Render the frames and save them as an animated gif.

Inputs:

  • filename : str. Where to save the gif. Should usually include a '.gif' extension.
  • fps : optional float or the string "achieved". Frame rate to encode. By default the animation's own fps, which for a recording is the rate that was asked for. Pass a number to override it, or "achieved" to use the durations actually measured while recording -- faithful right down to reproducing a frame that stalled, which is honest but rarely what a showcase gif wants. Requires durations, so only a recorded animation can use it.
  • upscale : int (>=1, default 1). Represent each pixel with a square of side-length upscale pixels.
  • downscale : int (>=1, default 1). Keep every downscaleth pixel. Does not need to evenly divide the image height or width (think slice(0, height or width, downscale)). Applied after upscaling.
  • bgcolor : optional ColorLike. Default background colour. If none, a transparent background is used.
  • repeat : bool (default True). If true (default), the gif loops indefinitely. If false, the gif only plays once.

Notes:

  • Frames of different sizes are aligned at the top left corner and padded with transparent pixels on the bottom and right. For different padding, compose the frames with blank blocks first.
  • A gif stores its frame delays in hundredths of a second, so the rate in the file is quantised to 10ms steps: 12 fps asks for 83ms, gets 80ms, and plays at 12.5. Delays are clamped to 10ms, the shortest a gif can express, so above 100 fps the file stops getting faster -- and above roughly 50 fps the number is fiction anyway, since many viewers refuse delays under 20ms.
class animation(tstack):

An animation from an array with a time axis: image, one dimension up.

Takes exactly what image takes with a leading frame index, so a field evolving over time goes straight to the screen without a loop in sight.

Inputs:

  • ims : float[t,h,w] | float[t,h,w,rgb] | int[t,h,w] | int[t,h,w,rgb]. The frames. As for image, floats are clipped to the unit interval and integers to 0..255, and the second and third axes are the image's rows and columns -- each pair of rows becomes one row of half-block characters, so a 2n row image is n character rows tall.
  • colormap : optional ColorMap. Applied to a scalar array to colour it, exactly as in image. Applied to the whole tensor in one call, which is both faster than doing it frame by frame and the only way to be sure every frame is coloured on the same scale.
  • fps : optional float (default 12.0). The rate the animation is meant to play at.

Examples:

# a diffusing blob, straight from the array
mp.animation(field, colormap=mp.viridis, fps=30).play()

# a video, as uint8 RGB
mp.animation(video).savegif("video.gif")
animation( ims: ArrayLike, colormap: ColorMap | None = None, fps: float | None = None)
class animate:

A context manager that owns the terminal while an animation runs.

Inside the block, each call to update writes one frame:

with mp.animate(fps=20) as anim:
    while running:
        anim.update(mp.axes(...))

which is the print(plot - prev) loop with the parts that are about terminals rather than about plots taken over: the prev sentinel and the assignment that maintains it, the frame clock, and separating whatever comes next on the screen from the plot on the way out.

Everything beyond that first job is opt-in, because a context manager takes over the caller's control flow and this library would rather be a library than a framework. The plain loop remains fully supported and is what the quickstart teaches.

Inputs:

  • fps : optional float. If given, cap the frame rate: update sleeps off whatever is left of the previous frame's budget before writing. An upper bound, not a guarantee -- a frame that takes longer than 1/fps to compute simply takes longer, and the clock picks up from there rather than trying to catch up. If omitted, update returns as soon as it has written.
  • record : bool (default False). If true, keep every frame, readable afterwards as anim.frames. Opt-in because holding an entire animation in memory is a real cost and a while True loop would never stop paying it.
  • stop_on_interrupt : bool (default False). If true, Ctrl-C ends the animation quietly: the with block exits and the statements after it still run, so a recording survives to be saved. Off by default because swallowing KeyboardInterrupt is precisely the kind of control flow a library should not take without being asked.

Attributes, readable during the block and after it:

  • frames : tstack. The recorded animation, with the frame timings attached. Raises unless record=True was passed.
  • achieved_fps : float | None. The frame rate actually managed so far, or None before the second frame. Worth printing after a run that felt sluggish: asking for 20 and getting 6 is not otherwise visible.
  • out : a writable text stream. anim.print as a file, for the things that want somewhere to write rather than something to call:

    print("step", step, file=anim.out)
    logging.basicConfig(stream=anim.out)
    

    Line buffered, since the plot moves out of the way a line at a time. Redirecting sys.stdout to it for the whole block routes every print in the program, including ones inside libraries you did not write:

    with mp.animate(fps=20) as anim, contextlib.redirect_stdout(anim.out):
        ...
    

    which works because the session captured the real stdout on the way in.

Notes:

  • A plot has to fit the terminal with a row to spare for the newline that print appends, so at most rows - 1 of it will render. If the first frame does not fit, and stdout is a terminal that can be measured, this warns once.
  • The cursor is left visible. It sits in the plot and blinks there, which is a fair trade for never leaving a terminal with an invisible cursor.
animate( fps: float | None = None, record: bool = False, stop_on_interrupt: bool = False)
fps
stop_on_interrupt
out
def update(self, plot: matthewplotlib.plots.plot) -> str:

Write one frame, and return the string that was written.

The first call renders the whole plot; each later call repaints only the cells that differ from the frame before it, which is the same plot.updatestr(prev) the - operator gives, with prev looked after here.

If the session has an fps, this is also where it waits. Everything a frame costs is spent inside its budget rather than on top of it: the time the caller spent computing it, because the sleep happens before the write, and the time spent working out the diff and pushing the bytes down the wire, because the next frame is scheduled from the moment this one's slot opened rather than from the moment its write finished. A flat sleep(1/fps) gets both wrong, and runs slow by their sum.

The return value is the frame's exact bytes, which is what makes the cost of differential rendering measurable from user code -- see examples/life.py, which plots it.

def print(self, *args: object, **kwargs: Any) -> None:

Print a line above the animation, without corrupting it.

A bare print from inside an animated loop lands in the middle of the plot, which leaves debugging an animated program a choice between not printing and not animating. This routes the line instead: the plot is erased, the message takes the row the plot's first row was on, and the plot is redrawn one row lower.

with mp.animate(fps=20) as anim:
    for step in range(1000):
        anim.update(vis(params))
        if step % 100 == 0:
            anim.print(f"step {step}: loss {loss:.4f}")

So messages pile up above the plot and scroll off the top of the screen in the usual way, with the plot pinned below them. Takes the same arguments as the builtin print, except that end must stay a newline: the redraw has to start at the beginning of a line.

Costs a full repaint, rather than the differential redraw a frame gets. Printing happens at human speed, so this is not worth optimising.

achieved_fps: float | None

Frames per second actually achieved so far, or None before frame two.

frames: tstack

The recorded frames, as a tstack, with their measured durations.

The animation's fps is the rate that was requested, so anim.frames.savegif(...) writes a gif at the intended speed by default, and savegif(fps="achieved") writes one at the speed the run actually managed.

Raises ValueError unless the session was created with record=True.