iOS ARKit 教程:用手指在空中绘画

已发表: 2022-03-11

最近,Apple 宣布了其名为 ARKit 的新增强现实 (AR) 库。 对许多人来说,它看起来只是另一个优秀的 AR 库,而不是需要关心的技术颠覆者。 但是,如果你看看过去几年 AR 的进展,应该不会很快得出这样的结论。

ARKit 教程插图:在 iOS ARKit 应用程序中与虚拟对象交互

在这篇文章中,我们将使用 iOS ARKit 创建一个有趣的 ARKit 示例项目。 用户将手指放在桌子上,就像他们拿着笔一样,点击缩略图并开始绘图。 完成后,用户将能够将他们的绘图转换为 3D 对象,如下面的动画所示。 GitHub 上提供了我们 iOS ARKit 示例的完整源代码。

演示我们正在使用的 iOS ARKit 示例增强现实应用程序

为什么我们现在应该关心 iOS ARKit?

每个有经验的开发人员都可能意识到 AR 是一个古老的概念。 我们可以将 AR 的第一次严肃开发确定为开发人员从网络摄像头访问单个帧的时间。 当时的应用程序通常被用来改变你的脸。 然而,人类很快就意识到将面孔变成兔子并不是他们最迫切的需求之一,很快这种炒作就消失了!

我相信 AR 一直缺少使其有用的两个关键技术飞跃:可用性和沉浸感。 如果您追踪其他 AR 炒作,您会注意到这一点。 例如,当开发人员可以访问移动摄像头的单个帧时,AR 炒作再次开始。 除了大兔子变形金刚的强势回归之外,我们还看到了一波将 3D 对象放在打印的二维码上的应用程序。 但他们从来没有作为一个概念起飞。 它们不是增强现实,而是增强的二维码。

然后谷歌用科幻小说谷歌眼镜让我们大吃一惊。 两年过去了,当这个神奇的产品被期待复活时,它已经死了! 许多批评者分析了谷歌眼镜失败的原因,将责任归咎于从社交方面到谷歌在推出产品时的沉闷方法等各种问题。 然而,我们在这篇文章中确实关心一个特殊的原因——沉浸在环境中。 虽然 Google Glass 解决了可用性问题,但它仍然只是在空中绘制的 2D 图像。

微软、Facebook 和苹果等科技巨头牢记这一惨痛教训。 2017 年 6 月,Apple 发布了其精美的 iOS ARKit 库,将沉浸感作为重中之重。 拿着手机仍然是一个很大的用户体验障碍,但谷歌眼镜的教训告诉我们,硬件不是问题。

我相信我们很快就会走向一个新的 AR 炒作高峰,并且随着这个新的重要支点,它最终可能会找到它的本土市场,让更多的 AR 应用程序开发成为主流。 这也意味着每个增强现实应用程序开发公司都将能够利用苹果的生态系统和用户群。

但是足够的历史,让我们用代码弄脏我们的手,看看苹果增强现实的实际应用吧!

ARKit 沉浸式功能

ARKit 提供了两个主要功能; 第一个是 3D 空间中的相机位置,第二个是水平面检测。 为了实现前者,ARKit 假设你的手机是一个在真实 3D 空间中移动的相机,因此在任何点放置一些 3D 虚拟对象都会被锚定到真实 3D 空间中的那个点。 对于后者,ARKit 可以检测水平面,例如桌子,以便您可以在上面放置对象。

那么 ARKit 是如何实现这一点的呢? 这是通过称为视觉惯性里程计 (VIO) 的技术完成的。 别担心,就像企业家发现他们在弄清楚他们的初创公司名称背后的来源时咯咯笑的次数感到高兴一样,研究人员发现他们的乐趣在于你试图破译他们提出的任何术语时的头部抓挠次数命名他们的发明 - 所以让我们让他们玩得开心,然后继续前进。

VIO 是一种将相机帧与运动传感器融合以跟踪设备在 3D 空间中的位置的技术。 跟踪摄像机帧的运动是通过检测特征来完成的,换句话说,就是图像中具有高对比度的边缘点——比如蓝色花瓶和白色桌子之间的边缘。 通过检测这些点从一帧到另一帧的相对移动量,可以估计设备在 3D 空间中的位置。 这就是为什么 ARKit 在面对毫无特色的白墙或设备快速移动导致图像模糊时无法正常工作的原因。

在 iOS 中开始使用 ARKit

