Skip to content

Commit ebb2a07

Browse files
feat: transparent mascot, improved mouth sprites, lyrics overlay
- examples/demo_fox.png: white background removed (saturation-based alpha masking) — fox now floats on the dark stage background - generate_sprites.py: V1.5 layered cartoon mouths — filled skin-tone lip, dark cavity, white teeth (A/C/E/F), pink tongue (A/H), outline on top; PHONEME_SHAPES updated with teeth/tongue flags per phoneme - config.yaml: mouth_region corrected to x=376 y=615 w=272 h=110 (detected from actual fox PNG, was x=200 y=280 w=112 h=70) - compose_animation.py: lyrics word overlay — SFNSRounded font, pill background, word centred at 82% height, synced to timed_words from Phase 1 prep_data; _get_word_at() + _render_lyric_overlay() added - sprites/: 9 sprites regenerated at 272x110 with new cartoon style Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a9685fc commit ebb2a07

13 files changed

Lines changed: 176 additions & 90 deletions

compose_animation.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from typing import Dict, List, Optional, Tuple
1717

1818
import numpy as np
19-
from PIL import Image, ImageDraw, ImageFilter
19+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
2020

2121
logger = logging.getLogger(__name__)
2222

@@ -57,9 +57,11 @@ def __init__(self, config: Dict, prep_data: Dict):
5757
self.base_image: Optional[Image.Image] = None
5858
self.mouth_sprites: Dict[str, Image.Image] = {}
5959
self._beat_times: List[float] = []
60+
self._lyric_font: Optional[ImageFont.FreeTypeFont] = None
6061

6162
self._load_assets()
6263
self._index_beats()
64+
self._load_font()
6365

6466
# ------------------------------------------------------------------ #
6567
# Asset loading
@@ -104,6 +106,26 @@ def _index_beats(self):
104106
beats = self.prep_data.get("beats", {})
105107
self._beat_times = [float(t) for t in beats.get("beat_times", [])]
106108

109+
def _load_font(self):
110+
"""Load the best available system font for lyric rendering."""
111+
candidates = [
112+
"/System/Library/Fonts/SFNSRounded.ttf",
113+
"/System/Library/Fonts/SFNS.ttf",
114+
"/Library/Fonts/Arial Unicode.ttf",
115+
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
116+
]
117+
font_size = max(int(self.height * 0.055), 24)
118+
for path in candidates:
119+
if os.path.exists(path):
120+
try:
121+
self._lyric_font = ImageFont.truetype(path, font_size)
122+
logger.info("Lyric font: %s @ %dpx", path, font_size)
123+
return
124+
except Exception:
125+
continue
126+
self._lyric_font = ImageFont.load_default()
127+
logger.warning("No system font found — using PIL default for lyrics")
128+
107129
# ------------------------------------------------------------------ #
108130
# Per-frame logic
109131
# ------------------------------------------------------------------ #
@@ -149,6 +171,44 @@ def _get_bob_offset(self, timestamp: float) -> int:
149171
offset = 0
150172
return offset
151173

174+
def _get_word_at(self, timestamp: float) -> Optional[str]:
175+
"""Return the active lyric word at the given timestamp, or None."""
176+
words = self.prep_data.get("timed_words", [])
177+
if not words:
178+
return None
179+
active = None
180+
for entry in words:
181+
t = float(entry.get("time", entry.get("start", 0)))
182+
if t <= timestamp:
183+
active = entry.get("word", entry.get("text", ""))
184+
else:
185+
break
186+
return active or None
187+
188+
def _render_lyric_overlay(self, frame: Image.Image, word: str):
189+
"""Draw the current lyric word centred near the bottom of the frame."""
190+
if not word:
191+
return
192+
d = ImageDraw.Draw(frame)
193+
font = self._lyric_font
194+
195+
# Measure text
196+
bbox = d.textbbox((0, 0), word.upper(), font=font)
197+
tw = bbox[2] - bbox[0]
198+
th = bbox[3] - bbox[1]
199+
200+
pad_x, pad_y = int(tw * 0.18), int(th * 0.30)
201+
x = (self.width - tw) // 2
202+
y = int(self.height * 0.82)
203+
204+
# Pill background
205+
pill = [x - pad_x, y - pad_y, x + tw + pad_x, y + th + pad_y]
206+
d.rounded_rectangle(pill, radius=int(th * 0.4), fill=(0, 0, 0, 160))
207+
208+
# White text with subtle shadow
209+
d.text((x + 2, y + 2), word.upper(), font=font, fill=(0, 0, 0, 120))
210+
d.text((x, y), word.upper(), font=font, fill=(255, 255, 255, 240))
211+
152212
def _render_frame(self, timestamp: float) -> Image.Image:
153213
"""Render a single composited frame at the given timestamp."""
154214
# 1. Background
@@ -171,7 +231,14 @@ def _render_frame(self, timestamp: float) -> Image.Image:
171231
my = mascot_y + self.mouth_region["y"] + bob
172232
frame.paste(mouth_sprite, (mx, my), mouth_sprite)
173233

