目标与范围

  • 将约 90MB 的 GIF 压缩到用户可控的目标体积(例如 --max-mb 10
  • 在体积、清晰度、流畅度之间做平衡,参数可调(分辨率、帧率、色彩数)

技术路线

  • 使用 Python + Pillow 读取/写入 GIF(与仓库中 writer='pillow' 一致,避免额外依赖)
  • 逐帧处理:ImageSequence 迭代帧、统一缩放、量化调色板、重写 duration
  • 保存开启 optimize=True,并设置 save_all=Truedisposal=2 以减少冗余数据

压缩策略(由轻到重渐进)

  • 分辨率:按 --scale--max-width/--max-height 对所有帧统一缩放(LANCZOS
  • 帧率:按 --target-fps 对帧集合采样,保持总时长基本不变(通过调整每帧 duration
  • 色彩数:使用 quantize 将每帧降到 128/64/32 色,必要时关闭抖动以进一步减小体积
  • 存储优化:共享首帧调色板、optimize=Truedisposal=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(仓库已通过 matplotlibwriter='pillow' 间接使用)
  • 环境:Windows / Python 3.8+;不强制要求安装 ffmpeggifsicle

验证与评估

  • 使用你的 90MB GIF 实测:打印压缩前后大小与关键参数,确保肉眼观感可接受
  • 回归测试:对 draw_results_pareto.py 生成的 GIF 进行压缩,验证兼容性

可选增强(确认后可一并实现)

  • 质量预设:--preset {high,medium,low} 映射到一组参数
  • 并行处理:多进程量化以加速(在 CPU 充足情况下)
  • 外部工具:检测到 gifsicle 时可选 --use-gifsicle 做二次优化

交付物

  • compress_gif.py 脚本 + 简要 README 使用示例
  • 示例命令与压缩前后对比报告(包含体积和主参数)

待确认的默认参数

  • max_mb=10fps=12scale=0.5colors=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()


Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