Informationen aus Dokumenten auslesen


Dr. Frank Weilandt


handwritten documents

Die Nachfrage nach der automatischen Verarbeitung von Briefen und anderen Dokumenten steigt. Moderne OCR-Verfahren (Optical Character Recognition), die durch maschinelles Lernen unterstützt werden, können den Text digitalisieren. Aber der nächste Schritt besteht darin, ihn zu interpretieren. Dies erfordert Ansätze aus Bereichen wie Informationsextraktion und NLP (Natural Language Processing). Hier zeigen wir anhand simpler Heuristiken, wie man das Datum eines Briefes mit dem Python OCR-Tool pytesseract automatisch einliest. Hoffentlich können Sie einige Ideen für Ihr eigenes Projekt verwenden.


Eingabedaten


Es ist nicht einfach, öffentlich zugängliche gescannte Dokumente zu finden, um den Ansatz zu testen. Wir haben den Datensatz RVL-CDIP verwendet. Es enthält mehrere Arten von Dokumenten - ein Typ ist Brief. Diese Dokumente sind Teil eines großen Korpus von Dokumenten der Tabakindustrie.

Jedes Bild ist 1000 Pixel hoch. Die Breiten der Bilder sind unterschiedlich. Typische Briefe sehen so aus (die ursprüngliche Auflösung beträgt 754x1000 bzw. 600x1000 Pixel).

Diese Bilddateien sind eine Herausforderung, da die Höhe von 1000 Pixel eher gering ist. Ein Brief auf Papier ist 11 Zoll hoch. Daher haben unsere Dateien 1000/11=91 DPI (dots per inch). Moderne OCR-Methoden empfehlen in der Regel 300 dpi für das Eingangsbild. Folglich wird die OCR einige Buchstaben und Ziffern falsch darstellen.


Vorgehensweise


Der Algorithmus sucht nach Phrasen, die wie ein Datum aussehen. Dann wählt er diejenige aus, die an der höchsten Stelle im Dokument erscheint. In dem von uns verwendeten Korpus enthielt fast jedes Datum den als Wort geschriebenen Monat (z.B. April) und den Tag in Ziffern (13) gefolgt vom Jahr (1994). Manchmal wurde der Tag vor dem Monat gedruckt (z.B. 4th September 1984). Der Algorithmus sucht nach den Mustern M D Y und D M Y, wobei M ein Monat ist, der als Wort angegeben ist, D eine Zahl ist, die den Tag darstellt und Y eine Zahl, die ein Jahr darstellt.


Software-Tools


Unsere Implementierung läuft in einem Jupyter Notebook mit Python 3. Wir verwenden Tesseract Version 4, um OCR durch den Wrapper pytesseract durchzuführen. Da die Software manchmal einen Buchstaben des Monats falsch erkennt (z.B. duly anstelle von July), akzeptieren wir alle Zeichenketten, die fast wie ein Monat aussehen: "Fast" heißt hier, dass nur wenige Buchstaben geändert werden müssen, um einen gültigen Monatsnamen zu erreichen. Die Anzahl dieser Operationen wird als Levenshtein-Distanz bezeichnet, eine übliche Zeichenkettenmetrik im Natural Language Processing (NLP). Zum Beispiel ist die Levenshtein-Distanz von duly und July 1, ebenso wie Moy, Septenber oder ähnliche Fehler. Wir verwenden python-Levenshtein. Für die Erkennung von Zahlen (Jahre und Tage) verwenden wir regular expressions. Wir verarbeiten alle Tabellen in Pandas und verwenden tqdm, um einen ordentlichen Fortschrittsbalken zu erhalten.


Algorithmus Schritt für Schritt


Zuerst importieren wir alle benötigten Pakete und geben an, wo die Bilddateien aus unseren Scans gespeichert werden.

import os
import re

import Levenshtein
import pandas as pd
import pytesseract
from tqdm import tqdm_notebook

image_dir = 'images'
ocr_dir = image_dir + '_ocr'

OCR

