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.
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".
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.
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=Truein 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.
Internal links
- How to resample and interpolate a track to a fixed interval β building the frame index
- Trajectories explained β why irregular sampling matters
- Sampling rate and gaps explained β where the gaps come from
- How to create interactive maps in Python with Folium β the time-slider route
- How to save a map as an image in Python with Matplotlib β static output
- How to aggregate movement into flows between zones β the summary to pair with the animation
- How to batch-generate a map image for every region in Python β rendering many frames unattended
- How to load GPS tracks into Python as trajectories β the input
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.