(Petite) Voiture de Course Autonome avec une Arduino, une Raspberry Pi et une Pi Camera

Wed, Nov 29, 2017 by Antonin Raffin

Pour une compétition à Toulouse, un ami et moi avons développé et programmé un robot autonome de course fonctionnant à l’aide d’une Raspberry Pi, une Arduino Uno et une Pi Camera. Nous avons utilisé Python, C++ et un réseau de neurones pour le traitement d’images, fonctionnant en temps réel à plus de 60 images/s (FPS)!

Dans cet article, nous partageons notre expérience et donnons les clés pour reproduire la voiture.

Nous rendons public les modèles 3D , le code source (https://github.com/sergionr2/RacingRobot) ainsi que les données utilisées pour l’entraînement du robot.

Note : aucune connaissance en apprentissage automatique (machine learning) n’est requise pour lire cet article, bien que cela puisse aider à comprendre les détails du traitement d’image. Cette article est une traduction de l’article original (en anglais) disponible ici

Après une démonstration de la petite voiture, nous allons présenter brièvement la compétition et décrire les composants principaux de la voiture (matériel/logiciel). Enfin, nous verrons plus en détail le fonctionnement de la voiture : comme la rendre autonome.


Le Résultat

(regardez la vidéo en entier, il y a des bonus à la fin ;) )


La Course

La compétition est une course contre-la-montre autonome de 110 mètres, avec une ligne noire&blanche au centre. A cause des changements d’illumination, c’était un véritable défi de faire du traitement d’image plutôt que d’utiliser un capteur de distance (ex : un capteur à ultrasons).

Vue d’ensemble de la piste :

La piste à Toulouse :

Notre stratégie était de suivre la ligne du milieu. Cela permet de rester loin des bords et permet d’anticiper les tournants. Cependant, il faut que la ligne soit toujours visible et que le traitement soit robuste aux changements de lumière.

Vue de la caméra embarquée :

Le Matériel

Qu’est ce qu’une Raspberry Pi?

Une Raspberry Pi est un ordinateur de la taille d’une carte de crédit conçu à l’origine pour l’éducation. (source : opensource.com)

Qu’est ce qu’une Arduino ?

En résumé, une Arduino est une carte électronique de développement matériel ouverte qui peut être utilisée pour concevoir et construire des périphériques qui interagissent avec le monde réel. (source : opensource.com)

Modèle 3D pour le “fixe-batterie”

Modèle 3D du “fixe-caméra”

Logiciel

  • C++ pour l’Arduino
  • Python + Numpy + OpenCV pour la Raspberry Pi
  • Lasagne + Theano pour entraîner le réseau de neurones
  • Protocole Serial maison pour la communication Arduino <-> Raspberry Pi (cf Annexe)

Vue d’Ensemble de l’Architecture

Vue d’Ensemble de l’Architecture Logicielle :

Le traitement d’image et le contrôle sont réalisés sur la Raspberry Pi. Elle communique avec l’Arduino qui envoie les ordres aux moteurs (direction et vitesse) à l’aide de la modulation de largeur d’impulsion (PWM). Nous détaillons la communication entre l’Arduino et la Raspberry pi dans l’annexe.


Après une brève présentation de la simulation, nous expliquons dans la section suivante notre approche pour détecter la ligne et décrivons le contrôleur utilisé pour la suivre.


Simulateur

Nous avons créé un simulateur avec Blender et Python pour tester l’algorithme de vision ainsi que notre stratégie de contrôle. Le résultat, qui était prometteur, est visible ci-dessous. Pour s’assurer de la robustesse de notre approche, nous simulons également les vibrations sur l’image d’entrée.

Traitement d’Image : Détecter la Ligne

Pour faire en sorte que le robot reste sur la piste, il faut détecter la ligne et anticiper les virages : cela permet d’aller à vitesse maximale en ligne droite et de réduire la vitesse avant de tourner. Cela est réalisé en deux étapes grâce au traitement de l’image provenant de la caméra. Nous allons d’avoir voir comment prédire la courbe de la ligne (à quel point le virage est sec) une fois celle-ci détectée, puis nous nous attarderons sur les deux approches que nous avons examinées pour trouver le centre de la ligne.

