Open-Source Pipeline with Tesseract OCR and spaCy

Solution for the use case Document Processing & Information Extraction Pipeline.

AI tools
Tesseract OCR, spaCy, scikit-learn, pypdf, Pillow, FastAPI
Language
Python
Published
2025-07-05

Approach

A self-hosted pipeline built only from open-source parts, for teams that cannot send documents to a cloud service.

  • Text: the PDF text layer is read with pypdf; scans and images go through Tesseract OCR after cleanup with Pillow (grayscale, contrast, denoise, binarize).
  • Document type: a TF-IDF + logistic regression classifier (scikit-learn) labels invoices, contracts, receipts and reports.
  • Fields: spaCy with an EntityRuler finds dates, amounts and e-mail addresses without downloading a model; rules turn them into invoice number, dates, subtotal, tax, total or contract parties and dates.
  • Checks: required fields, subtotal + tax = total and date order decide between valid, requires_review and invalid.
  • A FastAPI service with a Dockerfile (including Tesseract) exposes it as POST /documents.

Implementation steps

  1. Install Tesseract (the Dockerfile adds tesseract-ocr) and the pinned Python packages.
  2. Read the text layer of PDFs with pypdf; if a page has almost no text, OCR its embedded images.
  3. Clean up images (grayscale, autocontrast, median filter, threshold) and run Tesseract with --oem 3 --psm 6.
  4. Classify the document type with TF-IDF + logistic regression; retrain it on your own labelled documents.
  5. Run a blank spaCy English pipeline with EntityRuler patterns for dates, money and e-mail addresses.
  6. Map entities to fields from the words before them on the same line (Total, VAT, Due, effective, between ... and ...).
  7. Normalize dates to ISO and amounts to numbers, then validate required fields, totals and date order.
  8. Serve the pipeline with FastAPI: file type and size limits, random temp file names, clear error codes.
  9. Measure field accuracy on a labelled sample of your documents and add rules or a trained NER model where it falls short.

Code (Python)

"""Rule-based entity extraction with spaCy: no model download needed.

A blank English pipeline gets an EntityRuler with patterns for dates, money
and e-mail addresses. Field extraction then looks at the words around each
entity ("Total", "Tax", "effective", ...). Set SPACY_MODEL=en_core_web_sm
(after installing it) to add the statistical NER for ORG/PERSON as well.
"""
import os
import re
from datetime import date, datetime
from functools import lru_cache

import spacy
from spacy.language import Language
from spacy.tokens import Doc, Span

MONTHS = ["january", "february", "march", "april", "may", "june", "july", "august", "september",
          "october", "november", "december", "jan", "feb", "mar", "apr", "jun", "jul", "aug", "sep",
          "sept", "oct", "nov", "dec"]
CURRENCY = ["$", "€", "£", "EUR", "USD", "GBP", "CHF"]
PATTERNS = [
    {"label": "DATE", "pattern": [{"SHAPE": "dddd"}, {"ORTH": "-"}, {"SHAPE": "dd"}, {"ORTH": "-"}, {"SHAPE": "dd"}]},
    {"label": "DATE", "pattern": [{"TEXT": {"REGEX": r"^\d{1,2}[./]\d{1,2}[./]\d{4}$"}}]},
    {"label": "DATE", "pattern": [{"LOWER": {"IN": MONTHS}}, {"SHAPE": {"IN": ["d", "dd"]}},
                                  {"ORTH": ",", "OP": "?"}, {"SHAPE": "dddd"}]},
    {"label": "DATE", "pattern": [{"SHAPE": {"IN": ["d", "dd"]}}, {"LOWER": {"IN": MONTHS}}, {"SHAPE": "dddd"}]},
    {"label": "MONEY", "pattern": [{"ORTH": {"IN": CURRENCY}}, {"LIKE_NUM": True}]},
    {"label": "MONEY", "pattern": [{"LIKE_NUM": True}, {"ORTH": {"IN": CURRENCY}}]},
    {"label": "EMAIL", "pattern": [{"LIKE_EMAIL": True}]},
]
DATE_FORMATS = ["%Y-%m-%d", "%d/%m/%Y", "%d.%m.%Y", "%B %d %Y", "%b %d %Y", "%d %B %Y", "%d %b %Y"]


@lru_cache(maxsize=1)
def get_nlp() -> Language:
    name = os.environ.get("SPACY_MODEL")
    nlp = spacy.load(name) if name else spacy.blank("en")
    ruler = nlp.add_pipe("entity_ruler", before="ner" if "ner" in nlp.pipe_names else None)
    ruler.add_patterns(PATTERNS)
    return nlp


def parse_date(text: str, day_first: bool = True) -> date | None:
    clean = re.sub(r"\s+", " ", text.replace(",", "")).replace(" - ", "-").replace(" -", "-").strip()
    clean = clean.replace("Sept ", "Sep ")
    formats = DATE_FORMATS if day_first else [f.replace("%d/%m", "%m/%d") for f in DATE_FORMATS]
    for fmt in formats:
        try:
            return datetime.strptime(clean, fmt).date()
        except ValueError:
            continue
    return None


def parse_money(span: Span) -> float | None:
    number = next((t.text for t in span if t.like_num), None)
    if number is None:
        return None
    if re.fullmatch(r"\d{1,3}(\.\d{3})+,\d{2}", number):  # 1.234,56
        number = number.replace(".", "").replace(",", ".")
    return float(number.replace(",", ""))


def context(doc: Doc, span: Span, window: int = 5) -> str:
    """Lower-case words before the entity on the same line."""
    start = span.start
    while start > 0 and span.start - start < window and "\n" not in doc[start - 1].text_with_ws:
        start -= 1
    return doc[start:span.start].text.lower()


def token_after(doc: Doc, keywords: tuple[str, ...]) -> str | None:
    """Text glued together after e.g. 'Invoice No:' -> 'INV-2026-0042'."""
    for i, tok in enumerate(doc):
        if tok.lower_ in keywords:
            j = i + 1
            while j < len(doc) and (doc[j].lower_ in {"no", "nr", "number", "#", ":", "."} or doc[j].is_space):
                j += 1
            parts = []
            while j < len(doc) and not doc[j].is_space:
                parts.append(doc[j].text)
                if doc[j].whitespace_:
                    break
                j += 1
            if parts:
                return "".join(parts)
    return None


def extract_fields(text: str, doc_type: str) -> dict:
    doc = get_nlp()(text)
    dates = [(e, parse_date(e.text)) for e in doc.ents if e.label_ == "DATE"]
    money = [(e, parse_money(e)) for e in doc.ents if e.label_ == "MONEY"]
    lines = [line.strip() for line in te
…

Performance

Targets, not measured results:

  • Header fields (number, dates, totals) correct on at least 95 % of clean, labelled invoices in your own sample.
  • Text PDFs take well under a second; OCR typically takes a few seconds per page on one CPU core, depending on resolution.
  • Throughput scales with the number of worker processes; no per-page fees.

Lessons learned

Teams send a file to one REST endpoint and get JSON back: document type with confidence, extracted fields, how the text was obtained (text layer or OCR) and a validation status with reasons. Documents never leave your own servers, and rules can be adjusted in code when a new layout shows up.

Other solutions for this use case

Back to the use case · All AI use cases