4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Depth Proで動画まるごと深度推定して"なんちゃって空間ビデオ"つくってみた

Last updated at Posted at 2025-07-04

はじめに

最近はHugging Faceで面白そうなAIモデルを見つけて遊ぶことにハマっていまして、Appleが開発した単眼で深度推定するモデルDepth Proとやらを試してました。

これがいとも簡単に良さげな深度画像を生成してくれるもんですから、単に1枚の画像でやって終わり。じゃなくて動画全フレームに対して生成してこれを元になんちゃって空間ビデオをつくってMeta Quest 3で見てみた。という内容です。

1000000044.jpg

追記:2025/07/14
これを踏まえて本当の空間ビデオに変換してみた続編の記事もありますのでぜひこちらも。

Depth Proについて

自分は深度推定に関しては素人ですが、Depth Proは既存モデルと比べて細かい部分の精度が良いことに加えて、あっという間に生成できる、というのがすごいみたい。

標準的なのGPUなら0.3秒で生成できるとのこと。まあ標準的なGPUがどのレベルかって話ではありますが。

image.png
引用:https://arxiv.org/pdf/2410.02073

導入手順

GitHubに書いてある手順通り、conda上に環境を作ってインストールすると簡単に実行できました。さすがApple。

インストール

まずはお好きなディレクトリ上でgit cloneしてディレクトリ移動

git clone https://github.com/apple/ml-depth-pro.git
cd ml-depth-pro

新しいconda環境を作成して起動。

conda create -n depth-pro -y python=3.9
conda activate depth-pro

以下を実行して編集可能モードでインストールを開始。

pip install -e .

推論モデルのダウンロード

Linux環境の場合

Linux系コマンドが使える環境であればそのまま以下のシェルスクリプトを実行すると、checkpointsというフォルダが作成されてその中に1.7GBほどの推論モデルdepth_pro.ptがダウンロードされるみたいです。

source get_pretrained_models.sh

Windows環境の場合

windows環境の方は手動でやったほうが簡単だと思います。

ml-depth-proディレクトリの場所でcheckpointsフォルダを自分で作成し、
ここにあるdepth_pro.ptをダウンロードしてcheckpointsフォルダに入れるだけです。

実行

以下の環境で実行してます。

項目 内容
CPU Intel Core i9-14900K
メモリ DDR4 64GB
GPU NVIDIA GeForce RTX 3090 (VRAM24GB)
OS Windows 11

動作確認

まずはサンプル画像で動作確認しましょう。

depth-pro-run -i ./data/example.jpg

ロードには少し時間がかかりますが、推論を開始してからは宣伝通り一瞬で終わりましたね。
結果は以下のようになったと思います。

image.png

空間ビデオの作成

さて、ここからが本番です。といってもそんなに難しいことはしてなくて、このDepth Proを独自のスクリプトから呼んで動画分のフレーム(画像)に対して深度推定するだけです。

一つのスクリプトで全フレームの深度推定から空間ビデオ作成まで書いてもいいのですが、右目視点と左目視点を合体させたステレオ画像をつくる過程でちょっとした調整が必要になり、不変の深度画像はいったん保存しておいて使いまわしたいので以下の2つのスクリプトに分けます。

  1. 動画から深度画像の生成・保存
  2. 保存した深度画像とカラー画像を元にしてステレオ画像へ。それらを結合してなんちゃって空間ビデオの作成

ほぼChatGPTに書いてもらいました。

1. 動画から深度画像の生成・保存

import cv2
import torch
import numpy as np
from PIL import Image
from pathlib import Path
from tqdm import tqdm
import depth_pro

def preprocess_frame(frame: np.ndarray, transform, device=None):
    frame_rgb = frame[..., ::-1] if frame.shape[2] == 3 else frame
    img_pil = Image.fromarray(frame_rgb)
    image_tensor = transform(img_pil).unsqueeze(0)
    return image_tensor.to(device) if device else image_tensor

def generate_stereo_video(input_video):
    video_name = str(Path(input_video).stem)
    output_folder_path = Path("./output/") / video_name
    output_folder_path.mkdir(parents=True, exist_ok=True)

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f"Using device: {device}")

    model, transform = depth_pro.create_model_and_transforms()
    model = model.to(device)
    model.eval()

    cap = cv2.VideoCapture(input_video)
    if not cap.isOpened():
        raise RuntimeError(f"動画の読み込みに失敗しました: {input_video}")

    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    print(f"Total frames: {total_frames}")

    frame_idx = 0

    with tqdm(total=total_frames, desc="Processing frames", unit="frame") as pbar:
        while True:
            ret, frame = cap.read()
            if not ret:
                break

            # RGB画像として保存するための変換
            frame_rgb = frame[..., ::-1]  # BGR → RGB
            image_tensor = preprocess_frame(frame, transform, device=device)

            with torch.no_grad():
                prediction = model.infer(image_tensor, f_px=None)

            # 深度画像作成
            depth = prediction["depth"].squeeze().cpu().numpy()
            depth_normalized = ((depth - depth.min()) / (depth.max() - depth.min()) * 255).astype(np.uint8)
            depth_image = Image.fromarray(depth_normalized)

            # カラー画像をPIL形式で
            color_image = Image.fromarray(frame_rgb)

            # 保存パスの設定(深度+カラー)
            depth_path = output_folder_path / f"{video_name}_depth_{frame_idx:06d}.png"
            color_path = output_folder_path / f"{video_name}_rgb_{frame_idx:06d}.png"

            # 保存
            depth_image.save(depth_path)
            color_image.save(color_path)

            frame_idx += 1
            pbar.update(1)

    cap.release()

