Segmentazione dell’immagine una guida approfondita

Segmentazione dell'immagine una guida completa

Come puoi far sì che un computer distingua tra differenti tipi di oggetti in un’immagine? Una guida passo-passo.

Un'immagine di un gatto di fronte a una recinzione bianca a doghe. Da DALL·E 3.

Tabella dei contenuti

  1. Introduzione, Motivazione
  2. Estrazione dei dati
  3. Visualizzazione delle immagini
  4. Costruzione di un modello U-Net semplice
  5. Metriche e funzione di perdita
  6. Costruzione del modello U-Net completo
  7. Sommario
  8. Riferimenti

Link rilevanti

Introduzione, Motivazione

Segmentazione delle immagini si riferisce alla capacità dei computer (o più precisamente dei modelli memorizzati sui computer) di prendere un’immagine e assegnare ogni pixel dell’immagine a una categoria corrispondente. Ad esempio, è possibile eseguire l’immagine di un gatto di fronte a una recinzione bianca mostrata sopra attraverso un segmentatore di immagini e ottenere l’immagine segmentata qui sotto:

L'immagine del gatto, segmentata in pixel 'gatto' e pixel 'sfondo'. Immagine modificata da DALL·E 3.

In questo esempio, ho segmentato l’immagine manualmente. Questa è un’operazione tediosa e vorremmo automatizzarla. In questa guida ti guiderò nel processo di addestramento di un algoritmo per effettuare la segmentazione delle immagini. Molte guide su internet e nei libri di testo sono utili fino a un certo punto, ma tutte peccano nei dettagli di implementazione. Qui cercherò di chiarire il più possibile, per aiutarti a risparmiare tempo nella tua implementazione della segmentazione delle immagini sui tuoi dataset.

Innanzitutto, poniamo il nostro compito nel contesto più ampio dell’apprendimento automatico. La definizione di apprendimento automatico è evidente: stiamo insegnando alle macchine come imparare a risolvere problemi che vorremmo automatizzare. Ci sono molti problemi che gli esseri umani vorrebbero automatizzare; in questo articolo ci concentriamo su un sottoinsieme di problemi nella visione artificiale. La visione artificiale cerca di insegnare a un computer come vedere. È facile dare a un bambino di sei anni un’immagine di un gatto di fronte a una recinzione bianca e chiedergli di segmentare l’immagine in pixel “gatto” e pixel “sfondo” (dopo aver spiegato al bambino confuso cosa significa “segmentare”, ovviamente). Eppure, per decenni i computer hanno lottato duramente con questo problema.

Perché i computer faticano a fare ciò che un bambino di sei anni può fare? Possiamo immedesimarci nel computer pensando a come si impara a leggere in braille. Immagina di ricevere un saggio scritto in braille e ipotizza che tu non abbia conoscenza di come leggerlo. Come procederesti? Cosa avresti bisogno per decifrare il braille in inglese?

Un breve passaggio scritto in braille. Da Unsplash.

Ciò di cui hai bisogno è un metodo per trasformare questo input in un output che sia leggibile per te. In matematica chiamiamo questo una mappatura. Diciamo che vogliamo imparare una funzione f(x) che mappa il nostro input x, che è illeggibile, in un output y che è leggibile.

Con molti mesi di pratica e un buon insegnante, chiunque può imparare la mappatura necessaria dal braille all’inglese. Per analogia, un computer che elabora un’immagine è un po’ come qualcuno che si trova di fronte al braille per la prima volta; sembra un insieme di sciocchezze. Il computer deve imparare la mappatura necessaria f(x) per trasformare un insieme di numeri corrispondenti ai pixel in qualcosa che può usare per segmentare l’immagine. E sfortunatamente il modello del computer non ha migliaia di anni di evoluzione, biologia e anni di esperienza nel vedere il mondo; esso essenzialmente “nasce” quando si avvia il programma. Questo è ciò che speriamo di insegnare al nostro modello nella visione artificiale.

Perché vorremmo condurre la segmentazione delle immagini in primo luogo? Uno dei casi d’uso più evidenti è Zoom. Molte persone preferiscono utilizzare sfondi virtuali durante le videoconferenze per evitare che i loro colleghi vedano il cane che fa capriole in salotto. La segmentazione delle immagini è cruciale per questa attività. Un altro potente caso d’uso è l’imaging medico. Durante l’esecuzione di scansioni TC degli organi dei pazienti, potrebbe essere utile disporre di un algoritmo che segmenta automaticamente gli organi nelle immagini, in modo che i professionisti medici possano determinare cose come lesioni, presenza di tumori, ecc. Ecco un ottimo esempio di una competizione Kaggle incentrata su questo compito.

Esistono diverse varianti della segmentazione delle immagini, che vanno da semplici a complesse. In questo articolo tratteremo il tipo più semplice di segmentazione delle immagini: la segmentazione binaria. Ciò significa che ci saranno solo due diverse classi di oggetti, ad esempio “gatto” e “sfondo”. Nient’altro, niente di meno.

