730 lines
29 KiB
Python
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()
|