Immagini in Python

Immagine digitale

Oggi ci occupiamo di come vengono trattate le immagini dal computer. Un'immagine è una griglia di pixel, piccoli quadratini con associato un colore. Ad esempio, quando vedete scritto che un'immagine è 1024x768 questo vuol dire che ha una larghezza di 1024 pixel e un'altezza di 768 pixel.

Come vengono rappresentati i colori di ogni pixel? Nel caso più semplice, le immagini in bianco e nero, ogni pixel è semplicemente un numero, 0 o 1, a seconda del colore. L'immagine è quindi una matrice (ad esempio, 1024x768) di zeri e uni. Ammettendo numeri tra 0 e 255 possiamo costruire un'immagine in 'scala di grigi': il nero è 0, il bianco 255, e più alto sarà il numero inserito più il pixel sarà 'scuro'. In questo caso il valore indica l'intensità del colore rappresentato, ovvero il nero. Per introdurre i colori ci sono diversi formati: uno semplice è quello 'RGB' (Red Green Blue). In questo caso non avremo una matrice di numeri ma una matrice di tuple, dove il primo numero indica l'intensità del rosso, il secondo l'intensità del verde, e il terzo l'intensità del blu. Anche qui, ogni valore va da 0 a 255. La scelta di 255 come massimo non è casuale: contando da 0 abbiamo 256, ovvero 2^8, gradazioni possibili per ogni colore. Per il momento, ci limitiamo a considerare che le potenze di due sono spesso apprezzate dai computer.

Talvolta per indicare i valori si usa la notazione esadecimale, in cui le cifre non vanno da 0 a 9 ma da 0 a F (0,1,..,8,9,A,B,C,D,E,F) e la a vale 10, B vale 11 ecc. Ogni 'posizione' non vale 10 ma 16: se scrivo 21 normalmente intenderei 2x10 + 1 mentre in notazione esadecimale intendo 2x16 + 1. In questo modo ad esempio A4 vale 10x16 + 4, perchè A vale 10. Se non capite questa cosa non preoccupatevi: sappiate solo che se trovate un colore scritto come A5B3F2 vuol dire:

  • il rosso ha intensità A5 (10x16+5 = 156)
  • il verde ha intensità B3
  • il blu ha intensità F2 Se non riuscite a calcolare questi valori a mente potete metterli in un convertitore, o semplicemente stimare la loro grandezza pensando che la A sta sopra il 9. Notate che il numero più grande che possiamo ottenere è FF, che vale 15x16 + 15 = 255, esattamente come prima. Per gli stessi motivi avremo quindi sempre 256 gradazioni possibili per ogni colore.

La 'RGB' non è l'unica codifica possibile delle immagini, ma ci sono diverse varianti. A volte nelle immagini è presente un quarto valore, detto 'alpha', che indica la trasparenza del singolo pixel. Altri formati si basano su parametri come 'brillantezza' del colore o cose simili. Il concetto importante è però che ci stiamo occupando di una matrice di tuple, RxC (righe per colonne), o al massimo di una matrice di numeri. Questi numeri, essendo punti molto piccoli, ci danno quando visualizzati la sensazione di colori continui.

Python Imaging Library: Pillow

Iniziamo a vedere una libreria che ci permette di trattare le immagini. Si chiamava PIL (Python Imaging Library) ma ha recentemente cambiato il suo nome in Pillow:

In [ ]:
!pip3 install Pillow

Importiamo oltre a lei numpy e pyplot che ci serviranno però solo per mostrare le immagini all'interno del notebook. Pillow possiede infatti un tool che mostra le immagini in una nuova finestra, e che potete usare tranquillamente senza altre librerie sia in jupyter che dentro spyder.

In [46]:
from matplotlib.pyplot import imshow
import numpy as np
from PIL import Image

Vediamo ora come aprire un'immagine; come con tutti i file, è importante che l'immagine sia nella stessa cartella del notebook per poterla leggere facilmente.

In [47]:
im = Image.open('fiore.jpeg', 'r')
#im.show()
imshow(np.asarray(im))
Out[47]:
<matplotlib.image.AxesImage at 0x7f49b91adcd0>

Il comando im.show() commentato ci permetterebbe di aprire il visualizzatore di immagini di pillow. Usando imshow invece riusciamo a stampare il nostro fiore direttamente all'interno del notebook. Vediamo ora come ottenere le informazioni base sulla nostra immagine.

