#!/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:]))