Veröffentlicht am

PDF-Qualität mit KI verbessern: VLMs und OCR auf Kubernetes

Teilen:
Authors

TL;DR

  • Vision Language Models (VLMs) uebertreffen klassische OCR deutlich bei komplexen Layouts, Tabellen und handschriftlichen Dokumenten.
  • Eine produktive Pipeline kombiniert Preprocessing (Super-Resolution, Denoising), VLM-Analyse und Post-Processing in containerisierten Microservices.
  • GPU-Workloads auf Kubernetes erfordern den NVIDIA Device Plugin, passende Resource Requests und durchdachtes Scheduling.
  • Startet mit einem FastAPI-Service hinter einem Kubernetes Deployment mit HPA. Skaliert GPU-Pods horizontal basierend auf Queue-Laenge.
  • Messt Qualitaet quantitativ: Word Accuracy, BLEU-Score und Layout-Preservation sind die relevanten Metriken.

Das Problem mit klassischer OCR

Wer schon mal Tesseract auf ein eingescanntes Formular aus den 90ern losgelassen hat, kennt das Ergebnis: Buchstabensalat, kaputte Tabellen, fehlende Absaetze. Klassische OCR arbeitet zeichenbasiert und versteht weder Layout noch Kontext.

Typische Schwaechen:

  • Handschriftliche oder schlecht gedruckte Texte werden falsch erkannt
  • Tabellenstrukturen gehen verloren (Zellen werden zu Fliesstext)
  • Mehrspaltige Layouts werden in falscher Reihenfolge gelesen
  • Bilder, Diagramme und Formulare werden ignoriert

Vision Language Models loesen diese Probleme, weil sie Dokumente als Bild verstehen und gleichzeitig Text-Semantik anwenden koennen.

VLM-basierte Pipeline: Architektur

Eine produktive PDF-Pipeline besteht aus drei Stufen:

1. Preprocessing: Bildqualitaet verbessern, bevor das VLM ueberhaupt sieht. 2. VLM-Analyse: Layout-Erkennung, Textextraktion, Tabellen-Parsing. 3. Post-Processing: Ergebnisse validieren, Format aufbereiten, Konfidenz berechnen.

Preprocessing: Super-Resolution und Denoising

Schlechte Scan-Qualitaet ist der haeufigste Grund fuer schlechte Ergebnisse. Bevor ihr ein teures VLM drauf ansetzt, verbessert das Eingabebild:

# preprocessing.py
import fitz  # PyMuPDF
from PIL import Image, ImageFilter, ImageEnhance
import numpy as np

def extract_page_as_image(pdf_path: str, page_num: int, zoom: float = 2.0) -> Image.Image:
    """Extrahiert eine PDF-Seite als hochaufgeloestes Bild."""
    doc = fitz.open(pdf_path)
    page = doc.load_page(page_num)
    mat = fitz.Matrix(zoom, zoom)
    pix = page.get_pixmap(matrix=mat)
    img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
    doc.close()
    return img

def enhance_for_ocr(image: Image.Image) -> Image.Image:
    """Optimiert ein Bild fuer nachfolgende OCR/VLM-Verarbeitung."""
    # Kontrast erhoehen
    enhancer = ImageEnhance.Contrast(image)
    image = enhancer.enhance(1.5)

    # Schaerfen
    image = image.filter(ImageFilter.SHARPEN)

    # In Graustufen konvertieren und zurueck (reduziert Rauschen)
    gray = image.convert("L")
    # Adaptive Binarisierung per Schwellwert
    threshold = np.mean(np.array(gray))
    binary = gray.point(lambda x: 255 if x > threshold else 0)

    return binary.convert("RGB")

VLM-Analyse mit TrOCR und LayoutLMv3

Fuer die eigentliche Textextraktion kombinieren wir zwei Modelle:

  • TrOCR (microsoft/trocr-base-handwritten): Transformer-basierte OCR, besonders stark bei Handschrift
  • LayoutLMv3 (microsoft/layoutlmv3-base): Versteht Dokumenten-Layouts und kann Regionen klassifizieren
# vlm_processor.py
from transformers import TrOCRProcessor, VisionEncoderDecoderModel
from transformers import LayoutLMv3Processor, LayoutLMv3ForSequenceClassification
import torch
from PIL import Image
from typing import List, Dict

