狼视··By/蜜汁炒酸奶

iOS ARKit教程:赤手在空中绘画

前言

这次推荐的是ios上的文章,无奈ios上的东西没接触过,权且当做开拓视野了。老规矩,原文如下:

iOS ARKit Tutorial: Drawing in the Air with Bare Fingers

文章正文

最近,苹果宣布了其名为ARKit的新增强现实(AR)库。对许多人来说,它看起来只是另一个优秀的AR库,而不是值得关注测technology disruptor。但是,如果你在过去几年里看到AR的进展情况,那么你不应该太快得出这样的结论。

iOS ARKit教程:赤手在空中绘画

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

iOS ARKit教程:赤手在空中绘画

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

每个有经验的开发人员都可能意识到AR是一个旧概念。我们可以确定AR的第一次真正的开发是在开发人员从网络摄像头获取个人轮廓的时候。那时的应用通常是用来改变你的脸的。然而,人类并没有花很长时间意识到把脸变成兔子并不是他们最迫切需要的,很快这种炒作就消失了!

我认为,AR一直缺少两项关键技术的飞跃:可用性和沉浸性。如果你追踪其他的AR,你会注意到这个。例如,当开发者从移动设备上获得独立的帧时,AR炒作又开始了。除了大兔子变形金刚的强劲回归之外,我们还看到了一大批应用程序,它们将3D对象打印在二维码上。但他们从来没有像一个概念那样腾飞。它们并不是增强现实,而是增强了二维码。

然后,谷歌用谷歌眼镜给我们带来了惊喜。两年过去了,当这个神奇的产品被期待已久的时候,它已经死了!许多批评人士分析了谷歌眼镜失败的原因,把责任归咎于从社交方面到谷歌在推出产品时的迟钝做法。然而,我们在这篇文章中关注的是一个特殊的原因——沉浸在环境中。当Google Glass解决了可用性问题时,它仍然只是一个在空中的二维图像。

像微软、Facebook和苹果这样的科技巨头们都从心底里吸取了教训。在2017年6月,苹果发布了其漂亮的iOS ARKit库,将沉浸式应用作为首要任务。拿着手机仍然是一个巨大的用户体验阻碍者,但谷歌眼镜的教训告诉我们,硬件不是问题。

我相信,我们很快就会进入一个新的“AR炒作峰”,随着这个新的重大战略重心的到来,它最终可能会找到自己的本土市场。随着历史的发展,让我们用代码弄脏手,看看苹果的增强现实吧!

ARKit的沉浸性

ARKit提供了两个主要功能;第一个是三维空间中的相机位置,第二个是水平平面检测。为了实现这一目标,ARKit假设你的手机是一个在真实3D空间中移动的摄像头,这样一来,在任何时候,任何一个3D虚拟物体都会被固定在3D空间的那个点上。而对于后者,ARKit检测到水平面,比如桌子,这样你就可以在上面放置物体。

那么,ARKit是如何做到这一点的呢?这是通过一种叫做视觉惯性测量(VIO)的技术完成的。别担心,就像企业家会发现他们的乐趣是当你发现他们的创业公司名字背后的源头时的笑,研究人员发现你绞尽脑汁地想要破译他们发明的任何术语。让我们让他们有乐趣,并继续前进。

VIO是一种技术,通过这种技术可以将摄像机与运动传感器融合在一起,从而在3D空间中追踪设备的位置。从相机帧中追踪图像的功能是通过检测特征来完成的,或者换句话说,在图像中有高对比度的边缘点——比如蓝色花瓶和白色桌子之间的边缘。通过检测这些点在一个帧之间移动的距离,我们可以估算出这个设备在3D空间中的位置。这就是为什么当设备被放置在一个毫无特色的白色墙壁上或者当设备移动得非常快导致图像模糊的时候,ARKit不能正常工作。

iOS中的ARKit入门

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

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

ARSCNView是一个名为SCNView的SceneKit主视图的子类,但它使用一些有用的特性扩展了视图。它将设备摄像头拍摄的实时视频作为场景背景,并自动将场景与现实世界相匹配,假设设备是这个世界上一个移动的摄像头。

