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
+25
View File
@@ -0,0 +1,25 @@
# Keep the build context small — none of this is needed at runtime.
.git
.venv-quant
.venv
venv
*.venv
.pytest_cache
__pycache__
**/__pycache__
*.pyc
results
baseline_outputs
eval_set
docs
tests
logs/*.log
concurrency_*.json
concurrency_*.csv
concurrency_*.png
concurrency_sweep.py
concurrency_test.py
*.md
temp_image_*.png
temp_image_*.jpg
temp_image_*.jpeg
+27
View File
@@ -0,0 +1,27 @@
# Python bytecode
__pycache__/
*.py[cod]
# Logs
logs/
*.log
# Virtualenvs, caches, agent tooling
.venv*/
.pytest_cache/
.agents/
.codex/
# Benchmark / eval / quantization artifacts (kept on disk, never in git)
results/
baseline_outputs/
eval_set/
# Local docs / working notes
docs/
# Scratch OCR inputs/outputs
temp_image_*
output.json
compare_out/
*.pdf
+125
View File
@@ -0,0 +1,125 @@
# Repository Instructions
## Project Context
This repo serves a FastAPI OCR API from `api.py`. The API keeps the legacy
request/response shape:
```json
{"data": ..., "message": "success", "code": 200}
```
Input validation is handled with Pydantic in `Info`. Keep external payload
fields stable unless the user explicitly asks for a breaking API change.
Primary endpoints:
- `POST /v1/api/ai/suya_ocr/`: legacy OCR route.
- `POST /v1/api/ai/suya_ocr_vllm/`: vLLM-backed OCR route.
- `GET /v1/api/ai/suya_ocr_vllm/health`: reports vLLM backend attachment.
- `POST /v1/api/ai/suya_text_det/`, `/suya_layout_det/`, `/suya_table_rec/`.
## Local Run
Use the conda Python on this host; Homebrew `python3` may not have the API
dependencies installed. For local direct `api.py` runs, start a vLLM OpenAI
server separately and point the API at it.
```bash
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/path/to/suya-ocr-api \
SURYA_INFERENCE_BACKEND=vllm \
SURYA_INFERENCE_URL=http://127.0.0.1:8000/v1 \
SURYA_INFERENCE_AUTOSTART=false \
SURYA_INFERENCE_PARALLEL=8 \
SURYA_MAX_TOKENS_FULL_PAGE=6144 \
/opt/conda/bin/python api.py
```
The API listens on `http://127.0.0.1:5002`.
For Kubernetes/container deployment, use `Dockerfile`. It is based on
`vllm/vllm-openai:v0.20.1` and runs vLLM in the background, then `api.py` in
the foreground through `scripts/start_single_container.sh`.
## vLLM Backend Notes
The repo vendors the Surya source under `surya/`; runtime must not depend on
`/path/to/surya`. `vllm_tools.py` must set Surya/vLLM environment
defaults before importing `surya.settings` or Surya predictor classes.
Important defaults:
- `SURYA_INFERENCE_BACKEND=vllm`
- `SURYA_INFERENCE_URL=http://127.0.0.1:8000/v1`
- `SURYA_INFERENCE_AUTOSTART=false`
- `SURYA_INFERENCE_LOGPROBS=false`
- `SURYA_INFERENCE_MAX_RETRIES=1`
- `SURYA_MAX_TOKENS_FULL_PAGE=6144`
- `SURYA_MAX_BLOCKS_PER_PAGE=80`
- `SUYA_OCR_MODE=block`
The Dockerfile is tuned conservatively for a T4 machine:
- `VLLM_GPU_TYPE=t4`
- `VLLM_DTYPE=float16`
- `SURYA_INFERENCE_PARALLEL=8`
- `VLLM_MAX_NUM_SEQS=16`
- `VLLM_MAX_BATCHED_TOKENS=4096`
- `VLLM_GPU_MEMORY_UTILIZATION=0.85`
If running on A100 hardware, do not silently change the Dockerfile T4 defaults;
ask or document the deployment target first.
## Docker/NVIDIA
The production container must not run nested Docker. Kubernetes should provide
GPU access to the single container. The old Surya Docker auto-spawn path is
disabled unless `SUYA_ALLOW_NESTED_DOCKER=true` is explicitly set.
For host-level debugging only, Docker GPU runtime can be checked with:
```bash
docker info --format '{{json .Runtimes}}'
docker run --rm --runtime nvidia --gpus all --entrypoint nvidia-smi vllm/vllm-openai:v0.20.1 -L
```
If the named runtime is missing, use:
```bash
sudo nvidia-ctk runtime configure --runtime=docker --config=/etc/docker/daemon.json
sudo systemctl restart docker
```
`scripts/docker_nvidia_runtime_compat.sh` is legacy support for local debugging,
not the Kubernetes deployment path.
## Concurrency Benchmark
Run the comparison benchmark with the same image and settings when updating
results:
```bash
/opt/conda/bin/python concurrency_test.py \
--image /path/to/surya/static/images/excerpt.png \
--requests 16 \
--concurrency 16 \
--timeout 900 \
--output concurrency_test_results.json
```
After a successful run, update:
- `concurrency_test_results.json`
- `IMPLEMENTATION_PLAN_AND_TEST_RESULTS.md`
Report latency columns as seconds. Mean latency is the average elapsed time per
request, not wall-clock time for the whole benchmark.
## Editing Guidance
- Use `rg`/`rg --files` for search.
- Use `apply_patch` for manual edits.
- Do not revert user changes or generated benchmark artifacts unless requested.
- Keep logging structured and include request IDs for API paths.
- Avoid import-time OCR work; model or backend attachment should happen only as
needed by predictor setup and requests.
+66
View File
@@ -0,0 +1,66 @@
FROM vllm/vllm-openai:v0.20.1
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install --no-install-recommends -y \
ffmpeg \
libsm6 \
libxext6 \
libcairo2 \
libgirepository1.0-dev \
libdbus-1-3 \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/opt/suya-ocr
ENV SURYA_MODEL_CHECKPOINT=datalab-to/surya-ocr-2
ENV SURYA_INFERENCE_BACKEND=vllm
ENV SURYA_INFERENCE_URL=http://127.0.0.1:8000/v1
ENV SURYA_INFERENCE_AUTOSTART=false
ENV SURYA_INFERENCE_KEEP_ALIVE=false
ENV SURYA_INFERENCE_PARALLEL=8
ENV SURYA_INFERENCE_LOGPROBS=false
ENV SURYA_INFERENCE_MAX_RETRIES=1
# Cap concurrent in-flight chat-completion requests to vLLM per coalesced batch.
# Must track VLLM_MAX_NUM_SEQS below: the request batcher otherwise ran only
# SURYA_INFERENCE_PARALLEL(=8) wide, leaving half of vLLM's 16 sequence slots
# idle. Setting this to 16 closed a 33% mean / 32% p95 regression (see
# docs/diagnosis_baseline.md). Do not exceed VLLM_MAX_NUM_SEQS (overcommit only
# queues).
ENV SURYA_INFERENCE_MAX_INFLIGHT=16
ENV SUYA_OCR_MODE=block
ENV SUYA_MAX_BATCH_SIZE=8
ENV SUYA_BATCH_WAIT_MS=25
ENV SUYA_MAX_QUEUE_SIZE=128
ENV SUYA_VLLM_IMAGE_FORMAT=JPEG
ENV SUYA_VLLM_JPEG_QUALITY=92
ENV SURYA_MAX_BLOCKS_PER_PAGE=80
ENV SURYA_MAX_TOKENS_FULL_PAGE=6144
# GPU/dtype defaults target T4 (deployment hardware). Do not change these
# without confirming the deployment target.
ENV VLLM_GPU_TYPE=t4
ENV VLLM_DTYPE=float16
ENV VLLM_GPU_MEMORY_UTILIZATION=0.85
ENV VLLM_MAX_MODEL_LEN=18000
ENV VLLM_MAX_NUM_SEQS=16
ENV VLLM_MAX_BATCHED_TOKENS=4096
# Latency-tuned vLLM serve flags (CUDA graph, prefix caching, chunked prefill)
# are applied in scripts/start_single_container.sh — see that file and
# docs/quantization_benchmark_results.md §5 for the measured rationale.
WORKDIR /opt/suya-ocr
COPY requirements.txt pyproject.toml ./
RUN pip install --no-cache-dir uv==0.8.15 && \
python3 -m uv pip install --system -r requirements.txt
COPY . .
EXPOSE 5002
ENTRYPOINT []
CMD ["/opt/suya-ocr/scripts/start_single_container.sh"]
+182
View File
@@ -0,0 +1,182 @@
# vLLM Backend Plan and Test Results
Date: 2026-06-09
## Plan
1. Keep the legacy OCR endpoint at `/v1/api/ai/suya_ocr/` for baseline comparison.
2. Add `/v1/api/ai/suya_ocr_vllm/`, backed by Surya2 `SuryaInferenceManager(method="vllm")`.
3. Use the Surya vLLM backend settings from the referenced Surya checkout:
- `SURYA_INFERENCE_BACKEND=vllm`
- `SURYA_INFERENCE_KEEP_ALIVE=true`
- T4 default: `VLLM_GPU_TYPE=t4`, `VLLM_DTYPE=float16`, `SURYA_INFERENCE_PARALLEL=16`
- optional tuning through `VLLM_GPUS`, `VLLM_GPU_MEMORY_UTILIZATION`, and `SURYA_INFERENCE_URL`
4. Add a concurrency benchmark that sends the same payload to the legacy and vLLM endpoints and writes latency/throughput JSON.
5. Verify syntax locally, then run the benchmark on a host with the Surya2 package, Docker, and NVIDIA GPU access.
## Implemented Files
- `vllm_tools.py`: vLLM-backed Surya2 predictor setup and OCR response formatting.
- `api.py`: new `/v1/api/ai/suya_ocr_vllm/` endpoint and health endpoint.
- `concurrency_test.py`: repeatable old-vs-new endpoint concurrency benchmark.
- `concurrency_test_results.json`: placeholder result file that the benchmark overwrites with measured results.
- `requirements.txt`: dependency alignment for Surya2/vLLM OpenAI client support.
- `Dockerfile`: T4 vLLM defaults. The referenced backend maps T4 16 GB VRAM to `max_num_seqs=16` and `max_num_batched_tokens=4096`; `float16` is required because T4 does not support `bfloat16`.
## Single-Container Kubernetes Deployment
The deployment now uses `vllm/vllm-openai:v0.20.1` as the base image. The container starts the vLLM OpenAI-compatible server in the background and runs `api.py` in the foreground through `scripts/start_single_container.sh`.
The API no longer depends on nested Docker. It attaches to:
```text
SURYA_INFERENCE_URL=http://127.0.0.1:8000/v1
```
The Surya source needed by the API is vendored into this repo under `surya/`, so the container does not depend on `/path/to/surya` at runtime.
Serving defaults for T4:
```text
SUYA_OCR_MODE=block
SUYA_MAX_BATCH_SIZE=8
SUYA_BATCH_WAIT_MS=25
SURYA_INFERENCE_PARALLEL=8
SURYA_INFERENCE_MAX_RETRIES=1
SURYA_MAX_BLOCKS_PER_PAGE=80
VLLM_DTYPE=float16
VLLM_MAX_NUM_SEQS=16
VLLM_MAX_BATCHED_TOKENS=4096
```
Speed-related changes:
- `/v1/api/ai/suya_ocr_vllm/` uses `vllm_batcher.py` to coalesce concurrent HTTP requests into batch Surya calls.
- `vllm_tools.py` batches layout and recognition across images.
- Default OCR mode is block mode, avoiding long full-page generations and hidden fallback bursts.
- Vendored Surya caps block OCR per page with `SURYA_MAX_BLOCKS_PER_PAGE`.
- Retry amplification is limited with `SURYA_INFERENCE_MAX_RETRIES=1`.
- The endpoint skips drawing annotated images because the API response only returns JSON/text.
- Base64 input is decoded once per request and the same PIL image object is reused for low/high-res OCR paths unless a caller explicitly asks for a copy.
- vLLM image payload encoding defaults to high-quality JPEG (`SUYA_VLLM_IMAGE_FORMAT=JPEG`, `SUYA_VLLM_JPEG_QUALITY=92`) to reduce CPU serialization and local vLLM transfer size versus PNG. Set `SUYA_VLLM_IMAGE_FORMAT=PNG` if lossless transport is required.
## Benchmark Command
```bash
python concurrency_test.py \
--image /path/to/surya/ttt.png \
--requests 20 \
--concurrency 8 \
--output concurrency_test_results.json
```
## Current Test Results
Import verification passed with `PYTHONPATH=/path/to/surya`.
Concurrency benchmark run:
```bash
/opt/conda/bin/python concurrency_test.py \
--image temp_image_c3c56948-282e-453b-8fac-6c482243d1e5.jpg \
--requests 16 \
--concurrency 16 \
--timeout 900 \
--output concurrency_test_results.json
```
Run started at `2026-06-11T14:35:17+0400`. Docker NVIDIA runtime and GPU checks passed. The local Python environment does not include the `vllm` package, so this validation attached the updated API to the already-warm local `vllm/vllm-openai:v0.20.1` server on port `43401` with `SURYA_INFERENCE_URL=http://127.0.0.1:43401/v1`. The API path still used the single-container attach mode (`SURYA_INFERENCE_AUTOSTART=false`) and did not start nested Docker.
| Endpoint | Requests | Concurrency | Success | Failed | Wall seconds | Throughput rps | Mean latency | P50 latency | P95 latency | Max latency |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `/v1/api/ai/suya_ocr/` | 16 | 16 | 16 | 0 | 43.62 | 0.3668 | 40.81 | 41.84 | 43.39 | 43.62 |
| `/v1/api/ai/suya_ocr_vllm/` | 16 | 16 | 16 | 0 | 83.59 | 0.1914 | 59.08 | 60.41 | 83.52 | 83.54 |
Latency values are per request in seconds. Mean latency is the average request elapsed time, not the total benchmark wall time.
## Approach A Outcome (2026-06-12)
Diagnosis + work-reduction tuning (see `docs/diagnosis_baseline.md` and
`docs/tier2_results.md`). Measured on the A100 dev host against the warm
`surya-vllm-43401` server — same host/server as the table above, so comparable.
**Production is T4; the fix below is hardware-independent, the absolute numbers
are not.**
**Root cause:** the request batcher coalesced requests then dispatched them only
`SURYA_INFERENCE_PARALLEL=8` wide, so only 8 of vLLM's 16 sequence slots were
used (poller `peak_running=8`). The GPU sat half-idle, which is why the vLLM
endpoint was *slower* than the flooding legacy path.
**Fix (Tier 1):** scale the worker pool to the in-flight block count, capped by a
new `SURYA_INFERENCE_MAX_INFLIGHT` (default 16, = `VLLM_MAX_NUM_SEQS`). This fills
all 16 slots (`peak_running=16`).
| vLLM endpoint, 16/16 | mean | p50 | p95 | peak_running |
| --- | ---: | ---: | ---: | ---: |
| MI=8 (before) | 50.22 | 49.48 | 72.69 | 8 |
| MI=16 (fix) | 33.75 | 36.11 | 49.15 | 16 |
- Mean 33%, p95 32%; single request 6.16 s → ~5.3 s. CER divergence 0.0009
(lossless; gate ≤ 0.005). The vLLM endpoint now beats legacy on mean and median.
- MI=24 overcommits (worse). Batch-size / batch-wait are within noise.
**Tier 2 (token ceiling, recognition DPI, block cap):** no latency benefit on
this sparse page — decode dominates and the page is light — so all Tier-2 knobs
are left at defaults. They remain relevant for dense / pathological pages.
**Locked change:** `SURYA_INFERENCE_MAX_INFLIGHT=16` added to `Dockerfile` and
`scripts/start_single_container.sh`. Re-confirm absolute latencies on a T4 before
treating them as production SLAs; the regression fix itself applies unchanged.
## FP8 Experiment
I attempted to start the latest vLLM image with multimodal encoder FP8 enabled:
```bash
--dtype bfloat16 \
--mm-encoder-attn-dtype fp8
```
The first attempt on `cuda:0` failed before serving traffic because GPU 0 did not have enough free memory for the default `--gpu-memory-utilization 0.85` target.
The second attempt on `cuda:1` booted farther, but vLLM aborted during model initialization with:
```text
ValueError: mm_encoder_attn_dtype='fp8' requires the FlashInfer cuDNN backend with cuDNN >= 9.17.1 on a GPU with native FP8 support.
```
That means:
- no FP8 latency number was produced
- the current A100 hardware is not a valid target for this exact FP8 path
- the failure is architectural, not a tuning issue
To measure a real FP8 speed change, the next engineer should use a GPU with native FP8 support and a vLLM build that satisfies the FlashInfer cuDNN requirement for multimodal encoder FP8.
## NVIDIA Docker Fix
Docker currently has no registered `nvidia` runtime. The expected daemon config is:
```json
{
"data-root": "/data/docker_cache",
"runtimes": {
"nvidia": {
"args": [],
"path": "nvidia-container-runtime"
}
}
}
```
This user cannot write `/etc/docker/daemon.json` or restart Docker because `sudo` requires a password. The host does support Docker GPU passthrough through modern `--gpus all`, so the API now patches Surya's vLLM spawn path to use `scripts/docker_nvidia_runtime_compat.sh`. That wrapper strips the legacy `--runtime nvidia` pair and preserves `--gpus device=...`.
An admin can still register the named runtime permanently with:
```bash
sudo nvidia-ctk runtime configure --runtime=docker --config=/etc/docker/daemon.json
sudo systemctl restart docker
docker run --rm --runtime nvidia --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
```
The repo includes `scripts/configure_nvidia_docker.sh` with those commands.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Fu Dai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+308
View File
@@ -0,0 +1,308 @@
# 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>
+60
View File
@@ -0,0 +1,60 @@
# Single Call Timing Result
Test image: `/path/to/suya-ocr-api/temp_image_c3c56948-282e-453b-8fac-6c482243d1e5.jpg`
Endpoint: `/v1/api/ai/suya_ocr_vllm/`
Run time: 2026-06-10 16:18 Asia/Dubai
Environment:
- `SURYA_INFERENCE_URL=http://127.0.0.1:43401/v1`
- `SURYA_INFERENCE_PARALLEL=8`
- `SUYA_OCR_MODE=block`
- `SUYA_TIMING_ENABLED=true`
Summary:
| Stage | Time ms | Notes |
|---|---:|---|
| Client observed request | 7812.80 | Single HTTP call to API |
| API request total | 7800.86 | FastAPI middleware duration |
| Base64/PIL decode | 33.38 | 683,466 byte JPEG, 1410x1033 |
| API wait for vLLM batcher | 7755.73 | Includes queue, layout, recognition |
| OCR batch total | 7730.19 | Surya vLLM OCR path |
| Layout predictor total | 2114.89 | One page layout call |
| Layout vLLM generate | 2053.21 | Includes backend attach/start check |
| vLLM backend attach/start check | 97.84 | Health/model lookup on first request |
| Layout chat completion | 1902.34 | One vLLM `/chat/completions` call |
| Layout parse/output boxes | 61.48 | JSON parse, bbox conversion, blank filtering |
| Recognition block total | 5615.26 | Block OCR after layout |
| Recognition build crop batch | 1.09 | Crop 17 OCR blocks, skip 1 block |
| Recognition vLLM generate | 5613.12 | 17 block calls, parallelism 8 |
| Recognition output assembly | 0.28 | Clean HTML and build page result |
| Response assembly | 0.01 | No bbox image drawing |
Block OCR details:
| Metric | Value |
|---|---:|
| Layout blocks returned in response | 18 |
| OCR block requests sent to vLLM | 17 |
| Skipped blocks | 1 |
| Sum of requested block max tokens | 6210 |
| Generated block tokens | 4454 |
| Sum of block chat completion times | 33259.06 ms |
| Mean block chat completion time | 1956.42 ms |
| Max block chat completion time | 4305.79 ms |
| Wall time for all block completions | 5613.02 ms |
Conclusion:
The bottleneck is vLLM generation, not Python parsing or image decode. The single request spends about 72% of wall time in block OCR generation (`5613 ms / 7801 ms`) and about 24% in layout generation (`1902 ms / 7801 ms`). CPU-side crop/build/parse/assembly is negligible for this image.
Most useful next optimization directions:
1. Reduce number of OCR block requests per page.
2. Reduce block max token budget.
3. Merge or batch adjacent text blocks before recognition.
4. Avoid running layout when caller can provide/skip detection.
5. Tune vLLM scheduling/parallelism after reducing block count.
+6
View File
@@ -0,0 +1,6 @@
import uvicorn
from surya.endpoint.app import app # noqa: F401 (re-exported for `uvicorn api:app`)
if __name__ == "__main__":
uvicorn.run("api:app", host="0.0.0.0", port=5002, workers=1)
+64
View File
@@ -0,0 +1,64 @@
# import base64
# import requests
# import os
# # Configuration
# API_URL = "http://localhost:5002/v1/api/ai/suya_ocr"
# # Helper to encode image
# def encode_image_to_base64(path):
# with open(path, "rb") as image_file:
# return base64.b64encode(image_file.read()).decode("utf-8")
# def test_ocr_endpoint_requests():
# image_path = "/path/to/surya/ttt.png"
# assert os.path.exists(image_path), f"Image file not found: {image_path}"
# encoded_image = encode_image_to_base64(image_path)
# payload = {
# "file": encoded_image,
# "type": "jpg",
# "skip_text_detection": False,
# "skip_table_detection": False,
# "recognize_math": False,
# "ocr_with_boxes": True
# }
# # Send request using requests
# response = requests.post(API_URL, json=payload)
# assert response.status_code == 200
# response_json = response.json()
# assert response_json["code"] == 200
# assert "ocr_text_json" in response_json["data"]
# assert "text_lines" in response_json["data"]
# print("OCR Response Text:")
# print(response_json["data"]["text_lines"])
# import time
# tik = time.time()
# test_ocr_endpoint_requests()
# tok = time.time()
# print("time consumption: ", tok - tik)
import requests
# API 地址
url = "http://127.0.0.1:5002/image2text"
# 测试用图像路径
image_path = "/path/to/surya/ttt.png" # 替换为你自己的图像路径
# 发起 POST 请求
with open(image_path, "rb") as image_file:
files = {"file": ("ttt.png", image_file, "image/png")}
response = requests.post(url, files=files)
# 打印响应
print("Status Code:", response.status_code)
print("Response JSON:", response.json())
+15
View File
@@ -0,0 +1,15 @@
<img alt="A colorful illustration of a structural steel brick facade with a complex graphic design of a steel brick facade an
تسس الأطراف سنة ١٨٧٥ : سانيم ويشمارة نقلا
رئيس مجلس الادارة   رئيس الشعب وير   عبد الله عبد البخاري   إبراهيم تلفيح
<img alt="A small rectangular label with a black border. It features the text 'Al-Akram' in a large, bold serif font at the bottom center. Below the text, the label contains the dates '13 OCTOBER 1981' and '1 APRI
- السنة __1٠٠٧__ المدد ٣٩٦٣٨ -
المنطقات المنتصرة
عن اليوم المسيحي ع بعض وعرف منذ باحد ابن محمد المرق حول من الطائف السلامة
اعدادة تأليف الوزارة بنفس تشكيلها الحد للحافظون باقيون لحين صدور قرار
يعلن شسعب مصر اليوم أسلام الصالم لجمسع ارانته الخسرة في مواصلة مسير الوطس منصب أنور السادات حيث يتفق ملايين الواطئين على صناعيق الاستفتاء ليؤكد شسع مصر وأجماعه على تأييد الانس والمبادئ والسياسة الحكيمة التي وضعها الرئيس مغطة في شخص ثانيه ومرشح مجلس الشسع محمد حسنى مبارك .
الاجتهان المسيّدات مص : الفَتْيَسارُ مِمْسارُك وتَمِمسارُ شَيْرِ مَمْيَس هُن عَلَيْهِ السَّمالَات عَلَت السِّيرَة جِبلِن السَّادَت وعَى تنتَلِ النَّواء مِن مسيدات. مص الثَّنِي الَّذِي مَن مَلَونا : ان كل من يجب رسم ويجب السلادات يجب ان يؤدى الجابه ويعرض على النَّواء بصرى في الامتداد لأخير السيد حسن ملوك دليسا الجوهرية كانه شريك بصرىّه من البل بناء مص والسلام : واغشته أن الشاركة في الامتداد والجابه فوهى وعلى الجوهر تأديته سواء كل رجل. أو لمرانة . والجواب على الجوهر على الجوهر المتوازية ساداته صلة .
اسم الزعيم المسادات
على مسندان التطوير وهابعة الدوليسة وجزيرة الكورسسان والق الجلس الخصوم المعارضة القادرة السن على الطاق اسم التنمية التي المتحالف على موجات التعوين لا واضحة مثلا في بالذيان تعتبر / لما وقت من أجل بحث حتى استحديث غير سنويا الجائزة والفم . كذلك عبر الإيتاء التوجيه المتبع بصفحة الإسبانية السن الذي انتم التنمية المستحالة على بوليزية الفرسان الحالة على فتاة الموسوعين والتالية تباين في بالحكم الجيولوجي الحر والحقوق والملكين المستحلة التي الحكم الحكما المستحلة المتبعة .
حتى الطريق الرئيسي للانسياتولية -- كما قرر مجلس جامعة الالزينية الملائي التم الزعيم الراحل طبي جنيفةللالزينية وهو وفي ملابرح ترى الإيطالي الفسي والتنيدي للمحافظة الملائي اسم الزعيم الراحل طبي الواجه الجديدة الفريتوليز واجه سيود من الثوريا ولاني فستطيع الان ويتم استورداع ٢ الاند
شعب مصر يقول كلمته اليوم ص (5)
@isaadawi
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

+27
View File
@@ -0,0 +1,27 @@
大清光緒十六年
本館新聞除禮拜外日出一張 諸君賜閱本報者無論本埠外埠均可函致本館取
本埠外埠均可向我希龍照
閱如外埠閱報照加寄費可
也除日報照常刊送外復不
工務局告示
本局為便利商民起見,特將本局之工務局,改名為工務局,並將各項工程,由本局負責。凡有需要之工程,請向本局申請。此布。
升印書籍 單件前有英國名
體活字及各式花邊等延有
西人排印工藝精所有中
西文字均能刊印並有石版印字大小機器均用精工刊
第二章
吹笛
巴 巴 巴 巴
百四十
第十四卷
九字
印工緻純倫前延有文士書
寫各體文字現由上海購到各種書籍出售其書目另行刊布所有各書均詳列如左
邦他所有名者置焉却宜與本館所發來札原擇其有關世道人心者附登否亦須無
礙於人而後始錄其有謬罵成文以及支離費解者概不登報嗣後 諸君來札請祈
登報嗣後,諸翁來札尚祈自諒,更須寫實,倘里而後敢登,但來稿登與不登,均不能
庚寅正月初八日
第五次每週發行日元一張 每張售價五角 每張可換一張日本銀行票
叻報
本館開設在舊巴虱口門牌第十一號
本館啟事 ○啟者本埠自開設至今將益十餘所列新聞時事彙錄 諸君移步 惠顧焉 誠感良深 本館更欲大振報端 業於廈門 汕頭及南洋各埠 經營 採訪 新聞 如有要事 須為 刊登 以便 諸君 藉 閱 凡 近聞有 不肖之徒 在 外 妄 為 流言 有 礙 本館 本館 奉 札 外 處 之 信 稱 本館 寄 諸 君 以 查 報 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報 不肖者 有 強 明 指 出 廣 告 諸 君 之 所 自 報
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

