21-苹果官方3D物体扫瞄及检测Demo解读

595 阅读7分钟

说明

ARKit文章目录

Scanning and Detecting 3D Objects的使用及注意点

ARKit的物体检测功能可用于3D检测. 比如识别一个小玩具或Nefertiti奈费尔提蒂(公元前14世纪埃及王后)的雕像.

不过在检测之前,需要先对物体进行扫瞄.

如何获得一个物体?

扫瞄物体类似于建立世界地图.可使用“Scanning and Detecting 3D Objects”示例程序.物体扫瞄的质量会影响物体检测的质量.

打开“Scanning and Detecting 3D Objects”示例程序,点击开始扫瞄物体,会自动生成并不断调整边界盒子.你也可以手动拖动来调整边界盒子的大小,或者用双指旋转盒子以避免个别部位突出边界.

a815ce45b825ab4851b373db2996d437.gif

接着是对各个面的扫瞄,希望从哪个面进行识别,就对哪个面进行扫瞄,可以离近点以获得更好的扫瞄效果.

33c2e646abc56a83df0579bb13333d9f.gif

最后可以拖动调整物体坐标原点.

5d372159c9583d857788f83e5d56d280.gif

为了检测扫瞄效果,可以将app切换到"检测"模式,试试能不能检测到刚才扫瞄的物体.

试着将摄像头对准别处,再移动回来,看不能顺利检测到物体;
试着将物体移动到其他地方,比如灯光不同的地方,测试能否重新检测到物体;
如果不能顺利识别,应调整重新进行扫瞄.

物体扫瞄

如果对效果满意,就可以分享到Xcode中,以供检测识别.

适合追踪的物体

适合用于追踪的物体应有以下特点:

  • 刚性物体
  • 纹理细节丰富
  • 无表面反射
  • 无透明效果

物体追踪API

API类似于图片追踪

// Load Objects from Assets
let objects = ARReferenceObjects.referenceObjects(inGroupNamed: "Object", bundle: Bundle.main)
guard let objects = objects else {
    print("Error loading objects")
    return
}
// Create a session configuration
let configuration = ARWorldTrackingConfiguration()
configuration.detectionObjects = objects
 // Run the session
 let session = ARSession() session.run(configuration)

获取结果

检测结果也是在代理方法中获取

func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
    for anchor in anchors {
        if let objectAnchor = anchor as? ARObjectAnchor {
            let objectName = objectAnchor.referenceObject.name ?? "" 
            print("Object found: \(objectName)")
        } 
    } 
} 

物体追踪vs.世界地图重定位

你可能会觉得物体追踪世界地图重定位有点类似,但其实它们还是有一些不同的.

物体追踪中最重要的是给出识别到的物体,在世界坐标系中的位置;而在世界地图重定位中则是给出摄像机在世界地图中的位置;

另外,物体追踪中可以识别多个物体.最适用于识别追踪放在桌面上或家具上的物体,因为下面的物体可提供有效支持有助识别.

代码解析

下面我们来看看整个项目的代码,先从代码整体逻辑开始.

整体逻辑

主要的类,及其主要作用如下

各个类之间的逻辑如下 可以看到以ViewController类为流程控制中枢,以State来控制整个流程,利用其Set方法来自动处理相应刷新.

enum State {
    case startARSession
    case notReady
    case scanning
    case testing
}

我们可以看到最主要的流程是Scan流程,由Scan类来管理.在这个流程:

  1. 我们要根据ARSession的回调,来不断更新被扫瞄物体,被扫瞄的点云,还有其坐标轴和边界盒;
  2. 要根据手势的拖动,来不断更新被扫瞄物体,被扫瞄的点云,还有其坐标轴和边界盒; 而Scan类中,也是依靠一个State来控制扫瞄流程中的各个步骤的,同样也利用Set方法来自动处理相应刷新.
enum State {
   case ready
   case defineBoundingBox
   case scanning
   case adjustingOrigin
}

其次还有TestRun流程,但相应要简单一些,没有了复杂的3D UI处理,也没有再使用State来控制流程.

几个有意思的方法

BoundingBox类中的fitOverPointCloud方法

告诉我们应该如何过滤识别出的特征点,太远的点不要,太稀疏的点不要.最后转换坐标系并更新边界盒的大小:

func fitOverPointCloud(_ pointCloud: ARPointCloud, focusPoint: float3?) {
    var filteredPoints: [vector_float3] = []
    
    for point in pointCloud.points {
        if let focus = focusPoint {
            // Skip this point if it is more than maxDistanceToFocusPoint meters away from the focus point.
            // 如果该点距离焦点大于maxDistanceToFocusPoint,忽略该点.
            let distanceToFocusPoint = length(point - focus)
            if distanceToFocusPoint > maxDistanceToFocusPoint {
                continue
            }
        }
        
        // Skip this point if it is an outlier (not at least 3 other points closer than 3 cm)
        // 如果是异常点,跳过该点(某个点周围3cm内至少要有3个点的其他点,否则不要该点)
        var nearbyPoints = 0
        for otherPoint in pointCloud.points {
            if distance(point, otherPoint) < 0.03 {
                nearbyPoints += 1
                if nearbyPoints >= 3 {
                    filteredPoints.append(point)
                    break
                }
            }
        }
    }
    
    guard !filteredPoints.isEmpty else { return }
    
    var localMin = -extent / 2
    var localMax = extent / 2
    
    for point in filteredPoints {
        // The bounding box is in local coordinates, so convert point to local, too.
        // 边界盒是在本地坐标系中,所以也需要将点转换到本地坐标系中.
        let localPoint = self.simdConvertPosition(point, from: nil)
        
        localMin = min(localMin, localPoint)
        localMax = max(localMax, localPoint)
    }
    
    // Update the position & extent of the bounding box based on the new min & max values.
    // 根据最新的最小值&最大值,更新边界盒的位置和面积.
    self.simdPosition += (localMax + localMin) / 2
    self.extent = localMax - localMin
}

