5.17. Een catalogus van standaardkernels

Klassieke beeldverwerking heeft een behoorlijke catalogus aan kernelgewichtpatronen opgebouwd die steeds opnieuw terugkomen – randdetectoren, verscherpers, embossers, smoothers, motion blurs – en elk daarvan loopt via morph(). Elke is kort, elke doet één ding, en de meeste zijn eenvoudig te lezen zodra de basislogica van de gewichten duidelijk is.

De onderstaande kernels zijn allemaal 3-bij-3 tenzij anders vermeld, dus ze gebruiken allemaal size=1 in de aanroep. De gewichtstructuur van elke kernel wordt ernaast beschreven, want het lezen van de gewichten is wat de intuïtie opbouwt voor waarom de ene kernel embosst en de andere verscherpt.

5.17.1. De identiteitskernel

De eenvoudigst mogelijke kernel is de identiteit – een één in het midden, overal elders nul:

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

img.morph(1, identity)

Elke uitvoerpixel neemt zijn waarde over uit het midden van de omgeving, wat de invoerpixel op dezelfde positie is. De afbeelding gaat ongewijzigd door. De identiteit heeft geen praktisch nut als filter, maar vormt de nuttige basislijn om elke andere kernel te begrijpen: elke niet-identiteitskernel is de identiteit plus een zekere wijziging.

Een kernel waarvan het middengewicht groot is met kleine negatieve gewichten eromheen trekt de omgeving van het midden af. Een kernel met een middengewicht van nul negeert de pixel zelf en reageert alleen op verschillen tussen zijn buren. Een kernel op deze manier lezen – wat het middengewicht met de pixel doet, wat de omringende gewichten toevoegen of wegnemen – is de snelste manier om het effect ervan te voorspellen.

5.17.2. Randdetectie

Randdetectie-kernels reageren sterk op posities waar de helderheid snel verandert in een bepaalde richting, en produceren bijna nul uitvoer waar de helderheid uniform is. Zij vormen de familie waarvan de gewichten optellen tot nul: een vlak vlakje (elke pixel dezelfde waarde) levert nul uitvoer op, omdat elk positief gewicht precies wordt opgeheven door een negatief gewicht van gelijke grootte.

Sobel-x is het canonieke voorbeeld. Het detecteert verticale randen (helderheidsovergangen links/rechts):

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

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

De bijbehorende Sobel-y is hetzelfde patroon 90 graden gedraaid; het detecteert horizontale randen (helderheidsovergangen boven/onder):

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

De middelste rij van Sobel-x heeft gewichten -2 en 2 in plaats van -1 en 1. Het extra gewicht op de middenrij geeft de kernel een kleine ingebouwde smoothing in de richting langs de rand, wat hem robuuster maakt tegen ruis dan de eenvoudigere Prewitt-operator die die extra groottes laat vallen:

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

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

Prewitt weegt elke rij gelijk, dus zijn respons is een tikje scherper dan die van Sobel, ten koste van een grotere gevoeligheid voor enkele-pixelruis (de kosten van het uitvoeren van de kernel zijn identiek – de convolutie doet hetzelfde werk wat de gewichten ook zijn). Op een schone afbeelding met sterke randen is het een prima bruikbare vervanger voor Sobel.

Scharr gaat de andere richting op. De gewichten zijn groter en afgestemd op nauwkeurige detectie van de randrichting bij fijnere hoeken:

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

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

De mul=0.0625-deler (1/16) brengt de uitvoer na de grotere som-van-producten weer binnen 0255. Scharr is het juiste antwoord wanneer de toepassing de meest geometrisch getrouwe gradiëntrespons nodig heeft en bereid is daarvoor iets meer rekenwerk te betalen.

5.17.3. De Laplaciaan

Een Laplaciaan-kernel reageert in één keer op randen in elke richting. Waar de Sobels elk helderheidsveranderingen langs één as detecteren, reageert het symmetrische gewichtspatroon van de Laplaciaan op dezelfde manier ongeacht in welke richting de rand loopt:

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

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

