Im Laufe der Jahre habe ich viele Ausgaben vom Chefkoch-Magazin angesammelt. Ein Problem ist, dass man in der Regel nicht mehr weiß, welches Rezept in welchem Heft stand. Deshalb wollte ich alle Rezepte in einer Liste sammeln. Das Abtippen jedes Titels erwies sich allerdings als zu umständlich.

Deshalb habe ich alle Inhaltsverzeichnisse gescannt und anschließend mit tesseract ausgewertet. Das Ergebnis fällt mittelmäßig aus: Sehr viele Zeichenfehler, aber grundsätzlich kann man das Wort in der Regel erkennen. Als Alternative hätte sich die Vision API von Google Cloud empfohlen, welche sehr gut in OCR sein soll. Allerdings habe ich bisher keinen Account bei Google Cloud und wollte dann auch keinen anlegen. Rekognition von Amazon AWS eignet sich leider nicht, weil es hier ein Limit von 50 Wörtern in der Erkennung von Text auf Bild gibt.

Ausschnitt aus Chefkoch Magazin Inhaltsverzeichnis

Ein kurzer Test mit Google Cloud auf der API-Seite (ohne Google-Cloud-Konto) zeigte tatsächlich, dass die Texterkennung von Google Vision wesentlich besser ist als ein lokales tesseract. Für diesen Blog-Eintrag verwende ich trotzdem nur tesseract und bewahre die Scans auf, eventuell lasse ich sie später dann noch einmal mit Google Cloud durchrechnen.

Zunächst habe ich mich mit tesseract und dem deutschen Sprachpaket begnügt. Die Scans habe ich zur leichteren Verarbeitung direkt auf den Bildbereich des Inhaltsverzeichnisses eingeschränkt.

Unter Archlinux verläuft die Installation von tesseract sehr einfach, man installiert lediglich die Pakete tesseract und tesseract-data-deu.

Ist tesseract installiert, können die ersten Auswertungen auch schon beginnen.

tesseract images/17-11.jpg stdout -l deu

Dieser Befehl liefert uns als Ausgabe:

Mit Fleisch '

Blumenkohlpüree mit Hackfleischtopping ...... 10
Brotsalat mit Avocado und Mangold ............... 69
Entenbrust mit Trauben—Cassis-Sauce .............. 64
Geschnetzeltes Stroganoff 26
Hackbraten, gefüllten mit Käse ........................... 46
Hähnchenkeulen pihont mit Ajuar ..................... 69
Käsesuppe, Allgäuer, mit Kräutern ........................ 43

[...]

Vanillekipfefl 19
Wulnussplöt5chen mit Zitronengtasur .............. 16

Zimtringerl, Wiener 18

Dabei habe ich der Übersichtlichkeit die meiste Ausgabe durch [...] abgeschnitten.

Die Erkennung des Zwei-Spalten-Layouts funktionierte erfreulicherweise reibungslos und ohne weitere Eingaben. Die Text-Erkennung selbst hat relativ viele Zeichenfehler, weshalb wir im Kopf behalten, den Algorithmus eventuell später ersetzen zu wollen.

Zur weiteren Verarbeitung wechseln wir zu Python, welches mit pytesseract eine Anbindung an Python hat.

from PIL import Image
import pytesseract


class TesseractReader(object):
    def parse(self, filepath):
        img = Image.open(filepath)
        return pytesseract.image_to_string(img, lang='deu')

Die einzige Methode dieser Klasse ist parse. Diese nimmt einen Dateipfad entgegen und gibt den gelesenen Text zurück. Sollten wir später andere OCR-Algorithmen hinzufügen, können wir weitere Klassen erstellen. Für die bisherige Implementierung würde auch eine einfache Funktion ohne Klasse vollkommen ausreichen, ich habe trotzdem eine Klasse gewählt, weil zukünftige Anbindungen eventuell komplexer werden könnten (je nach API).

Der ausgelesene Text muss natürlich noch weiter verarbeitet werden. Von obigem Screenshot wissen wir, dass im Inhaltsverzeichnis immer die Kategorie (z.B. Mit Fleisch) genannt wird und dann darunter alle Rezepte dieser Kategorie. Die Seitennummer eines Rezepts folgt hinter dem Rezept, evtl. abgetrennt durch Punkte.

Die Kategorien sind in der Regel:

  • Mit Fleisch
  • Vegetarisch
  • Vegan
  • Mit Fisch
  • Süßes

