Pixellab library expansion

This commit is contained in:
=
2026-10-02 07:01:10 -05:00
parent 0e905a2824
commit b9d634882c
7990 changed files with 116140 additions and 856 deletions

View File

@@ -24,7 +24,7 @@ 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)
"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",
@@ -32,6 +32,13 @@ Spec (pixellab_library/<body>.json)
"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",
@@ -49,6 +56,10 @@ Spec (pixellab_library/<body>.json)
}]
}
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.
@@ -60,6 +71,7 @@ Conventions every base and edit must follow so parts mix across bodies
import argparse
import importlib
import json
import os
import random
import sys
import zlib
@@ -87,6 +99,13 @@ SLOTS = reg.FEATURE_SLOTS
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 = {
@@ -118,30 +137,45 @@ class SpecError(Exception):
# ---------------------------------------------------------------- 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=()):
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'")
"""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)}")
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
@@ -183,16 +217,6 @@ def base_payload(spec):
}
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)
@@ -203,76 +227,120 @@ def save_meta(path, endpoint, payload, usage):
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
# 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()
trial = (bal.get("subscription") or {}).get("generations") or 0
sub = bal.get("subscription") or {}
included = sub.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")
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 generate(specs, only=(), force=False, dry=False, workers=1, base_only=False):
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()
for spec in specs:
if client and not budget_ok(client):
if not budget_ok(client):
return
def make_base(spec):
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
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():
if dry:
print(f" (edits need {base_path})")
print(f"{spec['name']}: no base, edits skipped")
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")]
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 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})"
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"
with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
for line in ex.map(one, todo):
print(line)
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
@@ -566,26 +634,41 @@ def extract_body(spec):
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()})
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()])
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])
@@ -600,9 +683,66 @@ def extract_body(spec):
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}"):
@@ -652,22 +792,83 @@ def label_grid(cells, cols, path, scale, title=None):
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 _, 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)
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):
@@ -679,17 +880,22 @@ def allowed_on(lib, body, slot, p):
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, 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)."""
"""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 lib.BODY:
for body in bodies:
for p in parts:
label = f"{body['name'][3:]}+{p['name'][3:]}"
if not allowed_on(lib, body, slot, p):
@@ -734,7 +940,7 @@ def cost():
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")
print(f"ledger: {n} calls, ${usd:.3f}, {gens:g} included generations")
from pixellab_api import Client
print("balance:", Client().balance())
@@ -744,18 +950,20 @@ def main(argv=None):
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("--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=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)")
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:
@@ -767,7 +975,10 @@ def main(argv=None):
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)
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":
@@ -776,7 +987,9 @@ def main(argv=None):
preview_cross(lib)
if a.random:
preview_random(lib, a.random)
if a.bodies or not (a.cross or 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: