2.5 图像序列 Image Sequence


2.5 图像序列(Image Sequence)

本节摘要:GIF 等动图本质是"图像序列"——多帧按时间顺序播放。本节用 seek/tell 遍历帧、逐帧处理、合成新动画,讲解帧数与持续时间的控制,以及 GIF 的调色板限制(最多 256 色),并演示"GIF 转长图""逐帧加水印"两个实战。

读前必看

阅读完本节,你应当能够:

  1. 用 seek/tell 遍历 GIF 的每一帧
  2. 获取帧数、帧尺寸、帧持续时间
  3. 逐帧处理并保存为新 GIF
  4. 理解 GIF 的 256 色调色板限制
  5. 完成"GIF 转长图"与"逐帧水印"实战

问题与直觉:GIF 不是一张图,是一叠图

很多人以为 GIF 是一张"会动的图片",其实它是一叠静止帧按时间顺序播放,就像翻页动画。Pillow 打开 GIF 时,默认只加载第一帧——想看其他帧,要像"翻书"一样一页页翻。

from PIL import Image gif = Image.open("animation.gif") # 当前帧位置(从 0 开始) print("当前帧:", gif.tell())

tell() 返回当前帧索引,seek(n) 跳到第 n 帧——这就是"翻书"的两个把手。

核心原理:帧遍历三件套

1. n_frames——总帧数

print("总帧数:", gif.n_frames)

n_frames 属性告诉你有多少页"书页"。注意不是所有格式都支持(静态图 n_frames=1)。

2. seek——跳转帧

gif.seek(0) # 第一帧 gif.seek(5) # 第六帧

seek 后,Image 对象就代表那一帧的内容。seek 会改变 Image 对象本身(原地切换帧),处理完记得保存副本或继续 seek。

3. 完整遍历模式

from PIL import Image gif = Image.open("animation.gif") frames = [] try: while True: frames.append(gif.copy()) # copy 保存当前帧 gif.seek(gif.tell() + 1) # 跳下一帧 except EOFError: pass # 到底了 print(f"共提取 {len(frames)} 帧")

while True + EOFError 捕获帧尾是标准遍历模式。每一帧都要 copy——因为 seek 会覆盖 Image 对象内容,不 copy 就只剩最后一帧。

💡 关键直觉:GIF 是"懒加载 + 增量帧"。很多 GIF 的帧只存"变化的部分"(帧差),seek 时需要叠加之前帧才能得到完整画面。copy 当前 seek 到的帧会自动完成叠加,所以遍历时务必 copy。

工程实践要点:两个实战

实战一:GIF 转长图(拼贴所有帧)

from PIL import Image gif = Image.open("animation.gif") frames = [] try: while True: frames.append(gif.convert("RGB").copy()) gif.seek(gif.tell() + 1) except EOFError: pass # 横向拼接成长图 w, h = frames[0].size canvas = Image.new("RGB", (w * len(frames), h), "white") for i, frame in enumerate(frames): canvas.paste(frame, (i * w, 0)) canvas.save("gif_filmstrip.png") print(f"已拼接 {len(frames)} 帧为长图")

"胶片条"效果适合快速预览 GIF 的全部内容,也是"GIF 转静态长图"的常用方案。

实战二:逐帧加水印

from PIL import Image, ImageDraw, ImageFont gif = Image.open("animation.gif") frames = [] font = ImageFont.truetype("C:/Windows/Fonts/arial.ttf", 24) try: idx = 0 while True: frame = gif.convert("RGBA").copy() draw = ImageDraw.Draw(frame) draw.text((10, 10), f"frame {idx}", fill=(255, 0, 0, 200), font=font) frames.append(frame.convert("P")) idx += 1 gif.seek(gif.tell() + 1) except EOFError: pass # 保存为动画 frames[0].save( "watermarked.gif", save_all=True, append_images=frames[1:], duration=100, loop=0, ) print(f"已生成 {len(frames)} 帧的水印动图")

保存动画的关键参数:save_all=True(保存所有帧)、append_images(后续帧列表)、duration(每帧毫秒数)、loop(循环次数,0 无限)。

⚠️ 常见坑 1:GIF 只有 256 色。处理时转 RGB/RGBA 可以自由操作,但保存回 GIF 时必须转回 P 模式(调色板),否则报错或颜色失真。convert("P") 会用默认调色板量化颜色。

⚠️ 常见坑 2:seek 边界。seek 超过最后一帧抛 EOFError,这是"正常结束"的信号,不是 bug。用 try/except EOFError 处理,不要用 for 循环猜帧数。

动手实验:帧信息体检

