AIを用いた単一画像の3次元化(カメラ対応版)

この資料は、AIによる単眼深度推定(1枚の画像から各画素の奥行きを推定する技術)を題材に、2次元のカメラ画像から3次元の点群(ポイントクラウド:色付きの点の集まりで立体形状を表すデータ)を復元する流れを学ぶ演習集である。2つの深度推定モデルを順に試し、結果を見比べることで、モデルの違いが3次元復元の品質にどう影響するかを確かめる。

【目次】


演習1: MiDaS による単眼深度推定

プログラム概要

このプログラムは、AIモデル「MiDaS」を使って、PCカメラの1枚の画像から各画素の奥行き(深度)を推定する演習である。 人間が片目でも遠近感をつかめるように、MiDaSは大量の画像で学習した知識から、画像の見た目だけで手前と奥を予測する。 モデルは初回実行時にインターネットから自動でダウンロードされる。GPU(画像処理向けの演算装置で、AIの計算を高速化する)があれば自動で使い、無ければCPUで動く。 画面には常にカメラ映像と日本語の操作説明が表示され、深度マップ(奥行きを明暗で表した画像)は別ウィンドウに表示される。 sキーを押すと、その時点の画像と推定深度から3次元の点群を生成し、ファイルに保存して立体表示する。 画像が立体データに変わる過程を確かめる。

演習の進め方

演習番号:演習1

テーマ名:MiDaS による単眼深度推定と点群復元

手順:

  1. 下記のインストールコマンドで必要なライブラリを準備する。
  2. PCにカメラが接続されていることを確認する。
  3. 下記のコードを実行(メモ帳を用いる場合は a.py のようなファイル名で保存して実行)する。初回はモデルが自動でダウンロードされる。
  4. カメラ映像と深度マップの2つのウィンドウが出ることを確認する。
  5. 奥行きの異なる被写体(手前に手をかざす等)を映し、深度マップの明暗の変化を観察する。
  6. sキーを押して点群を生成し、別ウィンドウでマウス操作(ドラッグで回転、ホイールで拡大縮小)しながら立体構造を確認する。

ヒント:

考察ポイント:

必要なライブラリのインストール

管理者権限コマンドプロンプトを起動する (手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。

そして、以下のコマンドを実行し、関連ライブラリをインストールしたうえで、動作確認を行う。

MiDaSは内部でtimmに依存するため、timmも併せてインストールする。

pip install --no-user timm opencv-python numpy open3d pillow
import torch
import cv2
import numpy as np
import open3d as o3d
from PIL import Image, ImageDraw, ImageFont
import os

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用デバイス: {device}")

# ---- 日本語フォントの取得 ----
def get_japanese_font(size=24):
    candidates = [
        "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
        "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
        "C:/Windows/Fonts/meiryo.ttc",
        "C:/Windows/Fonts/msgothic.ttc",
        "C:/Windows/Fonts/YuGothM.ttc",
        "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
        "/System/Library/Fonts/Hiragino Sans GB.ttc",
    ]
    for path in candidates:
        if os.path.exists(path):
            return ImageFont.truetype(path, size)
    return ImageFont.load_default()


jp_font = get_japanese_font(22)
jp_font_small = get_japanese_font(18)


def draw_japanese_text(img_bgr, lines, org=(10, 10), font=jp_font,
                       color=(255, 255, 255), bg=(0, 0, 0)):
    # OpenCV(BGR)画像にPILで日本語テキストを重畳する
    img_pil = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(img_pil)
    x, y = org
    line_h = font.size + 8

    max_w = 0
    for line in lines:
        bbox = draw.textbbox((0, 0), line, font=font)
        max_w = max(max_w, bbox[2] - bbox[0])
    panel = Image.new("RGBA", img_pil.size, (0, 0, 0, 0))
    pdraw = ImageDraw.Draw(panel)
    pdraw.rectangle(
        [x - 6, y - 6, x + max_w + 12, y + line_h * len(lines) + 4],
        fill=(bg[0], bg[1], bg[2], 140),
    )
    img_pil = Image.alpha_composite(img_pil.convert("RGBA"), panel).convert("RGB")
    draw = ImageDraw.Draw(img_pil)

    for i, line in enumerate(lines):
        draw.text((x, y + i * line_h), line, font=font, fill=color[::-1])

    return cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)


# ---- 学習済みMiDaSモデルをtorch.hubから自動ダウンロード ----
model_type = "MiDaS_small"  # カメラ用に軽量版(DPT_Large/DPT_Hybridも選択可)
midas = torch.hub.load("intel-isl/MiDaS", model_type)
midas.to(device)
midas.eval()

