Skip to content

Commit 1837e8f

Browse files
committed
Added support for omnidirectional cameras
1 parent 4e81913 commit 1837e8f

File tree

5 files changed

+294
-5
lines changed

5 files changed

+294
-5
lines changed

envmap/environmentmap.py

+75-2
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,79 @@ def fromSkybox(cls, top, bottom, left, right, front, back):
144144

145145
return cls(cube, format_="cube")
146146

147+
148+
@classmethod
149+
def from_omnicam(cls, im, targetDim, targetFormat='skyangular', OcamCalib_=None, copy=True, order=1):
150+
"""
151+
Creates an EnvironmentMap from Omnidirectional Camera (OcamCalib) capture.
152+
153+
:param im: Image path (str, pathlib.Path) or data (np.ndarray) representing
154+
an ocam image.
155+
:param OcamCalib: OCamCalib calibration dictionary. If not provided and
156+
param im is an image path, then calibration will be loaded directly
157+
from matching ".meta.xml" file.
158+
:param targetFormat: Target format.
159+
:param targetDim: Target dimension.
160+
:param order: Interpolation order (0: nearest, 1: linear, ..., 5).
161+
:param copy: When a numpy array is given, should it be copied.
162+
163+
:type im: str, Path, np.ndarray
164+
:type OcamCalib: dict
165+
:type targetFormat: string
166+
:type targetDim: integer
167+
:type order: integer (0,1,...,5)
168+
:type copy: bool
169+
"""
170+
171+
if not OcamCalib_ and isPath(im):
172+
filename = os.path.splitext(str(im))[0]
173+
metadata = EnvmapXMLParser("{}.meta.xml".format(filename))
174+
OcamCalib_ = metadata.get_calibration()
175+
176+
assert OcamCalib_ is not None, (
177+
"Please provide OCam (metadata file not found).")
178+
179+
if isPath(im):
180+
# We received the filename
181+
data = imread(str(im))
182+
elif type(im).__module__ == np.__name__:
183+
# We received a numpy array
184+
data = np.asarray(im, dtype='double')
185+
if copy:
186+
data = data.copy()
187+
else:
188+
raise Exception('Could not understand input. Please provide a '
189+
'filename (str) or an image (np.ndarray).')
190+
191+
e = EnvironmentMap(targetDim, targetFormat)
192+
dx, dy, dz, valid = e.worldCoordinates()
193+
u,v = world2ocam(dx, dy, dz, OcamCalib_)
194+
195+
# Interpolate
196+
# Repeat the first and last rows/columns for interpolation purposes
197+
h, w, d = data.shape
198+
source = np.empty((h + 2, w + 2, d))
199+
200+
source[1:-1, 1:-1] = data
201+
source[0,1:-1] = data[0,:]; source[0,0] = data[0,0]; source[0,-1] = data[0,-1]
202+
source[-1,1:-1] = data[-1,:]; source[-1,0] = data[-1,0]; source[-1,-1] = data[-1,-1]
203+
source[1:-1,0] = data[:,0]
204+
source[1:-1,-1] = data[:,-1]
205+
206+
# To avoid displacement due to the padding
207+
u += 0.5/data.shape[1]
208+
v += 0.5/data.shape[0]
209+
target = np.vstack((u.flatten()*data.shape[0], v.flatten()*data.shape[1]))
210+
211+
data = np.zeros((u.shape[0], u.shape[1], d))
212+
for c in range(d):
213+
map_coordinates(source[:,:,c], target, output=data[:,:,c].reshape(-1), cval=np.nan, order=order, prefilter=filter)
214+
e.data = data
215+
216+
# Done
217+
return e
218+
219+
147220
def __hash__(self):
148221
"""Provide a hash of the environment map type and size.
149222
Warning: doesn't take into account the data, just the type,
@@ -228,7 +301,7 @@ def world2image(self, x, y, z):
228301
def world2pixel(self, x, y, z):
229302
"""Returns the (u, v) coordinates (in the interval defined by the MxN image)."""
230303

231-
# Get (u,v) in [-1, 1] interval
304+
# Get (u,v) in [0, 1] interval
232305
u,v = self.world2image(x, y, z)
233306

234307
# de-Normalize coordinates to interval defined by the MxN image
@@ -241,7 +314,7 @@ def world2pixel(self, x, y, z):
241314
def pixel2world(self, u, v):
242315
"""Returns the (x, y, z) coordinates for pixel cordinates (u,v)(in the interval defined by the MxN image)."""
243316

244-
# Normalize coordinates to [-1, 1] interval
317+
# Normalize coordinates to [0, 1] interval
245318
u = (u+0.5) / self.data.shape[1]
246319
v = (v+0.5) / self.data.shape[0]
247320

envmap/projections.py

+112
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import numpy as np
22
from numpy import logical_and as land, logical_or as lor
33

4+
from .rotations import *
45

56
def world2latlong(x, y, z):
67
"""Get the (u, v) coordinates of the point defined by (x, y, z) for
@@ -295,3 +296,114 @@ def cube2world(u, v):
295296
return x.item(), y.item(), z.item(), valid.item()
296297

