[資料分析與視覺化] 基於Python繪製簡單動圖

2023-10-24 15:01:36

動畫是一種高效的視覺化工具,能夠提升使用者的吸引力和視覺體驗,有助於以富有意義的方式呈現資料視覺化。本文的主要介紹在Python中兩種簡單製作動圖的方法。其中一種方法是使用matplotlib的Animations模組繪製動圖,另一種方法是基於Pillow生成GIF動圖。

1 Animations模組

Matplotlib的Animations模組提供了FuncAnimation和ArtistAnimation類來建立matplotlib繪圖動畫,FuncAnimation和ArtistAnimation都是Animation類的子類。它們的區別在於實現動畫的方式和使用場景不同。FuncAnimation適用於根據時間更新圖形狀態的動畫效果,且更加靈活和常用。而ArtistAnimation適用於將已有的靜態影象序列組合成動畫的效果。具體區別如下:

  • FuncAnimation:FuncAnimation是基於函數的方法來建立動畫的。它使用使用者提供的一個或多個函數來更新圖形的狀態,並按照一定的時間間隔連續地呼叫這些函數,從而實現動畫效果。使用者需要定義一個更新函數,該函數在每個時間步長上更新圖形物件的屬性,然後FuncAnimation會根據使用者指定的幀數、時間間隔等引數來自動計算動畫的幀序列。這種方法適用於需要根據時間變化來更新圖形狀態的動畫效果。

  • ArtistAnimation:ArtistAnimation是基於靜態影象的方法來建立動畫的。它要求使用者提供一系列的靜態影象,稱為藝術家物件。這些影象可以是通過Matplotlib建立的任何型別的視覺化物件,例如Figure、Axes、Line2D等。使用者需要將這些靜態影象儲存在一個列表中,然後通過ArtistAnimation來顯示這些影象的序列。ArtistAnimation會按照使用者指定的時間間隔逐幀地顯示這些影象,從而實現動畫效果。這種方法適用於已經有一系列靜態影象需要組合成動畫的場景。

本節將通過幾個範例來介紹Animations模組的使用,所介紹的範例出自:gallery-animation

1.1 FuncAnimation類

FuncAnimation建構函式的引數含義如下:

  • fig:要繪製動畫的Figure物件。
  • func:用於更新每一幀的函數,該函數接受一個引數frame,表示當前待繪製的資料框。
  • frames:用於產生待繪製的資料,可以是整數、生成器函數或迭代器。
  • init_func:在繪製動畫之前呼叫的初始化函數。
  • fargs:傳遞給func函數的附加引數(可選)。
  • save_count:指定動畫中快取的幀數量(可選),預設為100。注意該引數用於確定最後生成動圖和視訊所用影象的數量。
  • interval:每一幀之間的時間間隔,以毫秒為單位,預設為200。
  • repeat:控制動畫是否重複播放,預設為True。
  • repeat_delay:重複動畫之間的延遲時間(以毫秒為單位),預設為0。
  • blit:指定是否使用blitting技術來進行繪製優化,預設為False。
  • cache_frame_data:指定是否快取幀資料,預設為True。

範例-生成動態的正弦波動畫

import itertools
import matplotlib.pyplot as plt
import numpy as np
import matplotlib.animation as animation

# 定義生成資料的函數
def data_gen(max_range):
    # 使用itertools.count()生成無限遞增的計數器
    for cnt in itertools.count():
        # 當計數器超過最大範圍時停止生成資料
        if cnt > max_range:
            break
        print(cnt)
        # 計算時間t和對應的y值,使用np.sin()計算sin函數,np.exp()計算指數函數
        t = cnt / 10
        yield t, np.sin(2*np.pi*t) * np.exp(-t/10.)

# 初始化函數,設定座標軸範圍和清空資料
def init():
    ax.set_ylim(-1.1, 1.1)
    ax.set_xlim(0, 1)
    del xdata[:]
    del ydata[:]
    line.set_data(xdata, ydata)
    return line,


# 建立圖形物件以及子圖物件
fig, ax = plt.subplots()
# 建立線條物件
line, = ax.plot([], [], lw=2)
# 建立文字物件用於顯示 x 和 y 值
text = ax.text(0., 0., '', transform=ax.transAxes)
# 設定文字位置
text.set_position((0.7, 0.95))
# 將文字物件新增到圖形中
ax.add_artist(text)
ax.grid()
xdata, ydata = [], []