Wir führen die OCR bei jedem Bild durch. Die Funktion pytesseract.imagetodata erzeugt eine Tabelle mit Tab-getrennten Werten (tsv). Da dieser Schritt für jedes Dokument mehrere Sekunden dauern kann, schreiben wir diese Informationen auf die Festplatte. Sie enthalten auch die Position jedes gelesenen Wortes. 

image_files = os.listdir(image_dir)
if not os.path.isdir(ocr_dir):
    os.mkdir(ocr_dir)
print('Writing output of OCR to directory %s' % ocr_dir)
for f in tqdm_notebook(image_files):
    image_file = os.path.join(image_dir, f)
    info = pytesseract.image_to_data(image_file, lang='eng')
    tsv_file = os.path.join(ocr_dir, os.path.splitext(f)[0]+'.tsv')
    with open(tsv_file, 'wt') as file:
        file.write(info)

Die Dateien, die wir gerade erstellt haben, enthalten mehr Informationen als nur den reinen Text. Tesseract unterteilt den Text in Blöcke, Absätze, Zeilen und Wörter. Für jedes Wort gibt es auch den Begrenzungsrahmen für dieses Wort. Beispielsweise enthält das Attribut top eines Wortes die Position in Pixeln, die vom oberen Rand der Seite gezählt werden. Um einen schnellen Eindruck von der Ausgabe von Tesseract zu bekommen, verwenden wir gImageReader, ein Frontend für Tesseract. Jede blaue Box repräsentiert einen von Tesseract erkannten Block, das Ergebnis der Layoutanalyse. Beachten Sie, dass das Datum nicht immer als separater Block erkannt wird. 

Suche nach dem nächstliegenden Monat

Die folgende Funktion prüft, ob eine bestimmte Zeichenkette einem Monat ähnlich sieht. Für jedes word gibt es den Monat (als Ziffer) zurück, der am ähnlichsten ist, und die Levenshtein-Distanz zu diesem Monat.  

month_strings = ['January', 'February', 'March', 'April', 'May', 'June', 'July','August', 'September', 'October', 'November', 'December']

def closest_month(word):
    df = pd.DataFrame({'month': month_strings})
    df['dist'] = df['month'].apply(lambda month: Levenshtein.distance(word, month))
    idxmin = df['dist'].idxmin()
    return (idxmin+1, df.loc[idxmin, 'dist'])

Ein Beispiel:

closest_month('Cetober')

ergibt

(10, 2)

Der Monat Oktober wurde erkannt (erste Komponente der Ausgabe), und der Levenshtein-Abstand zwischen dem falsch geschriebenen Wort und der richtigen Schreibweise ist 2.

Suche nach Zeichenkettentripeln, die wie ein Datum aussehen

Die folgende Funktion verwendet die komplette Ausgabe der OCR und gibt den Dataframe df_year zurück, der alle Vorkommen von Daten im Dokument auflistet. Für jedes Datum speichern wir auch explizit Tag und Monat. In df_dates werden zunächst alle Jahre aufgelistet. Wir akzeptieren eine Zeichenkette als Jahreszahl, wenn sie dem regulären Ausdruck ^[12]\d{3}$ entspricht, d.h. wenn die Zeichenkette nur aus einer Ziffer 1 oder 2 besteht, gefolgt von genau drei Ziffern. Dann entfernen wir die Jahre, die nicht den Namen eines Monats vor sich haben. Der Dataframe df_months enthält zunächst die beiden Wörter (Zeichenketten) direkt vor dem Jahr und behält dann nur die Zeichenketten mit einem Levenshtein-Abstand von höchstens 2 zum nächsten Monat.

