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