Tutorial iOS ARKit: dibujar en el aire con los dedos desnudos

Publicado: 2022-03-11

Recientemente, Apple anunció su nueva biblioteca de realidad aumentada (AR) llamada ARKit. Para muchos, parecía solo otra buena biblioteca AR, no un disruptor tecnológico que preocupara. Sin embargo, si observa el progreso de AR en los últimos años, no debería apresurarse a sacar tales conclusiones.

Ilustración del tutorial ARKit: Interactuar con objetos virtuales en una aplicación iOS ARKit

En esta publicación, crearemos un divertido proyecto de ejemplo de ARKit usando iOS ARKit. El usuario colocará sus dedos sobre una mesa como si estuviera sosteniendo un bolígrafo, toque la miniatura y comience a dibujar. Una vez terminado, el usuario podrá transformar su dibujo en un objeto 3D, como se muestra en la animación a continuación. El código fuente completo para nuestro ejemplo ARKit de iOS está disponible en GitHub.

Demostración del uso de nuestra aplicación de realidad aumentada de muestra iOS ARKit

¿Por qué deberíamos preocuparnos por iOS ARKit ahora?

Todo desarrollador experimentado probablemente sea consciente del hecho de que AR es un concepto antiguo. Podemos precisar el primer desarrollo serio de AR en el momento en que los desarrolladores tuvieron acceso a fotogramas individuales de las cámaras web. Las aplicaciones en ese momento generalmente se usaban para transformar tu rostro. Sin embargo, la humanidad no tardó mucho en darse cuenta de que transformar rostros en conejitos no era una de sus necesidades más inminentes, ¡y pronto se desvaneció la exageración!

Creo que AR siempre ha estado perdiendo dos saltos tecnológicos clave para que sea útil: usabilidad e inmersión. Si rastreó otras exageraciones de AR, notará esto. Por ejemplo, el bombo de AR despegó nuevamente cuando los desarrolladores tuvieron acceso a cuadros individuales de cámaras móviles. Además del fuerte regreso de los grandes transformadores de conejitos, vimos una ola de aplicaciones que arrojan objetos 3D en códigos QR impresos. Pero nunca despegaron como concepto. No eran realidad aumentada, sino códigos QR aumentados.

Entonces Google nos sorprendió con una pieza de ciencia ficción, las Google Glass. Pasaron dos años, y cuando se esperaba que este increíble producto cobrara vida, ¡ya estaba muerto! Muchos críticos analizaron las razones del fracaso de Google Glass, culpando a todo, desde los aspectos sociales hasta el enfoque aburrido de Google al lanzar el producto. Sin embargo, en este artículo nos preocupamos por una razón en particular: la inmersión en el medio ambiente. Si bien Google Glass resolvió el problema de usabilidad, todavía no era más que una imagen 2D trazada en el aire.

Los titanes tecnológicos como Microsoft, Facebook y Apple aprendieron esta dura lección de memoria. En junio de 2017, Apple anunció su hermosa biblioteca iOS ARKit, haciendo de la inmersión su máxima prioridad. Sostener un teléfono sigue siendo un gran bloqueador de la experiencia del usuario, pero la lección de Google Glass nos enseñó que el hardware no es el problema.

Creo que muy pronto nos dirigimos hacia un nuevo pico de exageración de AR, y con este nuevo giro significativo, eventualmente podría encontrar su mercado local, lo que permitiría que más desarrollo de aplicaciones AR se generalice. Esto también significa que todas las empresas de desarrollo de aplicaciones de realidad aumentada podrán aprovechar el ecosistema y la base de usuarios de Apple.

Pero basta de historia, ensuciémonos las manos con el código y veamos la realidad aumentada de Apple en acción.

Funciones de inmersión de ARKit

ARKit proporciona dos funciones principales; el primero es la ubicación de la cámara en el espacio 3D y el segundo es la detección del plano horizontal. Para lograr lo primero, ARKit asume que su teléfono es una cámara que se mueve en el espacio 3D real, de modo que dejar caer un objeto virtual 3D en cualquier punto se anclará a ese punto en el espacio 3D real. Y para este último, ARKit detecta planos horizontales a modo de mesas para que puedas colocar objetos sobre él.

