X
    Categories: blog

Creiamo un semplice OCR

Dopo un lungo periodo di pausa, rieccoci qua con un nuovo articolo dove andremo a mettere in pratica la teoria vista in precedenza sulle reti neurali ricorrenti.

In particolare andremo a scrivere il codice necessario a creare un semplice OCR, cioè uno strumento che ci consentirà di convertire in formato testuale una parola impressa su una immagine.

Cos’è un OCR

Un OCR (da Optical Character Recognition) è un sistema software che consente di estrarre il contenuto testuale presente all’interno di una immagine.

I problemi che un OCR deve affrontare per arrivare al suo obiettivo sono numerosi: migliorare per quanto possibile la qualità dell’immagine gestendo rumori, variazioni di luminosità, contrasto o colore, individuare le scritte presenti, gestire eventuali rotazioni o distorsioni delle scritte, decodificare le immagini delle scritte in testo, ricostruire il testo completo. Per questo motivo un sistema OCR è solitamente composto da una vera e propria pipeline di sottosistemi, i quali si occupano dei singoli problemi.

Nella fase di pre-processing si tenta di migliorare la qualità dell’immagine nel suo insieme. Una immagine scansionata o una foto può infatti contenere un numero elevato di rumori che possono condizionare negativamente l’efficacia dei passi successivi. Questi rumori possono essere ombre, macchie, variazioni di luminosità e contrasto, variazioni cromatiche, rotazioni, deformazioni, eccetera. Per porre rimedio almeno parzialmente a questi problemi vengono utilizzati vari algoritmi e filtri grafici.

Dopo questa prima fase di pulizia e normalizzazione dell’immagine, segue una seconda fase dove le parole vengono individuate singolarmente: la fase di word-segmentation. Anche in questo caso i metodi utilizzati possono essere di diversa natura: neurali o “classici“. Una volta individuate le sotto-immagini contenenti le singole parole, queste possono essere ulteriormente elaborate per rimuovere o limitare eventuali rumori locali: ad esempio una parola può risultare ruotata o più scura rispetto alle altre.

Dopo la fase di word-segmentation, le singole immagini contenenti le parole vengono inviate al cuore vero e proprio dell’OCR: l’algoritmo che si occupa di “leggere” l’immagine in ingresso e restituire il testo. Nel nostro caso si tratterà di un modello neurale, ma possono essere utilizzati anche algoritmi non neurali.

Infine il testo viene ricostruito nella fase di post-processing, basandosi sulle posizioni delle singole parole all’interno dell’immagine complessiva. Ad esempio, nel caso di tipici testi “occidentali“, in cui il testo viene letto da sinistra a destra e dall’alto in basso, le parole codificate vengono concatenate tra loro considerando questo ordinamento. 

A questo punto il testo completo e ricostruito viene fornito come output del sistema OCR.

In questo articolo non indagheremo sulle fasi pre e post-processing, ma piuttosto ci concentreremo solo sul nucleo dell’OCR, cioè quella parte che riceve in ingresso una immagine “ritagliata” e “pulita” contenente una sola parola e ne restituisce la sua decodifica testuale.

In ogni caso dovremo affrontare un ulteriore problema: la character-segmentation. Per decodificare il testo, infatti, l’OCR necessita di individuare l’inizio e la fine di ogni singolo carattere di una parola. Questo problema può non essere banale, soprattutto quando si ha a che fare con parole scritte a mano, o immagini con una distorsione residua o semplicemente con un font non monospazio (ad esempio il Courier è un tipico font monospazio, in cui ogni carattere occupa sempre lo stesso spazio, mentre un Times New Roman non lo è, in quanto caratteri stretti come la “i” occupano meno spazio di caratteri larghi come la “m“). 

Una volta superato quest’ultimo scoglio, avremmo la possibilità di classificare ogni singolo carattere individuato, in modo simile a come abbiamo già fatto in un articolo precedente con il dataset MNIST.

Facciamo un passo alla volta. Diamo uno sguardo a quale sarà l’architettura della nostra rete neurale.

La rete CRNN

Il nostro obiettivo è quello di partire da una immagine ed ottenere un testo. Dagli articoli precedenti sappiamo che per lavorare con le immagini abbiamo uno strumento perfetto: le reti convoluzionali. Grazie a queste si ottengono feature di alta qualità che possiamo poi utilizzare per diversi scopi, come classificare le immagini, eccetera. Ora sappiamo anche che l’immagine in ingresso contiene un testo, quindi una sequenza di caratteri. Nell’articolo precedente abbiamo introdotto le reti ricorrenti, le quali lavorano egregiamente sulle sequenze. L’idea quindi è quella di applicare agli ingressi della rete ricorrente le feature estratte con la rete convoluzionale, in modo da elaborare l’immagine come fosse una sequenza di sotto-immagini.

Una rete del genere viene chiamata rete CRNN (da Convolutional Recurrent Neural Network), il cui schema è rappresentato di seguito.

Feature Sequence, Receptive Field e Time-Step

Durante il passaggio dalla parte convoluzionale alla parte ricorrente della rete CRNN, le feature map convoluzionali vengono adattate per diventare ingressi della parte ricorrente. 

L’adattamento è un semplice ridimensionamento di un insieme di matrici bidimensionali (le mappe di feature convoluzionali) in un insieme di vettori monodimensionali (la feature sequence che verrà elaborata dalla rete ricorrente). Ogni elemento della feature sequence passata in ingresso alla rete ricorrente viene chiamata time-step

Un time-step rappresenta quindi una “fetta” ritagliata dalle feature map convoluzionali e successivamente “stirata” in forma di vettore monodimensionale. Ognuna di queste fette corrisponde ad una parte dell’immagine iniziale, il cosiddetto receptive field della rete convoluzionale, cioè quella regione nello spazio di input (in questo caso l’immagine) che influenza una particolare feature della rete CNN.

