Dans ce tutoriel, nous implémentons OmniParseBench, le benchmark ouvert de Datalab pour les parseurs de documents, qui évalue la sortie d’un parseur avec 16,288 petits tests de réussite ou d’échec répartis sur 2,937 pages, des formulaires numérisés et de l’écriture manuscrite aux mathématiques d’arXiv et aux tableaux denses. Plutôt que de faire confiance au classement, nous le reconstruisons à partir des verdicts publiés par Datalab pour 27 parseurs, après avoir soumis au scoreur une page fabriquée à la main pour voir quelles erreurs font basculer un verdict. Nous mesurons ensuite la certitude du classement, la découpons par type de document, testons si les deux modèles que la fiche désigne comme ayant contribué à la construction des tests se comportent différemment sur les tests corrigés par un modèle, et exécutons nous-mêmes Tesseract et Indra OCR sur un échantillon de 50 pages. En chemin, nous constatons que les deux premiers parseurs sont statistiquement à égalité, que le leader change sur la plupart des tranches, et qu’un chiffre de lancement ne correspond pas aux fichiers publiés.
Copier le codeimport os
import re
import sys
import json
import time
import html
import shutil
import platform
import traceback
import subprocess
import collections
import urllib.request
from pathlib import Path
RESULTS = {} # step -> one-line result, for the summary table
CLAIMS = [] # (step, claim, held)
CURRENT = ["1"]
def banner(title):
print("\n" + "=" * 78)
print(title)
print("=" * 78)
def section(name):
def wrap(fn):
def run(*a, **kw):
CURRENT[0] = name.split(".")[0]
banner(name)
t0 = time.time()
try:
out = fn(*a, **kw)
RESULTS[name] = out if isinstance(out, str) else "ok"
return out
except Exception as e:
RESULTS[name] = f"SKIPPED / FAILED -> {type(e).__name__}: {e}"
print(f"\n[!] {name} did not complete: {type(e).__name__}: {e}")
traceback.print_exc(limit=3)
return None
finally:
print(f"\n[{time.time() - t0:.1f} s]")
return run
return wrap
def claim(text, held):
CLAIMS.append((CURRENT[0], text, bool(held)))
return held
banner("1. Install omni-parse-bench 1.0.0rc1 and Tesseract, and pin the dataset revision")
SYSTEM = platform.system()
OPB_VERSION = "1.0.0rc1" # a pre-release: a bare `pip install omni-parse-bench` resolves to 0.2.0, whose API differs
REPO = "datalab-to/omni_parse_bench"
REV = "961c0f89a2da701f5ce53afbd5e80aa469b0369a" # the dataset with the published verdicts of 27 parsers
INDRA = ["indra-ocr==0.3.2", "numpy==2.5.4", "onnxruntime==1.31.0", "opencv-python-headless==5.0.0.93",
"pypdfium2==5.14.0", "huggingface_hub==2.2.0"] # the pins of the package's own Indra server image
WORK = Path(os.environ.get("OPB_WORK") or ("/content/opb" if os.path.isdir("/content") else "opb_work")).resolve()
WORK.mkdir(parents=True, exist_ok=True)
subprocess.run([sys.executable, "-m", "pip", "install", "-q", f"omni-parse-bench[benchmark]=={OPB_VERSION}", "uv"], check=True)
if SYSTEM == "Linux" and shutil.which("tesseract") is None:
scripts = ("arab armn beng cans cher cyrl deva ethi frak geor grek gujr guru hang hang-vert hans hans-vert hant "
"hant-vert hebr jpan jpan-vert knda khmr laoo latn mlym mymr orya sinh syrc taml telu thaa thai tibt viet").split()
subprocess.run(["apt-get", "-qq", "update"], check=True)
subprocess.run(["apt-get", "-qq", "install", "-y", "tesseract-ocr", "tesseract-ocr-eng", "tesseract-ocr-osd"]
+ [f"tesseract-ocr-script-{s}" for s in scripts], check=True, stdout=subprocess.DEVNULL)
import numpy as np
import pandas as pd
import importlib.metadata as md
from huggingface_hub import snapshot_download
from omni_parse_bench import benchmark, data, harness, markdown, vocab
from omni_parse_bench.scorers import unit_tests
print(f"omni-parse-bench {md.version('omni-parse-bench')}, Python {platform.python_version()}, {SYSTEM} {platform.machine()}, "
f"{os.cpu_count()} CPU cores")
if SYSTEM == "Linux":
print("os:", open("/etc/os-release").read().split('PRETTY_NAME="')[1].split('"')[0])
tess = subprocess.run(["tesseract", "--version"], capture_output=True, text=True)
print("tesseract:", (tess.stdout or tess.stderr).splitlines()[0])
TESS_MAJOR = int((tess.stdout or tess.stderr).split()[1].split(".")[0])
print("test families:", {f: [t for t, g in vocab.FAMILIES.items() if g == f] for f in dict.fromkeys(vocab.FAMILIES.values())})
print(f"dataset {REPO} @ {REV[:7]}; work folder {WORK}")
RESULTS["1. Install omni-parse-bench 1.0.0rc1 and Tesseract, and pin the dataset revision"] = (
f"omni-parse-bench {md.version('omni-parse-bench')}, tesseract {(tess.stdout or tess.stderr).split()[1]}, dataset @ {REV[:7]}")
Nous installons omni-parse-bench épinglé à 1.0.0rc1, car un simple pip install résout encore vers 0.2.0, une version plus ancienne avec une API différente. L’extra benchmark apporte les adaptateurs de parseurs, le client Hugging Face et pyarrow pour le manifeste, et sur Colab nous ajoutons Tesseract avec ses modèles de scripts via apt. Nous épinglons aussi le jeu de données à un commit, car le dépôt évolue au fur et à mesure que des parseurs sont ajoutés. L’affichage des familles révèle la structure sur laquelle repose le benchmark : les tests present, order et repeat forment la famille text ; table_cell forme la famille tables ; et layout_kind forme la famille layout.
Copier le code@section("2. How the scorer decides: one hand-built page, five test types, one change at a time")
def scorer_anatomy():
T = lambda i, tt, out, tags, args, st=None: {"id": i, "test_type": tt, "output_type": out, "derivation": "authored",
"tags": tags, "args": args, "settings": st or {}}
exact = lambda s: {"text": s, "max_diffs": 0}
gold = [
T("title", "present", "html", ["heading"], {"content": exact("Quarterly Report")}),
T("total", "present", "html", ["eng"], {"content": exact("Total due")}),
T("growth", "present", "html", ["eng"], {"content": {"number": "-4.8%"}}),
T("amount", "present", "html", ["eng"], {"content": {"number": "1,240.00"}}),
T("eq_ratio", "present", "html", ["math"], {"content": {"latex": r"\alpha_{1} + \beta"}}),
T("eq_half", "present", "html", ["math"], {"content": {"latex": r"\frac{1}{2} \to x"}}),
T("order", "order", "html", ["eng"], {"before": exact("Introduction: this report covers the second quarter."),
"after": exact("Conclusion: the plan holds.")}),
T("repeat", "repeat", "html", ["eng"], {"content": exact("Revenue")}, {"expected_count": 2}),
T("cell_top", "table_cell", "html", ["table"], {"cell": exact("1,240"), "top_heading": exact("2024")}),
T("cell_left", "table_cell", "html", ["table"], {"cell": exact("1,240"), "left_heading": exact("Income")}),
T("cell_price", "table_cell", "html", ["table"], {"cell": exact("9.50"), "top_heading": exact("Price"),
"left": exact("Widget")}),
T("block_title", "layout_kind", "blocks", ["heading"],
{"content": dict(exact("Quarterly Report"), place=[[0.10, 0.04, 0.90, 0.09]])}, {"kind": "heading"}),
T("block_total", "layout_kind", "blocks", ["eng"],
{"content": dict(exact("Total due"), place=[[0.10, 0.12, 0.90, 0.16]])}, {"kind": "text"}),
]
gold_file = WORK / "fixture" / "gold" / "demo-p1.json" # a gold file is named after its page
gold_file.parent.mkdir(parents=True, exist_ok=True)
gold_file.write_text(json.dumps({"tests": gold}, indent=1))
page = data.Page(page_id="demo-p1", document="demo", suite="demo", doc_path=WORK / "fixture" / "demo-p1.pdf",
gt_path=gold_file, outputs=("blocks", "html"))
tests = unit_tests.gold(page)
table_a = ("<table><tr><th></th><th>2023</th><th>2024</th></tr><tr><th>Income</th><td>1,000</td><td>1,240</td></tr>"
"<tr><th>Costs</th><td>600</td><td>700</td></tr></table>")
table_b = "<table><tr><th>Product</th><th>Price</th></tr><tr><td>Widget</td><td>9.50</td></tr></table>"
md_table_b = "| Product | Price |\n|---|---|\n| Widget | 9.50 |"
parts = {"title": "<h1>Quarterly Report</h1>", "total": "<p>Total due: 1,240.00 USD.</p>",
"growth": "<p>Growth was -4.8% year on year.</p>", "ratio": r"<p>Ratio: 1+ holds.</p>",
"half": r"<p>Half: 12x in the model.</p>",
"intro": "<p>Introduction: this report covers the second quarter.</p>",
"revenue": "<p>Revenue is shown in thousands. Revenue grew in the second quarter.</p>",
"table_a": table_a, "conclusion": "<p>Conclusion: the plan holds.</p>", "table_b": table_b}
blocks = [{"label": "heading", "bbox": [0.10, 0.04, 0.90, 0.09], "text": "Quarterly Report"},
{"label": "text", "bbox": [0.10, 0.12, 0.90, 0.16], "text": "Total due: 1,240.00 USD."},
{"label": "table", "bbox": [0.10, 0.40, 0.90, 0.60], "text": "Income 1,000 1,240 Costs 600 700"}]
def pred(change=None, order=None, new_blocks=None):
p = dict(parts, **(change or {}))
return {"html": "\n".join(p[k] for k in (order or list(parts))), "blocks": blocks if new_blocks is None else new_blocks}
base = {v.id: v.result for v in unit_tests.score(pred(), tests)}
print(f"base prediction: {sum(r == 'pass' for r in base.values())} of {len(tests)} tests pass\n")
swapped = ["title", "total", "growth", "ratio", "half", "conclusion", "intro", "revenue", "table_a", "table_b"]
variants = [
("flip", "a typo: 'Total dve'", pred({"total": "<p>Total dve: 1,240.00 USD.</p>"}), ()),
("flip", "the minus sign dropped: 4.8%", pred({"growth": "<p>Growth was 4.8% year on year.</p>"}), ()),
("flip", "another symbol: alpha_2", pred({"ratio": r"<p>Ratio: 2+ holds.</p>"}), ()),
("flip", "conclusion before introduction", pred(order=swapped), ()),
("flip", "'Revenues' for the second 'Revenue'",
pred({"revenue": "<p>Revenue is shown in thousands. Revenues grew in the second quarter.</p>"}), ()),
("flip", "2023 and 2024 headings swapped", pred({"table_a": table_a.replace("<th>2023</th><th>2024</th>",
"<th>2024</th><th>2023</th>")}), ()),
("flip", "row label 'Margin' for 'Income'", pred({"table_a": table_a.replace("<th>Income</th>", "<th>Margin</th>")}), ()),
("flip", "a markdown table left in the html", pred({"table_b": md_table_b}), ()),
("flip", "the title's block labelled text", pred(new_blocks=[dict(blocks[0], label="text")] + blocks[1:]), ()),
("control", r"\dfrac12\rightarrow for \frac{1}{2}\to", pred({"half": r"<p>Half: $\dfrac12\rightarrow x$ in the model.</p>"}), ()),
("control", "'TOTAL DUE' and '1240.00'", pred({"total": "<p>TOTAL DUE: 1240.00 USD.</p>"}), ()),
("control", "markdown table through markdown.to_html", pred({"table_b": markdown.to_html(md_table_b)}), ()),
("unsupported", "no blocks, declared unsupported", {"html": pred()["html"]}, ("blocks",)),
]
as_expected = 0
for kind, name, p, unsupported in variants:
changed = [v for v in unit_tests.score(p, tests, unsupported) if v.result != base[v.id]]
if kind == "flip":
ok = len(changed) == 1 and changed[0].result == "fail"
elif kind == "control":
ok = not changed
else:
ok = bool(changed) and all(v.result == "unsupported" for v in changed)
as_expected += ok
shown = "; ".join(f"{v.id} -> {v.result}" + (f" ({v.why})" if v.result == "fail" else "") for v in changed) or "no verdict changes"
print(f"{kind:11s} {name:42s} {shown}")
s = unit_tests.summarize(unit_tests.lines(unit_tests.score(variants[0][2], tests), tests, page))
print(f"\nsummary of the typo variant: headline {s['headline']:.4f} = mean of "
+ ", ".join(f"{k} {v['score']:.3f} ({v['tests']} tests)" for k, v in s["families"].items()))
claim("every one-change variant flips exactly one verdict to fail, and no control flips any", as_expected == len(variants))
return f"{as_expected} of {len(variants)} hand-built variants behave as expected (9 flips, 3 controls, 1 unsupported)"
scorer_anatomy()
Avant de faire confiance à un score, nous voulons voir ce que signifie un verdict, alors nous écrivons à la main un fichier gold avec treize tests sur une page inventée et évaluons des prédictions avec unit_tests.score, la fonction que le benchmark utilise lui-même. Une prédiction de base passe tout, et chaque variante change une seule chose. Une faute de frappe d’une lettre, un signe moins perdu, un symbole différent dans une équation, des titres inversés et un bloc mal étiqueté font chacun basculer exactement un test en échec, avec une raison jointe. Les contrôles passent toujours: les capitales, un séparateur de milliers disparu et dfrac à la place de frac, car le texte est replié et les équations sont comparées après rendu par KaTeX.
Copier le code@section("3. The dataset: 16,288 tests, what they check, and how each answer was established")
def dataset_step():
global DATA, PAGES, TESTS, CARD_TEXT
snapshot_download(REPO, repo_type="dataset", revision=REV, local_dir=str(WORK / "dataset"),
allow_patterns=["manifest.parquet", "gold/*", "results.parquet", "README.md"])
DATA, PAGES = data.read_manifest(str(WORK / "dataset"))
rows = []
for p in PAGES:
for t in unit_tests.gold(p):
rows.append(dict(test_id=t.id, page_id=p.page_id, suite=p.suite, document=p.document, test_type=t.test_type,
derivation=t.derivation, tags=list(t.tags), pdf=p.doc_path.suffix == ".pdf"))
TESTS = pd.DataFrame(rows)
tags = collections.Counter(g for ts in TESTS.tags for g in ts)
langs = {g for g in tags if re.fullmatch(r"[a-z]{3}", g)}
scripts = {g for g in tags if re.fullmatch(r"[A-Z][a-z]{3}", g)}
n_pdf = sum(p.doc_path.suffix == ".pdf" for p in PAGES)
print(f"{len(TESTS):,} tests on {TESTS.page_id.nunique():,} pages from {TESTS.document.nunique():,} documents in "
f"{TESTS.suite.nunique()} suites; {n_pdf:,} PDF pages and {len(PAGES) - n_pdf:,} images")
print(f"tags: {len(langs)} language codes ({len(langs - {'und', 'zxx'})} without und and zxx), {len(scripts)} scripts")
counts = lambda col: {k: int(v) for k, v in col.value_counts().items()}
print("\ntests by type: ", counts(TESTS.test_type))
print("tests by family: ", counts(TESTS.test_type.map(vocab.FAMILIES)))
print("structure and role tags:", {g: n for g, n in tags.most_common() if g not in langs | scripts})
print("\nhow each test's answer was established (derivation):")
for d, n in TESTS.derivation.value_counts().items():
print(f" {d:22s} {n:6,d} {', '.join(f'{k} {v}' for k, v in TESTS[TESTS.derivation == d].test_type.value_counts().items())}")
CARD_TEXT = (DATA / "README.md").read_text()
claim("16,288 tests on 2,937 pages from 2,343 documents in 22 suites, as the card says",
(len(TESTS), TESTS.page_id.nunique(), TESTS.document.nunique(), TESTS.suite.nunique()) == (16288, 2937, 2343, 22))
return f"{len(TESTS):,} tests, {TESTS.page_id.nunique():,} pages, {TESTS.document.nunique():,} documents, {len(langs - {'und', 'zxx'})} languages"
dataset_step()
Le scoreur étant compris, nous téléchargeons le manifeste, les fichiers gold et les verdicts publiés à la révision épinglée, sautons les 1.5 GB d’images de pages, et lisons les tests de chaque page via unit_tests.gold. Les comptages correspondent à la fiche du jeu de données, y compris 92 langues une fois les codes indéterminés et sans langue retirés. Deux champs méritent attention. Les balises telles que handwriting, rotated ou table ne décident jamais d’un verdict ; elles ne nous permettent que de découper les résultats. Derivation indique comment chaque réponse a été établie, du balisage de l’éditeur et des couches de texte aux panneaux de modèles et à la revue humaine, ce qui nous permet de tester plus tard si la construction des tests a favorisé un parseur en particulier.
Copier le codeCOLS = ["page_id", "suite", "document", "test_id", "test_type", "output_type", "derivation", "tags", "result", "why"]
STARRED = ["Gemini 3.8 Flash", "Claude Sonnet 5.5"] # the card: models "used in creating and verifying at least some tests"
@section("4. Recompute the published leaderboard from 439,776 verdicts")
def recompute_table():
global VERDICTS, TABLE
VERDICTS = pd.read_parquet(DATA / "results.parquet")
VERDICTS["output_type"] = VERDICTS.test_type.map(vocab.READS)
VERDICTS["tags"] = VERDICTS.tags.apply(list)
VERDICTS["family"] = VERDICTS.test_type.map(vocab.FAMILIES)
rows = []
for system, g in VERDICTS.groupby("system", sort=False):
s = unit_tests.summarize(g[COLS].to_dict("records"))
fam = {k: (np.nan if v["score"] is None else 100 * v["score"]) for k, v in s["families"].items()}
scored = g[g.result != "unsupported"]
rows.append(dict(system=system, headline=np.nan if s["headline"] is None else 100 * s["headline"],
text=fam.get("text"), tables=fam.get("tables"), layout=fam.get("layout"),
pooled=100 * (scored.result == "pass").mean()))
TABLE = pd.DataFrame(rows).set_index("system")
card = {}
for line in CARD_TEXT.splitlines():
cells = [c.strip().strip("*").strip() for c in line.strip().strip("|").split("|")]
if len(cells) == 6 and cells[1] in ("open", "closed"):
card[cells[0]] = [None if c in ("none", "unsupported") else float(c) for c in cells[2:]]
mismatch = []
for name, vals in card.items():
mine = [None if pd.isna(x) else round(x, 1) for x in TABLE.loc[name, ["headline", "text", "tables", "layout"]]]
mismatch += [(name, a, b) for a, b in zip(mine, vals) if a != b]
print(f"{len(VERDICTS):,} verdicts = {VERDICTS.system.nunique()} parsers x {VERDICTS.test_id.nunique():,} tests: "
f"{ {k: int(v) for k, v in VERDICTS.result.value_counts().items()} }")
numeric = sum(v is not None for vals in card.values() for v in vals)
print(f"the card's table: {len(card)} parsers, {4 * len(card)} entries ({numeric} numbers, the rest none or unsupported); "
f"recomputed with unit_tests.summarize, {len(mismatch)} differ {mismatch[:3] if mismatch else ''}\n")
show = TABLE.sort_values("headline", ascending=False, na_position="last")
f = lambda x: "none" if pd.isna(x) else f"{x:.2f}"
print(f"{'parser':24s} {'headline':>9s} {'text':>7s} {'tables':>7s} {'layout':>7s} {'pooled':>7s}")
for name, r in show.iterrows():
if name in show.index[:8] or pd.isna(r.headline):
print(f"{name + ('*' if name in STARRED else ''):24s} {f(r.headline):>9s} {f(r.text):>7s} {f(r.tables):>7s} "
f"{f(r.layout):>7s} {r.pooled:7.2f}")
a, g = TABLE.loc["Datalab accurate", "headline"], TABLE.loc["Gemini 3.8 Flash", "headline"]
ranked = TABLE.dropna(subset=["headline"])
print(f"\nDatalab accurate {a:.3f} vs Gemini 3.8 Flash {g:.3f}: both {a:.1f} at one decimal, {a - g:+.3f} points apart")
print("top 3 by headline (mean of the three families):", list(ranked.headline.nlargest(3).index))
print("top 3 by pooled pass rate over every scored test:", list(ranked.pooled.nlargest(3).index))
claim("every number in the card's results table is reproduced from results.parquet", not mismatch)
return f"{4 * len(card) - len(mismatch)} of {4 * len(card)} card entries reproduced; Datalab accurate {a:.2f}, Gemini {g:.2f}"
recompute_table()
Comme results.parquet contient une ligne par parseur et par test, nous pouvons reconstruire le classement en passant les lignes de chaque parseur à unit_tests.summarize. Le score principal est la moyenne des taux de réussite text, tables et layout, les tests non pris en charge étant exclus, de sorte qu’un parseur incapable de renvoyer des blocs de mise en page, comme Tesseract, n’obtient aucun score principal. Les 108 entrées du tableau de la fiche correspondent à ses valeurs à une décimale. Le recalcul révèle aussi une quasi-égalité en tête : Datalab accurate et Gemini 3.8 Flash s’arrondissent tous deux à 92.9, à 0.024 points d’écart, et en regroupant tous les tests au lieu de moyenner les familles, Gemini arrive premier.
Copier le codedef per_document(df, systems, groups):
"""For each system: an array (documents x groups x [passes, scored tests]); `groups` maps a name to a row mask."""
docs = {d: i for i, d in enumerate(sorted(df.document.unique()))}
s = df[df.result != "unsupported"]
out = {}
for name in systems:
g = s[s.system == name]
arr = np.zeros((len(docs), len(groups), 2))
for k, mask in enumerate(groups.values()):
sel = g[mask(g)]
d = sel.document.map(docs).to_numpy()
arr[:, k, 0] = np.bincount(d, weights=(sel.result == "pass").to_numpy(), minlength=len(docs))
arr[:, k, 1] = np.bincount(d, minlength=len(docs))
out[name] = arr
return out, len(docs)
def resample(n_docs, B, seed):
"""B bootstrap resamples of the documents, each as a count per document."""
rng = np.random.default_rng(seed)
return np.stack([np.bincount(rng.integers(0, n_docs, n_docs), minlength=n_docs) for _ in range(B)]).astype(float)
@section("5. How sure is the order at the top? A document-level bootstrap")
def bootstrap_step():
top = list(TABLE.headline.nlargest(6).index)
families = {f: (lambda g, f=f: g.family == f) for f in ("text", "tables", "layout")}
arrs, n_docs = per_document(VERDICTS[VERDICTS.system.isin(top)], top, families)
W = resample(n_docs, 2000, seed=20261022)
head = {}
for s in top:
tot = np.tensordot(W, arrs[s], axes=([1], [0])) # resamples x families x [passes, tests]
head[s] = 100 * (tot[..., 0] / tot[..., 1]).mean(axis=1)
point = {s: 100 * (arrs[s].sum(0)[:, 0] / arrs[s].sum(0)[:, 1]).mean() for s in top}
print(f"2,000 resamples of {n_docs:,} documents; a document's tests stay together\n")
print(f"{'parser':22s} {'headline':>9s} {'95% interval':>15s} {'gap to ' + top[0]:>26s} {'resamples where it leads':>24s}")
leader = np.argmax(np.stack([head[x] for x in top]), axis=0)
for s in top:
lo, hi = np.percentile(head[s], [2.5, 97.5])
d = head[top[0]] - head[s]
gap = "" if s == top[0] else f"{point[top[0]] - point[s]:+.2f} [{np.percentile(d, 2.5):+.2f}, {np.percentile(d, 97.5):+.2f}]"
print(f"{s:22s} {point[s]:9.2f} {lo:6.2f} - {hi:6.2f} {gap:>26s} {100 * np.mean(leader == top.index(s)):23.1f}%")
d = head[top[0]] - head[top[1]]
lo, hi = np.percentile(d, [2.5, 97.5])
claim(f"{top[0]} and {top[1]} cannot be told apart (the 95% interval of their gap includes zero)", lo < 0 < hi)
return f"{top[0]} vs {top[1]}: {point[top[0]] - point[top[1]]:+.2f} points, 95% interval [{lo:+.2f}, {hi:+.2f}]"
bootstrap_step()
Un écart aussi faible soulève la question de savoir quelle part de l’ordre relève du bruit. Les tests d’un même document tendent à réussir ou échouer ensemble, donc nous rééchantillonnons les documents plutôt que les tests: chacun des 2,000 tirages sélectionne 2,343 documents avec remise et recalcule chaque score principal à partir des comptages de réussite par document avec un seul produit matriciel. L’écart entre les deux premiers varie d’environ -0.9 à +0.9 points, et chacun mène dans environ la moitié des tirages, de sorte que la lecture honnête est une égalité. Datalab balanced, à 1.35 points derrière le leader, en est clairement séparé, et il en va de même pour chaque parseur en dessous.
Copier le codedef slice_headline(df):
"""Per system: family pass rates on a slice of tests and their mean, as the benchmark's headline is built."""
s = df[df.result != "unsupported"]
fam = s.groupby(["system", "family"]).result.apply(lambda r: 100 * (r == "pass").mean()).unstack()
return fam.mean(axis=1), fam
@section("6. Slices where a different parser comes first")
def slices_step():
ranked = TABLE.dropna(subset=["headline"]).sort_values("headline", ascending=False)
rank = {s: i + 1 for i, s in enumerate(ranked.index)}
V = VERDICTS[VERDICTS.system.isin(ranked.index)]
whole, _ = slice_headline(V)
assert np.allclose(whole[ranked.index], ranked.headline), "slice_headline disagrees with unit_tests.summarize"
print(f"{'tag':13s} {'tests':>6s} {'docs':>5s} {'first (overall rank)':34s} {'second':26s} families")
leaders = collections.Counter()
for tag in ("handwriting", "tiny", "rotated", "degraded", "math", "table", "form", "multi_column",
"Grek", "Hani", "Cyrl", "Armn", "Laoo"):
sl = V[V.tags.apply(lambda t: tag in t)]
head, fam = slice_headline(sl)
order = head.sort_values(ascending=False)
(a, x), (b, y) = list(order.items())[:2]
docs = sl.document.nunique()
leaders[a] += 1
note = (" (tie for first)" if x == y else "") + (" (10 or fewer documents)" if docs <= 10 else "")
print(f"{tag:13s} {sl.test_id.nunique():6d} {docs:5d} {f'{a} {x:.1f} (#{rank[a]})':34s} {f'{b} {y:.1f}':26s} "
f"{'+'.join(c for c in ('text', 'tables', 'layout') if c in fam.columns)}{note}")
first = ranked.index[0]
claim("the overall leader is not first on every slice", leaders[first] < 13)
return f"{first} is first on {leaders[first]} of 13 slices; {len(leaders) - (first in leaders)} other parsers lead the rest"
slices_step()
Découper par balise révèle ce qu'un seul titre masque sur les documents dont nous disposons réellement. Nous récalculons le titre sur l'écriture manuscrite, les pages pivotées, les tableaux, les écritures uniqueset d'autres découpes avec la même règle de moyenne par famille, après qu'une assertion a confirmé que la règle reproduit les titres officiels sur l'ensemble complet. Le leader global arrive premier sur seulement trois des treize découpes. Gemini 3.8 Flash mène sur l'écriture manuscrite, les mathématiques et les formulaires ; Claude Sonnet 5.5 sur les pages pivotées ; et Mistral OCR sur le grec. L'arménien et le lao reposent sur six et cinq documents, nous les traitons donc comme des indices plutôt que des classements.
Copier le code@section("7. Do the two models named on the card hold up better on tests a model corrected? A difference-in-differences check")
def creation_check():
others = [s for s in TABLE.dropna(subset=["headline"]).index if s not in STARRED]
V = VERDICTS[VERDICTS.system.isin(STARRED + others)]
print(f"starred: {STARRED}; compared with the {len(others)} other parsers that have a headline")
print("DiD = (starred: pass rate on the split - elsewhere) - (others: the same gap); 1,000 document resamples\n")
print(f"{'tests established by':21s} {'tests':>6s} {'starred in / out':>17s} {'others in / out':>16s} {'DiD [95% interval]':>23s}")
W = None
out = {}
for deriv, note in (("model_review", "a vision model corrected the test"),
("model_panel", "independent models agreed"),
("human_review", "placebo: a person decided")):
groups = {"in": lambda g, d=deriv: g.derivation == d, "out": lambda g, d=deriv: g.derivation != d}
arrs, n_docs = per_document(V, STARRED + others, groups)
W = resample(n_docs, 1000, seed=7) if W is None else W
def gaps(weights):
r = {}
for s, a in arrs.items():
tot = np.tensordot(weights, a, axes=([-1], [0]))
with np.errstate(invalid="ignore", divide="ignore"):
r[s] = 100 * tot[..., 0] / tot[..., 1]
did = (np.nanmean([r[s][..., 0] - r[s][..., 1] for s in STARRED], axis=0)
- np.nanmean([r[s][..., 0] - r[s][..., 1] for s in others], axis=0))
return r, did
r, d = gaps(np.ones(n_docs))
_, boot = gaps(W)
lo, hi = np.nanpercentile(boot, [2.5, 97.5])
si, so = np.mean([r[s][0] for s in STARRED]), np.mean([r[s][1] for s in STARRED])
oi, oo = np.mean([r[s][0] for s in others]), np.mean([r[s][1] for s in others])
out[deriv] = (d, lo, hi)
print(f"{deriv:21s} {(TESTS.derivation == deriv).sum():6,d} {si:7.1f} / {so:6.1f} {oi:6.1f} / {oo:6.1f} "
f"{d:+6.1f} [{lo:+5.1f}, {hi:+5.1f}] {note}")
panel = TESTS[TESTS.derivation == "model_panel"]
layout = TESTS[TESTS.test_type == "layout_kind"]
print(f"\nmodel_panel tests are {100 * (panel.test_type == 'layout_kind').mean():.0f}% layout tests, and "
f"{100 * (layout.derivation == 'model_panel').mean():.0f}% of layout tests are model_panel")
review = VERDICTS[VERDICTS.derivation == "model_review"]
rate = lambda s: 100 * (review[review.system == s].result == "pass").mean()
print(f"pass rate on the {(TESTS.derivation == 'model_review').sum()} model_review tests: "
+ ", ".join(f"{s} {rate(s):.1f}%" for s in STARRED) + f", median of the others {np.median([rate(s) for s in others]):.1f}%")
claim("the starred models gain more on model_review tests than on the placebo split", out["model_review"][1] > out["human_review"][2])
claim("the placebo split shows no gap (its interval includes zero)", out["human_review"][1] < 0 < out["human_review"][2])
return (f"DiD model_review {out['model_review'][0]:+.1f}, model_panel {out['model_panel'][0]:+.1f}, "
f"placebo human_review {out['human_review'][0]:+.1f} points")
creation_check()
La fiche du jeu de données indique que Gemini 3.8 Flash et Claude Sonnet 5.5 ont été utilisés pour créer et vérifier certains tests sans préciser lesquels ; nous posons donc une question plus étroite avec une différence de différences: le taux de réussite d'un parseur sur une répartition de dérivation, par rapport à son taux ailleurs, change-t-il différemment pour le duo marqué ? Sur les 102 tests model_review, corrigés par un modèle de vision, les deux groupes réussissent bien moins souvent qu'ailleurs, mais le duo marqué chute d'environ 25 points de moins. Le placebo human_review montre un écart plus faible dans la même direction et model_panel va dans l'autre sens, aussi rapportons-nous un signal, pas une preuve.
Copier le codedef predict_pages(run, pages, alive=lambda: True):
"""Predict every page the run still lacks. A failed call is recorded, not raised, so we count failures ourselves."""
t0, n, failed = time.time(), 0, []
for p in pages:
if not alive():
raise RuntimeError(f"{run.provider}: the server stopped after {n} pages")
if need := benchmark.needs(run, p):
got = harness.predict(run.provider, p.doc_path, need, timeout=900, **run.options)
benchmark.record(run, p.page_id, got)
n += 1
if any(c.get("failure") for c in got["calls"]):
failed.append(f"{p.page_id}: {got['errors']}")
if failed:
print(f"{len(failed)} of {n} pages failed, e.g. {failed[0][:160]}")
if 2 * len(failed) >= n:
raise RuntimeError(f"{run.provider} failed on {len(failed)} of {n} pages")
return n, (time.time() - t0) / max(n, 1)
@section("8. Run Tesseract ourselves on 50 pages that span all 22 suites")
def tesseract_step():
global SAMPLE
if TESS_MAJOR < 5:
raise RuntimeError(f"tesseract {TESS_MAJOR}.x is installed; this tutorial was verified with tesseract 5")
SAMPLE = data.select(PAGES, sample=50)
snapshot_download(REPO, repo_type="dataset", revision=REV, local_dir=str(DATA),
allow_patterns=sorted(str(p.doc_path.relative_to(DATA)) for p in SAMPLE))
n_pdf = sum(p.doc_path.suffix == ".pdf" for p in SAMPLE)
print(f"sample: {len(SAMPLE)} pages from {len({p.suite for p in SAMPLE})} suites, {n_pdf} PDF and {len(SAMPLE) - n_pdf} images, "
f"{sum(p.doc_path.stat().st_size for p in SAMPLE) / 1e6:.1f} MB, {sum(len(unit_tests.gold(p)) for p in SAMPLE)} tests")
# The adapter asks Tesseract for '-l script/<Name>'. Homebrew keeps those models in a script/ folder; Ubuntu installs
# them flat, so on Linux we point Tesseract at a folder of symlinks with the layout it expects.
listing = subprocess.run(["tesseract", "--list-langs"], capture_output=True, text=True)
listing = listing.stdout + listing.stderr
if "script/Latin" not in listing:
src = Path(re.search(r'"(.+?)"', listing).group(1))
dst = WORK / "tessdata"
(dst / "script").mkdir(parents=True, exist_ok=True)
for f in src.glob("*.traineddata"):
for link in [dst / f.name] + ([dst / "script" / f.name] if f.name[0].isupper() else []):
if not link.exists():
link.symlink_to(f)
os.environ["TESSDATA_PREFIX"] = str(dst)
print(f"script models linked under {dst}")
run = benchmark.Run("tesseract", {}, WORK / "runs" / "tesseract")
benchmark.prepare(run, str(DATA), 900)
n, per_page = predict_pages(run, SAMPLE)
s = benchmark.grade(run, SAMPLE, say=lambda *_: None)
fam = s["families"]
print(f"\nTesseract: {n} pages read, {per_page:.1f} s per page")
for k, v in fam.items():
print(f" {k:7s} " + ("unsupported (no blocks output)" if v["score"] is None else f"{100 * v['score']:5.1f}% of {v['tests']} tests"))
print(f" headline {'none' if s['headline'] is None else round(100 * s['headline'], 1)} (a family it cannot produce leaves no headline)")
return f"Tesseract on 50 pages: text {100 * fam['text']['score']:.1f}%, tables {100 * fam['tables']['score']:.1f}%, {per_page:.1f} s/page"
tesseract_step()
Reconstruire un classement à partir des verdicts publiés est une chose ; produire nous-mêmes des verdicts en est une autre. data.select choisit un échantillon fixe de 50 pages réparties sur les 22 suites, et nous ne téléchargeons que ces pages. La boucle est celle du benchmark lui-même : needs liste les sorties qu'il manque encore à une page, harness. predict exécute le parseur ; record enregistre la prédiction et note le dossier, de sorte qu'une exécution interrompue peut reprendre. Sous Ubuntu, nous lions aussi les modèles de script de Tesseract dans la disposition attendue par l'adaptateur. Tesseract réussit 30.4% des tests de texte de l'échantillon contre 17.6% sur l'ensemble complet, un écart qui dépend des pages échantillonnées.
Copier le codedef agreement(folder, published):
"""Our verdicts against the published ones for the same tests, where both scored the test."""
ours = pd.read_json(folder / "scores.jsonl", lines=True)[["test_id", "result", "why"]]
pub = VERDICTS[VERDICTS.system == published][["test_id", "result"]]
m = ours.merge(pub, on="test_id", suffixes=("_ours", "_pub"))
m = m[(m.result_ours != "unsupported") & (m.result_pub != "unsupported")]
return m, int((m.result_ours == m.result_pub).sum()), len(m)
@section("9. Indra OCR on the same pages, served on the CPU from the package's own server code")
def indra_step():
if sys.version_info < (3, 12):
raise RuntimeError("the Indra pins (numpy 2.5.4) need Python 3.12 or newer")
venv = WORK / "indra-venv"
py = venv / "bin" / "python"
if not py.exists():
uv = shutil.which("uv") or str(Path(sys.executable).parent / "uv")
subprocess.run([uv, "venv", "-q", "--python", sys.executable, str(venv)], check=True)
subprocess.run([uv, "pip", "install", "-q", "--python", str(py)] + INDRA, check=True)
server = Path(harness.__file__).parent / "providers" / "oss" / "indra" / "server.py"
# server.py counts cores with os.sched_getaffinity, which macOS lacks; on Linux the shim changes nothing
launcher = WORK / "indra_launch.py"
launcher.write_text("import os, runpy, sys\n"
"if not hasattr(os, 'sched_getaffinity'):\n"
" os.sched_getaffinity = lambda pid: set(range(os.cpu_count()))\n"
"sys.argv = ['server.py', '--port', '8001', '--workers', '2']\n"
f"runpy.run_path({str(server)!r}, run_name='__main__')\n")
log = open(WORK / "indra_server.log", "w")
proc = subprocess.Popen([str(py), str(launcher)], stdout=log, stderr=subprocess.STDOUT)
t0 = time.time()
while True:
try:
urllib.request.urlopen("http://127.0.0.1:8001/health", timeout=2)
break
except Exception:
if proc.poll() is not None or time.time() - t0 > 600:
proc.terminate()
raise RuntimeError("the Indra server did not start; see " + log.name)
time.sleep(2)
print(f"server up in {time.time() - t0:.0f} s: {INDRA[0]}, 2 worker processes")
try:
run = benchmark.Run("indra-ocr", {"base_url": "http://127.0.0.1:8001"}, WORK / "runs" / "indra-ocr")
benchmark.prepare(run, str(DATA), 900)
n, per_page = predict_pages(run, SAMPLE, alive=lambda: proc.poll() is None)
s = benchmark.grade(run, SAMPLE, say=lambda *_: None)
finally:
proc.terminate()
fam = {k: 100 * v["score"] for k, v in s["families"].items()}
print(f"Indra OCR: {n} pages, {per_page:.2f} s per page; text {fam['text']:.1f}%, tables {fam['tables']:.1f}%, "
f"layout {fam['layout']:.1f}%; headline on these pages {100 * s['headline']:.1f}\n")
ids = {p.page_id for p in SAMPLE}
fmt = lambda x: "none" if x is None else f"{100 * x:.1f}"
print(f"{'on the 50 pages':22s} {'headline':>9s} {'text':>6s} {'tables':>7s} {'layout':>7s}")
for name, folder in (("Tesseract", WORK / "runs" / "tesseract"), ("Indra OCR", WORK / "runs" / "indra-ocr")):
pub = unit_tests.summarize(VERDICTS[(VERDICTS.system == name) & VERDICTS.page_id.isin(ids)][COLS].to_dict("records"))
ours = unit_tests.summarize(benchmark.read_scores(benchmark.Run(name, {}, folder)))
for who, x in (("ours", ours), ("published", pub)):
f = x["families"]
print(f"{name + ', ' + who:22s} {fmt(x['headline']):>9s} " + " ".join(f"{fmt(f[k]['score']) if k in f else 'none':>6s}"
for k in ("text", "tables", "layout")))
print()
for name, folder in (("Tesseract", WORK / "runs" / "tesseract"), ("Indra OCR", WORK / "runs" / "indra-ocr")):
m, same, total = agreement(folder, name)
diff = m[m.result_ours != m.result_pub]
print(f"{name:10s} our verdicts equal the published ones on {same} of {total} tests ({100 * same / total:.1f}%); "
f"of the {len(diff)} that differ, {(diff.result_ours == 'pass').sum()} pass only for us and "
f"{(diff.result_ours == 'fail').sum()} only in the published run")
if len(diff):
by_page = diff.test_id.map(TESTS.set_index("test_id").page_id).value_counts()
print(" differing tests by type:", {k: int(v) for k, v in diff.test_id.map(TESTS.set_index("test_id").test_type).value_counts().items()},
f"| on {len(by_page)} pages, {by_page.iloc[0]} of them on {by_page.index[0]}")
claim(f"{name}: our verdicts equal the published ones on at least 95% of the sample's tests", same >= 0.95 * total)
return f"Indra OCR on 50 pages: headline {100 * s['headline']:.1f} (text {fam['text']:.1f}, tables {fam['tables']:.1f}, layout {fam['layout']:.1f}), {per_page:.2f} s/page"
indra_step()
Indra OCR est un modèle CPU ouvert, et le paquet fournit le serveur HTTP auquel son adaptateur parle ; nous installons donc les versions épinglées dans le Dockerfile de ce serveur dans un environnement séparé et le démarrons en arrière-plan. Face aux verdicts publiés sur les mêmes pages, Tesseract est d'accord sur chaque test noté, donc son score d'échantillon correspond au score publié. Indra est d'accord sur 203 des 221, et toutes ses différences de tableaux se trouvent sur une seule page, ce qui explique tout l'écart entre son score de tableaux publié de 36.1 et notre 69.4. Une seule page faisant bouger une famille d'autant est l'argument le plus clair en faveur d'un rééchantillonnage des documents.
Copier le code@section("10. Bring your own parser: grade a PDF text-layer baseline with the same code")
def byo_step():
import pypdfium2 as pdfium
def text_layer(path):
if path.suffix.lower() != ".pdf":
return "" # an image has no text layer to read
doc = pdfium.PdfDocument(str(path))
try:
return "<pre>" + html.escape(doc[0].get_textpage().get_text_range()) + "</pre>"
finally:
doc.close()
out = WORK / "runs" / "text-layer"
(out / "predictions").mkdir(parents=True, exist_ok=True)
for p in SAMPLE:
(out / "predictions" / f"{p.page_id}.json").write_text(json.dumps({"html": text_layer(p.doc_path)}))
s = benchmark.grade(benchmark.Run("text-layer", {}, out), SAMPLE, say=lambda *_: None, settings={"unsupported": ["blocks"]})
kind = {p.page_id: "PDF" if p.doc_path.suffix == ".pdf" else "image" for p in SAMPLE}
print(f"{'parser on the same 50 pages':28s} {'text: PDF':>10s} {'images':>8s} {'all':>6s} {'tables':>8s}")
for name in ("text-layer", "tesseract", "indra-ocr"):
sc = pd.read_json(WORK / "runs" / name / "scores.jsonl", lines=True)
sc = sc[sc.result.isin(["pass", "fail"])]
txt = sc[sc.test_type.map(vocab.FAMILIES) == "text"]
by = txt.groupby(txt.page_id.map(kind)).result.apply(lambda r: 100 * (r == "pass").mean())
tab = sc[sc.test_type == "table_cell"]
print(f"{name:28s} {by.get('PDF', np.nan):10.1f} {by.get('image', np.nan):8.1f} "
f"{100 * (txt.result == 'pass').mean():6.1f} {100 * (tab.result == 'pass').mean():8.1f}")
fam = s["families"]
return f"text layer, no OCR: text {100 * fam['text']['score']:.1f}%, tables {100 * fam['tables']['score']:.1f}% on the 50 pages"
byo_step()
N'importe quel parseur peut être noté sans adaptateur : nous écrivons un fichier de prédictions par page et appelons benchmark.grade, en déclarant comme non prises en charge les sorties que nous ne pouvons pas produire. Notre parseur lit la couche de texte intégrée d'un PDF avec pypdfium2 et renvoie une chaîne vide pour les images, qui n'en ont pas. Sur les pages PDF, il réussit exactement autant de tests de texte que Tesseract, 42.3% chacun, sans aucun OCR, tandis que sur les images il n'a rien et Indra OCR réussit encore 43.1%. Le texte brut échoue à tous les tests de tableaux, car une réussite exige une structure de cellules ; ainsi le mélange de PDF et d'images importe dès que deux parseurs sont comparés.
Copier le code@section("11. The launch claims, checked against the files")
def claims_step():
a, t = TABLE.loc["Datalab accurate"], TABLE.loc["Tesseract"]
g = TABLE.loc["Gemini 3.8 Flash", "headline"]
c = TABLE.loc["Claude Sonnet 5.5"]
tags = TESTS.tags
langs = {x for ts in tags for x in ts if re.fullmatch("[a-z]{3}", x)} - {"und", "zxx"}
pkg = Path(vocab.__file__).parent # the scorer and the modules it reads; data.py and the harness only fetch and run
scoring = sorted(pkg.glob("scorers/**/*.py")) + [pkg / f"{m}.py" for m in ("normalize", "tables", "equations", "views", "vocab", "markdown")]
clients = [f.name for f in scoring if re.search(r"^\s*(import|from)\s+(httpx|requests|urllib|huggingface_hub|openai|anthropic|google"
r"|transformers|torch|omni_parse_bench\.harness)\b", f.read_text(), re.M)]
n_eng, n_table = tags.apply(lambda x: "eng" in x).sum(), tags.apply(lambda x: "table" in x).sum()
rows = [
("16,288 tests, 2,937 pages, 2,343 documents", f"{len(TESTS):,} / {TESTS.page_id.nunique():,} / {TESTS.document.nunique():,}",
(len(TESTS), TESTS.page_id.nunique(), TESTS.document.nunique()) == (16288, 2937, 2343)),
("92 languages", f"{len(langs)} codes besides und and zxx", len(langs) == 92),
("10,089 English tests, 6,767 table tests", f"{n_eng:,} and {n_table:,}", (n_eng, n_table) == (10089, 6767)),
("Datalab accurate scores 92.7", f"{a.headline:.2f} in results.parquet", round(a.headline, 1) == 92.7),
("Datalab accurate ranks second, behind Gemini", f"{a.headline:.3f} vs {g:.3f}", a.headline < g),
("Claude Sonnet 5.5: text 93.1, tables 95.4, layout 77.5", f"{c.text:.1f} / {c.tables:.1f} / {c.layout:.1f}",
[round(c[k], 1) for k in ("text", "tables", "layout")] == [93.1, 95.4, 77.5]),
("Tesseract: no headline, text 17.6, tables 0.0",
f"{'none' if pd.isna(t.headline) else round(t.headline, 1)} / {t.text:.1f} / {t.tables:.1f}",
pd.isna(t.headline) and round(t.text, 1) == 17.6 and round(t.tables, 1) == 0.0),
("no model judges the outputs", f"{len(clients)} of {len(scoring)} scorer modules import a model or HTTP client", not clients),
]
print(f"{'claim (launch article and blog)':56s} {'what the files say':38s} verdict")
for text, found, ok in rows:
print(f"{text:56s} {found:38s} {'holds' if ok else 'does not hold'}")
claim(text, ok)
print("\nnot checkable from these files: latency, price per 1,000 pages, the other benchmarks' test counts")
return f"{sum(bool(ok) for *_, ok in rows)} of {len(rows)} launch claims hold against the files"
claims_step()
Plutôt que de répéter les affirmations du lancement, nous vérifions chacune contre les fichiers. Les comptages tiennent exactement, des tests et pages aux langues et sous-ensembles, et les lignes Claude et Tesseract correspondent. Datalabpropres ne correspondent pas : l'article de lancement et le blog donnent à Datalab un 92.7 exact et le placent deuxième derrière Gemini, tandis que les verdicts publiés donnent 92.92 contre 92.89, une égalité qui s'arrondit au même chiffre. L'affirmation sans juge tient pour le scoreur lui-même, dont les modules n'importent aucun modèle ni client HTTP ; KaTeX s'exécute dans un moteur JavaScript intégré pour comparer les équations. La latence et le prix ne peuvent être vérifiés à partir de ces fichiers.
Copier le codebanner("SUMMARY")
for name, res in RESULTS.items():
print(f" {name:<100s} {res}")
print("\nchecks decided in this run:")
for step, text, held in CLAIMS:
print(f" [{'held' if held else 'did not hold'}] step {step}: {text}")
failed = [k for k, v in RESULTS.items() if str(v).startswith("SKIPPED")]
print(f"\n{sum(h for *_, h in CLAIMS)} of {len(CLAIMS)} checks held; steps failed or skipped: {failed or 'none'}")
print(f"omni-parse-bench {md.version('omni-parse-bench')}, dataset @ {REV[:7]}, Python {platform.python_version()}, {SYSTEM}")
print("not run here: the hosted parsers (paid API keys), the GPU models, latency and cost, all 2,937 pages")
Le résumé affiche le résultat d'une ligne de chaque étape et chaque vérification décidée par l'exécution, en marquant lesquelles ont tenu et lesquelles non : 13 sur 17 ont tenu, et les quatre qui n'ont pas tenu sont des constats plutôt que des échecs, à savoir l'écart du placebo, la reproduction d'Indra et les deux chiffres de lancement de Datalab. Il liste aussi ce que nous n'avons pas exécuté ici : les parseurs hébergés qui nécessitent des clés payantes, les modèles GPU, la latence et le coût, et l'ensemble complet des 2,937 pages.
En conclusion, OmniParseBench est un référentiel que nous pouvons auditer de bout en bout : le scoreur est un code ouvert qui ne fait appel à aucun modèle, les verdicts sont publiés, et chaque entrée de la fiche peut être reconstruite à partir de ceux-ci. Les deux meilleurs parseurs sont statistiquement à égalité, regrouper les tests au lieu des familles change l'ordre, et un parseur différent arrive en tête sur l'écriture manuscrite, les pages pivotées, les tableaux et plusieurs écritures. Les deux modèles nommés sur la fiche reculent moins que les autres sur les tests qu'un modèle a corrigés, ce que nous signalons comme un indicateur plutôt que comme une preuve. Les verdicts publiés de Tesseract se sont reproduits exactement sur notre échantillon, tandis que ceux d'Indra OCR concordaient sur 92 % des tests. La leçon pratique est de découper le référentiel selon nos propres documents, de publier des intervalles et de figer chaque version.
Découvrez le Ordinateur portable et Dépôt. Tout le mérite revient au chercheur de ce projet. N'hésitez pas également à nous suivre sur Twitter et n'oubliez pas de rejoindre notre 150k+ SubReddit ML et abonnez-vous à notre Newsletter. Attends ! es-tu sur telegram ? vous pouvez désormais nous rejoindre également sur Telegram.
Besoin de vous associer à nous pour promouvoir votre dépôt GitHub OU votre page Hugging Face OU le lancement de votre produit OU un webinaire, etc. ? Connectez-vous avec nous
L'article Guide du développeur pour OmniParseBench : classements de référence et tests OCR est apparu en premier sur MarkTechPost.