Imbottitura nelle reti neurali convoluzionali
Il riempimento è un processo essenziale nelle reti neurali convoluzionali. Sebbene non sia obbligatorio, è un processo che viene spesso utilizzato in molte architetture CNN all'avanguardia. In questo articolo esploreremo perché e come è fatto.
Il meccanismo della convoluzione
La convoluzione in un contesto di elaborazione di immagini/visione artificiale è un processo mediante il quale un'immagine viene “scansionata” da un filtro per elaborarla in qualche modo. Andiamo un po' nel tecnico con i dettagli.
Per un computer, un'immagine è semplicemente un array di tipi numerici (numeri, interi o float), questi tipi numerici sono giustamente chiamati pixel. Infatti un'immagine HD di 1920 pixel per 1080 pixel (1080p) è semplicemente una tabella/array di tipi numerici con 1080 righe e 1920 colonne. Un filtro invece è essenzialmente lo stesso ma solitamente di dimensioni più piccole, il comune filtro di convoluzione (3, 3) è un array di 3 righe e 3 colonne.
Quando si esegue il convolution su un'immagine, un filtro viene applicato su patch sequenziali dell'immagine in cui avviene la moltiplicazione degli elementi tra gli elementi del filtro e i pixel in quella patch, una somma cumulativa viene quindi restituita come un nuovo pixel a sé stante. Ad esempio, quando si esegue la convoluzione utilizzando un filtro (3, 3), 9 pixel vengono aggregati per produrre un singolo pixel. A causa di questo processo di aggregazione alcuni pixel vengono persi.
Filtra la scansione su un'immagine per generare una nuova immagine tramite convoluzione.
I pixel perduti
Per capire perché i pixel vengono persi, tieni presente che se un filtro di convoluzione esce dai limiti durante la scansione di un'immagine, quella particolare istanza di convoluzione viene ignorata. Per illustrare, si consideri un'immagine di 6 x 6 pixel su cui viene effettuata la convoluzione da parte di un filtro 3 x 3. Come si può vedere nell'immagine seguente, le prime 4 convoluzioni rientrano nell'immagine per produrre 4 pixel per la prima riga mentre la quinta e la sesta istanza escono dai limiti e vengono quindi ignorate. Allo stesso modo, se il filtro viene spostato verso il basso di 1 pixel, lo stesso schema si ripete con una perdita di 2 pixel anche per la seconda riga. Una volta completato il processo, l'immagine da 6 x 6 pixel diventa un'immagine da 4 x 4 pixel poiché avrebbe perso 2 colonne di pixel in dim 0 (x) e 2 righe di pixel in dim 1 (y).
Istanze di convoluzione che utilizzano un filtro 3x3.
Allo stesso modo, se viene utilizzato un filtro 5 x 5, 4 colonne e righe di pixel vengono perse rispettivamente in dim 0 (x) e dim 1 (y), risultando in un'immagine di 2 x 2 pixel.
Istanze di convoluzione che utilizzano un filtro 5x5.
Non credermi sulla parola, prova la funzione qui sotto per vedere se è davvero così. Sentiti libero di modificare gli argomenti come desideri.
import numpy as np
import torch
import torch.nn.functional as F
import cv2
import torch.nn as nn
from tqdm import tqdm
import matplotlib.pyplot as plt
def check_convolution(filter=(3,3), image_size=(6,6)):
"""
This function creates a pseudo image, performs
convolution and returns the size of both the pseudo
and convolved image
"""
# creating pseudo image
original_image = torch.ones(image_size)
# adding channel as typical to images (1 channel = grayscale)
original_image = original_image.view(1, 6, 6)
# perfoming convolution
conv_image = nn.Conv2d(1, 1, filter)(original_image)
print(f'original image size: {original_image.shape}')
print(f'image size after convolution: {conv_image.shape}')
pass
check_convolution()
Sembra che ci sia uno schema nel modo in cui i pixel vengono persi. Sembra che ogni volta che viene utilizzato un filtro m x n, m-1 colonne di pixel vengono perse in dim 0 e n-1 righe di pixel vengono perse in dim 1. Diventiamo un po' più matematici...
dimensione immagine=(x, y) dimensione del filtro=(m, n) dimensione dell'immagine dopo la convoluzione=(x-(m-1), y-(n-1))=(x-m+1, y-n+1)
Ogni volta che un'immagine di dimensione (x, y) viene sottoposta a convoluzione utilizzando un filtro di dimensione (m, n), viene prodotta un'immagine di dimensione (x-m+1, y-n+1).
Sebbene questa equazione possa sembrare un po’ contorta (nessun gioco di parole), la logica che sta dietro è abbastanza semplice da seguire. Poiché i filtri più comuni sono di dimensione quadrata (stesse dimensioni su entrambi gli assi), tutto quello che c'è da sapere è che una volta eseguita la convoluzione utilizzando un filtro (3, 3), 2 righe e colonne di pixel vengono perse (3-1); se viene fatto utilizzando un filtro (5, 5), vengono perse 4 righe e colonne di pixel (5-1); e se viene eseguito utilizzando un filtro (9, 9), hai indovinato, vengono perse 8 righe e colonne di pixel (9-1).
Implicazione dei pixel persi
La perdita di 2 righe e colonne di pixel potrebbe non sembrare avere un grande effetto soprattutto quando si ha a che fare con immagini di grandi dimensioni, ad esempio, un'immagine 4K UHD (3840, 2160) sembrerebbe non influenzata dalla perdita di 2 righe e colonne di pixel quando convoluto da un filtro (3, 3) quando diventa (3838, 2158), una perdita di circa lo 0,1% dei suoi pixel totali. I problemi iniziano a manifestarsi quando sono coinvolti più livelli di convoluzione, come è tipico nelle architetture CNN all'avanguardia. Prendiamo ad esempio RESNET 128, questa architettura ha circa 50 (3, 3) livelli di convoluzione, che comporterebbe una perdita di circa 100 righe e colonne di pixel, riducendo la dimensione dell'immagine a (3740, 2060), una perdita di circa il 7,2% dei pixel totali dell'immagine, il tutto senza tenere conto delle operazioni di downsampling.
Anche con architetture poco profonde, la perdita di pixel potrebbe avere un effetto enorme. Una CNN con solo 4 livelli di convoluzione applicati e utilizzata su un'immagine nel set di dati MNIST con dimensione (28, 28) comporterebbe una perdita di 8 righe e colonne di pixel, riducendone la dimensione a (20, 20), una perdita di 57,1 % dei suoi pixel totali, il che è piuttosto considerevole.
Poiché le operazioni di convoluzione avvengono da sinistra a destra e dall'alto verso il basso, i pixel vengono persi sui bordi più a destra e in basso. Pertanto si può affermare con certezza che la convoluzione comporta la perdita di pixel del bordo, pixel che potrebbero contenere caratteristiche essenziali per l'attività di visione artificiale in questione.
Imbottitura come soluzione
Poiché sappiamo che i pixel andranno persi dopo la convoluzione, possiamo prevenire questo problema aggiungendo pixel in anticipo. Ad esempio, se verrà utilizzato un filtro (3, 3), potremmo aggiungere in anticipo 2 righe e 2 colonne di pixel all'immagine in modo che una volta eseguita la convoluzione, la dimensione dell'immagine sia la stessa dell'immagine originale.
Torniamo un po’ alla matematica…
dimensione immagine=(x, y) dimensione del filtro=(m, n)
dimensione dell'immagine dopo il riempimento=(x+2, y+2)
utilizzando l'equazione ==> (x-m+1, y-n+1)
dimensione dell'immagine dopo la convoluzione (3, 3)=(x+2-3+1, y+2-3+1)=(x, y)
Imbottitura in termini di livello
Poiché abbiamo a che fare con tipi di dati numerici, è logico che anche il valore dei pixel aggiuntivi sia numerico. Il valore comune adottato è un valore di pixel pari a zero, ecco perché viene spesso utilizzato il termine "imbottitura zero".
Il problema dell'aggiunta preventiva di righe e colonne di pixel a un array di immagini è che l'operazione deve essere eseguita in modo uniforme su entrambi i lati. Ad esempio, quando si aggiungono 2 righe e 2 colonne di pixel, queste dovrebbero essere aggiunte come una riga in alto, una riga in basso, una colonna a sinistra e una colonna a destra.
Guardando l'immagine qui sotto, sono state aggiunte 2 righe e 2 colonne per riempire la matrice 6 x 6 di quelle a sinistra, mentre sono state aggiunte 4 righe e 4 colonne a destra. Le righe e le colonne aggiuntive sono state distribuite uniformemente lungo tutti i bordi come indicato nel paragrafo precedente.
Dando uno sguardo attento agli array, a sinistra, sembra che l'array 6 x 6 di uno sia stato racchiuso in un singolo strato di zeri, quindi imbottitura=1. D'altra parte, l'array a destra sembra essere racchiuso tra due strati di zeri, quindi imbottitura=2.
Strati di zeri aggiunti tramite riempimento.
Mettendo insieme tutti questi elementi, si può affermare con certezza che quando si cerca di aggiungere 2 righe e 2 colonne di pixel in preparazione per la convoluzione (3, 3), è necessario un singolo strato di riempimento. Nello stesso pannello, se è necessario aggiungere 6 righe e 6 colonne di pixel in preparazione alla convoluzione (7, 7), sono necessari 3 strati di riempimento. In termini più tecnici,
Dato un filtro di dimensione (m, n), sono necessari (m-1)/2 strati di riempimento per mantenere la stessa dimensione dell'immagine dopo la convoluzione; purché m=n e m sia un numero dispari.
Il processo di imbottitura
Per dimostrare il processo di riempimento, ho scritto del codice vanilla per replicare il processo di riempimento e convoluzione.
Innanzitutto, diamo un'occhiata alla funzione di riempimento di seguito, la funzione accetta un'immagine come parametro con un livello di riempimento predefinito pari a 2. Quando il parametro di visualizzazione viene lasciato su True, la funzione genera un mini report visualizzando la dimensione di entrambi i immagine originale e imbottita; viene restituito anche un grafico di entrambe le immagini.
def pad_image(image_path, padding=2, display=True, title=''):
"""
This function performs zero padding using the number of
padding layers supplied as argument and return the padded
image.
"""
# reading image as grayscale
image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
# creating an array of zeros
padded = arr = np.zeros((image.shape[0] + padding*2,
image.shape[1] + padding*2))
# inserting image into zero array
padded[int(padding):-int(padding),
int(padding):-int(padding)] = image
if display:
print(f'original image size: {image.shape}')
print(f'padded image size: {padded.shape}')
# displaying results
figure, axes = plt.subplots(1,2, sharey=True, dpi=120)
plt.suptitle(title)
axes[0].imshow(image, cmap='gray')
axes[0].set_title('original')
axes[1].imshow(padded, cmap='gray')
axes[1].set_title('padded')
axes[0].axis('off')
axes[1].axis('off')
plt.show()
print('image array preview:')
return padded
Funzione di imbottitura.
Per testare la funzione di riempimento, considera l'immagine seguente di dimensione (375, 500). Passando questa immagine attraverso la funzione di riempimento con riempimento=2 dovrebbe produrre la stessa immagine con due colonne di zero sul bordo sinistro e destro e due righe di zeri in alto e in basso aumentando la dimensione dell'immagine a (379, 504). Vediamo se è così…
Immagine di dimensioni (375, 500)
pad_image('image.jpg')
produzione: dimensione immagine originale: (375, 500) dimensione immagine imbottita: (379, 504)
Notare la sottile linea di pixel neri lungo i bordi dell'immagine imbottita.
Funziona! Sentiti libero di provare la funzione su qualsiasi immagine che potresti trovare e regolare i parametri come richiesto. Di seguito è riportato il codice vanilla per replicare la convoluzione.
def convolve(image_path, padding=2, filter, title='', pad=False):
"""
This function performs convolution over an image
"""
# reading image as grayscale
image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
if pad:
original_image = image[:]
image = pad_image(image, padding=padding, display=False)
else:
image = image
# defining filter size
filter_size = filter.shape[0]
# creating an array to store convolutions
convolved = np.zeros(((image.shape[0] - filter_size) + 1,
(image.shape[1] - filter_size) + 1))
# performing convolution
for i in tqdm(range(image.shape[0])):
for j in range(image.shape[1]):
try:
convolved[i,j] = (image[i:(i+filter_size), j:(j+filter_size)] * filter).sum()
except Exception:
pass
# displaying results
if not pad:
print(f'original image size: {image.shape}')
else:
print(f'original image size: {original_image.shape}')
print(f'convolved image size: {convolved.shape}')
figure, axes = plt.subplots(1,2, dpi=120)
plt.suptitle(title)
if not pad:
axes[0].imshow(image, cmap='gray')
axes[0].axis('off')
else:
axes[0].imshow(original_image, cmap='gray')
axes[0].axis('off')
axes[0].set_title('original')
axes[1].imshow(convolved, cmap='gray')
axes[1].axis('off')
axes[1].set_title('convolved')
pass
Funzione di convoluzione
Per il filtro ho scelto di utilizzare un array (5, 5) con valori pari a 0,01. L'idea alla base di ciò è che il filtro riduca l'intensità dei pixel del 99% prima di sommarli per produrre un singolo pixel. In termini semplicistici, questo filtro dovrebbe avere un effetto sfocato sulle immagini.
filter_1 = np.ones((5,5))/100
filter_1
[[0.01, 0.01, 0.01, 0.01, 0.01]
[0.01, 0.01, 0.01, 0.01, 0.01]
[0.01, 0.01, 0.01, 0.01, 0.01]
[0.01, 0.01, 0.01, 0.01, 0.01]
[0.01, 0.01, 0.01, 0.01, 0.01]]
(5, 5) Filtro di convoluzione
L'applicazione del filtro sull'immagine originale senza riempimento dovrebbe produrre un'immagine sfocata di dimensioni (371, 496), una perdita di 4 righe e 4 colonne.
convolve('image.jpg', filter=filter_1)
Esecuzione della convoluzione senza imbottitura
produzione: dimensione immagine originale: (375, 500) dimensione immagine convoluta: (371, 496)
(5, 5) convoluzione senza imbottitura
Tuttavia, quando il pad è impostato su true, la dimensione dell'immagine rimane la stessa.
convolve('image.jpg', pad=True, padding=2, filter=filter_1)
Convoluzione con 2 strati di imbottitura.
produzione: dimensione immagine originale: (375, 500) dimensione immagine convoluta: (375, 500)
(5, 5) convoluzione con riempimento
Ripetiamo gli stessi passaggi ma questa volta con un filtro (9, 9)...
filter_2 = np.ones((9,9))/100
filter_2
filter_2
[[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
[0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]])
(9, 9) filtro
Senza riempimento, l'immagine risultante si riduce di dimensioni...
convolve('image.jpg', filter=filter_2)
produzione: dimensione immagine originale: (375, 500) dimensione immagine convoluta: (367, 492)
(9, 9) convoluzione senza imbottitura
Utilizzando un filtro (9, 9), per mantenere la stessa dimensione dell'immagine dobbiamo specificare un livello di riempimento di 4 (9-1/2) poiché cercheremo di aggiungere 8 righe e 8 colonne all'immagine originale.
convolve('image.jpg', pad=True, padding=4, filter=filter_2)
produzione: dimensione immagine originale: (375, 500) dimensione immagine convoluta: (375, 500)
(9, 9) convoluzione con riempimento
Dal punto di vista di PyTorch
Per facilità di illustrazione ho scelto di spiegare i processi utilizzando il codice Vanilla nella sezione precedente. Lo stesso processo può essere replicato in PyTorch, tenendo presente tuttavia che l'immagine risultante molto probabilmente subirà una trasformazione minima o nulla poiché PyTorch inizializzerà casualmente un filtro che non è progettato per uno scopo specifico.
Per dimostrarlo, modifichiamo la funzione check_convolution() definita in una delle sezioni precedenti sopra...
def check_convolution(image_path, filter=(3,3), padding=0):
"""
This function performs convolution on an image and
returns the size of both the original and convolved image
"""
image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
image = torch.from_numpy(image).float()
# adding channel as typical to images (1 channel = grayscale)
image = image.view(1, image.shape[0], image.shape[1])
# perfoming convolution
with torch.no_grad():
conv_image = nn.Conv2d(1, 1, filter, padding=padding)(image)
print(f'original image size: {image.shape}')
print(f'image size after convolution: {conv_image.shape}')
pass
La funzione esegue la convoluzione utilizzando la classe di convoluzione PyTorch predefinita
Si noti che nella funzione ho utilizzato la classe di convoluzione 2D PyTorch predefinita e il parametro di riempimento della funzione viene fornito direttamente alla classe di convoluzione. Ora proviamo diversi filtri e vediamo quali sono le dimensioni dell'immagine risultante...
check_convolution('image.jpg', filter=(3, 3))
(3, 3) convoluzione senza imbottitura
produzione: dimensione dell'immagine originale: torcia. Dimensione (1, 375, 500) dimensione dell'immagine dopo la convoluzione: torch.Size(1, 373, 498)
check_convolution('image.jpg', filter=(3, 3), padding=1)
(3, 3) convoluzione con uno strato di riempimento.-
produzione: dimensione dell'immagine originale: torcia. Dimensione (1, 375, 500) dimensione dell'immagine dopo la convoluzione: torch.Size(1, 375, 500)
check_convolution('image.jpg', filter=(5, 5))
(5, 5) convoluzione senza imbottitura-
produzione: dimensione dell'immagine originale: torcia. Dimensione (1, 375, 500) dimensione dell'immagine dopo la convoluzione: torch.Size(1, 371, 496)
check_convolution('image.jpg', filter=(5, 5), padding=2)
(5, 5) convoluzione con 2 strati di imbottitura-
produzione: dimensione dell'immagine originale: torcia. Dimensione (1, 375, 500) dimensione dell'immagine dopo la convoluzione: torch.Size(1, 375, 500)
Come evidente negli esempi precedenti, quando la convoluzione viene eseguita senza riempimento, l'immagine risultante è di dimensioni ridotte. Tuttavia, quando la convoluzione viene eseguita con la quantità corretta di strati di riempimento, l'immagine risultante ha dimensioni uguali all'immagine originale.
Osservazioni finali
In questo articolo abbiamo potuto constatare che il processo di convoluzione comporta effettivamente una perdita di pixel. Siamo stati anche in grado di dimostrare che l'aggiunta preventiva di pixel a un'immagine, in un processo chiamato riempimento, prima della convoluzione garantisce che l'immagine mantenga la sua dimensione originale dopo la convoluzione.