个性化阅读
专注于IT技术分析

iOS ARKit教程:用裸露的手指画画

本文概述

苹果最近宣布了其名为ARKit的新增强现实(AR)库。对于许多人来说, 它似乎只是另一个好的AR库, 而不是值得关注的技术破坏者。但是, 如果你看一下过去几年中AR的进展, 得出这样的结论应该不会太快。

ARKit教程插图:与iOS ARKit应用中的虚拟对象进行交互

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

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

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

每个有经验的开发人员都可能意识到AR是一个古老的概念。我们可以将AR的第一个认真开发归结为开发人员可以从网络摄像头访问各个帧的时间。当时的应用通常用于改变你的面孔。但是, 人类很快就意识到, 将脸庞变成兔子并不是他们最迫切的需求之一, 而且炒作很快就消失了!

我相信AR一直缺少使它有用的两个关键技术飞跃:可用性和沉浸性。如果跟踪其他AR炒作, 你会注意到这一点。例如, 当开发人员可以从移动相机访问单个帧时, AR炒作又开始流行。除了伟大的兔子变形金刚的强劲回报外, 我们还看到了一波应用程序, 它们将3D对象放置在打印的QR码上。但是他们从来没有从概念上脱颖而出。它们不是增强现实, 而是增强的QR码。

然后, 谷歌用科幻小说《谷歌眼镜》使我们感到惊讶。两年过去了, 当这个令人惊奇的产品面世时, 它已经死了!许多批评家分析了Google Glass失败的原因, 将责任归咎于从社交方面到Google在推出该产品时采取的呆板方法之类的任何事情。但是, 我们在本文中确实出于某种特殊原因-浸入环境中。虽然Google Glass解决了可用性问题, 但它不过是空中绘制的2D图像而已。

微软, Facebook和苹果等技术巨头都从心中吸取了惨痛的教训。 2017年6月, Apple宣布了其漂亮的iOS ARKit库, 将沉浸作为其首要任务。手持电话仍然是阻碍用户体验的主要因素, 但是Google Glass的教训告诉我们, 硬件不是问题。

我相信我们很快就会朝着新的AR炒作高峰迈进, 有了这个新的重要转折点, 它最终可能会找到其本国市场, 从而使更多的AR应用程序开发成为主流。这也意味着, 每个在那里的增强现实应用程序开发公司都将能够利用Apple的生态系统和用户群。

但是, 有了足够的历史, 就让我们着手编写代码, 看看苹果增强现实在行动!

ARKit浸入功能

ARKit提供了两个主要功能;第一个是相机在3D空间中的位置, 第二个是水平面检测。为了实现前者, ARKit假定你的手机是在真实3D空间中移动的摄像头, 因此在任意点上放置某些3D虚拟对象将被锚定到真实3D空间中的该点。对于后者, ARKit可以检测像桌子一样的水平面, 因此你可以在上面放置对象。

那么, ARKit如何实现这一目标?这是通过一种称为视觉惯性里程表(VIO)的技术完成的。别担心, 就像企业家在发现创业公司名称背后的来历时对你的笑声数量感到高兴时, 研究人员在你试图破译他们想出的任何术语时发现的头划痕数量中发现的一样。命名他们的发明-因此, 让我们让他们尽情享受并继续前进。

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

iOS中的ARKit入门

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

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

ARSCNView是SceneKit主视图的子类, 名为SCNView, 但它通过几个有用的功能扩展了该视图。它将设备摄像头的实时视频馈送渲染为场景背景, 同时自动将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()
    }
    ...
}

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

那么如何在ARPlaneAnchor中保存位置和方向?位置, 方向和比例均以4×4矩阵编码。如果我有机会选择一个数学概念供你学习, 那无疑是矩阵。无论如何, 我们可以通过如下描述此4×4矩阵来规避此问题:一个精巧的二维数组, 其中包含4×4浮点数。通过以某种方式将这些数字乘以其本地空间中的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是一个4×4矩阵, 其中包含检测到的平面的所有变换信息, 而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模式下, 我们估计与最后一个位置的差, 并使用该值增加/减少所有点的高度。

到目前为止, 假设虚拟笔位于屏幕中心, 我们将在检测到的表面上进行绘制。现在开始有趣的部分-检测用户的手指并使用它代替屏幕中心。

检测用户的指尖

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

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

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

视觉库检测到的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环境识别技术相结合。你可以等着看谁将赢得这场比赛, 也可以通过现在开发真正的沉浸式增强现实应用程序来参与其中!但是, 请人类帮个忙, 不要将活物变成兔子。

赞(0)
未经允许不得转载:srcmini » iOS ARKit教程:用裸露的手指画画

评论 抢沙发

评论前必须登录!