Upload folder using huggingface_hub
Browse files- Dockerfile +3 -4
- README.md +5 -76
- project-README.md +0 -0
- pyproject.toml +0 -49
- src/ocr_bench/space.py → space.py +0 -0
- src/ocr_bench/__init__.py +0 -3
- src/ocr_bench/__pycache__/__init__.cpython-311.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__/judge.cpython-311.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 +0 -238
- src/ocr_bench/cli.py +0 -632
- src/ocr_bench/dataset.py +0 -301
- src/ocr_bench/elo.py +0 -320
- src/ocr_bench/judge.py +0 -289
- src/ocr_bench/publish.py +0 -271
- src/ocr_bench/py.typed +0 -0
- src/ocr_bench/run.py +0 -193
- src/ocr_bench/static/style.css +0 -379
- src/ocr_bench/templates/base.html +0 -48
- src/ocr_bench/templates/comparison_card.html +0 -88
- src/ocr_bench/templates/comparisons.html +0 -40
- src/ocr_bench/templates/leaderboard.html +0 -182
- src/ocr_bench/templates/stats_panel.html +0 -10
- src/ocr_bench/validate.py +0 -362
- src/ocr_bench/viewer.py +0 -202
- src/ocr_bench/web.py +0 -514
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 |
-
|
| 9 |
-
COPY --chown=user src/ ./src/
|
| 10 |
|
| 11 |
-
|
| 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", "
|
|
|
|
| 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 |
-
#
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
| 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">←</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">→</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">·</span>
|
| 57 |
-
{% endif %}
|
| 58 |
-
<span class="keys">keys: <kbd>a</kbd> <kbd>t</kbd> <kbd>b</kbd> vote · <kbd>←</kbd> <kbd>→</kbd> navigate{% if not revealed %} · <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 |
-
· You: {{ human_vote }}
|
| 67 |
-
· <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 }}–{{ 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 |
-
·
|
| 4 |
-
<span>{{ agreement_pct }}% agree</span>
|
| 5 |
-
{% if hard_disagree_rate > 25 %}
|
| 6 |
-
· <span class="warning">judge may be miscalibrated</span>
|
| 7 |
-
{% elif vote_count >= 15 %}
|
| 8 |
-
· <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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|