#!/usr/bin/env python3 """Generate LED panel animations for Lux robot (128x96 px landscape).""" import math import os from PIL import Image, ImageDraw import imageio.v3 as iio W, H = 128, 96 BG = (0, 0, 0) AMBER = (255, 160, 0) AMBER_DIM = (80, 50, 0) WHITE = (255, 255, 255) CYAN = (0, 200, 255) RED = (255, 30, 30) GREEN = (0, 200, 80) YELLOW = (255, 220, 0) OUT = os.path.join(os.path.dirname(__file__), "..", "animations") def ensure_dir(name): d = os.path.join(OUT, name) os.makedirs(d, exist_ok=True) return d def save_anim(name, frames, fps=10): d = ensure_dir(name) # Rotate 90° CW: panel app assumes portrait, panel is mounted landscape rotated = [f.rotate(-90, expand=True) for f in frames] for i, f in enumerate(rotated): f.save(os.path.join(d, f"frame_{i+1:03d}.png")) gif_path = os.path.join(d, f"{name}.gif") iio.imwrite( gif_path, [f.copy().convert("RGBA") for f in rotated], duration=int(1000 / fps), loop=0, plugin="pillow", ) print(f" {name}: {len(rotated)} frames ({rotated[0].size})") # ── TURN SIGNALS ────────────────────────────────────────────── def make_arrow(direction, sweep_step, total_steps=8): """Draw a sweeping turn signal arrow.""" img = Image.new("RGB", (W, H), BG) draw = ImageDraw.Draw(img) # Arrow shape: thick chevron + shaft cx, cy = W // 2, H // 2 sign = -1 if direction == "left" else 1 # Sweep: segments light up progressively for seg in range(total_steps): if seg > sweep_step: break alpha = 1.0 if seg <= sweep_step else 0.3 color = tuple(int(c * alpha) for c in AMBER) # Shaft segments sx = cx + sign * (seg * 7 - 28) draw.rectangle([sx - 3, cy - 6, sx + 3, cy + 6], fill=color) # Arrow head if sweep_step >= total_steps // 2: tip_x = cx + sign * 36 draw.polygon([ (tip_x, cy), (tip_x - sign * 20, cy - 24), (tip_x - sign * 20, cy - 12), (tip_x - sign * 8, cy - 12), (tip_x - sign * 8, cy + 12), (tip_x - sign * 20, cy + 12), (tip_x - sign * 20, cy + 24), ], fill=AMBER) else: tip_x = cx + sign * 36 dim = AMBER_DIM draw.polygon([ (tip_x, cy), (tip_x - sign * 20, cy - 24), (tip_x - sign * 20, cy - 12), (tip_x - sign * 8, cy - 12), (tip_x - sign * 8, cy + 12), (tip_x - sign * 20, cy + 12), (tip_x - sign * 20, cy + 24), ], fill=dim) return img def gen_turn_signal(direction): frames = [] steps = 8 # Sweep on for s in range(steps): frames.append(make_arrow(direction, s, steps)) # Hold for _ in range(4): frames.append(make_arrow(direction, steps - 1, steps)) # Off off = Image.new("RGB", (W, H), BG) for _ in range(4): frames.append(off) save_anim(f"turn_{direction}", frames, fps=10) # ── FACES ───────────────────────────────────────────────────── def draw_eye(draw, cx, cy, w, h, pupil_dy=0, blink=0.0): """Draw a rounded robot eye. blink 0=open, 1=closed.""" eh = max(2, int(h * (1.0 - blink))) ey = cy - eh // 2 draw.rounded_rectangle([cx - w // 2, ey, cx + w // 2, ey + eh], radius=min(6, eh // 2), fill=CYAN) if blink < 0.6: # Pupil pw, ph = w // 3, max(2, eh // 3) draw.rounded_rectangle( [cx - pw // 2, cy - ph // 2 + pupil_dy, cx + pw // 2, cy + ph // 2 + pupil_dy], radius=2, fill=WHITE ) def draw_mouth(draw, cy, style="smile", width=30): cx = W // 2 if style == "smile": draw.arc([cx - width, cy - width // 2, cx + width, cy + width // 2 + 10], start=10, end=170, fill=CYAN, width=3) elif style == "neutral": draw.line([cx - width // 2, cy, cx + width // 2, cy], fill=CYAN, width=2) elif style == "open": draw.ellipse([cx - 12, cy - 8, cx + 12, cy + 8], fill=CYAN) elif style == "focused": draw.line([cx - width // 2, cy, cx + width // 2, cy], fill=CYAN, width=3) def gen_happy_face(): frames = [] eye_w, eye_h = 24, 20 eye_y = 34 eye_sep = 22 mouth_y = 68 for i in range(30): img = Image.new("RGB", (W, H), BG) draw = ImageDraw.Draw(img) # Blink at frame 10-12 blink = 0.0 if i == 10: blink = 0.5 elif i == 11: blink = 1.0 elif i == 12: blink = 0.5 # Slight eye squint for happiness (smaller height) h = int(eye_h * 0.85) draw_eye(draw, W // 2 - eye_sep, eye_y, eye_w, h, blink=blink) draw_eye(draw, W // 2 + eye_sep, eye_y, eye_w, h, blink=blink) draw_mouth(draw, mouth_y, "smile", width=28) # Cheek dots draw.ellipse([W // 2 - 44, 48, W // 2 - 36, 56], fill=(0, 100, 150)) draw.ellipse([W // 2 + 36, 48, W // 2 + 44, 56], fill=(0, 100, 150)) frames.append(img) save_anim("face_happy", frames, fps=10) def gen_idle_face(): frames = [] eye_w, eye_h = 22, 18 eye_y = 36 eye_sep = 22 mouth_y = 68 for i in range(40): img = Image.new("RGB", (W, H), BG) draw = ImageDraw.Draw(img) # Gentle breathing: slight eye movement breath = math.sin(i * 2 * math.pi / 40) * 2 pupil_dy = int(breath) # Blink at frames 15-17, 35-37 blink = 0.0 if i in (15, 35): blink = 0.5 elif i in (16, 36): blink = 1.0 elif i in (17, 37): blink = 0.5 draw_eye(draw, W // 2 - eye_sep, eye_y, eye_w, eye_h, pupil_dy=pupil_dy, blink=blink) draw_eye(draw, W // 2 + eye_sep, eye_y, eye_w, eye_h, pupil_dy=pupil_dy, blink=blink) draw_mouth(draw, mouth_y, "neutral") frames.append(img) save_anim("face_idle", frames, fps=8) def gen_alert_face(): frames = [] eye_w, eye_h = 28, 26 eye_y = 34 eye_sep = 24 mouth_y = 70 for i in range(20): img = Image.new("RGB", (W, H), BG) draw = ImageDraw.Draw(img) # Pop-in effect: eyes grow from small to large in first 4 frames scale = min(1.0, (i + 1) / 4) ew = int(eye_w * scale) eh = int(eye_h * scale) # Slight shake shake = 0 if 4 <= i <= 8: shake = int(math.sin(i * 5) * 2) draw_eye(draw, W // 2 - eye_sep + shake, eye_y, max(4, ew), max(4, eh)) draw_eye(draw, W // 2 + eye_sep + shake, eye_y, max(4, ew), max(4, eh)) if scale >= 1.0: # Eyebrows raised draw.line([W // 2 - eye_sep - 14, eye_y - 18, W // 2 - eye_sep + 14, eye_y - 18], fill=CYAN, width=3) draw.line([W // 2 + eye_sep - 14, eye_y - 18, W // 2 + eye_sep + 14, eye_y - 18], fill=CYAN, width=3) draw_mouth(draw, mouth_y, "open") frames.append(img) save_anim("face_alert", frames, fps=10) def gen_following_face(): frames = [] eye_w, eye_h = 24, 20 eye_y = 34 eye_sep = 20 mouth_y = 68 for i in range(24): img = Image.new("RGB", (W, H), BG) draw = ImageDraw.Draw(img) # Focus: crosshair-like elements, pupils centered and intense draw_eye(draw, W // 2 - eye_sep, eye_y, eye_w, eye_h) draw_eye(draw, W // 2 + eye_sep, eye_y, eye_w, eye_h) draw_mouth(draw, mouth_y, "focused") # Scanning line sweeping down scan_y = 10 + (i * 3) % (H - 20) draw.line([10, scan_y, W - 10, scan_y], fill=(0, 80, 120), width=1) # Corner brackets (targeting reticle) c = (0, 150, 200) bw = 3 # top-left draw.line([8, 8, 20, 8], fill=c, width=bw) draw.line([8, 8, 8, 20], fill=c, width=bw) # top-right draw.line([W - 8, 8, W - 20, 8], fill=c, width=bw) draw.line([W - 8, 8, W - 8, 20], fill=c, width=bw) # bottom-left draw.line([8, H - 8, 20, H - 8], fill=c, width=bw) draw.line([8, H - 8, 8, H - 20], fill=c, width=bw) # bottom-right draw.line([W - 8, H - 8, W - 20, H - 8], fill=c, width=bw) draw.line([W - 8, H - 8, W - 8, H - 20], fill=c, width=bw) frames.append(img) save_anim("face_following", frames, fps=8) # ── STATUS ──────────────────────────────────────────────────── def gen_boot(): frames = [] total = 30 for i in range(total): img = Image.new("RGB", (W, H), BG) draw = ImageDraw.Draw(img) t = i / total if t < 0.2: # Scanline appears y = int(t / 0.2 * H) draw.line([0, y, W, y], fill=CYAN, width=2) elif t < 0.5: # Grid materializes alpha = (t - 0.2) / 0.3 gc = tuple(int(c * alpha) for c in (0, 60, 80)) for x in range(0, W, 16): draw.line([x, 0, x, H], fill=gc, width=1) for y in range(0, H, 16): draw.line([0, y, W, y], fill=gc, width=1) # Center circle expanding r = int(alpha * 30) cx, cy = W // 2, H // 2 draw.ellipse([cx - r, cy - r, cx + r, cy + r], outline=CYAN, width=2) elif t < 0.7: # Eyes powering on alpha = (t - 0.5) / 0.2 gc = tuple(int(c * 0.3) for c in (0, 60, 80)) for x in range(0, W, 16): draw.line([x, 0, x, H], fill=gc, width=1) for y in range(0, H, 16): draw.line([0, y, W, y], fill=gc, width=1) eye_c = tuple(int(c * alpha) for c in CYAN) ew = int(22 * alpha) eh = int(18 * alpha) draw_eye(draw, W // 2 - 22, 36, max(4, ew), max(4, eh)) draw_eye(draw, W // 2 + 22, 36, max(4, ew), max(4, eh)) else: # Full face fades in alpha = min(1.0, (t - 0.7) / 0.2) # Fading grid ga = max(0, 0.3 - alpha * 0.3) gc = tuple(int(c * ga) for c in (0, 60, 80)) for x in range(0, W, 16): draw.line([x, 0, x, H], fill=gc, width=1) for y in range(0, H, 16): draw.line([0, y, W, y], fill=gc, width=1) draw_eye(draw, W // 2 - 22, 36, 22, 18) draw_eye(draw, W // 2 + 22, 36, 22, 18) if alpha > 0.5: draw_mouth(draw, 68, "neutral") frames.append(img) # Hold final frame for _ in range(10): frames.append(frames[-1].copy()) save_anim("status_boot", frames, fps=12) def gen_low_battery(): frames = [] for i in range(24): img = Image.new("RGB", (W, H), BG) draw = ImageDraw.Draw(img) cx = W // 2 blink = (i % 6) < 3 # Large centered battery icon bw, bh = 76, 40 bx = cx - bw // 2 by = 10 draw.rounded_rectangle([bx, by, bx + bw, by + bh], radius=5, outline=WHITE, width=3) # Battery nub draw.rectangle([bx + bw, by + 12, bx + bw + 7, by + bh - 12], fill=WHITE) # Fill level: critically low (blinking red) fill_w = 14 if blink: draw.rectangle([bx + 5, by + 5, bx + 5 + fill_w, by + bh - 5], fill=RED) # Exclamation mark below battery ey = by + bh + 8 draw.rectangle([cx - 3, ey, cx + 3, ey + 16], fill=RED if blink else (120, 20, 20)) draw.rectangle([cx - 3, ey + 20, cx + 3, ey + 26], fill=RED if blink else (120, 20, 20)) frames.append(img) save_anim("status_low_battery", frames, fps=6) # ── MAIN ────────────────────────────────────────────────────── if __name__ == "__main__": print("Generating LED panel animations (128x96)...") print("\nTurn signals:") gen_turn_signal("left") gen_turn_signal("right") print("\nFaces:") gen_happy_face() gen_idle_face() gen_alert_face() gen_following_face() print("\nStatus:") gen_boot() gen_low_battery() print("\nDone! All animations in:", os.path.abspath(OUT))