【iOS 17】オンデバイス完結!オブジェクトキャプチャ→3Dモデル生成の過程を確認してみた

2024.02.29

WWDC23で発表のあったiOS 17から追加されたObjectCaptureViewを使用することで簡単にオブジェクトキャプチャを実現でき、3Dモデルを生成できるということで試してみました。

環境

  • Xcode 15.2
  • iPhone 15 Pro
  • iOS 17.2.1

はじめに

こちらのサンプルを動作させるには、LiDAR搭載のiPhone もしくは、iPadでA14 Bionic チップ以上を搭載している必要があります。

また、今回は実際にどのような過程で3Dモデルが生成できるのかの流れを確認するのがメインになる為、適切なエラーハンドリングやUIUXは考慮はしていません。実際に使用される際は最適化を行っていただければと思います。

使用するAPIの紹介

今回使用するオブジェクトキャプチャ関連のAPIを簡単に紹介します。

ObjectCaptureView

iOS 17から追加されたObjectCaptureViewを使用してオブジェクトキャプチャを試していきます。

このViewは、生成時に引数で渡したObjectCaptureSessionの状態をUIに表示するために使用されます。常にセッションの現在の状態を表示してくれます。

struct ObjectCaptureView<Overlay> where Overlay : View

ObjectCaptureSession

ObjectCaptureSessionは、フォトグラメトリの為の画像キャプチャを監視、制御するセッションオブジェクトです。

@MainActor
class ObjectCaptureSession

ObjectCaptureView常にセッションの現在の状態を表示すると記載したのしたのですが、このプロパティはCaptureStateを持っており、その状態を監視することで現在の状態を判断することが出来ます。

CaptureState

ObjectCaptureSession.CaptureStateは、7つの状態を持っています。

状態 概要
initializing セッションとカメラフィードが初期化されています
ready セッションはキャプチャを開始する為の準備が整っています
detecting オブジェクト選択ボックスが検出/操作されており、まだ完了していません. startCapturing()を呼び出すことで.capturing状態になり、現在のバウンディングボックス内のオブジェクトのキャプチャを開始します
capturing 自動キャプチャが進行中です
finishing セッションは未処理のデータを保存し、終了処理を実施しています
completed セッションはデータを保存し、安全にセッションを終了出来ます。また、画像フォルダはReconstruction(3Dモデルの再構築)に使用できる状態になっています
failed(any Error) リカバリー不能なエラーは発生し、セッションが無効になった為、セッションを終了させる必要があります

PhotogrammetorySession

PhotogrammetrySessionは、一連の画像から3Dモデルの作成を管理するセッションです。

どのように使用するかについては、後半のオブジェクトをキャプチャ後の3Dモデル生成部分で登場しますのでその際に説明いたします。

実際に動かして確認する

それぞれのCaptureStateが画面に実際にどのような影響を与えるのか確認いきます。

今回はCaptureStateを画面上にわかりやすく反映する為にExtensionで変数labelを作成しました。

extension ObjectCaptureSession.CaptureState {

    var label: String {
        switch self {
        case .initializing:
            "initializing"
        case .ready:
            "ready"
        case .detecting:
            "detecting"
        case .capturing:
            "capturing"
        case .finishing:
            "finishing"
        case .completed:
            "completed"
        case .failed(let error):
            "failed: \(String(describing: error))"
        @unknown default:
            fatalError("unknown default: \(self)")
        }
    }
}

initializing

まずは初めの第一歩でinitializing状態を確認します。

import SwiftUI
import RealityKit

struct ContentView: View {

    @State private var session: ObjectCaptureSession?

    var body: some View {

        ZStack(alignment: .bottom) {
            if let session {
                ObjectCaptureView(session: session)

                // CaptureStateの状態ラベル
                Text(session.state.label)
                    .bold()
                    .foregroundStyle(.yellow)
                    .padding(.bottom)
            }
        }
        .task {
            session = ObjectCaptureSession()
        }
    }
}

ObjectCaptureViewはカメラを利用する為、Privacy - Camera Usage Descriptionの記載が必要です。

結果

実機で起動すると、画面が真っ暗でinitializingのラベルが確認出来ました。

ready

ready状態に移行する為には、ObjectCaptureSessionstart(imagesDirectory: URL, configuration: ObjectCaptureSession.Configuration)メソッドを呼び出す必要があります。

その為には、出力画像の保存ディレクトリを指定する必要があります。configurationでcheckpointディレクトリを指定することでオンデバイスでのReconstruction(3Dモデルの再構築)を高速化出来ますが今回は割愛しています。

