ARKit教程14_第十章:检测占位符

前言

ARKit感兴趣的同学,可以订阅ARKit教程专题
源代码地址在这里

正文

本章重点介绍如何检测占位符并首先显示一些位置标记,以及之后的平面。

平面检测与物体检测

不要将平面检测与物体检测混淆;他们是两个不同的东西。平面检测内置于ARKit中,以帮助程序员将对象放入场景中。

ARKit的平面检测不是RazeAd所需的工具。相反,我们将使用Vision Framework来检测有限形状,然后我们将ARKit平面基于该形状。

检测矩形

iOS的一个更好的方面是不同框架之间的互操作性。在这种情况下,我们正在利用Vision框架。视觉框架是一种图像分析和计算机视觉框架,用于识别和分类现实世界的对象。

我们将使用Vision框架检测矩形,然后使用VNDetectRectanglesRequest将该矩形转换为ARKit对象,顾名思义,它检测矩形形状。

Vision静态检测对象,因此我们必须为每个请求提供图像。
现在,我们可以使用屏幕上的点按来触发图像分析。

ARKit导入后再导入Vision框架:

import Vision

我们添加以下代码:

// 1 
guard let currentFrame = sceneView.session.currentFrame else { 
    return
}

// 2 
DispatchQueue.global(qos: .background).async {

    // 3 
    do { 
        // 4 
        let request = VNDetectRectanglesRequest {(request, error) in

             // Access the first result in the array, 
             // after converting to an array 
             // of VNRectangleObservation 
             // 5 
            guard let results = request.results?.compactMap({ $0 as? VNRectangleObservation }),
                 // 6
                let result = results.first else {
                print ("[Vision] VNRequest produced no result")
                return 
                  }
            } 
        } catch(let error) { 
    print( "An error occurred during rectangle detection: \(error)") 
    }
}  

上面的代码作用如下:

  • 1: ARSession有一个currentFrame属性,它是ARFrame的一个实例。帧是视频源的单个捕获。 ARKit分析并将其与设备的运动感应相结合。与其他数据一起,它包含相机捕获的图像。由于你需要框架,我们必须防止零帧,在这种情况下你跳过处理,只需提前返回。
  • 2: 图像处理对CPU消耗比较大 - 我们需要使用后台线程。
  • 3: 很快就会需要do/catch块。
  • 4: 我们创建一个矩形检测请求,它将闭包作为唯一参数。在完成图像分析时调用闭包,提供刚创建的VNDetectRectanglesRequest实例和可选错误。结果可以作为请求参数的属性进行访问。
  • 5: results属性将是VNRectangleObservation实例的数组,每个检测到的矩形一个。在这里,我们将使用compactMap()[Any]转换为[VNRectangleObservation]。然后,我们将结果数组存储到结果变量中。
  • 6: 如果请求没有产生任何结果,我们可以提前退出,因为没有其他事可做。否则,如果结果数组包含至少一个检测到的矩形,则选择第一个。

请求将返回最多一个值,因为VNDetectRectanglesRequestmaximumObservations属性默认为1

let result = results.first else ... block之后,添加以下代码:

// 1 
let coordinates: [matrix_float4x4] = [ 
    result.topLeft, 
    result.topRight, 
    result.bottomRight, 
    result.bottomLeft ].compactMap { 
    // 2 
    guard let hitFeature = currentFrame.hitTest( $0, types: .featurePoint).first else { return nil }
    // 3 
    return hitFeature.worldTransform
}
// 4 
guard coordinates.count == 4 else { return }
// 5 
DispatchQueue.main.async {
    // 6
    self.removeBillboard()
    let (topLeft, topRight, bottomRight, bottomLeft) = (coordinates[0], coordinates[1], coordinates[2], coordinates[3])
// 7 
self.createBillboard(topLeft: topLeft, topRight: topRight, bottomRight: bottomRight, bottomLeft: bottomLeft)
}

上面的代码作用如下:

  • 1: 结果是VNRectangleObservation的一个实例,它有四个属性,用于标识检测到的矩形的四个顶点。

注意:Vision适用于2D图像,因此它不了解3D世界。结果点始终是位图图像中的2D坐标。

  我们将这四个坐标转换为一个地图友好的数组,以便更容易按顺序处理它们。

  • 2:还记得包含ARKit当前处理的图像的currentFrame变量吗?它公开了一个有用的hitTest()方法,用于通过将其投影到对象或3D世界中的锚点来消除2D图像的点。

    hitTest()返回一个ARHitTestResult列表,按距离排序,从最近到最远,因此,再次,我们首先将其存储到hitFeature变量中。稍后会详细介绍。
  • 3:在它的属性中,我们只需要worldTransform,这就是我们返回的内容。
  • 4:现在坐标包含一个4x4矩阵的数组,这些矩阵取自worldTransform属性。由于我们正在处理矩形,因此请确保正好有四个坐标。如果在任何角落中将矩形的2D点更改为ARKit世界中的3D点的过程失败,则可以提前退出。
  • 5: 由于代码将添加和删除UI元素,因此我们需要返回主线程。
  • 6:如果已显示先前的广告牌,请将其删除。此方法尚未实现。
  • 7: 最后,通过调用createBillboard()创建一个新的广告牌,然后将前面步骤中找到的四个世界坐标传递给它。此方法尚未实现。