In sostanza è un po’ come se, scorrendo la sequenza di time-step, si eseguisse una scansione dell’immagine in ingresso in un verso (ad esempio da sinistra a destra).

La rete ricorrente, eseguendo questa scansione, fornisce in uscita una nuova sequenza di feature che potrà essere sfruttata per altri scopi, ad esempio per decodificare il testo utilizzando un classificatore che elabori ogni singola feature della sequenza.

Ricorrente ma bidirezionale

La natura ricorrente di questa rete consente di tener traccia di quanto rilevato nei time-step precedenti per predire il contenuto del time-step successivo.

Come si vede nello schema di cui sopra, la parte ricorrente sfrutta in particolar modo degli strati bidirezionali. Questo significa che la sequenza di feature che arriva dalla parte convoluzionale, viene analizzata in entrambi i versi per migliorare l’accuracy complessiva. Questo ovviamente è possibile in questo caso perché l’intera sequenza di feature è subito disponibile e non arriva alla rete ricorrente in modo “seriale“.

Proviamo a spiegare in parole povere quale sia il vantaggio di utilizzare degli strati bidirezionali. Cominciamo con spiegare il concetto di contesto.

Vi è mai capitato di leggere una frase in modo molto veloce? In quel caso, probabilmente il vostro cervello non legge interamente le singole parole, ma si accontenta di leggerne l’inizio e sfrutta il contesto della frase per arrivare subito al risultato finale.

Ad esempio, leggendo una parola ci soffermiamo sulle iniziali “bo“. Già possiamo escludere tantissime parole con iniziali diverse, ma restano comunque migliaia di parole potenzialmente corrette (borgo, bontà, boscimano, eccetera). Se però questa parola è all’interno di una frase del tipo “stavo camminando nel bo…”, allora le parole probabili per una frase del genere si riducono drasticamente ed il nostro cervello, considerando la probabilità di ogni parola, sceglierà quasi sicuramente la parola “bosco” come la più attendibile. In questo caso il cervello è stato condizionato molto dal contesto della frase per predire la parola che si stava leggendo.

Nel caso del nostro OCR non abbiamo a disposizione una frase, ma solo una parola, quindi il nostro contesto sarà molto più semplice e limitato, ma il principio rimane valido: mano a mano che leggiamo i caratteri che compongono la parola, aumenta la probabilità di trovare la parola giusta.

  1. Leggo “b“: abbiamo migliaia di parole che iniziano per “b“.
  2. Leggo “o“: il numero di parole che iniziano per “bo” si riduce a qualche centinaia.
  3. Leggo “s“: le parole che iniziano per “bos” si riducono ulteriormente a qualche decina.
  4. Leggo “c“: le parole che iniziano per “bosc” si riducono ad una manciata.
  5. Leggo “o“: la parola è “bosco

Mano a mano che la parola si compone, la probabilità che la parola sia “bosco” aumenta fino ad arrivare alla certezza. Questo comportamento rafforza anche la lettura dei caratteri intermedi: se ad esempio l’OCR avesse letto la “c” con una bassa confidenza tale da essere in dubbio con la “z“, aver letto quella lettera dopo la sequenza “bos” avrebbe potuto rafforzare la scelta per la “c” in quanto la sequenza “bosz” sarebbe decisamente meno probabile.

Detto questo, cosa succederebbe se la parola potesse venir letta da entrambe le direzioni? In questo caso la rete neurale si può appoggiare a due contesti per predire la lettura della parola.

  1. Leggo “b” a sinistra e “o” a destra: le parole che hanno questa combinazione di inizio e fine sono probabilmente alcune centinaia, quindi siamo già in vantaggio rispetto al punto 1 visto in precedenza.
  2. Leggo “o” a sinistra e “c” a destra: le parole che iniziano per “bo” e finiscono per “co” saranno solo alcune decine, forse meno.
  3. Leggo “s” a sinistra e a destra: ho la certezza che la parola sia “bosco
  4. Continuando a leggere, si continuano a rafforzare le confidenze di lettura di tutte le lettere della parola.

L’uso del contesto aiuta la predizione, quindi usarne due aumenta l’intensità di questo aiuto. In alcuni casi l’utilizzo della bidirezionalità consente anche di recuperare errori di battitura nei testi, perché la sequenza di parole in questo caso è governata da vincoli grammaticali e sintattici che impediscono l’utilizzo di certe combinazioni (ad esempio quando si sbaglia un congiuntivo in una frase).

Il nostro cervello fa largo uso del contesto e in alcuni casi utilizza anche un contesto bidirezionale. Non siete convinti? Provate a leggere questo testo.

Non imorpta in che oridne apapaino le letetre in una paolra,
l’uinca csoa imnorptate è che la pimra e la ulimta letetra
sinao nel ptoso gituso.
Il riustlato può serbmare mloto cnofuso e noonstatne ttuto
si può legerge sezna mloti prleobmi.
Qesuto si dvee al ftato che la mtene uanma non lgege ongi
letetra una ad una, ma la paolra nel suo isineme.
Cuorsio, no?

In questo esempio il cervello utilizza il contesto di ogni singola parola, che essendo limitata graficamente in uno spazio ridotto, riesce a leggere in un colpo solo soffermandosi principalmente sulla parte iniziale e su quella finale. Inoltre aggiunge anche il contesto della frase mano a mano che questa si compone.

Paradossalmente, se ci si sofferma su una singola parola nel mezzo della frase, ci si deve sforzare un attimo per indovinare di che parola si tratta, mentre leggendo la frase dall’inizio, la lettura avviene in modo molto più fluido, quasi senza sforzo.

Connectionist Temporal Classification (per gli amici CTC)

Bene, a questo punto l’immagine è stata elaborata dalla rete convoluzionale che ha fornito una sequenza di feature alla rete ricorrente (bidirezionale), la quale a sua volta fornisce una sequenza di feature nuove dalle quali si dovrà desumere il testo. Le feature fornite dalla rete ricorrente vengono raggruppate per time-step ed elaborate da un’ultima sotto-rete che funge da classificatore. Questo classificatore sarà addestrato a riconoscere un certo insieme di lettere, che fanno parte del suo vocabolario

