Python+OpenCV+Unity | 全景视频投影至立方体

假设你处在一个立方体房间内,该房间的6面都是显示屏。为了让你足不出户就能看到各地美景,我们将用全景摄像头拍摄图像和视频片段,并将全景图像和视频通过6个显示器播放,以产生身临其境的感觉。

下面问题来了,我们并没有这样的房间(误

不过还是先了解一下如何把一个全景图像/视频投影到立方体上吧!

将全景图转化为盒图

首先从简单的开始,如何处理全景图片?(示例图片)

这个过程还是找了不少资料的,最后都不如直接读源码,这里非常感谢stackoverflow上的一个回答:参考链接

下面开始简单解读。

想象一个带有经纬线的球体,它的外部有一立方体将其恰好包围(外切)。

那么,从球体中心向立方体表面作中心投影,就能得到变化后的格子。

建立球坐标系$(r,\theta,\phi)$:对空间内任意一点$P$,$r$为$P$点到坐标系中心$O$点的欧式距离,$\theta$为$OP$与$z$轴正向的夹角(天顶角),$\phi$为$OP$在$XOY$平面的投影线与$x$轴正向的夹角(方位角)。

对于单位球体而言,有

\[r=1, 0<\theta\leq\pi, 0<\phi\leq2\pi\]

全景图大小往往是2:1,而一幅2D的图像如何与球面产生对应关系呢?这就需要坐标变换了。

计算机读取图像时,通常将左上角的像素点坐标计作$(0,0)$,右下角计作$(w,h)$,对应图像的宽和高。当遍历一幅全景图像时,水平方向从左到右遍历恰好对应了方位角从小到大的变化,而竖直方向从上到下遍历恰好对应了天顶角从小到大的变化。这样就能够在遍历全景图像的过程中,确定$\theta$和$\phi$的大小了。

不同的图像函数库对横/纵坐标的记录次序可能相反,比如PIL计作(宽,高),而opencv则计作(行,列),编程过程中要谨慎。

首先根据方位角$\phi$将图像划分为四个部分:$\frac{7\pi}{4}<\phi<2\pi$与$0<\phi<\frac{\pi}{4}$;$\frac{\pi}{4}<\phi<\frac{3\pi}{4}$;$\frac{3\pi}{4}<\phi<\frac{5\pi}{4}$;$\frac{5\pi}{4}<\phi<\frac{7\pi}{4}$,分别对应立方体前、左、后、右四个部分。

再根据天顶角的范围分割出顶面和底面:$tan θ < 1/\sqrt2$ 对应顶面,即$\theta<0.615$;同理$\theta >2.527$时对应底面。

根据球坐标系和直角坐标的定义,可得转换关系:

\[x= r sin\theta cos\phi\] \[y= r sin\theta sin\phi\] \[z= rcos\theta\]

作中心投影时,三个坐标等比例缩放,令缩放倍数为$a$,由于$r=1$,故得新坐标$(asin\theta cos\phi,asin\theta sin\phi, acos\theta)$,对应到立方体上的6个面时,只需分别令$x,y,z=\pm1$即可。

代码可参考上面给出的链接,得到效果图如下:

这个输出明显达不到我们预期的效果,问题就出在:我们是扫描输入图像上的每个点获取的输出,这就会出现多个输入点映射到相同的输出点(坐标舍入误差),从而留下黑斑。而比较好的效果是,根据输出图像上的像素位置,通过上述投影的反变换过程,找到与之对应的输入图像上的像素,并复制过去,从而获得最终效果。代码如下:(这里使用左手坐标系)

# coding=utf-8
import cv2
from math import pi,sin,cos,tan,atan2,hypot,floor
import numpy as np

# get x,y,z coords from out image pixels coords
# i,j are pixel coords
# face is face number
# edge is edge length
def outImgToXYZ(i,j,face,edge):
    a = 2.0*float(i)/edge
    b = 2.0*float(j)/edge
    if face==0: # back
        (x,y,z) = (-1.0, 1.0-a, 3.0 - b)
    elif face==1: # left
        (x,y,z) = (a-3.0, -1.0, 3.0 - b)
    elif face==2: # front
        (x,y,z) = (1.0, a - 5.0, 3.0 - b)
    elif face==3: # right
        (x,y,z) = (7.0-a, 1.0, 3.0 - b)
    elif face==4: # top
        (x,y,z) = (b-1.0, a -5.0, 1.0)
    elif face==5: # bottom
        (x,y,z) = (5.0-b, a-5.0, -1.0)
    return (x,y,z)

# convert using an inverse transformation
def convertBack(imgIn,imgOut):
    rowsIn, colsIn, chI = imgIn.shape
    rowsOut, colsOut, chO = imgOut.shape
    edge = colsIn/4   # the length of each edge in pixels
    for i in xrange(colsOut):
        face = int(i/edge) # 0 - back, 1 - left 2 - front, 3 - right
        if face==2:
            rng = xrange(0,edge*3)
        else:
            rng = xrange(edge,edge*2)

        for j in rng:
            if j<edge:
                face2 = 4 # top
            elif j>=2*edge:
                face2 = 5 # bottom
            else:
                face2 = face

            (x,y,z) = outImgToXYZ(i,j,face2,edge)
            phi = atan2(y,x)
            r = hypot(x,y) # 欧式范数,等价于(x^2+y^2)^(1/2)
            theta = atan2(r,z)
            # source img coords
            uf = ( 2.0*edge*phi/pi )
            vf = ( 2.0*edge * theta/pi)

            u = floor(uf)
            v = floor(vf)
            if u>=colsIn:
                u = u-1
            if v>=rowsIn:
                v = v-1
            (b,g,r) = imgIn[v,u]
            imgOut[j, i] = (b,g,r)

imgIn = cv2.imread('pano.jpg')
rows,cols,ch = imgIn.shape
imgOut = np.zeros((rows*3/2, cols, ch), np.uint8)
convertBack(imgIn,imgOut)
cv2.imshow('imgOut',imgOut)
cv2.waitKey(0)
cv2.destroyAllWindows()

输出效果图:

将核心代码稍作修改,可在指定目录输出六个图片文件,便于放入Unity使用。

修改函数outImgToXYZ(i,j,face,edge)中的内容,改变映射位置。

    if face==0: # back
        (x,y,z) = (-1.0, 1.0-a, 1.0 - b)
    elif face==1: # left
        (x,y,z) = (a-1.0, -1.0, 1.0 - b)
    elif face==2: # front
        (x,y,z) = (1.0, a - 1.0, 1.0 - b)
    elif face==3: # right
        (x,y,z) = (1.0-a, 1.0, 1.0 - b)
    elif face==4: # top
        (x,y,z) = (b-1.0, a -1.0, 1.0)
    elif face==5: # bottom
        (x,y,z) = (1.0-b, a-1.0, -1.0)
    return (x,y,z)

convertBack(imgIn,imgOut)中外嵌一层循环,对6个面分别输出。

    for f in xrange(6):
        ...
        if f == 0:
            cv2.imwrite('img/pano_b.jpg', imgOut)
        elif f == 1:
            cv2.imwrite('img/pano_l.jpg', imgOut)
        elif f == 2:
            cv2.imwrite('img/pano_f.jpg', imgOut)
        elif f == 3:
            cv2.imwrite('img/pano_r.jpg', imgOut)
        elif f == 4:
            cv2.imwrite('img/pano_u.jpg', imgOut)
        else:
            cv2.imwrite('img/pano_d.jpg', imgOut)

最终在Unity中的效果如本文开头所示。

将全景视频投影至立方体

既然对已将图片处理完毕,那么视频的处理也是水到渠成的问题了。

视频可以看作随时间轴不断变化的一组图片序列,我们调用OpenCV的视频处理工具,对打开的视频逐帧作上文的变换,并将结果写入本地存储。

先从修改代码开始。

在主函数中创建一个VideoCapture用来容纳要读取的视频。设置编码器fourcc,再创建6个面的VideoWriter,用于将处理后的视频序列写入6个独立的文件。

cap1 = cv2.VideoCapture('video/surfers_360.mp4')

fourcc = cv2.cv.CV_FOURCC(*'avc1')
out_b = cv2.VideoWriter('video/out_b.avi', fourcc, 25.0, (512, 512))
out_l = cv2.VideoWriter('video/out_l.avi', fourcc, 25.0, (512, 512))
out_f = cv2.VideoWriter('video/out_f.avi', fourcc, 25.0, (512, 512))
out_r = cv2.VideoWriter('video/out_r.avi', fourcc, 25.0, (512, 512))
out_u = cv2.VideoWriter('video/out_u.avi', fourcc, 25.0, (512, 512))
out_d = cv2.VideoWriter('video/out_d.avi', fourcc, 25.0, (512, 512))

再将针对6个面的循环从convertBack函数中移到主函数中:

while cap1.isOpened():
    ret,imgIn = cap1.read()
    rows, cols, ch = imgIn.shape
    #print rows,cols
    imgOut = np.zeros((rows / 2, cols / 4, ch), np.uint8)
    for f in xrange(6):
        imgOut = convertBack(imgIn, imgOut, f)
        if f == 0:
            out_b.write(imgOut)
            cv2.imshow('Cliped', imgOut)
        elif f == 1:
            out_l.write(imgOut)
        elif f == 2:
            out_f.write(imgOut)
        elif f == 3:
            out_r.write(imgOut)
        elif f == 4:
            out_u.write(imgOut)
        else:
            out_d.write(imgOut)
    #cv2.imshow('Cliped', imgOut)
    if cv2.waitKey(1) == ord('q'):
        break

cap1.release()

当然,理论上就是这么easy,实践过程中还是存在坑的。

我使用的环境是Python2.7 + OpenCV 2.X + MacOS High Sierra,IDE是PyCharm Community Edition 2016.3.2。在调试视频输出的时候,一直存在无法写入到本地存储的问题(输出的视频序列文件是414字节),参考了这个链接和链接中的若干子链接都没有解决问题,最后在知乎上的这个回答找到了合适的编码器选项(也就是现在代码里用的cv2.cv.CV_FOURCC(*'avc1'))。这个问题在一年前就碰到过,最后的解决也还是比较玄学,不过坑迟早是要填的(

最后,这种处理方法可以说是非常初级,算法效率自然也非常低下,并且没有对输出作什么优化(源stackoverflow上的那个回答还用双线性插值对反变换做了优化,但肉眼不太容易看出差别,并且更降低了算法效率,就舍弃了),如果要达到实时传输,还需要借用现有的框架进行进一步处理,这个Demo主要是深入理解这种映射背后的逻辑。

Unity实现视频盒子

Unity做场景漫游非常方便,过程也比较简单,GameObject的目录如下:

在盒子中央放置一个点光源(Point Light)作为全局光,用6个平面作为视频组,调用Unity自带的角色控制库,并在适当的位置放置两个摄像机(TPSCamera和FPSCamera),分别显示第三人称视角和第一人称视角。写一个简单的脚本用来切换摄像机:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class cameraManager : MonoBehaviour 
{
	public GameObject FPS;
	public GameObject TPS;
	// Use this for initialization
	void Start () 
	{
		
	}
	
	// Update is called once per frame
	void Update () 
	{
		bool isButtonDown = false;
		if (Input.GetKeyDown(KeyCode.Q) && !isButtonDown)
		{
			isButtonDown = true;
			FPS.SetActive (true);
			TPS.SetActive (false);
        }
		else if (Input.GetKeyDown(KeyCode.Q) && isButtonDown)
        {
            isButtonDown = false;
			TPS.SetActive (true);
			FPS.SetActive (false);
        }

	}
}

这样运行后就可以通过按Q键来切换机位了,效果如下。

第三人称视角:

第一人称视角:


12.21 更新优化

第一帧渲染开始前,先将映射关系的索引存储下来,在渲染每一帧时直接根据索引寻找原图中对应的像素,避免重复计算。

该优化将计算速度提升了约30%。

然而还是达不到实时性要求(