How to Animate Movement Over Time in Python

Problem statement

An animation is the only visualisation that shows movement as movement. It is also the easiest to make badly, because the two things it must get right are invisible in a still frame:

  • Frames must be equally spaced in time, not in rows. GPS logs are irregular β€” the traces measured here had a median interval of 3 s and a maximum of 2,989 s β€” so one frame per row plays back at wildly varying speed.
  • A gap must look like a gap. Interpolating across a fifty-minute hole produces a smooth glide along a route nobody took.

Quick answer

Build a regular time index, snap positions to it, and mark what was interpolated:

import numpy as np
import pandas as pd


def to_frames(df, id_col="track_id", step="10s", gap_s=300):
    """One row per track per frame, with interpolated positions flagged."""
    g = df.groupby(id_col)
    df = df.copy()
    df["leg"] = (g["time"].diff().dt.total_seconds() > gap_s) \
        .groupby(df[id_col]).cumsum()

    out = []
    for keys, sub in df.groupby([id_col, "leg"]):
        sub = sub.sort_values("time").set_index("time")
        frames = sub[["x", "y"]].resample(step).mean()
        observed = sub[["x"]].resample(step).count()["x"] > 0
        frames = frames.interpolate(method="time", limit_area="inside")
        frames["interpolated"] = ~observed.reindex(frames.index,
                                                   fill_value=False).values
        frames[id_col], frames["leg"] = keys
        out.append(frames.reset_index())

    result = pd.concat(out, ignore_index=True)
    print(f"  {result['time'].nunique():,} frames, "
          f"{result['interpolated'].mean():.1%} interpolated positions")
    return result

limit_area="inside" is what stops the interpolation extending past the last real fix of a leg β€” otherwise a track keeps moving after the receiver stopped recording.

Irregular GPS fixes resampled onto a regular frame index, with frames inside a data gap left empty rather than interpolated.
Frames are regular in time. Gaps stay empty, so the animation shows an absence rather than inventing a glide.

Step-by-step solution

1. Choose the frame interval from the movement, not the file

The frame interval sets both the smoothness and the number of frames. A useful rule: an object should move a few pixels between frames.

At a 5 m/s walking-to-cycling pace and a map scale of 2 m per pixel, a 10-second frame moves 25 pixels β€” visible but jumpy. Two seconds moves 5 pixels, which is smooth. Two seconds over an eight-hour dataset is 14,400 frames, so there is a length constraint too.

2. Resample, do not iterate over rows

One frame per row plays back at the sampling rate, which varies. The result speeds up where the receiver logged often and freezes where it did not β€” the opposite of what the viewer will infer.

3. Keep gaps empty

A leg boundary should produce frames with no position for that track. In a scatter animation the marker simply disappears, which reads correctly as "no data" rather than as "stopped" or "teleported".

4. Add a trailing tail, not a full history

Drawing the whole track from the start makes the frame progressively more cluttered and hides where the object is. A tail of the last n frames shows direction and recent speed while keeping the current position readable.

5. Put the clock in the frame

An animation without a visible timestamp cannot be interrogated. A text element showing the frame's time, and a progress bar, turn "something happens around here" into "at 08:42".

An animation frame showing the current position with a short trailing tail against one drawing the entire accumulated track.
A fixed-length tail encodes recent speed and direction. A full history encodes nothing after the first minute.

Code examples

Example 1 β€” a Matplotlib animation with tails and a clock

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation


