davanstrien HF Staff commited on
Commit
73b3e93
·
verified ·
1 Parent(s): ce717c3

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -5,10 +5,9 @@ WORKDIR /app
5
 
6
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
7
 
8
- COPY --chown=user pyproject.toml README.md ./
9
- COPY --chown=user src/ ./src/
10
 
11
- RUN uv pip install --system --no-cache ".[viewer]"
12
 
13
  USER user
14
  ENV HOME=/home/user \
@@ -18,4 +17,4 @@ ENV HOME=/home/user \
18
  ENV REPOS="davanstrien/bpl-ocr-bench-results"
19
 
20
  EXPOSE 7860
21
- CMD ["python", "-m", "ocr_bench.space"]
 
5
 
6
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
7
 
8
+ RUN uv pip install --system --no-cache "ocr-bench[viewer] @ git+https://github.com/davanstrien/ocr-bench.git"
 
9
 
10
+ COPY --chown=user space.py ./
11
 
12
  USER user
13
  ENV HOME=/home/user \
 
17
  ENV REPOS="davanstrien/bpl-ocr-bench-results"
18
 
19
  EXPOSE 7860
20
+ CMD ["python", "space.py"]
README.md CHANGED
@@ -11,83 +11,12 @@ tags:
11
  - vlm-judge
12
  ---
13
 
14
- # ocr-bench
15
 
16
- **There is no single best OCR model.** Rankings change depending on your documents — manuscript cards, printed books, and historical texts all produce different winners.
17
 
18
- ocr-bench creates **per-collection leaderboards** using a VLM-as-judge approach, so you can find what works best for *your* documents rather than relying on generic benchmarks.
19
 
20
- ## Why?
21
 