Entonces, ¿cómo logra ARKit esto? Esto se hace a través de una técnica llamada Odometría Inercial Visual (VIO). No te preocupes, al igual que los empresarios encuentran su placer en la cantidad de risitas que te ríes cuando descubres la fuente detrás del nombre de su empresa, los investigadores encuentran el suyo en la cantidad de rasguños en la cabeza que haces tratando de descifrar cualquier término que se les ocurra cuando nombrando sus inventos, así que dejemos que se diviertan y sigamos adelante.

VIO es una técnica mediante la cual los marcos de la cámara se fusionan con sensores de movimiento para rastrear la ubicación del dispositivo en el espacio 3D. El seguimiento del movimiento de los fotogramas de la cámara se realiza detectando características o, en otras palabras, puntos de borde en la imagen con alto contraste, como el borde entre un jarrón azul y una mesa blanca. Al detectar cuánto se movieron estos puntos entre sí de un marco a otro, se puede estimar dónde se encuentra el dispositivo en el espacio 3D. Es por eso que ARKit no funcionará correctamente cuando se coloca frente a una pared blanca sin rasgos distintivos o cuando el dispositivo se mueve muy rápido, lo que genera imágenes borrosas.

Primeros pasos con ARKit en iOS

Al momento de escribir este artículo, ARKit es parte de iOS 11, que aún se encuentra en versión beta. Entonces, para comenzar, debe descargar iOS 11 Beta en iPhone 6s o superior, y el nuevo Xcode Beta. Podemos iniciar un nuevo proyecto ARKit desde New > Project > Augmented Reality App . Sin embargo, encontré más conveniente comenzar este tutorial de realidad aumentada con la muestra oficial de Apple ARKit, que proporciona algunos bloques de código esenciales y es especialmente útil para la detección de aviones. Entonces, comencemos con este código de ejemplo, expliquemos primero los puntos principales y luego modifiquemos para nuestro proyecto.

En primer lugar, debemos determinar qué motor vamos a utilizar. ARKit se puede usar con Sprite SceneKit o Metal. En el ejemplo de Apple ARKit, estamos usando iOS SceneKit, un motor 3D proporcionado por Apple. A continuación, debemos configurar una vista que represente nuestros objetos 3D. Esto se hace agregando una vista de tipo ARSCNView .

ARSCNView es una subclase de la vista principal de SceneKit llamada SCNView , pero amplía la vista con un par de características útiles. Muestra la transmisión de video en vivo de la cámara del dispositivo como fondo de la escena, mientras que automáticamente hace coincidir el espacio de SceneKit con el mundo real, asumiendo que el dispositivo es una cámara en movimiento en este mundo.

ARSCNView no realiza el procesamiento AR por sí solo, sino que requiere un objeto de sesión AR que administre la cámara del dispositivo y el procesamiento de movimiento. Entonces, para comenzar, necesitamos asignar una nueva sesión:

 self.session = ARSession() sceneView.session = session sceneView.delegate = self setupFocusSquare()

La última línea de arriba agrega un indicador visual que ayuda al usuario visualmente a describir el estado de detección del avión. Focus Square lo proporciona el código de muestra, no la biblioteca ARKit, y es una de las principales razones por las que comenzamos con este código de muestra. Puede encontrar más información al respecto en el archivo Léame incluido en el código de muestra. La siguiente imagen muestra un cuadro de foco proyectado sobre una mesa:

Foco cuadrado proyectado en una mesa usando Apple ARKit

El siguiente paso es iniciar la sesión de ARKit. Tiene sentido reiniciar la sesión cada vez que aparece la vista porque no podemos hacer uso de la información de la sesión anterior si ya no estamos rastreando al usuario. Entonces, vamos a iniciar la sesión en viewDidAppear:

 override func viewDidAppear(_ animated: Bool) { let configuration = ARWorldTrackingSessionConfiguration() configuration.planeDetection = .horizontal session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) }

En el código anterior, comenzamos configurando la sesión de ARKit para detectar planos horizontales. Al momento de escribir este artículo, Apple no ofrece más opciones que esta. Pero aparentemente, apunta a la detección de objetos más complejos en el futuro. Luego, comenzamos a ejecutar la sesión y nos aseguramos de restablecer el seguimiento.

Finalmente, necesitamos actualizar Focus Square siempre que cambie la posición de la cámara, es decir, la orientación o posición real del dispositivo. Esto se puede hacer en la función de delegado del renderizador de SCNView, que se llama cada vez que se va a renderizar un nuevo cuadro del motor 3D:

 func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { updateFocusSquare() }

