import math
import numpy
import scipy
import scipy.special
from scipy.stats import chi2
[docs]
def fitVonMisesFisher(orientations, confVMF=None, confMu=None, confKappa=None):
"""
This function fits a vonMises-Fisher distribution to a set of N 3D unit vectors.
The distribution is characterised by a mean orientation mu and a spread parameter kappa.
Parameters
-----------
orientations : Nx3 array of floats
Z, Y and X components of each vector.
confVMF : float
Confidence interval for the test on the vMF distribution
Used for checking wheter the data can be modeled with a vMF distribution
Default = 95%
confMu : float
Confidence interval for the test on the mean orientation mu
Used for computing the error on the mean orientation
Default = 95%
confKappa : float
Confidence interval for the test on kappa
Used for computing the error on kappa
Default = 95%
Returns
--------
Dictionary containing:
Keys:
orientations : Nx3 array of floats
Z, Y and X components of each vector that is located in the same quadrant as the mean orientation
mu : 1x3 array of floats
Z, Y and X components of mean orientation.
theta : float
Inclination angle of the mean orientation in degrees - angle with the Z axis
alpha : float
Azimuth angle of the mean orientation in degrees - angle in the X-Y plane
R : float
Mean resultant length
First order measure of concentration (ranging between 0 and 1)
kappa : int
Spread of the distribution, must be > 0.
Higher values of kappa mean a higher concentration along the main orientation
vectorsProj : Nx3 array of floats
Z, Y and X components of each vector projected along the mean orientation
fisherTest : bool
Boolean representing the result of the test on the vMF distribution
1 = The data can be modeled with a vMF distribution
0 = The data cannot be modeled with a vMF distribution
fisherTestVal : float
Value to be compared against the critical value, taken from a Chi-squared distrition
muTest : float
Error associated to the mean orientation
Defined as the semi-vertical angle of the cone that comprises the distribution
kappaTest : 1x2 list of floats
Maximum and minimum value of kappa, given the confidence interval
Notes
-----
The calculation of kappa implemented from Tanabe, A., et al., (2007). Parameter estimation for von Mises_Fisher distributions. doi: 10.1007/s00180-007-0030-7
"""
# Check that the vectors are 3D
assert orientations.shape[1] == 3, "\n spam.orientations.fitVonMisesFisher: The vectors must be an array of Nx3"
# If needed, assign confidence intervals
if confVMF is None:
confVMF = 0.95
if confMu is None:
confMu = 0.95
if confKappa is None:
confKappa = 0.95
# Check the values of the confidence intervals
assert confVMF > 0 and confVMF < 1, "\n spam.orientations.fitVonMisesFisher: The confidence interval for confVMF should be between 0 and 1"
assert confMu > 0 and confMu < 1, "\n spam.orientations.fitVonMisesFisher: The confidence interval for confMu should be between 0 and 1"
assert confKappa > 0 and confKappa < 1, "\n spam.orientations.fitVonMisesFisher: The confidence interval for confKappa should be between 0 and 1"
# Create result dictionary
res = {}
# Remove possible vectors [0, 0, 0]
orientations = orientations[numpy.where(numpy.sum(orientations, axis=1) != 0)[0]]
# Normalize all the vectors from http://stackoverflow.com/questions/2850743/numpy-how-to-quickly-normalize-many-vectors
norms = numpy.apply_along_axis(numpy.linalg.norm, 1, orientations)
orientations = orientations / norms.reshape(-1, 1)
# Read Number of Points
numberOfPoints = orientations.shape[0]
# 1. Get raw-orientation with SVD and flip accordingly
vectSVD = meanOrientation(orientations)
# Flip accordingly to main direction
for i in range(numberOfPoints):
vect = orientations[i, :]
# Compute angle between both vectors
delta1 = numpy.degrees(numpy.arccos((numpy.dot(vectSVD, vect)) / (numpy.linalg.norm(vectSVD) * numpy.linalg.norm(vect))))
delta2 = numpy.degrees(numpy.arccos((numpy.dot(vectSVD, -1 * vect)) / (numpy.linalg.norm(vectSVD) * numpy.linalg.norm(-1 * vect))))
if delta1 < delta2:
orientations[i, :] = vect
else:
orientations[i, :] = -1 * vect
res.update({"orientations": orientations})
# 2. Compute parameters of vMF
# Compute mean orientation
mu = numpy.sum(orientations, axis=0) / numpy.linalg.norm(numpy.sum(orientations, axis=0))
res.update({"mu": mu})
# Decompose mean orientation into polar coordinates
thetaR = numpy.arccos(mu[0])
alphaR = numpy.arctan2(mu[1], mu[2])
if alphaR < 0:
alphaR = alphaR + 2 * numpy.pi
res.update({"theta": numpy.degrees(thetaR)})
res.update({"alpha": numpy.degrees(alphaR)})
# Compute mean resultant length
R = numpy.linalg.norm(numpy.sum(orientations, axis=0)) / numberOfPoints
res.update({"R": R})
# Compute rotation matrix - needed for projecting all vector around mu
# Taken from pg 194 from MardiaJupp - Fisher book eq. 3.9 is wrong!
rotMatrix = numpy.array(
[
[
numpy.cos(thetaR),
numpy.sin(thetaR) * numpy.sin(alphaR),
numpy.sin(thetaR) * numpy.cos(alphaR),
],
[0, numpy.cos(alphaR), -1 * numpy.sin(alphaR)],
[
-1 * numpy.sin(thetaR),
numpy.cos(thetaR) * numpy.sin(alphaR),
numpy.cos(thetaR) * numpy.cos(alphaR),
],
]
)
# Project vectors - needed for computing kappa
orientationsProj = numpy.zeros((numberOfPoints, 3))
for i in range(numberOfPoints):
orientationsProj[i, :] = rotMatrix.dot(orientations[i, :])
if orientationsProj[i, 0] < 0:
orientationsProj[i, :] = -1 * orientationsProj[i, :]
res.update({"vectorsProj": orientationsProj})
# Compute Kappa
Z_bar = numpy.sum(orientationsProj[:, 0]) / len(orientationsProj)
Y_bar = numpy.sum(orientationsProj[:, 1]) / len(orientationsProj)
X_bar = numpy.sum(orientationsProj[:, 2]) / len(orientationsProj)
R = numpy.sqrt(Z_bar**2 + Y_bar**2 + X_bar**2)
# First Kappa guess
k_t = R * (3 - 1) / (1 - R**2)
error_i = 5
# Main Iteration
while error_i > 0.001: # t is step i, T is step i+1
I_1 = scipy.special.iv(3 / 2 - 1, k_t)
I_2 = scipy.special.iv(3 / 2, k_t)
k_T = R * k_t * (I_1 / I_2)
error_i = 100 * (numpy.abs(k_T - k_t) / k_t)
k_t = k_T.copy()
# Add results
res.update({"kappa": k_t})
# 3. Tests
# Test for vMF distribution - Can we really model the data with a vMF distribution?
valCritic = scipy.stats.chi2.ppf(1 - confVMF, 3)
test = 3 * (R**2) / numberOfPoints
if test < valCritic:
fisherFit = True
else:
fisherFit = False
res.update({"fisherTest": fisherFit})
res.update({"fisherTestVal": test})
# Test the location of mu - compute the semi-vertical angle of the cone
d = 0
for vect in orientations:
d += (numpy.sum(vect * mu)) ** 2
d = 1 - (1 / numberOfPoints) * d
sigma = numpy.sqrt(d / (numberOfPoints * R**2))
angle = numpy.degrees(numpy.arcsin(numpy.sqrt(-1 * numpy.log(1 - confMu)) * sigma))
res.update({"muTest": angle})
# Test the value of Kappa - compute interval for Kappa - eq. 5.37 Fisher
kappaDown = 0.5 * chi2.ppf(0.5 * (1 - confKappa), 2 * numberOfPoints - 2) / (numberOfPoints - numberOfPoints * R)
kappaUp = 0.5 * chi2.ppf(1 - 0.5 * (1 - confKappa), 2 * numberOfPoints - 2) / (numberOfPoints - numberOfPoints * R)
res.update({"kappaTest": [kappaDown, kappaUp]})
return res
[docs]
def meanOrientation(orientations):
"""
This function performs a Singular Value Decomposition (SVD) on a series of 3D unit vectors to find the main direction of the set
Parameters
-----------
orientations : Nx3 numpy array of floats
Z, Y and X components of direction vectors.
Non-unit vectors are normalised.
Returns
--------
orientationVector : 1x3 numpy arrayRI*numpy.cos(thetaI) of floats
Z, Y and X components of direction vector.
Notes
-----
Implementation taken from https://www.ltu.se/cms_fs/1.51590!/svd-fitting.pdf
"""
# Read Number of Points
orientations.shape[0]
# Remove possible vectors [0, 0, 0]
orientations = orientations[numpy.where(numpy.sum(orientations, axis=1) != 0)[0]]
# Normalise all the vectors from http://stackoverflow.com/questions/2850743/numpy-how-to-quickly-normalize-many-vectors
norms = numpy.apply_along_axis(numpy.linalg.norm, 1, orientations)
orientations = orientations / norms.reshape(-1, 1)
# Include the negative part
orientationsSVD = numpy.concatenate((orientations, -1 * orientations), axis=0)
# Compute the centre
meanVal = numpy.mean(orientationsSVD, axis=0)
# Center array
orientationsCenteredSVD = orientationsSVD - meanVal
# Run SVD
svd = numpy.linalg.svd(orientationsCenteredSVD.T, full_matrices=False)
# Principal direction
orientationVector = svd[0][:, 0]
# Flip (if needed) to have a positive Z value
if orientationVector[0] < 0:
orientationVector = -1 * orientationVector
return orientationVector
[docs]
def fabricTensor(orientations):
"""
Calculation of a second order fabric tensor from 3D unit vectors representing orientations
Parameters
----------
orientations: Nx3 array of floats
Z, Y and X components of direction vectors
Non-unit vectors are normalised.
Returns
-------
N: 3x3 array of floats
normalised second order fabric tensor
with N[0,0] corresponding to z-z, N[1,1] to y-y and N[2,2] x-x
F: 3x3 array of floats
fabric tensor of the third kind (deviatoric part)
with F[0,0] corresponding to z-z, F[1,1] to y-y and F[2,2] x-x
a: float
scalar anisotropy factor based on the deviatoric part F
Note
----
see [Kanatani, 1984] for more information on the fabric tensor
and [Gu et al, 2017] for the scalar anisotropy factor
Function contibuted by Max Wiebicke (Dresden University)
"""
# from http://stackoverflow.com/questions/2850743/numpy-how-to-quickly-normalize-many-vectors
norms = numpy.apply_along_axis(numpy.linalg.norm, 1, orientations)
orientations = orientations / norms.reshape(-1, 1)
# create an empty array
N = numpy.zeros((3, 3))
size = len(orientations)
for i in range(size):
orientation = orientations[i]
tensProd = numpy.outer(orientation, orientation)
N[:, :] = N[:, :] + tensProd
# fabric tensor of the first kind
N = N / size
# fabric tensor of third kind
F = (N - (numpy.trace(N) * (1.0 / 3.0)) * numpy.eye(3, 3)) * (15.0 / 2.0)
# scalar anisotropy factor
a = math.sqrt(3.0 / 2.0 * numpy.tensordot(F, F, axes=2))
return N, F, a
[docs]
def projectOrientation(vector, coordSystem, projectionSystem):
"""
This functions projects a 3D vector from a given coordinate system into a 2D plane given by a defined projection.
Parameters
----------
vector: 1x3 array of floats
Vector to be projected
For cartesian system: ZYX
For spherical system: r, tetha (inclination), phi (azimuth) in Radians
coordSystem: string
Coordinate system of the vector
Either "cartesian" or "spherical"
projectionSystem : string
Projection to be used
Either "lambert", "stereo" or "equidistant"
Returns
-------
projection_xy: 1x2 array of floats
X and Y coordinates of the projected vector
projection_theta_r: 1x2 array of floats
Theta and R coordinates of the projected vector in radians
"""
projection_xy_local = numpy.zeros(2)
projection_theta_r_local = numpy.zeros(2)
# Reshape the vector and check for errors in shape
try:
vector = numpy.reshape(vector, (3, 1))
except Exception:
print("\n spam.orientations.projectOrientation: The vector must be an array of 1x3")
return
if coordSystem == "spherical":
# unpack vector
r, theta, phi = vector
x = r * math.sin(theta) * math.cos(phi)
y = r * math.sin(theta) * math.sin(phi)
z = r * math.cos(theta)
elif coordSystem == "cartesian":
# unpack vector
z, y, x = vector
# we're in cartesian coordinates, (x-y-z mode) Calculate spherical coordinates
# passing to 3d spherical coordinates too...
# From: https://en.wikipedia.org/wiki/Spherical_coordinate_system
# Several different conventions exist for representing the three coordinates, and for the order in which they should be written.
# The use of (r, θ, φ) to denote radial distance, inclination (or elevation), and azimuth, respectively,
# is common practice in physics, and is specified by ISO standard 80000-2 :2009, and earlier in ISO 31-11 (1992).
r = numpy.sqrt(x**2 + y**2 + z**2)
theta = math.acos(z / r) # inclination
phi = math.atan2(y, x) # azimuth
else:
print("\n spam.orientations.projectOrientation: Wrong coordinate system")
return
if projectionSystem == "lambert": # dividing by sqrt(2) so that we're projecting onto a unit circle
projection_xy_local[0] = x * (math.sqrt(2 / (1 + z)))
projection_xy_local[1] = y * (math.sqrt(2 / (1 + z)))
# sperhical coordinates -- CAREFUL as per this wikipedia page: https://en.wikipedia.org/wiki/Lambert_azimuthal_equal-area_projection
# the symbols for inclination and azimuth ARE INVERTED WITH RESPEST TO THE SPHERICAL COORDS!!!
projection_theta_r_local[0] = phi
# HACK: doing math.pi - angle in order for the +z to be projected to 0,0
projection_theta_r_local[1] = 2 * math.cos((math.pi - theta) / 2)
# cylindrical coordinates
# projection_theta_r_local[0] = phi
# projection_theta_r_local[1] = math.sqrt( 2.0 * ( 1 + z ) )
elif projectionSystem == "stereo":
projection_xy_local[0] = x / (1 - z)
projection_xy_local[1] = y / (1 - z)
# https://en.wikipedia.org/wiki/Stereographic_projection uses a different standard from the page on spherical coord Spherical_coordinate_system
projection_theta_r_local[0] = phi
# HACK: doing math.pi - angle in order for the +z to be projected to 0,0
# HACK: doing math.pi - angle in order for the +z to be projected to 0,0
projection_theta_r_local[1] = numpy.sin(math.pi - theta) / (1 - numpy.cos(math.pi - theta))
elif projectionSystem == "equidistant":
# https://en.wikipedia.org/wiki/Azimuthal_equidistant_projection
# TODO: To be checked, but this looks like it should -- a straight down projection.
projection_xy_local[0] = math.sin(phi)
projection_xy_local[1] = math.cos(phi)
projection_theta_r_local[0] = phi
projection_theta_r_local[1] = numpy.cos(theta - math.pi / 2)
else:
print("\n spam.orientations.projectOrientation: Wrong projection system")
return
return projection_xy_local, projection_theta_r_local