☰
Python生成GIF全攻略:Pillow、imageio与OpenCV实战
2026/9/29 12:45:57 网站建设 项目流程

先交代一下这篇内容的起因。前阵子做一份数据分析报告,需要把一个三分半的操作演示视频转成GIF发给远程同事,录屏软件自带的导出功能生成的文件足有30多MB,在线压缩工具又限制在10MB,压完以后画质完全没法看。折腾半个多小时之后我干脆关掉所有网页,打开编辑器用Python自己写了一套GIF生成脚本。这篇文章就是那套脚本的完整复盘,内容包括Pillow与imageio的源码实现、视频抽帧、画质调优,还有几个我实际踩过的坑。如果你也经常要批量做动图,或者想把动图自动化嵌入到自己的工作流里,这篇应该对你有用。

1. 为什么我最后选了Python方案来做GIF

1.1 从一次30MB的演示动图说起

当时我需要的只是一个“能看清关键操作步骤”的演示动图,录屏软件输出的GIF体积却大得离谱。在线压缩网站试了三家,要么限制上传大小,要么压缩完文字边缘全是锯齿,箭头的颜色都变了。更麻烦的是,隔两天又要换一组截图重新做一次动图,难道每次都要手动打开工具再调一遍参数吗?

这时候Python的优势就显现出来了。它不需要图形界面,不需要重复点击,只需要把“读图片→拼帧→设置帧间隔→导出”这条链路写成脚本,以后换任何素材都只需要替换文件路径,运行一次就出结果。对我这种需要反复生产动图的人来说,这才是最核心的价值。

1.2 Python做GIF的三种典型需求

在实际工作里,我用Python做GIF遇到过三类高频需求,这篇文章会覆盖前两种:

  • 静态图片合成动图:比如产品截图、步骤说明、表情包制作,把一组PNG/JPG按顺序拼成GIF,核心是控制帧顺序和帧间隔。
  • 视频片段转GIF:比如复现Bug、分享录屏,需要从一大段视频里截取某个时间段,抽帧后合成为GIF。
  • 数据可视化动画:比如把趋势折线图、动态热力图转成动图,这一般用matplotlib的animation完成,但底层思路和逐帧拼GIF一致。

很多人以为做GIF必须用Photoshop或专门的录屏工具,其实只要理解了GIF的存储逻辑,Python写起来非常直接。

1.3 这套方案适合谁

如果你是有Python基础、想直接抄源码的开发者,这篇文章里的代码可以直接跑;如果你是经常做运营图、教程动图的非程序员,只需要按照环境安装步骤把Python配好,复制代码改一改路径也能用;如果你负责的自动化脚本里需要“出错时自动生成一张现场动图”,那第4章的OpenCV抽帧方案值得重点看。

2. 准备阶段:GIF原理与库选型

2.1 先搞懂GIF里到底存了什么

GIF看似是个视频格式,其实本质就是“多张图片按时间顺序快速播放”,类似小时候玩的那种翻页动画书——每一页画一个动作,快速翻动时眼睛就以为画面在动。

但GIF有个历史限制:单帧最多只能显示256种颜色。这意味着,你把一张色彩丰富的照片直接存成GIF,肉眼会明显感觉到色彩断层。另一个关键属性是帧延迟,也就是每张图停留多久,单位通常是毫秒。Pillow里duration=100表示每帧停留100毫秒,也就是10帧每秒;而imageio库的duration参数单位却是秒,很多人做视频转GIF时会在这里栽跟头。GIF还支持设置循环次数,loop=0表示无限循环,这也是表情包的常规配置。

理解这三点,你就已经理解了GIF的百分之八十。

2.2 Pillow、imageio、OpenCV怎么分工

我一开始也以为一个库就能搞定所有场景,实际用下来发现三个库各管一段,配合使用效果最好。下面是我的分工习惯:

