5.17. Ein Katalog der Standard-Kernel

Die klassische Bildverarbeitung hat einen recht umfangreichen Katalog von Kernel-Gewichtsmustern angesammelt, die immer wieder auftauchen – Kantendetektoren, Schärfer, Prägungen, Glätter, Bewegungsunschärfen – und jedes einzelne davon läuft über morph(). Jedes ist kurz, jedes erledigt eine einzige Sache, und die meisten lassen sich leicht lesen, sobald die grundlegende Logik der Gewichte verständlich ist.

Die nachfolgenden Kernel sind allesamt 3-mal-3 groß, sofern nicht anders angegeben, daher verwenden sie alle size=1 im Aufruf. Die Gewichtsstruktur jedes Kernels wird daneben beschrieben, denn das Lesen der Gewichte ist es, was das Verständnis dafür aufbaut, warum ein Kernel prägt und ein anderer schärft.

5.17.1. Der Identitäts-Kernel

Der einfachste mögliche Kernel ist die Identität – eine Eins in der Mitte, überall sonst Null:

identity = [0, 0, 0,
            0, 1, 0,
            0, 0, 0]

img.morph(1, identity)

Jeder Ausgabe-Pixel übernimmt seinen Wert aus der Mitte der Nachbarschaft, also vom Eingabe-Pixel an derselben Position. Das Bild geht unverändert hindurch. Die Identität hat als Filter keinen praktischen Nutzen, aber sie ist die nützliche Grundlage für das Verständnis aller anderen Kernel: jeder Nicht-Identitäts-Kernel ist die Identität plus eine gewisse Modifikation.

Ein Kernel mit großem Mittelgewicht und kleinen negativen Gewichten ringsum subtrahiert die Umgebung von der Mitte. Ein Kernel mit einem Mittelgewicht von Null ignoriert den Pixel selbst und reagiert nur auf Unterschiede zwischen seinen Nachbarn. Einen Kernel auf diese Weise zu lesen – was das Mittelgewicht mit dem Pixel macht, was die umgebenden Gewichte hinzufügen oder wegnehmen – ist der schnellste Weg, seine Wirkung vorherzusagen.

5.17.2. Kantenerkennung

Kantenerkennungs-Kernel reagieren stark an Positionen, wo sich die Helligkeit in einer bestimmten Richtung rasch ändert, und liefern nahezu Null als Ausgabe dort, wo die Helligkeit gleichmäßig ist. Sie sind die Familie, deren Gewichte sich zu Null summieren: ein flacher Bildbereich (jeder Pixel mit demselben Wert) erzeugt eine Ausgabe von Null, weil jedes positive Gewicht exakt durch ein negatives Gewicht gleicher Größe aufgehoben wird.

Sobel-x ist das kanonische Beispiel. Es erkennt vertikale Kanten (Helligkeitsübergänge von links nach rechts):

sobel_x = [-1,  0,  1,
           -2,  0,  2,
           -1,  0,  1]

img.morph(1, sobel_x, mul=0.25, add=128)

Das passende Sobel-y ist dasselbe Muster, um 90 Grad gedreht; es erkennt horizontale Kanten (Helligkeitsübergänge von oben nach unten):

sobel_y = [-1, -2, -1,
            0,  0,  0,
            1,  2,  1]

Die mittlere Zeile von Sobel-x hat die Gewichte -2 und 2 statt -1 und 1. Das zusätzliche Gewicht in der mittleren Zeile verleiht dem Kernel eine kleine eingebaute Glättung in der Richtung entlang der Kante, was ihn robuster gegenüber Rauschen macht als den einfacheren Prewitt-Operator, der auf diese zusätzlichen Größen verzichtet:

prewitt_x = [-1, 0, 1,
             -1, 0, 1,
             -1, 0, 1]

prewitt_y = [-1, -1, -1,
              0,  0,  0,
              1,  1,  1]

