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

Add ExtractArena tab to the Space app

Browse files

app.py becomes a gr.TabbedInterface: the existing council tab plus an
ExtractArena tab (UI + REST + MCP tool `extract_fields`) that reuses
extract_arena.py as a library β€” prompt building, N/A-abstention
normalization, and JSON-reply parsing verbatim. Remote backends
(HF Inference Providers + moondream Space) run unbilled in the main
process; local council members reuse the resident _gpu_run_council path
in a single billed GPU window. HF_TOKEN is optional: hosted models fail
per-model (logged) without it while the rest run.

New env knobs: MAX_EXTRACT_MODELS (default 3) and DEFAULT_EXTRACT_MODELS
(default qwen3.5,muse-glimmer). MCP docstring follows the
gradio-mcp-tool-docstrings rules and links MODEL_RESEARCH.md for
calling agents; verified against the live MCP schema and the bib test
image (remote, local, mixed, multi-field, abstention, validation paths).

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

Files changed (5) hide show
  1. CLAUDE.md +4 -2
  2. README.md +10 -4
  3. app.py +238 -5
  4. pyproject.toml +2 -0
  5. uv.lock +4 -0
CLAUDE.md CHANGED
@@ -24,7 +24,7 @@ since that data goes stale. Update it after any new model research.
24
  - Install deps: `uv sync` (creates/updates `.venv` from `pyproject.toml` + `uv.lock`)
25
  - Add a dependency: `uv add <package>` (updates `pyproject.toml` and `uv.lock`)
26
  - Run the app locally: `uv run app.py` (launches Gradio on `http://127.0.0.1:7860`, with Swagger docs at `/docs` and an MCP server enabled)
27
- - Regenerate the deploy requirements manually: `uv export --no-hashes --format requirements-txt > requirements.txt`
28
 
29
  There are no tests or linters configured.
30
 
@@ -47,7 +47,9 @@ HF Spaces rejects raw binaries in the git tree β€” image/video/audio/model-weigh
47
 
48
  ## App architecture (`app.py`)
49
 
50
- `app.py` serves the council from `svlm_council.py` β€” it imports the `COUNCIL` registry from there but has its own ZeroGPU load/infer path (the CLI's MPS single-slot eviction cache doesn't apply on the Space). Adding/changing a council member means editing `COUNCIL` in `svlm_council.py`; `app.py` picks it up automatically.
 
 
51
 
