feat(lcd): add cached listening animation and asset guidelines

This commit is contained in:
chenjintao
2026-10-09 18:01:32 +08:00
parent c0770ac459
commit 22f5ac70a2
32 changed files with 3384 additions and 35 deletions
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""拆分 listening 三个关键造型,输出 RLE 和与固件同算法的动画预览。
会覆盖 lcd_layers/listening 和 listening_* 生成资产;不修改旧 PNG。
需要 Pillow、Clang。预览和 PNG 图层不进入应用资源包。
"""
from collections import deque
import ctypes
import json
from pathlib import Path
import subprocess
import tempfile
from PIL import Image
from compress_idle_expression import encode, pixels565
ROOT = Path(__file__).resolve().parents[1]
LAYERS = ROOT / "components/platform/assets/lcd_layers/listening"
GENERATED = ROOT / "components/platform/src/generated/emote_gfx"
WINDOW = (41, 87, 283, 164)
CLOSURES = (0, 16, 24, 64, 80, 128, 160, 192, 232)
def split(image):
image = image.convert("RGB")
px = image.load()
candidates = {(x, y) for y in range(240) for x in range(320) if max(px[x, y]) > 16}
eyes = Image.new("RGBA", (320, 240))
base = Image.new("RGBA", (320, 240))
while candidates:
queue = deque([candidates.pop()])
component = []
while queue:
x, y = queue.popleft()
component.append((x, y))
for nx in range(max(0, x - 1), min(320, x + 2)):
for ny in range(max(0, y - 1), min(240, y + 2)):
if (nx, ny) in candidates:
candidates.remove((nx, ny))
queue.append((nx, ny))
if len(component) < 20:
continue
xs, ys = zip(*component)
is_eye = max(xs) - min(xs) >= 60 and min(ys) < 140 and max(ys) < 170
target = eyes if is_eye else base
for x, y in component:
if is_eye:
assert WINDOW[0] <= x < WINDOW[2] and WINDOW[1] <= y < WINDOW[3]
target.putpixel((x, y), (*px[x, y], 255))
return base, eyes.crop(WINDOW)
def generate(name, image, x, y):
data = encode(pixels565(image))
rows = [" " + ", ".join(f"0x{v:02x}" for v in data[i:i + 24]) + ","
for i in range(0, len(data), 24)]
text = ('/* Generated by tools/prepare_listening_expression.py; do not edit. */\n'
'#include "lcd_emote_assets.h"\n\n'
f'static const uint8_t {name}_rle[] = {{\n' + '\n'.join(rows) + '\n};\n\n'
f'const lcd_emote_rle_asset_t {name}_rle_asset = {{\n'
f' .width = {image.width}, .height = {image.height}, .x = {x}, .y = {y},\n'
f' .compressed_size = sizeof({name}_rle), .data = {name}_rle,\n}};\n')
(GENERATED / f"{name}.c").write_text(text)
return len(data)
def preview(base, keys):
# 调用实际固件 C 插值函数,不维护第二套 Python 动画算法。
with tempfile.TemporaryDirectory(prefix="listening-preview-") as tmp:
library = Path(tmp) / "morph.dylib"
subprocess.run(["clang", "-std=c11", "-shared", "-fPIC", "-O2",
"-I", str(ROOT / "components/platform/internal"),
str(ROOT / "components/platform/src/lcd_emote_listening.c"),
"-o", str(library)], check=True)
lib = ctypes.CDLL(str(library))
byte_p = ctypes.POINTER(ctypes.c_uint8)
build = lib.lcd_emote_listening_build_frame
build.argtypes = [byte_p, ctypes.c_uint16, ctypes.c_uint16,
ctypes.POINTER(byte_p), ctypes.c_uint8]
build.restype = ctypes.c_bool
buffers = []
for image in keys:
color = bytes(v for hi, lo, a in pixels565(image) for v in (hi, lo))
buffers.append((ctypes.c_uint8 * len(color)).from_buffer_copy(color))
pointers = (byte_p * 3)(*(ctypes.cast(b, byte_p) for b in buffers))
width, height = keys[0].size
output = (ctypes.c_uint8 * (width * height * 2))()
frames = []
for closure in CLOSURES:
assert build(output, width, height, pointers, closure)
colors = []
for i in range(width * height):
value = output[i * 2] * 256 + output[i * 2 + 1]
colors.append(((value >> 11) * 255 // 31,
((value >> 5) & 63) * 255 // 63, (value & 31) * 255 // 31))
eye = Image.new("RGB", (width, height))
eye.putdata(colors)
composed = Image.new("RGB", (320, 240))
composed.paste(base, mask=base.getchannel("A"))
# 眼型黑色区域透明显示静态底图,与固件局部缓存合成一致。
for y in range(height):
for x in range(width):
if eye.getpixel((x, y)) != (0, 0, 0):
composed.putpixel((x + WINDOW[0], y + WINDOW[1]), eye.getpixel((x, y)))
composed.save(LAYERS / f"preview_{closure:03d}.png", optimize=True)
frames.append(composed)
sheet = Image.new("RGB", (320 * 3, 240 * 3))
for i, frame in enumerate(frames):
sheet.paste(frame, ((i % 3) * 320, (i // 3) * 240))
sheet.save(LAYERS / "preview_sheet.png")
order = (0, 2, 4, 6, 8, 8, 7, 5, 3, 1, 0)
frames[0].save(LAYERS / "preview.gif", save_all=True,
append_images=[frames[i] for i in order[1:]],
duration=[2400] + [30] * (len(order) - 1), loop=0)
def main():
LAYERS.mkdir(parents=True, exist_ok=True)
GENERATED.mkdir(parents=True, exist_ok=True)
sources = [Image.open(ROOT / f"components/platform/assets/lcd_source/listening{i}.png")
for i in range(3)]
assert all(image.size == (320, 240) for image in sources)
layers = [split(image) for image in sources]
base = layers[0][0] # 固定第一帧眉毛、鼻子与胡须,消除原三帧位置抖动。
base.save(LAYERS / "listening_base.png", optimize=True)
bbox = base.getbbox()
assert bbox is not None
sizes = {"base": generate("listening_base", base.crop(bbox), bbox[0], bbox[1])}
keys = [layer[1] for layer in layers]
for state, image in zip(("open", "half", "closed"), keys):
image.save(LAYERS / f"listening_eyes_{state}.png", optimize=True)
sizes[state] = generate(f"listening_eyes_{state}", image, WINDOW[0], WINDOW[1])
layout = {"canvas": [320, 240], "window": list(WINDOW),
"closures": CLOSURES, "compressed_bytes": sizes,
"cache_bytes": len(CLOSURES) * keys[0].width * keys[0].height * 2,
"base_decoded_bytes": (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) * 3}
(LAYERS / "layout.json").write_text(json.dumps(layout, ensure_ascii=False, indent=2) + "\n")
preview(base, keys)
print(json.dumps(layout, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""实际 C 插值、资源解码、缓存分配失败和状态切换主机回归;不代替实屏验收。"""
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_assets.h"
#include "lcd_emote_listening.h"
#define ESP_OK 0
#define ESP_ERR_NO_MEM 1
#define ESP_ERR_INVALID_SIZE 2
#define ESP_ERR_INVALID_STATE 3
#define ESP_ERR_INVALID_ARG 4
#define LCD_DIRECT_WIDTH 320
#define LCD_DIRECT_HEIGHT 240
#define LCD_DIRECT_EMOTE_CACHE_COUNT 9
#define LCD_DIRECT_EMOTE_CLOSED_THRESHOLD 232
#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 LCD_DIRECT_EMOTE_FPS 30U
#define LCD_DIRECT_EMOTE_STEP_MS 34U
#define LCD_DIRECT_EMOTE_OPEN_MS 2000U
#define portENTER_CRITICAL(x) ((void)0)
#define portEXIT_CRITICAL(x) ((void)0)
#define ESP_RETURN_ON_FALSE(c,e,...) do {if(!(c))return (e);}while(0)
#define ESP_RETURN_ON_ERROR(c,...) do {int err=(c);if(err)return err;}while(0)
typedef int esp_err_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 void *src;} gfx_obj_t;
static void *s_emote_handle=(void*)1,*s_emote_disp=(void*)1;
static gfx_obj_t object,bg,mesh,closed,idle;
static gfx_obj_t *s_emote_background=&bg,*s_emote_eyes_open=&mesh,*s_emote_closed_eyes=&closed;
static gfx_obj_t *s_emote_cached_eyes=&idle,*s_listening_eyes;
static gfx_image_dsc_t s_listening_base_image,s_emote_base_image,s_listening_images[9];
static uint8_t *s_listening_base_pixels,*s_listening_pixels[9];
static bool s_listening_ready,s_emote_cache_ready=true,s_emote_show_closed;
static int s_listening_current=-1,allocs,fail_alloc,live,op,fail_op,lock_depth,source_calls;
static const uint8_t s_emote_cache_closures[9]={0,16,24,64,80,128,160,192,232};
static int64_t now;
static int64_t esp_timer_get_time(void){return now;}
static int operation(void){return ++op==fail_op ? 1 : 0;}
static void *heap_caps_malloc(size_t n,int cap){if(++allocs==fail_alloc)return NULL;void *p=malloc(n);if(p)++live;return p;}
static void test_free(void *p){if(p)--live;free(p);}
#define free test_free
static int gfx_emote_lock(void *h){int e=operation();if(!e)++lock_depth;return e;}
static int gfx_emote_unlock(void *h){assert(lock_depth==1);--lock_depth;return operation();}
static gfx_obj_t *gfx_img_create(void *d){assert(lock_depth==1);return operation()?NULL:&object;}
static int gfx_obj_delete(gfx_obj_t *o){assert(lock_depth==1);return 0;}
static int gfx_img_set_src(gfx_obj_t *o,const void *src){assert(lock_depth==1);int e=operation();if(!e){o->src=src;++source_calls;}return e;}
static int gfx_obj_set_pos(gfx_obj_t *o,int x,int y){assert(lock_depth==1);int e=operation();if(!e){o->x=x;o->y=y;}return e;}
static int gfx_obj_set_visible(gfx_obj_t *o,bool v){assert(lock_depth==1);int e=operation();if(!e)o->visible=v;return e;}
static int lcd_direct_emote_set_eye_closure(uint8_t v){return 0;}
typedef struct {char asset_dir[32];uint16_t repeat_count;uint32_t frame_ms;} lcd_direct_request_t;
static bool s_emote_active,stop;
static int completion,deadline_count,cancel_step;
static int64_t deadlines[16];
static uint64_t s_emote_copy_us,s_emote_spi_us;
static uint32_t s_emote_completed_regions;
static int lcd_direct_emote_refresh(void){assert(lock_depth==1);now+=13000;++s_emote_completed_regions;return 0;}
static int lcd_direct_emote_update_hud(bool force, bool *changed){if(changed)*changed=false;return 0;}
static void gfx_disp_refresh_all(void *disp){assert(lock_depth==1);}
static int lcd_direct_emote_wait(uint32_t ms){now+=ms*1000;return stop?ESP_ERR_INVALID_STATE:0;}
static int lcd_direct_emote_wait_until(int64_t deadline){
assert(deadline_count<16);deadlines[deadline_count++]=deadline;
if(deadline_count==cancel_step){stop=true;return ESP_ERR_INVALID_STATE;}
if(now<deadline)now=deadline;return 0;
}
static bool lcd_direct_should_stop_idle(void){return stop;}
static void lcd_direct_signal_completion(void){++completion;}
'''
TEST = r'''
static void cleanup(void){
for(int i=0;i<9;++i){free(s_listening_pixels[i]);s_listening_pixels[i]=NULL;}
free(s_listening_base_pixels);s_listening_base_pixels=NULL;
s_listening_eyes=NULL;s_listening_ready=false;s_listening_current=-1;
assert(live==0 && lock_depth==0);
}
int main(void){
for(int failure=1;failure<=13;++failure){
allocs=op=0;fail_alloc=failure;fail_op=0;
assert(lcd_direct_listening_prepare()!=0);
assert(live==0 && !s_listening_ready && s_listening_eyes==NULL);
cleanup();
}
fail_alloc=0;
for(int failure=1;failure<=6;++failure){
allocs=op=0;fail_op=failure;
assert(lcd_direct_listening_prepare()!=0 && !s_listening_ready);
if(failure<6)assert(live==0 && s_listening_eyes==NULL);
cleanup();
}
allocs=op=fail_op=0;
assert(lcd_direct_listening_prepare()==0 && s_listening_ready && live==10);
assert(s_listening_base_image.data_size==87822);
assert(s_listening_images[0].data_size==242*77*2);
const lcd_emote_rle_asset_t *assets[3]={&listening_eyes_open_rle_asset,&listening_eyes_half_rle_asset,&listening_eyes_closed_rle_asset};
uint8_t *keys[3];gfx_image_dsc_t d;
for(int i=0;i<3;++i)assert(lcd_direct_emote_decode_asset(assets[i],&d,&keys[i])==0);
const uint8_t *sources[3]={keys[0],keys[1],keys[2]};
uint8_t *frame=malloc(242*77*2);
for(int closure=0;closure<256;++closure)assert(lcd_emote_listening_build_frame(frame,242,77,sources,closure));
for(int i=0;i<3;++i){
assert(lcd_emote_listening_build_frame(frame,242,77,sources,i==0?0:i==1?128:232));
assert(memcmp(frame,keys[i],242*77*2)==0);
}
/* 闭眼缓存里的静态眉毛/胡须不能被黑色窗口清空。 */
size_t pixels=0;
for(int y=0;y<77;++y)for(int x=0;x<242;++x){
int bx=41+x-listening_base_rle_asset.x,by=87+y-listening_base_rle_asset.y;
size_t eye=((size_t)y*242+x)*2;
size_t base=((size_t)by*listening_base_rle_asset.width+bx)*2;
if(!keys[2][eye] && !keys[2][eye+1]){
assert(s_listening_pixels[8][eye]==s_listening_base_pixels[base]);
assert(s_listening_pixels[8][eye+1]==s_listening_base_pixels[base+1]);
if(s_listening_base_pixels[base] || s_listening_base_pixels[base+1])++pixels;
}
}
assert(pixels>100);
++live;free(frame);for(int i=0;i<3;++i)free(keys[i]);
assert(gfx_emote_lock(s_emote_handle)==0);
assert(lcd_direct_emote_select_face(true)==0);
assert(bg.src==&s_listening_base_image && object.visible && !idle.visible && !mesh.visible && !closed.visible);
assert(lcd_direct_emote_set_face_closure(true,232)==0);
int before=source_calls;
assert(lcd_direct_emote_set_face_closure(true,255)==0 && source_calls==before);
assert(lcd_direct_emote_set_face_closure(true,99)!=0);
assert(lcd_direct_emote_select_face(false)==0);
assert(bg.src==&s_emote_base_image && idle.visible && !object.visible && !mesh.visible);
s_emote_cache_ready=false;
assert(lcd_direct_emote_select_face(false)==0 && mesh.visible && !closed.visible);
assert(gfx_emote_unlock(s_emote_handle)==0);
lcd_direct_request_t request={.asset_dir="listening",.repeat_count=1};
int64_t before_play=now;
assert(lcd_direct_play_emote_face_until_pending(&request)==0);
assert(completion==1 && deadline_count==10 && !s_emote_active && lock_depth==0);
assert(deadlines[9]-deadlines[0]==270000 && now-before_play==13000+2400000+300000);
for(int i=1;i<10;++i)assert(deadlines[i]-deadlines[i-1]==30000);
assert(s_listening_current==0);
request.repeat_count=0;deadline_count=completion=0;cancel_step=5;
assert(lcd_direct_play_emote_face_until_pending(&request)==0);
assert(stop && completion==0 && deadline_count==5 && !s_emote_active && lock_depth==0);
cleanup();
/* 最大尺寸以及孤立/空白列:捕获整数溢出与越界。 */
uint8_t *large[3];for(int i=0;i<3;++i){large[i]=calloc(320*240,2);large[i][2*320*239]=255;large[i][2*320]=255;}
const uint8_t *ls[3]={large[0],large[1],large[2]};uint8_t *out=malloc(320*240*2);
for(int c=1;c<232;++c)assert(lcd_emote_listening_build_frame(out,320,240,ls,c));
assert(!lcd_emote_listening_build_frame(NULL,320,240,ls,0));
assert(!lcd_emote_listening_build_frame(out,321,240,ls,0));
assert(!lcd_emote_listening_build_frame(out,320,0,ls,0));
#undef free
free(out);for(int i=0;i<3;++i)free(large[i]);
puts("PASS: real RLE assets, 256 closures, endpoint identity, static restoration, 13 allocation and 6 GFX failures, state switching and no duplicate closed update");
}
'''
with tempfile.TemporaryDirectory(prefix="listening-gfx-test-") as tmp:
c = Path(tmp) / "test.c"
exe = Path(tmp) / "test"
functions = ("lcd_direct_emote_decode_asset", "lcd_direct_listening_prepare",
"lcd_direct_emote_cache_index", "lcd_direct_emote_select_face",
"lcd_direct_emote_set_face_closure", "lcd_direct_emote_fps_x100",
"lcd_direct_emote_step_deadline", "lcd_direct_play_emote_face_until_pending")
c.write_text(PREFIX + "\n".join(extract(name) for name in functions) + TEST)
generated = ROOT / "components/platform/src/generated/emote_gfx"
command = ["clang", "-std=c11", "-fsanitize=address,undefined",
"-I", str(ROOT / "components/platform/internal"), str(c),
str(ROOT / "components/platform/src/lcd_emote_assets.c"),
str(ROOT / "components/platform/src/lcd_emote_listening.c"),
*(str(generated / (name + ".c")) for name in
("idle_base", "listening_base", "listening_eyes_open",
"listening_eyes_half", "listening_eyes_closed")),
"-o", str(exe)]
for profiling in (0, 1):
subprocess.run(command + [f"-DCONFIG_TQ_LCD_EMOTE_FPS_LOG={profiling}"], check=True)
subprocess.run([str(exe)], check=True)
print("PASS: actual listening playback deadlines, finite completion and cancellation without false completion; profiling on/off")
catalog = (ROOT / "components/domain/src/runtime/domain_runtime_adapters.c").read_text()
assert '.asset_dir = "listening"' in catalog
assert 's_listening_ready && request->duration_ms == 0U' in SRC
assert 'listening ? blink_start_us + (int64_t)(i + 1U) * step_ms * 1000' in SRC
print("PASS: catalog unchanged, GFX readiness fallback and anchored listening timeline")