4.4. 🚀 OCR in Python mit PyTesseract#

4.4.1. Hinweise zur Ausführung des Notebooks#

Dieses Notebook kann auf unterschiedlichen Levels erarbeitet werden (siehe Abschnitt „Technische Voraussetzungen“):

  1. Book-Only Mode

  2. Cloud Mode: Dafür auf 🚀 klicken und z.B. in Colab ausführen.

  3. Local Mode: Dafür auf Herunterladen ↓ klicken und „.ipynb“ wählen.

Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️

Voraussetzungen zur Ausführung des Jupyter Notebooks

  1. Installieren der Bibliotheken
  2. 2. Laden der Daten (z.B. über den Command `wget` (s.u.))
  3. 3. Pfad zu den Daten setzen
Zum Testen: Ausführen der Zelle „load libraries“ und der Sektion „Einlesen des Texts“.
Alle Zellen, die mit 🚀 gekennzeichnet sind, werden nur bei der Ausführung des Notebooks in Colab / JupyterHub bzw. lokal ausgeführt.

4.4.2. OCR mit Python#

In diesem Notebook werden wir pyTesseract ausführen, um maschinenlesbaren Text zu erzeugen aus:

  • einem JPEG-Bild

  • einem mehrseitigen PDF

  • einem Korpus mehrseitiger PDFs

4.4.3. Installationen und Importe #

Hide code cell content
# 🚀 Install libraries
import sys
if 'google.colab' in sys.modules:
    !sudo apt install tesseract-ocr
    !sudo apt install tesseract-ocr-frk
    !sudo apt install poppler-utils
!pip install pytesseract pillow
!pip install pdf2image
!pip install tqdm
Hide code cell content
import pytesseract
from PIL import Image
from pathlib import Path
from pdf2image import convert_from_path
from tqdm import tqdm

4.4.4. Verarbeitung eines Bildes #

Hide code cell content
!wget https://raw.githubusercontent.com/quadriga-dk/Text-Fallstudie-1/refs/heads/main/assets/images/grippe.jpeg
../_images/grippe.jpeg

So können wir OCR auf dieses Bild des Zeitungsartikels (‘Die Grippe wütet weiter’) durchführen:

ocr_output = pytesseract.image_to_string(Image.open('grippe.jpeg'), lang='frk') 
print(ocr_output)
Zie Grippe wüfel weiter

Zunahme der ſchweren Fälle in Berlin.

Die Zahl der Grippefälle iſt in den lezten
beider Tagen auch in Groß-Berlin noH
erf>lig zeftiegen. Die Warenhäuſer und ſon-
Haen aroßen GeſHöäfte, die Krirgs- unh die prie
n Betriebe lagen, daß übermäig viele An«-

. fich 5cben rep? melden müſſen,-und an<
; .“ Loft und 5ei der Straßenbahn iſt der
ſos der Grippelranten bedeuten) gt&

MeB 4 2 8 1

4.4.5. Verschiedene Typen von OCR-Fehlern#

Betrachten wir dieses Beispiel, so fallen sofort zahlreiche Fehler auf. Bereits das allererste Zeichen des ersten Wortes ist falsch: „Zie“ statt „Die“. Dies ist ein sehr häufiger OCR-Fehlertyp, bei dem ein Zeichen mit einem anderen verwechselt wird — in diesem Fall liegt die Ursache vermutlich unter anderem in der unterschiedlichen Druckfarbintensität verschiedener Bereiche des Buchstabens „D“. Dasselbe ist bei beiden „t“-Buchstaben im Wort „wütet“ geschehen: Sie wurden fälschlicherweise als „f“ bzw. „l“ erkannt. Solche Fehler bezeichnen wir als Substitutionsfehler.

Manchmal werden Zeichen nicht durch andere ersetzt, sondern gar nicht erst erkannt. Dies ist beispielsweise bei den meisten Zeichen im Wort „Angestellte“ der Fall — es wurde lediglich als „An«“ ausgegeben. Derartige Fehler lassen sich als Auslassungsfehler (engl. omission errors) klassifizieren.

Darüber hinaus treten gelegentlich zusätzliche Zeichen auf, die im Original nicht vorhanden sind. Dieser Fehlertyp lässt sich allerdings nicht immer eindeutig von einer Substitution abgrenzen — etwa dann, wenn ein einzelnes Zeichen der Vorlage im OCR-Ergebnis zu zwei oder mehr Zeichen wird.

