源代码变更: - main/dzbj/bg_gif_demo.c/h: 方案 C 最终实现 - JPG 背景图(lv_img) + 透明 GIF(lv_gif) 叠加 - main/dzbj/dual_gif_demo.c/h: 方案 B 中间产物 - 双 GIF 循环切换 - main/dzbj/sprite_demo.c/h: 方案 A 已弃用 - DMA 直写 GRAM 与 LVGL 争抢 LCD IO 失败 - main/dzbj/ai_chat_ui.c: 集成 USE_BG_GIF_POC 开关,加载背景图+透明 GIF - main/dzbj/lcd.c: panel_handle 移除 static,便于其他模块访问 - main/CMakeLists.txt: 新增 3 个 dzbj 模块编译 资源新增: - spiffs_image/Background_360x360.jpg: 设备背景图(20KB) - spiffs_image/hiyori_m05.gif: Cubism Editor 直接导出的透明 GIF(2.3MB) - docs/Rtc_AIavatar/: Live2D 模型(Hiyori/Haru) + 32 段 Haru GIF + 方案文档第18章 PoC 实战记录 - tools/sprite_poc/: Python GIF→RGB565 转换脚本 踩坑要点(详见 docs/Rtc_AIavatar 第18章): - PIL Image.quantize() 会破坏 RGBA 透明度,必须改用 gifsicle - PIL 保存动画 GIF 仅第1帧有透明,后续帧不透明 - LVGL gifdec 按帧读取 - Cubism Editor 直接导出 GIF 才能逐帧保留透明信息(FREE 版限制部分模型) - gifsicle --lossy 会严重锯齿化,去掉只保留 --colors 256 + -O3 即可 - 裁剪居中需用全帧 bbox 不能只看第1帧(Live2D 角色每帧位置有偏移) - LVGL 默认不支持 PNG,背景图用 JPG + esp_jpeg 解码到 RGB565 buffer - 透明 GIF 显示黑色背景: gifdec.c canvas 初始化 alpha 须改为 0x00
146 lines
5.1 KiB
Python
146 lines
5.1 KiB
Python
#!/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)
|