Spaces:
Building on Zero
Building on Zero
Deploy: 12-task vision extraction + fusion ZeroGPU showcase
Browse files- README.md +66 -6
- app.py +669 -0
- examples/json_text_card.png +0 -0
- examples/shapes_scene.png +0 -0
- face_age_filter.py +312 -0
- qwen_test_runner/__init__.py +89 -0
- qwen_test_runner/data_gen.py +199 -0
- qwen_test_runner/eval_set.py +103 -0
- qwen_test_runner/evaluator.py +427 -0
- qwen_test_runner/model_runner.py +361 -0
- qwen_test_runner/providers/__init__.py +35 -0
- qwen_test_runner/providers/claude_api.py +437 -0
- qwen_test_runner/py.typed +0 -0
- qwen_test_runner/registry.py +210 -0
- qwen_test_runner/run_benchmark.py +223 -0
- qwen_test_runner/schema.py +423 -0
- qwen_test_runner/tasks.py +461 -0
- qwen_test_runner/vision/__init__.py +62 -0
- qwen_test_runner/vision/bench.py +197 -0
- qwen_test_runner/vision/configs/dataset_gen.yaml +68 -0
- qwen_test_runner/vision/coords.py +161 -0
- qwen_test_runner/vision/datasets.py +928 -0
- qwen_test_runner/vision/derive.py +363 -0
- qwen_test_runner/vision/fuse.py +758 -0
- qwen_test_runner/vision/fuse_prompt.py +153 -0
- qwen_test_runner/vision/fuse_schema.py +228 -0
- qwen_test_runner/vision/fusion_metrics.py +523 -0
- qwen_test_runner/vision/metrics.py +997 -0
- qwen_test_runner/vision/model_registry.py +275 -0
- qwen_test_runner/vision/report.py +179 -0
- qwen_test_runner/vision/run_vlmbench.py +68 -0
- qwen_test_runner/vision/runner_types.py +23 -0
- qwen_test_runner/vision/runners.py +301 -0
- qwen_test_runner/vision/specialists.py +181 -0
- qwen_test_runner/vision/specialists_gpu.py +768 -0
- qwen_test_runner/vision/strata.py +185 -0
- qwen_test_runner/vision/stub_runner.py +115 -0
- qwen_test_runner/vision/tasks_vision.py +530 -0
- qwen_test_runner/vision/throughput.py +56 -0
- requirements.txt +19 -0
README.md
CHANGED
|
@@ -1,13 +1,73 @@
|
|
| 1 |
---
|
| 2 |
-
title: Qwen
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|