sgl-project--sglang
94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
632 行
23 KiB
Python
632 行
23 KiB
Python
"""
|
|
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"<table[^>]*>.*?</table>", 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 <tr>/<td>, Markdown) – full positional check
|
|
2. Flat <table>content</table> 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 <tr>/<td> 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 <table>…</table> fallback (DeepSeek-OCR-2 format) ────────────
|
|
# The model emits <table>AllCellsConcatenated</table> without <tr>/<td>.
|
|
# Fall back to checking that cell + all non-null headings/neighbors appear
|
|
# somewhere within the same table block.
|
|
flat_blocks = re.findall(
|
|
r"<table[^>]*>(.*?)</table>", ocr_output, re.DOTALL | re.IGNORECASE
|
|
)
|
|
for flat_text in flat_blocks:
|
|
# Strip any residual HTML tags (e.g. inline <br>) 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)
|