原理を理解するために全天球画像画像からの変換のPythonコードを色々作ってみましたが、整理して関数として使えるようにしました。理屈はいいから使いたい。という方はこちらをどうぞ。理屈が知りたい方は過去の記事をご参照ください。
OpenCVに装備されててもよさそうなものですが、どうもなさそうなので自作です。Pythonのライブラリはありそうですが。
全体像
全天球画像もしくはCubeMapから自由視点での画像切り出しを目的に作りました。一般的なビューアーで見られる画像は作れるのではないかと思います。
こんな全天球画像や

こんなCubeMapから

こんな画像を切り出すものです。

また全天球画像もしくはCubeMapへの書き出しもできるようにしました。
基本思想としては3次元座標x,y,zを介してマッピングをしています。全天球画像→(x,y,z)→2次元座標となるようにしています。視点はx,y,zから変換することで切り替えています。回転はこの3次元座標で行っています。

保存時に360度画像だよ~ってタグをつけてはいないのでそのあたりは何とかしてください。
コード
コピペしてください。OpenCVが必要です。moduleとして外だしするのがクールだと思いますがひとまずベタベタに羅列。
import numpy as np
import cv2
##
# @brief 3次元座標群取得関数
# @details 360度画像の緯度経度が半径1の球の3次元座標のどこにマッピングされるか
# @param img_w 360度画像の幅
# @param img_h 360度画像の高さ
# @return x,y,zの座標群
def create_3dmap_for_equirectangular(img_w, img_h):
h = np.linspace(-np.pi/2, np.pi/2, img_h, endpoint=False)
w = np.linspace(-np.pi, np.pi, img_w, endpoint=False)
# 配列を対称形にするためのオフセット
h = h + (np.pi/2) / img_h
w = w + np.pi / img_w
theta, phi = np.meshgrid(w, h)
x = np.cos(phi) * np.cos(theta)
y = np.cos(phi) * np.sin(theta)
z = np.sin(phi)
return x, y, z
##
# @brief 3次元座標群切り出し関数
# @details とある視点からとある距離の平面を通る半径1の球の表面を結んだ交点座標群を切り出す
# @param img_w 画像の幅(分解能)
# @return x,y,zの座標群
def create_3dmap_for_cube(img_w):
senser_w = 0.5
w = np.linspace(-senser_w, senser_w, img_w, endpoint=False)
h = np.linspace(-senser_w, senser_w, img_w, endpoint=False)
# 配列を対称形にするためのオフセット
w = w + senser_w / img_w
h = h + senser_w / img_w
# センサの座標
ww, hh = np.meshgrid(w, h)
# 直線の式
a1 = 2 * ww
a2 = 2 * hh
# 球面上の3次元座標
x = (1 / (1 + a1**2 + a2**2)) ** (1/2)
y = a1 * x
z = a2 * x
return x, y, z
##
# @brief 3次元座標群切り出し関数
# @details とある視点からとある距離の平面を通る半径1の球の表面を結んだ交点座標群を切り出す
# @param img_w 画像の幅(分解能)
# @param img_h 画像の高さ(分解能)
# @param senser_size センサに見立てた平面の大きさ(0以上の少数)
# @param view_point 視点位置
# @param senser_point 視点から平面までの距離
# @return x,y,zの座標群
def create_3dmap_from_viewpoint(img_w, img_h, senser_size, view_point, senser_point):
senser_w = senser_size
senser_h = senser_size * img_h / img_w
x1 = view_point # 視点の位置
x2 = view_point + senser_point # 撮像面の位置(必ず視点より前)
w = np.linspace(-senser_w, senser_w, img_w, endpoint=False)
h = np.linspace(-senser_h, senser_h, img_h, endpoint=False)
# 配列を対称形にするためのオフセット
w = w + senser_w / img_w
h = h + senser_h / img_h
# センサの座標
ww, hh = np.meshgrid(w, h)
# 直線の式
a1 = ww / (x2 - x1)
a2 = hh / (x2 - x1)
b1 = -a1 * x1
b2 = -a2 * x1
a = 1 + a1**2 + a2**2
b = 2 * (a1 * b1 + a2 * b2)
c = b1**2 + b2**2 - 1
d = (b**2 - 4*a*c) ** (1/2)
# 球面上の3次元座標
x = (-b + d) / (2 * a)
y = a1 * x + b1
z = a2 * x + b2
return x, y, z
##
# @brief 3次元回転座標を取得する関数
# @details 3次元座標群を原点から指定された角度で回転させる
# @param x x座標群
# @param y y座標群
# @param z z座標群
# @param roll roll角(度単位)
# @param pitch pitch角(度単位)
# @param yaw yaw角(度単位)
# @return 回転後の3次元座標
def rotate_3dmap(x, y, z, roll, pitch, yaw):
# 回転量
roll = np.deg2rad(roll)
pitch = np.deg2rad(pitch)
yaw = np.deg2rad(yaw)
# 3次元の回転行列
mtx1 = np.array([[1, 0, 0],
[0, np.cos(roll), np.sin(roll)],
[0, -np.sin(roll), np.cos(roll)]])
mtx2 = np.array([[np.cos(pitch), 0, -np.sin(pitch)],
[0, 1, 0],
[np.sin(pitch), 0, np.cos(pitch)]])
mtx3 = np.array([[np.cos(yaw), np.sin(yaw), 0],
[-np.sin(yaw), np.cos(yaw), 0],
[0, 0, 1]])
# 回転行列の積
mtx4 = np.dot(mtx3, np.dot(mtx2, mtx1))
# 座標の行列計算
xd = mtx4[0][0] * x + mtx4[0][1] * y + mtx4[0][2] * z
yd = mtx4[1][0] * x + mtx4[1][1] * y + mtx4[1][2] * z
zd = mtx4[2][0] * x + mtx4[2][1] * y + mtx4[2][2] * z
return xd, yd, zd
##
# @brief 緯度経度情報にしたがって画像を変換する関数
# @details 1:2で格納されている画像から、そのx,y座標を緯度経度と見立てて対応する座標変換を行う
# @param img 元となる360度画像(1:2)
# @param x x座標群
# @param y y座標群
# @param z z座標群
# @return 変換された画像
def remap_from_equirectangular(img, x, y, z, interpolation=cv2.INTER_CUBIC, borderMode=cv2.BORDER_WRAP):
# 緯度・経度へ変換
phi = np.arcsin(z)
theta = np.arcsin(np.clip(y / np.cos(phi), -1, 1))
theta = np.where((x<0) & (y<0), -np.pi-theta, theta)
theta = np.where((x<0) & (y>0), np.pi-theta, theta)
img_h, img_w = img.shape[:2]
# 画像座標へ正規化(座標を画素位置に戻すため0.5オフセット)
phi = (phi * img_h / np.pi + img_h / 2).astype(np.float32) - 0.5
theta = (theta * img_w / (2 * np.pi) + img_w / 2).astype(np.float32) - 0.5
return cv2.remap(img, theta, phi, interpolation, borderMode = borderMode)
##
# @brief 全天球画像からキューブ画像を作る
# @details 半径1の球の中の1辺1の立方体に画像をマッピングさせ、6面を展開図として張り付ける
# @param img 元となる360度画像(1:2)
# @param width 1面の幅
# @return 変換された画像
def equirectangular_to_cube(img, width, interpolation=cv2.INTER_CUBIC):
# x, y, z = create_3dmap_from_viewpoint(width, width, 0.5, 0, 0.5)
x, y, z = create_3dmap_for_cube(width)
front = remap_from_equirectangular(img, x, y, z, interpolation)
x1, y1, z1 = rotate_3dmap(x, y, z, 0, 0, -90) # 球を左90度まわす
right = remap_from_equirectangular(img, x1, y1, z1, interpolation)
x1, y1, z1 = rotate_3dmap(x, y, z, 0, 0, 90)
left = remap_from_equirectangular(img, x1, y1, z1, interpolation)
x1, y1, z1 = rotate_3dmap(x, y, z, 0, 0, 180)
back = remap_from_equirectangular(img, x1, y1, z1, interpolation)
x1, y1, z1 = rotate_3dmap(x, y, z, 0, 90, 0) # 球を上90度まわす(正面に下が来る)
bottom = remap_from_equirectangular(img, x1, y1, z1, interpolation)
x1, y1, z1 = rotate_3dmap(x, y, z, 0, -90, 0)
up = remap_from_equirectangular(img, x1, y1, z1, interpolation)
# 背景となる画像
canvas = np.zeros((width*3, width*4, img.shape[2]), dtype=img.dtype)
canvas[width*1:width*2, :width*1] = left
canvas[width*1:width*2, width*1:width*2] = front
canvas[width*1:width*2, width*2:width*3] = right
canvas[width*1:width*2, width*3: ] = back
canvas[ :width*1, width*1:width*2] = up
canvas[width*2: , width*1:width*2] = bottom
return canvas
##
# @brief cube画像にのりしろをつける
# @details 画素補間用にcube画像の境界部に2画素分反対側の画像を付与する
# @param img cube画像
# @return のりしろ付き画像
def add_norishiro_to_cube(img):
h,w,c = img.shape
cw = w // 4
# 周囲2画素大きい画像を用意
canvas = np.zeros((h+4, w+4, c), dtype=img.dtype)
canvas[2:-2, 2:-2,:] = img
# 上下左右にのりしろをつける
# up
canvas[0:2,cw+2:2*cw+2,:] = np.rot90(img[cw:cw+2, 3*cw:,:], 2)
# bottom
canvas[-2:,cw+2:2*cw+2,:] = np.rot90(img[2*cw-2:2*cw,3*cw:,:], 2)
# left
canvas[cw+2:2*cw+2,0:2,:] = img[cw:2*cw,-2:,:]
# right
canvas[cw+2:2*cw+2,-2:,:] = img[cw:2*cw,0:2,:]
# 残りの折り返しの部分のコピー
canvas[cw:cw+2,:cw+2,:] = np.rot90(canvas[:cw+2,cw+2:cw+4,:])
canvas[:cw+2,cw:cw+2,:] = np.rot90(canvas[cw+2:cw+4,:cw+2,:],3)
#
canvas[2*cw+2:2*cw+4,:cw+2,:] = np.rot90(canvas[2*cw+2:,cw+2:cw+4,:],3)
canvas[2*cw+2:,cw:cw+2,:] = np.rot90(canvas[2*cw:2*cw+2,:cw+2,:])
#
canvas[cw:cw+2,2*cw+2:3*cw+2,:] = np.rot90(canvas[2:cw+2,2*cw:2*cw+2,:],3)
canvas[:cw+2,2*cw+2:2*cw+4:] = np.rot90(canvas[cw+2:cw+4,2*cw+2:3*cw+4,:])
#
canvas[2*cw+2:2*cw+4,2*cw+2:3*cw+2,:] = np.rot90(canvas[2*cw+2:-2,2*cw:2*cw+2,:])
canvas[2*cw+2:,2*cw+2:2*cw+4,:] = np.rot90(canvas[2*cw:2*cw+2,2*cw+2:3*cw+4,:], 3)
#
canvas[cw:cw+2, 3*cw+2:,:] = canvas[3:1:-1, 2*cw+1:cw-1:-1,:]
canvas[2*cw+2:2*cw+4, 3*cw+2:,:] = canvas[-3:-5:-1, 2*cw+1:cw-1:-1,:]
return canvas
##
# @brief キューブ画像を元に画像を変換する。
# @details 6面を展開図から変換
# @param img 元となるキューブ(展開図)画像
# @param x x座標群
# @param y y座標群
# @param z z座標群
# @return 変換された画像
def remap_from_cube(img, x, y, z, interpolation=cv2.INTER_CUBIC):
width = img.shape[1] // 4
w = 0.5
# front
xx = w*y / x + w
yy = w*z / x + w
mask = np.where((xx > 0) & (xx < 1) & (yy > 0) & (yy < 1) & (x > 0), 1, 0)
tmpx = np.where(mask, xx*width + width, 0)
tmpy = np.where(mask, yy*width + width, 0)
# back
xx = w*y / x + w
yy = -w*z / x + w
mask = np.where((xx > 0) & (xx < 1) & (yy > 0) & (yy < 1) & (x < 0), 1, 0)
tmpx = np.where(mask, xx*width + width*3, tmpx)
tmpy = np.where(mask, yy*width + width, tmpy)
#right
xx = -w*x / y + w
yy = w*z / y + w
mask = np.where((xx > 0) & (xx < 1) & (yy > 0) & (yy < 1) & (y > 0), 1, 0)
tmpx = np.where(mask, xx*width + width*2, tmpx)
tmpy = np.where(mask, yy*width + width, tmpy)
#left
xx = -w*x / y + w
yy = -w*z / y + w
mask = np.where((xx > 0) & (xx < 1) & (yy > 0) & (yy < 1) & (y < 0), 1, 0)
tmpx = np.where(mask, xx*width, tmpx)
tmpy = np.where(mask, yy*width + width, tmpy)
#up
xx = -w*y / z + w
yy = -w*x / z + w
mask = np.where((xx > 0) & (xx < 1) & (yy > 0) & (yy < 1) & (z < 0), 1, 0)
tmpx = np.where(mask, xx*width + width, tmpx)
tmpy = np.where(mask, yy*width, tmpy)
#bottom
xx = w*y / z + w
yy = -w*x / z + w
mask = np.where((xx > 0) & (xx < 1) & (yy > 0) & (yy < 1) & (z > 0), 1, 0)
tmpx = np.where(mask, xx*width + width, tmpx)
tmpy = np.where(mask, yy*width + width*2, tmpy)
cube = add_norishiro_to_cube(img)
# のりしろ2画素と座標を画素位置に戻すため0.5オフセット
tmpx += (2.0 - 0.5)
tmpy += (2.0 - 0.5)
return cv2.remap(cube, tmpx.astype(np.float32), tmpy.astype(np.float32), interpolation)
##
# @brief キューブ画像から全天球画像を作る
# @details 6面を展開図から全天球画像へ変換する
# @param img 元となるキューブ(展開図)画像
# @param width 全天球画像の幅(2の倍数である必要がある)
# @return 変換された画像
def cube_to_equirectangular(img, width, interpolation=cv2.INTER_CUBIC):
img_w = width
img_h = width // 2
width = img.shape[1] // 4
x, y, z = create_3dmap_for_equirectangular(img_w, img_h)
return remap_from_cube(img, x, y, z, interpolation)
コメント込みでも350行。
使い方その1 視点の回転
首を左右に振る。上下に振る。首を傾げる。の3方向。rotate_3dmapに与える角度を変えるだけです。
ソース画像はこれ。もちろん全天球画像(エクイレクタングラー画像)でも同じように扱えます。

