File size: 37,517 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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
"""
specialists_gpu.py — the GPU half: load the Apache/MIT specialist models and populate a
`Solids` per image, then score the built task JSON through the EXISTING vlmbench scorers.

This runs on Colab (needs torch + transformers + the model weights). It is deliberately
incremental: the detection hub (GroundingDINO) + the detection-dependent tasks are wired
and validated first (against real COCO GT) so the whole chain — detect → Solids → build →
score_vision_sample — is proven before the other loaders (SAM2, Depth, OCR, SigLIP2, RAM++)
are added. Each loader is a small function returning primitives in PIXEL space.

Model picks are pinned to the plan's Apache/MIT license ledger.
"""

from __future__ import annotations

import json
import time
from typing import Optional

import numpy as np

from .specialists import Solids, build_bbox, build_spatial, build_subject
from .tasks_vision import get_task

# ── detection hub: GroundingDINO (Apache) ────────────────────────────────────
GROUNDING_DINO_ID = "IDEA-Research/grounding-dino-base"   # Apache-2.0, local weights (NOT the API-only 1.5)

# COCO-80 (a known closed vocab for the detection *validation*; production uses the RAM++ tagger)
COCO_CLASSES = [
    "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat",
    "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat",
    "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack",
    "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball",
    "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket",
    "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple",
    "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
    "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse",
    "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink", "refrigerator",
    "book", "clock", "vase", "scissors", "teddy bear", "hair drier", "toothbrush",
]


def load_grounding_dino(device: str = "cuda"):
    """Returns (processor, model). Apache checkpoint, loads via transformers.
    No dtype kwarg — float32 is the default and `torch_dtype=` now deprecation-warns."""
    from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection
    proc = AutoProcessor.from_pretrained(GROUNDING_DINO_ID)
    model = AutoModelForZeroShotObjectDetection.from_pretrained(
        GROUNDING_DINO_ID).to(device).eval()
    return proc, model


def _gdino_prompt(classes) -> str:
    # GroundingDINO wants lowercased, "." separated phrases ending in a period.
    return ". ".join(c.strip().lower() for c in classes) + "."


def detect(proc, model, image, classes, box_threshold: float = 0.30,
           text_threshold: float = 0.25, device: str = "cuda") -> list:
    """image → [{label, box:[x1,y1,x2,y2] PIXEL-ABS, score}]. Forces target_sizes so the
    boxes come back pixel-abs xyxy (else transformers returns normalized cxcywh)."""
    import torch
    inputs = proc(images=image, text=_gdino_prompt(classes), return_tensors="pt").to(device)
    with torch.no_grad():
        outputs = model(**inputs)
    W, H = image.size
    _kw = dict(text_threshold=text_threshold, target_sizes=[(H, W)])  # (height, width) → pixel-abs xyxy
    try:                                                              # transformers renamed the arg
        res = proc.post_process_grounded_object_detection(
            outputs, inputs["input_ids"], threshold=box_threshold, **_kw)[0]
    except TypeError:
        res = proc.post_process_grounded_object_detection(
            outputs, inputs["input_ids"], box_threshold=box_threshold, **_kw)[0]
    # dict.get(k, default) evaluates the default EAGERLY — touching the deprecated
    # "labels" key fires transformers' FutureWarning even when text_labels exists
    labels = res["text_labels"] if "text_labels" in res else res.get("labels")
    out = []
    for box, score, lab in zip(res["boxes"], res["scores"], labels):
        b = [float(v) for v in box.tolist()]
        out.append({"label": str(lab).strip() or "object", "box": b, "score": float(score)})
    return out


