File size: 8,441 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
"""
specialists.py — the CPU assembly layer of the deterministic pipeline.

`Solids` holds one image's solidification primitives (detector boxes, seg masks, a
relative depth map, optional saliency, OCR, tags, class/style) — all in PIXEL space.
The `build_*` functions turn a `Solids` into each task's exact `tasks_vision` JSON, in
the task's declared coord space (via the existing `coords.from_canonical`), reusing the
`derive` engine for the INTEGRATE tasks.

The GPU half — loading the models and populating `Solids` — lives in the Colab runner
(`specialists_gpu`); this module is model-free and unit-tested on CPU.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional

import numpy as np

from . import derive
from .coords import BBox, CoordSpace, XYXY, _scale_for_space, from_canonical
from .tasks_vision import get_task


# ── coordinate helpers (pixel → task space) ──────────────────────────────────
def box_to_space(pix_xyxy, space: CoordSpace, size, ndigits: int = 2) -> list:
    """Pixel-abs [x1,y1,x2,y2] → the task's coord space."""
    return [round(v, ndigits) for v in from_canonical(BBox(*map(float, pix_xyxy)), space, size, XYXY)]


def poly_to_space(pix_flat, space: CoordSpace, size, ndigits: int = 2) -> list:
    """Flat pixel polygon [x1,y1,x2,y2,...] → the task's coord space, per vertex."""
    sx, sy = _scale_for_space(space, size)          # raw = pixel / scale
    out = []
    for i in range(0, len(pix_flat) - 1, 2):
        out.append(round(float(pix_flat[i]) / sx, ndigits))
        out.append(round(float(pix_flat[i + 1]) / sy, ndigits))
    return out


def quad_to_xyxy(quad) -> list:
    """A 4-point polygon (PaddleOCR) — [[x,y]*4] or flat [x,y,...] — → pixel xyxy."""
    a = np.asarray(quad, dtype=float).reshape(-1, 2)
    return [float(a[:, 0].min()), float(a[:, 1].min()), float(a[:, 0].max()), float(a[:, 1].max())]


# ── per-image primitives ─────────────────────────────────────────────────────
@dataclass
class Solids:
    size: tuple                       # (W, H) pixels
    boxes: list = field(default_factory=list)   # [{label, box:[x1,y1,x2,y2] px, score, mask?}]
    depth: Optional[np.ndarray] = None          # HxW relative (Depth-Anything: higher = nearer)
    saliency: Optional[np.ndarray] = None       # HxW float in [0,1]
    tags: list = field(default_factory=list)     # [{label, score}] (RAM++/SigLIP2)
    class_top: list = field(default_factory=list)  # [{label, score}] top-k classification
    ocr: Optional[dict] = None                   # {full_text, lines:[{text, box:[quad px], conf?}]}
    style: Optional[str] = None                  # SigLIP2 style label
    gray: Optional[np.ndarray] = None            # HxW grayscale (for symmetry)
    depth_higher_is_nearer: bool = True
    attr_boxes: list = field(default_factory=list)  # fusion tier: caption-phrase grounding
    #   [{phrase, matched_span, box:[x1,y1,x2,y2] px, score}] from ground_phrases()


# ── SOLID task builders (direct model outputs) ───────────────────────────────
# Emitted lists/strings are truncated to the registry caps (read from the spec,
# never hardcoded) — the generated pydantic models enforce max_items/max_length
# as hard constraints, so an uncapped emit would flunk schema validation on
# busy images even though the content is correct.
def build_bbox(s: Solids) -> dict:
    spec = get_task("bbox_grounding")
    cap = spec.fields["detections"].max_items
    boxes = sorted(s.boxes, key=lambda b: -float(b.get("score", 1.0)))[:cap]
    dets = [{"label": str(b["label"]),
             "box": box_to_space(b["box"], spec.coord_space, s.size),
             "score": round(float(b.get("score", 1.0)), 4)} for b in boxes]
    return {"detections": dets, "count": len(dets)}


def build_segmentation(s: Solids) -> dict:
    spec = get_task("segmentation")
    cap = spec.fields["masks"].max_items
    masked = [b for b in s.boxes if b.get("mask") is not None]
    masked.sort(key=lambda b: -float(np.asarray(b["mask"]).sum()))   # keep largest
    masks = []
    for b in masked:
        if len(masks) >= cap:
            break
        poly = derive.outline_polygon(b["mask"], b["label"])["outline"]
        if len(poly) >= 6:
            masks.append({"label": str(b["label"]),
                          "polygon": poly_to_space(poly, spec.coord_space, s.size)})
    return {"masks": masks}


