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

207 lines
7.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Validate a part library (default: the pixellab one in parts_pl/; --library classic for 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 (only for libraries without a fits() rule), plus a random sample of
real composites
python validate_parts.py [--library pixellab|classic] [--sample N] [--verbose]
"""
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
from sprite_parts import TYPES
lib = None # registry module, set in main
PARTS = None
ALLOWED = {tuple(v) for v in gs.KEYS.values()} | {gs.PUPIL, gs.EYE_WHITE} | {tuple(v) for v in gs.MARKERS.values()} | gs.FIXED_COLORS
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 lib.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 lib.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 > lib.CANVAS:
problems.append(f"{body['name']}: worst width {w} > {lib.CANVAS - 2} ({wwhy})")
if h + lib.GROUND_MARGIN + 1 > lib.CANVAS:
problems.append(f"{body['name']}: worst height {h} > {lib.CANVAS - lib.GROUND_MARGIN - 1} ({hwhy})")
return problems, worst
def sample_composites(n):
problems = []
rng = random.Random(12345)
for i in range(n):
ptype = rng.choice(TYPES)
prng = random.Random(i)
plan, chosen, markings, tags = gs.pick_parts(lib, 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 + lib.GROUND_MARGIN + 1) > lib.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):
global lib, PARTS
n = 2000
if "--sample" in argv:
n = int(argv[argv.index("--sample") + 1])
name = argv[argv.index("--library") + 1] if "--library" in argv else gs.DEFAULT_LIBRARY
if name not in gs.LIBRARIES:
print(f"unknown library {name!r}, choose from {', '.join(gs.LIBRARIES)}")
return 2
lib = gs.load_library(name)
PARTS = Path(lib.PARTS_DIR)
print(f"library {name} ({PARTS}/, canvas {lib.CANVAS})")
if not lib.BODY:
print("library is empty (no bodies), nothing to validate")
return 0
errs = []
for slot, parts in lib.SLOTS.items():
for p in parts:
if p["name"] != "none":
errs += check_file(slot, p)
if hasattr(lib, "fits"):
worst = {}
print("exhaustive size check skipped: library has a fits() rule, relying on the random sample")
else:
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 lib.SLOTS.values() for p in parts if p["name"] != "none")
present = sum(1 for slot, parts in lib.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:]))