def ground_phrases(gdino, image, phrases, box_threshold: float = 0.25,
                   text_threshold: float = 0.20, max_tokens_per_chunk: int = 250,
                   device: str = "cuda") -> list:
    """Fusion-tier grounding pass: caption-derived attribute phrases → boxes.
    → [{phrase, matched_span, box:[x1,y1,x2,y2] px, score}].

    Lower thresholds than the base detection pass — fine-grained phrases score
    lower than category nouns, and the downstream containment+margin gate protects
    precision (recall matters more here: an ungrounded attribute falls back to the
    weaker caption-binding path).

    GDINO's BERT text encoder truncates at 256 TOKENS silently (the model slices
    input_ids with no warning), so phrases are chunked by the processor's OWN
    tokenizer count (≤ max_tokens_per_chunk, headroom for [CLS]/[SEP]) with an
    accounting assert — a silently dropped phrase is the failure mode. GDINO
    returns the matched text SPAN (possibly a sub-span, "earrings" from "silver
    drop earrings"): each hit is re-mapped to its source phrase by maximum token
    overlap within the chunk."""
    import torch
    proc, model = gdino
    phrases = [p.strip().lower() for p in phrases if p and p.strip()]
    if not phrases:
        return []

    tok = getattr(proc, "tokenizer", None)

    def _ntok(p):
        if tok is not None:
            return len(tok(p, add_special_tokens=False)["input_ids"]) + 1   # +1 for ". "
        return len(p.split()) + 1                       # crude fallback, ~1.3 tok/word

    chunks, cur, ntok = [], [], 0
    budget = max_tokens_per_chunk if tok is not None else max_tokens_per_chunk // 2
    for p in phrases:
        t = _ntok(p)
        if cur and ntok + t > budget:
            chunks.append(cur)
            cur, ntok = [], 0
        cur.append(p)
        ntok += t
    if cur:
        chunks.append(cur)
    assert sum(len(c) for c in chunks) == len(phrases), "phrase chunking dropped input"

    W, H = image.size
    out = []
    for chunk in chunks:
        text = ". ".join(chunk) + "."
        inputs = proc(images=image, text=text, return_tensors="pt").to(device)
        with torch.no_grad():
            outputs = model(**inputs)
        _kw = dict(text_threshold=text_threshold, target_sizes=[(H, W)])
        try:                                                          # transformers renamed the arg
            res = proc.post_process_grounded_object_detection(
                outputs, inputs["input_ids"], threshold=box_threshold, **_kw)[0]
        except TypeError:
            res = proc.post_process_grounded_object_detection(
                outputs, inputs["input_ids"], box_threshold=box_threshold, **_kw)[0]
        out.extend(_remap_spans(res, chunk))
    out.sort(key=lambda r: (r["phrase"], -r["score"]))
    return out


def _remap_spans(res, chunk) -> list:
    """GDINO post-process result → attr-box records, re-mapping each matched text
    SPAN back to its source phrase by maximum token overlap within the chunk."""
    labels = res["text_labels"] if "text_labels" in res else res.get("labels")
    out = []
    for box, score, span in zip(res["boxes"], res["scores"], labels):
        span_toks = set(str(span).lower().split())
        best, best_ov = None, 0
        for p in chunk:
            ov = len(span_toks & set(p.split()))
            if ov > best_ov or (ov == best_ov and best and len(p) > len(best)):
                if ov > 0:
                    best, best_ov = p, ov
        if best is None:
            continue
        out.append({"phrase": best, "matched_span": str(span).strip(),
                    "box": [float(v) for v in box.tolist()],
                    "score": float(score)})
    return out


# ── BATCHED specialist paths (throughput: the serial path leaves a 96GB card ──
#    ~90% idle; every model here batches across images) ────────────────────────

def detect_batch(gdino, images, classes, box_threshold: float = 0.30,
                 text_threshold: float = 0.25, device: str = "cuda") -> list:
    """Batched detection: ONE forward for N images sharing one vocabulary.
    → [boxes_list per image] (same record shape as detect())."""
    import torch
    proc, model = gdino
    images = list(images)
    text = _gdino_prompt(classes)
    inputs = proc(images=images, text=[text] * len(images),
                  return_tensors="pt", padding=True).to(device)
    with torch.no_grad():
        outputs = model(**inputs)
    sizes = [(im.size[1], im.size[0]) for im in images]          # (H, W)
    _kw = dict(text_threshold=text_threshold, target_sizes=sizes)
    try:
        res = proc.post_process_grounded_object_detection(
            outputs, inputs["input_ids"], threshold=box_threshold, **_kw)
    except TypeError:
        res = proc.post_process_grounded_object_detection(
            outputs, inputs["input_ids"], box_threshold=box_threshold, **_kw)
    out = []
    for r in res:
        labels = r["text_labels"] if "text_labels" in r else r.get("labels")
        out.append([{"label": str(l).strip() or "object",
                     "box": [float(v) for v in b.tolist()], "score": float(s)}
                    for b, s, l in zip(r["boxes"], r["scores"], labels)])
    return out


