[Python] 360度パノラマ画像平面展開ツール

概要

360度画像(パノラマ画像, Equirectangular)を平面に展開するpythonツールです。雑な解説を載せますが、詳しいことはソースコードを読んでください。

解説

平面展開(__call__)

360度画像の指定方向の像を画像ファイルとして出力する。

最終的にcv2.remapを使うため、展開結果の画像の座標(i, j)元の360度画像のどの座標(x, y)に対応しているかということを表す行列を作ることから始める。

いきなり360度画像の座標に割り当てるのは難しいので、最初は直径1の球面上の座標(x, y, z)に割り当てることから始める。「像の座標」と「視点中心からの角度」の対応関係は複数の射影方式(正射影・立体射影など)があり、それぞれmapping functionに従っている(英語版wikipediaなど参照)。いずれかのmapping functionを使って座標ごとの「視点中心からの角度」を算出し、z軸正の方向の球面上の点を割り当てる。

次に視野の角度を考慮して割り当てた点全てを一斉に回転させる。そして最後に回転後の座標から緯度(lon)経度(lat)を求め、対応する360度画像上の座標を求めて終了。

逆展開(inverse)

平面展開の逆で、平面展開された画像と同じサイズのレイヤー(透過pngを想定)を読み込み、元の360度画像に重なる形で引き戻す。

考え方は簡単で、平面展開の逆を行うだけ。最初に球面上の座標(x, y, z)がわかり、最後にレイヤー上の座標(x, y)が求められる。途中、視界の裏側の点を一様に無限遠点に取るという操作で不要な範囲を取り除いている(ソースコードを読んでください)。

コード

必要なpythonパッケージと360度画像は各自手に入れてください。360度画像はEquirectangularと検索すると出てくると思います。

from abc import ABCMeta, abstractmethod
import tkinter
import tkinter.filedialog

import numpy as np
import cv2


