Poké-Pi-Dex
Sul Progetto
Poké-Pi-Dex è un dispositivo che emula un Pokédex, in grado di classificare i Pokémon di prima generazione a partire da un’immagine. Funziona su un Raspberry Pi4 con una PiCamera e altri componenti, il tutto inserito all’interno di una custodia di cartoncino riciclato fatta a mano 🌱.
TryKatChup ed io abbiamo sviluppato questo progetto come parte del corso Sistemi Digitali M presso l’Alma Mater Studiorum, Università di Bologna.
L’Idea
Per sostenere l’esame, gli studenti dovevano sviluppare un progetto che coinvolgesse la programmazione embedded o la computer vision, scrivere un report e mostrare il progetto ai professori tramite una presentazione.
Un giorno, mentre stavamo ancora decidendo l’argomento del progetto, abbiamo ordinato da McDonald’s e abbiamo notato che con ogni Happy Meal venivano regalate delle carte Pokémon. Ne abbiamo presi tipo 5, e mentre mangiavamo ci è venuta l’idea del Pokédex.
Entrambi siamo cresciuti con i Pokémon e abbiamo giocato a molti dei titoli del franchise, quindi pensavamo che un Pokédex reale, anche se solo parzialmente funzionante, sarebbe stato troppo figo da realizzare.
Pokédex di Hoenn nella Generazione VI
Sviluppo
Lo sviluppo di questo progetto ha coinvolto diversi passaggi. L’obiettivo era creare un classificatore in grado di riconoscere (cioè effettuare il labeling) un Pokémon a partire da un’immagine e fornire informazioni su di esso. In particolare, l’immagine può rappresentare un Pokémon in varie forme, come peluche, bambole, action figure, carte, ecc.
Dataset
Quando abbiamo iniziato il progetto, il numero di Pokémon era già enorme, con circa 900 specie diverse. Creare un classificatore per un insieme così vasto di classi sarebbe stato semplicemente impossibile, dato che ci aspettavamo di incontrare alcuni problemi e di non trovare molti dati. Pertanto, abbiamo optato per un insieme più piccolo, limitando il campo ai “soli” 151 Pokémon della prima generazione.
Abbiamo iniziato cercando un dataset di Pokémon con alcune immagini etichettate, ma ne abbiamo trovato solo uno con circa 7000 immagini in totale. Ci siamo comunque ritenuti fortunati, ma dopo averlo analizzato più approfonditamente, abbiamo scoperto che era un bidone™: molte immagini erano etichettate in modo errato e non avevano lo stesso aspect ratio.
Poiché avevamo già deciso di portare avanti questo progetto, abbiamo deciso che valesse la pena provare a creare un nostro dataset, e alla fine abbiamo raccolto circa 12.000 immagini, che abbiamo accuratamente selezionato e ridimensionato per avere dimensioni uniformi.
Se hai un minimo di conoscenza sui classificatori, probabilmente starai pensando: “Come cacnea pensavate di creare un classificatore funzionante con 151 etichette e solo 12.000 immagini???”
MA, dato che era la nostra prima esperienza con il machine/deep learning, ancora non lo sapevamo, e almeno ci siamo divertiti a provarci.
Come previsto, trovare un dataset con informazioni sui Pokémon è stato molto più facile, grazie alla community enorme: abbiamo trovato un meraviglioso repository GitHub (fanzeyi/pokemon.json) contenente diversi file JSON con informazioni e sprite per i primi 809 Pokémon. Abbiamo estratto i primi 151 e li abbiamo estesi e personalizzati un po'.
Modello del Classificatore
Per implementare il classificatore, abbiamo deciso di adottare una Rete Neurale Convoluzionale (CNN), che abbiamo strutturato come mostrato nella figura seguente:
- Input layer: prende un’immagine con rapporto d’aspetto 224x224 e 3 canali di colore (RGB).
- 3 layer convoluzionali: con stride unitario, filtri convoluzionali con dimensione crescente (16, 32, 64) e kernel di dimensione 3. Questi servono per l’estrazione delle caratteristiche. Per ciascuno di essi il modello applica:
- Batch normalization;
- ReLU;
- Max pooling 2D.
- Flatten: riduce le dimensioni dell’input a 1.
- 2 Fully connected layers: applicano trasformazioni lineari attraverso una matrice ponderata.
- Softmax: ultima funzione di attivazione che converte il punteggio di ogni classe in una probabilità.
Per addestrare il modello abbiamo suddiviso il dataset in:
- traning set (80%), usato per imparare a riconoscere le classi di Pokémon;
- validation set (10%), usato per regolare gli iperparametri;
- test set (10%), usato per avere alcuni esempi su cui valutare il modello.
Addestramento
Per compilare il modello, abbiamo utilizzato l’ottimizzatore Adam (ADAptive Moment Estimation), che è estremamente robusto e tende a convergere anche con piccole variazioni nei parametri iper. Per valutare la loss, abbiamo utilizzato SparseCategoricalCrossentropy
da Keras.
model.compile(
optimizer='adam',
loss=tf.keras.losses.SparseCategoricalCrossentropy(),
metrics=['accuracy']
)
Abbiamo adottato l’early stopping per fermare l’addestramento quando il modello iniziava a degradarsi:
callback = tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True)
Dunque, abbiamo eseguito il fit del modello con epochs=100
e batch_size=64
:
history = model.fit(
train_dataset,
epochs=100,
callbacks=[callback],
steps_per_epoch=train_len // 64,
validation_data=val_dataset,
validation_steps=val_len // 64
)
L’addestramento è stato eseguito su una GPU Nvidia GTX 1060 6GB e ha impiegato circa 17 minuti.
Risultati dell’Addestramento
Train Loss | Train Acc | Val Loss | Val Acc | Test Loss | Test Acc | Epoche |
---|---|---|---|---|---|---|
0.0465 | 0.9901 | 0.0266 | 0.9929 | 0.0262 | 0.9933 | 72 |
Raspberry Pi
Per implementare il dispositivo fisico, avevamo bisogno di un sistema compatto in grado di eseguire un interprete TensorFlow, catturare immagini e visualizzare i risultati su uno schermo. Un Raspberry Pi era la scelta perfetta per le nostre esigenze, e poiché ne avevamo già uno, abbiamo deciso di utilizzarlo.
Hardware e Componenti
Avevamo già un Kit Raspberry che includeva:
- Raspberry Pi4 Model B
- micro SD 32GB classe 10
- alimentatore
Inoltre, abbiamo acquistato i seguenti componenti:
- PiCamera Rev 1.3 (5MP, 1080p)
- LCD display 3.5" HDMI con schermo touch resistivo
- mini altoparlanti
- pulsanti
- powerbank ultrasottile
Sistema Operativo
Per il sistema operativo abbiamo scelto Raspberry Pi OS 32-bit, la distribuzione ufficiale Linux per Raspberry Pi, che include firmware e driver necessari per interagire con le periferiche. Inoltre, questo sistema operativo supportava TensorFlow Lite 2.4, che ci serviva per eseguire la nostra CNN.
Custodia di Cartoncino
Per creare il nostro Pokédex, abbiamo deciso di realizzare una custodia di cartone 🌱. Ci siamo ispirati al Pokédex di Hoenn di Sesta Generazione, tenendo a mente che avrebbe dovuto contenere il Raspberry Pi e tutti i suoi componenti.
Applicazione Python
Infine, abbiamo sviluppato una semplice applicazione Python che carica il modello addestrato ed esegue la predizione: l’applicazione cattura un’immagine dal flusso video della PiCamera e la fornisce al modello che, utilizzando l’algoritmo k-max, restituisce l’etichetta più probabile. Poi, interrogando un dizionario, l’applicazione visualizza le informazioni del Pokémon.
Dipendenze
L’applicazione è stata sviluppata utilizzando Python 3.x e le seguenti librerie/moduli:
- Tkinter: un toolkit robusto utilizzato per creare l’interfaccia grafica (GUI).
- TensorFlow: una piattaforma open-source di machine learning end-to-end, utilizzata per caricare ed eseguire il classificatore.
- OpenCV: una libreria completa di computer vision, utilizzata per gestire l’input video dalla PiCamera.
Più altre librerie tra cui Pillow, Pygame, Gpiozero, Numpy, Sklearn, ecc.
Calibrazione della PiCamera
La PiCamera, come qualsiasi dispositivo in grado di catturare immagini, produce immagini con una certa distorsione dovuta alla lente. Questo fenomeno può essere mitigato calcolando i coefficienti di distorsione e la matrice della fotocamera. OpenCV fornisce un tutorial e il codice Python per trovare le proprietà intrinseche ed estrinseche di una fotocamera e per correggere le immagini: questo processo è chiamato Calibrazione della Fotocamera. Abbiamo seguito questo tutorial e salvato i nostri parametri in un file.
Per correggere le immagini, utilizziamo una funzione che applica questi parametri a ogni immagine scattata con la PiCamera:
import cv2
import numpy as np
def rectify_image(img):
camera_matrix = np.load("resources/camera_matrix.npy")
dist_coefs = np.load("resources/distortion_coefficients.npy")
h, w = img.shape[:2]
# Undistort the image
new_camera_matrix, roi = cv2.getOptimalNewCameraMatrix(camera_matrix, dist_coefs, (w, h), 1, (w, h))
dst = cv2.undistort(img, camera_matrix, dist_coefs, None, new_camera_matrix)
# Crop and Return the image
x, y, w, h = roi
dst = dst[y:y + h, x:x + w]
return dst
Classificazione
Il processo di classificazione segue questi passaggi:
L’applicazione cattura continuamente fotogrammi dalla PiCamera e li visualizza sull’interfaccia utente:
def update(self): if self.update_video: ret, frame = self.video.get_frame() if ret: self.photo = ImageTk.PhotoImage(image=Image.fromarray(frame).resize(image_size, Image.ANTIALIAS)) self.canvas_video.create_image(res_width/4, res_width/4, image=self.photo, anchor=tk.CENTER) self.window.after(self.delay, self.update)
Quando l’utente seleziona “Cerca”, l’applicazione salva il fotogramma corrente in memoria e lo corregge applicando i parametri di rettifica:
def search(self): ret, frame = self.video.get_frame() if self.var_flip_image.get(): frame = np.flip(frame, axis=1) cv2.imwrite("frame.jpg", cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)) frame = rectify_image(frame) cv2.imwrite("frame_undistorted.jpg", cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)) (pkmn, confidence) = pc.predict_top_n_pokemon("frame_undistorted.jpg", 1) pkmn = str(pkmn)[2:-2] confidence = str(confidence)[1:-1] self.load_pokemon(pkmn)
Poi, il classificatore esegue la predizione k-max sul fotogramma:
def predict_top_n_pokemon(image_filename, num_top_pokemon): # Predicts num_top_pokemon from image_file, using a tflite model interpreter = tf.lite.Interpreter("resources/model.tflite") interpreter.allocate_tensors() # Get input and output tensors input_details = interpreter.get_input_details() output_details = interpreter.get_output_details() # Load image and convert it to tensor if tf.__version__ == "2.6.0": # Open image with keras.utils from tensorflow.keras.utils import load_img, img_to_array img = load_img(image_filename, target_size=(224, 224)) #"./evee_1.jpg"5 img = img_to_array(img, dtype=np.float32) else: # Open image with PIL.Image img = Image.open(image_filename) img = img.resize((224, 224), Image.ANTIALIAS) img = np.asarray(img, dtype=np.float32) img /= 255 img = np.expand_dims(img, axis=0) input_tensor = np.array(img, dtype=np.float32) # Load TFLite model and allocate tensors interpreter.set_tensor(input_details[0]['index'], input_tensor) interpreter.invoke() # Get output output_data = interpreter.get_tensor(output_details[0]['index']) # Get label encoder label_encoder = get_label_encoder() # Get best num_top_pokemon (top_k_scores, top_k_idx) = tf.math.top_k(output_data, num_top_pokemon) top_k_scores = np.squeeze(top_k_scores.numpy(), axis=0) top_k_idx = np.squeeze(top_k_idx.numpy(), axis=0) top_k_labels = label_encoder.inverse_transform(top_k_idx) return top_k_labels, top_k_scores
L’applicazione interroga il dizionario di dati e visualizza le informazioni sul Pokémon, popolando i componenti dell’interfaccia utente.
def load_pokemon(self, pkmn_id): try: self.loaded_pokemon = self.pokemon_repo.pokemon[pkmn_id] self.load_image() self.load_name() self.load_id() self.load_types() self.load_description() self.load_stats() self.load_evolutions() self.load_cry() if self.settings.descr_voice: self.play_description() except KeyError: self.loaded_pokemon = None
Esempio
Membri del Team
Karina Chichifoi | Michele Righi |