スキャンデータの保存先を作成

session.startを実行するには、出力画像の保存ディレクトリを指定する必要がある為、保存先を作成するメソッドを作成しました。

extension ContentView {

    func createNewScanDirectory() -> URL? {
        guard let capturesFolder = getRootScansFolder() else { return nil }

        let formatter = ISO8601DateFormatter()
        let timestamp = formatter.string(from: Date())
        let newCaptureDirectory = capturesFolder.appendingPathComponent(timestamp,
                                                                        isDirectory: true)
        print("▶️ Start creating capture path: \(newCaptureDirectory)")
        let capturePath = newCaptureDirectory.path
        do {
            try FileManager.default.createDirectory(atPath: capturePath,
                                                    withIntermediateDirectories: true)
        } catch {
            print("😨Failed to create capture path: \(capturePath) with error: \(String(describing: error))")
        }

        var isDirectory: ObjCBool = false
        let exists = FileManager.default.fileExists(atPath: capturePath,
                                                    isDirectory: &isDirectory)
        guard exists, isDirectory.boolValue else { return nil }
        print("🎉 New capture path was created")
        return newCaptureDirectory
    }

    private func getRootScansFolder() -> URL? {
        guard let documentFolder = try? FileManager.default.url(for: .documentDirectory,
                                                                in: .userDomainMask,
                                                                appropriateFor: nil,
                                                                create: false)
        else { return nil }
        return documentFolder.appendingPathComponent("Scans/", isDirectory: true)
    }
}

session.start

sessionを代入していた後にstart(imagesDirectory:)を呼び出すコードに変更しました。また、画像保存先imageFolderPathも後で使用する為、変数として保持しておきます。

struct ContentView: View {

    @State private var session: ObjectCaptureSession?
    @State private var imageFolderPath: URL? // 画像保存先

    var body: some View {
        // ... 省略
        .task {
            guard let directory = createNewScanDirectory() else { return }
            session = ObjectCaptureSession()
            imageFolderPath = directory.appending(path: "Images/")
            guard let imageFolderPath else { return }
            // セッションを開始
            session?.start(imagesDirectory: imageFolderPath)
        }
    }
}

結果

カメラ許可ダイアログが表示された後、画面上にオブジェクト選択ボックスと中心点、readyの文字が表示されました。

detecting

次は、ready状態になったら、Detectingを開始できるようにボタンを表示するように変更しました。

struct ContentView: View {

    @State private var session: ObjectCaptureSession?
    @State private var imageFolderPath: URL?

    var body: some View {

        ZStack(alignment: .bottom) {
            if let session {
                ObjectCaptureView(session: session)

                VStack(spacing: 16) {

                    if session.state == .ready {

                        // Detecting開始ボタン
                        Button("Start Detecting") {
                            let isDetecting = session.startDetecting()
                            print(isDetecting ? "▶️Start detecting" : "😨Not start detecting")
                        }
                        .foregroundStyle(.white)
                        .padding()
                        .background(RoundedRectangle(cornerRadius: 36).fill(.tint))
                    }
                    // CaptureStateの状態ラベル
                    Text(session.state.label)
                        .bold()
                        .foregroundStyle(.yellow)
                        .padding(.bottom)
                }
            }
        }
        // ... 省略
}

結果

ready状態でsession.startDetecting()を実行すると検出ボックスが表示されました。

capturing

次は、detecting状態の時に、capturing状態に移行できるようにボタンを作成しました。

@MainActor
struct CreateButton: View {
    let session: ObjectCaptureSession

    var body: some View {
        Button(action: {
            performAction()
        }, label: {
            Text(label)
            .foregroundStyle(.white)
            .padding()
            .background(.tint)
            .clipShape(Capsule())
        })
    }

    private var label: LocalizedStringKey {
        if session.state == .ready {
            return "Start detecting"
        } else if session.state == .detecting {
            return "Start capturing"
        } else {
            return "Undefined"
        }
    }

    private func performAction() {
        if session.state == .ready {
            let isDetecting = session.startDetecting()
            print(isDetecting ? "▶️Start detecting" : "😨Not start detecting")
        } else if session.state == .detecting {
            session.startCapturing()
        } else {
            print("Undefined")
        }
    }
}

ContentViewのボタンを作成したボタンに変更します。

struct ContentView: View {

    @State private var session: ObjectCaptureSession?

