【python】使用python脚本进行gif压缩
·
目标与范围
- 将约 90MB 的 GIF 压缩到用户可控的目标体积(例如
--max-mb 10) - 在体积、清晰度、流畅度之间做平衡,参数可调(分辨率、帧率、色彩数)
技术路线
- 使用 Python + Pillow 读取/写入 GIF(与仓库中
writer='pillow'一致,避免额外依赖) - 逐帧处理:
ImageSequence迭代帧、统一缩放、量化调色板、重写duration - 保存开启
optimize=True,并设置save_all=True、disposal=2以减少冗余数据
压缩策略(由轻到重渐进)
- 分辨率:按
--scale或--max-width/--max-height对所有帧统一缩放(LANCZOS) - 帧率:按
--target-fps对帧集合采样,保持总时长基本不变(通过调整每帧duration) - 色彩数:使用
quantize将每帧降到128/64/32色,必要时关闭抖动以进一步减小体积 - 存储优化:共享首帧调色板、
optimize=True、disposal=2,减少重复像素记录 - 自适应迭代:若仍超出
--max-mb,依次降低色彩→降低帧率→进一步缩放,直到达标
程序设计
- 新增脚本:
compress_gif.py - CLI 接口:
python compress_gif.py input.gif -o output.gif --max-mb 10 --scale 0.5 --fps 12 --colors 128- 仅给
--max-mb时走自动模式,按上述策略逐步压缩直至达标
- 输出日志:原/新大小、压缩比例、最终参数(便于复现与调参)
依赖与环境
- 依赖:
Pillow(仓库已通过matplotlib的writer='pillow'间接使用) - 环境:Windows / Python 3.8+;不强制要求安装
ffmpeg或gifsicle
验证与评估
- 使用你的 90MB GIF 实测:打印压缩前后大小与关键参数,确保肉眼观感可接受
- 回归测试:对
draw_results_pareto.py生成的 GIF 进行压缩,验证兼容性
可选增强(确认后可一并实现)
- 质量预设:
--preset {high,medium,low}映射到一组参数 - 并行处理:多进程量化以加速(在 CPU 充足情况下)
- 外部工具:检测到
gifsicle时可选--use-gifsicle做二次优化
交付物
compress_gif.py脚本 + 简要 README 使用示例- 示例命令与压缩前后对比报告(包含体积和主参数)
待确认的默认参数
max_mb=10、fps=12、scale=0.5、colors=128- 如需不同目标体积或更高/更低质量,告知我调整默认值后开始实现
import argparse
import os
import math
import tempfile
from PIL import Image, ImageSequence
def _resample_filter():
try:
return Image.Resampling.LANCZOS
except Exception:
return Image.LANCZOS
def _load_frames(input_path):
im = Image.open(input_path)
frames = []
durations = []
for frame in ImageSequence.Iterator(im):
duration = frame.info.get("duration", im.info.get("duration", 100))
if frame.mode in ("RGBA", "LA"):
bg = Image.new("RGB", frame.size, (255, 255, 255))
frame = frame.convert("RGBA")
bg.paste(frame, mask=frame.split()[-1])
frame = bg
else:
frame = frame.convert("RGB")
frames.append(frame)
durations.append(int(duration))
if len(durations) == 0:
durations = [100] * len(frames)
return frames, durations
def _compute_fps(durations):
if not durations:
return 10.0
avg = sum(durations) / float(len(durations))
if avg <= 0:
return 10.0
return 1000.0 / avg
def _compute_scale(size, scale, max_width, max_height):
w, h = size
s = 1.0
if scale is not None:
s = min(s, float(scale))
if max_width is not None and w * s > max_width:
s = min(s, float(max_width) / float(w))
if max_height is not None and h * s > max_height:
s = min(s, float(max_height) / float(h))
s = max(s, 0.05)
return s
def _resize_frames(frames, scale, max_width, max_height):
if scale is None and max_width is None and max_height is None:
return frames
resample = _resample_filter()
w0, h0 = frames[0].size
s = _compute_scale((w0, h0), scale, max_width, max_height)
new_w = max(1, int(w0 * s))
new_h = max(1, int(h0 * s))
return [f.resize((new_w, new_h), resample) for f in frames]
def _sample_frames(frames, durations, target_fps):
if target_fps is None:
return frames, durations
orig_fps = _compute_fps(durations)
if target_fps >= orig_fps:
return frames, durations
step = max(1, int(math.ceil(orig_fps / float(target_fps))))
new_frames = []
new_durations = []
acc = 0
for i, (f, d) in enumerate(zip(frames, durations)):
if i % step == 0:
new_frames.append(f)
new_durations.append(acc + d)
acc = 0
else:
acc += d
if acc > 0 and new_durations:
new_durations[-1] += acc
return new_frames, new_durations
def _quantize_frames(frames, colors, dither):
if colors is None:
return frames
q = []
d = 1 if dither else 0
for f in frames:
q.append(f.quantize(colors=int(colors), method=Image.MEDIANCUT, dither=d))
return q
def _save_gif(frames, durations, output_path):
if not frames:
raise RuntimeError("no frames")
first = frames[0]
rest = frames[1:] if len(frames) > 1 else []
first.save(output_path, save_all=True, append_images=rest, optimize=True, duration=durations, loop=0, disposal=2)
def _file_size_mb(path):
return os.path.getsize(path) / (1024.0 * 1024.0)
def compress_once(input_path, output_path, scale=None, target_fps=None, colors=None, dither=True, max_width=None, max_height=None):
frames, durations = _load_frames(input_path)
frames = _resize_frames(frames, scale, max_width, max_height)
frames, durations = _sample_frames(frames, durations, target_fps)
frames = _quantize_frames(frames, colors, dither)
_save_gif(frames, durations, output_path)
return _file_size_mb(output_path)
def compress_auto(input_path, output_path, max_mb, init_scale=None, init_fps=None, init_colors=None, dither=True, max_width=None, max_height=None):
frames, durations = _load_frames(input_path)
orig_fps = _compute_fps(durations)
scale = init_scale if init_scale is not None else 0.5
colors_tiers = [128, 64, 32, 16]
fps_tiers = [12, 10, 8, 6]
colors = init_colors if init_colors is not None else colors_tiers[0]
fps = init_fps if init_fps is not None else min(int(orig_fps), fps_tiers[0])
tmp_path = None
try:
for _ in range(12):
with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as t:
tmp_path = t.name
size = compress_once(input_path, tmp_path, scale=scale, target_fps=fps, colors=colors, dither=dither, max_width=max_width, max_height=max_height)
if size <= max_mb:
os.replace(tmp_path, output_path)
return size, {"scale": scale, "fps": fps, "colors": colors, "dither": dither}
if colors > colors_tiers[-1]:
for c in colors_tiers:
if colors > c:
colors = c
break
continue
if fps > fps_tiers[-1]:
for f in fps_tiers:
if fps > f:
fps = f
break
continue
scale = max(0.3, scale * 0.85)
os.replace(tmp_path, output_path)
return _file_size_mb(output_path), {"scale": scale, "fps": fps, "colors": colors, "dither": dither}
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except Exception:
pass
def generate_sample_gif(path):
frames = []
for i in range(60):
color = ((i * 5) % 255, 128, (255 - i * 4) % 255)
frames.append(Image.new("RGB", (640, 360), color))
durations = [50] * len(frames)
frames[0].save(path, save_all=True, append_images=frames[1:], duration=durations, loop=0, optimize=True, disposal=2)
def main():
p = argparse.ArgumentParser()
p.add_argument("input", nargs="?", help="输入 GIF 路径")
p.add_argument("-o", "--output", help="输出 GIF 路径")
p.add_argument("--max-mb", type=float, default=None, help="目标最大体积 MB")
p.add_argument("--scale", type=float, default=None, help="缩放比例 0-1")
p.add_argument("--fps", type=int, default=None, help="目标帧率")
p.add_argument("--colors", type=int, default=None, help="色彩数量 256 以下")
p.add_argument("--max-width", type=int, default=None, help="最大宽度")
p.add_argument("--max-height", type=int, default=None, help="最大高度")
p.add_argument("--no-dither", action="store_true", help="关闭抖动")
p.add_argument("--self-test", action="store_true", help="生成示例 GIF 并压缩")
args = p.parse_args()
if args.self_test:
test_in = os.path.join(os.getcwd(), "_sample.gif")
test_out = os.path.join(os.getcwd(), "_sample_compressed.gif")
generate_sample_gif(test_in)
if args.max_mb:
size, params = compress_auto(test_in, test_out, max_mb=float(args.max_mb), init_scale=args.scale, init_fps=args.fps, init_colors=args.colors, dither=(not args.no_dither), max_width=args.max_width, max_height=args.max_height)
else:
size = compress_once(test_in, test_out, scale=args.scale, target_fps=args.fps, colors=args.colors, dither=(not args.no_dither), max_width=args.max_width, max_height=args.max_height)
params = {"scale": args.scale, "fps": args.fps, "colors": args.colors, "dither": (not args.no_dither)}
print("原始体积(MB)", _file_size_mb(test_in))
print("压缩体积(MB)", size)
print("参数", params)
return
if not args.input:
raise SystemExit("缺少输入 GIF 路径或使用 --self-test")
input_path = args.input
output_path = args.output or (os.path.splitext(input_path)[0] + "_compressed.gif")
if args.max_mb is not None:
size, params = compress_auto(input_path, output_path, max_mb=float(args.max_mb), init_scale=args.scale, init_fps=args.fps, init_colors=args.colors, dither=(not args.no_dither), max_width=args.max_width, max_height=args.max_height)
print("原始体积(MB)", _file_size_mb(input_path))
print("压缩体积(MB)", size)
print("参数", params)
print("输出", output_path)
else:
size = compress_once(input_path, output_path, scale=args.scale, target_fps=args.fps, colors=args.colors, dither=(not args.no_dither), max_width=args.max_width, max_height=args.max_height)
print("原始体积(MB)", _file_size_mb(input_path))
print("压缩体积(MB)", size)
print("输出", output_path)
if __name__ == "__main__":
main()
更多推荐

所有评论(0)