class DocumentProcessor:
    def __init__(self, device: str = None):
        self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")

        # TrOCR fuer Texterkennung
        self.trocr_processor = TrOCRProcessor.from_pretrained(
            "microsoft/trocr-base-handwritten"
        )
        self.trocr_model = VisionEncoderDecoderModel.from_pretrained(
            "microsoft/trocr-base-handwritten"
        ).to(self.device)

        # LayoutLMv3 fuer Dokumentenverstaendnis
        self.layout_processor = LayoutLMv3Processor.from_pretrained(
            "microsoft/layoutlmv3-base"
        )
        self.layout_model = LayoutLMv3ForSequenceClassification.from_pretrained(
            "microsoft/layoutlmv3-base"
        ).to(self.device)

    def extract_text(self, image: Image.Image) -> str:
        """Extrahiert Text aus einem Bild per TrOCR."""
        pixel_values = self.trocr_processor(
            image, return_tensors="pt"
        ).pixel_values.to(self.device)

        with torch.no_grad():
            generated_ids = self.trocr_model.generate(pixel_values, max_new_tokens=512)

        return self.trocr_processor.batch_decode(
            generated_ids, skip_special_tokens=True
        )[0]

    def classify_regions(self, image: Image.Image) -> List[Dict]:
        """Klassifiziert Regionen eines Dokuments (Header, Table, Text, etc.)."""
        encoding = self.layout_processor(
            image, return_tensors="pt", truncation=True
        )
        encoding = {k: v.to(self.device) for k, v in encoding.items()}

        with torch.no_grad():
            outputs = self.layout_model(**encoding)
            predictions = torch.softmax(outputs.logits, dim=1)

        labels = ["header", "footer", "title", "text", "table",
                  "figure", "list", "form", "signature"]

        return [
            {"label": labels[i], "confidence": predictions[0][i].item()}
            for i in range(len(labels))
            if predictions[0][i].item() > 0.1
        ]

Kubernetes-Deployment fuer GPU-Workloads

KI-Modelle brauchen GPUs. Auf Kubernetes managt ihr GPU-Zugang ueber den NVIDIA Device Plugin und Resource Requests.

Deployment-Manifest

# pdf-processor-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pdf-processor
  namespace: document-ai
spec:
  replicas: 2
  selector:
    matchLabels:
      app: pdf-processor
  template:
    metadata:
      labels:
        app: pdf-processor
    spec:
      containers:
        - name: processor
          image: registry.example.com/pdf-processor:v1.2.0
          ports:
            - containerPort: 8000
          resources:
            requests:
              cpu: "2"
              memory: "8Gi"
              nvidia.com/gpu: "1"
            limits:
              cpu: "4"
              memory: "16Gi"
              nvidia.com/gpu: "1"
          env:
            - name: MODEL_CACHE_DIR
              value: "/models"
            - name: WORKERS
              value: "2"
          volumeMounts:
            - name: model-cache
              mountPath: /models
            - name: upload-volume
              mountPath: /data/uploads
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 30
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 60
            periodSeconds: 30
      volumes:
        - name: model-cache
          persistentVolumeClaim:
            claimName: model-cache-pvc
        - name: upload-volume
          persistentVolumeClaim:
            claimName: upload-pvc
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
      nodeSelector:
        accelerator: nvidia-a100
---
apiVersion: v1
kind: Service
metadata:
  name: pdf-processor
  namespace: document-ai
spec:
  selector:
    app: pdf-processor
  ports:
    - port: 80
      targetPort: 8000
  type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: pdf-processor-hpa
  namespace: document-ai
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: pdf-processor
  minReplicas: 1
  maxReplicas: 6
  metrics:
    - type: Pods
      pods:
        metric:
          name: queue_depth
        target:
          type: AverageValue
          averageValue: "5"

Wichtige Details:

  • nvidia.com/gpu: "1" in den Resource Requests stellt sicher, dass der Pod auf einem Node mit GPU scheduliert wird.
  • tolerations und nodeSelector sorgen dafuer, dass GPU-Pods nur auf GPU-Nodes landen.
  • Der HPA skaliert basierend auf einer Custom Metric (Queue-Tiefe), nicht auf CPU. Das ist bei GPU-Workloads sinnvoller.
  • Model-Cache per PVC: Modelle werden nur einmal heruntergeladen und persistent gespeichert. Das spart Startup-Zeit bei Pod-Restarts.

API-Service mit FastAPI

Der eigentliche Service exponiert eine REST-API und verarbeitet PDFs asynchron:

# api.py
from fastapi import FastAPI, UploadFile, File, BackgroundTasks
from fastapi.responses import JSONResponse
import uuid
import os
import redis
import json

app = FastAPI(title="PDF Quality Enhancement API")
cache = redis.Redis(host=os.getenv("REDIS_HOST", "redis"), port=6379, db=0)

processor = DocumentProcessor()