def find_dates(df_complete):
    mask_year = df_complete['text'].str.match(r'^[12]\d{3}$')
    df_dates = df_complete[mask_year]
    df_dates = df_dates.assign(month=0, day=0)

    for idx in df_dates.index.values:
        df_months = df_complete.loc[idx-2:idx-1].copy()
        if idx-2 not in df_complete.index.values:
            continue
        if idx-1 not in df_complete.index.values:
            continue
        df_months['month'] = df_months['text'].apply(lambda x: closest_month(x)[0])
        df_months['distance'] = df_months['text'].apply(lambda x: closest_month(x)[1])
        df_months = df_months[df_months['distance'] <= 2]
        if not df_months.empty:
            idx_month = df_months['distance'].idxmin()
            df_dates.loc[idx, 'month'] = df_months.loc[idx_month, 'month']
            idx_day = [idx-2, idx-1]
            idx_day.remove(idx_month)
            idx_day = idx_day[0]
            match_digits = re.search(r'\d+', df_complete.loc[idx_day, 'text'])
            if match_digits is not None:
                df_dates.loc[idx, 'day'] = match_digits.group()

    df_dates = df_dates[df_dates['month'] > 0]
    return df_dates

Zusammenführung

Jetzt können wir all dies endlich kombinieren, um über alle Bilddateien, die die Dokumente enthalten, zu iterieren. Die resultierende Tabelle enthält für jedes Dokument eine Zeile. Unser Notebook verarbeitet ca. 20 tsv Dateien pro Sekunde.

ocr_files = sorted(os.listdir(ocr_dir))
df_extracted_dates = pd.DataFrame(columns=['date_string', 'year', 'month', 'day'], index=ocr_files)

for file in tqdm_notebook(ocr_files):
    tsv_file = os.path.join(ocr_dir, file)
    df = pd.read_csv(tsv_file, sep=r'\t', dtype={'text': str}, engine='python')
    df = df[df.conf>-1]  # remove empty words
    df.dropna(subset=['text'], inplace=True)
    if df.empty:
        continue 
    dates_found = find_dates(df)
    if not dates_found.empty:
        idx_topmost_date = dates_found['top'].idxmin()
        df_extracted_dates.loc[f, 'month'] = dates_found.loc[idx_topmost_date, 'month']
        df_extracted_dates.loc[f, 'day'] = dates_found.loc[idx_topmost_date, 'day']
        df_extracted_dates.loc[f, 'year'] = dates_found.loc[idx_topmost_date, 'text']
        day_string = dates_found.loc[idx_topmost_date, 'day'] 
        date_string = df.loc[idx_topmost_date-2:idx_topmost_date, 'text'].str.cat(sep=' ')
        df_extracted_dates.loc[f, 'date_string'] = date_string

output_file = image_dir + '_results.csv'
df_extracted_dates.to_csv(output_file)

Beispielausgabe

Unser Notebook erzeugt die folgende Ausgabedatei, gegeben die obigen Bilder und einige weitere. Eine Zeile bleibt leer, wenn der Algorithmus kein Datum finden konnte.

date_string

year

month

day

0000.tsv

"April 13, 1994"

1994

4

13

0001.tsv

"Noveaber 15, 1971"

1971

11

15

0002.tsv

"Cetober 31, 1994"

1994

10

31

0003.tsv

"January 25, 1985"

1985

1

25

0004.tsv

0005.tsv

"January 25, 1985"

1984

9

4


Zusammenfassung


Bei der Implementierung dieser Lösung haben wir folgendes beobachtet: 

  • Wenn die Qualität der OCR nicht optimal ist, aber wir wissen, nach welchen Wörtern wir suchen, dann ist die Levenshtein-Distanz nützlicher als eine strikte regular expression. Es scheint ziemlich schwierig zu sein, "maximal zwei Buchstaben sind falsch" als regular expression darzustellen. Aber wir wollen Rechtschreibfehler wie Cetober finden.

  • Die meisten Fehler des Algorithmus waren auf Fehler der OCR zurückzuführen. Mit Google Cloud Vision würde man bessere Ergebnisse erzielen. Aber wir haben unseren Ansatz beibehalten, da es sich um eine vollständig Open Source-Lösung handelt.

  • Andere Informationen wie den Namen des Empfängers zu finden, ist in der Regel schwieriger, da sie nicht durch ein so einfaches Muster wie D M Y oben erkannt werden können.

Wir haben die Informationsextraktion ebenfalls schon erfolgreich zur automatischen Überprüfung von Nebenkostenabrechnungen und zur automatischen Überprüfung von Mietverträgen eingesetzt.