Text-to-Image
English
numpy
machine-learning
deep-learning
generative-ai
text-2-image
image-generation
open-weights
model-weights
ai-art
pixel-art
game-development
gamedev
game-assets
asset-generator
sprite-generator
offline
tiny-model
numpy-runtime
int8-quantization
self-supervised
procedural-data
gpt
english-prompts
awesome-ai
Upload pxg_tiny/quality.py with huggingface_hub
Browse files- pxg_tiny/quality.py +644 -0
pxg_tiny/quality.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt parsing + attribute grounding checks for PXG-Tiny.
|
| 2 |
+
|
| 3 |
+
The tiny model understands English through its intent encoder; this module is
|
| 4 |
+
the *measurable* side of that understanding: it parses free-form prompts into
|
| 5 |
+
attribute constraints (material, orientation, size, glow, ...) and scores
|
| 6 |
+
generated sprites against them. Used by the pipeline's auto-retry sampler and
|
| 7 |
+
by the generalization eval.
|
| 8 |
+
"""
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
# palette-index families (see config.PALETTE)
|
| 14 |
+
FAMILIES = {
|
| 15 |
+
"gold": {10, 11, 21}, "iron": {3, 4, 5, 30}, "crystal": {16, 17, 18},
|
| 16 |
+
"blue": {16, 17, 18}, "ruby": {7, 8}, "red": {7, 8},
|
| 17 |
+
"emerald": {12, 13, 14}, "green": {12, 13, 14, 29}, "slime": {29, 12, 13},
|
| 18 |
+
"purple": {19, 20}, "amethyst": {19, 20}, "wood": {23, 25, 26, 27},
|
| 19 |
+
"copper": {8, 9}, "slate": {2, 3, 4}, "terracotta": {8, 9},
|
| 20 |
+
"white": {1, 24}, "gray": {2, 3, 4}, "brown": {25, 26, 27},
|
| 21 |
+
"tan": {22, 23}, "pink": {31},
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
SYNONYMS = {
|
| 25 |
+
"golden": "gold", "big": "large", "huge": "large",
|
| 26 |
+
"wooden": "wood", "azure": "blue", "steel": "iron",
|
| 27 |
+
"sapphire": "blue", "amethyst": "purple", "moss-covered": "mossy",
|
| 28 |
+
"boulder": "rock", "gemstone": "gem", "cottage": "house",
|
| 29 |
+
"stone": "gray", "shrub": "bush", "grassy": "grass",
|
| 30 |
+
# v0.4 character / animal synonyms
|
| 31 |
+
"grey": "gray", "mage": "wizard", "sorcerer": "wizard",
|
| 32 |
+
"warlock": "wizard", "ranger": "archer",
|
| 33 |
+
"hunter": "archer", "scout": "archer", "kitty": "cat",
|
| 34 |
+
"kitten": "cat", "puppy": "dog", "hound": "dog",
|
| 35 |
+
"songbird": "bird", "sparrow": "bird", "parrot": "bird",
|
| 36 |
+
"owl": "bird", "hawk": "bird", "eagle": "bird",
|
| 37 |
+
"goldfish": "fish", "undead": "zombie", "corpse": "zombie",
|
| 38 |
+
"fawn": "deer", "doe": "deer", "stag": "deer", "violet": "purple",
|
| 39 |
+
"crimson": "red", "scarlet": "red", "brass": "gold",
|
| 40 |
+
"bronze": "copper", "lawn": "grass", "dungeon": "stone",
|
| 41 |
+
"ocean": "water", "waves": "water",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# generic fighter words: only become "knight" when no other class noun is
|
| 45 |
+
# present (prevents "skeleton warrior" / "zombie fighter" from re-classing)
|
| 46 |
+
FIGHTER_WORDS = {"warrior", "fighter", "soldier", "paladin", "hero"}
|
| 47 |
+
|
| 48 |
+
# plural -> singular for every class noun the parser knows
|
| 49 |
+
PLURALIZE = ["wizard", "knight", "archer", "skeleton", "zombie", "cat",
|
| 50 |
+
"dog", "bird", "fish", "deer", "mouse", "bat", "sword",
|
| 51 |
+
"dagger", "shield", "staff", "chest", "barrel", "crate",
|
| 52 |
+
"coin", "gem", "key", "apple", "bread", "bush", "rock",
|
| 53 |
+
"fireball", "chair", "table", "stool", "house", "tile",
|
| 54 |
+
"potion", "vial", "tree"]
|
| 55 |
+
|
| 56 |
+
# canonical corpus-style caption per class, used by the retry sampler's
|
| 57 |
+
# anchor stage (prompt normalization with our own parser - fully offline)
|
| 58 |
+
ANCHOR_CAPTIONS = {
|
| 59 |
+
"wizard": "a wizard with a purple hat",
|
| 60 |
+
"knight": "a knight in iron armor",
|
| 61 |
+
"archer": "an archer with a green hood",
|
| 62 |
+
"skeleton": "a skeleton warrior",
|
| 63 |
+
"zombie": "a green zombie",
|
| 64 |
+
"cat": "a small gray cat",
|
| 65 |
+
"dog": "a brown dog",
|
| 66 |
+
"bird": "a small blue bird",
|
| 67 |
+
"fish": "a golden fish",
|
| 68 |
+
"deer": "a brown deer",
|
| 69 |
+
"mouse": "a little gray mouse",
|
| 70 |
+
"bat": "a purple bat with wings",
|
| 71 |
+
"sword": "a sword",
|
| 72 |
+
"dagger": "a small dagger",
|
| 73 |
+
"shield": "a round shield",
|
| 74 |
+
"staff": "a wooden staff with an orb",
|
| 75 |
+
"potion_round": "a round potion bottle",
|
| 76 |
+
"potion_vial": "a slim vial of potion",
|
| 77 |
+
"gem": "a shiny gem",
|
| 78 |
+
"coin": "a gold coin",
|
| 79 |
+
"chest": "a wooden chest",
|
| 80 |
+
"key": "a golden key",
|
| 81 |
+
"house": "a small house",
|
| 82 |
+
"barrel": "a wooden barrel",
|
| 83 |
+
"crate": "a wooden crate",
|
| 84 |
+
"apple": "a red apple",
|
| 85 |
+
"bread": "a loaf of bread",
|
| 86 |
+
"fireball": "a fireball",
|
| 87 |
+
"oak_tree": "a green oak tree",
|
| 88 |
+
"pine_tree": "a dark pine tree",
|
| 89 |
+
"bush": "a leafy bush",
|
| 90 |
+
"rock": "a gray rock",
|
| 91 |
+
"chair": "a wooden chair",
|
| 92 |
+
"table": "a wooden table",
|
| 93 |
+
"stool": "a wooden stool",
|
| 94 |
+
"grass_tile": "a grass texture tile",
|
| 95 |
+
"stone_tile": "a stone texture tile",
|
| 96 |
+
"water_tile": "a water texture tile",
|
| 97 |
+
}
|
| 98 |
+
KNOWN_CLASSES = [
|
| 99 |
+
"wizard", "knight", "cat", "dog", "bird", "fish", "sword",
|
| 100 |
+
"potion", "gem", "house", "chest", "tree", "archer", "skeleton",
|
| 101 |
+
"zombie", "deer", "mouse", "bat", "chair", "table", "stool",
|
| 102 |
+
"dagger", "shield", "staff", "vial", "barrel", "crate", "coin",
|
| 103 |
+
"apple", "bread", "oak", "pine", "bush", "rock", "grass tile",
|
| 104 |
+
"stone tile", "water tile", "fireball",
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
# classes whose red/green color words are design axes (never stripped)
|
| 108 |
+
COLORABLE = {"potion_round", "potion_vial", "gem", "wizard", "zombie",
|
| 109 |
+
"archer", "bird", "fish", "cat", "dog", "knight",
|
| 110 |
+
"oak_tree", "rock", "bush", "shield", "chest", "house"}
|
| 111 |
+
|
| 112 |
+
# Empirical capability map: which material families each class can actually
|
| 113 |
+
# express (family >= 18% of the sprite in at least one GT recipe render).
|
| 114 |
+
# Asking for an unsupported combo ("emerald dagger") must not hard-fail the
|
| 115 |
+
# gate forever - the parser drops the color and the class still renders.
|
| 116 |
+
CLASS_MATERIAL_SUPPORT = {
|
| 117 |
+
"apple": {"copper", "red", "ruby", "terracotta"},
|
| 118 |
+
"archer": {"brown", "wood"},
|
| 119 |
+
"barrel": {"brown", "wood"},
|
| 120 |
+
"bat": {"blue", "crystal"},
|
| 121 |
+
"bird": {"blue", "copper", "crystal", "gold", "red", "ruby", "terracotta"},
|
| 122 |
+
"bread": {"brown", "wood"},
|
| 123 |
+
"bush": {"emerald", "green"},
|
| 124 |
+
"cat": {"brown", "gray", "iron", "slate", "tan", "wood"},
|
| 125 |
+
"chair": {"brown", "wood"},
|
| 126 |
+
"chest": {"brown", "gold", "gray", "iron", "slate", "wood"},
|
| 127 |
+
"coin": {"copper", "gold", "red", "ruby", "terracotta"},
|
| 128 |
+
"crate": {"brown", "wood"},
|
| 129 |
+
"dagger": {"blue", "crystal", "gold", "gray", "iron", "slate"},
|
| 130 |
+
"deer": {"brown", "wood"},
|
| 131 |
+
"dog": {"brown", "gold", "iron", "wood"},
|
| 132 |
+
"fireball": {"copper", "gold", "terracotta"},
|
| 133 |
+
"fish": {"blue", "crystal", "gold", "pink", "red", "ruby"},
|
| 134 |
+
"gem": {"blue", "copper", "crystal", "emerald", "green", "purple",
|
| 135 |
+
"red", "ruby", "terracotta", "white"},
|
| 136 |
+
"grass_tile": {"emerald", "green"},
|
| 137 |
+
"house": {"copper", "gray", "iron", "slate", "terracotta", "white"},
|
| 138 |
+
"key": {"gold", "gray", "iron", "slate"},
|
| 139 |
+
"knight": {"gold", "gray", "iron", "slate"},
|
| 140 |
+
"mouse": {"gray", "iron", "slate"},
|
| 141 |
+
"oak_tree": {"emerald", "green"},
|
| 142 |
+
"pine_tree": {"emerald", "green"},
|
| 143 |
+
"potion_round": {"copper", "emerald", "gold", "gray", "green",
|
| 144 |
+
"purple", "red", "ruby", "slate", "terracotta"},
|
| 145 |
+
"potion_vial": {"copper", "emerald", "gold", "gray", "green",
|
| 146 |
+
"purple", "red", "ruby", "slate", "terracotta"},
|
| 147 |
+
"rock": {"gray", "iron", "slate"},
|
| 148 |
+
"shield": {"brown", "iron", "red", "ruby", "wood"},
|
| 149 |
+
"skeleton": {"white"},
|
| 150 |
+
"staff": {"blue", "brown", "crystal", "emerald", "green", "red",
|
| 151 |
+
"ruby", "wood"},
|
| 152 |
+
"stone_tile": {"gray", "iron", "slate"},
|
| 153 |
+
"stool": {"brown", "wood"},
|
| 154 |
+
"sword": {"blue", "crystal", "gold", "gray", "iron", "slate"},
|
| 155 |
+
"table": {"brown", "wood"},
|
| 156 |
+
"water_tile": {"blue", "crystal"},
|
| 157 |
+
"wizard": {"blue", "crystal", "purple", "red", "ruby"},
|
| 158 |
+
"zombie": {"brown", "green", "tan", "wood"},
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
# attribute axes the recipes actually implement (from corpus attrs GT):
|
| 162 |
+
# only these classes must be held to the glow/moss/berries/autumn bar
|
| 163 |
+
ATTR_SUPPORT = {
|
| 164 |
+
"glow": {"gem"},
|
| 165 |
+
"moss": {"rock", "oak_tree", "pine_tree", "bush"},
|
| 166 |
+
"berries": {"bush", "oak_tree"},
|
| 167 |
+
"autumn": {"oak_tree"},
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
REFUSE_PATTERNS = [
|
| 171 |
+
r"\bphoto\w*", r"\brealistic\b", r"\b3\s?d\b", r"\bthree.dee\b",
|
| 172 |
+
r"\bblender\b", r"\banimat\w*", r"\bgif\b", r"\blogo\b", r"\bfont\b",
|
| 173 |
+
r"\btext art\b", r"\bhd\b", r"\b4k\b", r"\b(32|64|128|256)\s*x\s*\d+",
|
| 174 |
+
r"\bspritesheet\b", r"\bsprite sheet\b", r"\btileset\b", r"\btile set\b",
|
| 175 |
+
]
|
| 176 |
+
|
| 177 |
+
CONFLICT_PAIRS = [
|
| 178 |
+
({"tiny", "small"}, {"large", "big", "huge"}),
|
| 179 |
+
({"vertical", "upright", "tall"}, {"horizontal", "sideways", "flat"}),
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _singularize(norm):
|
| 184 |
+
"""coins -> coin, bushes -> bush (keeps classes/bushes special cases)."""
|
| 185 |
+
for w in PLURALIZE:
|
| 186 |
+
if f" {w}s " in norm:
|
| 187 |
+
norm = norm.replace(f" {w}s ", f" {w} ")
|
| 188 |
+
if " bushes " in norm:
|
| 189 |
+
norm = norm.replace(" bushes ", " bush ")
|
| 190 |
+
if " fishes " in norm:
|
| 191 |
+
norm = norm.replace(" fishes ", " fish ")
|
| 192 |
+
return norm
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def parse_prompt(text):
|
| 196 |
+
"""Free-form English -> constraint dict. Best-effort, synonym-aware."""
|
| 197 |
+
t = " " + re.sub(r"[^a-z0-9 ]", " ", text.lower()) + " "
|
| 198 |
+
words = set(t.split())
|
| 199 |
+
norm = t
|
| 200 |
+
for syn, canon in SYNONYMS.items():
|
| 201 |
+
if f" {syn} " in t:
|
| 202 |
+
norm = norm.replace(f" {syn} ", f" {canon} ")
|
| 203 |
+
words = set(norm.split())
|
| 204 |
+
norm = _singularize(norm)
|
| 205 |
+
words = set(norm.split())
|
| 206 |
+
spec = {"cls": None, "material": None, "orient": None, "size": None,
|
| 207 |
+
"glow": False, "moss": False, "berries": False,
|
| 208 |
+
"autumn": False, "roof": None}
|
| 209 |
+
|
| 210 |
+
# tiles: order-independent ("grass texture tile", "tile of stone",
|
| 211 |
+
# "rocky stone pavement", ...). Match on BOTH raw and synonym-normalized
|
| 212 |
+
# text: "stone"->"gray" replacement must not hide the tile base word.
|
| 213 |
+
for base, key in (("grass", "grass_tile"), ("stone", "stone_tile"),
|
| 214 |
+
("water", "water_tile")):
|
| 215 |
+
pat = rf"\b{base}\b[\w\s]*?\btile\b|\btile\b[\w\s]*?\b{base}\b"
|
| 216 |
+
if re.search(pat, norm) or re.search(pat, t):
|
| 217 |
+
spec["cls"] = key
|
| 218 |
+
break
|
| 219 |
+
if spec["cls"] is None and re.search(
|
| 220 |
+
r"\bpavement\b|\bpaved\b|\bcobblestone\b|\broad\b|\bpath\b", norm):
|
| 221 |
+
spec["cls"] = "stone_tile"
|
| 222 |
+
if spec["cls"] is None:
|
| 223 |
+
# candidates = every class noun mentioned; head noun wins. English
|
| 224 |
+
# head nouns come LAST ("wizard staff", "staff-wielding wizard"),
|
| 225 |
+
# except inside a "... with ..." tail which describes, not names.
|
| 226 |
+
KEYWORDS = [("potion_vial", "vial"), ("potion_round", "flask"),
|
| 227 |
+
("potion_round", "potion"), ("oak_tree", "oak"),
|
| 228 |
+
("pine_tree", "pine")]
|
| 229 |
+
cands = []
|
| 230 |
+
for s in ("potion_vial", "potion_round", "oak_tree", "pine_tree",
|
| 231 |
+
"fireball", "wizard", "knight", "archer", "skeleton",
|
| 232 |
+
"zombie", "fish", "cat", "dog", "bird", "deer",
|
| 233 |
+
"mouse", "bat", "chair", "table", "stool",
|
| 234 |
+
"sword", "dagger", "shield", "staff", "chest",
|
| 235 |
+
"barrel", "crate", "coin", "gem", "key", "apple",
|
| 236 |
+
"bread", "bush", "rock", "house"):
|
| 237 |
+
for m in re.finditer(rf"\b{s}\b", norm):
|
| 238 |
+
cands.append((m.start(), s))
|
| 239 |
+
for cls_key, kw in KEYWORDS:
|
| 240 |
+
for m in re.finditer(rf"\b{kw}\b", norm):
|
| 241 |
+
cands.append((m.start(), cls_key))
|
| 242 |
+
# bare "tree" -> oak unless pine already a candidate
|
| 243 |
+
if not re.search(r"\bpine\b", norm):
|
| 244 |
+
for m in re.finditer(r"\btree\b", norm):
|
| 245 |
+
cands.append((m.start(), "oak_tree"))
|
| 246 |
+
if cands:
|
| 247 |
+
head_seg = norm.split(" with ")[0]
|
| 248 |
+
in_head = [c for c in cands if c[0] < len(head_seg)
|
| 249 |
+
and re.search(rf"\b{c[1]}\b", head_seg)]
|
| 250 |
+
pool = in_head if in_head else cands
|
| 251 |
+
spec["cls"] = max(pool, key=lambda c: c[0])[1]
|
| 252 |
+
# "vial of potion" names the CONTAINER; vial always wins
|
| 253 |
+
if re.search(r"\bvial\b", norm) and spec["cls"] != "potion_vial":
|
| 254 |
+
spec["cls"] = "potion_vial"
|
| 255 |
+
elif re.search(r"\bflame\b|\bfire\s?orb\b", norm):
|
| 256 |
+
spec["cls"] = "fireball"
|
| 257 |
+
elif words & FIGHTER_WORDS:
|
| 258 |
+
spec["cls"] = "knight" # generic fighter fallback
|
| 259 |
+
|
| 260 |
+
for m in ("gold", "iron", "crystal", "blue", "ruby", "emerald", "purple",
|
| 261 |
+
"wood", "copper", "red", "slate", "terracotta", "green",
|
| 262 |
+
"gray", "brown", "tan", "pink", "white"):
|
| 263 |
+
if re.search(rf"\b{m}\b", norm):
|
| 264 |
+
spec["material"] = m
|
| 265 |
+
break
|
| 266 |
+
# red/green default to potion-liquid or gem material semantics, but stay
|
| 267 |
+
# as real color axes for the v0.4 character/animal families
|
| 268 |
+
if spec["material"] in ("red", "green") and spec["cls"] not in COLORABLE:
|
| 269 |
+
spec["material"] = None
|
| 270 |
+
|
| 271 |
+
if re.search(r"\b(vertical|upright)\b", norm):
|
| 272 |
+
spec["orient"] = "vertical"
|
| 273 |
+
if re.search(r"\b(horizontal|sideways)\b", norm):
|
| 274 |
+
spec["orient"] = "horizontal"
|
| 275 |
+
if re.search(r"\b(large|huge|big)\b", norm):
|
| 276 |
+
spec["size"] = "large"
|
| 277 |
+
if re.search(r"\btiny\b|\bsmall\b", norm):
|
| 278 |
+
spec["size"] = "small"
|
| 279 |
+
# idioms like "shining armor" / "shiny sword" do NOT mean glowing pixels;
|
| 280 |
+
# only explicit light language maps to the glow attribute
|
| 281 |
+
if re.search(r"\bglowing|shimmering|luminous|radiant|neon\b", norm):
|
| 282 |
+
spec["glow"] = True
|
| 283 |
+
if re.search(r"\bmossy|\bwith moss\b|\bin moss\b", norm):
|
| 284 |
+
spec["moss"] = True
|
| 285 |
+
if re.search(r"\bberries|berry\b", norm):
|
| 286 |
+
spec["berries"] = True
|
| 287 |
+
if re.search(r"\bautumn\b", norm):
|
| 288 |
+
spec["autumn"] = True
|
| 289 |
+
if spec["cls"] == "house":
|
| 290 |
+
if spec["material"] == "slate":
|
| 291 |
+
spec["roof"] = "slate"
|
| 292 |
+
elif spec["material"] == "terracotta":
|
| 293 |
+
spec["roof"] = "terracotta"
|
| 294 |
+
# capability filter: drop constraints the recipes cannot express so the
|
| 295 |
+
# verify-and-retry loop never chases an impossible render
|
| 296 |
+
cls_key = spec["cls"]
|
| 297 |
+
if spec["material"] and cls_key:
|
| 298 |
+
allowed = CLASS_MATERIAL_SUPPORT.get(cls_key, set())
|
| 299 |
+
if spec["material"] not in allowed:
|
| 300 |
+
spec["material"] = None
|
| 301 |
+
for attr in ("glow", "moss", "berries", "autumn"):
|
| 302 |
+
if spec[attr] and cls_key not in ATTR_SUPPORT[attr]:
|
| 303 |
+
spec[attr] = False
|
| 304 |
+
return spec
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# ------------------------------------------------------------ gate logic --
|
| 308 |
+
def should_ask(text):
|
| 309 |
+
"""Rule-based ask-first gate. Returns (label, message).
|
| 310 |
+
label in {"accept", "clarify", "refuse"}."""
|
| 311 |
+
t = text.lower().strip()
|
| 312 |
+
for pat in REFUSE_PATTERNS:
|
| 313 |
+
if re.search(pat, t):
|
| 314 |
+
return "refuse", ("i only make 16x16 pixel-art sprites from the "
|
| 315 |
+
"trained families (weapons, potions, trees, "
|
| 316 |
+
"tiles, treasure...). no photos, 3d, animation "
|
| 317 |
+
"files, logos or other resolutions.")
|
| 318 |
+
if len(t) < 4:
|
| 319 |
+
return "clarify", "what would you like me to draw? (e.g. 'a gold sword')"
|
| 320 |
+
spec = parse_prompt(t)
|
| 321 |
+
if spec["cls"] is None:
|
| 322 |
+
known = ", ".join(KNOWN_CLASSES[:12]) + "..."
|
| 323 |
+
return "clarify", (f"i don't know that object yet. i can draw: {known}"
|
| 324 |
+
" try one of those.")
|
| 325 |
+
ws = set(re.sub(r"[^a-z0-9 ]", " ", t).split())
|
| 326 |
+
for lo, hi in CONFLICT_PAIRS:
|
| 327 |
+
if ws & lo and ws & hi:
|
| 328 |
+
return "clarify", ("those size/orientation words conflict-"
|
| 329 |
+
"pick one (e.g. 'vertical' or 'horizontal').")
|
| 330 |
+
if spec["cls"].startswith("potion"):
|
| 331 |
+
mats = {m for m in ("red", "gold", "purple", "slime", "green")
|
| 332 |
+
if re.search(rf"\b{m}\b", t)}
|
| 333 |
+
if len(mats) > 1:
|
| 334 |
+
return "clarify", "which liquid color: " + " or ".join(sorted(mats)) + "?"
|
| 335 |
+
return "accept", ""
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
# ------------------------------------------------- self-guided sampling --
|
| 339 |
+
# class-default palette nudges applied by the retry sampler when the prompt
|
| 340 |
+
# names a class without an explicit color/material word
|
| 341 |
+
DEFAULT_BOOSTS = {
|
| 342 |
+
"skeleton": "white", "mouse": "gray", "deer": "brown",
|
| 343 |
+
"chair": "wood", "table": "wood", "stool": "wood",
|
| 344 |
+
"wizard": "purple", "archer": "green", "zombie": "green",
|
| 345 |
+
"staff": "wood", "coin": "gold", "barrel": "wood", "crate": "wood",
|
| 346 |
+
"chest": "wood", "rock": "gray", "oak_tree": "green",
|
| 347 |
+
"bush": "green", "grass_tile": "emerald",
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def bias_from_spec(spec, strength=1.6):
|
| 352 |
+
"""Palette-logit bias vector (32,) from a parsed spec — used by the
|
| 353 |
+
pipeline's retry sampler to nudge generation toward requested palette
|
| 354 |
+
families. Fully self-contained guidance (no external model/network):
|
| 355 |
+
it reuses the same attribute mapping as the grounding checker.
|
| 356 |
+
Structural colors (ink outline 6, glass/metal neutrals) are never
|
| 357 |
+
suppressed; only requested families get a positive boost."""
|
| 358 |
+
import numpy as np
|
| 359 |
+
b = np.zeros(32, dtype=np.float64)
|
| 360 |
+
fams = []
|
| 361 |
+
if spec.get("material"):
|
| 362 |
+
fams.append(spec["material"])
|
| 363 |
+
elif str(spec.get("cls") or "") in DEFAULT_BOOSTS:
|
| 364 |
+
fams.append(DEFAULT_BOOSTS[spec["cls"]])
|
| 365 |
+
if spec["cls"] == "house" and spec.get("roof"):
|
| 366 |
+
fams.append(spec["roof"])
|
| 367 |
+
if spec.get("moss"):
|
| 368 |
+
fams.append("green")
|
| 369 |
+
if spec.get("berries"):
|
| 370 |
+
fams.append("red")
|
| 371 |
+
if spec.get("autumn"):
|
| 372 |
+
b[10] += strength / 2 # gold + olive leaves
|
| 373 |
+
b[28] += strength / 2
|
| 374 |
+
for f in fams:
|
| 375 |
+
if f in FAMILIES:
|
| 376 |
+
for idx in FAMILIES[f]:
|
| 377 |
+
b[idx] += strength
|
| 378 |
+
if spec.get("glow"):
|
| 379 |
+
b[1] += strength # white specular pixels
|
| 380 |
+
# tiles must be seam-ready: suppress transparency, tint by tile kind
|
| 381 |
+
cls = str(spec.get("cls") or "")
|
| 382 |
+
if cls.endswith("tile"):
|
| 383 |
+
b[0] -= strength * 1.2 # discourage transparent holes
|
| 384 |
+
tint = {"grass_tile": ("emerald", strength),
|
| 385 |
+
"water_tile": ("crystal", strength),
|
| 386 |
+
"stone_tile": ("slate", strength * 0.8)}.get(cls)
|
| 387 |
+
if tint:
|
| 388 |
+
for idx in FAMILIES[tint[0]]:
|
| 389 |
+
b[idx] += tint[1]
|
| 390 |
+
if cls == "bat":
|
| 391 |
+
# wing membranes: GT bats have ~46 px of blue-purple (18/19); push
|
| 392 |
+
# both plus violet so partial samples keep the wing silhouette
|
| 393 |
+
b[18] += strength * 1.2
|
| 394 |
+
b[19] += strength * 1.2
|
| 395 |
+
b[20] += strength * 0.4
|
| 396 |
+
if cls == "deer":
|
| 397 |
+
# keep the body away from stray skin-tan grabs; deer body = browns
|
| 398 |
+
b[25] += strength * 0.4
|
| 399 |
+
b[26] += strength * 0.4
|
| 400 |
+
return b
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
STRUCTURAL_PREFIX = {
|
| 404 |
+
# AR rollout for deer collapses into a legless body mode (teacher-forced
|
| 405 |
+
# top-1 is 100%, but the antler rows 0-1 are sparse and get skipped when
|
| 406 |
+
# sampling). Seeding the two recipe-constant antler rows re-anchors the
|
| 407 |
+
# decode; corpus-identical across all 1800 GT deer, so this is a class
|
| 408 |
+
# prior, not a per-prompt leak. Same guidance family as the face box.
|
| 409 |
+
"deer": [0, 0, 0, 0, 6, 6, 0, 6, 6, 0, 0, 0, 0, 0, 0, 0,
|
| 410 |
+
0, 0, 0, 6, 23, 23, 6, 23, 23, 6, 0, 0, 0, 0, 0, 0],
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def positional_bias_from_spec(spec, strength=2.2):
|
| 415 |
+
"""Per-visual-position bias matrix (256, 32) — spatial guidance the flat
|
| 416 |
+
vector cannot express. Two surgically-scoped cues:
|
| 417 |
+
* humanoids: skin pixels inside the head box (rows 2-6, cols 5-10)
|
| 418 |
+
* potion_vial: transparency at the side columns (GT vials are 6px wide)
|
| 419 |
+
Everything else stays zero. Offline and deterministic."""
|
| 420 |
+
import numpy as np
|
| 421 |
+
m = np.zeros((256, 32), dtype=np.float64)
|
| 422 |
+
cls = str(spec.get("cls") or "")
|
| 423 |
+
if cls in ("wizard", "archer"):
|
| 424 |
+
for y in range(2, 7):
|
| 425 |
+
for x in range(5, 11):
|
| 426 |
+
m[y * 16 + x, 22] += strength # skin in head region
|
| 427 |
+
elif cls == "potion_vial":
|
| 428 |
+
for y in range(16):
|
| 429 |
+
for x in list(range(0, 4)) + list(range(12, 16)):
|
| 430 |
+
m[y * 16 + x, 0] += strength * 1.1 # transparent margins
|
| 431 |
+
elif cls == "knight":
|
| 432 |
+
for y in range(3, 8):
|
| 433 |
+
for x in range(5, 11):
|
| 434 |
+
m[y * 16 + x, 3] += strength * 0.5 # steel helm region
|
| 435 |
+
return m
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
# -------------------------------------------------------- sprite scoring --
|
| 439 |
+
def _frac_family(grid, fam):
|
| 440 |
+
op = grid > 0
|
| 441 |
+
n = int(op.sum())
|
| 442 |
+
if n == 0:
|
| 443 |
+
return 0.0
|
| 444 |
+
return float(np.isin(grid[op], list(FAMILIES[fam])).sum()) / n
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def _count_idx(grid, idxs):
|
| 448 |
+
return int(np.isin(grid, list(idxs)).sum())
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _extent(grid):
|
| 452 |
+
op = grid > 0
|
| 453 |
+
rows = np.where(op.any(axis=1))[0]
|
| 454 |
+
cols = np.where(op.any(axis=0))[0]
|
| 455 |
+
if len(rows) == 0:
|
| 456 |
+
return 0, 0
|
| 457 |
+
return int(rows[-1] - rows[0] + 1), int(cols[-1] - cols[0] + 1)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def _conn_frac(grid):
|
| 461 |
+
# 4-connectivity largest-component fraction (small BFS, 256 px max)
|
| 462 |
+
op = (grid > 0)
|
| 463 |
+
seen = np.zeros_like(op)
|
| 464 |
+
best = 0
|
| 465 |
+
for y in range(16):
|
| 466 |
+
for x in range(16):
|
| 467 |
+
if op[y, x] and not seen[y, x]:
|
| 468 |
+
stack = [(y, x)]
|
| 469 |
+
seen[y, x] = True
|
| 470 |
+
n = 0
|
| 471 |
+
while stack:
|
| 472 |
+
cy, cx = stack.pop()
|
| 473 |
+
n += 1
|
| 474 |
+
for ny, nx in ((cy+1,cx),(cy-1,cx),(cy,cx+1),(cy,cx-1)):
|
| 475 |
+
if 0 <= ny < 16 and 0 <= nx < 16 and op[ny,nx] \
|
| 476 |
+
and not seen[ny,nx]:
|
| 477 |
+
seen[ny, nx] = True
|
| 478 |
+
stack.append((ny, nx))
|
| 479 |
+
best = max(best, n)
|
| 480 |
+
tot = int(op.sum())
|
| 481 |
+
return 1.0 if tot == 0 else best / tot
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def _max_col_runs(grid, row_lo, row_hi):
|
| 485 |
+
"""Max count of contiguous non-ink opaque column-groups over a row band.
|
| 486 |
+
Robust to small vertical variance in where legs/sabatons actually land."""
|
| 487 |
+
best = 0
|
| 488 |
+
for yy in range(row_lo, row_hi + 1):
|
| 489 |
+
row = grid[yy, :]
|
| 490 |
+
mask = (row > 0) & (row != 6)
|
| 491 |
+
cols = np.where(mask)[0]
|
| 492 |
+
runs, prev = 0, -9
|
| 493 |
+
for cx in cols:
|
| 494 |
+
if cx != prev + 1:
|
| 495 |
+
runs += 1
|
| 496 |
+
prev = cx
|
| 497 |
+
best = max(best, runs)
|
| 498 |
+
return best
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def _signature_reasons(grid, cls):
|
| 502 |
+
"""Light structural fingerprints: does this sprite actually LOOK like the
|
| 503 |
+
requested class? Derived from the procedural recipes themselves; keeps the
|
| 504 |
+
verify-and-retry loop from accepting a plausible wrong-class render
|
| 505 |
+
(e.g. a tall staff standing in for a wizard)."""
|
| 506 |
+
r = []
|
| 507 |
+
h, w = _extent(grid)
|
| 508 |
+
wood = _frac_family(grid, "wood")
|
| 509 |
+
if cls in ("wizard", "archer") and _count_idx(grid, {22}) < 3:
|
| 510 |
+
r.append("no_face") # skin pixels required
|
| 511 |
+
if cls == "zombie" and _count_idx(grid, {29, 13, 22}) < 6:
|
| 512 |
+
r.append("no_flesh")
|
| 513 |
+
if cls == "fish" and w < h:
|
| 514 |
+
r.append(f"fish_not_horizontal({w}x{h})")
|
| 515 |
+
if cls == "rock" and _frac_family(grid, "gray") < 0.30:
|
| 516 |
+
r.append("not_stony")
|
| 517 |
+
if cls == "knight" and _max_col_runs(grid, 10, 15) < 2:
|
| 518 |
+
r.append("knight_legs")
|
| 519 |
+
if cls == "deer":
|
| 520 |
+
# GT deer: 4 leg runs over rows 10-15; body wood+deer-tan >= 0.54.
|
| 521 |
+
# Generated variants sit slightly differently -> wider band, bar 3,
|
| 522 |
+
# and index 22 (tan) counts as deer body, not just wood family.
|
| 523 |
+
n_op = max(1, int((grid > 0).sum()))
|
| 524 |
+
deer_body = (_count_idx(grid, {22, 23}) / n_op) + wood
|
| 525 |
+
if _max_col_runs(grid, 10, 15) < 3:
|
| 526 |
+
r.append("deer_legs")
|
| 527 |
+
if deer_body < 0.40:
|
| 528 |
+
r.append("not_deer")
|
| 529 |
+
if cls == "mouse" and _frac_family(grid, "gray") < 0.30:
|
| 530 |
+
r.append("not_mousy")
|
| 531 |
+
if cls == "bat" and (_count_idx(grid, {18, 19}) < 10 or w < 11):
|
| 532 |
+
r.append("no_wings")
|
| 533 |
+
# GT dogs: 3 leg runs; sitting variants have 2 visible paws -> accept 2
|
| 534 |
+
if cls == "dog" and _max_col_runs(grid, 10, 15) < 2:
|
| 535 |
+
r.append("dog_legs")
|
| 536 |
+
if cls == "cat" and (_frac_family(grid, "gray") +
|
| 537 |
+
_frac_family(grid, "tan") +
|
| 538 |
+
_frac_family(grid, "brown")) < 0.30:
|
| 539 |
+
r.append("not_furry")
|
| 540 |
+
if cls in ("chair", "table", "stool"):
|
| 541 |
+
bar = {"chair": 0.36, "table": 0.45, "stool": 0.45}[cls]
|
| 542 |
+
if wood < bar:
|
| 543 |
+
r.append(f"not_wooden({wood:.2f})")
|
| 544 |
+
if cls == "table" and w < 11:
|
| 545 |
+
r.append(f"table_narrow({w})")
|
| 546 |
+
if cls == "table":
|
| 547 |
+
rows = np.where((grid > 0).any(axis=1))[0]
|
| 548 |
+
if len(rows):
|
| 549 |
+
top = grid[rows[0]:rows[0] + 3, :]
|
| 550 |
+
best, cur = 0, 0
|
| 551 |
+
for xx in range(16):
|
| 552 |
+
if (top[:, xx] > 0).any():
|
| 553 |
+
cur += 1
|
| 554 |
+
best = max(best, cur)
|
| 555 |
+
else:
|
| 556 |
+
cur = 0
|
| 557 |
+
if best < 9:
|
| 558 |
+
r.append(f"table_no_top({best})")
|
| 559 |
+
if cls == "chair" and h < 11:
|
| 560 |
+
r.append(f"chair_short({h})")
|
| 561 |
+
return r
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def check_sprite(grid, spec):
|
| 565 |
+
"""Return (ok, [failure reasons]) for a generated 16x16 grid."""
|
| 566 |
+
reasons = []
|
| 567 |
+
op = int((grid > 0).sum())
|
| 568 |
+
if op < 20:
|
| 569 |
+
return False, ["nearly_empty"]
|
| 570 |
+
if spec["cls"] and spec["cls"].endswith("tile"):
|
| 571 |
+
if op != 256:
|
| 572 |
+
reasons.append("tile_not_full")
|
| 573 |
+
else:
|
| 574 |
+
cf = _conn_frac(grid)
|
| 575 |
+
if cf < 0.88:
|
| 576 |
+
reasons.append(f"fragmented({cf:.2f})")
|
| 577 |
+
h, w = _extent(grid)
|
| 578 |
+
|
| 579 |
+
if spec["material"] and (spec["cls"] or "") != "bat":
|
| 580 |
+
# bats are exempt: "a purple bat" names the archetype's own hue, and
|
| 581 |
+
# wing membrane colors dominate the whole sprite by design
|
| 582 |
+
fam = spec["material"]
|
| 583 |
+
cls0 = spec["cls"] or ""
|
| 584 |
+
# Region-aware material grounding: for several classes the named
|
| 585 |
+
# material lives in a SMALL sub-region by design (staff orb, archer
|
| 586 |
+
# hood, chest bands). Whole-sprite fraction would unfairly fail
|
| 587 |
+
# correct renders, so score the relevant region with its own bar.
|
| 588 |
+
if cls0 in ("staff", "archer", "wizard"):
|
| 589 |
+
region, thr = grid[:6], 0.30 # orb / hood / hat+brim
|
| 590 |
+
elif cls0 == "chest":
|
| 591 |
+
region = np.concatenate([grid[:, 3:6], grid[:, 10:13]], axis=1)
|
| 592 |
+
thr = 0.30 # side bands
|
| 593 |
+
elif cls0 == "knight":
|
| 594 |
+
region, thr = grid[2:11, :], 0.30 # armor body
|
| 595 |
+
else:
|
| 596 |
+
region, thr = grid, 0.18
|
| 597 |
+
f = _frac_family(region, fam)
|
| 598 |
+
if f < thr:
|
| 599 |
+
reasons.append(f"material_{fam}_weak({f:.2f})")
|
| 600 |
+
if spec["cls"] == "house" and spec["roof"]:
|
| 601 |
+
top = grid[:8]
|
| 602 |
+
want = FAMILIES[spec["roof"]]
|
| 603 |
+
if _count_idx(top, want) < 8:
|
| 604 |
+
reasons.append("roof_color_missing")
|
| 605 |
+
tree = (spec["cls"] or "").endswith("_tree")
|
| 606 |
+
v_bar = 1.05 if tree else 1.2 # canopies read wider than trunks
|
| 607 |
+
if spec["orient"] == "vertical" and h < w * v_bar:
|
| 608 |
+
reasons.append(f"not_vertical({w}x{h})")
|
| 609 |
+
if spec["orient"] == "horizontal" and w < h * 1.2:
|
| 610 |
+
reasons.append(f"not_horizontal({w}x{h})")
|
| 611 |
+
if spec["cls"] == "potion_vial" and w > 8:
|
| 612 |
+
reasons.append(f"vial_too_wide({w})")
|
| 613 |
+
if spec["cls"] == "potion_round" and w < 9:
|
| 614 |
+
reasons.append(f"round_too_narrow({w})")
|
| 615 |
+
if spec["size"] == "large" and spec["cls"] == "fireball" and op < 84:
|
| 616 |
+
reasons.append(f"fireball_small({op})")
|
| 617 |
+
if spec["size"] == "small" and spec["cls"] == "fireball" and op > 130:
|
| 618 |
+
reasons.append(f"fireball_big({op})")
|
| 619 |
+
if spec["glow"] and _count_idx(grid, {1}) < 2:
|
| 620 |
+
reasons.append("no_glow_pixels")
|
| 621 |
+
if spec["moss"] and _count_idx(grid, FAMILIES["green"]) < 2:
|
| 622 |
+
reasons.append("no_moss")
|
| 623 |
+
if spec["berries"] and _count_idx(grid, FAMILIES["red"]) < 2:
|
| 624 |
+
reasons.append("no_berries")
|
| 625 |
+
if spec["autumn"] and _count_idx(grid, {10, 28}) < 3:
|
| 626 |
+
reasons.append("not_autumn")
|
| 627 |
+
|
| 628 |
+
# ---- v0.4 archetype grounding (characters / animals / furniture) ----
|
| 629 |
+
cls = spec["cls"] or ""
|
| 630 |
+
HUMANOIDS = {"wizard", "knight", "archer", "skeleton", "zombie"}
|
| 631 |
+
ANIMALS = {"cat", "dog", "bird", "deer", "mouse", "bat", "fish"}
|
| 632 |
+
if cls in HUMANOIDS:
|
| 633 |
+
if h < 9:
|
| 634 |
+
reasons.append(f"figure_too_short({h})")
|
| 635 |
+
if op < 45:
|
| 636 |
+
reasons.append(f"figure_thin({op})")
|
| 637 |
+
if cls == "wizard" and int((grid[:8] > 0).sum()) < 10:
|
| 638 |
+
reasons.append("no_hat")
|
| 639 |
+
if cls == "skeleton" and _count_idx(grid, {24, 2, 4, 5}) < 8:
|
| 640 |
+
reasons.append("not_bony") # bone whites + light-gray shading
|
| 641 |
+
if cls in ANIMALS and h < 6:
|
| 642 |
+
reasons.append(f"figure_too_short({h})")
|
| 643 |
+
reasons.extend(_signature_reasons(grid, cls))
|
| 644 |
+
return (len(reasons) == 0), reasons
|