from PIL import Image gif = Image.open("animation.gif") print(f"总帧数: {gif.n_frames}") print(f"帧尺寸: {gif.size}") print(f"模式: {gif.mode}") for i in range(gif.n_frames): gif.seek(i) dur = gif.info.get("duration", 100) print(f"帧 {i}: 尺寸 {gif.size} 时长 {dur}ms")

这个"体检"让你一眼看清 GIF 的结构:几帧、多大、多快。处理 GIF 前先体检,能避免"帧数不对""时长不对"的困惑

进阶:GIF 转视频帧序列

视频处理前常把 GIF 拆成帧序列,或把帧序列合成 GIF:

from PIL import Image import os # 把 GIF 拆成 PNG 帧序列 gif = Image.open("animation.gif") os.makedirs("frames", exist_ok=True) try: i = 0 while True: frame = gif.convert("RGB").copy() frame.save(f"frames/frame_{i:04d}.png") i += 1 gif.seek(gif.tell() + 1) except EOFError: pass print(f"已导出 {i} 帧")

拆帧→逐帧处理→合成是动图处理的三段式:任何"对动画做处理"的需求(加水印、调色、加滤镜)都按这个流程走。

实战:从帧合成"动画 GIF"

把静态帧序列合成动画(如把截图拼成演示动画):

from PIL import Image import os # 读取帧序列 frames = [] for fname in sorted(os.listdir("frames")): if fname.endswith(".png"): frames.append(Image.open(os.path.join("frames", fname))) # 统一尺寸 + 转 P 模式 if frames: w, h = frames[0].size frames = [f.resize((w, h)) if f.size != (w, h) else f for f in frames] frames = [f.convert("P") for f in frames] frames[0].save( "output.gif", save_all=True, append_images=frames[1:], duration=200, loop=0, optimize=True, ) print(f"已生成 {len(frames)} 帧动画")

合成动画的四个关键参数:save_all、append_images、duration、loop。这是"截图做教程动图"的完整方案。

💡 关键直觉:GIF 调色板是"共享的"。所有帧共用同一个 256 色调色板,颜色丰富的动图转 GIF 会明显失真。要做高质量动图,考虑 WebP 动图(第三章 3.4 有介绍)。

FAQ:图像序列高频问题

问:GIF 打开只有第一帧?
正常。GIF 是惰性加载,seek 才切换帧。想拿全部帧必须遍历(while + seek + copy)。

问:合成 GIF 时帧尺寸不一样报错?
GIF 要求所有帧同尺寸。合成前统一 resize 到第一帧尺寸(或指定统一尺寸)。透明帧还要统一模式。

问:duration 单位是什么?
毫秒。duration=100 表示每帧显示 0.1 秒。常见动画 80-200ms,GIF 兼容性的最低建议不小于 20ms。

问:怎么控制 GIF 的循环次数?
loop=0 无限循环,loop=5 循环 5 次后停止。注意:部分查看器对"有限循环"支持不统一,无限循环最通用。

问:能不能处理 WebP 动图?
可以。Pillow 对 WebP 动图支持 seek/n_frames(与 GIF 相同 API),且支持真彩色、体积更小。动图场景优先 WebP。

动手演练:动图逐帧风格化

对动图的每个帧应用风格化处理,生成"滤镜版动画":

from PIL import Image, ImageFilter, ImageEnhance def apply_to_animation(src, dst, style_fn, duration=100): """把 style_fn 应用到动图的每一帧""" img = Image.open(src) frames = [] try: while True: frame = img.convert("RGB").copy() frames.append(style_fn(frame).convert("P")) img.seek(img.tell() + 1) except EOFError: pass frames[0].save(dst, save_all=True, append_images=frames[1:], duration=duration, loop=0) # 风格:素描风 def sketch_style(frame): gray = frame.convert("L") edges = gray.filter(ImageFilter.FIND_EDGES) return edges.point(lambda p: 255 - p) # 风格:复古风 def retro_style(frame): frame = ImageEnhance.Color(frame).enhance(0.6) frame = ImageEnhance.Contrast(frame).enhance(0.85) return frame apply_to_animation("anim.gif", "anim_sketch.gif", sketch_style) apply_to_animation("anim.gif", "anim_retro.gif", retro_style)

动图风格化 = 逐帧应用静态处理。把"单帧处理函数"(style_fn)抽象出来,任意静态滤镜/调色都能无缝应用到动图——这就是"帧处理框架"的雏形。

进阶:GIF 的帧差与优化原理

理解 GIF 的文件结构,能解释很多"奇怪现象":

帧差存储:GIF 支持"增量帧"——不是每帧存完整画面,而是只存与上一帧不同的区域。这大幅减小了体积,但也带来两个影响:

  1. seek 需要叠加:Pillow 的 seek 会自动叠加帧差得到完整画面,所以遍历时要 copy
  2. 透明优化:GIF 用"透明索引"表示不变区域,背景透明 GIF 每帧可能只更新局部