En este punto, si ejecuta la aplicación, debería ver el cuadro de enfoque sobre el flujo de la cámara en busca de un plano horizontal. En la siguiente sección, explicaremos cómo se detectan los planos y cómo podemos colocar el cuadro de enfoque en consecuencia.

Detección de aviones en ARKit

ARKit puede detectar nuevos aviones, actualizar los existentes o eliminarlos. Para manejar los planos de una manera práctica, crearemos un nodo ficticio de SceneKit que contenga la información de la posición del plano y una referencia al cuadro de enfoque. Los planos se definen en la dirección X y Z, donde Y es la normal de la superficie, es decir, siempre debemos mantener las posiciones de nuestros nodos de dibujo dentro del mismo valor Y del plano si queremos que parezca que está impreso en el plano. .

La detección de aviones se realiza a través de funciones de devolución de llamada proporcionadas por ARKit. Por ejemplo, la siguiente función de devolución de llamada se llama cada vez que se detecta un nuevo avión:

 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 función de devolución de llamada nos proporciona dos parámetros, anchor y node . node es un nodo normal de SceneKit colocado en la posición y orientación exactas del plano. No tiene geometría, por lo que es invisible. Lo usamos para agregar nuestro propio nodo de plano, que también es invisible, pero contiene información sobre la orientación y posición del plano en anchor .

Entonces, ¿cómo se guardan la posición y la orientación en ARPlaneAnchor ? La posición, la orientación y la escala están codificadas en una matriz de 4x4. Si tengo la oportunidad de elegir un concepto matemático para que aprendas, sin duda serán las matrices. De todos modos, podemos eludir esto describiendo esta matriz de 4x4 de la siguiente manera: una brillante matriz bidimensional que contiene números de coma flotante de 4x4. Al multiplicar estos números de cierta manera por un vértice 3D, v1, en su espacio local, da como resultado un nuevo vértice 3D, v2, que representa v1 en el espacio mundial. Entonces, si v1 = (1, 0, 0) en su espacio local, y queremos ubicarlo en x = 100 en el espacio mundial, v2 será igual a (101, 0, 0) con respecto al espacio mundial. Por supuesto, las matemáticas detrás de esto se vuelven más complejas cuando agregamos rotaciones sobre ejes, pero la buena noticia es que podemos hacerlo sin entenderlo (recomiendo consultar la sección correspondiente de este excelente artículo para obtener una explicación detallada de este concepto). ).

checkIfObjectShouldMoveOntoPlane comprueba si ya tenemos objetos dibujados y comprueba si el eje y de todos estos objetos coincide con el de los planos recién detectados.

Ahora, regrese a updateFocusSquare() , descrito en la sección anterior. Queremos mantener el foco cuadrado en el centro de la pantalla, pero proyectado en el plano detectado más cercano. El siguiente código demuestra esto:

 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 busca planos del mundo real correspondientes a un punto 2D en la vista de pantalla al proyectar este punto 2D al plano inferior más cercano. result.worldTransform es una matriz de 4x4 que contiene toda la información de transformación del plano detectado, mientras que result.worldTransform.translation es una función útil que solo devuelve la posición.

Ahora, tenemos toda la información que necesitamos para dejar caer un objeto 3D en las superficies detectadas dado un punto 2D en la pantalla. Entonces, comencemos a dibujar.

Dibujo

Primero expliquemos el enfoque de dibujar formas que sigue el dedo de un humano en la visión por computadora. Dibujar formas se realiza detectando cada nueva ubicación para el dedo en movimiento, colocando un vértice en esa ubicación y conectando cada vértice con el anterior. Los vértices se pueden conectar mediante una línea simple o mediante una curva Bezier si necesitamos una salida suave.

Para simplificar, seguiremos un enfoque un poco ingenuo para dibujar. Por cada nueva ubicación del dedo, dejaremos caer una caja muy pequeña con esquinas redondeadas y una altura casi nula en el plano detectado. Aparecerá como si fuera un punto. Una vez que el usuario termine de dibujar y seleccione el botón 3D, cambiaremos la altura de todos los objetos soltados según el movimiento del dedo del usuario.

El siguiente código muestra la clase PointNode que representa un punto:

 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) } . . . }

Notarás en el código anterior que trasladamos la geometría a lo largo del eje y a la mitad de la altura. La razón de esto es asegurarse de que la parte inferior del objeto esté siempre en y = 0 , de modo que aparezca sobre el plano.