cube = cv2.imread("color_cube.png")
x, y, z = create_3dmap_for_equirectangular(640, 320)
for i in range(10):
ofile = os.path.join("\ani", 'cube_{0:03d}.png'.format(i+1))
out = remap_from_cube(cube, x, y, z)
cv2.imwrite(ofile, out)
x, y, z = rotate_3dmap(x, y, z, 5, 0, 0)
for i in range(10):
ofile = os.path.join("\ani", 'cube_{0:03d}.png'.format(i+10))
out = remap_from_cube(cube, x, y, z)
cv2.imwrite(ofile, out)
x, y, z = rotate_3dmap(x, y, z, -5, 0, 0)

上下(ピッチ)

左右(ヨー)

首傾げ(ロール)
ヨー・ピッチ・ロールの意味合いとしてはこんな感じ。

使い方その2 ズームと前進
とある視点から見えている画像をズームする。とある視点から前進する。似てますが微妙に効果が違います。
ズーム
cube = cv2.imread("color_cube.png")
for i in range(40):
x, y, z = create_3dmap_from_viewpoint(640, 480, 0.5 - i/100, -2.0, 0.5)
ofile = os.path.join("\ani", 'cube_{0:03d}.png'.format(i+1))
out = remap_from_cube(cube, x, y, z)
cv2.imwrite(ofile, out)
前進
cube = cv2.imread("color_cube.png")
for i in range(40):
x, y, z = create_3dmap_from_viewpoint(640, 480, 0.5, -2.0 + i/20, 0.5)
ofile = os.path.join("\ani", 'cube_{0:03d}.png'.format(i+1))
out = remap_from_cube(cube, x, y, z)
cv2.imwrite(ofile, out)
結果

ズーム

前進
正面の正方形のゆがみ方が微妙に異なります。左右の壁の見え方も変わります。ズームはただ拡大しているだけですね。
まとめ
理屈はさておき360度画像を自在に操作できる気がしてきました。色々遊べそうです。
原理が知りてー。って方は
1.360度写真の平面投影
2.視点の変更と背面の投影
3.視点の回転
CubeMapの扱いは
1.全天球画像からCubeMap
2.CubeMapから全天球画像
原理はなんとなくわかったので頑張ればCでも書けそうです。
コメント