首页/Python 与数据实践/Matplotlib 动画与交互
Python 与数据实践 · 第 30 章

Matplotlib 动画与交互

上一章末尾留了个尾巴:静态图是"定妆照",拍完就定格了。可现实里的数据故事经常是动态的——气温逐小时爬升、监控指标滚动刷新、参数一调结果跟着变。这一章给 Matplotlib 装上两样东西:时间轴(动画,图自己动)和操纵…

约 27 分钟10,998 字11 个代码示例

上一章末尾留了个尾巴:静态图是"定妆照",拍完就定格了。可现实里的数据故事经常是动态的——气温逐小时爬升、监控指标滚动刷新、参数一调结果跟着变。这一章给 Matplotlib 装上两样东西:时间轴(动画,图自己动)和操纵杆(交互组件,看图的人上手调)。两样东西底层是同一套本事——第 29 章埋的那句话:"Artist 是对象,画上去之后随时能改"。动画是"按节奏自动改",交互是"看人操作再改"。

运行环境先说清楚:本章代码在普通脚本里直接跑(plt.show() 弹出的窗口天然可交互);在 Notebook 里跑,需要先启用交互后端 %matplotlib widget(装 ipympl,见第 25 章)。另外 Notebook 里反复运行前建议 plt.close("all") 清场,免得旧窗口越攒越多。

1. 动画的底层逻辑:不重画,只替换

很多人以为动画是"每帧重画一张图",其实完全不是。想想第 29 章的 line.set_ydata()——改一下折线 Artist 携带的数据,画面刷新,线就变了形。动画的全部秘密就这一句:先摆好 Artist,之后每隔几十毫秒改一次它们的数据,再让画布重绘。放映机换胶片,不是重新拍电影。

Matplotlib 把这套流程封装成了两个类:FuncAnimation(你写"每帧干什么")和 ArtistAnimation(你先把每帧都摆好)。先讲前者,因为它常用得多。

2. FuncAnimation:把"每帧干什么"写成函数

用 FuncAnimation 只需要交代三件事:在哪块画布上放、每帧执行什么函数、一共多少帧。看一个"全天气温曲线逐帧生长"的例子:

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

plt.rcParams.update({
    "font.sans-serif": ["PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"],
    "axes.unicode_minus": False,
    "figure.dpi": 100,
})

hours = np.linspace(0, 24, 145)                                   # 每 10 分钟一个点
temps = 16 + 8 * np.sin((hours - 8) / 24 * 2 * np.pi) \
        + np.random.normal(0, 0.3, 145)                           # 一天的气温曲线

fig, ax = plt.subplots(figsize=(8, 4))
(line,) = ax.plot([], [], lw=2, color="orangered")                # 先摆一个空 Artist
ax.set_xlim(0, 24)
ax.set_ylim(5, 30)
ax.set_title("全天气温:曲线随帧生长")
ax.set_xlabel("时刻(点)")
ax.set_ylabel("气温(℃)")

def update(frame: int):
    """第 frame 帧:曲线推进到第 frame 个采样点。"""
    line.set_data(hours[:frame], temps[:frame])
    return (line,)   # 返回本帧变化过的 Artist

ani = FuncAnimation(fig, update, frames=len(hours) + 1, interval=30, repeat=True)
plt.show()

跑起来是一条橘红色气温线从左往右慢慢"长"出来,画完整条后从头再来。三个关键细节:

  • 先摆空线再填数据:ax.plot([], []) 先把 Artist 挂到展格上,update 只管往里灌数据——这正是"不重画、只替换";
  • update 返回改过的 Artist:这是给优化器看的清单,后面讲 blit 时会用到;
  • ani 这个变量必须留住:FuncAnimation 返回的对象得有名字接着,否则可能被垃圾回收,动画莫名其妙就停了。

常用参数一张表:

参数 作用 备注
frames 帧总数,或任何可迭代对象 range(100)、生成器都行
interval 相邻两帧的间隔 单位毫秒,默认 200
repeat 播完是否从头再来 默认 True
blit 只重绘变化的 Artist 性能开关,见第 4 节
init_func 开播前的初始化函数 配合 blit 使用
cache_frame_data 是否缓存每帧数据 无限流要关掉,见下节

