5.16. Benutzerdefinierte Faltungskernel

Die bisher behandelten Nachbarschaftsfilter besaßen jeweils eine eingebaute Statistik, die der Filter an jeder Position auf das Fenster anwandte – den Mittelwert, den Gauß-gewichteten Durchschnitt, den Median. morph() ist der eine Filter, der es der Anwendung erlaubt, die Statistik selbst bereitzustellen, und zwar in Form eines Kernels: einer kleinen Matrix aus Gewichten, die beschreibt, wie der Filter die Nachbarschaftspixel zu einem einzigen Ausgabewert kombinieren soll.

Der Mechanismus ist die klassische Faltungsoperation. An jeder Ausgabeposition wird jedes Nachbarschaftspixel mit dem passenden Gewicht im Kernel multipliziert, die Produkte werden summiert, das Ergebnis wird optional skaliert und mit einem Offset versehen, und der Wert wird in das Ausgabepixel geschrieben. Verschiedene Kernel liefern aus derselben Eingabe verschiedene Ergebnisse. Ein Kernel mit lauter gleichen positiven Gewichten reproduziert den mean()-Filter; ein glockenförmiger reproduziert gaussian(). Muster jenseits davon erzeugen Kantenreaktionen, Prägungen, Gradienten, Schärfung, Bewegungsunschärfe und einen langen Katalog weiterer Effekte – alles, was die klassische Bildverarbeitung je mit einem einzigen linearen Durchlauf erreichen wollte.

5.16.1. Die morph-Methode

Die Signatur sieht aus wie die der anderen Nachbarschaftsfilter, mit einem zusätzlichen Argument:

img.morph(size, kernel, mul=1.0, add=0.0)

size ist der Radius wie überall sonst, der Kernel muss also exakt (2 * size + 1) Zeilen mal (2 * size + 1) Spalten umfassen. Der Kernel selbst ist eine flache Python-Liste mit ebenso vielen Zahlen, in zeilenweiser Reihenfolge (row-major) – die ersten (2 * size + 1) Einträge bilden die obere Zeile, die nächsten (2 * size + 1) die zweite Zeile, und so weiter bis hinunter zur untersten Zeile. mul skaliert die Summe der Produkte, bevor sie in das Ausgabepixel geschrieben wird, und add addiert eine Konstante. Die Standardwerte mul=1.0 und add=0.0 lassen die Faltungsausgabe unverändert.

Ein Detail, das man ausdrücklich erwähnen sollte: Die Methode teilt die Summe der Produkte vor dem Schreiben der Ausgabe automatisch durch die Summe der Kerneleinträge. Diese automatische Division bedeutet, dass ein Mittelungskernel, dessen Einträge sich zu neun summieren – etwa eine 3-mal-3-Boxunschärfe – ohne zusätzlichen Aufwand auf ein Neuntel skaliert herauskommt, und ein Gauß-Näherungskernel, der sich zu sechzehn summiert, auf ein Sechzehntel skaliert herauskommt, beides ohne dass die Anwendung die Division selbst berechnen muss. Die Anwendung setzt mul nur dann, wenn sie eine weitere Skalierung zusätzlich zur automatischen Normalisierung wünscht – oder, häufiger, wenn der Kernel sich zu null summiert (ein Kantenreaktionskernel) und die automatische Division eine Division durch nichts wäre. Das Framework behandelt die Summe in diesem Fall als eins, und mul wird zum einzigen Stellrad, um die unskalierte Summe der Produkte im Wertebereich zu halten.

Das Paar threshold=True / offset=N aus dem Abschnitt zur adaptiven Schwellenwertbildung funktioniert auch bei morph(), sodass dasselbe Framework für benutzerdefinierte Kernel einen binären Schwellenwert erzeugen kann, dessen Grenzwert von einer benutzerdefinierten Statistik berechnet wird.

5.16.2. Das Kernel-Layout

Ein 3-mal-3-Kernel (size=1) ist eine flache Liste aus neun Zahlen, angeordnet von links nach rechts und von oben nach unten. Die Konvention liest sich natürlich, wenn man die Liste über drei Python-Zeilen umbricht:

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

Dies ist der Sobel-x-Gradientenoperator – der erste Standardkernel, den jede Anwendung haben möchte, und ein nützlicher, um ihn von Anfang bis Ende durchzugehen. Das Muster ist unkompliziert: negative Gewichte in der linken Spalte, positive Gewichte in der rechten Spalte, mit einer Nullspalte in der Mitte. Die Zeilengewichte -1, -2, -1 (bzw. 1, 2, 1 auf der rechten Seite) sind in der Mitte höher als an den Ecken, was der mittleren Zeile mehr Einfluss auf das Ergebnis gibt als den Eckzeilen.

Wenn der Kernel über eine vertikale Kante hinwegstreicht – eine Pixelspalte, die von dunkel links zu hell rechts übergeht – erfassen die negativen Gewichte die dunkle Seite und die positiven Gewichte die helle Seite. Die Summe der Produkte ist eine große positive Zahl, die der Filter als helles Ausgabepixel schreibt. Ein horizontaler Bereich gleichmäßiger Helligkeit ergibt null, weil jedem positiven Gewicht ein negatives Gewicht gleicher Größe auf einem Pixel mit demselben Wert gegenübersteht.

