原理を理解するために全天球画像画像からの変換の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でも書けそうです。
コメント