52
  - **Startup (module level, unbilled)**: every enabled member is loaded via `AutoProcessor`/`AutoModelForImageTextToText` and moved `.to("cuda")` β€” on ZeroGPU the main process runs a CUDA emulation mode, so module-level CUDA placement is the officially recommended pattern (lazy-loading inside `@spaces.GPU` is discouraged). All four members β‰ˆ 37GB bf16, fitting the default `large` ZeroGPU slice (48GB VRAM; `xlarge` = 96GB at 2Γ— quota). No flash-attn and no quantization; on CUDA every member is forced to sdpa attention (a spec'd `eager` β€” hunyuan-ocr β€” would materialize seqΒ² float32 attention transients that OOM the VRAM left beside the resident council).
53
  - **`council_query`** is the public function = REST endpoint = MCP tool. It validates (returns a structured single-element error list instead of raising, so MCP clients get something actionable), then delegates to **`_gpu_run_council`**, the only `@spaces.GPU` function β€” its dynamic `duration` callable scales with the number of selected models (the requested duration is ZeroGPU's quota gate, so keep it tight; tune from Space logs).
 
24
  - Install deps: `uv sync` (creates/updates `.venv` from `pyproject.toml` + `uv.lock`)
25
  - Add a dependency: `uv add <package>` (updates `pyproject.toml` and `uv.lock`)
26
  - Run the app locally: `uv run app.py` (launches Gradio on `http://127.0.0.1:7860`, with Swagger docs at `/docs` and an MCP server enabled)
27
+ - `requirements.txt` is **hand-curated** (loose pins; no `gradio`/`spaces` β€” the Space's SDK provides those). Don't regenerate it with `uv export`; when app code gains a dependency, append it manually.
28
 
29
  There are no tests or linters configured.
30
 
 
47
 
48
  ## App architecture (`app.py`)
49
 
50
+ `app.py` is a `gr.TabbedInterface` with two tabs, each a `gr.Interface` = REST endpoint = MCP tool:
51
+ **Council** (`council_query`) serves the council from `svlm_council.py` β€” it imports the `COUNCIL` registry from there but has its own ZeroGPU load/infer path (the CLI's MPS single-slot eviction cache doesn't apply on the Space). Adding/changing a council member means editing `COUNCIL` in `svlm_council.py`; `app.py` picks it up automatically.
52
+ **ExtractArena** (`extract_fields`) reuses `extract_arena.py` as a library: its prompt building (`build_prompt`), reply normalization (`_normalize_value`/`_parse_json_reply`), and remote backends (`extract_arena.MODELS[name].fn` β€” HF Inference Providers + the moondream Space) run verbatim in the main process (unbilled); local council names instead route through the resident-model `_gpu_run_council` path (never `extract_arena`'s MPS loader). `import extract_arena` must stay **after** `_load_dotenv()` β€” it reads `HF_TOKEN` at import time. `HF_TOKEN` is optional: without it the hosted backends fail per-model (logged) while the rest run. Result shape mirrors `ExtractResult`: `{"model", "value", "values", "raw", "latency_s", "error"}` (`value` normalized single-field answer with `""` = abstained; `values` for multi-field). Extract knobs: `MAX_EXTRACT_MODELS` (default 3), `DEFAULT_EXTRACT_MODELS` (default `qwen3.5,muse-glimmer`).
53
 
54
  - **Startup (module level, unbilled)**: every enabled member is loaded via `AutoProcessor`/`AutoModelForImageTextToText` and moved `.to("cuda")` β€” on ZeroGPU the main process runs a CUDA emulation mode, so module-level CUDA placement is the officially recommended pattern (lazy-loading inside `@spaces.GPU` is discouraged). All four members β‰ˆ 37GB bf16, fitting the default `large` ZeroGPU slice (48GB VRAM; `xlarge` = 96GB at 2Γ— quota). No flash-attn and no quantization; on CUDA every member is forced to sdpa attention (a spec'd `eager` β€” hunyuan-ocr β€” would materialize seqΒ² float32 attention transients that OOM the VRAM left beside the resident council).
55
  - **`council_query`** is the public function = REST endpoint = MCP tool. It validates (returns a structured single-element error list instead of raising, so MCP clients get something actionable), then delegates to **`_gpu_run_council`**, the only `@spaces.GPU` function β€” its dynamic `duration` callable scales with the number of selected models (the requested duration is ZeroGPU's quota gate, so keep it tight; tune from Space logs).
README.md CHANGED
@@ -7,15 +7,18 @@ sdk: gradio
7
  sdk_version: 6.26.0
8
  app_file: app.py
9
  pinned: false
10
- short_description: Compare small vision-language models on any image
11
  ---
12
 
13
  # sVLM Council
14
 
15
  Run the same image + prompt through a council of small (~1–9B) open vision-language
16
  models and compare their answers β€” targeted data extraction or vanilla VQA. `app.py`
17
- serves the council as a Gradio app with an MCP server and Swagger docs at `/docs`;
18
- the model registry is shared with [`svlm_council.py`](svlm_council.py) (the local CLI).
 
 
 
19
 
20
  The Space needs **ZeroGPU** hardware (set it in the Space settings). All council
21
  members are loaded to CUDA at startup (~37GB bf16 β€” fits the default 48GB `large`
@@ -23,7 +26,10 @@ ZeroGPU slice); only generation runs inside the billed `@spaces.GPU` window. Env
23
  knobs: `MAX_COUNCIL_MODELS` (default 3), `DEFAULT_COUNCIL_MODEL` (default
24
  `qianfan-ocr`), `COUNCIL_MEMBERS` (comma-separated subset to load, e.g.
25
  `COUNCIL_MEMBERS=hunyuan-ocr` for a light local run), `MAX_IMAGE_PIXELS`
26
- (default 3MP β€” larger inputs are downscaled before inference).
 
 
 
27
 
28
  ## Deployment
29
 
 
7
  sdk_version: 6.26.0
8
  app_file: app.py
9
  pinned: false
10
+ short_description: Compare small VLMs on any image β€” VQA and field extraction
11
  ---
12
 
13
  # sVLM Council
14
 
15
  Run the same image + prompt through a council of small (~1–9B) open vision-language
16
  models and compare their answers β€” targeted data extraction or vanilla VQA. `app.py`
17
+ serves two tabs as a Gradio app with an MCP server and Swagger docs at `/docs`:
18
+ **Council** (the local council; registry shared with [`svlm_council.py`](svlm_council.py))
19
+ and **ExtractArena** (named-field extraction with abstention guardrails from
20
+ [`extract_arena.py`](extract_arena.py) β€” hosted models via HF Inference Providers +
21
+ the moondream Space, plus the resident council members).
22
 
23
  The Space needs **ZeroGPU** hardware (set it in the Space settings). All council
24
  members are loaded to CUDA at startup (~37GB bf16 β€” fits the default 48GB `large`
 
26
  knobs: `MAX_COUNCIL_MODELS` (default 3), `DEFAULT_COUNCIL_MODEL` (default
27
  `qianfan-ocr`), `COUNCIL_MEMBERS` (comma-separated subset to load, e.g.
28
  `COUNCIL_MEMBERS=hunyuan-ocr` for a light local run), `MAX_IMAGE_PIXELS`
29
+ (default 3MP β€” larger inputs are downscaled before inference),
30
+ `MAX_EXTRACT_MODELS` (default 3), `DEFAULT_EXTRACT_MODELS` (default
31
+ `qwen3.5,muse-glimmer`), and an optional `HF_TOKEN` secret for the hosted
32
+ ExtractArena backends (without it they fail per-model; the rest still run).
33
 
34
  ## Deployment
35
 
app.py CHANGED
@@ -1,17 +1,27 @@
1
  """sVLM-Council Space app β€” ask small local VLMs about an image, side by side.
2
 
3
- Gradio app + MCP server serving the council from `svlm_council.py` (the model
4
- registry is imported from there; the load/infer path here is ZeroGPU-specific).
 
 
 
 
 
5
  All enabled members are loaded to CUDA at module level β€” on ZeroGPU that runs
6
  under the CUDA emulation mode at startup (unbilled); only generation happens
7
  inside the @spaces.GPU window. The four default members total ~37GB bf16, which
8
  fits resident on the default `large` ZeroGPU slice (48GB).
9
 
10
  Env knobs:
11
- - MAX_COUNCIL_MODELS max models per request (default 3)
12
  - DEFAULT_COUNCIL_MODEL model used when none selected (default qianfan-ocr)
13
  - COUNCIL_MEMBERS comma-separated subset to load at startup (default: all;
14
  e.g. COUNCIL_MEMBERS=hunyuan-ocr for a light local run)
 
 
 
 
 
15
  """
16
 
17
  import spaces # must be imported before any CUDA-touching torch usage on ZeroGPU
@@ -48,7 +58,11 @@ def _load_dotenv(path: Path = Path(".env")):
48
 
49
  _load_dotenv()
50
 
 
 
 
51
  MAX_COUNCIL_MODELS = int(os.environ.get("MAX_COUNCIL_MODELS", "3"))
 
52
  MAX_IMAGE_PIXELS = int(os.environ.get("MAX_IMAGE_PIXELS", str(3_000_000)))
53
  DEFAULT_MODEL = os.environ.get("DEFAULT_COUNCIL_MODEL", "qianfan-ocr")
54
  MEMBERS = [
@@ -64,6 +78,23 @@ if DEFAULT_MODEL not in MEMBERS:
64
  )
65
  DEFAULT_MODEL = MEMBERS[0]
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  DEVICE = (
68
  "cuda"
69
  if torch.cuda.is_available() # true on ZeroGPU's main process (emulation mode)
@@ -329,7 +360,167 @@ Args:
329
  """
330
 
331
 
332
- app = gr.Interface(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  fn=council_query,
334
  inputs=[
335
  gr.Image(type="filepath", label="Input Image"),
@@ -352,7 +543,6 @@ app = gr.Interface(
352
  gr.Slider(label="Max Tokens", value=1024, minimum=32, maximum=4096, step=32),
353
  ],
354
  outputs=gr.JSON(label="Council Results"),
355
- title="sVLM Council",
356
  description=(
357
  "Run the same image + prompt through small (~1-9B) open vision-language models and "
358
  "compare answers β€” data extraction or vanilla VQA. Model choices, benchmarks, and "
@@ -362,4 +552,47 @@ app = gr.Interface(
362
  api_name="council_query",
363
  api_visibility="public",
364
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  app.launch(mcp_server=True, app_kwargs={"docs_url": "/docs"})
 
1
  """sVLM-Council Space app β€” ask small local VLMs about an image, side by side.
2
 
3
+ Gradio app + MCP server with two tabs:
4
+ - Council: the council from `svlm_council.py` (the model registry is imported
5
+ from there; the load/infer path here is ZeroGPU-specific).
6
+ - ExtractArena: targeted field extraction from `extract_arena.py` β€” its remote
7
+ backends (HF Inference Providers + the moondream Space) run in the main
8
+ process (unbilled); local council members reuse the resident GPU path.
9
+
10
  All enabled members are loaded to CUDA at module level β€” on ZeroGPU that runs
11
  under the CUDA emulation mode at startup (unbilled); only generation happens
12
  inside the @spaces.GPU window. The four default members total ~37GB bf16, which
13
  fits resident on the default `large` ZeroGPU slice (48GB).
14
 
15
  Env knobs:
16
+ - MAX_COUNCIL_MODELS max models per council request (default 3)
17
  - DEFAULT_COUNCIL_MODEL model used when none selected (default qianfan-ocr)
18
  - COUNCIL_MEMBERS comma-separated subset to load at startup (default: all;
19
  e.g. COUNCIL_MEMBERS=hunyuan-ocr for a light local run)
20
+ - MAX_EXTRACT_MODELS max models per extract request (default 3)
21
+ - DEFAULT_EXTRACT_MODELS comma-separated models used when none selected
22
+ (default qwen3.5,muse-glimmer)
23
+ - HF_TOKEN optional; without it the HF-Inference extract backends
24
+ fail per-model (logged) while everything else runs
25
  """
26
 
27
  import spaces # must be imported before any CUDA-touching torch usage on ZeroGPU
 
58
 
59
  _load_dotenv()
60
 
61
+ # After _load_dotenv(): extract_arena reads HF_TOKEN at import time.
62
+ import extract_arena # noqa: E402
63
+
64
  MAX_COUNCIL_MODELS = int(os.environ.get("MAX_COUNCIL_MODELS", "3"))
65
+ MAX_EXTRACT_MODELS = int(os.environ.get("MAX_EXTRACT_MODELS", "3"))
66
  MAX_IMAGE_PIXELS = int(os.environ.get("MAX_IMAGE_PIXELS", str(3_000_000)))
67
  DEFAULT_MODEL = os.environ.get("DEFAULT_COUNCIL_MODEL", "qianfan-ocr")
68
  MEMBERS = [
 
78
  )
79
  DEFAULT_MODEL = MEMBERS[0]
80
 
81
+ # ExtractArena roster: extract_arena's remote backends + whatever council members
82
+ # are actually loaded (local names in extract_arena.MODELS route through the
83
+ # resident GPU path here, never through its MPS single-slot loader).
84
+ EXTRACT_REMOTE = [n for n in extract_arena.MODELS if n not in COUNCIL]
85
+ EXTRACT_ROSTER = EXTRACT_REMOTE + MEMBERS
86
+ DEFAULT_EXTRACT_MODELS = [
87
+ name.strip()
88
+ for name in os.environ.get("DEFAULT_EXTRACT_MODELS", "qwen3.5,muse-glimmer").split(",")
89
+ if name.strip() in EXTRACT_ROSTER
90
+ ]
91
+ if not DEFAULT_EXTRACT_MODELS:
92
+ logger.warning(
93
+ "DEFAULT_EXTRACT_MODELS matched no roster entry; falling back to {}",
94
+ EXTRACT_ROSTER[0],
95
+ )
96
+ DEFAULT_EXTRACT_MODELS = [EXTRACT_ROSTER[0]]
97
+
98
  DEVICE = (
99
  "cuda"
100
  if torch.cuda.is_available() # true on ZeroGPU's main process (emulation mode)
 
360
  """
361
 
362
 
363
+ def _extract_error(message: str) -> list[dict]:
364
+ return [
365
+ {
366
+ "model": None,
367
+ "value": None,
368
+ "values": None,
369
+ "raw": "",
370
+ "latency_s": 0.0,
371
+ "error": message,
372
+ }
373
+ ]
374
+
375
+
376
+ def _to_extract_result(
377
+ name: str, raw: str, latency_s: float, multi: bool, error: str | None = None
378
+ ) -> dict:
379
+ """Shape one reply like extract_arena.ExtractResult (normalized + raw kept)."""
380
+ if error:
381
+ return {
382
+ "model": name,
383
+ "value": None,
384
+ "values": None,
385
+ "raw": "",
386
+ "latency_s": latency_s,
387
+ "error": error,
388
+ }
389
+ raw = raw.strip()
390
+ if multi:
391
+ parsed = extract_arena._parse_json_reply(raw)
392
+ values = (
393
+ {k: extract_arena._normalize_value(v) for k, v in parsed.items()}
394
+ if parsed is not None
395
+ else None
396
+ )
397
+ value = None
398
+ else:
399
+ values = None
400
+ value = extract_arena._normalize_value(raw)
401
+ return {
402
+ "model": name,
403
+ "value": value,
404
+ "values": values,
405
+ "raw": raw,
406
+ "latency_s": latency_s,
407
+ "error": None,
408
+ }
409
+
410
+
411
+ def extract_fields(
412
+ image: str,
413
+ fields: list[str] | None = None,
414
+ models: list[str] | None = None,
415
+ answer_format: str = "",
416
+ ) -> list[dict]:
417
+ # The real docstring (the MCP tool description) is assigned below β€” it needs
418
+ # runtime values (roster, defaults, max) a literal docstring can't interpolate.
419
+ if not image:
420
+ return _extract_error("No image provided.")
421
+ if isinstance(fields, str): # tolerate a single field from API/MCP callers
422
+ fields = [fields]
423
+ fields = [f.strip() for f in (fields or []) if f and f.strip()]
424
+ if not fields:
425
+ return _extract_error("No fields provided.")
426
+
427
+ if isinstance(models, str): # tolerate a single name from API/MCP callers
428
+ models = [models]
429
+ models = [m for m in (models or []) if m] or list(DEFAULT_EXTRACT_MODELS)
430
+ models = list(dict.fromkeys(models)) # results are keyed by name below
431
+
432
+ unknown = [m for m in models if m not in EXTRACT_ROSTER]
433
+ if unknown:
434
+ return _extract_error(
435
+ f"Unknown or unloaded model(s): {', '.join(unknown)}. "
436
+ f"Available: {', '.join(EXTRACT_ROSTER)}."
437
+ )
438
+ if len(models) > MAX_EXTRACT_MODELS:
439
+ return _extract_error(
440
+ f"Too many models selected ({len(models)}); "
441
+ f"the maximum per request is {MAX_EXTRACT_MODELS}."
442
+ )
443
+ try:
444
+ # One downscale for every backend: bounds remote upload size too, and
445
+ # keeps all models comparing the same pixels.
446
+ image = _prepare_image(image)
447
+ except Exception as exc: # noqa: BLE001 β€” unreadable/corrupt upload
448
+ return _extract_error(
449
+ f"Could not read the input image: {type(exc).__name__}: {exc}"
450
+ )
451
+
452
+ prompt = extract_arena.build_prompt(fields, answer_format.strip() or None)
453
+ multi = len(fields) > 1
454
+ image_path = Path(image)
455
+
456
+ results: dict[str, dict] = {}
457
+ local = [m for m in models if m in MODELS]
458
+ for name in (m for m in models if m not in MODELS): # remote β€” unbilled
459
+ logger.info("[{}] prompt: {!r}", name, prompt)
460
+ t0 = time.perf_counter()
461
+ try:
462
+ raw = extract_arena.MODELS[name].fn(image_path, prompt)
463
+ results[name] = _to_extract_result(
464
+ name, raw, round(time.perf_counter() - t0, 2), multi
465
+ )
466
+ logger.info("[{}] done in {}s", name, results[name]["latency_s"])
467
+ except Exception as exc: # noqa: BLE001 β€” isolate failures per model
468
+ logger.exception("[{}] failed", name) # incl. missing HF_TOKEN
469
+ results[name] = _to_extract_result(
470
+ name,
471
+ "",
472
+ round(time.perf_counter() - t0, 2),
473
+ multi,
474
+ error=f"{type(exc).__name__}: {exc}",
475
+ )
476
+ if local: # resident members β€” one billed GPU window for the whole subset
477
+ for r in _gpu_run_council(image, prompt, local, "", 0.0, 2048):
478
+ results[r["model"]] = _to_extract_result(
479
+ r["model"], r["text"], r["latency_s"], multi, error=r["error"]
480
+ )
481
+ return [results[name] for name in models]
482
+
483
+
484
+ _EXTRACT_ROSTER_DOCS = {
485
+ "moondream3": "moondream/moondream3-preview via the GF-John/moondream-pointer Space β€” no token needed, but its ZeroGPU quota can exhaust",
486
+ "qwen3.5": "Qwen/Qwen3.5-9B, hosted β€” most reliable remote entry; strong general VLM",
487
+ "muse-glimmer": "meta-models/Muse-Glimmer-30B, hosted β€” 86.6% Roboflow Data Extraction, the best open <=40B on the closest proxy benchmark for this task",
488
+ "gemma4-31b": "google/gemma-4-31B-it, hosted β€” fastest remote observed; best hosted availability; extraction quality unbenchmarked",
489
+ "gemma4-26b-a4b": "google/gemma-4-26B-A4B-it, hosted β€” cheapest hosted option; 3.8B-active MoE sibling of the 31B",
490
+ **_ROSTER_DOCS,
491
+ }
492
+ extract_fields.__doc__ = f"""Extract the value(s) of named fields from an image with several vision-language models (VLMs) and compare their answers side by side.
493
+
494
+ Builds one extraction prompt from the field names β€” with an abstention guardrail instructing
495
+ each model to answer exactly "N/A" rather than guess when a field is not visible β€” and runs it
496
+ through every selected model: hosted models via HF Inference Providers or a helper Space, local
497
+ council members on this Space's GPU. Available models:
498
+ {"; ".join(f'"{m}" ({_EXTRACT_ROSTER_DOCS.get(m, m)})' for m in EXTRACT_ROSTER)}.
499
+ If "models" is omitted or empty, the defaults ({", ".join(f'"{m}"' for m in DEFAULT_EXTRACT_MODELS)}) run;
500
+ at most {MAX_EXTRACT_MODELS} models may be selected per request. Hosted models need an HF_TOKEN
501
+ configured on the server; without it they fail per-model while the rest still run. To decide
502
+ which models fit your task, read the maintained model research notes (benchmarks, strengths,
503
+ quirks per member) at {MODEL_RESEARCH_URLS}.
504
+ Inputs larger than {MAX_IMAGE_PIXELS / 1e6:.1f} megapixels are downscaled (aspect preserved)
505
+ before inference, so very small text in very large images may need pre-cropping by the caller.
506
+ The output is a JSON list with one object per requested model, in request order. Each object
507
+ has: "model" (the model name, or null when the request itself was invalid); "value" (single-field
508
+ requests: the normalized answer, "" meaning the model abstained/field not found, null on
509
+ multi-field requests or failure); "values" (multi-field requests: an object mapping each field
510
+ name to its normalized value with "" for absent fields, or null if the reply didn't parse as
511
+ JSON β€” check "raw" then); "raw" (the model's verbatim reply, kept for debugging); "latency_s"
512
+ (seconds, float); and "error" (null on success, otherwise a message β€” per-model failures don't
513
+ abort the other models).
514
+
515
+ Args:
516
+ image: Filepath or URL of the input image to extract from.
517
+ fields: List of field names to extract, e.g. ["bib number"] or ["bib number", "race name"]; single-field requests are the most reliable regime.
518
+ models: List of model names to run (see the description for the roster); empty or omitted runs the defaults.
519
+ answer_format: Optional format directive appended verbatim to the prompt, e.g. "Format the answer as a continuous sequence of digits (e.g., 12345)."; with multiple fields, phrase it per value; empty string means none.
520
+ """
521
+
522
+
523
+ council_iface = gr.Interface(
524
  fn=council_query,
525
  inputs=[
526
  gr.Image(type="filepath", label="Input Image"),
 
543
  gr.Slider(label="Max Tokens", value=1024, minimum=32, maximum=4096, step=32),
544
  ],
545
  outputs=gr.JSON(label="Council Results"),
 
546
  description=(
547
  "Run the same image + prompt through small (~1-9B) open vision-language models and "
548
  "compare answers β€” data extraction or vanilla VQA. Model choices, benchmarks, and "
 
552
  api_name="council_query",
553
  api_visibility="public",
554
  )
555
+
556
+ extract_iface = gr.Interface(
557
+ fn=extract_fields,
558
+ inputs=[
559
+ gr.Image(type="filepath", label="Input Image"),
560
+ gr.Dropdown(
561
+ label="Fields",
562
+ multiselect=True,
563
+ allow_custom_value=True,
564
+ choices=["bib number", "race name"],
565
+ value=["bib number"],
566
+ info="Field name(s) to extract β€” type your own and press Enter; one field per request is the most reliable",
567
+ ),
568
+ gr.CheckboxGroup(
569
+ label="Models",
570
+ choices=EXTRACT_ROSTER,
571
+ value=DEFAULT_EXTRACT_MODELS,
572
+ info=f"Select up to {MAX_EXTRACT_MODELS} models; see MODEL_RESEARCH.md for how to choose",
573
+ ),
574
+ gr.Textbox(
575
+ label="Format Directive",
576
+ lines=1,
577
+ value="",
578
+ info='Optional; appended verbatim to the prompt, e.g. "Format the answer as a continuous sequence of digits (e.g., 12345)."',
579
+ ),
580
+ ],
581
+ outputs=gr.JSON(label="Extraction Results"),
582
+ description=(
583
+ "Ask several VLMs β€” hosted and local β€” for the value of one or more named fields in an "
584
+ 'image, with an abstention guardrail (models answer "N/A", normalized to "", instead '
585
+ "of guessing when a field is absent). Model choices, benchmarks, and quirks are "
586
+ "documented in "
587
+ "[MODEL_RESEARCH.md](https://huggingface.co/spaces/GF-John/svlm-council/raw/main/MODEL_RESEARCH.md)."
588
+ ),
589
+ api_name="extract_fields",
590
+ api_visibility="public",
591
+ )
592
+
593
+ app = gr.TabbedInterface(
594
+ [council_iface, extract_iface],
595
+ ["Council", "ExtractArena"],
596
+ title="sVLM Council",
597
+ )
598
  app.launch(mcp_server=True, app_kwargs={"docs_url": "/docs"})
pyproject.toml CHANGED
@@ -16,4 +16,6 @@ dependencies = [
16
  "pillow>=12.2",
17
  "loguru>=0.7.3",
18
  "typer>=0.12",
 
 
19
  ]
 
16
  "pillow>=12.2",
17
  "loguru>=0.7.3",
18
  "typer>=0.12",
19
+ "huggingface-hub>=0.34",
20
+ "gradio-client>=1.4",
21
  ]
uv.lock CHANGED
@@ -680,6 +680,8 @@ source = { virtual = "." }
680
  dependencies = [
681
  { name = "accelerate" },
682
  { name = "gradio", extra = ["mcp"] },
 
 
683
  { name = "loguru" },
684
  { name = "peft" },
685
  { name = "pillow" },
@@ -694,6 +696,8 @@ dependencies = [
694
  requires-dist = [
695
  { name = "accelerate", specifier = ">=1.0" },
696
  { name = "gradio", extras = ["mcp"], specifier = ">=5.38.0" },
 
 
697
  { name = "loguru", specifier = ">=0.7.3" },
698
  { name = "peft", specifier = ">=0.19.1" },
699
  { name = "pillow", specifier = ">=12.2" },
 
680
  dependencies = [
681
  { name = "accelerate" },
682
  { name = "gradio", extra = ["mcp"] },
683
+ { name = "gradio-client" },
684
+ { name = "huggingface-hub" },
685
  { name = "loguru" },
686
  { name = "peft" },
687
  { name = "pillow" },
 
696
  requires-dist = [
697
  { name = "accelerate", specifier = ">=1.0" },
698
  { name = "gradio", extras = ["mcp"], specifier = ">=5.38.0" },
699
+ { name = "gradio-client", specifier = ">=1.4" },
700
+ { name = "huggingface-hub", specifier = ">=0.34" },
701
  { name = "loguru", specifier = ">=0.7.3" },
702
  { name = "peft", specifier = ">=0.19.1" },
703
  { name = "pillow", specifier = ">=12.2" },