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
+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()