De structuur: middengewicht 4, vier horizontale/verticale buren met gewicht -1, de vier diagonalen met gewicht nul. De kernel telt op tot nul, dus vlakke vlakjes leveren nul uitvoer op. Waar de helderheid verandert, verschilt de middenwaarde van het gemiddelde van zijn vier kardinale buren, en de uitvoer is de grootte van dat verschil.

De 8-verbonden variant omvat de diagonale buren:

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

Elke kernel detecteert net iets andere dingen. De 4-verbonden versie produceert schonere uitvoer op horizontale en verticale randen; de 8-verbonden versie is meer isotroop – hij reageert in elke richting even goed – maar produceert iets ruisachtiger uitvoer. De 8-verbonden kernel circuleert ook onder de naam outline, naar zijn gebruik voor het visualiseren van randen.

5.17.4. Verscherpen

Een verscherpings-kernel is de identiteit plus een randresponskernel. De uitvoer is de oorspronkelijke afbeelding plus een kopie van de randen, zodat hoogfrequente kenmerken worden versterkt ten opzichte van vlakke binnengebieden.

De standaard 4-verbonden verscherpingskernel voegt de 4-verbonden Laplaciaan toe aan de identiteit:

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

img.morph(1, sharpen)

Het lezen van de kernel: het middengewicht is identity (1) + Laplacian centre (4) = 5, en de omgeving komt overeen met die van de Laplaciaan. Vlakke vlakjes leveren 5 * 1 - 4 * 1 = 1 keer de middenwaarde op – de identiteit. Randen leveren het origineel plus de Laplaciaan-respons op. De som van de gewichten is 1, dus mul en add blijven op hun standaardwaarden.

Voor sterkere verscherping gaat de 8-verbonden variant verder:

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

img.morph(1, sharpen_strong)

Het middengewicht 9 is identity (1) + Laplacian-8 centre (8). Dezelfde logica, meer versterking, meer risico op het ook versterken van sensorruis.

Sterke verscherpingskernels zijn in wezen gaussian() met unsharp=True, alleen direct uitgedrukt als een kernel in plaats van via de unsharp-mask-vlag. Het gedrag op pixelniveau is hetzelfde; de keuze gaat tussen het gemak van de benoemde methode en de fijnregeling van een handmatig afgestemde kernel.

5.17.5. Emboss

Een emboss-kernel produceert het van-opzij-belichte effect dat in klassieke beeldbewerkers voorkomt. De uitvoer ziet eruit alsof de afbeelding tot een reliëf is geëxtrudeerd en vervolgens vanuit één hoek is belicht:

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

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

De truc is de asymmetrie over de diagonaal. De linkerbovenhoek heeft het meest negatieve gewicht, de rechteronderhoek het meest positieve gewicht, en de diagonaal van hoek tot hoek loopt van negatief via één naar positief. Bij elke pixel berekent de kernel in wezen “helderheid rechtsonder van mij min helderheid linksboven van mij”, wat positief is waar de afbeelding in die richting helderder wordt en negatief waar hij donkerder wordt. Het toevoegen van 128 hercentreert de getekende uitvoer naar middengrijs zodat het effect zichtbaar is.

De asymmetrie over de andere diagonaal draaien embosst vanuit de tegenovergestelde richting:

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

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

De twee embossrichtingen zijn nuttig in combinatie – de ene van de andere aftrekken, of beide op dezelfde afbeelding uitvoeren en de responsen vergelijken – wanneer een toepassing de oriëntatie moet detecteren.

5.17.6. Smoothing

Smoothing-kernels vormen de familie waarvan de gewichten optellen tot één (en allemaal niet-negatief zijn). Een vlak vlakje door zo’n kernel levert dezelfde vlakke helderheid op, omdat de kernel de pixelwaarden middelt in plaats van hun verschillen te versterken.

De eenvoudigste is de box blur, wat precies is wat mean() berekent:

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