Prédire la Courbe de la Ligne

Pour anticiper les virages et réduire le temps de calcul, nous faisons de la détection de ligne sur trois “patchs” ou régions d’intérêt (region of interest ou ROI en anglais).

Légende : sur la droite, on peut voir les trois patchs d’image ainsi que le résultat de la prédiction du centre de la ligne (cercle rouge). Sur la gauche, le résultat sur l’image d’entrée. La prédiction de la courbe de la ligne est en vert. La ligne bleue est une ligne de référence qui correspond à une ligne droite.

Nous ajustons ensuite une ligne (la ligne verte sur l’image ci-dessus) aux trois points obtenus, c’est à dire que nous essayons de trouver la droite qui passe par ces trois points, chaque point correspondant à une prédiction pour une région de l’image. Grâce à cette ligne, nous pouvons calculer l’angle entre celle-ci est une référence (en bleue ci-dessus), ce qui donne une estimation de la courbe de la ligne. Si la voiture est sur une ligne droite, la ligne verte (prédite) et la ligne de référence sont confondues.

Approche Basée Sur la Couleur

Notre approche initiale pour détecter la ligne était basée sur la couleur. L’idée générale est de trouver la plus grande région de l’image qui contient une couleur donnée puis de calculer le barycentre (“centre”) de cette région.

La première étape consiste à convertir l’image dans l’espace HSV (Hue Saturation Value), puis de calculer un masque de couleur avec des seuils pré-définis. Enfin, il faut trouver le barycentre du masque qui est par hypothèse le centre de la ligne.

Résultat du masque de couleur dans l’environnement simulé. Le cercle rouge correspond au centre de la ligne qui a été prédit et la ligne verte indique le contour du masque de couleur.

En 30 lignes de code python, cela ressemble à ça :

from __future__ import division