Visto che i receptive field possono sovrapporsi, ma anche solo perché una lettera può persistere su più receptive field adiacenti, il risultato della classificazione potrebbe essere simile a quello rappresentato di seguito.

Come si vede la rete può fornire lettere ripetute, proprio per il motivo appena citato.

Inoltre si può notare come insieme alle lettere sia presente un trattino ““, che rappresenta il cosiddetto carattere “blank“. Questo carattere speciale viene fornito in uscita dal classificatore quando nel corrispondente time-step non viene riconosciuto un carattere valido o anche quando la rete individua una separazione tra le lettere.

Ora, dagli articoli precedenti, sappiamo che una rete neurale, quando addestrata in modo supervised, necessita di esempi etichettati. Le etichette (o label) devono essere fornite in modo che siano compatibili con l’uscita della rete. Nel caso in esempio, chi si occupa di etichettare gli esempi, dovrebbe fare attenzione a come le lettere cadono nelle time-step, in modo da fornire la giusta sequenza di blank, lettere e lettere eventualmente ripetute. Come intuibile, si tratterebbe di un lavoro estremamente oneroso e da ripetere ogni qualvolta le caratteristiche della rete vengano modificate in modo tale da variare i receptive field convoluzionali. 

Per evitare questo lavoraccio e anche per altri scopi, ci viene in aiuto l’algoritmo CTC, il quale gestisce questi tre compiti di base.

Encoding

Esegue l’encoding dell’output predetto “fondendo” le lettere ripetute ed utilizzando il blank come un separatore. Infine elimina il carattere blank  dall’encoding.

Nell’esempio di cui sopra, partendo dall’output predetto “-s-t-aatte“, la fusione dei doppioni trasforma la stringa in “-s-t-ate“, infine il blank viene eliminato ottenendo “state” che è la stringa corretta dal punto di vista “umano“.

Il concetto di blank come separatore torna utile nei casi in cui la rete individui una separazione tra lettere e ce lo voglia comunicare. Mettiamo il caso che la predizione, non utilizzando il blank, sia “heeellloo“. Senza blank, il CTC fonderebbe semplicemente le lettere doppie, ottenendo “helo“. Quando invece la rete utilizza il blank come separatore, la predizione potrebbe essere “-heeel-ll-oo“. I doppioni verrebbero fusi, ottenendo “-hel-l-o“, che eliminando infine il blank diventerebbe “hello“.

Loss

Calcola la loss utilizzando la somma degli score ottenuti dalla predizione della rete, per ogni allineamento dell’uscita desiderata. Cerchiamo di spiegarlo con un esempio. Consideriamo la figura seguente:

Essa mostra la possibile uscita della rete per ogni time-step. In questo caso il vocabolario del classificatore è formato dalle lettere “a” e “b“, con l’aggiunta del solito blank (che graficamente viene rappresentato da un trattino).

Nei cerchi sono indicate le probabilità (gli score) con cui, in quel time-step, quel particolare carattere è stato riconosciuto. La somma delle probabilità di ogni time-step (cioè di una colonna di cerchi) è ovviamente pari ad 1

Se l’output desiderato (la label) è “a“, allora devo considerare che questa label deve essere “spalmata” su 3 time-step. Ciò significa che posso ottenere 6 combinazioni (o allineamenti) diversi della mia label su questi time-step che attraverso l’encoding descritto sopra, possono validare la label “a“.

Questi allineamenti sono: “a a a“, “a – –“, “– a –“, “a a –“, “– a a“, “– – a” (verificate pure usando le regole di encoding).

Per ognuno di questi allineamenti, calcolo la probabilità di predizione moltiplicando gli score associati ad ogni lettera che compone l’allineamento nel time-step corrispondente. Con i valori presenti nell’immagine di esempio, otteniamo:

  • a a a“: 0,4 * 0,3 * 0,4 = 0,048
  • a – –” : 0,4 * 0,7 * 0,6 = 0,168 
  • – a –” : 0,1 * 0,3 * 0,6 = 0,018 
  • a a –” : 0,4 * 0,3 * 0,6 = 0,072 
  • – a a” : 0,1 * 0,3 * 0,4 = 0,012 
  • – – a” : 0,1 * 0,7 * 0,4 = 0,028

Sommando tutte queste probabilità si ottiene 0,346 che è la probabilità che quella predizione rappresenti la nostra label (considerando tutti i possibili allineamenti). Come si nota, è una probabilità non tanto alta ed in effetti soffermandoci sul primo time-step della figura, si nota che la lettera “b” è quella che ha maggior probabilità di predizione, quindi è più probabile che quella predizione corrisponda ad una “b” seguito da altro.

In ogni modo da questa probabilità complessiva, viene successivamente calcolato il valore della loss, utilizzando la Negative Log-Likelihood che tende ad infinito quando la probabilità tende a 0 e sarà invece pari a 0 quando la probabilità risulta essere pari ad 1. Quindi in sostanza la loss aumenta (peggiora) quando la predizione ha meno probabilità di rappresentare la label desiderata, mentre diminuisce (migliora) nel caso contrario. Esattamente quello che dovrebbe fare una loss.

Decoding

Esegue la decodifica della predizione, utilizzando il cosiddetto algoritmo best path, con il quale si va a prendere, per ogni time-step, il carattere corrispondente alla massima probabilità. 

Nella figura di esempio otterremmo la stringa “b – –“, la quale poi, per effetto dell’encoding, diventerebbe semplicemente “b“, che è la stringa rappresentata da quella particolare predizione.

Mettiamo in pratica quanto visto fin ora

Bene, dopo questa veloce descrizione del funzionamento teorico, andiamo sul pratico sviluppando un OCR che legga dei numeri a più cifre. Per farlo avremo bisogno di un dataset etichettato. La cosa più veloce da fare è … farselo da soli!

