假设你处在一个立方体房间内,该房间的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%。
然而还是达不到实时性要求(