体积优化建议

# 保存 GIF 时用 optimize 自动优化帧差 frames[0].save("optimized.gif", save_all=True, append_images=frames[1:], duration=100, loop=0, optimize=True) # 颜色越少体积越小:转 P 前先降色 small = img.convert("P", palette=Image.ADAPTIVE, colors=64)

💡 关键直觉:GIF 体积 = 帧数 × 每帧内容。帧差优化(optimize)减少"每帧内容",颜色量化(colors=64)减少"每帧数据量"——两个方向同时用,体积能大幅下降。

实战:生成"逐帧加载进度"动图

用图像序列做实际应用——生成一个"加载进度"动画:

from PIL import Image, ImageDraw import math def make_loading_animation(frames=24, size=(200, 200)): """生成一个旋转加载圈的动图""" frames_out = [] for i in range(frames): img = Image.new("RGBA", size, (0, 0, 0, 0)) draw = ImageDraw.Draw(img) cx, cy = 100, 100 radius, dot_r = 60, 10 for j in range(8): angle = j * 45 + i * 15 x = cx + radius * math.cos(math.radians(angle)) y = cy + radius * math.sin(math.radians(angle)) alpha = 255 if j == (i // 3) % 8 else 60 draw.ellipse([x-dot_r, y-dot_r, x+dot_r, y+dot_r], fill=(90, 140, 255, alpha)) frames_out.append(img.convert("P")) return frames_out frames = make_loading_animation(24) frames[0].save("loading.gif", save_all=True, append_images=frames[1:], duration=80, loop=0, optimize=True)

"程序生成动图"的思路:不用现成素材,用绘图函数按帧计算图形(位置随帧号变化)——"帧是参数,函数生成画面"。这个模式可以做进度圈、加载条、粒子动画等各种程序化动图。

序列处理的应用场景总览

场景 序列操作 说明
GIF 转长图 遍历+拼接 预览全部帧
逐帧水印 遍历+绘图 版权保护
动图风格化 遍历+滤镜 批量风格统一
帧抽取 按步长 seek 采样关键帧
转视频 拆帧+ffmpeg 配合外部工具
程序化动画 函数生成帧 无需素材

序列处理的通用框架:"遍历帧 → 逐帧处理 → 合成输出"。只要"逐帧处理函数"是通用的,任何静态处理都能应用到动图——这是图像序列处理最重要的复用模式。

动手演练:帧抽取与关键帧

长动图中"抽取关键帧"是常用需求(视频预览、动画分析):

def extract_keyframes(src, interval=5, out_prefix="frame"): """按间隔抽取帧(相当于视频抽帧)""" img = Image.open(src) frames = [] try: idx = 0 while True: if idx % interval == 0: frames.append(img.convert("RGB").copy()) idx += 1 img.seek(img.tell() + 1) except EOFError: pass for i, f in enumerate(frames): f.save(f"{out_prefix}_{i:03d}.jpg", quality=90) print(f"从 {idx} 帧中抽取 {len(frames)} 帧") return frames extract_keyframes("long_anim.gif", interval=6)

抽帧的价值:动画分析、生成"帧预览表"、减少处理量。"按步长取样"是处理大数据集(帧/图片/视频)的通用降采样技巧——信息有损失但可控,换来的是性能。

进阶:WebP 动图与现代格式

GIF 的 256 色限制让它难以承载高质量动图。WebP 动图是更好的选择:

def convert_gif_to_webp(src, dst, quality=85): """GIF → WebP 动图(真彩色、体积更小)""" img = Image.open(src) frames = [] try: while True: frames.append(img.convert("RGB").copy()) img.seek(img.tell() + 1) except EOFError: pass frames[0].save(dst, "WEBP", save_all=True, append_images=frames[1:], duration=100, loop=0, quality=quality) convert_gif_to_webp("anim.gif", "anim.webp")

WebP 动图 vs GIF

维度 GIF WebP 动图
颜色 256 色 真彩色 24bit
体积 小 30-50%
兼容 最广 现代浏览器
透明 支持 支持
帧压缩 帧差 帧差+有损

选型建议:现代 Web 优先 WebP 动图,老环境 fallback GIF。**"WebP 动图 + GIF 兜底"**是 Web 动图的标准双格式策略。

序列处理的实际项目:GIF 压缩器

GIF 文件往往很大,做一个"GIF 压缩器"实战:

import os def compress_gif(src, dst, scale=0.5, colors=128, duration=None): """GIF 压缩:缩小 + 降色 + 可选调速""" img = Image.open(src) orig_duration = img.info.get("duration", 100) duration = duration or orig_duration frames = [] try: while True: frame = img.convert("RGB").copy() if scale < 1: new_size = (int(frame.width * scale), int(frame.height * scale)) frame = frame.resize(new_size, Image.Resampling.BILINEAR) frame = frame.convert("P", palette=Image.ADAPTIVE, colors=colors) frames.append(frame) img.seek(img.tell() + 1) except EOFError: pass frames[0].save(dst, save_all=True, append_images=frames[1:], duration=duration, loop=0, optimize=True) src_size = os.path.getsize(src) dst_size = os.path.getsize(dst) print(f"压缩: {src_size//1024}KB → {dst_size//1024}KB " f"({100 - dst_size/src_size*100:.0f}% 减小)") compress_gif("big.gif", "small.gif", scale=0.5, colors=128)

GIF 压缩三手段:缩小(scale)、降色(colors)、帧差优化(optimize)。"体积 = 帧数 × 每帧内容 × 每帧色彩"——三个维度都能压缩。这个工具可以直接用于"上传 GIF 自动压缩"场景。

序列处理的常见误区

误区 正确认知
"GIF 是视频" 是帧序列,不是视频
"seek 后直接处理" seek 切换帧,处理前要 copy
"保存 GIF 自动动" 要 save_all + append_images
"GIF 颜色无损" 只有 256 色,必量化
"所有动图都是 GIF" WebP 动图更优

误区根源:把"动图"当"一种文件"而非"帧序列的容器"。理解"动图 = 帧序列 + 播放参数"(duration/loop),序列处理就通了——GIF、WebP 动图、TIFF 多帧都是同一套逻辑。

本节小结:图像序列处理是"动图思维"的起点——理解帧、时长、循环、调色板四个要素,就能自由处理 GIF、WebP 动图与多帧 TIFF。无论处理什么动图,请记住:先体检(帧数/时长/尺寸)、再遍历(seek+copy)、后合成(save_all+duration+loop)。

序列处理的项目实战:动画相册

把图像序列技术用于"动画相册"——把多张照片按时间线串成动图:

def photo_album_animation(photos, out_path, duration=500): """把多张照片串成幻灯片动画""" frames = [] for path in photos: with Image.open(path) as img: # 统一尺寸 img = ImageOps.fit(img, (800, 600)) frames.append(img.convert("P")) # 合成动画 frames[0].save(out_path, save_all=True, append_images=frames[1:], duration=duration, loop=0, optimize=True) print(f"已生成 {len(frames)} 帧的幻灯片动画") photos = ["photo1.jpg", "photo2.jpg", "photo3.jpg", "photo4.jpg"] photo_album_animation(photos, "album.gif", duration=800)

幻灯片动画的价值:照片回顾、演示轮播、相册分享——把"静态照片"变成"动态展示"。这个实战展示了序列技术(遍历+统一尺寸+合成)在真实产品中的应用,也再次验证了"拆帧→逐帧处理→合成"三段式的通用性。

序列处理的完整知识地图

把图像序列处理的知识组织成一张"决策地图":

决策逻辑:先想"要处理动图的什么"(预览/处理/抽取/生成/压缩),再选对应方案。"需求→方案"的映射让序列处理从"背 API"变"选工具"——遇到任何动图需求,都能快速定位到正确的处理方法。

序列处理的常见误区

序列处理完整知识地图

把图像序列处理的知识组织成"决策地图":

一节小结

  • GIF=帧序列:翻页动画,默认只加载第一帧
  • seek/tell/n_frames:翻页三件套
  • 遍历模式:while True + EOFError + copy
  • 每帧必须 copy:seek 覆盖对象内容
  • 256 色限制:保存回 GIF 转 P 模式
  • 保存动画:save_all + append_images + duration + loop
  • 三段式流程:拆帧→逐帧处理→合成
  • 动图风格化:抽象 style_fn 逐帧应用
  • 帧差存储:optimize 优化体积
  • 程序化动画:帧是参数,函数生成画面
  • 帧抽取:按步长采样关键帧
  • WebP 动图:真彩色小体积,现代 Web 优先
  • GIF 压缩器:缩小+降色+优化三维压缩
  • 逐帧分析:先分析再处理
  • 通用框架:遍历+逐帧处理+合成
  • GIF 调色板共享:颜色丰富动图优先 WebP
  • 先体检再处理:看帧数/时长/尺寸

动图也会处理了!下一节把几何变换(裁剪旋转缩放)再往深做——插值算法、保持质量、批量策略,为实战章热身。

练习提示:学完本节后,建议用程序化动画的方法(make_loading_animation)做一个"加载进度圈"动图,把 8 个圆点改成 12 个、颜色换成品牌色。亲手改一次参数并观察效果,你会理解"帧是参数,函数生成画面"的核心思想。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U