3. frames 也可以是个生成器:实时数据流

frames=100 是"预制好的 100 帧";但监控类场景数据是源源不断来的,帧数根本事先不知道。解决办法非常 Python:把一个无限生成器交给 frames,来一条数据算一帧:

from collections import deque

WINDOW = 120   # 画面只保留最近 120 个采样点

fig, ax = plt.subplots(figsize=(8, 4))
temps = deque(maxlen=WINDOW)                 # deque 装满自动挤掉最老的
(line,) = ax.plot([], [], lw=2)
ax.set_xlim(0, WINDOW)
ax.set_ylim(15, 35)
ax.set_title("机房温度:实时滚动窗口")

def sensor():
    """无限生成器:模拟一秒一条的传感器读数。"""
    tick, drift = 0, 0.0
    while True:
        drift = np.clip(drift + np.random.normal(0, 0.05), -1.5, 1.5)
        yield tick, 24 + drift + np.random.normal(0, 0.4)
        tick += 1

def update(frame):
    tick, value = frame
    temps.append(value)
    line.set_data(range(len(temps)), temps)
    if tick >= WINDOW:                        # 窗口满了,横轴跟着右移
        ax.set_xlim(tick - WINDOW + 1, tick + 1)

ani = FuncAnimation(fig, update, frames=sensor(),
                    interval=50, cache_frame_data=False)
plt.show()

两个新知识点:deque(maxlen=...) 是滚动窗口的天选容器,超出容量自动丢弃旧值;cache_frame_data=False 必须给——默认行为会把每一帧的数据都缓存起来,面对无限流就是无限吃内存。效果是一条温度线不断从右侧续新点,旧数据从左边滑出视野,活脱脱一块监控大屏。

4. blit:只重绘"动过"的部分

默认情况下每帧都整幅重绘,数据一多就掉帧。blit=True 是性能开关:画面里静止的部分只画一次,每帧只重绘 update 返回的那几个 Artist。配套要求是提供 init_func,把静态部分(坐标范围之类)先布置好:

minutes = np.linspace(0, 60, 300)
base = 22 + 2 * np.sin(minutes / 60 * 2 * np.pi)      # 一小时的室温曲线

fig, ax = plt.subplots(figsize=(8, 4))
(curve,) = ax.plot([], [], lw=2, color="teal")
(cursor,) = ax.plot([], [], "o", color="crimson", ms=9)  # 跟在曲线末端的游标点

def init():
    """开播前把静态部分布置好,返回参与 blit 的 Artist。"""
    ax.set_xlim(0, 60)
    ax.set_ylim(18, 27)
    return curve, cursor

def update(frame: int):
    y = base + 0.4 * np.sin(minutes * 0.7 + frame * 0.08)
    curve.set_data(minutes, y)
    cursor.set_data([minutes[-1]], [y[-1]])            # 游标始终咬住曲线右端
    return curve, cursor

ani = FuncAnimation(fig, update, frames=200, init_func=init,
                    blit=True, interval=40)
plt.show()

画面是一条上下起伏的室温曲线,末端缀一颗红点,整体波形缓缓漂移,红点始终追着线头跑。帧数多、数据密时,blit 前后的流畅度差距肉眼可见——这也是它叫"性能开关"的原因。

5. ArtistAnimation:先把每一帧都摆进画面

FuncAnimation 是"边播边算",ArtistAnimation 是"先全摆好再播":你把每一帧要显示的 Artist 列表提前备齐,交给它按顺序轮播。适合帧内容可以整体预制、且想精确控制每一帧的场景。比如用"滚动均值窗口逐级加大"演示平滑效果——每帧就是一条现成的线:

from matplotlib.animation import ArtistAnimation

rng = np.random.default_rng(7)
days = np.arange(1, 91)
orders = 500 + 35 * days / 10 + rng.normal(0, 60, 90)   # 90 天外卖订单量

fig, ax = plt.subplots(figsize=(8, 4))
frames = []
for width in range(1, 31):
    smoothed = np.convolve(orders, np.ones(width) / width, mode="same")
    (line,) = ax.plot(days, smoothed, color="royalblue", alpha=0.8)
    frames.append([line])                                 # 每帧一个 Artist 列表