在撰写本文时,ARKit 是 iOS 11 的一部分,目前仍处于测试阶段。 因此,要开始使用,您需要在 iPhone 6s 或更高版本上下载 iOS 11 Beta,以及新的 Xcode Beta。 我们可以从New > Project > Augmented Reality App开始一个新的 ARKit 项目。 但是,我发现使用官方 Apple ARKit 示例开始这个增强现实教程更方便,它提供了一些基本代码块,对平面检测特别有用。 所以,让我们从这个示例代码开始,先解释其中的要点,然后为我们的项目修改它。

首先,我们应该确定我们将使用哪个引擎。 ARKit 可以与 Sprite SceneKit 或 Metal 一起使用。 在 Apple ARKit 示例中,我们使用的是 Apple 提供的 3D 引擎 iOS SceneKit。 接下来,我们需要设置一个视图来渲染我们的 3D 对象。 这是通过添加ARSCNView类型的视图来完成的。

ARSCNView是名为SCNView的 SceneKit 主视图的子类,但它通过几个有用的功能扩展了视图。 它将来自设备摄像头的实时视频馈送渲染为场景背景,同时它会自动将 SceneKit 空间与现实世界匹配,假设设备是这个世界中的移动摄像头。

ARSCNView本身不进行 AR 处理,但它需要一个 AR 会话对象来管理设备摄像头和运动处理。 因此,首先,我们需要分配一个新会话:

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

上面的最后一行添加了一个视觉指示器,帮助用户直观地描述平面检测的状态。 Focus Square 是由示例代码而不是 ARKit 库提供的,这也是我们开始使用此示例代码的主要原因之一。 您可以在示例代码中包含的自述文件中找到有关它的更多信息。 下图显示了投影在桌子上的焦点方块:

使用 Apple ARKit 在桌子上投影的焦点方块

下一步是启动 ARKit 会话。 每次视图出现时重新启动会话是有意义的,因为如果我们不再跟踪用户,我们就无法使用以前的会话信息。 因此,我们将在 viewDidAppear 中启动会话:

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

在上面的代码中,我们首先设置 ARKit 会话配置来检测水平面。 在撰写本文时,Apple 不提供除此之外的选项。 但显然,它暗示未来会检测到更复杂的物体。 然后,我们开始运行会话并确保我们重置跟踪。

最后,我们需要在相机位置(即实际设备方向或位置)发生变化时更新 Focus Square。 这可以在 SCNView 的渲染器委托函数中完成,每次要渲染 3D 引擎的新帧时都会调用该函数:

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

至此,如果您运行该应用程序,您应该会在相机流上看到正在搜索水平面的焦点方块。 在下一节中,我们将解释如何检测平面,以及如何相应地定位焦点方块。

在 ARKit 中检测平面

ARKit 可以检测新平面、更新现有平面或删除它们。 为了方便地处理平面,我们将创建一些虚拟的 SceneKit 节点来保存平面位置信息和对焦点方块的引用。 平面在 X 和 Z 方向上定义,其中 Y 是表面的法线,也就是说,如果我们想让它看起来好像打印在平面上,我们应该始终保持我们的绘图节点位置在平面的相同 Y 值内.

平面检测是通过 ARKit 提供的回调函数完成的。 例如,每当检测到新平面时,都会调用以下回调函数:

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

回调函数为我们提供了两个参数, anchornodenode是一个普通的 SceneKit 节点,放置在平面的确切位置和方向上。 它没有几何形状,所以它是不可见的。 我们使用它来添加我们自己的平面节点,该节点也是不可见的,但在anchor中保存有关平面方向和位置的信息。

那么在ARPlaneAnchor中是如何保存位置和方向的呢? 位置、方向和比例都编码在一个 4x4 矩阵中。 如果我有机会选择一个数学概念让你学习,那无疑是矩阵。 无论如何,我们可以通过如下描述这个 4x4 矩阵来规避这个问题: 一个包含 4x4 浮点数的出色二维数组。 通过以某种方式将这些数字乘以其局部空间中的 3D 顶点 v1,得到一个新的 3D 顶点 v2,它表示世界空间中的 v1。 因此,如果 v1 = (1, 0, 0) 在其局部空间中,并且我们希望将其放置在世界空间中的 x = 100 处,则相对于世界空间,v2 将等于 (101, 0, 0)。 当然,当我们添加关于轴的旋转时,其背后的数学变得更加复杂,但好消息是我们可以不理解它(我强烈建议查看这篇优秀文章的相关部分,以深入解释这个概念)。

checkIfObjectShouldMoveOntoPlane检查我们是否已经绘制了对象,并检查所有这些对象的 y 轴是否与新检测到的平面的 y 轴匹配。

现在,回到上一节中描述的updateFocusSquare() 。 我们希望将焦点方块保持在屏幕的中心,但投影在最近的检测平面上。 下面的代码演示了这一点:

 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通过将 2D 点投影到最近的下方平面来搜索与屏幕视图中的 2D 点对应的真实世界平面。 result.worldTransform是一个 4x4 矩阵,包含检测到的平面的所有变换信息,而result.worldTransform.translation是一个方便的函数,它只返回位置。

