ARKit可视化LiDAR点云

在探索创建点云的第二部分中,我们将在第一部分建立的基础之上。

捕获并处理单个彩色点后,我们的下一个目标是将这些点合并为统一的点云。之后,我们将在 AR 视图中可视化点云,并最终将其导出到 .PLY 文件。

1、存储顶点

现在,我们从深度图中获得了每个点的颜色和 3D 位置。下一步是将这些点存储在点云中,但简单地附加我们捕获的每个点效率不高,并且可能导致不必要的大数据集。为了有效地处理这个问题,我们需要过滤来自不同深度图的点。

我们将使用基于网格的算法,而不是处理每个点。这种方法涉及将 3D 空间划分为均匀的网格单元或预定义大小的“框”。对于每个网格单元,我们只存储一个代表点,从而有效地对数据进行下采样。通过调整这些框的大小,我们可以控制点云的密度。

这种方法不仅减少了存储的数据量,还使我们能够根据应用程序的要求灵活地微调点云密度——无论我们需要更高的精度来获取详细的数据,还是需要更轻的数据集来加快处理速度。

网格中的过滤点云

让我们定义一个 Vertex、GridKey 和一个相应的字典作为 PointCloud 参与者的一部分。

actor PointCloud {

    //--
    struct GridKey: Hashable {
        
        static let density: Float = 100
    
        private let id: Int
        
        init(_ position: SCNVector3) {
            var hasher = Hasher()
            for component in [position.x, position.y, position.z] {
                hasher.combine(Int(round(component * Self.density)))
            }
            id = hasher.finalize()
        }
    }
    
    struct Vertex {
        let position: SCNVector3
        let color: simd_float4
    }
    
    private(set) var vertices: [GridKey: Vertex] = [:]
    //--

}

GridKey 的设计目的是根据指定的密度对点坐标进行舍入,这样我们就可以将彼此靠近的点分配给同一个键。

我们现在可以完成 process函数了。

func process(frame: ARFrame) async {
    guard let depth = (frame.smoothedSceneDepth ?? frame.sceneDepth),
          let depthBuffer = PixelBuffer<Float32>(pixelBuffer: depth.depthMap),
          let confidenceMap = depth.confidenceMap,
          let confidenceBuffer = PixelBuffer<UInt8>(pixelBuffer: confidenceMap),
          let imageBuffer = YCBCRBuffer(pixelBuffer: frame.capturedImage) else { return }
       
    let rotateToARCamera = makeRotateToARCameraMatrix(orientation: .portrait)
    let cameraTransform = frame.camera.viewMatrix(for: .portrait).inverse * rotateToARCamera
    
    // iterate through pixels in depth buffer
    for row in 0..<depthBuffer.size.height {
        for col in 0..<depthBuffer.size.width {
            // get confidence value
            let confidenceRawValue = Int(confidenceBuffer.value(x: col, y: row))
            guard let confidence = ARConfidenceLevel(rawValue: confidenceRawValue) else {
                continue
            }
                        
            // filter by confidence
            if confidence != .high { continue }
                        
            // get distance value from
            let depth = depthBuffer.value(x: col, y: row)
                        
            // filter points by distance
            if depth > 2 { return }
                        
            let normalizedCoord = simd_float2(Float(col) / Float(depthBuffer.size.width),
                                              Float(row) / Float(depthBuffer.size.height))
                        
            let imageSize = imageBuffer.size.asFloat
            let screenPoint = simd_float3(normalizedCoord * imageSize, 1)

            // Transform the 2D screen point into local 3D camera space
            let localPoint = simd_inverse(frame.camera.intrinsics) * screenPoint * depth
                
            // Converts the local camera space 3D point into world space.
            let worldPoint = cameraTransform * simd_float4(localPoint, 1)
                
            // Normalizes the result.
            let resulPosition = (worldPoint / worldPoint.w)
                
            let pointPosition = SCNVector3(x: resulPosition.x, 
                                           y: resulPosition.y, 
                                           z: resulPosition.z)

            let key = PointCloud.GridKey(pointPosition)
                
            if vertices[key] == nil {
                let pixelRow = Int(round(normalizedCoord.y * imageSize.y))
                let pixelColumn = Int(round(normalizedCoord.x * imageSize.x))
                let color = imageBuffer.color(x: pixelColumn, y: pixelRow)


                vertices[key] = PointCloud.Vertex(position: pointPosition,
                                                  color: color)
            }
        }
    }
}

2、点云可视化

为了在 AR 视图中可视化点云,我们将在每次处理帧时创建一个 SCNGeometry,并使用 SCNNode 显示。这种方法使我们能够实时动态渲染点云。

但是,渲染高密度点云会严重消耗设备的资源,可能会导致性能问题。为了解决这个问题并保持流畅的渲染,我们将实施一个微小的优化,即仅从云中绘制每 10 个点。这种选择性渲染将有助于平衡视觉保真度和性能,确保应用程序保持响应,同时点云仍能传达扫描环境的基本细节。

让我们在 ARManager 中创建一个 geometryNode 并将其附加到初始化程序中的 rootNode

接下来,我们将添加一个异步 updateGeometry 函数,该函数将顶点从点云转换为 SCNGeometry 并替换 geometryNode 中的几何图形。

最后,我们将此 updateGeometry 函数集成到我们的处理管道中。