def zero_shot_batch(siglip, images, labels, device: str = "cuda",
                    template: str = "a photo of a {}.") -> list:
    """Batched SigLIP2 zero-shot → per-image ranked [{label, score}]."""
    import torch
    proc, model = siglip
    texts = [template.format(l) for l in labels]
    # max_length=64 is REQUIRED: the SigLIP2 Gemma tokenizer has no model_max_length,
    # so padding="max_length" alone silently degrades to no padding (HF's own
    # zero-shot pipeline hardcodes 64 for the siglip family)
    inputs = proc(text=texts, images=list(images), return_tensors="pt",
                  padding="max_length", max_length=64, truncation=True).to(device)
    with torch.no_grad():
        logits = model(**inputs).logits_per_image                # [B, n_text]
    probs = torch.sigmoid(logits).float().cpu().numpy()
    out = []
    for row in probs:
        order = row.argsort()[::-1]
        out.append([{"label": labels[i], "score": float(row[i])} for i in order])
    return out


def depth_map_batch(dp, images) -> list:
    """Batched Depth-Anything → per-image HxW float32 nearness maps.

    The DPT image processor resizes with keep_aspect_ratio and NO padding, so a
    mixed-aspect batch produces ragged tensors and crashes. Images are therefore
    grouped by exact size (one forward per group) — byte-identical to the serial
    path, and full batching whenever a set shares a resolution (the synth set)."""
    import torch
    from PIL import Image as _I
    proc, model = dp
    images = list(images)
    groups: dict = {}
    for i, im in enumerate(images):
        groups.setdefault(im.size, []).append(i)
    out = [None] * len(images)
    for size, idxs in sorted(groups.items()):
        chunk = [images[i] for i in idxs]
        inputs = proc(images=chunk, return_tensors="pt").to(model.device)
        with torch.no_grad():
            pd = model(**inputs).predicted_depth                 # [B, h, w]
        W, H = size
        for arr, i in zip(pd, idxs):
            a = arr.squeeze().float().cpu().numpy().astype(np.float32)
            if a.shape != (H, W):
                a = np.asarray(_I.fromarray(a).resize((W, H), _I.BILINEAR),
                               dtype=np.float32)
            out[i] = a
    return out


def segment_batch(sam, images, boxes_list, device: str = "cuda") -> list:
    """Batched grounded-SAM. Variable per-image box counts are PADDED to the batch
    max (dummy [0,0,2,2] prompts) and the surplus masks dropped — SAM's processor
    needs a rectangular input_boxes tensor. Mutates boxes in place like segment()."""
    if sam is None or not any(boxes_list):
        return boxes_list
    import torch
    proc, model = sam
    keep = [i for i, bl in enumerate(boxes_list) if bl]
    imgs = [images[i] for i in keep]
    max_n = max(len(boxes_list[i]) for i in keep)
    padded = [[[float(v) for v in b["box"]] for b in boxes_list[i]]
              + [[0.0, 0.0, 2.0, 2.0]] * (max_n - len(boxes_list[i]))
              for i in keep]
    inputs = proc(imgs, input_boxes=padded, return_tensors="pt").to(device)
    with torch.no_grad():
        outputs = model(**inputs)
    masks = proc.image_processor.post_process_masks(
        outputs.pred_masks.cpu(), inputs["original_sizes"].cpu(),
        inputs["reshaped_input_sizes"].cpu())                    # [n_obj, 3, H, W] per image
    scores = outputs.iou_scores.cpu().numpy()                    # [B, n_obj, 3]
    for bi, i in enumerate(keep):
        m = np.asarray(masks[bi])
        sc = scores[bi]
        for oi, b in enumerate(boxes_list[i]):                   # surplus (padded) masks ignored
            mo = m[oi]
            if mo.ndim == 3:
                best = int(sc[oi].argmax()) if oi < len(sc) else 0
                b["mask_score"] = float(sc[oi][best]) if oi < len(sc) else None
                mo = mo[best]
            b["mask"] = np.asarray(mo, dtype=bool)
    return boxes_list


