All frames and GIFs are 96x128 portrait (rotated 90° CW). Upload directly to panel via app — displays correctly in landscape. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
406 lines
12 KiB
Python
406 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)
|
|
# 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))
|