virtual-characters / scripts /generate_character_assets.py
ShadowInk's picture
Upload complete Space runtime files
6bcddd0 verified
Raw
History Blame Contribute Delete
7.06 kB
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()