Files
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

309 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Suya OCR API
A FastAPI service that wraps the [Surya-OCR-2](https://huggingface.co/datalab-to/surya-ocr-2)
document-OCR model and serves it through **vLLM** for production-grade throughput and latency.
A single 650M vision-language model (`qwen3_5` architecture) performs both **layout detection**
and **text recognition** — there is no separate torch detection model in the hot path.
```
HTTP client ──► FastAPI app (surya/endpoint/, served via api.py shim :5002)
├─ legacy.py /v1/api/ai/* envelope endpoints
├─ openai.py /v1/chat/completions (OpenAI-compatible)
├─ request batcher (vllm_batcher.py) ← coalesces concurrent requests
│ ▼
└─ vLLM OpenAI server (:8000) ← Surya-OCR-2 VLM
```
> **Inbound OpenAI:** clients call `/v1/chat/completions`. **Outbound OpenAI:** the service itself calls the vLLM server via `surya/inference/backends/openai_client.py`.
---
## What was improved
This service started as a torch-spawn pipeline and was re-engineered for serving, with the
measured results summarized below:
| Area | Change | Result |
| --- | --- | --- |
| **Serving engine** | Recognition + layout now run through a vLLM **OpenAI-compatible** server instead of in-process torch | Continuous batching, CUDA graphs, prefix caching |
| **vLLM tuning** | CUDA graph on, prefix caching on, chunked prefill on (pinned in the launch script) | CUDA graph is the biggest lever — **~9.5× vs eager**; prefix cache ~15% |
| **Concurrency fix** | Matched the batcher's HTTP fan-out (`SURYA_INFERENCE_MAX_INFLIGHT`) to vLLM's 16 sequence slots | Closed a **33% mean / 32% p95** regression |
| **Request batching** | Added a coalescing batcher so concurrent page requests share vLLM batches | Fills all 16 slots under load |
| **Quantization study** | Benchmarked fp8/int8/awq/gptq/bnb4/bnb8 vs BF16 on A100 | **No method beats BF16 on latency**; int8 (GPTQ W8A8) near-lossless at ~40% smaller |
| **MTP speculative decode** | Tested `--speculative-config mtp` | **Rejected: +2736% slower** at 16-wide block concurrency |
**Bottom line:** on A100 the latency levers are *serving config + batching*, not weight
quantization or speculative decoding. Keep BF16 with CUDA graphs and prefix caching on.
---
## Examples — multilingual OCR
Real outputs from **this service** (block mode, single A100) on public-domain newspaper/magazine
scans: the original page, the same page with detected text-block boxes (coloured by block type /
reading order; orange = non-text region skipped), and an excerpt of the recognised text. Full
text for each sample lives in [`assets/`](assets/).
| Language | Original | Detected boxes | OCR text (excerpt) |
| --- | --- | --- | --- |
| **Chinese**<br><sub>叻報, 1890</sub> | <img src="assets/chinese_original.jpg" width="180"> | <img src="assets/chinese_boxes.png" width="180"> | 大清光緒十六年<br>本館新聞除禮拜外日出一張<br>諸君賜閱本報者無論本埠外埠…<br>工務局告示 |
| **English**<br><sub>The Nation, 1846</sub> | <img src="assets/english_original.png" width="180"> | <img src="assets/english_boxes.png" width="180"> | VOL. IV. No. 181.<br>DUBLIN, SATURDAY, MARCH 28, 1846.<br>PRICE 6 D.<br>DAVIS TESTIMONIAL. |
| **Arabic**<br><sub>Al-Ahram, 1981</sub> | <img src="assets/arabic_original.jpg" width="180"> | <img src="assets/arabic_boxes.png" width="180"> | رئيس مجلس الادارة — عبد الله عبد البخاري<br>المنطقات المنتصرة<br>السنة ١٠٠٧ — العدد ٣٩٦٣٨ |
| **Russian**<br><sub>Виттова Пляска, 1905</sub> | <img src="assets/russian_original.jpg" width="180"> | <img src="assets/russian_boxes.png" width="180"> | Виттова Пляска<br>ОДНОДНЕВНАЯ ГАЗЕТА. ПАЛИ—ТИКО—ФИ—НАНСОВАЯ<br>Цѣна 5 копѣекъ. |
| **French**<br><sub>Le Miroir des Sports, 1937</sub> | <img src="assets/french_original.jpg" width="180"> | <img src="assets/french_boxes.png" width="180"> | LE MIROIR DES SPORTS<br>Le plus fort tirage des hebdomadaires sportifs<br>Mardi 10 Août 1937 |
> Excerpts are the model's **raw** output on hard, dense historical scans — ornate mastheads
> (e.g. the gothic “THE NATION”) are the failure cases. Reproduce any row with, e.g.,
> `python -m surya.scripts.ocr_text assets/russian_original.jpg`, or via the API/CLI examples below.
---
## API
FastAPI app served on port **5002**. Interactive docs: `http://<host>:5002/v1/api/ai/swagger`.
All OCR endpoints accept the same JSON body and return the same envelope.
**Request body:**
```json
{
"file": "<base64-encoded image bytes>",
"type": "png", // one of: png | jpg | jpeg | gif
"skip_text_detection": false, // true ⇒ full-page OCR, skip layout
"skip_table_detection": false, // table endpoint only
"recognize_math": false,
"ocr_with_boxes": true
}
```
**Response envelope:**
```json
{ "data": { "ocr_text_json": {...}, "text_lines": "line1\nline2\n...", "elapsed_seconds": 4.64 },
"message": "success",
"code": 200 }
```
| Method | Path | Purpose | Backend |
| --- | --- | --- | --- |
| POST | `/v1/api/ai/suya_ocr_vllm/` | **Primary OCR** — layout + recognition, request-batched | vLLM |
| GET | `/v1/api/ai/suya_ocr_vllm/health` | Backend info (model, base URL, mode, parallelism) | — |
| POST | `/v1/api/ai/suya_ocr/` | OCR (routes to the same vLLM path) | vLLM |
| POST | `/v1/api/ai/suya_layout_det/` | Layout detection only | vLLM |
| POST | `/v1/api/ai/suya_table_rec/` | Table structure recognition | vLLM |
| POST | `/image2text` | OCR a multipart file upload (no base64) | vLLM |
> The legacy `/v1/api/ai/suya_text_det/` endpoint targets the standalone torch detection model,
> which is **not loaded** in the vLLM deployment (the VLM does detection internally). It will
> return an error envelope — use `/suya_ocr_vllm/` instead.
### OpenAI-compatible endpoint (`POST /v1/chat/completions`)
Point any OpenAI client at `http://<host>:5002/v1`. Attach the page as an `image_url`
content part; the joined OCR text comes back in `choices[0].message.content`, and the
structured per-line JSON + timing come back in a non-standard `surya` field.
OCR options are passed through `extra_body` (the OpenAI SDK delivers these at the top
level of the request, which is where the server reads them):
| Field | Default | Purpose |
| --- | --- | --- |
| `mode` | `block` | `block` (layout→per-block), `full_page` (one call), or `table` |
| `skip_text_detection` | `false` | Full-page OCR, skip layout (same as `mode:full_page`) |
| `recognize_math` | `false` | Math-aware recognition |
| `skip_table_detection` | `false` | `mode:table` only — treat whole image as one table |
| `ocr_with_boxes` | `true` | When `false`, omit `surya.ocr_text_json` (text only) |
Streaming (`stream:true`) is **not supported** and returns HTTP 400 — OCR completes
all at once, so there is no token stream.
### OpenAI-compatible backend
Recognition and layout are issued as **OpenAI Chat Completions** calls to the vLLM server
(`POST {SURYA_INFERENCE_URL}/chat/completions`, default `http://127.0.0.1:8000/v1`), one per
image crop, with the page image passed as a base64 `image_url` content part. This means the
inference tier is a standard vLLM OpenAI server — you can point `SURYA_INFERENCE_URL` at any
OpenAI-compatible vLLM endpoint (local, remote, or a shared cluster).
---
## Concurrency support
Two cooperating layers keep the GPU saturated without overcommitting:
1. **Request batcher** (`vllm_batcher.py`): a background thread coalesces incoming page
requests that share the same options into one batch — up to `SUYA_MAX_BATCH_SIZE` (8),
waiting at most `SUYA_BATCH_WAIT_MS` (25 ms). Requests queue up to `SUYA_MAX_QUEUE_SIZE`
(128); callers block on a future until their page completes.
2. **vLLM sequence slots**: within a page, the detected blocks are fanned out over HTTP up to
`SURYA_INFERENCE_MAX_INFLIGHT` (16) concurrent workers, matching vLLM's `--max-num-seqs` (16)
so every sequence slot is filled. (Overcommitting past 16 only queues — see the diagnosis.)
**Measured behaviour** (single A100, `benchmarks/concurrency_sweep_results.csv`) — 1→50 concurrent
clients, **0 failures** at every level. Throughput is GPU-bound and plateaus around
**0.20 req/s** on one model replica:
| Concurrency | Success / Failed | Throughput (req/s) | Mean latency (s) | p95 latency (s) |
| --- | --- | --- | --- | --- |
| 1 | 1 / 0 | 0.13 | 7.66 | 7.66 |
| 4 | 4 / 0 | 0.19 | 20.95 | 20.96 |
| 8 | 8 / 0 | 0.19 | 34.05 | 41.46 |
| 12 | 12 / 0 | 0.20 | 45.55 | 59.96 |
| 20 | 20 / 0 | 0.20 | 66.68 | 92.91 |
| 30 | 30 / 0 | 0.20 | 90.50 | 147.47 |
| 50 | 50 / 0 | 0.21 | 149.29 | 240.87 |
![Latency vs concurrency](benchmarks/concurrency_sweep_latency.png)
Scale throughput by adding GPU replicas behind a load balancer, each with its own vLLM server.
---
## How to use
### Run with Docker (recommended)
```bash
docker build -t suya-ocr-api .
docker run --rm --gpus all -p 5002:5002 suya-ocr-api
```
The container entrypoint (`scripts/start_single_container.sh`) starts the vLLM server with the
tuned flags, waits for it to become healthy, then launches the API on `:5002`.
> **Deployment target:** the Dockerfile defaults target **T4** (`VLLM_DTYPE=float16`,
> `VLLM_GPU_TYPE=t4`). On bf16-capable GPUs (A100/L40/4090) set `VLLM_DTYPE=bfloat16`.
### API mode — call the HTTP service (curl)
```bash
# encode an image and OCR it
B64=$(base64 -w0 page.png)
curl -s http://localhost:5002/v1/api/ai/suya_ocr_vllm/ \
-H 'Content-Type: application/json' \
-d "{\"file\":\"$B64\",\"type\":\"png\",\"ocr_with_boxes\":true}" \
| python3 -c "import sys,json; d=json.load(sys.stdin)['data']; print(d['text_lines']); print('elapsed:', d['elapsed_seconds'])"
# full-page OCR (skip layout) — good for single-column pages
curl -s http://localhost:5002/v1/api/ai/suya_ocr_vllm/ \
-H 'Content-Type: application/json' \
-d "{\"file\":\"$B64\",\"type\":\"png\",\"skip_text_detection\":true}"
# health / backend info
curl -s http://localhost:5002/v1/api/ai/suya_ocr_vllm/health
```
### Demo: OCR one image via the OpenAI API
**OpenAI Python SDK** (the headline "it's really OpenAI-compatible" path):
```python
import base64
from openai import OpenAI
client = OpenAI(base_url="http://localhost:5002/v1", api_key="not-needed")
with open("page.png", "rb") as f:
b64 = base64.b64encode(f.read()).decode()
resp = client.chat.completions.create(
model="surya-ocr",
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
{"type": "text", "text": "Extract the text"},
],
}],
extra_body={"mode": "block", "ocr_with_boxes": True},
)
print(resp.choices[0].message.content) # plain OCR text
print(resp.model_dump()["surya"]["ocr_text_json"]) # structured boxes
```
**curl** (data-URL base64 image):
```bash
B64=$(base64 -w0 page.png)
curl -s http://localhost:5002/v1/chat/completions \
-H 'Content-Type: application/json' \
-d "{\"model\":\"surya-ocr\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/png;base64,$B64\"}},{\"type\":\"text\",\"text\":\"ocr\"}]}]}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['choices'][0]['message']['content'])"
```
### CLI mode — local OCR (no HTTP server)
Run OCR straight from the command line. The `surya` package spins up its own vLLM-backed
inference backend, so the FastAPI server does **not** need to be running. Input can be a single
image, a PDF, or a folder of them; results are written as `results.json`.
```bash
# OCR an image or PDF → writes results/surya/<name>/results.json
python -m surya.scripts.ocr_text page.png
# pick an output dir and OCR only some PDF pages (0-indexed; ranges allowed)
python -m surya.scripts.ocr_text doc.pdf --output_dir out --page_range 0,2-4
# also save annotated bbox images, and print per-stage timing
python -m surya.scripts.ocr_text page.png --images --debug
```
By default the CLI **autostarts a vLLM server** (`SURYA_INFERENCE_AUTOSTART=true`) and tears it
down on exit. To reuse a vLLM server that is already running (for example the one started by the
Docker container or `scripts/start_single_container.sh`), point the CLI at it instead of spawning
a new one:
```bash
SURYA_INFERENCE_URL=http://127.0.0.1:8000/v1 python -m surya.scripts.ocr_text page.png
```
Pass `--keep_server` to leave a spawned server up so back-to-back commands reuse it. Sibling
commands share the same options (run with `--help`):
`surya.scripts.detect_layout`, `surya.scripts.detect_text`, `surya.scripts.table_recognition`.
### Run without Docker
Requires a CUDA GPU with the matching vLLM. Start the two processes (vLLM first, then the API)
exactly as `scripts/start_single_container.sh` does, or run that script directly inside an
environment that already has the dependencies from `requirements.txt`.
---
## Configuration
Set via environment variables (defaults shown; see `Dockerfile` and `surya/settings.py`):
| Variable | Default | Purpose |
| --- | --- | --- |
| `SURYA_INFERENCE_URL` | `http://127.0.0.1:8000/v1` | vLLM OpenAI endpoint |
| `SURYA_INFERENCE_PARALLEL` | `8` | Recognition worker hint |
| `SURYA_INFERENCE_MAX_INFLIGHT` | `16` | Max concurrent HTTP calls to vLLM — **keep = `VLLM_MAX_NUM_SEQS`** |
| `SUYA_OCR_MODE` | `block` | `block` (layout→per-block) or `full_page` (single call) |
| `SUYA_MAX_BATCH_SIZE` | `8` | Request-batcher coalesce size |
| `SUYA_BATCH_WAIT_MS` | `25` | Request-batcher wait window |
| `SUYA_MAX_QUEUE_SIZE` | `128` | Request-batcher backlog cap |
| `VLLM_DTYPE` | `float16` | `bfloat16` on A100/L40/4090; `float16` on T4 |
| `VLLM_MAX_MODEL_LEN` | `18000` | vLLM context length |
| `VLLM_MAX_NUM_SEQS` | `16` | vLLM sequence slots |
| `VLLM_GPU_MEMORY_UTILIZATION` | `0.85` | vLLM GPU memory fraction |
---
## Benchmarks
- `benchmarks/` — concurrency sweep scripts and results (see the table above)
- `scripts/quant/` — the quantization benchmarking toolchain (build, serve, capture, score, plot)
---
## Acknowledgements
This project is a serving wrapper around **[Surya OCR](https://github.com/datalab-to/surya)** by
**[Datalab](https://www.datalab.to/)**. All layout detection and text recognition is done by their
[`datalab-to/surya-ocr-2`](https://huggingface.co/datalab-to/surya-ocr-2) model — this repo only
adds the vLLM serving, request batching, and API/CLI around it. Huge thanks to the Surya team for
building and open-sourcing such a capable multilingual OCR model.
- Official site: <https://www.datalab.to/>
- Surya GitHub: <https://github.com/datalab-to/surya>
- Model: <https://huggingface.co/datalab-to/surya-ocr-2>