# 更新函數,將新的資料新增到圖形中
def run(data):
    # 獲取傳入的資料
    t, y = data
    # 將時間和對應的y值新增到xdata和ydata中
    xdata.append(t)
    ydata.append(y)
    # 獲取當前座標軸的範圍
    xmin, xmax = ax.get_xlim()
    # 更新文字物件的值
    text.set_text('x = {:.2f}, y = {:.2f}'.format(t, y))
    # 如果時間t超過當前範圍,更新座標軸範圍
    if t >= xmax:
        ax.set_xlim(xmin, 2*xmax)
        # 重繪圖形
        ax.figure.canvas.draw()
    # 更新線條的資料
    line.set_data(xdata, ydata)

    return line, text

# 建立動畫物件
# fig:圖形物件
# run:更新函數,用於更新圖形中的資料
# data_gen(20):生成器函數,產生資料的最大範圍為20
# interval=100:每幀動畫的時間間隔為100毫秒
# init_func=init:初始化函數,用於設定圖形的初始狀態
# repeat=True:動畫重複播放
ani = animation.FuncAnimation(fig, run, data_gen(20), interval=100, init_func=init, repeat=True)

# 顯示圖形
plt.show()

範例-建立動態散點圖與折線圖

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

# 建立一個圖形視窗和座標軸
fig, ax = plt.subplots()

# 建立時間陣列
t = np.linspace(0, 3, 50)

# 自由落體加速度
g = -9.81

# 初始速度
v0 = 12

# 計算高度
z = g * t**2 / 2 + v0 * t

# 第二個初始速度
v02 = 5

# 計算第二個高度
z2 = g * t**2 / 2 + v02 * t

# 建立散點圖
scat = ax.scatter(t[0], z[0], c="b", s=5, label=f'v0 = {v0} m/s')

# 建立線圖
line2 = ax.plot(t[0], z2[0], label=f'v0 = {v02} m/s')[0]

# 設定座標軸範圍和標籤
ax.set(xlim=[0, 3], ylim=[-4, 10], xlabel='Time [s]', ylabel='Z [m]')

# 新增圖例
ax.legend()


def update(frame):
    x = t[:frame]
    y = z[:frame]
    
    # 更新散點圖
    data = np.stack([x, y]).T
    # 更新散點圖中每個點的位置
    scat.set_offsets(data)
    
    # 更新線圖
    line2.set_xdata(t[:frame])
    line2.set_ydata(z2[:frame])
    
    return (scat, line2)

# 建立動畫
# frames為數值表示動畫的總幀數,即每次更新引數傳入當前幀號
ani = animation.FuncAnimation(fig=fig, func=update, frames=40, interval=30)

# 顯示圖形
plt.show()

範例-貝葉斯更新動畫

import math

import matplotlib.pyplot as plt
import numpy as np

from matplotlib.animation import FuncAnimation

# 定義分佈概率密度函數
def beta_pdf(x, a, b):
    return (x**(a-1) * (1-x)**(b-1) * math.gamma(a + b)
            / (math.gamma(a) * math.gamma(b)))

# 更新分佈類,用於更新動態圖
class UpdateDist:
    def __init__(self, ax, prob=0.5):
        self.success = 0
        self.prob = prob
        self.line, = ax.plot([], [], 'k-')
        self.x = np.linspace(0, 1, 200)
        self.ax = ax

        # 設定圖形引數
        self.ax.set_xlim(0, 1)
        self.ax.set_ylim(0, 10)
        self.ax.grid(True)

        # 這條豎直線代表了理論值,圖中的分佈應該趨近於這個值
        self.ax.axvline(prob, linestyle='--', color='black')

    def __call__(self, i):
        # 這樣圖形可以連續執行,我們只需不斷觀察過程的新實現
        if i == 0:
            self.success = 0
            self.line.set_data([], [])
            return self.line,

        # 根據超過閾值與均勻選擇來選擇成功
        if np.random.rand() < self.prob:
            self.success += 1
        y = beta_pdf(self.x, self.success + 1, (i - self.success) + 1)
        self.line.set_data(self.x, y)
        return self.line,

# 設定隨機狀態以便再現結果
np.random.seed(0)

# 建立圖形和座標軸物件
fig, ax = plt.subplots()

# 建立更新分佈物件,並應該收斂到的理論值為0.7
ud = UpdateDist(ax, prob=0.7)

# 建立動畫物件
anim = FuncAnimation(fig, ud, frames=100, interval=100,
                     blit=True, repeat_delay=1000)

# 顯示動畫
plt.show()

範例-模擬雨滴

import matplotlib.pyplot as plt
import numpy as np

from matplotlib.animation import FuncAnimation

# 設定隨機種子以確保可復現性
np.random.seed(0)

# 建立畫布和座標軸物件
fig = plt.figure(figsize=(7, 7))
# 在畫布上新增一個座標軸物件。
# [0, 0, 1, 1]引數指定了座標軸的位置和大小,分別表示左下角的 x 座標、左下角的 y 座標、寬度和高度。
# frameon=False參數列示不顯示座標軸的邊框
ax = fig.add_axes([0, 0, 1, 1], frameon=False)
ax.set_xlim(0, 1), ax.set_xticks([])
ax.set_ylim(0, 1), ax.set_yticks([])