库擅长的事情不擅长的场景
Pillow图片读取、尺寸调整、文字标注、GIF编码直接读取视频文件
imageio一行代码读取视频帧、快速导出GIF精细的逐帧控制和裁剪
OpenCV视频解码、精确抽帧、区域裁剪GIF编码本身

单纯把几张图片合成GIF,用Pillow就够;从视频转GIF,我推荐OpenCV抽帧后再交给Pillow保存;如果只是临时快速验证一段视频能不能转,用imageio的mimsave最省事。

2.3 环境准备与版本验证

我在Python 3.8以上的环境里都跑过,建议先建个虚拟环境再装依赖,避免和项目里其他包版本冲突。

pip install pillow imageio opencv-python

安装完成后可以跑一下版本验证,确认三个库都能正常导入:

from PIL import Image import imageio.v2 as iio import cv2 print("Pillow version:", Image.__version__) print("imageio version:", iio.__version__) print("OpenCV version:", cv2.__version__)

我实测最稳的组合是Pillow 9.5+、imageio 2.31+、opencv-python 4.8+。如果只是静态图合成GIF,只需要Pillow一个库就够了。

3. 静态图片合成GIF:Pillow完整实战

3.1 最简版:把一组PNG拼成GIF

先给出一段可以直接跑的源码,它做的事情是:读取frames文件夹下所有PNG图片,按文件名顺序拼成一个无限循环的GIF。

from PIL import Image import glob frame_paths = sorted(glob.glob('frames/*.png')) frames = [] for path in frame_paths: im = Image.open(path).convert('RGBA') frames.append(im) frames[0].save( 'output.gif', save_all=True, append_images=frames[1:], duration=120, loop=0, optimize=True )

这里面几个参数值得解释一下:

  • save_all=True是Pillow保存动画的关键开关,不设置它就只能保存第一帧。
  • append_images=frames[1:]把剩余帧追加到第一帧后面。
  • duration=120表示每帧停留120毫秒,约8帧每秒,适合演示类动图。
  • loop=0表示无限循环,改成loop=1就是播一遍后停住。
  • optimize=True让Pillow尝试压缩GIF体积。

我实际用的经验是:如果图片数量很多,比如超过50张,不要在内存里一次性把所有Image对象都存进列表,可以流式处理。但对大多数场景来说,上面的代码已经够用。

3.2 统一尺寸:避免生成的GIF忽大忽小

一个很容易忽略的问题:如果几张图片尺寸不一致,Pillow保存GIF时以第一帧的尺寸为准,后面的图片会被拉伸或裁切,位置也可能偏掉。我最早做步骤图时就遇到过这个问题,屏幕截图偶尔差几个像素,生成后画面边缘一直在跳。

解决方法是在合并前统一缩放,这里用Image.Resampling.LANCZOS保证缩放质量:

from PIL import Image import glob target_size = (640, 480) frame_paths = sorted(glob.glob('frames/*.png')) frames = [] for path in frame_paths: im = Image.open(path).convert('RGBA') im = im.resize(target_size, Image.Resampling.LANCZOS) frames.append(im) frames[0].save('output_uniform.gif', save_all=True, append_images=frames[1:], duration=120, loop=0, optimize=True)

需要注意的是,resize会直接拉伸图片,如果原始素材是不同宽高比,画面会变形。更稳妥的做法是先thumbnail按比例缩到目标范围内,再贴到统一画布上。不过对于步骤截图这类内容差异不大的素材,直接resize通常看不出问题。

3.3 添加文字标注:给动图打水印

很多场景需要在动图的某一帧上加文字说明,比如“点击这里”“注意看底部状态栏”。Pillow的ImageDraw模块可以轻松做到,但有个坑:中文字体需要加载系统字体文件,不指定字体的话中文会变成方块。

from PIL import Image, ImageDraw, ImageFont def add_text_to_frame(im, text, position=(20, 20), font_size=36): draw = ImageDraw.Draw(im) try: font = ImageFont.truetype("msyh.ttc", font_size) except OSError: font = ImageFont.load_default(size=font_size) draw.text(position, text, font=font, fill=(255, 0, 0)) return im