    var body: some View {

        ZStack(alignment: .bottom) {
            if let session {
                ObjectCaptureView(session: session)

                VStack(spacing: 16) {

                    if session.state == .ready || session.state == .detecting {
                        // 検出/キャプチャボタン
                        CreateButton(session: session)
                    }
                    // CaptureStateの状態ラベル
                    Text(session.state.label)
                        .bold()
                        .foregroundStyle(.yellow)
                        .padding(.bottom)
                }
            }
        }
        // ... 省略
}

結果

キャプチャダイアルが表示され、ゆっくりと対象物の全体を撮影します。キャプチャダイアルが満たされるまで続けます。

キャプチャダイアログが満たされた状態になり、1回目のキャプチャが成功したように見えます。

このキャプチャダイアログが満たされ、スキャンが成功したかどうかは、ObjectCaptureSessionuserCompletedScanPassプロパティから取得することが出来ます。

このプロパティはBool値で、スキャンが開始するとfalseになり、成功するとtrueに切り替わります。

スキャン成功後の処理

Meet Object Capture for iOSのセッションを見てみると、高品質な3Dモデルを生成する為に3回のスキャンパスを完了させることを勧めていました。

次のスキャンに移行するには、ObjectCaptureSessionの下記2つのどちらかのAPIを呼び出す必要があります。

  • beginNewScanPass()
  • beginNewScanPassAfterFlip()

対象物が反転可能Flippableな場合、反転させて撮影できてない箇所を撮影することでより良いアウトプットを得ることが出来ます。また、反転が推奨されていない対象物の場合でも、撮影する際の高さを変更することで高品質のアウトプットを得ることが出来ます。

Flippable

WWDCのセッションでは、反転可能(Flippable)なオブジェクトの例を挙げてくれていました。

Flippable Non-flippable
反転可能な硬いオブジェクト 反転させると形が変わってしまうオブジェクト
テクスチャが豊富なオブジェクト テクスチャが少ないオブジェクト、テクスチャが反復的なオブジェクト

また、ObjectCaptureSession.Feedbackから.objectNotFlippableの値を取得することができる為、システム的に反転が必要かどうかは判断することが可能です。

finishing

今回は一度だけのスキャンでfinishing状態に進みたいと思います。(Apple推奨は3回)

ObjectCaptureSessionuserCompletedScanPassプロパティからスキャンパスの成功を取得できる為、onChangeで値がtrueに変わり次第、finish()を実行し、finishing状態に移行します。

finishing状態に移行すると、ObjectCaptureSessionの停止と全てのデータの保存が開始されます。

struct ContentView: View {

    @State private var session: ObjectCaptureSession?
    @State private var imageFolderPath: URL?

    var body: some View {

        // ...省略
        .onChange(of: session?.userCompletedScanPass) { _, newValue in
            if let newValue,
               newValue {
                // 今回は1度のスキャンパスで完了とする
                session?.finish()
            }
        }
    }
}

結果

スキャンが完了すると、セッションの状態を示すのラベルがcompletedに変わっているのが分かります、 これは、finishingに移行後に、正常にデータが保存され、セッションが終了できると自動的にcompleted状態に移動するためです。

Reconstruction

Reconstructionとは、取得したデータから3D形状や構造を推定し、それを元に3Dモデルを生成するプロセスを指します。

保存されたデータから3Dモデルを作成していきます。作成後、結果をQLPreviewControllerで表示します。

Reconstructionの開始

struct ContentView: View {

    @State private var session: ObjectCaptureSession?
    @State private var imageFolderPath: URL?

    @State private var photogrammetrySession: PhotogrammetrySession? // フォトグラメトリ用セッション
    @State private var modelFolderPath: URL?  // 3Dモデル保存先
    @State private var isProgressing = false  // 3Dモデル生成進行中フラグ
    @State private var quickLookIsPresented = false // 3Dモデル用画面の表示フラグ

    var modelPath: URL? {
        return modelFolderPath?.appending(path: "model.usdz")
    }

    var body: some View {
        // ...省略

        // 3Dモデル生成進行中はローディング表示
        if isProgressing {
            Color.black.opacity(0.4)
                .overlay {
                    ProgressView()
                }
        }
        .task {
            guard let directory = createNewScanDirectory() else { return }
            session = ObjectCaptureSession()
            // ディレクトリ生成時に3Dモデル保存先を追加
            modelFolderPath = directory.appending(path: "Models/")
            imageFolderPath = directory.appending(path: "Images/")
            guard let imageFolderPath else { return }
            // セッションを開始
            session?.start(imagesDirectory: imageFolderPath)
        }
        // ...省略
        // 3Dモデル表示用
        .sheet(isPresented: $quickLookIsPresented) {

            if let modelPath {
                ARQuickLookView(modelFile: modelPath) {
                    QuickLookIsPresented = false
                    // ObjectCapture再開
                }
            }
        }
    }

