led-panel/scripts/generate_animations.py
lux f52a528340 Rotate frames 90° CW for portrait-assuming upload app
The Amusing LED app assumes portrait orientation. Pre-rotating
the frames so they display correctly on the landscape-mounted
panel. Added _preview.gif files (un-rotated) for Gitea review.

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

415 lines
13 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)
# Rotate 90° CW so portrait-assuming app displays correctly on landscape panel
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",
)
# Also save a landscape preview GIF (un-rotated) for Gitea review
preview_path = os.path.join(d, f"{name}_preview.gif")
iio.imwrite(
preview_path,
[f.copy().convert("RGBA") for f in frames],
duration=int(1000 / fps),
loop=0,
plugin="pillow",
)
print(f" {name}: {len(rotated)} frames ({rotated[0].size}), preview: {preview_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))