class PanoramaProjector(metaclass=ABCMeta):
    @staticmethod
    def parse_mapping_style(mapping_style):
        mode = 1
        if isinstance(mapping_style, str):
            if mapping_style == 'perspective':
                mode = 1
            elif mapping_style == 'stereographic':
                mode = 1 / 2
            elif mapping_style == 'equidistant':
                mode = 0
            elif mapping_style == 'equisolid_angle':
                mode = -1 / 2
            elif mapping_style == 'orthographic':
                mode = -1
        elif isinstance(mapping_style, tuple):
            f, n = mapping_style
            if f == 'tan':
                mode = 1 / n
            elif f == 'id':
                mode = 0
            elif f == 'sin':
                mode = -1 / n
        else:
            mode = mapping_style

        return mode

    def __call__(self, panorama, lon=0, lat=0, fov=105, width=800, height=600, mapping_style=1, fov_mode=True):
        lon = np.radians(lon)
        lat = np.radians(lat)
        if fov_mode:
            fov = np.radians(fov)

        [panorama_height, panorama_width, _] = panorama.shape
        spmap = self._spmap(lon, lat, fov, width, height, mapping_style=mapping_style, fov_mode=fov_mode)
        pxmap = self._pxmap(spmap, panorama_width, panorama_height)
        return cv2.remap(panorama,
                         pxmap[0].astype(np.float32),
                         pxmap[1].astype(np.float32),
                         cv2.INTER_CUBIC,
                         borderMode=cv2.BORDER_WRAP,
                         )

    @staticmethod
    def r(angle, mapping_style=1):
        # projection fomula of distance
        if mapping_style == 0:
            return angle
        elif mapping_style > 0:
            return np.tan(mapping_style * angle) / mapping_style
        else:
            return np.sin(mapping_style * angle) / mapping_style

    @staticmethod
    def r_inv(d, mapping_style=1):
        if mapping_style == 0:
            inv = d
        elif mapping_style > 0:
            inv = np.arctan(mapping_style * d) / mapping_style
        else:
            inv = np.arcsin(mapping_style * d) / mapping_style

        if isinstance(inv, np.ndarray):
            inv[inv > np.pi / 2] = np.float('inf')

        return inv

    def _spmap(self, lon, lat, fov, width, height, mapping_style=1, fov_mode=True):
        xyz = self._3dmap(fov, width, height, mapping_style=mapping_style, fov_mode=fov_mode)

        # x軸周りに -(pi/2-lat) 回転
        rx = np.array([[1, 0, 0],
                       [0, np.sin(lat), np.cos(lat)],
                       [0, -np.cos(lat), np.sin(lat)]])
        # z軸周りに -(pi/2-lon) 回転
        rz = np.array([[np.sin(lon), np.cos(lon), 0],
                       [-np.cos(lon), np.sin(lon), 0],
                       [0, 0, 1]])

        xyz = xyz.reshape([height * width, 3]).T
        xyz = np.dot(rx, xyz)
        xyz = np.dot(rz, xyz)

        # 球面上の座標(x, y, z)を緯度lonと経度latに変換する
        lon_map = np.arctan2(xyz[1], xyz[0])  # 東経
        lat_map = np.arcsin(xyz[2])  # 北緯

        lon_map = lon_map.reshape([height, width])
        lat_map = lat_map.reshape([height, width])

        return lon_map, lat_map

    def _3dmap(self, fov, width, height, mapping_style=1, fov_mode=True):
        # 画像の座標(i, j)から回転前の球面上の座標(x, y, z)への対応を作る

        # 像の範囲を求める
        if fov_mode:
            # fovが視野角を意味するモード
            # 視野角fovから(像の対角線の長さ)/2=r(fov/2)に変換
            fov = self.r(fov / 2, mapping_style=mapping_style)
        a = fov / np.sqrt(width ** 2 + height ** 2)
        vw = width * a  # z=1平面上の幅の半分
        vh = height * a

        # 画像の座標(i, j)から像の中の座標への対応
        x = np.tile(np.linspace(-vw, vw, width), [height, 1])
        y = np.tile(np.linspace(-vh, vh, height), [width, 1]).T

        # 球面上の座標に変換するための倍率を計算
        # self._r_inv(a2) が像の中心からの角度になっている
        a2 = np.sqrt(x ** 2 + y ** 2)
        a2 = np.sin(self.r_inv(a2, mapping_style=mapping_style)) / a2

        # 球面上の座標(x, y, z)を求める
        x *= a2
        y *= a2
        z = np.sqrt(1 - x ** 2 - y ** 2)

        return np.stack((x, y, z), axis=2)  # shape = height, width, 3

    @abstractmethod
    def _pxmap(self, sperical_map, panorama_width, panorama_height):
        pass

    def inverse(self, perspective, lon=0, lat=0, fov=105, panorama_width=8192, panorama_height=4096,
                mapping_style=1, fov_mode=True):
        lon = np.radians(lon)
        lat = np.radians(lat)
        if fov_mode:
            fov = np.radians(fov)

        [height, width, _] = perspective.shape
        pxmap_inv = self._pxmap_inv(panorama_width, panorama_height)
        x, y = self._spmap_inv(pxmap_inv, lon, lat, fov, width, height,
                               mapping_style=mapping_style, fov_mode=fov_mode)

        panorama = cv2.remap(perspective,
                             x.astype(np.float32),
                             y.astype(np.float32),
                             cv2.INTER_CUBIC,
                             borderMode=cv2.BORDER_TRANSPARENT)

        return panorama

    def _spmap_inv(self, pxmap_inv, lon, lat, fov, width, height, mapping_style=1, fov_mode=True):
        xyz, panorama_width, panorama_height = self._3dmap_inv(pxmap_inv, lon, lat)

        if fov_mode:
            fov = self.r(fov / 2, mapping_style=mapping_style)
        a = fov / np.sqrt(width ** 2 + height ** 2)
        vw = width * a
        vh = height * a

        # 裏側を取り除く
        f = xyz[2] <= 0  # filter
        xyz[:, f] = [[float('inf')], [float('inf')], [-1]]

        a2 = self.r(np.arccos(xyz[2]), mapping_style=mapping_style) / np.sqrt(xyz[0] ** 2 + xyz[1] ** 2)
        xyz *= a2

        xyz[0] += vw
        xyz[1] += vh

        xyz /= a*2

        x = xyz[0].reshape([panorama_height, panorama_width])
        y = xyz[1].reshape([panorama_height, panorama_width])

        return x, y

    def _3dmap_inv(self, pxmap_inv, lon, lat):
        lon_map, lat_map = pxmap_inv
        panorama_height = lon_map.shape[0]
        panorama_width = lat_map.shape[1]

        # x = sin(pi/2 - lat) * cos(lon) = cos(lat) * cos(lon)
        # y = sin(pi/2 - lat) * sin(lon) = cos(lat) * sin(lon)
        # z = cos(pi/2 - lat)            = sin(lat)
        x = np.cos(lat_map) * np.cos(lon_map)
        y = np.cos(lat_map) * np.sin(lon_map)
        z = np.sin(lat_map)
        xyz = np.stack((x, y, z), axis=2)

        # x軸周りに (pi/2-lat) 回転
        rxi = np.array([[1,           0,            0],
                        [0, np.sin(lat), -np.cos(lat)],
                        [0, np.cos(lat),  np.sin(lat)]])
        # z軸周りに (pi/2-lon) 回転
        rzi = np.array([[np.sin(lon), -np.cos(lon), 0],
                        [np.cos(lon),  np.sin(lon), 0],
                        [          0,            0, 1]])

        xyz = xyz.reshape([panorama_height * panorama_width, 3]).T
        xyz = np.dot(rzi, xyz)
        xyz = np.dot(rxi, xyz)

        return xyz, panorama_width, panorama_height

    @abstractmethod
    def _pxmap_inv(self, panorama_width, panorama_height):
        pass


