From eceadda80794216ca9905d6d14e779013e74ecd7 Mon Sep 17 00:00:00 2001 From: Rdzleo Date: Fri, 15 May 2026 17:38:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Phase=2010=20step=201+2=20-=20?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E5=9B=BE=20+=20=E4=B8=AD=E6=96=87=E5=AD=97?= =?UTF-8?q?=E5=B9=95=20+=20=E6=95=B0=E5=AD=97=E4=BA=BA=E9=80=8F=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完成数字人模式 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) --- main/dzbj/ai_chat_ui_eaf.c | 119 ++++++++++- .../src/widget/anim/gfx_anim.c | 33 +-- tools/patch_eaf_transparency.py | 197 ++++++++++++++++++ 3 files changed, 333 insertions(+), 16 deletions(-) create mode 100644 tools/patch_eaf_transparency.py diff --git a/main/dzbj/ai_chat_ui_eaf.c b/main/dzbj/ai_chat_ui_eaf.c index b626466..5600d24 100644 --- a/main/dzbj/ai_chat_ui_eaf.c +++ b/main/dzbj/ai_chat_ui_eaf.c @@ -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 .rodata,0 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, "字幕: %s(PoC 阶段暂不显示)", 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) { diff --git a/managed_components/espressif2022__esp_emote_gfx/src/widget/anim/gfx_anim.c b/managed_components/espressif2022__esp_emote_gfx/src/widget/anim/gfx_anim.c index 09944d9..386fe04 100644 --- a/managed_components/espressif2022__esp_emote_gfx/src/widget/anim/gfx_anim.c +++ b/managed_components/espressif2022__esp_emote_gfx/src/widget/anim/gfx_anim.c @@ -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=true,src_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++) { diff --git a/tools/patch_eaf_transparency.py b/tools/patch_eaf_transparency.py new file mode 100644 index 0000000..e46e213 --- /dev/null +++ b/tools/patch_eaf_transparency.py @@ -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(" 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<