174-
return frame.convert("RGB")
234+
rgb = frame.convert("RGB")
235+
236+
# 5. Lyric word overlay
237+
word = self._get_word_at(timestamp)
238+
if word:
239+
self._render_lyric_overlay(rgb, word)
240+
241+
return rgb
175242

176243
# ------------------------------------------------------------------ #
177244
# Sequence rendering

config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ character:
1313
# Pixel bounding box on the mascot image where the mouth sprites are composited.
1414
# Run generate_sprites.py with --detect to auto-detect, or set manually.
1515
mouth_region:
16-
x: 200 # left edge of mouth area on the mascot image
17-
y: 280 # top edge
18-
w: 112 # width
19-
h: 70 # height
16+
x: 376 # left edge of mouth area on the mascot image
17+
y: 615 # top edge
18+
w: 272 # width
19+
h: 110 # height
2020

2121
animation:
2222
fps: 24

examples/demo_fox.png

1.29 KB
Loading

generate_sprites.py

Lines changed: 103 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,22 @@
3131
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
3232
logger = logging.getLogger(__name__)
3333

34-
# 9 Rhubarb phoneme mouth shapes, described as (openness_ratio, width_ratio, shape)
35-
# openness_ratio: vertical opening as fraction of sprite height (0 = closed, 1 = fully open)
36-
# width_ratio: horizontal width as fraction of sprite width
37-
# shape: 'oval' | 'pressed' | 'narrow' | 'wide' | 'round' | 'lip'
34+
# 9 Rhubarb phoneme mouth shapes
35+
# openness: vertical opening as fraction of sprite height (0=closed, 1=fully open)
36+
# width: horizontal opening as fraction of sprite width
37+
# shape: controls cavity geometry
38+
# teeth: show upper teeth strip
39+
# tongue: show tongue at cavity bottom
3840
PHONEME_SHAPES = {
39-
"X": {"openness": 0.00, "width": 0.85, "shape": "pressed"}, # silence / rest
40-
"A": {"openness": 0.65, "width": 0.80, "shape": "oval"}, # "father"
41-
"B": {"openness": 0.02, "width": 0.88, "shape": "pressed"}, # "bad" / M / P
42-
"C": {"openness": 0.40, "width": 0.75, "shape": "oval"}, # "cut"
43-
"D": {"openness": 0.25, "width": 0.72, "shape": "oval"}, # "dead"
44-
"E": {"openness": 0.30, "width": 0.95, "shape": "wide"}, # "bed" — teeth showing
45-
"F": {"openness": 0.15, "width": 0.70, "shape": "lip"}, # "fat" — bottom lip up
46-
"G": {"openness": 0.45, "width": 0.60, "shape": "narrow"}, # "good"
47-
"H": {"openness": 0.55, "width": 0.68, "shape": "round"}, # "hot"
41+
"X": {"openness": 0.00, "width": 0.82, "shape": "closed", "teeth": False, "tongue": False},
42+
"A": {"openness": 0.62, "width": 0.78, "shape": "oval", "teeth": True, "tongue": True},
43+
"B": {"openness": 0.00, "width": 0.85, "shape": "pressed", "teeth": False, "tongue": False},
44+
"C": {"openness": 0.36, "width": 0.72, "shape": "oval", "teeth": True, "tongue": False},
45+
"D": {"openness": 0.20, "width": 0.68, "shape": "oval", "teeth": False, "tongue": False},
46+
"E": {"openness": 0.26, "width": 0.92, "shape": "wide", "teeth": True, "tongue": False},
47+
"F": {"openness": 0.14, "width": 0.64, "shape": "lip", "teeth": True, "tongue": False},
48+
"G": {"openness": 0.42, "width": 0.54, "shape": "narrow", "teeth": False, "tongue": False},
49+
"H": {"openness": 0.48, "width": 0.62, "shape": "round", "teeth": False, "tongue": True},
4850
}
4951

5052

@@ -81,88 +83,105 @@ def _make_sprite(
8183
width: int,
8284
height: int,
8385
skin_tone: Tuple[int, int, int, int],
84-
outline_color: Tuple[int, int, int, int] = (30, 15, 10, 255),
85-
interior_color: Tuple[int, int, int, int] = (20, 10, 10, 230),
8686
) -> Image.Image:
8787
"""
88-
Draw a single mouth sprite for the given phoneme.
88+
Draw a layered cartoon mouth sprite for the given phoneme.
89+
Layers (bottom→top): lip fill → cavity → teeth → tongue → lip outline.
8990
Returns an RGBA image with a transparent background.
9091
"""
91-
sprite = Image.new("RGBA", (width, height), (0, 0, 0, 0))
92-
draw = ImageDraw.Draw(sprite)
92+
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
93+
d = ImageDraw.Draw(img)
9394

94-
shape_cfg = PHONEME_SHAPES[phoneme]
95-
openness = shape_cfg["openness"]
96-
w_ratio = shape_cfg["width"]
97-
shape = shape_cfg["shape"]
95+
cfg = PHONEME_SHAPES[phoneme]
96+
openness = cfg["openness"]
97+
w_ratio = cfg["width"]
98+
shape = cfg["shape"]
99+
show_teeth = cfg["teeth"]
100+
show_tongue= cfg["tongue"]
98101

99102
cx, cy = width // 2, height // 2
100-
ow = int(width * w_ratio) # mouth opening width
101-
oh = int(height * openness) if openness > 0 else 2 # mouth opening height
102-
103-
# Outer lip boundary (always present)
104-
lip_w = int(width * min(w_ratio + 0.08, 1.0))
105-
lip_h = max(int(height * 0.20), 6)
106-
lip_left = cx - lip_w // 2
107-
lip_right = cx + lip_w // 2
108-
lip_top = cy - lip_h // 2
109-
lip_bot = cy + lip_h // 2
110-
111-
if shape == "pressed" or openness < 0.05:
112-
# Closed / pressed lips — just the lip line
113-
draw.ellipse(
114-
[lip_left, lip_top, lip_right, lip_bot],
115-
fill=skin_tone,
116-
outline=outline_color,
117-
width=2,
118-
)
119-
# Mouth line
120-
draw.line(
121-
[(lip_left + 4, cy), (lip_right - 4, cy)],
122-
fill=outline_color,
123-
width=2,
124-
)
125-
126-
else:
127-
left = cx - ow // 2
128-
right = cx + ow // 2
129-
top = cy - oh // 2
130-
bot = cy + oh // 2
131103