# 実行例
if __name__ == "__main__":
    input_video = "sample.mp4" # 入力動画
    generate_stereo_video(input_video)

変換したい動画を指定してスクリプトを実行します。

実行すると、outputフォルダの中に動画のファイル名のフォルダの中に全フレームのカラー画像とその深度画像が連番で保存されます。

私の環境だと、WQHD画質1枚あたり1.7秒ほどかかったので、例えば60fpsの1分の動画であれば、単純計算で6120秒 = 約1時間40分ほどかかります。思ってた以上に時間かかりますね。なのでcuda対応のGPUがないと完走できないと思います。

注意:Windows Game Bar等でスクリーンキャプチャした動画は可変フレームレートになる影響で、のちの処理を終えて完成した動画を再生すると音声と映像がズレる場合があります。そのときは以下を実行してフレームレートを固定にすると同期するようになります。
$ ffmpeg -i input.mp4 -vf "fps=30" -vsync cfr -c:v libx264 -crf 18 output_fixed.mp4

2. 保存した深度画像とカラー画像を元にしてステレオ画像へ。それらを結合してなんちゃって空間ビデオの作成

import cv2
import numpy as np
from PIL import Image
from pathlib import Path
from tqdm import tqdm
import subprocess
import glob

def extract_fps_and_audio(source_video, temp_audio_path="temp_audio.aac"):
    # FPS取得
    probe = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0",
         "-show_entries", "stream=r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", source_video],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
    )
    fps_text = probe.stdout.strip()
    if '/' in fps_text:
        num, den = map(int, fps_text.split('/'))
        fps = num / den
    else:
        fps = float(fps_text)

    # 音声抽出
    subprocess.run([
        "ffmpeg", "-y", "-i", source_video, "-vn", "-acodec", "copy", temp_audio_path
    ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    return fps, temp_audio_path

def apply_horizontal_shift(rgb_img, shift_map):
    """
    深度シフトをNumPyのベクトル演算で高速に適用
    """
    h, w, c = rgb_img.shape
    x_coords = np.arange(w)[None, :]  # shape: (1, w)
    x_map = np.clip(x_coords - shift_map, 0, w - 1)
    indices_y = np.arange(h)[:, None]
    shifted_rgb = rgb_img[indices_y, x_map]
    return shifted_rgb

def create_spatial_video_frames(input_folder, max_pixel_shift):
    input_folder = Path(input_folder)
    rgb_files = sorted(glob.glob(str(input_folder / "*_rgb_*.png")))
    depth_files = sorted(glob.glob(str(input_folder / "*_depth_*.png")))

    assert len(rgb_files) == len(depth_files), "RGB画像とDepth画像の数が一致しません。"

    frames = []

    for rgb_path, depth_path in tqdm(zip(rgb_files, depth_files), total=len(rgb_files), desc="Creating stereo frames"):
        rgb_img = np.array(Image.open(rgb_path))
        depth_img = np.array(Image.open(depth_path)).astype(np.float32) / 255.0

        h, w = depth_img.shape
        shift_map = (depth_img * max_pixel_shift).astype(np.int32)

        # ベクトル化された高速処理
        right_eye = apply_horizontal_shift(rgb_img, shift_map)
        left_eye = rgb_img

        stereo_img = np.concatenate((left_eye, right_eye), axis=1)
        frames.append(stereo_img)

    return frames

def save_video(frames, output_path, fps):
    h, w, _ = frames[0].shape
    fourcc = cv2.VideoWriter_fourcc(*'mp4v')
    out = cv2.VideoWriter(str(output_path), fourcc, fps, (w, h))
    for frame in tqdm(frames, desc="Saving video"):
        out.write(cv2.cvtColor(frame, cv2.COLOR_RGB2BGR))
    out.release()

def mux_audio_with_video(video_path, audio_path, final_output_path):
    subprocess.run([
        "ffmpeg", "-y", "-i", video_path, "-i", audio_path,
        "-c:v", "copy", "-c:a", "aac", "-strict", "experimental", final_output_path
    ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

def create_spatial_video_with_audio(input_folder, source_video, max_pixel_shift):
    print("抽出中:FPSと音声...")
    fps, audio_path = extract_fps_and_audio(source_video)
    print(f"FPS: {fps}, 音声ファイル: {audio_path}")

    # 出力ファイル名に max_pixel_shift を付加
    input_folder = Path(input_folder)
    base_name = input_folder.name
    output_video_path = f"{base_name}_spatial_shift{max_pixel_shift}.mp4"

    print("空間ビデオフレームを生成中...")
    frames = create_spatial_video_frames(input_folder, max_pixel_shift)

    temp_video_path = "temp_video.mp4"
    print("動画を保存中(音声なし)...")
    save_video(frames, temp_video_path, fps)

    print("音声を合成中...")
    mux_audio_with_video(temp_video_path, audio_path, output_video_path)

    print(f"🎬 空間ビデオ(音声付き)を保存しました: {output_video_path}")

    # クリーンアップ
    Path(temp_video_path).unlink(missing_ok=True)
    Path(audio_path).unlink(missing_ok=True)

# 実行例
if __name__ == "__main__":
    create_spatial_video_with_audio(
        input_folder="./output/sample", # 前処理した生成されたフォルダ名
        source_video="sample.mp4", # 元の動画
        max_pixel_shift=20 # 視差の最大シフト量
    )

先ほど処理した動画のファイル名、フォルダ名を指定して実行します。

max_pixel_shiftは視差の最大シフト量を表しています。つまり、この値が大きければ大きいほど左右の視差が大きくなるので、理論的にはより立体感が増しますが、大きくしすぎるとあまりにも不自然になるので個人的には20〜25ぐらいがちょうどいいのではと思ってます。
こちらをいろんな値で試して生成できるよう、わざわざ1度深度画像をまるごと生成して保存しておいたというわけです。

いざ視聴

今回はMeta Quest 3で見ます。こちらはBOBOVRでカスタムしてます。

GR015904.JPG

Meta Questにサイドバイサイドの画像・動画を入れても認識してくれないので、サードパーティー製のアプリでないとお手製の空間ビデオを見ることができません(Appleの本物の空間ビデオはMeta Quest標準アプリで対応してます)。今回は4XVR Video Palyerを使いました。これ無料なのにすごい高機能。

1000000036.jpg
Meta Horizon Storeでアプリをダウンロードして開きます。

生成した動画をQuestに送るときは、PCとQuestを有線で接続して、Quest側の通知欄に出てくるUSB接続を許可みたいなのを選択するとPC側で認識してくれるようになります。
下のようなパスのダウンロードフォルダにでも入れましょう。
image.png

すると4XVR Video Palyer側で認識してくれるはずです。(添付画像は白塗りしてます)
タイトルなし.png

動画を選択して再生し、画面外でトリガーを引くとシークバー等が出てきます。そこの赤丸の部分を選択して、
10000.jpg

フラットの3D FSBSを選択しましょう(おそらく既に選択されていると思いますが)。
1000000040.jpg

これでなんちゃって空間ビデオが楽しめます。
1000000044.jpg
これは推しの櫻坂46。

感想

出来としては、圧倒的立体感とまではいきませんが、なんとなく飛び出して見えるぐらいですかね。

正直シーンによってかなり左右されちゃいます。少なくとも画質が良くないとダメです。
1000000041.jpg
この砂地に二人が寝ているシーンはかなりリアルに立体的に見えました(2D画像だと伝わりませんが)。綺麗な平面と人とで深度推定の精度がよかったからとか??


近くに映ってるもののほうがよりリアルに立体感を感じるという単純な仕組みでもなさそうなので、いろんなパターンで試して調べようと思います。

本家空間ビデオと比較して

でもMeta QuestのGallelyアプリに入ってるサンプルの空間ビデオ(本物)と比べるとやはり劣ります。こればっかりは実際にHMDで見てもらうしかないんですけどね。

1000000045.jpg
Meta QuestのGalleryアプリの右上のタブから空間ビデオを選択するとサンプルが見れます。

1000000046.jpg
特にこのバンド演奏のボーカルのおっちゃんの飛び出し具合は、VRに慣れてる人でも驚くと思います(2D画像だと伝わらなくて悲しい)。
調べたところ、Appleの空間ビデオは30FPSで撮影されるみたいなので、ヌルヌルだから綺麗に見えるというわけでもなさそうです。まあこちらは深度情報を推定して復元しているわけではないはずなので、綺麗で当たり前ではありますが(Appleだし)。
こちらのなんちゃって空間ビデオの強みとしては、既存の普通の2D動画をあとから立体動画にできるというところです。もっとクオリティが上がって制作コストが下がれば、手軽に立体動画を楽しめるようになるかもしれません。

追記(2025/07/14)

ということでこれを踏まえて本家空間ビデオに変換した記事がこちら。

4
3
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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?