Wie man ein Jupyter Notebook-basiertes Labeling-Tool zur Bildklassifizierung implementiert


Felix Brunner


Different variations of hotdogs

Hotdog" oder "kein Hotdog"? So könnte die Frage lauten - zumindest, wenn man eine Bildklassifizierungsaufgabe durchführt. Um diese oder eine ähnlich wichtige Frage mit Hilfe eines Machine-Learning-Modells beantworten zu können, müssen wir zunächst einen gelabelten Datensatz für das Training erstellen. Es kann vorkommen, dass wir hunderte oder sogar tausende von Bildern von potentiellen Hotdogs anschauen müssen, um zu entscheiden, ob sie tatsächlich Hotdogs enthalten.

Eine Möglichkeit wäre, ein Bild nach dem anderen zu öffnen und die Bildklassen in einer anderen Datei, z. B. in einer Excel-Tabelle, zu speichern. Ein solch umständlicher Ansatz klingt jedoch ziemlich mühsam und ist wahrscheinlich anfällig für Flüchtigkeitsfehler. Wäre es nicht toll, wenn es eine schlanke Lösung gäbe, die diesen Labeling-Prozess effizienter gestaltet und sogar Spaß macht?

Genau das ist es, was wir uns in diesem Artikel vorgenommen haben: Ein einfaches Annotationstool zu erstellen, mit dem man einer Reihe von Bildern auf einfache Weise Klassen-Labels zuweisen kann.


Bildklassifizierung und warum man ein weiteres Labeling-Tool dazu braucht


Die Bildklassifizierung ist eine der grundlegendsten Anwendungen des Machine Learnings im Bereich Computer Vision. Natürlich sind die Anwendungen nicht immer so banal wie die Unterscheidung von Hotdogs von anderen Lebensmitteln, aber dieser einfache Fall eignet sich gut als anschauliches Beispiel, um zu verdeutlichen, worum es uns geht. Reale Kontexte, in denen wir auf Bildklassifizierungsprobleme gestoßen sind, sind z. B. die Extraktion von Informationen aus technischen Zeichnungen, die automatische Detektion von Defekten oder die visuelle Überprüfung des Erfolgs eines Fertigungsprozesses in einem unserer aktuellen Projekte. Wie bei allen überwachten Lerntechniken ist es notwendig, die Trainingsschleife mit gelabelten Daten zu füttern, damit der Algorithmus in der Lage ist, Muster in den Bildern zu erkennen.

Wenn Ihr Anwendungsfall keinen geeigneten gelabelten Datensatz wie das allgegenwärtige Imagenet beinhaltet, ist manuelles Labeln erforderlich, bevor wir überhaupt mit dem Training eines Machine-Learning-Modells beginnen können. Wie in früheren Artikeln über Labeling-Tools für Computer Vision und NLP beschrieben, gibt es viele kommerzielle oder Open-Source-Lösungen für den Labeling-Prozess, die alle ihre Vor- und Nachteile haben.

Wozu brauchen wir also ein zusätzliches Tool, wenn es bereits eine Vielzahl von Lösungen gibt? In diesem Zusammenhang ist es wichtig zu bedenken, dass das Labeling oft von einem Experten in der Problemdomäne durchgeführt wird, der mit den Feinheiten des maschinellen Lernens nicht vertraut ist. Daher sollte das Labeling-Tool so intuitiv wie möglich aufgebaut sein und gleichzeitig auf Funktionen verzichten, die nur für komplexere Computer-Vision-Aufgaben wie Bildsegmentierung oder Objekterkennung benötigt werden. Für eine einfache Bildklassifizierung kann eine vollwertige Labeling-Lösung den Arbeitsablauf jedoch erheblich verkomplizieren, ohne wesentliche Vorteile zu bringen. Um diese Lücke zu schließen, haben wir im Rahmen eines unserer Projekte selbst ein einfaches Labeling-Tool entwickelt, das alle für die Klassifizierung von Bildern erforderlichen Funktionen bietet - und nicht mehr.