@app.post("/process")
async def process_pdf(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
    """Nimmt ein PDF entgegen und startet asynchrone Verarbeitung."""
    task_id = str(uuid.uuid4())
    upload_path = f"/data/uploads/{task_id}.pdf"

    with open(upload_path, "wb") as f:
        content = await file.read()
        f.write(content)

    cache.set(f"task:{task_id}", json.dumps({"status": "queued"}))
    background_tasks.add_task(run_pipeline, upload_path, task_id)

    return {"task_id": task_id, "status": "queued"}

@app.get("/status/{task_id}")
async def get_status(task_id: str):
    """Gibt den Verarbeitungsstatus zurueck."""
    result = cache.get(f"task:{task_id}")
    if not result:
        return JSONResponse(status_code=404, content={"error": "Task nicht gefunden"})
    return json.loads(result)

@app.get("/health")
async def health():
    return {"status": "ok", "gpu_available": torch.cuda.is_available()}

async def run_pipeline(pdf_path: str, task_id: str):
    """Verarbeitet ein PDF durch die gesamte Pipeline."""
    try:
        cache.set(f"task:{task_id}", json.dumps({"status": "processing"}))

        from preprocessing import extract_page_as_image, enhance_for_ocr

        doc = fitz.open(pdf_path)
        results = []

        for page_num in range(len(doc)):
            image = extract_page_as_image(pdf_path, page_num)
            enhanced = enhance_for_ocr(image)
            text = processor.extract_text(enhanced)
            regions = processor.classify_regions(enhanced)

            results.append({
                "page": page_num + 1,
                "text": text,
                "regions": regions
            })

        doc.close()
        output = {"status": "completed", "pages": len(results), "results": results}
        cache.set(f"task:{task_id}", json.dumps(output))

    except Exception as e:
        cache.set(f"task:{task_id}", json.dumps({"status": "failed", "error": str(e)}))

Qualitaet messen

Subjektive Bewertung reicht nicht. Definiert quantitative Metriken:

MetrikWas sie misstZielwert
Word AccuracyAnteil korrekt erkannter Woerter> 95%
Character Error Rate (CER)Fehlerrate auf Zeichenebene< 3%
BLEU ScoreAehnlichkeit zum Referenztext> 0.85
Table Structure AccuracyKorrekte Zeilen/Spalten-Zuordnung> 90%
Layout PreservationStrukturelle Aehnlichkeit zum Original> 85%

Erstellt ein Ground-Truth-Dataset mit 50-100 manuell transkribierten Dokumenten und evaluiert automatisiert nach jedem Modell-Update.

Vergleich: Klassische OCR vs. VLM

KriteriumTesseract (klassisch)TrOCR + LayoutLMv3
Gedruckter Text (sauber)95%+ Accuracy98%+ Accuracy
Handschrift40-60%85%+
TabellenStruktur geht verlorenStruktur bleibt erhalten
MehrspaltigFalsche LesereihenfolgeKorrekte Regionen-Erkennung
FormulareNur Text, keine FelderFeld-Label-Zuordnung
GeschwindigkeitSehr schnell (CPU)Langsamer (GPU empfohlen)
Infrastruktur-KostenMinimalGPU-Nodes noetig

VLMs sind nicht immer die richtige Wahl. Fuer saubere, gedruckte Dokumente ist Tesseract schneller und guenstiger. Die Staerke der VLMs zeigt sich bei schwierigen Dokumenten: schlechte Scans, Handschrift, komplexe Layouts.

Typische Fehler beim Deployment

Modelle im Container-Image baken. KI-Modelle sind mehrere GB gross. Wenn sie im Image liegen, wird jeder Build und Pull langsam. Nutzt stattdessen ein PersistentVolume oder einen Model-Registry-Service.

Keine GPU-Limits setzen. Ohne explizite GPU-Limits kann ein Pod alle GPUs eines Nodes belegen. Setzt immer nvidia.com/gpu: "1" (oder den gewuenschten Wert).

Synchrone Verarbeitung. PDFs koennen Hunderte Seiten haben. Synchrone API-Calls fuehren zu Timeouts. Nutzt asynchrone Verarbeitung mit einer Queue (Redis, RabbitMQ) und einem separaten Worker-Deployment.

Weitergehende Artikel


Ihr wollt eine KI-basierte Dokumentenverarbeitung aufsetzen und braucht Unterstuetzung beim Kubernetes-Setup, GPU-Scheduling oder der Pipeline-Architektur? Meldet euch unter /kontakt.

Kubernetes-Beratung gesucht?

Wir helfen deutschen Unternehmen bei der Kubernetes-Implementierung, Migration und Optimierung. DSGVO-konform und praxiserprobt.

📖 Verwandte Artikel

Weitere interessante Beiträge zu ähnlichen Themen