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
状態に移行する為には、ObjectCaptureSession
のstart(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回目のキャプチャが成功したように見えます。
このキャプチャダイアログが満たされ、スキャンが成功したかどうかは、ObjectCaptureSession
のuserCompletedScanPass
プロパティから取得することが出来ます。
このプロパティは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回)
ObjectCaptureSession
のuserCompletedScanPass
プロパティからスキャンパスの成功を取得できる為、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
を生成する際にインプットとして、オブジェクトのスキャンデータが保存されているパスを渡しています。
その後、photogrammetrySession
のprocess(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モデル表示用に使うARQuickLookView
はQLPreviewController
を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モデルが作成できてとても感動しました。
まだまだ深掘りはできていない為、これから少しずつ調べていきたいと思います。
参考
- Meet Object Capture for iOS
- Scanning objects using Object Capture
- Object Captureの内部処理
- フォトグラメトリ?LiDARスキャン?Object Capture?
- Apple Developer - start(imagesDirectory:configuration:)
- Apple Developer - ObjectCaptureSession.Configuration
- Apple Developer - userCompletedScanPass
- Apple Developer - ObjectCaptureSession.Feedback
- Apple Developer - beginNewScanPass()
- Apple Developer - beginNewScanPassAfterFlip()
- Apple Developer - process(requests:)
- Apple Developer - QLPreviewController