文章/Matplotlib/第 37 章
保存与导出
Matplotlib · 第 37 / 43 章
屏幕上是 plt.show(),进文档靠 savefig。一次调用要同时定对三件事:多大(figsize × dpi)、留多少边(bbox / pad)、存成什么(位图还是矢量、字体怎么嵌)。这三项的默认值都不适合直接交付:默认 PNG 只有 100 dpi、默认不裁边、SVG 默认把文字转成路径。
savefig:一次调用定成品
Figure.savefig(fname, *, transparent=None, dpi='figure', format=None,
metadata=None, bbox_inches=None, pad_inches=0.1,
facecolor='auto', edgecolor='auto', backend=None, **kwargs)
pyplot.savefig(*args, **kwargs) # = plt.gcf().savefig(...),存"当前图"| 参数 | 一句话 | 默认值的影响 |
|---|---|---|
fname |
路径 / 文件对象 | 扩展名决定格式;无扩展名时补 savefig.format |
dpi |
分辨率 | 'figure' = 跟随 figure.dpi(100),不是 300 |
format |
强制格式 | None 时看扩展名;给了就原样用 fname、不改后缀 |
bbox_inches |
保存哪块区域 | None = 整块画布,白边全留 |
pad_inches |
裁边后的留白 | 0.1 英寸 ≈ 10 px @100 dpi |
transparent |
背景透明 | False |
facecolor / edgecolor |
背景 / 边框色 | 'auto' = 跟随 figure 设置 |
metadata |
元信息 | None;只对 png/pdf/svg/eps 有效 |
backend |
临时换渲染后端 | 一般不用 |
**kwargs |
透传给具体后端 | 例如 pil_kwargs={'quality': 90} |
最小示例
import matplotlib.pyplot as plt
from matplotlib.image import imread
fig, ax = plt.subplots(figsize=(3, 2))
ax.plot([0, 1, 2, 3], [0, 3, 1, 4], 'o-')
fig.savefig('X_save_demo.png')
print(imread('X_save_demo.png').shape)
# 输出: (200, 300, 4)
plt.close(fig)figsize=(3, 2) 英寸 × 默认 savefig.dpi='figure' → figure.dpi=100 → 300×200 像素。像素数永远是 figsize × dpi,这一条换算背下来,剩下的都是细节。
fname:扩展名决定格式
两种完全不同的行为,取决于给不给 format:
import matplotlib as mpl
import matplotlib.pyplot as plt
from pathlib import Path
fig, ax = plt.subplots(figsize=(2, 1))
ax.plot([0, 1])
fig.savefig('X_noext') # 无扩展名 → 用 savefig.format 并补后缀
print(sorted(p.name for p in Path('.').glob('X_noext*')))
# 输出: ['X_noext.png']
print(mpl.rcParams['savefig.format'])
# 输出: png
fig.savefig('X_fake.png', format='pdf') # 给了 format → fname 原样用
print(Path('X_fake.png').read_bytes()[:5])
# 输出: b'%PDF-'
plt.close(fig)第二种是“谎报后缀”:文件名还叫 .png,内容是 PDF。文档里写的是 “the file is saved as fname. Note that fname is used verbatim”——它不会帮你改扩展名,也不加后缀。批量脚本里靠 format 而不是靠改名,能省掉一堆 replace('.png', '.pdf')。
dpi:像素 = figsize × dpi
import matplotlib.pyplot as plt
from matplotlib.image import imread
fig, ax = plt.subplots(figsize=(6.4, 4.8))
ax.plot([0, 1])
fig.savefig('X_d100.png') # 'figure' → figure.dpi = 100
fig.savefig('X_d300.png', dpi=300)
for n in ['X_d100.png', 'X_d300.png']:
s = imread(n).shape
print(n, f'{s[1]}x{s[0]}')
# 输出: X_d100.png 640x480
# 输出: X_d300.png 1920x1440
plt.close(fig)默认 figure.figsize 是 [6.4, 4.8],所以“默认图”在 100 dpi 下正好 640×480,在 300 dpi 下 1920×1440。
三档 dpi 存同一张图,各裁同一块(标题栏)放大看,差别就是“能不能印”:

dpi 只对位图有意义;存成 pdf/svg 时它只影响图里被栅格化的那部分(见「矢量里嵌位图」一节)。传 0 或负数会被拒:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(2, 1))
ax.plot([0, 1])
try:
fig.savefig('X_d0.png', dpi=0)
except ValueError as e:
print('ValueError', e)
# 输出: ValueError dpi must be positive
plt.close(fig)bbox_inches 与 pad_inches:裁边与留白
bbox_inches='tight' 会把“有内容的区域”重新算一遍边界。注意它不是“一定变小”——宽度可能缩、高度可能涨:

import matplotlib.pyplot as plt
from matplotlib.image import imread
def demo(**kw):
fig, ax = plt.subplots(figsize=(3, 2))
ax.plot([0, 1, 2, 3], [0, 3, 1, 4], 'o-')
ax.set_title('标题')
fig.text(0.5, 0.03, 'fig.text 底部文字', ha='center')
fig.savefig('X_box.png', **kw)
plt.close(fig)
s = imread('X_box.png').shape
return f'{s[1]}x{s[0]}'
print(demo())
# 输出: 300x200
print(demo(bbox_inches='tight'))
# 输出: 271x220
print(demo(bbox_inches='tight', pad_inches=0))
# 输出: 251x200
print(demo(bbox_inches='tight', pad_inches=0.3))
# 输出: 311x260读这四行:tight 把左右的白边(约 15 px 一边)切掉了,宽度 300→271;但标题和贴底的 fig.text 顶出了原画布,高度 200→220。pad_inches 是在这个新边界外再加的留白,0.1 英寸 × 100 dpi × 两侧 = 20 px,所以 pad=0 比默认窄 20 px,pad=0.3 比默认宽 40 px。
图上的 px 数是这台机器用 Microsoft YaHei 量出来的(270×222 / 250×202 / 310×262);正文这四行是默认 DejaVu Sans 的结果。差 1~2 px 是正常的——tight 要拿字体的实际字宽字高算边界,换字体就换数字。所以别把 tight 的输出像素写进断言,要断言就断言“比默认窄”这类关系。
性能代价:同样一张 6×4 英寸的图存 10 次,默认 183 ms,tight 296 ms——约 1.6 倍。批量出图几十张时能感觉到,改用 subplots_adjust / layout='constrained' 固定留白更划算。
bbox_inches 也可以用 rcParams 全局打开:
import matplotlib as mpl
import matplotlib.pyplot as plt
mpl.rcParams['savefig.bbox'] = 'tight'
print(mpl.rcParams['savefig.bbox'], mpl.rcParams['savefig.pad_inches'])
# 输出: tight 0.1
mpl.rcdefaults()transparent / facecolor / edgecolor:背景
import matplotlib.pyplot as plt
from matplotlib.image import imread
fig, ax = plt.subplots(figsize=(2, 1))
ax.plot([0, 1])
fig.savefig('X_t_on.png', transparent=True)
fig.savefig('X_t_off.png')
fig.savefig('X_fc.png', facecolor='#222222')
for n in ['X_t_on.png', 'X_t_off.png', 'X_fc.png']:
print(n, imread(n)[0, 0]) # 左上角像素,第 4 位是 alpha
# 输出: X_t_on.png [1. 1. 1. 0.]
# 输出: X_t_off.png [1. 1. 1. 1.]
# 输出: X_fc.png [0.13333334 0.13333334 0.13333334 1.]
plt.close(fig)alpha=0 才是真透明。facecolor='none' 与 transparent=True 效果一样;facecolor='auto'(默认)表示“跟随 figure.facecolor”。注意 transparent=True 同时会把坐标轴背景也设透明——只想要画布透明、坐标轴仍是白底时,用 facecolor='none' + ax.set_facecolor('white')。
带透明底的图别存成 jpg(jpg 没有 alpha 通道)。
format:忽略扩展名的强制格式
同一个 figure 要出两三种格式时,format 比改名可靠:
import matplotlib.pyplot as plt
from pathlib import Path
fig, ax = plt.subplots(figsize=(2, 1))
ax.plot([0, 1])
for fmt in ['png', 'pdf', 'svg']:
fig.savefig(f'X_fmt.{fmt}', format=fmt)
print(sorted(p.suffix for p in Path('.').glob('X_fmt.*')))
# 输出: ['.pdf', '.png', '.svg']
plt.close(fig)metadata:元信息
import matplotlib.pyplot as plt
from pathlib import Path
fig, ax = plt.subplots(figsize=(2, 1))
ax.plot([0, 1])
fig.savefig('X_meta.pdf', metadata={'Title': '销售报告', 'Author': '分析组'})
print(b'/Title' in Path('X_meta.pdf').read_bytes())
# 输出: True
fig.savefig('X_meta.png', metadata={'Title': 'T1', 'Foo': 'bar'})
raw = Path('X_meta.png').read_bytes()
print(b'tEXt' in raw, b'Foo' in raw)
# 输出: True True
plt.close(fig)- PDF:键名有限(
Title/Author/Subject/Keywords/Creator/Producer),中文按 UTF-16 存,所以read_bytes()里搜不到 UTF-8 的销售报告,但 Acrobat 里能看到。 - PNG:写成
tEXt块,不认识的键也照写(Foo也在文件里),不会报错。 - SVG:写进
<dc:/<cc:命名空间。 - 不支持:
jpg/tiff/webp(文档明确说暂不支持),pgf/raw/rgba也不支持;eps/ps只支持Creator。
**kwargs:真正的参数不在签名里
签名里只有一个 **kwargs,因为不同格式的参数不一样,全部透传给后端。典型是 jpg 的质量:
import matplotlib.pyplot as plt
from pathlib import Path
fig, ax = plt.subplots(figsize=(4, 3))
ax.plot([0, 1], [1, 0])
fig.savefig('X_q95.jpg', pil_kwargs={'quality': 95})
fig.savefig('X_q30.jpg', pil_kwargs={'quality': 30})
print(Path('X_q95.jpg').stat().st_size, Path('X_q30.jpg').stat().st_size)
# 输出: 15807 6608
try:
fig.savefig('X_q.jpg', quality=95)
except TypeError as e:
print('TypeError', str(e)[-52:])
# 输出: TypeError print_jpg() got an unexpected keyword argument 'quality'
plt.close(fig)quality 必须包在 pil_kwargs 里。直接写 quality=95 会一路透传到 print_jpg() 然后 TypeError——报错里出现的是后端方法名,很容易看着莫名其妙。
返回 。它不是“生成一个图片对象”,而是“把当前 figure 画到 fname 上”;产物就是磁盘文件(或你传进去的 file-like 对象)。所以:
- 不能链式调用,也没有“存好的图”这个东西可以再操作;
- 想改图再存,改完
fig再savefig一次即可,多次调用互不影响; - 要拿到像素,得自己
imread/PIL.Image.open读回来。
注意事项
- 像素 =
figsize×dpi。savefig.dpi默认'figure',即跟随figure.dpi(100),不是 300。 bbox_inches='tight'不一定变小:本次实测宽 300→271,高 200→220。tight依赖字体度量,换字体差 1~2 px;别把它的输出像素写成断言。tight慢约 1.6 倍(10 次 183 → 296 ms)。- 目录不存在 →
FileNotFoundError,savefig不会自动建目录。 - 中文 / 空格文件名可用(
销售 图.png实测正常)。 plt.savefig()存的是当前图;循环里有多张 figure 时一律用fig.savefig(),否则会存错。transparent=True连坐标轴背景一起透明;只要画布透明用facecolor='none'。
综合示例:一张图出三种交付版本
import matplotlib.pyplot as plt
import numpy as np
from pathlib import Path
x = np.linspace(0, 2 * np.pi, 200)
fig, axs = plt.subplots(1, 3, figsize=(9, 2.6))
for i, ax in enumerate(axs):
ax.plot(x, np.sin(x + i), label=f'相位 {i}')
ax.set_title(f'子图 {i + 1}')
ax.legend(fontsize=7)
fig.tight_layout()
fig.savefig('X_deliver.png', dpi=150) # 屏幕 / Word 文档
fig.savefig('X_deliver.pdf') # 投稿(矢量)
fig.savefig('X_deliver.svg') # 网页(矢量)
print(sorted(p.name for p in Path('.').glob('X_deliver.*')))
# 输出: ['X_deliver.pdf', 'X_deliver.png', 'X_deliver.svg']
plt.close(fig)三种产物来自同一个 fig,tight_layout() 只做一次——先把版面定好,再分别导出,比每次导出前重画一遍可靠。
支持的格式:本机 14 种
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ft = sorted(fig.canvas.get_supported_filetypes())
print(len(ft), ft)
# 输出: 14 ['eps', 'jpeg', 'jpg', 'pdf', 'pgf', 'png', 'ps', 'raw', 'rgba', 'svg', 'svgz', 'tif', 'tiff', 'webp']
plt.close(fig)| 格式 | 类型 | 什么时候用 | 注意 |
|---|---|---|---|
png |
位图 | 屏幕、文档、网页配图 | 尺寸由 dpi 定;支持 alpha |
pdf |
矢量 | 论文投稿、LaTeX 插图 | 文字可嵌入;pdf.fonttype 决定可编辑性 |
svg |
矢量 | 网页、需要后期编辑 | 默认把文字转路径 |
jpg / jpeg |
位图 | 照片类 | 无 alpha;质量靠 pil_kwargs |
webp |
位图 | 网页,体积小 | 兼容性不如 png |
tif / tiff |
位图 | 印刷、期刊指定 | 文件大 |
eps / ps |
矢量 | 老期刊 / LaTeX 老流程 | metadata 只支持 Creator |
svgz |
压缩矢量 | 网页 | 需要服务器给对 Content-Encoding |
pgf |
矢量 | LaTeX pgfplots |
不支持 metadata |
raw / rgba |
裸像素 | 交给别的程序继续处理 | 无文件头,只给 numpy 用 |
清单是“后端能力”,不是“matplotlib 能力”
get_supported_filetypes() 报的是当前后端能写什么。这台机器上 Agg 后端给的是 14 种,旧资料里常见的 avif、gif 不在其中——所以查格式支持别背书,跑一行:
import matplotlib
import matplotlib.pyplot as plt
print(matplotlib.get_backend())
# 输出: Agg ← 无头 / 脚本环境
print(len(plt.gcf().canvas.get_supported_filetypes()))
# 输出: 14换到 pdf 后端或 GUI 后端(TkAgg / QtAgg)时这份清单会变。
字体:svg.fonttype 与 pdf.fonttype
矢量格式要决定“文字怎么进文件”。SVG 默认把每个字转成路径:
import matplotlib as mpl
import matplotlib.pyplot as plt
from pathlib import Path
print(mpl.rcParams['svg.fonttype'], mpl.rcParams['pdf.fonttype'])
# 输出: path 3
fig, ax = plt.subplots(figsize=(2, 1))
ax.plot([0, 1])
ax.set_title('Sales 2026')
mpl.rcParams['svg.fonttype'] = 'path'
fig.savefig('X_f_path.svg')
mpl.rcParams['svg.fonttype'] = 'none'
fig.savefig('X_f_none.svg')
print('<text' in Path('X_f_path.svg').read_text(encoding='utf-8'),
'<text' in Path('X_f_none.svg').read_text(encoding='utf-8'))
# 输出: False True
plt.close(fig)'path'(默认):字形变成<path>,任何机器打开都一模一样;代价是文字不可搜索、不可编辑,Illustrator 里也改不了字。'none':直接写<text font-family="...">,文件小、可搜索可编辑;代价是阅读端必须有该字体,否则回落成别的字形。
PDF 侧对应的是 pdf.fonttype:
import matplotlib as mpl
import matplotlib.pyplot as plt
from pathlib import Path
fig, ax = plt.subplots(figsize=(2, 1))
ax.plot([0, 1])
for ft in (3, 42):
mpl.rcParams['pdf.fonttype'] = ft
fig.savefig(f'X_ft{ft}.pdf')
print(ft, b'/Type3' in Path(f'X_ft{ft}.pdf').read_bytes())
# 输出: 3 True
# 输出: 42 False
plt.close(fig)默认 3(Type 3):每个字形是一小段绘图程序,印刷厂的预检工具常常报警,也搜不出文字。投稿要求“文字可复制/可编辑”时设 42(TrueType 嵌入),文件略大一点。
注意事项
png/jpg/tif/webp是位图:放大必糊,dpi决定一切。pdf/svg/eps/pgf是矢量:缩放无损,但图里可能有位图元素(imshow的照片、rasterized的图层)。- jpg 没有 alpha,带透明底的图存 jpg 会得到黑底或白底。
- 投稿类 PDF:
pdf.fonttype=42;要跨机器保证字形一致:svg.fonttype='path'。 - 别把格式清单硬编码进脚本,
get_supported_filetypes()一行就够。
矢量里嵌位图:rasterized=True
一张 PDF 里可以既有矢量线条、又有位图块。rasterized=True 是单个 artist 的属性,把它那块单独栅格化后再嵌进矢量文件。
实测:省的是渲染时间,不是体积
import matplotlib.pyplot as plt
import numpy as np
from pathlib import Path
x = np.linspace(0, 10, 4000)
for name, flag in [('X_ras.pdf', True), ('X_vec.pdf', False)]:
fig, ax = plt.subplots(figsize=(2, 1))
ax.plot(x, np.sin(x * 6), rasterized=flag)
fig.savefig(name)
plt.close(fig)
print(Path('X_ras.pdf').stat().st_size, Path('X_vec.pdf').stat().st_size)
# 输出: 8196 6375反直觉:这组数据 rasterized=True 反而更大。4000 个点的折线在 PDF 里只是一串压缩得很好的线段(6.4 KB),栅格化却要塞进一张整幅位图(8.2 KB)。所以别把 rasterized 当压缩手段——它换的是阅读端渲染速度:几万个散点、带透明的密集图元,矢量描述会让 PDF 阅读器卡住,栅格化后打开就顺了。
该用与不该用
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize=(3, 2))
rng = np.random.default_rng(0)
ax.scatter(rng.normal(size=30000), rng.normal(size=30000),
s=1, alpha=.15, rasterized=True) # 3 万个点半透明 → 该栅格化
ax.plot([-3, 3], [-3, 3], color='#e8590c') # 线条仍然矢量
ax.set_title('3 万点半透明散点')
fig.savefig('X_ras_scatter.pdf')
plt.close(fig)- 该用:几万点的散点、半透明堆叠、复杂等高线 / 热力图、
imshow的大数组。 - 不该用:折线、柱、文字标注、图例——矢量的优势全在这些地方,栅格化只会让它们变糊。
注意事项
rasterized是 artist 的属性(plot(scatter=...)都收),不是savefig的参数。- 只对矢量输出有意义;存 png 时它什么也不做。
- 整图都想栅格化的话,直接存 png 就行,不必绕
rasterized。 - 栅格化的分辨率由
savefig的dpi决定,300 dpi 以上才够印。
批量导出与多页 PDF
循环出图:close 不能省
import matplotlib.pyplot as plt
import numpy as np
from pathlib import Path
out = Path('X_figs')
out.mkdir(exist_ok=True)
rng = np.random.default_rng(0)
for i, n in enumerate([30, 100, 500]):
fig, ax = plt.subplots(figsize=(4, 3), layout='constrained')
ax.plot(np.arange(n), rng.normal(size=n).cumsum())
ax.set_title(f'窗口 {n}')
fig.savefig(out / f'panel_{i}.png', dpi=150)
plt.close(fig) # 不 close,figure 会一直挂在 pyplot 里
print(sorted(p.name for p in out.glob('*.png')))
# 输出: ['panel_0.png', 'panel_1.png', 'panel_2.png']
print(plt.get_fignums())
# 输出: []plt.get_fignums() 是判断“有没有漏 close”的最快方式:循环结束后它应该是空列表。几百张图不关,内存和 GDI 句柄会一路涨上去。
PdfPages:一个文件多页
import matplotlib.pyplot as plt
from matplotlib.backends.backend_pdf import PdfPages
from pathlib import Path
with PdfPages('X_multi.pdf') as pdf:
for i in range(3):
fig, ax = plt.subplots(figsize=(3, 2))
ax.plot([0, 1], [0, i])
ax.set_title(f'第 {i + 1} 页')
pdf.savefig(fig)
plt.close(fig)
raw = Path('X_multi.pdf').read_bytes()
print(raw.count(b'/Type /Page'), b'/Count 3' in raw)
# 输出: 4 True/Type /Page 出现 4 次是 3 个页面对象 + 1 个 /Type /Pages 树节点(前缀相同),/Count 3 才是页数。PdfPages 必须用 with——退出时才写文件尾的交叉引用表,不写就是个打不开的残文件。
存到内存:BytesIO
import io
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(2, 1))
ax.plot([0, 1])
buf = io.BytesIO()
fig.savefig(buf, format='png')
data = buf.getvalue()
print(len(data), data[:4])
# 输出: 2988 b'\x89PNG'
plt.close(fig)给了 file-like 对象就必须给 format——没有扩展名可猜。另外读回来之前要 buf.seek(0):savefig 结束时指针停在末尾,直接 imread(buf) 会读到空内容、报 SyntaxError: not a PNG file(我们画配图时踩过这个)。
注意事项
- 循环里
plt.close(fig),结束用plt.get_fignums()复查。 savefig不建目录,先Path.mkdir(parents=True, exist_ok=True)。- 文件名带变量时用
Path/ f-string,别手拼'out/' + name + '.png'(Windows 上混用/和\会出怪路径)。 PdfPages用with;中途return也安全。
给 LaTeX 投稿的固定套餐
把“投稿”这件事收敛成一份预设,避免每次出图都调一遍字号:
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
PAPER = {
'font.size': 9,
'axes.labelsize': 9, 'axes.titlesize': 9,
'legend.fontsize': 8, 'xtick.labelsize': 8, 'ytick.labelsize': 8,
'lines.linewidth': 1.0,
'savefig.dpi': 300,
'pdf.fonttype': 42, # 文字可编辑,投稿常要求
'ps.fonttype': 42,
'axes.spines.top': False, 'axes.spines.right': False,
}
with mpl.rc_context(PAPER):
fig, ax = plt.subplots(figsize=(3.5, 2.4)) # 单栏 3.5 英寸 ≈ 88 mm
x = np.linspace(0, 2 * np.pi, 100)
ax.plot(x, np.sin(x), label='sin')
ax.set_xlabel('角度 (rad)')
ax.set_ylabel('幅值')
ax.legend()
fig.tight_layout()
fig.savefig('X_paper.pdf')
plt.close(fig)
print(mpl.rcParams['pdf.fonttype'])
# 输出: 3 ← 出栈已还原三条经验:
- 尺寸按栏宽给:单栏 3.5 in(≈88 mm)、双栏 7.0 in(≈178 mm)。用默认 6.4×4.8 再靠
\includegraphics[width=\linewidth]缩,图里的 9pt 字会被缩成 5pt。 - 别用
bbox_inches='tight':LaTeX 会按width=再缩放一次,各图白边不同 → 视觉字号不一致。留白交给 LaTeX。 pdf.fonttype=42:否则 Type 3 字体的图在投稿系统预检里常被挑出来。