ax.set_title("订单量平滑:均值窗口从 1 天拉宽到 30 天")
ani = ArtistAnimation(fig, frames, interval=120, repeat=True)
plt.show()

播放时曲线从毛刺满身一路变顺滑,窗口越宽线条越温和——用动画讲"平滑是什么",比十张静态图都直观。代价也说一句:所有帧的 Artist 同时存在内存里,帧数别铺太狠。

6. 把动画带出 Python:保存与嵌入

动画做完总得交付。三条路:

ani.save("orders.gif", writer="pillow", fps=20)   # GIF:装了 pillow 就能存,兼容性最好

ani.save("orders.mp4", writer="ffmpeg", fps=20)   # MP4:画质体积俱佳,需要系统装 ffmpeg

HTML(ani.to_jshtml())                             # HTML:转成网页版动画,直接嵌进 Notebook
# from IPython.display import HTML

发聊天群用 GIF,进视频剪辑用 MP4,写报告嵌 Notebook 用 to_jshtml()——最后这个不依赖任何外部程序,输出的网页自带播放控件(ani.to_html5_video() 也能嵌视频,但同样要 ffmpeg)。

至此动画篇收工。下面换个方向:图不再自己动,而是等人来操作。

7. 交互组件:一个套路用到黑

Matplotlib 自带一盒控件,全在 matplotlib.widgets 里:滑块、按钮、复选框、单选按钮、文本框。别看种类多,用法只有一个套路,三步:

  1. 给控件腾块地方:fig.add_axes((左边距, 底边距, 宽, 高))——四个数都是 0~1 的画布比例坐标,跟数据坐标无关;
  2. 把控件放上去,绑上回调:每种控件有自己的事件名,on_changed、on_clicked、on_submit;
  3. 回调里改 Artist,然后 fig.canvas.draw_idle():draw_idle 是"有空再重绘"的礼貌版本,比强行重绘顺滑。

套路就这么多。剩下的只是"哪种控件发哪种事件、回调收到什么参数"的差别,逐个过。

8. Slider:连续参数的旋钮

拖动滑块改一个连续数值,回调里拿到新值。场景:复利计算器——拖收益率,看 1 万元 30 年后滚成多少:

from matplotlib.widgets import Slider

years = np.arange(0, 31)
principal = 10_000

fig, ax = plt.subplots(figsize=(8, 4))
fig.subplots_adjust(bottom=0.25)          # 展格下方腾出放滑块的地方

(line,) = ax.plot(years, principal * 1.05 ** years, lw=2)
ax.set_title("复利曲线:拖动滑块调整年化收益率")
ax.set_xlabel("年数")
ax.set_ylabel("账户余额(元)")

slider_ax = fig.add_axes((0.2, 0.1, 0.6, 0.03))       # 滑块的落脚处
rate_slider = Slider(slider_ax, "年化收益率",
                     0.01, 0.20, valinit=0.05, valstep=0.005)

def refresh(rate: float) -> None:
    line.set_ydata(principal * (1 + rate) ** years)
    fig.canvas.draw_idle()

rate_slider.on_changed(refresh)
plt.show()

拖动时曲线实时弯上去又压下来——"复利有多猛"这件事,一根能拖的线比任何文字都好使。valmin/valmax/valinit/valstep 四个参数分别管下限、上限、初值和步长。

一个滑块管一个参数,多参数就摆多个滑块,同一个回调里各取所需:

fig, ax = plt.subplots(figsize=(8, 5))
fig.subplots_adjust(bottom=0.3)

years = np.arange(0, 31)
(line,) = ax.plot(years, 10_000 * 1.05 ** years, lw=2)
ax.set_title("复利曲线:本金与收益率双滑块")

deposit_ax = fig.add_axes((0.22, 0.15, 0.6, 0.03))
rate_ax = fig.add_axes((0.22, 0.08, 0.6, 0.03))
deposit_slider = Slider(deposit_ax, "本金(万)", 1, 50, valinit=10)
rate_slider = Slider(rate_ax, "年化收益率", 0.01, 0.20,
                     valinit=0.05, valstep=0.005)