midas_transforms = torch.hub.load("intel-isl/MiDaS", "transforms")
if model_type in ("DPT_Large", "DPT_Hybrid"):
    transform = midas_transforms.dpt_transform
else:
    transform = midas_transforms.small_transform


def estimate_depth(img_rgb):
    H, W = img_rgb.shape[:2]
    input_batch = transform(img_rgb).to(device)
    with torch.no_grad():
        prediction = midas(input_batch)
        prediction = torch.nn.functional.interpolate(
            prediction.unsqueeze(1),
            size=(H, W),
            mode="bicubic",
            align_corners=False,
        ).squeeze()
    return prediction.cpu().numpy()


def make_point_cloud(img_rgb, depth):
    # MiDaSの出力は「逆深度(視差)」。値が大きいほど近い。
    H, W = img_rgb.shape[:2]

    # (1) 視差の外れ値をパーセンタイルでクリップして安定化
    disp = depth.astype(np.float32)
    lo, hi = np.percentile(disp, 2), np.percentile(disp, 98)
    disp = np.clip(disp, lo, hi)

    # (2) 視差を 0.1〜1.0 に正規化してから距離へ変換(ゼロ割を回避)
    disp_norm = (disp - disp.min()) / (disp.max() - disp.min() + 1e-8)
    disp_norm = disp_norm * 0.9 + 0.1   # 0.1〜1.0
    z = 1.0 / disp_norm                  # 近い所ほど z 小、遠い所ほど z 大

    # (3) 距離側も外れ値をクリップし、実用的なスケールへ正規化
    z = np.clip(z, np.percentile(z, 2), np.percentile(z, 98))
    z = (z - z.min()) / (z.max() - z.min() + 1e-8)  # 0〜1
    z = z * 3.0 + 1.0                                # 1.0〜4.0 の奥行き

    # (4) ピンホール逆投影で3次元座標へ
    fx = fy = W * 0.8
    cx, cy = W / 2.0, H / 2.0
    xs, ys = np.meshgrid(np.arange(W), np.arange(H))
    X = (xs - cx) * z / fx
    Y = -(ys - cy) * z / fy
    Z = -z

    points = np.stack([X, Y, Z], axis=-1).reshape(-1, 3)
    colors = img_rgb.reshape(-1, 3).astype(np.float32) / 255.0

    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points)
    pcd.colors = o3d.utility.Vector3dVector(colors)

    # (5) 重心を原点へ移動(初期視点で中央に収まりやすくする)
    pcd = pcd.translate(-pcd.get_center())
    return pcd


def show_point_cloud(pcd, window_name):
    # ビューを点群にフィットさせて表示(点が消えないよう点サイズも調整)
    vis = o3d.visualization.Visualizer()
    vis.create_window(window_name=window_name, width=1024, height=768)
    vis.add_geometry(pcd)
    opt = vis.get_render_option()
    opt.point_size = 2.0
    opt.background_color = np.array([0.1, 0.1, 0.1])
    vis.reset_view_point(True)  # 点群全体が収まる初期視点に設定
    vis.run()
    vis.destroy_window()


def grab_latest_frame(cap, flush=5):
    # バッファに残った旧フレームを読み捨てて最新フレームを取得
    for _ in range(flush):
        cap.grab()
    ret, frame = cap.read()
    return ret, frame


HELP_LINES = [
    "MiDaS 単眼深度推定 デモ",
    "[s] キー : 3D点群を生成・保存・表示",
    "[d] キー : 深度マップ表示の ON/OFF",
    "[Esc] キー : 終了",
]

cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)

show_depth = True
print("カメラ起動中... 操作方法は画面内に表示されます。")

while True:
    ret, frame = grab_latest_frame(cap)
    if not ret:
        break

    img_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    depth = estimate_depth(img_rgb)

    # 別ウィンドウ:深度マップを常時表示
    if show_depth:
        depth_norm = (depth - depth.min()) / (depth.max() - depth.min() + 1e-8)
        depth_vis = (depth_norm * 255).astype(np.uint8)
        depth_color = cv2.applyColorMap(depth_vis, cv2.COLORMAP_INFERNO)
        depth_color = draw_japanese_text(
            depth_color, ["深度マップ(処理結果)"], org=(10, 10),
            font=jp_font_small, color=(255, 255, 255))
        cv2.imshow("depth (result)", depth_color)

    # メインウィンドウ:カメラ画像+日本語操作説明
    display = draw_japanese_text(frame.copy(), HELP_LINES, org=(10, 10))
    cv2.imshow("camera", display)

    key = cv2.waitKey(1) & 0xFF
    if key == 27:  # Esc
        break
    elif key == ord('d'):
        show_depth = not show_depth
        if not show_depth:
            cv2.destroyWindow("depth (result)")
    elif key == ord('s'):  # 点群を生成・保存・別画面表示
        pcd = make_point_cloud(img_rgb, depth)
        o3d.io.write_point_cloud("pointcloud_midas.ply", pcd)
        print("3D点群を保存: pointcloud_midas.ply")
        show_point_cloud(pcd, "MiDaS 3D Point Cloud")