Erstellung eines interaktiven Jupyter-Notebooks mit ipywidgets


Nehmen wir an, Sie müssen einen Datensatz labeln, der Bilder von verschiedenen Lebensmitteln enthält, und Sie sollen diese in die Kategorien 'Hotdog' und 'kein Hotdog' einordnen. Dann sollte Ihr Labeling-Tool nur ein Bild nach dem anderen anzeigen und Schaltflächen bereitstellen, um dem angezeigten Bild die Klassenlabels zuzuweisen. Außerdem wäre es praktisch, wenn es eine Möglichkeit gäbe, zwischen den Bildern zu navigieren.

Ein einfaches Tool könnte etwa so aussehen:

 image labeling process sketch

Jupyter-Notebooks sind zu einem unverzichtbaren Bestandteil im Werkzeugkasten vieler Data Scientists geworden. Als interaktiver Interpreter mit grafischen Funktionen ermöglicht Jupyter den Benutzern, kurze Codeschnipsel auszuführen und grundlegende Datenanalysen durchzuführen. Um jedoch ein intuitives Labeling-Tool zu implementieren, benötigen wir Interaktivität, die über die Möglichkeit, Code auszuführen, hinausgeht. Wir möchten zum Beispiel, dass der angezeigte Inhalt dynamisch aktualisiert wird, wenn der Benutzer auf eine Schaltfläche klickt - mit anderen Worten: wir benötigen JavaScript.

ipywidgets bietet eine praktische Lösung, um Jupyter-Notebooks in interaktive Anwendungen zu verwandeln. Unter anderem bietet es die Möglichkeit, gängige Widgets wie Schieberegler, Dropdowns oder Schaltflächen zu Anwendungen und Dashboards hinzuzufügen. Das heißt, es lässt Sie ausschließlich Code in Python schreiben und generiert selbständig das notwendige JavaScript, um interaktive HTML-Widgets zu erstellen, die in Ihrem Browser ausgeführt werden können.


Die ersten Schritte


Werfen wir also einen Blick auf die Module und Widgets, die wir verwenden werden, um unser einfaches Labeling-Tool zu erstellen.

  1. Zunächst importieren wir die Python-Bibliothek pathlib, um mit den Pfaden der Bilddateien richtig arbeiten zu können.

  2. Zweitens möchten wir der Person, die das Labeling vornimmt, jeweils ein Bild zusammen mit interaktiven Schaltflächen anzeigen. Das Output-Widget von ipywidget bietet eine gute Möglichkeit, die Anzeigefähigkeiten von IPython zu nutzen, während das Button-Widget das Hinzufügen interaktiver Schaltflächen zu dem Tool ermöglicht. Zusätzlich dient Layout dazu, die Dimensionen der eingefügten Widgets zu definieren, und HBox ermöglicht die horizontale Ausrichtung einer Reihe von Schaltflächen.

  3. Schließlich verwenden wir Image, display und clear_output aus dem Display-Modul von IPython, um die angezeigten Bilder anzuzeigen und zu löschen.

Lassen Sie uns all das importieren, indem wir das untenstehende Codeschnipsel ausführen:

from pathlib import Path
from ipywidgets import Output, Button, Layout, HBox
from IPython.display import Image, display, clear_output

Zusätzlich müssen wir einige Variablen einrichten, die den Labeling-Prozess definieren.

  1. Wir beginnen mit der Definition von classes - eine Liste, die die möglichen Klassenbezeichnungen enthält, die das Labeling-Tool dem Benutzer als Schaltflächen anzeigen soll. In unserem Fall enthält sie die Optionen "hotdog" und "not hotdog".

  2. Als nächstes ist path ein Path-Objekt, das das Tool in das Verzeichnis im Dateisystem führt, in dem sich die zu labelnden Bilder befinden, die wir automatisch nach Dateien mit der Endung '.jpg' durchsuchen. Die entsprechenden Dateinamen werden dann in der Liste images gespeichert.

  3. Um mit dem Labeling-Prozess zu beginnen, initialisieren wir auch ein leeres Wörterbuch labels, das die ausgewählten Labels enthalten soll, und einen ganzzahligen Zeiger auf die aktuelle Position namens position.