currentFrame.hitTest(_:types :)将一个点投射到一个3D对象。 types参数确定它们是什么类型的对象,包括:

  • featurePoint:曲面的一个点部分,但没有锚点。
  • estimatedHorizo​​ntalPlane:搜索检测到的水平曲面,但没有相应的锚点。
  • existingPlane:具有关联锚点的平面,不考虑平面的大小。
  • existingPlaneUsingExtent:具有关联锚点的平面,与平面的大小相关。

为了检测表面上的矩形,我们可能认为最后三个中的任何一个都可能是一个不错的选择。事实上,ARKit中的平面有一个对齐。由于我们希望检测任何表面上的矩形 - 因此不限于水平或垂直 - 只留下一个选项:featurePoint

还有一个缺失部分需要完成touchesBegan()实现:使用VNDetectRectanglesRequest实例。

catch回调之前,我们添加下面的代码:

// 1 
let handler = VNImageRequestHandler( cvPixelBuffer: currentFrame.capturedImage)

// 2 
try handler.perform([request])
  • 1: 执行VNDetectRectanglesRequest的方法是创建一个负责执行实际图像处理的图像请求处理程序。它通过cvPixelBuffer参数获取要分析的图像。
  • 2: 创建请求处理程序后,我们必须让它发挥其魔力。所以我们调用它的perform方法,传递一个只包含前面步骤中创建的请求的数组。

注意如何为单帧定义处理程序实例,但它可用于在同一帧上执行多个请求,例如文本识别,条形码检测等。如果需要执行多个分析,则创建一个请求每个分析,但只有一个处理程序。

创建广告牌

现在我们有四个矩阵确定四个矩形顶点中每个矩形顶点的位置和方向。我们将使用这些来帮助定位广告牌。

世界变换矩阵包含通过从相机向相反方向投影2D点及其方向而得到的交点。方向取决于从摄像机到矩形顶点的虚线之间的角度,如下图中的红线所示,矩形方向由垂直于平面的直线确定,如绿线所示。

现在我们需要添加一个扩展:

func createBillboard(
topLeft: matrix_float4x4, topRight: matrix_float4x4, bottomRight: matrix_float4x4, bottomLeft: matrix_float4x4) {
    // 1 
    let plane = RectangularPlane( topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight)
    // 2
    let anchor = ARAnchor(transform: plane.center)
    // 3 
    billboard = BillboardContainer(billboardAnchor: anchor, plane: plane)
    // 4 
    sceneView.session.add(anchor: anchor)
    print("New billboard created")
}

上面的代码作用如下:

  • 1: 将四个矩阵存储到名为RectangularPlane的数据容器中,该容器还计算矩形大小及其中心。
  • 2: 为平面创建锚点,位于矩形的中心。
  • 3: 将锚点和平面存储到容器中,以便以后可以访问它们。
  • 4: 最后,将锚添加到ARKit会话。

BillboardContainer是一个实用程序数据结构,用于存储有关在BillboardContainer.swift中实现的广告牌的数据。现在我们将扩展BillboardContainer

我们需要添加的最后一段代码是删除广告牌。在createBillboard()之后立即添加以下函数:

func removeBillboard() {
    // 1 
    if let anchor = billboard?.billboardAnchor {
    // 2 
    sceneView.session.remove(anchor: anchor) 
    // 3 
    billboard?.billboardNode?.removeFromParentNode() billboard = nil 
    }
}

上面代码作用如下:

  • 1: 我们可以使用保护声明对此进行预先处理,以确保广告牌属性不为零。但它不需要,因为我们只需要检查锚是否有值。
  • 2: 如果有锚,请将其从ARKit会话中删除。
  • 3: 最后,删除SceneKit节点。

下面的物体都是可以检测到的矩形形状:

  • 外部触控板。
  • A4纸或者白色的信纸。
  • 笔记本
  • 海报

重要的是它的颜色必须与它背后的表面形成鲜明对比,所以白色桌子上的白纸有可能识别不出来。

Xcode启动应用程序后,将iPhone的相机指向我们选择检测的对象并点按屏幕。

如果你没有可以扫描的对象,那就对着屏幕扫描下面这个黑色的正方形吧:

如果你可以扫描到,那么控制台会有如下提示:

如果Vision无法检测到形状,我们将在控制台中看到如下消息:

[Vision] VNRequest produced no result

检测到之后会有如下提示:

New billboard created

显示地标

我们可能希望在检测点时显示占位符。这可以帮助我们直观地调试应用程序。

我们在ViewController.swift文件中的touchesBegan()方法中在self.createBillboard调用之后加入如下代码:

for coordinate in coordinates {
    // 1 
    let box = SCNBox(width: 0.01, height: 0.01, length: 0.001, chamferRadius: 0.0)
    // 2
   let node = SCNNode(geometry: box)
    // 3 
    node.transform = SCNMatrix4(coordinate)
    // 4 
    self.sceneView.scene.rootNode.addChildNode(node)
}
  • 1: 首先,我们创建一个10×10×1 mm的小型SceneKit框。
  • 2: 然后,使用该框创建SceneKit节点。
  • 3: 接下来,在转换为SCNMatrix4之后,为新节点设置转换矩阵。
  • 4: 最后,将节点添加到场景的根节点。

构建并运行并尝试检测矩形形状;你会看到类似的东西:

注意每个矩形顶点的小白色矩形。当完成视觉测试时,可以注释掉该代码。

注意:我们还可以选择不同的形状,大小,颜色,方向以及可能需要的任何内容,以使每个地标在拥挤的场景中脱颖而出。

添加SceneKit节点

createBillboard中,我们创建了一个ARKit锚点并将其添加到ARKit会话中。下一步是将ARKit锚转换为SceneKit节点。

接下来我们需要完善ARSCNViewDelegate方法:

func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {

    // 1 
    guard let billboard = billboard else { return nil } 
    var node: SCNNode? = nil

    // 2 
    //DispatchQueue.main.sync { switch anchor { 
        // 3 
        case billboard.billboardAnchor:
            let billboardNode = addBillboardNode() 
            node = billboardNode 
        default:
             break 
            } 
        //}
   return node
}

当手动将新锚点添加到ARKit会话时,ARKit会调用此方法,让我们有机会通过在方法结束时返回它来为新创建的锚点提供SceneKit节点。

上面的代码作用如下:

  • 1: 验证是否有广告牌,否则退出,返回nil
  • 2: 这是注释掉的,以提醒我们通常不会在主线程中调用此方法。我们我们有在这里进行任何与UI相关的处理,因此任何线程都可以。
  • 3: 在这里检查锚点是否是广告牌的锚点。如果是这样,则调用addBillboardNode(),它返回一个SCNNode。然后将其设置为返回值。

createBillboard()后面,我们添加如下代码:

func addBillboardNode() -> SCNNode? { 
    guard let billboard = billboard else { return nil }
    // 1 
    let rectangle = SCNPlane(width: billboard.plane.width, height: billboard.plane.height)
    // 2 
    let rectangleNode = SCNNode(geometry: rectangle) 
    self.billboard?.billboardNode = rectangleNode
    return rectangleNode
}

上面的代码作用如下:

  • 1: 使用之前在RectangularPlane结构中计算的大小创建SCNPlane
  • 2: 创建一个SCNNode,将平面作为几何体传递。然后,将节点添加到广告牌容器并返回它。

运行程序,效果如下:

位置有一些偏差,不过后面我们会做优化的。

处理中断

除了这个方向问题,一切看起来都很棒。但是,在将应用程序置于后台之前,有一个小问题可能会被忽视。比如下面这些:

  • 1: 运行app
  • 2: 检测矩形。
  • 3: ARKit显示检测到的平面后,按Home键。
  • 4: 应用程序进入后台后,请更改设备的方向。
  • 5: 恢复应用程序。

无论新方向是什么,我们都可以在将应用程序发送到后台之前的同一屏幕位置找到该平面。但是,如果我们在应用程序处于活动状态时更改方向,则该平面将移动到其新位置。

ARKit会话中断时,设备停止向ARKit馈送用于确定相对于当前手机位置和方向的节点位置的硬件传感器信息。

虽然可以使用后台处理作为解决方法,但这不是一个合理的解决方案 - 除非只想使用它几秒钟,例如当用户暂时被外部事件分心并且他们在几个内部返回应用程序时秒。但是定期执行此操作会对设备的资源造成巨大损失。

当用户恢复应用程序时,他们必须重复平面检测过程。

实施需要在会话中断时删除广告牌。在ARSCNViewDelegate中有一个SceneKit委托方法:sessionWasInterrupted

此方法已包含在代码中但它是空的。将这行代码添加到其正文中:

removeBillboard()

这将删除广告牌,以便当我们从后台恢复应用程序时,它将返回到相同的状态。

上一章 目录 下一章