Tutoriel iOS ARKit : Dessiner dans les airs avec les doigts nus
Publié: 2022-03-11Récemment, Apple a annoncé sa nouvelle bibliothèque de réalité augmentée (AR) nommée ARKit. Pour beaucoup, cela ressemblait à une autre bonne bibliothèque AR, pas à un perturbateur technologique dont il fallait se soucier. Cependant, si vous jetez un coup d'œil aux progrès de la RA au cours des deux dernières années, il ne faut pas être trop rapide pour tirer de telles conclusions.
Dans cet article, nous allons créer un exemple de projet ARKit amusant à l'aide d'iOS ARKit. L'utilisateur placera ses doigts sur une table comme s'il tenait un stylo, appuyez sur la vignette et commencez à dessiner. Une fois terminé, l'utilisateur pourra transformer son dessin en un objet 3D, comme le montre l'animation ci-dessous. Le code source complet de notre exemple iOS ARKit est disponible sur GitHub.
Pourquoi devrions-nous nous soucier d'iOS ARKit maintenant ?
Chaque développeur expérimenté est probablement conscient du fait que la réalité augmentée est un vieux concept. Nous pouvons identifier le premier développement sérieux de la RA au moment où les développeurs ont eu accès aux images individuelles des webcams. Les applications à cette époque étaient généralement utilisées pour transformer votre visage. Cependant, l'humanité n'a pas tardé à réaliser que transformer des visages en lapins n'était pas l'un de ses besoins les plus imminents, et bientôt le battage médiatique s'est estompé !
Je pense que la réalité augmentée a toujours manqué deux sauts technologiques clés pour la rendre utile : la convivialité et l'immersion. Si vous avez suivi d'autres hypes AR, vous le remarquerez. Par exemple, le battage médiatique de la réalité augmentée a de nouveau décollé lorsque les développeurs ont eu accès à des images individuelles à partir de caméras mobiles. Outre le retour en force des grands transformateurs de lapin, nous avons vu une vague d'applications qui déposent des objets 3D sur des codes QR imprimés. Mais ils n'ont jamais décollé en tant que concept. Il ne s'agissait pas de réalité augmentée, mais plutôt de codes QR augmentés.
Ensuite, Google nous a surpris avec un morceau de science-fiction, Google Glass. Deux ans se sont écoulés, et au moment où ce produit étonnant devait voir le jour, il était déjà mort ! De nombreux critiques ont analysé les raisons de l'échec de Google Glass, blâmant tout, des aspects sociaux à l'approche ennuyeuse de Google lors du lancement du produit. Cependant, nous nous soucions de cet article pour une raison particulière - l'immersion dans l'environnement. Bien que Google Glass ait résolu le problème de convivialité, il ne s'agissait toujours que d'une image 2D tracée dans les airs.
Des titans de la technologie comme Microsoft, Facebook et Apple ont appris cette dure leçon par cœur. En juin 2017, Apple a annoncé sa magnifique bibliothèque iOS ARKit, faisant de l'immersion sa priorité absolue. Tenir un téléphone est toujours un gros obstacle à l'expérience utilisateur, mais la leçon de Google Glass nous a appris que le matériel n'est pas le problème.
Je pense que nous nous dirigeons très bientôt vers un nouveau pic de battage publicitaire AR, et avec ce nouveau pivot important, il pourrait éventuellement trouver son marché domestique, permettant à davantage de développement d'applications AR de devenir grand public. Cela signifie également que chaque société de développement d'applications de réalité augmentée pourra exploiter l'écosystème et la base d'utilisateurs d'Apple.
Mais assez d'histoire, mettons-nous la main à la pâte avec le code et voyons la réalité augmentée d'Apple en action !
Fonctionnalités d'immersion d'ARKit
ARKit fournit deux fonctionnalités principales ; le premier est l'emplacement de la caméra dans l'espace 3D et le second est la détection du plan horizontal. Pour réaliser le premier, ARKit suppose que votre téléphone est une caméra se déplaçant dans l'espace 3D réel de sorte que la chute d'un objet virtuel 3D à n'importe quel point sera ancrée à ce point dans l'espace 3D réel. Et pour ce dernier, ARKit détecte les plans horizontaux comme des tables afin que vous puissiez y placer des objets.
Alors, comment ARKit y parvient-il ? Cela se fait grâce à une technique appelée Visual Inertial Odometry (VIO). Ne vous inquiétez pas, tout comme les entrepreneurs trouvent leur plaisir dans le nombre de rires que vous rigolez lorsque vous découvrez la source derrière leur nom de startup, les chercheurs trouvent le leur dans le nombre de grattements de tête que vous faites en essayant de déchiffrer n'importe quel terme qu'ils trouvent quand nommer leurs inventions - alors laissons-les s'amuser et passer à autre chose.
VIO est une technique par laquelle les cadres de caméra sont fusionnés avec des capteurs de mouvement pour suivre l'emplacement de l'appareil dans l'espace 3D. Le suivi du mouvement à partir des images de la caméra se fait en détectant des caractéristiques ou, en d'autres termes, des points de bord dans l'image avec un contraste élevé - comme le bord entre un vase bleu et une table blanche. En détectant de combien ces points se sont déplacés les uns par rapport aux autres d'une image à l'autre, on peut estimer où se trouve l'appareil dans l'espace 3D. C'est pourquoi ARKit ne fonctionnera pas correctement lorsqu'il est placé face à un mur blanc sans relief ou lorsque l'appareil se déplace très rapidement, ce qui entraîne des images floues.
Premiers pas avec ARKit dans iOS
Au moment de la rédaction de cet article, ARKit fait partie d'iOS 11, qui est toujours en version bêta. Donc, pour commencer, vous devez télécharger iOS 11 Beta sur iPhone 6s ou supérieur, et le nouveau Xcode Beta. Nous pouvons démarrer un nouveau projet ARKit à partir de Nouveau > Projet > Application de réalité augmentée . Cependant, j'ai trouvé plus pratique de commencer ce didacticiel de réalité augmentée avec l'exemple officiel d'Apple ARKit, qui fournit quelques blocs de code essentiels et est particulièrement utile pour la détection d'avions. Alors, commençons par cet exemple de code, expliquons d'abord les points principaux, puis modifions-le pour notre projet.
Tout d'abord, nous devons déterminer quel moteur nous allons utiliser. ARKit peut être utilisé avec Sprite SceneKit ou Metal. Dans l'exemple Apple ARKit, nous utilisons iOS SceneKit, un moteur 3D fourni par Apple. Ensuite, nous devons configurer une vue qui rendra nos objets 3D. Cela se fait en ajoutant une vue de type ARSCNView
.
ARSCNView
est une sous-classe de la vue principale de SceneKit nommée SCNView
, mais elle étend la vue avec quelques fonctionnalités utiles. Il rend le flux vidéo en direct de la caméra de l'appareil comme arrière-plan de la scène, tandis qu'il adapte automatiquement l'espace SceneKit au monde réel, en supposant que l'appareil est une caméra en mouvement dans ce monde.
ARSCNView
n'effectue pas de traitement AR seul, mais il nécessite un objet de session AR qui gère la caméra de l'appareil et le traitement des mouvements. Donc, pour commencer, nous devons attribuer une nouvelle session :
self.session = ARSession() sceneView.session = session sceneView.delegate = self setupFocusSquare()
La dernière ligne ci-dessus ajoute un indicateur visuel qui aide visuellement l'utilisateur à décrire l'état de la détection d'avion. Focus Square est fourni par l'exemple de code, pas la bibliothèque ARKit, et c'est l'une des principales raisons pour lesquelles nous avons commencé avec cet exemple de code. Vous pouvez en savoir plus à ce sujet dans le fichier readme inclus dans l'exemple de code. L'image suivante montre un carré de focus projeté sur une table :
L'étape suivante consiste à démarrer la session ARKit. Il est logique de redémarrer la session à chaque fois que la vue apparaît car nous ne pouvons pas utiliser les informations de la session précédente si nous ne suivons plus l'utilisateur. Nous allons donc démarrer la session dans viewDidAppear :
override func viewDidAppear(_ animated: Bool) { let configuration = ARWorldTrackingSessionConfiguration() configuration.planeDetection = .horizontal session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) }
Dans le code ci-dessus, nous commençons par définir la configuration de la session ARKit pour détecter les plans horizontaux. Au moment de la rédaction de cet article, Apple ne propose pas d'autres options que celle-ci. Mais apparemment, cela fait allusion à la détection d'objets plus complexes à l'avenir. Ensuite, nous commençons à exécuter la session et nous nous assurons de réinitialiser le suivi.
Enfin, nous devons mettre à jour le carré de mise au point chaque fois que la position de la caméra, c'est-à-dire l'orientation ou la position réelle de l'appareil, change. Cela peut être fait dans la fonction déléguée de rendu de SCNView, qui est appelée chaque fois qu'une nouvelle image du moteur 3D va être rendue :
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { updateFocusSquare() }
À ce stade, si vous exécutez l'application, vous devriez voir le carré de mise au point sur le flux de la caméra à la recherche d'un plan horizontal. Dans la section suivante, nous expliquerons comment les avions sont détectés et comment nous pouvons positionner le carré de mise au point en conséquence.
Détection d'avions dans ARKit
ARKit peut détecter de nouveaux avions, mettre à jour ceux existants ou les supprimer. Afin de gérer les plans de manière pratique, nous allons créer un nœud SceneKit factice contenant les informations de position du plan et une référence au carré de mise au point. Les plans sont définis dans les directions X et Z, où Y est la normale de la surface, c'est-à-dire que nous devons toujours conserver les positions de nos nœuds de dessin dans la même valeur Y du plan si nous voulons qu'il ait l'air d'être imprimé sur le plan .
La détection des avions se fait grâce aux fonctions de rappel fournies par ARKit. Par exemple, la fonction de rappel suivante est appelée chaque fois qu'un nouvel avion est détecté :
var planes = [ARPlaneAnchor: Plane]() func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { if let planeAnchor = anchor as? ARPlaneAnchor { serialQueue.async { self.addPlane(node: node, anchor: planeAnchor) self.virtualObjectManager.checkIfObjectShouldMoveOntoPlane(anchor: planeAnchor, planeAnchorNode: node) } } } func addPlane(node: SCNNode, anchor: ARPlaneAnchor) { let plane = Plane(anchor) planes[anchor] = plane node.addChildNode(plane) } ... class Plane: SCNNode { var anchor: ARPlaneAnchor var focusSquare: FocusSquare? init(_ anchor: ARPlaneAnchor) { self.anchor = anchor super.init() } ... }
La fonction de rappel nous fournit deux paramètres, anchor
et node
. Le node
est un nœud SceneKit normal placé à la position et à l'orientation exactes du plan. Il n'a pas de géométrie, il est donc invisible. Nous l'utilisons pour ajouter notre propre nœud de plan, qui est également invisible, mais contient des informations sur l'orientation et la position du plan dans anchor
.
Alors, comment la position et l'orientation sont-elles enregistrées dans ARPlaneAnchor
? La position, l'orientation et l'échelle sont toutes codées dans une matrice 4x4. Si j'ai la chance de choisir un concept mathématique à vous apprendre, ce sera sans aucun doute les matrices. Quoi qu'il en soit, nous pouvons contourner cela en décrivant cette matrice 4x4 comme suit : Un tableau brillant à 2 dimensions contenant des nombres à virgule flottante 4x4. En multipliant ces nombres d'une certaine manière par un sommet 3D, v1, dans son espace local, il en résulte un nouveau sommet 3D, v2, qui représente v1 dans l'espace mondial. Donc, si v1 = (1, 0, 0) dans son espace local, et qu'on veut le placer à x = 100 dans l'espace monde, v2 sera égal à (101, 0, 0) par rapport à l'espace monde. Bien sûr, les calculs derrière cela deviennent plus complexes lorsque nous ajoutons des rotations autour des axes, mais la bonne nouvelle est que nous pouvons nous passer de le comprendre (je recommande fortement de consulter la section pertinente de cet excellent article pour une explication approfondie de ce concept ).