Prewitt gewichtet jede Zeile gleich, sodass seine Reaktion eine Spur schärfer ausfällt als die von Sobel, allerdings um den Preis einer höheren Empfindlichkeit gegenüber Einzelpixel-Rauschen (die Kosten für die Ausführung des Kernels sind identisch – die Faltung leistet dieselbe Arbeit, unabhängig davon, wie die Gewichte aussehen). Bei einem sauberen Bild mit starken Kanten ist er ein durchaus brauchbarer Ersatz für Sobel.

Scharr geht in die andere Richtung. Seine Gewichte sind größer und auf die genaue Erkennung der Kantenrichtung bei feineren Winkeln abgestimmt:

scharr_x = [-3,   0,  3,
            -10,  0, 10,
            -3,   0,  3]

img.morph(1, scharr_x, mul=0.0625, add=128)

Der Divisor mul=0.0625 (1/16) bringt die Ausgabe nach der größeren Produktsumme wieder in den Bereich 0255 zurück. Scharr ist die richtige Wahl, wenn die Anwendung die geometrisch treueste Gradientenreaktion benötigt und bereit ist, dafür etwas mehr Rechenarbeit in Kauf zu nehmen.

5.17.3. Der Laplace-Operator

Ein Laplace-Kernel reagiert auf Kanten in jeder Richtung zugleich. Während die Sobels jeweils Helligkeitsänderungen entlang einer Achse erkennen, reagiert das symmetrische Gewichtsmuster des Laplace-Operators auf dieselbe Weise, unabhängig davon, in welche Richtung die Kante verläuft:

laplacian_4 = [ 0, -1,  0,
               -1,  4, -1,
                0, -1,  0]

img.morph(1, laplacian_4, add=128)

Der Aufbau: Mittelgewicht 4, vier horizontale/vertikale Nachbarn mit Gewicht -1, die vier Diagonalen mit Gewicht Null. Der Kernel summiert sich zu Null, sodass flache Bildbereiche eine Ausgabe von Null erzeugen. Wo sich die Helligkeit ändert, weicht der Mittelwert vom Durchschnitt seiner vier kardinalen Nachbarn ab, und die Ausgabe entspricht der Größe dieser Abweichung.

Die 8-fach verbundene Variante schließt die diagonalen Nachbarn ein:

laplacian_8 = [-1, -1, -1,
               -1,  8, -1,
               -1, -1, -1]

Jeder Kernel erkennt leicht unterschiedliche Dinge. Die 4-fach verbundene Version erzeugt sauberere Ausgaben bei horizontalen und vertikalen Kanten; die 8-fach verbundene ist isotroper – sie reagiert in jeder Richtung gleich gut – erzeugt aber eine etwas verrauschtere Ausgabe. Der 8-fach verbundene Kernel kursiert auch unter dem Namen Outline, nach seiner Verwendung zur Visualisierung von Kanten.

5.17.4. Schärfen

Ein Schärfungs-Kernel ist die Identität plus ein Kantenreaktions-Kernel. Die Ausgabe ist das Originalbild plus eine Kopie der Kanten, sodass hochfrequente Merkmale gegenüber glatten Innenflächen verstärkt werden.

Der standardmäßige 4-fach verbundene Schärfungs-Kernel addiert den 4-fach verbundenen Laplace-Operator zur Identität:

sharpen = [ 0, -1,  0,
           -1,  5, -1,
            0, -1,  0]

img.morph(1, sharpen)

Den Kernel lesen: das Mittelgewicht ist identity (1) + Laplacian centre (4) = 5, und die Umgebung entspricht der des Laplace-Operators. Flache Bildbereiche erzeugen 5 * 1 - 4 * 1 = 1 mal den Mittelwert – die Identität. Kanten erzeugen das Original plus die Laplace-Reaktion. Die Summe der Gewichte ist 1, sodass mul und add bei ihren Standardwerten bleiben.

Für eine stärkere Schärfung geht die 8-fach verbundene Variante weiter:

sharpen_strong = [-1, -1, -1,
                  -1,  9, -1,
                  -1, -1, -1]

img.morph(1, sharpen_strong)

Das Mittelgewicht 9 ist identity (1) + Laplacian-8 centre (8). Dieselbe Logik, mehr Verstärkung, mehr Risiko, dabei auch Sensorrauschen zu verstärken.

Starke Schärfungs-Kernel sind im Wesentlichen gaussian() mit unsharp=True, nur direkt als Kernel ausgedrückt statt über das Unsharp-Mask-Flag. Das Verhalten auf Pixelebene ist dasselbe; die Wahl liegt zwischen dem Komfort der benannten Methode und der feinen Kontrolle eines handabgestimmten Kernels.

5.17.5. Prägung

Ein Präge-Kernel erzeugt den von der Seite beleuchteten Effekt, den man aus klassischen Bildbearbeitungsprogrammen kennt. Die Ausgabe sieht aus, als wäre das Bild in ein Relief extrudiert und dann von einer Ecke aus beleuchtet worden:

emboss = [-2, -1,  0,
          -1,  1,  1,
           0,  1,  2]

img.morph(1, emboss, add=128)

Der Trick ist die Asymmetrie über die Diagonale. Die obere linke Ecke hat das negativste Gewicht, die untere rechte das positivste Gewicht, und die Diagonale von Ecke zu Ecke verläuft von negativ über eins zu positiv. An jedem Pixel berechnet der Kernel im Wesentlichen „Helligkeit unten rechts von mir minus Helligkeit oben links von mir“, was positiv ist, wo das Bild in dieser Richtung heller wird, und negativ, wo es dunkler wird. Das Addieren von 128 zentriert die vorzeichenbehaftete Ausgabe wieder auf Mittelgrau, damit der Effekt sichtbar wird.

Wird die Asymmetrie über die andere Diagonale gedreht, prägt der Kernel aus der entgegengesetzten Richtung:

emboss_alt = [ 0,  1,  2,
              -1,  1,  1,
              -2, -1,  0]

img.morph(1, emboss_alt, add=128)

Die beiden Prägerichtungen sind in Kombination nützlich – indem man eine von der anderen subtrahiert oder jede auf dasselbe Bild anwendet und die Reaktionen vergleicht – wenn eine Anwendung die Ausrichtung erkennen muss.

5.17.6. Glättung

Glättungs-Kernel sind die Familie, deren Gewichte sich zu eins summieren (und alle nicht negativ sind). Ein flacher Bildbereich, der durch einen solchen Kernel läuft, erzeugt dieselbe flache Helligkeit, weil der Kernel die Pixelwerte mittelt, statt ihre Unterschiede zu verstärken.

Der einfachste ist die Box-Unschärfe, also genau das, was mean() berechnet:

box_blur = [1, 1, 1,
            1, 1, 1,
            1, 1, 1]

img.morph(1, box_blur)

Der Kernel summiert sich zu 9, sodass die automatische Division durch die Kernelsumme die Produktsumme in einen echten Durchschnitt über die neun Nachbarschafts-Pixel verwandelt. In der Praxis ist mean() der bessere Weg, diesen Kernel auszuführen – es erzeugt dieselbe Ausgabe schneller, über einen Pfad, der für die Berechnung des Mittelwerts und nichts anderes optimiert ist, während morph die allgemeine Faltungsmaschinerie ausführt. Die Box-Unschärfe steht im Katalog, weil sie die richtige Grundlage zum Verständnis jedes anderen Glättungs-Kernels ist.

Eine 3-mal-3-Annäherung an die Gauß-Funktion gewichtet die Mitte und die kardinalen Nachbarn stärker als die Ecken:

gaussian = [1, 2, 1,
            2, 4, 2,
            1, 2, 1]

img.morph(1, gaussian)