# 建立雨滴資料
n_drops = 50
rain_drops = np.zeros(n_drops, dtype=[('position', float, (2,)),
                                      ('size',     float),
                                      ('growth',   float),
                                      ('color',    float, (4,))])

# 隨機初始化雨滴的位置和生長速率
rain_drops['position'] = np.random.uniform(0, 1, (n_drops, 2))
rain_drops['growth'] = np.random.uniform(50, 200, n_drops)

# 建立散點圖物件,用於在動畫中更新雨滴的狀態
scat = ax.scatter(rain_drops['position'][:, 0], rain_drops['position'][:, 1],
                  s=rain_drops['size'], lw=0.5, edgecolors=rain_drops['color'],
                  facecolors='none')

def update(frame_number):
    # 獲取一個索引,用於重新生成最舊的雨滴
    current_index = frame_number % n_drops

    # 隨著時間的推移,使所有雨滴的顏色更加透明
    rain_drops['color'][:, 3] -= 1.0 / len(rain_drops)
    rain_drops['color'][:, 3] = np.clip(rain_drops['color'][:, 3], 0, 1)

    # 所有雨滴變大
    rain_drops['size'] += rain_drops['growth']

    # 為最舊的雨滴選擇一個新的位置,重置其大小、顏色和生長速率
    rain_drops['position'][current_index] = np.random.uniform(0, 1, 2)
    rain_drops['size'][current_index] = 5
    rain_drops['color'][current_index] = (0, 0, 0, 1)
    rain_drops['growth'][current_index] = np.random.uniform(50, 200)

    # 使用新的顏色、大小和位置更新散點圖物件
    scat.set_edgecolors(rain_drops['color'])
    scat.set_sizes(rain_drops['size'])
    scat.set_offsets(rain_drops['position'])

# 建立動畫,並將update函數作為動畫的回撥函數
animation = FuncAnimation(fig, update, interval=10, save_count=100)
plt.show()

範例-跨子圖動畫

import matplotlib.pyplot as plt
import numpy as np

import matplotlib.animation as animation
from matplotlib.patches import ConnectionPatch

# 建立一個包含左右兩個子圖的圖形物件
fig, (axl, axr) = plt.subplots(
    ncols=2, # 指定一行中子圖的列數為2,即建立兩個子圖
    sharey=True,  # 共用y軸刻度
    figsize=(6, 2),  
    # width_ratios=[1, 3]指定第二個子圖的寬度為第一個子圖的三倍
    # wspace=0 設定子圖之間的水平間距為0
    gridspec_kw=dict(width_ratios=[1, 3], wspace=0), 
)

# 設定左側子圖縱橫比為1,即使得它的寬度和高度相等
axl.set_aspect(1)
# 設定右側子圖縱橫比為1/3,即高度是寬度的三分之一
axr.set_box_aspect(1 / 3)

# 右子圖不顯示y軸刻度
axr.yaxis.set_visible(False)

# 設定右子圖x軸刻度以及對應的標籤
axr.xaxis.set_ticks([0, np.pi, 2 * np.pi], ["0", r"$\pi$", r"$2\pi$"])

# 在左子圖上繪製圓
x = np.linspace(0, 2 * np.pi, 50)
axl.plot(np.cos(x), np.sin(x), "k", lw=0.3)

# 在左子圖上繪製初始點
point, = axl.plot(0, 0, "o")

# 在右子圖上繪製完整的正弦曲線,以設定檢視限制
sine, = axr.plot(x, np.sin(x))

# 繪製連線兩個圖表的連線
con = ConnectionPatch(
    (1, 0), # 連線線的起始點座標
    (0, 0), # 連線線的終點座標
    "data",
    "data",
    axesA=axl, # 指定連線線的起始點所在的座標軸
    axesB=axr, # 指定連線線的終點所在的座標軸
    color="red", 
    ls="dotted", # 連線線型別
)
fig.add_artist(con)

# 定義動畫函數
def animate(i):
    x = np.linspace(0, i, int(i * 25 / np.pi))
    sine.set_data(x, np.sin(x))
    x, y = np.cos(i), np.sin(i)
    point.set_data([x], [y])
    con.xy1 = x, y
    con.xy2 = i, y
    return point, sine, con

# 建立動畫物件
ani = animation.FuncAnimation(
    fig,
    animate,
    interval=50,  
    blit=False,   # 不使用blitting技術,這裡Figure artists不支援blitting
    frames=x,     
    repeat_delay=100,  # 動畫重複播放延遲100毫秒
)

# 展示動畫
plt.show()

