先交代一下这篇内容的起因。前阵子做一份数据分析报告需要把一个三分半的操作演示视频转成GIF发给远程同事录屏软件自带的导出功能生成的文件足有30多MB在线压缩工具又限制在10MB压完以后画质完全没法看。折腾半个多小时之后我干脆关掉所有网页打开编辑器用Python自己写了一套GIF生成脚本。这篇文章就是那套脚本的完整复盘内容包括Pillow与imageio的源码实现、视频抽帧、画质调优还有几个我实际踩过的坑。如果你也经常要批量做动图或者想把动图自动化嵌入到自己的工作流里这篇应该对你有用。1. 为什么我最后选了Python方案来做GIF1.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里duration100表示每帧停留100毫秒也就是10帧每秒而imageio库的duration参数单位却是秒很多人做视频转GIF时会在这里栽跟头。GIF还支持设置循环次数loop0表示无限循环这也是表情包的常规配置。理解这三点你就已经理解了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. 静态图片合成GIFPillow完整实战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_allTrue, append_imagesframes[1:], duration120, loop0, optimizeTrue )这里面几个参数值得解释一下save_allTrue是Pillow保存动画的关键开关不设置它就只能保存第一帧。append_imagesframes[1:]把剩余帧追加到第一帧后面。duration120表示每帧停留120毫秒约8帧每秒适合演示类动图。loop0表示无限循环改成loop1就是播一遍后停住。optimizeTrue让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_allTrue, append_imagesframes[1:], duration120, loop0, optimizeTrue)需要注意的是resize会直接拉伸图片如果原始素材是不同宽高比画面会变形。更稳妥的做法是先thumbnail按比例缩到目标范围内再贴到统一画布上。不过对于步骤截图这类内容差异不大的素材直接resize通常看不出问题。3.3 添加文字标注给动图打水印很多场景需要在动图的某一帧上加文字说明比如“点击这里”“注意看底部状态栏”。Pillow的ImageDraw模块可以轻松做到但有个坑中文字体需要加载系统字体文件不指定字体的话中文会变成方块。from PIL import Image, ImageDraw, ImageFont def add_text_to_frame(im, text, position(20, 20), font_size36): draw ImageDraw.Draw(im) try: font ImageFont.truetype(msyh.ttc, font_size) except OSError: font ImageFont.load_default(sizefont_size) draw.text(position, text, fontfont, fill(255, 0, 0)) return immsyh.ttc是微软雅黑在Windows下的字体文件名。macOS可以用PingFang.ttcLinux可以用wqy-microhei.ttc。新版Pillow还支持ImageFont.load_default(sizefont_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_allTrue, append_imagesframes[1:], duration120, loop0, optimizeTrue) 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, duration0.1)这里duration0.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_allTrue, append_imagesframes[1:], duration100, loop0, optimizeTrue )这段代码的核心逻辑就三步抽帧、转格式、缩尺寸。interval的计算逻辑是保证输出的GIF大致稳定在10帧每秒不管原视频是24帧还是60帧都能自动换算采帧间隔。如果原视频是30帧interval3就是每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_allTrue, append_imagesframes[1:], duration100, loop0, optimizeTrue )补充一点CAP_PROP_POS_MSEC的跳转精度在某些视频编码格式下并不是精确到毫秒可能会跳到关键帧附近所以生成的GIF开头可能比预期早几帧或晚几帧。如果对精度要求很高可以跳转后再向前多读十几帧从目标起点自己判断。5. 体积与画质GIF参数调优实战表5.1 每个参数是怎么影响结果的做GIF时最纠结的就是体积和画质的平衡。GIF的每一帧是无损压缩存储的颜色一多体积就压不下来。我在这里把关键参数和推荐值整理成一张表方便直接对照参数作用推荐范围实测感受duration单帧停留时间单位毫秒60~200低于60会非常卡高于200像幻灯片loop循环次数0一般表情包和演示图都用0optimize是否优化调色板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(colors64, methodImage.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保存时还可以显式设置disposal2这个参数告诉播放器在显示下一帧前恢复背景能避免帧与帧之间的视觉残影frames[0].save(output.gif, save_allTrue, append_imagesframes[1:], duration100, loop0, optimizeTrue, disposal2)这个disposal参数我建议默认都加上实测能解决不少播放器上的闪帧问题。6.2 颜色发灰发紫像老照片有一次把一组高清截图转成GIF生成的动图颜色整体发灰部分区域发紫和原图差别很大。问题根源有两个一是原图是RGB模式但某些PNG带着Alpha通道如果把半透明像素和背景混合颜色就会变脏二是GIF只有256色色域覆盖有限鲜艳的颜色会被舍入成附近值。处理方式就是先convert(RGB)如果需要透明背景则用convert(P, paletteImage.ADAPTIVE, colors255)转成调色板模式把其中1个颜色位留给透明。我还发现图像中的渐变区域比如阴影、光晕转成GIF后最容易出现色带。避免的方法是尽量缩小这类区域的占比或者在截图阶段就减少渐变效果。6.3 imageio和Pillow的duration单位是反的这个坑我觉得值得单独拿出来讲。imageio的mimsave里如果传duration100含义是每帧显示100秒不是100毫秒。我第一次用imageio时直接把Pillow的参数搬过去导出的GIF卡得像死机一样每隔一分多钟才动一下。正确的换算是Pillow的duration(ms) 1000 / fps而imageio的duration(秒) 1 / fps。比如要10fpsPillow写法duration100imageio写法duration0.1或者直接传fps10让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), keynatural_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_allTrue, append_imagesframes[1:], duration100, loop0)需要注意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那份最简脚本开始跑通后面的事情就自然顺了。