目标与范围 将约 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 argparseimport osimport mathimport tempfilefrom PILimport Image, ImageSequencedef _resample_filter ( ) : try : return Image. Resampling. LANCZOSexcept Exception: return Image. LANCZOSdef _load_frames ( input_path) : im= Image. open ( input_path) frames= [ ] durations= [ ] for framein ImageSequence. Iterator( im) : duration= frame. info. get( "duration" , im. info. get( "duration" , 100 ) ) if frame. 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) ) if len ( durations) == 0 : durations= [ 100 ] * len ( frames) return frames, durationsdef _compute_fps ( durations) : if not durations: return 10.0 avg= sum ( durations) / float ( len ( durations) ) if avg<= 0 : return 10.0 return 1000.0 / avgdef _compute_scale ( size, scale, max_width, max_height) : w, h= size s= 1.0 if scaleis not None : s= min ( s, float ( scale) ) if max_widthis not None and w* s> max_width: s= min ( s, float ( max_width) / float ( w) ) if max_heightis not None and h* s> max_height: s= min ( s, float ( max_height) / float ( h) ) s= max ( s, 0.05 ) return sdef _resize_frames ( frames, scale, max_width, max_height) : if scaleis None and max_widthis None and max_heightis 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 fin frames] def _sample_frames ( frames, durations, target_fps) : if target_fpsis 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+= dif acc> 0 and new_durations: new_durations[ - 1 ] += accreturn new_frames, new_durationsdef _quantize_frames ( frames, colors, dither) : if colorsis None : return frames q= [ ] d= 1 if ditherelse 0 for fin frames: q. append( f. quantize( colors= int ( colors) , method= Image. MEDIANCUT, dither= d) ) return qdef _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_scaleif init_scaleis not None else 0.5 colors_tiers= [ 128 , 64 , 32 , 16 ] fps_tiers= [ 12 , 10 , 8 , 6 ] colors= init_colorsif init_colorsis not None else colors_tiers[ 0 ] fps= init_fpsif init_fpsis 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 cin colors_tiers: if colors> c: colors= cbreak continue if fps> fps_tiers[ - 1 ] : for fin fps_tiers: if fps> f: fps= fbreak 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_pathand os. path. exists( tmp_path) : try : os. remove( tmp_path) except Exception: pass def generate_sample_gif ( path) : frames= [ ] for iin 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. outputor ( os. path. splitext( input_path) [ 0 ] + "_compressed.gif" ) if args. max_mbis 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( )