目录19

文章Matplotlib第 41 章

动画

Matplotlib · 第 41 / 43 章

动画只有三种交付形态,先定形态再写代码:

形态 适用 代价
.gif 网页、论文插图、聊天工具 只支持 256 色,体积随帧数线性涨
.mp4 视频、会议录屏 需要 ffmpeg;网页里要 <video>
.html(内嵌帧序列) Jupyter / 交互演示 每帧一张 png 全塞进 HTML,最重

同样的 20 帧,gif 14.4 KB、mp4 3.8 KB、html 48.9 KB——mp4 最省,html 最贵(它把每帧 base64 内嵌进文档)。

同一段 20 帧动画的三种交付格式

FuncAnimation:每帧调一次函数

Python2 行
FuncAnimation(fig, func, frames=None, init_func=None, fargs=None,
              save_count=None, *, cache_frame_data=True, **kwargs)
参数 一句话 默认值的影响
fig 要画在哪张图上 必须给,动画不生成新 figure
func(frame) 每帧调用一次,负责改 artist 必须返回被改动的 artist 元组
frames 帧从哪来 None 是无限计数(保存时会一直跑,别这么用)
init_func 第一帧前的初始化 不写也能保存,但交互播放的第一帧可能残留旧内容
fargs 给 func 追加的额外参数 None;传 (a, b) 等于调用 func(frame, a, b)
save_count 只保存前 n 帧 只对 frames 是无限序列时有意义
cache_frame_data 是否缓存每帧数据 True;frames 是生成器且内存吃紧时关掉
interval 播放间隔(毫秒) 在 **kwargs 里,默认 200;保存时被 fps 覆盖
blit 只重绘变化的 artist 在 **kwargs 里,默认 False;只影响交互播放,不影响保存结果
repeat 播完是否重播 在 **kwargs 里,默认 True;不影响存出来的 gif 循环标记

interval / blit / repeat 这三个常用参数不在签名里,它们随 **kwargs 透传给 TimedAnimation——所以查 inspect.signature 看不到,不代表不能用。

最小示例

Python32 行
import matplotlib
matplotlib.use('Agg')                      # 无窗口环境必须先设后端
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation

fig, ax = plt.subplots(figsize=(3.6, 2.2))
x = np.linspace(0, 2 * np.pi, 120)
line, = ax.plot([], [], lw=1.6, color='#3266a8')
ax.set_xlim(0, 2 * np.pi); ax.set_ylim(-1.35, 1.35)
ax.set_xlabel('x'); ax.set_ylabel('sin(x - t)')
ax.set_title('t = 0.00 周期', fontsize=8.5)  # 先给标题,tight_layout 才算进边距

def init():
    line.set_data([], [])
    return (line,)

def update(i):
    line.set_data(x, np.sin(x - i * 2 * np.pi / 36))
    ax.set_title(f't = {i / 36:.2f} 周期', fontsize=8.5)
    return line, ax.title