In [48]:
print(im.format, im.size, im.mode)
JPEG (298, 169) RGB

im.format indica il formato, in questo caso JPEG. im.size la dimensione (larghezza*altezza) della nostra immagine. im.mode, invece, la modalità con cui viene aperta l'immagine. RGB significa che ogni pixel è rappresentato da tre numeri, tra 0 e 255, che rappresentano l'intensità del rosso, del verde e del blu, come abbiamo visto prima.

Possiamo fare molto rapidamente semplici trasformazioni di immagini, dato che ci stiamo occupando di matrici. Molte di queste sono già contenute nella libreria Pillow.

In [51]:
out = im.transpose(Image.FLIP_TOP_BOTTOM)
#out.show()
imshow(np.asarray(out))
Out[51]:
<matplotlib.image.AxesImage at 0x7f49b8e42210>

Con il parametro FLIP_TOP_BOTTOM, come si poteva immaginare, ribaltiamo verticalmente l'immagine. Potete trovare altre trasformazioni simili al link https://pillow.readthedocs.io/en/stable/handbook/tutorial.html.

Filtri

Vediamo ora come applicare dei semplici filtri ad un'immagine. Per farlo ci servirà la subroutine ImageFilter.

In [52]:
from PIL import ImageFilter
out = im.filter(ImageFilter.BLUR)
#out.show()
imshow(np.asarray(out))
Out[52]:
<matplotlib.image.AxesImage at 0x7f49b8dc4310>

Con il comando im.filter applichiamo alla nostra immagine un filtro di tipo BLUR. Anche qui, diversi esempi si possono trovare sulla documentazione ufficiale: https://pillow.readthedocs.io/en/5.1.x/reference/ImageFilter.html.

Veniamo però ora alle potenzialità specifiche di questa libreria. Un primo esempio sono le cosiddette trasformazioni puntuali: con il comando im.point, a cui dobbiamo fornire una funzione, applichiamo tale funzione ad ogni punto della nostra immagine. Vediamo un semplice esempio.

In [53]:
out = im.point(lambda i: i * 1.5)
#out.show()
imshow(np.asarray(out))
Out[53]:
<matplotlib.image.AxesImage at 0x7f49b8d31710>

In questo modo abbiamo 'aumentato' l'intensità di colore di ogni pixel. Il risultato non è il massimo, ma come ci aspettavamo la foto è più luminosa dell'originale. Possiamo però sfruttare le trasformazioni puntuali anche per modificare il canale di un singolo colore. Per farlo ci serviamo della funzione im.split, che spezza l'immagine in diversi livelli. In questo caso, dato che la foto è in modalità RGB, riceveremo tre canali, uno che rappresenta la quantità di rosso, uno di verde, e uno di blu, in ogni punto. Ogni singolo canale può essere pensato come un'immagine che però contiene un solo valore, quindi visualizzabile come un'immagine in bianco e nero. Andando a visualizzare il canale ci rendiamo conto di quanto colore ci sia in ogni punto, per ogni colore. Per mostrare il canale in bianco e nero dobbiamo però fare attenzione a settare cmap="grey", per dire a matplotlib che usiamo il bianco e nero.

In [54]:
source = im.split()
R, G, B = 0, 1, 2
print(type(source[B]))
#source[R].show()
imshow(np.asarray(source[B]), cmap='gray', vmin=0, vmax=255)
<class 'PIL.Image.Image'>
Out[54]:
<matplotlib.image.AxesImage at 0x7f49b8ca20d0>

Fatto questo possiamo andare ad applicare una trasformazione puntuale ad un singolo colore, ad esempio il rosso. Rimettendo poi tutto insieme con image.merge otteniamo la foto iniziale, con il rosso accentuato.

In [55]:
source = im.split()
band = source[R].point(lambda i: i*1.5)
source[R].paste(band) #immagine, box, mask
out = Image.merge(im.mode, source)
imshow(out)
Out[55]:
<matplotlib.image.AxesImage at 0x7f49b8c87a90>

Threshold

