Files
Company_Sprite_Gen/pixellab_library.py
2026-10-01 22:46:04 -05:00

790 lines
35 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": 2}, "other": 1, # type boosts; every body is open to every type (other > 0)
"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
"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
}]
}
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 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"
# 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 load_specs(names=()):
specs = reg.load_specs(SPEC_DIR)
seen = {}
for s in specs:
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", []):
if p["name"] in seen:
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'")
if names:
unknown = set(names) - {s["name"] for s in specs}
if unknown:
raise SpecError(f"no spec for {sorted(unknown)}")
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 edit_payload(spec, part, base):
return {
"image": gs_b64(base),
"description": part["edit"] + EDIT_SUFFIX,
"width": CANVAS, "height": CANVAS,
"seed": part.get("seed", stable_seed(part["name"])),
"no_background": True,
}
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")
MIN_USD = 0.30 # generate stops starting new bodies below this balance once trial generations are gone
def budget_ok(client):
bal = client.balance()
trial = (bal.get("subscription") or {}).get("generations") or 0
usd = (bal.get("credits") or {}).get("usd") or 0
if trial <= 0 and usd < MIN_USD:
print(f"budget stop: ${usd:.2f} left (floor ${MIN_USD:.2f}), no trial generations")
return False
return True
def generate(specs, only=(), force=False, dry=False, workers=1, base_only=False):
client = None
if not dry:
from pixellab_api import Client
client = Client()
for spec in specs:
if client and not budget_ok(client):
return
d = SRC_DIR / spec["name"]
d.mkdir(parents=True, exist_ok=True)
base_path = d / "base.png"
if force and not only or not base_path.exists():
payload = base_payload(spec)
if dry:
print(f"{spec['name']}: create-image-pixen {json.dumps(payload)}")
else:
print(f"{spec['name']}: generating base ...")
try:
raw, usage = client.run("create-image-pixen", payload, f"{spec['name']}/base")
raw.save(d / "base_raw.png")
normalize_base(raw).save(base_path)
except Exception as e: # API error after retries, or a creature too big for 64x64
print(f" base FAILED: {e}")
continue
save_meta(d / "base.json", "create-image-pixen", payload, usage)
stale = [f for f in d.glob("*.png") if not f.name.startswith("base")]
for f in stale: # edits of the old base no longer match it
f.unlink()
f.with_suffix(".json").unlink(missing_ok=True)
print(f" base saved ({usage})" + (f", removed {len(stale)} old edits" if stale else ""))
if base_only:
continue
if not base_path.exists():
if dry:
print(f" (edits need {base_path})")
continue
base = Image.open(base_path).convert("RGBA")
todo = [p for p in spec.get("parts", [])
if (not only or p["name"] in only) and (force and (not only or p["name"] in only)
or not (d / f"{p['name']}.png").exists())]
todo = [p for p in todo if not p.get("skip")]
def one(p):
payload = edit_payload(spec, p, base)
if dry:
return f" {p['name']}: edit-image-pixen \"{payload['description']}\" seed {payload['seed']}"
try:
img, usage = client.run("edit-image-pixen", payload, f"{spec['name']}/{p['name']}")
except Exception as e: # one failed edit shouldn't stop the others
return f" {p['name']}: FAILED {e}"
img.save(d / f"{p['name']}.png")
save_meta(d / f"{p['name']}.json", "edit-image-pixen", payload, usage)
return f" {p['name']}: saved ({usage})"
with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
for line in ex.map(one, todo):
print(line)
# ---------------------------------------------------------------- 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))
anchors.update(from_part)
anchors.update({k: tuple(v) for k, v in spec.get("anchors", {}).items()})
taken = {}
for k, xy in anchors.items():
if xy in taken:
raise SpecError(f"{spec['name']}: {k} and {taken[xy]} anchors share pixel {xy}; set one in 'anchors'")
taken[xy] = k
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", spec["name"], body,
[f"{spec['name']}: from pixellab_src/{spec['name']}/base.png (pixellab_library.py extract)"]
+ [f"{k} at {xy}" + (" (from part)" if k in from_part else "") for k, xy in anchors.items()])
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 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 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 |= {(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)
def preview_body(lib, spec):
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
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]
cells = []
for _, colors in PREVIEW_PALETTES:
base = Image.open(SRC_DIR / spec["name"] / "base.png").convert("RGBA")
cells.append(("pixellab base", Image.alpha_composite(Image.new("RGBA", base.size, (27, 31, 42, 255)), base)))
cells.append((spec["name"], render(lib, body, {}, btype, colors)))
for slot, p in own:
cells.append((p["name"], render(lib, body, {slot: p}, top_type(p, btype), colors)))
label_grid(cells, len(own) + 2, PREVIEW_DIR / f"body_{spec['name']}.png", 3)
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)
def preview_cross(lib):
"""Per slot: rows = 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 from the body, or too big for the canvas)."""
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 lib.BODY:
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} trial 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 (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=1,
help="parallel edit requests (the account runs one job at a time, so extra workers just queue)")
g.add_argument("--base-only", action="store_true", help="only the base image (review it 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")
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:
extract_body(spec)
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.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())