def ground_phrases_batch(gdino, images, phrases_list, box_threshold: float = 0.25,
                         text_threshold: float = 0.20, max_tokens_per_chunk: int = 250,
                         device: str = "cuda") -> list:
    """Batched phrase grounding. Images whose phrase text fits ONE chunk (the
    typical case) share a single forward; oversized ones fall back to the serial
    chunked ground_phrases. → per-image attr-box record lists."""
    import torch
    proc, model = gdino
    norm = [[p.strip().lower() for p in (ph or []) if p and p.strip()]
            for ph in phrases_list]
    tok = getattr(proc, "tokenizer", None)
    out = [[] for _ in images]

    easy, hard = [], []
    for i, ph in enumerate(norm):
        if not ph:
            continue
        text = ". ".join(ph) + "."
        ntok = (len(tok(text)["input_ids"]) if tok is not None
                else 2 * len(text.split()))
        (easy if ntok <= max_tokens_per_chunk else hard).append(i)

    if easy:
        imgs = [images[i] for i in easy]
        texts = [". ".join(norm[i]) + "." for i in easy]
        inputs = proc(images=imgs, text=texts, return_tensors="pt",
                      padding=True).to(device)
        with torch.no_grad():
            outputs = model(**inputs)
        sizes = [(im.size[1], im.size[0]) for im in imgs]
        _kw = dict(text_threshold=text_threshold, target_sizes=sizes)
        try:
            res = proc.post_process_grounded_object_detection(
                outputs, inputs["input_ids"], threshold=box_threshold, **_kw)
        except TypeError:
            res = proc.post_process_grounded_object_detection(
                outputs, inputs["input_ids"], box_threshold=box_threshold, **_kw)
        for bi, i in enumerate(easy):
            recs = _remap_spans(res[bi], norm[i])
            recs.sort(key=lambda r: (r["phrase"], -r["score"]))
            out[i] = recs
    for i in hard:
        out[i] = ground_phrases(gdino, images[i], norm[i],
                                box_threshold=box_threshold,
                                text_threshold=text_threshold,
                                max_tokens_per_chunk=max_tokens_per_chunk,
                                device=device)
    return out


def solids_from_detection(image, boxes) -> Solids:
    """Minimal Solids from detection alone (feeds bbox / spatial / subject)."""
    return Solids(size=image.size, boxes=boxes)


# ── validation: detection hub vs COCO GT, through the existing scorers ────────
def validate_detection(n: int = 24, box_threshold: float = 0.30, device: str = "cuda") -> dict:
    """Run GroundingDINO on real COCO images, build the bbox JSON, and score it with the
    EXISTING vlmbench detection scorer — apples-to-apples with the VLM's bbox number."""
    from .datasets import load_gt
    from .metrics import score_vision_sample, score_vision_run

    spec = get_task("bbox_grounding")
    proc, model = load_grounding_dino(device)
    samples = load_gt(spec.gt_dataset, n=n, split=spec.gt_split, dataset="full")
    print(f"[validate_detection] {GROUNDING_DINO_ID} on {len(samples)} COCO images")

    results, t0 = [], time.perf_counter()
    for i, s in enumerate(samples):
        boxes = detect(proc, model, s.image, COCO_CLASSES, box_threshold, device=device)
        pred = build_bbox(solids_from_detection(s.image, boxes))
        mr = score_vision_sample(spec, json.dumps(pred), s.gt, mode="specialist",
                                 image_id=s.image_id, image_size=s.size)
        results.append(mr)
        if i < 3:
            print(f"  {s.image_id}: {len(boxes)} boxes, primary={mr.primary_score}")
    dt = time.perf_counter() - t0
    run = score_vision_run(results, model="grounding-dino-base", category=spec.category, mode="specialist")
    out = {"model": GROUNDING_DINO_ID, "n": len(samples),
           "primary_score_mean": run.primary_score_mean, "schema_valid_rate": run.schema_valid_rate,
           "img_per_s": round(len(samples) / max(0.001, dt), 2)}
    print(f"\n[validate_detection] mean primary={out['primary_score_mean']}  "
          f"valid={out['schema_valid_rate']}  {out['img_per_s']} img/s")
    print("Compare vs the VLM bbox_grounding effective yield (~0.16–0.30 in the vlmbench).")
    return out


# ── depth hub: Depth-Anything-V2-Small (Apache; higher = nearer) ─────────────
DEPTH_ID = "depth-anything/Depth-Anything-V2-Small-hf"   # ONLY Small is Apache

def load_depth_anything(device: str = "cuda"):
    """Returns (processor, model). Direct model call — the transformers pipeline()
    warns ("use a dataset") when invoked per-image on GPU and adds dispatch overhead."""
    from transformers import AutoImageProcessor, AutoModelForDepthEstimation
    proc = AutoImageProcessor.from_pretrained(DEPTH_ID)
    model = AutoModelForDepthEstimation.from_pretrained(DEPTH_ID).to(device).eval()
    return proc, model


def depth_map(dp, image):
    """HxW float32 relative depth; Depth-Anything convention: HIGHER = NEARER."""
    import torch
    proc, model = dp
    inputs = proc(images=image, return_tensors="pt").to(model.device)
    with torch.no_grad():
        pd = model(**inputs).predicted_depth
    arr = pd.squeeze().float().cpu().numpy().astype(np.float32)
    # resize to image size if the model returned a different resolution
    W, H = image.size
    if arr.shape != (H, W):
        from PIL import Image as _I
        arr = np.asarray(_I.fromarray(arr).resize((W, H), _I.BILINEAR), dtype=np.float32)
    return arr


