2枚の平面画像をパノラマ化してMeta Questで立体視 (その3 Python編)


前回の「2枚の平面画像をパノラマ化してMeta Questで立体視 (その1 概要編)」と「2枚の平面画像をパノラマ化してMeta Questで立体視 (その2 Blender編)」の続き。右目用・左目用がセットになった画像をMeta Questの標準の画像ビューアで立体視できるように変換する方法を紹介しているが、今回はPython編だ。

こちらは、前回のBlenderを使った方法と比べると、一度準備が出来れば後の実行がとても簡単。コマンドひとつで生成できるので、大量生産や試行錯誤に適している。

使い方

numpy・scipy・cv2 を使っているので、インストールされていなければ先にインストールする。環境にもよると思うが、pipなら

pip install numpy opencv-python scipy

これでひととおり揃う。

コード

こちらが実際に画像の変換を行うコード。Python 3.13.2で動作を確認した。

import numpy as np
import cv2
import argparse
from scipy.interpolate import RegularGridInterpolator

parser = argparse.ArgumentParser(description='Generate VR stereoscopic images.')
parser.add_argument('--left', type=str, required=True, help='Path to the left eye image')
parser.add_argument('--right', type=str, required=True, help='Path to the right eye image')
parser.add_argument('--out', type=str, default='out', help='Prefix for output filenames (default: out)')
parser.add_argument('--fov', type=int, default=100, help='Horizontal field of view in degrees (default: 100)')
parser.add_argument('--out_width', type=int, default=8192, help='Output image width (default: 8192)')
parser.add_argument('--eye_shift', type=float, default=0.0, help='Eye shift in degrees for convergence effect (default: 0.0)')

args = parser.parse_args()

### --- Constants ---
BACKGROUND_COLOR = 0  # Black background
FOV_HORIZONTAL = args.fov  # Horizontal field of view (degrees)

### --- Load input images ---
left_img = cv2.imread(args.left)
right_img = cv2.imread(args.right)
if left_img is None or right_img is None:
    raise FileNotFoundError(f"{args.left} or {args.right} not found.")

INPUT_HEIGHT, INPUT_WIDTH, _ = left_img.shape  # Get image dimensions

# Automatically calculate vertical FOV based on aspect ratio
FOV_VERTICAL = FOV_HORIZONTAL * (INPUT_HEIGHT / INPUT_WIDTH)
FOV_H = np.radians(FOV_HORIZONTAL)  # Convert to radians
FOV_V = np.radians(FOV_VERTICAL)

# Output equirectangular image size
OUTPUT_WIDTH = args.out_width
OUTPUT_HEIGHT = args.out_width // 2
FRONT_PANO_WIDTH = OUTPUT_WIDTH // 2
FRONT_PANO_HEIGHT = OUTPUT_HEIGHT

# Initialize output images with a black background
output_left = np.full((FRONT_PANO_HEIGHT, FRONT_PANO_WIDTH, 3), BACKGROUND_COLOR, dtype=np.uint8)
output_right = np.full((FRONT_PANO_HEIGHT, FRONT_PANO_WIDTH, 3), BACKGROUND_COLOR, dtype=np.uint8)

### --- Equirectangular coordinate calculation ---

x, y = np.meshgrid(np.arange(FRONT_PANO_WIDTH), np.arange(FRONT_PANO_HEIGHT))

theta = np.pi * x / FRONT_PANO_WIDTH - np.pi / 2  # -π/2 to π/2
phi = np.pi / 2 - np.pi * y / FRONT_PANO_HEIGHT   # π/2 to -π/2

# Compute 3D direction vectors from azimuth and elevation angles
dir_x = np.cos(phi) * np.sin(theta)
dir_y = np.sin(phi)
dir_z = np.cos(phi) * np.cos(theta)

# Compute intersection with the front-facing plane (Z = 1.0)
t = 1.0 / dir_z  # Scaling factor
intersect_x = dir_x * t
intersect_y = dir_y * t

# Convert to normalized plane coordinates (-1 to 1 range)
norm_x = intersect_x / (1.0 * np.tan(FOV_H / 2))
norm_y = intersect_y / (1.0 * np.tan(FOV_V / 2))

# Convert to pixel coordinates on the input image
src_x = (norm_x + 1) * (INPUT_WIDTH / 2)
src_y = (1 - norm_y) * (INPUT_HEIGHT / 2)

# Mask to invalidate coordinates outside the valid range
valid_mask = (src_x >= 0) & (src_x < INPUT_WIDTH) & (src_y >= 0) & (src_y < INPUT_HEIGHT) & (dir_z > 0)
method = "linear"

