AbstractPhil commited on
Commit
fed954e
·
verified ·
1 Parent(s): 4790c3a

Deploy: 12-task vision extraction + fusion ZeroGPU showcase

Browse files
Files changed (40) hide show
  1. README.md +66 -6
  2. app.py +669 -0
  3. examples/json_text_card.png +0 -0
  4. examples/shapes_scene.png +0 -0
  5. face_age_filter.py +312 -0
  6. qwen_test_runner/__init__.py +89 -0
  7. qwen_test_runner/data_gen.py +199 -0
  8. qwen_test_runner/eval_set.py +103 -0
  9. qwen_test_runner/evaluator.py +427 -0
  10. qwen_test_runner/model_runner.py +361 -0
  11. qwen_test_runner/providers/__init__.py +35 -0
  12. qwen_test_runner/providers/claude_api.py +437 -0
  13. qwen_test_runner/py.typed +0 -0
  14. qwen_test_runner/registry.py +210 -0
  15. qwen_test_runner/run_benchmark.py +223 -0
  16. qwen_test_runner/schema.py +423 -0
  17. qwen_test_runner/tasks.py +461 -0
  18. qwen_test_runner/vision/__init__.py +62 -0
  19. qwen_test_runner/vision/bench.py +197 -0
  20. qwen_test_runner/vision/configs/dataset_gen.yaml +68 -0
  21. qwen_test_runner/vision/coords.py +161 -0
  22. qwen_test_runner/vision/datasets.py +928 -0
  23. qwen_test_runner/vision/derive.py +363 -0
  24. qwen_test_runner/vision/fuse.py +758 -0
  25. qwen_test_runner/vision/fuse_prompt.py +153 -0
  26. qwen_test_runner/vision/fuse_schema.py +228 -0
  27. qwen_test_runner/vision/fusion_metrics.py +523 -0
  28. qwen_test_runner/vision/metrics.py +997 -0
  29. qwen_test_runner/vision/model_registry.py +275 -0
  30. qwen_test_runner/vision/report.py +179 -0
  31. qwen_test_runner/vision/run_vlmbench.py +68 -0
  32. qwen_test_runner/vision/runner_types.py +23 -0
  33. qwen_test_runner/vision/runners.py +301 -0
  34. qwen_test_runner/vision/specialists.py +181 -0
  35. qwen_test_runner/vision/specialists_gpu.py +768 -0
  36. qwen_test_runner/vision/strata.py +185 -0
  37. qwen_test_runner/vision/stub_runner.py +115 -0
  38. qwen_test_runner/vision/tasks_vision.py +530 -0
  39. qwen_test_runner/vision/throughput.py +56 -0
  40. requirements.txt +19 -0
README.md CHANGED
@@ -1,13 +1,73 @@
1
  ---
2
- title: Qwen Caption Array
3
- emoji: 🌖
4
- colorFrom: red
5
  colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Qwen Runner Vision — 12-Task Extraction + Fusion
3
+ emoji: 🧩
4
+ colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 4.44.1
 
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
+ short_description: Image → 12-task JSON + fused prompt on ZeroGPU
12
  ---
13
 
14
+ # Qwen Runner Vision deterministic 12-task extraction + fusion
15
+
16
+ **Stick an image in → get a full JSON readout.** This Space showcases the vision
17
+ half of [`qwen-test-runner`](https://github.com/AbstractEyes/qwen-test-runner): a
18
+ **deterministic-first** pipeline that replaces a hallucinating VLM with hand-picked
19
+ Apache/MIT specialist models, one per task, then **fuses** everything into one
20
+ relational scene and a byte-deterministic prompt.
21
+
22
+ It runs the **batched extraction structure** on **ZeroGPU**.
23
+
24
+ ## What comes out
25
+
26
+ Per image, the full readout:
27
+
28
+ - **12 task JSONs** — 11 from the specialist/derive engine
29
+ (`image_classification`, `bbox_grounding`, `ocr_text`, `data_type_differentiation`,
30
+ `data_type_utilization`, `structural_spatial_awareness`, `depth_analysis`,
31
+ `subject_fixation`, `segmentation`, `outline_association`, `style_structural_awareness`)
32
+ plus `semantic_association` from the fusion tier — with a per-task **schema-validity** map.
33
+ - **`FusedScene`** — entities (dedup + left-to-right ids), relations, the attribute
34
+ **ownership cascade** and the **shared basin** (uncertainty stored, never guessed),
35
+ a voted scene block, and a quality/accounting block.
36
+ - **`prompt_fused`** — the deterministic natural-language prompt, plus `fusion_confidence`.
37
+ - **Overlays** — detection boxes, SAM mask fills, subject box, outline, and a depth heatmap.
38
+ - **Download** — one row in the production column shape
39
+ (`tasks_json`, `tasks_valid`, `fused_json`, `prompt_fused`, `fusion_confidence`,
40
+ `proc_width/height`, plus `struct_*`/`age_audit` when those toggles are on).
41
+
42
+ ## Everything is a toggle
43
+
44
+ It's a *multiple-possibility system*: **structurer** (`off` / Qwen3.5-0.8B / Qwen3.5-9B for
45
+ caption enrichment) · **tasks** (which to run/show) · **vocab** (COCO-80 / shapes / custom
46
+ phrases) · **specialists** (OCR, SAM masks, depth on/off) · **detection** (box/text
47
+ thresholds) · **fusion** (`t_own`, `t_margin`, `dedup_iou`, coord space) · **batch**
48
+ (`extract_batch`, `gdino_batch`) · optional **age-gate** pre-filter.
49
+
50
+ The core path needs no captions: the fusion attribute-ownership and shared-basin machinery
51
+ light up when you supply captions and pick a structurer, but entities/relations/scene come
52
+ from the image alone.
53
+
54
+ ## Model ledger (Apache-2.0 — redistributable)
55
+
56
+ | role | checkpoint |
57
+ |---|---|
58
+ | detection (hub) | `IDEA-Research/grounding-dino-base` |
59
+ | segmentation | `facebook/sam-vit-base` |
60
+ | depth | `depth-anything/Depth-Anything-V2-Small-hf` |
61
+ | classification / style | `google/siglip2-so400m-patch14-384` |
62
+ | OCR | EasyOCR |
63
+ | structurer (optional) | `Qwen/Qwen3.5-0.8B` · `Qwen/Qwen3.5-9B` |
64
+ | age gate (optional) | `nateraw/vit-age-classifier` |
65
+
66
+ ## Hardware
67
+
68
+ Select **ZeroGPU** in the Space's hardware settings. `large` (48 GB) is enough for the
69
+ default config and the 0.8B structurer; the 9B structurer wants `xlarge` (96 GB). The
70
+ fusion tier is CPU-only (torch-free) and runs off the GPU.
71
+
72
+ Full-corpus production (streaming an HF dataset → published `{src}-fused` parquet shards)
73
+ runs in Colab via `colab/produce_fused_dataset.py` — this Space is the interactive showcase.
app.py ADDED
@@ -0,0 +1,669 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """app.py — HuggingFace ZeroGPU Space: the deterministic 12-task vision
2
+ extraction + fusion pipeline as an interactive showcase.
3
+
4
+ Stick an image in → get the full JSON readout (12 task JSONs + FusedScene +
5
+ deterministic fused prompt + overlays). Every possibility in the system is a
6
+ selectable toggle.
7
+
8
+ ZeroGPU teardown-friendly design
9
+ --------------------------------
10
+ * PYTORCH_CUDA_ALLOC_CONF is set BEFORE torch imports (OOM-probing batched path).
11
+ * The always-on specialist models load ONCE at module level (CUDA-emulation
12
+ outside `@spaces.GPU`; real CUDA inside) — the efficient, fork-friendly residency.
13
+ * Optional structurer (0.8B / 9B) + age gate load on demand, single-resident.
14
+ * GPU functions return only picklable CPU data (task/digest dicts + rendered PIL
15
+ overlays). fuse()/fused_prompt()/build_semantic_association() run on the CPU in
16
+ the main process — no GPU is held during fusion.
17
+
18
+ The pipeline modules themselves are the real `qwen_test_runner` package, vendored
19
+ verbatim by ../build_space.py.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+
25
+ # ── ZeroGPU rule: set the allocator conf BEFORE torch is imported ────────────
26
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
27
+
28
+ import json
29
+ import tempfile
30
+ import time
31
+
32
+ import numpy as np
33
+ from PIL import Image, ImageDraw
34
+
35
+ import gradio as gr
36
+
37
+ # spaces (ZeroGPU). Degrade to a no-op decorator when running off-platform.
38
+ try:
39
+ import spaces
40
+
41
+ _HAS_SPACES = True
42
+ except Exception: # pragma: no cover - local/CPU dev
43
+ _HAS_SPACES = False
44
+
45
+ class _NoSpaces:
46
+ @staticmethod
47
+ def GPU(*_a, **_k):
48
+ def deco(fn):
49
+ return fn
50
+
51
+ return deco
52
+
53
+ spaces = _NoSpaces() # type: ignore
54
+
55
+ import torch
56
+
57
+ # ── real pipeline (vendored package) ─────────────────────────────────────────
58
+ import qwen_test_runner.vision.specialists_gpu as g
59
+ from qwen_test_runner.vision.specialists import Solids
60
+ from qwen_test_runner.vision import derive
61
+ from qwen_test_runner.vision.fuse import (
62
+ solids_digest,
63
+ fuse,
64
+ phrases_for_grounding,
65
+ build_semantic_association,
66
+ )
67
+ from qwen_test_runner.vision.fuse_prompt import fused_prompt
68
+ from qwen_test_runner.vision.tasks_vision import get_task, model_for
69
+ from qwen_test_runner.vision.coords import CoordSpace
70
+ from qwen_test_runner.model_runner import SYSTEM_PROMPT_JSON
71
+ from qwen_test_runner.evaluator import parse_safely
72
+
73
+
74
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
75
+ IS_GPU_ENV = bool(os.environ.get("SPACES_ZERO_GPU")) or DEVICE == "cuda"
76
+ MAX_DIM = 1024 # match production DECODE_MAX_DIM
77
+ BATCH_CAP = 24 # interactive batch ceiling (ZeroGPU quota)
78
+
79
+ # 12 deterministic tasks (11 from _build_tasks + semantic_association from fusion)
80
+ DET_TASKS = [
81
+ "image_classification", "bbox_grounding", "ocr_text",
82
+ "data_type_differentiation", "data_type_utilization",
83
+ "structural_spatial_awareness", "depth_analysis", "subject_fixation",
84
+ "segmentation", "outline_association", "style_structural_awareness",
85
+ "semantic_association",
86
+ ]
87
+ # registry entries with no deterministic builder (shown, disabled)
88
+ VLM_TASKS = ["vit_accuracy_to_prompt", "geometric_3d_object_id", "camera_rotational_offset"]
89
+
90
+ VOCABS = {"COCO-80": g.COCO_CLASSES, "shapes": g.SHAPE_CLASSES}
91
+ STRUCTURERS = {"off": None, "Qwen3.5-0.8B": "Qwen/Qwen3.5-0.8B", "Qwen3.5-9B": "Qwen/Qwen3.5-9B"}
92
+ COORD_SPACES = ["norm_0_1000", "norm_0_1", "pixel_abs"]
93
+
94
+ _PALETTE = [
95
+ (239, 71, 111), (17, 138, 178), (6, 214, 160), (255, 209, 102),
96
+ (155, 93, 229), (241, 91, 181), (0, 187, 249), (254, 127, 45),
97
+ ]
98
+
99
+
100
+ # ═════════════════════════════════════════════════════════════════════════════
101
+ # Model residency (teardown-friendly)
102
+ # ═════════════════════════════════════════════════════════════════════════════
103
+
104
+ _PIPE = None
105
+ _OCR = None
106
+ _AGE = None
107
+ _STRUCT: dict = {}
108
+
109
+
110
+ def get_pipe():
111
+ """The always-on specialist pipeline (GroundingDINO/SAM/Depth/SigLIP2[/OCR])."""
112
+ global _PIPE
113
+ if _PIPE is None:
114
+ with_ocr = os.environ.get("SPACE_WITH_OCR", "1") == "1"
115
+ _PIPE = g.SpecialistPipeline(device=DEVICE, with_ocr=with_ocr)
116
+ return _PIPE
117
+
118
+
119
+ def _get_ocr(pipe):
120
+ """OCR reader — from the pipeline if it loaded there, else a lazy singleton
121
+ (the teardown-safe fallback when EasyOCR misbehaves at module level)."""
122
+ global _OCR
123
+ if pipe.ocr is not None:
124
+ return pipe.ocr
125
+ if _OCR is None:
126
+ _OCR = g.load_ocr(DEVICE)
127
+ return _OCR
128
+
129
+
130
+ def _get_age_filter():
131
+ """Age-gate pre-filter — imported lazily (the module loads its model at import)."""
132
+ global _AGE
133
+ if _AGE is None:
134
+ import importlib
135
+
136
+ faf = importlib.import_module("face_age_filter")
137
+ _AGE = faf.FaceAgeFilter(decision_mode="strict", batch_size=32)
138
+ return _AGE
139
+
140
+
141
+ class _Structurer:
142
+ """Caption→struct (slot-registry JSON), mirroring the production ModelPack."""
143
+
144
+ def __init__(self, model_id: str):
145
+ from transformers import AutoProcessor
146
+
147
+ try:
148
+ from transformers import AutoModelForMultimodalLM as _M
149
+ except ImportError: # pragma: no cover
150
+ from transformers import AutoModelForImageTextToText as _M
151
+
152
+ self.proc = AutoProcessor.from_pretrained(model_id)
153
+ tok = getattr(self.proc, "tokenizer", self.proc)
154
+ tok.padding_side = "left"
155
+ if tok.pad_token_id is None:
156
+ tok.pad_token = tok.eos_token
157
+ self.pad_id = tok.pad_token_id
158
+ if DEVICE == "cuda":
159
+ dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
160
+ self.model = _M.from_pretrained(model_id, dtype=dtype, device_map="cuda").eval()
161
+ else:
162
+ self.model = _M.from_pretrained(model_id).to("cpu").eval()
163
+
164
+ @torch.no_grad()
165
+ def structure(self, captions: list, max_tok: int = 512) -> list:
166
+ msgs = [[{"role": "system", "content": SYSTEM_PROMPT_JSON},
167
+ {"role": "user", "content": c}] for c in captions]
168
+ enc = self.proc.apply_chat_template(
169
+ msgs, add_generation_prompt=True, tokenize=True, return_dict=True,
170
+ return_tensors="pt", padding=True, enable_thinking=False).to(self.model.device)
171
+ n_in = enc["input_ids"].shape[1]
172
+ gen = self.model.generate(**enc, max_new_tokens=max_tok, do_sample=False,
173
+ pad_token_id=self.pad_id)
174
+ outs = [self.proc.decode(s, skip_special_tokens=True).strip() for s in gen[:, n_in:]]
175
+ structs = []
176
+ for raw in outs:
177
+ pr = parse_safely(raw)
178
+ if pr.schema_valid and pr.parsed is not None:
179
+ m = pr.parsed
180
+ structs.append(m.model_dump() if hasattr(m, "model_dump") else m.dict())
181
+ else:
182
+ structs.append(None)
183
+ return structs
184
+
185
+
186
+ def _get_structurer(model_id: str):
187
+ if model_id in _STRUCT:
188
+ return _STRUCT[model_id]
189
+ _STRUCT.clear() # single-resident (evict on switch)
190
+ if DEVICE == "cuda":
191
+ torch.cuda.empty_cache()
192
+ _STRUCT[model_id] = _Structurer(model_id)
193
+ return _STRUCT[model_id]
194
+
195
+
196
+ # Preload the always-on specialists at module level on a GPU/ZeroGPU env
197
+ # (lazy on a CPU dev box so the module imports cheaply for tests).
198
+ if IS_GPU_ENV:
199
+ try:
200
+ get_pipe()
201
+ except Exception as e: # pragma: no cover
202
+ print(f"[app] specialist preload deferred: {type(e).__name__}: {e}")
203
+
204
+
205
+ # ═════════════════════════════════════════════════════════════════════════════
206
+ # Solidify orchestration (public batched primitives + threshold / skip control)
207
+ # ═════════════════════════════════════════════════════════════════════════════
208
+
209
+ def _resolve_vocab(vocab_choice: str, custom: str) -> list:
210
+ if vocab_choice == "custom":
211
+ toks = [t.strip() for t in (custom or "").split(",") if t.strip()]
212
+ return toks or g.COCO_CLASSES
213
+ return VOCABS.get(vocab_choice, g.COCO_CLASSES)
214
+
215
+
216
+ def _solidify(pipe, images, vocab, phrases_list, box_thr, text_thr,
217
+ use_ocr, use_masks, use_depth, batch, gdino_batch) -> list:
218
+ """Mirror SpecialistPipeline.solidify_batch, but pass detection thresholds and
219
+ honour the specialist on/off toggles."""
220
+ images = list(images)
221
+ solids = []
222
+ ocr_reader = _get_ocr(pipe) if use_ocr else None
223
+ for start in range(0, len(images), batch):
224
+ chunk = images[start:start + batch]
225
+ p_chunk = phrases_list[start:start + batch] if phrases_list is not None else None
226
+
227
+ boxes_list = []
228
+ for s2 in range(0, len(chunk), gdino_batch):
229
+ boxes_list.extend(g.detect_batch(
230
+ pipe.gdino, chunk[s2:s2 + gdino_batch], vocab,
231
+ box_threshold=box_thr, text_threshold=text_thr, device=DEVICE))
232
+ if use_masks and pipe.sam is not None:
233
+ boxes_list = g.segment_batch(pipe.sam, chunk, boxes_list, device=DEVICE)
234
+ depths = (g.depth_map_batch(pipe.depth, chunk)
235
+ if (use_depth and pipe.depth is not None) else [None] * len(chunk))
236
+ classes = (g.zero_shot_batch(pipe.siglip, chunk, vocab, device=DEVICE)
237
+ if pipe.siglip is not None else [None] * len(chunk))
238
+ styles = (g.zero_shot_batch(pipe.siglip, chunk, g.STYLE_LABELS, device=DEVICE)
239
+ if pipe.siglip is not None else [None] * len(chunk))
240
+ if p_chunk is not None and any(p_chunk):
241
+ attrs = []
242
+ for s2 in range(0, len(chunk), gdino_batch):
243
+ attrs.extend(g.ground_phrases_batch(
244
+ pipe.gdino, chunk[s2:s2 + gdino_batch],
245
+ p_chunk[s2:s2 + gdino_batch], device=DEVICE))
246
+ else:
247
+ attrs = [[] for _ in chunk]
248
+
249
+ for k, im in enumerate(chunk):
250
+ s = Solids(size=im.size)
251
+ s.boxes = boxes_list[k]
252
+ s.depth = depths[k]
253
+ s.gray = np.asarray(im.convert("L"), dtype=np.float32)
254
+ if classes[k] is not None:
255
+ s.class_top = classes[k][:5]
256
+ s.style = styles[k][0]["label"]
257
+ if ocr_reader is not None:
258
+ s.ocr = g.ocr_read(ocr_reader, im)
259
+ s.attr_boxes = attrs[k]
260
+ solids.append(s)
261
+ return solids
262
+
263
+
264
+ def _solidify_oom(pipe, images, vocab, phrases_list, box_thr, text_thr,
265
+ use_ocr, use_masks, use_depth, batch, gdino_batch) -> list:
266
+ """OOM-halving wrapper (mirrors produce_fused_dataset's guard)."""
267
+ solids, i, bs = [], 0, batch
268
+ while i < len(images):
269
+ chunk = images[i:i + bs]
270
+ p_chunk = phrases_list[i:i + bs] if phrases_list is not None else None
271
+ try:
272
+ solids.extend(_solidify(pipe, chunk, vocab, p_chunk, box_thr, text_thr,
273
+ use_ocr, use_masks, use_depth, bs, gdino_batch))
274
+ i += len(chunk)
275
+ bs = batch
276
+ except torch.cuda.OutOfMemoryError: # pragma: no cover
277
+ torch.cuda.empty_cache()
278
+ if bs == 1:
279
+ solids.append(Solids(size=images[i].size))
280
+ i += 1
281
+ bs = batch
282
+ else:
283
+ bs = max(1, bs // 2)
284
+ return solids
285
+
286
+
287
+ # ═════════════════════════════════════════════════════════════════════════════
288
+ # GPU stage (everything that touches CUDA) — teardown-friendly
289
+ # ═════════════════════════════════════════════════════════════════════════════
290
+
291
+ def _gpu_duration(images, *_a, **_k):
292
+ n = len(images) if images else 1
293
+ return int(min(240, 25 + 9 * n))
294
+
295
+
296
+ @spaces.GPU(duration=_gpu_duration)
297
+ def gpu_extract(images, vocab, box_thr, text_thr, use_ocr, use_masks, use_depth,
298
+ structurer_id, captions_list, use_age, batch, gdino_batch, render):
299
+ """All CUDA work in one allocation. Returns picklable CPU data:
300
+ per-image (tasks, digest, overlays) + caption structs + age audits + timing."""
301
+ pipe = get_pipe()
302
+ timing = {}
303
+ n = len(images)
304
+
305
+ audits = None
306
+ if use_age:
307
+ t = time.perf_counter()
308
+ audits = [r.to_audit() for r in _get_age_filter().check_batch(images)]
309
+ timing["age_s"] = round(time.perf_counter() - t, 3)
310
+
311
+ structs_rows = [{} for _ in images]
312
+ raws_rows = [{} for _ in images]
313
+ if structurer_id and any(captions_list or []):
314
+ t = time.perf_counter()
315
+ st = _get_structurer(structurer_id)
316
+ for idx, caps in enumerate(captions_list or []):
317
+ caps = [c for c in (caps or []) if c and str(c).strip()]
318
+ if not caps:
319
+ continue
320
+ got = st.structure(caps)
321
+ structs_rows[idx] = {f"cap_{j}": s for j, s in enumerate(got)}
322
+ raws_rows[idx] = {f"cap_{j}": c for j, c in enumerate(caps)}
323
+ timing["struct_s"] = round(time.perf_counter() - t, 3)
324
+
325
+ phrases_list = [phrases_for_grounding(sr) for sr in structs_rows]
326
+
327
+ t = time.perf_counter()
328
+ solids = _solidify_oom(pipe, images, vocab, phrases_list, box_thr, text_thr,
329
+ use_ocr, use_masks, use_depth, batch, gdino_batch)
330
+ timing["extract_s"] = round(time.perf_counter() - t, 3)
331
+
332
+ results = []
333
+ for s, im in zip(solids, images):
334
+ tasks = g.SpecialistPipeline._build_tasks(s) # CPU, torch-free, fast
335
+ digest = solids_digest(s)
336
+ overlays = _render_overlays(im, s) if render else None
337
+ results.append({"tasks": tasks, "digest": digest, "overlays": overlays})
338
+
339
+ if DEVICE == "cuda":
340
+ torch.cuda.empty_cache()
341
+ timing["n_images"] = n
342
+ return results, structs_rows, raws_rows, audits, timing
343
+
344
+
345
+ # ═════════════════════════════════════════════════════════════════════════════
346
+ # CPU fusion + assembly (no GPU held)
347
+ # ═════════════════════════════════════════════════════════════════════════════
348
+
349
+ def _task_valid(task: str, pred) -> bool:
350
+ try:
351
+ m = model_for(get_task(task))
352
+ m.model_validate(pred) if hasattr(m, "model_validate") else m(**pred)
353
+ return True
354
+ except Exception:
355
+ return False
356
+
357
+
358
+ def _assemble(results, structs_rows, raws_rows, audits, sizes,
359
+ t_own, t_margin, dedup_iou, coord_space, task_filter):
360
+ """Fuse each image's digest + structs → scene + prompt + one output row."""
361
+ rows = []
362
+ cs = CoordSpace(coord_space)
363
+ for i, r in enumerate(results):
364
+ tasks = dict(r["tasks"])
365
+ try:
366
+ scene = fuse(r["digest"], structs_rows[i] or {}, raws_rows[i] or {},
367
+ t_own=t_own, t_margin=t_margin, dedup_iou=dedup_iou, coord_space=cs)
368
+ tasks["semantic_association"] = build_semantic_association(scene)
369
+ prompt = fused_prompt(scene)
370
+ conf = float(scene["quality"]["overall_confidence"])
371
+ except Exception as e: # pragma: no cover
372
+ scene, prompt, conf = {"__error__": f"{type(e).__name__}: {e}"}, "", 0.0
373
+ valid = {t: _task_valid(t, p) for t, p in tasks.items() if t != "__error__"}
374
+ shown = {t: tasks[t] for t in tasks if (not task_filter or t in task_filter)}
375
+ W, H = sizes[i]
376
+ rows.append({
377
+ "tasks_json": shown, "tasks_valid": valid, "fused_json": scene,
378
+ "prompt_fused": prompt, "fusion_confidence": round(conf, 4),
379
+ "struct": structs_rows[i] or {}, "age_audit": (audits[i] if audits else None),
380
+ "proc_width": W, "proc_height": H,
381
+ "overlays": r.get("overlays"),
382
+ })
383
+ return rows
384
+
385
+
386
+ def _download_row(row: dict) -> str:
387
+ payload = {
388
+ "tasks_json": json.dumps(row["tasks_json"]),
389
+ "tasks_valid": json.dumps(row["tasks_valid"]),
390
+ "fused_json": json.dumps(row["fused_json"]),
391
+ "prompt_fused": row["prompt_fused"],
392
+ "fusion_confidence": row["fusion_confidence"],
393
+ "struct": json.dumps(row["struct"]),
394
+ "age_audit": json.dumps(row["age_audit"]),
395
+ "proc_width": row["proc_width"], "proc_height": row["proc_height"],
396
+ }
397
+ f = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8")
398
+ json.dump(payload, f, indent=2)
399
+ f.close()
400
+ return f.name
401
+
402
+
403
+ # ═════════════════════════════════════════════════════════════════════════════
404
+ # Overlay rendering (from Solids, pixel space)
405
+ # ═════════════════════════════════════════════════════════════════════════════
406
+
407
+ def _colorize_depth(depth: np.ndarray) -> Image.Image:
408
+ d = np.asarray(depth, dtype=np.float32)
409
+ lo, hi = float(d.min()), float(d.max())
410
+ n = (d - lo) / (hi - lo + 1e-6) # 0=far, 1=near
411
+ # 3-stop gradient far(indigo)→mid(teal)→near(amber)
412
+ stops = np.array([[40, 30, 90], [17, 138, 178], [255, 209, 102]], dtype=np.float32)
413
+ x = n * 2.0
414
+ lo_i = np.clip(np.floor(x).astype(int), 0, 1)
415
+ frac = (x - lo_i)[..., None]
416
+ rgb = (stops[lo_i] * (1 - frac) + stops[lo_i + 1] * frac).astype(np.uint8)
417
+ return Image.fromarray(rgb, "RGB")
418
+
419
+
420
+ def _render_overlays(image: Image.Image, s: Solids) -> dict:
421
+ base = image.convert("RGB")
422
+ annotated = base.copy()
423
+ overlay = Image.new("RGBA", annotated.size, (0, 0, 0, 0))
424
+ od = ImageDraw.Draw(overlay)
425
+ dr = ImageDraw.Draw(annotated)
426
+
427
+ for i, b in enumerate(s.boxes):
428
+ color = _PALETTE[i % len(_PALETTE)]
429
+ x1, y1, x2, y2 = [int(v) for v in b["box"]]
430
+ mask = b.get("mask")
431
+ if mask is not None:
432
+ m = np.asarray(mask, dtype=bool)
433
+ fill = np.zeros((*m.shape, 4), dtype=np.uint8)
434
+ fill[m] = (*color, 90)
435
+ overlay.alpha_composite(Image.fromarray(fill, "RGBA"))
436
+ dr.rectangle([x1, y1, x2, y2], outline=color, width=3)
437
+ label = f'{b.get("label", "?")} {b.get("score", 0):.2f}'
438
+ dr.text((x1 + 3, max(0, y1 - 12)), label, fill=color)
439
+
440
+ annotated = Image.alpha_composite(annotated.convert("RGBA"), overlay).convert("RGB")
441
+ dr = ImageDraw.Draw(annotated)
442
+
443
+ # subject box (thick white)
444
+ subj = derive.subject_fixation(s.boxes, s.size).get("primary_subject", {})
445
+ if subj.get("box"):
446
+ x1, y1, x2, y2 = [int(v) for v in subj["box"]]
447
+ dr.rectangle([x1, y1, x2, y2], outline=(255, 255, 255), width=4)
448
+
449
+ # outline of the largest mask
450
+ masked = [b for b in s.boxes if b.get("mask") is not None]
451
+ if masked:
452
+ big = max(masked, key=lambda b: np.asarray(b["mask"]).sum())
453
+ poly = derive.outline_polygon(big["mask"], big["label"])["outline"]
454
+ if len(poly) >= 6:
455
+ pts = [(poly[j], poly[j + 1]) for j in range(0, len(poly) - 1, 2)]
456
+ dr.line(pts + [pts[0]], fill=(255, 0, 128), width=2)
457
+
458
+ depth_img = _colorize_depth(s.depth) if s.depth is not None else None
459
+ return {"annotated": annotated, "depth": depth_img}
460
+
461
+
462
+ # ═════════════════════════════════════════════════════════════════════════════
463
+ # Gradio callbacks
464
+ # ═════════════════════════════════════════════════════════════════════════════
465
+
466
+ def _prep(image) -> Image.Image:
467
+ im = image if isinstance(image, Image.Image) else Image.open(image)
468
+ im = im.convert("RGB")
469
+ if max(im.size) > MAX_DIM:
470
+ im.thumbnail((MAX_DIM, MAX_DIM))
471
+ return im
472
+
473
+
474
+ def run_single(image, vocab_choice, custom_vocab, tasks_sel, use_ocr, use_masks,
475
+ use_depth, box_thr, text_thr, structurer_choice, captions_text,
476
+ use_age, t_own, t_margin, dedup_iou, coord_space):
477
+ if image is None:
478
+ raise gr.Error("Upload or pick an image first.")
479
+ im = _prep(image)
480
+ vocab = _resolve_vocab(vocab_choice, custom_vocab)
481
+ struct_id = STRUCTURERS.get(structurer_choice)
482
+ caps = [c.strip() for c in (captions_text or "").splitlines() if c.strip()]
483
+
484
+ results, structs_rows, raws_rows, audits, timing = gpu_extract(
485
+ [im], vocab, float(box_thr), float(text_thr), bool(use_ocr), bool(use_masks),
486
+ bool(use_depth), struct_id, [caps], bool(use_age), 1, 2, True)
487
+
488
+ row = _assemble(results, structs_rows, raws_rows, audits, [im.size],
489
+ float(t_own), float(t_margin), float(dedup_iou), coord_space,
490
+ set(tasks_sel or []))[0]
491
+ ov = row["overlays"] or {}
492
+ return (
493
+ ov.get("annotated"), ov.get("depth"),
494
+ row["prompt_fused"], row["fusion_confidence"],
495
+ row["tasks_json"], row["tasks_valid"], row["fused_json"],
496
+ row["struct"], (row["age_audit"] or {}), timing,
497
+ _download_row(row),
498
+ )
499
+
500
+
501
+ def run_batch(files, vocab_choice, custom_vocab, use_ocr, use_masks, use_depth,
502
+ box_thr, text_thr, structurer_choice, use_age, t_own, t_margin,
503
+ dedup_iou, coord_space, batch, gdino_batch):
504
+ if not files:
505
+ raise gr.Error("Upload at least one image.")
506
+ files = files[:BATCH_CAP]
507
+ ims = [_prep(f) for f in files]
508
+ vocab = _resolve_vocab(vocab_choice, custom_vocab)
509
+ struct_id = STRUCTURERS.get(structurer_choice)
510
+
511
+ t0 = time.perf_counter()
512
+ results, structs_rows, raws_rows, audits, timing = gpu_extract(
513
+ ims, vocab, float(box_thr), float(text_thr), bool(use_ocr), bool(use_masks),
514
+ bool(use_depth), struct_id, [[] for _ in ims], bool(use_age),
515
+ int(batch), int(gdino_batch), False)
516
+ rows = _assemble(results, structs_rows, raws_rows, audits, [im.size for im in ims],
517
+ float(t_own), float(t_margin), float(dedup_iou), coord_space, None)
518
+ wall = time.perf_counter() - t0
519
+
520
+ table, jsonl = [], []
521
+ for i, row in enumerate(rows):
522
+ cls = row["tasks_json"].get("image_classification", {}) if row["tasks_json"] else {}
523
+ n_ent = len(row["fused_json"].get("entities", [])) if isinstance(row["fused_json"], dict) else 0
524
+ nvalid = sum(1 for v in row["tasks_valid"].values() if v)
525
+ table.append([i, cls.get("label", ""), n_ent, row["fusion_confidence"],
526
+ f"{nvalid}/{len(row['tasks_valid'])}", (row["prompt_fused"] or "")[:90]])
527
+ jsonl.append({
528
+ "idx": i, "tasks_json": json.dumps(row["tasks_json"]),
529
+ "tasks_valid": json.dumps(row["tasks_valid"]),
530
+ "fused_json": json.dumps(row["fused_json"]),
531
+ "prompt_fused": row["prompt_fused"], "fusion_confidence": row["fusion_confidence"],
532
+ "proc_width": row["proc_width"], "proc_height": row["proc_height"],
533
+ })
534
+
535
+ f = tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False, encoding="utf-8")
536
+ for r in jsonl:
537
+ f.write(json.dumps(r) + "\n")
538
+ f.close()
539
+
540
+ summary = {
541
+ "images": len(ims), "wall_s": round(wall, 2),
542
+ "img_per_s": round(len(ims) / max(0.001, wall), 2),
543
+ **{k: v for k, v in timing.items() if k != "n_images"},
544
+ }
545
+ return table, summary, f.name
546
+
547
+
548
+ # ═════════════════════════════════════════════════════════════════════════════
549
+ # UI
550
+ # ═════════════════════════════════════════════════════════════════════════════
551
+
552
+ def _controls():
553
+ """Shared control widgets — returned so both tabs can wire them."""
554
+ vocab_choice = gr.Radio(list(VOCABS) + ["custom"], value="COCO-80", label="Detection vocab")
555
+ custom_vocab = gr.Textbox(label="Custom phrases (comma-separated)", visible=False,
556
+ placeholder="person, red circle, laptop")
557
+ with gr.Row():
558
+ use_ocr = gr.Checkbox(True, label="OCR")
559
+ use_masks = gr.Checkbox(True, label="SAM masks")
560
+ use_depth = gr.Checkbox(True, label="Depth")
561
+ with gr.Row():
562
+ box_thr = gr.Slider(0.05, 0.6, 0.30, step=0.01, label="box threshold")
563
+ text_thr = gr.Slider(0.05, 0.6, 0.25, step=0.01, label="text threshold")
564
+ structurer = gr.Radio(list(STRUCTURERS), value="off", label="Caption structurer")
565
+ with gr.Row():
566
+ t_own = gr.Slider(0.0, 1.0, 0.60, step=0.01, label="t_own")
567
+ t_margin = gr.Slider(0.0, 1.0, 0.25, step=0.01, label="t_margin")
568
+ dedup_iou = gr.Slider(0.0, 1.0, 0.75, step=0.01, label="dedup_iou")
569
+ coord_space = gr.Radio(COORD_SPACES, value="norm_0_1000", label="Fused-scene coord space")
570
+ use_age = gr.Checkbox(False, label="Age-gate pre-filter (nateraw/vit-age-classifier)")
571
+
572
+ vocab_choice.change(lambda c: gr.update(visible=(c == "custom")),
573
+ vocab_choice, custom_vocab)
574
+ return (vocab_choice, custom_vocab, use_ocr, use_masks, use_depth, box_thr,
575
+ text_thr, structurer, coord_space, use_age, t_own, t_margin, dedup_iou)
576
+
577
+
578
+ with gr.Blocks(title="Qwen Runner Vision — 12-task extraction + fusion") as demo:
579
+ gr.Markdown(
580
+ "# 🧩 Qwen Runner Vision\n"
581
+ "Deterministic **12-task** extraction + **fusion** — stick an image in, get the "
582
+ "full JSON readout (task JSONs + `FusedScene` + fused prompt). Specialists run on "
583
+ "**ZeroGPU**; fusion is CPU-only. Every option below is a live toggle."
584
+ )
585
+
586
+ with gr.Tab("Single image"):
587
+ with gr.Row():
588
+ with gr.Column(scale=1):
589
+ img_in = gr.Image(type="pil", label="Image", height=320)
590
+ tasks_sel = gr.CheckboxGroup(DET_TASKS, value=DET_TASKS,
591
+ label="Tasks to show (all 12 always computed)")
592
+ gr.CheckboxGroup(VLM_TASKS, label="VLM/DEFER (no deterministic builder)",
593
+ interactive=False)
594
+ captions = gr.Textbox(lines=3, label="Captions (one per line — enrich fusion)",
595
+ placeholder="a woman with long red hair in a blue coat")
596
+ with gr.Accordion("Settings", open=False):
597
+ ctl = _controls()
598
+ run_b = gr.Button("Extract", variant="primary")
599
+ with gr.Column(scale=1):
600
+ with gr.Row():
601
+ annotated = gr.Image(label="Detections · masks · subject · outline", height=280)
602
+ depth_img = gr.Image(label="Depth (near → far)", height=280)
603
+ prompt_out = gr.Textbox(label="Fused prompt", lines=3)
604
+ conf_out = gr.Number(label="Fusion confidence")
605
+ dl = gr.File(label="Download row (JSON)")
606
+ with gr.Accordion("Full JSON readout", open=True):
607
+ tasks_out = gr.JSON(label="tasks_json (12 tasks)")
608
+ with gr.Row():
609
+ valid_out = gr.JSON(label="tasks_valid")
610
+ struct_out = gr.JSON(label="caption structs")
611
+ fused_out = gr.JSON(label="fused_json (FusedScene)")
612
+ with gr.Row():
613
+ age_out = gr.JSON(label="age_audit")
614
+ timing_out = gr.JSON(label="timing")
615
+
616
+ (vocab_choice, custom_vocab, use_ocr, use_masks, use_depth, box_thr,
617
+ text_thr, structurer, coord_space, use_age, t_own, t_margin, dedup_iou) = ctl
618
+
619
+ ex_dir = os.path.join(os.path.dirname(__file__), "examples")
620
+ if os.path.isdir(ex_dir):
621
+ ex_imgs = [[os.path.join(ex_dir, f)] for f in sorted(os.listdir(ex_dir))
622
+ if f.lower().endswith((".png", ".jpg", ".jpeg"))]
623
+ if ex_imgs:
624
+ gr.Examples(ex_imgs, inputs=img_in, label="Examples")
625
+
626
+ run_b.click(
627
+ run_single,
628
+ inputs=[img_in, vocab_choice, custom_vocab, tasks_sel, use_ocr, use_masks,
629
+ use_depth, box_thr, text_thr, structurer, captions, use_age,
630
+ t_own, t_margin, dedup_iou, coord_space],
631
+ outputs=[annotated, depth_img, prompt_out, conf_out, tasks_out, valid_out,
632
+ fused_out, struct_out, age_out, timing_out, dl],
633
+ )
634
+
635
+ with gr.Tab("Batch (the batched structure)"):
636
+ gr.Markdown(
637
+ f"Upload up to **{BATCH_CAP}** images → batched `solidify_batch` on ZeroGPU "
638
+ "(`gdino_batch=2`, GDINO anti-scales) + per-image CPU fusion. Throughput mirrors "
639
+ "`runs/extract_throughput_results.md`."
640
+ )
641
+ with gr.Row():
642
+ with gr.Column(scale=1):
643
+ files_in = gr.Files(label="Images", file_types=["image"])
644
+ with gr.Accordion("Settings", open=False):
645
+ bctl = _controls()
646
+ with gr.Row():
647
+ batch_sl = gr.Slider(1, 24, 16, step=1, label="extract_batch")
648
+ gdino_sl = gr.Slider(1, 8, 2, step=1, label="gdino_batch (keep ~2)")
649
+ run_batch_b = gr.Button("Run batch", variant="primary")
650
+ with gr.Column(scale=1):
651
+ batch_table = gr.Dataframe(
652
+ headers=["#", "label", "entities", "fusion_conf", "valid", "prompt…"],
653
+ label="Per-image results", wrap=True)
654
+ batch_summary = gr.JSON(label="Throughput")
655
+ batch_dl = gr.File(label="Download rows (JSONL)")
656
+
657
+ (b_vocab, b_custom, b_ocr, b_masks, b_depth, b_box, b_text, b_struct,
658
+ b_coord, b_age, b_town, b_tmargin, b_dedup) = bctl
659
+
660
+ run_batch_b.click(
661
+ run_batch,
662
+ inputs=[files_in, b_vocab, b_custom, b_ocr, b_masks, b_depth, b_box, b_text,
663
+ b_struct, b_age, b_town, b_tmargin, b_dedup, b_coord, batch_sl, gdino_sl],
664
+ outputs=[batch_table, batch_summary, batch_dl],
665
+ )
666
+
667
+
668
+ if __name__ == "__main__":
669
+ demo.queue().launch()
examples/json_text_card.png ADDED
examples/shapes_scene.png ADDED
face_age_filter.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # face_age_filter.py — age-classification pre-filter (no face detection deps).
3
+ #
4
+ # This rewrite drops facenet-pytorch / MTCNN entirely — they pull torchvision
5
+ # which collides with Colab's current Pillow (the classic "_util.is_directory"
6
+ # ImportError). The project CLAUDE.md flags this exact failure mode.
7
+ #
8
+ # Strategy:
9
+ # - The age classifier (HF nateraw/vit-age-classifier) runs on PIL images
10
+ # directly. For datasets where the image IS a centered face (FFHQ) or
11
+ # where face bbox coords are provided (IMDB has `rect` in its CSV), no
12
+ # face detector is needed.
13
+ # - For deepfashion (face position unknown, possibly cropped out) we'll add
14
+ # a lightweight detector later — a separate concern.
15
+ #
16
+ # Threshold logic unchanged from the previous draft:
17
+ # reject if expected age < 24 OR P(0-2)+P(3-9)+P(10-19) > 0.20
18
+ #
19
+ # Paste this cell ONCE per Colab session, after super_dataset_lib.py.
20
+ # ─────────────────────────────────────────────────────────────────────────────
21
+
22
+
23
+ # ═════════════════════════════════════════════════════════════════════════════
24
+ # 1. ENSURE DEPS (no force-upgrades — Colab's stock transformers/torch/PIL
25
+ # are kept untouched to avoid the torchvision↔Pillow ImportError chain
26
+ # documented in the project CLAUDE.md).
27
+ # ═════════════════════════════════════════════════════════════════════════════
28
+
29
+ import importlib, subprocess, sys
30
+
31
+ def _ensure(pkg_spec: str, import_name: str | None = None):
32
+ name = import_name or pkg_spec.split(">=")[0].split("==")[0].split("<")[0]
33
+ try:
34
+ importlib.import_module(name)
35
+ except ImportError:
36
+ print(f" installing missing dep: {pkg_spec}")
37
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg_spec])
38
+
39
+ _ensure("transformers")
40
+ _ensure("torch")
41
+ print("face_age_filter deps OK (no force-upgrades).")
42
+
43
+
44
+ # ═════════════════════════════════════════════════════════════════════════════
45
+ # 2. IMPORTS + MODEL CONFIG
46
+ # ═════════════════════════════════════════════════════════════════════════════
47
+
48
+ from dataclasses import dataclass
49
+ from typing import Optional
50
+
51
+ import numpy as np
52
+ import torch
53
+ from PIL import Image as _PILImage
54
+ from transformers import AutoImageProcessor, AutoModelForImageClassification
55
+
56
+ AGE_MODEL_ID = "nateraw/vit-age-classifier"
57
+ AGE_THRESHOLD = 24.0
58
+ MINOR_MASS_MAX = 0.20
59
+
60
+ # Device selection.
61
+ # DEVICE_OVERRIDE = None → auto-detect, GPU-test-then-fallback (default)
62
+ # DEVICE_OVERRIDE = "cuda" → force GPU (ignore warnings)
63
+ # DEVICE_OVERRIDE = "cpu" → force CPU (~10× slower but always works)
64
+ #
65
+ # Auto-detect catches the case where the installed PyTorch's bundled CUDA
66
+ # kernels don't include your GPU's compute capability (e.g. stock Colab torch
67
+ # topping out at sm_90 vs an RTX 6000 Blackwell at sm_120). We detect by
68
+ # running a tiny model forward; if it crashes, fall back to CPU.
69
+ DEVICE_OVERRIDE = None
70
+
71
+
72
+ def _select_device() -> str:
73
+ if DEVICE_OVERRIDE in ("cpu", "cuda"):
74
+ return DEVICE_OVERRIDE
75
+ if not torch.cuda.is_available():
76
+ return "cpu"
77
+ # Check that the GPU's capability is in torch's compiled-for list.
78
+ try:
79
+ cap_major, cap_minor = torch.cuda.get_device_capability(0)
80
+ my_sm = f"sm_{cap_major}{cap_minor}"
81
+ # Some torch builds expose get_arch_list, some don't.
82
+ arch_list = getattr(torch.cuda, "get_arch_list", lambda: [])()
83
+ # arch_list entries look like "sm_80" / "compute_80"; normalize.
84
+ compiled_sm = {a.replace("compute_", "sm_") for a in arch_list}
85
+ if compiled_sm and my_sm not in compiled_sm:
86
+ print(f" GPU is {my_sm} but PyTorch was compiled for {sorted(compiled_sm)}.")
87
+ print(f" Trying GPU anyway — if forward fails we'll fall back to CPU.")
88
+ except Exception:
89
+ pass
90
+ return "cuda"
91
+
92
+
93
+ DEVICE = _select_device()
94
+
95
+ # Bucket → midpoint mapping. Multiplied by per-bucket probability to get a
96
+ # continuous expected age estimate.
97
+ AGE_BUCKETS = [
98
+ ("0-2", 1.0),
99
+ ("3-9", 6.0),
100
+ ("10-19", 14.0),
101
+ ("20-29", 24.0),
102
+ ("30-39", 34.0),
103
+ ("40-49", 44.0),
104
+ ("50-59", 54.0),
105
+ ("60-69", 64.0),
106
+ ("more than 70", 75.0),
107
+ ]
108
+ MINOR_BUCKETS = {"0-2", "3-9", "10-19"}
109
+
110
+
111
+ # ═════════════════════════════════════════════════════════════════════════════
112
+ # 3. MODEL LOAD (singleton)
113
+ # ═════════════════════════════════════════════════════════════════════════════
114
+
115
+ print(f"Loading age classifier {AGE_MODEL_ID} ({DEVICE}) …")
116
+ # Fast (Rust-backed) image preprocessing is the default in current transformers;
117
+ # passing use_fast= now deprecation-warns, so we pass nothing.
118
+ _AGE_PROCESSOR = AutoImageProcessor.from_pretrained(AGE_MODEL_ID)
119
+ _AGE_MODEL = AutoModelForImageClassification.from_pretrained(AGE_MODEL_ID).to(DEVICE).eval()
120
+
121
+ _MODEL_LABELS = [_AGE_MODEL.config.id2label[i] for i in range(_AGE_MODEL.config.num_labels)]
122
+ _LABEL_TO_MIDPOINT = dict(AGE_BUCKETS)
123
+
124
+ _missing = [lbl for lbl, _ in AGE_BUCKETS if lbl not in _MODEL_LABELS]
125
+ if _missing:
126
+ print(f" WARNING — model labels don't include AGE_BUCKETS entries: {_missing}")
127
+ print(f" model labels: {_MODEL_LABELS}")
128
+
129
+ # GPU smoke test: run a tiny zero-tensor forward to confirm the GPU kernels
130
+ # actually execute on this device. If PyTorch was compiled without our SM
131
+ # version (Blackwell sm_120 on stock Colab torch) this fails immediately
132
+ # rather than crashing mid-ingest.
133
+ if DEVICE == "cuda":
134
+ try:
135
+ with torch.no_grad():
136
+ _test_in = torch.zeros(1, 3, 224, 224, device=DEVICE)
137
+ _ = _AGE_MODEL(_test_in)
138
+ print(f" GPU smoke test passed. VRAM: {torch.cuda.memory_allocated()/1024**3:.2f} GB")
139
+ except RuntimeError as e:
140
+ msg = str(e).splitlines()[0]
141
+ print(f" GPU smoke test FAILED ({msg!r}) — falling back to CPU.")
142
+ DEVICE = "cpu"
143
+ _AGE_MODEL = _AGE_MODEL.to(DEVICE)
144
+ print(f" age model relocated to CPU.")
145
+ else:
146
+ print(f" running on CPU (slower, but compatible).")
147
+
148
+
149
+ # ═════════════════════════════════════════════════════════════════════════════
150
+ # 4. RESULT TYPE
151
+ # ═════════════════════════════════════════════════════════════════════════════
152
+
153
+ @dataclass
154
+ class FaceCheckResult:
155
+ """Outcome of running the age filter on ONE image."""
156
+ decision: str # "pass" | "fail"
157
+ expected_age: float # continuous age estimate
158
+ minor_mass: float # P(0-2)+P(3-9)+P(10-19)
159
+ most_likely_bucket: str # argmax bucket label
160
+ most_likely_prob: float # probability of argmax bucket
161
+ reasons: list # human-readable reasons for fail
162
+
163
+ def to_audit(self) -> dict:
164
+ return {
165
+ "decision": self.decision,
166
+ "expected_age": round(self.expected_age, 1),
167
+ "minor_mass": round(self.minor_mass, 3),
168
+ "most_likely": f"{self.most_likely_bucket} ({self.most_likely_prob:.2f})",
169
+ "reasons": self.reasons,
170
+ }
171
+
172
+
173
+ # ═════════════════════════════════════════════════════════════════════════════
174
+ # 5. FaceAgeFilter — age-classifier-only variant
175
+ # ═════════════════════════════════════════════════════════════════════════════
176
+
177
+ class FaceAgeFilter:
178
+ """Runs the age classifier over images (or pre-cropped face regions).
179
+
180
+ Entry points:
181
+ .check_one(pil, bbox=None) — single image (optional face bbox crop)
182
+ .check_batch(pils, bboxes=None) — N images, batched on GPU
183
+
184
+ `bbox` (if provided) is an (x1, y1, x2, y2) tuple in pixel coords —
185
+ the image is cropped to that region before classification. Useful for
186
+ IMDB where the CSV provides face bbox coords. For FFHQ leave bbox=None
187
+ and the whole image is classified (each FFHQ image is a centered face crop).
188
+
189
+ decision_mode controls how strict the reject rule is:
190
+ "strict" — fail if expected_age < age_threshold OR minor_mass > minor_mass_max
191
+ (catches every borderline; gives ~30-40% reject rate on FFHQ)
192
+ "balanced" — fail only if most_likely bucket is a minor bucket OR minor_mass > 0.40
193
+ (single-bucket-argmax + relaxed mass; ~10-20% reject rate)
194
+ "loose" — fail only if most_likely bucket is a minor bucket
195
+ (most permissive; only rejects model-confident minors)
196
+ """
197
+
198
+ def __init__(self,
199
+ age_threshold: float = AGE_THRESHOLD,
200
+ minor_mass_max: float = MINOR_MASS_MAX,
201
+ decision_mode: str = "strict", # "strict" | "balanced" | "loose"
202
+ batch_size: int = 32):
203
+ assert decision_mode in ("strict", "balanced", "loose")
204
+ self.age_threshold = age_threshold
205
+ self.minor_mass_max = minor_mass_max
206
+ self.decision_mode = decision_mode
207
+ self.batch_size = batch_size
208
+
209
+ # ── core ────────────────────────────────────────────────────────────────
210
+
211
+ def _prep_one(self, img: _PILImage.Image,
212
+ bbox: Optional[tuple] = None) -> _PILImage.Image:
213
+ if img.mode != "RGB":
214
+ img = img.convert("RGB")
215
+ if bbox is not None:
216
+ x1, y1, x2, y2 = bbox
217
+ W, H = img.size
218
+ x1, y1 = max(0, int(x1)), max(0, int(y1))
219
+ x2, y2 = min(W, int(x2)), min(H, int(y2))
220
+ if x2 > x1 and y2 > y1:
221
+ img = img.crop((x1, y1, x2, y2))
222
+ return img
223
+
224
+ def _classify_batch(self, crops: list) -> tuple:
225
+ """Returns (expected_ages, minor_masses, most_likely_buckets, most_likely_probs)
226
+ per crop. Each is a list aligned with `crops`."""
227
+ if not crops:
228
+ return [], [], [], []
229
+ inputs = _AGE_PROCESSOR(images=crops, return_tensors="pt").to(DEVICE)
230
+ with torch.no_grad():
231
+ logits = _AGE_MODEL(**inputs).logits
232
+ probs = torch.softmax(logits, dim=-1).cpu().numpy()
233
+ expected_ages, minor_masses = [], []
234
+ most_likely_buckets, most_likely_probs = [], []
235
+ for row in probs:
236
+ exp_age, minor_mass = 0.0, 0.0
237
+ for i, label in enumerate(_MODEL_LABELS):
238
+ p = float(row[i])
239
+ exp_age += p * _LABEL_TO_MIDPOINT.get(label, 0.0)
240
+ if label in MINOR_BUCKETS:
241
+ minor_mass += p
242
+ expected_ages.append(exp_age)
243
+ minor_masses.append(minor_mass)
244
+ mli = int(row.argmax())
245
+ most_likely_buckets.append(_MODEL_LABELS[mli])
246
+ most_likely_probs.append(float(row[mli]))
247
+ return expected_ages, minor_masses, most_likely_buckets, most_likely_probs
248
+
249
+ def _decide(self, exp_age: float, minor_mass: float,
250
+ most_likely_bucket: str, most_likely_prob: float) -> tuple:
251
+ reasons = []
252
+ mode = self.decision_mode
253
+ if mode == "strict":
254
+ if exp_age < self.age_threshold:
255
+ reasons.append(f"expected_age={exp_age:.1f} < {self.age_threshold}")
256
+ if minor_mass > self.minor_mass_max:
257
+ reasons.append(f"minor_mass={minor_mass:.2f} > {self.minor_mass_max}")
258
+ elif mode == "balanced":
259
+ if most_likely_bucket in MINOR_BUCKETS:
260
+ reasons.append(f"most_likely={most_likely_bucket} ({most_likely_prob:.2f}) is minor bucket")
261
+ elif minor_mass > 0.40:
262
+ reasons.append(f"minor_mass={minor_mass:.2f} > 0.40")
263
+ elif mode == "loose":
264
+ if most_likely_bucket in MINOR_BUCKETS:
265
+ reasons.append(f"most_likely={most_likely_bucket} ({most_likely_prob:.2f}) is minor bucket")
266
+ return (("fail", reasons) if reasons else ("pass", []))
267
+
268
+ # ── public ──────────────────────────────────────────────────────────────
269
+
270
+ def check_one(self, img: _PILImage.Image,
271
+ bbox: Optional[tuple] = None) -> FaceCheckResult:
272
+ prepped = self._prep_one(img, bbox)
273
+ ea, mm, mlb, mlp = self._classify_batch([prepped])
274
+ decision, reasons = self._decide(ea[0], mm[0], mlb[0], mlp[0])
275
+ return FaceCheckResult(
276
+ decision=decision, expected_age=ea[0], minor_mass=mm[0],
277
+ most_likely_bucket=mlb[0], most_likely_prob=mlp[0],
278
+ reasons=reasons,
279
+ )
280
+
281
+ def check_batch(self, images: list,
282
+ bboxes: Optional[list] = None) -> list:
283
+ """Process N images. `bboxes`, if given, must have same length as `images`
284
+ (use None for items where no crop should happen)."""
285
+ if not images:
286
+ return []
287
+ if bboxes is None:
288
+ bboxes = [None] * len(images)
289
+ assert len(bboxes) == len(images), "bboxes and images must align"
290
+
291
+ prepped = [self._prep_one(im, bb) for im, bb in zip(images, bboxes)]
292
+
293
+ all_exp, all_mm, all_mlb, all_mlp = [], [], [], []
294
+ bs = self.batch_size
295
+ for start in range(0, len(prepped), bs):
296
+ ea, mm, mlb, mlp = self._classify_batch(prepped[start:start + bs])
297
+ all_exp.extend(ea); all_mm.extend(mm)
298
+ all_mlb.extend(mlb); all_mlp.extend(mlp)
299
+
300
+ results = []
301
+ for ea, mm, mlb, mlp in zip(all_exp, all_mm, all_mlb, all_mlp):
302
+ decision, reasons = self._decide(ea, mm, mlb, mlp)
303
+ results.append(FaceCheckResult(
304
+ decision=decision, expected_age=ea, minor_mass=mm,
305
+ most_likely_bucket=mlb, most_likely_prob=mlp,
306
+ reasons=reasons,
307
+ ))
308
+ return results
309
+
310
+
311
+ print(f"face_age_filter loaded. threshold={AGE_THRESHOLD}, "
312
+ f"minor_mass_max={MINOR_MASS_MAX}, batch={32}")
qwen_test_runner/__init__.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """qwen_test_runner — testbed for evaluating small Qwen models on JSON schema.
2
+
3
+ Public API:
4
+ from qwen_test_runner import Caption, QwenRunner, score_sample, score_run
5
+ from qwen_test_runner import SLOT_REGISTRY, SlotSpec # v0.2 registry
6
+
7
+ CLI:
8
+ qwen-bench --help
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.2.0"
14
+
15
+ # Registry — single source of truth for all slot definitions
16
+ from .registry import (
17
+ SLOT_REGISTRY,
18
+ SlotSpec,
19
+ SubjectValue,
20
+ Category,
21
+ Cardinality,
22
+ Vocabulary,
23
+ Groundedness,
24
+ slots_by_category,
25
+ slot_names,
26
+ get_slot,
27
+ all_closed_vocab,
28
+ )
29
+
30
+ # Schema — generated from registry at import time
31
+ from .schema import (
32
+ Caption,
33
+ Subject, # alias for SubjectValue, kept for back-compat
34
+ CAPTION_JSON_SCHEMA,
35
+ CAPTION_GRAMMAR_GBNF,
36
+ build_gbnf_grammar,
37
+ )
38
+
39
+ # Evaluation — scoring functions
40
+ from .evaluator import (
41
+ parse_safely,
42
+ ground_check,
43
+ coverage_check,
44
+ score_sample,
45
+ score_run,
46
+ SampleResult,
47
+ RunMetrics,
48
+ GroundingReport,
49
+ CoverageReport,
50
+ )
51
+
52
+ # Eval data
53
+ from .eval_set import BUILTIN_CAPTIONS, load_eval_set
54
+
55
+
56
+ # Model runner — imported lazily so `import qwen_test_runner` doesn't drag in
57
+ # torch unless the user actually needs it. The names are still importable as
58
+ # `from qwen_test_runner import QwenRunner` thanks to __getattr__.
59
+ def __getattr__(name: str):
60
+ if name == "QwenRunner":
61
+ from .model_runner import QwenRunner
62
+ return QwenRunner
63
+ if name == "GenResult":
64
+ from .model_runner import GenResult
65
+ return GenResult
66
+ if name == "ClaudeProvider":
67
+ from .providers.claude_api import ClaudeProvider
68
+ return ClaudeProvider
69
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
70
+
71
+
72
+ __all__ = [
73
+ "__version__",
74
+ # registry
75
+ "SLOT_REGISTRY", "SlotSpec", "SubjectValue",
76
+ "Category", "Cardinality", "Vocabulary", "Groundedness",
77
+ "slots_by_category", "slot_names", "get_slot", "all_closed_vocab",
78
+ # schema (generated)
79
+ "Caption", "Subject",
80
+ "CAPTION_JSON_SCHEMA", "CAPTION_GRAMMAR_GBNF", "build_gbnf_grammar",
81
+ # evaluator
82
+ "parse_safely", "ground_check", "coverage_check",
83
+ "score_sample", "score_run",
84
+ "SampleResult", "RunMetrics", "GroundingReport", "CoverageReport",
85
+ # eval data
86
+ "BUILTIN_CAPTIONS", "load_eval_set",
87
+ # runners (lazy)
88
+ "QwenRunner", "GenResult", "ClaudeProvider",
89
+ ]
qwen_test_runner/data_gen.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ data_gen.py — Generate SFT-ready caption→schema training data.
3
+
4
+ Pipeline:
5
+ 1. Load source captions from a file (one per line) or the builtin eval set.
6
+ 2. Pass each through a provider (Claude by default) to produce structured JSON.
7
+ 3. Score each result against the registry's grounding rules.
8
+ 4. Filter: keep only rows where grounding_rate == 1.0 (no hallucinations).
9
+ 5. Write one JSONL row per kept sample, in the OpenAI-chat format that
10
+ trl.SFTTrainer accepts directly.
11
+
12
+ The "filter on grounding" step is essential: Claude is excellent but not
13
+ perfect, and we don't want Claude's stray hallucinations leaking into the
14
+ Qwen training set. Roughly 30-50% rejection is normal on diverse inputs;
15
+ that's a feature, not a bug.
16
+
17
+ Usage:
18
+ qwen-datagen --source captions.txt --output train.jsonl --n 1000
19
+ qwen-datagen --source builtin --prompt strict
20
+ qwen-datagen --source captions.txt --provider claude --model claude-haiku-4-5
21
+ """
22
+
23
+ from __future__ import annotations
24
+ import argparse
25
+ import json
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+ from typing import Iterable, Optional
30
+
31
+ from .registry import SLOT_REGISTRY
32
+ from .schema import CAPTION_JSON_SCHEMA
33
+ from .evaluator import score_sample
34
+ from .eval_set import load_eval_set
35
+
36
+
37
+ # ──────────────────────────────────────────────────────────────────────────────
38
+ # SFT row formatting — OpenAI chat format consumed directly by trl.SFTTrainer.
39
+ # Single system + single user + single assistant. Assistant emits raw JSON.
40
+ # ──────────────────────────────────────────────────────────────────────────────
41
+
42
+ SFT_SYSTEM_PROMPT = """You are a caption-structuring assistant. Convert each
43
+ image caption into JSON matching the schema. Only include subjects, attributes,
44
+ and actions explicitly mentioned in the caption. Use null/[] for unspecified
45
+ fields.""".strip()
46
+
47
+
48
+ def make_sft_row(caption: str, structured_json: str) -> dict:
49
+ """Build one SFTTrainer-compatible row."""
50
+ return {
51
+ "messages": [
52
+ {"role": "system", "content": SFT_SYSTEM_PROMPT},
53
+ {"role": "user", "content": caption},
54
+ {"role": "assistant", "content": structured_json},
55
+ ]
56
+ }
57
+
58
+
59
+ # ──────────────────────────────────────────────────────────────────────────────
60
+ # Source loaders
61
+ # ──────────────────────────────────────────────────────────────────────────────
62
+
63
+ def load_captions(source: str, limit: Optional[int] = None) -> list[str]:
64
+ """Load captions from `builtin`, a .txt (one per line), or a .json (list)."""
65
+ captions = load_eval_set(source) # `load_eval_set` already handles all three
66
+ if limit is not None:
67
+ captions = captions[:limit]
68
+ return captions
69
+
70
+
71
+ # ──────────────────────────────────────────────────────────────────────────────
72
+ # Generation loop
73
+ # ──────────────────────────────────────────────────────────────────────────────
74
+
75
+ def generate_dataset(
76
+ captions: list[str],
77
+ provider,
78
+ prompt: str = "strict",
79
+ grounding_threshold: float = 1.0,
80
+ on_progress=None,
81
+ ) -> tuple[list[dict], dict]:
82
+ """Run captions through the provider, filter on grounding, return SFT rows + stats.
83
+
84
+ Returns:
85
+ (rows, stats)
86
+ rows — list of SFT-format dicts (ready to json.dump line-by-line)
87
+ stats — {"total", "kept", "rejected_halluc", "rejected_invalid", "total_cost_usd"}
88
+ """
89
+ rows: list[dict] = []
90
+ stats = {"total": 0, "kept": 0, "rejected_halluc": 0,
91
+ "rejected_invalid": 0, "total_cost_usd": 0.0}
92
+
93
+ for i, cap in enumerate(captions):
94
+ stats["total"] += 1
95
+ try:
96
+ result = provider.process(cap, prompt=prompt)
97
+ except Exception as e:
98
+ stats["rejected_invalid"] += 1
99
+ if on_progress:
100
+ on_progress(i, cap, status=f"provider error: {e}")
101
+ continue
102
+
103
+ stats["total_cost_usd"] += result.cost_usd
104
+ scored = score_sample(cap, result.raw_text, mode=result.mode,
105
+ n_input_tokens=result.n_input_tokens,
106
+ n_output_tokens=result.n_output_tokens)
107
+
108
+ if not scored.schema_valid:
109
+ stats["rejected_invalid"] += 1
110
+ if on_progress:
111
+ on_progress(i, cap, status=f"invalid: {scored.parse_error}",
112
+ cost=result.cost_usd)
113
+ continue
114
+
115
+ if scored.grounding_rate < grounding_threshold:
116
+ stats["rejected_halluc"] += 1
117
+ if on_progress:
118
+ on_progress(i, cap, status=f"halluc: {scored.hallucinations}",
119
+ cost=result.cost_usd)
120
+ continue
121
+
122
+ rows.append(make_sft_row(cap, result.raw_text))
123
+ stats["kept"] += 1
124
+ if on_progress:
125
+ on_progress(i, cap, status="kept", cost=result.cost_usd)
126
+
127
+ return rows, stats
128
+
129
+
130
+ # ──────────────────────────────────────────────────────────────────────────────
131
+ # CLI
132
+ # ──────────────────────────────────────────────────────────────────────────────
133
+
134
+ def _print_progress(i: int, caption: str, status: str, cost: float = 0.0):
135
+ short = caption[:60] + ("…" if len(caption) > 60 else "")
136
+ cost_str = f" ${cost:.4f}" if cost else ""
137
+ print(f" [{i + 1:4d}] {status[:30]:30s}{cost_str} → {short}")
138
+
139
+
140
+ def main(argv: Optional[list[str]] = None) -> int:
141
+ p = argparse.ArgumentParser(description="Generate SFT-ready caption→schema dataset.")
142
+ p.add_argument("--source", default="builtin",
143
+ help="builtin | path to .txt (one per line) | path to .json (list)")
144
+ p.add_argument("--output", default="train.jsonl",
145
+ help="output JSONL file (overwritten if exists)")
146
+ p.add_argument("--n", type=int, default=None,
147
+ help="cap captions to this many (default: all)")
148
+ p.add_argument("--provider", choices=["claude"], default="claude",
149
+ help="backend to use (more added later)")
150
+ p.add_argument("--model", default="claude-sonnet-4-6",
151
+ help="model id for the provider")
152
+ p.add_argument("--prompt", choices=["strict", "enhance"], default="strict",
153
+ help="strict: descriptive only; enhance: license style/mood inference")
154
+ p.add_argument("--grounding-threshold", type=float, default=1.0,
155
+ help="reject samples below this grounding rate (default: 1.0 = strict)")
156
+ args = p.parse_args(argv)
157
+
158
+ captions = load_captions(args.source, limit=args.n)
159
+ print(f"Loaded {len(captions)} source captions from {args.source}")
160
+
161
+ if args.provider == "claude":
162
+ from .providers.claude_api import ClaudeProvider
163
+ provider = ClaudeProvider(model=args.model)
164
+ else:
165
+ raise NotImplementedError(args.provider)
166
+
167
+ print(f"Provider: {args.provider} ({args.model}) prompt={args.prompt} "
168
+ f"grounding>={args.grounding_threshold}")
169
+ t0 = time.time()
170
+
171
+ rows, stats = generate_dataset(
172
+ captions=captions,
173
+ provider=provider,
174
+ prompt=args.prompt,
175
+ grounding_threshold=args.grounding_threshold,
176
+ on_progress=_print_progress,
177
+ )
178
+
179
+ dt = time.time() - t0
180
+
181
+ out_path = Path(args.output)
182
+ out_path.parent.mkdir(parents=True, exist_ok=True)
183
+ with out_path.open("w") as fh:
184
+ for row in rows:
185
+ fh.write(json.dumps(row) + "\n")
186
+
187
+ print("\n=== summary ===")
188
+ print(f" total : {stats['total']}")
189
+ print(f" kept : {stats['kept']} ({stats['kept']/max(stats['total'], 1):.1%})")
190
+ print(f" rejected halluc : {stats['rejected_halluc']}")
191
+ print(f" rejected invalid: {stats['rejected_invalid']}")
192
+ print(f" total cost : ${stats['total_cost_usd']:.4f}")
193
+ print(f" wall time : {dt:.1f}s")
194
+ print(f" output : {out_path}")
195
+ return 0
196
+
197
+
198
+ if __name__ == "__main__":
199
+ sys.exit(main())
qwen_test_runner/eval_set.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ eval_set.py — Seed evaluation captions.
3
+
4
+ The set is designed to stress different failure modes:
5
+ * short captions (where the model is most tempted to invent)
6
+ * long descriptive captions (where it's most tempted to drop information)
7
+ * captions with explicit setting words ("kitchen", "park")
8
+ * captions with NO setting cues (force "unknown")
9
+ * captions with abstract nouns (no clear "subject")
10
+ * captions in different domains (photo, painting, screenshot)
11
+ """
12
+
13
+ from __future__ import annotations
14
+ from typing import List
15
+ import json
16
+ from pathlib import Path
17
+
18
+
19
+ BUILTIN_CAPTIONS: List[str] = [
20
+ # short, sparse — model will be tempted to invent details
21
+ "a dog",
22
+ "a red car",
23
+ "two people talking",
24
+
25
+ # everyday scenes with clear setting cues
26
+ "A golden retriever catching a red frisbee in a sunny park.",
27
+ "A child eating cereal at a kitchen table.",
28
+ "Three commuters waiting at a subway platform during rush hour.",
29
+ "An elderly woman knitting on a porch swing.",
30
+ "A chef plating pasta in a busy restaurant kitchen.",
31
+
32
+ # outdoor / landscape (no people, no explicit framing)
33
+ "A snow-covered mountain ridge under a clear blue sky.",
34
+ "Waves crashing against jagged coastal rocks at sunset.",
35
+ "A field of yellow sunflowers stretching to the horizon.",
36
+
37
+ # indoor / no setting word (model must infer)
38
+ "Books stacked haphazardly on a worn wooden desk.",
39
+ "A laptop showing a half-finished email beside a steaming mug.",
40
+ "A single candle burning in an otherwise dark room.",
41
+
42
+ # explicit composition / framing words present
43
+ "Close-up of a bumblebee on a lavender flower, side view.",
44
+ "Wide shot of a marching band crossing a stadium field.",
45
+ "Overhead view of a chess game in progress.",
46
+
47
+ # action-heavy
48
+ "A skateboarder grinding a metal rail at a skatepark.",
49
+ "Two boxers exchanging punches in a brightly lit ring.",
50
+ "Firefighters carrying hoses up a smoke-filled stairwell.",
51
+
52
+ # mood-laden
53
+ "An empty playground at dusk, swings creaking in the wind.",
54
+ "A bride laughing as she dances with her father at a wedding reception.",
55
+ "A lone wolf howling at the moon on a snowy ridge.",
56
+
57
+ # abstract / art / non-photographic
58
+ "An abstract painting of swirling reds and oranges.",
59
+ "A digital illustration of a cyberpunk city at night with neon signs.",
60
+ "A black and white sketch of a hand holding a pencil.",
61
+
62
+ # screenshots / UI / unusual
63
+ "A screenshot of a video game character standing in a forest clearing.",
64
+ "A satellite image of a hurricane over the Atlantic Ocean.",
65
+ "A microscope photograph of red blood cells.",
66
+
67
+ # tricky — multiple subjects, multiple actions
68
+ "A barista pouring milk into a latte while a customer types on a laptop in the background.",
69
+ "A cat watching from the windowsill as squirrels chase each other on the lawn outside.",
70
+ ]
71
+
72
+
73
+ def load_eval_set(name_or_path: str = "builtin") -> List[str]:
74
+ """
75
+ Load captions. If `name_or_path == "builtin"`, return the hand-curated set.
76
+ Otherwise treat as a path to a .txt (one per line) or .json (list of strings).
77
+ """
78
+ if name_or_path == "builtin":
79
+ return list(BUILTIN_CAPTIONS)
80
+
81
+ path = Path(name_or_path)
82
+ if not path.exists():
83
+ raise FileNotFoundError(name_or_path)
84
+
85
+ if path.suffix == ".json":
86
+ data = json.loads(path.read_text())
87
+ if not isinstance(data, list) or not all(isinstance(x, str) for x in data):
88
+ raise ValueError("JSON eval set must be a list of strings")
89
+ return data
90
+
91
+ # .txt — one caption per line, ignore blank lines and lines starting with #
92
+ return [
93
+ line.strip()
94
+ for line in path.read_text().splitlines()
95
+ if line.strip() and not line.strip().startswith("#")
96
+ ]
97
+
98
+
99
+ if __name__ == "__main__":
100
+ captions = load_eval_set("builtin")
101
+ print(f"builtin eval set: {len(captions)} captions")
102
+ lengths = [len(c.split()) for c in captions]
103
+ print(f" word counts: min={min(lengths)} median={sorted(lengths)[len(lengths)//2]} max={max(lengths)}")
qwen_test_runner/evaluator.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ evaluator.py — Scores model output along three orthogonal axes:
3
+
4
+ 1. SCHEMA VALIDITY: does it parse as JSON, and validate against the Pydantic schema?
5
+ 2. GROUNDING: is every leaf string traceable to the input caption?
6
+ This is the hallucination metric. v0.2: per-slot rule
7
+ driven by `groundedness` in registry.SLOT_REGISTRY.
8
+ 3. COVERAGE: did the model surface the obvious nouns/verbs from the input,
9
+ or did it drop information? (cheap recall signal)
10
+
11
+ Grounding rules (per slot, read from registry):
12
+ - must_ground : every leaf MUST trace to input. Otherwise hallucinated.
13
+ - may_infer : leaf is allowed regardless of input. Counted as grounded.
14
+ Closed-vocab values (e.g. "indoor") are also auto-grounded
15
+ because the grammar enforces the value space anyway.
16
+ - derived_only : leaf is expected to be inferred. Auto-grounded, never penalized.
17
+ """
18
+
19
+ from __future__ import annotations
20
+ import json
21
+ import re
22
+ from dataclasses import dataclass, field, asdict
23
+ from typing import Any, List, Optional, Tuple
24
+
25
+ from pydantic import BaseModel, ValidationError
26
+
27
+ from .schema import Caption
28
+ from .registry import SLOT_REGISTRY, SubjectValue, all_closed_vocab
29
+
30
+
31
+ # Auto-grounded values — anything in any closed vocab is always counted as
32
+ # grounded since the grammar pins the value space.
33
+ CLOSED_VOCAB: set[str] = all_closed_vocab()
34
+
35
+
36
+ # ──────────────────────────────────────────────────────────────────────────────
37
+ # Parsing — recover JSON from messy model output (markdown fences, prose, etc.)
38
+ # ──────────────────────────────────────────────────────────────────────────────
39
+
40
+ _FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
41
+
42
+
43
+ def _strip_fences(text: str) -> str:
44
+ """If the model wrapped output in ```json ... ```, peel it off. First fence wins."""
45
+ m = _FENCE_RE.search(text)
46
+ return m.group(1).strip() if m else text.strip()
47
+
48
+
49
+ def _extract_first_json_object(text: str) -> Optional[str]:
50
+ """
51
+ Walk the text and return the first balanced {...} substring.
52
+ Tolerates leading prose. Returns None if no balanced object found.
53
+ """
54
+ text = _strip_fences(text)
55
+ start = text.find("{")
56
+ if start < 0:
57
+ return None
58
+ depth = 0
59
+ in_str = False
60
+ esc = False
61
+ for i in range(start, len(text)):
62
+ c = text[i]
63
+ if esc:
64
+ esc = False
65
+ continue
66
+ if c == "\\":
67
+ esc = True
68
+ continue
69
+ if c == '"':
70
+ in_str = not in_str
71
+ continue
72
+ if in_str:
73
+ continue
74
+ if c == "{":
75
+ depth += 1
76
+ elif c == "}":
77
+ depth -= 1
78
+ if depth == 0:
79
+ return text[start:i + 1]
80
+ return None
81
+
82
+
83
+ @dataclass
84
+ class GenericParseReport:
85
+ """Result of recovering + validating JSON against an arbitrary Pydantic model.
86
+
87
+ Two repair signals, because not all repair is equal:
88
+ `needed_repair` — any recovery was needed (not clean bare JSON).
89
+ `needed_structural_repair` — recovery needed MORE than stripping a markdown
90
+ code fence (prose prefix, trailing tokens,
91
+ runaway generation). Fence-stripping is benign
92
+ and 100% deterministic, so the vision
93
+ `json_robustness` metric keys off the STRUCTURAL
94
+ signal — a model that only wraps clean JSON in
95
+ ```fences``` is still robust; one that buries it
96
+ in prose is not.
97
+ """
98
+ parsed: Optional[Any]
99
+ schema_valid: bool
100
+ error: Optional[str]
101
+ needed_repair: bool = False
102
+ needed_structural_repair: bool = False
103
+
104
+
105
+ def parse_against(raw_text: str, model: type[BaseModel]) -> GenericParseReport:
106
+ """Recover the first JSON object from raw model output and validate it against
107
+ `model`. Never raises. Used by both the caption path (model=Caption) and the
108
+ vision metrics (model=per-category schema)."""
109
+ obj_str = _extract_first_json_object(raw_text)
110
+ if obj_str is None:
111
+ return GenericParseReport(None, False, "no JSON object found",
112
+ needed_repair=True, needed_structural_repair=True)
113
+ stripped = raw_text.strip()
114
+ needed_repair = stripped != obj_str
115
+ # If the ONLY thing between the raw text and the object was a code fence, the
116
+ # fence-stripped text equals the object → benign (fence-only) repair.
117
+ fenced_inner = _strip_fences(raw_text).strip()
118
+ fence_only = needed_repair and (fenced_inner == obj_str)
119
+ needed_structural_repair = needed_repair and not fence_only
120
+ try:
121
+ as_dict = json.loads(obj_str)
122
+ except json.JSONDecodeError as e:
123
+ return GenericParseReport(None, False, f"json decode: {e}",
124
+ needed_repair=True, needed_structural_repair=True)
125
+ try:
126
+ parsed = model.model_validate(as_dict)
127
+ except ValidationError as e:
128
+ return GenericParseReport(None, False, f"schema: {e.errors()[:2]}",
129
+ needed_repair=needed_repair,
130
+ needed_structural_repair=needed_structural_repair)
131
+ return GenericParseReport(parsed, True, None, needed_repair=needed_repair,
132
+ needed_structural_repair=needed_structural_repair)
133
+
134
+
135
+ @dataclass
136
+ class ParseReport:
137
+ parsed: Optional[Caption]
138
+ schema_valid: bool
139
+ error: Optional[str]
140
+
141
+
142
+ def parse_safely(raw_text: str) -> ParseReport:
143
+ """Try to recover a Caption object from raw model output. Never raises."""
144
+ r = parse_against(raw_text, Caption)
145
+ return ParseReport(r.parsed, r.schema_valid, r.error)
146
+
147
+
148
+ # ──────────────────────────────────────────────────────────────────────────────
149
+ # Grounding — the hallucination metric
150
+ # ──────────────────────────────────────────────────────────────────────────────
151
+
152
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
153
+
154
+
155
+ def _normalize(s: str) -> str:
156
+ return s.lower().strip()
157
+
158
+
159
+ def _tokens(s: str) -> List[str]:
160
+ return _TOKEN_RE.findall(s.lower())
161
+
162
+
163
+ def _depluralize(token: str) -> str:
164
+ """Cheap singularization: drop trailing -s, -es, -ies. No NLTK dependency.
165
+
166
+ LIMITATION: irregular plurals (children, mice, geese, men) are not handled.
167
+ Those will surface as false-positive hallucinations. Upgrade to a real
168
+ lemmatizer if irregular-plural FPs become a problem in production data.
169
+ """
170
+ if len(token) <= 3:
171
+ return token
172
+ if token.endswith("ies"):
173
+ return token[:-3] + "y"
174
+ if token.endswith("es"):
175
+ return token[:-2]
176
+ if token.endswith("s"):
177
+ return token[:-1]
178
+ return token
179
+
180
+
181
+ def _is_grounded(leaf: str, input_caption: str) -> bool:
182
+ """Does `leaf` trace back to `input_caption`?"""
183
+ leaf_norm = _normalize(leaf)
184
+ if leaf_norm in CLOSED_VOCAB:
185
+ return True
186
+
187
+ cap_norm = _normalize(input_caption)
188
+ # Direct substring (handles multi-word phrases like "blue car")
189
+ if leaf_norm in cap_norm:
190
+ return True
191
+
192
+ # Token-level: every token of the leaf (after singularization) must appear in caption
193
+ cap_tokens = {_depluralize(t) for t in _tokens(input_caption)}
194
+ leaf_tokens = [_depluralize(t) for t in _tokens(leaf)]
195
+ if leaf_tokens and all(t in cap_tokens for t in leaf_tokens):
196
+ return True
197
+ return False
198
+
199
+
200
+ @dataclass
201
+ class GroundingReport:
202
+ leaves_total: int
203
+ leaves_grounded: int
204
+ hallucinated: List[Tuple[str, str]] # (field_path, value)
205
+
206
+ @property
207
+ def grounding_rate(self) -> float:
208
+ return self.leaves_grounded / self.leaves_total if self.leaves_total else 1.0
209
+
210
+
211
+ def _collect_leaves(caption: Caption) -> List[Tuple[str, str, str]]:
212
+ """Walk the caption and return (path, value, groundedness) for every leaf.
213
+
214
+ Closed-vocab single-value slots are NOT included — their value space is
215
+ grammar-enforced, so they can't hallucinate by definition.
216
+ """
217
+ leaves: List[Tuple[str, str, str]] = []
218
+ for slot_name, spec in SLOT_REGISTRY.items():
219
+ val = getattr(caption, slot_name)
220
+
221
+ if spec.cardinality == "list":
222
+ if spec.nested_model is SubjectValue:
223
+ for i, subj in enumerate(val):
224
+ leaves.append((f"{slot_name}[{i}].name", subj.name, spec.groundedness))
225
+ for j, attr in enumerate(subj.attributes):
226
+ leaves.append(
227
+ (f"{slot_name}[{i}].attributes[{j}]", attr, spec.groundedness)
228
+ )
229
+ else:
230
+ for i, item in enumerate(val):
231
+ leaves.append((f"{slot_name}[{i}]", item, spec.groundedness))
232
+ else:
233
+ if val is None:
234
+ continue
235
+ if spec.vocabulary == "closed":
236
+ # Value space is grammar-enforced — auto-grounded, not a leaf.
237
+ continue
238
+ leaves.append((slot_name, val, spec.groundedness))
239
+ return leaves
240
+
241
+
242
+ def ground_check(caption: Caption, input_text: str) -> GroundingReport:
243
+ """Walk every leaf in the parsed caption; flag per the slot's groundedness rule.
244
+
245
+ - must_ground: leaf must trace to input or it's hallucinated
246
+ - may_infer: leaf auto-counts as grounded (closed enums + soft slots)
247
+ - derived_only: leaf auto-counts as grounded (model is expected to infer)
248
+ """
249
+ leaves = _collect_leaves(caption)
250
+ grounded = 0
251
+ halluc: List[Tuple[str, str]] = []
252
+
253
+ for path, val, groundedness in leaves:
254
+ if groundedness in ("may_infer", "derived_only"):
255
+ grounded += 1
256
+ continue
257
+ # must_ground — strict check
258
+ if _is_grounded(val, input_text):
259
+ grounded += 1
260
+ else:
261
+ halluc.append((path, val))
262
+
263
+ return GroundingReport(
264
+ leaves_total=len(leaves),
265
+ leaves_grounded=grounded,
266
+ hallucinated=halluc,
267
+ )
268
+
269
+
270
+ # ──────────────────────────────────────────────────────────────────────────────
271
+ # Coverage — did the model surface the obvious nouns from input? (cheap recall)
272
+ # ──────────────────────────────────────────────────────────────────────────────
273
+
274
+ # Common English stop-tokens we don't expect to appear as caption subjects/actions
275
+ _STOP = {
276
+ "a", "an", "the", "of", "in", "on", "at", "to", "and", "or", "with",
277
+ "is", "are", "was", "were", "be", "been", "being",
278
+ "this", "that", "these", "those", "it", "its",
279
+ "for", "from", "by", "as", "into", "onto", "over", "under",
280
+ }
281
+
282
+
283
+ def _content_tokens(text: str) -> set[str]:
284
+ return {_depluralize(t) for t in _tokens(text) if t not in _STOP and len(t) > 2}
285
+
286
+
287
+ @dataclass
288
+ class CoverageReport:
289
+ input_content_tokens: int
290
+ output_coverage: int
291
+
292
+ @property
293
+ def coverage_rate(self) -> float:
294
+ return self.output_coverage / self.input_content_tokens if self.input_content_tokens else 1.0
295
+
296
+
297
+ def _collect_output_strings(caption: Caption) -> list[str]:
298
+ """All string content the model produced, for coverage / recall scoring.
299
+
300
+ Iterates the registry so new slots automatically participate in coverage.
301
+ Closed-vocab single-value slots are excluded — their values come from the
302
+ enum, not from input content, so they're not informative for recall.
303
+ """
304
+ out: list[str] = []
305
+ for slot_name, spec in SLOT_REGISTRY.items():
306
+ val = getattr(caption, slot_name)
307
+ if spec.cardinality == "list":
308
+ if spec.nested_model is SubjectValue:
309
+ for subj in val:
310
+ out.append(subj.name)
311
+ out.extend(subj.attributes)
312
+ else:
313
+ out.extend(val)
314
+ else:
315
+ if val is None:
316
+ continue
317
+ if spec.vocabulary == "closed":
318
+ continue
319
+ out.append(val)
320
+ return out
321
+
322
+
323
+ def coverage_check(caption: Caption, input_text: str) -> CoverageReport:
324
+ in_tokens = _content_tokens(input_text)
325
+ out_blob = " ".join(_collect_output_strings(caption))
326
+ out_tokens = _content_tokens(out_blob)
327
+ overlap = in_tokens & out_tokens
328
+ return CoverageReport(len(in_tokens), len(overlap))
329
+
330
+
331
+ # ──────────────────────────────────────────────────────────────────────────────
332
+ # Per-sample and per-run aggregation
333
+ # ──────────────────────────────────────────────────────────────────────────────
334
+
335
+ @dataclass
336
+ class SampleResult:
337
+ input_caption: str
338
+ mode: str
339
+ raw_output: str
340
+ schema_valid: bool
341
+ parse_error: Optional[str]
342
+ grounding_rate: float
343
+ hallucinations: List[Tuple[str, str]]
344
+ coverage_rate: float
345
+ n_input_tokens: int
346
+ n_output_tokens: int
347
+
348
+ def to_dict(self) -> dict:
349
+ return asdict(self)
350
+
351
+
352
+ def score_sample(
353
+ input_caption: str,
354
+ raw_output: str,
355
+ mode: str,
356
+ n_input_tokens: int = 0,
357
+ n_output_tokens: int = 0,
358
+ ) -> SampleResult:
359
+ parse = parse_safely(raw_output)
360
+ if not parse.schema_valid or parse.parsed is None:
361
+ return SampleResult(
362
+ input_caption=input_caption,
363
+ mode=mode,
364
+ raw_output=raw_output,
365
+ schema_valid=False,
366
+ parse_error=parse.error,
367
+ grounding_rate=0.0,
368
+ hallucinations=[],
369
+ coverage_rate=0.0,
370
+ n_input_tokens=n_input_tokens,
371
+ n_output_tokens=n_output_tokens,
372
+ )
373
+
374
+ g = ground_check(parse.parsed, input_caption)
375
+ c = coverage_check(parse.parsed, input_caption)
376
+ return SampleResult(
377
+ input_caption=input_caption,
378
+ mode=mode,
379
+ raw_output=raw_output,
380
+ schema_valid=True,
381
+ parse_error=None,
382
+ grounding_rate=g.grounding_rate,
383
+ hallucinations=g.hallucinated,
384
+ coverage_rate=c.coverage_rate,
385
+ n_input_tokens=n_input_tokens,
386
+ n_output_tokens=n_output_tokens,
387
+ )
388
+
389
+
390
+ @dataclass
391
+ class RunMetrics:
392
+ mode: str
393
+ n_samples: int
394
+ schema_valid_rate: float
395
+ mean_grounding_rate: float
396
+ mean_coverage_rate: float
397
+ total_hallucinations: int
398
+ samples_with_zero_hallucinations: int
399
+
400
+ def __str__(self) -> str:
401
+ return (
402
+ f"[{self.mode}] n={self.n_samples} "
403
+ f"schema_valid={self.schema_valid_rate:.1%} "
404
+ f"grounding={self.mean_grounding_rate:.1%} "
405
+ f"coverage={self.mean_coverage_rate:.1%} "
406
+ f"clean_samples={self.samples_with_zero_hallucinations}/{self.n_samples} "
407
+ f"halluc_total={self.total_hallucinations}"
408
+ )
409
+
410
+
411
+ def score_run(results: List[SampleResult]) -> RunMetrics:
412
+ if not results:
413
+ return RunMetrics("empty", 0, 0.0, 0.0, 0.0, 0, 0)
414
+ mode = results[0].mode
415
+ n = len(results)
416
+ valid = [r for r in results if r.schema_valid]
417
+ return RunMetrics(
418
+ mode=mode,
419
+ n_samples=n,
420
+ schema_valid_rate=len(valid) / n,
421
+ mean_grounding_rate=sum(r.grounding_rate for r in valid) / len(valid) if valid else 0.0,
422
+ mean_coverage_rate=sum(r.coverage_rate for r in valid) / len(valid) if valid else 0.0,
423
+ total_hallucinations=sum(len(r.hallucinations) for r in results),
424
+ samples_with_zero_hallucinations=sum(
425
+ 1 for r in results if r.schema_valid and not r.hallucinations
426
+ ),
427
+ )
qwen_test_runner/model_runner.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ model_runner.py — Loads a Qwen instruct model once and exposes three generation modes.
3
+
4
+ Modes:
5
+ 1. free — raw chat, no JSON instruction. Establishes a "what does the model
6
+ do unprompted?" floor.
7
+ 2. json_mode — chat with a strong system prompt asking for JSON-only output.
8
+ No decoder-level constraint. Tests in-context schema obedience.
9
+ 3. constrained — uses xgrammar (preferred) or outlines (fallback) to enforce the
10
+ grammar at decode time. Schema validity becomes guaranteed; the
11
+ interesting question is whether faithfulness survives.
12
+
13
+ The model is loaded ONCE in __init__. All three modes share the same weights.
14
+
15
+ Optional dependencies (xgrammar, outlines) degrade gracefully — if neither is installed,
16
+ generate_constrained falls back to json_mode and emits a warning.
17
+ """
18
+
19
+ from __future__ import annotations
20
+ import json
21
+ import warnings
22
+ from dataclasses import dataclass
23
+ from typing import Optional
24
+
25
+ import torch
26
+ from transformers import AutoModelForCausalLM, AutoTokenizer
27
+
28
+ # Optional backends — import lazily and tolerate missing
29
+ try:
30
+ import xgrammar as xgr
31
+ _HAS_XGRAMMAR = True
32
+ except ImportError:
33
+ _HAS_XGRAMMAR = False
34
+
35
+ try:
36
+ import outlines
37
+ _HAS_OUTLINES = True
38
+ except ImportError:
39
+ _HAS_OUTLINES = False
40
+
41
+
42
+ SYSTEM_PROMPT_FREE = (
43
+ "You are a vision-language assistant. Given an image caption, describe what the "
44
+ "image shows."
45
+ )
46
+
47
+ # NOTE: this schema block mirrors SLOT_REGISTRY (registry.py) — the registry is
48
+ # the source of truth. If a slot is added/removed there, update this block too
49
+ # (a stale prompt validates fine because pydantic ignores extras, but the model
50
+ # wastes output budget on fields that get silently dropped — caught 2026-07).
51
+ SYSTEM_PROMPT_JSON = """You are a caption structuring assistant. Given an image caption,
52
+ extract its content into JSON matching this exact schema:
53
+
54
+ {
55
+ "subjects": [{"name": str, "attributes": [str]}],
56
+ "actions": [str],
57
+ "setting": "indoor" | "outdoor" | "unknown",
58
+ "style": str or null,
59
+ "mood": str or null
60
+ }
61
+
62
+ Rules:
63
+ - Only include subjects, attributes, and actions that are EXPLICITLY mentioned in the caption.
64
+ - Never invent details that aren't in the input.
65
+ - If the caption doesn't specify the setting, use "unknown".
66
+ - If no style or mood is evident, use null.
67
+ - Limits (hard): at most 8 subjects and at most 8 actions (attributes per subject are
68
+ unlimited), and every string under 64 characters. If the caption has more, keep only
69
+ the most important ones.
70
+ - Output ONLY the JSON object. No prose, no markdown, no code fences.
71
+ """.strip()
72
+
73
+
74
+ @dataclass
75
+ class GenResult:
76
+ """Output of a single generation call."""
77
+ mode: str # "free" | "json_mode" | "constrained"
78
+ raw_text: str # exactly what the model decoded (after chat template strip)
79
+ backend: str # "transformers" | "xgrammar" | "outlines"
80
+ n_input_tokens: int
81
+ n_output_tokens: int
82
+
83
+
84
+ class QwenRunner:
85
+ """Loads a Qwen instruct model once, runs three generation modes against it."""
86
+
87
+ def __init__(
88
+ self,
89
+ model_id: str = "Qwen/Qwen3.5-0.8B",
90
+ device: Optional[str] = None,
91
+ dtype: torch.dtype = torch.bfloat16,
92
+ trust_remote_code: bool = True,
93
+ enable_thinking: bool = False,
94
+ ):
95
+ """
96
+ Loads a Qwen3.5 post-trained checkpoint.
97
+
98
+ Notes on Qwen3.5-0.8B specifically:
99
+ * It is a vision-language model (image-text-to-text). For text-only use
100
+ (this benchmark), just don't pass image content; the chat template
101
+ handles it. The vision encoder still gets loaded into VRAM (~0.1 GB).
102
+ * model_type=qwen3_5 needs transformers from git main:
103
+ pip install "transformers @ git+https://github.com/huggingface/transformers.git@main"
104
+ * Default is non-thinking mode. Qwen3.5-0.8B is prone to thinking loops,
105
+ so leave enable_thinking=False unless you have a reason.
106
+ """
107
+ self.model_id = model_id
108
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
109
+ self.dtype = dtype
110
+ self.enable_thinking = enable_thinking
111
+
112
+ print(f"[QwenRunner] loading {model_id} on {self.device} ({dtype})")
113
+ self.tokenizer = AutoTokenizer.from_pretrained(
114
+ model_id, trust_remote_code=trust_remote_code
115
+ )
116
+ self.model = AutoModelForCausalLM.from_pretrained(
117
+ model_id,
118
+ torch_dtype=dtype,
119
+ device_map=self.device,
120
+ trust_remote_code=trust_remote_code,
121
+ )
122
+ self.model.eval()
123
+
124
+ # xgrammar compiler is reusable across calls — build once.
125
+ self._xgr_compiled_grammar = None
126
+ self._xgr_tokenizer_info = None
127
+ if _HAS_XGRAMMAR:
128
+ try:
129
+ self._xgr_tokenizer_info = xgr.TokenizerInfo.from_huggingface(self.tokenizer)
130
+ self._xgr_compiler = xgr.GrammarCompiler(self._xgr_tokenizer_info)
131
+ except Exception as e:
132
+ warnings.warn(f"xgrammar tokenizer init failed: {e}; falling back")
133
+ self._xgr_compiler = None
134
+ else:
135
+ self._xgr_compiler = None
136
+
137
+ print(f"[QwenRunner] ready. xgrammar={_HAS_XGRAMMAR}, outlines={_HAS_OUTLINES}")
138
+
139
+ # ── prompt construction ──────────────────────────────────────────────
140
+
141
+ def _build_chat(self, system: str, user: str) -> str:
142
+ """Apply chat template; returns the formatted prompt string.
143
+
144
+ Per the Qwen3.5 card, thinking mode is toggled via the `enable_thinking`
145
+ template variable (the legacy /think /nothink soft switch was removed).
146
+ When calling apply_chat_template directly, pass it as a regular kwarg;
147
+ when calling via OpenAI-compat APIs, nest it under chat_template_kwargs.
148
+ """
149
+ msgs = [
150
+ {"role": "system", "content": system},
151
+ {"role": "user", "content": user},
152
+ ]
153
+ return self.tokenizer.apply_chat_template(
154
+ msgs,
155
+ tokenize=False,
156
+ add_generation_prompt=True,
157
+ enable_thinking=self.enable_thinking,
158
+ )
159
+
160
+ # Recommended sampling for Qwen3.5-0.8B non-thinking text tasks (per model card).
161
+ # Keep top_k since transformers supports it; min_p, presence_penalty likewise.
162
+ RECOMMENDED_SAMPLING_NONTHINKING = dict(
163
+ temperature=1.0, top_p=1.0, top_k=20, min_p=0.0,
164
+ repetition_penalty=1.0, # presence_penalty=2.0 not directly supported in HF generate
165
+ )
166
+ RECOMMENDED_SAMPLING_THINKING = dict(
167
+ temperature=1.0, top_p=0.95, top_k=20, min_p=0.0,
168
+ repetition_penalty=1.0,
169
+ )
170
+
171
+ def _generate_unconstrained(
172
+ self,
173
+ prompt_str: str,
174
+ max_new_tokens: int,
175
+ temperature: float,
176
+ sampling_preset: Optional[str] = None,
177
+ ) -> tuple[str, int, int]:
178
+ """Plain HF generation; returns (decoded, n_in, n_out).
179
+
180
+ sampling_preset:
181
+ None — greedy (or sampled at given temperature), default top_p/top_k
182
+ "recommended" — apply Qwen3.5 paper's recommended params for current mode
183
+ """
184
+ inputs = self.tokenizer(prompt_str, return_tensors="pt").to(self.device)
185
+ n_in = inputs["input_ids"].shape[1]
186
+
187
+ gen_kwargs = dict(
188
+ max_new_tokens=max_new_tokens,
189
+ pad_token_id=self.tokenizer.eos_token_id,
190
+ )
191
+
192
+ if sampling_preset == "recommended":
193
+ preset = (
194
+ self.RECOMMENDED_SAMPLING_THINKING
195
+ if self.enable_thinking else self.RECOMMENDED_SAMPLING_NONTHINKING
196
+ )
197
+ gen_kwargs.update(preset)
198
+ gen_kwargs["do_sample"] = True
199
+ else:
200
+ gen_kwargs["do_sample"] = (temperature > 0)
201
+ gen_kwargs["temperature"] = temperature if temperature > 0 else 1.0
202
+
203
+ with torch.no_grad():
204
+ out = self.model.generate(**inputs, **gen_kwargs)
205
+
206
+ # Strip the prompt to keep only newly generated tokens
207
+ new_tokens = out[0, n_in:]
208
+ n_out = int(new_tokens.shape[0])
209
+ text = self.tokenizer.decode(new_tokens, skip_special_tokens=True)
210
+ return text, n_in, n_out
211
+
212
+ # ── public modes ─────────────────────────────────────────────────────
213
+
214
+ def generate_free(
215
+ self, caption: str, max_new_tokens: int = 256, temperature: float = 0.0,
216
+ sampling_preset: Optional[str] = None,
217
+ ) -> GenResult:
218
+ prompt = self._build_chat(SYSTEM_PROMPT_FREE, caption)
219
+ text, n_in, n_out = self._generate_unconstrained(
220
+ prompt, max_new_tokens, temperature, sampling_preset
221
+ )
222
+ return GenResult("free", text, "transformers", n_in, n_out)
223
+
224
+ def generate_json_mode(
225
+ self, caption: str, max_new_tokens: int = 256, temperature: float = 0.0,
226
+ sampling_preset: Optional[str] = None,
227
+ ) -> GenResult:
228
+ prompt = self._build_chat(SYSTEM_PROMPT_JSON, caption)
229
+ text, n_in, n_out = self._generate_unconstrained(
230
+ prompt, max_new_tokens, temperature, sampling_preset
231
+ )
232
+ return GenResult("json_mode", text, "transformers", n_in, n_out)
233
+
234
+ def generate_constrained(
235
+ self,
236
+ caption: str,
237
+ grammar_gbnf: Optional[str] = None,
238
+ json_schema: Optional[dict] = None,
239
+ max_new_tokens: int = 256,
240
+ temperature: float = 0.0,
241
+ sampling_preset: Optional[str] = None,
242
+ ) -> GenResult:
243
+ """
244
+ Grammar-constrained decoding. Prefers xgrammar (fastest), falls back to outlines,
245
+ then to plain json_mode with a warning.
246
+
247
+ Provide EITHER grammar_gbnf (xgrammar path) OR json_schema (outlines path).
248
+ If both are provided, xgrammar wins when available.
249
+ """
250
+ prompt = self._build_chat(SYSTEM_PROMPT_JSON, caption)
251
+
252
+ # xgrammar path
253
+ if self._xgr_compiler is not None and grammar_gbnf is not None:
254
+ return self._generate_xgrammar(
255
+ prompt, grammar_gbnf, max_new_tokens, temperature, sampling_preset
256
+ )
257
+
258
+ # outlines path — keep as fallback; install instructions in dependencies.txt
259
+ if _HAS_OUTLINES and json_schema is not None:
260
+ warnings.warn("outlines path not yet implemented; falling back to json_mode")
261
+
262
+ # final fallback
263
+ warnings.warn(
264
+ "No constrained-decoding backend active; falling back to json_mode. "
265
+ "Install xgrammar for true grammar-constrained generation."
266
+ )
267
+ text, n_in, n_out = self._generate_unconstrained(
268
+ prompt, max_new_tokens, temperature, sampling_preset
269
+ )
270
+ return GenResult("constrained_fallback", text, "transformers", n_in, n_out)
271
+
272
+ def _generate_xgrammar(
273
+ self, prompt_str: str, grammar_gbnf: str, max_new_tokens: int,
274
+ temperature: float, sampling_preset: Optional[str] = None,
275
+ ) -> GenResult:
276
+ """xgrammar-backed constrained generation.
277
+
278
+ Uses a hand-rolled LogitsProcessor instead of `xgr.contrib.hf.LogitsProcessor`
279
+ because the latter passes a tensor scalar to `matcher.accept_token`, which
280
+ the current xgrammar tvm-ffi binding rejects (it requires a Python int).
281
+ Calling `.item()` on the token id, as every official xgrammar tutorial does,
282
+ sidesteps the bug.
283
+ """
284
+ compiled = self._xgr_compiler.compile_grammar(grammar_gbnf)
285
+
286
+ inputs = self.tokenizer(prompt_str, return_tensors="pt").to(self.device)
287
+ n_in = inputs["input_ids"].shape[1]
288
+
289
+ logits_processor = _XGrammarLogitsProcessor(
290
+ compiled_grammar=compiled,
291
+ vocab_size=self._xgr_tokenizer_info.vocab_size,
292
+ prompt_len=n_in,
293
+ )
294
+
295
+ gen_kwargs = dict(
296
+ max_new_tokens=max_new_tokens,
297
+ pad_token_id=self.tokenizer.eos_token_id,
298
+ logits_processor=[logits_processor],
299
+ )
300
+ if sampling_preset == "recommended":
301
+ preset = (
302
+ self.RECOMMENDED_SAMPLING_THINKING
303
+ if self.enable_thinking else self.RECOMMENDED_SAMPLING_NONTHINKING
304
+ )
305
+ gen_kwargs.update(preset)
306
+ gen_kwargs["do_sample"] = True
307
+ else:
308
+ gen_kwargs["do_sample"] = (temperature > 0)
309
+ gen_kwargs["temperature"] = temperature if temperature > 0 else 1.0
310
+
311
+ with torch.no_grad():
312
+ out = self.model.generate(**inputs, **gen_kwargs)
313
+
314
+ new_tokens = out[0, n_in:]
315
+ n_out = int(new_tokens.shape[0])
316
+ text = self.tokenizer.decode(new_tokens, skip_special_tokens=True)
317
+ return GenResult("constrained", text, "xgrammar", n_in, n_out)
318
+
319
+
320
+ # ──────────────────────────────────────────────────────────────────────────────
321
+ # Custom xgrammar LogitsProcessor.
322
+ #
323
+ # Replaces the broken `xgr.contrib.hf.LogitsProcessor` (it passes a tensor scalar
324
+ # to `accept_token`, which the current tvm-ffi binding rejects with
325
+ # "Expected int but got ffi.Tensor"). We track previously-accepted positions and
326
+ # convert every token to a plain int via `.item()` before passing it to xgrammar.
327
+ # ──────────────────────────────────────────────────────────────────────────────
328
+
329
+ class _XGrammarLogitsProcessor:
330
+ """Constrains HF `generate` output to a compiled xgrammar grammar."""
331
+
332
+ def __init__(self, compiled_grammar, vocab_size: int, prompt_len: int):
333
+ if not _HAS_XGRAMMAR: # pragma: no cover
334
+ raise RuntimeError("xgrammar is not installed")
335
+ self.matcher = xgr.GrammarMatcher(compiled_grammar)
336
+ # bitmask must be int32 CPU per xgrammar docs; we move to logits.device
337
+ # on apply.
338
+ self.bitmask = xgr.allocate_token_bitmask(1, vocab_size)
339
+ self.prompt_len = prompt_len
340
+ self.accepted_up_to = prompt_len # next position to accept from
341
+
342
+ def __call__(self, input_ids, scores):
343
+ # input_ids: (batch=1, cur_len) scores: (batch=1, vocab_size)
344
+ cur_len = int(input_ids.shape[1])
345
+
346
+ # Accept every token generated since we last ran. On the first call
347
+ # cur_len == prompt_len, so this loop is a no-op.
348
+ for pos in range(self.accepted_up_to, cur_len):
349
+ tok = int(input_ids[0, pos].item()) # ← the critical .item() fix
350
+ ok = self.matcher.accept_token(tok)
351
+ if not ok: # pragma: no cover — shouldn't happen with constrained sampling
352
+ break
353
+ self.accepted_up_to = cur_len
354
+
355
+ if self.matcher.is_terminated():
356
+ return scores
357
+
358
+ # Fill bitmask and apply to current-step logits.
359
+ self.matcher.fill_next_token_bitmask(self.bitmask)
360
+ xgr.apply_token_bitmask_inplace(scores, self.bitmask.to(scores.device))
361
+ return scores
qwen_test_runner/providers/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ providers — pluggable backends that produce structured caption JSON.
3
+
4
+ A provider exposes one method:
5
+
6
+ process(caption: str, **kwargs) -> ProviderResult
7
+
8
+ …where ProviderResult mirrors the shape of model_runner.GenResult so the
9
+ evaluator and benchmark scorer can consume both interchangeably.
10
+
11
+ Current providers:
12
+ - QwenRunner (model_runner.py — kept at top-level for back-compat)
13
+ - ClaudeProvider (claude_api.py — Anthropic API with native structured output)
14
+ """
15
+
16
+ from __future__ import annotations
17
+ from dataclasses import dataclass
18
+
19
+
20
+ @dataclass
21
+ class ProviderResult:
22
+ """Backend-agnostic result of one caption-processing call.
23
+
24
+ Shape matches model_runner.GenResult so the scorer doesn't care which
25
+ backend produced the result. `backend` says which backend ran it; `mode`
26
+ says how (constrained vs. free vs. tool-use vs. …).
27
+ """
28
+ mode: str
29
+ raw_text: str # the JSON string the backend produced
30
+ backend: str # "qwen" | "claude" | …
31
+ n_input_tokens: int # non-cached input (matches Anthropic usage.input_tokens)
32
+ n_output_tokens: int # output tokens
33
+ cost_usd: float = 0.0 # for paid APIs; 0 for local models
34
+ n_cache_creation_tokens: int = 0 # tokens written to prompt cache (1.25x rate)
35
+ n_cache_read_tokens: int = 0 # tokens served from prompt cache (0.10x rate)
qwen_test_runner/providers/claude_api.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ claude_api.py — Anthropic Claude as a caption-processing provider.
3
+
4
+ Uses Anthropic's tool-calling API with forced tool choice and CAPTION_JSON_SCHEMA
5
+ as the tool's input_schema. The model is constrained to emit JSON matching the
6
+ schema — equivalent in semantic guarantee to xgrammar-constrained Qwen output,
7
+ but produced by a far more capable model.
8
+
9
+ Primary use cases:
10
+ 1. Teacher labels for SFT — generate gold structured outputs from real
11
+ captions (COCO, LAION, Flickr30k) to fine-tune Qwen3.5-0.8B on.
12
+ 2. Comparison baseline — see what near-perfect schema/faithfulness numbers
13
+ look like on the same eval set Qwen runs against.
14
+
15
+ Requires:
16
+ pip install anthropic
17
+ export ANTHROPIC_API_KEY=sk-ant-...
18
+
19
+ Cost note: at ~250 input tokens + ~200 output tokens per caption, Claude Sonnet
20
+ costs roughly $0.003/sample (~$30 per 10K captions). Cheaper models (Haiku) are
21
+ available; pass `model=...` to swap.
22
+ """
23
+
24
+ from __future__ import annotations
25
+ import json
26
+ import os
27
+ import time
28
+ import warnings
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ from ..registry import SLOT_REGISTRY
33
+ from ..schema import CAPTION_JSON_SCHEMA
34
+ from . import ProviderResult
35
+
36
+ # TaskSpec import is lazy — inside the function — to avoid a hard dependency
37
+ # at module-load time for callers that don't use the task-driven path.
38
+
39
+
40
+ # ──────────────────────────────────────────────────────────────────────────────
41
+ # .env loading — minimal stdlib parser, no python-dotenv dependency.
42
+ #
43
+ # Cowork's VM doesn't inherit the host shell's environment, so a `.env` file
44
+ # inside the mounted repo folder is the most reliable way to get the API
45
+ # key in. Same pattern works for Claude Code and plain CLI use.
46
+ # ──────────────────────────────────────────────────────────────────────────────
47
+
48
+ def _load_dotenv(path: Path) -> int:
49
+ """Parse a .env file and set unset vars in os.environ. Returns count set.
50
+
51
+ Existing env vars are not overwritten (host env wins over .env file).
52
+ Supports lines like: KEY=value / KEY="quoted value" / # comments
53
+ """
54
+ if not path.is_file():
55
+ return 0
56
+ n_set = 0
57
+ for raw in path.read_text().splitlines():
58
+ line = raw.strip()
59
+ if not line or line.startswith("#") or "=" not in line:
60
+ continue
61
+ key, _, val = line.partition("=")
62
+ key = key.strip()
63
+ # Strip surrounding quotes if present
64
+ val = val.strip()
65
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
66
+ val = val[1:-1]
67
+ # Skip "export KEY=..." prefix if user copied a shell-style file
68
+ if key.startswith("export "):
69
+ key = key[len("export "):].strip()
70
+ # Treat empty existing values as unset. Some sandboxes (e.g. Claude
71
+ # Code / Cowork) inject blank ANTHROPIC_API_KEY into child processes
72
+ # to mask the host's real key; .env must remain authoritative there.
73
+ if key and not os.environ.get(key):
74
+ os.environ[key] = val
75
+ n_set += 1
76
+ return n_set
77
+
78
+
79
+ def _autoload_dotenv() -> None:
80
+ """Search common locations for a .env and load the first match.
81
+
82
+ Priority: cwd/.env, then walk up to 3 parent dirs (for cases where the
83
+ agent is invoked from a subdirectory of the repo).
84
+ """
85
+ cwd = Path.cwd().resolve()
86
+ for candidate in [cwd, *list(cwd.parents)[:3]]:
87
+ env_path = candidate / ".env"
88
+ if env_path.is_file():
89
+ _load_dotenv(env_path)
90
+ return
91
+
92
+
93
+ # ──────────────────────────────────────────────────────────────────────────────
94
+ # System prompts — the strict/enhance distinction is encoded here.
95
+ #
96
+ # Both prompts pin the model to the registry-driven schema. The difference is
97
+ # what category fields each prompt licenses the model to populate:
98
+ #
99
+ # strict — descriptive only. style/mood → null. For SFT teacher labels
100
+ # where we want grounded-only outputs to filter on.
101
+ # enhance — all categories. Style/mood may be inferred. For prompt-
102
+ # enhancement training data.
103
+ # ──────────────────────────────────────────────────────────────────────────────
104
+
105
+ # Both prompts are intentionally sized so that (tool def + system) lands
106
+ # safely above Sonnet's 1024-token prompt-caching minimum. The original
107
+ # 165-token versions kept the cacheable prefix at ~1023 tokens — 1 below
108
+ # threshold — which silently disabled caching and cost ~60% more per call.
109
+ # The richer examples also tightened grounding (fewer style/mood leaks on
110
+ # strict, more consistent inference on enhance).
111
+
112
+ PROMPT_STRICT = """You are a caption-structuring assistant. Given an image caption,
113
+ emit JSON matching the provided schema. Your sole job is to extract structured
114
+ information explicitly stated in the input — never embellish, infer, or imagine
115
+ details that aren't there.
116
+
117
+ RULES:
118
+ - `subjects`: every entity named in the caption. Each subject has a `name`
119
+ (a noun phrase from the caption) and optional `attributes` — adjectives or
120
+ descriptors the caption explicitly attaches to that subject (color, age,
121
+ expression, material, count, etc.).
122
+ - `actions`: verb phrases describing what's happening. Prefer the caption's
123
+ own wording when possible.
124
+ - `setting`: use "indoor" or "outdoor" if the caption indicates it (kitchen,
125
+ restaurant → indoor; park, beach → outdoor). Use "unknown" if no cue.
126
+ - `style`: ALWAYS null in strict mode. Do not infer style from content.
127
+ - `mood`: ALWAYS null in strict mode. Do not infer mood from content.
128
+ - Empty lists `[]` and `null` are correct outputs — DO NOT invent content
129
+ just to populate a field.
130
+
131
+ EXAMPLES:
132
+ - "a young girl in a red dress" → subjects: [{name: "girl", attributes: ["young"]},
133
+ {name: "dress", attributes: ["red"]}]; setting: "unknown"
134
+ - "a cat sleeping on a sofa" → subjects: [{name: "cat", attributes: []},
135
+ {name: "sofa", attributes: []}], actions: ["sleeping on a sofa"];
136
+ setting: "indoor"
137
+ - "the beach at sunset" → subjects: [{name: "beach", attributes: []}];
138
+ setting: "outdoor"
139
+
140
+ WHAT TO AVOID:
141
+ - Adding subjects not named in the caption (no inferred "people" or
142
+ "background figures" unless the caption mentions them).
143
+ - Inferring attributes the caption does not state (do not add "fluffy" for
144
+ a cat just because cats are typically fluffy).
145
+ - Setting `style` or `mood` to anything other than null.
146
+
147
+ Call the `emit_caption_schema` tool with your structured output.""".strip()
148
+
149
+
150
+ PROMPT_ENHANCE = """You are a caption-structuring assistant. Given an image caption,
151
+ emit JSON matching the provided schema. Extract grounded content faithfully, and
152
+ where the schema licenses inference, draw it from the caption's content rather
153
+ than imagining unrelated detail.
154
+
155
+ RULES:
156
+ - `subjects`, `actions`, subject `attributes`: ONLY content explicitly named
157
+ in the caption. Use noun phrases from the caption itself; do not invent
158
+ entities or descriptors.
159
+ - `setting`: "indoor" or "outdoor" if the caption indicates or strongly
160
+ implies it (kitchen, restaurant → indoor; park, beach → outdoor);
161
+ otherwise "unknown".
162
+ - `style`: you MAY infer a visual style (e.g. "photorealistic",
163
+ "watercolor", "cyberpunk illustration", "vintage film", "anime") when
164
+ the caption suggests one. Leave null if there's no signal.
165
+ - `mood`: you MAY infer a mood from the caption's content (e.g. "tense",
166
+ "celebratory", "melancholy", "playful", "serene"). Leave null if neutral.
167
+
168
+ EXAMPLES:
169
+ - "an oil painting of a stormy sea" → subjects: [{name: "sea",
170
+ attributes: ["stormy"]}]; setting: "outdoor"; style: "oil painting";
171
+ mood: "tense"
172
+ - "a child laughing at a birthday party" → subjects: [{name: "child",
173
+ attributes: []}], actions: ["laughing"]; setting: "indoor";
174
+ style: null; mood: "celebratory"
175
+ - "a sketch of a hand holding a pencil" → subjects: [{name: "hand",
176
+ attributes: []}, {name: "pencil", attributes: []}],
177
+ actions: ["holding a pencil"]; setting: "unknown"; style: "sketch";
178
+ mood: null
179
+
180
+ Call the `emit_caption_schema` tool with your structured output.""".strip()
181
+
182
+
183
+ # ──────────────────────────────────────────────────────────────────────────────
184
+ # Pricing table (per million tokens). Update when Anthropic publishes new rates.
185
+ # Used only to estimate cost_usd in ProviderResult — not authoritative.
186
+ # ──────────────────────────────────────────────────────────────────────────────
187
+
188
+ _PRICING = {
189
+ # model_id_substring → (input_$/Mtok, output_$/Mtok)
190
+ "claude-opus-4": (15.0, 75.0),
191
+ "claude-sonnet-4": ( 3.0, 15.0),
192
+ "claude-haiku-4": ( 0.80, 4.0),
193
+ # legacy 3.x models, in case someone pins to them
194
+ "claude-3-5-sonnet":(3.0, 15.0),
195
+ "claude-3-5-haiku": (0.80, 4.0),
196
+ }
197
+
198
+
199
+ def _estimate_cost(
200
+ model_id: str,
201
+ n_in: int,
202
+ n_out: int,
203
+ n_cache_create: int = 0,
204
+ n_cache_read: int = 0,
205
+ ) -> float:
206
+ """Cost in USD for a single call. Cache write at 1.25x input, read at 0.10x."""
207
+ rates = next((v for k, v in _PRICING.items() if k in model_id), None)
208
+ if rates is None:
209
+ return 0.0
210
+ in_rate, out_rate = rates
211
+ return (
212
+ (n_in / 1_000_000) * in_rate
213
+ + (n_cache_create / 1_000_000) * (in_rate * 1.25)
214
+ + (n_cache_read / 1_000_000) * (in_rate * 0.10)
215
+ + (n_out / 1_000_000) * out_rate
216
+ )
217
+
218
+
219
+ # ──────────────────────────────────────────────────────────────────────────────
220
+ # Schema slimming — Pydantic's model_json_schema() is verbose by default
221
+ # (per-field "title", "default", "$defs/$ref" for nested types). Anthropic's
222
+ # tool-use enforcer ignores those cosmetic fields, but they cost input tokens
223
+ # on every uncached call. Stripping them cuts the tool definition roughly in
224
+ # half while keeping the constraints (types, enums, maxLength, maxItems, etc).
225
+ # ──────────────────────────────────────────────────────────────────────────────
226
+
227
+ _STRIP_KEYS = {"title", "default", "description"}
228
+
229
+
230
+ def _slim_schema(schema: dict) -> dict:
231
+ """Return a copy of `schema` with cosmetic keys removed and $defs inlined.
232
+
233
+ Drops: title, default, description (the model's tool-use enforcement
234
+ doesn't read them). Inlines local $defs/$ref pairs so nested types like
235
+ SubjectValue appear directly under their parent property. Constraints
236
+ (types, enums, anyOf, maxLength, minLength, maxItems, required) are kept.
237
+ """
238
+ import copy
239
+ schema = copy.deepcopy(schema)
240
+ defs = schema.pop("$defs", {})
241
+
242
+ def resolve(node):
243
+ if isinstance(node, dict):
244
+ if "$ref" in node and node["$ref"].startswith("#/$defs/"):
245
+ name = node["$ref"][len("#/$defs/"):]
246
+ return resolve(defs.get(name, {}))
247
+ return {k: resolve(v) for k, v in node.items() if k not in _STRIP_KEYS}
248
+ if isinstance(node, list):
249
+ return [resolve(x) for x in node]
250
+ return node
251
+
252
+ return resolve(schema)
253
+
254
+
255
+ # ──────────────────────────────────────────────────────────────────────────────
256
+ # Provider
257
+ # ──────────────────────────────────────────────────────────────────────────────
258
+
259
+ class ClaudeProvider:
260
+ """Caption-processing provider backed by Anthropic's Claude API.
261
+
262
+ Loads the anthropic SDK lazily so importing the package doesn't fail
263
+ when anthropic isn't installed and the user just wants the Qwen path.
264
+ """
265
+
266
+ def __init__(
267
+ self,
268
+ model: str = "claude-sonnet-4-6",
269
+ api_key: Optional[str] = None,
270
+ max_retries: int = 3,
271
+ retry_backoff: float = 2.0,
272
+ ):
273
+ try:
274
+ import anthropic
275
+ except ImportError as e:
276
+ raise ImportError(
277
+ "ClaudeProvider requires the `anthropic` package. "
278
+ "Install with: pip install anthropic"
279
+ ) from e
280
+
281
+ # Cowork/Claude Code don't inherit the host shell environment — load
282
+ # a project-local .env if one exists. Has no effect when the key is
283
+ # already in os.environ.
284
+ _autoload_dotenv()
285
+
286
+ api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
287
+ if not api_key:
288
+ raise RuntimeError(
289
+ "No Anthropic API key. Set ANTHROPIC_API_KEY in your shell, "
290
+ "drop a `.env` file with ANTHROPIC_API_KEY=... in the repo "
291
+ "root, or pass api_key= to ClaudeProvider(...)."
292
+ )
293
+
294
+ self.client = anthropic.Anthropic(api_key=api_key)
295
+ self.model = model
296
+ self.max_retries = max_retries
297
+ self.retry_backoff = retry_backoff
298
+
299
+ # The tool definition is the JSON Schema generated by the registry.
300
+ # Forcing tool use guarantees the output is schema-valid.
301
+ #
302
+ # NOTE: we keep the *verbose* pydantic schema rather than passing
303
+ # _slim_schema(CAPTION_JSON_SCHEMA) here. The slim form saves ~110
304
+ # tokens per call, but it also pushes the (tools + system) cacheable
305
+ # prefix below Sonnet's 1024-token minimum, which silently disables
306
+ # prompt caching — losing ~60% in subsequent-call savings. The
307
+ # _slim_schema helper stays available for cases where caching is
308
+ # off (e.g. one-shot calls) or for models with a smaller minimum.
309
+ self._tool = {
310
+ "name": "emit_caption_schema",
311
+ "description": (
312
+ "Emit the structured caption representation. The input_schema "
313
+ "follows the qwen-test-runner slot registry."
314
+ ),
315
+ "input_schema": CAPTION_JSON_SCHEMA,
316
+ }
317
+
318
+ def process(
319
+ self,
320
+ caption: str,
321
+ prompt: str = "strict",
322
+ max_tokens: int = 1024,
323
+ task=None, # Optional[TaskSpec] — takes precedence over prompt= when set
324
+ ) -> ProviderResult:
325
+ """Convert one caption to schema-conformant JSON.
326
+
327
+ Two modes (use one):
328
+
329
+ task=<TaskSpec> — task-driven: system prompt + tool schema come
330
+ from the TaskSpec. mode_tag = "claude_<task.name>".
331
+ Preferred for the per-task SFT pipeline.
332
+
333
+ prompt="strict"|"enhance"|"<custom>" — legacy path. Uses the module-level
334
+ PROMPT_STRICT/PROMPT_ENHANCE constants and the
335
+ universal CAPTION_JSON_SCHEMA as the tool's
336
+ input_schema. Kept for back-compat with data_gen.py.
337
+ """
338
+ if task is not None:
339
+ # Task-driven path. We build a tool definition locally rather than
340
+ # reuse self._tool so the per-task schema overlay takes effect.
341
+ tool = {
342
+ "name": "emit_caption_schema",
343
+ "description": self._tool["description"],
344
+ "input_schema": task.tool_schema,
345
+ }
346
+ sys_prompt = task.system_prompt
347
+ mode_tag = f"claude_{task.name}"
348
+ else:
349
+ tool = self._tool
350
+ if prompt == "strict":
351
+ sys_prompt = PROMPT_STRICT
352
+ mode_tag = "claude_strict"
353
+ elif prompt == "enhance":
354
+ sys_prompt = PROMPT_ENHANCE
355
+ mode_tag = "claude_enhance"
356
+ else:
357
+ sys_prompt = prompt
358
+ mode_tag = "claude_custom"
359
+
360
+ response = self._call_with_retry(
361
+ system=sys_prompt,
362
+ user=caption,
363
+ max_tokens=max_tokens,
364
+ tool=tool,
365
+ )
366
+
367
+ # Find the tool_use block. Forced tool_choice means there's always
368
+ # exactly one — but we extract by type, not position, for safety.
369
+ tool_input = None
370
+ for block in response.content:
371
+ if block.type == "tool_use" and block.name == "emit_caption_schema":
372
+ tool_input = block.input
373
+ break
374
+ if tool_input is None:
375
+ raise RuntimeError(
376
+ f"Claude returned no tool_use block. Stop reason: {response.stop_reason!r}"
377
+ )
378
+
379
+ raw_json = json.dumps(tool_input, separators=(",", ":"))
380
+
381
+ usage = response.usage
382
+ n_in = usage.input_tokens
383
+ n_out = usage.output_tokens
384
+ n_cache_create = getattr(usage, "cache_creation_input_tokens", 0) or 0
385
+ n_cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
386
+ cost = _estimate_cost(self.model, n_in, n_out, n_cache_create, n_cache_read)
387
+
388
+ return ProviderResult(
389
+ mode=mode_tag,
390
+ raw_text=raw_json,
391
+ backend="claude",
392
+ n_input_tokens=n_in,
393
+ n_output_tokens=n_out,
394
+ cost_usd=cost,
395
+ n_cache_creation_tokens=n_cache_create,
396
+ n_cache_read_tokens=n_cache_read,
397
+ )
398
+
399
+ def _call_with_retry(self, system: str, user: str, max_tokens: int, tool=None):
400
+ """Anthropic call with exponential backoff on rate-limit / transient errors.
401
+
402
+ tool: optional override for the tool definition. Defaults to self._tool
403
+ (the universal schema). Task-driven callers pass their own.
404
+ """
405
+ import anthropic # already imported in __init__, just re-bind name
406
+
407
+ tool = tool if tool is not None else self._tool
408
+ last_err: Optional[Exception] = None
409
+ for attempt in range(self.max_retries):
410
+ try:
411
+ # `cache_control` on the last system block marks the cache
412
+ # breakpoint. Everything before/including it (tools + system)
413
+ # is cached; the user message remains variable per-call.
414
+ # Sonnet's minimum cacheable prefix is 1024 tokens — our
415
+ # tool def + system prompt sits just above that.
416
+ return self.client.messages.create(
417
+ model=self.model,
418
+ max_tokens=max_tokens,
419
+ system=[{
420
+ "type": "text",
421
+ "text": system,
422
+ "cache_control": {"type": "ephemeral"},
423
+ }],
424
+ tools=[tool],
425
+ tool_choice={"type": "tool", "name": "emit_caption_schema"},
426
+ messages=[{"role": "user", "content": f"Caption: {user}"}],
427
+ )
428
+ except (anthropic.RateLimitError, anthropic.APIStatusError) as e:
429
+ last_err = e
430
+ sleep_s = self.retry_backoff ** attempt
431
+ warnings.warn(
432
+ f"Claude API error (attempt {attempt + 1}/{self.max_retries}): "
433
+ f"{type(e).__name__}: {e}. Sleeping {sleep_s:.1f}s."
434
+ )
435
+ time.sleep(sleep_s)
436
+ # All retries exhausted
437
+ raise RuntimeError(f"Claude API failed after {self.max_retries} retries") from last_err
qwen_test_runner/py.typed ADDED
File without changes
qwen_test_runner/registry.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ registry.py — The slot registry.
3
+
4
+ This is the source of truth for the caption schema. Every slot the system
5
+ knows about lives here as a SlotSpec entry. The Pydantic Caption model, the
6
+ JSON Schema export, the GBNF grammar, and the evaluator's grounding rules
7
+ are all derived from this registry at import time.
8
+
9
+ Adding a slot is one dict entry. Adding a category is one Literal expansion.
10
+ No code outside this file should hardcode slot names or category logic.
11
+
12
+ Slot taxonomy (the three categories that came out of the baseline analysis):
13
+ - descriptive : grounded in the input caption. Hallucination forbidden.
14
+ Examples: subjects, actions, setting.
15
+ - aesthetic : how the scene should look. Often empty in input;
16
+ legitimate inference (or null) in enhancement mode.
17
+ Examples: style, lighting, palette.
18
+ - semantic : interpretive meaning. Inferential by definition.
19
+ Examples: mood, implication, narrative_function.
20
+
21
+ Groundedness rules (drive the evaluator):
22
+ - must_ground : every leaf MUST trace to the input caption.
23
+ - may_infer : leaf may be grounded OR inferred; both are acceptable.
24
+ - derived_only : leaf is expected to be inferred. Grounding check skipped.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from typing import Literal, Optional, Type
31
+
32
+ from pydantic import BaseModel, Field
33
+
34
+
35
+ # ──────────────────────────────────────────────────────────────────────────────
36
+ # Slot-level enums. Adding a value here is a registry-only change; no
37
+ # code outside this file matches on these strings directly (the helpers below
38
+ # encapsulate all behavior).
39
+ # ──────────────────────────────────────────────────────────────────────────────
40
+
41
+ Category = Literal["descriptive", "aesthetic", "semantic"]
42
+ Cardinality = Literal["single", "list"]
43
+ Vocabulary = Literal["closed", "open"]
44
+ Groundedness = Literal["must_ground", "may_infer", "derived_only"]
45
+
46
+ # value_kind selects the leaf's primitive type for code generation. "string" is
47
+ # the caption default (every existing slot). The numeric kinds exist so the SAME
48
+ # registry→Pydantic→JSON-Schema→GBNF machinery can describe vision outputs
49
+ # (bounding boxes, confidences, depths) without a second codegen path.
50
+ # string → str
51
+ # number → float (optionally bounded via number_range)
52
+ # integer → int
53
+ # bbox → list[float] of length 4 (x1,y1,x2,y2 or x,y,w,h — see coords.py)
54
+ # point → list[float] of length 2
55
+ ValueKind = Literal["string", "number", "integer", "bbox", "point"]
56
+
57
+
58
+ # ──────────────────────────────────────────────────────────────────────────────
59
+ # Nested value models. Used by slots whose value is structured (e.g. subjects
60
+ # have a name and a list of attributes). New nested types go here and are
61
+ # referenced from the SlotSpec via `nested_model=`.
62
+ # ──────────────────────────────────────────────────────────────────────────────
63
+
64
+ class SubjectValue(BaseModel):
65
+ """A single entity in the caption."""
66
+ name: str = Field(..., min_length=1, max_length=64)
67
+ # No max_length on attributes: rich captions (JoyCaption prose, booru tag
68
+ # strings) legitimately carry >8 per subject, and the cap was rejecting 44%
69
+ # of otherwise-valid structs in the 100-row bench (2026-07).
70
+ attributes: list[str] = Field(default_factory=list)
71
+
72
+
73
+ # ──────────────────────────────────────────────────────────────────────────────
74
+ # SlotSpec — the unit of the registry.
75
+ # ──────────────────────────────────────────────────────────────────────────────
76
+
77
+ @dataclass(frozen=True)
78
+ class SlotSpec:
79
+ """Declarative description of one schema slot.
80
+
81
+ The structural axes (cardinality, vocabulary, value_kind, nested_fields,
82
+ nested_model) are what drive code generation in schema.py — these apply
83
+ equally to caption slots and to vision-task fields. The caption-only axes
84
+ (category, groundedness) drive the text evaluator and default to neutral
85
+ values so vision per-category registries can omit them.
86
+
87
+ cardinality — single value vs list
88
+ vocabulary — open (any string) vs closed (one of `closed_values`)
89
+ value_kind — leaf primitive type (string / number / integer / bbox / point)
90
+ nested_model — BaseModel subclass for a structured value (caption: SubjectValue)
91
+ nested_fields — declarative nested-object fields; the generalized form of
92
+ nested_model — schema.py builds both the Pydantic model and
93
+ the GBNF object rule recursively from these
94
+ category — taxonomy bucket (caption prompts); ignored by vision
95
+ groundedness — strict / soft / never (text evaluator); ignored by vision
96
+ optional — may the model emit null/[] when empty
97
+ number_range — (min, max) bound for numeric value_kinds (Pydantic ge/le)
98
+ """
99
+ name: str
100
+ cardinality: Cardinality
101
+ vocabulary: Vocabulary
102
+ category: Category = "descriptive"
103
+ groundedness: Groundedness = "may_infer"
104
+ value_kind: ValueKind = "string"
105
+ closed_values: tuple[str, ...] = ()
106
+ nested_model: Optional[Type[BaseModel]] = None
107
+ nested_fields: tuple["SlotSpec", ...] = ()
108
+ optional: bool = True
109
+ max_items: int = 8 # only for cardinality == "list"
110
+ max_str_length: int = 64 # for open-vocab strings
111
+ number_range: Optional[tuple[float, float]] = None
112
+
113
+ def __post_init__(self):
114
+ # Lightweight validation — catch registry mistakes at import time
115
+ if self.vocabulary == "closed" and not self.closed_values:
116
+ raise ValueError(f"slot {self.name!r}: closed vocab requires closed_values")
117
+ if self.vocabulary == "open" and self.closed_values:
118
+ raise ValueError(f"slot {self.name!r}: open vocab cannot have closed_values")
119
+ if self.nested_model is not None and self.vocabulary == "closed":
120
+ raise ValueError(f"slot {self.name!r}: nested_model is incompatible with closed vocab")
121
+ if self.nested_fields and self.nested_model is not None:
122
+ raise ValueError(f"slot {self.name!r}: nested_fields and nested_model are mutually exclusive")
123
+ if self.nested_fields and self.vocabulary == "closed":
124
+ raise ValueError(f"slot {self.name!r}: nested_fields is incompatible with closed vocab")
125
+
126
+
127
+ # ──────────────────────────────────────────────────────────────────────────────
128
+ # THE REGISTRY.
129
+ #
130
+ # Starter set: 5 slots that exercise all three categories and both
131
+ # groundedness extremes. Adding a slot is a single entry below.
132
+ # ──────────────────────────────────────────────────────────────────────────────
133
+
134
+ SLOT_REGISTRY: dict[str, SlotSpec] = {
135
+ "subjects": SlotSpec(
136
+ name="subjects",
137
+ category="descriptive",
138
+ cardinality="list",
139
+ vocabulary="open",
140
+ groundedness="must_ground",
141
+ nested_model=SubjectValue,
142
+ max_items=8,
143
+ ),
144
+ "actions": SlotSpec(
145
+ name="actions",
146
+ category="descriptive",
147
+ cardinality="list",
148
+ vocabulary="open",
149
+ groundedness="must_ground",
150
+ max_items=8,
151
+ ),
152
+ "setting": SlotSpec(
153
+ name="setting",
154
+ category="descriptive",
155
+ cardinality="single",
156
+ vocabulary="closed",
157
+ # `may_infer` because Qwen reliably guesses indoor/outdoor from cues
158
+ # even when the caption doesn't say. The grammar pins the value to
159
+ # the enum anyway.
160
+ groundedness="may_infer",
161
+ closed_values=("indoor", "outdoor", "unknown"),
162
+ optional=False, # always required; the enum includes "unknown" as escape
163
+ ),
164
+ "style": SlotSpec(
165
+ name="style",
166
+ category="aesthetic",
167
+ cardinality="single",
168
+ vocabulary="open",
169
+ groundedness="may_infer",
170
+ ),
171
+ "mood": SlotSpec(
172
+ name="mood",
173
+ category="semantic",
174
+ cardinality="single",
175
+ vocabulary="open",
176
+ # Baseline finding: mood is 73% of all hallucinations under the old
177
+ # rule. Reclassifying it as derived_only stops penalizing the model
178
+ # for inferring; it's correct behavior now, not error.
179
+ groundedness="derived_only",
180
+ ),
181
+ }
182
+
183
+
184
+ # ──────────────────────────────────────────────────────────────────────────────
185
+ # Query helpers. Use these instead of poking SLOT_REGISTRY directly so behavior
186
+ # stays centralized.
187
+ # ──────────────────────────────────────────────────────────────────────────────
188
+
189
+ def slots_by_category(category: Category) -> list[SlotSpec]:
190
+ return [s for s in SLOT_REGISTRY.values() if s.category == category]
191
+
192
+
193
+ def slot_names() -> list[str]:
194
+ """Slot names in registry-declaration order. JSON output uses this order."""
195
+ return list(SLOT_REGISTRY.keys())
196
+
197
+
198
+ def get_slot(name: str) -> SlotSpec:
199
+ if name not in SLOT_REGISTRY:
200
+ raise KeyError(f"unknown slot: {name!r}")
201
+ return SLOT_REGISTRY[name]
202
+
203
+
204
+ # Set of closed-vocab values across all slots — used by the evaluator as the
205
+ # "always grounded" allowlist for the `may_infer` closed-vocab case.
206
+ def all_closed_vocab() -> set[str]:
207
+ out: set[str] = set()
208
+ for s in SLOT_REGISTRY.values():
209
+ out.update(s.closed_values)
210
+ return out
qwen_test_runner/run_benchmark.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run_benchmark.py — End-to-end testbed entrypoint.
3
+
4
+ Usage (after `pip install -e .`):
5
+ qwen-bench # all modes, builtin set
6
+ qwen-bench --modes free json_mode # subset of modes
7
+ qwen-bench --model Qwen/Qwen3.5-0.8B
8
+ qwen-bench --eval-set my_captions.txt
9
+ qwen-bench --max-samples 5 # smoke test
10
+
11
+ Equivalent module invocation:
12
+ python -m qwen_test_runner.run_benchmark --max-samples 5
13
+
14
+ Outputs to runs/{timestamp}/:
15
+ - config.json : exact arguments + environment
16
+ - results.jsonl : one row per (sample, mode) pair
17
+ - summary.json : aggregated RunMetrics per mode
18
+ - report.md : human-readable summary with hallucination examples
19
+ """
20
+
21
+ from __future__ import annotations
22
+ import argparse
23
+ import json
24
+ import sys
25
+ import time
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import List
29
+
30
+ from .schema import CAPTION_GRAMMAR_GBNF, CAPTION_JSON_SCHEMA
31
+ from .eval_set import load_eval_set
32
+ from .evaluator import score_sample, score_run, SampleResult, RunMetrics
33
+
34
+
35
+ def make_run_dir(root: Path) -> Path:
36
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
37
+ run_dir = root / ts
38
+ run_dir.mkdir(parents=True, exist_ok=True)
39
+ return run_dir
40
+
41
+
42
+ def run_mode(
43
+ runner,
44
+ mode: str,
45
+ captions: List[str],
46
+ max_new_tokens: int,
47
+ temperature: float,
48
+ sampling_preset: str | None = None,
49
+ ) -> List[SampleResult]:
50
+ """Run all captions through one mode. Returns per-sample results."""
51
+ results: List[SampleResult] = []
52
+ for i, cap in enumerate(captions):
53
+ t0 = time.time()
54
+ if mode == "free":
55
+ r = runner.generate_free(
56
+ cap, max_new_tokens=max_new_tokens, temperature=temperature,
57
+ sampling_preset=sampling_preset,
58
+ )
59
+ elif mode == "json_mode":
60
+ r = runner.generate_json_mode(
61
+ cap, max_new_tokens=max_new_tokens, temperature=temperature,
62
+ sampling_preset=sampling_preset,
63
+ )
64
+ elif mode == "constrained":
65
+ r = runner.generate_constrained(
66
+ cap,
67
+ grammar_gbnf=CAPTION_GRAMMAR_GBNF,
68
+ json_schema=CAPTION_JSON_SCHEMA,
69
+ max_new_tokens=max_new_tokens,
70
+ temperature=temperature,
71
+ sampling_preset=sampling_preset,
72
+ )
73
+ else:
74
+ raise ValueError(f"unknown mode: {mode}")
75
+ dt = time.time() - t0
76
+
77
+ scored = score_sample(
78
+ input_caption=cap,
79
+ raw_output=r.raw_text,
80
+ mode=mode,
81
+ n_input_tokens=r.n_input_tokens,
82
+ n_output_tokens=r.n_output_tokens,
83
+ )
84
+ results.append(scored)
85
+ print(
86
+ f" [{mode}] {i + 1:3d}/{len(captions)} "
87
+ f"valid={scored.schema_valid} "
88
+ f"ground={scored.grounding_rate:.0%} "
89
+ f"halluc={len(scored.hallucinations)} "
90
+ f"{dt:.1f}s "
91
+ f"→ {cap[:50]}{'…' if len(cap) > 50 else ''}"
92
+ )
93
+ return results
94
+
95
+
96
+ def write_report(run_dir: Path, all_results: dict[str, List[SampleResult]],
97
+ metrics: dict[str, RunMetrics]) -> None:
98
+ """Human-readable markdown summary."""
99
+ lines = ["# Qwen Caption Schema Benchmark", ""]
100
+ lines.append(f"_Generated: {datetime.now().isoformat(timespec='seconds')}_")
101
+ lines.append("")
102
+ lines.append("## Headline metrics")
103
+ lines.append("")
104
+ lines.append("| Mode | Schema valid | Grounding | Coverage | Clean samples | Total halluc |")
105
+ lines.append("|------|--------------|-----------|----------|---------------|--------------|")
106
+ for mode, m in metrics.items():
107
+ lines.append(
108
+ f"| {mode} | {m.schema_valid_rate:.1%} | {m.mean_grounding_rate:.1%} | "
109
+ f"{m.mean_coverage_rate:.1%} | {m.samples_with_zero_hallucinations}/{m.n_samples} | "
110
+ f"{m.total_hallucinations} |"
111
+ )
112
+ lines.append("")
113
+
114
+ # Hallucination examples per mode
115
+ for mode, rs in all_results.items():
116
+ offenders = [r for r in rs if r.hallucinations]
117
+ if not offenders:
118
+ continue
119
+ lines.append(f"## Hallucination examples — `{mode}` ({len(offenders)} samples)")
120
+ lines.append("")
121
+ for r in offenders[:6]:
122
+ lines.append(f"**Input:** {r.input_caption}")
123
+ for path, val in r.hallucinations:
124
+ lines.append(f"- `{path}` = `{val}`")
125
+ lines.append("")
126
+
127
+ # Parse failures
128
+ for mode, rs in all_results.items():
129
+ broken = [r for r in rs if not r.schema_valid]
130
+ if not broken:
131
+ continue
132
+ lines.append(f"## Schema parse failures — `{mode}` ({len(broken)} samples)")
133
+ lines.append("")
134
+ for r in broken[:4]:
135
+ lines.append(f"**Input:** {r.input_caption}")
136
+ lines.append(f"- Error: `{r.parse_error}`")
137
+ lines.append(f"- Raw output (first 200 chars):")
138
+ lines.append(f" ```")
139
+ lines.append(f" {r.raw_output[:200]}")
140
+ lines.append(f" ```")
141
+ lines.append("")
142
+
143
+ (run_dir / "report.md").write_text("\n".join(lines))
144
+
145
+
146
+ def main(argv: list[str] | None = None) -> int:
147
+ p = argparse.ArgumentParser(description="Qwen caption schema benchmark")
148
+ p.add_argument("--model", default="Qwen/Qwen3.5-0.8B",
149
+ help="HF model id. Qwen3.5-0.8B is a VLM but works text-only here.")
150
+ p.add_argument("--modes", nargs="+", default=["free", "json_mode", "constrained"],
151
+ choices=["free", "json_mode", "constrained"])
152
+ p.add_argument("--eval-set", default="builtin")
153
+ p.add_argument("--max-samples", type=int, default=None,
154
+ help="limit eval set size (for smoke tests)")
155
+ p.add_argument("--max-new-tokens", type=int, default=256)
156
+ p.add_argument("--temperature", type=float, default=0.0,
157
+ help="Used only when --sampling=manual. 0.0 = greedy.")
158
+ p.add_argument("--sampling", choices=["manual", "recommended"], default="manual",
159
+ help="'manual' uses --temperature (good for reproducibility). "
160
+ "'recommended' uses Qwen3.5 paper's recommended params.")
161
+ p.add_argument("--enable-thinking", action="store_true",
162
+ help="Turn on Qwen3.5 thinking mode. NOTE: 0.8B is prone to "
163
+ "thinking loops; benchmark may be slow or hang.")
164
+ p.add_argument("--output-root", default="runs")
165
+ p.add_argument("--device", default=None)
166
+ args = p.parse_args(argv)
167
+
168
+ # Import the model runner lazily so smoke-testing other modules doesn't drag in torch
169
+ from .model_runner import QwenRunner
170
+
171
+ captions = load_eval_set(args.eval_set)
172
+ if args.max_samples is not None:
173
+ captions = captions[:args.max_samples]
174
+ print(f"Loaded {len(captions)} captions from {args.eval_set}")
175
+
176
+ run_dir = make_run_dir(Path(args.output_root))
177
+ print(f"Run dir: {run_dir}")
178
+
179
+ # Save the exact config
180
+ (run_dir / "config.json").write_text(json.dumps(vars(args), indent=2, default=str))
181
+
182
+ runner = QwenRunner(
183
+ model_id=args.model,
184
+ device=args.device,
185
+ enable_thinking=args.enable_thinking,
186
+ )
187
+
188
+ sampling_preset = "recommended" if args.sampling == "recommended" else None
189
+
190
+ all_results: dict[str, List[SampleResult]] = {}
191
+ metrics: dict[str, RunMetrics] = {}
192
+
193
+ for mode in args.modes:
194
+ print(f"\n=== mode: {mode} ===")
195
+ rs = run_mode(
196
+ runner, mode, captions,
197
+ max_new_tokens=args.max_new_tokens,
198
+ temperature=args.temperature,
199
+ sampling_preset=sampling_preset,
200
+ )
201
+ all_results[mode] = rs
202
+ metrics[mode] = score_run(rs)
203
+ print(f" → {metrics[mode]}")
204
+
205
+ # Persist
206
+ with (run_dir / "results.jsonl").open("w") as fh:
207
+ for mode, rs in all_results.items():
208
+ for r in rs:
209
+ fh.write(json.dumps(r.to_dict()) + "\n")
210
+ (run_dir / "summary.json").write_text(json.dumps(
211
+ {mode: vars(m) for mode, m in metrics.items()}, indent=2
212
+ ))
213
+ write_report(run_dir, all_results, metrics)
214
+
215
+ print("\n=== Summary ===")
216
+ for m in metrics.values():
217
+ print(f" {m}")
218
+ print(f"\nReport written to {run_dir / 'report.md'}")
219
+ return 0
220
+
221
+
222
+ if __name__ == "__main__":
223
+ sys.exit(main())
qwen_test_runner/schema.py ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ schema.py — Registry-parametric schema code generation.
3
+
4
+ Generates three representations of a *registry* (a `dict[str, SlotSpec]`):
5
+
6
+ build_model_from_registry(name, registry) → Pydantic model class
7
+ build_json_schema(model) → JSON Schema dict
8
+ build_gbnf_from_registry(registry) → GBNF grammar string
9
+
10
+ The caption schema is the canonical instance, exposed under stable names:
11
+
12
+ Caption — Pydantic model (validation / parsing)
13
+ CAPTION_JSON_SCHEMA — JSON Schema dict (Anthropic API, outlines, etc.)
14
+ CAPTION_GRAMMAR_GBNF — GBNF grammar string (xgrammar)
15
+
16
+ The vision subpackage reuses the SAME generators per task category (each
17
+ category owns a small `dict[str, SlotSpec]`), so numbers, bounding boxes, and
18
+ nested objects are described with the same machinery — see
19
+ `qwen_test_runner/vision/`.
20
+
21
+ All caption artifacts are generated at import time from `registry.SLOT_REGISTRY`.
22
+ To add or modify a caption slot, edit `registry.py` only — this file stays
23
+ untouched.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any, Literal, Mapping, Optional
29
+
30
+ from pydantic import BaseModel, Field, create_model
31
+
32
+ from .registry import SLOT_REGISTRY, SlotSpec, SubjectValue
33
+
34
+
35
+ # ──────────────────────────────────────────────────────────────────────────────
36
+ # Pydantic model — built dynamically from any registry.
37
+ # ──────────────────────────────────────────────────────────────────────────────
38
+
39
+ _OBJECT_MODEL_CACHE: dict[SlotSpec, type[BaseModel]] = {}
40
+
41
+
42
+ def _object_model_name(spec: SlotSpec) -> str:
43
+ return "".join(part.capitalize() for part in spec.name.split("_")) + "Obj"
44
+
45
+
46
+ def _build_object_model(spec: SlotSpec) -> type[BaseModel]:
47
+ """Build (and cache) a Pydantic model from a spec's `nested_fields`."""
48
+ cached = _OBJECT_MODEL_CACHE.get(spec)
49
+ if cached is not None:
50
+ return cached
51
+ fields: dict[str, Any] = {}
52
+ for f in spec.nested_fields:
53
+ fields[f.name] = (_python_type_for_slot(f), _field_for_slot(f))
54
+ model = create_model(_object_model_name(spec), **fields)
55
+ _OBJECT_MODEL_CACHE[spec] = model
56
+ return model
57
+
58
+
59
+ def _item_type_for_slot(spec: SlotSpec) -> Any:
60
+ """Python type of a single value of this slot (before list/Optional wrapping)."""
61
+ if spec.nested_fields:
62
+ return _build_object_model(spec)
63
+ if spec.nested_model is not None:
64
+ return spec.nested_model
65
+ if spec.vocabulary == "closed":
66
+ # Literal[("a", "b", "c")] parses identically to Literal["a", "b", "c"].
67
+ return Literal[spec.closed_values]
68
+ vk = spec.value_kind
69
+ if vk == "number":
70
+ return float
71
+ if vk == "integer":
72
+ return int
73
+ if vk in ("bbox", "point"):
74
+ return list[float]
75
+ return str
76
+
77
+
78
+ def _python_type_for_slot(spec: SlotSpec) -> Any:
79
+ """Compute the Python type annotation for a slot's value.
80
+
81
+ List cardinality wraps the item type in list[...].
82
+ Single + optional wraps in Optional[...].
83
+ """
84
+ item_type = _item_type_for_slot(spec)
85
+ if spec.cardinality == "list":
86
+ return list[item_type]
87
+ if spec.optional:
88
+ return Optional[item_type]
89
+ return item_type
90
+
91
+
92
+ def _default_for_slot(spec: SlotSpec) -> Any:
93
+ if spec.cardinality == "list":
94
+ return [] # default_factory handled by Field below
95
+ if spec.optional:
96
+ return None
97
+ # Required single value with no default. For closed vocab, default to
98
+ # the last value (usually "unknown") so partial outputs don't blow up.
99
+ if spec.vocabulary == "closed":
100
+ return spec.closed_values[-1]
101
+ return ... # required, no default
102
+
103
+
104
+ def _field_for_slot(spec: SlotSpec):
105
+ """Construct a Pydantic Field with the right constraints for this slot."""
106
+ kwargs: dict[str, Any] = {}
107
+
108
+ if spec.cardinality == "list":
109
+ kwargs["default_factory"] = list
110
+ kwargs["max_length"] = spec.max_items
111
+ return Field(**kwargs)
112
+
113
+ default = _default_for_slot(spec)
114
+
115
+ # Fixed-length numeric arrays (bbox/point): exactly 4 / 2 elements.
116
+ if spec.value_kind in ("bbox", "point"):
117
+ n = 4 if spec.value_kind == "bbox" else 2
118
+ if default is ...:
119
+ return Field(..., min_length=n, max_length=n)
120
+ return Field(default=default, min_length=n, max_length=n)
121
+
122
+ # Scalar numerics, optionally bounded.
123
+ if spec.value_kind in ("number", "integer"):
124
+ rng: dict[str, Any] = {}
125
+ if spec.number_range is not None:
126
+ rng["ge"] = spec.number_range[0]
127
+ rng["le"] = spec.number_range[1]
128
+ if default is ...:
129
+ return Field(..., **rng)
130
+ return Field(default=default, **rng)
131
+
132
+ # Strings / enums / nested objects.
133
+ if default is ...:
134
+ # Only plain open strings get a length cap; nested models / enums don't.
135
+ if spec.vocabulary == "open" and spec.nested_model is None and not spec.nested_fields:
136
+ return Field(..., max_length=spec.max_str_length)
137
+ return Field(...)
138
+ kwargs["default"] = default
139
+ if (
140
+ spec.vocabulary == "open"
141
+ and spec.nested_model is None
142
+ and not spec.nested_fields
143
+ and spec.value_kind == "string"
144
+ ):
145
+ kwargs["max_length"] = spec.max_str_length
146
+ return Field(**kwargs)
147
+
148
+
149
+ def build_model_from_registry(model_name: str, registry: Mapping[str, SlotSpec]) -> type[BaseModel]:
150
+ """Build a Pydantic model with one field per registry entry."""
151
+ fields: dict[str, Any] = {}
152
+ for name, spec in registry.items():
153
+ fields[name] = (_python_type_for_slot(spec), _field_for_slot(spec))
154
+ return create_model(model_name, **fields)
155
+
156
+
157
+ def build_json_schema(model: type[BaseModel]) -> dict:
158
+ """JSON Schema for a generated model (thin wrapper for symmetry)."""
159
+ return model.model_json_schema()
160
+
161
+
162
+ Caption = build_model_from_registry("Caption", SLOT_REGISTRY)
163
+
164
+ # Re-export SubjectValue under the old name "Subject" for callers that
165
+ # imported it from schema previously.
166
+ Subject = SubjectValue
167
+
168
+
169
+ # ──────────────────────────────────────────────────────────────────────────────
170
+ # JSON Schema — derived from the Pydantic model.
171
+ # ──────────────────────────────────────────────────────────────────────────────
172
+
173
+ CAPTION_JSON_SCHEMA: dict = build_json_schema(Caption)
174
+
175
+
176
+ # ──────────────────────────────────────────────────────────────────────────────
177
+ # GBNF grammar — built from the registry. Independent of pydantic.
178
+ #
179
+ # xgrammar's auto-converter from JSON schema sometimes adds unwanted slack
180
+ # (e.g. permissive whitespace patterns that hurt parse rates). Generating GBNF
181
+ # by hand from the registry gives tighter control and stays consistent with
182
+ # the Pydantic model.
183
+ #
184
+ # The four base primitives (str_array, string, char, ws) are always emitted, as
185
+ # in v0.2. Numeric primitives (number, bbox4, …) are emitted ONLY when a slot
186
+ # references them, so the caption grammar is byte-for-byte the v0.2 grammar.
187
+ # ──────────────────────────────────────────────────────────────────────────────
188
+
189
+ # Primitive rule definitions, emitted on demand.
190
+ _PRIMITIVE_DEFS: dict[str, str] = {
191
+ "uint": 'uint ::= "0" | [1-9] [0-9]*',
192
+ "frac": 'frac ::= "." [0-9]+',
193
+ "exp": 'exp ::= ("e" | "E") ("+" | "-")? [0-9]+',
194
+ "integer": 'integer ::= "-"? uint',
195
+ "number": 'number ::= "-"? uint frac? exp?',
196
+ "num_array": 'num_array ::= "[" ws "]" | "[" ws number (ws "," ws number)* ws "]"',
197
+ "bbox4": 'bbox4 ::= "[" ws number ws "," ws number ws "," ws number ws "," ws number ws "]"',
198
+ "point2": 'point2 ::= "[" ws number ws "," ws number ws "]"',
199
+ }
200
+
201
+ # Transitive dependencies between numeric primitives (the four base primitives
202
+ # are always present, so they are never listed here).
203
+ _PRIMITIVE_DEPS: dict[str, set[str]] = {
204
+ "uint": set(),
205
+ "frac": set(),
206
+ "exp": set(),
207
+ "integer": {"uint"},
208
+ "number": {"uint", "frac", "exp"},
209
+ "num_array": {"number"},
210
+ "bbox4": {"number"},
211
+ "point2": {"number"},
212
+ }
213
+
214
+ # Stable emission order so the grammar regenerates deterministically.
215
+ _PRIMITIVE_ORDER = ["integer", "number", "num_array", "bbox4", "point2", "uint", "frac", "exp"]
216
+
217
+
218
+ def _gbnf_string_alternation(values: tuple[str, ...]) -> str:
219
+ """Emit `"\"a\"" | "\"b\"" | ...` for a closed enum."""
220
+ return " | ".join(f'"\\"{v}\\""' for v in values)
221
+
222
+
223
+ def _resolve_primitive_deps(deps: set[str]) -> set[str]:
224
+ """Expand a set of primitive names with all transitive dependencies."""
225
+ out: set[str] = set()
226
+ stack = list(deps)
227
+ while stack:
228
+ d = stack.pop()
229
+ if d in out:
230
+ continue
231
+ out.add(d)
232
+ stack.extend(_PRIMITIVE_DEPS.get(d, set()))
233
+ return out
234
+
235
+
236
+ def _gbnf_object_rule(spec: SlotSpec) -> tuple[str, list[str], set[str]]:
237
+ """Build the GBNF object rule for a spec's nested_fields. Returns
238
+ (object_rule_name, extra_rules, primitive_deps)."""
239
+ rule_name = f"obj_{spec.name}"
240
+ parts: list[str] = ['"{"', "ws"]
241
+ extras: list[str] = []
242
+ deps: set[str] = set()
243
+ for i, f in enumerate(spec.nested_fields):
244
+ if i > 0:
245
+ parts += ['","', "ws"]
246
+ frhs, fextras, fdeps = _gbnf_slot_value_rule(f)
247
+ extras += fextras
248
+ deps |= fdeps
249
+ # Wrap the field value in parens so alternations (e.g. "null" | bbox4)
250
+ # compose correctly inside the object.
251
+ parts += [f'"\\"{f.name}\\":"', "ws", f"( {frhs} )", "ws"]
252
+ parts.append('"}"')
253
+ extras.append(f"{rule_name} ::= " + " ".join(parts))
254
+ return rule_name, extras, deps
255
+
256
+
257
+ def _gbnf_slot_value_rule(spec: SlotSpec) -> tuple[str, list[str], set[str]]:
258
+ """Return (right-hand-side, extra_rules, primitive_deps) for this slot's value.
259
+
260
+ The RHS is what appears after `slot_<name> ::=`. It is either a rule name or
261
+ a small alternation (e.g. `"null" | number`). Extra rules are helper rules
262
+ this slot needs; primitive_deps names numeric primitives to emit globally.
263
+ """
264
+ extras: list[str] = []
265
+ deps: set[str] = set()
266
+
267
+ if spec.cardinality == "list":
268
+ if spec.nested_model is SubjectValue:
269
+ # SubjectValue is the caption's one hand-written nested type; keep the
270
+ # exact v2 rules so the caption grammar is unchanged.
271
+ extras.append(
272
+ 'subject ::= "{" ws "\\"name\\":" ws string ws "," ws '
273
+ '"\\"attributes\\":" ws str_array ws "}"'
274
+ )
275
+ extras.append(
276
+ 'subject_list ::= "[" ws "]" | '
277
+ '"[" ws subject (ws "," ws subject)* ws "]"'
278
+ )
279
+ return "subject_list", extras, deps
280
+ if spec.nested_fields:
281
+ obj_name, obj_extras, obj_deps = _gbnf_object_rule(spec)
282
+ extras += obj_extras
283
+ deps |= obj_deps
284
+ list_name = f"{spec.name}_list"
285
+ extras.append(
286
+ f'{list_name} ::= "[" ws "]" | '
287
+ f'"[" ws {obj_name} (ws "," ws {obj_name})* ws "]"'
288
+ )
289
+ return list_name, extras, deps
290
+ if spec.value_kind in ("number", "integer"):
291
+ deps.add("num_array")
292
+ return "num_array", extras, deps
293
+ # Primitive open-vocab list — array of strings
294
+ return "str_array", extras, deps
295
+
296
+ # Single value
297
+ if spec.nested_fields:
298
+ # A single nested object (e.g. subject_fixation.primary_subject). Without
299
+ # this, the grammar would fall through to the string rule and force the
300
+ # object to serialize as a string — breaking constrained decoding.
301
+ obj_name, obj_extras, obj_deps = _gbnf_object_rule(spec)
302
+ extras += obj_extras
303
+ deps |= obj_deps
304
+ if spec.optional:
305
+ return f'"null" | {obj_name}', extras, deps
306
+ return obj_name, extras, deps
307
+
308
+ if spec.vocabulary == "closed":
309
+ alts = _gbnf_string_alternation(spec.closed_values)
310
+ rule_name = f"closed_{spec.name}"
311
+ extras.append(f"{rule_name} ::= {alts}")
312
+ return rule_name, extras, deps
313
+
314
+ if spec.value_kind == "bbox":
315
+ deps.add("bbox4")
316
+ base = "bbox4"
317
+ elif spec.value_kind == "point":
318
+ deps.add("point2")
319
+ base = "point2"
320
+ elif spec.value_kind == "number":
321
+ deps.add("number")
322
+ base = "number"
323
+ elif spec.value_kind == "integer":
324
+ deps.add("integer")
325
+ base = "integer"
326
+ else:
327
+ base = "string"
328
+
329
+ # Optional single → allow null literal.
330
+ if spec.optional:
331
+ return f'"null" | {base}', extras, deps
332
+ return base, extras, deps
333
+
334
+
335
+ def build_gbnf_from_registry(registry: Mapping[str, SlotSpec], root_name: str = "root") -> str:
336
+ """Generate a GBNF grammar that produces JSON conforming to `registry`."""
337
+ slot_rules: list[str] = []
338
+ helper_rules: list[str] = []
339
+ helper_seen: set[str] = set()
340
+ deps: set[str] = set()
341
+
342
+ for name, spec in registry.items():
343
+ rhs, extras, sdeps = _gbnf_slot_value_rule(spec)
344
+ slot_rules.append(f"slot_{name} ::= {rhs}")
345
+ deps |= sdeps
346
+ for r in extras:
347
+ head = r.split("::=", 1)[0].strip()
348
+ if head not in helper_seen:
349
+ helper_rules.append(r)
350
+ helper_seen.add(head)
351
+
352
+ # Root rule: opening brace, slot1, comma, slot2, ..., closing brace.
353
+ parts: list[str] = ['"{"', "ws"]
354
+ for i, name in enumerate(registry.keys()):
355
+ if i > 0:
356
+ parts += ['","', "ws"]
357
+ parts += [f'"\\"{name}\\":"', "ws", f"slot_{name}", "ws"]
358
+ parts.append('"}"')
359
+ root_rule = f"{root_name} ::= " + " ".join(parts)
360
+
361
+ # Base primitives — always present (str_array is needed by open-vocab lists
362
+ # and SubjectValue.attributes).
363
+ common = [
364
+ 'str_array ::= "[" ws "]" | "[" ws string (ws "," ws string)* ws "]"',
365
+ 'string ::= "\\"" char* "\\""',
366
+ 'char ::= [^"\\\\] | "\\\\" ["\\\\/bfnrt]',
367
+ 'ws ::= [ \\t\\n]*',
368
+ ]
369
+
370
+ # Numeric primitives — only those actually referenced (keeps caption grammar
371
+ # identical to v2 and vision grammars minimal).
372
+ resolved = _resolve_primitive_deps(deps)
373
+ numeric = [_PRIMITIVE_DEFS[k] for k in _PRIMITIVE_ORDER if k in resolved]
374
+
375
+ return "\n".join([root_rule] + slot_rules + helper_rules + common + numeric)
376
+
377
+
378
+ def build_gbnf_grammar() -> str:
379
+ """Generate the caption GBNF grammar (back-compat wrapper)."""
380
+ return build_gbnf_from_registry(SLOT_REGISTRY)
381
+
382
+
383
+ CAPTION_GRAMMAR_GBNF: str = build_gbnf_grammar()
384
+
385
+
386
+ # ──────────────────────────────────────────────────────────────────────────────
387
+ # Smoke test — `python -m qwen_test_runner.schema` validates the three reps.
388
+ # ──────────────────────────────────────────────────────────────────────────────
389
+
390
+ def _smoke_test() -> None:
391
+ example = Caption(
392
+ subjects=[Subject(name="dog", attributes=["golden"])],
393
+ actions=["catching"],
394
+ setting="outdoor",
395
+ style="photorealistic",
396
+ mood="energetic",
397
+ )
398
+
399
+ as_dict = example.model_dump()
400
+ rebuilt = Caption.model_validate(as_dict)
401
+ assert rebuilt == example, "pydantic round-trip failed"
402
+
403
+ as_json = example.model_dump_json()
404
+ reparsed = Caption.model_validate_json(as_json)
405
+ assert reparsed == example, "JSON round-trip failed"
406
+
407
+ schema = CAPTION_JSON_SCHEMA
408
+ assert "properties" in schema
409
+ assert set(schema["properties"].keys()) == set(SLOT_REGISTRY.keys())
410
+
411
+ g = CAPTION_GRAMMAR_GBNF
412
+ for slot in SLOT_REGISTRY:
413
+ assert f'\\"{slot}\\"' in g, f"GBNF missing slot {slot}"
414
+
415
+ print("schema.py smoke test: OK")
416
+ print(f" slots: {list(SLOT_REGISTRY.keys())}")
417
+ print(f" example JSON length: {len(as_json)}")
418
+ print(f" JSON Schema fields: {list(schema['properties'].keys())}")
419
+ print(f" GBNF length: {len(g)} chars")
420
+
421
+
422
+ if __name__ == "__main__":
423
+ _smoke_test()
qwen_test_runner/tasks.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tasks.py — Task registry.
3
+
4
+ Each TaskSpec declares a single "what kind of JSON should the model emit"
5
+ behaviour and owns everything needed to drive Claude (the teacher) and Qwen
6
+ (the student) toward it: a system prompt, a tool-schema overlay on top of
7
+ the universal CAPTION_JSON_SCHEMA, and a validator hook for post-schema
8
+ checks (grounding for task_1, regex pattern for task_2 and task_3).
9
+
10
+ Three tasks (as of v0.2):
11
+
12
+ task_1 — hallucination_reduction
13
+ Grounded literal extraction. Subject/action/attribute values come
14
+ from the caption verbatim. Style and mood are forbidden (null).
15
+ The schema does not enable inference; the validator runs grounding
16
+ check (substring + token match against input caption).
17
+
18
+ task_2 — useful_generalization
19
+ Encouraged categorical abstraction. Every string value is a
20
+ bracketed canonical generic like [pet], [vehicle], [color], [playing].
21
+ Schema constrains values to regex /^\\[[a-z_]+\\]$/.
22
+ Validator just enforces the format; semantic correctness is
23
+ a soft target — the open vocabulary is curated post-hoc from
24
+ what the model actually emits.
25
+
26
+ task_3 — generic_symbolism
27
+ Pure positional placeholders. subjects[].name → [ENTITY_N],
28
+ actions[] → [ACTION_N], setting → [INDOOR|OUTDOOR|UNKNOWN],
29
+ attributes → [ATTRIBUTE_N]. Numbering is within-slot, starts at 1,
30
+ monotonically increasing. Style and mood are nullable typed
31
+ placeholders.
32
+
33
+ Adding a task is one TASK_REGISTRY entry. The pipeline (prompt_maker.py)
34
+ iterates TASK_REGISTRY; downstream consumers (ClaudeProvider, the qwen
35
+ tester) look tasks up by name.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import copy
40
+ import re
41
+ from dataclasses import dataclass, field
42
+ from typing import Callable, Optional
43
+
44
+ from .schema import CAPTION_JSON_SCHEMA
45
+
46
+
47
+ # ──────────────────────────────────────────────────────────────────────────────
48
+ # TaskSpec
49
+ # ──────────────────────────────────────────────────────────────────────────────
50
+
51
+ @dataclass(frozen=True)
52
+ class TaskSpec:
53
+ """Declarative definition of one differentiation mode.
54
+
55
+ Fields:
56
+ name — stable task id used in row tags + filenames
57
+ description — one-liner for logs and row meta
58
+ system_prompt — the task's system prompt (Claude + Qwen)
59
+ tool_schema — a JSON Schema dict, fully built (with overlays applied).
60
+ Passed as input_schema to Claude's tool def.
61
+ value_pattern — optional regex every emitted string value must match.
62
+ Used by both Claude (via schema 'pattern') AND the
63
+ evaluator (post-hoc check on Qwen outputs).
64
+ validate — optional post-hoc validator. Signature:
65
+ (caption, parsed_args_dict) -> list[str]
66
+ Returns a list of reject reasons; empty list = pass.
67
+ """
68
+ name: str
69
+ description: str
70
+ system_prompt: str
71
+ tool_schema: dict
72
+ value_pattern: Optional[str] = None
73
+ validate: Optional[Callable] = None
74
+
75
+
76
+ # ──────────────────────────────────────────────────────────────────────────────
77
+ # Schema-overlay helpers (used to build per-task tool_schema from base)
78
+ # ──────────────────────────────────────────────────────────────────────────────
79
+
80
+ def _deep_merge(base: dict, overlay: dict) -> dict:
81
+ """Recursively merge overlay into a copy of base. Overlay wins on conflicts."""
82
+ out = copy.deepcopy(base)
83
+ for k, v in overlay.items():
84
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
85
+ out[k] = _deep_merge(out[k], v)
86
+ else:
87
+ out[k] = copy.deepcopy(v)
88
+ return out
89
+
90
+
91
+ def _apply_string_pattern(schema: dict, pattern: str) -> dict:
92
+ """Return a copy of schema with `pattern` applied to every string-typed leaf.
93
+
94
+ Walks the schema and adds {'pattern': pattern} to every node where
95
+ type=='string' (including inside anyOf branches). Skips closed enums
96
+ — those are already constrained.
97
+ """
98
+ out = copy.deepcopy(schema)
99
+
100
+ def walk(node):
101
+ if isinstance(node, dict):
102
+ # If this node is a string type without an enum, attach pattern
103
+ if node.get("type") == "string" and "enum" not in node:
104
+ node["pattern"] = pattern
105
+ # Recurse into children
106
+ for v in node.values():
107
+ walk(v)
108
+ elif isinstance(node, list):
109
+ for item in node:
110
+ walk(item)
111
+
112
+ walk(out)
113
+ return out
114
+
115
+
116
+ # ──────────────────────────────────────────────────────────────────────────────
117
+ # Task 1: hallucination_reduction
118
+ #
119
+ # Schema overlay forces style and mood to const null so Claude cannot emit
120
+ # anything else. The system prompt also forbids them — belt and suspenders.
121
+ # Grounding check is the validator (uses evaluator.ground_check).
122
+ # ──────────────────────────────────────────────────────────────────────────────
123
+
124
+ _TASK1_OVERLAY = {
125
+ "properties": {
126
+ "style": {"const": None},
127
+ "mood": {"const": None},
128
+ },
129
+ }
130
+
131
+ _TASK1_PROMPT = """You are a caption-structuring assistant. Given an image-synthesis
132
+ prompt, emit structured JSON via the emit_caption_schema tool. Your job is
133
+ GROUNDED LITERAL EXTRACTION — extract structured information that is
134
+ explicitly stated in the input, never embellish, infer, or imagine details.
135
+
136
+ RULES:
137
+ - subjects: every entity named in the caption. Each subject has a name (a noun
138
+ phrase taken from the caption) and optional attributes (adjectives/descriptors
139
+ the caption explicitly attaches to that subject: color, age, expression,
140
+ material, count, etc.).
141
+ - actions: verb phrases describing what is happening. Use caption wording.
142
+ - setting: "indoor" or "outdoor" if the caption indicates it (kitchen,
143
+ restaurant → indoor; park, beach → outdoor). Otherwise "unknown".
144
+ - style: ALWAYS null. The schema does not permit any other value here.
145
+ - mood: ALWAYS null. The schema does not permit any other value here.
146
+ - Empty lists [] and null are correct outputs — DO NOT invent content to fill
147
+ any field. Schema-valid empty is better than schema-valid invented.
148
+
149
+ EXAMPLES:
150
+ - "a young girl in a red dress" → subjects: [{name: "girl", attributes: ["young"]},
151
+ {name: "dress", attributes: ["red"]}]; setting: "unknown"
152
+ - "a cat sleeping on a sofa" → subjects: [{name: "cat", attributes: []},
153
+ {name: "sofa", attributes: []}], actions: ["sleeping on a sofa"];
154
+ setting: "indoor"
155
+ - "the beach at sunset" → subjects: [{name: "beach", attributes: []}];
156
+ setting: "outdoor"
157
+
158
+ WHAT TO AVOID:
159
+ - Inventing subjects, attributes, or actions not in the caption.
160
+ - Inferring style or mood — the schema rejects anything but null for these.
161
+
162
+ Call the emit_caption_schema tool with the structured output.""".strip()
163
+
164
+
165
+ # ──────────────────────────────────────────────────────────────────────────────
166
+ # Task 2: useful_generalization
167
+ #
168
+ # All open-vocab string values must match /^\[[a-z_]+\]$/ — bracketed lowercase
169
+ # generics like [pet], [vehicle], [playing], [outdoor_scene].
170
+ # setting's enum is replaced with bracketed versions for consistency.
171
+ # Style and mood remain null (style/mood are out of scope for this task too).
172
+ # ──────────────────────────────────────────────────────────────────────────────
173
+
174
+ _TASK2_PATTERN = r"^\[[a-z_]+\]$"
175
+
176
+ _TASK2_SETTING_ENUM = ["[indoor]", "[outdoor]", "[unknown]"]
177
+
178
+ # Build task_2's schema: apply pattern to all open strings, then overlay
179
+ # setting's enum + force style/mood null.
180
+ def _build_task2_schema() -> dict:
181
+ s = _apply_string_pattern(CAPTION_JSON_SCHEMA, _TASK2_PATTERN)
182
+ overlay = {
183
+ "properties": {
184
+ "setting": {
185
+ "enum": _TASK2_SETTING_ENUM,
186
+ "default": "[unknown]",
187
+ },
188
+ "style": {"const": None},
189
+ "mood": {"const": None},
190
+ },
191
+ }
192
+ s = _deep_merge(s, overlay)
193
+ # The 'setting' enum was overwritten; remove its old pattern (closed vocab
194
+ # doesn't need it, and pattern + enum can confuse some validators).
195
+ if "pattern" in s["properties"]["setting"]:
196
+ del s["properties"]["setting"]["pattern"]
197
+ return s
198
+
199
+
200
+ _TASK2_PROMPT = """You are a caption-structuring assistant. Given an image-synthesis
201
+ prompt, emit structured JSON via the emit_caption_schema tool. Your job is
202
+ USEFUL GENERALIZATION — abstract every concrete noun, adjective, and verb to
203
+ a small canonical CATEGORICAL GENERIC in [bracket_word] form.
204
+
205
+ RULES:
206
+ - Every open-vocabulary string value MUST be in [lowercase_with_underscores]
207
+ format, between square brackets. The schema enforces this.
208
+ - subjects: list of bracketed generics that abstract caption entities.
209
+ Prefer the smallest sensible category ([pet] over [golden_retriever];
210
+ [clothing] over [red_dress]; [tool] over [pencil]).
211
+ - attributes: bracketed generic descriptors ([color], [young], [shiny]).
212
+ - actions: bracketed generic verbs ([playing], [eating], [waiting]).
213
+ - setting: choose [indoor], [outdoor], or [unknown].
214
+ - style: ALWAYS null in this task.
215
+ - mood: ALWAYS null in this task.
216
+
217
+ EXAMPLES:
218
+ - "a golden retriever catching a red frisbee in a sunny park" →
219
+ subjects: [{name: "[pet]", attributes: []},
220
+ {name: "[toy]", attributes: ["[color]"]}]
221
+ actions: ["[playing]"]
222
+ setting: "[outdoor]"
223
+
224
+ - "a young girl in a red dress" →
225
+ subjects: [{name: "[person]", attributes: ["[young]"]},
226
+ {name: "[clothing]", attributes: ["[color]"]}]
227
+ actions: []
228
+ setting: "[unknown]"
229
+
230
+ - "an architect at his desk reviewing blueprints" →
231
+ subjects: [{name: "[person]", attributes: []},
232
+ {name: "[furniture]", attributes: []},
233
+ {name: "[document]", attributes: []}]
234
+ actions: ["[working]"]
235
+ setting: "[indoor]"
236
+
237
+ The aim is to teach a categorical view of caption content. Pick generics that
238
+ group similar specifics together. Different captions producing similar generic
239
+ structures is GOOD — that is the point.
240
+
241
+ Call the emit_caption_schema tool with the structured output.""".strip()
242
+
243
+
244
+ # ──────────────────────────────────────────────────────────────────────────────
245
+ # Task 3: generic_symbolism
246
+ #
247
+ # Numbered typed placeholders. Each slot has its own type prefix and integer
248
+ # index (1-based, monotonic within slot). Captures positional structure with
249
+ # zero semantic content.
250
+ # ──────────────────────────────────────────────────────────────────────────────
251
+
252
+ _TASK3_ENTITY_PATTERN = r"^\[ENTITY_\d+\]$"
253
+ _TASK3_ATTRIBUTE_PATTERN = r"^\[ATTRIBUTE_\d+\]$"
254
+ _TASK3_ACTION_PATTERN = r"^\[ACTION_\d+\]$"
255
+
256
+ _TASK3_SETTING_ENUM = ["[INDOOR]", "[OUTDOOR]", "[UNKNOWN]"]
257
+
258
+
259
+ def _build_task3_schema() -> dict:
260
+ s = copy.deepcopy(CAPTION_JSON_SCHEMA)
261
+ # subjects[].name → ENTITY pattern; subjects[].attributes[] → ATTRIBUTE pattern
262
+ subj = s["$defs"]["SubjectValue"]["properties"]
263
+ subj["name"]["pattern"] = _TASK3_ENTITY_PATTERN
264
+ subj["attributes"]["items"]["pattern"] = _TASK3_ATTRIBUTE_PATTERN
265
+ # actions[] → ACTION pattern
266
+ s["properties"]["actions"]["items"]["pattern"] = _TASK3_ACTION_PATTERN
267
+ # setting → bracketed UPPERCASE enum
268
+ s["properties"]["setting"]["enum"] = _TASK3_SETTING_ENUM
269
+ s["properties"]["setting"]["default"] = "[UNKNOWN]"
270
+ # style and mood: must be null in this task too (placeholder structure
271
+ # doesn't have a meaningful "style" position — keep nullable for symmetry).
272
+ s["properties"]["style"] = {"const": None}
273
+ s["properties"]["mood"] = {"const": None}
274
+ return s
275
+
276
+
277
+ _TASK3_PROMPT = """You are a caption-structuring assistant. Given an image-synthesis
278
+ prompt, emit structured JSON via the emit_caption_schema tool. Your job is
279
+ PURE STRUCTURAL SYMBOLISM — convert every entity to a numbered typed
280
+ placeholder. The output captures positional roles only, with zero semantic
281
+ content.
282
+
283
+ FORMAT:
284
+ - subjects[i].name → [ENTITY_N] (N = 1, 2, 3, ... in caption order)
285
+ - subjects[i].attributes[j] → [ATTRIBUTE_N] (N restarts at 1 within each subject)
286
+ - actions[i] → [ACTION_N] (N = 1, 2, 3, ... in caption order)
287
+ - setting → [INDOOR], [OUTDOOR], or [UNKNOWN] (uppercase)
288
+ - style → null
289
+ - mood → null
290
+
291
+ NUMBERING RULES:
292
+ - N is a positive integer starting at 1.
293
+ - Within a slot, numbering is monotonically increasing with no gaps.
294
+ - Each occurrence of a real entity → one ENTITY_N; do not collapse duplicates.
295
+
296
+ EXAMPLES:
297
+
298
+ - "a golden retriever catching a red frisbee in a sunny park" →
299
+ subjects: [{name: "[ENTITY_1]", attributes: [],
300
+ "..."},
301
+ {name: "[ENTITY_2]", attributes: ["[ATTRIBUTE_1]"]}]
302
+ actions: ["[ACTION_1]"]
303
+ setting: "[OUTDOOR]"
304
+ (ENTITY_1=retriever, ATTRIBUTE_1 on ENTITY_1=golden was DROPPED because
305
+ the caption attached "golden" to the retriever; we keep that as attributes.
306
+ Wait — corrected: golden retriever has attribute "golden" → ATTRIBUTE_1.
307
+ frisbee has attribute "red" → ATTRIBUTE_1 (restart per subject).)
308
+
309
+ - "two children playing chess" →
310
+ subjects: [{name: "[ENTITY_1]", attributes: ["[ATTRIBUTE_1]"]},
311
+ {name: "[ENTITY_2]", attributes: []}]
312
+ actions: ["[ACTION_1]"]
313
+ setting: "[UNKNOWN]"
314
+ (ENTITY_1=children, ATTRIBUTE_1=two on children; ENTITY_2=chess)
315
+
316
+ - "the beach at sunset" →
317
+ subjects: [{name: "[ENTITY_1]", attributes: []}]
318
+ actions: []
319
+ setting: "[OUTDOOR]"
320
+
321
+ The aim is to teach the model to think about caption STRUCTURE divorced from
322
+ content. Two completely different captions with the same shape should produce
323
+ the same JSON.
324
+
325
+ Call the emit_caption_schema tool with the structured output.""".strip()
326
+
327
+
328
+ # ──────────────────────────────────────────────────────────────────────────────
329
+ # Validators
330
+ # ──────────────────────────────────────────────────────────────────────────────
331
+
332
+ def _validate_task1(caption: str, args: dict) -> list[str]:
333
+ """Grounding check. Imported lazily to avoid circular import with evaluator."""
334
+ from .evaluator import parse_safely, ground_check
335
+ import json
336
+ parse = parse_safely(json.dumps(args))
337
+ if not parse.schema_valid or parse.parsed is None:
338
+ return [f"schema: {parse.error}"]
339
+ report = ground_check(parse.parsed, caption)
340
+ if report.grounding_rate < 1.0:
341
+ return [f"hallucinated: {h[1]!r} at {h[0]}" for h in report.hallucinated]
342
+ return []
343
+
344
+
345
+ _TASK2_VALUE_RE = re.compile(_TASK2_PATTERN)
346
+ _TASK3_ENTITY_RE = re.compile(_TASK3_ENTITY_PATTERN)
347
+ _TASK3_ATTRIBUTE_RE = re.compile(_TASK3_ATTRIBUTE_PATTERN)
348
+ _TASK3_ACTION_RE = re.compile(_TASK3_ACTION_PATTERN)
349
+
350
+
351
+ def _safe_match(regex: re.Pattern, value) -> bool:
352
+ """Match-or-False without crashing on non-string inputs.
353
+
354
+ Claude occasionally emits dicts where strings are expected (e.g.
355
+ actions=[{'type':'action','text':'...'}]). The schema's tool_use
356
+ enforcement *usually* catches this, but failures slip through often
357
+ enough that the validator must not crash on them.
358
+ """
359
+ return isinstance(value, str) and regex.fullmatch(value) is not None
360
+
361
+
362
+ def _validate_task2(caption: str, args: dict) -> list[str]:
363
+ """Every open-vocab string must match the bracketed-generic pattern."""
364
+ errs: list[str] = []
365
+ if not isinstance(args, dict):
366
+ return [f"args is not a dict: {type(args).__name__}"]
367
+ for i, subj in enumerate(args.get("subjects") or []):
368
+ if not isinstance(subj, dict):
369
+ errs.append(f"subjects[{i}] is not a dict: {type(subj).__name__}")
370
+ continue
371
+ if not _safe_match(_TASK2_VALUE_RE, subj.get("name")):
372
+ errs.append(f"subjects[{i}].name not bracketed: {subj.get('name')!r}")
373
+ for j, attr in enumerate(subj.get("attributes") or []):
374
+ if not _safe_match(_TASK2_VALUE_RE, attr):
375
+ errs.append(f"subjects[{i}].attributes[{j}] not bracketed: {attr!r}")
376
+ for i, a in enumerate(args.get("actions") or []):
377
+ if not _safe_match(_TASK2_VALUE_RE, a):
378
+ errs.append(f"actions[{i}] not bracketed: {a!r}")
379
+ setting = args.get("setting")
380
+ if setting is not None and setting not in _TASK2_SETTING_ENUM:
381
+ errs.append(f"setting not in enum: {setting!r}")
382
+ return errs
383
+
384
+
385
+ def _validate_task3(caption: str, args: dict) -> list[str]:
386
+ """Typed numbered placeholders + monotonic numbering within slot."""
387
+ errs: list[str] = []
388
+ if not isinstance(args, dict):
389
+ return [f"args is not a dict: {type(args).__name__}"]
390
+ # subjects.name → ENTITY_N, monotonic
391
+ for i, subj in enumerate(args.get("subjects") or []):
392
+ if not isinstance(subj, dict):
393
+ errs.append(f"subjects[{i}] is not a dict: {type(subj).__name__}")
394
+ continue
395
+ name = subj.get("name")
396
+ if not _safe_match(_TASK3_ENTITY_RE, name):
397
+ errs.append(f"subjects[{i}].name not [ENTITY_N]: {name!r}")
398
+ continue
399
+ if name != f"[ENTITY_{i + 1}]":
400
+ errs.append(f"subjects[{i}].name should be [ENTITY_{i + 1}], got {name!r}")
401
+ for j, attr in enumerate(subj.get("attributes") or []):
402
+ if not _safe_match(_TASK3_ATTRIBUTE_RE, attr):
403
+ errs.append(f"subjects[{i}].attributes[{j}] not [ATTRIBUTE_N]: {attr!r}")
404
+ continue
405
+ if attr != f"[ATTRIBUTE_{j + 1}]":
406
+ errs.append(
407
+ f"subjects[{i}].attributes[{j}] should be [ATTRIBUTE_{j + 1}], got {attr!r}"
408
+ )
409
+ # actions: ACTION_N, monotonic
410
+ for i, a in enumerate(args.get("actions") or []):
411
+ if not _safe_match(_TASK3_ACTION_RE, a):
412
+ errs.append(f"actions[{i}] not [ACTION_N]: {a!r}")
413
+ continue
414
+ if a != f"[ACTION_{i + 1}]":
415
+ errs.append(f"actions[{i}] should be [ACTION_{i + 1}], got {a!r}")
416
+ setting = args.get("setting")
417
+ if setting is not None and setting not in _TASK3_SETTING_ENUM:
418
+ errs.append(f"setting not in enum: {setting!r}")
419
+ return errs
420
+
421
+
422
+ # ──────────────────────────────────────────────────────────────────────────────
423
+ # THE REGISTRY
424
+ # ──��───────────────────────────────────────────────────────────────────────────
425
+
426
+ TASK_REGISTRY: dict[str, TaskSpec] = {
427
+ "task_1": TaskSpec(
428
+ name="task_1",
429
+ description="hallucination_reduction: grounded literal extraction; null style/mood",
430
+ system_prompt=_TASK1_PROMPT,
431
+ tool_schema=_deep_merge(CAPTION_JSON_SCHEMA, _TASK1_OVERLAY),
432
+ value_pattern=None,
433
+ validate=_validate_task1,
434
+ ),
435
+ "task_2": TaskSpec(
436
+ name="task_2",
437
+ description="useful_generalization: bracketed categorical generics",
438
+ system_prompt=_TASK2_PROMPT,
439
+ tool_schema=_build_task2_schema(),
440
+ value_pattern=_TASK2_PATTERN,
441
+ validate=_validate_task2,
442
+ ),
443
+ "task_3": TaskSpec(
444
+ name="task_3",
445
+ description="generic_symbolism: numbered typed placeholders",
446
+ system_prompt=_TASK3_PROMPT,
447
+ tool_schema=_build_task3_schema(),
448
+ value_pattern=None, # multiple patterns per slot, handled in validator
449
+ validate=_validate_task3,
450
+ ),
451
+ }
452
+
453
+
454
+ def get_task(name: str) -> TaskSpec:
455
+ if name not in TASK_REGISTRY:
456
+ raise KeyError(f"unknown task: {name!r}. known: {list(TASK_REGISTRY)}")
457
+ return TASK_REGISTRY[name]
458
+
459
+
460
+ def task_names() -> list[str]:
461
+ return list(TASK_REGISTRY.keys())
qwen_test_runner/vision/__init__.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ qwen_test_runner.vision — image→JSON benchmark harness.
3
+
4
+ Extends the text testbed to vision: a per-category task registry (mirroring the
5
+ caption SLOT_REGISTRY), coordinate normalization, ground-truth metrics that
6
+ replace substring grounding, a multi-model VLM runner over the Qwen3.5 / Qwen3-VL
7
+ ladder, and an orchestrator that ranks models for the no-finetune labeler verdict.
8
+
9
+ The data-driven modules (coords, model_registry, tasks_vision, metrics) are
10
+ torch-free and import eagerly. The VLM runner and orchestrator are imported lazily
11
+ so `import qwen_test_runner.vision` stays cheap on a CPU box.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .coords import BBox, CoordSpace, to_canonical, from_canonical, detect_space, prompt_hint_for
17
+ from .model_registry import (
18
+ MODEL_REGISTRY, ModelSpec, get_model, model_keys, models_that_fit,
19
+ reasoning_variants, get_runner,
20
+ )
21
+ from .tasks_vision import (
22
+ VISION_TASK_REGISTRY, VisionTaskSpec, get_task, category_names, pilot_categories,
23
+ model_for, json_schema_for, gbnf_for, tool_schema_for, resolved_system_prompt,
24
+ )
25
+ from .metrics import (
26
+ MetricResult, VisionRunMetrics, labeler_score,
27
+ score_vision_sample, score_vision_run,
28
+ )
29
+
30
+
31
+ def __getattr__(name: str):
32
+ # StubVLMRunner + VLMResult are torch-free; VLMRunner pulls torch.
33
+ if name in ("StubVLMRunner",):
34
+ from .stub_runner import StubVLMRunner
35
+ return StubVLMRunner
36
+ if name == "VLMResult":
37
+ from .runner_types import VLMResult
38
+ return VLMResult
39
+ if name == "VLMRunner":
40
+ from .runners import VLMRunner
41
+ return VLMRunner
42
+ if name == "run_bench":
43
+ from .bench import run_bench
44
+ return run_bench
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
46
+
47
+
48
+ __all__ = [
49
+ # coords
50
+ "BBox", "CoordSpace", "to_canonical", "from_canonical", "detect_space", "prompt_hint_for",
51
+ # model registry
52
+ "MODEL_REGISTRY", "ModelSpec", "get_model", "model_keys", "models_that_fit",
53
+ "reasoning_variants", "get_runner",
54
+ # tasks
55
+ "VISION_TASK_REGISTRY", "VisionTaskSpec", "get_task", "category_names", "pilot_categories",
56
+ "model_for", "json_schema_for", "gbnf_for", "tool_schema_for", "resolved_system_prompt",
57
+ # metrics
58
+ "MetricResult", "VisionRunMetrics", "labeler_score",
59
+ "score_vision_sample", "score_vision_run",
60
+ # lazy
61
+ "VLMRunner", "StubVLMRunner", "run_bench",
62
+ ]
qwen_test_runner/vision/bench.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ bench.py — The orchestrator (torch-free at import; the runner is injected).
3
+
4
+ Run matrix = models × reasoning × category × mode × N, iterated model-outer so a
5
+ heavy checkpoint loads once and is freed before the next. Ground truth is loaded
6
+ once per category so every model sees identical inputs (fairness).
7
+
8
+ Durability (the project's standing pattern): config.json + a stream-written
9
+ results.jsonl (one row per scored sample, written immediately) + a rejects.jsonl
10
+ sidecar + an append-only run.log. Resume skips already-completed
11
+ (model, reasoning, category, mode, image_id) keys, so a Colab disconnect costs at
12
+ most one row.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ from dataclasses import asdict, dataclass, field
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Callable, Optional
23
+
24
+ from .datasets import load_gt
25
+ from .metrics import score_vision_run, score_vision_sample
26
+ from .report import write_reports
27
+ from .tasks_vision import get_task, pilot_categories
28
+
29
+
30
+ @dataclass
31
+ class BenchConfig:
32
+ models: list[str]
33
+ categories: list[str] = field(default_factory=pilot_categories)
34
+ reasonings: list[str] = field(default_factory=lambda: ["instruct"])
35
+ modes: list[str] = field(default_factory=lambda: ["json_mode"])
36
+ n: int = 50
37
+ dataset: str = "smoke" # "smoke" | "full"
38
+ runner: str = "stub" # "stub" | "vlm"
39
+ precision: str = "bf16"
40
+ stub_behavior: str = "perfect" # stub only
41
+ output_root: str = "runs/vision"
42
+ gpu_hourly_rate: float = 2.0
43
+ clear_cache_after_model: bool = False # rm each model's HF cache after use (full-array sweeps)
44
+
45
+
46
+ def _free_model_cache(model_key: str) -> None:
47
+ """Delete a model's HF Hub cache from disk (full-array sweeps on a tight SSD)."""
48
+ import os
49
+ import shutil
50
+ try:
51
+ from .model_registry import get_model
52
+ spec = get_model(model_key)
53
+ repos = [spec.repo_id] + list(spec.quant_repo_ids.values())
54
+ if spec.thinking_repo_id:
55
+ repos.append(spec.thinking_repo_id)
56
+ except Exception:
57
+ repos = [model_key]
58
+ base = os.path.expanduser("~/.cache/huggingface/hub")
59
+ for repo in repos:
60
+ p = os.path.join(base, "models--" + repo.replace("/", "--"))
61
+ if os.path.isdir(p):
62
+ shutil.rmtree(p, ignore_errors=True)
63
+
64
+
65
+ def _utc_stamp() -> str:
66
+ # microsecond precision so back-to-back runs never collide into one run dir
67
+ # (which would let resume fold one run's metrics into another's report)
68
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S_%fZ")
69
+
70
+
71
+ def _default_runner_factory(config: BenchConfig) -> Callable[[str, str], object]:
72
+ if config.runner == "stub":
73
+ from .stub_runner import StubVLMRunner
74
+ return lambda mk, rsn: StubVLMRunner(model_id=mk, behavior=config.stub_behavior, reasoning=rsn)
75
+ # real VLM — imports torch lazily inside get_runner
76
+ from .model_registry import get_runner
77
+ return lambda mk, rsn: get_runner(mk, precision=config.precision, reasoning=rsn)
78
+
79
+
80
+ def _completed_keys(results_path: Path) -> set:
81
+ done = set()
82
+ if not results_path.exists():
83
+ return done
84
+ for line in results_path.read_text(encoding="utf-8").splitlines():
85
+ if not line.strip():
86
+ continue
87
+ try:
88
+ r = json.loads(line)
89
+ done.add((r["model"], r["reasoning"], r["category"], r["mode"], r["image_id"]))
90
+ except (json.JSONDecodeError, KeyError):
91
+ continue
92
+ return done
93
+
94
+
95
+ def run_bench(config: BenchConfig, runner_factory: Optional[Callable] = None,
96
+ run_dir: Optional[Path] = None) -> dict:
97
+ runner_factory = runner_factory or _default_runner_factory(config)
98
+ root = Path(config.output_root)
99
+ run_dir = run_dir or (root / _utc_stamp())
100
+ run_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ results_path = run_dir / "results.jsonl"
103
+ rejects_path = run_dir / "rejects.jsonl"
104
+ metrics_path = run_dir / "metrics.jsonl"
105
+ log_path = run_dir / "run.log"
106
+
107
+ (run_dir / "config.json").write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")
108
+ done = _completed_keys(results_path)
109
+
110
+ def log(msg: str) -> None:
111
+ stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
112
+ with log_path.open("a", encoding="utf-8") as fh:
113
+ fh.write(f"[{stamp}] {msg}\n")
114
+
115
+ log(f"start config={asdict(config)}")
116
+ metric_rows: list[dict] = []
117
+ n_total = n_valid = n_reject = n_skip = 0
118
+
119
+ with results_path.open("a", encoding="utf-8") as res_fh, \
120
+ rejects_path.open("a", encoding="utf-8") as rej_fh, \
121
+ metrics_path.open("a", encoding="utf-8") as met_fh:
122
+
123
+ for model_key in config.models:
124
+ for reasoning in config.reasonings:
125
+ t_model = time.perf_counter()
126
+ runner = runner_factory(model_key, reasoning)
127
+ log(f"loaded {model_key}/{reasoning}")
128
+ try:
129
+ for category in config.categories:
130
+ spec = get_task(category)
131
+ gt_key = category if config.dataset == "smoke" else spec.gt_dataset
132
+ samples = load_gt(gt_key, n=config.n, split=spec.gt_split,
133
+ dataset=config.dataset)
134
+ for mode in config.modes:
135
+ cell: list = []
136
+ for s in samples:
137
+ key = (model_key, reasoning, category, mode, s.image_id)
138
+ if key in done:
139
+ n_skip += 1
140
+ continue
141
+ up = s.prompt if spec.per_sample_prompt else None
142
+ res = runner.generate(spec, s.image, mode, image_id=s.image_id,
143
+ image_size=s.size, gt=s.gt, user_prompt=up)
144
+ mr = score_vision_sample(
145
+ spec, res.raw_text, s.gt, mode=mode, image_id=s.image_id,
146
+ image_size=s.size, grammar_conformant=res.grammar_conformant,
147
+ n_output_tokens=res.n_output_tokens, gen_seconds=res.gen_seconds)
148
+ cell.append(mr)
149
+ n_total += 1
150
+ row = {"model": model_key, "reasoning": reasoning, **mr.to_dict()}
151
+ res_fh.write(json.dumps(row) + "\n")
152
+ res_fh.flush()
153
+ if mr.schema_valid:
154
+ n_valid += 1
155
+ else:
156
+ n_reject += 1
157
+ rej_fh.write(json.dumps({**row, "raw_text": res.raw_text}) + "\n")
158
+ rej_fh.flush()
159
+ if cell:
160
+ rm = score_vision_run(cell, model=model_key, reasoning=reasoning,
161
+ category=category, mode=mode)
162
+ row = asdict(rm)
163
+ metric_rows.append(row)
164
+ met_fh.write(json.dumps(row) + "\n")
165
+ met_fh.flush()
166
+ log(str(rm))
167
+ finally:
168
+ close = getattr(runner, "close", None)
169
+ if callable(close):
170
+ close()
171
+ if config.clear_cache_after_model and config.runner == "vlm":
172
+ _free_model_cache(model_key)
173
+ log(f"freed {model_key}/{reasoning} in {time.perf_counter() - t_model:.1f}s")
174
+
175
+ # If resuming, fold in prior metric rows from metrics.jsonl for a complete report.
176
+ if metrics_path.exists():
177
+ seen = {(r["model"], r["reasoning"], r["category"], r["mode"]) for r in metric_rows}
178
+ for line in metrics_path.read_text(encoding="utf-8").splitlines():
179
+ if not line.strip():
180
+ continue
181
+ try:
182
+ r = json.loads(line)
183
+ except json.JSONDecodeError:
184
+ continue
185
+ if (r["model"], r["reasoning"], r["category"], r["mode"]) not in seen:
186
+ metric_rows.append(r)
187
+ seen.add((r["model"], r["reasoning"], r["category"], r["mode"]))
188
+
189
+ write_reports(run_dir, metric_rows, asdict(config))
190
+ summary = {
191
+ "run_dir": str(run_dir),
192
+ "n_total": n_total, "n_schema_valid": n_valid, "n_rejected": n_reject, "n_skipped": n_skip,
193
+ "models": config.models, "categories": config.categories,
194
+ }
195
+ (run_dir / "run_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
196
+ log(f"done {summary}")
197
+ return summary
qwen_test_runner/vision/configs/dataset_gen.yaml ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dataset_gen.yaml — recommended config for image->JSON labeling at scale.
2
+ # Derived from the 15-category qwen-vlmbench investigation (2026-06, RTX 6000 Pro).
3
+ # Every model runs ZERO-SHOT (no finetune). Constrained decoding guarantees valid JSON.
4
+ # Metric throughout: effective yield = task_accuracy x schema_valid_rate (real per-image yield).
5
+
6
+ coordinate_convention: NORM_0_1000 # LOCKED: Qwen3-VL emits 0..1000 relative xyxy; GT in pixels
7
+ decoding:
8
+ mode: constrained # xgrammar; guarantees schema-valid JSON, ~2% slower than json_mode
9
+ fallback: json_mode # if xgrammar is unavailable
10
+ precision: bf16
11
+
12
+ # ── Single best ALL-ROUNDER (highest mean effective yield across all 15 categories) ─────────
13
+ primary_labeler:
14
+ model: Qwen/Qwen3-VL-8B-Instruct # registry key: qwen3vl-8b
15
+ mean_effective_yield: 0.542
16
+ approx_tok_per_sec: 57
17
+ vram_gb_bf16: 16
18
+ strengths: [segmentation, outline_association, bbox_grounding, style, data_type_utilization,
19
+ subject_fixation, camera_rotational_offset, semantic_association]
20
+ notes: >
21
+ Best balance of accuracy + speed; the only model with NO catastrophic category failure
22
+ (except 3D, which all models fail). Strongest visual-grounding model.
23
+
24
+ # ── Specialist for LANGUAGE / STRUCTURED / RELATIONAL tasks (use instead of primary here) ───
25
+ specialist_labeler:
26
+ model: Qwen/Qwen3.5-9B # registry key: qwen3.5-9b
27
+ mean_effective_yield: 0.524
28
+ approx_tok_per_sec: 50
29
+ vram_gb_bf16: 18
30
+ strengths: [image_classification, ocr_text, data_type_differentiation, structural_spatial_awareness,
31
+ depth_analysis, camera_rotational_offset, semantic_association]
32
+ notes: >
33
+ Best on language/relational tasks, but FAILS pixel segmentation/outline (~0.00) — do NOT use
34
+ it for grounding tasks.
35
+
36
+ # ── Throughput option (high-volume, accuracy-tolerant) ──────────────────────────────────────
37
+ fleet_labeler:
38
+ model: Qwen/Qwen3.5-2B # registry key: qwen3.5-2b
39
+ mean_effective_yield: 0.299
40
+ approx_tok_per_sec: 65
41
+ vram_gb_bf16: 4
42
+ notes: fastest; surprisingly strong VQA effective yield (0.75); weak on grounding/depth/classification.
43
+
44
+ # ── Per-task routing: the highest-yield model per category (max-quality labeling) ───────────
45
+ # Pin one model per category if you want the strongest possible label for each task.
46
+ task_routing:
47
+ image_classification: qwen3.5-9b # 0.40
48
+ bbox_grounding: qwen3.5-9b # 0.37
49
+ ocr_text: qwen3.5-9b # 0.45
50
+ data_type_differentiation: qwen3.5-9b # 0.89
51
+ data_type_utilization: qwen3vl-8b # 0.67
52
+ structural_spatial_awareness: qwen3.5-9b # 0.87
53
+ depth_analysis: qwen3.5-9b # 1.00
54
+ subject_fixation: qwen3.5-9b # 1.00 (vl-8b ties)
55
+ segmentation: qwen3vl-8b # 0.51 (9b/2b ~0.00)
56
+ outline_association: qwen3vl-8b # 0.33 (9b/2b 0.00)
57
+ camera_rotational_offset: qwen3.5-9b # 0.78
58
+ semantic_association: qwen3.5-9b # 0.77
59
+ style_structural_awareness: qwen3vl-8b # 0.58
60
+ vit_accuracy_to_prompt: qwen3vl-8b # 0.75 eff (N=48). 9b collapses to 0.38 (only 44% valid); 2b 0.69
61
+ geometric_3d_object_id: NONE # all models ~0.00 from a 2D proxy — needs finetune or a real 3D dataset
62
+
63
+ # ── Caveats / open items ────────────────────────────────────────────────────────────────────
64
+ caveats:
65
+ - geometric_3d_object_id: every model near 0 (low valid + low acc) — 3D-from-single-2D-proxy is unsolved here.
66
+ - constrained decoding requires installing xgrammar BEFORE importing the package (lazy-detected at runtime).
67
+ - GT loaders: imagenet (gated) + textvqa (script-based) still need parquet swaps for a one-shot
68
+ `qwen-vlmbench --dataset full` reproduction; detection(COCO)/vqa(VQAv2)/synthetic categories all work.
qwen_test_runner/vision/coords.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ coords.py — Coordinate normalization (the correctness centerpiece).
3
+
4
+ Different VLMs report boxes in different spaces:
5
+ * Qwen3-VL emits RELATIVE coordinates in 0..1000 (integers).
6
+ * Qwen3.5 / many checkpoints emit 0..1 floats.
7
+ * COCO / LVIS ground truth is ABSOLUTE pixels.
8
+
9
+ If predictions and ground truth are compared in mismatched spaces, every IoU is
10
+ silently wrong and detection/3D/segmentation metrics collapse. To prevent that,
11
+ the canonical internal form is ALWAYS pixel-absolute xyxy. GT is converted to
12
+ canonical at load time; predictions are converted at score time. No metric ever
13
+ sees a non-canonical coordinate (enforced by tests).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+ from typing import Sequence
21
+
22
+
23
+ class CoordSpace(str, Enum):
24
+ """The space a set of raw coordinates lives in."""
25
+ PIXEL_ABS = "pixel_abs" # absolute pixels, image-sized
26
+ NORM_0_1 = "norm_0_1" # 0..1 floats
27
+ NORM_0_1000 = "norm_0_1000" # 0..1000 ints (Qwen3-VL native)
28
+
29
+
30
+ # Box coordinate layouts a model might emit / GT might store.
31
+ XYXY = "xyxy" # [x1, y1, x2, y2]
32
+ XYWH = "xywh" # [x, y, w, h] (COCO GT layout)
33
+
34
+
35
+ def xywh_to_xyxy(box: Sequence[float]) -> list[float]:
36
+ x, y, w, h = box
37
+ return [x, y, x + w, y + h]
38
+
39
+
40
+ def xyxy_to_xywh(box: Sequence[float]) -> list[float]:
41
+ x1, y1, x2, y2 = box
42
+ return [x1, y1, x2 - x1, y2 - y1]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class BBox:
47
+ """A bounding box in the canonical form: pixel-absolute xyxy."""
48
+ x1: float
49
+ y1: float
50
+ x2: float
51
+ y2: float
52
+
53
+ def area(self) -> float:
54
+ return max(0.0, self.x2 - self.x1) * max(0.0, self.y2 - self.y1)
55
+
56
+ def clip(self, size: tuple[int, int]) -> "BBox":
57
+ """Clip to image bounds. size = (W, H)."""
58
+ w, h = size
59
+ return BBox(
60
+ x1=min(max(self.x1, 0.0), w),
61
+ y1=min(max(self.y1, 0.0), h),
62
+ x2=min(max(self.x2, 0.0), w),
63
+ y2=min(max(self.y2, 0.0), h),
64
+ )
65
+
66
+ def iou(self, other: "BBox") -> float:
67
+ ix1 = max(self.x1, other.x1)
68
+ iy1 = max(self.y1, other.y1)
69
+ ix2 = min(self.x2, other.x2)
70
+ iy2 = min(self.y2, other.y2)
71
+ iw = max(0.0, ix2 - ix1)
72
+ ih = max(0.0, iy2 - iy1)
73
+ inter = iw * ih
74
+ if inter <= 0.0:
75
+ return 0.0
76
+ union = self.area() + other.area() - inter
77
+ return inter / union if union > 0 else 0.0
78
+
79
+ def as_list(self) -> list[float]:
80
+ return [self.x1, self.y1, self.x2, self.y2]
81
+
82
+
83
+ def _scale_for_space(space: CoordSpace, size: tuple[int, int]) -> tuple[float, float]:
84
+ """Return (x_scale, y_scale) that maps a raw coord in `space` to pixels."""
85
+ w, h = size
86
+ if space == CoordSpace.PIXEL_ABS:
87
+ return 1.0, 1.0
88
+ if space == CoordSpace.NORM_0_1:
89
+ return float(w), float(h)
90
+ if space == CoordSpace.NORM_0_1000:
91
+ return w / 1000.0, h / 1000.0
92
+ raise ValueError(f"unknown coord space: {space!r}")
93
+
94
+
95
+ def to_canonical(
96
+ raw: Sequence[float],
97
+ space: CoordSpace,
98
+ size: tuple[int, int],
99
+ fmt: str = XYXY,
100
+ ) -> BBox:
101
+ """Convert a raw 4-tuple in `space`/`fmt` to a canonical pixel-abs xyxy BBox.
102
+
103
+ size = (W, H) in pixels. `fmt` is XYXY or XYWH.
104
+ """
105
+ if len(raw) != 4:
106
+ raise ValueError(f"bbox must have 4 values, got {len(raw)}: {raw!r}")
107
+ coords = list(map(float, raw))
108
+ if fmt == XYWH:
109
+ coords = xywh_to_xyxy(coords)
110
+ elif fmt != XYXY:
111
+ raise ValueError(f"unknown bbox fmt: {fmt!r}")
112
+ sx, sy = _scale_for_space(space, size)
113
+ return BBox(coords[0] * sx, coords[1] * sy, coords[2] * sx, coords[3] * sy).clip(size)
114
+
115
+
116
+ def from_canonical(box: BBox, space: CoordSpace, size: tuple[int, int], fmt: str = XYXY) -> list[float]:
117
+ """Inverse of to_canonical: canonical BBox → raw coords in `space`/`fmt`."""
118
+ sx, sy = _scale_for_space(space, size)
119
+ xyxy = [box.x1 / sx, box.y1 / sy, box.x2 / sx, box.y2 / sy]
120
+ return xyxy_to_xywh(xyxy) if fmt == XYWH else xyxy
121
+
122
+
123
+ def detect_space(raw_values: Sequence[float], size: tuple[int, int]) -> CoordSpace:
124
+ """Defensive fallback for models that ignore the requested space.
125
+
126
+ Used ONLY when a model's output space can't be trusted; the caller logs
127
+ `coord_space_inferred=True` (itself a robustness signal). Heuristic:
128
+ * all values <= 1.0 → NORM_0_1
129
+ * all values <= 1000 and the image is larger than 1000 px on a side → NORM_0_1000
130
+ * otherwise → PIXEL_ABS
131
+ """
132
+ vals = [abs(float(v)) for v in raw_values if v is not None]
133
+ if not vals:
134
+ return CoordSpace.PIXEL_ABS
135
+ mx = max(vals)
136
+ w, h = size
137
+ if mx <= 1.0:
138
+ return CoordSpace.NORM_0_1
139
+ if mx <= 1000.0 and max(w, h) > 1000:
140
+ return CoordSpace.NORM_0_1000
141
+ if mx <= 1000.0 and max(w, h) <= 1000:
142
+ # ambiguous: 0..1000 ints vs small-image pixels. Prefer NORM_0_1000 only
143
+ # if values clearly exceed the image dimensions.
144
+ if mx > max(w, h):
145
+ return CoordSpace.NORM_0_1000
146
+ return CoordSpace.PIXEL_ABS
147
+ return CoordSpace.PIXEL_ABS
148
+
149
+
150
+ def prompt_hint_for(space: CoordSpace) -> str:
151
+ """A sentence appended to the system prompt telling the model which space to use."""
152
+ if space == CoordSpace.PIXEL_ABS:
153
+ return "Report bounding boxes as [x1, y1, x2, y2] in absolute pixel coordinates."
154
+ if space == CoordSpace.NORM_0_1:
155
+ return "Report bounding boxes as [x1, y1, x2, y2] normalized to 0..1 of the image dimensions."
156
+ if space == CoordSpace.NORM_0_1000:
157
+ return (
158
+ "Report bounding boxes as [x1, y1, x2, y2] integers in 0..1000, "
159
+ "relative to the image width and height."
160
+ )
161
+ raise ValueError(f"unknown coord space: {space!r}")
qwen_test_runner/vision/datasets.py ADDED
@@ -0,0 +1,928 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ datasets.py — Ground-truth providers.
3
+
4
+ `GTSample` is the uniform shape every loader yields: an image (PIL, or None for
5
+ the torch-free smoke set), the per-category ground truth, and the image size used
6
+ for coordinate normalization. The packaged smoke set runs on CPU with no network
7
+ so tests and `--dataset smoke --runner stub` work offline. Real loaders stream
8
+ public datasets via HF `datasets` (imported lazily) for Phase 1+.
9
+
10
+ GT shapes (per category):
11
+ image_classification : {"labels": [acceptable label strings]}
12
+ bbox_grounding : {"boxes": [{"label": str, "bbox": [x,y,w,h], "fmt": "xywh"}]}
13
+ ocr_text : {"text": "the reference transcription / answer"}
14
+ (stub categories) : None (no GT wired yet)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import itertools
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Callable, Optional
22
+
23
+
24
+ @dataclass
25
+ class GTSample:
26
+ image: Any # PIL.Image or None (smoke / stub)
27
+ prompt: str
28
+ gt: Any
29
+ category: str
30
+ image_id: str
31
+ size: tuple[int, int] # (W, H) for coordinate normalization
32
+ meta: dict = field(default_factory=dict)
33
+
34
+
35
+ # ──────────────────────────────────────────────────────────────────────────────
36
+ # Packaged CPU smoke set (no network, no torch). Small but exercises every shape.
37
+ # ──────────────────────────────────────────────────────────────────────────────
38
+
39
+ _SMOKE: dict[str, list[GTSample]] = {
40
+ "image_classification": [
41
+ GTSample(None, "Classify this image.", {"labels": ["golden retriever", "dog"]},
42
+ "image_classification", "smk_cls_0", (640, 480)),
43
+ GTSample(None, "Classify this image.", {"labels": ["espresso", "coffee"]},
44
+ "image_classification", "smk_cls_1", (512, 512)),
45
+ GTSample(None, "Classify this image.", {"labels": ["school bus", "bus"]},
46
+ "image_classification", "smk_cls_2", (800, 600)),
47
+ ],
48
+ "bbox_grounding": [
49
+ GTSample(None, "Detect all objects.",
50
+ {"boxes": [{"label": "dog", "bbox": [64, 48, 128, 96], "fmt": "xywh"}]},
51
+ "bbox_grounding", "smk_box_0", (640, 480)),
52
+ GTSample(None, "Detect all objects.",
53
+ {"boxes": [{"label": "cat", "bbox": [10, 10, 40, 40], "fmt": "xywh"},
54
+ {"label": "ball", "bbox": [200, 150, 50, 50], "fmt": "xywh"}]},
55
+ "bbox_grounding", "smk_box_1", (640, 480)),
56
+ ],
57
+ "ocr_text": [
58
+ GTSample(None, "Read all text.", {"text": "STOP"}, "ocr_text", "smk_ocr_0", (200, 200)),
59
+ GTSample(None, "Read all text.", {"text": "no entry"}, "ocr_text", "smk_ocr_1", (300, 200)),
60
+ ],
61
+ }
62
+
63
+
64
+ def smoke_samples(category: str, n: Optional[int] = None) -> list[GTSample]:
65
+ """Smoke samples for a category. Stub categories (no GT) get synthetic blanks."""
66
+ if category in _SMOKE:
67
+ out = _SMOKE[category]
68
+ else:
69
+ out = [GTSample(None, "Analyze this image.", None, category, f"smk_{category}_{i}", (64, 64))
70
+ for i in range(2)]
71
+ return out[:n] if n else list(out)
72
+
73
+
74
+ # ──────────────────────────────────────────────────────────────────────────────
75
+ # Real loaders (Phase 1+). HF `datasets` is imported lazily so the smoke path and
76
+ # the CPU tests never require it.
77
+ # ──────────────────────────────────────────────────────────────────────────────
78
+
79
+ def _hf_stream(repo: str, split: str, n: int, **kw):
80
+ from datasets import load_dataset # lazy
81
+ ds = load_dataset(repo, split=split, streaming=True, **kw)
82
+ return list(itertools.islice(ds, n))
83
+
84
+
85
+ def load_imagenet_val(n: int = 200, split: str = "validation") -> list[GTSample]:
86
+ """Classification GT. ImageNet-1k is GATED and its label is a bare integer id;
87
+ use food101 (ungated parquet) and map the ClassLabel id -> class name."""
88
+ from datasets import load_dataset # lazy
89
+ last = None
90
+ for repo, sp in [("ethz/food101", "validation"), ("food101", "validation")]:
91
+ try:
92
+ ds = load_dataset(repo, split=sp, streaming=True)
93
+ try:
94
+ names = ds.features["label"].names
95
+ except Exception:
96
+ names = None
97
+ rows = list(itertools.islice(ds, n))
98
+ out = []
99
+ for i, r in enumerate(rows):
100
+ img = r.get("image")
101
+ lbl = r.get("label")
102
+ name = (names[lbl].replace("_", " ")
103
+ if names and isinstance(lbl, int) and 0 <= lbl < len(names) else str(lbl))
104
+ size = (img.width, img.height) if img is not None else (0, 0)
105
+ out.append(GTSample(img, "Classify this image.", {"labels": [name]},
106
+ "image_classification", f"cls_{i}", size))
107
+ if out:
108
+ return out
109
+ except Exception as e:
110
+ last = e
111
+ continue
112
+ raise RuntimeError(f"no classification dataset streamable (food101): {last}")
113
+
114
+
115
+ # COCO-80 class names in category-id order (confirmed from detection-datasets/coco
116
+ # ClassLabel features). objects.bbox is [x1,y1,x2,y2] in absolute pixels (xyxy).
117
+ COCO_CLASSES = [
118
+ "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat",
119
+ "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat",
120
+ "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack",
121
+ "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball",
122
+ "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket",
123
+ "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple",
124
+ "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
125
+ "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse",
126
+ "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink", "refrigerator",
127
+ "book", "clock", "vase", "scissors", "teddy bear", "hair drier", "toothbrush",
128
+ ]
129
+
130
+
131
+ def _coco_label(cid) -> str:
132
+ if isinstance(cid, int) and 0 <= cid < len(COCO_CLASSES):
133
+ return COCO_CLASSES[cid]
134
+ return str(cid)
135
+
136
+
137
+ def load_coco_detection(n: int = 200, split: str = "val") -> list[GTSample]:
138
+ """detection-datasets/coco: objects.bbox is xyxy pixels; category is a ClassLabel id."""
139
+ rows = _hf_stream("detection-datasets/coco", split, n)
140
+ out = []
141
+ for i, r in enumerate(rows):
142
+ img = r["image"]
143
+ objs = r.get("objects", {})
144
+ boxes = [{"label": _coco_label(c), "bbox": list(map(float, b)), "fmt": "xyxy"}
145
+ for c, b in zip(objs.get("category", []), objs.get("bbox", []))]
146
+ out.append(GTSample(img, "Detect all objects in this image. Output only the raw JSON object.",
147
+ {"boxes": boxes}, "bbox_grounding", f"coco_{i}", (img.width, img.height)))
148
+ return out
149
+
150
+
151
+ def load_textvqa(n: int = 200, split: str = "validation") -> list[GTSample]:
152
+ """OCR GT. The script-based 'textvqa' repo is rejected by modern `datasets`; use
153
+ parquet repos. GT = {"text": <gold answer>}; the model transcribes the image and
154
+ the OCR scorer credits containment of the answer."""
155
+ from datasets import load_dataset # lazy
156
+ last = None
157
+ for repo, sp in [("lmms-lab/textvqa", "validation"), ("howard-hou/OCR-VQA", "test")]:
158
+ try:
159
+ ds = load_dataset(repo, split=sp, streaming=True)
160
+ rows = list(itertools.islice(ds, n))
161
+ out = []
162
+ for i, r in enumerate(rows):
163
+ img = r.get("image")
164
+ ans = r.get("answers")
165
+ if ans is None:
166
+ ans = r.get("answer") or r.get("questions")
167
+ if isinstance(ans, (list, tuple)):
168
+ ans = next((str(a) for a in ans if str(a).strip()), "")
169
+ size = (img.width, img.height) if img is not None else (0, 0)
170
+ out.append(GTSample(img, "Read all the text in this image.",
171
+ {"text": str(ans or "")}, "ocr_text", f"ocr_{i}", size))
172
+ if out:
173
+ return out
174
+ except Exception as e:
175
+ last = e
176
+ continue
177
+ raise RuntimeError(f"no OCR dataset streamable (textvqa/ocr-vqa): {last}")
178
+
179
+
180
+ # ──────────────────────────────────────────────────────────────────────────────
181
+ # Synthetic data-format images (self-contained: no external dataset). Renders a
182
+ # small record in several serialization formats to an image, with exact GT for
183
+ # both the format (data_type) and the normalized content. Tests whether a VLM can
184
+ # recognize a data format from a screenshot and re-serialize it to JSON.
185
+ # ──────────────────────────────────────────────────────────────────────────────
186
+
187
+ _DATATYPE_RECORDS = [
188
+ {"name": "Alice", "age": "30", "city": "Paris"},
189
+ {"id": "7", "title": "Widget", "price": "9"},
190
+ {"user": "bob", "active": "true", "score": "42"},
191
+ {"country": "Japan", "capital": "Tokyo", "pop": "14"},
192
+ ]
193
+
194
+
195
+ def _datatype_font(sz=22):
196
+ from PIL import ImageFont
197
+ for name in ("DejaVuSansMono.ttf", "DejaVuSans.ttf"):
198
+ try:
199
+ return ImageFont.truetype(name, sz)
200
+ except Exception:
201
+ continue
202
+ try:
203
+ return ImageFont.load_default(size=sz) # Pillow >= 10
204
+ except Exception:
205
+ return ImageFont.load_default()
206
+
207
+
208
+ def _render_text_image(text: str, size=(640, 360)) -> "object":
209
+ from PIL import Image, ImageDraw
210
+ img = Image.new("RGB", size, (255, 255, 255))
211
+ d = ImageDraw.Draw(img)
212
+ d.multiline_text((18, 18), text, fill=(0, 0, 0), font=_datatype_font(22), spacing=8)
213
+ return img
214
+
215
+
216
+ def _serialize(rec: dict, fmt: str) -> str:
217
+ if fmt == "json":
218
+ import json as _j
219
+ return _j.dumps(rec, indent=2)
220
+ if fmt == "yaml":
221
+ return "\n".join(f"{k}: {v}" for k, v in rec.items())
222
+ if fmt == "toml":
223
+ return "\n".join(f'{k} = "{v}"' for k, v in rec.items())
224
+ if fmt == "xml":
225
+ inner = "".join(f"<{k}>{v}</{k}>" for k, v in rec.items())
226
+ return f"<record>{inner}</record>"
227
+ if fmt == "csv":
228
+ return ",".join(rec.keys()) + "\n" + ",".join(rec.values())
229
+ if fmt == "markdown":
230
+ return "# Record\n" + "\n".join(f"- **{k}**: {v}" for k, v in rec.items())
231
+ raise ValueError(fmt)
232
+
233
+
234
+ _DATATYPE_FORMATS = ["json", "yaml", "toml", "xml", "csv", "markdown"]
235
+
236
+
237
+ def make_datatype_samples(n: int = 18, split=None) -> list[GTSample]:
238
+ """Self-contained: render records across formats. GT = {data_type, content}."""
239
+ out = []
240
+ i = 0
241
+ while len(out) < n:
242
+ rec = _DATATYPE_RECORDS[i % len(_DATATYPE_RECORDS)]
243
+ fmt = _DATATYPE_FORMATS[i % len(_DATATYPE_FORMATS)]
244
+ text = _serialize(rec, fmt)
245
+ # csv normalizes to a one-row list; everything else to the dict
246
+ content = [rec] if fmt == "csv" else rec
247
+ img = _render_text_image(text)
248
+ out.append(GTSample(img, "Identify the data format and contents. Output only raw JSON.",
249
+ {"data_type": fmt, "content": content},
250
+ "data_type", f"dt_{i}", img.size))
251
+ i += 1
252
+ return out
253
+
254
+
255
+ # ──────────────────────────────────────────────────────────────────────────────
256
+ # Synthetic colored-shapes scenes (self-contained). One scene yields exact GT for
257
+ # spatial relations (by x-order), depth ordering (bigger circle = nearer), and
258
+ # subject fixation (largest circle = primary subject). Reliable + no download.
259
+ # ──────────────────────────────────────────────────────────────────────────────
260
+
261
+ import itertools as _it
262
+
263
+ _SHAPE_COLORS = {"red": (220, 30, 30), "green": (30, 170, 30), "blue": (40, 40, 220)}
264
+ _SHAPE_NAMES = ["red", "green", "blue"]
265
+ _SHAPE_SIZES = [110, 76, 46] # diameters: big / medium / small (depth cue)
266
+
267
+
268
+ def _shape_scene(i: int):
269
+ """Deterministic 3-circle scene. Returns ((W,H), [shape dicts]) sorted left→right."""
270
+ W, H = 540, 320
271
+ x_centers = [110, 270, 430]
272
+ color_perm = list(_it.permutations(range(3)))[i % 6] # which color in which column
273
+ size_perm = list(_it.permutations(range(3)))[(i // 6) % 6] # which color gets which size
274
+ shapes = []
275
+ for ci, color in enumerate(_SHAPE_NAMES):
276
+ cx = x_centers[color_perm[ci]]
277
+ d = _SHAPE_SIZES[size_perm[ci]]
278
+ cy = H // 2
279
+ shapes.append({"label": color, "cx": cx, "cy": cy, "d": d, "area": d * d,
280
+ "bbox": [cx - d / 2, cy - d / 2, cx + d / 2, cy + d / 2]})
281
+ shapes.sort(key=lambda s: s["cx"])
282
+ return (W, H), shapes
283
+
284
+
285
+ def _render_scene(size, shapes):
286
+ from PIL import Image, ImageDraw
287
+ img = Image.new("RGB", size, (245, 245, 245))
288
+ d = ImageDraw.Draw(img)
289
+ for s in shapes:
290
+ d.ellipse(s["bbox"], fill=_SHAPE_COLORS[s["label"]])
291
+ return img
292
+
293
+
294
+ def make_shapes_samples(n: int = 12, split=None) -> list[GTSample]:
295
+ """Scenes carrying GT for spatial / depth / subject_fixation simultaneously."""
296
+ out = []
297
+ for i in range(n):
298
+ size, shapes = _shape_scene(i)
299
+ # spatial: left_of for every left→right pair
300
+ triples = []
301
+ for a, b in _it.combinations(shapes, 2): # already x-sorted → a left of b
302
+ triples.append([a["label"], "left_of", b["label"]])
303
+ # depth: bigger area = nearer
304
+ pairs = []
305
+ for a, b in _it.combinations(shapes, 2):
306
+ pairs.append({"a": a["label"], "b": b["label"],
307
+ "a_is": "nearer" if a["area"] >= b["area"] else "farther"})
308
+ # subject: largest area
309
+ subj = max(shapes, key=lambda s: s["area"])
310
+ gt = {"triples": triples, "pairs": pairs,
311
+ "label": subj["label"], "box": subj["bbox"], "fmt": "xyxy"}
312
+ img = _render_scene(size, shapes)
313
+ out.append(GTSample(img, "Analyze the colored shapes. Output only raw JSON.",
314
+ gt, "shapes", f"shapes_{i}", size))
315
+ return out
316
+
317
+
318
+ def _circle_polygon(cx, cy, d, n=16):
319
+ """Approximate a circle (diameter d, center cx,cy) as a flat pixel-coord
320
+ polygon [x1,y1,x2,y2,...] with n vertices."""
321
+ import math
322
+ r = d / 2.0
323
+ flat = []
324
+ for k in range(n):
325
+ ang = 2.0 * math.pi * k / n
326
+ flat.append(cx + r * math.cos(ang))
327
+ flat.append(cy + r * math.sin(ang))
328
+ return flat
329
+
330
+
331
+ def make_segmentation_samples(n: int = 12, split=None) -> list[GTSample]:
332
+ """Self-contained instance-segmentation GT: reuse the 3-circle shape scenes.
333
+ Each colored circle becomes one mask whose polygon is the circle approximated
334
+ by 16 vertices (label = color). Polygons are in PIXEL coords; the scorer
335
+ converts model polygons from NORM_0_1000 to pixels."""
336
+ out = []
337
+ for i in range(n):
338
+ size, shapes = _shape_scene(i)
339
+ masks = [{"label": s["label"],
340
+ "polygon_pixels": _circle_polygon(s["cx"], s["cy"], s["d"], n=16)}
341
+ for s in shapes]
342
+ img = _render_scene(size, shapes)
343
+ out.append(GTSample(img, "Segment the colored shapes as labeled polygons. Output only raw JSON.",
344
+ {"masks": masks}, "segmentation", f"seg_{i}", size))
345
+ return out
346
+
347
+
348
+ def make_outline_samples(n: int = 12, split=None) -> list[GTSample]:
349
+ """Self-contained: reuse the 3-circle synthetic scene. GT outline = the largest
350
+ circle approximated as a 16-point polygon (pixels), label = its color."""
351
+ out = []
352
+ for i in range(n):
353
+ size, shapes = _shape_scene(i)
354
+ main = max(shapes, key=lambda s: s["area"]) # largest = main object
355
+ poly = _circle_polygon(main["cx"], main["cy"], main["d"], 16)
356
+ gt = {"outline": poly, "label": main["label"], "bbox": main["bbox"], "fmt": "xyxy"}
357
+ img = _render_scene(size, shapes)
358
+ out.append(GTSample(img, "Trace the main object's outline. Output only raw JSON.",
359
+ gt, "outline_association", f"outline_{i}", size))
360
+ return out
361
+
362
+
363
+ _BOX3D_COLORS = {"red": (220, 40, 40), "green": (40, 175, 40), "blue": (50, 50, 225)}
364
+ _BOX3D_NAMES = ["red", "green", "blue"]
365
+
366
+
367
+ def _box3d_scene(i: int):
368
+ """Deterministic 2-3 colored boxes at known ground (x,z) positions.
369
+
370
+ GT convention (normalized 0..1): bbox3d = [x, y, z, w, h, l, yaw] with
371
+ x = left-right ground position, z = depth (0 near .. 1 far), y = 0 (on the
372
+ floor), (w,h,l) the box footprint width / height / length, yaw = 0. The GT is
373
+ exact-by-construction; the render is a simplified ground-plane 3D proxy.
374
+ """
375
+ import math
376
+ import itertools
377
+ W, H = 480, 360
378
+ n_boxes = 2 + (i % 2) # 2 or 3 boxes
379
+ names = _BOX3D_NAMES[:n_boxes]
380
+ perm = list(itertools.permutations(range(n_boxes)))[i % math.factorial(n_boxes)]
381
+ x_slots = [0.2, 0.5, 0.8][:n_boxes]
382
+ z_slots = [0.25, 0.55, 0.85][:n_boxes]
383
+ objects, draw = [], []
384
+ for k, color in enumerate(names):
385
+ x = x_slots[perm[k] % n_boxes]
386
+ z = z_slots[k] # increasing depth per index
387
+ w = 0.16 + 0.04 * ((i + k) % 3) # footprint width
388
+ l = 0.14
389
+ h = 0.22 + 0.03 * (k % 2) # box height
390
+ objects.append({"class": color,
391
+ "bbox3d": [round(x, 4), 0.0, round(z, 4),
392
+ round(w, 4), round(h, 4), round(l, 4), 0.0]})
393
+ draw.append((color, x, z, w, h))
394
+ return (W, H), objects, draw
395
+
396
+
397
+ def _render_box3d_scene(size, draw):
398
+ """Perspective proxy: nearer (small z) boxes drawn lower in frame and larger."""
399
+ from PIL import Image, ImageDraw
400
+ W, H = size
401
+ img = Image.new("RGB", size, (235, 235, 240))
402
+ d = ImageDraw.Draw(img)
403
+ d.rectangle([0, int(H * 0.5), W, H], fill=(205, 200, 190)) # ground band
404
+ for color, x, z, w, h in sorted(draw, key=lambda t: t[2], reverse=True): # far first
405
+ scale = 1.0 - 0.45 * z # nearer = bigger
406
+ bw = w * W * scale
407
+ bh = h * H * scale
408
+ cx = x * W
409
+ cy = (0.5 + 0.45 * z) * H # nearer = lower
410
+ d.rectangle([cx - bw / 2, cy - bh, cx + bw / 2, cy],
411
+ fill=_BOX3D_COLORS[color], outline=(20, 20, 20))
412
+ return img
413
+
414
+
415
+ def make_3d_samples(n: int = 12, split=None) -> list[GTSample]:
416
+ """Self-contained synthetic 3D scenes. GT exact-by-construction (proxy)."""
417
+ out = []
418
+ for i in range(n):
419
+ size, objects, draw = _box3d_scene(i)
420
+ img = _render_box3d_scene(size, draw)
421
+ out.append(GTSample(img, "Identify the 3D boxes. Output only raw JSON.",
422
+ {"objects": objects}, "geometric_3d_object_id",
423
+ f"box3d_{i}", size))
424
+ return out
425
+
426
+
427
+ def make_camera_samples(n: int = 12, split=None) -> list[GTSample]:
428
+ """Self-contained synthetic camera-roll set. A clear orientation cue (an upward
429
+ arrow over a horizon line) is drawn upright, then the whole image is rotated by a
430
+ KNOWN roll angle that varies by index; yaw=pitch=0 (a single 2D cue cannot
431
+ disambiguate yaw/pitch). GT = {"rotation": [0, 0, roll_deg]}.
432
+
433
+ NOTE: SIMPLIFIED proxy — this tests recovery of ROLL from a 2D cue only; it does
434
+ not exercise yaw/pitch (which would need a 3D scene). Reliable, no download.
435
+ """
436
+ from PIL import Image, ImageDraw
437
+
438
+ W, H = 480, 480
439
+ cx, cy = W / 2.0, H / 2.0
440
+ # deterministic spread of rolls across the wrapped range, indexed by sample
441
+ roll_table = [0, 15, 30, 45, 60, 90, -15, -30, -45, -60, -90, 120,
442
+ -120, 150, 75, -75, 10, -10]
443
+ out = []
444
+ for i in range(n):
445
+ roll = float(roll_table[i % len(roll_table)])
446
+ base = Image.new("RGB", (W, H), (250, 250, 250))
447
+ d = ImageDraw.Draw(base)
448
+ d.line([(60, cy), (W - 60, cy)], fill=(60, 60, 60), width=6) # horizon line
449
+ d.line([(cx, cy), (cx, 90)], fill=(200, 40, 40), width=8) # arrow shaft (points up)
450
+ d.polygon([(cx, 60), (cx - 22, 105), (cx + 22, 105)], fill=(200, 40, 40)) # arrow head
451
+ # Rotate scene by -roll about the centre (expand=False keeps size + GT stable):
452
+ # a positive camera roll (CW) rotates scene content CCW in the image.
453
+ img = base.rotate(-roll, resample=Image.BICUBIC, center=(cx, cy),
454
+ fillcolor=(250, 250, 250), expand=False)
455
+ gt = {"rotation": [0.0, 0.0, roll]}
456
+ out.append(GTSample(img,
457
+ "Estimate the camera rotation [yaw, pitch, roll]. Output only raw JSON.",
458
+ gt, "camera_rotational_offset", f"camrot_{i}", (W, H)))
459
+ return out
460
+
461
+
462
+ def make_gqa_samples(n: int = 200, split: str = "validation") -> list[GTSample]:
463
+ """Grounded-VQA GT (REAL, best-effort). Streams a VQA dataset; one sample per
464
+ (image, question, answers). The question is per-image and goes in
465
+ GTSample.prompt; gt = {"answers": [<gold strings>]}. Image is row["image"]
466
+ (a PIL image).
467
+
468
+ Repo ids are BEST-EFFORT — the maintainer must verify id/config/split:
469
+ primary : "lmms-lab/GQA" (testdev_balanced / val splits; row has
470
+ "question" + "answer"; image under "image")
471
+ fallback: "HuggingFaceM4/VQAv2" (row has "question" + "answers"
472
+ list-of-dicts or list-of-strings)
473
+ The answer-field probing below tolerates both shapes.
474
+ """
475
+ # Script-based repos (HuggingFaceM4/VQAv2, lmms-lab/GQA) are rejected by modern
476
+ # `datasets`. Use PARQUET repos (verified format:parquet on the Hub), in order.
477
+ rows = None
478
+ for repo, sp in [("lmms-lab/VQAv2", split), ("merve/vqav2-small", "validation"),
479
+ ("merve/vqav2-small", "train"), ("lmms-lab/OK-VQA", "val2014")]:
480
+ try:
481
+ rows = _hf_stream(repo, sp, n)
482
+ if rows:
483
+ break
484
+ except Exception:
485
+ continue
486
+ if not rows:
487
+ raise RuntimeError("no parquet VQA dataset streamable "
488
+ "(tried lmms-lab/VQAv2, merve/vqav2-small, lmms-lab/OK-VQA)")
489
+ out = []
490
+ for i, r in enumerate(rows):
491
+ img = r.get("image")
492
+ question = str(r.get("question") or r.get("question_str") or "What is in this image?")
493
+ raw_ans = r.get("answers")
494
+ if raw_ans is None:
495
+ raw_ans = r.get("multiple_choice_answer") or r.get("answer")
496
+ if isinstance(raw_ans, dict): # {"answer": "x"} or value-map
497
+ raw_ans = raw_ans.get("answer") or list(raw_ans.values())
498
+ if isinstance(raw_ans, (list, tuple)):
499
+ answers = []
500
+ for a in raw_ans:
501
+ if isinstance(a, dict): # VQAv2: [{"answer": "x"}, ...]
502
+ a = a.get("answer", "")
503
+ if str(a).strip():
504
+ answers.append(str(a))
505
+ elif raw_ans is not None and str(raw_ans).strip():
506
+ answers = [str(raw_ans)]
507
+ else:
508
+ answers = []
509
+ size = (img.width, img.height) if img is not None else (0, 0)
510
+ out.append(GTSample(img, question, {"answers": answers},
511
+ "vit_accuracy_to_prompt", f"vqa_{i}", size))
512
+ return out
513
+
514
+
515
+ def make_semantic_samples(n: int = 12, split=None) -> list[GTSample]:
516
+ """Self-contained colored-shapes scenes carrying GT semantic-association triples.
517
+
518
+ Reuses the deterministic 3-circle scene (`_shape_scene`). Associations are
519
+ derived purely from geometry so they are exact and reproducible:
520
+ * left->right ordering -> (left, "left_of", right) AND (right, "right_of", left)
521
+ * adjacency (consecutive) -> (a, "near", b) for neighbouring shapes
522
+ * taxonomy -> (color, "is_a", "circle") for every shape
523
+ GT shape: {"triples": [[a, relation, b], ...]} -- read directly by score_triples,
524
+ which does tolerant subject/object matching + normalized-exact predicate matching.
525
+ Relations are chosen so they round-trip cleanly through metrics._norm_pred
526
+ (left_of/right_of/near/is_a stay identical after normalization).
527
+ """
528
+ out = []
529
+ for i in range(n):
530
+ size, shapes = _shape_scene(i) # sorted left->right
531
+ triples: list[list] = []
532
+ # ordering relations over every left->right pair (both directions)
533
+ for a, b in _it.combinations(shapes, 2): # a is left of b
534
+ triples.append([a["label"], "left_of", b["label"]])
535
+ triples.append([b["label"], "right_of", a["label"]])
536
+ # adjacency ("near") for consecutive shapes in the x-ordering
537
+ for a, b in zip(shapes, shapes[1:]):
538
+ triples.append([a["label"], "near", b["label"]])
539
+ # taxonomic: each colored shape is a circle
540
+ for s in shapes:
541
+ triples.append([s["label"], "is_a", "circle"])
542
+ img = _render_scene(size, shapes)
543
+ out.append(GTSample(
544
+ img,
545
+ "List semantic associations between the shapes. Output only raw JSON.",
546
+ {"triples": triples}, "semantic_association", f"semassoc_{i}", size,
547
+ ))
548
+ return out
549
+
550
+
551
+ def _style_font(sz=28):
552
+ from PIL import ImageFont
553
+ for name in ("DejaVuSans.ttf", "DejaVuSansMono.ttf"):
554
+ try:
555
+ return ImageFont.truetype(name, sz)
556
+ except Exception:
557
+ continue
558
+ try:
559
+ return ImageFont.load_default(size=sz) # Pillow >= 10
560
+ except Exception:
561
+ return ImageFont.load_default()
562
+
563
+
564
+ def _render_style_image(style: str, size=(320, 320)):
565
+ """Render a controllable, visually-distinguishable exemplar for each coarse style.
566
+ photo: smooth RGB gradient (photographic continuous tone). painting: soft color blobs
567
+ on canvas. sketch: black outlines on white. 3d_render: lit/shaded sphere. anime:
568
+ flat-shaded face with big eyes. other: a labelled fallback."""
569
+ from PIL import Image, ImageDraw
570
+ import math
571
+ W, H = size
572
+ cx, cy = W // 2, H // 2
573
+
574
+ if style == "photo":
575
+ img = Image.new("RGB", size, (0, 0, 0))
576
+ px = img.load()
577
+ for y in range(H):
578
+ for x in range(W):
579
+ px[x, y] = (int(40 + 180 * x / W), int(40 + 180 * y / H),
580
+ int(120 + 100 * ((x + y) % 50) / 50))
581
+ return img
582
+
583
+ if style == "painting":
584
+ img = Image.new("RGB", size, (235, 225, 205))
585
+ d = ImageDraw.Draw(img)
586
+ for (bx, by), r, col in [((90, 90), 70, (200, 70, 60)),
587
+ ((210, 120), 60, (70, 110, 190)),
588
+ ((140, 220), 80, (90, 170, 90))]:
589
+ d.ellipse([bx - r, by - r, bx + r, by + r], fill=col)
590
+ return img
591
+
592
+ if style == "sketch":
593
+ img = Image.new("RGB", size, (255, 255, 255))
594
+ d = ImageDraw.Draw(img)
595
+ d.rectangle([cx - 70, cy - 70, cx + 70, cy + 70], outline=(0, 0, 0), width=3)
596
+ d.line([cx - 70, cy - 70, cx + 70, cy + 70], fill=(0, 0, 0), width=2)
597
+ d.line([cx + 70, cy - 70, cx - 70, cy + 70], fill=(0, 0, 0), width=2)
598
+ d.ellipse([cx - 40, cy - 40, cx + 40, cy + 40], outline=(0, 0, 0), width=2)
599
+ return img
600
+
601
+ if style == "3d_render":
602
+ img = Image.new("RGB", size, (245, 245, 250))
603
+ d = ImageDraw.Draw(img)
604
+ r = 90
605
+ for yy in range(cy - r, cy + r):
606
+ for xx in range(cx - r, cx + r):
607
+ dx, dy = (xx - cx) / r, (yy - cy) / r
608
+ if dx * dx + dy * dy <= 1.0:
609
+ lx, ly = -0.5, -0.6
610
+ nz = math.sqrt(max(0.0, 1.0 - dx * dx - dy * dy))
611
+ shade = max(0.12, (-dx * lx - dy * ly + nz) / 1.7)
612
+ v = int(60 + 195 * min(1.0, shade))
613
+ d.point((xx, yy), fill=(v, int(v * 0.7), int(v * 0.5)))
614
+ return img
615
+
616
+ if style == "anime":
617
+ img = Image.new("RGB", size, (250, 240, 230))
618
+ d = ImageDraw.Draw(img)
619
+ d.ellipse([cx - 80, cy - 90, cx + 80, cy + 70], fill=(255, 224, 196),
620
+ outline=(40, 30, 30), width=3)
621
+ for ex in (cx - 35, cx + 35):
622
+ d.ellipse([ex - 18, cy - 10, ex + 18, cy + 30], fill=(255, 255, 255),
623
+ outline=(20, 20, 20), width=2)
624
+ d.ellipse([ex - 10, cy + 2, ex + 10, cy + 26], fill=(60, 110, 200))
625
+ d.ellipse([ex - 4, cy + 6, ex + 4, cy + 16], fill=(20, 20, 20))
626
+ d.polygon([(cx - 90, cy - 90), (cx - 30, cy - 110), (cx, cy - 80)], fill=(90, 60, 40))
627
+ return img
628
+
629
+ # "other" fallback
630
+ img = Image.new("RGB", size, (200, 200, 200))
631
+ ImageDraw.Draw(img).text((20, H // 2), "other", fill=(0, 0, 0), font=_style_font(28))
632
+ return img
633
+
634
+
635
+ # Each rendered style implies a controlled (layout, symmetry) GT pair.
636
+ _STYLE_LAYOUTS = {
637
+ "photo": ("rule_of_thirds", "none"),
638
+ "painting": ("scattered", "none"),
639
+ "sketch": ("centered", "radial"),
640
+ "3d_render": ("centered", "radial"),
641
+ "anime": ("centered", "vertical"),
642
+ "other": ("centered", "none"),
643
+ }
644
+ _STYLE_ORDER = ["photo", "painting", "sketch", "3d_render", "anime"]
645
+
646
+
647
+ def make_style_samples(n: int = 10, split=None) -> list[GTSample]:
648
+ """Self-contained: render distinguishable styles we control. Cycles through
649
+ photo/painting/sketch/3d_render/anime. GT = {style, layout, symmetry}."""
650
+ out = []
651
+ for i in range(n):
652
+ style = _STYLE_ORDER[i % len(_STYLE_ORDER)]
653
+ layout, symmetry = _STYLE_LAYOUTS[style]
654
+ size = (320, 320)
655
+ img = _render_style_image(style, size)
656
+ gt = {"style": style, "layout": layout, "symmetry": symmetry}
657
+ out.append(GTSample(img, "Classify the visual style and structure. Output only raw JSON.",
658
+ gt, "style_structural_awareness", f"style_{i}", size))
659
+ return out
660
+
661
+
662
+ # ──────────────────────────────────────────────────────────────────────────────
663
+ # REAL COCO instance segmentation GT (for segmentation / outline / subject) — parses
664
+ # the official COCO annotations JSON directly (no script-dataset, no pycocotools) and
665
+ # pulls images by URL. Replaces the synthetic colored-shape GT with real images.
666
+ # ──────────────────────────────────────────────────────────────────────────────
667
+ _COCO_CACHE: dict = {}
668
+ _COCO_PERSON_CAT = 1 # COCO category_id for "person"
669
+
670
+
671
+ def _coco_ann_file(name: str) -> str:
672
+ """Ensure `{cache_dir}/{name}` exists. ONE zip download extracts BOTH
673
+ instances_val2017.json and captions_val2017.json (they ship in the same
674
+ annotations_trainval2017.zip — extracting only one wastes the 241MB fetch)."""
675
+ import io
676
+ import os
677
+ import urllib.request
678
+ import zipfile
679
+
680
+ cache_dir = os.environ.get("HF_HOME") or os.environ.get("TMPDIR") or "/tmp"
681
+ os.makedirs(cache_dir, exist_ok=True)
682
+ path = os.path.join(cache_dir, name)
683
+ if not os.path.exists(path):
684
+ print(f" downloading COCO val2017 annotations (~241MB, one-time) for {name} …")
685
+ zurl = "http://images.cocodataset.org/annotations/annotations_trainval2017.zip"
686
+ zb = urllib.request.urlopen(zurl, timeout=600).read()
687
+ with zipfile.ZipFile(io.BytesIO(zb)) as z:
688
+ for member in ("instances_val2017.json", "captions_val2017.json"):
689
+ target = os.path.join(cache_dir, member)
690
+ if not os.path.exists(target):
691
+ with z.open(f"annotations/{member}") as f:
692
+ open(target, "wb").write(f.read())
693
+ return path
694
+
695
+
696
+ def _coco_ann(kind: str = "instances") -> dict:
697
+ """Parsed-JSON cache for the COCO annotation files. `kind` is "instances" or
698
+ "captions". Keeps the existing _COCO_CACHE["ann"] key for instances."""
699
+ import json as _json
700
+
701
+ key = "ann" if kind == "instances" else f"ann_{kind}"
702
+ if key not in _COCO_CACHE:
703
+ with open(_coco_ann_file(f"{kind}_val2017.json"), encoding="utf-8") as f:
704
+ _COCO_CACHE[key] = _json.load(f)
705
+ return _COCO_CACHE[key]
706
+
707
+
708
+ def _coco_instances(n: int) -> list:
709
+ """Returns [(image, (W,H), image_id, [{label, polygon_pixels, box_xyxy, area}])].
710
+ Downloads + caches instances_val2017.json (~one-time) and the first `n` val images."""
711
+ import io
712
+ import urllib.request
713
+ from collections import defaultdict
714
+ from PIL import Image
715
+
716
+ key = f"inst_{n}"
717
+ if key in _COCO_CACHE:
718
+ return _COCO_CACHE[key]
719
+ data = _coco_ann("instances")
720
+ cats = {c["id"]: c["name"] for c in data["categories"]}
721
+ imgs = {im["id"]: im for im in data["images"]}
722
+ anns = defaultdict(list)
723
+ for a in data["annotations"]:
724
+ anns[a["image_id"]].append(a)
725
+
726
+ out = []
727
+ for iid in list(imgs):
728
+ if len(out) >= n:
729
+ break
730
+ info = imgs[iid]
731
+ try:
732
+ raw = urllib.request.urlopen(
733
+ f"http://images.cocodataset.org/val2017/{info['file_name']}", timeout=60).read()
734
+ img = Image.open(io.BytesIO(raw)).convert("RGB")
735
+ except Exception:
736
+ continue
737
+ objs = []
738
+ for a in anns[iid]:
739
+ seg = a.get("segmentation")
740
+ if a.get("iscrowd") or not isinstance(seg, list) or not seg:
741
+ continue # skip RLE / crowd
742
+ poly = [float(v) for v in seg[0]]
743
+ if len(poly) < 6:
744
+ continue
745
+ x, y, w, h = a["bbox"]
746
+ objs.append({"label": cats.get(a["category_id"], "object"),
747
+ "polygon_pixels": poly, "box_xyxy": [x, y, x + w, y + h],
748
+ "area": float(a.get("area", w * h))})
749
+ if objs:
750
+ out.append((img, (img.width, img.height), f"coco_{iid}", objs))
751
+ _COCO_CACHE[key] = out
752
+ return out
753
+
754
+
755
+ def load_coco_segmentation(n: int = 24, split=None) -> list[GTSample]:
756
+ return [GTSample(img, "Segment every object as a labeled polygon.",
757
+ {"masks": [{"label": o["label"], "polygon_pixels": o["polygon_pixels"]}
758
+ for o in objs]},
759
+ "segmentation", iid, size)
760
+ for (img, size, iid, objs) in _coco_instances(n)]
761
+
762
+
763
+ def load_coco_outline(n: int = 24, split=None) -> list[GTSample]:
764
+ out = []
765
+ for (img, size, iid, objs) in _coco_instances(n):
766
+ big = max(objs, key=lambda o: o["area"])
767
+ out.append(GTSample(img, "Trace the main object's outline.",
768
+ {"outline": big["polygon_pixels"], "label": big["label"]},
769
+ "outline_association", iid, size))
770
+ return out
771
+
772
+
773
+ def load_coco_subject(n: int = 24, split=None) -> list[GTSample]:
774
+ out = []
775
+ for (img, size, iid, objs) in _coco_instances(n):
776
+ big = max(objs, key=lambda o: o["area"])
777
+ out.append(GTSample(img, "Identify the primary subject.",
778
+ {"label": big["label"], "box": big["box_xyxy"], "fmt": "xyxy"},
779
+ "subject_fixation", iid, size))
780
+ return out
781
+
782
+
783
+ # ── multi-person slice (fusion-tier validation GT) ────────────────────────────
784
+ def _select_multi_person_ids(ann: dict, *, min_persons: int = 2, max_persons: int = 6,
785
+ min_person_area_frac: float = 0.005,
786
+ require_nonperson: bool = False) -> list:
787
+ """Image ids with TRUSTWORTHY multi-person GT: min..max non-crowd persons, no
788
+ crowd-person annotation anywhere in the image (a crowd RLE blob means "many
789
+ unlabeled people" — the count GT becomes untrustworthy), and no tiny background
790
+ persons (< min_person_area_frac of the image). Deliberately a CLEAN slice; the
791
+ bias is stated in every validation report. Pure filter over the parsed
792
+ annotations — no network, testable with a fake ann dict."""
793
+ from collections import defaultdict
794
+
795
+ imgs = {im["id"]: im for im in ann["images"]}
796
+ per_img = defaultdict(list)
797
+ for a in ann["annotations"]:
798
+ per_img[a["image_id"]].append(a)
799
+ out = []
800
+ for iid, image_anns in per_img.items():
801
+ info = imgs.get(iid)
802
+ if info is None:
803
+ continue
804
+ wh = float(info["width"] * info["height"]) or 1.0
805
+ persons = [a for a in image_anns if a["category_id"] == _COCO_PERSON_CAT]
806
+ if any(a.get("iscrowd") for a in persons):
807
+ continue
808
+ if not (min_persons <= len(persons) <= max_persons):
809
+ continue
810
+ if any(float(a.get("area", 0.0)) < min_person_area_frac * wh for a in persons):
811
+ continue
812
+ if require_nonperson and not any(
813
+ a["category_id"] != _COCO_PERSON_CAT and not a.get("iscrowd")
814
+ for a in image_anns):
815
+ continue
816
+ out.append(iid)
817
+ out.sort() # deterministic selection order
818
+ return out
819
+
820
+
821
+ def _multi_person_gt(image_anns: list, cats: dict) -> dict:
822
+ """Shape one image's annotations into the fusion GT. Keeps ALL instances and ALL
823
+ polygon parts per annotation — occluded people are routinely split into 2+
824
+ polygons; the first-polygon-only rule used by _coco_instances would corrupt
825
+ person masks on exactly this slice."""
826
+ persons, objects = [], []
827
+ for a in image_anns:
828
+ if a.get("iscrowd"):
829
+ continue
830
+ seg = a.get("segmentation")
831
+ polys = ([[float(v) for v in part] for part in seg
832
+ if isinstance(part, list) and len(part) >= 6]
833
+ if isinstance(seg, list) else [])
834
+ x, y, w, h = a["bbox"]
835
+ rec = {"ann_id": a["id"], "box_xyxy": [x, y, x + w, y + h],
836
+ "polygons": polys, "area": float(a.get("area", w * h))}
837
+ if a["category_id"] == _COCO_PERSON_CAT:
838
+ persons.append(rec)
839
+ else:
840
+ objects.append(dict(rec, label=cats.get(a["category_id"], "object")))
841
+ return {"persons": persons, "objects": objects, "n_persons": len(persons)}
842
+
843
+
844
+ def load_coco_multi_person(n: int = 24, split=None, *, min_persons: int = 2,
845
+ max_persons: int = 6, min_person_area_frac: float = 0.005,
846
+ require_nonperson: bool = False) -> list[GTSample]:
847
+ """Clean 2-6-person COCO slice for fusion validation. GT retains all instances +
848
+ all polygon parts; the 5 human captions ride in meta["captions"]. Filtering runs
849
+ over the cached annotations BEFORE any image download."""
850
+ import io
851
+ import urllib.request
852
+ from collections import defaultdict
853
+ from PIL import Image
854
+
855
+ key = (f"multi_{n}_{min_persons}_{max_persons}_{min_person_area_frac}"
856
+ f"_{require_nonperson}")
857
+ if key in _COCO_CACHE:
858
+ return _COCO_CACHE[key]
859
+ ann = _coco_ann("instances")
860
+ cap_ann = _coco_ann("captions")
861
+ cats = {c["id"]: c["name"] for c in ann["categories"]}
862
+ imgs = {im["id"]: im for im in ann["images"]}
863
+ per_img = defaultdict(list)
864
+ for a in ann["annotations"]:
865
+ per_img[a["image_id"]].append(a)
866
+ caps = defaultdict(list)
867
+ for c in cap_ann["annotations"]:
868
+ caps[c["image_id"]].append(str(c["caption"]).strip())
869
+
870
+ out = []
871
+ for iid in _select_multi_person_ids(
872
+ ann, min_persons=min_persons, max_persons=max_persons,
873
+ min_person_area_frac=min_person_area_frac,
874
+ require_nonperson=require_nonperson):
875
+ if len(out) >= n:
876
+ break
877
+ info = imgs[iid]
878
+ try:
879
+ raw = urllib.request.urlopen(
880
+ f"http://images.cocodataset.org/val2017/{info['file_name']}",
881
+ timeout=60).read()
882
+ img = Image.open(io.BytesIO(raw)).convert("RGB")
883
+ except Exception:
884
+ continue
885
+ gt = _multi_person_gt(per_img[iid], cats)
886
+ out.append(GTSample(img, "Fuse the scene into entities, relations, and counts.",
887
+ gt, "fusion_scene", f"coco_{iid}",
888
+ (img.width, img.height), meta={"captions": caps.get(iid, [])}))
889
+ _COCO_CACHE[key] = out
890
+ return out
891
+
892
+
893
+ def load_coco_multi_person_rich(n: int = 24, split=None) -> list[GTSample]:
894
+ """Multi-person images that ALSO contain a non-person object (relation richness)."""
895
+ return load_coco_multi_person(n, split, require_nonperson=True)
896
+
897
+
898
+ DATASET_REGISTRY: dict[str, Callable[..., list[GTSample]]] = {
899
+ "imagenet_val": load_imagenet_val,
900
+ "coco_detection": load_coco_detection,
901
+ "coco_segmentation": load_coco_segmentation,
902
+ "coco_outline": load_coco_outline,
903
+ "coco_subject": load_coco_subject,
904
+ "coco_multi_person": load_coco_multi_person,
905
+ "coco_multi_person_rich": load_coco_multi_person_rich,
906
+ "textvqa": load_textvqa,
907
+ "datatype_synth": make_datatype_samples,
908
+ "shapes_synth": make_shapes_samples,
909
+ "segmentation_synth": make_segmentation_samples,
910
+ "outline_synth": make_outline_samples,
911
+ "boxes3d_synth": make_3d_samples,
912
+ "camera_rot_synth": make_camera_samples,
913
+ "gqa": make_gqa_samples,
914
+ "semantic_synth": make_semantic_samples,
915
+ "style_synth": make_style_samples,
916
+ }
917
+
918
+
919
+ def load_gt(dataset_key: str, n: int = 200, split: str = "validation",
920
+ dataset: str = "full") -> list[GTSample]:
921
+ """Top-level GT loader. dataset='smoke' uses the packaged offline set."""
922
+ if dataset == "smoke" or dataset_key in ("", "smoke"):
923
+ # caller passes the category as dataset_key for smoke
924
+ return smoke_samples(dataset_key, n)
925
+ loader = DATASET_REGISTRY.get(dataset_key)
926
+ if loader is None:
927
+ raise KeyError(f"no loader for dataset {dataset_key!r}. known: {list(DATASET_REGISTRY)}")
928
+ return loader(n=n, split=split)
qwen_test_runner/vision/derive.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ derive.py — the deterministic "semantic engine": derive INTEGRATE tasks from the
3
+ solidification primitives (detector boxes, segmentation masks, a relative depth map,
4
+ optional saliency), with NO model. Pure numpy + stdlib; OpenCV is used lazily only for
5
+ the outline contour.
6
+
7
+ Every function returns the exact JSON shape of its `tasks_vision` task, so the output
8
+ validates against the task's registry Pydantic model and scores through the existing
9
+ `score_vision_sample`. Boxes/polygons are in whatever coordinate space the caller passes
10
+ in (pixels for real specialists) — coord-space normalization is done by the adapter layer,
11
+ not here.
12
+
13
+ Primitive conventions (documented, unit-tested):
14
+ box = [x1, y1, x2, y2] (x right, y DOWN)
15
+ mask = HxW bool ndarray
16
+ depth = HxW float ndarray, relative/ordinal. Depth-Anything convention: HIGHER = NEARER
17
+ (disparity-like). Pass higher_is_nearer=False for metric depth (smaller = nearer).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from typing import Optional, Sequence
25
+
26
+ import numpy as np
27
+
28
+ # tasks_vision closed vocabularies (kept in sync with the registry)
29
+ _DATATYPE_VALUES = ("json", "yaml", "markdown", "csv", "toml", "xml", "code", "plaintext")
30
+
31
+
32
+ # ── geometry helpers ─────────────────────────────────────────────────────────
33
+ def _centroid(b):
34
+ return (0.5 * (b[0] + b[2]), 0.5 * (b[1] + b[3]))
35
+
36
+
37
+ def _area(b):
38
+ return max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])
39
+
40
+
41
+ def _iou(a, b):
42
+ ix1, iy1 = max(a[0], b[0]), max(a[1], b[1])
43
+ ix2, iy2 = min(a[2], b[2]), min(a[3], b[3])
44
+ iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
45
+ inter = iw * ih
46
+ u = _area(a) + _area(b) - inter
47
+ return inter / u if u > 0 else 0.0
48
+
49
+
50
+ def _contains(outer, inner, frac=0.85):
51
+ """True if `inner` is (mostly) inside `outer` — inter/area(inner) >= frac."""
52
+ ix1, iy1 = max(outer[0], inner[0]), max(outer[1], inner[1])
53
+ ix2, iy2 = min(outer[2], inner[2]), min(outer[3], inner[3])
54
+ inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
55
+ ai = _area(inner)
56
+ return ai > 0 and inter / ai >= frac
57
+
58
+
59
+ def _uniq_labels(labels):
60
+ """Disambiguate duplicate label strings: person, person -> person_1, person_2."""
61
+ seen, counts = {}, {}
62
+ for l in labels:
63
+ counts[l] = counts.get(l, 0) + 1
64
+ out, running = [], {}
65
+ for l in labels:
66
+ if counts[l] == 1:
67
+ out.append(l)
68
+ else:
69
+ running[l] = running.get(l, 0) + 1
70
+ out.append(f"{l}_{running[l]}")
71
+ return out
72
+
73
+
74
+ # ── #8 structural_spatial_awareness ──────────────────────────────────────────
75
+ def spatial_relations(boxes: Sequence[dict], depth: Optional[np.ndarray] = None,
76
+ higher_is_nearer: bool = True, max_items: int = 12) -> dict:
77
+ """boxes: [{label, box:[x1,y1,x2,y2], score?}]. Emits projective relations
78
+ (left_of/right_of/above/below), containment (inside), and — if a depth map is given —
79
+ in_front_of/behind. Returns {"relations":[{subject,predicate,object}]}."""
80
+ labs = _uniq_labels([str(b["label"]) for b in boxes])
81
+ bxs = [b["box"] for b in boxes]
82
+ n = len(bxs)
83
+ # per-box depth (median over the box region) if a map is provided
84
+ box_depth = None
85
+ if depth is not None and n:
86
+ H, W = depth.shape[:2]
87
+ box_depth = []
88
+ for b in bxs:
89
+ x1, y1, x2, y2 = (int(round(b[0])), int(round(b[1])), int(round(b[2])), int(round(b[3])))
90
+ x1, y1 = max(0, x1), max(0, y1)
91
+ x2, y2 = min(W, max(x1 + 1, x2)), min(H, max(y1 + 1, y2))
92
+ patch = depth[y1:y2, x1:x2]
93
+ box_depth.append(float(np.median(patch)) if patch.size else 0.0)
94
+
95
+ rels, seen = [], set() # seen holds (subject, predicate, object) triples
96
+ # order pairs by centroid distance so the nearest, most meaningful pairs win the budget
97
+ cents = [_centroid(b) for b in bxs]
98
+ pairs = [(i, j) for i in range(n) for j in range(n) if i != j]
99
+ pairs.sort(key=lambda ij: (cents[ij[0]][0] - cents[ij[1]][0]) ** 2
100
+ + (cents[ij[0]][1] - cents[ij[1]][1]) ** 2)
101
+
102
+ def _emit(si, pred, oi):
103
+ t = (labs[si], pred, labs[oi])
104
+ if t not in seen:
105
+ seen.add(t)
106
+ rels.append({"subject": labs[si], "predicate": pred, "object": labs[oi]})
107
+
108
+ for i, j in pairs:
109
+ if len(rels) >= max_items:
110
+ break
111
+ a, ca, cb = bxs[i], cents[i], cents[j]
112
+ if _contains(bxs[j], a): # containment first (most specific)
113
+ _emit(i, "inside", j)
114
+ continue
115
+ dx, dy = cb[0] - ca[0], cb[1] - ca[1]
116
+ if abs(dx) >= abs(dy):
117
+ _emit(i, "left_of" if dx > 0 else "right_of", j) # a left_of b when a.x < b.x
118
+ else:
119
+ _emit(i, "above" if dy > 0 else "below", j) # y DOWN: a above b when a.y < b.y
120
+ # depth relations — a pair can carry BOTH a projective and a depth relation
121
+ if box_depth is not None:
122
+ rng = (max(box_depth) - min(box_depth)) or 1.0
123
+ for i, j in pairs:
124
+ if len(rels) >= max_items:
125
+ break
126
+ d = (box_depth[i] - box_depth[j]) / rng
127
+ if abs(d) < 0.15:
128
+ continue
129
+ a_nearer = (d > 0) if higher_is_nearer else (d < 0)
130
+ _emit(i, "in_front_of" if a_nearer else "behind", j)
131
+ return {"relations": rels}
132
+
133
+
134
+ # ── #7 depth_analysis (ordering) ─────────────────────────────────────────────
135
+ def depth_scalars(entities: Sequence[dict], depth: np.ndarray,
136
+ higher_is_nearer: bool = True) -> list[float]:
137
+ """Continuous per-entity NEARNESS in [0,1] (bigger = nearer): median relative depth
138
+ over each entity's mask (preferred) or box, min-max normalized across the entities.
139
+ This is the scalar core of `depth_order`, exposed so the fusion tier can keep the
140
+ continuous signal instead of only the categorical nearer/farther/same."""
141
+ if not entities:
142
+ return []
143
+ H, W = depth.shape[:2]
144
+ vals = []
145
+ for e in entities:
146
+ if e.get("mask") is not None:
147
+ m = np.asarray(e["mask"], dtype=bool)
148
+ v = float(np.median(depth[m])) if m.any() else 0.0
149
+ else:
150
+ b = e["box"]
151
+ x1, y1 = max(0, int(b[0])), max(0, int(b[1]))
152
+ x2, y2 = min(W, int(b[2])), min(H, int(b[3]))
153
+ patch = depth[y1:max(y1 + 1, y2), x1:max(x1 + 1, x2)]
154
+ v = float(np.median(patch)) if patch.size else 0.0
155
+ vals.append(v)
156
+ # normalize to [0,1] for a stable "same" tolerance
157
+ lo, hi = min(vals), max(vals)
158
+ rng = (hi - lo) or 1.0
159
+ norm = [(v - lo) / rng for v in vals]
160
+ # nearness score: bigger = nearer
161
+ return norm if higher_is_nearer else [1 - x for x in norm]
162
+
163
+
164
+ def depth_order(entities: Sequence[dict], depth: np.ndarray,
165
+ higher_is_nearer: bool = True, same_tol: float = 0.08,
166
+ max_items: int = 12) -> dict:
167
+ """entities: [{label, mask:HxW bool}] (preferred) or [{label, box}]. Samples the
168
+ RELATIVE depth over each entity (mask median, foreground-robust), orders them, and
169
+ emits {"nearest","farthest","relative_depth":[{a,b,a_is}]}."""
170
+ if not entities:
171
+ return {"nearest": "", "farthest": "", "relative_depth": []}
172
+ labs = _uniq_labels([str(e["label"]) for e in entities])
173
+ near = depth_scalars(entities, depth, higher_is_nearer)
174
+ order = sorted(range(len(labs)), key=lambda i: -near[i])
175
+ out = {"nearest": labs[order[0]], "farthest": labs[order[-1]], "relative_depth": []}
176
+ for i in range(len(labs)):
177
+ for j in range(i + 1, len(labs)):
178
+ if len(out["relative_depth"]) >= max_items:
179
+ return out
180
+ d = near[i] - near[j]
181
+ a_is = "same" if abs(d) < same_tol else ("nearer" if d > 0 else "farther")
182
+ out["relative_depth"].append({"a": labs[i], "b": labs[j], "a_is": a_is})
183
+ return out
184
+
185
+
186
+ # ── #9 subject_fixation ──────────────────────────────────────────────────────
187
+ def subject_scores(boxes: Sequence[dict], image_size,
188
+ saliency: Optional[np.ndarray] = None) -> list[float]:
189
+ """Per-box subject score (saliency-PRIMARY, area×centrality tie-break) for EVERY
190
+ box — the scoring core of `subject_fixation`, exposed so the fusion tier can keep
191
+ the full ranking instead of only the winner."""
192
+ W, H = image_size
193
+ cx, cy = W / 2.0, H / 2.0
194
+ diag = (W ** 2 + H ** 2) ** 0.5 or 1.0
195
+
196
+ def score(b):
197
+ bx = b["box"]
198
+ area = _area(bx) / (W * H + 1e-9)
199
+ ctr = _centroid(bx)
200
+ centrality = 1.0 - (((ctr[0] - cx) ** 2 + (ctr[1] - cy) ** 2) ** 0.5) / diag
201
+ geo = area * (0.5 + 0.5 * centrality)
202
+ if saliency is not None:
203
+ x1, y1 = max(0, int(bx[0])), max(0, int(bx[1]))
204
+ x2, y2 = min(int(saliency.shape[1]), int(bx[2])), min(int(saliency.shape[0]), int(bx[3]))
205
+ patch = saliency[y1:max(y1 + 1, y2), x1:max(x1 + 1, x2)]
206
+ sal = float(patch.mean()) if patch.size else 0.0
207
+ return sal + 0.01 * geo # saliency primary, geometry breaks ties
208
+ return geo
209
+
210
+ return [score(b) for b in boxes]
211
+
212
+
213
+ def subject_fixation(boxes: Sequence[dict], image_size, saliency: Optional[np.ndarray] = None) -> dict:
214
+ """Saliency-PRIMARY (mean saliency inside each box), area×centrality tie-break.
215
+ image_size = (W, H). Falls back to the largest box, then whole-image. Returns
216
+ {"primary_subject":{"label","box"}}."""
217
+ W, H = image_size
218
+ if not boxes:
219
+ return {"primary_subject": {"label": "scene", "box": [0.0, 0.0, float(W), float(H)]}}
220
+ scores = subject_scores(boxes, image_size, saliency)
221
+ best = boxes[max(range(len(boxes)), key=scores.__getitem__)]
222
+ return {"primary_subject": {"label": str(best["label"]),
223
+ "box": [float(v) for v in best["box"]]}}
224
+
225
+
226
+ # ── #10 outline_association (mask → contour polygon) ─────────────────────────
227
+ def outline_polygon(mask: np.ndarray, label: str, max_points: int = 128) -> dict:
228
+ """SAM2 mask → closed outline polygon, flat [x1,y1,x2,y2,...]. Pure-numpy row-scan:
229
+ trace the left boundary top→bottom, then the right boundary bottom→top (a closed loop).
230
+ No OpenCV dependency; the dense boundary is IoU-accurate (subsampled to max_points)."""
231
+ m = np.asarray(mask) > 0
232
+ if m.ndim != 2 or not m.any():
233
+ return {"outline": [], "label": str(label)}
234
+ ys = np.where(m.any(axis=1))[0]
235
+ left, right = [], []
236
+ for y in ys:
237
+ xs = np.where(m[y])[0]
238
+ left.append((float(xs[0]), float(y)))
239
+ right.append((float(xs[-1]), float(y)))
240
+ pts = left + right[::-1] # closed loop
241
+ if len(pts) > max_points:
242
+ idx = np.linspace(0, len(pts) - 1, max_points).round().astype(int)
243
+ pts = [pts[i] for i in idx]
244
+ flat = [v for xy in pts for v in xy] # (x, y) interleaved
245
+ return {"outline": flat, "label": str(label)}
246
+
247
+
248
+ # ── #6 style: symmetry + layout (the deterministic halves) ───────────────────
249
+ def symmetry_scores(gray: np.ndarray) -> dict:
250
+ """Continuous L/R and T/B mirror correlations in [-1,1] — the scalar core of
251
+ `symmetry_axis`, exposed so the fusion tier keeps the magnitudes the categorical
252
+ label throws away. Returns {"lr": float, "tb": float}."""
253
+ g = np.asarray(gray, dtype=np.float64)
254
+ if g.ndim == 3:
255
+ g = g.mean(axis=2)
256
+ g = g - g.mean()
257
+
258
+ def corr(a, b):
259
+ a, b = a.ravel(), b.ravel()
260
+ da, db = np.linalg.norm(a), np.linalg.norm(b)
261
+ return float(a @ b / (da * db)) if da > 0 and db > 0 else 0.0
262
+
263
+ return {"lr": corr(g, g[:, ::-1]), # left-right mirror -> vertical-axis symmetry
264
+ "tb": corr(g, g[::-1, :])} # top-bottom mirror -> horizontal-axis symmetry
265
+
266
+
267
+ def symmetry_axis(gray: np.ndarray, thresh: float = 0.80) -> str:
268
+ """Normalized-correlation of the image vs its L/R and T/B flips. Returns one of
269
+ horizontal/vertical/radial/none. 'vertical' = mirror across a vertical axis (L==R)."""
270
+ s = symmetry_scores(gray)
271
+ lr, tb = s["lr"], s["tb"]
272
+ v, h = lr >= thresh, tb >= thresh
273
+ if v and h:
274
+ return "radial"
275
+ if v:
276
+ return "vertical"
277
+ if h:
278
+ return "horizontal"
279
+ return "none"
280
+
281
+
282
+ def layout_kind(boxes: Sequence[dict], image_size) -> str:
283
+ """From the box constellation: centered / rule_of_thirds / symmetric / scattered /
284
+ unknown."""
285
+ W, H = image_size
286
+ if not boxes:
287
+ return "unknown"
288
+ cents = np.array([_centroid(b["box"]) for b in boxes], dtype=float)
289
+ areas = np.array([_area(b["box"]) for b in boxes], dtype=float)
290
+ if len(boxes) == 1 or areas.max() > 0.5 * (W * H):
291
+ cx, cy = cents[int(areas.argmax())]
292
+ if abs(cx - W / 2) < 0.15 * W and abs(cy - H / 2) < 0.15 * H:
293
+ return "centered"
294
+ # left-right centroid symmetry about the vertical axis
295
+ xs = cents[:, 0] / W
296
+ if len(xs) >= 2 and abs(np.mean(xs) - 0.5) < 0.08 and np.std(xs) > 0.15:
297
+ return "symmetric"
298
+ # proximity to rule-of-thirds lines
299
+ thirds = np.array([1 / 3, 2 / 3])
300
+ nx = np.min(np.abs((cents[:, 0] / W)[:, None] - thirds[None, :]), axis=1)
301
+ ny = np.min(np.abs((cents[:, 1] / H)[:, None] - thirds[None, :]), axis=1)
302
+ if np.mean((nx < 0.08) | (ny < 0.08)) > 0.5:
303
+ return "rule_of_thirds"
304
+ return "scattered"
305
+
306
+
307
+ # ── #12/#13 data-type recognition + re-serialization ─────────────────────────
308
+ def detect_data_type(text: str) -> dict:
309
+ """OCR text -> {"data_type","confidence"}. Deterministic regex/heuristic with a
310
+ precedence order and a confidence proxy."""
311
+ t = (text or "").strip()
312
+ if not t:
313
+ return {"data_type": "plaintext", "confidence": 0.2}
314
+ scores = {k: 0.0 for k in _DATATYPE_VALUES}
315
+ if re.search(r"^\s*[{\[]", t) and re.search(r"[}\]]\s*$", t) and '"' in t:
316
+ scores["json"] += 0.9
317
+ if re.search(r"^\s*<\?xml|</[a-zA-Z]", t):
318
+ scores["xml"] += 0.9
319
+ if re.search(r"^\s*---\s*$", t, re.M) or re.search(r"^\s*[\w-]+:\s+\S", t, re.M):
320
+ scores["yaml"] += 0.6
321
+ if re.search(r"^\s*#{1,6}\s|\*\*|\[.+\]\(.+\)|^\s*[-*]\s", t, re.M):
322
+ scores["markdown"] += 0.6
323
+ if re.search(r"^\s*\[[\w.\-]+\]\s*$", t, re.M) or re.search(r'^\s*[\w.-]+\s*=\s*("|\d|\[)', t, re.M):
324
+ scores["toml"] += 0.6
325
+ if "\n" in t and all("," in ln for ln in t.splitlines()[:3] if ln.strip()):
326
+ scores["csv"] += 0.5
327
+ if re.search(r"\b(def|function|class|import|return|const|var|let)\b", t):
328
+ scores["code"] += 0.4
329
+ best = max(scores, key=scores.get)
330
+ conf = scores[best]
331
+ if conf <= 0.0:
332
+ return {"data_type": "plaintext", "confidence": 0.3}
333
+ return {"data_type": best, "confidence": round(min(0.99, conf), 2)}
334
+
335
+
336
+ def _repair_json(t: str) -> str:
337
+ t = t.strip().strip("`")
338
+ t = re.sub(r"[“”]", '"', t) # curly double quotes
339
+ t = re.sub(r"[‘’]", "'", t) # curly single quotes
340
+ t = re.sub(r",\s*([}\]])", r"\1", t) # trailing commas
341
+ return t
342
+
343
+
344
+ def parse_data_type(text: str) -> tuple[dict, bool]:
345
+ """OCR text -> ({"data_type","content"}, parsed_ok). Deterministic parse with light
346
+ repair; content is a compact JSON string of the parsed structure. Returns
347
+ parsed_ok=False when nothing parsed (caller decides on a VLM fallback)."""
348
+ dt = detect_data_type(text)["data_type"]
349
+ raw = _repair_json(text or "")
350
+ parsed = None
351
+ try:
352
+ parsed = json.loads(raw)
353
+ except Exception:
354
+ try:
355
+ import yaml
356
+ y = yaml.safe_load(raw)
357
+ if isinstance(y, (dict, list)):
358
+ parsed = y
359
+ except Exception:
360
+ parsed = None
361
+ if parsed is None:
362
+ return {"data_type": dt, "content": ""}, False
363
+ return {"data_type": dt, "content": json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))}, True
qwen_test_runner/vision/fuse.py ADDED
@@ -0,0 +1,758 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ fuse.py — the fusion engine: bind every captured signal into one FusedScene.
3
+
4
+ Consumes a `solids_digest` (compact JSON-able snapshot of a `Solids` — detection
5
+ boxes + scores + mask polygons + mask quality, continuous depth nearness, saliency
6
+ scores, OCR with confidence, style/class/symmetry/layout) plus the caption structs
7
+ (slot-registry JSON from the 9B structurer) and the raw captions, and emits the
8
+ fused relational representation:
9
+
10
+ entities — addressable instances (person_1, person_2, dog) with position grid,
11
+ offset-from-center, continuous depth + rank, saliency + rank, mask,
12
+ and STRATIFIED OWNED ATTRIBUTES (ownership decided by segmentation-
13
+ polygon containment with confidence + margin thresholds)
14
+ relations — pairwise predicates + continuous dx/dy/distance/iou/depth-delta
15
+ counts — synonym-collapsed instance counts
16
+ shared_basin — attributes NOT confidently assignable (never subjectively grouped),
17
+ with per-entity likelihoods and the reason
18
+ scene — voted setting/style/mood + layout/symmetry/OCR/actions
19
+ quality — retained confidences + grounding accounting + overall_confidence
20
+
21
+ Pure numpy + stdlib + PIL (polygon rasterization) — torch-free, CPU-testable.
22
+ The ONLY GPU dependency is upstream: the optional `attr_boxes` in the digest come
23
+ from a second GroundingDINO pass over `phrases_for_grounding(...)` phrases.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from collections import Counter, defaultdict
30
+ from typing import Optional
31
+
32
+ import numpy as np
33
+
34
+ from . import derive
35
+ from .coords import CoordSpace
36
+ from .fuse_schema import (FusedScene, MASK_POLY_MAX_POINTS, MAX_ENTITIES,
37
+ MAX_RELATION_ENTITIES)
38
+ from .metrics import _depluralize, _seg_poly_points, _seg_rasterize, labels_match
39
+ from .specialists import box_to_space, poly_to_space
40
+ from .strata import _content_tokens, classify_stratum, is_groundable
41
+
42
+ # Containment rasterization grid (mask polygons are ≤64 points; 160² cells is
43
+ # ample resolution for an ownership FRACTION).
44
+ _GRID = 160
45
+
46
+ # Depth-relation threshold on the normalized nearness delta — same magnitude the
47
+ # spatial_relations engine uses on its normalized per-box depth deltas.
48
+ _DEPTH_REL_TOL = 0.15
49
+
50
+ # "near" relation threshold on centroid-distance / image-diagonal.
51
+ _NEAR_DIST = 0.25
52
+
53
+ # Positional-cue lexicon for caption-subject binding votes.
54
+ _POS_LEFT = frozenset({"left", "leftmost"})
55
+ _POS_RIGHT = frozenset({"right", "rightmost"})
56
+ _POS_FRONT = frozenset({"front", "foreground", "nearest", "closest", "nearer", "closer"})
57
+ _POS_BACK = frozenset({"behind", "background", "back", "farthest", "farther", "rear"})
58
+ _POS_TALL = frozenset({"tall", "taller", "tallest"})
59
+
60
+
61
+ # ═════════════════════════════════════════════════════════════════════════════
62
+ # Digest — the GPU→CPU handoff (also the durability/parquet payload)
63
+ # ═════════════════════════════════════════════════════════════════════════════
64
+
65
+ def solids_digest(s) -> dict:
66
+ """Compact, JSON-able, deterministic snapshot of a Solids. Retains the signals
67
+ the build_* task projections drop (mask quality, OCR conf, continuous nearness,
68
+ the full saliency ranking, symmetry magnitudes)."""
69
+ from .coords import BBox
70
+ W, H = s.size
71
+ nearness = (derive.depth_scalars(s.boxes, s.depth, s.depth_higher_is_nearer)
72
+ if (s.depth is not None and s.boxes) else None)
73
+ sal = derive.subject_scores(s.boxes, s.size, s.saliency) if s.boxes else []
74
+ boxes = []
75
+ for i, b in enumerate(s.boxes):
76
+ mask = b.get("mask")
77
+ poly = (derive.outline_polygon(mask, b["label"],
78
+ max_points=MASK_POLY_MAX_POINTS)["outline"]
79
+ if mask is not None else None)
80
+ # GDINO emits unclamped boxes (border objects go past the frame) — clip
81
+ # once at the digest boundary so all downstream geometry is in-range
82
+ clipped = BBox(*[float(v) for v in b["box"]]).clip((W, H)).as_list()
83
+ boxes.append({
84
+ "label": str(b["label"]),
85
+ "box": clipped,
86
+ "score": float(b.get("score", 1.0)),
87
+ "area_px": derive._area(clipped),
88
+ "sal": float(sal[i]) if i < len(sal) else 0.0,
89
+ "nearness": (round(float(nearness[i]), 4) if nearness is not None else None),
90
+ "mask_poly": poly or None,
91
+ "mask_quality": (float(b["mask_score"]) if b.get("mask_score") is not None
92
+ else None),
93
+ })
94
+ ocr = {"full_text": "", "lines": []}
95
+ if s.ocr:
96
+ ocr["full_text"] = str(s.ocr.get("full_text", ""))
97
+ for ln in s.ocr.get("lines", []):
98
+ q = ln.get("box")
99
+ flat = ([min(max(float(v), 0.0), float(W if i % 2 == 0 else H))
100
+ for xy in q for i, v in enumerate(xy)] if q else None)
101
+ ocr["lines"].append({"text": str(ln["text"]),
102
+ "quad": flat,
103
+ "conf": (float(ln["conf"]) if ln.get("conf") is not None
104
+ else None)})
105
+ attr_boxes = []
106
+ for a in getattr(s, "attr_boxes", []):
107
+ a = dict(a)
108
+ a["box"] = BBox(*[float(v) for v in a["box"]]).clip((W, H)).as_list()
109
+ attr_boxes.append(a)
110
+ return {
111
+ "size": [int(W), int(H)],
112
+ "boxes": boxes,
113
+ "attr_boxes": attr_boxes,
114
+ "class_top": [{"label": str(c["label"]), "score": float(c["score"])}
115
+ for c in (s.class_top or [])],
116
+ "style": s.style,
117
+ "ocr": ocr,
118
+ "symmetry": (derive.symmetry_scores(s.gray) if s.gray is not None else None),
119
+ "layout": derive.layout_kind(s.boxes, s.size),
120
+ "higher_is_nearer": bool(s.depth_higher_is_nearer),
121
+ }
122
+
123
+
124
+ # ═════════════════════════════════════════════════════════════════════════════
125
+ # Caption-side collection + cross-source merge
126
+ # ═════════════════════════════════════════════════════════════════════════════
127
+
128
+ def _attr_key(text: str) -> frozenset:
129
+ """Dedup key: depluralized content-token set (raw + depluralized forms so the
130
+ crude depluralizer can't split 'dress'/'dres')."""
131
+ toks = _content_tokens(text)
132
+ return frozenset(t for tok in toks for t in (tok, _depluralize(tok)))
133
+
134
+
135
+ _HEAD_SPLIT_RE = re.compile(r"\b(?:in|on|at|with|of|to|wearing|holding)\b")
136
+
137
+
138
+ def _subject_head(name: str) -> str:
139
+ """Head noun = last content token BEFORE the first post-modifier ("woman in
140
+ red" → woman, "person on a bench" → person); falls back to the full-name head
141
+ when the pre-modifier part has no content tokens."""
142
+ pre = _HEAD_SPLIT_RE.split((name or "").lower(), 1)[0]
143
+ toks = _content_tokens(pre)
144
+ if toks:
145
+ return toks[-1]
146
+ toks = _content_tokens(name)
147
+ return toks[-1] if toks else ""
148
+
149
+
150
+ def _collect_merged(caption_structs: dict) -> tuple:
151
+ """caption_structs: {source: struct-or-None}. Returns (merged_attrs, actions,
152
+ votes) where merged_attrs = [{text, key, sources, consensus, stratum,
153
+ parents:{source: subject_name}}] (cross-source dedup: token-set equal-or-subset
154
+ → canonical = longest text; provenance kept). Subjects are NEVER merged across
155
+ sources by name — merging happens only through binding downstream."""
156
+ sources = [k for k, v in caption_structs.items() if v]
157
+ n_src = max(1, len(sources))
158
+
159
+ raw_items = []
160
+ actions = []
161
+ votes = {"setting": Counter(), "style": Counter(), "mood": {}}
162
+ for src in sources:
163
+ st = caption_structs[src]
164
+ for subj in (st.get("subjects") or []):
165
+ name = str(subj.get("name") or "").strip()
166
+ for att in (subj.get("attributes") or []):
167
+ att = str(att).strip()
168
+ if att:
169
+ raw_items.append({"text": att, "source": src, "subject": name})
170
+ for act in (st.get("actions") or []):
171
+ act = str(act).strip()
172
+ if act:
173
+ actions.append({"text": act, "source": src})
174
+ if st.get("setting"):
175
+ votes["setting"][str(st["setting"])] += 1
176
+ if st.get("style"):
177
+ votes["style"][str(st["style"])] += 1
178
+ if st.get("mood"):
179
+ votes["mood"][src] = str(st["mood"])
180
+
181
+ # merge: iterate longest-token-set first so merged records are supersets
182
+ raw_items.sort(key=lambda it: (-len(_attr_key(it["text"])), it["text"], it["source"]))
183
+ merged = []
184
+ for it in raw_items:
185
+ key = _attr_key(it["text"])
186
+ if not key:
187
+ continue
188
+ home = next((m for m in merged if key <= m["key"] or m["key"] <= key), None)
189
+ if home is None:
190
+ merged.append({"text": it["text"], "key": key, "sources": [it["source"]],
191
+ "parents": {it["source"]: it["subject"]}})
192
+ else:
193
+ home["key"] = home["key"] | key
194
+ if len(it["text"]) > len(home["text"]):
195
+ home["text"] = it["text"]
196
+ if it["source"] not in home["sources"]:
197
+ home["sources"].append(it["source"])
198
+ home["parents"].setdefault(it["source"], it["subject"])
199
+ for m in merged:
200
+ m["sources"] = sorted(m["sources"])
201
+ m["consensus"] = round(len(m["sources"]) / n_src, 4)
202
+ m["stratum"] = classify_stratum(m["text"])
203
+
204
+ # actions: same dedup, no parents
205
+ actions.sort(key=lambda it: (-len(_attr_key(it["text"])), it["text"], it["source"]))
206
+ merged_acts = []
207
+ for it in actions:
208
+ key = _attr_key(it["text"])
209
+ if not key:
210
+ continue
211
+ home = next((m for m in merged_acts if key <= m["key"] or m["key"] <= key), None)
212
+ if home is None:
213
+ merged_acts.append({"text": it["text"], "key": key, "sources": [it["source"]]})
214
+ else:
215
+ home["key"] = home["key"] | key
216
+ if len(it["text"]) > len(home["text"]):
217
+ home["text"] = it["text"]
218
+ if it["source"] not in home["sources"]:
219
+ home["sources"].append(it["source"])
220
+ for m in merged_acts:
221
+ m["sources"] = sorted(m["sources"])
222
+ m["consensus"] = round(len(m["sources"]) / n_src, 4)
223
+
224
+ return merged, merged_acts, votes
225
+
226
+
227
+ def phrases_for_grounding(caption_structs: dict) -> list:
228
+ """The canonical phrases the GPU grounding pass should box — merged attribute
229
+ texts whose stratum is GROUNDABLE, emitted stripped-lowercase (ground_phrases
230
+ lowercases anyway; matching its normalization keeps the downstream
231
+ phrase↔attribute lookup exact)."""
232
+ merged, _, _ = _collect_merged(caption_structs)
233
+ return sorted({m["text"].strip().lower() for m in merged
234
+ if is_groundable(m["stratum"])})
235
+
236
+
237
+ # ═════════════════════════════════════════════════════════════════════════════
238
+ # Geometry: entities, containment, relations
239
+ # ═════════════════════════════════════════════════════════════════════════════
240
+
241
+ def _grid_cell(cx: float, cy: float, W: float, H: float) -> str:
242
+ col = "left" if cx < W / 3 else ("center" if cx < 2 * W / 3 else "right")
243
+ row = "upper" if cy < H / 3 else ("middle" if cy < 2 * H / 3 else "lower")
244
+ return f"{row} {col}"
245
+
246
+
247
+ def _build_entities(digest: dict, dedup_iou: float) -> list:
248
+ """Dedup detector double-boxes, cap by saliency, order left-to-right, and
249
+ assign _uniq_labels ids. Returns internal entity dicts (pixel space)."""
250
+ boxes = [dict(b) for b in digest["boxes"]]
251
+ kept = []
252
+ for b in sorted(boxes, key=lambda b: (-b["score"], b["box"][0])):
253
+ if any(derive._iou(b["box"], k["box"]) >= dedup_iou
254
+ and labels_match(b["label"], k["label"]) for k in kept):
255
+ continue
256
+ kept.append(b)
257
+ kept.sort(key=lambda b: -b["sal"])
258
+ kept = kept[:MAX_ENTITIES]
259
+ for rank, b in enumerate(kept, 1):
260
+ b["sal_rank"] = rank
261
+ kept.sort(key=lambda b: (0.5 * (b["box"][0] + b["box"][2]), b["box"][1]))
262
+ ids = derive._uniq_labels([b["label"] for b in kept])
263
+ for b, eid in zip(kept, ids):
264
+ b["id"] = eid
265
+ if any(b["nearness"] is not None for b in kept):
266
+ by_near = sorted([b for b in kept if b["nearness"] is not None],
267
+ key=lambda b: -b["nearness"])
268
+ for rank, b in enumerate(by_near, 1):
269
+ b["depth_rank"] = rank
270
+ return kept
271
+
272
+
273
+ def _entity_grid_mask(ent: dict, size, cache: dict):
274
+ """Rasterized mask polygon on the containment grid (cached per entity)."""
275
+ eid = ent["id"]
276
+ if eid in cache:
277
+ return cache[eid]
278
+ W, H = size
279
+ m = None
280
+ if ent.get("mask_poly"):
281
+ pts = _seg_poly_points(ent["mask_poly"])
282
+ m = _seg_rasterize(pts, _GRID, _GRID / max(1.0, W), _GRID / max(1.0, H))
283
+ cache[eid] = m
284
+ return m
285
+
286
+
287
+ def _own_frac(attr_box, ent: dict, size, cache: dict) -> float:
288
+ """|attr_box ∩ entity mask| / |attr_box| on the grid; box-fraction fallback
289
+ when the entity has no mask polygon ("box_containment")."""
290
+ W, H = size
291
+ m = _entity_grid_mask(ent, size, cache)
292
+ x1 = int(np.clip(attr_box[0] / W * _GRID, 0, _GRID))
293
+ y1 = int(np.clip(attr_box[1] / H * _GRID, 0, _GRID))
294
+ x2 = int(np.clip(np.ceil(attr_box[2] / W * _GRID), 0, _GRID))
295
+ y2 = int(np.clip(np.ceil(attr_box[3] / H * _GRID), 0, _GRID))
296
+ if x2 <= x1 or y2 <= y1:
297
+ return 0.0
298
+ if m is not None:
299
+ return float(m[y1:y2, x1:x2].sum()) / float((x2 - x1) * (y2 - y1))
300
+ # box fallback: inter / area(attr_box)
301
+ b = ent["box"]
302
+ ix1, iy1 = max(attr_box[0], b[0]), max(attr_box[1], b[1])
303
+ ix2, iy2 = min(attr_box[2], b[2]), min(attr_box[3], b[3])
304
+ inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
305
+ a = derive._area(attr_box)
306
+ return inter / a if a > 0 else 0.0
307
+
308
+
309
+ def _pair_predicates(a: dict, b: dict) -> list:
310
+ """a→b predicates, same semantics as derive.spatial_relations (dominant axis,
311
+ containment first, depth via nearness delta) — pinned by a consistency test."""
312
+ preds = []
313
+ if derive._contains(b["box"], a["box"]):
314
+ preds.append("inside")
315
+ else:
316
+ ca, cb = derive._centroid(a["box"]), derive._centroid(b["box"])
317
+ dx, dy = cb[0] - ca[0], cb[1] - ca[1]
318
+ if abs(dx) >= abs(dy):
319
+ preds.append("left_of" if dx > 0 else "right_of")
320
+ else:
321
+ preds.append("above" if dy > 0 else "below")
322
+ if a["nearness"] is not None and b["nearness"] is not None:
323
+ d = a["nearness"] - b["nearness"]
324
+ if abs(d) >= _DEPTH_REL_TOL:
325
+ preds.append("in_front_of" if d > 0 else "behind")
326
+ return preds
327
+
328
+
329
+ # ═════════════════════════════════════════════════════════════════════════════
330
+ # The fusion
331
+ # ═════════════════════════════════════════════════════════════════════════════
332
+
333
+ def fuse(digest: dict, caption_structs: dict, raw_captions: Optional[dict] = None,
334
+ *, t_own: float = 0.60, t_margin: float = 0.25, dedup_iou: float = 0.75,
335
+ coord_space: CoordSpace = CoordSpace.NORM_0_1000) -> dict:
336
+ """→ FusedScene as a schema-validated dict. Deterministic: same inputs →
337
+ byte-identical json.dumps. See the module docstring for the shape and the
338
+ ownership cascade; t_own / t_margin are the assignment thresholds (an attribute
339
+ below them lands in shared_basin with per-entity likelihoods — never guessed)."""
340
+ W, H = digest["size"]
341
+ size = (float(W), float(H))
342
+ raw_captions = raw_captions or {}
343
+
344
+ ents = _build_entities(digest, dedup_iou)
345
+ by_id = {e["id"]: e for e in ents}
346
+ grid_cache: dict = {}
347
+
348
+ # entity output records (attributes attached during the cascade)
349
+ ent_out = {}
350
+ for e in ents:
351
+ cx, cy = derive._centroid(e["box"])
352
+ ent_out[e["id"]] = {
353
+ "id": e["id"], "label": e["label"], "detection_score": round(e["score"], 4),
354
+ "box": box_to_space(e["box"], coord_space, size),
355
+ "centroid": poly_to_space([cx, cy], coord_space, size),
356
+ "area_frac": round(e["area_px"] / (W * H + 1e-9), 4),
357
+ "position": {"grid": _grid_cell(cx, cy, W, H),
358
+ "offset_from_center": [round((cx - W / 2) / (W / 2), 4),
359
+ round((cy - H / 2) / (H / 2), 4)]},
360
+ "depth": ({"nearness": round(e["nearness"], 4), "rank": e["depth_rank"]}
361
+ if e.get("nearness") is not None and e.get("depth_rank") else None),
362
+ "saliency": {"score": round(e["sal"], 4), "rank": e["sal_rank"]},
363
+ "is_primary": e["sal_rank"] == 1,
364
+ "mask": ({"polygon": poly_to_space(e["mask_poly"], coord_space, size),
365
+ "quality": (round(e["mask_quality"], 4)
366
+ if e.get("mask_quality") is not None else None)}
367
+ if e.get("mask_poly") else None),
368
+ "caption_bindings": [],
369
+ "attributes": [],
370
+ }
371
+
372
+ counts = Counter()
373
+ for e in ents:
374
+ counts["person" if labels_match(e["label"], "person") else e["label"]] += 1
375
+ people = counts.get("person", 0)
376
+
377
+ merged, merged_acts, votes = _collect_merged(caption_structs)
378
+ n_sources = sum(1 for v in caption_structs.values() if v)
379
+
380
+ # grounding lookup: canonical phrase -> [attr box records]. ground_phrases
381
+ # lowercases its input phrases, so BOTH sides normalize to strip().lower()
382
+ # (an uppercase structurer attribute must not silently lose its grounding).
383
+ grounded_by_phrase = defaultdict(list)
384
+ for a in digest.get("attr_boxes", []):
385
+ grounded_by_phrase[str(a["phrase"]).strip().lower()].append(a)
386
+ for recs in grounded_by_phrase.values():
387
+ recs.sort(key=lambda r: -r["score"])
388
+
389
+ def _candidates(m) -> tuple:
390
+ """(candidates, head_ok) — head_ok is False only when a subject head EXISTS
391
+ and matched no entity (fallback-to-all is then a guess, not evidence).
392
+ Pose/action attributes fall back to PERSON entities only — verbs apply to
393
+ agents, not to a baseball glove."""
394
+ cands, any_head = [], False
395
+ for src, subj in sorted(m["parents"].items()):
396
+ head = _subject_head(subj)
397
+ any_head = any_head or bool(head)
398
+ for e in ents:
399
+ if head and labels_match(head, e["label"]) and e not in cands:
400
+ cands.append(e)
401
+ if cands:
402
+ return cands, True
403
+ if m.get("stratum") in ("pose", "action"):
404
+ persons = [e for e in ents if labels_match(e["label"], "person")]
405
+ if persons:
406
+ return persons, not any_head
407
+ return list(ents), not any_head
408
+
409
+ basin, scene_attrs, assigned_attrs = [], [], []
410
+ unresolved = [] # (merged, candidates) awaiting the binding pass
411
+ subj_votes = defaultdict(lambda: defaultdict(float)) # (src, subject) -> {eid: score}
412
+ subj_nvotes = defaultdict(int)
413
+
414
+ def _attach(eid, m, conf, method, margin=None, gbox=None, gscore=None):
415
+ rec = {"text": m["text"], "stratum": m["stratum"], "sources": m["sources"],
416
+ "consensus": m["consensus"], "grounded": gbox is not None,
417
+ "box": box_to_space(gbox, coord_space, size) if gbox else None,
418
+ "grounding_score": round(gscore, 4) if gscore is not None else None,
419
+ "ownership": {"confidence": round(conf, 4),
420
+ "margin": round(margin, 4) if margin is not None else None,
421
+ "method": method},
422
+ "region_on_owner": None}
423
+ if gbox is not None:
424
+ o = by_id[eid]
425
+ ocx, ocy = derive._centroid(o["box"])
426
+ acx, acy = derive._centroid(gbox)
427
+ hw = max(1.0, (o["box"][2] - o["box"][0]) / 2)
428
+ hh = max(1.0, (o["box"][3] - o["box"][1]) / 2)
429
+ rel_y, rel_x = (acy - ocy) / hh, (acx - ocx) / hw
430
+ rec["region_on_owner"] = {
431
+ "vertical": "upper" if rel_y < -1 / 3 else ("lower" if rel_y > 1 / 3 else "middle"),
432
+ "horizontal": "left" if rel_x < -1 / 3 else ("right" if rel_x > 1 / 3 else "center"),
433
+ "offset": [round(rel_x, 4), round(rel_y, 4)]}
434
+ ent_out[eid]["attributes"].append(rec)
435
+ assigned_attrs.append((m, eid, conf))
436
+ for src, subj in m["parents"].items():
437
+ subj_votes[(src, subj)][eid] += conf
438
+ subj_nvotes[(src, subj)] += 1
439
+
440
+ def _to_basin(m, reason, gbox=None, fracs=None):
441
+ cands = [{"entity_id": e["id"], "likelihood": round(f, 4)}
442
+ for e, f in (fracs or []) if f >= 0.15]
443
+ cands.sort(key=lambda c: -c["likelihood"])
444
+ basin.append({"text": m["text"], "stratum": m["stratum"], "sources": m["sources"],
445
+ "consensus": m["consensus"], "reason": reason,
446
+ "grounded": gbox is not None,
447
+ "box": box_to_space(gbox, coord_space, size) if gbox else None,
448
+ "candidates": cands})
449
+
450
+ # ── pass A: scene routing, single-candidate fast path, grounded assignment ──
451
+ n_grounded_phrases = 0
452
+ for m in merged:
453
+ if m["stratum"] == "scene_level":
454
+ scene_attrs.append({"text": m["text"], "stratum": m["stratum"],
455
+ "sources": m["sources"]})
456
+ continue
457
+ gboxes = (grounded_by_phrase.get(m["text"].strip().lower(), [])
458
+ if is_groundable(m["stratum"]) else [])
459
+ if gboxes:
460
+ n_grounded_phrases += 1
461
+ cands, head_ok = _candidates(m)
462
+
463
+ if len(cands) == 1 and head_ok:
464
+ e = cands[0]
465
+ if gboxes:
466
+ f = _own_frac(gboxes[0]["box"], e, size, grid_cache)
467
+ if f < 0.2: # caption mentions something visibly NOT on this entity
468
+ _to_basin(m, "low_margin", gboxes[0]["box"], [(e, f)])
469
+ continue
470
+ _attach(e["id"], m, 0.9, "single_entity",
471
+ gbox=gboxes[0]["box"], gscore=gboxes[0]["score"])
472
+ else:
473
+ _attach(e["id"], m, 0.9, "single_entity")
474
+ continue
475
+
476
+ if gboxes:
477
+ if not cands: # zero entities survived detection — grounded but unownable
478
+ _to_basin(m, "low_margin", gboxes[0]["box"])
479
+ continue
480
+ top = gboxes[0]["score"]
481
+ accepted = [g for g in gboxes if g["score"] >= 0.75 * top]
482
+ taken_eids = set()
483
+ any_assigned = False
484
+ best_fracs = None
485
+ for g in accepted:
486
+ fracs = sorted(((e, _own_frac(g["box"], e, size, grid_cache))
487
+ for e in cands), key=lambda ef: -ef[1])
488
+ if best_fracs is None:
489
+ best_fracs = (g, fracs)
490
+ f1 = fracs[0][1]
491
+ f2 = fracs[1][1] if len(fracs) > 1 else 0.0
492
+ winner = fracs[0][0]
493
+ if winner["id"] in taken_eids:
494
+ continue
495
+ method = ("mask_containment"
496
+ if _entity_grid_mask(winner, size, grid_cache) is not None
497
+ else "box_containment")
498
+ if f1 >= t_own and (f1 - f2) >= t_margin:
499
+ taken_eids.add(winner["id"])
500
+ any_assigned = True
501
+ _attach(winner["id"], m, f1, method, margin=f1 - f2,
502
+ gbox=g["box"], gscore=g["score"])
503
+ if not any_assigned:
504
+ g, fracs = best_fracs
505
+ _to_basin(m, "low_margin", g["box"], fracs)
506
+ continue
507
+
508
+ unresolved.append((m, cands))
509
+
510
+ # ── binding: caption subjects ↔ entities (votes from grounded assignments
511
+ # + positional cues in subject names and raw captions) ───────────────────
512
+ def _positional_vote(text: str, cands: list, votes_out: dict):
513
+ if not cands:
514
+ return 0
515
+ toks = set(_content_tokens(text))
516
+ if toks & _POS_LEFT:
517
+ e = min(cands, key=lambda e: derive._centroid(e["box"])[0])
518
+ votes_out[e["id"]] += 0.5
519
+ return 1
520
+ if toks & _POS_RIGHT:
521
+ e = max(cands, key=lambda e: derive._centroid(e["box"])[0])
522
+ votes_out[e["id"]] += 0.5
523
+ return 1
524
+ if toks & _POS_FRONT and any(e.get("depth_rank") for e in cands):
525
+ e = min((e for e in cands if e.get("depth_rank")), key=lambda e: e["depth_rank"])
526
+ votes_out[e["id"]] += 0.5
527
+ return 1
528
+ if toks & _POS_BACK and any(e.get("depth_rank") for e in cands):
529
+ e = max((e for e in cands if e.get("depth_rank")), key=lambda e: e["depth_rank"])
530
+ votes_out[e["id"]] += 0.5
531
+ return 1
532
+ if toks & _POS_TALL:
533
+ e = max(cands, key=lambda e: e["box"][3] - e["box"][1])
534
+ votes_out[e["id"]] += 0.5
535
+ return 1
536
+ return 0
537
+
538
+ bindings = {} # (src, subject) -> (eid, bind_conf)
539
+ all_subjects = {(src, subj) for m in merged for src, subj in m["parents"].items()}
540
+ for (src, subj) in sorted(all_subjects):
541
+ head = _subject_head(subj)
542
+ cands = [e for e in ents if head and labels_match(head, e["label"])] or list(ents)
543
+ v = dict(subj_votes.get((src, subj), {}))
544
+ v = defaultdict(float, v)
545
+ nv = subj_nvotes.get((src, subj), 0)
546
+ pos_n = _positional_vote(subj, cands, v)
547
+ raw = raw_captions.get(src, "")
548
+ if raw and head:
549
+ # "<positional> [word] <head>" — tight adjacency, so a positional word
550
+ # in a NEIGHBORING clause can't vote for this subject
551
+ for mtc in re.finditer(rf"\b(\w+)\s+(?:\w+\s+)?{re.escape(head)}\b", raw.lower()):
552
+ pos_n += _positional_vote(mtc.group(1), cands, v)
553
+ # "<head> ... on the <positional>" — reject windows crossing an "and"
554
+ # (clause boundary: "a woman AND a man on the right")
555
+ for mtc in re.finditer(rf"\b{re.escape(head)}\b([\w\s,]{{0,24}}?)\bon the (\w+)",
556
+ raw.lower()):
557
+ if " and " in f" {mtc.group(1)} ":
558
+ continue
559
+ pos_n += _positional_vote(mtc.group(2), cands, v)
560
+ # bind on >=2 containment votes, OR any explicit positional cue (the caption
561
+ # author's own disambiguation — stronger evidence than one weak containment)
562
+ if not v or (nv < 2 and pos_n < 1):
563
+ continue
564
+ total = sum(v.values())
565
+ eid, top = max(sorted(v.items()), key=lambda kv: kv[1])
566
+ bind_conf = top / total if total > 0 else 0.0
567
+ if bind_conf >= 0.6:
568
+ bindings[(src, subj)] = (eid, bind_conf)
569
+ ent_out[eid]["caption_bindings"].append(
570
+ {"source": src, "subject_name": subj, "confidence": round(bind_conf, 4)})
571
+
572
+ # ── pass B: unresolved attributes inherit their subject's binding ───────────
573
+ for m, cands in unresolved:
574
+ # collapse per entity (max conf) with DETERMINISTIC iteration order —
575
+ # set iteration over tuples is process-hash-dependent
576
+ by_eid: dict = {}
577
+ for src, subj in sorted(m["parents"].items()):
578
+ if (src, subj) in bindings:
579
+ eid, conf = bindings[(src, subj)]
580
+ by_eid[eid] = max(by_eid.get(eid, 0.0), conf)
581
+ if len(by_eid) == 1:
582
+ eid, bind_conf = next(iter(by_eid.items()))
583
+ _attach(eid, m, bind_conf * 0.6, "caption_binding")
584
+ elif len(by_eid) > 1:
585
+ _to_basin(m, "ambiguous_binding",
586
+ fracs=sorted(((by_id[eid], conf) for eid, conf in by_eid.items()),
587
+ key=lambda ef: (-ef[1], ef[0]["id"])))
588
+ else:
589
+ reason = ("no_grounding_multi_entity" if is_groundable(m["stratum"])
590
+ else "abstract_unbound")
591
+ n_c = max(1, len(cands))
592
+ _to_basin(m, reason, fracs=[(e, 1.0 / n_c) for e in cands])
593
+
594
+ # ── actions: one person → attach as stratum "action"; else scene-level ─────
595
+ # (actions are NOT part of the attribute-routing identity
596
+ # assigned + basin + scene_level == phrases_total — separate accumulator)
597
+ scene_actions = []
598
+ action_confs = []
599
+ person_ents = [e for e in ents if labels_match(e["label"], "person")]
600
+ for m in merged_acts:
601
+ if len(person_ents) == 1:
602
+ e = person_ents[0]
603
+ ent_out[e["id"]]["attributes"].append(
604
+ {"text": m["text"], "stratum": "action", "sources": m["sources"],
605
+ "consensus": m["consensus"], "grounded": False, "box": None,
606
+ "grounding_score": None,
607
+ "ownership": {"confidence": 0.9, "margin": None,
608
+ "method": "single_entity"},
609
+ "region_on_owner": None})
610
+ action_confs.append(0.9)
611
+ else:
612
+ scene_actions.append({"text": m["text"], "stratum": "action",
613
+ "sources": m["sources"]})
614
+
615
+ # ── relations among the top-K entities by saliency ──────────────────────────
616
+ rel_ents = sorted(ents, key=lambda e: e["sal_rank"])[:MAX_RELATION_ENTITIES]
617
+ rel_ents = sorted(rel_ents, key=lambda e: [x["id"] for x in ents].index(e["id"]))
618
+ diag = (W ** 2 + H ** 2) ** 0.5 or 1.0
619
+ relations = []
620
+ for i in range(len(rel_ents)):
621
+ for j in range(i + 1, len(rel_ents)):
622
+ a, b = rel_ents[i], rel_ents[j]
623
+ # containment is orientation-independent: put the INNER entity first so
624
+ # "inside" always reads a-inside-b regardless of left-to-right id order
625
+ if (derive._contains(a["box"], b["box"])
626
+ and not derive._contains(b["box"], a["box"])):
627
+ a, b = b, a
628
+ ca, cb = derive._centroid(a["box"]), derive._centroid(b["box"])
629
+ depth_delta = (round(a["nearness"] - b["nearness"], 4)
630
+ if a["nearness"] is not None and b["nearness"] is not None
631
+ else None)
632
+ relations.append({
633
+ "a": a["id"], "b": b["id"],
634
+ "predicates": _pair_predicates(a, b),
635
+ "dx": round((cb[0] - ca[0]) / W, 4), "dy": round((cb[1] - ca[1]) / H, 4),
636
+ "distance": round(((cb[0] - ca[0]) ** 2 + (cb[1] - ca[1]) ** 2) ** 0.5 / diag, 4),
637
+ "iou": round(derive._iou(a["box"], b["box"]), 4),
638
+ "depth_delta": depth_delta,
639
+ "confidence": round(min(a["score"], b["score"]), 4),
640
+ })
641
+
642
+ # ── scene block ─────────────────────────────────────────────────────────────
643
+ set_votes = votes["setting"]
644
+ setting_val = None
645
+ if set_votes:
646
+ ranked = set_votes.most_common()
647
+ setting_val = ("unknown" if len(ranked) > 1 and ranked[0][1] == ranked[1][1]
648
+ else ranked[0][0])
649
+ style_votes = votes["style"]
650
+ style_val = digest.get("style") or (style_votes.most_common(1)[0][0]
651
+ if style_votes else None)
652
+ mood_per_source = votes["mood"]
653
+ mood_val = None
654
+ if mood_per_source:
655
+ mood_counts = Counter(mood_per_source.values())
656
+ mood_val = mood_counts.most_common(1)[0][0]
657
+ sym = digest.get("symmetry")
658
+ sym_axis = "none"
659
+ if sym:
660
+ v, h = sym["lr"] >= 0.80, sym["tb"] >= 0.80
661
+ sym_axis = "radial" if (v and h) else ("vertical" if v else ("horizontal" if h else "none"))
662
+ ocr_lines = []
663
+ for ln in digest.get("ocr", {}).get("lines", []):
664
+ q = ln.get("quad")
665
+ box = None
666
+ if q:
667
+ xs, ys = q[0::2], q[1::2]
668
+ box = box_to_space([min(xs), min(ys), max(xs), max(ys)], coord_space, size)
669
+ ocr_lines.append({"text": ln["text"], "box": box, "conf": ln.get("conf")})
670
+
671
+ scene = {
672
+ "setting": {"value": setting_val, "votes": dict(sorted(set_votes.items()))},
673
+ "style": {"value": style_val, "caption_votes": dict(sorted(style_votes.items())),
674
+ "specialist": digest.get("style")},
675
+ "mood": {"value": mood_val, "per_source": dict(sorted(mood_per_source.items()))},
676
+ "layout": digest.get("layout", "unknown"),
677
+ "symmetry": {"axis": sym_axis,
678
+ "lr": round(sym["lr"], 4) if sym else None,
679
+ "tb": round(sym["tb"], 4) if sym else None},
680
+ "actions": scene_actions,
681
+ "scene_attributes": scene_attrs,
682
+ "ocr": {"full_text": digest.get("ocr", {}).get("full_text", ""), "lines": ocr_lines},
683
+ "class_top": digest.get("class_top", []),
684
+ }
685
+
686
+ # ── quality + accounting ────────────────────────────────────────────────────
687
+ n_groundable = sum(1 for m in merged if is_groundable(m["stratum"]))
688
+ n_scene = len(scene_attrs)
689
+ n_ungroundable = sum(1 for m in merged
690
+ if not is_groundable(m["stratum"]) and m["stratum"] != "scene_level")
691
+ n_assigned_attrs = len({id(m) for m, _, _ in assigned_attrs})
692
+ mask_qualities = [e["mask_quality"] for e in ents if e.get("mask_quality") is not None]
693
+ ocr_confs = [l["conf"] for l in ocr_lines if l.get("conf") is not None]
694
+ det_mean = float(np.mean([e["score"] for e in ents])) if ents else 0.0
695
+ own_confs = [c for _, _, c in assigned_attrs] + action_confs
696
+ n_routed = n_assigned_attrs + len(basin)
697
+ overall = round(
698
+ 0.5 * (float(np.mean(own_confs)) if own_confs else 0.0)
699
+ + 0.3 * det_mean
700
+ + 0.2 * (n_assigned_attrs / n_routed if n_routed else 0.0), 4)
701
+
702
+ out = {
703
+ "coord_space": str(coord_space.value if hasattr(coord_space, "value") else coord_space),
704
+ "image_size": [int(W), int(H)],
705
+ "counts": {"total_entities": len(ents), "people": people,
706
+ "by_label": dict(sorted(counts.items()))},
707
+ "entities": [ent_out[e["id"]] for e in ents],
708
+ "relations": relations,
709
+ "shared_basin": basin,
710
+ "scene": scene,
711
+ "quality": {
712
+ "n_caption_sources": n_sources,
713
+ "detection_score_mean": round(det_mean, 4),
714
+ "mask_quality_mean": (round(float(np.mean(mask_qualities)), 4)
715
+ if mask_qualities else None),
716
+ "ocr_conf_mean": (round(float(np.mean(ocr_confs)), 4) if ocr_confs else None),
717
+ "grounding": {"phrases_total": len(merged),
718
+ "phrases_grounded": n_grounded_phrases,
719
+ "assigned": n_assigned_attrs,
720
+ "basin": len(basin),
721
+ "scene_level": n_scene,
722
+ "ungroundable": n_ungroundable},
723
+ "overall_confidence": overall,
724
+ },
725
+ }
726
+ # schema-validate + normalize field order (byte-determinism of json.dumps)
727
+ return FusedScene.model_validate(out).model_dump()
728
+
729
+
730
+ # ═════════════════════════════════════════════════════════════════════════════
731
+ # semantic_association — the 12th deterministic task (VLM→INTEGRATE reclass)
732
+ # ═════════════════════════════════════════════════════════════════════════════
733
+
734
+ def build_semantic_association(scene: dict, max_items: int = 32) -> dict:
735
+ """FusedScene → the EXISTING registry shape {associations:[{a,relation,b}]}
736
+ (enum: left_of/right_of/near/is_a/related_to). Deterministic: geometry gives
737
+ left_of/right_of/near; caption bindings give is_a (bound subject head vs the
738
+ detector label, e.g. woman is_a person)."""
739
+ out, seen = [], set()
740
+
741
+ def _emit(a, rel, b):
742
+ t = (a, rel, b)
743
+ if t not in seen and len(out) < max_items:
744
+ seen.add(t)
745
+ out.append({"a": a, "relation": rel, "b": b})
746
+
747
+ for r in scene.get("relations", []):
748
+ for p in r.get("predicates", []):
749
+ if p in ("left_of", "right_of"):
750
+ _emit(r["a"], p, r["b"])
751
+ if r.get("distance") is not None and r["distance"] <= _NEAR_DIST:
752
+ _emit(r["a"], "near", r["b"])
753
+ for e in scene.get("entities", []):
754
+ for cb in e.get("caption_bindings", []):
755
+ head = _subject_head(cb["subject_name"])
756
+ if head and head != e["label"] and labels_match(head, e["label"]):
757
+ _emit(head, "is_a", e["label"])
758
+ return {"associations": out}
qwen_test_runner/vision/fuse_prompt.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ fuse_prompt.py — deterministic natural-language rendering of a FusedScene.
3
+
4
+ A pure function of the fused JSON: fixed clause order (counts → primary entity →
5
+ other entities by saliency → relations → shared basin → scene), fixed attribute
6
+ ordering (consensus desc → ownership confidence desc → stratum precedence), zero
7
+ randomness — `fused_prompt(scene) == fused_prompt(scene)` byte-for-byte is a unit
8
+ test. Uncertainty is RENDERED, never guessed away: basin items become "One of
9
+ {candidates} has {attribute}." LLM smoothing is deliberately not here — if ever
10
+ wanted it is a separate additional dataset column, so this one stays trustworthy.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .strata import STRATUM_PRECEDENCE
16
+
17
+ _NUM_WORDS = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six",
18
+ 7: "seven", 8: "eight", 9: "nine", 10: "ten"}
19
+
20
+ _PRED_PHRASE = {"left_of": "to the left of", "right_of": "to the right of",
21
+ "above": "above", "below": "below", "inside": "inside",
22
+ "in_front_of": "in front of", "behind": "behind"}
23
+
24
+ _STRATUM_ORDER = {s: i for i, s in enumerate(STRATUM_PRECEDENCE + ("action",))}
25
+
26
+
27
+ def _num(n: int) -> str:
28
+ return _NUM_WORDS.get(n, str(n))
29
+
30
+
31
+ def _plural(label: str, n: int) -> str:
32
+ if n == 1:
33
+ return label
34
+ if label == "person":
35
+ return "people"
36
+ return label if label.endswith("s") else label + "s"
37
+
38
+
39
+ def _ref(entity_id: str) -> str:
40
+ """person_1 -> "person 1"; dog -> "the dog"."""
41
+ if "_" in entity_id and entity_id.rsplit("_", 1)[1].isdigit():
42
+ base, num = entity_id.rsplit("_", 1)
43
+ return f"{base.replace('_', ' ')} {num}"
44
+ return f"the {entity_id.replace('_', ' ')}"
45
+
46
+
47
+ def _cap(sentence: str) -> str:
48
+ return sentence[0].upper() + sentence[1:] if sentence else sentence
49
+
50
+
51
+ def _join(parts: list) -> str:
52
+ parts = [p for p in parts if p]
53
+ if not parts:
54
+ return ""
55
+ if len(parts) == 1:
56
+ return parts[0]
57
+ return ", ".join(parts[:-1]) + " and " + parts[-1]
58
+
59
+
60
+ def _ordered_attrs(entity: dict, max_attrs: int) -> list:
61
+ attrs = sorted(entity.get("attributes", []),
62
+ key=lambda a: (-a["consensus"], -a["ownership"]["confidence"],
63
+ _STRATUM_ORDER.get(a["stratum"], 99), a["text"]))
64
+ return attrs[:max_attrs]
65
+
66
+
67
+ def _entity_clause(entity: dict, intro: str, max_attrs: int, n_entities: int) -> str:
68
+ bits = [f"{intro} in the {entity['position']['grid']} of the frame"]
69
+ d = entity.get("depth")
70
+ if d and n_entities > 1:
71
+ if d["rank"] == 1:
72
+ bits.append("nearest to the camera")
73
+ elif d["rank"] == n_entities:
74
+ bits.append("farthest from the camera")
75
+ attrs = _ordered_attrs(entity, max_attrs)
76
+ # pose/action attributes read as participles ("…, sitting"), not "with sitting"
77
+ plain = [a["text"] for a in attrs if a["stratum"] not in ("action", "pose")]
78
+ acts = [a["text"] for a in attrs if a["stratum"] in ("action", "pose")]
79
+ s = ", ".join(bits)
80
+ if plain:
81
+ s += f", with {_join(plain)}"
82
+ if acts:
83
+ s += f", {_join(acts)}"
84
+ return _cap(s + ".")
85
+
86
+
87
+ def fused_prompt(scene: dict, max_attrs_per_entity: int = 6, max_relations: int = 6,
88
+ max_basin: int = 4) -> str:
89
+ sentences = []
90
+
91
+ # 1) counts
92
+ by_label = scene.get("counts", {}).get("by_label", {})
93
+ if by_label:
94
+ parts = [f"{_num(n)} {_plural(lab, n)}"
95
+ for lab, n in sorted(by_label.items(), key=lambda kv: (-kv[1], kv[0]))]
96
+ sentences.append(_cap(_join(parts) + "."))
97
+
98
+ # 2) entities, primary first, then by saliency rank
99
+ entities = scene.get("entities", [])
100
+ ordered = sorted(entities, key=lambda e: e["saliency"]["rank"])
101
+ n_ent = len(entities)
102
+ for k, e in enumerate(ordered):
103
+ if k == 0:
104
+ intro = f"the primary subject is a {e['label']}" if n_ent > 1 else f"a {e['label']}"
105
+ else:
106
+ intro = f"{_ref(e['id'])} is"
107
+ sentences.append(_entity_clause(e, intro, max_attrs_per_entity, n_ent))
108
+
109
+ # 3) relations (strongest-confidence first, capped)
110
+ rels = sorted(scene.get("relations", []),
111
+ key=lambda r: (-r["confidence"], r["a"], r["b"]))[:max_relations]
112
+ for r in rels:
113
+ phrases = [_PRED_PHRASE[p] for p in r.get("predicates", []) if p in _PRED_PHRASE]
114
+ if phrases:
115
+ sentences.append(_cap(f"{_ref(r['a'])} is {_join(phrases)} {_ref(r['b'])}."))
116
+
117
+ # 4) shared basin — uncertainty rendered, never guessed
118
+ for b in scene.get("shared_basin", [])[:max_basin]:
119
+ cands = [_ref(c["entity_id"]) for c in b.get("candidates", [])[:3]]
120
+ if cands:
121
+ joined = cands[0] if len(cands) == 1 else " or ".join(cands)
122
+ sentences.append(_cap(f"one of them ({joined}) has {b['text']}."))
123
+ else:
124
+ sentences.append(_cap(f"somewhere in the scene: {b['text']}."))
125
+
126
+ # 5) scene
127
+ sc = scene.get("scene", {})
128
+ bits = []
129
+ setting = (sc.get("setting") or {}).get("value")
130
+ if setting and setting != "unknown":
131
+ bits.append(f"{setting} scene")
132
+ style = (sc.get("style") or {}).get("value")
133
+ if style and style not in ("other", "unknown"):
134
+ bits.append(f"{style} style")
135
+ mood = (sc.get("mood") or {}).get("value")
136
+ if mood:
137
+ bits.append(f"{mood} mood")
138
+ layout = sc.get("layout")
139
+ if layout and layout not in ("unknown", "scattered"):
140
+ bits.append(f"{layout.replace('_', ' ')} composition")
141
+ if (sc.get("symmetry") or {}).get("axis", "none") != "none":
142
+ bits.append(f"{sc['symmetry']['axis']} symmetry")
143
+ if bits:
144
+ sentences.append(_cap(", ".join(bits) + "."))
145
+ for act in sc.get("actions", [])[:3]:
146
+ sentences.append(_cap(f"action in the scene: {act['text']}."))
147
+ for sa in sc.get("scene_attributes", [])[:4]:
148
+ sentences.append(_cap(f"{sa['text']}."))
149
+ ocr_text = (sc.get("ocr") or {}).get("full_text", "").strip()
150
+ if ocr_text:
151
+ sentences.append(_cap(f'visible text: "{ocr_text[:120]}".'))
152
+
153
+ return " ".join(sentences)
qwen_test_runner/vision/fuse_schema.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ fuse_schema.py — the FusedScene pydantic schema (fusion tier).
3
+
4
+ Hand-written nested models (the SubjectValue precedent — the SlotSpec codegen in
5
+ schema.py handles one nesting level; FusedScene needs three: entities ->
6
+ attributes -> ownership). Deliberately NOT a registered VisionTaskSpec: FusedScene
7
+ is a deterministically-produced dataset artifact, not a VLM probe — it needs no
8
+ GBNF, no system prompt, no scorer. If a VLM is ever trained to EMIT FusedScene,
9
+ register a flattened variant then.
10
+
11
+ Versioned from day one: this module is a second schema authority next to the
12
+ registry, so every instance stamps `fused_scene_version`.
13
+
14
+ Coordinate policy: all geometry emitted in the declared `coord_space`
15
+ (NORM_0_1000 by default, via specialists.box_to_space/poly_to_space); the one
16
+ pixel-unit field is `image_size`, documented as such. Scorers convert via
17
+ coords.to_canonical.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Optional
23
+
24
+ from pydantic import BaseModel, Field
25
+
26
+ FUSED_SCENE_VERSION = "1.0"
27
+
28
+ # Caps (data, referenced by fuse.py — never hardcoded at call sites)
29
+ MAX_ENTITIES = 12 # kept by saliency rank
30
+ MAX_RELATION_ENTITIES = 8 # pairwise relations among the top-K entities
31
+ MASK_POLY_MAX_POINTS = 64 # outline_polygon(max_points=...) per entity
32
+
33
+ # Basin reasons (closed set, tested)
34
+ BASIN_REASONS = ("low_margin", "no_grounding_multi_entity", "ambiguous_binding",
35
+ "abstract_unbound")
36
+
37
+ # Ownership methods (closed set, tested)
38
+ OWNERSHIP_METHODS = ("single_entity", "mask_containment", "box_containment",
39
+ "caption_binding")
40
+
41
+
42
+ class GridPosition(BaseModel):
43
+ grid: str # "{upper|middle|lower} {left|center|right}"
44
+ offset_from_center: list[float] # (centroid-center)/half-extents, in [-1,1]
45
+
46
+
47
+ class DepthInfo(BaseModel):
48
+ nearness: float # continuous [0,1], bigger = nearer
49
+ rank: int # 1 = nearest
50
+
51
+
52
+ class SaliencyInfo(BaseModel):
53
+ score: float
54
+ rank: int # 1 = most salient
55
+
56
+
57
+ class MaskInfo(BaseModel):
58
+ polygon: list[float] = Field(default_factory=list) # flat x,y interleaved, task space
59
+ quality: Optional[float] = None # SAM predicted-IoU (retained signal)
60
+
61
+
62
+ class CaptionBinding(BaseModel):
63
+ source: str # caption column key
64
+ subject_name: str
65
+ confidence: float
66
+
67
+
68
+ class Ownership(BaseModel):
69
+ confidence: float
70
+ margin: Optional[float] = None # f1 - f2 (containment methods only)
71
+ method: str # one of OWNERSHIP_METHODS
72
+
73
+
74
+ class RegionOnOwner(BaseModel):
75
+ vertical: str # upper | middle | lower
76
+ horizontal: str # left | center | right
77
+ offset: list[float] # (attr center - owner centroid)/owner half-extents
78
+
79
+
80
+ class AttributeRecord(BaseModel):
81
+ text: str # canonical (longest) form after dedup
82
+ stratum: str
83
+ sources: list[str] # caption columns that carried it
84
+ consensus: float # len(sources)/n_caption_sources
85
+ grounded: bool = False
86
+ box: Optional[list[float]] = None # present iff grounded (task space)
87
+ grounding_score: Optional[float] = None # GDINO phrase score
88
+ ownership: Ownership
89
+ region_on_owner: Optional[RegionOnOwner] = None
90
+
91
+
92
+ class Entity(BaseModel):
93
+ id: str # person_1, person_2, dog, ... (left-to-right)
94
+ label: str
95
+ detection_score: float
96
+ box: list[float] # task space
97
+ centroid: list[float] # task space
98
+ area_frac: float
99
+ position: GridPosition
100
+ depth: Optional[DepthInfo] = None
101
+ saliency: SaliencyInfo
102
+ is_primary: bool = False
103
+ mask: Optional[MaskInfo] = None
104
+ caption_bindings: list[CaptionBinding] = Field(default_factory=list)
105
+ attributes: list[AttributeRecord] = Field(default_factory=list)
106
+
107
+
108
+ class Relation(BaseModel):
109
+ a: str # entity id (smaller entity index)
110
+ b: str
111
+ predicates: list[str] # spatial_relations predicate vocab
112
+ dx: float # (centroid_b - centroid_a)/W, task-space-free
113
+ dy: float
114
+ distance: float # centroid distance / image diagonal
115
+ iou: float
116
+ depth_delta: Optional[float] = None # nearness_a - nearness_b (continuous)
117
+ confidence: float # min(detection scores)
118
+
119
+
120
+ class BasinCandidate(BaseModel):
121
+ entity_id: str
122
+ likelihood: float
123
+
124
+
125
+ class BasinItem(BaseModel):
126
+ text: str
127
+ stratum: str
128
+ sources: list[str]
129
+ consensus: float
130
+ reason: str # one of BASIN_REASONS
131
+ grounded: bool = False
132
+ box: Optional[list[float]] = None
133
+ candidates: list[BasinCandidate] = Field(default_factory=list)
134
+
135
+
136
+ class VotedValue(BaseModel):
137
+ value: Optional[str] = None
138
+ votes: dict[str, int] = Field(default_factory=dict)
139
+
140
+
141
+ class StyleValue(BaseModel):
142
+ value: Optional[str] = None # specialist wins conflicts (it saw the image)
143
+ caption_votes: dict[str, int] = Field(default_factory=dict)
144
+ specialist: Optional[str] = None
145
+
146
+
147
+ class MoodValue(BaseModel):
148
+ value: Optional[str] = None
149
+ per_source: dict[str, str] = Field(default_factory=dict)
150
+
151
+
152
+ class SymmetryInfo(BaseModel):
153
+ axis: str = "none"
154
+ lr: Optional[float] = None # continuous correlations (retained)
155
+ tb: Optional[float] = None
156
+
157
+
158
+ class SceneAttribute(BaseModel):
159
+ text: str
160
+ stratum: str = "scene_level"
161
+ sources: list[str] = Field(default_factory=list)
162
+
163
+
164
+ class SceneOCRLine(BaseModel):
165
+ text: str
166
+ box: Optional[list[float]] = None
167
+ conf: Optional[float] = None # EasyOCR confidence (retained)
168
+
169
+
170
+ class SceneOCR(BaseModel):
171
+ full_text: str = ""
172
+ lines: list[SceneOCRLine] = Field(default_factory=list)
173
+
174
+
175
+ class LabelScore(BaseModel):
176
+ label: str
177
+ score: float
178
+
179
+
180
+ class SceneBlock(BaseModel):
181
+ setting: VotedValue = Field(default_factory=VotedValue)
182
+ style: StyleValue = Field(default_factory=StyleValue)
183
+ mood: MoodValue = Field(default_factory=MoodValue)
184
+ layout: str = "unknown"
185
+ symmetry: SymmetryInfo = Field(default_factory=SymmetryInfo)
186
+ actions: list[SceneAttribute] = Field(default_factory=list) # unbound caption actions
187
+ scene_attributes: list[SceneAttribute] = Field(default_factory=list)
188
+ ocr: SceneOCR = Field(default_factory=SceneOCR)
189
+ class_top: list[LabelScore] = Field(default_factory=list)
190
+
191
+
192
+ class Counts(BaseModel):
193
+ total_entities: int = 0
194
+ people: int = 0 # via the person synonym group
195
+ by_label: dict[str, int] = Field(default_factory=dict)
196
+
197
+
198
+ class GroundingStats(BaseModel):
199
+ phrases_total: int = 0
200
+ phrases_grounded: int = 0
201
+ assigned: int = 0
202
+ basin: int = 0
203
+ scene_level: int = 0
204
+ ungroundable: int = 0
205
+
206
+
207
+ class Quality(BaseModel):
208
+ n_caption_sources: int = 0
209
+ detection_score_mean: float = 0.0
210
+ mask_quality_mean: Optional[float] = None
211
+ ocr_conf_mean: Optional[float] = None
212
+ grounding: GroundingStats = Field(default_factory=GroundingStats)
213
+ overall_confidence: float = 0.0 # the fusion_confidence scalar
214
+
215
+
216
+ class FusedScene(BaseModel):
217
+ fused_scene_version: str = FUSED_SCENE_VERSION
218
+ coord_space: str
219
+ image_size: list[int] # (W, H) PIXELS — the one pixel field
220
+ counts: Counts = Field(default_factory=Counts)
221
+ entities: list[Entity] = Field(default_factory=list)
222
+ relations: list[Relation] = Field(default_factory=list)
223
+ shared_basin: list[BasinItem] = Field(default_factory=list)
224
+ scene: SceneBlock = Field(default_factory=SceneBlock)
225
+ quality: Quality = Field(default_factory=Quality)
226
+
227
+
228
+ FUSED_SCENE_JSON_SCHEMA = FusedScene.model_json_schema()
qwen_test_runner/vision/fusion_metrics.py ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ fusion_metrics.py — validation scorers for the fusion tier (COCO multi-person).
3
+
4
+ Three metric classes, NEVER mixed in a report:
5
+ HARD — anchored to COCO instance GT (counts, entity F1, relation agreement)
6
+ PROXY — honestly-labeled plausibility checks (attribute-binding via mined
7
+ caption phrases + GDINO grounding — both sides are proxies; the rate
8
+ is reported with its coverage and skip histogram, never a headline)
9
+ STRUCTURAL — invariants (prompt determinism/faithfulness, no-invention, depth
10
+ internal consistency). PASS/FAIL, not accuracy.
11
+
12
+ Identity matters here: labels_match puts man/woman/person in ONE synonym group, so
13
+ string matching would silently accept identity SWAPS between two people. Every
14
+ identity-sensitive score therefore RELABELS-THEN-COMPARES through `match_entities`
15
+ (greedy IoU@0.5 fused-entity -> GT-instance assignment) before any set math.
16
+
17
+ Pure CPU (numpy + PIL rasterization via metrics helpers), torch-free.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from collections import Counter
24
+ from dataclasses import dataclass, field
25
+ from typing import Optional
26
+
27
+ import numpy as np
28
+
29
+ from . import derive
30
+ from .coords import CoordSpace, to_canonical
31
+ from .fuse_prompt import _num, _plural
32
+ from .fuse_schema import MAX_RELATION_ENTITIES
33
+ from .metrics import _seg_poly_points, _seg_rasterize, labels_match
34
+ from .strata import STRATA, _content_tokens
35
+
36
+ _GRID = 160
37
+ _IOU_MATCH = 0.5
38
+
39
+ # concrete-modifier gate for the phrase miner: colors + garments + accessories +
40
+ # a small holdable-object list (reuses the strata lexicons — one vocabulary)
41
+ _CONCRETE = (STRATA["color"] | STRATA["clothing"] | STRATA["accessory"]
42
+ | frozenset({"umbrella", "phone", "camera", "cup", "bottle", "book",
43
+ "ball", "racket", "bat", "kite", "surfboard", "skateboard",
44
+ "frisbee", "laptop", "pizza", "donut", "banana", "wine"}))
45
+
46
+ _PERSON_HEADS = ("person", "man", "woman", "boy", "girl", "lady", "guy", "child",
47
+ "kid", "player", "skier", "surfer")
48
+
49
+
50
+ # ═════════════════════════════════════════════════════════════════════════════
51
+ # Entity matching — relabel-then-compare
52
+ # ═════════════════════════════════════════════════════════════════════════════
53
+
54
+ @dataclass
55
+ class EntityMatch:
56
+ fused_to_gt: dict = field(default_factory=dict) # entity_id -> GT id ("p0" | "q0")
57
+ ious: dict = field(default_factory=dict) # entity_id -> matched IoU
58
+ unmatched_fused: list = field(default_factory=list)
59
+ unmatched_gt: list = field(default_factory=list) # GT ids with no fused entity
60
+
61
+
62
+ def _fused_boxes_px(fused: dict, size) -> dict:
63
+ """entity_id -> pixel-abs xyxy (converting from the scene's declared coord_space)."""
64
+ space = CoordSpace(fused.get("coord_space", "norm_0_1000"))
65
+ out = {}
66
+ for e in fused.get("entities", []):
67
+ out[e["id"]] = to_canonical(e["box"], space, size).as_list()
68
+ return out
69
+
70
+
71
+ def _greedy_iou_match(pred: list, gts: list, iou_thr: float = _IOU_MATCH) -> list:
72
+ """[(pred_idx, gt_idx, iou)] greedy by IoU desc, one-to-one."""
73
+ cand = []
74
+ for i, pb in enumerate(pred):
75
+ for j, gb in enumerate(gts):
76
+ iou = derive._iou(pb, gb)
77
+ if iou >= iou_thr:
78
+ cand.append((iou, i, j))
79
+ cand.sort(key=lambda t: (-t[0], t[1], t[2]))
80
+ used_p, used_g, out = set(), set(), []
81
+ for iou, i, j in cand:
82
+ if i in used_p or j in used_g:
83
+ continue
84
+ used_p.add(i)
85
+ used_g.add(j)
86
+ out.append((i, j, iou))
87
+ return out
88
+
89
+
90
+ def match_entities(fused: dict, gt: dict, size) -> EntityMatch:
91
+ """Persons match persons (IoU@0.5); non-person fused entities match GT objects
92
+ (label-tolerant + IoU@0.5). GT ids: persons p0..pN, objects q0..qM."""
93
+ boxes_px = _fused_boxes_px(fused, size)
94
+ ents = fused.get("entities", [])
95
+ m = EntityMatch()
96
+
97
+ p_ents = [e for e in ents if labels_match(e["label"], "person")]
98
+ p_boxes = [boxes_px[e["id"]] for e in p_ents]
99
+ g_boxes = [p["box_xyxy"] for p in gt.get("persons", [])]
100
+ for i, j, iou in _greedy_iou_match(p_boxes, g_boxes):
101
+ m.fused_to_gt[p_ents[i]["id"]] = f"p{j}"
102
+ m.ious[p_ents[i]["id"]] = round(iou, 4)
103
+
104
+ o_ents = [e for e in ents if not labels_match(e["label"], "person")]
105
+ gt_objs = gt.get("objects", [])
106
+ cand = []
107
+ for i, e in enumerate(o_ents):
108
+ for j, o in enumerate(gt_objs):
109
+ if not labels_match(e["label"], o["label"]):
110
+ continue
111
+ iou = derive._iou(boxes_px[e["id"]], o["box_xyxy"])
112
+ if iou >= _IOU_MATCH:
113
+ cand.append((iou, i, j))
114
+ cand.sort(key=lambda t: (-t[0], t[1], t[2]))
115
+ used_p, used_g = set(), set()
116
+ for iou, i, j in cand:
117
+ if i in used_p or j in used_g:
118
+ continue
119
+ used_p.add(i)
120
+ used_g.add(j)
121
+ m.fused_to_gt[o_ents[i]["id"]] = f"q{j}"
122
+ m.ious[o_ents[i]["id"]] = round(iou, 4)
123
+
124
+ m.unmatched_fused = sorted(e["id"] for e in ents if e["id"] not in m.fused_to_gt)
125
+ matched_gt = set(m.fused_to_gt.values())
126
+ m.unmatched_gt = ([f"p{j}" for j in range(len(g_boxes)) if f"p{j}" not in matched_gt]
127
+ + [f"q{j}" for j in range(len(gt_objs)) if f"q{j}" not in matched_gt])
128
+ return m
129
+
130
+
131
+ # ═════════════════════════════════════════════════════════════════════════════
132
+ # HARD scorers
133
+ # ═════════════════════════════════════════════════════════════════════════════
134
+
135
+ def score_person_count(fused: dict, gt: dict) -> dict:
136
+ pred, ref = fused.get("counts", {}).get("people", 0), gt.get("n_persons", 0)
137
+ return {"pred": pred, "gt": ref, "exact": pred == ref,
138
+ "off_by_one": abs(pred - ref) <= 1, "abs_err": abs(pred - ref)}
139
+
140
+
141
+ def score_entity_f1(fused: dict, gt: dict, size, match: Optional[EntityMatch] = None) -> dict:
142
+ match = match or match_entities(fused, gt, size)
143
+ n_pred_p = sum(1 for e in fused.get("entities", [])
144
+ if labels_match(e["label"], "person"))
145
+ n_gt_p = len(gt.get("persons", []))
146
+ tp = sum(1 for gid in match.fused_to_gt.values() if gid.startswith("p"))
147
+ prec = tp / n_pred_p if n_pred_p else 0.0
148
+ rec = tp / n_gt_p if n_gt_p else 0.0
149
+ f1 = 2 * prec * rec / (prec + rec) if (prec + rec) else 0.0
150
+ n_pred_all = len(fused.get("entities", []))
151
+ n_gt_all = n_gt_p + len(gt.get("objects", []))
152
+ tp_all = len(match.fused_to_gt)
153
+ prec_a = tp_all / n_pred_all if n_pred_all else 0.0
154
+ rec_a = tp_all / n_gt_all if n_gt_all else 0.0
155
+ f1_a = 2 * prec_a * rec_a / (prec_a + rec_a) if (prec_a + rec_a) else 0.0
156
+ return {"person_f1": round(f1, 4), "person_precision": round(prec, 4),
157
+ "person_recall": round(rec, 4), "all_f1": round(f1_a, 4)}
158
+
159
+
160
+ def _canon_triple(a: str, pred: str, b: str):
161
+ """Direction-normalize so (a,left_of,b) == (b,right_of,a) compares equal.
162
+ `inside` stays directional."""
163
+ flip = {"right_of": "left_of", "below": "above", "behind": "in_front_of"}
164
+ if pred in flip:
165
+ return (b, flip[pred], a)
166
+ return (a, pred, b)
167
+
168
+
169
+ def _gt_triples(gt: dict) -> set:
170
+ """Deterministic GT-side relations: the SAME derive engine run on GT boxes
171
+ (projective + containment only — COCO has no depth GT). For a contained pair,
172
+ derive emits BOTH (inner, inside, outer) and a projective from the reverse
173
+ ordering — fuse emits ONE predicate per pair (containment-first), so the
174
+ projective is dropped here whenever the pair carries `inside` (otherwise
175
+ relation recall is structurally capped at 0.5 on containment pairs)."""
176
+ boxes = ([{"label": f"p{j}", "box": p["box_xyxy"]}
177
+ for j, p in enumerate(gt.get("persons", []))]
178
+ + [{"label": f"q{j}", "box": o["box_xyxy"]}
179
+ for j, o in enumerate(gt.get("objects", []))])
180
+ rels = derive.spatial_relations(boxes, depth=None, max_items=10_000)["relations"]
181
+ triples = {_canon_triple(r["subject"], r["predicate"], r["object"]) for r in rels}
182
+ inside_pairs = {frozenset((s, o)) for s, p, o in triples if p == "inside"}
183
+ return {t for t in triples
184
+ if t[1] == "inside" or frozenset((t[0], t[2])) not in inside_pairs}
185
+
186
+
187
+ def score_relation_agreement(fused: dict, gt: dict, size,
188
+ match: Optional[EntityMatch] = None) -> dict:
189
+ """Relabel-then-compare triple F1, matched (both endpoints matched) AND
190
+ unconditional (unmatched endpoints count as FP/FN — the joint-yield view).
191
+ Depth predicates are EXCLUDED (no GT); projective + inside only."""
192
+ match = match or match_entities(fused, gt, size)
193
+ gt_set = _gt_triples(gt)
194
+
195
+ pred_matched, n_pred_unmatched = set(), 0
196
+ for r in fused.get("relations", []):
197
+ ga, gb = match.fused_to_gt.get(r["a"]), match.fused_to_gt.get(r["b"])
198
+ for p in r.get("predicates", []):
199
+ if p in ("in_front_of", "behind"):
200
+ continue
201
+ if ga and gb:
202
+ pred_matched.add(_canon_triple(ga, p, gb))
203
+ else:
204
+ n_pred_unmatched += 1
205
+
206
+ def _f1(preds: set, gts: set, extra_fp: int = 0, extra_fn: int = 0):
207
+ tp = len(preds & gts)
208
+ n_p, n_g = len(preds) + extra_fp, len(gts) + extra_fn
209
+ prec = tp / n_p if n_p else 0.0
210
+ rec = tp / n_g if n_g else 0.0
211
+ return round(2 * prec * rec / (prec + rec), 4) if (prec + rec) else 0.0
212
+
213
+ # condition on RELATION-ELIGIBLE fused entities (fuse only emits relations
214
+ # among the top-K by saliency) — else >K-entity scenes accrue structural FNs
215
+ eligible = {e["id"] for e in fused.get("entities", [])
216
+ if e["saliency"]["rank"] <= MAX_RELATION_ENTITIES}
217
+ matched_gt_ids = {gid for eid, gid in match.fused_to_gt.items() if eid in eligible}
218
+ gt_cond = {t for t in gt_set if t[0] in matched_gt_ids and t[2] in matched_gt_ids}
219
+ n_gt_uncond_extra = len(gt_set) - len(gt_cond)
220
+ return {"matched_f1": _f1(pred_matched, gt_cond),
221
+ "uncond_f1": _f1(pred_matched, gt_cond,
222
+ extra_fp=n_pred_unmatched, extra_fn=n_gt_uncond_extra),
223
+ "n_pred": len(pred_matched) + n_pred_unmatched, "n_gt": len(gt_set)}
224
+
225
+
226
+ # ═════════════════════════════════════════════════════════════════════════════
227
+ # STRUCTURAL checks
228
+ # ═════════════════════════════════════════════════════════════════════════════
229
+
230
+ def check_depth_consistency(fused: dict) -> dict:
231
+ """COCO has no depth GT — this checks INTERNAL consistency only: the
232
+ in_front_of digraph must be acyclic and each pair's predicate must agree with
233
+ the sign of its continuous depth_delta."""
234
+ edges = []
235
+ sign_ok = True
236
+ for r in fused.get("relations", []):
237
+ d = r.get("depth_delta")
238
+ if "in_front_of" in r.get("predicates", []):
239
+ edges.append((r["a"], r["b"]))
240
+ sign_ok &= d is None or d > 0
241
+ elif "behind" in r.get("predicates", []):
242
+ edges.append((r["b"], r["a"]))
243
+ sign_ok &= d is None or d < 0
244
+ adj = {}
245
+ for a, b in edges:
246
+ adj.setdefault(a, []).append(b)
247
+ seen, stack = set(), set()
248
+
249
+ def _cyclic(u):
250
+ seen.add(u)
251
+ stack.add(u)
252
+ for v in adj.get(u, []):
253
+ if v in stack or (v not in seen and _cyclic(v)):
254
+ return True
255
+ stack.discard(u)
256
+ return False
257
+
258
+ acyclic = not any(_cyclic(u) for u in list(adj) if u not in seen)
259
+ return {"pass": bool(acyclic and sign_ok), "acyclic": acyclic, "sign_ok": sign_ok}
260
+
261
+
262
+ def check_no_invention(fused: dict, caption_structs: dict) -> dict:
263
+ """Every fused attribute/basin/scene-attribute/action text must be VERBATIM one
264
+ of the caption leaves — fusion routes and drops, never invents."""
265
+ leaves = set()
266
+ for st in caption_structs.values():
267
+ if not st:
268
+ continue
269
+ for s in (st.get("subjects") or []):
270
+ leaves.update(str(a).strip() for a in (s.get("attributes") or []))
271
+ leaves.update(str(a).strip() for a in (st.get("actions") or []))
272
+ offenders = []
273
+ for e in fused.get("entities", []):
274
+ offenders += [a["text"] for a in e.get("attributes", []) if a["text"] not in leaves]
275
+ offenders += [b["text"] for b in fused.get("shared_basin", []) if b["text"] not in leaves]
276
+ offenders += [sa["text"] for sa in fused.get("scene", {}).get("scene_attributes", [])
277
+ if sa["text"] not in leaves]
278
+ offenders += [sa["text"] for sa in fused.get("scene", {}).get("actions", [])
279
+ if sa["text"] not in leaves]
280
+ return {"pass": not offenders, "offenders": sorted(set(offenders))[:10]}
281
+
282
+
283
+ # words the deterministic template itself contributes (never counted as ungrounded)
284
+ _TEMPLATE_GLUE = frozenset("""
285
+ the a an and or of in is are with has have to them one two three four five six seven
286
+ eight nine ten primary subject frame camera scene style mood composition action
287
+ visible text left right front behind above below inside nearest farthest from upper
288
+ middle lower center people person persons dog dogs first second them somewhere
289
+ likely either symmetry
290
+ """.split())
291
+
292
+
293
+ def _scene_value_tokens(obj) -> set:
294
+ out = set()
295
+ if isinstance(obj, dict):
296
+ for v in obj.values():
297
+ out |= _scene_value_tokens(v)
298
+ elif isinstance(obj, (list, tuple)):
299
+ for v in obj:
300
+ out |= _scene_value_tokens(v)
301
+ elif isinstance(obj, str):
302
+ out.update(_content_tokens(obj))
303
+ out.update(t for part in obj.split("_") for t in _content_tokens(part))
304
+ return out
305
+
306
+
307
+ def check_prompt_faithfulness(fused: dict, prompt: str, re_render: Optional[str] = None) -> dict:
308
+ """(1) determinism (caller passes a fresh re-render), (2) every prompt content
309
+ token traceable to a scene string value or the fixed template vocabulary,
310
+ (3) inverse coverage: fraction of fused attribute/basin texts surfaced."""
311
+ deterministic = re_render is None or prompt == re_render
312
+ scene_toks = set(_scene_value_tokens(fused)) | _TEMPLATE_GLUE
313
+ # the template's own count renderings ("two cars") and truncated OCR quote
314
+ for lab, cnt in (fused.get("counts", {}).get("by_label") or {}).items():
315
+ scene_toks.update(_content_tokens(f"{_num(cnt)} {_plural(lab, cnt)}"))
316
+ ocr_txt = ((fused.get("scene") or {}).get("ocr") or {}).get("full_text", "")
317
+ if ocr_txt:
318
+ scene_toks.update(_content_tokens(ocr_txt[:120]))
319
+ p_toks = set(_content_tokens(prompt))
320
+ offending = sorted(p_toks - scene_toks)
321
+ grounded_rate = 1.0 - (len(offending) / len(p_toks) if p_toks else 0.0)
322
+
323
+ texts = ([a["text"] for e in fused.get("entities", []) for a in e.get("attributes", [])]
324
+ + [b["text"] for b in fused.get("shared_basin", [])])
325
+ covered = sum(1 for t in texts if t.lower() in prompt.lower())
326
+ coverage = covered / len(texts) if texts else 1.0
327
+ return {"deterministic": deterministic, "grounded_rate": round(grounded_rate, 4),
328
+ "offending_tokens": offending[:8], "coverage": round(coverage, 4)}
329
+
330
+
331
+ # ═════════════════════════════════════════════════════════════════════════════
332
+ # PROXY — attribute-binding plausibility on COCO captions
333
+ # ═════════════════════════════════════════════════════════════════════════════
334
+
335
+ _PHRASE_RE = re.compile(
336
+ r"\b(?:a|an|the|one|two|young|old|little)?\s*"
337
+ r"(" + "|".join(_PERSON_HEADS) + r")\s+"
338
+ r"(?:is\s+|are\s+)?(wearing|wears|in|holding|holds|with|carrying|carries)\s+"
339
+ r"((?:[\w-]+\s*){1,4})", re.IGNORECASE)
340
+
341
+
342
+ def mine_person_phrases(captions: list) -> list:
343
+ """Regex NP miner over raw captions: person-head + wearing/holding/in/with +
344
+ a modifier containing a CONCRETE visual token (color/garment/holdable).
345
+ Abstract modifiers are rejected — they can't be grounded honestly."""
346
+ out, seen = [], set()
347
+ for ci, cap in enumerate(captions or []):
348
+ for m in _PHRASE_RE.finditer(cap):
349
+ head, verb, mod = m.group(1).lower(), m.group(2).lower(), m.group(3).strip()
350
+ mod_toks = _content_tokens(mod)
351
+ if not any(t in _CONCRETE for t in mod_toks):
352
+ continue
353
+ phrase = f"{head} {verb} {mod}".strip()
354
+ if phrase in seen:
355
+ continue
356
+ seen.add(phrase)
357
+ out.append({"phrase": phrase, "head": head, "modifier": mod,
358
+ "caption_idx": ci})
359
+ return out
360
+
361
+
362
+ def _gt_person_grid_masks(gt: dict, size) -> list:
363
+ """Rasterize every GT person (union of ALL polygon parts) onto the grid."""
364
+ W, H = size
365
+ out = []
366
+ for p in gt.get("persons", []):
367
+ acc = None
368
+ for poly in p.get("polygons", []):
369
+ m = _seg_rasterize(_seg_poly_points(poly), _GRID,
370
+ _GRID / max(1.0, W), _GRID / max(1.0, H))
371
+ if m is not None:
372
+ acc = m if acc is None else (acc | m)
373
+ out.append(acc)
374
+ return out
375
+
376
+
377
+ def score_attr_plausibility(fused: dict, grounded_phrases: list, gt: dict, size,
378
+ match: Optional[EntityMatch] = None) -> dict:
379
+ """grounded_phrases: mined phrases + their GDINO box (pixel) + score, grounded
380
+ by the Colab cell. Verdict per CHECKABLE phrase: does fusion's owner map to the
381
+ same GT person whose mask contains the phrase box? PROXY — reported with
382
+ coverage and a skip histogram, never a headline number."""
383
+ match = match or match_entities(fused, gt, size)
384
+ W, H = size
385
+ gmasks = _gt_person_grid_masks(gt, size)
386
+ skips = Counter()
387
+ checked, agree = 0, 0
388
+ for g in grounded_phrases or []:
389
+ box = g.get("box")
390
+ if box is None or g.get("score", 0.0) < 0.35:
391
+ skips["low_score"] += 1
392
+ continue
393
+ frac_area = derive._area(box) / (W * H + 1e-9)
394
+ if not (0.001 <= frac_area <= 0.9):
395
+ skips["bad_area"] += 1
396
+ continue
397
+ x1 = int(np.clip(box[0] / W * _GRID, 0, _GRID))
398
+ y1 = int(np.clip(box[1] / H * _GRID, 0, _GRID))
399
+ x2 = int(np.clip(np.ceil(box[2] / W * _GRID), 0, _GRID))
400
+ y2 = int(np.clip(np.ceil(box[3] / H * _GRID), 0, _GRID))
401
+ cells = max(1, (x2 - x1) * (y2 - y1))
402
+ owners = [j for j, m in enumerate(gmasks)
403
+ if m is not None and float(m[y1:y2, x1:x2].sum()) / cells >= 0.5]
404
+ if len(owners) != 1:
405
+ skips["ambiguous" if len(owners) > 1 else "no_gt_owner"] += 1
406
+ continue
407
+ gt_owner = f"p{owners[0]}"
408
+ # fusion side: the attribute whose tokens best overlap the modifier
409
+ mod_toks = set(_content_tokens(g["modifier"]))
410
+ best, best_ov = None, 0
411
+ for e in fused.get("entities", []):
412
+ for a in e.get("attributes", []):
413
+ ov = len(mod_toks & set(_content_tokens(a["text"])))
414
+ if ov > best_ov:
415
+ best, best_ov = e["id"], ov
416
+ if best is None or best_ov == 0:
417
+ skips["no_matching_attr"] += 1
418
+ continue
419
+ fused_gt = match.fused_to_gt.get(best)
420
+ if fused_gt is None:
421
+ skips["owner_unmatched"] += 1
422
+ continue
423
+ checked += 1
424
+ agree += int(fused_gt == gt_owner)
425
+ mined = len(grounded_phrases or [])
426
+ return {"plausible_rate": round(agree / checked, 4) if checked else None,
427
+ "checked": checked, "mined": mined,
428
+ "coverage": round(checked / mined, 4) if mined else 0.0,
429
+ "skips": dict(skips)}
430
+
431
+
432
+ # ═════════════════════════════════════════════════════════════════════════════
433
+ # Aggregation + report
434
+ # ═════════════════════════════════════════════════════════════════════════════
435
+
436
+ def score_fusion_sample(fused: dict, prompt: str, gt: dict, *, size,
437
+ re_render: Optional[str] = None,
438
+ grounded_phrases: Optional[list] = None,
439
+ caption_structs: Optional[dict] = None) -> dict:
440
+ match = match_entities(fused, gt, size)
441
+ out = {
442
+ "count": score_person_count(fused, gt),
443
+ "entity": score_entity_f1(fused, gt, size, match),
444
+ "relation": score_relation_agreement(fused, gt, size, match),
445
+ "depth": check_depth_consistency(fused),
446
+ "prompt": check_prompt_faithfulness(fused, prompt, re_render),
447
+ "basin_rate": (len(fused.get("shared_basin", []))
448
+ / max(1, fused.get("quality", {}).get("grounding", {})
449
+ .get("phrases_total", 0) or 1)),
450
+ "match": {"matched": len(match.fused_to_gt),
451
+ "unmatched_fused": match.unmatched_fused,
452
+ "unmatched_gt": match.unmatched_gt},
453
+ }
454
+ if grounded_phrases is not None:
455
+ out["plausibility"] = score_attr_plausibility(fused, grounded_phrases, gt,
456
+ size, match)
457
+ if caption_structs is not None:
458
+ out["no_invention"] = check_no_invention(fused, caption_structs)
459
+ return out
460
+
461
+
462
+ def score_fusion_run(results: list) -> dict:
463
+ n = max(1, len(results))
464
+ plaus = [r["plausibility"] for r in results if r.get("plausibility")]
465
+ checked = sum(p["checked"] for p in plaus)
466
+ agree = sum(round(p["plausible_rate"] * p["checked"]) for p in plaus
467
+ if p["plausible_rate"] is not None)
468
+ skips = Counter()
469
+ for p in plaus:
470
+ skips.update(p["skips"])
471
+ return {
472
+ "n": len(results),
473
+ "person_count_exact": round(sum(r["count"]["exact"] for r in results) / n, 4),
474
+ "person_count_off1": round(sum(r["count"]["off_by_one"] for r in results) / n, 4),
475
+ "person_count_mae": round(sum(r["count"]["abs_err"] for r in results) / n, 4),
476
+ "person_count_over": round(sum(r["count"]["pred"] > r["count"]["gt"]
477
+ for r in results) / n, 4),
478
+ "person_count_under": round(sum(r["count"]["pred"] < r["count"]["gt"]
479
+ for r in results) / n, 4),
480
+ "entity_f1_person": round(sum(r["entity"]["person_f1"] for r in results) / n, 4),
481
+ "entity_f1_all": round(sum(r["entity"]["all_f1"] for r in results) / n, 4),
482
+ "relation_f1_matched": round(sum(r["relation"]["matched_f1"] for r in results) / n, 4),
483
+ "relation_f1_uncond": round(sum(r["relation"]["uncond_f1"] for r in results) / n, 4),
484
+ "prompt_grounded_rate": round(sum(r["prompt"]["grounded_rate"] for r in results) / n, 4),
485
+ "prompt_coverage": round(sum(r["prompt"]["coverage"] for r in results) / n, 4),
486
+ "prompt_determinism": sum(r["prompt"]["deterministic"] for r in results),
487
+ "depth_consistency": sum(r["depth"]["pass"] for r in results),
488
+ "no_invention": all(r.get("no_invention", {}).get("pass", True) for r in results),
489
+ "basin_rate_mean": round(sum(r["basin_rate"] for r in results) / n, 4),
490
+ "plausible_rate": round(agree / checked, 4) if checked else None,
491
+ "plaus_checked": checked,
492
+ "plaus_mined": sum(p["mined"] for p in plaus),
493
+ "plaus_skips": dict(skips),
494
+ }
495
+
496
+
497
+ def format_fusion_report(agg: dict, header: str = "") -> str:
498
+ n = agg["n"]
499
+ lines = [
500
+ "═" * 74,
501
+ f"FUSION VALIDATION — {header} n={n} (clean 2–6-person slice; crowds + tiny "
502
+ "persons excluded)",
503
+ "═" * 74,
504
+ "HARD (GT-anchored)",
505
+ f" person_count_exact {agg['person_count_exact']:.2f} (±1: {agg['person_count_off1']:.2f}, MAE {agg['person_count_mae']:.2f}, "
506
+ f"over {agg.get('person_count_over', 0):.2f} / under {agg.get('person_count_under', 0):.2f})",
507
+ f" entity_f1 person@0.5 {agg['entity_f1_person']:.3f} all-objects {agg['entity_f1_all']:.3f}",
508
+ f" relation_f1 matched {agg['relation_f1_matched']:.3f} unconditional {agg['relation_f1_uncond']:.3f}",
509
+ f" prompt_grounded_rate {agg['prompt_grounded_rate']:.3f} prompt_coverage {agg['prompt_coverage']:.3f}",
510
+ "",
511
+ "PROXY (labeled honestly — never a headline)",
512
+ (f" attr_binding_plausible {agg['plausible_rate']:.2f} over {agg['plaus_checked']} checkable"
513
+ if agg.get("plausible_rate") is not None else " attr_binding_plausible n/a (0 checkable)"),
514
+ f" mined {agg.get('plaus_mined', 0)} skips {agg.get('plaus_skips', {})}",
515
+ f" basin_rate_mean {agg['basin_rate_mean']:.3f}",
516
+ "",
517
+ "STRUCTURAL (PASS/FAIL)",
518
+ f" prompt_determinism {agg['prompt_determinism']}/{n}",
519
+ f" attr_no_invention {'PASS' if agg['no_invention'] else 'FAIL'}",
520
+ f" depth_internal_consistency {agg['depth_consistency']}/{n} (no GT — consistency only)",
521
+ "═" * 74,
522
+ ]
523
+ return "\n".join(lines)
qwen_test_runner/vision/metrics.py ADDED
@@ -0,0 +1,997 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ metrics.py — Vision scoring (replaces the text substring-grounding metric).
3
+
4
+ Every category carries two UNIVERSAL metrics that encode the project's thesis —
5
+ robust, schema-valid JSON — plus a category-specific accuracy scorer:
6
+
7
+ schema_valid : did the output validate against the category's Pydantic model
8
+ (after the never-raises recovery walk)?
9
+ json_robust : did it parse WITHOUT repair (clean bare JSON)? This, measured
10
+ in json_mode, is the native-capability signal driving the
11
+ no-finetune decision.
12
+
13
+ The headline `labeler_score` MULTIPLIES accuracy by validity and robustness, so a
14
+ model that is accurate but emits fragile JSON scores worse than a slightly less
15
+ accurate model that emits clean JSON — exactly what you want when pointing a
16
+ labeler at a million images with no human in the loop.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import re
23
+ from dataclasses import asdict, dataclass
24
+ from typing import Callable, Optional
25
+
26
+ from ..evaluator import parse_against
27
+ from .coords import XYWH, XYXY, BBox, CoordSpace, to_canonical
28
+ from .tasks_vision import VisionTaskSpec, model_for
29
+
30
+
31
+ # ──────────────────────────────────────────────────────────────────────────────
32
+ # Result types
33
+ # ──────────────────────────────────────────────────────────────────────────────
34
+
35
+ @dataclass
36
+ class MetricResult:
37
+ category: str
38
+ image_id: str
39
+ mode: str
40
+ parse_ok: bool # a JSON object was recovered + decoded (maybe invalid schema)
41
+ schema_valid: bool # validated against the category model
42
+ needed_repair: bool # recovery had to strip fences / skip prose / trim junk
43
+ grammar_conformant: bool # constrained decoding actually applied (backend == xgrammar)
44
+ primary_score: Optional[float] # task accuracy 0..1; None if no GT / invalid
45
+ metrics: dict
46
+ needed_structural_repair: bool = False # repair beyond a benign fence strip
47
+ n_output_tokens: int = 0
48
+ gen_seconds: float = 0.0
49
+ error: Optional[str] = None
50
+ notes: str = ""
51
+
52
+ @property
53
+ def json_robust(self) -> bool:
54
+ """Valid JSON that needed no STRUCTURAL repair — the native-capability signal.
55
+ A benign markdown-fence wrap is tolerated (fence-stripping is deterministic);
56
+ prose/runaway/malformed is not."""
57
+ return self.schema_valid and not self.needed_structural_repair
58
+
59
+ def to_dict(self) -> dict:
60
+ d = asdict(self)
61
+ d["json_robust"] = self.json_robust
62
+ return d
63
+
64
+
65
+ @dataclass
66
+ class VisionRunMetrics:
67
+ category: str
68
+ model: str
69
+ reasoning: str
70
+ mode: str
71
+ n: int
72
+ schema_valid_rate: float
73
+ json_robustness: float
74
+ has_task_score: bool
75
+ primary_score_mean: Optional[float]
76
+ metrics_mean: dict
77
+ mean_output_tokens: float
78
+ total_gen_seconds: float
79
+ tokens_per_sec: float
80
+ labeler_score: Optional[float]
81
+
82
+ def __str__(self) -> str:
83
+ acc = "n/a" if self.primary_score_mean is None else f"{self.primary_score_mean:.3f}"
84
+ lab = "n/a" if self.labeler_score is None else f"{self.labeler_score:.3f}"
85
+ return (f"[{self.model}/{self.reasoning}/{self.category}/{self.mode}] n={self.n} "
86
+ f"valid={self.schema_valid_rate:.1%} robust={self.json_robustness:.1%} "
87
+ f"acc={acc} labeler={lab} tok/s={self.tokens_per_sec:.0f}")
88
+
89
+
90
+ # ──────────────────────────────────────────────────────────────────────────────
91
+ # The labeler-selection composite (the verdict core)
92
+ # ──────────────────────────────────────────────────────────────────────────────
93
+
94
+ def labeler_score(accuracy: Optional[float], schema_valid_rate: float,
95
+ json_robustness: float) -> Optional[float]:
96
+ """Multiplicative composite: accuracy × validity-gate × robustness-penalty.
97
+
98
+ Invalid JSON is unusable (hard-ish cap via the 0.5+0.5·valid term); fragile
99
+ but repairable JSON is penalized, not killed (0.7+0.3·robust term).
100
+ """
101
+ if accuracy is None:
102
+ return None
103
+ return accuracy * (0.5 + 0.5 * schema_valid_rate) * (0.7 + 0.3 * json_robustness)
104
+
105
+
106
+ # ──────────────────────────────────────────────────────────────────────────────
107
+ # Small pure helpers (no external deps — editdistance/jiwer are optional accel)
108
+ # ──────────────────────────────────────────────────────────────────────────────
109
+
110
+ def _norm_text(s: str) -> str:
111
+ return re.sub(r"\s+", " ", (s or "").strip().lower())
112
+
113
+
114
+ def _levenshtein(a: list, b: list) -> int:
115
+ if a == b:
116
+ return 0
117
+ if not a:
118
+ return len(b)
119
+ if not b:
120
+ return len(a)
121
+ prev = list(range(len(b) + 1))
122
+ for i, ca in enumerate(a, 1):
123
+ cur = [i]
124
+ for j, cb in enumerate(b, 1):
125
+ cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
126
+ prev = cur
127
+ return prev[-1]
128
+
129
+
130
+ def _cer(pred: str, gt: str) -> float:
131
+ g = _norm_text(gt)
132
+ p = _norm_text(pred)
133
+ if not g:
134
+ return 0.0 if not p else 1.0
135
+ return _levenshtein(list(p), list(g)) / len(g)
136
+
137
+
138
+ def _wer(pred: str, gt: str) -> float:
139
+ g = _norm_text(gt).split()
140
+ p = _norm_text(pred).split()
141
+ if not g:
142
+ return 0.0 if not p else 1.0
143
+ return _levenshtein(p, g) / len(g)
144
+
145
+
146
+ # ──────────────────────────────────────────────────────────────────────────────
147
+ # Tolerant label matching — VLMs use richer / synonymous labels than dataset
148
+ # vocabularies (e.g. "television" vs COCO's "tv"). Without this, correct boxes are
149
+ # discarded on a string mismatch (observed: Qwen3-VL localizes COCO near-perfectly
150
+ # but exact-match F1 read ~0.25). Synonym groups + substring + plural fallback.
151
+ # ──────────────────────────────────────────────────────────────────────────────
152
+
153
+ _SYNONYM_GROUPS = [
154
+ {"tv", "television", "televisions", "telly", "monitor", "screen"},
155
+ {"couch", "sofa", "settee", "loveseat"},
156
+ {"motorcycle", "motorbike", "moped", "scooter"},
157
+ {"airplane", "aeroplane", "plane", "aircraft", "jet"},
158
+ {"cell phone", "cellphone", "mobile phone", "mobile", "phone", "smartphone"},
159
+ {"potted plant", "houseplant", "plant", "pot plant", "flowerpot", "flower pot"},
160
+ {"dining table", "table", "desk"},
161
+ {"car", "automobile", "sedan", "vehicle"},
162
+ {"bicycle", "bike", "cycle"},
163
+ {"person", "people", "man", "men", "woman", "women", "human", "boy", "girl",
164
+ "child", "kid", "pedestrian", "player", "lady", "guy", "skier", "surfer",
165
+ "rider", "athlete", "batter", "pitcher", "catcher"},
166
+ {"hot dog", "hotdog"},
167
+ {"donut", "doughnut"},
168
+ {"remote", "remote control"},
169
+ {"sports ball", "ball"},
170
+ {"wine glass", "wineglass", "glass"},
171
+ {"tie", "necktie"},
172
+ ]
173
+ _SYN_GROUP: dict[str, int] = {}
174
+ for _gi, _grp in enumerate(_SYNONYM_GROUPS):
175
+ for _w in _grp:
176
+ _SYN_GROUP[_w] = _gi
177
+
178
+
179
+ def _depluralize(t: str) -> str:
180
+ if len(t) <= 3:
181
+ return t
182
+ if t.endswith("ies"):
183
+ return t[:-3] + "y"
184
+ if t.endswith("es"):
185
+ return t[:-2]
186
+ if t.endswith("s"):
187
+ return t[:-1]
188
+ return t
189
+
190
+
191
+ def labels_match(a: str, b: str) -> bool:
192
+ """True if two object labels refer to the same thing (tolerant)."""
193
+ a, b = _norm_text(a), _norm_text(b)
194
+ if not a or not b:
195
+ return False
196
+ if a == b:
197
+ return True
198
+ if _depluralize(a) == _depluralize(b):
199
+ return True
200
+ ga, gb = _SYN_GROUP.get(a), _SYN_GROUP.get(b)
201
+ if ga is not None and ga == gb:
202
+ return True
203
+ # word-level containment: "dining table" vs "table", "red car" vs "car"
204
+ aw, bw = set(a.split()), set(b.split())
205
+ if aw and bw and (aw <= bw or bw <= aw):
206
+ return True
207
+ return False
208
+
209
+
210
+ # ──────────────────────────────────────────────────────────────────────────────
211
+ # Per-category scorers: (pred_dict, gt, ctx) -> (primary_score|None, metrics_dict)
212
+ # ctx carries {"size": (W,H), "coord_space": CoordSpace}
213
+ # ──────────────────────────────────────────────────────────────────────────────
214
+
215
+ def _acceptable_labels(gt) -> set[str]:
216
+ if isinstance(gt, dict):
217
+ if "labels" in gt:
218
+ return {_norm_text(x) for x in gt["labels"]}
219
+ if "label" in gt:
220
+ return {_norm_text(gt["label"])}
221
+ if isinstance(gt, str):
222
+ return {_norm_text(gt)}
223
+ return set()
224
+
225
+
226
+ def score_classification(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
227
+ accept = _acceptable_labels(gt)
228
+ pred_label = _norm_text(str(pred.get("label", "")))
229
+ # tolerant: "spaghetti" credits "spaghetti bolognese", "tv" credits "television"
230
+ top1 = 1.0 if any(labels_match(pred_label, a) for a in accept) else 0.0
231
+ top5_labels = {_norm_text(str(d.get("label", ""))) for d in (pred.get("top5") or [])}
232
+ top5_labels.add(pred_label)
233
+ top5 = 1.0 if any(labels_match(p, a) for p in top5_labels for a in accept) else 0.0
234
+ return top1, {"top1": top1, "top5": top5}
235
+
236
+
237
+ def _gt_boxes_to_canonical(gt, size) -> list[tuple[str, BBox]]:
238
+ out = []
239
+ for b in (gt.get("boxes") if isinstance(gt, dict) else []) or []:
240
+ label = _norm_text(str(b.get("label", "")))
241
+ fmt = b.get("fmt", XYWH)
242
+ box = to_canonical(b["bbox"], CoordSpace.PIXEL_ABS, size, fmt=fmt)
243
+ out.append((label, box))
244
+ return out
245
+
246
+
247
+ def _greedy_match_f1(preds, gts, iou_thr, require_label) -> tuple[float, float, float, int]:
248
+ """Greedy IoU matching (preds pre-sorted by score). Returns (precision, recall, f1, tp).
249
+ `require_label` toggles labeled vs class-agnostic matching."""
250
+ matched: set[int] = set()
251
+ tp = 0
252
+ for plabel, _score, pbox in preds:
253
+ best_gi, best_iou = -1, iou_thr
254
+ for gi, (glabel, gbox) in enumerate(gts):
255
+ if gi in matched:
256
+ continue
257
+ if require_label and not labels_match(plabel, glabel):
258
+ continue
259
+ iou = pbox.iou(gbox)
260
+ if iou >= best_iou:
261
+ best_gi, best_iou = gi, iou
262
+ if best_gi >= 0:
263
+ matched.add(best_gi)
264
+ tp += 1
265
+ fp = len(preds) - tp
266
+ fn = len(gts) - len(matched)
267
+ precision = tp / (tp + fp) if (tp + fp) else (1.0 if not gts else 0.0)
268
+ recall = tp / (tp + fn) if (tp + fn) else 1.0
269
+ f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) else 0.0
270
+ return precision, recall, f1, tp
271
+
272
+
273
+ def score_detection(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
274
+ size = ctx["size"]
275
+ space = ctx["coord_space"]
276
+ gts = _gt_boxes_to_canonical(gt, size)
277
+
278
+ preds = []
279
+ for d in (pred.get("detections") or []):
280
+ box_raw = d.get("box")
281
+ if not (isinstance(box_raw, (list, tuple)) and len(box_raw) == 4):
282
+ continue
283
+ try:
284
+ box = to_canonical(box_raw, space, size, fmt=XYXY)
285
+ except (ValueError, TypeError):
286
+ continue
287
+ preds.append((_norm_text(str(d.get("label", ""))), float(d.get("score", 1.0) or 1.0), box))
288
+ preds.sort(key=lambda t: t[1], reverse=True)
289
+
290
+ iou_thr = 0.5
291
+ # labeled (tolerant) match — the headline accuracy
292
+ precision, recall, f1, _ = _greedy_match_f1(preds, gts, iou_thr, require_label=True)
293
+ # class-agnostic localization — "can it find/box objects" regardless of naming
294
+ loc_p, loc_r, loc_f1, _ = _greedy_match_f1(preds, gts, iou_thr, require_label=False)
295
+
296
+ pred_count = pred.get("count")
297
+ count_err = abs(int(pred_count) - len(gts)) if isinstance(pred_count, (int, float)) else len(preds) - len(gts)
298
+ return f1, {"precision": precision, "recall": recall, "f1": f1,
299
+ "localization_f1": loc_f1, "localization_recall": loc_r,
300
+ "iou_thr": iou_thr, "count_abs_err": float(abs(count_err)),
301
+ "n_pred": float(len(preds)), "n_gt": float(len(gts))}
302
+
303
+
304
+ def score_ocr(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
305
+ gt_text = gt.get("text") if isinstance(gt, dict) else str(gt)
306
+ pred_text = str(pred.get("full_text", ""))
307
+ cer = _cer(pred_text, gt_text)
308
+ wer = _wer(pred_text, gt_text)
309
+ exact = 1.0 if _norm_text(pred_text) == _norm_text(gt_text) else 0.0
310
+ # answer-containment credit (TextVQA-style: GT is the answer phrase)
311
+ contains = 1.0 if _norm_text(gt_text) and _norm_text(gt_text) in _norm_text(pred_text) else 0.0
312
+ primary = max(exact, contains, max(0.0, 1.0 - cer))
313
+ return primary, {"cer": cer, "wer": wer, "exact": exact, "contains": contains}
314
+
315
+
316
+ def score_datatype_diff(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
317
+ """Did the model identify the rendered data format (json/yaml/md/...)?"""
318
+ gt_type = _norm_text(gt.get("data_type") if isinstance(gt, dict) else str(gt))
319
+ pred_type = _norm_text(str(pred.get("data_type", "")))
320
+ ok = 1.0 if pred_type == gt_type else 0.0
321
+ return ok, {"type_acc": ok}
322
+
323
+
324
+ def _flatten_kv(obj, prefix="") -> set[str]:
325
+ """Flatten a parsed JSON-ish object to a set of 'path=value' leaf strings."""
326
+ out: set[str] = set()
327
+ if isinstance(obj, dict):
328
+ for k, v in obj.items():
329
+ out |= _flatten_kv(v, f"{prefix}{k}.")
330
+ elif isinstance(obj, list):
331
+ for i, v in enumerate(obj):
332
+ out |= _flatten_kv(v, f"{prefix}{i}.")
333
+ else:
334
+ out.add(f"{prefix.rstrip('.')}={_norm_text(str(obj))}")
335
+ return out
336
+
337
+
338
+ def score_datatype_util(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
339
+ """Did the model re-emit the rendered data as normalized JSON matching the GT?
340
+ Scored by leaf 'path=value' F1 (order-independent), plus a type-correct bonus."""
341
+ import json as _json
342
+ gt_obj = gt.get("content") if isinstance(gt, dict) else gt
343
+ if isinstance(gt_obj, str):
344
+ try:
345
+ gt_obj = _json.loads(gt_obj)
346
+ except (ValueError, TypeError):
347
+ pass
348
+ pred_content = pred.get("content")
349
+ if isinstance(pred_content, str):
350
+ try:
351
+ pred_content = _json.loads(pred_content)
352
+ except (ValueError, TypeError):
353
+ pass # leave as string → flatten will treat as a single leaf
354
+ g = _flatten_kv(gt_obj)
355
+ p = _flatten_kv(pred_content)
356
+ if not g:
357
+ return (1.0 if not p else 0.0), {"kv_f1": 0.0}
358
+ tp = len(g & p)
359
+ prec = tp / len(p) if p else 0.0
360
+ rec = tp / len(g)
361
+ f1 = (2 * prec * rec / (prec + rec)) if (prec + rec) else 0.0
362
+ type_ok = 1.0 if _norm_text(str(pred.get("data_type", ""))) == _norm_text(
363
+ gt.get("data_type", "") if isinstance(gt, dict) else "") else 0.0
364
+ return f1, {"kv_f1": f1, "kv_precision": prec, "kv_recall": rec, "type_acc": type_ok}
365
+
366
+
367
+ _SPATIAL_PRED_NORM = {
368
+ "left of": "left_of", "to the left of": "left_of", "left": "left_of",
369
+ "right of": "right_of", "to the right of": "right_of", "right": "right_of",
370
+ "in front of": "in_front_of", "front of": "in_front_of",
371
+ }
372
+
373
+
374
+ def _norm_pred(p: str) -> str:
375
+ n = _norm_text(p)
376
+ return _SPATIAL_PRED_NORM.get(n, n.replace(" ", "_"))
377
+
378
+
379
+ def _pred_triples(pred: dict) -> list[tuple]:
380
+ """Extract (subject, predicate, object) triples from either the spatial
381
+ ('relations': [{subject,predicate,object}]) or semantic
382
+ ('associations': [{a,relation,b}]) shape."""
383
+ out = []
384
+ for r in (pred.get("relations") or []):
385
+ if isinstance(r, dict):
386
+ out.append((_norm_text(str(r.get("subject", ""))), _norm_pred(str(r.get("predicate", ""))),
387
+ _norm_text(str(r.get("object", "")))))
388
+ for r in (pred.get("associations") or []):
389
+ if isinstance(r, dict):
390
+ out.append((_norm_text(str(r.get("a", ""))), _norm_pred(str(r.get("relation", ""))),
391
+ _norm_text(str(r.get("b", "")))))
392
+ return out
393
+
394
+
395
+ def score_triples(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
396
+ """Triple-F1 with tolerant subject/object matching + exact (normalized) predicate."""
397
+ gts = [(_norm_text(s), _norm_pred(p), _norm_text(o))
398
+ for s, p, o in (gt.get("triples", []) if isinstance(gt, dict) else [])]
399
+ preds = _pred_triples(pred)
400
+ matched = set()
401
+ tp = 0
402
+ for ps, pp, po in preds:
403
+ for gi, (gs, gp, go) in enumerate(gts):
404
+ if gi in matched or pp != gp:
405
+ continue
406
+ if labels_match(ps, gs) and labels_match(po, go):
407
+ matched.add(gi)
408
+ tp += 1
409
+ break
410
+ prec = tp / len(preds) if preds else (1.0 if not gts else 0.0)
411
+ rec = tp / len(gts) if gts else 1.0
412
+ f1 = (2 * prec * rec / (prec + rec)) if (prec + rec) else 0.0
413
+ return f1, {"triple_f1": f1, "precision": prec, "recall": rec,
414
+ "n_pred": float(len(preds)), "n_gt": float(len(gts))}
415
+
416
+
417
+ def score_depth_order(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
418
+ """Pairwise nearer/farther ordering accuracy over the GT pairs."""
419
+ gpairs = gt.get("pairs", []) if isinstance(gt, dict) else []
420
+ preds = pred.get("relative_depth") or []
421
+
422
+ def _find(a, b):
423
+ for r in preds:
424
+ if not isinstance(r, dict):
425
+ continue
426
+ ra, rb = _norm_text(str(r.get("a", ""))), _norm_text(str(r.get("b", "")))
427
+ if labels_match(ra, a) and labels_match(rb, b):
428
+ return _norm_text(str(r.get("a_is", "")))
429
+ if labels_match(ra, b) and labels_match(rb, a): # reversed → flip
430
+ v = _norm_text(str(r.get("a_is", "")))
431
+ return {"nearer": "farther", "farther": "nearer"}.get(v, v)
432
+ return None
433
+
434
+ correct = 0
435
+ for p in gpairs:
436
+ got = _find(_norm_text(p["a"]), _norm_text(p["b"]))
437
+ if got == _norm_text(p["a_is"]):
438
+ correct += 1
439
+ acc = correct / len(gpairs) if gpairs else (1.0 if not preds else 0.0)
440
+ return acc, {"order_acc": acc, "n_gt_pairs": float(len(gpairs))}
441
+
442
+
443
+ def score_subject_fixation(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
444
+ """IoU of the predicted primary-subject box vs the GT salient box (+ label)."""
445
+ size = ctx["size"]
446
+ space = ctx["coord_space"]
447
+ ps = pred.get("primary_subject") or {}
448
+ raw = ps.get("box") if isinstance(ps, dict) else None
449
+ if not (isinstance(raw, (list, tuple)) and len(raw) == 4):
450
+ return 0.0, {"iou": 0.0, "label_ok": 0.0}
451
+ try:
452
+ pbox = to_canonical(raw, space, size, fmt=XYXY)
453
+ except (ValueError, TypeError):
454
+ return 0.0, {"iou": 0.0, "label_ok": 0.0}
455
+ gbox = to_canonical(gt["box"], CoordSpace.PIXEL_ABS, size, fmt=gt.get("fmt", XYXY))
456
+ iou = pbox.iou(gbox)
457
+ label_ok = 1.0 if labels_match(str(ps.get("label", "")), str(gt.get("label", ""))) else 0.0
458
+ primary = 1.0 if (iou >= 0.5 and label_ok) else (0.5 if iou >= 0.5 else 0.0)
459
+ return primary, {"iou": iou, "label_ok": label_ok}
460
+
461
+
462
+ def _seg_poly_points(flat, sx=1.0, sy=1.0):
463
+ """Flat [x1,y1,x2,y2,...] -> list of (x,y) tuples scaled by (sx,sy).
464
+ Tolerant: ignores a trailing odd value and skips non-numeric entries."""
465
+ pts = []
466
+ if not isinstance(flat, (list, tuple)):
467
+ return pts
468
+ n = (len(flat) // 2) * 2
469
+ for i in range(0, n, 2):
470
+ try:
471
+ x = float(flat[i]) * sx
472
+ y = float(flat[i + 1]) * sy
473
+ except (TypeError, ValueError):
474
+ continue
475
+ pts.append((x, y))
476
+ # Tolerate a 4-number bbox-as-polygon: expand [x1,y1,x2,y2] -> rectangle corners.
477
+ if len(pts) == 2:
478
+ (x1, y1), (x2, y2) = pts
479
+ pts = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
480
+ return pts
481
+
482
+
483
+ def _seg_rasterize(points, grid, gsx, gsy):
484
+ """Fill a polygon (pixel-coord points) onto a grid×grid boolean mask.
485
+ Maps pixel->grid via (px*gsx, py*gsy). Returns a bool ndarray or None."""
486
+ import numpy as np
487
+ from PIL import Image, ImageDraw
488
+ if len(points) < 3:
489
+ return None
490
+ mapped = [(px * gsx, py * gsy) for (px, py) in points]
491
+ img = Image.new("L", (grid, grid), 0)
492
+ ImageDraw.Draw(img).polygon(mapped, fill=1)
493
+ return np.asarray(img, dtype=bool)
494
+
495
+
496
+ def score_segmentation(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
497
+ """Instance-segmentation mIoU. For each GT mask, greedily match a predicted
498
+ mask of the same (tolerant) label by polygon IoU, computed by rasterizing
499
+ both polygons onto a shared grid. mIoU is averaged over GT masks.
500
+
501
+ GT polygons (gt['masks'][i]['polygon_pixels']) are absolute pixels.
502
+ Predicted polygons (pred['masks'][i]['polygon']) are in ctx['coord_space']
503
+ (NORM_0_1000 by default) and are scaled to pixels before rasterizing.
504
+ Never raises: missing / short / malformed polygons score 0 for that mask."""
505
+ import numpy as np
506
+
507
+ size = ctx.get("size", (1, 1))
508
+ space = ctx.get("coord_space", CoordSpace.NORM_0_1000)
509
+ if isinstance(size, (list, tuple)) and len(size) == 2:
510
+ W, H = size
511
+ else:
512
+ W, H = (1, 1)
513
+ W = max(int(W or 1), 1)
514
+ H = max(int(H or 1), 1)
515
+
516
+ # GT masks (pixel coords). Accept 'polygon_pixels' or 'polygon' as a fallback.
517
+ gts = []
518
+ for m in (gt.get("masks") if isinstance(gt, dict) else []) or []:
519
+ if not isinstance(m, dict):
520
+ continue
521
+ poly = m.get("polygon_pixels")
522
+ if poly is None:
523
+ poly = m.get("polygon") or []
524
+ pts = _seg_poly_points(poly)
525
+ if len(pts) >= 3:
526
+ gts.append((_norm_text(str(m.get("label", ""))), pts))
527
+
528
+ # Predicted masks: scale ctx-space polygons to pixels.
529
+ if space == CoordSpace.NORM_0_1:
530
+ sx, sy = float(W), float(H)
531
+ elif space == CoordSpace.NORM_0_1000:
532
+ sx, sy = float(W) / 1000.0, float(H) / 1000.0
533
+ else: # PIXEL_ABS or unknown -> treat as pixels
534
+ sx, sy = 1.0, 1.0
535
+ preds = []
536
+ for m in (pred.get("masks") if isinstance(pred, dict) else []) or []:
537
+ if not isinstance(m, dict):
538
+ continue
539
+ pts = _seg_poly_points(m.get("polygon") or [], sx, sy)
540
+ if len(pts) >= 3:
541
+ preds.append((_norm_text(str(m.get("label", ""))), pts))
542
+
543
+ if not gts:
544
+ ok = 1.0 if not preds else 0.0
545
+ return ok, {"miou": ok, "n_pred": float(len(preds)), "n_gt": 0.0, "matched": 0.0}
546
+
547
+ # Shared raster grid. Per-axis scale preserves the image aspect ratio so a
548
+ # non-square image doesn't distort IoU. 128 keeps cost trivial.
549
+ GRID = 128
550
+ gsx = GRID / float(W)
551
+ gsy = GRID / float(H)
552
+ gt_masks = [(lbl, _seg_rasterize(pts, GRID, gsx, gsy)) for (lbl, pts) in gts]
553
+ pred_masks = [(lbl, _seg_rasterize(pts, GRID, gsx, gsy)) for (lbl, pts) in preds]
554
+
555
+ used: set = set()
556
+ ious = []
557
+ matched = 0
558
+ for glabel, garr in gt_masks:
559
+ if garr is None or not garr.any():
560
+ ious.append(0.0)
561
+ continue
562
+ best_iou, best_j = 0.0, -1
563
+ for j, (plabel, parr) in enumerate(pred_masks):
564
+ if j in used or parr is None:
565
+ continue
566
+ if not labels_match(plabel, glabel):
567
+ continue
568
+ inter = int(np.logical_and(garr, parr).sum())
569
+ if inter == 0:
570
+ continue
571
+ union = int(np.logical_or(garr, parr).sum())
572
+ iou = inter / union if union else 0.0
573
+ if iou > best_iou:
574
+ best_iou, best_j = iou, j
575
+ if best_j >= 0:
576
+ used.add(best_j)
577
+ matched += 1
578
+ ious.append(best_iou)
579
+
580
+ miou = sum(ious) / len(ious) if ious else 0.0
581
+ return miou, {"miou": miou, "n_pred": float(len(preds)),
582
+ "n_gt": float(len(gts)), "matched": float(matched)}
583
+
584
+
585
+ def _outline_points(flat, space, size):
586
+ """Parse a flat [x1,y1,x2,y2,...] list into pixel (x,y) vertices.
587
+ Each vertex is run through to_canonical as a degenerate 1px box so the
588
+ documented space/clip handling is reused. Robust to odd length / junk;
589
+ returns [] (no polygon) if fewer than 3 usable vertices."""
590
+ import math as _math
591
+ if not isinstance(flat, (list, tuple)):
592
+ return []
593
+ pts = []
594
+ n = len(flat) // 2
595
+ for k in range(n):
596
+ try:
597
+ x = float(flat[2 * k]); y = float(flat[2 * k + 1])
598
+ except (TypeError, ValueError, IndexError):
599
+ continue
600
+ if _math.isnan(x) or _math.isnan(y) or _math.isinf(x) or _math.isinf(y):
601
+ continue
602
+ b = to_canonical([x, y, x, y], space, size, fmt=XYXY) # scales space + clips to image
603
+ pts.append((b.x1, b.y1))
604
+ # Tolerate a 4-number bbox-as-outline: expand [x1,y1,x2,y2] -> rectangle corners.
605
+ if len(pts) == 2:
606
+ (x1, y1), (x2, y2) = pts
607
+ pts = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
608
+ return pts if len(pts) >= 3 else []
609
+
610
+
611
+ def _outline_raster_iou(poly_a, poly_b, size, max_dim=200):
612
+ """Polygon IoU by scanline rasterization on a downscaled grid (<= max_dim on
613
+ the long side). Even-odd fill; handles convex + concave outlines. Never raises."""
614
+ import math as _math
615
+ import numpy as np
616
+ W, H = size
617
+ if len(poly_a) < 3 or len(poly_b) < 3:
618
+ return 0.0
619
+ scale = min(1.0, float(max_dim) / max(1, max(W, H)))
620
+ rw = max(1, int(round(W * scale))); rh = max(1, int(round(H * scale)))
621
+
622
+ def _fill(poly):
623
+ grid = np.zeros((rh, rw), dtype=bool)
624
+ xs = [p[0] * scale for p in poly]
625
+ ys = [p[1] * scale for p in poly]
626
+ m = len(poly)
627
+ y0 = max(0, int(_math.floor(min(ys)))); y1 = min(rh - 1, int(_math.ceil(max(ys))))
628
+ for yy in range(y0, y1 + 1):
629
+ yc = yy + 0.5
630
+ xint = []
631
+ for i in range(m):
632
+ xi, yi = xs[i], ys[i]
633
+ xj, yj = xs[(i + 1) % m], ys[(i + 1) % m]
634
+ if (yi <= yc < yj) or (yj <= yc < yi):
635
+ xint.append(xi + (yc - yi) / (yj - yi) * (xj - xi))
636
+ xint.sort()
637
+ for c in range(0, len(xint) - 1, 2):
638
+ xa = max(0, int(_math.ceil(xint[c] - 0.5)))
639
+ xb = min(rw - 1, int(_math.floor(xint[c + 1] - 0.5)))
640
+ if xb >= xa:
641
+ grid[yy, xa:xb + 1] = True
642
+ return grid
643
+
644
+ a = _fill(poly_a); b = _fill(poly_b)
645
+ inter = int(np.logical_and(a, b).sum())
646
+ union = int(np.logical_or(a, b).sum())
647
+ return inter / union if union else 0.0
648
+
649
+
650
+ def score_outline_iou(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
651
+ """Polygon IoU (rasterized) of the predicted main-object outline vs the GT
652
+ outline, gated by a tolerant label match.
653
+ primary = 0.0 if IoU < 0.5
654
+ = 1.0 if label matches else 0.5 if IoU >= 0.5
655
+ Pred outline is in ctx['coord_space']; GT outline is pixel-abs. Never raises."""
656
+ size = ctx["size"]
657
+ space = ctx["coord_space"]
658
+ pred_poly = _outline_points(pred.get("outline"), space, size)
659
+ if isinstance(gt, dict):
660
+ gt_flat = gt.get("outline") or []
661
+ gt_label = str(gt.get("label", ""))
662
+ else:
663
+ gt_flat, gt_label = [], ""
664
+ gt_poly = _outline_points(gt_flat, CoordSpace.PIXEL_ABS, size)
665
+ iou = _outline_raster_iou(pred_poly, gt_poly, size)
666
+ label_ok = 1.0 if labels_match(str(pred.get("label", "")), gt_label) else 0.0
667
+ if iou >= 0.5:
668
+ primary = 1.0 if label_ok else 0.5
669
+ else:
670
+ primary = 0.0
671
+ return primary, {"poly_iou": iou, "label_ok": label_ok,
672
+ "n_pred_pts": float(len(pred_poly)), "n_gt_pts": float(len(gt_poly))}
673
+
674
+
675
+ def _as_xyzwhl_yaw(b):
676
+ """Coerce a bbox3d list to 7 floats [x,y,z,w,h,l,yaw]; pad missing yaw.
677
+ Returns None if fewer than 6 usable numbers (need at least center+size)."""
678
+ if not isinstance(b, (list, tuple)):
679
+ return None
680
+ vals = []
681
+ for v in b[:7]:
682
+ try:
683
+ vals.append(float(v))
684
+ except (TypeError, ValueError):
685
+ vals.append(0.0)
686
+ if len(vals) < 6:
687
+ return None
688
+ while len(vals) < 7:
689
+ vals.append(0.0) # missing yaw -> 0
690
+ return vals
691
+
692
+
693
+ def score_iou3d(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
694
+ """3D-center matching for the simplified ground-plane proxy.
695
+
696
+ A predicted object matches a GT object when (a) their 3D-center L2 distance is
697
+ below `dist_thr` (normalized units) and (b) the size ratio is reasonable (each
698
+ of w,h,l within [1/size_tol, size_tol]); class is checked separately for credit.
699
+ Headline = (0.5*matched + 0.5*class_correct) / n_gt, so a box in the right place
700
+ but mislabeled earns half credit. Reports center distance + class/recall metrics.
701
+ Never raises: short/missing bbox3d entries are dropped, not fatal.
702
+ """
703
+ import math
704
+ dist_thr = 0.3
705
+ size_tol = 3.0
706
+
707
+ gts = gt.get("objects", []) if isinstance(gt, dict) else []
708
+ raw_preds = pred.get("objects") if isinstance(pred, dict) else None
709
+ preds = []
710
+ for d in (raw_preds or []):
711
+ if not isinstance(d, dict):
712
+ continue
713
+ vec = _as_xyzwhl_yaw(d.get("bbox3d"))
714
+ if vec is None:
715
+ continue
716
+ cls = _norm_text(str(d.get("class", d.get("label", ""))))
717
+ try:
718
+ sc = float(d.get("score", 1.0))
719
+ except (TypeError, ValueError):
720
+ sc = 1.0
721
+ preds.append((cls, sc, vec))
722
+ preds.sort(key=lambda t: t[1], reverse=True)
723
+
724
+ if not gts:
725
+ return (1.0 if not preds else 0.0), {"matched_frac": 0.0, "precision": 0.0,
726
+ "class_acc": 0.0, "center_dist": float(dist_thr),
727
+ "n_pred": float(len(preds)), "n_gt": 0.0}
728
+
729
+ matched_gt: set = set()
730
+ n_class_ok = 0
731
+ dist_sum = 0.0
732
+ dist_n = 0
733
+ for pcls, _sc, pvec in preds:
734
+ best_gi, best_d = -1, dist_thr
735
+ for gi, g in enumerate(gts):
736
+ if gi in matched_gt:
737
+ continue
738
+ gvec = _as_xyzwhl_yaw(g.get("bbox3d"))
739
+ if gvec is None:
740
+ continue
741
+ dx, dy, dz = pvec[0] - gvec[0], pvec[1] - gvec[1], pvec[2] - gvec[2]
742
+ d3 = math.sqrt(dx * dx + dy * dy + dz * dz)
743
+ if d3 > best_d:
744
+ continue
745
+ ok_size = True
746
+ for idx in (3, 4, 5): # w, h, l
747
+ ps, gs = abs(pvec[idx]), abs(gvec[idx])
748
+ if gs <= 1e-6:
749
+ continue
750
+ r = (ps / gs) if ps > 1e-6 else 0.0
751
+ if r < (1.0 / size_tol) or r > size_tol:
752
+ ok_size = False
753
+ break
754
+ if not ok_size:
755
+ continue
756
+ best_gi, best_d = gi, d3
757
+ if best_gi >= 0:
758
+ matched_gt.add(best_gi)
759
+ dist_sum += best_d
760
+ dist_n += 1
761
+ gcls = _norm_text(str(gts[best_gi].get("class", gts[best_gi].get("label", ""))))
762
+ if labels_match(pcls, gcls):
763
+ n_class_ok += 1
764
+
765
+ n_gt = len(gts)
766
+ n_pred = len(preds)
767
+ matched = len(matched_gt)
768
+ recall = matched / n_gt
769
+ precision = matched / n_pred if n_pred else 0.0
770
+ class_acc = n_class_ok / matched if matched else 0.0
771
+ mean_center_dist = (dist_sum / dist_n) if dist_n else float(dist_thr)
772
+ primary = (0.5 * matched + 0.5 * n_class_ok) / n_gt
773
+ return primary, {"matched_frac": recall, "precision": precision,
774
+ "class_acc": class_acc, "center_dist": mean_center_dist,
775
+ "n_pred": float(n_pred), "n_gt": float(n_gt)}
776
+
777
+
778
+ def _wrap_deg(a: float) -> float:
779
+ """Wrap an angle (degrees) into [-180, 180)."""
780
+ return (float(a) + 180.0) % 360.0 - 180.0
781
+
782
+
783
+ def score_camera_rotation(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
784
+ """Camera rotation: mean absolute angular error across yaw/pitch/roll, each wrapped
785
+ to [-180,180). primary = acc@30deg = fraction of the 3 axes within 30 deg of GT.
786
+ Robust to missing / short / non-numeric rotation lists (never raises)."""
787
+ gt_rot = (gt or {}).get("rotation") if isinstance(gt, dict) else None
788
+ if not isinstance(gt_rot, (list, tuple)) or len(gt_rot) < 3:
789
+ return None, {}
790
+ raw = pred.get("rotation")
791
+ p = list(raw) if isinstance(raw, (list, tuple)) else []
792
+ p = (p + [0.0, 0.0, 0.0])[:3] # pad missing axes with 0 so a short list scores, not crashes
793
+ errs, within = [], 0
794
+ for i in range(3):
795
+ try:
796
+ d = abs(_wrap_deg(float(p[i]) - float(gt_rot[i])))
797
+ except (TypeError, ValueError):
798
+ d = 180.0
799
+ errs.append(d)
800
+ if d <= 30.0:
801
+ within += 1
802
+ mean_abs_err = sum(errs) / 3.0
803
+ acc30 = within / 3.0
804
+ return acc30, {"acc@30deg": acc30, "mean_abs_err": mean_abs_err,
805
+ "yaw_err": errs[0], "pitch_err": errs[1], "roll_err": errs[2]}
806
+
807
+
808
+ def score_vqa(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
809
+ """Grounded-VQA answer accuracy (OCR-style containment, reused here).
810
+
811
+ Credit if the predicted answer EQUALS any gold answer, CONTAINS a gold
812
+ answer, or is CONTAINED IN a gold answer (all after _norm_text). Reports
813
+ `exact` and `contains` separately. The optional grounded_region box is NOT
814
+ scored — VQA datasets carry no per-question GT box, so answer text is the
815
+ only signal. Never raises: missing/short fields collapse to a 0.0 score.
816
+ """
817
+ golds = _vqa_gold_answers(gt)
818
+ pred_ans = _norm_text(str(pred.get("answer", "")))
819
+ if not golds:
820
+ # no GT answers -> only an empty prediction can be "correct"
821
+ return (1.0 if not pred_ans else 0.0), {"exact": 0.0, "contains": 0.0}
822
+ exact = 1.0 if pred_ans in golds else 0.0
823
+ contains = 0.0
824
+ if pred_ans:
825
+ for g in golds:
826
+ if g and (g in pred_ans or pred_ans in g):
827
+ contains = 1.0
828
+ break
829
+ primary = max(exact, contains)
830
+ return primary, {"exact": exact, "contains": contains}
831
+
832
+
833
+ def _vqa_gold_answers(gt) -> list[str]:
834
+ """Normalize GT into a list of acceptable (normalized) gold answer strings.
835
+ Accepts {"answers": [...]}, {"answers": "x"}, {"answer": "x"}, a bare list,
836
+ or a bare string. Anything else -> []."""
837
+ if isinstance(gt, dict):
838
+ a = gt.get("answers")
839
+ if isinstance(a, (list, tuple)):
840
+ return [_norm_text(str(x)) for x in a if str(x).strip()]
841
+ if a is not None:
842
+ return [_norm_text(str(a))]
843
+ if "answer" in gt:
844
+ return [_norm_text(str(gt["answer"]))]
845
+ if isinstance(gt, (list, tuple)):
846
+ return [_norm_text(str(x)) for x in gt if str(x).strip()]
847
+ if isinstance(gt, str):
848
+ return [_norm_text(gt)]
849
+ return []
850
+
851
+
852
+ def score_style(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
853
+ """Style accuracy = 1.0 if pred style matches gt style (tolerant via labels_match),
854
+ else 0.0. Layout/symmetry accuracy reported as side metrics when the GT carries them.
855
+ Robust: never raises; guards missing/short fields and non-dict GT."""
856
+ def _acc(pred_val, gt_val):
857
+ if gt_val is None:
858
+ return None
859
+ gv = _norm_text(str(gt_val))
860
+ if not gv:
861
+ return None
862
+ pv = _norm_text(str(pred_val if pred_val is not None else ""))
863
+ if not pv:
864
+ return 0.0
865
+ return 1.0 if (pv == gv or labels_match(pv, gv)) else 0.0
866
+
867
+ if not isinstance(gt, dict):
868
+ gt = {"style": gt}
869
+
870
+ style_acc = _acc(pred.get("style"), gt.get("style"))
871
+ metrics: dict = {}
872
+ if style_acc is not None:
873
+ metrics["style_acc"] = style_acc
874
+ layout_acc = _acc(pred.get("layout"), gt.get("layout"))
875
+ if layout_acc is not None:
876
+ metrics["layout_acc"] = layout_acc
877
+ symmetry_acc = _acc(pred.get("symmetry"), gt.get("symmetry"))
878
+ if symmetry_acc is not None:
879
+ metrics["symmetry_acc"] = symmetry_acc
880
+
881
+ # Headline accuracy is style accuracy (the category's defining axis).
882
+ primary = style_acc if style_acc is not None else None
883
+ return primary, metrics
884
+
885
+
886
+ def score_schema_only(pred: dict, gt, ctx) -> tuple[Optional[float], dict]:
887
+ """Stub categories: no GT wired yet → no task accuracy, only universal metrics."""
888
+ return None, {}
889
+
890
+
891
+ _SCORERS: dict[str, Callable] = {
892
+ "classification": score_classification,
893
+ "detection": score_detection,
894
+ "ocr": score_ocr,
895
+ "datatype_diff": score_datatype_diff,
896
+ "datatype_util": score_datatype_util,
897
+ "triples": score_triples,
898
+ "depth_order": score_depth_order,
899
+ "subject_fixation": score_subject_fixation,
900
+ "segmentation": score_segmentation,
901
+ "outline_iou": score_outline_iou,
902
+ "iou3d": score_iou3d,
903
+ "angular_error": score_camera_rotation,
904
+ "vqa": score_vqa,
905
+ "style": score_style,
906
+ "schema_only": score_schema_only,
907
+ }
908
+
909
+
910
+ # ──────────────────────────────────────────────────────────────────────────────
911
+ # Sample + run scoring
912
+ # ──────────────────────────────────────────────────────────────────────────────
913
+
914
+ def score_vision_sample(
915
+ spec: VisionTaskSpec,
916
+ raw_output: str,
917
+ gt,
918
+ *,
919
+ mode: str,
920
+ image_id: str,
921
+ image_size: tuple[int, int],
922
+ grammar_conformant: bool = False,
923
+ n_output_tokens: int = 0,
924
+ gen_seconds: float = 0.0,
925
+ ) -> MetricResult:
926
+ parse = parse_against(raw_output, model_for(spec))
927
+ parse_ok = parse.schema_valid or (parse.error or "").startswith("schema:")
928
+
929
+ if not parse.schema_valid or parse.parsed is None:
930
+ return MetricResult(
931
+ category=spec.category, image_id=image_id, mode=mode,
932
+ parse_ok=parse_ok, schema_valid=False, needed_repair=parse.needed_repair,
933
+ needed_structural_repair=parse.needed_structural_repair,
934
+ grammar_conformant=grammar_conformant, primary_score=None, metrics={},
935
+ n_output_tokens=n_output_tokens, gen_seconds=gen_seconds, error=parse.error,
936
+ )
937
+
938
+ pred = parse.parsed.model_dump()
939
+ ctx = {"size": image_size, "coord_space": spec.coord_space}
940
+ scorer = _SCORERS.get(spec.metric, score_schema_only)
941
+ try:
942
+ primary, m = scorer(pred, gt, ctx)
943
+ except Exception as e: # a scorer bug must never crash a long run
944
+ primary, m = None, {}
945
+ return MetricResult(
946
+ category=spec.category, image_id=image_id, mode=mode, parse_ok=True,
947
+ schema_valid=True, needed_repair=parse.needed_repair,
948
+ needed_structural_repair=parse.needed_structural_repair,
949
+ grammar_conformant=grammar_conformant, primary_score=None, metrics={},
950
+ n_output_tokens=n_output_tokens, gen_seconds=gen_seconds,
951
+ error=f"scorer error: {e}",
952
+ )
953
+ return MetricResult(
954
+ category=spec.category, image_id=image_id, mode=mode, parse_ok=True,
955
+ schema_valid=True, needed_repair=parse.needed_repair,
956
+ needed_structural_repair=parse.needed_structural_repair,
957
+ grammar_conformant=grammar_conformant, primary_score=primary, metrics=m,
958
+ n_output_tokens=n_output_tokens, gen_seconds=gen_seconds,
959
+ )
960
+
961
+
962
+ def score_vision_run(results: list[MetricResult], *, model: str = "", reasoning: str = "",
963
+ category: str = "", mode: str = "") -> VisionRunMetrics:
964
+ n = len(results)
965
+ if n == 0:
966
+ return VisionRunMetrics(category, model, reasoning, mode, 0, 0.0, 0.0,
967
+ False, None, {}, 0.0, 0.0, 0.0, None)
968
+ valid = [r for r in results if r.schema_valid]
969
+ schema_valid_rate = len(valid) / n
970
+ json_robustness = sum(1 for r in results if r.json_robust) / n
971
+ scored = [r for r in valid if r.primary_score is not None]
972
+ has_task = bool(scored)
973
+ primary_mean = (sum(r.primary_score for r in scored) / len(scored)) if scored else None
974
+
975
+ # average the per-sample metric dicts
976
+ metrics_mean: dict = {}
977
+ keys = set()
978
+ for r in scored:
979
+ keys |= set(r.metrics.keys())
980
+ for k in keys:
981
+ vals = [r.metrics[k] for r in scored if k in r.metrics and not math.isnan(r.metrics[k])]
982
+ if vals:
983
+ metrics_mean[k] = sum(vals) / len(vals)
984
+
985
+ total_tokens = sum(r.n_output_tokens for r in results)
986
+ total_secs = sum(r.gen_seconds for r in results)
987
+ mean_tokens = total_tokens / n
988
+ tok_per_sec = (total_tokens / total_secs) if total_secs > 0 else 0.0
989
+
990
+ return VisionRunMetrics(
991
+ category=category or results[0].category, model=model, reasoning=reasoning,
992
+ mode=mode or results[0].mode, n=n,
993
+ schema_valid_rate=schema_valid_rate, json_robustness=json_robustness,
994
+ has_task_score=has_task, primary_score_mean=primary_mean, metrics_mean=metrics_mean,
995
+ mean_output_tokens=mean_tokens, total_gen_seconds=total_secs, tokens_per_sec=tok_per_sec,
996
+ labeler_score=labeler_score(primary_mean, schema_valid_rate, json_robustness),
997
+ )
qwen_test_runner/vision/model_registry.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ model_registry.py — The Qwen VLM model universe (analogous to SLOT_REGISTRY).
3
+
4
+ One ModelSpec per checkpoint family. The benchmark iterates this registry to
5
+ decide what to load on the 96GB RTX 6000 Pro. Both generations are natively
6
+ multimodal (every checkpoint has its own ViT):
7
+
8
+ * Qwen3.5 (qwen3_5 / qwen3_5_moe, AutoModelForMultimodalLM) — `enable_thinking`
9
+ toggle on a single checkpoint.
10
+ * Qwen3-VL (qwen3_vl / qwen3_vl_moe, AutoModelForImageTextToText) — separate
11
+ -Instruct / -Thinking checkpoints.
12
+
13
+ VRAM rule of thumb (weights only): bf16 ≈ 2·B, fp8 ≈ B, int4 ≈ 0.55·B GB.
14
+ For MoE, ALL experts are resident, so total params drive memory; active params
15
+ drive decode speed (and thus throughput / fleet score).
16
+
17
+ Nothing hardcodes a repo id outside this file — it is the single source of truth
18
+ for the model ladder, exactly as registry.py is for the schema.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Literal, Optional
25
+
26
+ Family = Literal["qwen3_5", "qwen3_5_moe", "qwen3_vl", "qwen3_vl_moe", "joycaption"]
27
+ LoaderKind = Literal["multimodal_lm", "image_text_to_text", "llava_conditional"]
28
+ ReasoningMode = Literal["toggle", "separate_ckpt", "none"]
29
+ Precision = Literal["bf16", "fp8", "int4"]
30
+ Reasoning = Literal["instruct", "thinking"]
31
+
32
+ # weights-only bytes-per-parameter, in GB-per-billion-params
33
+ _BYTES_PER_B: dict[str, float] = {"bf16": 2.0, "fp8": 1.0, "int4": 0.55}
34
+
35
+ # Headroom reserved for the vision encoder, activations, and KV cache.
36
+ VRAM_HEADROOM_GB = 12.0
37
+ GPU_VRAM_GB = 96.0
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ModelSpec:
42
+ key: str # stable short id used on the CLI + in filenames
43
+ repo_id: str # the Instruct / default checkpoint
44
+ family: Family
45
+ params_b: float # total parameters (billions)
46
+ loader_kind: LoaderKind
47
+ reasoning_mode: ReasoningMode
48
+ active_b: Optional[float] = None # active params for MoE (decode speed)
49
+ thinking_repo_id: Optional[str] = None # separate -Thinking ckpt (Qwen3-VL)
50
+ quant_repo_ids: dict[Precision, str] = field(default_factory=dict) # fp8/int4 ckpts
51
+ is_moe: bool = False
52
+ is_baseline: bool = False # the user's fine-tuned reference
53
+ notes: str = ""
54
+
55
+ # ── VRAM math ────────────────────────────────────────────────────────────
56
+
57
+ def est_vram_gb(self, precision: Precision = "bf16") -> float:
58
+ return self.params_b * _BYTES_PER_B[precision]
59
+
60
+ def fits_on(self, precision: Precision = "bf16", gpu_gb: float = GPU_VRAM_GB,
61
+ headroom: float = VRAM_HEADROOM_GB) -> bool:
62
+ return self.est_vram_gb(precision) + headroom <= gpu_gb
63
+
64
+ def available_precisions(self) -> list[Precision]:
65
+ """bf16 is always available (base repo); fp8/int4 only if a quant ckpt exists."""
66
+ return ["bf16"] + [p for p in ("fp8", "int4") if p in self.quant_repo_ids]
67
+
68
+ def best_fitting_precision(self, gpu_gb: float = GPU_VRAM_GB) -> Optional[Precision]:
69
+ """Smallest-footprint precision that fits, preferring higher fidelity first
70
+ (bf16 > fp8 > int4). Returns None if nothing fits without CPU offload."""
71
+ for prec in ("bf16", "fp8", "int4"):
72
+ if prec in self.available_precisions() and self.fits_on(prec, gpu_gb):
73
+ return prec
74
+ return None
75
+
76
+ def needs_offload(self, gpu_gb: float = GPU_VRAM_GB) -> bool:
77
+ return self.best_fitting_precision(gpu_gb) is None
78
+
79
+ def repo_for(self, precision: Precision, reasoning: Reasoning = "instruct") -> str:
80
+ """Resolve the concrete HF repo id for a (precision, reasoning) request."""
81
+ if reasoning == "thinking" and self.reasoning_mode == "separate_ckpt":
82
+ if self.thinking_repo_id is None:
83
+ raise ValueError(f"{self.key}: no separate thinking checkpoint")
84
+ base = self.thinking_repo_id
85
+ else:
86
+ base = self.repo_id
87
+ if precision != "bf16" and precision in self.quant_repo_ids:
88
+ # Quant repos are published for the instruct line; thinking quants are
89
+ # less common, so fall back to the instruct quant id if needed.
90
+ return self.quant_repo_ids[precision]
91
+ return base
92
+
93
+ def supports_thinking(self) -> bool:
94
+ return self.reasoning_mode == "toggle" or self.thinking_repo_id is not None
95
+
96
+ def enable_thinking_flag(self, reasoning: Reasoning) -> bool:
97
+ """For toggle models, thinking is a generation flag, not a separate repo."""
98
+ return reasoning == "thinking" and self.reasoning_mode == "toggle"
99
+
100
+
101
+ # ──────────────────────────────────────────────────────────────────────────────
102
+ # THE MODEL UNIVERSE. Bench everything that fits on 96GB, both reasoning variants.
103
+ # Stretch rungs (397B / 235B) are kept but flagged needs_offload by the VRAM math.
104
+ # ──────────────────────────────────────────────────────────────────────────────
105
+
106
+ def _q(*pairs: tuple[Precision, str]) -> dict[Precision, str]:
107
+ return dict(pairs)
108
+
109
+
110
+ MODEL_REGISTRY: dict[str, ModelSpec] = {
111
+ # ── Qwen3.5 dense (native multimodal; enable_thinking toggle) ──────────────
112
+ "qwen3.5-0.8b": ModelSpec(
113
+ key="qwen3.5-0.8b", repo_id="Qwen/Qwen3.5-0.8B", family="qwen3_5",
114
+ params_b=0.873, loader_kind="multimodal_lm", reasoning_mode="toggle",
115
+ notes="smallest native VLM; floor of the ladder",
116
+ ),
117
+ "qwen3.5-2b": ModelSpec(
118
+ key="qwen3.5-2b", repo_id="Qwen/Qwen3.5-2B", family="qwen3_5",
119
+ params_b=2.0, loader_kind="multimodal_lm", reasoning_mode="toggle",
120
+ ),
121
+ "qwen3.5-4b": ModelSpec(
122
+ key="qwen3.5-4b", repo_id="Qwen/Qwen3.5-4B", family="qwen3_5",
123
+ params_b=4.0, loader_kind="multimodal_lm", reasoning_mode="toggle",
124
+ ),
125
+ "qwen3.5-9b": ModelSpec(
126
+ key="qwen3.5-9b", repo_id="Qwen/Qwen3.5-9B", family="qwen3_5",
127
+ params_b=9.0, loader_kind="multimodal_lm", reasoning_mode="toggle",
128
+ ),
129
+ "qwen3.5-27b": ModelSpec(
130
+ key="qwen3.5-27b", repo_id="Qwen/Qwen3.5-27B", family="qwen3_5",
131
+ params_b=27.0, loader_kind="multimodal_lm", reasoning_mode="toggle",
132
+ quant_repo_ids=_q(("fp8", "Qwen/Qwen3.5-27B-FP8"), ("int4", "Qwen/Qwen3.5-27B-GPTQ-Int4")),
133
+ ),
134
+ # ── Qwen3.5 MoE ────────────────────────────────────────────────────────────
135
+ "qwen3.5-35b-a3b": ModelSpec(
136
+ key="qwen3.5-35b-a3b", repo_id="Qwen/Qwen3.5-35B-A3B", family="qwen3_5_moe",
137
+ params_b=35.0, active_b=3.0, loader_kind="multimodal_lm", reasoning_mode="toggle",
138
+ is_moe=True,
139
+ quant_repo_ids=_q(("fp8", "Qwen/Qwen3.5-35B-A3B-FP8"), ("int4", "Qwen/Qwen3.5-35B-A3B-GPTQ-Int4")),
140
+ notes="MoE: ~3B active → decodes near a 3B dense",
141
+ ),
142
+ "qwen3.5-122b-a10b": ModelSpec(
143
+ key="qwen3.5-122b-a10b", repo_id="Qwen/Qwen3.5-122B-A10B", family="qwen3_5_moe",
144
+ params_b=122.0, active_b=10.0, loader_kind="multimodal_lm", reasoning_mode="toggle",
145
+ is_moe=True,
146
+ quant_repo_ids=_q(("fp8", "Qwen/Qwen3.5-122B-A10B-FP8"), ("int4", "Qwen/Qwen3.5-122B-A10B-GPTQ-Int4")),
147
+ notes="fits 96GB only at GPTQ-Int4 (~67GB)",
148
+ ),
149
+ "qwen3.5-397b-a17b": ModelSpec(
150
+ key="qwen3.5-397b-a17b", repo_id="Qwen/Qwen3.5-397B-A17B", family="qwen3_5_moe",
151
+ params_b=397.0, active_b=17.0, loader_kind="multimodal_lm", reasoning_mode="toggle",
152
+ is_moe=True,
153
+ quant_repo_ids=_q(("fp8", "Qwen/Qwen3.5-397B-A17B-FP8"), ("int4", "Qwen/Qwen3.5-397B-A17B-GPTQ-Int4")),
154
+ notes="STRETCH: needs CPU offload even at Int4",
155
+ ),
156
+ # ── Qwen3-VL dense (separate -Instruct / -Thinking) ────────────────────────
157
+ "qwen3vl-2b": ModelSpec(
158
+ key="qwen3vl-2b", repo_id="Qwen/Qwen3-VL-2B-Instruct",
159
+ thinking_repo_id="Qwen/Qwen3-VL-2B-Thinking", family="qwen3_vl",
160
+ params_b=2.0, loader_kind="image_text_to_text", reasoning_mode="separate_ckpt",
161
+ quant_repo_ids=_q(("fp8", "Qwen/Qwen3-VL-2B-Instruct-FP8")),
162
+ ),
163
+ "qwen3vl-4b": ModelSpec(
164
+ key="qwen3vl-4b", repo_id="Qwen/Qwen3-VL-4B-Instruct",
165
+ thinking_repo_id="Qwen/Qwen3-VL-4B-Thinking", family="qwen3_vl",
166
+ params_b=4.0, loader_kind="image_text_to_text", reasoning_mode="separate_ckpt",
167
+ quant_repo_ids=_q(("fp8", "Qwen/Qwen3-VL-4B-Instruct-FP8")),
168
+ ),
169
+ "qwen3vl-8b": ModelSpec(
170
+ key="qwen3vl-8b", repo_id="Qwen/Qwen3-VL-8B-Instruct",
171
+ thinking_repo_id="Qwen/Qwen3-VL-8B-Thinking", family="qwen3_vl",
172
+ params_b=8.0, loader_kind="image_text_to_text", reasoning_mode="separate_ckpt",
173
+ quant_repo_ids=_q(("fp8", "Qwen/Qwen3-VL-8B-Instruct-FP8")),
174
+ ),
175
+ "qwen3vl-32b": ModelSpec(
176
+ key="qwen3vl-32b", repo_id="Qwen/Qwen3-VL-32B-Instruct",
177
+ thinking_repo_id="Qwen/Qwen3-VL-32B-Thinking", family="qwen3_vl",
178
+ params_b=32.0, loader_kind="image_text_to_text", reasoning_mode="separate_ckpt",
179
+ quant_repo_ids=_q(("fp8", "Qwen/Qwen3-VL-32B-Instruct-FP8")),
180
+ notes="top dense that fits bf16 (~64GB)",
181
+ ),
182
+ # ── Qwen3-VL MoE ───────────────────────────────────────────────────────────
183
+ "qwen3vl-30b-a3b": ModelSpec(
184
+ key="qwen3vl-30b-a3b", repo_id="Qwen/Qwen3-VL-30B-A3B-Instruct",
185
+ thinking_repo_id="Qwen/Qwen3-VL-30B-A3B-Thinking", family="qwen3_vl_moe",
186
+ params_b=30.0, active_b=3.0, loader_kind="image_text_to_text", reasoning_mode="separate_ckpt",
187
+ is_moe=True, quant_repo_ids=_q(("fp8", "Qwen/Qwen3-VL-30B-A3B-Instruct-FP8")),
188
+ notes="MoE: ~3B active, fits bf16 (~60GB)",
189
+ ),
190
+ "qwen3vl-235b-a22b": ModelSpec(
191
+ key="qwen3vl-235b-a22b", repo_id="Qwen/Qwen3-VL-235B-A22B-Instruct",
192
+ thinking_repo_id="Qwen/Qwen3-VL-235B-A22B-Thinking", family="qwen3_vl_moe",
193
+ params_b=235.0, active_b=22.0, loader_kind="image_text_to_text", reasoning_mode="separate_ckpt",
194
+ is_moe=True, quant_repo_ids=_q(("fp8", "Qwen/Qwen3-VL-235B-A22B-Instruct-FP8")),
195
+ notes="STRETCH: needs CPU offload",
196
+ ),
197
+ # ── User's fine-tuned reference (combined ViT-classification + JSON) ────────
198
+ "qwen3.5-0.8b-json-captioner": ModelSpec(
199
+ key="qwen3.5-0.8b-json-captioner",
200
+ repo_id="AbstractPhil/Qwen3.5-0.8B-json-captioner", family="qwen3_5",
201
+ params_b=0.873, loader_kind="multimodal_lm", reasoning_mode="toggle",
202
+ is_baseline=True,
203
+ notes="LoRA-merged baseline: existence proof of native ViT-class + JSON; throughput ceiling",
204
+ ),
205
+ # ── JoyCaption (LLaVA: SigLIP2/SigLIP + Llama 3.1 8B). Captioner JSON-capacity ─
206
+ # baseline — not grounding-trained, so treat coordinate tasks as exploratory.
207
+ "joycaption-beta-one": ModelSpec(
208
+ key="joycaption-beta-one",
209
+ repo_id="fancyfeast/llama-joycaption-beta-one-hf-llava", family="joycaption",
210
+ params_b=8.48, loader_kind="llava_conditional", reasoning_mode="none",
211
+ notes="SigLIP2 + Llama 3.1 8B captioner; latest JoyCaption; not grounding-trained",
212
+ ),
213
+ "joycaption-alpha-two": ModelSpec(
214
+ key="joycaption-alpha-two",
215
+ repo_id="fancyfeast/llama-joycaption-alpha-two-hf-llava", family="joycaption",
216
+ params_b=8.48, loader_kind="llava_conditional", reasoning_mode="none",
217
+ notes="prior JoyCaption (SigLIP v1); version-over-version JSON comparison",
218
+ ),
219
+ }
220
+
221
+
222
+ # ──────────────────────────────────────────────────────────────────────────────
223
+ # Query helpers
224
+ # ──────────────────────────────────────────────────────────────────────────────
225
+
226
+ def get_model(key: str) -> ModelSpec:
227
+ if key not in MODEL_REGISTRY:
228
+ raise KeyError(f"unknown model: {key!r}. known: {list(MODEL_REGISTRY)}")
229
+ return MODEL_REGISTRY[key]
230
+
231
+
232
+ def model_keys() -> list[str]:
233
+ return list(MODEL_REGISTRY.keys())
234
+
235
+
236
+ def models_that_fit(gpu_gb: float = GPU_VRAM_GB, include_offload: bool = False) -> list[ModelSpec]:
237
+ """Models that fit at some precision on `gpu_gb` (plus offload stretch if asked)."""
238
+ out = []
239
+ for spec in MODEL_REGISTRY.values():
240
+ if spec.best_fitting_precision(gpu_gb) is not None:
241
+ out.append(spec)
242
+ elif include_offload:
243
+ out.append(spec)
244
+ return out
245
+
246
+
247
+ def reasoning_variants(spec: ModelSpec, both: bool = True) -> list[Reasoning]:
248
+ """The reasoning variants to run for a model."""
249
+ if not both or not spec.supports_thinking():
250
+ return ["instruct"]
251
+ return ["instruct", "thinking"]
252
+
253
+
254
+ def get_runner(model_key: str, precision: Precision = "bf16", reasoning: Reasoning = "instruct",
255
+ device_map: Optional[str] = None, **kwargs):
256
+ """Factory: build a real VLMRunner for a (model, precision, reasoning) request.
257
+
258
+ Imports torch lazily (via runners), so importing this registry stays cheap.
259
+ CPU-offload stretch rungs get device_map='auto' automatically.
260
+ """
261
+ from .runners import VLMRunner
262
+ spec = get_model(model_key)
263
+ if precision == "bf16" and not spec.fits_on("bf16") and spec.best_fitting_precision() is not None:
264
+ precision = spec.best_fitting_precision() # auto-downshift to a fitting quant
265
+ repo = spec.repo_for(precision, reasoning)
266
+ if device_map is None:
267
+ device_map = "auto" if spec.needs_offload() else "cuda"
268
+ return VLMRunner(
269
+ model_id=repo,
270
+ loader_kind=spec.loader_kind,
271
+ precision=precision,
272
+ enable_thinking=spec.enable_thinking_flag(reasoning),
273
+ device_map=device_map,
274
+ **kwargs,
275
+ )
qwen_test_runner/vision/report.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ report.py — Leaderboards + the no-finetune labeler verdict.
3
+
4
+ Aggregates per-(model, reasoning, category, mode) metrics into:
5
+ * a QUALITY leaderboard ranked by labeler_score (native json_mode columns),
6
+ * a FLEET leaderboard folding throughput, and
7
+ * a verdict naming the best NO-FINETUNE model — or, if none is natively
8
+ sufficient, the best finetune-candidate to feed the SFT/LoRA pipeline.
9
+
10
+ Native columns use json_mode (no grammar crutch) because that is the signal for
11
+ whether a model emits robust JSON on its own.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from .metrics import labeler_score
21
+ from .throughput import fleet_score
22
+
23
+ # Bucketing thresholds (config-surfaceable later).
24
+ ACC_FLOOR = 0.55 # below this the vision itself is too weak
25
+ NATIVE_ROBUST = 0.90 # native json_mode robustness to count as "ships as-is"
26
+
27
+
28
+ def _bucket(accuracy: Optional[float], native_robust: float) -> str:
29
+ if accuracy is None:
30
+ return "no_task_gt"
31
+ if accuracy < ACC_FLOOR:
32
+ return "insufficient"
33
+ if native_robust >= NATIVE_ROBUST:
34
+ return "native_capable"
35
+ return "finetune_candidate"
36
+
37
+
38
+ def _mean(xs):
39
+ xs = [x for x in xs if x is not None]
40
+ return sum(xs) / len(xs) if xs else None
41
+
42
+
43
+ def summarize(metric_rows: list[dict]) -> list[dict]:
44
+ """Collapse per-category metric rows into one summary per (model, reasoning).
45
+
46
+ Native (json_mode) columns drive the no-finetune decision; the constrained
47
+ rows are kept only to compute the native-vs-constrained validity gap.
48
+ """
49
+ by_model: dict[tuple[str, str], dict] = {}
50
+ for r in metric_rows:
51
+ key = (r["model"], r["reasoning"])
52
+ by_model.setdefault(key, {"json_mode": [], "constrained": []})
53
+ bucket = r["mode"] if r["mode"] in ("json_mode", "constrained") else "json_mode"
54
+ by_model[key][bucket].append(r)
55
+
56
+ summaries = []
57
+ for (model, reasoning), modes in by_model.items():
58
+ jm = modes["json_mode"] or modes["constrained"]
59
+ acc = _mean([r["primary_score_mean"] for r in jm if r["has_task_score"]])
60
+ valid = _mean([r["schema_valid_rate"] for r in jm]) or 0.0
61
+ robust = _mean([r["json_robustness"] for r in jm]) or 0.0
62
+ constrained_valid = _mean([r["schema_valid_rate"] for r in modes["constrained"]])
63
+ gap = (constrained_valid - valid) if constrained_valid is not None else None
64
+ tok_s = _mean([r["tokens_per_sec"] for r in jm]) or 0.0
65
+ mean_out = _mean([r["mean_output_tokens"] for r in jm]) or 0.0
66
+ lab = labeler_score(acc, valid, robust)
67
+ # samples/hour from tok/s + mean output tokens (prefill folded in elsewhere)
68
+ sph = (3600.0 * tok_s / mean_out) if (tok_s > 0 and mean_out > 0) else 0.0
69
+ summaries.append({
70
+ "model": model, "reasoning": reasoning,
71
+ "accuracy": acc, "native_valid": valid, "native_robust": robust,
72
+ "constrained_valid": constrained_valid, "native_gap": gap,
73
+ "labeler_score": lab, "tokens_per_sec": tok_s, "samples_per_hour": sph,
74
+ "fleet_score": fleet_score(lab, sph) if lab is not None else None,
75
+ "bucket": _bucket(acc, robust),
76
+ })
77
+ summaries.sort(key=lambda s: (s["labeler_score"] is not None, s["labeler_score"] or -1), reverse=True)
78
+ return summaries
79
+
80
+
81
+ def _fmt(x, pct=False):
82
+ if x is None:
83
+ return "n/a"
84
+ return f"{x:.1%}" if pct else f"{x:.3f}"
85
+
86
+
87
+ def quality_table(summaries: list[dict]) -> str:
88
+ lines = [
89
+ "| rank | model | reason | acc | native_valid | native_robust | gap | labeler | tok/s | bucket |",
90
+ "|------|-------|--------|-----|--------------|---------------|-----|---------|-------|--------|",
91
+ ]
92
+ for i, s in enumerate(summaries, 1):
93
+ lines.append(
94
+ f"| {i} | {s['model']} | {s['reasoning']} | {_fmt(s['accuracy'])} | "
95
+ f"{_fmt(s['native_valid'], True)} | {_fmt(s['native_robust'], True)} | "
96
+ f"{_fmt(s['native_gap'], True)} | {_fmt(s['labeler_score'])} | "
97
+ f"{s['tokens_per_sec']:.0f} | {s['bucket']} |"
98
+ )
99
+ return "\n".join(lines)
100
+
101
+
102
+ def fleet_table(summaries: list[dict]) -> str:
103
+ fs = sorted([s for s in summaries if s["fleet_score"] is not None],
104
+ key=lambda s: s["fleet_score"], reverse=True)
105
+ lines = [
106
+ "| rank | model | reason | labeler | samples/hr | fleet |",
107
+ "|------|-------|--------|---------|------------|-------|",
108
+ ]
109
+ for i, s in enumerate(fs, 1):
110
+ lines.append(
111
+ f"| {i} | {s['model']} | {s['reasoning']} | {_fmt(s['labeler_score'])} | "
112
+ f"{s['samples_per_hour']:.0f} | {_fmt(s['fleet_score'])} |"
113
+ )
114
+ return "\n".join(lines)
115
+
116
+
117
+ def verdict(summaries: list[dict]) -> str:
118
+ native = [s for s in summaries if s["bucket"] == "native_capable"]
119
+ ft = [s for s in summaries if s["bucket"] == "finetune_candidate"]
120
+ out = ["## Headline recommendation", ""]
121
+ if native:
122
+ b = native[0]
123
+ out.append(
124
+ f"> **Best no-finetune labeler:** `{b['model']}` ({b['reasoning']}) — "
125
+ f"{_fmt(b['native_valid'], True)} native-valid, {_fmt(b['accuracy'])} accuracy, "
126
+ f"{b['tokens_per_sec']:.0f} tok/s. Ships as-is."
127
+ )
128
+ elif ft:
129
+ b = ft[0]
130
+ out.append(
131
+ f"> **No natively-sufficient model.** Best **finetune-candidate:** `{b['model']}` "
132
+ f"({b['reasoning']}) — robust vision ({_fmt(b['accuracy'])} acc) but native JSON gap = "
133
+ f"{_fmt(b['native_gap'], True)}. Close it with the existing data-gen → SFT/LoRA pipeline."
134
+ )
135
+ else:
136
+ out.append("> No model cleared the accuracy floor on this run. Re-check inputs / categories.")
137
+ fleet = sorted([s for s in summaries if s["fleet_score"] is not None],
138
+ key=lambda s: s["fleet_score"], reverse=True)
139
+ if fleet:
140
+ f = fleet[0]
141
+ out.append("")
142
+ out.append(
143
+ f"> **Best fleet labeler (1M+ images):** `{f['model']}` ({f['reasoning']}) — "
144
+ f"{f['samples_per_hour']:.0f} samples/hr at labeler {_fmt(f['labeler_score'])}."
145
+ )
146
+ return "\n".join(out)
147
+
148
+
149
+ def write_reports(run_dir: Path, metric_rows: list[dict], config: dict) -> dict:
150
+ summaries = summarize(metric_rows)
151
+ md = [
152
+ f"# Qwen VLM Labeler Selection — {run_dir.name}",
153
+ "",
154
+ f"models={config.get('models')} categories={len(config.get('categories', []))} "
155
+ f"reasoning={config.get('reasonings')} modes={config.get('modes')} "
156
+ f"n={config.get('n')} dataset={config.get('dataset')} runner={config.get('runner')}",
157
+ "",
158
+ "## Quality leaderboard (labeler_score, native json_mode)",
159
+ "",
160
+ quality_table(summaries),
161
+ "",
162
+ "## Fleet leaderboard (accuracy × throughput)",
163
+ "",
164
+ fleet_table(summaries),
165
+ "",
166
+ verdict(summaries),
167
+ "",
168
+ ]
169
+ (run_dir / "leaderboard.md").write_text("\n".join(md), encoding="utf-8")
170
+ (run_dir / "summary.json").write_text(
171
+ json.dumps({"config": config, "summaries": summaries}, indent=2), encoding="utf-8")
172
+ # CSV
173
+ cols = ["model", "reasoning", "accuracy", "native_valid", "native_robust", "native_gap",
174
+ "labeler_score", "tokens_per_sec", "samples_per_hour", "fleet_score", "bucket"]
175
+ csv_lines = [",".join(cols)]
176
+ for s in summaries:
177
+ csv_lines.append(",".join("" if s.get(c) is None else str(s.get(c)) for c in cols))
178
+ (run_dir / "leaderboard.csv").write_text("\n".join(csv_lines), encoding="utf-8")
179
+ return {"summaries": summaries}
qwen_test_runner/vision/run_vlmbench.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run_vlmbench.py — CLI entry point (`qwen-vlmbench`).
3
+
4
+ Examples:
5
+ # Offline CPU smoke (no torch, no network) — Phase-0 acceptance:
6
+ qwen-vlmbench --runner stub --dataset smoke \
7
+ --categories image_classification bbox_grounding ocr_text
8
+
9
+ # Real run on Colab (single RTX 6000 Pro):
10
+ qwen-vlmbench --runner vlm --dataset full --n 200 \
11
+ --models qwen3.5-9b qwen3vl-8b --reasoning instruct thinking \
12
+ --modes json_mode constrained --categories image_classification bbox_grounding ocr_text
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+
20
+ from .bench import BenchConfig, run_bench
21
+ from .tasks_vision import category_names, pilot_categories
22
+
23
+
24
+ def _build_parser() -> argparse.ArgumentParser:
25
+ p = argparse.ArgumentParser(prog="qwen-vlmbench", description="Qwen VLM image→JSON labeler benchmark")
26
+ p.add_argument("--models", nargs="+", default=["qwen3.5-0.8b-json-captioner"],
27
+ help="model keys from the model registry (or any label for --runner stub)")
28
+ p.add_argument("--categories", nargs="+", default=pilot_categories(),
29
+ help=f"vision categories. all: {category_names()}")
30
+ p.add_argument("--reasoning", nargs="+", default=["instruct"], choices=["instruct", "thinking"])
31
+ p.add_argument("--modes", nargs="+", default=["json_mode"],
32
+ choices=["json_mode", "constrained", "tool_use", "free"])
33
+ p.add_argument("--n", type=int, default=50, help="samples per category")
34
+ p.add_argument("--dataset", default="smoke", choices=["smoke", "full"])
35
+ p.add_argument("--runner", default="stub", choices=["stub", "vlm"])
36
+ p.add_argument("--precision", default="bf16", choices=["bf16", "fp8", "int4"])
37
+ p.add_argument("--stub-behavior", default="perfect", choices=["perfect", "fragile", "random"])
38
+ p.add_argument("--output-root", default="runs/vision")
39
+ p.add_argument("--gpu-hourly-rate", type=float, default=2.0)
40
+ p.add_argument("--clear-cache-after-model", action="store_true",
41
+ help="delete each model's HF cache after use (full-array sweeps on a tight SSD)")
42
+ return p
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ args = _build_parser().parse_args(argv)
47
+ config = BenchConfig(
48
+ models=args.models,
49
+ categories=args.categories,
50
+ reasonings=args.reasoning,
51
+ modes=args.modes,
52
+ n=args.n,
53
+ dataset=args.dataset,
54
+ runner=args.runner,
55
+ precision=args.precision,
56
+ stub_behavior=args.stub_behavior,
57
+ output_root=args.output_root,
58
+ gpu_hourly_rate=args.gpu_hourly_rate,
59
+ clear_cache_after_model=args.clear_cache_after_model,
60
+ )
61
+ summary = run_bench(config)
62
+ print(json.dumps(summary, indent=2))
63
+ print(f"\nLeaderboard: {summary['run_dir']}/leaderboard.md")
64
+ return 0
65
+
66
+
67
+ if __name__ == "__main__":
68
+ raise SystemExit(main())
qwen_test_runner/vision/runner_types.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ runner_types.py — Shared, torch-free runner result type.
3
+
4
+ Lives in its own module so the orchestrator and the stub runner can import it
5
+ without dragging in torch/transformers (Phase-0 CPU runs must not import torch).
6
+ Mirrors model_runner.GenResult + image/timing metadata.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass
15
+ class VLMResult:
16
+ mode: str # "json_mode" | "constrained" | "tool_use" | "free"
17
+ raw_text: str # exactly what the model decoded (after prompt strip)
18
+ backend: str # "transformers" | "xgrammar" | "stub"
19
+ n_input_tokens: int
20
+ n_output_tokens: int
21
+ gen_seconds: float
22
+ image_id: str = ""
23
+ grammar_conformant: bool = False # constrained decoding actually applied
qwen_test_runner/vision/runners.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ runners.py — VLMRunner: load a Qwen VLM once, run image→JSON generation.
3
+
4
+ Promotes the working image→JSON patterns from colab/qwen_vit_json_test.py
5
+ (AutoProcessor + AutoModelFor{ImageTextToText,MultimodalLM}, image content
6
+ blocks, left-padding, batched generate with OOM-halving) into package code, and
7
+ reuses model_runner._XGrammarLogitsProcessor verbatim for constrained decoding.
8
+
9
+ This module imports torch/transformers at module load, so it is imported LAZILY
10
+ from qwen_test_runner.vision (the Phase-0 stub path never touches it).
11
+
12
+ xgrammar + images: the processor only inspects input_ids/scores, never image
13
+ tensors, so the grammar matcher works with image prompts PROVIDED the prompt_len
14
+ handed to it is the image-INCLUSIVE tokenized length and TokenizerInfo is built
15
+ from processor.tokenizer. Constrained mode runs at batch=1 (matcher is
16
+ per-sequence).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ import warnings
23
+ from typing import Optional
24
+
25
+ import torch
26
+ from transformers import AutoProcessor, AutoModelForImageTextToText
27
+
28
+ from ..model_runner import _XGrammarLogitsProcessor, _HAS_XGRAMMAR
29
+ from .runner_types import VLMResult
30
+ from .tasks_vision import VisionTaskSpec, gbnf_for, resolved_system_prompt, tool_schema_for
31
+
32
+ try: # newer transformers exposes a unified multimodal class (Qwen3.5)
33
+ from transformers import AutoModelForMultimodalLM
34
+ _HAS_MULTIMODAL = True
35
+ except ImportError: # pragma: no cover
36
+ AutoModelForMultimodalLM = None
37
+ _HAS_MULTIMODAL = False
38
+
39
+ if _HAS_XGRAMMAR: # pragma: no cover - GPU/optional path
40
+ import xgrammar as xgr
41
+
42
+
43
+ _DTYPE = {"bf16": torch.bfloat16}
44
+
45
+
46
+ class VLMRunner:
47
+ """Loads one Qwen VLM checkpoint and runs the four generation modes."""
48
+
49
+ def __init__(
50
+ self,
51
+ model_id: str,
52
+ loader_kind: str = "image_text_to_text",
53
+ precision: str = "bf16",
54
+ device: Optional[str] = None,
55
+ device_map: str = "cuda",
56
+ enable_thinking: bool = False,
57
+ trust_remote_code: bool = True,
58
+ ):
59
+ self.model_id = model_id
60
+ self.precision = precision
61
+ self.loader_kind = loader_kind
62
+ self.enable_thinking = enable_thinking
63
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
64
+
65
+ print(f"[VLMRunner] loading {model_id} ({loader_kind}, {precision}) on {device_map}")
66
+ self.processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=trust_remote_code)
67
+ # left-pad for correct batched decoding; ensure a pad token exists — Llama-based
68
+ # checkpoints (e.g. JoyCaption) ship without one, which breaks processor padding.
69
+ if getattr(self.processor, "tokenizer", None) is not None:
70
+ self.processor.tokenizer.padding_side = "left"
71
+ if self.processor.tokenizer.pad_token_id is None:
72
+ self.processor.tokenizer.pad_token = self.processor.tokenizer.eos_token
73
+
74
+ load_cls = AutoModelForImageTextToText
75
+ if loader_kind == "multimodal_lm" and _HAS_MULTIMODAL:
76
+ load_cls = AutoModelForMultimodalLM
77
+ elif loader_kind == "llava_conditional": # JoyCaption (SigLIP + Llama 3.1)
78
+ from transformers import LlavaForConditionalGeneration
79
+ load_cls = LlavaForConditionalGeneration
80
+
81
+ load_kwargs = dict(device_map=device_map, trust_remote_code=trust_remote_code)
82
+ if precision in _DTYPE:
83
+ load_kwargs["dtype"] = _DTYPE[precision] # quant repos carry their own config
84
+ self.model = load_cls.from_pretrained(model_id, **load_kwargs)
85
+ self.model.eval()
86
+
87
+ tok = getattr(self.processor, "tokenizer", self.processor)
88
+ self._pad_id = getattr(tok, "pad_token_id", None) or getattr(tok, "eos_token_id", None)
89
+
90
+ # xgrammar compiler — reusable; per-category grammars compiled on demand.
91
+ # Detect xgrammar LAZILY here (not just at module import) so installing it
92
+ # AFTER the package was first imported still enables constrained mode — and
93
+ # flip model_runner's captured flag so _XGrammarLogitsProcessor works too.
94
+ self._xgr = None
95
+ self._xgr_compiler = None
96
+ self._xgr_tokenizer_info = None
97
+ self._compiled: dict[str, object] = {}
98
+ try:
99
+ import xgrammar as _xgr_mod
100
+ self._xgr = _xgr_mod
101
+ except ImportError:
102
+ pass
103
+ if self._xgr is not None:
104
+ import qwen_test_runner.model_runner as _mr
105
+ if not _mr._HAS_XGRAMMAR: # _XGrammarLogitsProcessor reads these
106
+ _mr.xgr = self._xgr
107
+ _mr._HAS_XGRAMMAR = True
108
+ try:
109
+ self._xgr_tokenizer_info = self._xgr.TokenizerInfo.from_huggingface(tok)
110
+ self._xgr_compiler = self._xgr.GrammarCompiler(self._xgr_tokenizer_info)
111
+ except Exception as e: # pragma: no cover
112
+ warnings.warn(f"xgrammar init failed: {e}; constrained mode unavailable")
113
+ print(f"[VLMRunner] ready. xgrammar={self._xgr_compiler is not None}")
114
+
115
+ def close(self) -> None:
116
+ """Free the model from VRAM (used between models in a sweep)."""
117
+ try:
118
+ del self.model
119
+ except AttributeError:
120
+ pass
121
+ import gc
122
+ gc.collect()
123
+ if torch.cuda.is_available():
124
+ torch.cuda.empty_cache()
125
+
126
+ # ── message construction ─────────────────────────────────────────────────
127
+
128
+ def _messages(self, spec: VisionTaskSpec, image, user_prompt=None) -> list:
129
+ system = resolved_system_prompt(spec)
130
+ user_content = []
131
+ if image is not None:
132
+ user_content.append({"type": "image", "image": image})
133
+ # per-sample prompt override (e.g. the question for VQA), else the task default
134
+ user_content.append({"type": "text", "text": user_prompt or spec.user_prompt})
135
+ return [
136
+ {"role": "system", "content": system},
137
+ {"role": "user", "content": user_content},
138
+ ]
139
+
140
+ def _encode(self, messages_list: list, tools=None):
141
+ kw = dict(add_generation_prompt=True, tokenize=True, return_dict=True,
142
+ return_tensors="pt", padding=True)
143
+ if tools is not None: # don't pass tools=None (some templates warn)
144
+ kw["tools"] = tools
145
+ # `enable_thinking` is a Qwen3.5 (multimodal_lm) toggle; Qwen3-VL's processor rejects it.
146
+ if self.loader_kind == "multimodal_lm":
147
+ kw["enable_thinking"] = self.enable_thinking
148
+ return self.processor.apply_chat_template(messages_list, **kw).to(self.model.device)
149
+
150
+ def _encode_llava(self, messages_list: list):
151
+ """LLaVA/JoyCaption encode. Its chat template expects STRING `content` (it does
152
+ string ops like `.replace` on it) and prepends the `<image>` token itself, so we
153
+ cannot pass Qwen's structured content-parts list — we rebuild a string-content
154
+ conversation. Render to TEXT (no `enable_thinking` no-op kwarg; no `tools`, since
155
+ the Llama template would render a tools block), then let the processor attach the
156
+ PIL image and expand `<image>` into feature tokens in `input_ids` — keeping the
157
+ prompt length image-inclusive for xgrammar."""
158
+ prompts, images = [], []
159
+ for messages in messages_list:
160
+ system_txt, user_txt, img = None, [], None
161
+ for msg in messages:
162
+ content = msg.get("content")
163
+ if msg.get("role") == "system":
164
+ system_txt = content if isinstance(content, str) else None
165
+ continue
166
+ if isinstance(content, list):
167
+ for part in content:
168
+ if not isinstance(part, dict):
169
+ continue
170
+ if part.get("type") == "image":
171
+ img = part.get("image")
172
+ elif part.get("type") == "text":
173
+ user_txt.append(part.get("text", ""))
174
+ elif isinstance(content, str):
175
+ user_txt.append(content)
176
+ convo = []
177
+ if system_txt:
178
+ convo.append({"role": "system", "content": system_txt})
179
+ convo.append({"role": "user", "content": " ".join(t for t in user_txt if t)})
180
+ prompts.append(self.processor.apply_chat_template(
181
+ convo, add_generation_prompt=True, tokenize=False))
182
+ images.append(img)
183
+ inputs = self.processor(
184
+ images=images, text=prompts, return_tensors="pt", padding=True,
185
+ ).to(self.model.device)
186
+ # The SigLIP vision tower is bf16 but the processor emits float32 pixel_values —
187
+ # cast to the model dtype or the vision tower raises a dtype mismatch (the JoyCaption
188
+ # model card does exactly this).
189
+ if "pixel_values" in inputs:
190
+ mdtype = getattr(self.model, "dtype", None)
191
+ if mdtype is not None and inputs["pixel_values"].dtype != mdtype:
192
+ inputs["pixel_values"] = inputs["pixel_values"].to(mdtype)
193
+ return inputs
194
+
195
+ # ── modes ─────────────────────────────────────────────────────────────────
196
+
197
+ @torch.no_grad()
198
+ def generate(self, spec: VisionTaskSpec, image, mode: str, *,
199
+ image_id: str = "", image_size=None, gt=None, user_prompt=None) -> VLMResult:
200
+ if mode == "constrained":
201
+ return self._generate_constrained(spec, image, image_id, user_prompt)
202
+ tools = [self._tool_def(spec)] if mode == "tool_use" else None
203
+ return self._generate_free(spec, image, mode, image_id, tools, user_prompt)
204
+
205
+ @torch.no_grad()
206
+ def _generate_free(self, spec, image, mode, image_id, tools, user_prompt=None) -> VLMResult:
207
+ msgs = [self._messages(spec, image, user_prompt)]
208
+ inputs = (self._encode_llava(msgs) if self.loader_kind == "llava_conditional"
209
+ else self._encode(msgs, tools=tools))
210
+ n_in = inputs["input_ids"].shape[1]
211
+ t0 = time.perf_counter()
212
+ out = self.model.generate(**inputs, max_new_tokens=spec.max_new_tokens,
213
+ do_sample=False, pad_token_id=self._pad_id)
214
+ dt = time.perf_counter() - t0
215
+ cont = out[0, n_in:]
216
+ text = self.processor.decode(cont, skip_special_tokens=True)
217
+ return VLMResult(mode, text, "transformers", int(n_in), int(cont.shape[0]), dt, image_id)
218
+
219
+ @torch.no_grad()
220
+ def _generate_constrained(self, spec, image, image_id, user_prompt=None) -> VLMResult:
221
+ if self._xgr_compiler is None:
222
+ warnings.warn("xgrammar unavailable; constrained falling back to json_mode")
223
+ return self._generate_free(spec, image, "json_mode", image_id, None, user_prompt)
224
+ grammar = gbnf_for(spec)
225
+ compiled = self._compiled.get(spec.category)
226
+ if compiled is None:
227
+ compiled = self._xgr_compiler.compile_grammar(grammar)
228
+ self._compiled[spec.category] = compiled
229
+
230
+ msgs = [self._messages(spec, image, user_prompt)]
231
+ inputs = (self._encode_llava(msgs) if self.loader_kind == "llava_conditional"
232
+ else self._encode(msgs))
233
+ n_in = inputs["input_ids"].shape[1] # image-INCLUSIVE length — critical
234
+ lp = _XGrammarLogitsProcessor(
235
+ compiled_grammar=compiled,
236
+ vocab_size=self._xgr_tokenizer_info.vocab_size,
237
+ prompt_len=n_in,
238
+ )
239
+ t0 = time.perf_counter()
240
+ out = self.model.generate(**inputs, max_new_tokens=spec.max_new_tokens,
241
+ do_sample=False, pad_token_id=self._pad_id,
242
+ logits_processor=[lp])
243
+ dt = time.perf_counter() - t0
244
+ cont = out[0, n_in:]
245
+ text = self.processor.decode(cont, skip_special_tokens=True)
246
+ return VLMResult("constrained", text, "xgrammar", int(n_in), int(cont.shape[0]),
247
+ dt, image_id, grammar_conformant=True)
248
+
249
+ def _tool_def(self, spec: VisionTaskSpec) -> dict:
250
+ return {
251
+ "type": "function",
252
+ "function": {
253
+ "name": "emit_" + spec.category,
254
+ "description": spec.probes,
255
+ "parameters": tool_schema_for(spec),
256
+ },
257
+ }
258
+
259
+ # ── batched json_mode with OOM-halving (throughput path) ───────────────────
260
+
261
+ @torch.no_grad()
262
+ def generate_batch(self, spec: VisionTaskSpec, images: list, mode: str = "json_mode",
263
+ image_ids: Optional[list] = None) -> list[VLMResult]:
264
+ image_ids = image_ids or [""] * len(images)
265
+ return self._batch_with_fallback(spec, images, image_ids, mode)
266
+
267
+ def _batch_with_fallback(self, spec, images, ids, mode) -> list[VLMResult]:
268
+ if not images:
269
+ return []
270
+ try:
271
+ return self._batch(spec, images, ids, mode)
272
+ except torch.cuda.OutOfMemoryError:
273
+ torch.cuda.empty_cache()
274
+ if len(images) == 1:
275
+ return [VLMResult(mode, "", "transformers", 0, 0, 0.0, ids[0])]
276
+ half = max(1, len(images) // 2)
277
+ return (self._batch_with_fallback(spec, images[:half], ids[:half], mode)
278
+ + self._batch_with_fallback(spec, images[half:], ids[half:], mode))
279
+ except Exception:
280
+ if len(images) == 1:
281
+ return [VLMResult(mode, "", "transformers", 0, 0, 0.0, ids[0])]
282
+ return [self._batch_with_fallback(spec, [im], [i], mode)[0]
283
+ for im, i in zip(images, ids)]
284
+
285
+ @torch.no_grad()
286
+ def _batch(self, spec, images, ids, mode) -> list[VLMResult]:
287
+ tools = [self._tool_def(spec)] if mode == "tool_use" else None
288
+ msgs = [self._messages(spec, im) for im in images]
289
+ inputs = (self._encode_llava(msgs) if self.loader_kind == "llava_conditional"
290
+ else self._encode(msgs, tools=tools))
291
+ n_in = inputs["input_ids"].shape[1]
292
+ t0 = time.perf_counter()
293
+ out = self.model.generate(**inputs, max_new_tokens=spec.max_new_tokens,
294
+ do_sample=False, pad_token_id=self._pad_id)
295
+ dt = time.perf_counter() - t0
296
+ per = dt / len(images)
297
+ results = []
298
+ for row, iid in zip(out[:, n_in:], ids):
299
+ text = self.processor.decode(row, skip_special_tokens=True)
300
+ results.append(VLMResult(mode, text, "transformers", int(n_in), int(row.shape[0]), per, iid))
301
+ return results
qwen_test_runner/vision/specialists.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ specialists.py — the CPU assembly layer of the deterministic pipeline.
3
+
4
+ `Solids` holds one image's solidification primitives (detector boxes, seg masks, a
5
+ relative depth map, optional saliency, OCR, tags, class/style) — all in PIXEL space.
6
+ The `build_*` functions turn a `Solids` into each task's exact `tasks_vision` JSON, in
7
+ the task's declared coord space (via the existing `coords.from_canonical`), reusing the
8
+ `derive` engine for the INTEGRATE tasks.
9
+
10
+ The GPU half — loading the models and populating `Solids` — lives in the Colab runner
11
+ (`specialists_gpu`); this module is model-free and unit-tested on CPU.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Optional
18
+
19
+ import numpy as np
20
+
21
+ from . import derive
22
+ from .coords import BBox, CoordSpace, XYXY, _scale_for_space, from_canonical
23
+ from .tasks_vision import get_task
24
+
25
+
26
+ # ── coordinate helpers (pixel → task space) ──────────────────────────────────
27
+ def box_to_space(pix_xyxy, space: CoordSpace, size, ndigits: int = 2) -> list:
28
+ """Pixel-abs [x1,y1,x2,y2] → the task's coord space."""
29
+ return [round(v, ndigits) for v in from_canonical(BBox(*map(float, pix_xyxy)), space, size, XYXY)]
30
+
31
+
32
+ def poly_to_space(pix_flat, space: CoordSpace, size, ndigits: int = 2) -> list:
33
+ """Flat pixel polygon [x1,y1,x2,y2,...] → the task's coord space, per vertex."""
34
+ sx, sy = _scale_for_space(space, size) # raw = pixel / scale
35
+ out = []
36
+ for i in range(0, len(pix_flat) - 1, 2):
37
+ out.append(round(float(pix_flat[i]) / sx, ndigits))
38
+ out.append(round(float(pix_flat[i + 1]) / sy, ndigits))
39
+ return out
40
+
41
+
42
+ def quad_to_xyxy(quad) -> list:
43
+ """A 4-point polygon (PaddleOCR) — [[x,y]*4] or flat [x,y,...] — → pixel xyxy."""
44
+ a = np.asarray(quad, dtype=float).reshape(-1, 2)
45
+ return [float(a[:, 0].min()), float(a[:, 1].min()), float(a[:, 0].max()), float(a[:, 1].max())]
46
+
47
+
48
+ # ── per-image primitives ─────────────────────────────────────────────────────
49
+ @dataclass
50
+ class Solids:
51
+ size: tuple # (W, H) pixels
52
+ boxes: list = field(default_factory=list) # [{label, box:[x1,y1,x2,y2] px, score, mask?}]
53
+ depth: Optional[np.ndarray] = None # HxW relative (Depth-Anything: higher = nearer)
54
+ saliency: Optional[np.ndarray] = None # HxW float in [0,1]
55
+ tags: list = field(default_factory=list) # [{label, score}] (RAM++/SigLIP2)
56
+ class_top: list = field(default_factory=list) # [{label, score}] top-k classification
57
+ ocr: Optional[dict] = None # {full_text, lines:[{text, box:[quad px], conf?}]}
58
+ style: Optional[str] = None # SigLIP2 style label
59
+ gray: Optional[np.ndarray] = None # HxW grayscale (for symmetry)
60
+ depth_higher_is_nearer: bool = True
61
+ attr_boxes: list = field(default_factory=list) # fusion tier: caption-phrase grounding
62
+ # [{phrase, matched_span, box:[x1,y1,x2,y2] px, score}] from ground_phrases()
63
+
64
+
65
+ # ── SOLID task builders (direct model outputs) ───────────────────────────────
66
+ # Emitted lists/strings are truncated to the registry caps (read from the spec,
67
+ # never hardcoded) — the generated pydantic models enforce max_items/max_length
68
+ # as hard constraints, so an uncapped emit would flunk schema validation on
69
+ # busy images even though the content is correct.
70
+ def build_bbox(s: Solids) -> dict:
71
+ spec = get_task("bbox_grounding")
72
+ cap = spec.fields["detections"].max_items
73
+ boxes = sorted(s.boxes, key=lambda b: -float(b.get("score", 1.0)))[:cap]
74
+ dets = [{"label": str(b["label"]),
75
+ "box": box_to_space(b["box"], spec.coord_space, s.size),
76
+ "score": round(float(b.get("score", 1.0)), 4)} for b in boxes]
77
+ return {"detections": dets, "count": len(dets)}
78
+
79
+
80
+ def build_segmentation(s: Solids) -> dict:
81
+ spec = get_task("segmentation")
82
+ cap = spec.fields["masks"].max_items
83
+ masked = [b for b in s.boxes if b.get("mask") is not None]
84
+ masked.sort(key=lambda b: -float(np.asarray(b["mask"]).sum())) # keep largest
85
+ masks = []
86
+ for b in masked:
87
+ if len(masks) >= cap:
88
+ break
89
+ poly = derive.outline_polygon(b["mask"], b["label"])["outline"]
90
+ if len(poly) >= 6:
91
+ masks.append({"label": str(b["label"]),
92
+ "polygon": poly_to_space(poly, spec.coord_space, s.size)})
93
+ return {"masks": masks}
94
+
95
+
96
+ def build_classification(s: Solids) -> dict:
97
+ top = s.class_top or ([{"label": s.boxes[0]["label"], "score": 1.0}] if s.boxes else
98
+ [{"label": "unknown", "score": 0.0}])
99
+ top = sorted(top, key=lambda t: -t["score"])[:5]
100
+ return {"label": str(top[0]["label"]), "confidence": round(float(top[0]["score"]), 4),
101
+ "top5": [{"label": str(t["label"]), "score": round(float(t["score"]), 4)} for t in top]}
102
+
103
+
104
+ def build_ocr(s: Solids) -> dict:
105
+ spec = get_task("ocr_text")
106
+ if not s.ocr:
107
+ return {"full_text": "", "lines": []}
108
+ lines_spec = spec.fields["lines"]
109
+ text_cap = next((f.max_str_length for f in lines_spec.nested_fields
110
+ if f.name == "text"), 512)
111
+ lines = []
112
+ for ln in s.ocr.get("lines", [])[:lines_spec.max_items]:
113
+ d = {"text": str(ln["text"])[:text_cap]}
114
+ if ln.get("box") is not None:
115
+ d["box"] = box_to_space(quad_to_xyxy(ln["box"]), spec.coord_space, s.size)
116
+ lines.append(d)
117
+ full_cap = spec.fields["full_text"].max_str_length
118
+ return {"full_text": str(s.ocr.get("full_text", ""))[:full_cap], "lines": lines}
119
+
120
+
121
+ # ── INTEGRATE task builders (derive engine + coord conversion) ───────────────
122
+ def build_spatial(s: Solids) -> dict:
123
+ return derive.spatial_relations(s.boxes, depth=s.depth,
124
+ higher_is_nearer=s.depth_higher_is_nearer)
125
+
126
+
127
+ def build_depth_order(s: Solids) -> dict:
128
+ if s.depth is None:
129
+ return {"nearest": "", "farthest": "", "relative_depth": []}
130
+ ents = [{"label": b["label"], "mask": b.get("mask"), "box": b["box"]} for b in s.boxes]
131
+ return derive.depth_order(ents, s.depth, higher_is_nearer=s.depth_higher_is_nearer)
132
+
133
+
134
+ def build_subject(s: Solids) -> dict:
135
+ space = get_task("subject_fixation").coord_space
136
+ out = derive.subject_fixation(s.boxes, s.size, saliency=s.saliency)
137
+ out["primary_subject"]["box"] = box_to_space(out["primary_subject"]["box"], space, s.size)
138
+ return out
139
+
140
+
141
+ def build_outline(s: Solids) -> dict:
142
+ space = get_task("outline_association").coord_space
143
+ masked = [b for b in s.boxes if b.get("mask") is not None]
144
+ if not masked:
145
+ return {"outline": [], "label": ""}
146
+ big = max(masked, key=lambda b: np.asarray(b["mask"]).sum())
147
+ o = derive.outline_polygon(big["mask"], big["label"])
148
+ o["outline"] = poly_to_space(o["outline"], space, s.size)
149
+ return o
150
+
151
+
152
+ def build_style(s: Solids) -> dict:
153
+ style = s.style if s.style in ("photo", "painting", "3d_render", "sketch", "anime", "other") else "other"
154
+ layout = derive.layout_kind(s.boxes, s.size)
155
+ symmetry = derive.symmetry_axis(s.gray) if s.gray is not None else "none"
156
+ return {"style": style, "layout": layout, "symmetry": symmetry}
157
+
158
+
159
+ def build_datatype_diff(s: Solids) -> dict:
160
+ return derive.detect_data_type(s.ocr.get("full_text", "") if s.ocr else "")
161
+
162
+
163
+ def build_datatype_util(s: Solids) -> tuple[dict, bool]:
164
+ """Returns ({data_type, content}, parsed_ok). parsed_ok=False → caller may VLM-fallback."""
165
+ return derive.parse_data_type(s.ocr.get("full_text", "") if s.ocr else "")
166
+
167
+
168
+ # task → builder (SOLID + INTEGRATE only; semantic_association + vqa are VLM)
169
+ DETERMINISTIC_BUILDERS = {
170
+ "bbox_grounding": build_bbox,
171
+ "segmentation": build_segmentation,
172
+ "image_classification": build_classification,
173
+ "ocr_text": build_ocr,
174
+ "structural_spatial_awareness": build_spatial,
175
+ "depth_analysis": build_depth_order,
176
+ "subject_fixation": build_subject,
177
+ "outline_association": build_outline,
178
+ "style_structural_awareness": build_style,
179
+ "data_type_differentiation": build_datatype_diff,
180
+ # data_type_utilization handled specially (returns the parsed_ok flag)
181
+ }
qwen_test_runner/vision/specialists_gpu.py ADDED
@@ -0,0 +1,768 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ specialists_gpu.py — the GPU half: load the Apache/MIT specialist models and populate a
3
+ `Solids` per image, then score the built task JSON through the EXISTING vlmbench scorers.
4
+
5
+ This runs on Colab (needs torch + transformers + the model weights). It is deliberately
6
+ incremental: the detection hub (GroundingDINO) + the detection-dependent tasks are wired
7
+ and validated first (against real COCO GT) so the whole chain — detect → Solids → build →
8
+ score_vision_sample — is proven before the other loaders (SAM2, Depth, OCR, SigLIP2, RAM++)
9
+ are added. Each loader is a small function returning primitives in PIXEL space.
10
+
11
+ Model picks are pinned to the plan's Apache/MIT license ledger.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from typing import Optional
19
+
20
+ import numpy as np
21
+
22
+ from .specialists import Solids, build_bbox, build_spatial, build_subject
23
+ from .tasks_vision import get_task
24
+
25
+ # ── detection hub: GroundingDINO (Apache) ────────────────────────────────────
26
+ GROUNDING_DINO_ID = "IDEA-Research/grounding-dino-base" # Apache-2.0, local weights (NOT the API-only 1.5)
27
+
28
+ # COCO-80 (a known closed vocab for the detection *validation*; production uses the RAM++ tagger)
29
+ COCO_CLASSES = [
30
+ "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat",
31
+ "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat",
32
+ "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack",
33
+ "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball",
34
+ "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket",
35
+ "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple",
36
+ "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
37
+ "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse",
38
+ "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink", "refrigerator",
39
+ "book", "clock", "vase", "scissors", "teddy bear", "hair drier", "toothbrush",
40
+ ]
41
+
42
+
43
+ def load_grounding_dino(device: str = "cuda"):
44
+ """Returns (processor, model). Apache checkpoint, loads via transformers.
45
+ No dtype kwarg — float32 is the default and `torch_dtype=` now deprecation-warns."""
46
+ from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection
47
+ proc = AutoProcessor.from_pretrained(GROUNDING_DINO_ID)
48
+ model = AutoModelForZeroShotObjectDetection.from_pretrained(
49
+ GROUNDING_DINO_ID).to(device).eval()
50
+ return proc, model
51
+
52
+
53
+ def _gdino_prompt(classes) -> str:
54
+ # GroundingDINO wants lowercased, "." separated phrases ending in a period.
55
+ return ". ".join(c.strip().lower() for c in classes) + "."
56
+
57
+
58
+ def detect(proc, model, image, classes, box_threshold: float = 0.30,
59
+ text_threshold: float = 0.25, device: str = "cuda") -> list:
60
+ """image → [{label, box:[x1,y1,x2,y2] PIXEL-ABS, score}]. Forces target_sizes so the
61
+ boxes come back pixel-abs xyxy (else transformers returns normalized cxcywh)."""
62
+ import torch
63
+ inputs = proc(images=image, text=_gdino_prompt(classes), return_tensors="pt").to(device)
64
+ with torch.no_grad():
65
+ outputs = model(**inputs)
66
+ W, H = image.size
67
+ _kw = dict(text_threshold=text_threshold, target_sizes=[(H, W)]) # (height, width) → pixel-abs xyxy
68
+ try: # transformers renamed the arg
69
+ res = proc.post_process_grounded_object_detection(
70
+ outputs, inputs["input_ids"], threshold=box_threshold, **_kw)[0]
71
+ except TypeError:
72
+ res = proc.post_process_grounded_object_detection(
73
+ outputs, inputs["input_ids"], box_threshold=box_threshold, **_kw)[0]
74
+ # dict.get(k, default) evaluates the default EAGERLY — touching the deprecated
75
+ # "labels" key fires transformers' FutureWarning even when text_labels exists
76
+ labels = res["text_labels"] if "text_labels" in res else res.get("labels")
77
+ out = []
78
+ for box, score, lab in zip(res["boxes"], res["scores"], labels):
79
+ b = [float(v) for v in box.tolist()]
80
+ out.append({"label": str(lab).strip() or "object", "box": b, "score": float(score)})
81
+ return out
82
+
83
+
84
+ def ground_phrases(gdino, image, phrases, box_threshold: float = 0.25,
85
+ text_threshold: float = 0.20, max_tokens_per_chunk: int = 250,
86
+ device: str = "cuda") -> list:
87
+ """Fusion-tier grounding pass: caption-derived attribute phrases → boxes.
88
+ → [{phrase, matched_span, box:[x1,y1,x2,y2] px, score}].
89
+
90
+ Lower thresholds than the base detection pass — fine-grained phrases score
91
+ lower than category nouns, and the downstream containment+margin gate protects
92
+ precision (recall matters more here: an ungrounded attribute falls back to the
93
+ weaker caption-binding path).
94
+
95
+ GDINO's BERT text encoder truncates at 256 TOKENS silently (the model slices
96
+ input_ids with no warning), so phrases are chunked by the processor's OWN
97
+ tokenizer count (≤ max_tokens_per_chunk, headroom for [CLS]/[SEP]) with an
98
+ accounting assert — a silently dropped phrase is the failure mode. GDINO
99
+ returns the matched text SPAN (possibly a sub-span, "earrings" from "silver
100
+ drop earrings"): each hit is re-mapped to its source phrase by maximum token
101
+ overlap within the chunk."""
102
+ import torch
103
+ proc, model = gdino
104
+ phrases = [p.strip().lower() for p in phrases if p and p.strip()]
105
+ if not phrases:
106
+ return []
107
+
108
+ tok = getattr(proc, "tokenizer", None)
109
+
110
+ def _ntok(p):
111
+ if tok is not None:
112
+ return len(tok(p, add_special_tokens=False)["input_ids"]) + 1 # +1 for ". "
113
+ return len(p.split()) + 1 # crude fallback, ~1.3 tok/word
114
+
115
+ chunks, cur, ntok = [], [], 0
116
+ budget = max_tokens_per_chunk if tok is not None else max_tokens_per_chunk // 2
117
+ for p in phrases:
118
+ t = _ntok(p)
119
+ if cur and ntok + t > budget:
120
+ chunks.append(cur)
121
+ cur, ntok = [], 0
122
+ cur.append(p)
123
+ ntok += t
124
+ if cur:
125
+ chunks.append(cur)
126
+ assert sum(len(c) for c in chunks) == len(phrases), "phrase chunking dropped input"
127
+
128
+ W, H = image.size
129
+ out = []
130
+ for chunk in chunks:
131
+ text = ". ".join(chunk) + "."
132
+ inputs = proc(images=image, text=text, return_tensors="pt").to(device)
133
+ with torch.no_grad():
134
+ outputs = model(**inputs)
135
+ _kw = dict(text_threshold=text_threshold, target_sizes=[(H, W)])
136
+ try: # transformers renamed the arg
137
+ res = proc.post_process_grounded_object_detection(
138
+ outputs, inputs["input_ids"], threshold=box_threshold, **_kw)[0]
139
+ except TypeError:
140
+ res = proc.post_process_grounded_object_detection(
141
+ outputs, inputs["input_ids"], box_threshold=box_threshold, **_kw)[0]
142
+ out.extend(_remap_spans(res, chunk))
143
+ out.sort(key=lambda r: (r["phrase"], -r["score"]))
144
+ return out
145
+
146
+
147
+ def _remap_spans(res, chunk) -> list:
148
+ """GDINO post-process result → attr-box records, re-mapping each matched text
149
+ SPAN back to its source phrase by maximum token overlap within the chunk."""
150
+ labels = res["text_labels"] if "text_labels" in res else res.get("labels")
151
+ out = []
152
+ for box, score, span in zip(res["boxes"], res["scores"], labels):
153
+ span_toks = set(str(span).lower().split())
154
+ best, best_ov = None, 0
155
+ for p in chunk:
156
+ ov = len(span_toks & set(p.split()))
157
+ if ov > best_ov or (ov == best_ov and best and len(p) > len(best)):
158
+ if ov > 0:
159
+ best, best_ov = p, ov
160
+ if best is None:
161
+ continue
162
+ out.append({"phrase": best, "matched_span": str(span).strip(),
163
+ "box": [float(v) for v in box.tolist()],
164
+ "score": float(score)})
165
+ return out
166
+
167
+
168
+ # ── BATCHED specialist paths (throughput: the serial path leaves a 96GB card ──
169
+ # ~90% idle; every model here batches across images) ────────────────────────
170
+
171
+ def detect_batch(gdino, images, classes, box_threshold: float = 0.30,
172
+ text_threshold: float = 0.25, device: str = "cuda") -> list:
173
+ """Batched detection: ONE forward for N images sharing one vocabulary.
174
+ → [boxes_list per image] (same record shape as detect())."""
175
+ import torch
176
+ proc, model = gdino
177
+ images = list(images)
178
+ text = _gdino_prompt(classes)
179
+ inputs = proc(images=images, text=[text] * len(images),
180
+ return_tensors="pt", padding=True).to(device)
181
+ with torch.no_grad():
182
+ outputs = model(**inputs)
183
+ sizes = [(im.size[1], im.size[0]) for im in images] # (H, W)
184
+ _kw = dict(text_threshold=text_threshold, target_sizes=sizes)
185
+ try:
186
+ res = proc.post_process_grounded_object_detection(
187
+ outputs, inputs["input_ids"], threshold=box_threshold, **_kw)
188
+ except TypeError:
189
+ res = proc.post_process_grounded_object_detection(
190
+ outputs, inputs["input_ids"], box_threshold=box_threshold, **_kw)
191
+ out = []
192
+ for r in res:
193
+ labels = r["text_labels"] if "text_labels" in r else r.get("labels")
194
+ out.append([{"label": str(l).strip() or "object",
195
+ "box": [float(v) for v in b.tolist()], "score": float(s)}
196
+ for b, s, l in zip(r["boxes"], r["scores"], labels)])
197
+ return out
198
+
199
+
200
+ def zero_shot_batch(siglip, images, labels, device: str = "cuda",
201
+ template: str = "a photo of a {}.") -> list:
202
+ """Batched SigLIP2 zero-shot → per-image ranked [{label, score}]."""
203
+ import torch
204
+ proc, model = siglip
205
+ texts = [template.format(l) for l in labels]
206
+ # max_length=64 is REQUIRED: the SigLIP2 Gemma tokenizer has no model_max_length,
207
+ # so padding="max_length" alone silently degrades to no padding (HF's own
208
+ # zero-shot pipeline hardcodes 64 for the siglip family)
209
+ inputs = proc(text=texts, images=list(images), return_tensors="pt",
210
+ padding="max_length", max_length=64, truncation=True).to(device)
211
+ with torch.no_grad():
212
+ logits = model(**inputs).logits_per_image # [B, n_text]
213
+ probs = torch.sigmoid(logits).float().cpu().numpy()
214
+ out = []
215
+ for row in probs:
216
+ order = row.argsort()[::-1]
217
+ out.append([{"label": labels[i], "score": float(row[i])} for i in order])
218
+ return out
219
+
220
+
221
+ def depth_map_batch(dp, images) -> list:
222
+ """Batched Depth-Anything → per-image HxW float32 nearness maps.
223
+
224
+ The DPT image processor resizes with keep_aspect_ratio and NO padding, so a
225
+ mixed-aspect batch produces ragged tensors and crashes. Images are therefore
226
+ grouped by exact size (one forward per group) — byte-identical to the serial
227
+ path, and full batching whenever a set shares a resolution (the synth set)."""
228
+ import torch
229
+ from PIL import Image as _I
230
+ proc, model = dp
231
+ images = list(images)
232
+ groups: dict = {}
233
+ for i, im in enumerate(images):
234
+ groups.setdefault(im.size, []).append(i)
235
+ out = [None] * len(images)
236
+ for size, idxs in sorted(groups.items()):
237
+ chunk = [images[i] for i in idxs]
238
+ inputs = proc(images=chunk, return_tensors="pt").to(model.device)
239
+ with torch.no_grad():
240
+ pd = model(**inputs).predicted_depth # [B, h, w]
241
+ W, H = size
242
+ for arr, i in zip(pd, idxs):
243
+ a = arr.squeeze().float().cpu().numpy().astype(np.float32)
244
+ if a.shape != (H, W):
245
+ a = np.asarray(_I.fromarray(a).resize((W, H), _I.BILINEAR),
246
+ dtype=np.float32)
247
+ out[i] = a
248
+ return out
249
+
250
+
251
+ def segment_batch(sam, images, boxes_list, device: str = "cuda") -> list:
252
+ """Batched grounded-SAM. Variable per-image box counts are PADDED to the batch
253
+ max (dummy [0,0,2,2] prompts) and the surplus masks dropped — SAM's processor
254
+ needs a rectangular input_boxes tensor. Mutates boxes in place like segment()."""
255
+ if sam is None or not any(boxes_list):
256
+ return boxes_list
257
+ import torch
258
+ proc, model = sam
259
+ keep = [i for i, bl in enumerate(boxes_list) if bl]
260
+ imgs = [images[i] for i in keep]
261
+ max_n = max(len(boxes_list[i]) for i in keep)
262
+ padded = [[[float(v) for v in b["box"]] for b in boxes_list[i]]
263
+ + [[0.0, 0.0, 2.0, 2.0]] * (max_n - len(boxes_list[i]))
264
+ for i in keep]
265
+ inputs = proc(imgs, input_boxes=padded, return_tensors="pt").to(device)
266
+ with torch.no_grad():
267
+ outputs = model(**inputs)
268
+ masks = proc.image_processor.post_process_masks(
269
+ outputs.pred_masks.cpu(), inputs["original_sizes"].cpu(),
270
+ inputs["reshaped_input_sizes"].cpu()) # [n_obj, 3, H, W] per image
271
+ scores = outputs.iou_scores.cpu().numpy() # [B, n_obj, 3]
272
+ for bi, i in enumerate(keep):
273
+ m = np.asarray(masks[bi])
274
+ sc = scores[bi]
275
+ for oi, b in enumerate(boxes_list[i]): # surplus (padded) masks ignored
276
+ mo = m[oi]
277
+ if mo.ndim == 3:
278
+ best = int(sc[oi].argmax()) if oi < len(sc) else 0
279
+ b["mask_score"] = float(sc[oi][best]) if oi < len(sc) else None
280
+ mo = mo[best]
281
+ b["mask"] = np.asarray(mo, dtype=bool)
282
+ return boxes_list
283
+
284
+
285
+ def ground_phrases_batch(gdino, images, phrases_list, box_threshold: float = 0.25,
286
+ text_threshold: float = 0.20, max_tokens_per_chunk: int = 250,
287
+ device: str = "cuda") -> list:
288
+ """Batched phrase grounding. Images whose phrase text fits ONE chunk (the
289
+ typical case) share a single forward; oversized ones fall back to the serial
290
+ chunked ground_phrases. → per-image attr-box record lists."""
291
+ import torch
292
+ proc, model = gdino
293
+ norm = [[p.strip().lower() for p in (ph or []) if p and p.strip()]
294
+ for ph in phrases_list]
295
+ tok = getattr(proc, "tokenizer", None)
296
+ out = [[] for _ in images]
297
+
298
+ easy, hard = [], []
299
+ for i, ph in enumerate(norm):
300
+ if not ph:
301
+ continue
302
+ text = ". ".join(ph) + "."
303
+ ntok = (len(tok(text)["input_ids"]) if tok is not None
304
+ else 2 * len(text.split()))
305
+ (easy if ntok <= max_tokens_per_chunk else hard).append(i)
306
+
307
+ if easy:
308
+ imgs = [images[i] for i in easy]
309
+ texts = [". ".join(norm[i]) + "." for i in easy]
310
+ inputs = proc(images=imgs, text=texts, return_tensors="pt",
311
+ padding=True).to(device)
312
+ with torch.no_grad():
313
+ outputs = model(**inputs)
314
+ sizes = [(im.size[1], im.size[0]) for im in imgs]
315
+ _kw = dict(text_threshold=text_threshold, target_sizes=sizes)
316
+ try:
317
+ res = proc.post_process_grounded_object_detection(
318
+ outputs, inputs["input_ids"], threshold=box_threshold, **_kw)
319
+ except TypeError:
320
+ res = proc.post_process_grounded_object_detection(
321
+ outputs, inputs["input_ids"], box_threshold=box_threshold, **_kw)
322
+ for bi, i in enumerate(easy):
323
+ recs = _remap_spans(res[bi], norm[i])
324
+ recs.sort(key=lambda r: (r["phrase"], -r["score"]))
325
+ out[i] = recs
326
+ for i in hard:
327
+ out[i] = ground_phrases(gdino, images[i], norm[i],
328
+ box_threshold=box_threshold,
329
+ text_threshold=text_threshold,
330
+ max_tokens_per_chunk=max_tokens_per_chunk,
331
+ device=device)
332
+ return out
333
+
334
+
335
+ def solids_from_detection(image, boxes) -> Solids:
336
+ """Minimal Solids from detection alone (feeds bbox / spatial / subject)."""
337
+ return Solids(size=image.size, boxes=boxes)
338
+
339
+
340
+ # ── validation: detection hub vs COCO GT, through the existing scorers ────────
341
+ def validate_detection(n: int = 24, box_threshold: float = 0.30, device: str = "cuda") -> dict:
342
+ """Run GroundingDINO on real COCO images, build the bbox JSON, and score it with the
343
+ EXISTING vlmbench detection scorer — apples-to-apples with the VLM's bbox number."""
344
+ from .datasets import load_gt
345
+ from .metrics import score_vision_sample, score_vision_run
346
+
347
+ spec = get_task("bbox_grounding")
348
+ proc, model = load_grounding_dino(device)
349
+ samples = load_gt(spec.gt_dataset, n=n, split=spec.gt_split, dataset="full")
350
+ print(f"[validate_detection] {GROUNDING_DINO_ID} on {len(samples)} COCO images")
351
+
352
+ results, t0 = [], time.perf_counter()
353
+ for i, s in enumerate(samples):
354
+ boxes = detect(proc, model, s.image, COCO_CLASSES, box_threshold, device=device)
355
+ pred = build_bbox(solids_from_detection(s.image, boxes))
356
+ mr = score_vision_sample(spec, json.dumps(pred), s.gt, mode="specialist",
357
+ image_id=s.image_id, image_size=s.size)
358
+ results.append(mr)
359
+ if i < 3:
360
+ print(f" {s.image_id}: {len(boxes)} boxes, primary={mr.primary_score}")
361
+ dt = time.perf_counter() - t0
362
+ run = score_vision_run(results, model="grounding-dino-base", category=spec.category, mode="specialist")
363
+ out = {"model": GROUNDING_DINO_ID, "n": len(samples),
364
+ "primary_score_mean": run.primary_score_mean, "schema_valid_rate": run.schema_valid_rate,
365
+ "img_per_s": round(len(samples) / max(0.001, dt), 2)}
366
+ print(f"\n[validate_detection] mean primary={out['primary_score_mean']} "
367
+ f"valid={out['schema_valid_rate']} {out['img_per_s']} img/s")
368
+ print("Compare vs the VLM bbox_grounding effective yield (~0.16–0.30 in the vlmbench).")
369
+ return out
370
+
371
+
372
+ # ── depth hub: Depth-Anything-V2-Small (Apache; higher = nearer) ─────────────
373
+ DEPTH_ID = "depth-anything/Depth-Anything-V2-Small-hf" # ONLY Small is Apache
374
+
375
+ def load_depth_anything(device: str = "cuda"):
376
+ """Returns (processor, model). Direct model call — the transformers pipeline()
377
+ warns ("use a dataset") when invoked per-image on GPU and adds dispatch overhead."""
378
+ from transformers import AutoImageProcessor, AutoModelForDepthEstimation
379
+ proc = AutoImageProcessor.from_pretrained(DEPTH_ID)
380
+ model = AutoModelForDepthEstimation.from_pretrained(DEPTH_ID).to(device).eval()
381
+ return proc, model
382
+
383
+
384
+ def depth_map(dp, image):
385
+ """HxW float32 relative depth; Depth-Anything convention: HIGHER = NEARER."""
386
+ import torch
387
+ proc, model = dp
388
+ inputs = proc(images=image, return_tensors="pt").to(model.device)
389
+ with torch.no_grad():
390
+ pd = model(**inputs).predicted_depth
391
+ arr = pd.squeeze().float().cpu().numpy().astype(np.float32)
392
+ # resize to image size if the model returned a different resolution
393
+ W, H = image.size
394
+ if arr.shape != (H, W):
395
+ from PIL import Image as _I
396
+ arr = np.asarray(_I.fromarray(arr).resize((W, H), _I.BILINEAR), dtype=np.float32)
397
+ return arr
398
+
399
+
400
+ # ── segmentation hub: SAM v1 (Apache), prompted by detection boxes ───────────
401
+ # SAM2's transformers processor is currently broken (missing preprocessor_config on the -hf
402
+ # repo); SAM v1 is equally Apache-2.0, box-promptable, and rock-solid in transformers.
403
+ SAM_ID = "facebook/sam-vit-base"
404
+
405
+ def load_sam(device: str = "cuda"):
406
+ """Returns (processor, model) or None. SAM v1 via transformers (SamModel/SamProcessor)."""
407
+ try:
408
+ from transformers import SamModel, SamProcessor
409
+ proc = SamProcessor.from_pretrained(SAM_ID)
410
+ model = SamModel.from_pretrained(SAM_ID).to(device).eval()
411
+ return proc, model
412
+ except Exception as e:
413
+ print(f"[load_sam] SAM unavailable: {type(e).__name__}: {e}")
414
+ return None
415
+
416
+
417
+ def segment(sam, image, boxes, device: str = "cuda"):
418
+ """Attach a boolean mask to each box dict (in place). Grounded-SAM: box → mask.
419
+ Picks the highest-IoU of SAM's 3 mask proposals per box."""
420
+ if sam is None or not boxes:
421
+ return boxes
422
+ import torch
423
+ proc, model = sam
424
+ input_boxes = [[[float(v) for v in b["box"]] for b in boxes]] # [image][obj][xyxy]
425
+ inputs = proc(image, input_boxes=input_boxes, return_tensors="pt").to(device)
426
+ with torch.no_grad():
427
+ outputs = model(**inputs)
428
+ masks = proc.image_processor.post_process_masks(
429
+ outputs.pred_masks.cpu(), inputs["original_sizes"].cpu(),
430
+ inputs["reshaped_input_sizes"].cpu())[0] # [obj, n_masks, H, W]
431
+ m = np.asarray(masks)
432
+ scores = outputs.iou_scores.cpu().numpy()[0] # [obj, n_masks]
433
+ for i, b in enumerate(boxes):
434
+ if i >= len(m):
435
+ break
436
+ mi = m[i]
437
+ if mi.ndim == 3: # [n_masks, H, W] → best by IoU
438
+ best = int(scores[i].argmax()) if i < len(scores) else 0
439
+ b["mask_score"] = float(scores[i][best]) if i < len(scores) else None
440
+ mi = mi[best]
441
+ b["mask"] = np.asarray(mi, dtype=bool)
442
+ return boxes
443
+
444
+
445
+ # ── classification / style hub: SigLIP2 (Apache) zero-shot ───────────────────
446
+ SIGLIP_ID = "google/siglip2-so400m-patch14-384"
447
+ STYLE_LABELS = ["photo", "painting", "3d_render", "sketch", "anime", "other"]
448
+
449
+ def load_siglip(device: str = "cuda"):
450
+ from transformers import AutoProcessor, AutoModel
451
+ from transformers.utils import logging as hf_logging
452
+ # The checkpoint's config carries CLIP-tokenizer bos/eos ids (49406/49407) that
453
+ # newer transformers flags against the 32k text vocab. SigLIP never generates,
454
+ # so the ids are inert — silence the config validation for just this load.
455
+ prev = hf_logging.get_verbosity()
456
+ hf_logging.set_verbosity_error()
457
+ try:
458
+ proc = AutoProcessor.from_pretrained(SIGLIP_ID)
459
+ model = AutoModel.from_pretrained(SIGLIP_ID).to(device).eval()
460
+ finally:
461
+ hf_logging.set_verbosity(prev)
462
+ return proc, model
463
+
464
+
465
+ def zero_shot(siglip, image, labels, device: str = "cuda", template: str = "a photo of a {}.") -> list:
466
+ """SigLIP2 zero-shot → [{label, score}] sorted desc (sigmoid, not softmax)."""
467
+ import torch
468
+ proc, model = siglip
469
+ texts = [template.format(l) for l in labels]
470
+ # max_length=64 required — see zero_shot_batch (SigLIP2 tokenizer has no
471
+ # model_max_length, so padding="max_length" alone silently doesn't pad)
472
+ inputs = proc(text=texts, images=image, return_tensors="pt", padding="max_length",
473
+ max_length=64, truncation=True).to(device)
474
+ with torch.no_grad():
475
+ logits = model(**inputs).logits_per_image[0]
476
+ probs = torch.sigmoid(logits).float().cpu().numpy()
477
+ order = probs.argsort()[::-1]
478
+ return [{"label": labels[i], "score": float(probs[i])} for i in order]
479
+
480
+
481
+ # ── OCR hub: EasyOCR (Apache, torch — no Paddle/torch CUDA conflict) ──────────
482
+ def load_ocr(device: str = "cuda"):
483
+ try:
484
+ import easyocr
485
+ return easyocr.Reader(["en"], gpu=(device == "cuda"))
486
+ except Exception as e:
487
+ print(f"[load_ocr] EasyOCR unavailable: {type(e).__name__}: {e}")
488
+ return None
489
+
490
+
491
+ def ocr_read(reader, image) -> dict:
492
+ """EasyOCR → {full_text, lines:[{text, box:[quad px], conf}]}. Confidence is
493
+ RETAINED (the fusion tier carries it; build_ocr ignores the extra key)."""
494
+ if reader is None:
495
+ return {"full_text": "", "lines": []}
496
+ res = reader.readtext(np.asarray(image)) # [(quad, text, conf), ...]
497
+ lines = [{"text": str(t), "box": [[float(x), float(y)] for x, y in quad],
498
+ "conf": float(c)} for quad, t, c in res]
499
+ return {"full_text": " ".join(l["text"] for l in lines), "lines": lines}
500
+
501
+
502
+ # ── generic per-task validation through the existing scorers ─────────────────
503
+ SHAPE_CLASSES = ["red circle", "green circle", "blue circle"] # synthetic-shape GT vocab
504
+
505
+ # task → dict of which models it needs, the GDINO vocab, and (optional) a REAL-image GT
506
+ # override that replaces the synthetic GT in the task spec.
507
+ _TASK_CFG = {
508
+ "bbox_grounding": dict(vocab=COCO_CLASSES, gdino=True),
509
+ "segmentation": dict(vocab=COCO_CLASSES, gdino=True, masks=True, gt="coco_segmentation"),
510
+ "outline_association": dict(vocab=COCO_CLASSES, gdino=True, masks=True, gt="coco_outline"),
511
+ "subject_fixation": dict(vocab=COCO_CLASSES, gdino=True, gt="coco_subject"),
512
+ # still synthetic — need real depth (NYU/DIODE) + relations (Visual Genome); next real-GT pass
513
+ "depth_analysis": dict(vocab=SHAPE_CLASSES, gdino=True, depth=True, masks=True),
514
+ "structural_spatial_awareness": dict(vocab=SHAPE_CLASSES, gdino=True, depth=True),
515
+ "image_classification": dict(siglip=True), # vocab from GT labels
516
+ "style_structural_awareness": dict(gdino=True, siglip=True, gray=True), # style has no real GT
517
+ "ocr_text": dict(ocr=True),
518
+ "data_type_differentiation": dict(ocr=True), # rendered-format GT is synthetic
519
+ "data_type_utilization": dict(ocr=True),
520
+ }
521
+
522
+
523
+ def validate_task(task: str, n: int = 24, device: str = "cuda", *, gdino=None,
524
+ depth_pipe=None, sam=None, siglip=None, ocr=None) -> dict:
525
+ """Run the specialist/derive chain for one task and score it with the vlmbench scorer."""
526
+ from .datasets import load_gt
527
+ from .metrics import score_vision_sample, score_vision_run
528
+ from .specialists import (Solids, build_bbox, build_spatial, build_subject,
529
+ build_depth_order, build_segmentation, build_outline,
530
+ build_classification, build_style, build_ocr,
531
+ build_datatype_diff, build_datatype_util)
532
+
533
+ spec = get_task(task)
534
+ cfg = _TASK_CFG[task]
535
+ gt_key = cfg.get("gt", spec.gt_dataset) # real-image GT override when available
536
+ samples = load_gt(gt_key, n=n, split=spec.gt_split or "", dataset="full")
537
+
538
+ # candidate label set for zero-shot classification: the classes present in this GT slice
539
+ class_vocab = None
540
+ if task == "image_classification":
541
+ seen = []
542
+ for s in samples:
543
+ for l in (s.gt.get("labels", []) if isinstance(s.gt, dict) else []):
544
+ if l not in seen:
545
+ seen.append(l)
546
+ class_vocab = seen or ["object"]
547
+
548
+ results = []
549
+ for s in samples:
550
+ sol = Solids(size=s.image.size)
551
+ if cfg.get("gdino") and gdino is not None:
552
+ sol.boxes = detect(gdino[0], gdino[1], s.image, cfg.get("vocab", COCO_CLASSES), device=device)
553
+ if cfg.get("masks") and sam is not None:
554
+ sol.boxes = segment(sam, s.image, sol.boxes, device=device)
555
+ if cfg.get("depth") and depth_pipe is not None:
556
+ sol.depth = depth_map(depth_pipe, s.image)
557
+ if cfg.get("gray"):
558
+ sol.gray = np.asarray(s.image.convert("L"), dtype=np.float32)
559
+ if cfg.get("siglip") and siglip is not None:
560
+ if task == "image_classification":
561
+ sol.class_top = zero_shot(siglip, s.image, class_vocab, device=device)[:5]
562
+ if task == "style_structural_awareness":
563
+ sol.style = zero_shot(siglip, s.image, STYLE_LABELS, device=device)[0]["label"]
564
+ if cfg.get("ocr") and ocr is not None:
565
+ sol.ocr = ocr_read(ocr, s.image)
566
+
567
+ if task == "depth_analysis":
568
+ pred = build_depth_order(sol)
569
+ elif task == "segmentation":
570
+ pred = build_segmentation(sol)
571
+ elif task == "outline_association":
572
+ pred = build_outline(sol)
573
+ elif task == "structural_spatial_awareness":
574
+ pred = build_spatial(sol)
575
+ elif task == "subject_fixation":
576
+ pred = build_subject(sol)
577
+ elif task == "bbox_grounding":
578
+ pred = build_bbox(sol)
579
+ elif task == "image_classification":
580
+ pred = build_classification(sol)
581
+ elif task == "style_structural_awareness":
582
+ pred = build_style(sol)
583
+ elif task == "ocr_text":
584
+ pred = build_ocr(sol)
585
+ elif task == "data_type_differentiation":
586
+ pred = build_datatype_diff(sol)
587
+ elif task == "data_type_utilization":
588
+ pred = build_datatype_util(sol)[0]
589
+ else:
590
+ raise KeyError(task)
591
+
592
+ mr = score_vision_sample(spec, json.dumps(pred), s.gt, mode="specialist",
593
+ image_id=s.image_id, image_size=s.size)
594
+ results.append(mr)
595
+ run = score_vision_run(results, model="specialist", category=task, mode="specialist")
596
+ return {"task": task, "n": len(samples), "primary": run.primary_score_mean,
597
+ "valid": run.schema_valid_rate}
598
+
599
+
600
+ class SpecialistPipeline:
601
+ """Simplified interface: load the Apache/MIT specialist models ONCE, then `extract(image)`
602
+ returns every deterministic task's JSON (the production entry point for `tasks_json`).
603
+
604
+ pipe = SpecialistPipeline()
605
+ tasks = pipe.extract(pil_image) # {"bbox_grounding": {...}, "segmentation": {...}, ...}
606
+ """
607
+ DEFAULT_VOCAB = COCO_CLASSES
608
+
609
+ def __init__(self, device: str = "cuda", with_ocr: bool = True):
610
+ self.device = device
611
+ self.gdino = load_grounding_dino(device)
612
+ self.depth = load_depth_anything(device)
613
+ self.sam = load_sam(device)
614
+ self.siglip = load_siglip(device)
615
+ self.ocr = load_ocr(device) if with_ocr else None
616
+
617
+ def solidify(self, image, vocab=None):
618
+ """Run every specialist once → a `Solids` (primitives in pixel space)."""
619
+ vocab = vocab or self.DEFAULT_VOCAB
620
+ s = Solids(size=image.size)
621
+ s.boxes = detect(self.gdino[0], self.gdino[1], image, vocab, device=self.device)
622
+ s.boxes = segment(self.sam, image, s.boxes, device=self.device)
623
+ s.depth = depth_map(self.depth, image)
624
+ s.gray = np.asarray(image.convert("L"), dtype=np.float32)
625
+ if self.siglip is not None:
626
+ s.class_top = zero_shot(self.siglip, image, vocab, device=self.device)[:5]
627
+ s.style = zero_shot(self.siglip, image, STYLE_LABELS, device=self.device)[0]["label"]
628
+ if self.ocr is not None:
629
+ s.ocr = ocr_read(self.ocr, image)
630
+ return s
631
+
632
+ @staticmethod
633
+ def _build_tasks(s) -> dict:
634
+ """Solids → {task_name: task_json} for all 11 deterministic tasks."""
635
+ from .specialists import (build_bbox, build_segmentation, build_classification,
636
+ build_ocr, build_spatial, build_depth_order, build_subject,
637
+ build_outline, build_style, build_datatype_diff,
638
+ build_datatype_util)
639
+ util, _ = build_datatype_util(s)
640
+ return {
641
+ "bbox_grounding": build_bbox(s),
642
+ "segmentation": build_segmentation(s),
643
+ "outline_association": build_outline(s),
644
+ "subject_fixation": build_subject(s),
645
+ "depth_analysis": build_depth_order(s),
646
+ "structural_spatial_awareness": build_spatial(s),
647
+ "image_classification": build_classification(s),
648
+ "style_structural_awareness": build_style(s),
649
+ "ocr_text": build_ocr(s),
650
+ "data_type_differentiation": build_datatype_diff(s),
651
+ "data_type_utilization": util,
652
+ }
653
+
654
+ def extract(self, image, vocab=None) -> dict:
655
+ """→ {task_name: task_json} for all 11 deterministic tasks (one solidify pass)."""
656
+ return self._build_tasks(self.solidify(image, vocab))
657
+
658
+ def ground(self, image, phrases) -> list:
659
+ """Fusion grounding pass: caption phrases → attr-box records (GDINO reused)."""
660
+ return ground_phrases(self.gdino, image, phrases, device=self.device)
661
+
662
+ def solidify_batch(self, images, vocab=None, phrases_list=None,
663
+ batch: int = 16, gdino_batch: int = 2) -> list:
664
+ """Batched solidify: SAM/depth/SigLIP run at `batch` images per forward;
665
+ GroundingDINO runs in sub-chunks of `gdino_batch` — its deformable
666
+ attention's activation memory EXPLODES with padded batches (measured on
667
+ the 96GB Blackwell: B2 = 11GB, B16 = 42GB for LESS throughput), so ~2 is
668
+ its sweet spot. EasyOCR stays serial (4% of the budget). → [Solids]
669
+ aligned with `images`, same output contract as solidify()."""
670
+ vocab = vocab or self.DEFAULT_VOCAB
671
+ images = list(images)
672
+ solids = []
673
+ for start in range(0, len(images), batch):
674
+ chunk = images[start:start + batch]
675
+ p_chunk = (phrases_list[start:start + batch]
676
+ if phrases_list is not None else None)
677
+ boxes_list = []
678
+ for s2 in range(0, len(chunk), gdino_batch):
679
+ boxes_list.extend(detect_batch(
680
+ self.gdino, chunk[s2:s2 + gdino_batch], vocab,
681
+ device=self.device))
682
+ boxes_list = segment_batch(self.sam, chunk, boxes_list, device=self.device)
683
+ depths = (depth_map_batch(self.depth, chunk)
684
+ if self.depth is not None else [None] * len(chunk))
685
+ classes = (zero_shot_batch(self.siglip, chunk, vocab, device=self.device)
686
+ if self.siglip is not None else [None] * len(chunk))
687
+ styles = (zero_shot_batch(self.siglip, chunk, STYLE_LABELS, device=self.device)
688
+ if self.siglip is not None else [None] * len(chunk))
689
+ if p_chunk is not None:
690
+ attrs = []
691
+ for s2 in range(0, len(chunk), gdino_batch):
692
+ attrs.extend(ground_phrases_batch(
693
+ self.gdino, chunk[s2:s2 + gdino_batch],
694
+ p_chunk[s2:s2 + gdino_batch], device=self.device))
695
+ else:
696
+ attrs = [[] for _ in chunk]
697
+ for k, im in enumerate(chunk):
698
+ s = Solids(size=im.size)
699
+ s.boxes = boxes_list[k]
700
+ s.depth = depths[k]
701
+ s.gray = np.asarray(im.convert("L"), dtype=np.float32)
702
+ if classes[k] is not None:
703
+ s.class_top = classes[k][:5]
704
+ s.style = styles[k][0]["label"]
705
+ if self.ocr is not None:
706
+ s.ocr = ocr_read(self.ocr, im)
707
+ s.attr_boxes = attrs[k]
708
+ solids.append(s)
709
+ return solids
710
+
711
+ def extract_batch(self, images, vocab=None, phrases_list=None,
712
+ batch: int = 16) -> list:
713
+ """Batched extract(+digest): → [(tasks_dict, digest)] aligned with images."""
714
+ from .fuse import solids_digest
715
+ out = []
716
+ for s in self.solidify_batch(images, vocab, phrases_list, batch=batch):
717
+ out.append((self._build_tasks(s), solids_digest(s)))
718
+ return out
719
+
720
+ def extract_with_digest(self, image, phrases=None, vocab=None) -> tuple:
721
+ """→ (tasks_dict, solids_digest) from ONE solidify pass (+ the phrase-
722
+ grounding pass when `phrases` is given). The digest is the fusion tier's
723
+ input — compact, JSON-able, carries the retained confidences."""
724
+ from .fuse import solids_digest
725
+ s = self.solidify(image, vocab)
726
+ if phrases:
727
+ s.attr_boxes = self.ground(image, phrases)
728
+ return self._build_tasks(s), solids_digest(s)
729
+
730
+
731
+ def load_vlm(model_key: str = "qwen3vl-4b"):
732
+ from .model_registry import get_runner
733
+ return get_runner(model_key)
734
+
735
+
736
+ def validate_task_vlm(task: str, n: int = 24, model_key: str = "qwen3vl-4b",
737
+ runner=None, device: str = "cuda") -> dict:
738
+ """Run the Qwen VLM on the SAME (real) GT as validate_task — a true apples-to-apples
739
+ head-to-head. Reuses the existing VLMRunner + score path. Pass a pre-loaded `runner`
740
+ to avoid reloading the model per task."""
741
+ from .datasets import load_gt
742
+ from .metrics import score_vision_sample, score_vision_run
743
+
744
+ spec = get_task(task)
745
+ gt_key = _TASK_CFG[task].get("gt", spec.gt_dataset)
746
+ samples = load_gt(gt_key, n=n, split=spec.gt_split or "", dataset="full")
747
+ own = runner is None
748
+ if own:
749
+ runner = load_vlm(model_key)
750
+ results = []
751
+ try:
752
+ for s in samples:
753
+ up = s.prompt if spec.per_sample_prompt else None
754
+ res = runner.generate(spec, s.image, "json_mode", image_id=s.image_id,
755
+ image_size=s.size, gt=s.gt, user_prompt=up)
756
+ results.append(score_vision_sample(spec, res.raw_text, s.gt, mode="json_mode",
757
+ image_id=s.image_id, image_size=s.size))
758
+ finally:
759
+ if own:
760
+ close = getattr(runner, "close", None)
761
+ if callable(close):
762
+ close()
763
+ run = score_vision_run(results, model=model_key, category=task, mode="json_mode")
764
+ # effective yield = accuracy × validity (the vlmbench headline metric)
765
+ acc = run.primary_score_mean
766
+ return {"task": task, "vlm_primary": acc, "vlm_valid": run.schema_valid_rate,
767
+ "vlm_yield": (acc * run.schema_valid_rate) if acc is not None else None}
768
+
qwen_test_runner/vision/strata.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ strata.py — the attribute stratification lexicon (fusion tier).
3
+
4
+ Classifies a caption attribute string into a STRATUM — the "disperse the topics"
5
+ layer that replaces the flat attribute list with typed, routable records. Pure
6
+ stdlib, deterministic, registry-as-python (same pattern as registry.py /
7
+ tasks_vision.py): the lexicon is data to iterate on, not code.
8
+
9
+ Routing semantics consumed by fuse.py:
10
+ - GROUNDABLE strata are sent to the GDINO phrase-grounding pass (they name
11
+ visible things a detector can box).
12
+ - "scene_level" bypasses entities entirely -> FusedScene.scene.scene_attributes.
13
+ - "abstract_quality", "color", and "action" ride the caption-binding-only path
14
+ (never grounded: a detector box for "elegant" or bare "red" is noise).
15
+ - Everything is classified; "abstract_quality" is the catch-all default.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ from .metrics import _depluralize
23
+
24
+ # Minimal stopword set for head-noun extraction (articles/preps/conjunctions that
25
+ # can trail a phrase). Deliberately tiny — attribute phrases are short.
26
+ _STOP = frozenset({
27
+ "a", "an", "the", "of", "in", "on", "at", "with", "and", "or", "to",
28
+ "her", "his", "its", "their", "very", "slightly",
29
+ })
30
+
31
+ _TOKEN_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
32
+
33
+
34
+ STRATA: dict[str, frozenset] = {
35
+ "hair": frozenset({
36
+ "hair", "hairstyle", "bangs", "fringe", "ponytail", "pigtails", "twintails",
37
+ "braid", "braids", "bun", "curls", "updo", "bob", "undercut", "mohawk",
38
+ "sidelocks", "ahoge", "afro", "dreadlocks", "cornrows", "mullet", "buzzcut",
39
+ }),
40
+ "face": frozenset({
41
+ "face", "eyes", "eye", "eyebrows", "eyebrow", "eyelashes", "lips", "lip",
42
+ "mouth", "nose", "cheeks", "cheekbones", "chin", "jaw", "jawline", "forehead",
43
+ "freckles", "dimples", "beard", "mustache", "stubble", "smile", "grin",
44
+ "expression", "gaze", "makeup", "lipstick", "eyeliner", "eyeshadow", "blush",
45
+ "mascara", "teeth",
46
+ }),
47
+ "skin": frozenset({
48
+ "skin", "complexion", "tan", "tattoo", "tattoos", "scar", "scars", "mole",
49
+ "birthmark", "wrinkles", "pores",
50
+ }),
51
+ "clothing": frozenset({
52
+ "dress", "shirt", "t-shirt", "tshirt", "blouse", "top", "skirt", "pants",
53
+ "trousers", "jeans", "shorts", "jacket", "coat", "hoodie", "sweater",
54
+ "cardigan", "vest", "suit", "uniform", "kimono", "yukata", "robe", "gown",
55
+ "leotard", "swimsuit", "bikini", "armor", "cape", "cloak", "apron",
56
+ "sleeves", "sleeve", "collar", "neckline", "hem", "outfit", "attire",
57
+ "clothes", "clothing", "costume", "sweatshirt", "leggings", "stockings",
58
+ "tights", "socks", "corset", "bodysuit", "tunic", "sari", "poncho",
59
+ }),
60
+ "accessory": frozenset({
61
+ "earrings", "earring", "necklace", "pendant", "choker", "bracelet", "ring",
62
+ "rings", "watch", "hat", "cap", "beanie", "beret", "crown", "tiara",
63
+ "headband", "hairband", "ribbon", "bow", "hairpin", "hairclip", "scrunchie",
64
+ "glasses", "sunglasses", "eyepatch", "monocle", "mask", "scarf", "gloves",
65
+ "glove", "belt", "bag", "handbag", "backpack", "purse", "umbrella", "fan",
66
+ "brooch", "badge", "piercing", "anklet", "shoes", "boots", "sandals",
67
+ "heels", "sneakers", "veil", "headphones", "tie", "bowtie",
68
+ }),
69
+ "body": frozenset({
70
+ "build", "figure", "physique", "body", "shoulders", "shoulder", "arms",
71
+ "arm", "hands", "hand", "fingers", "legs", "leg", "thighs", "knees",
72
+ "feet", "chest", "waist", "hips", "back", "neck", "collarbone", "height",
73
+ "frame", "posture", "muscles", "abs", "curves",
74
+ }),
75
+ "pose": frozenset({
76
+ "standing", "sitting", "kneeling", "crouching", "lying", "leaning",
77
+ "walking", "running", "jumping", "dancing", "posing", "looking", "facing",
78
+ "reaching", "pointing", "waving", "holding", "carrying", "crossed",
79
+ "outstretched", "tilted", "turned", "pose", "stance",
80
+ }),
81
+ "color": frozenset({
82
+ "red", "orange", "yellow", "green", "blue", "purple", "violet", "pink",
83
+ "brown", "black", "white", "gray", "grey", "silver", "gold", "golden",
84
+ "blonde", "blond", "brunette", "auburn", "crimson", "scarlet", "teal",
85
+ "turquoise", "cyan", "magenta", "lavender", "beige", "cream", "ivory",
86
+ "navy", "maroon", "olive", "platinum", "pastel", "neon", "dark", "light",
87
+ "pale", "bright", "vivid", "striped", "plaid", "polka-dot", "checkered",
88
+ "floral", "gradient",
89
+ }),
90
+ "abstract_quality": frozenset({
91
+ "beautiful", "pretty", "handsome", "cute", "elegant", "graceful", "stylish",
92
+ "fashionable", "detailed", "intricate", "delicate", "soft", "sharp",
93
+ "masterpiece", "quality", "aesthetic", "gorgeous", "stunning", "charming",
94
+ "youthful", "mature", "young", "old", "confident", "shy", "serene", "calm",
95
+ "cheerful", "melancholic", "mysterious", "dramatic", "ethereal", "dreamy",
96
+ }),
97
+ "scene_level": frozenset({
98
+ "background", "foreground", "backdrop", "lighting", "light", "shadow",
99
+ "shadows", "sunlight", "moonlight", "sunset", "sunrise", "dusk", "dawn",
100
+ "sky", "clouds", "bokeh", "blur", "depth", "wall", "walls", "floor",
101
+ "ceiling", "window", "windows", "door", "room", "indoors", "outdoors",
102
+ "outdoor", "indoor", "scenery", "landscape", "cityscape", "street",
103
+ "forest", "beach", "mountains", "atmosphere", "ambiance", "setting",
104
+ "scene", "environment", "composition", "framing",
105
+ }),
106
+ }
107
+
108
+ # hyphen/compound-adjective suffixes -> stratum ("silver-haired", "blue-eyed")
109
+ SUFFIX_RULES: tuple = (
110
+ ("haired", "hair"),
111
+ ("eyed", "face"),
112
+ ("faced", "face"),
113
+ ("skinned", "skin"),
114
+ ("sleeved", "clothing"),
115
+ ("dressed", "clothing"),
116
+ ("clad", "clothing"),
117
+ ("shouldered", "body"),
118
+ ("legged", "body"),
119
+ ("armed", "body"),
120
+ )
121
+
122
+ # -ing words that are NOUNS, not gerunds — exempt from the verb-phrase rule
123
+ # (data to extend as COCO round-trips surface more)
124
+ _NOUN_ING = frozenset({
125
+ "wedding", "building", "painting", "lighting", "ceiling", "clothing",
126
+ "evening", "morning", "string", "earring", "ring", "king", "wing",
127
+ "railing", "awning",
128
+ })
129
+
130
+ # Strata whose phrases go to the GDINO grounding pass (visible, boxable things).
131
+ GROUNDABLE = frozenset({"hair", "face", "skin", "clothing", "accessory", "body", "pose"})
132
+
133
+ # Any-token tie-break order (only reached when the head noun missed the lexicon).
134
+ # Concrete/visible strata outrank colors and abstractions.
135
+ STRATUM_PRECEDENCE = ("hair", "face", "skin", "accessory", "clothing", "body",
136
+ "pose", "scene_level", "color", "abstract_quality")
137
+
138
+ # The full stratum vocabulary fuse.py may emit ("action" is assigned by fuse.py to
139
+ # caption `actions` entries directly — it has no lexicon and is never grounded).
140
+ ALL_STRATA = tuple(STRATA.keys()) + ("action",)
141
+
142
+
143
+ def _content_tokens(text: str) -> list:
144
+ return [t for t in _TOKEN_RE.findall((text or "").lower()) if t not in _STOP]
145
+
146
+
147
+ def classify_stratum(text: str) -> str:
148
+ """Deterministic stratum for an attribute string.
149
+
150
+ 1. head-noun rule: depluralized LAST content token, exact lexicon lookup
151
+ 2. suffix rules on the head token ("silver-haired" -> hair)
152
+ 3. any-token lookup in STRATUM_PRECEDENCE order
153
+ 4. all tokens are color/pattern terms -> color
154
+ 5. default -> abstract_quality (nothing is ever unclassified)
155
+ """
156
+ toks = _content_tokens(text)
157
+ if not toks:
158
+ return "abstract_quality"
159
+ # _depluralize is crude ("dress"->"dres") — always try the raw form too
160
+ head_forms = {toks[-1], _depluralize(toks[-1])}
161
+
162
+ for stratum, words in STRATA.items():
163
+ if head_forms & words:
164
+ return stratum
165
+ for suffix, stratum in SUFFIX_RULES:
166
+ if any(h.endswith(suffix) for h in head_forms):
167
+ return stratum
168
+ forms = [{t, _depluralize(t)} for t in toks]
169
+ for stratum in STRATUM_PRECEDENCE:
170
+ words = STRATA[stratum]
171
+ if any(f & words for f in forms):
172
+ return stratum
173
+ if all(f & STRATA["color"] for f in forms):
174
+ return "color"
175
+ # verb-phrase heuristic: leading gerund ("playing baseball", "taking a photo",
176
+ # "running") → pose. Caught live on COCO captions, where the structurer emits
177
+ # verb phrases as attributes that otherwise fell to abstract_quality.
178
+ first = toks[0]
179
+ if first.endswith("ing") and len(first) > 4 and first not in _NOUN_ING:
180
+ return "pose"
181
+ return "abstract_quality"
182
+
183
+
184
+ def is_groundable(stratum: str) -> bool:
185
+ return stratum in GROUNDABLE
qwen_test_runner/vision/stub_runner.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ stub_runner.py — A CPU, torch-free fake VLM for tests and dry runs.
3
+
4
+ `StubVLMRunner` produces deterministic outputs so the whole orchestrator —
5
+ scoring, durability, leaderboard, verdict — can be exercised with no GPU and no
6
+ torch import. Three behaviours:
7
+
8
+ perfect : schema-valid output that MATCHES the ground truth (high accuracy) and
9
+ is emitted as clean bare JSON (json_robust = True).
10
+ fragile : the SAME content, but wrapped in a ```json fence so it only parses
11
+ after repair (json_robust = False) — used to prove that the
12
+ labeler_score penalizes fragile JSON even at equal accuracy.
13
+ random : emits invalid / off output for a fraction of samples, to exercise the
14
+ reject sidecar and the schema_valid_rate path.
15
+
16
+ Note: "fragile" uses a PROSE wrapper (structural repair), not a markdown fence —
17
+ fence-stripping is benign/deterministic and no longer counts against robustness,
18
+ so a fenced output would (correctly) still score as robust.
19
+
20
+ It is a test double, so it is allowed to peek at the ground truth (a real runner
21
+ never does).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from typing import Optional
28
+
29
+ from .coords import XYWH, XYXY, CoordSpace, from_canonical, to_canonical
30
+ from .runner_types import VLMResult
31
+ from .tasks_vision import VisionTaskSpec, resolved_system_prompt
32
+
33
+
34
+ def _default_value(fs):
35
+ """A schema-valid placeholder for one field spec."""
36
+ if fs.cardinality == "list":
37
+ return [] # lists always validate (default_factory=list)
38
+ if fs.nested_fields:
39
+ return {f.name: _default_value(f) for f in fs.nested_fields}
40
+ if fs.vocabulary == "closed":
41
+ return fs.closed_values[0]
42
+ vk = fs.value_kind
43
+ if vk == "number":
44
+ return 0.0
45
+ if vk == "integer":
46
+ return 0
47
+ if vk == "bbox":
48
+ return [0.0, 0.0, 0.0, 0.0]
49
+ if vk == "point":
50
+ return [0.0, 0.0]
51
+ return "x"
52
+
53
+
54
+ def _synthesize_valid(spec: VisionTaskSpec) -> dict:
55
+ return {name: _default_value(fs) for name, fs in spec.fields.items()}
56
+
57
+
58
+ def _gt_to_prediction(spec: VisionTaskSpec, gt, image_size) -> Optional[dict]:
59
+ """Build a GT-matching, schema-valid prediction for the pilot categories.
60
+ Returns None if the category has no GT-driven construction (use synthesize)."""
61
+ cat = spec.category
62
+ if cat == "image_classification" and isinstance(gt, dict):
63
+ label = (gt.get("labels") or [gt.get("label", "x")])[0]
64
+ return {"label": label, "confidence": 0.95,
65
+ "top5": [{"label": label, "score": 0.95}]}
66
+ if cat == "bbox_grounding" and isinstance(gt, dict):
67
+ dets = []
68
+ for b in gt.get("boxes", []):
69
+ canon = to_canonical(b["bbox"], CoordSpace.PIXEL_ABS, image_size, fmt=b.get("fmt", XYWH))
70
+ box = from_canonical(canon, spec.coord_space, image_size, fmt=XYXY)
71
+ dets.append({"label": b.get("label", "x"), "box": [round(c, 2) for c in box], "score": 0.95})
72
+ return {"detections": dets, "count": len(dets)}
73
+ if cat == "ocr_text" and isinstance(gt, dict):
74
+ text = gt.get("text", "")
75
+ return {"full_text": text, "lines": [{"text": text}]}
76
+ return None
77
+
78
+
79
+ class StubVLMRunner:
80
+ """Drop-in fake runner. Matches the VLMRunner.generate signature."""
81
+
82
+ def __init__(self, model_id: str = "stub", behavior: str = "perfect",
83
+ reasoning: str = "instruct", **_kwargs):
84
+ self.model_id = model_id
85
+ self.behavior = behavior
86
+ self.reasoning = reasoning
87
+ self._n = 0
88
+
89
+ def close(self) -> None: # symmetry with VLMRunner
90
+ pass
91
+
92
+ def generate(self, spec: VisionTaskSpec, image, mode: str, *,
93
+ image_id: str = "", image_size=(64, 64), gt=None, user_prompt=None) -> VLMResult:
94
+ self._n += 1
95
+ # Build content
96
+ pred = _gt_to_prediction(spec, gt, image_size)
97
+ if pred is None:
98
+ pred = _synthesize_valid(spec)
99
+
100
+ if self.behavior == "random" and (self._n % 4 == 0):
101
+ raw = "I cannot answer that." # invalid → exercises reject sidecar
102
+ return VLMResult(mode, raw, "stub", 8, 6, 0.001, image_id, grammar_conformant=False)
103
+
104
+ body = json.dumps(pred)
105
+ if self.behavior == "fragile":
106
+ # prose wrapper = STRUCTURAL repair (not a benign fence) → not robust
107
+ raw = f"Sure! Here is the structured result you requested: {body} — hope that helps!"
108
+ else:
109
+ raw = body # clean bare JSON
110
+
111
+ # touch the resolved prompt so prompt wiring is exercised
112
+ _ = resolved_system_prompt(spec)
113
+ grammar = (mode == "constrained")
114
+ return VLMResult(mode, raw, "stub", 12, max(1, len(body) // 4), 0.001,
115
+ image_id, grammar_conformant=grammar)
qwen_test_runner/vision/tasks_vision.py ADDED
@@ -0,0 +1,530 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tasks_vision.py — The 15 vision-task categories, as data.
3
+
4
+ Each VisionTaskSpec owns a small per-category field registry (dict[str, SlotSpec])
5
+ and a system/user prompt. The Pydantic model, JSON Schema, GBNF grammar, and
6
+ Claude tool schema are generated from that registry by the SAME machinery the
7
+ caption schema uses (schema.build_*). Adding a category is one dict entry.
8
+
9
+ Three categories are PILOT (full schema + GT dataset + real metric); the other
10
+ twelve are STUB (valid minimal schema so their grammar builds, metric wired in
11
+ Phase 3). This mirrors how registry.py grows the caption schema.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Mapping
18
+
19
+ from ..registry import SlotSpec
20
+ from ..schema import build_gbnf_from_registry, build_json_schema, build_model_from_registry
21
+ from .coords import CoordSpace
22
+
23
+
24
+ # ──────────────────────────────────────────────────────────────────────────────
25
+ # Field-builder shorthand (keeps the registry readable)
26
+ # ──────────────────────────────────────────────────────────────────────────────
27
+
28
+ def _f(name, **kw) -> SlotSpec:
29
+ """A single-value open string field unless overridden."""
30
+ kw.setdefault("cardinality", "single")
31
+ kw.setdefault("vocabulary", "open")
32
+ return SlotSpec(name=name, **kw)
33
+
34
+
35
+ def _enum(name, values, optional=False) -> SlotSpec:
36
+ return SlotSpec(name=name, cardinality="single", vocabulary="closed",
37
+ closed_values=tuple(values), optional=optional)
38
+
39
+
40
+ def _list_of(name, *fields, max_items=32) -> SlotSpec:
41
+ return SlotSpec(name=name, cardinality="list", vocabulary="open",
42
+ nested_fields=tuple(fields), max_items=max_items)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class VisionTaskSpec:
47
+ category: str
48
+ probes: str
49
+ fields: Mapping[str, SlotSpec]
50
+ system_prompt: str
51
+ user_prompt: str
52
+ metric: str # key into metrics._SCORERS
53
+ status: str = "pilot" # "pilot" | "stub"
54
+ coord_space: CoordSpace = CoordSpace.NORM_0_1000
55
+ gt_dataset: str = "" # key into datasets.DATASET_REGISTRY
56
+ gt_split: str = ""
57
+ max_new_tokens: int = 512
58
+ license_note: str = ""
59
+ download_gb: float = 0.0
60
+ per_sample_prompt: bool = False # use GTSample.prompt as the user prompt (e.g. VQA question)
61
+
62
+
63
+ # Generated-artifact caches (keyed by category — VisionTaskSpec holds a dict so
64
+ # it isn't hashable; categories are unique).
65
+ _MODEL_CACHE: dict[str, type] = {}
66
+ _GBNF_CACHE: dict[str, str] = {}
67
+
68
+
69
+ def model_for(spec: VisionTaskSpec):
70
+ if spec.category not in _MODEL_CACHE:
71
+ _MODEL_CACHE[spec.category] = build_model_from_registry(
72
+ "Vision_" + spec.category.title().replace("_", ""), dict(spec.fields)
73
+ )
74
+ return _MODEL_CACHE[spec.category]
75
+
76
+
77
+ def json_schema_for(spec: VisionTaskSpec) -> dict:
78
+ return build_json_schema(model_for(spec))
79
+
80
+
81
+ def gbnf_for(spec: VisionTaskSpec) -> str:
82
+ if spec.category not in _GBNF_CACHE:
83
+ _GBNF_CACHE[spec.category] = build_gbnf_from_registry(dict(spec.fields))
84
+ return _GBNF_CACHE[spec.category]
85
+
86
+
87
+ def tool_schema_for(spec: VisionTaskSpec) -> dict:
88
+ """Claude-style tool input_schema (the per-category JSON Schema)."""
89
+ return json_schema_for(spec)
90
+
91
+
92
+ # ──────────────────────────────────────────────────────────────────────────────
93
+ # PILOT categories (full)
94
+ # ──────────────────────────────────────────────────────────────────────────────
95
+
96
+ _CLASSIFICATION = VisionTaskSpec(
97
+ category="image_classification",
98
+ probes="native ViT classification emitted as JSON",
99
+ fields={
100
+ "label": _f("label", optional=False, max_str_length=64),
101
+ "confidence": _f("confidence", value_kind="number", optional=False, number_range=(0.0, 1.0)),
102
+ "top5": _list_of(
103
+ "top5",
104
+ _f("label", optional=False, max_str_length=64),
105
+ _f("score", value_kind="number", optional=False, number_range=(0.0, 1.0)),
106
+ max_items=5,
107
+ ),
108
+ },
109
+ system_prompt=(
110
+ "You are an image classifier. Identify the single most prominent object or scene "
111
+ "category in the image. Output ONLY a raw JSON object and NOTHING else — no prose, "
112
+ "no explanation, and NO markdown code fences (do not wrap it in ```). "
113
+ "It must match this shape exactly:\n"
114
+ '{"label": "<string>", "confidence": <number 0..1>, '
115
+ '"top5": [{"label": "<string>", "score": <number 0..1>}]}'
116
+ ),
117
+ user_prompt="Classify this image. Output only the raw JSON object.",
118
+ metric="classification",
119
+ gt_dataset="imagenet_val",
120
+ gt_split="validation",
121
+ max_new_tokens=160,
122
+ license_note="ImageNet: non-commercial research use.",
123
+ )
124
+
125
+ _BBOX = VisionTaskSpec(
126
+ category="bbox_grounding",
127
+ probes="object localization + grounded counting",
128
+ fields={
129
+ "detections": _list_of(
130
+ "detections",
131
+ _f("label", optional=False, max_str_length=64),
132
+ _f("box", value_kind="bbox", optional=False),
133
+ _f("score", value_kind="number", optional=False, number_range=(0.0, 1.0)),
134
+ max_items=32,
135
+ ),
136
+ "count": _f("count", value_kind="integer", optional=False),
137
+ },
138
+ system_prompt=(
139
+ "You are an object detector. Find every distinct object in the image. Output ONLY a "
140
+ "raw JSON object and NOTHING else — no prose, no markdown code fences (do not wrap it "
141
+ "in ```). It must match this shape exactly:\n"
142
+ '{"detections": [{"label": "<string>", "box": [x1, y1, x2, y2], "score": <number 0..1>}], '
143
+ '"count": <integer>}\n'
144
+ "{coord_hint} Use the key \"box\" (NOT bbox_2d) with exactly four numbers [x1, y1, x2, y2]."
145
+ ),
146
+ user_prompt="Detect all objects in this image. Output only the raw JSON object.",
147
+ metric="detection",
148
+ coord_space=CoordSpace.NORM_0_1000,
149
+ gt_dataset="coco_detection",
150
+ gt_split="val",
151
+ max_new_tokens=768,
152
+ license_note="COCO: CC-BY 4.0 (images vary).",
153
+ )
154
+
155
+ _OCR = VisionTaskSpec(
156
+ category="ocr_text",
157
+ probes="text reading + transcription fidelity + localization",
158
+ fields={
159
+ "full_text": _f("full_text", optional=False, max_str_length=4096),
160
+ "lines": _list_of(
161
+ "lines",
162
+ _f("text", optional=False, max_str_length=512),
163
+ _f("box", value_kind="bbox", optional=True),
164
+ max_items=64,
165
+ ),
166
+ },
167
+ system_prompt=(
168
+ "You are an OCR engine. Transcribe all readable text in the image. Output ONLY a raw "
169
+ "JSON object and NOTHING else — no prose, no markdown code fences (do not wrap it in "
170
+ "```). It must match this shape exactly:\n"
171
+ '{"full_text": "<all text, joined by spaces>", '
172
+ '"lines": [{"text": "<string>", "box": [x1, y1, x2, y2]}]}\n'
173
+ "{coord_hint} If you cannot localize a line, omit its box."
174
+ ),
175
+ user_prompt="Read all the text in this image. Output only the raw JSON object.",
176
+ metric="ocr",
177
+ coord_space=CoordSpace.NORM_0_1000,
178
+ gt_dataset="textvqa",
179
+ gt_split="validation",
180
+ max_new_tokens=512,
181
+ license_note="TextVQA: CC-BY 4.0.",
182
+ )
183
+
184
+
185
+ # ──────────────────────────────────────────────────────────────────────────────
186
+ # STUB categories (minimal valid schema; metric + GT wired in Phase 3)
187
+ # ──────────────────────────────────────────────────────────────────────────────
188
+
189
+ def _stub(category, probes, fields, prompt, **kw) -> VisionTaskSpec:
190
+ kw.setdefault("metric", "schema_only")
191
+ kw.setdefault("status", "stub")
192
+ kw.setdefault("user_prompt", "Analyze this image.")
193
+ return VisionTaskSpec(category=category, probes=probes, fields=fields,
194
+ system_prompt=prompt, **kw)
195
+
196
+
197
+ _SPATIAL_PREDS = ("left_of", "right_of", "above", "below", "on", "under",
198
+ "inside", "behind", "in_front_of")
199
+
200
+ _STUBS = []
201
+
202
+ _DATATYPE_VALUES = ("json", "yaml", "markdown", "csv", "toml", "xml", "code", "plaintext")
203
+
204
+ _DATATYPE_DIFF = VisionTaskSpec(
205
+ category="data_type_differentiation",
206
+ probes="recognize a rendered data format from a screenshot",
207
+ fields={
208
+ "data_type": _enum("data_type", _DATATYPE_VALUES),
209
+ "confidence": _f("confidence", value_kind="number", optional=False, number_range=(0.0, 1.0)),
210
+ },
211
+ system_prompt=(
212
+ "You are shown a screenshot of structured data. Identify which serialization format "
213
+ "it is. Output ONLY a raw JSON object, no markdown fences:\n"
214
+ '{"data_type": "<one of: json, yaml, markdown, csv, toml, xml, code, plaintext>", '
215
+ '"confidence": <number 0..1>}'
216
+ ),
217
+ user_prompt="What data format is shown? Output only the raw JSON object.",
218
+ metric="datatype_diff",
219
+ gt_dataset="datatype_synth",
220
+ max_new_tokens=96,
221
+ license_note="synthetic (self-contained).",
222
+ )
223
+
224
+ _SPATIAL = VisionTaskSpec(
225
+ category="structural_spatial_awareness",
226
+ probes="spatial relations between objects",
227
+ fields={"relations": _list_of(
228
+ "relations",
229
+ _f("subject", optional=False),
230
+ _enum("predicate", _SPATIAL_PREDS),
231
+ _f("object", optional=False), max_items=12)},
232
+ system_prompt=(
233
+ "Describe the spatial relations between the colored shapes. Subjects and objects are "
234
+ "the colors (red, green, blue). Output ONLY raw JSON, no fences:\n"
235
+ '{"relations": [{"subject": "<color>", "predicate": '
236
+ '"<left_of|right_of|above|below>", "object": "<color>"}]}'
237
+ ),
238
+ user_prompt="List the spatial relations between the colored shapes. Raw JSON only.",
239
+ metric="triples", gt_dataset="shapes_synth", max_new_tokens=256,
240
+ license_note="synthetic (self-contained).",
241
+ )
242
+
243
+ _DEPTH = VisionTaskSpec(
244
+ category="depth_analysis",
245
+ probes="relative depth ordering",
246
+ fields={
247
+ "nearest": _f("nearest"),
248
+ "farthest": _f("farthest"),
249
+ "relative_depth": _list_of(
250
+ "relative_depth",
251
+ _f("a", optional=False),
252
+ _f("b", optional=False),
253
+ _enum("a_is", ("nearer", "farther", "same")), max_items=12),
254
+ },
255
+ system_prompt=(
256
+ "Judge relative depth of the colored shapes: a LARGER shape appears NEARER. Output ONLY "
257
+ "raw JSON, no fences:\n{\"nearest\": \"<color>\", \"farthest\": \"<color>\", "
258
+ '"relative_depth": [{"a": "<color>", "b": "<color>", "a_is": "<nearer|farther|same>"}]}'
259
+ ),
260
+ user_prompt="Report the relative depth of the colored shapes. Raw JSON only.",
261
+ metric="depth_order", gt_dataset="shapes_synth", max_new_tokens=256,
262
+ license_note="synthetic (self-contained).",
263
+ )
264
+
265
+ _SUBJECT = VisionTaskSpec(
266
+ category="subject_fixation",
267
+ probes="primary salient subject",
268
+ fields={"primary_subject": SlotSpec(
269
+ name="primary_subject", cardinality="single", vocabulary="open", optional=False,
270
+ nested_fields=(_f("label", optional=False), _f("box", value_kind="bbox", optional=False)))},
271
+ system_prompt=(
272
+ "Identify the single most prominent (largest) shape — its color and bounding box. "
273
+ "Output ONLY raw JSON, no fences:\n"
274
+ '{"primary_subject": {"label": "<color>", "box": [x1, y1, x2, y2]}}\n{coord_hint}'
275
+ ),
276
+ user_prompt="Identify the primary subject and its box. Raw JSON only.",
277
+ metric="subject_fixation", gt_dataset="shapes_synth", coord_space=CoordSpace.NORM_0_1000,
278
+ max_new_tokens=128, license_note="synthetic (self-contained).",
279
+ )
280
+
281
+ _DATATYPE_UTIL = VisionTaskSpec(
282
+ category="data_type_utilization",
283
+ probes="parse a rendered data format into normalized JSON",
284
+ fields={
285
+ "data_type": _enum("data_type", _DATATYPE_VALUES),
286
+ "content": _f("content", optional=False, max_str_length=2048),
287
+ },
288
+ system_prompt=(
289
+ "You are shown a screenshot of structured data. Read it and re-serialize its contents "
290
+ "as JSON. Output ONLY a raw JSON object, no markdown fences:\n"
291
+ '{"data_type": "<the format>", "content": "<the data as a JSON string, e.g. '
292
+ '{\\"name\\": \\"Alice\\"}>"}'
293
+ ),
294
+ user_prompt="Read the data and output {data_type, content} as raw JSON.",
295
+ metric="datatype_util",
296
+ gt_dataset="datatype_synth",
297
+ max_new_tokens=512,
298
+ license_note="synthetic (self-contained).",
299
+ )
300
+
301
+
302
+ # ──────────────────────────────────────────────────────────────────────────────
303
+ # THE REGISTRY
304
+ # ──────────────────────────────────────────────────────────────────────────────
305
+
306
+ _SEGMENTATION = VisionTaskSpec(
307
+ category="segmentation",
308
+ probes="instance segmentation as labeled polygons",
309
+ fields={
310
+ "masks": _list_of(
311
+ "masks",
312
+ _f("label", optional=False, max_str_length=64),
313
+ SlotSpec(name="polygon", cardinality="list", vocabulary="open",
314
+ value_kind="number", max_items=512, optional=False),
315
+ max_items=32,
316
+ ),
317
+ },
318
+ system_prompt=(
319
+ "You are an instance segmenter. Trace the outline of every distinct object "
320
+ "as a closed polygon. Output ONLY a raw JSON object and NOTHING else — no prose, "
321
+ "no markdown code fences (do not wrap it in ```). It must match this shape exactly:\n"
322
+ '{"masks": [{"label": "<string>", "polygon": [x1, y1, x2, y2, x3, y3, ...]}]}\n'
323
+ "All x, y values are integers in 0..1000 relative to the image width and height. "
324
+ "Each polygon is a FLAT list of alternating x, y vertices — a closed shape with at "
325
+ "least 3 points / 6 numbers tracing the object boundary in order. This is a POLYGON, "
326
+ "NOT a 4-number bounding box."
327
+ ),
328
+ user_prompt="Segment every object in this image as a labeled polygon. Output only the raw JSON object.",
329
+ metric="segmentation",
330
+ coord_space=CoordSpace.NORM_0_1000,
331
+ gt_dataset="segmentation_synth",
332
+ max_new_tokens=768,
333
+ license_note="synthetic (self-contained).",
334
+ )
335
+
336
+ _OUTLINE = VisionTaskSpec(
337
+ category="outline_association",
338
+ probes="trace the main (largest) object's outline polygon + label it",
339
+ fields={
340
+ "outline": SlotSpec(name="outline", cardinality="list", vocabulary="open",
341
+ value_kind="number", max_items=256, optional=False),
342
+ "label": _f("label", optional=False, max_str_length=64),
343
+ },
344
+ system_prompt=(
345
+ "You are an outline tracer. Find the SINGLE largest (most prominent) object in the "
346
+ "image and trace its outline as a closed polygon. Output ONLY a raw JSON object and "
347
+ "NOTHING else - no prose, no markdown code fences (do not wrap it in ```). It must "
348
+ "match this shape exactly:\n"
349
+ '{"outline": [x1, y1, x2, y2, x3, y3, ...], "label": "<string>"}\n'
350
+ "The outline is a flat list of alternating x, y vertex coordinates (at least 3 "
351
+ "vertices = 6 numbers), tracing the object boundary in order. All x, y values are "
352
+ "integers in 0..1000 relative to the image width and height. This is a POLYGON with "
353
+ "MANY points, NOT a 4-number bounding box."
354
+ ),
355
+ user_prompt="Trace the main object's outline and label it. Output only the raw JSON object.",
356
+ metric="outline_iou",
357
+ status="pilot",
358
+ coord_space=CoordSpace.NORM_0_1000,
359
+ gt_dataset="outline_synth",
360
+ max_new_tokens=640,
361
+ license_note="synthetic (self-contained).",
362
+ )
363
+
364
+ _GEO3D = VisionTaskSpec(
365
+ category="geometric_3d_object_id",
366
+ probes="3D object identification with 3D boxes (simplified ground-plane proxy)",
367
+ fields={
368
+ "objects": _list_of(
369
+ "objects",
370
+ _f("class", optional=False, max_str_length=64),
371
+ SlotSpec(name="bbox3d", cardinality="list", vocabulary="open",
372
+ value_kind="number", max_items=7, optional=False),
373
+ _f("score", value_kind="number", optional=True, number_range=(0.0, 1.0)),
374
+ max_items=16,
375
+ ),
376
+ },
377
+ system_prompt=(
378
+ "You are a 3D object detector looking at a scene of colored boxes resting on a "
379
+ "ground plane. For each box report its class (its color) and a 3D bounding box. "
380
+ "Output ONLY a raw JSON object and NOTHING else - no prose, no markdown code "
381
+ "fences (do not wrap it in ```). It must match this shape exactly:\n"
382
+ '{"objects": [{"class": "<color>", "bbox3d": [x, y, z, w, h, l, yaw], '
383
+ '"score": <number 0..1>}]}\n'
384
+ "All coordinates are normalized to 0..1 of the scene: x is the left-right ground "
385
+ "position, z is the depth (0=near, 1=far), y is the height off the ground (0 on the "
386
+ "floor); w, h, l are the box width, height and length; yaw is the rotation in "
387
+ 'radians. Use the key "bbox3d" with exactly seven numbers [x, y, z, w, h, l, yaw].'
388
+ ),
389
+ user_prompt="Identify the 3D boxes in this scene. Output only the raw JSON object.",
390
+ metric="iou3d",
391
+ status="pilot",
392
+ coord_space=CoordSpace.NORM_0_1,
393
+ gt_dataset="boxes3d_synth",
394
+ max_new_tokens=384,
395
+ license_note="synthetic (self-contained); simplified ground-plane 3D proxy.",
396
+ )
397
+
398
+ _CAMERA_ROT = VisionTaskSpec(
399
+ category="camera_rotational_offset",
400
+ probes="camera pose / rotation estimation from a 2D orientation cue",
401
+ fields={
402
+ "rotation": SlotSpec(name="rotation", cardinality="list", vocabulary="open",
403
+ value_kind="number", max_items=3, optional=False),
404
+ },
405
+ system_prompt=(
406
+ "You estimate the camera's rotation relative to the scene. Output the three "
407
+ "Euler angles in DEGREES as [yaw, pitch, roll]. Output ONLY a raw JSON object and "
408
+ "NOTHING else — no prose, no explanation, and NO markdown code fences (do not wrap "
409
+ "it in ```). It must match this shape exactly:\n"
410
+ '{\"rotation\": [<yaw>, <pitch>, <roll>]}\n'
411
+ "Each angle is a number in degrees in the range -180..180. If an axis is not "
412
+ "discernible, report 0."
413
+ ),
414
+ user_prompt="Estimate the camera rotation [yaw, pitch, roll] in degrees. Output only the raw JSON object.",
415
+ metric="angular_error",
416
+ status="pilot",
417
+ gt_dataset="camera_rot_synth",
418
+ max_new_tokens=64,
419
+ license_note="synthetic (self-contained).",
420
+ )
421
+
422
+ _VQA = VisionTaskSpec(
423
+ category="vit_accuracy_to_prompt",
424
+ probes="grounded visual question answering",
425
+ fields={
426
+ "answer": _f("answer", optional=False, max_str_length=512),
427
+ "grounded_region": _f("grounded_region", value_kind="bbox", optional=True),
428
+ },
429
+ system_prompt=(
430
+ "You are a visual question answering engine. Answer the user's question about "
431
+ "the image as briefly as possible (a single word or short phrase). Optionally "
432
+ "ground your answer with the bounding box of the region you used. Output ONLY a "
433
+ "raw JSON object and NOTHING else — no prose, no explanation, and NO markdown "
434
+ "code fences (do not wrap it in ```). It must match this shape exactly:\n"
435
+ '{"answer": "<short answer>", "grounded_region": [x1, y1, x2, y2]}\n'
436
+ "{coord_hint} If you cannot or need not localize, omit grounded_region entirely."
437
+ ),
438
+ user_prompt="Answer the question about this image. Output only the raw JSON object.",
439
+ metric="vqa",
440
+ per_sample_prompt=True,
441
+ coord_space=CoordSpace.NORM_0_1000,
442
+ gt_dataset="gqa",
443
+ gt_split="validation",
444
+ max_new_tokens=128,
445
+ license_note="GQA / VQAv2: research use; images CC-BY (vary).",
446
+ )
447
+
448
+ _SEMANTIC = VisionTaskSpec(
449
+ category="semantic_association",
450
+ probes="semantic associations between entities as (a, relation, b) triples",
451
+ fields={
452
+ "associations": _list_of(
453
+ "associations",
454
+ _f("a", optional=False, max_str_length=64),
455
+ _enum("relation", ("left_of", "right_of", "near", "is_a", "related_to")),
456
+ _f("b", optional=False, max_str_length=64),
457
+ max_items=32,
458
+ ),
459
+ },
460
+ system_prompt=(
461
+ "You relate the entities in the image to each other as semantic association "
462
+ "triples. Each association links entity \"a\" to entity \"b\" by a relation. "
463
+ "For the colored shapes, the entities are the colors (red, green, blue) and "
464
+ "the shape type (circle). Allowed relations: left_of, right_of, near, is_a, "
465
+ "related_to. Output ONLY a raw JSON object and NOTHING else - no prose, no "
466
+ "explanation, and NO markdown code fences (do not wrap it in ```). It must "
467
+ "match this shape exactly:\n"
468
+ '{"associations": [{"a": "<entity>", "relation": '
469
+ '"<left_of|right_of|near|is_a|related_to>", "b": "<entity>"}]}'
470
+ ),
471
+ user_prompt="List the semantic associations between the entities. Output only the raw JSON object.",
472
+ metric="triples",
473
+ gt_dataset="semantic_synth",
474
+ max_new_tokens=384,
475
+ license_note="synthetic (self-contained).",
476
+ )
477
+
478
+ _STYLE = VisionTaskSpec(
479
+ category="style_structural_awareness",
480
+ probes="visual style + structural layout/symmetry, as a coarse closed-vocab triple",
481
+ fields={
482
+ "style": _enum("style", ("photo", "painting", "3d_render", "sketch", "anime", "other")),
483
+ "layout": _enum("layout", ("centered", "rule_of_thirds", "symmetric", "scattered", "unknown")),
484
+ "symmetry": _enum("symmetry", ("horizontal", "vertical", "radial", "none")),
485
+ },
486
+ system_prompt=(
487
+ "You judge the VISUAL STYLE and STRUCTURE of an image. Pick exactly one value "
488
+ "from each closed vocabulary. Output ONLY a raw JSON object and NOTHING else — no "
489
+ "prose, no explanation, and NO markdown code fences (do not wrap it in ```). "
490
+ "It must match this shape exactly:\n"
491
+ '{"style": "<one of: photo, painting, 3d_render, sketch, anime, other>", '
492
+ '"layout": "<one of: centered, rule_of_thirds, symmetric, scattered, unknown>", '
493
+ '"symmetry": "<one of: horizontal, vertical, radial, none>"}'
494
+ ),
495
+ user_prompt="Classify the visual style and structure. Output only the raw JSON object.",
496
+ metric="style",
497
+ status="pilot",
498
+ gt_dataset="style_synth",
499
+ max_new_tokens=96,
500
+ license_note="synthetic (self-contained).",
501
+ )
502
+
503
+
504
+ VISION_TASK_REGISTRY: dict[str, VisionTaskSpec] = {
505
+ t.category: t for t in [_CLASSIFICATION, _BBOX, _OCR, _DATATYPE_DIFF, _DATATYPE_UTIL,
506
+ _SPATIAL, _DEPTH, _SUBJECT,
507
+ _SEGMENTATION, _OUTLINE, _GEO3D, _CAMERA_ROT, _VQA, _SEMANTIC, _STYLE]
508
+ }
509
+
510
+
511
+ def get_task(category: str) -> VisionTaskSpec:
512
+ if category not in VISION_TASK_REGISTRY:
513
+ raise KeyError(f"unknown vision category: {category!r}. known: {list(VISION_TASK_REGISTRY)}")
514
+ return VISION_TASK_REGISTRY[category]
515
+
516
+
517
+ def category_names() -> list[str]:
518
+ return list(VISION_TASK_REGISTRY.keys())
519
+
520
+
521
+ def pilot_categories() -> list[str]:
522
+ return [c for c, t in VISION_TASK_REGISTRY.items() if t.status == "pilot"]
523
+
524
+
525
+ def resolved_system_prompt(spec: VisionTaskSpec) -> str:
526
+ """Fill the {coord_hint} placeholder using the task's coord_space."""
527
+ if "{coord_hint}" in spec.system_prompt:
528
+ from .coords import prompt_hint_for
529
+ return spec.system_prompt.replace("{coord_hint}", prompt_hint_for(spec.coord_space))
530
+ return spec.system_prompt
qwen_test_runner/vision/throughput.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ throughput.py — Pure GPU-hours / cost model (CPU-testable, no torch).
3
+
4
+ The labeler verdict trades accuracy against throughput: a model that is 94% as
5
+ good but 3× faster is the better choice for labeling a million images. This
6
+ module converts measured decode speed into samples/hour and GPU-hours/$ per
7
+ million labels.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass
16
+ class ThroughputEstimate:
17
+ model: str
18
+ tokens_per_sec: float
19
+ mean_output_tokens: float
20
+ samples_per_hour: float
21
+ gpu_hours_per_million: float
22
+ est_cost_per_million_usd: float
23
+
24
+
25
+ def estimate(model: str, tokens_per_sec: float, mean_output_tokens: float,
26
+ prefill_overhead_s: float = 0.0, gpu_hourly_rate: float = 2.0) -> ThroughputEstimate:
27
+ """samples_per_hour = 3600 / (prefill + output_tokens / tok_per_sec).
28
+
29
+ `prefill_overhead_s` is the per-sample vision-encoder + image-token cost
30
+ (measured during the run, not guessed). `gpu_hourly_rate` is a config rate
31
+ printed alongside the result so the dollar figure is transparent.
32
+ """
33
+ if tokens_per_sec <= 0 or mean_output_tokens <= 0:
34
+ return ThroughputEstimate(model, tokens_per_sec, mean_output_tokens, 0.0, float("inf"), float("inf"))
35
+ per_sample_s = prefill_overhead_s + mean_output_tokens / tokens_per_sec
36
+ samples_per_hour = 3600.0 / per_sample_s
37
+ gpu_hours_per_million = 1_000_000.0 / samples_per_hour
38
+ cost = gpu_hours_per_million * gpu_hourly_rate
39
+ return ThroughputEstimate(
40
+ model=model, tokens_per_sec=tokens_per_sec, mean_output_tokens=mean_output_tokens,
41
+ samples_per_hour=samples_per_hour, gpu_hours_per_million=gpu_hours_per_million,
42
+ est_cost_per_million_usd=cost,
43
+ )
44
+
45
+
46
+ def fleet_score(labeler: float, samples_per_hour: float, saturate_at: float = 50_000.0) -> float:
47
+ """Fold throughput into the labeler score for the 'label 1M images' goal.
48
+
49
+ Throughput weight saturates (diminishing returns past `saturate_at`), so a
50
+ tiny-but-inaccurate model can't win on speed alone.
51
+ """
52
+ if labeler is None:
53
+ return 0.0
54
+ import math
55
+ w = math.log1p(max(0.0, samples_per_hour)) / math.log1p(saturate_at)
56
+ return labeler * min(1.0, w)
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ZeroGPU provides torch (2.8+) and CUDA at runtime — do NOT pin or reinstall
2
+ # torch here (a mismatched pin fights the platform image).
3
+ #
4
+ # transformers is pinned to a RELEASED wheel, NOT the package's
5
+ # `transformers @ git+…@main` pin (that pin is the Space-build landmine).
6
+ # qwen3_5 ships in released transformers now (the base model has 12M+ downloads
7
+ # and WebGPU spaces); the Colab path already runs on transformers>=4.50.
8
+
9
+ spaces
10
+ gradio>=4.44
11
+ transformers>=4.50
12
+ accelerate>=1.0
13
+ easyocr
14
+ pydantic>=2.0
15
+ pillow
16
+ numpy
17
+ huggingface_hub>=0.25
18
+ datasets>=2.20
19
+ safetensors