# ── segmentation hub: SAM v1 (Apache), prompted by detection boxes ───────────
#   SAM2's transformers processor is currently broken (missing preprocessor_config on the -hf
#   repo); SAM v1 is equally Apache-2.0, box-promptable, and rock-solid in transformers.
SAM_ID = "facebook/sam-vit-base"

def load_sam(device: str = "cuda"):
    """Returns (processor, model) or None. SAM v1 via transformers (SamModel/SamProcessor)."""
    try:
        from transformers import SamModel, SamProcessor
        proc = SamProcessor.from_pretrained(SAM_ID)
        model = SamModel.from_pretrained(SAM_ID).to(device).eval()
        return proc, model
    except Exception as e:
        print(f"[load_sam] SAM unavailable: {type(e).__name__}: {e}")
        return None


def segment(sam, image, boxes, device: str = "cuda"):
    """Attach a boolean mask to each box dict (in place). Grounded-SAM: box → mask.
    Picks the highest-IoU of SAM's 3 mask proposals per box."""
    if sam is None or not boxes:
        return boxes
    import torch
    proc, model = sam
    input_boxes = [[[float(v) for v in b["box"]] for b in boxes]]     # [image][obj][xyxy]
    inputs = proc(image, input_boxes=input_boxes, return_tensors="pt").to(device)
    with torch.no_grad():
        outputs = model(**inputs)
    masks = proc.image_processor.post_process_masks(
        outputs.pred_masks.cpu(), inputs["original_sizes"].cpu(),
        inputs["reshaped_input_sizes"].cpu())[0]                     # [obj, n_masks, H, W]
    m = np.asarray(masks)
    scores = outputs.iou_scores.cpu().numpy()[0]                      # [obj, n_masks]
    for i, b in enumerate(boxes):
        if i >= len(m):
            break
        mi = m[i]
        if mi.ndim == 3:                                             # [n_masks, H, W] → best by IoU
            best = int(scores[i].argmax()) if i < len(scores) else 0
            b["mask_score"] = float(scores[i][best]) if i < len(scores) else None
            mi = mi[best]
        b["mask"] = np.asarray(mi, dtype=bool)
    return boxes


# ── classification / style hub: SigLIP2 (Apache) zero-shot ───────────────────
SIGLIP_ID = "google/siglip2-so400m-patch14-384"
STYLE_LABELS = ["photo", "painting", "3d_render", "sketch", "anime", "other"]

def load_siglip(device: str = "cuda"):
    from transformers import AutoProcessor, AutoModel
    from transformers.utils import logging as hf_logging
    # The checkpoint's config carries CLIP-tokenizer bos/eos ids (49406/49407) that
    # newer transformers flags against the 32k text vocab. SigLIP never generates,
    # so the ids are inert — silence the config validation for just this load.
    prev = hf_logging.get_verbosity()
    hf_logging.set_verbosity_error()
    try:
        proc = AutoProcessor.from_pretrained(SIGLIP_ID)
        model = AutoModel.from_pretrained(SIGLIP_ID).to(device).eval()
    finally:
        hf_logging.set_verbosity(prev)
    return proc, model


def zero_shot(siglip, image, labels, device: str = "cuda", template: str = "a photo of a {}.") -> list:
    """SigLIP2 zero-shot → [{label, score}] sorted desc (sigmoid, not softmax)."""
    import torch
    proc, model = siglip
    texts = [template.format(l) for l in labels]
    # max_length=64 required — see zero_shot_batch (SigLIP2 tokenizer has no
    # model_max_length, so padding="max_length" alone silently doesn't pad)
    inputs = proc(text=texts, images=image, return_tensors="pt", padding="max_length",
                  max_length=64, truncation=True).to(device)
    with torch.no_grad():
        logits = model(**inputs).logits_per_image[0]
    probs = torch.sigmoid(logits).float().cpu().numpy()
    order = probs.argsort()[::-1]
    return [{"label": labels[i], "score": float(probs[i])} for i in order]


# ── OCR hub: EasyOCR (Apache, torch — no Paddle/torch CUDA conflict) ──────────
def load_ocr(device: str = "cuda"):
    try:
        import easyocr
        return easyocr.Reader(["en"], gpu=(device == "cuda"))
    except Exception as e:
        print(f"[load_ocr] EasyOCR unavailable: {type(e).__name__}: {e}")
        return None