22
- Generic OCR benchmarks tell you which model wins *on average*. But if you're digitising 18th-century encyclopaedias, that average doesn't help — the best model for your documents might be the worst on someone else's.
23
-
24
- ocr-bench lets you run the same set of OCR models on a sample of *your* collection, then uses a vision-language model to judge which produces the best transcription for each document. The result is a leaderboard specific to your data.
25
-
26
- | Model | BPL card catalog | Britannica 1771 |
27
- |-------|:---:|:---:|
28
- | LightOnOCR-2 (1B) | **#1** | **#1** (1788) |
29
- | GLM-OCR (0.9B) | #4 | #2 (1757) |
30
- | DeepSeek-OCR (4B) | #3 | #4 (1429) |
31
- | dots.ocr (1.7B) | #2 | #5 (972) |
32
-
33
- Rankings flip completely between collections. The model that's #2 on BPL cards is dead last on Britannica.
34
-
35
- ## Hub-native by design
36
-
37
- The entire evaluation loop lives on the Hugging Face Hub:
38
-
39
- 1. **Your dataset** on the Hub (images + optional ground truth)
40
- 2. **OCR models** run via [HF Jobs](https://huggingface.co/docs/hub/jobs) → outputs written as PRs on a Hub dataset
41
- 3. **VLM judge** via [HF Inference Providers](https://huggingface.co/docs/inference-providers) — only needs an HF token
42
- 4. **Results** published to a Hub dataset (leaderboard + pairwise comparisons)
43
- 5. **Viewer** as a [HF Space](https://huggingface.co/spaces) for browsing and human validation
44
-
45
- No third-party API keys. No local GPU required. Everything is shareable via Hub URLs.
46
-
47
- ## Quickstart
48
-
49
- ```bash
50
- pip install ocr-bench[viewer]
51
-
52
- # 1. Run OCR models on your dataset
53
- ocr-bench run <input-dataset> <output-repo> --max-samples 50
54
-
55
- # 2. Judge outputs pairwise with a VLM
56
- ocr-bench judge <output-repo>
57
-
58
- # 3. Browse results + validate
59
- ocr-bench view <output-repo>-results
60
- ```
61
-
62
- ## How it works
63
-
64
- **`ocr-bench run`** launches OCR models on your dataset via [HF Jobs](https://huggingface.co/docs/hub/jobs). Each model writes its output as a PR on the same Hub dataset, keeping everything together without merge conflicts.
65
-
66
- **`ocr-bench judge`** runs pairwise comparisons using a VLM judge (default: [Qwen3.5-35B-A3B](https://huggingface.co/Qwen/Qwen3.5-35B-A3B) via HF Inference Providers). For each document, the judge sees the original image and two OCR outputs (anonymised as A/B) and picks the better transcription. Results are fit to a [Bradley-Terry model](https://en.wikipedia.org/wiki/Bradley%E2%80%93Terry_model) to produce ELO ratings with bootstrap 95% confidence intervals. Adaptive stopping halts early when rankings are statistically resolved.
67
-
68
- **`ocr-bench view`** serves a local web viewer with a leaderboard, comparison browser, and human validation. Vote on comparisons to cross-check the automated judge with human judgement.
69
-
70
- ## Example results
71
-
72
- Browse these on the Hub:
73
- - [davanstrien/ocr-bench-britannica-results](https://huggingface.co/datasets/davanstrien/ocr-bench-britannica-results) — Encyclopaedia Britannica 1771, 5 models, 50 samples
74
- - [davanstrien/bpl-ocr-bench-results](https://huggingface.co/datasets/davanstrien/bpl-ocr-bench-results) — Boston Public Library card catalog, 4 models, 150 samples
75
-
76
- ## Install
77
-
78
- ```bash
79
- pip install ocr-bench # Core (run + judge)
80
- pip install ocr-bench[viewer] # With web UI
81
- ```
82
-
83
- Or with [uv](https://docs.astral.sh/uv/):
84
-
85
- ```bash
86
- uv pip install ocr-bench[viewer]
87
- ```
88
-
89
- Requires Python >= 3.11 and an [HF token](https://huggingface.co/settings/tokens).
90
-
91
- ## Status
92
-
93
- Working proof of concept. The core pipeline (run → judge → view) is functional. Not polished production software — expect rough edges.
 
11
  - vlm-judge
12
  ---
13
 
14
+ # OCR Bench Viewer
15
 
16
+ Browse OCR model evaluation results with per-dataset leaderboards.
17
 
18
+ Rankings change by document type manuscript cards, printed books, historical texts all produce different winners.
19
 
20
+ Keyboard shortcuts: arrow keys to navigate, `a`/`b`/`t` to vote, `r` to reveal judge verdict.
21
 
22
+ Source: [github.com/davanstrien/ocr-bench](https://github.com/davanstrien/ocr-bench)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
project-README.md DELETED
File without changes
pyproject.toml DELETED
@@ -1,49 +0,0 @@
1
- [project]
2
- name = "ocr-bench"
3
- version = "0.1.0"
4
- description = "OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards"
5
- readme = "README.md"
6
- authors = [
7
- { name = "Daniel van Strien", email = "davanstrien@gmail.com" }
8
- ]
9
- requires-python = ">=3.11"
10
- license = "Apache-2.0"
11
- dependencies = [
12
- "datasets>=4.0.0",
13
- "huggingface-hub",
14
- "numpy",
15
- "openai",
16
- "pillow",
17
- "rich",
18
- "scipy",
19
- "stamina",
20
- "structlog",
21
- "tqdm",
22
- ]
23
-
24
- [project.urls]
25
- Homepage = "https://github.com/davanstrien/ocr-bench"
26
- Repository = "https://github.com/davanstrien/ocr-bench"
27
- Demo = "https://huggingface.co/spaces/davanstrien/ocr-bench-viewer"
28
-
29
- [project.scripts]
30
- ocr-bench = "ocr_bench.cli:main"
31
-
32
- [project.optional-dependencies]
33
- viewer = ["fastapi>=0.115", "uvicorn[standard]>=0.32", "jinja2>=3.1", "python-multipart>=0.0.9"]
34
-
35
- [build-system]
36
- requires = ["uv_build>=0.9.27,<0.10.0"]
37
- build-backend = "uv_build"
38
-
39
- [tool.ruff]
40
- line-length = 100
41
-
42
- [tool.ruff.lint]
43
- select = ["E", "F", "I", "UP"]
44
-
45
- [dependency-groups]
46
- dev = [
47
- "pytest>=9.0.2",
48
- "ty>=0.0.17",
49
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/space.py → space.py RENAMED
File without changes
src/ocr_bench/__init__.py DELETED
@@ -1,3 +0,0 @@
1
- """OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards."""
2
-
3
- __version__ = "0.1.0"
 
 
 
 
src/ocr_bench/__pycache__/__init__.cpython-311.pyc DELETED
Binary file (302 Bytes)
 
src/ocr_bench/__pycache__/backends.cpython-311.pyc DELETED
Binary file (11.8 kB)
 
src/ocr_bench/__pycache__/cli.cpython-311.pyc DELETED
Binary file (31.9 kB)
 
src/ocr_bench/__pycache__/dataset.cpython-311.pyc DELETED
Binary file (12.1 kB)
 
src/ocr_bench/__pycache__/elo.cpython-311.pyc DELETED
Binary file (16 kB)
 
src/ocr_bench/__pycache__/judge.cpython-311.pyc DELETED
Binary file (12.8 kB)
 
src/ocr_bench/__pycache__/publish.cpython-311.pyc DELETED
Binary file (13.6 kB)
 
src/ocr_bench/__pycache__/run.cpython-311.pyc DELETED
Binary file (7.81 kB)
 
src/ocr_bench/__pycache__/validate.cpython-311.pyc DELETED
Binary file (15.9 kB)
 
src/ocr_bench/__pycache__/viewer.cpython-311.pyc DELETED
Binary file (10.6 kB)
 
src/ocr_bench/__pycache__/web.cpython-311.pyc DELETED
Binary file (23.8 kB)
 
src/ocr_bench/backends.py DELETED
@@ -1,238 +0,0 @@
1
- """Judge backends — API-based (HF Inference Providers, OpenAI-compatible)."""
2
-
3
- from __future__ import annotations
4
-
5
- import abc
6
- from collections import Counter
7
- from concurrent.futures import ThreadPoolExecutor, as_completed
8
- from typing import Any
9
-
10
- import stamina
11
- import structlog
12
- from huggingface_hub import InferenceClient
13
- from openai import OpenAI
14
-
15
- from ocr_bench.judge import JUDGE_SCHEMA, Comparison, parse_judge_output
16
-
17
- logger = structlog.get_logger()
18
-
19
- # Retry on these exception types with exponential backoff + jitter.
20
- _RETRYABLE = (Exception,)
21
-
22
-
23
- class JudgeBackend(abc.ABC):
24
- """Base class for judge backends."""
25
-
26
- name: str
27
- concurrency: int = 1
28
-
29
- @abc.abstractmethod
30
- def _call_single(self, comp: Comparison) -> dict[str, str]:
31
- """Run the judge on a single comparison."""
32
-
33
- def judge(self, comparisons: list[Comparison]) -> list[dict[str, str]]:
34
- """Run the judge on a list of comparisons (concurrently if supported).
35
-
36
- Returns a list of parsed results (one per comparison).
37
- Each result is a dict with ``winner`` and ``reason`` keys,
38
- or an empty dict on failure.
39
- """
40
- if self.concurrency <= 1 or len(comparisons) <= 1:
41
- return [self._call_single(comp) for comp in comparisons]
42
-
43
- # Concurrent execution preserving order
44
- results: list[dict[str, str]] = [{}] * len(comparisons)
45
- with ThreadPoolExecutor(max_workers=self.concurrency) as pool:
46
- future_to_idx = {
47
- pool.submit(self._call_single, comp): i
48
- for i, comp in enumerate(comparisons)
49
- }
50
- for future in as_completed(future_to_idx):
51
- idx = future_to_idx[future]
52
- try:
53
- results[idx] = future.result()
54
- except Exception as exc:
55
- logger.warning("judge_call_failed", idx=idx, error=str(exc))
56
- results[idx] = {}
57
- return results
58
-
59
-
60
- DEFAULT_MAX_TOKENS = 1024
61
-
62
-
63
- class InferenceProviderJudge(JudgeBackend):
64
- """HF Inference Providers backend (Novita, Together, etc.)."""
65
-
66
- def __init__(
67
- self, model: str, provider: str | None = None, max_tokens: int = DEFAULT_MAX_TOKENS,
68
- ):
69
- self.name = f"{provider + ':' if provider else ''}{model}"
70
- self.model = model
71
- self.max_tokens = max_tokens
72
- self.client = InferenceClient(model=model, provider=provider) # type: ignore[invalid-argument-type]
73
-
74
- @stamina.retry(on=_RETRYABLE, attempts=6)
75
- def _call_single(self, comp: Comparison) -> dict[str, str]:
76
- response = self.client.chat_completion( # type: ignore[no-matching-overload]
77
- messages=comp.messages,
78
- max_tokens=self.max_tokens,
79
- temperature=0.0,
80
- response_format={"type": "json_object"},
81
- extra_body={"chat_template_kwargs": {"enable_thinking": False}},
82
- )
83
- raw = response.choices[0].message.content.strip()
84
- result = parse_judge_output(raw)
85
- if not result:
86
- logger.warning("empty_parse", backend=self.name, sample=comp.sample_idx)
87
- return result
88
-
89
-
90
- class OpenAICompatibleJudge(JudgeBackend):
91
- """OpenAI-compatible endpoint (local vLLM server, Ollama, HF IE, etc.)."""
92
-
93
- def __init__(
94
- self,
95
- base_url: str,
96
- model: str = "default",
97
- max_tokens: int = DEFAULT_MAX_TOKENS,
98
- api_key: str = "not-needed",
99
- extra_body: dict | None = None,
100
- temperature: float = 0.0,
101
- concurrency: int = 1,
102
- ):
103
- self.name = model if model != "default" else f"openai@{base_url}"
104
- self.model = model
105
- self.max_tokens = max_tokens
106
- self.temperature = temperature
107
- self.extra_body = extra_body if extra_body is not None else {"guided_json": JUDGE_SCHEMA}
108
- self.concurrency = concurrency
109
- self.client = OpenAI(base_url=base_url, api_key=api_key)
110
-
111
- @stamina.retry(on=_RETRYABLE, attempts=3)
112
- def _call_single(self, comp: Comparison) -> dict[str, str]:
113
- response = self.client.chat.completions.create(
114
- model=self.model,
115
- messages=comp.messages, # type: ignore[invalid-argument-type]
116
- max_tokens=self.max_tokens,
117
- temperature=self.temperature,
118
- extra_body=self.extra_body,
119
- )
120
- raw = response.choices[0].message.content.strip()
121
- result = parse_judge_output(raw)
122
- if not result:
123
- logger.warning("empty_parse", backend=self.name, sample=comp.sample_idx)
124
- return result
125
-
126
-
127
- # ---------------------------------------------------------------------------
128
- # Spec parsing
129
- # ---------------------------------------------------------------------------
130
-
131
- DEFAULT_JUDGE = "novita:Qwen/Qwen3.5-35B-A3B"
132
-
133
-
134
- def parse_judge_spec(
135
- spec: str, max_tokens: int = DEFAULT_MAX_TOKENS, concurrency: int = 1,
136
- ) -> JudgeBackend:
137
- """Parse a judge specification string into a backend.
138
-
139
- Formats:
140
- - ``"https://xxx.endpoints.huggingface.cloud"`` → :class:`OpenAICompatibleJudge`
141
- (HF Inference Endpoints, OpenAI-compatible with HF token auth)
142
- - ``"http://..."`` or ``"https://..."`` (other) → :class:`OpenAICompatibleJudge`
143
- - ``"provider:org/model"`` (colon before first ``/``) → :class:`InferenceProviderJudge`
144
- - anything else → :class:`InferenceProviderJudge` (no provider)
145
- """
146
- if spec.startswith("http://") or spec.startswith("https://"):
147
- # Check for url:model format (e.g. https://...cloud/v1/:org/model)
148
- url_part = spec
149
- model_name = "default"
150
- # Split on /v1/: to separate URL from model name
151
- if "/v1/:" in spec:
152
- url_part, model_name = spec.split("/v1/:", 1)
153
- url_part += "/v1"
154
-
155
- # HF Inference Endpoints — OpenAI-compatible, auth via HF token
156
- if ".endpoints.huggingface." in url_part:
157
- from huggingface_hub import get_token
158
-
159
- base_url = url_part.rstrip("/")
160
- if not base_url.endswith("/v1"):
161
- base_url += "/v1"
162
- token = get_token() or "not-needed"
163
- return OpenAICompatibleJudge(
164
- base_url=base_url,
165
- model=model_name,
166
- api_key=token,
167
- max_tokens=max_tokens,
168
- temperature=0.7,
169
- extra_body={"chat_template_kwargs": {"enable_thinking": False}},
170
- concurrency=concurrency,
171
- )
172
- return OpenAICompatibleJudge(
173
- base_url=url_part, model=model_name, max_tokens=max_tokens,
174
- concurrency=concurrency,
175
- )
176
-
177
- if ":" in spec:
178
- # provider:model format — colon must come before first slash
179
- colon_idx = spec.index(":")
180
- slash_idx = spec.find("/")
181
- if slash_idx == -1 or colon_idx < slash_idx:
182
- provider, model = spec.split(":", 1)
183
- return InferenceProviderJudge(model=model, provider=provider, max_tokens=max_tokens)
184
-
185
- return InferenceProviderJudge(model=spec, max_tokens=max_tokens)
186
-
187
-
188
- # ---------------------------------------------------------------------------
189
- # Jury aggregation
190
- # ---------------------------------------------------------------------------
191
-
192
-
193
- def aggregate_jury_votes(
194
- all_results: list[list[dict[str, str]]],
195
- judge_names: list[str],
196
- ) -> list[dict[str, Any]]:
197
- """Aggregate votes from multiple judges using majority voting.
198
-
199
- Args:
200
- all_results: List of result lists, one per judge. Each inner list
201
- has one dict per comparison.
202
- judge_names: Names of the judges (same order as *all_results*).
203
-
204
- Returns:
205
- Aggregated results with ``winner``, ``reason``, and ``agreement`` fields.
206
- """
207
- if not all_results:
208
- return []
209
-
210
- n_comparisons = len(all_results[0])
211
- n_judges = len(all_results)
212
- aggregated: list[dict[str, Any]] = []
213
-
214
- for i in range(n_comparisons):
215
- votes: list[str] = []
216
- reasons: list[str] = []
217
- for j in range(n_judges):
218
- result = all_results[j][i] if i < len(all_results[j]) else {}
219
- winner = result.get("winner", "")
220
- if winner:
221
- votes.append(winner)
222
- reasons.append(f"{judge_names[j]}: {result.get('reason', '')}")
223
-
224
- if not votes:
225
- aggregated.append({"winner": "tie", "reason": "no valid votes", "agreement": "0/0"})
226
- continue
227
-
228
- counter = Counter(votes)
229
- majority_winner, majority_count = counter.most_common(1)[0]
230
- agreement = f"{majority_count}/{len(votes)}"
231
-
232
- aggregated.append({
233
- "winner": majority_winner,
234
- "reason": "; ".join(reasons),
235
- "agreement": agreement,
236
- })
237
-
238
- return aggregated
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/cli.py DELETED
@@ -1,632 +0,0 @@
1
- """CLI entrypoint for ocr-bench."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import sys
7
-
8
- import structlog
9
- from rich.console import Console
10
- from rich.table import Table
11
-
12
- from ocr_bench.backends import (
13
- DEFAULT_JUDGE,
14
- DEFAULT_MAX_TOKENS,
15
- aggregate_jury_votes,
16
- parse_judge_spec,
17
- )
18
- from ocr_bench.dataset import (
19
- DatasetError,
20
- discover_configs,
21
- discover_pr_configs,
22
- load_config_dataset,
23
- load_flat_dataset,
24
- )
25
- from ocr_bench.elo import ComparisonResult, Leaderboard, compute_elo, rankings_resolved
26
- from ocr_bench.judge import Comparison, _normalize_pair, build_comparisons, sample_indices
27
- from ocr_bench.publish import (
28
- EvalMetadata,
29
- load_existing_comparisons,
30
- load_existing_metadata,
31
- publish_results,
32
- )
33
-
34
- logger = structlog.get_logger()
35
- console = Console()
36
-
37
-
38
- def build_parser() -> argparse.ArgumentParser:
39
- parser = argparse.ArgumentParser(
40
- prog="ocr-bench",
41
- description="OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards",
42
- )
43
- sub = parser.add_subparsers(dest="command")
44
-
45
- judge = sub.add_parser("judge", help="Run pairwise VLM judge on OCR outputs")
46
-
47
- # Dataset
48
- judge.add_argument("dataset", help="HF dataset repo id")
49
- judge.add_argument("--split", default="train", help="Dataset split (default: train)")
50
- judge.add_argument("--columns", nargs="+", default=None, help="Explicit OCR column names")
51
- judge.add_argument(
52
- "--configs", nargs="+", default=None, help="Config-per-model: list of config names"
53
- )
54
- judge.add_argument("--from-prs", action="store_true", help="Force PR-based config discovery")
55
- judge.add_argument(
56
- "--merge",
57
- action="store_true",
58
- help="Merge PRs to main after discovery (default: load via revision)",
59
- )
60
-
61
- # Judge
62
- judge.add_argument(
63
- "--model",
64
- action="append",
65
- dest="models",
66
- help=f"Judge model spec (repeatable for jury). Default: {DEFAULT_JUDGE}",
67
- )
68
-
69
- # Eval
70
- judge.add_argument("--max-samples", type=int, default=None, help="Max samples to evaluate")
71
- judge.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)")
72
- judge.add_argument(
73
- "--max-tokens",
74
- type=int,
75
- default=DEFAULT_MAX_TOKENS,
76
- help=f"Max tokens for judge response (default: {DEFAULT_MAX_TOKENS})",
77
- )
78
-
79
- # Output
80
- judge.add_argument(
81
- "--save-results",
82
- default=None,
83
- help="HF repo id to publish results to (default: {dataset}-results)",
84
- )
85
- judge.add_argument(
86
- "--no-publish",
87
- action="store_true",
88
- help="Don't publish results (default: publish to {dataset}-results)",
89
- )
90
- judge.add_argument(
91
- "--full-rejudge",
92
- action="store_true",
93
- help="Re-judge all pairs, ignoring existing comparisons in --save-results repo",
94
- )
95
- judge.add_argument(
96
- "--no-adaptive",
97
- action="store_true",
98
- help="Disable adaptive stopping (default: adaptive is on)",
99
- )
100
- judge.add_argument(
101
- "--concurrency",
102
- type=int,
103
- default=1,
104
- help="Number of concurrent judge API calls (default: 1)",
105
- )
106
-
107
- # --- run subcommand ---
108
- run = sub.add_parser("run", help="Launch OCR models on a dataset via HF Jobs")
109
- run.add_argument("input_dataset", help="HF dataset repo id with images")
110
- run.add_argument("output_repo", help="Output dataset repo (all models push here)")
111
- run.add_argument(
112
- "--models", nargs="+", default=None, help="Model slugs to run (default: all 4 core)"
113
- )
114
- run.add_argument("--max-samples", type=int, default=None, help="Per-model sample limit")
115
- run.add_argument("--split", default="train", help="Dataset split (default: train)")
116
- run.add_argument("--flavor", default=None, help="Override GPU flavor for all models")
117
- run.add_argument("--timeout", default="4h", help="Per-job timeout (default: 4h)")
118
- run.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)")
119
- run.add_argument("--shuffle", action="store_true", help="Shuffle source dataset")
120
- run.add_argument("--list-models", action="store_true", help="Print available models and exit")
121
- run.add_argument(
122
- "--dry-run", action="store_true", help="Show what would launch without launching"
123
- )
124
- run.add_argument(
125
- "--no-wait", action="store_true", help="Launch and exit without polling (default: wait)"
126
- )
127
-
128
- # --- view subcommand ---
129
- view = sub.add_parser("view", help="Browse and validate results in a web UI")
130
- view.add_argument("results", help="HF dataset repo id with published results")
131
- view.add_argument("--port", type=int, default=7860, help="Port (default: 7860)")
132
- view.add_argument("--host", default="127.0.0.1", help="Host (default: 127.0.0.1)")
133
- view.add_argument("--output", default=None, help="Path to save annotations JSON")
134
-
135
- # --- publish subcommand ---
136
- publish = sub.add_parser("publish", help="Deploy results viewer as a Hugging Face Space")
137
- publish.add_argument("results", help="HF results dataset repo id to view in the Space")
138
- publish.add_argument(
139
- "--space", default=None, help="Space repo id (default: {results}-viewer)"
140
- )
141
- publish.add_argument("--private", action="store_true", help="Make the Space private")
142
-
143
- return parser
144
-
145
-
146
- def print_leaderboard(board: Leaderboard) -> None:
147
- """Print leaderboard as a Rich table."""
148
- from ocr_bench.publish import _get_model_sizes
149
-
150
- sizes = _get_model_sizes()
151
- table = Table(title="OCR Model Leaderboard")
152
- table.add_column("Rank", style="bold")
153
- table.add_column("Model")
154
- table.add_column("Params", justify="right")
155
- has_ci = bool(board.elo_ci)
156
- if has_ci:
157
- table.add_column("ELO (95% CI)", justify="right")
158
- else:
159
- table.add_column("ELO", justify="right")
160
- table.add_column("Wins", justify="right")
161
- table.add_column("Losses", justify="right")
162
- table.add_column("Ties", justify="right")
163
- table.add_column("Win%", justify="right")
164
-
165
- for rank, (model, elo) in enumerate(board.ranked, 1):
166
- pct = board.win_pct(model)
167
- pct_str = f"{pct:.0f}%" if pct is not None else "-"
168
- if has_ci and model in board.elo_ci:
169
- lo, hi = board.elo_ci[model]
170
- elo_str = f"{round(elo)} ({round(lo)}\u2013{round(hi)})"
171
- else:
172
- elo_str = str(round(elo))
173
- table.add_row(
174
- str(rank),
175
- model,
176
- sizes.get(model, ""),
177
- elo_str,
178
- str(board.wins[model]),
179
- str(board.losses[model]),
180
- str(board.ties[model]),
181
- pct_str,
182
- )
183
-
184
- console.print(table)
185
-
186
-
187
- def _convert_results(
188
- comparisons: list[Comparison], aggregated: list[dict]
189
- ) -> list[ComparisonResult]:
190
- """Convert judged comparisons + aggregated outputs into ComparisonResult list."""
191
- results: list[ComparisonResult] = []
192
- for comp, result in zip(comparisons, aggregated):
193
- if not result:
194
- continue
195
- results.append(
196
- ComparisonResult(
197
- sample_idx=comp.sample_idx,
198
- model_a=comp.model_a,
199
- model_b=comp.model_b,
200
- winner=result.get("winner", "tie"),
201
- reason=result.get("reason", ""),
202
- agreement=result.get("agreement", "1/1"),
203
- swapped=comp.swapped,
204
- text_a=comp.text_a,
205
- text_b=comp.text_b,
206
- col_a=comp.col_a,
207
- col_b=comp.col_b,
208
- )
209
- )
210
- return results
211
-
212
-
213
- def _resolve_results_repo(dataset: str, save_results: str | None, no_publish: bool) -> str | None:
214
- """Derive the results repo id. Returns None if publishing is disabled."""
215
- if no_publish:
216
- return None
217
- if save_results:
218
- return save_results
219
- return f"{dataset}-results"
220
-
221
-
222
- def cmd_judge(args: argparse.Namespace) -> None:
223
- """Orchestrate: load → compare → judge → elo → print → publish."""
224
- # --- Resolve flags ---
225
- adaptive = not args.no_adaptive
226
- merge = args.merge
227
- results_repo = _resolve_results_repo(args.dataset, args.save_results, args.no_publish)
228
- from_prs = False # track for metadata
229
-
230
- if results_repo:
231
- console.print(f"Results will be published to [bold]{results_repo}[/bold]")
232
-
233
- # --- Load dataset (cascading auto-detection) ---
234
- if args.configs:
235
- # Explicit configs — use them directly
236
- config_names = args.configs
237
- ds, ocr_columns = load_config_dataset(args.dataset, config_names, split=args.split)
238
- elif args.columns:
239
- # Explicit columns — flat loading
240
- ds, ocr_columns = load_flat_dataset(args.dataset, split=args.split, columns=args.columns)
241
- elif args.from_prs:
242
- # Forced PR discovery
243
- config_names, pr_revisions = discover_pr_configs(args.dataset, merge=merge)
244
- if not config_names:
245
- raise DatasetError("No configs found in open PRs")
246
- from_prs = True
247
- console.print(f"Discovered {len(config_names)} configs from PRs: {config_names}")
248
- ds, ocr_columns = load_config_dataset(
249
- args.dataset,
250
- config_names,
251
- split=args.split,
252
- pr_revisions=pr_revisions if not merge else None,
253
- )
254
- else:
255
- # Auto-detect: PRs + main branch configs combined, fall back to flat
256
- pr_configs, pr_revisions = discover_pr_configs(args.dataset, merge=merge)
257
- main_configs = discover_configs(args.dataset)
258
-
259
- # Combine: PR configs + main configs not already in PRs
260
- config_names = list(pr_configs)
261
- for mc in main_configs:
262
- if mc not in pr_configs:
263
- config_names.append(mc)
264
-
265
- if config_names:
266
- if pr_configs:
267
- from_prs = True
268
- console.print(f"Auto-detected {len(pr_configs)} configs from PRs: {pr_configs}")
269
- if main_configs:
270
- main_only = [c for c in main_configs if c not in pr_configs]
271
- if main_only:
272
- console.print(f"Auto-detected {len(main_only)} configs on main: {main_only}")
273
- ds, ocr_columns = load_config_dataset(
274
- args.dataset,
275
- config_names,
276
- split=args.split,
277
- pr_revisions=pr_revisions if pr_configs else None,
278
- )
279
- else:
280
- # No configs anywhere — fall back to flat loading
281
- ds, ocr_columns = load_flat_dataset(args.dataset, split=args.split)
282
-
283
- console.print(f"Loaded {len(ds)} samples with {len(ocr_columns)} models:")
284
- for col, model in ocr_columns.items():
285
- console.print(f" {col} → {model}")
286
-
287
- # --- Incremental: load existing comparisons ---
288
- existing_results: list[ComparisonResult] = []
289
- existing_meta_rows: list[dict] = []
290
- skip_pairs: set[tuple[str, str]] | None = None
291
-
292
- if results_repo and not args.full_rejudge:
293
- existing_results = load_existing_comparisons(results_repo)
294
- if existing_results:
295
- judged_pairs = {_normalize_pair(r.model_a, r.model_b) for r in existing_results}
296
- skip_pairs = judged_pairs
297
- console.print(
298
- f"\nIncremental mode: {len(existing_results)} existing comparisons "
299
- f"across {len(judged_pairs)} model pairs — skipping those."
300
- )
301
- existing_meta_rows = load_existing_metadata(results_repo)
302
- else:
303
- console.print("\nNo existing comparisons found — full judge run.")
304
-
305
- model_names = list(set(ocr_columns.values()))
306
-
307
- # --- Judge setup (shared by both paths) ---
308
- model_specs = args.models or [DEFAULT_JUDGE]
309
- judges = [
310
- parse_judge_spec(spec, max_tokens=args.max_tokens, concurrency=args.concurrency)
311
- for spec in model_specs
312
- ]
313
- is_jury = len(judges) > 1
314
-
315
- def _judge_batch(batch_comps: list[Comparison]) -> list[ComparisonResult]:
316
- """Run judge(s) on a batch of comparisons and return ComparisonResults."""
317
- all_judge_outputs: list[list[dict]] = []
318
- for judge in judges:
319
- results = judge.judge(batch_comps)
320
- all_judge_outputs.append(results)
321
- if is_jury:
322
- judge_names = [j.name for j in judges]
323
- aggregated = aggregate_jury_votes(all_judge_outputs, judge_names)
324
- else:
325
- aggregated = all_judge_outputs[0]
326
- return _convert_results(batch_comps, aggregated)
327
-
328
- if adaptive:
329
- # --- Adaptive stopping: batch-by-batch with convergence check ---
330
- from itertools import combinations as _combs
331
-
332
- all_indices = sample_indices(len(ds), args.max_samples, args.seed)
333
- n_pairs = len(list(_combs(model_names, 2)))
334
- batch_samples = 5
335
- min_before_check = max(3 * n_pairs, 20)
336
-
337
- if is_jury:
338
- console.print(f"\nJury mode: {len(judges)} judges")
339
- console.print(
340
- f"\n[bold]Adaptive mode[/bold]: {len(all_indices)} samples, "
341
- f"{n_pairs} pairs, batch size {batch_samples}, "
342
- f"checking after {min_before_check} comparisons"
343
- )
344
-
345
- new_results: list[ComparisonResult] = []
346
- total_comparisons = 0
347
- for batch_num, batch_start in enumerate(range(0, len(all_indices), batch_samples)):
348
- batch_indices = all_indices[batch_start : batch_start + batch_samples]
349
- batch_comps = build_comparisons(
350
- ds,
351
- ocr_columns,
352
- skip_pairs=skip_pairs,
353
- indices=batch_indices,
354
- seed=args.seed,
355
- )
356
- if not batch_comps:
357
- continue
358
-
359
- batch_results = _judge_batch(batch_comps)
360
- new_results.extend(batch_results)
361
- total_comparisons += len(batch_comps)
362
- # batch_comps goes out of scope → GC can free images
363
-
364
- total = len(existing_results) + len(new_results)
365
- console.print(f" Batch {batch_num + 1}: {len(batch_results)} new, {total} total")
366
-
367
- if total >= min_before_check:
368
- board = compute_elo(existing_results + new_results, model_names)
369
- # Show CI gaps for each adjacent pair
370
- ranked = board.ranked
371
- if board.elo_ci:
372
- gaps: list[str] = []
373
- for i in range(len(ranked) - 1):
374
- hi_model, _ = ranked[i]
375
- lo_model, _ = ranked[i + 1]
376
- hi_ci = board.elo_ci.get(hi_model)
377
- lo_ci = board.elo_ci.get(lo_model)
378
- if hi_ci and lo_ci:
379
- gap = hi_ci[0] - lo_ci[1] # positive = resolved
380
- if gap > 0:
381
- status = "[green]ok[/green]"
382
- else:
383
- status = f"[yellow]overlap {-gap:.0f}[/yellow]"
384
- gaps.append(f" {hi_model} vs {lo_model}: gap={gap:+.0f} {status}")
385
- if gaps:
386
- console.print(" CI gaps:")
387
- for g in gaps:
388
- console.print(g)
389
-
390
- if rankings_resolved(board):
391
- remaining = len(all_indices) - batch_start - len(batch_indices)
392
- console.print(
393
- f"[green]Rankings converged after {total} comparisons! "
394
- f"Skipped ~{remaining * n_pairs} remaining.[/green]"
395
- )
396
- break
397
-
398
- console.print(f"\n{len(new_results)}/{total_comparisons} valid comparisons")
399
- else:
400
- # --- Standard single-pass flow ---
401
- comparisons = build_comparisons(
402
- ds,
403
- ocr_columns,
404
- max_samples=args.max_samples,
405
- seed=args.seed,
406
- skip_pairs=skip_pairs,
407
- )
408
- console.print(f"\nBuilt {len(comparisons)} new pairwise comparisons")
409
-
410
- if not comparisons and not existing_results:
411
- console.print(
412
- "[yellow]No valid comparisons — check that OCR columns have text.[/yellow]"
413
- )
414
- return
415
-
416
- if not comparisons:
417
- console.print("[green]All pairs already judged — refitting leaderboard.[/green]")
418
- board = compute_elo(existing_results, model_names)
419
- console.print()
420
- print_leaderboard(board)
421
- if results_repo:
422
- metadata = EvalMetadata(
423
- source_dataset=args.dataset,
424
- judge_models=[],
425
- seed=args.seed,
426
- max_samples=args.max_samples or len(ds),
427
- total_comparisons=0,
428
- valid_comparisons=0,
429
- from_prs=from_prs,
430
- )
431
- publish_results(
432
- results_repo,
433
- board,
434
- metadata,
435
- existing_metadata=existing_meta_rows,
436
- )
437
- console.print(f"\nResults published to [bold]{results_repo}[/bold]")
438
- return
439
-
440
- if is_jury:
441
- console.print(f"\nJury mode: {len(judges)} judges")
442
-
443
- for judge in judges:
444
- console.print(f"\nRunning judge: {judge.name}")
445
-
446
- new_results = _judge_batch(comparisons)
447
- total_comparisons = len(comparisons)
448
- console.print(f"\n{len(new_results)}/{total_comparisons} valid comparisons")
449
-
450
- # --- Merge existing + new, compute ELO ---
451
- all_results = existing_results + new_results
452
- board = compute_elo(all_results, model_names)
453
- console.print()
454
- print_leaderboard(board)
455
-
456
- # --- Publish ---
457
- if results_repo:
458
- metadata = EvalMetadata(
459
- source_dataset=args.dataset,
460
- judge_models=[j.name for j in judges],
461
- seed=args.seed,
462
- max_samples=args.max_samples or len(ds),
463
- total_comparisons=total_comparisons,
464
- valid_comparisons=len(new_results),
465
- from_prs=from_prs,
466
- )
467
- publish_results(results_repo, board, metadata, existing_metadata=existing_meta_rows)
468
- console.print(f"\nResults published to [bold]{results_repo}[/bold]")
469
-
470
-
471
- def cmd_run(args: argparse.Namespace) -> None:
472
- """Launch OCR models on a dataset via HF Jobs."""
473
- from ocr_bench.run import (
474
- DEFAULT_MODELS,
475
- MODEL_REGISTRY,
476
- build_script_args,
477
- launch_ocr_jobs,
478
- poll_jobs,
479
- )
480
-
481
- # --list-models
482
- if args.list_models:
483
- table = Table(title="Available OCR Models", show_lines=True)
484
- table.add_column("Slug", style="cyan bold")
485
- table.add_column("Model ID")
486
- table.add_column("Size", justify="right")
487
- table.add_column("Default GPU", justify="center")
488
-
489
- for slug in sorted(MODEL_REGISTRY):
490
- cfg = MODEL_REGISTRY[slug]
491
- default = " (default)" if slug in DEFAULT_MODELS else ""
492
- table.add_row(slug + default, cfg.model_id, cfg.size, cfg.default_flavor)
493
-
494
- console.print(table)
495
- console.print(f"\nDefault set: {', '.join(DEFAULT_MODELS)}")
496
- return
497
-
498
- selected = args.models or DEFAULT_MODELS
499
- for slug in selected:
500
- if slug not in MODEL_REGISTRY:
501
- console.print(f"[red]Unknown model: {slug}[/red]")
502
- console.print(f"Available: {', '.join(MODEL_REGISTRY.keys())}")
503
- sys.exit(1)
504
-
505
- console.print("\n[bold]OCR Benchmark Run[/bold]")
506
- console.print(f" Source: {args.input_dataset}")
507
- console.print(f" Output: {args.output_repo}")
508
- console.print(f" Models: {', '.join(selected)}")
509
- if args.max_samples:
510
- console.print(f" Samples: {args.max_samples} per model")
511
- console.print()
512
-
513
- # Dry run
514
- if args.dry_run:
515
- console.print("[bold yellow]DRY RUN[/bold yellow] — no jobs will be launched\n")
516
- for slug in selected:
517
- cfg = MODEL_REGISTRY[slug]
518
- flavor = args.flavor or cfg.default_flavor
519
- script_args = build_script_args(
520
- args.input_dataset,
521
- args.output_repo,
522
- slug,
523
- max_samples=args.max_samples,
524
- shuffle=args.shuffle,
525
- seed=args.seed,
526
- extra_args=cfg.default_args or None,
527
- )
528
- console.print(f"[cyan]{slug}[/cyan] ({cfg.model_id})")
529
- console.print(f" Flavor: {flavor}")
530
- console.print(f" Timeout: {args.timeout}")
531
- console.print(f" Script: {cfg.script}")
532
- console.print(f" Args: {' '.join(script_args)}")
533
- console.print()
534
- console.print("Remove --dry-run to launch these jobs.")
535
- return
536
-
537
- # Launch
538
- jobs = launch_ocr_jobs(
539
- args.input_dataset,
540
- args.output_repo,
541
- models=selected,
542
- max_samples=args.max_samples,
543
- split=args.split,
544
- shuffle=args.shuffle,
545
- seed=args.seed,
546
- flavor_override=args.flavor,
547
- timeout=args.timeout,
548
- )
549
-
550
- console.print(f"\n[green]{len(jobs)} jobs launched.[/green]")
551
- for job in jobs:
552
- console.print(f" [cyan]{job.model_slug}[/cyan]: {job.job_url}")
553
-
554
- if not args.no_wait:
555
- console.print("\n[bold]Waiting for jobs to complete...[/bold]")
556
- poll_jobs(jobs)
557
- console.print("\n[bold green]All jobs finished![/bold green]")
558
- console.print("\nEvaluate:")
559
- console.print(f" ocr-bench judge {args.output_repo}")
560
- else:
561
- console.print("\nJobs running in background.")
562
- console.print("Check status at: https://huggingface.co/settings/jobs")
563
- console.print(f"When complete: ocr-bench judge {args.output_repo}")
564
-
565
-
566
- def cmd_view(args: argparse.Namespace) -> None:
567
- """Launch the FastAPI + HTMX results viewer."""
568
- try:
569
- import uvicorn
570
-
571
- from ocr_bench.web import create_app
572
- except ImportError:
573
- console.print(
574
- "[red]Error:[/red] FastAPI/uvicorn not installed. "
575
- "Install the viewer extra: [bold]pip install ocr-bench\\[viewer][/bold]"
576
- )
577
- sys.exit(1)
578
-
579
- console.print(f"Loading results from [bold]{args.results}[/bold]...")
580
- app = create_app(args.results, output_path=args.output)
581
- console.print(f"Starting viewer at [bold]http://{args.host}:{args.port}[/bold]")
582
- uvicorn.run(app, host=args.host, port=args.port)
583
-
584
-
585
- SPACE_TEMPLATE = "davanstrien/ocr-bench-space-template"
586
-
587
-
588
- def cmd_publish(args: argparse.Namespace) -> None:
589
- """Deploy results viewer as a Hugging Face Space."""
590
- from huggingface_hub import HfApi
591
-
592
- api = HfApi()
593
- results = args.results
594
- space_id = args.space or f"{results}-viewer"
595
-
596
- console.print(f"Deploying viewer for [bold]{results}[/bold] to [bold]{space_id}[/bold]...")
597
-
598
- api.duplicate_space(
599
- from_id=SPACE_TEMPLATE,
600
- to_id=space_id,
601
- private=args.private if args.private else None,
602
- hardware="cpu-basic",
603
- exist_ok=True,
604
- variables=[{"key": "REPOS", "value": results}],
605
- )
606
-
607
- api.add_space_variable(repo_id=space_id, key="REPOS", value=results)
608
-
609
- url = f"https://huggingface.co/spaces/{space_id}"
610
- console.print(f"[green]Space published![/green] {url}")
611
-
612
-
613
- def main() -> None:
614
- parser = build_parser()
615
- args = parser.parse_args()
616
-
617
- if args.command is None:
618
- parser.print_help()
619
- sys.exit(0)
620
-
621
- try:
622
- if args.command == "judge":
623
- cmd_judge(args)
624
- elif args.command == "run":
625
- cmd_run(args)
626
- elif args.command == "view":
627
- cmd_view(args)
628
- elif args.command == "publish":
629
- cmd_publish(args)
630
- except DatasetError as exc:
631
- console.print(f"[red]Error:[/red] {exc}")
632
- sys.exit(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/dataset.py DELETED
@@ -1,301 +0,0 @@
1
- """Dataset loading — flat, config-per-model, PR-based. OCR column discovery."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
-
7
- import structlog
8
- from datasets import Dataset, get_dataset_config_names, load_dataset
9
- from huggingface_hub import HfApi
10
-
11
- logger = structlog.get_logger()
12
-
13
-
14
- class DatasetError(Exception):
15
- """Raised when dataset loading or column discovery fails."""
16
-
17
-
18
- # ---------------------------------------------------------------------------
19
- # OCR column discovery
20
- # ---------------------------------------------------------------------------
21
-
22
-
23
- def discover_ocr_columns(dataset: Dataset) -> dict[str, str]:
24
- """Discover OCR output columns and their model names from a dataset.
25
-
26
- Strategy:
27
- 1. Parse ``inference_info`` JSON from the first row (list or single entry).
28
- 2. Fallback: heuristic column-name matching (``markdown``, ``ocr``, ``text``).
29
- 3. Disambiguate duplicate model names by appending the column name.
30
-
31
- Returns:
32
- Mapping of ``column_name → model_name``.
33
-
34
- Raises:
35
- DatasetError: If no OCR columns can be found.
36
- """
37
- columns: dict[str, str] = {}
38
-
39
- try:
40
- if "inference_info" not in dataset.column_names:
41
- raise KeyError("no inference_info column")
42
- info_raw = dataset["inference_info"][0] # column access avoids image decode
43
- if info_raw:
44
- info = json.loads(info_raw)
45
- if not isinstance(info, list):
46
- info = [info]
47
- for entry in info:
48
- col = entry.get("column_name", "")
49
- model = entry.get("model_id", entry.get("model_name", "unknown"))
50
- if col and col in dataset.column_names:
51
- columns[col] = model
52
- except (json.JSONDecodeError, TypeError, KeyError) as exc:
53
- logger.warning("could_not_parse_inference_info", error=str(exc))
54
-
55
- # Fallback: heuristic
56
- if not columns:
57
- for col in dataset.column_names:
58
- lower = col.lower()
59
- if "markdown" in lower or "ocr" in lower or col == "text":
60
- columns[col] = col
61
-
62
- if not columns:
63
- raise DatasetError(f"No OCR columns found. Available columns: {dataset.column_names}")
64
-
65
- # Disambiguate duplicates
66
- model_counts: dict[str, int] = {}
67
- for model in columns.values():
68
- model_counts[model] = model_counts.get(model, 0) + 1
69
-
70
- disambiguated: dict[str, str] = {}
71
- for col, model in columns.items():
72
- if model_counts[model] > 1:
73
- short = model.split("/")[-1] if "/" in model else model
74
- disambiguated[col] = f"{short} ({col})"
75
- else:
76
- disambiguated[col] = model
77
-
78
- return disambiguated
79
-
80
-
81
- # ---------------------------------------------------------------------------
82
- # PR-based config discovery
83
- # ---------------------------------------------------------------------------
84
-
85
-
86
- def discover_pr_configs(
87
- repo_id: str,
88
- merge: bool = False,
89
- api: HfApi | None = None,
90
- ) -> tuple[list[str], dict[str, str]]:
91
- """Discover dataset configs from open PRs on a Hub dataset repo.
92
-
93
- PR titles must end with ``[config_name]`` to be detected.
94
-
95
- Args:
96
- repo_id: HF dataset repo id.
97
- merge: If True, merge each discovered PR before loading.
98
- api: Optional pre-configured HfApi instance.
99
-
100
- Returns:
101
- Tuple of (config_names, {config_name: pr_revision}).
102
- """
103
- if api is None:
104
- api = HfApi()
105
-
106
- config_names: list[str] = []
107
- revisions: dict[str, str] = {}
108
-
109
- discussions = api.get_repo_discussions(repo_id, repo_type="dataset")
110
- for disc in discussions:
111
- if not disc.is_pull_request or disc.status != "open":
112
- continue
113
- title = disc.title
114
- if "[" in title and title.endswith("]"):
115
- config = title[title.rindex("[") + 1 : -1].strip()
116
- if config:
117
- if merge:
118
- api.merge_pull_request(repo_id, disc.num, repo_type="dataset")
119
- logger.info("merged_pr", pr=disc.num, config=config)
120
- else:
121
- revisions[config] = f"refs/pr/{disc.num}"
122
- config_names.append(config)
123
-
124
- return config_names, revisions
125
-
126
-
127
- def discover_configs(repo_id: str) -> list[str]:
128
- """List non-default configs from the main branch of a Hub dataset.
129
-
130
- Returns:
131
- Config names excluding "default", or empty list if none found.
132
- """
133
- try:
134
- configs = get_dataset_config_names(repo_id)
135
- except Exception as exc:
136
- logger.info("no_configs_on_main", repo=repo_id, reason=str(exc))
137
- return []
138
- return [c for c in configs if c != "default"]
139
-
140
-
141
- # ---------------------------------------------------------------------------
142
- # Config-per-model loading
143
- # ---------------------------------------------------------------------------
144
-
145
-
146
- def load_config_dataset(
147
- repo_id: str,
148
- config_names: list[str],
149
- split: str = "train",
150
- pr_revisions: dict[str, str] | None = None,
151
- ) -> tuple[Dataset, dict[str, str]]:
152
- """Load multiple configs from a Hub dataset and merge into one.
153
-
154
- Each config becomes a column whose name is the config name and whose value
155
- is the OCR text (from the first column matching heuristics, or ``markdown``).
156
-
157
- Args:
158
- repo_id: HF dataset repo id.
159
- config_names: List of config names to load.
160
- split: Dataset split to load.
161
- pr_revisions: Optional mapping of config_name → revision for PR-based loading.
162
-
163
- Returns:
164
- Tuple of (unified Dataset, {column_name: model_id}).
165
- """
166
- if not config_names:
167
- raise DatasetError("No config names provided")
168
-
169
- pr_revisions = pr_revisions or {}
170
- unified: Dataset | None = None
171
- ocr_columns: dict[str, str] = {}
172
-
173
- for config in config_names:
174
- revision = pr_revisions.get(config)
175
- kwargs: dict = {"path": repo_id, "name": config, "split": split}
176
- if revision:
177
- kwargs["revision"] = revision
178
-
179
- ds = load_dataset(**kwargs)
180
-
181
- # Find the OCR text column in this config
182
- text_col = _find_text_column(ds)
183
- if text_col is None:
184
- logger.warning("no_text_column_in_config", config=config)
185
- continue
186
-
187
- # Extract model_id from inference_info if available
188
- model_id = _extract_model_id(ds, config)
189
- ocr_columns[config] = model_id
190
-
191
- # Build unified dataset using Arrow-level ops (no per-row image decode)
192
- text_values = ds[text_col] # column access — no image decoding
193
- if unified is None:
194
- # First config: keep all columns except text_col, add text as config name
195
- drop = [text_col] if text_col != config else []
196
- unified = ds.remove_columns(drop) if drop else ds
197
- if config != text_col:
198
- unified = unified.add_column(config, text_values)
199
- # Also rename text_col to config if they differ and text_col was kept
200
- else:
201
- if len(ds) != len(unified):
202
- logger.warning(
203
- "config_length_mismatch",
204
- config=config,
205
- expected=len(unified),
206
- got=len(ds),
207
- )
208
- text_values = text_values[: len(unified)]
209
- unified = unified.add_column(config, text_values)
210
-
211
- if unified is None:
212
- raise DatasetError("No configs loaded successfully")
213
-
214
- return unified, ocr_columns
215
-
216
-
217
- def _extract_model_id(ds: Dataset, config: str) -> str:
218
- """Extract model_id from inference_info in first row, falling back to config name.
219
-
220
- Takes the *last* entry in the inference_info list, since OCR scripts append
221
- new entries — the last one is the model that actually produced this config.
222
- """
223
- if "inference_info" not in ds.column_names:
224
- return config
225
- try:
226
- info_raw = ds["inference_info"][0] # column access avoids image decode
227
- if info_raw:
228
- info = json.loads(info_raw)
229
- if isinstance(info, list):
230
- info = info[-1]
231
- return info.get("model_id", info.get("model_name", config))
232
- except (json.JSONDecodeError, TypeError, KeyError, IndexError):
233
- pass
234
- return config
235
-
236
-
237
- def _find_text_column(ds: Dataset) -> str | None:
238
- """Find the likely OCR text column in a dataset.
239
-
240
- Priority:
241
- 1. ``inference_info[0]["column_name"]`` if present and exists in dataset.
242
- 2. First column matching ``markdown`` (case-insensitive).
243
- 3. First column matching ``ocr`` (case-insensitive).
244
- 4. Column named exactly ``text``.
245
- """
246
- # Try inference_info first (column access avoids image decoding)
247
- if "inference_info" in ds.column_names:
248
- try:
249
- info_raw = ds["inference_info"][0]
250
- if info_raw:
251
- info = json.loads(info_raw)
252
- if isinstance(info, list):
253
- info = info[0]
254
- col_name = info.get("column_name", "")
255
- if col_name and col_name in ds.column_names:
256
- return col_name
257
- except (json.JSONDecodeError, TypeError, KeyError, IndexError):
258
- pass
259
-
260
- # Prioritized heuristic: markdown > ocr > text
261
- for pattern in ["markdown", "ocr"]:
262
- for col in ds.column_names:
263
- if pattern in col.lower():
264
- return col
265
- if "text" in ds.column_names:
266
- return "text"
267
- return None
268
-
269
-
270
- # ---------------------------------------------------------------------------
271
- # Flat dataset loading
272
- # ---------------------------------------------------------------------------
273
-
274
-
275
- def load_flat_dataset(
276
- repo_id: str,
277
- split: str = "train",
278
- columns: list[str] | None = None,
279
- ) -> tuple[Dataset, dict[str, str]]:
280
- """Load a flat dataset from Hub and discover OCR columns.
281
-
282
- Args:
283
- repo_id: HF dataset repo id.
284
- split: Dataset split.
285
- columns: If given, use these as OCR columns (maps col→col).
286
-
287
- Returns:
288
- Tuple of (Dataset, {column_name: model_name}).
289
- """
290
- ds = load_dataset(repo_id, split=split)
291
-
292
- if columns:
293
- # Validate columns exist
294
- for col in columns:
295
- if col not in ds.column_names:
296
- raise DatasetError(f"Column '{col}' not found. Available: {ds.column_names}")
297
- ocr_columns = {col: col for col in columns}
298
- else:
299
- ocr_columns = discover_ocr_columns(ds)
300
-
301
- return ds, ocr_columns
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/elo.py DELETED
@@ -1,320 +0,0 @@
1
- """Bradley-Terry MLE rating computation for pairwise comparisons."""
2
-
3
- from __future__ import annotations
4
-
5
- import math
6
- import random
7
- from collections import defaultdict
8
- from dataclasses import dataclass, field
9
- from typing import Literal
10
-
11
- import numpy as np
12
- from scipy.optimize import minimize
13
-
14
- INITIAL_ELO: float = 1500.0
15
-
16
- Winner = Literal["A", "B", "tie"]
17
-
18
-
19
- @dataclass
20
- class ComparisonResult:
21
- """Result of a single pairwise comparison, ready for ELO computation."""
22
-
23
- sample_idx: int
24
- model_a: str
25
- model_b: str
26
- winner: Winner
27
- reason: str = ""
28
- agreement: str = "1/1"
29
- swapped: bool = False
30
- text_a: str = ""
31
- text_b: str = ""
32
- col_a: str = ""
33
- col_b: str = ""
34
-
35
-
36
- @dataclass
37
- class Leaderboard:
38
- """ELO leaderboard computed from pairwise comparison results."""
39
-
40
- elo: dict[str, float] = field(default_factory=dict)
41
- wins: dict[str, int] = field(default_factory=dict)
42
- losses: dict[str, int] = field(default_factory=dict)
43
- ties: dict[str, int] = field(default_factory=dict)
44
- comparison_log: list[dict[str, object]] = field(default_factory=list)
45
- elo_ci: dict[str, tuple[float, float]] = field(default_factory=dict)
46
-
47
- @property
48
- def ranked(self) -> list[tuple[str, float]]:
49
- """Models sorted by ELO rating, descending."""
50
- return sorted(self.elo.items(), key=lambda x: x[1], reverse=True)
51
-
52
- def win_pct(self, model: str) -> float | None:
53
- """Win percentage for a model, or None if no comparisons."""
54
- total = self.wins[model] + self.losses[model] + self.ties[model]
55
- if total == 0:
56
- return None
57
- return self.wins[model] / total * 100
58
-
59
-
60
- def _unswap_winner(winner: Winner, swapped: bool) -> Winner:
61
- """Unswap winner if positions were randomized."""
62
- if swapped:
63
- if winner == "A":
64
- return "B"
65
- elif winner == "B":
66
- return "A"
67
- return winner
68
-
69
-
70
- def _build_win_matrix(
71
- results: list[ComparisonResult],
72
- ) -> tuple[dict[tuple[str, str], float], set[str]]:
73
- """Count wins per ordered pair. Ties count as 0.5 for each side.
74
-
75
- Returns (win_counts, models_seen) where win_counts[(i, j)] = fractional
76
- wins of i over j.
77
- """
78
- win_counts: dict[tuple[str, str], float] = defaultdict(float)
79
- models_seen: set[str] = set()
80
-
81
- for r in results:
82
- winner = _unswap_winner(r.winner, r.swapped)
83
- models_seen.add(r.model_a)
84
- models_seen.add(r.model_b)
85
-
86
- if winner == "A":
87
- win_counts[(r.model_a, r.model_b)] += 1.0
88
- elif winner == "B":
89
- win_counts[(r.model_b, r.model_a)] += 1.0
90
- else:
91
- win_counts[(r.model_a, r.model_b)] += 0.5
92
- win_counts[(r.model_b, r.model_a)] += 0.5
93
-
94
- return win_counts, models_seen
95
-
96
-
97
- def _bt_mle(
98
- win_counts: dict[tuple[str, str], float],
99
- model_names: list[str],
100
- ) -> dict[str, float]:
101
- """Fit Bradley-Terry model via maximum likelihood estimation.
102
-
103
- Returns theta (strength) per model. Uses scipy L-BFGS-B on the
104
- negative log-likelihood with log-parameterization for positivity.
105
- """
106
- n = len(model_names)
107
- if n == 0:
108
- return {}
109
- if n == 1:
110
- return {model_names[0]: 1.0}
111
-
112
- idx = {name: i for i, name in enumerate(model_names)}
113
-
114
- # Collect all pairs with nonzero games
115
- pairs: list[tuple[int, int, float, float]] = []
116
- for i_name in model_names:
117
- for j_name in model_names:
118
- if i_name >= j_name:
119
- continue
120
- w_ij = win_counts.get((i_name, j_name), 0.0)
121
- w_ji = win_counts.get((j_name, i_name), 0.0)
122
- if w_ij + w_ji > 0:
123
- pairs.append((idx[i_name], idx[j_name], w_ij, w_ji))
124
-
125
- if not pairs:
126
- return {name: 1.0 for name in model_names}
127
-
128
- def neg_log_likelihood(log_theta: np.ndarray) -> float:
129
- nll = 0.0
130
- for i, j, w_ij, w_ji in pairs:
131
- diff = log_theta[i] - log_theta[j]
132
- # log(theta_i / (theta_i + theta_j)) = diff - log(1 + exp(diff))
133
- # log(theta_j / (theta_i + theta_j)) = -diff - log(1 + exp(-diff))
134
- # Use log-sum-exp for numerical stability
135
- log_p_ij = diff - np.logaddexp(0.0, diff)
136
- log_p_ji = -diff - np.logaddexp(0.0, -diff)
137
- nll -= w_ij * log_p_ij + w_ji * log_p_ji
138
- return nll
139
-
140
- def gradient(log_theta: np.ndarray) -> np.ndarray:
141
- grad = np.zeros(n)
142
- for i, j, w_ij, w_ji in pairs:
143
- diff = log_theta[i] - log_theta[j]
144
- p_ij = 1.0 / (1.0 + np.exp(-diff)) # sigmoid(diff)
145
- total = w_ij + w_ji
146
- # d(NLL)/d(log_theta_i)
147
- grad[i] -= w_ij - total * p_ij
148
- grad[j] -= w_ji - total * (1.0 - p_ij)
149
- return grad
150
-
151
- # Pin first model at 0 to fix the scale
152
- x0 = np.zeros(n)
153
- result = minimize(
154
- neg_log_likelihood,
155
- x0,
156
- jac=gradient,
157
- method="L-BFGS-B",
158
- )
159
-
160
- log_theta = result.x
161
- # Center: subtract geometric mean (= mean of log_theta)
162
- log_theta -= log_theta.mean()
163
- theta = np.exp(log_theta)
164
-
165
- return {name: float(theta[idx[name]]) for name in model_names}
166
-
167
-
168
- def _theta_to_elo(theta: dict[str, float], center: float = 1500.0) -> dict[str, float]:
169
- """Convert BT theta values to ELO scale.
170
-
171
- ELO_i = 400 * log10(theta_i / theta_ref) + center
172
- where theta_ref is the geometric mean of all theta values.
173
- """
174
- if not theta:
175
- return {}
176
-
177
- values = list(theta.values())
178
- log_geo_mean = sum(math.log(v) for v in values) / len(values)
179
- geo_mean = math.exp(log_geo_mean)
180
-
181
- return {
182
- name: 400.0 * math.log10(t / geo_mean) + center
183
- for name, t in theta.items()
184
- }
185
-
186
-
187
- def _bootstrap_ci(
188
- results: list[ComparisonResult],
189
- model_names: list[str],
190
- n_bootstrap: int = 1000,
191
- ci: float = 0.95,
192
- seed: int = 42,
193
- ) -> dict[str, tuple[float, float]]:
194
- """Compute bootstrap confidence intervals for ELO ratings.
195
-
196
- Resamples comparisons with replacement, fits BT-MLE each time,
197
- returns percentile-based CIs.
198
- """
199
- if not results or not model_names:
200
- return {}
201
-
202
- rng = random.Random(seed)
203
- n = len(results)
204
- elo_samples: dict[str, list[float]] = {name: [] for name in model_names}
205
-
206
- for _ in range(n_bootstrap):
207
- boot = rng.choices(results, k=n)
208
- win_counts, _ = _build_win_matrix(boot)
209
- theta = _bt_mle(win_counts, model_names)
210
- elos = _theta_to_elo(theta)
211
- for name in model_names:
212
- elo_samples[name].append(elos.get(name, 1500.0))
213
-
214
- alpha = (1.0 - ci) / 2.0
215
- lo_pct = alpha * 100
216
- hi_pct = (1.0 - alpha) * 100
217
-
218
- cis: dict[str, tuple[float, float]] = {}
219
- for name in model_names:
220
- samples = sorted(elo_samples[name])
221
- lo_idx = int(len(samples) * lo_pct / 100)
222
- hi_idx = min(int(len(samples) * hi_pct / 100), len(samples) - 1)
223
- cis[name] = (samples[lo_idx], samples[hi_idx])
224
-
225
- return cis
226
-
227
-
228
- def rankings_resolved(board: Leaderboard) -> bool:
229
- """Check if all adjacent ranks have non-overlapping 95% CIs.
230
-
231
- Returns True when the ranking order is statistically resolved — i.e. for
232
- every pair of adjacent models in the ranking, the higher-ranked model's
233
- CI lower bound exceeds the lower-ranked model's CI upper bound.
234
- """
235
- if not board.elo_ci:
236
- return False
237
- ranked = board.ranked
238
- if len(ranked) < 2:
239
- return False
240
- for i in range(len(ranked) - 1):
241
- model_hi, _ = ranked[i]
242
- model_lo, _ = ranked[i + 1]
243
- if model_hi not in board.elo_ci or model_lo not in board.elo_ci:
244
- return False
245
- lo_of_higher, _ = board.elo_ci[model_hi]
246
- _, hi_of_lower = board.elo_ci[model_lo]
247
- if hi_of_lower >= lo_of_higher:
248
- return False # CIs overlap
249
- return True
250
-
251
-
252
- def compute_elo(
253
- results: list[ComparisonResult],
254
- model_names: list[str],
255
- n_bootstrap: int = 1000,
256
- ) -> Leaderboard:
257
- """Compute ELO ratings from pairwise comparison results using Bradley-Terry MLE.
258
-
259
- Handles position-bias unswapping: if a result has swapped=True,
260
- the winner is flipped before updating ratings.
261
-
262
- Bootstrap confidence intervals are computed when n_bootstrap > 0.
263
- """
264
- board = Leaderboard(
265
- elo={m: INITIAL_ELO for m in model_names},
266
- wins={m: 0 for m in model_names},
267
- losses={m: 0 for m in model_names},
268
- ties={m: 0 for m in model_names},
269
- )
270
-
271
- # Tally wins/losses/ties and build comparison log
272
- for r in results:
273
- winner = _unswap_winner(r.winner, r.swapped)
274
-
275
- if winner == "A":
276
- board.wins[r.model_a] += 1
277
- board.losses[r.model_b] += 1
278
- elif winner == "B":
279
- board.losses[r.model_a] += 1
280
- board.wins[r.model_b] += 1
281
- else:
282
- board.ties[r.model_a] += 1
283
- board.ties[r.model_b] += 1
284
-
285
- # Canonicalise the reason text so A/B references match model_a/model_b
286
- reason = r.reason
287
- if r.swapped and reason:
288
- # Swap "Output A"↔"Output B" (and bare A/B) so the stored reason
289
- # uses A/B consistently with model_a/model_b ordering.
290
- reason = (
291
- reason.replace("Output A", "Output __X__")
292
- .replace("Output B", "Output A")
293
- .replace("Output __X__", "Output B")
294
- )
295
-
296
- board.comparison_log.append(
297
- {
298
- "sample_idx": r.sample_idx,
299
- "model_a": r.model_a,
300
- "model_b": r.model_b,
301
- "winner": winner,
302
- "reason": reason,
303
- "agreement": r.agreement,
304
- "text_a": r.text_a,
305
- "text_b": r.text_b,
306
- "col_a": r.col_a,
307
- "col_b": r.col_b,
308
- }
309
- )
310
-
311
- # Fit BT-MLE
312
- win_counts, _ = _build_win_matrix(results)
313
- theta = _bt_mle(win_counts, model_names)
314
- board.elo = _theta_to_elo(theta)
315
-
316
- # Bootstrap CIs
317
- if n_bootstrap > 0 and results:
318
- board.elo_ci = _bootstrap_ci(results, model_names, n_bootstrap=n_bootstrap)
319
-
320
- return board
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/judge.py DELETED
@@ -1,289 +0,0 @@
1
- """Pairwise VLM judge — prompt templates, structured output schema, comparison building."""
2
-
3
- from __future__ import annotations
4
-
5
- import base64
6
- import io
7
- import json
8
- import logging
9
- import random
10
- from dataclasses import dataclass
11
- from itertools import combinations
12
- from typing import Any
13
-
14
- from PIL import Image
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
- # --- Judge prompt ---
19
-
20
- PAIRWISE_PROMPT = """\
21
- You are an expert OCR quality evaluator. You are given a document image and \
22
- TWO OCR outputs (A and B) extracted from that same image.
23
-
24
- Compare them and decide which extraction is better overall.
25
-
26
- Evaluation criteria (in priority order):
27
-
28
- 1. Faithfulness: The output must ONLY contain text actually visible in the document. \
29
- Hallucinating text that is not in the image (garbled strings, repeated tokens, \
30
- nonsensical output) is the most serious error. Added commentary or notes \
31
- (e.g. "it appears the text says...") is also an error, but less severe than \
32
- hallucination. If a page is blank or has minimal text, saying so is acceptable — \
33
- fabricating content is always worse.
34
-
35
- 2. Completeness: ALL visible text must be captured — headers, footers, marginalia, \
36
- stamps, handwritten notes. Missing any section of text is a significant penalty.
37
-
38
- 3. Accuracy: Correct characters, no garbled or fabricated words.
39
-
40
- 4. Reading order: Text flows naturally as a human would read the document.
41
-
42
- 5. Formatting: Clean structure. Ignore bounding box tags like <|ref|> <|det|> \
43
- if present. Markdown formatting markers (#, **, *, etc.) are neutral — do not \
44
- penalise or reward their presence. Judge only on the actual text content, not \
45
- on whether it is wrapped in markup. Plain text and markdown-formatted text that \
46
- contain the same words are equivalent.
47
-
48
- If both outputs capture the same text with similar accuracy, respond with "tie". \
49
- Only pick a winner when there is a clear quality difference.
50
-
51
- Output A:
52
- ---
53
- {ocr_text_a}
54
- ---
55
-
56
- Output B:
57
- ---
58
- {ocr_text_b}
59
- ---
60
-
61
- Respond with JSON only (no markdown fences, no extra text):
62
- {{"winner": "A", "reason": "brief explanation"}}
63
- Use "A", "B", or "tie" for the winner field."""
64
-
65
- JUDGE_SCHEMA: dict[str, Any] = {
66
- "type": "object",
67
- "properties": {
68
- "winner": {"type": "string", "enum": ["A", "B", "tie"]},
69
- "reason": {"type": "string"},
70
- },
71
- "required": ["winner", "reason"],
72
- }
73
-
74
- # Max characters of OCR text to include per output in the prompt.
75
- MAX_OCR_TEXT_LENGTH = 2500
76
-
77
- # Max image dimension (longer side) before resizing.
78
- MAX_IMAGE_DIM = 1024
79
-
80
-
81
- # --- Image helpers ---
82
-
83
-
84
- def image_to_base64(image: Image.Image, max_dim: int = MAX_IMAGE_DIM) -> str:
85
- """Convert a PIL image to a base64-encoded JPEG string, resizing if needed."""
86
- if image.mode != "RGB":
87
- image = image.convert("RGB")
88
- if max(image.size) > max_dim:
89
- ratio = max_dim / max(image.size)
90
- new_size = (int(image.width * ratio), int(image.height * ratio))
91
- image = image.resize(new_size, Image.Resampling.LANCZOS)
92
- buf = io.BytesIO()
93
- image.save(buf, format="JPEG", quality=85)
94
- return base64.b64encode(buf.getvalue()).decode()
95
-
96
-
97
- # --- Comparison ---
98
-
99
-
100
- @dataclass
101
- class Comparison:
102
- """A single pairwise comparison to evaluate."""
103
-
104
- sample_idx: int
105
- model_a: str
106
- model_b: str
107
- col_a: str
108
- col_b: str
109
- swapped: bool
110
- messages: list[dict[str, Any]]
111
- text_a: str = ""
112
- text_b: str = ""
113
-
114
-
115
- def build_prompt(text_a: str, text_b: str, swapped: bool) -> tuple[str, bool]:
116
- """Build the pairwise comparison prompt, applying position-bias swap.
117
-
118
- Returns (prompt_text, swapped).
119
- """
120
- a = text_a[:MAX_OCR_TEXT_LENGTH]
121
- b = text_b[:MAX_OCR_TEXT_LENGTH]
122
- if swapped:
123
- a, b = b, a
124
- return PAIRWISE_PROMPT.format(ocr_text_a=a, ocr_text_b=b), swapped
125
-
126
-
127
- def build_messages(image_b64: str, prompt: str) -> list[dict[str, Any]]:
128
- """Build chat messages for the judge (image + prompt)."""
129
- return [
130
- {
131
- "role": "user",
132
- "content": [
133
- {
134
- "type": "image_url",
135
- "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
136
- },
137
- {"type": "text", "text": prompt},
138
- ],
139
- }
140
- ]
141
-
142
-
143
- def _normalize_pair(a: str, b: str) -> tuple[str, str]:
144
- """Return a canonical (sorted) pair for symmetric lookup."""
145
- return (a, b) if a <= b else (b, a)
146
-
147
-
148
- def sample_indices(
149
- dataset_len: int, max_samples: int | None = None, seed: int = 42
150
- ) -> list[int]:
151
- """Compute shuffled sample indices (cheap — no image loading).
152
-
153
- Args:
154
- dataset_len: Total number of rows in the dataset.
155
- max_samples: If set, randomly sample this many indices.
156
- seed: Random seed for reproducible sampling.
157
-
158
- Returns:
159
- List of integer indices into the dataset.
160
- """
161
- indices = list(range(dataset_len))
162
- if max_samples and max_samples < len(indices):
163
- random.seed(seed)
164
- indices = random.sample(indices, max_samples)
165
- return indices
166
-
167
-
168
- def build_comparisons(
169
- dataset: Any,
170
- ocr_columns: dict[str, str],
171
- max_samples: int | None = None,
172
- seed: int = 42,
173
- skip_pairs: set[tuple[str, str]] | None = None,
174
- indices: list[int] | None = None,
175
- ) -> list[Comparison]:
176
- """Build pairwise comparison prompts from a dataset.
177
-
178
- Args:
179
- dataset: HF dataset with an "image" column and OCR output columns.
180
- ocr_columns: Mapping of column_name -> model_name.
181
- max_samples: If set, randomly sample this many rows. Ignored when
182
- ``indices`` is provided.
183
- seed: Random seed for sampling and position-bias randomization.
184
- skip_pairs: Set of (model_a, model_b) pairs to exclude. Pairs are
185
- normalized so (a, b) and (b, a) are treated identically.
186
- If None, all pairs are included.
187
- indices: Explicit row indices to use. When provided, ``max_samples``
188
- and ``seed`` are not used for index selection (seed is still used
189
- for position-bias randomization).
190
-
191
- Returns:
192
- List of Comparison objects with pre-built chat messages.
193
- """
194
- col_names = list(ocr_columns.keys())
195
- model_names = list(ocr_columns.values())
196
- pairs = list(combinations(range(len(col_names)), 2))
197
-
198
- # Normalize skip set for symmetric lookup
199
- normalized_skip: set[tuple[str, str]] = set()
200
- if skip_pairs:
201
- normalized_skip = {_normalize_pair(a, b) for a, b in skip_pairs}
202
-
203
- if indices is None:
204
- indices = sample_indices(len(dataset), max_samples, seed)
205
-
206
- rng = random.Random(seed)
207
- comparisons: list[Comparison] = []
208
-
209
- # Pre-fetch text columns to avoid triggering image decode per row.
210
- # HF Dataset supports column access (dataset["col"]), plain lists don't.
211
- text_cols_data: dict[str, list] | None = None
212
- if hasattr(dataset, "column_names"):
213
- text_cols_data = {col: dataset[col] for col in col_names}
214
-
215
- for idx in indices:
216
- # Determine which pairs need judging for this row
217
- needed_pairs = [
218
- (i, j)
219
- for i, j in pairs
220
- if _normalize_pair(model_names[i], model_names[j]) not in normalized_skip
221
- ]
222
- if not needed_pairs:
223
- continue # Skip image encoding entirely
224
-
225
- # Check text availability before decoding the image
226
- valid_pairs = []
227
- if text_cols_data is not None:
228
- for i, j in needed_pairs:
229
- text_a = text_cols_data[col_names[i]][idx] or ""
230
- text_b = text_cols_data[col_names[j]][idx] or ""
231
- if text_a.strip() and text_b.strip():
232
- valid_pairs.append((i, j, text_a, text_b))
233
- else:
234
- row = dataset[idx]
235
- for i, j in needed_pairs:
236
- text_a = row[col_names[i]] or ""
237
- text_b = row[col_names[j]] or ""
238
- if text_a.strip() and text_b.strip():
239
- valid_pairs.append((i, j, text_a, text_b))
240
-
241
- if not valid_pairs:
242
- continue
243
-
244
- image_b64 = image_to_base64(dataset[idx]["image"])
245
-
246
- for i, j, text_a, text_b in valid_pairs:
247
- swapped = rng.random() < 0.5
248
- prompt, swapped = build_prompt(text_a, text_b, swapped)
249
- messages = build_messages(image_b64, prompt)
250
-
251
- comparisons.append(
252
- Comparison(
253
- sample_idx=idx,
254
- model_a=model_names[i],
255
- model_b=model_names[j],
256
- col_a=col_names[i],
257
- col_b=col_names[j],
258
- swapped=swapped,
259
- messages=messages,
260
- text_a=text_a,
261
- text_b=text_b,
262
- )
263
- )
264
-
265
- return comparisons
266
-
267
-
268
- # --- Output parsing ---
269
-
270
-
271
- def parse_judge_output(text: str) -> dict[str, str]:
272
- """Parse judge JSON output, handling markdown fences and invalid values.
273
-
274
- Returns dict with "winner" and "reason" keys, or empty dict on failure.
275
- """
276
- text = text.strip()
277
- if text.startswith("```"):
278
- text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
279
- try:
280
- result = json.loads(text)
281
- winner = result.get("winner", "tie").upper().strip()
282
- if winner == "TIE":
283
- winner = "tie"
284
- if winner not in ("A", "B", "tie"):
285
- winner = "tie"
286
- return {"winner": winner, "reason": result.get("reason", "")}
287
- except json.JSONDecodeError:
288
- logger.warning("Failed to parse judge output: %s", text[:200])
289
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/publish.py DELETED
@@ -1,271 +0,0 @@
1
- """Hub publishing — push comparisons, leaderboard, and metadata configs to HF Hub."""
2
-
3
- from __future__ import annotations
4
-
5
- import datetime
6
- import json
7
- from dataclasses import dataclass
8
-
9
- import structlog
10
- from datasets import Dataset, load_dataset
11
- from huggingface_hub import HfApi
12
-
13
- from ocr_bench.elo import ComparisonResult, Leaderboard
14
- from ocr_bench.run import MODEL_REGISTRY
15
-
16
- logger = structlog.get_logger()
17
-
18
-
19
- @dataclass
20
- class EvalMetadata:
21
- """Metadata for an evaluation run, stored alongside results on Hub."""
22
-
23
- source_dataset: str
24
- judge_models: list[str]
25
- seed: int
26
- max_samples: int
27
- total_comparisons: int
28
- valid_comparisons: int
29
- from_prs: bool = False
30
- timestamp: str = ""
31
-
32
- def __post_init__(self):
33
- if not self.timestamp:
34
- self.timestamp = datetime.datetime.now(datetime.UTC).isoformat()
35
-
36
-
37
- def load_existing_comparisons(repo_id: str) -> list[ComparisonResult]:
38
- """Load existing comparisons from a Hub results repo.
39
-
40
- The stored winner is already unswapped (canonical), so ``swapped=False``.
41
- Returns an empty list if the repo or config doesn't exist.
42
- """
43
- try:
44
- ds = load_dataset(repo_id, name="comparisons", split="train")
45
- except Exception as exc:
46
- logger.info("no_existing_comparisons", repo=repo_id, reason=str(exc))
47
- return []
48
-
49
- results = []
50
- for row in ds:
51
- results.append(
52
- ComparisonResult(
53
- sample_idx=row["sample_idx"],
54
- model_a=row["model_a"],
55
- model_b=row["model_b"],
56
- winner=row["winner"],
57
- reason=row.get("reason", ""),
58
- agreement=row.get("agreement", "1/1"),
59
- swapped=False,
60
- text_a=row.get("text_a", ""),
61
- text_b=row.get("text_b", ""),
62
- col_a=row.get("col_a", ""),
63
- col_b=row.get("col_b", ""),
64
- )
65
- )
66
- logger.info("loaded_existing_comparisons", repo=repo_id, n=len(results))
67
- return results
68
-
69
-
70
- def load_existing_metadata(repo_id: str) -> list[dict]:
71
- """Load existing metadata rows from a Hub results repo.
72
-
73
- Returns an empty list if the repo or config doesn't exist.
74
- """
75
- try:
76
- ds = load_dataset(repo_id, name="metadata", split="train")
77
- return [dict(row) for row in ds]
78
- except Exception as exc:
79
- logger.info("no_existing_metadata", repo=repo_id, reason=str(exc))
80
- return []
81
-
82
-
83
- def _get_model_sizes() -> dict[str, str]:
84
- """Build model_id → size lookup from the model registry."""
85
- return {cfg.model_id: cfg.size for cfg in MODEL_REGISTRY.values()}
86
-
87
-
88
- def build_leaderboard_rows(board: Leaderboard) -> list[dict]:
89
- """Convert a Leaderboard into rows suitable for a Hub dataset."""
90
- sizes = _get_model_sizes()
91
- rows = []
92
- for model, elo in board.ranked:
93
- total = board.wins[model] + board.losses[model] + board.ties[model]
94
- row = {
95
- "model": model,
96
- "elo": round(elo),
97
- "params": sizes.get(model, ""),
98
- "wins": board.wins[model],
99
- "losses": board.losses[model],
100
- "ties": board.ties[model],
101
- "win_pct": round(board.wins[model] / total * 100) if total > 0 else 0,
102
- }
103
- if board.elo_ci and model in board.elo_ci:
104
- lo, hi = board.elo_ci[model]
105
- row["elo_low"] = round(lo)
106
- row["elo_high"] = round(hi)
107
- rows.append(row)
108
- return rows
109
-
110
-
111
- def build_metadata_row(metadata: EvalMetadata) -> dict:
112
- """Convert EvalMetadata into a single row for a Hub dataset."""
113
- return {
114
- "source_dataset": metadata.source_dataset,
115
- "judge_models": json.dumps(metadata.judge_models),
116
- "seed": metadata.seed,
117
- "max_samples": metadata.max_samples,
118
- "total_comparisons": metadata.total_comparisons,
119
- "valid_comparisons": metadata.valid_comparisons,
120
- "from_prs": metadata.from_prs,
121
- "timestamp": metadata.timestamp,
122
- }
123
-
124
-
125
- def publish_results(
126
- repo_id: str,
127
- board: Leaderboard,
128
- metadata: EvalMetadata,
129
- existing_metadata: list[dict] | None = None,
130
- ) -> None:
131
- """Push evaluation results to Hub as a dataset with multiple configs.
132
-
133
- Configs:
134
- - (default): Leaderboard table — ``load_dataset("repo")`` returns this.
135
- - ``leaderboard``: Same table, named config (backward compat for viewer).
136
- - ``comparisons``: Full comparison log from the board (caller merges
137
- existing + new before ``compute_elo``, so ``board.comparison_log``
138
- is already the complete set).
139
- - ``metadata``: Append-only run log. New row is appended to
140
- ``existing_metadata``.
141
- """
142
- # Comparisons
143
- if board.comparison_log:
144
- comp_ds = Dataset.from_list(board.comparison_log)
145
- comp_ds.push_to_hub(repo_id, config_name="comparisons")
146
- logger.info("published_comparisons", repo=repo_id, n=len(board.comparison_log))
147
-
148
- # Leaderboard — dual push: default config + named config
149
- rows = build_leaderboard_rows(board)
150
- lb_ds = Dataset.from_list(rows)
151
- lb_ds.push_to_hub(repo_id)
152
- lb_ds.push_to_hub(repo_id, config_name="leaderboard")
153
- logger.info("published_leaderboard", repo=repo_id, n=len(rows))
154
-
155
- # Metadata — append-only
156
- meta_row = build_metadata_row(metadata)
157
- all_meta = (existing_metadata or []) + [meta_row]
158
- Dataset.from_list(all_meta).push_to_hub(repo_id, config_name="metadata")
159
- logger.info("published_metadata", repo=repo_id, n=len(all_meta))
160
-
161
- # README — auto-generated dataset card with leaderboard
162
- readme = _build_readme(repo_id, rows, board, metadata)
163
- api = HfApi()
164
- api.upload_file(
165
- path_or_fileobj=readme.encode(),
166
- path_in_repo="README.md",
167
- repo_id=repo_id,
168
- repo_type="dataset",
169
- )
170
- logger.info("published_readme", repo=repo_id)
171
-
172
-
173
- def _build_readme(
174
- repo_id: str,
175
- rows: list[dict],
176
- board: Leaderboard,
177
- metadata: EvalMetadata,
178
- ) -> str:
179
- """Build a dataset card README with the leaderboard table."""
180
- has_ci = bool(board.elo_ci)
181
- source_short = metadata.source_dataset.split("/")[-1]
182
- judges = json.loads(
183
- metadata.judge_models
184
- if isinstance(metadata.judge_models, str)
185
- else json.dumps(metadata.judge_models)
186
- )
187
- judge_str = ", ".join(j.split("/")[-1] for j in judges) if judges else "N/A"
188
- n_comparisons = len(board.comparison_log)
189
-
190
- lines = [
191
- "---",
192
- "license: mit",
193
- "tags:",
194
- " - ocr-bench",
195
- " - leaderboard",
196
- "configs:",
197
- " - config_name: default",
198
- " data_files:",
199
- " - split: train",
200
- " path: data/train-*.parquet",
201
- " - config_name: comparisons",
202
- " data_files:",
203
- " - split: train",
204
- " path: comparisons/train-*.parquet",
205
- " - config_name: leaderboard",
206
- " data_files:",
207
- " - split: train",
208
- " path: leaderboard/train-*.parquet",
209
- " - config_name: metadata",
210
- " data_files:",
211
- " - split: train",
212
- " path: metadata/train-*.parquet",
213
- "---",
214
- "",
215
- f"# OCR Bench Results: {source_short}",
216
- "",
217
- "VLM-as-judge pairwise evaluation of OCR models. "
218
- "Rankings depend on document type — there is no single best OCR model.",
219
- "",
220
- "## Leaderboard",
221
- "",
222
- ]
223
-
224
- # Table header
225
- if has_ci:
226
- lines.append("| Rank | Model | Params | ELO | 95% CI | Wins | Losses | Ties | Win% |")
227
- lines.append("|------|-------|--------|-----|--------|------|--------|------|------|")
228
- else:
229
- lines.append("| Rank | Model | Params | ELO | Wins | Losses | Ties | Win% |")
230
- lines.append("|------|-------|--------|-----|------|--------|------|------|")
231
-
232
- for rank, row in enumerate(rows, 1):
233
- model = row["model"]
234
- elo = row["elo"]
235
- params = row.get("params", "")
236
- if has_ci and "elo_low" in row:
237
- ci = f"{row['elo_low']}\u2013{row['elo_high']}"
238
- lines.append(
239
- f"| {rank} | {model} | {params} | {elo} | {ci} "
240
- f"| {row['wins']} | {row['losses']} | {row['ties']} "
241
- f"| {row['win_pct']}% |"
242
- )
243
- else:
244
- lines.append(
245
- f"| {rank} | {model} | {params} | {elo} "
246
- f"| {row['wins']} | {row['losses']} | {row['ties']} "
247
- f"| {row['win_pct']}% |"
248
- )
249
-
250
- lines += [
251
- "",
252
- "## Details",
253
- "",
254
- f"- **Source dataset**: [`{metadata.source_dataset}`]"
255
- f"(https://huggingface.co/datasets/{metadata.source_dataset})",
256
- f"- **Judge**: {judge_str}",
257
- f"- **Comparisons**: {n_comparisons}",
258
- "- **Method**: Bradley-Terry MLE with bootstrap 95% CIs",
259
- "",
260
- "## Configs",
261
- "",
262
- f"- `load_dataset(\"{repo_id}\")` — leaderboard table",
263
- f"- `load_dataset(\"{repo_id}\", name=\"comparisons\")` "
264
- "— full pairwise comparison log",
265
- f"- `load_dataset(\"{repo_id}\", name=\"metadata\")` "
266
- "— evaluation run history",
267
- "",
268
- "*Generated by [ocr-bench](https://github.com/davanstrien/ocr-bench)*",
269
- ]
270
-
271
- return "\n".join(lines) + "\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/py.typed DELETED
File without changes
src/ocr_bench/run.py DELETED
@@ -1,193 +0,0 @@
1
- """OCR model orchestration — launch HF Jobs for multiple OCR models."""
2
-
3
- from __future__ import annotations
4
-
5
- import time
6
- from dataclasses import dataclass, field
7
-
8
- import structlog
9
- from huggingface_hub import HfApi, get_token
10
-
11
- logger = structlog.get_logger()
12
-
13
-
14
- @dataclass
15
- class ModelConfig:
16
- """Configuration for a single OCR model."""
17
-
18
- script: str
19
- model_id: str
20
- size: str
21
- default_flavor: str = "l4x1"
22
- default_args: list[str] = field(default_factory=list)
23
-
24
-
25
- MODEL_REGISTRY: dict[str, ModelConfig] = {
26
- "glm-ocr": ModelConfig(
27
- script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py",
28
- model_id="zai-org/GLM-OCR",
29
- size="0.9B",
30
- default_flavor="l4x1",
31
- ),
32
- "deepseek-ocr": ModelConfig(
33
- script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/deepseek-ocr-vllm.py",
34
- model_id="deepseek-ai/DeepSeek-OCR",
35
- size="4B",
36
- default_flavor="l4x1",
37
- default_args=["--prompt-mode", "free"],
38
- ),
39
- "lighton-ocr-2": ModelConfig(
40
- script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr2.py",
41
- model_id="lightonai/LightOnOCR-2-1B",
42
- size="1B",
43
- default_flavor="a100-large",
44
- ),
45
- "dots-ocr": ModelConfig(
46
- script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-ocr.py",
47
- model_id="rednote-hilab/dots.ocr",
48
- size="1.7B",
49
- default_flavor="l4x1",
50
- ),
51
- "firered-ocr": ModelConfig(
52
- script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/firered-ocr.py",
53
- model_id="FireRedTeam/FireRed-OCR",
54
- size="2.1B",
55
- default_flavor="l4x1",
56
- ),
57
- }
58
-
59
- DEFAULT_MODELS = ["glm-ocr", "deepseek-ocr", "lighton-ocr-2", "dots-ocr", "firered-ocr"]
60
-
61
-
62
- @dataclass
63
- class JobRun:
64
- """Tracks a launched HF Job."""
65
-
66
- model_slug: str
67
- job_id: str
68
- job_url: str
69
- status: str = "running"
70
-
71
-
72
- def list_models() -> list[str]:
73
- """Return sorted list of available model slugs."""
74
- return sorted(MODEL_REGISTRY.keys())
75
-
76
-
77
- def build_script_args(
78
- input_dataset: str,
79
- output_repo: str,
80
- config_name: str,
81
- *,
82
- max_samples: int | None = None,
83
- shuffle: bool = False,
84
- seed: int = 42,
85
- extra_args: list[str] | None = None,
86
- ) -> list[str]:
87
- """Build the script_args list for run_uv_job."""
88
- args = [
89
- input_dataset,
90
- output_repo,
91
- "--config",
92
- config_name,
93
- "--create-pr",
94
- ]
95
- if max_samples is not None:
96
- args += ["--max-samples", str(max_samples)]
97
- if shuffle:
98
- args.append("--shuffle")
99
- if seed != 42:
100
- args += ["--seed", str(seed)]
101
- if extra_args:
102
- args += extra_args
103
- return args
104
-
105
-
106
- def launch_ocr_jobs(
107
- input_dataset: str,
108
- output_repo: str,
109
- *,
110
- models: list[str] | None = None,
111
- max_samples: int | None = None,
112
- split: str = "train",
113
- shuffle: bool = False,
114
- seed: int = 42,
115
- flavor_override: str | None = None,
116
- timeout: str = "4h",
117
- api: HfApi | None = None,
118
- ) -> list[JobRun]:
119
- """Launch HF Jobs for each model. Returns list of JobRun tracking objects."""
120
- if api is None:
121
- api = HfApi()
122
-
123
- token = get_token()
124
- if not token:
125
- raise RuntimeError("No HF token found. Log in with `hf login` or set HF_TOKEN.")
126
-
127
- selected = models or DEFAULT_MODELS
128
- for slug in selected:
129
- if slug not in MODEL_REGISTRY:
130
- raise ValueError(
131
- f"Unknown model: {slug}. Available: {', '.join(MODEL_REGISTRY.keys())}"
132
- )
133
-
134
- jobs: list[JobRun] = []
135
- for slug in selected:
136
- config = MODEL_REGISTRY[slug]
137
- flavor = flavor_override or config.default_flavor
138
- script_args = build_script_args(
139
- input_dataset,
140
- output_repo,
141
- slug,
142
- max_samples=max_samples,
143
- shuffle=shuffle,
144
- seed=seed,
145
- extra_args=config.default_args or None,
146
- )
147
-
148
- logger.info("launching_job", model=slug, flavor=flavor, script=config.script)
149
- job = api.run_uv_job(
150
- script=config.script,
151
- script_args=script_args,
152
- flavor=flavor,
153
- secrets={"HF_TOKEN": token},
154
- timeout=timeout,
155
- )
156
- jobs.append(JobRun(model_slug=slug, job_id=job.id, job_url=job.url))
157
- logger.info("job_launched", model=slug, job_id=job.id, url=job.url)
158
-
159
- return jobs
160
-
161
-
162
- _TERMINAL_STAGES = frozenset({"COMPLETED", "ERROR", "CANCELED", "DELETED"})
163
-
164
-
165
- def poll_jobs(
166
- jobs: list[JobRun],
167
- *,
168
- interval: int = 30,
169
- api: HfApi | None = None,
170
- ) -> list[JobRun]:
171
- """Poll until all jobs complete or fail. Updates status in-place and returns the list."""
172
- if api is None:
173
- api = HfApi()
174
-
175
- pending = {j.job_id: j for j in jobs if j.status == "running"}
176
-
177
- while pending:
178
- time.sleep(interval)
179
- still_running: dict[str, JobRun] = {}
180
- for job_id, job_run in pending.items():
181
- info = api.inspect_job(job_id=job_id)
182
- stage = info.status.stage
183
- if stage in _TERMINAL_STAGES:
184
- job_run.status = stage.lower()
185
- logger.info("job_finished", model=job_run.model_slug, status=job_run.status)
186
- else:
187
- still_running[job_id] = job_run
188
- pending = still_running
189
- if pending:
190
- slugs = [j.model_slug for j in pending.values()]
191
- logger.info("jobs_pending", models=slugs)
192
-
193
- return jobs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/static/style.css DELETED
@@ -1,379 +0,0 @@
1
- /* ocr-bench viewer — Tufte-inspired minimal styles */
2
-
3
- *,
4
- *::before,
5
- *::after {
6
- box-sizing: border-box;
7
- }
8
-
9
- body {
10
- font-family: system-ui, -apple-system, sans-serif;
11
- color: #333;
12
- background: #fff;
13
- margin: 0;
14
- padding: 0;
15
- line-height: 1.5;
16
- }
17
-
18
- .container {
19
- max-width: 960px;
20
- margin: 0 auto;
21
- padding: 0 1.5rem 3rem;
22
- }
23
-
24
- /* Navigation */
25
- nav {
26
- border-bottom: 1px solid #ddd;
27
- padding: 0.75rem 0;
28
- margin-bottom: 2rem;
29
- display: flex;
30
- align-items: baseline;
31
- gap: 2rem;
32
- }
33
-
34
- nav .brand {
35
- font-weight: 600;
36
- color: #333;
37
- text-decoration: none;
38
- font-size: 0.9rem;
39
- letter-spacing: 0.02em;
40
- }
41
-
42
- nav a {
43
- color: #666;
44
- text-decoration: none;
45
- font-size: 0.85rem;
46
- }
47
-
48
- nav a:hover,
49
- nav a.active {
50
- color: #333;
51
- }
52
-
53
- nav a.active {
54
- border-bottom: 2px solid #333;
55
- padding-bottom: 2px;
56
- }
57
-
58
- /* Comparison layout */
59
- .comparison-columns {
60
- display: grid;
61
- grid-template-columns: 1fr 1fr;
62
- gap: 2rem;
63
- margin: 1.5rem 0;
64
- }
65
-
66
- .ocr-column h3 {
67
- font-size: 0.85rem;
68
- font-weight: 600;
69
- color: #666;
70
- margin: 0 0 0.5rem;
71
- padding-bottom: 0.35rem;
72
- border-bottom: 1px solid #ddd;
73
- letter-spacing: 0.02em;
74
- }
75
-
76
- .ocr-column h3.revealed {
77
- color: #333;
78
- }
79
-
80
- .ocr-text {
81
- font-family: "SF Mono", "Menlo", "Consolas", monospace;
82
- font-size: 0.82rem;
83
- line-height: 1.6;
84
- white-space: pre-wrap;
85
- word-break: break-word;
86
- max-height: 50vh;
87
- overflow-y: auto;
88
- padding: 0.25rem 0;
89
- color: #444;
90
- }
91
-
92
- /* Navigation header */
93
- .comp-nav {
94
- display: flex;
95
- justify-content: flex-end;
96
- align-items: baseline;
97
- gap: 0.75rem;
98
- margin-bottom: 0.5rem;
99
- color: #999;
100
- font-size: 0.8rem;
101
- }
102
-
103
- .comp-nav a {
104
- color: #999;
105
- text-decoration: none;
106
- font-size: 0.85rem;
107
- padding: 0.15rem 0.4rem;
108
- }
109
-
110
- .comp-nav a:hover {
111
- color: #333;
112
- }
113
-
114
- /* Vote prompt */
115
- .vote-prompt {
116
- text-align: center;
117
- font-size: 0.8rem;
118
- color: #999;
119
- margin: 1.5rem 0 0.5rem;
120
- }
121
-
122
- /* Vote buttons */
123
- .vote-row {
124
- text-align: center;
125
- margin: 0.25rem 0 0.5rem;
126
- display: flex;
127
- justify-content: center;
128
- gap: 0.5rem;
129
- }
130
-
131
- .vote-btn {
132
- display: inline-block;
133
- color: #555;
134
- text-decoration: none;
135
- padding: 0.35rem 1rem;
136
- border: 1px solid #ddd;
137
- border-radius: 4px;
138
- font-size: 0.85rem;
139
- transition: border-color 0.15s, color 0.15s;
140
- }
141
-
142
- .vote-btn:hover {
143
- color: #333;
144
- border-color: #999;
145
- }
146
-
147
- .vote-btn.vote-tie {
148
- color: #888;
149
- }
150
-
151
- /* Hints below vote buttons */
152
- .vote-hints {
153
- text-align: center;
154
- margin: 0.5rem 0 1rem;
155
- font-size: 0.75rem;
156
- color: #bbb;
157
- }
158
-
159
- .vote-hints a {
160
- color: #999;
161
- text-decoration: none;
162
- }
163
-
164
- .vote-hints a:hover {
165
- color: #666;
166
- text-decoration: underline;
167
- }
168
-
169
- .vote-hints .separator {
170
- color: #ddd;
171
- }
172
-
173
- .vote-hints kbd {
174
- font-family: system-ui, sans-serif;
175
- font-size: 0.7rem;
176
- padding: 0.05rem 0.3rem;
177
- border: 1px solid #ddd;
178
- border-radius: 3px;
179
- background: #f8f8f8;
180
- color: #999;
181
- }
182
-
183
- /* Legacy reveal-row (kept for compat) */
184
- .reveal-row {
185
- text-align: right;
186
- margin: 0.25rem 0 1rem;
187
- font-size: 0.8rem;
188
- }
189
-
190
- .reveal-row a {
191
- color: #999;
192
- text-decoration: none;
193
- }
194
-
195
- .reveal-row a:hover {
196
- color: #666;
197
- }
198
-
199
- /* Verdict display */
200
- .verdict {
201
- margin: 1rem 0;
202
- font-size: 0.85rem;
203
- color: #555;
204
- line-height: 1.6;
205
- }
206
-
207
- .verdict .agreement {
208
- font-weight: 500;
209
- }
210
-
211
- .verdict .agreement.agreed {
212
- color: #457b4d;
213
- }
214
-
215
- .verdict .agreement.soft-disagree {
216
- color: #a07828;
217
- }
218
-
219
- .verdict .agreement.hard-disagree {
220
- color: #b04040;
221
- }
222
-
223
- .verdict .reason {
224
- font-style: italic;
225
- color: #777;
226
- display: block;
227
- margin-top: 0.25rem;
228
- }
229
-
230
- /* Document image */
231
- .doc-image {
232
- margin: 1.5rem 0;
233
- text-align: center;
234
- }
235
-
236
- .doc-image img {
237
- max-width: 100%;
238
- height: auto;
239
- max-height: 60vh;
240
- }
241
-
242
- /* Leaderboard table */
243
- table {
244
- width: 100%;
245
- border-collapse: collapse;
246
- font-size: 0.85rem;
247
- margin: 1.5rem 0;
248
- }
249
-
250
- thead th {
251
- text-align: left;
252
- font-weight: 600;
253
- padding: 0.5rem 0.75rem;
254
- border-bottom: 2px solid #333;
255
- color: #333;
256
- font-size: 0.8rem;
257
- letter-spacing: 0.02em;
258
- }
259
-
260
- thead th.num {
261
- text-align: right;
262
- }
263
-
264
- tbody td {
265
- padding: 0.4rem 0.75rem;
266
- border-bottom: 1px solid #eee;
267
- }
268
-
269
- tbody td.num {
270
- text-align: right;
271
- font-variant-numeric: tabular-nums;
272
- }
273
-
274
- tbody td.model {
275
- font-weight: 500;
276
- }
277
-
278
- tbody tr:hover {
279
- background: #fafafa;
280
- }
281
-
282
- /* Filters */
283
- .filters {
284
- display: flex;
285
- gap: 1rem;
286
- margin-bottom: 1rem;
287
- align-items: center;
288
- }
289
-
290
- .filters label {
291
- font-size: 0.8rem;
292
- color: #666;
293
- }
294
-
295
- .filters select {
296
- font-size: 0.8rem;
297
- padding: 0.25rem 0.5rem;
298
- border: 1px solid #ddd;
299
- border-radius: 3px;
300
- background: #fff;
301
- color: #333;
302
- }
303
-
304
- /* Stats panel */
305
- .stats-panel {
306
- color: #888;
307
- font-size: 0.8rem;
308
- padding: 1rem 0;
309
- border-top: 1px solid #eee;
310
- margin-top: 2rem;
311
- }
312
-
313
- .stats-panel .calibrated {
314
- color: #457b4d;
315
- }
316
-
317
- .stats-panel .warning {
318
- color: #b04040;
319
- }
320
-
321
- /* Pair summary table */
322
- .pair-summary {
323
- margin-bottom: 1rem;
324
- }
325
-
326
- .pair-table {
327
- width: auto;
328
- font-size: 0.8rem;
329
- color: #888;
330
- }
331
-
332
- .pair-table th {
333
- font-size: 0.75rem;
334
- color: #999;
335
- font-weight: 500;
336
- padding: 0.2rem 0.6rem;
337
- border-bottom: 1px solid #ddd;
338
- }
339
-
340
- .pair-table td {
341
- padding: 0.15rem 0.6rem;
342
- border-bottom: 1px solid #f0f0f0;
343
- }
344
-
345
- /* HTMX loading indicator */
346
- .htmx-indicator {
347
- opacity: 0;
348
- transition: opacity 200ms ease-in;
349
- }
350
-
351
- .htmx-request .htmx-indicator,
352
- .htmx-request.htmx-indicator {
353
- opacity: 1;
354
- }
355
-
356
- /* Empty state */
357
- .empty {
358
- text-align: center;
359
- color: #999;
360
- padding: 3rem 0;
361
- font-size: 0.9rem;
362
- }
363
-
364
- /* Responsive */
365
- @media (max-width: 768px) {
366
- .comparison-columns {
367
- grid-template-columns: 1fr;
368
- }
369
-
370
- .container {
371
- padding: 0 1rem 2rem;
372
- }
373
-
374
- table {
375
- display: block;
376
- overflow-x: auto;
377
- -webkit-overflow-scrolling: touch;
378
- }
379
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/templates/base.html DELETED
@@ -1,48 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>{% block title %}OCR Bench{% endblock %}</title>
7
- <link rel="stylesheet" href="/static/style.css">
8
- <script src="https://unpkg.com/htmx.org@2.0.4"></script>
9
- </head>
10
- <body>
11
- <div class="container">
12
- <nav>
13
- <a href="/" class="brand">ocr-bench</a>
14
- <a href="/leaderboard" {% if active_tab == "leaderboard" %}class="active"{% endif %}>Leaderboard</a>
15
- <a href="/comparisons" {% if active_tab == "comparisons" %}class="active"{% endif %}>Comparisons</a>
16
- </nav>
17
- {% block content %}{% endblock %}
18
- </div>
19
-
20
- <script>
21
- document.addEventListener("keydown", function(e) {
22
- // Ignore when focus is in input/select/textarea
23
- var tag = document.activeElement.tagName.toLowerCase();
24
- if (tag === "input" || tag === "select" || tag === "textarea") return;
25
-
26
- if (e.key === "ArrowLeft") {
27
- var prev = document.querySelector("[data-nav='prev']");
28
- if (prev) { prev.click(); e.preventDefault(); }
29
- } else if (e.key === "ArrowRight") {
30
- var next = document.querySelector("[data-nav='next']");
31
- if (next) { next.click(); e.preventDefault(); }
32
- } else if (e.key === "a" || e.key === "A") {
33
- var voteA = document.querySelector("[data-vote='A']");
34
- if (voteA) { voteA.click(); e.preventDefault(); }
35
- } else if (e.key === "b" || e.key === "B") {
36
- var voteB = document.querySelector("[data-vote='B']");
37
- if (voteB) { voteB.click(); e.preventDefault(); }
38
- } else if (e.key === "t" || e.key === "T") {
39
- var voteTie = document.querySelector("[data-vote='tie']");
40
- if (voteTie) { voteTie.click(); e.preventDefault(); }
41
- } else if (e.key === "r" || e.key === "R") {
42
- var reveal = document.querySelector("[data-action='reveal']");
43
- if (reveal) { reveal.click(); e.preventDefault(); }
44
- }
45
- });
46
- </script>
47
- </body>
48
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/templates/comparison_card.html DELETED
@@ -1,88 +0,0 @@
1
- {% if comp %}
2
- <div class="comp-nav">
3
- <span>{{ nav_idx + 1 }} of {{ nav_total }}</span>
4
- {% if nav_idx > 0 %}
5
- <a href="#" data-nav="prev"
6
- hx-get="/comparisons/{{ nav_idx - 1 }}{% if winner_filter and winner_filter != 'All' %}?winner={{ winner_filter }}{% endif %}{% if model_filter and model_filter != 'All' %}{{ '&' if winner_filter and winner_filter != 'All' else '?' }}model={{ model_filter }}{% endif %}"
7
- hx-target="#comparison-container">&larr;</a>
8
- {% endif %}
9
- {% if nav_idx < nav_total - 1 %}
10
- <a href="#" data-nav="next"
11
- hx-get="/comparisons/{{ nav_idx + 1 }}{% if winner_filter and winner_filter != 'All' %}?winner={{ winner_filter }}{% endif %}{% if model_filter and model_filter != 'All' %}{{ '&' if winner_filter and winner_filter != 'All' else '?' }}model={{ model_filter }}{% endif %}"
12
- hx-target="#comparison-container">&rarr;</a>
13
- {% endif %}
14
- </div>
15
-
16
- <div class="comparison-columns">
17
- <div class="ocr-column">
18
- {% if revealed %}
19
- <h3 class="revealed">{{ model_a_name }}</h3>
20
- {% else %}
21
- <h3>A</h3>
22
- {% endif %}
23
- <div class="ocr-text">{{ display_text_a }}</div>
24
- </div>
25
- <div class="ocr-column">
26
- {% if revealed %}
27
- <h3 class="revealed">{{ model_b_name }}</h3>
28
- {% else %}
29
- <h3>B</h3>
30
- {% endif %}
31
- <div class="ocr-text">{{ display_text_b }}</div>
32
- </div>
33
- </div>
34
-
35
- {% if not voted %}
36
- <div class="vote-prompt">Which OCR output is better?</div>
37
- <div class="vote-row">
38
- <a href="#" data-vote="A" class="vote-btn"
39
- hx-post="/vote/{{ comp_idx }}"
40
- hx-vals='{"winner": "A"}'
41
- hx-target="#comparison-container">A is better</a>
42
- <a href="#" data-vote="tie" class="vote-btn vote-tie"
43
- hx-post="/vote/{{ comp_idx }}"
44
- hx-vals='{"winner": "tie"}'
45
- hx-target="#comparison-container">Tie</a>
46
- <a href="#" data-vote="B" class="vote-btn"
47
- hx-post="/vote/{{ comp_idx }}"
48
- hx-vals='{"winner": "B"}'
49
- hx-target="#comparison-container">B is better</a>
50
- </div>
51
- <div class="vote-hints">
52
- {% if not revealed %}
53
- <a href="#" data-action="reveal"
54
- hx-get="/reveal/{{ comp_idx }}"
55
- hx-target="#comparison-container">show judge verdict</a>
56
- <span class="separator">&middot;</span>
57
- {% endif %}
58
- <span class="keys">keys: <kbd>a</kbd> <kbd>t</kbd> <kbd>b</kbd> vote &middot; <kbd>&larr;</kbd> <kbd>&rarr;</kbd> navigate{% if not revealed %} &middot; <kbd>r</kbd> reveal{% endif %}</span>
59
- </div>
60
- {% endif %}
61
-
62
- {% if revealed %}
63
- <div class="verdict">
64
- {% if voted %}
65
- Judge: {{ judge_verdict }}
66
- &middot; You: {{ human_vote }}
67
- &middot; <span class="agreement {{ agreement_class }}">{{ agreement_word }}</span>
68
- {% else %}
69
- Judge: {{ judge_verdict }}
70
- {% endif %}
71
- {% if reason %}
72
- <span class="reason">"{{ reason }}"</span>
73
- {% endif %}
74
- </div>
75
- {% if just_voted and next_url %}
76
- <div hx-get="{{ next_url }}" hx-trigger="load delay:1.2s" hx-target="#comparison-container"></div>
77
- {% endif %}
78
- {% endif %}
79
-
80
- {% if has_image %}
81
- <div class="doc-image">
82
- <img src="/image/{{ sample_idx }}" alt="Document image" loading="lazy">
83
- </div>
84
- {% endif %}
85
-
86
- {% else %}
87
- <div class="empty">No comparisons match the current filters.</div>
88
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/templates/comparisons.html DELETED
@@ -1,40 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block title %}Comparisons — OCR Bench{% endblock %}
3
- {% block content %}
4
- <div class="filters">
5
- <label>Winner
6
- <select name="winner"
7
- hx-get="/comparisons/filter"
8
- hx-target="#comparison-container"
9
- hx-include="[name='model']">
10
- <option value="All" {% if winner_filter == "All" %}selected{% endif %}>All</option>
11
- <option value="A" {% if winner_filter == "A" %}selected{% endif %}>A</option>
12
- <option value="B" {% if winner_filter == "B" %}selected{% endif %}>B</option>
13
- <option value="tie" {% if winner_filter == "tie" %}selected{% endif %}>tie</option>
14
- </select>
15
- </label>
16
- <label>Model
17
- <select name="model"
18
- hx-get="/comparisons/filter"
19
- hx-target="#comparison-container"
20
- hx-include="[name='winner']">
21
- <option value="All" {% if model_filter == "All" %}selected{% endif %}>All</option>
22
- {% for m in models %}
23
- <option value="{{ m }}" {% if model_filter == m %}selected{% endif %}>{{ m }}</option>
24
- {% endfor %}
25
- </select>
26
- </label>
27
- </div>
28
-
29
- {% if pair_summary %}
30
- <div class="pair-summary">{{ pair_summary | safe }}</div>
31
- {% endif %}
32
-
33
- <div id="comparison-container">
34
- {% include "comparison_card.html" %}
35
- </div>
36
-
37
- <div id="stats-panel" hx-get="/stats" hx-trigger="vote-recorded from:body" hx-swap="innerHTML">
38
- {% include "stats_panel.html" %}
39
- </div>
40
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/templates/leaderboard.html DELETED
@@ -1,182 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block title %}Leaderboard — OCR Bench{% endblock %}
3
- {% block content %}
4
- <h2 style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem;">Leaderboard</h2>
5
- <p style="font-size: 0.8rem; color: #888; margin-top: 0;"><a href="https://huggingface.co/datasets/{{ repo_id }}" style="color: #888; text-decoration: underline;" target="_blank">{{ repo_id }}</a></p>
6
-
7
- <p style="font-size: 0.82rem; color: #999; line-height: 1.5; max-width: 48rem; margin: 0.75rem 0 1rem;">
8
- Rankings are computed using <strong style="color: #bbb;">Bradley-Terry MLE</strong> from pairwise comparisons judged by a vision-language model.
9
- The judge sees the original document image alongside two anonymised OCR outputs and picks the more faithful transcription.
10
- Browse the <a href="/comparisons" style="color: #aaa;">comparisons</a> to see the evidence — and vote yourself to build a Human ELO column.
11
- Human votes are stored locally for this session only and will reset when the server restarts.
12
- </p>
13
-
14
- <table>
15
- <thead>
16
- <tr>
17
- <th>#</th>
18
- <th>Model</th>
19
- <th class="num">Params</th>
20
- <th class="num">Judge ELO</th>
21
- {% if has_ci %}<th class="num">95% CI</th>{% endif %}
22
- <th class="num">Wins</th>
23
- <th class="num">Losses</th>
24
- <th class="num">Ties</th>
25
- <th class="num">Win%</th>
26
- {% if has_human_elo %}
27
- <th class="num">Human ELO</th>
28
- <th class="num">H-Win%</th>
29
- {% endif %}
30
- </tr>
31
- </thead>
32
- <tbody>
33
- {% for row in rows %}
34
- <tr>
35
- <td>{{ loop.index }}</td>
36
- <td class="model">{{ row.model_short }}</td>
37
- <td class="num">{{ row.params if row.params else "—" }}</td>
38
- <td class="num">{{ row.elo }}</td>
39
- {% if has_ci %}<td class="num">{{ row.elo_low }}&ndash;{{ row.elo_high }}</td>{% endif %}
40
- <td class="num">{{ row.wins }}</td>
41
- <td class="num">{{ row.losses }}</td>
42
- <td class="num">{{ row.ties }}</td>
43
- <td class="num">{{ row.win_pct }}%</td>
44
- {% if has_human_elo %}
45
- <td class="num">{{ row.human_elo if row.human_elo is not none else "—" }}</td>
46
- <td class="num">{{ row.human_win_pct if row.human_win_pct is not none else "—" }}</td>
47
- {% endif %}
48
- </tr>
49
- {% endfor %}
50
- </tbody>
51
- </table>
52
-
53
- {% if chart_points|length >= 2 %}
54
- <h3 style="font-size: 0.95rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem;">
55
- ELO vs Parameter Count
56
- </h3>
57
- <p style="font-size: 0.78rem; color: #888; margin-top: 0; margin-bottom: 0.75rem;">
58
- Smaller models can win on the right documents. Error bars show 95% confidence intervals.
59
- </p>
60
-
61
- <div style="max-width: 560px; position: relative;">
62
- <canvas id="paramsChart"></canvas>
63
- </div>
64
-
65
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
66
- <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
67
- <script>
68
- (function() {
69
- const points = {{ chart_points | tojson }};
70
- const colors = ['#6fa8dc', '#93c47d', '#e06666', '#f6b26b', '#8e7cc3'];
71
-
72
- const gridColor = 'rgba(255,255,255,0.1)';
73
- const textColor = '#aaa';
74
-
75
- const datasets = points.map((p, i) => ({
76
- label: p.name,
77
- data: [{
78
- x: p.params,
79
- y: p.elo,
80
- elo_low: p.elo_low,
81
- elo_high: p.elo_high,
82
- win_pct: p.win_pct,
83
- }],
84
- backgroundColor: colors[i % colors.length],
85
- borderColor: colors[i % colors.length],
86
- pointRadius: 10,
87
- pointHoverRadius: 13,
88
- }));
89
-
90
- // Custom plugin to draw error bars
91
- const errorBarPlugin = {
92
- id: 'errorBars',
93
- afterDatasetsDraw(chart) {
94
- const { ctx } = chart;
95
- chart.data.datasets.forEach((ds, i) => {
96
- const meta = chart.getDatasetMeta(i);
97
- meta.data.forEach((point, j) => {
98
- const d = ds.data[j];
99
- if (d.elo_low == null || d.elo_high == null) return;
100
- const xPx = point.x;
101
- const yLo = chart.scales.y.getPixelForValue(d.elo_low);
102
- const yHi = chart.scales.y.getPixelForValue(d.elo_high);
103
- const capW = 5;
104
- ctx.save();
105
- ctx.strokeStyle = ds.borderColor;
106
- ctx.lineWidth = 1.5;
107
- ctx.globalAlpha = 0.6;
108
- // Vertical line
109
- ctx.beginPath();
110
- ctx.moveTo(xPx, yLo);
111
- ctx.lineTo(xPx, yHi);
112
- ctx.stroke();
113
- // Top cap
114
- ctx.beginPath();
115
- ctx.moveTo(xPx - capW, yHi);
116
- ctx.lineTo(xPx + capW, yHi);
117
- ctx.stroke();
118
- // Bottom cap
119
- ctx.beginPath();
120
- ctx.moveTo(xPx - capW, yLo);
121
- ctx.lineTo(xPx + capW, yLo);
122
- ctx.stroke();
123
- ctx.restore();
124
- });
125
- });
126
- },
127
- };
128
-
129
- Chart.register(ChartDataLabels, errorBarPlugin);
130
-
131
- new Chart(document.getElementById('paramsChart'), {
132
- type: 'scatter',
133
- data: { datasets },
134
- options: {
135
- responsive: true,
136
- animation: { duration: 400 },
137
- scales: {
138
- x: {
139
- title: { display: true, text: 'Parameters (B)', color: textColor },
140
- grid: { color: gridColor },
141
- ticks: { color: textColor, callback: v => v + 'B' },
142
- },
143
- y: {
144
- title: { display: true, text: 'ELO', color: textColor },
145
- grid: { color: gridColor },
146
- ticks: { color: textColor },
147
- },
148
- },
149
- plugins: {
150
- legend: { display: false },
151
- tooltip: {
152
- callbacks: {
153
- label: ctx => {
154
- const d = ctx.raw;
155
- let s = `${ctx.dataset.label}: ${d.x}B, ELO ${d.y}`;
156
- if (d.elo_low != null) s += ` (${d.elo_low}\u2013${d.elo_high})`;
157
- s += `, ${d.win_pct}% wins`;
158
- return s;
159
- },
160
- },
161
- },
162
- datalabels: {
163
- align: function(ctx) {
164
- const d = ctx.dataset.data[0];
165
- const maxX = Math.max(...points.map(p => p.params));
166
- // Rightmost point: label left. Otherwise label right.
167
- if (d.x >= maxX) return 'left';
168
- return 'right';
169
- },
170
- anchor: 'center',
171
- offset: 12,
172
- color: textColor,
173
- font: { size: 11 },
174
- formatter: (val, ctx) => ctx.dataset.label,
175
- },
176
- },
177
- },
178
- });
179
- })();
180
- </script>
181
- {% endif %}
182
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/templates/stats_panel.html DELETED
@@ -1,10 +0,0 @@
1
- {% if vote_count > 0 %}
2
- <span>{{ vote_count }} vote{{ "s" if vote_count != 1 else "" }}</span>
3
- &middot;
4
- <span>{{ agreement_pct }}% agree</span>
5
- {% if hard_disagree_rate > 25 %}
6
- &middot; <span class="warning">judge may be miscalibrated</span>
7
- {% elif vote_count >= 15 %}
8
- &middot; <span class="calibrated">judge well-calibrated</span>
9
- {% endif %}
10
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/validate.py DELETED
@@ -1,362 +0,0 @@
1
- """Blind human A/B validation for OCR judge quality."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import random
8
- from collections import defaultdict
9
- from dataclasses import dataclass, field
10
- from typing import Any
11
-
12
- import structlog
13
-
14
- logger = structlog.get_logger()
15
-
16
- # Confidence thresholds
17
- MIN_ANNOTATIONS_FOR_CONFIDENCE = 15
18
- HIGH_AGREEMENT_THRESHOLD = 0.75
19
-
20
-
21
- @dataclass
22
- class AgreementStats:
23
- """Tracks agreement between human and VLM judge."""
24
-
25
- agree: int = 0
26
- soft_disagree: int = 0 # one picks tie, other picks winner
27
- hard_disagree: int = 0 # both pick winners but opposite
28
- total: int = 0
29
-
30
- @property
31
- def agreement_rate(self) -> float:
32
- """Rate including soft disagreements as partial agreement."""
33
- return (self.agree + self.soft_disagree) / self.total if self.total else 0.0
34
-
35
- @property
36
- def hard_disagree_rate(self) -> float:
37
- return self.hard_disagree / self.total if self.total else 0.0
38
-
39
-
40
- @dataclass
41
- class ValidationComparison:
42
- """A single comparison for human validation.
43
-
44
- Built from enriched comparison data published by the judge.
45
- """
46
-
47
- comparison_id: int
48
- sample_idx: int
49
- model_a: str
50
- model_b: str
51
- winner: str # judge's verdict (hidden during annotation)
52
- reason: str
53
- agreement: str # jury agreement (e.g. "2/2")
54
- text_a: str # OCR text from model A
55
- text_b: str # OCR text from model B
56
- col_a: str
57
- col_b: str
58
- swapped: bool # position-bias randomization for human display
59
- display_text_a: str = "" # text shown to human (may be swapped)
60
- display_text_b: str = ""
61
-
62
-
63
- @dataclass
64
- class ValidationSession:
65
- """Holds state for a validation session."""
66
-
67
- comparisons: list[ValidationComparison]
68
- model_names: list[str]
69
- metadata: dict[str, Any] = field(default_factory=dict)
70
- annotations: list[dict[str, Any]] = field(default_factory=list)
71
- completed_ids: set[int] = field(default_factory=set)
72
-
73
-
74
- def _is_split_jury(agreement: str) -> bool:
75
- """Check if a jury vote was split (e.g. '1/2' not '2/2')."""
76
- parts = agreement.split("/")
77
- return len(parts) == 2 and parts[0] != parts[1]
78
-
79
-
80
- def _interleave_by_sample(
81
- comparisons: list[ValidationComparison],
82
- ) -> list[ValidationComparison]:
83
- """Interleave comparisons so you see different samples before repeating."""
84
- by_sample: dict[int, list[ValidationComparison]] = defaultdict(list)
85
- for comp in comparisons:
86
- by_sample[comp.sample_idx].append(comp)
87
-
88
- result: list[ValidationComparison] = []
89
- queues = list(by_sample.values())
90
- while queues:
91
- next_round = []
92
- for q in queues:
93
- result.append(q.pop(0))
94
- if q:
95
- next_round.append(q)
96
- queues = next_round
97
- return result
98
-
99
-
100
- def _has_overlapping_cis(
101
- model_a: str,
102
- model_b: str,
103
- ci_map: dict[str, tuple[float, float]],
104
- ) -> bool:
105
- """Check if two models have overlapping confidence intervals."""
106
- if model_a not in ci_map or model_b not in ci_map:
107
- return True # assume overlapping if CI data missing for a model
108
- a_low, a_high = ci_map[model_a]
109
- b_low, b_high = ci_map[model_b]
110
- return max(a_low, b_low) < min(a_high, b_high)
111
-
112
-
113
- def build_validation_comparisons(
114
- comparison_rows: list[dict[str, Any]],
115
- *,
116
- leaderboard_rows: list[dict[str, Any]] | None = None,
117
- n: int | None = None,
118
- prioritize_splits: bool = True,
119
- seed: int = 42,
120
- ) -> list[ValidationComparison]:
121
- """Build validation comparisons from published judge results.
122
-
123
- Args:
124
- comparison_rows: Rows from the comparisons config of a results dataset.
125
- leaderboard_rows: Leaderboard rows with elo_low/elo_high for focus-pairs.
126
- When provided, comparisons between models with overlapping CIs are
127
- prioritized (those are where human input can change the ranking).
128
- n: Max number of comparisons to include (None = all).
129
- prioritize_splits: Show split-jury cases first (most informative).
130
- seed: Random seed for position-bias randomization.
131
- """
132
- rng = random.Random(seed)
133
-
134
- comps: list[ValidationComparison] = []
135
- for i, row in enumerate(comparison_rows):
136
- swapped = rng.random() < 0.5
137
- text_a = row.get("text_a", "")
138
- text_b = row.get("text_b", "")
139
-
140
- if swapped:
141
- display_a, display_b = text_b, text_a
142
- else:
143
- display_a, display_b = text_a, text_b
144
-
145
- comps.append(
146
- ValidationComparison(
147
- comparison_id=i,
148
- sample_idx=row.get("sample_idx", i),
149
- model_a=row.get("model_a", ""),
150
- model_b=row.get("model_b", ""),
151
- winner=row.get("winner", "tie"),
152
- reason=row.get("reason", ""),
153
- agreement=row.get("agreement", "1/1"),
154
- text_a=text_a,
155
- text_b=text_b,
156
- col_a=row.get("col_a", ""),
157
- col_b=row.get("col_b", ""),
158
- swapped=swapped,
159
- display_text_a=display_a,
160
- display_text_b=display_b,
161
- )
162
- )
163
-
164
- # Build CI map from leaderboard rows (if available and has CI data)
165
- ci_map: dict[str, tuple[float, float]] = {}
166
- if leaderboard_rows:
167
- for row in leaderboard_rows:
168
- model = row.get("model", "")
169
- lo = row.get("elo_low")
170
- hi = row.get("elo_high")
171
- if model and lo is not None and hi is not None:
172
- ci_map[model] = (lo, hi)
173
-
174
- if prioritize_splits and ci_map:
175
- # 4-tier priority: overlapping+split > overlapping+unanimous >
176
- # resolved+split > resolved+unanimous
177
- overlap_split: list[ValidationComparison] = []
178
- overlap_unanimous: list[ValidationComparison] = []
179
- resolved_split: list[ValidationComparison] = []
180
- resolved_unanimous: list[ValidationComparison] = []
181
- for c in comps:
182
- overlapping = _has_overlapping_cis(c.model_a, c.model_b, ci_map)
183
- split = _is_split_jury(c.agreement)
184
- if overlapping and split:
185
- overlap_split.append(c)
186
- elif overlapping:
187
- overlap_unanimous.append(c)
188
- elif split:
189
- resolved_split.append(c)
190
- else:
191
- resolved_unanimous.append(c)
192
- ordered = (
193
- _interleave_by_sample(overlap_split)
194
- + _interleave_by_sample(overlap_unanimous)
195
- + _interleave_by_sample(resolved_split)
196
- + _interleave_by_sample(resolved_unanimous)
197
- )
198
- elif prioritize_splits:
199
- splits = [c for c in comps if _is_split_jury(c.agreement)]
200
- unanimous = [c for c in comps if not _is_split_jury(c.agreement)]
201
- ordered = _interleave_by_sample(splits) + _interleave_by_sample(unanimous)
202
- else:
203
- ordered = _interleave_by_sample(comps)
204
-
205
- if n is not None and n < len(ordered):
206
- ordered = ordered[:n]
207
-
208
- # Re-assign comparison IDs after reordering
209
- return [
210
- ValidationComparison(
211
- comparison_id=i,
212
- sample_idx=c.sample_idx,
213
- model_a=c.model_a,
214
- model_b=c.model_b,
215
- winner=c.winner,
216
- reason=c.reason,
217
- agreement=c.agreement,
218
- text_a=c.text_a,
219
- text_b=c.text_b,
220
- col_a=c.col_a,
221
- col_b=c.col_b,
222
- swapped=c.swapped,
223
- display_text_a=c.display_text_a,
224
- display_text_b=c.display_text_b,
225
- )
226
- for i, c in enumerate(ordered)
227
- ]
228
-
229
-
230
- def compute_agreement(
231
- annotations: list[dict[str, Any]],
232
- comparisons: list[ValidationComparison],
233
- ) -> AgreementStats:
234
- """Compute agreement between human annotations and judge verdicts."""
235
- comp_by_id = {c.comparison_id: c for c in comparisons}
236
- stats = AgreementStats()
237
-
238
- for ann in annotations:
239
- comp = comp_by_id.get(ann.get("comparison_id"))
240
- if not comp:
241
- continue
242
-
243
- # Unswap human vote
244
- human_winner = ann["winner"]
245
- if comp.swapped:
246
- if human_winner == "A":
247
- human_winner = "B"
248
- elif human_winner == "B":
249
- human_winner = "A"
250
-
251
- judge_winner = comp.winner
252
- stats.total += 1
253
-
254
- if human_winner == judge_winner:
255
- stats.agree += 1
256
- elif human_winner == "tie" or judge_winner == "tie":
257
- stats.soft_disagree += 1
258
- else:
259
- stats.hard_disagree += 1
260
-
261
- return stats
262
-
263
-
264
- def compute_human_elo(
265
- annotations: list[dict[str, Any]],
266
- comparisons: list[ValidationComparison],
267
- ) -> Any:
268
- """Compute ELO leaderboard from human annotations.
269
-
270
- Returns a ``Leaderboard`` from ``elo.py``, or None if no annotations.
271
- """
272
- from ocr_bench.elo import ComparisonResult, compute_elo
273
-
274
- comp_by_id = {c.comparison_id: c for c in comparisons}
275
- model_set: set[str] = set()
276
- results: list[ComparisonResult] = []
277
-
278
- for ann in annotations:
279
- comp = comp_by_id.get(ann.get("comparison_id"))
280
- if not comp:
281
- continue
282
-
283
- # Unswap human vote to get canonical winner
284
- human_winner = ann["winner"]
285
- if comp.swapped:
286
- if human_winner == "A":
287
- human_winner = "B"
288
- elif human_winner == "B":
289
- human_winner = "A"
290
-
291
- model_set.add(comp.model_a)
292
- model_set.add(comp.model_b)
293
- results.append(
294
- ComparisonResult(
295
- sample_idx=comp.sample_idx,
296
- model_a=comp.model_a,
297
- model_b=comp.model_b,
298
- winner=human_winner,
299
- )
300
- )
301
-
302
- if not results:
303
- return None
304
-
305
- return compute_elo(results, sorted(model_set))
306
-
307
-
308
- def save_annotations(
309
- path: str,
310
- metadata: dict[str, Any],
311
- annotations: list[dict[str, Any]],
312
- ) -> None:
313
- """Atomically save annotations to JSON file."""
314
- data = {"metadata": metadata, "annotations": annotations}
315
- tmp = path + ".tmp"
316
- with open(tmp, "w") as f:
317
- json.dump(data, f, indent=2)
318
- os.replace(tmp, path)
319
-
320
-
321
- def load_annotations(path: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
322
- """Load annotations from JSON file. Returns (metadata, annotations)."""
323
- if not os.path.exists(path):
324
- return {}, []
325
- with open(path) as f:
326
- data = json.load(f)
327
- return data.get("metadata", {}), data.get("annotations", [])
328
-
329
-
330
- def _agreement_banner(stats: AgreementStats) -> str:
331
- """Format agreement stats for display."""
332
- if stats.total == 0:
333
- return ""
334
-
335
- parts = [f"Agree: {stats.agree}"]
336
- if stats.soft_disagree:
337
- parts.append(f"Soft: {stats.soft_disagree}")
338
- if stats.hard_disagree:
339
- parts.append(f"**Hard: {stats.hard_disagree}**")
340
- parts.append(f"(of {stats.total})")
341
-
342
- confidence = ""
343
- if stats.total >= MIN_ANNOTATIONS_FOR_CONFIDENCE:
344
- if stats.hard_disagree_rate == 0:
345
- confidence = (
346
- f" -- No hard disagreements after {stats.total} annotations. "
347
- "Judge rankings reliable for this domain."
348
- )
349
- elif stats.hard_disagree_rate <= 0.1:
350
- confidence = (
351
- f" -- Very few hard disagreements ({stats.hard_disagree}). "
352
- "Rankings likely trustworthy."
353
- )
354
- elif stats.hard_disagree_rate > 0.25:
355
- confidence = (
356
- f" -- Many hard disagreements ({stats.hard_disagree}/{stats.total}). "
357
- "Judge may not be calibrated for this content."
358
- )
359
-
360
- return f"Judge: {' | '.join(parts)}{confidence}"
361
-
362
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/viewer.py DELETED
@@ -1,202 +0,0 @@
1
- """Results viewer — data loading and helpers for OCR bench results."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import TYPE_CHECKING, Any
6
-
7
- import structlog
8
- from datasets import load_dataset
9
-
10
- if TYPE_CHECKING:
11
- from PIL import Image
12
-
13
- logger = structlog.get_logger()
14
-
15
-
16
- def load_results(repo_id: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
17
- """Load leaderboard and comparisons from a Hub results dataset.
18
-
19
- Tries the default config first (new repos), then falls back to the
20
- named ``leaderboard`` config (old repos).
21
-
22
- Returns:
23
- (leaderboard_rows, comparison_rows)
24
- """
25
- try:
26
- leaderboard_ds = load_dataset(repo_id, split="train")
27
- leaderboard_rows = [dict(row) for row in leaderboard_ds]
28
- except Exception:
29
- leaderboard_ds = load_dataset(repo_id, name="leaderboard", split="train")
30
- leaderboard_rows = [dict(row) for row in leaderboard_ds]
31
-
32
- try:
33
- comparisons_ds = load_dataset(repo_id, name="comparisons", split="train")
34
- except Exception:
35
- logger.warning("no_comparisons_config", repo=repo_id)
36
- return leaderboard_rows, []
37
- comparison_rows = [dict(row) for row in comparisons_ds]
38
-
39
- return leaderboard_rows, comparison_rows
40
-
41
-
42
- def _load_source_metadata(repo_id: str) -> dict[str, Any]:
43
- """Load metadata config from results repo to find the source dataset."""
44
- try:
45
- meta_ds = load_dataset(repo_id, name="metadata", split="train")
46
- if len(meta_ds) > 0:
47
- return dict(meta_ds[0])
48
- except Exception as exc:
49
- logger.warning("could_not_load_metadata", repo=repo_id, error=str(exc))
50
- return {}
51
-
52
-
53
- class ImageLoader:
54
- """Lazy image loader — fetches images from source dataset by sample_idx."""
55
-
56
- def __init__(self, source_dataset: str, from_prs: bool = False):
57
- self._source = source_dataset
58
- self._from_prs = from_prs
59
- self._cache: dict[int, Any] = {}
60
- self._image_col: str | None = None
61
- self._pr_revision: str | None = None
62
- self._available = True
63
- self._init_done = False
64
-
65
- def _init_source(self) -> None:
66
- """Lazy init: discover image column and PR revision on first call."""
67
- if self._init_done:
68
- return
69
- self._init_done = True
70
-
71
- try:
72
- if self._from_prs:
73
- from ocr_bench.dataset import discover_pr_configs
74
-
75
- _, revisions = discover_pr_configs(self._source)
76
- if revisions:
77
- # Use the first PR revision to get images
78
- first_config = next(iter(revisions))
79
- self._pr_revision = revisions[first_config]
80
-
81
- # Probe for image column by loading 1 row
82
- kwargs: dict[str, Any] = {"path": self._source, "split": "train[:1]"}
83
- if self._pr_revision:
84
- # Load from the first PR config
85
- first_config = next(iter(revisions))
86
- kwargs["name"] = first_config
87
- kwargs["revision"] = self._pr_revision
88
- probe = load_dataset(**kwargs)
89
- for col in probe.column_names:
90
- if col == "image" or "image" in col.lower():
91
- self._image_col = col
92
- break
93
- if not self._image_col:
94
- logger.info("no_image_column_in_source", source=self._source)
95
- self._available = False
96
- except Exception as exc:
97
- logger.warning("image_loader_init_failed", source=self._source, error=str(exc))
98
- self._available = False
99
-
100
- def get(self, sample_idx: int) -> Image.Image | None:
101
- """Fetch image for a sample index. Returns None on failure."""
102
- self._init_source()
103
- if not self._available or self._image_col is None:
104
- return None
105
- if sample_idx in self._cache:
106
- return self._cache[sample_idx]
107
- try:
108
- kwargs: dict[str, Any] = {
109
- "path": self._source,
110
- "split": f"train[{sample_idx}:{sample_idx + 1}]",
111
- }
112
- if self._pr_revision:
113
- from ocr_bench.dataset import discover_pr_configs
114
-
115
- _, revisions = discover_pr_configs(self._source)
116
- if revisions:
117
- first_config = next(iter(revisions))
118
- kwargs["name"] = first_config
119
- kwargs["revision"] = revisions[first_config]
120
- row = load_dataset(**kwargs)
121
- img = row[0][self._image_col]
122
- self._cache[sample_idx] = img
123
- return img
124
- except Exception as exc:
125
- logger.debug("image_load_failed", sample_idx=sample_idx, error=str(exc))
126
- return None
127
-
128
-
129
- def _filter_comparisons(
130
- comparisons: list[dict[str, Any]],
131
- winner_filter: str,
132
- model_filter: str,
133
- ) -> list[dict[str, Any]]:
134
- """Filter comparison rows by winner and model."""
135
- filtered = comparisons
136
- if winner_filter and winner_filter != "All":
137
- filtered = [c for c in filtered if c.get("winner") == winner_filter]
138
- if model_filter and model_filter != "All":
139
- filtered = [
140
- c
141
- for c in filtered
142
- if c.get("model_a") == model_filter or c.get("model_b") == model_filter
143
- ]
144
- return filtered
145
-
146
-
147
- def _winner_badge(winner: str) -> str:
148
- """Return a badge string for the winner."""
149
- if winner == "A":
150
- return "Winner: A"
151
- elif winner == "B":
152
- return "Winner: B"
153
- else:
154
- return "Tie"
155
-
156
-
157
- def _model_label(model: str, col: str) -> str:
158
- """Format model name with optional column name. Avoids empty parens."""
159
- if col:
160
- return f"{model} ({col})"
161
- return model
162
-
163
-
164
- def _build_pair_summary(comparisons: list[dict[str, Any]]) -> str:
165
- """Build a win/loss summary string for each model pair."""
166
- from collections import Counter
167
-
168
- pair_counts: dict[tuple[str, str], Counter[str]] = {}
169
- for c in comparisons:
170
- ma = c.get("model_a", "")
171
- mb = c.get("model_b", "")
172
- winner = c.get("winner", "tie")
173
- key = (ma, mb) if ma <= mb else (mb, ma)
174
- if key not in pair_counts:
175
- pair_counts[key] = Counter()
176
- # Track from perspective of first model in sorted pair
177
- if winner == "A":
178
- actual_winner = ma
179
- elif winner == "B":
180
- actual_winner = mb
181
- else:
182
- actual_winner = "tie"
183
-
184
- if actual_winner == key[0]:
185
- pair_counts[key]["W"] += 1
186
- elif actual_winner == key[1]:
187
- pair_counts[key]["L"] += 1
188
- else:
189
- pair_counts[key]["T"] += 1
190
-
191
- if not pair_counts:
192
- return ""
193
-
194
- parts = []
195
- for (ma, mb), counts in sorted(pair_counts.items()):
196
- short_a = ma.split("/")[-1] if "/" in ma else ma
197
- short_b = mb.split("/")[-1] if "/" in mb else mb
198
- wins, losses, ties = counts["W"], counts["L"], counts["T"]
199
- parts.append(f"**{short_a}** vs **{short_b}**: {wins}W {losses}L {ties}T")
200
- return " | ".join(parts)
201
-
202
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ocr_bench/web.py DELETED
@@ -1,514 +0,0 @@
1
- """FastAPI + HTMX viewer — unified browse + validate for OCR bench results."""
2
-
3
- from __future__ import annotations
4
-
5
- import io
6
- from dataclasses import dataclass, field
7
- from datetime import UTC, datetime
8
- from pathlib import Path
9
- from typing import Any
10
-
11
- import structlog
12
- from fastapi import FastAPI, Form, Request
13
- from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
14
- from fastapi.staticfiles import StaticFiles
15
- from fastapi.templating import Jinja2Templates
16
-
17
- from ocr_bench.validate import (
18
- ValidationComparison,
19
- build_validation_comparisons,
20
- compute_agreement,
21
- compute_human_elo,
22
- load_annotations,
23
- save_annotations,
24
- )
25
- from ocr_bench.viewer import (
26
- ImageLoader,
27
- _filter_comparisons,
28
- _load_source_metadata,
29
- load_results,
30
- )
31
-
32
- logger = structlog.get_logger()
33
-
34
-
35
- def _short_model(model: str) -> str:
36
- """Return just the model name after the org prefix."""
37
- return model.split("/")[-1] if "/" in model else model
38
-
39
-
40
- def _build_pair_summary_html(comparisons: list[dict[str, Any]]) -> str:
41
- """Build a compact HTML table of head-to-head records."""
42
- from collections import Counter
43
-
44
- pair_counts: dict[tuple[str, str], Counter[str]] = {}
45
- for c in comparisons:
46
- ma = c.get("model_a", "")
47
- mb = c.get("model_b", "")
48
- winner = c.get("winner", "tie")
49
- key = (ma, mb) if ma <= mb else (mb, ma)
50
- if key not in pair_counts:
51
- pair_counts[key] = Counter()
52
- if winner == "A":
53
- actual_winner = ma
54
- elif winner == "B":
55
- actual_winner = mb
56
- else:
57
- actual_winner = "tie"
58
- if actual_winner == key[0]:
59
- pair_counts[key]["W"] += 1
60
- elif actual_winner == key[1]:
61
- pair_counts[key]["L"] += 1
62
- else:
63
- pair_counts[key]["T"] += 1
64
-
65
- if not pair_counts:
66
- return ""
67
-
68
- rows = []
69
- for (ma, mb), counts in sorted(pair_counts.items()):
70
- short_a = _short_model(ma)
71
- short_b = _short_model(mb)
72
- wins, losses, ties = counts["W"], counts["L"], counts["T"]
73
- rows.append(
74
- f"<tr><td>{short_a}</td><td>{short_b}</td>"
75
- f"<td class='num'>{wins}</td><td class='num'>{losses}</td>"
76
- f"<td class='num'>{ties}</td></tr>"
77
- )
78
- return (
79
- '<table class="pair-table"><thead><tr>'
80
- "<th>Model A</th><th>Model B</th>"
81
- '<th class="num">W</th><th class="num">L</th><th class="num">T</th>'
82
- "</tr></thead><tbody>" + "".join(rows) + "</tbody></table>"
83
- )
84
-
85
-
86
- PKG_DIR = Path(__file__).parent
87
- TEMPLATES_DIR = PKG_DIR / "templates"
88
- STATIC_DIR = PKG_DIR / "static"
89
-
90
-
91
- @dataclass
92
- class ViewerState:
93
- """In-memory state for the single-user viewer."""
94
-
95
- repo_id: str
96
- leaderboard_rows: list[dict[str, Any]]
97
- comparison_rows: list[dict[str, Any]]
98
- validation_comps: list[ValidationComparison]
99
- models: list[str]
100
- img_loader: ImageLoader | None
101
- save_path: str
102
- annotations: list[dict[str, Any]] = field(default_factory=list)
103
- completed_ids: set[int] = field(default_factory=set)
104
- filtered_indices: list[int] = field(default_factory=list)
105
-
106
-
107
- def _build_filtered_indices(
108
- state: ViewerState,
109
- winner_filter: str = "All",
110
- model_filter: str = "All",
111
- ) -> list[int]:
112
- """Map nav indices to validation_comps indices, respecting filters."""
113
- filtered_comps = _filter_comparisons(state.comparison_rows, winner_filter, model_filter)
114
- # Build a lookup from (sample_idx, model_a, model_b) -> validation comp index
115
- filtered_sample_keys = {
116
- (c["sample_idx"], c["model_a"], c["model_b"]) for c in filtered_comps
117
- }
118
- return [
119
- i
120
- for i, vc in enumerate(state.validation_comps)
121
- if (vc.sample_idx, vc.model_a, vc.model_b) in filtered_sample_keys
122
- ]
123
-
124
-
125
- def create_app(
126
- repo_id: str,
127
- *,
128
- output_path: str | None = None,
129
- n_validate: int | None = None,
130
- ) -> FastAPI:
131
- """Create the FastAPI app with all routes.
132
-
133
- Args:
134
- repo_id: HF dataset repo with published judge results.
135
- output_path: Path to save human annotations JSON.
136
- n_validate: Max comparisons to include for validation (None = all).
137
- """
138
- app = FastAPI(title=f"OCR Bench — {repo_id}")
139
- app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
140
- templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
141
-
142
- # --- Load data ---
143
- leaderboard_rows, comparison_rows = load_results(repo_id)
144
-
145
- metadata = _load_source_metadata(repo_id)
146
- source_dataset = metadata.get("source_dataset", "")
147
- from_prs = metadata.get("from_prs", False)
148
-
149
- img_loader: ImageLoader | None = None
150
- if source_dataset:
151
- img_loader = ImageLoader(source_dataset, from_prs=from_prs)
152
-
153
- validation_comps = build_validation_comparisons(
154
- comparison_rows,
155
- leaderboard_rows=leaderboard_rows,
156
- n=n_validate,
157
- prioritize_splits=True,
158
- )
159
-
160
- models = sorted(
161
- {c.get("model_a", "") for c in comparison_rows}
162
- | {c.get("model_b", "") for c in comparison_rows}
163
- )
164
-
165
- slug = repo_id.replace("/", "-")
166
- save_path = output_path or f"human-eval-{slug}.json"
167
-
168
- # Resume existing annotations
169
- _, existing_annotations = load_annotations(save_path)
170
- completed_ids = {ann["comparison_id"] for ann in existing_annotations}
171
-
172
- state = ViewerState(
173
- repo_id=repo_id,
174
- leaderboard_rows=leaderboard_rows,
175
- comparison_rows=comparison_rows,
176
- validation_comps=validation_comps,
177
- models=models,
178
- img_loader=img_loader,
179
- save_path=save_path,
180
- annotations=existing_annotations,
181
- completed_ids=completed_ids,
182
- filtered_indices=list(range(len(validation_comps))),
183
- )
184
-
185
- # Store state on app for access in routes
186
- app.state.viewer = state
187
-
188
- ann_metadata = {
189
- "results_repo": repo_id,
190
- "n_comparisons": len(validation_comps),
191
- "models": models,
192
- "started_at": datetime.now(UTC).isoformat(),
193
- }
194
-
195
- # --- Helpers ---
196
-
197
- def _get_comp_context(
198
- nav_idx: int,
199
- *,
200
- revealed: bool = False,
201
- voted: bool = False,
202
- human_vote: str = "",
203
- winner_filter: str = "All",
204
- model_filter: str = "All",
205
- ) -> dict[str, Any]:
206
- """Build template context for a comparison card."""
207
- indices = state.filtered_indices
208
- if nav_idx < 0 or nav_idx >= len(indices):
209
- return {"comp": None, "nav_idx": nav_idx, "nav_total": len(indices)}
210
-
211
- comp_idx = indices[nav_idx]
212
- comp = state.validation_comps[comp_idx]
213
-
214
- # Check if already voted
215
- already_voted = comp.comparison_id in state.completed_ids
216
- if already_voted:
217
- voted = True
218
- revealed = True
219
- # Find the annotation to get human vote
220
- for ann in state.annotations:
221
- if ann["comparison_id"] == comp.comparison_id:
222
- human_vote = ann["winner"]
223
- break
224
-
225
- # Model names — short form for clean headers
226
- model_a_name = _short_model(comp.model_a)
227
- model_b_name = _short_model(comp.model_b)
228
- if comp.swapped:
229
- model_a_name, model_b_name = model_b_name, model_a_name
230
-
231
- # Judge verdict (canonical → display)
232
- judge_winner = comp.winner
233
- if comp.swapped:
234
- if judge_winner == "A":
235
- judge_verdict = "B"
236
- elif judge_winner == "B":
237
- judge_verdict = "A"
238
- else:
239
- judge_verdict = "tie"
240
- else:
241
- judge_verdict = judge_winner
242
-
243
- # Agreement
244
- agreement_word = ""
245
- agreement_class = ""
246
- if voted and human_vote:
247
- # Unswap human vote for comparison
248
- unswapped_human = human_vote
249
- if comp.swapped:
250
- if human_vote == "A":
251
- unswapped_human = "B"
252
- elif human_vote == "B":
253
- unswapped_human = "A"
254
-
255
- if unswapped_human == comp.winner:
256
- agreement_word = "agreed"
257
- agreement_class = "agreed"
258
- elif unswapped_human == "tie" or comp.winner == "tie":
259
- agreement_word = "soft disagree"
260
- agreement_class = "soft-disagree"
261
- else:
262
- agreement_word = "hard disagree"
263
- agreement_class = "hard-disagree"
264
-
265
- has_image = img_loader is not None
266
-
267
- return {
268
- "comp": comp,
269
- "comp_idx": comp_idx,
270
- "nav_idx": nav_idx,
271
- "nav_total": len(indices),
272
- "revealed": revealed,
273
- "voted": voted,
274
- "display_text_a": comp.display_text_a,
275
- "display_text_b": comp.display_text_b,
276
- "model_a_name": model_a_name,
277
- "model_b_name": model_b_name,
278
- "judge_verdict": judge_verdict,
279
- "human_vote": human_vote,
280
- "agreement_word": agreement_word,
281
- "agreement_class": agreement_class,
282
- "reason": comp.reason,
283
- "sample_idx": comp.sample_idx,
284
- "has_image": has_image,
285
- "winner_filter": winner_filter,
286
- "model_filter": model_filter,
287
- }
288
-
289
- def _stats_context() -> dict[str, Any]:
290
- """Build template context for the stats panel."""
291
- stats = compute_agreement(state.annotations, state.validation_comps)
292
- return {
293
- "vote_count": stats.total,
294
- "agreement_pct": round(stats.agreement_rate * 100) if stats.total else 0,
295
- "hard_disagree_rate": round(stats.hard_disagree_rate * 100) if stats.total else 0,
296
- }
297
-
298
- def _nav_idx_for_comp_idx(comp_idx: int) -> int:
299
- """Find the nav_idx for a given comp_idx in filtered_indices."""
300
- try:
301
- return state.filtered_indices.index(comp_idx)
302
- except ValueError:
303
- return 0
304
-
305
- # --- Routes ---
306
-
307
- @app.get("/", response_class=RedirectResponse)
308
- async def index():
309
- return RedirectResponse(url="/comparisons", status_code=302)
310
-
311
- @app.get("/leaderboard", response_class=HTMLResponse)
312
- async def leaderboard(request: Request):
313
- from ocr_bench.publish import _get_model_sizes
314
-
315
- # Build human ELO if we have annotations
316
- human_board = compute_human_elo(state.annotations, state.validation_comps)
317
- sizes = _get_model_sizes()
318
-
319
- rows = []
320
- for row in sorted(state.leaderboard_rows, key=lambda r: r.get("elo", 0), reverse=True):
321
- model = row.get("model", "")
322
- short = model.split("/")[-1] if "/" in model else model
323
- human_elo = None
324
- human_win_pct = None
325
- if human_board and model in human_board.elo:
326
- human_elo = round(human_board.elo[model])
327
- wp = human_board.win_pct(model)
328
- human_win_pct = f"{wp:.0f}" if wp is not None else None
329
-
330
- rows.append({
331
- "model": model,
332
- "model_short": short,
333
- "params": row.get("params") or sizes.get(model, ""),
334
- "elo": round(row.get("elo", 0)),
335
- "elo_low": row.get("elo_low"),
336
- "elo_high": row.get("elo_high"),
337
- "wins": row.get("wins", 0),
338
- "losses": row.get("losses", 0),
339
- "ties": row.get("ties", 0),
340
- "win_pct": row.get("win_pct", 0),
341
- "human_elo": human_elo,
342
- "human_win_pct": human_win_pct,
343
- })
344
-
345
- has_ci = any(r.get("elo_low") is not None for r in rows)
346
-
347
- # Build chart data — params (numeric) vs win%
348
- chart_points = []
349
- for r in rows:
350
- params_str = r.get("params", "")
351
- if params_str:
352
- try:
353
- params_num = float(params_str.rstrip("B"))
354
- except ValueError:
355
- continue
356
- chart_points.append({
357
- "name": r["model_short"],
358
- "params": params_num,
359
- "win_pct": r["win_pct"],
360
- "elo": r["elo"],
361
- "elo_low": r.get("elo_low"),
362
- "elo_high": r.get("elo_high"),
363
- })
364
-
365
- return templates.TemplateResponse(request, "leaderboard.html", {
366
- "active_tab": "leaderboard",
367
- "repo_id": state.repo_id,
368
- "rows": rows,
369
- "has_ci": has_ci,
370
- "has_human_elo": human_board is not None,
371
- "chart_points": chart_points,
372
- })
373
-
374
- @app.get("/comparisons", response_class=HTMLResponse)
375
- async def comparisons_page(request: Request):
376
- state.filtered_indices = _build_filtered_indices(state)
377
- pair_summary = _build_pair_summary_html(state.comparison_rows)
378
- ctx = _get_comp_context(0)
379
- stats = _stats_context()
380
- return templates.TemplateResponse(request, "comparisons.html", {
381
- "active_tab": "comparisons",
382
- "models": state.models,
383
- "pair_summary": pair_summary,
384
- "winner_filter": "All",
385
- "model_filter": "All",
386
- **ctx,
387
- **stats,
388
- })
389
-
390
- @app.get("/comparisons/filter", response_class=HTMLResponse)
391
- async def comparisons_filter(
392
- request: Request,
393
- winner: str = "All",
394
- model: str = "All",
395
- ):
396
- state.filtered_indices = _build_filtered_indices(state, winner, model)
397
- ctx = _get_comp_context(0, winner_filter=winner, model_filter=model)
398
- return templates.TemplateResponse(request, "comparison_card.html", ctx)
399
-
400
- @app.get("/comparisons/{nav_idx}", response_class=HTMLResponse)
401
- async def comparison_at(
402
- request: Request,
403
- nav_idx: int,
404
- winner: str = "All",
405
- model: str = "All",
406
- ):
407
- # Clamp nav_idx
408
- nav_idx = max(0, min(nav_idx, len(state.filtered_indices) - 1))
409
- ctx = _get_comp_context(nav_idx, winner_filter=winner, model_filter=model)
410
- return templates.TemplateResponse(request, "comparison_card.html", ctx)
411
-
412
- @app.post("/vote/{comp_idx}", response_class=HTMLResponse)
413
- async def vote(request: Request, comp_idx: int, winner: str = Form(...)):
414
- if comp_idx < 0 or comp_idx >= len(state.validation_comps):
415
- return HTMLResponse("Invalid comparison", status_code=404)
416
-
417
- comp = state.validation_comps[comp_idx]
418
-
419
- # Idempotent: if already voted, just return revealed card
420
- if comp.comparison_id not in state.completed_ids:
421
- # Unswap for storage
422
- winner_unswapped = winner
423
- if comp.swapped:
424
- if winner == "A":
425
- winner_unswapped = "B"
426
- elif winner == "B":
427
- winner_unswapped = "A"
428
-
429
- if winner_unswapped == "A":
430
- winner_model = comp.model_a
431
- elif winner_unswapped == "B":
432
- winner_model = comp.model_b
433
- else:
434
- winner_model = "tie"
435
-
436
- ann = {
437
- "comparison_id": comp.comparison_id,
438
- "sample_idx": comp.sample_idx,
439
- "model_a": comp.model_a,
440
- "model_b": comp.model_b,
441
- "swapped": comp.swapped,
442
- "winner": winner,
443
- "winner_model": winner_model,
444
- "timestamp": datetime.now(UTC).isoformat(),
445
- }
446
-
447
- state.annotations.append(ann)
448
- state.completed_ids.add(comp.comparison_id)
449
- save_annotations(state.save_path, ann_metadata, state.annotations)
450
-
451
- nav_idx = _nav_idx_for_comp_idx(comp_idx)
452
- # Read current filters from request query params (forwarded by htmx)
453
- winner_filter = request.query_params.get("winner", "All")
454
- model_filter = request.query_params.get("model", "All")
455
-
456
- ctx = _get_comp_context(
457
- nav_idx,
458
- revealed=True,
459
- voted=True,
460
- human_vote=winner,
461
- winner_filter=winner_filter,
462
- model_filter=model_filter,
463
- )
464
- # Auto-advance: tell template this was a fresh vote
465
- next_nav = nav_idx + 1 if nav_idx + 1 < len(state.filtered_indices) else None
466
- ctx["just_voted"] = True
467
- ctx["next_nav_idx"] = next_nav
468
- ctx["next_url"] = (
469
- f"/comparisons/{next_nav}"
470
- + (f"?winner={winner_filter}" if winner_filter != "All" else "")
471
- + (f"{'&' if winner_filter != 'All' else '?'}model={model_filter}" if model_filter != "All" else "")
472
- if next_nav is not None
473
- else None
474
- )
475
- response = templates.TemplateResponse(request, "comparison_card.html", ctx)
476
- response.headers["HX-Trigger"] = "vote-recorded"
477
- return response
478
-
479
- @app.get("/reveal/{comp_idx}", response_class=HTMLResponse)
480
- async def reveal(request: Request, comp_idx: int):
481
- if comp_idx < 0 or comp_idx >= len(state.validation_comps):
482
- return HTMLResponse("Invalid comparison", status_code=404)
483
-
484
- nav_idx = _nav_idx_for_comp_idx(comp_idx)
485
- winner_filter = request.query_params.get("winner", "All")
486
- model_filter = request.query_params.get("model", "All")
487
-
488
- ctx = _get_comp_context(
489
- nav_idx,
490
- revealed=True,
491
- voted=False,
492
- winner_filter=winner_filter,
493
- model_filter=model_filter,
494
- )
495
- return templates.TemplateResponse(request, "comparison_card.html", ctx)
496
-
497
- @app.get("/stats", response_class=HTMLResponse)
498
- async def stats(request: Request):
499
- ctx = _stats_context()
500
- return templates.TemplateResponse(request, "stats_panel.html", ctx)
501
-
502
- @app.get("/image/{sample_idx}")
503
- async def image(sample_idx: int):
504
- if img_loader is None:
505
- return HTMLResponse("No images available", status_code=404)
506
- img = img_loader.get(sample_idx)
507
- if img is None:
508
- return HTMLResponse("Image not found", status_code=404)
509
- buf = io.BytesIO()
510
- img.save(buf, format="PNG")
511
- buf.seek(0)
512
- return StreamingResponse(buf, media_type="image/png")
513
-
514
- return app