Schließlich gibt es Abweichungen zwischen dem gewünschten und dem tatsächlichen OCR-Ergebnis, die sich nicht als Fehler im engeren Sinne einordnen lassen, sondern vielmehr Unterschiede in den Normalisierungskonventionen darstellen. So finden wir in der Originalquelle das lange s (ſ, U+017F, LATIN SMALL LETTER LONG S) an Stellen, an denen die moderne Orthographie ein gewöhnliches s (U+0073) vorsieht. Technisch handelt es sich dabei um zwei verschiedene Unicode-Zeichen. Ob eine Normalisierung vorgenommen wird oder nicht, ist letztlich eine Frage der Konvention und des jeweiligen Erkenntnisinteresses. Viele OCR-Engines normalisieren solche historischen Zeichenvarianten stillschweigend, andere bewahren sie im Sinne der editionsphilologischen Treue. Tesseract nimmt hier keine Normalisierung vor.

🚀 Selbst ausprobieren#

Nutzen Sie das interaktive Werkzeug unten, um die OCR-Fehler direkt zu erkunden. Klicken Sie auf eine der Schaltflächen, um Substitutionen, fehlende Zeichen, zusätzliche Zeichen oder Fraktur-Verwechslungen hervorzuheben. Die entsprechenden Stellen im Ground Truth werden automatisch markiert, sodass Sie genau sehen können, wie und wo die OCR vom Originaltext abgewichen ist. Sie können auch „Show missing GT“ aktivieren, um Zeichen anzuzeigen, die von der OCR übersprungen wurden.