def ocr_read(reader, image) -> dict:
    """EasyOCR → {full_text, lines:[{text, box:[quad px], conf}]}. Confidence is
    RETAINED (the fusion tier carries it; build_ocr ignores the extra key)."""
    if reader is None:
        return {"full_text": "", "lines": []}
    res = reader.readtext(np.asarray(image))     # [(quad, text, conf), ...]
    lines = [{"text": str(t), "box": [[float(x), float(y)] for x, y in quad],
              "conf": float(c)} for quad, t, c in res]
    return {"full_text": " ".join(l["text"] for l in lines), "lines": lines}


# ── generic per-task validation through the existing scorers ─────────────────
SHAPE_CLASSES = ["red circle", "green circle", "blue circle"]   # synthetic-shape GT vocab

# task → dict of which models it needs, the GDINO vocab, and (optional) a REAL-image GT
# override that replaces the synthetic GT in the task spec.
_TASK_CFG = {
    "bbox_grounding":               dict(vocab=COCO_CLASSES, gdino=True),
    "segmentation":                 dict(vocab=COCO_CLASSES, gdino=True, masks=True, gt="coco_segmentation"),
    "outline_association":          dict(vocab=COCO_CLASSES, gdino=True, masks=True, gt="coco_outline"),
    "subject_fixation":             dict(vocab=COCO_CLASSES, gdino=True, gt="coco_subject"),
    # still synthetic — need real depth (NYU/DIODE) + relations (Visual Genome); next real-GT pass
    "depth_analysis":               dict(vocab=SHAPE_CLASSES, gdino=True, depth=True, masks=True),
    "structural_spatial_awareness": dict(vocab=SHAPE_CLASSES, gdino=True, depth=True),
    "image_classification":         dict(siglip=True),                       # vocab from GT labels
    "style_structural_awareness":   dict(gdino=True, siglip=True, gray=True),  # style has no real GT
    "ocr_text":                     dict(ocr=True),
    "data_type_differentiation":    dict(ocr=True),                          # rendered-format GT is synthetic
    "data_type_utilization":        dict(ocr=True),
}


def validate_task(task: str, n: int = 24, device: str = "cuda", *, gdino=None,
                  depth_pipe=None, sam=None, siglip=None, ocr=None) -> dict:
    """Run the specialist/derive chain for one task and score it with the vlmbench scorer."""
    from .datasets import load_gt
    from .metrics import score_vision_sample, score_vision_run
    from .specialists import (Solids, build_bbox, build_spatial, build_subject,
                              build_depth_order, build_segmentation, build_outline,
                              build_classification, build_style, build_ocr,
                              build_datatype_diff, build_datatype_util)

    spec = get_task(task)
    cfg = _TASK_CFG[task]
    gt_key = cfg.get("gt", spec.gt_dataset)               # real-image GT override when available
    samples = load_gt(gt_key, n=n, split=spec.gt_split or "", dataset="full")

    # candidate label set for zero-shot classification: the classes present in this GT slice
    class_vocab = None
    if task == "image_classification":
        seen = []
        for s in samples:
            for l in (s.gt.get("labels", []) if isinstance(s.gt, dict) else []):
                if l not in seen:
                    seen.append(l)
        class_vocab = seen or ["object"]

    results = []
    for s in samples:
        sol = Solids(size=s.image.size)
        if cfg.get("gdino") and gdino is not None:
            sol.boxes = detect(gdino[0], gdino[1], s.image, cfg.get("vocab", COCO_CLASSES), device=device)
            if cfg.get("masks") and sam is not None:
                sol.boxes = segment(sam, s.image, sol.boxes, device=device)
        if cfg.get("depth") and depth_pipe is not None:
            sol.depth = depth_map(depth_pipe, s.image)
        if cfg.get("gray"):
            sol.gray = np.asarray(s.image.convert("L"), dtype=np.float32)
        if cfg.get("siglip") and siglip is not None:
            if task == "image_classification":
                sol.class_top = zero_shot(siglip, s.image, class_vocab, device=device)[:5]
            if task == "style_structural_awareness":
                sol.style = zero_shot(siglip, s.image, STYLE_LABELS, device=device)[0]["label"]
        if cfg.get("ocr") and ocr is not None:
            sol.ocr = ocr_read(ocr, s.image)

        if task == "depth_analysis":
            pred = build_depth_order(sol)
        elif task == "segmentation":
            pred = build_segmentation(sol)
        elif task == "outline_association":
            pred = build_outline(sol)
        elif task == "structural_spatial_awareness":
            pred = build_spatial(sol)
        elif task == "subject_fixation":
            pred = build_subject(sol)
        elif task == "bbox_grounding":
            pred = build_bbox(sol)
        elif task == "image_classification":
            pred = build_classification(sol)
        elif task == "style_structural_awareness":
            pred = build_style(sol)
        elif task == "ocr_text":
            pred = build_ocr(sol)
        elif task == "data_type_differentiation":
            pred = build_datatype_diff(sol)
        elif task == "data_type_utilization":
            pred = build_datatype_util(sol)[0]
        else:
            raise KeyError(task)

        mr = score_vision_sample(spec, json.dumps(pred), s.gt, mode="specialist",
                                 image_id=s.image_id, image_size=s.size)
        results.append(mr)
    run = score_vision_run(results, model="specialist", category=task, mode="specialist")
    return {"task": task, "n": len(samples), "primary": run.primary_score_mean,
            "valid": run.schema_valid_rate}


