目录25

文章Matplotlib第 37 章

保存与导出

Matplotlib · 第 37 / 43 章

屏幕上是 plt.show(),进文档靠 savefig。一次调用要同时定对三件事:多大(figsize × dpi)、留多少边(bbox / pad)、存成什么(位图还是矢量、字体怎么嵌)。这三项的默认值都不适合直接交付:默认 PNG 只有 100 dpi、默认不裁边、SVG 默认把文字转成路径。

savefig:一次调用定成品

Python4 行
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}

最小示例

Python10 行
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:

Python17 行
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

Python14 行
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 存同一张图,各裁同一块(标题栏)放大看,差别就是“能不能印”:

同一块区域在 72 / 150 / 300 dpi 下(1 裁切对比)

dpi 只对位图有意义;存成 pdf/svg 时它只影响图里被栅格化的那部分(见「矢量里嵌位图」一节)。传 0 或负数会被拒:

Python10 行
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' 会把“有内容的区域”重新算一遍边界。注意它不是“一定变小”——宽度可能缩、高度可能涨:

同一张图四种裁法(同尺度对比)

Python21 行
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 全局打开:

Python7 行
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:背景

Python15 行
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 比改名可靠:

Python10 行
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:元信息

Python15 行
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 的质量:

Python16 行
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'。

综合示例:一张图出三种交付版本

Python18 行
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 种

Python7 行
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 不在其中——所以查格式支持别背书,跑一行:

Python7 行
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 默认把每个字转成路径:

Python20 行
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:

Python13 行
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 的属性,把它那块单独栅格化后再嵌进矢量文件。

实测:省的是渲染时间,不是体积

Python13 行
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 阅读器卡住,栅格化后打开就顺了。

该用与不该用

Python11 行
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 不能省

Python19 行
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:一个文件多页

Python15 行
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

Python11 行
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 投稿的固定套餐

把“投稿”这件事收敛成一份预设,避免每次出图都调一遍字号:

Python28 行
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        ← 出栈已还原

三条经验:

  1. 尺寸按栏宽给:单栏 3.5 in(≈88 mm)、双栏 7.0 in(≈178 mm)。用默认 6.4×4.8 再靠 \includegraphics[width=\linewidth] 缩,图里的 9pt 字会被缩成 5pt。
  2. 别用 bbox_inches='tight':LaTeX 会按 width= 再缩放一次,各图白边不同 → 视觉字号不一致。留白交给 LaTeX。
  3. pdf.fonttype=42:否则 Type 3 字体的图在投稿系统预检里常被挑出来。