feat(lcd): add compressed GFX idle expression animation

Integrate esp_emote_gfx for idle rendering with symmetric mesh-based blinking and LCD DMA flush synchronization.

Store cropped face layers with lossless RLE, decode once into PSRAM, and retain the PNG backend for other expressions. Include source layers and asset generation tools.
This commit is contained in:
chenjintao
2026-10-09 14:19:16 +08:00
parent 7bc9a3dc76
commit f6408be05b
21 changed files with 1554 additions and 5 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Generate lossless RLE assets; PNGs remain the editable source of truth."""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
LAYERS = ROOT / "components/platform/assets/lcd_layers/jichubiaoqing"
GENERATED = ROOT / "components/platform/src/generated/emote_gfx"
def pixels565(image):
result = []
for r, g, b, a in image.convert("RGBA").getdata():
color = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
result.append((color >> 8, color & 255, a))
return result
def encode(pixels):
out = bytearray()
for pixel in pixels:
if out and tuple(out[-3:]) == pixel and out[-4] < 255:
out[-4] += 1
else:
out.extend((1, *pixel))
# Round-trip validation for every generated asset.
decoded = []
for i in range(0, len(out), 4):
decoded.extend([tuple(out[i + 1:i + 4])] * out[i])
assert decoded == pixels
return out
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/compress_idle_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.mkdir(parents=True, exist_ok=True)
(GENERATED / f"{name}.c").write_text(text)
print(f"{name}: {image.width}x{image.height} at ({x},{y}), "
f"raw={image.width * image.height * 3}B compressed={len(data)}B")
def main():
base = Image.open(LAYERS / "idle_base.png").convert("RGBA")
# Pure black is supplied by the display, preserving all nonblack RGB565 pixels.
rgba = list(base.getdata())
rgba = [(r, g, b, 255 if ((r >> 3) or (g >> 2) or (b >> 3)) else 0)
for r, g, b, _ in rgba]
base.putdata(rgba)
bbox = base.getbbox()
assert bbox is not None
generate("idle_base", base.crop(bbox), bbox[0], bbox[1])
eyes = Image.open(LAYERS / "idle_eyes_open.png").convert("RGBA")
assert eyes.size == (238, 93), "eye geometry must match the mesh layout"
generate("idle_eyes_open", eyes, 41, 65)
if __name__ == "__main__":
main()