""" fuse.py — the fusion engine: bind every captured signal into one FusedScene. Consumes a `solids_digest` (compact JSON-able snapshot of a `Solids` — detection boxes + scores + mask polygons + mask quality, continuous depth nearness, saliency scores, OCR with confidence, style/class/symmetry/layout) plus the caption structs (slot-registry JSON from the 9B structurer) and the raw captions, and emits the fused relational representation: entities — addressable instances (person_1, person_2, dog) with position grid, offset-from-center, continuous depth + rank, saliency + rank, mask, and STRATIFIED OWNED ATTRIBUTES (ownership decided by segmentation- polygon containment with confidence + margin thresholds) relations — pairwise predicates + continuous dx/dy/distance/iou/depth-delta counts — synonym-collapsed instance counts shared_basin — attributes NOT confidently assignable (never subjectively grouped), with per-entity likelihoods and the reason scene — voted setting/style/mood + layout/symmetry/OCR/actions quality — retained confidences + grounding accounting + overall_confidence Pure numpy + stdlib + PIL (polygon rasterization) — torch-free, CPU-testable. The ONLY GPU dependency is upstream: the optional `attr_boxes` in the digest come from a second GroundingDINO pass over `phrases_for_grounding(...)` phrases. """ from __future__ import annotations import re from collections import Counter, defaultdict from typing import Optional import numpy as np from . import derive from .coords import CoordSpace from .fuse_schema import (FusedScene, MASK_POLY_MAX_POINTS, MAX_ENTITIES, MAX_RELATION_ENTITIES) from .metrics import _depluralize, _seg_poly_points, _seg_rasterize, labels_match from .specialists import box_to_space, poly_to_space from .strata import _content_tokens, classify_stratum, is_groundable # Containment rasterization grid (mask polygons are ≤64 points; 160² cells is # ample resolution for an ownership FRACTION). _GRID = 160 # Depth-relation threshold on the normalized nearness delta — same magnitude the # spatial_relations engine uses on its normalized per-box depth deltas. _DEPTH_REL_TOL = 0.15 # "near" relation threshold on centroid-distance / image-diagonal. _NEAR_DIST = 0.25 # Positional-cue lexicon for caption-subject binding votes. _POS_LEFT = frozenset({"left", "leftmost"}) _POS_RIGHT = frozenset({"right", "rightmost"}) _POS_FRONT = frozenset({"front", "foreground", "nearest", "closest", "nearer", "closer"}) _POS_BACK = frozenset({"behind", "background", "back", "farthest", "farther", "rear"}) _POS_TALL = frozenset({"tall", "taller", "tallest"}) # ═════════════════════════════════════════════════════════════════════════════ # Digest — the GPU→CPU handoff (also the durability/parquet payload) # ═════════════════════════════════════════════════════════════════════════════ def solids_digest(s) -> dict: """Compact, JSON-able, deterministic snapshot of a Solids. Retains the signals the build_* task projections drop (mask quality, OCR conf, continuous nearness, the full saliency ranking, symmetry magnitudes).""" from .coords import BBox W, H = s.size nearness = (derive.depth_scalars(s.boxes, s.depth, s.depth_higher_is_nearer) if (s.depth is not None and s.boxes) else None) sal = derive.subject_scores(s.boxes, s.size, s.saliency) if s.boxes else [] boxes = [] for i, b in enumerate(s.boxes): mask = b.get("mask") poly = (derive.outline_polygon(mask, b["label"], max_points=MASK_POLY_MAX_POINTS)["outline"] if mask is not None else None) # GDINO emits unclamped boxes (border objects go past the frame) — clip # once at the digest boundary so all downstream geometry is in-range clipped = BBox(*[float(v) for v in b["box"]]).clip((W, H)).as_list() boxes.append({ "label": str(b["label"]), "box": clipped, "score": float(b.get("score", 1.0)), "area_px": derive._area(clipped), "sal": float(sal[i]) if i < len(sal) else 0.0, "nearness": (round(float(nearness[i]), 4) if nearness is not None else None), "mask_poly": poly or None, "mask_quality": (float(b["mask_score"]) if b.get("mask_score") is not None else None), }) ocr = {"full_text": "", "lines": []} if s.ocr: ocr["full_text"] = str(s.ocr.get("full_text", "")) for ln in s.ocr.get("lines", []): q = ln.get("box") flat = ([min(max(float(v), 0.0), float(W if i % 2 == 0 else H)) for xy in q for i, v in enumerate(xy)] if q else None) ocr["lines"].append({"text": str(ln["text"]), "quad": flat, "conf": (float(ln["conf"]) if ln.get("conf") is not None else None)}) attr_boxes = [] for a in getattr(s, "attr_boxes", []): a = dict(a) a["box"] = BBox(*[float(v) for v in a["box"]]).clip((W, H)).as_list() attr_boxes.append(a) return { "size": [int(W), int(H)], "boxes": boxes, "attr_boxes": attr_boxes, "class_top": [{"label": str(c["label"]), "score": float(c["score"])} for c in (s.class_top or [])], "style": s.style, "ocr": ocr, "symmetry": (derive.symmetry_scores(s.gray) if s.gray is not None else None), "layout": derive.layout_kind(s.boxes, s.size), "higher_is_nearer": bool(s.depth_higher_is_nearer), } # ═════════════════════════════════════════════════════════════════════════════ # Caption-side collection + cross-source merge # ═════════════════════════════════════════════════════════════════════════════ def _attr_key(text: str) -> frozenset: """Dedup key: depluralized content-token set (raw + depluralized forms so the crude depluralizer can't split 'dress'/'dres').""" toks = _content_tokens(text) return frozenset(t for tok in toks for t in (tok, _depluralize(tok))) _HEAD_SPLIT_RE = re.compile(r"\b(?:in|on|at|with|of|to|wearing|holding)\b") def _subject_head(name: str) -> str: """Head noun = last content token BEFORE the first post-modifier ("woman in red" → woman, "person on a bench" → person); falls back to the full-name head when the pre-modifier part has no content tokens.""" pre = _HEAD_SPLIT_RE.split((name or "").lower(), 1)[0] toks = _content_tokens(pre) if toks: return toks[-1] toks = _content_tokens(name) return toks[-1] if toks else "" def _collect_merged(caption_structs: dict) -> tuple: """caption_structs: {source: struct-or-None}. Returns (merged_attrs, actions, votes) where merged_attrs = [{text, key, sources, consensus, stratum, parents:{source: subject_name}}] (cross-source dedup: token-set equal-or-subset → canonical = longest text; provenance kept). Subjects are NEVER merged across sources by name — merging happens only through binding downstream.""" sources = [k for k, v in caption_structs.items() if v] n_src = max(1, len(sources)) raw_items = [] actions = [] votes = {"setting": Counter(), "style": Counter(), "mood": {}} for src in sources: st = caption_structs[src] for subj in (st.get("subjects") or []): name = str(subj.get("name") or "").strip() for att in (subj.get("attributes") or []): att = str(att).strip() if att: raw_items.append({"text": att, "source": src, "subject": name}) for act in (st.get("actions") or []): act = str(act).strip() if act: actions.append({"text": act, "source": src}) if st.get("setting"): votes["setting"][str(st["setting"])] += 1 if st.get("style"): votes["style"][str(st["style"])] += 1 if st.get("mood"): votes["mood"][src] = str(st["mood"]) # merge: iterate longest-token-set first so merged records are supersets raw_items.sort(key=lambda it: (-len(_attr_key(it["text"])), it["text"], it["source"])) merged = [] for it in raw_items: key = _attr_key(it["text"]) if not key: continue home = next((m for m in merged if key <= m["key"] or m["key"] <= key), None) if home is None: merged.append({"text": it["text"], "key": key, "sources": [it["source"]], "parents": {it["source"]: it["subject"]}}) else: home["key"] = home["key"] | key if len(it["text"]) > len(home["text"]): home["text"] = it["text"] if it["source"] not in home["sources"]: home["sources"].append(it["source"]) home["parents"].setdefault(it["source"], it["subject"]) for m in merged: m["sources"] = sorted(m["sources"]) m["consensus"] = round(len(m["sources"]) / n_src, 4) m["stratum"] = classify_stratum(m["text"]) # actions: same dedup, no parents actions.sort(key=lambda it: (-len(_attr_key(it["text"])), it["text"], it["source"])) merged_acts = [] for it in actions: key = _attr_key(it["text"]) if not key: continue home = next((m for m in merged_acts if key <= m["key"] or m["key"] <= key), None) if home is None: merged_acts.append({"text": it["text"], "key": key, "sources": [it["source"]]}) else: home["key"] = home["key"] | key if len(it["text"]) > len(home["text"]): home["text"] = it["text"] if it["source"] not in home["sources"]: home["sources"].append(it["source"]) for m in merged_acts: m["sources"] = sorted(m["sources"]) m["consensus"] = round(len(m["sources"]) / n_src, 4) return merged, merged_acts, votes def phrases_for_grounding(caption_structs: dict) -> list: """The canonical phrases the GPU grounding pass should box — merged attribute texts whose stratum is GROUNDABLE, emitted stripped-lowercase (ground_phrases lowercases anyway; matching its normalization keeps the downstream phrase↔attribute lookup exact).""" merged, _, _ = _collect_merged(caption_structs) return sorted({m["text"].strip().lower() for m in merged if is_groundable(m["stratum"])}) # ═════════════════════════════════════════════════════════════════════════════ # Geometry: entities, containment, relations # ═════════════════════════════════════════════════════════════════════════════ def _grid_cell(cx: float, cy: float, W: float, H: float) -> str: col = "left" if cx < W / 3 else ("center" if cx < 2 * W / 3 else "right") row = "upper" if cy < H / 3 else ("middle" if cy < 2 * H / 3 else "lower") return f"{row} {col}" def _build_entities(digest: dict, dedup_iou: float) -> list: """Dedup detector double-boxes, cap by saliency, order left-to-right, and assign _uniq_labels ids. Returns internal entity dicts (pixel space).""" boxes = [dict(b) for b in digest["boxes"]] kept = [] for b in sorted(boxes, key=lambda b: (-b["score"], b["box"][0])): if any(derive._iou(b["box"], k["box"]) >= dedup_iou and labels_match(b["label"], k["label"]) for k in kept): continue kept.append(b) kept.sort(key=lambda b: -b["sal"]) kept = kept[:MAX_ENTITIES] for rank, b in enumerate(kept, 1): b["sal_rank"] = rank kept.sort(key=lambda b: (0.5 * (b["box"][0] + b["box"][2]), b["box"][1])) ids = derive._uniq_labels([b["label"] for b in kept]) for b, eid in zip(kept, ids): b["id"] = eid if any(b["nearness"] is not None for b in kept): by_near = sorted([b for b in kept if b["nearness"] is not None], key=lambda b: -b["nearness"]) for rank, b in enumerate(by_near, 1): b["depth_rank"] = rank return kept def _entity_grid_mask(ent: dict, size, cache: dict): """Rasterized mask polygon on the containment grid (cached per entity).""" eid = ent["id"] if eid in cache: return cache[eid] W, H = size m = None if ent.get("mask_poly"): pts = _seg_poly_points(ent["mask_poly"]) m = _seg_rasterize(pts, _GRID, _GRID / max(1.0, W), _GRID / max(1.0, H)) cache[eid] = m return m def _own_frac(attr_box, ent: dict, size, cache: dict) -> float: """|attr_box ∩ entity mask| / |attr_box| on the grid; box-fraction fallback when the entity has no mask polygon ("box_containment").""" W, H = size m = _entity_grid_mask(ent, size, cache) x1 = int(np.clip(attr_box[0] / W * _GRID, 0, _GRID)) y1 = int(np.clip(attr_box[1] / H * _GRID, 0, _GRID)) x2 = int(np.clip(np.ceil(attr_box[2] / W * _GRID), 0, _GRID)) y2 = int(np.clip(np.ceil(attr_box[3] / H * _GRID), 0, _GRID)) if x2 <= x1 or y2 <= y1: return 0.0 if m is not None: return float(m[y1:y2, x1:x2].sum()) / float((x2 - x1) * (y2 - y1)) # box fallback: inter / area(attr_box) b = ent["box"] ix1, iy1 = max(attr_box[0], b[0]), max(attr_box[1], b[1]) ix2, iy2 = min(attr_box[2], b[2]), min(attr_box[3], b[3]) inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1) a = derive._area(attr_box) return inter / a if a > 0 else 0.0 def _pair_predicates(a: dict, b: dict) -> list: """a→b predicates, same semantics as derive.spatial_relations (dominant axis, containment first, depth via nearness delta) — pinned by a consistency test.""" preds = [] if derive._contains(b["box"], a["box"]): preds.append("inside") else: ca, cb = derive._centroid(a["box"]), derive._centroid(b["box"]) dx, dy = cb[0] - ca[0], cb[1] - ca[1] if abs(dx) >= abs(dy): preds.append("left_of" if dx > 0 else "right_of") else: preds.append("above" if dy > 0 else "below") if a["nearness"] is not None and b["nearness"] is not None: d = a["nearness"] - b["nearness"] if abs(d) >= _DEPTH_REL_TOL: preds.append("in_front_of" if d > 0 else "behind") return preds # ═════════════════════════════════════════════════════════════════════════════ # The fusion # ═════════════════════════════════════════════════════════════════════════════ def fuse(digest: dict, caption_structs: dict, raw_captions: Optional[dict] = None, *, t_own: float = 0.60, t_margin: float = 0.25, dedup_iou: float = 0.75, coord_space: CoordSpace = CoordSpace.NORM_0_1000) -> dict: """→ FusedScene as a schema-validated dict. Deterministic: same inputs → byte-identical json.dumps. See the module docstring for the shape and the ownership cascade; t_own / t_margin are the assignment thresholds (an attribute below them lands in shared_basin with per-entity likelihoods — never guessed).""" W, H = digest["size"] size = (float(W), float(H)) raw_captions = raw_captions or {} ents = _build_entities(digest, dedup_iou) by_id = {e["id"]: e for e in ents} grid_cache: dict = {} # entity output records (attributes attached during the cascade) ent_out = {} for e in ents: cx, cy = derive._centroid(e["box"]) ent_out[e["id"]] = { "id": e["id"], "label": e["label"], "detection_score": round(e["score"], 4), "box": box_to_space(e["box"], coord_space, size), "centroid": poly_to_space([cx, cy], coord_space, size), "area_frac": round(e["area_px"] / (W * H + 1e-9), 4), "position": {"grid": _grid_cell(cx, cy, W, H), "offset_from_center": [round((cx - W / 2) / (W / 2), 4), round((cy - H / 2) / (H / 2), 4)]}, "depth": ({"nearness": round(e["nearness"], 4), "rank": e["depth_rank"]} if e.get("nearness") is not None and e.get("depth_rank") else None), "saliency": {"score": round(e["sal"], 4), "rank": e["sal_rank"]}, "is_primary": e["sal_rank"] == 1, "mask": ({"polygon": poly_to_space(e["mask_poly"], coord_space, size), "quality": (round(e["mask_quality"], 4) if e.get("mask_quality") is not None else None)} if e.get("mask_poly") else None), "caption_bindings": [], "attributes": [], } counts = Counter() for e in ents: counts["person" if labels_match(e["label"], "person") else e["label"]] += 1 people = counts.get("person", 0) merged, merged_acts, votes = _collect_merged(caption_structs) n_sources = sum(1 for v in caption_structs.values() if v) # grounding lookup: canonical phrase -> [attr box records]. ground_phrases # lowercases its input phrases, so BOTH sides normalize to strip().lower() # (an uppercase structurer attribute must not silently lose its grounding). grounded_by_phrase = defaultdict(list) for a in digest.get("attr_boxes", []): grounded_by_phrase[str(a["phrase"]).strip().lower()].append(a) for recs in grounded_by_phrase.values(): recs.sort(key=lambda r: -r["score"]) def _candidates(m) -> tuple: """(candidates, head_ok) — head_ok is False only when a subject head EXISTS and matched no entity (fallback-to-all is then a guess, not evidence). Pose/action attributes fall back to PERSON entities only — verbs apply to agents, not to a baseball glove.""" cands, any_head = [], False for src, subj in sorted(m["parents"].items()): head = _subject_head(subj) any_head = any_head or bool(head) for e in ents: if head and labels_match(head, e["label"]) and e not in cands: cands.append(e) if cands: return cands, True if m.get("stratum") in ("pose", "action"): persons = [e for e in ents if labels_match(e["label"], "person")] if persons: return persons, not any_head return list(ents), not any_head basin, scene_attrs, assigned_attrs = [], [], [] unresolved = [] # (merged, candidates) awaiting the binding pass subj_votes = defaultdict(lambda: defaultdict(float)) # (src, subject) -> {eid: score} subj_nvotes = defaultdict(int) def _attach(eid, m, conf, method, margin=None, gbox=None, gscore=None): rec = {"text": m["text"], "stratum": m["stratum"], "sources": m["sources"], "consensus": m["consensus"], "grounded": gbox is not None, "box": box_to_space(gbox, coord_space, size) if gbox else None, "grounding_score": round(gscore, 4) if gscore is not None else None, "ownership": {"confidence": round(conf, 4), "margin": round(margin, 4) if margin is not None else None, "method": method}, "region_on_owner": None} if gbox is not None: o = by_id[eid] ocx, ocy = derive._centroid(o["box"]) acx, acy = derive._centroid(gbox) hw = max(1.0, (o["box"][2] - o["box"][0]) / 2) hh = max(1.0, (o["box"][3] - o["box"][1]) / 2) rel_y, rel_x = (acy - ocy) / hh, (acx - ocx) / hw rec["region_on_owner"] = { "vertical": "upper" if rel_y < -1 / 3 else ("lower" if rel_y > 1 / 3 else "middle"), "horizontal": "left" if rel_x < -1 / 3 else ("right" if rel_x > 1 / 3 else "center"), "offset": [round(rel_x, 4), round(rel_y, 4)]} ent_out[eid]["attributes"].append(rec) assigned_attrs.append((m, eid, conf)) for src, subj in m["parents"].items(): subj_votes[(src, subj)][eid] += conf subj_nvotes[(src, subj)] += 1 def _to_basin(m, reason, gbox=None, fracs=None): cands = [{"entity_id": e["id"], "likelihood": round(f, 4)} for e, f in (fracs or []) if f >= 0.15] cands.sort(key=lambda c: -c["likelihood"]) basin.append({"text": m["text"], "stratum": m["stratum"], "sources": m["sources"], "consensus": m["consensus"], "reason": reason, "grounded": gbox is not None, "box": box_to_space(gbox, coord_space, size) if gbox else None, "candidates": cands}) # ── pass A: scene routing, single-candidate fast path, grounded assignment ── n_grounded_phrases = 0 for m in merged: if m["stratum"] == "scene_level": scene_attrs.append({"text": m["text"], "stratum": m["stratum"], "sources": m["sources"]}) continue gboxes = (grounded_by_phrase.get(m["text"].strip().lower(), []) if is_groundable(m["stratum"]) else []) if gboxes: n_grounded_phrases += 1 cands, head_ok = _candidates(m) if len(cands) == 1 and head_ok: e = cands[0] if gboxes: f = _own_frac(gboxes[0]["box"], e, size, grid_cache) if f < 0.2: # caption mentions something visibly NOT on this entity _to_basin(m, "low_margin", gboxes[0]["box"], [(e, f)]) continue _attach(e["id"], m, 0.9, "single_entity", gbox=gboxes[0]["box"], gscore=gboxes[0]["score"]) else: _attach(e["id"], m, 0.9, "single_entity") continue if gboxes: if not cands: # zero entities survived detection — grounded but unownable _to_basin(m, "low_margin", gboxes[0]["box"]) continue top = gboxes[0]["score"] accepted = [g for g in gboxes if g["score"] >= 0.75 * top] taken_eids = set() any_assigned = False best_fracs = None for g in accepted: fracs = sorted(((e, _own_frac(g["box"], e, size, grid_cache)) for e in cands), key=lambda ef: -ef[1]) if best_fracs is None: best_fracs = (g, fracs) f1 = fracs[0][1] f2 = fracs[1][1] if len(fracs) > 1 else 0.0 winner = fracs[0][0] if winner["id"] in taken_eids: continue method = ("mask_containment" if _entity_grid_mask(winner, size, grid_cache) is not None else "box_containment") if f1 >= t_own and (f1 - f2) >= t_margin: taken_eids.add(winner["id"]) any_assigned = True _attach(winner["id"], m, f1, method, margin=f1 - f2, gbox=g["box"], gscore=g["score"]) if not any_assigned: g, fracs = best_fracs _to_basin(m, "low_margin", g["box"], fracs) continue unresolved.append((m, cands)) # ── binding: caption subjects ↔ entities (votes from grounded assignments # + positional cues in subject names and raw captions) ─────────────────── def _positional_vote(text: str, cands: list, votes_out: dict): if not cands: return 0 toks = set(_content_tokens(text)) if toks & _POS_LEFT: e = min(cands, key=lambda e: derive._centroid(e["box"])[0]) votes_out[e["id"]] += 0.5 return 1 if toks & _POS_RIGHT: e = max(cands, key=lambda e: derive._centroid(e["box"])[0]) votes_out[e["id"]] += 0.5 return 1 if toks & _POS_FRONT and any(e.get("depth_rank") for e in cands): e = min((e for e in cands if e.get("depth_rank")), key=lambda e: e["depth_rank"]) votes_out[e["id"]] += 0.5 return 1 if toks & _POS_BACK and any(e.get("depth_rank") for e in cands): e = max((e for e in cands if e.get("depth_rank")), key=lambda e: e["depth_rank"]) votes_out[e["id"]] += 0.5 return 1 if toks & _POS_TALL: e = max(cands, key=lambda e: e["box"][3] - e["box"][1]) votes_out[e["id"]] += 0.5 return 1 return 0 bindings = {} # (src, subject) -> (eid, bind_conf) all_subjects = {(src, subj) for m in merged for src, subj in m["parents"].items()} for (src, subj) in sorted(all_subjects): head = _subject_head(subj) cands = [e for e in ents if head and labels_match(head, e["label"])] or list(ents) v = dict(subj_votes.get((src, subj), {})) v = defaultdict(float, v) nv = subj_nvotes.get((src, subj), 0) pos_n = _positional_vote(subj, cands, v) raw = raw_captions.get(src, "") if raw and head: # " [word] " — tight adjacency, so a positional word # in a NEIGHBORING clause can't vote for this subject for mtc in re.finditer(rf"\b(\w+)\s+(?:\w+\s+)?{re.escape(head)}\b", raw.lower()): pos_n += _positional_vote(mtc.group(1), cands, v) # " ... on the " — reject windows crossing an "and" # (clause boundary: "a woman AND a man on the right") for mtc in re.finditer(rf"\b{re.escape(head)}\b([\w\s,]{{0,24}}?)\bon the (\w+)", raw.lower()): if " and " in f" {mtc.group(1)} ": continue pos_n += _positional_vote(mtc.group(2), cands, v) # bind on >=2 containment votes, OR any explicit positional cue (the caption # author's own disambiguation — stronger evidence than one weak containment) if not v or (nv < 2 and pos_n < 1): continue total = sum(v.values()) eid, top = max(sorted(v.items()), key=lambda kv: kv[1]) bind_conf = top / total if total > 0 else 0.0 if bind_conf >= 0.6: bindings[(src, subj)] = (eid, bind_conf) ent_out[eid]["caption_bindings"].append( {"source": src, "subject_name": subj, "confidence": round(bind_conf, 4)}) # ── pass B: unresolved attributes inherit their subject's binding ─────────── for m, cands in unresolved: # collapse per entity (max conf) with DETERMINISTIC iteration order — # set iteration over tuples is process-hash-dependent by_eid: dict = {} for src, subj in sorted(m["parents"].items()): if (src, subj) in bindings: eid, conf = bindings[(src, subj)] by_eid[eid] = max(by_eid.get(eid, 0.0), conf) if len(by_eid) == 1: eid, bind_conf = next(iter(by_eid.items())) _attach(eid, m, bind_conf * 0.6, "caption_binding") elif len(by_eid) > 1: _to_basin(m, "ambiguous_binding", fracs=sorted(((by_id[eid], conf) for eid, conf in by_eid.items()), key=lambda ef: (-ef[1], ef[0]["id"]))) else: reason = ("no_grounding_multi_entity" if is_groundable(m["stratum"]) else "abstract_unbound") n_c = max(1, len(cands)) _to_basin(m, reason, fracs=[(e, 1.0 / n_c) for e in cands]) # ── actions: one person → attach as stratum "action"; else scene-level ───── # (actions are NOT part of the attribute-routing identity # assigned + basin + scene_level == phrases_total — separate accumulator) scene_actions = [] action_confs = [] person_ents = [e for e in ents if labels_match(e["label"], "person")] for m in merged_acts: if len(person_ents) == 1: e = person_ents[0] ent_out[e["id"]]["attributes"].append( {"text": m["text"], "stratum": "action", "sources": m["sources"], "consensus": m["consensus"], "grounded": False, "box": None, "grounding_score": None, "ownership": {"confidence": 0.9, "margin": None, "method": "single_entity"}, "region_on_owner": None}) action_confs.append(0.9) else: scene_actions.append({"text": m["text"], "stratum": "action", "sources": m["sources"]}) # ── relations among the top-K entities by saliency ────────────────────────── rel_ents = sorted(ents, key=lambda e: e["sal_rank"])[:MAX_RELATION_ENTITIES] rel_ents = sorted(rel_ents, key=lambda e: [x["id"] for x in ents].index(e["id"])) diag = (W ** 2 + H ** 2) ** 0.5 or 1.0 relations = [] for i in range(len(rel_ents)): for j in range(i + 1, len(rel_ents)): a, b = rel_ents[i], rel_ents[j] # containment is orientation-independent: put the INNER entity first so # "inside" always reads a-inside-b regardless of left-to-right id order if (derive._contains(a["box"], b["box"]) and not derive._contains(b["box"], a["box"])): a, b = b, a ca, cb = derive._centroid(a["box"]), derive._centroid(b["box"]) depth_delta = (round(a["nearness"] - b["nearness"], 4) if a["nearness"] is not None and b["nearness"] is not None else None) relations.append({ "a": a["id"], "b": b["id"], "predicates": _pair_predicates(a, b), "dx": round((cb[0] - ca[0]) / W, 4), "dy": round((cb[1] - ca[1]) / H, 4), "distance": round(((cb[0] - ca[0]) ** 2 + (cb[1] - ca[1]) ** 2) ** 0.5 / diag, 4), "iou": round(derive._iou(a["box"], b["box"]), 4), "depth_delta": depth_delta, "confidence": round(min(a["score"], b["score"]), 4), }) # ── scene block ───────────────────────────────────────────────────────────── set_votes = votes["setting"] setting_val = None if set_votes: ranked = set_votes.most_common() setting_val = ("unknown" if len(ranked) > 1 and ranked[0][1] == ranked[1][1] else ranked[0][0]) style_votes = votes["style"] style_val = digest.get("style") or (style_votes.most_common(1)[0][0] if style_votes else None) mood_per_source = votes["mood"] mood_val = None if mood_per_source: mood_counts = Counter(mood_per_source.values()) mood_val = mood_counts.most_common(1)[0][0] sym = digest.get("symmetry") sym_axis = "none" if sym: v, h = sym["lr"] >= 0.80, sym["tb"] >= 0.80 sym_axis = "radial" if (v and h) else ("vertical" if v else ("horizontal" if h else "none")) ocr_lines = [] for ln in digest.get("ocr", {}).get("lines", []): q = ln.get("quad") box = None if q: xs, ys = q[0::2], q[1::2] box = box_to_space([min(xs), min(ys), max(xs), max(ys)], coord_space, size) ocr_lines.append({"text": ln["text"], "box": box, "conf": ln.get("conf")}) scene = { "setting": {"value": setting_val, "votes": dict(sorted(set_votes.items()))}, "style": {"value": style_val, "caption_votes": dict(sorted(style_votes.items())), "specialist": digest.get("style")}, "mood": {"value": mood_val, "per_source": dict(sorted(mood_per_source.items()))}, "layout": digest.get("layout", "unknown"), "symmetry": {"axis": sym_axis, "lr": round(sym["lr"], 4) if sym else None, "tb": round(sym["tb"], 4) if sym else None}, "actions": scene_actions, "scene_attributes": scene_attrs, "ocr": {"full_text": digest.get("ocr", {}).get("full_text", ""), "lines": ocr_lines}, "class_top": digest.get("class_top", []), } # ── quality + accounting ──────────────────────────────────────────────────── n_groundable = sum(1 for m in merged if is_groundable(m["stratum"])) n_scene = len(scene_attrs) n_ungroundable = sum(1 for m in merged if not is_groundable(m["stratum"]) and m["stratum"] != "scene_level") n_assigned_attrs = len({id(m) for m, _, _ in assigned_attrs}) mask_qualities = [e["mask_quality"] for e in ents if e.get("mask_quality") is not None] ocr_confs = [l["conf"] for l in ocr_lines if l.get("conf") is not None] det_mean = float(np.mean([e["score"] for e in ents])) if ents else 0.0 own_confs = [c for _, _, c in assigned_attrs] + action_confs n_routed = n_assigned_attrs + len(basin) overall = round( 0.5 * (float(np.mean(own_confs)) if own_confs else 0.0) + 0.3 * det_mean + 0.2 * (n_assigned_attrs / n_routed if n_routed else 0.0), 4) out = { "coord_space": str(coord_space.value if hasattr(coord_space, "value") else coord_space), "image_size": [int(W), int(H)], "counts": {"total_entities": len(ents), "people": people, "by_label": dict(sorted(counts.items()))}, "entities": [ent_out[e["id"]] for e in ents], "relations": relations, "shared_basin": basin, "scene": scene, "quality": { "n_caption_sources": n_sources, "detection_score_mean": round(det_mean, 4), "mask_quality_mean": (round(float(np.mean(mask_qualities)), 4) if mask_qualities else None), "ocr_conf_mean": (round(float(np.mean(ocr_confs)), 4) if ocr_confs else None), "grounding": {"phrases_total": len(merged), "phrases_grounded": n_grounded_phrases, "assigned": n_assigned_attrs, "basin": len(basin), "scene_level": n_scene, "ungroundable": n_ungroundable}, "overall_confidence": overall, }, } # schema-validate + normalize field order (byte-determinism of json.dumps) return FusedScene.model_validate(out).model_dump() # ═════════════════════════════════════════════════════════════════════════════ # semantic_association — the 12th deterministic task (VLM→INTEGRATE reclass) # ═════════════════════════════════════════════════════════════════════════════ def build_semantic_association(scene: dict, max_items: int = 32) -> dict: """FusedScene → the EXISTING registry shape {associations:[{a,relation,b}]} (enum: left_of/right_of/near/is_a/related_to). Deterministic: geometry gives left_of/right_of/near; caption bindings give is_a (bound subject head vs the detector label, e.g. woman is_a person).""" out, seen = [], set() def _emit(a, rel, b): t = (a, rel, b) if t not in seen and len(out) < max_items: seen.add(t) out.append({"a": a, "relation": rel, "b": b}) for r in scene.get("relations", []): for p in r.get("predicates", []): if p in ("left_of", "right_of"): _emit(r["a"], p, r["b"]) if r.get("distance") is not None and r["distance"] <= _NEAR_DIST: _emit(r["a"], "near", r["b"]) for e in scene.get("entities", []): for cb in e.get("caption_bindings", []): head = _subject_head(cb["subject_name"]) if head and head != e["label"] and labels_match(head, e["label"]): _emit(head, "is_a", e["label"]) return {"associations": out}