cap.release()
cv2.destroyAllWindows()

実行結果例

実行結果動画

深度マップ

3次元点群


演習2: Depth Anything による単眼深度推定

プログラム概要

このプログラムは、より新しいAIモデル「Depth Anything V2」を使って、PCカメラの1枚の画像から奥行きを推定する演習である。 このモデルは多様で大量の画像で学習しているため、屋内外を問わず安定した深度推定ができる。 モデルはHugging Face(学習済みモデルを配布する公開プラットフォーム)から初回実行時に自動ダウンロードされ、GPUがあれば自動で使い、無ければCPUで動作する。 画面には常にカメラ映像と日本語の操作説明が表示され、推定された深度マップは別ウィンドウに表示される。 sキーを押すと、その時点の画像と深度から3次元の点群を生成し、ファイルに保存して立体表示する。 演習1のMiDaSと結果を見比べることで、モデルの違いが3次元復元の品質にどう影響するかを確かめられる。 AIが画像から立体構造を読み取る仕組みへの理解を深める。

演習の進め方

演習番号:演習2

テーマ名:Depth Anything V2 による単眼深度推定と点群復元

手順:

  1. 下記のインストールコマンドで必要なライブラリを準備する。
  2. PCにカメラが接続されていることを確認する。
  3. 下記のコードを実行(メモ帳を用いる場合は a.py のようなファイル名で保存して実行)する。初回はモデルが自動でダウンロードされる。
  4. 演習1と同じ被写体を映し、深度マップを観察する。
  5. sキーを押して点群を生成し、立体構造を確認する。

ヒント:

考察ポイント:

管理者権限コマンドプロンプトを起動する (手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。

そして、以下のコマンドを実行し、関連ライブラリをインストールしたうえで、動作確認を行う。

Depth Anything V2はtransformersライブラリ経由で読み込む。

pip install --no-user transformers pillow opencv-python numpy open3d
import torch
import cv2
import numpy as np
import open3d as o3d
from PIL import Image, ImageDraw, ImageFont
import os
from transformers import AutoImageProcessor, AutoModelForDepthEstimation

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用デバイス: {device}")

# ---- 日本語フォントの取得 ----
def get_japanese_font(size=24):
    candidates = [
        "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
        "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
        "C:/Windows/Fonts/meiryo.ttc",
        "C:/Windows/Fonts/msgothic.ttc",
        "C:/Windows/Fonts/YuGothM.ttc",
        "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
        "/System/Library/Fonts/Hiragino Sans GB.ttc",
    ]
    for path in candidates:
        if os.path.exists(path):
            return ImageFont.truetype(path, size)
    return ImageFont.load_default()


jp_font = get_japanese_font(22)
jp_font_small = get_japanese_font(18)


def draw_japanese_text(img_bgr, lines, org=(10, 10), font=jp_font,
                       color=(255, 255, 255), bg=(0, 0, 0)):
    # OpenCV(BGR)画像にPILで日本語テキストを重畳する
    img_pil = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(img_pil)
    x, y = org
    line_h = font.size + 8

    max_w = 0
    for line in lines:
        bbox = draw.textbbox((0, 0), line, font=font)
        max_w = max(max_w, bbox[2] - bbox[0])
    panel = Image.new("RGBA", img_pil.size, (0, 0, 0, 0))
    pdraw = ImageDraw.Draw(panel)
    pdraw.rectangle(
        [x - 6, y - 6, x + max_w + 12, y + line_h * len(lines) + 4],
        fill=(bg[0], bg[1], bg[2], 140),
    )
    img_pil = Image.alpha_composite(img_pil.convert("RGBA"), panel).convert("RGB")
    draw = ImageDraw.Draw(img_pil)

    for i, line in enumerate(lines):
        draw.text((x, y + i * line_h), line, font=font, fill=color[::-1])

    return cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)


# ---- 学習済みDepth Anything V2モデルをHuggingFaceから自動ダウンロード ----
model_name = "depth-anything/Depth-Anything-V2-Small-hf"  # Base/Largeも選択可
processor = AutoImageProcessor.from_pretrained(model_name)
model = AutoModelForDepthEstimation.from_pretrained(model_name)
model.to(device)
model.eval()


