Pixellab library expansion
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user