A small FastAPI service that wraps PaddleOCR behind an HTTP endpoint.
I built this for a couple of reasons. Mostly I wanted to play with FastAPI. The other reason is that PaddleOCR has a heavy startup cost: initializing the engine takes seconds, so running it as a one-shot script per image is painful. Wrapping it in a long-running service keeps the engine warm and brings per-request latency back down to just the inference cost.
The engine is loaded once at app startup (via FastAPI's lifespan) and reused across requests.
The API returns text, per-box confidence, and pixel coordinates for each detection. Rendering an overlay like the one above isn't part of the service. It's something a caller can build on top of the response.
- Python 3.12+, FastAPI, Uvicorn
- PaddleOCR 3.7 with the ONNX Runtime backend (English; document orientation + unwarping enabled, per-line orientation disabled)
structlogfor JSON logginguvfor dependency management- Scalar for API docs
- pytest + ruff + pyright
| Method | Path | Description |
|---|---|---|
| POST | /ocr/ |
Multipart upload (file=<image>). Returns detected text, confidence, boxes. |
| GET | /health |
Liveness. Always returns {"status": "ok"}. |
| GET | /ready |
Readiness. 200 once the OCR engine is initialized, 503 otherwise. |
| GET | /docs |
Scalar-rendered API reference. |
Response shape:
{
"results": [
{ "text": "hello", "confidence": 0.9876, "box": [[x, y], [x, y], [x, y], [x, y]] }
]
}Large images are downscaled to ocr_max_dimension (default 2048px on the longest side) before inference, and box coordinates are rescaled back to the original image's space.
uv sync
uv run uvicorn app.main:app --reloadFirst boot will download the PaddleOCR model weights. Subsequent boots reuse the cached models.
The Dockerfile bakes the model weights into the image at build time so containers start without a network round-trip:
docker compose up --buildOr without compose:
docker build -t fast-api-ocr .
docker run --rm -p 8000:8000 fast-api-ocrEnvironment variables (via pydantic-settings):
| Variable | Default | Purpose |
|---|---|---|
LOG_PRETTY |
false |
true for human-readable console logs. |
OCR_MAX_DIMENSION |
2048 |
Longest side (px) before downscaling. |
MAX_UPLOAD_BYTES |
10485760 (10 MB) |
Per-request upload limit. |
A few trade-offs that aren't obvious from skimming the code.
PaddleOCR inference is synchronous, CPU-bound, and takes seconds per image. Calling it directly from an async def handler would block the event loop, so even /health would hang while a single image was being processed. PaddleOCR also doesn't document its predictors as thread-safe, and there's a single shared engine cached on the app.
So inference is gated behind an asyncio.Semaphore(1) and offloaded via asyncio.to_thread. Concurrent OCR requests queue rather than race on the shared engine, and the event loop stays free to serve everything else. The path to higher throughput is horizontal scaling: multiple Uvicorn workers, one engine each.
Measured on tests/fixtures/sample.jpeg (~5s inference):
OCR request (background): HTTP 200 in 5.15s
/health probe 1: HTTP 200 in 0.0013s
/health probe 2: HTTP 200 in 0.0015s
... (8 probes during the OCR window)
/health probe 8: HTTP 200 in 0.0013s
Two concurrent OCR requests:
B: HTTP 200 in 5.15s ← took the semaphore first
A: HTTP 200 in 10.39s ← queued behind B, then ran
- ONNX Runtime backend rather than the default Paddle Inference runtime. Smaller install footprint, faster cold start, no GPU dependencies. Fits a CPU-only service.
- Preprocessing: document orientation classification and unwarping are enabled (
use_doc_orientation_classify=True,use_doc_unwarping=True) so rotated scans and perspective-distorted phone photos still OCR correctly. Per-line orientation detection (use_textline_orientation) is left off, since it's mainly useful for documents that mix horizontal and vertical text lines, which isn't the expected input here. Skipping it saves model load time and a bit of per-request latency. - Lowered detection thresholds (
text_det_thresh=0.2,text_det_box_thresh=0.4) for better recall on faint or low-contrast text. The trade is more false-positive boxes; callers can filter onconfidenceif precision matters.
Images larger than OCR_MAX_DIMENSION (default 2048px) are downscaled before inference, then box coordinates are rescaled back to the original image's coordinate space. PaddleOCR's accuracy plateaus well below 4K, so feeding it full-resolution phone photos just burns CPU.
The engine is constructed once during FastAPI's lifespan startup and lives for the process's lifetime. /ready reports 503 until initialization completes, so orchestrators can route traffic only when the engine is actually warm.
uv run pytest # fast tests only
uv run pytest -m slow # also runs real OCR inferenceSlow tests load the actual PaddleOCR engine and are skipped by default.
