#!/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 .") 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//.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()