# store list of available class labels
classes = ["hotdog", "not hotdog"]

# store path and search for .jpg images in path
path = Path("./data")
images = [img.name for img in path.glob("*.jpg")]

# set up empty dict for labels and initial position
labels = {}
position = 0

Das schrittweise Anzeigen einzelner Bilder


Nachdem alle wesentlichen Eingaben definiert sind, beginnen wir nun mit dem Aufbau der verschiedenen Elemente des Labeling-Tools.

In erster Linie wollen wir ein einzelnes Bild anzeigen, damit der Labeler es betrachten und entscheiden kann, zu welcher Klasse es gehört. Sobald der Labeler eine Bildklasse auswählt oder zu einem anderen Bild navigiert, soll die Bildanzeige zum nächsten Bild wechseln.

  1. Dazu laden wir das erste Bild von dem angegebenen Ort, indem wir eine Instanz von Image auf der Grundlage des Bildpfads erstellen.

  2. Um das Bild bei der Anzeige kontinuierlich zu aktualisieren, erstellen wir außerdem einen Output namens frame, den wir immer verwenden werden, um die verschiedenen Bilder anzuzeigen. Wir wählen auch eine passende Skalierung für das Bild, so dass es sich später in die Schaltflächen einfügt und sich beim Laden eines größeren oder kleineren Bildes nicht verändert. Wir tun dies, indem wir das Layout-Argument von Output angeben, z. B. legen wir die vertikale Größe auf height='300px' und die maximale horizontale Abmessung auf max_width='300px' fest.

  3. Schließlich lassen wir frame das erste Bild anzeigen.

image = Image(path / images[position])
frame = Output(layout=Layout(height="300px", max_width="300px"))
with frame:
    display(image)

Beachten Sie, dass die Anzeige des Bildes in einem Output nicht dasselbe ist wie die direkte Anzeige im Notebook. Wenn wir den frame, der das image zeigt, in unserem Notebook sichtbar machen wollten, bräuchten wir nur display(frame) auszuführen.

Aber lassen wir das, und erstellen wir stattdessen zuerst die anderen Elemente unseres Werkzeugs.


Das Hinzufügen von Navigationsschaltflächen


Das nächste Element in unserer Skizze ist das Hinzufügen einer Reihe von Navigationsschaltflächen, um zwischen den Bildern zu springen.

Der erste Schritt besteht darin, zu definieren, was passieren soll, wenn auf die einzelnen Schaltflächen geklickt wird. Wir schreiben also eine Funktion next_image, die immer dann ausgeführt wird, wenn der Benutzer auf das Feld "next >" klickt. Alles, was diese Funktion tun muss, ist, die Position des Labeling-Prozesses zu aktualisieren und die Bildanzeige mit dem entsprechenden Bild zu aktualisieren.

  1. Das heißt, sie erhöht zunächst die Variable position um eins.

  2. Wenn die Position das Ende der Bilder erreicht, springt die Funktion an den Anfang zurück.

  3. Sobald die Position aktualisiert ist, laden wir einfach das entsprechende Bild und aktualisieren die Anzeige im frame, indem wir zuerst die aktuelle Anzeige löschen und dann die neue anzeigen.

def next_image(*args) -> None:
    """Select the next image and update the displays."""
    global position, image

    # update position
    position += 1
    if position == len(images):
        position = 0

    # refresh display
    image = Image(path / images[position])
    with frame:
        clear_output(wait=True)
        display(image)