checkIfObjectShouldMoveOntoPlane
vérifie si nous avons déjà des objets dessinés et vérifie si l'axe y de tous ces objets correspond à celui des plans nouvellement détectés.
Revenons maintenant à updateFocusSquare()
, décrit dans la section précédente. Nous voulons garder la mise au point carrée au centre de l'écran, mais projetée sur le plan détecté le plus proche. Le code ci-dessous le démontre :
func updateFocusSquare() { let worldPos = worldPositionFromScreenPosition(screenCenter, self.sceneView) self.focusSquare?.simdPosition = worldPos } func worldPositionFromScreenPosition(_ position: CGPoint, in sceneView: ARSCNView) -> float3? { let planeHitTestResults = sceneView.hitTest(position, types: .existingPlaneUsingExtent) if let result = planeHitTestResults.first { return result.worldTransform.translation } return nil }
sceneView.hitTest
recherche les plans du monde réel correspondant à un point 2D dans la vue d'écran en projetant ce point 2D sur le plan inférieur le plus proche. result.worldTransform
est une matrice 4x4 qui contient toutes les informations de transformation du plan détecté, tandis que result.worldTransform.translation
est une fonction pratique qui renvoie uniquement la position.
Maintenant, nous avons toutes les informations dont nous avons besoin pour déposer un objet 3D sur des surfaces détectées en fonction d'un point 2D sur l'écran. Alors, commençons à dessiner.
Dessin
Expliquons d'abord l'approche de dessiner des formes qui suit le doigt d'un humain en vision par ordinateur. Le dessin des formes se fait en détectant chaque nouvel emplacement pour le doigt en mouvement, en déposant un sommet à cet emplacement et en connectant chaque sommet au précédent. Les sommets peuvent être connectés par une simple ligne, ou par une courbe de Bézier si nous avons besoin d'une sortie lisse.
Pour plus de simplicité, nous suivrons une approche un peu naïve pour le dessin. Pour chaque nouvel emplacement du doigt, on déposera une toute petite case aux coins arrondis et de hauteur quasi nulle sur le plan détecté. Il apparaîtra comme s'il s'agissait d'un point. Une fois que l'utilisateur a terminé de dessiner et sélectionné le bouton 3D, nous modifierons la hauteur de tous les objets déposés en fonction du mouvement du doigt de l'utilisateur.
Le code suivant montre la classe PointNode
qui représente un point :
let POINT_SIZE = CGFloat(0.003) let POINT_HEIGHT = CGFloat(0.00001) class PointNode: SCNNode { static var boxGeo: SCNBox? override init() { super.init() if PointNode.boxGeo == nil { PointNode.boxGeo = SCNBox(width: POINT_SIZE, height: POINT_HEIGHT, length: POINT_SIZE, chamferRadius: 0.001) // Setup the material of the point let material = PointNode.boxGeo!.firstMaterial material?.lightingModel = SCNMaterial.LightingModel.blinn material?.diffuse.contents = UIImage(named: "wood-diffuse.jpg") material?.normal.contents = UIImage(named: "wood-normal.png") material?.specular.contents = UIImage(named: "wood-specular.jpg") } let object = SCNNode(geometry: PointNode.boxGeo!) object.transform = SCNMatrix4MakeTranslation(0.0, Float(POINT_HEIGHT) / 2.0, 0.0) self.addChildNode(object) } . . . }
Vous remarquerez dans le code ci-dessus que nous traduisons la géométrie le long de l'axe y de la moitié de la hauteur. La raison en est de s'assurer que le bas de l'objet est toujours à y = 0 , de sorte qu'il apparaisse au-dessus du plan.
Ensuite, dans la fonction de rappel du rendu de SceneKit, nous allons dessiner un indicateur qui agit comme un point de pointe de stylo, en utilisant la même classe PointNode
. Nous déposerons un point à cet endroit si le dessin est activé, ou élèverons le dessin dans une structure 3D si le mode 3D est activé :
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { updateFocusSquare() // Setup a dot that represents the virtual pen's tippoint if (self.virtualPenTip == nil) { self.virtualPenTip = PointNode(color: UIColor.red) self.sceneView.scene.rootNode.addChildNode(self.virtualPenTip!) } // Draw if let screenCenterInWorld = worldPositionFromScreenPosition(self.screenCenter, self.sceneView) { // Update virtual pen position self.virtualPenTip?.isHidden = false self.virtualPenTip?.simdPosition = screenCenterInWorld // Draw new point if (self.inDrawMode && !self.virtualObjectManager.pointNodeExistAt(pos: screenCenterInWorld)){ let newPoint = PointNode() self.sceneView.scene.rootNode.addChildNode(newPoint) self.virtualObjectManager.loadVirtualObject(newPoint, to: screenCenterInWorld) } // Convert drawing to 3D if (self.in3DMode ) { if self.trackImageInitialOrigin != nil { DispatchQueue.main.async { let newH = 0.4 * (self.trackImageInitialOrigin!.y - screenCenterInWorld.y) / self.sceneView.frame.height self.virtualObjectManager.setNewHeight(newHeight: newH) } } else { self.trackImageInitialOrigin = screenCenterInWorld } } }
virtualObjectManager
est une classe qui gère les points dessinés. En mode 3D, nous estimons la différence par rapport à la dernière position et augmentons/diminuons la hauteur de tous les points avec cette valeur.
Jusqu'à présent, nous dessinons sur la surface détectée en supposant que le stylo virtuel est au centre de l'écran. Passons maintenant à la partie amusante : détecter le doigt de l'utilisateur et l'utiliser à la place du centre de l'écran.
Détection du bout du doigt de l'utilisateur
L'une des bibliothèques intéressantes introduites par Apple dans iOS 11 est Vision Framework. Il fournit des techniques de vision par ordinateur d'une manière assez pratique et efficace. Nous allons notamment utiliser la technique de tracking d'objets pour notre tutoriel de réalité augmentée. Le suivi d'objet fonctionne comme suit : nous lui fournissons d'abord une image et les coordonnées d'un carré dans les limites de l'image pour l'objet que nous voulons suivre. Après cela, nous appelons une fonction pour initialiser le suivi. Enfin, nous alimentons une nouvelle image dans laquelle la position de cet objet a changé et le résultat d'analyse de l'opération précédente. Compte tenu de cela, il nous renverra le nouvel emplacement de l'objet.
Nous allons utiliser une petite astuce. Nous demanderons à l'utilisateur de poser sa main sur la table comme s'il tenait un stylo et de s'assurer que sa vignette fait face à la caméra, après quoi il doit appuyer sur sa vignette à l'écran. Il y a deux points à développer ici. Tout d'abord, la vignette doit avoir suffisamment de caractéristiques uniques pour être tracées via le contraste entre la vignette blanche, la peau et le tableau. Cela signifie qu'un pigment de peau plus foncé se traduira par un suivi plus fiable. Deuxièmement, étant donné que l'utilisateur pose ses mains sur la table et que nous détectons déjà la table comme un plan, la projection de l'emplacement de la vignette de la vue 2D vers l'environnement 3D se traduira par l'emplacement presque exact du doigt sur le table.
L'image suivante montre les points caractéristiques qui pourraient être détectés par la bibliothèque Vision :
Nous allons initialiser le suivi des vignettes en un geste de toucher comme suit :
// MARK: Object tracking fileprivate var lastObservation: VNDetectedObjectObservation? var trackImageBoundingBox: CGRect? let trackImageSize = CGFloat(20) @objc private func tapAction(recognizer: UITapGestureRecognizer) { lastObservation = nil let tapLocation = recognizer.location(in: view) // Set up the rect in the image in view coordinate space that we will track let trackImageBoundingBoxOrigin = CGPoint(x: tapLocation.x - trackImageSize / 2, y: tapLocation.y - trackImageSize / 2) trackImageBoundingBox = CGRect(origin: trackImageBoundingBoxOrigin, size: CGSize(width: trackImageSize, height: trackImageSize)) let t = CGAffineTransform(scaleX: 1.0 / self.view.frame.size.width, y: 1.0 / self.view.frame.size.height) let normalizedTrackImageBoundingBox = trackImageBoundingBox!.applying(t) // Transfrom the rect from view space to image space guard let fromViewToCameraImageTransform = self.sceneView.session.currentFrame?.displayTransform(withViewportSize: self.sceneView.frame.size, orientation: UIInterfaceOrientation.portrait).inverted() else { return } var trackImageBoundingBoxInImage = normalizedTrackImageBoundingBox.applying(fromViewToCameraImageTransform) trackImageBoundingBoxInImage.origin.y = 1 - trackImageBoundingBoxInImage.origin.y // Image space uses bottom left as origin while view space uses top left lastObservation = VNDetectedObjectObservation(boundingBox: trackImageBoundingBoxInImage) }
La partie la plus délicate ci-dessus est de savoir comment convertir l'emplacement du robinet de l'espace de coordonnées UIView à l'espace de coordonnées de l'image. ARKit nous fournit la matrice displayTransform
qui convertit l'espace de coordonnées de l'image en un espace de coordonnées de la fenêtre d'affichage, mais pas l'inverse. Alors, comment pouvons-nous faire l'inverse? En utilisant l'inverse de la matrice. J'ai vraiment essayé de minimiser l'utilisation des mathématiques dans ce post, mais c'est parfois inévitable dans le monde 3D.
Ensuite, dans le moteur de rendu, nous allons alimenter une nouvelle image pour suivre le nouvel emplacement du doigt :
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { // Track the thumbnail guard let pixelBuffer = self.sceneView.session.currentFrame?.capturedImage, let observation = self.lastObservation else { return } let request = VNTrackObjectRequest(detectedObjectObservation: observation) { [unowned self] request, error in self.handle(request, error: error) } request.trackingLevel = .accurate do { try self.handler.perform([request], on: pixelBuffer) } catch { print(error) } . . . }
Une fois le suivi d'objet terminé, il appellera une fonction de rappel dans laquelle nous mettrons à jour l'emplacement de la vignette. Il s'agit généralement de l'inverse du code écrit dans la reconnaissance de prise :
fileprivate func handle(_ request: VNRequest, error: Error?) { DispatchQueue.main.async { guard let newObservation = request.results?.first as? VNDetectedObjectObservation else { return } self.lastObservation = newObservation var trackImageBoundingBoxInImage = newObservation.boundingBox // Transfrom the rect from image space to view space trackImageBoundingBoxInImage.origin.y = 1 - trackImageBoundingBoxInImage.origin.y guard let fromCameraImageToViewTransform = self.sceneView.session.currentFrame?.displayTransform(withViewportSize: self.sceneView.frame.size, orientation: UIInterfaceOrientation.portrait) else { return } let normalizedTrackImageBoundingBox = trackImageBoundingBoxInImage.applying(fromCameraImageToViewTransform) let t = CGAffineTransform(scaleX: self.view.frame.size.width, y: self.view.frame.size.height) let unnormalizedTrackImageBoundingBox = normalizedTrackImageBoundingBox.applying(t) self.trackImageBoundingBox = unnormalizedTrackImageBoundingBox // Get the projection if the location of the tracked image from image space to the nearest detected plane if let trackImageOrigin = self.trackImageBoundingBox?.origin { self.lastFingerWorldPos = self.virtualObjectManager.worldPositionFromScreenPosition(CGPoint(x: trackImageOrigin.x - 20.0, y: trackImageOrigin.y + 40.0), in: self.sceneView) } } }
Enfin, nous utiliserons self.lastFingerWorldPos
au lieu du centre de l'écran lors du dessin, et nous avons terminé.
ARKit et le futur
Dans cet article, nous avons démontré comment la réalité augmentée pouvait être immersive grâce à l'interaction avec les doigts des utilisateurs et des tables réelles. Avec plus de progrès dans la vision par ordinateur et en ajoutant plus de matériel compatible AR aux gadgets (comme les caméras de profondeur), nous pouvons accéder à la structure 3D de plus en plus d'objets qui nous entourent.
Bien qu'il ne soit pas encore diffusé au grand public, il convient de mentionner à quel point Microsoft est très sérieux pour gagner la course AR grâce à son appareil Hololens, qui combine du matériel adapté à l'AR avec des techniques avancées de reconnaissance d'environnement 3D. Vous pouvez attendre de voir qui remportera cette course, ou vous pouvez en faire partie en développant dès maintenant de véritables applications immersives de réalité augmentée ! Mais s'il vous plaît, rendez service à l'humanité et ne changez pas d'objets vivants en lapins.