File size: 10,771 Bytes
4641048
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "typer>=0.12",
#     "loguru>=0.7.2",
#     "transformers>=5.15",
#     "torch>=2.10",
#     "torchvision>=0.25",
#     "accelerate>=1.0",
#     "peft>=0.19.1",
#     "pillow>=12.2",
# ]
# ///
"""sVLM-Council β€” run the same image+prompt through small local VLMs.

All members run locally via transformers (no Spaces, no Inference API) through
one generic `query()` function; per-model quirks live in the COUNCIL registry.
Works for data extraction or vanilla VQA alike.

Council members (all in-tree transformers, dense, MPS-friendly):
- qianfan-ocr     -> baidu/Qianfan-OCR                  (4.7B, KIE leader)
- hunyuan-ocr     -> tencent/HunyuanOCR                 (1.1B, scene text + IE)
- granite-vision  -> ibm-granite/granite-vision-4.1-4b  (4B, key-value extraction)
- qwen3-vl-8b     -> Qwen/Qwen3-VL-8B-Instruct          (8.8B, best sub-10B OCRBench)

Models are loaded one at a time (single-slot cache with eviction): the four
together exceed 32GB unified memory, and first use downloads each checkpoint.

Usage:
    uv run svlm_council.py bib.jpg "What is the bib number in this image?"
    uv run svlm_council.py bib.jpg "Describe this image." -m hunyuan-ocr -t 0.7
    uv run svlm_council.py bib.jpg "..." -s "You are a terse assistant." --json -o out.json

As a library (e.g. from extract_arena.py):
    import svlm_council
    text = svlm_council.query("qianfan-ocr", "bib.jpg", "What is the bib number?")

Results go to stdout; logs go to stderr (loguru default).
"""
from __future__ import annotations

import gc
import json
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Optional

import typer
from loguru import logger

# ----------------------------------------------------------------- registry


@dataclass(frozen=True)
class CouncilSpec:
    name: str
    model_id: str
    dtype: str = "auto"  # "auto" or a torch dtype name like "bfloat16"
    device_map: str = "mps"  # accelerate's "auto" offloads to disk far too eagerly on Macs
    attn_implementation: str = "sdpa"  # none of the members needs flash-attn
    processor_kwargs: dict = field(default_factory=dict)
    generate_kwargs: dict = field(default_factory=dict)


COUNCIL: dict[str, CouncilSpec] = {
    "qianfan-ocr": CouncilSpec(
        "qianfan-ocr",
        "baidu/Qianfan-OCR",
        dtype="bfloat16",
    ),
    "hunyuan-ocr": CouncilSpec(
        "hunyuan-ocr",
        "tencent/HunyuanOCR",
        dtype="bfloat16",
        attn_implementation="eager",  # recommended by the transformers doc for the OCR path
        processor_kwargs={"backend": "pil"},
        generate_kwargs={"repetition_penalty": 1.08},  # model card's recommended setting
    ),
    "granite-vision": CouncilSpec(
        "granite-vision",
        "ibm-granite/granite-vision-4.1-4b",
        dtype="bfloat16",
    ),
    "qwen3-vl-8b": CouncilSpec(
        "qwen3-vl-8b",
        "Qwen/Qwen3-VL-8B-Instruct",
    ),
}


def _resolve_spec(model: str) -> CouncilSpec:
    # Any HF repo id also works: unknown names get default handling, so trying
    # a new model is just a name change.
    return COUNCIL.get(model) or CouncilSpec(name=model, model_id=model)


# ------------------------------------------------- single-slot model cache
# The council members total ~37GB bf16 β€” more than this host's unified memory β€”
# so only one model stays resident; switching models evicts the previous one.

_LOADED: dict[str, object] = {}


def _load(spec: CouncilSpec):
    import torch
    from transformers import AutoModelForImageTextToText, AutoProcessor

    if _LOADED.get("name") == spec.name:
        return _LOADED["processor"], _LOADED["model"]
    if _LOADED:
        logger.info("Evicting {} to free memory", _LOADED["name"])
        _LOADED.clear()
        gc.collect()
        if torch.backends.mps.is_available():
            torch.mps.empty_cache()

    logger.info("Loading {} ({}) β€” first use downloads the weights", spec.name, spec.model_id)
    processor = AutoProcessor.from_pretrained(spec.model_id, **spec.processor_kwargs)
    device_map = spec.device_map if torch.backends.mps.is_available() else "auto"
    model = AutoModelForImageTextToText.from_pretrained(
        spec.model_id,
        dtype=spec.dtype if spec.dtype == "auto" else getattr(torch, spec.dtype),
        device_map=device_map,
        attn_implementation=spec.attn_implementation,
    ).eval()
    _LOADED.update(name=spec.name, processor=processor, model=model)
    return processor, model


# ----------------------------------------------------------------- core API


