from __future__ import annotations from pathlib import Path from PIL import Image, ImageDraw, ImageFilter ROOT = Path(__file__).resolve().parents[1] OUT_DIR = ROOT / "assets" / "characters" SIZE = (900, 1200) SCALE = 3 CHARACTERS = { "memory": { "hair": "#29374f", "hair_shadow": "#151f33", "accent": "#7dd3fc", "coat": "#1f2937", "eye": "#38bdf8", "skin": "#ffd8cc", "skin_shadow": "#f0b7aa", }, "star": { "hair": "#d9fbff", "hair_shadow": "#7dd3fc", "accent": "#67e8f9", "coat": "#243042", "eye": "#00bcd4", "skin": "#ffd9cb", "skin_shadow": "#efb4a7", }, "mascot": { "hair": "#f8c34a", "hair_shadow": "#d97706", "accent": "#facc15", "coat": "#384152", "eye": "#f59e0b", "skin": "#ffd8bc", "skin_shadow": "#efb184", }, } EXPRESSIONS = ["idle", "listening", "thinking", "worried", "smile", "happy"] def _hex(color: str) -> tuple[int, int, int, int]: color = color.lstrip("#") return tuple(int(color[i : i + 2], 16) for i in (0, 2, 4)) + (255,) def _draw_soft_ellipse(draw: ImageDraw.ImageDraw, xy, fill, blur: int = 0) -> None: if blur <= 0: draw.ellipse(xy, fill=fill) return overlay = Image.new("RGBA", (SIZE[0] * SCALE, SIZE[1] * SCALE), (0, 0, 0, 0)) o = ImageDraw.Draw(overlay) o.ellipse(xy, fill=fill) overlay = overlay.filter(ImageFilter.GaussianBlur(blur * SCALE)) draw.bitmap((0, 0), overlay) def _mouth(draw: ImageDraw.ImageDraw, expression: str, x: int, y: int, color: str) -> None: stroke = _hex(color) if expression in {"smile", "happy"}: draw.arc((x - 46, y - 24, x + 46, y + 34), 15, 165, fill=stroke, width=9 * SCALE) elif expression == "worried": draw.arc((x - 38, y - 4, x + 38, y + 42), 195, 345, fill=stroke, width=8 * SCALE) else: draw.arc((x - 36, y - 16, x + 36, y + 20), 25, 155, fill=stroke, width=7 * SCALE) def _eye(draw: ImageDraw.ImageDraw, cx: int, cy: int, expression: str, iris: str) -> None: if expression in {"smile", "happy"}: draw.arc((cx - 42, cy - 12, cx + 42, cy + 36), 200, 340, fill=(31, 41, 55, 255), width=8 * SCALE) return eye_box = (cx - 35, cy - 28, cx + 35, cy + 30) draw.ellipse(eye_box, fill=(248, 250, 252, 255)) draw.ellipse((cx - 18, cy - 20, cx + 18, cy + 24), fill=_hex(iris)) draw.ellipse((cx - 9, cy - 12, cx + 9, cy + 15), fill=(15, 23, 42, 255)) draw.ellipse((cx + 3, cy - 13, cx + 13, cy - 3), fill=(255, 255, 255, 235)) def draw_character(key: str, expression: str) -> Image.Image: p = CHARACTERS[key] w, h = SIZE[0] * SCALE, SIZE[1] * SCALE img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) def s(value: int) -> int: return value * SCALE # Soft silhouette shadow. shadow = Image.new("RGBA", (w, h), (0, 0, 0, 0)) sd = ImageDraw.Draw(shadow) sd.ellipse((s(210), s(1020), s(690), s(1120)), fill=(0, 0, 0, 80)) shadow = shadow.filter(ImageFilter.GaussianBlur(s(16))) img.alpha_composite(shadow) # Body and outfit. draw.polygon( [(s(245), s(1125)), (s(330), s(705)), (s(570), s(705)), (s(655), s(1125))], fill=_hex(p["coat"]), ) draw.polygon( [(s(335), s(720)), (s(450), s(945)), (s(565), s(720)), (s(610), s(1125)), (s(290), s(1125))], fill=(17, 24, 39, 255), ) draw.polygon([(s(390), s(715)), (s(450), s(805)), (s(510), s(715))], fill=_hex(p["skin"])) draw.line((s(330), s(780), s(570), s(780)), fill=_hex(p["accent"]), width=s(10)) draw.rounded_rectangle((s(390), s(815), s(510), s(850)), radius=s(18), fill=_hex(p["accent"])) # Hair back mass. draw.ellipse((s(208), s(145), s(692), s(765)), fill=_hex(p["hair_shadow"])) draw.pieslice((s(176), s(198), s(724), s(870)), 185, 355, fill=_hex(p["hair_shadow"])) # Neck and face. draw.rounded_rectangle((s(397), s(642), s(503), s(765)), radius=s(38), fill=_hex(p["skin_shadow"])) draw.ellipse((s(260), s(190), s(640), s(665)), fill=_hex(p["skin"])) draw.ellipse((s(245), s(380), s(308), s(515)), fill=_hex(p["skin"])) draw.ellipse((s(592), s(380), s(655), s(515)), fill=_hex(p["skin"])) # Hair front and bangs. draw.pieslice((s(235), s(90), s(665), s(540)), 190, 350, fill=_hex(p["hair"])) draw.polygon([(s(270), s(275)), (s(350), s(90)), (s(405), s(340))], fill=_hex(p["hair"])) draw.polygon([(s(370), s(215)), (s(455), s(75)), (s(500), s(350))], fill=_hex(p["hair"])) draw.polygon([(s(500), s(255)), (s(585), s(115)), (s(610), s(380))], fill=_hex(p["hair"])) draw.polygon([(s(245), s(315)), (s(210), s(690)), (s(335), s(570))], fill=_hex(p["hair_shadow"])) draw.polygon([(s(655), s(310)), (s(690), s(690)), (s(565), s(570))], fill=_hex(p["hair_shadow"])) # Expression. brow_y = 365 if expression != "worried" else 350 left_brow = (s(334), s(brow_y), s(404), s(brow_y - (18 if expression == "worried" else 5))) right_brow = (s(496), s(brow_y - (18 if expression == "worried" else 5)), s(566), s(brow_y)) draw.line(left_brow, fill=(51, 65, 85, 255), width=s(8)) draw.line(right_brow, fill=(51, 65, 85, 255), width=s(8)) _eye(draw, s(370), s(420), expression, p["eye"]) _eye(draw, s(530), s(420), expression, p["eye"]) # Nose, blush, mouth. draw.arc((s(437), s(445), s(468), s(505)), 270, 40, fill=(190, 119, 104, 150), width=s(4)) if expression in {"smile", "happy", "listening"}: draw.ellipse((s(302), s(480), s(365), s(520)), fill=(255, 145, 160, 65)) draw.ellipse((s(535), s(480), s(598), s(520)), fill=(255, 145, 160, 65)) _mouth(draw, expression, s(450), s(548), "#9f1239") # Accent accessories. if key == "star": draw.polygon([(s(620), s(245)), (s(650), s(305)), (s(715), s(315)), (s(666), s(358)), (s(680), s(425)), (s(620), s(390)), (s(560), s(425)), (s(574), s(358)), (s(525), s(315)), (s(590), s(305))], fill=_hex(p["accent"])) elif key == "memory": draw.rounded_rectangle((s(195), s(402), s(245), s(500)), radius=s(18), outline=_hex(p["accent"]), width=s(7)) draw.line((s(215), s(500), s(170), s(565)), fill=_hex(p["accent"]), width=s(5)) else: draw.polygon([(s(280), s(185)), (s(320), s(90)), (s(360), s(195))], fill=_hex(p["accent"])) draw.polygon([(s(540), s(195)), (s(580), s(90)), (s(620), s(185))], fill=_hex(p["accent"])) # Soft highlight. draw.arc((s(305), s(210), s(610), s(620)), 210, 315, fill=(255, 255, 255, 60), width=s(10)) return img.resize(SIZE, Image.Resampling.LANCZOS) def main() -> None: for key in CHARACTERS: character_dir = OUT_DIR / key character_dir.mkdir(parents=True, exist_ok=True) for expression in EXPRESSIONS: path = character_dir / f"{expression}.png" draw_character(key, expression).save(path) print(path.relative_to(ROOT)) if __name__ == "__main__": main()