fig.tight_layout()
ani = FuncAnimation(fig, update, frames=36, init_func=init,
                    interval=1000 // 12, blit=False)
ani.save('X_anim_demo.gif', writer='pillow', fps=12)

from PIL import Image
im = Image.open('X_anim_demo.gif')
print(im.n_frames, im.info.get('duration'), type(ani).__name__)
# 输出: 36 80 FuncAnimation
plt.close(fig)

行波:sin(x − t) 随帧平移

两个反直觉的地方,下面各有一节:blit=False 不影响存出来的 gif;fps=12 把 interval=83 换算成“每帧 83 ms”,两者说的是同一件事。

frames:四种给法

Python21 行
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

fig, ax = plt.subplots()
line, = ax.plot([], [])

def upd(i):
    line.set_data([0, i], [0, i])
    return (line,)

for desc, kw in [('int 5', {'frames': 5}),
                 ('list [0,3,6]', {'frames': [0, 3, 6]}),
                 ('range(4)', {'frames': range(4)}),
                 ('None + save_count=4', {'frames': None, 'save_count': 4})]:
    ani = FuncAnimation(fig, upd, **kw)
    print(desc, '->', type(ani).__name__)
# 输出: int 5 -> FuncAnimation
# 输出: list [0,3,6] -> FuncAnimation
# 输出: range(4) -> FuncAnimation
# 输出: None + save_count=4 -> FuncAnimation
plt.close(fig)

frames 是一个可迭代对象:给 int 就是 range(n),给 list 就逐个把元素传给 func(所以 func 的参数不一定是整数,可以是 ['a', 'b'] 这样的标签)。

frames=None 最危险:它内部是 itertools.count(),无限。plt.show() 下没问题(播到天荒地老),但 ani.save() 会永远跑不完——本机实测跑了 4 分钟仍在写帧,只能强杀。要“无限播放 + 有限保存”就配 save_count=n。

func 的返回值与 blit

func 必须返回这一帧改动过的所有 artist,包成 tuple。blit=True 时框架只重绘这些对象,其他像素原样保留——这是交互播放能流畅的原因,也是“某条线不动”的唯一成因。

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

def build(blit):
    fig, ax = plt.subplots(figsize=(2, 1.4), dpi=70)
    ax.set_xlim(0, 10); ax.set_ylim(-1.5, 1.5)
    line, = ax.plot([], [], lw=1.5)
    x = np.linspace(0, 10, 60)

    def upd(i):
        line.set_data(x[:i + 2], np.sin(x[:i + 2] - i / 3))
        return (line,)

    ani = FuncAnimation(fig, upd, frames=12, interval=80, blit=blit)
    ani.save(f'X_blit_{blit}.gif', writer='pillow')
    from pathlib import Path
    plt.close(fig)
    return Path(f'X_blit_{blit}.gif').stat().st_size

print(build(True), build(False))
# 输出: 10791 10791

体积一模一样。因为保存时走的是“每帧全量重绘 → 编码成图片”,blit 那套增量刷新完全没用上。所以:

  • 只为存 gif / mp4 的话,blit 随便给,别为它纠结;
  • 要交互播放(plt.show())才认真写返回值;
  • blit=True 且返回值漏了 artist,交互时那一块不更新,但存出来的文件是对的——这个错会一直藏着。

init_func:不写也能跑

Python17 行
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

fig, ax = plt.subplots(figsize=(2, 1.4))
ax.set_xlim(0, 10); ax.set_ylim(-1.5, 1.5)
line, = ax.plot([], [], lw=1.5)

def upd(i):
    line.set_data([0, i], [i / 4, i / 4])                # y 要留在 -1.5~1.5 之间
    return (line,)

ani = FuncAnimation(fig, upd, frames=5, interval=100)   # 没有 init_func
ani.save('X_noinit.gif', writer='pillow')
from PIL import Image
print(Image.open('X_noinit.gif').n_frames)
# 输出: 5
plt.close(fig)

保存完全正常。

帧数对不上先别怀疑框架

上面那句 i / 4 不是随手写的。若照直觉写成 set_data([0, i], [0, i]), y 从第 2 帧起就跳出 ylim 的 -1.5 ~ 1.5,画面此后恒等不变, 于是 n_frames 打出 3 而不是 5 —— pillow 会把连续相同的帧合并成一个, 不是丢帧、更不是 frames 没生效。实测 frames=3/5/8 全部得到 3。 判断办法:把 frames 调大看帧数变不变,不变就是画面真的没变。

init_func 的真正作用是在交互播放时先清一次场:不给的话,动画开始前画布上是 plt.plot 留下的初始内容,第一帧会和它叠在一起。另外 blit=True 时框架靠 init_func 的返回值建立“背景图层”,不给就只能整帧重绘,性能打折。

interval 与 fps:两套口径,fps 赢

Python24 行
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from PIL import Image

def make(iv=None, fps=None):
    fig, ax = plt.subplots(figsize=(2, 1.4), dpi=70)
    ax.set_xlim(0, 10); ax.set_ylim(-1.5, 1.5)
    line, = ax.plot([], [], lw=1.5)

    def upd(i):
        line.set_data([0, i], [0, i])
        return (line,)

    kw = {} if iv is None else {'interval': iv}
    ani = FuncAnimation(fig, upd, frames=8, **kw)
    skw = {} if fps is None else {'fps': fps}
    ani.save('X_speed.gif', writer='pillow', **skw)
    im = Image.open('X_speed.gif')
    r = (im.n_frames, im.info.get('duration'))
    plt.close(fig)
    return r

print(make(50), make(200), make(50, fps=5), make(50, fps=20))
# 输出: (3, 50) (3, 200) (3, 200) (3, 50)

三条规律:

  1. interval=50 → 每帧 50 ms(20 fps);
  2. interval 只改播放速度,改成 200 之后帧数不变、文件字节数一字节不差(gif 的每帧时长写在元数据里,不占体积);
  3. save(fps=) 覆盖 interval:fps=5 得 200 ms(1000/5),fps=20 得 50 ms。

所以存文件时只认 fps,interval 是给 plt.show() 用的。

cache_frame_data 与生成器

frames 是生成器时,cache_frame_data=True 会把帧数据全存内存里。数据大的动画(几万点 × 几百帧)会吃满内存,这时关掉:

Python20 行
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

fig, ax = plt.subplots(figsize=(2, 1.4), dpi=70)
ax.set_xlim(0, 10); ax.set_ylim(-1.5, 1.5)
line, = ax.plot([], [], lw=1.5)

def gen():
    for i in range(6):
        yield i

def upd(i):
    line.set_data([0, i], [0, i])
    return (line,)

ani = FuncAnimation(fig, upd, frames=gen(), cache_frame_data=False)
ani.save('X_gen.gif', writer='pillow')
print(type(ani).__name__)
# 输出: FuncAnimation
plt.close(fig)
是个“惰性播放器”:构造函数不画任何东西,只登记 、、帧序列。所以:
  • 它必须被变量引用住。不赋值直接 FuncAnimation(...) 会在垃圾回收时打印警告:
Animation was deleted without rendering anything. This is most likely not intended.
To prevent deletion, assign the Animation to a variable, e.g. `anim`, that exists
until you output the Animation using `plt.show()` or `anim.save()`.
  • 关键方法只有三个:save(path, writer=, fps=, dpi=)、to_jshtml()、to_html5_video();
  • 它继承自 Animation,save 走的是“临时目录存每帧 png → writer 合并”的流程,所以中途强杀会留下临时文件;
  • plt.close(fig) 之后 ani 就废了,save 要在关闭之前调用。

注意事项

  • 无窗口环境(脚本 / CI / 服务器)必须先 matplotlib.use('Agg'),否则动画保存可能挂住等 GUI 事件循环。
  • 动画相关的 rcParams 只有 9 个,其中 animation.writer 默认 'ffmpeg':
Python15 行
import matplotlib.pyplot as plt

for k in sorted(plt.rcParams):
    if 'anim' in k.lower():
        print(k, '=', plt.rcParams[k])
# 输出: animation.bitrate = -1
# 输出: animation.codec = h264
# 输出: animation.convert_args = ['-layers', 'OptimizePlus']
# 输出: animation.convert_path = convert
# 输出: animation.embed_limit = 20.0
# 输出: animation.ffmpeg_args = []
# 输出: animation.ffmpeg_path = ffmpeg
# 输出: animation.frame_format = png
# 输出: animation.html = none
# 输出: animation.writer = ffmpeg
  • 存 .gif 时不用管 animation.writer='ffmpeg':save() 会按扩展名重新挑 writer,.gif 走 pillow。
  • 每帧都改 ax.set_title(...) 时,tight_layout() 要在设了初始标题之后调用,否则标题会被裁掉(因为边距是按“没有标题”算的)。
  • ani.save() 里传 dpi= 会覆盖 figure 的 dpi,但不会重新排版——字号在像素上的比例变了,看起来会偏小。

ArtistAnimation:先把每帧画好

FuncAnimation 是“给我一个更新函数”,ArtistAnimation 是“给我一组已画好的 artist 列表”:

Python19 行
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import ArtistAnimation

fig, ax = plt.subplots(figsize=(2.4, 1.6), dpi=70)
ax.set_xlim(0, 10); ax.set_ylim(-1.4, 1.4)
x = np.linspace(0, 10, 60)

arts = []
for i in range(8):
    ln, = ax.plot(x[:i * 6 + 4], np.sin(x[:i * 6 + 4] - i / 3), lw=1.4)
    arts.append([ln])                      # 每帧一个 list

ani = ArtistAnimation(fig, arts, interval=120, blit=True)
ani.save('X_art.gif', writer='pillow', fps=8)
from pathlib import Path
print(len(arts), Path('X_art.gif').stat().st_size)
# 输出: 8 12936
plt.close(fig)

注意循环里不关 figure:每帧的 Line2D 都还活着,arts 里存的是它们的引用。八帧都在同一张 ax 上,但因为每帧只在列表里列出“这一帧该显示的”artist,播出来就是逐条递增的效果。

何时用它

  • 数据已经算完、每帧就是不同的完整图(比如不同时间的两个快照对比)→ ArtistAnimation 更直观,不用写“更新逻辑”。
  • 帧与帧之间是连续演化(同一个对象改坐标)→ FuncAnimation 更省内存,不用留一堆 artist。
  • ArtistAnimation 的帧全在内存里,几百帧的大图容易炸;FuncAnimation 可以配 cache_frame_data=False 流式处理。

保存:writer、格式与体积

本机可用的 writer

Python6 行
import matplotlib.animation as man

print(man.writers.list())
# 输出: ['pillow', 'ffmpeg', 'ffmpeg_file', 'html']
print(man.writers.is_available('ffmpeg'), man.writers.is_available('pillow'))
# 输出: True True

writers.list() 只列可用的。本机还注册了 imagemagick / imagemagick_file,但因为没装 ImageMagick 的 convert,不会出现在列表里——所以查“能不能存 mp4”跑这一行就够,别猜。

gif(pillow):默认就够

Python18 行
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from PIL import Image

fig, ax = plt.subplots(figsize=(2, 1.4), dpi=70)
ax.set_xlim(0, 10); ax.set_ylim(-1.5, 1.5)
line, = ax.plot([], [])

def upd(i):
    line.set_data([0, i], [0, i])
    return (line,)

ani = FuncAnimation(fig, upd, frames=10, interval=80)
ani.save('X_plain.gif')                      # 不写 writer
im = Image.open('X_plain.gif')
print(im.n_frames, im.info.get('duration'), im.info.get('loop'))
# 输出: 10 80 0
plt.close(fig)

不写 writer= 也能成——save() 从扩展名反推出 pillow。loop=0 在 GIF 规范里表示无限循环。

repeat=False 存出来还是无限循环

这是个真坑:repeat 只控制 plt.show() 的播放行为,不写进文件。

Python17 行
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from PIL import Image

fig, ax = plt.subplots(figsize=(2, 1.4), dpi=70)
ax.set_xlim(0, 10); ax.set_ylim(-1.5, 1.5)
line, = ax.plot([], [])

def upd(i):
    line.set_data([0, i], [0, i])
    return (line,)

ani = FuncAnimation(fig, upd, frames=6, interval=100, repeat=False)
ani.save('X_norep.gif', writer='pillow')
print(Image.open('X_norep.gif').info.get('loop'))
# 输出: 0
plt.close(fig)

loop=0 就是无限循环。要“只播一次”,得存完再用 PIL 重写一遍索引信息:

Python11 行
from PIL import Image

im = Image.open('X_norep.gif')
frames = []
for i in range(im.n_frames):
    im.seek(i)
    frames.append(im.convert('RGB'))
frames[0].save('X_once.gif', save_all=True, append_images=frames[1:],
               duration=100, loop=1)         # loop=1:播完停在最后一帧
print(len(frames))
# 输出: 3

mp4(ffmpeg)

Python17 行
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from pathlib import Path

fig, ax = plt.subplots(figsize=(2, 1.4), dpi=70)
ax.set_xlim(0, 10); ax.set_ylim(-1.5, 1.5)
line, = ax.plot([], [])

def upd(i):
    line.set_data([0, i], [0, i])
    return (line,)

ani = FuncAnimation(fig, upd, frames=20, interval=80)
ani.save('X_small.mp4', writer='ffmpeg', fps=12)
print(Path('X_small.mp4').stat().st_size)
# 输出: 3017
plt.close(fig)

mp4 走 animation.codec='h264' + animation.bitrate=-1(自动码率)。同样的 20 帧,mp4 只要 gif 的四分之一——因为 gif 每帧是独立的 LZW 压缩位图,mp4 是帧间预测。

想更小就压码率,但 bitrate 是全局 rcParam,改了记得还原:

Python8 行
import matplotlib as mpl
import matplotlib.pyplot as plt

with mpl.rc_context({'animation.bitrate': 200}):    # 200 kbps,画面会明显糊
    print(mpl.rcParams['animation.bitrate'])
# 输出: 200
print(mpl.rcParams['animation.bitrate'])
# 输出: -1

html:三种嵌法,体积差很多

Python19 行
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

fig, ax = plt.subplots(figsize=(2, 1.4), dpi=60)
line, = ax.plot([], [])

def upd(i):
    line.set_data([0, i], [0, i])
    return (line,)

ani = FuncAnimation(fig, upd, frames=5, interval=100)

h5 = ani.to_html5_video()                    # 内嵌 base64 mp4
js = ani.to_jshtml()                         # 内嵌每帧 png + JS 播放器
print(h5[:30])
# 输出: <video width="120" height="84"
print(len(h5), len(js))
# 输出: 3531 21837
plt.close(fig)

to_jshtml() 比 to_html5_video() 大 5 倍多——它把每帧压成 png 内嵌,再加一段 JS 播放器。体积上限由 animation.embed_limit(默认 20 MB)管,超了不会抛异常,只打一条 log 然后静默丢帧:

Animation size has reached 1459 bytes, exceeding the limit of 104.8576.
If you're sure you want a larger animation embedded, set the animation.embed_limit
rc parameter to a larger value (in MB). This and further frames will be dropped.
Python9 行
import matplotlib as mpl
import matplotlib.pyplot as plt

print(mpl.rcParams['animation.embed_limit'])
# 输出: 20.0

with mpl.rc_context({'animation.embed_limit': 100}):
    print(mpl.rcParams['animation.embed_limit'])
# 输出: 100

这个提示走的是 logging 不是 warnings,warnings.catch_warnings() 抓不到,只能看终端输出。做完一堆动画后 HTML “变短了”,先查这条。

注意事项

  • 存 gif 的 fps 不必是整数,但低于 10 会显卡顿;网页里 gif 建议 12~15 fps。
  • gif 只有 256 色,cmap='viridis' 这类连续色带会出现明显的色阶断层——要求高就用 mp4。
  • ani.save() 的 dpi= 影响像素尺寸与体积,与静止图一样遵守“像素 = figsize × dpi”。
  • 保存路径的目录必须已存在,save() 不会自动建目录(和 savefig 一致)。
  • 每帧都 ax.set_title() 会拖慢保存:文字排版比画线贵,几百帧时很明显。

性能与内存

三条经验,按收益排序:

  1. 减帧数比减分辨率有效。36 帧降到 18 帧,体积直接腰斩,观感损失远小于把 dpi 砍半。
  2. blit=True 只救交互,不救保存。上面实测体积一字节不差;要保存快只能减帧、减点数、降 dpi。
  3. frames 用生成器 + cache_frame_data=False 能压住峰值内存,代价是保存时间略长(每帧重算)。
Python24 行
import time
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation

def cost(n_frames, n_points, dpi):
    fig, ax = plt.subplots(figsize=(2.4, 1.6), dpi=dpi)
    ax.set_xlim(0, 10); ax.set_ylim(-1.4, 1.4)
    line, = ax.plot([], [], lw=1)
    x = np.linspace(0, 10, n_points)

    def upd(i):
        line.set_data(x, np.sin(x - i / 4))
        return (line,)

    ani = FuncAnimation(fig, upd, frames=n_frames, interval=80)
    t0 = time.perf_counter()
    ani.save('X_cost.gif', writer='pillow')
    dt = (time.perf_counter() - t0) * 1000
    plt.close(fig)
    return round(dt)

print(cost(10, 100, 70), cost(20, 100, 70), cost(10, 100, 140))
# 输出: 约 210 470 270(每次都不一样,见下)

同一台机器连跑五次得 214 505 257 / 235 404 250 / 231 568 298,第一项在 210235 ms 之间摆、第二项 400570 ms——耗时只作量级参考,别写进断言。规律是清楚的:

  • 帧数翻倍:≈210 → ≈470 ms(约 2.2 倍)
  • dpi 翻倍:≈210 → ≈270 ms(约 1.3 倍,因为点数没变、只有画布变大)

所以减帧比降 dpi 划算:一半的帧直接省一半时间与一半体积,而 dpi 砍半只是模糊了画面,时间几乎不降。做几百帧的长动画前先拿 10 帧测一次,乘上去估总时间。

综合示例:一个双面板动画

Python45 行
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation
from PIL import Image

rng = np.random.default_rng(7)
N = 300
theta = rng.random(N) * 2 * np.pi
rad = np.sqrt(rng.random(N))
v = rng.normal(0.15, 0.05, N)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(6.2, 2.2))