def refresh(_):
    balance = deposit_slider.val * 10_000 * (1 + rate_slider.val) ** years
    line.set_ydata(balance)
    fig.canvas.draw_idle()

deposit_slider.on_changed(refresh)
rate_slider.on_changed(refresh)
plt.show()

两个滑块都绑同一个 refresh:不管谁动,都按两个滑块的当前值重算整条线。这就是多参数实验台的雏形——维度再加几个,套路原样复制。

9. Button:一次点击,一个动作

滑块管连续值,按钮管离散动作:换一批数据、重置视图、导出图片。场景:门店客流模拟器,点一下重新模拟一个 30 天:

from matplotlib.widgets import Button

rng = np.random.default_rng()

fig, ax = plt.subplots(figsize=(8, 4))
fig.subplots_adjust(bottom=0.2)

days = np.arange(1, 31)
(line,) = ax.plot(days, 200 + rng.normal(0, 5, 30).cumsum(),
                  lw=2, marker="o", ms=3)
ax.set_title("门店客流:点按钮再模拟一次")

button_ax = fig.add_axes((0.38, 0.05, 0.24, 0.075))
resim = Button(button_ax, "再模拟一次", color="lightyellow", hovercolor="gold")

def rerun(event):
    line.set_ydata(200 + rng.normal(0, 5, 30).cumsum())
    ax.relim()            # 按新数据重新划定数据边界
    ax.autoscale_view()   # 坐标轴随之自适应
    fig.canvas.draw_idle()

resim.on_clicked(rerun)
plt.show()

color 与 hovercolor 分别是常态和悬停时的底色,鼠标扫过去会变色,提示"这里能点"。注意 ax.relim() 加 ax.autoscale_view() 这对组合——新数据换了量级,坐标轴得重新量体裁衣,否则曲线可能"画出界"。

10. CheckButtons:多个独立开关

复选框 = 一组各自独立的开关,最典型的用法是控制多条曲线的显隐。场景:三家门店的日销量对比,勾谁谁出现:

from matplotlib.widgets import CheckButtons

days = np.arange(1, 15)
shop_data = {
    "西湖店": 120 + 6 * days + np.random.default_rng(1).normal(0, 8, 14),
    "滨江店": 95 + 4 * days + np.random.default_rng(2).normal(0, 8, 14),
    "城西店": 150 + 2 * days + np.random.default_rng(3).normal(0, 8, 14),
}

fig, ax = plt.subplots(figsize=(8, 5))
fig.subplots_adjust(left=0.28)          # 左边腾地方放复选框

lines = {}
for name, values in shop_data.items():
    (lines[name],) = ax.plot(days, values, lw=2, label=name)
ax.legend(loc="upper left")
ax.set_title("门店日销量:勾选控制显隐")

check_ax = fig.add_axes((0.05, 0.4, 0.18, 0.2))
check = CheckButtons(check_ax, list(shop_data), [True, True, True])

def toggle(name: str):
    lines[name].set_visible(not lines[name].get_visible())
    fig.canvas.draw_idle()

check.on_clicked(toggle)
plt.show()

回调收到的是被点击那一项的名字,拿它找到对应 Artist,把可见性取反——set_visible 是 Artist 的通用方法,任何元素都能这么开关。多系列图配上这排复选框,看图的人自己决定对比谁。

11. RadioButtons:一组互斥的选项

单选按钮 = 只能选一个的开关,适合切换视图、口径、数据源。场景:同一份 90 天营收数据,三种统计口径随时切:

from matplotlib.widgets import RadioButtons

rng = np.random.default_rng(7)
days = np.arange(1, 91)
revenue = 5000 + 35 * days + 800 * np.sin(days / 90 * 4 * np.pi) \
          + rng.normal(0, 450, 90)

def moving_average(values: np.ndarray, width: int) -> np.ndarray:
    return np.convolve(values, np.ones(width) / width, mode="same")

views = {
    "原始日营收": revenue,
    "7 日均值": moving_average(revenue, 7),
    "30 日均值": moving_average(revenue, 30),
}

