feat(ui): Phase 10 step 1+2 - 背景图 + 中文字幕 + 数字人透明

完成数字人模式 UI 的"背景图叠加 + 实时字幕"功能。所有改动基于 EAF
框架(Phase 10 commit 31982ba),保持 0 个 lv_* UI 函数链接进固件。

Step 1: JPG 背景图叠加
- ai_chat_ui_eaf.c 加 esp_jpeg 解码 Background_360x360.jpg →
  RGB565 buffer (252KB PSRAM) → gfx_img_create 作为底层
- z-index 通过创建顺序控制: 背景 → 数字人 anim → 字幕 label
- 选项 A 保留 JPG (~20KB SPIFFS) 比选项 B (252KB .bin) 省 232KB

数字人透明: esp_emote_gfx local patch (gfx_anim.c::gfx_anim_render_24bit_pixels)
- 根因: 在线 EAF Packer 默认导出 24-bit 模式,工具不暴露 bit_depth
  选项,alpha 滑块拉到 0 无法保存,导致 GIF 透明像素被烘焙成屏幕背景
  色 (黑色 RGB888 #000000)
- 解决: 在 24-bit 渲染函数加 chroma key,跳过近黑像素让背景图露出
- 阈值演化 v1 (0x0000) → v3 (16) → v4 (24),最终 RGB888 ≤ (24,24,24)
- 保留 R/G/B AND 关系(三分量都小才透明),保护数字人本体暗色不破洞
- 双字节序判定,兼容 disp_config_t.flags.swap = true

Step 2: 中文字幕 (gfx_label + LVGL bitmap font 方案 A)
- 字体方案对比 3 方案后选方案 A(C 数组 XIP from Flash):
  • A: 1.4MB Flash + 0 RAM (推荐)
  • B: xiaozhi-fonts .bin 1.18MB SPIFFS + 1.18MB PSRAM
  • C: 自转 .bin ~2.8MB 总占用
- extern const lv_font_t font_puhui_20_4 → gfx_label_set_font 直接喂
- linker 副作用: 仅引入 7 个 LVGL 函数 ~2.2KB(lv_font_get_bitmap_fmt_txt
  / lv_mem_* 幽灵符号),无 lv_obj/lv_disp/lv_indev 等 UI 框架函数
- 字幕参数: 300×56 (2 行限制) + 行间距 4 + 贴底 y_ofs=-4
- GFX_LABEL_LONG_WRAP 字符级断行(中文友好),CENTER 居中
- 流式 TTS 节流 50ms(比 LVGL 100ms 短,EAF 渲染更快)

工具脚本 (tools/patch_eaf_transparency.py)
- 探索性脚本:解析 hiyori-assets.bin 尝试修补 EAF palette alpha
- 实际未生效(工具导出 24-bit 无 palette),保留作为 EAF bin layout
  解析参考

固件大小: 2.75MB → 4.30MB(+1.55MB = 字体 1.4MB + 字幕代码 + 背景图代码)
分区余量: 50% → 25% (1.42MB 空闲,安全)

完整踩坑经验已沉淀到 ~/.claude/CLAUDE.md §13 + 项目 memory。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rdzleo 2026-05-15 17:38:31 +08:00
parent 4de9f2ba61
commit eceadda807
3 changed files with 333 additions and 16 deletions

View File

@ -19,6 +19,14 @@
#include "ai_chat_ui.h"
#include "lcd.h" // 引用 panel_handle / lcd_io_handle
#include "fatfs.h" // Phase 10 step 1: DecodeImg 用于背景 JPG 解码
#include "jpeg_decoder.h" // esp_jpeg_image_output_t
#include "lvgl.h" // Phase 10 step 2: 引用 lv_font_t 类型(不调任何 LVGL 函数)
// Phase 10 step 2: 字幕字体LVGL bitmap font C 数组,编译进 Flash .rodata0 RAM
// esp_emote_gfx 的 gfx_font_lv_init_adapter 接收 lv_font_t* 直接用,
// 独立解析 LVGL bitmap 数据,不调用任何 LVGL 字体函数。
extern const lv_font_t font_puhui_20_4;
#include "gfx.h"
#include "esp_mmap_assets.h"
@ -47,6 +55,17 @@ typedef struct {
static eaf_cache_entry_t s_eaf_cache[8]; // 预留 8 个表情槽位
static int s_eaf_cache_count = 0;
// Phase 10 step 1: 背景图
#define EAF_BG_JPG_PATH "/spiflash/Background_360x360.jpg"
static uint8_t *s_bg_rgb565_data = NULL; // 解码后的 RGB565 buffer永不释放
static gfx_image_dsc_t s_bg_image_dsc;
static gfx_obj_t *s_bg_img_obj = NULL;
// Phase 10 step 2: 字幕
static gfx_obj_t *s_chat_label = NULL;
static char s_last_chat_content[256] = {0}; // 去重缓存
static int64_t s_last_chat_update_us = 0; // 100ms 节流
static const char *TAG = "AI_CHAT_EAF";
// ==========================================================
@ -354,7 +373,43 @@ void ai_chat_screen_init(void) {
// 4. 设置背景色 = BG_COLOR (0x000000 黑色,与 LVGL 版一致)
gfx_disp_set_bg_color(s_disp, GFX_COLOR_HEX(0x000000));
// 5. 创建动画对象 + 加载默认表情 m06
// 4.5 加载 360x360 静态背景图JPG → esp_jpeg 解码 → RGB565 → gfx_img
// 必须先于 gfx_anim_create让背景在最底层数字人在上层
{
esp_jpeg_image_output_t bg_outdata;
esp_err_t bg_ret = DecodeImg((char *)EAF_BG_JPG_PATH, &s_bg_rgb565_data, &bg_outdata);
if (bg_ret != ESP_OK || !s_bg_rgb565_data) {
ESP_LOGW(TAG, "背景图加载失败: %s继续无背景", EAF_BG_JPG_PATH);
} else {
s_bg_image_dsc.header.magic = C_ARRAY_HEADER_MAGIC;
s_bg_image_dsc.header.cf = GFX_COLOR_FORMAT_RGB565;
s_bg_image_dsc.header.w = bg_outdata.width;
s_bg_image_dsc.header.h = bg_outdata.height;
s_bg_image_dsc.header.stride = bg_outdata.width * 2;
s_bg_image_dsc.data_size = bg_outdata.width * bg_outdata.height * 2;
s_bg_image_dsc.data = s_bg_rgb565_data;
gfx_emote_lock(s_emote_handle);
s_bg_img_obj = gfx_img_create(s_disp);
if (s_bg_img_obj) {
gfx_img_src_t bg_src = {
.type = GFX_IMG_SRC_TYPE_IMAGE_DSC,
.data = &s_bg_image_dsc,
};
gfx_img_set_src_desc(s_bg_img_obj, &bg_src);
gfx_obj_align(s_bg_img_obj, GFX_ALIGN_CENTER, 0, 0);
ESP_LOGI(TAG, "背景图已加载: %dx%d (%u KB RGB565 @ %p)",
bg_outdata.width, bg_outdata.height,
(unsigned)(bg_outdata.width * bg_outdata.height * 2 / 1024),
s_bg_rgb565_data);
} else {
ESP_LOGE(TAG, "gfx_img_create 失败");
}
gfx_emote_unlock(s_emote_handle);
}
}
// 5. 创建动画对象 + 加载默认表情 m06在背景之后创建 → 显示在背景上层)
s_anim_obj = gfx_anim_create(s_disp);
if (!s_anim_obj) {
ESP_LOGE(TAG, "gfx_anim_create 失败");
@ -366,6 +421,37 @@ void ai_chat_screen_init(void) {
// 默认表情 = neutral → m06
switch_emotion_by_asset("hiyori_m06.eaf");
// 6. 字幕 label在 anim 之后创建,确保 z-index 在最上层)
gfx_emote_lock(s_emote_handle);
s_chat_label = gfx_label_create(s_disp);
if (s_chat_label) {
// 字体LVGL bitmap font C 数组直接喂入esp_emote_gfx 独立解析,不调 LVGL 函数)
gfx_label_set_font(s_chat_label, (gfx_font_t)&font_puhui_20_4);
// 尺寸:宽 300 / 高 56最多 2 行 × 24px 行高 + 8px 余量;超出部分自动 clip
// - 字体 20px + line_spacing 4 = 24px/行
// - height = 56 → 2 行 + 余白,避免裁剪到字底
// - 超过 2 行的字幕被 height clip 不显示(长字幕只看到前两行)
// - 300 比 320 略小,给左右各 ~30px 余白
gfx_obj_set_size(s_chat_label, 300, 56);
// 自动换行(按 obj geometry width 断行,中文支持字符级断行)
gfx_label_set_long_mode(s_chat_label, GFX_LABEL_LONG_WRAP);
// 居中对齐
gfx_label_set_text_align(s_chat_label, GFX_TEXT_ALIGN_CENTER);
// 黑色字(与原 LVGL 版一致,背景图浅色)
gfx_label_set_color(s_chat_label, GFX_COLOR_HEX(0x000000));
gfx_label_set_bg_enable(s_chat_label, false); // 无背景框
gfx_label_set_line_spacing(s_chat_label, 4); // 行间距 4px
// 底部贴边y_ofs = -4贴近屏幕底留 4px 余白防溢出)
gfx_obj_align(s_chat_label, GFX_ALIGN_BOTTOM_MID, 0, -4);
gfx_label_set_text(s_chat_label, "");
// 隐藏,等收到字幕再显示
gfx_obj_set_visible(s_chat_label, false);
ESP_LOGI(TAG, "字幕 label 创建成功300×90, 黑字, WRAP, 居中)");
} else {
ESP_LOGE(TAG, "字幕 label 创建失败");
}
gfx_emote_unlock(s_emote_handle);
ESP_LOGI(TAG, "=== EAF 数字人 UI 初始化完成 ===");
}
@ -392,10 +478,35 @@ void ai_chat_set_emotion(const char* emotion) {
void ai_chat_set_chat_message(const char* role, const char* content) {
(void)role;
// PoC 阶段不显示字幕gfx_label 需要字体资源接驳,留待后续)
if (content && content[0]) {
ESP_LOGI(TAG, "字幕: %sPoC 阶段暂不显示)", content);
if (!s_initialized || !s_chat_label) return;
if (!content) content = "";
// 去重:流式 ASR 大量重复中间结果,相同内容直接返回
if (strncmp(s_last_chat_content, content, sizeof(s_last_chat_content)) == 0) {
return;
}
// 50ms 节流EAF 渲染比 LVGL 快很多,缩短节流让字幕更跟手)
// 例外:空内容(清空字幕)不节流
int64_t now_us = esp_timer_get_time();
if (content[0] != '\0' && (now_us - s_last_chat_update_us) < 50000) {
return;
}
s_last_chat_update_us = now_us;
esp_err_t lk = gfx_emote_lock(s_emote_handle);
if (lk != ESP_OK) {
ESP_LOGW(TAG, "字幕 lock 失败,跳过");
return;
}
gfx_label_set_text(s_chat_label, content);
// 空内容时隐藏(避免空 label 占位)
gfx_obj_set_visible(s_chat_label, content[0] != '\0');
gfx_emote_unlock(s_emote_handle);
// 缓存当前内容
strncpy(s_last_chat_content, content, sizeof(s_last_chat_content) - 1);
s_last_chat_content[sizeof(s_last_chat_content) - 1] = '\0';
}
void ai_chat_resume_animation(void) {

View File

@ -651,23 +651,32 @@ static void gfx_anim_render_24bit_pixels(gfx_color_t *dest_pixels, gfx_coord_t d
uint16_t *src_pixels_16 = (uint16_t *)src_pixels;
uint16_t *dest_pixels_16 = (uint16_t *)dest_pixels;
// Phase 10 LOCAL PATCH v3: 24-bit chroma key with 双字节序近黑判定
// ESP Emote GFX Packer 把 GIF 透明像素烘焙成黑色 + 边缘抗锯齿产生近黑噪点。
// 由于 disp 配置 swap=truesrc_pixels 可能是 swap 后的字节序,
// 同时按小端和大端解析 RGB565任一解释为"近黑"则视为透明。
// 近黑阈值 v4: R≤3(5b) / G≤6(6b) / B≤3(5b) ≈ RGB888 ≤ (24,24,24)
// 比 v3 (16) 略放宽吸收抗锯齿噪点;保留 AND 关系(三分量都小才透明)
// 保护 hiyori头发(40-60,30-50,30-50) / 海军领(20,40,80) / 衣服线条(30,30,30)
// 都至少有一个分量 > 24 → 不会误透明本体
// 注意reconfigure 后此 patch 会丢失,需 reapply
#define _IS_NEAR_BLACK_RGB565(p) ( \
(((p) >> 11) & 0x1F) <= 3 && \
(((p) >> 5) & 0x3F) <= 6 && \
((p) & 0x1F) <= 3)
for (int32_t y = 0; y < clip_height; y++) {
uint16_t *dst_row = dest_pixels_16 + y * dest_stride;
const uint16_t *src_row = src_pixels_16 + y * src_stride;
int32_t x = 0;
int32_t x_end4 = clip_width - 4;
for (; x <= x_end4; x += 4) {
uint32_t *d32 = (uint32_t *)(dst_row + x);
const uint32_t *s32 = (const uint32_t *)(src_row + x);
d32[0] = s32[0];
d32[1] = s32[1];
}
for (; x < clip_width; x++) {
dst_row[x] = src_row[x];
for (int32_t x = 0; x < clip_width; x++) {
uint16_t pixel = src_row[x];
uint16_t pixel_swap = (uint16_t)((pixel >> 8) | (pixel << 8));
// 任一字节序解释都视为近黑则跳过(保证 swap on/off 都能正确)
if (!(_IS_NEAR_BLACK_RGB565(pixel) || _IS_NEAR_BLACK_RGB565(pixel_swap))) {
dst_row[x] = pixel;
}
}
}
#undef _IS_NEAR_BLACK_RGB565
if (mirror_mode != GFX_MIRROR_DISABLED) {
for (int32_t y = 0; y < clip_height; y++) {

View File

@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""
Phase 10 step 1.5: 修补 hiyori-assets.bin hiyori GIF 黑色背景变透明
ESP Emote GFX Packer 在线工具导出 EAF 时把 GIF 透明像素填充为不透明黑色
(palette idx ? = BGRA 0x00 0x00 0x00 0xFF)导致叠加到背景图上显示黑色矩形
本脚本直接 patch .bin 文件内嵌的 EAF 数据
1. 解析 MMAP bin 找每个 EAF 资源
2. 对每个 EAF 解析每帧 header palette RGB=黑色 entry
3. 把这些 entry alpha 字节从 0xFF 0x00 idx 视为透明
4. 重算 EAF stored_chk 写回
EAF frame header layout (from gfx_eaf_dec.c):
[9] bit_depth (1B): 4/8/24
[10-11] width
[12-13] height
[14-15] blocks
[16-17] block_height
[18+] block_len table: blocks × 4B
[+] palette: num_colors × 4B (BGRA)
[+] data
EAF main header (from gfx_eaf_dec.h):
[0] format magic 0x89
[1-3] "EAF"
[4-7] total_frames (uint32 LE)
[8-11] stored_chk (uint32 LE)
[12-15] stored_len (uint32 LE)
[16+] frame table: total_frames × 8B (frame_size + frame_offset)
[16 + total_frames*8] frame data (each frame: 2B 0x5A5A magic + frame_header + data)
"""
import sys
import struct
from pathlib import Path
BIN_PATH = Path(__file__).parent.parent / "spiffs_image" / "hiyori-assets.bin"
def patch_eaf(eaf_bytes: bytearray) -> int:
"""对单个 EAF 二进制内容做透明 patch返回修改的 palette entry 数"""
# 校验 EAF magic
if eaf_bytes[0] != 0x89 or eaf_bytes[1:4] not in (b"EAF", b"AAF"):
raise ValueError(f"非 EAF/AAF 数据 (开头: {eaf_bytes[:4].hex()})")
total_frames = struct.unpack("<I", eaf_bytes[4:8])[0]
stored_chk_orig = struct.unpack("<I", eaf_bytes[8:12])[0]
stored_len = struct.unpack("<I", eaf_bytes[12:16])[0]
# 校验原 checksum
calc_orig = sum(eaf_bytes[16:16 + stored_len]) & 0xFFFFFFFF
if calc_orig != stored_chk_orig:
print(f" ⚠️ 原 checksum 不匹配 (stored={stored_chk_orig:#x} calc={calc_orig:#x})")
print(f" stored_len={stored_len} 数据长度={len(eaf_bytes)-16}")
# 容忍esp_mmap_assets 短读 2 字节也可能影响这里,不强中止
print(f" EAF: frames={total_frames}, stored_chk={stored_chk_orig:#x}, stored_len={stored_len}")
# frame table 起点 = offset 16
# 每 entry 8B: frame_size(4) + frame_offset(4)
# 帧数据起点 = 16 + total_frames * 8
frame_table_off = 16
frame_data_base = frame_table_off + total_frames * 8
patched_entries = 0
for fi in range(total_frames):
entry_off = frame_table_off + fi * 8
frame_size = struct.unpack("<I", eaf_bytes[entry_off:entry_off+4])[0]
frame_offset = struct.unpack("<I", eaf_bytes[entry_off+4:entry_off+8])[0]
# 帧绝对位置 = frame_data_base + frame_offset
# 前 2B 是 0x5A 0x5A magic prefixEAF_MAGIC_HEAD
abs_frame_pos = frame_data_base + frame_offset
if abs_frame_pos + 2 > len(eaf_bytes):
print(f" ❌ frame {fi} 偏移越界 (abs={abs_frame_pos}, total={len(eaf_bytes)})")
continue
# 验证 frame magic
if eaf_bytes[abs_frame_pos] != 0x5A or eaf_bytes[abs_frame_pos+1] != 0x5A:
print(f" ❌ frame {fi} magic 错误 (got {eaf_bytes[abs_frame_pos]:#x} {eaf_bytes[abs_frame_pos+1]:#x})")
continue
# frame_header 从 magic 后开始,但 BIT_DEPTH_OFFSET = 9 是相对什么?
# 看 gfx_eaf_dec.c line 198: file_data[EAF_FRAME_BIT_DEPTH_OFFSET]
# 其中 file_data = entries[i].frame_mem (注:含 0x5A5A magic 前缀)
# 所以 file_data + 9 = 帧 magic 前缀之后第 7 字节
# layout (相对 abs_frame_pos):
# [0-1] 0x5A 0x5A magic
# [2-8] format (3) + version (4)
# [9] bit_depth
# [10-11] width
# [12-13] height
# [14-15] blocks
# [16-17] block_height
# [18+] block_len: blocks × 4B
# [+] palette: (1<<bit_depth) × 4B (BGRA)
bit_depth = eaf_bytes[abs_frame_pos + 9]
if bit_depth not in (4, 8, 24):
print(f" ❌ frame {fi} bit_depth 异常: {bit_depth}")
continue
if bit_depth == 24:
# 24-bit 无 palette
continue
blocks = struct.unpack("<H", eaf_bytes[abs_frame_pos+14:abs_frame_pos+16])[0]
num_colors = 1 << bit_depth
palette_off = abs_frame_pos + 18 + blocks * 4
# 扫 palette 找 RGB=0,0,0 entries
for ci in range(num_colors):
entry = abs_frame_pos + 18 + blocks * 4 + ci * 4 - abs_frame_pos + palette_off - palette_off
entry_abs = palette_off + ci * 4
b = eaf_bytes[entry_abs + 0]
g = eaf_bytes[entry_abs + 1]
r = eaf_bytes[entry_abs + 2]
a = eaf_bytes[entry_abs + 3]
# 找 R=G=B=0 且 A 非 0 的 entry改 A=0
if b == 0 and g == 0 and r == 0 and a != 0:
eaf_bytes[entry_abs + 3] = 0
patched_entries += 1
if patched_entries <= 5 or fi == 0:
print(f" frame[{fi}] palette[{ci}]: BGRA={b:02x}{g:02x}{r:02x}{a:02x} → 00000000 透明")
# 重算 stored_chk
new_chk = sum(eaf_bytes[16:16 + stored_len]) & 0xFFFFFFFF
struct.pack_into("<I", eaf_bytes, 8, new_chk)
print(f" ✓ checksum 重算: {stored_chk_orig:#x}{new_chk:#x}, patched {patched_entries} 个 palette entries")
return patched_entries
def main():
if not BIN_PATH.exists():
print(f"❌ 文件不存在: {BIN_PATH}")
sys.exit(1)
data = bytearray(BIN_PATH.read_bytes())
print(f"载入 {BIN_PATH} ({len(data)} bytes)")
# 解析 MMAP header
if bytes(data[:4]) != b"MMAP":
print(f"❌ 不是 MMAP 文件 (head={data[:4]})")
sys.exit(1)
file_count = struct.unpack("<I", data[12:16])[0]
print(f"MMAP file_count = {file_count}")
ENTRY_SIZE = 28
DATA_START = 0x20 + file_count * ENTRY_SIZE
total_patched = 0
for i in range(file_count):
entry_off = 0x20 + i * ENTRY_SIZE
name = data[entry_off:entry_off+16].split(b'\0')[0].decode('utf-8', errors='replace')
size = struct.unpack("<I", data[entry_off+16:entry_off+20])[0]
offset = struct.unpack("<I", data[entry_off+20:entry_off+24])[0]
if not name.endswith('.eaf'):
print(f"\n[{i}] 跳过非 EAF: {name}")
continue
# 真实位置 = DATA_START + offset + 2 (跳 0x5A5A magic)
real_off = DATA_START + offset + 2
print(f"\n[{i}] {name} @ file_offset={real_off} size={size}")
# 抽出 EAF 数据(含 magic prefix 跳过)
eaf_view = data[real_off:real_off + size]
if len(eaf_view) != size:
print(f" ❌ 数据截断: 期望 {size}, 实际 {len(eaf_view)}")
continue
# 转 bytearray 才能 in-place 修改
eaf_buf = bytearray(eaf_view)
try:
patched = patch_eaf(eaf_buf)
except Exception as e:
print(f" ❌ patch 失败: {e}")
continue
total_patched += patched
# 写回原 data
data[real_off:real_off + size] = eaf_buf
# 备份原文件
backup = BIN_PATH.with_suffix('.bin.bak')
if not backup.exists():
backup.write_bytes(BIN_PATH.read_bytes())
print(f"\n✓ 原文件备份到: {backup}")
else:
print(f"\n(备份已存在: {backup})")
BIN_PATH.write_bytes(bytes(data))
print(f"✓ 已写回 {BIN_PATH}")
print(f"✓ 共 patched {total_patched} 个 palette entries黑色 RGB+A=FF → A=00")
if __name__ == "__main__":
main()