Files
Company_Sprite_Gen/generate_sprite.py
Gabrieal Jimmy 97a27f9a42 init
2026-10-01 15:15:20 -05:00

730 lines
29 KiB
Python

#!/usr/bin/env python3
"""
Generate a Pokémon-style sprite for a business.
Pipeline: Jev picks a type -> seeded RNG picks a base creature and its variation parts
(weighted by type) -> parts snap together via anchor pixels -> markings painted on ->
palette keys swapped for colors pulled from the business logo.
Logo colors: transparent pixels, anti-aliased edges and a solid white/black/gray background
(the colour filling the image border) are ignored; hued brand colours win over white, black
or gray text. A logo that is only white/black (or only its background) still uses that.
Usage
python generate_sprite.py "Rosas Landscaping" logos/rosas.png # Jev picks the type
python generate_sprite.py "Rosas Landscaping" logos/rosas.png --type grass
python generate_sprite.py "Rosas Landscaping" --type grass --colors "#2E7D32,#FBC02D"
python generate_sprite.py --batch businesses.csv # columns: name,logo,type,description
python generate_sprite.py --make-placeholders # test art for every part
python generate_sprite.py --sprite-list SPRITES.md # art checklist + weight report
Requires: pip install pillow numpy requests
"""
import argparse
import colorsys
import csv
import hashlib
import json
import random
import re
import sys
from collections import Counter
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
import sprite_parts as cfg
CANVAS = 96 # final sprite size (square)
GROUND_MARGIN = 4 # empty rows under the feet
# Palette keys: draw every part using ONLY these exact colors (plus pass-through colors
# like eye white). They get swapped for logo-derived colors at render time.
KEYS = {
"main_light": (255, 128, 128), "main_base": (255, 0, 0), "main_dark": (128, 0, 0),
"accent_light": (128, 255, 128), "accent_base": (0, 255, 0), "accent_dark": (0, 128, 0),
"type_light": (128, 128, 255), "type_base": (0, 0, 255), "type_dark": (0, 0, 128),
"outline": (0, 0, 0),
}
PUPIL = (20, 20, 20) # near-black that is NOT recolored
EYE_WHITE = (255, 255, 255)
# Anchor marker pixels. Place one pixel of the color where a child part attaches.
# They are read, then replaced with the surrounding color.
MARKERS = {
"pivot": (255, 0, 255), "tail": (253, 0, 255), "back": (252, 0, 255),
"aura": (251, 0, 255), "head_top": (250, 0, 255),
}
PICK_ORDER = ["body", "head_top", "back", "tail", "aura"]
DEFAULT_Z = {"aura": 0, "back": 10, "tail": 20, "body": 30, "head_top": 50}
def warn(msg):
print(f" ! {msg}", file=sys.stderr)
# ============================================================ seeded picking
def make_rng(name, reroll=0):
key = f"{name.strip().lower()}|{reroll}"
return random.Random(int(hashlib.sha256(key.encode()).hexdigest()[:16], 16))
def weighted_pick(rng, items):
items = [(it, w) for it, w in items if w > 0]
total = sum(w for _, w in items)
if total <= 0:
return None
r = rng.random() * total
acc = 0.0
for it, w in items:
acc += w
if r < acc:
return it
return items[-1][0]
def part_weight(p, ptype):
return p["types"].get(ptype, p["other"])
def candidates(slot, plan, ptype, tags, excluded, denied=()):
out = []
for p in cfg.SLOTS[slot]:
if plan not in p["plans"] or p["name"] in denied:
continue
if p["requires"] and not set(p["requires"]) & tags:
continue
if set(p["tags"]) & excluded or set(p["excludes"]) & tags:
continue
out.append((p, part_weight(p, ptype)))
return out
def motif_factor(p, motifs):
"""Keep one creature's features on a theme: same motif boosted, clashing motifs damped."""
m = cfg.MOTIFS.get(p["name"])
if m is None or not motifs:
return 1.0
if m in motifs:
return cfg.MOTIF_SAME
if all(frozenset((m, o)) in cfg.MOTIF_PAIRS for o in motifs):
return 1.0
return cfg.MOTIF_CLASH
def pick_parts(ptype, rng):
plan = weighted_pick(rng, cfg.PLAN_WEIGHTS[ptype].items())
chosen, tags, excluded, denied, motifs = {}, {f"plan:{plan}"}, set(), set(), set()
features = 0
for slot in PICK_ORDER:
if slot in cfg.DISABLED_SLOTS:
continue
cands = candidates(slot, plan, ptype, tags, excluded, denied)
if slot in cfg.FEATURE_SLOTS:
if features >= cfg.MAX_FEATURES:
cands = [(p, w) for p, w in cands if p["name"] == "none"]
cands = [(p, w * motif_factor(p, motifs)) for p, w in cands]
p = weighted_pick(rng, cands)
if p is None:
continue
chosen[slot] = p
tags |= set(p["tags"])
excluded |= set(p["excludes"])
denied |= set(p.get("deny", ()))
if p["name"] != "none" and slot in cfg.FEATURE_SLOTS:
features += 1
if cfg.MOTIFS.get(p["name"]):
motifs.add(cfg.MOTIFS[p["name"]])
markings = weighted_pick(rng, [(m, w.get(ptype, w.get("_other", 0))) for m, w in cfg.MARKINGS.items()])
return plan, chosen, markings, tags
def attributes_from(tags):
"""Public attributes: drop compatibility plumbing (*_head family tags, _internal tags, plan)."""
return sorted(t for t in tags if not t.endswith("_head") and not t.startswith(("_", "plan:")))
# ============================================================ loading + compositing
_part_cache = {}
def load_part(parts_dir, slot, name):
path = Path(parts_dir) / slot / f"{name}.png"
if path in _part_cache:
return _part_cache[path]
if not path.exists():
_part_cache[path] = None
return None
arr = np.array(Image.open(path).convert("RGBA"))
rgb, alpha = arr[:, :, :3], arr[:, :, 3] > 0
anchors, marker_mask = {}, np.zeros(alpha.shape, bool)
for mname, col in MARKERS.items():
m = np.all(rgb == col, axis=-1) & alpha
ys, xs = np.nonzero(m)
if len(xs):
anchors[mname] = (int(round(xs.mean())), int(round(ys.mean())))
marker_mask |= m
# Replace marker pixels with the most common opaque, non-marker 4-neighbor
h, w = alpha.shape
for y, x in zip(*np.nonzero(marker_mask)):
neigh = [tuple(arr[ny, nx]) for ny, nx in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1))
if 0 <= ny < h and 0 <= nx < w and arr[ny, nx, 3] > 0 and not marker_mask[ny, nx]]
arr[y, x] = Counter(neigh).most_common(1)[0][0] if neigh else (0, 0, 0, 0)
result = (Image.fromarray(arr), anchors)
_part_cache[path] = result
return result
def key_mask(arr, key):
return np.all(arr[:, :, :3] == KEYS[key], axis=-1) & (arr[:, :, 3] > 0)
def apply_markings(img, kind, rng, center=None):
"""Paint accent markings onto main-ramp pixels.
center: the body's aura anchor (torso centre). When given, markings stay on the body
below the face (rows from a little above the torso centre down) and the belly patch
is centred there."""
if kind in (None, "none"):
return img
arr = np.array(img)
h, w = arr.shape[:2]
main = key_mask(arr, "main_light") | key_mask(arr, "main_base") | key_mask(arr, "main_dark")
ys, xs = np.mgrid[0:h, 0:w]
if center:
main &= ys >= center[1] - h * 0.12
if not main.any():
return img
if kind == "spots":
pat = np.zeros((h, w), bool)
pts = np.argwhere(main)
for _ in range(rng.randint(4, 8)):
y, x = pts[rng.randrange(len(pts))]
r = rng.randint(1, 3)
pat |= (xs - x) ** 2 + (ys - y) ** 2 <= r * r
elif kind == "speckles":
pat = np.random.default_rng(rng.randrange(2 ** 32)).random((h, w)) < 0.1
elif kind == "belly":
oy, ox = np.nonzero(arr[:, :, 3] > 0)
x0, x1, y0, y1 = ox.min(), ox.max(), oy.min(), oy.max()
cx, cy = center if center else ((x0 + x1) / 2, y0 + (y1 - y0) * 0.62)
rx, ry = max((x1 - x0) * 0.2, 1), max((y1 - y0) * 0.17, 1)
pat = ((xs - cx) / rx) ** 2 + ((ys - cy) / ry) ** 2 <= 1
else:
return img
mask = pat & main
for src, dst in (("main_light", "accent_light"), ("main_base", "accent_base"), ("main_dark", "accent_dark")):
arr[mask & key_mask(arr, src), :3] = KEYS[dst]
return Image.fromarray(arr)
def outline_behind(canvas, body, pos):
"""Where parts drawn behind the body (back, tail, rear ears) touch the body's silhouette,
add a second outline pixel so the creature's own shape stays readable against them."""
arr = np.array(canvas)
h, w = arr.shape[:2]
m = np.zeros((h, w), bool)
x, y = pos
m[y:y + body.height, x:x + body.width] = np.array(body)[:, :, 3] > 0
ring = np.zeros_like(m)
ring[1:] |= m[:-1]
ring[:-1] |= m[1:]
ring[:, 1:] |= m[:, :-1]
ring[:, :-1] |= m[:, 1:]
hit = ring & ~m & (arr[:, :, 3] > 0)
arr[hit] = KEYS["outline"] + (255,)
return Image.fromarray(arr)
def compose(parts_dir, chosen, markings, rng):
body = chosen["body"]
loaded = load_part(parts_dir, "body", body["name"])
if loaded is None:
raise FileNotFoundError(f"Missing body sprite: {parts_dir}/body/{body['name']}.png")
bimg, banch = loaded
bimg = apply_markings(bimg, markings, rng, banch.get("aura"))
bpiv = banch.get("pivot", (bimg.width // 2, bimg.height - 1))
bpos = (-bpiv[0], -bpiv[1])
layers = [(body["z"] or DEFAULT_Z["body"], 0, bimg, bpos)]
for i, slot in enumerate(PICK_ORDER[1:], 1):
p = chosen.get(slot)
if not p or p["name"] == "none":
continue
loaded = load_part(parts_dir, slot, p["name"])
if loaded is None:
warn(f"missing sprite {slot}/{p['name']}.png, skipped")
continue
img, anch = loaded
a = banch.get(slot)
if a is None:
warn(f"no '{slot}' anchor on {body['name']}, {p['name']} skipped")
continue
piv = anch.get("pivot", (img.width // 2, img.height // 2))
pos = (bpos[0] + a[0] - piv[0], bpos[1] + a[1] - piv[1])
layers.append((p["z"] if p["z"] is not None else DEFAULT_Z[slot], i, img, pos))
layers.sort(key=lambda l: (l[0], l[1]))
minx = min(l[3][0] for l in layers)
miny = min(l[3][1] for l in layers)
maxx = max(l[3][0] + l[2].width for l in layers)
maxy = max(l[3][1] + l[2].height for l in layers)
canvas = Image.new("RGBA", (maxx - minx, maxy - miny), (0, 0, 0, 0))
for _, i, img, (x, y) in layers:
if i == 0:
canvas = outline_behind(canvas, img, (x - minx, y - miny))
canvas.alpha_composite(img, (x - minx, y - miny))
return canvas.crop(canvas.getbbox())
def place_on_canvas(sprite):
w, h = sprite.size
size = max(CANVAS, w + 2, h + GROUND_MARGIN + 1)
if size > CANVAS:
warn(f"sprite is {w}x{h}, canvas expanded to {size}px")
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
out.alpha_composite(sprite, ((size - w) // 2, size - GROUND_MARGIN - h))
return out
def recolor(img, palette):
arr = np.array(img)
masks = {k: key_mask(arr, k) for k in palette} # compute all before writing
for k, rgb in palette.items():
arr[masks[k], :3] = rgb
return Image.fromarray(arr)
# ============================================================ colors
def hex_to_rgb(h):
h = h.strip().lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def rgb_to_hex(c):
return "#{:02X}{:02X}{:02X}".format(*c)
def to_hls(rgb):
return colorsys.rgb_to_hls(*(c / 255 for c in rgb))
def from_hls(h, l, s):
return tuple(int(round(c * 255)) for c in colorsys.hls_to_rgb(h % 1, min(max(l, 0), 1), min(max(s, 0), 1)))
def hue_toward(h, target, amount):
d = ((target - h + 0.5) % 1.0) - 0.5
return (h + max(-amount, min(amount, d))) % 1.0
def color_dist(a, b):
rmean = (a[0] + b[0]) / 2
dr, dg, db = a[0] - b[0], a[1] - b[1], a[2] - b[2]
return ((2 + rmean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rmean) / 256) * db * db) ** 0.5
def rotate_hue(rgb, amount):
h, l, s = to_hls(rgb)
return from_hls(h + amount, l, s)
def ramp(rgb):
"""light/base/dark with hue shifting: highlights lean yellow, shadows lean blue."""
h, l, s = to_hls(rgb)
l = min(max(l, 0.25), 0.75)
light = from_hls(hue_toward(h, 1 / 6, 0.03), min(l + 0.17, 0.95), s * 0.92)
base = from_hls(h, l, s)
dark = from_hls(hue_toward(h, 2 / 3, 0.035), max(l - 0.2, 0.06), s * 1.08)
return light, base, dark
def outline_color(rgb):
h, l, s = to_hls(rgb)
return from_hls(hue_toward(h, 2 / 3, 0.05), min(l * 0.35, 0.16), s * 0.8)
def _dist_to(px, rgb):
"""color_dist from many pixels (N,3 float) to one colour."""
r, g, b = rgb
rmean = (px[:, 0] + r) / 2
dr, dg, db = px[:, 0] - r, px[:, 1] - g, px[:, 2] - b
return np.sqrt((2 + rmean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rmean) / 256) * db * db)
def is_neutral(rgb):
"""White, black or gray: carries no brand hue."""
mx, mn = max(rgb), min(rgb)
light, chroma = (mx + mn) / 510, (mx - mn) / 255
return chroma < 0.12 or light > 0.93 or light < 0.08
def logo_background(arr):
"""The solid background colour of a logo, if its border is mostly one opaque colour."""
h, w = arr.shape[:2]
border = np.concatenate([arr[0], arr[-1], arr[:, 0], arr[:, -1]])
opaque = border[border[:, 3] >= 200][:, :3].astype(float)
if len(opaque) < 0.4 * len(border):
return None # transparent or busy border: no solid background
best = None
for c in opaque[:: max(1, len(opaque) // 64)]:
share = np.mean(_dist_to(opaque, c) < 40)
if best is None or share > best[1]:
best = (c, share)
if best[1] < 0.4:
return None
near = opaque[_dist_to(opaque, best[0]) < 40]
return tuple(int(v) for v in near.mean(0))
def _quantize(px, n):
q = Image.fromarray(px.astype(np.uint8).reshape(1, -1, 3), "RGB").quantize(colors=n, method=Image.Quantize.MEDIANCUT)
pal, counts = q.getpalette(), q.getcolors()
total = sum(c for c, _ in counts)
merged = []
for rgb, frac in sorted(((tuple(pal[i * 3:i * 3 + 3]), c / total) for c, i in counts), key=lambda cf: -cf[1]):
for m in merged:
if color_dist(m[0], rgb) < 40:
m[1] += frac
break
else:
merged.append([rgb, frac])
return [(tuple(c), f) for c, f in merged]
def _interior(arr, tol=24):
"""Opaque pixels whose 4 neighbours are opaque and nearly the same colour. Drops the
anti-aliased blends at shape edges, which would otherwise show up as muddy extra colours."""
rgb = arr[:, :, :3].astype(float)
ok = arr[:, :, 3] >= 200
keep = ok.copy()
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nb = np.roll(np.roll(rgb, dy, 0), dx, 1)
nok = np.roll(np.roll(ok, dy, 0), dx, 1)
keep &= nok & (np.abs(rgb - nb).max(-1) < tol)
keep[0, :] = keep[-1, :] = keep[:, 0] = keep[:, -1] = False
return keep
def extract_logo_colors(path, n=6):
"""Return [(rgb, fraction)] of a logo's brand colours, most important first.
- Transparent pixels and anti-aliased edge blends are ignored.
- A solid white/black/gray background (the colour filling most of the image border) is
removed. A coloured background is kept: it is part of the brand.
- Brand hues come first; neutral colours (white, black, gray) that are part of the logo
itself rank after every hued colour.
- If nothing but the background is left (e.g. a plain logo on its own colour), the
background colour is used rather than nothing.
"""
img = Image.open(path).convert("RGBA")
img.thumbnail((256, 256))
arr = np.array(img)
opaque = arr[:, :, 3] >= 200
if not opaque.any():
return []
inner = _interior(arr)
if inner.sum() < 0.25 * opaque.sum(): # tiny or noisy logo: use every opaque pixel
inner = opaque
px = arr[inner][:, :3].astype(float)
bg = logo_background(arr)
if bg is not None and is_neutral(bg):
fg = px[_dist_to(px, bg) >= 60]
if len(fg) < max(20, 0.002 * len(px)):
return [(bg, 1.0)]
px = fg
found = _quantize(px, n)
hued = [cf for cf in found if not is_neutral(cf[0])]
neutral = [cf for cf in found if is_neutral(cf[0])]
return sorted(hued, key=lambda cf: -cf[1]) + sorted(neutral, key=lambda cf: -cf[1])
def pick_main_accent(colors, ptype):
type_rgb = hex_to_rgb(cfg.TYPE_COLORS[ptype])
if not colors:
main = type_rgb
accent = rotate_hue(type_rgb, 0.5)
else:
main = colors[0][0]
cands = [(c, f) for c, f in colors[1:] if f >= 0.04 and color_dist(c, main) >= 90]
if cands:
# hued brand colours beat white/black/gray text for the accent
accent = max(cands, key=lambda cf: color_dist(cf[0], main) * cf[1] ** 0.5
* (0.6 if is_neutral(cf[0]) else 1.0))[0]
elif color_dist(type_rgb, main) >= 90:
accent = type_rgb
else:
accent = rotate_hue(main, 0.5)
# Keep main and accent readable against each other
(_, lm, _), (ha, la, sa) = to_hls(main), to_hls(accent)
if abs(lm - la) < 0.12 and color_dist(main, accent) < 140:
la = la + 0.22 if lm < 0.55 else la - 0.22
accent = from_hls(ha, la, sa)
return main, accent
def build_palette(main, accent, ptype):
type_rgb = hex_to_rgb(cfg.TYPE_COLORS[ptype])
# Elemental parts (leaves, flames, sparks) must read against the body: if the type colour
# is close to the main colour, push its lightness away from the main colour.
if color_dist(type_rgb, main) < 150:
(_, lm, _), (ht, lt, st) = to_hls(main), to_hls(type_rgb)
type_rgb = from_hls(ht, lm + 0.24 if lm < 0.5 else lm - 0.24, st)
pal = {}
for group, rgb in (("main", main), ("accent", accent), ("type", type_rgb)):
pal[f"{group}_light"], pal[f"{group}_base"], pal[f"{group}_dark"] = ramp(rgb)
pal["outline"] = outline_color(main)
return pal
# ============================================================ generate
def slugify(s):
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "business"
def classify(description):
try:
from business_pokemon_type import get_business_type # imported lazily so --type works offline
except ImportError:
sys.exit("business_pokemon_type.py not found next to this script; pass --type instead.")
except SystemExit:
sys.exit("No --type given and OPENROUTER_API_KEY is not set: export it or pass --type <type>.")
ans = get_business_type(description)
return ans["choice"].lower(), ans.get("probabilities")
def generate(name, parts_dir, out_dir, ptype=None, description=None, logo=None, colors=None,
reroll=0, scale=4):
probs = None
if not ptype:
ptype, probs = classify(description or name)
ptype = ptype.lower()
if ptype not in cfg.PLAN_WEIGHTS:
sys.exit(f"Unknown type '{ptype}'. Options: {', '.join(cfg.TYPES)}")
rng = make_rng(name, reroll)
plan, chosen, markings, tags = pick_parts(ptype, rng)
if colors:
logo_colors = [(hex_to_rgb(c), 1.0) for c in colors]
elif logo:
logo_colors = extract_logo_colors(logo)
if not logo_colors:
warn("no usable colors in logo, falling back to type color")
else:
logo_colors = []
main, accent = pick_main_accent(logo_colors, ptype)
palette = build_palette(main, accent, ptype)
sprite = recolor(place_on_canvas(compose(parts_dir, chosen, markings, rng)), palette)
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
slug = slugify(name) + (f"-r{reroll}" if reroll else "")
sprite.save(out_dir / f"{slug}.png")
if scale > 1:
sprite.resize((sprite.width * scale, sprite.height * scale), Image.NEAREST).save(out_dir / f"{slug}@{scale}x.png")
info = {
"name": name, "type": ptype, "type_probabilities": probs, "plan": plan,
"parts": {s: p["name"] for s, p in chosen.items()}, "markings": markings,
"attributes": attributes_from(tags),
"colors": {"main": rgb_to_hex(main), "accent": rgb_to_hex(accent),
"type": cfg.TYPE_COLORS[ptype], "outline": rgb_to_hex(palette["outline"])},
}
(out_dir / f"{slug}.json").write_text(json.dumps(info, indent=2))
print(f"{name}: {ptype.upper()} {plan} | {', '.join(info['attributes']) or 'no attributes'} | "
f"main {info['colors']['main']} accent {info['colors']['accent']} -> {out_dir / slug}.png")
return info
# ============================================================ placeholders
BASE_ANCHORS = {"pivot": (0.5, 1.0), "head_top": (0.35, 0.08), "back": (0.7, 0.35), "tail": (0.9, 0.7),
"aura": (0.5, 0.55)}
CHILD_ANCHORS = {
"head_top": {"pivot": (0.5, 1.0)}, "back": {"pivot": (0.5, 0.85)}, "tail": {"pivot": (0.05, 0.6)},
"aura": {"pivot": (0.5, 0.5)},
}
DEFAULT_SIZE = {"head_top": (16, 14), "back": (40, 30), "tail": (26, 14), "aura": (84, 84)}
def anchors_for(slot, p):
return BASE_ANCHORS if slot == "body" else CHILD_ANCHORS[slot]
def size_for(slot, p):
return tuple(p["size"] or DEFAULT_SIZE[slot])
def _shaded(d, box, pal):
x0, y0, x1, y1 = [int(v) for v in box]
w, h = x1 - x0, y1 - y0
d.ellipse((x0, y0, x1, y1), fill=KEYS[f"{pal}_dark"], outline=KEYS["outline"])
if w > 4 and h > 4:
d.ellipse((x0 + 1, y0 + 1, x1 - max(2, int(w * 0.2)), y1 - max(2, int(h * 0.25))), fill=KEYS[f"{pal}_base"])
d.ellipse((x0 + int(w * 0.2), y0 + int(h * 0.15), x0 + int(w * 0.4), y0 + int(h * 0.3)), fill=KEYS[f"{pal}_light"])
def _rect(d, box, pal):
d.rectangle([int(v) for v in box], fill=KEYS[f"{pal}_base"], outline=KEYS["outline"])
def draw_placeholder(slot, p):
w, h = size_for(slot, p)
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
pal = p["palette"]
if slot == "body": # base creature: round body with a big head and two dot eyes
_shaded(d, (w * 0.3, h * 0.45, w - 1, h - 1), pal)
_shaded(d, (0, 0, w * 0.65, h * 0.7), pal)
for ex in (0.2, 0.42):
d.rectangle((int(w * ex), int(h * 0.3), int(w * ex) + 1, int(h * 0.3) + 2), fill=PUPIL)
elif slot == "head_top":
d.polygon([(0, h - 1), (w // 2, 0), (w - 1, h - 1)], fill=KEYS[f"{pal}_base"], outline=KEYS["outline"])
d.line([(w // 2, 2), (w // 2, h - 3)], fill=KEYS[f"{pal}_light"])
elif slot == "aura":
rng = random.Random(p["name"])
for _ in range(14):
x, y = rng.randrange(4, w - 8), rng.randrange(4, h - 8)
r = rng.randint(2, 3)
d.ellipse((x, y, x + r * 2, y + r * 2), fill=KEYS["type_base"], outline=KEYS["type_dark"])
else:
_shaded(d, (0, 0, w - 1, h - 1), pal)
for mname, (fx, fy) in anchors_for(slot, p).items():
img.putpixel((min(int(fx * w), w - 1), min(int(fy * h), h - 1)), MARKERS[mname] + (255,))
return img
def make_placeholders(parts_dir, overwrite=False):
made = 0
for slot, parts in cfg.SLOTS.items():
(Path(parts_dir) / slot).mkdir(parents=True, exist_ok=True)
for p in parts:
if p["name"] == "none":
continue
path = Path(parts_dir) / slot / f"{p['name']}.png"
if path.exists() and not overwrite:
continue
draw_placeholder(slot, p).save(path)
made += 1
print(f"Wrote {made} placeholder sprites to {parts_dir}/")
# ============================================================ sprite list + weight report
def simulate(ptype, n=3000):
plans, slots, marks = Counter(), {s: Counter() for s in PICK_ORDER}, Counter()
for i in range(n):
plan, chosen, m, _ = pick_parts(ptype, random.Random(i))
plans[plan] += 1
marks[m] += 1
for s in PICK_ORDER:
slots[s][chosen[s]["name"] if s in chosen else "none"] += 1
return plans, slots, marks
def pct(counter, n, top=None):
return ", ".join(f"{k} {v / n:.0%}" for k, v in counter.most_common(top) if v / n >= 0.01)
def write_sprite_list(path):
L = ["# Sprite checklist", ""]
total = sum(1 for parts in cfg.SLOTS.values() for p in parts if p["name"] != "none")
L += [f"**{total} sprites** across {len(cfg.SLOTS)} slots. Save each as `parts/<slot>/<name>.png`.", "",
"## Art rules", "",
f"- Final canvas is {CANVAS}x{CANVAS}. Sizes below are suggested part canvases; crop tight with transparency.",
"- Characters face left in a 3/4 view, light from the top-left. 1px outline, no anti-aliasing, no semi-transparent pixels.",
"- Draw only with the palette keys below. Any other color (eye white, pupils) is left as-is.", ""]
L += ["| Key | Hex | Use |", "|---|---|---|"]
uses = {"main": "body / most fur and skin", "accent": "horns, wings, beaks, crests, shells",
"type": "elemental bits: flames, leaves, sparks, ice, water"}
for k, v in KEYS.items():
L.append(f"| {k} | {rgb_to_hex(v)} | {'outline (recolored)' if k == 'outline' else uses[k.split('_')[0]]} |")
L += [f"| pupil | {rgb_to_hex(PUPIL)} | not recolored |", f"| eye white | {rgb_to_hex(EYE_WHITE)} | not recolored |", ""]
L += ["### Anchor marker pixels", "",
"One pixel each. `pivot` is where the part attaches to its parent; the others are where children attach.", "",
"| Marker | Hex | Needed in |", "|---|---|---|",
f"| pivot | {rgb_to_hex(MARKERS['pivot'])} | every part (bodies: bottom center between the feet) |",
f"| head_top | {rgb_to_hex(MARKERS['head_top'])} | all bodies (top of the head) |",
f"| tail | {rgb_to_hex(MARKERS['tail'])} | all bodies |",
f"| back | {rgb_to_hex(MARKERS['back'])} | all bodies |",
f"| aura | {rgb_to_hex(MARKERS['aura'])} | all bodies (center of mass; belly patch centre) |", ""]
for slot in ["body", "head_top", "back", "tail", "aura"]:
parts = [p for p in cfg.SLOTS[slot] if p["name"] != "none"]
L += [f"## {slot} ({len(parts)})", "", "| Done | File | Size | Palette | Plans | Description | Most likely for |",
"|---|---|---|---|---|---|---|"]
for p in parts:
top = sorted(cfg.TYPES, key=lambda t: -part_weight(p, t))[:3]
top = ", ".join(t for t in top if part_weight(p, t) > 0)
plans = "all" if p["plans"] == cfg.ALL else ", ".join(p["plans"])
w, h = size_for(slot, p)
L.append(f"| [ ] | `{slot}/{p['name']}.png` | {w}x{h} | {p['palette']} | {plans} | {p['desc']} | {top} |")
L.append("")
L += ["## Simulated pick rates per type", "",
"From 3,000 random seeds per type. Use this to spot parts that never show up or show up too much.", ""]
for t in cfg.TYPES:
n = 3000
plans, slots, marks = simulate(t, n)
L += [f"### {t.capitalize()}", "", f"- **Body plan:** {pct(plans, n)}"]
for s in PICK_ORDER:
L.append(f"- **{s}:** {pct(slots[s], n, 8)}")
L += [f"- **markings:** {pct(marks, n)}", ""]
Path(path).write_text("\n".join(L))
print(f"Wrote {path}")
# ============================================================ CLI
def main():
ap = argparse.ArgumentParser(description="Generate Pokémon-style business sprites")
ap.add_argument("name", nargs="?", help="Business name (also the random seed)")
ap.add_argument("logo_file", nargs="?", help="Logo image to pull colors from (same as --logo)")
ap.add_argument("--desc", help="Business description for Jev (defaults to the name)")
ap.add_argument("--type", help="Skip Jev and use this type")
ap.add_argument("--logo", help="Logo image to pull colors from")
ap.add_argument("--colors", help='Manual colors, e.g. "#2E7D32,#FBC02D" (main first)')
ap.add_argument("--reroll", type=int, default=0, help="Alternate creature for the same business")
ap.add_argument("--parts", default="parts", help="Parts folder")
ap.add_argument("--out", default="sprites", help="Output folder")
ap.add_argument("--scale", type=int, default=4, help="Also save an upscaled copy (1 to disable)")
ap.add_argument("--batch", help="CSV with columns name,logo,type,description (only name required)")
ap.add_argument("--make-placeholders", action="store_true", help="Create test art for every part")
ap.add_argument("--overwrite", action="store_true", help="With --make-placeholders, replace existing files")
ap.add_argument("--sprite-list", metavar="PATH", help="Write the art checklist and weight report")
a = ap.parse_args()
if a.make_placeholders:
make_placeholders(a.parts, a.overwrite)
if a.sprite_list:
write_sprite_list(a.sprite_list)
colors = a.colors.split(",") if a.colors else None
if a.batch:
with open(a.batch, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if not (row.get("name") or "").strip():
continue
generate(row["name"].strip(), a.parts, a.out, ptype=(row.get("type") or "").strip() or None,
description=(row.get("description") or "").strip() or None,
logo=(row.get("logo") or "").strip() or None, scale=a.scale)
elif a.name:
logo = a.logo or a.logo_file
if logo and not Path(logo).exists():
sys.exit(f"Logo not found: {logo}")
generate(a.name, a.parts, a.out, a.type, a.desc, logo, colors, a.reroll, a.scale)
elif not (a.make_placeholders or a.sprite_list):
ap.print_help()
if __name__ == "__main__":
main()