2023年10月

动画是一种高效的可视化工具,能够提升用户的吸引力和视觉体验,有助于以富有意义的方式呈现数据可视化。本文的主要介绍在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_race

pandas_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 参考

Java核心知识体系1:泛型机制详解
Java核心知识体系2:注解机制详解
Java核心知识体系3:异常机制详解
Java核心知识体系4:AOP原理和切面应用

1 介绍

无论是那种语言体系,反射都是必不可少的一个技术特征。从Java体系来说,很多常用的技术框架或多或少都使用到了反射技术,比如Spring、MyBatis、RocketMQ、FastJson 等等。反射技术强大而必要,在大多数框架中起到举足轻重的作用。所以,反射也是Java必不可少的核心技术之一。

接下来我们来看看反射的一些技术要点:

  1. 反射的概念(即什么是反射)?
  2. 反射的作用(它帮我们解决了哪些问题)?
  3. 反射的实现原理?
  4. 如何使用反射?
    下面我就针对以上的疑问,一一来讲解。

1.1 反射是什么?

Java反射(Reflection)是Java语言的一个核心特性,它允许运行中的Java代码对自身进行自我检查,甚至修改自身的组件。具体来说,反射机制提供了在运行状态中,对于任意一个类,都能够了解这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性。这种动态获取的信息以及动态调用对象的方法在Java中就叫做反射。
一句话总结:反射就是在运行时才具体知晓要操作的类是什么结构,并在运行时获取类的完整构造,并调用对应的方法、属性等。

Java的反射主要包括以下三个部分:

  • 类的加载:Java的类在需要使用时才会被加载到JVM中。这个过程是由类加载器(ClassLoader)完成的。类加载器首先检查这个类是否已经被加载过,如果还没有加载,那么就会从磁盘上加载类的字节码并创建一个Class对象。
  • 获取类的信息:当一个对象被创建后,我们可以使用反射来获取这个对象的Class对象。通过这个Class对象,我们可以获取到这个类的所有属性和方法。
  • 方法的调用:通过反射,我们可以动态的调用一个对象的方法。即使这个方法是一个私有的方法,也能够通过反射来调用。

1.2 为什么要用反射?

Java Reflection功能非常强大,并且非常有用,比如:

  • 获取任意类的名称、package信息、所有属性、方法、注解、类型、类加载器等
  • 获取任意对象的属性,并且能改变对象的属性
  • 调用任意对象的方法
  • 判断任意一个对象所属的类
  • 实例化任意一个类的对象
  • 通过反射我们可以实现动态装配,降低代码的耦合度,实现动态代理等。

具体的应用场景:

  • 框架设计:许多框架,如Spring,Hibernate等,都大量使用了反射来实现对象的自动装配,动态代理等功能。
  • 单元测试:单元测试框架(如JUnit)会使用反射来调用被注解的方法。
  • 插件化:为了实现插件化,可以通过反射加载不同的插件。
  • 对象序列化与反序列化:在对象进行序列化和反序列化的时候,会使用反射获取到对象的所有属性和方法。

2 反射的使用

在Java中,Class类与java.lang.reflect类库配合对反射技术进行了完整的支持。在反射的Package中,我们经常使用功能类如下:

  • Constructor类表示的是Class 对象所表示的类的构造方法,利用它可以在运行时动态创建对象
  • Field类表示Class对象所表示的类的成员变量,通过它可以在运行时动态修改成员变量的属性值(包含private)
  • Method类表示Class对象所表示的类的成员方法,通过它可以动态调用对象的方法(包含private)

下面将对这几个类进行详细介绍。

2.1 反射创建类对象

一般情况下我们通过反射创建类对象主要有两种方式:

  • 通过 Class 对象的 newInstance() 方法

  • 通过 Constructor 对象的 newInstance() 方法

  • 通过 Class 对象的 newInstance() 方法实现

Class clz = Class.forName("com.ad.reflection.TestRefle");
TestRefle tr= (TestRefle)clz.newInstance();
  • 通过 Constructor 对象的 newInstance() 方法实现
Class clz = Class.forName("com.ad.reflection.TestRefle");
Constructor constructor = clz.getConstructor();
TestRefle tr= (TestRefle)constructor.newInstance();

这边需要注意,通过 Constructor 对象创建类对象可以选择特定构造方法,而通过 Class 对象则只能使用默认的无参数构造方法。
下面的代码演示的是通过 Constructor 调用有参构造方法进行了类对象初始化:

Class clz = Class.forName("com.ad.reflection.TestRefle");
Constructor constructor = clz.getConstructor(String.class);
TestRefle tr= (TestRefle)constructor.newInstance("提供一个String参数");

