L’algoritmo di Watershed è una tecnica di segmentazione delle immagini che mira a separare un’immagine in regioni o segmenti basati sulle informazioni di gradiente. Questo algoritmo è particolarmente utile in scenari in cui si desidera separare oggetti vicini o toccanti in un’immagine. L’approccio di Watershed simula l’immagine come un paesaggio topografico, dove i picchi rappresentano i massimi locali e le valli rappresentano le separazioni tra gli oggetti. L’algoritmo quindi riempie queste “vasche” con acqua, partendo dai minimi locali e unendo le vasche quando l’acqua proveniente da differenti vasche si incontra.
L’algoritmo di Watershed
L’algoritmo di Watershed è basato sulla concettualizzazione dell’immagine come una superficie topografica, dove l’intensità dei pixel rappresenta l’altezza. L’obiettivo è separare le regioni dell’immagine come se fossero bacini idrografici, dove l’acqua scorre dai punti più bassi ai punti più alti.
Formalmente possiamo suddividere l’algoritmo di Watershed in diversi step, ognuno dei quali richiede una base matematica.
1. Gradiente dell’Immagine:
Prima di applicare l’algoritmo di Watershed, calcoliamo il gradiente dell’immagine per identificare le regioni di transizione. Il gradiente dell’immagine, indicato con ( \nabla I ), può essere calcolato utilizzando operatori di derivata, come il gradiente di Sobel o il gradiente di Scharr.
Dove
2. Immagine delle Etichette:
Successivamente, calcoliamo i minimi locali dell’immagine del gradiente per ottenere i punti di partenza per l’algoritmo Watershed. Questi minimi locali indicano le “vasche” iniziali da cui inizieremo a riempire l’immagine con l’acqua.
3. Transformata di Distanza e Marcatori:
La trasformata di distanza dell’immagine delle etichette crea un’immagine in cui ogni pixel indica la distanza tra quel pixel e il punto di partenza più vicino.
Questi risultano essere i marcatori per l’algoritmo di Watershed.
4. Applicazione di Watershed:
L’algoritmo di Watershed applica l’acqua da questi marcatori, simulando il riempimento delle “vasche”. L’acqua converge nelle regioni di confine e l’algoritmo restituisce i bordi di queste regioni.
L’immagine risultante dopo l’applicazione di Watershed, con (M) marcatori, può essere ottenuta risolvendo l’equazione della trasformata di distanza modificata:
Dove
Questo processo simula il riempimento delle “vasche” dell’immagine, permettendo una segmentazione robusta delle regioni dell’immagine basata sui gradienti di intensità.
L’approccio di OpenCV con i Marker
Una cosa è il formalismo matematico ed una cosa è la pratica. Se applichiamo questo algoritmo ad una qualsiasi immagine, si rischerà di non ottenere i risultati sperati. Infatti otterremo un livello di segmentazione eccessivo rispetto a quello reale (fenomeno di sovra-segmentazione). Tali effetti sono dovuti spesso a delle irregolarità presenti nelle immagini o nel rumore di fondo dell’immagine.
Una soluzione a tale problema è quello di implementare l’algoritmo di Watershed basandosi su dei marker, che hanno la funzione di indicare quali saranno le valli da “unificare” e quali no. Quindi questo approccio non è totalmente automatico, ma richiede l’interazione di chi opera l’analisi.
Dal punto di vista pratico, questa operazione consiste nell’assegnare delle etichette nelle diverse parti dell’immagine. I soggetti da segmentare verranno etichettati come foreground (soggetti che dovranno essere segmentati) mentre le aree di sfondo o soggetti a noi non di interesse, verranno etichettati come background. Un modo comodo per farlo sarà quello di etichettare con 0 tutte le aree di background, mentre con una sequenza di numeri interi (1,2,3…), i diversi soggetti da segmentare.
Implementazione in Python con OpenCV
Per utilizzare l’algoritmo di Watershed con OpenCV in Python, è necessario seguire alcuni passaggi fondamentali. Partiamo da un’immagine semplice come la seguente. Scaricatela sul tuo PC e salvala come simple.jpg.
Innanzitutto, carichiamo l’immagine e convertiamola in scala di grigi.
import cv2
import numpy as np
from matplotlib import pyplot as plt
image = cv2.imread('simple.jpg')
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
plt.imshow(cv2.cvtColor(gray, cv2.COLOR_BGR2RGB))
plt.show()
Quando vogliamo rappresentare un’immagine prodotta da OpenCV dobbiamo sempre tenere presente che quest’ultimo legge le immagini in formato BGR (Blu, Verde, Rosso), mentre Matplotlib utilizza RGB (Rosso, Verde, Blu). Quindi è necessario convertire l’immagine utilizzando cv2.cvtColor
per garantire che i colori siano visualizzati correttamente. Eseguendo si ottiene la visualizzazione dell’immagine gray.
Adesso che abbiamo caricato l’immagine a colori e convertita in scala di grici, possiamo applicare dei filtri a gradienti per identificare le regioni di confine.
ret, thresh = cv2.threshold(gray,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
# noise removal
kernel = np.ones((3,3),np.uint8)
opening = cv2.morphologyEx(thresh,cv2.MORPH_OPEN,kernel, iterations = 2)
plt.imshow(cv2.cvtColor(opening, cv2.COLOR_BGR2RGB))
plt.show()
Iniziamo dalla prima linea: ret, thresh = cv2.threshold(gray,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
. Qui, si converte l’immagine in scala di grigi gray
in un’immagine binaria utilizzando la tecnica di binarizzazione di OTSU. In pratica, questa tecnica determina automaticamente il valore di soglia ottimale per la binarizzazione, separando i pixel dell’immagine in due classi. I pixel sopra la soglia diventano neri e quelli sotto diventano bianchi, creando così un’immagine binaria invertita.
Successivamente, viene definito un kernel 3×3 che sarà utilizzato per le operazioni morfologiche. Nel nostro caso, il kernel è semplicemente una matrice di valori 1. Dopo di che, viene applicata un’operazione di apertura morfologica all’immagine binarizzata. L’apertura è una combinazione di erosione (che riduce le dimensioni degli oggetti nell’immagine) seguita da una dilatazione (che allarga i bordi degli oggetti). Questo processo aiuta a rimuovere il rumore nell’immagine e a separare gli oggetti che potrebbero essere collegati tra loro.
Infine, il risultato viene mostrato nella finestra di visualizzazione.
Adesso elaboreremo l’immagine per suddividerla in regioni chiaramente identificabili che sono parte degli oggetti di interesse (foreground) e regioni sconosciute che richiedono ulteriori elaborazioni con l’algoritmo di Watershed per una segmentazione più precisa.
# Finding sure foreground area
dist_transform = cv2.distanceTransform(opening,cv2.DIST_L2,5)
ret, sure_fg = cv2.threshold(dist_transform,0.3*dist_transform.max(),255,0)
# Finding unknown region
sure_fg = np.uint8(sure_fg)
unknown = cv2.subtract(opening,sure_fg)
plt.imshow(cv2.cvtColor(unknown, cv2.COLOR_BGR2RGB))
plt.show()
Con la riga dist_transform = cv2.distanceTransform(opening,cv2.DIST_L2,5)
si calcola la trasformata di distanza sull’immagine dopo l’operazione di apertura. La trasformata di distanza attribuisce a ciascun pixel un valore che rappresenta la sua distanza dal pixel più vicino dell’oggetto nell’immagine. Questo è utile per determinare quali pixel sono più vicini agli oggetti rispetto agli altri.
Poi, ret, sure_fg = cv2.threshold(dist_transform,0.3*dist_transform.max(),255,0)
applica una sogliatura sull’immagine della trasformata di distanza per identificare le aree che sono sicuramente parte dell’oggetto di interesse (foreground). Il valore di soglia è calcolato come il 30% del valore massimo della trasformata di distanza. Questo processo consente di individuare le regioni che sono sicuramente parte dell’oggetto senza ambiguità.
Successivamente si converte l’immagine delle aree sicure del foreground con sure_fg = np.uint8(sure_fg)
in un tipo di dati uint8, che è il tipo di dato appropriato per le immagini in OpenCV. La riga seguenteunknown = cv2.subtract(opening,sure_fg)
identifica la regione sconosciuta dell’immagine sottraendo le aree sicure del foreground dall’immagine dopo l’operazione di apertura. Questo passaggio ci fornisce una stima delle aree dell’immagine che potrebbero essere parte dell’oggetto ma non sono state ancora etichettate come tali.
Adesso abbiamo tutti gli elementi per applicare l’algoritmo di Watershed vero e proprio, con il procedimento di etichettatura con i markers.
# Marker labelling
ret, markers = cv2.connectedComponents(sure_fg)
# Add one to all labels so that sure background is not 0, but 1
markers = markers+1
# Now, mark the region of unknown with zero
markers[unknown==255] = 0
markers = cv2.watershed(image,markers)
image[markers == -1] = [0,0,255]
# Visualizza i risultati
plt.imshow(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
plt.title('Watershed Segmentation')
plt.show()
Il Marker Labelling (Etichettatura dei marcatori) viene eseguito nella prima parte del codice:
ret, markers = cv2.connectedComponents(sure_fg)
: Qui vengono etichettati i diversi oggetti nell’immagine.cv2.connectedComponents
assegna un’etichetta unica a ciascun oggetto nell’immagine binariasure_fg
, che rappresenta l’area di primo piano sicura identificata precedentemente. Questo passaggio suddivide l’immagine in diverse regioni connesse, dove ogni regione ha un’etichetta diversa.markers = markers+1
: Si aggiunge uno a tutte le etichette per assicurarsi che lo sfondo sicuro non abbia un’etichetta di valore 0, in quanto ciò potrebbe causare problemi nell’algoritmo di Watershed.- Successivamente, vengono contrassegnate le regioni sconosciute, ovvero quelle parti dell’immagine in cui non siamo sicuri se appartengono allo sfondo o agli oggetti di interesse. Queste sono le aree che sono state identificate tramite la differenza tra l’apertura morfologica
opening
e le regioni di primo piano sicuresure_fg
. Queste aree sono state memorizzate nell’arrayunknown
. markers[unknown==255] = 0
: Qui viene contrassegnata l’area sconosciuta impostando a 0 le etichette corrispondenti nell’arraymarkers
.
Poi successivamente si effettua Watershed Transformation (Trasformazione Watershed):
markers = cv2.watershed(image,markers)
: Questo è il passaggio chiave dell’algoritmo di Watershed. Viene applicato l’algoritmo di Watershed all’immagine originaleimage
utilizzando i marcatori ottenuti dal passaggio precedente. L’algoritmo di Watershed divide l’immagine in regioni, utilizzando i marcatori per determinare i confini tra le regioni. Il risultato è un’immagine in cui gli oggetti sono delimitati da contorni.
image[markers == -1] = [0,0,255]
: Qui vengono colorate le regioni non definite o i bordi delle regioni identificati come “linea di watershed” con il colore rosso[0, 0, 255]
nell’immagine originaleimage
. Questo aiuta a visualizzare chiaramente i confini tra le regioni segmentate.
Infine, l’immagine risultante viene visualizzata utilizzando Matplotlib con il titolo “Watershed Segmentation” per evidenziare il processo di segmentazione.
Come possiamo vedere bene sopra, le regioni coperte dai cerchi blu sono state ben identificate, evidenziate nell’immagine con i contorni di color rosso.
Conclusione
Questo è solo un semplice esempio su come si possa applicare l’algoritmo di Watershed su di un semplice esempio. E’ chiaro che immagini più complesse richiedano dei procedimenti preliminari molto più elaborati, specifici di caso in caso. Ma l’obiettivo di questo articolo è quello di poter illustrare con un breve e semplice esempio come l’algoritmo di Watershed possa essere un valido strumento per la segmentazione delle immagini, specialmente quando si tratta di separare oggetti complessi e sovrapposti. Utilizzando Python insieme a OpenCV, è possibile implementare facilmente questo algoritmo e ottenere risultati significativi nella segmentazione delle immagini.