297298
return x, y, z, valid
299+
300+
301+
def ocam2world(u, v, ocam_calibration):
302+
""" Project a point (u, v) in omnidirectional camera space to
303+
(x, y, z) point in world coordinate space."""
304+
305+
# Step 0. De-Normalize coordinates to interval defined by the MxN image
306+
# Where u=cols(x), v=rows(y)
307+
width_cols = ocam_calibration['width']
308+
height_rows = ocam_calibration['height']
309+
310+
v = np.floor(v*height_rows).astype(int)
311+
u = np.floor(u*width_cols).astype(int)
312+
313+
# Step 1. Center & Skew correction
314+
# M = affine matrix [c,d,xc; e,1,yc; 0,0,1]
315+
# p' = M^-1 * (p)
316+
M = ocam_calibration['affine_3x3']
317+
F = ocam_calibration['F']
318+
319+
# Inverse: M_ = M^-1
320+
M_ = np.linalg.inv(M)
321+
322+
# Affine transform
323+
w = np.ones_like(u)
324+
assert u.shape == v.shape
325+
save_original_shape = u.shape
326+
p_uvw = np.array((v.reshape(-1),u.reshape(-1),w.reshape(-1)))
327+
p_xyz = np.matmul(M_,p_uvw)
328+
329+
# Add epsilon to mitigate NAN
330+
p_xyz[ p_xyz==0 ] = np.finfo(p_xyz.dtype).eps
331+
332+
# Step 2. Get unit-sphere world coordinate z
333+
# Distance to center of image: p = sqrt(X^2 + Y^2)
334+
p_z = np.linalg.norm(p_xyz[0:2], axis=0)
335+
# Convert to z-coordinate with p_z = F(p)
336+
p_xyz[2] = F(p_z)
337+
338+
# Step 3. Normalize x,y,z to unit length of 1 (unit sphere)
339+
p_xyz = p_xyz / np.linalg.norm(p_xyz, axis=0)
340+
341+
# Step 4. Fix coordinate system alignment
342+
# (rotate -90 degrees around z-axis)
343+
# (x,y,z) -> (x,-z,y) as +y is up (not +z)
344+
p_xyz = np.matmul(rotz(np.deg2rad(-90)),p_xyz)
345+
x,y,z = (
346+
p_xyz[0].reshape(save_original_shape),
347+
-p_xyz[2].reshape(save_original_shape),
348+
-p_xyz[1].reshape(save_original_shape)
349+
)
350+
351+
valid = np.ones(x.shape, dtype='bool')
352+
return x,y,z, valid
353+
354+
355+
def world2ocam(x, y, z, ocam_calibration):
356+
""" Project a point (x, y, z) in world coordinate space to
357+
a (u, v) point in omnidirectional camera space."""
358+
359+
# Step 1. Center & Skew correction
360+
# M = affine matrix [c,d; e,1]
361+
# T = translation vector
362+
# p' = M^-1 * (p - T)
363+
M = ocam_calibration['affine_3x3']
364+
F = ocam_calibration['F']
365+
366+
assert x.shape == y.shape and x.shape == z.shape, f'{x.shape} == {y.shape} == {z.shape}'
367+
save_original_shape = x.shape
368+
369+
# Step 2. Fix coordinate system alignment
370+
# (x,y,z) -> (x,z,-y) as +z is up (not +y)
371+
p_xyz = np.array((x.reshape(-1),y.reshape(-1),-z.reshape(-1)))
372+
373+
# Add epsilon to mitigate NAN
374+
p_xyz[ p_xyz==0 ] = np.finfo(p_xyz.dtype).eps
375+
376+
# (rotate 90 degrees around z-axis)
377+
p_xyz = np.array((p_xyz[0], p_xyz[2], -p_xyz[1]))
378+
p_xyz = np.matmul(rotz(np.deg2rad(90)),p_xyz)
379+
380+
# Step 3. 3D to 2D
381+
m = p_xyz[2] / np.linalg.norm(p_xyz[0:2], axis=0)
382+
383+
def poly_inverse(y):
384+
F_ = F.copy()
385+
F_.coef[1] -=y
386+
F_r = F_.roots()
387+
F_r = F_r[ (F_r >= 0) & (F_r.imag == 0) ]
388+
if len(F_r)>0:
389+
return F_r[0].real
390+
else:
391+
return np.nan
392+
m_ = np.vectorize(poly_inverse)(m)
393+
394+
uvw = p_xyz / np.linalg.norm(p_xyz[0:2], axis=0) * m_
395+
396+
# Step 4. Affine transform for center and skew
397+
uvw[2,:] = 1
398+
uvw = np.nan_to_num(uvw)
399+
uvw = np.matmul(M, uvw)
400+
u,v = uvw[1].reshape(save_original_shape), uvw[0].reshape(save_original_shape)
401+
402+
# Step 3. Normalize coordinates to interval [0,1]
403+
# Where u=cols(x), v=rows(y)
404+
width_cols = ocam_calibration['width']
405+
height_rows = ocam_calibration['height']
406+
u = (u+0.5) / width_cols
407+
v = (v+0.5) / height_rows
408+
409+
return u,v

