5.17. Un catalogo di kernel standard¶
L’elaborazione classica delle immagini ha accumulato un catalogo piuttosto ampio di pattern di pesi dei kernel che ricorrono di continuo – rilevatori di bordi, filtri di nitidezza, effetti di rilievo, filtri di smoothing, motion blur – e ognuno di essi viene eseguito tramite morph(). Ciascuno è breve, ciascuno fa una cosa sola e la maggior parte è semplice da leggere una volta che la logica di base dei pesi diventa chiara.
I kernel qui sotto sono tutti 3-per-3 salvo diversa indicazione, quindi usano tutti size=1 nella chiamata. La struttura dei pesi di ogni kernel è descritta accanto ad esso, perché leggere i pesi è ciò che costruisce l’intuizione del motivo per cui un kernel produce un effetto di rilievo e un altro aumenta la nitidezza.
5.17.1. Il kernel identità¶
Il kernel più semplice possibile è l”identità – uno al centro, zero ovunque:
identity = [0, 0, 0,
0, 1, 0,
0, 0, 0]
img.morph(1, identity)
Ogni pixel di output prende il proprio valore dal centro del vicinato, che è il pixel di input nella stessa posizione. L’immagine passa inalterata. L’identità non ha alcun uso pratico come filtro, ma è la base di riferimento utile per comprendere ogni altro kernel: qualsiasi kernel diverso dall’identità è l’identità più qualche modifica.
Un kernel il cui peso centrale è grande con piccoli pesi negativi attorno sottrae il contorno dal centro. Un kernel con peso centrale nullo ignora il pixel stesso e risponde solo alle differenze tra i suoi vicini. Leggere un kernel in questo modo – cosa fa il peso centrale al pixel, cosa aggiungono o tolgono i pesi circostanti – è il modo più rapido per prevederne l’effetto.
5.17.2. Rilevamento dei bordi¶
I kernel di rilevamento dei bordi rispondono fortemente nelle posizioni in cui la luminosità cambia rapidamente in una direzione particolare e producono un output prossimo a zero dove la luminosità è uniforme. Sono la famiglia i cui pesi sommano a zero: una zona piatta (ogni pixel con lo stesso valore) produce output nullo, perché ogni peso positivo è esattamente annullato da un peso negativo di pari magnitudine.
Sobel-x è l’esempio canonico. Rileva i bordi verticali (transizioni di luminosità sinistra/destra):
sobel_x = [-1, 0, 1,
-2, 0, 2,
-1, 0, 1]
img.morph(1, sobel_x, mul=0.25, add=128)
Il corrispondente Sobel-y è lo stesso pattern ruotato di 90 gradi; rileva i bordi orizzontali (transizioni di luminosità alto/basso):
sobel_y = [-1, -2, -1,
0, 0, 0,
1, 2, 1]
La riga centrale di Sobel-x ha pesi -2 e 2 invece di -1 e 1. Il peso aggiuntivo sulla riga centrale conferisce al kernel un piccolo smoothing integrato nella direzione lungo il bordo, il che lo rende più robusto al rumore rispetto al più semplice operatore Prewitt, che rinuncia a queste magnitudini extra:
prewitt_x = [-1, 0, 1,
-1, 0, 1,
-1, 0, 1]
prewitt_y = [-1, -1, -1,
0, 0, 0,
1, 1, 1]
Prewitt pesa ogni riga in modo uguale, quindi la sua risposta è leggermente più netta di quella di Sobel, al costo di una maggiore sensibilità al rumore di singoli pixel (il costo di esecuzione del kernel è identico – la convoluzione svolge lo stesso lavoro qualunque siano i pesi). Su un’immagine pulita con bordi marcati, è un sostituto perfettamente adeguato di Sobel.
Scharr va nella direzione opposta. I suoi pesi sono più grandi e calibrati per un rilevamento accurato della direzione dei bordi ad angolazioni più fini:
scharr_x = [-3, 0, 3,
-10, 0, 10,
-3, 0, 3]
img.morph(1, scharr_x, mul=0.0625, add=128)
Il divisore mul=0.0625 (1/16) riporta l’output nell’intervallo 0 – 255 dopo la somma di prodotti più grande. Scharr è la scelta giusta quando l’applicazione necessita della risposta del gradiente geometricamente più fedele ed è disposta a pagare un costo aritmetico leggermente superiore per ottenerla.
5.17.3. Il Laplaciano¶
Un kernel Laplaciano risponde ai bordi in qualsiasi direzione contemporaneamente. Mentre i Sobel rilevano ciascuno i cambiamenti di luminosità lungo un asse, il pattern di pesi simmetrico del Laplaciano risponde allo stesso modo indipendentemente dalla direzione del bordo:
laplacian_4 = [ 0, -1, 0,
-1, 4, -1,
0, -1, 0]
img.morph(1, laplacian_4, add=128)
La struttura: peso centrale 4, quattro vicini orizzontali/verticali con peso -1, le quattro diagonali con peso zero. Il kernel somma a zero, quindi le zone piatte producono output nullo. Dove la luminosità sta cambiando, il valore centrale differisce dalla media dei suoi quattro vicini cardinali e l’output è l’entità di tale differenza.
La variante a 8 connessioni include i vicini diagonali:
laplacian_8 = [-1, -1, -1,
-1, 8, -1,
-1, -1, -1]
Ogni kernel rileva cose leggermente diverse. La versione a 4 connessioni produce un output più pulito su bordi orizzontali e verticali; quella a 8 connessioni è più isotropa – risponde altrettanto bene in ogni direzione – ma produce un output leggermente più rumoroso. Il kernel a 8 connessioni circola anche con il nome outline, per il suo impiego nella visualizzazione dei bordi.
5.17.5. Effetto rilievo¶
Un kernel di rilievo (emboss) produce l’effetto di illuminazione laterale presente nei classici editor di immagini. L’output sembra come se l’immagine fosse stata estrusa in un bassorilievo e poi illuminata da un angolo:
emboss = [-2, -1, 0,
-1, 1, 1,
0, 1, 2]
img.morph(1, emboss, add=128)
Il trucco sta nell”asimmetria lungo la diagonale. L’angolo in alto a sinistra ha il peso più negativo, quello in basso a destra ha il peso più positivo e la diagonale da angolo ad angolo passa da negativo attraverso uno fino a positivo. In ogni pixel il kernel calcola essenzialmente «luminosità in basso a destra meno luminosità in alto a sinistra», che è positiva dove l’immagine diventa più luminosa in quella direzione e negativa dove diventa più scura. Aggiungere 128 ricentra l’output con segno sul grigio medio, in modo che l’effetto sia visibile.
Ruotando l’asimmetria lungo l’altra diagonale si ottiene l’effetto rilievo dalla direzione opposta:
emboss_alt = [ 0, 1, 2,
-1, 1, 1,
-2, -1, 0]
img.morph(1, emboss_alt, add=128)
Le due direzioni di rilievo sono utili in combinazione – sottraendo l’una dall’altra, oppure eseguendo ciascuna sulla stessa immagine e confrontando le risposte – quando un’applicazione deve rilevare l’orientamento.
5.17.6. Smoothing¶
I kernel di smoothing sono la famiglia i cui pesi sommano a uno (e sono tutti non negativi). Una zona piatta che attraversa un tale kernel produce la stessa luminosità uniforme, perché il kernel media tra loro i valori dei pixel anziché amplificarne le differenze.
Il più semplice è il box blur, che è esattamente ciò che calcola mean():
box_blur = [1, 1, 1,
1, 1, 1,
1, 1, 1]
img.morph(1, box_blur)
Il kernel somma a 9, quindi la divisione automatica per la somma del kernel trasforma la somma di prodotti in una vera media sui nove pixel del vicinato. In pratica mean() è il modo migliore per eseguire questo kernel – produce lo stesso output più velocemente, attraverso un percorso ottimizzato per il calcolo della media e nient’altro, mentre morph esegue il meccanismo generale di convoluzione. Il box blur è nel catalogo perché è la base di riferimento giusta per comprendere ogni altro kernel di smoothing.
Un’approssimazione 3-per-3 della Gaussiana pesa il centro e i vicini cardinali più degli angoli:
gaussian = [1, 2, 1,
2, 4, 2,
1, 2, 1]
img.morph(1, gaussian)
I pesi sono la riga del triangolo di Pascal 1, 2, 1 moltiplicata per prodotto esterno con se stessa. Il peso centrale 4 è il più grande perché il pixel centrale contribuisce maggiormente al proprio output; gli angoli valgono 1 perché sono i più lontani dal centro. Il kernel somma a 16 e la divisione automatica per la somma del kernel gestisce la normalizzazione – non serve alcun argomento mul. La forma 3-per-3 è un’approssimazione grossolana di una vera Gaussiana e indistinguibile da gaussian() con size=1; la forma morph è utile soprattutto quando un’applicazione vuole comporre lo smoothing con un’altra operazione nello stesso passaggio.
5.17.7. Motion blur¶
Un kernel di motion blur media i pixel lungo una direzione, lasciando inalterata la direzione perpendicolare. Il caso più semplice è quello orizzontale:
motion_h = [0, 0, 0,
1, 1, 1,
0, 0, 0]
img.morph(1, motion_h)
La riga centrale media tre pixel lungo l’asse orizzontale; le righe superiore e inferiore sono zero. Il kernel somma a 3, quindi la divisione automatica per la somma del kernel produce una vera media su tre pixel senza bisogno di alcun mul. L’output è una copia dell’input spalmata orizzontalmente – l’effetto che una camera cattura quando il soggetto si muove lateralmente durante l’esposizione. Il motion blur verticale è lo stesso pattern ruotato:
motion_v = [0, 1, 0,
0, 1, 0,
0, 1, 0]
Un motion blur diagonale usa la diagonale principale:
motion_diag = [1, 0, 0,
0, 1, 0,
0, 0, 1]
img.morph(1, motion_diag)
I kernel di motion blur sono utili sia come effetto (sfocando deliberatamente un frame a scopo visivo) sia come pattern di test per algoritmi che devono essere robusti contro gli artefatti di movimento (esegui l’algoritmo su un input con motion blur e verifica che produca ancora la risposta corretta).
5.17.8. Leggere i kernel a colpo d’occhio¶
Alcune regole pratiche rendono più facile leggere a vista i nuovi kernel:
Somma a uno con pesi non negativi ⇒ smoothing (preserva la luminosità media).
Somma a zero con pesi sia positivi che negativi ⇒ risposta ai bordi (zero sulle zone piatte).
Somma a uno con un grande centro positivo e piccoli contorni negativi ⇒ aumento della nitidezza (identità più risposta ai bordi).
Asimmetrico lungo una diagonale con somma a uno ⇒ effetto rilievo (mette in risalto un lato di ogni transizione di luminosità).
Concentrato lungo un asse con somma a uno ⇒ sfocatura direzionale.
La prima di queste regole a cui il kernel corrisponde è di solito l’ipotesi giusta su ciò che fa. La maggior parte dei kernel utili è riconoscibile dalla sola disposizione del proprio pattern di pesi.
Quando nessuno dei kernel standard fa ciò che l’applicazione desidera, il passo successivo è calibrarne uno a mano. La combinazione delle regole sopra con i controlli mul / add copre quasi ogni passaggio lineare che una pipeline classica di visione artificiale abbia mai voluto; da lì è questione di provare pesi, osservare l’output e iterare.