+72
View File
@@ -0,0 +1,72 @@
Н Н А Т І Є
VOL. IV. No. 181.
DUBLIN, SATURDAY, MARCH 28, 1846.
PRICE 6 D .
DAVIS TESTIMONIAL.
THE NATIVES OF IRELAND.
THE Committee appointed for the purpose of receiving and analyzing the Telegraphic Statistics of LATE THOMAS DAVIS, ESQ., has received to leave open to public competition among American scientists and the public and the property of Specimens of a Status, in Sketch-model, together with a basin of Clay, lists size, to be sent for inspection on or before the 30th of June next. Any further information required may be obtained by applying to the Secretaries,
LOWER DOMINICK-STREET, DUBLIN.
THOMAS DAVIS
HAS the pleasure of announcing that the PORTRAIT OF THE LATE THOMAS DAVIS, Esq.,
LATE THOMAS DAVIS, Esq., P. W. BURTON, Esq., R.H.A., In the case of Sir John W.
Is now ready for delivery
in India Paper .. .. . 2s. 6d.
.. .. . . . . . . . 1s. 0d.
ished by JAMES DUFFY. Sole Agent
in Ringrose, 11, Sherrard-street, Golden
THE CABINET EDITION.
LODGE'S PORTRAITS. —The First Volume, containing Thirty Portraits, in new earth, handsomely bound in crimson cloth, price 6 c . 6d. See also Lodge's Portraits, price 6 c . 64c. sec. or in Forty-eight Numbers, price 1 s . each. Dubin: J. M. GILLASHAN, 21, D'Olier-street; William Smith, 113, Fleet-street, London; Fraser and Co., Edinburgh.
JUST PUBLISHED,
THE FORTUNES OF TOROLOGH O'BRIEN:
A TALE OF THE WORLD OF KING DEBORAH AND HER LASH AND D'Olier-street;
London: William S. Orr and Co., Anne-corner, Pater-
monter-row; Edinburgh: Fraser and Co. Sold by all Book-
sellers.
THE DUBLIN UNIVERSITY MAGAZINE
NOTICE. JAMES DUFFY
BEGS to announce that he has removed an IMMENSE STOCK OF BOOKS FROM 23, ANGELESA-STREET, To the Extensive Premises Above Occupied by MR. JOHN HARVIE, No. 10, WELLINGTON-QUAY.
Now Ready, Part III., Price 1s., VALENTINE MCCLUTCHY, THE IRISH AGENT WITH ILLUSTRATIONS BY FRIZ. Dublin: Published by JAMES DUPPY; London Simpkin, Marshall, and Co.
KNIGHT'S WEEKLY VOLUME, Printed in United States and Europe
Price 1s. Just Published,
VEGETABLE SUBSTANCES USED FOR
THE FOOD OF MAN. Vol. I.
London: Charles Knight and Co., 22, Ludgate-street.
Just Published, 8rd., No. I, price 6d., PAYNE'S ILLUSTRATED LONDON
A t the present moment no Illustrated Work exists, giving an adequate idea of the vast improvements of modern London. These, together with the great accommodation afforded for visiting them, imperatively mand such an undertaking as is now brought before it public.
The Work will be published in Fortnightly Parts, at St pence, containing Five highly-finished steel Engraving and 16 to 24 pages of historical and descriptive letter-pre and in Monthly Parts, at One Shiling, to be completed about 20 Parts.
THE NATIONAL LIBRARY FOR IRELAND,
THE NATIONAL LIBRARY FOR IRELAND. BY DISTINGUISHED LITERARY ERISHMEN, Is now Publishing, in Fourpenny Monthly Volumes, complete.
NATIONAL ENGRAVINGS, beautifully coloured, price One Penny, arc simultaneously issued with this Series.
issued with this Series. Already Published, THE LIFE, TIMES, AND SPEECHES OF DANIEL O'CONNELL ESQ. M.P.
DANIEL O'CONNELL, ESQ., M.P. (Fifth Edition.)
NOW READY, PRICE FOURPENG THE LIFE AND WRITINGS OF JOHN R. CURRAN
THE NEW WOOLLEN WAREHOUSE 17, DAME-STREET.
17, DAME-STREET, IS NOW OPEN, EDWARD K. HOLROYD AND CO., PROPRIETORS.
THE support of the Public is sought to proprietors of this Establishment with respect to the fact that the real and legitimate stock is secured by every one of the states provided for them the First and Leading Woollen Trade in D position they are resolved to attain, by SELLING WOOLLEN GOODS AT PRICES WHICH WILL BE PROVIDED BY THE SUPPORT.
The extent of Stock will be commensurate with portence of the city of Dublin, and will invariably plete in its selection of Noveltics for the Season. The System now in use by many honest Trade marking their Goods, the Lowest Price, in Plain F will be adopted by E. K. H. and Co.
At the present day, such and so many are the use of advertisers, that the Public wisely view with such professions of exclusive advantages.
The Stock will comprise every class of WO GOODS, including the finest West of England Coded.
E. K. H. and Co. would remark that their not to establish a Trade in low Woollens, but and First-class Goods ; from buyers of such goods.
"Tailors will find a really EXTENSIVE AMENT OF TRIMMINGS.
TERMS—CASH ONLY. E. K. HOLROYD AND COMPANY, INC.
E. K. HOLBOYD AND COMPANY, 17 STREET, DUBLIN.
MECHANICS INSTITUTE. TO BOOKSELLERS. T HE Board of Directors will receive for furnishing 300. worth of Books to a Catalogue of the Books required (with the &c.) can be had by applying to the Librarian, M
THE PHOENIX PARK, DUBLIN.
THE IRISH FARMERS' JOURNAL Wednesday last contains a beautifully painted of the Phoenix Park, the only Public Park in concise account is also given of the improvemented by Mr. Decimus Burton to the Common Woods and Forests.
LOYAL NATIONAL REPEAL ASSOCIATION
THE LOYAL NATIONAL REPEAL ACTION OF IRELAND will meet on MAY DAY, the 30th of MARCH, at ONE o'Clock, at the CONCILIATION HALL,
CORN-EXCHANGE ROOMS. Ladies submitted fees will enter by the Third Executive.
Ladies admitted free will enter by the Third Front I and proceed to the portion of the Side and End Gallery which have set a spart for them. For Members having been admitted to Found in the property of the Side are preserved under the Second Side Gallery. The access for them to the Seats under the Left-hand Gallery, is by the Door of the Hall nearest to the Exchange; and the access to the Seats under the hand Gallery, is by the Door in White's-lane.
By Order, T. M. RAY, Secret
BUTLER and ARCHER, 58, North STREET, have received their Annual Extraordinary of AGRICULTURAL SEEDS, consisting of 56 SEED OATS of the different vertexes, YETCHES, RED and WHITE CLOVERS, TRI COW-GRASS, SCOTCH and ITALIAN-RYER SEEDS.
B. and A. have taken great care in the selecting Stock, and the quality of the various Articles can it is such as they feel assured will give satisfaction they can with great confidence recommend.
NEW FARM SEEDS.
JOHN HODRICHT,
JOHN THOMAS-STEELEY,
RESPECTFULLY makes known the a
following New-York imported Seeds, which he is extensively supplied, having been pu
competent judges in the best markets. He is to
dispose of them at very Redwood New England
Cover and Tree Shore, Hosteton, Potato, and Early August
New York.
Scotch, Hopeau, Patao, and Bary Angus of English and Irish Spring Vetches, True Art Grass. All warranted news and genuine. The public is also associated with and Refined Sagars, Tess, Wince, and White her offers to the Public on liberal terms, and for they will be found to give general satisfaction. 28, THOMAS-STREET.
Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+5
View File
@@ -0,0 +1,5 @@
Le Numéro : 75 Centimes.
Mardi 10 Août 1937. - N° 963
LE MIROIR DES SPORTS
Le plus fort tirage des hebdomadaires sportifs
LOUISETE FLEURET, 18 ANS , championne de France de natation de demi-fond, à battu dimanche, au stade nautique des Tournelles, les records de France des 800 mètres, 1.000 mètres et 1.500 mètres nage libre, respectivement en 12° 7" 2/10, 15° 16" 4/10 et 23° 13" 8/10. Voici, sortant de l'eau après son triple exploit, la jeune championne qui ne paraît nullement déprimée par son effort et qui conserve son frais sourire habituel.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

+48
View File
@@ -0,0 +1,48 @@
Виттова Плясна
ОДНОДНЕВНАЯ ГАЗЕТА. ПАЛИ—ТИКО—ФИ—НАНСОВАЯ.
Цѣна 5 копѣекъ.
Условія подписки см. на послѣдней страницѣ.
СКОРО ПОСТУПЯТЪ ВЪ ПРОДАЖУ НОВЫЯ ПАПИРОСЫ
25 штукъ 25 копѣекъ.
МОШЕННИКЪ 10 шт. 6 кош.
Об Почтением:
Альянсъ-Израелитъ.
Чудо!
„ТАРЛЭ“
Чудо!
Новость! Новость!
СПЕЦІАЛЬНО ДЛЯ САМООБОРОНЫ.
Револьверы повой спетемы «Таря». Главное преимущество.
ЗА НЕНАДОВНОСТЬУ ПРОДАЕТСЯ ЗДАНЕ. Указоренники заброшен. 1-9, сорость Д. С. С. Борова.
ЕБ ЗАЛЬ СОЕДИНЕНЫХЪ ПРИСУТСТВИ при благосклонномъ участии любимъяшихъ русской публикой социалъ демократовъ и прочихъ особъ.
СОСТОИТСЯ ВЕЧЕРЪ, по программѣ всѣхъ революціонныхъ партій. Подъѣздъ со своей платформы.
СОДВРЖАНІЕ.
Объ редкали.—Текатарицы.—Фальтелян.—Равоварская: Правительства.—Стактонорали.—Замянки.—Мустепильная возрось.—Раболай о режиме.—О вроеперагах.—Ос катури.—Хроника.—Учебная одъван.—Театры и журналы.—Въезд кат. врояждан.—Обиходная редентура.—Объявления.
С.Петербург, без числа. Вступили из период дель нашего издания священные узбурги наших и читаемых, что издание наше возник соответствует избранному для него активно. Пометили, что витвовали шляхов происходить темери у наст. Итакъ, товарищи внереды!
Телеграммы
Театрафиаго Агентетог.
МОССА - 20 мафа. Назвистие молод дочи, орбитант на Петрура, был мир казаков, и углавл их молочное свото. Как при последних последних денях после последних днев были их получены прочие правители ста. Вдохновл влад и составил требомай просто последний день последний представитель образа, чтобы молочное пате жил в падании строить и влясти во приблизку из Баратов. Железное бластко привело к последним дням последних днев. МОССА 21 мафа. Вернула сообщения казакам не точе. Молод дочи на Петрура приглашил казаков правительство прибрестить почти прогрессивно вырожить. Казак отманяли оснавные и то, что ени не желают распать все наставить. Железное обратило себе
ФЕЛЬЕТОНЪ.
КОВЫЙ РЕВИЗОРЪ.
Под Я присвоены васть, посподь, чтобы сообщить вам присворетное нам бить и на присвоенных местах.
Как Рисковский.
Тор, да я нас как слабуют, учрестить но собрать себя на восток.
Ано, Одь Ворчат на!
Лич, Луч, Посподь После мой!
Одь Я как усиль будет правдучественно, после того, что я присвоен местам и согласно новому зат интервализивая — при шля, важно по серебряной после и ушля — при шля, после того, что я присвоен местам и согласно новому просто, успехам, затяги увачуюю спосочу. "Русское Спосоч", посочу посподу.
Ано, Одь Ну, у „постояна“ спосочу нати.
Тор, Нати, Тати, воть пом прямо пом прямо пом прямо пом прямо пом прямо пом (тамамо) пом прямо пом спроять какого-то же желан
вит до него из замалых, и так, усмотряясь на простые ветви нестояния.
По своей стороне, в том случае Давида и нескольких Дина стоят во время своего братства и противления с ними и нестоянием их отряда.
В том случае Давид и нестояние их отряда не получают своего общения и забили прох. В том случае Давид и нестояние их отряда не получают своего общения.
В ООСА 21 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох. В том случае Давид и нестояние их отряда не получают своего общения и забили прох.
В ООСА 22 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох.
В ООСА 23 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох.
В ООСА 24 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох.
В ООСА 25 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох.
В ООСА 26 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох.
В ООСА 27 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох.
В ООСА 28 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох.
В ООСА 29 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох.
В ООСА 30 января. В листоке Давида и нестояния их отряда на простые ветви нестояния из Дав, так как они в листоке выбили нестояние их отряда и нестояние их отряда не получают своего общения и забили прох.
В ООСА 31
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