    // 3Dモデルの生成を開始
    private func startReconstruction() async {
        guard let imageFolderPath,
              let modelPath else { return }
        isProgressing = true
        do {
            // フォトグラメトリ
            photogrammetrySession = try PhotogrammetrySession(input: imageFolderPath)
            guard let photogrammetrySession else { return }

            try photogrammetrySession.process(requests: [.modelFile(url: modelPath)])
            for try await output in photogrammetrySession.outputs {
                switch output {
                case .requestError, .processingCancelled:
                    isProgressing = false
                    self.photogrammetrySession = nil
                case .processingComplete:
                    isProgressing = false
                    self.photogrammetrySession = nil
                    quickLookisPresented = true

                default:
                    break
                }
            }

        } catch {
            print("error", error)
        }
    }
}

3Dモデルの作成を開始する為のstartReconstructionを作成しました。

startReconstruction

まず、PhotogrammetrySessionを生成する際にインプットとして、オブジェクトのスキャンデータが保存されているパスを渡しています。

その後、photogrammetrySessionprocess(requests:)を実行し、3Dモデルの生成を開始しています。.modelFile(url:)を指定することでUSDZのデータを生成することが出来ます。

photogrammetrySession = try PhotogrammetrySession(input: imageFolderPath)
guard let photogrammetrySession else { return }
try photogrammetrySession.process(requests: [.modelFile(url: modelPath)])

このprocessを実行すると、PhotogrammetrySessionのOutputパブリッシャーから現在のステータス状態のメッセージを受け取れるようになります。

for try await output in photogrammetrySession.outputs {
    switch output {
    case .requestError, .processingCancelled:
        isProgressing = false
        self.photogrammetrySession = nil
    case .processingComplete:
        isProgressing = false
        self.photogrammetrySession = nil
        // 3Dモデルを画面表示
        quickLookisPresented = true

    default:
        break
    }
}

processingCompleteになると、生成が完了したことになるので3Dモデル表示用の画面表示フラグをtrueに切り替えています。

ARQuickLookView

3Dモデル表示用に使うARQuickLookViewQLPreviewControllerをSwiftUI用にラップしたものです。

今回は渡した1つのモデルファイルのみを表示するようにしています。

import SwiftUI
import QuickLook

struct ARQuickLookView: UIViewControllerRepresentable {

    let modelFile: URL
    let endCaptureCallback: () -> Void

    func makeUIViewController(context: Context) -> QLPreviewControllerWrapper {
        let controller = QLPreviewControllerWrapper()
        controller.previewController.dataSource = context.coordinator
        controller.previewController.delegate = context.coordinator
        return controller
    }

    func updateUIViewController(_ uiViewController: QLPreviewControllerWrapper, context: Context) {}

    func makeCoordinator() -> Coordinator {
        return Coordinator(parent: self)
    }

    class Coordinator: NSObject, QLPreviewControllerDelegate, QLPreviewControllerDataSource {
        let parent: ARQuickLookView

        init(parent: ARQuickLookView) {
            self.parent = parent
        }

        func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
            return 1
        }

        func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
            return parent.modelFile as QLPreviewItem
        }

        func previewControllerWillDismiss(_ controller: QLPreviewController) {
            parent.endCaptureCallback()
        }
    }
}

extension ARQuickLookView {

    class QLPreviewControllerWrapper: UIViewController {
        let previewController = QLPreviewController()
        var quickLookIsPresented = false

        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)

            if !quickLookIsPresented {
                present(previewController, animated: false)
                quickLookIsPresented = true
            }
        }
    }
}

結果

では、最終的にどのようになるのか見てみたいと思います。

※ 生成時間は少し長いの飛ばしてください。

無事に3Dモデルの生成ができました🎉

GitHub

コードはGitHubで確認できます。

おわりに

はじめにでも記載したのですが、今回は動きの確認がメインになる為、適切なエラーハンドリングやUIUXは考慮はしていません。より使いやすいアプリにする為に色々と考えていきたいと思います。

今回のような形状のぬいぐるみだと一度でもかなりの再現率で作成出来ました。オンデバイスで簡単に3Dモデルが作成できてとても感動しました。

まだまだ深掘りはできていない為、これから少しずつ調べていきたいと思います。

参考