# -*- coding: utf-8 -*-
# Copyright (c) 2019 Stephen Wasilewski
# =======================================================================
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
# =======================================================================
"""functions for translating between coordinate spaces and resolutions"""
import numpy as np
from scipy.ndimage import gaussian_filter, uniform_filter
from scipy.spatial import cKDTree, Voronoi
from shapely.geometry import Polygon
[docs]def norm(v):
"""normalize 2D array of vectors along last dimension"""
return v/np.linalg.norm(v, axis=-1).reshape(-1, 1)
[docs]def norm1(v):
"""normalize flat vector"""
n = np.sqrt(np.sum(np.square(v)))
if n == 0:
n = 1
return np.asarray(v)/n
###############################################
# Shirley=Chiu Disk to Square Transformations #
###############################################
[docs]def uv2xy(uv):
"""translate from unit square (0,1),(0,1) to disk (x,y)
http://psgraphics.blogspot.com/2011/01/improved-code-for-concentric
-map.html.
"""
uv = np.atleast_2d(uv)
np.seterr(all="ignore")
xy = np.empty((uv.shape[0], 2), float)
u = uv[:, 0]
v = uv[:, 1]
n = np.where(u > 1.0, -1, 1)
u = np.where(u > 1.0, u - 1.0, u)
a = 2. * u - 1
b = 2. * v - 1
cond = a*a > b*b
r = np.where(cond, a, np.where(b == 0, 0, b))
phi = np.where(cond, b/(2*a), np.where(b == 0, 0, 1 - a/(2*b)))*np.pi/2
xy[:, 0] = n*np.cos(phi)*r
xy[:, 1] = np.sin(phi)*r
return xy
[docs]def uv2xyz(uv, axes=(0, 1, 2), xsign=1):
"""translate from 2 x unit square (0,2),(0,1) to unit sphere (x,y,z)
http://psgraphics.blogspot.com/2011/01/improved-code-for-concentric
-map.html.
"""
np.seterr(all="ignore")
uv = np.atleast_2d(uv)
xyz = np.empty((uv.shape[0], 3), float)
u = uv[:, 0]
v = uv[:, 1]
# u > 1 values lay in the negative z hemisphere
n = np.where(u > 1.0, -1, 1)
# bring both hemispheres to (0,1)
u = np.where(u > 1.0, u - 1.0, u)
a = 2. * u - 1
b = 2. * v - 1
cond = a*a > b*b
r = np.where(cond, a, np.where(b == 0, 0, b))
phi = np.where(cond, b/(2*a), np.where(b == 0, 0, 1 - a/(2*b)))*np.pi/2
sphterm = r*np.sqrt(2 - r*r)
# flip back x in positive z space
xyz[:, axes[0]] = xsign*n*np.cos(phi)*sphterm
xyz[:, axes[1]] = np.sin(phi)*sphterm
# add sign to z
xyz[:, axes[2]] = n*(1 - r*r)
return xyz
[docs]def xyz2uv(xyz, normalize=False, axes=(0, 1, 2), flipu=False):
"""translate from vector x,y,z (normalized) to u,v (0,2),(0,1)
Shirley, Peter, and Kenneth Chiu. A Low Distortion Map Between Disk and
Square. Journal of Graphics Tools, vol. 2, no. 3, Jan. 1997, pp. 45-52.
Taylor and Francis+NEJM, doi:10.1080/10867651.1997.10487479.
"""
xyz = np.atleast_2d(xyz)
if normalize:
xyz = norm(xyz)
uv = np.empty((xyz.shape[0], 2), float)
# store sign of z-axis to map both hemispheres as positive
n = np.where(xyz[:, axes[2]] < 0, -1, 1)
r2 = 1 - n*xyz[:, axes[2]]
x = xyz[:, axes[0]] / np.sqrt(2 - r2)
y = xyz[:, axes[1]] / np.sqrt(2 - r2)
r = np.sqrt(np.square(x) + np.square(y))
phi = np.arctan2(y, x)
pi4 = np.pi/4
phi = phi + np.where(phi < -pi4, 2*np.pi, 0)
a = np.where(phi < pi4, (r, phi*r/pi4),
np.where(phi < 3*pi4, (-(phi - np.pi/2)*r/pi4, r),
np.where(phi < 5*pi4, (-r, -(phi - np.pi)*r/pi4),
((phi - 3*np.pi/2)*r/pi4, -r)))).T
# for the positive z-direction (n=1) map -1,1 to 1,0 (to correct flip)
# for the negative z-direction (n=-1) map -1,1 to 1,2
if flipu:
uv[:, 0] = (np.where(n < 0, 3, 1) - a[:, 0]*n) / 2.
else:
uv[:, 0] = (a[:, 0] + 2 - n) / 2.
uv[:, 1] = (a[:, 1] + 1) / 2.
return uv
###########################################
# Translate to/from shirley chiu sky bins #
###########################################
scbinscal = ("""
{ map U/V axis to bin divisions }
axis(x) : mod(floor(side * x), side);
nrbins = side * side;
{ get bin of u,v }
binl(u, v) : axis(u)*side + axis(v);
{ shirley-chiu disk to square (with spherical term) }
PI : 3.14159265358979323846;
pi4 : PI/4;
n = if(Dz, 1, -1);
r2 = 1 - n*Dz;
x = Dx/sqrt(2 - r2);
y = -Dy/sqrt(2 - r2);
r = sqrt( sq(x) + sq(y));
ph = atan2(x, y);
phi = ph + if(-pi4 - ph, 2*PI, 0);
a = if(pi4 - phi, r, if(3*pi4 - phi, -(phi - PI/2)*r/pi4, if(5*pi4 - phi,"""
""" -r, (phi - 3*PI/2)*r/pi4)));
b = if(pi4 - phi, phi*r/pi4, if(3*pi4 - phi, r, if(5*pi4 - phi, """
"""-(phi - PI)*r/pi4, -r)));
{ map to (0,2),(0,1) matches raytraverse.translate.xyz2uv}
U = (if(n, 1, 3) - a*n)/2;
V = (b + 1)/2;
bin = if(n, binl(V, U), nrbins);
{ for visualizing with gridlines }
t : .015;
grid(x) : if(and(inside(t, frac(U*side), 1-t), inside(t, frac(V*side), 1-t)), x, 0);
""")
scxyzcal = """
x1 = .5;
x2 = .5;
U = ((bin - mod(bin, side)) / side + x1)/side;
V = (mod(bin, side) + x2)/side;
n = if(U - 1, -1, 1);
ur = if(U - 1, U - 1, U);
a = 2 * ur - 1;
b = 2 * V - 1;
conda = sq(a) - sq(b);
condb = abs(b) - FTINY;
r = if(conda, a, if(condb, b, 0));
phi = if(conda, b/(2*a), if(condb, 1 - a/(2*b), 0)) * PI/2;
sphterm = r * sqrt(2 - sq(r));
Dx = n * cos(phi)*sphterm;
Dy = sin(phi)*sphterm;
Dz = n * (1 - sq(r));
"""
[docs]def xyz2skybin(xyz, side, tol=0, normalize=False):
xyz = np.atleast_2d(xyz)
uv = xyz2uv(xyz, flipu=False, normalize=normalize)
if tol > 0:
tol = tol/side
uvi = np.linspace(-tol, tol, 3)
uvs = np.stack(np.meshgrid(uvi, uvi)).reshape(2, 9).T + uv
sbin = np.unique(uv2bin(uvs, side)).astype(int)
skybin = sbin[sbin < side**2]
else:
skybin = uv2bin(uv, side)
return skybin
[docs]def skybin2xyz(bn, side):
"""generate source vectors from sky bins
Parameters
----------
bn: np.array
bin numbers
side: int
square side of discretization
Returns
-------
xyz: np.array
direction to center of sky patches
"""
uv = bin2uv(bn, side)
xyz = uv2xyz(uv, xsign=1)
xyz[xyz[:, 2] < 0] = (0, 0, -1)
return xyz
##################################################
# Translate to/from anglular fisheye projeection #
##################################################
[docs]def xyz2xy(xyz, axes=(0, 1, 2), flip=False):
"""xyz coordinates to xy mapping of angular fisheye proejection"""
xyz = np.atleast_2d(xyz)
r = np.arctan2(np.sqrt(np.sum(np.square(xyz[:, axes[0:2]]), -1)),
xyz[:, axes[2]])/(np.pi/2)
phi = np.arctan2(xyz[:, axes[0]], xyz[:, axes[1]])
x = r * np.sin(phi)
y = r * np.cos(phi)
if flip:
x = -x
return np.stack((x, y)).T
##########################################
# Translate to/from anglular coordinates #
##########################################
[docs]def tpnorm(thetaphi):
"""normalize angular vector to 0-pi, 0-2pi"""
thetaphi = np.atleast_2d(thetaphi)
thetaphi[:, 0] = np.mod(thetaphi[:, 0] + np.pi, np.pi)
thetaphi[:, 1] = np.mod(thetaphi[:, 1] + 2*np.pi, 2*np.pi)
return thetaphi
[docs]def tp2xyz(thetaphi, normalize=True):
"""calculate x,y,z vector from theta (0-pi) and phi (0-2pi) RHS Z-up"""
thetaphi = np.atleast_2d(thetaphi)
if normalize:
thetaphi = tpnorm(thetaphi)
theta = thetaphi[:, 0]
phi = thetaphi[:, 1]
sint = np.sin(theta)
xyz = np.array([sint*np.cos(phi), sint*np.sin(phi),
np.cos(theta)]).T
return norm(xyz)
[docs]def xyz2tp(xyz):
"""calculate theta (0-pi), phi from x,y,z RHS Z-up"""
xyz = np.atleast_2d(xyz)
theta = np.arccos(xyz[:, 2])
phi = np.where(np.isclose(theta, 0.0, atol=1e-10), np.pi,
np.where(np.isclose(theta, np.pi, atol=1e-10),
np.pi, np.arctan2(xyz[:, 1], xyz[:, 0])))
return tpnorm(np.column_stack([theta, phi]))
[docs]def tp2uv(thetaphi):
"""calculate UV from theta (0-pi), phi"""
thetaphi = np.atleast_2d(thetaphi)
return xyz2uv(tp2xyz(thetaphi))
[docs]def uv2tp(uv):
"""calculate theta (0-pi), phi from UV"""
uv = np.atleast_2d(uv)
return xyz2tp(uv2xyz(uv))
[docs]def aa2xyz(aa):
"""calculate altitude (0-90), azimuth (-180,180) from xyz"""
aa = np.atleast_2d(aa)
tp = np.pi/2 - aa * np.pi/180
tp[:, 1] += np.pi
return tp2xyz(tp)
[docs]def xyz2aa(xyz):
"""calculate xyz from altitude (0-90), azimuth (-180,180)"""
xyz = np.atleast_2d(xyz)
tp = xyz2tp(xyz)
tp[:, 1] -= np.pi
return (np.pi/2 - tp)/(np.pi/180)
[docs]def chord2theta(c):
"""compute angle from chord on unit circle
Parameters
----------
c: float
chord or euclidean distance between normalized direction vectors
Returns
-------
theta: float
angle captured by chord
"""
return 2*np.arcsin(c/2)
[docs]def theta2chord(theta):
"""compute chord length on unit sphere from angle
Parameters
----------
theta: float
angle
Returns
-------
c: float
chord or euclidean distance between normalized direction vectors
"""
return 2*np.sin(theta/2)
[docs]def ctheta(a, b):
"""cos(theta) (dot product) between a and b"""
a = np.asarray(a)
b = np.asarray(b)
return np.clip(np.einsum("i,ji->j", a, b.reshape(-1, b.shape[-1])), -1, 1)
[docs]def radians(a, b):
"""angle in radians betweeen a and b"""
return np.arccos(ctheta(a, b))
[docs]def degrees(a, b):
"""angle in degrees betweeen a and b"""
return radians(a, b) * 180/np.pi
################################################
# digitize and serialize UV square coordinates #
################################################
[docs]def uv2ij(uv, side, aspect=2):
uv = np.atleast_2d(uv)
ij = np.mod(np.floor(side*uv), side)
if aspect == 2:
ij[:, 0] += (uv[:, 0] >= 1) * side
return ij.astype(int)
[docs]def uv2bin(uv, side):
buv = uv2ij(uv, side)
return buv[:, 0]*side + buv[:, 1]
[docs]def bin2uv(bn, side, offset=0.5):
u = ((bn - np.mod(bn, side))/side + offset)/side
v = (np.mod(bn, side) + offset)/side
return np.stack((u, v)).T
#########################
# image like resampling #
#########################
[docs]def resample(samps, ts=None, gauss=True, radius=None):
"""simple array resampling. requires whole number multiple scaling.
Parameters
----------
samps: np.array
array to resample along each axis
ts: tuple, optional
shape of output array, should be multiple of samps.shape
gauss: bool, optional
apply gaussian filter to upsampling
radius: float, optional
when gauss is True, filter radius, default is the scale ratio - 1
Returns
-------
np.array
to resampled array
"""
if ts is None:
ts = samps.shape
rs = np.array(ts)/np.array(samps.shape)
if np.prod(rs) > 1:
for i in range(len(rs)):
samps = np.repeat(samps, rs[i], i)
if gauss:
if radius is None:
radius = tuple(rs - 1)
samps = gaussian_filter(samps, radius)
elif np.prod(rs) < 1:
rs = (1/rs).astype(int)
og = (-rs/2).astype(int)
samps = uniform_filter(samps, rs, origin=og)
for i, j in enumerate(rs):
samps = np.take(samps, np.arange(0, samps.shape[i], j), i)
elif radius is not None:
if gauss:
samps = gaussian_filter(samps, radius)
else:
samps = uniform_filter(samps, int(radius))
return samps
####################
# vector rotations #
####################
[docs]def rmtx_elem(theta, axis=2, degrees=True):
if degrees:
theta = theta * np.pi / 180
rmtx = np.array([(np.cos(theta), -np.sin(theta), 0),
(np.sin(theta), np.cos(theta), 0),
(0, 0, 1)])
return np.roll(rmtx, axis-2, (0, 1))
[docs]def rotate_elem(v, theta, axis=2, degrees=True):
rmtx = rmtx_elem(theta, axis=axis, degrees=degrees)
return np.einsum('ij,kj->ki', rmtx, v)
[docs]def rmtx_yp(v):
"""generate a pair of rotation matrices to transform from vector v to
z, enforcing a z-up in the source space and a y-up in the destination. If
v is z, returns pair of identity matrices, if v is -z returns pair of 180
degree rotation matrices.
Parameters
----------
v: array-like of size (N, 3)
the vector direction representing the starting coordinate space
Returns
-------
ymtx, pmtx: (np.array, np.array)
two rotation matrices to be premultiplied in order to reverse transform,
swap order and transpose.
Notes
-----
if N is one:
Forward: (pmtx@(ymtx@xyz.T)).T or np.einsum("ij,kj,li->kl", ymtx, xyz, pmtx)
Backward: (ymtx.T@(pmtx.T@xyz.T)).T or np.einsum("ji,kj,il-kl", pmtx, nv, ymtx)
else:
Forward: np.einsum("vij,vkj,vli->vkl", ymtx, xyz, pmtx)
Backward: np.einsum("vji,vkj,vil-vkl", pmtx, nv, ymtx)
"""
vs = norm(np.asarray(v).reshape(-1, 3))
v2 = np.array((0, 0, 1)).reshape(-1, 3)
tp = xyz2tp(vs)
# check for identity or reverse transforms
e = np.all(np.isclose(vs, v2), 1)
ne = np.all(np.isclose(vs, -v2), 1)
z = np.zeros(len(vs))
o = np.ones(len(vs))
# yaw matrix
y = 3*np.pi/2 - tp[:, 1]
cy = np.cos(y)
sy = np.sin(y)
ymtx = np.stack(((cy, sy, z), (-sy, cy, z), (z, z, o))).T
ymtx[e] = np.eye(3)
ymtx[ne] = np.array([(-1, 0, 0), (0, -1, 0), (0, 0, 1)])
# pitch matrix
p = -tp[:, 0]
cp = np.cos(p)
sp = np.sin(p)
pmtx = np.stack(((o, z, z), (z, cp, sp), (z, -sp, cp))).T
pmtx[e] = np.eye(3)
pmtx[ne] = np.array([(1, 0, 0), (0, -1, 0), (0, 0, -1)])
return np.squeeze(ymtx), np.squeeze(pmtx)
[docs]def cull_vectors(vecs, tol):
"""return mask to cull duplicate vectors within tolerance
Parameters
----------
vecs: Union[cKDTree, np.array]
prebuilt KDTree or np.array to build a new one. culling keeps
first vector in array used to build tree.
tol: float
tolerance for culling
Returns
-------
np.array
boolean mask of vecs (or vecs.data) to cull vectors
"""
if type(vecs) != cKDTree:
pkd = cKDTree(vecs)
else:
pkd = vecs
vecs = pkd.data
pairs = pkd.query_ball_tree(pkd, tol, 2)
flt = np.full(len(vecs), True)
# keep track of culled indices to avoid removing chains of points
flagged = set()
for j, p in enumerate(pairs):
if j not in flagged and len(p) > 1:
# don't purge first or current indices
q = [i for i in p[1:] if i != j]
flt[q] = False
flagged.update(q)
return flt
[docs]def reflect(ray, normal, returnmasked=False):
refl = (ray[:, None] -
2 * normal[None] * np.einsum("ij,kj->ik", ray, normal)[..., None])
try:
refl = np.squeeze(refl, 0)
except ValueError:
pass
n = np.isclose(np.linalg.norm(refl, axis=-1), 1)
if returnmasked:
return refl[n]
return refl, n
[docs]def simple_take(ar, *slices, axes=None):
"""consistent array indexing with arrays, lists, tuples and slices
Parameters
----------
ar: np.array
the multidimensional arary to index
slices: tuple
if sequence, takes those indices along axis, if None, take whole
dimension, if slice, applies to index array before take
axes: Union[Sequence, int], optional
when None, slices are automatically taken starting on axes 0. Use this
argument to only operate on a subset of dimensions.
Returns
-------
np.array
matches ndims of ar
"""
if axes is None:
axes = np.arange(len(slices))
else:
axes = np.ravel(axes)
for i, j in zip(slices, axes):
if i is None:
pass
elif type(i) == slice:
k = np.arange(ar.shape[j])[i]
ar = np.take(ar, k, j)
else:
ar = np.take(ar, np.ravel(i), j)
return ar
[docs]def calc_omega(vecs, pm):
"""calculate area"""
# border capture any infinite edges
bordered = np.concatenate((vecs, pm.bbox_vertices(pm.area**.5 * 10)))
# due to precision errors, Qhull may return fewer regions than
# vertices, the QJ options helps this...
vor = Voronoi(bordered[:, 0:2], qhull_options="Qbb Qc QJ")
# but in case of failure, this will distribute the area of the duplicate
# region evenly among the enclosed vertices by scaling by 1/# of vertices
# using each region.
if len(vor.regions) < len(vor.point_region):
r, idx, inv, cnt = np.unique(vor.point_region[:len(vecs)],
return_index=True, return_inverse=True,
return_counts=True)
scale = 1./cnt[inv]
else:
scale = 1.
omega = np.zeros(len(vecs))
for i in range(len(vecs)):
region = vor.regions[vor.point_region[i]]
p = Polygon(vor.vertices[region])
if pm.boundary.contains(p):
omega[i] = p.area
else:
omega[i] = p.intersection(pm.boundary).area
omega *= scale
return np.asarray(omega)