def estimate_depth(img_rgb):
    H, W = img_rgb.shape[:2]
    image = Image.fromarray(img_rgb)
    inputs = processor(images=image, return_tensors="pt").to(device)
    with torch.no_grad():
        outputs = model(**inputs)
        predicted_depth = outputs.predicted_depth
    prediction = torch.nn.functional.interpolate(
        predicted_depth.unsqueeze(1),
        size=(H, W),
        mode="bicubic",
        align_corners=False,
    ).squeeze()
    return prediction.cpu().numpy()


def make_point_cloud(img_rgb, depth):
    # Depth Anythingの出力は「相対深度」。値が大きいほど近い傾向。
    H, W = img_rgb.shape[:2]

    # (1) 外れ値をパーセンタイルでクリップして安定化
    d = depth.astype(np.float32)
    d = np.clip(d, np.percentile(d, 2), np.percentile(d, 98))
    d_norm = (d - d.min()) / (d.max() - d.min() + 1e-8)  # 0〜1(大=近い)

    # (2) 「近い=z小、遠い=z大」へ反転し、実用スケールへ
    z = (1.0 - d_norm) * 3.0 + 1.0   # 1.0〜4.0 の奥行き

    # (3) ピンホール逆投影で3次元座標へ
    fx = fy = W * 0.8
    cx, cy = W / 2.0, H / 2.0
    xs, ys = np.meshgrid(np.arange(W), np.arange(H))
    X = (xs - cx) * z / fx
    Y = -(ys - cy) * z / fy
    Z = -z

    points = np.stack([X, Y, Z], axis=-1).reshape(-1, 3)
    colors = img_rgb.reshape(-1, 3).astype(np.float32) / 255.0

    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points)
    pcd.colors = o3d.utility.Vector3dVector(colors)

    # (4) 重心を原点へ移動
    pcd = pcd.translate(-pcd.get_center())
    return pcd


def show_point_cloud(pcd, window_name):
    # ビューを点群にフィットさせて表示(点が消えないよう点サイズも調整)
    vis = o3d.visualization.Visualizer()
    vis.create_window(window_name=window_name, width=1024, height=768)
    vis.add_geometry(pcd)
    opt = vis.get_render_option()
    opt.point_size = 2.0
    opt.background_color = np.array([0.1, 0.1, 0.1])
    vis.reset_view_point(True)  # 点群全体が収まる初期視点に設定
    vis.run()
    vis.destroy_window()


def grab_latest_frame(cap, flush=5):
    # バッファに残った旧フレームを読み捨てて最新フレームを取得
    for _ in range(flush):
        cap.grab()
    ret, frame = cap.read()
    return ret, frame


HELP_LINES = [
    "Depth Anything 単眼深度推定 デモ",
    "[s] キー : 3D点群を生成・保存・表示",
    "[d] キー : 深度マップ表示の ON/OFF",
    "[Esc] キー : 終了",
]

cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)

show_depth = True
print("カメラ起動中... 操作方法は画面内に表示されます。")

while True:
    ret, frame = grab_latest_frame(cap)
    if not ret:
        break

    img_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    depth = estimate_depth(img_rgb)

    # 別ウィンドウ:深度マップを常時表示
    if show_depth:
        depth_norm = (depth - depth.min()) / (depth.max() - depth.min() + 1e-8)
        depth_vis = (depth_norm * 255).astype(np.uint8)
        depth_color = cv2.applyColorMap(depth_vis, cv2.COLORMAP_INFERNO)
        depth_color = draw_japanese_text(
            depth_color, ["深度マップ(処理結果)"], org=(10, 10),
            font=jp_font_small, color=(255, 255, 255))
        cv2.imshow("depth (result)", depth_color)

    # メインウィンドウ:カメラ画像+日本語操作説明
    display = draw_japanese_text(frame.copy(), HELP_LINES, org=(10, 10))
    cv2.imshow("camera", display)

    key = cv2.waitKey(1) & 0xFF
    if key == 27:  # Esc
        break
    elif key == ord('d'):
        show_depth = not show_depth
        if not show_depth:
            cv2.destroyWindow("depth (result)")
    elif key == ord('s'):  # 点群を生成・保存・別画面表示
        pcd = make_point_cloud(img_rgb, depth)
        o3d.io.write_point_cloud("pointcloud_anything.ply", pcd)
        print("3D点群を保存: pointcloud_anything.ply")
        show_point_cloud(pcd, "Depth Anything 3D Point Cloud")

cap.release()
cv2.destroyAllWindows()

実行結果例

実行結果動画

深度マップ

3次元点群