This commit is contained in:
Gabrieal Jimmy
2026-10-01 15:15:20 -05:00
commit 97a27f9a42
327 changed files with 7075 additions and 0 deletions

189
validate_parts.py Executable file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Validate the part library in parts/.
Fails on:
- a missing file for any part listed in sprite_parts.py
- any color outside the palette keys, pupil, eye white and marker colors
- any partial alpha
- a missing (or duplicated) required marker for the slot / body type
- any legal composite that triggers "canvas expanded": checked exhaustively per body
via bounding boxes, plus a random sample of real composites
python validate_parts.py [--sample N]
"""
import random
import sys
from itertools import product
from pathlib import Path
import numpy as np
from PIL import Image
import generate_sprite as gs
import sprite_parts as cfg
PARTS = Path("parts")
ALLOWED = {tuple(v) for v in gs.KEYS.values()} | {gs.PUPIL, gs.EYE_WHITE} | {tuple(v) for v in gs.MARKERS.values()}
MARKER_BY_COLOR = {v: k for k, v in gs.MARKERS.items()}
def required_markers(slot, p):
if slot == "body":
return {"pivot", "tail", "back", "aura", "head_top"}
return {"pivot"}
def check_file(slot, p):
errs = []
path = PARTS / slot / f"{p['name']}.png"
if not path.exists():
return [f"missing file {path}"]
arr = np.array(Image.open(path).convert("RGBA"))
a = arr[:, :, 3]
partial = np.count_nonzero((a > 0) & (a < 255))
if partial:
errs.append(f"{path}: {partial} partial-alpha pixel(s)")
opaque = arr[a > 0][:, :3]
colors = {tuple(int(v) for v in c) for c in np.unique(opaque, axis=0)} if len(opaque) else set()
bad = colors - ALLOWED
if bad:
errs.append(f"{path}: illegal colors {sorted(gs.rgb_to_hex(c) for c in bad)[:8]}")
found = {}
for c in colors & set(MARKER_BY_COLOR):
n = int(np.count_nonzero(np.all(arr[:, :, :3] == c, axis=-1) & (a > 0)))
found[MARKER_BY_COLOR[c]] = n
req = required_markers(slot, p)
missing = req - set(found)
if missing:
errs.append(f"{path}: missing markers {sorted(missing)}")
extra = set(found) - req
if extra:
errs.append(f"{path}: unexpected markers {sorted(extra)}")
dup = [k for k, n in found.items() if n > 1]
if dup:
errs.append(f"{path}: marker used more than once {sorted(dup)}")
if slot == "aura" and (arr.shape[1], arr.shape[0]) != (84, 84):
errs.append(f"{path}: aura must be 84x84, is {arr.shape[1]}x{arr.shape[0]}")
return errs
# ------------------------------------------------------------------ size checks
def bbox_of(slot, name):
"""(anchors, opaque bbox x0,y0,x1,y1 exclusive) of a loaded part."""
loaded = gs.load_part(str(PARTS), slot, name)
if loaded is None:
return None
img, anch = loaded
bb = img.getbbox()
return anch, bb
def exhaustive_extents():
"""For each body, the worst legal composite size, computed from bounding boxes.
Every variation slot is independent given the body, so the union bbox worst case is
found by taking each side's extreme from a different slot (or one part covering both)."""
problems = []
worst = {}
for body in cfg.BODY:
plan = body["plans"][0]
b = bbox_of("body", body["name"])
if b is None:
continue
banch, bbb = b
bpos = (-banch["pivot"][0], -banch["pivot"][1])
tags = {f"plan:{plan}"} | set(body["tags"])
excl = set(body["excludes"])
fx0, fy0, fx1, fy1 = bpos[0] + bbb[0], bpos[1] + bbb[1], bpos[0] + bbb[2], bpos[1] + bbb[3]
opt = {}
for slot in gs.PICK_ORDER[1:]:
lst = []
for p in cfg.SLOTS[slot]:
if p["name"] == "none" or plan not in p["plans"] or p["name"] in body["deny"]:
continue
if p["requires"] and not set(p["requires"]) & tags:
continue
if set(p["tags"]) & excl or set(p["excludes"]) & tags:
continue
pb = bbox_of(slot, p["name"])
a = banch.get(slot)
if pb is None or a is None or pb[1] is None:
continue
panch, pbb = pb
piv = panch.get("pivot", (0, 0))
pos = (bpos[0] + a[0] - piv[0], bpos[1] + a[1] - piv[1])
lst.append((pos[0] + pbb[0], pos[1] + pbb[1], pos[0] + pbb[2], pos[1] + pbb[3], p["name"]))
if lst:
opt[slot] = lst
def worst_span(lo_i, hi_i, f_lo, f_hi):
best = (f_hi - f_lo, "")
for s1 in opt:
for p in opt[s1]: # single part both sides
span = max(f_hi, p[hi_i]) - min(f_lo, p[lo_i])
if span > best[0]:
best = (span, f"{s1}={p[4]}")
for s1, s2 in product(opt, opt):
if s1 == s2:
continue
lo = min(opt[s1], key=lambda p: p[lo_i])
hi = max(opt[s2], key=lambda p: p[hi_i])
span = max(f_hi, hi[hi_i]) - min(f_lo, lo[lo_i])
if span > best[0]:
best = (span, f"{s1}={lo[4]} + {s2}={hi[4]}")
return best
w, wwhy = worst_span(0, 2, fx0, fx1)
h, hwhy = worst_span(1, 3, fy0, fy1)
worst[body["name"]] = (w, h, wwhy, hwhy)
if w + 2 > gs.CANVAS:
problems.append(f"{body['name']}: worst width {w} > {gs.CANVAS - 2} ({wwhy})")
if h + gs.GROUND_MARGIN + 1 > gs.CANVAS:
problems.append(f"{body['name']}: worst height {h} > {gs.CANVAS - gs.GROUND_MARGIN - 1} ({hwhy})")
return problems, worst
def sample_composites(n):
problems = []
rng = random.Random(12345)
for i in range(n):
ptype = rng.choice(cfg.TYPES)
prng = random.Random(i)
plan, chosen, markings, tags = gs.pick_parts(ptype, prng)
if any(not (PARTS / s / f"{p['name']}.png").exists() for s, p in chosen.items() if p["name"] != "none"):
continue
spr = gs.compose(str(PARTS), chosen, markings, prng)
if max(spr.width + 2, spr.height + gs.GROUND_MARGIN + 1) > gs.CANVAS:
problems.append(f"sample {ptype} seed {i}: {spr.width}x{spr.height} "
f"({', '.join(f'{s}={p['name']}' for s, p in chosen.items())})")
return problems
def main(argv):
n = 2000
if "--sample" in argv:
n = int(argv[argv.index("--sample") + 1])
errs = []
for slot, parts in cfg.SLOTS.items():
for p in parts:
if p["name"] != "none":
errs += check_file(slot, p)
size_probs, worst = exhaustive_extents()
errs += size_probs
errs += sample_composites(n)
for e in errs:
print("FAIL", e)
if "--verbose" in argv:
for b, (w, hh, ww, hw) in sorted(worst.items()):
print(f"{b:16s} worst {w}x{hh} w:{ww} h:{hw}")
total = sum(1 for parts in cfg.SLOTS.values() for p in parts if p["name"] != "none")
present = sum(1 for slot, parts in cfg.SLOTS.items() for p in parts
if p["name"] != "none" and (PARTS / slot / f"{p['name']}.png").exists())
print(f"{present}/{total} parts present, {len(errs)} problem(s)")
return 1 if errs else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))