3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Webカメラから3D Gaussian Splattingをリアルタイム生成するmacOSアプリを作った話

Last updated at Posted at 2025-12-25

はじめに

クリスマスイブの夜、ふと思いました。「Webカメラで撮った映像をリアルタイムで3D Gaussian Splatting (3DGS) に変換できたら面白いのでは?」

約24時間後、そのアイデアは動くアプリになっていました。本記事では、その開発の軌跡を振り返ります。

cameramode3.gif

どんな画像も単体でインポートできます!
gaussian-2.gif

必要条件

ハードウェア

  • Apple Silicon Mac(M4以降)
  • Webカメラ(内蔵または外付け)
  • 16GB RAM(最小)、32GB RAM(推奨)

ソフトウェア

  • macOS 14.0(Sonoma)以降
  • Xcode 15.0以降
  • Python 3.10-3.13(PyTorch MPSサポート付き)

なぜこのアプリを作ったのか

3D Gaussian Splattingの衝撃

2023年8月、SIGGRAPHで発表された「3D Gaussian Splatting」は3D表現技術に革命をもたらしました。従来のNeRF(Neural Radiance Fields)と比較して:

項目 NeRF 3D Gaussian Splatting
レンダリング速度 数秒/フレーム リアルタイム(60+ FPS)
学習時間 数時間〜数日 数十分
編集性 困難 点群ベースで容易
メモリ効率 ニューラルネット依存 明示的な点群表現

しかし、3DGSには大きな課題がありました。複数視点からの画像が必要という点です。通常、対象物の周囲から数十〜数百枚の写真を撮影し、SfM(Structure from Motion)で点群を初期化してから学習します。

「1枚の写真から3D」という夢

そこに2025年12月に登場したのが、Appleの SHARP(Single-image-to-3D with Hyper-realistic Priors)です。2024年後半に発表されたこのモデルは、たった1枚の画像から3DGSを生成できます。

これを見た瞬間、直感しました:

「これ、リアルタイムでできるんじゃない?」

VR/ARの未来への布石

なぜリアルタイム化が重要なのか。それは 空間コンピューティングの未来 に直結するからです。

【現在の3Dコンテンツ制作】
撮影 → SfM処理 → 3DGS学習 → エクスポート → アプリ配信
      (数分)     (数十分)     (手動)       (数日)

【本アプリが実現する未来】
カメラを向ける → リアルタイム3D表示
              (500ms)

Apple Vision Proの登場により、空間コンテンツへの需要は今後爆発的に増加します。しかし、3Dコンテンツの制作は依然として専門知識が必要で、時間もかかります。

本アプリは、「誰でも、どこでも、瞬時に3Dコンテンツを生成できる」 という未来への第一歩です。

技術的な挑戦として

また、純粋な技術的興味もありました:

  1. Swift ↔ Python の高速IPC: ネイティブアプリとMLモデルをどう効率的につなぐか
  2. Apple Silicon の限界に挑戦: M1/M2/M3チップでどこまでリアルタイム処理できるか
  3. 顔追跡によるインタラクション: 2Dディスプレイで3D体験をどう表現するか

これらの課題に24時間で挑戦することで、空間コンピューティング時代のアプリ開発の知見を得られると考えました。


完成したもの

RealtimeWebcam3DGS - macOS向けリアルタイム3DGS生成アプリ

主な機能

  • 📷 Webカメラからの映像キャプチャ
  • 🔄 SHARP モデルによる単一画像→3DGS変換
  • 🎮 Metal による高速リアルタイムレンダリング
  • 👤 顔追跡による視差パララックス効果
  • 📂 静的画像のインポート&変換モード

アーキテクチャ

Screenshot 2025-12-25 at 14.56.58.png


Day 1: 基盤構築 (12/24)

初期実装

まず、基本的なパイプラインを構築しました。

Swift側のコンポーネント

RealtimeWebcam3DGS/
├── Camera/CameraCaptureManager.swift    # Webカメラキャプチャ
├── Coordinator/AppCoordinator.swift     # 全体の統括
├── SHARPClient/SHARPClient.swift        # Python サーバーとの通信
├── Renderer/SplatRenderManager.swift    # Metalレンダリング管理
└── UI/
    ├── ContentView.swift                # メインビュー
    ├── CameraPreviewView.swift          # カメラプレビュー
    ├── ControlPanelView.swift           # 操作パネル
    └── SplatRenderView.swift            # 3DGS表示

Swift ↔ Python 通信

Swift と Python 間の通信には Unix Domain Socket を採用しました。TCPよりオーバーヘッドが少なく、macOSのローカル環境に最適です。

// SHARPClient.swift
class SHARPClient {
    private let socketPath = "/tmp/sharp_server.sock"

    func generatePLY(from imagePath: String) async throws -> PLYResult {
        let connection = NWConnection(
            to: .unix(path: socketPath),
            using: .tcp
        )
        // JSON-RPC風のプロトコルで通信
        let request = ["action": "generate", "image_path": imagePath]
        // ...
    }
}

Python側: SHARPサーバー

Apple の SHARP (Single-image-to-3D with Hyper-realistic Priors) モデルをサービング:

# sharp_server.py
class SHARPServer:
    def __init__(self):
        self.model = ml_sharp.load_model()

    async def handle_client(self, reader, writer):
        data = await reader.read()
        request = json.loads(data)

        if request["action"] == "generate":
            ply_data = self._generate_3dgs(request["image_path"])
            response = {"success": True, "ply_path": ply_data}
            writer.write(json.dumps(response).encode())

この段階では、1回の変換に 約2-3秒 かかっていました。


Phase 1: 最初の最適化

初期実装の問題点を分析:

  1. CPU-GPU同期のブロッキング: PLY読み込みとレンダリングが直列
  2. 不要な再ソート: 毎フレーム全Gaussianをソートしていた
  3. ファイルI/O: PLYファイルを一旦ディスクに書いて読み直し

改善: 非同期パイプライン

// AppCoordinator.swift - Before
func captureAndGenerate() async {
    let image = await captureImage()
    let plyPath = await sharpClient.generatePLY(from: imagePath)
    await renderManager.loadPLY(from: plyPath)  // ブロッキング
}

// After - パイプライン化
func captureAndGenerate() async {
    let image = await captureImage()

    // バックグラウンドで生成開始
    Task.detached(priority: .userInitiated) {
        let plyPath = await sharpClient.generatePLY(from: imagePath)

        await MainActor.run {
            // メインスレッドでUIのみ更新
            self.renderManager.loadPLYAsync(from: plyPath)
        }
    }
}

Phase 2: パイプライン並列化

次のボトルネックはPython側の推論速度でした。

ダブルバッファリング

レンダリングしながら次のフレームを生成:

class SplatRenderManager {
    // ダブルバッファ: 表示用と読み込み用を分離
    private var frontBuffer: SplatData?
    private var backBuffer: SplatData?

    func swapBuffers() {
        swap(&frontBuffer, &backBuffer)
    }
}

FP16推論 (試行錯誤)

PyTorchでFP16で軽量化しようと試みましたが、SHARPモデルとの互換性問題に直面し、この方法は断念しました。

# 失敗パターン
model.half()  # FP16化
outputs = model(image.half())  # → 異常な出力値

# 解決策: 入力のみFP16、中間処理はFP32維持
image = image.half()
with torch.cuda.amp.autocast(enabled=True):
    outputs = model(image)
# Gaussians出力は明示的にFP32に戻す

この時点で、生成時間は 約1.5-2秒 に短縮。


Day 2: 高速化とUX改善 (12/25)

Phase 3: 劇的な高速化

Core ML変換

PyTorchからCore MLへの変換で、Apple Silicon のNeural Engineを活用:

# convert_to_coreml.py
import coremltools as ct

traced_model = torch.jit.trace(model, sample_input)
mlmodel = ct.convert(
    traced_model,
    inputs=[ct.TensorType(shape=sample_input.shape)],
    compute_units=ct.ComputeUnit.ALL,  # ANE含む全ユニット利用
)
mlmodel.save("sharp_model.mlpackage")

しかし、ANE (Apple Neural Engine) でランタイムエラーが発生:

ANEProgramProcessRequestDirect() Failed with status=0x15

解決策として、Core ML失敗時に自動でPyTorchへフォールバック:

def _run_inference(self, image):
    if self.use_coreml:
        try:
            return self._run_coreml_inference(image)
        except RuntimeError as e:
            if "ANE" in str(e):
                logger.warning("Core ML failed, falling back to PyTorch")
                return self._run_pytorch_inference(image)
            raise
    return self._run_pytorch_inference(image)

ソケット直接転送

ファイルI/Oを完全に排除し、PLYデータをBase64でインラインで転送:

# Before: ファイル経由
ply_path = generate_ply(image)
response = {"ply_path": str(ply_path)}

# After: 直接転送
ply_data = generate_ply_bytes(image)
ply_base64 = base64.b64encode(ply_data).decode()
response = {"ply_data": ply_base64}  # インライン

Swift側でも対応:

if let plyDataString = response["ply_data"] as? String,
   let plyData = Data(base64Encoded: plyDataString) {
    // ファイル保存せずに直接パース
    try renderManager.loadPLY(from: plyData)
}

重要度ベースサンプリング

SHARPは約50万のGaussiansを生成しますが、すべてが等しく重要ではありません。

def sample_gaussians_by_importance(gaussians, target_count=10000):
    """不透明度と大きさに基づいてGaussianをサンプリング"""
    # 重要度スコア = 不透明度 × スケール平均
    importance = gaussians.opacities * np.mean(gaussians.scales, axis=1)

    # 上位を確定選択 + 残りをランダムサンプリング
    top_k = target_count // 2
    top_indices = np.argsort(importance)[-top_k:]
    remaining = np.random.choice(...)

    return gaussians[np.concatenate([top_indices, remaining])]

これにより、50万 → 1万 Gaussians に削減しても視覚的品質を維持!

この最適化の結果、生成時間は 約500-800ms まで短縮されました。


顔追跡パララックス