A continuación, en la función de devolución de llamada del renderizador de SceneKit, dibujaremos algún indicador que actúe como la punta de un bolígrafo, utilizando la misma clase PointNode . Soltaremos un punto en esa ubicación si el dibujo está habilitado, o elevaremos el dibujo a una estructura 3D si el modo 3D está habilitado:

 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 es una clase que gestiona los puntos dibujados. En modo 3D, estimamos la diferencia desde la última posición y aumentamos/disminuimos la altura de todos los puntos con ese valor.

Hasta ahora, estamos dibujando en la superficie detectada asumiendo que el bolígrafo virtual está en el centro de la pantalla. Ahora viene la parte divertida: detectar el dedo del usuario y usarlo en lugar del centro de la pantalla.

Detección de la yema del dedo del usuario

Una de las bibliotecas geniales que Apple presentó en iOS 11 es Vision Framework. Proporciona algunas técnicas de visión por computadora de una manera bastante práctica y eficiente. En particular, vamos a utilizar la técnica de seguimiento de objetos para nuestro tutorial de realidad aumentada. El seguimiento de objetos funciona de la siguiente manera: primero, le proporcionamos una imagen y las coordenadas de un cuadrado dentro de los límites de la imagen del objeto que queremos rastrear. Después de eso, llamamos a alguna función para inicializar el seguimiento. Finalmente, alimentamos una nueva imagen en la que cambió la posición de ese objeto y el resultado del análisis de la operación anterior. Dado eso, nos devolverá la nueva ubicación del objeto.

Vamos a utilizar un pequeño truco. Le pediremos al usuario que descanse la mano sobre la mesa como si estuviera sosteniendo un bolígrafo y que se asegure de que su miniatura mire hacia la cámara, después de lo cual debe tocar su miniatura en la pantalla. Hay dos puntos que necesitan ser elaborados aquí. En primer lugar, la miniatura debe tener suficientes características únicas para rastrearlas a través del contraste entre la miniatura blanca, la piel y la tabla. Esto significa que un pigmento de piel más oscuro dará como resultado un seguimiento más confiable. En segundo lugar, dado que el usuario apoya las manos sobre la mesa y que ya estamos detectando la mesa como un plano, proyectar la ubicación de la miniatura desde la vista 2D al entorno 3D dará como resultado una ubicación casi exacta del dedo en la mesa.

La siguiente imagen muestra puntos característicos que la biblioteca de Vision podría detectar:

iOS ARKit Feature puntos detectados por la biblioteca Vision

Inicializaremos el seguimiento de miniaturas con un gesto de toque de la siguiente manera:

 // 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 parte más complicada anterior es cómo convertir la ubicación del toque del espacio de coordenadas de UIView al espacio de coordenadas de la imagen. ARKit nos proporciona la matriz displayTransform que convierte el espacio de coordenadas de la imagen en un espacio de coordenadas de ventana gráfica, pero no al revés. Entonces, ¿cómo podemos hacer lo contrario? Usando la inversa de la matriz. Realmente traté de minimizar el uso de las matemáticas en esta publicación, pero a veces es inevitable en el mundo 3D.

A continuación, en el renderizador, introduciremos una nueva imagen para rastrear la nueva ubicación del dedo:

 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) } . . . }

Una vez que se complete el seguimiento de objetos, llamará a una función de devolución de llamada en la que actualizaremos la ubicación de la miniatura. Por lo general, es el inverso del código escrito en el reconocedor de toque:

 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) } } }

Finalmente, usaremos self.lastFingerWorldPos en lugar del centro de la pantalla al dibujar, y listo.

ARKit y el futuro

En esta publicación, hemos demostrado cómo AR podría ser inmersivo a través de la interacción con los dedos de los usuarios y las tablas de la vida real. Con más avances en la visión por computadora y al agregar más hardware compatible con AR a los dispositivos (como cámaras de profundidad), podemos obtener acceso a la estructura 3D de más y más objetos a nuestro alrededor.

Aunque aún no se ha lanzado a las masas, vale la pena mencionar cómo Microsoft se toma muy en serio ganar la carrera de AR a través de su dispositivo Hololens, que combina hardware diseñado para AR con técnicas avanzadas de reconocimiento de entornos 3D. ¡Puede esperar para ver quién ganará esta carrera, o puede ser parte de ella desarrollando aplicaciones reales de realidad aumentada inmersiva ahora! Pero por favor, hazle un favor a la humanidad y no conviertas objetos vivos en conejitos.