Sfruttando il dataset MNIST che già conosciamo, possiamo creare una serie di immagini di numeri a più cifre ed etichettarli automaticamente.

NOTA: in questo esempio ci prenderemo alcune libertà:

  • i numeri avranno tutti la stessa lunghezza in cifre: ciò ci consentirà di semplificare alcune parti del codice dell’OCR e del generatore di dataset
  • le immagini del dataset avranno tutte le stesse dimensioni
  • le immagini del dataset saranno tutte pulite, senza rumore

Ovviamente queste semplificazioni ci servono per arrivare velocemente ad un risultato, ma nella vita reale dovremo affrontare questi ed altri problemi, come già spiegato in precedenza. In ogni caso, il principio di funzionamento del nostro OCR resterà immutato.

NOTA: il codice di questo esempio lo trovi qui

Partiamo con un piccolo escamotage per evitare il logorroico log di debug di TensorFlow. Seguendo la documentazione ufficiale, si può vedere che è sufficiente impostare una variabile d’ambiente specifica con alcuni valori per limitare o disabilitare la generazione di log. 

ATTENZIONE! Questo lo si può fare una volta che si sia testato il corretto funzionamento del programma. In caso di problemi, infatti, il log dettagliato di TensorFlow torna utile per scoprire errori o trovare soluzioni.

import os

# disabilito il logger di TensorFlow per avere un log pulito
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"

Definiamo alcune costanti di comodo. In particolare:

  • la directory dove verranno salvate le immagini del dataset autocreato
  • il vocabolario da utilizzare (in questo caso cifre decimali)
  • la lunghezza delle nostre label
  • le dimensioni delle immagini da fornire in input al modello (queste dimensioni non devono necessariamente essere uguali a quelle reali delle immagini)
  • il numero di esempi che compone il nostro dataset 
  • il numero di immagini che terremo a parte per il test finale (saranno immagini che il modello non vedrà mai in fase di addestramento, quindi saranno un ottimo test di valutazione delle prestazioni complessive del nostro OCR)
  • la percentuale di immagini da utilizzare per il dataset di train, rispetto al dataset di validazione
  • il numero di immagini che comporranno un batch di addestramento
  • il numero di epoche di addestramento da eseguire
DATASET_DIR = "dataset"
VOCABULARY = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
LABEL_LEN = 9
IMG_WIDTH, IMG_HEIGHT = 256, 32
NR_SAMPLES = 2000
TEST_SIZE = 16
TRAIN_SPLIT = 0.9
BATCH_SIZE = 16
EPOCHS = 10

Ora, se non l’abbiamo già fatto in precedenza, generiamo le immagini del nostro dataset. Utilizziamo il dataset MNIST, il quale viene fornito suddiviso in train e test. Uniamo i due sotto-dataset in modo da ottenere il numero massimo di immagini possibili (70.000).

Successivamente, per ogni esempio che è necessario creare, generiamo una label casuale di lunghezza LABEL_LEN utilizzando il vocabolario predefinito. Ottenuta la label, verifichiamo se essa non sia già stata utilizzata in precedenza, per evitare doppioni, perché questi andrebbero a ridurre il numero di esempi unici. Se la label è già stata utilizzata, la rigeneriamo e ripetiamo la verifica, altrimenti proseguiamo. 

A questo punto creiamo una immagine vuota di altezza pari all’altezza delle cifre MNIST e di larghezza pari alla larghezza delle cifre MNIST moltiplicata per il numero di cifre del numero da generare. Nel nostro caso otterremo delle immagini di dimensioni 252 x 28. Questa immagine la useremo come contenitore delle sotto-immagini delle cifre, che otteniamo andando a prendere un carattere alla volta dalla label, utilizzandolo per selezionare una immagine casuale dal dataset MNIST che rappresenti quella cifra. Ogni sotto-immagine verrà copiata all’interno dell’immagine complessiva, spostandola nella posizione corrispondente al carattere estratto dalla label.

Infine l’immagine complessiva la salveremo nella directory del dataset, utilizzando il nome del file come label.

# se il dataset non è presente, lo preparo
if not os.path.exists(DATASET_DIR):
    print("Devo preparare il dataset")
    # ottengo il dataset MNIST già suddiviso in dataset X e Y, di addestramento e di test
    (X_train, y_train), (X_test, y_test) = tf.keras.datasets.mnist.load_data()
    # unisco il dataset train e test per ottenerne uno unico
    X_dataset = np.concatenate([X_train, X_test], axis=0)
    y_dataset = np.concatenate([y_train, y_test], axis=0)
    # preparo i samples unici del dataset
    os.makedirs(DATASET_DIR)
    samples = []
    for _ in tqdm(range(NR_SAMPLES)):
        while True:
            label = "".join(map(lambda x: VOCABULARY[x], np.random.randint(0, len(VOCABULARY), size=LABEL_LEN)))
            if label not in samples:
                samples.append(label)
                break
        img = np.zeros((28, 28 * LABEL_LEN), dtype=np.uint8)
        for pos, digit in enumerate(label):
            img[:, 28 * pos:28 * (pos + 1)] = X_dataset[np.random.choice(np.where(y_dataset == int(digit))[0], 1)[0]]
        cv2.imwrite(os.path.join(DATASET_DIR, label + ".jpg"), img)
    print()
    print("Dataset preparato")

Una volta che il dataset è creato, andiamo a leggere i file nella cartella del dataset e prepariamo la lista images con il nome delle immagini (completandolo con il path). Poi mischiamo questa lista e infine prepariamo la corrispettiva lista con le label che estrarremo dal nome di ogni file.

# ottengo l'elenco dei file immagini e randomizzo l'elenco
images = list(map(lambda x: os.path.join(DATASET_DIR, x), os.listdir(DATASET_DIR)))
np.random.shuffle(images)
# dai nomi dei file ottengo le label
labels = list(map(lambda x: os.path.splitext(os.path.basename(x))[0], images))

