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:
@@ -0,0 +1,166 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user