Meines Wissens hat es aber auch bereits andere Kategorien gegeben und leider werden die Kategorien auch nicht immer korrekt gelesen. Deshalb sollten wir zunächst alle Zeilen sammeln, die keine Zeilennummer haben.

import re
import collections


def detect_headers(filepaths, reader):
    possible_headers = []

    for filepath in filepaths:
        text = reader.parse(filepath)

        for line in map(str.strip, text.splitlines()):
            # require either a number at line end or a dot
            if not re.match(r'.+(\d+|\.)$', line):
                possible_headers.append(line)

    return collections.Counter(possible_headers)

In meinem Fall von ein paar wenigen Inhaltsverzeichnissen liefert dies:

  • ’’: 322
  • ‘Vegan’: 5
  • ‘Süßes’: 5
  • ‘Mit Fleisch’: 4
  • ‘Vegeta fisch’: 3
  • ‘Mit Fisch’: 2
  • ‘Vegetarisch’: 2
  • ‘Mit Fleisch “’: 1
  • ”’ WcL”: 1
  • ’. 0 °’: 1
  • ‘DL“‘5” 3°”;‚“3„9 W\\wt’: 1
  • ‘Advu\f$%üf’: 1
  • ‘Mit Fisc%z’: 1
  • ‘Mit F isch’: 1

Wir sehen, dass hier durchaus einige Fehlererkennungen enthalten sind, die sicherlich keine Kategorie sind - vermutlich aus dem Bereich oben rechts, wo in kursiv ein netter Spruch steht. Allerdings gibt es auch fehlerhafte Erkennungen, die eigentlich eine Kategorie wären.

Um diese richtig zuzuordnen, werden wir vermutlich eine Textähnlichkeit benötigen, z.B. die Levenshtein-Distanz. Auch hier gibt es wieder ein passendes Python-Modul, python-Levenshtein. Die Levenshtein-Distanz ist die Anzahl an Änderungen, die benötigt werden, um ein gegebenes Wort in das Zielwort zu transformieren. Ein Test auf meinen Daten ergab, dass eine maximale Levenshtein-Distanz von 2 die richtigen Zuordnungen findet.

import Levenshtein


def match_headers(possible_headers, available_headers):
    matches = []

    for possible in possible_headers:
        qualities = []
        for check in available_headers:
            dist = Levenshtein.distance(check, possible)
            qualities.append((dist, check))

        best_match = sorted(qualities)[0]
        matches.append((possible, best_match[1], best_match[0]))

    ok_headers = {}
    for m in matches:
        if m[2] <= 2:
            ok_headers[m[0]] = m[1]

    return ok_headers

Nachdem nun die Überschriften richtig zugeordnet sind, müssen wir lediglich noch die Zeilen durchgehen und die Rezepte in die richtigen Kategorien sortieren. Dabei trennen wir nach Namen des Rezepts und der Seitenzahl.

def read_toc(filepath, headers, reader):
    text = reader.parse(filepath)

    recipes = collections.defaultdict(list)
    cur_header = None
    for line in map(str.strip, text.splitlines()):
        if line in headers:
            cur_header = headers[line]
        else:
            m = re.match(r'(.+?)[\.\s]*(\d+|\.)$', line)
            if m:
                recipes[cur_header].append((m.group(1), m.group(2)))

    return recipes

Anschließend können wir diese Funktion aufrufen und alle Rezepte in eine CSV-Datei schreiben.

import os
import sys


if __name__ == '__main__':
    directory = sys.argv[1]
    
    legal_headers = [
        'Mit Fleisch', 'Vegetarisch', 'Vegan',
        'Mit Fisch', 'Süßes'
    ]
    files = glob.glob(os.path.join(directory, '*.jpg'))

    reader = TesseractReader()
    headers = detect_headers(files, reader)
    matched_headers = match_headers(headers, legal_headers)

    with open('out.csv', 'w') as f:
        writer = csv.writer(f)
        writer.writerow(['Ausgabe', 'Kategorie', 'Rezept', 'Seite'])

        for filepath in files:
            recipesdict = read_toc(filepath, matched_headers, reader)

            for category, recipes in recipesdict.items():
                for recipe in recipes:
                    writer.writerow([filepath, category, recipe[0], recipe[1]])

Das Ergebnis ist nun eine CSV-Datei mit allen Rezepten, dem Heft und der passenden Seitenzahl.

CSV-Datei mit Rezepten

I do not maintain a comments section. If you have any questions or comments regarding my posts, please do not hesitate to send me an e-mail to blog@stefan-koch.name.