Files
wehub-resource-sync 9f97f3abbe
CI - Python Bindings / sdist (push) Failing after 1s
CI - Python Bindings / Build x86_64-unknown-linux-musl (push) Failing after 1s
CI / fmt (push) Failing after 1s
E2E Output Validation / compare-outputs (push) Failing after 1s
Sync Docs to Developer Hub / sync-docs (push) Failing after 1s
CI - Python Bindings / Build x86_64-unknown-linux-gnu (push) Failing after 1s
CI - WASM Bindings / Build WASM (push) Failing after 0s
CI / clippy (push) Failing after 1s
CI / build-and-test (ubuntu-latest) (push) Failing after 0s
CI - WASM Bindings / Edge runtime PDF parse test (push) Has been skipped
CI - WASM Bindings / Browser PDF parse test (push) Has been skipped
Deploy Demo to GitHub Pages / deploy (push) Failing after 1s
CI / build-docker-image (push) Failing after 3s
CI - Node Bindings / Build darwin-x64 (push) Has been cancelled
CI - Node Bindings / Test win32-x64-msvc (push) Has been cancelled
CI - Node Bindings / Build linux-arm64-gnu (push) Has been cancelled
CI - Node Bindings / Build linux-x64-gnu (push) Has been cancelled
CI - Node Bindings / Build linux-x64-musl (push) Has been cancelled
CI - Node Bindings / Build win32-arm64-msvc (push) Has been cancelled
CI - Node Bindings / Build win32-x64-msvc (push) Has been cancelled
CI - Node Bindings / Test darwin-arm64 (push) Has been cancelled
CI - Node Bindings / Test darwin-x64 (push) Has been cancelled
CI - Node Bindings / Test linux-x64-gnu (push) Has been cancelled
CI - Node Bindings / Test linux-x64-musl (push) Has been cancelled
CI - Node Bindings / Test win32-arm64-msvc (push) Has been cancelled
CI - Python Bindings / Build aarch64-pc-windows-msvc (push) Has been cancelled
CI - Python Bindings / Build x86_64-pc-windows-msvc (push) Has been cancelled
CI - Python Bindings / Build x86_64-apple-darwin (push) Has been cancelled
CI - Python Bindings / Build aarch64-apple-darwin (push) Has been cancelled
CI - Python Bindings / Build aarch64-unknown-linux-gnu (push) Has been cancelled
CI - Node Bindings / Build darwin-arm64 (push) Has been cancelled
CI - Python Bindings / Test x86_64-apple-darwin (push) Has been cancelled
CI - Python Bindings / Test aarch64-apple-darwin (push) Has been cancelled
CI - Python Bindings / Test x86_64-unknown-linux-gnu (push) Has been cancelled
CI - Python Bindings / Test x86_64-unknown-linux-musl (push) Has been cancelled
CI - Python Bindings / Test aarch64-pc-windows-msvc (push) Has been cancelled
CI - Python Bindings / Test x86_64-pc-windows-msvc (push) Has been cancelled
CI / build-and-test (macos-26-intel) (push) Has been cancelled
CI / build-and-test (macos-latest) (push) Has been cancelled
CI / build-and-test (windows-11-arm) (push) Has been cancelled
CI / build-and-test (windows-latest) (push) Has been cancelled
E2E Output Validation / upload-dataset (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:44 +08:00

187 lines
6.2 KiB
Python

import io
import logging
import os
import re
import traceback
from html import unescape
from html.parser import HTMLParser
from typing import Any
# Surya 2 is a VLM-backed model: text recognition runs through an inference
# backend. Default to the CPU-friendly llama.cpp backend, which spawns a
# `llama-server` binary (bundled in the Docker image; install locally with
# `brew install llama.cpp` or from github.com/ggml-org/llama.cpp/releases) and
# downloads the GGUF weights on first use. Override for GPU with
# SURYA_INFERENCE_BACKEND=vllm, or attach to an already-running inference
# server with SURYA_INFERENCE_URL. These must be set before importing surya.
os.environ.setdefault("SURYA_INFERENCE_BACKEND", "llamacpp")
os.environ.setdefault("LLAMA_CPP_NGL", "0") # 0 = CPU; set 99 for full GPU offload
# Surya only sends a grammar for the layout step (LAYOUT_JSON_SCHEMA), whose
# regex `pattern` the upstream llama.cpp json-schema→GBNF converter cannot
# parse ("failed to parse grammar"). Disable guided layout so layout runs as
# free generation (Surya parses the output itself); block/full-page OCR never
# use a grammar. Required for the llama.cpp backend to return results.
os.environ.setdefault("SURYA_GUIDED_LAYOUT", "false")
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.datastructures import UploadFile
from fastapi.param_functions import File, Form
from PIL import Image
from pydantic import BaseModel
from surya.inference import SuryaInferenceManager
from surya.recognition import RecognitionPredictor
_BLOCK_OR_BREAK = {
"br", "p", "div", "li", "tr", "td", "th",
"h1", "h2", "h3", "h4", "h5", "h6",
}
class _TextExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__()
self._parts: list[str] = []
def handle_data(self, data: str) -> None:
self._parts.append(data)
def handle_starttag(self, tag: str, attrs: object) -> None:
if tag in _BLOCK_OR_BREAK:
self._parts.append(" ")
def handle_endtag(self, tag: str) -> None:
if tag in _BLOCK_OR_BREAK:
self._parts.append(" ")
def text(self) -> str:
return "".join(self._parts)
def _html_to_text(html: str) -> str:
"""Strip block HTML to collapsed plain text (stdlib only)."""
if not html:
return ""
parser = _TextExtractor()
parser.feed(html)
parser.close()
return re.sub(r"\s+", " ", unescape(parser.text())).strip()
class OcrResponse(BaseModel):
results: list[Any]
class StatusResponse(BaseModel):
status: str
def _coerce_polygon(polygon: Any) -> list[list[float]] | None:
"""Return a 4x2 float polygon, or None if the shape is invalid."""
if polygon is None:
return None
if hasattr(polygon, "tolist"):
polygon = polygon.tolist()
if len(polygon) == 4 and all(len(pt) == 2 for pt in polygon):
return [[float(pt[0]), float(pt[1])] for pt in polygon]
return None
def _block_to_result(block: Any) -> dict[str, Any] | None:
"""Map a Surya block to the LiteParse OCR shape, or None to skip it."""
get = (
block.get if isinstance(block, dict) else lambda k, d=None: getattr(block, k, d)
)
if get("skipped", False):
return None
text = _html_to_text(get("html", "") or "")
if not text:
return None
polygon = _coerce_polygon(get("polygon"))
bbox = get("bbox")
if hasattr(bbox, "tolist"):
bbox = bbox.tolist()
if bbox is not None:
bbox = [int(round(float(v))) for v in bbox]
elif polygon is not None:
xs = [pt[0] for pt in polygon]
ys = [pt[1] for pt in polygon]
bbox = [int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))]
else:
bbox = [0, 0, 0, 0]
confidence = get("confidence")
confidence = float(confidence) if confidence is not None else 1.0
result: dict[str, Any] = {"text": text, "bbox": bbox, "confidence": confidence}
if polygon is not None:
result["polygon"] = polygon
return result
class SuryaOCRServer:
def __init__(self) -> None:
# Surya 2 is multilingual; one model handles all languages. The
# inference manager selects the device automatically (override with
# the TORCH_DEVICE env var) and may download models on first run.
self.manager = SuryaInferenceManager()
self.recognition_predictor = RecognitionPredictor(self.manager)
def _create_ocr_server(self) -> FastAPI:
app = FastAPI()
@app.post("/ocr")
async def ocr_endpoint(
file: UploadFile = File(...), language: str = Form(default="en")
) -> OcrResponse:
# `language` is accepted for API compatibility but unused: Surya 2
# is multilingual and needs no per-language model reload.
try:
image_data = await file.read()
image = Image.open(io.BytesIO(image_data))
if image.mode != "RGB":
image = image.convert("RGB")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid image: {e}")
try:
predictions = self.recognition_predictor([image])
except Exception as e:
logging.error("OCR failed:\n%s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
formatted: list[dict[str, Any]] = []
if predictions:
page = predictions[0]
blocks = (
page.get("blocks", [])
if isinstance(page, dict)
else getattr(page, "blocks", [])
)
for block in blocks:
mapped = _block_to_result(block)
if mapped is not None:
formatted.append(mapped)
return OcrResponse(results=formatted)
@app.get("/health")
def health() -> StatusResponse:
return StatusResponse(status="healthy")
return app
def serve(self) -> None:
app = self._create_ocr_server()
uvicorn.run(app, host="0.0.0.0", port=8830)
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
logging.info("Starting server on port 8830")
SuryaOCRServer().serve()