""" strata.py — the attribute stratification lexicon (fusion tier). Classifies a caption attribute string into a STRATUM — the "disperse the topics" layer that replaces the flat attribute list with typed, routable records. Pure stdlib, deterministic, registry-as-python (same pattern as registry.py / tasks_vision.py): the lexicon is data to iterate on, not code. Routing semantics consumed by fuse.py: - GROUNDABLE strata are sent to the GDINO phrase-grounding pass (they name visible things a detector can box). - "scene_level" bypasses entities entirely -> FusedScene.scene.scene_attributes. - "abstract_quality", "color", and "action" ride the caption-binding-only path (never grounded: a detector box for "elegant" or bare "red" is noise). - Everything is classified; "abstract_quality" is the catch-all default. """ from __future__ import annotations import re from .metrics import _depluralize # Minimal stopword set for head-noun extraction (articles/preps/conjunctions that # can trail a phrase). Deliberately tiny — attribute phrases are short. _STOP = frozenset({ "a", "an", "the", "of", "in", "on", "at", "with", "and", "or", "to", "her", "his", "its", "their", "very", "slightly", }) _TOKEN_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") STRATA: dict[str, frozenset] = { "hair": frozenset({ "hair", "hairstyle", "bangs", "fringe", "ponytail", "pigtails", "twintails", "braid", "braids", "bun", "curls", "updo", "bob", "undercut", "mohawk", "sidelocks", "ahoge", "afro", "dreadlocks", "cornrows", "mullet", "buzzcut", }), "face": frozenset({ "face", "eyes", "eye", "eyebrows", "eyebrow", "eyelashes", "lips", "lip", "mouth", "nose", "cheeks", "cheekbones", "chin", "jaw", "jawline", "forehead", "freckles", "dimples", "beard", "mustache", "stubble", "smile", "grin", "expression", "gaze", "makeup", "lipstick", "eyeliner", "eyeshadow", "blush", "mascara", "teeth", }), "skin": frozenset({ "skin", "complexion", "tan", "tattoo", "tattoos", "scar", "scars", "mole", "birthmark", "wrinkles", "pores", }), "clothing": frozenset({ "dress", "shirt", "t-shirt", "tshirt", "blouse", "top", "skirt", "pants", "trousers", "jeans", "shorts", "jacket", "coat", "hoodie", "sweater", "cardigan", "vest", "suit", "uniform", "kimono", "yukata", "robe", "gown", "leotard", "swimsuit", "bikini", "armor", "cape", "cloak", "apron", "sleeves", "sleeve", "collar", "neckline", "hem", "outfit", "attire", "clothes", "clothing", "costume", "sweatshirt", "leggings", "stockings", "tights", "socks", "corset", "bodysuit", "tunic", "sari", "poncho", }), "accessory": frozenset({ "earrings", "earring", "necklace", "pendant", "choker", "bracelet", "ring", "rings", "watch", "hat", "cap", "beanie", "beret", "crown", "tiara", "headband", "hairband", "ribbon", "bow", "hairpin", "hairclip", "scrunchie", "glasses", "sunglasses", "eyepatch", "monocle", "mask", "scarf", "gloves", "glove", "belt", "bag", "handbag", "backpack", "purse", "umbrella", "fan", "brooch", "badge", "piercing", "anklet", "shoes", "boots", "sandals", "heels", "sneakers", "veil", "headphones", "tie", "bowtie", }), "body": frozenset({ "build", "figure", "physique", "body", "shoulders", "shoulder", "arms", "arm", "hands", "hand", "fingers", "legs", "leg", "thighs", "knees", "feet", "chest", "waist", "hips", "back", "neck", "collarbone", "height", "frame", "posture", "muscles", "abs", "curves", }), "pose": frozenset({ "standing", "sitting", "kneeling", "crouching", "lying", "leaning", "walking", "running", "jumping", "dancing", "posing", "looking", "facing", "reaching", "pointing", "waving", "holding", "carrying", "crossed", "outstretched", "tilted", "turned", "pose", "stance", }), "color": frozenset({ "red", "orange", "yellow", "green", "blue", "purple", "violet", "pink", "brown", "black", "white", "gray", "grey", "silver", "gold", "golden", "blonde", "blond", "brunette", "auburn", "crimson", "scarlet", "teal", "turquoise", "cyan", "magenta", "lavender", "beige", "cream", "ivory", "navy", "maroon", "olive", "platinum", "pastel", "neon", "dark", "light", "pale", "bright", "vivid", "striped", "plaid", "polka-dot", "checkered", "floral", "gradient", }), "abstract_quality": frozenset({ "beautiful", "pretty", "handsome", "cute", "elegant", "graceful", "stylish", "fashionable", "detailed", "intricate", "delicate", "soft", "sharp", "masterpiece", "quality", "aesthetic", "gorgeous", "stunning", "charming", "youthful", "mature", "young", "old", "confident", "shy", "serene", "calm", "cheerful", "melancholic", "mysterious", "dramatic", "ethereal", "dreamy", }), "scene_level": frozenset({ "background", "foreground", "backdrop", "lighting", "light", "shadow", "shadows", "sunlight", "moonlight", "sunset", "sunrise", "dusk", "dawn", "sky", "clouds", "bokeh", "blur", "depth", "wall", "walls", "floor", "ceiling", "window", "windows", "door", "room", "indoors", "outdoors", "outdoor", "indoor", "scenery", "landscape", "cityscape", "street", "forest", "beach", "mountains", "atmosphere", "ambiance", "setting", "scene", "environment", "composition", "framing", }), } # hyphen/compound-adjective suffixes -> stratum ("silver-haired", "blue-eyed") SUFFIX_RULES: tuple = ( ("haired", "hair"), ("eyed", "face"), ("faced", "face"), ("skinned", "skin"), ("sleeved", "clothing"), ("dressed", "clothing"), ("clad", "clothing"), ("shouldered", "body"), ("legged", "body"), ("armed", "body"), ) # -ing words that are NOUNS, not gerunds — exempt from the verb-phrase rule # (data to extend as COCO round-trips surface more) _NOUN_ING = frozenset({ "wedding", "building", "painting", "lighting", "ceiling", "clothing", "evening", "morning", "string", "earring", "ring", "king", "wing", "railing", "awning", }) # Strata whose phrases go to the GDINO grounding pass (visible, boxable things). GROUNDABLE = frozenset({"hair", "face", "skin", "clothing", "accessory", "body", "pose"}) # Any-token tie-break order (only reached when the head noun missed the lexicon). # Concrete/visible strata outrank colors and abstractions. STRATUM_PRECEDENCE = ("hair", "face", "skin", "accessory", "clothing", "body", "pose", "scene_level", "color", "abstract_quality") # The full stratum vocabulary fuse.py may emit ("action" is assigned by fuse.py to # caption `actions` entries directly — it has no lexicon and is never grounded). ALL_STRATA = tuple(STRATA.keys()) + ("action",) def _content_tokens(text: str) -> list: return [t for t in _TOKEN_RE.findall((text or "").lower()) if t not in _STOP] def classify_stratum(text: str) -> str: """Deterministic stratum for an attribute string. 1. head-noun rule: depluralized LAST content token, exact lexicon lookup 2. suffix rules on the head token ("silver-haired" -> hair) 3. any-token lookup in STRATUM_PRECEDENCE order 4. all tokens are color/pattern terms -> color 5. default -> abstract_quality (nothing is ever unclassified) """ toks = _content_tokens(text) if not toks: return "abstract_quality" # _depluralize is crude ("dress"->"dres") — always try the raw form too head_forms = {toks[-1], _depluralize(toks[-1])} for stratum, words in STRATA.items(): if head_forms & words: return stratum for suffix, stratum in SUFFIX_RULES: if any(h.endswith(suffix) for h in head_forms): return stratum forms = [{t, _depluralize(t)} for t in toks] for stratum in STRATUM_PRECEDENCE: words = STRATA[stratum] if any(f & words for f in forms): return stratum if all(f & STRATA["color"] for f in forms): return "color" # verb-phrase heuristic: leading gerund ("playing baseball", "taking a photo", # "running") → pose. Caught live on COCO captions, where the structurer emits # verb phrases as attributes that otherwise fell to abstract_quality. first = toks[0] if first.endswith("ing") and len(first) > 4 and first not in _NOUN_ING: return "pose" return "abstract_quality" def is_groundable(stratum: str) -> bool: return stratum in GROUNDABLE