"""
Evaluation utilities for the OCR benchmark (olmOCR-bench test classes).
Implements:
- text_presence : short text segment must be present in OCR output
- text_absence : text (headers/footers/page numbers) must NOT appear
- natural_reading_order : two text spans must appear in correct relative order
- table_accuracy : cell values with correct neighbor relationships (Markdown + HTML)
- math_formula_accuracy : LaTeX key-symbol token matching (simplified; no KaTeX/playwright)
Also provides:
- normalized_edit_distance() for OmniDocBench-style text quality measurement
- aggregate_results() / print_results_table() for summary reporting
"""
import re
import unicodedata
from difflib import SequenceMatcher
from html.parser import HTMLParser
from typing import Dict, List, Optional
# ── Unicode normalization ─────────────────────────────────────────────────────
_HYPHEN_RE = re.compile(
r"[\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D]"
)
_DQUOTE_RE = re.compile(
r"[\u00AB\u00BB\u201C\u201D\u201E\u201F\u2033\u2036\u276E\u276F\u3003\uFF02]"
)
_SQUOTE_RE = re.compile(
r"[\u2018\u2019\u201A\u201B\u2032\u2035\u2039\u203A\u2C8D\uFF07]"
)
_MARKDOWN_RE = re.compile(r"(\*{1,3}|_{1,3}|`{1,3}|~~|#{1,6}\s?)")
def normalize_text(text: str) -> str:
"""Apply olmOCR-bench standard Unicode normalization."""
text = unicodedata.normalize("NFC", text)
text = _HYPHEN_RE.sub("-", text)
text = _DQUOTE_RE.sub('"', text)
text = _SQUOTE_RE.sub("'", text)
return text
def strip_markdown(text: str) -> str:
"""Remove Markdown syntax markers for soft matching."""
return _MARKDOWN_RE.sub("", text)
# ── Matching helpers ──────────────────────────────────────────────────────────
def fuzzy_contains(needle: str, haystack: str, threshold: float = 0.85) -> bool:
"""Check if needle appears in haystack using fuzzy sliding-window matching."""
needle = normalize_text(strip_markdown(needle).strip())
haystack = normalize_text(strip_markdown(haystack))
# Fast exact check first
if needle.lower() in haystack.lower():
return True
n = len(needle)
if n == 0:
return True
step = max(1, n // 4)
for i in range(0, max(1, len(haystack) - n + 1), step):
window = haystack[i : i + n]
ratio = SequenceMatcher(None, needle.lower(), window.lower()).ratio()
if ratio >= threshold:
return True
return False
def exact_contains(needle: str, haystack: str, case_sensitive: bool = True) -> bool:
"""Check if needle appears exactly in haystack (after normalization)."""
needle = normalize_text(strip_markdown(needle).strip())
haystack = normalize_text(strip_markdown(haystack))
if not case_sensitive:
return needle.lower() in haystack.lower()
return needle in haystack
def _get_words_slice(text: str, first_n: Optional[int], last_n: Optional[int]) -> str:
"""Return the first or last N whitespace-separated words of text."""
if first_n is None and last_n is None:
return text
words = text.split()
if first_n is not None:
return " ".join(words[:first_n])
if last_n is not None:
return " ".join(words[-last_n:])
return text
# ── olmOCR-bench test evaluators ─────────────────────────────────────────────
def eval_text_presence(test: dict, ocr_output: str) -> bool:
"""Evaluate a present/text_presence test: target text must appear in OCR output.
Supports olmOCR-bench flat schema (max_diffs, first_n, last_n) and the
legacy nested-position schema.
"""
needle = test.get("text", "")
max_diffs = test.get("max_diffs", 0)
case_sensitive = test.get("case_sensitive", True)
first_n = test.get("first_n", None)
last_n = test.get("last_n", None)
haystack = _get_words_slice(ocr_output, first_n, last_n)
if max_diffs == 0:
return exact_contains(needle, haystack, case_sensitive=case_sensitive)
# Fuzzy: compute similarity threshold from allowed diffs
n = max(1, len(needle))
threshold = max(0.6, (n - max_diffs) / n)
return fuzzy_contains(needle, haystack, threshold=threshold)
def eval_text_absence(test: dict, ocr_output: str) -> bool:
"""Evaluate an absent/text_absence test: target text must NOT appear in OCR output.
Supports olmOCR-bench flat schema (max_diffs, first_n, last_n) and the
legacy nested-position schema.
"""
needle = test.get("text", "")
max_diffs = test.get("max_diffs", 0)
case_sensitive = test.get("case_sensitive", False)
first_n = test.get("first_n", None)
last_n = test.get("last_n", None)
haystack = _get_words_slice(ocr_output, first_n, last_n)
if max_diffs == 0:
present = exact_contains(needle, haystack, case_sensitive=case_sensitive)
else:
n = max(1, len(needle))
threshold = max(0.6, (n - max_diffs) / n)
present = fuzzy_contains(needle, haystack, threshold=threshold)
return not present
def eval_reading_order(test: dict, ocr_output: str) -> bool:
"""Evaluate an order/natural_reading_order test: 'before' text must precede 'after'.
Uses max_diffs to choose exact vs fuzzy matching.
"""
before_text = test.get("before", "")
after_text = test.get("after", "")
max_diffs = test.get("max_diffs", 0)
fuzzy = max_diffs > 0
output_norm = normalize_text(strip_markdown(ocr_output))
def find_approx_pos(needle: str, text: str) -> int:
needle = normalize_text(strip_markdown(needle).strip())
n = len(needle)
if n == 0:
return 0
# Exact first
idx = text.lower().find(needle.lower())
if idx != -1:
return idx
# Fuzzy fallback
step = max(1, n // 4)
best_pos, best_ratio = -1, 0.0
for i in range(0, max(1, len(text) - n + 1), step):
window = text[i : i + n]
ratio = SequenceMatcher(None, needle.lower(), window.lower()).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_pos = i
return best_pos if best_ratio >= 0.80 else -1
if fuzzy:
pos_before = find_approx_pos(before_text, output_norm)
pos_after = find_approx_pos(after_text, output_norm)
else:
b = normalize_text(strip_markdown(before_text).strip())
a = normalize_text(strip_markdown(after_text).strip())
pos_before = output_norm.find(b)
pos_after = output_norm.find(a)
if pos_before == -1 or pos_after == -1:
return False
return pos_before < pos_after
# ── Table parsing helpers ─────────────────────────────────────────────────────
def _parse_markdown_table(text: str) -> List[List[str]]:
"""Parse a Markdown table into a list-of-rows, each row a list of cells."""
rows: List[List[str]] = []
for line in text.splitlines():
stripped = line.strip()
if "|" not in stripped:
continue
# Skip separator rows like |---|---|
if re.match(r"^\|?[-:| ]+\|?$", stripped):
continue
cells = [c.strip() for c in stripped.strip("|").split("|")]
if cells:
rows.append(cells)
return rows
class _HTMLTableParser(HTMLParser):
"""Minimal HTML table parser (does not handle colspan/rowspan)."""
def __init__(self) -> None:
super().__init__()
self.rows: List[List[str]] = []
self._current_row: List[str] = []
self._current_cell: str = ""
self._in_cell: bool = False
def handle_starttag(self, tag: str, attrs) -> None:
if tag == "tr":
self._current_row = []
elif tag in ("td", "th"):
self._in_cell = True
self._current_cell = ""
def handle_endtag(self, tag: str) -> None:
if tag in ("td", "th"):
self._current_row.append(self._current_cell.strip())
self._in_cell = False
elif tag == "tr" and self._current_row:
self.rows.append(self._current_row)
def handle_data(self, data: str) -> None:
if self._in_cell:
self._current_cell += data
def _extract_tables(ocr_output: str) -> List[List[List[str]]]:
"""Extract all tables (HTML + Markdown) from OCR output."""
tables: List[List[List[str]]] = []
# HTML tables
for match in re.finditer(
r"
", ocr_output, re.DOTALL | re.IGNORECASE
):
parser = _HTMLTableParser()
parser.feed(match.group(0))
if parser.rows:
tables.append(parser.rows)
# Markdown tables
md_pattern = re.compile(
r"(\|[^\n]+\|\n(?:\|[-:| ]+\|\n)?(?:\|[^\n]+\|?\n?)+)", re.MULTILINE
)
for match in md_pattern.finditer(ocr_output):
rows = _parse_markdown_table(match.group(0))
if len(rows) >= 2: # At least header + one data row
tables.append(rows)
return tables
def eval_table_flat(test: dict, ocr_output: str) -> bool:
"""
Evaluate a flat-schema olmOCR-bench 'table' test.
Schema fields:
cell – text of the target cell to locate
up/down/left/right – expected text of the neighbor in that direction (null = skip)
top_heading – expected column heading (row 0, same column)
left_heading – expected row heading (column 0, same row)
All non-null fields must fuzzy-match for the test to pass.
Supports:
1. Structured tables (HTML with /| , Markdown) – full positional check
2. Flat blocks (DeepSeek-OCR-2 format) – text presence
fallback when no rows are parseable from HTML
"""
cell_text = test.get("cell", "")
directional = {
"up": test.get("up"),
"down": test.get("down"),
"left": test.get("left"),
"right": test.get("right"),
}
top_heading = test.get("top_heading")
left_heading = test.get("left_heading")
checks = [(d, v) for d, v in directional.items() if v is not None]
# ── 1. Structured tables (HTML |
/| or Markdown) ────────────────────
for rows in _extract_tables(ocr_output):
if not rows:
continue
header_row = rows[0]
for r_idx, row in enumerate(rows):
for c_idx, cell in enumerate(row):
if not fuzzy_contains(cell_text, cell, threshold=0.85):
continue
# Verify all directional neighbors
all_ok = True
for direction, expected in checks:
if direction == "up" and r_idx > 0:
prev_row = rows[r_idx - 1]
nb = prev_row[c_idx] if c_idx < len(prev_row) else ""
if not fuzzy_contains(expected, nb, threshold=0.85):
all_ok = False
break
elif direction == "down" and r_idx < len(rows) - 1:
next_row = rows[r_idx + 1]
nb = next_row[c_idx] if c_idx < len(next_row) else ""
if not fuzzy_contains(expected, nb, threshold=0.85):
all_ok = False
break
elif direction == "left" and c_idx > 0:
nb = row[c_idx - 1]
if not fuzzy_contains(expected, nb, threshold=0.85):
all_ok = False
break
elif direction == "right" and c_idx < len(row) - 1:
nb = row[c_idx + 1]
if not fuzzy_contains(expected, nb, threshold=0.85):
all_ok = False
break
else:
all_ok = False
break # expected neighbor out of bounds
if not all_ok:
continue
# Verify top_heading (column header, row 0)
if top_heading is not None:
th = header_row[c_idx] if c_idx < len(header_row) else ""
if not fuzzy_contains(top_heading, th, threshold=0.85):
continue
# Verify left_heading (first cell of same row)
if left_heading is not None:
lh = row[0] if row else ""
if not fuzzy_contains(left_heading, lh, threshold=0.85):
continue
return True
# ── 2. Flat fallback (DeepSeek-OCR-2 format) ────────────
# The model emits without |
/.
# Fall back to checking that cell + all non-null headings/neighbors appear
# somewhere within the same table block.
flat_blocks = re.findall(
r"", ocr_output, re.DOTALL | re.IGNORECASE
)
for flat_text in flat_blocks:
# Strip any residual HTML tags (e.g. inline ) and bounding-box annotations
flat_clean = re.sub(r"<[^>]+>", " ", flat_text)
flat_clean = re.sub(r"\[\[\d+,\s*\d+,\s*\d+,\s*\d+\]\]", " ", flat_clean)
if not fuzzy_contains(cell_text, flat_clean, threshold=0.85):
continue
if top_heading is not None and not fuzzy_contains(
top_heading, flat_clean, threshold=0.85
):
continue
if left_heading is not None and not fuzzy_contains(
left_heading, flat_clean, threshold=0.85
):
continue
all_ok = all(
fuzzy_contains(expected, flat_clean, threshold=0.85)
for _, expected in checks
)
if all_ok:
return True
return False
def eval_baseline(test: dict, ocr_output: str) -> bool:
"""
Evaluate a baseline/sanity test.
When check_disallowed_characters is False (the common case), always passes.
When True, checks that the OCR output contains no non-printable control
characters (excluding normal whitespace).
"""
if not test.get("check_disallowed_characters", False):
return True
for ch in ocr_output:
cat = unicodedata.category(ch)
if cat.startswith("C") and ch not in ("\n", "\t", "\r", " "):
return False
return bool(ocr_output.strip()) # also fail if completely empty
def eval_table_accuracy(test: dict, ocr_output: str) -> bool:
"""
Evaluate a table_accuracy test (legacy nested schema).
Checks that a cell with ``cell_text`` exists in a table and that its
neighbor in the specified ``relationship`` (above/below/left/right)
contains ``neighbor_text``.
"""
cell_text = test.get("cell_text", "")
neighbor_text = test.get("neighbor_text", "")
relationship = test.get("relationship", "")
for rows in _extract_tables(ocr_output):
for r_idx, row in enumerate(rows):
for c_idx, cell in enumerate(row):
if not fuzzy_contains(cell_text, cell, threshold=0.88):
continue
# Found the target cell — check its neighbor
if relationship == "above" and r_idx > 0:
prev_row = rows[r_idx - 1]
nb = prev_row[c_idx] if c_idx < len(prev_row) else ""
if fuzzy_contains(neighbor_text, nb, threshold=0.88):
return True
elif relationship == "below" and r_idx < len(rows) - 1:
next_row = rows[r_idx + 1]
nb = next_row[c_idx] if c_idx < len(next_row) else ""
if fuzzy_contains(neighbor_text, nb, threshold=0.88):
return True
elif relationship == "left" and c_idx > 0:
nb = row[c_idx - 1]
if fuzzy_contains(neighbor_text, nb, threshold=0.88):
return True
elif relationship == "right" and c_idx < len(row) - 1:
nb = row[c_idx + 1]
if fuzzy_contains(neighbor_text, nb, threshold=0.88):
return True
return False
# Math formula evaluation ─────────────────────────────────────────────────────
_MATH_REGION_RE = re.compile(
r"\$\$[\s\S]*?\$\$" # $$ block $$
r"|\$[^$\n]+?\$" # inline $...$
r"|\\?\\\[[\s\S]*?\\?\\\]" # \[...\]
r"|\\?\\\([\s\S]*?\\?\\\)", # \(...\)
)
_LATEX_TOKEN_RE = re.compile(r"\\[a-zA-Z]+|[a-zA-Z0-9]|[+\-*/=<>^_{}()\[\]]")
def eval_math_formula_accuracy(test: dict, ocr_output: str) -> bool:
"""
Simplified math formula accuracy check.
Checks that the key symbol tokens from a LaTeX expression appear in
math-delimited regions of the OCR output.
Note: Full KaTeX bounding-box matching (as used by the official
olmOCR-bench) requires playwright and is not performed here.
"""
latex = (test.get("math") or test.get("latex") or "").strip()
if not latex:
return False
math_text = " ".join(m.group(0) for m in _MATH_REGION_RE.finditer(ocr_output))
if not math_text:
# Fall back to full output if no delimited regions found
math_text = ocr_output
tokens = _LATEX_TOKEN_RE.findall(latex)
if not tokens:
return False
present = sum(1 for t in tokens if t in math_text)
return present / len(tokens) >= 0.70
# ── Main dispatcher ───────────────────────────────────────────────────────────
_TEST_EVALUATORS = {
# olmOCR-bench flat-JSONL type names
"present": eval_text_presence,
"absent": eval_text_absence,
"order": eval_reading_order,
"math": eval_math_formula_accuracy,
"table": eval_table_flat,
"baseline": eval_baseline,
# Legacy / aliased names
"text_presence": eval_text_presence,
"text_absence": eval_text_absence,
"natural_reading_order": eval_reading_order,
"table_accuracy": eval_table_accuracy,
"math_formula_accuracy": eval_math_formula_accuracy,
}
def evaluate_olmocr_tests(tests: List[dict], ocr_output: str) -> List[dict]:
"""Run all olmOCR-bench unit tests against OCR output; return per-test results."""
results: List[dict] = []
for test in tests:
test_type = test.get("type", "")
evaluator = _TEST_EVALUATORS.get(test_type)
if evaluator is None:
results.append(
{
"type": test_type,
"passed": False,
"error": f"Unknown type: {test_type}",
}
)
continue
try:
passed = bool(evaluator(test, ocr_output))
except Exception as exc:
results.append({"type": test_type, "passed": False, "error": str(exc)})
continue
results.append({"type": test_type, "passed": passed})
return results
# ── Aggregation & reporting ───────────────────────────────────────────────────
def aggregate_results(split_name: str, sample_results: List[dict]) -> dict:
"""Aggregate per-sample results into split-level statistics."""
by_type: Dict[str, Dict[str, int]] = {}
total_passed = 0
total_tests = 0
error_count = 0
for sample in sample_results:
if "error" in sample and not sample.get("test_results"):
error_count += 1
continue
for tr in sample.get("test_results", []):
t = tr.get("type", "unknown")
by_type.setdefault(t, {"passed": 0, "total": 0})
by_type[t]["total"] += 1
total_tests += 1
if tr.get("passed"):
by_type[t]["passed"] += 1
total_passed += 1
type_scores = {
t: round(100.0 * v["passed"] / v["total"], 1) if v["total"] > 0 else 0.0
for t, v in by_type.items()
}
overall = round(100.0 * total_passed / total_tests, 1) if total_tests > 0 else 0.0
return {
"split": split_name,
"total_samples": len(sample_results),
"error_samples": error_count,
"total_tests": total_tests,
"total_passed": total_passed,
"overall_score": overall,
"by_type": type_scores,
"by_type_counts": by_type,
}
def print_results_table(all_results: Dict[str, dict]) -> None:
"""Print a formatted results summary table to stdout."""
sep = "=" * 70
print(f"\n{sep}")
print(" olmOCR-bench Results Summary (DeepSeek-OCR-2 via sglang)")
print(sep)
print(f"{'Split':<22} {'Tests':>8} {'Passed':>8} {'Score':>8}")
print("-" * 50)
splits = list(all_results.keys())
scores = []
for split in splits:
r = all_results[split]
score = r.get("overall_score", 0.0)
scores.append(score)
print(
f"{split:<22} {r['total_tests']:>8} {r['total_passed']:>8} {score:>7.1f}%"
)
print("-" * 50)
total_tests = sum(r["total_tests"] for r in all_results.values())
total_passed = sum(r["total_passed"] for r in all_results.values())
overall = round(100.0 * total_passed / total_tests, 1) if total_tests > 0 else 0.0
mean_score = round(sum(scores) / len(scores), 1) if scores else 0.0
print(f"{'TOTAL':<22} {total_tests:>8} {total_passed:>8} {overall:>7.1f}%")
print(f"{'Mean across splits':<22} {'':>17} {mean_score:>7.1f}%")
print(sep)
# Per-type breakdown
all_types: set = set()
for r in all_results.values():
all_types.update(r.get("by_type", {}).keys())
if all_types:
print("\nPer-test-type breakdown:")
print(f"{'Test Type':<35} {'Tests':>8} {'Score':>8}")
print("-" * 55)
for t in sorted(all_types):
totals = {"passed": 0, "total": 0}
for r in all_results.values():
counts = r.get("by_type_counts", {}).get(t, {"passed": 0, "total": 0})
totals["passed"] += counts["passed"]
totals["total"] += counts["total"]
type_score = (
round(100.0 * totals["passed"] / totals["total"], 1)
if totals["total"] > 0
else 0.0
)
print(f"{t:<35} {totals['total']:>8} {type_score:>7.1f}%")
print(sep)
# ── Normalized Edit Distance (OmniDocBench-style text quality metric) ─────────
def normalized_edit_distance(pred: str, ref: str) -> float:
"""
Character-level Normalized Edit Distance in [0, 1].
0.0 = identical, 1.0 = completely different.
"""
pred = normalize_text(pred.strip())
ref = normalize_text(ref.strip())
if not ref and not pred:
return 0.0
if not ref or not pred:
return 1.0
m, n = len(pred), len(ref)
# Space-optimised single-row DP
dp = list(range(n + 1))
for i in range(1, m + 1):
prev = dp[0]
dp[0] = i
for j in range(1, n + 1):
temp = dp[j]
if pred[i - 1] == ref[j - 1]:
dp[j] = prev
else:
dp[j] = 1 + min(prev, dp[j], dp[j - 1])
prev = temp
return dp[n] / max(m, n)
|