Veniamo ora ad un semplice algoritmo di elaborazione immagini, che proveremo ad implementare a mano: l'algoritmo di threshold (o sogliatura). Questo algoritmo si basa sul mostrare solo i colori che superano una certa soglia. La sua applicazione classica è la 'riduzione' di immagini in bianco e nero (in questo caso letteralmente bianco e nero, non scala di grigi) senza far perdere l'idea generale del soggetto rappresentato. In seguito tratteremo anche quella versione. Un'applicazione però molto simile, e oggi più comune, è quella che consiste nell'isolare un singolo colore di spicco in un'immagine, rendendo grigio tutto il resto. Ovviamente tutti questi algoritmi si possono perfezionare, ottenendo poi i risultati che vedete nei veri programmi di elaborazione immagini. Per ora ne tratteremo solo una versione semplificata.

Iniziamo a vedere come funziona il comando im.load(). Data un'immagine, ci restituisce un punto di accesso ad ogni singolo pixel. Salvato l'oggetto che ci viene restituito da im.load, in questo caso nella variabile pixels, possiamo accedere al pixel in posizione (x,y) con il comando pixels[x,y], con i due indici in un'unica parentesi quadra come da sintassi standard di numpy.

In [63]:
im = Image.open('fiore.jpeg', 'r')
pixels = im.load()
print(pixels[0, 0])
print(im.size)
(176, 121, 41)
(298, 169)

I pixel ci vengono restituiti come una tupla di tre valori, rispettivamente rosso, verde e blu. Conoscendo la dimensione dell'immagine possiamo fare un ciclo che legga i pixel uno per uno, e controllare in questo modo l'intensità media e l'intensità massima del blu in ogni pixel.

In [58]:
blu_tot = 0
blu_max = 0
for i in range(im.size[0]):
    for j in range(im.size[1]):
        blu = pixels[i,j][2]
        blu_tot += blu
        blu_max = max(blu_max, blu)
blu_mean = int(blu_tot/(im.size[0]*im.size[1]))
print(f'Media blu: {blu_mean}, max: {blu_max}')
Media blu: 67, max: 255

A questo punto dobbiamo fissare una soglia: ciò che vogliamo è prendere solo i punti che sono 'più blu' della media. Andiamo quindi un po' ad occhio e proviamo con 140.

In [64]:
soglia = 140
for i in range(im.size[0]):
    for j in range(im.size[1]):
        blu = pixels[i,j][2]
        if blu < soglia:
            grigio = int(sum(pixels[i,j])/3)
            pixels[i,j] = (grigio, grigio, grigio)
imshow(im)
Out[64]:
<matplotlib.image.AxesImage at 0x7f49b8ad96d0>

Il risultato è abbastanza simile a quello che volevamo: una romanticissima foto con solo i fiori blu colorati, e lo sfondo in grigio. Ovviamente con tecniche più sofisticate si può arrivare a risultati migliori; con questo metodo però non possiamo andare tanto meglio modificando la soglia: se la abbassiamo, altre aree di luce, ad esempio in alto a destra, verranno colorate, se la abbassiamo invece taglieremo via un'altra parte dei nostri fori.

Veniamo ora all'algoritmo di sogliatura originale. Come accennato, data un'immagine vogliamo rappresentarla usando solo il bianco e il nero.

In [65]:
im = Image.open('paesaggio_neve.jpg', 'r')
pixels = im.load()
print(pixels[0, 0])
print(im.size)
imshow(im)
(2, 2, 12)
(1280, 865)
Out[65]:
<matplotlib.image.AxesImage at 0x7f49b8a46310>

L'algoritmo è molto simile a prima, con la differenza che questa volta per ogni punto prendiamo la media di tutti i colori, e poi calcoliamo la media su queste medie. Ovviamente, se prima il massimo che potevamo ottenere era 255 (massimo del blu in un pixel) ora è 765, ovvero un punto bianco.

In [67]:
col_tot = 0
col_max = 0
for i in range(im.size[0]):
    for j in range(im.size[1]):
        col_tot += sum(pixels[i,j])
        col_max = max(col_max, sum(pixels[i,j]))
col_mean = int(col_tot/(im.size[0]*im.size[1]))
print(f'Media colore: {col_mean}, max: {col_max}')
Media colore: 497, max: 765

Questa volta prendiamo semplicemente la media come soglia, e coloriamo di nero, ovvero (0,0,0), tutti i punti che stanno sotto la soglia, mentre di bianco, ovvero (255,255,255), tutti gli altri.

In [68]:
im = Image.open('paesaggio_neve.jpg', 'r')
pixels = im.load()
soglia = col_mean
for i in range(im.size[0]):
    for j in range(im.size[1]):
        color = sum(pixels[i,j])
        if color < soglia:
            pixels[i,j] = (0,0,0)
        else:
            pixels[i,j] = (255,255,255)