import cv2
import numpy as np
# Input Image
image = cv2.imread("my_image.jpg")
# Convert to HSV color space
hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV)
# Define range of white color in HSV
lower_white = np.array([0, 0, 212])
upper_white = np.array([131, 255, 255])
# Threshold the HSV image
mask = cv2.inRange(hsv, lower_white, upper_white)
# Remove noise
kernel_erode = np.ones((4,4), np.uint8)
eroded_mask = cv2.erode(mask, kernel_erode, iterations=1)
kernel_dilate = np.ones((6,6), np.uint8)
dilated_mask = cv2.dilate(eroded_mask, kernel_dilate, iterations=1)
# Find the different contours
im2, contours, hierarchy = cv2.findContours(dilated_mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
# Sort by area (keep only the biggest one)
contours = sorted(contours, key=cv2.contourArea, reverse=True)[:1]
if len(contours) > 0:
    M = cv2.moments(contours[0])
    # Centroid
    cx = int(M['m10']/M['m00'])
    cy = int(M['m01']/M['m00'])
    print("Centroid of the biggest area: ({}, {})".format(cx, cy))
else:
    print("No Centroid Found")

Approche Apprentissage Automatique (Machine Learning)

Le principal inconvénient de la méthode précédente est qu’elle n’est pas robuste aux changements d’illuminations. Nous avons essayé l’égalisation d’histogramme pour corriger ce problème mais ce n’était pas suffisant et coûteux en temps de calcul.

De ce fait, nous avons décidé d’utiliser une technique d’apprentissage pour détecter la linge, c’est à dire que nous voulions entraîner un modèle qui puisse prédire où se trouve la ligne à partir d’une image d’entrée. J’ai choisi d’utiliser un réseau de neurones car c’est une méthode que je maîtrise et qui est facile à implémenter en utilisant uniquement python et la bibliothèque numpy.

Aperçu du Problème de Régression

Lorsque l’on fait de l’apprentissage supervisé, c’est à dire lorsque l’on a des données étiquetées, le but est de prédire l’étiquette connaissant l’entrée (par exemple, prédire si une image contient un chat ou un chien – l’étiquette est “chat” ou “chien” et l’entrée est l’image). Dans notre cas, nous voulons prédire les coordonnées du centre de la ligne à partir de l’image de la caméra embarquée sur le robot.

Nous avons simplifié le problème en ne prédisant que la position du centre de la ligne le long de la largeur de l’image, c’est-à-dire que nous avons fait l’hypothèse que le centre de la ligne était situé à mi-hauteur de chaque “patch” d’image.

Pour évaluer la performance de notre modèle, nous choisi l’erreur quadratique moyenne (MSE en anglais): nous prenons l’erreur au carré entre la position prédite et al position réelle du centre de la ligne et faisons la moyenne sur l’ensemble des images de l’ensemble d’apprentissage.

Étiquetage des images

Après avoir enregistré une vidéo avec le mode télécommande, nous avons étiqueté à la main plus de 3000 photos (en ~25 minutes, soit 1 étiquette/s). Pour se faire, nous avons créer nous propre outil pour étiqueter les images.

Prétraitement et Augmentation des Données

Différentes étapes sont requises avant d’utiliser notre algorithme d’apprentissage sur les données. Tout d’abord, nous redimensionnons l’image pour réduire la dimension en entrée (par un facteur 4), cela permet de réduire drastiquement le nombre de paramètres à apprendre. Cela simplifie le problème et accélère à la fois l’apprentissage et le temps de prédiction.

Pour éviter des problèmes de saturation et accélérer l’entraînement, il est recommandé de normaliser les données. Dans notre cas, nous normalisons les images en entrée dans l’intervalle [-1, 1] et la sortie (la position du centre) dans [0, 1].

Le script de prétraitement des données est affiché ci-dessous :

def preprocessImage(image, width, height):
    """
    Preprocessing script to convert image into neural net input array
    :param image: (cv2 RBG image)
    :param width: (int)
    :param height: (int)
    :return: (numpy array)
    """
    image = cv2.resize(image, (width, height), interpolation=cv2.INTER_LINEAR)
    x = image.flatten()  # create a big 1D-array
    # Normalize
    x = x / 255. # values in [0, 1]
    x -= 0.5 # values in [-0.5, 0.5]
    x *= 2 # values in [-1, 1]
    return x

Pour augmenter le nombre d’images d’entraînement, nous avons retourné verticalement les images (image en miroir), ce qui a permis de multiplier la taille de l’ensemble d’apprentissage par deux de manière rapide et peu coûteuse.

Architecture du Réseau de Neurones

Nous avons utilisé un réseau feedforward composé de deux couches cachées avec respectivement 8 et 4 neurones. Bien que nous avons testé d’autres architectures (dont des réseaux convolutionels), celui-ci donnait les meilleurs résultats et pouvait tourner en temps réel à plus de 60 images/s !

Hyperparamètres

Les hyperparamètres sont des paramètres dans les valeurs sont fixées avant le commencement du processus d’apprentissage. Au contraire, les valeurs des autres paramètres sont apprises lors de l’entraînement. (Définition adaptée de Wikipedia)

Les hyperparamètres incluent l’architecture du modèle, le pas d’apprentissage, la taille des “minibatches”, …

Pour valider le choix des hyperparamètres, nous avons séparer le jeu de données en trois sous-ensembles : un ensemble d’apprentissage (60% des données), un ensemble de validation (20%) et un ensemble de test (20%). Nous avons gardé le modèle avec la plus petite erreur sur l’ensemble de validation et estimé l’erreur de généralisation sur l’ensemble de test. Le détail des ces hyperparamètres peut être trouvé en annexe. Le réseau a été entraîné en moins de 20 minutes sur un CPU (8-cœurs) de PC portable.

Le Contrôleur : Suivre la Ligne

Une fois que nous avons traité l’image et que nous avons calculé notre déviation par rapport au centre de la ligne, nous devons corriger notre erreur en régulant la direction et la vitesse de la voiture.

Pour cela, nous avons utilisé un contrôleur Proportionnel-Dérivée (PD)) afin de suivre la ligne. La vitesse de la voiture est régulée grâce à deux heuristiques : la déviation actuelle par rapport au centre de la ligne (qui est notre erreur) et la courbe de la ligne. Plus l’erreur est grande, plus petite est la vitesse (idem pour la courbe de la ligne).