msyh.ttc是微软雅黑在Windows下的字体文件名。macOS可以用PingFang.ttc,Linux可以用wqy-microhei.ttc。新版Pillow还支持ImageFont.load_default(size=font_size),但旧版本不支持这个写法,所以我用try...except做了兼容处理。

给动图加文字的时机有两种:一种是在合成前对每一帧统一加,适合固定水印;另一种是在某一帧单独加,适合做分步骤演示。实际做教程时,我更推荐在对应的原始截图阶段就把文字处理好,而不是后期在GIF上叠加,这样文字清晰度更高,也不会因为GIF压缩算法导致文字边缘发虚。

3.4 批量合成多个GIF:运营同事的福利

如果素材不是一组图片,而是多个子目录,每个子目录需要生成一个独立的GIF,手写循环即可。这个脚本我在公司里给运营同事改过一版,他们每周都要把投放数据截图做成动图汇报。

import glob import os from PIL import Image for folder in sorted(glob.glob('groups/*')): frame_paths = sorted(glob.glob(os.path.join(folder, '*.png'))) if not frame_paths: continue frames = [Image.open(p).convert('RGBA') for p in frame_paths] out_name = os.path.basename(folder) + '.gif' frames[0].save(out_name, save_all=True, append_images=frames[1:], duration=120, loop=0, optimize=True) print(f"生成完成: {out_name}")

注意,批量生成时最容易出问题的是文件名排序。sorted()默认按字符串排,这意味着frame_10.png会排在frame_2.png前面。如果你发现生成出来的GIF顺序乱了,优先怀疑这里。关于自然排序的解决办法,我放在第6章避坑实录里仔细讲。

4. 从视频抽帧做GIF:两条路线都给你

4.1 路线一:imageio快速转换

如果你的需求是“快速把一段视频转成GIF,不在乎对帧级别的精细控制”,imageio是最短路径。

import imageio.v2 as iio reader = iio.get_reader('input.mp4') meta = reader.get_meta_data() src_fps = meta['fps'] frames = [] for i, frame in enumerate(reader): if i % int(src_fps / 10) == 0: frames.append(frame) reader.close() iio.mimsave('output_from_imageio.gif', frames, duration=0.1)

这里duration=0.1表示每帧显示0.1秒,即10帧每秒。初看很简单,但有两个细节需要注意:

  • get_reader是流式读取,不会一次性把整段视频都塞进内存,这是它比mimread更可取的地方。mimread虽然写法更短,但如果视频稍微长一点,内存占用会非常夸张。
  • imageio读出来的帧是numpy数组,直接传给mimsave就能用,不需要自己做通道转换。这一点和OpenCV不同,OpenCV读出来的帧是BGR通道,直接合成会出现颜色偏蓝偏红的问题。

如果只是临时验证一下素材,这条路线很好用。但如果你要精确控制取哪一段、画质怎么压缩、目标尺寸多大,我建议用下面的OpenCV路线。

4.2 路线二:OpenCV抽帧+Pillow合成

这套方案是我实际项目里最常用的,控制力最强。思路是:用OpenCV精确读取视频的每一帧,按照目标帧率采样,然后转成Pillow的Image对象,最后统一保存为GIF。

