目标与范围
- 将约 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- 如需不同目标体积或更高/更低质量,告知我调整默认值后开始实现
importargparseimportosimportmathimporttempfilefromPILimportImage,ImageSequencedef_resample_filter():try:returnImage.Resampling.LANCZOSexceptException:returnImage.LANCZOSdef_load_frames(input_path):im=Image.open(input_path)frames=[]durations=[]forframeinImageSequence.Iterator(im):duration=frame.info.get("duration",im.info.get("duration",100))ifframe.modein("RGBA","LA"):bg=Image.new("RGB",frame.size,(255,255,255))frame=frame.convert("RGBA")bg.paste(frame,mask=frame.split()[-1])frame=bgelse:frame=frame.convert("RGB")frames.append(frame)durations.append(int(duration))iflen(durations)==0:durations=[100]*len(frames)returnframes,durationsdef_compute_fps(durations):ifnotdurations:return10.0avg=sum(durations)/float(len(durations))ifavg<=0:return10.0return1000.0/avgdef_compute_scale(size,scale,max_width,max_height):w,h=size s=1.0ifscaleisnotNone:s=min(s,float(scale))ifmax_widthisnotNoneandw*s>max_width:s=min(s,float(max_width)/float(w))ifmax_heightisnotNoneandh*s>max_height:s=min(s,float(max_height)/float(h))s=max(s,0.05)returnsdef_resize_frames(frames,scale,max_width,max_height):ifscaleisNoneandmax_widthisNoneandmax_heightisNone:returnframes 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)forfinframes]def_sample_frames(frames,durations,target_fps):iftarget_fpsisNone:returnframes,durations orig_fps=_compute_fps(durations)iftarget_fps>=orig_fps:returnframes,durations step=max(1,int(math.ceil(orig_fps/float(target_fps))))new_frames=[]new_durations=[]acc=0fori,(f,d)inenumerate(zip(frames,durations)):ifi%step==0:new_frames.append(f)new_durations.append(acc+d)acc=0else:acc+=difacc>0andnew_durations:new_durations[-1]+=accreturnnew_frames,new_durationsdef_quantize_frames(frames,colors,dither):ifcolorsisNone:returnframes q=[]d=1ifditherelse0forfinframes:q.append(f.quantize(colors=int(colors),method=Image.MEDIANCUT,dither=d))returnqdef_save_gif(frames,durations,output_path):ifnotframes:raiseRuntimeError("no frames")first=frames[0]rest=frames[1:]iflen(frames)>1else[]first.save(output_path,save_all=True,append_images=rest,optimize=True,duration=durations,loop=0,disposal=2)def_file_size_mb(path):returnos.path.getsize(path)/(1024.0*1024.0)defcompress_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)defcompress_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_scaleifinit_scaleisnotNoneelse0.5colors_tiers=[128,64,32,16]fps_tiers=[12,10,8,6]colors=init_colorsifinit_colorsisnotNoneelsecolors_tiers[0]fps=init_fpsifinit_fpsisnotNoneelsemin(int(orig_fps),fps_tiers[0])tmp_path=Nonetry:for_inrange(12):withtempfile.NamedTemporaryFile(suffix=".gif",delete=False)ast: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)ifsize<=max_mb:os.replace(tmp_path,output_path)returnsize,{"scale":scale,"fps":fps,"colors":colors,"dither":dither}ifcolors>colors_tiers[-1]:forcincolors_tiers:ifcolors>c:colors=cbreakcontinueiffps>fps_tiers[-1]:forfinfps_tiers:iffps>f:fps=fbreakcontinuescale=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:iftmp_pathandos.path.exists(tmp_path):try:os.remove(tmp_path)exceptException:passdefgenerate_sample_gif(path):frames=[]foriinrange(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)defmain():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()ifargs.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)ifargs.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=(notargs.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=(notargs.no_dither),max_width=args.max_width,max_height=args.max_height)params={"scale":args.scale,"fps":args.fps,"colors":args.colors,"dither":(notargs.no_dither)}print("原始体积(MB)",_file_size_mb(test_in))print("压缩体积(MB)",size)print("参数",params)returnifnotargs.input:raiseSystemExit("缺少输入 GIF 路径或使用 --self-test")input_path=args.inputoutput_path=args.outputor(os.path.splitext(input_path)[0]+"_compressed.gif")ifargs.max_mbisnotNone: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=(notargs.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=(notargs.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()