Beachten Sie zwei kleine Tricks, die wir verwendet haben, damit dies funktioniert:

  1. Erstens erlauben wir der Funktion, zusätzliche Eingaben zu akzeptieren, wie z. B. eine Schaltfläche, indem wir *args in der Funktionssignatur angeben.

  2. Zweitens kann die Funktion die Variablen position und image außerhalb des Funktionsbereichs ändern, indem sie das global-Keyword verwendet.

Um nun ein interaktives Element für unser Tool zu erstellen, müssen wir lediglich eine Instanz von Button erstellen und die Funktion next_image mit ihr verknüpfen:

forward_button = Button(description="next >")
forward_button.onclick(next_image)

Äquivalent dazu wissen wir jetzt, wie wir eine Funktion erstellen können, um zum vorherigen Bild zu wechseln und sie mit einer entsprechenden Schaltfläche zu verknüpfen. Wir werden im Folgenden davon ausgehen, dass ein solcher backward_button existiert und zusammen mit dem forward_button in einer Liste navigation_buttons gespeichert wurde:

navigation_buttons = [backward_button, forward_button]

Das Hinzufügen von Schaltflächen zur Zuweisung von Klassenlabels


Die Anzeige von Bildern und die Möglichkeit, zwischen ihnen zu wechseln, ist schon ganz ansehnlich, aber die wichtigste Funktion fehlt noch. Wir wollen in der Lage sein, dem aktuellen Bild Klassenlabels zuzuweisen und diese Daten in einem Dictionary zu speichern. Um dieses Ziel zu erreichen, schreiben wir wieder eine Funktion, die wir später an eine Schaltfläche anhängen werden.

Diesmal soll die Funktion ein Button-Objekt entgegennehmen und daraus den Namen des Labels extrahieren. Sie sollte dann ein Paar "Bildname", "Label" im globalen Dictionary labels speichern.

Schauen wir uns die folgende Funktion store_label an:

def store_label(button: Button) -> None:
    """Annotates the current image with the button's description."""
    global labels

    # store the assigned label
    current_image = images[position]
    labels[current_image] = button.description

    # move to next image
    next_image()

Diese Funktion erhält über das Attribut description der Eingabe-Schaltfläche Zugriff auf den Klassennamen. Sie ist auch in der Lage, das Dictionary labels zu ändern, indem sie wiederum das global-Keyword verwendet. Alles, was sie von nun an tut, ist, einen Eintrag zu labels hinzuzufügen, der den Namen des aktuellen Bildes als Schlüssel und das extrahierte Klassenlabel als Wert enthält. Schließlich wird zum nächsten Bild gewechselt, indem die zuvor definierte Funktion next_image aufgerufen wird.

Nun wollen wir für jede in Frage kommende Bildklasse eine Schaltfläche erstellen und diese Schaltflächen in einer Liste mit dem Namen class_buttons sammeln. Dazu durchlaufen wir einfach eine Schleife über die ursprünglich definierte Liste classes und erstellen jedes Mal eine neue Schaltfläche mit der entsprechenden Klasse als Beschreibung und der angehängten Funktion store_label:

class_buttons = []
for label in classes:
    label_button = Button(description=label)
    label_button.onclick(store_label)
    class_buttons.append(label_button)

In unserem einfachen Beispiel werden Schaltflächen für die binären Klassen hotdog und not hotdog erstellt und in class_buttons gesammelt.


Zusammenfügen der Elemente


Nachdem wir nun alle Elemente erstellt haben, können wir damit beginnen, sie auf die folgende Weise darzustellen:

display(frame)
display(HBox(navigation_buttons))
display(HBox(class_buttons))

Beachten Sie die Verwendung von HBox, um die Schaltflächen horizontal auszurichten und sie nebeneinander anzuzeigen.

Sobald wir das obige Codeschnipsel ausführen, zeigt unser Jupyter-Notebook den frame, die navigation_buttons und die class_buttons an, und wir können beginnen, damit zu interagieren:

Sieht ziemlich genau nach dem aus, was wir wollten!

Jetzt müssen wir nur noch überprüfen, ob die Labels im labels-Dictionary gespeichert sind:

