207 lines
7.9 KiB
Python
Executable File
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:]))
|