John Ho Claude Fable 5 commited on
Commit
4641048
Β·
1 Parent(s): aef333f

Add sVLM-Council: local small-VLM arena, wired into ExtractArena

Browse files

New svlm_council.py runs four <=10B extraction-strong VLMs locally via
transformers only (no Spaces, no Inference API), chosen from research
for KIE/extraction quality plus in-tree transformers support:
baidu/Qianfan-OCR (4.7B), tencent/HunyuanOCR (1.1B),
ibm-granite/granite-vision-4.1-4b (4B), Qwen/Qwen3-VL-8B-Instruct (8.8B).

- One generic query(model, image, prompt, *, system_prompt, temperature,
max_tokens) serves all members (and any raw HF repo id); per-model
quirks are data in the COUNCIL registry (attn implementation,
processor kwargs, generate extras), so adding a model is one entry.
- Works for data extraction or vanilla VQA; deterministic by default
(explicitly neutralizes sampling defaults some checkpoints ship).
- Single-slot model cache with eviction (gc + torch.mps.empty_cache) --
the four total ~37GB bf16, more than this host's 32GB unified memory.
- device_map="mps" instead of "auto": accelerate's auto offloads to
disk far too eagerly on Macs (qianfan-ocr: 190s -> ~20s per query).
- Dual use: typer CLI (banners/--json/-o like the sibling scripts) and
importable module.

extract_arena.py gains four registry entries (qianfan-ocr, hunyuan-ocr,
granite-vision, qwen3-vl-8b) through a lazy-import adapter over
svlm_council.query, plus the council's deps in its PEP 723 header.

Verified on the bib photo: qianfan/granite/qwen3-vl read 16551
(hunyuan misreads as 1651 under a terse prompt -- an arena finding);
negative "phone number" -> (not found) through extract_arena; remote
backends unaffected. Note: qwen3-vl-8b is correct but slow (~8-10 min)
on 32GB due to memory pressure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (2) hide show
  1. extract_arena.py +20 -0
  2. svlm_council.py +312 -0
extract_arena.py CHANGED
@@ -6,6 +6,12 @@
6
  # "loguru>=0.7.2",
7
  # "gradio-client>=1.4",
8
  # "huggingface-hub>=0.34",
 
 
 
 
 
 
9
  # ]
10
  # ///
11
  """ExtractArena β€” compare data extraction by several VLMs on one image.
@@ -17,6 +23,8 @@ Backends:
17
  - moondream3 -> HF Space GF-John/moondream-pointer via gradio_client
18
  - qwen3.5 -> Qwen/Qwen3.5-9B via HF Inference Providers (needs HF_TOKEN)
19
  - infinity-parser2 -> infly/Infinity-Parser2-Flash via HF Inference Providers
 
 
20
 
21
  Usage:
22
  uv run --env-file .env extract_arena.py bib.jpg -f "bib number"
@@ -232,6 +240,13 @@ def extract_hf_inference(model_id: str, image_path: Path, prompt: str, provider:
232
  raise RuntimeError("unreachable")
233
 
234
 
 
 
 
 
 
 
 
235
  # ----------------------------------------------------------------- registry
236
 
237
 
@@ -249,6 +264,11 @@ MODELS: dict[str, ModelSpec] = {
249
  # only served by featherless-ai, which auto-routing skips unless enabled account-side
250
  functools.partial(extract_hf_inference, INFINITY_MODEL_ID, provider="featherless-ai"),
251
  ),
 
 
 
 
 
252
  }
253
 
254
  # ------------------------------------------------------------- orchestration
 
6
  # "loguru>=0.7.2",
7
  # "gradio-client>=1.4",
8
  # "huggingface-hub>=0.34",
9
+ # "transformers>=5.15",
10
+ # "torch>=2.10",
11
+ # "torchvision>=0.25",
12
+ # "accelerate>=1.0",
13
+ # "peft>=0.19.1",
14
+ # "pillow>=12.2",
15
  # ]
16
  # ///
17
  """ExtractArena β€” compare data extraction by several VLMs on one image.
 
23
  - moondream3 -> HF Space GF-John/moondream-pointer via gradio_client
24
  - qwen3.5 -> Qwen/Qwen3.5-9B via HF Inference Providers (needs HF_TOKEN)
25
  - infinity-parser2 -> infly/Infinity-Parser2-Flash via HF Inference Providers
26
+ - qianfan-ocr, hunyuan-ocr, granite-vision, qwen3-vl-8b
27
+ -> local transformers via svlm_council.py (see that file)
28
 
29
  Usage:
30
  uv run --env-file .env extract_arena.py bib.jpg -f "bib number"
 
240
  raise RuntimeError("unreachable")