Nota che il codice che presento qui è stato leggermente riorganizzato e modificato per maggiore chiarezza. Per eseguire del codice funzionante, puoi consultare i link al codice in alto nell’articolo. Utilizzeremo il dataset Carvana Image Masking Challenge di Kaggle. Dovrai registrarti per questa sfida per ottenere l’accesso al dataset e inserire la tua chiave API di Kaggle nel notebook di Colab per farlo funzionare (se non vuoi utilizzare il notebook di Kaggle). Consulta questo post di discussione per i dettagli su come fare.

Un’altra cosa; per quanto mi piacerebbe approfondire ogni idea di questo codice, presumo che tu abbia una conoscenza pratica delle reti neurali convoluzionali, dei livelli di max pooling, dei livelli densamente connessi, dei livelli di dropout e dei connettori residui. Sfortunatamente, discutere a lungo di questi concetti richiederebbe un nuovo articolo, ed è al di fuori del campo di questo, in cui ci concentriamo sugli aspetti pratici dell’implementazione.

Estrarre Dati

I dati rilevanti per questo articolo saranno contenuti nelle seguenti cartelle:

  • train_hq.zip: Cartella contenente immagini ad alta qualità per l’addestramento delle auto
  • test_hq.zip: Cartella contenente immagini di test ad alta qualità delle auto
  • train_masks.zip: Cartella contenente maschere per l’insieme di addestramento

Nel contesto della segmentazione delle immagini, una maschera è l’immagine segmentata. Stiamo cercando di far imparare al nostro modello come mappare un’immagine di input a una maschera di segmentazione in output. Di solito si assume che la vera maschera (anche chiamata ground truth) sia disegnata a mano da un esperto umano.

Un esempio di un'immagine insieme alla sua vera maschera corrispondente, disegnata a mano da un esperto umano. Dal dataset Carvana Image Masking Challenge.

Il tuo primo passo sarà decomprimere le cartelle dalla tua sorgente /kaggle/input:

def getZippedFilePaths():    zip_file_names = []    for dirname, _, filenames in os.walk('/kaggle/input'):        for filename in filenames:            if filename.split('.')[-1] == 'zip':                zip_file_names.append((os.path.join(dirname, filename)))    return zip_file_nameszip_file_names = getZippedFilePaths()items_to_remove = ['/kaggle/input/carvana-image-masking-challenge/train.zip',                    '/kaggle/input/carvana-image-masking-challenge/test.zip']     zip_file_names = [item for item in zip_file_names if item not in items_to_remove]for zip_file_path in zip_file_names:    with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:        zip_ref.extractall()

Codesto codice ottiene i percorsi dei file per tutti i file .zip nell’input e li estrae nella tua directory /kaggle/output. Nota che di proposito non estraggo le foto non di alta qualità; il repository di Kaggle può contenere solo 20 GB di dati, e questo passaggio è necessario per evitare di superare questo limite.

Visualizzazione delle Immagini

Il primo passo nella maggior parte dei problemi di visione artificiale è ispezionare il tuo dataset. Con cosa stiamo esattamente lavorando? Prima abbiamo bisogno di assemblare le nostre immagini in dataset organizzati per la visualizzazione. (Questa guida utilizzerà TensorFlow; la conversione in PyTorch non dovrebbe essere troppo difficile.)

# Aggiunta di tutti i percorsi dei file a una lista ordinatetrain_hq_dir = '/kaggle/working/train_hq/'train_masks_dir = '/kaggle/working/train_masks/'test_hq_dir = '/kaggle/working/test_hq/'X_train_id = sorted([os.path.join(train_hq_dir, filename) for filename in os.listdir(train_hq_dir)], key=lambda x: x.split('/')[-1].split('.')[0])y_train = sorted([os.path.join(train_masks_dir, filename) for filename in os.listdir(train_masks_dir)], key=lambda x: x.split('/')[-1].split('.')[0])X_test_id = sorted([os.path.join(test_hq_dir, filename) for filename in os.listdir(test_hq_dir)], key=lambda x: x.split('/')[-1].split('.')[0])X_train_id = X_train_id[:1000]y_train = y_train[:1000]X_train, X_val, y_train, y_val = train_test_split(X_train_id, y_train, test_size=0.2, random_state=42)# Creazione di oggetti Dataset dalla lista dei percorsi dei fileX_train = tf.data.Dataset.from_tensor_slices(X_train)y_train = tf.data.Dataset.from_tensor_slices(y_train)X_val = tf.data.Dataset.from_tensor_slices(X_val)y_val = tf.data.Dataset.from_tensor_slices(y_val)X_test = tf.data.Dataset.from_tensor_slices(X_test_id)img_height = 96img_width = 128num_channels = 3img_size = (img_height, img_width)# Applicazione preprocessingX_train = X_train.map(preprocess_image)y_train = y_train.map(preprocess_target)X_val = X_val.map(preprocess_image)y_val = y_val.map(preprocess_target)X_test = X_test.map(preprocess_image)# Aggiunta etichette agli oggetti dataframe (one-hot encoding)train_dataset = tf.data.Dataset.zip((X_train, y_train))val_dataset = tf.data.Dataset.zip((X_val, y_val))# Applicazione dimensione batch al datasetBATCH_SIZE = 32batched_train_dataset = train_dataset.batch(BATCH_SIZE)batched_val_dataset = val_dataset.batch(BATCH_SIZE)batched_test_dataset = X_test.batch(BATCH_SIZE)# Aggiunta autotune per pre-fetchingAUTOTUNE = tf.data.experimental.AUTOTUNEbatched_train_dataset = batched_train_dataset.prefetch(buffer_size=AUTOTUNE)batched_val_dataset = batched_val_dataset.prefetch(buffer_size=AUTOTUNE)batched_test_dataset = batched_test_dataset.prefetch(buffer_size=AUTOTUNE)