现在,给定屏幕上的 2D 点,我们拥有了在检测到的表面上放置 3D 对象所需的所有信息。 那么,让我们开始画画吧。

绘画

让我们首先解释在计算机视觉中按照人类手指绘制形状的方法。 绘制形状是通过检测移动手指的每个新位置、在该位置放置一个顶点并将每个顶点与前一个顶点连接来完成的。 顶点可以通过一条简单的线连接,如果我们需要平滑的输出,也可以通过贝塞尔曲线连接。

为简单起见,我们将遵循一些简单的绘图方法。 对于手指的每个新位置,我们将在检测到的平面上放置一个带有圆角且高度几乎为零的非常小的盒子。 它看起来好像是一个点。 一旦用户完成绘图并选择 3D 按钮,我们将根据用户手指的移动来更改所有拖放对象的高度。

以下代码显示了表示点的PointNode类:

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

您会注意到在上面的代码中,我们沿 y 轴将几何图形平移了一半高度。 这样做的原因是要确保对象的底部始终位于y = 0处,以便它出现在平面上方。

接下来,在 SceneKit 的渲染器回调函数中,我们将使用相同的PointNode类绘制一些像笔尖一样的指示器。 如果启用绘图,我们将在该位置放置一个点,如果启用 3D 模式,我们将把绘图提升为 3D 结构:

 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是一个管理绘制点的类。 在 3D 模式下,我们估计与最后一个位置的差异,并使用该值增加/减少所有点的高度。

到目前为止,假设虚拟笔位于屏幕中心,我们正在检测到的表面上绘图。 现在是有趣的部分——检测用户的手指并使用它而不是屏幕中心。

检测用户的指尖

Apple 在 iOS 11 中引入的很酷的库之一是 Vision Framework。 它以非常方便和有效的方式提供了一些计算机视觉技术。 特别是,我们将在我们的增强现实教程中使用对象跟踪技术。 对象跟踪的工作原理如下:首先,我们为它提供图像和我们要跟踪的对象在图像边界内的正方形坐标。 之后我们调用一些函数来初始化跟踪。 最后,我们输入一个新图像,其中该对象的位置发生了变化,以及之前操作的分析结果。 鉴于此,它将为我们返回对象的新位置。

我们将使用一个小技巧。 我们将要求用户将手放在桌子上,就像他们拿着笔一样,并确保他们的缩略图朝向相机,然后他们应该在屏幕上点击他们的缩略图。 这里有两点需要说明。 首先,缩略图应该有足够的独特特征,可以通过白色缩略图、皮肤和表格之间的对比来追踪。 这意味着较深的皮肤色素将导致更可靠的跟踪。 其次,由于用户将手放在桌子上,并且由于我们已经将桌子检测为平面,因此将缩略图的位置从 2D 视图投影到 3D 环境将导致手指在桌子。

下图显示了 Vision 库可以检测到的特征点:

视觉库检测到的 iOS ARKit 特征点

我们将在点击手势中初始化缩略图跟踪,如下所示:

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

上面最棘手的部分是如何将点击位置从 UIView 坐标空间转换为图像坐标空间。 ARKit 为我们提供了从图像坐标空间转换为视口坐标空间的displayTransform矩阵,但反之则不行。 那么我们如何做相反的事情呢? 通过使用矩阵的逆。 我真的试图在这篇文章中尽量减少对数学的使用,但在 3D 世界中有时是不可避免的。

接下来,在渲染器中,我们将输入一个新图像来跟踪手指的新位置:

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

一旦对象跟踪完成,它将调用一个回调函数,我们将在其中更新缩略图位置。 它通常与敲击识别器中编写的代码相反:

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

最后,我们在绘制时使用self.lastFingerWorldPos代替屏幕中心,就大功告成了。

ARKit 与未来

在这篇文章中,我们展示了 AR 如何通过与用户手指和现实生活中的桌子进行交互来实现沉浸式体验。 随着计算机视觉的更多进步,以及通过向小工具(如深度相机)添加更多对 AR 友好的硬件,我们可以访问我们周围越来越多物体的 3D 结构。

虽然尚未向大众发布,但值得一提的是,微软非常认真地通过其 Hololens 设备赢得 AR 竞赛,该设备将 AR 定制硬件与先进的 3D 环境识别技术相结合。 您可以等着看谁将赢得这场比赛,或者您现在可以通过开发真正的沉浸式增强现实应用程序来参与其中! 但是,请帮人类一个忙,不要把活的物体变成兔子。