ARKit读取LiDAR点云

ARKit 是 Apple 强大的增强现实框架,允许开发人员制作专为 iOS 设备设计的沉浸式交互式 AR 体验。

对于配备 LiDAR 的设备,ARKit 充分利用了深度感应功能,大大提高了环境扫描精度。与许多体积庞大且价格昂贵的传统 LIDAR 系统不同,iPhone 的 LiDAR 结构紧凑、经济高效,并可无缝集成到消费设备中,使更广泛的开发人员和应用程序能够使用高级深度感应。

LiDAR 允许创建点云,点云是一组数据点,表示 3D 空间中物体的表面。

在本文的第一部分中,我们将构建一个应用程序,演示如何提取 LiDAR 数据并将其转换为 AR 3D 环境中的单个点。

第二部分将解释如何将从 LiDAR 传感器连续接收的点合并为统一的点云。最后,我们将介绍如何将这些数据导出为广泛使用的 .PLY 文件格式,以便在各种应用程序中进行进一步分析和利用。

1、先决条件

我们将使用:

  • Xcode 16 和 Swift 6。
  • SwiftUI 用于应用程序的用户界面
  • Swift Concurrency 用于高效的多线程。

请确保你可以使用配备 LiDAR 传感器的 iPhone 或 iPad 来跟进。

2、设置和创建 UI

创建一个新项目 ProjectCloudExample 并删除我们不会使用的所有不必要的文件,只保留 ProjectCloudExampleApp.swift。

空项目

接下来,让我们创建一个带有参与者的 ARManager.swift 来管理 ARSCNView 并处理相关的 AR 会话。由于 SwiftUI 目前缺乏对 ARSCNView 的原生支持,我们将它与 UIKit 桥接。

ARManager 的初始化程序中,我们将其作为 ARSession 的委托,并使用 ARWorldTrackingConfiguration 启动会话。鉴于我们的目标是配备 LiDAR 技术的设备,将 .sceneDepth 属性设置为框架语义至关重要。

import Foundation
import ARKit

actor ARManager: NSObject, ARSessionDelegate, ObservableObject {
    
    @MainActor let sceneView = ARSCNView()

    @MainActor
    override init() {
        super.init()
        
        sceneView.session.delegate = self

        // start session
        let configuration = ARWorldTrackingConfiguration()
        configuration.frameSemantics = .sceneDepth
        sceneView.session.run(configuration)
    }
}

现在让我们打开主 ProjectCloudExampleApp.swift,创建 ARManager 的一个实例作为状态对象,并将我们的 AR 视图呈现给 SwiftUI。我们将使用 UIViewWrapper 来实现后者。

struct UIViewWrapper<V: UIView>: UIViewRepresentable {
    
    let view: UIView
    
    func makeUIView(context: Context) -> some UIView { view }
    func updateUIView(_ uiView: UIViewType, context: Context) { }
}

@main
struct PointCloudExampleApp: App {
    
    @StateObject var arManager = ARManager()
    
    var body: some Scene {
        WindowGroup {
            UIViewWrapper(view: arManager.sceneView).ignoresSafeArea()
        }
    }
}

3、获取 LiDAR 深度数据

让我们回到 ARManager.swift

AR 会话不断生成包含深度和相机图像数据的帧,可以使用委托函数进行处理。

为了保持实时性能,由于时间限制,处理每一帧是不切实际的。相反,我们会在处理一帧时跳过一些帧。

此外,由于我们的 ARManager 是作为参与者实现的,我们将在单独的线程上处理处理。这可以防止在密集操作期间 UI 出现任何潜在的冻结,从而确保流畅的用户体验。

添加 isProcessing 属性来管理正在进行的帧操作,并添加委托函数来处理传入的帧。实现专门用于帧处理的函数。

还添加 isCapturing 属性,我们稍后将在 UI 中使用它来切换捕获。

actor ARManager: NSObject, ARSessionDelegate, ObservableObject {
    
    //...
    @MainActor private var isProcessing = false
    @MainActor @Published var isCapturing = false
    
    // an ARSessionDelegate function for receiving an ARFrame instances
    nonisolated func session(_ session: ARSession, didUpdate frame: ARFrame) {
        Task { await process(frame: frame) }
    }
    
    // process a frame and skip frames that arrive while processing
    @MainActor
    private func process(frame: ARFrame) async {
        guard !isProcessing else { return }
        
        isProcessing = true
        //processing code here
        isProcessing = false
    }
    //...
}

由于我们的处理函数和 isProcessing 属性是独立的,因此我们无需担心线程之间的任何额外同步。

现在让我们创建一个 PointCloud.swift,其中包含一个用于处理 ARFrame 的参与者。

