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