def query(
    model: str,
    image_path: Path | str,
    prompt: str,
    *,
    system_prompt: str | None = None,
    temperature: float = 0.0,
    max_tokens: int = 1024,
) -> str:
    """Ask one council member (or any HF repo id) a question about an image.

    Generic over tasks: works for targeted data extraction and vanilla VQA.
    Returns the model's text reply; raises on failure (callers isolate errors).
    """
    import torch

    spec = _resolve_spec(model)
    processor, vlm = _load(spec)

    messages = []
    if system_prompt:
        messages.append({"role": "system", "content": [{"type": "text", "text": system_prompt}]})
    messages.append(
        {
            "role": "user",
            "content": [
                {"type": "image", "url": str(image_path)},
                {"type": "text", "text": prompt},
            ],
        }
    )
    inputs = processor.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=True,
        return_dict=True,
        return_tensors="pt",
    ).to(vlm.device)
    inputs.pop("token_type_ids", None)  # required for Qwen3-VL, harmless for the rest

    gen_kwargs: dict = {
        "max_new_tokens": max_tokens,
        "use_cache": True,  # Qianfan-OCR's config ships use_cache: false
        **spec.generate_kwargs,
    }
    if temperature > 0:
        gen_kwargs.update(do_sample=True, temperature=temperature)
    else:
        # Explicit Nones neutralize sampling defaults baked into some
        # generation_configs (Qwen3-VL ships do_sample=true, temperature=0.7).
        gen_kwargs.update(do_sample=False, temperature=None, top_p=None, top_k=None)

    with torch.inference_mode():
        generated = vlm.generate(**inputs, **gen_kwargs)
    return processor.batch_decode(
        generated[:, inputs["input_ids"].shape[1] :],
        skip_special_tokens=True,
        clean_up_tokenization_spaces=False,  # preserve whitespace fidelity in OCR-ish output
    )[0].strip()


# ------------------------------------------------------------- orchestration


@dataclass
class CouncilResult:
    model: str
    text: str = ""
    latency_s: float = 0.0
    error: str | None = None


def run_council(
    image_path: Path,
    prompt: str,
    model_names: list[str],
    *,
    system_prompt: str | None = None,
    temperature: float = 0.0,
    max_tokens: int = 1024,
) -> list[CouncilResult]:
    results = []
    for name in model_names:
        logger.info("[{}] prompt: {!r}", name, prompt)
        t0 = time.perf_counter()
        try:
            text = query(
                name,
                image_path,
                prompt,
                system_prompt=system_prompt,
                temperature=temperature,
                max_tokens=max_tokens,
            )
            result = CouncilResult(name, text=text, latency_s=round(time.perf_counter() - t0, 2))
            logger.info("[{}] done in {}s", name, result.latency_s)
        except Exception as exc:  # noqa: BLE001 β€” isolate failures per model
            logger.exception("[{}] failed", name)
            result = CouncilResult(
                name,
                error=f"{type(exc).__name__}: {exc}",
                latency_s=round(time.perf_counter() - t0, 2),
            )
        results.append(result)
    return results


# -------------------------------------------------------------- presentation


def results_to_payload(
    image_path: Path,
    prompt: str,
    results: list[CouncilResult],
    *,
    system_prompt: str | None = None,
    temperature: float = 0.0,
    max_tokens: int = 1024,
) -> dict:
    return {
        "image": str(image_path),
        "prompt": prompt,
        "system_prompt": system_prompt,
        "temperature": temperature,
        "max_tokens": max_tokens,
        "results": [asdict(r) for r in results],
        "errors": [f"{r.model}: {r.error}" for r in results if r.error],
    }


def print_results(results: list[CouncilResult]) -> None:
    for r in results:
        status = "── ERROR " if r.error else ""
        print("\n" + f"── {r.model} {status}── {r.latency_s}s ".ljust(60, "─"))
        print(r.error if r.error else r.text)


# ---------------------------------------------------------------------- CLI

cli = typer.Typer(add_completion=False, no_args_is_help=True)


@cli.command()
def main(
    image: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True, help="Input image"),
    prompt: str = typer.Argument(..., help="Question or instruction for the image"),
    models: Optional[list[str]] = typer.Option(
        None, "--models", "-m", help="Models to run (repeatable): council names or HF repo ids. Council: " + ", ".join(COUNCIL)
    ),
    system_prompt: Optional[str] = typer.Option(None, "--system-prompt", "-s", help="Optional system prompt"),
    temperature: float = typer.Option(0.0, "--temperature", "-t", min=0.0, help="0 = deterministic (greedy)"),
    max_tokens: int = typer.Option(1024, "--max-tokens", min=1, help="Max new tokens to generate"),
    output: Optional[Path] = typer.Option(None, "--output", "-o", help="Also write results as JSON to this file"),
    as_json: bool = typer.Option(False, "--json", help="Print JSON to stdout instead of readable text"),
) -> None:
    """Ask every selected local VLM the same question about the image and compare answers."""
    names = models or list(COUNCIL)
    results = run_council(
        image, prompt, names, system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens
    )
    payload = results_to_payload(
        image, prompt, results, system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens
    )

    if as_json:
        print(json.dumps(payload, indent=2, ensure_ascii=False))
    else:
        print_results(results)
    if output:
        output.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
        logger.info("Wrote {}", output)

    if all(r.error for r in results):
        raise typer.Exit(1)


if __name__ == "__main__":
    cli()