def animate_tracks(frames, id_col="track_id", tail=15, basemap=None,
                   out_path="movement.mp4", fps=20, dpi=120):
    """One marker per track, with a trailing tail and a visible clock."""
    times = np.sort(frames["time"].unique())
    tracks = frames[id_col].unique()
    colours = plt.cm.tab20(np.linspace(0, 1, len(tracks)))

    fig, ax = plt.subplots(figsize=(8, 8))
    if basemap is not None:
        basemap.plot(ax=ax, color="#f2f4f7", edgecolor="#dfe3e8", linewidth=0.4)
    ax.set_xlim(frames["x"].min(), frames["x"].max())
    ax.set_ylim(frames["y"].min(), frames["y"].max())
    ax.set_aspect("equal")
    ax.set_axis_off()

    lines = {t: ax.plot([], [], lw=1.6, color=c, alpha=0.9)[0]
             for t, c in zip(tracks, colours)}
    dots = {t: ax.plot([], [], "o", ms=5, color=c)[0]
            for t, c in zip(tracks, colours)}
    clock = ax.text(0.02, 0.97, "", transform=ax.transAxes, va="top",
                    fontsize=11, family="monospace")

    by_time = {t: g for t, g in frames.groupby("time")}

    def update(i):
        window = times[max(0, i - tail):i + 1]
        for track in tracks:
            recent = frames[(frames[id_col] == track) &
                            (frames["time"].isin(window))].dropna(subset=["x"])
            lines[track].set_data(recent["x"], recent["y"])
            now = by_time.get(times[i])
            current = now[now[id_col] == track].dropna(subset=["x"]) \
                if now is not None else None
            if current is not None and len(current):
                dots[track].set_data(current["x"], current["y"])
            else:
                dots[track].set_data([], [])       # gap: marker disappears
        clock.set_text(str(times[i])[:19])
        return list(lines.values()) + list(dots.values()) + [clock]

    anim = FuncAnimation(fig, update, frames=len(times), blit=True, interval=1000 / fps)
    anim.save(out_path, fps=fps, dpi=dpi)
    print(f"  wrote {out_path}: {len(times):,} frames at {fps} fps "
          f"({len(times) / fps:.1f} s)")
    plt.close(fig)

blit=True redraws only the artists that changed, which is the difference between a few seconds and a few minutes for a long animation.

Example 2 β€” choosing the frame interval from the data

import numpy as np


def suggest_frame_interval(frames, extent_m, pixels=800,
                           target_px_per_frame=4.0, max_frames=3000):
    """Balance smoothness against file size, using the actual speeds."""
    speeds = frames.groupby("track_id").apply(
        lambda s: np.hypot(s["x"].diff(), s["y"].diff()).median(),
        include_groups=False)
    typical = float(np.nanmedian(speeds))

    m_per_px = extent_m / pixels
    smooth = target_px_per_frame * m_per_px / max(typical, 0.1)

    span = (frames["time"].max() - frames["time"].min()).total_seconds()
    minimum = span / max_frames

    chosen = max(smooth, minimum)
    print(f"  typical step {typical:.1f} m, scale {m_per_px:.1f} m/px")
    print(f"  smoothness suggests {smooth:.1f}s; "
          f"the {max_frames}-frame cap requires at least {minimum:.1f}s")
    print(f"  use {chosen:.0f}s -> {span / chosen:,.0f} frames")
    return chosen

The two constraints usually disagree, and the frame cap usually wins for anything longer than an hour. When it does, the honest options are to shorten the time window or to accept a jumpier animation β€” not to drop frames unevenly.

Example 3 β€” a lightweight interactive alternative

import folium
from folium.plugins import TimestampedGeoJson


def timestamped_map(frames, id_col="track_id", period="PT10S"):
    """A browser-based animation with a time slider, no video encoding."""
    features = []
    for track, sub in frames.dropna(subset=["x"]).groupby(id_col):
        sub = sub.sort_values("time")
        features.append({
            "type": "Feature",
            "geometry": {"type": "LineString",
                         "coordinates": sub[["lon", "lat"]].values.tolist()},
            "properties": {
                "times": sub["time"].dt.strftime("%Y-%m-%dT%H:%M:%S").tolist(),
                "style": {"color": "#0ea5e9", "weight": 3},
                "popup": str(track),
            },
        })

    centre = [frames["lat"].mean(), frames["lon"].mean()]
    m = folium.Map(location=centre, zoom_start=13, tiles="CartoDB positron")
    TimestampedGeoJson({"type": "FeatureCollection", "features": features},
                       period=period, add_last_point=True,
                       auto_play=False, loop=False).add_to(m)
    print(f"  {len(features)} tracks, "
          f"{sum(len(f['properties']['times']) for f in features):,} timestamps")
    return m