Analizziamo questo passaggio nel dettaglio:

  • Prima di tutto creiamo una lista ordinata di tutti i percorsi dei file per tutte le immagini nel set di addestramento, nel set di test e nelle maschere di verità fondamentale. Nota che questi non sono ancora immagini; finora abbiamo solo esaminato i percorsi dei file delle immagini.
  • Poi prendiamo solo i primi 1000 percorsi dei file delle immagini / maschere nel dataset Carvana. Questo è fatto per ridurre il carico computazionale e velocizzare l’addestramento. Se hai accesso a più potenti GPU (fortunato te!) sentiti libero di usare tutte le immagini per prestazioni ancora migliori. Creiamo anche uno split di addestramento / convalida dell’80/20. Più dati (immagini) includi, maggiormente deve pendere questo split a favore del set di addestramento. Non è raro vedere split del 98/1/1 per addestramento / convalida / test quando si lavora con dataset molto grandi. Più dati nel set di addestramento, migliore sarà il tuo modello in generale.
  • Creiamo quindi oggetti TensorFlow (TF) Dataset utilizzando il metodo tf.data.Dataset.from_tensor_slices(). Utilizzare un oggetto Dataset è un metodo comune per gestire set di addestramento, convalida e test, invece di mantenerli come array Numpy. Nella mia esperienza, la pre-elaborazione dei dati è molto più veloce e semplice quando si utilizzano oggetti DataSet. Vedi questo link per la documentazione.
  • In seguito specificiamo l’altezza, la larghezza e il numero di canali delle nostre immagini di input. Le immagini effettive in alta qualità sono molto più grandi di 96 pixel per 128 pixel; questo downsampling delle nostre immagini viene fatto per ridurre il carico computazionale (immagini più grandi richiedono più tempo per l’addestramento). Se hai le risorse necessarie (GPU), non consiglio il downsampling.
  • Successivamente utilizziamo la funzione .map() degli oggetti Dataset per pre-elaborare le nostre immagini. Questo converte i percorsi dei file in immagini e fa la pre-elaborazione appropriata. Ne parleremo meglio tra un attimo.
  • Una volta pre-elaborate le immagini di addestramento grezze e le maschere di verità fondamentale, abbiamo bisogno di un modo per associare le immagini alle loro maschere. Per fare ciò, utilizziamo la funzione .zip() degli oggetti Dataset. Questa prende due liste di dati, unisce il primo elemento di ciascuna lista e li mette in una tupla. Fa lo stesso per il secondo elemento, il terzo e così via. Il risultato finale è una singola lista piena di tuple nella forma (immagine, maschera).
  • Utilizziamo quindi la funzione .batch() per creare batch di 32 immagini dalle nostre mille immagini. Il batching è una parte importante del flusso di lavoro dell’apprendimento automatico, in quanto ci consente di elaborare più immagini contemporaneamente anziché una alla volta. Questo velocizza l’addestramento.
  • Infine utilizziamo la funzione .prefetch(). Questo è un altro passaggio che contribuisce a velocizzare l’addestramento. Caricare e pre-elaborare i dati può essere un collo di bottiglia nei flussi di lavoro di addestramento. Ciò può portare a tempi di inattività di GPU o CPU, cosa che nessuno desidera. Mentre il tuo modello sta eseguendo la propagazione in avanti e all’indietro, la funzione .prefetch() può preparare il batch successivo. La variabile AUTOTUNE in TensorFlow calcola dinamicamente quanti batch pre-fetch in base alle risorse di sistema; questo è generalmente consigliato.

Diamo un’occhiata più da vicino al passaggio di pre-elaborazione:

def preprocess_image(file_path):    # Carica e decodifica l'immagine    img = tf.io.read_file(file_path)    # Puoi regolare i canali in base alle tue immagini (3 per RGB)    img = tf.image.decode_jpeg(img, channels=3) # Restituita come uint8    # Normalizza i valori dei pixel tra [0, 1]    img = tf.image.convert_image_dtype(img, tf.float32)    # Ridimensiona l'immagine alle dimensioni desiderate    img = tf.image.resize(img, [96, 128], method = 'nearest')    return imgdef preprocess_target(file_path):    # Carica e decodifica l'immagine    mask = tf.io.read_file(file_path)    # Normalizza tra 0 e 1 (solo due classi)    mask = tf.image.decode_image(mask, expand_animations=False, dtype=tf.float32)    # Ottieni solo un valore per il 3 ° canale    mask = tf.math.reduce_max(mask, axis=-1, keepdims=True)    # Ridimensiona l'immagine alle dimensioni desiderate    mask = tf.image.resize(mask, [96, 128], method = 'nearest')    return mask