# 左:粒子随 t 外扩,颜色编码半径
sc = ax1.scatter([], [], s=[], c=[])
ax1.set_xlim(-1.8, 1.8); ax1.set_ylim(-1.8, 1.8)
ax1.set_aspect('equal'); ax1.set_title('t = 0.00', fontsize=9)

# 右:半径的直方图同步演化
_, _, patches = ax2.hist([], bins=20, range=(0, 1.8), color='#3266a8')
ax2.set_xlim(0, 1.8); ax2.set_ylim(0, 60)
ax2.set_title('半径分布', fontsize=9); ax2.set_xlabel('r')

def update(i):
    t = i / 10
    r = rad * (1 + v * t)
    sc.set_offsets(np.c_[r * np.cos(theta + t / 2), r * np.sin(theta + t / 2)])
    sc.set_sizes(8 + 36 * (r / r.max()))
    sc.set_array(r)
    cnt, _ = np.histogram(r, bins=20, range=(0, 1.8))
    for h, c in zip(patches, cnt):
        h.set_height(c)
    ax1.set_title(f't = {t:.2f}', fontsize=9)
    return (sc, *patches, ax1.title)

sc.set_cmap('plasma')
fig.tight_layout()
ani = FuncAnimation(fig, update, frames=20, interval=1000 // 10)
ani.save('X_anim_both.gif', writer='pillow', fps=10)
im = Image.open('X_anim_both.gif')
print(im.n_frames, im.size, len(patches))
# 输出: 20 (620, 220) 20
plt.close(fig)

代码里的中文标题('半径分布')要先按「中文字体」一节设好 font.sans-serif,否则渲染成方框——本机默认的 DejaVu Sans 没有汉字。

两个要点:

  1. update 的返回值要把直方图的每一个 patch 都列出来(*patches),漏一个就有一根柱子不动——这是 blit=True 下最典型的 bug。上面的 len(patches) 是 20,正好等于 bins=20。
  2. 双面板动画的帧像素是两张图之和,体积比单图大得多。真要发布就拆成两个 gif,或者干脆用 mp4。

粒子外扩 + 半径分布同步演化

三条收尾:

  1. 先定交付格式再写动画代码。gif(网页)、mp4(视频)、html(Notebook)三者的参数重点完全不同。
  2. interval 给播放、fps 给文件。这两个是不同口径,save(fps=) 会盖掉 interval。
  3. 保存前先跑 man.writers.list(),确认本机有对应 writer——ffmpeg 不是 Python 包,是外部可执行文件,换机器就没了。