範例-動態示波器

import matplotlib.pyplot as plt
import numpy as np

import matplotlib.animation as animation
from matplotlib.lines import Line2D

# 建立一個 Scope 類用於繪製動態圖形
class Scope:
    def __init__(self, ax, maxt=2, dt=0.02):
        """
        :param ax: Matplotlib 的座標軸物件
        :param maxt: 時間的最大值,預設為2
        :param dt: 時間步長,預設為0.02
        """
        self.ax = ax
        self.dt = dt
        self.maxt = maxt
        self.tdata = [0]  # 時間資料的列表
        self.ydata = [0]  # y軸資料的列表
        self.line = Line2D(self.tdata, self.ydata)  # 建立一條線物件
        self.ax.add_line(self.line)  # 將線物件新增到座標軸上
        self.ax.set_ylim(-.1, 1.1)  # 設定y軸範圍
        self.ax.set_xlim(0, self.maxt)  # 設定x軸範圍

    def update(self, y):
        """
        更新圖形資料
        :param y: 新的y軸資料
        :return: 更新後的線物件
        """
        lastt = self.tdata[-1]
        if lastt >= self.tdata[0] + self.maxt:  # 如果當前時間超過了最大時間,重新設定陣列
            self.tdata = [self.tdata[-1]]
            self.ydata = [self.ydata[-1]]
            self.ax.set_xlim(self.tdata[0], self.tdata[0] + self.maxt)
            self.ax.figure.canvas.draw()

        # 進行時間的計算
        t = self.tdata[0] + len(self.tdata) * self.dt

        self.tdata.append(t)
        self.ydata.append(y)
        self.line.set_data(self.tdata, self.ydata)
        return self.line,

def emitter(p=0.1):
    """以概率p(範圍為[0, 1))返回一個隨機值,否則返回0"""
    while True:
        v = np.random.rand()
        if v > p:
            yield 0.
        else:
            yield np.random.rand()

np.random.seed(0)

fig, ax = plt.subplots()  # 建立一個圖形視窗和一對座標軸
scope = Scope(ax)  # 建立一個Scope物件,用於繪製動態圖

# 使用scope的類函數update作為更新函數
ani = animation.FuncAnimation(fig, scope.update, emitter, interval=50, blit=True, save_count=100)

plt.show() 

範例-世界主要城市的人口數量動態展示

本範例程式碼和資料來自於: how-to-create-animations-in-python。這段程式碼支援展示自1500年到2020年期間人口數排名靠前的城市的變化趨勢。該範例只是介紹簡單的動態條形圖繪製,更加精美的條形圖繪製可使用:bar_chart_racepandas_alive

import pandas as pd 
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker 
from matplotlib.animation import FuncAnimation  
import matplotlib.patches as mpatches 

# 定義一個函數,用於生成顏色列表
def generate_colors(string_list):
    num_colors = len(string_list)
    # 使用tab10調色盤,可以根據需要選擇不同的調色盤
    colormap = plt.cm.get_cmap('tab10', num_colors)

    colors = []
    for i in range(num_colors):
        color = colormap(i)
        colors.append(color)

    return colors

# 讀取CSV檔案,並選擇所需的列
# 資料地址:https://media.geeksforgeeks.org/wp-content/cdn-uploads/20210901121516/city_populations.csv
df = pd.read_csv('city_populations.csv', usecols=[
                 'name', 'group', 'year', 'value'])

# 將年份列轉換為整數型
df['year'] = df['year'].astype(int)
# 將人口數量列轉換為浮點型
df['value'] = df['value'].astype(float)

# 獲取城市分組列表
group = list(set(df.group))

# 生成城市分組對應的顏色字典
group_clolor = dict(zip(group, generate_colors(group)))

# 建立城市名稱與分組的字典
group_name = df.set_index('name')['group'].to_dict()


