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:
@@ -0,0 +1,123 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user