BoundingBox类中的updateCapturingProgress方法

展示了如何计算各个图块内已经识别出的特征点的数目,如果已经足够多特征点了,图块变为黄色,识别下一个图块.该方法还考虑了性能问题:

func updateCapturingProgress() {
    guard let camera = sceneView.pointOfView, !self.contains(camera.simdWorldPosition) else { return }
    
    frameCounter += 1
    
    // Add new hit test rays at a lower frame rate to keep the list of previous rays
    // at a reasonable size.
    // 添加新的低帧率命中测试射线,以将先前射线列表保持在合理的大小.(每一帧都发射一个射线的话,射线列表就太多了)
    if frameCounter % 20 == 0 {
        frameCounter = 0
        
        // Create a new hit test ray. A line segment defined by its start and end point
        // is used to hit test against bounding box tiles. The ray's length allows for
        // intersections if the user is no more than five meters away from the bounding box.
        // 创建一个新的命中测试射线.该线段起点是发出点(相机),终点则是命中边界盒图块的点.射线的长度决定了能否交互:如果用户距离边界盒超过五米,则不允许交互.
        let currentRay = Ray(from: camera, length: 5.0)
        
        // Only remember the ray if it hit the bounding box,
        // and the hit location is significantly different from all previous hit locations.
        // 只有命中边界盒的射线才会被记录下来,并且命中位置必须明显不同与先前的其他命中位置.
        if let (_, hitLocation) = tile(hitBy: currentRay) {
            if isHitLocationDifferentFromPreviousRayHitTests(hitLocation) {
                cameraRaysAndHitLocations.append((ray: currentRay, hitLocation: hitLocation))
            }
        }
    }
    
    // Update tiles at a frame rate that provides a trade-off between responsiveness and performance.
    // 以低帧率更新图块,以在响应和性能之间取得平衡.
    guard frameCounter % 10 == 0, !isUpdatingCapturingProgress else { return }
    
    self.isUpdatingCapturingProgress = true
    
    var capturedTiles: [Tile] = []
    
    // Perform hit tests with all previous rays.
    // 用以前所有的射线,执行命中测试.
    for hitTest in self.cameraRaysAndHitLocations {
        if let (tile, _) = self.tile(hitBy: hitTest.ray) {
            capturedTiles.append(tile)
            tile.isCaptured = true
        }
    }
    
    for (_, side) in self.sides {
        side.tiles.forEach {
            if !capturedTiles.contains($0) {
                $0.isCaptured = false
            }
        }
    }
    
    // Update the opacity of all tiles.
    // 更新所有图块的不透明度.
    for (_, side) in self.sides {
        side.tiles.forEach { $0.updateVisualization() }
    }
    
    // Update scan percentage for all sides, except the bottom
    // 更新所有面的扫瞄进度,除了底面.
    var sum: Float = 0
    for (pos, side) in self.sides where pos != .bottom {
        sum += side.completion / 5.0
    }
    let progressPercentage: Int = min(Int(floor(sum * 100)), 100)
    if self.progressPercentage != progressPercentage {
        self.progressPercentage = progressPercentage
        NotificationCenter.default.post(name: BoundingBox.scanPercentageChangedNotification,
                                        object: self,
                                        userInfo: [BoundingBox.scanPercentageUserInfoKey: progressPercentage])
    }
    
    self.isUpdatingCapturingProgress = false
}

Snapping中的自动捕捉对齐/自动吸附功能

Snapping中对BoundingBoxObjectOrigin添加了类扩展,提供了距离足够近时,自动捕捉对齐的功能,ObjectOrigin中还可以处理旋转角度的捕捉对齐.以BoundingBox的自动对齐水平面方法为例:

extension BoundingBox {
    
    func snapToHorizontalPlane() {
        // Snap to align with horizontal plane if y-position is close enough
        // 如果y方向非常接近,则按水平平面捕捉对齐.
        let snapThreshold: Float = 0.01
        var isWithinSnapThreshold = false
        let bottomY = simdWorldPosition.y - extent.y / 2
        
        guard let currentFrame = ViewController.instance!.sceneView.session.currentFrame else { return }
        
        for anchor in currentFrame.anchors where anchor is ARPlaneAnchor {
            let distanceFromHorizontalPlane = abs(bottomY - anchor.transform.position.y)
            
            if distanceFromHorizontalPlane < snapThreshold {
                isWithinSnapThreshold = true
                self.simdWorldPosition.y = anchor.transform.position.y + extent.y / 2
                
                // Provide haptic feedback when reaching the snapThreshold for the first time
                // 当第一次达到snapThreshold时,提供触觉反馈.
                if !isSnappedToHorizontalPlane {
                    isSnappedToHorizontalPlane = true
                    playHapticFeedback()
                }
            }
        }
        
        if !isWithinSnapThreshold {
            isSnappedToHorizontalPlane = false
        }
    }
}

其他小技巧

  • Utilities中有不少矩阵转换操作,非常便利.其中dragPlaneTransform方法特地添加了大量注释,解释这样构造平面的好处,非常值得参考学习.
  • ObjectOriginAxis中,为了让用户更容易选中并操作坐标轴,特地对坐标轴的几何体进行了放大处理,使其更容易选中.
  • BoundingBoxSide中,边界盒的每个面上,有多少个图块是在setupTiles方法中动态计算出来的.

最后

代码github.com/XanderXu/Sc…及README.md文件翻译已发布于github.