Ausführen des Kernels:

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

Der Sobel-Kernel summiert sich zu null – jedem negativen Gewicht auf der linken Seite steht ein gleich großes positives Gewicht auf der rechten gegenüber – sodass die automatische Division durch nichts dividiert und mul die einzige Skalierung der Summe der Produkte ist. mul=0.25 hält die Reaktion im Wertebereich: Die größte absolute Summe, die Sobel-x aus einem 3-mal-3-Bereich erzeugen kann, beträgt etwa 4 * 255 = 1020 (acht helle Pixel, gewichtet bis zu 2), und eine Division durch vier lässt die Extremfälle bei 255 landen, wo das Format sie sauber abschneidet.

Der passende Sobel-y-Kernel erkennt horizontale Kanten, indem er dasselbe Gewichtsmuster um 90 Grad dreht:

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

Anwendungen, die jede Kante erkennen wollen, unabhängig von der Richtung, lassen typischerweise beide Sobels laufen und kombinieren die Reaktionen.

5.16.3. Versetzen der Ausgabe

add ist die andere Hälfte der Skalierungsgeschichte. Die Reaktion eines Kernels mit Nullsumme ist vorzeichenbehaftet – positiv auf der einen Seite einer Kante, negativ auf der anderen – und die negative Hälfte wird beim Schreiben in ein vorzeichenloses Pixel auf null abgeschnitten. add=128 verschiebt die Reaktion so, dass sie auf Mittelgrau zentriert ist, sodass negative Reaktionen als Werte unter 128 und positive als Werte darüber erhalten bleiben: Eine Kantenreaktion oder eine Prägung wird in beide Richtungen sichtbar, auf Kosten der halben Reichweite in jeder Richtung.

Welche Kombination aus mul und add ein Kernel erwartet, ist Teil des Kerneldesigns; der Standard-Kernelkatalog listet die richtigen Einstellungen für jeden gängigen Kernel auf.

5.16.4. Größere Kernel

Alles auf dieser Seite wurde mit 3-mal-3-Kerneln (size=1) beschrieben, weil das die Größe ist, die der Standardkatalog verwendet, und weil sich das zeilenweise Layout (row-major) in dieser Größe leicht von Hand niederschreiben lässt. Nichts am Mechanismus beschränkt den Kernel jedoch auf 3-mal-3. size=2 führt einen 5-mal-5-Kernel mit fünfundzwanzig Einträgen in der flachen Liste aus; size=3 führt einen 7-mal-7-Kernel mit neunundvierzig aus; und so weiter, bis zu welchem Radius auch immer die Anwendung zu zahlen bereit ist. Das Framework verarbeitet bei jeder ungeraden Größe entweder ein flaches Listen- oder ein verschachteltes Zeilenlayout.

Der Grund, zu einem größeren Kernel zu greifen, ist derselbe wie der Grund, bei jedem der eingebauten Filter zu einer größeren Nachbarschaft zu greifen: mehr Mittelung, breitere Merkmalserkennung, geringere Empfindlichkeit gegenüber Einzelpixelrauschen. Die Kosten wachsen mit dem Quadrat des Radius – ein 5-mal-5 leistet etwa das 2,8-Fache der Arbeit pro Pixel eines 3-mal-3, ein 7-mal-7 etwa das 5,4-Fache – und dieser Multiplikator geht direkt von der Bildrate ab.

Das praktische Vorgehen besteht darin, beim Standardkatalog bei size=1 zu bleiben und nur dann zu größeren Größen zu greifen, wenn der Algorithmus die größere Nachbarschaft benötigt. Kantendetektoren profitieren selten über 3-mal-3 hinaus; Glättungsfilter manchmal schon; die richtige Größe hängt von der Skala der Merkmale ab, die die Anwendung hervorheben oder unterdrücken will.

5.16.5. Wann man zu morph greifen sollte

Für die alltägliche Glättung sind mean(), gaussian() und bilateral() schneller und sauberer. Für die Kantenerkennung sind laplacian() und find_edges() eigens dafür gebaut. Direkt zu morph() zu greifen lohnt sich, wenn die Anwendung eine bestimmte Faltung benötigt, die die eingebauten Filter nicht bereitstellen – einen gerichteten Sobel, eine benutzerdefinierte Kantenvorlage, einen auf eine bestimmte Textur abgestimmten Kernel, nach der der Rest der Pipeline suchen wird, oder einen beliebigen der Standardkataloge nützlicher Kernel, die die klassische Bildverarbeitung über die Jahrzehnte aufgebaut hat. Die volle Flexibilität beliebiger Kernel steht zur Verfügung; der Preis dafür ist, dass die Anwendung selbst für die Wahl der Kernelwerte verantwortlich ist, die das gewünschte Ergebnis erzeugen.