Suya OCR API — vLLM-backed, OpenAI-compatible OCR service

FastAPI service wrapping the Surya-OCR-2 model (datalab-to) served through vLLM:
legacy /v1/api/ai/* endpoints, an OpenAI-compatible /v1/chat/completions endpoint,
a coalescing request batcher, a local OCR CLI, Docker packaging, multilingual
example outputs, and quantization/concurrency benchmarks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fu Dai
2026-06-17 10:20:02 +04:00
co-authored by Claude Opus 4.8
commit 1a585693be
147 changed files with 13827 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
"""Parsers for the three task outputs."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import List, Tuple
from surya.logging import get_logger
logger = get_logger()
# ---- Layout (LAYOUT_PROMPT) -------------------------------------------------
@dataclass
class ParsedLayoutBlock:
label: str
bbox: Tuple[float, float, float, float] # 0-1000 normalized
count: int # multiple of 50, model's token estimate
_JSON_ARRAY_RE = re.compile(r"\[.*\]", re.DOTALL)
def _strip_fences(text: str) -> str:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```[a-zA-Z]*\n", "", cleaned)
cleaned = re.sub(r"\n```\s*$", "", cleaned)
return cleaned
def _coerce_bbox(bbox) -> Tuple[float, float, float, float]:
if isinstance(bbox, str):
parts = [float(x) for x in bbox.replace(",", " ").split()]
else:
parts = [float(x) for x in bbox]
if len(parts) != 4:
raise ValueError(f"Bad bbox: {bbox!r}")
return (parts[0], parts[1], parts[2], parts[3])
def _coerce_count(value) -> int:
if value is None:
return 0
try:
return max(0, int(value))
except (TypeError, ValueError):
return 0
def parse_layout(text: str) -> List[ParsedLayoutBlock]:
"""Pull the JSON array out of LAYOUT_PROMPT output and convert to typed blocks.
Tolerates code fences, missing fields, and stringified bboxes.
"""
cleaned = _strip_fences(text)
m = _JSON_ARRAY_RE.search(cleaned)
if not m:
raise ValueError(f"No JSON array found in layout output: {text[:500]!r}")
raw = json.loads(m.group(0))
out: List[ParsedLayoutBlock] = []
for item in raw:
try:
bbox = _coerce_bbox(item["bbox"])
except (KeyError, ValueError) as e:
logger.warning(f"Skipping layout block with bad bbox: {e}")
continue
label = str(item.get("label", "block"))
count = _coerce_count(item.get("count"))
out.append(ParsedLayoutBlock(label=label, bbox=bbox, count=count))
return out
# ---- Table rec (TABLE_REC_PROMPT) ------------------------------------------
@dataclass
class ParsedTableElement:
label: str # "Row" or "Col"
bbox: Tuple[float, float, float, float]
def parse_table_rec(text: str) -> List[ParsedTableElement]:
"""Parse JSON array of {label: "Row"|"Col", bbox: "x0 y0 x1 y1"} from
TABLE_REC_PROMPT output. Returns a flat list of Row + Col elements;
cell derivation is the caller's job."""
cleaned = _strip_fences(text)
m = _JSON_ARRAY_RE.search(cleaned)
if not m:
raise ValueError(f"No JSON array found in table_rec output: {text[:500]!r}")
raw = json.loads(m.group(0))
out: List[ParsedTableElement] = []
for item in raw:
label = str(item.get("label", "")).strip()
if label not in ("Row", "Col"):
continue
try:
bbox = _coerce_bbox(item["bbox"])
except (KeyError, ValueError):
continue
out.append(ParsedTableElement(label=label, bbox=bbox))
return out
# ---- Block HTML (BLOCK_PROMPT for full table path / general block path) ---
def clean_block_html(html: str) -> str:
"""Light cleanup of model-emitted HTML for a single block.
Strips code fences, leading/trailing whitespace. Does NOT validate against
ALLOWED_TAGS — the model is expected to comply, and downstream consumers
can sanitize further if needed.
"""
cleaned = _strip_fences(html).strip()
return cleaned
# ---- Full-page fallback (HIGH_ACCURACY_BBOX_PROMPT) -----------------------
@dataclass
class ParsedFullPageBlock:
label: str
bbox: Tuple[float, float, float, float] # 0-1000 normalized
html: str # inner HTML of the wrapping div
def parse_full_page_html(text: str) -> List[ParsedFullPageBlock]:
"""Parse output of HIGH_ACCURACY_BBOX_PROMPT — top-level <div data-bbox=...
data-label=...>inner HTML</div> blocks. Returns one entry per top-level div."""
from bs4 import BeautifulSoup
cleaned = _strip_fences(text).strip()
if not cleaned:
return []
# The model outputs a sequence of top-level divs (no surrounding root).
# BeautifulSoup parses fine without one.
soup = BeautifulSoup(cleaned, "html.parser")
divs = soup.find_all("div", recursive=False)
out: List[ParsedFullPageBlock] = []
for div in divs:
label = div.get("data-label")
bbox_str = div.get("data-bbox")
if not label or not bbox_str:
continue
try:
parts = [float(x) for x in bbox_str.split()]
except ValueError:
continue
if len(parts) != 4:
continue
# Strip nested data-bbox attrs from the inner HTML so downstream
# consumers don't see model debug info on every child element.
for tag in div.find_all(attrs={"data-bbox": True}):
del tag["data-bbox"]
for tag in div.find_all(attrs={"data-label": True}):
del tag["data-label"]
inner = "".join(str(c) for c in div.contents).strip()
out.append(
ParsedFullPageBlock(
label=str(label),
bbox=(parts[0], parts[1], parts[2], parts[3]),
html=inner,
)
)
return out
def denorm_bbox(bbox, img_w: int, img_h: int, scale: int = 1000):
x0, y0, x1, y1 = bbox
return (
x0 / scale * img_w,
y0 / scale * img_h,
x1 / scale * img_w,
y1 / scale * img_h,
)