241
 
242
 
243
+ def extract_local(council_name: str, image_path: Path, prompt: str) -> str:
244
+ # Lazy import: remote-only runs never pay the torch/transformers load time.
245
+ import svlm_council
246
+
247
+ return svlm_council.query(council_name, image_path, prompt, temperature=0.0, max_tokens=2048)
248
+
249
+
250
  # ----------------------------------------------------------------- registry
251
 
252
 
 
264
  # only served by featherless-ai, which auto-routing skips unless enabled account-side
265
  functools.partial(extract_hf_inference, INFINITY_MODEL_ID, provider="featherless-ai"),
266
  ),
267
+ # Local small-VLM council members (svlm_council.py); loaded one at a time.
268
+ "qianfan-ocr": ModelSpec("qianfan-ocr", functools.partial(extract_local, "qianfan-ocr")),
269
+ "hunyuan-ocr": ModelSpec("hunyuan-ocr", functools.partial(extract_local, "hunyuan-ocr")),
270
+ "granite-vision": ModelSpec("granite-vision", functools.partial(extract_local, "granite-vision")),
271
+ "qwen3-vl-8b": ModelSpec("qwen3-vl-8b", functools.partial(extract_local, "qwen3-vl-8b")),
272
  }
273
 
274
  # ------------------------------------------------------------- orchestration