接下来我们继续,通过具体的API获取详细的类信息:类信息、方法信息、属性信息等。

2.2 获取Class类对象

 // 获取Class对象的三种方式
 根据类名: Class mailClass = MailInfo.class;
 根据对象: Class mailClass = new MailInfo().getClass();
 根据全限定类名: Class mailClass = Class.forName("com.ad.MailInfo");
 
 // 根据对象获取信息和实例对象
 获取全限定类名: mailClass.getName();
 获取类名: mailClass.getSimpleName();
 实例化: userClass.getDeclaredConstructor().newInstance();

更加详细Class类获取参考如下:

方法 用途
forName() (1)获取Class对象的一个引用,但引用的类还没有加载(该类的第一个对象没有生成)就加载了这个类。 (2)为了产生Class引用,forName()立即就进行了初始化。
Object-getClass() 获取Class对象的一个引用,返回表示该对象的实际类型的Class引用。
getName() 取全限定的类名(包括包名),即类的完整名字。 getSimpleName() 获取类名(不包括包名)
getCanonicalName() 获取全限定的类名(包括包名)
isInterface() 判断Class对象是否是表示一个接口
getInterfaces() 返回Class对象数组,表示Class对象所引用的类所实现的所有接口。
getSupercalss() 返回Class对象,表示Class对象所引用的类所继承的直接基类。应用该方法可在运行时发现一个对象完整的继承结构。
newInstance() 返回一个Oject对象,是实现“虚拟构造器”的一种途径。使用该方法创建的类,必须带有无参的构造器。

2.3 获取类的成员变量的信息

Field[] fields = _class.getDeclaredFields();

更加详细成员变量获取参考如下:

方法 用途
getField(String name) 获得某个公有的属性对象
getFields() 获取所有的公有的属性对象
getDeclaredField(String name) 获得某个属性对象(public和非public)
getDeclaredFields() 获得所有属性对象(public和非public)

2.4 获得类方法

Method[] methods = _class.getDeclaredMethods();

更加详细方法获取参考如下:

方法 用途
getMethod(String name, Class...<?> paramerterTypes) 获得某个公有的方法对象
getMethods() 获取所有的公有的方法对象
getDeclaredMethod(String name, Class...<?> paramerterTypes) 获得对应类下某个方法(public和非public)
getDeclaredMethods() 获得对应类下所有方法(public和非public)

2.5 获得构造函数

Constructor[] constructors = _class.getDeclaredConstructors();

更加详细构造函数获取参考如下:

方法 用途
getConstructor(Class...<?> paramerterTypes) 获得该类中与参数类型匹配的公有构造方法
getConstructors() 获取该类的所有公有构造方法
getDeclaredConstructor(Class...<?> paramerterTypes) 获得该类中与参数类型匹配的构造方法
getDeclaredConstructors() 获取该类的所有构造方法

这样通过反射就可以做在运行时获取类的完整构造,并获得类信息了。

类名 用途
Class类 代表类的实体,在运行的Java应用程序中表示类和接口
Field类 代表类的成员变量(即类的属性)
Method类 代表类的方法
Constructor类 代表类的构造函数

通过上面的几个示例我们基本了解了反射的使用,但这仅仅是使用,我们还需深入理解反射背后的底层实现原理。

3 反射原理分析

3.1 反射的调用流程

1、编写完Java项目之后,java文件都会被编译成一个.class文件
2、这些class文件在程序运行时会被ClassLoader加载到JVM中,当一个类被加载以后,JVM就会在内存中自动产生一个Class对象。
3、通过Class对象获取 Field(属性)、Method(方法)、Construcor(构造函数)
我们平时通过new的形式创建对象,本质上就是通过Class来创建个新对象
image
通过上面的流程我们可以看出反射的优势:

  • 动态装配

我们的程序在运行时,可能不一定会用到所有我们编写和构建的类,这样避免启动时间太长并且浪费大量无用的机器资源。
取而代之的是动态的加载一些类,这些类可能之前用不到所以不用加载到jvm,而是在运行时根据需要才加载。

  • 降低耦合
    如果你在使用new时明确的指定类名,那这就是典型的硬编码实现,而在使用反射的时候,可以只传入类名参数,就可以生成对象,降低了耦合度,使得程序更具灵性。

完整的调用流程,图片来自网上,比较模糊,后续再补一个
image

3.2 反射的应用场景

