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>
211 lines
7.5 KiB
Python
211 lines
7.5 KiB
Python
import html
|
|
import os
|
|
import re
|
|
import time
|
|
from typing import Any, Dict, List, Sequence, Tuple
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
# These must be set before importing surya.settings, because settings are
|
|
# instantiated at import time.
|
|
os.environ.setdefault("SURYA_INFERENCE_BACKEND", "vllm")
|
|
os.environ.setdefault("SURYA_INFERENCE_URL", "http://127.0.0.1:8000/v1")
|
|
os.environ.setdefault("SURYA_INFERENCE_AUTOSTART", "false")
|
|
os.environ.setdefault("SURYA_INFERENCE_KEEP_ALIVE", "false")
|
|
os.environ.setdefault("SURYA_INFERENCE_LOGPROBS", "false")
|
|
os.environ.setdefault("SURYA_INFERENCE_MAX_RETRIES", "1")
|
|
os.environ.setdefault("SURYA_INFERENCE_PARALLEL", "8")
|
|
os.environ.setdefault("SURYA_MAX_TOKENS_FULL_PAGE", "6144")
|
|
os.environ.setdefault("SURYA_MAX_BLOCKS_PER_PAGE", "80")
|
|
os.environ.setdefault("SUYA_VLLM_IMAGE_FORMAT", "JPEG")
|
|
os.environ.setdefault("SUYA_VLLM_JPEG_QUALITY", "92")
|
|
|
|
from surya.debug.draw import draw_polys_on_image, draw_bboxes_on_image
|
|
from surya.inference import SuryaInferenceManager
|
|
from surya.layout import LayoutPredictor
|
|
from surya.recognition import RecognitionPredictor
|
|
from surya.table_rec import TableRecPredictor
|
|
from surya.timing import timing_span
|
|
|
|
|
|
_TAG_RE = re.compile(r"<[^>]+>")
|
|
OCR_MODE = os.getenv("SUYA_OCR_MODE", "block").strip().lower()
|
|
|
|
|
|
def _html_to_text(value: str) -> str:
|
|
return html.unescape(_TAG_RE.sub(" ", value or "")).strip()
|
|
|
|
|
|
def _load_predictors_vllm() -> Dict[str, Any]:
|
|
manager = SuryaInferenceManager(method="vllm")
|
|
return {
|
|
"manager": manager,
|
|
"layout": LayoutPredictor(manager),
|
|
"recognition": RecognitionPredictor(manager),
|
|
"table_rec": TableRecPredictor(manager),
|
|
}
|
|
|
|
|
|
predictors_vllm = _load_predictors_vllm()
|
|
|
|
|
|
def backend_info() -> Dict[str, Any]:
|
|
manager = predictors_vllm["manager"]
|
|
backend = manager.backend
|
|
handle = getattr(backend, "handle", None)
|
|
return {
|
|
"backend": getattr(manager, "method", "vllm"),
|
|
"started": handle is not None,
|
|
"base_url": getattr(handle, "base_url", None),
|
|
"model_name": getattr(handle, "model_name", None),
|
|
"inference_url": os.getenv("SURYA_INFERENCE_URL"),
|
|
"ocr_mode": OCR_MODE,
|
|
"parallel": os.getenv("SURYA_INFERENCE_PARALLEL"),
|
|
"max_blocks_per_page": os.getenv("SURYA_MAX_BLOCKS_PER_PAGE"),
|
|
}
|
|
|
|
|
|
def vllm_backend_info() -> Dict[str, Any]:
|
|
return backend_info()
|
|
|
|
|
|
def _annotate_page(highres_img: Image.Image, page: Any, with_bboxes: bool) -> Image.Image:
|
|
if not with_bboxes:
|
|
return highres_img
|
|
annotated = highres_img.copy()
|
|
draw = ImageDraw.Draw(annotated)
|
|
for block in page.blocks:
|
|
x0, y0, x1, y1 = block.bbox
|
|
color = "orange" if block.skipped else ("red" if block.error else "green")
|
|
draw.rectangle((x0, y0, x1, y1), outline=color, width=3)
|
|
draw.text((x0 + 4, y0 + 4), str(block.reading_order), fill=color)
|
|
return annotated
|
|
|
|
|
|
def ocr_vllm_batch(
|
|
images: Sequence[Image.Image],
|
|
highres_images: Sequence[Image.Image],
|
|
skip_text_detection: bool = False,
|
|
recognize_math: bool = True,
|
|
with_bboxes: bool = True,
|
|
) -> List[Tuple[Image.Image, Any, Image.Image]]:
|
|
start = time.perf_counter()
|
|
if OCR_MODE not in {"full_page", "block"}:
|
|
raise ValueError("SUYA_OCR_MODE must be 'full_page' or 'block'")
|
|
if len(images) != len(highres_images):
|
|
raise ValueError("images and highres_images must have the same length")
|
|
if not images:
|
|
return []
|
|
|
|
with timing_span(
|
|
"ocr_vllm_batch_total",
|
|
image_count=len(images),
|
|
ocr_mode=OCR_MODE,
|
|
skip_text_detection=skip_text_detection,
|
|
):
|
|
if OCR_MODE == "full_page" or skip_text_detection:
|
|
with timing_span("recognition_full_page_call", image_count=len(highres_images)):
|
|
pages = predictors_vllm["recognition"](list(highres_images), full_page=True)
|
|
else:
|
|
target_sizes = [img.size for img in highres_images]
|
|
with timing_span("layout_predictor_call", image_count=len(images)):
|
|
layouts = predictors_vllm["layout"](list(images), target_image_sizes=target_sizes)
|
|
with timing_span("recognition_block_call", image_count=len(highres_images)):
|
|
pages = predictors_vllm["recognition"](list(highres_images), layouts, full_page=False)
|
|
|
|
elapsed = time.perf_counter() - start
|
|
results = []
|
|
with timing_span("response_assembly", image_count=len(highres_images), with_bboxes=with_bboxes):
|
|
for highres_img, page in zip(highres_images, pages):
|
|
object.__setattr__(page, "_elapsed_seconds", elapsed)
|
|
annotated = _annotate_page(highres_img, page, with_bboxes)
|
|
results.append((annotated, page, annotated))
|
|
return results
|
|
|
|
|
|
def ocr_vllm(
|
|
img: Image.Image,
|
|
highres_img: Image.Image,
|
|
skip_text_detection: bool = False,
|
|
recognize_math: bool = True,
|
|
with_bboxes: bool = True,
|
|
) -> Tuple[Image.Image, Any, Image.Image]:
|
|
return ocr_vllm_batch(
|
|
[img],
|
|
[highres_img],
|
|
skip_text_detection=skip_text_detection,
|
|
recognize_math=recognize_math,
|
|
with_bboxes=with_bboxes,
|
|
)[0]
|
|
|
|
|
|
def page_ocr_to_response(page: Any) -> Dict[str, Any]:
|
|
page_json = page.model_dump()
|
|
text_lines = [
|
|
text
|
|
for text in (_html_to_text(block.get("html", "")) for block in page_json.get("blocks", []))
|
|
if text
|
|
]
|
|
return {
|
|
"ocr_text_json": page_json,
|
|
"text_lines": "\n".join(text_lines),
|
|
"backend": backend_info(),
|
|
"elapsed_seconds": getattr(page, "_elapsed_seconds", None),
|
|
}
|
|
|
|
|
|
def layout_detection_vllm(img: Image.Image) -> Tuple[Image.Image, Any]:
|
|
pred = predictors_vllm["layout"]([img])[0]
|
|
polygons = [p.polygon for p in pred.bboxes]
|
|
labels = [
|
|
f"{p.label}-{p.position}-{round(getattr(p, 'confidence', 0) or 0, 2)}"
|
|
for p in pred.bboxes
|
|
]
|
|
layout_img = draw_polys_on_image(
|
|
polygons, img.copy(), labels=labels, label_font_size=18
|
|
)
|
|
return layout_img, pred
|
|
|
|
|
|
def table_recognition_vllm(
|
|
img: Image.Image, highres_img: Image.Image, skip_table_detection: bool
|
|
) -> Tuple[Image.Image, Any]:
|
|
if skip_table_detection:
|
|
layout_tables = [(0, 0, highres_img.size[0], highres_img.size[1])]
|
|
table_imgs = [highres_img]
|
|
else:
|
|
_, layout_pred = layout_detection_vllm(img)
|
|
layout_tables = [
|
|
tuple(map(int, line.bbox))
|
|
for line in layout_pred.bboxes
|
|
if line.label in ["Table", "TableOfContents"]
|
|
]
|
|
table_imgs = [highres_img.crop(tb) for tb in layout_tables]
|
|
|
|
table_preds = predictors_vllm["table_rec"](table_imgs)
|
|
table_img = highres_img.copy()
|
|
for result, table_bbox in zip(table_preds, layout_tables):
|
|
adjusted_bboxes = []
|
|
labels = []
|
|
colors = []
|
|
for item in [*getattr(result, "rows", []), *getattr(result, "cols", []), *getattr(result, "cells", [])]:
|
|
adjusted_bboxes.append(
|
|
[
|
|
item.bbox[0] + table_bbox[0],
|
|
item.bbox[1] + table_bbox[1],
|
|
item.bbox[2] + table_bbox[0],
|
|
item.bbox[3] + table_bbox[1],
|
|
]
|
|
)
|
|
labels.append(item.label)
|
|
colors.append("blue" if "Row" in item.label else "red")
|
|
if adjusted_bboxes:
|
|
table_img = draw_bboxes_on_image(
|
|
adjusted_bboxes,
|
|
highres_img,
|
|
labels=labels,
|
|
label_font_size=18,
|
|
color=colors,
|
|
)
|
|
return table_img, table_preds
|