Pixellab based system

This commit is contained in:
=
2026-10-01 22:46:04 -05:00
parent 97a27f9a42
commit 0e905a2824
650 changed files with 10879 additions and 113 deletions

View File

@@ -6,6 +6,9 @@ Pipeline: Jev picks a type -> seeded RNG picks a base creature and its variation
(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.
@@ -14,8 +17,9 @@ 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" logos/rosas.png --compare # both libraries side by side
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 --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
@@ -24,6 +28,7 @@ import argparse
import colorsys
import csv
import hashlib
import importlib
import json
import random
import re
@@ -34,10 +39,20 @@ from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
import sprite_parts as cfg
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])
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.
@@ -50,6 +65,18 @@ KEYS = {
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 = {
@@ -90,9 +117,9 @@ def part_weight(p, ptype):
return p["types"].get(ptype, p["other"])
def candidates(slot, plan, ptype, tags, excluded, denied=()):
def candidates(lib, slot, plan, ptype, tags, excluded, denied=()):
out = []
for p in cfg.SLOTS[slot]:
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:
@@ -103,30 +130,37 @@ def candidates(slot, plan, ptype, tags, excluded, denied=()):
return out
def motif_factor(p, motifs):
def motif_factor(lib, p, motifs):
"""Keep one creature's features on a theme: same motif boosted, clashing motifs damped."""
m = cfg.MOTIFS.get(p["name"])
m = lib.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 lib.MOTIF_SAME
if all(frozenset((m, o)) in lib.MOTIF_PAIRS for o in motifs):
return 1.0
return cfg.MOTIF_CLASH
return lib.MOTIF_CLASH
def pick_parts(ptype, rng):
plan = weighted_pick(rng, cfg.PLAN_WEIGHTS[ptype].items())
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 cfg.DISABLED_SLOTS:
if slot in lib.DISABLED_SLOTS:
continue
cands = candidates(slot, plan, ptype, tags, excluded, denied)
if slot in cfg.FEATURE_SLOTS:
if features >= cfg.MAX_FEATURES:
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(p, motifs)) for p, w in cands]
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
@@ -134,11 +168,11 @@ def pick_parts(ptype, rng):
tags |= set(p["tags"])
excluded |= set(p["excludes"])
denied |= set(p.get("deny", ()))
if p["name"] != "none" and slot in cfg.FEATURE_SLOTS:
if p["name"] != "none" and slot in lib.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()])
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
@@ -281,13 +315,13 @@ def compose(parts_dir, chosen, markings, rng):
return canvas.crop(canvas.getbbox())
def place_on_canvas(sprite):
def place_on_canvas(sprite, lib):
w, h = sprite.size
size = max(CANVAS, w + 2, h + GROUND_MARGIN + 1)
if size > CANVAS:
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 - GROUND_MARGIN - h))
out.alpha_composite(sprite, ((size - w) // 2, size - lib.GROUND_MARGIN - h))
return out
@@ -445,7 +479,7 @@ def extract_logo_colors(path, n=6):
def pick_main_accent(colors, ptype):
type_rgb = hex_to_rgb(cfg.TYPE_COLORS[ptype])
type_rgb = hex_to_rgb(TYPE_COLORS[ptype])
if not colors:
main = type_rgb
accent = rotate_hue(type_rgb, 0.5)
@@ -469,7 +503,7 @@ def pick_main_accent(colors, ptype):
def build_palette(main, accent, ptype):
type_rgb = hex_to_rgb(cfg.TYPE_COLORS[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:
@@ -499,17 +533,23 @@ def classify(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):
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 cfg.PLAN_WEIGHTS:
sys.exit(f"Unknown type '{ptype}'. Options: {', '.join(cfg.TYPES)}")
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=""):
"""Render one sprite with library `lib`; writes <slug><suffix>.png/.json (+ upscaled copy).
Returns (info, sprite)."""
rng = make_rng(name, reroll)
plan, chosen, markings, tags = pick_parts(ptype, rng)
plan, chosen, markings, tags = pick_parts(lib, ptype, rng)
if colors:
logo_colors = [(hex_to_rgb(c), 1.0) for c in colors]
@@ -522,26 +562,53 @@ def generate(name, parts_dir, out_dir, ptype=None, description=None, logo=None,
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)
sprite = recolor(place_on_canvas(compose(parts_dir or lib.PARTS_DIR, chosen, markings, rng), lib), palette)
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
slug = slugify(name) + (f"-r{reroll}" if reroll else "")
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")
info = {
"name": name, "type": ptype, "type_probabilities": probs, "plan": plan,
"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": cfg.TYPE_COLORS[ptype], "outline": rgb_to_hex(palette["outline"])},
"type": 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'} | "
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
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
@@ -603,9 +670,9 @@ def draw_placeholder(slot, p):
return img
def make_placeholders(parts_dir, overwrite=False):
def make_placeholders(lib, parts_dir, overwrite=False):
made = 0
for slot, parts in cfg.SLOTS.items():
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":
@@ -620,10 +687,10 @@ def make_placeholders(parts_dir, overwrite=False):
# ============================================================ sprite list + weight report
def simulate(ptype, n=3000):
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(ptype, random.Random(i))
plan, chosen, m, _ = pick_parts(lib, ptype, random.Random(i))
plans[plan] += 1
marks[m] += 1
for s in PICK_ORDER:
@@ -635,20 +702,23 @@ 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`.", "",
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}/<slot>/<name>.png`.", "",
"## Art rules", "",
f"- Final canvas is {CANVAS}x{CANVAS}. Sizes below are suggested part canvases; crop tight with transparency.",
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) is left as-is.", ""]
"- 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 |", ""]
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 |", "|---|---|---|",
@@ -663,18 +733,18 @@ def write_sprite_list(path):
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 = 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 p["plans"] == cfg.ALL else ", ".join(p["plans"])
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 cfg.TYPES:
for t in TYPES:
n = 3000
plans, slots, marks = simulate(t, n)
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)}")
@@ -685,6 +755,18 @@ def write_sprite_list(path):
# ============================================================ CLI
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)")
@@ -694,7 +776,11 @@ def main():
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("--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: <slug>-<library>.png plus <slug>-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("--batch", help="CSV with columns name,logo,type,description (only name required)")
@@ -702,25 +788,27 @@ def main():
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(a.parts, a.overwrite)
make_placeholders(lib, a.parts or lib.PARTS_DIR, a.overwrite)
if a.sprite_list:
write_sprite_list(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
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)
run_one(a, row["name"].strip(), (row.get("type") or "").strip() or None,
(row.get("description") or "").strip() or None, (row.get("logo") or "").strip() or None)
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)
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()