5.16. Kernel di convoluzione personalizzati¶
I filtri di vicinato visti finora avevano ciascuno una statistica integrata che il filtro applicava alla finestra in ogni posizione: la media, la media pesata gaussiana, la mediana. morph() è l’unico filtro che consente all’applicazione di fornire essa stessa la statistica, sotto forma di un kernel: una piccola matrice di pesi che descrive come il filtro debba combinare i pixel del vicinato in un singolo valore di uscita.
Il meccanismo è la classica operazione di convoluzione. In ogni posizione di uscita, ogni pixel del vicinato viene moltiplicato per il peso corrispondente nel kernel, i prodotti vengono sommati, il risultato viene opzionalmente scalato e traslato, e il valore viene scritto nel pixel di uscita. Kernel diversi producono risultati diversi a partire dallo stesso ingresso. Un kernel con pesi positivi tutti uguali riproduce il filtro mean(); uno a forma di campana riproduce gaussian(). Pattern oltre a questi producono risposte ai bordi, effetti di rilievo (emboss), gradienti, nitidezza, sfocatura da movimento e un lungo catalogo di altri effetti: tutto ciò che l’elaborazione classica delle immagini abbia mai voluto fare con un singolo passaggio lineare.
5.16.1. Il metodo morph¶
La firma assomiglia a quella degli altri filtri di vicinato, con un argomento aggiuntivo:
img.morph(size, kernel, mul=1.0, add=0.0)
size è il raggio, esattamente come altrove, quindi il kernel deve essere composto esattamente da (2 * size + 1) righe per (2 * size + 1) colonne. Il kernel stesso è una lista Python piatta di quel numero di valori, in ordine row-major (per righe): le prime (2 * size + 1) voci sono la riga superiore, le successive (2 * size + 1) sono la seconda riga, e così via, fino alla riga inferiore. mul scala la somma dei prodotti prima che venga scritta nel pixel di uscita, e add aggiunge una costante. I valori predefiniti mul=1.0 e add=0.0 lasciano invariata l’uscita della convoluzione.
Un dettaglio che vale la pena esplicitare: il metodo divide automaticamente la somma dei prodotti per la somma delle voci del kernel prima di scrivere l’uscita. Questa divisione automatica fa sì che un kernel di media le cui voci sommano a nove (un box blur 3 per 3, per esempio) esca a un nono della scala senza alcuno sforzo aggiuntivo, e che un kernel di approssimazione gaussiana che somma a sedici esca a un sedicesimo della scala, entrambi senza che l’applicazione debba calcolare essa stessa la divisione. L’applicazione imposta mul solo quando desidera una scala ulteriore sopra alla normalizzazione automatica, oppure, più comunemente, quando il kernel somma a zero (un kernel di risposta ai bordi) e la divisione automatica risulterebbe una divisione per nulla. In quel caso il framework considera la somma pari a uno, e mul diventa l’unica manopola per mantenere nell’intervallo la somma dei prodotti non scalata.
La coppia threshold=True / offset=N della sezione sulla soglia adattiva funziona anche su morph(), quindi lo stesso framework di kernel personalizzati può produrre una soglia binaria il cui valore di taglio è calcolato da una statistica personalizzata.
5.16.2. La disposizione del kernel¶
Un kernel 3 per 3 (size=1) è una lista piatta di nove numeri disposti da sinistra a destra e dall’alto in basso. La convenzione si legge in modo naturale se la lista viene suddivisa su tre righe Python:
sobel_x = [-1, 0, 1,
-2, 0, 2,
-1, 0, 1]
Questo è l’operatore di gradiente Sobel-x, il primo kernel standard che qualsiasi applicazione vorrà usare e uno utile da analizzare dall’inizio alla fine. Il pattern è semplice: pesi negativi nella colonna sinistra, pesi positivi nella colonna destra, con la colonna centrale a zero. I pesi di riga -1, -2, -1 (oppure 1, 2, 1 sulla destra) sono più alti al centro che agli angoli, il che conferisce alla riga centrale maggiore influenza sul risultato rispetto alle righe degli angoli.
Quando il kernel scorre attraverso un bordo verticale (una colonna di pixel che passa da scura a sinistra a luminosa a destra) i pesi negativi raccolgono il lato scuro e i pesi positivi raccolgono il lato luminoso. La somma dei prodotti è un grande numero positivo, che il filtro scrive come un pixel di uscita luminoso. Una zona orizzontale di luminosità uniforme produce zero, perché ogni peso positivo è bilanciato da un peso negativo di pari magnitudine su un pixel con lo stesso valore.
Esecuzione del kernel:
img.morph(1, sobel_x, mul=0.25)
Il kernel di Sobel somma a zero: ogni peso negativo sul lato sinistro è bilanciato da un peso positivo uguale sulla destra, quindi la divisione automatica non divide per nulla e mul è l’unica scala sulla somma dei prodotti. mul=0.25 mantiene la risposta nell’intervallo: la maggiore somma assoluta che Sobel-x può produrre da una zona 3 per 3 è all’incirca 4 * 255 = 1020 (otto pixel luminosi pesati fino a 2), e dividere quel valore per quattro porta i casi estremi a 255, dove il formato li satura in modo pulito.
Il corrispondente kernel Sobel-y rileva i bordi orizzontali ruotando lo stesso pattern di pesi di 90 gradi:
sobel_y = [-1, -2, -1,
0, 0, 0,
1, 2, 1]
Le applicazioni che vogliono rilevare qualsiasi bordo, indipendentemente dalla direzione, in genere eseguono entrambi i Sobel e combinano le risposte.
5.16.3. Traslazione dell’uscita¶
add è l’altra metà della storia sullo scaling. La risposta di un kernel a somma zero è con segno (positiva su un lato di un bordo, negativa sull’altro) e la metà negativa viene saturata a zero quando viene scritta in un pixel senza segno. add=128 sposta la risposta affinché sia centrata sul grigio medio, così le risposte negative sopravvivono come valori inferiori a 128 e quelle positive si collocano al di sopra: una risposta ai bordi o un effetto di rilievo diventa visibile in entrambe le direzioni, a costo di metà dell’intervallo in ciascuna.
Quale combinazione di mul e add un kernel si aspetta fa parte della progettazione del kernel stesso; il catalogo dei kernel standard elenca le impostazioni corrette per ciascun kernel comune.
5.16.4. Kernel più grandi¶
Tutto in questa pagina è stato descritto con kernel 3 per 3 (size=1), perché è la dimensione che usa il catalogo standard e perché la disposizione row-major è facile da scrivere a mano a quella dimensione. Nulla nel meccanismo limita però il kernel a 3 per 3. size=2 esegue un kernel 5 per 5, con venticinque voci nella lista piatta; size=3 esegue un 7 per 7 con quarantanove; e così via, fino a qualunque raggio l’applicazione sia disposta a pagare. Il framework gestisce sia la disposizione a lista piatta sia quella a righe annidate per qualsiasi dimensione dispari.
Il motivo per ricorrere a un kernel più grande è lo stesso per cui si ricorre a un vicinato più grande in uno qualsiasi dei filtri integrati: più mediazione, rilevamento di caratteristiche più ampie, minore sensibilità al rumore del singolo pixel. Il costo cresce con il quadrato del raggio (un 5 per 5 svolge all’incirca 2,8 volte il lavoro per pixel di un 3 per 3, un 7 per 7 circa 5,4 volte) e quel moltiplicatore si riflette direttamente sul frame rate.
Il pattern pratico è restare a size=1 per il catalogo standard e ricorrere a dimensioni maggiori solo quando l’algoritmo ha bisogno del vicinato più ampio. I rilevatori di bordi raramente traggono beneficio oltre il 3 per 3; i filtri di smussamento a volte sì; la dimensione giusta dipende dalla scala delle caratteristiche che l’applicazione cerca di evidenziare o sopprimere.
5.16.5. Quando ricorrere a morph¶
Per lo smussamento quotidiano, mean(), gaussian() e bilateral() sono più veloci e puliti. Per il rilevamento dei bordi, laplacian() e find_edges() sono appositamente realizzati. La ragione per ricorrere direttamente a morph() è quando l’applicazione necessita di una convoluzione specifica che i filtri integrati non espongono: un Sobel direzionale, un template di bordo personalizzato, un kernel ottimizzato per una particolare texture che il resto della pipeline andrà a cercare, o uno qualsiasi dei kernel utili del catalogo standard che l’elaborazione classica delle immagini ha accumulato nei decenni. È disponibile tutta la flessibilità dei kernel arbitrari; il prezzo è che l’applicazione è responsabile della scelta dei valori del kernel che producono il risultato desiderato.