# 定義繪製柱狀圖的函數
def draw_barchart(year):
    # 根據年份篩選資料,並按人口數量進行降序排序,取出最大範圍的資料
    df_year = df[df['year'].eq(year)].sort_values(
        by='value', ascending=True).tail(max_range)
    ax.clear()
    # 繪製水平柱狀圖,並設定顏色
    ax.barh(df_year['name'], df_year['value'], color=[
            group_clolor[group_name[x]] for x in df_year['name']])
    
    # 在柱狀圖上方新增文字標籤
    dx = df_year['value'].max() / 200
    for i, (value, name) in enumerate(zip(df_year['value'], df_year['name'])):
        # 城市名
        ax.text(value-dx, i, name,
                size=12, weight=600,
                ha='right', va='bottom')
        ax.text(value-dx, i-0.25, group_name[name],
                size=10, color='#333333',
                ha='right', va='baseline')
        # 地區名
        ax.text(value+dx, i, f'{value:,.0f}',
                size=12, ha='left',  va='center')

    # 設定其他樣式
    ax.text(1, 0.2, year, transform=ax.transAxes,
            color='#777777', size=46, ha='right',
            weight=800)
    ax.text(0, 1.06, 'Population (thousands)',
            transform=ax.transAxes, size=12,
            color='#777777')
    # 新增圖例
    handles = []
    for name, color in group_clolor.items():
        patch = mpatches.Patch(color=color, label=name)
        handles.append(patch)
    ax.legend(handles=handles, fontsize=12, loc='center', bbox_to_anchor=(
        0.5, -0.03), ncol=len(group_clolor), frameon=False)
    
    # x軸的主要刻度格式化,不保留小數
    ax.xaxis.set_major_formatter(ticker.StrMethodFormatter('{x:,.0f}'))
    # 將x軸的刻度位置設定在圖的頂部
    ax.xaxis.set_ticks_position('top')
    # 設定x軸的刻度顏色為灰色(#777777),字型大小為16
    ax.tick_params(axis='x', colors='#777777', labelsize=16)
    # 清除y軸的刻度標籤
    ax.set_yticks([])
    # 在x軸和y軸上設定0.01的邊距
    ax.margins(0, 0.01)
    # 在x軸上繪製主要格線,線條樣式為實線
    ax.grid(which='major', axis='x', linestyle='-')
    # 設定格線繪製在影象下方
    ax.set_axisbelow(True)

    # 新增繪圖資訊
    ax.text(0, 1.10, f'The {max_range} most populous cities in the world from {start_year} to {end_year}',
            transform=ax.transAxes, size=24, weight=600, ha='left')

    ax.text(1, 0, 'Produced by luohenyueji',
            transform=ax.transAxes, ha='right', color='#777777',
            bbox=dict(facecolor='white', alpha=0.8, edgecolor='white'))
    plt.box(False)


# 建立繪圖所需的figure和axes
fig, ax = plt.subplots(figsize=(12, 8))
start_year = 2000
end_year = 2020
# 設定最多顯示城市數量
max_range = 15

# 獲取資料中的最小年份和最大年份,並進行校驗
min_year, max_year = min(set(df.year)), max(set(df.year))
assert min_year <= start_year, f"end_year cannot be lower than {min_year}"
assert end_year <= max_year, f"end_year cannot be higher  than {max_year}"

# 建立動畫物件,呼叫draw_barchart函數進行繪製
ani = FuncAnimation(fig, draw_barchart, frames=range(
    start_year, end_year+1), repeat_delay=1000, interval=200)
fig.subplots_adjust(left=0.04, right=0.94, bottom=0.05)

# 顯示圖形
plt.show()

結果如下:

1.2 ArtistAnimation類

ArtistAnimation建構函式的引數含義如下:

  • fig:要繪製動畫的Figure物件。
  • artists:包含了一系列繪圖物件的列表,這些繪圖物件將被作為動畫的幀。
  • interval:每一幀之間的時間間隔,以毫秒為單位,預設為200。
  • repeat:控制動畫是否重複播放,預設為True。
  • repeat_delay:重複動畫之間的延遲時間(以毫秒為單位),預設為0。
  • blit:指定是否使用blitting技術來進行繪製優化,預設為False。

範例-ArtistAnimation簡單使用

import matplotlib.pyplot as plt
import numpy as np

import matplotlib.animation as animation

fig, ax = plt.subplots()

# 定義函數 f(x, y),返回 np.sin(x) + np.cos(y)
def f(x, y):
    return np.sin(x) + np.cos(y)

# 生成 x 和 y 的取值範圍
x = np.linspace(0, 2 * np.pi, 120)
y = np.linspace(0, 2 * np.pi, 100).reshape(-1, 1)

# ims 是一個列表的列表,每一行是當前幀要繪製的藝術品列表;
# 在這裡我們只在每一幀動畫中繪製一個藝術家,即影象
ims = []

# 迴圈生成動畫的每一幀,並存入一個列表
for i in range(60):
    # 更新 x 和 y 的取值
    x += np.pi / 15
    y += np.pi / 30
    # 呼叫函數 f(x, y),並繪製其返回的影象
    im = ax.imshow(f(x, y), animated=True)
    if i == 0:
        # 首先顯示一個初始的影象
        ax.imshow(f(x, y))
    # 將當前幀新增到ims中
    ims.append([im])

# 基於ims中的繪圖物件繪製動圖
ani = animation.ArtistAnimation(fig, ims, interval=50, blit=True,
                                repeat_delay=1000)

# 顯示動畫
plt.show()

範例-建立動態柱狀圖

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