class SpecialistPipeline:
    """Simplified interface: load the Apache/MIT specialist models ONCE, then `extract(image)`
    returns every deterministic task's JSON (the production entry point for `tasks_json`).

        pipe = SpecialistPipeline()
        tasks = pipe.extract(pil_image)   # {"bbox_grounding": {...}, "segmentation": {...}, ...}
    """
    DEFAULT_VOCAB = COCO_CLASSES

    def __init__(self, device: str = "cuda", with_ocr: bool = True):
        self.device = device
        self.gdino = load_grounding_dino(device)
        self.depth = load_depth_anything(device)
        self.sam = load_sam(device)
        self.siglip = load_siglip(device)
        self.ocr = load_ocr(device) if with_ocr else None

    def solidify(self, image, vocab=None):
        """Run every specialist once → a `Solids` (primitives in pixel space)."""
        vocab = vocab or self.DEFAULT_VOCAB
        s = Solids(size=image.size)
        s.boxes = detect(self.gdino[0], self.gdino[1], image, vocab, device=self.device)
        s.boxes = segment(self.sam, image, s.boxes, device=self.device)
        s.depth = depth_map(self.depth, image)
        s.gray = np.asarray(image.convert("L"), dtype=np.float32)
        if self.siglip is not None:
            s.class_top = zero_shot(self.siglip, image, vocab, device=self.device)[:5]
            s.style = zero_shot(self.siglip, image, STYLE_LABELS, device=self.device)[0]["label"]
        if self.ocr is not None:
            s.ocr = ocr_read(self.ocr, image)
        return s

    @staticmethod
    def _build_tasks(s) -> dict:
        """Solids → {task_name: task_json} for all 11 deterministic tasks."""
        from .specialists import (build_bbox, build_segmentation, build_classification,
                                  build_ocr, build_spatial, build_depth_order, build_subject,
                                  build_outline, build_style, build_datatype_diff,
                                  build_datatype_util)
        util, _ = build_datatype_util(s)
        return {
            "bbox_grounding": build_bbox(s),
            "segmentation": build_segmentation(s),
            "outline_association": build_outline(s),
            "subject_fixation": build_subject(s),
            "depth_analysis": build_depth_order(s),
            "structural_spatial_awareness": build_spatial(s),
            "image_classification": build_classification(s),
            "style_structural_awareness": build_style(s),
            "ocr_text": build_ocr(s),
            "data_type_differentiation": build_datatype_diff(s),
            "data_type_utilization": util,
        }

    def extract(self, image, vocab=None) -> dict:
        """→ {task_name: task_json} for all 11 deterministic tasks (one solidify pass)."""
        return self._build_tasks(self.solidify(image, vocab))

    def ground(self, image, phrases) -> list:
        """Fusion grounding pass: caption phrases → attr-box records (GDINO reused)."""
        return ground_phrases(self.gdino, image, phrases, device=self.device)

    def solidify_batch(self, images, vocab=None, phrases_list=None,
                       batch: int = 16, gdino_batch: int = 2) -> list:
        """Batched solidify: SAM/depth/SigLIP run at `batch` images per forward;
        GroundingDINO runs in sub-chunks of `gdino_batch` — its deformable
        attention's activation memory EXPLODES with padded batches (measured on
        the 96GB Blackwell: B2 = 11GB, B16 = 42GB for LESS throughput), so ~2 is
        its sweet spot. EasyOCR stays serial (4% of the budget). → [Solids]
        aligned with `images`, same output contract as solidify()."""
        vocab = vocab or self.DEFAULT_VOCAB
        images = list(images)
        solids = []
        for start in range(0, len(images), batch):
            chunk = images[start:start + batch]
            p_chunk = (phrases_list[start:start + batch]
                       if phrases_list is not None else None)
            boxes_list = []
            for s2 in range(0, len(chunk), gdino_batch):
                boxes_list.extend(detect_batch(
                    self.gdino, chunk[s2:s2 + gdino_batch], vocab,
                    device=self.device))
            boxes_list = segment_batch(self.sam, chunk, boxes_list, device=self.device)
            depths = (depth_map_batch(self.depth, chunk)
                      if self.depth is not None else [None] * len(chunk))
            classes = (zero_shot_batch(self.siglip, chunk, vocab, device=self.device)
                       if self.siglip is not None else [None] * len(chunk))
            styles = (zero_shot_batch(self.siglip, chunk, STYLE_LABELS, device=self.device)
                      if self.siglip is not None else [None] * len(chunk))
            if p_chunk is not None:
                attrs = []
                for s2 in range(0, len(chunk), gdino_batch):
                    attrs.extend(ground_phrases_batch(
                        self.gdino, chunk[s2:s2 + gdino_batch],
                        p_chunk[s2:s2 + gdino_batch], device=self.device))
            else:
                attrs = [[] for _ in chunk]
            for k, im in enumerate(chunk):
                s = Solids(size=im.size)
                s.boxes = boxes_list[k]
                s.depth = depths[k]
                s.gray = np.asarray(im.convert("L"), dtype=np.float32)
                if classes[k] is not None:
                    s.class_top = classes[k][:5]
                    s.style = styles[k][0]["label"]
                if self.ocr is not None:
                    s.ocr = ocr_read(self.ocr, im)
                s.attr_boxes = attrs[k]
                solids.append(s)
        return solids

    def extract_batch(self, images, vocab=None, phrases_list=None,
                      batch: int = 16) -> list:
        """Batched extract(+digest): → [(tasks_dict, digest)] aligned with images."""
        from .fuse import solids_digest
        out = []
        for s in self.solidify_batch(images, vocab, phrases_list, batch=batch):
            out.append((self._build_tasks(s), solids_digest(s)))
        return out

    def extract_with_digest(self, image, phrases=None, vocab=None) -> tuple:
        """→ (tasks_dict, solids_digest) from ONE solidify pass (+ the phrase-
        grounding pass when `phrases` is given). The digest is the fusion tier's
        input — compact, JSON-able, carries the retained confidences."""
        from .fuse import solids_digest
        s = self.solidify(image, vocab)
        if phrases:
            s.attr_boxes = self.ground(image, phrases)
        return self._build_tasks(s), solids_digest(s)


