feat(lcd): animate fortune results with shared GFX layers

This commit is contained in:
chenjintao
2026-10-10 11:36:54 +08:00
parent bf06618848
commit ee32b4d63f
26 changed files with 3063 additions and 11 deletions
+11 -1
View File
@@ -234,7 +234,7 @@ Use a clear conventional format:
和预览,不把 tq-cat 的实现或其他目录里的旧文件当成当前工程。 和预览,不把 tq-cat 的实现或其他目录里的旧文件当成当前工程。
2. 优先查看 `lcd_direct.c`、领域表情目录、平台公共接口、构建列表和 Kconfig。 2. 优先查看 `lcd_direct.c`、领域表情目录、平台公共接口、构建列表和 Kconfig。
图层制作还需阅读 `split_idle_expression.py`、`compress_idle_expression.py`。 图层制作还需阅读 `split_idle_expression.py`、`compress_idle_expression.py`。
3. 当前是待机/listening GFX 与其他 PNG 混合后端;未接入 esp_emote_expression 状态机, 3. 当前是待机/listening/抽签 GFX 与其他 PNG 混合后端;未接入 esp_emote_expression 状态机,
也未整体迁移到 LVGL。不要凭依赖存在或请求优先级字段推断功能已经实现。 也未整体迁移到 LVGL。不要凭依赖存在或请求优先级字段推断功能已经实现。
### 必须保持的正确性 ### 必须保持的正确性
@@ -314,6 +314,15 @@ Use a clear conventional format:
22. 实机验收包括待机/表情切换、跨分钟、充电、缓存启动、唤醒与网络并行、 22. 实机验收包括待机/表情切换、跨分钟、充电、缓存启动、唤醒与网络并行、
取消和超时。区分源码检查、主机测试、编译、设备日志与用户观感证据。 取消和超时。区分源码检查、主机测试、编译、设备日志与用户观感证据。
没有 TE 同步,不能承诺完全无撕裂;用户满意也不等于已量化稳定帧率。 没有 TE 同步,不能承诺完全无撕裂;用户满意也不等于已量化稳定帧率。
23. 抽签还需阅读 `docs/抽签分层动画开发说明.zh-CN.md`。共享六图层与五份签文,
一次只解压共享图层及所选签文;LZ4 与旧四字节 RLE 不是同一格式。
`prepare_fortune_expression.py --check` 验证生成一致性/往返,
`test_lcd_fortune_gfx.py` 验证实际 C 解码、几何、取消和失败释放。
不能把所有结果都常驻或缓存全屏中间帧;退出先解绑 GFX 再释放 PSRAM。
动画前准备失败才可 PNG 后备,取消/传输失败不得重播旧六帧。
保留原领域结果选择和六步匹配;不得在平台重新随机抽签或改签文语义。
保持 `(56,20,208,220)` 安全窗口,覆盖放大爪尖且避开时间/电池;
图层尺寸或几何变化须同步生成器、预览与测试,不只替换 PNG。
回归命令(仓库根目录): 回归命令(仓库根目录):
@@ -322,6 +331,7 @@ python3 tools/test_lcd_status_hud.py
python3 tools/test_lcd_emote_transport.py python3 tools/test_lcd_emote_transport.py
python3 tools/test_lcd_emote_timeline.py python3 tools/test_lcd_emote_timeline.py
python3 tools/test_lcd_listening_gfx.py python3 tools/test_lcd_listening_gfx.py
python3 tools/test_lcd_fortune_gfx.py
``` ```
提交只包含任务相关文件,遵守主题分支与原有提交规则;不要提交临时补丁脚本、 提交只包含任务相关文件,遵守主题分支与原有提交规则;不要提交临时补丁脚本、
@@ -659,6 +659,20 @@ static void handle_command_line(char *line)
handle_prepare_rotate(key_version); handle_prepare_rotate(key_version);
} else if (strcmp(cmd, "lcd_test") == 0) { } else if (strcmp(cmd, "lcd_test") == 0) {
handle_lcd_test(); handle_lcd_test();
} else if (strcmp(cmd, "lcd_fortune_test") == 0) {
/* 有界、仅显示测试;不调用云抽签、不写 NVS、不改变语义选择映射。 */
if (argc != 2 || argv[1][0] < '0' || argv[1][0] > '4' || argv[1][1] != '\0') {
cli_write_error("invalid_fortune_index");
return;
}
const domain_expression_request_t request = {
.id = DOMAIN_EXPRESSION_CHOUQIAN,
.trigger = DOMAIN_EXPRESSION_TRIGGER_FACTORY_LCD_TEST,
.chouqian_result_index_valid = true,
.chouqian_result_index = (size_t)(argv[1][0] - '0'),
};
if (domain_runtime_request_expression(&request) == ESP_OK) cli_write_ok();
else cli_write_error("lcd_fortune_test_failed");
} else { } else {
cli_write_error("unknown_command"); cli_write_error("unknown_command");
} }
+2
View File
@@ -31,6 +31,8 @@ if(CONFIG_TQ_LCD_EMOTE_GFX_TEST)
list(APPEND platform_srcs list(APPEND platform_srcs
"src/lcd_emote_assets.c" "src/lcd_emote_assets.c"
"src/lcd_emote_listening.c" "src/lcd_emote_listening.c"
"src/lcd_emote_fortune.c"
"src/generated/emote_gfx/fortune_assets.c"
"src/generated/emote_gfx/idle_base.c" "src/generated/emote_gfx/idle_base.c"
"src/generated/emote_gfx/idle_eyes_open.c" "src/generated/emote_gfx/idle_eyes_open.c"
"src/generated/emote_gfx/listening_base.c" "src/generated/emote_gfx/listening_base.c"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,114 @@
{
"canvas": [
320,
240
],
"window": [
56,
20,
208,
220
],
"layers": [
{
"name": "pile",
"width": 208,
"height": 76,
"x": 56,
"y": 164,
"raw_bytes": 47424,
"lz4_bytes": 9417
},
{
"name": "back",
"width": 63,
"height": 113,
"x": 0,
"y": 0,
"raw_bytes": 21357,
"lz4_bytes": 4179
},
{
"name": "edge",
"width": 41,
"height": 110,
"x": 0,
"y": 0,
"raw_bytes": 13530,
"lz4_bytes": 4389
},
{
"name": "front",
"width": 100,
"height": 190,
"x": 0,
"y": 0,
"raw_bytes": 57000,
"lz4_bytes": 10774
},
{
"name": "question",
"width": 27,
"height": 50,
"x": 0,
"y": 0,
"raw_bytes": 4050,
"lz4_bytes": 817
},
{
"name": "rod",
"width": 169,
"height": 56,
"x": 0,
"y": 40,
"raw_bytes": 28392,
"lz4_bytes": 2094
},
{
"name": "text_buyun",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2638
},
{
"name": "text_heping",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2851
},
{
"name": "text_shangshang",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2069
},
{
"name": "text_xiaoji",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2210
},
{
"name": "text_zhongshang",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2109
}
],
"note": "黑背景归零;共享爪子,签文字形来自原图;旧 PNG 保留"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,32 @@
#pragma once
#include "lcd_emote_assets.h"
/* 平台私有图层与几何;领域仍选择资产序列,不引入业务状态机。 */
enum { LCD_FORTUNE_PILE, LCD_FORTUNE_BACK, LCD_FORTUNE_EDGE,
LCD_FORTUNE_FRONT, LCD_FORTUNE_QUESTION, LCD_FORTUNE_ROD,
LCD_FORTUNE_SHARED_COUNT, LCD_FORTUNE_RESULT_COUNT = 5 };
#define LCD_FORTUNE_X 56
#define LCD_FORTUNE_Y 20
#define LCD_FORTUNE_WIDTH 208U
#define LCD_FORTUNE_HEIGHT 220U
#define LCD_FORTUNE_MOTION_MS 2500U
#define LCD_FORTUNE_TOTAL_MS 4000U
#define LCD_FORTUNE_HOLD_MS 1500U
#define LCD_FORTUNE_STEP_MS 34U
typedef struct {
uint16_t width, height, x, y;
size_t compressed_size;
const uint8_t *data;
} lcd_fortune_asset_t;
extern const lcd_fortune_asset_t lcd_fortune_shared[LCD_FORTUNE_SHARED_COUNT];
extern const lcd_fortune_asset_t lcd_fortune_text[LCD_FORTUNE_RESULT_COUNT];
/* 有界 LZ4 block 解码,无动态内存;拒绝截断、无效回指及长度不符。 */
bool lcd_emote_fortune_decode(const uint8_t *src, size_t size, uint8_t *dst, size_t capacity);
extern const char *const lcd_fortune_result_dirs[LCD_FORTUNE_RESULT_COUNT];
/* RGB565 高字节在前 + 独立 A8;所有像素由调用方拥有。 */
typedef struct { uint16_t width, height; const uint8_t *pixels; } lcd_fortune_texture_t;
/* 输出不透明 RGB565,完整重画固定局部窗口,确保旧轮廓恢复而不残影。 */
bool lcd_emote_fortune_build_frame(uint8_t *dst, size_t capacity,
const lcd_fortune_texture_t shared[LCD_FORTUNE_SHARED_COUNT],
const lcd_fortune_texture_t *text, uint32_t time_ms);
File diff suppressed because it is too large Load Diff
+207
View File
@@ -38,6 +38,7 @@
#include "gfx.h" #include "gfx.h"
#include "lcd_emote_assets.h" #include "lcd_emote_assets.h"
#include "lcd_emote_listening.h" #include "lcd_emote_listening.h"
#include "lcd_emote_fortune.h"
#endif #endif
#define LCD_DIRECT_SPI_HOST SPI2_HOST #define LCD_DIRECT_SPI_HOST SPI2_HOST
@@ -970,6 +971,7 @@ static esp_err_t lcd_direct_listening_prepare(void)
static esp_err_t lcd_direct_emote_select_face(bool listening) static esp_err_t lcd_direct_emote_select_face(bool listening)
{ {
const lcd_emote_rle_asset_t *base = listening ? &listening_base_rle_asset : &idle_base_rle_asset; const lcd_emote_rle_asset_t *base = listening ? &listening_base_rle_asset : &idle_base_rle_asset;
ESP_RETURN_ON_ERROR(gfx_obj_set_visible(s_emote_background, true), TAG, "show face base failed");
ESP_RETURN_ON_ERROR(gfx_img_set_src(s_emote_background, ESP_RETURN_ON_ERROR(gfx_img_set_src(s_emote_background,
listening ? &s_listening_base_image : &s_emote_base_image), TAG, "switch face base failed"); listening ? &s_listening_base_image : &s_emote_base_image), TAG, "switch face base failed");
ESP_RETURN_ON_ERROR(gfx_obj_set_pos(s_emote_background, base->x, base->y), TAG, "switch face position failed"); ESP_RETURN_ON_ERROR(gfx_obj_set_pos(s_emote_background, base->x, base->y), TAG, "switch face position failed");
@@ -1166,6 +1168,203 @@ static esp_err_t lcd_direct_play_emote_face_until_pending(const lcd_direct_reque
} }
return result != ESP_OK ? result : lock_err; return result != ESP_OK ? result : lock_err;
} }
typedef struct {
lcd_fortune_texture_t shared[LCD_FORTUNE_SHARED_COUNT], text;
uint8_t *pixels[LCD_FORTUNE_SHARED_COUNT + 1], *frame;
gfx_image_dsc_t image, rod_image;
gfx_obj_t *object;
} lcd_direct_fortune_scene_t;
/* 极端锁/解绑故障保留已绑定资源到重启,不能释放给 GFX 的悬空指针。 */
static lcd_direct_fortune_scene_t *s_fortune_orphan;
static int lcd_direct_fortune_sequence_index(const lcd_direct_request_t *request)
{
if (request == NULL || request->step_count != 6) return -1;
for (size_t i = 0; i < 6; ++i) {
const platform_lcd_presentation_step_t *step = &request->steps[i];
if (step->asset_dir == NULL || step->frame_index != i % 3 ||
step->icon_id != PLATFORM_LCD_PRESENTATION_NO_ICON ||
step->delay_ms != (i == 5 ? 1500U : 500U)) return -1;
if (i < 3 && strcmp(step->asset_dir, "chouqian_qianzhi") != 0) return -1;
if (i >= 3 && strcmp(step->asset_dir, request->steps[3].asset_dir) != 0) return -1;
}
for (size_t i = 0; i < LCD_FORTUNE_RESULT_COUNT; ++i)
if (strcmp(request->steps[3].asset_dir, lcd_fortune_result_dirs[i]) == 0) return (int)i;
return -1; /* 不匹配的通用步骤仍使用原 PNG 播放,不偷换其他动作。 */
}
static void lcd_direct_fortune_free(lcd_direct_fortune_scene_t *scene)
{
if (scene == NULL) return;
for (size_t i = 0; i <= LCD_FORTUNE_SHARED_COUNT; ++i) free(scene->pixels[i]);
free(scene->frame);
free(scene);
}
static lcd_direct_fortune_scene_t *lcd_direct_fortune_prepare(size_t index)
{
if (index >= LCD_FORTUNE_RESULT_COUNT) return NULL;
lcd_direct_fortune_scene_t *scene = heap_caps_calloc(1, sizeof(*scene), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (scene == NULL) return NULL;
for (size_t i = 0; i <= LCD_FORTUNE_SHARED_COUNT; ++i) {
const lcd_fortune_asset_t *asset = i < LCD_FORTUNE_SHARED_COUNT ?
&lcd_fortune_shared[i] : &lcd_fortune_text[index];
size_t bytes = (size_t)asset->width * asset->height * 3;
scene->pixels[i] = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (scene->pixels[i] == NULL || !lcd_emote_fortune_decode(asset->data,
asset->compressed_size, scene->pixels[i], bytes)) {
lcd_direct_fortune_free(scene);
return NULL;
}
lcd_fortune_texture_t texture = {asset->width, asset->height, scene->pixels[i]};
if (i < LCD_FORTUNE_SHARED_COUNT) scene->shared[i] = texture;
else scene->text = texture;
}
const size_t bytes = LCD_FORTUNE_WIDTH * LCD_FORTUNE_HEIGHT * 2;
scene->frame = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (scene->frame == NULL) {lcd_direct_fortune_free(scene); return NULL;}
scene->image = (gfx_image_dsc_t) {
.header.cf = GFX_COLOR_FORMAT_RGB565, .header.magic = C_ARRAY_HEADER_MAGIC,
.header.w = LCD_FORTUNE_WIDTH, .header.h = LCD_FORTUNE_HEIGHT,
.data_size = bytes, .data = scene->frame,
};
const lcd_fortune_asset_t *rod = &lcd_fortune_shared[LCD_FORTUNE_ROD];
scene->rod_image = (gfx_image_dsc_t) {
.header.cf = GFX_COLOR_FORMAT_RGB565A8, .header.magic = C_ARRAY_HEADER_MAGIC,
.header.w = rod->width, .header.h = rod->height,
.data_size = (size_t)rod->width * rod->height * 3,
.data = scene->pixels[LCD_FORTUNE_ROD],
};
return scene;
}
/* 同一任务持锁退出并解绑对象,然后才能释放一次性 PSRAM 图层。 */
static esp_err_t lcd_direct_fortune_detach(lcd_direct_fortune_scene_t *scene)
{
esp_err_t err = gfx_emote_lock(s_emote_handle);
if (err != ESP_OK) return err;
portENTER_CRITICAL(&s_state_lock);
s_emote_active = false;
portEXIT_CRITICAL(&s_state_lock);
err = lcd_direct_emote_select_face(false);
if (scene->object != NULL) {
esp_err_t deleted = gfx_obj_delete(scene->object);
if (deleted == ESP_OK) scene->object = NULL;
if (err == ESP_OK) err = deleted;
}
esp_err_t unlock_err = gfx_emote_unlock(s_emote_handle);
return err != ESP_OK ? err : unlock_err;
}
static esp_err_t lcd_direct_play_fortune(size_t index)
{
if (s_fortune_orphan != NULL || s_emote_transfer_fault) return ESP_ERR_INVALID_STATE;
if (lcd_direct_should_stop_idle()) return ESP_ERR_INVALID_STATE;
const int64_t prepare_start_us = esp_timer_get_time();
lcd_direct_fortune_scene_t *scene = lcd_direct_fortune_prepare(index);
if (scene == NULL) return ESP_ERR_NOT_SUPPORTED;
esp_err_t result = gfx_emote_lock(s_emote_handle);
if (result != ESP_OK) {lcd_direct_fortune_free(scene); return result;}
bool started = false;
bool rod_hidden = false;
if (lcd_direct_should_stop_idle()) result = ESP_ERR_INVALID_STATE;
if (result == ESP_OK) {
scene->object = gfx_img_create(s_emote_disp);
result = scene->object != NULL ? ESP_OK : ESP_ERR_NO_MEM;
}
if (result == ESP_OK) result = gfx_obj_set_visible(s_emote_eyes_open, false);
if (result == ESP_OK) result = gfx_obj_set_visible(s_emote_closed_eyes, false);
if (result == ESP_OK && s_emote_cached_eyes != NULL) result = gfx_obj_set_visible(s_emote_cached_eyes, false);
if (result == ESP_OK && s_listening_eyes != NULL) result = gfx_obj_set_visible(s_listening_eyes, false);
if (result == ESP_OK) result = gfx_img_set_src(s_emote_background, &scene->rod_image);
if (result == ESP_OK) result = gfx_obj_set_pos(s_emote_background, 0, 40);
if (result == ESP_OK) result = gfx_obj_set_visible(s_emote_background, true);
if (result == ESP_OK) result = gfx_obj_set_pos(scene->object, LCD_FORTUNE_X, LCD_FORTUNE_Y);
if (result == ESP_OK) result = gfx_img_set_src(scene->object, &scene->image);
if (result == ESP_OK) result = lcd_direct_emote_update_hud(true, NULL);
esp_err_t unlock_err = gfx_emote_unlock(s_emote_handle);
if (result == ESP_OK) result = unlock_err;
size_t pixel_bytes = scene->image.data_size;
for (size_t i = 0; i < LCD_FORTUNE_SHARED_COUNT; ++i)
pixel_bytes += (size_t)scene->shared[i].width * scene->shared[i].height * 3;
pixel_bytes += (size_t)scene->text.width * scene->text.height * 3;
ESP_LOGI(TAG, "Fortune layers asset=%s pixels_psram=%u scene_bytes=%u prepare_us=%llu",
lcd_fortune_result_dirs[index], (unsigned)pixel_bytes, (unsigned)sizeof(*scene),
(unsigned long long)(esp_timer_get_time() - prepare_start_us));
const int64_t start_us = esp_timer_get_time();
#if CONFIG_TQ_LCD_EMOTE_FPS_LOG
uint32_t frames = 0, draw_attempts = 0, late = 0, max_draw_us = 0;
uint64_t total_draw_us = 0;
#endif
for (uint32_t ms = 0; result == ESP_OK;) {
if (lcd_direct_should_stop_idle()) {result = ESP_ERR_INVALID_STATE; break;}
const int64_t draw_start_us = esp_timer_get_time();
result = gfx_emote_lock(s_emote_handle);
if (result != ESP_OK) break;
if (!lcd_emote_fortune_build_frame(scene->frame, scene->image.data_size,
scene->shared, &scene->text, ms)) result = ESP_ERR_INVALID_SIZE;
if (result == ESP_OK) result = gfx_img_set_src(scene->object, &scene->image);
if (result == ESP_OK && ms >= 600 && !rod_hidden) {
result = gfx_obj_set_visible(s_emote_background, false);
rod_hidden = result == ESP_OK;
}
if (result == ESP_OK) result = lcd_direct_emote_update_hud(false, NULL);
if (result == ESP_OK) {
portENTER_CRITICAL(&s_state_lock);
s_emote_active = true;
portEXIT_CRITICAL(&s_state_lock);
if (!started) gfx_disp_refresh_all(s_emote_disp);
started = true;
result = lcd_direct_emote_refresh();
}
unlock_err = gfx_emote_unlock(s_emote_handle);
if (result == ESP_OK) result = unlock_err;
#if CONFIG_TQ_LCD_EMOTE_FPS_LOG
uint32_t draw_us = (uint32_t)(esp_timer_get_time() - draw_start_us);
total_draw_us += draw_us;
++draw_attempts;
if (draw_us > max_draw_us) max_draw_us = draw_us;
if (draw_us >= LCD_FORTUNE_STEP_MS * 1000) ++late;
if (result == ESP_OK) ++frames;
#else
(void)draw_start_us;
#endif
if (result != ESP_OK) break;
if (ms == LCD_FORTUNE_MOTION_MS) break;
uint32_t next_ms = ms + LCD_FORTUNE_STEP_MS;
if (next_ms > LCD_FORTUNE_MOTION_MS) next_ms = LCD_FORTUNE_MOTION_MS;
result = lcd_direct_emote_wait_until(start_us + (int64_t)next_ms * 1000);
ms = next_ms;
}
#if CONFIG_TQ_LCD_EMOTE_FPS_LOG
if (started && frames > 0) {
uint64_t motion_us = esp_timer_get_time() - start_us;
uint64_t fps100 = lcd_direct_emote_fps_x100(frames, motion_us);
ESP_LOGI(TAG, "fortune_fps result=%s frames=%u motion_us=%llu update_fps=%llu.%02llu "
"draw_avg_us=%llu draw_max_us=%u late_frames=%u status=%s",
lcd_fortune_result_dirs[index], (unsigned)frames, (unsigned long long)motion_us,
(unsigned long long)(fps100 / 100), (unsigned long long)(fps100 % 100),
(unsigned long long)(total_draw_us / draw_attempts), (unsigned)max_draw_us, (unsigned)late,
esp_err_to_name(result));
}
#endif
/* 运动仍锚定同一起点;迟到不能吞掉用户阅读签文所需的末帧停留。 */
if (result == ESP_OK) result = lcd_direct_emote_wait_until(
esp_timer_get_time() + (int64_t)LCD_FORTUNE_HOLD_MS * 1000);
esp_err_t detach_err = lcd_direct_fortune_detach(scene);
if (detach_err == ESP_OK) lcd_direct_fortune_free(scene);
else {
s_fortune_orphan = scene;
s_emote_transfer_fault = true;
ESP_LOGE(TAG, "Fortune detach failed; bound pixels retained, restart required");
}
if (result == ESP_OK) result = detach_err;
if (result == ESP_OK && !lcd_direct_should_stop_idle()) lcd_direct_signal_completion();
if (!started && detach_err == ESP_OK && result != ESP_ERR_INVALID_STATE)
return ESP_ERR_NOT_SUPPORTED;
return result;
}
#endif #endif
static uint16_t lcd_direct_swap_color(uint16_t color) static uint16_t lcd_direct_swap_color(uint16_t color)
@@ -2457,6 +2656,14 @@ static esp_err_t lcd_direct_play_step_sequence(const lcd_direct_request_t *reque
ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_ARG,
TAG, TAG,
"invalid step sequence"); "invalid step sequence");
#if CONFIG_TQ_LCD_EMOTE_GFX_TEST
int fortune = lcd_direct_fortune_sequence_index(request);
if (fortune >= 0 && s_emote_ready && LCD_DIRECT_WIDTH == 320 && LCD_DIRECT_HEIGHT == 240) {
esp_err_t err = lcd_direct_play_fortune((size_t)fortune);
if (err != ESP_ERR_NOT_SUPPORTED) return err;
ESP_LOGW(TAG, "Fortune layers unavailable; using PNG fallback");
}
#endif
for (size_t i = 0; i < request->step_count; ++i) { for (size_t i = 0; i < request->step_count; ++i) {
(void)lcd_direct_service_preheat(); (void)lcd_direct_service_preheat();
+200
View File
@@ -0,0 +1,200 @@
#include "lcd_emote_fortune.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
typedef struct { float x, y, u, v; } vertex_t;
static bool read_length(const uint8_t *src,size_t size,size_t *pos,size_t *length)
{
if (*length!=15) return true;
unsigned value;
do {
if (*pos>=size || *length>SIZE_MAX-255) return false;
value=src[(*pos)++]; *length+=value;
} while (value==255);
return true;
}
bool lcd_emote_fortune_decode(const uint8_t *src,size_t size,uint8_t *dst,size_t capacity)
{
if (!src || !size || !dst || !capacity) return false;
size_t pos=0,written=0;
while (pos<size) {
unsigned token=src[pos++]; size_t literals=token>>4;
if (!read_length(src,size,&pos,&literals) || literals>size-pos || literals>capacity-written) return false;
memcpy(dst+written,src+pos,literals);written+=literals;pos+=literals;
if (pos==size) return written==capacity && (token&15)==0;
if (size-pos<2) return false;
size_t offset=src[pos]|((size_t)src[pos+1]<<8);pos+=2;
size_t match=token&15;
if (!offset || offset>written || !read_length(src,size,&pos,&match) ||
match>SIZE_MAX-4 || match+4>capacity-written) return false;
match+=4;
for (size_t i=0;i<match;++i) {dst[written]=dst[written-offset];++written;}
}
return false;
}
static float smooth(float v) { v = fmaxf(0, fminf(1, v)); return v*v*(3-2*v); }
static uint16_t color_at(const lcd_fortune_texture_t *t, size_t i)
{ return ((uint16_t)t->pixels[i*2]<<8) | t->pixels[i*2+1]; }
static void blend(uint8_t *dst, int x, int y, uint16_t color, unsigned alpha)
{
x -= LCD_FORTUNE_X; y -= LCD_FORTUNE_Y;
if (x < 0 || y < 0 || x >= LCD_FORTUNE_WIDTH || y >= LCD_FORTUNE_HEIGHT || !alpha) return;
size_t i = ((size_t)y*LCD_FORTUNE_WIDTH+x)*2;
uint16_t old = ((uint16_t)dst[i]<<8) | dst[i+1];
unsigned r = (((color>>11)&31)*alpha + ((old>>11)&31)*(255-alpha)+127)/255;
unsigned g = (((color>>5)&63)*alpha + ((old>>5)&63)*(255-alpha)+127)/255;
unsigned b = ((color&31)*alpha + (old&31)*(255-alpha)+127)/255;
uint16_t out = (r<<11)|(g<<5)|b;
dst[i] = out>>8; dst[i+1] = out;
}
/* 双线性采样在预乘 Alpha 空间,避免黑色透明边缘造成轮廓暗边。 */
static void sample(uint8_t *dst, int x, int y, const lcd_fortune_texture_t *t,
float u, float v, unsigned opacity)
{
if (u < 0 || v < 0 || u > 1 || v > 1 || !opacity) return;
float sx = u*(t->width-1), sy = v*(t->height-1);
int x0 = (int)sx, y0 = (int)sy;
int x1 = x0+1 < t->width ? x0+1 : x0, y1 = y0+1 < t->height ? y0+1 : y0;
float fx = sx-x0, fy = sy-y0;
const size_t indices[4] = {(size_t)y0*t->width+x0, (size_t)y0*t->width+x1,
(size_t)y1*t->width+x0, (size_t)y1*t->width+x1};
const float weights[4] = {(1-fx)*(1-fy), fx*(1-fy), (1-fx)*fy, fx*fy};
float a=0, r=0, g=0, b=0;
size_t count = (size_t)t->width*t->height;
for (int i=0; i<4; ++i) {
float aw = weights[i]*t->pixels[count*2+indices[i]];
uint16_t c = color_at(t, indices[i]);
a += aw; r += ((c>>11)&31)*aw; g += ((c>>5)&63)*aw; b += (c&31)*aw;
}
if (a < 0.5f) return;
float inv_a = 1/a;
uint16_t c = ((unsigned)(r*inv_a+0.5f)<<11)|((unsigned)(g*inv_a+0.5f)<<5)|(unsigned)(b*inv_a+0.5f);
blend(dst,x,y,c,(unsigned)(a*opacity/255+0.5f));
}
static void blit(uint8_t *dst, const lcd_fortune_texture_t *t, int x, int y, unsigned opacity)
{
size_t count=(size_t)t->width*t->height;
for (int sy=0; sy<t->height; ++sy) for (int sx=0; sx<t->width; ++sx) {
size_t i=(size_t)sy*t->width+sx;
unsigned a=t->pixels[count*2+i]*opacity/255;
if (a) blend(dst,x+sx,y+sy,color_at(t,i),a);
}
}
static void triangle(uint8_t *dst, const lcd_fortune_texture_t *t,
vertex_t a, vertex_t b, vertex_t c, bool include_shared)
{
float det=(b.x-a.x)*(c.y-a.y)-(c.x-a.x)*(b.y-a.y);
if (fabsf(det)<0.01f) return;
int left=(int)floorf(fminf(a.x,fminf(b.x,c.x))), right=(int)ceilf(fmaxf(a.x,fmaxf(b.x,c.x)));
int top=(int)floorf(fminf(a.y,fminf(b.y,c.y))), bottom=(int)ceilf(fmaxf(a.y,fmaxf(b.y,c.y)));
if (left<LCD_FORTUNE_X) left=LCD_FORTUNE_X;
if (top<LCD_FORTUNE_Y) top=LCD_FORTUNE_Y;
if (right>LCD_FORTUNE_X+LCD_FORTUNE_WIDTH) right=LCD_FORTUNE_X+LCD_FORTUNE_WIDTH;
if (bottom>LCD_FORTUNE_Y+LCD_FORTUNE_HEIGHT) bottom=LCD_FORTUNE_Y+LCD_FORTUNE_HEIGHT;
float inv=1/det;
for (int y=top; y<bottom; ++y) for (int x=left; x<right; ++x) {
float px=x+0.5f-a.x, py=y+0.5f-a.y;
float wb=(px*(c.y-a.y)-py*(c.x-a.x))*inv;
float wc=((b.x-a.x)*py-(b.y-a.y)*px)*inv;
/* 两个三角形共边只属于一方,避免 Alpha 轮廓重复混合。 */
if (wb>=0 && wc>=0 && (include_shared ? wb+wc<=1 : wb+wc<1)) sample(dst,x,y,t,
a.u+wb*(b.u-a.u)+wc*(c.u-a.u), a.v+wb*(b.v-a.v)+wc*(c.v-a.v),255);
}
}
static vertex_t project(float u, float v, float width, float height, float theta,
float cx, float cy, bool side, bool front)
{
float c=cosf(theta), s=sinf(theta), roll=0.025f*s;
float x=side?0:(front?0.5f-u:u-0.5f)*width;
float z=side?(u-0.5f)*10:0;
float X=x*c-z*s, Z=x*s+z*c, k=320/(320+Z);
float xx=X*k, yy=(v-0.5f)*height*k;
return (vertex_t){cx+xx*cosf(roll)-yy*sinf(roll),cy+xx*sinf(roll)+yy*cosf(roll),u,v};
}
static void paw(uint8_t *dst, const lcd_fortune_texture_t *t, float width, float height,
float theta, float cx, float cy, bool side, bool front)
{
/* 16 条纵向纹理带近似透视;每带仅两个三角形,非三维模型。 */
for (int i=0; i<16; ++i) {
float u0=i/16.0f,u1=(i+1)/16.0f;
vertex_t a=project(u0,0,width,height,theta,cx,cy,side,front);
vertex_t b=project(u1,0,width,height,theta,cx,cy,side,front);
vertex_t c=project(u0,1,width,height,theta,cx,cy,side,front);
vertex_t d=project(u1,1,width,height,theta,cx,cy,side,front);
triangle(dst,t,a,b,c,true); triangle(dst,t,d,c,b,false);
}
}
static void flip_question(uint8_t *dst,const lcd_fortune_texture_t *t,float theta,float cx,float cy)
{
/* 小问号与签的正面共用透视平面;正面展开时不突然贴出平面图案。 */
vertex_t a=project(0.30f,0.43f,60,114,theta,cx,cy,false,true);
vertex_t b=project(0.70f,0.43f,60,114,theta,cx,cy,false,true);
vertex_t c=project(0.30f,0.816f,60,114,theta,cx,cy,false,true);
vertex_t d=project(0.70f,0.816f,60,114,theta,cx,cy,false,true);
/* 正面 local X 反向,因此按屏幕方向摆放 UV,签文不镜像。 */
a.u=c.u=0; b.u=d.u=1; a.v=b.v=0; c.v=d.v=1;
triangle(dst,t,a,b,c,true);triangle(dst,t,d,c,b,false);
}
static void overlay(uint8_t *dst,const lcd_fortune_texture_t *t,
float left,float top,float width,float height,float reveal)
{
for (int y=(int)floorf(top); y<(int)ceilf(top+height*reveal); ++y)
for (int x=(int)floorf(left); x<(int)ceilf(left+width); ++x)
sample(dst,x,y,t,(x+0.5f-left)/width,(y+0.5f-top)/height,255);
}
static void stars(uint8_t *dst,float progress)
{
static const uint8_t points[][3]={{89,62,4},{232,91,6},{75,132,5},{251,145,4},
{119,195,5},{229,211,6},{198,40,4},{79,191,4}};
unsigned alpha=(unsigned)(255*progress);
for (size_t i=0;i<sizeof(points)/sizeof(points[0]);++i) {
int radius=points[i][2];
for (int j=-radius;j<=radius;++j) {
unsigned a=alpha*(radius+1-abs(j))/(radius+1);
blend(dst,points[i][0]+j,points[i][1],0xffad,a);
if (j) blend(dst,points[i][0],points[i][1]+j,0xffad,a);
}
}
}
bool lcd_emote_fortune_build_frame(uint8_t *dst,size_t capacity,
const lcd_fortune_texture_t shared[LCD_FORTUNE_SHARED_COUNT],
const lcd_fortune_texture_t *text,uint32_t time_ms)
{
const size_t bytes=LCD_FORTUNE_WIDTH*LCD_FORTUNE_HEIGHT*2;
if (!dst || capacity<bytes || !shared || !text || !text->pixels ||
!text->width || !text->height || text->width>320 || text->height>240) return false;
for (int i=0;i<LCD_FORTUNE_SHARED_COUNT;++i)
if (!shared[i].pixels || !shared[i].width || !shared[i].height ||
shared[i].width>320 || shared[i].height>240) return false;
memset(dst,0,bytes);
if (time_ms>LCD_FORTUNE_MOTION_MS) time_ms=LCD_FORTUNE_MOTION_MS;
float zoom=smooth(((float)time_ms-1900)/600);
if (time_ms<600) blit(dst,&shared[LCD_FORTUNE_ROD],lcd_fortune_shared[LCD_FORTUNE_ROD].x,
lcd_fortune_shared[LCD_FORTUNE_ROD].y,255);
float width=60+40*zoom,height=114+76*zoom,cx=160,cy=94+25*zoom;
if (time_ms<600) {
cy=212-118*smooth(time_ms/600.0f);
paw(dst,&shared[LCD_FORTUNE_BACK],60,110,0,cx,cy,false,false);
} else if (time_ms<1200) {
float theta=3.14159265f*smooth((time_ms-600)/600.0f), c=cosf(theta), s=sinf(theta);
bool side=fabsf(c)<0.17f,front=c<0;
const lcd_fortune_texture_t *t=&shared[side?LCD_FORTUNE_EDGE:front?LCD_FORTUNE_FRONT:LCD_FORTUNE_BACK];
paw(dst,t,60,side?110:114,theta,cx+4*s,cy-3*s,side,front);
if (front && !side) flip_question(dst,&shared[LCD_FORTUNE_QUESTION],theta,cx+4*s,cy-3*s);
} else {
paw(dst,&shared[LCD_FORTUNE_FRONT],width,height,3.14159265f,cx,cy,false,true);
}
if (time_ms>=1200 && time_ms<1600)
overlay(dst,&shared[LCD_FORTUNE_QUESTION],cx-12,cy-8,24,44,1);
if (time_ms>=1600) {
float left=cx-width/2,top=cy-height/2;
overlay(dst,text,left+width*0.26f,top+height*70/190,width*0.48f,height*104/190,
smooth((time_ms-1600)/300.0f));
}
/* 下方爪子遮挡抽出时的签底;放大阶段淡出,最终背景纯黑。 */
blit(dst,&shared[LCD_FORTUNE_PILE],lcd_fortune_shared[LCD_FORTUNE_PILE].x,
lcd_fortune_shared[LCD_FORTUNE_PILE].y,(unsigned)(255*(1-zoom)));
if (zoom>0) stars(dst,zoom);
return true;
}
+32 -5
View File
@@ -1,6 +1,6 @@
# GFX 表情显示应用与开发指南 # GFX 表情显示应用与开发指南
本文面向本项目的开发者,说明当前 `esp_emote_gfx` 的具体应用及后续开发方法。正文使用中文,代码、路径和日志字段保留原名以便检索。适用基线为 2026 年 10 月 9 日的工作区实现;资源和性能数字随代码变化而变化。 本文面向本项目的开发者,说明当前 `esp_emote_gfx` 的具体应用及后续开发方法。正文使用中文,代码、路径和日志字段保留原名以便检索。适用基线包含 2026 年 10 月 10 日的抽签分层动画;资源和性能数字随代码变化而变化。
## 一、当前方案与边界 ## 一、当前方案与边界
@@ -8,9 +8,9 @@
- 底层为 ESP32-S3、ST7789P3、单线 SPI,当前像素时钟为 40 MHz,逻辑画布为 320×240。 - 底层为 ESP32-S3、ST7789P3、单线 SPI,当前像素时钟为 40 MHz,逻辑画布为 320×240。
- 当前使用 `esp_emote_gfx` 3.0.5,由 `platform` 私有依赖。没有接入 `esp_emote_expression` 状态管理组件,也没有把当前表情绘制建立在 LVGL 上。依赖目录中存在其他库不等于当前显示链路使用它们。 - 当前使用 `esp_emote_gfx` 3.0.5,由 `platform` 私有依赖。没有接入 `esp_emote_expression` 状态管理组件,也没有把当前表情绘制建立在 LVGL 上。依赖目录中存在其他库不等于当前显示链路使用它们。
- 待机基础表情与 listening 采用 GFX;其他表情仍走原 PNG 或步骤序列,不是所有资产都已经迁移。listening 缓存失败时单独回退旧 PNG,不影响待机。 - 待机基础表情、listening 与五种抽签结果采用 GFX 图层;其他表情仍走原 PNG 或步骤序列,不是所有资产都已经迁移。listening 缓存失败时单独回退旧 PNG,不影响待机。抽签只在开始画面前准备失败时回退,播放途中取消/传输失败不从头重播 PNG。
- 屏幕目前未使用 TE 同步。软件预合成和连续传输能改善撕裂观感,但不能保证面板扫描与写入完全同步。 - 屏幕目前未使用 TE 同步。软件预合成和连续传输能改善撕裂观感,但不能保证面板扫描与写入完全同步。
- 用户已确认待机缓存/固定时间轴以及新 listening 版本的观感满意。两者最终版本尚无新的配套量化日志,不能写成“稳定达到 30 FPS”或“全部实机验收完成”。 - 用户已确认待机缓存/固定时间轴以及新 listening 版本的观感满意。2026-10-10 启动样本中的待机眨眼约 410 ms、步骤约 29.26/s、实际眼型更新约 21.95/s;不是 listening/抽签帧率或全负载验证,不能写成“全部实机验收完成”。
已安装组件提供图片、网格图片、运动、动画、标签、二维码和按钮等接口。但本项目仅实际使用图片、网格、显示刷新及相关对象控制;通用中文文本、按钮、触摸和复杂布局不属于当前已实现能力。 已安装组件提供图片、网格图片、运动、动画、标签、二维码和按钮等接口。但本项目仅实际使用图片、网格、显示刷新及相关对象控制;通用中文文本、按钮、触摸和复杂布局不属于当前已实现能力。
@@ -63,6 +63,11 @@ esp_err_t err = domain_runtime_request_expression(&request);
| `components/platform/internal/lcd_emote_listening.h` | 平台私有插值接口,不向领域或控制层泄漏 GFX 类型 | | `components/platform/internal/lcd_emote_listening.h` | 平台私有插值接口,不向领域或控制层泄漏 GFX 类型 |
| `tools/prepare_listening_expression.py` | 拆分旧 listening、编码往返校验、生成四个 C 资产及同算法动画预览 | | `tools/prepare_listening_expression.py` | 拆分旧 listening、编码往返校验、生成四个 C 资产及同算法动画预览 |
| `tools/test_lcd_listening_gfx.py` | 实际资产、全闭合参数、缓存分配失败、GFX 对象失败、静态像素恢复与切换回归 | | `tools/test_lcd_listening_gfx.py` | 实际资产、全闭合参数、缓存分配失败、GFX 对象失败、静态像素恢复与切换回归 |
| `components/platform/src/lcd_emote_fortune.c` | 抽签 LZ4 有界解码、轻透视纹理光栅、签文展开、放大与星光 |
| `components/platform/internal/lcd_emote_fortune.h` | 抽签私有图层/纹理/固定窗口契约 |
| `components/platform/src/generated/emote_gfx/fortune_assets.c` | 六个共享图层、五种签文 LZ4 压缩载荷 |
| `tools/prepare_fortune_expression.py` / `tools/preview_fortune_expression.py` | 拆分/压缩与实际 C 算法预览;均不进设备固件 |
| `tools/test_lcd_fortune_gfx.py` | 五签、损坏输入、几何边界、时间轴、取消和释放回归 |
`/tmp` 下修改过程中生成的补丁脚本不是工程运行依赖,不应提交或作为正式资源流程使用。 `/tmp` 下修改过程中生成的补丁脚本不是工程运行依赖,不应提交或作为正式资源流程使用。
@@ -178,6 +183,7 @@ RLE(游程长度编码)把连续相同像素写成“数量+像素值”
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 普通 PNG 与 GFX 失败后备 | `lcd_source/*.png` | `lcd_optimized/*.png` 经素材包链接入应用 | 原 PNG 解码/缓存链路 | | 普通 PNG 与 GFX 失败后备 | `lcd_source/*.png` | `lcd_optimized/*.png` 经素材包链接入应用 | 原 PNG 解码/缓存链路 |
| 新待机/listening GFX | 原 PNG 与拆分图层 PNG | 六个实际参与编译的 RLE C 资产 | 初始化解压,生成 PSRAM 眼型缓存,之后切换缓存 | | 新待机/listening GFX | 原 PNG 与拆分图层 PNG | 六个实际参与编译的 RLE C 资产 | 初始化解压,生成 PSRAM 眼型缓存,之后切换缓存 |
| 五种抽签 GFX | 抽签源 PNG 与 `lcd_layers/chouqian` 拆分层 | 十一图层的 LZ4 block C 数组 | 动作开始解压六个共享层及所选签文,实时局部纹理光栅,结束/取消解绑后释放 |
| 离线预览 | 图层与实际插值算法 | 不入固件 | 在电脑上检查造型,不能模拟 LCD 扫描 | | 离线预览 | 图层与实际插值算法 | 不入固件 | 在电脑上检查造型,不能模拟 LCD 扫描 |
RLE 的编码往返对转换后的 RGB565+A8 数据无损,不等于原 PNG 的所有 RGB888 RLE 的编码往返对转换后的 RGB565+A8 数据无损,不等于原 PNG 的所有 RGB888
@@ -195,6 +201,7 @@ components/platform/assets/
lcd_layers/ lcd_layers/
jichubiaoqing/ 待机可编辑拆分图层、布局及预览 jichubiaoqing/ 待机可编辑拆分图层、布局及预览
listening/ 聆听可编辑拆分图层、布局及预览 listening/ 聆听可编辑拆分图层、布局及预览
chouqian/ 抽签共享图层、五种原签文及布局
components/platform/src/generated/emote_gfx/ components/platform/src/generated/emote_gfx/
idle_base.c 实际编译的底图压缩数据 idle_base.c 实际编译的底图压缩数据
idle_eyes_open.c 实际编译的睁眼压缩数据 idle_eyes_open.c 实际编译的睁眼压缩数据
@@ -202,6 +209,7 @@ components/platform/src/generated/emote_gfx/
listening_eyes_open.c 实际编译的聆听睁眼关键图 listening_eyes_open.c 实际编译的聆听睁眼关键图
listening_eyes_half.c 实际编译的聆听半闭关键图 listening_eyes_half.c 实际编译的聆听半闭关键图
listening_eyes_closed.c 实际编译的聆听闭眼关键图 listening_eyes_closed.c 实际编译的聆听闭眼关键图
fortune_assets.c 实际编译的抽签 LZ4;不是 RLE
``` ```
半闭眼、闭眼源图及对应历史生成文件不代表当前参与编译;以构建列表为准,不把目录里的全部文件自动加入固件,也不擅自删除旧资源。 半闭眼、闭眼源图及对应历史生成文件不代表当前参与编译;以构建列表为准,不把目录里的全部文件自动加入固件,也不擅自删除旧资源。
@@ -290,7 +298,7 @@ PSRAM 外部内存充足当成内部 DMA 安全。当前 listening 的已接受
## 八、性能日志如何读 ## 八、性能日志如何读
通过 `menuconfig` 搜索 `TQ_LCD_EMOTE_GFX_TEST` 和 `TQ_LCD_EMOTE_FPS_LOG`。前者控制 GFX 待机及 listening 路径;后者控制性能统计,默认关闭,当前本地测量配置开启。关闭统计不应改变动画节奏。 通过 `menuconfig` 搜索 `TQ_LCD_EMOTE_GFX_TEST` 和 `TQ_LCD_EMOTE_FPS_LOG`。前者控制 GFX 待机、listening 及抽签路径;后者控制性能统计,默认关闭,当前本地测量配置开启。关闭统计不应改变动画节奏。抽签使用 `fortune_fps`,只计算运动段、不含最后停留,详见抽签开发说明。
每轮完整眨眼输出一次 `emote_fps` 和 `emote_cost`,不在逐帧传输中打印: 每轮完整眨眼输出一次 `emote_fps` 和 `emote_cost`,不在逐帧传输中打印:
@@ -366,7 +374,25 @@ python3 tools/test_lcd_listening_gfx.py
固定窗口/锚点变化需同步脚本与测试。需要手工保留眉毛等素材编辑时,不要直接重新 固定窗口/锚点变化需同步脚本与测试。需要手工保留眉毛等素材编辑时,不要直接重新
拆分覆盖;当前生成脚本不是所有角色通用的热替换工具。 拆分覆盖;当前生成脚本不是所有角色通用的热替换工具。
### 接入更多表情 ### 已接入的五种抽签结果(2026-10-10)
统一抽出、轻透视翻面、问号、签文展开、放大/闪光与回待机。领域仍选择旧六步
序列和所选结果,平台只对完整匹配的资产序列优化;其他步骤序列不受影响。
纹理光栅由平台私有 C 完成,GFX 使用普通图片承接局部合成及 HUD,不是完整
三维引擎或 LVGL 动画。六图层共享,一次只载入一份签文,不建立五套缓存。
旧十八张 PNG 完整保留。详见 [抽签开发说明](抽签分层动画开发说明.zh-CN.md)。
新载荷 43,547 B,应用实际净增 51,856 B(本次构建 3,157,728 B);运动期
像素 PSRAM 278,249 B,加场景描述 148 B,正常结束/取消释放;临时图片对象
普通堆请求 96 B(不含分配器开销);静态可写 RAM 增量 4 B,无新 DMA/任务。
动画前分配失败后备 PNG;已开始后不因错误重播 PNG;解绑异常保留绑定资源
直到重启,不能假装完整释放。不是新格式一定最省 Flash,比较详见资产台账。
本次主机测试、ESP-IDF 5.5.4、日志/GFX 关闭编译及应用烧录校验通过。
启动日志确认待机/listening 缓存成功,短时显示日志无传输/锁/崩溃错误;
尚未捕获实机抽签播放日志,不宣称已测得抽签 FPS 或五签观感已验收。
### 接入更多表情(逐个评估)
先逐个迁移,保留未迁移 PNG 路径。每个表情准备静态层、动态图层、锚点、时间线、缓存预算及结束行为。固定短动作适合预生成缓存;大量不同动作或连续交互需要考虑按需生成、共享缓存和生命周期,不应给每个表情无上限常驻一份整屏缓存。 先逐个迁移,保留未迁移 PNG 路径。每个表情准备静态层、动态图层、锚点、时间线、缓存预算及结束行为。固定短动作适合预生成缓存;大量不同动作或连续交互需要考虑按需生成、共享缓存和生命周期,不应给每个表情无上限常驻一份整屏缓存。
@@ -397,6 +423,7 @@ python3 tools/test_lcd_status_hud.py
python3 tools/test_lcd_emote_transport.py python3 tools/test_lcd_emote_transport.py
python3 tools/test_lcd_emote_timeline.py python3 tools/test_lcd_emote_timeline.py
python3 tools/test_lcd_listening_gfx.py python3 tools/test_lcd_listening_gfx.py
python3 tools/test_lcd_fortune_gfx.py
``` ```
这些脚本在电脑上从实际源码提取 C 函数,使用 Clang 与内存、未定义行为检查器。需要 Python 和 Clang;不是固件代码,不占设备 Flash 或 RAM。测试使用模拟接口,不能证明屏幕无撕裂、实时帧率或设备像素完全一致。 这些脚本在电脑上从实际源码提取 C 函数,使用 Clang 与内存、未定义行为检查器。需要 Python 和 Clang;不是固件代码,不占设备 Flash 或 RAM。测试使用模拟接口,不能证明屏幕无撕裂、实时帧率或设备像素完全一致。
@@ -0,0 +1,151 @@
# 抽签分层动画开发说明
## 当前实现
2026-10-10 将五种结果接入同一动作:卜运签(保留原素材中的“补运签”字形)、
和平签、上上签、小吉签、中上签。领域结果目录及云端结果索引未改变,不在平台
重新随机,不改抽签请求、语音或联网流程。
领域继续下发原六步:前三步 `chouqian_qianzhi` 的 0/1/2,后三步所选结果
的 0/1/2,间隔 500/500/500/500/500/1500 ms。平台只有在完整匹配六步、
无附加图标、原间隔及 320×240、GFX 就绪时使用新动画。修改步骤/间隔会回到
普通 PNG,不静默覆盖通用步骤序列。
| 时间 | 动作 |
| --- | --- |
| 0~600 ms | 背面爪签从下方爪堆中抽出;爪堆遮挡签底,保留钓线/鱼 |
| 600~1200 ms | 轻透视翻面;背面缩窄,近 90° 用侧面厚度关键层,正面展开 |
| 1200~1600 ms | 正面问号保持;问号在翻面后半段已经随正面透视展开 |
| 1600~1900 ms | 所选原签文自上往下展开,不使用完整图片交叉淡化 |
| 1900~2500 ms | 爪签连续放大,爪堆淡出,程序金色星光出现 |
| 运动末帧后 1500 ms | 保持结果,变化的时间/电池可刷新,最后正常完成并守卫回待机 |
名义总长约 4 秒;若运动因负载迟到,仍保留完整 1500 ms 末帧阅读时间。
共同起点固定时间轴,目标运动步间隔 34 ms,75 张绘制(含起始与末端),不
画完再等 34 ms、不跳动作、不忙等。实际调度可迟到;目标不是实测 FPS。
取消以 20 ms 等待片轮询加当前绘制/传输时间为边界,不视为成功完成。
保留统一待机 guard,旧动画完成不能覆盖新 pending 请求。
## 图层和渲染
六个共享层:下方爪堆、背面、侧面、空白正面、问号、钓线/鱼;另有五个原签文。
保留源图,背景抖动杂点归零,清除背面裁剪内的钓线,将正面原签文区恢复签底,
保留肉垫与外轮廓;程序星光代替分散的原星光。属于小幅性能适配,并非原 PNG
逐像素不变。签文轮廓/颜色来自旧素材,不使用另加中文字体。
`lcd_emote_fortune.c` 在 PSRAM 的 `(56,20,208,220)` 局部不透明 RGB565 窗口
完成光栅:16 条纹理带近似透视、前后近远缩放、微小姿态变化;仅近侧面时使用
厚度层,避免半翻面时浮出一条不属于轮廓的金线。正面 UV 补偿反向,文字不镜像。
双线性采样在预乘 Alpha 空间进行,边缘不会把透明黑混出暗边。
每步完整恢复同一局部窗口并画新轮廓,防止旧轮廓残影。图片位置/尺寸固定,
无需以错误的“只更新新轮廓”留下旧像素。钓线在窗口外部分借用现有背景对象,
翻面开始只隐藏一次;不逐帧让不变的钓线失效。窗口顶部覆盖放大爪尖但避开
左上时间和右上电池;源图/几何变化需同步边界及预览。
GFX 负责普通图片图层、共享 HUD、区域合成及既有安全传输,不是使用 GFX
三维引擎,也没有 LVGL GUI。复用 153,600 B GFX 合成缓冲、10,240 B DMA
暂存区、ISR 完成等待和故障锁存;新增 91,520 B 是局部光栅缓冲,不是第二个
整屏换帧缓冲。屏幕无 TE,不能承诺完全无撕裂。
## 制作与测试
```text
components/platform/assets/lcd_source/chouqian_*.png 原十八张 PNG,保留
components/platform/assets/lcd_optimized/chouqian_*.png 旧 PNG 后备,仍入包
components/platform/assets/lcd_layers/chouqian/ 六共享图层、五签文、layout
components/platform/src/generated/emote_gfx/fortune_assets.c 实际编译压缩资产
components/platform/internal/lcd_emote_fortune.h 平台私有描述
components/platform/src/lcd_emote_fortune.c 解码与几何
components/platform/src/lcd_direct.c 匹配、准备、播放、解绑、测量
```
生成文件不要手改。以下需要 Pillow 和 Clang,生成会覆盖本次拆分图层及 C,
先保存手工美术修改;旧十八张源/优化 PNG 不改,不把拆分 PNG 重复入资源包。
```bash
python3 tools/prepare_fortune_expression.py
python3 tools/prepare_fortune_expression.py --check
python3 tools/test_lcd_fortune_gfx.py
python3 tools/preview_fortune_expression.py --output /tmp/lucky-fortune-preview --result 2
```
新压缩编码为 LZ4 block:字面量加 16-bit 回指,用有界、自包含解码器直接恢复
RGB565 高字节在前+独立 A8,不引入新解码任务/库,不在每帧解压。解码拒绝
越界回指、截断和输出长度错误。与旧四字节像素 RLE 不能互换。往返相对转换后
的 RGB565+A8 无损;转换/拆分并非 RGB888 原画无损。Alpha 全平面校验保留。
主机测试实际 C 解码和几何,五种结果共 1180 个采样时刻、空间不足/损坏输入、
九次分配失败、75 个取消点、4 秒时间轴、解绑/传输失败;日志开/关均覆盖。
预览也调用实际 C,另比较 C 解压与图层 PNG 转换后的像素及 Alpha。
仍需执行原四个显示回归和 IDF 5.5.4,并对 LCD 源文件验证日志/GFX 关闭条件编译。
替换资源不是只改 PNG:当前拆分坐标、尺寸、问号、原签文区域、锚点、放大
比例和窗口耦合。要调整生成器与几何,并用实际 C 预览检查翻面镜像、爪尖/签底
裁剪、字形缺笔、Alpha 暗边、HUD 遮挡及返回待机。
## 资源与生命周期
新载荷 43,547 B,十一张优化图层 PNG 37,046 B,原十八 PNG 372,012 B、
旧优化十八 PNG 136,226 B。不是相同图层数量的无损压缩比例对比;新方案
只保留少量关键素材而实时计算过渡。新 LZ4 比其十一 PNG 大 6,501 B,选择
它是为有界直接解压与减少 PNG 解码暂存,不是保证最小 Flash。
| 新增项 | 数值 |
| --- | ---: |
| 十一图层原始 RGB565+A8 | 246,633 B(含五签文,不全解压到运行内存) |
| 编译资产对象只读段(含描述/目录名) | 43,831 B;data/BSS 0 |
| 应用镜像 | 3,157,728 B,较上一提交 3,105,872 B 净增 51,856 B |
| 单 OTA 槽余量 | 1,560,864 B |
| 六共享图层解压像素 | 171,753 B |
| 本次所选一份签文 | 14,976 B |
| 局部光栅缓冲 | 91,520 B |
| 播放期像素 PSRAM 峰值 | 278,249 B |
| 场景状态/描述 | 148 B PSRAM;峰值明确请求合计 278,397 B |
| 临时 GFX 图片对象/状态/链节点 | 80+8+8=96 B 普通堆,不含管理开销 |
| 静态可写数据 | LCD 文件增加 4 B 指针 |
| 新内部/DMA 暂存区、任务 | 0 |
| 正常完成/取消后新增 PSRAM/对象常驻 | 0,解绑释放 |
数据来自目标编译器及本次构建,不包含分配器管理、局部栈和绘制临时开销。
旧 PNG 后备保留,包仍是 820,125 B、75 PNG/32 Opus,所以完整固件不会按
新旧素材差额减少。成本在本次提出的额外 Flash 80 KiB、像素 PSRAM 300 KiB
预算内;不要将此预算自动授权给后续更大缓存或新内部/DMA 缓冲。
播放准备失败释放部分成果;创建/绑定失败且安全解绑后才回退 PNG;开始绘制
后取消/失败不从头重播。每次动作只解压六共享层与一签文、申请一局部缓冲和
一个图片对象,正常退出持同任务 GFX 锁,背景重新绑定待机,再删除临时对象,
释放像素/场景。若锁/解绑异常,保留已绑定资源到重启,禁止再次创建该场景,
不能宣称此异常下已完成释放;既有强制删任务异常路径也不作为正常释放保证。
## 设备测试与日志
工厂 UART0 增加仅显示的测试命令,不调用云端,不写 NVS:
```text
lcd_fortune_test 0
lcd_fortune_test 1
lcd_fortune_test 2
lcd_fortune_test 3
lcd_fortune_test 4
```
每条结束后等至少 4 秒再发下一条;新命令会中断当前画面,不能当排队命令。
0~4 映射与上面顺序一致;仅接受单字符索引,缺失/越界/多参数拒绝。
`OK` 仅代表请求入队,完成/显示仍以日志及屏幕为准。当前 USB Serial/JTAG
是次级日志输出,CLI 读取 UART0,不能假设向 USB 日志口发送命令就会被执行。
正常抽签仍可通过既有 SW2/云端流程触发,不需要该测试命令。
开启 `CONFIG_TQ_LCD_EMOTE_FPS_LOG` 时每次运动段输出 `fortune_fps`:
`frames` 仅计成功绘制/传输;`motion_us` 为实际运动墙钟,排除最后保持;
`update_fps` 使用相同时间分母;`draw_avg_us/max_us` 含锁、光栅、GFX、DMA
复制及传输等待,平均分母是绘制尝试数(包括失败尝试);`late_frames` 是绘制
达到 34 ms 的次数,不是面板丢帧。
这些是任务墙钟,不能当纯 CPU,也不等于面板扫描率。开始日志另有解压/准备
时间、像素 PSRAM 及场景描述字节。
本次五签主机/条件编译/IDF 5.5.4 构建通过;当前设备 ota_0 有效,经读取 OTA
选择区确认后只用 app-flash 写 ota_0 应用,镜像校验成功,NVS/模型/选择区未改。
启动采样 25 秒:最终应用 SHA 前缀 06e65e4a0、待机/listening 缓存成功,采样内
无显示锁/传输/崩溃错误。未捕获抽签运动日志,未完成五签 LCD 观感及并行负载
验收,不写成实测稳定 30 FPS。启动期间的内存最低值不能代表抽签运行期最低值。
+51 -1
View File
@@ -419,7 +419,57 @@ PNG 包仍为 820,125 B、75 PNG/32 Opus;只读资产已包含于整个应用
内部/DMA 专用缓冲及静态可写数据均为 0;复用既有 HUD 和行缓冲。 内部/DMA 专用缓冲及静态可写数据均为 0;复用既有 HUD 和行缓冲。
局部栈、CPU 耗时和设备最低内存尚未新增实机测量,不应写成整体资源零成本。 局部栈、CPU 耗时和设备最低内存尚未新增实机测量,不应写成整体资源零成本。
## 维护与后续清理 ## 新增抽签分层资产(2026-10-10)
以下新增项不合并进上面的历史目录汇总。旧抽签十八张源 PNG 共 372,012 B、
优化 PNG 共 136,226 B,全部保留且后备仍入包。PNG 包仍 820,125 B,不重复
加入本次拆分层。生成器使用优化 PNG 保存拆分层,但实际编译 LZ4,不是 RLE。
图层目录:`components/platform/assets/lcd_layers/chouqian/`。
| 图层 | 尺寸 | 优化 PNG B | 本机分配 B | RGB565+A8 B | LZ4 载荷 B |
| --- | --- | ---: | ---: | ---: | ---: |
| `pile.png` | 208×76 | 9,594 | 12,288 | 47,424 | 9,417 |
| `back.png` | 63×113 | 2,999 | 4,096 | 21,357 | 4,179 |
| `edge.png` | 41×110 | 2,957 | 4,096 | 13,530 | 4,389 |
| `front.png` | 100×190 | 7,579 | 8,192 | 57,000 | 10,774 |
| `question.png` | 27×50 | 730 | 4,096 | 4,050 | 817 |
| `rod.png` | 169×56 | 1,584 | 4,096 | 28,392 | 2,094 |
| `text_buyun.png` | 48×104 | 2,502 | 4,096 | 14,976 | 2,638 |
| `text_heping.png` | 48×104 | 2,784 | 4,096 | 14,976 | 2,851 |
| `text_shangshang.png` | 48×104 | 2,132 | 4,096 | 14,976 | 2,069 |
| `text_xiaoji.png` | 48×104 | 2,091 | 4,096 | 14,976 | 2,210 |
| `text_zhongshang.png` | 48×104 | 2,094 | 4,096 | 14,976 | 2,109 |
| 合计 | 十一图层 | 37,046 | 57,344 | 246,633 | 43,547 |
`layout.json` 1,929 B、分配 4,096 B,不入固件;目录共十二文件 38,975 B、
分配 61,440 B。预览工具默认输出 `/tmp/lucky-fortune-preview`,不是编译资产。
源十八 PNG 与本次拆分层不是相同数量的无损比较;背景/几何做了小幅性能适配。
LZ4 对转换后的 RGB565+A8 无损且完整保留 Alpha,但比拆分 PNG 多 6,501 B,
不能称为压得最小。生成器及实际 C 解码均做往返校验,原旧 PNG 不覆盖。
生成 C:`components/platform/src/generated/emote_gfx/fortune_assets.c`,文本
270,251 B、分配 270,336 B;目标对象只读段总计 43,831 B(载荷、176 B 资产
描述、20 B 目录指针、88 B 目录名),可写 data/BSS 为 0。生成 C 不手改。
本次完整应用 3,157,728 B,较 HUD 提交 3,105,872 B 增加 51,856 B,包含
资产、几何/解码、播放与 UART 显示测试命令的代码及对齐;单 OTA 槽余量
1,560,864 B。所有只读资源已经包含在应用里,不能再相加。
六共享层解压 171,753 B、一次仅选一份签文 14,976 B、208×220 RGB565
局部光栅缓冲 91,520 B,像素峰值合计 278,249 B PSRAM;目标编译器场景
描述 148 B 也在 PSRAM,明确申请峰值 278,397 B,不含堆管理。
正常结束/取消后释放,新增常驻像素为 0。临时 GFX 图片对象/状态/链节点
普通堆请求合计 96 B,不含分配器;LCD 静态可写数据增加 4 B 指针,
无新增内部/DMA 缓冲、合成整屏缓冲或任务;局部栈/CPU/实际最低余量另测。
解绑故障时保留绑定资源到重启,不冒充完整释放。
五签主机回归、IDF 5.5.4、日志关闭/GFX 关闭编译与应用烧录校验通过。
设备启动确认缓存成功、25 秒采样无显示锁/传输/崩溃错误;未捕获抽签运动
日志,FPS/五签观感/并行负载最低内存仍待实机验收。详见
[抽签开发说明](抽签分层动画开发说明.zh-CN.md)。
## 维护与后续清理(通用)
1. PNG 新增/替换按 [图片优化流程](lcd-image-asset-optimization.zh-CN.md) 生成并验证, 1. PNG 新增/替换按 [图片优化流程](lcd-image-asset-optimization.zh-CN.md) 生成并验证,
不能编辑优化产物代替修改源图。GFX 新增/替换使用对应压缩脚本和编码往返测试。 不能编辑优化产物代替修改源图。GFX 新增/替换使用对应压缩脚本和编码往返测试。
+7 -4
View File
@@ -639,7 +639,7 @@ menu "TQ Cat Configuration"
Stack size in bytes for the control-plane LCD render worker. Stack size in bytes for the control-plane LCD render worker.
config TQ_LCD_EMOTE_GFX_TEST config TQ_LCD_EMOTE_GFX_TEST
bool "Use esp_emote_gfx for idle and listening expressions (experimental)" bool "Use esp_emote_gfx for idle, listening and fortune animations (experimental)"
default n default n
depends on TQ_SCREEN_ENABLE depends on TQ_SCREEN_ENABLE
help help
@@ -647,18 +647,21 @@ menu "TQ Cat Configuration"
RGB565A8 eye layer. Only the eye region is refreshed while RGB565A8 eye layer. Only the eye region is refreshed while
blinking. Listening uses three compressed key shapes to generate blinking. Listening uses three compressed key shapes to generate
a local eye-frame cache once. Both expressions share the display, a local eye-frame cache once. Both expressions share the display,
DMA staging and clock/battery overlay. Other presentations use DMA staging and clock/battery overlay. Fortune sequences share
layered paw textures and five original result texts, decoded
only for one playback and freed afterward. Other presentations use
the existing PNG renderer; listening falls back to PNG if its the existing PNG renderer; listening falls back to PNG if its
cache allocation fails. cache allocation fails.
config TQ_LCD_EMOTE_FPS_LOG config TQ_LCD_EMOTE_FPS_LOG
bool "Log measured GFX blink FPS" bool "Log measured GFX animation FPS"
depends on TQ_LCD_EMOTE_GFX_TEST depends on TQ_LCD_EMOTE_GFX_TEST
default n default n
help help
Log once per completed blink: measured step FPS, actual eye Log once per completed blink: measured step FPS, actual eye
update FPS, draw time and deadline overruns. Excludes the idle update FPS, draw time and deadline overruns. Excludes the idle
open-eye pause. This does not measure panel scan rate. open-eye pause. Fortune logs once per motion segment, excluding
the final hold. This does not measure panel scan rate.
config TQ_SCREEN_COLOR_ORDER_BGR config TQ_SCREEN_COLOR_ORDER_BGR
bool "Use BGR color order" bool "Use BGR color order"
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""从保留的抽签源 PNG 拆分共享图层、五个签文,生成 LZ4;--check 不写文件。
会覆盖 lcd_layers/chouqian 和生成 C;先保存手工美术编辑。背景杂点归零、
清除背面残留钓线、抽取并共享正面;RGB565+A8 编码往返无损不等于拆分原画无损。
"""
import argparse
from collections import deque
import json
from pathlib import Path
from PIL import Image
from compress_idle_expression import pixels565
def compress(raw):
"""LZ4 block:字面量和 16-bit 回指;编码器自包含,不下载库。"""
out=bytearray();table={};anchor=pos=0
def length(value):
while value>=255: out.append(255);value-=255
out.append(value)
while pos+12<=len(raw):
key=raw[pos:pos+4];previous=table.get(key);table[key]=pos
if previous is None or pos-previous>65535:
pos+=1;continue
size=4
while pos+size<len(raw)-5 and raw[previous+size]==raw[pos+size]: size+=1
literals=pos-anchor;match=size-4
out.append((min(literals,15)<<4)|min(match,15))
if literals>=15: length(literals-15)
out.extend(raw[anchor:pos]);offset=pos-previous;out.extend((offset&255,offset>>8))
if match>=15: length(match-15)
end=pos+size
for p in range(pos+1,end):
if p+4<=len(raw):table[raw[p:p+4]]=p
pos=anchor=end
literals=len(raw)-anchor;out.append(min(literals,15)<<4)
if literals>=15:length(literals-15)
out.extend(raw[anchor:])
assert decompress(out)==raw
return out
def decompress(data):
out=bytearray();pos=0
def length(value):
nonlocal pos
if value==15:
while True:
v=data[pos];pos+=1;value+=v
if v!=255:break
return value
while pos<len(data):
token=data[pos];pos+=1;literals=length(token>>4)
out.extend(data[pos:pos+literals]);pos+=literals
if pos==len(data):break
offset=data[pos]|data[pos+1]<<8;pos+=2;match=length(token&15)+4
assert 0<offset<=len(out)
for _ in range(match):out.append(out[-offset])
return bytes(out)
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "components/platform/assets/lcd_source"
LAYERS = ROOT / "components/platform/assets/lcd_layers/chouqian"
GENERATED = ROOT / "components/platform/src/generated/emote_gfx/fortune_assets.c"
RESULTS = ("buyun", "heping", "shangshang", "xiaoji", "zhongshang")
def clean(name, box, largest=False):
image = Image.open(SOURCE / name).convert("RGBA").crop(box)
pixels = list(image.getdata())
candidates = {(i % image.width, i // image.width) for i, (r,g,b,a) in enumerate(pixels)
if max(r,g,b)>35 and a}
components=[]
while candidates:
seed=candidates.pop(); queue=deque([seed]); group={seed}
while queue:
x,y=queue.popleft()
for nx in range(max(0,x-1),min(image.width,x+2)):
for ny in range(max(0,y-1),min(image.height,y+2)):
if (nx,ny) in candidates:
candidates.remove((nx,ny));group.add((nx,ny));queue.append((nx,ny))
if len(group)>=8: components.append(group)
selected=max(components,key=len) if largest else set().union(*components)
image.putdata([(r,g,b,a) if (i%image.width,i//image.width) in selected else (0,0,0,0)
for i,(r,g,b,a) in enumerate(pixels)])
return image
def text_layer(name):
image=Image.open(SOURCE/f"chouqian_{name}2.png").convert("RGBA").crop((136,94,184,198))
# 只保留原签文橙红笔画及边缘,不把黄色签底一起叠加。
image.putdata([(r,g,b,a) if r>150 and g<175 and b<100 and r>g*1.35 else (0,0,0,0)
for r,g,b,a in image.getdata()])
assert image.getbbox(), name
return image
def layers():
pile=clean("chouqian_qianzhi1.png",(56,164,264,240))
back=clean("chouqian_qianzhi1.png",(130,37,193,150),True)
for y in range(18,32):
for x in range(8):
r,g,b,a=back.getpixel((x,y))
if r>g*1.3 and g>b*1.5: back.putpixel((x,y),(0,0,0,0))
edge=clean("chouqian_qianzhi2.png",(139,38,180,148),True)
front=clean("chouqian_shangshang2.png",(110,24,210,214),True)
# 原签文所在内部签底做逐行双色插值;边框、肉垫、外轮廓保持原图。
for y in range(70,174):
l=front.getpixel((26,y));r=front.getpixel((73,y))
assert l[3] and r[3], y
for x in range(26,74):
f=(x-26)/47
front.putpixel((x,y),tuple(round(l[c]*(1-f)+r[c]*f) for c in range(3))+(255,))
question=Image.open(SOURCE/"chouqian_shangshang0.png").convert("RGBA").crop((147,83,174,133))
question.putdata([(r,g,b,a) if r>150 and g<215 and b<170 and r>g*1.10 else (0,0,0,0)
for r,g,b,a in question.getdata()])
assert question.getbbox()
rod=clean("chouqian_qianzhi0.png",(0,40,169,96))
return [("pile",pile,56,164),("back",back,0,0),("edge",edge,0,0),
("front",front,0,0),("question",question,0,0),("rod",rod,0,40)] + [
("text_"+name,text_layer(name),0,0) for name in RESULTS]
def main():
args=argparse.ArgumentParser();args.add_argument("--check",action="store_true");args=args.parse_args()
items=layers(); out=['/* Generated by tools/prepare_fortune_expression.py; do not edit. */',
'#include "lcd_emote_fortune.h"','']
entries=[]; report=[]
if not args.check: LAYERS.mkdir(parents=True,exist_ok=True)
for name,image,x,y in items:
pixels=pixels565(image)
raw=bytes(v for hi,lo,a in pixels for v in (hi,lo))+bytes(a for hi,lo,a in pixels)
data=compress(raw) # 对 RGB565 高低字节与独立 A8 平面逐字节往返检查。
out += [f'static const uint8_t fortune_{name}[] = {{']
out += [' '+', '.join(f'0x{v:02x}' for v in data[i:i+24])+',' for i in range(0,len(data),24)]
out += ['};','']
entries.append(f' {{{image.width}, {image.height}, {x}, {y}, sizeof(fortune_{name}), fortune_{name}}},')
report.append(dict(name=name,width=image.width,height=image.height,x=x,y=y,
raw_bytes=image.width*image.height*3,lz4_bytes=len(data)))
if args.check:
with Image.open(LAYERS/f"{name}.png") as old:
assert old.convert("RGBA").tobytes()==image.tobytes(), name
else: image.save(LAYERS/f"{name}.png",optimize=True)
out += ['const lcd_fortune_asset_t lcd_fortune_shared[LCD_FORTUNE_SHARED_COUNT] = {',*entries[:6],'};',
'const lcd_fortune_asset_t lcd_fortune_text[LCD_FORTUNE_RESULT_COUNT] = {',*entries[6:],'};',
'const char *const lcd_fortune_result_dirs[LCD_FORTUNE_RESULT_COUNT] = {',
' '+', '.join('"chouqian_'+s+'"' for s in RESULTS)+',','};','']
source='\n'.join(out)
metadata=json.dumps(dict(canvas=[320,240],window=[56,20,208,220],layers=report,
note="黑背景归零;共享爪子,签文字形来自原图;旧 PNG 保留"),ensure_ascii=False,indent=2)+'\n'
if args.check:
assert GENERATED.read_text()==source
assert (LAYERS/"layout.json").read_text()==metadata
else:
GENERATED.write_text(source);(LAYERS/"layout.json").write_text(metadata)
total=sum(s["lz4_bytes"] for s in report)
peak=208*220*2+sum(s["raw_bytes"] for s in report[:6])+max(s["raw_bytes"] for s in report[6:])
assert total<70*1024, ("为代码/描述保留10 KiB预算",total)
assert peak<300*1024,peak
print(json.dumps(dict(layers=report,payload_bytes=total,pixel_psram_peak=peak),ensure_ascii=False,indent=2))
if __name__=="__main__": main()
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""调用实际固件 C 解码与光栅函数生成 GIF/五签预览;不模拟 SPI 或扫描撕裂。"""
import argparse
import ctypes as C
from pathlib import Path
import subprocess
import tempfile
from PIL import Image
from compress_idle_expression import pixels565
ROOT=Path(__file__).resolve().parents[1]
class Asset(C.Structure):
_fields_=[("width",C.c_uint16),("height",C.c_uint16),("x",C.c_uint16),("y",C.c_uint16),
("size",C.c_size_t),("data",C.POINTER(C.c_uint8))]
class Texture(C.Structure):
_fields_=[("width",C.c_uint16),("height",C.c_uint16),("pixels",C.POINTER(C.c_uint8))]
def image565(raw,width,height):
pixels=[]
for i in range(0,len(raw),2):
c=raw[i]<<8|raw[i+1];r=(c>>11)&31;g=(c>>5)&63;b=c&31
pixels.append(((r<<3)|(r>>2),(g<<2)|(g>>4),(b<<3)|(b>>2)))
image=Image.new("RGB",(width,height));image.putdata(pixels);return image
def main():
parser=argparse.ArgumentParser();parser.add_argument("--output",type=Path,default=Path("/tmp/lucky-fortune-preview"))
parser.add_argument("--result",type=int,choices=range(5),default=2);args=parser.parse_args()
args.output.mkdir(parents=True,exist_ok=True)
with tempfile.TemporaryDirectory(prefix="fortune-preview-") as tmp:
library=Path(tmp)/"fortune.dylib"
subprocess.run(["clang","-std=c11","-shared","-fPIC","-O2","-I",str(ROOT/"components/platform/internal"),
str(ROOT/"components/platform/src/lcd_emote_fortune.c"),
str(ROOT/"components/platform/src/generated/emote_gfx/fortune_assets.c"),"-o",str(library)],check=True)
lib=C.CDLL(str(library));shared=(Asset*6).in_dll(lib,"lcd_fortune_shared");texts=(Asset*5).in_dll(lib,"lcd_fortune_text")
decode=lib.lcd_emote_fortune_decode;decode.argtypes=[C.POINTER(C.c_uint8),C.c_size_t,C.POINTER(C.c_uint8),C.c_size_t];decode.restype=C.c_bool
build=lib.lcd_emote_fortune_build_frame;build.argtypes=[C.POINTER(C.c_uint8),C.c_size_t,C.POINTER(Texture),C.POINTER(Texture),C.c_uint32];build.restype=C.c_bool
buffers=[]
def texture(a,name):
count=a.width*a.height;buffer=(C.c_uint8*(count*3))();buffers.append(buffer)
assert decode(a.data,a.size,buffer,len(buffer))
pixels=pixels565(Image.open(ROOT/"components/platform/assets/lcd_layers/chouqian"/(name+".png")))
expected=bytes(v for hi,lo,alpha in pixels for v in (hi,lo))+bytes(alpha for hi,lo,alpha in pixels)
assert bytes(buffer)==expected,name # 验证 C 解压与图层完整 A8 一致。
return Texture(a.width,a.height,buffer)
names=("pile","back","edge","front","question","rod")
textures=(Texture*6)(*(texture(a,n) for a,n in zip(shared,names)))
results=("buyun","heping","shangshang","xiaoji","zhongshang")
text_textures=[texture(a,"text_"+n) for a,n in zip(texts,results)]
dst=(C.c_uint8*(208*220*2))()
rod=Image.open(ROOT/"components/platform/assets/lcd_layers/chouqian/rod.png").convert("RGBA")
def frame(ms,index):
assert build(dst,len(dst),textures,C.byref(text_textures[index]),ms)
image=Image.new("RGB",(320,240))
if ms<600:image.paste(rod,(0,40),rod)
image.paste(image565(bytes(dst),208,220),(56,20));return image
frames=[frame(t,args.result) for t in range(0,2500,40)]+[frame(2500,args.result)]
frames[0].save(args.output/"preview.gif",save_all=True,append_images=frames[1:],duration=[40]*(len(frames)-1)+[1500],loop=0)
sheet=Image.new("RGB",(320*5,240))
for i in range(5):
f=frame(2500,i);sheet.paste(f,(320*i,0));f.save(args.output/(results[i]+".png"))
sheet.save(args.output/"results.png")
phases=(0,600,750,900,1050,1200,1800,2200,2500)
stages=Image.new("RGB",(320*3,240*3))
for i,t in enumerate(phases):stages.paste(frame(t,args.result),(i%3*320,i//3*240))
stages.save(args.output/"stages.png")
print("PASS: actual C decode == RGB565+A8 inputs; actual C GIF, five results and motion stages:",args.output)
if __name__=="__main__":main()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""抽签实际 C 解码/几何、五种资源、分配失败/取消/释放主机回归,非设备 FPS。"""
from pathlib import Path
import re
import subprocess
import tempfile
ROOT=Path(__file__).resolve().parents[1]
SRC=(ROOT/"components/platform/src/lcd_direct.c").read_text()
def extract(name):
match=re.search(r"static [^;{}]+\b"+name+r"\([^;{}]*\)\s*\{",SRC)
assert match,name
start=match.start();end=SRC.index("{",start)+1;depth=1
while depth:
if SRC[end]=="{":depth+=1
if SRC[end]=="}":depth-=1
end+=1
return SRC[start:end]
PREFIX=r'''
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "lcd_emote_fortune.h"
#define ESP_OK 0
#define ESP_ERR_INVALID_STATE 1
#define ESP_ERR_NO_MEM 2
#define ESP_ERR_INVALID_SIZE 3
#define ESP_ERR_NOT_SUPPORTED 4
#define ESP_ERR_TIMEOUT 5
#define PLATFORM_LCD_PRESENTATION_NO_ICON 255
#define MALLOC_CAP_SPIRAM 1
#define MALLOC_CAP_8BIT 2
#define GFX_COLOR_FORMAT_RGB565 0
#define GFX_COLOR_FORMAT_RGB565A8 1
#define C_ARRAY_HEADER_MAGIC 0
#define ESP_LOGI(...) ((void)0)
#define ESP_LOGE(...) ((void)0)
#define portENTER_CRITICAL(x) ((void)0)
#define portEXIT_CRITICAL(x) ((void)0)
typedef int esp_err_t;
typedef struct {const char *asset_dir;unsigned frame_index,icon_id,delay_ms;} platform_lcd_presentation_step_t;
typedef struct {size_t step_count;platform_lcd_presentation_step_t steps[6];} lcd_direct_request_t;
typedef struct { struct {int cf,magic,w,h;} header;size_t data_size;const uint8_t *data;} gfx_image_dsc_t;
typedef struct {bool visible;int x,y;const gfx_image_dsc_t *src;} gfx_obj_t;
static void *s_emote_handle=(void*)1,*s_emote_disp=(void*)1;
static gfx_obj_t mesh,closed,cache,listening,bg,scene_obj;
static gfx_obj_t *s_emote_eyes_open=&mesh,*s_emote_closed_eyes=&closed,*s_emote_cached_eyes=&cache;
static gfx_obj_t *s_listening_eyes=&listening,*s_emote_background=&bg;
static bool s_emote_active,s_emote_transfer_fault,stop;
static int lock_depth,allocs,live,fail_alloc,fail_obj,fail_lock,fail_unlock,fail_delete;
static int completions,waits,cancel_wait,refreshes,fail_refresh;
static int64_t now,deadlines[100];
static int64_t esp_timer_get_time(void){return now;}
static bool lcd_direct_should_stop_idle(void){return stop;}
static void *heap_caps_malloc(size_t n,int cap){assert(cap==3);if(++allocs==fail_alloc)return NULL;void *p=malloc(n);if(p)++live;return p;}
static void *heap_caps_calloc(size_t n,size_t s,int cap){void *p=heap_caps_malloc(n*s,cap);if(p)memset(p,0,n*s);return p;}
static void test_free(void *p){if(p)--live;free(p);}
#define free test_free
static int gfx_emote_lock(void *h){if(fail_lock){--fail_lock;return 5;}assert(!lock_depth);++lock_depth;return 0;}
static int gfx_emote_unlock(void *h){assert(lock_depth==1);--lock_depth;if(fail_unlock){--fail_unlock;return 5;}return 0;}
static gfx_obj_t *gfx_img_create(void *d){assert(lock_depth==1);return fail_obj?NULL:&scene_obj;}
static int gfx_obj_delete(gfx_obj_t *o){assert(lock_depth==1);if(fail_delete)return 5;o->src=NULL;return 0;}
static int gfx_img_set_src(gfx_obj_t *o,void *src){assert(lock_depth==1);o->src=src;return 0;}
static int gfx_obj_set_pos(gfx_obj_t *o,int x,int y){assert(lock_depth==1);o->x=x;o->y=y;return 0;}
static int gfx_obj_set_visible(gfx_obj_t *o,bool v){assert(lock_depth==1);o->visible=v;return 0;}
static int lcd_direct_emote_update_hud(bool force,bool *change){assert(lock_depth==1);return 0;}
static int lcd_direct_emote_select_face(bool face){assert(lock_depth==1);bg.src=NULL;return 0;}
static void gfx_disp_refresh_all(void *d){assert(lock_depth==1);}
static int lcd_direct_emote_refresh(void){assert(lock_depth==1 && s_emote_active);++refreshes;now+=13000;return refreshes==fail_refresh?5:0;}
static int lcd_direct_emote_wait_until(int64_t deadline){assert(!lock_depth && waits<100);deadlines[waits++]=deadline;
if(waits==cancel_wait){stop=true;return 1;} if(now<deadline)now=deadline;return stop?1:0;}
static void lcd_direct_signal_completion(void){++completions;}
static uint64_t lcd_direct_emote_fps_x100(uint32_t n,uint64_t t){return t?n*100000000ULL/t:0;}
static const char *esp_err_to_name(int e){return "mock";}
'''
TEST=r'''
static void reset(void){assert(live==0 && !lock_depth && !s_fortune_orphan);
allocs=fail_alloc=fail_obj=fail_lock=fail_unlock=fail_delete=waits=refreshes=cancel_wait=fail_refresh=completions=0;
now=0;stop=s_emote_active=s_emote_transfer_fault=false;}
int main(void){
uint8_t out[64];
assert(!lcd_emote_fortune_decode(NULL,1,out,1));
const uint8_t invalid[][5]={{0x10},{0x00,0,0},{0x00,1,0},{0xf0,255,255},{0x11,5}};
for(size_t i=0;i<5;++i)assert(!lcd_emote_fortune_decode(invalid[i],sizeof(invalid[i]),out,sizeof(out)));
for(size_t r=0;r<5;++r){
reset();lcd_direct_fortune_scene_t *s=lcd_direct_fortune_prepare(r);assert(s && live==9);
const size_t bytes=LCD_FORTUNE_WIDTH*LCD_FORTUNE_HEIGHT*2;
uint8_t *guard=malloc(bytes+2);guard[0]=guard[bytes+1]=0xa5;
for(unsigned ms=0;ms<=4000;ms+=17){
assert(lcd_emote_fortune_build_frame(guard+1,bytes,s->shared,&s->text,ms));
assert(guard[0]==0xa5 && guard[bytes+1]==0xa5);
}
assert(!lcd_emote_fortune_build_frame(guard+1,bytes-1,s->shared,&s->text,0));
assert(!lcd_emote_fortune_build_frame(NULL,bytes,s->shared,&s->text,0));
assert(!lcd_emote_fortune_build_frame(guard+1,bytes,NULL,&s->text,0));
lcd_emote_fortune_build_frame(guard+1,bytes,s->shared,&s->text,4000);
lcd_emote_fortune_build_frame(s->frame,bytes,s->shared,&s->text,2500);
assert(!memcmp(guard+1,s->frame,bytes));
/* 最终签文输出供回归查看;五种结果不能全都变成同一张。 */
char path[100];snprintf(path,sizeof(path),"/tmp/lucky-fortune-%zu.rgb565",r);
FILE *f=fopen(path,"wb");assert(f);fwrite(s->frame,1,bytes,f);fclose(f);
/* 截断输入和不足的解压空间不允许写越界。 */
for(size_t i=0;i<6;++i){const lcd_fortune_asset_t *a=&lcd_fortune_shared[i];size_t n=(size_t)a->width*a->height*3;
uint8_t *bad=malloc(n+2);bad[0]=bad[n+1]=0xa5;
assert(!lcd_emote_fortune_decode(a->data,a->compressed_size-1,bad+1,n));
assert(!lcd_emote_fortune_decode(a->data,a->compressed_size,bad+1,n-1));
assert(bad[0]==0xa5 && bad[n+1]==0xa5);
/* 普通测试内存不计入设备分配追踪。 */
++live;free(bad);
}
++live;free(guard);lcd_direct_fortune_free(s);assert(live==0);
lcd_direct_request_t req={.step_count=6};
for(size_t i=0;i<6;++i)req.steps[i]=(platform_lcd_presentation_step_t){i<3?"chouqian_qianzhi":lcd_fortune_result_dirs[r],i%3,255,i==5?1500:500};
assert(lcd_direct_fortune_sequence_index(&req)==r);
req.steps[4].delay_ms=100;assert(lcd_direct_fortune_sequence_index(&req)==-1);
req.steps[4].delay_ms=500;req.steps[3].asset_dir=NULL;assert(lcd_direct_fortune_sequence_index(&req)==-1);
reset();assert(lcd_direct_play_fortune(r)==0 && live==0 && completions==1 && !s_emote_active);
assert(waits==75 && refreshes==75 && now==4013000 && deadlines[74]==4013000);
for(int i=0;i<73;++i)assert(deadlines[i]==(i+1)*34000);
assert(deadlines[73]==2500000);
}
for(int n=1;n<=9;++n){reset();fail_alloc=n;assert(lcd_direct_play_fortune(0)==4 && !live && !completions);}
reset();fail_obj=1;assert(lcd_direct_play_fortune(0)==4 && !live);
reset();fail_lock=1;assert(lcd_direct_play_fortune(0)==5 && !live);
for(int n=1;n<=75;++n){reset();cancel_wait=n;assert(lcd_direct_play_fortune(0)==1 && !live && !completions && !s_emote_active);}
reset();fail_refresh=3;assert(lcd_direct_play_fortune(0)==5 && !live && !completions);
reset();fail_delete=1;assert(lcd_direct_play_fortune(0)==5 && s_fortune_orphan && live==9 && !completions);
assert(lcd_direct_play_fortune(0)==1);fail_delete=0;
assert(!lcd_direct_fortune_detach(s_fortune_orphan));lcd_direct_fortune_free(s_fortune_orphan);s_fortune_orphan=NULL;
reset();s_emote_transfer_fault=true;assert(lcd_direct_play_fortune(0)==1 && !live);
puts("PASS: five assets/results, LZ4 corruption/bounds, 1180 geometry frames, nine allocation failures, 75 cancellation points, anchored 4s playback, cleanup/transfer fault");
}
'''
start=SRC.index("typedef struct {\n lcd_fortune_texture_t shared")
end=SRC.index("static int lcd_direct_fortune_sequence_index",start)
names=["lcd_direct_fortune_sequence_index","lcd_direct_fortune_free","lcd_direct_fortune_prepare",
"lcd_direct_fortune_detach","lcd_direct_play_fortune"]
with tempfile.TemporaryDirectory(prefix="fortune-test-") as tmp:
path=Path(tmp);c=path/"test.c";exe=path/"test"
c.write_text(PREFIX+SRC[start:end]+"\n"+"\n".join(extract(n) for n in names)+TEST)
for profile in (0,1):
subprocess.run(["clang","-std=c11","-O1","-g","-fsanitize=address,undefined",
f"-DCONFIG_TQ_LCD_EMOTE_FPS_LOG={profile}","-I",str(ROOT/"components/platform/internal"),
str(c),str(ROOT/"components/platform/src/lcd_emote_fortune.c"),
str(ROOT/"components/platform/src/generated/emote_gfx/fortune_assets.c"),"-o",str(exe)],check=True)
subprocess.run([str(exe)],check=True)
assert "DOMAIN_EXPRESSION_" not in (ROOT/"components/platform/src/lcd_emote_fortune.c").read_text()
assert "if (err != ESP_ERR_NOT_SUPPORTED) return err;" in SRC