fig, ax = plt.subplots() 
rng = np.random.default_rng(0) 
# # 建立一個包含5個元素的陣列,表示資料集
data = np.array([20, 20, 20, 20,20])  
# 建立一個包含5個字串的列表,表示資料集的標籤
x = ["A", "B", "C", "D","E"]  

# 建立一個空列表,用於儲存圖形物件
artists = []  
# 建立一個包含5個顏色值的列表,用於繪製圖形
colors = ['tab:blue', 'tab:red', 'tab:green', 'tab:purple', 'tab:orange']  

for i in range(20):
    # 隨機生成一個與data形狀相同的陣列,並將其加到data中
    data += rng.integers(low=0, high=10, size=data.shape)  
    # 建立一個水平條形圖,並設定顏色
    container = ax.barh(x, data, color=colors)
    # 設定x軸範圍
    ax.set_xlim(0,150)
    # 將建立的圖形物件新增到列表中
    artists.append(container)  

# 建立一個ArtistAnimation物件,指定圖形視窗和圖形物件列表以及動畫間隔時間
ani = animation.ArtistAnimation(fig=fig, artists=artists, interval=200) 
plt.show() 

1.3 動畫儲存

Matplotlib通過plot方法建立和顯示動畫。為了儲存動畫為動圖或視訊,Animation類提供了save函數。save函數的常見引數如下:

  • filename:儲存檔案的路徑和名稱。
  • writer:指定要使用的寫入器(Writer)。如果未指定,則預設使用ffmpeg寫入器。
  • fps:設定幀速率(每秒顯示多少幀),預設值為None,表示使用Animation物件中的interval屬性作為幀速率。
  • dpi:設定輸出影象的解析度,預設值為None,表示使用系統預設值。
  • codec:指定視訊編解碼器,僅當writer為ffmpeg_writer時有效。
  • bitrate:設定位元率,僅當writer為ffmpeg_writer時有效。
  • extra_args:用於傳遞給寫入器的額外引數。
  • metadata:包含檔案後設資料的字典。
  • extra_anim:與主要動畫同時播放的其他動畫。
  • savefig_kwargs:傳遞給savefig()的關鍵字引數。
  • progress_callback:用於在儲存過程中更新進度的回撥函數。

writer寫入器可以指定使用各種多媒體寫入程式(例如:Pillow、ffpmeg、imagemagik)儲存到本地,如下所示:

Writer Supported Formats
~matplotlib.animation.PillowWriter .gif, .apng, .webp
~matplotlib.animation.HTMLWriter .htm, .html, .png
~matplotlib.animation.FFMpegWriter All formats supported by ffmpeg: ffmpeg -formats
~matplotlib.animation.ImageMagickWriter All formats supported by imagemagick: magick -list format

儲存動圖和視訊的程式碼如下:

# 動圖
ani.save(filename="pillow_example.gif", writer="pillow")
ani.save(filename="pillow_example.apng", writer="pillow")

# 視訊,需要安裝ffmpeg
ani.save(filename="ffmpeg_example.mkv", writer="ffmpeg")
ani.save(filename="ffmpeg_example.mp4", writer="ffmpeg")
ani.save(filename="ffmpeg_example.mjpeg", writer="ffmpeg")

需要注意的是動圖構建物件時所設定的引數不會影響save函數,如下所示,在FuncAnimation中設定repeat=False,即動圖只播放一次。但是儲存的gif檔案卻迴圈播放。這是因為save函數呼叫了其他第三庫的動圖或者視訊保持函數,需要重新設定引數。

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

# 建立畫布和座標軸
fig, ax = plt.subplots()
xdata, ydata = [], []
ln, = plt.plot([], [], 'r-')


def init():
    ax.set_xlim(0, 2*np.pi)
    ax.set_ylim(-1, 1)
    return ln,


def update(frame):
    x = np.linspace(0, 2*np.pi, 100)
    y = np.sin(x + frame/10)
    ln.set_data(x, y)
    return ln,


# 建立動畫物件
ani = FuncAnimation(fig, update, frames=100, interval=100,
                    init_func=init, blit=True, repeat=False)

ani.save(filename="pillow_example.gif", writer=writer, dpi=150)

要解決儲存動畫問題,需要自定義動畫儲存類,如下所示:

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

# 建立畫布和座標軸
fig, ax = plt.subplots()
xdata, ydata = [], []
ln, = plt.plot([], [], 'r-')


def init():
    ax.set_xlim(0, 2*np.pi)
    ax.set_ylim(-1, 1)
    return ln,


def update(frame):
    x = np.linspace(0, 2*np.pi, 100)
    y = np.sin(x + frame/10)
    ln.set_data(x, y)
    return ln,


# 建立動畫物件
ani = FuncAnimation(fig, update, frames=100, interval=100,
                    init_func=init, blit=True, repeat=False)