Die Gewichte sind die Pascal-Dreieck-Zeile 1, 2, 1, äußerlich mit sich selbst multipliziert. Das Mittelgewicht 4 ist das größte, weil der Mittel-Pixel am meisten zu seiner eigenen Ausgabe beiträgt; die Ecken sind 1, weil sie am weitesten von der Mitte entfernt sind. Der Kernel summiert sich zu 16, und die automatische Division durch die Kernelsumme übernimmt die Normalisierung – kein mul-Argument erforderlich. Die 3-mal-3-Form ist eine grobe Annäherung an eine echte Gauß-Funktion und bei size=1 nicht von gaussian() zu unterscheiden; die morph-Form ist vor allem nützlich, wenn eine Anwendung die Glättung mit einer anderen Operation im selben Durchlauf kombinieren möchte.

5.17.7. Bewegungsunschärfe

Ein Bewegungsunschärfe-Kernel mittelt Pixel entlang einer Richtung und lässt die senkrechte Richtung unscharffrei. Der einfachste Fall ist horizontal:

motion_h = [0, 0, 0,
            1, 1, 1,
            0, 0, 0]

img.morph(1, motion_h)

Die mittlere Zeile mittelt drei Pixel entlang der horizontalen Achse; die obere und untere Zeile sind Null. Der Kernel summiert sich zu 3, sodass die automatische Division durch die Kernelsumme einen echten Drei-Pixel-Durchschnitt erzeugt, ohne dass ein mul benötigt wird. Die Ausgabe ist eine horizontal verschmierte Kopie des Eingangs – der Effekt, den eine Kamera einfängt, wenn sich das Motiv während der Belichtung seitwärts bewegt. Die vertikale Bewegungsunschärfe ist dasselbe Muster, gedreht:

motion_v = [0, 1, 0,
            0, 1, 0,
            0, 1, 0]

Eine diagonale Bewegungsunschärfe verwendet die Hauptdiagonale:

motion_diag = [1, 0, 0,
               0, 1, 0,
               0, 0, 1]

img.morph(1, motion_diag)

Bewegungsunschärfe-Kernel sind sowohl als Effekt nützlich (gezieltes Unscharfmachen eines Einzelbilds zu visuellen Zwecken) als auch als Testmuster für Algorithmen, die robust gegenüber Bewegungsartefakten sein müssen (man lässt den Algorithmus auf einer bewegungsunscharfen Eingabe laufen und prüft, ob er weiterhin die richtige Antwort liefert).

5.17.8. Kernel auf einen Blick lesen

Ein paar Faustregeln machen neue Kernel auf den ersten Blick leichter lesbar:

  • Summe gleich eins mit nicht negativen Gewichten ⇒ Glättung (erhält die durchschnittliche Helligkeit).

  • Summe gleich null mit sowohl positiven als auch negativen Gewichten ⇒ Kantenreaktion (Null auf flachen Bildbereichen).

  • Summe gleich eins mit einem großen positiven Zentrum und kleinen negativen Umgebungen ⇒ Schärfung (Identität plus Kantenreaktion).

  • Asymmetrisch über eine Diagonale mit Summe gleich eins ⇒ Prägung (hebt eine Seite jedes Helligkeitsübergangs hervor).

  • Konzentriert entlang einer Achse mit Summe gleich eins ⇒ gerichtete Unschärfe.

Die erste dieser Regeln, auf die der Kernel passt, ist meist die richtige Vermutung darüber, was er tut. Die meisten nützlichen Kernel sind allein an der Anordnung ihres Gewichtsmusters erkennbar.

Wenn keiner der Standard-Kernel das tut, was die Anwendung möchte, besteht der nächste Schritt darin, selbst einen abzustimmen. Die Kombination aus den obigen Regeln und den Steuerungen mul / add deckt nahezu jeden linearen Durchlauf ab, den eine klassische Pipeline für maschinelles Sehen je gebraucht hat; von dort aus ist es eine Frage des Ausprobierens von Gewichten, des Betrachtens der Ausgabe und des Iterierens.