Files
lucky-cat/tools/compress_idle_expression.py
T
chenjintao f6408be05b 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.
2026-10-09 14:19:16 +08:00

67 lines
2.5 KiB
Python

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