# 建立自定義的動畫寫入類
class SubPillowWriter(animation.PillowWriter):
    def __init__(self, loop=1, **kwargs):
        super().__init__(**kwargs)
        # 將loop設定為0,表示無限迴圈播放;如果設定為一個大於0的數值,表示迴圈播放指定次數
        self.loop = loop

    # 定義播放結束時,儲存圖片的程式碼
    def finish(self):
        # 呼叫了pillow包
        self._frames[0].save(self.outfile, save_all=True, append_images=self._frames[1:], duration=int(
            1000 / self.fps), loop=self.loop)


# 建立動畫寫入物件
# fps=15:每秒幀數,表示動畫的播放速度為每秒 15 幀。
# metadata=dict(artist='luohenyueji'):後設資料資訊,包括藝術家資訊,將被新增到生成的GIF檔案中。
writer = SubPillowWriter(fps=15, metadata=dict(artist='luohenyueji'))
ani.save(filename="pillow_example.gif", writer=writer, dpi=150)

2 基於Pillow庫生成動圖

使用Pillow庫生成動圖非常簡單。首先,準備一個包含一系列影象幀的列表。這些影象幀可以是連續的圖片,每張圖片表示動畫的一個時間點。接下來,使用Pillow庫中的save()方法將這些影象幀儲存為一個gif檔案。在儲存動圖時,還可以設定一些引數來控制動畫效果。參考以下範例,可獲取具體的使用說明。

範例-滑動動圖

該範例展示了一種影象滑動展示的動畫效果,即通過滑動漸變的方式逐步將起始黑白圖片轉變為目標彩色圖片。所示起始圖片和目標圖片如下所示:

動畫結果如下所示:

本範例所提供程式碼主要可調引數介紹如下:

  • span (int): 分割步長,預設為100。此引數用於控制圖片合併過程中的分割步長,即每次移動的距離。

  • save (bool): 是否儲存中間幀影象,預設為False。如果設定為True,則會將生成的每一幀影象儲存到指定的資料夾中。

  • orient (str): 合併方向,預設水平。可選值為'horizontal'(水平方向)或'vertical'(垂直方向)。用於控制影象的合併方向。

  • loop (int): 迴圈次數,預設為0(無限迴圈)。設定為正整數時,動畫會迴圈播放指定次數;設定為0時,動畫會無限迴圈播放。

  • duration (int): 幀持續時間(毫秒),預設為100。用於設定每一幀影象在動畫中的顯示時間。

  • repeat_delay (int): 迴圈之間的延遲時間(毫秒),預設為500。用於設定每次迴圈之間的延遲時間。

  • save_name (str): 儲存動畫的檔名,預設為"output"。用於設定生成的動畫檔案的名稱。

以下是程式碼實現的範例。該程式碼首先讀取起始圖片和目標圖片,然後指定分割位置以設定圖片兩側的效果。最後,通過調整分割位置來實現滑動漸變效果。

from PIL import Image, ImageDraw
import os


def merge_image(in_img, out_img, pos, orient="horizontal"):
    """
    合併影象的函數

    引數:
        in_img (PIL.Image): 輸入影象
        out_img (PIL.Image): 輸出影象
        pos (int): 分割位置
        orient (str): 影象合併方向,預設水平horizontal,可選垂直vertical

    返回:
        result_image (PIL.Image): 合併後的影象
    """
    if orient == "horizontal":
        # 將影象分為左右兩部分
        left_image = out_img.crop((0, 0, pos, out_img.size[1]))
        right_image = in_img.crop((pos, 0, in_img.size[0], in_img.size[1]))

        # 合併左右兩部分影象
        result_image = Image.new(
            'RGB', (left_image.size[0] + right_image.size[0], left_image.size[1]))
        result_image.paste(left_image, (0, 0))
        result_image.paste(right_image, (left_image.size[0], 0))

        # 新增滑動線條
        draw = ImageDraw.Draw(result_image)
        draw.line([(left_image.size[0], 0), (left_image.size[0],
                  left_image.size[1])], fill=(0, 255, 255), width=3)

    elif orient == 'vertical':
        # 將影象分為上下兩部分
        top_image = out_img.crop((0, 0, out_img.size[0], pos))
        bottom_image = in_img.crop((0, pos, in_img.size[0], in_img.size[1]))

        # 合併上下兩部分影象
        result_image = Image.new(
            'RGB', (top_image.size[0], top_image.size[1] + bottom_image.size[1]))
        result_image.paste(top_image, (0, 0))
        result_image.paste(bottom_image, (0, top_image.size[1]))

        # 新增滑動線條
        draw = ImageDraw.Draw(result_image)
        draw.line([(0, top_image.size[1]), (top_image.size[0],
                  top_image.size[1])], fill=(0, 255, 255), width=3)

    return result_image


