| 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 |
|
|
| |
| 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) |
|
|
| |
| 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"])) |
|
|
| |
| 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"])) |
|
|
| |
| 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"])) |
|
|
| |
| 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"])) |
|
|
| |
| 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"]) |
|
|
| |
| 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") |
|
|
| |
| 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"])) |
|
|
| |
| 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() |
|
|