#!/usr/bin/env python3 """ Build the PixelLab part library (64x64 sprites, registry: sprite_parts_pl.py). Each base creature has a spec, pixellab_library/.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_.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/.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())