「3DGSを見ているなら、頭を動かしたら視点も変わるべきでは?」という発想から実装。

Vision フレームワークで顔検出

// FaceTrackingManager.swift
class FaceTrackingManager: ObservableObject {
    @Published var facePosition: SIMD3<Float> = .zero

    private let faceDetectionRequest = VNDetectFaceRectanglesRequest()

    func processCameraFrame(_ pixelBuffer: CVPixelBuffer) {
        let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer)
        try? handler.perform([faceDetectionRequest])

        guard let face = faceDetectionRequest.results?.first else { return }

        // 顔の位置を正規化 (-1 〜 1)
        let centerX = Float(face.boundingBox.midX) * 2 - 1
        let centerY = Float(face.boundingBox.midY) * 2 - 1

        // 顔のサイズから距離を推定
        let faceSize = Float(face.boundingBox.width)
        let estimatedZ = 0.15 / faceSize  // 基準顔幅15cm

        facePosition = SIMD3(centerX, centerY, estimatedZ)
    }
}

回転ベースのパララックス

当初はオフアクシス投影を試みましたが、より直感的な「回転ベース」アプローチを採用:

// SplatRenderManager.swift
func updateHeadTrackingRotation() {
    guard useHeadTracking, headPosition != .zero else { return }

    // 頭の位置に応じてモデルを回転
    let yawAngle = -headPosition.x * headTrackingSensitivity
    let pitchAngle = headPosition.y * headTrackingSensitivity * 0.5

    headTrackingRotation = simd_quatf(angle: yawAngle, axis: SIMD3(0, 1, 0)) *
                           simd_quatf(angle: pitchAngle, axis: SIMD3(1, 0, 0))
}

これにより、頭を左に傾けると3Dモデルが右から見えるような、自然なパララックス効果を実現!


静的画像モード

Webカメラだけでなく、任意の画像をインポートして3DGSに変換する機能を追加。

モード切替UI

// ControlPanelView.swift
struct ControlPanelView: View {
    var body: some View {
        VStack {
            // モードピッカー
            Picker("Mode", selection: $coordinator.appMode) {
                Text("Webcam").tag(AppMode.webcam)
                Text("Static").tag(AppMode.staticImage)
            }
            .pickerStyle(.segmented)

            // モードに応じたUI
            if coordinator.appMode == .webcam {
                CameraControlsSection()
            } else {
                StaticModeSection()
            }
        }
    }
}

画像インポート

// AppCoordinator.swift
func importImage() async {
    let panel = NSOpenPanel()
    panel.allowedContentTypes = [.jpeg, .png, .heic, .tiff]

    guard panel.runModal() == .OK, let url = panel.url else { return }

    // プレビュー表示
    importedImage = NSImage(contentsOf: url)?.cgImage(...)

    // 3DGS変換
    appState = .generating
    let result = try await sharpClient.generatePLYDirect(from: url)
    if result.success {
        try await renderManager.loadPLY(from: result.plyData!)
        appState = .rendering
    }
}

技術的なポイント

1. Swift ↔ Python IPC

Swiftアプリ内でPythonを実行する方法はいくつかありますが、プロセス分離 + Unix Domain Socket を選んだ理由:

  • PythonのGILやメモリリークがSwiftアプリに影響しない
  • GPU メモリを独立して管理可能
  • デバッグ時にPythonサーバーを単独で再起動可能
  • 将来的にサーバーをリモート化する余地

2. MetalSplatter統合

MetalSplatterは3DGSのMetal実装です。統合で苦労したポイント:

// 正しい座標系変換が必要
// PLYのY-upをMetalのZ-upに変換
func convertCoordinates(_ point: SIMD3<Float>) -> SIMD3<Float> {
    return SIMD3(point.x, point.z, -point.y)
}

3. 非同期処理のキャンセル

連続キャプチャ時、古い生成リクエストをキャンセルする処理:

private var currentGenerationTask: Task<Void, Never>?

func captureAndGenerate() async {
    // 前のタスクをキャンセル
    currentGenerationTask?.cancel()

    currentGenerationTask = Task {
        guard !Task.isCancelled else { return }
        // 生成処理...
    }
}

パフォーマンス結果

Phase 生成時間 改善点
初期実装 ~3,000ms -
Phase 1 ~2,000ms 非同期化
Phase 2 ~1,500ms パイプライン並列化
Phase 3 ~500ms 直接転送 + サンプリング

約6倍の高速化 を達成!


まとめ

クリスマスの24時間で、Webカメラからリアルタイム3DGSを生成するmacOSアプリを作りました。

ポイント:

  • Unix Domain Socket でSwift-Python間の低レイテンシ通信
  • 段階的な最適化 で初期の6倍の速度を達成、現在は、3秒に1回3DGS化可能!
  • Vision フレームワーク で顔追跡パララックス
  • モジュラーアーキテクチャ でClaude Codeに指定してもらい、機能追加が容易

3DGSはまだ発展途上の技術ですが、リアルタイムアプリケーションの可能性を感じるプロジェクトでした。


リポジトリ

GitHub: ra9g16/RealtimeWebcam3DGS

参考資料

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?