>>> print(labels)

{'19.jpg': 'not hotdog', '14.jpg': 'not hotdog', '50.jpg': 'hotdog', '23.jpg': 'not hotdog'}

Fantastisch, es hat auch die Labels gespeichert! Das war einfach, oder nicht?

Um ein solches Tool einzusetzen, sollten wir es wahrscheinlich objektorientiert implementieren, so dass alle beschriebenen Elemente in einer übersichtlichen Klasse LabelingTool zusammenfasst sind. Wir werden dann die Elemente des Labeling-Tools als Objektattribute speichern, was die obigen umständlichen Workarounds mit globalen Variablen vermeidet. Dieser Schritt hat nicht nur den Vorteil, dass der benötigte Code kompakter ist, sondern er macht das Tool auch portabel, sodass wir es leicht in ein kurzes Notebook importieren können, in dem das Tool läuft.

Die ersten paar Codezeilen zur Initialisierung des Tools könnten dann zum Beispiel so aussehen:

class LabelingTool:
	"""A tool for image classification labeling to run in a jupyter notebook."""
    
	def init(self, classes: list, path: str) -> None:
		"""Construct all necessary attributes for the LabelingTool object."""

		# store list of available class labels
		self.classes = classes

		# store path and list of .jpg images in path
        self.path = Path(path)
        self.images = [f.name for f in self.path.glob("*.jpg")]

        # set up empty dict for labels and initial position
      	self.labels = {}
      	self.position = 0

Wir haben oben bereits die gleichen Variablen gesehen, nur dass sie jetzt Teil der init-Methode sind. Natürlich werden dann auch alle Funktionen als Methoden implementiert, die die Attribute verändern.

Wenn Sie sich die vollständige Implementierung ansehen möchten, können Sie dies in unserem öffentlichen GitHub-Repository tun. Sie können sie natürlich auch gerne klonen und für Ihre eigenen Bedürfnisse anpassen.


Fazit und Erweiterungen


In diesem Artikel haben wir gezeigt, wie man das Grundgerüst eines interaktiven Labeling-Tools zur Bildklassifizierung in einem Jupyter-Notebook zum Laufen bringt. Das Tool ist in der Lage, ein Bild nach dem anderen anzuzeigen, und eine Reihe von Schaltflächen ermöglicht es, Bilddaten interaktiv mit Klassenlabels zu versehen, die das Tool intern speichert.

Ich bin mir sicher, dass Sie viele spannende Ideen haben, wie man dieses einfache Framework noch verbessern kann. Einige nützliche Erweiterungen für die Funktionalität des Tools könnten zum Beispiel...

  • ...einen Fortschrittsbalken für den Labeling-Prozess anzeigen

  • ...eine Verknüpfung anbieten, um zum ersten ungelabelten Bild zu springen

  • ...Labels als .json-Datei auf der Festplatte speichern, möglicherweise sogar automatisch nach jedem Klick

  • ...die Möglichkeit bieten, bestehende Labels von der Festplatte zu laden

  • ...einige Ausnahmen auffangen (z.B. wenn alle Bilder gelabelt sind oder sich keine Bilder im Bildverzeichnis befinden)

  • ...einige Informationen über das aktuelle Bild anzeigen, wie z.B. den Dateinamen und ob bereits ein Label vorhanden ist

Trotz all dieses ungenutzten Potenzials bietet das in diesem Artikel dargestellte einfache Gerüst einen guten Ausgangspunkt für die flexible Anpassung eines Werkzeugs an Ihre spezifischen Bedürfnisse.

Und denken Sie daran: Labeln ist eine anstrengende Arbeit - wenn Sie sie so transparent und schlank wie möglich gestalten, macht das den Labelern das Leben sehr viel leichter. Als Ergebnis erhalten Sie in kürzerer Zeit konsistentere Labels und können endlich Ihren Bildklassifikator auf den neu gelabelten Daten trainieren.