按 GSD 框架 .planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/ 规划完成 Phase 3 数字人表情 GIF 资源处理。 ## 处理方式(与 PoC 阶段 hiyori_m05.gif 一致) ```bash gifsicle --resize _x360 -O3 input.gif -o output.gif ``` - 高度 = LCD 360px,宽度按原比例自动算 → 209px - 不裁剪(保持源 GIF 完整人物) - 不加 --lossy / --colors(保留 256 色,画质优先) - 只用 -O3 优化文件大小 ## 处理结果 | GIF | 用途 | 源 | 处理后 | 节省 | |-----|------|-----|--------|------| | m03 | 负面/严肃 | 407×700 3.3MB | 209×360 1.15MB | 66% | | m06 | 默认/积极 | 407×700 1.3MB | 209×360 0.44MB | 66% | | m07 | 思考/疲倦 | 407×700 1.2MB | 209×360 0.40MB | 66% | | 合计 | — | 5.7MB | 1.94MB | 66% | ## 决策过程(避免后续重复犯错) Phase 3 初稿曾尝试裁剪到 240×320 + PIL 全帧 bbox 居中裁剪, 用户烧录后反馈"视觉感官不好"——角色被横向压扁(240×320 纵横比 0.75 vs 源 407×700 纵横比 0.583)。回归 PoC 等比例缩放方式后效果与 PoC 一致。 PoC 处理标准已写入用户级 feedback memory(feedback_hiyori_gif_processing.md), 后续 hiyori GIF 处理一律用本方式,除非用户主动要求修改。 ## 显示效果(用户已目视确认) LCD 360×360 居中显示 209×360 GIF: - 垂直方向: 360 = 360,完全充满 - 横向: 209 < 360,左右各 75.5px 留边显示背景图 - 角色比例: 完整保留源 GIF 的 407:700 = 0.582 纵横比,人物细高自然 ## 删除项 - spiffs_image/hiyori_m05.gif (2.3MB) 已删除 - 被 m06/m07/m03 替代 文件历史保留在 git,可通过 git show eb96130:spiffs_image/hiyori_m05.gif 恢复 ## 默认表情切换 main/dzbj/ai_chat_ui.c:234: - PoC: bg_gif_demo_start(..., "/spiflash/hiyori_m05.gif") - Phase 3: bg_gif_demo_start(..., "/spiflash/hiyori_m06.gif") ## 烧录运行时验证 - 烧录后 0 次重启(连续监控 18 秒) - BG_GIF: GIF 已加载到 PSRAM: /spiflash/hiyori_m06.gif (441.8 KB) - AudioCodec: Audio codec started(首次冷启动直接成功) - 用户目视确认显示效果良好 ## GSD 文档(同时提交) - .planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/PLAN.md - .planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/GIF_REPORT.md ## SPIFFS 容量 新 SPIFFS 4.94MB 当前实际占用 ~2MB(40%),余量 ~2.94MB 充足。
115 lines
3.8 KiB
Python
Executable File
115 lines
3.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Phase 3: 准备 hiyori 表情 GIF(等比例缩小到高 360px,与 PoC 保持一致)
|
||
|
||
输入: docs/Rtc_AIavatar/Resources/hiyori_free_zh/Export/m{03,06,07}/hiyori_m*.gif
|
||
输出: spiffs_image/hiyori_m{03,06,07}.gif
|
||
|
||
处理策略(与 PoC 阶段 hiyori_m05.gif 一致,复现 209×360 等比例缩放):
|
||
- 不裁剪,保持源 GIF 的人物全身完整(407×700 原始比例)
|
||
- 用 gifsicle --resize _x360 让高度 = LCD 360px,宽度按比例自动算 → 约 209px
|
||
- LVGL lv_obj_align(LV_ALIGN_CENTER) 居中显示:垂直充满 LCD,左右各 ~75px 留边
|
||
- 不加 --lossy / --colors(保留 256 色,画质优先;CLAUDE.md 经验)
|
||
- 只用 -O3 优化文件大小
|
||
|
||
LCD 360×360,目标显示效果:人物充满垂直方向、横向居中、左右留边显示背景图
|
||
"""
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
GIFSICLE = "/opt/homebrew/var/homebrew/tmp/.cellar/gifsicle/1.96/bin/gifsicle"
|
||
TARGET_HEIGHT = 360 # = LCD 高度,与 PoC 阶段 m05.gif 一致
|
||
|
||
SRC_BASE = Path("docs/Rtc_AIavatar/Resources/hiyori_free_zh/Export")
|
||
DST_BASE = Path("spiffs_image")
|
||
TARGETS = ["m03", "m06", "m07"]
|
||
|
||
|
||
def check_tools():
|
||
if not Path(GIFSICLE).exists():
|
||
import shutil
|
||
alt = shutil.which("gifsicle")
|
||
if alt:
|
||
globals()["GIFSICLE"] = alt
|
||
print(f"使用 PATH 中的 gifsicle: {alt}")
|
||
else:
|
||
print(f"ERROR: gifsicle 不存在于 {GIFSICLE},且 PATH 中找不到", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
def process_gif(src_path, dst_path):
|
||
"""处理单个 GIF: gifsicle 等比例缩小到高 360px"""
|
||
src_size_kb = Path(src_path).stat().st_size / 1024
|
||
|
||
# 读源尺寸(仅日志用)
|
||
info = subprocess.check_output(
|
||
[GIFSICLE, "--info", str(src_path)], text=True
|
||
)
|
||
# 解析 "logical screen WxH"
|
||
src_w, src_h, frame_count = "?", "?", "?"
|
||
for line in info.splitlines():
|
||
if "logical screen" in line:
|
||
wh = line.split("logical screen")[1].strip().split("x")
|
||
src_w, src_h = wh[0], wh[1]
|
||
if "images" in line and line.startswith("*"):
|
||
frame_count = line.split()[-2]
|
||
|
||
# gifsicle: 高度 = 360,宽度按比例自动算
|
||
cmd = [
|
||
GIFSICLE,
|
||
"--resize", f"_x{TARGET_HEIGHT}",
|
||
"-O3",
|
||
str(src_path),
|
||
"-o", str(dst_path),
|
||
]
|
||
|
||
print(f" 源: {src_w}x{src_h}, {frame_count} 帧, {src_size_kb:.1f} KB")
|
||
subprocess.run(cmd, check=True)
|
||
|
||
# 验证输出尺寸
|
||
dst_info = subprocess.check_output([GIFSICLE, "--info", str(dst_path)], text=True)
|
||
for line in dst_info.splitlines():
|
||
if "logical screen" in line:
|
||
dst_wh = line.split("logical screen")[1].strip()
|
||
break
|
||
else:
|
||
dst_wh = "?"
|
||
|
||
dst_size_kb = Path(dst_path).stat().st_size / 1024
|
||
reduction = (1 - dst_size_kb / src_size_kb) * 100
|
||
print(f" 输出: {dst_wh}, {dst_size_kb:.1f} KB (节省 {reduction:.1f}%)")
|
||
|
||
|
||
def main():
|
||
check_tools()
|
||
|
||
if not DST_BASE.exists():
|
||
print(f"ERROR: 目标目录 {DST_BASE} 不存在", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
print(f"=== Phase 3: 处理 hiyori GIF (等比例缩小到高 {TARGET_HEIGHT}px) ===\n")
|
||
|
||
total_kb = 0
|
||
for name in TARGETS:
|
||
src = SRC_BASE / name / f"hiyori_{name}.gif"
|
||
dst = DST_BASE / f"hiyori_{name}.gif"
|
||
|
||
if not src.exists():
|
||
print(f"SKIP {name}: 源文件不存在 {src}")
|
||
continue
|
||
|
||
print(f"处理 {name}...")
|
||
try:
|
||
process_gif(src, dst)
|
||
total_kb += Path(dst).stat().st_size / 1024
|
||
except subprocess.CalledProcessError as e:
|
||
print(f" ERROR: gifsicle 失败: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
print()
|
||
|
||
print(f"=== 合计输出: {total_kb:.1f} KB ({total_kb / 1024:.2f} MB) ===")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|