# Use bilinear interpolation to retrieve corresponding pixel values (background is black)
def process_image(input_img):
    interp_func = RegularGridInterpolator(
        (np.arange(INPUT_HEIGHT), np.arange(INPUT_WIDTH)), input_img,
        method=method, bounds_error=False, fill_value=BACKGROUND_COLOR
    )
    coords = np.stack([src_y.ravel(), src_x.ravel()], axis=-1)
    output_img = np.full((FRONT_PANO_HEIGHT, FRONT_PANO_WIDTH, 3), BACKGROUND_COLOR, dtype=np.uint8)
    output_img[valid_mask] = interp_func(coords[valid_mask.ravel()]).reshape((-1, 3)).astype(np.uint8)
    return output_img

output_left = process_image(left_img)
output_right = process_image(right_img)

# Save left and right eye images
cv2.imwrite(f"{args.out}_left.png", output_left)
cv2.imwrite(f"{args.out}_right.png", output_right)

# Create full panorama (-π to π) with shift effect
EYE_SHIFT_RAD = np.radians(args.eye_shift) / 2  # Convert eye shift to radians
full_pano = np.full((OUTPUT_HEIGHT * 2, OUTPUT_WIDTH, 3), BACKGROUND_COLOR, dtype=np.uint8)
shift_pixels = int((EYE_SHIFT_RAD / (2 * np.pi)) * OUTPUT_WIDTH)

# Place left image shifted right and right image shifted left
full_pano[:FRONT_PANO_HEIGHT, FRONT_PANO_WIDTH // 2 + shift_pixels:FRONT_PANO_WIDTH + FRONT_PANO_WIDTH // 2 + shift_pixels] = output_left
full_pano[FRONT_PANO_HEIGHT:, FRONT_PANO_WIDTH // 2 - shift_pixels:FRONT_PANO_WIDTH + FRONT_PANO_WIDTH // 2 - shift_pixels] = output_right

# Combine both images vertically and save as output.jpg
cv2.imwrite(f"{args.out}.png", full_pano)

このコードでは、まず、生成されるパノラマ画像の各ピクセルに相当する配列を用意する。そして、パノラマ画像を構成する各ピクセルの座標が元画像のどこのピクセルに相当するかを幾何的に計算する。この計算については後述する。

これで「パノラマ上の座標→元画像上の座標」が対応付いたので、あとはパノラマ画像の各ピクセルを元画像の該当ピクセルの色で埋めていく。その際、画像をなめらかにするためにscipy.interpolateの力で周囲のピクセルの色も含めて補完を行っている。

実行例

次のようにコマンドラインから元画像のパスと出力画像を指定すれば、左目パノラマ・右目パノラマ・3Dパノラマの3枚を出力してくれる。

/path/to/left_image の部分は左目画像、/path/to/right_image の部分は右目画像のパスを指定、/path/to/product のところには保存する場所を指定する。左目パノラマと右目パノラマ、そしてその2枚を統合したパノラマが保存される。オプションは以下の通り。

  • –left : 左目画像 (必須)
  • –right : 右目画像 (必須)
  • –out : 出力のprefix (必須)
  • –fov : 水平画角 (デフォルト値100)
  • –out_width : 出力画像の水平ピクセル数 (デフォルト8192)

コマンドラインからの実行例。

python plane_to_pano.py --left /path/to/left_image --right /path/to/right_image --out /path/to/product

水平画角を110°、出力画像幅4096にする例。

python plane_to_pano.py --left /path/to/left_image --right /path/to/right_image --out /path/to/product --fov 110 --out_width 4096

数学的な解説

何をやっているのかわからないと気持ち悪いと思うので、簡単に解説しておく。

このコードでやっているのは、仮想空間内に画像がポスターのように置かれた空間をパノラマで撮影した画像の生成である。

正距円筒図法は、視点位置から見た対象物の極座標 (方位角と仰角の2つの値) をそのまま直交座標にマッピングしたものである。

正距円筒図法の座標が元の画像のどこに相当するかを計算し、そのピクセルの色を拾うという方法である。計算は以下の手順で行う。

正距円筒図法の座標 → 極座標 → スクリーン上の座標 → 画像内の座標

正距円筒図法の座標 → 極座標

Equirectangular図法のルールはとてもシンプル。パノラマ画像の仕上がりの幅を Wpano, Hpanoとする。パノラマ画像内の座標をカメラ (観測者) から見たときの方位角θ、仰角φは次のようになる。

極座標→スクリーン座標

カメラから1の距離に置かれたスクリーンがあり、そこに元画像が貼られている状況を考えよう。

カメラから極座標(θ, φ) の方向に見えるスクリーン上の座標 (xscreen, yscreen, zscreen)

画角

このスクリーン上に元画像の表示されたポスターが登場する。

水平画角をθFOVとすると、ポスターの幅は

ポスターの高さは元画像の縦横比で決まる。元画像の横ピクセル数をWsrc, 縦ピクセル数をHsrcとすると、ポスターの高さは

ピクセル

あとは、スクリーン上の座標を、元画像のピクセル (xsrc, ysrc) に変換する。

この流れを順次プログラムに落とし込んで、正距円筒図法の座標→元画像の座標が対応づけられた。