A questo punto ne approfittiamo per visualizzare alcune informazioni sul dataset.

print("Informazioni sul dataset")
print("========================")
print()
print("Immagini totali: %d" % len(images))
print("Lunghezza massima label: %d" % LABEL_LEN)
print("Vocabolario di %d caratteri" % len(VOCABULARY))
print("Vocabolario: %s" % str(VOCABULARY))
print()
Dataset preparato 
Informazioni sul dataset
======================== 

Immagini totali: 2000
Lunghezza massima label: 9
Vocabolario di 10 caratteri
Vocabolario: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

Ora definiamo le funzioni che verranno utilizzate per codificare da carattere a numero e da numero a carattere. In questo modo potremo codificare e decodificare le label per l’utilizzo con la nostra rete.

Queste funzioni sono fatte in modo da poter essere utilizzate all’interno di un modello TensorFlow, in quanto utilizzano i tensori per lavorare, cioè strutture dati simili a vettori e matrici ma che TensorFlow può utilizzare ed elaborare in modo diretto ed accelerato. Potremmo sviluppare queste funzioni a modo nostro, ma lavorare con i tensori è un po’ più complicato del solito, inoltre TensorFlow mette già a disposizione strumenti pronti, per cui perché complicarci la vita?

La funzione StringLookup sfrutta un vocabolario per mappare gli ingressi che riceve, aggiungendo al vocabolario iniziale, se non già presente, un nuovo token [UNK] che serve ad identificare tutto ciò che non è riconosciuto all’interno del vocabolario. Quindi possiamo sfruttare questa funzione già pronta per  definire le nostre char_to_num e num_to_char. In particolare, nella funzione di decodifica num_to_char utilizziamo il parametro invert=True per indicare alla StringLookup che dovrà funzionare a rovescio, cioè decodificare l’ingresso. Inoltre utilizzeremo la nostra costante VOCABULARY per la char_to_num, mentre utilizzeremo il vocabolario autogenerato (contenente il token [UNK]) per la num_to_char, in modo da gestire ogni combinazione possibile per la decodifica.

# preparo la funzione che mappa dai caratteri ai codici numerici
char_to_num = tf.keras.layers.experimental.preprocessing.StringLookup(
    vocabulary=VOCABULARY, mask_token=None
)

# preparo la funzione che mappa dai codici numerici ai caratteri
# uso il nuovo vocabolario che contiene anche il token [UNK] per i caratteri sconosciuti
num_to_char = tf.keras.layers.experimental.preprocessing.StringLookup(
    vocabulary=char_to_num.get_vocabulary(), mask_token=None, invert=True
)

Approfittiamo anche per visualizzare come i caratteri verranno codificati. Il token [UNK] verrà sempre codificato come 0.

print("Codifica caratteri->numeri:")
for ch in VOCABULARY:
    print("'%s' -> %d" % (ch, char_to_num(ch)))
print()

print("Decodifica codice 0: '%s'" % num_to_char(0).numpy().decode("utf8"))
print()
Codifica caratteri->numeri: 
'0' -> 1 
'1' -> 2 
'2' -> 3 
'3' -> 4 
'4' -> 5 
'5' -> 6 
'6' -> 7 
'7' -> 8 
'8' -> 9 
'9' -> 10 
 
Decodifica codice 0: '[UNK]' 

A questo punto possiamo estrarre le immagini e le label che utilizzeremo per il test delle prestazioni. Queste immagini le escluderemo dal restante dataset, in modo che non ci sia la possibilità che finiscano in addestramento. Già che ci siamo, visualizziamo anche come appare questo dataset.

# ottengo il dataset di test e lo tolgo dal dataset
test_img = images[:TEST_SIZE]
test_lab = labels[:TEST_SIZE]
images = images[TEST_SIZE:]
labels = labels[TEST_SIZE:]