import cv2 from PIL import Image cap = cv2.VideoCapture('input.mp4') video_fps = cap.get(cv2.CAP_PROP_FPS) if video_fps <= 0: video_fps = 25 target_fps = 10 interval = max(1, int(round(video_fps / target_fps))) frames = [] idx = 0 while True: ret, frame = cap.read() if not ret: break if idx % interval == 0: rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) im = Image.fromarray(rgb) im = im.resize((im.width // 2, im.height // 2), Image.Resampling.LANCZOS) frames.append(im) idx += 1 cap.release() if frames: frames[0].save( 'output_from_opencv.gif', save_all=True, append_images=frames[1:], duration=100, loop=0, optimize=True )

这段代码的核心逻辑就三步:抽帧、转格式、缩尺寸。interval的计算逻辑是保证输出的GIF大致稳定在10帧每秒,不管原视频是24帧还是60帧,都能自动换算采帧间隔。如果原视频是30帧,interval=3,就是每3帧取1帧,最终10帧每秒。

为什么要主动缩一半尺寸?因为GIF的压缩效率远低于视频编码,1080p的视频直接转GIF,体积分分钟超过100MB。缩小尺寸是控制GIF体积最有效的手段,后面第5章我会给出具体测试数据。

4.3 进阶:只提取视频中间某一段

完整视频通常不需要全部转成GIF,比如只要展示第2分15秒到第2分30秒这段。操作方法是用cap.set把读指针跳到指定毫秒位置,然后在读取循环里设置结束条件。

import cv2 from PIL import Image cap = cv2.VideoCapture('input.mp4') start_ms = 2 * 60 * 1000 + 15 * 1000 end_ms = 2 * 60 * 1000 + 30 * 1000 cap.set(cv2.CAP_PROP_POS_MSEC, start_ms) video_fps = cap.get(cv2.CAP_PROP_FPS) if video_fps <= 0: video_fps = 25 total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) end_frame = int(end_ms / 1000 * video_fps) frames = [] idx = 0 while True: ret, frame = cap.read() if not ret: break if idx > end_frame: break if idx % 3 == 0: rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) im = Image.fromarray(rgb) im = im.resize((im.width // 2, im.height // 2), Image.Resampling.LANCZOS) frames.append(im) idx += 1 cap.release() if frames: frames[0].save( 'output_clip.gif', save_all=True, append_images=frames[1:], duration=100, loop=0, optimize=True )

补充一点,CAP_PROP_POS_MSEC的跳转精度在某些视频编码格式下并不是精确到毫秒,可能会跳到关键帧附近,所以生成的GIF开头可能比预期早几帧或晚几帧。如果对精度要求很高,可以跳转后再向前多读十几帧,从目标起点自己判断。

5. 体积与画质:GIF参数调优实战表

5.1 每个参数是怎么影响结果的

做GIF时最纠结的就是体积和画质的平衡。GIF的每一帧是无损压缩存储的,颜色一多,体积就压不下来。我在这里把关键参数和推荐值整理成一张表,方便直接对照:

参数作用推荐范围实测感受
duration单帧停留时间,单位毫秒60~200低于60会非常卡;高于200像幻灯片
loop循环次数0一般表情包和演示图都用0
optimize是否优化调色板True能省约20%体积,效果明显
帧采样率每秒抽多少帧5~15低于5会明显卡顿;超过15体积翻倍
缩放系数分辨率缩小倍数0.5左右1080p缩到960宽以下效果最好
颜色数调色板颜色数量64~255极彩色内容不要低于128

这些参数不是独立起作用的。比如帧采样率从15降到10,体积能少三成,但画面流畅度几乎看不出来;缩放到0.5倍,体积能直接少一半以上。在做优化时,优先动缩放系数,其次动帧率,最后再用颜色量化兜底。

5.2 10秒视频的实测压缩过程

我之前有一段约10秒的1080p录屏,原始MP4文件大约是25MB。直接把它全部帧转成GIF,不抽帧不缩放,体积直接超过300MB。这个结果一点都不夸张,因为GIF存储的是连续位图,每一帧之间的差异没有跨帧压缩的概念。

随后我做了三轮优化,记录下来的数据供参考:

优化方案GIF体积观感
全帧无缩放超过300MB无法使用
10fps抽帧,1080p不缩放约35MB流畅但太大
10fps抽帧,缩放到0.5倍约8MB可用
10fps抽帧,缩放0.5倍,再加optimize约6MB推荐

从此我养成了一个习惯:视频转GIF之前,先问自己两件事,画面里是否真的需要这么高的分辨率?是否真的需要这么高的帧率?大多数演示动图的答案都是不需要。一个1280宽的GIF配合10fps,在聊天工具和文档里已经足够清晰。

5.3 压到极致:颜色量化策略

如果体积还是超标,最后一招是减少颜色数。Pillow可以用quantize方法把图片从RGB模式转为P模式,也就是调色板模式,这样可以精确控制颜色数量。

from PIL import Image im = Image.open('screenshot.png').convert('RGB') im_quantized = im.quantize(colors=64, method=Image.Quantize.MEDIANCUT) im_quantized.save('screenshot_64.gif')

在做视频转GIF时,我建议对所有抽帧先做统一量化,再交给frames[0].save合成,这样整段GIF的颜色表现会更均匀。需要注意,量化后颜色数会明显减少,照片类的素材降到64色会出现色块,但对截图、界面、图表这类以纯色为主的素材,128色几乎无损。

还有一个小经验:如果最终GIF要嵌入到PPT或者网页里,不要为了体积把颜色压太低,因为屏幕观看时色块比体积更显眼。宁可将分辨率降到640宽,也别把颜色砍到64以下。

6. 避坑实录:生成GIF时最容易翻车的五个细节

6.1 首帧变黑或闪烁

第一次做GIF时,我发现生成结果的第一帧是黑色的,后面才正常显示。排查后发现,原因是输入图片里有透明通道,而GIF不支持半透明。我用convert('RGBA')保留了Alpha通道,但某些播放器对首帧的透明通道处理有差异,结果首帧就显示成黑色。

解决办法很简单,统一转为RGB模式:

im = Image.open(path).convert('RGB')

如果确实需要透明背景,那就只能做全透明或不透明,不能保留半透明效果。Pillow保存时还可以显式设置disposal=2,这个参数告诉播放器在显示下一帧前恢复背景,能避免帧与帧之间的视觉残影:

frames[0].save('output.gif', save_all=True, append_images=frames[1:], duration=100, loop=0, optimize=True, disposal=2)

这个disposal参数我建议默认都加上,实测能解决不少播放器上的闪帧问题。

6.2 颜色发灰发紫,像老照片

有一次把一组高清截图转成GIF,生成的动图颜色整体发灰,部分区域发紫,和原图差别很大。问题根源有两个:一是原图是RGB模式,但某些PNG带着Alpha通道,如果把半透明像素和背景混合,颜色就会变脏;二是GIF只有256色,色域覆盖有限,鲜艳的颜色会被舍入成附近值。

处理方式就是先convert('RGB'),如果需要透明背景则用convert('P', palette=Image.ADAPTIVE, colors=255)转成调色板模式,把其中1个颜色位留给透明。我还发现,图像中的渐变区域,比如阴影、光晕,转成GIF后最容易出现色带。避免的方法是尽量缩小这类区域的占比,或者在截图阶段就减少渐变效果。

6.3 imageio和Pillow的duration单位是反的

这个坑我觉得值得单独拿出来讲。imageio的mimsave里如果传duration=100,含义是每帧显示100秒,不是100毫秒。我第一次用imageio时直接把Pillow的参数搬过去,导出的GIF卡得像死机一样,每隔一分多钟才动一下。

正确的换算是:Pillow的duration(ms) = 1000 / fps,而imageio的duration(秒) = 1 / fps。比如要10fps:

  • Pillow写法:duration=100
  • imageio写法:duration=0.1,或者直接传fps=10让imageio自己换算

如果要在两套API之间迁移代码,这地方务必确认清楚。

6.4 视频直接读进内存导致内存爆炸

imageio的mimread用法很诱人,一行代码读完整段视频:

frames = iio.mimread('input.mp4')

但一段10分钟的1080p视频,解码成numpy数组之后可能占用几个GB内存,我跑过一次,进程直接卡死。所以我现在坚持用两种方式之一:要么用imageio的get_reader流式读取,逐帧判断后只保留需要的帧;要么用OpenCV的read()逐帧读取。这两种都不会一次性把整段视频加载进内存。

同样的道理也适用于Pillow。如果原始图片很多,不要一次性把所有Image.open都放进列表,而是边读边处理,只保留缩放过的小图。

6.5 sorted()排序并不保险

文件名排序这个坑,我踩过不止一次。Python的sorted()按字符串逐字符比较,所以frame_10.png会排在frame_2.png前面。如果你把截图命名为frame_1.png到frame_12.png,排序之后播放顺序就是1、10、11、12、2、3,动图看起来完全错乱。

解决办法是自然排序,也就是把数字部分当成整数来比较:

import re def natural_key(name): return [ int(part) if part.isdigit() else part for part in re.split(r'(\d+)', name) ] frame_paths = sorted(glob.glob('frames/*.png'), key=natural_key)

re.split(r'(\d+)', 'frame_10.png')的结果是['frame_', '10', '.png'],第二项会转成整数10,这样排序时自然按数字大小排列。

我建议所有做GIF的脚本都直接用这个排序函数,不要用默认的sorted(),省得哪天文件名里带着递增数字又出问题。

7. 超出GIF之外的扩展玩法

7.1 WebP动画:更小更清晰

GIF最大的瓶颈就是颜色和体积,如果你对画质更敏感,可以考虑输出WebP动画。WebP支持真彩色和有损压缩,同样的内容体积往往比GIF小一半以上,而且没有256色限制。

Pillow同样支持保存WebP动画:

from PIL import Image frames = [] for path in sorted(glob.glob('frames/*.png')): im = Image.open(path).convert('RGB') frames.append(im) frames[0].save('output.webp', save_all=True, append_images=frames[1:], duration=100, loop=0)

需要注意,WebP动画不是所有软件都能播放。我实测下来,主流聊天工具和现代浏览器基本都支持,但一些老旧的图片查看器可能无法识别。如果动图是要发给外部客户,为了兼容性我仍然推荐GIF;如果只是在团队内部工具里展示,WebP是更好的选择。

7.2 数据可视化动图:动态折线图

前面说的都是图片和视频,还有一种很常见的需求是想生成一张“数据在动”的折线图或柱状图。最常见的做法是用matplotlib的FuncAnimation,然后保存成GIF。但如果你想对输出更精细地控制,不妨把它拆成两步:先用matplotlib生成每一帧的PNG,再用第3章的Pillow脚本合成GIF。

拆开的好处是每一步都能单独调试,比如某一帧的标题颜色错了,只需要重新生成那一帧PNG,不必重跑整个动画。我在做月度数据汇报时就是这么干的,一张图生成几十帧,最终合成后效果稳定,还能顺便给每一帧加上数据标签。

7.3 把动图嵌入自动化工作流

做完了基础的GIF生成,我建议你优先考虑一件事:这个脚本能不能被别的程序调用?我在内部工具里做过一个很小的功能,当定时检测任务发现页面异常时,自动截取几帧屏幕并生成GIF,随告警消息一起发出去。这个功能本身不复杂,核心就是调用第4章的OpenCV抽帧逻辑,但实际意义很大——它把“人盯屏幕发现问题”这件事变成了“系统自动留证”。

如果你有类似的自动化场景,可以把GIF生成部分封装成一个函数,入参是图片列表或视频路径,出参是GIF文件路径。这样不管前端是定时任务、监控脚本还是命令行工具,都可以直接复用。

最后再分享一个调试小技巧:在做GIF脚本时,不要每次修改参数都生成完整动图,可以先只取前5帧拼成一张预览小图,确认帧顺序、尺寸、文字位置没问题后,再生成完整GIF。这一招能帮你节省大量试错时间。我自己的体会是,做GIF本身并不难,真正的价值在于你开始理解帧、延迟、调色板这些概念之后,再看视频文件和图片格式的视角会和以前完全不同。先从Pillow那份最简脚本开始跑通,后面的事情就自然顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询