In deze guide bouw je een werkende tool. Aan het einde kun je dit uitvoeren:
python invoice_ocr.py input/factuur.pdfEn krijg je dit terug:
output/
βββ factuur.md # de uitgelezen factuur (markdown)
βββ factuur.json # gestructureerde, gevalideerde gegevensGeen losse snippets: je kopieert de bestanden, installeert de dependencies en draait het.
Wat het doet
Unlimited-OCR van Baidu is een vision-language model (± 3B parameters, mixture-of-experts) dat een heel document parseert: tekst, tabellen en layout, over meerdere pagina's. Het heet "Unlimited" omdat het lange documenten in één keer verwerkt: dankzij Reference Sliding Window Attention (R-SWA) blijft het geheugengebruik constant, ook bij tientallen pagina's.
Kort en simpel gezegd je kunt dit model gebruiken om grote hoeveelheden documenten te parsen zonder dat de context vol raakt.
Belangrijk om te begrijpen: OCR en data-extractie zijn twee aparte stappen.
PDF
β Unlimited-OCR (document parsing)
markdown / documentstructuur
β LLM-extractie (structured extraction)
JSON
β validatie
gegevens kloppen β boeken | twijfel β handmatige controleUnlimited-OCR doet de eerste stap: het leest de factuur en geeft nette tekst terug. Die tekst is nog vrije vorm. Een tweede, kleine LLM zet die tekst om naar een vast JSON-schema. Zo blijf je onafhankelijk van het sjabloon van de leverancier, en dat is precies waarom dit beter werkt dan vaste patronen per factuur.
Wat je nodig hebt
Wat je nodig hebt
- Taal
- Python 3.12
- Niveau
- Gemiddeld
- Hardware
- NVIDIA-GPU aanbevolen (praktijk: Β± 12 GB VRAM). Geen GPU? Zie de demo-tip.
- Packages
torchtransformerspymupdfpydanticollama- Overige tools
- CUDA (NVIDIA)Ollama (lokaal LLM)
Het model is Β± 6,7 GB en wordt bij het eerste gebruik automatisch gedownload. De VRAM-richtlijn is een praktische aanbeveling, geen officiΓ«le minimumeis; test op je eigen hardware.
Projectstructuur
We bouwen dit:
invoice-ocr/
βββ invoice_ocr.py # de tool (CLI)
βββ invoice_schema.py # Pydantic-schema + validatie
βββ requirements.txt
βββ input/ # hier zet je je PDF's
βββ output/ # hier komt de md + jsonStap 1: project opzetten en omgeving activeren
Open een terminal (Windows: PowerShell, macOS: Terminal, Linux: Ctrl + Alt + T) en maak het project aan met een virtuele omgeving, zodat de dependencies niet botsen met andere projecten.
mkdir invoice-ocr
cd invoice-ocr
mkdir input output
python -m venv .venvActiveer de omgeving. Windows (PowerShell):
.venv\Scripts\Activate.ps1Linux / macOS:
source .venv/bin/activateStap 2: dependencies installeren
Open een nieuw bestand requirements.txt:
nano requirements.txtPlak dit erin (kopieerknop rechtsboven het blok), sla op met Ctrl + O en Enter, en sluit met Ctrl + X:
torch==2.10.0
torchvision==0.25.0
transformers==4.57.1
Pillow==12.1.1
einops==0.8.2
addict==2.4.0
easydict==1.13
pymupdf==1.27.2.2
pydantic==2.9.2
ollama==0.3.3Installeer alles:
pip install -r requirements.txtControleer dat je GPU zichtbaar is voor PyTorch:
python -c "import torch; print(torch.cuda.is_available())"Zie je True, dan is alles in orde. Zie je False, dan draai je zonder GPU; gebruik dan de demo of een cloudmachine met een GPU.
We gebruiken twee AI-modellen, elk voor een eigen taak:
- Unlimited-OCR leest de PDF en geeft de tekst terug (dat installeerde je hierboven al via
transformers). - Een tweede, klein taalmodel zet die tekst om naar gestructureerde JSON. Daarvoor gebruiken we
llama3.1, lokaal via Ollama.
Installeer Ollama en haal het tweede model op:
ollama pull llama3.1Dit llama3.1-model is dus het model dat de JSON maakt. In de code (stap 4) vragen we het met format="json" om uitsluitend geldige JSON terug te geven volgens ons schema.
Stap 3: het schema en de validatie
Dit bestand definieert welke velden we willen (met Pydantic) en de controles die bepalen of een factuur automatisch verwerkt mag worden. Open een nieuw bestand:
nano invoice_schema.pyPlak onderstaande code erin, sla op met Ctrl + O en Enter, en sluit met Ctrl + X:
from __future__ import annotations
import re
from datetime import date
from pydantic import BaseModel, Field
class InvoiceLine(BaseModel):
description: str | None = None
quantity: float | None = None
unit_price: float | None = None
vat_rate: float | None = None
total: float | None = None
class Invoice(BaseModel):
supplier: str | None = Field(None, description="Naam van de leverancier.")
invoice_number: str | None = None
invoice_date: date | None = None
due_date: date | None = None
kvk: str | None = None
vat_number: str | None = None
iban: str | None = None
currency: str | None = None
subtotal: float | None = None
vat: float | None = None
total: float | None = None
lines: list[InvoiceLine] = Field(default_factory=list)
def _valid_iban(iban: str) -> bool:
s = re.sub(r"\s+", "", iban).upper()
if not re.fullmatch(r"[A-Z0-9]{15,34}", s):
return False
rearranged = s[4:] + s[:4]
digits = "".join(str(int(c, 36)) for c in rearranged)
return int(digits) % 97 == 1
def validate_invoice(inv: Invoice) -> list[str]:
"""Geeft een lijst met problemen terug. Leeg = klaar om te verwerken."""
problems: list[str] = []
if not inv.supplier:
problems.append("Leverancier ontbreekt")
if not inv.invoice_number:
problems.append("Factuurnummer ontbreekt")
if inv.invoice_date is None:
problems.append("Factuurdatum ontbreekt of is ongeldig")
if inv.total is None:
problems.append("Totaalbedrag ontbreekt")
# Rekenkundige controle: subtotaal + btw β totaal (1 cent speling).
if inv.subtotal is not None and inv.vat is not None and inv.total is not None:
if abs((inv.subtotal + inv.vat) - inv.total) > 0.01:
problems.append(f"Bedragen kloppen niet: {inv.subtotal} + {inv.vat} β {inv.total}")
if inv.due_date and inv.invoice_date and inv.due_date < inv.invoice_date:
problems.append("Vervaldatum ligt vΓ³Γ³r de factuurdatum")
if inv.iban and not _valid_iban(inv.iban):
problems.append(f"IBAN lijkt ongeldig: {inv.iban}")
return problemsWe gebruiken hier geen regex om velden te raden, alleen om een IBAN wiskundig te controleren. Dat verschil is belangrijk: het herkennen laten we aan het LLM, het valideren doen we hard.
Stap 4: de tool
Dit is de volledige tool: PDF omzetten, uitlezen met Unlimited-OCR, structureren met het LLM, valideren en wegschrijven. Het model wordt maar één keer geladen. Open een nieuw bestand:
nano invoice_ocr.pyPlak onderstaande code erin, sla op met Ctrl + O en Enter, en sluit met Ctrl + X:
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
import tempfile
from pathlib import Path
def pdf_to_images(pdf_path: Path, dpi: int = 300) -> tuple[list[str], Path]:
import fitz # PyMuPDF
tmp_dir = Path(tempfile.mkdtemp(prefix="invoice_ocr_"))
scale = fitz.Matrix(dpi / 72, dpi / 72)
paths: list[str] = []
with fitz.open(pdf_path) as doc:
for i, page in enumerate(doc):
out = tmp_dir / f"page_{i + 1:04d}.png"
page.get_pixmap(matrix=scale).save(out)
paths.append(str(out))
return paths, tmp_dir
_DET_RE = re.compile(r"<\|det\|>([^<\s]+)(?:\s*\[[^\]]*\])?\s*<\|/det\|>(.*)", re.DOTALL)
def strip_det_markers(raw: str) -> str:
"""Verwijder de <|det|>...<|/det|> markers uit de OCR-uitvoer."""
blocks: list[list[str]] = []
current: list[str] | None = None
for line in raw.splitlines():
line = line.rstrip()
if not line:
continue
m = _DET_RE.match(line)
if m:
category, content = m.group(1).strip(), m.group(2).strip()
if category == "image":
continue
if current is not None:
blocks.append(current)
current = [content] if content else []
continue
if current is None:
current = []
current.append(line)
if current is not None:
blocks.append(current)
return "\n\n".join("\n".join(b) for b in blocks).strip()
class OCRModel:
"""Laadt Unlimited-OCR één keer."""
def __init__(self, model_name: str) -> None:
import torch
from transformers import AutoModel, AutoTokenizer
if not torch.cuda.is_available():
raise RuntimeError("Geen CUDA-GPU gevonden. Gebruik een NVIDIA-GPU of de online demo.")
self.tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
self.model = (
AutoModel.from_pretrained(
model_name, trust_remote_code=True, use_safetensors=True, torch_dtype=torch.bfloat16
)
.eval()
.cuda()
)
def read(self, image_paths: list[str]) -> str:
out_dir = Path(tempfile.mkdtemp(prefix="invoice_ocr_out_"))
try:
self.model.infer_multi(
self.tokenizer,
prompt="<image>Multi page parsing.",
image_files=image_paths,
output_path=str(out_dir),
image_size=1024,
max_length=32768,
no_repeat_ngram_size=35,
ngram_window=1024,
save_results=True,
)
texts: list[str] = []
for pattern in ("*.mmd", "*.md", "*.txt"):
for path in sorted(out_dir.glob(pattern)):
texts.append(path.read_text(encoding="utf-8"))
return strip_det_markers("\n".join(texts))
finally:
shutil.rmtree(out_dir, ignore_errors=True)
EXTRACTION_PROMPT = """Je krijgt de uitgelezen tekst van een factuur.
Geef uitsluitend geldige JSON terug die exact dit schema volgt:
{schema}
Regels:
- Bedragen als getallen (punt als decimaalteken), geen valutasymbool.
- Datums als YYYY-MM-DD.
- Onbekende velden: null. lines is een lijst (mag leeg zijn).
- Geen uitleg, alleen de JSON.
Factuurtekst:
---
{text}
---
"""
def extract_json(markdown: str, model: str = "llama3.1") -> dict:
"""Laat een lokaal LLM (via Ollama) de tekst omzetten naar JSON."""
import ollama
from invoice_schema import Invoice
prompt = EXTRACTION_PROMPT.format(schema=Invoice.model_json_schema(), text=markdown)
response = ollama.chat(
model=model,
messages=[{"role": "user", "content": prompt}],
format="json",
options={"temperature": 0},
)
return json.loads(response["message"]["content"])
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Lees een PDF-factuur uit met Unlimited-OCR.")
parser.add_argument("pdf", type=Path, help="Pad naar de PDF-factuur.")
parser.add_argument("--output-dir", type=Path, default=Path("output"))
parser.add_argument("--model", default="baidu/Unlimited-OCR")
parser.add_argument("--llm", default="llama3.1")
parser.add_argument("--dpi", type=int, default=300)
return parser.parse_args()
def main() -> int:
from invoice_schema import Invoice, validate_invoice
args = parse_args()
if not args.pdf.is_file():
print(f"Bestand niet gevonden: {args.pdf}", file=sys.stderr)
return 1
args.output_dir.mkdir(parents=True, exist_ok=True)
stem = args.pdf.stem
images, tmp_dir = pdf_to_images(args.pdf, dpi=args.dpi)
try:
print(f"[1/3] {len(images)} pagina('s) uitlezen ...")
markdown = OCRModel(args.model).read(images)
(args.output_dir / f"{stem}.md").write_text(markdown, encoding="utf-8")
print(f"[2/3] Structureren met {args.llm} ...")
invoice = Invoice.model_validate(extract_json(markdown, model=args.llm))
print("[3/3] Valideren ...")
problems = validate_invoice(invoice)
result = invoice.model_dump(mode="json")
result["_needs_review"] = bool(problems)
result["_problems"] = problems
(args.output_dir / f"{stem}.json").write_text(
json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8"
)
if problems:
print("\nβ Handmatige controle nodig:")
for p in problems:
print(f" - {p}")
else:
print("\nβ Validatie geslaagd.")
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())Stap 5: uitvoeren
Zet een factuur in input/ en draai:
python invoice_ocr.py input/factuur.pdfWat je ziet gebeuren
Eerst geeft Unlimited-OCR de factuur terug als markdown (output/factuur.md), ongeveer zo:
# Voorbeeld B.V.
KvK: 12345678 Β· Btw: NL001234567B01
Factuur INV-2026-001
Factuurdatum: 29-08-2026
Vervaldatum: 28-09-2026
| Omschrijving | Aantal | Prijs | Btw | Totaal |
| ------------ | ------ | ------ | --- | ------ |
| Hosting | 1 | 100,00 | 21% | 100,00 |
Subtotaal: β¬ 100,00
Btw 21%: β¬ 21,00
Totaal: β¬ 121,00
IBAN: NL91 ABNA 0417 1643 00Vervolgens zet de extractiestap dat om naar output/factuur.json:
{
"supplier": "Voorbeeld B.V.",
"invoice_number": "INV-2026-001",
"invoice_date": "2026-08-29",
"due_date": "2026-09-28",
"kvk": "12345678",
"vat_number": "NL001234567B01",
"iban": "NL91ABNA0417164300",
"currency": "EUR",
"subtotal": 100.0,
"vat": 21.0,
"total": 121.0,
"lines": [
{
"description": "Hosting",
"quantity": 1,
"unit_price": 100.0,
"vat_rate": 21,
"total": 100.0
}
],
"_needs_review": false,
"_problems": []
}De validatie heeft gecontroleerd dat subtotal + vat = total (100 + 21 = 121), dat de verplichte velden aanwezig zijn en dat de IBAN klopt. Faalt een van die controles, dan staat "_needs_review": true met de reden erbij, en zet je de factuur klaar voor een mens in plaats van hem automatisch te boeken.
In een echte automation
Voor productie laad je het model niet per factuur opnieuw; dat kost elke keer tientallen seconden. Je draait Unlimited-OCR als langlevende OCR-service en roept die aan vanuit je automation. Het model ondersteunt hiervoor officieel vLLM en SGLang, beide met een OpenAI-compatibele API. Je automatiseringsplatform (n8n, Zapier, Make of een eigen applicatie) praat dan met die API:
factuur uit e-mail
β
n8n / Zapier / Make (trigger + flow)
β HTTP
OCR-service (vLLM / SGLang) (Unlimited-OCR, altijd geladen)
β
LLM-extractie β validatie
β
gegevens kloppen β Exact / AFAS / SnelStart
twijfel β taak voor een medewerkerZo verwerk je facturen die binnenkomen via e-mail volledig automatisch, met een menselijke controle alleen waar de validatie erom vraagt.
Wat kun je ermee
- Inkomende facturen automatisch uitlezen en als gevalideerde JSON klaarzetten.
- Gescande en gefotografeerde facturen verwerken, niet alleen digitale PDF's.
- Facturen van willekeurige leveranciers aan zonder per sjabloon regels te schrijven.
- Koppelen aan n8n, Zapier of Make om de hele flow vanaf de mailbox te automatiseren.
- De gevalideerde gegevens doorzetten naar AFAS, Exact Online of SnelStart (volgend artikel).