# visualizzo il dataset di test
_, axs = plt.subplots(4, 4, figsize=(15, 5))
for i, (imgf, label) in enumerate(zip(test_img, test_lab)):
    axs[i // 4, i % 4].imshow(mpimg.imread(imgf), cmap="gray")
    axs[i // 4, i % 4].set_title(label)
    axs[i // 4, i % 4].axis("off")
plt.show()

Ora estraiamo il dataset di addestramento e quello di validazione utilizzando la percentuale TRAIN_SPLIT definita in precedenza. Poi visualizziamo il numero di esempi per ogni dataset.

# suddivido il resto del dataset in training set e validation set, utilizzando la percentuale di split
num_train_samples = math.ceil(len(images) * TRAIN_SPLIT)

train_img = images[:num_train_samples]
train_lab = labels[:num_train_samples]
val_img = images[num_train_samples:]
val_lab = labels[num_train_samples:]

print("Test set: %d immagini" % len(test_img))
print("Train set: %d immagini" % len(train_img))
print("Validation set: %d immagini" % len(val_img))
Test set: 16 immagini 
Train set: 1786 immagini 
Validation set: 198 immagini 

A questo punto dobbiamo definire una funzione che prepari i dati per essere elaborati dalla rete. Per ogni dataset abbiamo a disposizione una lista con i nomi di file e una lista con le corrispettive label. Definiamo quindi una funzione prepara_sample che in ingresso si aspetta un nome di file ed una label. Il nome di file lo utilizziamo per leggere il file da disco e decodificarlo come immagine jpeg monocromatica.

In questo modo otteniamo un vettore con valori interi nell’intervallo [0, 255] che corrispondono ai livelli di grigio. Sappiamo che una rete neurale lavora meglio con valori in virgola mobile normalizzati, per cui applichiamo la funzione convert_image_dtype per convertire i dati e normalizzarli allo stesso tempo.

Le immagini che abbiamo generato sono in dimensione 252 x 28 che non corrisponde alle dimensioni degli ingressi desiderati, quindi applichiamo la resize per adattare le immagini. 

Infine applichiamo la transpose per eseguire una sorta di rotazione di 90° alle immagini. In questo modo la scansione dei time-step avverrà sull’asse orizzontale dell’immagine e non su quella verticale.

Da notare che l’uso della transpose esegue una rotazione 90° ma l’immagine che ne deriva sarà “specchiata” rispetto a quanto ci si aspetterebbe. Quindi perché non usiamo una rotate? Questo in verità non è un problema, in quanto la rete neurale imparerà a leggere l’immagine specchiata. Inoltre la transpose è una funzione molto più veloce della rotate e in sostanza otteniamo performance migliori senza inficiare sulla lettura dell’immagine.

Per quanto riguarda la label, invece, semplicemente applichiamo la nostra funzione char_to_num per la codifica della stringa. Visto che la char_to_num può lavorare solo su tensori, utilizziamo la unicode_split per convertire la stringa della label in un tensore.

Infine i due tensori pre-processati vengono restituiti in un dizionario associandoli ai nomi che verranno utilizzati per i layer di ingresso della nostra rete neurale (vedi più avanti).

def prepara_sample(imgf, labelstr):
    # leggo l'immagine
    # la converto in scala di grigi
    # utilizzo il datatype float32
    # ridimensiono l'immagine alla dimensione di input desiderata
    # ruoto l'immagine in modo che la larghezza dell'immagine funga da dimensione temporale (timesteps)
    # NOTA: i valori sono normalizzati nel range [0, 1]
    img = tf.transpose(
        tf.image.resize(
            tf.image.convert_image_dtype(
                tf.io.decode_jpeg(
                    tf.io.read_file(imgf),
                    channels=1
                ),
                tf.float32
            ),
            [IMG_HEIGHT, IMG_WIDTH]
        ),
        perm=[1, 0, 2]
    )
    # codifico la stringa della label nella sua versione numerica
    label = char_to_num(tf.strings.unicode_split(labelstr, input_encoding="UTF-8"))

    return {"input_image": img, "input_label": label}

Adesso possiamo utilizzare questa funzione di preparazione appena definita. Per farlo sfruttiamo i Dataset di TensorFlow, che si occupano di fornire gli ingressi per la rete neurale durante la fase di addestramento e possono essere personalizzati usando una serie di metodi che verranno poi eseguiti in successione. Creeremo un oggetto per la gestione del dataset di training ed uno per il dataset di validation.

Cominciamo usando il metodo from_tensor_slices il quale consente di gestire i dati leggendoli direttamente da delle liste. Forniremo le liste con i nomi di file e con le label. 

Poi applichiamo il metodo map indicando come parametro la funzione prepara_sample che abbiamo definito poco fa. Così facendo ogni coppia delle liste nomi file e label verrà passata a questa automaticamente, così da ottenere le immagini e le corrispettive label pre-processate. Il parametro num_parallel_calls indica alla funzione di mappatura di autogestirsi l’esecuzione parallela nel modo migliore.

In coda alla mappatura applichiamo il metodo batch il quale andrà a suddividere in batch i risultati della mappatura. Il parametro di questo metodo serve ad indicare la dimensione di ogni batch, nel nostro caso pari alla costante BATCH_SIZE che abbiamo definito all’inizio.

Infine applichiamo il metodo prefetch che serve a pre-caricare i dati con l’obiettivo di migliorare le prestazioni. Il parametro buffer_size serve a dimensionare il buffer di pre-caricamento. In questo caso indichiamo che la dimensione di questo buffer sarà gestito automaticamente per l’ottenimento delle migliori performance.

# creo degli oggetti Dataset che mi consentono di gestire i dati in maniera automatica
train_ds = tf.data.Dataset.from_tensor_slices((train_img, train_lab))
train_ds = train_ds. \
    map(prepara_sample, num_parallel_calls=tf.data.experimental.AUTOTUNE). \
    batch(BATCH_SIZE). \
    prefetch(buffer_size=tf.data.experimental.AUTOTUNE)

val_ds = tf.data.Dataset.from_tensor_slices((val_img, val_lab))
val_ds = val_ds. \
    map(prepara_sample, num_parallel_calls=tf.data.experimental.AUTOTUNE). \
    batch(BATCH_SIZE). \
    prefetch(buffer_size=tf.data.experimental.AUTOTUNE)

Arriviamo ora al fulcro di tutto: la funzione CTC per il calcolo della loss. In questo caso andremo a definire un nuovo layer personalizzato utilizzando la classe Layer di Keras. Un qualsiasi layer personalizzato deve occuparsi principalmente di ridefinire il costruttore __init__ (per la propria inizializzazione e configurazione) ed il metodo call (per il suo funzionamento). In questo caso il layer funzionerà partendo da una coppia di parametri che corrispondono alla label desiderata y_true (già codificata) e alla predizione della rete y_pred

Per eseguire il calcolo della loss, sfruttiamo la funzione ctc_batch_cost messa a disposizione da Keras, la quale vuole in ingresso la label desiderata, la predizione, la lunghezza della predizione e la lunghezza della label desiderata. Visto che questo layer dovrà gestire batch di dati, anche le informazioni passate alla funzione di calcolo saranno in forma di batch. 

Ottenuto il valore della loss, questo verrà aggiunto all’accumulatore di loss complessivo tramite il metodo add_loss. Infine il layer fornirà in uscita la predizione. Così facendo, dopo questo layer è possibile aggiungere altre elaborazioni, se serve.

# definisco un nuovo layer custom per il calcolo della CTC Loss
class CTCLayer(tf.keras.layers.Layer):
    def __init__(self, name=None):
        super().__init__(name=name)
        self.loss_fn = tf.keras.backend.ctc_batch_cost

    def call(self, y_true, y_pred):
        # calcolo la CTC loss ed aggiungo il valore usando il metodo "add_loss"
        batch_len = tf.cast(tf.shape(y_true)[0], dtype="int32")
        y_pred_len = tf.cast(tf.shape(y_pred)[1], dtype="int32")
        y_true_len = tf.cast(tf.shape(y_true)[1], dtype="int32")

        y_pred_len = y_pred_len * tf.ones(shape=(batch_len, 1), dtype="int32")
        y_true_len = y_true_len * tf.ones(shape=(batch_len, 1), dtype="int32")

        loss = self.loss_fn(y_true, y_pred, y_pred_len, y_true_len)
        self.add_loss(loss)

        # restituisco la predizione per la fase di inferesi/test
        return y_pred

Ora siamo pronti per definire il nostro modello neurale. Innanzitutto creiamo due layer di input: infatti al nostro modello, in fase di addestramento, dovranno essere fornite sia le immagini che le label desiderate. 

# ingressi del modello di training (per il modello di inference verrà utilizzato solo l'input delle immagini)
input_img_layer = tf.keras.layers.Input(shape=(IMG_WIDTH, IMG_HEIGHT, 1), name="input_image", dtype="float32")
input_lab_layer = tf.keras.layers.Input(shape=(None,), name="input_label", dtype="float32")

A questo punto l’input delle immagini può essere applicato alla parte convoluzionale della rete: avremo due coppie convoluzione-maxpooling.

# parte convoluzionale
x = tf.keras.layers.Conv2D(32, (3, 3), activation="relu", padding="same", kernel_initializer="he_normal", name="conv1", )(input_img_layer)
x = tf.keras.layers.MaxPooling2D((2, 2), name="pool1")(x)

x = tf.keras.layers.Conv2D(64, (3, 3), activation="relu", padding="same", kernel_initializer="he_normal", name="conv2", )(x)
x = tf.keras.layers.MaxPooling2D((2, 2), name="pool2")(x)

Come abbiamo detto in precedenza, le feature map convoluzionali devono essere adattate per diventare ingressi della parte ricorrente. Questo si fa attraverso un layer Reshape: la dimensione ad indice 0 della feature map corrisponde alla dimensione del batch, quindi resta invariata e non rientra nel ridimensionamento. Manteniamo anche la dimensione 1 che corrisponde alla altezza dell’immagine in ingresso. Ricordate che l’immagine è stata ruotata di 90°, quindi in verità stiamo mantenendo la larghezza dell’immagine originale. Questa dimensione, nella feature sequence, corrisponderà a quella dei time-step. Infine moltiplichiamo tra loro le dimensioni 2 e 3 della feature map, in modo da trasformare le matrici bidimensionali di ogni feature in un vettore monodimensionale (con stesso numero di elementi). Ecco che abbiamo ottenuto la nostra feature sequence.

# converto da output convoluzionale ad input ricorrente
x = tf.keras.layers.Reshape(target_shape=(x.shape[1], x.shape[2] * x.shape[3]), name="reshape")(x)

Ora applichiamo la feature sequence agli strati ricorrenti bidirezionali. Ce ne sono due in quanto il problema è comunque abbastanza complesso per essere gestito con un unico strato. 

# parte ricorrente
x = tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(128, return_sequences=True, dropout=0.25))(x)
x = tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(64, return_sequences=True, dropout=0.25))(x)

Siamo arrivati alla parte di classificazione vera e propria. L’uscita della parte ricorrente sarà una nuova sequenza di feature (parametro return_sequences=True). Quindi anche questo classificatore fornirà una sequenza di classificazioni. Si noti che il numero di classi da gestire è pari al numero di classi del vocabolario ampliato (quello che contiene anche il token [UNK]) ulteriormente ampliato di una unità per gestire la classe speciale blank.

# layer di uscita (uso il vocabolario con il token [UNK] e aggiungo un valore per il [BLANK])
out_prediction = tf.keras.layers.Dense(len(char_to_num.get_vocabulary()) + 1, activation="softmax", name="out_prediction")(x)

A questo punto aggiungiamo il layer personalizzato per il calcolo della loss CTC, utilizzando l’ingresso delle label desiderate e l’uscita del classificatore.

# aggiungo il CTCLayer per calcolare la CTC Loss (confronta la label in ingresso e quella della predizione)
out_ctc_loss = CTCLayer(name="out_ctc_loss")(input_lab_layer, out_prediction)

Bene, la struttura dei layer è completata, quindi possiamo procedere a definire il modello di addestramento che avrà due ingressi (immagini e label desiderate) e come uscita avrà l’uscita del layer CTC. Infine lo compiliamo per l’addestramento utilizzando un ottimizzatore standard Adam

# creo e compilo il modello per il training
train_model = tf.keras.models.Model(inputs=[input_img_layer, input_lab_layer], outputs=out_ctc_loss, name="ocr_training_model")
train_model.compile(optimizer="adam")

Il modello di addestramento però non può funzionare per l’inferesi. Infatti in quel caso abbiamo solo delle immagini in ingresso. Inoltre il calcolo della CTC loss sarebbe un inutile perdita di tempo. Quindi definiamo anche un modello da usarsi in predizione, che avrà un unico ingresso (le immagini) e come uscita avrà direttamente la predizione del classificatore. Questo modello non va compilato e condivide i pesi con quello di addestramento. Ciò significa che mentre addestriamo il modello di addestramento, stiamo addestrando anche quello di predizione.

# creo il modello di predizione
pred_model = tf.keras.models.Model(inputs=input_img_layer, outputs=out_prediction, name="ocr_prediction_model")

Possiamo procedere ad eseguire l’addestramento. In poco tempo, nonostante si esegua un addestramento da zero, si arriverà ad un buon valore di loss.

# avvio l'addestramento
fit_data = train_model.fit(
    train_ds,
    validation_data=val_ds,
    epochs=EPOCHS,
    verbose=1
)
Epoch 1/10 
112/112 [==============================] - 30s 213ms/step - loss: 25.3164 - val_loss: 21.8266 
Epoch 2/10 
112/112 [==============================] - 22s 200ms/step - loss: 17.8605 - val_loss: 10.5244 
Epoch 3/10 
112/112 [==============================] - 23s 203ms/step - loss: 5.0622 - val_loss: 2.3422 
Epoch 4/10 
112/112 [==============================] - 23s 204ms/step - loss: 1.8587 - val_loss: 1.3291 
Epoch 5/10 
112/112 [==============================] - 22s 198ms/step - loss: 1.2537 - val_loss: 1.0078 
Epoch 6/10 
112/112 [==============================] - 23s 204ms/step - loss: 0.9137 - val_loss: 0.8665 
Epoch 7/10 
112/112 [==============================] - 23s 202ms/step - loss: 0.7187 - val_loss: 0.7408 
Epoch 8/10 
112/112 [==============================] - 23s 201ms/step - loss: 0.6081 - val_loss: 0.7098 
Epoch 9/10 
112/112 [==============================] - 21s 191ms/step - loss: 0.4982 - val_loss: 0.7165 
Epoch 10/10 
112/112 [==============================] - 22s 199ms/step - loss: 0.4032 - val_loss: 0.6321 

Visualizziamo l’andamento della loss di addestramento e di validazione.

plt.plot(fit_data.history["loss"], label="Loss Train")
plt.plot(fit_data.history["val_loss"], label="Loss Validation")
plt.legend()
plt.title("Andamento loss")
plt.xlabel("Epoche")
plt.ylabel("Loss")
plt.show()

Non ci resta che visualizzare i risultati sul dataset di test. Definiamo innanzitutto una funzione che esegua la decodifica dell’uscita del nostro modello di inferesi. Utilizzeremo in questo caso la funzione ctc_decode fornita da Keras. Utilizzeremo l’algoritmo greedy il quale semplicemente sfrutta il best path che abbiamo descritto in precedenza. In altri casi è possibile utilizzare altri algoritmi che possono migliorare l’accuracy, ad esempio basandosi su un vocabolario di parole accettate. Ma visto che lavoriamo su numeri, non è il nostro caso. 

La funzione di decodifica vuole in ingresso la predizione del modello e la lunghezza della predizione. L’uscita sarà un tensore che contiene la sequenza di codifiche dei vari time-step. Quindi, tramite la funzione reduce_join, andiamo automaticamente a concatenare i caratteri forniti dalla funzione di decodifica che abbiamo definito in precedenza num_to_char. Otteniamo così la nostra stringa decodificata della predizione. Da notare che anche in questo caso le funzioni lavorano accettando dei batch, quindi la funzione decodifica_predizione restituirà una lista della stringhe predette per quello specifico batch in ingresso.

def decodifica_predizione(pred):
    input_len = np.ones(pred.shape[0]) * pred.shape[1]
    # utilizzo la ricerca "greedy" per decodificare la predizione
    results = tf.keras.backend.ctc_decode(pred, input_length=input_len, greedy=True)[0][0][:, :LABEL_LEN]
    # ricostruisco la stringa predetta iterando il risultato
    output_text = []
    for res in results:
        res = tf.strings.reduce_join(num_to_char(res)).numpy().decode("utf-8")
        output_text.append(res)
    return output_text

Infine, in maniera simile a quanto fatto prima, andiamo a visualizzare il dataset di test, completo di label desiderata (GT da Ground Truth) e label predetta (Pred). Nel caso in cui le due label combacino, la scritta sarà in blu, altrimenti in rosso.

_, axs = plt.subplots(4, 4, figsize=(15, 5))
for i, (imgf, labelstr) in enumerate(zip(test_img, test_lab)):
    sample = prepara_sample(imgf, labelstr)

    pred = pred_model.predict(tf.expand_dims(sample["input_image"], axis=0))
    pred_text = decodifica_predizione(pred)[0]

    axs[i // 4, i % 4].imshow(mpimg.imread(imgf), cmap="gray")
    axs[i // 4, i % 4].title.set_color("red" if labelstr != pred_text else "blue")
    axs[i // 4, i % 4].set_title("GT: %s -- Pred: %s" % (labelstr, pred_text))
    axs[i // 4, i % 4].axis("off")

plt.show()

Il risultato, considerato il numero basso di epoche di addestramento e la semplicità del modello, sembra comunque molto buono. L’unico esempio rosso lo si può notare in basso a sinistra e notiamo che in questo caso il modello ha predetto correttamente tutte le cifre tranne una.

Conclusioni

Il nostro semplice OCR è un esempio di come sia possibile combinare le varie tecniche già viste in precedenza, come le reti convoluzionali, quelle ricorrenti, i classificatori, eccetera, per poter ottenere un modello più complesso che ci consenta di elaborare un problema non banale come la lettura di testo da immagini. 

La natura neurale di questo modello consente di gestire anche situazioni più difficili dove le immagini in ingresso non siano così pulite come quelle nostre e dove la lunghezza delle scritte non sia fissa.

Infine una curiosità sull’algoritmo CTC: esso si presta bene anche ad altri utilizzi, ad esempio nei modelli speech-to-text dove l’ingresso non è una immagine ma un audio. Anche in questo caso infatti esiste il problema dell’allineamento tra ingresso audio e label desiderata, perché una persona può parlare velocemente o lentamente o scandire diversamente le parole e le lettere.

Noi intanto ci rivediamo al prossimo articolo.

Bye!

Cristiano Casadei: Lavoro in Maggioli dal ’96 e ho contribuito a diversi prodotti dell’Azienda: da Concilia a TradeWin, ai prodotti per i Demografici. Dal 2016 entro a far parte a tempo pieno del team dei Progetti Speciali, ora R&D. In questo team ho contribuito allo sviluppo di Revisal, Scacco2 e ora mi occupo di studiare e sperimentare soluzioni che fanno uso di Intelligenza Artificiale. Come si può intuire dalla foto, amo la montagna.
Related Post