|
31 | 31 | logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") |
32 | 32 | logger = logging.getLogger(__name__) |
33 | 33 |
|
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 |
38 | 40 | 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}, |
48 | 50 | } |
49 | 51 |
|
50 | 52 |
|
@@ -81,88 +83,105 @@ def _make_sprite( |
81 | 83 | width: int, |
82 | 84 | height: int, |
83 | 85 | 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), |
86 | 86 | ) -> Image.Image: |
87 | 87 | """ |
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. |
89 | 90 | Returns an RGBA image with a transparent background. |
90 | 91 | """ |
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) |
93 | 94 |
|
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"] |
98 | 101 |
|
99 | 102 | 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 |
131 | 103 |
|
| 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 |
132 | 133 | 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) |
139 | 135 | 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) |
144 | 138 | 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 |
148 | 140 | 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 |
166 | 185 |
|
167 | 186 |
|
168 | 187 | def generate_sprites( |
@@ -205,7 +224,7 @@ def main(): |
205 | 224 | parser.add_argument("--out", default="sprites/", help="Output directory for sprites") |
206 | 225 | parser.add_argument( |
207 | 226 | "--region", nargs=4, type=int, metavar=("X", "Y", "W", "H"), |
208 | | - default=[200, 280, 112, 70], |
| 227 | + default=[376, 615, 272, 110], |
209 | 228 | help="Mouth region on the mascot image (x y w h). Default: 200 280 112 70" |
210 | 229 | ) |
211 | 230 | parser.add_argument( |
|
0 commit comments