img.morph(1, box_blur)

De kernel telt op tot 9, dus de automatische deling door de kernelsom verandert de som-van-producten in een echt gemiddelde over de negen omringende pixels. In de praktijk is mean() de betere manier om deze kernel uit te voeren – het produceert dezelfde uitvoer sneller, via een pad dat geoptimaliseerd is voor het berekenen van het gemiddelde en niets anders, terwijl morph de algemene convolutiemachinerie draait. De box blur staat in de catalogus omdat het de juiste basislijn is om elke andere smoothing-kernel te begrijpen.

Een 3-bij-3-benadering van de Gaussiaan weegt het midden en de kardinale buren zwaarder dan de hoeken:

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

img.morph(1, gaussian)

De gewichten zijn de Pascal-driehoeksrij 1, 2, 1 als buitenproduct met zichzelf. Het middengewicht 4 is het grootst omdat de middenpixel het meest bijdraagt aan zijn eigen uitvoer; de hoeken zijn 1 omdat ze het verst van het midden liggen. De kernel telt op tot 16, en de automatische deling door de kernelsom verzorgt de normalisatie – geen mul-argument nodig. De 3-bij-3-vorm is een grove benadering van een echte Gaussiaan en niet te onderscheiden van gaussian() bij size=1; de morph-vorm is vooral nuttig wanneer een toepassing de smoothing in dezelfde doorgang met een andere bewerking wil combineren.

5.17.7. Motion blur

Een motion-blur-kernel middelt pixels langs één richting en laat de loodrechte richting onvervaagd. Het eenvoudigste geval is horizontaal:

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

img.morph(1, motion_h)

De middelste rij middelt drie pixels langs de horizontale as; de boven- en onderrij zijn nul. De kernel telt op tot 3, dus de automatische deling door de kernelsom produceert een echt drie-pixelgemiddelde zonder dat enige mul nodig is. De uitvoer is een horizontaal uitgesmeerde kopie van de invoer – het effect dat een camera vastlegt wanneer het onderwerp tijdens de belichting zijwaarts beweegt. De verticale motion blur is hetzelfde patroon, gedraaid:

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

Een diagonale motion blur gebruikt de hoofddiagonaal:

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

img.morph(1, motion_diag)

Motion-blur-kernels zijn nuttig zowel als effect (een frame opzettelijk vervagen voor visuele doeleinden) als als testpatroon voor algoritmen die robuust moeten zijn tegen bewegingsartefacten (voer het algoritme uit op een door motion blur vervaagde invoer en controleer of het nog steeds het juiste antwoord produceert).

5.17.8. Kernels in één oogopslag lezen

Een paar vuistregels maken nieuwe kernels gemakkelijker om in één oogopslag te lezen:

  • Optellen tot één met niet-negatieve gewichten ⇒ smoothing (behoudt de gemiddelde helderheid).

  • Optellen tot nul met zowel positieve als negatieve gewichten ⇒ randrespons (nul op vlakke vlakjes).

  • Optellen tot één met een groot positief midden en kleine negatieve omgeving ⇒ verscherping (identiteit plus randrespons).

  • Asymmetrisch over een diagonaal met optelling tot één ⇒ embossing (accentueert één kant van elke helderheidsovergang).

  • Geconcentreerd langs één as met optelling tot één ⇒ directionele blur.

De eerste van deze waarmee de kernel overeenkomt is meestal de juiste gok van wat hij doet. De meeste nuttige kernels zijn al herkenbaar aan de indeling van hun gewichtspatroon alleen.

Wanneer geen van de standaardkernels doet wat de toepassing wil, is de volgende stap er een handmatig af te stemmen. De combinatie van de bovenstaande regels en de mul / add-besturingselementen dekt vrijwel elke lineaire doorgang die een klassieke machine vision-pijplijn ooit heeft gewild; vanaf daar is het een kwestie van gewichten uitproberen, naar de uitvoer kijken en itereren.