Source code for spharpy.transforms.rotations

"""
Rotation for data in the spherical harmonic domains.
Note: Docstring not included in the docs. See init.py for the public docstring.
"""

import numpy as np
import spharpy
from scipy.special import eval_jacobi, factorial
from pyfar import Rotation
from spharpy import (
    SphericalHarmonicSignal,
    SphericalHarmonicTimeData,
    SphericalHarmonicFrequencyData,
    SphericalHarmonicDefinition,
)
from spharpy.classes.audio import _SphericalHarmonicAudio
from typing import Union


[docs] class SphericalHarmonicRotation(Rotation): """Class for rotations of coordinates and spherical harmonic expansions. The class extends the :py:class:`scipy.spatial.transform.Rotation` class and provides methods to express the rotation as spherical harmonic rotation matrices as well as to apply the rotation to spherical harmonic data objects. The spherical harmonic rotation matrices are computed based on the Wigner-D functions as described in [#]_ and [#]_ for complex and real-valued spherical harmonics, respectively. To create a rotation object, use one of the available :py:meth:`~scipy.spatial.transform.Rotation.from_*` methods, e.g., :py:meth:`scipy.spatial.transform.Rotation.from_euler` or :py:meth:`scipy.spatial.transform.Rotation.from_quat` for creation using Euler angles or quaternions, respectively. Also see the examples below. Examples -------- The following examples demonstrate how to create a rotation and apply it to spherical harmonic coefficients and corresponding spherical harmonic data containers using the implemented methods and operators. .. plot:: :context: close-figs >>> import numpy as np >>> import matplotlib.pyplot as plt >>> import spharpy Define the convention and order of the spherical harmonics and a set of coefficients. Note that the generated coefficients correspond to a dipole oriented along the y-axis. >>> definition = spharpy.SphericalHarmonicDefinition(n_max=1) >>> coefficients = np.array([0, 1, 0, 0]) Define a rotation by 45 degrees around the z-axis based on the Euler angles. >>> angle = np.pi / 4 >>> R = spharpy.transforms.SphericalHarmonicRotation.from_euler( >>> 'z', angle) The spherical harmonic rotation matrix can be obtained and applied to the spherical harmonic coefficients using the following: >>> D = R.as_spherical_harmonic_matrix(definition) >>> rotated_coefficients = D @ coefficients >>> print(f"Rotated coefficients: {np.round(rotated_coefficients, 2)}") ... # Rotated coefficients: [ 0. 0.71 0. -0.71] To visualize the effect of the rotation, we can expand the series expansion on the unit sphere and plot the original and rotated data: >>> sampling = spharpy.samplings.equal_area(0, n_points=250) >>> Y = spharpy.SphericalHarmonics.from_definition( >>> definition, coordinates=sampling) ... >>> _, axs = plt.subplots( >>> 1, 2, subplot_kw={'projection': '3d'}, figsize=(5, 2.5), >>> layout='constrained') >>> spharpy.plot.balloon_wireframe( >>> sampling, Y.basis @ coefficients, ax=axs[0], colorbar=False) >>> spharpy.plot.balloon_wireframe( >>> sampling, Y.basis @ rotated_coefficients, ax=axs[1], >>> colorbar=False) .. plot:: :context: close-figs The rotation can also be applied to spherical harmonic data objects using the overloaded multiplication operator. This includes the class object itself as well as :class:`~spharpy.SphericalHarmonicFrequencyData`, :class:`~spharpy.SphericalHarmonicTimeData`, and :class:`~spharpy.SphericalHarmonicSignal`. The following example demonstrates the application to an arbitrary :class:`~spharpy.SphericalHarmonicFrequencyData` object containing the same series expansion in `frequency_data.freq` as above: >>> frequency_data = spharpy.SphericalHarmonicFrequencyData( >>> np.atleast_2d(coefficients).T, frequencies=1e3, >>> basis_type=definition.basis_type, >>> normalization=definition.normalization, >>> channel_convention=definition.channel_convention, >>> condon_shortley=definition.condon_shortley) The rotation can now be applied using the `apply` method >>> rotated_frequency_data = R.apply(frequency_data) or the `*` operator >>> rotated_frequency_data = R * frequency_data The effect of the rotation can again be visualized by evaluating the series expansion on the unit sphere: >>> _, axs = plt.subplots( >>> 1, 2, subplot_kw={'projection': '3d'}, figsize=(5, 2.5), >>> layout='constrained') >>> spharpy.plot.balloon_wireframe( >>> sampling, np.squeeze(Y.basis @ frequency_data.freq), >>> ax=axs[0], colorbar=False) >>> spharpy.plot.balloon_wireframe( >>> sampling, np.squeeze(Y.basis @ rotated_frequency_data.freq), >>> ax=axs[1], colorbar=False) .. plot:: :context: close-figs Multiple rotations can be combined by multiplying the respective :class:`~spharpy.transforms.SphericalHarmonicRotation` objects. >>> rotated_frequency_data = R * R * frequency_data This corresponds to a rotation of 90 degrees around the z-axis. >>> _, axs = plt.subplots( >>> 1, 2, subplot_kw={'projection': '3d'}, figsize=(5, 2.5), >>> layout='constrained') >>> spharpy.plot.balloon_wireframe( >>> sampling, np.squeeze(Y.basis @ frequency_data.freq), >>> ax=axs[0], colorbar=False) >>> spharpy.plot.balloon_wireframe( >>> sampling, np.squeeze(Y.basis @ rotated_frequency_data.freq), >>> ax=axs[1], colorbar=False) Note ---- Currently, only spherical harmonic rotations matrices following the ``ACN`` indexing and ``N3D`` normalization are supported. References ---------- .. [#] B. Rafaely, Fundamentals of Spherical Array Processing, 2nd ed., vol. 8. Springer-Verlag GmbH Berlin Heidelberg, 2019. .. [#] M. A. Blanco, M. Flórez, and M. Bermejo, “Evaluation of the rotation matrices in the basis of real spherical harmonics,” Journal of Molecular Structure: THEOCHEM, vol. 419, no. 1-3, pp. 19-27, Dec. 1997, doi: 10.1016/S0166-1280(97)00185-1. """ def __init__( self, quat, *args, **kwargs): """Initialize. Parameters ---------- quat : array_like, shape (N, 4) or (4,) Each row is a (possibly non-unit norm) quaternion in scalar-last (x, y, z, w) format. Each quaternion will be normalized to unit norm. *args : optional Arguments are passed to :py:class:`~scipy.spatial.transform.Rotation` **kwargs : optional Keyword arguments are passed to :py:class:`~scipy.spatial.transform.Rotation` Returns ------- SphericalHarmonicRotation The rotation object. Note ---- Initializing using the constructor is not advised. Always use the respective ``from_quat`` method. """ super().__init__(quat, *args, **kwargs)
[docs] def as_spherical_harmonic_matrix( self, spherical_harmonic_definition: SphericalHarmonicDefinition, ) -> np.ndarray: """Express the rotation as spherical harmonic rotation matrices. Supports complex and real-valued spherical harmonics. Parameters ---------- spherical_harmonic_definition : SphericalHarmonicDefinition Definition of the spherical harmonics. Returns ------- np.ndarray, complex or float Stack of block-diagonal rotation matrices with individual shapes :math:`[(n_{max}+1)^2, (n_{max}+1)^2]`. Note ---- At the moment, only spherical harmonic rotations matrices following the ``ACN`` indexing and ``N3D`` normalization are supported. """ if spherical_harmonic_definition.normalization != 'N3D': raise NotImplementedError( "Only 'N3D' normalization is supported for spherical " "harmonic rotations.") if spherical_harmonic_definition.channel_convention != 'ACN': raise NotImplementedError( "Only 'ACN' channel convention is supported for spherical " "harmonic rotations.") euler_angles = np.atleast_2d(self.as_euler('zyz')) n_matrices = euler_angles.shape[0] n_sh = (spherical_harmonic_definition.n_max + 1)**2 if spherical_harmonic_definition.basis_type == 'real': dtype = float rotation_function = wigner_d_rotation_real elif spherical_harmonic_definition.basis_type == 'complex': dtype = complex rotation_function = wigner_d_rotation D = np.zeros((n_matrices, n_sh, n_sh), dtype=dtype) for idx, angles in enumerate(euler_angles): D[idx, :, :] = rotation_function( spherical_harmonic_definition.n_max, angles[0], angles[1], angles[2]) return np.squeeze(D)
[docs] def apply( self, target: Union[ SphericalHarmonicTimeData, SphericalHarmonicFrequencyData, SphericalHarmonicSignal], ) -> Union[ SphericalHarmonicTimeData, SphericalHarmonicFrequencyData, SphericalHarmonicSignal]: """Apply the rotation to spherical harmonic data object. Note that the spherical harmonic definition of the target object is used to generate a fitting spherical harmonic rotation matrix. Parameters ---------- target : SphericalHarmonicTimeData, SphericalHarmonicFrequencyData, SphericalHarmonicSignal Spherical harmonic series expansion to be rotated. Returns ------- SphericalHarmonicTimeData, SphericalHarmonicFrequencyData, SphericalHarmonicSignal The rotated signal. """ # noqa: E501 # Audio objects use ACN/N3D internally target_definition = SphericalHarmonicDefinition( n_max=target.n_max, basis_type=target.basis_type, normalization='N3D', channel_convention='ACN', ) D = self.as_spherical_harmonic_matrix(target_definition) M = np.linalg.multi_dot(list(D)) if D.ndim > 2 else D rotated_data = np.einsum('ij, ljt -> lit', M, target._data) rotated = target.copy() rotated._data = rotated_data return rotated
def __mul__(self, other): """Multiplication with rotations or application to signals.""" if isinstance(other, Rotation): result = super().__mul__(other) return SphericalHarmonicRotation.from_quat(result.as_quat()) elif isinstance(other, _SphericalHarmonicAudio): return self.apply(other) else: raise ValueError( "Multiplication is only supported for " "SphericalHarmonicRotation and SphericalHarmonic audio " "objects.")
[docs] def rotation_z_axis(n_max, angle): r"""Rotation matrix for complex spherical harmonics around the z-axis by a given angle. The rotation is performed such that positive angles result in a counter clockwise rotation of the data [#]_. .. math:: c_{nm}(\theta, \phi + \xi) = e^{-im\xi} c_{nm}(\theta, \phi) Parameters ---------- n_max : integer Spherical harmonic order angle : number Rotation angle in radians `[0, 2 \\pi]` Returns ------- array_like, complex, shape :math:`((n_{max}+1)^2, (n_{max}+1)^2)` Diagonal rotation matrix evaluated for the specified angle References ---------- .. [#] N. A. Gumerov and R. Duraiswami, “Recursions for the computation of multipole translation and rotation coefficients for the 3-d helmholtz equation,” vol. 25, no. 4, pp. 1344–1381, 2003. Examples -------- >>> import numpy as np >>> import spharpy >>> n_max = 1 >>> sh_vec = np.array([0, 1, 0, 0]) >>> rotMat = spharpy.transforms.rotation_z_axis(n_max, np.pi/2) >>> sh_vec_rotated = rotMat @ sh_vec """ acn = np.arange(0, (n_max+1)**2) m = spharpy.spherical.acn_to_nm(acn)[1] rotation_phi = np.exp(-1j*angle*m) return np.diag(rotation_phi)
[docs] def rotation_z_axis_real(n_max, angle): r"""Rotation matrix for real-valued spherical harmonics around the z-axis by a given angle. The rotation is performed such that positive angles result in a counter clockwise rotation of the data [#]_. Parameters ---------- n_max : integer Spherical harmonic order angle : number Rotation angle in radians `[0, 2 \pi]` Returns ------- array_like, float, shape :math:`((n_max+1)^2, (n_max+1)^2)` Block-diagonal Rotation matrix evaluated for the specified angle. References ---------- .. [#] M. Kronlachner, “Spatial Transformations for the Alteration of Ambisonic Recordings,” Master Thesis, University of Music and Performing Arts, Graz, 2014. Examples -------- >>> import numpy as np >>> import spharpy >>> n_max = 1 >>> sh_vec = np.array([0, 1, 0, 0]) >>> rotMat = spharpy.transforms.rotation_z_axis_real(n_max, np.pi/2) >>> sh_vec_rotated = rotMat @ sh_vec """ acn = np.arange(0, (n_max + 1) ** 2) n, m = spharpy.spherical.acn_to_nm(acn) acn_reverse_degree = n ** 2 + n - m rotation_phi = np.zeros(((n_max + 1) ** 2, (n_max + 1) ** 2)) mask = m == 0 rotation_phi[acn[mask], acn[mask]] = 1.0 mask_pos = m > 0 mask_neg = m < 0 rotation_phi[acn[mask_pos], acn[mask_pos]] = np.cos( np.abs(m[mask_pos]) * angle) rotation_phi[acn[mask_neg], acn[mask_neg]] = np.cos( np.abs(m[mask_neg]) * angle) # non diagonal rotation_phi[acn[mask_pos], acn_reverse_degree[mask_pos]] = -np.sin( np.abs(m[mask_pos]) * angle) rotation_phi[acn[mask_neg], acn_reverse_degree[mask_neg]] = np.sin( np.abs(m[mask_neg]) * angle) return rotation_phi
[docs] def wigner_d_rotation(n_max, alpha, beta, gamma): r"""Wigner-D rotation matrix for Euler rotations by angles (\alpha, \beta, \gamma) around the (z,y,z)-axes. The implementation follows [#]_. and rotation is performed such that positive angles result in a counter clockwise rotation of the data. .. math:: D_{m^\prime,m}^n(\alpha, \beta, \gamma) = e^{-im^\prime\alpha} d_{m^\prime,m}^n(\beta) e^{-im\gamma} Parameters ---------- n_max : int Spherical harmonic order alpha : float First z-axis rotation angle beta : float Y-axis rotation angle gamma : float Second z-axis rotation angle Returns ------- array_like, complex,:math:`((n_max+1)^2, (n_max+1)^2)` Block diagonal rotation matrix Examples -------- >>> import numpy as np >>> import spharpy >>> n_max = 1 >>> sh_vec = np.array([0, 0, 1, 0]) >>> rotMat = spharpy.transforms.wigner_d_rotation(n_max, 0, np.pi/4, 0) >>> sh_vec_rotated = rotMat @ sh_vec References ---------- .. [#] B. Rafaely, Fundamentals of Spherical Array Processing, 1st ed., vol. 8. Springer-Verlag GmbH Berlin Heidelberg, 2015. """ n_sh = (n_max+1)**2 R = np.zeros((n_sh, n_sh), dtype=complex) for row in np.arange(0, (n_max+1)**2): n_dash, m_dash = spharpy.spherical.acn_to_nm(row) for column in np.arange(0, (n_max+1)**2): n, m = spharpy.spherical.acn_to_nm(column) if n == n_dash: rot_alpha = np.exp(-1j*m_dash*alpha) rot_beta = wigner_d_function(n, m_dash, m, beta) rot_gamma = np.exp(-1j*m*gamma) R[row, column] = rot_alpha * rot_beta * rot_gamma return R
[docs] def wigner_d_rotation_real(n_max, alpha, beta, gamma): r"""Wigner-D rotation matrix for Euler rotations for real-valued spherical harmonics by angles (\alpha, \beta, \gamma) around the (z,y,z)-axes. The implementation follows [#]_ and the rotation is performed such that positive angles result in a counter clockwise rotation of the data. Parameters ---------- n_max : int Spherical harmonic order alpha : float First z-axis rotation angle beta : float Y-axis rotation angle gamma : float Second z-axis rotation angle Returns ------- array_like, float, :math:`((n_max+1)^2, (n_max+1)^2)` Block diagonal rotation matrix Examples -------- >>> import numpy as np >>> import spharpy >>> n_max = 1 >>> sh_vec = np.array([0, 0, 1, 0]) >>> rotMat = spharpy.transforms.wigner_d_rotation_real( >>> n_max, 0, np.pi/4, 0) >>> sh_vec_rotated = rotMat @ sh_vec References ---------- .. [#] M. A. Blanco, M. Flórez, and M. Bermejo, “Evaluation of the rotation matrices in the basis of real spherical harmonics,” Journal of Molecular Structure: THEOCHEM, vol. 419, no. 1–3, pp. 19–27, Dec. 1997, doi: 10.1016/S0166-1280(97)00185-1. """ n_sh = (n_max+1)**2 R = np.zeros((n_sh, n_sh), dtype=float) for row_acn in np.arange(0, (n_max+1)**2): for col_acn in np.arange(0, (n_max+1)**2): n, m = spharpy.spherical.acn_to_nm(col_acn) n_dash, m_dash = spharpy.spherical.acn_to_nm(row_acn) if n == n_dash: # minus beta opposite rotation direction d_l_1 = wigner_d_function(n, np.abs(m_dash), np.abs(m), -beta) d_l_2 = wigner_d_function(n, np.abs(m), -np.abs(m_dash), -beta) R[row_acn, col_acn] = \ _sign(m_dash) * _Phi(m, alpha) * _Phi(m_dash, gamma) * \ (d_l_1 + (-1)**int(m) * d_l_2)/2 \ - _sign(m) * _Phi(-m, alpha) * _Phi(-m_dash, gamma) * \ (d_l_1 - (-1)**int(m) * d_l_2)/2 return R
def _sign(x): """ Returns sign of x, differs from numpy definition for x=0. """ if x < 0: sign = -1 else: sign = 1 return sign def _Phi(m, angle): """ Rotation Matrix around z-axis for real Spherical Harmonics as defined in Blanco et al., Evaluation of the rotation matrices in the basis of real spherical harmonics, eq.(8). """ if m > 0: phi = np.sqrt(2)*np.cos(m*angle) elif m == 0: phi = 1 elif m < 0: # minus due to differing phase convention phi = -np.sqrt(2)*np.sin(np.abs(m)*angle) return phi
[docs] def wigner_d_function(n, m_dash, m, beta): r"""Wigner-d function for rotations around the y-axis. Convention as defined in [#]_. Parameters ---------- n : int order m_dash : int degree m : int degree beta : float Rotation angle Returns ------- float Wigner-d symbol References ---------- .. [#] B. Rafaely, Fundamentals of Spherical Array Processing, 1st ed., vol. 8. Springer-Verlag GmbH Berlin Heidelberg, 2015. """ if m >= m_dash: sign = 1 else: sign = (-1)**int(m-m_dash) mu = np.abs(m_dash - m) nu = np.abs(m_dash + m) s = n - (mu + nu)/2 norm = (factorial(s)*factorial(s+mu+nu))/(factorial(s+mu)*factorial(s+nu)) P = eval_jacobi(s, mu, nu, np.cos(beta)) d = sign * np.sqrt(norm) * np.sin(beta/2)**mu * np.cos(beta/2)**nu * P return d