Files
lucky-cat/tools/prepare_fortune_expression.py
T

157 lines
7.6 KiB
Python

#!/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()