Baji_Rtc_Toy/tools/sprite_poc/gif_to_rgb565.py
Rdzleo eb96130fc9 feat(Rtc_AIavatar): 数字人透明 GIF 显示方案 PoC 完成(背景图+透明GIF叠加)
源代码变更:
- 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
2026-05-12 17:14:49 +08:00

146 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)