ocr-bench 0.0.2__tar.gz → 0.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/PKG-INFO +1 -1
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/pyproject.toml +2 -1
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/backends.py +67 -17
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/cli.py +49 -5
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/dataset.py +31 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/judge.py +19 -7
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/publish.py +15 -5
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/run.py +71 -2
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/web.py +5 -2
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/README.md +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/__init__.py +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/elo.py +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/py.typed +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/space.py +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/static/style.css +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/base.html +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/comparison_card.html +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/comparisons.html +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/leaderboard.html +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/stats_panel.html +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/validate.py +0 -0
- {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/viewer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ocr-bench"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.4"
|
|
4
4
|
description = "OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -44,6 +44,7 @@ select = ["E", "F", "I", "UP"]
|
|
|
44
44
|
|
|
45
45
|
[dependency-groups]
|
|
46
46
|
dev = [
|
|
47
|
+
"hypothesis>=6.156.1",
|
|
47
48
|
"pytest>=9.0.2",
|
|
48
49
|
"ruff>=0.11",
|
|
49
50
|
"ty>=0.0.17",
|
|
@@ -5,19 +5,57 @@ from __future__ import annotations
|
|
|
5
5
|
import abc
|
|
6
6
|
from collections import Counter
|
|
7
7
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from itertools import zip_longest
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
10
11
|
import stamina
|
|
11
12
|
import structlog
|
|
12
13
|
from huggingface_hub import InferenceClient
|
|
13
|
-
from openai import OpenAI
|
|
14
|
+
from openai import APIConnectionError, OpenAI
|
|
14
15
|
|
|
15
16
|
from ocr_bench.judge import JUDGE_SCHEMA, Comparison, parse_judge_output
|
|
16
17
|
|
|
17
18
|
logger = structlog.get_logger()
|
|
18
19
|
|
|
19
|
-
#
|
|
20
|
-
|
|
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)
|
|
21
59
|
|
|
22
60
|
|
|
23
61
|
class JudgeBackend(abc.ABC):
|
|
@@ -38,7 +76,14 @@ class JudgeBackend(abc.ABC):
|
|
|
38
76
|
or an empty dict on failure.
|
|
39
77
|
"""
|
|
40
78
|
if self.concurrency <= 1 or len(comparisons) <= 1:
|
|
41
|
-
|
|
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
|
|
42
87
|
|
|
43
88
|
# Concurrent execution preserving order
|
|
44
89
|
results: list[dict[str, str]] = [{}] * len(comparisons)
|
|
@@ -69,11 +114,11 @@ class InferenceProviderJudge(JudgeBackend):
|
|
|
69
114
|
self.name = f"{provider + ':' if provider else ''}{model}"
|
|
70
115
|
self.model = model
|
|
71
116
|
self.max_tokens = max_tokens
|
|
72
|
-
self.client = InferenceClient(model=model, provider=provider) #
|
|
117
|
+
self.client = InferenceClient(model=model, provider=provider) # ty: ignore[invalid-argument-type]
|
|
73
118
|
|
|
74
|
-
@stamina.retry(on=
|
|
119
|
+
@stamina.retry(on=_is_retryable, attempts=_RETRY_ATTEMPTS)
|
|
75
120
|
def _call_single(self, comp: Comparison) -> dict[str, str]:
|
|
76
|
-
response = self.client.chat_completion( #
|
|
121
|
+
response = self.client.chat_completion( # ty: ignore[no-matching-overload]
|
|
77
122
|
messages=comp.messages,
|
|
78
123
|
max_tokens=self.max_tokens,
|
|
79
124
|
temperature=0.0,
|
|
@@ -108,11 +153,11 @@ class OpenAICompatibleJudge(JudgeBackend):
|
|
|
108
153
|
self.concurrency = concurrency
|
|
109
154
|
self.client = OpenAI(base_url=base_url, api_key=api_key)
|
|
110
155
|
|
|
111
|
-
@stamina.retry(on=
|
|
156
|
+
@stamina.retry(on=_is_retryable, attempts=_RETRY_ATTEMPTS)
|
|
112
157
|
def _call_single(self, comp: Comparison) -> dict[str, str]:
|
|
113
158
|
response = self.client.chat.completions.create(
|
|
114
159
|
model=self.model,
|
|
115
|
-
messages=comp.messages, #
|
|
160
|
+
messages=comp.messages, # ty: ignore[invalid-argument-type]
|
|
116
161
|
max_tokens=self.max_tokens,
|
|
117
162
|
temperature=self.temperature,
|
|
118
163
|
extra_body=self.extra_body,
|
|
@@ -165,7 +210,7 @@ def parse_judge_spec(
|
|
|
165
210
|
model=model_name,
|
|
166
211
|
api_key=token,
|
|
167
212
|
max_tokens=max_tokens,
|
|
168
|
-
temperature=0.
|
|
213
|
+
temperature=0.0,
|
|
169
214
|
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
|
170
215
|
concurrency=concurrency,
|
|
171
216
|
)
|
|
@@ -207,26 +252,31 @@ def aggregate_jury_votes(
|
|
|
207
252
|
if not all_results:
|
|
208
253
|
return []
|
|
209
254
|
|
|
210
|
-
n_comparisons = len(all_results[0])
|
|
211
|
-
n_judges = len(all_results)
|
|
212
255
|
aggregated: list[dict[str, Any]] = []
|
|
213
256
|
|
|
214
|
-
|
|
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={}):
|
|
215
260
|
votes: list[str] = []
|
|
216
261
|
reasons: list[str] = []
|
|
217
|
-
for
|
|
218
|
-
result = all_results[j][i] if i < len(all_results[j]) else {}
|
|
262
|
+
for name, result in zip(judge_names, judge_results):
|
|
219
263
|
winner = result.get("winner", "")
|
|
220
264
|
if winner:
|
|
221
265
|
votes.append(winner)
|
|
222
|
-
reasons.append(f"{
|
|
266
|
+
reasons.append(f"{name}: {result.get('reason', '')}")
|
|
223
267
|
|
|
224
268
|
if not votes:
|
|
225
269
|
aggregated.append({"winner": "tie", "reason": "no valid votes", "agreement": "0/0"})
|
|
226
270
|
continue
|
|
227
271
|
|
|
228
272
|
counter = Counter(votes)
|
|
229
|
-
|
|
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"
|
|
230
280
|
agreement = f"{majority_count}/{len(votes)}"
|
|
231
281
|
|
|
232
282
|
aggregated.append({
|
|
@@ -6,6 +6,7 @@ import argparse
|
|
|
6
6
|
import sys
|
|
7
7
|
|
|
8
8
|
import structlog
|
|
9
|
+
from openai import OpenAIError
|
|
9
10
|
from rich.console import Console
|
|
10
11
|
from rich.table import Table
|
|
11
12
|
|
|
@@ -87,6 +88,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
87
88
|
action="store_true",
|
|
88
89
|
help="Don't publish results (default: publish to {dataset}-results)",
|
|
89
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
|
+
)
|
|
90
100
|
judge.add_argument(
|
|
91
101
|
"--full-rejudge",
|
|
92
102
|
action="store_true",
|
|
@@ -190,7 +200,9 @@ def _convert_results(
|
|
|
190
200
|
"""Convert judged comparisons + aggregated outputs into ComparisonResult list."""
|
|
191
201
|
results: list[ComparisonResult] = []
|
|
192
202
|
for comp, result in zip(comparisons, aggregated):
|
|
193
|
-
|
|
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":
|
|
194
206
|
continue
|
|
195
207
|
results.append(
|
|
196
208
|
ComparisonResult(
|
|
@@ -433,6 +445,7 @@ def cmd_judge(args: argparse.Namespace) -> None:
|
|
|
433
445
|
board,
|
|
434
446
|
metadata,
|
|
435
447
|
existing_metadata=existing_meta_rows,
|
|
448
|
+
license_id=args.license,
|
|
436
449
|
)
|
|
437
450
|
console.print(f"\nResults published to [bold]{results_repo}[/bold]")
|
|
438
451
|
return
|
|
@@ -464,7 +477,13 @@ def cmd_judge(args: argparse.Namespace) -> None:
|
|
|
464
477
|
valid_comparisons=len(new_results),
|
|
465
478
|
from_prs=from_prs,
|
|
466
479
|
)
|
|
467
|
-
publish_results(
|
|
480
|
+
publish_results(
|
|
481
|
+
results_repo,
|
|
482
|
+
board,
|
|
483
|
+
metadata,
|
|
484
|
+
existing_metadata=existing_meta_rows,
|
|
485
|
+
license_id=args.license,
|
|
486
|
+
)
|
|
468
487
|
console.print(f"\nResults published to [bold]{results_repo}[/bold]")
|
|
469
488
|
|
|
470
489
|
|
|
@@ -489,7 +508,8 @@ def cmd_run(args: argparse.Namespace) -> None:
|
|
|
489
508
|
for slug in sorted(MODEL_REGISTRY):
|
|
490
509
|
cfg = MODEL_REGISTRY[slug]
|
|
491
510
|
default = " (default)" if slug in DEFAULT_MODELS else ""
|
|
492
|
-
|
|
511
|
+
gpu = cfg.default_flavor + (" (image-mode)" if cfg.image else "")
|
|
512
|
+
table.add_row(slug + default, cfg.model_id, cfg.size, gpu)
|
|
493
513
|
|
|
494
514
|
console.print(table)
|
|
495
515
|
console.print(f"\nDefault set: {', '.join(DEFAULT_MODELS)}")
|
|
@@ -528,6 +548,10 @@ def cmd_run(args: argparse.Namespace) -> None:
|
|
|
528
548
|
console.print(f"[cyan]{slug}[/cyan] ({cfg.model_id})")
|
|
529
549
|
console.print(f" Flavor: {flavor}")
|
|
530
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}")
|
|
531
555
|
console.print(f" Script: {cfg.script}")
|
|
532
556
|
console.print(f" Args: {' '.join(script_args)}")
|
|
533
557
|
console.print()
|
|
@@ -587,7 +611,7 @@ SPACE_TEMPLATE = "davanstrien/ocr-bench-space-template"
|
|
|
587
611
|
|
|
588
612
|
def cmd_publish(args: argparse.Namespace) -> None:
|
|
589
613
|
"""Deploy results viewer as a Hugging Face Space."""
|
|
590
|
-
from huggingface_hub import HfApi
|
|
614
|
+
from huggingface_hub import HfApi, SpaceHardware
|
|
591
615
|
|
|
592
616
|
api = HfApi()
|
|
593
617
|
results = args.results
|
|
@@ -599,13 +623,26 @@ def cmd_publish(args: argparse.Namespace) -> None:
|
|
|
599
623
|
from_id=SPACE_TEMPLATE,
|
|
600
624
|
to_id=space_id,
|
|
601
625
|
private=args.private if args.private else None,
|
|
602
|
-
hardware=
|
|
626
|
+
hardware=SpaceHardware.CPU_BASIC,
|
|
603
627
|
exist_ok=True,
|
|
604
628
|
variables=[{"key": "REPOS", "value": results}],
|
|
605
629
|
)
|
|
606
630
|
|
|
607
631
|
api.add_space_variable(repo_id=space_id, key="REPOS", value=results)
|
|
608
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
|
+
|
|
609
646
|
url = f"https://huggingface.co/spaces/{space_id}"
|
|
610
647
|
console.print(f"[green]Space published![/green] {url}")
|
|
611
648
|
|
|
@@ -630,3 +667,10 @@ def main() -> None:
|
|
|
630
667
|
except DatasetError as exc:
|
|
631
668
|
console.print(f"[red]Error:[/red] {exc}")
|
|
632
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)
|
|
@@ -211,6 +211,37 @@ def load_config_dataset(
|
|
|
211
211
|
if unified is None:
|
|
212
212
|
raise DatasetError("No configs loaded successfully")
|
|
213
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
|
+
|
|
214
245
|
return unified, ocr_columns
|
|
215
246
|
|
|
216
247
|
|
|
@@ -275,15 +275,27 @@ def parse_judge_output(text: str) -> dict[str, str]:
|
|
|
275
275
|
"""
|
|
276
276
|
text = text.strip()
|
|
277
277
|
if text.startswith("```"):
|
|
278
|
-
|
|
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 ""
|
|
279
281
|
try:
|
|
280
282
|
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
283
|
except json.JSONDecodeError:
|
|
288
284
|
logger.warning("Failed to parse judge output: %s", text[:200])
|
|
289
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}
|
|
@@ -127,6 +127,7 @@ def publish_results(
|
|
|
127
127
|
board: Leaderboard,
|
|
128
128
|
metadata: EvalMetadata,
|
|
129
129
|
existing_metadata: list[dict] | None = None,
|
|
130
|
+
license_id: str | None = None,
|
|
130
131
|
) -> None:
|
|
131
132
|
"""Push evaluation results to Hub as a dataset with multiple configs.
|
|
132
133
|
|
|
@@ -159,7 +160,7 @@ def publish_results(
|
|
|
159
160
|
logger.info("published_metadata", repo=repo_id, n=len(all_meta))
|
|
160
161
|
|
|
161
162
|
# README — auto-generated dataset card with leaderboard
|
|
162
|
-
readme = _build_readme(repo_id, rows, board, metadata)
|
|
163
|
+
readme = _build_readme(repo_id, rows, board, metadata, license_id=license_id)
|
|
163
164
|
api = HfApi()
|
|
164
165
|
api.upload_file(
|
|
165
166
|
path_or_fileobj=readme.encode(),
|
|
@@ -175,6 +176,7 @@ def _build_readme(
|
|
|
175
176
|
rows: list[dict],
|
|
176
177
|
board: Leaderboard,
|
|
177
178
|
metadata: EvalMetadata,
|
|
179
|
+
license_id: str | None = None,
|
|
178
180
|
) -> str:
|
|
179
181
|
"""Build a dataset card README with the leaderboard table."""
|
|
180
182
|
has_ci = bool(board.elo_ci)
|
|
@@ -187,12 +189,19 @@ def _build_readme(
|
|
|
187
189
|
judge_str = ", ".join(j.split("/")[-1] for j in judges) if judges else "N/A"
|
|
188
190
|
n_comparisons = len(board.comparison_log)
|
|
189
191
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 += [
|
|
193
200
|
"tags:",
|
|
194
201
|
" - ocr-bench",
|
|
195
202
|
" - leaderboard",
|
|
203
|
+
"source_datasets:",
|
|
204
|
+
f" - {metadata.source_dataset}",
|
|
196
205
|
"configs:",
|
|
197
206
|
" - config_name: default",
|
|
198
207
|
" data_files:",
|
|
@@ -230,7 +239,8 @@ def _build_readme(
|
|
|
230
239
|
lines.append("|------|-------|--------|-----|------|--------|------|------|")
|
|
231
240
|
|
|
232
241
|
for rank, row in enumerate(rows, 1):
|
|
233
|
-
model
|
|
242
|
+
# Escape pipes so arbitrary model names can't break the table
|
|
243
|
+
model = str(row["model"]).replace("|", "\\|")
|
|
234
244
|
elo = row["elo"]
|
|
235
245
|
params = row.get("params", "")
|
|
236
246
|
if has_ci and "elo_low" in row:
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import time
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
import structlog
|
|
9
10
|
from huggingface_hub import HfApi, get_token
|
|
@@ -13,13 +14,33 @@ logger = structlog.get_logger()
|
|
|
13
14
|
|
|
14
15
|
@dataclass
|
|
15
16
|
class ModelConfig:
|
|
16
|
-
"""Configuration for a single OCR model.
|
|
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
|
+
"""
|
|
17
25
|
|
|
18
26
|
script: str
|
|
19
27
|
model_id: str
|
|
20
28
|
size: str
|
|
21
29
|
default_flavor: str = "l4x1"
|
|
22
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"}
|
|
23
44
|
|
|
24
45
|
|
|
25
46
|
MODEL_REGISTRY: dict[str, ModelConfig] = {
|
|
@@ -54,6 +75,37 @@ MODEL_REGISTRY: dict[str, ModelConfig] = {
|
|
|
54
75
|
size="2.1B",
|
|
55
76
|
default_flavor="l4x1",
|
|
56
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
|
+
),
|
|
57
109
|
}
|
|
58
110
|
|
|
59
111
|
DEFAULT_MODELS = ["glm-ocr", "deepseek-ocr", "lighton-ocr-2", "dots-ocr", "firered-ocr"]
|
|
@@ -145,13 +197,30 @@ def launch_ocr_jobs(
|
|
|
145
197
|
extra_args=config.default_args or None,
|
|
146
198
|
)
|
|
147
199
|
|
|
148
|
-
|
|
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
|
+
)
|
|
149
217
|
job = api.run_uv_job(
|
|
150
218
|
script=config.script,
|
|
151
219
|
script_args=script_args,
|
|
152
220
|
flavor=flavor,
|
|
153
221
|
secrets={"HF_TOKEN": token},
|
|
154
222
|
timeout=timeout,
|
|
223
|
+
**extra_kwargs,
|
|
155
224
|
)
|
|
156
225
|
jobs.append(JobRun(model_slug=slug, job_id=job.id, job_url=job.url))
|
|
157
226
|
logger.info("job_launched", model=slug, job_id=job.id, url=job.url)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import html
|
|
5
6
|
import io
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
from datetime import UTC, datetime
|
|
@@ -67,8 +68,10 @@ def _build_pair_summary_html(comparisons: list[dict[str, Any]]) -> str:
|
|
|
67
68
|
|
|
68
69
|
rows = []
|
|
69
70
|
for (ma, mb), counts in sorted(pair_counts.items()):
|
|
70
|
-
|
|
71
|
-
|
|
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))
|
|
72
75
|
wins, losses, ties = counts["W"], counts["L"], counts["T"]
|
|
73
76
|
rows.append(
|
|
74
77
|
f"<tr><td>{short_a}</td><td>{short_b}</td>"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|