Matplotlib 动画与交互
上一章末尾留了个尾巴:静态图是"定妆照",拍完就定格了。可现实里的数据故事经常是动态的——气温逐小时爬升、监控指标滚动刷新、参数一调结果跟着变。这一章给 Matplotlib 装上两样东西:时间轴(动画,图自己动)和操纵…
上一章末尾留了个尾巴:静态图是"定妆照",拍完就定格了。可现实里的数据故事经常是动态的——气温逐小时爬升、监控指标滚动刷新、参数一调结果跟着变。这一章给 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 里:滑块、按钮、复选框、单选按钮、文本框。别看种类多,用法只有一个套路,三步:
- 给控件腾块地方:
fig.add_axes((左边距, 底边距, 宽, 高))——四个数都是 0~1 的画布比例坐标,跟数据坐标无关; - 把控件放上去,绑上回调:每种控件有自己的事件名,
on_changed、on_clicked、on_submit; - 回调里改 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:统计图的"自动挡",你负责说清"画什么",长相它包了。