def build_classification(s: Solids) -> dict:
    top = s.class_top or ([{"label": s.boxes[0]["label"], "score": 1.0}] if s.boxes else
                          [{"label": "unknown", "score": 0.0}])
    top = sorted(top, key=lambda t: -t["score"])[:5]
    return {"label": str(top[0]["label"]), "confidence": round(float(top[0]["score"]), 4),
            "top5": [{"label": str(t["label"]), "score": round(float(t["score"]), 4)} for t in top]}


def build_ocr(s: Solids) -> dict:
    spec = get_task("ocr_text")
    if not s.ocr:
        return {"full_text": "", "lines": []}
    lines_spec = spec.fields["lines"]
    text_cap = next((f.max_str_length for f in lines_spec.nested_fields
                     if f.name == "text"), 512)
    lines = []
    for ln in s.ocr.get("lines", [])[:lines_spec.max_items]:
        d = {"text": str(ln["text"])[:text_cap]}
        if ln.get("box") is not None:
            d["box"] = box_to_space(quad_to_xyxy(ln["box"]), spec.coord_space, s.size)
        lines.append(d)
    full_cap = spec.fields["full_text"].max_str_length
    return {"full_text": str(s.ocr.get("full_text", ""))[:full_cap], "lines": lines}


# ── INTEGRATE task builders (derive engine + coord conversion) ───────────────
def build_spatial(s: Solids) -> dict:
    return derive.spatial_relations(s.boxes, depth=s.depth,
                                    higher_is_nearer=s.depth_higher_is_nearer)


def build_depth_order(s: Solids) -> dict:
    if s.depth is None:
        return {"nearest": "", "farthest": "", "relative_depth": []}
    ents = [{"label": b["label"], "mask": b.get("mask"), "box": b["box"]} for b in s.boxes]
    return derive.depth_order(ents, s.depth, higher_is_nearer=s.depth_higher_is_nearer)


def build_subject(s: Solids) -> dict:
    space = get_task("subject_fixation").coord_space
    out = derive.subject_fixation(s.boxes, s.size, saliency=s.saliency)
    out["primary_subject"]["box"] = box_to_space(out["primary_subject"]["box"], space, s.size)
    return out


def build_outline(s: Solids) -> dict:
    space = get_task("outline_association").coord_space
    masked = [b for b in s.boxes if b.get("mask") is not None]
    if not masked:
        return {"outline": [], "label": ""}
    big = max(masked, key=lambda b: np.asarray(b["mask"]).sum())
    o = derive.outline_polygon(big["mask"], big["label"])
    o["outline"] = poly_to_space(o["outline"], space, s.size)
    return o


def build_style(s: Solids) -> dict:
    style = s.style if s.style in ("photo", "painting", "3d_render", "sketch", "anime", "other") else "other"
    layout = derive.layout_kind(s.boxes, s.size)
    symmetry = derive.symmetry_axis(s.gray) if s.gray is not None else "none"
    return {"style": style, "layout": layout, "symmetry": symmetry}


def build_datatype_diff(s: Solids) -> dict:
    return derive.detect_data_type(s.ocr.get("full_text", "") if s.ocr else "")


def build_datatype_util(s: Solids) -> tuple[dict, bool]:
    """Returns ({data_type, content}, parsed_ok). parsed_ok=False → caller may VLM-fallback."""
    return derive.parse_data_type(s.ocr.get("full_text", "") if s.ocr else "")


# task → builder (SOLID + INTEGRATE only; semantic_association + vqa are VLM)
DETERMINISTIC_BUILDERS = {
    "bbox_grounding": build_bbox,
    "segmentation": build_segmentation,
    "image_classification": build_classification,
    "ocr_text": build_ocr,
    "structural_spatial_awareness": build_spatial,
    "depth_analysis": build_depth_order,
    "subject_fixation": build_subject,
    "outline_association": build_outline,
    "style_structural_awareness": build_style,
    "data_type_differentiation": build_datatype_diff,
    # data_type_utilization handled specially (returns the parsed_ok flag)
}