def load_vlm(model_key: str = "qwen3vl-4b"):
    from .model_registry import get_runner
    return get_runner(model_key)


def validate_task_vlm(task: str, n: int = 24, model_key: str = "qwen3vl-4b",
                      runner=None, device: str = "cuda") -> dict:
    """Run the Qwen VLM on the SAME (real) GT as validate_task — a true apples-to-apples
    head-to-head. Reuses the existing VLMRunner + score path. Pass a pre-loaded `runner`
    to avoid reloading the model per task."""
    from .datasets import load_gt
    from .metrics import score_vision_sample, score_vision_run

    spec = get_task(task)
    gt_key = _TASK_CFG[task].get("gt", spec.gt_dataset)
    samples = load_gt(gt_key, n=n, split=spec.gt_split or "", dataset="full")
    own = runner is None
    if own:
        runner = load_vlm(model_key)
    results = []
    try:
        for s in samples:
            up = s.prompt if spec.per_sample_prompt else None
            res = runner.generate(spec, s.image, "json_mode", image_id=s.image_id,
                                  image_size=s.size, gt=s.gt, user_prompt=up)
            results.append(score_vision_sample(spec, res.raw_text, s.gt, mode="json_mode",
                                               image_id=s.image_id, image_size=s.size))
    finally:
        if own:
            close = getattr(runner, "close", None)
            if callable(close):
                close()
    run = score_vision_run(results, model=model_key, category=task, mode="json_mode")
    # effective yield = accuracy × validity (the vlmbench headline metric)
    acc = run.primary_score_mean
    return {"task": task, "vlm_primary": acc, "vlm_valid": run.schema_valid_rate,
            "vlm_yield": (acc * run.schema_valid_rate) if acc is not None else None}