ARSCNView 本身并没有进行AR处理,但它需要一个AR session对象来管理设备摄像头和动作处理。首先,我们需要分配一个新的会话:

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

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

iOS ARKit教程:赤手在空中绘画

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

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

在上面的代码中,我们首先设置ARKit会话配置来检测水平平面。在撰写本文时,苹果并没有提供其他选择。但很明显,它暗示了未来探测更复杂的对象。然后,我们开始运行会话,并确保我们重新设置跟踪。

最后,我们需要在摄像机位置时更新焦点方块。实际的设备朝向或位置,变化。这可以在名为SCNView的渲染器委托功能中完成,每当一个新的3D引擎的框架被渲染时,它就会被调用。

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

在这一点上,如果你运行这个应用程序,你应该会看到在摄像机流中寻找水平平面的焦点正方形。在下一节中,我们将解释如何检测飞机,以及如何相应地定位焦点广场。在下一节中,我们将解释如何检测平面,以及如何相应地定位焦点。

检测平面

ARKit可以探测到新的平面,更新现有的平面,或者移除它们。

为了方便地处理平面,我们将创建一个虚拟场景节点,该节点包含平面位置信息和对焦点正方形的引用。平面是在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。 node 是放置在平面的精确位置和方向上的一个正常的SceneKit节点。它没有几何形状,所以它是不可见的。我们使用它来添加我们自己的平面节点,它也是不可见的,但是它包含关于anchor中的平面方向和位置。

那么位置和方向如何保存在ARPlaneAnchor中?位置、方向和规模都被编码在一个4x4矩阵中。位置、方向和规模都被编码在一个4x4矩阵中。无论如何,我们可以通过描述这个4x4矩阵来规避这一点:一个包含4x4浮点数的出色的二维数组。通过将这些数字乘以一个3D顶点v1,在它的局部空间中,它会产生一个新的三维顶点,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 是一个管理绘制点的类。在三维模型中,我们估计了与最后位置的不同,并增加/减少了所有点的高度。

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

检测用户的指尖

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

我们要用一个小技巧。我们会要求用户把他们的手放在桌上,就好像他们拿着一支笔,确保他们的thumbnail 面对着摄像头,然后他们就会在屏幕上点击thumbnail 。这里需要阐述两点。首先,thumbnail 应该有足够的独特功能,可以通过白色thumbnail 、皮肤和表格之间的对比来追踪。这意味着深色的皮肤色素会导致更可靠的追踪。其次,由于用户将双手放在桌子上,并且我们已经将表格作为一个平面来检测,所以将thumbnail的位置从2D视图投射到3D环境将会导致手指在表格上的精确位置。

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

iOS ARKit教程:赤手在空中绘画

我们将用一个点击手势来初始化thumbnail 跟踪:

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

    . . .
}

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

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。随着计算机视觉技术的进步,以及向小设备(比如深度相机)添加更多的友好型硬件,我们可以获得越来越多的物体的3D结构。

虽然还没有向大众发布,但值得一提的是,微软是如何通过Hololens设备来赢得AR竞赛的。Hololens的设备结合了定制的硬件和先进的3D环境识别技术。你可以等着看谁会赢得这场比赛,或者你可以通过开发真正的沉浸式应用程序来参与其中。但是拜托,给人类一个恩惠吧,不要把活的东西变成兔子。

了解基础知识

[toggle hide="yes" title="Apple ARKit为开发人员提供了哪些功能?" color=""]

ARKit允许开发人员通过分析摄像机视图呈现的场景并在房间中查找水平平面,在iPhone和iPad上构建沉浸式增强现实应用程序。

[/toggle]

[toggle hide=“yes” title=“我们如何跟踪Apple Vision库的对象?” color=“”]

Apple Vision库允许开发人员跟踪视频流中的对象。开发人员为他们要跟踪的对象在初始图像帧内提供一个矩形的坐标,然后在视频帧中提供矩形,并且该库返回该对象的新位置。

[/toggle]

[toggle hide=“yes” title=“我们如何开始使用Apple ARKit?” color=“”]

[/toggle]
预览
Loading comments...
0 条评论

暂无数据

example
预览