Spaces:
Running on Zero
Add ExtractArena tab to the Space app
Browse filesapp.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>
- CLAUDE.md +4 -2
- README.md +10 -4
- app.py +238 -5
- pyproject.toml +2 -0
- uv.lock +4 -0
|
@@ -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 |
-
-
|
| 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`
|
|
|
|
|
|
|
| 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).
|
|
@@ -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
|
| 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
|
| 18 |
-
the
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"})
|
|
@@ -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 |
]
|
|
@@ -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" },
|