Files
Company_Sprite_Gen/pixellab_library.py
2026-10-02 07:01:10 -05:00

1003 lines
46 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Build the PixelLab part library (64x64 sprites, registry: sprite_parts_pl.py).
Each base creature has a spec, pixellab_library/<body>.json. The base is generated once with
PixelLab's pixen model; every part is an edit of that exact image ("add horns ...") made with
edit-image-pixen, and the part is the pixel difference between the edit and the base. All art
is drawn in a stand-in palette (teal body = main, crimson = accent, gold = type) that maps to
the palette keys, so the generator can recolor it with brand colors.
Workflow
python pixellab_library.py generate pl_fox --base-only # base only (pixellab_src/pl_fox/base.png): review it first
python pixellab_library.py generate pl_fox # every missing edit
python pixellab_library.py extract pl_fox # grids parts_pl_src/, PNGs parts_pl/, diffs preview/pixellab/diff/
python pixellab_library.py preview pl_fox # preview/pixellab/body_pl_fox.png
python pixellab_library.py generate pl_fox --parts pl_tail_flame --force # redo one edit (new seed!)
python pixellab_library.py preview --cross # every part on every body: preview/pixellab/cross_<slot>.png
python pixellab_library.py preview --random 60 # real random picks: preview/pixellab/random.png
python pixellab_library.py cost # spend so far (ledger) and live balance
python validate_parts.py --library pixellab # colors, markers, canvas fit
Spec (pixellab_library/<body>.json)
{
"name": "pl_fox", # unique, "pl_" prefix; also the body part name
"plan": "quadruped", # quadruped biped serpentine avian aquatic blob insectoid
"head": ["mammal_head"], # head-family tags; head_top parts gate on them via "requires"
"types": {"fire": 5, "dragon": 3}, # body weight per type; unlisted types get BODY_OTHER (0.15)
"tags": [], "excludes": [], "deny": [], # as in sprite_parts.py (excludes: tags of parts it can't take)
"slots": ["head_top", "back", "tail"], # feature slots this body accepts (default all three)
"desc": "Chibi fox, 3/4 view",
"prompt": "chibi fox creature with ...", # body only; BASE_STYLE (colors, framing) is appended
"size": 48, # pixen canvas, multiple of 4; the creature is re-placed on 64x64
"seed": 11, "direction": "south", "detail": "medium detail",
"anchors": {"tail": [40, 50]}, # optional, pixel coords on the 64x64 base
"probe_seeds": {"tail": 7}, # seeds for probe edits (see below)
"variants": [{ # same creature idea, visibly different body: an edit of this
"name": "pl_fox_b", # base, so it inherits plan/head/tags/slots and the anchors
"edit": "make it chubbier with crimson stripes", "seed": 1,
"desc": "...", "types": {...}, # types optional (default: the parent's)
"skip": false
}],
"parts": [{
"slot": "tail", "name": "pl_tail_flame",
"edit": "add a long tail ending in a big orange and yellow flame, curling up on the right side",
"seed": 3, # change it to get a different edit
"types": {"fire": 6}, "other": 0, # picking weight per type / for other types
"plans": "all", # or a list of plans it may go on
"requires": [], "tags": ["tailed", "fiery"], "excludes": [], "motif": "fire",
"palette": "type", # main | accent | type: the ramp it mostly uses (for previews)
"fixed": "fire", # optional fixed-color ramp (generate_sprite.FIXED); needs types + other 0
"fixed_from": ["accent", "type"], # ramps folded into the fixed ramp (default accent, type)
"z": null, # draw order override (back 10, tail 20, body 30, head_top 50)
"deny_bodies": [], # bodies it must never go on
"box": [x0, y0, x1, y1], "chars": "xyz", "min_area": 8, # optional diff filters
"skip": false # keep the spec entry but leave the part out of the library
}]
}
Probes: every accepted slot with no real part gets a probe edit (a standard gold horn pair, wing
pair or tail; PROBE_EDITS) that extract uses only to find that slot's anchor. A new concept
therefore needs no parts: base + 3 probes, plus 1 edit per variant.
Conventions every base and edit must follow so parts mix across bodies
- Front or 3/4 view facing the viewer (direction south / south-west), feet at the bottom.
- Tails come out on the image's RIGHT side. Wings and horns are symmetric pairs.
- Body in teal, details crimson; generic elemental features gold (type ramp, recolored per
type); fixed-color features in their natural colors (orange/yellow fire, pale ice, ...).
- The base creature fills its pixen canvas; at 64x64 it should leave room for features
(height <= ~46px, width <= ~48px).
"""
import argparse
import importlib
import json
import os
import random
import sys
import zlib
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import numpy as np
from PIL import Image, ImageColor, ImageDraw
import build_parts as bp
import generate_sprite as gs
import sprite_parts_pl as reg
from sprite_parts import TYPES
SPEC_DIR = reg.SPEC_DIR
SRC_DIR = Path("pixellab_src")
GRID_DIR = Path("parts_pl_src")
PARTS_DIR = Path(reg.PARTS_DIR)
PREVIEW_DIR = Path("preview/pixellab")
CANVAS, GROUND_MARGIN = reg.CANVAS, reg.GROUND_MARGIN
SLOTS = reg.FEATURE_SLOTS
# No franchise names here: "pokemon-style" made pixen copy existing designs (a Bulbasaur fox).
BASE_STYLE = (", original cute monster creature design, chibi proportions, teal body with darker teal shading, "
"small crimson details, big expressive eyes, full body, centered, retro handheld game sprite")
EDIT_SUFFIX = ", keep everything else exactly the same"
# Probe edits: a standard feature per slot, made on bodies that have no real part in that slot,
# only to find where that slot attaches. Probes are never added to the library.
PROBE_EDITS = {
"head_top": "add a pair of small gold horns on top of its head",
"back": "add a pair of small gold feathered wings spread from its back",
"tail": "add a long thick gold tail coming out from its body on the right side",
}
# Stand-in palette: what PixelLab draws in, mapped to the legend characters of build_parts.py
STANDIN = {
"main": ("123", ("#9FE0D0", "#3E9C8F", "#1F5560")), # teal
"accent": ("abc", ("#F07A8A", "#C42E4A", "#7A1830")), # crimson
"type": ("xyz", ("#FFF0A0", "#F2C230", "#A87A10")), # gold
}
OUTLINE_RGB, WHITE_RGB = ImageColor.getrgb("#10131F"), (255, 255, 255)
OUTLINE_MAX_LIGHT = 0.17 # darker than every ramp's dark shade -> outline
# On a base, "gold" this pale or this washed-out is a cream/tan belly or highlight, not a gold
# detail (beaks, irises measure saturation >= 0.6): it becomes the main ramp, same shade.
BODY_PALE_GOLD_LIGHT = 0.7
BODY_TAN_MAX_SAT = 0.45
WHITE_MIN_LIGHT, WHITE_MAX_SAT = 0.9, 0.35
GRAY_MAX_SAT = 0.12 # too gray to tell the hue -> nearest RGB
# Separating the feature from the body redrawn by the edit, on and right around the body:
NOISE_RGB = 48 # any edit pixel this close (raw RGB) to the base pixel is the body
SAME_RAMP_RGB = 120 # same ramp as the base pixel and closer than this: shading shift, body
JITTER_RAMPS = ("main", "outline", "white") # just outside the old silhouette: edge jitter, body
BODY_CHANGE_WARN = 0.12
MARKER_CHAR = {"pivot": "P", "head_top": "^", "back": "B", "tail": "T", "aura": "A"}
PREVIEW_PALETTES = [("blue/amber", ["#1565C0", "#FFB300"]), ("green/red", ["#2E7D32", "#E53935"])]
BG = (226, 226, 220, 255)
class SpecError(Exception):
pass
# ---------------------------------------------------------------- specs
def check_spec(s, seen):
for key in ("name", "plan", "prompt"):
if key not in s:
raise SpecError(f"{s['_path']}: missing '{key}'")
if s["plan"] not in reg.PLANS:
raise SpecError(f"{s['_path']}: plan must be one of {reg.PLANS}")
for p in [s] + s.get("parts", []) + s.get("variants", []):
if p["name"] in seen and seen[p["name"]] != s["_path"]:
raise SpecError(f"{s['_path']}: name {p['name']} already used in {seen[p['name']]}")
seen[p["name"]] = s["_path"]
for p in s.get("parts", []):
if p.get("slot") not in SLOTS:
raise SpecError(f"{s['_path']}: {p.get('name')}: slot must be one of {SLOTS}")
if p.get("fixed") and p["fixed"] not in gs.FIXED:
raise SpecError(f"{s['_path']}: {p['name']}: fixed must be one of {list(gs.FIXED)}")
if "edit" not in p:
raise SpecError(f"{s['_path']}: {p['name']}: missing 'edit'")
for v in s.get("variants", []):
if "edit" not in v:
raise SpecError(f"{s['_path']}: variant {v['name']}: missing 'edit'")
def load_specs(names=()):
"""All valid specs (or the named ones). An invalid spec someone else is still writing is
skipped with a warning; an invalid spec you asked for by name is an error."""
specs, seen = [], {}
for s in reg.load_specs(SPEC_DIR):
try:
check_spec(s, seen)
except (SpecError, KeyError) as e:
if s.get("name") in names:
raise SpecError(str(e))
print(f"warning: skipping {s['_path']}: {e}", file=sys.stderr)
continue
specs.append(s)
if names:
unknown = set(names) - {s["name"] for s in specs}
if unknown:
raise SpecError(f"no spec for {sorted(unknown)} (variants are generated/extracted with their parent)")
specs = [s for s in specs if s["name"] in names]
return specs
def stable_seed(text):
return zlib.crc32(text.encode()) % 100000
# ---------------------------------------------------------------- generation
def normalize_base(raw):
"""Binarize alpha, crop to the creature, put it bottom-centre on the 64x64 canvas."""
arr = np.array(raw.convert("RGBA"))
arr[..., 3] = np.where(arr[..., 3] >= 128, 255, 0)
img = Image.fromarray(arr)
bbox = img.getbbox()
if bbox is None:
raise SpecError("base image is empty")
img = img.crop(bbox)
if img.width > CANVAS - 2 or img.height > CANVAS - GROUND_MARGIN - 1:
raise SpecError(f"base creature is {img.width}x{img.height}; must fit {CANVAS - 2}x{CANVAS - GROUND_MARGIN - 1} "
f"(use a smaller 'size')")
out = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0))
out.paste(img, ((CANVAS - img.width) // 2, CANVAS - GROUND_MARGIN - img.height))
return out
def base_payload(spec):
size = spec.get("size", 48)
return {
"description": spec["prompt"] + BASE_STYLE,
"image_size": {"width": size, "height": size},
"no_background": True,
"outline": "single color black outline",
"detail": spec.get("detail", "medium detail"),
"view": "side",
"direction": spec.get("direction", "south"),
"seed": spec.get("seed", stable_seed(spec["name"])),
}
def gs_b64(img):
from pixellab_api import b64
return b64(img)
def save_meta(path, endpoint, payload, usage):
from pixellab_api import redact
path.write_text(json.dumps({"endpoint": endpoint, "request": redact(payload), "usage": usage}, indent=2) + "\n")
# generate refuses to start when the month's included generations fall to this reserve (kept for
# later work this month; override with PIXELLAB_RESERVE), or when they're gone and USD is low.
MIN_INCLUDED = int(os.environ.get("PIXELLAB_RESERVE", "800"))
MIN_USD = 0.30
def budget_ok(client):
bal = client.balance()
sub = bal.get("subscription") or {}
included = sub.get("generations") or 0
usd = (bal.get("credits") or {}).get("usd") or 0
if sub.get("total", 0) > 100 and included <= MIN_INCLUDED:
print(f"budget stop: {included:g} included generations left, reserve is {MIN_INCLUDED}")
return False
if included <= 0 and usd < MIN_USD:
print(f"budget stop: ${usd:.2f} left (floor ${MIN_USD:.2f}), no included generations")
return False
return True
def probe_slots(spec):
"""Accepted feature slots with no real part and no anchor override: they get a probe edit
(a standard feature, never added to the library) just to find the body's anchor."""
has = {p["slot"] for p in spec.get("parts", []) if not p.get("skip")}
return [s for s in spec.get("slots", SLOTS) if s not in has and s not in spec.get("anchors", {})]
def edit_jobs(spec):
"""(name, output path, edit text, seed) for every edit of this body: parts, probes, variants."""
d = SRC_DIR / spec["name"]
jobs = [(p["name"], d / f"{p['name']}.png", p["edit"], p.get("seed", stable_seed(p["name"])))
for p in spec.get("parts", []) if not p.get("skip")]
jobs += [(f"_probe_{s}", d / f"_probe_{s}.png", PROBE_EDITS[s],
spec.get("probe_seeds", {}).get(s, stable_seed(spec["name"] + s))) for s in probe_slots(spec)]
jobs += [(v["name"], SRC_DIR / v["name"] / "base.png", v["edit"], v.get("seed", stable_seed(v["name"])))
for v in spec.get("variants", []) if not v.get("skip")]
return jobs
def generate(specs, only=(), force=False, dry=False, workers=10, base_only=False):
"""Create missing bases, then every missing edit (parts, probes, variants) of the given bodies.
Bases run in parallel first, then all edits in parallel; the API client caps concurrency
account-wide. --force regenerates the named parts/variants/probes (only=...), or with no
names the bases themselves, which deletes those bodies' old edits and variants."""
client = None
if not dry:
from pixellab_api import Client
client = Client()
if not budget_ok(client):
return
def make_base(spec):
d = SRC_DIR / spec["name"]
d.mkdir(parents=True, exist_ok=True)
payload = base_payload(spec)
if dry:
return f"{spec['name']}: create-image-pixen {payload['description']!r} seed {payload['seed']}"
try:
raw, usage = client.run("create-image-pixen", payload, f"{spec['name']}/base")
raw.save(d / "base_raw.png")
normalize_base(raw).save(d / "base.png")
except Exception as e: # API error after retries, or a creature too big for 64x64
return f"{spec['name']}: base FAILED: {e}"
save_meta(d / "base.json", "create-image-pixen", payload, usage)
stale = [f for f in d.glob("*.png") if not f.name.startswith("base")]
stale += [SRC_DIR / v["name"] / "base.png" for v in spec.get("variants", [])
if (SRC_DIR / v["name"] / "base.png").exists()]
for f in stale: # edits and variants of the old base no longer match it
f.unlink()
f.with_suffix(".json").unlink(missing_ok=True)
return f"{spec['name']}: base saved" + (f", removed {len(stale)} old edits/variants" if stale else "")
bases = [s for s in specs if (force and not only) or not (SRC_DIR / s["name"] / "base.png").exists()]
with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
for line in ex.map(make_base, bases):
print(line, flush=True)
if base_only:
return
todo = []
for spec in specs:
base_path = SRC_DIR / spec["name"] / "base.png"
if not base_path.exists():
print(f"{spec['name']}: no base, edits skipped")
continue
for name, out, text, seed in edit_jobs(spec):
wanted = not only or name in only or (name.startswith("_probe_") and f"{spec['name']}/{name}" in only)
if wanted and ((force and only) or not out.exists()):
todo.append((spec, base_path, name, out, text, seed))
def make_edit(job):
spec, base_path, name, out, text, seed = job
payload = {"image": gs_b64(Image.open(base_path).convert("RGBA")), "description": text + EDIT_SUFFIX,
"width": CANVAS, "height": CANVAS, "seed": seed, "no_background": True}
if dry:
return f" {spec['name']}/{name}: edit-image-pixen {text!r} seed {seed}"
try:
img, usage = client.run("edit-image-pixen", payload, f"{spec['name']}/{name}")
except Exception as e: # one failed edit shouldn't stop the others
return f" {spec['name']}/{name}: FAILED {e}"
out.parent.mkdir(parents=True, exist_ok=True)
if out.name == "base.png": # a variant: binarize alpha like a base
arr = np.array(img)
arr[..., 3] = np.where(arr[..., 3] >= 128, 255, 0)
img = Image.fromarray(arr)
img.save(out)
save_meta(out.with_suffix(".json"), "edit-image-pixen", payload, usage)
return f" {spec['name']}/{name}: saved"
print(f"{len(todo)} edits to make", flush=True)
with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
for line in ex.map(make_edit, todo):
print(line, flush=True)
# ---------------------------------------------------------------- color conversion
def hls(rgb):
"""(hue 0-1, lightness, saturation) of an (..., 3) array of 0-255 colors."""
c = np.asarray(rgb, float) / 255.0
mx, mn = c.max(-1), c.min(-1)
light, d = (mx + mn) / 2, mx - mn
sat = np.where(d == 0, 0.0, d / np.maximum(1 - np.abs(2 * light - 1), 1e-9))
r, g, b = c[..., 0], c[..., 1], c[..., 2]
dd = np.maximum(d, 1e-9)
hue = np.where(mx == r, ((g - b) / dd) % 6, np.where(mx == g, (b - r) / dd + 2, (r - g) / dd + 4)) / 6
return hue, light, sat
def ramp_table(fixed=None):
"""[(ramp name, chars, rgb array 3x3)] for classification: stand-in ramps (+ one fixed ramp)."""
table = [(name, chars, np.array([ImageColor.getrgb(h) for h in hexes])) for name, (chars, hexes) in STANDIN.items()]
if fixed:
table.append((fixed, bp.FIXED_CHARS[fixed], np.array(gs.FIXED[fixed])))
return table
def shade_centers(values, default):
"""Light/base/dark lightness centres of one ramp's pixels (1D k-means, k=3), so an image's
own three tones map to the three shades whatever their absolute lightness."""
if len(np.unique(np.round(values, 3))) < 3:
return default
centers = np.percentile(values, [85, 50, 15])
for _ in range(20):
idx = np.abs(values[:, None] - centers).argmin(-1)
new = np.array([values[idx == k].mean() if (idx == k).any() else centers[k] for k in range(3)])
if np.allclose(new, centers):
break
centers = new
return centers
def to_grid(arr, fixed=None, body=False):
"""RGBA array -> (char grid, ramp-name grid).
Exact stand-in / fixed colors map directly. Otherwise: very dark -> outline, bright and
unsaturated -> eye white, gray -> nearest color, anything else picks a ramp by hue and a
shade by clustering that ramp's lightness in this image into three tones.
body=True (base creatures): cream/tan gold (bellies, highlights) becomes the main ramp at the
same shade instead of type color; saturated gold details (beaks, irises) stay type."""
table = ramp_table(fixed)
colors = np.concatenate([t[2] for t in table] + [np.array([OUTLINE_RGB, WHITE_RGB])])
chars = np.array([c for t in table for c in t[1]] + ["o", "w"])
names = np.array([t[0] for t in table for _ in range(3)] + ["outline", "white"])
rgb = arr[..., :3].astype(int)
opaque = arr[..., 3] >= 128
dist = ((rgb[:, :, None, :] - colors[None, None]) ** 2).sum(-1)
nearest = dist.argmin(-1)
hue, light, sat = hls(rgb)
exact = dist.min(-1) == 0
outline = light <= OUTLINE_MAX_LIGHT
white = (light >= WHITE_MIN_LIGHT) & (sat <= WHITE_MAX_SAT)
gray = sat <= GRAY_MAX_SAT
ramp_hue = np.array([hls(t[2][1])[0] for t in table])
gap = np.abs(hue[..., None] - ramp_hue)
ramp = np.minimum(gap, 1 - gap).argmin(-1)
free = opaque & ~(exact | outline | white | gray)
shade = np.zeros(ramp.shape, int)
for r, (_, _, rgbs) in enumerate(table):
sel = ramp == r
centers = shade_centers(light[free & sel], hls(rgbs)[1])
shade[sel] = np.abs(light[sel][:, None] - centers).argmin(-1)
by_hue = ramp * 3 + shade
idx = np.select([exact | gray, outline, white], [nearest, len(chars) - 2, len(chars) - 1], by_hue)
if body:
tan = (names[idx] == "type") & ((light >= BODY_PALE_GOLD_LIGHT) | (sat < BODY_TAN_MAX_SAT))
idx = np.where(tan, np.where(light >= BODY_PALE_GOLD_LIGHT, 0, idx % 3), idx) # main ramp is 0-2
grid = np.where(opaque, chars[idx], ".")
ramps = np.where(opaque, names[idx], "empty")
return grid, ramps
def load_rgba(path):
return np.array(Image.open(path).convert("RGBA"))
# ---------------------------------------------------------------- extraction
def grow(m, diag=True):
out = m.copy()
out[1:] |= m[:-1]
out[:-1] |= m[1:]
out[:, 1:] |= m[:, :-1]
out[:, :-1] |= m[:, 1:]
if diag:
out[1:, 1:] |= m[:-1, :-1]
out[1:, :-1] |= m[:-1, 1:]
out[:-1, 1:] |= m[1:, :-1]
out[:-1, :-1] |= m[1:, 1:]
return out
def components(mask):
"""8-connected components as lists of (y, x)."""
seen = np.zeros_like(mask)
h, w = mask.shape
out = []
for y, x in zip(*np.nonzero(mask)):
if seen[y, x]:
continue
seen[y, x] = True
comp, q = [], deque([(y, x)])
while q:
cy, cx = q.popleft()
comp.append((cy, cx))
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
ny, nx = cy + dy, cx + dx
if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not seen[ny, nx]:
seen[ny, nx] = True
q.append((ny, nx))
out.append(comp)
return out
def nearest(mask, x, y):
ys, xs = np.nonzero(mask)
i = int(np.argmin((xs - x) ** 2 + (ys - y) ** 2))
return int(xs[i]), int(ys[i])
def extract(base, part):
"""Pixels of the edit that belong to the new feature.
base/var: dicts with grid (chars), ramps (ramp names), rgb (raw array). Returns
(part char grid, fill mask, body-change fraction)."""
bgrid, vgrid = base["grid"], part["var"]["grid"]
bramp, vramp = base["ramps"], part["var"]["ramps"]
spec = part["spec"]
raw_dist = np.sqrt(((base["rgb"][..., :3].astype(int) - part["var"]["rgb"][..., :3].astype(int)) ** 2).sum(-1))
near_body = grow(bgrid != ".")
over_body = bgrid != "."
noise = near_body & ((vramp == "outline")
| (over_body & (raw_dist < NOISE_RGB))
| (over_body & (vramp == bramp) & (raw_dist < SAME_RAMP_RGB))
| (~over_body & np.isin(vramp, JITTER_RAMPS)))
changed = (vgrid != ".") & (vgrid != bgrid)
allowed = np.ones_like(changed)
if "box" in spec:
x0, y0, x1, y1 = spec["box"]
allowed[:] = False
allowed[y0:y1 + 1, x0:x1 + 1] = True
if "chars" in spec:
allowed &= np.isin(vgrid, list(spec["chars"] + "o"))
keep = np.zeros_like(changed)
for comp in components(changed & allowed & ~noise):
if len(comp) >= spec.get("min_area", 8):
keep[tuple(np.array(comp).T)] = True
# The noise rule also drops the feature's root where it meets the body (a teal tail base,
# say). Take back changed pixels outside the original silhouette that touch the feature.
keep |= changed & allowed & (bgrid == ".") & grow(keep & (vgrid != "o"))
fill = keep & (vgrid != "o")
if not fill.any():
raise SpecError(f"{spec['name']}: the edit added nothing (after box/chars/min_area)")
mask = keep | (grow(fill) & (vgrid == "o")) # plus the outline hugging the new fill
grid = np.where(mask, vgrid, ".")
if spec.get("fixed"):
grid = fold_fixed(grid, part["var"], fill, spec)
# Close the silhouette where the part ends in empty space; where it meets the creature it
# stays open, so a head_top drawn over the head joins without a seam.
grid[grow(fill, diag=False) & (vgrid == ".") & ~mask] = "o"
body_px = bgrid != "."
redrawn = body_px & ~grow(mask, diag=True) & ((vgrid == ".") | (raw_dist >= SAME_RAMP_RGB))
return grid, fill, float(redrawn.sum() / max(1, body_px.sum()))
def fold_fixed(grid, var, fill, spec):
"""Recolor the feature's pixels from the listed ramps into the fixed ramp, by lightness."""
fixed = spec["fixed"]
sources = set(spec.get("fixed_from", ["accent", "type"])) | {fixed}
sel = fill & np.isin(var["ramps"], list(sources))
if not sel.any():
return grid
_, light, _ = hls(var["rgb"][..., :3])
centers = shade_centers(light[sel], hls(np.array(gs.FIXED[fixed]))[1])
shade = np.abs(light[sel][:, None] - centers).argmin(-1)
out = grid.copy()
out[sel] = np.array(list(bp.FIXED_CHARS[fixed]))[shade]
return out
def contact_anchor(bgrid, vgrid, fill):
"""Body pixel nearest the middle of where the part touches the body (for a pair of horns or
wings, the middle of the head or back); None if the part doesn't touch the body."""
touch = grow(fill, diag=False) & ~fill & (bgrid != ".") & (vgrid != ".")
if not touch.any():
return None
ys, xs = np.nonzero(touch)
return nearest(bgrid != ".", xs.mean(), ys.mean())
def default_anchors(bgrid):
opaque = bgrid != "."
ys, xs = np.nonzero(opaque)
x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
cx, cy, h = xs.mean(), ys.mean(), y1 - y0 + 1
bottom = opaque & (np.arange(bgrid.shape[0])[:, None] == y1)
return {
"pivot": nearest(bottom, (x0 + x1) / 2, y1),
"aura": nearest(opaque, cx, cy),
"head_top": nearest(opaque, cx, y0),
"back": nearest(opaque, cx, cy - 0.2 * h),
"tail": nearest(opaque, x1, y0 + 0.75 * h),
}
def crop_box(mask, points):
ys, xs = np.nonzero(mask)
xs = list(xs) + [p[0] for p in points]
ys = list(ys) + [p[1] for p in points]
return min(xs), min(ys), max(xs), max(ys)
def write_part(slot, name, grid, header):
src = GRID_DIR / slot / f"{name}.txt"
src.parent.mkdir(parents=True, exist_ok=True)
src.write_text("".join(f"# {line}\n" for line in header) + "\n".join("".join(r) for r in grid) + "\n")
rows = bp.read_grid(src) # same validation build_parts applies
png = PARTS_DIR / slot / f"{name}.png"
png.parent.mkdir(parents=True, exist_ok=True)
bp.to_image(rows).save(png)
return png
def standin_image(grid):
"""Char grid -> RGBA in the stand-in palette (readable, unlike the key colors)."""
lut = {"o": OUTLINE_RGB, "w": WHITE_RGB}
for chars, hexes in STANDIN.values():
lut.update(zip(chars, (ImageColor.getrgb(h) for h in hexes)))
for ramp, chars in bp.FIXED_CHARS.items():
lut.update(zip(chars, gs.FIXED[ramp]))
lut.update({m: (255, 0, 255) for m in MARKER_CHAR.values()})
img = Image.new("RGBA", (grid.shape[1], grid.shape[0]), (0, 0, 0, 0))
img.putdata([lut[c] + (255,) if c in lut else (0, 0, 0, 0) for c in grid.ravel()])
return img
def zoom(img, k, bg=(27, 31, 42, 255)):
big = img.resize((img.width * k, img.height * k), Image.NEAREST)
return Image.alpha_composite(Image.new("RGBA", big.size, bg), big)
def hstack(tiles, gap=8, bg=(0, 0, 0, 0)):
sheet = Image.new("RGBA", (sum(t.width for t in tiles) + gap * (len(tiles) - 1), max(t.height for t in tiles)), bg)
x = 0
for t in tiles:
sheet.paste(t, (x, 0))
x += t.width + gap
return sheet
def extract_body(spec):
d = SRC_DIR / spec["name"]
if not (d / "base.png").exists():
print(f"{spec['name']}: no base yet, run generate")
return
rgb = load_rgba(d / "base.png")
bgrid, bramps = to_grid(rgb, body=True)
base = {"grid": bgrid, "ramps": bramps, "rgb": rgb}
anchors = default_anchors(bgrid)
from_part, extracted = {}, []
for p in spec.get("parts", []):
if p.get("skip"):
continue
src = d / f"{p['name']}.png"
if not src.exists():
print(f" {p['name']}: no edit yet, run generate")
continue
vrgb = load_rgba(src)
if vrgb.shape != rgb.shape:
print(f" {p['name']}: edit is {vrgb.shape[1]}x{vrgb.shape[0]}, expected {CANVAS}x{CANVAS}; skipped")
continue
vgrid, vramps = to_grid(vrgb, p.get("fixed"))
var = {"grid": vgrid, "ramps": vramps, "rgb": vrgb}
try:
grid, fill, change = extract(base, {"spec": p, "var": var})
except SpecError as e:
print(f" {p['name']}: {e}")
continue
if p["slot"] not in from_part:
a = contact_anchor(bgrid, vgrid, fill)
if a is None:
print(f" ! {p['name']}: doesn't touch the body; {p['slot']} anchor uses the default")
else:
from_part[p["slot"]] = a
extracted.append((p, var, grid, fill, change))
probes = []
for slot in probe_slots(spec):
src = d / f"_probe_{slot}.png"
if slot in from_part or not src.exists():
continue
vrgb = load_rgba(src)
vgrid, vramps = to_grid(vrgb)
try:
grid, fill, _ = extract(base, {"spec": {"name": f"{spec['name']} probe {slot}"},
"var": {"grid": vgrid, "ramps": vramps, "rgb": vrgb}})
except SpecError as e:
print(f" ! {e}; {slot} anchor uses the default")
continue
a = contact_anchor(bgrid, vgrid, fill)
if a is None:
print(f" ! probe {slot} doesn't touch the body; {slot} anchor uses the default")
continue
from_part[slot] = a
probes.append((slot, vrgb, vgrid, grid))
anchors.update(from_part)
anchors.update({k: tuple(v) for k, v in spec.get("anchors", {}).items()})
anchors = unique_anchors(bgrid, anchors)
(d / "anchors.json").write_text(json.dumps({k: list(v) for k, v in anchors.items()}) + "\n")
body = write_body(spec["name"], bgrid, anchors, f"pixellab_src/{spec['name']}/base.png", set(from_part))
print(f"{spec['name']}: body {body.shape[1]}x{body.shape[0]} "
+ " ".join(f"{k}={anchors[k]}" for k in MARKER_CHAR))
diff_dir = PREVIEW_DIR / "diff"
diff_dir.mkdir(parents=True, exist_ok=True)
for slot, vrgb, vgrid, grid in probes:
tiles = [Image.fromarray(rgb), Image.fromarray(vrgb), standin_image(grid)]
hstack([zoom(t, 4) for t in tiles]).save(diff_dir / f"_probe_{spec['name']}_{slot}.png")
for v in spec.get("variants", []):
if not v.get("skip"):
extract_variant(spec, v, anchors)
for p, var, grid, fill, change in extracted:
pivot = anchors[p["slot"]]
px0, py0, px1, py1 = crop_box(grid != ".", [pivot])
pgrid = grid[py0:py1 + 1, px0:px1 + 1].copy()
pgrid[pivot[1] - py0, pivot[0] - px0] = "P"
write_part(p["slot"], p["name"], pgrid,
[f"{p['name']}: pixellab_src/{spec['name']}/{p['name']}.png minus base.png", f"pivot at {pivot}"])
tiles = [Image.fromarray(rgb), Image.fromarray(var["rgb"]), standin_image(var["grid"]), standin_image(grid)]
hstack([zoom(t, 6) for t in tiles]).save(diff_dir / f"{p['name']}.png")
flag = " ! body redrawn, check the diff" if change > BODY_CHANGE_WARN else ""
print(f" {p['slot']:8} {p['name']:28} {pgrid.shape[1]}x{pgrid.shape[0]} {int(fill.sum())} px "
f"body change {change:.0%}{flag}")
def unique_anchors(bgrid, anchors):
"""Anchors must be distinct pixels; on a clash, move the later one to the nearest free body pixel."""
out, taken = {}, set()
opaque = bgrid != "."
for k, xy in anchors.items():
if xy in taken:
free = opaque.copy()
for tx, ty in taken:
free[ty, tx] = False
xy = nearest(free, *xy)
out[k] = xy
taken.add(xy)
return out
def write_body(name, bgrid, anchors, source, derived=()):
"""Crop the body grid to the creature, stamp the anchor markers, write grid + PNG."""
x0, y0, x1, y1 = crop_box(bgrid != ".", anchors.values())
body = bgrid[y0:y1 + 1, x0:x1 + 1].copy()
for k, (x, y) in anchors.items():
body[y - y0, x - x0] = MARKER_CHAR[k]
write_part("body", name, body, [f"{name}: from {source} (pixellab_library.py extract)"]
+ [f"{k} at {xy}" + (" (from part/probe)" if k in derived else "") for k, xy in anchors.items()])
return body
def extract_variant(parent, v, parent_anchors):
"""A variant is an edit of its parent's base on the same canvas, so it reuses the parent's
feature anchors, adjusted to its own outline (edits often come out bigger): head_top moves to
the top of the body in that column, tail to the right edge in that row, back to the nearest
body pixel. A variant's own "anchors" override these. Ground pivot and aura are its own."""
src = SRC_DIR / v["name"] / "base.png"
if not src.exists():
print(f" {v['name']}: no variant image yet, run generate")
return
rgb = load_rgba(src)
vgrid, _ = to_grid(rgb, body=True)
opaque = vgrid != "."
if not opaque.any():
print(f" {v['name']}: empty image, skipped")
return
anchors = default_anchors(vgrid)
for slot in SLOTS:
x, y = parent_anchors[slot]
if slot == "head_top" and opaque[:, x].any():
anchors[slot] = (x, int(np.nonzero(opaque[:, x])[0].min()))
elif slot == "tail" and opaque[y].any():
anchors[slot] = (int(np.nonzero(opaque[y])[0].max()), y)
else:
anchors[slot] = nearest(opaque, x, y)
anchors.update({k: tuple(xy) for k, xy in v.get("anchors", {}).items()})
anchors = unique_anchors(vgrid, anchors)
body = write_body(v["name"], vgrid, anchors, f"pixellab_src/{v['name']}/base.png (variant of {parent['name']})")
print(f" variant {v['name']}: body {body.shape[1]}x{body.shape[0]}")
def sweep_orphans(specs):
"""Remove grids/PNGs for parts and bodies no longer in any spec (or marked skip)."""
live = {("body", s["name"]) for s in specs}
live |= {("body", v["name"]) for s in specs for v in s.get("variants", []) if not v.get("skip")}
live |= {(p["slot"], p["name"]) for s in specs for p in s.get("parts", []) if not p.get("skip")}
for root, ext in ((GRID_DIR, "txt"), (PARTS_DIR, "png")):
for f in root.glob(f"*/*.{ext}"):
if (f.parent.name, f.stem) not in live:
f.unlink()
print(f"removed stale {f}")
# ---------------------------------------------------------------- previews
def registry():
gs._part_cache.clear()
reg._extents.clear()
return importlib.reload(reg)
def top_type(p, fallback="normal"):
if not p["types"]:
return fallback
return max(p["types"], key=p["types"].get)
def render(lib, body, picks, ptype, colors):
chosen = {"body": body, **{s: p for s, p in picks.items()}}
spr = gs.compose(lib.PARTS_DIR, chosen, "none", random.Random(0))
spr = gs.place_on_canvas(spr, lib)
main, accent = gs.pick_main_accent([(gs.hex_to_rgb(c), 1.0) for c in colors], ptype)
return gs.recolor(spr, gs.build_palette(main, accent, ptype))
def label_grid(cells, cols, path, scale, title=None):
"""cells: [(label, image or None)] in a grid; None draws an empty cell."""
cw, lh = CANVAS * scale, 12
rows = -(-len(cells) // cols)
top = 16 if title else 0
sheet = Image.new("RGBA", (cols * (cw + 4) + 4, top + rows * (cw + lh + 4) + 4), BG)
d = ImageDraw.Draw(sheet)
if title:
d.text((4, 2), title, fill=(30, 30, 30, 255))
for i, (label, img) in enumerate(cells):
x, y = 4 + (i % cols) * (cw + 4), top + 4 + (i // cols) * (cw + lh + 4)
d.text((x, y), label[:28], fill=(40, 40, 40, 255))
if img is not None:
sheet.alpha_composite(img.resize((cw, cw), Image.NEAREST), (x, y + lh))
path.parent.mkdir(parents=True, exist_ok=True)
sheet.save(path)
print(path)
ANCHOR_COLORS = {"head_top": (255, 40, 40, 255), "back": (40, 220, 40, 255), "tail": (40, 120, 255, 255)}
TEST_FIT = {"head_top": ["pl_horns_ram", "pl_horn_single", "pl_antennae"],
"back": ["pl_wings_gold", "pl_wings_bat", "pl_scarf_long"],
"tail": ["pl_tail_fluffy", "pl_tail_spade", "pl_tail_curly"]}
def dark(img):
return Image.alpha_composite(Image.new("RGBA", img.size, (27, 31, 42, 255)), img)
def anchor_tile(lib, body, ptype, colors):
"""The body recolored, with a 3x3 dot on each feature anchor (head_top red, back green, tail blue)."""
img = render(lib, body, {}, ptype, colors)
bimg, anch = gs.load_part(lib.PARTS_DIR, "body", body["name"])
bb = bimg.getbbox()
ox = (CANVAS - (bb[2] - bb[0])) // 2 - bb[0]
oy = CANVAS - GROUND_MARGIN - (bb[3] - bb[1]) - bb[1]
d = ImageDraw.Draw(img)
for slot, col in ANCHOR_COLORS.items():
x, y = anch[slot]
d.rectangle((x + ox - 1, y + oy - 1, x + ox + 1, y + oy + 1), fill=col)
return img
def test_fit(lib, body, ptype, colors):
"""The body with one common library part per slot (the first that's allowed and fits)."""
by = {p["name"]: (s, p) for s in SLOTS for p in lib.SLOTS[s]}
picks, names = {}, []
for slot, cands in TEST_FIT.items():
for n in cands:
if n in by and allowed_on(lib, body, slot, by[n][1]) and lib.fits(body, {**picks, slot: by[n][1]}):
picks[slot] = by[n][1]
names.append(n[3:])
break
return "test: " + " ".join(names), render(lib, body, picks, ptype, colors)
def preview_body(lib, spec):
"""One review sheet per body family: preview/pixellab/body_<name>.png.
Row 1: raw base, recolored, anchors, test fit with library parts, each own part.
Then one row per variant: raw, recolored, anchors (inherited), test fit."""
by = {p["name"]: p for s in lib.SLOTS.values() for p in s}
if spec["name"] not in by:
print(f"{spec['name']}: not extracted")
return
colors = PREVIEW_PALETTES[0][1]
body = by[spec["name"]]
btype = top_type(body)
own = [(p["slot"], by[p["name"]]) for p in spec.get("parts", []) if p["name"] in by]
rows = [[("base " + spec["name"][3:], dark(Image.open(SRC_DIR / spec["name"] / "base.png").convert("RGBA"))),
("recolored", render(lib, body, {}, btype, colors)),
("anchors", anchor_tile(lib, body, btype, colors)),
test_fit(lib, body, btype, colors)]
+ [(p["name"][3:], render(lib, body, {slot: p}, top_type(p, btype), colors)) for slot, p in own]]
for v in spec.get("variants", []):
if v.get("skip") or v["name"] not in by:
continue
vb = by[v["name"]]
rows.append([("variant " + v["name"][3:], dark(Image.open(SRC_DIR / v["name"] / "base.png").convert("RGBA"))),
("recolored", render(lib, vb, {}, btype, colors)),
("anchors", anchor_tile(lib, vb, btype, colors)),
test_fit(lib, vb, btype, colors)])
cols = max(len(r) for r in rows)
cells = [c for r in rows for c in r + [("", None)] * (cols - len(r))]
label_grid(cells, cols, PREVIEW_DIR / f"body_{spec['name']}.png", 3)
def preview_sheet(specs, path):
"""Raw bases (and variants) of many bodies on one sheet: for a quick originality check."""
cells = []
for spec in specs:
for name in [spec["name"]] + [v["name"] for v in spec.get("variants", []) if not v.get("skip")]:
f = SRC_DIR / name / "base.png"
if f.exists():
cells.append((name[3:], dark(Image.open(f).convert("RGBA"))))
if cells:
label_grid(cells, 8, path, 2)
def allowed_on(lib, body, slot, p):
tags = set(body["tags"]) | {f"plan:{body['plans'][0]}"}
if body["plans"][0] not in p["plans"] or p["name"] in body["deny"]:
return False
if p["requires"] and not set(p["requires"]) & tags:
return False
return not (set(p["tags"]) & set(body["excludes"]) or set(p["excludes"]) & tags)
CROSS_MAX_BODIES = 30 # the full library is far too big for one image; rows are an even sample
def preview_cross(lib):
"""Per slot: rows = bodies (an even sample of CROSS_MAX_BODIES), columns = parts; every
pairing the rules allow is rendered. Gray cell: not allowed by plans/tags/deny. Red frame:
rendered for review, but the generator never picks it (detached, or too big for the canvas)."""
step = max(1, -(-len(lib.BODY) // CROSS_MAX_BODIES))
bodies = lib.BODY[::step]
colors = PREVIEW_PALETTES[0][1]
for slot in SLOTS:
parts = [p for p in lib.SLOTS[slot] if p["name"] != "none"]
if not parts:
continue
cells = []
for body in bodies:
for p in parts:
label = f"{body['name'][3:]}+{p['name'][3:]}"
if not allowed_on(lib, body, slot, p):
cells.append((label, Image.new("RGBA", (CANVAS, CANVAS), (200, 200, 196, 255))))
continue
img = render(lib, body, {slot: p}, top_type(p, top_type(body)), colors)
if not lib.fits(body, {slot: p}):
if img.width > CANVAS: # expanded canvas: show the bottom-centre 64x64
img = img.crop(((img.width - CANVAS) // 2, img.height - CANVAS,
(img.width + CANVAS) // 2, img.height))
ImageDraw.Draw(img).rectangle((0, 0, CANVAS - 1, CANVAS - 1), outline=(220, 0, 0, 255), width=2)
label += " (detached)" if not lib.attached(body, slot, p) else " (too big)"
cells.append((label, img))
label_grid(cells, len(parts), PREVIEW_DIR / f"cross_{slot}.png", 2,
title=f"{slot}: rows = bodies, columns = parts ({', '.join(p['name'] for p in parts)})")
def preview_random(lib, n):
palettes = [["#1565C0", "#FFB300"], ["#2E7D32", "#E53935"], ["#6A1B9A", "#FDD835"], ["#D84315", "#263238"],
["#00897B", "#F06292"], ["#37474F", "#FFCA28"], ["#C62828", "#ECEFF1"], ["#F9A825", "#1A237E"]]
cells = []
for i in range(n):
ptype = TYPES[i % len(TYPES)]
rng = random.Random(i)
plan, chosen, markings, _ = gs.pick_parts(lib, ptype, rng)
spr = gs.place_on_canvas(gs.compose(lib.PARTS_DIR, chosen, markings, rng), lib)
main, accent = gs.pick_main_accent([(gs.hex_to_rgb(c), 1.0) for c in palettes[i % len(palettes)]], ptype)
spr = gs.recolor(spr, gs.build_palette(main, accent, ptype))
extras = [p["name"][3:] for s, p in chosen.items() if s != "body" and p["name"] != "none"]
cells.append((f"{ptype} {chosen['body']['name'][3:]} {' '.join(extras)}", spr))
label_grid(cells, 10, PREVIEW_DIR / "random.png", 2)
# ---------------------------------------------------------------- cost
def cost():
ledger = SRC_DIR / "ledger.jsonl"
usd = gens = n = 0
if ledger.exists():
for line in ledger.read_text().splitlines():
u = json.loads(line).get("usage") or {}
n += 1
usd += u.get("usd", 0) or 0
gens += u.get("generations", 0) or 0
print(f"ledger: {n} calls, ${usd:.3f}, {gens:g} included generations")
from pixellab_api import Client
print("balance:", Client().balance())
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
g = sub.add_parser("generate", help="create missing bases and edits")
g.add_argument("bodies", nargs="*")
g.add_argument("--parts", nargs="*", default=(),
help="only these parts / variants / '<body>/_probe_<slot>' probes (with --force: redo them)")
g.add_argument("--force", action="store_true", help="regenerate even if the image exists")
g.add_argument("--dry-run", action="store_true")
g.add_argument("--workers", type=int, default=10,
help="parallel requests (the API client also caps concurrency account-wide)")
g.add_argument("--base-only", action="store_true", help="only the base images (review them before paying for edits)")
e = sub.add_parser("extract", help="convert + diff + anchors -> parts_pl_src/ and parts_pl/")
e.add_argument("bodies", nargs="*")
pv = sub.add_parser("preview", help="review sheets in preview/pixellab/")
pv.add_argument("bodies", nargs="*")
pv.add_argument("--cross", action="store_true", help="every part on every body, one sheet per slot")
pv.add_argument("--random", type=int, metavar="N", help="N real random picks")
pv.add_argument("--sheet", metavar="PNG", help="raw bases + variants of the given bodies on one sheet")
sub.add_parser("cost", help="spend so far and live balance")
a = ap.parse_args(argv)
try:
if a.cmd == "cost":
cost()
return 0
specs = load_specs(getattr(a, "bodies", ()))
if a.cmd == "generate":
generate(specs, set(a.parts), a.force, a.dry_run, a.workers, a.base_only)
elif a.cmd == "extract":
for spec in specs:
try:
extract_body(spec)
except (SpecError, bp.SourceError) as err: # one bad body shouldn't stop the rest
print(f"{spec['name']}: error: {err}", file=sys.stderr)
if not a.bodies:
sweep_orphans(specs)
elif a.cmd == "preview":
lib = registry()
if a.cross:
preview_cross(lib)
if a.random:
preview_random(lib, a.random)
if a.sheet:
preview_sheet(specs, Path(a.sheet))
elif a.bodies or not (a.cross or a.random):
for spec in specs:
preview_body(lib, spec)
except (SpecError, reg.SpecError, bp.SourceError) as err:
print(f"error: {err}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())