imshow(im)
im.show()

Come vedete, la seconda immagine rappresenta abbastanza bene la prima.

Analisi immagini: Skimage

Skimage è una libreria del pacchetto scipy per l'elaborazione e l'analisi delle immagini. Contiene algoritmi molto avanzati, di cui oggi vedremo solo alcuni esempi. Iniziamo ad importare alcuni pacchetti

In [69]:
from skimage import data, io, filters

Importando skimage.data abbiamo accesso, come con altre librerie, a immagini di esempio, sulle quali testare gli algoritmi. Vediamone subito uno.

In [70]:
image = data.coins()
io.imshow(image)
Out[70]:
<matplotlib.image.AxesImage at 0x7f49b8928f90>

Andiamo ad applicare a questa immagine il filtro di sobel, un algoritmo per la cosiddetta edge detection (identificazione dei bordi). È un algoritmo a basso costo, che su immagini come questa, con i bordi ben definiti, riesce a dare ottimi risultati:

In [71]:
edges = filters.sobel(image)
io.imshow(edges)
io.show()

Vediamo adesso un altro algoritmo che si usa in questi casi. Applichiamo prima un filtro gaussiano e poi sfruttiamo la funzione reconstruction per ottenere un'immagine che contenga solo lo sfondo. Poi sottraiamo questa nuova immagine a quella originale, ottenendo solo gli oggetti in primo piano. Ovviamente questi sono algoritmi avanzati, di cui non ci occuperemo direttamente. È comunque interessante vedere le potenzialità di questa libreria.

In [73]:
import matplotlib.pyplot as plt

from scipy.ndimage import gaussian_filter
from skimage import img_as_float
from skimage.morphology import reconstruction

Per far funzionare correttamente la sottrazione tra immagini dobbiamo prima convertirle in float.

In [75]:
image = img_as_float(data.coins())
image = gaussian_filter(image, 1)
io.imshow(image)
Out[75]:
<matplotlib.image.AxesImage at 0x7f49b8893990>

Dopo aver applicato il filtro gaussiano, separiamo le componenti che ci servono per la reconstruction. Per capire meglio come funzionano questi algoritmi, o anche solo per sapere come applicarli in una situazione specifica, esiste un'approfondita documentazione sul sito di skimage.

In [76]:
seed = np.copy(image)
seed[1:-1, 1:-1] = image.min()
mask = image
In [77]:
dilated = reconstruction(seed, mask, method='dilation')
In [78]:
io.imshow(dilated, cmap='gray')
Out[78]:
<matplotlib.image.AxesImage at 0x7f49b8802ad0>
In [79]:
io.imshow(image-dilated, cmap='gray')
Out[79]:
<matplotlib.image.AxesImage at 0x7f49b876fed0>

Da RGB ad HSV

Un altro modo di rappresentare le immagini, che non si basa sul valore di ogni colore come l'RGB, è il metodo HSV, ovvero dall'inglese Hue Saturation Value (tonalità, saturazione e valore). Come suggeriscono i nomi, il primo parametro indica la tonalità del colore (quindi il 'colore' in senso più stretto), il secondo la sua saturazione, e il terzo il suo valore (la sua intensità, nel modello di prima). Questa modellizzazione (o altre simili, come HSB) ha un grandissimo impiego nelle immagini a colori, perchè permette di ottenere informazioni molto interessanti. Vediamo ora come migliorare l'algoritmo di threshold introdotto prima.

Iniziamo ad importare da skimage.color la funzione che ci permette di convertire immagini rgb in hsv.

In [80]:
from skimage.color import rgb2hsv

Importiamo ora un'altra immagine di esempio, che ci servirà per testare l'algoritmo.

In [81]:
rgb_img = data.coffee()
io.imshow(rgb_img)
Out[81]:
<matplotlib.image.AxesImage at 0x7f49b86e9290>

Grazie alla funzione rgb2hsv, dividiamo l'immagine nelle sue tre componenti H, S e V. Come vedete ciò che otteniamo è una matrice tridimensionale, il cui ultimo parametro regola il canale (in pratica sono tre griglie sovrapposte, ma a differenza dei tre canali RGB non è immediato il modo di sommarle per ottenere l'immagine finale).

In [82]:
hsv_img = rgb2hsv(rgb_img)
hue_img = hsv_img[:, :, 0]
sat_img = hsv_img[:, :, 1]
value_img = hsv_img[:, :, 2]