Hide code cell source
#@title Interactive OCR error widget (click to expand code)
from IPython.display import display, HTML
display(HTML(r"""
<div id="ocr-error-v4" style="font-family: system-ui, monospace; max-width:1000px; margin: 8px 0;">

  <h3 style="margin:0 0 8px 0; color: inherit;">OCR Output (annotated)</h3>

  <div style="margin:8px 0 12px 0;">
    <button class="ocrBtn" onclick="toggleErrorsV4('sub')">Substitutions</button>
    <button class="ocrBtn" onclick="toggleErrorsV4('miss')">Missing (in OCR)</button>
    <button class="ocrBtn" onclick="toggleGTunderV4()">Show missing GT</button>
    <button class="ocrBtn" onclick="toggleErrorsV4('ins')">Extra (in OCR)</button>
    <button class="ocrBtn" onclick="toggleErrorsV4('frak')">Fraktur confusions</button>
    <button class="ocrBtn" onclick="clearErrorsV4()">Clear</button>
  </div>

  <div id="ocrBoxV4"
       style="white-space:pre-wrap; padding:10px; border-radius:6px;
              background:#1f1f1f; color:#eee; border:1px solid #2f2f2f;
              font-family:monospace;"></div>

  <div style="margin-top:10px; font-size:1.0rem; color: inherit;">
    <strong>Legend:</strong>
    <span style="margin-left:8px; color:#ff6b6b;">sub</span>
    <span style="margin-left:8px; color:#47b5ff;">missing</span>
    <span style="margin-left:8px; color:#7effa2;">extra</span>
    <span style="margin-left:8px; color:#ffd54d;">fraktur</span>
  </div>


  <h3 style="margin:0 0 8px 0; color: inherit;">Ground truth (reference)</h3>
  <div id="gtBoxV4"
       style="white-space:pre-wrap; padding:10px; border-radius:6px;
              background:#29313d; color:#e5e5e5; border:1px solid #2f2f2f;
              font-family:monospace;"></div>

</div>

<style>

  #ocr-error-v4 .ocr-heading {
  margin: 0 0 8px 0 !important;
  color: #222 !important;
  }

  html[data-theme="dark"] #ocr-error-v4 .ocr-heading,
  body[data-theme="dark"] #ocr-error-v4 .ocr-heading {
    color: #eee !important;
  }
  
  #ocr-error-v4 .ocrBtn {
    background:#2d3b4f; color:#fff; border:none;
    padding:6px 10px; margin-right:6px;
    border-radius:5px; cursor:pointer; font-size:0.9rem;
  }

  #ocr-error-v4 .ocrSpan { padding:0 2px; border-radius:3px; display:inline-block; }
  #ocr-error-v4 .ok   { color:#cfcfcf; }
  #ocr-error-v4 .sub  { background:#ff6b6b; color:#000; }
  #ocr-error-v4 .ins  { background:#7effa2; color:#000; }
  #ocr-error-v4 .miss { background:#47b5ff; color:#000; position:relative; }
  #ocr-error-v4 .frak { background:#ffd54d; color:#000; }

  #ocr-error-v4 .gtSpan { transition:color 0.2s ease; }

  #ocr-error-v4 .gt-under {
    display:block; font-size:0.8rem; text-align:center;
    line-height:0.8; opacity:0; transition:opacity 0.25s ease;
    color:#000;
  }
  #ocr-error-v4 .miss.showGT .gt-under { opacity:1; }
</style>

<script>
(function(){
  const ocrRaw = `Zie Grippe wüfel weiter Zunahme der ſchweren Fälle in Berlin. Die Zahl der Grippefälle iſt in den letzten beider Tagen auch in Groß-Berlin noH erfblih zefitiegen. Die Worenhäuſer und ſon- Haen aroßen GerſHäfte, die Krirgs- und die pri« n Betriebe lagen, daß übermäig viele An« : fich 5cben kren? melden miüen,-und an: ; ew Loſt und 5ei der Straßenbahn iſt der ſoz der Grippekranken bedeuten) g&`.trim();
  const gtRaw = `Die Grippe wütet weiter Zunahme der schweren Fälle in Berlin. Die Zahl der Grippefälle ist in den letzten beiden Tagen auch in Groß-Berlin noch erheblich gestiegen. Die Warenhäuser und sonstigen großen Geschäfte, die Kriegs- und die privaten Betriebe klagen, daß übermäßig viele An-
gestellte sich haben krank melden müssen und auch bei der Post und bei der Straßenbahn ist der Prozentsatz der Grippekranken bedeutend gestiegen.`.trim();

  const ocr = ocrRaw.replace(/\s+/g,' ');
  const gt  = gtRaw.replace(/\s+/g,' ');

  const gtBox = document.getElementById('gtBoxV4');
  gtBox.innerHTML = gt.split('').map(ch=>`<span class="gtSpan">${ch}</span>`).join('');

  /* Levenshtein diff */
  function computeEdits(a,b){
    const n=a.length, m=b.length;
    const dp=Array.from({length:n+1},()=>Array(m+1).fill(0));
    for(let i=0;i<=n;i++) dp[i][0]=i;
    for(let j=0;j<=m;j++) dp[0][j]=j;

    for(let i=1;i<=n;i++){
      for(let j=1;j<=m;j++){
        dp[i][j]=Math.min(
          dp[i-1][j]+1,
          dp[i][j-1]+1,
          dp[i-1][j-1] + (a[i-1]===b[j-1]?0:1)
        );
      }
    }

    const ops=[];
    let i=n,j=m;
    while(i>0||j>0){
      if(i>0&&j>0&&a[i-1]===b[j-1]&&dp[i][j]===dp[i-1][j-1]){
        ops.push({type:'ok', a:a[i-1], b:b[j-1], gi:j-1});
        i--;j--;continue;
      }
      if(i>0&&j>0&&dp[i][j]===dp[i-1][j-1]+1){
        ops.push({type:'sub', a:a[i-1], b:b[j-1], gi:j-1});
        i--;j--;continue;
      }
      if(i>0&&dp[i][j]===dp[i-1][j]+1){
        ops.push({type:'ins', a:a[i-1], b:null, gi:null});
        i--;continue;
      }
      if(j>0&&dp[i][j]===dp[i][j-1]+1){
        ops.push({type:'miss', a:null, b:b[j-1], gi:j-1});
        j--;continue;
      }
    }
    return ops.reverse();
  }

  const edits = computeEdits(ocr,gt);
  const frakPairs = [['ſ','s'],['ſ','f'],['u','n'],['0','o'],['5','s']];
  function isFrak(op){
    if(op.type!=='sub') return false;
    return frakPairs.some(([x,y]) => (op.a===x&&op.b===y)||(op.a===y&&op.b===x));
  }

  const ocrBox = document.getElementById('ocrBoxV4');
  let showGT = false;
  let currentFilter = null;

  function render(){
    ocrBox.innerHTML='';

    edits.forEach(op=>{
      if(op.type==='ok'){
        const s=document.createElement('span');
        s.className='ocrSpan ok';
        s.textContent=op.a;
        s.dataset.type='ok';
        ocrBox.appendChild(s);
      }
      else if(op.type==='sub'){
        const s=document.createElement('span');
        s.className='ocrSpan sub';
        if(isFrak(op)) s.classList.add('frak');
        s.textContent=op.a;
        s.dataset.type='sub';
        s.dataset.gi=op.gi;
        ocrBox.appendChild(s);
      }
      else if(op.type==='ins'){
        const s=document.createElement('span');
        s.className='ocrSpan ins';
        s.textContent=op.a;
        s.dataset.type='ins';
        ocrBox.appendChild(s);
      }
      else if(op.type==='miss'){
        const w=document.createElement('span');
        w.className='ocrSpan miss';
        w.textContent='⟦ ⟧';
        w.dataset.type='miss';
        w.dataset.gi=op.gi;

        const gtline=document.createElement('span');
        gtline.className='gt-under';
        gtline.textContent=op.b;
        w.appendChild(gtline);

        if(showGT) w.classList.add('showGT');
        ocrBox.appendChild(w);
      }
    });

    applyFilter();
  }

  function applyFilter(){
    const ocrSpans = ocrBox.querySelectorAll('.ocrSpan');
    const gtSpans = gtBox.querySelectorAll('.gtSpan');

    gtSpans.forEach(s=>{
      s.style.color='#e5e5e5';
      s.style.fontWeight='normal';
    });

    ocrSpans.forEach(s=>{
      s.style.opacity='1';
      s.style.outline='none';
      s.style.background = '';
      s.style.color = '';
    });

    if(!currentFilter) return;

    ocrSpans.forEach((s,i)=>{
      const t = s.dataset.type;
    
      let match = false;
    
      if(currentFilter === 'sub'){
        match = (t === 'sub' && !isFrak(edits[i]));
      }
      else if(currentFilter === 'frak'){
        match = isFrak(edits[i]);
      }
      else{
        match = (t === currentFilter);
      }
    
      if(!match){
        s.style.opacity='0.45';
      } else {
        s.style.outline='2px solid rgba(255,255,255,0.25)';
        if(currentFilter === 'frak' && isFrak(edits[i])){
          s.style.background = '#ffd54d';
          s.style.color = '#000';
        }
      }
    });
    
    // GT coloring
    edits.forEach(op=>{
      if(op.gi===null) return;
      const gtSpan = gtSpans[op.gi];
      if(!gtSpan) return;

      if(currentFilter==='sub' && op.type==='sub'&& !isFrak(op)){
        gtSpan.style.color='#ff6b6b';
        gtSpan.style.fontWeight='bold';
      }
      if(currentFilter==='miss' && op.type==='miss'){
        gtSpan.style.color='#47b5ff';
        gtSpan.style.fontWeight='bold';
      }
      if(currentFilter==='frak' && isFrak(op)){
        gtSpan.style.color='#ffd54d';
        gtSpan.style.fontWeight='bold';
      }
    });
  }

  window.toggleErrorsV4=function(type){
    currentFilter=type;
    render();
  };

  window.clearErrorsV4=function(){
    currentFilter=null;
    render();
  };

  window.toggleGTunderV4=function(){
    showGT=!showGT;
    render();
  };

  render();  
})();
</script>
"""))

OCR Output (annotated)

Legend: sub missing extra fraktur

Ground truth (reference)

4.4.6. Verarbeitung eines (mehrseitigen) PDFs#

Mit ein wenig mehr Python-Code können wir pytesseract auch verwenden, um gesamte PDF-Dateien mit vielen Seiten zu OCRen:

Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️ Zuerst wird der Ordner angelegt, in dem die Textdateien gespeichert werden. Der Einfachheit halber wird die gleich Datenablagestruktur wie in dem GitHub Repository, in dem die Daten gespeichert sind, vorausgesetzt.
Der Text wird aus GitHub heruntergeladen und in dem Ordner ../data/pdf/ abgespeichert.
Der Pfad kann in der Variable sample_pdf_path angepasst werden. Die einzulesenden Daten müssen die Endung `.pdf` haben.
Was ist ein Dateipfad? (klicken)

Ein Dateipfad ist eine Zeichenkette, die deinem Programm sagt, wo eine Datei auf deinem Computer oder Server gespeichert ist. Er hilft dem Programm, Dateien zu finden und auf sie zuzugreifen, um sie zu lesen, zu schreiben oder zu bearbeiten.

Arten von Dateipfaden:#

  1. Absoluter Dateipfad:
    Ein absoluter Pfad gibt den vollständigen Speicherort einer Datei ausgehend vom Stammverzeichnis deines Systems an.

    • Beispiel unter Windows:
      C:\Users\JohnDoe\Documents\file.txt

    • Beispiel unter macOS/Linux:
      /Users/JohnDoe/Documents/file.txt

  2. Relativer Dateipfad:
    Ein relativer Pfad zeigt dem Programm, wie es eine Datei basierend auf dem aktuellen Arbeitsverzeichnis (dem Ordner, in dem dein Skript ausgeführt wird) finden kann.

    • Beispiel:
      Documents/file.txt
      (Dies sucht die Datei in einem Ordner namens Documents innerhalb des aktuellen Verzeichnisses).

