skeleton-gif / engine.py
ocmannazirbriet's picture
Upload skeleton-gif model
38d7f79 verified
"""
generate.py — Deterministic skeleton-GIF generator.
Pipeline:
prompt -> (keyword / zero-shot LLM) -> (action, emotion)
-> procedural skeleton keyframes
-> PIL frames (black bg, white bones)
-> GIF
Hard guarantees:
* Output is ALWAYS a .gif
* Output is ALWAYS a skeleton (drawn by us, no diffusion)
* Emotion is visible on the skeleton face AND body posture
* Zero hallucination (classification is closed-set; rendering is deterministic)
Usage:
python generate.py "a sad man reading a book" [--out ./outputs] [--debug]
"""
from __future__ import annotations
import argparse
import logging
import math
import os
import re
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
from typing import Callable, Dict, Tuple
from PIL import Image, ImageDraw
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logger = logging.getLogger("generate")
def _setup_logging(debug: bool) -> None:
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
# Silence noisy third-party loggers so our debug output stays readable.
for noisy in ("httpcore", "httpx", "urllib3", "transformers", "filelock",
"huggingface_hub", "PIL"):
logging.getLogger(noisy).setLevel(logging.WARNING)
logger.setLevel(level)
logger.debug("Logging initialised at level=%s", logging.getLevelName(level))
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CANVAS = 512
N_FRAMES = 24
FPS = 10
FRAME_MS = int(1000 / FPS)
BG_COLOR = (8, 8, 14) # near-black
BONE_COLOR = (240, 240, 240) # bone-white
JOINT_COLOR = (255, 255, 255)
ACCENT_COLOR = (200, 60, 60) # for "book" / props
BONE_WIDTH = 6
JOINT_RADIUS = 5
FLOOR_Y = 462 # where skeleton feet meet the ground
# Joint IDs
HEAD, NECK, TORSO, PELVIS = 0, 1, 2, 3
LSH, LEL, LHA = 4, 5, 6
RSH, REL, RHA = 7, 8, 9
LHP, LKN, LFT = 10, 11, 12
RHP, RKN, RFT = 13, 14, 15
SKELETON_EDGES: Tuple[Tuple[int, int], ...] = (
(NECK, TORSO), (TORSO, PELVIS),
(NECK, LSH), (LSH, LEL), (LEL, LHA),
(NECK, RSH), (RSH, REL), (REL, RHA),
(PELVIS, LHP), (LHP, LKN), (LKN, LFT),
(PELVIS, RHP), (RHP, RKN), (RKN, RFT),
)
# Rest pose (standing, arms down). Coords in image space (y grows downward).
REST_POSE: Dict[int, Tuple[float, float]] = {
HEAD: (256.0, 110.0),
NECK: (256.0, 155.0),
TORSO: (256.0, 215.0),
PELVIS: (256.0, 290.0),
LSH: (220.0, 160.0),
LEL: (205.0, 220.0),
LHA: (198.0, 280.0),
RSH: (292.0, 160.0),
REL: (307.0, 220.0),
RHA: (314.0, 280.0),
LHP: (232.0, 300.0),
LKN: (228.0, 370.0),
LFT: (225.0, 440.0),
RHP: (280.0, 300.0),
RKN: (284.0, 370.0),
RFT: (287.0, 440.0),
}
ACTION_LABELS = [
"dancing", "walking", "running", "reading", "waving",
"sitting", "jumping", "standing_idle", "fighting", "thinking", "working",
"eating", "drinking", "sleeping", "crying", "laughing",
"praying", "clapping", "writing", "stretching", "kicking",
"vacuuming", "sweeping", "cooking", "washing", "gardening",
"cleaning", "dusting", "mopping", "ironing", "polishing",
# Sports + gaming
"football", "cricket", "basketball", "tennis", "baseball",
"golf", "bowling", "skateboarding", "gaming",
# Body / motion
"bowing", "hugging", "kissing", "handshake", "pointing",
"climbing", "falling", "crawling", "rolling", "juggling",
# Water / snow / board sports
"swimming", "diving", "surfing", "skating", "skiing", "biking",
# Vehicle / locomotion
"driving", "riding_horse", "rowing",
# Involuntary / body care
"sneezing", "coughing", "yawning", "shivering", "scratching",
"shaving", "brushing_teeth", "combing_hair", "dressing",
# Load / force
"carrying", "lifting", "pushing", "pulling", "throwing", "catching",
# Precision / craft
"fishing", "shooting", "archery", "painting", "drawing",
"sculpting", "photographing",
# Sound / voice / teaching
"singing", "whistling", "yelling", "teaching", "presenting",
# Instruments
"playing_guitar", "playing_piano", "playing_drums",
# Modern life
"shopping", "texting", "meditating",
]
EMOTION_LABELS = [
"happy", "sad", "angry", "tired", "excited", "neutral",
"scared", "surprised", "bored", "confused",
]
SCENE_LABELS = [
"bedroom", "market", "office", "park", "library",
"kitchen", "street", "gym",
"beach", "forest", "restaurant", "school",
"hospital", "bathroom", "church", "space", "rooftop", "farm",
"living_room",
# Sports venues
"soccer_field", "cricket_ground", "basketball_court",
"tennis_court", "baseball_field", "golf_course", "bowling_alley",
# Education / institutional
"classroom", "auditorium", "laboratory",
# Culture / entertainment
"museum", "art_gallery", "theater", "cinema", "concert_hall",
"stadium", "zoo", "aquarium",
# Nature / outdoor
"greenhouse", "cave", "mountain", "desert", "waterfall", "vineyard",
# Architectural / historic
"cemetery", "castle", "mansion", "cottage", "cabin",
"lighthouse", "temple", "monastery",
# Transport hubs
"airport", "train_station", "subway", "bridge",
"parking_lot", "gas_station",
# Civic
"bank", "prison", "police_station", "fire_station", "courthouse",
# Industrial
"factory", "warehouse", "construction_site", "garage",
# Home annexes
"basement", "attic", "laundry_room", "pantry",
# Fun / recreation
"swimming_pool", "casino", "nightclub", "arcade", "spa",
# Misc
"barn", "salon", "bakery",
"none",
]
# Keyword shortcuts (deterministic, run before LLM). Dict iteration order
# = priority order (Python preserves insertion order), so put the more
# specific / less ambiguous labels first.
ACTION_KEYWORDS: Dict[str, Tuple[str, ...]] = {
# Motion
"dancing": ("danc", "boogi", "groov", "salsa", "waltz", "ballet",
"tango", "disco", "twerk", "breakdanc", "hiphop",
"choreograph", "bop"),
"running": ("run", "jog", "sprint", "dash", "race", "rush",
"scamper", "gallop"),
"walking": ("walk", "stroll", "stride", "march", "pace", "saunter",
"amble", "wander", "trek", "hike", "promenade", "tread"),
"jumping": ("jump", "leap", "hop", "bound", "bounc", "spring",
"skip", "vault"),
"kicking": ("kick", "punt", "karate"),
"climbing": (), # alias placeholder (not implemented)
"stretching": ("stretch", "yoga", "warm-up", "warmup", "limber",
"flex ", "loosen"),
# Combat
"fighting": ("fight", "punch", "box", "brawl", "attack", "combat",
"battle", "duel", "wrestle", "spar", "martial",
"strike", "assault"),
# Social / gesture
"waving": ("wav", "hello", " hi ", "greet", "salute", "bye",
"farewell", "goodbye", "hail", "beckon"),
"clapping": ("clap", "applau", "ovation", "hand claps", "handclap"),
# Consumption
"eating": ("eat", "munch", "chew", "feast", "dine", "meal",
"food ", "breakfast", "lunch", "dinner", "snack",
"bite", "nibble", "tast"),
"drinking": ("drink", "sip", "gulp", "beverage", "coffee", "tea ",
"juice", "soda", "water bottle", "thirst", "chug"),
# Mental / quiet
"reading": ("read", "book", "stud", "novel", "magazine",
"newspaper", "textbook", "literat", "peruse", "brows"),
"thinking": ("think", "ponder", "wonder", "contemplat", "muse ",
"reflect", "meditat", "imagin", "consider", "daydream"),
"writing": ("writ", "scribbl", "sign ", "note ", "pen ", "pencil",
"letter", "journal", "jot", "author"),
"praying": ("pray", "worship", "kneel", "devot", "amen", "blessing",
"rosary"),
# Emotional expression (but as ACTIONS — can combine with emotions)
"laughing": ("laugh", "giggl", "chuckl", "guffaw", "snicker",
"cackl", "lol", "rofl"),
"crying": ("cry", "weep", "sob", "tear", "bawl", "whimper",
"wail"),
# Sports + gaming (put specific sport keywords BEFORE the generic
# "kicking" and "fighting" keywords so they win).
"football": ("football", "soccer", "fifa", "penalty kick",
"goalkeep"),
"cricket": ("cricket", "=wicket", "=wickets", "batsman", "bowler",
"ipl "),
"basketball": ("basketball", "dribbl", "slam dunk", "=dunk", "=nba",
"hoops"),
"tennis": ("tennis", "racquet", "=racket", "=wimbledon",
"tennis serve"),
"baseball": ("baseball", "home run", "homerun", "=mlb", "softball",
"pitcher mound"),
"golf": ("golf", "putter", "=putt ", "=putts", "tee shot",
"fairway", "caddy"),
"bowling": ("bowling", "strike game", "bowling alley", "tenpin",
"=pins"),
"skateboarding": ("skateboard", "skate park", "kickflip", "=ollie",
"skating rink", "ice skat"),
"gaming": ("gaming", "video game", "videogame", "=console",
"playstation", "=xbox", "nintendo", "esport",
"controller", "gamepad", "=ps5", "=ps4"),
# Body / motion
# Note: use "bowing"/"bowed"/"bow down" (not bare "bow") so we don't
# catch "bow and arrow" (archery) below.
"bowing": ("bowing", "=bowed", "take a bow", "bow down"),
"hugging": ("hug", "embrac", "cuddl"),
"kissing": ("kiss", "smooch", "peck"),
"handshake": ("handshake", "shake hand", "shaking hand"),
"pointing": ("point ", "pointing", "=points", "indicat"),
"climbing": ("climb", "scal", "ascend", "mountaineer"),
"falling": ("fall", "falling", "tripp", "tumbl", "toppl"),
"crawling": ("crawl", "=creep", "creeping"),
"rolling": ("=roll", "rolling", "=rolls"),
"juggling": ("juggl",),
# Water / snow / board
"swimming": ("swim", "freestyle", "breaststroke", "backstroke"),
"diving": ("div", "divin", "plung"),
"surfing": ("surf", "wave ride", "longboard wave"),
"skating": ("skate", "roller skat", "ice skat", "skating rink"),
"skiing": ("ski ", "skiing", "=skis", "slalom"),
"biking": ("bik", "bicycl", "cycl", "=bike", "pedal"),
# Vehicle
"driving": ("driv", "steering wheel", "behind the wheel"),
"riding_horse": ("horseback", "riding a horse", "on horseback",
"equestrian"),
"rowing": ("row ", "rowing", "oar ", "canoe"),
# Involuntary / body care
"sneezing": ("sneez", "achoo"),
"coughing": ("cough", "hack ", "splutter"),
"yawning": ("yawn",),
"shivering": ("shiver", "shudder", "freezing cold"),
"scratching": ("scratch", "itch"),
"shaving": ("shav",),
"brushing_teeth": ("brush teeth", "brushing teeth", "toothbrush",
"dental"),
"combing_hair": ("comb hair", "combing hair", "brush hair",
"brushing hair"),
"dressing": ("dress ", "dressing", "getting dressed", "put on"),
# Load / force
"carrying": ("carry", "hauling", "toting", "lug ", "lugging"),
"lifting": ("lift ", "lifting", "deadlift", "bench press",
"barbell", "dumbbell"),
"pushing": ("push ", "pushing", "=pushes", "shove", "press forward"),
"pulling": ("pull ", "pulling", "=pulls", "tug ", "tugging",
"dragg"),
"throwing": ("throw", "toss", "hurl", "chuck ", "pitch "),
"catching": ("catch", "grabbing"),
# Precision / craft
"fishing": ("fish", "angling"),
"shooting": ("shoot", "rifle", "gun ", "pistol", "firing "),
"archery": ("archer", "=bow", "arrow", "crossbow"),
"painting": ("paint ", "painting", "easel", "=paints"),
"drawing": ("draw ", "drawing", "sketch", "doodl"),
"sculpting": ("sculpt", "clay", "statue carv"),
"photographing": ("photograph", "=photo", "camera", "snapshot",
"taking a picture"),
# Voice / teach
"singing": ("sing ", "singing", "sung", "=sings", "croon"),
"whistling": ("whistl",),
"yelling": ("yell", "shout", "scream"),
"teaching": ("teach", "lectur", "instruct"),
"presenting": ("present ", "presenting", "keynote", "pitch to"),
# Instruments
"playing_guitar": ("guitar", "strum", "bass guitar", "ukulele"),
"playing_piano": ("piano", "keyboard play", "keys play"),
"playing_drums": ("drum", "drumkit", "drum kit", "percuss"),
# Modern life
"shopping": ("shopping", "grocery run", "shopping cart",
"browsing aisles"),
"texting": ("text", "texting", "sms", "whatsapp", "messag"),
"meditating": ("meditat", "=zen", "mindful", "namaste"),
# Chores / household (must come before the "working" catch-all)
"vacuuming": ("vacuum", "vaccum", "vaccu", "vaccui", "hoover",
"hoovering"),
"sweeping": ("sweep", "broom"),
"cooking": ("cook", "bake", "boil", "fry", "recipe", "stir ",
"simmer", "chef", "prepar"),
# Wash is about water + dishes/laundry specifically.
"washing": ("wash", "dish", "scrub", "launder", "laundry",
"rins"),
# Generic tidy-up — put BEFORE washing so "cleaning" wins its own label.
"cleaning": ("clean", "tidy", "tidying", "wipe", "wipin",
"spray bottle"),
"dusting": ("dust", "feather duster", "dusting"),
"mopping": ("mop ", "mopping", "mop the"),
"ironing": ("iron ", "ironing", "press shirt", "ironing board"),
"polishing": ("polish", "buff", "shine the", "waxing"),
"gardening": ("garden", " plant ", "watering plant", "trowel",
"weed"),
# Rest / idle
"sleeping": ("sleep", "nap ", "slumber", "doz", "snor", " rest",
"snooze", "shuteye"),
"sitting": ("sit ", "sitting", "seat", "chair", "bench", "stool",
"perch", "recline", "lounge", "squat", "cross-legged",
"crouch"),
"standing_idle": ("stand", "idle", "wait", "motionless", "still ",
"pose", "upright"),
# Default catch-all — most specific last so others win first
"working": ("work", "typ", "comput", "keyboard", "laptop", "task",
"labor", "busy ", "grind", "employ", "job ", "offic",
"market", " cook", "shop"),
}
# Remove the placeholder "climbing" entry (kept for future extension).
ACTION_KEYWORDS.pop("climbing", None)
EMOTION_KEYWORDS: Dict[str, Tuple[str, ...]] = {
"happy": ("happy", "joy", "joyful", "cheer", "glad", "smil",
"delight", "merr", "content", "pleased", "gleeful",
"jolly", "elat", "bliss"),
"sad": ("sad", "cry", "depress", "down ", "grief", "sorrow",
"gloom", "melanchol", "unhappy", "heartbroken", "miser",
"blue ", "mourn", "tearful", "woeful"),
"angry": ("angry", "mad ", "furious", "rage", "annoy", "irrit",
"frustrat", "livid", "fuming", "hostile", "enrag",
"outrag", "pissed", "seething", "grumpy"),
"tired": ("tired", "exhaust", "weary", "drows", "fatigue", "worn",
"drained", "spent", "sluggish", "lethargic"),
"excited": ("excit", "thrill", "energ", "eager", "pumped", "stoked",
"hyped", "ecstatic", "euphoric", "enthusiast"),
"scared": ("scared", "afraid", "terrif", "frighten", "fear",
"petrified", "panick", "horrif", "spook", "nervous",
"anxious", "worried", "unease"),
"surprised": ("surpris", "shock", "astonish", "amaz", "stun", "startl",
"dumbfound", "flabbergast", "taken aback", "gasp"),
"bored": ("bored", "boring", "dull", "uninterest", "apathetic",
"listless", "tedious", "monoton"),
"confused": ("confus", "puzzl", "perplex", "bewilder", "baffl",
"mystif", "lost ", "unclear", "huh"),
"neutral": ("neutral", "calm", "plain", "ordinary", "normal"),
}
SCENE_KEYWORDS: Dict[str, Tuple[str, ...]] = {
# Specific & unambiguous first
"living_room": ("living room", "livingroom", "living-room", "lounge",
"sofa", "couch", "sitting room", "parlor", "parlour",
"den ", "carpet", "tv room",
"=rug", "=rugs", "=room", "=rooms"),
"soccer_field": ("soccer field", "football field", "football pitch",
"soccer pitch", "football ground", "=pitch"),
"cricket_ground": ("cricket ground", "cricket field", "cricket pitch",
"cricket stadium"),
"basketball_court": ("basketball court", "basketball arena",
"basketball stadium", "indoor court"),
"tennis_court": ("tennis court", "clay court", "grass court"),
"baseball_field": ("baseball field", "baseball diamond",
"baseball park", "ballpark"),
"golf_course": ("golf course", "golf club", "putting green",
"=fairway"),
"bowling_alley": ("bowling alley", "bowling lane",
"bowling center", "tenpin alley"),
# Education / institutional
"classroom": ("classroom", "=class", "lecture hall"),
"auditorium": ("auditorium", "assembly hall"),
"laboratory": ("laboratory", "=lab ", "=labs"),
# Culture / entertainment
"museum": ("museum", "exhibit", "gallery museum"),
"art_gallery": ("art gallery", "art museum", "=gallery",
"=galleries"),
"theater": ("theater", "theatre", "playhouse", "broadway"),
"cinema": ("cinema", "movie theater", "=movies", "movie hall"),
"concert_hall": ("concert hall", "philharmonic", "symphony hall"),
"stadium": ("stadium", "arena ", "coliseum", "bleacher"),
"zoo": ("=zoo", "=zoos"),
"aquarium": ("aquarium", "fish tank", "oceanarium"),
# Nature / outdoor
"greenhouse": ("greenhouse", "conservatory"),
"cave": ("=cave", "=caves", "cavern", "grotto"),
"mountain": ("mountain", "=peak", "summit", "alpine",
"hillside"),
"desert": ("desert", "=dune", "=dunes", "sahara"),
"waterfall": ("waterfall", "=falls", "cascade"),
"vineyard": ("vineyard", "winery", "wine estate", "grapevine"),
# Architectural / historic
"cemetery": ("cemetery", "graveyard", "=grave", "tombstone"),
"castle": ("castle", "fortress", "citadel"),
"mansion": ("mansion", "manor ", "estate house", "chateau"),
"cottage": ("cottage", "hut "),
"cabin": ("=cabin", "=cabins", "log cabin"),
"lighthouse": ("lighthouse",),
"temple": ("temple", "shrine", "pagoda"),
"monastery": ("monastery", "abbey", "convent"),
# Transport hubs
"airport": ("airport", "terminal ", "airstrip", "runway"),
"train_station": ("train station", "railway station", "platform "),
"subway": ("subway", "=metro", "underground rail", "tube "),
"bridge": ("=bridge", "=bridges", "overpass"),
"parking_lot": ("parking lot", "parking garage", "car park"),
"gas_station": ("gas station", "petrol station", "service station"),
# Civic
"bank": ("=bank", "=banks", "vault "),
"prison": ("prison", "=jail", "=jails", "penitentiary"),
"police_station": ("police station", "=precinct", "cop shop"),
"fire_station": ("fire station", "firehouse"),
"courthouse": ("courthouse", "court room", "courtroom", "=court"),
# Industrial
"factory": ("factory", "plant floor", "assembly line",
"manufacturing"),
"warehouse": ("warehouse", "stockroom", "depot"),
"construction_site": ("construction site", "building site",
"scaffolding"),
"garage": ("=garage", "=garages", "repair shop"),
# Home annexes
"basement": ("basement", "cellar"),
"attic": ("=attic", "=attics", "loft "),
"laundry_room": ("laundry room", "washer dryer"),
"pantry": ("pantry", "larder"),
# Fun / recreation
"swimming_pool": ("swimming pool", "=pool", "=pools", "poolside"),
"casino": ("casino", "roulette ", "slot machine"),
"nightclub": ("nightclub", "=club ", "disco ", "rave "),
"arcade": ("=arcade", "=arcades", "game hall"),
"spa": ("=spa", "=spas", "massage parlor", "wellness center"),
# Misc
"barn": ("=barn", "=barns"),
"salon": ("=salon", "barbershop", "hair salon", "beauty salon"),
"bakery": ("bakery", "pastry shop", "patisserie"),
"bedroom": ("bedroom", " bed ", " bed,", " bed.", "bedside",
"pillow", "mattress", "dorm"),
"bathroom": ("bathroom", "toilet", "shower", "washroom", "restroom",
"lavatory", "sink ", "bathtub"),
"kitchen": ("kitchen", "stove", "counter", " cook", "recipe",
"cupboard", "fridge"),
"library": ("library", "bookshelf", "shelves", "archive",
"reading room"),
"office": ("office", "cubicle", "meeting", "boardroom", "workplace",
"workspace", "desk "),
"school": ("school", "classroom", "university", "college",
"lecture", "chalkboard", "blackboard", "student"),
"hospital": ("hospital", "clinic", "infirmary", "icu", "ward ",
"medical", "doctor", "nurse", "emergency room", "er "),
"church": ("church", "cathedral", "chapel", "temple", "mosque",
"synagogue", "altar", "pew"),
"restaurant": ("restaurant", "cafe", "café", "diner", "bistro",
"bar ", "pub", "canteen", "eatery"),
"gym": ("gym", "fitness", "workout", "weight room", "studio ",
"dojo", "stadium", "arena", "court "),
"market": ("market", "bazaar", "stall", " shop", "store",
"supermarket", "grocery"),
"park": ("park", "garden", "playground", "lawn", "meadow"),
"forest": ("forest", "woods", "jungle", "rainforest", "thicket",
"grove", "bush "),
"beach": ("beach", "seaside", "shore", "coast", "ocean", " sea ",
"sandy", "sand ", "surf"),
"street": ("street", "road", "sidewalk", "alley", "avenue",
"boulevard", "city", "downtown", "intersection"),
"rooftop": ("rooftop", "roof ", "terrace", "balcony"),
"space": ("space", "cosmos", "galaxy", "moon ", "mars ", "planet",
"spaceship", "astronaut", " orbit", "star "),
"farm": ("farm", "barn", "pasture", "ranch", "countryside",
"field ", "meadow", "hayloft"),
}
# ---------------------------------------------------------------------------
# Prompt parsing
# ---------------------------------------------------------------------------
def _keyword_match(prompt_lc: str, table: Dict[str, Tuple[str, ...]]) -> str | None:
"""Match keywords against the prompt.
Convention: a keyword starting with `=` is matched as a *whole word*
(`\\b...\\b`). Everything else is a prefix match (`\\b...`), which lets
us catch conjugations like 'danc' -> dance/dancing/danced. The `=word`
form is used for short ambiguous words (e.g. =rug avoids matching
'rugged').
"""
for label, keys in table.items():
for k in keys:
if k.startswith("="):
pattern = rf"\b{k[1:]}\b"
else:
pattern = rf"\b{k}"
if re.search(pattern, prompt_lc):
return label
return None
def _llm_classify(prompt: str, labels: list[str], hypothesis: str) -> Tuple[str, float]:
"""Zero-shot classification via local transformers pipeline. No network API."""
from transformers import pipeline # lazy: avoid import cost if keyword matched
global _ZS_PIPE # noqa: PLW0603
if _ZS_PIPE is None:
logger.info("Loading zero-shot classifier (facebook/bart-large-mnli, cached on first run)...")
t0 = time.time()
_ZS_PIPE = pipeline("zero-shot-classification", model="facebook/bart-large-mnli")
logger.debug("Classifier loaded in %.1fs", time.time() - t0)
out = _ZS_PIPE(prompt, candidate_labels=labels, hypothesis_template=hypothesis)
top_label = out["labels"][0]
top_score = float(out["scores"][0])
logger.debug("[llm] labels=%s scores=%s", out["labels"][:3], [round(s, 3) for s in out["scores"][:3]])
return top_label, top_score
_ZS_PIPE = None # type: ignore[assignment]
def parse_prompt(prompt: str) -> Tuple[str, str, str]:
"""Return (action, emotion, scene). Always canonical labels — never hallucinates."""
if not prompt or not prompt.strip():
raise ValueError("Prompt is empty.")
prompt_lc = prompt.lower().strip()
logger.debug("[parse] prompt=%r", prompt_lc)
action = _keyword_match(prompt_lc, ACTION_KEYWORDS)
emotion = _keyword_match(prompt_lc, EMOTION_KEYWORDS)
scene = _keyword_match(prompt_lc, SCENE_KEYWORDS)
logger.debug("[parse] keyword action=%s emotion=%s scene=%s", action, emotion, scene)
# Thresholds are conservative: BART's softmax over a closed set always
# produces *something*, and low-confidence wins are usually hallucinated
# attributes the user never mentioned. Prefer the neutral fallback.
if action is None:
try:
lbl, score = _llm_classify(
prompt_lc, ACTION_LABELS,
"This is a description of someone {}."
)
action = lbl if score >= 0.35 else "standing_idle"
logger.debug("[parse] llm-action=%s (score=%.3f -> %s)", lbl, score, action)
except Exception as e: # model load / inference failure -> safe default
logger.warning("[parse] LLM action classification failed (%s); defaulting to standing_idle", e)
action = "standing_idle"
if emotion is None:
try:
lbl, score = _llm_classify(
prompt_lc, EMOTION_LABELS,
"The emotional tone of this is {}."
)
emotion = lbl if score >= 0.55 else "neutral"
logger.debug("[parse] llm-emotion=%s (score=%.3f -> %s)", lbl, score, emotion)
except Exception as e:
logger.warning("[parse] LLM emotion classification failed (%s); defaulting to neutral", e)
emotion = "neutral"
if scene is None:
try:
# Only the real scenes — we add "none" manually if confidence is low.
lbl, score = _llm_classify(
prompt_lc, SCENE_LABELS[:-1],
"The setting or location of this scene is a {}."
)
scene = lbl if score >= 0.55 else "none"
logger.debug("[parse] llm-scene=%s (score=%.3f -> %s)", lbl, score, scene)
except Exception as e:
logger.warning("[parse] LLM scene classification failed (%s); defaulting to none", e)
scene = "none"
assert action in ACTION_LABELS
assert emotion in EMOTION_LABELS
assert scene in SCENE_LABELS
logger.info("[parse] resolved action=%s emotion=%s scene=%s", action, emotion, scene)
return action, emotion, scene
# ---------------------------------------------------------------------------
# Action keyframe functions
# ---------------------------------------------------------------------------
def _copy_pose() -> Dict[int, Tuple[float, float]]:
return {k: (v[0], v[1]) for k, v in REST_POSE.items()}
def _shift(p: Dict[int, Tuple[float, float]], joint: int, dx: float, dy: float) -> None:
x, y = p[joint]
p[joint] = (x + dx, y + dy)
def action_standing_idle(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
breath = 1.2 * math.sin(2 * math.pi * t)
for j in (HEAD, NECK, TORSO, LSH, RSH):
_shift(p, j, 0, -breath)
return p
def action_walking(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
# Leg swing (opposite phase)
leg_amp = 28
_shift(p, LKN, 0, 0); _shift(p, LFT, 0, 0)
_shift(p, RKN, 0, 0); _shift(p, RFT, 0, 0)
p[LFT] = (p[LFT][0] + leg_amp * math.sin(phase), p[LFT][1] - abs(leg_amp * 0.35 * math.sin(phase)))
p[RFT] = (p[RFT][0] + leg_amp * math.sin(phase + math.pi), p[RFT][1] - abs(leg_amp * 0.35 * math.sin(phase + math.pi)))
p[LKN] = ((p[LHP][0] + p[LFT][0]) / 2 - 4, (p[LHP][1] + p[LFT][1]) / 2)
p[RKN] = ((p[RHP][0] + p[RFT][0]) / 2 + 4, (p[RHP][1] + p[RFT][1]) / 2)
# Arms counter-swing
arm_amp = 22
p[LHA] = (p[LHA][0] + arm_amp * math.sin(phase + math.pi), p[LHA][1])
p[RHA] = (p[RHA][0] + arm_amp * math.sin(phase), p[RHA][1])
p[LEL] = ((p[LSH][0] + p[LHA][0]) / 2 - 2, (p[LSH][1] + p[LHA][1]) / 2)
p[REL] = ((p[RSH][0] + p[RHA][0]) / 2 + 2, (p[RSH][1] + p[RHA][1]) / 2)
# Vertical bob
bob = 3 * abs(math.sin(phase * 2))
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, -bob)
return p
def action_running(t: float) -> Dict[int, Tuple[float, float]]:
p = action_walking(t)
# Amplify by lifting knees/arms further; lean torso forward.
for j in (HEAD, NECK, TORSO):
_shift(p, j, 10, 0) # lean forward
return p
def action_dancing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
# Arms raised, waving up
p[LSH] = (p[LSH][0], p[LSH][1])
p[RSH] = (p[RSH][0], p[RSH][1])
p[LEL] = (p[LSH][0] - 30 + 8 * math.sin(phase), p[LSH][1] - 20)
p[LHA] = (p[LEL][0] - 10, p[LEL][1] - 60 + 12 * math.sin(phase))
p[REL] = (p[RSH][0] + 30 + 8 * math.sin(phase + math.pi), p[RSH][1] - 20)
p[RHA] = (p[REL][0] + 10, p[REL][1] - 60 + 12 * math.sin(phase + math.pi))
# Hip sway
sway = 10 * math.sin(phase)
for j in (PELVIS, LHP, RHP, LKN, LFT, RKN, RFT):
_shift(p, j, sway, 0)
# Counter-sway upper body
for j in (HEAD, NECK, TORSO, LSH, RSH):
_shift(p, j, -sway * 0.4, 0)
# Vertical bounce
bounce = 6 * abs(math.sin(phase * 2))
for j in p:
_shift(p, j, 0, -bounce)
return p
def action_reading(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
# Bend torso slightly forward
_shift(p, HEAD, 0, 18)
_shift(p, NECK, 0, 10)
_shift(p, TORSO, 0, 5)
# Hands in front of chest, slightly apart (holding a book)
p[LEL] = (230, 230)
p[LHA] = (240, 260)
p[REL] = (282, 230)
p[RHA] = (272, 260)
# Subtle head nod
nod = 2 * math.sin(2 * math.pi * t)
_shift(p, HEAD, 0, nod)
return p
def action_waving(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
# Right arm raised, hand waving left-right
p[RSH] = (p[RSH][0], p[RSH][1])
p[REL] = (p[RSH][0] + 40, p[RSH][1] - 40)
p[RHA] = (p[REL][0] + 20 * math.sin(phase * 2), p[REL][1] - 60)
# Slight head tilt right
_shift(p, HEAD, 3, -2)
return p
def action_sitting(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
drop = 40
# Lower whole upper body
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, drop)
# Knees forward, feet forward
p[LKN] = (220, 360 + drop)
p[RKN] = (292, 360 + drop)
p[LFT] = (180, 380 + drop)
p[RFT] = (330, 380 + drop)
# Tiny sway
sway = 1.5 * math.sin(2 * math.pi * t)
for j in (HEAD, NECK, TORSO):
_shift(p, j, sway, 0)
return p
def action_jumping(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
lift = max(0.0, 40 * math.sin(phase))
# Whole body rises/falls
for j in p:
_shift(p, j, 0, -lift)
# Arms up
p[LEL] = (p[LSH][0] - 15, p[LSH][1] - 35)
p[LHA] = (p[LSH][0] - 5, p[LSH][1] - 85)
p[REL] = (p[RSH][0] + 15, p[RSH][1] - 35)
p[RHA] = (p[RSH][0] + 5, p[RSH][1] - 85)
# Knees bent when in-air
if lift > 8:
_shift(p, LKN, 0, -12); _shift(p, RKN, 0, -12)
_shift(p, LFT, 0, -18); _shift(p, RFT, 0, -18)
return p
def action_fighting(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
# Slight crouch + forward lean
for j in (HEAD, NECK, TORSO):
_shift(p, j, 6, 6)
# Alternating punches
punch_r = max(0.0, math.sin(phase))
punch_l = max(0.0, math.sin(phase + math.pi))
p[REL] = (p[RSH][0] + 20 + 10 * punch_r, p[RSH][1] + 5)
p[RHA] = (p[RSH][0] + 40 + 40 * punch_r, p[RSH][1])
p[LEL] = (p[LSH][0] - 20 - 10 * punch_l, p[LSH][1] + 5)
p[LHA] = (p[LSH][0] - 40 - 40 * punch_l, p[LSH][1])
return p
def action_thinking(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
# Right hand near chin
p[REL] = (280, 180)
p[RHA] = (260, 140)
# Head tilt
tilt = 4 * math.sin(2 * math.pi * t)
_shift(p, HEAD, tilt, 0)
return p
def action_working(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t * 2
# Lean forward
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 8)
# Hands out front, typing
p[LEL] = (230, 230)
p[REL] = (282, 230)
p[LHA] = (235, 265 + 3 * math.sin(phase))
p[RHA] = (277, 265 + 3 * math.sin(phase + math.pi))
return p
def action_eating(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t * 2
lift = 22 + 8 * math.sin(phase)
# Right hand goes to mouth and back
p[REL] = (p[HEAD][0] + 22, p[HEAD][1] + 18)
p[RHA] = (p[HEAD][0] + 6, p[HEAD][1] + 6 - lift * 0.2)
# Left hand holds something in front (bowl)
p[LEL] = (230, 240)
p[LHA] = (244, 268)
return p
def action_drinking(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
tilt = 6 * math.sin(phase)
# Head tilts back slightly while drinking
_shift(p, HEAD, 0, -2 - abs(tilt))
# Right hand to mouth holding "cup"
p[REL] = (p[HEAD][0] + 18, p[HEAD][1] + 20)
p[RHA] = (p[HEAD][0] + 4, p[HEAD][1] - 6)
return p
def action_sleeping(t: float) -> Dict[int, Tuple[float, float]]:
# Lying down: rotate the rest pose 90° around the body's midpoint.
breath = 1.2 * math.sin(2 * math.pi * t)
cx, cy = 256.0, 400.0
rotated: Dict[int, Tuple[float, float]] = {}
for j, (x, y) in REST_POSE.items():
# Rotate 90° counter-clockwise: (x,y) -> (y, -x), then translate.
dx = x - 256.0
dy = y - 275.0
nx = cx + dy * 1.0
ny = cy - dx * 0.9
rotated[j] = (nx, ny + breath)
return rotated
def action_crying(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t * 3
sob = 3 * math.sin(phase)
# Shoulders shake; body slumps
_shift(p, HEAD, sob * 0.5, 6)
_shift(p, NECK, sob * 0.3, 3)
_shift(p, LSH, -sob - 2, 4)
_shift(p, RSH, sob + 2, 4)
# Hands to face
p[LEL] = (p[HEAD][0] - 32, p[HEAD][1] + 15)
p[LHA] = (p[HEAD][0] - 12, p[HEAD][1] + 4)
p[REL] = (p[HEAD][0] + 32, p[HEAD][1] + 15)
p[RHA] = (p[HEAD][0] + 12, p[HEAD][1] + 4)
return p
def action_laughing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t * 4
shake = 4 * math.sin(phase)
# Head tilts back, body shakes
_shift(p, HEAD, shake * 0.5, -4)
_shift(p, NECK, shake * 0.3, -1)
_shift(p, TORSO, shake * 0.4, 0)
# Hand over belly / mouth
p[REL] = (p[HEAD][0] + 20, p[HEAD][1] + 28)
p[RHA] = (p[HEAD][0] + 5, p[HEAD][1] + 8)
p[LEL] = (p[TORSO][0] - 30, p[TORSO][1] + 15)
p[LHA] = (p[TORSO][0] - 10, p[TORSO][1] + 25)
return p
def action_praying(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
# Slight head bow
_shift(p, HEAD, 0, 6 + 1.5 * math.sin(2 * math.pi * t))
_shift(p, NECK, 0, 3)
# Hands pressed together in front of chest
p[LEL] = (238, 210)
p[REL] = (274, 210)
p[LHA] = (254, 190)
p[RHA] = (258, 190)
return p
def action_clapping(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t * 4
# Hands come together and apart
offset = 12 + 14 * abs(math.cos(phase))
p[LEL] = (228, 215)
p[REL] = (284, 215)
cx = 256
p[LHA] = (cx - offset, 225)
p[RHA] = (cx + offset, 225)
# Slight body bounce
bounce = 2 * abs(math.sin(phase))
for j in (HEAD, NECK, TORSO, PELVIS):
_shift(p, j, 0, -bounce)
return p
def action_writing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t * 3
# Lean forward slightly
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 8)
# Left hand holds paper steady
p[LEL] = (228, 240)
p[LHA] = (238, 272)
# Right hand makes small writing motion
wx = 268 + 8 * math.sin(phase)
wy = 272 + 4 * math.cos(phase * 2)
p[REL] = (286, 240)
p[RHA] = (wx, wy)
return p
def action_stretching(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
reach = 8 + 4 * math.sin(phase)
# Arms overhead, reaching
p[LEL] = (p[LSH][0] - 10, p[LSH][1] - 40)
p[LHA] = (p[LSH][0] - 4, p[LSH][1] - 80 - reach)
p[REL] = (p[RSH][0] + 10, p[RSH][1] - 40)
p[RHA] = (p[RSH][0] + 4, p[RSH][1] - 80 - reach)
# Body extension
_shift(p, HEAD, 0, -3)
return p
def action_vacuuming(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
# Lean forward over the vacuum
for j in (HEAD, NECK, TORSO):
_shift(p, j, 10, 6)
# Both hands grip an invisible handle in front of the body at waist level.
# Handle slides side-to-side following `sway`.
sway = 28 * math.sin(phase)
grip_x = 256 + sway
grip_y = 310
p[LEL] = (grip_x - 26, grip_y - 30)
p[REL] = (grip_x + 26, grip_y - 30)
p[LHA] = (grip_x - 10, grip_y)
p[RHA] = (grip_x + 10, grip_y)
return p
def action_sweeping(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
# Similar to vacuuming but wider arc and slightly higher grip
for j in (HEAD, NECK, TORSO):
_shift(p, j, 8, 6)
sway = 36 * math.sin(phase)
grip_x = 256 + sway
grip_y = 300
p[LEL] = (grip_x - 30, grip_y - 30)
p[REL] = (grip_x + 10, grip_y - 50)
p[LHA] = (grip_x - 10, grip_y)
p[RHA] = (grip_x + 18, grip_y - 20)
return p
def action_cooking(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t * 2
# Lean slightly forward over the stove
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 8)
# Left hand holds pot handle (steady)
p[LEL] = (228, 245)
p[LHA] = (222, 290)
# Right hand stirs in circular motion
cx, cy = 276, 290
r = 10
p[REL] = (290, 245)
p[RHA] = (cx + r * math.cos(phase), cy + r * math.sin(phase))
return p
def action_washing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t * 3
# Bend forward at sink
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 10)
# Hands close together, scrubbing motion
scrub = 6 * math.sin(phase)
p[LEL] = (236, 250)
p[REL] = (276, 250)
p[LHA] = (248 + scrub, 290)
p[RHA] = (264 - scrub, 290 + 3 * math.cos(phase))
return p
def action_gardening(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t * 2
# Bent over / crouched
for j in (HEAD, NECK, TORSO):
_shift(p, j, 10, 30)
_shift(p, PELVIS, 0, 20)
# Knees bent (squat)
p[LKN] = (228, 380)
p[RKN] = (284, 380)
p[LFT] = (222, 438)
p[RFT] = (290, 438)
# Hands down near the ground, small digging motion
dig = 4 * math.sin(phase)
p[LEL] = (240, 320)
p[REL] = (290, 320)
p[LHA] = (240, 400 + dig)
p[RHA] = (290, 400 - dig)
return p
def action_kicking(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
kick = max(0.0, math.sin(phase))
# Right leg kicks forward + up
p[RKN] = (p[RHP][0] + 30 * kick, p[RHP][1] + 60 - 30 * kick)
p[RFT] = (p[RHP][0] + 70 * kick, p[RHP][1] + 80 - 70 * kick)
# Arms balance (opposite)
p[LEL] = (p[LSH][0] - 10, p[LSH][1] + 40 - 20 * kick)
p[LHA] = (p[LSH][0] - 18, p[LSH][1] + 60 - 30 * kick)
p[REL] = (p[RSH][0] + 10, p[RSH][1] + 20 + 15 * kick)
p[RHA] = (p[RSH][0] + 25, p[RSH][1] + 10 + 25 * kick)
return p
def action_cleaning(t: float) -> Dict[int, Tuple[float, float]]:
"""Wiping a surface with spray + cloth. One hand sprays, other wipes."""
p = _copy_pose()
phase = 2 * math.pi * t * 2
wipe = 18 * math.sin(phase)
# Slight lean forward
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 6)
# Left hand holds the spray bottle (steady, raised)
p[LEL] = (232, 230)
p[LHA] = (226, 260)
# Right hand wipes side-to-side
p[REL] = (282, 230)
p[RHA] = (282 + wipe, 258)
return p
def action_dusting(t: float) -> Dict[int, Tuple[float, float]]:
"""Feather-dusting a high shelf — one arm up, sweeping side-to-side."""
p = _copy_pose()
phase = 2 * math.pi * t * 2
sweep = 16 * math.sin(phase)
# Slight upward reach
_shift(p, HEAD, 0, -2)
# Right arm raised, dusting
p[RSH] = (p[RSH][0], p[RSH][1])
p[REL] = (p[RSH][0] + 14, p[RSH][1] - 40)
p[RHA] = (p[RSH][0] + 20 + sweep, p[RSH][1] - 80)
# Left arm resting
p[LEL] = (p[LSH][0] - 15, p[LSH][1] + 30)
p[LHA] = (p[LSH][0] - 22, p[LSH][1] + 55)
return p
def action_mopping(t: float) -> Dict[int, Tuple[float, float]]:
"""Mopping the floor — similar stance to sweeping but a mop prop."""
p = _copy_pose()
phase = 2 * math.pi * t
for j in (HEAD, NECK, TORSO):
_shift(p, j, 6, 8)
sway = 32 * math.sin(phase)
grip_x = 256 + sway
grip_y = 302
p[LEL] = (grip_x - 28, grip_y - 28)
p[REL] = (grip_x + 8, grip_y - 44)
p[LHA] = (grip_x - 8, grip_y - 4)
p[RHA] = (grip_x + 14, grip_y - 18)
return p
def action_ironing(t: float) -> Dict[int, Tuple[float, float]]:
"""Moving an iron back and forth on an ironing-board surface."""
p = _copy_pose()
phase = 2 * math.pi * t * 2
slide = 20 * math.sin(phase)
# Lean forward slightly
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 6)
# Right hand holds the iron and moves
p[REL] = (266, 250)
p[RHA] = (266 + slide, 288)
# Left hand steadies the garment
p[LEL] = (244, 250)
p[LHA] = (238, 288)
return p
def action_polishing(t: float) -> Dict[int, Tuple[float, float]]:
"""Circular polishing motion on a surface with a cloth."""
p = _copy_pose()
phase = 2 * math.pi * t * 2
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 6)
# Right hand circles on a surface; left hand braces nearby
cx, cy, r = 276, 288, 14
p[REL] = (278, 250)
p[RHA] = (cx + r * math.cos(phase), cy + r * math.sin(phase))
p[LEL] = (238, 250)
p[LHA] = (244, 288)
return p
def action_football(t: float) -> Dict[int, Tuple[float, float]]:
"""Soccer: plant foot + swinging kick leg, arms for balance."""
p = _copy_pose()
phase = 2 * math.pi * t
kick = max(0.0, math.sin(phase))
# Right leg swings forward for the kick
p[RKN] = (p[RHP][0] + 20 * kick, p[RHP][1] + 60 - 20 * kick)
p[RFT] = (p[RHP][0] + 55 * kick, p[RHP][1] + 85 - 40 * kick)
# Left leg planted (slight bend)
p[LKN] = (p[LHP][0] - 6, p[LHP][1] + 65)
p[LFT] = (p[LHP][0] - 8, p[LHP][1] + 130)
# Arms out for balance
p[LEL] = (p[LSH][0] - 25, p[LSH][1] + 10)
p[LHA] = (p[LSH][0] - 45, p[LSH][1] - 5)
p[REL] = (p[RSH][0] + 25, p[RSH][1] + 20)
p[RHA] = (p[RSH][0] + 40, p[RSH][1] + 40)
# Slight lean back on kick
for j in (HEAD, NECK, TORSO):
_shift(p, j, -4 * kick, -2 * kick)
return p
def action_cricket(t: float) -> Dict[int, Tuple[float, float]]:
"""Cricket batting stance: bat held two-handed, slight body twist."""
p = _copy_pose()
phase = 2 * math.pi * t
swing = math.sin(phase)
# Body sideways-ish (shift torso slightly)
for j in (HEAD, NECK, TORSO):
_shift(p, j, -4, 2)
# Both hands together on the bat, near the right side
p[LEL] = (250, 220)
p[REL] = (275, 210)
p[LHA] = (286 + 8 * swing, 235 - 6 * swing)
p[RHA] = (296 + 8 * swing, 230 - 6 * swing)
# Knees slightly bent (stance)
p[LKN] = (p[LHP][0] - 2, p[LHP][1] + 70)
p[RKN] = (p[RHP][0] + 2, p[RHP][1] + 70)
return p
def action_basketball(t: float) -> Dict[int, Tuple[float, float]]:
"""Dribbling: one hand pushes down on a bouncing ball."""
p = _copy_pose()
phase = 2 * math.pi * t * 2
# Slight crouch, forward lean
for j in (HEAD, NECK, TORSO):
_shift(p, j, 6, 8)
# Dribbling hand (right) bounces up and down
push = abs(math.sin(phase))
p[REL] = (p[RSH][0] + 25, p[RSH][1] + 30)
p[RHA] = (p[RSH][0] + 40, p[RSH][1] + 60 + 20 * push)
# Left hand lower / guarding
p[LEL] = (p[LSH][0] - 15, p[LSH][1] + 35)
p[LHA] = (p[LSH][0] - 30, p[LSH][1] + 55)
# Knees bent (athletic stance)
p[LKN] = (p[LHP][0] - 4, p[LHP][1] + 60)
p[RKN] = (p[RHP][0] + 4, p[RHP][1] + 60)
return p
def action_tennis(t: float) -> Dict[int, Tuple[float, float]]:
"""Forehand swing: racket arm swings across the body."""
p = _copy_pose()
phase = 2 * math.pi * t
# Swing phase: arm goes from cocked-back to forward
swing = math.sin(phase) # -1 to 1
# Right arm carries the racket
p[REL] = (p[RSH][0] + 20 + 15 * swing, p[RSH][1] + 10)
p[RHA] = (p[RSH][0] + 40 + 40 * swing, p[RSH][1] - 10 - 15 * swing)
# Left arm opposes for balance
p[LEL] = (p[LSH][0] - 20 - 10 * swing, p[LSH][1] + 10)
p[LHA] = (p[LSH][0] - 35 - 20 * swing, p[LSH][1] + 25)
# Slight hip twist via a lateral shift
for j in (PELVIS, LHP, RHP, LKN, RKN, LFT, RFT):
_shift(p, j, 3 * swing, 0)
return p
def action_baseball(t: float) -> Dict[int, Tuple[float, float]]:
"""Batting: bat held over the shoulder, small wind-up motion."""
p = _copy_pose()
phase = 2 * math.pi * t
windup = math.sin(phase) * 0.4
# Body slightly coiled
for j in (HEAD, NECK, TORSO):
_shift(p, j, -3 + 4 * windup, 4)
# Both hands grip bat near right shoulder
p[LEL] = (270, 195)
p[REL] = (290, 180)
grip_x = 310
grip_y = 175
p[LHA] = (grip_x - 6, grip_y + 4)
p[RHA] = (grip_x + 4, grip_y)
# Feet apart stance
p[LFT] = (p[LHP][0] - 12, 448)
p[RFT] = (p[RHP][0] + 12, 448)
p[LKN] = (p[LHP][0] - 6, 380)
p[RKN] = (p[RHP][0] + 6, 380)
return p
def action_golf(t: float) -> Dict[int, Tuple[float, float]]:
"""Golf swing: club swings from behind to follow-through."""
p = _copy_pose()
phase = 2 * math.pi * t
swing = math.sin(phase) # -1 (backswing) to 1 (follow-through)
# Bent over the ball
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 10)
# Hands together on the club
base_x = 256 + 40 * swing
base_y = 290 - 20 * abs(swing)
p[LEL] = (base_x - 20, base_y - 40)
p[REL] = (base_x - 5, base_y - 50)
p[LHA] = (base_x - 2, base_y - 5)
p[RHA] = (base_x + 6, base_y - 2)
# Stance
p[LFT] = (p[LHP][0] - 10, 448)
p[RFT] = (p[RHP][0] + 10, 448)
return p
def action_bowling(t: float) -> Dict[int, Tuple[float, float]]:
"""Bowling release: right arm swings forward, body steps forward."""
p = _copy_pose()
phase = 2 * math.pi * t
swing = math.sin(phase)
# Right arm swings from behind (swing<0) to forward (swing>0)
p[REL] = (p[RSH][0] + 20 * swing, p[RSH][1] + 30 + 20 * abs(swing))
p[RHA] = (p[RSH][0] + 50 * swing, p[RSH][1] + 65 + 10 * swing)
# Left arm raised for balance
p[LEL] = (p[LSH][0] - 20, p[LSH][1] + 5)
p[LHA] = (p[LSH][0] - 35, p[LSH][1] - 5)
# Slight forward step
for j in (HEAD, NECK, TORSO):
_shift(p, j, 4 * swing, 2)
return p
def action_skateboarding(t: float) -> Dict[int, Tuple[float, float]]:
"""Riding a skateboard: balanced, slight crouch, one foot pushes."""
p = _copy_pose()
phase = 2 * math.pi * t
lean = 3 * math.sin(phase)
# Crouched slightly
for j in (HEAD, NECK, TORSO):
_shift(p, j, lean, 6)
# Arms out for balance
p[LEL] = (p[LSH][0] - 25, p[LSH][1] + 10 + 3 * math.sin(phase))
p[LHA] = (p[LSH][0] - 45, p[LSH][1] + 5)
p[REL] = (p[RSH][0] + 25, p[RSH][1] + 10 - 3 * math.sin(phase))
p[RHA] = (p[RSH][0] + 45, p[RSH][1] + 5)
# Feet apart (on board), slight knee bend
p[LFT] = (224, 440)
p[RFT] = (288, 440)
p[LKN] = (p[LHP][0] - 4, p[LHP][1] + 60)
p[RKN] = (p[RHP][0] + 4, p[RHP][1] + 60)
return p
# ---------------------------------------------------------------------------
# Extra actions (body/motion, water/snow, vehicles, body-care, craft, voice,
# instruments, modern life). Each is a simple pose — distinctive props drawn
# in draw_frame do most of the recognition work.
# ---------------------------------------------------------------------------
def action_bowing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
bow = 30 + 4 * math.sin(2 * math.pi * t)
_shift(p, HEAD, 0, bow)
_shift(p, NECK, 0, bow * 0.7)
_shift(p, TORSO, 0, bow * 0.4)
_shift(p, LSH, 0, bow * 0.5); _shift(p, RSH, 0, bow * 0.5)
_shift(p, LEL, 6, bow * 0.6); _shift(p, REL, -6, bow * 0.6)
_shift(p, LHA, 12, bow * 0.6); _shift(p, RHA, -12, bow * 0.6)
return p
def action_hugging(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
squeeze = 2 * math.sin(2 * math.pi * t * 2)
p[LEL] = (226, 220)
p[REL] = (286, 220)
p[LHA] = (246 - squeeze, 230)
p[RHA] = (266 + squeeze, 230)
_shift(p, HEAD, 0, 4)
return p
def action_kissing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
_shift(p, HEAD, 6, 6)
_shift(p, NECK, 3, 2)
p[REL] = (p[HEAD][0] + 18, p[HEAD][1] + 10)
p[RHA] = (p[HEAD][0] + 6, p[HEAD][1] + 2)
return p
def action_handshake(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
shake = 4 * math.sin(2 * math.pi * t * 3)
p[REL] = (p[RSH][0] + 30, p[RSH][1] + 20)
p[RHA] = (p[RSH][0] + 70, p[RSH][1] + 30 + shake)
return p
def action_pointing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
p[REL] = (p[RSH][0] + 35, p[RSH][1] + 10)
p[RHA] = (p[RSH][0] + 95, p[RSH][1] + 4)
return p
def action_climbing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
up = math.sin(phase)
# Left arm reaching up
p[LEL] = (p[LSH][0] - 10, p[LSH][1] - 30 + 10 * up)
p[LHA] = (p[LSH][0] - 6, p[LSH][1] - 80 + 10 * up)
# Right arm pulling down
p[REL] = (p[RSH][0] + 15, p[RSH][1] + 10 - 10 * up)
p[RHA] = (p[RSH][0] + 10, p[RSH][1] + 50 - 10 * up)
# Legs staggered
p[LKN] = (p[LHP][0] - 4, p[LHP][1] + 40 + 10 * up)
p[LFT] = (p[LHP][0] - 8, p[LHP][1] + 90 + 10 * up)
p[RKN] = (p[RHP][0] + 6, p[RHP][1] + 70 - 10 * up)
p[RFT] = (p[RHP][0] + 10, p[RHP][1] + 130 - 10 * up)
return p
def action_falling(t: float) -> Dict[int, Tuple[float, float]]:
# Rotate entire body ~30° clockwise around pelvis.
ang = math.radians(35 + 10 * math.sin(2 * math.pi * t))
cos, sin = math.cos(ang), math.sin(ang)
cx, cy = 256.0, 310.0
rot: Dict[int, Tuple[float, float]] = {}
for j, (x, y) in REST_POSE.items():
dx, dy = x - cx, y - cy
rot[j] = (cx + dx * cos - dy * sin, cy + dx * sin + dy * cos)
# Flail arms outward
rot[LEL] = (rot[LSH][0] - 40, rot[LSH][1] + 10)
rot[LHA] = (rot[LSH][0] - 70, rot[LSH][1] - 20)
rot[REL] = (rot[RSH][0] + 40, rot[RSH][1] + 10)
rot[RHA] = (rot[RSH][0] + 70, rot[RSH][1] - 20)
return rot
def action_crawling(t: float) -> Dict[int, Tuple[float, float]]:
phase = 2 * math.pi * t * 2
# Horizontal body: rotate 90°, then override arms/legs with crawl cycle.
ang = math.radians(90)
cos, sin = math.cos(ang), math.sin(ang)
cx, cy = 256.0, 380.0
p: Dict[int, Tuple[float, float]] = {}
for j, (x, y) in REST_POSE.items():
dx, dy = x - 256.0, y - 275.0
p[j] = (cx + dx * cos - dy * sin, cy + dx * sin + dy * cos)
# Alternate arm/leg reach
reach = 18 * math.sin(phase)
_shift(p, LHA, reach, 0); _shift(p, RHA, -reach, 0)
_shift(p, LFT, -reach, 0); _shift(p, RFT, reach, 0)
return p
def action_rolling(t: float) -> Dict[int, Tuple[float, float]]:
ang = 2 * math.pi * t
cos, sin = math.cos(ang), math.sin(ang)
cx, cy = 256.0, 340.0
r: Dict[int, Tuple[float, float]] = {}
for j, (x, y) in REST_POSE.items():
dx, dy = x - 256.0, y - 275.0
r[j] = (cx + dx * cos - dy * sin, cy + dx * sin + dy * cos)
return r
def action_juggling(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
up = 2 * math.sin(2 * math.pi * t * 4)
p[LEL] = (238, 250); p[LHA] = (234, 280 + up)
p[REL] = (278, 250); p[RHA] = (282, 280 - up)
return p
def action_swimming(t: float) -> Dict[int, Tuple[float, float]]:
# Horizontal body, arm windmill.
ang = math.radians(90)
cos, sin = math.cos(ang), math.sin(ang)
cx, cy = 256.0, 350.0
p: Dict[int, Tuple[float, float]] = {}
for j, (x, y) in REST_POSE.items():
dx, dy = x - 256.0, y - 275.0
p[j] = (cx + dx * cos - dy * sin, cy + dx * sin + dy * cos)
phase = 2 * math.pi * t
# Override arm tips to rotate
for arm_sh, arm_el, arm_ha, off in ((LSH, LEL, LHA, 0.0),
(RSH, REL, RHA, math.pi)):
sx, sy = p[arm_sh]
p[arm_el] = (sx + 20 * math.cos(phase + off),
sy + 20 * math.sin(phase + off))
p[arm_ha] = (sx + 40 * math.cos(phase + off),
sy + 40 * math.sin(phase + off))
return p
def action_diving(t: float) -> Dict[int, Tuple[float, float]]:
# Vertical head-down plunge.
ang = math.radians(180)
cos, sin = math.cos(ang), math.sin(ang)
cx, cy = 256.0, 275.0
p: Dict[int, Tuple[float, float]] = {}
for j, (x, y) in REST_POSE.items():
dx, dy = x - 256.0, y - 275.0
p[j] = (cx + dx * cos - dy * sin, cy + dx * sin + dy * cos)
# Arms together overhead (which is now below)
p[LEL] = (248, p[LSH][1] + 40)
p[REL] = (264, p[RSH][1] + 40)
p[LHA] = (252, p[LSH][1] + 80)
p[RHA] = (260, p[RSH][1] + 80)
# Slight upward bob
bob = 6 * math.sin(2 * math.pi * t)
for j in p:
_shift(p, j, 0, -bob)
return p
def action_surfing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
lean = 4 * math.sin(phase)
for j in (HEAD, NECK, TORSO):
_shift(p, j, lean, 8)
# Arms out wide
p[LEL] = (p[LSH][0] - 30, p[LSH][1] + 5)
p[LHA] = (p[LSH][0] - 55, p[LSH][1] + 10)
p[REL] = (p[RSH][0] + 30, p[RSH][1] + 5)
p[RHA] = (p[RSH][0] + 55, p[RSH][1] + 10)
# Feet slightly apart & staggered
p[LFT] = (224, 440); p[RFT] = (292, 440)
return p
def action_skating(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
glide = 15 * math.sin(phase)
# Arms swinging opposite
p[LHA] = (p[LHA][0] + glide, p[LHA][1])
p[RHA] = (p[RHA][0] - glide, p[RHA][1])
p[LEL] = ((p[LSH][0] + p[LHA][0]) / 2, (p[LSH][1] + p[LHA][1]) / 2)
p[REL] = ((p[RSH][0] + p[RHA][0]) / 2, (p[RSH][1] + p[RHA][1]) / 2)
# One foot out (gliding)
p[RFT] = (p[RFT][0] + 20, p[RFT][1])
return p
def action_skiing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
for j in (HEAD, NECK, TORSO):
_shift(p, j, 4, 10)
# Crouch
p[LKN] = (p[LHP][0] - 4, p[LHP][1] + 50)
p[RKN] = (p[RHP][0] + 4, p[RHP][1] + 50)
p[LFT] = (p[LHP][0] - 10, p[LHP][1] + 110)
p[RFT] = (p[RHP][0] + 10, p[RHP][1] + 110)
# Arms back, holding poles
p[LEL] = (p[LSH][0] - 20, p[LSH][1] + 30)
p[REL] = (p[RSH][0] + 20, p[RSH][1] + 30)
p[LHA] = (p[LSH][0] - 28, p[LSH][1] + 55)
p[RHA] = (p[RSH][0] + 28, p[RSH][1] + 55)
return p
def action_biking(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
# Crouched, hands on handlebars
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 20)
p[LEL] = (244, 270); p[REL] = (268, 270)
p[LHA] = (244, 300); p[RHA] = (268, 300)
# Pedaling legs
cx_l, cy_l = p[LHP][0] - 4, p[LHP][1] + 80
cx_r, cy_r = p[RHP][0] + 4, p[RHP][1] + 80
r = 30
p[LKN] = (cx_l + r * math.cos(phase), cy_l + r * math.sin(phase))
p[RKN] = (cx_r + r * math.cos(phase + math.pi),
cy_r + r * math.sin(phase + math.pi))
p[LFT] = (cx_l + 40 * math.cos(phase), cy_l + 40 * math.sin(phase))
p[RFT] = (cx_r + 40 * math.cos(phase + math.pi),
cy_r + 40 * math.sin(phase + math.pi))
return p
def action_driving(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
drop = 30
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, drop)
# Hands on wheel
p[LEL] = (236, 250 + drop); p[REL] = (276, 250 + drop)
p[LHA] = (232, 290 + drop); p[RHA] = (280, 290 + drop)
# Legs forward (seated)
p[LKN] = (224, 380 + drop); p[RKN] = (288, 380 + drop)
p[LFT] = (190, 410 + drop); p[RFT] = (322, 410 + drop)
return p
def action_riding_horse(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
bounce = 4 * abs(math.sin(2 * math.pi * t * 2))
drop = 20
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, drop - bounce)
# Hands holding reins
p[LEL] = (236, 250 + drop); p[REL] = (276, 250 + drop)
p[LHA] = (240, 285 + drop); p[RHA] = (272, 285 + drop)
# Legs astride (bent outward)
p[LKN] = (208, 380 + drop); p[RKN] = (304, 380 + drop)
p[LFT] = (196, 440 + drop); p[RFT] = (316, 440 + drop)
return p
def action_rowing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
pull = 14 * math.sin(phase)
drop = 20
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, drop)
p[LEL] = (232 + pull, 240 + drop); p[REL] = (280 - pull, 240 + drop)
p[LHA] = (220 + pull * 2, 280 + drop)
p[RHA] = (292 - pull * 2, 280 + drop)
# Legs forward
p[LKN] = (220, 370 + drop); p[RKN] = (292, 370 + drop)
p[LFT] = (200, 420 + drop); p[RFT] = (312, 420 + drop)
return p
def action_sneezing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
jerk = 6 * math.sin(2 * math.pi * t * 4)
_shift(p, HEAD, 0, 6 + jerk)
_shift(p, NECK, 0, 3)
p[REL] = (p[HEAD][0] + 20, p[HEAD][1] + 14)
p[RHA] = (p[HEAD][0] + 6, p[HEAD][1] + 2)
return p
def action_coughing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
hunch = 4 * abs(math.sin(2 * math.pi * t * 3))
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 4 + hunch)
p[REL] = (p[HEAD][0] + 18, p[HEAD][1] + 16)
p[RHA] = (p[HEAD][0] + 4, p[HEAD][1] + 6)
return p
def action_yawning(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
_shift(p, HEAD, 0, -6)
p[REL] = (p[HEAD][0] + 22, p[HEAD][1] + 18)
p[RHA] = (p[HEAD][0] + 4, p[HEAD][1] - 2)
# Stretched arms
p[LEL] = (p[LSH][0] - 15, p[LSH][1] - 20)
p[LHA] = (p[LSH][0] - 25, p[LSH][1] - 60)
return p
def action_shivering(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
tx = 2 * math.sin(2 * math.pi * t * 10)
ty = 1.5 * math.cos(2 * math.pi * t * 10)
for j in p:
_shift(p, j, tx, ty)
# Arms hug chest
p[LEL] = (p[LSH][0] - 10, p[LSH][1] + 30)
p[LHA] = (265, p[LSH][1] + 55)
p[REL] = (p[RSH][0] + 10, p[RSH][1] + 30)
p[RHA] = (247, p[RSH][1] + 55)
return p
def action_scratching(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
itch = 2 * math.sin(2 * math.pi * t * 5)
p[REL] = (p[HEAD][0] + 14, p[HEAD][1] - 8)
p[RHA] = (p[HEAD][0] + 6 + itch, p[HEAD][1] - 18)
return p
def action_shaving(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
stroke = 4 * math.sin(2 * math.pi * t * 3)
p[REL] = (p[HEAD][0] + 24, p[HEAD][1] + 10)
p[RHA] = (p[HEAD][0] + 18, p[HEAD][1] + stroke)
_shift(p, HEAD, 0, 2)
return p
def action_brushing_teeth(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
brush = 6 * math.sin(2 * math.pi * t * 4)
p[REL] = (p[HEAD][0] + 24, p[HEAD][1] + 18)
p[RHA] = (p[HEAD][0] + 10 + brush, p[HEAD][1] + 8)
return p
def action_combing_hair(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
stroke = 12 * math.sin(2 * math.pi * t)
p[REL] = (p[HEAD][0] + 18, p[HEAD][1] - 16)
p[RHA] = (p[HEAD][0] + 6, p[HEAD][1] - 36 + stroke)
return p
def action_dressing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
pull = 8 * math.sin(2 * math.pi * t)
p[LEL] = (p[LSH][0] - 10, p[LSH][1] - 25)
p[LHA] = (p[HEAD][0] - 10, p[HEAD][1] - 10 - pull)
p[REL] = (p[RSH][0] + 10, p[RSH][1] - 25)
p[RHA] = (p[HEAD][0] + 10, p[HEAD][1] - 10 - pull)
return p
def action_carrying(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
sway = 1.5 * math.sin(2 * math.pi * t)
for j in (HEAD, NECK, TORSO):
_shift(p, j, sway, 4)
p[LEL] = (p[LSH][0] - 5, p[LSH][1] + 40)
p[REL] = (p[RSH][0] + 5, p[RSH][1] + 40)
p[LHA] = (226, p[LSH][1] + 70)
p[RHA] = (286, p[RSH][1] + 70)
return p
def action_lifting(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
lift = math.sin(phase)
# Arms overhead
p[LEL] = (p[LSH][0] - 10, p[LSH][1] - 30)
p[LHA] = (p[LSH][0] - 18, p[LSH][1] - 80)
p[REL] = (p[RSH][0] + 10, p[RSH][1] - 30)
p[RHA] = (p[RSH][0] + 18, p[RSH][1] - 80)
# Crouch depth varies
p[LKN] = (p[LHP][0] - 6, p[LHP][1] + 40 + 10 * lift)
p[RKN] = (p[RHP][0] + 6, p[RHP][1] + 40 + 10 * lift)
p[LFT] = (p[LHP][0] - 12, 448)
p[RFT] = (p[RHP][0] + 12, 448)
return p
def action_pushing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
for j in (HEAD, NECK, TORSO):
_shift(p, j, 10, 4)
# Both arms extended forward
p[LEL] = (p[LSH][0] + 20, p[LSH][1] + 5)
p[REL] = (p[RSH][0] + 20, p[RSH][1] + 5)
p[LHA] = (p[LSH][0] + 50, p[LSH][1] + 10)
p[RHA] = (p[RSH][0] + 50, p[RSH][1] + 10)
# Feet staggered
p[RFT] = (p[RHP][0] + 10, 444)
return p
def action_pulling(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
for j in (HEAD, NECK, TORSO):
_shift(p, j, -8, 0)
# Both arms pulled back
p[LEL] = (p[LSH][0] - 20, p[LSH][1] + 20)
p[REL] = (p[RSH][0] - 10, p[RSH][1] + 20)
p[LHA] = (p[LSH][0] + 10, p[LSH][1] + 45)
p[RHA] = (p[RSH][0] + 14, p[RSH][1] + 45)
return p
def action_throwing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
phase = 2 * math.pi * t
fwd = math.sin(phase)
p[REL] = (p[RSH][0] + 10 + 20 * fwd, p[RSH][1] + 5 - 10 * fwd)
p[RHA] = (p[RSH][0] + 30 + 50 * fwd, p[RSH][1] - 5 - 25 * fwd)
p[LEL] = (p[LSH][0] - 25, p[LSH][1] + 20)
p[LHA] = (p[LSH][0] - 40, p[LSH][1] + 30)
return p
def action_catching(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
rise = 6 * math.sin(2 * math.pi * t)
p[LEL] = (p[LSH][0] - 15, p[LSH][1] - 20 + rise)
p[LHA] = (p[LSH][0] - 10, p[LSH][1] - 55 + rise)
p[REL] = (p[RSH][0] + 15, p[RSH][1] - 20 + rise)
p[RHA] = (p[RSH][0] + 10, p[RSH][1] - 55 + rise)
return p
def action_fishing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
drop = 30
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, drop)
# Seated legs
p[LKN] = (224, 380 + drop); p[RKN] = (288, 380 + drop)
p[LFT] = (194, 410 + drop); p[RFT] = (318, 410 + drop)
# Hold rod in right hand extended forward
p[REL] = (p[RSH][0] + 20, p[RSH][1] + 10 + drop)
p[RHA] = (p[RSH][0] + 60, p[RSH][1] + drop)
p[LEL] = (p[LSH][0] - 5, p[LSH][1] + 35 + drop)
p[LHA] = (p[LSH][0] + 15, p[LSH][1] + 60 + drop)
return p
def action_shooting(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
for j in (HEAD, NECK, TORSO):
_shift(p, j, 6, 6)
p[REL] = (p[RSH][0] + 25, p[RSH][1] + 10)
p[RHA] = (p[RSH][0] + 60, p[RSH][1] + 10)
p[LEL] = (p[LSH][0] + 5, p[LSH][1] + 20)
p[LHA] = (p[LSH][0] + 40, p[LSH][1] + 20)
return p
def action_archery(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
pull = 4 * math.sin(2 * math.pi * t)
p[LEL] = (p[LSH][0] - 22, p[LSH][1] + 5)
p[LHA] = (p[LSH][0] - 58, p[LSH][1] + 5)
p[REL] = (p[RSH][0] - 10 + pull, p[RSH][1] + 15)
p[RHA] = (p[HEAD][0] - 14 + pull, p[HEAD][1] + 20)
return p
def action_painting(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
stroke = 8 * math.sin(2 * math.pi * t * 2)
# Left hand holds palette
p[LEL] = (p[LSH][0] - 20, p[LSH][1] + 25)
p[LHA] = (p[LSH][0] - 30, p[LSH][1] + 55)
# Right brushes on easel
p[REL] = (p[RSH][0] + 20, p[RSH][1] + 5)
p[RHA] = (p[RSH][0] + 50 + stroke * 0.2, p[RSH][1] + 0 + stroke)
return p
def action_drawing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
for j in (HEAD, NECK, TORSO):
_shift(p, j, 0, 6)
tiny = 3 * math.sin(2 * math.pi * t * 4)
p[LEL] = (230, 240); p[LHA] = (228, 270)
p[REL] = (278, 250)
p[RHA] = (270 + tiny, 280 + tiny * 0.5)
return p
def action_sculpting(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
press = 3 * math.sin(2 * math.pi * t * 3)
p[LEL] = (234, 230); p[REL] = (278, 230)
p[LHA] = (248 + press, 270); p[RHA] = (264 - press, 270)
return p
def action_photographing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
p[LEL] = (238, 200); p[REL] = (274, 200)
p[LHA] = (242, p[HEAD][1] + 14)
p[RHA] = (270, p[HEAD][1] + 14)
return p
def action_singing(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
_shift(p, HEAD, 0, -6)
p[REL] = (p[HEAD][0] + 20, p[HEAD][1] + 14)
p[RHA] = (p[HEAD][0] + 6, p[HEAD][1] + 4)
p[LEL] = (p[LSH][0] - 22, p[LSH][1] - 10)
p[LHA] = (p[LSH][0] - 35, p[LSH][1] - 30)
return p
def action_whistling(t: float) -> Dict[int, Tuple[float, float]]:
p = action_standing_idle(t)
_shift(p, HEAD, 0, -2)
return p
def action_yelling(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
_shift(p, HEAD, 0, 2)
# Cupped hands around mouth
p[LEL] = (p[HEAD][0] - 22, p[HEAD][1] + 8)
p[REL] = (p[HEAD][0] + 22, p[HEAD][1] + 8)
p[LHA] = (p[HEAD][0] - 10, p[HEAD][1] + 16)
p[RHA] = (p[HEAD][0] + 10, p[HEAD][1] + 16)
return p
def action_teaching(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
p[REL] = (p[RSH][0] + 25, p[RSH][1] - 5)
p[RHA] = (p[RSH][0] + 70, p[RSH][1] - 30)
p[LEL] = (p[LSH][0] - 20, p[LSH][1] + 20)
p[LHA] = (p[LSH][0] - 30, p[LSH][1] + 45)
return p
def action_presenting(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
swing = 4 * math.sin(2 * math.pi * t)
p[REL] = (p[RSH][0] + 30, p[RSH][1] + 10 + swing)
p[RHA] = (p[RSH][0] + 80, p[RSH][1] + 25 + swing)
return p
def action_playing_guitar(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
drop = 20
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, drop)
strum = 10 * math.sin(2 * math.pi * t * 3)
# Right hand strums low; left hand on neck
p[LEL] = (218, 250 + drop); p[LHA] = (200, 290 + drop)
p[REL] = (278, 270 + drop)
p[RHA] = (286, 300 + drop + strum)
return p
def action_playing_piano(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
drop = 25
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, drop)
press = 3 * math.sin(2 * math.pi * t * 4)
p[LEL] = (228, 250 + drop); p[REL] = (282, 250 + drop)
p[LHA] = (220, 290 + drop + press)
p[RHA] = (288, 290 + drop - press)
return p
def action_playing_drums(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
drop = 25
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, drop)
phase = 2 * math.pi * t * 4
l = 8 * math.sin(phase)
r = 8 * math.sin(phase + math.pi)
p[LEL] = (230, 240 + drop)
p[REL] = (282, 240 + drop)
p[LHA] = (218, 285 + drop + l)
p[RHA] = (294, 285 + drop + r)
return p
def action_shopping(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
for j in (HEAD, NECK, TORSO):
_shift(p, j, 8, 4)
p[LEL] = (246, 260); p[REL] = (272, 260)
p[LHA] = (252, 300); p[RHA] = (268, 300)
return p
def action_texting(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
_shift(p, HEAD, 0, 10)
_shift(p, NECK, 0, 4)
tap = 1.5 * math.sin(2 * math.pi * t * 6)
p[LEL] = (238, 240); p[REL] = (274, 240)
p[LHA] = (248, 268 + tap)
p[RHA] = (264, 268 - tap)
return p
def action_meditating(t: float) -> Dict[int, Tuple[float, float]]:
p = _copy_pose()
drop = 35
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH):
_shift(p, j, 0, drop)
# Cross-legged
p[LHP] = (232, 330); p[RHP] = (280, 330)
p[LKN] = (190, 370); p[RKN] = (322, 370)
p[LFT] = (250, 380); p[RFT] = (262, 380)
# Hands on knees
p[LEL] = (218, 280 + drop); p[REL] = (294, 280 + drop)
p[LHA] = (200, 345); p[RHA] = (312, 345)
return p
def action_gaming(t: float) -> Dict[int, Tuple[float, float]]:
"""Video gaming: seated, controller in both hands, leaning toward screen."""
p = _copy_pose()
phase = 2 * math.pi * t * 3
# Seated lower
drop = 30
for j in (HEAD, NECK, TORSO, PELVIS, LSH, RSH, LHP, RHP):
_shift(p, j, 0, drop)
# Head forward toward screen
_shift(p, HEAD, 0, 6)
# Hands together in front of body (controller)
p[LEL] = (238, 270 + drop)
p[REL] = (274, 270 + drop)
p[LHA] = (248 + math.sin(phase), 300 + drop)
p[RHA] = (264 - math.sin(phase), 300 + drop)
# Knees forward (sitting on couch)
p[LKN] = (220, 380 + drop)
p[RKN] = (292, 380 + drop)
p[LFT] = (180, 400 + drop)
p[RFT] = (330, 400 + drop)
return p
ACTIONS: Dict[str, Callable[[float], Dict[int, Tuple[float, float]]]] = {
"standing_idle": action_standing_idle,
"walking": action_walking,
"running": action_running,
"dancing": action_dancing,
"reading": action_reading,
"waving": action_waving,
"sitting": action_sitting,
"jumping": action_jumping,
"fighting": action_fighting,
"thinking": action_thinking,
"working": action_working,
"eating": action_eating,
"drinking": action_drinking,
"sleeping": action_sleeping,
"crying": action_crying,
"laughing": action_laughing,
"praying": action_praying,
"clapping": action_clapping,
"writing": action_writing,
"stretching": action_stretching,
"kicking": action_kicking,
"vacuuming": action_vacuuming,
"sweeping": action_sweeping,
"cooking": action_cooking,
"washing": action_washing,
"gardening": action_gardening,
"cleaning": action_cleaning,
"dusting": action_dusting,
"mopping": action_mopping,
"ironing": action_ironing,
"polishing": action_polishing,
"football": action_football,
"cricket": action_cricket,
"basketball": action_basketball,
"tennis": action_tennis,
"baseball": action_baseball,
"golf": action_golf,
"bowling": action_bowling,
"skateboarding": action_skateboarding,
"gaming": action_gaming,
# Body / motion
"bowing": action_bowing,
"hugging": action_hugging,
"kissing": action_kissing,
"handshake": action_handshake,
"pointing": action_pointing,
"climbing": action_climbing,
"falling": action_falling,
"crawling": action_crawling,
"rolling": action_rolling,
"juggling": action_juggling,
# Water / snow / board
"swimming": action_swimming,
"diving": action_diving,
"surfing": action_surfing,
"skating": action_skating,
"skiing": action_skiing,
"biking": action_biking,
# Vehicle
"driving": action_driving,
"riding_horse": action_riding_horse,
"rowing": action_rowing,
# Involuntary / body care
"sneezing": action_sneezing,
"coughing": action_coughing,
"yawning": action_yawning,
"shivering": action_shivering,
"scratching": action_scratching,
"shaving": action_shaving,
"brushing_teeth": action_brushing_teeth,
"combing_hair": action_combing_hair,
"dressing": action_dressing,
# Load / force
"carrying": action_carrying,
"lifting": action_lifting,
"pushing": action_pushing,
"pulling": action_pulling,
"throwing": action_throwing,
"catching": action_catching,
# Precision / craft
"fishing": action_fishing,
"shooting": action_shooting,
"archery": action_archery,
"painting": action_painting,
"drawing": action_drawing,
"sculpting": action_sculpting,
"photographing": action_photographing,
# Voice / teach
"singing": action_singing,
"whistling": action_whistling,
"yelling": action_yelling,
"teaching": action_teaching,
"presenting": action_presenting,
# Instruments
"playing_guitar": action_playing_guitar,
"playing_piano": action_playing_piano,
"playing_drums": action_playing_drums,
# Modern life
"shopping": action_shopping,
"texting": action_texting,
"meditating": action_meditating,
}
# ---------------------------------------------------------------------------
# Emotion post-transform
# ---------------------------------------------------------------------------
def apply_emotion(
joints: Dict[int, Tuple[float, float]],
emotion: str,
t: float,
) -> Dict[int, Tuple[float, float]]:
p = {k: v for k, v in joints.items()}
if emotion == "sad":
_shift(p, HEAD, 0, 8)
_shift(p, NECK, 0, 4)
_shift(p, LSH, -4, 8); _shift(p, RSH, 4, 8)
_shift(p, LEL, -2, 10); _shift(p, REL, 2, 10)
_shift(p, LHA, -2, 12); _shift(p, RHA, 2, 12)
elif emotion == "happy":
_shift(p, HEAD, 0, -3)
bounce = 3 * abs(math.sin(2 * math.pi * t * 2))
for j in p:
_shift(p, j, 0, -bounce)
elif emotion == "angry":
for j in (HEAD, NECK, TORSO):
_shift(p, j, 5, 2)
_shift(p, LSH, -2, -2); _shift(p, RSH, 2, -2)
elif emotion == "tired":
for j in p: # dampen — pull halfway toward rest pose
rx, ry = REST_POSE[j]
x, y = p[j]
p[j] = (rx + (x - rx) * 0.5, ry + (y - ry) * 0.5)
_shift(p, HEAD, 0, 10)
_shift(p, NECK, 0, 4)
elif emotion == "excited":
bounce = 6 * abs(math.sin(2 * math.pi * t * 3))
for j in p:
_shift(p, j, 0, -bounce)
elif emotion == "scared":
# Trembling + slight backward lean, arms pulled inward/up
tremble_x = 2.2 * math.sin(2 * math.pi * t * 8)
tremble_y = 1.4 * math.cos(2 * math.pi * t * 8)
for j in p:
_shift(p, j, tremble_x, tremble_y)
_shift(p, HEAD, 0, -2)
_shift(p, LSH, 2, -3); _shift(p, RSH, -2, -3)
_shift(p, LEL, 4, -6); _shift(p, REL, -4, -6)
_shift(p, LHA, 8, -10); _shift(p, RHA, -8, -10)
elif emotion == "surprised":
# Slight backward lean + arms raised outward
for j in (HEAD, NECK, TORSO):
_shift(p, j, -3, -2)
_shift(p, LEL, -6, -8); _shift(p, REL, 6, -8)
_shift(p, LHA, -12, -20); _shift(p, RHA, 12, -20)
elif emotion == "bored":
# Saggy: head down, shoulders drooping, minimal motion
for j in p: # dampen
rx, ry = REST_POSE[j]
x, y = p[j]
p[j] = (rx + (x - rx) * 0.6, ry + (y - ry) * 0.6)
_shift(p, HEAD, 4, 10)
_shift(p, NECK, 2, 5)
_shift(p, LSH, -3, 5); _shift(p, RSH, 3, 5)
elif emotion == "confused":
# Head tilt (sideways), one shoulder raised
tilt = 6
_shift(p, HEAD, tilt, 2)
_shift(p, NECK, tilt * 0.4, 1)
_shift(p, RSH, 0, -4)
# Hand scratching head
_shift(p, REL, 0, -20); _shift(p, RHA, -20, -30)
# neutral: identity
return p
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def _draw_floor_line(draw: ImageDraw.ImageDraw, color: Tuple[int, int, int]) -> None:
draw.line((0, FLOOR_Y, CANVAS, FLOOR_Y), fill=color, width=2)
def _draw_scene_bedroom(draw: ImageDraw.ImageDraw) -> None:
wall = (30, 24, 52)
floor = (68, 45, 32)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Floorboards
for y in range(FLOOR_Y + 12, CANVAS, 16):
draw.line((0, y, CANVAS, y), fill=(50, 30, 22), width=1)
# Window (back-left) with curtains
draw.rectangle((60, 80, 180, 210), outline=(180, 190, 210), width=3, fill=(30, 45, 90))
draw.line((120, 80, 120, 210), fill=(180, 190, 210), width=2)
draw.line((60, 145, 180, 145), fill=(180, 190, 210), width=2)
# Curtains
draw.polygon([(36, 70), (60, 80), (60, 220), (40, 230)], fill=(130, 50, 60))
draw.polygon([(200, 70), (180, 80), (180, 220), (204, 230)], fill=(130, 50, 60))
# Moon through window
draw.ellipse((135, 100, 170, 135), fill=(240, 230, 200))
# --- Bed (right side) ---
bed_x1, bed_y1, bed_x2, bed_y2 = 300, 400, 504, 458
# Headboard (tall, behind the pillow)
draw.rectangle((bed_x2 - 18, 330, bed_x2 + 6, bed_y1 + 10),
fill=(90, 55, 30), outline=(160, 110, 70), width=2)
# Decorative headboard curve
draw.arc((bed_x2 - 22, 318, bed_x2 + 10, 350),
start=180, end=360, fill=(200, 150, 100), width=3)
# Footboard
draw.rectangle((bed_x1 - 4, 395, bed_x1 + 10, bed_y2 + 8),
fill=(90, 55, 30), outline=(160, 110, 70), width=2)
# Bed frame
draw.rectangle((bed_x1, bed_y1 + 14, bed_x2, bed_y2 + 8),
fill=(80, 50, 28), outline=(160, 110, 70), width=2)
# Mattress layer (visible as a stripe under the blanket)
draw.rectangle((bed_x1 + 4, bed_y1 + 6, bed_x2 - 4, bed_y1 + 16),
fill=(230, 225, 210), outline=(180, 170, 150), width=1)
# Blanket (covering most of the mattress)
draw.rectangle((bed_x1, bed_y1, bed_x2 - 4, bed_y1 + 28),
fill=(120, 50, 80), outline=(180, 100, 130), width=2)
# Blanket top edge (folded-over cuff)
draw.rectangle((bed_x1 + 78, bed_y1 - 4, bed_x2 - 4, bed_y1 + 8),
fill=(200, 200, 215), outline=(140, 140, 160), width=1)
# Pillows (two, stacked at headboard end)
draw.rectangle((bed_x2 - 82, bed_y1 - 12, bed_x2 - 20, bed_y1 + 10),
fill=(245, 245, 250), outline=(150, 150, 165), width=2)
draw.rectangle((bed_x2 - 82, bed_y1 - 14, bed_x2 - 20, bed_y1 - 6),
fill=(220, 220, 230), outline=(150, 150, 165), width=1)
# --- Nightstand ---
ns_x1, ns_x2 = 240, 290
draw.rectangle((ns_x1, 408, ns_x2, 462),
fill=(72, 48, 30), outline=(150, 110, 70), width=2)
# Drawer
draw.line((ns_x1, 432, ns_x2, 432), fill=(150, 110, 70), width=2)
draw.ellipse(((ns_x1 + ns_x2) // 2 - 3, 418,
(ns_x1 + ns_x2) // 2 + 3, 424), fill=(200, 170, 100))
# --- Lamp on the nightstand ---
lamp_cx = (ns_x1 + ns_x2) // 2
lamp_base_y = 408
# Base
draw.rectangle((lamp_cx - 14, lamp_base_y - 8, lamp_cx + 14, lamp_base_y),
fill=(180, 150, 90), outline=(100, 80, 40), width=1)
# Stem
draw.line((lamp_cx, lamp_base_y - 8, lamp_cx, lamp_base_y - 40),
fill=(200, 180, 120), width=3)
# Shade (trapezoid)
draw.polygon([(lamp_cx - 22, lamp_base_y - 40),
(lamp_cx + 22, lamp_base_y - 40),
(lamp_cx + 30, lamp_base_y - 76),
(lamp_cx - 30, lamp_base_y - 76)],
fill=(240, 210, 140), outline=(180, 140, 60))
# Warm glow under the shade
draw.ellipse((lamp_cx - 42, lamp_base_y - 46,
lamp_cx + 42, lamp_base_y - 18),
fill=None, outline=(250, 220, 140), width=1)
# --- Rug at the foot of the bed ---
draw.rectangle((160, 475, 360, 505), fill=(100, 60, 60),
outline=(200, 160, 110), width=2)
# Tassels (short vertical ticks) at each short edge
for tx in (160, 358):
for dx in (-4, -1, 2, 5):
draw.line((tx + dx, 505, tx + dx, 512),
fill=(220, 180, 120), width=1)
# Inner decoration
for y in (484, 496):
draw.line((175, y, 345, y), fill=(200, 160, 110), width=1)
def _draw_scene_market(draw: ImageDraw.ImageDraw) -> None:
sky = (60, 80, 110)
ground = (55, 45, 30)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=sky)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=ground)
# Stall posts
for x in (60, 180, 340, 460):
draw.line((x, 200, x, FLOOR_Y), fill=(120, 80, 50), width=4)
# Striped awnings
stripes = [(180, 60, 60), (230, 220, 200)]
for i, (x1, x2) in enumerate(((40, 200), (320, 480))):
for k in range(8):
c = stripes[k % 2]
sx1 = x1 + (x2 - x1) * k // 8
sx2 = x1 + (x2 - x1) * (k + 1) // 8
draw.polygon([(sx1, 180), (sx2, 180), (sx2, 210), (sx1, 210)], fill=c)
# Stall table + fruit circles
draw.rectangle((40, 360, 200, 410), fill=(90, 60, 40), outline=(150, 110, 70), width=2)
for cx, cy, col in ((70, 355, (220, 70, 70)), (95, 355, (240, 160, 50)),
(120, 355, (230, 200, 60)), (150, 355, (160, 50, 60)),
(175, 355, (220, 120, 60))):
draw.ellipse((cx - 8, cy - 8, cx + 8, cy + 8), fill=col)
draw.rectangle((320, 360, 480, 410), fill=(90, 60, 40), outline=(150, 110, 70), width=2)
def _draw_scene_office(draw: ImageDraw.ImageDraw) -> None:
wall = (40, 48, 60)
floor = (30, 30, 36)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Desk
draw.rectangle((60, 380, 450, 410), fill=(60, 40, 25), outline=(140, 100, 60), width=2)
draw.line((80, 410, 80, FLOOR_Y), fill=(140, 100, 60), width=3)
draw.line((430, 410, 430, FLOOR_Y), fill=(140, 100, 60), width=3)
# Monitor
draw.rectangle((340, 300, 430, 370), outline=(180, 180, 180), width=3, fill=(20, 30, 50))
draw.rectangle((370, 370, 400, 380), fill=(100, 100, 100))
# Wall clock
draw.ellipse((60, 60, 120, 120), outline=(200, 200, 200), width=3, fill=(25, 25, 30))
draw.line((90, 90, 90, 70), fill=(200, 200, 200), width=2)
draw.line((90, 90, 108, 90), fill=(200, 200, 200), width=2)
def _draw_scene_park(draw: ImageDraw.ImageDraw) -> None:
sky = (60, 100, 140)
grass = (40, 90, 50)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=sky)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=grass)
# Sun
draw.ellipse((400, 60, 470, 130), fill=(240, 220, 120))
# Trees
for tx in (70, 440):
draw.rectangle((tx - 8, 320, tx + 8, FLOOR_Y), fill=(70, 45, 25))
draw.ellipse((tx - 50, 250, tx + 50, 350), fill=(35, 110, 55))
# Bench
draw.rectangle((200, 420, 330, 430), fill=(110, 70, 40))
draw.rectangle((200, 430, 210, FLOOR_Y), fill=(110, 70, 40))
draw.rectangle((320, 430, 330, FLOOR_Y), fill=(110, 70, 40))
def _draw_scene_library(draw: ImageDraw.ImageDraw) -> None:
wall = (50, 40, 30)
floor = (30, 24, 18)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Bookshelves (left and right)
for shelf_x in (0, 410):
draw.rectangle((shelf_x, 60, shelf_x + 100, FLOOR_Y),
outline=(120, 80, 40), width=3, fill=(70, 48, 28))
for row_y in range(90, FLOOR_Y - 20, 60):
# books
bx = shelf_x + 6
while bx < shelf_x + 94:
bw = 7 + (bx * 13 % 9)
bh = 40 + (bx * 7 % 12)
colors = [(150, 50, 60), (60, 100, 140), (90, 140, 70),
(180, 140, 60), (120, 60, 130)]
col = colors[(bx // 10) % len(colors)]
draw.rectangle((bx, row_y - bh, bx + bw, row_y), fill=col, outline=(20, 20, 20))
bx += bw + 2
draw.line((shelf_x + 2, row_y + 4, shelf_x + 98, row_y + 4),
fill=(120, 80, 40), width=2)
def _draw_scene_kitchen(draw: ImageDraw.ImageDraw) -> None:
wall = (210, 195, 160)
backsplash = (180, 200, 210)
floor = (85, 70, 55)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Floor tile pattern
for x in range(0, CANVAS, 40):
draw.line((x, FLOOR_Y, x, CANVAS), fill=(60, 50, 40), width=1)
for y in range(FLOOR_Y + 18, CANVAS, 18):
draw.line((0, y, CANVAS, y), fill=(60, 50, 40), width=1)
# Backsplash strip between upper cabinets and counter
draw.rectangle((20, 260, 490, 360), fill=backsplash)
for x in range(20, 490, 24):
draw.line((x, 260, x, 360), fill=(150, 170, 180), width=1)
for y in (282, 305, 328, 352):
draw.line((20, y, 490, y), fill=(150, 170, 180), width=1)
# Upper cabinets with doors + handles
draw.rectangle((20, 80, 390, 260),
outline=(120, 85, 55), width=3, fill=(165, 120, 75))
for x in (145, 270):
draw.line((x, 80, x, 260), fill=(120, 85, 55), width=2)
for hx in (125, 250, 375):
draw.ellipse((hx - 3, 165, hx + 3, 175), fill=(220, 210, 180))
# Window above the counter (right side)
draw.rectangle((410, 100, 490, 240),
outline=(120, 85, 55), width=3, fill=(130, 180, 210))
draw.line((450, 100, 450, 240), fill=(120, 85, 55), width=2)
draw.line((410, 170, 490, 170), fill=(120, 85, 55), width=2)
# Little potted plant on window sill
draw.rectangle((432, 240, 468, 258), fill=(140, 80, 50))
draw.ellipse((430, 220, 470, 248), fill=(70, 130, 60))
# Counter (thicker, with edge shadow)
draw.rectangle((20, 360, 490, 388),
fill=(235, 230, 215), outline=(160, 150, 130), width=2)
draw.line((20, 385, 490, 385), fill=(170, 160, 140), width=1)
# Lower cabinets with doors + handles
draw.rectangle((20, 388, 490, FLOOR_Y),
outline=(120, 85, 55), width=3, fill=(140, 100, 65))
for x in (145, 270, 395):
draw.line((x, 388, x, FLOOR_Y), fill=(120, 85, 55), width=2)
for hx in (85, 210, 335, 445):
draw.line((hx - 6, 410, hx + 6, 410), fill=(220, 210, 180), width=3)
# Stove on the counter
draw.rectangle((290, 330, 400, 380),
outline=(90, 90, 95), width=2, fill=(45, 45, 50))
# Control knobs
for kx in (300, 320, 370, 390):
draw.ellipse((kx - 3, 335, kx + 3, 341), fill=(200, 200, 210))
# Burners
for bx, by in ((317, 360), (373, 360)):
draw.ellipse((bx - 11, by - 9, bx + 11, by + 9),
outline=(180, 180, 190), width=2, fill=(25, 25, 30))
draw.ellipse((bx - 6, by - 5, bx + 6, by + 5),
outline=(120, 120, 130), width=1)
# Pot on the left burner with steam
draw.rectangle((304, 310, 332, 332),
fill=(70, 70, 80), outline=(180, 180, 190), width=2)
draw.line((300, 308, 336, 308), fill=(200, 200, 210), width=3)
for sx in (308, 318, 328):
draw.line((sx, 300, sx + 3, 290), fill=(220, 220, 230), width=2)
draw.line((sx + 3, 290, sx, 280), fill=(220, 220, 230), width=2)
# Fridge on the left
draw.rectangle((20, 200, 120, FLOOR_Y),
outline=(120, 130, 140), width=3, fill=(225, 230, 235))
draw.line((20, 290, 120, 290), fill=(120, 130, 140), width=2)
# Fridge handles
draw.line((110, 220, 110, 270), fill=(150, 160, 170), width=3)
draw.line((110, 310, 110, 380), fill=(150, 160, 170), width=3)
# Cutting board + knife on the right counter
draw.rectangle((150, 348, 240, 378),
fill=(180, 140, 80), outline=(120, 80, 40), width=1)
draw.line((170, 362, 220, 362), fill=(220, 220, 220), width=2)
draw.line((220, 362, 224, 360), fill=(60, 50, 40), width=3)
def _draw_scene_street(draw: ImageDraw.ImageDraw) -> None:
sky = (40, 45, 70)
road = (35, 35, 40)
sidewalk = (90, 90, 95)
draw.rectangle((0, 0, CANVAS, 400), fill=sky)
draw.rectangle((0, 400, CANVAS, FLOOR_Y), fill=sidewalk)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=road)
# Road dashes
for x in range(20, CANVAS, 60):
draw.rectangle((x, 485, x + 30, 492), fill=(220, 210, 140))
# Buildings in background
for bx, bw, bh in ((10, 110, 280), (130, 90, 220), (230, 130, 300),
(370, 100, 250), (480, 60, 200)):
by = 400 - bh
draw.rectangle((bx, by, bx + bw, 400),
fill=(55, 55, 75), outline=(90, 90, 110), width=2)
# Windows
for wy in range(by + 20, 390, 30):
for wx in range(bx + 10, bx + bw - 10, 25):
lit = (250, 220, 120) if (wx + wy) % 3 == 0 else (30, 35, 55)
draw.rectangle((wx, wy, wx + 12, wy + 16), fill=lit)
# Lamp post
draw.line((70, FLOOR_Y, 70, 270), fill=(180, 180, 180), width=3)
draw.ellipse((55, 250, 85, 280), fill=(250, 220, 120), outline=(180, 180, 180), width=2)
def _draw_scene_gym(draw: ImageDraw.ImageDraw) -> None:
wall = (50, 55, 70)
floor = (60, 45, 35)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Mirror (back wall)
draw.rectangle((80, 80, 440, 280), outline=(180, 200, 210), width=4, fill=(70, 85, 100))
# Dumbbells on floor
for cx in (80, 420):
draw.rectangle((cx - 30, 440, cx + 30, 455), fill=(50, 50, 55))
draw.ellipse((cx - 45, 430, cx - 20, 465), fill=(30, 30, 35), outline=(150, 150, 160), width=2)
draw.ellipse((cx + 20, 430, cx + 45, 465), fill=(30, 30, 35), outline=(150, 150, 160), width=2)
# Weight rack
draw.rectangle((200, 330, 310, FLOOR_Y), outline=(150, 150, 160), width=3, fill=(40, 40, 50))
for y in (360, 395, 430):
draw.line((200, y, 310, y), fill=(150, 150, 160), width=2)
def _draw_scene_living_room(draw: ImageDraw.ImageDraw) -> None:
wall = (68, 50, 62)
floor = (120, 85, 55)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Floorboards
for y in range(FLOOR_Y + 14, CANVAS, 18):
draw.line((0, y, CANVAS, y), fill=(85, 60, 40), width=1)
# Wainscoting / baseboard line
draw.line((0, FLOOR_Y - 2, CANVAS, FLOOR_Y - 2), fill=(180, 150, 110), width=2)
# Framed picture on the wall
draw.rectangle((70, 120, 190, 230),
outline=(200, 165, 110), width=4, fill=(160, 130, 90))
draw.polygon([(80, 215), (110, 165), (150, 205), (185, 155), (185, 215)],
fill=(60, 110, 70))
# Sun in picture
draw.ellipse((160, 130, 180, 150), fill=(240, 210, 120))
# TV mounted on the wall
draw.rectangle((330, 150, 490, 270),
outline=(40, 40, 50), width=6, fill=(15, 20, 35))
# TV screen glow
draw.rectangle((340, 160, 480, 260), fill=(30, 50, 90))
draw.polygon([(360, 200), (420, 180), (460, 220), (380, 250)],
fill=(80, 120, 180))
# TV stand
draw.rectangle((340, 280, 480, 310),
fill=(80, 55, 35), outline=(150, 100, 60), width=2)
# --- Sofa (back-left) ---
sofa_x1, sofa_y1, sofa_x2, sofa_y2 = 40, 340, 270, FLOOR_Y - 4
# Back
draw.rectangle((sofa_x1, sofa_y1 - 30, sofa_x2, sofa_y1 + 10),
fill=(100, 55, 90), outline=(160, 100, 140), width=2)
# Arms
draw.rectangle((sofa_x1, sofa_y1 - 10, sofa_x1 + 22, sofa_y2),
fill=(110, 65, 100), outline=(160, 100, 140), width=2)
draw.rectangle((sofa_x2 - 22, sofa_y1 - 10, sofa_x2, sofa_y2),
fill=(110, 65, 100), outline=(160, 100, 140), width=2)
# Seat cushions
draw.rectangle((sofa_x1 + 22, sofa_y1 + 10, sofa_x2 - 22, sofa_y2),
fill=(90, 50, 80), outline=(160, 100, 140), width=2)
for x in (118, 196):
draw.line((x, sofa_y1 + 10, x, sofa_y2), fill=(160, 100, 140), width=2)
# Throw pillows
draw.rectangle((60, 342, 102, 376),
fill=(220, 180, 100), outline=(150, 110, 50), width=2)
draw.rectangle((208, 342, 252, 376),
fill=(180, 70, 70), outline=(120, 40, 40), width=2)
# Sofa feet
for fx in (sofa_x1 + 6, sofa_x2 - 10):
draw.rectangle((fx, sofa_y2, fx + 6, sofa_y2 + 8),
fill=(60, 40, 25))
# Coffee table
draw.rectangle((190, 440, 330, 458),
fill=(110, 75, 45), outline=(170, 120, 70), width=2)
for lx in (196, 322):
draw.line((lx, 458, lx, FLOOR_Y - 6),
fill=(90, 60, 35), width=4)
# Mug on the coffee table
draw.rectangle((220, 430, 240, 442),
fill=(230, 230, 230), outline=(160, 160, 160), width=1)
draw.line((240, 432, 244, 440), fill=(160, 160, 160), width=2)
# --- Rug / carpet under the skeleton ---
rug_x1, rug_y1, rug_x2, rug_y2 = 90, 470, 430, 505
# Outer border
draw.rectangle((rug_x1, rug_y1, rug_x2, rug_y2),
fill=(150, 75, 55), outline=(230, 180, 110), width=3)
# Inner border
draw.rectangle((rug_x1 + 10, rug_y1 + 5, rug_x2 - 10, rug_y2 - 5),
outline=(230, 180, 110), width=2)
# Diamond pattern centers
cxr = (rug_x1 + rug_x2) // 2
cyr = (rug_y1 + rug_y2) // 2
for dx in (-120, -60, 0, 60, 120):
x = cxr + dx
draw.polygon([(x, cyr - 8), (x + 10, cyr), (x, cyr + 8), (x - 10, cyr)],
outline=(230, 180, 110), width=1, fill=(180, 100, 70))
# Tassels (fringe) on each short edge
for tx in range(rug_x1 - 2, rug_x1 + 18, 4):
draw.line((tx, rug_y1 + 4, tx, rug_y1 - 8),
fill=(230, 190, 120), width=1)
for tx in range(rug_x2 - 16, rug_x2 + 4, 4):
draw.line((tx, rug_y2 - 4, tx, rug_y2 + 8),
fill=(230, 190, 120), width=1)
# --- Floor lamp (left corner) ---
lamp_cx = 30
draw.ellipse((lamp_cx - 14, FLOOR_Y - 10, lamp_cx + 14, FLOOR_Y + 4),
fill=(90, 65, 40), outline=(150, 110, 70), width=2)
draw.line((lamp_cx, FLOOR_Y - 8, lamp_cx, 270),
fill=(180, 160, 110), width=3)
draw.polygon([(lamp_cx - 28, 270), (lamp_cx + 28, 270),
(lamp_cx + 22, 230), (lamp_cx - 22, 230)],
fill=(240, 210, 140), outline=(180, 140, 60))
# Warm halo
for r, col in ((48, (255, 230, 150)), (60, (255, 220, 130))):
draw.ellipse((lamp_cx - r, 270 - r // 2, lamp_cx + r, 270 + r // 2),
outline=col, width=1)
def _draw_scene_beach(draw: ImageDraw.ImageDraw) -> None:
sky = (90, 140, 180)
sea = (35, 90, 120)
sand = (200, 170, 110)
draw.rectangle((0, 0, CANVAS, 330), fill=sky)
draw.rectangle((0, 330, CANVAS, FLOOR_Y), fill=sea)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=sand)
# Sun
draw.ellipse((380, 60, 450, 130), fill=(250, 220, 120))
# Gentle wave lines
for y, w in ((360, 180), (395, 260), (430, 340)):
draw.line((CANVAS // 2 - w // 2, y, CANVAS // 2 + w // 2, y),
fill=(180, 210, 225), width=2)
# Palm tree (left)
draw.line((70, FLOOR_Y, 65, 280), fill=(90, 60, 30), width=6)
for dx, dy in ((-40, -10), (-30, -40), (10, -40), (40, -10), (0, 30)):
draw.polygon([(65, 280), (65 + dx, 280 + dy - 30),
(65 + dx + 6, 280 + dy - 20)], fill=(40, 110, 55))
# Beach umbrella (right)
draw.line((440, FLOOR_Y, 440, 360), fill=(80, 80, 80), width=3)
draw.pieslice((380, 320, 500, 400), start=180, end=360,
fill=(200, 70, 70), outline=(140, 40, 40), width=2)
def _draw_scene_forest(draw: ImageDraw.ImageDraw) -> None:
sky = (60, 80, 60)
ground = (40, 55, 30)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=sky)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=ground)
# Scatter of trees at different depths/sizes
trees = [(30, 400, 60, 170), (110, 380, 50, 190), (210, 380, 50, 180),
(330, 390, 55, 170), (430, 380, 60, 180),
(70, 300, 40, 130), (250, 310, 42, 120), (400, 300, 40, 130)]
for tx, trunk_top, canopy_r, trunk_h in trees:
trunk_x1 = tx - 6
trunk_x2 = tx + 6
draw.rectangle((trunk_x1, trunk_top, trunk_x2, trunk_top + trunk_h),
fill=(70, 45, 25))
draw.ellipse((tx - canopy_r, trunk_top - canopy_r,
tx + canopy_r, trunk_top + canopy_r),
fill=(30, 90, 45), outline=(20, 60, 30), width=2)
# Ground tufts
for x in range(0, CANVAS, 40):
draw.line((x, FLOOR_Y + 4, x + 8, FLOOR_Y - 2), fill=(60, 90, 40), width=2)
def _draw_scene_restaurant(draw: ImageDraw.ImageDraw) -> None:
wall = (55, 35, 45)
floor = (35, 22, 28)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Wine rack / wall decor
draw.rectangle((60, 100, 230, 220), outline=(140, 100, 60), width=3, fill=(70, 45, 30))
for y in (130, 170, 210):
draw.line((60, y, 230, y), fill=(140, 100, 60), width=2)
for x in (90, 120, 150, 180, 210):
draw.line((x, 100, x, 220), fill=(140, 100, 60), width=1)
# Hanging lamp
draw.line((256, 0, 256, 140), fill=(140, 140, 140), width=2)
draw.pieslice((220, 130, 292, 180), start=0, end=180,
fill=(230, 200, 90), outline=(180, 160, 60), width=2)
# Table + tablecloth
draw.rectangle((130, 400, 380, 420), fill=(230, 230, 230))
draw.polygon([(130, 420), (380, 420), (360, FLOOR_Y), (150, FLOOR_Y)],
fill=(210, 210, 220))
# Wine glass
draw.polygon([(240, 360), (270, 360), (265, 395), (245, 395)], fill=(140, 30, 40))
draw.line((255, 395, 255, 410), fill=(200, 200, 200), width=2)
draw.line((240, 410, 270, 410), fill=(200, 200, 200), width=2)
def _draw_scene_school(draw: ImageDraw.ImageDraw) -> None:
wall = (200, 180, 130)
floor = (110, 80, 50)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Blackboard
draw.rectangle((60, 80, 450, 260), fill=(35, 60, 50), outline=(90, 60, 30), width=4)
# Chalk text lines
for y in range(110, 250, 22):
w = 30 + ((y * 7) % 120)
draw.line((90, y, 90 + w, y), fill=(220, 220, 220), width=2)
# "Equation"
draw.line((310, 200, 340, 200), fill=(250, 230, 180), width=3)
draw.line((345, 185, 345, 215), fill=(250, 230, 180), width=3)
draw.line((355, 200, 385, 200), fill=(250, 230, 180), width=3)
draw.line((395, 200, 410, 200), fill=(250, 230, 180), width=3)
# Desk
draw.rectangle((180, 410, 340, 430), fill=(120, 80, 50))
draw.line((190, 430, 190, FLOOR_Y), fill=(120, 80, 50), width=4)
draw.line((330, 430, 330, FLOOR_Y), fill=(120, 80, 50), width=4)
def _draw_scene_hospital(draw: ImageDraw.ImageDraw) -> None:
wall = (210, 220, 225)
floor = (180, 185, 190)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Red cross on wall
draw.rectangle((50, 80, 130, 180), outline=(160, 160, 160), width=3, fill=(240, 240, 240))
draw.rectangle((82, 90, 98, 170), fill=(200, 40, 40))
draw.rectangle((58, 122, 122, 138), fill=(200, 40, 40))
# Hospital bed
draw.rectangle((280, 380, 490, 440), fill=(230, 230, 230), outline=(150, 150, 150), width=2)
draw.rectangle((280, 360, 300, 440), fill=(180, 180, 185)) # headboard
draw.rectangle((485, 370, 500, 440), fill=(180, 180, 185)) # footboard
# Pillow
draw.rectangle((300, 370, 360, 395), fill=(255, 255, 255), outline=(180, 180, 180), width=1)
# IV stand
draw.line((250, FLOOR_Y, 250, 200), fill=(180, 180, 180), width=3)
draw.rectangle((238, 200, 262, 240), outline=(160, 160, 160), width=2, fill=(220, 240, 240))
def _draw_scene_bathroom(draw: ImageDraw.ImageDraw) -> None:
wall = (200, 215, 220)
floor = (220, 215, 205)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Tile lines on wall
for y in range(80, FLOOR_Y, 40):
draw.line((0, y, CANVAS, y), fill=(170, 185, 190), width=1)
for x in range(0, CANVAS, 40):
draw.line((x, 0, x, FLOOR_Y), fill=(170, 185, 190), width=1)
# Mirror
draw.rectangle((160, 100, 350, 240), outline=(140, 140, 140), width=4, fill=(150, 170, 180))
# Sink
draw.rectangle((160, 370, 350, 400), fill=(245, 245, 245), outline=(160, 160, 160), width=2)
draw.ellipse((210, 378, 300, 398), fill=(220, 225, 230), outline=(160, 160, 160), width=2)
# Faucet
draw.line((255, 370, 255, 355), fill=(190, 190, 200), width=3)
draw.line((255, 355, 270, 355), fill=(190, 190, 200), width=3)
def _draw_scene_church(draw: ImageDraw.ImageDraw) -> None:
wall = (40, 35, 55)
floor = (60, 45, 30)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Arched stained-glass windows
for cx in (120, 256, 392):
draw.pieslice((cx - 50, 60, cx + 50, 260), start=180, end=360,
fill=(100, 70, 130), outline=(200, 180, 90), width=3)
draw.rectangle((cx - 50, 160, cx + 50, 260), fill=(100, 70, 130),
outline=(200, 180, 90), width=3)
# Cross panes
draw.line((cx, 60, cx, 260), fill=(200, 180, 90), width=2)
draw.line((cx - 50, 160, cx + 50, 160), fill=(200, 180, 90), width=2)
# Altar cross
draw.rectangle((248, 340, 264, 440), fill=(200, 180, 90))
draw.rectangle((220, 370, 292, 386), fill=(200, 180, 90))
def _draw_scene_space(draw: ImageDraw.ImageDraw) -> None:
draw.rectangle((0, 0, CANVAS, CANVAS), fill=(4, 4, 16))
# Stars
star_points = [(37, 52), (91, 120), (163, 40), (220, 95), (278, 60),
(340, 110), (410, 45), (470, 100), (60, 220), (130, 270),
(250, 310), (345, 240), (450, 280), (25, 350), (185, 400),
(400, 380), (75, 450), (300, 450), (490, 460)]
for x, y in star_points:
draw.ellipse((x - 1, y - 1, x + 2, y + 2), fill=(250, 250, 240))
# Twinkle crosses
for x, y in ((140, 150), (390, 180), (210, 370)):
draw.line((x - 6, y, x + 6, y), fill=(220, 220, 230), width=1)
draw.line((x, y - 6, x, y + 6), fill=(220, 220, 230), width=1)
# Planet (bottom-left)
draw.ellipse((20, 420, 160, 560), fill=(120, 70, 40), outline=(180, 100, 60), width=2)
# Moon crescent (top right)
draw.ellipse((390, 80, 460, 150), fill=(220, 220, 230))
draw.ellipse((405, 80, 470, 150), fill=(4, 4, 16))
# Distant nebula glow
for r, col in ((90, (60, 30, 80)), (60, (90, 40, 110)), (30, (140, 80, 180))):
draw.ellipse((280 - r, 260 - r, 280 + r, 260 + r), outline=col, width=1)
def _draw_scene_rooftop(draw: ImageDraw.ImageDraw) -> None:
sky = (30, 35, 60)
rooftop = (65, 60, 55)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=sky)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=rooftop)
# City skyline silhouettes in distance
skyline = [(0, 340, 80, 150), (80, 320, 60, 170), (140, 300, 100, 190),
(240, 330, 70, 160), (310, 310, 90, 180), (400, 320, 60, 170),
(460, 340, 80, 150)]
for bx, by, bw, bh in skyline:
by2 = 400
draw.rectangle((bx, by, bx + bw, by2), fill=(45, 50, 85),
outline=(70, 80, 120), width=1)
for wy in range(by + 20, by2, 18):
for wx in range(bx + 8, bx + bw - 8, 14):
if (wx + wy) % 3 == 0:
draw.rectangle((wx, wy, wx + 6, wy + 8), fill=(240, 220, 130))
# Railing on rooftop
draw.line((0, 410, CANVAS, 410), fill=(160, 160, 160), width=3)
for x in range(10, CANVAS, 40):
draw.line((x, 410, x, FLOOR_Y), fill=(160, 160, 160), width=2)
# Stars
for x, y in ((60, 60), (200, 40), (380, 70), (470, 120)):
draw.ellipse((x - 1, y - 1, x + 2, y + 2), fill=(230, 230, 240))
def _draw_scene_farm(draw: ImageDraw.ImageDraw) -> None:
sky = (120, 170, 200)
grass = (70, 120, 50)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=sky)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=grass)
# Sun
draw.ellipse((60, 80, 130, 150), fill=(250, 220, 130))
# Distant hills
draw.pieslice((-60, 340, 240, 500), start=180, end=360, fill=(60, 100, 50))
draw.pieslice((280, 360, 560, 490), start=180, end=360, fill=(80, 120, 55))
# Red barn
bx1, by1, bx2, by2 = 300, 320, 470, FLOOR_Y
draw.rectangle((bx1, by1 + 30, bx2, by2), fill=(150, 50, 40),
outline=(90, 30, 20), width=2)
draw.polygon([(bx1, by1 + 30), (bx2, by1 + 30), ((bx1 + bx2) / 2, by1)],
fill=(120, 40, 30), outline=(90, 30, 20))
# Barn door
draw.rectangle((360, 400, 410, FLOOR_Y), fill=(70, 40, 20),
outline=(20, 20, 20), width=2)
# X pattern on door
draw.line((360, 400, 410, FLOOR_Y), fill=(180, 140, 80), width=2)
draw.line((410, 400, 360, FLOOR_Y), fill=(180, 140, 80), width=2)
# Fence on the left
draw.line((20, 420, 250, 420), fill=(180, 160, 120), width=3)
draw.line((20, 440, 250, 440), fill=(180, 160, 120), width=3)
for x in range(30, 250, 30):
draw.line((x, 410, x, FLOOR_Y), fill=(180, 160, 120), width=3)
def _draw_scene_soccer_field(draw: ImageDraw.ImageDraw) -> None:
sky = (110, 160, 210)
grass = (50, 130, 60)
draw.rectangle((0, 0, CANVAS, 280), fill=sky)
draw.rectangle((0, 280, CANVAS, CANVAS), fill=grass)
# Distant stadium stands (curved silhouette)
for bx, bw, bh in ((0, 120, 60), (120, 100, 70), (220, 140, 80),
(360, 110, 70), (470, 50, 60)):
by = 280 - bh
draw.rectangle((bx, by, bx + bw, 280),
fill=(60, 70, 90), outline=(40, 45, 60), width=1)
# Crowd dots
for cx, cy in [(40, 240), (80, 245), (140, 235), (180, 240), (230, 220),
(280, 230), (330, 235), (380, 225), (430, 240), (480, 235)]:
draw.ellipse((cx - 3, cy - 3, cx + 3, cy + 3), fill=(200, 200, 220))
# Grass stripes
for i, y in enumerate(range(280, CANVAS, 30)):
shade = (45, 125, 55) if i % 2 else (55, 140, 65)
draw.rectangle((0, y, CANVAS, y + 30), fill=shade)
# Field lines
draw.line((0, 340, CANVAS, 340), fill=(240, 240, 240), width=2)
draw.ellipse((180, 320, 332, 360),
outline=(240, 240, 240), width=2)
# Left goal (back of field)
draw.rectangle((30, 310, 130, 352),
outline=(240, 240, 240), width=3)
draw.line((30, 310, 130, 310), fill=(240, 240, 240), width=3)
# Goal net pattern
for x in range(40, 130, 14):
draw.line((x, 310, x, 352), fill=(200, 200, 200), width=1)
for y in range(320, 352, 8):
draw.line((30, y, 130, y), fill=(200, 200, 200), width=1)
# Penalty arc
draw.arc((5, 285, 170, 400), start=-60, end=60,
fill=(240, 240, 240), width=2)
def _draw_scene_cricket_ground(draw: ImageDraw.ImageDraw) -> None:
sky = (120, 170, 210)
grass = (60, 140, 65)
pitch_color = (190, 165, 115)
draw.rectangle((0, 0, CANVAS, 300), fill=sky)
draw.rectangle((0, 300, CANVAS, CANVAS), fill=grass)
# Oval boundary rope
draw.ellipse((-60, 340, CANVAS + 60, 540),
outline=(255, 255, 255), width=3)
# Pitch (brown rectangle down the middle)
pitch_pts = [(200, 370), (312, 370), (350, FLOOR_Y + 20),
(162, FLOOR_Y + 20)]
draw.polygon(pitch_pts, fill=pitch_color, outline=(150, 120, 80))
# Crease lines
for py in (390, 440):
draw.line((200 + (py - 370) * 0.3, py,
312 - (py - 370) * 0.3, py),
fill=(255, 255, 255), width=2)
# Wickets at top and bottom of pitch
for wx, wy in ((256, 376), (256, 460)):
for dx in (-6, 0, 6):
draw.line((wx + dx, wy - 12, wx + dx, wy + 6),
fill=(200, 180, 120), width=2)
draw.line((wx - 8, wy - 14, wx + 8, wy - 14),
fill=(200, 180, 120), width=2)
# Distant stadium
draw.rectangle((0, 250, CANVAS, 300),
fill=(55, 65, 85), outline=(30, 35, 50), width=2)
for x in range(10, CANVAS, 16):
draw.rectangle((x, 260, x + 8, 290), fill=(80, 90, 110))
def _draw_scene_basketball_court(draw: ImageDraw.ImageDraw) -> None:
wall = (50, 55, 70)
floor = (190, 135, 75)
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
# Wooden floor planks
for y in range(FLOOR_Y + 8, CANVAS, 14):
draw.line((0, y, CANVAS, y), fill=(150, 100, 55), width=1)
# Key (painted free-throw lane) on the floor
draw.polygon([(180, FLOOR_Y + 4), (332, FLOOR_Y + 4),
(370, CANVAS - 2), (142, CANVAS - 2)],
outline=(230, 230, 230), width=3, fill=None)
# Free-throw circle
draw.arc((195, FLOOR_Y - 4, 317, FLOOR_Y + 38),
start=0, end=180, fill=(230, 230, 230), width=3)
# Hoop + backboard at the back of the court
draw.rectangle((196, 180, 316, 260),
fill=(240, 240, 250), outline=(80, 80, 100), width=4)
# Backboard "square"
draw.rectangle((232, 210, 280, 244),
outline=(200, 40, 40), width=3)
# Rim
draw.ellipse((226, 256, 286, 276),
outline=(220, 80, 40), width=4)
# Net (triangle of lines hanging)
for dx in range(-24, 25, 6):
draw.line((256 + dx * 0.6, 266, 256 + dx * 0.3, 302),
fill=(220, 220, 220), width=1)
# Scoreboard in the upper corner
draw.rectangle((40, 70, 190, 130),
fill=(20, 25, 40), outline=(180, 180, 200), width=2)
draw.line((115, 78, 115, 122), fill=(200, 200, 220), width=1)
for tx, text_x in ((70, 90), (140, 160)):
for bit_y in (90, 100, 110):
draw.line((tx, bit_y, tx + 14, bit_y),
fill=(220, 60, 60), width=2)
def _draw_scene_tennis_court(draw: ImageDraw.ImageDraw) -> None:
sky = (130, 190, 230)
court = (150, 85, 65) # clay
out = (75, 110, 55)
draw.rectangle((0, 0, CANVAS, 300), fill=sky)
draw.rectangle((0, 300, CANVAS, CANVAS), fill=out)
# Court area (trapezoid due to perspective)
draw.polygon([(90, 330), (422, 330), (500, CANVAS), (12, CANVAS)],
fill=court, outline=(240, 240, 240))
# Baselines
draw.line((12, CANVAS - 2, 500, CANVAS - 2), fill=(240, 240, 240), width=2)
draw.line((90, 330, 422, 330), fill=(240, 240, 240), width=2)
# Centerline
draw.line((256, 330, 256, CANVAS),
fill=(240, 240, 240), width=2)
# Service line
draw.line((130, 390, 382, 390), fill=(240, 240, 240), width=2)
# Net across the far baseline
draw.line((80, 330, 432, 330), fill=(255, 255, 255), width=4)
for x in range(90, 430, 8):
draw.line((x, 315, x, 335), fill=(220, 220, 220), width=1)
# Net posts
draw.line((80, 300, 80, 340), fill=(200, 200, 200), width=3)
draw.line((432, 300, 432, 340), fill=(200, 200, 200), width=3)
# Umpire chair (right)
draw.line((470, 280, 470, 330), fill=(160, 140, 100), width=3)
draw.rectangle((460, 260, 484, 282),
fill=(110, 80, 50), outline=(160, 120, 70), width=2)
def _draw_scene_baseball_field(draw: ImageDraw.ImageDraw) -> None:
sky = (120, 170, 220)
outfield = (60, 140, 60)
infield = (170, 120, 70)
draw.rectangle((0, 0, CANVAS, 280), fill=sky)
draw.rectangle((0, 280, CANVAS, CANVAS), fill=outfield)
# Diamond (infield) — tilted square from batter POV
draw.polygon([(256, 310), (460, 420), (256, CANVAS - 4), (52, 420)],
fill=infield, outline=(240, 240, 240))
# Bases (white squares)
for bx, by in ((256, 310), (460, 420), (256, CANVAS - 4), (52, 420)):
draw.rectangle((bx - 10, by - 10, bx + 10, by + 10),
fill=(255, 255, 255), outline=(200, 200, 200))
# Pitcher's mound
draw.ellipse((236, 390, 276, 420), fill=(200, 155, 100),
outline=(140, 100, 60))
# Outfield fence + wall ads
draw.line((0, 280, CANVAS, 280), fill=(200, 200, 200), width=3)
for x in range(20, CANVAS, 50):
draw.rectangle((x, 255, x + 40, 278),
fill=(180, 50, 50), outline=(90, 20, 20))
# Bleachers in distance
draw.rectangle((0, 200, CANVAS, 250), fill=(60, 70, 90))
for y in (208, 220, 232, 244):
draw.line((0, y, CANVAS, y), fill=(100, 110, 130), width=1)
def _draw_scene_golf_course(draw: ImageDraw.ImageDraw) -> None:
sky = (160, 200, 230)
fairway = (110, 180, 80)
rough = (70, 130, 60)
sand = (220, 200, 140)
draw.rectangle((0, 0, CANVAS, 270), fill=sky)
draw.rectangle((0, 270, CANVAS, CANVAS), fill=rough)
# Distant rolling hills
draw.pieslice((-80, 220, 260, 360), start=180, end=360,
fill=(80, 150, 70))
draw.pieslice((260, 230, 600, 360), start=180, end=360,
fill=(70, 140, 65))
# Sun
draw.ellipse((400, 80, 460, 140), fill=(250, 230, 140))
# Fairway (winding shape)
draw.polygon([(140, 300), (372, 300), (470, CANVAS), (42, CANVAS)],
fill=fairway)
# Bunker / sand trap
draw.ellipse((90, 400, 210, 450), fill=sand, outline=(180, 150, 90))
# Flagstick + hole
flag_x, flag_y = 380, 350
draw.ellipse((flag_x - 8, flag_y - 3, flag_x + 8, flag_y + 5),
fill=(40, 40, 50))
draw.line((flag_x, flag_y, flag_x, flag_y - 80),
fill=(220, 220, 220), width=3)
draw.polygon([(flag_x, flag_y - 80), (flag_x + 35, flag_y - 74),
(flag_x, flag_y - 60)], fill=(220, 60, 60))
# Small ball on the tee near front
draw.ellipse((250, FLOOR_Y - 4, 262, FLOOR_Y + 8), fill=(255, 255, 255))
draw.line((256, FLOOR_Y + 10, 256, FLOOR_Y + 18),
fill=(200, 170, 100), width=2)
def _draw_scene_bowling_alley(draw: ImageDraw.ImageDraw) -> None:
back = (30, 25, 40)
lane = (200, 150, 90)
gutter = (50, 45, 60)
draw.rectangle((0, 0, CANVAS, CANVAS), fill=back)
# Lane (trapezoid receding to a vanishing point)
draw.polygon([(120, FLOOR_Y), (392, FLOOR_Y),
(310, 200), (202, 200)],
fill=lane, outline=(120, 85, 45), width=2)
# Gutters on either side of the lane
draw.polygon([(80, FLOOR_Y), (120, FLOOR_Y),
(202, 200), (190, 200)], fill=gutter)
draw.polygon([(432, FLOOR_Y), (392, FLOOR_Y),
(310, 200), (322, 200)], fill=gutter)
# Lane arrows (aiming dots)
for y, sc in ((340, 1.0), (300, 0.8), (260, 0.6)):
cx = 256
w = 80 * sc
draw.polygon([(cx, y - 6 * sc), (cx + w / 2, y + 4 * sc),
(cx, y + 10 * sc), (cx - w / 2, y + 4 * sc)],
outline=(240, 220, 160), width=1)
# Pin deck at the back
draw.rectangle((190, 185, 322, 215),
fill=(130, 100, 60), outline=(80, 60, 30), width=2)
# 10 pins (triangular formation) at the back
rows = [(1, 256), (2, 248), (3, 240), (4, 232)]
for row_i, top_y in enumerate(reversed([200, 195, 190, 185])):
n = row_i + 1
spacing = 12
start_x = 256 - spacing * (n - 1) / 2
for i in range(n):
px = start_x + i * spacing
py = top_y
# Pin body
draw.ellipse((px - 4, py - 12, px + 4, py + 4),
fill=(250, 250, 250), outline=(180, 180, 180))
# Red collar
draw.line((px - 3, py - 7, px + 3, py - 7),
fill=(200, 40, 40), width=2)
# Ball return on the right
draw.rectangle((430, FLOOR_Y - 40, 495, FLOOR_Y),
outline=(100, 100, 120), width=3, fill=(60, 60, 80))
draw.ellipse((445, FLOOR_Y - 32, 483, FLOOR_Y - 4),
fill=(140, 40, 140), outline=(90, 20, 90), width=2)
draw.ellipse((460, FLOOR_Y - 24, 468, FLOOR_Y - 16),
fill=(40, 10, 40))
# ---------------------------------------------------------------------------
# Extra scenes. Each uses simple colored shapes and distinctive props so the
# location reads at a glance.
# ---------------------------------------------------------------------------
def _room_bg(draw: ImageDraw.ImageDraw, wall: Tuple[int, int, int],
floor: Tuple[int, int, int]) -> None:
draw.rectangle((0, 0, CANVAS, FLOOR_Y), fill=wall)
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=floor)
def _outdoor_bg(draw: ImageDraw.ImageDraw, sky: Tuple[int, int, int],
ground: Tuple[int, int, int], horizon: int = FLOOR_Y) -> None:
draw.rectangle((0, 0, CANVAS, horizon), fill=sky)
draw.rectangle((0, horizon, CANVAS, CANVAS), fill=ground)
def _draw_scene_classroom(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (180, 160, 110), (110, 80, 50))
# Blackboard
draw.rectangle((60, 80, 430, 240), fill=(30, 60, 50), outline=(100, 70, 40), width=4)
for y in range(110, 230, 20):
w = 30 + ((y * 7) % 120)
draw.line((90, y, 90 + w, y), fill=(230, 230, 230), width=2)
# Teacher desk with apple
draw.rectangle((340, 380, 490, FLOOR_Y), fill=(110, 75, 45), outline=(160, 110, 70), width=2)
draw.ellipse((430, 365, 450, 385), fill=(200, 50, 50), outline=(80, 20, 20), width=1)
draw.line((440, 365, 444, 355), fill=(50, 80, 40), width=2)
# Student desks (small rows in background)
for x in range(40, 340, 60):
draw.rectangle((x, 350, x + 40, 370), fill=(140, 100, 60), outline=(80, 50, 20), width=1)
draw.line((x + 8, 370, x + 8, 400), fill=(80, 50, 20), width=2)
draw.line((x + 32, 370, x + 32, 400), fill=(80, 50, 20), width=2)
def _draw_scene_auditorium(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (40, 25, 50), (60, 30, 40))
# Stage (bright rectangle at back)
draw.rectangle((20, 300, 490, 400), fill=(120, 60, 70), outline=(200, 150, 100), width=3)
# Curtains on either side of stage
for x in (20, 490):
dx = -1 if x < 100 else 1
for i in range(6):
draw.polygon([(x, 80), (x + dx * 30, 80),
(x + dx * 28, 400), (x - dx * 2, 400)],
fill=(130, 30, 40), outline=(80, 15, 20))
# Podium center-stage
draw.rectangle((236, 330, 276, 390), fill=(160, 120, 70), outline=(90, 60, 30), width=2)
# Spotlight cone
draw.polygon([(256, 80), (200, 330), (312, 330)], fill=(255, 240, 180))
# Seat rows in the foreground silhouette
for y in range(430, CANVAS, 18):
draw.rectangle((10, y, CANVAS - 10, y + 10), fill=(30, 20, 40))
def _draw_scene_laboratory(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (200, 215, 225), (170, 180, 190))
# Periodic table on wall
draw.rectangle((40, 80, 220, 200), outline=(120, 140, 160), width=3, fill=(240, 245, 250))
for gx in range(48, 218, 12):
for gy in range(88, 195, 12):
draw.rectangle((gx, gy, gx + 10, gy + 10), outline=(160, 170, 185), width=1)
# Bench
draw.rectangle((20, 380, 490, 400), fill=(220, 220, 230), outline=(160, 160, 170), width=2)
draw.rectangle((20, 400, 490, FLOOR_Y), fill=(160, 160, 170))
# Flasks and beakers
for i, (cx, h, col) in enumerate([(280, 30, (80, 200, 120)),
(330, 50, (200, 80, 100)),
(380, 40, (80, 120, 220))]):
# Beaker
draw.rectangle((cx - 14, 380 - h, cx + 14, 378),
outline=(150, 160, 180), width=2, fill=(230, 240, 250))
draw.rectangle((cx - 12, 380 - h + 6, cx + 12, 378), fill=col)
# Bubble
draw.ellipse((cx - 2, 380 - h + 10, cx + 2, 380 - h + 14),
fill=(230, 240, 250))
# Microscope
draw.rectangle((420, 360, 470, 380), fill=(50, 55, 70), outline=(120, 130, 150), width=2)
draw.rectangle((435, 310, 455, 360), fill=(50, 55, 70), outline=(120, 130, 150), width=2)
draw.ellipse((430, 300, 460, 320), fill=(50, 55, 70), outline=(120, 130, 150), width=2)
def _draw_scene_museum(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (220, 210, 195), (120, 100, 80))
# Marble columns
for cx in (80, 432):
draw.rectangle((cx - 20, 60, cx + 20, FLOOR_Y), fill=(240, 235, 220),
outline=(160, 150, 130), width=2)
draw.rectangle((cx - 26, 60, cx + 26, 80), fill=(230, 225, 210),
outline=(160, 150, 130), width=2)
draw.rectangle((cx - 26, FLOOR_Y - 20, cx + 26, FLOOR_Y),
fill=(230, 225, 210), outline=(160, 150, 130), width=2)
# Framed artifact
draw.rectangle((180, 140, 332, 300), outline=(200, 170, 100), width=6, fill=(70, 60, 40))
draw.polygon([(210, 280), (256, 170), (302, 280)], fill=(180, 160, 110))
# Velvet rope
for x in (150, 230, 310, 390):
draw.rectangle((x - 4, 430, x + 4, 460), fill=(150, 120, 70))
draw.ellipse((x - 8, 420, x + 8, 436), fill=(200, 170, 100), outline=(140, 110, 60))
draw.line((158, 438, 386, 438), fill=(140, 30, 40), width=4)
def _draw_scene_art_gallery(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (235, 235, 235), (200, 200, 200))
# Three paintings evenly spaced
for i, col in enumerate([(70, 120, 180), (200, 100, 80), (160, 160, 90)]):
x1 = 60 + i * 140
draw.rectangle((x1, 140, x1 + 100, 280),
outline=(60, 60, 60), width=6, fill=(240, 240, 240))
draw.rectangle((x1 + 10, 150, x1 + 90, 270), fill=col)
# Abstract splash
draw.ellipse((x1 + 20, 170, x1 + 50, 200), fill=(250, 240, 120))
draw.polygon([(x1 + 50, 220), (x1 + 80, 200), (x1 + 85, 250)],
fill=(255, 255, 255))
# Spotlights
for i in range(3):
lx = 110 + i * 140
draw.line((lx, 80, lx, 120), fill=(180, 180, 180), width=2)
draw.polygon([(lx - 18, 120), (lx + 18, 120),
(lx + 10, 140), (lx - 10, 140)], fill=(80, 80, 90))
def _draw_scene_theater(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (40, 15, 25), (30, 10, 20))
# Red velvet curtains (sides)
for x in (0, CANVAS - 60):
for i in range(6):
px = x + i * 10
draw.rectangle((px, 60, px + 10, FLOOR_Y - 20),
fill=(150, 30, 40), outline=(100, 15, 25), width=1)
# Top valance
draw.rectangle((0, 60, CANVAS, 110), fill=(180, 40, 50))
for x in range(0, CANVAS, 30):
draw.arc((x, 100, x + 30, 130), start=180, end=360,
fill=(140, 20, 30), width=3)
# Stage
draw.rectangle((60, 400, 452, FLOOR_Y), fill=(90, 60, 40), outline=(160, 110, 70), width=2)
# Footlights
for x in range(80, 445, 30):
draw.ellipse((x - 5, 395, x + 5, 405), fill=(250, 220, 130))
def _draw_scene_cinema(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (20, 20, 35), (40, 20, 40))
# Screen
draw.rectangle((60, 80, 450, 280), fill=(240, 240, 250),
outline=(80, 80, 100), width=6)
# Scene on screen (simple shapes)
draw.polygon([(80, 260), (180, 160), (280, 260)], fill=(60, 100, 60))
draw.ellipse((330, 110, 400, 180), fill=(250, 230, 140))
# Aisle
draw.polygon([(200, 330), (312, 330), (400, CANVAS), (112, CANVAS)],
fill=(70, 30, 45))
# Seat rows (back to front)
for y in range(340, CANVAS, 22):
for x in range(0, CANVAS, 24):
draw.rectangle((x + 4, y, x + 18, y + 14),
fill=(40, 15, 30), outline=(20, 5, 15), width=1)
def _draw_scene_concert_hall(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (80, 50, 40), (50, 30, 25))
# Stage with grand piano
draw.rectangle((50, 380, 462, FLOOR_Y), fill=(120, 80, 50), outline=(180, 130, 80), width=2)
# Piano (big black shape)
draw.polygon([(210, 340), (330, 340), (390, 390), (150, 390)],
fill=(20, 20, 25), outline=(120, 120, 140), width=3)
draw.rectangle((210, 340, 330, 360), fill=(20, 20, 25), outline=(150, 150, 170))
# Keyboard
draw.rectangle((200, 360, 340, 380), fill=(240, 240, 240), outline=(120, 120, 140), width=2)
for x in range(200, 340, 8):
draw.line((x, 360, x, 380), fill=(60, 60, 70), width=1)
# Tiered seating
for y in range(420, CANVAS, 20):
draw.rectangle((0, y, CANVAS, y + 14), fill=(70, 40, 30))
def _draw_scene_stadium(draw: ImageDraw.ImageDraw) -> None:
sky = (120, 170, 210)
field = (60, 140, 65)
draw.rectangle((0, 0, CANVAS, 320), fill=sky)
draw.rectangle((0, 320, CANVAS, CANVAS), fill=field)
# Curved stands
for y in range(180, 320, 18):
draw.arc((-80, y - 40, CANVAS + 80, y + 140),
start=0, end=180, fill=(70, 80, 100), width=10)
# Crowd dots
for cx in range(30, CANVAS, 14):
for cy in range(190, 310, 18):
if (cx + cy) % 3 == 0:
draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2),
fill=(220, 220, 240))
# Track line
draw.line((0, 330, CANVAS, 330), fill=(240, 240, 240), width=3)
# Goalposts (distant)
draw.line((240, 335, 240, 355), fill=(240, 240, 240), width=3)
draw.line((272, 335, 272, 355), fill=(240, 240, 240), width=3)
draw.line((240, 335, 272, 335), fill=(240, 240, 240), width=3)
def _draw_scene_zoo(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (140, 180, 210), (90, 140, 70))
# Cage bars
for x in range(40, 260, 20):
draw.line((x, 120, x, 400), fill=(60, 60, 70), width=3)
draw.rectangle((40, 120, 260, 400), outline=(60, 60, 70), width=4)
# Animal silhouette (elephant-like)
draw.ellipse((90, 300, 220, 390), fill=(120, 110, 120))
draw.ellipse((190, 280, 240, 320), fill=(120, 110, 120))
draw.line((230, 320, 250, 380), fill=(120, 110, 120), width=6)
# Legs
for lx in (110, 140, 170, 200):
draw.rectangle((lx, 370, lx + 10, 400), fill=(100, 90, 100))
# Signpost
draw.rectangle((350, 360, 420, 400), fill=(220, 180, 90), outline=(130, 100, 50), width=2)
draw.line((385, 400, 385, FLOOR_Y), fill=(130, 100, 50), width=3)
def _draw_scene_aquarium(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (25, 30, 50), (15, 20, 35))
# Big water tank
draw.rectangle((40, 80, 472, 420), outline=(140, 160, 190), width=6,
fill=(40, 100, 150))
# Fish silhouettes
for fx, fy, col in ((120, 180, (240, 140, 40)), (280, 220, (220, 220, 140)),
(360, 160, (240, 240, 240)), (200, 300, (200, 80, 120)),
(400, 340, (140, 200, 240))):
draw.ellipse((fx - 18, fy - 8, fx + 18, fy + 8), fill=col)
draw.polygon([(fx - 18, fy), (fx - 28, fy - 8), (fx - 28, fy + 8)],
fill=col)
# Bubbles
for bx, by in ((80, 380), (100, 340), (90, 300), (110, 260)):
draw.ellipse((bx - 4, by - 4, bx + 4, by + 4),
outline=(200, 220, 240), width=1)
# Seaweed
for x in (80, 200, 340, 440):
draw.line((x, 420, x, 360), fill=(40, 130, 70), width=3)
def _draw_scene_greenhouse(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (170, 210, 200), (120, 90, 60))
# Glass panel grid on walls
for x in range(0, CANVAS, 40):
draw.line((x, 0, x, FLOOR_Y), fill=(210, 230, 220), width=1)
for y in range(0, FLOOR_Y, 40):
draw.line((0, y, CANVAS, y), fill=(210, 230, 220), width=1)
# Roof slope
draw.polygon([(0, 0), (CANVAS, 0), (CANVAS - 30, 60), (30, 60)],
fill=(140, 180, 170))
# Rows of potted plants
for i, y in enumerate((390, FLOOR_Y - 8)):
for x in range(40, CANVAS, 80):
draw.rectangle((x - 14, y, x + 14, y + 18),
fill=(140, 70, 40), outline=(80, 40, 20), width=1)
draw.ellipse((x - 20, y - 24, x + 20, y + 4),
fill=(60, 140, 70), outline=(30, 90, 40), width=1)
def _draw_scene_cave(draw: ImageDraw.ImageDraw) -> None:
draw.rectangle((0, 0, CANVAS, CANVAS), fill=(20, 15, 25))
# Jagged opening (bright exit)
draw.polygon([(60, 80), (120, 60), (200, 100), (256, 70), (310, 110),
(380, 70), (450, 100), (460, 220), (380, 200),
(320, 250), (220, 230), (160, 260), (90, 240)],
fill=(40, 30, 50))
# Stalactites
for x in range(40, CANVAS, 60):
draw.polygon([(x, 0), (x + 14, 0), (x + 7, 40 + (x * 3) % 30)],
fill=(50, 40, 60), outline=(20, 10, 20))
# Stalagmites
for x in range(30, CANVAS, 70):
draw.polygon([(x, FLOOR_Y), (x + 16, FLOOR_Y),
(x + 8, FLOOR_Y - 40 - (x * 5) % 30)],
fill=(50, 40, 60), outline=(20, 10, 20))
# Glow
draw.ellipse((220, 180, 292, 230), fill=(80, 60, 30))
def _draw_scene_mountain(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (150, 200, 230), (90, 130, 70))
# Back peaks
draw.polygon([(-40, 320), (140, 120), (320, 300)], fill=(100, 120, 160),
outline=(60, 80, 120))
draw.polygon([(200, 320), (380, 80), (560, 320)], fill=(110, 130, 170),
outline=(70, 90, 130))
# Snow caps
draw.polygon([(110, 180), (140, 120), (170, 180), (150, 190),
(130, 190)], fill=(240, 245, 250))
draw.polygon([(350, 140), (380, 80), (410, 140), (390, 155), (370, 155)],
fill=(240, 245, 250))
# Pine trees
for tx in (40, 90, 450, 480):
draw.rectangle((tx - 3, 380, tx + 3, 430), fill=(70, 45, 25))
draw.polygon([(tx - 16, 390), (tx + 16, 390), (tx, 330)],
fill=(40, 100, 60))
def _draw_scene_desert(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (240, 180, 120), (220, 170, 100), horizon=340)
# Sun
draw.ellipse((380, 90, 460, 170), fill=(250, 210, 100))
# Rolling dunes
for color, y in (((200, 150, 80), 380), ((180, 130, 70), 420)):
draw.pieslice((-100, y - 40, 280, y + 80), start=180, end=360,
fill=color)
draw.pieslice((240, y - 50, 620, y + 60), start=180, end=360,
fill=color)
# Cactus
cx = 100
draw.rectangle((cx - 6, 360, cx + 6, 450), fill=(60, 120, 60),
outline=(30, 80, 30), width=2)
draw.rectangle((cx - 18, 380, cx - 6, 410), fill=(60, 120, 60),
outline=(30, 80, 30), width=2)
draw.rectangle((cx + 6, 400, cx + 18, 420), fill=(60, 120, 60),
outline=(30, 80, 30), width=2)
def _draw_scene_waterfall(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (100, 160, 200), (60, 120, 70))
# Cliff face (left)
draw.polygon([(0, 80), (220, 80), (220, FLOOR_Y), (0, FLOOR_Y)],
fill=(80, 70, 60), outline=(40, 35, 30), width=2)
draw.polygon([(280, 80), (CANVAS, 80), (CANVAS, FLOOR_Y), (280, FLOOR_Y)],
fill=(80, 70, 60), outline=(40, 35, 30), width=2)
# Water cascade
draw.rectangle((220, 80, 280, FLOOR_Y), fill=(140, 200, 230))
for y in range(100, FLOOR_Y, 14):
draw.line((228, y, 272, y + 6), fill=(200, 230, 245), width=1)
# Splash pool
draw.ellipse((160, FLOOR_Y - 20, 340, FLOOR_Y + 40),
fill=(120, 180, 220))
# Mist
for mx in (200, 240, 280):
draw.ellipse((mx - 12, FLOOR_Y - 30, mx + 12, FLOOR_Y - 10),
outline=(220, 235, 245), width=1)
def _draw_scene_vineyard(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (180, 210, 210), (110, 150, 80))
# Distant hills
draw.pieslice((-100, 280, 200, 380), start=180, end=360, fill=(90, 140, 90))
draw.pieslice((200, 290, 560, 390), start=180, end=360, fill=(80, 130, 85))
# Rows of grapevines (trellises)
for i, y in enumerate(range(370, CANVAS, 26)):
draw.line((0, y, CANVAS, y + i * 4), fill=(140, 110, 70), width=2)
for x in range(10, CANVAS, 40 + i * 4):
draw.ellipse((x, y - 6, x + 14, y + 6), fill=(100, 50, 120))
draw.rectangle((x + 5, y + 6, x + 9, y + 12), fill=(80, 60, 40))
def _draw_scene_cemetery(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (50, 50, 70), (60, 70, 55))
# Moon
draw.ellipse((400, 90, 450, 140), fill=(230, 230, 210))
# Bare tree
draw.rectangle((50, 250, 62, 430), fill=(50, 40, 30))
for dx, dy in ((-40, -60), (40, -60), (-60, -90), (60, -100)):
draw.line((56, 260, 56 + dx, 260 + dy), fill=(50, 40, 30), width=3)
# Tombstones
for tx in (180, 260, 340, 430):
draw.rectangle((tx - 20, 400, tx + 20, 460), fill=(140, 140, 150),
outline=(60, 60, 70), width=2)
draw.arc((tx - 20, 380, tx + 20, 420), start=180, end=360,
fill=(140, 140, 150), width=20)
draw.line((tx - 8, 420, tx + 8, 420), fill=(80, 80, 90), width=1)
draw.line((tx - 8, 430, tx + 8, 430), fill=(80, 80, 90), width=1)
def _draw_scene_castle(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (100, 140, 180), (100, 130, 70))
# Big castle silhouette
draw.rectangle((120, 180, 392, 440), fill=(160, 150, 130),
outline=(90, 80, 60), width=3)
# Battlements (crenels)
for x in range(120, 392, 30):
draw.rectangle((x, 160, x + 15, 180), fill=(160, 150, 130),
outline=(90, 80, 60), width=2)
# Towers
for tx in (80, 400):
draw.rectangle((tx, 150, tx + 50, 440), fill=(170, 160, 140),
outline=(90, 80, 60), width=3)
draw.polygon([(tx - 6, 150), (tx + 56, 150),
(tx + 25, 100)], fill=(140, 40, 50))
# Gate
draw.rectangle((220, 340, 292, 440), fill=(60, 40, 25),
outline=(100, 70, 40), width=3)
draw.arc((220, 310, 292, 370), start=180, end=360, fill=(60, 40, 25), width=20)
# Flag
draw.line((256, 100, 256, 40), fill=(100, 80, 50), width=3)
draw.polygon([(256, 40), (290, 50), (256, 60)], fill=(180, 40, 50))
def _draw_scene_mansion(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (70, 60, 80), (150, 115, 80))
# Grand staircase (two sides converging)
for step in range(12):
y1 = 380 - step * 8
left_x = 100 - step * 5
right_x = 412 + step * 5
draw.polygon([(left_x, y1), (220, y1 - 10),
(220, y1), (left_x + 5, y1 + 8)],
fill=(120, 80, 50), outline=(80, 50, 20))
draw.polygon([(292, y1 - 10), (right_x, y1),
(right_x - 5, y1 + 8), (292, y1)],
fill=(120, 80, 50), outline=(80, 50, 20))
# Chandelier
draw.line((256, 0, 256, 80), fill=(140, 140, 140), width=2)
draw.ellipse((200, 60, 312, 120), fill=(200, 170, 90), outline=(120, 90, 40), width=3)
for angle_deg in range(-80, 81, 20):
ang = math.radians(angle_deg - 90)
lx = 256 + 60 * math.cos(ang)
ly = 90 + 60 * math.sin(ang)
draw.ellipse((lx - 3, ly - 3, lx + 3, ly + 3), fill=(255, 240, 160))
# Tall windows on either side
for x in (40, 412):
draw.rectangle((x, 140, x + 60, 340), outline=(200, 180, 130), width=3,
fill=(50, 70, 110))
draw.line((x + 30, 140, x + 30, 340), fill=(200, 180, 130), width=2)
draw.line((x, 240, x + 60, 240), fill=(200, 180, 130), width=2)
def _draw_scene_cottage(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (180, 150, 100), (90, 55, 30))
# Thatched-ish beam lines on the wall
for y in range(80, FLOOR_Y, 30):
draw.line((0, y, CANVAS, y), fill=(140, 110, 70), width=1)
# Hearth / fireplace
draw.rectangle((200, 260, 312, 420), fill=(80, 60, 45), outline=(140, 100, 60), width=3)
draw.rectangle((220, 300, 292, 420), fill=(30, 20, 15))
# Fire flames
for dx, col in ((-16, (240, 120, 40)), (-4, (250, 160, 60)),
(12, (240, 120, 40))):
draw.polygon([(256 + dx, 400), (256 + dx - 10, 380),
(256 + dx + 6, 360), (256 + dx + 2, 340),
(256 + dx + 14, 360), (256 + dx + 8, 380)],
fill=col)
# Mantel
draw.rectangle((180, 250, 332, 270), fill=(120, 80, 50),
outline=(80, 50, 20), width=2)
# Small window
draw.rectangle((50, 180, 130, 270), outline=(140, 110, 70), width=3,
fill=(160, 200, 210))
draw.line((90, 180, 90, 270), fill=(140, 110, 70), width=2)
def _draw_scene_cabin(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (130, 95, 60), (70, 45, 25))
# Log wall lines
for y in range(40, FLOOR_Y, 36):
draw.line((0, y, CANVAS, y), fill=(80, 55, 30), width=3)
draw.ellipse((-8, y - 10, 8, y + 10), fill=(100, 70, 40))
draw.ellipse((CANVAS - 8, y - 10, CANVAS + 8, y + 10), fill=(100, 70, 40))
# Stone fireplace
for gy in range(200, 420, 20):
for gx in range(200, 312, 22):
draw.ellipse((gx, gy, gx + 20, gy + 18),
fill=(140, 130, 120), outline=(80, 70, 65))
# Opening
draw.rectangle((220, 320, 292, 420), fill=(30, 20, 15))
# Bearskin rug
draw.ellipse((100, 475, 400, 510), fill=(70, 45, 25),
outline=(30, 20, 10), width=2)
for dx in (-120, 120):
draw.ellipse((250 + dx - 20, 470, 250 + dx + 20, 490),
fill=(70, 45, 25), outline=(30, 20, 10))
def _draw_scene_lighthouse(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (40, 45, 70), (60, 40, 30))
# Spiral staircase (stacked segments)
for i in range(12):
y = 100 + i * 30
x = 220 + 30 * math.sin(i * 0.6)
draw.rectangle((x, y, x + 70, y + 12),
fill=(140, 100, 60), outline=(80, 50, 20), width=1)
# Lamp at top
draw.ellipse((200, 60, 312, 140), fill=(250, 220, 140),
outline=(200, 160, 70), width=3)
# Light cone
draw.polygon([(256, 100), (60, 20), (60, 180)], fill=(255, 240, 170, 120))
# Small sea window
draw.ellipse((40, 360, 120, 440), outline=(200, 210, 220), width=3,
fill=(60, 110, 150))
def _draw_scene_temple(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (70, 50, 40), (90, 70, 50))
# Columns
for cx in (80, 180, 332, 432):
draw.rectangle((cx - 18, 100, cx + 18, FLOOR_Y),
fill=(200, 180, 140), outline=(120, 100, 70), width=2)
draw.rectangle((cx - 24, 100, cx + 24, 120),
fill=(180, 160, 120), outline=(120, 100, 70), width=2)
# Roof eaves
draw.polygon([(20, 100), (CANVAS - 20, 100), (CANVAS - 60, 60), (60, 60)],
fill=(130, 40, 40), outline=(80, 20, 25), width=2)
# Idol center
draw.ellipse((230, 280, 282, 340), fill=(230, 200, 100),
outline=(160, 130, 50), width=3)
draw.rectangle((240, 320, 272, 400), fill=(230, 200, 100),
outline=(160, 130, 50), width=3)
# Incense smoke
for sx in (210, 302):
draw.rectangle((sx - 6, 380, sx + 6, 400), fill=(100, 70, 40))
for i in range(4):
draw.arc((sx - 10, 360 - i * 20, sx + 10, 380 - i * 20),
start=0, end=180, fill=(200, 200, 220), width=1)
def _draw_scene_monastery(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (150, 135, 110), (100, 85, 65))
# Cloister arches
for i, x in enumerate(range(20, CANVAS, 90)):
draw.rectangle((x, 180, x + 30, FLOOR_Y),
fill=(170, 155, 130), outline=(110, 95, 70), width=2)
draw.rectangle((x + 30, 280, x + 80, FLOOR_Y),
fill=(110, 90, 60))
draw.pieslice((x + 30, 180, x + 80, 300), start=180, end=360,
fill=(110, 90, 60))
# Floor tiles
for y in range(FLOOR_Y + 10, CANVAS, 20):
draw.line((0, y, CANVAS, y), fill=(70, 55, 40), width=1)
# Candles
for cx in (80, 430):
draw.rectangle((cx - 3, 420, cx + 3, 450), fill=(220, 200, 140))
draw.polygon([(cx - 5, 420), (cx, 405), (cx + 5, 420)],
fill=(250, 180, 80))
def _draw_scene_airport(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (200, 215, 225), (100, 100, 110))
# Long terminal windows showing a plane
draw.rectangle((40, 100, 472, 300), outline=(130, 140, 155), width=4,
fill=(120, 160, 200))
for x in range(40, 472, 80):
draw.line((x, 100, x, 300), fill=(130, 140, 155), width=2)
# Plane
draw.ellipse((140, 200, 340, 240), fill=(240, 240, 250), outline=(100, 100, 120), width=2)
draw.polygon([(340, 220), (400, 215), (400, 225)], fill=(240, 240, 250),
outline=(100, 100, 120))
draw.polygon([(200, 210), (240, 180), (260, 210)], fill=(240, 240, 250))
# Runway line
draw.line((0, FLOOR_Y - 30, CANVAS, FLOOR_Y - 30), fill=(220, 210, 80), width=3)
# Departure board
draw.rectangle((60, 330, 260, 400), fill=(20, 25, 35), outline=(160, 160, 180), width=2)
for y in (345, 365, 385):
for x in (70, 130, 180, 230):
draw.line((x, y, x + 20, y), fill=(240, 200, 60), width=2)
def _draw_scene_train_station(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (50, 60, 75), (80, 80, 90))
# Platform edge
draw.rectangle((0, FLOOR_Y - 10, CANVAS, FLOOR_Y), fill=(220, 210, 80))
# Train silhouette
draw.rectangle((40, 300, 472, FLOOR_Y - 14),
fill=(120, 30, 40), outline=(60, 15, 20), width=3)
# Windows
for x in range(60, 470, 50):
draw.rectangle((x, 330, x + 30, 370), fill=(160, 200, 230),
outline=(40, 10, 20), width=2)
# Wheels
for x in (100, 200, 300, 400):
draw.ellipse((x - 15, FLOOR_Y - 28, x + 15, FLOOR_Y + 2),
fill=(40, 40, 50), outline=(200, 200, 210), width=2)
# Overhead sign
draw.rectangle((180, 80, 332, 140), fill=(220, 210, 80),
outline=(130, 110, 50), width=3)
draw.line((200, 110, 310, 110), fill=(40, 40, 40), width=4)
def _draw_scene_subway(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (220, 225, 230), (40, 40, 50))
# Tile grid
for y in range(60, FLOOR_Y, 24):
draw.line((0, y, CANVAS, y), fill=(180, 185, 195), width=1)
for x in range(0, CANVAS, 30):
draw.line((x, 60, x, FLOOR_Y), fill=(180, 185, 195), width=1)
# Arriving train
draw.rectangle((120, 280, 500, 440), fill=(80, 90, 120),
outline=(40, 50, 70), width=3)
for x in range(140, 480, 40):
draw.rectangle((x, 310, x + 28, 350), fill=(180, 210, 240),
outline=(40, 50, 70), width=2)
# Tracks
draw.rectangle((0, FLOOR_Y, CANVAS, CANVAS), fill=(60, 50, 40))
for y in range(FLOOR_Y + 8, CANVAS, 16):
draw.line((0, y, CANVAS, y), fill=(120, 100, 80), width=2)
draw.line((120, FLOOR_Y + 20, CANVAS, FLOOR_Y + 20), fill=(200, 200, 210), width=3)
draw.line((120, FLOOR_Y + 40, CANVAS, FLOOR_Y + 40), fill=(200, 200, 210), width=3)
def _draw_scene_bridge(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (130, 170, 210), (60, 110, 150), horizon=380)
# River ripples
for y in range(400, CANVAS, 20):
for x in range(0, CANVAS, 40):
draw.arc((x, y, x + 40, y + 10), start=0, end=180,
fill=(140, 180, 210), width=1)
# Suspension towers
for tx in (90, 422):
draw.rectangle((tx - 8, 80, tx + 8, 380), fill=(180, 60, 40),
outline=(90, 25, 20), width=2)
draw.rectangle((tx - 14, 140, tx + 14, 160), fill=(180, 60, 40))
# Main cables
draw.arc((60, 120, 462, 360), start=180, end=360,
fill=(40, 40, 50), width=4)
# Vertical suspenders
for x in range(100, 420, 25):
cy = 350 - (abs(x - 256) * 0.7)
draw.line((x, cy, x, 380), fill=(60, 60, 70), width=1)
# Deck
draw.rectangle((0, 380, CANVAS, FLOOR_Y), fill=(100, 100, 110),
outline=(60, 60, 70), width=2)
def _draw_scene_parking_lot(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (180, 180, 200), (80, 80, 90))
# Parking lines
for x in range(20, CANVAS, 56):
draw.line((x, 320, x, FLOOR_Y), fill=(230, 230, 80), width=2)
# Cars (silhouettes)
for i, (cx, col) in enumerate([(80, (200, 60, 60)), (200, (60, 100, 180)),
(320, (240, 220, 90)), (440, (100, 180, 100))]):
draw.rectangle((cx - 25, 360, cx + 25, 410), fill=col,
outline=(30, 30, 40), width=2)
draw.polygon([(cx - 18, 360), (cx + 18, 360),
(cx + 10, 330), (cx - 10, 330)], fill=col,
outline=(30, 30, 40))
draw.ellipse((cx - 22, 402, cx - 10, 414), fill=(40, 40, 50))
draw.ellipse((cx + 10, 402, cx + 22, 414), fill=(40, 40, 50))
# Streetlamp
draw.line((470, FLOOR_Y, 470, 200), fill=(80, 80, 90), width=3)
draw.ellipse((460, 190, 480, 210), fill=(250, 220, 130))
def _draw_scene_gas_station(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (140, 180, 210), (90, 90, 95))
# Canopy
draw.rectangle((50, 180, 462, 230), fill=(200, 60, 50),
outline=(120, 30, 25), width=3)
draw.rectangle((40, 230, 60, 440), fill=(140, 140, 150))
draw.rectangle((452, 230, 472, 440), fill=(140, 140, 150))
# Pumps
for px in (180, 332):
draw.rectangle((px - 15, 340, px + 15, 440),
fill=(230, 230, 230), outline=(100, 100, 110), width=2)
# Screen
draw.rectangle((px - 10, 350, px + 10, 380), fill=(60, 80, 60))
# Nozzle hose
draw.line((px + 15, 370, px + 35, 380), fill=(60, 60, 70), width=3)
# Convenience store
draw.rectangle((300, 260, 490, 440), fill=(230, 215, 180),
outline=(140, 110, 70), width=3)
draw.rectangle((340, 320, 420, 400), fill=(80, 110, 150),
outline=(60, 70, 90), width=2)
def _draw_scene_bank(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (220, 200, 160), (160, 130, 100))
# Teller counter
draw.rectangle((60, 340, 452, 400), fill=(140, 100, 60),
outline=(80, 50, 20), width=3)
draw.rectangle((60, 400, 452, FLOOR_Y), fill=(110, 80, 50))
# Teller windows
for x in range(80, 440, 90):
draw.rectangle((x, 280, x + 60, 340), outline=(80, 50, 20), width=3,
fill=(200, 220, 230))
# Safe door
draw.ellipse((350, 80, 470, 200), fill=(100, 110, 130),
outline=(40, 40, 60), width=4)
draw.ellipse((380, 110, 440, 170), fill=(60, 70, 90),
outline=(40, 40, 60), width=2)
for angle_deg in range(0, 360, 45):
ang = math.radians(angle_deg)
x = 410 + 22 * math.cos(ang)
y = 140 + 22 * math.sin(ang)
draw.ellipse((x - 2, y - 2, x + 2, y + 2), fill=(180, 180, 190))
def _draw_scene_prison(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (90, 85, 75), (60, 55, 50))
# Block pattern wall
for y in range(80, FLOOR_Y, 30):
offset = 15 if (y // 30) % 2 else 0
for x in range(-20 + offset, CANVAS, 60):
draw.rectangle((x, y, x + 58, y + 28),
outline=(50, 45, 40), width=1, fill=(100, 90, 80))
# Barred cell on right
draw.rectangle((280, 200, 480, FLOOR_Y), fill=(40, 35, 30),
outline=(20, 15, 10), width=3)
for bx in range(290, 480, 18):
draw.rectangle((bx - 2, 200, bx + 2, FLOOR_Y), fill=(160, 160, 170))
for by in (250, 340, 400):
draw.line((280, by, 480, by), fill=(160, 160, 170), width=3)
# Bunk
draw.rectangle((310, 360, 440, 400), fill=(120, 100, 90),
outline=(60, 50, 40), width=2)
def _draw_scene_police_station(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (180, 190, 200), (120, 120, 130))
# Wanted posters
for i, x in enumerate((60, 160, 260)):
draw.rectangle((x, 100, x + 70, 200),
fill=(230, 220, 200), outline=(120, 100, 70), width=2)
draw.ellipse((x + 15, 115, x + 55, 155), fill=(100, 80, 70))
draw.line((x + 5, 170, x + 65, 170), fill=(60, 40, 30), width=2)
# Desk
draw.rectangle((60, 380, 320, 410), fill=(130, 90, 55),
outline=(80, 50, 20), width=2)
draw.line((80, 410, 80, FLOOR_Y), fill=(80, 50, 20), width=3)
draw.line((300, 410, 300, FLOOR_Y), fill=(80, 50, 20), width=3)
# Jail-cell corner (right)
draw.rectangle((370, 180, 490, FLOOR_Y), outline=(60, 60, 70), width=4, fill=(40, 40, 50))
for bx in range(378, 490, 14):
draw.line((bx, 180, bx, FLOOR_Y), fill=(170, 170, 180), width=3)
def _draw_scene_fire_station(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (180, 50, 50), (80, 80, 90))
# Red garage door
draw.rectangle((80, 120, 430, FLOOR_Y), fill=(180, 40, 40),
outline=(100, 20, 20), width=4)
for y in range(150, FLOOR_Y, 30):
draw.line((80, y, 430, y), fill=(100, 20, 20), width=2)
# Fire truck outline through the door
draw.rectangle((120, 340, 390, 440), fill=(150, 30, 30),
outline=(90, 15, 15), width=2)
draw.rectangle((130, 290, 220, 340), fill=(150, 30, 30),
outline=(90, 15, 15), width=2)
draw.ellipse((130, 420, 170, 460), fill=(30, 30, 40))
draw.ellipse((330, 420, 370, 460), fill=(30, 30, 40))
# Fire-pole (right side)
draw.line((470, 80, 470, FLOOR_Y), fill=(220, 200, 90), width=6)
def _draw_scene_courthouse(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (120, 100, 80), (80, 60, 40))
# Columns (4)
for cx in (60, 180, 300, 420):
draw.rectangle((cx, 80, cx + 30, FLOOR_Y),
fill=(220, 210, 190), outline=(130, 120, 100), width=2)
draw.rectangle((cx - 4, 80, cx + 34, 100),
fill=(200, 190, 170), outline=(130, 120, 100), width=2)
# Judge's bench
draw.rectangle((120, 360, 392, 430), fill=(90, 60, 35),
outline=(150, 100, 55), width=3)
# Gavel on desk
draw.rectangle((220, 340, 250, 360), fill=(130, 90, 50),
outline=(70, 45, 20), width=2)
draw.line((240, 346, 280, 346), fill=(130, 90, 50), width=5)
# Scales of justice
draw.line((256, 260, 256, 340), fill=(180, 150, 70), width=3)
draw.line((210, 260, 302, 260), fill=(180, 150, 70), width=3)
for px in (210, 302):
draw.arc((px - 15, 260, px + 15, 280), start=0, end=180,
fill=(180, 150, 70), width=3)
def _draw_scene_factory(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (90, 95, 110), (60, 65, 75))
# Smokestack (tall, left)
draw.rectangle((30, 100, 90, FLOOR_Y), fill=(150, 80, 60),
outline=(80, 40, 30), width=3)
# Smoke
for sx, sy in ((40, 80), (60, 60), (50, 40)):
draw.ellipse((sx - 15, sy - 15, sx + 15, sy + 15),
fill=(120, 120, 130))
# Conveyor belt
draw.rectangle((120, 360, 480, 400), fill=(50, 55, 65),
outline=(130, 130, 150), width=3)
for x in range(130, 470, 30):
draw.ellipse((x, 365, x + 20, 395), outline=(160, 160, 180), width=2)
# Boxes on belt
for bx in (160, 260, 360):
draw.rectangle((bx - 15, 340, bx + 15, 360),
fill=(180, 140, 90), outline=(100, 70, 40), width=2)
draw.line((bx - 15, 350, bx + 15, 350), fill=(100, 70, 40), width=1)
# Machinery gears in bg
for cx, cy in ((200, 200), (280, 160), (360, 210)):
r = 28
draw.ellipse((cx - r, cy - r, cx + r, cy + r),
fill=(110, 100, 90), outline=(60, 50, 40), width=3)
for angle_deg in range(0, 360, 45):
ang = math.radians(angle_deg)
x = cx + r * math.cos(ang)
y = cy + r * math.sin(ang)
draw.rectangle((x - 4, y - 4, x + 4, y + 4), fill=(80, 70, 60))
def _draw_scene_warehouse(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (140, 130, 110), (70, 65, 55))
# Tall shelving racks
for rx in (40, 180, 320, 440):
draw.rectangle((rx, 100, rx + 60, FLOOR_Y),
fill=(80, 75, 70), outline=(30, 25, 20), width=2)
for y in (160, 240, 320, 400):
draw.line((rx, y, rx + 60, y), fill=(30, 25, 20), width=2)
# Boxes on shelves
for i in range(3):
bx = rx + 5 + i * 18
draw.rectangle((bx, y - 30, bx + 14, y - 2),
fill=(180, 140, 90), outline=(100, 70, 40), width=1)
# Forklift
draw.rectangle((240, 400, 310, 460), fill=(220, 180, 50),
outline=(130, 100, 30), width=3)
draw.line((310, 400, 340, 360), fill=(80, 80, 90), width=4)
draw.line((310, 440, 340, 400), fill=(80, 80, 90), width=4)
draw.ellipse((240, 450, 270, 480), fill=(30, 30, 40))
draw.ellipse((285, 450, 315, 480), fill=(30, 30, 40))
def _draw_scene_construction_site(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (180, 170, 140), (150, 130, 100))
# Scaffolding
for x in (60, 110, 160, 210):
draw.line((x, 120, x, FLOOR_Y), fill=(80, 80, 90), width=3)
for y in range(140, FLOOR_Y, 40):
draw.line((60, y, 210, y), fill=(80, 80, 90), width=3)
# Building frame
draw.rectangle((270, 160, 470, FLOOR_Y), outline=(140, 80, 40), width=3,
fill=(160, 130, 100))
for y in range(200, FLOOR_Y, 60):
draw.line((270, y, 470, y), fill=(140, 80, 40), width=2)
for x in range(310, 470, 40):
draw.line((x, 160, x, FLOOR_Y), fill=(140, 80, 40), width=1)
# Cement mixer
draw.ellipse((60, 400, 140, 460), fill=(180, 160, 120),
outline=(90, 70, 40), width=3)
draw.line((100, 400, 100, 370), fill=(120, 100, 70), width=3)
# Orange safety cones
for cx in (220, 250):
draw.polygon([(cx - 8, FLOOR_Y - 4), (cx + 8, FLOOR_Y - 4),
(cx, FLOOR_Y - 30)], fill=(230, 110, 40))
draw.line((cx - 6, FLOOR_Y - 18, cx + 6, FLOOR_Y - 18),
fill=(240, 240, 240), width=1)
def _draw_scene_garage(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (160, 165, 170), (90, 90, 95))
# Pegboard with tools (left)
draw.rectangle((30, 100, 220, 260), fill=(220, 180, 100),
outline=(130, 100, 50), width=3)
for y in range(115, 260, 18):
for x in range(40, 220, 18):
draw.ellipse((x - 1, y - 1, x + 1, y + 1), fill=(100, 70, 30))
# Hanging tools (hammer, wrench, saw)
draw.rectangle((70, 130, 76, 180), fill=(100, 60, 30))
draw.rectangle((60, 120, 90, 140), fill=(70, 70, 80))
draw.rectangle((140, 140, 145, 200), fill=(150, 150, 160))
draw.polygon([(140, 140), (160, 140), (158, 150), (142, 150)],
fill=(80, 80, 90))
# Workbench
draw.rectangle((30, 320, 260, 360), fill=(110, 75, 45),
outline=(60, 40, 20), width=2)
draw.line((40, 360, 40, FLOOR_Y), fill=(60, 40, 20), width=4)
draw.line((250, 360, 250, FLOOR_Y), fill=(60, 40, 20), width=4)
# Toolbox on workbench
draw.rectangle((70, 290, 150, 320), fill=(200, 40, 40),
outline=(130, 20, 20), width=2)
# Half-open garage door (right)
draw.rectangle((300, 80, 500, 200), outline=(100, 100, 110), width=4,
fill=(60, 80, 100))
for y in range(95, 200, 15):
draw.line((300, y, 500, y), fill=(100, 100, 110), width=1)
def _draw_scene_basement(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (90, 85, 80), (60, 55, 50))
# Exposed beams
for x in range(80, CANVAS, 100):
draw.rectangle((x, 60, x + 20, 120), fill=(100, 70, 40),
outline=(60, 40, 20), width=2)
# Furnace (metal rectangle)
draw.rectangle((60, 260, 200, 440), fill=(70, 75, 85),
outline=(40, 40, 50), width=3)
draw.rectangle((90, 290, 170, 370), fill=(240, 140, 40))
for y in range(300, 370, 8):
draw.line((90, y, 170, y), fill=(150, 80, 20), width=1)
# Pipes
draw.line((200, 300, CANVAS, 300), fill=(160, 110, 40), width=8)
draw.line((200, 340, CANVAS, 340), fill=(160, 110, 40), width=8)
# Boxes stacked
for i, (x, w, h, col) in enumerate([(280, 80, 60, (180, 140, 90)),
(290, 60, 40, (160, 120, 80)),
(370, 70, 70, (170, 130, 85)),
(380, 50, 30, (150, 110, 75))]):
y2 = 440
y1 = y2 - h
draw.rectangle((x, y1, x + w, y2), fill=col,
outline=(80, 50, 20), width=2)
draw.line((x, y1 + h // 2, x + w, y1 + h // 2),
fill=(80, 50, 20), width=1)
def _draw_scene_attic(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (130, 95, 70), (90, 60, 40))
# Sloped rafters (triangular roof shape)
draw.polygon([(0, 200), (256, 40), (CANVAS, 200)],
fill=(150, 115, 85), outline=(80, 50, 25), width=3)
draw.polygon([(0, 200), (0, 60), (80, 60)], fill=(130, 95, 70))
draw.polygon([(CANVAS, 200), (CANVAS, 60), (432, 60)], fill=(130, 95, 70))
for ry in range(80, 200, 30):
draw.line((0, ry, CANVAS, ry), fill=(80, 50, 25), width=1)
# Circular gable window
draw.ellipse((206, 60, 306, 160), fill=(200, 220, 240),
outline=(80, 50, 25), width=3)
draw.line((256, 60, 256, 160), fill=(80, 50, 25), width=2)
draw.line((206, 110, 306, 110), fill=(80, 50, 25), width=2)
# Dusty trunk
draw.rectangle((140, 380, 260, 440), fill=(110, 70, 40),
outline=(60, 35, 15), width=3)
draw.arc((140, 360, 260, 400), start=180, end=360,
fill=(110, 70, 40), width=15)
# Old lamp
draw.rectangle((320, 400, 330, 440), fill=(80, 60, 30))
draw.polygon([(306, 400), (344, 400), (340, 380), (310, 380)],
fill=(180, 150, 80))
def _draw_scene_laundry_room(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (230, 235, 240), (170, 170, 180))
# Washer
draw.rectangle((60, 260, 220, 440), fill=(240, 240, 245),
outline=(120, 130, 140), width=3)
draw.ellipse((85, 290, 195, 400), outline=(120, 130, 140), width=4,
fill=(150, 180, 210))
draw.ellipse((110, 315, 170, 375), outline=(120, 130, 140), width=2,
fill=(120, 160, 200))
# Dryer
draw.rectangle((240, 260, 400, 440), fill=(240, 240, 245),
outline=(120, 130, 140), width=3)
draw.ellipse((265, 290, 375, 400), outline=(120, 130, 140), width=4,
fill=(60, 70, 90))
# Controls on top
for x in (100, 140, 180, 280, 320, 360):
draw.ellipse((x - 5, 270, x + 5, 280),
outline=(120, 130, 140), width=1, fill=(200, 210, 220))
# Folded clothes stack
for i, col in enumerate([(120, 170, 210), (200, 160, 100),
(200, 80, 100), (140, 200, 140)]):
y = 430 - i * 14
draw.rectangle((430, y, 490, y + 12), fill=col,
outline=(60, 60, 70), width=1)
def _draw_scene_pantry(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (180, 150, 100), (110, 80, 50))
# Shelves
for shelf_y in (90, 170, 250, 330, 410):
draw.rectangle((30, shelf_y, 482, shelf_y + 12),
fill=(150, 110, 70), outline=(80, 50, 20), width=2)
# Jars and cans
for shelf_y in (90, 170, 250, 330):
for i, x in enumerate(range(50, 480, 38)):
col_options = [(200, 170, 110), (180, 60, 60), (100, 150, 90),
(230, 200, 90), (140, 100, 150), (200, 130, 80)]
col = col_options[(i + shelf_y) % len(col_options)]
shape = (x + i * 0, shelf_y - 32, x + 28, shelf_y - 2)
if i % 2:
draw.rectangle(shape, fill=col, outline=(60, 40, 30), width=1)
draw.rectangle((shape[0], shape[1], shape[2], shape[1] + 6),
fill=(180, 180, 190))
else:
draw.ellipse(shape, fill=col, outline=(60, 40, 30), width=1)
# Lid
draw.rectangle((shape[0] + 4, shape[1] - 4, shape[2] - 4,
shape[1] + 4), fill=(140, 110, 70))
def _draw_scene_swimming_pool(draw: ImageDraw.ImageDraw) -> None:
_outdoor_bg(draw, (150, 200, 230), (200, 210, 220), horizon=280)
# Pool
draw.rectangle((30, 300, 482, FLOOR_Y), fill=(60, 160, 200),
outline=(40, 110, 150), width=4)
# Lane ropes
for y in range(330, FLOOR_Y, 25):
for x in range(40, 480, 14):
col = (230, 60, 60) if (x // 28) % 2 else (230, 230, 230)
draw.ellipse((x, y - 3, x + 10, y + 3), fill=col)
# Ripples
for y in range(310, FLOOR_Y, 20):
for x in range(60, 470, 40):
draw.arc((x, y, x + 20, y + 6), start=0, end=180,
fill=(100, 180, 220), width=1)
# Diving board (right)
draw.rectangle((380, 260, 490, 280), fill=(200, 200, 210),
outline=(100, 100, 110), width=2)
draw.rectangle((470, 280, 486, 310), fill=(80, 80, 90))
def _draw_scene_casino(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (60, 20, 20), (30, 10, 10))
# Neon lights (top band)
for x in range(0, CANVAS, 50):
col = [(250, 80, 100), (250, 200, 80), (100, 200, 250),
(200, 80, 250)][(x // 50) % 4]
draw.rectangle((x, 60, x + 40, 80), fill=col)
# Roulette table (green)
draw.ellipse((60, 300, 452, 460), fill=(30, 90, 60),
outline=(200, 170, 80), width=5)
# Wheel
draw.ellipse((180, 320, 332, 440), outline=(200, 170, 80), width=4,
fill=(120, 80, 40))
for angle_deg in range(0, 360, 30):
ang = math.radians(angle_deg)
x1 = 256 + 60 * math.cos(ang)
y1 = 380 + 40 * math.sin(ang)
col = (200, 40, 40) if (angle_deg // 30) % 2 else (40, 40, 40)
draw.ellipse((x1 - 4, y1 - 4, x1 + 4, y1 + 4), fill=col)
# Slot machines on the wall (left/right)
for sx in (40, 440):
draw.rectangle((sx - 25, 140, sx + 25, 280), fill=(220, 40, 40),
outline=(100, 20, 20), width=3)
draw.rectangle((sx - 18, 170, sx + 18, 220), fill=(30, 30, 40),
outline=(200, 180, 100), width=2)
for y in (190, 210):
for x in (sx - 10, sx, sx + 10):
draw.ellipse((x - 4, y - 4, x + 4, y + 4), fill=(230, 200, 100))
def _draw_scene_nightclub(draw: ImageDraw.ImageDraw) -> None:
draw.rectangle((0, 0, CANVAS, CANVAS), fill=(20, 10, 30))
# Disco ball
draw.ellipse((216, 70, 296, 150), fill=(180, 190, 210),
outline=(100, 100, 120), width=2)
for angle_deg in range(0, 360, 22):
ang = math.radians(angle_deg)
x = 256 + 38 * math.cos(ang)
y = 110 + 38 * math.sin(ang)
draw.ellipse((x - 3, y - 3, x + 3, y + 3), fill=(240, 240, 255))
# Cord
draw.line((256, 0, 256, 70), fill=(80, 80, 90), width=2)
# Light beams
for x_target, col in ((60, (200, 60, 180)), (460, (60, 200, 220)),
(140, (240, 220, 80)), (380, (240, 80, 80))):
draw.polygon([(256, 110), (x_target - 20, FLOOR_Y),
(x_target + 20, FLOOR_Y)], fill=col + (60,)
if len(col) == 3 else col)
# DJ booth (back)
draw.rectangle((180, 340, 332, 410), fill=(40, 30, 60),
outline=(180, 80, 200), width=3)
# Turntables
for tx in (216, 296):
draw.ellipse((tx - 16, 350, tx + 16, 382), outline=(200, 200, 220), width=2,
fill=(20, 10, 30))
# Dance floor (checkered glow)
for i, y in enumerate(range(420, CANVAS, 20)):
for j, x in enumerate(range(0, CANVAS, 40)):
col = (200, 80, 220) if (i + j) % 2 else (60, 180, 220)
draw.rectangle((x, y, x + 40, y + 20), fill=col)
def _draw_scene_arcade(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (30, 25, 50), (40, 30, 60))
# Row of arcade cabinets
for i, col in enumerate([(220, 60, 60), (60, 200, 220), (240, 200, 60),
(160, 80, 220)]):
x = 40 + i * 110
draw.rectangle((x, 120, x + 90, 440), fill=col,
outline=(30, 20, 10), width=3)
# Screen
draw.rectangle((x + 10, 150, x + 80, 230), fill=(30, 30, 50),
outline=(220, 220, 240), width=2)
# Glowing content on screen
draw.rectangle((x + 20, 190, x + 30, 200), fill=(240, 240, 100))
draw.ellipse((x + 40, 170, x + 60, 190), fill=(240, 80, 120))
# Control panel
draw.rectangle((x + 10, 260, x + 80, 300), fill=(50, 40, 30),
outline=(20, 10, 5), width=2)
draw.ellipse((x + 20, 270, x + 32, 288), fill=(200, 200, 210))
draw.ellipse((x + 50, 270, x + 62, 288), fill=(240, 80, 80))
# Coin slot
draw.rectangle((x + 38, 330, x + 52, 340), fill=(20, 20, 30))
def _draw_scene_spa(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (220, 230, 220), (200, 200, 180))
# Bamboo wall decor
for x in (60, 80, 100, 420, 440, 460):
draw.rectangle((x, 120, x + 12, 320), fill=(150, 180, 90),
outline=(80, 110, 40), width=2)
for y in (150, 190, 230, 270):
draw.line((x, y, x + 12, y), fill=(80, 110, 40), width=1)
# Massage bed
draw.rectangle((120, 380, 400, 410), fill=(230, 220, 210),
outline=(150, 140, 120), width=2)
draw.rectangle((120, 410, 400, 440), fill=(180, 160, 140))
draw.line((150, 410, 150, FLOOR_Y), fill=(100, 90, 80), width=3)
draw.line((370, 410, 370, FLOOR_Y), fill=(100, 90, 80), width=3)
# Hole for face
draw.ellipse((140, 380, 170, 410), fill=(180, 160, 140))
# Candles
for cx in (60, 460):
draw.rectangle((cx - 6, 410, cx + 6, 440), fill=(220, 200, 170))
draw.polygon([(cx - 4, 410), (cx, 395), (cx + 4, 410)],
fill=(250, 180, 80))
# Stone bowls
for bx in (190, 330):
draw.ellipse((bx - 20, 355, bx + 20, 380), fill=(160, 150, 130),
outline=(80, 70, 60), width=2)
draw.ellipse((bx - 10, 358, bx + 10, 370), fill=(90, 130, 180))
def _draw_scene_barn(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (120, 40, 30), (100, 80, 50))
# Wooden plank walls
for x in range(0, CANVAS, 30):
draw.line((x, 60, x, FLOOR_Y), fill=(60, 20, 15), width=2)
# Hayloft (top)
draw.rectangle((100, 60, 412, 220), fill=(100, 25, 20),
outline=(40, 10, 10), width=3)
for y in range(80, 220, 20):
draw.line((100, y, 412, y), fill=(40, 10, 10), width=1)
# Hay bales (yellow rectangles)
for i, x in enumerate((130, 200, 280, 350)):
draw.rectangle((x, 130, x + 50, 210), fill=(230, 190, 80),
outline=(130, 100, 40), width=2)
for hy in (140, 155, 170, 185, 200):
draw.line((x, hy, x + 50, hy), fill=(180, 140, 60), width=1)
# Cow stall
draw.rectangle((40, 300, 180, 440), fill=(60, 40, 30),
outline=(30, 20, 15), width=3)
draw.line((40, 340, 180, 340), fill=(30, 20, 15), width=2)
# Pitchfork leaning
draw.line((400, FLOOR_Y, 420, 280), fill=(130, 90, 50), width=4)
for dx in (-10, 0, 10):
draw.line((420, 280, 420 + dx, 250), fill=(160, 160, 170), width=3)
def _draw_scene_salon(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (230, 200, 210), (180, 160, 170))
# Big mirror on wall
draw.rectangle((60, 100, 300, 360), outline=(200, 170, 100), width=6,
fill=(180, 200, 210))
# Reflected light streaks
draw.line((80, 160, 280, 180), fill=(240, 245, 250), width=2)
draw.line((80, 220, 280, 240), fill=(240, 245, 250), width=2)
# Barber chair
draw.rectangle((160, 380, 250, 440), fill=(120, 30, 50),
outline=(70, 15, 30), width=3)
draw.rectangle((150, 360, 260, 390), fill=(140, 40, 60),
outline=(70, 15, 30), width=3)
draw.line((205, 440, 205, FLOOR_Y), fill=(120, 120, 130), width=5)
draw.ellipse((170, FLOOR_Y - 10, 240, FLOOR_Y + 20),
fill=(100, 100, 110), outline=(60, 60, 70), width=2)
# Tray with scissors & comb
draw.rectangle((340, 380, 470, 420), fill=(120, 120, 130),
outline=(60, 60, 70), width=2)
draw.line((360, 400, 400, 380), fill=(200, 200, 210), width=2)
draw.line((400, 380, 420, 400), fill=(200, 200, 210), width=2)
draw.rectangle((420, 385, 460, 395), fill=(40, 120, 200))
def _draw_scene_bakery(draw: ImageDraw.ImageDraw) -> None:
_room_bg(draw, (240, 220, 190), (160, 120, 80))
# Chalkboard menu
draw.rectangle((40, 80, 200, 220), fill=(40, 60, 50),
outline=(160, 110, 60), width=4)
for y in range(100, 215, 20):
w = 40 + ((y * 5) % 90)
draw.line((60, y, 60 + w, y), fill=(230, 230, 220), width=2)
# Display case
draw.rectangle((240, 300, 490, 420), fill=(240, 230, 200),
outline=(140, 100, 60), width=3)
draw.rectangle((240, 300, 490, 320), fill=(200, 160, 100))
# Pastries in the case
for i, (col, shape) in enumerate([((200, 150, 80), 'circle'),
((220, 100, 80), 'square'),
((240, 200, 120), 'circle'),
((180, 120, 70), 'square'),
((230, 180, 100), 'circle')]):
x = 260 + i * 44
if shape == 'circle':
draw.ellipse((x, 340, x + 30, 370), fill=col,
outline=(120, 80, 40), width=2)
draw.ellipse((x + 8, 345, x + 16, 353), fill=(140, 100, 60))
else:
draw.rectangle((x, 340, x + 30, 370), fill=col,
outline=(120, 80, 40), width=2)
draw.line((x, 355, x + 30, 355), fill=(120, 80, 40), width=1)
# Flour dust
for fx, fy in ((120, 400), (170, 420), (80, 440)):
draw.ellipse((fx, fy, fx + 6, fy + 4), fill=(250, 245, 230))
SCENES: Dict[str, Callable[[ImageDraw.ImageDraw], None]] = {
"bedroom": _draw_scene_bedroom,
"market": _draw_scene_market,
"office": _draw_scene_office,
"park": _draw_scene_park,
"library": _draw_scene_library,
"kitchen": _draw_scene_kitchen,
"street": _draw_scene_street,
"gym": _draw_scene_gym,
"beach": _draw_scene_beach,
"forest": _draw_scene_forest,
"restaurant": _draw_scene_restaurant,
"school": _draw_scene_school,
"hospital": _draw_scene_hospital,
"bathroom": _draw_scene_bathroom,
"church": _draw_scene_church,
"space": _draw_scene_space,
"rooftop": _draw_scene_rooftop,
"farm": _draw_scene_farm,
"living_room": _draw_scene_living_room,
"soccer_field": _draw_scene_soccer_field,
"cricket_ground": _draw_scene_cricket_ground,
"basketball_court": _draw_scene_basketball_court,
"tennis_court": _draw_scene_tennis_court,
"baseball_field": _draw_scene_baseball_field,
"golf_course": _draw_scene_golf_course,
"bowling_alley": _draw_scene_bowling_alley,
# Education / institutional
"classroom": _draw_scene_classroom,
"auditorium": _draw_scene_auditorium,
"laboratory": _draw_scene_laboratory,
# Culture / entertainment
"museum": _draw_scene_museum,
"art_gallery": _draw_scene_art_gallery,
"theater": _draw_scene_theater,
"cinema": _draw_scene_cinema,
"concert_hall": _draw_scene_concert_hall,
"stadium": _draw_scene_stadium,
"zoo": _draw_scene_zoo,
"aquarium": _draw_scene_aquarium,
# Nature / outdoor
"greenhouse": _draw_scene_greenhouse,
"cave": _draw_scene_cave,
"mountain": _draw_scene_mountain,
"desert": _draw_scene_desert,
"waterfall": _draw_scene_waterfall,
"vineyard": _draw_scene_vineyard,
# Architectural / historic
"cemetery": _draw_scene_cemetery,
"castle": _draw_scene_castle,
"mansion": _draw_scene_mansion,
"cottage": _draw_scene_cottage,
"cabin": _draw_scene_cabin,
"lighthouse": _draw_scene_lighthouse,
"temple": _draw_scene_temple,
"monastery": _draw_scene_monastery,
# Transport hubs
"airport": _draw_scene_airport,
"train_station": _draw_scene_train_station,
"subway": _draw_scene_subway,
"bridge": _draw_scene_bridge,
"parking_lot": _draw_scene_parking_lot,
"gas_station": _draw_scene_gas_station,
# Civic
"bank": _draw_scene_bank,
"prison": _draw_scene_prison,
"police_station": _draw_scene_police_station,
"fire_station": _draw_scene_fire_station,
"courthouse": _draw_scene_courthouse,
# Industrial
"factory": _draw_scene_factory,
"warehouse": _draw_scene_warehouse,
"construction_site": _draw_scene_construction_site,
"garage": _draw_scene_garage,
# Home annexes
"basement": _draw_scene_basement,
"attic": _draw_scene_attic,
"laundry_room": _draw_scene_laundry_room,
"pantry": _draw_scene_pantry,
# Fun / recreation
"swimming_pool": _draw_scene_swimming_pool,
"casino": _draw_scene_casino,
"nightclub": _draw_scene_nightclub,
"arcade": _draw_scene_arcade,
"spa": _draw_scene_spa,
# Misc
"barn": _draw_scene_barn,
"salon": _draw_scene_salon,
"bakery": _draw_scene_bakery,
}
def _draw_skull(draw: ImageDraw.ImageDraw, head_xy: Tuple[float, float], emotion: str) -> None:
cx, cy = head_xy
r = 28
# Skull outline
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=BONE_COLOR, width=3, fill=BG_COLOR)
# Jaw hint (two small lines)
draw.line((cx - 10, cy + r - 2, cx - 6, cy + r + 6), fill=BONE_COLOR, width=2)
draw.line((cx + 10, cy + r - 2, cx + 6, cy + r + 6), fill=BONE_COLOR, width=2)
# Eyes (hollow skeleton sockets)
eye_y = cy - 4
eye_dx = 9
eye_r = 5
if emotion == "tired":
draw.line((cx - eye_dx - 4, eye_y, cx - eye_dx + 4, eye_y), fill=BONE_COLOR, width=3)
draw.line((cx + eye_dx - 4, eye_y, cx + eye_dx + 4, eye_y), fill=BONE_COLOR, width=3)
elif emotion == "angry":
# Sockets + angled brow lines
draw.ellipse((cx - eye_dx - eye_r, eye_y - eye_r, cx - eye_dx + eye_r, eye_y + eye_r), fill=BONE_COLOR)
draw.ellipse((cx + eye_dx - eye_r, eye_y - eye_r, cx + eye_dx + eye_r, eye_y + eye_r), fill=BONE_COLOR)
draw.line((cx - eye_dx - 8, eye_y - 10, cx - eye_dx + 6, eye_y - 4), fill=BONE_COLOR, width=3)
draw.line((cx + eye_dx + 8, eye_y - 10, cx + eye_dx - 6, eye_y - 4), fill=BONE_COLOR, width=3)
elif emotion == "excited":
draw.ellipse((cx - eye_dx - eye_r - 1, eye_y - eye_r - 1, cx - eye_dx + eye_r + 1, eye_y + eye_r + 1), fill=BONE_COLOR)
draw.ellipse((cx + eye_dx - eye_r - 1, eye_y - eye_r - 1, cx + eye_dx + eye_r + 1, eye_y + eye_r + 1), fill=BONE_COLOR)
elif emotion == "scared" or emotion == "surprised":
# Wide open eyes — big empty sockets with a pupil dot
big = eye_r + 2
for dx in (-eye_dx, eye_dx):
draw.ellipse((cx + dx - big, eye_y - big, cx + dx + big, eye_y + big),
outline=BONE_COLOR, width=2, fill=BG_COLOR)
draw.ellipse((cx + dx - 2, eye_y - 2, cx + dx + 2, eye_y + 2), fill=BONE_COLOR)
elif emotion == "bored":
# Droopy half-closed: horizontal ovals
for dx in (-eye_dx, eye_dx):
draw.ellipse((cx + dx - eye_r - 1, eye_y - 2, cx + dx + eye_r + 1, eye_y + 3),
fill=BONE_COLOR)
elif emotion == "confused":
# Asymmetric: one normal, one slightly squinted + a "?" mark near head
draw.ellipse((cx - eye_dx - eye_r, eye_y - eye_r, cx - eye_dx + eye_r, eye_y + eye_r), fill=BONE_COLOR)
draw.ellipse((cx + eye_dx - eye_r + 1, eye_y - 2, cx + eye_dx + eye_r - 1, eye_y + 3), fill=BONE_COLOR)
# Question mark to the upper-right of the skull
qx, qy = cx + r + 14, cy - r + 4
draw.arc((qx - 6, qy - 6, qx + 6, qy + 6), start=200, end=20, fill=BONE_COLOR, width=2)
draw.line((qx + 2, qy + 4, qx + 2, qy + 9), fill=BONE_COLOR, width=2)
draw.ellipse((qx + 1, qy + 12, qx + 4, qy + 15), fill=BONE_COLOR)
else:
draw.ellipse((cx - eye_dx - eye_r, eye_y - eye_r, cx - eye_dx + eye_r, eye_y + eye_r), fill=BONE_COLOR)
draw.ellipse((cx + eye_dx - eye_r, eye_y - eye_r, cx + eye_dx + eye_r, eye_y + eye_r), fill=BONE_COLOR)
# Nasal cavity (triangle)
nose_y = cy + 4
draw.polygon([(cx, nose_y), (cx - 3, nose_y + 8), (cx + 3, nose_y + 8)], outline=BONE_COLOR)
# Mouth (teeth grid) — emotion-shaped
my = cy + 14
mw = 18
mh = 6
if emotion == "happy":
draw.arc((cx - mw, my - mh, cx + mw, my + mh + 4), start=0, end=180, fill=BONE_COLOR, width=3)
# Teeth ticks
for i in range(-2, 3):
draw.line((cx + i * 5, my - 2, cx + i * 5, my + 2), fill=BONE_COLOR, width=1)
elif emotion == "sad":
draw.arc((cx - mw, my + 2, cx + mw, my + mh + 10), start=180, end=360, fill=BONE_COLOR, width=3)
elif emotion == "angry":
# Zigzag teeth
pts = []
for i in range(7):
pts.append((cx - mw + i * (mw * 2 // 6), my + (mh if i % 2 else -mh // 2)))
draw.line(pts, fill=BONE_COLOR, width=2)
elif emotion == "excited":
draw.ellipse((cx - mw // 2, my - 4, cx + mw // 2, my + 10), outline=BONE_COLOR, width=3, fill=BG_COLOR)
elif emotion == "tired":
draw.line((cx - mw // 2, my + 4, cx + mw // 2, my + 4), fill=BONE_COLOR, width=2)
elif emotion == "scared":
# Wavy trembling line
pts = [(cx - mw + i * (mw * 2 // 8), my + (3 if i % 2 else -3)) for i in range(9)]
draw.line(pts, fill=BONE_COLOR, width=2)
elif emotion == "surprised":
# Small round 'O'
draw.ellipse((cx - mw // 3, my - 4, cx + mw // 3, my + 10), outline=BONE_COLOR, width=3, fill=BG_COLOR)
elif emotion == "bored":
# Flat line slanted down-right
draw.line((cx - mw // 2, my + 2, cx + mw // 2, my + 6), fill=BONE_COLOR, width=2)
elif emotion == "confused":
# Squiggly mouth
pts = [(cx - mw // 2 + i * (mw // 6), my + (2 if i % 2 else 6)) for i in range(7)]
draw.line(pts, fill=BONE_COLOR, width=2)
else: # neutral
draw.line((cx - mw // 2, my + 2, cx + mw // 2, my + 2), fill=BONE_COLOR, width=2)
for i in range(-1, 2):
draw.line((cx + i * 5, my - 1, cx + i * 5, my + 5), fill=BONE_COLOR, width=1)
def _draw_ribs(draw: ImageDraw.ImageDraw, neck: Tuple[float, float], pelvis: Tuple[float, float]) -> None:
# Spine goes from neck to pelvis; ribs are short arcs on each side.
nx, ny = neck
px, py = pelvis
for i in range(1, 5):
frac = i / 5.0
cy = ny + (py - ny) * frac
w = 24 - i * 2 # taper
draw.arc((nx - w, cy - 8, nx + w, cy + 8), start=200, end=340, fill=BONE_COLOR, width=2)
def _draw_pelvis(draw: ImageDraw.ImageDraw, pelvis: Tuple[float, float]) -> None:
cx, cy = pelvis
draw.arc((cx - 26, cy - 4, cx + 26, cy + 26), start=0, end=180, fill=BONE_COLOR, width=3)
def _draw_book(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
# Open book held between the two hands: a slight V-shape with two pages
# visible and ruled lines suggesting text.
cx = (lh[0] + rh[0]) / 2
cy = (lh[1] + rh[1]) / 2 - 6
half_w = max(30, abs(rh[0] - lh[0]) / 2 + 12)
page_h = 34
# Cover (red) — rounded-ish trapezoid behind the pages for depth
cover_pts = [
(cx - half_w - 4, cy + page_h // 2 + 4),
(cx + half_w + 4, cy + page_h // 2 + 4),
(cx + half_w + 2, cy - page_h // 2 - 2),
(cx - half_w - 2, cy - page_h // 2 - 2),
]
draw.polygon(cover_pts, fill=(150, 40, 50), outline=(90, 20, 30))
# Two pages, each tilted slightly outward (the V of an open book)
left_page = [
(cx, cy - page_h // 2),
(cx - half_w, cy - page_h // 2 + 4),
(cx - half_w + 2, cy + page_h // 2),
(cx, cy + page_h // 2 - 2),
]
right_page = [
(cx, cy - page_h // 2),
(cx + half_w, cy - page_h // 2 + 4),
(cx + half_w - 2, cy + page_h // 2),
(cx, cy + page_h // 2 - 2),
]
draw.polygon(left_page, fill=(245, 235, 210), outline=(160, 140, 100))
draw.polygon(right_page, fill=(245, 235, 210), outline=(160, 140, 100))
# Ruled text lines on each page
for i in range(1, 5):
ly = cy - page_h // 2 + i * 7
draw.line((cx - half_w + 8, ly, cx - 4, ly),
fill=(120, 100, 70), width=1)
draw.line((cx + 4, ly, cx + half_w - 8, ly),
fill=(120, 100, 70), width=1)
# Spine crease
draw.line((cx, cy - page_h // 2, cx, cy + page_h // 2 - 2),
fill=(120, 90, 60), width=2)
def _draw_vacuum(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
# Handle from midpoint of hands down to a "vacuum head" on the floor.
gx = (lh[0] + rh[0]) / 2
gy = (lh[1] + rh[1]) / 2
hx = gx + 10
hy = FLOOR_Y - 6
draw.line((gx, gy, hx, hy - 30), fill=(180, 180, 190), width=4)
draw.line((hx, hy - 30, hx, hy), fill=(200, 200, 210), width=5)
# Vacuum head (rectangle)
draw.rectangle((hx - 38, hy - 12, hx + 38, hy + 6),
fill=(220, 80, 90), outline=(120, 30, 40), width=2)
# Bristle strip on bottom
for bx in range(int(hx - 34), int(hx + 34), 8):
draw.line((bx, hy + 6, bx, hy + 12), fill=(210, 210, 210), width=1)
def _draw_broom(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
# A long handle from the higher hand down to bristles near the floor.
top = lh if lh[1] < rh[1] else rh
bot = rh if lh[1] < rh[1] else lh
head_x = bot[0] + 18
head_y = FLOOR_Y - 8
draw.line((top[0], top[1], head_x, head_y - 20), fill=(160, 110, 60), width=5)
# Bristle bundle
draw.polygon([(head_x - 22, head_y - 20), (head_x + 22, head_y - 20),
(head_x + 30, head_y + 14), (head_x - 30, head_y + 14)],
fill=(220, 190, 110), outline=(160, 120, 60), width=2)
# Bristle lines
for i in range(-5, 6):
draw.line((head_x + i * 4, head_y - 18, head_x + i * 5, head_y + 12),
fill=(180, 140, 80), width=1)
def _draw_pot(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
cx = (lh[0] + rh[0]) / 2
cy = max(lh[1], rh[1]) + 12
draw.rectangle((cx - 32, cy - 14, cx + 32, cy + 18),
fill=(60, 60, 70), outline=(180, 180, 200), width=2)
# Lid / rim
draw.line((cx - 36, cy - 14, cx + 36, cy - 14), fill=(200, 200, 220), width=3)
# Steam (3 wavy lines above the pot)
for dx in (-12, 0, 12):
x = cx + dx
draw.line((x, cy - 28, x + 4, cy - 40), fill=(200, 210, 220), width=2)
draw.line((x + 4, cy - 40, x, cy - 52), fill=(200, 210, 220), width=2)
def _draw_sink(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
cx = (lh[0] + rh[0]) / 2
cy = max(lh[1], rh[1]) + 8
# Basin
draw.rectangle((cx - 60, cy - 4, cx + 60, cy + 36),
fill=(220, 225, 230), outline=(160, 160, 170), width=2)
# Water
draw.rectangle((cx - 56, cy + 8, cx + 56, cy + 32), fill=(100, 160, 200))
# Bubbles
for bx, by, br in ((cx - 30, cy + 12, 4), (cx - 10, cy + 18, 3),
(cx + 20, cy + 14, 5), (cx + 40, cy + 22, 3)):
draw.ellipse((bx - br, by - br, bx + br, by + br),
outline=(230, 240, 250), width=1)
def _draw_spray_and_cloth(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
"""Spray bottle in left hand, wiping cloth in right hand."""
# Spray bottle (L hand)
lx, ly = lh
draw.rectangle((lx - 8, ly - 22, lx + 8, ly + 4),
fill=(60, 180, 200), outline=(20, 100, 120), width=2)
# Nozzle
draw.rectangle((lx + 6, ly - 20, lx + 16, ly - 12),
fill=(30, 120, 140), outline=(20, 80, 100), width=1)
# Trigger
draw.line((lx + 8, ly - 10, lx + 8, ly + 4),
fill=(20, 100, 120), width=2)
# Mist (three small drops)
for i, dx in enumerate((18, 24, 30)):
draw.ellipse((lx + dx, ly - 22 + i * 3, lx + dx + 4, ly - 18 + i * 3),
fill=(200, 230, 240))
# Cloth (R hand)
rx, ry = rh
draw.polygon([(rx - 18, ry - 2), (rx + 18, ry - 4),
(rx + 20, ry + 10), (rx - 16, ry + 12)],
fill=(230, 210, 120), outline=(160, 130, 70), width=2)
# Fold line
draw.line((rx - 4, ry + 2, rx + 6, ry + 4), fill=(160, 130, 70), width=1)
def _draw_feather_duster(draw: ImageDraw.ImageDraw, hand: Tuple[float, float]) -> None:
"""Feather duster sticking up/out from the raised hand."""
hx, hy = hand
# Handle
draw.line((hx, hy, hx + 6, hy - 18),
fill=(150, 100, 60), width=3)
# Feather bundle (fan shape)
fx, fy = hx + 6, hy - 18
for angle_deg in (-40, -20, 0, 20, 40):
angle = math.radians(angle_deg - 90)
ex = fx + 28 * math.cos(angle)
ey = fy + 28 * math.sin(angle)
draw.line((fx, fy, ex, ey), fill=(230, 180, 120), width=4)
# Tufts at the end
draw.ellipse((ex - 4, ey - 4, ex + 4, ey + 4),
fill=(250, 220, 160))
def _draw_mop(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
"""A mop: long handle down to a stringy head on the floor."""
top = lh if lh[1] < rh[1] else rh
bot = rh if lh[1] < rh[1] else lh
head_x = bot[0] + 16
head_y = FLOOR_Y - 6
draw.line((top[0], top[1], head_x, head_y - 30),
fill=(130, 180, 190), width=4)
# Mop head (rounded cluster)
draw.ellipse((head_x - 30, head_y - 32, head_x + 30, head_y + 4),
fill=(230, 225, 205), outline=(180, 175, 150), width=2)
# Strings dangling
for dx in range(-22, 23, 5):
draw.line((head_x + dx, head_y - 6, head_x + dx + 2, head_y + 14),
fill=(220, 210, 180), width=1)
# Water splashes
for sx in (head_x - 38, head_x + 36):
draw.ellipse((sx - 4, FLOOR_Y + 2, sx + 4, FLOOR_Y + 8),
outline=(130, 180, 210), width=1)
def _draw_iron_and_board(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
"""Ironing board stretching across the body + the iron in the right hand."""
# Board
board_y = max(lh[1], rh[1]) + 22
draw.polygon([(150, board_y), (380, board_y),
(360, board_y + 12), (170, board_y + 12)],
fill=(220, 215, 200), outline=(160, 150, 130), width=2)
# Board legs
draw.line((180, board_y + 12, 160, board_y + 70),
fill=(120, 120, 130), width=3)
draw.line((350, board_y + 12, 370, board_y + 70),
fill=(120, 120, 130), width=3)
# Shirt being ironed (rectangle on the board)
draw.rectangle((195, board_y - 10, 345, board_y),
fill=(180, 200, 230), outline=(120, 140, 170), width=1)
# Iron (R hand)
rx, ry = rh
draw.polygon([(rx - 20, ry - 6), (rx + 18, ry - 10),
(rx + 24, ry + 4), (rx - 18, ry + 8)],
fill=(60, 60, 70), outline=(180, 180, 200), width=2)
# Iron handle
draw.rectangle((rx - 10, ry - 18, rx + 10, ry - 6),
fill=(40, 40, 50), outline=(160, 160, 180), width=2)
# Steam
for sx in (rx - 8, rx, rx + 8):
draw.line((sx, ry - 20, sx + 2, ry - 30),
fill=(210, 220, 230), width=2)
def _draw_polish_cloth(draw: ImageDraw.ImageDraw, hand: Tuple[float, float]) -> None:
"""Polishing cloth in the hand + a sparkle on the polished surface."""
hx, hy = hand
# Cloth
draw.polygon([(hx - 14, hy - 4), (hx + 14, hy - 2),
(hx + 16, hy + 10), (hx - 12, hy + 12)],
fill=(240, 230, 180), outline=(180, 160, 110), width=2)
# Sparkles on a nearby surface
for sx, sy in ((hx - 40, hy + 20), (hx + 40, hy + 30),
(hx + 10, hy + 50)):
draw.line((sx - 4, sy, sx + 4, sy), fill=(255, 240, 180), width=1)
draw.line((sx, sy - 4, sx, sy + 4), fill=(255, 240, 180), width=1)
draw.ellipse((sx - 1, sy - 1, sx + 2, sy + 2),
fill=(255, 255, 220))
def _draw_soccer_ball(draw: ImageDraw.ImageDraw, foot: Tuple[float, float]) -> None:
"""Soccer ball near a foot."""
fx, fy = foot
bx = fx + 18
by = fy + 4
r = 14
draw.ellipse((bx - r, by - r, bx + r, by + r),
fill=(250, 250, 250), outline=(40, 40, 50), width=2)
# Pentagon pattern (5 black spots)
for dx, dy in ((0, -6), (-7, -1), (7, -1), (-4, 5), (4, 5)):
draw.polygon([(bx + dx, by + dy - 3), (bx + dx + 3, by + dy),
(bx + dx + 1, by + dy + 3), (bx + dx - 2, by + dy + 2),
(bx + dx - 3, by + dy - 1)],
fill=(30, 30, 40))
def _draw_cricket_bat(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
"""Cricket bat held by both hands — blade extending down past the hands."""
grip_x = (lh[0] + rh[0]) / 2
grip_y = (lh[1] + rh[1]) / 2
# Handle (thin, above the grip)
draw.line((grip_x, grip_y - 30, grip_x, grip_y + 4),
fill=(160, 100, 60), width=4)
# Blade
blade_top_y = grip_y + 4
blade_bot_y = min(FLOOR_Y - 8, blade_top_y + 90)
draw.rectangle((grip_x - 12, blade_top_y,
grip_x + 12, blade_bot_y),
fill=(230, 215, 170), outline=(150, 120, 70), width=2)
# Maker sticker
draw.rectangle((grip_x - 6, blade_top_y + 20,
grip_x + 6, blade_top_y + 36),
fill=(200, 40, 50))
# Wickets behind the batsman
wx = grip_x + 70
wy_top = blade_bot_y - 30
for dx in (-6, 0, 6):
draw.line((wx + dx, wy_top, wx + dx, blade_bot_y),
fill=(220, 200, 150), width=2)
draw.line((wx - 8, wy_top - 2, wx + 8, wy_top - 2),
fill=(220, 200, 150), width=2)
def _draw_basketball(draw: ImageDraw.ImageDraw, hand: Tuple[float, float]) -> None:
"""Orange basketball below the dribbling hand."""
bx, by = hand
r = 16
draw.ellipse((bx - r, by - r, bx + r, by + r),
fill=(220, 110, 40), outline=(140, 60, 20), width=2)
# Seam lines
draw.arc((bx - r, by - r, bx + r, by + r),
start=15, end=165, fill=(120, 50, 20), width=2)
draw.line((bx - r, by, bx + r, by), fill=(120, 50, 20), width=2)
draw.line((bx, by - r, bx, by + r), fill=(120, 50, 20), width=2)
def _draw_tennis_racket(draw: ImageDraw.ImageDraw, hand: Tuple[float, float]) -> None:
"""Tennis racket extending out from the hand."""
hx, hy = hand
# Handle
draw.line((hx, hy, hx + 20, hy - 24), fill=(40, 40, 50), width=4)
# Head (oval)
head_cx, head_cy = hx + 36, hy - 44
draw.ellipse((head_cx - 22, head_cy - 28, head_cx + 22, head_cy + 28),
outline=(200, 200, 210), width=4, fill=None)
# Strings
for dx in (-14, -7, 0, 7, 14):
draw.line((head_cx + dx, head_cy - 24, head_cx + dx, head_cy + 24),
fill=(220, 220, 220), width=1)
for dy in (-18, -9, 0, 9, 18):
draw.line((head_cx - 20, head_cy + dy, head_cx + 20, head_cy + dy),
fill=(220, 220, 220), width=1)
def _draw_baseball_bat(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
"""Baseball bat held with both hands, angled over the shoulder."""
grip_x = (lh[0] + rh[0]) / 2
grip_y = (lh[1] + rh[1]) / 2
# Bat extends up-right over the shoulder
end_x = grip_x + 70
end_y = grip_y - 100
# Taper: draw as a thick line for the barrel + thinner for handle
draw.line((grip_x, grip_y, end_x, end_y), fill=(160, 110, 60), width=10)
# Knob at the grip end
draw.ellipse((grip_x - 6, grip_y - 6, grip_x + 6, grip_y + 6),
fill=(120, 80, 40), outline=(60, 40, 20), width=2)
# Barrel end (thicker)
draw.ellipse((end_x - 8, end_y - 8, end_x + 8, end_y + 8),
fill=(200, 150, 90), outline=(120, 80, 40), width=2)
def _draw_golf_club(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
"""Golf club and tiny ball on tee."""
grip_x = (lh[0] + rh[0]) / 2
grip_y = (lh[1] + rh[1]) / 2
# Shaft
head_x = grip_x - 40
head_y = FLOOR_Y - 8
draw.line((grip_x, grip_y, head_x, head_y),
fill=(200, 200, 210), width=3)
# Club head (wedge)
draw.polygon([(head_x - 14, head_y - 4), (head_x + 6, head_y - 8),
(head_x + 10, head_y + 4), (head_x - 10, head_y + 6)],
fill=(60, 60, 70), outline=(20, 20, 30), width=2)
# Ball on tee
ball_x, ball_y = head_x - 40, FLOOR_Y - 6
draw.ellipse((ball_x - 5, ball_y - 5, ball_x + 5, ball_y + 5),
fill=(255, 255, 255), outline=(180, 180, 180), width=1)
draw.line((ball_x, ball_y + 5, ball_x, ball_y + 12),
fill=(200, 170, 100), width=2)
def _draw_bowling_ball(draw: ImageDraw.ImageDraw, hand: Tuple[float, float]) -> None:
"""Large bowling ball held by the swinging hand."""
bx, by = hand
r = 18
draw.ellipse((bx - r, by - r, bx + r, by + r),
fill=(80, 30, 120), outline=(40, 10, 60), width=2)
# Finger holes
for dx, dy in ((-5, -4), (2, -5), (-1, 3)):
draw.ellipse((bx + dx - 2, by + dy - 2, bx + dx + 2, by + dy + 2),
fill=(30, 10, 50))
def _draw_skateboard(draw: ImageDraw.ImageDraw, lf: Tuple[float, float], rf: Tuple[float, float]) -> None:
"""Skateboard deck + wheels under the feet."""
x1 = min(lf[0], rf[0]) - 20
x2 = max(lf[0], rf[0]) + 20
y = max(lf[1], rf[1]) + 2
# Deck (rounded rectangle)
draw.rectangle((x1, y, x2, y + 8),
fill=(120, 70, 150), outline=(60, 30, 90), width=2)
draw.ellipse((x1 - 8, y - 1, x1 + 12, y + 9),
fill=(120, 70, 150), outline=(60, 30, 90), width=2)
draw.ellipse((x2 - 12, y - 1, x2 + 8, y + 9),
fill=(120, 70, 150), outline=(60, 30, 90), width=2)
# Wheels
for wx in (x1 + 6, x2 - 6):
draw.ellipse((wx - 5, y + 8, wx + 5, y + 18),
fill=(50, 50, 50), outline=(20, 20, 20))
def _draw_controller_and_tv(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
"""Game controller in the hands + a small glowing TV in front."""
# Controller: capsule shape between the hands
cx = (lh[0] + rh[0]) / 2
cy = (lh[1] + rh[1]) / 2
# Body (rounded)
draw.rectangle((cx - 24, cy - 8, cx + 24, cy + 10),
fill=(45, 45, 55), outline=(180, 180, 200), width=2)
draw.ellipse((cx - 34, cy - 10, cx - 14, cy + 14),
fill=(45, 45, 55), outline=(180, 180, 200), width=2)
draw.ellipse((cx + 14, cy - 10, cx + 34, cy + 14),
fill=(45, 45, 55), outline=(180, 180, 200), width=2)
# D-pad (left)
draw.rectangle((cx - 26, cy - 2, cx - 20, cy + 4), fill=(200, 200, 210))
draw.rectangle((cx - 28, cy, cx - 18, cy + 2), fill=(200, 200, 210))
# A/B buttons (right)
draw.ellipse((cx + 14, cy - 4, cx + 20, cy + 2), fill=(200, 60, 60))
draw.ellipse((cx + 20, cy + 2, cx + 26, cy + 8), fill=(60, 120, 200))
# TV in front of the skeleton (bright rectangle at upper area)
tv_x1, tv_y1, tv_x2, tv_y2 = 120, 160, 392, 320
draw.rectangle((tv_x1, tv_y1, tv_x2, tv_y2),
outline=(40, 40, 50), width=8, fill=(15, 20, 35))
draw.rectangle((tv_x1 + 6, tv_y1 + 6, tv_x2 - 6, tv_y2 - 6),
fill=(40, 80, 160))
# Cartoon shapes on screen
draw.ellipse((180, 210, 240, 270), fill=(240, 220, 80))
draw.polygon([(300, 240), (340, 200), (380, 260)], fill=(220, 60, 60))
def _draw_plant_tuft(draw: ImageDraw.ImageDraw, lh: Tuple[float, float], rh: Tuple[float, float]) -> None:
# Little plant under the hands for gardening
cx = (lh[0] + rh[0]) / 2
cy = max(lh[1], rh[1]) + 20
# Dirt patch
draw.ellipse((cx - 40, cy, cx + 40, cy + 18), fill=(80, 50, 30))
# Stem + leaves
draw.line((cx, cy + 8, cx, cy - 12), fill=(60, 120, 50), width=3)
draw.polygon([(cx, cy - 4), (cx - 14, cy - 10), (cx - 4, cy - 2)],
fill=(70, 140, 60))
draw.polygon([(cx, cy - 8), (cx + 14, cy - 14), (cx + 4, cy - 4)],
fill=(70, 140, 60))
draw.polygon([(cx, cy - 12), (cx - 6, cy - 22), (cx + 6, cy - 22)],
fill=(80, 160, 70))
def draw_frame(
joints: Dict[int, Tuple[float, float]],
action: str,
emotion: str,
scene: str = "none",
) -> Image.Image:
img = Image.new("RGB", (CANVAS, CANVAS), BG_COLOR)
draw = ImageDraw.Draw(img)
# Scene backdrop (procedural — zero hallucination).
scene_fn = SCENES.get(scene)
if scene_fn is not None:
scene_fn(draw)
# Bones
for a, b in SKELETON_EDGES:
draw.line((joints[a], joints[b]), fill=BONE_COLOR, width=BONE_WIDTH)
# Ribs + pelvis bowl
_draw_ribs(draw, joints[NECK], joints[PELVIS])
_draw_pelvis(draw, joints[PELVIS])
# Joints
for jid, (x, y) in joints.items():
if jid == HEAD:
continue
draw.ellipse(
(x - JOINT_RADIUS, y - JOINT_RADIUS, x + JOINT_RADIUS, y + JOINT_RADIUS),
fill=JOINT_COLOR,
)
# Hands: angry => small filled fists
if emotion == "angry":
for hid in (LHA, RHA):
hx, hy = joints[hid]
draw.rectangle((hx - 7, hy - 7, hx + 7, hy + 7), fill=BONE_COLOR)
# Props
if action == "reading":
_draw_book(draw, joints[LHA], joints[RHA])
elif action == "vacuuming":
_draw_vacuum(draw, joints[LHA], joints[RHA])
elif action == "sweeping":
_draw_broom(draw, joints[LHA], joints[RHA])
elif action == "cooking":
_draw_pot(draw, joints[LHA], joints[RHA])
elif action == "washing":
_draw_sink(draw, joints[LHA], joints[RHA])
elif action == "gardening":
_draw_plant_tuft(draw, joints[LHA], joints[RHA])
elif action == "cleaning":
_draw_spray_and_cloth(draw, joints[LHA], joints[RHA])
elif action == "dusting":
_draw_feather_duster(draw, joints[RHA])
elif action == "mopping":
_draw_mop(draw, joints[LHA], joints[RHA])
elif action == "ironing":
_draw_iron_and_board(draw, joints[LHA], joints[RHA])
elif action == "polishing":
_draw_polish_cloth(draw, joints[RHA])
elif action == "football":
_draw_soccer_ball(draw, joints[RFT])
elif action == "cricket":
_draw_cricket_bat(draw, joints[LHA], joints[RHA])
elif action == "basketball":
_draw_basketball(draw, joints[RHA])
elif action == "tennis":
_draw_tennis_racket(draw, joints[RHA])
elif action == "baseball":
_draw_baseball_bat(draw, joints[LHA], joints[RHA])
elif action == "golf":
_draw_golf_club(draw, joints[LHA], joints[RHA])
elif action == "bowling":
_draw_bowling_ball(draw, joints[RHA])
elif action == "skateboarding":
_draw_skateboard(draw, joints[LFT], joints[RFT])
elif action == "gaming":
_draw_controller_and_tv(draw, joints[LHA], joints[RHA])
# Skull + face (drawn last so it sits above bones)
_draw_skull(draw, joints[HEAD], emotion)
return img
# ---------------------------------------------------------------------------
# Rendering loop + export
# ---------------------------------------------------------------------------
def render_gif(action: str, emotion: str, scene: str, out_path: str) -> str:
if action not in ACTIONS:
raise ValueError(f"Unknown action: {action}")
if emotion not in EMOTION_LABELS:
raise ValueError(f"Unknown emotion: {emotion}")
if scene not in SCENE_LABELS:
raise ValueError(f"Unknown scene: {scene}")
logger.info("[render] action=%s emotion=%s scene=%s frames=%d fps=%d",
action, emotion, scene, N_FRAMES, FPS)
action_fn = ACTIONS[action]
frames: list[Image.Image] = []
for i in range(N_FRAMES):
t = i / N_FRAMES
joints = action_fn(t)
joints = apply_emotion(joints, emotion, t)
img = draw_frame(joints, action, emotion, scene)
frames.append(img)
if i == 0 or i == N_FRAMES - 1:
logger.debug("[render] frame %d/%d head=%s pelvis=%s", i + 1, N_FRAMES, joints[HEAD], joints[PELVIS])
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
logger.info("[export] writing %d frames -> %s (%dx%d, %d ms/frame)",
len(frames), out_path, CANVAS, CANVAS, FRAME_MS)
frames[0].save(
out_path,
save_all=True,
append_images=frames[1:],
duration=FRAME_MS,
loop=0,
disposal=2,
optimize=False,
)
return out_path
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _default_out_path(prompt: str, out_dir: str) -> str:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
slug = re.sub(r"[^a-z0-9]+", "_", prompt.lower()).strip("_")[:40] or "skeleton"
return os.path.join(out_dir, f"{slug}-{ts}.gif")
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate a skeleton GIF from a text prompt (deterministic, no hallucination).",
)
parser.add_argument("prompt", type=str, help="e.g. 'a sad man reading a book'")
parser.add_argument("--out", "-o", type=str, default=None, help="Output .gif path or directory.")
parser.add_argument("--debug", action="store_true", help="Verbose logging.")
args = parser.parse_args()
_setup_logging(args.debug)
logger.debug("[main] argv=%s cwd=%s", sys.argv, os.getcwd())
prompt = args.prompt.strip()
if not prompt:
logger.error("Prompt cannot be empty.")
return 1
try:
action, emotion, scene = parse_prompt(prompt)
except Exception as e:
logger.error("[parse] FAILED: %s", e)
logger.debug("%s", traceback.format_exc())
return 1
# Resolve output path
project_root = Path(__file__).resolve().parent
default_dir = str(project_root / "outputs")
if args.out:
if args.out.endswith(".gif"):
out_path = args.out
else:
out_path = _default_out_path(prompt, args.out)
else:
out_path = _default_out_path(prompt, default_dir)
try:
t0 = time.time()
render_gif(action, emotion, scene, out_path)
logger.info("[done] saved %s in %.2fs", out_path, time.time() - t0)
print(out_path)
return 0
except Exception as e:
logger.error("[render] FAILED: %s", e)
logger.debug("%s", traceback.format_exc())
return 1
if __name__ == "__main__":
raise SystemExit(main())