image

  • 框架设计:许多框架,如Spring,Hibernate,mybatis,dubbo,rocketmq等,都大量使用了反射来实现对象的自动装配,动态代理等功能。
  • 单元测试:单元测试框架(如JUnit)会使用反射来调用被注解的方法。
  • 插件化:为了实现插件化,可以通过反射加载不同的插件。
  • 对象序列化与反序列化:在对象进行序列化和反序列化的时候,会使用反射获取到对象的所有属性和方法。
  • 动态配置、动态代理:通过反射去读取配置,以及代理请求

4 反射经典案例解析

以下案例来自百度文心一言大模型自动生成,已调试通过。

import java.lang.reflect.Method;  
  
public class ReflectionExample {  
    public static void main(String[] args) {  
        try {  
            // 获取目标类的Class对象  
            Class<?> targetClass = Class.forName("java.util.ArrayList");  
  
            // 获取目标类的所有公共方法  
            Method[] methods = targetClass.getMethods();  
  
            // 遍历所有方法并打印方法名  
            for (Method method : methods) {  
                System.out.println(method.getName());  
            }  
  
            // 获取特定方法,比如添加元素的add方法  
            Method addMethod = targetClass.getMethod("add", Object.class);  
  
            // 创建目标类的实例对象  
            Object targetObject = targetClass.newInstance();  
  
            // 调用add方法添加元素  
            addMethod.invoke(targetObject, "Hello, World!");  
  
            // 获取目标类的所有属性(字段)并打印属性名  
            Field[] fields = targetClass.getDeclaredFields();  
            for (Field field : fields) {  
                System.out.println(field.getName());  
            }  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  
}

这个案例展示了如何使用反射来获取目标类的Class对象,获取并打印目标类的所有公共方法,获取特定方法,创建目标类的实例对象,调用目标类的方法,以及获取并打印目标类的所有属性(字段)。

总结

无论是那种语言体系(C#、Java等等),反射都是必不可少的一个技术特征。而从Java体系来说,很多常用的技术框架或多或少都使用到了反射技术,比如Spring、MyBatis、RocketMQ、FastJson 等等。
学习好Java 反射技术能帮助你更好的理解底层调用的原理,也有助于设计更加 轻巧、高内聚、低耦合 的业务框架。

点集合的三角剖分是指如何将一些离散的点集合组合成不均匀的三角形网格,使得每个点成为三角网中三角面的顶点。这个算法的用处很多,一个典型的意义在于可以通过一堆离散点构建的TIN实现对整个构网区域的线性控制,比如用带高程的离散点构建的TIN来表达地形。

在实际工作中,使用最多的三角剖分是Delaunay三角剖分。通过Delaunay三角剖分算法能够构建一个具有空圆特性和最大化最小角特性的三角网。空圆特性其实就是对于两个共边的三角形,任意一个三角形的外接圆中都不能包含有另一个三角形的顶点,这种形式的剖分产生的最小角最大。这些特性可能有些难以理解,但是我们可以先谨记一点:Delaunay三角网是一种特性最优的三角剖分。

通过CGAL,我们可以直接通过离散点集生成Delaunay三角网,实现代码如下:

#include <CGAL/Delaunay_triangulation_2.h>
#include <CGAL/Exact_predicates_inexact_constructions_kernel.h>
#include <CGAL/Projection_traits_xy_3.h>

typedef CGAL::Exact_predicates_inexact_constructions_kernel K;
typedef CGAL::Projection_traits_xy_3<K> Gt;
typedef CGAL::Delaunay_triangulation_2<Gt> Delaunay;
typedef K::Point_3 Point;

#include <ogrsf_frmts.h>

#include <iostream>
#include <string>

using namespace std;

bool ReadVector(vector<Point> &vertexList) {
  string srcFile = "Data/Vector/points.shp";

  GDALDataset *poDS = (GDALDataset *)GDALOpenEx(srcFile.c_str(), GDAL_OF_VECTOR,
                                                NULL, NULL, NULL);
  if (!poDS) {
    printf("无法读取该文件,请检查数据是否存在问题!");
    return false;
  }

  if (poDS->GetLayerCount() < 1) {
    printf("该文件的层数小于1,请检查数据是否存在问题!");
    return false;
  }

  for (int li = 0; li < poDS->GetLayerCount(); li++) {
    OGRLayer *poLayer = poDS->GetLayer(li);  //读取层
    poLayer->ResetReading();

    //遍历特征
    OGRFeature *poFeature = nullptr;
    while ((poFeature = poLayer->GetNextFeature()) != nullptr) {
      OGRGeometry *geometry = poFeature->GetGeometryRef();
      OGRwkbGeometryType geometryType = geometry->getGeometryType();

      switch (geometryType) {
        case wkbPoint:
        case wkbPointM:
        case wkbPointZM: {
          OGRPoint *ogrPoint = dynamic_cast<OGRPoint *>(geometry);
          if (ogrPoint) {
            vertexList.emplace_back(ogrPoint->getX(), ogrPoint->getY(), 0);
          }
          break;
        }
        case wkbMultiPoint:
        case wkbMultiPointM:
        case wkbMultiPointZM: {
          OGRMultiPoint *ogrMultiPoint =
              dynamic_cast<OGRMultiPoint *>(geometry);
          if (!ogrMultiPoint) {
            continue;
          }

          for (int gi = 0; gi < ogrMultiPoint->getNumGeometries(); gi++) {
            OGRPoint *ogrPoint =
                dynamic_cast<OGRPoint *>(ogrMultiPoint->getGeometryRef(gi));
            if (ogrPoint) {
              vertexList.emplace_back(ogrPoint->getX(), ogrPoint->getY(), 0);
            }
          }

          break;
        }
        default: {
          printf("未处理的特征类型\n");
          break;
        }
      }

      OGRFeature::DestroyFeature(poFeature);
    }
  }

  GDALClose(poDS);
  poDS = nullptr;

  return true;
}

bool WriteVector(const Delaunay &dt) {
  string dstFile = "Data/Out.shp";

  GDALDriver *driver =
      GetGDALDriverManager()->GetDriverByName("ESRI Shapefile");
  if (!driver) {
    printf("Get Driver ESRI Shapefile Error!\n");
    return false;
  }

  GDALDataset *dataset =
      driver->Create(dstFile.c_str(), 0, 0, 0, GDT_Unknown, NULL);
  OGRLayer *poLayer = dataset->CreateLayer("tin", NULL, wkbPolygon, NULL);

  //创建面要素
  for (const auto &f : dt.finite_face_handles()) {
    OGRFeature *poFeature = new OGRFeature(poLayer->GetLayerDefn());

    OGRLinearRing ogrring;
    for (int i = 0; i < 3; i++) {
      ogrring.setPoint(i, f->vertex(i)->point().x(), f->vertex(i)->point().y());
    }
    ogrring.closeRings();

    OGRPolygon polygon;
    polygon.addRing(&ogrring);
    poFeature->SetGeometry(&polygon);

    if (poLayer->CreateFeature(poFeature) != OGRERR_NONE) {
      printf("Failed to create feature in shapefile.\n");
      return false;
    }
  }

  //释放
  GDALClose(dataset);
  dataset = nullptr;

  return true;
}

int main() {
  GDALAllRegister();
  CPLSetConfigOption("GDAL_FILENAME_IS_UTF8", "NO");  //支持中文路径
  CPLSetConfigOption("SHAPE_ENCODING", "");           //解决中文乱码问题

  vector<Point> vertexList;
  ReadVector(vertexList);

  Delaunay dt(vertexList.begin(), vertexList.end());

  WriteVector(dt);
  return 0;
}

这里我们先从一个矢量中读取了离散点集,在QGIS中显示如下图4.21所示:

三角构网前的离散点集

在程序最后,将生成的Delaunay三角网输出成另外一个矢量文件,在QGIS中显示如下图4.22所示:

三角构网后的TIN

读取和写出比较好理解,关键是调用CGAL进行构建Delaunay三角网,其实相当简短:

typedef CGAL::Exact_predicates_inexact_constructions_kernel K;
typedef CGAL::Projection_traits_xy_3<K> Gt;
typedef CGAL::Delaunay_triangulation_2<Gt> Delaunay;
typedef K::Point_3 Point;

int main() {
{
  //...
  vector<Point> vertexList;
  //...
  Delaunay dt(vertexList.begin(), vertexList.end());
  //...
}

CGAL大量应用了C++的模板(泛型)技术,因而使用的接口比较抽象可能难以理解,这里可以解释一下CGAL的设计逻辑。学过任何一门编程语言的都知道,浮点型数值的相等判断不能直接使用相等运算符;正确的做法是使用两者相减的绝对值与容差进行判断,因为计算机表达的浮点型是个近似值。计算几何的核心问题正在于此,内置数据类型的精度是有限的,处理容差是非常麻烦的事情。所以数值需要更为精确的表达,比如0.5就应该就是0.5,不能是0.49999999。因此CGAL确定了一个Kernel(核)的概念,通过模板来控制不同精度。

这里的
typedef CGAL::Exact_predicates_inexact_constructions_kernel K;
表示精确谓词,但不精确构造的内核。predicates(谓词)表示一个操作;(constructions)构造意味着会有新的数值对象作为结果,如果算法是一个不进行构造的算法中,就可以使用精确谓词但不精确构造的内核。比如这里的构建Delaunay三角网,并没有新的点对象生成出来,只是对点集进行了组织,点还是原来哪些点,并没有变化。

另外,
typedef K::Point_3 Point;
表示我们使用该精度下的内置三维点类型。但是另外一个问题在于,如果我们需要定义三个维度中的哪两个维度数值参与构网计算,或者使用自定义数据结构该怎么办呢?所以可以传入Traits类型,这其实是C++的模板中的traits技术,描述了传入数据的数值特性:比如类型,排序,方向测试或者相等判断等。每个Kernel中都有定义好的Traits类型,这里使用的就是
typedef CGAL::Projection_traits_xy_3<K> Gt;
,使用点的xy值参与构网计算。最后将该类型作为模板参数传入到Delaunay三角网构建类中:
typedef CGAL::Delaunay_triangulation_2<Gt> Delaunay;

上述的解析读者如果没有一定的C++模板知识的基础,肯定看的云里雾里。其实不要紧,笔者也只是希望大家能够理解CGAL如此设计接口的内在逻辑,并不是故意设计的如此抽象和繁琐,而是希望最大程度的保证精度和性能。更多更具体的解析,读者可以参看CGAL文档。对C++模板知识不熟悉的初学者,建议直接参考文档中的给出的实例,在实际使用过程中逐渐增加自己的认识。

本文分享自华为云社区《
KubeEdge v1.15.0发布!新增Windows 边缘节点支持,基于物模型的设备管理,DMI 数据面支持等功能
》,作者:云容器大未来 。

北京时间2023年10月13日,KubeEdge 发布 v1.15.0 版本。新版本新增多个增强功能,在边缘节点管理、边缘应用管理、边缘设备管理等方面均有大幅提升。

kubeedge.png

KubeEdge v1.15.0 新增特性:

  • 支持 Windows 边缘节点
  • 基于物模型的新版本设备管理 API v1beta1发布
  • 承载 DMI 数据面的 Mapper 自定义开发框架 Mapper-Framework 发布
  • 支持边缘节点运行静态 Pod
  • 支持更多的 Kubernetes 原生插件运行在边缘节点

新特性概览

▍支持 Windows 边缘节点

随着边缘计算应用场景的不断拓展,涉及到的设备类型也越来越多,其中包括很多基于Windows 操作系统的传感器、摄像头和工控设备等,因此新版本的KubeEdge 支持在 Windows 上运行边缘节点,覆盖更多的使用场景。

在 v1.15.0 版本中,KubeEdge 支持边缘节点运行在 Windows Server 2019,并且支持 Windows 容器运行在边缘节点上,将 KubeEdge 的使用场景成功拓展到 Windows 生态。

Windows 版本的 EdgeCore 配置新增了 windowsPriorityClass 字段,默认为NORMAL_PRIORITY_CLASS。用户可以在 Windows 边缘主机上下载 Windows 版本的 EdgeCore 安装包[1],解压后执行如下命令即可完成 Windows 边缘节点的注册与接入,用户可以通过在云端执行 kubectl get nodes 确认边缘节点的状态,并管理边缘 Windows 应用。

edgecore.exe --defaultconfig >edgecore.yaml
edgecore.exe
--config edgecore.yaml

更多信息可参考:

https://github.com/kubeedge/kubeedge/pull/4914

https://github.com/kubeedge/kubeedge/pull/4967

▍基于物模型的新版本设备管理 API v1beta1 发布

v1.15.0 版本中,基于物模型的设备管理 API,包括 Device Model 与 Device Instance,从 v1alpha2 升级到了 v1beta1,新增了边缘设备数据处理相关等的配置,北向设备 API 结合南向的 DMI 接口,实现设备数据处理,API 的主要更新包括:

  • Device Model 中按物模型标准新增了设备属性描述、设备属性类型、设备属性取值范围、设备属性单位等字段。
//ModelProperty describes an individual device property / attribute like temperature / humidity etc.
type ModelProperty struct{//Required: The device property name.
   Name string `json:"name,omitempty"`//The device property description.//+optional
   Description string `json:"description,omitempty"`//Required: Type of device property, ENUM: INT,FLOAT,DOUBLE,STRING,BOOLEAN,BYTES
   Type PropertyType `json:"type,omitempty"`//Required: Access mode of property, ReadWrite or ReadOnly.
   AccessMode PropertyAccessMode `json:"accessMode,omitempty"`//+optional
   Minimum string `json:"minimum,omitempty"`//+optional
   Maximum string `json:"maximum,omitempty"`//The unit of the property//+optional
   Unit string `json:"unit,omitempty"`
}
  • Device Instance 中内置的协议配置全部移除,包括 Modbus、Opc-UA、Bluetooth 等。用户可以通过可扩展的 Protocol 配置来设置自己的协议,以实现任何协议的设备接入。Modbus、Opc-UA、Bluetooth 等内置协议的 Mapper 不会从 mappers-go 仓库移除,并且会更新到对应的最新版本,且一直维护。
type ProtocolConfig struct{//Unique protocol name//Required.
   ProtocolName string `json:"protocolName,omitempty"`//Any config data//+optional//+kubebuilder:validation:XPreserveUnknownFields
   ConfigData *CustomizedValue `json:"configData,omitempty"`
}

type CustomizedValue
struct{
Data map[
string]interface{} `json:"-"`
}
  • 在 Device Instance 的设备属性中增加了数据处理的相关配置,包括设备上报频率、收集数据频率、属性是否上报云端、推送到边缘数据库等字段,数据的处理将在 Mapper 中进行。
type DeviceProperty struct{
......
//Define how frequent mapper will report the value.//+optional ReportCycle int64 `json:"reportCycle,omitempty"`//Define how frequent mapper will collect from device.//+optional CollectCycle int64 `json:"collectCycle,omitempty"`//whether be reported to the cloud ReportToCloud bool `json:"reportToCloud,omitempty"`//PushMethod represents the protocol used to push data,//please ensure that the mapper can access the destination address.//+optional PushMethod *PushMethod `json:"pushMethod,omitempty"`
}

更多信息可参考:

https://github.com/kubeedge/kubeedge/pull/4999

https://github.com/kubeedge/kubeedge/pull/4983

▍承载 DMI 数据面的 Mapper 自定义开发框架 Mapper-Framework 发布

v1.15.0 版本中,对 DMI 数据面部分提供了支持,主要承载在南向的 Mapper 开发框架 Mapper-Framework中。Mapper-Framework 提供了全新的 Mapper 自动生成框架,框架中集成了 DMI 设备数据管理(数据面)能力,允许设备在边缘端或云端处理数据,提升了设备数据管理的灵活性。Mapper-Framework 能够自动生成用户的 Mapper 工程,简化用户设计实现 Mapper 的复杂度,提升 Mapper 的开发效率。

  • DMI 设备数据面管理能力支持

v1.15.0 版本 DMI 提供了数据面能力的支持,增强边缘端处理设备数据的能力。设备数据在边缘端可以按配置直接被推送至用户数据库或者用户应用,也可以通过云边通道上报至云端,用户也可以通过 API 主动拉取设备数据。设备数据管理方式更加多样化,解决了 Mapper 频繁向云端上报设备数据,易造成云边通信阻塞的问题,能够减轻云边通信的数据量,降低云边通信阻塞的风险。DMI 数据面系统架构如下图所示:

  • Mapper 自动生成框架 Mapper-Framework

v1.15.0 版本提出全新的 Mapper 自动生成框架 Mapper-Framework。框架中已经集成 Mapper 向云端注册、云端向 Mapper 下发 Device Model 与 Device Instance 配置信息、设备数据传输上报等功能,大大简化用户设计实现 Mapper 的开发工作,便于用户体验 KubeEdge 边缘计算平台带来的云原生设备管理体验。

更多信息可参考:
https://github.com/kubeedge/kubeedge/pull/5023

▍支持边缘节点运行 Kubernetes 静态 Pod

新版本的 KubeEdge 支持了 Kubernetes 原生静态 Pod 能力,与 Kubernetes 中操作方式一致,用户可以在边缘主机的指定目录中,以 JSON 或者 YAML 的形式写入 Pod 的 Manifests 文件,Edged 会监控这个目录下的文件来创建/删除边缘静态 Pod,并在集群中创建镜像 Pod。

静态 Pod 默认目录是 /etc/kubeedge/manifests,您也可以通过修改 EdgeCore 配置的 staticPodPath 字段来指定目录。

更多信息可参考:
https://github.com/kubeedge/kubeedge/pull/4825

▍支持更多的 Kubernetes 原生插件运行在边缘节点

v1.15.0 版本的 KubeEdge 支持更多原生插件在边缘节点上运行。KubeEdge 提供了高扩展性的 Kubernetes 原生非资源类 API 透传框架,满足了原生插件对此类 API 的依赖。插件可以从边缘节点的 MetaServer 中获取集群 version 等信息,MetaServer 将对请求进行数据缓存,保证边缘节点网络中断时仍能正常服务。

当前框架下,社区开发者将更容易的开放更多非资源类 API。开发者只需关注插件依赖的 API,而不需要考虑请求如何传递至边缘节点。

更多信息可参考:
https://github.com/kubeedge/kubeedge/pull/4904

▍升级 Kubernetes 依赖到 v1.26

新版本将依赖的 Kubernetes 版本升级到 v1.26.7,您可以在云和边缘使用新版本的特性。

更多信息可参考:
https://github.com/kubeedge/kubeedge/pull/4929

升级注意事项

  • 新版本 v1beta1 的 Device API不兼容 v1alpha1 版本,如果您需要在 KubeEdge v1.15.0 中使用设备管理特性,您需要更新 Device API 的 yaml 配置。

  • 如果您使用 containerd 作为边缘容器运行时,您需要将 containerd 版本升级到 v1.6.0 或者更高版本,KubeEdge v1.15.0 不再支持 containerd 1.5 以及更早的版本。

    参考:
    https://kubernetes.io/blog/2022/11/18/upcoming-changes-in-kubernetes-1-26/#cri-api-removal

  • 在 KubeEdge v1.14 中,EdgeCore 已经移除了对 dockershim 的支持,边缘运行时仅支持 remote 类型,并且使用 containerd 作为默认运行时。如果您想要继续使用 docker 作为边缘运行时,您需要安装 cri-dockerd,并且在启动 EdgeCore 过程中,设置 runtimeType=remote 以及 remote-runtime-endpoint=unix:///var/run/cri-dockerd.sock。

    参考:
    https://github.com/kubeedge/kubeedge/issues/4843

▍致谢

感谢 KubeEdge 社区技术指导委员会( TSC )、各 SIG 成员对 v1.15.0 版本开发的支持与贡献,未来 KubeEdge 将持续在新场景探索与支持、稳定性、安全性、可扩展性等方面持续发展与演进!

▍相关链接

[1] Windows 版本 EdgeCore 安装包:

https://github.com/kubeedge/kubeedge/releases/download/v1.15.0/kubeedge-v1.15.0-windows-amd64.tar.gz

点击关注,第一时间了解华为云新鲜技术~

我在业余时间开发维护了一款免费开源的升讯威在线客服系统,也收获了许多用户。对我来说,只要能获得用户的认可,就是我最大的动力。

最近客服系统成功经受住了客户现场组织的
压力测试
,获得了客户的认可。
客户组织多名客服上线后,所有员工
同一时间
打开访客页面
疯狂不停
的给在线客服发消息,系统稳定
无异常无掉线
,客服回复消息正常。消息
实时到达
无任何延迟。

https://kf.shengxunwei.com/


我会通过一系列的文章详细分析升讯威在线客服系统的并发高性能技术是如何实现的,使用了哪些方案以及具体的做法。本文将介绍如何为多线程处理同步数据。

先看实现效果

客服端

访客端

为多线程处理同步数据

多个线程可以调用单个对象的属性和方法时,对这些调用进行同步处理是非常重要的。 否则,一个线程可能会中断另一个线程正在执行的任务,可能使该对象处于无效状态。 其成员不受这类中断影响的类叫做线程安全类。

  • .NET 提供了几种策略,用于同步对实例和静态成员的访问:

  • 同步代码区域。 可以使用 Monitor 类或此类的编译器支持,仅同步需要它的代码块,从而提升性能。

  • 手动同步。 可以使用 .NET 类库提供的同步对象。 请参阅同步基元概述,其中介绍了 Monitor 类。

  • 同步上下文。 仅对于 .NET Framework 和 Xamarin 应用程序,你可以使用 SynchronizationAttribute 为 ContextBoundObject 对象启用简单的自动同步。

  • System.Collections.Concurrent 命名空间中的集合类。 这些类提供了内置的同步添加和删除操作。 有关详细信息,请参阅线程安全集合。

公共语言运行时提供一个线程模型,在该模型中,类分为多种类别,这些类别可以根据要求以各种不同的方式进行同步。 下表显示了为具有给定同步类别的字段和方法提供的同步支持。

同步代码区域

可以使用 Monitor 类或编译器关键字,同步代码块、实例方法和静态方法。 不支持同步静态字段。

Visual Basic 和 C# 都支持使用特定语言关键字标记代码块,在 C# 中使用的是 lock 语句,在 Visual Basic 中使用的是 SyncLock 语句。 由线程执行代码时,会尝试获取锁。 如果该锁已由其他线程获取,则在锁变为可用状态之前,该线程一直处于阻止状态。 线程退出同步代码块时,锁会被释放,与线程的退出方式无关。

由于 lock 和 SyncLock 语句是使用 Monitor.Enter 和 Monitor.Exit 实现,因此 Monitor 的其他方法可以在同步区域内与它们结合使用。

还可以使用值为 MethodImplOptions.Synchronized 的 MethodImplAttribute 修饰方法,其效果和使用 Monitor 或其中一个编译器关键字锁定整个方法正文相同。

Thread.Interrupt 可用于中断对线程执行阻止操作(如等待访问同步代码区域)。 Thread.Interrupt 还用于中断对线程执行 Thread.Sleep 等操作。

Visual Basic 和 C# 均支持使用 Monitor.Enter 和 Monitor.Exit 锁定对象的语言关键字。

在这两种情况下,如果代码块中引发异常,则 lock 或 SyncLock 获取的锁将自动释放。 C# 和 Visual Basic 编译器在发出 try/finally 块时,在 try 的起始处使用 Monitor.Enter,在 finally 块中使用 Monitor.Exit。 如果 lock 或 SyncLock 块内部引发了异常,则会运行 finally 处理程序,从而允许执行任何清除工作。

WaitHandle 类和轻量同步类型

多个 .NET 同步基元派生自 System.Threading.WaitHandle 类,该类会封装本机操作系统同步句柄并将信号机制用于线程交互。 这些类包括:

  • System.Threading.Mutex,授予对共享资源的独占访问权限。 如果没有任何线程拥有它,则 mutex 将处于已发出信号状态。

  • System.Threading.Semaphore,限制可同时访问某一共享资源或资源池的线程数。 当信号量计数大于零时,会将信号量的状态设置为已发出信号;当信号量计数为零时,会将信号量的状态设置为未发出信号。

  • System.Threading.EventWaitHandle,表示线程同步事件,可以处于已发出信号状态或未发出信号状态。

  • System.Threading.AutoResetEvent,派生自 EventWaitHandle,当发出信号时,会在发布单个等待线程后自动重置为未发出信号状态。

  • System.Threading.ManualResetEvent,派生自 EventWaitHandle,当发出信号时,会保持已发出信号状态,直到调用 Reset 方法。

在 .NET Framework 中,由于 WaitHandle 派生自 System.MarshalByRefObject,因此,这些类型可用于跨应用程序域边界同步线程的活动。

轻量同步类型不依赖于基础操作系统句柄,通常会提供更好的性能。 但是,它们不能用于进程间同步。 将这些类型用于一个应用程序中的线程同步。

其中的一些类型是派生自 WaitHandle 的类型的替代项。 例如,SemaphoreSlim 是 Semaphore 的轻量替代项。

同步对共享资源的访问

System.Threading.Monitor 类通过获取或释放用于标识资源的对象上的 lock 来授予对共享资源的相互独占访问权限。 持有 lock 时,持有 lock 的线程可以再次获取并释放 lock。 阻止任何其他线程获取 lock,Monitor.Enter 方法等待释放 lock。 Enter 方法可获取释放的 lock。 还可以使用 Monitor.TryEnter 方法指定线程尝试获取 lock 的持续时间。 由于 Monitor 类具有线程关联,因此获取了 lock 的线程必须通过调用 Monitor.Exit 方法来释放 lock。

可以通过使用 Monitor.Wait、Monitor.Pulse 和 Monitor.PulseAll 方法来协调用于获取同一对象上的 lock 的线程的交互。

System.Threading.Mutex 类(与 Monitor 类似),授予对共享资源的独占访问权限。 使用 Mutex.WaitOne 方法重载之一请求 mutex 的所有权。 Mutex(与 Monitor 类似)具有线程关联,并且已获取 mutex 的线程必须通过调用 Mutex.ReleaseMutex 方法来释放它。

Mutex 类(与 Monitor 不同)可用于进程间同步。 为此,请使用命名 mutex,它在整个操作系统中都可见。 若要创建命名 mutex 实例,请使用指定了名称的 Mutex 构造函数。 还可以调用 Mutex.OpenExisting 方法来打开现有的命名系统 mutex。


简介

升讯威在线客服与营销系统是一款客服软件,但更重要的是一款营销利器。

https://kf.shengxunwei.com/

  • 可以追踪正在访问网站或使用 APP 的所有访客,收集他们的浏览情况,使客服能够主动出击,施展话术,促进成单。
    访* 客端在 PC 支持所有新老浏览器。包括不支持 WebSocket 的 IE8 也能正常使用。
  • 移动端支持所有手机浏览器、APP、各大平台的公众号对接。
  • 支持访客信息互通,可传输访客标识、名称和其它任意信息到客服系统。
  • 具备一线专业技术水平,网络中断,拔掉网线,手机飞行模式,不丢消息。同类软件可以按视频方式对比测试。

希望能够打造:
开放、开源、共享。努力打造 .net 社区的一款优秀开源产品。

钟意的话请给个赞支持一下吧,谢谢~