ARFrame 提供 depthMapconfidenceMapcaughtImage,它们都由 CVPixelBuffer 表示,具有不同的格式:

  • depthMap - Float32 缓冲区
  • confidenceMap - UInt8 缓冲区
  • capturedImage - YCbCr 格式的像素缓冲区

你可以将深度图视为 LiDAR 捕获的照片,其中每个像素都包含从相机到表面的距离(以米为单位)。这与捕获的图像提供的相机反馈一致。我们的目标是从捕获的图像中提取颜色并将其用于深度图中的相应像素。

置信度图与深度图共享相同的分辨率,包含从 [1, 3] 开始的值,表示每个像素深度测量的置信度。

actor PointCloud {
    
    func process(frame: ARFrame) async {
        if 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) {
            
	     //process buffers
        }
    }
}

4、从 CVPixelBuffer 访问像素数据

要从 CVPixelBuffer 中提取像素数据,我们将为每种特定格式创建一个类,例如深度、置信度和颜色图。对于深度和置信度图,我们可以设计一个通用类,因为它们都遵循类似的结构。

4.1 深度和置信度缓冲区

CVPixelBuffer 读取的核心概念相对简单:我们需要锁定缓冲区以确保对其数据的独占访问。锁定后,我们可以通过计算要访问的像素的正确偏移量直接读取内存。

Value = Y * bytesPerRow + X
//struct for storing CVPixelBuffer resolution
struct Size {
    let width: Int
    let height: Int
    
    var asFloat: simd_float2 {
        simd_float2(Float(width), Float(height))
    }
}

final class PixelBuffer<T> {
    
    let size: Size
    let bytesPerRow: Int

    private let pixelBuffer: CVPixelBuffer
    private let baseAddress: UnsafeMutableRawPointer
    
    init?(pixelBuffer: CVPixelBuffer) {
        self.pixelBuffer = pixelBuffer

        // lock the buffer while we are getting its values
        CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
        
        guard let baseAddress = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) else {
            CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
            return nil
        }
        self.baseAddress = baseAddress
        
        size = .init(width: CVPixelBufferGetWidth(pixelBuffer),
                     height: CVPixelBufferGetHeight(pixelBuffer))
        bytesPerRow =  CVPixelBufferGetBytesPerRow(pixelBuffer)
    }
    
    // obtain value from pixel buffer in specified coordinates
    func value(x: Int, y: Int) -> T {

        // move to the specified address and get the value bounded to our type
        let rowPtr = baseAddress.advanced(by: y * bytesPerRow)
        return rowPtr.assumingMemoryBound(to: T.self)[x]
    }
    
    deinit {
        CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
    }
}

4.2 YCbCr 捕获图像缓冲区

与使用典型的 RGB 缓冲区相比,从 YCbCr 格式的像素缓冲区中提取颜色值需要付出更多努力。YCbCr 颜色空间将亮度 (Y) 与色度 (Cb 和 Cr) 分开,这意味着我们必须将这些组件转换为更熟悉的 RGB 格式。

为了实现这一点,我们首先需要访问像素缓冲区内的 Y 和 Cb/Cr 平面。这些平面保存每个像素的必要数据。一旦我们从各自的平面获得值,我们就可以将它们转换为 RGB 值。转换依赖于一个众所周知的公式,其中 Y、Cb 和 Cr 值通过某些偏移量进行调整,然后乘以特定系数以产生最终的红色、绿色和蓝色值。

final class YCBCRBuffer {
    
    let size: Size
    
    private let pixelBuffer: CVPixelBuffer
    private let yPlane: UnsafeMutableRawPointer
    private let cbCrPlane: UnsafeMutableRawPointer
    private let ySize: Size
    private let cbCrSize: Size
    
    init?(pixelBuffer: CVPixelBuffer) {
        self.pixelBuffer = pixelBuffer
        CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
        
        guard let yPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
                let cbCrPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1) else {
            CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
            return nil
        }
        
        self.yPlane = yPlane
        self.cbCrPlane = cbCrPlane
 
        size = .init(width: CVPixelBufferGetWidth(pixelBuffer),
                     height: CVPixelBufferGetHeight(pixelBuffer))
        
        ySize = .init(width: CVPixelBufferGetWidthOfPlane(pixelBuffer, 0),
                      height: CVPixelBufferGetHeightOfPlane(pixelBuffer, 0))
        