# equirectangular
class EquirecProjector(PanoramaProjector):
    def _pxmap(self, spmap, panorama_width, panorama_height):
        i = panorama_width * (1 / 2 - spmap[0] / 2 / np.pi)
        j = panorama_height * (1 / 2 - spmap[1] / np.pi)

        return i, j

    def _pxmap_inv(self, width, height):
        lon_map = np.tile(np.linspace(np.pi, -np.pi, width), [height, 1])
        lat_map = np.tile(np.linspace(np.pi/2, -np.pi/2, height), [width, 1]).T

        return lon_map, lat_map


class PanoramaViewer(metaclass=ABCMeta):
    def __init__(self, image_path, projector, lon=0, lat=0, fov=105, width=800, height=600, mapping_style='perspective'):
        self._image = cv2.imread(image_path, cv2.IMREAD_COLOR)

        self._projector = projector

        self._lon = lon
        self._lat = lat
        self._width = width
        self._height = height
        self._mapping_style = projector.parse_mapping_style(mapping_style)
        self._d = self._projector.r(np.radians(fov) / 2, mapping_style=self._mapping_style)
        self._stride = np.degrees(self._projector.r_inv(self._d / 10))

    def __call__(self):
        if self._mapping_style > 0:
            p = 1 - np.cos(self._mapping_style * np.pi / 2)
        elif self._mapping_style < 0:
            p = -1 + np.cos(self._mapping_style * np.pi / 2)
        else:
            p = 0

        # f:p -> style
        # arccosに従って滑らかに遷移させる
        def f(p):
            if p > 0:
                return 2 / np.pi * np.arccos(1 - p)
            elif p < 0:
                return -2 / np.pi * np.arccos(1 + p)
            else:
                return 0

        while True:
            image = self._projector(self._image, self._lon, self._lat, self._d, self._width, self._height,
                                    mapping_style=self._mapping_style, fov_mode=False)
            cv2.imshow("image", image)
            key = cv2.waitKeyEx(0)

            if key == 2424832:  # left
                self._lon += self._stride
            elif key == 2490368:  # up
                self._lat += self._stride
                if self._lat > 90:
                    self._lat = 90
            elif key == 2555904:  # right
                self._lon -= self._stride
            elif key == 2621440:  # down
                self._lat -= self._stride
                if self._lat < -90:
                    self._lat = -90
            elif key == ord('z'):
                self._d /= 1.1
                self._stride = np.degrees(self._projector.r_inv(self._d / 10))
            elif key == ord('x'):
                self._d *= 1.1
                self._stride = np.degrees(self._projector.r_inv(self._d / 10))
            elif key == ord('m'):
                p -= 0.1
                if p < -1:
                    p = -1
                self._mapping_style = f(p)
            elif key == ord('n'):
                p += 0.1
                if p > 1:
                    p = 1
                self._mapping_style = f(p)
            elif key == ord('s'):
                self.output(image=image)
            elif key == ord('i'):
                root = tkinter.Tk()
                root.withdraw()
                layer_path = tkinter.filedialog.askopenfilename(filetypes=[('PNG', '*.png')])
                if layer_path is not '':
                    self.inverse(layer_path)
            elif cv2.getWindowProperty('img', cv2.WND_PROP_AUTOSIZE) == -1:
                cv2.destroyAllWindows()
                return

    def output(self, image=None, filename=None):
        if image is None:
            mapping_style = self._projector.parse_mapping_style(self._mapping_style)
            image = self._projector(self._image, self._lon, self._lat, self._d, self._width, self._height,
                                    mapping_style=mapping_style, fov_mode=False)
        if filename is None:
            filename = 'save/{}_{}_{}_{}_{}.jpg'.format(self._lon, self._lat, self._d, self._width, self._height)
        cv2.imwrite(filename, image)

    def inverse(self, layer_path, image=None, filename=None):
        if image is None:
            perspective = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED)
            [panorama_height, panorama_width, _] = self._image.shape

            mapping_style = self._projector.parse_mapping_style(self._mapping_style)
            image = self._projector.inverse(
                perspective, self._lon, self._lat, self._d, panorama_width, panorama_height,
                mapping_style=mapping_style, fov_mode=False)
        if filename is None:
            filename = 'save/{}_{}_{}_{}_{}.png'.format(self._lon, self._lat, self._d, self._width, self._height)
        cv2.imwrite(filename, image)


if __name__ == '__main__':
    projector = EquirecProjector()
    viewer = PanoramaViewer('resource/panorama.png', projector)
    viewer()