104+
# Colour palette derived from mascot skin tone
105+
r0, g0, b0 = skin_tone[0], skin_tone[1], skin_tone[2]
106+
lip_fill = (r0, g0, b0, 255)
107+
lip_shadow = (max(0, r0 - 60), max(0, g0 - 60), max(0, b0 - 60), 255)
108+
cavity_col = (18, 6, 4, 250)
109+
teeth_col = (245, 240, 228, 240)
110+
tongue_col = (210, 82, 78, 230)
111+
112+
# ── Outer lip ellipse ───────────────────────────────────────────────
113+
lw = int(width * min(w_ratio + 0.14, 0.96))
114+
lh = int(height * 0.62)
115+
ll = cx - lw // 2
116+
lr = cx + lw // 2
117+
lt = cy - lh // 2
118+
lb = cy + lh // 2
119+
120+
# 1. Filled skin-tone lip
121+
d.ellipse([ll, lt, lr, lb], fill=lip_fill)
122+
123+
is_closed = openness < 0.04
124+
125+
if not is_closed:
126+
ow = int(width * w_ratio)
127+
oh = int(height * openness)
128+
129+
# Cavity centre sits slightly below sprite centre (lower lip is fuller)
130+
kcy = cy + max(int(lh * 0.07), 2)
131+
132+
# Size cavity geometry per phoneme shape
132133
if shape == "wide":
133-
# Wide open with flat top (E sound, teeth showing)
134-
draw.ellipse([left, top, right, bot], fill=interior_color, outline=outline_color, width=2)
135-
# Suggest teeth with a light stripe near top
136-
teeth_y = top + max(oh // 6, 2)
137-
draw.rectangle([left + 2, top + 1, right - 2, teeth_y],
138-
fill=(220, 215, 210, 200))
134+
cw = int(ow * 0.88); ch = int(oh * 0.78)
139135
elif shape == "round":
140-
# Rounder, more circular (H/O sound)
141-
r = min(ow, oh) // 2
142-
draw.ellipse([cx - r, cy - r, cx + r, cy + r],
143-
fill=interior_color, outline=outline_color, width=2)
136+
sz = int(min(ow, oh * 1.3) * 0.50)
137+
cw = sz * 2; ch = int(sz * 1.5)
144138
elif shape == "narrow":
145-
# Narrow vertical oval (G sound)
146-
draw.ellipse([left + ow // 4, top, right - ow // 4, bot],
147-
fill=interior_color, outline=outline_color, width=2)
139+
cw = int(ow * 0.52); ch = oh
148140
elif shape == "lip":
149-
# Bottom lip slightly raised (F/V sound)
150-
draw.ellipse([left, top + oh // 4, right, bot],
151-
fill=interior_color, outline=outline_color, width=2)
152-
else:
153-
# Default oval (A, C, D, H)
154-
draw.ellipse([left, top, right, bot],
155-
fill=interior_color, outline=outline_color, width=2)
156-
157-
# Lip surround
158-
draw.ellipse([lip_left, lip_top, lip_right, lip_bot],
159-
fill=None, outline=skin_tone, width=3)
160-
lower_top = min(top + oh, lip_bot)
161-
lower_bot = max(lip_bot + max(oh // 3, 3), lower_top + 2)
162-
draw.ellipse([lip_left, lower_top, lip_right, lower_bot],
163-
fill=None, outline=skin_tone, width=2)
164-
165-
return sprite
141+
cw = int(ow * 0.65); ch = int(oh * 0.62)
142+
kcy = cy - max(int(oh * 0.10), 1)
143+
else: # oval (A, C, D)
144+
cw = int(ow * 0.82); ch = oh
145+
146+
# Clamp cavity strictly inside lip bounds
147+
cl = max(cx - cw // 2, ll + 3)
148+
cr = min(cx + cw // 2, lr - 3)
149+
ct = max(kcy - ch // 2, lt + 3)
150+
cb = min(kcy + ch // 2, lb - 3)
151+
if cr - cl < 4: cr = cl + 4
152+
if cb - ct < 4: cb = ct + 4
153+
154+
ch_actual = cb - ct
155+
cw_actual = cr - cl
156+
157+
# 2. Dark mouth cavity
158+
d.ellipse([cl, ct, cr, cb], fill=cavity_col)
159+
160+
# 3. Upper teeth — cream ellipse covering top of cavity
161+
if show_teeth and ch_actual > 10:
162+
th = max(ch_actual // 4, 5)
163+
d.ellipse([cl + 2, ct, cr - 2, ct + th * 2], fill=teeth_col)
164+
165+
# 4. Tongue — pink rounded shape at cavity bottom
166+
if show_tongue and ch_actual > 16:
167+
tw = int(cw_actual * 0.64)
168+
ts = max(ch_actual // 3, 7)
169+
td_top = max(cb - ts, ct + ch_actual // 2)
170+
td_bot = min(cb, lb - 2)
171+
if td_bot > td_top + 3:
172+
d.ellipse(
173+
[cx - tw // 2, td_top, cx + tw // 2, td_bot],
174+
fill=tongue_col,
175+
)
176+
177+
# 5. Re-draw lip outline on top of all layers
178+
d.ellipse([ll, lt, lr, lb], fill=None, outline=lip_shadow, width=3)
179+
180+
# 6. Centre mouth crease (always visible)
181+
crease_y = cy + 1
182+
d.line([(ll + 10, crease_y), (lr - 10, crease_y)], fill=lip_shadow, width=2)
183+
184+
return img
166185

167186

168187
def generate_sprites(
@@ -205,7 +224,7 @@ def main():
205224
parser.add_argument("--out", default="sprites/", help="Output directory for sprites")
206225
parser.add_argument(
207226
"--region", nargs=4, type=int, metavar=("X", "Y", "W", "H"),
208-
default=[200, 280, 112, 70],
227+
default=[376, 615, 272, 110],
209228
help="Mouth region on the mascot image (x y w h). Default: 200 280 112 70"
210229
)
211230
parser.add_argument(

sprites/mouth_A.png

695 Bytes
Loading

sprites/mouth_B.png

652 Bytes
Loading

sprites/mouth_C.png

653 Bytes
Loading

sprites/mouth_D.png

685 Bytes
Loading

sprites/mouth_E.png

642 Bytes
Loading

sprites/mouth_F.png

604 Bytes
Loading

0 commit comments

Comments
 (0)