Files
surya-ocr/surya/endpoint/service.py
T
Fu DaiandClaude Opus 4.8 1a585693be 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>
2026-06-17 10:20:02 +04:00

167 lines
5.4 KiB
Python

import base64
import binascii
import io
import json
import logging
import time
import traceback
from typing import Any, Dict
import requests
from fastapi import Request
from PIL import Image
import tools
from vllm_batcher import vllm_ocr_batcher
from vllm_tools import page_ocr_to_response
logger = logging.getLogger("surya.endpoint")
class OcrRequestError(Exception):
"""Client-side error in an OCR request. Maps to HTTP 400."""
# ---- helpers moved verbatim from api.py ------------------------------------
def _load_base64_image(p, copy_highres: bool = False):
start = time.perf_counter()
filetype = p.type.lower()
allowed_types = ["png", "jpg", "jpeg", "gif"]
if filetype not in allowed_types:
raise ValueError("Unsupported file type")
pil_image = Image.open(io.BytesIO(p._decoded_file)).convert("RGB")
logger.info(
"api_decode_image duration_ms=%.2f size_bytes=%s image_size=%s copy_highres=%s",
(time.perf_counter() - start) * 1000,
len(p._decoded_file),
pil_image.size,
copy_highres,
)
return pil_image, pil_image.copy() if copy_highres else pil_image
def _error_response(e: Exception):
tb = traceback.extract_tb(e.__traceback__)
frame = tb[-1] if tb else None
return {
"data": [],
"message": json.dumps(
{
"error info": str(e),
"error at": frame.filename if frame else "unknown",
"errort line at": frame.lineno if frame else 0,
}
),
"code": 500,
}
def _success_response(data: Any) -> Dict[str, Any]:
return {"data": data, "message": "success", "code": 200}
def _log_exception(endpoint: str, request: Request, e: Exception, results: Dict[str, Any]):
request_id = getattr(request.state, "request_id", "-")
logger.exception(
"endpoint_failed request_id=%s endpoint=%s code=%s message=%s",
request_id,
endpoint,
results.get("code"),
results.get("message"),
exc_info=True,
)
def _ocr_response_data(pred):
if hasattr(pred, "text_lines"):
text_json = pred.model_dump()
text_lines = "\n".join([p.text for p in pred.text_lines])
return {"ocr_text_json": text_json, "text_lines": text_lines}
return page_ocr_to_response(pred)
def _dump_model_or_list(value):
if hasattr(value, "model_dump"):
return value.model_dump()
if isinstance(value, list):
return [
item.model_dump() if hasattr(item, "model_dump") else item
for item in value
]
return value
# ---- new helpers for the OpenAI endpoint -----------------------------------
def load_image_from_url(url: str) -> Image.Image:
"""Decode an OpenAI `image_url` (data: base64 URL or http(s): URL) to RGB."""
if url.startswith("data:"):
header, _, b64 = url.partition(",")
if "base64" not in header or not b64:
raise OcrRequestError("image_url data URL must be base64-encoded")
try:
raw = base64.b64decode(b64, validate=True)
except binascii.Error as exc:
raise OcrRequestError("image_url contains invalid base64") from exc
elif url.startswith("http://") or url.startswith("https://"):
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
except Exception as exc:
raise OcrRequestError(f"could not fetch image_url: {exc}") from exc
raw = resp.content
else:
raise OcrRequestError("image_url must be a data: or http(s): URL")
try:
return Image.open(io.BytesIO(raw)).convert("RGB")
except Exception as exc:
raise OcrRequestError(f"could not decode image: {exc}") from exc
def extract_last_image(messages) -> Image.Image:
"""Return the last image found across the chat messages' content parts."""
url = None
for msg in messages:
content = msg.content
if isinstance(content, list):
for part in content:
if getattr(part, "type", None) == "image_url" and part.image_url:
url = part.image_url.url
if url is None:
raise OcrRequestError("no image_url content part found in messages")
return load_image_from_url(url)
def ocr_via_batcher(
image: Image.Image,
*,
skip_text_detection: bool,
recognize_math: bool,
request_id: str,
) -> Dict[str, Any]:
"""Run page OCR through the shared request batcher. The annotated image is
discarded, so we request with_bboxes=False to maximise batch coalescing with
the primary /suya_ocr_vllm endpoint."""
_, pred, _ = vllm_ocr_batcher.submit(
image,
image,
skip_text_detection=skip_text_detection,
recognize_math=recognize_math,
with_bboxes=False,
request_id=request_id,
)
return _ocr_response_data(pred)
def table_image(image: Image.Image, skip_table_detection: bool) -> Dict[str, Any]:
"""Run table-structure recognition; mirrors the /suya_table_rec response shape."""
_, pred = tools.table_recognition(image, image.copy(), skip_table_detection)
text_json = _dump_model_or_list(pred)
text_lines = ""
if isinstance(pred, list):
text_lines = "\n".join([item.html for item in pred if getattr(item, "html", None)])
elif hasattr(pred, "text_lines"):
text_lines = "\n".join([line.text for line in pred.text_lines])
return {"ocr_text_json": text_json, "text_lines": text_lines, "elapsed_seconds": None}