A time slider is often better than a video: the viewer controls the pace, can stop on a moment, and can zoom. It also avoids the frame-count trade-off entirely, at the cost of needing the data in the browser.

Explanation

Why frames must be regular in time

An animation maps frame number to elapsed time. If frames come from rows, and rows arrive irregularly, that mapping is nonlinear β€” playback speeds up where the receiver logged often and crawls where it did not.

Viewers read apparent speed as real speed, so an irregular animation communicates the receiver's behaviour rather than the object's. Resampling to a regular index makes apparent speed proportional to real speed, which is the entire point of animating.

Why gaps should stay empty

The two alternatives are both worse. Interpolating across a fifty-minute gap draws a smooth glide along a route that was never travelled, at an average speed that was never held. Freezing the marker in place says the object stopped, when it may have driven fifty kilometres.

An absent marker is unambiguous, and viewers read it correctly with a single legend note. It is also the only option that does not assert something false.

Why a tail beats a full track

A full accumulated track is informative for the first few seconds and then becomes an increasingly dense scribble hiding the current position.

A tail of fixed length has a second property that is easy to miss: because frames are regular in time, the tail's length on screen is proportional to speed. A stationary object has a dot; a fast one has a long streak. Speed becomes visible without a colour scale.

Why an animation is a poor analytical tool

Animations are excellent for communicating that movement happened and roughly where. They are poor for quantities: viewers cannot count, cannot compare durations reliably, and cannot revisit a moment without scrubbing.

Pair an animation with a static summary β€” a flow map, a space-time plot, a table β€” and let the animation do the job it is good at. Where the audience needs to interrogate the data, a time slider beats a video every time.

Frame counts for an eight-hour dataset falling from 28,800 at one second to 960 at thirty seconds.
The two constraints disagree. For anything over an hour, the frame cap decides.

Edge cases or notes

  • Resample to a regular time index, never one frame per row.
  • Keep gaps empty rather than interpolating or freezing.
  • limit_area="inside" stops interpolation running past the last real fix.
  • Use blit=True in Matplotlib, or long animations take minutes to render.
  • Cap the frame count. Above a few thousand, file size and render time dominate.
  • Put the timestamp in the frame. An animation without a clock cannot be interrogated.
  • Fix the axis limits before animating, or the view jumps as tracks enter and leave.
  • Consider a time slider instead of a video where the audience needs control.

FAQ

How do I animate GPS tracks in Python?

Resample the tracks onto a regular time index, then use matplotlib.animation.FuncAnimation to draw one marker and a short tail per track per frame, with the timestamp shown.

Why does my animation play at uneven speed?

Because you made one frame per row, and GPS sampling is irregular. Resample to a fixed time interval so frame number is proportional to elapsed time.

What frame interval should I use?

One where the object moves a few pixels per frame at the map's scale, subject to a cap on total frames. The two constraints usually conflict for long datasets.

What should happen during a data gap?

Nothing. Leave the frames empty so the marker disappears. Interpolating invents a journey; freezing implies a stop.

Should I draw the whole track or a tail?

A tail. With regular frames, the tail's on-screen length is proportional to speed, so it encodes information a full track does not.

Is a video or an interactive map better?

An interactive time slider is usually better for analysis, because the viewer controls the pace and can stop on a moment. A video is better for a fixed presentation.

Why is my animation so slow to render?

Usually blit=False and too many frames. Enable blitting and cap the frame count at a few thousand.