envmap/xmlhelper.py

+79-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import numpy as np
2+
import datetime as dt
13
import xml.etree.ElementTree as ET
24

35

@@ -9,26 +11,101 @@ def __init__(self, filename):
911
self.tree = ET.parse(filename)
1012
self.root = self.tree.getroot()
1113

14+
1215
def _getFirstChildTag(self, tag):
1316
for elem in self.root:
1417
if elem.tag == tag:
1518
return elem.attrib
1619

20+
1721
def _getAttrib(self, node, attribute, default=None):
1822
if node:
1923
return node.get(attribute, default)
2024
return default
2125

26+
2227
def getFormat(self):
2328
"""Returns the format of the environment map."""
2429
node = self._getFirstChildTag('data')
2530
return self._getAttrib(node, 'format', 'Unknown')
2631

32+
2733
def getDate(self):
28-
"""Returns the date of the environment mapin dict format."""
34+
"""Returns the date of the environment map in dict format."""
2935
return self._getFirstChildTag('date')
3036

37+
38+
def get_datetime(self):
39+
"""Returns the date of the environment map in datetime format."""
40+
# Example: <date day="24" hour="16" minute="36" month="9" second="49.1429" utc="-4" year="2014"/>
41+
date = self.root.find('date')
42+
year = date.get('year')
43+
month = date.get('month').zfill(2)
44+
day = date.get('day').zfill(2)
45+
hour = date.get('hour').zfill(2)
46+
minute = date.get('minute').zfill(2)
47+
second = str(int(float(date.get('second')))).zfill(2)
48+
utc_offset = int(date.get('utc'))
49+
if utc_offset > 0:
50+
utc_offset = f'+{str(utc_offset).zfill(2)}'
51+
else:
52+
utc_offset = f'-{str(np.abs(utc_offset)).zfill(2)}'
53+
return dt.datetime.fromisoformat(f"{year}-{month}-{day} {hour}:{minute}:{second}{utc_offset}:00")
54+
55+
56+
def get_calibration(self):
57+
"""Returns the OCamCalib calibration metadata."""
58+
59+
calibration = {}
60+
# Calibration Model
61+
node = self.root.find('calibrationModel')
62+
# Affine 2D = [c,d;
63+
# e,1];
64+
c = float(node.get('c'))
65+
d = float(node.get('d'))
66+
e = float(node.get('e'))
67+
affine_2x2 = np.array([[c,d],[e, 1]])
68+
calibration['c'] = c
69+
calibration['d'] = d
70+
calibration['e'] = e
71+
calibration['affine_2x2'] = affine_2x2
72+
73+
# shape = [height, width]
74+
height = int(node.get('height'))
75+
width = int(node.get('width'))
76+
calibration['height'] = height
77+
calibration['width'] = width
78+
calibration['shape'] = (height, width)
79+
80+
# center = [xc, yc]
81+
xc = float(node.get('xc'))
82+
yc = float(node.get('yc'))
83+
calibration['xc'] = xc
84+
calibration['yc'] = yc
85+
calibration['center'] = (xc, yc)
86+
87+
# Affine 3D = [c,d,xc;
88+
# e,1,yc;
89+
# 0,0,1];
90+
affine_3x3 = np.array([[c,d,xc],[e, 1, yc],[0,0,1]])
91+
calibration['affine_3x3'] = affine_3x3
92+
93+
# ss = [a_0, a_1, ..., a_n]
94+
ss = [ float(s.get('s')) for s in node.findall('ss') ]
95+
calibration['ss'] = np.array(ss)
96+
polydomain = xc if xc > yc else yc
97+
polydomain = [-polydomain,polydomain]
98+
calibration['F'] = \
99+
np.polynomial.polynomial.Polynomial(
100+
ss,
101+
domain=polydomain,
102+
window=polydomain
103+
)
104+
105+
return calibration
106+
107+
31108
def getExposure(self):
32109
"""Returns the exposure of the environment map in EV."""
33110
node = self._getFirstChildTag('exposure')
34-
return self._getAttrib(node, 'EV')
111+
return float(self._getAttrib(node, 'EV'))