Pfadtrennzeichen:#

  • Unter Windows verwenden Pfade Backslashes (\):
    C:\folder\file.txt

  • Unter macOS/Linux verwenden Pfade Schrägstriche (/):
    /folder/file.txt

Beispiel in Python:#

# Absoluter Pfad
file = open('C:/Users/JohnDoe/Documents/file.txt')
    
# Relativer Pfad
file = open('Documents/file.txt')

Python bietet auch Tools, um Pfade so zu handhaben, dass sie auf jedem Betriebssystem funktionieren, wie die Module os und pathlib. Wir verwenden oben pathlib, damit dieses Notebook auf jedem Rechner funktioniert. Dadurch können wir Pfade im Unix-Stil schreiben.

Hide code cell content
# 🚀 Create data directory path
corpus_dir = Path("../data/pdf")
if not corpus_dir.exists():
    corpus_dir.mkdir(parents=True)
Hide code cell content
# 🚀 Load the txt file from GitHub 
! wget https://raw.githubusercontent.com/quadriga-dk/Text-Fallstudie-1/refs/heads/main/data/pdf/SNP27112366-19181224-0-0-0-0.pdf

# move the file to the data directory
! mv SNP27112366-19181224-0-0-0-0.pdf ../data/pdf
# set the path to file to be processed
sample_pdf_path = Path("../data/pdf/SNP27112366-19181224-0-0-0-0.pdf")

Dieser Code liest eine mehrseitige PDF-Datei mit einer Zeitungsausgabe vollständig ein und führt Seite für Seite eine Texterkennung (OCR) durch. Die Ausführung wird mehrere Minuten dauern

# this code here reads an entire PDF with a newspaper issue 
# and performs OCR page by page
# it will take a couple of minutes to run
recognized_pages = []
converted_pdf = tqdm(convert_from_path(sample_pdf_path, use_cropbox=True))
for image in converted_pdf:
    recognized = pytesseract.image_to_string(image, 
                                             lang='frk') 
    #print(recognized)
    recognized_pages.append(recognized)

Schauen wir uns die erste Seite an:

print(recognized_pages[0])

Letzte Seite:

print(recognized_pages[-1])

Keines dieser Ergebnisse sieht besonders gut aus (hauptsächlich aufgrund der Scan-Qualität und allgemeiner Herausforderungen bei der Arbeit mit alten Zeitungen). In den nächsten Abschnitten werden wir lernen, wie man

  • a) die OCR-Qualität misst

  • b) die Qualität in der OCR-Nachkorrekturphase verbessert

Um die OCR-Funktion auf einer anderen PDF-Datei auszuführen, müssen Sie in der obigen Zeile einen Dateipfad dazu angeben: sample_pdf_path = Path('/path/to/your.pdf').

4.4.7. (Advanced) Verarbeitung des gesamten Korpus von PDFs mit derselben OCR-Engine #

Der untenstehende Code verarbeitet alle Dateien im Ordner '../data/pdf', die die Endung ‘.pdf’ haben, und speichert die Ergebnisse dann im Ordner '../data/txt' (die Dateinamen bleiben gleich, aber mit der Endung ‘.txt’ anstelle von ‘.pdf’). WARNUNG: Bei einer großen Anzahl (>5) von PDFs wird dies viel Zeit in Anspruch nehmen.

Hide code cell content
# 🚀 Create txt directory path
corpus_dir = Path("../data/txt")
if not corpus_dir.exists():
    corpus_dir.mkdir(parents=True)
pathpdf = Path('../data/pdf')
pathtxt = Path('../data/txt')
for filename in tqdm(pathpdf.iterdir()):
    if filename.suffix == '.pdf':
        converted_pdf = convert_from_path(filename, use_cropbox=True)
        output_path = pathtxt / filename.stem 
        output_path = output_path.with_suffix('.txt')
        with output_path.open('w') as output_txt:
            for image in converted_pdf:
                recognized = pytesseract.image_to_string(image, 
                                                         lang='frk') 
                output_txt.write(recognized)