File size: 8,635 Bytes
fed954e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""
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