+267
View File
@@ -0,0 +1,267 @@
"""Concurrency benchmark + old-vs-new comparison for the OCR service.
Two subcommands:
sweep Drive one running service across a list of concurrency levels and
record latency/throughput/success per level. Reuses the request
harness in concurrency_test.py.
compare Load two sweep result files (old + new) and emit a markdown table,
a CSV, and comparison plots (latency & throughput vs concurrency,
plus a per-level speedup bar).
Typical flow (see scripts/run_concurrency_comparison.sh):
# against the new vLLM service (its optimized endpoint)
python concurrency_compare.py sweep --label new \
--url http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/ \
--image temp_image_c3c56948-282e-453b-8fac-6c482243d1e5.jpg \
--levels 1,2,4,8,16 --out results/compare/new.json
# ... swap containers, then against the old service (its OCR endpoint)
python concurrency_compare.py sweep --label old \
--url http://127.0.0.1:5002/v1/api/ai/suya_ocr/ \
--image temp_image_c3c56948-282e-453b-8fac-6c482243d1e5.jpg \
--levels 1,2,4,8,16 --out results/compare/old.json
python concurrency_compare.py compare \
--old results/compare/old.json --new results/compare/new.json \
--out-dir results/compare
"""
from __future__ import annotations
import argparse
import csv
import json
import time
from pathlib import Path
from typing import Any, Dict, List
from concurrency_test import _encode_image, run_endpoint
def _parse_levels(value: str) -> List[int]:
return [int(x.strip()) for x in value.split(",") if x.strip()]
# ---------------------------------------------------------------- sweep -----
def run_sweep(args: argparse.Namespace) -> None:
image_path = Path(args.image)
image_type = image_path.suffix.lstrip(".").lower() or "png"
payload = {
"file": _encode_image(image_path),
"type": "jpg" if image_type == "jpeg" else image_type,
"skip_text_detection": False,
"skip_table_detection": False,
"recognize_math": False,
"ocr_with_boxes": True,
}
levels = _parse_levels(args.levels)
rows: List[Dict[str, Any]] = []
for concurrency in levels:
requests_count = max(concurrency, concurrency * args.reqs_per_level)
print(f"[{args.label}] concurrency={concurrency} requests={requests_count}", flush=True)
row = run_endpoint(
args.label, args.url, payload,
requests_count=requests_count, concurrency=concurrency, timeout=args.timeout,
)
rows.append(row)
lat = row["latency_seconds"]
print(f" -> success={row['success']}/{row['requests']} "
f"rps={row['throughput_rps']:.3f} mean={lat['mean']:.2f}s p95={lat['p95']:.2f}s",
flush=True)
out = {
"label": args.label,
"url": args.url,
"image": str(image_path),
"levels": levels,
"reqs_per_level": args.reqs_per_level,
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"results": rows,
}
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
print(f"wrote {out_path}")
# -------------------------------------------------------------- compare -----
def _index_by_concurrency(sweep: Dict[str, Any]) -> Dict[int, Dict[str, Any]]:
return {row["concurrency"]: row for row in sweep["results"]}
def _comparison_rows(old: Dict[str, Any], new: Dict[str, Any]) -> List[Dict[str, Any]]:
old_by_c = _index_by_concurrency(old)
new_by_c = _index_by_concurrency(new)
concurrencies = sorted(set(old_by_c) | set(new_by_c))
rows = []
for c in concurrencies:
o = old_by_c.get(c)
n = new_by_c.get(c)
row: Dict[str, Any] = {"concurrency": c}
row["old_mean_s"] = round(o["latency_seconds"]["mean"], 2) if o else None
row["new_mean_s"] = round(n["latency_seconds"]["mean"], 2) if n else None
row["old_p95_s"] = round(o["latency_seconds"]["p95"], 2) if o else None
row["new_p95_s"] = round(n["latency_seconds"]["p95"], 2) if n else None
row["old_rps"] = round(o["throughput_rps"], 3) if o else None
row["new_rps"] = round(n["throughput_rps"], 3) if n else None
row["old_fail"] = o["failed"] if o else None
row["new_fail"] = n["failed"] if n else None
if o and n and o["latency_seconds"]["mean"] and n["latency_seconds"]["mean"]:
row["latency_speedup"] = round(o["latency_seconds"]["mean"] / n["latency_seconds"]["mean"], 2)
else:
row["latency_speedup"] = None
if o and n and o["throughput_rps"]:
row["throughput_gain"] = round(n["throughput_rps"] / o["throughput_rps"], 2)
else:
row["throughput_gain"] = None
rows.append(row)
return rows
def _write_table(rows: List[Dict[str, Any]], old_label: str, new_label: str, path: Path) -> str:
cols = [
("concurrency", "conc"),
("old_mean_s", f"{old_label} mean(s)"),
("new_mean_s", f"{new_label} mean(s)"),
("latency_speedup", "latency ×"),
("old_p95_s", f"{old_label} p95(s)"),
("new_p95_s", f"{new_label} p95(s)"),
("old_rps", f"{old_label} rps"),
("new_rps", f"{new_label} rps"),
("throughput_gain", "rps ×"),
("old_fail", f"{old_label} fail"),
("new_fail", f"{new_label} fail"),
]
header = "| " + " | ".join(label for _, label in cols) + " |"
sep = "| " + " | ".join("---" for _ in cols) + " |"
lines = [header, sep]
for r in rows:
cells = []
for key, _ in cols:
v = r.get(key)
cells.append("" if v is None else str(v))
lines.append("| " + " | ".join(cells) + " |")
md = "\n".join(lines) + "\n"
path.write_text(md, encoding="utf-8")
return md
def _write_csv(rows: List[Dict[str, Any]], path: Path) -> None:
if not rows:
return
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
def _write_plots(rows, old_label, new_label, out_dir: Path) -> List[Path]:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
x = [r["concurrency"] for r in rows]
paths = []
# 1. latency (mean + p95) vs concurrency
fig, ax = plt.subplots(figsize=(9, 6))
ax.plot(x, [r["old_mean_s"] for r in rows], marker="o", color="#c0392b", label=f"{old_label} mean")
ax.plot(x, [r["old_p95_s"] for r in rows], marker="^", color="#c0392b", linestyle="--", label=f"{old_label} p95")
ax.plot(x, [r["new_mean_s"] for r in rows], marker="o", color="#27ae60", label=f"{new_label} mean")
ax.plot(x, [r["new_p95_s"] for r in rows], marker="^", color="#27ae60", linestyle="--", label=f"{new_label} p95")
ax.set_xlabel("Concurrency (simultaneous requests)")
ax.set_ylabel("Latency per request (s)")
ax.set_title("OCR latency vs concurrency — old vs new")
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
p = out_dir / "latency_vs_concurrency.png"
fig.savefig(p, dpi=160)
plt.close(fig)
paths.append(p)
# 2. throughput vs concurrency
fig, ax = plt.subplots(figsize=(9, 6))
ax.plot(x, [r["old_rps"] for r in rows], marker="o", color="#c0392b", label=old_label)
ax.plot(x, [r["new_rps"] for r in rows], marker="o", color="#27ae60", label=new_label)
ax.set_xlabel("Concurrency (simultaneous requests)")
ax.set_ylabel("Throughput (requests/s)")
ax.set_title("OCR throughput vs concurrency — old vs new")
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
p = out_dir / "throughput_vs_concurrency.png"
fig.savefig(p, dpi=160)
plt.close(fig)
paths.append(p)
# 3. per-level latency speedup bar
fig, ax = plt.subplots(figsize=(9, 6))
speedups = [r["latency_speedup"] or 0 for r in rows]
ax.bar([str(c) for c in x], speedups, color="#2980b9")
ax.axhline(1.0, color="gray", linestyle="--", linewidth=1)
for i, v in enumerate(speedups):
ax.text(i, v, f"{v:.2f}×", ha="center", va="bottom")
ax.set_xlabel("Concurrency")
ax.set_ylabel(f"Latency speedup ({old_label} mean / {new_label} mean)")
ax.set_title("Per-level latency speedup (>1 = new is faster)")
ax.grid(True, axis="y", alpha=0.3)
fig.tight_layout()
p = out_dir / "latency_speedup.png"
fig.savefig(p, dpi=160)
plt.close(fig)
paths.append(p)
return paths
def run_compare(args: argparse.Namespace) -> None:
old = json.loads(Path(args.old).read_text(encoding="utf-8"))
new = json.loads(Path(args.new).read_text(encoding="utf-8"))
old_label = old.get("label", "old")
new_label = new.get("label", "new")
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
rows = _comparison_rows(old, new)
md = _write_table(rows, old_label, new_label, out_dir / "comparison_table.md")
_write_csv(rows, out_dir / "comparison_table.csv")
plots = _write_plots(rows, old_label, new_label, out_dir)
print(md)
print("plots:")
for p in plots:
print(f" {p}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("sweep", help="benchmark one running service across concurrency levels")
sp.add_argument("--label", required=True, help="short name for this service, e.g. old / new")
sp.add_argument("--url", required=True, help="full endpoint URL to POST to")
sp.add_argument("--image", required=True, help="path to a png/jpg test image")
sp.add_argument("--levels", default="1,2,4,8,16", help="comma-separated concurrency levels")
sp.add_argument("--reqs-per-level", type=int, default=2, help="requests = level * this (>=level)")
sp.add_argument("--timeout", type=float, default=900)
sp.add_argument("--out", required=True, help="output JSON path")
sp.set_defaults(func=run_sweep)
cp = sub.add_parser("compare", help="compare two sweep result files")
cp.add_argument("--old", required=True, help="old-service sweep JSON")
cp.add_argument("--new", required=True, help="new-service sweep JSON")
cp.add_argument("--out-dir", default="results/compare")
cp.set_defaults(func=run_compare)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
import argparse
import csv
import json
import time
from pathlib import Path
from concurrency_test import DEFAULT_NEW_ENDPOINT, _encode_image, run_endpoint
def parse_levels(value: str) -> list[int]:
return [int(item.strip()) for item in value.split(",") if item.strip()]
def write_csv(path: Path, rows: list[dict]) -> None:
columns = [
"concurrency",
"requests",
"success",
"failed",
"wall_seconds",
"throughput_rps",
"latency_min",
"latency_mean",
"latency_p50",
"latency_p95",
"latency_max",
]
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=columns)
writer.writeheader()
for row in rows:
lat = row["latency_seconds"]
writer.writerow(
{
"concurrency": row["concurrency"],
"requests": row["requests"],
"success": row["success"],
"failed": row["failed"],
"wall_seconds": row["wall_seconds"],
"throughput_rps": row["throughput_rps"],
"latency_min": lat["min"],
"latency_mean": lat["mean"],
"latency_p50": lat["p50"],
"latency_p95": lat["p95"],
"latency_max": lat["max"],
}
)
def write_plot(path: Path, rows: list[dict]) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
x = [row["concurrency"] for row in rows]
mean = [row["latency_seconds"]["mean"] for row in rows]
p50 = [row["latency_seconds"]["p50"] for row in rows]
p95 = [row["latency_seconds"]["p95"] for row in rows]
plt.figure(figsize=(10, 6))
plt.plot(x, mean, marker="o", label="mean")
plt.plot(x, p50, marker="o", label="p50")
plt.plot(x, p95, marker="o", label="p95")
plt.xlabel("Concurrency")
plt.ylabel("Latency per request (seconds)")
plt.title("vLLM OCR latency vs concurrency")
plt.grid(True, alpha=0.3)
plt.legend()
plt.tight_layout()
plt.savefig(path, dpi=160)
def main() -> None:
parser = argparse.ArgumentParser(description="Sweep vLLM OCR endpoint concurrency levels.")
parser.add_argument("--image", required=True)
parser.add_argument("--url", default=DEFAULT_NEW_ENDPOINT)
parser.add_argument("--levels", default="1,2,4,6,8,10,12,20,30,40,50")
parser.add_argument("--timeout", type=float, default=900)
parser.add_argument("--output-json", default="concurrency_sweep_results.json")
parser.add_argument("--output-csv", default="concurrency_sweep_results.csv")
parser.add_argument("--output-plot", default="concurrency_sweep_latency.png")
args = parser.parse_args()
image_path = Path(args.image)
image_type = image_path.suffix.lstrip(".").lower() or "png"
payload = {
"file": _encode_image(image_path),
"type": "jpg" if image_type == "jpeg" else image_type,
"skip_text_detection": False,
"skip_table_detection": False,
"recognize_math": False,
"ocr_with_boxes": True,
}
levels = parse_levels(args.levels)
rows = []
for concurrency in levels:
print(f"running concurrency={concurrency}", flush=True)
rows.append(
run_endpoint(
"vllm",
args.url,
payload,
requests_count=concurrency,
concurrency=concurrency,
timeout=args.timeout,
)
)
result = {
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"image": str(image_path),
"url": args.url,
"levels": levels,
"results": rows,
}
Path(args.output_json).write_text(json.dumps(result, indent=2), encoding="utf-8")
write_csv(Path(args.output_csv), rows)
write_plot(Path(args.output_plot), rows)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+12
View File
@@ -0,0 +1,12 @@
concurrency,requests,success,failed,wall_seconds,throughput_rps,latency_min,latency_mean,latency_p50,latency_p95,latency_max
1,1,1,0,7.6577067924663424,0.13058739738949013,7.656610286794603,7.656610286794603,7.656610286794603,7.656610286794603,7.656610286794603
2,2,2,0,11.936453193426132,0.16755395992349534,11.934747929684818,11.935045637656003,11.934747929684818,11.935343345627189,11.935343345627189
4,4,4,0,20.956660461612046,0.19087010582278188,20.942796256393194,20.951773355016485,20.954541454091668,20.95546007528901,20.95546007528901
6,6,6,0,29.489064288325608,0.20346525550406608,29.45817438978702,29.47708616297071,29.48345213010907,29.487088727764785,29.487088727764785
8,8,8,0,41.46563811413944,0.192930830534405,11.84945331979543,34.050638656364754,41.457184289582074,41.46130123361945,41.46130123361945
10,10,10,0,50.19571524951607,0.19922019141058875,12.104127056896687,42.553733562212436,50.15600565075874,50.19275312870741,50.19275312870741
12,12,12,0,59.99224958010018,0.20002583807059768,25.362822842784226,45.54706535985073,59.93020099774003,59.95823861565441,59.98695467971265
20,20,20,0,99.85960746835917,0.20028117981874766,16.85126264579594,66.68330737100914,55.060471390374005,92.9106959477067,99.76074412371963
30,30,30,0,147.6483807535842,0.20318543181362822,20.873554840683937,90.49832232653473,97.37336550559849,147.4727698881179,147.48508568760008
40,40,40,0,195.21150322351605,0.20490595758693703,25.34921144787222,114.89935769808945,102.41363409627229,194.96774306707084,194.99985321611166
50,50,50,0,241.15029445569962,0.20733957680978582,88.28880738746375,149.28542947791516,126.26868482958525,240.86813501361758,240.87839913833886
1 concurrency requests success failed wall_seconds throughput_rps latency_min latency_mean latency_p50 latency_p95 latency_max
2 1 1 1 0 7.6577067924663424 0.13058739738949013 7.656610286794603 7.656610286794603 7.656610286794603 7.656610286794603 7.656610286794603
3 2 2 2 0 11.936453193426132 0.16755395992349534 11.934747929684818 11.935045637656003 11.934747929684818 11.935343345627189 11.935343345627189
4 4 4 4 0 20.956660461612046 0.19087010582278188 20.942796256393194 20.951773355016485 20.954541454091668 20.95546007528901 20.95546007528901
5 6 6 6 0 29.489064288325608 0.20346525550406608 29.45817438978702 29.47708616297071 29.48345213010907 29.487088727764785 29.487088727764785
6 8 8 8 0 41.46563811413944 0.192930830534405 11.84945331979543 34.050638656364754 41.457184289582074 41.46130123361945 41.46130123361945
7 10 10 10 0 50.19571524951607 0.19922019141058875 12.104127056896687 42.553733562212436 50.15600565075874 50.19275312870741 50.19275312870741
8 12 12 12 0 59.99224958010018 0.20002583807059768 25.362822842784226 45.54706535985073 59.93020099774003 59.95823861565441 59.98695467971265
9 20 20 20 0 99.85960746835917 0.20028117981874766 16.85126264579594 66.68330737100914 55.060471390374005 92.9106959477067 99.76074412371963
10 30 30 30 0 147.6483807535842 0.20318543181362822 20.873554840683937 90.49832232653473 97.37336550559849 147.4727698881179 147.48508568760008
11 40 40 40 0 195.21150322351605 0.20490595758693703 25.34921144787222 114.89935769808945 102.41363409627229 194.96774306707084 194.99985321611166
12 50 50 50 0 241.15029445569962 0.20733957680978582 88.28880738746375 149.28542947791516 126.26868482958525 240.86813501361758 240.87839913833886
+218
View File
@@ -0,0 +1,218 @@
{
"started_at": "2026-06-10T15:58:07+0400",
"image": "/path/to/suya-ocr-api/temp_image_c3c56948-282e-453b-8fac-6c482243d1e5.jpg",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"levels": [
1,
2,
4,
6,
8,
10,
12,
20,
30,
40,
50
],
"results": [
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 1,
"concurrency": 1,
"success": 1,
"failed": 0,
"wall_seconds": 7.6577067924663424,
"throughput_rps": 0.13058739738949013,
"latency_seconds": {
"min": 7.656610286794603,
"mean": 7.656610286794603,
"p50": 7.656610286794603,
"p95": 7.656610286794603,
"max": 7.656610286794603
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 2,
"concurrency": 2,
"success": 2,
"failed": 0,
"wall_seconds": 11.936453193426132,
"throughput_rps": 0.16755395992349534,
"latency_seconds": {
"min": 11.934747929684818,
"mean": 11.935045637656003,
"p50": 11.934747929684818,
"p95": 11.935343345627189,
"max": 11.935343345627189
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 4,
"concurrency": 4,
"success": 4,
"failed": 0,
"wall_seconds": 20.956660461612046,
"throughput_rps": 0.19087010582278188,
"latency_seconds": {
"min": 20.942796256393194,
"mean": 20.951773355016485,
"p50": 20.954541454091668,
"p95": 20.95546007528901,
"max": 20.95546007528901
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 6,
"concurrency": 6,
"success": 6,
"failed": 0,
"wall_seconds": 29.489064288325608,
"throughput_rps": 0.20346525550406608,
"latency_seconds": {
"min": 29.45817438978702,
"mean": 29.47708616297071,
"p50": 29.48345213010907,
"p95": 29.487088727764785,
"max": 29.487088727764785
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 8,
"concurrency": 8,
"success": 8,
"failed": 0,
"wall_seconds": 41.46563811413944,
"throughput_rps": 0.192930830534405,
"latency_seconds": {
"min": 11.84945331979543,
"mean": 34.050638656364754,
"p50": 41.457184289582074,
"p95": 41.46130123361945,
"max": 41.46130123361945
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 10,
"concurrency": 10,
"success": 10,
"failed": 0,
"wall_seconds": 50.19571524951607,
"throughput_rps": 0.19922019141058875,
"latency_seconds": {
"min": 12.104127056896687,
"mean": 42.553733562212436,
"p50": 50.15600565075874,
"p95": 50.19275312870741,
"max": 50.19275312870741
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 12,
"concurrency": 12,
"success": 12,
"failed": 0,
"wall_seconds": 59.99224958010018,
"throughput_rps": 0.20002583807059768,
"latency_seconds": {
"min": 25.362822842784226,
"mean": 45.54706535985073,
"p50": 59.93020099774003,
"p95": 59.95823861565441,
"max": 59.98695467971265
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 20,
"concurrency": 20,
"success": 20,
"failed": 0,
"wall_seconds": 99.85960746835917,
"throughput_rps": 0.20028117981874766,
"latency_seconds": {
"min": 16.85126264579594,
"mean": 66.68330737100914,
"p50": 55.060471390374005,
"p95": 92.9106959477067,
"max": 99.76074412371963
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 30,
"concurrency": 30,
"success": 30,
"failed": 0,
"wall_seconds": 147.6483807535842,
"throughput_rps": 0.20318543181362822,
"latency_seconds": {
"min": 20.873554840683937,
"mean": 90.49832232653473,
"p50": 97.37336550559849,
"p95": 147.4727698881179,
"max": 147.48508568760008
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 40,
"concurrency": 40,
"success": 40,
"failed": 0,
"wall_seconds": 195.21150322351605,
"throughput_rps": 0.20490595758693703,
"latency_seconds": {
"min": 25.34921144787222,
"mean": 114.89935769808945,
"p50": 102.41363409627229,
"p95": 194.96774306707084,
"max": 194.99985321611166
},
"errors": []
},
{
"name": "vllm",
"url": "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/",
"requests": 50,
"concurrency": 50,
"success": 50,
"failed": 0,
"wall_seconds": 241.15029445569962,
"throughput_rps": 0.20733957680978582,
"latency_seconds": {
"min": 88.28880738746375,
"mean": 149.28542947791516,
"p50": 126.26868482958525,
"p95": 240.86813501361758,
"max": 240.87839913833886
},
"errors": []
}
]
}
+185
View File
@@ -0,0 +1,185 @@
import argparse
import base64
import json
import statistics
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List
import requests
DEFAULT_OLD_ENDPOINT = "http://127.0.0.1:5002/v1/api/ai/suya_ocr/"
DEFAULT_NEW_ENDPOINT = "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/"
def _encode_image(path: Path) -> str:
return base64.b64encode(path.read_bytes()).decode("utf-8")
def _preflight() -> Dict[str, Any]:
checks: Dict[str, Any] = {}
commands = {
"docker_runtimes": ["docker", "info", "--format", "{{json .Runtimes}}"],
"nvidia_smi": ["nvidia-smi", "-L"],
"nvidia_container_runtime": ["which", "nvidia-container-runtime"],
"nvidia_ctk": ["which", "nvidia-ctk"],
"docker_gpus": [
"docker",
"run",
"--rm",
"--gpus",
"all",
"--entrypoint",
"nvidia-smi",
"vllm/vllm-openai:v0.20.1",
"-L",
],
}
for name, cmd in commands.items():
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
checks[name] = {
"ok": result.returncode == 0,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
}
except Exception as exc:
checks[name] = {"ok": False, "stdout": "", "stderr": str(exc)}
runtimes = checks.get("docker_runtimes", {}).get("stdout", "")
checks["docker_has_nvidia_runtime"] = '"nvidia"' in runtimes
if not checks["docker_has_nvidia_runtime"] and checks.get("docker_gpus", {}).get("ok"):
checks["recommended_fix"] = (
"Docker GPU passthrough works with --gpus. Use the local "
"scripts/docker_nvidia_runtime_compat.sh wrapper to strip the legacy "
"--runtime nvidia flag from Surya's vLLM spawn command."
)
else:
checks["recommended_fix"] = (
"Run: sudo nvidia-ctk runtime configure --runtime=docker "
"--config=/etc/docker/daemon.json && sudo systemctl restart docker"
)
return checks
def _percentile(values: List[float], percentile: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = min(len(ordered) - 1, int(round((percentile / 100) * (len(ordered) - 1))))
return ordered[index]
def _post_once(url: str, payload: Dict[str, Any], timeout: float) -> Dict[str, Any]:
start = time.perf_counter()
try:
response = requests.post(url, json=payload, timeout=timeout)
elapsed = time.perf_counter() - start
body = response.json() if response.content else {}
return {
"ok": response.status_code == 200 and body.get("code") == 200,
"status_code": response.status_code,
"api_code": body.get("code"),
"elapsed_seconds": elapsed,
"error": body.get("message") if body.get("code") != 200 else None,
}
except Exception as exc:
return {
"ok": False,
"status_code": None,
"api_code": None,
"elapsed_seconds": time.perf_counter() - start,
"error": str(exc),
}
def run_endpoint(
name: str,
url: str,
payload: Dict[str, Any],
requests_count: int,
concurrency: int,
timeout: float,
) -> Dict[str, Any]:
start = time.perf_counter()
samples: List[Dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = [
pool.submit(_post_once, url, payload, timeout)
for _ in range(requests_count)
]
for future in as_completed(futures):
samples.append(future.result())
wall_seconds = time.perf_counter() - start
latencies = [sample["elapsed_seconds"] for sample in samples]
success_count = sum(1 for sample in samples if sample["ok"])
return {
"name": name,
"url": url,
"requests": requests_count,
"concurrency": concurrency,
"success": success_count,
"failed": requests_count - success_count,
"wall_seconds": wall_seconds,
"throughput_rps": requests_count / wall_seconds if wall_seconds else 0.0,
"latency_seconds": {
"min": min(latencies) if latencies else 0.0,
"mean": statistics.mean(latencies) if latencies else 0.0,
"p50": _percentile(latencies, 50),
"p95": _percentile(latencies, 95),
"max": max(latencies) if latencies else 0.0,
},
"errors": [sample for sample in samples if not sample["ok"]][:10],
}
def main() -> None:
parser = argparse.ArgumentParser(description="Compare legacy and vLLM OCR endpoint concurrency.")
parser.add_argument("--image", required=True, help="Path to an input png/jpg image.")
parser.add_argument("--old-url", default=DEFAULT_OLD_ENDPOINT)
parser.add_argument("--new-url", default=DEFAULT_NEW_ENDPOINT)
parser.add_argument("--requests", type=int, default=20)
parser.add_argument("--concurrency", type=int, default=8)
parser.add_argument("--timeout", type=float, default=900)
parser.add_argument("--output", default="concurrency_test_results.json")
parser.add_argument("--skip-old", action="store_true")
parser.add_argument("--skip-new", action="store_true")
args = parser.parse_args()
image_path = Path(args.image)
image_type = image_path.suffix.lstrip(".").lower() or "png"
payload = {
"file": _encode_image(image_path),
"type": "jpg" if image_type == "jpeg" else image_type,
"skip_text_detection": False,
"skip_table_detection": False,
"recognize_math": False,
"ocr_with_boxes": True,
}
results = {
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"image": str(image_path),
"requests": args.requests,
"concurrency": args.concurrency,
"preflight": _preflight(),
"endpoints": [],
}
if not args.skip_old:
results["endpoints"].append(
run_endpoint("legacy", args.old_url, payload, args.requests, args.concurrency, args.timeout)
)
if not args.skip_new:
results["endpoints"].append(
run_endpoint("vllm", args.new_url, payload, args.requests, args.concurrency, args.timeout)
)
output_path = Path(args.output)
output_path.write_text(json.dumps(results, indent=2), encoding="utf-8")
print(json.dumps(results, indent=2))
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+29
View File
@@ -0,0 +1,29 @@
version: 1
disable_existing_loggers: False
formatters:
simple:
format: "%(asctime)s - %(module)s - %(funcName)s - line:%(lineno)d - %(levelname)s - %(message)s"
handlers:
console:
class: logging.StreamHandler
level: INFO
formatter: simple
info_file_handler:
class: logging.handlers.TimedRotatingFileHandler
filename: logs/info.log
level: INFO
formatter: simple
encoding: utf8
when: d
interval: 1
backupCount: 30
error_file_handler:
class: logging.handlers.RotatingFileHandler
level: ERROR
formatter: simple
filename: logs/errors.log
backupCount: 20
encoding: utf8
root:
level: INFO
handlers: [console, info_file_handler, error_file_handler]
+25
View File
@@ -0,0 +1,25 @@
from typing import Dict
import torch
from surya.common.predictor import BasePredictor
from surya.detection import DetectionPredictor
from surya.layout import LayoutPredictor
from surya.logging import configure_logging
from surya.ocr_error import OCRErrorPredictor
from surya.recognition import RecognitionPredictor
from surya.table_rec import TableRecPredictor
configure_logging()
def load_predictors(
device: str | torch.device | None = None, dtype: torch.dtype | str | None = None
) -> Dict[str, BasePredictor]:
return {
"layout": LayoutPredictor(device=device, dtype=dtype),
"ocr_error": OCRErrorPredictor(device=device, dtype=dtype),
"recognition": RecognitionPredictor(device=device, dtype=dtype),
"detection": DetectionPredictor(device=device, dtype=dtype),
"table_rec": TableRecPredictor(device=device, dtype=dtype),
}
+35
View File
@@ -0,0 +1,35 @@
[project]
name = "suya-ocr-api"
version = "0.1.0"
description = "FastAPI OCR service using Surya OCR with vLLM-backed inference."
requires-python = ">=3.10,<4"
dependencies = [
"fastapi==0.115.7",
"uvicorn==0.37.0",
"python-multipart==0.0.20",
"requests==2.32.4",
"PyYAML==6.0.2",
"pillow==10.4.0",
"pydantic==2.11.7",
"pydantic-settings==2.9.1",
"pypdfium2==4.30.0",
"pandas==2.3.0",
"openai>=1.55.0,<2",
"httpx>=0.27.0,<0.28",
"beautifulsoup4>=4.12.0,<5",
"platformdirs==4.3.8",
"filelock==3.18.0",
"python-dotenv==1.1.0",
]
[tool.uv]
package = false
[project.optional-dependencies]
trtllm = [
"flash-linear-attention==0.5.0",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+4
View File
@@ -0,0 +1,4 @@
# Extra deps for the quantization benchmark (not needed by the production image).
llmcompressor>=0.3.0
bitsandbytes>=0.43.0
matplotlib>=3.8.0
+16
View File
@@ -0,0 +1,16 @@
fastapi==0.115.7
uvicorn==0.37.0
python-multipart==0.0.20
requests==2.32.4
PyYAML==6.0.2
pillow==10.4.0
pydantic==2.11.7
pydantic-settings==2.9.1
python-dotenv==1.1.0
pypdfium2==4.30.0
pandas==2.3.0
platformdirs==4.3.8
filelock==3.18.0
beautifulsoup4>=4.12.0,<5
openai>=1.55.0,<2
httpx>=0.27.0,<0.28
+54
View File
@@ -0,0 +1,54 @@
"""Post one or more images to an OCR endpoint and save text_lines per image.
Used to freeze the accuracy baseline and to capture candidate outputs for CER.
Usage:
python scripts/capture_ocr_text.py \
--url http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/ \
--out-dir baseline_outputs \
temp_image_c3c56948-282e-453b-8fac-6c482243d1e5.jpg
"""
from __future__ import annotations
import argparse
import base64
from pathlib import Path
import requests
def _payload(image_path: Path) -> dict:
suffix = image_path.suffix.lstrip(".").lower() or "png"
return {
"file": base64.b64encode(image_path.read_bytes()).decode("utf-8"),
"type": "jpg" if suffix == "jpeg" else suffix,
"skip_text_detection": False,
"skip_table_detection": False,
"recognize_math": False,
"ocr_with_boxes": True,
}
def capture(url: str, image_path: Path, out_dir: Path, timeout: float) -> str:
resp = requests.post(url, json=_payload(image_path), timeout=timeout)
body = resp.json()
text = (body.get("data") or {}).get("text_lines", "") if isinstance(body.get("data"), dict) else ""
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / (image_path.stem + ".txt")).write_text(text, encoding="utf-8")
return text
def main() -> None:
parser = argparse.ArgumentParser(description="Capture OCR text_lines per image.")
parser.add_argument("images", nargs="+", type=Path)
parser.add_argument("--url", required=True)
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--timeout", type=float, default=900)
args = parser.parse_args()
for image in args.images:
capture(args.url, image, args.out_dir, args.timeout)
print(f"captured {image.name} -> {args.out_dir}")
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
"""Character error rate (CER) between a reference baseline and a candidate.
CER = levenshtein(reference, hypothesis) / len(reference).
Used to gate work-reduction changes: keep a change only if CER <= 0.005.
Usage:
python scripts/cer_divergence.py baseline_outputs/ candidate_outputs/
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def _levenshtein(a: str, b: str) -> int:
if a == b:
return 0
if not a:
return len(b)
if not b:
return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cost = 0 if ca == cb else 1
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost))
prev = cur
return prev[-1]
def cer(reference: str, hypothesis: str) -> float:
if not reference:
return 0.0 if not hypothesis else 1.0
return _levenshtein(reference, hypothesis) / len(reference)
def cer_over_dirs(baseline_dir: Path, candidate_dir: Path) -> dict:
per_file = {}
for ref_path in sorted(baseline_dir.glob("*.txt")):
cand_path = candidate_dir / ref_path.name
ref = ref_path.read_text(encoding="utf-8")
hyp = cand_path.read_text(encoding="utf-8") if cand_path.exists() else ""
per_file[ref_path.name] = cer(ref, hyp)
mean = sum(per_file.values()) / len(per_file) if per_file else 0.0
return {"mean_cer": mean, "max_cer": max(per_file.values(), default=0.0), "per_file": per_file}
def main() -> None:
parser = argparse.ArgumentParser(description="CER divergence between two output dirs.")
parser.add_argument("baseline_dir", type=Path)
parser.add_argument("candidate_dir", type=Path)
args = parser.parse_args()
print(json.dumps(cer_over_dirs(args.baseline_dir, args.candidate_dir), indent=2))
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v nvidia-ctk >/dev/null 2>&1; then
echo "nvidia-ctk is not installed. Install NVIDIA Container Toolkit first." >&2
exit 1
fi
sudo nvidia-ctk runtime configure --runtime=docker --config=/etc/docker/daemon.json
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl restart docker
else
sudo service docker restart
fi
docker info --format '{{json .Runtimes}}'
docker run --rm --runtime nvidia --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
args=()
skip_next=0
for arg in "$@"; do
if [ "$skip_next" -eq 1 ]; then
skip_next=0
if [ "$arg" = "nvidia" ]; then
continue
fi
fi
if [ "$arg" = "--runtime" ]; then
skip_next=1
continue
fi
args+=("$arg")
done
exec /usr/bin/docker "${args[@]}"
+68
View File
@@ -0,0 +1,68 @@
"""Parse surya_timing_summary lines from logs/info.log and aggregate spans.
Usage:
python scripts/parse_timing.py logs/info.log
"""
from __future__ import annotations
import argparse
import ast
import json
import re
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional
_BATCH_RE = re.compile(r"batch_size=(\d+)")
_REQID_RE = re.compile(r"request_id=(\S+)")
def parse_line(line: str) -> Optional[Dict[str, Any]]:
if "surya_timing_summary" not in line or "events=" not in line:
return None
events_str = line.split("events=", 1)[1].strip()
try:
events = ast.literal_eval(events_str)
except (ValueError, SyntaxError):
return None
batch_match = _BATCH_RE.search(line)
reqid_match = _REQID_RE.search(line)
return {
"request_id": reqid_match.group(1) if reqid_match else "-",
"batch_size": int(batch_match.group(1)) if batch_match else 0,
"events": events,
}
def aggregate(records: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
stats: Dict[str, Dict[str, float]] = defaultdict(
lambda: {"count": 0, "total_ms": 0.0, "mean_ms": 0.0, "total_tokens": 0}
)
for rec in records:
for event in rec["events"]:
s = stats[event["name"]]
s["count"] += 1
s["total_ms"] += event.get("duration_ms", 0.0)
tokens = (event.get("metadata") or {}).get("token_count")
if tokens:
s["total_tokens"] += tokens
for s in stats.values():
s["mean_ms"] = round(s["total_ms"] / s["count"], 2) if s["count"] else 0.0
s["total_ms"] = round(s["total_ms"], 2)
return dict(stats)
def main() -> None:
parser = argparse.ArgumentParser(description="Aggregate surya timing spans from a log file.")
parser.add_argument("logfile", type=Path)
args = parser.parse_args()
records = [
rec
for rec in (parse_line(line) for line in args.logfile.read_text(encoding="utf-8").splitlines())
if rec is not None
]
print(json.dumps({"records": len(records), "spans": aggregate(records)}, indent=2))
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
"""Sample a vLLM server's Prometheus /metrics and report peak in-flight sequences.
vLLM exposes `vllm:num_requests_running` (currently executing on GPU) and
`vllm:num_requests_waiting` (queued). Peak running vs VLLM_MAX_NUM_SEQS tells us
whether we are saturating or overflowing the GPU's sequence slots.
Run this in the background during a benchmark, then read its printed summary.
Usage:
python scripts/poll_vllm_metrics.py \
--url http://127.0.0.1:8000/metrics --interval 0.25 --duration 120
"""
from __future__ import annotations
import argparse
import re
import time
import requests
_RUNNING_RE = re.compile(r"^vllm:num_requests_running\S*\s+([0-9.]+)", re.MULTILINE)
_WAITING_RE = re.compile(r"^vllm:num_requests_waiting\S*\s+([0-9.]+)", re.MULTILINE)
def _scrape(url: str) -> tuple[float, float]:
text = requests.get(url, timeout=5).text
running = max((float(m) for m in _RUNNING_RE.findall(text)), default=0.0)
waiting = max((float(m) for m in _WAITING_RE.findall(text)), default=0.0)
return running, waiting
def main() -> None:
parser = argparse.ArgumentParser(description="Poll vLLM /metrics for peak in-flight sequences.")
parser.add_argument("--url", default="http://127.0.0.1:8000/metrics")
parser.add_argument("--interval", type=float, default=0.25)
parser.add_argument("--duration", type=float, default=120)
args = parser.parse_args()
peak_running = 0.0
peak_waiting = 0.0
deadline = time.perf_counter() + args.duration
while time.perf_counter() < deadline:
try:
running, waiting = _scrape(args.url)
peak_running = max(peak_running, running)
peak_waiting = max(peak_waiting, waiting)
except Exception:
pass
time.sleep(args.interval)
print(f"peak_running={peak_running} peak_waiting={peak_waiting}")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""Quantization benchmark harness for the Surya-OCR-2 recognition VLM."""
+59
View File
@@ -0,0 +1,59 @@
"""Summary-row contract and rendering to CSV + markdown."""
from __future__ import annotations
import csv
import io
from typing import Any, Dict, List
SUMMARY_FIELDS = [
"method",
"status",
"t4_deployable",
"model_size_mb",
"mean_latency_s",
"p50_latency_s",
"p95_latency_s",
"throughput_rps",
"decode_tok_s",
"mean_cer",
"max_cer",
"mean_bbox_iou",
"mean_missed_lines",
"mean_extra_lines",
"error",
]
def build_row(**kwargs: Any) -> Dict[str, Any]:
unknown = set(kwargs) - set(SUMMARY_FIELDS)
if unknown:
raise KeyError(f"unknown summary field(s): {sorted(unknown)}")
row = {field: None for field in SUMMARY_FIELDS}
row.update(kwargs)
return row
def rows_to_csv(rows: List[Dict[str, Any]]) -> str:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=SUMMARY_FIELDS)
writer.writeheader()
for row in rows:
writer.writerow(row)
return buf.getvalue()
def _fmt(value: Any) -> str:
if value is None:
return ""
if isinstance(value, float):
return f"{value:.4f}"
return str(value)
def rows_to_markdown(rows: List[Dict[str, Any]]) -> str:
header = "| " + " | ".join(SUMMARY_FIELDS) + " |"
sep = "| " + " | ".join("---" for _ in SUMMARY_FIELDS) + " |"
lines = [header, sep]
for row in rows:
lines.append("| " + " | ".join(_fmt(row[f]) for f in SUMMARY_FIELDS) + " |")
return "\n".join(lines)
+66
View File
@@ -0,0 +1,66 @@
"""Greedy bbox IoU matching between reference and candidate page boxes."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, List, Sequence
Box = Sequence[float]
def iou(a: Box, b: Box) -> float:
ax0, ay0, ax1, ay1 = a
bx0, by0, bx1, by1 = b
ix0, iy0 = max(ax0, bx0), max(ay0, by0)
ix1, iy1 = min(ax1, bx1), min(ay1, by1)
iw, ih = max(0.0, ix1 - ix0), max(0.0, iy1 - iy0)
inter = iw * ih
if inter <= 0:
return 0.0
area_a = max(0.0, ax1 - ax0) * max(0.0, ay1 - ay0)
area_b = max(0.0, bx1 - bx0) * max(0.0, by1 - by0)
union = area_a + area_b - inter
return inter / union if union > 0 else 0.0
def match_boxes(ref: List[Box], cand: List[Box], iou_threshold: float = 0.5) -> Dict[str, float]:
used = [False] * len(cand)
matched_ious: List[float] = []
for r in ref:
best_j, best_iou = -1, 0.0
for j, c in enumerate(cand):
if used[j]:
continue
cur = iou(r, c)
if cur > best_iou:
best_iou, best_j = cur, j
if best_j >= 0 and best_iou >= iou_threshold:
used[best_j] = True
matched_ious.append(best_iou)
matched = len(matched_ious)
return {
"matched": matched,
"missed": len(ref) - matched,
"extra": len(cand) - matched,
"mean_matched_iou": sum(matched_ious) / matched if matched else 0.0,
}
def bbox_iou_over_dirs(ref_dir: Path, cand_dir: Path, iou_threshold: float = 0.5) -> Dict:
per_file: Dict[str, Dict[str, float]] = {}
for ref_path in sorted(ref_dir.glob("*.json")):
ref_boxes = json.loads(ref_path.read_text(encoding="utf-8")).get("boxes", [])
cand_path = cand_dir / ref_path.name
cand_boxes = (
json.loads(cand_path.read_text(encoding="utf-8")).get("boxes", [])
if cand_path.exists()
else []
)
per_file[ref_path.name] = match_boxes(ref_boxes, cand_boxes, iou_threshold)
n = len(per_file) or 1
return {
"mean_bbox_iou": sum(v["mean_matched_iou"] for v in per_file.values()) / n,
"mean_missed_lines": sum(v["missed"] for v in per_file.values()) / n,
"mean_extra_lines": sum(v["extra"] for v in per_file.values()) / n,
"per_file": per_file,
}
+144
View File
@@ -0,0 +1,144 @@
"""Produce vLLM-loadable quantized checkpoints.
compressor methods (int8/awq/gptq) use llm-compressor `oneshot`.
bnb methods (bnb8/bnb4) use transformers + BitsAndBytesConfig + save_pretrained.
baseline/online (bf16/fp8) need no build.
"""
from __future__ import annotations
from pathlib import Path
from typing import List
from scripts.quant.recipes import METHOD_SPECS
def needs_build(method: str) -> bool:
return METHOD_SPECS[method]["kind"] in ("compressor", "bnb")
def _is_built(out_dir: Path) -> bool:
return (out_dir / "config.json").exists()
CALIB_MAX_SEQ_LEN = 8192
# Never quantize the LM head or the vision tower: vision modules are shape-fragile
# (see the FP8 Marlin failure) and contribute little to decode cost.
QUANT_IGNORE = ["re:.*lm_head", "re:.*visual.*", "re:.*vision.*"]
def _calibration_dataset(calib_images: List[Path], processor):
"""HF Dataset of pre-tokenized multimodal samples (batch dim kept), matching
llm-compressor's multimodal-vision examples; re-tensorized by _data_collator.
Activation calibration only needs representative forward passes, not the
exact training prompt."""
from datasets import Dataset
from PIL import Image
samples = []
for path in calib_images:
image = Image.open(path).convert("RGB")
messages = [{
"role": "user",
"content": [
{"type": "image"},
{"type": "text", "text": "OCR this document."},
],
}]
prompt = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
inputs = processor(
text=[prompt], images=[image],
padding=False, truncation=True, max_length=CALIB_MAX_SEQ_LEN,
)
samples.append({k: (v.tolist() if hasattr(v, "tolist") else v) for k, v in inputs.items()})
return Dataset.from_list(samples)
def _data_collator(batch):
import torch
assert len(batch) == 1
return {key: torch.tensor(value) for key, value in batch[0].items()}
def _build_compressor(method: str, base_model: str, out_dir: Path, calib_images: List[Path]) -> None:
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import GPTQModifier
from transformers import AutoProcessor
try:
from transformers import AutoModelForImageTextToText as _AutoModel
except ImportError: # older transformers
from transformers import AutoModelForCausalLM as _AutoModel
spec = METHOD_SPECS[method]
model = _AutoModel.from_pretrained(base_model, dtype="auto", device_map="auto")
processor = AutoProcessor.from_pretrained(base_model)
dataset = _calibration_dataset(calib_images, processor)
if spec["modifier"] == "awq":
# llm-compressor main: AWQ is a transform paired with a QuantizationModifier.
from llmcompressor.modifiers.quantization import QuantizationModifier
try:
from llmcompressor.modifiers.transform.awq import AWQModifier
except ImportError: # older layouts keep AWQModifier under modifiers.awq
from llmcompressor.modifiers.awq import AWQModifier
recipe = [
AWQModifier(duo_scaling=False),
QuantizationModifier(scheme=spec["scheme"], ignore=QUANT_IGNORE),
]
else:
recipe = []
if spec.get("smoothquant"):
from llmcompressor.modifiers.smoothquant import SmoothQuantModifier
recipe.append(SmoothQuantModifier(smoothing_strength=0.8))
recipe.append(GPTQModifier(targets="Linear", scheme=spec["scheme"], ignore=QUANT_IGNORE))
oneshot(
model=model,
dataset=dataset,
recipe=recipe,
max_seq_length=CALIB_MAX_SEQ_LEN,
num_calibration_samples=len(dataset),
data_collator=_data_collator,
)
out_dir.mkdir(parents=True, exist_ok=True)
model.save_pretrained(out_dir, save_compressed=True)
processor.save_pretrained(out_dir)
def _build_bnb(method: str, base_model: str, out_dir: Path) -> None:
import torch
from transformers import AutoProcessor, BitsAndBytesConfig
try:
from transformers import AutoModelForImageTextToText as _AutoModel
except ImportError:
from transformers import AutoModelForCausalLM as _AutoModel
bits = METHOD_SPECS[method]["bits"]
if bits == 8:
config = BitsAndBytesConfig(load_in_8bit=True)
else:
config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
)
model = _AutoModel.from_pretrained(base_model, quantization_config=config, device_map="auto")
model.save_pretrained(out_dir)
AutoProcessor.from_pretrained(base_model).save_pretrained(out_dir)
def build_model(method: str, base_model: str, out_dir: Path, calib_images: List[Path]) -> Path:
if not needs_build(method):
return Path(base_model)
out_dir = Path(out_dir)
if _is_built(out_dir):
return out_dir
kind = METHOD_SPECS[method]["kind"]
if kind == "compressor":
_build_compressor(method, base_model, out_dir, calib_images)
else:
_build_bnb(method, base_model, out_dir)
return out_dir
+77
View File
@@ -0,0 +1,77 @@
"""Capture per-page OCR text, bboxes, and latency from the vLLM OCR endpoint."""
from __future__ import annotations
import argparse
import base64
import json
import sys
from pathlib import Path
from typing import Any, Dict
import requests
def extract_capture(body: Dict[str, Any]) -> Dict[str, Any]:
data = body.get("data") or {}
if not isinstance(data, dict):
data = {}
blocks = ((data.get("ocr_text_json") or {}).get("blocks")) or []
boxes = [list(b["bbox"]) for b in blocks if isinstance(b, dict) and b.get("bbox")]
return {
"text": data.get("text_lines", "") or "",
"boxes": boxes,
"elapsed_seconds": data.get("elapsed_seconds"),
}
def write_capture(cap: Dict[str, Any], out_dir: Path, stem: str) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / f"{stem}.txt").write_text(cap["text"], encoding="utf-8")
(out_dir / f"{stem}.json").write_text(
json.dumps({"boxes": cap["boxes"], "elapsed_seconds": cap["elapsed_seconds"]}),
encoding="utf-8",
)
def _payload(image_path: Path) -> Dict[str, Any]:
suffix = image_path.suffix.lstrip(".").lower() or "png"
return {
"file": base64.b64encode(image_path.read_bytes()).decode("utf-8"),
"type": "jpg" if suffix == "jpeg" else suffix,
"skip_text_detection": False,
"skip_table_detection": False,
"recognize_math": False,
"ocr_with_boxes": True,
}
def capture_page(url: str, image_path: Path, out_dir: Path, timeout: float) -> Dict[str, Any]:
resp = requests.post(url, json=_payload(image_path), timeout=timeout)
cap = extract_capture(resp.json())
if cap["elapsed_seconds"] is None:
# Latency is a headline metric; a successful response with no timing means
# the response contract changed. Surface it loudly instead of silently
# dropping the page from the latency stats.
print(
f"WARNING: no elapsed_seconds in OCR response for {image_path.name}; "
"latency for this page will be missing",
file=sys.stderr,
)
write_capture(cap, out_dir, image_path.stem)
return cap
def main() -> None:
parser = argparse.ArgumentParser(description="Capture OCR text+boxes+latency per image.")
parser.add_argument("images", nargs="+", type=Path)
parser.add_argument("--url", required=True)
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--timeout", type=float, default=900)
args = parser.parse_args()
for image in args.images:
cap = capture_page(args.url, image, args.out_dir, args.timeout)
print(f"captured {image.name}: {len(cap['boxes'])} boxes, {cap['elapsed_seconds']}s")
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
"""Assemble the quantization benchmark summary from captured per-page outputs.
Unlike run_all's inline scoring (which globs the reference and penalizes any page
a method did not capture), this scores **candidate-driven**: for each method it
scores only the pages that method actually produced, against the matching
reference page. That lets fast methods run the full eval set while slow/eager
methods (e.g. bitsandbytes) run a representative subset, without the subset being
unfairly penalized for missing pages.
Per-method status/reason and t4-deployability are passed in via a small spec so
methods that could not be built or served are recorded as explicit failed rows.
Run inside the bench container: python3 -m scripts.quant.finalize
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List
from scripts.cer_divergence import cer
from scripts.quant.aggregate import build_row, rows_to_csv, rows_to_markdown
from scripts.quant.bbox_iou import match_boxes
WORK = Path("results/quant")
REF = WORK / "reference"
RESULTS = WORK / "results"
# Per-method outcome metadata for this run (A100, vLLM v0.22.1 container).
# status "ok" methods are scored from their captured pages — if a method has no
# captures on disk it falls back to a failed row with `fallback_error`. "failed"
# methods record why outright. bf16 is the reference (CER 0 / IoU 1 by construction).
#
# int8/awq/gptq were built with llm-compressor main (0.12.x): the PyPI 0.11 line
# pins transformers<5 and cannot load qwen3_5, but main supports transformers 5.x.
# int8 is plain GPTQ W8A8 (SmoothQuant default mappings unresolvable for qwen3_5).
SPECS: List[Dict[str, Any]] = [
{"method": "bf16", "status": "ok", "t4_deployable": True, "is_reference": True},
{"method": "fp8", "status": "failed", "t4_deployable": False,
"error": "vLLM FP8 Marlin kernel: size_n=32 not divisible by tile_n_size=64 "
"(model layer shape incompatible with fp8 dynamic)"},
{"method": "int8", "status": "ok", "t4_deployable": True, "is_reference": False,
"fallback_error": "checkpoint built (llm-compressor main, GPTQ W8A8) but vLLM "
"failed to serve it — see /tmp/serve_int8.log"},
{"method": "awq", "status": "ok", "t4_deployable": True, "is_reference": False,
"fallback_error": "checkpoint built (llm-compressor main, AWQ W4A16) but vLLM "
"failed to serve it — see /tmp/serve_awq.log"},
{"method": "gptq", "status": "ok", "t4_deployable": True, "is_reference": False,
"fallback_error": "checkpoint built (llm-compressor main, GPTQ W4A16) but vLLM "
"failed to serve it — see /tmp/serve_gptq.log"},
{"method": "bnb8", "status": "ok", "t4_deployable": True, "is_reference": False},
{"method": "bnb4", "status": "ok", "t4_deployable": True, "is_reference": False},
]
def _load_page(d: Path, stem: str):
j = json.loads((d / f"{stem}.json").read_text(encoding="utf-8"))
txt = (d / f"{stem}.txt").read_text(encoding="utf-8") if (d / f"{stem}.txt").exists() else ""
return j, txt
def _score(method: str, is_reference: bool) -> Dict[str, Any]:
cand_dir = REF if is_reference else RESULTS / method
stems = sorted(p.stem for p in cand_dir.glob("*.json"))
latencies, cers, ious, missed, extra = [], [], [], [], []
for stem in stems:
cj, ctxt = _load_page(cand_dir, stem)
if cj.get("elapsed_seconds") is not None:
latencies.append(cj["elapsed_seconds"])
rj, rtxt = _load_page(REF, stem)
cers.append(cer(rtxt, ctxt))
m = match_boxes(rj.get("boxes", []), cj.get("boxes", []), iou_threshold=0.5)
ious.append(m["mean_matched_iou"])
missed.append(m["missed"])
extra.append(m["extra"])
latencies.sort()
n = len(latencies)
model_dir = WORK / "models" / method
size = None
if model_dir.exists():
size = round(sum(f.stat().st_size for f in model_dir.rglob("*") if f.is_file()) / (1024 * 1024), 1)
avg = lambda xs: round(sum(xs) / len(xs), 4) if xs else None
return {
"n_pages": len(stems),
"mean_latency_s": round(sum(latencies) / n, 2) if n else None,
"p50_latency_s": round(latencies[n // 2], 2) if n else None,
"p95_latency_s": round(latencies[min(n - 1, int(n * 0.95))], 2) if n else None,
"mean_cer": avg(cers),
"max_cer": round(max(cers), 4) if cers else None,
"mean_bbox_iou": avg(ious),
"mean_missed_lines": avg(missed),
"mean_extra_lines": avg(extra),
"model_size_mb": size,
}
def main() -> None:
rows = []
for spec in SPECS:
if spec["status"] != "ok":
rows.append(build_row(method=spec["method"], status="failed",
t4_deployable=spec["t4_deployable"], error=spec["error"]))
continue
cand_dir = REF if spec.get("is_reference") else RESULTS / spec["method"]
if not any(cand_dir.glob("*.json")):
rows.append(build_row(method=spec["method"], status="failed",
t4_deployable=spec["t4_deployable"],
error=spec.get("fallback_error", "no captures on disk")))
continue
s = _score(spec["method"], spec.get("is_reference", False))
rows.append(build_row(
method=spec["method"], status="ok", t4_deployable=spec["t4_deployable"],
model_size_mb=s["model_size_mb"], mean_latency_s=s["mean_latency_s"],
p50_latency_s=s["p50_latency_s"], p95_latency_s=s["p95_latency_s"],
mean_cer=s["mean_cer"], max_cer=s["max_cer"], mean_bbox_iou=s["mean_bbox_iou"],
mean_missed_lines=s["mean_missed_lines"], mean_extra_lines=s["mean_extra_lines"],
))
print(f"{spec['method']}: {s['n_pages']} pages, "
f"mean_latency={s['mean_latency_s']}s, mean_cer={s['mean_cer']}, iou={s['mean_bbox_iou']}")
(WORK / "summary.csv").write_text(rows_to_csv(rows), encoding="utf-8")
(WORK / "summary.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
(WORK / "summary.md").write_text(rows_to_markdown(rows), encoding="utf-8")
print(f"wrote summary for {len(rows)} methods to {WORK}")
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
"""Load the fixed eval-set manifest (one image filename per line)."""
from __future__ import annotations
from pathlib import Path
from typing import List
def load_manifest(manifest_path: Path, image_root: Path) -> List[Path]:
paths: List[Path] = []
for raw in manifest_path.read_text(encoding="utf-8").splitlines():
name = raw.strip()
if not name or name.startswith("#"):
continue
image = image_root / name
if not image.exists():
raise FileNotFoundError(f"manifest image not found: {name} (looked in {image_root})")
paths.append(image)
return paths
+78
View File
@@ -0,0 +1,78 @@
"""Speed-vs-accuracy Pareto scatter and per-method bar charts."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
import matplotlib
matplotlib.use("Agg") # headless
import matplotlib.pyplot as plt # noqa: E402
def pareto_points(rows: List[Dict[str, Any]], x_key: str, y_key: str) -> List[Dict[str, Any]]:
points = []
for row in rows:
if row.get("status") != "ok":
continue
if row.get(x_key) is None or row.get(y_key) is None:
continue
points.append({
"method": row["method"],
"x": row[x_key],
"y": row[y_key],
"t4_deployable": row.get("t4_deployable"),
})
return points
def _scatter(points, x_label, y_label, title, out_path: Path) -> None:
fig, ax = plt.subplots(figsize=(7, 5))
for p in points:
marker = "o" if p["t4_deployable"] else "x"
ax.scatter(p["x"], p["y"], marker=marker, s=80)
ax.annotate(p["method"], (p["x"], p["y"]), textcoords="offset points", xytext=(5, 5))
ax.set_xlabel(x_label)
ax.set_ylabel(y_label)
ax.set_title(title + " (o = T4-deployable, x = A100-only)")
fig.tight_layout()
fig.savefig(out_path, dpi=120)
plt.close(fig)
def _bar(rows, metric_key, y_label, out_path: Path) -> None:
ok = [r for r in rows if r.get("status") == "ok" and r.get(metric_key) is not None]
fig, ax = plt.subplots(figsize=(7, 5))
ax.bar([r["method"] for r in ok], [r[metric_key] for r in ok])
ax.set_ylabel(y_label)
ax.set_title(metric_key)
fig.tight_layout()
fig.savefig(out_path, dpi=120)
plt.close(fig)
def render_all(rows: List[Dict[str, Any]], out_dir: Path) -> List[Path]:
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
written: List[Path] = []
p1 = out_dir / "pareto_latency_cer.png"
_scatter(pareto_points(rows, "mean_latency_s", "mean_cer"),
"mean latency (s) [lower=faster]", "CER vs BF16 [lower=better]",
"Speed vs recognition accuracy", p1)
written.append(p1)
p2 = out_dir / "pareto_latency_iou.png"
_scatter(pareto_points(rows, "mean_latency_s", "mean_bbox_iou"),
"mean latency (s) [lower=faster]", "bbox IoU vs BF16 [higher=better]",
"Speed vs detection accuracy", p2)
written.append(p2)
for metric, label in [("mean_latency_s", "mean latency (s)"),
("mean_cer", "CER vs BF16"),
("mean_bbox_iou", "bbox IoU vs BF16")]:
bp = out_dir / f"bar_{metric}.png"
_bar(rows, metric, label, bp)
written.append(bp)
return written
+50
View File
@@ -0,0 +1,50 @@
"""Per-method quantization specs and vLLM serve-argument construction.
kind:
baseline - serve the unquantized model as-is (the accuracy reference)
online - vLLM quantizes at load (fp8 dynamic); serve the base model
compressor - llm-compressor produced a checkpoint; quant config travels with it
bnb - transformers+bitsandbytes produced a checkpoint; serve with bnb flags
"""
from __future__ import annotations
from typing import Dict, List
METHOD_SPECS: Dict[str, Dict] = {
"bf16": {"kind": "baseline", "t4_deployable": True},
"fp8": {"kind": "online", "vllm_quant": "fp8", "t4_deployable": False},
# smoothquant=False: SmoothQuant's default mappings fail to resolve for the
# qwen3_5 layer layout (each mapping matches all 24 input_layernorms), so
# int8 is plain GPTQ W8A8.
"int8": {"kind": "compressor", "modifier": "gptq", "scheme": "W8A8", "smoothquant": False, "t4_deployable": True},
"awq": {"kind": "compressor", "modifier": "awq", "scheme": "W4A16", "smoothquant": False, "t4_deployable": True},
"gptq": {"kind": "compressor", "modifier": "gptq", "scheme": "W4A16", "smoothquant": False, "t4_deployable": True},
"bnb8": {"kind": "bnb", "bits": 8, "t4_deployable": True},
"bnb4": {"kind": "bnb", "bits": 4, "t4_deployable": True},
}
def method_names() -> List[str]:
return list(METHOD_SPECS.keys())
def vllm_serve_args(method: str, model_path: str, base_model: str, port: int) -> List[str]:
spec = METHOD_SPECS[method]
kind = spec["kind"]
serve_model = base_model if kind in ("baseline", "online") else model_path
args = [
"--host", "127.0.0.1",
"--port", str(port),
"--model", serve_model,
"--served-model-name", "datalab-to/surya-ocr-2",
"--max-model-len", "18000",
"--max-num-seqs", "16",
"--gpu-memory-utilization", "0.85",
"--enable-prefix-caching",
"--mm-processor-kwargs", '{"min_pixels":3136,"max_pixels":6291456}',
]
if kind == "online":
args += ["--quantization", spec["vllm_quant"]]
elif kind == "bnb":
args += ["--quantization", "bitsandbytes", "--load-format", "bitsandbytes"]
return args
+111
View File
@@ -0,0 +1,111 @@
"""Orchestrate the quantization sweep: baseline -> each method -> aggregate -> plot.
Each method is isolated: any exception becomes a status="failed" row so one bad
method never aborts the sweep.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any, Dict, List
from scripts.quant.aggregate import build_row, rows_to_csv, rows_to_markdown
from scripts.quant.bbox_iou import bbox_iou_over_dirs
from scripts.quant.build_model import build_model
from scripts.quant.capture import capture_page
from scripts.quant.manifest import load_manifest
from scripts.quant.recipes import METHOD_SPECS, method_names, vllm_serve_args
from scripts.quant.serve import start_server, stop_server, wait_healthy
# Reuse the existing CER metric.
from scripts.cer_divergence import cer_over_dirs
OCR_URL = "http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/"
def _dir_size_mb(path: Path) -> float:
total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
return round(total / (1024 * 1024), 1)
def _measure_method(method: str, base_model: str, work_dir: Path,
eval_images: List[Path], reference_dir: Path) -> Dict[str, Any]:
"""Build -> serve -> capture -> metrics for one method. Raises on any failure.
Assumes the API process (api.py) is already running and reads SURYA_INFERENCE_URL
from the env to point at the per-method vLLM server (port set by the runbook).
Returns a dict of measured summary fields (no method/status/t4 keys).
"""
model_dir = work_dir / "models" / method
out_capture = work_dir / "results" / method
port = 8000
built = build_model(method, base_model, model_dir, eval_images[: min(8, len(eval_images))])
serve_args = vllm_serve_args(method, str(built), base_model, port)
proc = start_server(serve_args, str(work_dir / f"vllm_{method}.log"))
try:
if not wait_healthy(port):
raise RuntimeError(f"vLLM did not become healthy for {method}")
latencies = []
for image in eval_images:
cap = capture_page(OCR_URL, image, out_capture, timeout=900)
if cap["elapsed_seconds"] is not None:
latencies.append(cap["elapsed_seconds"])
cer = cer_over_dirs(reference_dir, out_capture)
iou = bbox_iou_over_dirs(reference_dir, out_capture)
finally:
stop_server(port, proc)
latencies.sort()
n = len(latencies)
size = _dir_size_mb(built) if METHOD_SPECS[method]["kind"] in ("compressor", "bnb") else None
return {
"mean_latency_s": round(sum(latencies) / n, 3) if n else None,
"p50_latency_s": latencies[n // 2] if n else None,
"p95_latency_s": latencies[min(n - 1, int(n * 0.95))] if n else None,
"mean_cer": cer["mean_cer"],
"max_cer": cer["max_cer"],
"mean_bbox_iou": iou["mean_bbox_iou"],
"mean_missed_lines": iou["mean_missed_lines"],
"mean_extra_lines": iou["mean_extra_lines"],
"model_size_mb": size,
}
def run_method(method: str, base_model: str, work_dir: Path,
eval_images: List[Path], reference_dir: Path) -> Dict[str, Any]:
t4 = METHOD_SPECS[method]["t4_deployable"]
try:
metrics = _measure_method(method, base_model, work_dir, eval_images, reference_dir)
return build_row(method=method, status="ok", t4_deployable=t4, **metrics)
except Exception as exc: # feasibility probe: record and continue
return build_row(method=method, status="failed", t4_deployable=t4, error=str(exc))
def main() -> None:
parser = argparse.ArgumentParser(description="Run the quantization benchmark sweep.")
parser.add_argument("--base-model", default="datalab-to/surya-ocr-2")
parser.add_argument("--manifest", type=Path, default=Path("eval_set/manifest.txt"))
parser.add_argument("--image-root", type=Path, default=Path("."))
parser.add_argument("--work-dir", type=Path, default=Path("results/quant"))
parser.add_argument("--methods", nargs="*", default=method_names())
args = parser.parse_args()
eval_images = load_manifest(args.manifest, args.image_root)
reference_dir = args.work_dir / "reference"
args.work_dir.mkdir(parents=True, exist_ok=True)
rows = []
for method in args.methods:
print(f"=== {method} ===", flush=True)
rows.append(run_method(method, args.base_model, args.work_dir, eval_images, reference_dir))
(args.work_dir / "summary.csv").write_text(rows_to_csv(rows), encoding="utf-8")
(args.work_dir / "summary.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
(args.work_dir / "summary.md").write_text(rows_to_markdown(rows), encoding="utf-8")
print(f"wrote summary for {len(rows)} methods to {args.work_dir}")
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Serve and score compressor-built checkpoints (int8/gptq/awq) over a page subset.
# Each method: hardened server teardown -> serve checkpoint -> health/fail watch ->
# capture N_PAGES through the OCR API. Run INSIDE the bench container:
# bash scripts/quant/score_methods.sh int8 gptq awq
# Requires api.py running on :5002 pointed at :8000.
set -uo pipefail
export LD_LIBRARY_PATH="/usr/local/cuda/compat:${LD_LIBRARY_PATH:-}"
N_PAGES="${N_PAGES:-3}"
OCR_URL="http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/"
LOG=/tmp/score_methods.log
mapfile -t ALL < <(sed '/^#/d;/^$/d' eval_set/manifest.txt)
IMAGES=("${ALL[@]:0:$N_PAGES}")
stop_server() {
local pid
pid=$(ss -ltnp 2>/dev/null | grep ":8000 " | grep -oP 'pid=\K[0-9]+' | head -1)
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null
pkill -9 -f "vllm.entrypoints" 2>/dev/null
# EngineCore outlives the API server and holds the GPU; kill compute procs and
# poll until memory actually frees (kill returns before GPU release).
for p in $(nvidia-smi --query-compute-apps=pid --format=csv,noheader 2>/dev/null); do
kill -9 "$p" 2>/dev/null
done
for _ in $(seq 1 30); do
used=$(nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits | head -1)
[ "${used:-99999}" -lt 2000 ] && break
sleep 2
done
}
for m in "$@"; do
echo "=== scoring $m ===" | tee -a "$LOG"
stop_server
# VLLM_EXTRA_ARGS lets a caller add e.g. --dtype float16 (Exllama W4A16 kernel
# on Ampere only supports float16 activations).
nohup python3 -m vllm.entrypoints.openai.api_server --host 127.0.0.1 --port 8000 \
--model "results/quant/models/$m" --served-model-name datalab-to/surya-ocr-2 \
--max-model-len 18000 --max-num-seqs 16 --gpu-memory-utilization 0.85 \
--enable-prefix-caching --mm-processor-kwargs '{"min_pixels":3136,"max_pixels":6291456}' \
${VLLM_EXTRA_ARGS:-} \
> "/tmp/serve_${m}.log" 2>&1 &
ok=0
for _ in $(seq 1 84); do
if curl -fs http://127.0.0.1:8000/health >/dev/null 2>&1; then ok=1; break; fi
if grep -qiE "Engine core initialization failed" "/tmp/serve_${m}.log" 2>/dev/null; then break; fi
sleep 5
done
if [ "$ok" -ne 1 ]; then
echo "$m: FAILED to start" | tee -a "$LOG"
continue
fi
python3 -m scripts.quant.capture --url "$OCR_URL" --out-dir "results/quant/results/$m" "${IMAGES[@]}" \
>> "$LOG" 2>&1
echo "$m: captured $(ls "results/quant/results/$m"/*.json 2>/dev/null | wc -l) pages" | tee -a "$LOG"
done
stop_server
echo "SCORE_DONE" | tee -a "$LOG"
+59
View File
@@ -0,0 +1,59 @@
"""Launch a vLLM OpenAI server for one quant variant, health-check it, and tear
it down by port (never by command-line match — that bit us in Approach A)."""
from __future__ import annotations
import os
import re
import subprocess
import time
import urllib.request
from typing import List, Optional
# Default seconds to wait for a server to become healthy. Overridable via env so a
# method that fails to boot is recorded quickly instead of blocking the sweep.
DEFAULT_HEALTH_TIMEOUT = float(os.environ.get("SURYA_QUANT_HEALTH_TIMEOUT", "900"))
def health_url(port: int) -> str:
return f"http://127.0.0.1:{port}/health"
def parse_listening_pid(ss_output: str, port: int) -> Optional[int]:
for line in ss_output.splitlines():
if f":{port} " in line or line.rstrip().endswith(f":{port}"):
m = re.search(r"pid=(\d+)", line)
if m:
return int(m.group(1))
return None
def wait_healthy(port: int, timeout: float = DEFAULT_HEALTH_TIMEOUT) -> bool:
deadline = time.time() + timeout
url = health_url(port)
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as resp:
if resp.status == 200:
return True
except Exception:
time.sleep(3)
return False
def start_server(serve_args: List[str], log_path: str) -> subprocess.Popen:
cmd = ["python", "-m", "vllm.entrypoints.openai.api_server", *serve_args]
log = open(log_path, "w")
return subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT)
def stop_server(port: int, proc: Optional[subprocess.Popen] = None) -> None:
if proc is not None:
proc.terminate()
try:
proc.wait(timeout=30)
except Exception:
proc.kill()
out = subprocess.run(["ss", "-ltnp"], capture_output=True, text=True).stdout
pid = parse_listening_pid(out, port)
if pid:
subprocess.run(["kill", str(pid)])
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# MTP (multi-token-prediction) speculative-decode sweep for the latency report.
# Serves the BF16 model three ways — no speculation (baseline), MTP with 1
# speculative token, MTP with 2 — and captures the same page subset through the
# OCR API for each. The model ships 1 nextn-predict layer, so MTP=1 is the
# expected-valid setting and MTP=2 is exploratory.
#
# Run INSIDE the bench container: bash scripts/quant/tune_mtp.sh
# Requires: api.py running on :5002 pointed at :8000; compat libs on path.
set -uo pipefail
MODEL="${MODEL:-datalab-to/surya-ocr-2}"
OCR_URL="http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/"
N_PAGES="${N_PAGES:-5}"
OUT_ROOT="results/quant/mtp"
LOG=/tmp/tune_mtp.log
export LD_LIBRARY_PATH="/usr/local/cuda/compat:${LD_LIBRARY_PATH:-}"
mapfile -t ALL < <(sed '/^#/d;/^$/d' eval_set/manifest.txt)
IMAGES=("${ALL[@]:0:$N_PAGES}")
# config_name | extra vLLM args. The speculative-config JSON is written compact
# (no spaces) so it survives word-splitting as a single argv token.
CONFIGS=(
"baseline|--enable-prefix-caching"
"mtp1|--enable-prefix-caching --speculative-config {\"method\":\"mtp\",\"num_speculative_tokens\":1}"
"mtp2|--enable-prefix-caching --speculative-config {\"method\":\"mtp\",\"num_speculative_tokens\":2}"
)
base_args() {
echo "--host 127.0.0.1 --port 8000 --model $MODEL --served-model-name $MODEL \
--max-model-len 18000 --max-num-seqs 16 --gpu-memory-utilization 0.85 --no-enforce-eager \
--mm-processor-kwargs {\"min_pixels\":3136,\"max_pixels\":6291456}"
}
stop_server() {
local pid
pid=$(ss -ltnp 2>/dev/null | grep ":8000 " | grep -oP 'pid=\K[0-9]+' | head -1)
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null
pkill -9 -f "vllm.entrypoints" 2>/dev/null
# EngineCore outlives the API server and holds the GPU; kill every remaining
# CUDA compute process, then poll until the memory is actually freed.
for p in $(nvidia-smi --query-compute-apps=pid --format=csv,noheader 2>/dev/null); do
kill -9 "$p" 2>/dev/null
done
for _ in $(seq 1 30); do
used=$(nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits | head -1)
[ "${used:-99999}" -lt 2000 ] && break
sleep 2
done
}
for entry in "${CONFIGS[@]}"; do
name="${entry%%|*}"
extra="${entry#*|}"
echo "=== mtp config: $name ($extra) ===" | tee -a "$LOG"
stop_server
# shellcheck disable=SC2046
nohup python3 -m vllm.entrypoints.openai.api_server $(base_args) $extra \
> "/tmp/mtp_${name}.log" 2>&1 &
ok=0
for _ in $(seq 1 96); do
if curl -fs http://127.0.0.1:8000/health >/dev/null 2>&1; then ok=1; break; fi
if grep -qiE "Engine core initialization failed|ValueError|RuntimeError|Traceback" "/tmp/mtp_${name}.log" 2>/dev/null; then break; fi
sleep 5
done
if [ "$ok" -ne 1 ]; then
echo "$name: FAILED to start (see /tmp/mtp_${name}.log)" | tee -a "$LOG"
continue
fi
python3 -m scripts.quant.capture --url "$OCR_URL" --out-dir "$OUT_ROOT/$name" "${IMAGES[@]}" \
>> "$LOG" 2>&1
echo "$name: captured $(ls "$OUT_ROOT/$name"/*.json 2>/dev/null | wc -l) pages" | tee -a "$LOG"
done
stop_server
echo "MTP_DONE" | tee -a "$LOG"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# vLLM serving-parameter tuning sweep for the OCR latency report.
# Serves the BF16 model under several vLLM flag combinations (CUDA graph vs eager,
# prefix caching on/off, chunked prefill on/off), captures a fixed page subset
# through the OCR API for each, and records per-config latency.
#
# Run INSIDE the bench container: bash scripts/quant/tune_vllm.sh
# Requires: api.py already running on :5002 pointed at :8000; compat libs on path.
set -uo pipefail
MODEL="${MODEL:-datalab-to/surya-ocr-2}"
OCR_URL="http://127.0.0.1:5002/v1/api/ai/suya_ocr_vllm/"
N_PAGES="${N_PAGES:-3}"
OUT_ROOT="results/quant/tuning"
LOG=/tmp/tune_vllm.log
export LD_LIBRARY_PATH="/usr/local/cuda/compat:${LD_LIBRARY_PATH:-}"
mapfile -t ALL < <(sed '/^#/d;/^$/d' eval_set/manifest.txt)
IMAGES=("${ALL[@]:0:$N_PAGES}")
# config_name | extra vLLM args
CONFIGS=(
"default|--enable-prefix-caching"
"eager|--enable-prefix-caching --enforce-eager"
"no_prefix_cache|--no-enable-prefix-caching"
"no_chunked_prefill|--enable-prefix-caching --no-enable-chunked-prefill"
)
base_args() {
echo "--host 127.0.0.1 --port 8000 --model $MODEL --served-model-name $MODEL \
--max-model-len 18000 --max-num-seqs 16 --gpu-memory-utilization 0.85 \
--mm-processor-kwargs {\"min_pixels\":3136,\"max_pixels\":6291456}"
}
stop_server() {
local pid
pid=$(ss -ltnp 2>/dev/null | grep ":8000 " | grep -oP 'pid=\K[0-9]+' | head -1)
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null
pkill -9 -f "vllm.entrypoints" 2>/dev/null
# The EngineCore worker holds the GPU and outlives the API server; kill every
# remaining CUDA compute process, then poll until the memory is actually freed
# (kill returns immediately, GPU release lags).
for p in $(nvidia-smi --query-compute-apps=pid --format=csv,noheader 2>/dev/null); do
kill -9 "$p" 2>/dev/null
done
for _ in $(seq 1 30); do
used=$(nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits | head -1)
[ "${used:-99999}" -lt 2000 ] && break
sleep 2
done
}
for entry in "${CONFIGS[@]}"; do
name="${entry%%|*}"
extra="${entry#*|}"
echo "=== tuning config: $name ($extra) ===" | tee -a "$LOG"
stop_server
# shellcheck disable=SC2046
nohup python3 -m vllm.entrypoints.openai.api_server $(base_args) $extra \
> "/tmp/tune_${name}.log" 2>&1 &
# wait for health or crash (max 7 min)
ok=0
for _ in $(seq 1 84); do
if curl -fs http://127.0.0.1:8000/health >/dev/null 2>&1; then ok=1; break; fi
if grep -qiE "Engine core initialization failed|RuntimeError" "/tmp/tune_${name}.log" 2>/dev/null; then break; fi
sleep 5
done
if [ "$ok" -ne 1 ]; then
echo "$name: FAILED to start" | tee -a "$LOG"
continue
fi
python3 -m scripts.quant.capture --url "$OCR_URL" --out-dir "$OUT_ROOT/$name" "${IMAGES[@]}" \
>> "$LOG" 2>&1
echo "$name: captured $(ls "$OUT_ROOT/$name"/*.json 2>/dev/null | wc -l) pages" | tee -a "$LOG"
done
stop_server
echo "TUNE_DONE" | tee -a "$LOG"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
export SURYA_MODEL_CHECKPOINT="${SURYA_MODEL_CHECKPOINT:-datalab-to/surya-ocr-2}"
export SURYA_INFERENCE_BACKEND="${SURYA_INFERENCE_BACKEND:-vllm}"
export SURYA_INFERENCE_URL="${SURYA_INFERENCE_URL:-http://127.0.0.1:8000/v1}"
export SURYA_INFERENCE_AUTOSTART="${SURYA_INFERENCE_AUTOSTART:-false}"
export SURYA_INFERENCE_PARALLEL="${SURYA_INFERENCE_PARALLEL:-8}"
export SURYA_INFERENCE_LOGPROBS="${SURYA_INFERENCE_LOGPROBS:-false}"
export SURYA_INFERENCE_MAX_RETRIES="${SURYA_INFERENCE_MAX_RETRIES:-1}"
# Keep in sync with VLLM_MAX_NUM_SEQS so the batcher fills every vLLM sequence
# slot (see docs/diagnosis_baseline.md). Overcommitting past it only queues.
export SURYA_INFERENCE_MAX_INFLIGHT="${SURYA_INFERENCE_MAX_INFLIGHT:-16}"
export SURYA_MAX_TOKENS_FULL_PAGE="${SURYA_MAX_TOKENS_FULL_PAGE:-6144}"
export SURYA_MAX_BLOCKS_PER_PAGE="${SURYA_MAX_BLOCKS_PER_PAGE:-80}"
export SUYA_OCR_MODE="${SUYA_OCR_MODE:-block}"
export SUYA_MAX_BATCH_SIZE="${SUYA_MAX_BATCH_SIZE:-8}"
export SUYA_BATCH_WAIT_MS="${SUYA_BATCH_WAIT_MS:-25}"
export SUYA_VLLM_IMAGE_FORMAT="${SUYA_VLLM_IMAGE_FORMAT:-JPEG}"
export SUYA_VLLM_JPEG_QUALITY="${SUYA_VLLM_JPEG_QUALITY:-92}"
export VLLM_DTYPE="${VLLM_DTYPE:-float16}"
export VLLM_GPU_MEMORY_UTILIZATION="${VLLM_GPU_MEMORY_UTILIZATION:-0.85}"
export VLLM_MAX_MODEL_LEN="${VLLM_MAX_MODEL_LEN:-18000}"
export VLLM_MAX_NUM_SEQS="${VLLM_MAX_NUM_SEQS:-16}"
export VLLM_MAX_BATCHED_TOKENS="${VLLM_MAX_BATCHED_TOKENS:-4096}"
# vLLM serving parameters are tuned for latency — see
# docs/quantization_benchmark_results.md §5 (A100, BF16). Three flags carry the
# win and MUST stay on; do not pass their negations via VLLM_EXTRA_ARGS:
# --no-enforce-eager CUDA-graph capture. The single biggest lever: eager
# mode (--enforce-eager) measured ~9.5x slower
# (48.97s vs 5.15s/page). Set explicitly so a future
# vLLM default flip can't silently disable graphs.
# --enable-prefix-caching ~15% win; OCR prompts share a long fixed prefix.
# chunked prefill (on by default) is REQUIRED — the qwen3_5 mamba/SSM cache
# fails engine init with --no-enable-chunked-prefill.
python3 -m vllm.entrypoints.openai.api_server \
--host 127.0.0.1 \
--port 8000 \
--model "${SURYA_MODEL_CHECKPOINT}" \
--served-model-name "${SURYA_MODEL_CHECKPOINT}" \
--dtype "${VLLM_DTYPE}" \
--max-model-len "${VLLM_MAX_MODEL_LEN}" \
--max-num-seqs "${VLLM_MAX_NUM_SEQS}" \
--max-num-batched-tokens "${VLLM_MAX_BATCHED_TOKENS}" \
--gpu-memory-utilization "${VLLM_GPU_MEMORY_UTILIZATION}" \
--no-enforce-eager \
--enable-prefix-caching \
--mm-processor-kwargs '{"min_pixels":3136,"max_pixels":6291456}' \
${VLLM_EXTRA_ARGS:-} &
VLLM_PID=$!
cleanup() {
kill "${VLLM_PID}" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
python3 - <<'PY'
import os
import time
import urllib.request
base = os.environ.get("SURYA_INFERENCE_URL", "http://127.0.0.1:8000/v1")
health = base[:-3] + "/health" if base.endswith("/v1") else base.rstrip("/") + "/health"
deadline = time.time() + float(os.environ.get("SURYA_INFERENCE_STARTUP_TIMEOUT", "900"))
while time.time() < deadline:
try:
with urllib.request.urlopen(health, timeout=2) as response:
if response.status == 200:
print(f"vLLM health check passed: {health}", flush=True)
raise SystemExit(0)
except Exception:
time.sleep(2)
raise SystemExit(f"vLLM did not become healthy: {health}")
PY
python3 api.py
View File
+3
View File
@@ -0,0 +1,3 @@
+64
View File
@@ -0,0 +1,64 @@
"""Pixel-content heuristics for detecting blank or near-uniform image regions.
Used by both the layout predictor (drop hallucinated layout blocks over empty
space) and the recognition predictor (drop hallucinated text blocks from
full-page OCR, decide whether an empty full-page output is a correct blank-page
read or a failure).
Two signals, combined:
* near-white fraction — most pixels have every RGB channel above a threshold
* pixel-value standard deviation — the region is essentially one color
(catches uniform-color fills that the white check misses)
"""
from __future__ import annotations
import numpy as np
from PIL import Image
# Per-channel value at/above which a pixel is considered "near-white".
# Tolerates the small noise typical of PDF renders at 96 DPI.
BLANK_WHITE_THRESHOLD = 245
# Fraction of pixels that must be near-white for a region to count as blank.
BLANK_PIXEL_FRACTION = 0.99
# Pixel-value std below which a region is "essentially one color" regardless
# of what that color is (catches solid-fill rectangles, dark banners, etc.).
UNIFORM_COLOR_STD = 8.0
def near_white_fraction(
image: Image.Image, white_threshold: int = BLANK_WHITE_THRESHOLD
) -> float:
"""Fraction of pixels where every RGB channel ≥ ``white_threshold``."""
arr = np.asarray(image.convert("RGB"))
if arr.size == 0:
return 0.0
return float(np.all(arr >= white_threshold, axis=-1).mean())
def is_blank_region(
image: Image.Image,
*,
white_threshold: int = BLANK_WHITE_THRESHOLD,
blank_pixel_fraction: float = BLANK_PIXEL_FRACTION,
uniform_color_std: float = UNIFORM_COLOR_STD,
) -> bool:
"""True iff the image is essentially blank — either mostly near-white or
near-uniform color. Use this on a per-block crop or a whole page.
Returns False for empty (0-pixel) crops so callers don't accidentally
treat a degenerate bbox as blank.
"""
arr = np.asarray(image.convert("RGB"))
if arr.size == 0:
return False
if np.all(arr >= white_threshold, axis=-1).mean() > blank_pixel_fraction:
return True
# Per-channel std — a uniform solid color (e.g., red banner with RGB=(200,50,50))
# has each channel constant across pixels, but mixing channels inflates the
# aggregate std. Check each channel independently.
per_channel_std = arr.reshape(-1, arr.shape[-1]).std(axis=0)
if float(per_channel_std.max()) < uniform_color_std:
return True
return False
+25
View File
@@ -0,0 +1,25 @@
from typing import Optional, Any
import torch
from surya.settings import settings
class ModelLoader:
def __init__(self, checkpoint: Optional[str] = None):
self.checkpoint = checkpoint
def model(
self,
device: torch.device | str | None = settings.TORCH_DEVICE_MODEL,
dtype: Optional[torch.dtype | str] = settings.MODEL_DTYPE,
attention_implementation: Optional[str] = None,
) -> Any:
raise NotImplementedError()
def processor(
self,
device: torch.device | str | None = settings.TORCH_DEVICE_MODEL,
dtype: Optional[torch.dtype | str] = settings.MODEL_DTYPE,
) -> Any:
raise NotImplementedError()
+122
View File
@@ -0,0 +1,122 @@
import copy
from typing import List, Optional
import numpy as np
from pydantic import BaseModel, field_validator, computed_field
import numbers
class PolygonBox(BaseModel):
polygon: List[List[float]]
confidence: Optional[float] = None
@field_validator("polygon", mode="before")
@classmethod
def convert_bbox_to_polygon(cls, value):
if isinstance(value, (list, tuple)) and len(value) == 4:
if all(isinstance(x, numbers.Number) for x in value):
value = [float(v) for v in value]
x_min, y_min, x_max, y_max = value
polygon = [
[x_min, y_min],
[x_max, y_min],
[x_max, y_max],
[x_min, y_max],
]
return polygon
elif all(
isinstance(point, (list, tuple)) and len(point) == 2 for point in value
):
value = [[float(v) for v in point] for point in value]
return value
elif isinstance(value, np.ndarray):
if value.shape == (4, 2):
return value.tolist()
raise ValueError(
f"Input must be either a bbox [x_min, y_min, x_max, y_max] or a polygon with 4 corners [(x,y), (x,y), (x,y), (x,y)]. All values must be numeric. You passed {value} of type {type(value)}. The first value is of type {type(value[0])}."
)
@property
def height(self):
return self.bbox[3] - self.bbox[1]
@property
def width(self):
return self.bbox[2] - self.bbox[0]
@property
def area(self):
return self.width * self.height
@computed_field
@property
def bbox(self) -> List[float]:
x_coords = [point[0] for point in self.polygon]
y_coords = [point[1] for point in self.polygon]
return [min(x_coords), min(y_coords), max(x_coords), max(y_coords)]
def rescale(self, processor_size, image_size):
# Point is in x, y format
page_width, page_height = processor_size
img_width, img_height = image_size
width_scaler = img_width / page_width
height_scaler = img_height / page_height
for corner in self.polygon:
corner[0] = int(corner[0] * width_scaler)
corner[1] = int(corner[1] * height_scaler)
def round(self, divisor):
for corner in self.polygon:
corner[0] = int(corner[0] / divisor) * divisor
corner[1] = int(corner[1] / divisor) * divisor
def fit_to_bounds(self, bounds):
new_corners = copy.deepcopy(self.polygon)
for corner in new_corners:
corner[0] = max(min(corner[0], bounds[2]), bounds[0])
corner[1] = max(min(corner[1], bounds[3]), bounds[1])
self.polygon = new_corners
def expand(self, x_margin: float, y_margin: float):
new_polygon = []
x_margin = x_margin * self.width
y_margin = y_margin * self.height
for idx, poly in enumerate(self.polygon):
if idx == 0:
new_polygon.append([int(poly[0] - x_margin), int(poly[1] - y_margin)])
elif idx == 1:
new_polygon.append([int(poly[0] + x_margin), int(poly[1] - y_margin)])
elif idx == 2:
new_polygon.append([int(poly[0] + x_margin), int(poly[1] + y_margin)])
elif idx == 3:
new_polygon.append([int(poly[0] - x_margin), int(poly[1] + y_margin)])
self.polygon = new_polygon
def intersection_area(self, other, x_margin=0, y_margin=0):
x_overlap = self.x_overlap(other, x_margin)
y_overlap = self.y_overlap(other, y_margin)
return x_overlap * y_overlap
def x_overlap(self, other, x_margin=0):
return max(
0,
min(self.bbox[2] + x_margin, other.bbox[2] + x_margin)
- max(self.bbox[0] - x_margin, other.bbox[0] - x_margin),
)
def y_overlap(self, other, y_margin=0):
return max(
0,
min(self.bbox[3] + y_margin, other.bbox[3] + y_margin)
- max(self.bbox[1] - y_margin, other.bbox[1] - y_margin),
)
@property
def center(self):
return [(self.bbox[0] + self.bbox[2]) / 2, (self.bbox[1] + self.bbox[3]) / 2]
def __hash__(self):
return hash(tuple(self.bbox))
+57
View File
@@ -0,0 +1,57 @@
from typing import Optional
import torch
from surya.common.load import ModelLoader
from surya.settings import settings
class BasePredictor:
model_loader_cls = ModelLoader
batch_size: Optional[int] = None
default_batch_sizes = {"cpu": 1, "mps": 1, "cuda": 1}
torch_dtype = settings.MODEL_DTYPE
@property
def disable_tqdm(self) -> bool:
return self._disable_tqdm
@disable_tqdm.setter
def disable_tqdm(self, value: bool) -> None:
self._disable_tqdm = bool(value)
def __init__(
self,
checkpoint: Optional[str] = None,
device: torch.device | str | None = settings.TORCH_DEVICE_MODEL,
dtype: Optional[torch.dtype | str] = None,
attention_implementation: Optional[str] = None,
):
if dtype is None:
dtype = self.torch_dtype
loader = self.model_loader_cls(checkpoint)
self.model = loader.model(device, dtype, attention_implementation)
self.processor = loader.processor()
self._disable_tqdm = settings.DISABLE_TQDM
def to(self, device_dtype: torch.device | str | None = None):
if hasattr(self, "model") and self.model:
self.model.to(device_dtype)
return
# Predictors that don't own a torch model (e.g. VLM-backed predictors that
# rely on an external server) treat .to() as a no-op.
if hasattr(self, "manager") and self.manager is not None:
return
raise ValueError("Model not loaded")
def get_batch_size(self):
batch_size = self.batch_size
if batch_size is None:
batch_size = self.default_batch_sizes["cpu"]
if settings.TORCH_DEVICE_MODEL in self.default_batch_sizes:
batch_size = self.default_batch_sizes[settings.TORCH_DEVICE_MODEL]
return batch_size
def __call__(self, *args, **kwargs):
raise NotImplementedError()
+22
View File
@@ -0,0 +1,22 @@
from typing import Optional
from transformers import PreTrainedModel
from transformers.utils import is_flash_attn_2_available
class SuryaPreTrainedModel(PreTrainedModel):
# No-op if we pass attention, so we can set attention however we want in the config
def _check_and_adjust_attn_implementation(
self, attn_implementation: Optional[str], **kwargs
):
if attn_implementation is None:
try:
self._sdpa_can_dispatch(True)
attn_implementation = "sdpa"
except (ValueError, ImportError):
attn_implementation = "eager"
if self._supports_flash_attn and is_flash_attn_2_available():
attn_implementation = "flash_attention_2"
return attn_implementation
+182
View File
@@ -0,0 +1,182 @@
import json
import os
import shutil
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import requests
from tqdm import tqdm
from surya.logging import get_logger
from surya.settings import settings
logger = get_logger()
# Lock file expiration time in seconds (10 minutes)
LOCK_EXPIRATION = 600
def join_urls(url1: str, url2: str):
url1 = url1.rstrip("/")
url2 = url2.lstrip("/")
return f"{url1}/{url2}"
def get_model_name(pretrained_model_name_or_path: str):
return pretrained_model_name_or_path.split("/")[0]
def download_file(remote_path: str, local_path: str, chunk_size: int = 1024 * 1024):
local_path = Path(local_path)
try:
response = requests.get(remote_path, stream=True, allow_redirects=True)
response.raise_for_status() # Raise an exception for bad status codes
# Get file size from headers for progress bar
total_size = int(response.headers.get('content-length', 0))
# Create progress bar with file name and size info
filename = local_path.name
pbar = tqdm(
total=total_size,
unit='B',
unit_scale=True,
unit_divisor=1024,
desc=f"Downloading {filename}",
miniters=1
)
with open(local_path, "wb") as f:
downloaded = 0
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
f.write(chunk)
downloaded += len(chunk)
pbar.update(len(chunk))
pbar.close()
return local_path
except Exception as e:
if local_path.exists():
local_path.unlink()
logger.error(f"Download error for file {remote_path}: {str(e)}")
raise
def check_manifest(local_dir: str):
local_dir = Path(local_dir)
manifest_path = local_dir / "manifest.json"
if not os.path.exists(manifest_path):
return False
try:
with open(manifest_path, "r") as f:
manifest = json.load(f)
for file in manifest["files"]:
if not os.path.exists(local_dir / file):
return False
except Exception:
return False
return True
def download_directory(remote_path: str, local_dir: str):
model_name = get_model_name(remote_path)
s3_url = join_urls(settings.S3_BASE_URL, remote_path)
# Check to see if it's already downloaded
model_exists = check_manifest(local_dir)
if model_exists:
return
# Use tempfile.TemporaryDirectory to automatically clean up
with tempfile.TemporaryDirectory() as temp_dir:
# Download the manifest file
manifest_file = join_urls(s3_url, "manifest.json")
manifest_path = os.path.join(temp_dir, "manifest.json")
download_file(manifest_file, manifest_path)
# List and download all files
with open(manifest_path, "r") as f:
manifest = json.load(f)
pbar = tqdm(
desc=f"Downloading {model_name} model to {local_dir}",
total=len(manifest["files"]),
)
with ThreadPoolExecutor(
max_workers=settings.PARALLEL_DOWNLOAD_WORKERS
) as executor:
futures = []
for file in manifest["files"]:
remote_file = join_urls(s3_url, file)
local_file = os.path.join(temp_dir, file)
futures.append(executor.submit(download_file, remote_file, local_file))
for future in futures:
future.result()
pbar.update(1)
pbar.close()
# Move all files to new directory
for file in os.listdir(temp_dir):
shutil.move(os.path.join(temp_dir, file), local_dir)
class S3DownloaderMixin:
s3_prefix = "s3://"
@classmethod
def get_local_path(cls, pretrained_model_name_or_path) -> str:
if pretrained_model_name_or_path.startswith(cls.s3_prefix):
pretrained_model_name_or_path = pretrained_model_name_or_path.replace(
cls.s3_prefix, ""
)
cache_dir = settings.MODEL_CACHE_DIR
local_path = os.path.join(cache_dir, pretrained_model_name_or_path)
os.makedirs(local_path, exist_ok=True)
else:
local_path = ""
return local_path
@classmethod
def from_pretrained(cls, pretrained_model_name_or_path, *args, **kwargs):
# Allow loading models directly from the hub, or using s3
if not pretrained_model_name_or_path.startswith(cls.s3_prefix):
return super().from_pretrained(
pretrained_model_name_or_path, *args, **kwargs
)
local_path = cls.get_local_path(pretrained_model_name_or_path)
pretrained_model_name_or_path = pretrained_model_name_or_path.replace(
cls.s3_prefix, ""
)
# Retry logic for downloading the model folder
retries = 3
delay = 5
attempt = 0
success = False
while not success and attempt < retries:
try:
download_directory(pretrained_model_name_or_path, local_path)
success = True # If download succeeded
except Exception as e:
logger.error(
f"Error downloading model from {pretrained_model_name_or_path}. Attempt {attempt + 1} of {retries}. Error: {e}"
)
attempt += 1
if attempt < retries:
logger.info(f"Retrying in {delay} seconds...")
time.sleep(delay) # Wait before retrying
else:
logger.error(
f"Failed to download {pretrained_model_name_or_path} after {retries} attempts."
)
raise e # Reraise exception after max retries
return super().from_pretrained(local_path, *args, **kwargs)
+44
View File
@@ -0,0 +1,44 @@
from typing import List
from surya.common.polygon import PolygonBox
def clean_boxes(boxes: List[PolygonBox]) -> List[PolygonBox]:
new_boxes = []
for box_obj in boxes:
xs = [point[0] for point in box_obj.polygon]
ys = [point[1] for point in box_obj.polygon]
if max(xs) == min(xs) or max(ys) == min(ys):
continue
box = box_obj.bbox
contained = False
for other_box_obj in boxes:
if other_box_obj.polygon == box_obj.polygon:
continue
other_box = other_box_obj.bbox
if box == other_box:
continue
if (
box[0] >= other_box[0]
and box[1] >= other_box[1]
and box[2] <= other_box[2]
and box[3] <= other_box[3]
):
contained = True
break
if not contained:
new_boxes.append(box_obj)
return new_boxes
def expand_bbox(bbox, expansion_factor=0.01):
expansion_low = 1 - expansion_factor
expansion_high = 1 + expansion_factor
return [
bbox[0] * expansion_low,
bbox[1] * expansion_low,
bbox[2] * expansion_high,
bbox[3] * expansion_high,
]
+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
+147
View File
@@ -0,0 +1,147 @@
from concurrent.futures import ThreadPoolExecutor
from typing import List, Generator, Tuple
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
from tqdm import tqdm
from surya.common.predictor import BasePredictor
from surya.detection.loader import DetectionModelLoader
from surya.detection.parallel import FakeExecutor
from surya.detection.util import get_total_splits, split_image
from surya.detection.schema import TextDetectionResult
from surya.settings import settings
from surya.detection.heatmap import parallel_get_boxes
class DetectionPredictor(BasePredictor):
model_loader_cls = DetectionModelLoader
batch_size = settings.DETECTOR_BATCH_SIZE
default_batch_sizes = {"cpu": 8, "mps": 8, "cuda": 36}
def __call__(
self, images: List[Image.Image], batch_size=None, include_maps=False
) -> List[TextDetectionResult]:
detection_generator = self.batch_detection(images, batch_size=batch_size)
postprocessing_futures = []
max_workers = min(settings.DETECTOR_POSTPROCESSING_CPU_WORKERS, len(images))
parallelize = (
not settings.IN_STREAMLIT
and len(images) >= settings.DETECTOR_MIN_PARALLEL_THRESH
)
executor = ThreadPoolExecutor if parallelize else FakeExecutor
with executor(max_workers=max_workers) as e:
for preds, orig_sizes in detection_generator:
for pred, orig_size in zip(preds, orig_sizes):
postprocessing_futures.append(
e.submit(parallel_get_boxes, pred, orig_size, include_maps)
)
return [future.result() for future in postprocessing_futures]
def prepare_image(self, img):
new_size = (self.processor.size["width"], self.processor.size["height"])
# This double resize actually necessary for downstream accuracy
img.thumbnail(new_size, Image.Resampling.LANCZOS)
img = img.resize(
new_size, Image.Resampling.LANCZOS
) # Stretch smaller dimension to fit new size
img = np.asarray(img, dtype=np.uint8)
img = self.processor(img)["pixel_values"][0]
img = torch.from_numpy(img)
return img
def batch_detection(
self, images: List, batch_size=None
) -> Generator[Tuple[List[List[np.ndarray]], List[Tuple[int, int]]], None, None]:
assert all([isinstance(image, Image.Image) for image in images])
if batch_size is None:
batch_size = self.get_batch_size()
heatmap_count = self.model.config.num_labels
orig_sizes = [image.size for image in images]
splits_per_image = [
get_total_splits(size, self.processor.size["height"]) for size in orig_sizes
]
batches = []
current_batch_size = 0
current_batch = []
for i in range(len(images)):
if current_batch_size + splits_per_image[i] > batch_size:
if len(current_batch) > 0:
batches.append(current_batch)
current_batch = []
current_batch_size = 0
current_batch.append(i)
current_batch_size += splits_per_image[i]
if len(current_batch) > 0:
batches.append(current_batch)
for batch_idx in tqdm(
range(len(batches)), desc="Detecting bboxes", disable=self.disable_tqdm
):
batch_image_idxs = batches[batch_idx]
batch_images = [images[j].convert("RGB") for j in batch_image_idxs]
split_index = []
split_heights = []
image_splits = []
for image_idx, image in enumerate(batch_images):
image_parts, split_height = split_image(
image, self.processor.size["height"]
)
image_splits.extend(image_parts)
split_index.extend([image_idx] * len(image_parts))
split_heights.extend(split_height)
image_splits = [self.prepare_image(image) for image in image_splits]
# Batch images in dim 0
batch = torch.stack(image_splits, dim=0).to(self.model.dtype)
with settings.INFERENCE_MODE():
pred = self.model(pixel_values=batch.to(self.model.device))
logits = pred.logits
correct_shape = [
self.processor.size["height"],
self.processor.size["width"],
]
current_shape = list(logits.shape[2:])
if current_shape != correct_shape:
logits = F.interpolate(
logits, size=correct_shape, mode="bilinear", align_corners=False
)
logits = logits.to(torch.float32).cpu().numpy()
preds = []
for i, (idx, height) in enumerate(zip(split_index, split_heights)):
# If our current prediction length is below the image idx, that means we have a new image
# Otherwise, we need to add to the current image
if len(preds) <= idx:
preds.append([logits[i][k] for k in range(heatmap_count)])
else:
heatmaps = preds[idx]
pred_heatmaps = [logits[i][k] for k in range(heatmap_count)]
if height < self.processor.size["height"]:
# Cut off padding to get original height
pred_heatmaps = [
pred_heatmap[:height, :] for pred_heatmap in pred_heatmaps
]
for k in range(heatmap_count):
heatmaps[k] = np.vstack([heatmaps[k], pred_heatmaps[k]])
preds[idx] = heatmaps
yield preds, [orig_sizes[j] for j in batch_image_idxs]
torch.cuda.empty_cache()
+165
View File
@@ -0,0 +1,165 @@
from typing import List
import cv2
import numpy as np
from PIL import Image
from surya.common.util import clean_boxes
from surya.detection import TextDetectionResult
from surya.common.polygon import PolygonBox
from surya.settings import settings
def get_dynamic_thresholds(linemap, text_threshold, low_text, typical_top10_avg=0.7):
# Find average intensity of top 10% pixels
flat_map = linemap.ravel()
top_10_count = int(len(flat_map) * 0.9)
avg_intensity = np.mean(np.partition(flat_map, top_10_count)[top_10_count:])
scaling_factor = np.clip(avg_intensity / typical_top10_avg, 0, 1) ** (1 / 2)
low_text = np.clip(low_text * scaling_factor, 0.1, 0.6)
text_threshold = np.clip(text_threshold * scaling_factor, 0.15, 0.8)
return text_threshold, low_text
def detect_boxes(linemap, text_threshold, low_text):
# From CRAFT - https://github.com/clovaai/CRAFT-pytorch
# Modified to return boxes and for speed, accuracy
img_h, img_w = linemap.shape
text_threshold, low_text = get_dynamic_thresholds(linemap, text_threshold, low_text)
text_score_comb = (linemap > low_text).astype(np.uint8)
label_count, labels, stats, centroids = cv2.connectedComponentsWithStats(
text_score_comb, connectivity=4
)
det = []
confidences = []
max_confidence = 0
for k in range(1, label_count):
# size filtering
size = stats[k, cv2.CC_STAT_AREA]
if size < 10:
continue
# make segmentation map
x, y, w, h = stats[
k,
[cv2.CC_STAT_LEFT, cv2.CC_STAT_TOP, cv2.CC_STAT_WIDTH, cv2.CC_STAT_HEIGHT],
]
try:
niter = int(np.sqrt(min(w, h)))
except ValueError:
niter = 0
buffer = 1
sx, sy = max(0, x - niter - buffer), max(0, y - niter - buffer)
ex, ey = min(img_w, x + w + niter + buffer), min(img_h, y + h + niter + buffer)
mask = labels[sy:ey, sx:ex] == k
selected_linemap = linemap[sy:ey, sx:ex][mask]
if selected_linemap.size == 0:
continue
line_max = np.max(selected_linemap)
# thresholding
if line_max < text_threshold:
continue
segmap = mask.astype(np.uint8)
ksize = buffer + niter
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (ksize, ksize))
selected_segmap = cv2.dilate(segmap, kernel)
# make box
y_inds, x_inds = np.nonzero(selected_segmap)
x_inds += sx
y_inds += sy
np_contours = np.column_stack((x_inds, y_inds))
rectangle = cv2.minAreaRect(np_contours)
box = cv2.boxPoints(rectangle)
# align diamond-shape
w, h = np.linalg.norm(box[0] - box[1]), np.linalg.norm(box[1] - box[2])
box_ratio = max(w, h) / (min(w, h) + 1e-5)
if abs(1 - box_ratio) <= 0.1:
left, right = np_contours[:, 0].min(), np_contours[:, 0].max()
top, bottom = np_contours[:, 1].min(), np_contours[:, 1].max()
box = np.array(
[[left, top], [right, top], [right, bottom], [left, bottom]],
dtype=np.float32,
)
# make clock-wise order
startidx = box.sum(axis=1).argmin()
box = np.roll(box, 4 - startidx, 0)
max_confidence = max(max_confidence, line_max)
confidences.append(line_max)
det.append(box)
if max_confidence > 0:
confidences = [c / max_confidence for c in confidences]
return det, confidences
def get_detected_boxes(textmap, text_threshold=None, low_text=None) -> List[PolygonBox]:
if text_threshold is None:
text_threshold = settings.DETECTOR_TEXT_THRESHOLD
if low_text is None:
low_text = settings.DETECTOR_BLANK_THRESHOLD
if textmap.dtype != np.float32:
textmap = textmap.astype(np.float32)
boxes, confidences = detect_boxes(textmap, text_threshold, low_text)
# From point form to box form
return [
PolygonBox(polygon=box, confidence=confidence)
for box, confidence in zip(boxes, confidences)
]
def get_and_clean_boxes(
textmap, processor_size, image_size, text_threshold=None, low_text=None
) -> List[PolygonBox]:
bboxes = get_detected_boxes(textmap, text_threshold, low_text)
for bbox in bboxes:
bbox.rescale(processor_size, image_size)
bbox.fit_to_bounds([0, 0, image_size[0], image_size[1]])
bboxes = clean_boxes(bboxes)
return bboxes
def parallel_get_boxes(preds, orig_sizes, include_maps=False):
heatmap, affinity_map = preds
heat_img, aff_img = None, None
if include_maps:
heat_img = Image.fromarray((heatmap * 255).astype(np.uint8))
aff_img = Image.fromarray((affinity_map * 255).astype(np.uint8))
heatmap_size = list(reversed(heatmap.shape))
bboxes = get_and_clean_boxes(heatmap, heatmap_size, orig_sizes)
for box in bboxes:
# Skip for vertical boxes
if box.height < 3 * box.width:
box.expand(x_margin=0, y_margin=settings.DETECTOR_BOX_Y_EXPAND_MARGIN)
box.fit_to_bounds(
[0, 0, orig_sizes[0], orig_sizes[1]]
) # Fix any bad expands
result = TextDetectionResult(
bboxes=bboxes,
heatmap=heat_img,
affinity_map=aff_img,
image_bbox=[0, 0, orig_sizes[0], orig_sizes[1]],
)
return result
+53
View File
@@ -0,0 +1,53 @@
from typing import Optional
import torch
from surya.common.load import ModelLoader
from surya.detection.processor import SegformerImageProcessor
from surya.detection.model.config import EfficientViTConfig
from surya.detection.model.encoderdecoder import EfficientViTForSemanticSegmentation
from surya.logging import get_logger
from surya.settings import settings
logger = get_logger()
class DetectionModelLoader(ModelLoader):
def __init__(self, checkpoint: Optional[str] = None):
super().__init__(checkpoint)
if self.checkpoint is None:
self.checkpoint = settings.DETECTOR_MODEL_CHECKPOINT
def model(
self,
device: Optional[torch.device | str] = None,
dtype: Optional[torch.dtype | str] = None,
attention_implementation: Optional[str] = None,
) -> EfficientViTForSemanticSegmentation:
if device is None:
device = settings.TORCH_DEVICE_MODEL
if dtype is None:
dtype = settings.MODEL_DTYPE
config = EfficientViTConfig.from_pretrained(self.checkpoint)
model = EfficientViTForSemanticSegmentation.from_pretrained(
self.checkpoint,
dtype=dtype,
config=config,
)
model = model.to(device)
model = model.eval()
logger.debug(
f"Loaded detection model {self.checkpoint} from {EfficientViTForSemanticSegmentation.get_local_path(self.checkpoint)} onto device {device} with dtype {dtype}"
)
return model
def processor(
self,
device: Optional[torch.device | str] = None,
dtype: Optional[torch.dtype | str] = None,
) -> SegformerImageProcessor:
return SegformerImageProcessor.from_pretrained(self.checkpoint)
View File
+53
View File
@@ -0,0 +1,53 @@
from transformers import PretrainedConfig
from surya.common.s3 import S3DownloaderMixin
class EfficientViTConfig(S3DownloaderMixin, PretrainedConfig):
r"""
```"""
model_type = "efficientvit"
def __init__(
self,
num_classes=2,
num_channels=3,
widths=(32, 64, 128, 256, 512),
head_dim=32,
num_stages=4,
depths=(1, 1, 1, 6, 6),
strides=(2, 2, 2, 2, 2),
hidden_sizes=(32, 64, 160, 256),
patch_size=(7, 7),
hidden_dropout_prob=0.0,
attention_probs_dropout_prob=0.0,
classifier_dropout_prob=0.0,
layer_norm_eps=1e-6,
decoder_layer_hidden_size=128,
decoder_hidden_size=512,
semantic_loss_ignore_index=255,
initializer_range=0.02,
**kwargs,
):
super().__init__(**kwargs)
self.num_classes = num_classes
self.widths = widths
self.head_dim = head_dim
self.num_channels = num_channels
self.num_stages = num_stages
self.depths = depths
self.strides = strides
self.hidden_sizes = hidden_sizes
self.patch_size = patch_size
self.hidden_dropout_prob = hidden_dropout_prob
self.attention_probs_dropout_prob = attention_probs_dropout_prob
self.classifier_dropout_prob = classifier_dropout_prob
self.layer_norm_eps = layer_norm_eps
self.decoder_hidden_size = decoder_hidden_size
self.decoder_layer_hidden_size = decoder_layer_hidden_size
self.semantic_loss_ignore_index = semantic_loss_ignore_index
self.initializer_range = initializer_range
+839
View File
@@ -0,0 +1,839 @@
"""
This is an implementation of efficientvit, with some modifications (decode head, etc).
Original paper at https://arxiv.org/abs/2205.14756
Code adapted from timm, https://github.com/huggingface/pytorch-image-models/blob/main/timm/models/efficientvit_mit.py
Original code (that timm adapted from) at https://github.com/mit-han-lab/efficientvit
License: Apache 2
"""
from __future__ import annotations
from typing import Optional, Union, Tuple, List, Any
from functools import partial
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers.modeling_outputs import SemanticSegmenterOutput
from surya.common.pretrained import SuryaPreTrainedModel
from surya.common.s3 import S3DownloaderMixin
from surya.detection.model.config import EfficientViTConfig
def val2list(x: Union[List, Tuple, Any], repeat_time=1):
if isinstance(x, (list, tuple)):
return list(x)
return [x for _ in range(repeat_time)]
def val2tuple(x: Union[List, Tuple, Any], min_len: int = 1, idx_repeat: int = -1):
# repeat elements if necessary
x = val2list(x)
if len(x) > 0:
x[idx_repeat:idx_repeat] = [x[idx_repeat] for _ in range(min_len - len(x))]
return tuple(x)
def get_same_padding(
kernel_size: Union[int, Tuple[int, ...]],
) -> Union[int, Tuple[int, ...]]:
if isinstance(kernel_size, tuple):
return tuple([get_same_padding(ks) for ks in kernel_size])
else:
assert kernel_size % 2 > 0, "kernel size should be odd number"
return kernel_size // 2
def get_padding(kernel_size: int, stride: int = 1, dilation: int = 1) -> int:
padding = ((stride - 1) + dilation * (kernel_size - 1)) // 2
return padding
class ConvNormAct(nn.Module):
def __init__(
self,
in_channels: int,
out_channels: int,
kernel_size=3,
stride=1,
dilation=1,
groups=1,
bias=False,
dropout=0.0,
norm_layer=nn.BatchNorm2d,
act_layer=nn.ReLU,
):
super(ConvNormAct, self).__init__()
self.dropout = nn.Dropout(dropout, inplace=False)
padding = get_padding(kernel_size, stride, dilation)
self.conv = nn.Conv2d(
in_channels,
out_channels,
kernel_size=kernel_size,
stride=stride,
dilation=dilation,
groups=groups,
bias=bias,
padding=padding,
)
self.norm = (
norm_layer(num_features=out_channels) if norm_layer else nn.Identity()
)
self.act = act_layer(inplace=True) if act_layer is not None else nn.Identity()
def forward(self, x):
x = self.conv(x)
x = self.norm(x)
x = self.act(x)
return x
class DSConv(nn.Module):
def __init__(
self,
in_channels: int,
out_channels: int,
kernel_size=3,
stride=1,
use_bias=False,
norm_layer=(nn.BatchNorm2d, nn.BatchNorm2d),
act_layer=(nn.ReLU6, None),
):
super(DSConv, self).__init__()
use_bias = val2tuple(use_bias, 2)
norm_layer = val2tuple(norm_layer, 2)
act_layer = val2tuple(act_layer, 2)
self.depth_conv = ConvNormAct(
in_channels,
in_channels,
kernel_size,
stride,
groups=in_channels,
norm_layer=norm_layer[0],
act_layer=act_layer[0],
bias=use_bias[0],
)
self.point_conv = ConvNormAct(
in_channels,
out_channels,
1,
norm_layer=norm_layer[1],
act_layer=act_layer[1],
bias=use_bias[1],
)
def forward(self, x):
x = self.depth_conv(x)
x = self.point_conv(x)
return x
class ConvBlock(nn.Module):
def __init__(
self,
in_channels: int,
out_channels: int,
kernel_size=3,
stride=1,
mid_channels=None,
expand_ratio=1,
use_bias=False,
norm_layer=(nn.BatchNorm2d, nn.BatchNorm2d),
act_layer=(nn.ReLU6, None),
):
super(ConvBlock, self).__init__()
use_bias = val2tuple(use_bias, 2)
norm_layer = val2tuple(norm_layer, 2)
act_layer = val2tuple(act_layer, 2)
mid_channels = mid_channels or round(in_channels * expand_ratio)
self.conv1 = ConvNormAct(
in_channels,
mid_channels,
kernel_size,
stride,
norm_layer=norm_layer[0],
act_layer=act_layer[0],
bias=use_bias[0],
)
self.conv2 = ConvNormAct(
mid_channels,
out_channels,
kernel_size,
1,
norm_layer=norm_layer[1],
act_layer=act_layer[1],
bias=use_bias[1],
)
def forward(self, x):
x = self.conv1(x)
x = self.conv2(x)
return x
class MBConv(nn.Module):
def __init__(
self,
in_channels: int,
out_channels: int,
kernel_size=3,
stride=1,
mid_channels=None,
expand_ratio=6,
use_bias=False,
norm_layer=(nn.BatchNorm2d, nn.BatchNorm2d, nn.BatchNorm2d),
act_layer=(nn.ReLU6, nn.ReLU6, None),
):
super(MBConv, self).__init__()
use_bias = val2tuple(use_bias, 3)
norm_layer = val2tuple(norm_layer, 3)
act_layer = val2tuple(act_layer, 3)
mid_channels = mid_channels or round(in_channels * expand_ratio)
self.inverted_conv = ConvNormAct(
in_channels,
mid_channels,
1,
stride=1,
norm_layer=norm_layer[0],
act_layer=act_layer[0],
bias=use_bias[0],
)
self.depth_conv = ConvNormAct(
mid_channels,
mid_channels,
kernel_size,
stride=stride,
groups=mid_channels,
norm_layer=norm_layer[1],
act_layer=act_layer[1],
bias=use_bias[1],
)
self.point_conv = ConvNormAct(
mid_channels,
out_channels,
1,
norm_layer=norm_layer[2],
act_layer=act_layer[2],
bias=use_bias[2],
)
def forward(self, x):
x = self.inverted_conv(x)
x = self.depth_conv(x)
x = self.point_conv(x)
return x
class FusedMBConv(nn.Module):
def __init__(
self,
in_channels: int,
out_channels: int,
kernel_size=3,
stride=1,
mid_channels=None,
expand_ratio=6,
groups=1,
use_bias=False,
norm_layer=(nn.BatchNorm2d, nn.BatchNorm2d),
act_layer=(nn.ReLU6, None),
):
super(FusedMBConv, self).__init__()
use_bias = val2tuple(use_bias, 2)
norm_layer = val2tuple(norm_layer, 2)
act_layer = val2tuple(act_layer, 2)
mid_channels = mid_channels or round(in_channels * expand_ratio)
self.spatial_conv = ConvNormAct(
in_channels,
mid_channels,
kernel_size,
stride=stride,
groups=groups,
norm_layer=norm_layer[0],
act_layer=act_layer[0],
bias=use_bias[0],
)
self.point_conv = ConvNormAct(
mid_channels,
out_channels,
1,
norm_layer=norm_layer[1],
act_layer=act_layer[1],
bias=use_bias[1],
)
def forward(self, x):
x = self.spatial_conv(x)
x = self.point_conv(x)
return x
class LiteMLA(nn.Module):
"""Lightweight multi-scale linear attention"""
def __init__(
self,
in_channels: int,
out_channels: int,
heads: Union[int, None] = None,
heads_ratio: float = 1.0,
dim=8,
use_bias=False,
norm_layer=(None, nn.BatchNorm2d),
act_layer=(None, None),
kernel_func=nn.ReLU,
scales=(5,),
eps=1e-5,
):
super(LiteMLA, self).__init__()
self.eps = eps
heads = heads or int(in_channels // dim * heads_ratio)
total_dim = heads * dim
use_bias = val2tuple(use_bias, 2)
norm_layer = val2tuple(norm_layer, 2)
act_layer = val2tuple(act_layer, 2)
self.dim = dim
self.qkv = ConvNormAct(
in_channels,
3 * total_dim,
1,
bias=use_bias[0],
norm_layer=norm_layer[0],
act_layer=act_layer[0],
)
self.aggreg = nn.ModuleList(
[
nn.Sequential(
nn.Conv2d(
3 * total_dim,
3 * total_dim,
scale,
padding=get_same_padding(scale),
groups=3 * total_dim,
bias=use_bias[0],
),
nn.Conv2d(
3 * total_dim,
3 * total_dim,
1,
groups=3 * heads,
bias=use_bias[0],
),
)
for scale in scales
]
)
self.kernel_func = kernel_func(inplace=False)
self.proj = ConvNormAct(
total_dim * (1 + len(scales)),
out_channels,
1,
bias=use_bias[1],
norm_layer=norm_layer[1],
act_layer=act_layer[1],
)
def _attn(self, q, k, v):
dtype = v.dtype
q, k, v = q.float(), k.float(), v.float()
kv = k.transpose(-1, -2) @ v
out = q @ kv
out = out[..., :-1] / (out[..., -1:] + self.eps)
return out.to(dtype)
def forward(self, x):
# Shape is B, C, H, W
B, _, H, W = x.shape
# generate multi-scale q, k, v
qkv = self.qkv(x)
multi_scale_qkv = [qkv]
for op in self.aggreg:
multi_scale_qkv.append(op(qkv))
multi_scale_qkv = torch.cat(multi_scale_qkv, dim=1)
multi_scale_qkv = multi_scale_qkv.reshape(B, -1, 3 * self.dim, H * W).transpose(
-1, -2
)
# Shape for each is B, C, HW, head_dim
q, k, v = multi_scale_qkv.chunk(3, dim=-1)
# lightweight global attention
q = self.kernel_func(q)
k = self.kernel_func(k)
v = F.pad(v, (0, 1), mode="constant", value=1.0)
out = self._attn(q, k, v)
# final projection
out = out.transpose(-1, -2).reshape(B, -1, H, W)
out = self.proj(out)
return out
class EfficientVitBlock(nn.Module):
def __init__(
self,
in_channels,
heads_ratio=1.0,
head_dim=32,
expand_ratio=4,
norm_layer=nn.BatchNorm2d,
act_layer=nn.Hardswish,
):
super(EfficientVitBlock, self).__init__()
self.context_module = ResidualBlock(
LiteMLA(
in_channels=in_channels,
out_channels=in_channels,
heads_ratio=heads_ratio,
dim=head_dim,
norm_layer=(None, norm_layer),
),
nn.Identity(),
)
self.local_module = ResidualBlock(
MBConv(
in_channels=in_channels,
out_channels=in_channels,
expand_ratio=expand_ratio,
use_bias=(True, True, False),
norm_layer=(None, None, norm_layer),
act_layer=(act_layer, act_layer, None),
),
nn.Identity(),
)
def forward(self, x):
x = self.context_module(x)
x = self.local_module(x)
return x
class ResidualBlock(nn.Module):
def __init__(
self,
main: Optional[nn.Module],
shortcut: Optional[nn.Module] = None,
pre_norm: Optional[nn.Module] = None,
):
super(ResidualBlock, self).__init__()
self.pre_norm = pre_norm if pre_norm is not None else nn.Identity()
self.main = main
self.shortcut = shortcut
def forward(self, x):
res = self.main(self.pre_norm(x))
if self.shortcut is not None:
res = res + self.shortcut(x)
return res
def build_local_block(
in_channels: int,
out_channels: int,
stride: int,
kernel_size: int,
expand_ratio: float,
norm_layer: str,
act_layer: str,
fewer_norm: bool = False,
block_type: str = "default",
):
assert block_type in ["default", "large", "fused"]
if expand_ratio == 1:
if block_type == "default":
block = DSConv(
in_channels=in_channels,
out_channels=out_channels,
stride=stride,
kernel_size=kernel_size,
use_bias=(True, False) if fewer_norm else False,
norm_layer=(None, norm_layer) if fewer_norm else norm_layer,
act_layer=(act_layer, None),
)
else:
block = ConvBlock(
in_channels=in_channels,
out_channels=out_channels,
stride=stride,
kernel_size=kernel_size,
use_bias=(True, False) if fewer_norm else False,
norm_layer=(None, norm_layer) if fewer_norm else norm_layer,
act_layer=(act_layer, None),
)
else:
if block_type == "default":
block = MBConv(
in_channels=in_channels,
out_channels=out_channels,
stride=stride,
kernel_size=kernel_size,
expand_ratio=expand_ratio,
use_bias=(True, True, False) if fewer_norm else False,
norm_layer=(None, None, norm_layer) if fewer_norm else norm_layer,
act_layer=(act_layer, act_layer, None),
)
else:
block = FusedMBConv(
in_channels=in_channels,
out_channels=out_channels,
stride=stride,
kernel_size=kernel_size,
expand_ratio=expand_ratio,
use_bias=(True, False) if fewer_norm else False,
norm_layer=(None, norm_layer) if fewer_norm else norm_layer,
act_layer=(act_layer, None),
)
return block
class Stem(nn.Sequential):
def __init__(
self,
in_chs,
out_chs,
depth,
stride,
norm_layer,
act_layer,
block_type="default",
):
super().__init__()
self.stride = stride
self.add_module(
"in_conv",
ConvNormAct(
in_chs,
out_chs,
kernel_size=stride + 1,
stride=stride,
norm_layer=norm_layer,
act_layer=act_layer,
),
)
stem_block = 0
for _ in range(depth):
self.add_module(
f"res{stem_block}",
ResidualBlock(
build_local_block(
in_channels=out_chs,
out_channels=out_chs,
stride=1,
kernel_size=3,
expand_ratio=1,
norm_layer=norm_layer,
act_layer=act_layer,
block_type=block_type,
),
nn.Identity(),
),
)
stem_block += 1
class EfficientVitLargeStage(nn.Module):
def __init__(
self,
in_chs,
out_chs,
depth,
stride,
norm_layer,
act_layer,
head_dim,
vit_stage=False,
fewer_norm=False,
):
super(EfficientVitLargeStage, self).__init__()
blocks = [
ResidualBlock(
build_local_block(
in_channels=in_chs,
out_channels=out_chs,
stride=stride,
kernel_size=stride + 1,
expand_ratio=24 if vit_stage else 16,
norm_layer=norm_layer,
act_layer=act_layer,
fewer_norm=vit_stage or fewer_norm,
block_type="default" if fewer_norm else "fused",
),
None,
)
]
in_chs = out_chs
if vit_stage:
# for stage 4
for _ in range(depth):
blocks.append(
EfficientVitBlock(
in_channels=in_chs,
head_dim=head_dim,
expand_ratio=6,
norm_layer=norm_layer,
act_layer=act_layer,
)
)
else:
# for stage 1, 2, 3
for i in range(depth):
blocks.append(
ResidualBlock(
build_local_block(
in_channels=in_chs,
out_channels=out_chs,
stride=1,
kernel_size=3,
expand_ratio=4,
norm_layer=norm_layer,
act_layer=act_layer,
fewer_norm=fewer_norm,
block_type="default" if fewer_norm else "fused",
),
nn.Identity(),
)
)
self.blocks = nn.Sequential(*blocks)
def forward(self, x):
return self.blocks(x)
class EfficientVitLarge(nn.Module):
def __init__(
self,
config: EfficientViTConfig,
norm_layer=nn.BatchNorm2d,
act_layer=nn.Hardswish,
):
super(EfficientVitLarge, self).__init__()
self.grad_checkpointing = False
self.num_classes = config.num_classes
self.norm_eps = config.layer_norm_eps
norm_layer = partial(norm_layer, eps=self.norm_eps)
# input stem
self.stem = Stem(
config.num_channels,
config.widths[0],
config.depths[0],
config.strides[0],
norm_layer,
act_layer,
block_type="large",
)
stride = config.strides[0]
# stages
self.feature_info = []
self.stages = nn.Sequential()
in_channels = config.widths[0]
for i, (w, d, s) in enumerate(
zip(config.widths[1:], config.depths[1:], config.strides[1:])
):
self.stages.append(
EfficientVitLargeStage(
in_channels,
w,
depth=d,
stride=s,
norm_layer=norm_layer,
act_layer=act_layer,
head_dim=config.head_dim,
vit_stage=i >= 3,
fewer_norm=i >= 2,
)
)
stride *= s
in_channels = w
self.feature_info += [
dict(num_chs=in_channels, reduction=stride, module=f"stages.{i}")
]
self.num_features = in_channels
@torch.jit.ignore
def set_grad_checkpointing(self, enable=True):
self.grad_checkpointing = enable
def forward(self, x):
x = self.stem(x)
encoder_hidden_states = []
for i, module in enumerate(self.stages):
x = module(x)
encoder_hidden_states.append(x)
return encoder_hidden_states
class EfficientViTPreTrainedModel(SuryaPreTrainedModel):
"""
An abstract class to handle weights initialization and a simple interface for downloading and loading pretrained
models.
"""
config_class = EfficientViTConfig
base_model_prefix = "efficientvit"
main_input_name = "pixel_values"
def _init_weights(self, module):
"""Initialize the weights"""
if isinstance(module, (nn.Linear, nn.Conv2d)):
# Slightly different from the TF version which uses truncated_normal for initialization
# cf https://github.com/pytorch/pytorch/pull/5617
module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
if module.bias is not None:
module.bias.data.zero_()
elif isinstance(module, nn.Embedding):
module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
if module.padding_idx is not None:
module.weight.data[module.padding_idx].zero_()
elif isinstance(module, nn.LayerNorm):
module.bias.data.zero_()
module.weight.data.fill_(1.0)
class DecodeMLP(nn.Module):
def __init__(self, input_dim, output_dim):
super().__init__()
self.proj = nn.Linear(input_dim, output_dim)
def forward(self, hidden_states: torch.Tensor):
# Input is B, C, H, W
hidden_states = hidden_states.flatten(2).transpose(1, 2)
# Output is B, HW, C
hidden_states = self.proj(hidden_states)
return hidden_states
class DecodeHead(EfficientViTPreTrainedModel):
def __init__(self, config: EfficientViTConfig):
super().__init__(config)
# linear layers which will unify the channel dimension of each of the encoder blocks to the same config.decoder_hidden_size
mlps = []
for width in config.widths[1:]:
mlp = DecodeMLP(
input_dim=width, output_dim=config.decoder_layer_hidden_size
)
mlps.append(mlp)
self.linear_c = nn.ModuleList(mlps)
# the following 3 layers implement the ConvModule of the original implementation
self.linear_fuse = nn.Conv2d(
in_channels=config.decoder_layer_hidden_size * config.num_stages,
out_channels=config.decoder_hidden_size,
kernel_size=1,
bias=False,
)
self.batch_norm = nn.BatchNorm2d(config.decoder_hidden_size)
self.activation = nn.ReLU()
self.dropout = nn.Dropout(config.classifier_dropout_prob)
self.classifier = nn.Conv2d(
config.decoder_hidden_size, config.num_labels, kernel_size=1
)
self.config = config
def forward(self, encoder_hidden_states: torch.FloatTensor) -> torch.Tensor:
batch_size = encoder_hidden_states[-1].shape[0]
all_hidden_states = ()
for encoder_hidden_state, mlp in zip(encoder_hidden_states, self.linear_c):
height, width = encoder_hidden_state.shape[2], encoder_hidden_state.shape[3]
encoder_hidden_state = mlp(encoder_hidden_state) # Output is B, HW, C
# Permute to B, C, HW
encoder_hidden_state = encoder_hidden_state.permute(0, 2, 1)
encoder_hidden_state = encoder_hidden_state.reshape(
batch_size, -1, height, width
)
# upsample
encoder_hidden_state = nn.functional.interpolate(
encoder_hidden_state,
size=encoder_hidden_states[0].size()[2:],
mode="bilinear",
align_corners=False,
)
all_hidden_states += (encoder_hidden_state,)
hidden_states = self.linear_fuse(torch.cat(all_hidden_states[::-1], dim=1))
hidden_states = self.batch_norm(hidden_states)
hidden_states = self.activation(hidden_states)
# logits are of shape (batch_size, num_labels, height/4, width/4)
logits = self.classifier(hidden_states)
return logits
class EfficientViTForSemanticSegmentation(
S3DownloaderMixin, EfficientViTPreTrainedModel
):
def __init__(self, config, **kwargs):
super().__init__(config)
self.vit = EfficientVitLarge(config)
self.decode_head = DecodeHead(config)
# Initialize weights and apply final processing
self.post_init()
def forward(
self, pixel_values: torch.FloatTensor
) -> Union[Tuple, SemanticSegmenterOutput]:
# Pixel values should be B,C,H,W
encoder_hidden_states = self.vit(
pixel_values,
)
logits = self.decode_head(encoder_hidden_states)
# Apply sigmoid to get 0-1 output
logits = torch.special.expit(logits)
return SemanticSegmenterOutput(
loss=None, logits=logits, hidden_states=encoder_hidden_states
)
class EfficientViTForSemanticLayoutSegmentation(EfficientViTPreTrainedModel):
def __init__(self, config, **kwargs):
super().__init__(config, **kwargs)
self.vit = EfficientVitLarge(config)
self.decode_head = DecodeHead(config)
# Initialize weights and apply final processing
self.post_init()
def forward(
self, pixel_values: torch.FloatTensor
) -> Union[Tuple, SemanticSegmenterOutput]:
# Pixel values should be B,C,H,W
encoder_hidden_states = self.vit(
pixel_values,
)
logits = self.decode_head(encoder_hidden_states)
# Apply sigmoid to get 0-1 output
logits = torch.special.expit(logits)
return SemanticSegmenterOutput(
loss=None, logits=logits, hidden_states=encoder_hidden_states
)
+19
View File
@@ -0,0 +1,19 @@
class FakeFuture:
def __init__(self, func, *args, **kwargs):
self._result = func(*args, **kwargs)
def result(self):
return self._result
class FakeExecutor:
def __init__(self, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *excinfo):
pass
def submit(self, fn, *args, **kwargs):
return FakeFuture(fn, *args, **kwargs)
+317
View File
@@ -0,0 +1,317 @@
# coding=utf-8
# Copyright 2022 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Modified image processor class for Segformer based on transformers"""
import warnings
from typing import Any, Dict, List, Optional, Union
import numpy as np
from transformers.image_processing_utils import (
BaseImageProcessor,
BatchFeature,
get_size_dict,
)
from transformers.image_transforms import to_channel_dimension_format
from transformers.image_utils import (
IMAGENET_DEFAULT_MEAN,
IMAGENET_DEFAULT_STD,
ChannelDimension,
ImageInput,
PILImageResampling,
infer_channel_dimension_format,
make_list_of_images,
)
from transformers.utils import TensorType
import PIL.Image
from surya.common.s3 import S3DownloaderMixin
class SegformerImageProcessor(S3DownloaderMixin, BaseImageProcessor):
r"""
Constructs a Segformer image processor.
Args:
do_resize (`bool`, *optional*, defaults to `True`):
Whether to resize the image's (height, width) dimensions to the specified `(size["height"],
size["width"])`. Can be overridden by the `do_resize` parameter in the `preprocess` method.
size (`Dict[str, int]` *optional*, defaults to `{"height": 512, "width": 512}`):
Size of the output image after resizing. Can be overridden by the `size` parameter in the `preprocess`
method.
resample (`PILImageResampling`, *optional*, defaults to `Resampling.BILINEAR`):
Resampling filter to use if resizing the image. Can be overridden by the `resample` parameter in the
`preprocess` method.
do_rescale (`bool`, *optional*, defaults to `True`):
Whether to rescale the image by the specified scale `rescale_factor`. Can be overridden by the `do_rescale`
parameter in the `preprocess` method.
rescale_factor (`int` or `float`, *optional*, defaults to `1/255`):
Whether to normalize the image. Can be overridden by the `do_normalize` parameter in the `preprocess`
method.
do_normalize (`bool`, *optional*, defaults to `True`):
Whether to normalize the image. Can be overridden by the `do_normalize` parameter in the `preprocess`
method.
image_mean (`float` or `List[float]`, *optional*, defaults to `IMAGENET_STANDARD_MEAN`):
Mean to use if normalizing the image. This is a float or list of floats the length of the number of
channels in the image. Can be overridden by the `image_mean` parameter in the `preprocess` method.
image_std (`float` or `List[float]`, *optional*, defaults to `IMAGENET_STANDARD_STD`):
Standard deviation to use if normalizing the image. This is a float or list of floats the length of the
number of channels in the image. Can be overridden by the `image_std` parameter in the `preprocess` method.
do_reduce_labels (`bool`, *optional*, defaults to `False`):
Whether or not to reduce all label values of segmentation maps by 1. Usually used for datasets where 0 is
used for background, and background itself is not included in all classes of a dataset (e.g. ADE20k). The
background label will be replaced by 255. Can be overridden by the `do_reduce_labels` parameter in the
`preprocess` method.
"""
model_input_names = ["pixel_values"]
def __init__(
self,
do_resize: bool = True,
size: Dict[str, int] = None,
resample: PILImageResampling = PILImageResampling.BILINEAR,
do_rescale: bool = True,
rescale_factor: Union[int, float] = 1 / 255,
do_normalize: bool = True,
image_mean: Optional[Union[float, List[float]]] = None,
image_std: Optional[Union[float, List[float]]] = None,
do_reduce_labels: bool = False,
**kwargs,
) -> None:
if "reduce_labels" in kwargs:
warnings.warn(
"The `reduce_labels` parameter is deprecated and will be removed in a future version. Please use "
"`do_reduce_labels` instead.",
FutureWarning,
)
do_reduce_labels = kwargs.pop("reduce_labels")
super().__init__(**kwargs)
size = size if size is not None else {"height": 512, "width": 512}
size = get_size_dict(size)
self.do_resize = do_resize
self.size = size
self.resample = resample
self.do_rescale = do_rescale
self.rescale_factor = rescale_factor
self.do_normalize = do_normalize
self.image_mean = (
image_mean if image_mean is not None else IMAGENET_DEFAULT_MEAN
)
self.image_std = image_std if image_std is not None else IMAGENET_DEFAULT_STD
self.do_reduce_labels = do_reduce_labels
self._valid_processor_keys = [
"images",
"segmentation_maps",
"do_resize",
"size",
"resample",
"do_rescale",
"rescale_factor",
"do_normalize",
"image_mean",
"image_std",
"do_reduce_labels",
"return_tensors",
"data_format",
"input_data_format",
]
@classmethod
def from_dict(cls, image_processor_dict: Dict[str, Any], **kwargs):
"""
Overrides the `from_dict` method from the base class to make sure `do_reduce_labels` is updated if image
processor is created using from_dict and kwargs e.g. `SegformerImageProcessor.from_pretrained(checkpoint,
reduce_labels=True)`
"""
image_processor_dict = image_processor_dict.copy()
if "reduce_labels" in kwargs:
image_processor_dict["reduce_labels"] = kwargs.pop("reduce_labels")
return super().from_dict(image_processor_dict, **kwargs)
def _preprocess(
self,
image: ImageInput,
do_resize: bool,
do_rescale: bool,
do_normalize: bool,
size: Optional[Dict[str, int]] = None,
resample: PILImageResampling = None,
rescale_factor: Optional[float] = None,
image_mean: Optional[Union[float, List[float]]] = None,
image_std: Optional[Union[float, List[float]]] = None,
input_data_format: Optional[Union[str, ChannelDimension]] = None,
):
if do_rescale:
image = self.rescale(
image=image, scale=rescale_factor, input_data_format=input_data_format
)
if do_normalize:
image = self.normalize(
image=image,
mean=image_mean,
std=image_std,
input_data_format=input_data_format,
)
return image
def _preprocess_image(
self,
image: ImageInput,
do_resize: bool = None,
size: Dict[str, int] = None,
resample: PILImageResampling = None,
do_rescale: bool = None,
rescale_factor: float = None,
do_normalize: bool = None,
image_mean: Optional[Union[float, List[float]]] = None,
image_std: Optional[Union[float, List[float]]] = None,
data_format: Optional[Union[str, ChannelDimension]] = None,
input_data_format: Optional[Union[str, ChannelDimension]] = None,
) -> np.ndarray:
"""Preprocesses a single image."""
# All transformations expect numpy arrays.
if input_data_format is None:
input_data_format = infer_channel_dimension_format(image)
image = self._preprocess(
image=image,
do_resize=do_resize,
size=size,
resample=resample,
do_rescale=do_rescale,
rescale_factor=rescale_factor,
do_normalize=do_normalize,
image_mean=image_mean,
image_std=image_std,
input_data_format=input_data_format,
)
if data_format is not None:
image = to_channel_dimension_format(
image, data_format, input_channel_dim=input_data_format
)
return image
def __call__(self, images, segmentation_maps=None, **kwargs):
"""
Preprocesses a batch of images and optionally segmentation maps.
Overrides the `__call__` method of the `Preprocessor` class so that both images and segmentation maps can be
passed in as positional arguments.
"""
return super().__call__(images, segmentation_maps=segmentation_maps, **kwargs)
def preprocess(
self,
images: ImageInput,
segmentation_maps: Optional[ImageInput] = None,
do_resize: Optional[bool] = None,
size: Optional[Dict[str, int]] = None,
resample: PILImageResampling = None,
do_rescale: Optional[bool] = None,
rescale_factor: Optional[float] = None,
do_normalize: Optional[bool] = None,
image_mean: Optional[Union[float, List[float]]] = None,
image_std: Optional[Union[float, List[float]]] = None,
do_reduce_labels: Optional[bool] = None,
return_tensors: Optional[Union[str, TensorType]] = None,
data_format: ChannelDimension = ChannelDimension.FIRST,
input_data_format: Optional[Union[str, ChannelDimension]] = None,
**kwargs,
) -> PIL.Image.Image:
"""
Preprocess an image or batch of images.
Args:
images (`ImageInput`):
Image to preprocess. Expects a single or batch of images with pixel values ranging from 0 to 255. If
passing in images with pixel values between 0 and 1, set `do_rescale=False`.
segmentation_maps (`ImageInput`, *optional*):
Segmentation map to preprocess.
do_resize (`bool`, *optional*, defaults to `self.do_resize`):
Whether to resize the image.
size (`Dict[str, int]`, *optional*, defaults to `self.size`):
Size of the image after `resize` is applied.
resample (`int`, *optional*, defaults to `self.resample`):
Resampling filter to use if resizing the image. This can be one of the enum `PILImageResampling`, Only
has an effect if `do_resize` is set to `True`.
do_rescale (`bool`, *optional*, defaults to `self.do_rescale`):
Whether to rescale the image values between [0 - 1].
rescale_factor (`float`, *optional*, defaults to `self.rescale_factor`):
Rescale factor to rescale the image by if `do_rescale` is set to `True`.
do_normalize (`bool`, *optional*, defaults to `self.do_normalize`):
Whether to normalize the image.
image_mean (`float` or `List[float]`, *optional*, defaults to `self.image_mean`):
Image mean.
image_std (`float` or `List[float]`, *optional*, defaults to `self.image_std`):
Image standard deviation.
do_reduce_labels (`bool`, *optional*, defaults to `self.do_reduce_labels`):
Whether or not to reduce all label values of segmentation maps by 1. Usually used for datasets where 0
is used for background, and background itself is not included in all classes of a dataset (e.g.
ADE20k). The background label will be replaced by 255.
return_tensors (`str` or `TensorType`, *optional*):
The type of tensors to return. Can be one of:
- Unset: Return a list of `np.ndarray`.
- `TensorType.TENSORFLOW` or `'tf'`: Return a batch of type `tf.Tensor`.
- `TensorType.PYTORCH` or `'pt'`: Return a batch of type `torch.Tensor`.
- `TensorType.NUMPY` or `'np'`: Return a batch of type `np.ndarray`.
- `TensorType.JAX` or `'jax'`: Return a batch of type `jax.numpy.ndarray`.
data_format (`ChannelDimension` or `str`, *optional*, defaults to `ChannelDimension.FIRST`):
The channel dimension format for the output image. Can be one of:
- `ChannelDimension.FIRST`: image in (num_channels, height, width) format.
- `ChannelDimension.LAST`: image in (height, width, num_channels) format.
input_data_format (`ChannelDimension` or `str`, *optional*):
The channel dimension format for the input image. If unset, the channel dimension format is inferred
from the input image. Can be one of:
- `"channels_first"` or `ChannelDimension.FIRST`: image in (num_channels, height, width) format.
- `"channels_last"` or `ChannelDimension.LAST`: image in (height, width, num_channels) format.
- `"none"` or `ChannelDimension.NONE`: image in (height, width) format.
"""
do_resize = do_resize if do_resize is not None else self.do_resize
do_rescale = do_rescale if do_rescale is not None else self.do_rescale
do_normalize = do_normalize if do_normalize is not None else self.do_normalize
resample = resample if resample is not None else self.resample
size = size if size is not None else self.size
rescale_factor = (
rescale_factor if rescale_factor is not None else self.rescale_factor
)
image_mean = image_mean if image_mean is not None else self.image_mean
image_std = image_std if image_std is not None else self.image_std
images = make_list_of_images(images)
images = [
self._preprocess_image(
image=img,
do_resize=do_resize,
resample=resample,
size=size,
do_rescale=do_rescale,
rescale_factor=rescale_factor,
do_normalize=do_normalize,
image_mean=image_mean,
image_std=image_std,
data_format=data_format,
input_data_format=input_data_format,
)
for img in images
]
data = {"pixel_values": images}
return BatchFeature(data=data, tensor_type=return_tensors)
+12
View File
@@ -0,0 +1,12 @@
from typing import List, Optional, Any
from pydantic import BaseModel
from surya.common.polygon import PolygonBox
class TextDetectionResult(BaseModel):
bboxes: List[PolygonBox]
heatmap: Optional[Any]
affinity_map: Optional[Any]
image_bbox: List[float]
+36
View File
@@ -0,0 +1,36 @@
import math
from PIL import ImageOps
from surya.settings import settings
def get_total_splits(image_size, height):
img_height = list(image_size)[1]
max_height = settings.DETECTOR_IMAGE_CHUNK_HEIGHT
if img_height > max_height:
num_splits = math.ceil(img_height / height)
return num_splits
return 1
def split_image(img, height):
# This will not modify/return the original image - it will either crop, or copy the image
img_height = list(img.size)[1]
max_height = settings.DETECTOR_IMAGE_CHUNK_HEIGHT
if img_height > max_height:
num_splits = math.ceil(img_height / height)
splits = []
split_heights = []
for i in range(num_splits):
top = i * height
bottom = (i + 1) * height
if bottom > img_height:
bottom = img_height
cropped = img.crop((0, top, img.size[0], bottom))
chunk_height = bottom - top
if chunk_height < height:
cropped = ImageOps.pad(cropped, (img.size[0], height), color=255, centering=(0, 0))
splits.append(cropped)
split_heights.append(chunk_height)
return splits, split_heights
return [img.copy()], [img_height]
View File
+97
View File
@@ -0,0 +1,97 @@
import json
import logging
import logging.config
import time
import uuid
import yaml
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from surya.endpoint.legacy import router as legacy_router
from surya.endpoint.openai import router as openai_router
from surya.endpoint.service import _error_response
with open("logger.yaml", "r", encoding="utf-8") as f:
config = yaml.safe_load(f.read())
logging.config.dictConfig(config)
logger = logging.getLogger(__name__)
app = FastAPI(
title="OCR SERVICE API SERVICE",
version="1.0",
docs_url="/v1/api/ai/swagger",
openapi_url="/v1/api/ai/openapi.json",
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
request_id = getattr(request.state, "request_id", "-")
logger.warning(
"request_validation_failed request_id=%s method=%s path=%s errors=%s",
request_id,
request.method,
request.url.path,
exc.errors(),
)
return JSONResponse(
status_code=200,
content={
"data": [],
"message": json.dumps({"error info": exc.errors()}, default=str),
"code": 422,
},
)
@app.middleware("http")
async def allow_openapi_unauthorized(request: Request, call_next):
if request.url.path == "/v1/api/ai/openapi.json":
response = await call_next(request)
return response
return await call_next(request)
@app.middleware("http")
async def log_request_response(request, call_next):
request_id = uuid.uuid4().hex
request.state.request_id = request_id
start = time.perf_counter()
client = request.client.host if request.client else "-"
logger.info(
"request_start request_id=%s method=%s path=%s client=%s",
request_id,
request.method,
request.url.path,
client,
)
try:
response = await call_next(request)
except Exception as e:
duration_ms = (time.perf_counter() - start) * 1000
logger.exception(
"request_unhandled_exception request_id=%s method=%s path=%s duration_ms=%.2f",
request_id,
request.method,
request.url.path,
duration_ms,
exc_info=True,
)
return JSONResponse(status_code=500, content=_error_response(e))
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
"request_end request_id=%s method=%s path=%s status_code=%s duration_ms=%.2f",
request_id,
request.method,
request.url.path,
response.status_code,
duration_ms,
)
return response
app.include_router(legacy_router)
app.include_router(openai_router)
+157
View File
@@ -0,0 +1,157 @@
import io
import time
from fastapi import APIRouter, File, Request, UploadFile
from PIL import Image
from tools import (
ocr,
text_detection,
layout_detection,
table_recognition,
extract_text_from_image,
)
from vllm_batcher import vllm_ocr_batcher
from vllm_tools import vllm_backend_info
from surya.endpoint.schemas import ApiResponse, Info
from surya.endpoint.service import (
_dump_model_or_list,
_error_response,
_load_base64_image,
_log_exception,
_ocr_response_data,
_success_response,
logger,
)
router = APIRouter()
@router.post("/home")
def home():
return "<h1>Welcome to SURYA OCR API!</h1>"
@router.post("/v1/api/ai/suya_ocr", include_in_schema=False, response_model=ApiResponse)
@router.post("/v1/api/ai/suya_ocr/", response_model=ApiResponse)
def run_ocr(p: Info, request: Request):
try:
pil_image, pil_image_highres = _load_base64_image(p)
rec_img, pred, box_img = ocr(
pil_image,
pil_image_highres,
p.skip_text_detection,
p.recognize_math,
with_bboxes=p.ocr_with_boxes,
)
return _success_response(_ocr_response_data(pred))
except Exception as e:
results = _error_response(e)
_log_exception("suya_ocr", request, e, results)
return results
@router.get("/v1/api/ai/suya_ocr_vllm/health", response_model=ApiResponse)
async def suya_ocr_vllm_health():
return _success_response(vllm_backend_info())
@router.post("/v1/api/ai/suya_ocr_vllm", include_in_schema=False, response_model=ApiResponse)
@router.post("/v1/api/ai/suya_ocr_vllm/", response_model=ApiResponse)
def run_ocr_vllm(p: Info, request: Request):
try:
pil_image, pil_image_highres = _load_base64_image(p)
start = time.perf_counter()
_, pred, _ = vllm_ocr_batcher.submit(
pil_image,
pil_image_highres,
skip_text_detection=p.skip_text_detection,
recognize_math=p.recognize_math,
with_bboxes=False,
request_id=getattr(request.state, "request_id", "-"),
)
logger.info(
"api_vllm_submit_wait_complete request_id=%s duration_ms=%.2f",
getattr(request.state, "request_id", "-"),
(time.perf_counter() - start) * 1000,
)
return _success_response(_ocr_response_data(pred))
except Exception as e:
results = _error_response(e)
_log_exception("suya_ocr_vllm", request, e, results)
return results
@router.post("/v1/api/ai/suya_text_det", include_in_schema=False, response_model=ApiResponse)
@router.post("/v1/api/ai/suya_text_det/", response_model=ApiResponse)
def run_text_det(p: Info, request: Request):
try:
pil_image, _ = _load_base64_image(p)
det_img, text_pred = text_detection(pil_image)
text_lines = text_pred.model_dump(exclude=["heatmap", "affinity_map"])
return _success_response({"text_lines": text_lines})
except Exception as e:
results = _error_response(e)
_log_exception("suya_text_det", request, e, results)
return results
@router.post("/v1/api/ai/suya_layout_det", include_in_schema=False, response_model=ApiResponse)
@router.post("/v1/api/ai/suya_layout_det/", response_model=ApiResponse)
def run_layout_det(p: Info, request: Request):
try:
pil_image, _ = _load_base64_image(p)
layout_img, pred = layout_detection(pil_image)
text_lines = pred.model_dump(exclude=["segmentation_map"])
return _success_response({"text_lines": text_lines})
except Exception as e:
results = _error_response(e)
_log_exception("suya_layout_det", request, e, results)
return results
@router.post("/v1/api/ai/suya_table_rec", include_in_schema=False, response_model=ApiResponse)
@router.post("/v1/api/ai/suya_table_rec/", response_model=ApiResponse)
def run_table_rec(p: Info, request: Request):
try:
pil_image, pil_image_highres = _load_base64_image(p)
table_img, pred = table_recognition(
pil_image, pil_image_highres, p.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([p.text for p in pred.text_lines])
return _success_response({"ocr_text_json": text_json, "text_lines": text_lines})
except Exception as e:
results = _error_response(e)
_log_exception("suya_table_rec", request, e, results)
return results
@router.post("/image2text", response_model=ApiResponse)
async def image_to_text(request: Request, file: UploadFile = File(...)):
try:
contents = await file.read()
image = Image.open(io.BytesIO(contents))
logger.info(
"image2text_upload request_id=%s filename=%s content_type=%s size_bytes=%s image_size=%s",
getattr(request.state, "request_id", "-"),
file.filename,
file.content_type,
len(contents),
image.size,
)
full_text = extract_text_from_image(image)
return _success_response(full_text)
except Exception as e:
results = _error_response(e)
_log_exception("image2text", request, e, results)
return results
+108
View File
@@ -0,0 +1,108 @@
import time
import uuid
from typing import Any, Dict, List, Literal, Optional, Union
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict
from surya.endpoint import service
router = APIRouter()
class _ImageUrl(BaseModel):
model_config = ConfigDict(extra="allow")
url: str
class _ContentPart(BaseModel):
model_config = ConfigDict(extra="allow")
type: str
image_url: Optional[_ImageUrl] = None
text: Optional[str] = None
class _ChatMessage(BaseModel):
model_config = ConfigDict(extra="allow")
role: str
content: Union[str, List[_ContentPart]]
class ChatCompletionRequest(BaseModel):
# The OpenAI SDK merges `extra_body={...}` into the TOP LEVEL of the request
# JSON, so the OCR control flags live here rather than nested under a key.
model_config = ConfigDict(extra="allow")
model: str = "surya-ocr"
messages: List[_ChatMessage]
stream: Optional[bool] = False
skip_text_detection: bool = False
recognize_math: bool = False
skip_table_detection: bool = False
ocr_with_boxes: bool = True
mode: Literal["block", "full_page", "table"] = "block"
def _openai_error(message: str, err_type: str, status_code: int) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={"error": {"message": message, "type": err_type, "code": None}},
)
def _build_chat_completion(
content: str, model: str, ocr_json: Any, elapsed: Optional[float]
) -> Dict[str, Any]:
# Token accounting happens inside vLLM and is not surfaced to this layer, so
# usage counts are reported as 0 (prompt_tokens intentionally 0).
return {
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": content},
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"surya": {"ocr_text_json": ocr_json, "elapsed_seconds": elapsed},
}
@router.post("/v1/chat/completions")
def chat_completions(req: ChatCompletionRequest, request: Request):
request_id = getattr(request.state, "request_id", "-")
if req.stream:
return _openai_error(
"streaming is not supported by this OCR endpoint",
"invalid_request_error",
400,
)
try:
image = service.extract_last_image(req.messages)
except service.OcrRequestError as exc:
return _openai_error(str(exc), "invalid_request_error", 400)
try:
if req.mode == "table":
data = service.table_image(image, req.skip_table_detection)
else:
skip = req.skip_text_detection or req.mode == "full_page"
data = service.ocr_via_batcher(
image,
skip_text_detection=skip,
recognize_math=req.recognize_math,
request_id=request_id,
)
except service.OcrRequestError as exc:
return _openai_error(str(exc), "invalid_request_error", 400)
except Exception as exc:
service._log_exception("chat_completions", request, exc, {})
return _openai_error(str(exc), "internal_error", 500)
content = data.get("text_lines", "") or ""
ocr_json = data.get("ocr_text_json") if req.ocr_with_boxes else None
return _build_chat_completion(content, req.model, ocr_json, data.get("elapsed_seconds"))
+46
View File
@@ -0,0 +1,46 @@
import base64
import binascii
from typing import Any, Literal, Optional
from pydantic import (
BaseModel,
Field,
PrivateAttr,
StrictBool,
field_validator,
model_validator,
)
class Info(BaseModel):
_decoded_file: bytes = PrivateAttr(default=b"")
file: str # base64 string
type: Literal["png", "jpg", "jpeg", "gif"] # image type like 'png', 'jpg'
skip_text_detection: Optional[StrictBool] = Field(False)
skip_table_detection: Optional[StrictBool] = Field(False)
recognize_math: Optional[StrictBool] = Field(False)
ocr_with_boxes: Optional[StrictBool] = Field(True)
@field_validator("type", mode="before")
@classmethod
def normalize_type(cls, value: str) -> str:
if not isinstance(value, str):
raise ValueError("type must be a string")
return value.lower()
@model_validator(mode="after")
def decode_base64_file(self):
if not self.file:
raise ValueError("file must be a non-empty base64 string")
try:
self._decoded_file = base64.b64decode(self.file, validate=True)
except binascii.Error as exc:
raise ValueError("file must be valid base64") from exc
return self
class ApiResponse(BaseModel):
data: Any
message: str
code: int
+166
View File
@@ -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}
+116
View File
@@ -0,0 +1,116 @@
"""Surya inference manager.
One process owns one SuryaInferenceManager. The manager wraps a single backend
(vllm | llamacpp) which speaks OpenAI-compatible chat completions.
Predictors take the manager via explicit injection at construction time.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from typing import List, Optional
from surya.inference.backends.base import Backend
from surya.inference.schema import BatchInputItem, BatchOutputItem
from surya.logging import get_logger
from surya.settings import settings
from surya.timing import timing_span
logger = get_logger()
def _has_nvidia_gpu() -> bool:
"""True if an NVIDIA GPU is present on this host.
We deliberately do *not* rely solely on ``torch.cuda.is_available()``:
the installed torch wheel's CUDA build can be newer than the host driver
(PyPI's default wheel tracks the latest CUDA), in which case torch reports
no CUDA even on a perfectly good GPU box. That would silently route us to
the CPU llama.cpp backend on a machine that should be running vllm. So we
take torch's word when it *does* see CUDA, and otherwise fall back to
probing for the GPU directly via ``nvidia-smi``.
"""
try:
import torch
if torch.cuda.is_available():
return True
except Exception:
pass
# Instant, load-independent check: the NVIDIA device node only exists when
# a GPU + driver are present. Preferred over nvidia-smi because nvidia-smi
# can block for several seconds on a GPU under heavy load, which would race
# a timeout and falsely report "no GPU".
if os.path.exists("/dev/nvidia0"):
return True
nvidia_smi = shutil.which("nvidia-smi")
if not nvidia_smi:
return False
try:
result = subprocess.run(
[nvidia_smi, "-L"], capture_output=True, text=True, timeout=15
)
return result.returncode == 0 and "GPU" in result.stdout
except Exception:
return False
def _autodetect_backend() -> str:
if settings.SURYA_INFERENCE_BACKEND:
return settings.SURYA_INFERENCE_BACKEND
# NVIDIA GPU → vllm, mps/cpu → llamacpp
if _has_nvidia_gpu():
return "vllm"
return "llamacpp"
def _build_backend(method: str) -> Backend:
method = method.lower()
if method == "vllm":
from surya.inference.backends.vllm import VllmBackend
return VllmBackend()
if method == "llamacpp":
from surya.inference.backends.llamacpp import LlamaCppBackend
return LlamaCppBackend()
raise ValueError(
f"Unknown inference backend {method!r}. Supported: 'vllm', 'llamacpp'."
)
class SuryaInferenceManager:
"""Single entry point for VLM inference. Construct once per process."""
def __init__(self, method: Optional[str] = None, lazy: bool = True):
self.method = method or _autodetect_backend()
self.backend: Backend = _build_backend(self.method)
if not lazy:
self.backend.start()
def start(self) -> None:
self.backend.start()
def stop(self) -> None:
self.backend.stop()
def generate(self, batch: List[BatchInputItem]) -> List[BatchOutputItem]:
with timing_span("surya_manager_generate", backend=self.method, item_count=len(batch)):
return self.backend.generate(batch)
# Module-level lazy singleton for callers that don't want explicit construction
# (notebooks, ad-hoc scripts). Surya's own models.py and marker should use
# explicit construction.
_default_manager: Optional[SuryaInferenceManager] = None
def get_default_manager() -> SuryaInferenceManager:
global _default_manager
if _default_manager is None:
_default_manager = SuryaInferenceManager()
return _default_manager
+2
View File
@@ -0,0 +1,2 @@
from surya.inference.backends.base import Backend as Backend
from surya.inference.backends.base import ServerHandle as ServerHandle
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List
from surya.inference.schema import BatchInputItem, BatchOutputItem
@dataclass
class ServerHandle:
base_url: str # e.g. "http://127.0.0.1:8765/v1"
model_name: str # what gets passed in OpenAI `model` field
spawned_by_us: bool # if True, we manage atexit cleanup
class Backend:
"""Abstract backend. Concrete backends own server lifecycle + generation."""
name: str # "vllm" | "llamacpp"
def start(self) -> ServerHandle:
"""Idempotent: probe → attach if alive, else spawn. Returns handle."""
raise NotImplementedError
def stop(self) -> None:
"""Stop the server if we spawned it."""
raise NotImplementedError
def generate(self, batch: List[BatchInputItem]) -> List[BatchOutputItem]:
raise NotImplementedError
+207
View File
@@ -0,0 +1,207 @@
"""llama.cpp backend: spawns the upstream `llama-server` binary natively.
Install:
- macOS: brew install llama.cpp (Metal build, MPS)
- Linux: brew install llama.cpp OR github.com/ggml-org/llama.cpp/releases
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional
from huggingface_hub import hf_hub_download
from openai import OpenAI
from surya.inference.backends.base import Backend, ServerHandle
from surya.inference.backends.openai_client import chat_completions_batch
from surya.inference.backends.spawn import (
SpawnHandle,
SpawnError,
attach_or_spawn,
)
from surya.inference.schema import BatchInputItem, BatchOutputItem
from surya.logging import get_logger
from surya.settings import settings
logger = get_logger()
def _resolve_llama_server_binary() -> str:
binary = settings.LLAMA_CPP_BINARY
if binary and os.path.isfile(binary):
return binary
found = shutil.which(binary or "llama-server")
if found:
return found
raise SpawnError(
"llama-server binary not found. Install with:\n"
" macOS: brew install llama.cpp\n"
" Linux: brew install llama.cpp OR download from\n"
" https://github.com/ggml-org/llama.cpp/releases\n"
"Or set LLAMA_CPP_BINARY in your env to the binary path."
)
def _download_gguf_files() -> tuple[str, str]:
"""Download model + mmproj GGUFs from HF Hub. Returns local paths."""
repo = settings.SURYA_GGUF_REPO
model_file = settings.SURYA_GGUF_MODEL_FILE
mmproj_file = settings.SURYA_GGUF_MMPROJ_FILE
logger.info(f"Downloading {model_file} and {mmproj_file} from {repo}")
model_path = hf_hub_download(repo_id=repo, filename=model_file)
mmproj_path = hf_hub_download(repo_id=repo, filename=mmproj_file)
return model_path, mmproj_path
def _health_url(port: int) -> str:
return f"http://{settings.SURYA_INFERENCE_HOST}:{port}"
def _openai_url(port: int) -> str:
return f"http://{settings.SURYA_INFERENCE_HOST}:{port}/v1"
class LlamaCppBackend(Backend):
name = "llamacpp"
def __init__(self):
self.handle: Optional[ServerHandle] = None
self._client: Optional[OpenAI] = None
def start(self) -> ServerHandle:
if self.handle is not None:
return self.handle
# If user pinned an external server, attach without spawning.
# No binary or GGUF download needed in that case.
if settings.SURYA_INFERENCE_URL:
spawned = attach_or_spawn(
backend=self.name,
expected_model_name=settings.SURYA_MODEL_CHECKPOINT,
spawn_fn=lambda port: SpawnHandle(
pid=None, cleanup_id="", cleanup_kind="process"
), # never called
health_url_for=_health_url,
openai_url_for=_openai_url,
startup_timeout=settings.SURYA_INFERENCE_STARTUP_TIMEOUT,
)
self.handle = ServerHandle(
base_url=spawned.base_url,
model_name=spawned.model_name,
spawned_by_us=spawned.spawned_by_us,
)
self._client = OpenAI(api_key="EMPTY", base_url=self.handle.base_url)
return self.handle
binary = _resolve_llama_server_binary()
# Pre-download GGUFs so the spawn doesn't race the download
if (
settings.SURYA_GGUF_LOCAL_MODEL_PATH
and settings.SURYA_GGUF_LOCAL_MMPROJ_PATH
):
model_path = settings.SURYA_GGUF_LOCAL_MODEL_PATH
mmproj_path = settings.SURYA_GGUF_LOCAL_MMPROJ_PATH
else:
model_path, mmproj_path = _download_gguf_files()
# Total KV-cache budget. llama-server divides --ctx-size across
# --parallel slots, so a too-small total silently truncates outputs
# once each slot's share fills. Scale with parallel by default;
# SURYA_INFERENCE_CTX_SIZE overrides to a fixed value if set.
parallel = settings.SURYA_INFERENCE_PARALLEL
per_slot = settings.SURYA_INFERENCE_CTX_PER_SLOT
ctx_size = settings.SURYA_INFERENCE_CTX_SIZE
if ctx_size is None:
ctx_size = max(16384, parallel * per_slot)
effective_per_slot = ctx_size // max(parallel, 1)
logger.info(
f"llama-server ctx-size={ctx_size} "
f"(~{effective_per_slot}/slot × {parallel} parallel slots)"
)
if effective_per_slot < per_slot:
logger.warning(
f"per-slot ctx ({effective_per_slot}) is below recommended "
f"{per_slot}; outputs may truncate. Raise "
f"SURYA_INFERENCE_CTX_SIZE or SURYA_INFERENCE_CTX_PER_SLOT, "
f"or lower SURYA_INFERENCE_PARALLEL."
)
def spawn_fn(port: int) -> SpawnHandle:
cmd = [
binary,
"-m",
model_path,
"--mmproj",
mmproj_path,
"-ngl",
str(settings.LLAMA_CPP_NGL),
"--host",
settings.SURYA_INFERENCE_HOST,
"--port",
str(port),
"--parallel",
str(parallel),
"--ctx-size",
str(ctx_size),
"--no-mmproj-offload" if settings.LLAMA_CPP_NO_MMPROJ_OFFLOAD else "",
"--alias",
settings.SURYA_MODEL_CHECKPOINT,
"--jinja",
]
cmd = [c for c in cmd if c]
for extra in (settings.LLAMA_CPP_EXTRA_ARGS or "").split():
cmd.append(extra)
logger.info(f"Spawning: {' '.join(cmd)}")
log_path = Path("~/.cache/datalab/surya/llamacpp_server.log").expanduser()
log_path.parent.mkdir(parents=True, exist_ok=True)
log_fp = open(log_path, "ab")
proc = subprocess.Popen(
cmd,
stdout=log_fp,
stderr=subprocess.STDOUT,
start_new_session=True,
)
return SpawnHandle(
pid=proc.pid, cleanup_id=str(proc.pid), cleanup_kind="process"
)
spawned = attach_or_spawn(
backend=self.name,
expected_model_name=settings.SURYA_MODEL_CHECKPOINT,
spawn_fn=spawn_fn,
health_url_for=_health_url,
openai_url_for=_openai_url,
startup_timeout=settings.SURYA_INFERENCE_STARTUP_TIMEOUT,
)
self.handle = ServerHandle(
base_url=spawned.base_url,
model_name=spawned.model_name,
spawned_by_us=spawned.spawned_by_us,
)
self._client = OpenAI(
api_key="EMPTY",
base_url=self.handle.base_url,
)
return self.handle
def stop(self) -> None:
# atexit handler in spawn.py owns cleanup; nothing to do here.
self.handle = None
self._client = None
def generate(self, batch: List[BatchInputItem]) -> List[BatchOutputItem]:
if self.handle is None or self._client is None:
self.start()
return chat_completions_batch(
batch,
client=self._client,
model_name=self.handle.model_name,
timeout=settings.SURYA_INFERENCE_TIMEOUT_SECONDS,
max_workers=settings.SURYA_INFERENCE_PARALLEL,
request_logprobs_default=settings.SURYA_INFERENCE_LOGPROBS,
)
+257
View File
@@ -0,0 +1,257 @@
"""Shared OpenAI-compatible chat completions client. Used by vllm + llama.cpp.
Both servers expose `/v1/chat/completions` with the same request/response shape,
so this module is the single point of HTTP contact for both backends.
"""
from __future__ import annotations
import base64
import io
import math
import os
import time
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional
from PIL import Image
from surya.inference.prompts import PROMPT_MAPPING
from surya.inference.schema import (
BatchInputItem,
BatchOutputItem,
GenerationResult,
)
from surya.inference.util import detect_repeat_token, scale_to_fit
from surya.logging import get_logger
from surya.timing import get_current_timing, set_current_timing, timing_span
logger = get_logger()
def resolve_max_workers(batch_len: int, max_inflight: int) -> int:
"""Concurrent HTTP workers for a batch: as many as the batch needs, capped
by max_inflight so we keep vLLM's sequence slots full without over-queueing."""
return max(1, min(batch_len, max_inflight))
def encode_image_b64(image: Image.Image) -> tuple[str, str]:
image_format = os.getenv("SUYA_VLLM_IMAGE_FORMAT", "JPEG").upper()
if image_format not in {"JPEG", "PNG"}:
raise ValueError("SUYA_VLLM_IMAGE_FORMAT must be JPEG or PNG")
buf = io.BytesIO()
if image_format == "JPEG":
quality = int(os.getenv("SUYA_VLLM_JPEG_QUALITY", "92"))
image.save(buf, format="JPEG", quality=quality, subsampling=0)
mime_type = "image/jpeg"
else:
image.save(buf, format="PNG")
mime_type = "image/png"
view = buf.getbuffer()
try:
return base64.b64encode(view).decode("ascii"), mime_type
finally:
view.release()
def _build_messages(image: Image.Image, prompt: str):
with timing_span("openai_encode_image_b64", image_size=image.size):
image_b64, mime_type = encode_image_b64(image)
return [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:{mime_type};base64,{image_b64}"},
},
{"type": "text", "text": prompt},
],
}
]
def _mean_token_prob(logprobs_content) -> Optional[float]:
if not logprobs_content:
return None
probs = []
for tok in logprobs_content:
lp = (
tok.get("logprob")
if isinstance(tok, dict)
else getattr(tok, "logprob", None)
)
if lp is None:
continue
probs.append(math.exp(lp))
if not probs:
return None
return sum(probs) / len(probs)
def _generate_one(
item: BatchInputItem,
client,
model_name: str,
max_tokens_default: int,
temperature: float,
top_p: float,
timeout: float,
request_logprobs_default: bool,
) -> GenerationResult:
prompt = item.prompt or PROMPT_MAPPING[item.prompt_type]
with timing_span("openai_scale_image", prompt_type=item.prompt_type, image_size=item.image.size):
image = scale_to_fit(item.image)
with timing_span("openai_build_messages", prompt_type=item.prompt_type):
messages = _build_messages(image, prompt)
max_tokens = item.max_tokens or max_tokens_default
request_logprobs = item.request_logprobs or request_logprobs_default
kwargs = dict(
model=model_name,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
timeout=timeout,
)
if request_logprobs:
kwargs["logprobs"] = True
# Structured output: prefer OpenAI-standard response_format (works on both
# vllm and llama.cpp). Fall back to vllm's extra_body for guided_regex.
if item.guided_json is not None:
kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "structured_output",
"schema": item.guided_json,
"strict": True,
},
}
if item.guided_regex is not None:
kwargs.setdefault("extra_body", {})["guided_regex"] = item.guided_regex
try:
with timing_span(
"openai_chat_completion",
prompt_type=item.prompt_type,
max_tokens=max_tokens,
):
completion = client.chat.completions.create(**kwargs)
raw = completion.choices[0].message.content or ""
token_count = completion.usage.completion_tokens if completion.usage else 0
with timing_span(
"openai_parse_completion",
prompt_type=item.prompt_type,
token_count=token_count,
):
mean_p = None
logprobs_content = None
if request_logprobs:
choice = completion.choices[0]
lp = getattr(choice, "logprobs", None)
if lp is not None:
content = getattr(lp, "content", None)
if content is not None:
logprobs_content = [
c.model_dump() if hasattr(c, "model_dump") else c
for c in content
]
mean_p = _mean_token_prob(content)
return GenerationResult(
raw=raw,
token_count=token_count,
error=False,
mean_token_prob=mean_p,
logprobs=logprobs_content,
)
except Exception as e:
logger.warning(f"Inference error: {e}")
return GenerationResult(raw="", token_count=0, error=True)
def _should_retry(
result: GenerationResult,
retries: int,
max_retries: int,
) -> bool:
if retries >= max_retries:
return False
if result.error:
return True
has_repeat = detect_repeat_token(result.raw) or (
len(result.raw) > 50 and detect_repeat_token(result.raw, cut_from_end=50)
)
return has_repeat
def chat_completions_batch(
batch: List[BatchInputItem],
client,
model_name: str,
max_tokens_default: int = 2048,
temperature: float = 0.0,
top_p: float = 0.1,
timeout: float = 600.0,
max_workers: Optional[int] = None,
max_retries: int = 3,
request_logprobs_default: bool = True,
) -> List[BatchOutputItem]:
"""Run a batch of items through the chat completions endpoint with concurrent workers."""
if not batch:
return []
if max_workers is None:
max_workers = min(64, len(batch))
collector = get_current_timing()
def _process(item: BatchInputItem) -> BatchOutputItem:
if collector is not None:
set_current_timing(collector)
result = _generate_one(
item,
client=client,
model_name=model_name,
max_tokens_default=max_tokens_default,
temperature=temperature,
top_p=top_p,
timeout=timeout,
request_logprobs_default=request_logprobs_default,
)
retries = 0
while _should_retry(result, retries, max_retries):
backoff = 1.5 * (retries + 1) if result.error else 0
if backoff:
time.sleep(backoff)
retry_temp = min(temperature + 0.2 * (retries + 1), 0.8)
retry_top_p = 0.95 if not result.error else top_p
result = _generate_one(
item,
client=client,
model_name=model_name,
max_tokens_default=max_tokens_default,
temperature=retry_temp,
top_p=retry_top_p,
timeout=timeout,
request_logprobs_default=request_logprobs_default,
)
retries += 1
return BatchOutputItem(
raw=result.raw,
token_count=result.token_count,
error=result.error,
mean_token_prob=result.mean_token_prob,
logprobs=result.logprobs,
metadata=item.metadata,
)
with timing_span(
"openai_batch_threadpool",
item_count=len(batch),
max_workers=max_workers,
max_retries=max_retries,
):
with ThreadPoolExecutor(max_workers=max_workers) as executor:
return list(executor.map(_process, batch))
+351
View File
@@ -0,0 +1,351 @@
"""Server lifecycle: probe, filelock, sentinel, atexit cleanup.
Pattern: probe `/health` → if alive return handle → else acquire lock, re-probe,
spawn detached, write sentinel, register atexit kill (only the spawner cleans up).
"""
from __future__ import annotations
import atexit
import json
import os
import socket
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import httpx
from surya.logging import get_logger
from surya.settings import settings
logger = get_logger()
def _cache_dir() -> Path:
base = Path(os.path.expanduser("~/.cache/datalab/surya"))
base.mkdir(parents=True, exist_ok=True)
return base
def _sentinel_path(backend: str) -> Path:
return _cache_dir() / f"{backend}_server.json"
def _lock_path(backend: str) -> Path:
return _cache_dir() / f"{backend}_server.lock"
def find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def probe_health(base_url: str, timeout: float = 1.0) -> bool:
"""Returns True if the server reports healthy at /health."""
try:
# llama.cpp returns 200 on /health when ready; vllm returns 200 on /health too.
with httpx.Client(timeout=timeout) as client:
r = client.get(f"{base_url}/health")
return r.status_code == 200
except Exception:
return False
def wait_for_health(
base_url: str, total_timeout: float = 300.0, interval: float = 1.0
) -> bool:
deadline = time.time() + total_timeout
while time.time() < deadline:
if probe_health(base_url):
return True
time.sleep(interval)
return False
def probe_model_id(openai_base: str, timeout: float = 5.0) -> Optional[str]:
"""Returns the model id reported by the running server, or None on failure."""
try:
with httpx.Client(timeout=timeout) as client:
r = client.get(f"{openai_base}/models")
r.raise_for_status()
data = r.json()
models = data.get("data") or []
if models:
return models[0].get("id")
except Exception:
return None
return None
@dataclass
class SpawnedServer:
base_url: str # full openai base, e.g. "http://127.0.0.1:8765/v1"
health_url: str # base for /health, e.g. "http://127.0.0.1:8765"
model_name: str # what to pass as `model`
pid: Optional[int]
backend: str
spawned_by_us: bool
class SpawnError(RuntimeError):
pass
def _read_sentinel(backend: str) -> Optional[dict]:
p = _sentinel_path(backend)
if not p.exists():
return None
try:
return json.loads(p.read_text())
except Exception:
return None
def _write_sentinel(backend: str, data: dict) -> None:
_sentinel_path(backend).write_text(json.dumps(data))
def _delete_sentinel(backend: str) -> None:
p = _sentinel_path(backend)
if p.exists():
try:
p.unlink()
except Exception:
pass
def _stop_process(pid: int, name: str) -> None:
try:
# Graceful first
os.kill(pid, 15) # SIGTERM
for _ in range(20):
try:
os.kill(pid, 0) # still alive?
except ProcessLookupError:
logger.info(f"Stopped {name} (pid {pid})")
return
time.sleep(0.5)
# Hard
os.kill(pid, 9)
logger.warning(f"Force-killed {name} (pid {pid})")
except ProcessLookupError:
pass
except Exception as e:
logger.warning(f"Failed to stop {name} (pid {pid}): {e}")
def _capture_server_logs(handle: "SpawnHandle", tail: int = 100) -> str:
"""Best-effort tail of a server's logs, for surfacing startup failures."""
try:
if handle.cleanup_kind == "docker":
r = subprocess.run(
["docker", "logs", "--tail", str(tail), handle.cleanup_id],
capture_output=True,
text=True,
timeout=15,
)
return (r.stdout or "") + (r.stderr or "") or "(no docker logs)"
# llama.cpp process backend logs to this file (see llamacpp.py)
log_path = Path("~/.cache/datalab/surya/llamacpp_server.log").expanduser()
if log_path.exists():
lines = log_path.read_text(errors="replace").splitlines()
return "\n".join(lines[-tail:]) or "(empty log)"
except Exception as e:
return f"(could not capture logs: {e})"
return "(no logs available)"
def _stop_docker_container(name: str) -> None:
try:
subprocess.run(
["docker", "stop", name], check=False, capture_output=True, timeout=30
)
logger.info(f"Stopped docker container {name}")
except Exception as e:
logger.warning(f"Failed to stop docker container {name}: {e}")
def attach_or_spawn(
backend: str,
expected_model_name: str,
spawn_fn: Callable[[int], "SpawnHandle"],
health_url_for: Callable[[int], str],
openai_url_for: Callable[[int], str],
startup_timeout: float = 600.0,
) -> SpawnedServer:
"""Generic attach-or-spawn with file lock and sentinel.
`spawn_fn(port)` must launch the server detached and return a SpawnHandle
with `pid` (int or None for docker) and a `cleanup_id` (e.g. container name).
"""
# 0. If user pinned an external URL, attach without lock
if settings.SURYA_INFERENCE_URL:
base_url = settings.SURYA_INFERENCE_URL.rstrip("/")
health_url = base_url[: -len("/v1")] if base_url.endswith("/v1") else base_url
if not probe_health(health_url):
raise SpawnError(
f"SURYA_INFERENCE_URL={base_url} is not reachable at /health. "
"Start the server or unset the variable."
)
model_name = probe_model_id(base_url) or expected_model_name
if model_name != expected_model_name:
raise SpawnError(
f"Model mismatch at {base_url}: expected {expected_model_name!r}, got {model_name!r}. "
"Stop the running server or unset SURYA_INFERENCE_URL."
)
return SpawnedServer(
base_url=base_url,
health_url=health_url,
model_name=model_name,
pid=None,
backend=backend,
spawned_by_us=False,
)
# 1. Probe sentinel without lock
existing = _read_sentinel(backend)
if existing:
port = existing.get("port")
pid = existing.get("pid")
if port and probe_health(health_url_for(port)):
running_model = probe_model_id(openai_url_for(port)) or expected_model_name
if running_model != expected_model_name:
raise SpawnError(
f"Existing {backend} server on port {port} serves {running_model!r}, "
f"expected {expected_model_name!r}. Stop it before continuing."
)
logger.info(f"Attaching to existing {backend} server on port {port}")
return SpawnedServer(
base_url=openai_url_for(port),
health_url=health_url_for(port),
model_name=running_model,
pid=pid,
backend=backend,
spawned_by_us=False,
)
else:
_delete_sentinel(backend)
if not settings.SURYA_INFERENCE_AUTOSTART:
raise SpawnError(
f"No running {backend} server and SURYA_INFERENCE_AUTOSTART is False. "
"Set the variable to True or start the server manually."
)
# 2. Acquire filelock to prevent races
try:
from filelock import FileLock
except ImportError as e:
raise SpawnError(
"filelock is required for server spawn. pip install filelock"
) from e
lock = FileLock(str(_lock_path(backend)), timeout=120)
with lock:
# Re-check sentinel inside the lock
existing = _read_sentinel(backend)
if existing:
port = existing.get("port")
if port and probe_health(health_url_for(port)):
running_model = (
probe_model_id(openai_url_for(port)) or expected_model_name
)
if running_model != expected_model_name:
raise SpawnError(
f"Existing {backend} server on port {port} serves {running_model!r}, "
f"expected {expected_model_name!r}."
)
return SpawnedServer(
base_url=openai_url_for(port),
health_url=health_url_for(port),
model_name=running_model,
pid=existing.get("pid"),
backend=backend,
spawned_by_us=False,
)
# 3. Spawn fresh
port = settings.SURYA_INFERENCE_PORT or find_free_port()
logger.info(f"Spawning {backend} server on port {port}")
spawn_handle = spawn_fn(port)
# 4. Write sentinel
_write_sentinel(
backend,
{
"port": port,
"pid": spawn_handle.pid,
"model": expected_model_name,
"backend": backend,
"cleanup_id": spawn_handle.cleanup_id,
"cleanup_kind": spawn_handle.cleanup_kind,
},
)
# 5. Register atexit cleanup (only spawner). Skipped when keep-alive is
# set so the server outlives this process and later commands attach to
# it via the sentinel. (_cleanup is still callable below on startup
# failure, where we always tear a half-started server down.)
def _cleanup():
try:
if spawn_handle.cleanup_kind == "docker":
_stop_docker_container(spawn_handle.cleanup_id)
elif spawn_handle.cleanup_kind == "process":
if spawn_handle.pid:
_stop_process(spawn_handle.pid, backend)
finally:
_delete_sentinel(backend)
if settings.SURYA_INFERENCE_KEEP_ALIVE:
logger.info(
f"keep-alive: {backend} server on port {port} will stay up "
f"after exit (cleanup_id={spawn_handle.cleanup_id!r})"
)
else:
atexit.register(_cleanup)
# 6. Wait for health
health_url = health_url_for(port)
if not wait_for_health(health_url, total_timeout=startup_timeout):
# Grab the server's own logs *before* cleanup tears the (--rm)
# container down, otherwise the actual failure reason is lost and
# all the caller sees is this timeout.
logs = _capture_server_logs(spawn_handle)
_cleanup()
raise SpawnError(
f"{backend} server failed to become healthy at {health_url} "
f"within {startup_timeout}s.\n"
f"--- last {backend} server logs ---\n{logs}"
)
# 7. Verify model name
running_model = probe_model_id(openai_url_for(port))
if running_model and running_model != expected_model_name:
logger.warning(
f"{backend} server reports model={running_model!r} "
f"but expected {expected_model_name!r}; using reported name."
)
expected_model_name = running_model
logger.info(
f"{backend} server ready on port {port} (model={expected_model_name})"
)
return SpawnedServer(
base_url=openai_url_for(port),
health_url=health_url,
model_name=expected_model_name,
pid=spawn_handle.pid,
backend=backend,
spawned_by_us=True,
)
@dataclass
class SpawnHandle:
pid: Optional[int]
cleanup_id: str # container name for docker, str(pid) for process
cleanup_kind: str # "docker" | "process"
+224
View File
@@ -0,0 +1,224 @@
"""vllm backend: spawns the vllm/vllm-openai docker image with MTP=2."""
from __future__ import annotations
import json
import math
import os
import shutil
import subprocess
from typing import List, Optional
from openai import OpenAI
from surya.inference.backends.base import Backend, ServerHandle
from surya.inference.backends.openai_client import chat_completions_batch, resolve_max_workers
from surya.inference.backends.spawn import (
SpawnHandle,
SpawnError,
attach_or_spawn,
)
from surya.inference.schema import BatchInputItem, BatchOutputItem
from surya.logging import get_logger
from surya.settings import settings
from surya.timing import timing_span
logger = get_logger()
# 24GB baseline (re-tune for surya-2 once benchmarks land)
BASELINE_VRAM_GB = 24
BASELINE_MAX_BATCHED_TOKENS = 8192
BASELINE_MAX_NUM_SEQS = 32
GPU_VRAM_GB = {
"b300": 270,
"b200": 180,
"h200": 141,
"h100": 80,
"a100-80": 80,
"a100": 40,
"a100-40": 40,
"l40s": 48,
"a10": 24,
"l4": 24,
"5090": 32,
"4090": 24,
"3090": 24,
"t4": 16,
}
def _gpu_settings(gpu: str) -> tuple[int, int]:
vram = GPU_VRAM_GB.get(gpu)
if vram is None:
available = ", ".join(sorted(GPU_VRAM_GB.keys()))
raise SpawnError(f"Unknown VLLM_GPU_TYPE {gpu!r}. Available: {available}")
ratio = vram / BASELINE_VRAM_GB
raw_tokens = BASELINE_MAX_BATCHED_TOKENS * ratio
max_batched_tokens = max(1024, 2 ** math.floor(math.log2(raw_tokens)))
max_num_seqs = max(8, (int(BASELINE_MAX_NUM_SEQS * ratio) // 8) * 8)
return max_batched_tokens, max_num_seqs
def _resolve_docker_binary() -> str:
found = shutil.which("docker")
if found:
return found
raise SpawnError(
"docker binary not found. Install Docker (https://docs.docker.com/get-docker/) "
"and ensure the daemon is running."
)
def _health_url(port: int) -> str:
return f"http://{settings.SURYA_INFERENCE_HOST}:{port}"
def _openai_url(port: int) -> str:
return f"http://{settings.SURYA_INFERENCE_HOST}:{port}/v1"
class VllmBackend(Backend):
name = "vllm"
def __init__(self):
self.handle: Optional[ServerHandle] = None
self._client: Optional[OpenAI] = None
def start(self) -> ServerHandle:
if self.handle is not None:
return self.handle
# If user pinned an external server, attach without spawning docker.
if settings.SURYA_INFERENCE_URL:
spawned = attach_or_spawn(
backend=self.name,
expected_model_name=settings.SURYA_MODEL_CHECKPOINT,
spawn_fn=lambda port: SpawnHandle(
pid=None, cleanup_id="", cleanup_kind="docker"
),
health_url_for=_health_url,
openai_url_for=_openai_url,
startup_timeout=settings.SURYA_INFERENCE_STARTUP_TIMEOUT,
)
self.handle = ServerHandle(
base_url=spawned.base_url,
model_name=spawned.model_name,
spawned_by_us=spawned.spawned_by_us,
)
self._client = OpenAI(
api_key=settings.VLLM_API_KEY, base_url=self.handle.base_url
)
return self.handle
if os.getenv("SUYA_ALLOW_NESTED_DOCKER", "false").lower() not in {"1", "true", "yes"}:
raise SpawnError(
"Nested Docker vLLM startup is disabled. Start vLLM in this "
"container and set SURYA_INFERENCE_URL, for example "
"http://127.0.0.1:8000/v1."
)
docker = _resolve_docker_binary()
max_batched_tokens, max_num_seqs = _gpu_settings(settings.VLLM_GPU_TYPE)
def spawn_fn(port: int) -> SpawnHandle:
container_name = f"surya-vllm-{port}"
hf_cache = os.path.expanduser(settings.DOCKER_HF_CACHE_PATH)
cmd = [
docker,
"run",
"--rm",
"-d",
"--name",
container_name,
"--runtime",
"nvidia",
"--gpus",
f"device={settings.VLLM_GPUS}",
"-v",
f"{hf_cache}:/root/.cache/huggingface",
"-p",
f"{port}:8000",
"--ipc=host",
settings.VLLM_DOCKER_IMAGE,
"--model",
settings.SURYA_MODEL_CHECKPOINT,
"--no-enforce-eager",
"--max-num-seqs",
str(max_num_seqs),
"--dtype",
settings.VLLM_DTYPE,
"--max-model-len",
str(settings.VLLM_MAX_MODEL_LEN),
"--max-num-batched-tokens",
str(max_batched_tokens),
"--gpu-memory-utilization",
str(settings.VLLM_GPU_MEMORY_UTILIZATION),
"--enable-prefix-caching",
"--mm-processor-kwargs",
json.dumps({"min_pixels": 3136, "max_pixels": 6291456}),
"--served-model-name",
settings.SURYA_MODEL_CHECKPOINT,
]
if settings.VLLM_ENABLE_MTP:
spec_config = json.dumps(
{
"method": "mtp",
"num_speculative_tokens": settings.VLLM_MTP_TOKENS,
}
)
cmd.extend(["--speculative-config", spec_config])
for extra in (settings.VLLM_EXTRA_ARGS or "").split():
cmd.append(extra)
logger.info(f"Spawning: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise SpawnError(f"docker run failed: {result.stderr or result.stdout}")
return SpawnHandle(
pid=None, cleanup_id=container_name, cleanup_kind="docker"
)
spawned = attach_or_spawn(
backend=self.name,
expected_model_name=settings.SURYA_MODEL_CHECKPOINT,
spawn_fn=spawn_fn,
health_url_for=_health_url,
openai_url_for=_openai_url,
startup_timeout=settings.SURYA_INFERENCE_STARTUP_TIMEOUT,
)
self.handle = ServerHandle(
base_url=spawned.base_url,
model_name=spawned.model_name,
spawned_by_us=spawned.spawned_by_us,
)
self._client = OpenAI(
api_key=settings.VLLM_API_KEY,
base_url=self.handle.base_url,
)
return self.handle
def stop(self) -> None:
self.handle = None
self._client = None
def generate(self, batch: List[BatchInputItem]) -> List[BatchOutputItem]:
if self.handle is None or self._client is None:
with timing_span("vllm_backend_start"):
self.start()
with timing_span(
"vllm_backend_generate",
item_count=len(batch),
parallel=settings.SURYA_INFERENCE_PARALLEL,
):
return chat_completions_batch(
batch,
client=self._client,
model_name=self.handle.model_name,
timeout=settings.SURYA_INFERENCE_TIMEOUT_SECONDS,
max_workers=resolve_max_workers(
len(batch), settings.SURYA_INFERENCE_MAX_INFLIGHT
),
max_retries=settings.SURYA_INFERENCE_MAX_RETRIES,
request_logprobs_default=settings.SURYA_INFERENCE_LOGPROBS,
)
+182
View File
@@ -0,0 +1,182 @@
"""Parsers for the three task outputs."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import List, Tuple
from surya.logging import get_logger
logger = get_logger()
# ---- Layout (LAYOUT_PROMPT) -------------------------------------------------
@dataclass
class ParsedLayoutBlock:
label: str
bbox: Tuple[float, float, float, float] # 0-1000 normalized
count: int # multiple of 50, model's token estimate
_JSON_ARRAY_RE = re.compile(r"\[.*\]", re.DOTALL)
def _strip_fences(text: str) -> str:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```[a-zA-Z]*\n", "", cleaned)
cleaned = re.sub(r"\n```\s*$", "", cleaned)
return cleaned
def _coerce_bbox(bbox) -> Tuple[float, float, float, float]:
if isinstance(bbox, str):
parts = [float(x) for x in bbox.replace(",", " ").split()]
else:
parts = [float(x) for x in bbox]
if len(parts) != 4:
raise ValueError(f"Bad bbox: {bbox!r}")
return (parts[0], parts[1], parts[2], parts[3])
def _coerce_count(value) -> int:
if value is None:
return 0
try:
return max(0, int(value))
except (TypeError, ValueError):
return 0
def parse_layout(text: str) -> List[ParsedLayoutBlock]:
"""Pull the JSON array out of LAYOUT_PROMPT output and convert to typed blocks.
Tolerates code fences, missing fields, and stringified bboxes.
"""
cleaned = _strip_fences(text)
m = _JSON_ARRAY_RE.search(cleaned)
if not m:
raise ValueError(f"No JSON array found in layout output: {text[:500]!r}")
raw = json.loads(m.group(0))
out: List[ParsedLayoutBlock] = []
for item in raw:
try:
bbox = _coerce_bbox(item["bbox"])
except (KeyError, ValueError) as e:
logger.warning(f"Skipping layout block with bad bbox: {e}")
continue
label = str(item.get("label", "block"))
count = _coerce_count(item.get("count"))
out.append(ParsedLayoutBlock(label=label, bbox=bbox, count=count))
return out
# ---- Table rec (TABLE_REC_PROMPT) ------------------------------------------
@dataclass
class ParsedTableElement:
label: str # "Row" or "Col"
bbox: Tuple[float, float, float, float]
def parse_table_rec(text: str) -> List[ParsedTableElement]:
"""Parse JSON array of {label: "Row"|"Col", bbox: "x0 y0 x1 y1"} from
TABLE_REC_PROMPT output. Returns a flat list of Row + Col elements;
cell derivation is the caller's job."""
cleaned = _strip_fences(text)
m = _JSON_ARRAY_RE.search(cleaned)
if not m:
raise ValueError(f"No JSON array found in table_rec output: {text[:500]!r}")
raw = json.loads(m.group(0))
out: List[ParsedTableElement] = []
for item in raw:
label = str(item.get("label", "")).strip()
if label not in ("Row", "Col"):
continue
try:
bbox = _coerce_bbox(item["bbox"])
except (KeyError, ValueError):
continue
out.append(ParsedTableElement(label=label, bbox=bbox))
return out
# ---- Block HTML (BLOCK_PROMPT for full table path / general block path) ---
def clean_block_html(html: str) -> str:
"""Light cleanup of model-emitted HTML for a single block.
Strips code fences, leading/trailing whitespace. Does NOT validate against
ALLOWED_TAGS — the model is expected to comply, and downstream consumers
can sanitize further if needed.
"""
cleaned = _strip_fences(html).strip()
return cleaned
# ---- Full-page fallback (HIGH_ACCURACY_BBOX_PROMPT) -----------------------
@dataclass
class ParsedFullPageBlock:
label: str
bbox: Tuple[float, float, float, float] # 0-1000 normalized
html: str # inner HTML of the wrapping div
def parse_full_page_html(text: str) -> List[ParsedFullPageBlock]:
"""Parse output of HIGH_ACCURACY_BBOX_PROMPT — top-level <div data-bbox=...
data-label=...>inner HTML</div> blocks. Returns one entry per top-level div."""
from bs4 import BeautifulSoup
cleaned = _strip_fences(text).strip()
if not cleaned:
return []
# The model outputs a sequence of top-level divs (no surrounding root).
# BeautifulSoup parses fine without one.
soup = BeautifulSoup(cleaned, "html.parser")
divs = soup.find_all("div", recursive=False)
out: List[ParsedFullPageBlock] = []
for div in divs:
label = div.get("data-label")
bbox_str = div.get("data-bbox")
if not label or not bbox_str:
continue
try:
parts = [float(x) for x in bbox_str.split()]
except ValueError:
continue
if len(parts) != 4:
continue
# Strip nested data-bbox attrs from the inner HTML so downstream
# consumers don't see model debug info on every child element.
for tag in div.find_all(attrs={"data-bbox": True}):
del tag["data-bbox"]
for tag in div.find_all(attrs={"data-label": True}):
del tag["data-label"]
inner = "".join(str(c) for c in div.contents).strip()
out.append(
ParsedFullPageBlock(
label=str(label),
bbox=(parts[0], parts[1], parts[2], parts[3]),
html=inner,
)
)
return out
def denorm_bbox(bbox, img_w: int, img_h: int, scale: int = 1000):
x0, y0, x1, y1 = bbox
return (
x0 / scale * img_w,
y0 / scale * img_h,
x1 / scale * img_w,
y1 / scale * img_h,
)
+158
View File
@@ -0,0 +1,158 @@
"""Prompt strings for surya2. The exact wording is the model's training-time
contract — do not paraphrase without retraining."""
from surya.inference.schema import PROMPT_TYPE_BLOCK as PROMPT_TYPE_BLOCK
from surya.inference.schema import (
PROMPT_TYPE_HIGH_ACCURACY_BBOX as PROMPT_TYPE_HIGH_ACCURACY_BBOX,
)
from surya.inference.schema import PROMPT_TYPE_LAYOUT as PROMPT_TYPE_LAYOUT
from surya.inference.schema import PROMPT_TYPE_TABLE_REC as PROMPT_TYPE_TABLE_REC
ALLOWED_TAGS = [
"math",
"br",
"i",
"b",
"u",
"del",
"sup",
"sub",
"table",
"tr",
"td",
"p",
"th",
"div",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"ul",
"ol",
"li",
"input",
"a",
"span",
"img",
"hr",
"tbody",
"small",
"caption",
"strong",
"thead",
"big",
"code",
"chem",
]
ALLOWED_ATTRIBUTES = [
"class",
"colspan",
"rowspan",
"display",
"checked",
"type",
"border",
"value",
"style",
"href",
"alt",
"align",
"data-bbox",
"data-label",
]
# Block labels we don't run OCR on.
SKIP_OCR_LABELS = {"Figure", "Image", "Diagram", "Blank-Page"}
LAYOUT_PROMPT = (
"Output the layout of this image as JSON. Each entry is a dict with "
'"label", "bbox", and "count" fields. Bbox is x0 y0 x1 y1, normalized 0-1000.'
)
BLOCK_PROMPT = "OCR this block image to HTML."
TABLE_REC_PROMPT = (
"Output the table rows then columns as JSON. Each entry is a dict with "
'"label" ("Row" or "Col") and "bbox" (x0 y0 x1 y1, normalized 0-1000).'
)
HIGH_ACCURACY_BBOX_PROMPT = (
"OCR this image to HTML. Each block is a div with data-label and data-bbox "
"(x0 y0 x1 y1, normalized 0-1000)."
)
PROMPT_MAPPING = {
"layout": LAYOUT_PROMPT,
"block": BLOCK_PROMPT,
"table_rec": TABLE_REC_PROMPT,
"high_accuracy_bbox": HIGH_ACCURACY_BBOX_PROMPT,
}
# JSON schema for LAYOUT_PROMPT — enforced via vllm guided decoding so the
# model can't emit malformed JSON. bbox is a "x0 y0 x1 y1" string (model's
# training-time format); count is a non-negative integer.
LAYOUT_LABEL_SET = [
"Caption",
"Footnote",
"Equation-Block",
"List-Group",
"Page-Header",
"Page-Footer",
"Image",
"Section-Header",
"Table",
"Text",
"Complex-Block",
"Code-Block",
"Form",
"Table-Of-Contents",
"Figure",
"Chemical-Block",
"Diagram",
"Bibliography",
"Blank-Page",
]
LAYOUT_JSON_SCHEMA = {
"type": "array",
"maxItems": 200,
"items": {
"type": "object",
"properties": {
"label": {"type": "string", "enum": LAYOUT_LABEL_SET},
"bbox": {
"type": "string",
"pattern": r"^\d{1,4} \d{1,4} \d{1,4} \d{1,4}$",
},
"count": {"type": "integer", "minimum": 0, "maximum": 10000},
},
"required": ["label", "bbox", "count"],
"additionalProperties": False,
},
}
# JSON schema for TABLE_REC_PROMPT — array of {label: Row|Col, bbox: "x0 y0 x1 y1"}.
TABLE_REC_LABEL_SET = ["Row", "Col"]
TABLE_REC_JSON_SCHEMA = {
"type": "array",
"maxItems": 200,
"items": {
"type": "object",
"properties": {
"label": {"type": "string", "enum": TABLE_REC_LABEL_SET},
"bbox": {
"type": "string",
"pattern": r"^\d{1,4} \d{1,4} \d{1,4} \d{1,4}$",
},
},
"required": ["label", "bbox"],
"additionalProperties": False,
},
}
+45
View File
@@ -0,0 +1,45 @@
from dataclasses import dataclass, field
from typing import Any, List, Optional
from PIL import Image
PROMPT_TYPE_LAYOUT = "layout"
PROMPT_TYPE_BLOCK = "block"
PROMPT_TYPE_TABLE_REC = "table_rec"
PROMPT_TYPE_HIGH_ACCURACY_BBOX = "high_accuracy_bbox"
@dataclass
class BatchInputItem:
image: Image.Image
prompt_type: str
prompt: Optional[str] = None # If set, overrides the default prompt for prompt_type
max_tokens: Optional[int] = None
request_logprobs: bool = False
# vllm-native guided decoding — JSON schema, regex, or grammar string.
# When set, the server constrains the decode tokens to match the schema.
guided_json: Optional[dict] = None
guided_regex: Optional[str] = None
metadata: dict = field(default_factory=dict) # Free-form, passes through to output
@dataclass
class GenerationResult:
raw: str
token_count: int
error: bool = False
# Mean of exp(logprob) across response tokens, if logprobs requested
mean_token_prob: Optional[float] = None
# Per-token logprobs (raw OpenAI-style content list), if requested - phase 2 use
logprobs: Optional[List[Any]] = None
@dataclass
class BatchOutputItem:
raw: str
token_count: int
error: bool
mean_token_prob: Optional[float] = None
logprobs: Optional[List[Any]] = None
metadata: dict = field(default_factory=dict)
+96
View File
@@ -0,0 +1,96 @@
from typing import Tuple
from PIL import Image
def scale_to_fit(
img: Image.Image,
max_size: Tuple[int, int] = (3072, 2048),
min_size: Tuple[int, int] = (1792, 28),
grid_size: int = 28,
) -> Image.Image:
resample_method = Image.Resampling.LANCZOS
width, height = img.size
if width <= 0 or height <= 0:
return img
original_ar = width / height
current_pixels = width * height
max_pixels = max_size[0] * max_size[1]
min_pixels = min_size[0] * min_size[1]
scale = 1.0
if current_pixels > max_pixels:
scale = (max_pixels / current_pixels) ** 0.5
elif current_pixels < min_pixels:
scale = (min_pixels / current_pixels) ** 0.5
w_blocks = max(1, round((width * scale) / grid_size))
h_blocks = max(1, round((height * scale) / grid_size))
while (w_blocks * h_blocks * grid_size * grid_size) > max_pixels:
if w_blocks == 1 and h_blocks == 1:
break
if w_blocks == 1:
h_blocks -= 1
continue
if h_blocks == 1:
w_blocks -= 1
continue
ar_w_loss = abs(((w_blocks - 1) / h_blocks) - original_ar)
ar_h_loss = abs((w_blocks / (h_blocks - 1)) - original_ar)
if ar_w_loss < ar_h_loss:
w_blocks -= 1
else:
h_blocks -= 1
new_width = w_blocks * grid_size
new_height = h_blocks * grid_size
if (new_width, new_height) == (width, height):
return img
return img.resize((new_width, new_height), resample=resample_method)
def detect_repeat_token(
predicted_tokens: str,
base_max_repeats: int = 4,
window_size: int = 500,
cut_from_end: int = 0,
scaling_factor: float = 3.0,
) -> bool:
if cut_from_end > 0:
predicted_tokens = predicted_tokens[:-cut_from_end]
for seq_len in range(1, window_size // 2 + 1):
candidate_seq = predicted_tokens[-seq_len:]
max_repeats = int(base_max_repeats * (1 + scaling_factor / seq_len))
repeat_count = 0
pos = len(predicted_tokens) - seq_len
if pos < 0:
continue
while pos >= 0:
if predicted_tokens[pos : pos + seq_len] == candidate_seq:
repeat_count += 1
pos -= seq_len
else:
break
if repeat_count > max_repeats:
return True
return False
def image_token_budget(block_count: int, ceiling: int = 4096, floor: int = 64) -> int:
"""Per-block max_tokens: count + 100, clamped to [floor, ceiling]."""
return min(max(block_count + 100, floor), ceiling)

Some files were not shown because too many files have changed in this diff Show More