test/__init__.py

Whitespace-only changes.

test/test_projections.py

+28-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import pytest
2-
import math
32
import numpy as np
43

54
from envmap import projections as t
@@ -113,3 +112,31 @@ def test_projections_image(format_):
113112

114113
np.testing.assert_array_almost_equal(u[valid], u_[valid], decimal=5)
115114
np.testing.assert_array_almost_equal(v[valid], v_[valid], decimal=5)
115+
116+
117+
@pytest.mark.parametrize("calibration",
118+
[
119+
# ( ocam calibration dict, from 20150909_144054_stack.meta.xml )
120+
({
121+
'height': 3840,
122+
'width': 5760,
123+
'F' : np.polynomial.polynomial.Polynomial(
124+
np.array([-1283.8735,0,0.00035359,-1.2974e-07,6.7764e-11]),
125+
domain=[-2895.3481,2895.3481],
126+
window=[-2895.3481,2895.3481]
127+
),
128+
'affine_3x3':np.array([[0.99981,0.00024371,1904.2826],[3.7633e-06, 1, 2895.3481],[0,0,1]]),
129+
}),
130+
]
131+
)
132+
def test_ocam(calibration):
133+
134+
e = env.EnvironmentMap(64, 'skyangular', channels=2)
135+
x,y,z, valid = e.worldCoordinates()
136+
137+
u_, v_ = t.world2ocam(x, y, z, calibration)
138+
x_, y_, z_, V = t.ocam2world(u_, v_, calibration)
139+
140+
np.testing.assert_array_almost_equal(x[valid], x_[valid], decimal=3)
141+
np.testing.assert_array_almost_equal(y[valid], y_[valid], decimal=3)
142+
np.testing.assert_array_almost_equal(z[valid], z_[valid], decimal=3)

0 commit comments

Comments
 (0)