        cbCrSize = .init(width: CVPixelBufferGetWidthOfPlane(pixelBuffer, 1),
                         height: CVPixelBufferGetHeightOfPlane(pixelBuffer, 1))
    }
    
    func color(x: Int, y: Int) -> simd_float4 {
        let yIndex = y * CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0) + x
        let uvIndex = y / 2 * CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1) + x / 2 * 2
        
        // Extract the Y, Cb, and Cr values
        let yValue = yPlane.advanced(by: yIndex)
                .assumingMemoryBound(to: UInt8.self).pointee

        let cbValue = cbCrPlane.advanced(by: uvIndex)
                .assumingMemoryBound(to: UInt8.self).pointee

        let crValue = cbCrPlane.advanced(by: uvIndex + 1)
                .assumingMemoryBound(to: UInt8.self).pointee
        
        // Convert YCbCr to RGB
        let y = Float(yValue) - 16
        let cb = Float(cbValue) - 128
        let cr = Float(crValue) - 128
        
        let r = 1.164 * y + 1.596 * cr
        let g = 1.164 * y - 0.392 * cb - 0.813 * cr
        let b = 1.164 * y + 2.017 * cb
        
        // normalize rgb components
        return simd_float4(max(0, min(255, r)) / 255.0,
                           max(0, min(255, g)) / 255.0,
                           max(0, min(255, b)) / 255.0, 1.0)
    }
    
    deinit {
        CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
    }
}

4.3 读取深度和颜色

现在我们已经设置了必要的缓冲区,我们可以返回 PointCloud 参与者中的核心处理功能。下一步是为我们的顶点数据创建一个结构,它将包括每个点的 3D 位置和颜色。

struct Vertex {
     let position: SCNVector3
     let color: simd_float4
}

接下来,我们需要遍历深度图中的每个像素,获取相应的置信度值和颜色。

我们将根据最佳置信度和距离筛选点,因为由于深度传感技术的性质,在较远距离捕获的点往往具有较低的准确性。

深度图和捕获的图像具有不同的分辨率。因此,为了正确地将深度数据映射到其相应的颜色,我们需要进行适当的坐标转换。

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 }
       
    // 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 pixelRow = Int(round(normalizedCoord.y * imageSize.y))
            let pixelColumn = Int(round(normalizedCoord.x * imageSize.x))
                        
            let color = imageBuffer.color(x: pixelColumn, y: pixelRow)
        }
    }
}

5、将点转换为 3D 场景坐标

我们首先计算所拍摄照片上的点 2D 坐标:

let screenPoint = simd_float3(normalizedCoord * imageSize, 1)

使用相机内在函数,我们将此点转换为具有指定深度值的相机坐标空间中的 3D 点:

let localPoint = simd_inverse(frame.camera.intrinsics) * screenPoint *depth

iPhone 相机与手机本身不对齐,这意味着当你将 iPhone 保持在纵向时,相机会给我们一张实际上具有横向正确方向的图像。此外,为了正确地将点从相机的本地坐标转换为世界坐标,我们需要对 Y 轴和 Z 轴应用翻转变换。

让我们为此制作一个变换矩阵。

func makeRotateToARCameraMatrix(orientation: UIInterfaceOrientation) -> matrix_float4x4 {
    // Flip Y and Z axes to align with ARKit's camera coordinate system
    let flipYZ = matrix_float4x4(
        [1, 0, 0, 0],
        [0, -1, 0, 0],
        [0, 0, -1, 0],
        [0, 0, 0, 1]
    )
    // Get rotation angle in radians based on the display orientation
    let rotationAngle: Float = switch orientation {
        case .landscapeLeft: .pi
        case .portrait: .pi / 2
        case .portraitUpsideDown: -.pi / 2
        default: 0
    }
    // Create a rotation matrix around the Z-axis
    let quaternion = simd_quaternion(rotationAngle, simd_float3(0, 0, 1))
    let rotationMatrix = matrix_float4x4(quaternion)

    // Combine flip and rotation matrices
    return flipYZ * rotationMatrix
}

let rotateToARCamera = makeRotateToARCameraMatrix(orientation: .portrait)

// the result transformation matrix for converting point from local camera coordinates to the world coordinates
let cameraTransform = frame.camera.viewMatrix(for: .portrait).inverse * rotateToARCamera

最后将局部点与变换矩阵相乘,然后进行归一化,即可得到结果点。

// Converts the local camera space 3D point into world space using the camera's transformation matrix.
let worldPoint = cameraTransform * simd_float4(localPoint, 1)
let resulPosition = (worldPoint / worldPoint.w)

6、结束语

在第一部分中,我们为使用 ARKit 和 LiDAR 创建点云奠定了基础。我们探索了如何从 LiDAR 传感器获取深度数据以及相应的图像,将每个像素转换为 3D 空间中的彩色点。我们还根据置信度过滤点以确保数据准确性。

在第二部分中,我们将研究如何将捕获的点合并为统一的点云,在我们的 AR 视图中将其可视化并导出为 .PLY 文件格式以供进一步使用。


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

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