Accediamo ai singoli canali tramite il terzo indice, prendendo tutta l'immagine nei primi due, e vediamo cosa otteniamo.

In [85]:
io.imshow(hue_img, cmap='hsv')
Out[85]:
<matplotlib.image.AxesImage at 0x7f49b85aba90>
In [87]:
io.imshow(sat_img, cmap='hsv')
Out[87]:
<matplotlib.image.AxesImage at 0x7f49b850e550>
In [88]:
io.imshow(value_img)
Out[88]:
<matplotlib.image.AxesImage at 0x7f49b847d1d0>

Come potete vedere, l'unico che assomiglia direttamente all'immagine originale è il terzo canale, quello del valore. Gli altri canali però contengono interessanti informazioni. Vediamo ad esempio, tramite il comando ravel (che ci mostra semplicemente tutti i valori contenuti in una matrice, in questo caso la matrice di tonalità) vediamo che la frequenza con cui compaiono le tonalità ha due picchi, ed è bassa in tutti gli altri punti. Questo ci suggerisce l'utilizzo di un algoritmo di sogliatura, separando l'immagine in base alla tonalità.

In [89]:
fig, (ax0) = plt.subplots(ncols=1, figsize=(8, 3))

ax0.hist(hue_img.ravel(), 512)
ax0.set_title("Frequenza delle tonalità")
ax0.axvline(x=0.04, color='r', linestyle='dashed', linewidth=2)
ax0.set_xbound(0, 0.12)

Il punto 0.04 separa i due picchi. Utilizzando l'operatore confronto sulla matrice che rappresenta le tonalità otteniamo una matrice di 1 (dove la tonalità è maggiore della soglia) e 0 (dove è minore). Possiamo vedere in bianco e nero l'ottimo risultato ottenuto con l'immagine sogliata.

In [45]:
hue_threshold = 0.04
binary_img = hue_img > hue_threshold
io.imshow(binary_img)
Out[45]:
<matplotlib.image.AxesImage at 0x7f49b910fd50>

Il modello HSV ha molte interessanti applicazioni. Vi lascio il link ad un esempio, preso direttamente dalla documentazione ufficiale, in cui questo modello viene utilizzato per colorare monocromaticamente immagini in scala di grigi. Sempre sulla documentazione ufficiale trovate numerosi altri esempi classici di applicazione di questa e altre tecniche.

In [90]:
fiore = io.imread('fiore.jpeg')
io.imshow(fiore)
Out[90]:
<matplotlib.image.AxesImage at 0x7f49b9990610>
In [91]:
hsv_fiore = rgb2hsv(fiore)
hue_fiore = hsv_fiore[:, :, 0]
sat_fiore = hsv_fiore[:, :, 1]
val_fiore = hsv_fiore[:, :, 2]
In [94]:
io.imshow(hue_fiore, cmap='hsv')
Out[94]:
<matplotlib.image.AxesImage at 0x7f49b8fe0f10>
In [108]:
io.imshow(val_fiore)
Out[108]:
<matplotlib.image.AxesImage at 0x7f49b88b7b90>
In [97]:
fig, (ax0) = plt.subplots(ncols=1, figsize=(8, 3))

ax0.hist(hue_fiore.ravel(), 512)
ax0.set_title("Frequenza delle tonalità")
#ax0.axvline(x=0.04, color='r', linestyle='dashed', linewidth=2)
#ax0.set_xbound(0.03, 0.2)
Out[97]:
Text(0.5, 1.0, 'Frequenza delle tonalità')
In [98]:
hue_threshold = 0.3
binary_img = hue_fiore > hue_threshold
io.imshow(binary_img)
Out[98]:
<matplotlib.image.AxesImage at 0x7f49b9ad0110>
In [104]:
print(hue_fiore[0,0])
print(hue_fiore.shape)
0.09876543209876543
(169, 298)
In [107]:
im = Image.open('fiore.jpeg', 'r')
pixels = im.load()
print(pixels[0, 0])
print(im.size)
soglia = 0.3

for i in range(im.size[0]):
    for j in range(im.size[1]):
        if hue_fiore[j,i] < soglia:
            col = int(sum(pixels[i,j])/3)
            pixels[i,j] = (col,col,col)

imshow(im)
(176, 121, 41)
(298, 169)
Out[107]:
<matplotlib.image.AxesImage at 0x7f49b840c510>