Files
lucky-cat/tools/split_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

124 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""Split the three idle face frames into an exact static base and eye layers."""
from __future__ import annotations
import json
from collections import deque
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "components/platform/assets/lcd_source"
OUTPUT = ROOT / "components/platform/assets/lcd_layers/jichubiaoqing"
FRAME_NAMES = (
"jichubiaoqing0.png",
"jichubiaoqing1.png",
"jichubiaoqing2.png",
)
def component_mask(image: Image.Image, *, dynamic: bool) -> set[tuple[int, int]]:
"""Select facial components while discarding isolated background artifacts."""
rgb = image.convert("RGB")
px = rgb.load()
candidates = {
(x, y)
for y in range(rgb.height)
for x in range(rgb.width)
if max(px[x, y]) > 8
}
selected: set[tuple[int, int]] = set()
while candidates:
seed = candidates.pop()
queue = deque([seed])
component = {seed}
while queue:
x, y = queue.popleft()
for nx in range(max(0, x - 1), min(rgb.width, x + 2)):
for ny in range(max(0, y - 1), min(rgb.height, y + 2)):
point = (nx, ny)
if point in candidates:
candidates.remove(point)
component.add(point)
queue.append(point)
if len(component) < 20:
continue
xs = [point[0] for point in component]
ys = [point[1] for point in component]
left, top, right, bottom = min(xs), min(ys), max(xs), max(ys)
is_eye = 30 <= left and right <= 290 and top < 135
is_static = top >= 135
if (dynamic and is_eye) or (not dynamic and is_static):
selected.update(component)
return selected
def main() -> None:
frames = [Image.open(SOURCE / name).convert("RGBA") for name in FRAME_NAMES]
if any(frame.size != (320, 240) for frame in frames):
raise SystemExit("idle frames must all be 320x240")
OUTPUT.mkdir(parents=True, exist_ok=True)
base = Image.new("RGBA", frames[0].size, (0, 0, 0, 255))
base_px = base.load()
source_px = frames[0].load()
for x, y in component_mask(frames[0], dynamic=False):
base_px[x, y] = source_px[x, y]
full_layers: list[Image.Image] = []
union_bbox = None
for frame in frames:
layer = Image.new("RGBA", frame.size, (0, 0, 0, 0))
layer_px = layer.load()
frame_px = frame.load()
for x, y in component_mask(frame, dynamic=True):
red, green, blue, _ = frame_px[x, y]
layer_px[x, y] = (red, green, blue, 255)
bbox = layer.getbbox()
if bbox is None:
raise SystemExit("an eye layer is empty")
union_bbox = bbox if union_bbox is None else (
min(union_bbox[0], bbox[0]),
min(union_bbox[1], bbox[1]),
max(union_bbox[2], bbox[2]),
max(union_bbox[3], bbox[3]),
)
full_layers.append(layer)
assert union_bbox is not None
base.save(OUTPUT / "idle_base.png", optimize=True)
state_names = ("open", "half", "closed")
for state, layer in zip(state_names, full_layers):
cropped = layer.crop(union_bbox)
cropped.save(OUTPUT / f"idle_eyes_{state}.png", optimize=True)
reconstructed = base.copy()
reconstructed.alpha_composite(cropped, (union_bbox[0], union_bbox[1]))
reconstructed.save(OUTPUT / f"preview_{state}.png", optimize=True)
metadata = {
"canvas": {"width": 320, "height": 240},
"base": "idle_base.png",
"eye_layer": {
"x": union_bbox[0],
"y": union_bbox[1],
"width": union_bbox[2] - union_bbox[0],
"height": union_bbox[3] - union_bbox[1],
"states": {
state: f"idle_eyes_{state}.png" for state in state_names
},
},
"note": "background normalized to pure black and isolated edge artifacts removed",
}
(OUTPUT / "layout.json").write_text(
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if __name__ == "__main__":
main()