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.
67 lines
2.5 KiB
Python
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()
|