actor ARManager: NSObject, ARSessionDelegate, ObservableObject {
    
    //-- 
    @MainActor let geometryNode = SCNNode()
    //-- 
    
    @MainActor
    override init() {
        //--          
        sceneView.scene.rootNode.addChildNode(geometryNode)
    }
    
    @MainActor
    private func process(frame: ARFrame) async {
        guard !isProcessing && isCapturing else { return }
        
        isProcessing = true
        await pointCloud.process(frame: frame)
        await updateGeometry() // <- add here the geometry update
        isProcessing = false
    }

    func updateGeometry() async {
        // make an array of every 10th point
        let vertices = await pointCloud.vertices.values.enumerated().filter { index, _ in
                index % 10 == 9
            }.map { $0.element }
        
        // create a vertex source for geometry
        let vertexSource = SCNGeometrySource(vertices: vertices.map { $0.position } )
        
        // create a color source
        let colorData = Data(bytes: vertices.map { $0.color }, 
                             count: MemoryLayout<simd_float4>.size * vertices.count)

        let colorSource = SCNGeometrySource(data: colorData,
                                            semantic: .color,
                                            vectorCount: vertices.count,
                                            usesFloatComponents: true,
                                            componentsPerVector: 4,
                                            bytesPerComponent: MemoryLayout<Float>.size,
                                            dataOffset: 0,
                                            dataStride: MemoryLayout<SIMD4<Float>>.size)

        // as we don't use proper geometry, we can pass just an array of 
        // indices to our geometry element
        let pointIndices: [UInt32] = Array(0..<UInt32(vertices.count))
        let element = SCNGeometryElement(indices: pointIndices, primitiveType: .point)
        
        // here we can customize the size of the point, rendered in ARView
        element.maximumPointScreenSpaceRadius = 15
        
        let geometry = SCNGeometry(sources: [vertexSource, colorSource], 
                                   elements: [element])
        geometry.firstMaterial?.isDoubleSided = true
        geometry.firstMaterial?.lightingModel = .constant
        
        Task { @MainActor in
            geometryNode.geometry = geometry
        }
    }

3、将点云导出到 .PLY 文件

最后,我们将捕获的点云导出到 .PLY 文件,利用 Transferable 协议在 SwiftUI ShareLink 中实现无缝数据处理。

.PLY 文件格式相对简单,由一个文本文件组成,其中包含一个指定数据内容和结构的标题,后面是顶点列表及其相应的颜色成分。

struct PLYFile: Transferable {
    
    let pointCloud: PointCloud
    
    enum Error: LocalizedError {
        case cannotExport
    }
    
    func export() async throws -> Data {
        let vertices = await pointCloud.vertices
        
        var plyContent = """
        ply
        format ascii 1.0
        element vertex \(vertices.count)
        property float x
        property float y
        property float z
        property uchar red
        property uchar green
        property uchar blue
        property uchar alpha
        end_header
        """
        
        for vertex in vertices.values {
            // Convert position and color
            let x = vertex.position.x
            let y = vertex.position.y
            let z = vertex.position.z
            let r = UInt8(vertex.color.x * 255)
            let g = UInt8(vertex.color.y * 255)
            let b = UInt8(vertex.color.z * 255)
            let a = UInt8(vertex.color.w * 255)
            
            // Append the vertex data
            plyContent += "\n\(x) \(y) \(z) \(r) \(g) \(b) \(a)"
        }
        
        guard let data = plyContent.data(using: .ascii) else {
            throw Error.cannotExport
        }
        return data
    }
    
    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(exportedContentType: .data) {
            try await $0.export()
        }.suggestedFileName("exported.ply")
    }
}

4、完成 UI

现在我们已经构建了应用的核心功能,我们可以完成 UI 了。

UI 将包含一个用于启动和停止点云捕获的按钮,以及一个用于导出和共享生成的点云文件的选项。

在我们的主视图中,我们将创建一个 ZStack,它将覆盖 AR 视图,其中包含用于控制捕获过程和共享结果的按钮。

@main
struct PointCloudExampleApp: App {
    
    @StateObject var arManager = ARManager()
    
    var body: some Scene {
        WindowGroup {
            ZStack(alignment: .bottom) {
                UIViewWrapper(view: arManager.sceneView).ignoresSafeArea()
                
                HStack(spacing: 30) {
                    Button {
                        arManager.isCapturing.toggle()
                    } label: {
                        Image(systemName: arManager.isCapturing ? 
                                          "stop.circle.fill" : 
                                          "play.circle.fill")
                    }
                    
                    ShareLink(item: PLYFile(pointCloud: arManager.pointCloud), 
                                            preview: SharePreview("exported.ply")) {
                        Image(systemName: "square.and.arrow.up.circle.fill")
                    }
                }.foregroundStyle(.black, .white)
                    .font(.system(size: 50))
                    .padding(25)
            }
        }
    }
}
捕捉点云
导出.ply文件预览

5、最后的想法

在这篇由两部分组成的文章中,我们构建了一个基本的 AR 应用程序,该应用程序能够使用 ARKit 和 LiDAR 在 Swift 中生成和呈现 3D 点云。我们发现了如何提取 LiDAR 数据,将其转换为 3D 空间中的点,并将其合并为单个点云,以及将其导出并共享为 .PLY 文件的能力。

这个应用程序只是一个开始。你可以通过添加更高级的过滤等功能来进一步增强它,允许用户调整点云密度,或者通过根据距离或其他因素替换网格字典中的点来提高云质量。

iPhone LiDAR 的紧凑和经济高效特性使开发人员能够使用高级深度感应,并为创新应用程序开辟了无限可能。


原文链接:ARKit & LiDAR: Building Point Clouds in Swift (part 2)

BimAnt翻译整理,转载请标明出处