fig, ax = plt.subplots(figsize=(8, 5))
fig.subplots_adjust(left=0.28)

(line,) = ax.plot(days, revenue, lw=2, color="royalblue")
ax.set_title("营收口径切换:原始 / 7 日 / 30 日")

radio_ax = fig.add_axes((0.05, 0.4, 0.18, 0.2))
radio = RadioButtons(radio_ax, tuple(views))

def switch(name: str):
    line.set_ydata(views[name])
    fig.canvas.draw_idle()

radio.on_clicked(switch)
plt.show()

勾"30 日均值",毛刺瞬间被熨平——原始数据的噪声、7 日的轻抚、30 日的大刀,一个按钮组讲清楚三种口径的取舍。

12. TextBox:要精确值的时候

滑块拖起来爽,但"我要的是 8.5% 不是拖到的 8.47%"这种时刻,就得键盘出场。TextBox 收文本输入,回车提交。场景:月储蓄计划计算器——输入每月存多少,看 20 年能攒出什么:

from matplotlib.widgets import TextBox

months = np.arange(0, 241)          # 20 年 = 240 个月
monthly_rate = 0.03 / 12            # 年化 3% 折成月利率

def balance(monthly: float) -> np.ndarray:
    """每月固定存一笔,按月复利滚存的总金额。"""
    return monthly * ((1 + monthly_rate) ** months - 1) / monthly_rate

fig, ax = plt.subplots(figsize=(8, 4))
fig.subplots_adjust(bottom=0.2)

(line,) = ax.plot(months / 12, balance(2000), lw=2)
ax.set_title("储蓄计划:输入月存款额后回车")
ax.set_xlabel("年数")
ax.set_ylabel("总额(元)")

box_ax = fig.add_axes((0.2, 0.06, 0.6, 0.06))
saving_box = TextBox(box_ax, "月存款", initial="2000")

def submit(text: str):
    try:
        amount = float(text)
    except ValueError:
        return                       # 输入不是数字就悄悄忽略
    line.set_ydata(balance(amount))
    fig.canvas.draw_idle()

saving_box.on_submit(submit)
plt.show()

on_submit 在回车时触发,拿到的是字符串,float() 转换失败就安静返回——用户敲错了不该被一屏报错糊脸。滑块扫范围、文本框点精值,两个组件正好互补。

13. 选型速查:五个控件各管一摊

需求 控件 事件
调连续参数(扫范围) Slider on_changed(val)
触发一次动作(重算/重置) Button on_clicked(event)
多个独立开关(曲线显隐) CheckButtons on_clicked(label)
互斥选项(切换口径) RadioButtons on_clicked(label)
精确输入数值 TextBox on_submit(text)

三步套路(腾地方 → 绑回调 → draw_idle)不变,变的只是控件和事件。再往深做,浏览器里的 ipywidgets、桌面端的 PyQt 都是同一思路的放大版——Matplotlib widgets 的价值在于零额外依赖,装了 Matplotlib 就有。

试一试

一、帧数预算

FuncAnimation(fig, update, frames=200, interval=40) 播完一轮要多久?想让播放速度翻倍,interval 该改成多少(假设每帧重绘时间忽略)?

二、白名单

blit 模式下,update 忘了 return 会发生什么?为什么"返回改过的 Artist"这步不能省?

三、加一个口径

第 11 节的营收切换器只有三个口径。请加上第四个"90 日均值"(提示:views 字典加一项,RadioButtons 的选项列表会自动跟着长吗——动手验证)。

本章全部示例的可运行版本在同目录:动画看 demo_grow.py、demo_stream.py、demo_blit.py;交互看 demo_slider_single.py、demo_slider_multi.py、demo_button.py、demo_checkbox.py、demo_radiobutton.py、demo_textbox.py。


到这儿,Matplotlib 的十八般武艺基本配齐:静态图会画,动态图会播,还接得上人的操作。但你也发现了——每根柱子、每条线的样式都要自己一件件交代,图一多手就酸。最后一章请出 Seaborn:统计图的"自动挡",你负责说清"画什么",长相它包了。


下一章:31 · Seaborn 统计可视化