led-panel/scripts/generate_animations.py
lux df6465267f Add LED panel animations for Lux robot (128x96 landscape)
Turn signals (amber sweeping arrows), robot faces (happy, idle,
alert, following), and status animations (boot sequence, low battery).
Each animation has numbered PNG frames + preview GIF.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:31:35 -04:00

404 lines
12 KiB
Python

#!/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)
for i, f in enumerate(frames):
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 frames],
duration=int(1000 / fps),
loop=0,
plugin="pillow",
)
print(f" {name}: {len(frames)} frames, {gif_path}")
# ── 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))