"""Prompt parsing + attribute grounding checks for PXG-Tiny. The tiny model understands English through its intent encoder; this module is the *measurable* side of that understanding: it parses free-form prompts into attribute constraints (material, orientation, size, glow, ...) and scores generated sprites against them. Used by the pipeline's auto-retry sampler and by the generalization eval. """ import re import numpy as np # palette-index families (see config.PALETTE) FAMILIES = { "gold": {10, 11, 21}, "iron": {3, 4, 5, 30}, "crystal": {16, 17, 18}, "blue": {16, 17, 18}, "ruby": {7, 8}, "red": {7, 8}, "emerald": {12, 13, 14}, "green": {12, 13, 14, 29}, "slime": {29, 12, 13}, "purple": {19, 20}, "amethyst": {19, 20}, "wood": {23, 25, 26, 27}, "copper": {8, 9}, "slate": {2, 3, 4}, "terracotta": {8, 9}, "white": {1, 24}, "gray": {2, 3, 4}, "brown": {25, 26, 27}, "tan": {22, 23}, "pink": {31}, } SYNONYMS = { "golden": "gold", "big": "large", "huge": "large", "wooden": "wood", "azure": "blue", "steel": "iron", "sapphire": "blue", "amethyst": "purple", "moss-covered": "mossy", "boulder": "rock", "gemstone": "gem", "cottage": "house", "stone": "gray", "shrub": "bush", "grassy": "grass", # v0.4 character / animal synonyms "grey": "gray", "mage": "wizard", "sorcerer": "wizard", "warlock": "wizard", "ranger": "archer", "hunter": "archer", "scout": "archer", "kitty": "cat", "kitten": "cat", "puppy": "dog", "hound": "dog", "songbird": "bird", "sparrow": "bird", "parrot": "bird", "owl": "bird", "hawk": "bird", "eagle": "bird", "goldfish": "fish", "undead": "zombie", "corpse": "zombie", "fawn": "deer", "doe": "deer", "stag": "deer", "violet": "purple", "crimson": "red", "scarlet": "red", "brass": "gold", "bronze": "copper", "lawn": "grass", "dungeon": "stone", "ocean": "water", "waves": "water", } # generic fighter words: only become "knight" when no other class noun is # present (prevents "skeleton warrior" / "zombie fighter" from re-classing) FIGHTER_WORDS = {"warrior", "fighter", "soldier", "paladin", "hero"} # plural -> singular for every class noun the parser knows PLURALIZE = ["wizard", "knight", "archer", "skeleton", "zombie", "cat", "dog", "bird", "fish", "deer", "mouse", "bat", "sword", "dagger", "shield", "staff", "chest", "barrel", "crate", "coin", "gem", "key", "apple", "bread", "bush", "rock", "fireball", "chair", "table", "stool", "house", "tile", "potion", "vial", "tree"] # canonical corpus-style caption per class, used by the retry sampler's # anchor stage (prompt normalization with our own parser - fully offline) ANCHOR_CAPTIONS = { "wizard": "a wizard with a purple hat", "knight": "a knight in iron armor", "archer": "an archer with a green hood", "skeleton": "a skeleton warrior", "zombie": "a green zombie", "cat": "a small gray cat", "dog": "a brown dog", "bird": "a small blue bird", "fish": "a golden fish", "deer": "a brown deer", "mouse": "a little gray mouse", "bat": "a purple bat with wings", "sword": "a sword", "dagger": "a small dagger", "shield": "a round shield", "staff": "a wooden staff with an orb", "potion_round": "a round potion bottle", "potion_vial": "a slim vial of potion", "gem": "a shiny gem", "coin": "a gold coin", "chest": "a wooden chest", "key": "a golden key", "house": "a small house", "barrel": "a wooden barrel", "crate": "a wooden crate", "apple": "a red apple", "bread": "a loaf of bread", "fireball": "a fireball", "oak_tree": "a green oak tree", "pine_tree": "a dark pine tree", "bush": "a leafy bush", "rock": "a gray rock", "chair": "a wooden chair", "table": "a wooden table", "stool": "a wooden stool", "grass_tile": "a grass texture tile", "stone_tile": "a stone texture tile", "water_tile": "a water texture tile", } KNOWN_CLASSES = [ "wizard", "knight", "cat", "dog", "bird", "fish", "sword", "potion", "gem", "house", "chest", "tree", "archer", "skeleton", "zombie", "deer", "mouse", "bat", "chair", "table", "stool", "dagger", "shield", "staff", "vial", "barrel", "crate", "coin", "apple", "bread", "oak", "pine", "bush", "rock", "grass tile", "stone tile", "water tile", "fireball", ] # classes whose red/green color words are design axes (never stripped) COLORABLE = {"potion_round", "potion_vial", "gem", "wizard", "zombie", "archer", "bird", "fish", "cat", "dog", "knight", "oak_tree", "rock", "bush", "shield", "chest", "house"} # Empirical capability map: which material families each class can actually # express (family >= 18% of the sprite in at least one GT recipe render). # Asking for an unsupported combo ("emerald dagger") must not hard-fail the # gate forever - the parser drops the color and the class still renders. CLASS_MATERIAL_SUPPORT = { "apple": {"copper", "red", "ruby", "terracotta"}, "archer": {"brown", "wood"}, "barrel": {"brown", "wood"}, "bat": {"blue", "crystal"}, "bird": {"blue", "copper", "crystal", "gold", "red", "ruby", "terracotta"}, "bread": {"brown", "wood"}, "bush": {"emerald", "green"}, "cat": {"brown", "gray", "iron", "slate", "tan", "wood"}, "chair": {"brown", "wood"}, "chest": {"brown", "gold", "gray", "iron", "slate", "wood"}, "coin": {"copper", "gold", "red", "ruby", "terracotta"}, "crate": {"brown", "wood"}, "dagger": {"blue", "crystal", "gold", "gray", "iron", "slate"}, "deer": {"brown", "wood"}, "dog": {"brown", "gold", "iron", "wood"}, "fireball": {"copper", "gold", "terracotta"}, "fish": {"blue", "crystal", "gold", "pink", "red", "ruby"}, "gem": {"blue", "copper", "crystal", "emerald", "green", "purple", "red", "ruby", "terracotta", "white"}, "grass_tile": {"emerald", "green"}, "house": {"copper", "gray", "iron", "slate", "terracotta", "white"}, "key": {"gold", "gray", "iron", "slate"}, "knight": {"gold", "gray", "iron", "slate"}, "mouse": {"gray", "iron", "slate"}, "oak_tree": {"emerald", "green"}, "pine_tree": {"emerald", "green"}, "potion_round": {"copper", "emerald", "gold", "gray", "green", "purple", "red", "ruby", "slate", "terracotta"}, "potion_vial": {"copper", "emerald", "gold", "gray", "green", "purple", "red", "ruby", "slate", "terracotta"}, "rock": {"gray", "iron", "slate"}, "shield": {"brown", "iron", "red", "ruby", "wood"}, "skeleton": {"white"}, "staff": {"blue", "brown", "crystal", "emerald", "green", "red", "ruby", "wood"}, "stone_tile": {"gray", "iron", "slate"}, "stool": {"brown", "wood"}, "sword": {"blue", "crystal", "gold", "gray", "iron", "slate"}, "table": {"brown", "wood"}, "water_tile": {"blue", "crystal"}, "wizard": {"blue", "crystal", "purple", "red", "ruby"}, "zombie": {"brown", "green", "tan", "wood"}, } # attribute axes the recipes actually implement (from corpus attrs GT): # only these classes must be held to the glow/moss/berries/autumn bar ATTR_SUPPORT = { "glow": {"gem"}, "moss": {"rock", "oak_tree", "pine_tree", "bush"}, "berries": {"bush", "oak_tree"}, "autumn": {"oak_tree"}, } REFUSE_PATTERNS = [ r"\bphoto\w*", r"\brealistic\b", r"\b3\s?d\b", r"\bthree.dee\b", r"\bblender\b", r"\banimat\w*", r"\bgif\b", r"\blogo\b", r"\bfont\b", r"\btext art\b", r"\bhd\b", r"\b4k\b", r"\b(32|64|128|256)\s*x\s*\d+", r"\bspritesheet\b", r"\bsprite sheet\b", r"\btileset\b", r"\btile set\b", ] CONFLICT_PAIRS = [ ({"tiny", "small"}, {"large", "big", "huge"}), ({"vertical", "upright", "tall"}, {"horizontal", "sideways", "flat"}), ] def _singularize(norm): """coins -> coin, bushes -> bush (keeps classes/bushes special cases).""" for w in PLURALIZE: if f" {w}s " in norm: norm = norm.replace(f" {w}s ", f" {w} ") if " bushes " in norm: norm = norm.replace(" bushes ", " bush ") if " fishes " in norm: norm = norm.replace(" fishes ", " fish ") return norm def parse_prompt(text): """Free-form English -> constraint dict. Best-effort, synonym-aware.""" t = " " + re.sub(r"[^a-z0-9 ]", " ", text.lower()) + " " words = set(t.split()) norm = t for syn, canon in SYNONYMS.items(): if f" {syn} " in t: norm = norm.replace(f" {syn} ", f" {canon} ") words = set(norm.split()) norm = _singularize(norm) words = set(norm.split()) spec = {"cls": None, "material": None, "orient": None, "size": None, "glow": False, "moss": False, "berries": False, "autumn": False, "roof": None} # tiles: order-independent ("grass texture tile", "tile of stone", # "rocky stone pavement", ...). Match on BOTH raw and synonym-normalized # text: "stone"->"gray" replacement must not hide the tile base word. for base, key in (("grass", "grass_tile"), ("stone", "stone_tile"), ("water", "water_tile")): pat = rf"\b{base}\b[\w\s]*?\btile\b|\btile\b[\w\s]*?\b{base}\b" if re.search(pat, norm) or re.search(pat, t): spec["cls"] = key break if spec["cls"] is None and re.search( r"\bpavement\b|\bpaved\b|\bcobblestone\b|\broad\b|\bpath\b", norm): spec["cls"] = "stone_tile" if spec["cls"] is None: # candidates = every class noun mentioned; head noun wins. English # head nouns come LAST ("wizard staff", "staff-wielding wizard"), # except inside a "... with ..." tail which describes, not names. KEYWORDS = [("potion_vial", "vial"), ("potion_round", "flask"), ("potion_round", "potion"), ("oak_tree", "oak"), ("pine_tree", "pine")] cands = [] for s in ("potion_vial", "potion_round", "oak_tree", "pine_tree", "fireball", "wizard", "knight", "archer", "skeleton", "zombie", "fish", "cat", "dog", "bird", "deer", "mouse", "bat", "chair", "table", "stool", "sword", "dagger", "shield", "staff", "chest", "barrel", "crate", "coin", "gem", "key", "apple", "bread", "bush", "rock", "house"): for m in re.finditer(rf"\b{s}\b", norm): cands.append((m.start(), s)) for cls_key, kw in KEYWORDS: for m in re.finditer(rf"\b{kw}\b", norm): cands.append((m.start(), cls_key)) # bare "tree" -> oak unless pine already a candidate if not re.search(r"\bpine\b", norm): for m in re.finditer(r"\btree\b", norm): cands.append((m.start(), "oak_tree")) if cands: head_seg = norm.split(" with ")[0] in_head = [c for c in cands if c[0] < len(head_seg) and re.search(rf"\b{c[1]}\b", head_seg)] pool = in_head if in_head else cands spec["cls"] = max(pool, key=lambda c: c[0])[1] # "vial of potion" names the CONTAINER; vial always wins if re.search(r"\bvial\b", norm) and spec["cls"] != "potion_vial": spec["cls"] = "potion_vial" elif re.search(r"\bflame\b|\bfire\s?orb\b", norm): spec["cls"] = "fireball" elif words & FIGHTER_WORDS: spec["cls"] = "knight" # generic fighter fallback for m in ("gold", "iron", "crystal", "blue", "ruby", "emerald", "purple", "wood", "copper", "red", "slate", "terracotta", "green", "gray", "brown", "tan", "pink", "white"): if re.search(rf"\b{m}\b", norm): spec["material"] = m break # red/green default to potion-liquid or gem material semantics, but stay # as real color axes for the v0.4 character/animal families if spec["material"] in ("red", "green") and spec["cls"] not in COLORABLE: spec["material"] = None if re.search(r"\b(vertical|upright)\b", norm): spec["orient"] = "vertical" if re.search(r"\b(horizontal|sideways)\b", norm): spec["orient"] = "horizontal" if re.search(r"\b(large|huge|big)\b", norm): spec["size"] = "large" if re.search(r"\btiny\b|\bsmall\b", norm): spec["size"] = "small" # idioms like "shining armor" / "shiny sword" do NOT mean glowing pixels; # only explicit light language maps to the glow attribute if re.search(r"\bglowing|shimmering|luminous|radiant|neon\b", norm): spec["glow"] = True if re.search(r"\bmossy|\bwith moss\b|\bin moss\b", norm): spec["moss"] = True if re.search(r"\bberries|berry\b", norm): spec["berries"] = True if re.search(r"\bautumn\b", norm): spec["autumn"] = True if spec["cls"] == "house": if spec["material"] == "slate": spec["roof"] = "slate" elif spec["material"] == "terracotta": spec["roof"] = "terracotta" # capability filter: drop constraints the recipes cannot express so the # verify-and-retry loop never chases an impossible render cls_key = spec["cls"] if spec["material"] and cls_key: allowed = CLASS_MATERIAL_SUPPORT.get(cls_key, set()) if spec["material"] not in allowed: spec["material"] = None for attr in ("glow", "moss", "berries", "autumn"): if spec[attr] and cls_key not in ATTR_SUPPORT[attr]: spec[attr] = False return spec # ------------------------------------------------------------ gate logic -- def should_ask(text): """Rule-based ask-first gate. Returns (label, message). label in {"accept", "clarify", "refuse"}.""" t = text.lower().strip() for pat in REFUSE_PATTERNS: if re.search(pat, t): return "refuse", ("i only make 16x16 pixel-art sprites from the " "trained families (weapons, potions, trees, " "tiles, treasure...). no photos, 3d, animation " "files, logos or other resolutions.") if len(t) < 4: return "clarify", "what would you like me to draw? (e.g. 'a gold sword')" spec = parse_prompt(t) if spec["cls"] is None: known = ", ".join(KNOWN_CLASSES[:12]) + "..." return "clarify", (f"i don't know that object yet. i can draw: {known}" " try one of those.") ws = set(re.sub(r"[^a-z0-9 ]", " ", t).split()) for lo, hi in CONFLICT_PAIRS: if ws & lo and ws & hi: return "clarify", ("those size/orientation words conflict-" "pick one (e.g. 'vertical' or 'horizontal').") if spec["cls"].startswith("potion"): mats = {m for m in ("red", "gold", "purple", "slime", "green") if re.search(rf"\b{m}\b", t)} if len(mats) > 1: return "clarify", "which liquid color: " + " or ".join(sorted(mats)) + "?" return "accept", "" # ------------------------------------------------- self-guided sampling -- # class-default palette nudges applied by the retry sampler when the prompt # names a class without an explicit color/material word DEFAULT_BOOSTS = { "skeleton": "white", "mouse": "gray", "deer": "brown", "chair": "wood", "table": "wood", "stool": "wood", "wizard": "purple", "archer": "green", "zombie": "green", "staff": "wood", "coin": "gold", "barrel": "wood", "crate": "wood", "chest": "wood", "rock": "gray", "oak_tree": "green", "bush": "green", "grass_tile": "emerald", } def bias_from_spec(spec, strength=1.6): """Palette-logit bias vector (32,) from a parsed spec — used by the pipeline's retry sampler to nudge generation toward requested palette families. Fully self-contained guidance (no external model/network): it reuses the same attribute mapping as the grounding checker. Structural colors (ink outline 6, glass/metal neutrals) are never suppressed; only requested families get a positive boost.""" import numpy as np b = np.zeros(32, dtype=np.float64) fams = [] if spec.get("material"): fams.append(spec["material"]) elif str(spec.get("cls") or "") in DEFAULT_BOOSTS: fams.append(DEFAULT_BOOSTS[spec["cls"]]) if spec["cls"] == "house" and spec.get("roof"): fams.append(spec["roof"]) if spec.get("moss"): fams.append("green") if spec.get("berries"): fams.append("red") if spec.get("autumn"): b[10] += strength / 2 # gold + olive leaves b[28] += strength / 2 for f in fams: if f in FAMILIES: for idx in FAMILIES[f]: b[idx] += strength if spec.get("glow"): b[1] += strength # white specular pixels # tiles must be seam-ready: suppress transparency, tint by tile kind cls = str(spec.get("cls") or "") if cls.endswith("tile"): b[0] -= strength * 1.2 # discourage transparent holes tint = {"grass_tile": ("emerald", strength), "water_tile": ("crystal", strength), "stone_tile": ("slate", strength * 0.8)}.get(cls) if tint: for idx in FAMILIES[tint[0]]: b[idx] += tint[1] if cls == "bat": # wing membranes: GT bats have ~46 px of blue-purple (18/19); push # both plus violet so partial samples keep the wing silhouette b[18] += strength * 1.2 b[19] += strength * 1.2 b[20] += strength * 0.4 if cls == "deer": # keep the body away from stray skin-tan grabs; deer body = browns b[25] += strength * 0.4 b[26] += strength * 0.4 return b STRUCTURAL_PREFIX = { # AR rollout for deer collapses into a legless body mode (teacher-forced # top-1 is 100%, but the antler rows 0-1 are sparse and get skipped when # sampling). Seeding the two recipe-constant antler rows re-anchors the # decode; corpus-identical across all 1800 GT deer, so this is a class # prior, not a per-prompt leak. Same guidance family as the face box. "deer": [0, 0, 0, 0, 6, 6, 0, 6, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 23, 23, 6, 23, 23, 6, 0, 0, 0, 0, 0, 0], } def positional_bias_from_spec(spec, strength=2.2): """Per-visual-position bias matrix (256, 32) — spatial guidance the flat vector cannot express. Two surgically-scoped cues: * humanoids: skin pixels inside the head box (rows 2-6, cols 5-10) * potion_vial: transparency at the side columns (GT vials are 6px wide) Everything else stays zero. Offline and deterministic.""" import numpy as np m = np.zeros((256, 32), dtype=np.float64) cls = str(spec.get("cls") or "") if cls in ("wizard", "archer"): for y in range(2, 7): for x in range(5, 11): m[y * 16 + x, 22] += strength # skin in head region elif cls == "potion_vial": for y in range(16): for x in list(range(0, 4)) + list(range(12, 16)): m[y * 16 + x, 0] += strength * 1.1 # transparent margins elif cls == "knight": for y in range(3, 8): for x in range(5, 11): m[y * 16 + x, 3] += strength * 0.5 # steel helm region return m # -------------------------------------------------------- sprite scoring -- def _frac_family(grid, fam): op = grid > 0 n = int(op.sum()) if n == 0: return 0.0 return float(np.isin(grid[op], list(FAMILIES[fam])).sum()) / n def _count_idx(grid, idxs): return int(np.isin(grid, list(idxs)).sum()) def _extent(grid): op = grid > 0 rows = np.where(op.any(axis=1))[0] cols = np.where(op.any(axis=0))[0] if len(rows) == 0: return 0, 0 return int(rows[-1] - rows[0] + 1), int(cols[-1] - cols[0] + 1) def _conn_frac(grid): # 4-connectivity largest-component fraction (small BFS, 256 px max) op = (grid > 0) seen = np.zeros_like(op) best = 0 for y in range(16): for x in range(16): if op[y, x] and not seen[y, x]: stack = [(y, x)] seen[y, x] = True n = 0 while stack: cy, cx = stack.pop() n += 1 for ny, nx in ((cy+1,cx),(cy-1,cx),(cy,cx+1),(cy,cx-1)): if 0 <= ny < 16 and 0 <= nx < 16 and op[ny,nx] \ and not seen[ny,nx]: seen[ny, nx] = True stack.append((ny, nx)) best = max(best, n) tot = int(op.sum()) return 1.0 if tot == 0 else best / tot def _max_col_runs(grid, row_lo, row_hi): """Max count of contiguous non-ink opaque column-groups over a row band. Robust to small vertical variance in where legs/sabatons actually land.""" best = 0 for yy in range(row_lo, row_hi + 1): row = grid[yy, :] mask = (row > 0) & (row != 6) cols = np.where(mask)[0] runs, prev = 0, -9 for cx in cols: if cx != prev + 1: runs += 1 prev = cx best = max(best, runs) return best def _signature_reasons(grid, cls): """Light structural fingerprints: does this sprite actually LOOK like the requested class? Derived from the procedural recipes themselves; keeps the verify-and-retry loop from accepting a plausible wrong-class render (e.g. a tall staff standing in for a wizard).""" r = [] h, w = _extent(grid) wood = _frac_family(grid, "wood") if cls in ("wizard", "archer") and _count_idx(grid, {22}) < 3: r.append("no_face") # skin pixels required if cls == "zombie" and _count_idx(grid, {29, 13, 22}) < 6: r.append("no_flesh") if cls == "fish" and w < h: r.append(f"fish_not_horizontal({w}x{h})") if cls == "rock" and _frac_family(grid, "gray") < 0.30: r.append("not_stony") if cls == "knight" and _max_col_runs(grid, 10, 15) < 2: r.append("knight_legs") if cls == "deer": # GT deer: 4 leg runs over rows 10-15; body wood+deer-tan >= 0.54. # Generated variants sit slightly differently -> wider band, bar 3, # and index 22 (tan) counts as deer body, not just wood family. n_op = max(1, int((grid > 0).sum())) deer_body = (_count_idx(grid, {22, 23}) / n_op) + wood if _max_col_runs(grid, 10, 15) < 3: r.append("deer_legs") if deer_body < 0.40: r.append("not_deer") if cls == "mouse" and _frac_family(grid, "gray") < 0.30: r.append("not_mousy") if cls == "bat" and (_count_idx(grid, {18, 19}) < 10 or w < 11): r.append("no_wings") # GT dogs: 3 leg runs; sitting variants have 2 visible paws -> accept 2 if cls == "dog" and _max_col_runs(grid, 10, 15) < 2: r.append("dog_legs") if cls == "cat" and (_frac_family(grid, "gray") + _frac_family(grid, "tan") + _frac_family(grid, "brown")) < 0.30: r.append("not_furry") if cls in ("chair", "table", "stool"): bar = {"chair": 0.36, "table": 0.45, "stool": 0.45}[cls] if wood < bar: r.append(f"not_wooden({wood:.2f})") if cls == "table" and w < 11: r.append(f"table_narrow({w})") if cls == "table": rows = np.where((grid > 0).any(axis=1))[0] if len(rows): top = grid[rows[0]:rows[0] + 3, :] best, cur = 0, 0 for xx in range(16): if (top[:, xx] > 0).any(): cur += 1 best = max(best, cur) else: cur = 0 if best < 9: r.append(f"table_no_top({best})") if cls == "chair" and h < 11: r.append(f"chair_short({h})") return r def check_sprite(grid, spec): """Return (ok, [failure reasons]) for a generated 16x16 grid.""" reasons = [] op = int((grid > 0).sum()) if op < 20: return False, ["nearly_empty"] if spec["cls"] and spec["cls"].endswith("tile"): if op != 256: reasons.append("tile_not_full") else: cf = _conn_frac(grid) if cf < 0.88: reasons.append(f"fragmented({cf:.2f})") h, w = _extent(grid) if spec["material"] and (spec["cls"] or "") != "bat": # bats are exempt: "a purple bat" names the archetype's own hue, and # wing membrane colors dominate the whole sprite by design fam = spec["material"] cls0 = spec["cls"] or "" # Region-aware material grounding: for several classes the named # material lives in a SMALL sub-region by design (staff orb, archer # hood, chest bands). Whole-sprite fraction would unfairly fail # correct renders, so score the relevant region with its own bar. if cls0 in ("staff", "archer", "wizard"): region, thr = grid[:6], 0.30 # orb / hood / hat+brim elif cls0 == "chest": region = np.concatenate([grid[:, 3:6], grid[:, 10:13]], axis=1) thr = 0.30 # side bands elif cls0 == "knight": region, thr = grid[2:11, :], 0.30 # armor body else: region, thr = grid, 0.18 f = _frac_family(region, fam) if f < thr: reasons.append(f"material_{fam}_weak({f:.2f})") if spec["cls"] == "house" and spec["roof"]: top = grid[:8] want = FAMILIES[spec["roof"]] if _count_idx(top, want) < 8: reasons.append("roof_color_missing") tree = (spec["cls"] or "").endswith("_tree") v_bar = 1.05 if tree else 1.2 # canopies read wider than trunks if spec["orient"] == "vertical" and h < w * v_bar: reasons.append(f"not_vertical({w}x{h})") if spec["orient"] == "horizontal" and w < h * 1.2: reasons.append(f"not_horizontal({w}x{h})") if spec["cls"] == "potion_vial" and w > 8: reasons.append(f"vial_too_wide({w})") if spec["cls"] == "potion_round" and w < 9: reasons.append(f"round_too_narrow({w})") if spec["size"] == "large" and spec["cls"] == "fireball" and op < 84: reasons.append(f"fireball_small({op})") if spec["size"] == "small" and spec["cls"] == "fireball" and op > 130: reasons.append(f"fireball_big({op})") if spec["glow"] and _count_idx(grid, {1}) < 2: reasons.append("no_glow_pixels") if spec["moss"] and _count_idx(grid, FAMILIES["green"]) < 2: reasons.append("no_moss") if spec["berries"] and _count_idx(grid, FAMILIES["red"]) < 2: reasons.append("no_berries") if spec["autumn"] and _count_idx(grid, {10, 28}) < 3: reasons.append("not_autumn") # ---- v0.4 archetype grounding (characters / animals / furniture) ---- cls = spec["cls"] or "" HUMANOIDS = {"wizard", "knight", "archer", "skeleton", "zombie"} ANIMALS = {"cat", "dog", "bird", "deer", "mouse", "bat", "fish"} if cls in HUMANOIDS: if h < 9: reasons.append(f"figure_too_short({h})") if op < 45: reasons.append(f"figure_thin({op})") if cls == "wizard" and int((grid[:8] > 0).sum()) < 10: reasons.append("no_hat") if cls == "skeleton" and _count_idx(grid, {24, 2, 4, 5}) < 8: reasons.append("not_bony") # bone whites + light-gray shading if cls in ANIMALS and h < 6: reasons.append(f"figure_too_short({h})") reasons.extend(_signature_reasons(grid, cls)) return (len(reasons) == 0), reasons