L’idée de notre stratégie de contrôle peut être résumée en deux lignes :

    command = Kp * e + Kd * (de/dt)  # where "e" is the error
    speed = MIN_SPEED * h + (1 - h) * MAX_SPEED

    # WHERE h = 0 if it is a straight line and 1 if it's a sharp turn
    #      MAX_SPEED depends on the error "e" in the same manner,
    #      that is to say the bigger the error, the lower the MAX_SPEED

L’estimateur de la courbure de la ligne est assez bruité, nous avons donc décidé d’utiliser une moyenne glissante, ce qui a amélioré la stabilité du contrôle.

Tous les robots compétiteurs :

Pendant la course :

Conclusion

Nous avons décrit comment nous avons construit une voiture de course autonome, avec un contrôle basé sur la vision par ordinateur. Nous avons pris du plaisir à développer ce robot et nous voulions partager notre expérience. Notre but est que vous puissiez réutiliser ou vous inspirer de ce que nous avons fait.

Nous espérons que vous avez apprécié cet article, et n’oubliez pas, partagez c’est aimer =D.

Si vous avez une question, une remarque (ou si vous avez trouvé une faute de frappe) merci de laisser un commentaire sous l’article original.

Annexe

Hyperparamètres Utilisés

Le réseau de neurones a été entraîné avec Lasagne (bibliothèque haut niveau autour de Theano), pendant 1000 itérations (epochs), avec une taille de minibatch de 1, ADAM comme optimiseur avec un pas d’apprentissage initial de 1e-5. Un dropout avec une probabilité 0.1 est appliqué sur l’entrée ainsi qu’une pénalité L2 de 1e-4 sur les poids.

Communication Arduino — Raspberry Pi : Protocole Série

Pour faire la communication entre les deux cartes, et parce qu’Arduino ne propose pas de méthode efficace pour écrire sur le port série, nous avons utilisé un protocole Serial maison, basé sur l’envoie octet par octet que donne Arduino avec la méthode Serial.write().

Le protocole est le suivant : nous envoyons d’abord l’ordre sur un octet (8 bits) puis nous envoyons octet par octet les paramètres de la commande. Par exemple, pour envoyer un ordre aux moteurs, nous envoyons d’abord l’octet qui correspond à cette commande puis nous envoyons la vitesse désirée sur un autre octet.

Nous avons implémenté les fonctions qui permettent d’encoder et de décoder des entiers de différentes tailles (de un octet à quatre octets). Si l’on souhaite envoyer un entier codé sur 16 bits, nous le découpons d’abord en deux octets et les stockons dans un tableau (buffer array). Ensuite, nous envoyons chaque octet du tableau et reconstruisons les 16 bits à la réception à l’aide d’opérations bit à bit (décalage et masquage).

Notre implémentation tient également compte de la taille limitée du buffer de l’Arduino: si nous envoyons trop d’octets dans un petit laps de temps, une parti des messages sera perdu. Pour éviter ce problème, nous avons mis en place un système d’accusé de réception.

Ce protocole est implémenté en C++ et en Python. J’ai également créé un interpréteur interactif de commande (très pratique pour le debug ;)). Ce protocole a notamment été utilisé sur nous robots de la coupe de France de Robotique (où nous avons terminé 4ème/142 =D !).

Pourquoi avoir utilisé Python sur la Raspberry Pi ?

Au début du projet, nous avions prévu d’écrire tout en C++ pour des raisons de performance. Cependant, nous n’avons pas réussi à récupérer plus de 30 images par seconde (FPS) avec la bibliothèque raspicam, là où la bibliothèque Python picamera permet de récupérer jusqu’à 90 images par seconde.

Interface ROS

Je travaille actuellement sur la construction de nœuds ROS pour utiliser la voiture avec la caméra. Un exemple du mode de téléopération utilisant deux nœuds ROS peut être trouvé sur l’image linux fournie (cf README pour l’installation) ou sur la branche ROS.

Antonin RAFFIN

Ingénieur de Recherche en Robotique et Machine Learning