#!/usr/bin/env python3 """ GIF → RGB565 raw sprite pack 转换脚本 用法: python3 gif_to_rgb565.py input.gif output.bin [width] [height] [max_frames] 示例: python3 gif_to_rgb565.py happy.gif sprite_test.bin 200 200 # 全帧 python3 gif_to_rgb565.py happy.gif sprite_test.bin 200 200 15 # 抽帧到 15 帧 依赖: pip install Pillow """ import sys import struct import os from PIL import Image, ImageSequence def gif_to_sprite_bin(gif_path, bin_path, target_w=200, target_h=200, max_frames=0, name='test'): img = Image.open(gif_path) total_frames = getattr(img, 'n_frames', 1) print(f"原始 GIF: {img.size}, 共 {total_frames} 帧") # 先扫描所有原始帧的 duration(保持总循环时长一致) original_durations = [] for frame in ImageSequence.Iterator(img): original_durations.append(frame.info.get('duration', 100)) total_original_ms = sum(original_durations) img.seek(0) # 回到第一帧 # 抽帧策略:均匀抽取 if max_frames > 0 and total_frames > max_frames: step = total_frames / max_frames keep_indices = set(int(i * step) for i in range(max_frames)) print(f"抽帧: {total_frames} → {len(keep_indices)} 帧(步长 {step:.2f})") else: keep_indices = None # 保留全部 frames_rgb565 = [] frame_durations = [] for idx, frame in enumerate(ImageSequence.Iterator(img)): if keep_indices is not None and idx not in keep_indices: continue # 转 RGBA 处理透明度 → 缩放 → 黑底合成(去 Alpha) f_rgba = frame.convert('RGBA').resize((target_w, target_h), Image.LANCZOS) rgb = Image.new('RGB', (target_w, target_h), (0, 0, 0)) rgb.paste(f_rgba, mask=f_rgba.split()[3]) # RGB888 → RGB565 大端(ESP32 LCD 通常用大端字节序) raw = bytearray() for r, g, b in rgb.getdata(): rgb565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) raw += struct.pack('>H', rgb565) frames_rgb565.append(bytes(raw)) frame_durations.append(frame.info.get('duration', 100)) frame_count = len(frames_rgb565) # FPS 计算:保持总循环时长与原 GIF 一致 # fps = 保留帧数 / 总时长(秒) if total_original_ms > 0: fps = max(1, round(frame_count / (total_original_ms / 1000.0))) else: fps = 10 print(f"原 GIF 总时长 {total_original_ms} ms,抽帧后 FPS = {fps}(保持循环时长一致)") frame_size = target_w * target_h * 2 total_frame_data = frame_size * frame_count print(f"\n转换信息:") print(f" 目标尺寸: {target_w}x{target_h}") print(f" 帧数: {frame_count}") print(f" 平均 FPS: {fps}") print(f" 单帧大小: {frame_size / 1024:.1f} KB") print(f" 帧数据总大小: {total_frame_data / 1024:.1f} KB") # ============ 文件格式(简化版,单情绪 PoC) ============ # 文件头 64 字节(与正式格式兼容) HEADER_SIZE = 64 TABLE_SIZE = 20 # 单个情绪索引项(8+2+2+4+4 = 20) FRAME_DATA_OFFSET = HEADER_SIZE + TABLE_SIZE # 84 header = struct.pack( '<8sIHHBBBBIIIII4s20s', b'SPRITES\0', # 8B magic 1, # 4B version target_w, target_h, # 2+2B size 0, # 1B pixel_format: 0=RGB565_BE 1, # 1B emotion_count 0, # 1B motion_count 1, # 1B mouth_levels HEADER_SIZE, # 4B emotion_table_offset 0, # 4B motion_table_offset FRAME_DATA_OFFSET, # 4B frame_data_offset frame_count, # 4B total_frames total_frame_data, # 4B total_size of frames b'\0\0\0\0', # 4B checksum 占位 b'\0' * 20, # 20B reserved ) # 8+4+2+2+1+1+1+1+4+4+4+4+4+4+20 = 64 assert len(header) == 64, f"header size = {len(header)}, expected 64" # 情绪索引表(16 字节) entry = struct.pack( '<8sHHII', name.encode('ascii').ljust(8, b'\0')[:8], frame_count, fps, 0, # first_frame_idx 0, # flags ) assert len(entry) == 20 # 写文件 with open(bin_path, 'wb') as f: f.write(header) f.write(entry) for raw in frames_rgb565: f.write(raw) total_size = os.path.getsize(bin_path) print(f"\n✓ 输出: {bin_path}") print(f" 总大小: {total_size / 1024:.1f} KB ({total_size} bytes)") if __name__ == '__main__': if len(sys.argv) < 3: print(__doc__) sys.exit(1) gif_path = sys.argv[1] bin_path = sys.argv[2] w = int(sys.argv[3]) if len(sys.argv) > 3 else 200 h = int(sys.argv[4]) if len(sys.argv) > 4 else 200 max_frames = int(sys.argv[5]) if len(sys.argv) > 5 else 0 if not os.path.exists(gif_path): print(f"错误: 找不到 {gif_path}") sys.exit(1) gif_to_sprite_bin(gif_path, bin_path, w, h, max_frames)