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???”

You’re goddamn right 😎
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 LossTrain AccVal LossVal AccTest LossTest AccEpoche
0.04650.99010.02660.99290.02620.993372

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:

  1. 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)
    
  2. 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)
    
  3. 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
    
  4. 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
Karina ChichifoiMichele Righi