Queste funzioni fanno quanto segue:

  • In primo luogo, convertiamo i percorsi dei file in un tensor di tipo di dati ‘stringa’ utilizzando tf.io.read_file(). Un tensor è una struttura dati speciale in TensorFlow simile a matrici multidimensionali in altre librerie matematiche, ma con proprietà e metodi speciali utili per l’apprendimento profondo. Per citare la documentazione di TensorFlow: tf.io.read_file() “non effettua alcun parsing, restituisce solo i contenuti così come sono”. Fondamentalmente questo significa che restituisce un file binario (1 e 0) nel tipo di dati stringa contenente le informazioni dell’immagine.
  • In secondo luogo, dobbiamo decodificare i dati binari. Per farlo, dobbiamo utilizzare il metodo appropriato in TensorFlow. Poiché i dati dell’immagine grezzi sono nel formato .jpeg, utilizziamo il metodo tf.image.decode_jpeg(). Poiché le maschere sono nel formato GIF, possiamo utilizzare tf.io.decode_gif(), o utilizzare il metodo più generale tf.image.decode_image(), che può gestire qualsiasi tipo di file. La tua scelta è davvero irrilevante. Impostiamo expand_animations=False perché non sono davvero animazioni, sono solo immagini.
  • Poi usiamo convert_image_dtype() per convertire i nostri dati di immagine in float32. Questo viene fatto solo per le immagini, non per la maschera, poiché la maschera è già stata decodificata in float32. Ci sono due tipi di dati comuni utilizzati nel processing delle immagini: float32 e uint8. Float32 indica un numero in virgola mobile (decimale) che occupa 32 bit nella memoria del computer. Sono firmati (il numero può essere negativo) e possono variare in valore da 0 a 2³² = 4294967296, anche se per convenzione nel processing delle immagini normalizziamo questi valori tra 0 e 1, dove 1 è il massimo di un colore. Uint8 indica un intero senza segno (positivo) che va da 0 a 255 e occupa solo 8 bit di memoria. Ad esempio, possiamo rappresentare il colore arancione bruciato come (Rosso: 204, Verde: 85, Blu: 0) per uint8 o (Rosso: 0,8, Verde: 0,33, Blu: 0) per float32. Float32 è di solito la scelta migliore, poiché offre una maggiore precisione ed è già normalizzato, il che aiuta a migliorare l’addestramento. Tuttavia, uint8 risparmia memoria, e questo può essere migliore a seconda delle tue limitazioni di memoria. Utilizzando float32 in convert_image_dtype si normalizzano automaticamente i valori.
  • Nella segmentazione binaria, ci aspettiamo che le nostre maschere abbiano forma (batch, altezza, larghezza, canali), con canali = 1. In altre parole, vogliamo che una classe (auto) sia rappresentata dal numero 1 e l’altra classe (sfondo) sia rappresentata dal numero 0. Non c’è motivo per cui il numero di canali debba essere 3, come per le immagini RGB. Purtroppo, dopo la decodifica viene fornito con tre canali, con il numero di classe ripetuto tre volte. Per risolvere questo, utilizziamo tf.math.reduce_max(mask, axis=-1, keepdims=True) per prendere il massimo dei valori nei tre canali ed eliminare il resto. Quindi un valore dei canali di (1,1,1) viene ridotto solo a (1) e un valore dei canali di (0,0,0) viene ridotto a (0).
  • Infine ridimensioniamo le immagini/maschere alle dimensioni desiderate (piccole). Nota che le immagini che ho mostrato in precedenza dell’auto con la maschera di verità fondamentale sembrano sfocate; questo dimensionamento ridotto è stato fatto appositamente per ridurre il carico computazionale e consentire un addestramento relativamente veloce. Utilizzare method=’nearest’ come valore predefinito
    Il colore arancione bruciato può essere rappresentato nel formato float32 o uint8. Immagine dell'autore.

    Una volta che abbiamo organizzato i nostri set di dati, ora possiamo visualizzare le nostre immagini:

    # Visualizza le immagini e le etichette associatefor immagini, maschere in batched_val_dataset.take(1):    numero_auto = 0    for immagine_slot in range(16):        ax = plt.subplot(4, 4, immagine_slot + 1)        if immagine_slot % 2 == 0:            plt.imshow((immagini[numero_auto]))             nome_classe = 'Immagine'        else:            plt.imshow(maschere[numero_auto], cmap = 'gray')            plt.colorbar()            nome_classe = 'Maschera'            numero_auto += 1                    plt.title(nome_classe)        plt.axis("off")
    Immagini delle nostre auto abbinati alle relative maschere.

    Qui stiamo utilizzando il metodo .take() per visualizzare il primo batch di dati nel nostro batched_val_dataset. Dal momento che stiamo facendo una segmentazione binaria, vogliamo che la nostra maschera contenga solo due valori: 0 e 1. La traccia dei colori sulla maschera conferma che abbiamo la configurazione corretta. Si noti che abbiamo aggiunto l’argomento cmap = ‘gray’ al comando imshow() della maschera per far sapere a plt che vogliamo che queste immagini vengano presentate in scala di grigi.

    Creazione di un modello U-Net semplice

    In una lettera datata 5 febbraio 1675 al suo rivale Robert Hooke, Isaac Newton ha affermato:

    “Se ho visto più lontano, è perché mi sono sollevato sulle spalle dei giganti.”

    Nella stessa linea, ci appoggeremo ai precedenti ricercatori di intelligenza artificiale che hanno scoperto quali architetture funzionano meglio per la segmentazione delle immagini. Non è una cattiva idea sperimentare con architetture proprie; tuttavia, i ricercatori che ci hanno preceduto hanno esplorato molte strade senza uscita per scoprire i modelli che funzionano. Queste architetture non sono necessariamente il risultato definitivo, poiché la ricerca è ancora in corso e potrebbe ancora essere trovata una migliore architettura.

    Visualizzazione del U-Net, descritto in [1]. Immagine dell'autore.

    Una delle architetture più conosciute è chiamata U-Net, così chiamata perché le porzioni di downsampling e upsampling della rete possono essere visualizzate come una U (vedi sotto). In un articolo intitolato U-Net: Reti convoluzionali per la segmentazione di immagini biomediche di Ronneberger, Fisher e Brox [1], gli autori descrivono come creare una rete convoluzionale completamente convoluzionale (FCN) che funziona in modo efficace per la segmentazione delle immagini. Completamente convoluzionale significa che non ci sono strati densamente collegati; tutti gli strati sono convoluzionali.

    Ci sono alcune cose da notare:

    • La rete è composta da una serie di blocchi ripetuti di due strati convoluzionali, con padding = ‘same’ e stride = 1 in modo che le uscite delle convoluzioni non vengano ridimensionate all’interno del blocco.
    • Ogni blocco è seguito da uno strato di max pooling, che riduce a metà la larghezza e l’altezza della mappa delle caratteristiche.
    • Il blocco successivo raddoppia quindi il numero di filtri. E il modello continua. Questo modello di riduzione dello spazio delle caratteristiche mentre si aumenta il numero di filtri dovrebbe risultare familiare se hai studiato le CNN. Questo completa ciò che gli autori chiamano “percorso contrattivo”.
    • Lo strato “botto” è nella parte inferiore della ‘U’. Questo strato cattura le caratteristiche altamente astratte (linee, curve, finestre, porte, ecc.) ma con una risoluzione spaziale significativamente ridotta.
    • Successivamente inizia ciò che chiamano “percorso espansivo”. In breve, questo riscrive le contrazioni, con ogni blocco che consiste di nuovo in due strati convoluzionali. Ogni blocco è seguito da uno strato di upsampling, che in TensorFlow chiamiamo Conv2DTranspose. Questo prende una mappa delle caratteristiche più piccola e raddoppia l’altezza e la larghezza.
    • Il blocco successivo quindi dimezza il numero di filtri. Ripetere il processo finché non si ottiene la stessa altezza e larghezza delle immagini di partenza. Infine, finire con uno strato di convoluzione 1×1 per ridurre il numero di canali a 1. Vogliamo finire con un solo canale perché questa è una segmentazione binaria, quindi desideriamo un singolo filtro in cui i valori dei pixel corrispondano alle nostre due classi. Utilizziamo una attivazione sigmoide per comprimere i valori dei pixel tra 0 e 1.
    • Nell’architettura U-Net ci sono anche connessioni di salto, che consentono alla rete di mantenere informazioni spaziali dettagliate anche dopo il downsampling e poi il upsampling. Normalmente, in questo processo si perde molte informazioni. Passando le informazioni da un blocco contrattivo a un blocco espansivo corrispondente, possiamo preservare queste informazioni spaziali. C’è una bella simmetria nell’architettura.

    Cominceremo facendo una versione semplice dell’U-Net. Sarà un FCN, ma senza connessioni residuali e senza livelli di max pooling.

    data_augmentation = tf.keras.Sequential([        tfl.RandomFlip(mode="horizontal", seed=42),        tfl.RandomRotation(factor=0.01, seed=42),        tfl.RandomContrast(factor=0.2, seed=42)])def get_model(img_size):    inputs = Input(shape=img_size + (3,))    x = data_augmentation(inputs)        # Percorso di contrazione    x = tfl.Conv2D(64, 3, strides=2, activation="relu", padding="same", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(64, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(128, 3, strides=2, activation="relu", padding="same", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(128, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(256, 3, strides=2, padding="same", activation="relu", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(256, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)        # Percorso di espansione    x = tfl.Conv2DTranspose(256, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)    x = tfl.Conv2DTranspose(256, 3, activation="relu", padding="same", kernel_initializer='he_normal', strides=2)(x)    x = tfl.Conv2DTranspose(128, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)    x = tfl.Conv2DTranspose(128, 3, activation="relu", padding="same", kernel_initializer='he_normal', strides=2)(x)    x = tfl.Conv2DTranspose(64, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)    x = tfl.Conv2DTranspose(64, 3, activation="relu", padding="same", kernel_initializer='he_normal', strides=2)(x)    outputs = tfl.Conv2D(1, 3, activation="sigmoid", padding="same")(x)    model = keras.Model(inputs, outputs)         return modelcustom_model = get_model(img_size=img_size)

    Qui abbiamo la stessa struttura di base dell’U-Net, con un percorso di contrazione e un percorso di espansione. Una cosa interessante da notare è che anziché utilizzare un livello di max pooling per dimezzare lo spazio delle caratteristiche, usiamo un livello convoluzionale con stride=2. Secondo Chollet [2], questo dimezza lo spazio delle caratteristiche preservando più informazioni spaziali rispetto ai livelli di max pooling. Afferma che ogni volta che l’informazione di posizione è importante (come nella segmentazione delle immagini) è una buona idea evitare i livelli distruttivi di max pooling e continuare a utilizzare convoluzioni con stride anziché (questo è curioso, perché la famosa architettura U-Net utilizza max pooling). Osservare anche che stiamo applicando un qualche tipo di data augmentation per aiutare il modello a generalizzare su esempi non visti in precedenza.

    Alcuni dettagli importanti: impostare kernel_initializer su ‘he_normal’ per le attivazioni ReLU fa una differenza sorprendentemente grande in termini di stabilità dell’addestramento. Inizialmente ho sottovalutato il potere dell’inizializzazione del kernel. Piuttosto che inizializzare i pesi in modo casuale, l’inizializzazione he_normalization inizializza i pesi con una media di 0 e una deviazione standard pari alla radice quadrata di (2 / # di unità di input al livello). Nel caso delle CNN, il numero di unità di input si riferisce al numero di canali nelle mappe di caratteristiche del livello precedente. Si è scoperto che questo porta a una convergenza più rapida, mitiga i gradienti che svaniscono e migliora l’apprendimento. Vedi riferimento [3] per ulteriori dettagli.

    Metriche e Funzione di Loss

    Esistono diverse metriche e funzioni di loss comuni che è possibile utilizzare per la segmentazione binaria. Qui utilizzeremo il coefficiente di Dice come metrica e la corrispondente loss di Dice per l’addestramento, poiché è ciò che richiede la competizione.

    Cominciamo con un’occhiata alla matematica alla base del coefficiente di Dice:

    The dice coefficient, in the general form.

    Il coefficiente di dice è definito come l’intersezione tra due insiemi (X e Y), diviso per la somma di ciascun insieme, moltiplicato per 2. Il coefficiente di dice si troverà tra 0 (se gli insiemi non hanno intersezione) e 1 (se gli insiemi si sovrappongono perfettamente). Ora vediamo perché questo è un ottimo indice per la segmentazione delle immagini.

    Un esempio di due maschere sovrapposte l'una sull'altra. Arancione utilizzato per chiarezza. Immagine dell'autore.

    L’equazione sopra è una definizione generale del coefficiente di dice; quando lo applichiamo a grandezze vettoriali (invece di insiemi), usiamo una definizione più specifica:

    Il coefficiente di dice, nella forma vettoriale.

    Qui, stiamo iterando su ogni elemento (pixel) in ogni maschera. x rappresenta l’i-esimo pixel nella maschera predetta e y rappresenta il pixel corrispondente nella maschera di riferimento. In alto stiamo facendo il prodotto elemento per elemento, e in basso stiamo sommando su tutti gli elementi in ciascuna maschera indipendentemente. N rappresenta il numero totale di pixel (che dovrebbe essere lo stesso per entrambe le maschere predette e di riferimento). Ricorda che nelle nostre maschere, i numeri saranno tutti 0 o 1, quindi un pixel con un valore di 1 nella maschera di riferimento e un pixel corrispondente nella maschera predetta con un valore di 0 non contribuiranno al punteggio di dice, come previsto (1 x 0 = 0).

    La perdita di dice sarà semplicemente definita come 1 – Punteggio di Dice. Poiché il punteggio di dice si trova tra 0 e 1, la perdita di dice sarà anch’essa compresa tra 0 e 1. In effetti, la somma del punteggio di dice e della perdita di dice deve essere pari a 1. Sono inversamente correlati.

    Diamo un’occhiata a come ciò è implementato nel codice:

    from tensorflow.keras import backend as Kdef dice_coef(y_true, y_pred, smooth=10e-6):    y_true_f = K.flatten(y_true)    y_pred_f = K.flatten(y_pred)    intersection = K.sum(y_true_f * y_pred_f)    dice = (2. * intersection + smooth) / (K.sum(y_true_f) + K.sum(y_pred_f) + smooth)    return dicedef dice_loss(y_true, y_pred):    return 1 - dice_coef(y_true, y_pred)

    Qui stiamo appiattendo due maschere 4-D (batch, altezza, larghezza, canali = 1) in vettori 1-D, e calcolando i punteggi di dice per tutte le immagini nel batch. Nota che aggiungiamo un valore di smoothing sia al numeratore che al denominatore per evitare un problema di 0/0 se le due maschere non si sovrappongono.

    Infine, iniziamo l’addestramento. Stiamo utilizzando l’early stopping per prevenire l’overfitting e salvando il miglior modello.

    custom_model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.0001,                                                        epsilon=1e-06),                                                         loss=[dice_loss],                                                         metrics=[dice_coef])callbacks_list = [    keras.callbacks.EarlyStopping(        monitor="val_loss",        patience=2,    ),    keras.callbacks.ModelCheckpoint(        filepath="best-custom-model",        monitor="val_loss",        save_best_only=True,    )]history = custom_model.fit(batched_train_dataset, epochs=20,                    callbacks=callbacks_list,                    validation_data=batched_val_dataset)

    Possiamo determinare i risultati del nostro addestramento con il seguente codice:

    def display(display_list):    plt.figure(figsize=(15, 15))    title = ['Immagine di input', 'Maschera vera', 'Maschera prevista']    for i in range(len(display_list)):        plt.subplot(1, len(display_list), i+1)        plt.title(title[i])        plt.imshow(tf.keras.preprocessing.image.array_to_img(display_list[i]))        plt.axis('off')    plt.show()    def create_mask(pred_mask):    mask = pred_mask[..., -1] >= 0.5    pred_mask[..., -1] = tf.where(mask, 1, 0)    # Restituisci solo la prima maschera del batch    return pred_mask[0]def show_predictions(model, dataset=None, num=1):    """    Mostra la prima immagine di ciascuno dei num batch    """    if dataset:        for image, mask in dataset.take(num):            pred_mask = model.predict(image)            display([image[0], mask[0], create_mask(pred_mask)])    else:        display([sample_image, sample_mask,             create_mask(model.predict(sample_image[tf.newaxis, ...]))])custom_model = keras.models.load_model("/kaggle/working/best-custom-model", custom_objects={'dice_coef': dice_coef, 'dice_loss': dice_loss})show_predictions(model = custom_model, dataset = batched_train_dataset, num = 6)

    Dopo 10 epoche, arriviamo ad un punteggio di validazione massimo del DICE di 0.8788. Non terribile, ma non eccezionale. Su una GPU P100 ci sono voluti circa 20 minuti. Ecco un esempio di maschera per la nostra revisione:

    Confronto tra immagine di input, maschera reale e maschera prevista. Dell'autore.

    Evidenziando alcuni punti interessanti:

    • Notare che create_mask è la funzione che assegna valori dei pixel a 0 o 1. Un valore di pixel inferiore a 0.5 verrà tagliato a 0 e sarà assegnato alla categoria “sfondo”. Un valore ≥ 0.5 verrà aumentato a 1 e sarà assegnato alla categoria “auto”.
    • Perché le maschere sono uscite gialle e viola, invece che nere e bianche? Abbiamo utilizzato: tf.keras.preprocessing.image.array_to_img() per convertire l’output della maschera da tensor a PIL Image. Abbiamo quindi passato l’immagine a plt.imshow(). Dalla documentazione vediamo che la colormap predefinita per le immagini a un solo canale è “viridis” (le immagini RGB a 3 canali vengono visualizzate così come sono). La colormap viridis trasforma i valori bassi in un viola intenso e i valori alti in giallo. Questa colormap può apparentemente aiutare le persone con daltonismo a ottenere una visione accurata del colore in un’immagine. Avremmo potuto risolvere questo problema aggiungendo cmap=”grayscale” come argomento, ma avrebbe compromesso la nostra immagine di input. Per saperne di più a questo link.
    La colormap viridis, dai valori bassi (viola) ai valori alti (giallo). Dell'autore.

    Costruire l’U-Net completa

    Adesso passiamo ad utilizzare l’architettura completa dell’U-Net, con connessioni residue, layer di max pooling e l’inclusione di layer di dropout per la regolarizzazione. Notare il percorso di contrazione, il layer bottleneck e il percorso di espansione. I layer di dropout possono essere aggiunti nel percorso di contrazione, alla fine di ogni blocco.

    def conv_block(inputs=None, n_filters=64, dropout_prob=0, max_pooling=True):    conv = Conv2D(n_filters,                    3,                     activation='relu',                  padding='same',                  kernel_initializer='he_normal')(inputs)    conv = Conv2D(n_filters,                    3,                     activation='relu',                  padding='same',                  kernel_initializer='he_normal')(conv)    if dropout_prob > 0:        conv = Dropout(dropout_prob)(conv)    if max_pooling:        next_layer = MaxPooling2D(pool_size=(2, 2))(conv)    else:        next_layer = conv    skip_connection = conv    return next_layer, skip_connectiondef upsampling_block(expansive_input, contractive_input, n_filters=64):    up = Conv2DTranspose(        n_filters,            3,            strides=(2, 2),        padding='same',        kernel_initializer='he_normal')(expansive_input)    # Unire l'output precedente con l'input di contrazione    merge = concatenate([up, contractive_input], axis=3)    conv = Conv2D(n_filters,                     3,                       activation='relu',                  padding='same',                  kernel_initializer='he_normal')(merge)    conv = Conv2D(n_filters,                     3,                       activation='relu',                  padding='same',                  kernel_initializer='he_normal')(conv)    return convdef unet_model(input_size=(96, 128, 3), n_filters=64, n_classes=1):    inputs = Input(input_size)        inputs = data_augmentation(inputs)    # Percorso di contrazione (encoding)    cblock1 = conv_block(inputs, n_filters)    cblock2 = conv_block(cblock1[0], n_filters*2)    cblock3 = conv_block(cblock2[0], n_filters*4)    cblock4 = conv_block(cblock3[0], n_filters*8, dropout_prob=0.3)    # Layer bottleneck    cblock5 = conv_block(cblock4[0], n_filters*16, dropout_prob=0.3, max_pooling=False)        # Percorso di espansione (decoding)    ublock6 = upsampling_block(cblock5[0], cblock4[1],  n_filters*8)    ublock7 = upsampling_block(ublock6, cblock3[1],  n_filters*4)    ublock8 = upsampling_block(ublock7, cblock2[1],  n_filters*2)    ublock9 = upsampling_block(ublock8, cblock1[1],  n_filters)    conv9 = Conv2D(n_filters,                   3,                   activation='relu',                   padding='same',                   kernel_initializer='he_normal')(ublock9)    conv10 = Conv2D(n_classes, 1, padding='same', activation="sigmoid")(conv9)    model = tf.keras.Model(inputs=inputs, outputs=conv10)    return model

    Quindi prendiamo e compiliamo l’U-Net. Sto usando 64 filtri per il primo blocco conv. Questo è un iperparametro che si desidera regolare per ottenere risultati ottimali.

    unet = unet_model(input_size=(img_height, img_width, num_channels), n_filters=64, n_classes=1)unet.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.0001, epsilon=1e-06),             loss=[dice_loss],              metrics=[dice_coef])callbacks_list = [    keras.callbacks.EarlyStopping(        monitor="val_loss",        patience=2,    ),    keras.callbacks.ModelCheckpoint(        filepath="best-u_net-model",        monitor="val_loss",        save_best_only=True,    )]history = unet.fit(batched_train_dataset, epochs=20,                    callbacks=callbacks_list,                    validation_data=batched_val_dataset)

    Dopo 16 epoche, ottengo un punteggio di validazione di dice di 0.9416, molto migliore rispetto al semplice U-Net. Questo non dovrebbe essere troppo sorprendente; guardando il conteggio dei parametri abbiamo un aumento dell’ordine di grandezza dal semplice U-Net al completo U-Net. Su una GPU P100 questo mi ha richiesto circa 32 minuti. Ora diamo un’occhiata alle previsioni:

    unet = keras.models.load_model("/kaggle/working/best-u_net-model", custom_objects={'dice_coef': dice_coef, 'dice_loss': dice_loss})show_predictions(model = unet, dataset = batched_train_dataset, num = 6)
    Maschera predetta per l'U-Net completo. Molto meglio! By the author.

    Queste previsioni sono molto migliori. Una cosa da notare guardando più previsioni è che le antenne sporgenti dalle auto sono difficili per la rete. Dato che le immagini sono molto pixelate, non posso biasimare la rete per averle perse.

    Per migliorare le prestazioni, si potrebbero regolare gli iperparametri, tra cui:

    • Numero di blocchi di downsampling e upsampling
    • Numero di filtri
    • Risoluzione dell’immagine
    • Dimensione dell’insieme di addestramento
    • Funzione di perdita (forse combinando perdita del dado con perdita della cross-entropia binaria)
    • Modificare i parametri dell’ottimizzatore. La stabilità dell’addestramento sembra essere un problema per entrambi i modelli. Dalla documentazione dell’ottimizzatore Adam: “Il valore predefinito di 1e-7 per epsilon potrebbe non essere un buon valore predefinito in generale”. Aumentare epsilon di un ordine di grandezza o più potrebbe aiutare con la stabilità dell’addestramento.

    Possiamo già intravedere la strada per un punteggio eccellente nella sfida Carvana. Peccato che sia già finita!

    Riassunto

    Questo articolo è stato un’analisi approfondita sull’argomento della segmentazione delle immagini, in particolare della segmentazione binaria. Se prendete qualcosa, ricordate quanto segue:

    • Lo scopo della segmentazione delle immagini è trovare una mappatura dai valori dei pixel di input di un’immagine a numeri di output che il vostro modello può usare per assegnare classi a ciascun pixel.
    • Uno dei primi passi è organizzare le vostre immagini in oggetti TensorFlow Dataset e dare un’occhiata alle vostre immagini e alle relative maschere.
    • Non c’è bisogno di reinventare la ruota quando si tratta dell’architettura del modello: sappiamo per esperienza che un U-Net funziona bene.
    • Il dice score è una metrica comune che viene utilizzata per monitorare il successo delle previsioni del vostro modello. Possiamo anche ottenere la nostra funzione di perdita da questa.

    Il lavoro futuro potrebbe consistere nel convertire i layer di max pooling nell’architettura canonica U-Net in layer convoluzionali con stride.

    Migliori auguri per i vostri problemi di segmentazione delle immagini!

    Riferimenti

    [1] O. Ronneberger, P. Fischer e T. Brox, U-Net: Convolutional Networks for Biomedical Image Segmentation (2015), International Conference MICCAI 2015

    [2] F. Chollet, Deep Learning with Python (2021), Manning Publications Co.

    [3] K. He, X. Zhang, S. Ren, J. Sun, Scavando in profondità nei rettificatori: Superare le prestazioni umane nella classificazione di ImageNet (2015), International Conference on Computer Vision (ICCV)