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:
Fu Dai
2026-06-17 10:20:02 +04:00
co-authored by Claude Opus 4.8
commit 1a585693be
147 changed files with 13827 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
from PIL import ImageDraw, ImageFont
from surya.debug.fonts import get_font_path
from surya.debug.text import get_text_size
def draw_bboxes_on_image(
bboxes, image, labels=None, label_font_size=10, color: str | list = "red"
):
polys = []
for bb in bboxes:
# Clockwise polygon
poly = [[bb[0], bb[1]], [bb[2], bb[1]], [bb[2], bb[3]], [bb[0], bb[3]]]
polys.append(poly)
return draw_polys_on_image(
polys, image, labels, label_font_size=label_font_size, color=color
)
def draw_polys_on_image(
corners,
image,
labels=None,
box_padding=-1,
label_offset=1,
label_font_size=10,
color: str | list = "red",
):
draw = ImageDraw.Draw(image)
font_path = get_font_path()
label_font = ImageFont.truetype(font_path, label_font_size)
for i in range(len(corners)):
poly = corners[i]
poly = [(int(p[0]), int(p[1])) for p in poly]
draw.polygon(
poly, outline=color[i] if isinstance(color, list) else color, width=1
)
if labels is not None:
label = labels[i]
text_position = (
min([p[0] for p in poly]) + label_offset,
min([p[1] for p in poly]) + label_offset,
)
text_size = get_text_size(label, label_font)
box_position = (
text_position[0] - box_padding + label_offset,
text_position[1] - box_padding + label_offset,
text_position[0] + text_size[0] + box_padding + label_offset,
text_position[1] + text_size[1] + box_padding + label_offset,
)
try:
draw.rectangle(box_position, fill="white")
except Exception as e:
print(f"Error drawing rectangle at {box_position}: {e}")
continue
draw.text(
text_position,
label,
fill=color[i] if isinstance(color, list) else color,
font=label_font,
)
return image
+24
View File
@@ -0,0 +1,24 @@
from typing import List, Optional
import os
import requests
from surya.settings import settings
def get_font_path(langs: Optional[List[str]] = None) -> str:
font_path = settings.RECOGNITION_RENDER_FONTS["all"]
if langs is not None:
for k in settings.RECOGNITION_RENDER_FONTS:
if k in langs and len(langs) == 1:
font_path = settings.RECOGNITION_RENDER_FONTS[k]
break
if not os.path.exists(font_path):
os.makedirs(os.path.dirname(font_path), exist_ok=True)
font_dl_path = f"{settings.RECOGNITION_FONT_DL_BASE}/{os.path.basename(font_path)}"
with requests.get(font_dl_path, stream=True) as r, open(font_path, 'wb') as f:
r.raise_for_status()
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return font_path
+64
View File
@@ -0,0 +1,64 @@
<style>
.katex-display-container {
display: inline-block;
max-width: 100%;
overflow-x: auto;
max-height: 100%;
}
.katex-inline-container {
display: inline-block;
max-width: 100%;
overflow-x: auto;
max-height: 100%;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js" onload="setTimeout(function() {renderMath()})" async></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css">
<script>
function htmlUnescape(escapedText) {
const htmlEntities = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' '
};
return escapedText.replace(/&amp;|&lt;|&gt;|&quot;|&#39;|&nbsp;/g, match => htmlEntities[match]);
}
const renderMath = (function() {
try {
const mathElements = document.querySelectorAll('math');
mathElements.forEach(function(element) {
let mathContent = element.innerHTML.trim();
mathContent = htmlUnescape(mathContent);
const isDisplay = element.getAttribute('display') === 'block';
const container = document.createElement('span');
container.className = isDisplay ? 'katex-display-container' : 'katex-inline-container';
element.parentNode.insertBefore(container, element);
try {
katex.render(mathContent, container, {
displayMode: isDisplay,
throwOnError: false
});
} catch (err) {
console.error('KaTeX rendering error:', err);
container.textContent = mathContent; // Fallback to raw text
}
element.parentNode.removeChild(element);
});
console.log('Math rendering complete with', mathElements.length, 'expressions');
} catch (err) {
console.error('Error in renderMath function:', err);
}
});
</script>
+90
View File
@@ -0,0 +1,90 @@
import html as htmllib
import os.path
import re
filepath = os.path.abspath(__file__)
def render_text_as_html(
bboxes: list[list[int]],
texts: list[str],
image_size: tuple[int, int],
base_font_size: int = 16,
scaler: int = 2
):
katex_path = os.path.join(os.path.dirname(filepath), "katex.js")
with open(katex_path, "r") as f:
katex_script = f.read()
html_content = []
image_size = tuple([int(s * scaler) for s in image_size])
width, height = image_size
html_content.append(f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{
margin: 0;
padding: 0;
width: {width}px;
height: {height}px;
position: relative;
overflow: hidden;
background: white;
color: black;
}}
.text-box {{
position: absolute;
overflow: hidden;
display: flex;
justify-content: left;
font-family: Arial, sans-serif;
white-space: pre-wrap;
}}
.vertical-text {{
writing-mode: vertical-rl; /* Top to bottom, right to left */
}}
</style>
{katex_script}
</head>
<body>
""")
for i, (bbox, text) in enumerate(zip(bboxes, texts)):
bbox = bbox.copy()
bbox = [int(bb * scaler) for bb in bbox]
x1, y1, x2, y2 = bbox
width = x2 - x1
height = y2 - y1
min_dim = min(width, height)
# Scale font size based on box height
font_size = min(int(min_dim * 0.75), base_font_size)
# Create div with absolute positioning
div_style = (
f"left: {x1}px; "
f"top: {y1}px; "
f"width: {width}px; "
f"height: {height}px; "
f"font-size: {font_size}px;"
)
class_ = "text-box"
if height > width * 2:
class_ += " vertical-text"
# Determine if content is HTML/MathML or plain text
if "<" in text and ">" in text and re.search(r"<(html|math|div|sub|sup|i|u|mark|small|del|b|br|code)\b", text.lower()):
# Content is already HTML/MathML, include as-is
html_content.append(f'<span class="{class_}" id="box-{i}" style="{div_style}">{text}</span>')
else:
# Plain text, escape it
escaped_text = htmllib.escape(text)
html_content.append(f'<span class="{class_}" id="box-{i}" style="{div_style}">{escaped_text}</span>')
html_content.append("</body></html>")
return "\n".join(html_content), image_size
+8
View File
@@ -0,0 +1,8 @@
from PIL import Image, ImageDraw
def get_text_size(text, font):
im = Image.new(mode="P", size=(0, 0))
draw = ImageDraw.Draw(im)
_, _, width, height = draw.textbbox((0, 0), text=text, font=font)
return width, height