Lors de notre Meetup MobileThings de juin 2018, nous avons montré comment utiliser Machine Learning et Réalité Augmentée afin de construire une application capable de reconnaître nos collègues Xebians et d’afficher un viseur virtuel autour de leurs têtes.
Notre POC, réalisé pour iOS, s’appuie sur les technologies Core ML et ARKit disponibles depuis iOS 11 et annoncées lors de la WWDC 2017.
Afin de compléter le talk présenté lors du meetup, dans cet article nous vous proposons un récapitulatif de l’usage que nous avons fait des technologies et des principaux détails d’implémentation.
Corps du billet :
Objectif du POC
Comme expliqué plut haut, le but de notre POC était de réaliser une application permettant d’identifier les Xebians qui sont en face de nous, en les encadrant avec l’appareil photo de notre téléphone et en affichant leurs noms respectifs à côté de leurs visages. Tout cela, en utilisant seulement des informations locales et sans s’appuyer sur aucun service en ligne.
Notre Proof of Concept se compose donc de deux niveaux :
- Un moteur basé sur le Machine Learning capable de reconnaître le visage
- Une couche de présentation (tridimensionnelle dans notre cas) permettant de visualiser indicateur et nom relatifs au Xebian reconnu
Dans cet article, nous allons nous concentrer exclusivement sur le point 1, la couche de présentation ayant déjà été décrite dans une série d’articles précédents publiés sur notre blog.
Le Machine Learning et la mobilité
L’intégration d’algorithmes issus du machine learning dans un téléphone mobile n’est pas une pratique des plus récentes, au contraire : les assistants vocaux ou les gestionnaires d’images sont deux des exemples les plus communs.
Ce qui est plus récent, par contre, est l’investissement des géants de l’IT sur des services et frameworks permettants aux développeurs d’intégrer facilement un réseau de neurones entraîné au sein de leurs logiciels nomades.
Cette vague technologique, dont les efforts de Google et Apple s’inscrivent, est la conséquence de deux facteurs :
- D’un côté l’amélioration dans le domaine de l’intelligence artificielle (la mise à point de nouveaux modèles en est un exemple)
- De l’autre côté, l’évolution extraordinaire des capacités de calcul des processeurs installés au sein des terminaux mobiles
Les progrès ci-dessus sont aujourd’hui à la base des initiatives de Google et Apple qui associent Machine Learning et Mobilité. En effet, à partir de 2017 les deux principaux développeurs de systèmes d’exploitation mobiles ont présenté des solutions et librairies tels que ML Kit, TensorFlow Lite, Vision et Core ML. Ces solutions simplifient sensiblement l’exécution d’algorithmes issus de modèles de machine learning tout en prenant en charge les problématiques de performances et de l’utilisation des ressources hardware du dispositif.
Reconnaissance faciale: un peu de théorie
La reconnaissance du visage d’un Xebian se base sur des recherches dans le domaine du Deep Learning.
Le postulat est que, en absence d’un jeu d’images suffisant pour reconnaître une personne, nous pouvons utiliser un modèle permettant de comparer deux visages et nous indiquer s’il s’agit ou pas de la même personne.
Le réseau de neurones associé au modèle produira, pour chaque visage, un vecteur de valeurs de type Float ; la comparaison (en distance euclidienne) entre les vecteurs issus de deux photos de visages appartenant à la même personne devra donc produire des valeurs proches de zéro. À l’inverse, la distance entre deux images des visages de deux individus différents devra être élevée.Core ML nous permettra donc de générer, à partir de la personne encadrée par notre appareil photo, un vecteur représentant le visage. Nous comparerons ensuite ce vecteur avec un référentiel que nous aurons préalablement chargé en local.
Notre approche
Le processus permettant de produire, à partir d’une image capturée avec notre téléphone, le nom du Xebian photographié est implémenté en 4 étapes :
- Récupération de l’image du capteur photo
- Identification de tous les visages (Xebians ou pas) sur l’image
- Pour chaque visage, calcul du vecteur à l’aide de Core ML
- Comparaison du vecteur de représentation avec notre référentiel local de vecteurs et restitution du Xebian ayant le vecteur de référence le plus proche au vecteur calculé.
Récupération de l’image et identification des visages
L’application devant s’intégrer à un contexte ARKit, nous pouvons accéder au stream de pixels du capteur, le CVPixelBuffer
.
Nous pouvons désormais utiliser le buffer pour trouver les visages présents dans l’image.
L’opération est simple : en effet, le framework Vision nous fournit des abstractions très pratiques pour les tâches les plus communes de reconnaissance d’image. Dans notre cas, nous nous sommes intéressés à une requête capable d’identifier les Face Rectangles, c-a-d, les zones de l’image qui contiennent un visage, sous forme de coordonnées CGRect
.
Cette requête est la VNDetectFaceRectanglesRequest
et on peut s’en servir comme suit :
let imageRequestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .right, options: [:]) let faceRectanglesRequest = VNDetectFaceRectanglesRequest() do { try imageRequestHandler.perform([faceRectanglesRequest]) guard let results = request.results as? [VNFaceObservation] else { return } // Utiliser ici les results } catch { // Gerer ici les erreurs issus de .perform(:) }
Le modèle étant spécialisé sur les visages uniquement, nous aurons besoin de découper l’image de départ, afin de pouvoir fournir à notre réseau de neurones une photo contenant exclusivement un FaceRectangle. Par conséquent, une fois obtenu le résultat, nous allons rogner le pixelBuffer
en utilisant les coordonnées exprimées par la boundingBo
x
:
func createFaceRequest(boundTo boundingBox: CGRect, pixelBuffer: CVPixelBuffer, rectangleUuid: UUID) -> Result { let ciImage = CIImage(cvPixelBuffer: pixelBuffer).oriented(.right) let transformedBox = boundingBox.transformed(toExtentSize: ciImage.extent.size) let croppedImage = ciImage.cropped(to: transformedBox) // Nous pouvons utiliser l'image rognée par la suite // ... }
Il est maintenant temps de faire passer cette nouvelle image à travers notre couche ML custom.
Intégration du modèle entrainé dans l’application
Concernant les premiers pas sur Core ML, il existe déjà plusieurs tutoriels bien expliquant comment installer le modèle entraîné dans notre application.
Il s’agit, la plupart des fois, d’une opération simple, automatisée par Xcode : il suffit de prendre un fichier .mlmodel, de le déposer dans un projet iOS ou macOS et le logiciel fera le reste, générant le code nécessaire à l’utilisation du réseau de neurones depuis un projet Swift ou Objective-C. Par exemple, un modèle nommé facenet_coreml sera utilisable dans notre application avec le code suivant facenet_keras_weights_coreml().model
.
En effet, difficile de faire plus simple : l’expérience développeur de Core ML est indéniablement au rendez-vous.
Un pas en arrière : création du modèle Core ML
Si le cas nominal ne requiert aucune compétence spécifique, là où les choses se compliquent c’est lors de la conversion d’un modèle préexistant en modèle Core ML.
En effet, dans la quasi-totalité des cas, vos collègues Data Scientists se basent sur des frameworks d’apprentissage qui ne sont pas supportés par iOS ou macOS comme Keras, TensorFlow, Caffe et bien d’autres. Une conversion se rendra donc nécessaire.
Pour ce faire, Apple met à disposition un outil Open Source nommé CoreMLTools, écrit en Python. Des connaissances basiques dans le domaine du Machine Learning sont tout de même requises mais les cas d’usage les plus simples sont suffisamment bien documentés sur la page du projet.
Dans notre application, cependant, le modèle à convertir présente des aspects nécessitant une configuration plus avancée de l’étape de conversion.
Conversion de notre modèle
Dans notre modèle en particulier nous avons deux problématiques à résoudre :
- La conversion des données d’input au format attendu
- La prise en charge d’un layer d’opérations « custom »
1. Conversion de l’input
Comme évoqué, le modèle Keras d’origine prend en entrée une image pour produire ensuite un vecteur de 128 float. Nous avons par contre besoin de préciser le format de l’image qu’on soumet au modèle : s’agit-il d’une représentation RGBA des pixels ou a-t-on juste besoin des niveaux de gris ? Ou plutôt, encore, des valeurs de l’écart type ?
Dans notre cas, le modèle Keras nécessite la représentation normalisée de l’image sur des valeurs entre -1 et +1 pour chaque canal de couleur et il faudra donc paramétrer le script de conversion en conséquence.
Nous allons donc écrire
image_scale=2/255.0, red_bias=-1, green_bias=-1, blue_bias=-1,
2. Layers Custom
Encore plus important, notre modèle Keras d’origine utilise des opérations « personnalisées » qui ne sont pas incluses dans le framework et qui ont été rajoutées par le Data Scientist. Ces opérations ne sont donc pas prises en charge par coremltools, qui ne saura pas comment les traiter.
La solution prévue par Core ML est donc d’implémenter la couche manquante dans notre application, en l’implémentant en Swift ou en Objective-C.
Dans notre cas, il suffit d’implémenter une simple fonction de scaling, de type f(x) = x * c
où c
est une constante.
Pour ce faire, nous allons écrire dans le script de conversion :
add_custom_layers=True, custom_conversion_functions={ "Lambda": convert_lambda }) // Définition de la fonction de conversion du layer "scaling" def convert_lambda(layer): if layer.function == scaling: params = NeuralNetwork_pb2.CustomLayerParams() params.className = "scaling" params.parameters["scale"].doubleValue = layer.arguments['scale'] return params else: return None
Côté application le layer custom sera implémenté comme suit :
@objc(scaling) class Scaling: NSObject, MLCustomLayer { let scale: Double required init(parameters: [String : Any]) throws { if let scale = parameters["scale"] as? Double { self.scale = scale } else { self.scale = 1 } super.init() } func setWeightData(_ weights: [Data]) throws { print(#function, weights) } func outputShapes(forInputShapes inputShapes: [[NSNumber]]) throws -> [[NSNumber]] { return inputShapes } func evaluate(inputs: [MLMultiArray], outputs: [MLMultiArray]) throws { for i in 0..<inputs.count { let input = inputs[i] let output = outputs[i] for j in 0..<input.count { let x = input[j].doubleValue let y = x * scale output[j] = NSNumber(value: y) } } } }
N’oubliez pas par contre qu’il s’agit d’une implémentation naïve, fonctionnelle, mais limitant la rapidité d’exécution de l’algorithme.
Une formulation permettant une exécution plus rapide devra impérativement prendre en compte les opérations SIMD de calcul vectoriel de la CPU, à travers le framework Accelerate ou son wrapper Surge. Une deuxième alternative, encore plus performante, consiste à développer un shader Metal personnalisé, capable d’exploiter la capacité de calcul mathématique de votre GPU.
L’implémentation complète du script Python de conversion vous permettra de consulter la totalité des dépendances importées.
Du vecteur au Xebian
La dernière étape est extrêmement simple, en tout cas pour un jeu réduit de données.
Comme évoqué, nous avons créé au préalable une liste locale, contenant une association entre Xebian et vecteur de référence :
ID du Xebian | Float<128>
Il ne reste donc plus qu’à parcourir la liste et renvoyer l’élément pour lequel la distance entre vecteur de référence et vecteur calculé par Core ML est moindre.
Conclusion
Dans cet article nous avons vu comment intégrer et exécuter un modèle Core ML custom au sein de notre application, dans un cas d’usage réel. Comme évoqué, nous n’avons pas abordé ici la manière d’améliorer les performances d’exécution, ce qui est l’objet d’une section de nos talks au meetup Mobile Things de mai 2018 et à la XebiCon ’18.