feat(lcd): add cached listening animation and asset guidelines
This commit is contained in:
@@ -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()
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user