diff --git a/ios-6dof-demo/ARCaptureDemo/ARCaptureDemoApp.swift b/ios-6dof-demo/ARCaptureDemo/ARCaptureDemoApp.swift new file mode 100644 index 0000000..bf45e6c --- /dev/null +++ b/ios-6dof-demo/ARCaptureDemo/ARCaptureDemoApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct ARCaptureDemoApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios-6dof-demo/ARCaptureDemo/ARCaptureManager.swift b/ios-6dof-demo/ARCaptureDemo/ARCaptureManager.swift new file mode 100644 index 0000000..84ed4e7 --- /dev/null +++ b/ios-6dof-demo/ARCaptureDemo/ARCaptureManager.swift @@ -0,0 +1,147 @@ +import Foundation +import ARKit + +final class ARCaptureManager: NSObject, ObservableObject { + @Published var isCapturing = false + @Published var statusText = "Initializing AR Session..." + @Published var isSessionInterrupted = false + + let session = ARSession() + + private let recorder = FrameRecorder(frameInterval: 0.5) + private var hasConfiguredSession = false + + override init() { + super.init() + session.delegate = self + } + + func startSessionIfNeeded() { + guard !hasConfiguredSession else { return } + guard ARWorldTrackingConfiguration.isSupported else { + updatePublishedStatus(text: "ARWorldTrackingConfiguration is not supported on this device.") + return + } + + let configuration = ARWorldTrackingConfiguration() + configuration.worldAlignment = .gravity + + if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) { + configuration.frameSemantics.insert(.sceneDepth) + } + + session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) + hasConfiguredSession = true + + if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) { + updatePublishedStatus(text: "AR running (sceneDepth supported). Ready to capture.") + } else { + updatePublishedStatus(text: "AR running (sceneDepth unavailable). Ready to capture.") + } + } + + func startCapture() { + guard !isCapturing else { return } + startSessionIfNeeded() + + let supportsDepth = ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) + + do { + try recorder.startNewSession(hasDepthSensor: supportsDepth) + updatePublishedStates(isCapturing: true, statusText: "Capturing...") + } catch { + updatePublishedStatus(text: "Failed to start capture: \(error.localizedDescription)") + } + } + + func stopCapture() { + guard isCapturing else { return } + + do { + let savedSessionURL = try recorder.stopAndFlushManifest() + + if let sessionURL = savedSessionURL { + updatePublishedStates(isCapturing: false, statusText: "Capture saved: \(sessionURL.lastPathComponent)") + } else { + updatePublishedStates(isCapturing: false, statusText: "Capture stopped (no session directory).") + } + } catch { + updatePublishedStates(isCapturing: false, statusText: "Stop failed: \(error.localizedDescription)") + } + } + + private func updatePublishedStatus(text: String) { + DispatchQueue.main.async { + self.statusText = text + } + } + + private func updatePublishedStates(isCapturing: Bool, statusText: String) { + DispatchQueue.main.async { + self.isCapturing = isCapturing + self.statusText = statusText + } + } + + private func updateInterruptionState(isInterrupted: Bool, statusText: String) { + DispatchQueue.main.async { + self.isSessionInterrupted = isInterrupted + self.statusText = statusText + } + } + + private func trackingLimitedReasonDescription(_ reason: ARCamera.TrackingState.Reason) -> String { + switch reason { + case .initializing: + return "initializing" + case .excessiveMotion: + return "excessiveMotion" + case .insufficientFeatures: + return "insufficientFeatures" + case .relocalizing: + return "relocalizing" + @unknown default: + return "unknown" + } + } +} + +extension ARCaptureManager: ARSessionDelegate { + func session(_ session: ARSession, didUpdate frame: ARFrame) { + guard isCapturing else { return } + recorder.appendFrame(frame) + } + + func session(_ session: ARSession, didFailWithError error: Error) { + updatePublishedStatus(text: "ARSession failed: \(error.localizedDescription)") + } + + func sessionWasInterrupted(_ session: ARSession) { + updateInterruptionState(isInterrupted: true, statusText: "Session interrupted. Capture paused.") + if isCapturing { + stopCapture() + } + } + + func sessionInterruptionEnded(_ session: ARSession) { + updateInterruptionState(isInterrupted: false, statusText: "Interruption ended. Resetting session...") + hasConfiguredSession = false + startSessionIfNeeded() + } + + func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { + switch camera.trackingState { + case .normal: + if isCapturing { + updatePublishedStatus(text: "Capturing...") + } else { + updatePublishedStatus(text: "Tracking normal. Ready to capture.") + } + case .notAvailable: + updatePublishedStatus(text: "Tracking unavailable.") + case .limited(let reason): + let reasonText = trackingLimitedReasonDescription(reason) + updatePublishedStatus(text: "Tracking limited: \(reasonText)") + } + } +} diff --git a/ios-6dof-demo/ARCaptureDemo/ARSessionView.swift b/ios-6dof-demo/ARCaptureDemo/ARSessionView.swift new file mode 100644 index 0000000..cf09dc6 --- /dev/null +++ b/ios-6dof-demo/ARCaptureDemo/ARSessionView.swift @@ -0,0 +1,17 @@ +import SwiftUI +import ARKit +import SceneKit + +struct ARSessionView: UIViewRepresentable { + let session: ARSession + + func makeUIView(context: Context) -> ARSCNView { + let view = ARSCNView(frame: .zero) + view.automaticallyUpdatesLighting = true + view.scene = SCNScene() + view.session = session + return view + } + + func updateUIView(_ uiView: ARSCNView, context: Context) {} +} diff --git a/ios-6dof-demo/ARCaptureDemo/CaptureRecord.swift b/ios-6dof-demo/ARCaptureDemo/CaptureRecord.swift new file mode 100644 index 0000000..a1cda6a --- /dev/null +++ b/ios-6dof-demo/ARCaptureDemo/CaptureRecord.swift @@ -0,0 +1,11 @@ +import Foundation + +struct CaptureRecord: Codable { + let imageFile: String + let timestamp: TimeInterval + let transform: [Float] + let depthFile: String? + let depthWidth: Int? + let depthHeight: Int? + let depthBytesPerRow: Int? +} diff --git a/ios-6dof-demo/ARCaptureDemo/CaptureSessionManifest.swift b/ios-6dof-demo/ARCaptureDemo/CaptureSessionManifest.swift new file mode 100644 index 0000000..5d520b5 --- /dev/null +++ b/ios-6dof-demo/ARCaptureDemo/CaptureSessionManifest.swift @@ -0,0 +1,9 @@ +import Foundation + +struct CaptureSessionManifest: Codable { + let sessionId: String + let startedAtISO8601: String + let hasDepthSensor: Bool + let frameIntervalSeconds: Double + let records: [CaptureRecord] +} diff --git a/ios-6dof-demo/ARCaptureDemo/ContentView.swift b/ios-6dof-demo/ARCaptureDemo/ContentView.swift new file mode 100644 index 0000000..b9558d2 --- /dev/null +++ b/ios-6dof-demo/ARCaptureDemo/ContentView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var manager = ARCaptureManager() + + var body: some View { + ZStack(alignment: .bottom) { + ARSessionView(session: manager.session) + .ignoresSafeArea() + + VStack(spacing: 12) { + Text(manager.statusText) + .font(.footnote) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.black.opacity(0.55), in: Capsule()) + + HStack(spacing: 12) { + Button("Start Capture") { + manager.startCapture() + } + .buttonStyle(.borderedProminent) + .tint(.green) + .disabled(manager.isCapturing) + + Button("Stop Capture") { + manager.stopCapture() + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(!manager.isCapturing) + } + } + .padding(.bottom, 24) + } + .onAppear { + manager.startSessionIfNeeded() + } + } +} + +#Preview { + ContentView() +} diff --git a/ios-6dof-demo/ARCaptureDemo/FrameRecorder.swift b/ios-6dof-demo/ARCaptureDemo/FrameRecorder.swift new file mode 100644 index 0000000..9d7ef71 --- /dev/null +++ b/ios-6dof-demo/ARCaptureDemo/FrameRecorder.swift @@ -0,0 +1,169 @@ +import Foundation +import ARKit +import UIKit + +final class FrameRecorder { + private(set) var isRecording = false + private(set) var sessionDirectoryURL: URL? + + private let frameInterval: TimeInterval + private var lastSavedTimestamp: TimeInterval = 0 + private var records: [CaptureRecord] = [] + private var sessionId = "" + private var hasDepthSensor = false + private var sessionStartDate: Date? + + init(frameInterval: TimeInterval = 0.5) { + self.frameInterval = frameInterval + } + + func startNewSession(hasDepthSensor: Bool) throws { + let fm = FileManager.default + let capturesRoot = try documentsDirectory() + .appendingPathComponent("captures", isDirectory: true) + try fm.createDirectory(at: capturesRoot, withIntermediateDirectories: true) + + sessionId = "session_\(Int(Date().timeIntervalSince1970))" + let sessionURL = capturesRoot.appendingPathComponent(sessionId, isDirectory: true) + try fm.createDirectory(at: sessionURL, withIntermediateDirectories: true) + + self.hasDepthSensor = hasDepthSensor + sessionDirectoryURL = sessionURL + sessionStartDate = Date() + lastSavedTimestamp = 0 + records = [] + isRecording = true + } + + func appendFrame(_ frame: ARFrame) { + guard isRecording, let sessionDirectoryURL else { return } + + let timestamp = frame.timestamp + if (timestamp - lastSavedTimestamp) < frameInterval { + return + } + + guard let jpegData = makeJPEG(from: frame.capturedImage) else { + return + } + + let index = records.count + let imageFile = String(format: "image_%05d.jpg", index) + let imageURL = sessionDirectoryURL.appendingPathComponent(imageFile) + + do { + try jpegData.write(to: imageURL) + } catch { + print("[FrameRecorder] Failed writing JPEG: \(error)") + return + } + + var depthFileName: String? + var depthWidth: Int? + var depthHeight: Int? + var depthBytesPerRow: Int? + + if let depthMap = frame.sceneDepth?.depthMap, + let depthRaw = makeDepthRawBytes(from: depthMap) { + let name = String(format: "depth_%05d.raw", index) + let url = sessionDirectoryURL.appendingPathComponent(name) + do { + try depthRaw.write(to: url) + depthFileName = name + depthWidth = CVPixelBufferGetWidth(depthMap) + depthHeight = CVPixelBufferGetHeight(depthMap) + depthBytesPerRow = CVPixelBufferGetBytesPerRow(depthMap) + } catch { + print("[FrameRecorder] Failed writing depth map: \(error)") + } + } + + records.append( + CaptureRecord( + imageFile: imageFile, + timestamp: timestamp, + transform: flattenTransform(frame.camera.transform), + depthFile: depthFileName, + depthWidth: depthWidth, + depthHeight: depthHeight, + depthBytesPerRow: depthBytesPerRow + ) + ) + + lastSavedTimestamp = timestamp + } + + func stopAndFlushManifest() throws -> URL? { + guard let sessionDirectoryURL else { return nil } + + let manifest = CaptureSessionManifest( + sessionId: sessionId, + startedAtISO8601: ISO8601DateFormatter().string(from: sessionStartDate ?? Date()), + hasDepthSensor: hasDepthSensor, + frameIntervalSeconds: frameInterval, + records: records + ) + + let jsonData = try JSONEncoder.prettyPrinted.encode(manifest) + let jsonURL = sessionDirectoryURL.appendingPathComponent("metadata.json") + try jsonData.write(to: jsonURL) + + let finishedURL = sessionDirectoryURL + isRecording = false + self.sessionDirectoryURL = nil + sessionId = "" + hasDepthSensor = false + sessionStartDate = nil + return finishedURL + } + + private func flattenTransform(_ matrix: simd_float4x4) -> [Float] { + [ + matrix.columns.0.x, matrix.columns.0.y, matrix.columns.0.z, matrix.columns.0.w, + matrix.columns.1.x, matrix.columns.1.y, matrix.columns.1.z, matrix.columns.1.w, + matrix.columns.2.x, matrix.columns.2.y, matrix.columns.2.z, matrix.columns.2.w, + matrix.columns.3.x, matrix.columns.3.y, matrix.columns.3.z, matrix.columns.3.w + ] + } + + private func makeJPEG(from pixelBuffer: CVPixelBuffer) -> Data? { + let ciImage = CIImage(cvPixelBuffer: pixelBuffer) + let context = CIContext(options: nil) + + guard let cgImage = context.createCGImage(ciImage, from: ciImage.extent) else { + return nil + } + + let image = UIImage(cgImage: cgImage) + return image.jpegData(compressionQuality: 0.9) + } + + private func makeDepthRawBytes(from depthMap: CVPixelBuffer) -> Data? { + CVPixelBufferLockBaseAddress(depthMap, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(depthMap, .readOnly) } + + guard let baseAddress = CVPixelBufferGetBaseAddress(depthMap) else { + return nil + } + + let height = CVPixelBufferGetHeight(depthMap) + let bytesPerRow = CVPixelBufferGetBytesPerRow(depthMap) + let dataSize = height * bytesPerRow + return Data(bytes: baseAddress, count: dataSize) + } + + private func documentsDirectory() throws -> URL { + guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw NSError(domain: "FrameRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "Documents directory not found"]) + } + return url + } +} + +private extension JSONEncoder { + static var prettyPrinted: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} diff --git a/ios-6dof-demo/README.md b/ios-6dof-demo/README.md new file mode 100644 index 0000000..92537b7 --- /dev/null +++ b/ios-6dof-demo/README.md @@ -0,0 +1,36 @@ +# ARCaptureDemo (SwiftUI + ARKit) + +最小可运行的 6DoF 数据采集 Demo。 + +## 文件放置位置 +在 Xcode 新建 iOS App(SwiftUI)后,把以下文件放到项目主 target(例如 `ARCaptureDemo`)中: + +- `ARCaptureDemoApp.swift` +- `ContentView.swift` +- `ARSessionView.swift` +- `ARCaptureManager.swift` +- `FrameRecorder.swift` +- `CaptureRecord.swift` +- `CaptureSessionManifest.swift` + +本仓库示例路径:`ios-6dof-demo/ARCaptureDemo/`。 + +## 必改配置(Info.plist) +增加: +- `Privacy - Camera Usage Description` (`NSCameraUsageDescription`) + - 示例值:`需要使用相机进行 AR 数据采集。` + +## 运行设备 +- 需要真机(例如 iPhone 15 Pro Max) +- 需要 iOS 17+(建议) +- Simulator 无法使用 ARKit World Tracking + +## 采集输出目录 +程序停止采集后会把数据写入: + +`Documents/captures/session_xxx/` + +每个 session 包含: +- `image_00000.jpg` ... (按 0.5 秒间隔采样) +- `depth_00000.raw` ...(仅当 sceneDepth 可用时) +- `metadata.json` (图片文件名、时间戳、4x4 transform、depth 文件名)