def main(img_in_path, img_out_path, span=100, save=False, orient='horizontal', loop=0, duration=100, repeat_delay=500, save_name="output"):
    """
    主函數

    引數:
        img_in_path (str): 起始圖片路徑
        img_out_path (str): 目標圖片路徑
        span (int): 分割步長,預設為100
        save (bool): 是否儲存中間幀影象,預設為False
        orient (str): 合併方向,預設水平
        loop (int): 迴圈次數,預設為0(無限迴圈)
        duration (int): 幀持續時間(毫秒),預設為100
        repeat_delay (int): 迴圈之間的延遲時間(毫秒),預設為500
        save_name (str): 儲存動畫的檔名,預設為"output"
    """
    # 讀取原始影象
    img_in = Image.open(img_in_path).convert("RGB")
    img_out = Image.open(img_out_path).convert("RGB")
    assert img_in.size == img_out.size, "Unequal size of two input images"

    if save:
        output_dir = 'output'
        os.makedirs(output_dir, exist_ok=True)

    frames = []
    frames.append(img_in)
    span_end = img_in.size[0] if orient == 'horizontal' else img_in.size[1]
    # 逐張生成gif圖片每一幀
    for pos in range(span, span_end, span):
        print(pos)
        result_image = merge_image(img_in, img_out, pos, orient)
        if save:
            result_image.save(f"output/{pos:04}.jpg")
        frames.append(result_image)

    if save:
        img_in.save("output/0000.jpg")
        img_out.save(f"output/{img_in.size[0]:04}.jpg")
    # 新增過渡效果
    durations = [duration]*len(frames)
    durations.append(repeat_delay)
    frames.append(img_out)
    # 生成動圖
    # frames[0].save:表示將frames列表中的第一張圖片作為輸出GIF動畫的第一幀
    # '{save_name}.gif':表示將輸出的GIF動畫儲存在當前目錄下並命名為{save_name}.gif
    # format='GIF':表示輸出的檔案格式為GIF格式
    # append_images=frames[1:]:表示將frames列表中除了第一張圖片以外的剩餘圖片作為輸出GIF動畫的後續幀
    # save_all=True:表示將所有的幀都儲存到輸出的GIF動畫中
    # duration:表示每一幀的持續時間duration,可以是數值也可以是列表。如果是列表則單獨表示每一幀的時間
    # loop=0:表示迴圈播放次數為0,即無限迴圈播放
    # optimize=True:表示優化圖片生成
    frames[0].save(f'{save_name}.gif', format='GIF', append_images=frames[1:],
                   save_all=True, duration=durations, loop=loop, optimize=True)


if __name__ == "__main__":
    # 起始圖片路徑
    img_in_path = 'in.jpg'
    # 目標圖片路徑
    img_out_path = 'out.jpg'
    # 呼叫 main 函數,並傳入相應的引數
    main(
        img_in_path,                   # 起始圖片路徑
        img_out_path,                  # 目標圖片路徑
        save=True,                     # 是否儲存中間結果
        span=150,                      # 分割步長,預設為 150
        orient='horizontal',           # 合併方向,預設為水平(可選值為 'horizontal' 或 'vertical')
        duration=500,                  # 幀持續時間(毫秒),預設為500
        save_name="output",            # 儲存動畫的檔名,預設為 "output"
        repeat_delay=2000              # 迴圈之間的延遲時間(毫秒)預設為 500
    )

上述程式碼演示了一種直接生成動圖的方法。此外,還可以通過讀取磁碟中的圖片集合來生成動圖。以下是範例程式碼,用於讀取之前儲存的中間圖片並生成動圖:

from PIL import Image
import os

# 圖片資料夾路徑
image_folder = 'output'

# 儲存的動圖路徑及檔名
animated_gif_path = 'output.gif'

# 獲取圖片檔案列表
image_files = [f for f in os.listdir(image_folder) if f.endswith('.jpg') or f.endswith('.png')]
image_files.sort()
# 建立圖片幀列表
frames = []
for file_name in image_files:
    image_path = os.path.join(image_folder, file_name)
    img = Image.open(image_path)
    frames.append(img)

# 儲存為動圖
frames[0].save(animated_gif_path, format='GIF', append_images=frames[1:], save_all=True, duration=200, loop=0)

值得注意,基於Pillow庫生成的gif圖片,往往檔案體積過大。這是因為Pillow庫採用無失真壓縮的方式儲存gif圖片。為了解決這個問題,可以嘗試以下方法對gif圖片進行壓縮:

  • 使用線上gif圖片壓縮網站,如:gif-compressor
  • 基於壓縮或優化gif圖片的工具,如:gifsicle
  • 縮小gif影象寬高

3 參考