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>
167 lines
5.4 KiB
Python
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}
|