svlm_council.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = [
5
+ # "typer>=0.12",
6
+ # "loguru>=0.7.2",
7
+ # "transformers>=5.15",
8
+ # "torch>=2.10",
9
+ # "torchvision>=0.25",
10
+ # "accelerate>=1.0",
11
+ # "peft>=0.19.1",
12
+ # "pillow>=12.2",
13
+ # ]
14
+ # ///
15
+ """sVLM-Council β€” run the same image+prompt through small local VLMs.
16
+
17
+ All members run locally via transformers (no Spaces, no Inference API) through
18
+ one generic `query()` function; per-model quirks live in the COUNCIL registry.
19
+ Works for data extraction or vanilla VQA alike.
20
+
21
+ Council members (all in-tree transformers, dense, MPS-friendly):
22
+ - qianfan-ocr -> baidu/Qianfan-OCR (4.7B, KIE leader)
23
+ - hunyuan-ocr -> tencent/HunyuanOCR (1.1B, scene text + IE)
24
+ - granite-vision -> ibm-granite/granite-vision-4.1-4b (4B, key-value extraction)
25
+ - qwen3-vl-8b -> Qwen/Qwen3-VL-8B-Instruct (8.8B, best sub-10B OCRBench)
26
+
27
+ Models are loaded one at a time (single-slot cache with eviction): the four
28
+ together exceed 32GB unified memory, and first use downloads each checkpoint.
29
+
30
+ Usage:
31
+ uv run svlm_council.py bib.jpg "What is the bib number in this image?"
32
+ uv run svlm_council.py bib.jpg "Describe this image." -m hunyuan-ocr -t 0.7
33
+ uv run svlm_council.py bib.jpg "..." -s "You are a terse assistant." --json -o out.json
34
+
35
+ As a library (e.g. from extract_arena.py):
36
+ import svlm_council
37
+ text = svlm_council.query("qianfan-ocr", "bib.jpg", "What is the bib number?")
38
+
39
+ Results go to stdout; logs go to stderr (loguru default).
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import gc
44
+ import json
45
+ import time
46
+ from dataclasses import asdict, dataclass, field
47
+ from pathlib import Path
48
+ from typing import Optional
49
+
50
+ import typer
51
+ from loguru import logger
52
+
53
+ # ----------------------------------------------------------------- registry
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class CouncilSpec:
58
+ name: str
59
+ model_id: str
60
+ dtype: str = "auto" # "auto" or a torch dtype name like "bfloat16"
61
+ device_map: str = "mps" # accelerate's "auto" offloads to disk far too eagerly on Macs
62
+ attn_implementation: str = "sdpa" # none of the members needs flash-attn
63
+ processor_kwargs: dict = field(default_factory=dict)
64
+ generate_kwargs: dict = field(default_factory=dict)
65
+
66
+
67
+ COUNCIL: dict[str, CouncilSpec] = {
68
+ "qianfan-ocr": CouncilSpec(
69
+ "qianfan-ocr",
70
+ "baidu/Qianfan-OCR",
71
+ dtype="bfloat16",
72
+ ),
73
+ "hunyuan-ocr": CouncilSpec(
74
+ "hunyuan-ocr",
75
+ "tencent/HunyuanOCR",
76
+ dtype="bfloat16",
77
+ attn_implementation="eager", # recommended by the transformers doc for the OCR path
78
+ processor_kwargs={"backend": "pil"},
79
+ generate_kwargs={"repetition_penalty": 1.08}, # model card's recommended setting
80
+ ),
81
+ "granite-vision": CouncilSpec(
82
+ "granite-vision",
83
+ "ibm-granite/granite-vision-4.1-4b",
84
+ dtype="bfloat16",
85
+ ),
86
+ "qwen3-vl-8b": CouncilSpec(
87
+ "qwen3-vl-8b",
88
+ "Qwen/Qwen3-VL-8B-Instruct",
89
+ ),
90
+ }
91
+
92
+
93
+ def _resolve_spec(model: str) -> CouncilSpec:
94
+ # Any HF repo id also works: unknown names get default handling, so trying
95
+ # a new model is just a name change.
96
+ return COUNCIL.get(model) or CouncilSpec(name=model, model_id=model)
97
+
98
+
99
+ # ------------------------------------------------- single-slot model cache
100
+ # The council members total ~37GB bf16 β€” more than this host's unified memory β€”
101
+ # so only one model stays resident; switching models evicts the previous one.
102
+
103
+ _LOADED: dict[str, object] = {}
104
+
105
+
106
+ def _load(spec: CouncilSpec):
107
+ import torch
108
+ from transformers import AutoModelForImageTextToText, AutoProcessor
109
+
110
+ if _LOADED.get("name") == spec.name:
111
+ return _LOADED["processor"], _LOADED["model"]
112
+ if _LOADED:
113
+ logger.info("Evicting {} to free memory", _LOADED["name"])
114
+ _LOADED.clear()
115
+ gc.collect()
116
+ if torch.backends.mps.is_available():
117
+ torch.mps.empty_cache()
118
+
119
+ logger.info("Loading {} ({}) β€” first use downloads the weights", spec.name, spec.model_id)
120
+ processor = AutoProcessor.from_pretrained(spec.model_id, **spec.processor_kwargs)
121
+ device_map = spec.device_map if torch.backends.mps.is_available() else "auto"
122
+ model = AutoModelForImageTextToText.from_pretrained(
123
+ spec.model_id,
124
+ dtype=spec.dtype if spec.dtype == "auto" else getattr(torch, spec.dtype),
125
+ device_map=device_map,
126
+ attn_implementation=spec.attn_implementation,
127
+ ).eval()
128
+ _LOADED.update(name=spec.name, processor=processor, model=model)
129
+ return processor, model
130
+
131
+
132
+ # ----------------------------------------------------------------- core API
133
+
134
+
135
+ def query(
136
+ model: str,
137
+ image_path: Path | str,
138
+ prompt: str,
139
+ *,
140
+ system_prompt: str | None = None,
141
+ temperature: float = 0.0,
142
+ max_tokens: int = 1024,
143
+ ) -> str:
144
+ """Ask one council member (or any HF repo id) a question about an image.
145
+
146
+ Generic over tasks: works for targeted data extraction and vanilla VQA.
147
+ Returns the model's text reply; raises on failure (callers isolate errors).
148
+ """
149
+ import torch
150
+
151
+ spec = _resolve_spec(model)
152
+ processor, vlm = _load(spec)
153
+
154
+ messages = []
155
+ if system_prompt:
156
+ messages.append({"role": "system", "content": [{"type": "text", "text": system_prompt}]})
157
+ messages.append(
158
+ {
159
+ "role": "user",
160
+ "content": [
161
+ {"type": "image", "url": str(image_path)},
162
+ {"type": "text", "text": prompt},
163
+ ],
164
+ }
165
+ )
166
+ inputs = processor.apply_chat_template(
167
+ messages,
168
+ tokenize=True,
169
+ add_generation_prompt=True,
170
+ return_dict=True,
171
+ return_tensors="pt",
172
+ ).to(vlm.device)
173
+ inputs.pop("token_type_ids", None) # required for Qwen3-VL, harmless for the rest
174
+
175
+ gen_kwargs: dict = {
176
+ "max_new_tokens": max_tokens,
177
+ "use_cache": True, # Qianfan-OCR's config ships use_cache: false
178
+ **spec.generate_kwargs,
179
+ }
180
+ if temperature > 0:
181
+ gen_kwargs.update(do_sample=True, temperature=temperature)
182
+ else:
183
+ # Explicit Nones neutralize sampling defaults baked into some
184
+ # generation_configs (Qwen3-VL ships do_sample=true, temperature=0.7).
185
+ gen_kwargs.update(do_sample=False, temperature=None, top_p=None, top_k=None)
186
+
187
+ with torch.inference_mode():
188
+ generated = vlm.generate(**inputs, **gen_kwargs)
189
+ return processor.batch_decode(
190
+ generated[:, inputs["input_ids"].shape[1] :],
191
+ skip_special_tokens=True,
192
+ clean_up_tokenization_spaces=False, # preserve whitespace fidelity in OCR-ish output
193
+ )[0].strip()
194
+
195
+
196
+ # ------------------------------------------------------------- orchestration
197
+
198
+
199
+ @dataclass
200
+ class CouncilResult:
201
+ model: str
202
+ text: str = ""
203
+ latency_s: float = 0.0
204
+ error: str | None = None
205
+
206
+
207
+ def run_council(
208
+ image_path: Path,
209
+ prompt: str,
210
+ model_names: list[str],
211
+ *,
212
+ system_prompt: str | None = None,
213
+ temperature: float = 0.0,
214
+ max_tokens: int = 1024,
215
+ ) -> list[CouncilResult]:
216
+ results = []
217
+ for name in model_names:
218
+ logger.info("[{}] prompt: {!r}", name, prompt)
219
+ t0 = time.perf_counter()
220
+ try:
221
+ text = query(
222
+ name,
223
+ image_path,
224
+ prompt,
225
+ system_prompt=system_prompt,
226
+ temperature=temperature,
227
+ max_tokens=max_tokens,
228
+ )
229
+ result = CouncilResult(name, text=text, latency_s=round(time.perf_counter() - t0, 2))
230
+ logger.info("[{}] done in {}s", name, result.latency_s)
231
+ except Exception as exc: # noqa: BLE001 β€” isolate failures per model
232
+ logger.exception("[{}] failed", name)
233
+ result = CouncilResult(
234
+ name,
235
+ error=f"{type(exc).__name__}: {exc}",
236
+ latency_s=round(time.perf_counter() - t0, 2),
237
+ )
238
+ results.append(result)
239
+ return results
240
+
241
+
242
+ # -------------------------------------------------------------- presentation
243
+
244
+
245
+ def results_to_payload(
246
+ image_path: Path,
247
+ prompt: str,
248
+ results: list[CouncilResult],
249
+ *,
250
+ system_prompt: str | None = None,
251
+ temperature: float = 0.0,
252
+ max_tokens: int = 1024,
253
+ ) -> dict:
254
+ return {
255
+ "image": str(image_path),
256
+ "prompt": prompt,
257
+ "system_prompt": system_prompt,
258
+ "temperature": temperature,
259
+ "max_tokens": max_tokens,
260
+ "results": [asdict(r) for r in results],
261
+ "errors": [f"{r.model}: {r.error}" for r in results if r.error],
262
+ }
263
+
264
+
265
+ def print_results(results: list[CouncilResult]) -> None:
266
+ for r in results:
267
+ status = "── ERROR " if r.error else ""
268
+ print("\n" + f"── {r.model} {status}── {r.latency_s}s ".ljust(60, "─"))
269
+ print(r.error if r.error else r.text)
270
+
271
+
272
+ # ---------------------------------------------------------------------- CLI
273
+
274
+ cli = typer.Typer(add_completion=False, no_args_is_help=True)
275
+
276
+
277
+ @cli.command()
278
+ def main(
279
+ image: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True, help="Input image"),
280
+ prompt: str = typer.Argument(..., help="Question or instruction for the image"),
281
+ models: Optional[list[str]] = typer.Option(
282
+ None, "--models", "-m", help="Models to run (repeatable): council names or HF repo ids. Council: " + ", ".join(COUNCIL)
283
+ ),
284
+ system_prompt: Optional[str] = typer.Option(None, "--system-prompt", "-s", help="Optional system prompt"),
285
+ temperature: float = typer.Option(0.0, "--temperature", "-t", min=0.0, help="0 = deterministic (greedy)"),
286
+ max_tokens: int = typer.Option(1024, "--max-tokens", min=1, help="Max new tokens to generate"),
287
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Also write results as JSON to this file"),
288
+ as_json: bool = typer.Option(False, "--json", help="Print JSON to stdout instead of readable text"),
289
+ ) -> None:
290
+ """Ask every selected local VLM the same question about the image and compare answers."""
291
+ names = models or list(COUNCIL)
292
+ results = run_council(
293
+ image, prompt, names, system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens
294
+ )
295
+ payload = results_to_payload(
296
+ image, prompt, results, system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens
297
+ )
298
+
299
+ if as_json:
300
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
301
+ else:
302
+ print_results(results)
303
+ if output:
304
+ output.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
305
+ logger.info("Wrote {}", output)
306
+
307
+ if all(r.error for r in results):
308
+ raise typer.Exit(1)
309
+
310
+
311
+ if __name__ == "__main__":
312
+ cli()