Skip to content

hoyla/fusion-ocr

Repository files navigation

fusion-ocr

A hybrid deterministic + Vision-LLM OCR pipeline for confidential documents.

It pairs a deterministic engine (PaddleOCR / PP-DocLayoutV2, or Apple Vision) with a self-hosted VLM so you get the best of both: trustworthy bounding boxes from the deterministic side and clean, structure-aware reading (tables → markdown, translation) from the VLM — fused into one "best of" output with a searchable bbox text overlay.

The fusion-ocr pipeline: a PDF is triaged and laid out, then forks into a deterministic geometry track and a VLM semantics track that meet at fusion, rendering an overlay PDF, per-language markdown, and a segment index.

Everything runs on your own estate — local models via Ollama/MLX now, an in-VPC CUDA endpoint (e.g. vLLM on the transcription GPU) later — with no third-party LLM calls. The most-sensitive tier runs fully air-gapped.

Core idea

Concern Owner
Geometry (boxes, tables, reading order) deterministic — PaddleOCR / Apple Vision; layout via PP-DocLayoutV2
Semantics (reading, structure, translation) VLM
Fusion align VLM text onto deterministic boxes; the deterministic layer is the anti-hallucination gate

Boxes never come from the VLM. That's what makes the searchable overlay reliable and keeps the VLM from inventing text where there was no ink.

Status: working pipeline

The real stages are in: triage → layout → language → OCR (PaddleOCR / Apple Vision) → VLM read → tables → fusion → render. A dropped PDF emits a provenance-carrying segment index, per-language markdown, and a searchable bbox overlay. On a born-digital recognition eval the OCR path scores ~95% word recall / ~96% precision; the headline case — a handwritten note tesseract recovers 6 characters from — comes through at ~3,185 searchable characters, fully local.

The contract underneath — Document in / Document out, raw and inferred both retained, serialised between stages for a recipe-fingerprinted resume — is in place and tested (130 tests). Degraded scans and handwriting still want a small hand-labelled set to measure against; word-level overlay subdivision and a few table refinements are follow-ups (see the roadmap).

Run it

python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"          # bare plumbing — no model deps
cp config.example.toml config.toml

python -m fusion_ocr.watcher --once   # process anything in in/, then exit
# drop a PDF into in/ ; artifacts land in out/<sha256>/
pytest                                # plumbing tests

Each job's results land in a content-addressed folder out/<sha256>/ — the three deliverables (document.md reading view, overlay.pdf searchable layer, segment_index.json provenance) plus the resumable per-stage state. What every file is: Docs/outputs.md.

Add capability behind extras as stages are implemented:

pip install -e ".[ocr,vlm,api]"
# For a byte-for-byte reproducible environment (same OCR output for a given PDF),
# install against the pinned lock — versions are bounded in pyproject, exact in the lock:
pip install -e ".[ocr,vlm,api]" -c constraints.txt
Extra Brings For
ocr PyMuPDF, PaddleOCR, PaddlePaddle triage + geometry + structure
vlm httpx, Pillow the OpenAI-compatible VLM client
api FastAPI, uvicorn, python-multipart the HTTP job API

Serving the readers (the toolkit)

Readers are OpenAI-compatible endpoints, so each tool runs wherever it's fastest and the router points per-script at the right one. Default setup on Apple Silicon:

# generalist reader — Qwen3.6-35B-A3B (MoE) via MLX (~10-40x faster than Ollama on Apple Silicon)
pip install mlx-vlm
python -m mlx_vlm.server --port 8080        # serves mlx-community/Qwen3.6-35B-A3B-4bit

# Thai specialist — Typhoon on Ollama
ollama pull scb10x/typhoon-ocr1.5-3b

The router sends Latin/handwriting/etc. → Qwen3.5/MLX (:8080), Thai → Typhoon (:11434). One config value moves any reader — Ollama, MLX, or in-VPC vLLM all speak the same API, so swapping local → transcription-GPU is just a base_url change. See Docs/routing.md.

Configuration & the job API

Configure via config.toml (copy from config.example.toml), or change the output-affecting tuning knobs on a running service over HTTP. The job API (the api extra; run it with fusion-ocr-serve) is the stable contract callers use:

Method & path Purpose
POST /jobs (multipart pdf) enqueue a PDF → 202 {sha256, status: "queued"}
GET /jobs [?status=] queue / completed-job feed
GET /jobs/{sha256} job status + artifact list (poll until done; see Docs/outputs.md)
GET /config surface every setting (secrets masked) + its constraints
PATCH /config {path: value} configure the allowlisted settings in-process
POST /config/save persist the current config to disk (explicit, opt-in)

Submit is asynchronous: POST /jobs enqueues and returns immediately. Run the API (fusion-ocr-serve) alongside a worker (fusion-ocr, the watcher) that drains the queue; clients poll GET /jobs/{sha256}. JobStore is the queue boundary and artifacts are content-addressed (storage.py), so a distributed queue / object store can drop in later.

The API is bearer-token auth'd and fails closed: set FUSION_OCR_API_TOKEN (it refuses to start without one) and send Authorization: Bearer …. Security/identity fields (airgap, in_dir, out_dir, routes) are surfaced but read-only — the API can't unseal the airgap tier or repoint paths. A tuning change re-keys the resume cache, so the next job reprocesses rather than reusing a stale result. To reach it from other machines on your LAN, set api_host = "0.0.0.0" and serve — see Docs/configuration.md for the full settings table and endpoint details, and Docs/deployment.md for a TLS deployment behind nginx (sample configs in deploy/).

Layout

src/fusion_ocr/
  models.py          the Document/Page/Region/Segment record
  pipeline.py        Stage protocol + orchestration + resume
  config.py          config + airgap guard
  settings.py        settings registry — what's surfaceable vs runtime-configurable
  raster.py          page-raster cache — render a page once, share across stages
  jobs.py            SQLite job queue (enqueue / atomic claim / status)
  storage.py         content-addressed artifact location (object-store swap point)
  watcher.py         drop-folder entrypoint + the queue worker
  api.py             HTTP job + config API (stable contract for callers)
  stages/            triage · layout · language · ocr_det · vlm_read · fusion · render
  vlm/               client protocol + OpenAI-compatible impl + prompts
  overlay/           PyMuPDF invisible-text overlay (line- → word-level)

Airgap note

PaddleOCR downloads its models to ~/.paddlex on first use. For the air-gapped sensitive tier, pre-pull the models once on a connected machine (run any OCR job), then run sealed — enforce_airgap() sets PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK so Paddle won't attempt its connectivity check.

Tool routing

The pipeline triages each region and routes it on two independent axes — geometry (always a deterministic engine — PaddleOCR, or Apple Vision when preferred — never the VLM) and reading (VLM, specialist varies). See Docs/routing.md. Thai is the first specialist route: paddle_lang=th reads Thai forms that the generalist VLMs can't, and a config [routing.thai] section points the reader at a served Typhoon endpoint when one's available.

Evaluation

Accuracy is a number, not a claim. A born-digital page carries its own exact text in the layer, so the eval renders it to an image (dropping the text layer), forces the OCR path, and scores the recovered text against the embedded text — no hand-labelling:

python -m fusion_ocr.eval report.pdf --pages 6,9,24 --apple-vision

It reports word recall / precision (order-insensitive: recognition completeness and the hallucination rate) alongside CER / WER (sequence-based, so reading order on multi-column pages inflates them — trust CER on single-column text). Caveat: rendered-clean pages are a floor on difficulty; genuinely degraded scans / handwriting need a small hand-labelled set. See src/fusion_ocr/eval/.

Design principles

Why this tool exists and how its architecture serves that is narrated in Docs/dev_notes/motivation_and_strategy.md.

Build-vs-adopt, determinism vs learned models, and auditing model defaults are codified in Docs/principles.md — the short version: orchestrate trusted tools, build only the connective tissue and the journalism-specific guarantees no component provides, and prefer a library's public surface over its internals.

Roadmap

What's shipped is logged in Docs/dev_notes/done.md; what's next — and the scale-triggered items (distributed queue, object store) — is in Docs/dev_notes/roadmap.md.

About

Hybrid deterministic + Vision-LLM OCR pipeline: trustworthy bounding-box overlays from PaddleOCR, structure-aware reading from a self-hosted VLM, fused into searchable PDFs + per-language markdown. Runs fully on-prem (Ollama/MLX or in-VPC vLLM).

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages