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
+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