#!/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. Two part libraries share this pipeline (see LIBRARIES): "pixellab" (64x64, art generated with PixelLab, sprite_parts_pl.py) and "classic" (96x96, hand-written grids, sprite_parts.py). 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 "Rosas Landscaping" --domain rosaslandscaping.com # logo from the website python generate_sprite.py "Rosas Landscaping" logos/rosas.png --compare # both libraries side by side python generate_sprite.py --batch businesses.csv # columns: name,logo,domain,type,description python generate_sprite.py --library classic --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 importlib 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 from sprite_parts import TYPE_COLORS, TYPES # Part libraries: registry module per library. Each defines NAME, CANVAS (final sprite size), # GROUND_MARGIN (empty rows under the feet), PARTS_DIR, and the picking tables (BODY, SLOTS, # PLAN_WEIGHTS, MARKINGS, MOTIFS, ...) documented in sprite_parts.py. LIBRARIES = {"pixellab": "sprite_parts_pl", "classic": "sprite_parts"} DEFAULT_LIBRARY = "pixellab" def load_library(name): if name not in LIBRARIES: sys.exit(f"Unknown library '{name}'. Options: {', '.join(LIBRARIES)}") return importlib.import_module(LIBRARIES[name]) # 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) # Fixed-color ramps (light, base, dark) for elemental features that keep their own colors # whatever the brand: fire stays yellow/orange/red, ice stays icy. Like eye white they are # never recolored; parts that use them are type-locked in the library registry. FIXED = { "fire": ((255, 230, 107), (255, 159, 46), (226, 70, 43)), "bluefire": ((214, 246, 255), (92, 200, 255), (47, 107, 232)), "ice": ((242, 252, 255), (168, 230, 247), (90, 169, 214)), "electric": ((255, 246, 168), (255, 217, 59), (224, 162, 26)), "ghostfire": ((240, 210, 255), (185, 123, 240), (116, 69, 184)), } FIXED_COLORS = {c for ramp in FIXED.values() for c in ramp} # 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(lib, slot, plan, ptype, tags, excluded, denied=()): out = [] for p in lib.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(lib, p, motifs): """Keep one creature's features on a theme: same motif boosted, clashing motifs damped.""" m = lib.MOTIFS.get(p["name"]) if m is None or not motifs: return 1.0 if m in motifs: return lib.MOTIF_SAME if all(frozenset((m, o)) in lib.MOTIF_PAIRS for o in motifs): return 1.0 return lib.MOTIF_CLASH def pick_parts(lib, ptype, rng): """Seeded pick of body plan, body, feature parts and markings. A library may define fits(body, picks) to drop candidates that would push the composite past its canvas.""" plan = weighted_pick(rng, lib.PLAN_WEIGHTS[ptype].items()) chosen, tags, excluded, denied, motifs = {}, {f"plan:{plan}"}, set(), set(), set() features = 0 fits = getattr(lib, "fits", None) for slot in PICK_ORDER: if slot in lib.DISABLED_SLOTS: continue cands = candidates(lib, slot, plan, ptype, tags, excluded, denied) if slot in lib.FEATURE_SLOTS: if features >= lib.MAX_FEATURES: cands = [(p, w) for p, w in cands if p["name"] == "none"] cands = [(p, w * motif_factor(lib, p, motifs)) for p, w in cands] if fits: picked = {s: q for s, q in chosen.items() if s != "body"} cands = [(p, w) for p, w in cands if p["name"] == "none" or fits(chosen["body"], {**picked, slot: p})] 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 lib.FEATURE_SLOTS: features += 1 if lib.MOTIFS.get(p["name"]): motifs.add(lib.MOTIFS[p["name"]]) markings = weighted_pick(rng, [(m, w.get(ptype, w.get("_other", 0))) for m, w in lib.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, lib): w, h = sprite.size size = max(lib.CANVAS, w + 2, h + lib.GROUND_MARGIN + 1) if size > lib.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 - lib.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(logo, n=6): """Return [(rgb, fraction)] of a logo's brand colours, most important first. `logo` is a path or a PIL image. - 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 = (logo.copy() if isinstance(logo, Image.Image) else Image.open(logo)).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(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(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 resolve_type(name, ptype=None, description=None): """(type, probabilities). Jev classifies when no type is given.""" probs = None if not ptype: ptype, probs = classify(description or name) ptype = ptype.lower() if ptype not in TYPES: sys.exit(f"Unknown type '{ptype}'. Options: {', '.join(TYPES)}") return ptype, probs def generate(lib, name, out_dir, ptype, probs=None, logo=None, colors=None, reroll=0, scale=4, parts_dir=None, suffix="", write_files=True): """Render one sprite with library `lib`; writes .png/.json (+ upscaled copy) into `out_dir` and prints a summary line. With write_files=False nothing is written or printed (out_dir may be None): the caller gets the image and info. `logo` is a path or a PIL image. Returns (info, sprite).""" rng = make_rng(name, reroll) plan, chosen, markings, tags = pick_parts(lib, 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 or lib.PARTS_DIR, chosen, markings, rng), lib), palette) info = { "name": name, "library": lib.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": TYPE_COLORS[ptype], "outline": rgb_to_hex(palette["outline"])}, } if not write_files: return info, sprite out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) slug = slugify(name) + (f"-r{reroll}" if reroll else "") + suffix 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") (out_dir / f"{slug}.json").write_text(json.dumps(info, indent=2)) print(f"{name} [{lib.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, sprite COMPARE_SIZE = 384 # each sprite is scaled by a whole factor to this height in the comparison def write_compare(path, title, rendered): """Side-by-side sheet. rendered: [(info, sprite)], one per library.""" pad, label = 16, 28 cells = [] for info, spr in rendered: k = max(1, COMPARE_SIZE // spr.height) big = spr.resize((spr.width * k, spr.height * k), Image.NEAREST) cells.append((f"{info['library']} ({spr.width}x{spr.height}): {info['plan']}, " f"{', '.join(n for s, n in info['parts'].items() if s != 'body' and n != 'none') or 'no extras'}", big)) w = sum(c.width for _, c in cells) + pad * (len(cells) + 1) h = max(c.height for _, c in cells) + pad * 2 + label * 2 sheet = Image.new("RGBA", (w, h), (232, 232, 226, 255)) d = ImageDraw.Draw(sheet) d.text((pad, pad // 2), title, fill=(30, 30, 30, 255)) x = pad for text, big in cells: d.text((x, pad // 2 + label), text, fill=(60, 60, 60, 255)) sheet.alpha_composite(big, (x, pad + label * 2)) x += big.width + pad sheet.save(path) print(f"comparison -> {path}") # ============================================================ 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(lib, parts_dir, overwrite=False): made = 0 for slot, parts in lib.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(lib, 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(lib, 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(lib, path): L = [f"# Sprite checklist ({lib.NAME} library)", ""] total = sum(1 for parts in lib.SLOTS.values() for p in parts if p["name"] != "none") L += [f"**{total} sprites** across {len(lib.SLOTS)} slots. Save each as `{lib.PARTS_DIR}//.png`.", "", "## Art rules", "", f"- Final canvas is {lib.CANVAS}x{lib.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, fixed ramps) 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 |"] for name, shades in FIXED.items(): L.append(f"| {name} (light, base, dark) | {', '.join(rgb_to_hex(c) for c in shades)} | fixed color, not recolored |") L.append("") 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(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 set(p["plans"]) == set(lib.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 TYPES: n = 3000 plans, slots, marks = simulate(lib, 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 logo_for_domain(a, domain): """Logo found from the business's website (logo_fetch.py, cached in --logo-cache), or None.""" from logo_fetch import fetch_logo_info path, method = fetch_logo_info(domain, a.logo_cache) if path: print(f"logo for {domain}: {path} ({method})") else: warn(f"no logo found for {domain}, using the type colors") return path def run_one(a, name, ptype=None, description=None, logo=None, colors=None, reroll=0): """Render `name` with --library, or with every library plus a comparison sheet (--compare).""" ptype, probs = resolve_type(name, ptype, description) if not a.compare: generate(load_library(a.library), name, a.out, ptype, probs, logo, colors, reroll, a.scale, a.parts) return rendered = [generate(load_library(n), name, a.out, ptype, probs, logo, colors, reroll, a.scale, suffix=f"-{n}") for n in LIBRARIES] slug = slugify(name) + (f"-r{reroll}" if reroll else "") write_compare(Path(a.out) / f"{slug}-compare.png", f"{name} | type: {ptype}", rendered) 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("--library", choices=list(LIBRARIES), default=DEFAULT_LIBRARY, help=f"Part library (default {DEFAULT_LIBRARY})") ap.add_argument("--compare", action="store_true", help="Render with every library: -.png plus -compare.png side by side") ap.add_argument("--parts", help="Parts folder (default: the library's own)") 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("--domain", help="Business website: find its logo there when no logo file is given") ap.add_argument("--logo-cache", default="logos", help="Folder for logos found with --domain (default logos/)") ap.add_argument("--batch", help="CSV with columns name,logo,domain,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.compare and a.parts: ap.error("--parts picks one library's folder; it can't be combined with --compare") lib = load_library(a.library) if a.make_placeholders: make_placeholders(lib, a.parts or lib.PARTS_DIR, a.overwrite) if a.sprite_list: write_sprite_list(lib, 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 logo = (row.get("logo") or "").strip() or None domain = (row.get("domain") or "").strip() if not logo and domain: logo = logo_for_domain(a, domain) run_one(a, row["name"].strip(), (row.get("type") or "").strip() or None, (row.get("description") or "").strip() or None, logo) elif a.name: logo = a.logo or a.logo_file if logo and not Path(logo).exists(): sys.exit(f"Logo not found: {logo}") if not logo and not colors and a.domain: logo = logo_for_domain(a, a.domain) run_one(a, a.name, a.type, a.desc, logo, colors, a.reroll) elif not (a.make_placeholders or a.sprite_list): ap.print_help() if __name__ == "__main__": main()