Spaces:
Sleeping
Sleeping
Sync src/ to 86e2b67 (viewer XSS fix + judge hardening)
Browse files- src/ocr_bench/__pycache__/__init__.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/__init__.cpython-314.pyc +0 -0
- src/ocr_bench/__pycache__/backends.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/cli.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/dataset.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/elo.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/elo.cpython-314.pyc +0 -0
- src/ocr_bench/__pycache__/judge.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/judge.cpython-314.pyc +0 -0
- src/ocr_bench/__pycache__/publish.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/run.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/validate.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/viewer.cpython-311.pyc +0 -0
- src/ocr_bench/__pycache__/web.cpython-311.pyc +0 -0
- src/ocr_bench/backends.py +68 -18
- src/ocr_bench/cli.py +90 -3
- src/ocr_bench/dataset.py +37 -2
- src/ocr_bench/elo.py +12 -1
- src/ocr_bench/judge.py +23 -9
- src/ocr_bench/publish.py +30 -11
- src/ocr_bench/py.typed +0 -0
- src/ocr_bench/run.py +78 -3
- src/ocr_bench/templates/leaderboard.html +140 -1
- src/ocr_bench/validate.py +52 -1
- src/ocr_bench/web.py +39 -5
src/ocr_bench/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (302 Bytes). View file
|
|
|
src/ocr_bench/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (295 Bytes). View file
|
|
|
src/ocr_bench/__pycache__/backends.cpython-311.pyc
ADDED
|
Binary file (13.4 kB). View file
|
|
|
src/ocr_bench/__pycache__/cli.cpython-311.pyc
ADDED
|
Binary file (33.7 kB). View file
|
|
|
src/ocr_bench/__pycache__/dataset.cpython-311.pyc
ADDED
|
Binary file (13.7 kB). View file
|
|
|
src/ocr_bench/__pycache__/elo.cpython-311.pyc
ADDED
|
Binary file (16 kB). View file
|
|
|
src/ocr_bench/__pycache__/elo.cpython-314.pyc
ADDED
|
Binary file (16.1 kB). View file
|
|
|
src/ocr_bench/__pycache__/judge.cpython-311.pyc
ADDED
|
Binary file (13.5 kB). View file
|
|
|
src/ocr_bench/__pycache__/judge.cpython-314.pyc
ADDED
|
Binary file (12.6 kB). View file
|
|
|
src/ocr_bench/__pycache__/publish.cpython-311.pyc
ADDED
|
Binary file (14 kB). View file
|
|
|
src/ocr_bench/__pycache__/run.cpython-311.pyc
ADDED
|
Binary file (9.67 kB). View file
|
|
|
src/ocr_bench/__pycache__/validate.cpython-311.pyc
ADDED
|
Binary file (15.9 kB). View file
|
|
|
src/ocr_bench/__pycache__/viewer.cpython-311.pyc
ADDED
|
Binary file (10.6 kB). View file
|
|
|
src/ocr_bench/__pycache__/web.cpython-311.pyc
ADDED
|
Binary file (23.9 kB). View file
|
|
|
src/ocr_bench/backends.py
CHANGED
|
@@ -5,19 +5,57 @@ from __future__ import annotations
|
|
| 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 |
-
#
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class JudgeBackend(abc.ABC):
|
|
@@ -38,7 +76,14 @@ class JudgeBackend(abc.ABC):
|
|
| 38 |
or an empty dict on failure.
|
| 39 |
"""
|
| 40 |
if self.concurrency <= 1 or len(comparisons) <= 1:
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
# Concurrent execution preserving order
|
| 44 |
results: list[dict[str, str]] = [{}] * len(comparisons)
|
|
@@ -69,11 +114,11 @@ class InferenceProviderJudge(JudgeBackend):
|
|
| 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) #
|
| 73 |
|
| 74 |
-
@stamina.retry(on=
|
| 75 |
def _call_single(self, comp: Comparison) -> dict[str, str]:
|
| 76 |
-
response = self.client.chat_completion( #
|
| 77 |
messages=comp.messages,
|
| 78 |
max_tokens=self.max_tokens,
|
| 79 |
temperature=0.0,
|
|
@@ -108,11 +153,11 @@ class OpenAICompatibleJudge(JudgeBackend):
|
|
| 108 |
self.concurrency = concurrency
|
| 109 |
self.client = OpenAI(base_url=base_url, api_key=api_key)
|
| 110 |
|
| 111 |
-
@stamina.retry(on=
|
| 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, #
|
| 116 |
max_tokens=self.max_tokens,
|
| 117 |
temperature=self.temperature,
|
| 118 |
extra_body=self.extra_body,
|
|
@@ -128,7 +173,7 @@ class OpenAICompatibleJudge(JudgeBackend):
|
|
| 128 |
# Spec parsing
|
| 129 |
# ---------------------------------------------------------------------------
|
| 130 |
|
| 131 |
-
DEFAULT_JUDGE = "novita:
|
| 132 |
|
| 133 |
|
| 134 |
def parse_judge_spec(
|
|
@@ -165,7 +210,7 @@ def parse_judge_spec(
|
|
| 165 |
model=model_name,
|
| 166 |
api_key=token,
|
| 167 |
max_tokens=max_tokens,
|
| 168 |
-
temperature=0.
|
| 169 |
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
| 170 |
concurrency=concurrency,
|
| 171 |
)
|
|
@@ -207,26 +252,31 @@ def aggregate_jury_votes(
|
|
| 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 |
-
|
|
|
|
|
|
|
| 215 |
votes: list[str] = []
|
| 216 |
reasons: list[str] = []
|
| 217 |
-
for
|
| 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"{
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
agreement = f"{majority_count}/{len(votes)}"
|
| 231 |
|
| 232 |
aggregated.append({
|
|
|
|
| 5 |
import abc
|
| 6 |
from collections import Counter
|
| 7 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 8 |
+
from itertools import zip_longest
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
import stamina
|
| 12 |
import structlog
|
| 13 |
from huggingface_hub import InferenceClient
|
| 14 |
+
from openai import APIConnectionError, OpenAI
|
| 15 |
|
| 16 |
from ocr_bench.judge import JUDGE_SCHEMA, Comparison, parse_judge_output
|
| 17 |
|
| 18 |
logger = structlog.get_logger()
|
| 19 |
|
| 20 |
+
# Number of attempts for a transient judge call (connection/timeout/429/5xx).
|
| 21 |
+
_RETRY_ATTEMPTS = 5
|
| 22 |
+
|
| 23 |
+
# Transport-level failures worth retrying — no HTTP status (connection reset,
|
| 24 |
+
# DNS failure, read timeout, server hang-up mid-response). `APIConnectionError`
|
| 25 |
+
# also covers the openai client's `APITimeoutError` (a subclass). HTTP errors are
|
| 26 |
+
# handled by status code below.
|
| 27 |
+
_TRANSPORT_ERRORS: tuple[type[BaseException], ...] = (APIConnectionError,)
|
| 28 |
+
try: # requests is always present via huggingface_hub; guard defensively
|
| 29 |
+
from requests.exceptions import ConnectionError as _ReqConnectionError
|
| 30 |
+
from requests.exceptions import Timeout as _ReqTimeout
|
| 31 |
+
|
| 32 |
+
_TRANSPORT_ERRORS += (_ReqConnectionError, _ReqTimeout)
|
| 33 |
+
except Exception: # pragma: no cover
|
| 34 |
+
pass
|
| 35 |
+
try: # httpx backs huggingface_hub's InferenceClient — the default judge path.
|
| 36 |
+
# Providers (e.g. Novita) disconnect on long runs, raising httpx
|
| 37 |
+
# TransportError subclasses (RemoteProtocolError, ConnectError, timeouts).
|
| 38 |
+
from httpx import TransportError as _HttpxTransportError
|
| 39 |
+
|
| 40 |
+
_TRANSPORT_ERRORS += (_HttpxTransportError,)
|
| 41 |
+
except Exception: # pragma: no cover
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _is_retryable(exc: Exception) -> bool:
|
| 46 |
+
"""Retry only *transient* judge failures.
|
| 47 |
+
|
| 48 |
+
Retries connection/timeout errors and HTTP 429 (rate limit) or 5xx
|
| 49 |
+
(server) responses. Fatal errors — a bad/expired token, a typo'd model
|
| 50 |
+
id, or any other 4xx — are NOT retried, so they surface immediately
|
| 51 |
+
instead of after several pointless backoffs.
|
| 52 |
+
"""
|
| 53 |
+
status = getattr(exc, "status_code", None)
|
| 54 |
+
if status is None:
|
| 55 |
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
| 56 |
+
if status is not None:
|
| 57 |
+
return status == 429 or status >= 500
|
| 58 |
+
return isinstance(exc, _TRANSPORT_ERRORS)
|
| 59 |
|
| 60 |
|
| 61 |
class JudgeBackend(abc.ABC):
|
|
|
|
| 76 |
or an empty dict on failure.
|
| 77 |
"""
|
| 78 |
if self.concurrency <= 1 or len(comparisons) <= 1:
|
| 79 |
+
results = []
|
| 80 |
+
for i, comp in enumerate(comparisons):
|
| 81 |
+
try:
|
| 82 |
+
results.append(self._call_single(comp))
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
logger.warning("judge_call_failed", idx=i, error=str(exc))
|
| 85 |
+
results.append({})
|
| 86 |
+
return results
|
| 87 |
|
| 88 |
# Concurrent execution preserving order
|
| 89 |
results: list[dict[str, str]] = [{}] * len(comparisons)
|
|
|
|
| 114 |
self.name = f"{provider + ':' if provider else ''}{model}"
|
| 115 |
self.model = model
|
| 116 |
self.max_tokens = max_tokens
|
| 117 |
+
self.client = InferenceClient(model=model, provider=provider) # ty: ignore[invalid-argument-type]
|
| 118 |
|
| 119 |
+
@stamina.retry(on=_is_retryable, attempts=_RETRY_ATTEMPTS)
|
| 120 |
def _call_single(self, comp: Comparison) -> dict[str, str]:
|
| 121 |
+
response = self.client.chat_completion( # ty: ignore[no-matching-overload]
|
| 122 |
messages=comp.messages,
|
| 123 |
max_tokens=self.max_tokens,
|
| 124 |
temperature=0.0,
|
|
|
|
| 153 |
self.concurrency = concurrency
|
| 154 |
self.client = OpenAI(base_url=base_url, api_key=api_key)
|
| 155 |
|
| 156 |
+
@stamina.retry(on=_is_retryable, attempts=_RETRY_ATTEMPTS)
|
| 157 |
def _call_single(self, comp: Comparison) -> dict[str, str]:
|
| 158 |
response = self.client.chat.completions.create(
|
| 159 |
model=self.model,
|
| 160 |
+
messages=comp.messages, # ty: ignore[invalid-argument-type]
|
| 161 |
max_tokens=self.max_tokens,
|
| 162 |
temperature=self.temperature,
|
| 163 |
extra_body=self.extra_body,
|
|
|
|
| 173 |
# Spec parsing
|
| 174 |
# ---------------------------------------------------------------------------
|
| 175 |
|
| 176 |
+
DEFAULT_JUDGE = "novita:Qwen/Qwen3.5-35B-A3B"
|
| 177 |
|
| 178 |
|
| 179 |
def parse_judge_spec(
|
|
|
|
| 210 |
model=model_name,
|
| 211 |
api_key=token,
|
| 212 |
max_tokens=max_tokens,
|
| 213 |
+
temperature=0.0,
|
| 214 |
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
| 215 |
concurrency=concurrency,
|
| 216 |
)
|
|
|
|
| 252 |
if not all_results:
|
| 253 |
return []
|
| 254 |
|
|
|
|
|
|
|
| 255 |
aggregated: list[dict[str, Any]] = []
|
| 256 |
|
| 257 |
+
# Transpose judge-major results to comparison-major; a judge that
|
| 258 |
+
# returned a short list pads out with failures.
|
| 259 |
+
for judge_results in zip_longest(*all_results, fillvalue={}):
|
| 260 |
votes: list[str] = []
|
| 261 |
reasons: list[str] = []
|
| 262 |
+
for name, result in zip(judge_names, judge_results):
|
|
|
|
| 263 |
winner = result.get("winner", "")
|
| 264 |
if winner:
|
| 265 |
votes.append(winner)
|
| 266 |
+
reasons.append(f"{name}: {result.get('reason', '')}")
|
| 267 |
|
| 268 |
if not votes:
|
| 269 |
aggregated.append({"winner": "tie", "reason": "no valid votes", "agreement": "0/0"})
|
| 270 |
continue
|
| 271 |
|
| 272 |
counter = Counter(votes)
|
| 273 |
+
top = counter.most_common(2)
|
| 274 |
+
majority_winner, majority_count = top[0]
|
| 275 |
+
if len(top) > 1 and top[1][1] == majority_count:
|
| 276 |
+
# No strict majority (1-1, 2-2, three-way split, ...): record a
|
| 277 |
+
# tie rather than letting Counter's insertion order side with
|
| 278 |
+
# whichever judge happens to be listed first.
|
| 279 |
+
majority_winner = "tie"
|
| 280 |
agreement = f"{majority_count}/{len(votes)}"
|
| 281 |
|
| 282 |
aggregated.append({
|
src/ocr_bench/cli.py
CHANGED
|
@@ -6,6 +6,7 @@ import argparse
|
|
| 6 |
import sys
|
| 7 |
|
| 8 |
import structlog
|
|
|
|
| 9 |
from rich.console import Console
|
| 10 |
from rich.table import Table
|
| 11 |
|
|
@@ -87,6 +88,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 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",
|
|
@@ -132,14 +142,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 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 |
return parser
|
| 136 |
|
| 137 |
|
| 138 |
def print_leaderboard(board: Leaderboard) -> None:
|
| 139 |
"""Print leaderboard as a Rich table."""
|
|
|
|
|
|
|
|
|
|
| 140 |
table = Table(title="OCR Model Leaderboard")
|
| 141 |
table.add_column("Rank", style="bold")
|
| 142 |
table.add_column("Model")
|
|
|
|
| 143 |
has_ci = bool(board.elo_ci)
|
| 144 |
if has_ci:
|
| 145 |
table.add_column("ELO (95% CI)", justify="right")
|
|
@@ -161,6 +183,7 @@ def print_leaderboard(board: Leaderboard) -> None:
|
|
| 161 |
table.add_row(
|
| 162 |
str(rank),
|
| 163 |
model,
|
|
|
|
| 164 |
elo_str,
|
| 165 |
str(board.wins[model]),
|
| 166 |
str(board.losses[model]),
|
|
@@ -177,7 +200,9 @@ def _convert_results(
|
|
| 177 |
"""Convert judged comparisons + aggregated outputs into ComparisonResult list."""
|
| 178 |
results: list[ComparisonResult] = []
|
| 179 |
for comp, result in zip(comparisons, aggregated):
|
| 180 |
-
|
|
|
|
|
|
|
| 181 |
continue
|
| 182 |
results.append(
|
| 183 |
ComparisonResult(
|
|
@@ -420,6 +445,7 @@ def cmd_judge(args: argparse.Namespace) -> None:
|
|
| 420 |
board,
|
| 421 |
metadata,
|
| 422 |
existing_metadata=existing_meta_rows,
|
|
|
|
| 423 |
)
|
| 424 |
console.print(f"\nResults published to [bold]{results_repo}[/bold]")
|
| 425 |
return
|
|
@@ -451,7 +477,13 @@ def cmd_judge(args: argparse.Namespace) -> None:
|
|
| 451 |
valid_comparisons=len(new_results),
|
| 452 |
from_prs=from_prs,
|
| 453 |
)
|
| 454 |
-
publish_results(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
console.print(f"\nResults published to [bold]{results_repo}[/bold]")
|
| 456 |
|
| 457 |
|
|
@@ -476,7 +508,8 @@ def cmd_run(args: argparse.Namespace) -> None:
|
|
| 476 |
for slug in sorted(MODEL_REGISTRY):
|
| 477 |
cfg = MODEL_REGISTRY[slug]
|
| 478 |
default = " (default)" if slug in DEFAULT_MODELS else ""
|
| 479 |
-
|
|
|
|
| 480 |
|
| 481 |
console.print(table)
|
| 482 |
console.print(f"\nDefault set: {', '.join(DEFAULT_MODELS)}")
|
|
@@ -515,6 +548,10 @@ def cmd_run(args: argparse.Namespace) -> None:
|
|
| 515 |
console.print(f"[cyan]{slug}[/cyan] ({cfg.model_id})")
|
| 516 |
console.print(f" Flavor: {flavor}")
|
| 517 |
console.print(f" Timeout: {args.timeout}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
console.print(f" Script: {cfg.script}")
|
| 519 |
console.print(f" Args: {' '.join(script_args)}")
|
| 520 |
console.print()
|
|
@@ -569,6 +606,47 @@ def cmd_view(args: argparse.Namespace) -> None:
|
|
| 569 |
uvicorn.run(app, host=args.host, port=args.port)
|
| 570 |
|
| 571 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
def main() -> None:
|
| 573 |
parser = build_parser()
|
| 574 |
args = parser.parse_args()
|
|
@@ -584,6 +662,15 @@ def main() -> None:
|
|
| 584 |
cmd_run(args)
|
| 585 |
elif args.command == "view":
|
| 586 |
cmd_view(args)
|
|
|
|
|
|
|
| 587 |
except DatasetError as exc:
|
| 588 |
console.print(f"[red]Error:[/red] {exc}")
|
| 589 |
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import sys
|
| 7 |
|
| 8 |
import structlog
|
| 9 |
+
from openai import OpenAIError
|
| 10 |
from rich.console import Console
|
| 11 |
from rich.table import Table
|
| 12 |
|
|
|
|
| 88 |
action="store_true",
|
| 89 |
help="Don't publish results (default: publish to {dataset}-results)",
|
| 90 |
)
|
| 91 |
+
judge.add_argument(
|
| 92 |
+
"--license",
|
| 93 |
+
default=None,
|
| 94 |
+
help=(
|
| 95 |
+
"License tag for the published results dataset card, e.g. cc0-1.0 "
|
| 96 |
+
"(default: none — the results embed source-derived text, so only "
|
| 97 |
+
"the publisher knows the right license)"
|
| 98 |
+
),
|
| 99 |
+
)
|
| 100 |
judge.add_argument(
|
| 101 |
"--full-rejudge",
|
| 102 |
action="store_true",
|
|
|
|
| 142 |
view.add_argument("--host", default="127.0.0.1", help="Host (default: 127.0.0.1)")
|
| 143 |
view.add_argument("--output", default=None, help="Path to save annotations JSON")
|
| 144 |
|
| 145 |
+
# --- publish subcommand ---
|
| 146 |
+
publish = sub.add_parser("publish", help="Deploy results viewer as a Hugging Face Space")
|
| 147 |
+
publish.add_argument("results", help="HF results dataset repo id to view in the Space")
|
| 148 |
+
publish.add_argument(
|
| 149 |
+
"--space", default=None, help="Space repo id (default: {results}-viewer)"
|
| 150 |
+
)
|
| 151 |
+
publish.add_argument("--private", action="store_true", help="Make the Space private")
|
| 152 |
+
|
| 153 |
return parser
|
| 154 |
|
| 155 |
|
| 156 |
def print_leaderboard(board: Leaderboard) -> None:
|
| 157 |
"""Print leaderboard as a Rich table."""
|
| 158 |
+
from ocr_bench.publish import _get_model_sizes
|
| 159 |
+
|
| 160 |
+
sizes = _get_model_sizes()
|
| 161 |
table = Table(title="OCR Model Leaderboard")
|
| 162 |
table.add_column("Rank", style="bold")
|
| 163 |
table.add_column("Model")
|
| 164 |
+
table.add_column("Params", justify="right")
|
| 165 |
has_ci = bool(board.elo_ci)
|
| 166 |
if has_ci:
|
| 167 |
table.add_column("ELO (95% CI)", justify="right")
|
|
|
|
| 183 |
table.add_row(
|
| 184 |
str(rank),
|
| 185 |
model,
|
| 186 |
+
sizes.get(model, ""),
|
| 187 |
elo_str,
|
| 188 |
str(board.wins[model]),
|
| 189 |
str(board.losses[model]),
|
|
|
|
| 200 |
"""Convert judged comparisons + aggregated outputs into ComparisonResult list."""
|
| 201 |
results: list[ComparisonResult] = []
|
| 202 |
for comp, result in zip(comparisons, aggregated):
|
| 203 |
+
# Skip failures: empty dict (single judge failed) and 0/0 "ties"
|
| 204 |
+
# (every judge in a jury failed) — neither is a real verdict.
|
| 205 |
+
if not result or result.get("agreement") == "0/0":
|
| 206 |
continue
|
| 207 |
results.append(
|
| 208 |
ComparisonResult(
|
|
|
|
| 445 |
board,
|
| 446 |
metadata,
|
| 447 |
existing_metadata=existing_meta_rows,
|
| 448 |
+
license_id=args.license,
|
| 449 |
)
|
| 450 |
console.print(f"\nResults published to [bold]{results_repo}[/bold]")
|
| 451 |
return
|
|
|
|
| 477 |
valid_comparisons=len(new_results),
|
| 478 |
from_prs=from_prs,
|
| 479 |
)
|
| 480 |
+
publish_results(
|
| 481 |
+
results_repo,
|
| 482 |
+
board,
|
| 483 |
+
metadata,
|
| 484 |
+
existing_metadata=existing_meta_rows,
|
| 485 |
+
license_id=args.license,
|
| 486 |
+
)
|
| 487 |
console.print(f"\nResults published to [bold]{results_repo}[/bold]")
|
| 488 |
|
| 489 |
|
|
|
|
| 508 |
for slug in sorted(MODEL_REGISTRY):
|
| 509 |
cfg = MODEL_REGISTRY[slug]
|
| 510 |
default = " (default)" if slug in DEFAULT_MODELS else ""
|
| 511 |
+
gpu = cfg.default_flavor + (" (image-mode)" if cfg.image else "")
|
| 512 |
+
table.add_row(slug + default, cfg.model_id, cfg.size, gpu)
|
| 513 |
|
| 514 |
console.print(table)
|
| 515 |
console.print(f"\nDefault set: {', '.join(DEFAULT_MODELS)}")
|
|
|
|
| 548 |
console.print(f"[cyan]{slug}[/cyan] ({cfg.model_id})")
|
| 549 |
console.print(f" Flavor: {flavor}")
|
| 550 |
console.print(f" Timeout: {args.timeout}")
|
| 551 |
+
if cfg.image:
|
| 552 |
+
console.print(f" Image: {cfg.image}")
|
| 553 |
+
console.print(f" Python: {cfg.python}")
|
| 554 |
+
console.print(f" Env: {cfg.env}")
|
| 555 |
console.print(f" Script: {cfg.script}")
|
| 556 |
console.print(f" Args: {' '.join(script_args)}")
|
| 557 |
console.print()
|
|
|
|
| 606 |
uvicorn.run(app, host=args.host, port=args.port)
|
| 607 |
|
| 608 |
|
| 609 |
+
SPACE_TEMPLATE = "davanstrien/ocr-bench-space-template"
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def cmd_publish(args: argparse.Namespace) -> None:
|
| 613 |
+
"""Deploy results viewer as a Hugging Face Space."""
|
| 614 |
+
from huggingface_hub import HfApi, SpaceHardware
|
| 615 |
+
|
| 616 |
+
api = HfApi()
|
| 617 |
+
results = args.results
|
| 618 |
+
space_id = args.space or f"{results}-viewer"
|
| 619 |
+
|
| 620 |
+
console.print(f"Deploying viewer for [bold]{results}[/bold] to [bold]{space_id}[/bold]...")
|
| 621 |
+
|
| 622 |
+
api.duplicate_space(
|
| 623 |
+
from_id=SPACE_TEMPLATE,
|
| 624 |
+
to_id=space_id,
|
| 625 |
+
private=args.private if args.private else None,
|
| 626 |
+
hardware=SpaceHardware.CPU_BASIC,
|
| 627 |
+
exist_ok=True,
|
| 628 |
+
variables=[{"key": "REPOS", "value": results}],
|
| 629 |
+
)
|
| 630 |
+
|
| 631 |
+
api.add_space_variable(repo_id=space_id, key="REPOS", value=results)
|
| 632 |
+
|
| 633 |
+
# Update Space metadata to link to results dataset
|
| 634 |
+
try:
|
| 635 |
+
from huggingface_hub import metadata_update
|
| 636 |
+
|
| 637 |
+
metadata_update(
|
| 638 |
+
space_id,
|
| 639 |
+
{"datasets": [results], "tags": ["ocr-bench"]},
|
| 640 |
+
repo_type="space",
|
| 641 |
+
overwrite=True,
|
| 642 |
+
)
|
| 643 |
+
except Exception as exc:
|
| 644 |
+
logger.warning("space_metadata_update_failed", error=str(exc))
|
| 645 |
+
|
| 646 |
+
url = f"https://huggingface.co/spaces/{space_id}"
|
| 647 |
+
console.print(f"[green]Space published![/green] {url}")
|
| 648 |
+
|
| 649 |
+
|
| 650 |
def main() -> None:
|
| 651 |
parser = build_parser()
|
| 652 |
args = parser.parse_args()
|
|
|
|
| 662 |
cmd_run(args)
|
| 663 |
elif args.command == "view":
|
| 664 |
cmd_view(args)
|
| 665 |
+
elif args.command == "publish":
|
| 666 |
+
cmd_publish(args)
|
| 667 |
except DatasetError as exc:
|
| 668 |
console.print(f"[red]Error:[/red] {exc}")
|
| 669 |
sys.exit(1)
|
| 670 |
+
except (OpenAIError, OSError) as exc:
|
| 671 |
+
# Judge or Hub request failed (bad/expired token, unknown model id,
|
| 672 |
+
# rate limit, provider/network outage) — every requests/HfHubHTTPError
|
| 673 |
+
# subclasses OSError — or another OS-level error. Fail with a clean
|
| 674 |
+
# message instead of dumping a traceback on the user.
|
| 675 |
+
console.print(f"[red]Error:[/red] {exc}")
|
| 676 |
+
sys.exit(1)
|
src/ocr_bench/dataset.py
CHANGED
|
@@ -211,11 +211,46 @@ def load_config_dataset(
|
|
| 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 |
if "inference_info" not in ds.column_names:
|
| 220 |
return config
|
| 221 |
try:
|
|
@@ -223,7 +258,7 @@ def _extract_model_id(ds: Dataset, config: str) -> str:
|
|
| 223 |
if info_raw:
|
| 224 |
info = json.loads(info_raw)
|
| 225 |
if isinstance(info, list):
|
| 226 |
-
info = info[
|
| 227 |
return info.get("model_id", info.get("model_name", config))
|
| 228 |
except (json.JSONDecodeError, TypeError, KeyError, IndexError):
|
| 229 |
pass
|
|
|
|
| 211 |
if unified is None:
|
| 212 |
raise DatasetError("No configs loaded successfully")
|
| 213 |
|
| 214 |
+
# Disambiguate configs that resolve to the same model_id (mirrors the flat
|
| 215 |
+
# discover_ocr_columns path). Downstream the ELO keys models by these values,
|
| 216 |
+
# so two configs of the same model run with different settings (e.g.
|
| 217 |
+
# `nuextract3` vs `nuextract3-rep`) would silently collapse into one
|
| 218 |
+
# leaderboard row. On collision, label by config name; keep the bare
|
| 219 |
+
# model_id when unique.
|
| 220 |
+
model_counts: dict[str, int] = {}
|
| 221 |
+
for model_id in ocr_columns.values():
|
| 222 |
+
model_counts[model_id] = model_counts.get(model_id, 0) + 1
|
| 223 |
+
duplicates = sorted(mid for mid, n in model_counts.items() if n > 1)
|
| 224 |
+
if duplicates:
|
| 225 |
+
# Capture the colliding config → model_id mapping before relabeling so
|
| 226 |
+
# the warning is actionable (which configs/model_ids collided, in which
|
| 227 |
+
# repo) when running multiple datasets/config sweeps.
|
| 228 |
+
collided = {
|
| 229 |
+
config: model_id
|
| 230 |
+
for config, model_id in ocr_columns.items()
|
| 231 |
+
if model_counts[model_id] > 1
|
| 232 |
+
}
|
| 233 |
+
for config, model_id in list(ocr_columns.items()):
|
| 234 |
+
if model_counts[model_id] > 1:
|
| 235 |
+
short = model_id.split("/")[-1] if "/" in model_id else model_id
|
| 236 |
+
ocr_columns[config] = f"{short} ({config})"
|
| 237 |
+
logger.warning(
|
| 238 |
+
"duplicate_model_ids",
|
| 239 |
+
repo_id=repo_id,
|
| 240 |
+
model_ids=duplicates,
|
| 241 |
+
collided_configs=collided,
|
| 242 |
+
note="configs sharing a model_id were labelled by config name to keep them distinct",
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
return unified, ocr_columns
|
| 246 |
|
| 247 |
|
| 248 |
def _extract_model_id(ds: Dataset, config: str) -> str:
|
| 249 |
+
"""Extract model_id from inference_info in first row, falling back to config name.
|
| 250 |
+
|
| 251 |
+
Takes the *last* entry in the inference_info list, since OCR scripts append
|
| 252 |
+
new entries — the last one is the model that actually produced this config.
|
| 253 |
+
"""
|
| 254 |
if "inference_info" not in ds.column_names:
|
| 255 |
return config
|
| 256 |
try:
|
|
|
|
| 258 |
if info_raw:
|
| 259 |
info = json.loads(info_raw)
|
| 260 |
if isinstance(info, list):
|
| 261 |
+
info = info[-1]
|
| 262 |
return info.get("model_id", info.get("model_name", config))
|
| 263 |
except (json.JSONDecodeError, TypeError, KeyError, IndexError):
|
| 264 |
pass
|
src/ocr_bench/elo.py
CHANGED
|
@@ -282,13 +282,24 @@ def compute_elo(
|
|
| 282 |
board.ties[r.model_a] += 1
|
| 283 |
board.ties[r.model_b] += 1
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
board.comparison_log.append(
|
| 286 |
{
|
| 287 |
"sample_idx": r.sample_idx,
|
| 288 |
"model_a": r.model_a,
|
| 289 |
"model_b": r.model_b,
|
| 290 |
"winner": winner,
|
| 291 |
-
"reason":
|
| 292 |
"agreement": r.agreement,
|
| 293 |
"text_a": r.text_a,
|
| 294 |
"text_b": r.text_b,
|
|
|
|
| 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,
|
src/ocr_bench/judge.py
CHANGED
|
@@ -40,8 +40,10 @@ stamps, handwritten notes. Missing any section of text is a significant penalty.
|
|
| 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.
|
| 44 |
-
|
|
|
|
|
|
|
| 45 |
|
| 46 |
If both outputs capture the same text with similar accuracy, respond with "tie". \
|
| 47 |
Only pick a winner when there is a clear quality difference.
|
|
@@ -273,15 +275,27 @@ def parse_judge_output(text: str) -> dict[str, str]:
|
|
| 273 |
"""
|
| 274 |
text = text.strip()
|
| 275 |
if text.startswith("```"):
|
| 276 |
-
|
|
|
|
|
|
|
| 277 |
try:
|
| 278 |
result = json.loads(text)
|
| 279 |
-
winner = result.get("winner", "tie").upper().strip()
|
| 280 |
-
if winner == "TIE":
|
| 281 |
-
winner = "tie"
|
| 282 |
-
if winner not in ("A", "B", "tie"):
|
| 283 |
-
winner = "tie"
|
| 284 |
-
return {"winner": winner, "reason": result.get("reason", "")}
|
| 285 |
except json.JSONDecodeError:
|
| 286 |
logger.warning("Failed to parse judge output: %s", text[:200])
|
| 287 |
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 275 |
"""
|
| 276 |
text = text.strip()
|
| 277 |
if text.startswith("```"):
|
| 278 |
+
# A truncated response can be a bare opening fence with no body
|
| 279 |
+
parts = text.split("\n", 1)
|
| 280 |
+
text = parts[1].rsplit("```", 1)[0].strip() if len(parts) == 2 else ""
|
| 281 |
try:
|
| 282 |
result = json.loads(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
except json.JSONDecodeError:
|
| 284 |
logger.warning("Failed to parse judge output: %s", text[:200])
|
| 285 |
return {}
|
| 286 |
+
if not isinstance(result, dict):
|
| 287 |
+
logger.warning("Judge output is not a JSON object: %s", text[:200])
|
| 288 |
+
return {}
|
| 289 |
+
winner = result.get("winner", "tie")
|
| 290 |
+
if not isinstance(winner, str):
|
| 291 |
+
logger.warning("Judge output has non-string winner: %s", text[:200])
|
| 292 |
+
return {}
|
| 293 |
+
winner = winner.upper().strip()
|
| 294 |
+
if winner == "TIE":
|
| 295 |
+
winner = "tie"
|
| 296 |
+
if winner not in ("A", "B", "tie"):
|
| 297 |
+
winner = "tie"
|
| 298 |
+
reason = result.get("reason", "")
|
| 299 |
+
if not isinstance(reason, str):
|
| 300 |
+
reason = json.dumps(reason)
|
| 301 |
+
return {"winner": winner, "reason": reason}
|
src/ocr_bench/publish.py
CHANGED
|
@@ -11,6 +11,7 @@ from datasets import Dataset, load_dataset
|
|
| 11 |
from huggingface_hub import HfApi
|
| 12 |
|
| 13 |
from ocr_bench.elo import ComparisonResult, Leaderboard
|
|
|
|
| 14 |
|
| 15 |
logger = structlog.get_logger()
|
| 16 |
|
|
@@ -79,14 +80,21 @@ def load_existing_metadata(repo_id: str) -> list[dict]:
|
|
| 79 |
return []
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
def build_leaderboard_rows(board: Leaderboard) -> list[dict]:
|
| 83 |
"""Convert a Leaderboard into rows suitable for a Hub dataset."""
|
|
|
|
| 84 |
rows = []
|
| 85 |
for model, elo in board.ranked:
|
| 86 |
total = board.wins[model] + board.losses[model] + board.ties[model]
|
| 87 |
row = {
|
| 88 |
"model": model,
|
| 89 |
"elo": round(elo),
|
|
|
|
| 90 |
"wins": board.wins[model],
|
| 91 |
"losses": board.losses[model],
|
| 92 |
"ties": board.ties[model],
|
|
@@ -119,6 +127,7 @@ def publish_results(
|
|
| 119 |
board: Leaderboard,
|
| 120 |
metadata: EvalMetadata,
|
| 121 |
existing_metadata: list[dict] | None = None,
|
|
|
|
| 122 |
) -> None:
|
| 123 |
"""Push evaluation results to Hub as a dataset with multiple configs.
|
| 124 |
|
|
@@ -151,7 +160,7 @@ def publish_results(
|
|
| 151 |
logger.info("published_metadata", repo=repo_id, n=len(all_meta))
|
| 152 |
|
| 153 |
# README — auto-generated dataset card with leaderboard
|
| 154 |
-
readme = _build_readme(repo_id, rows, board, metadata)
|
| 155 |
api = HfApi()
|
| 156 |
api.upload_file(
|
| 157 |
path_or_fileobj=readme.encode(),
|
|
@@ -167,6 +176,7 @@ def _build_readme(
|
|
| 167 |
rows: list[dict],
|
| 168 |
board: Leaderboard,
|
| 169 |
metadata: EvalMetadata,
|
|
|
|
| 170 |
) -> str:
|
| 171 |
"""Build a dataset card README with the leaderboard table."""
|
| 172 |
has_ci = bool(board.elo_ci)
|
|
@@ -179,12 +189,19 @@ def _build_readme(
|
|
| 179 |
judge_str = ", ".join(j.split("/")[-1] for j in judges) if judges else "N/A"
|
| 180 |
n_comparisons = len(board.comparison_log)
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
"tags:",
|
| 186 |
" - ocr-bench",
|
| 187 |
" - leaderboard",
|
|
|
|
|
|
|
| 188 |
"configs:",
|
| 189 |
" - config_name: default",
|
| 190 |
" data_files:",
|
|
@@ -215,25 +232,27 @@ def _build_readme(
|
|
| 215 |
|
| 216 |
# Table header
|
| 217 |
if has_ci:
|
| 218 |
-
lines.append("| Rank | Model | ELO | 95% CI | Wins | Losses | Ties | Win% |")
|
| 219 |
-
lines.append("|------|-------|-----|--------|------|--------|------|------|")
|
| 220 |
else:
|
| 221 |
-
lines.append("| Rank | Model | ELO | Wins | Losses | Ties | Win% |")
|
| 222 |
-
lines.append("|------|-------|-----|------|--------|------|------|")
|
| 223 |
|
| 224 |
for rank, row in enumerate(rows, 1):
|
| 225 |
-
|
|
|
|
| 226 |
elo = row["elo"]
|
|
|
|
| 227 |
if has_ci and "elo_low" in row:
|
| 228 |
ci = f"{row['elo_low']}\u2013{row['elo_high']}"
|
| 229 |
lines.append(
|
| 230 |
-
f"| {rank} | {model} | {elo} | {ci} "
|
| 231 |
f"| {row['wins']} | {row['losses']} | {row['ties']} "
|
| 232 |
f"| {row['win_pct']}% |"
|
| 233 |
)
|
| 234 |
else:
|
| 235 |
lines.append(
|
| 236 |
-
f"| {rank} | {model} | {elo} "
|
| 237 |
f"| {row['wins']} | {row['losses']} | {row['ties']} "
|
| 238 |
f"| {row['win_pct']}% |"
|
| 239 |
)
|
|
|
|
| 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 |
|
|
|
|
| 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],
|
|
|
|
| 127 |
board: Leaderboard,
|
| 128 |
metadata: EvalMetadata,
|
| 129 |
existing_metadata: list[dict] | None = None,
|
| 130 |
+
license_id: str | None = None,
|
| 131 |
) -> None:
|
| 132 |
"""Push evaluation results to Hub as a dataset with multiple configs.
|
| 133 |
|
|
|
|
| 160 |
logger.info("published_metadata", repo=repo_id, n=len(all_meta))
|
| 161 |
|
| 162 |
# README — auto-generated dataset card with leaderboard
|
| 163 |
+
readme = _build_readme(repo_id, rows, board, metadata, license_id=license_id)
|
| 164 |
api = HfApi()
|
| 165 |
api.upload_file(
|
| 166 |
path_or_fileobj=readme.encode(),
|
|
|
|
| 176 |
rows: list[dict],
|
| 177 |
board: Leaderboard,
|
| 178 |
metadata: EvalMetadata,
|
| 179 |
+
license_id: str | None = None,
|
| 180 |
) -> str:
|
| 181 |
"""Build a dataset card README with the leaderboard table."""
|
| 182 |
has_ci = bool(board.elo_ci)
|
|
|
|
| 189 |
judge_str = ", ".join(j.split("/")[-1] for j in judges) if judges else "N/A"
|
| 190 |
n_comparisons = len(board.comparison_log)
|
| 191 |
|
| 192 |
+
# The card license describes the published results DATA (which embeds
|
| 193 |
+
# OCR text derived from the source dataset), not this tool — so there is
|
| 194 |
+
# no correct default; it's declared per-run via --license or set on the
|
| 195 |
+
# Hub repo by the publisher.
|
| 196 |
+
lines = ["---"]
|
| 197 |
+
if license_id:
|
| 198 |
+
lines.append(f"license: {license_id}")
|
| 199 |
+
lines += [
|
| 200 |
"tags:",
|
| 201 |
" - ocr-bench",
|
| 202 |
" - leaderboard",
|
| 203 |
+
"source_datasets:",
|
| 204 |
+
f" - {metadata.source_dataset}",
|
| 205 |
"configs:",
|
| 206 |
" - config_name: default",
|
| 207 |
" data_files:",
|
|
|
|
| 232 |
|
| 233 |
# Table header
|
| 234 |
if has_ci:
|
| 235 |
+
lines.append("| Rank | Model | Params | ELO | 95% CI | Wins | Losses | Ties | Win% |")
|
| 236 |
+
lines.append("|------|-------|--------|-----|--------|------|--------|------|------|")
|
| 237 |
else:
|
| 238 |
+
lines.append("| Rank | Model | Params | ELO | Wins | Losses | Ties | Win% |")
|
| 239 |
+
lines.append("|------|-------|--------|-----|------|--------|------|------|")
|
| 240 |
|
| 241 |
for rank, row in enumerate(rows, 1):
|
| 242 |
+
# Escape pipes so arbitrary model names can't break the table
|
| 243 |
+
model = str(row["model"]).replace("|", "\\|")
|
| 244 |
elo = row["elo"]
|
| 245 |
+
params = row.get("params", "")
|
| 246 |
if has_ci and "elo_low" in row:
|
| 247 |
ci = f"{row['elo_low']}\u2013{row['elo_high']}"
|
| 248 |
lines.append(
|
| 249 |
+
f"| {rank} | {model} | {params} | {elo} | {ci} "
|
| 250 |
f"| {row['wins']} | {row['losses']} | {row['ties']} "
|
| 251 |
f"| {row['win_pct']}% |"
|
| 252 |
)
|
| 253 |
else:
|
| 254 |
lines.append(
|
| 255 |
+
f"| {rank} | {model} | {params} | {elo} "
|
| 256 |
f"| {row['wins']} | {row['losses']} | {row['ties']} "
|
| 257 |
f"| {row['win_pct']}% |"
|
| 258 |
)
|
src/ocr_bench/py.typed
ADDED
|
File without changes
|
src/ocr_bench/run.py
CHANGED
|
@@ -4,6 +4,7 @@ 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
|
|
@@ -13,13 +14,33 @@ logger = structlog.get_logger()
|
|
| 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] = {
|
|
@@ -48,9 +69,46 @@ MODEL_REGISTRY: dict[str, ModelConfig] = {
|
|
| 48 |
size="1.7B",
|
| 49 |
default_flavor="l4x1",
|
| 50 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
|
| 53 |
-
DEFAULT_MODELS = ["glm-ocr", "deepseek-ocr", "lighton-ocr-2", "dots-ocr"]
|
| 54 |
|
| 55 |
|
| 56 |
@dataclass
|
|
@@ -139,13 +197,30 @@ def launch_ocr_jobs(
|
|
| 139 |
extra_args=config.default_args or None,
|
| 140 |
)
|
| 141 |
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
job = api.run_uv_job(
|
| 144 |
script=config.script,
|
| 145 |
script_args=script_args,
|
| 146 |
flavor=flavor,
|
| 147 |
secrets={"HF_TOKEN": token},
|
| 148 |
timeout=timeout,
|
|
|
|
| 149 |
)
|
| 150 |
jobs.append(JobRun(model_slug=slug, job_id=job.id, job_url=job.url))
|
| 151 |
logger.info("job_launched", model=slug, job_id=job.id, url=job.url)
|
|
|
|
| 4 |
|
| 5 |
import time
|
| 6 |
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any
|
| 8 |
|
| 9 |
import structlog
|
| 10 |
from huggingface_hub import HfApi, get_token
|
|
|
|
| 14 |
|
| 15 |
@dataclass
|
| 16 |
class ModelConfig:
|
| 17 |
+
"""Configuration for a single OCR model.
|
| 18 |
+
|
| 19 |
+
``image`` / ``python`` / ``env`` are only needed by "image-mode" models —
|
| 20 |
+
ones whose CUDA kernels (e.g. flashinfer for Qwen3.5) must come from a
|
| 21 |
+
prebuilt Docker image because the default uv-script image lacks ``nvcc``.
|
| 22 |
+
They are passed straight through to ``run_uv_job`` and left ``None`` for
|
| 23 |
+
every standard model, which keeps the launch call identical to before.
|
| 24 |
+
"""
|
| 25 |
|
| 26 |
script: str
|
| 27 |
model_id: str
|
| 28 |
size: str
|
| 29 |
default_flavor: str = "l4x1"
|
| 30 |
default_args: list[str] = field(default_factory=list)
|
| 31 |
+
image: str | None = None
|
| 32 |
+
python: str | None = None
|
| 33 |
+
env: dict[str, str] | None = None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# Image-mode invocation for models needing prebuilt CUDA kernels (Qwen3.5 /
|
| 37 |
+
# flashinfer). The default uv-script image has no ``nvcc`` so flashinfer's JIT
|
| 38 |
+
# compile fails at vLLM warmup; the vllm/vllm-openai image ships them prebuilt.
|
| 39 |
+
# ``python`` points at that image's interpreter and ``env`` puts its site-packages
|
| 40 |
+
# on the path so ``uv run`` reuses them instead of rebuilding.
|
| 41 |
+
_VLLM_OPENAI_IMAGE = "vllm/vllm-openai:latest"
|
| 42 |
+
_VLLM_OPENAI_PYTHON = "/usr/bin/python3"
|
| 43 |
+
_VLLM_OPENAI_ENV = {"PYTHONPATH": "/usr/local/lib/python3.12/dist-packages"}
|
| 44 |
|
| 45 |
|
| 46 |
MODEL_REGISTRY: dict[str, ModelConfig] = {
|
|
|
|
| 69 |
size="1.7B",
|
| 70 |
default_flavor="l4x1",
|
| 71 |
),
|
| 72 |
+
"firered-ocr": ModelConfig(
|
| 73 |
+
script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/firered-ocr.py",
|
| 74 |
+
model_id="FireRedTeam/FireRed-OCR",
|
| 75 |
+
size="2.1B",
|
| 76 |
+
default_flavor="l4x1",
|
| 77 |
+
),
|
| 78 |
+
"qianfan-ocr": ModelConfig(
|
| 79 |
+
script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/qianfan-ocr.py",
|
| 80 |
+
model_id="baidu/Qianfan-OCR",
|
| 81 |
+
size="4.7B",
|
| 82 |
+
default_flavor="l4x1",
|
| 83 |
+
),
|
| 84 |
+
"dots-mocr": ModelConfig(
|
| 85 |
+
script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-mocr.py",
|
| 86 |
+
model_id="rednote-hilab/dots.mocr",
|
| 87 |
+
size="3B",
|
| 88 |
+
default_flavor="l4x1",
|
| 89 |
+
),
|
| 90 |
+
# Image-mode models (Qwen3.5 / flashinfer) — need the vllm/vllm-openai image.
|
| 91 |
+
"nuextract3": ModelConfig(
|
| 92 |
+
script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nuextract3.py",
|
| 93 |
+
model_id="numind/NuExtract3",
|
| 94 |
+
size="4B",
|
| 95 |
+
default_flavor="a100-large",
|
| 96 |
+
image=_VLLM_OPENAI_IMAGE,
|
| 97 |
+
python=_VLLM_OPENAI_PYTHON,
|
| 98 |
+
env=_VLLM_OPENAI_ENV,
|
| 99 |
+
),
|
| 100 |
+
"paddleocr-vl-1.6": ModelConfig(
|
| 101 |
+
script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl-1.6.py",
|
| 102 |
+
model_id="PaddlePaddle/PaddleOCR-VL-1.6",
|
| 103 |
+
size="0.9B",
|
| 104 |
+
default_flavor="a100-large",
|
| 105 |
+
image=_VLLM_OPENAI_IMAGE,
|
| 106 |
+
python=_VLLM_OPENAI_PYTHON,
|
| 107 |
+
env=_VLLM_OPENAI_ENV,
|
| 108 |
+
),
|
| 109 |
}
|
| 110 |
|
| 111 |
+
DEFAULT_MODELS = ["glm-ocr", "deepseek-ocr", "lighton-ocr-2", "dots-ocr", "firered-ocr"]
|
| 112 |
|
| 113 |
|
| 114 |
@dataclass
|
|
|
|
| 197 |
extra_args=config.default_args or None,
|
| 198 |
)
|
| 199 |
|
| 200 |
+
# Only image-mode models set image/python/env; standard models keep the
|
| 201 |
+
# exact same run_uv_job call as before (no extra kwargs).
|
| 202 |
+
extra_kwargs: dict[str, Any] = {}
|
| 203 |
+
if config.image:
|
| 204 |
+
extra_kwargs["image"] = config.image
|
| 205 |
+
if config.python:
|
| 206 |
+
extra_kwargs["python"] = config.python
|
| 207 |
+
if config.env:
|
| 208 |
+
extra_kwargs["env"] = config.env
|
| 209 |
+
|
| 210 |
+
logger.info(
|
| 211 |
+
"launching_job",
|
| 212 |
+
model=slug,
|
| 213 |
+
flavor=flavor,
|
| 214 |
+
script=config.script,
|
| 215 |
+
image=config.image,
|
| 216 |
+
)
|
| 217 |
job = api.run_uv_job(
|
| 218 |
script=config.script,
|
| 219 |
script_args=script_args,
|
| 220 |
flavor=flavor,
|
| 221 |
secrets={"HF_TOKEN": token},
|
| 222 |
timeout=timeout,
|
| 223 |
+
**extra_kwargs,
|
| 224 |
)
|
| 225 |
jobs.append(JobRun(model_slug=slug, job_id=job.id, job_url=job.url))
|
| 226 |
logger.info("job_launched", model=slug, job_id=job.id, url=job.url)
|
src/ocr_bench/templates/leaderboard.html
CHANGED
|
@@ -2,13 +2,21 @@
|
|
| 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;">{{ repo_id }}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
<table>
|
| 8 |
<thead>
|
| 9 |
<tr>
|
| 10 |
<th>#</th>
|
| 11 |
<th>Model</th>
|
|
|
|
| 12 |
<th class="num">Judge ELO</th>
|
| 13 |
{% if has_ci %}<th class="num">95% CI</th>{% endif %}
|
| 14 |
<th class="num">Wins</th>
|
|
@@ -26,6 +34,7 @@
|
|
| 26 |
<tr>
|
| 27 |
<td>{{ loop.index }}</td>
|
| 28 |
<td class="model">{{ row.model_short }}</td>
|
|
|
|
| 29 |
<td class="num">{{ row.elo }}</td>
|
| 30 |
{% if has_ci %}<td class="num">{{ row.elo_low }}–{{ row.elo_high }}</td>{% endif %}
|
| 31 |
<td class="num">{{ row.wins }}</td>
|
|
@@ -40,4 +49,134 @@
|
|
| 40 |
{% endfor %}
|
| 41 |
</tbody>
|
| 42 |
</table>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
{% endblock %}
|
|
|
|
| 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>
|
|
|
|
| 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 }}–{{ row.elo_high }}</td>{% endif %}
|
| 40 |
<td class="num">{{ row.wins }}</td>
|
|
|
|
| 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/validate.py
CHANGED
|
@@ -97,9 +97,23 @@ def _interleave_by_sample(
|
|
| 97 |
return result
|
| 98 |
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def build_validation_comparisons(
|
| 101 |
comparison_rows: list[dict[str, Any]],
|
| 102 |
*,
|
|
|
|
| 103 |
n: int | None = None,
|
| 104 |
prioritize_splits: bool = True,
|
| 105 |
seed: int = 42,
|
|
@@ -108,6 +122,9 @@ def build_validation_comparisons(
|
|
| 108 |
|
| 109 |
Args:
|
| 110 |
comparison_rows: Rows from the comparisons config of a results dataset.
|
|
|
|
|
|
|
|
|
|
| 111 |
n: Max number of comparisons to include (None = all).
|
| 112 |
prioritize_splits: Show split-jury cases first (most informative).
|
| 113 |
seed: Random seed for position-bias randomization.
|
|
@@ -144,7 +161,41 @@ def build_validation_comparisons(
|
|
| 144 |
)
|
| 145 |
)
|
| 146 |
|
| 147 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
splits = [c for c in comps if _is_split_jury(c.agreement)]
|
| 149 |
unanimous = [c for c in comps if not _is_split_jury(c.agreement)]
|
| 150 |
ordered = _interleave_by_sample(splits) + _interleave_by_sample(unanimous)
|
|
|
|
| 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,
|
|
|
|
| 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.
|
|
|
|
| 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)
|
src/ocr_bench/web.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import io
|
| 6 |
from dataclasses import dataclass, field
|
| 7 |
from datetime import UTC, datetime
|
|
@@ -67,8 +68,10 @@ def _build_pair_summary_html(comparisons: list[dict[str, Any]]) -> str:
|
|
| 67 |
|
| 68 |
rows = []
|
| 69 |
for (ma, mb), counts in sorted(pair_counts.items()):
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
wins, losses, ties = counts["W"], counts["L"], counts["T"]
|
| 73 |
rows.append(
|
| 74 |
f"<tr><td>{short_a}</td><td>{short_b}</td>"
|
|
@@ -151,7 +154,10 @@ def create_app(
|
|
| 151 |
img_loader = ImageLoader(source_dataset, from_prs=from_prs)
|
| 152 |
|
| 153 |
validation_comps = build_validation_comparisons(
|
| 154 |
-
comparison_rows,
|
|
|
|
|
|
|
|
|
|
| 155 |
)
|
| 156 |
|
| 157 |
models = sorted(
|
|
@@ -303,12 +309,15 @@ def create_app(
|
|
| 303 |
|
| 304 |
@app.get("/", response_class=RedirectResponse)
|
| 305 |
async def index():
|
| 306 |
-
return RedirectResponse(url="/
|
| 307 |
|
| 308 |
@app.get("/leaderboard", response_class=HTMLResponse)
|
| 309 |
async def leaderboard(request: Request):
|
|
|
|
|
|
|
| 310 |
# Build human ELO if we have annotations
|
| 311 |
human_board = compute_human_elo(state.annotations, state.validation_comps)
|
|
|
|
| 312 |
|
| 313 |
rows = []
|
| 314 |
for row in sorted(state.leaderboard_rows, key=lambda r: r.get("elo", 0), reverse=True):
|
|
@@ -324,6 +333,7 @@ def create_app(
|
|
| 324 |
rows.append({
|
| 325 |
"model": model,
|
| 326 |
"model_short": short,
|
|
|
|
| 327 |
"elo": round(row.get("elo", 0)),
|
| 328 |
"elo_low": row.get("elo_low"),
|
| 329 |
"elo_high": row.get("elo_high"),
|
|
@@ -336,12 +346,32 @@ def create_app(
|
|
| 336 |
})
|
| 337 |
|
| 338 |
has_ci = any(r.get("elo_low") is not None for r in rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
return templates.TemplateResponse(request, "leaderboard.html", {
|
| 340 |
"active_tab": "leaderboard",
|
| 341 |
"repo_id": state.repo_id,
|
| 342 |
"rows": rows,
|
| 343 |
"has_ci": has_ci,
|
| 344 |
"has_human_elo": human_board is not None,
|
|
|
|
| 345 |
})
|
| 346 |
|
| 347 |
@app.get("/comparisons", response_class=HTMLResponse)
|
|
@@ -441,7 +471,11 @@ def create_app(
|
|
| 441 |
ctx["next_url"] = (
|
| 442 |
f"/comparisons/{next_nav}"
|
| 443 |
+ (f"?winner={winner_filter}" if winner_filter != "All" else "")
|
| 444 |
-
+ (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
if next_nav is not None
|
| 446 |
else None
|
| 447 |
)
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import html
|
| 6 |
import io
|
| 7 |
from dataclasses import dataclass, field
|
| 8 |
from datetime import UTC, datetime
|
|
|
|
| 68 |
|
| 69 |
rows = []
|
| 70 |
for (ma, mb), counts in sorted(pair_counts.items()):
|
| 71 |
+
# Model names come from dataset columns — escape before embedding in
|
| 72 |
+
# HTML that the template renders with `| safe`.
|
| 73 |
+
short_a = html.escape(_short_model(ma))
|
| 74 |
+
short_b = html.escape(_short_model(mb))
|
| 75 |
wins, losses, ties = counts["W"], counts["L"], counts["T"]
|
| 76 |
rows.append(
|
| 77 |
f"<tr><td>{short_a}</td><td>{short_b}</td>"
|
|
|
|
| 154 |
img_loader = ImageLoader(source_dataset, from_prs=from_prs)
|
| 155 |
|
| 156 |
validation_comps = build_validation_comparisons(
|
| 157 |
+
comparison_rows,
|
| 158 |
+
leaderboard_rows=leaderboard_rows,
|
| 159 |
+
n=n_validate,
|
| 160 |
+
prioritize_splits=True,
|
| 161 |
)
|
| 162 |
|
| 163 |
models = sorted(
|
|
|
|
| 309 |
|
| 310 |
@app.get("/", response_class=RedirectResponse)
|
| 311 |
async def index():
|
| 312 |
+
return RedirectResponse(url="/leaderboard", status_code=302)
|
| 313 |
|
| 314 |
@app.get("/leaderboard", response_class=HTMLResponse)
|
| 315 |
async def leaderboard(request: Request):
|
| 316 |
+
from ocr_bench.publish import _get_model_sizes
|
| 317 |
+
|
| 318 |
# Build human ELO if we have annotations
|
| 319 |
human_board = compute_human_elo(state.annotations, state.validation_comps)
|
| 320 |
+
sizes = _get_model_sizes()
|
| 321 |
|
| 322 |
rows = []
|
| 323 |
for row in sorted(state.leaderboard_rows, key=lambda r: r.get("elo", 0), reverse=True):
|
|
|
|
| 333 |
rows.append({
|
| 334 |
"model": model,
|
| 335 |
"model_short": short,
|
| 336 |
+
"params": row.get("params") or sizes.get(model, ""),
|
| 337 |
"elo": round(row.get("elo", 0)),
|
| 338 |
"elo_low": row.get("elo_low"),
|
| 339 |
"elo_high": row.get("elo_high"),
|
|
|
|
| 346 |
})
|
| 347 |
|
| 348 |
has_ci = any(r.get("elo_low") is not None for r in rows)
|
| 349 |
+
|
| 350 |
+
# Build chart data — params (numeric) vs win%
|
| 351 |
+
chart_points = []
|
| 352 |
+
for r in rows:
|
| 353 |
+
params_str = r.get("params", "")
|
| 354 |
+
if params_str:
|
| 355 |
+
try:
|
| 356 |
+
params_num = float(params_str.rstrip("B"))
|
| 357 |
+
except ValueError:
|
| 358 |
+
continue
|
| 359 |
+
chart_points.append({
|
| 360 |
+
"name": r["model_short"],
|
| 361 |
+
"params": params_num,
|
| 362 |
+
"win_pct": r["win_pct"],
|
| 363 |
+
"elo": r["elo"],
|
| 364 |
+
"elo_low": r.get("elo_low"),
|
| 365 |
+
"elo_high": r.get("elo_high"),
|
| 366 |
+
})
|
| 367 |
+
|
| 368 |
return templates.TemplateResponse(request, "leaderboard.html", {
|
| 369 |
"active_tab": "leaderboard",
|
| 370 |
"repo_id": state.repo_id,
|
| 371 |
"rows": rows,
|
| 372 |
"has_ci": has_ci,
|
| 373 |
"has_human_elo": human_board is not None,
|
| 374 |
+
"chart_points": chart_points,
|
| 375 |
})
|
| 376 |
|
| 377 |
@app.get("/comparisons", response_class=HTMLResponse)
|
|
|
|
| 471 |
ctx["next_url"] = (
|
| 472 |
f"/comparisons/{next_nav}"
|
| 473 |
+ (f"?winner={winner_filter}" if winner_filter != "All" else "")
|
| 474 |
+
+ (
|
| 475 |
+
f"{'&' if winner_filter != 'All' else '?'}model={model_filter}"
|
| 476 |
+
if model_filter != "All"
|
| 477 |
+
else ""
|
| 478 |
+
)
|
| 479 |
if next_nav is not None
|
| 480 |
else None
|
| 481 |
)
|