woodfireind's picture
HOA7 Spatial Field Decoder (hoa64 v0.5.0): 7th-order Ambisonics encode/decode, Wigner-D rotation, DOA analysis, vision fuse, diffusion conditioning
570b87b verified
Raw
History Blame Contribute Delete
8.75 kB
"""Fast HOA rotation via Wigner-D matrices (real Ambix ACN / SN3D).
Within each order n the (2n+1) coefficients transform by a real matrix built
from complex Wigner D functions. Normalization (SN3D vs N3D) cancels inside
an order, so the same matrices apply to Ambix SN3D.
Public:
rotation_matrix_zyx — 3×3 active rotation (yaw/pitch/roll)
hoa_rotation_matrix — full ((N+1)²)×((N+1)²) block-diagonal matrix
apply_hoa_rotation — a' = M @ a (also (C,T) streams)
"""
from __future__ import annotations
import math
from functools import lru_cache
from typing import Tuple
import numpy as np
from .basis import MAX_ORDER, N_CHANNELS, acn_index
def rotation_matrix_zyx(
yaw: float, pitch: float, roll: float, *, degrees: bool = True
) -> np.ndarray:
"""R = Rz(yaw) @ Ry(pitch) @ Rx(roll); active rotation of column vectors."""
if degrees:
yaw, pitch, roll = map(math.radians, (yaw, pitch, roll))
cy, sy = math.cos(yaw), math.sin(yaw)
cp, sp = math.cos(pitch), math.sin(pitch)
cr, sr = math.cos(roll), math.sin(roll)
Rz = np.array([[cy, -sy, 0.0], [sy, cy, 0.0], [0.0, 0.0, 1.0]])
Ry = np.array([[cp, 0.0, sp], [0.0, 1.0, 0.0], [-sp, 0.0, cp]])
Rx = np.array([[1.0, 0.0, 0.0], [0.0, cr, -sr], [0.0, sr, cr]])
return Rz @ Ry @ Rx
def _fact(n: int) -> float:
if n < 0:
return 0.0
return float(math.factorial(n))
@lru_cache(maxsize=8192)
def wigner_d(j: int, mp: int, m: int, beta: float) -> float:
"""Wigner small-d matrix element d^j_{mp,m}(beta), real.
Explicit finite sum (safe for j ≤ 7 used here).
"""
if abs(mp) > j or abs(m) > j:
return 0.0
# Numerical stability: beta in [0, pi] preferred but general ok
cb = math.cos(beta * 0.5)
sb = math.sin(beta * 0.5)
# Avoid 0**negative
s_min = max(0, m - mp)
s_max = min(j + m, j - mp)
total = 0.0
for s in range(s_min, s_max + 1):
den = (
_fact(j + m - s)
* _fact(j - mp - s)
* _fact(s)
* _fact(s + mp - m)
)
if den == 0.0:
continue
num = _fact(j + m) * _fact(j - m) * _fact(j + mp) * _fact(j - mp)
pref = ((-1.0) ** (mp - m + s)) * math.sqrt(num) / den
cpow = 2 * j + m - mp - 2 * s
spow = mp - m + 2 * s
# handle base cases
cterm = 1.0 if cpow == 0 else (cb ** cpow if abs(cb) > 1e-15 or cpow > 0 else 0.0)
sterm = 1.0 if spow == 0 else (sb ** spow if abs(sb) > 1e-15 or spow > 0 else 0.0)
if cpow < 0 and abs(cb) < 1e-15:
cterm = 0.0
if spow < 0 and abs(sb) < 1e-15:
sterm = 0.0
total += pref * cterm * sterm
return total
def wigner_D_complex(
j: int, alpha: float, beta: float, gamma: float
) -> np.ndarray:
"""Complex Wigner-D matrix for order j, indices m',m ∈ [-j..j].
Ordering: row/col index = m + j (m from -j to +j).
D_{mp,m} = e^{-i mp α} d_{mp,m}(β) e^{-i m γ}
"""
dim = 2 * j + 1
D = np.zeros((dim, dim), dtype=np.complex128)
for imp, mp in enumerate(range(-j, j + 1)):
for im, m in enumerate(range(-j, j + 1)):
d = wigner_d(j, mp, m, beta)
D[imp, im] = (
math.cos(mp * alpha)
- 1j * math.sin(mp * alpha)
) * d * (
math.cos(m * gamma) - 1j * math.sin(m * gamma)
)
# e^{-i θ} = cosθ - i sinθ
return D
def _real_to_complex_matrix(j: int) -> np.ndarray:
"""Unitary map U: real ACN coeffs (m=-j..j) → complex m=-j..j.
Convention (common in Ambisonics / real SH):
c_0 = r_0
c_{+m} = (-1)^m / √2 * (r_{+m} - i r_{-m})
c_{-m} = 1 / √2 * (r_{+m} + i r_{-m})
so r = U^H c and c = U r with U unitary.
"""
dim = 2 * j + 1
U = np.zeros((dim, dim), dtype=np.complex128)
# index helper: m -> i = m + j
def idx(m: int) -> int:
return m + j
U[idx(0), idx(0)] = 1.0 + 0.0j
s2 = 1.0 / math.sqrt(2.0)
for m in range(1, j + 1):
sign = (-1.0) ** m
# c_{+m} from r_{+m}, r_{-m}
U[idx(m), idx(m)] = sign * s2
U[idx(m), idx(-m)] = -1j * sign * s2
# c_{-m} from r_{+m}, r_{-m}
U[idx(-m), idx(m)] = s2
U[idx(-m), idx(-m)] = 1j * s2
return U
def real_sh_rotation_block(
j: int, alpha: float, beta: float, gamma: float
) -> np.ndarray:
"""Real (2j+1)×(2j+1) rotation matrix for ACN order-j block (m=-j..j)."""
if j == 0:
return np.array([[1.0]], dtype=np.float64)
U = _real_to_complex_matrix(j)
D = wigner_D_complex(j, alpha, beta, gamma)
# real coeffs: r' = U^H D U r
R_c = U.conj().T @ D @ U
# Should be real symmetric orthogonal (numerically tiny imag)
return np.real(R_c)
def rotation_matrix_to_zyz(R: np.ndarray) -> Tuple[float, float, float]:
"""Extract ZYZ Euler angles (α, β, γ) from a right-handed rotation matrix.
R = Rz(α) @ Ry(β) @ Rz(γ) (active, column vectors).
"""
R = np.asarray(R, dtype=np.float64)
# β ∈ [0, π]
# R[2,2] = cos β
cbeta = float(np.clip(R[2, 2], -1.0, 1.0))
beta = math.acos(cbeta)
sb = math.sin(beta)
if abs(sb) > 1e-10:
# α = atan2(R[1,2]/sinβ, R[0,2]/sinβ)
alpha = math.atan2(R[1, 2] / sb, R[0, 2] / sb)
# γ = atan2(R[2,1]/sinβ, -R[2,0]/sinβ)
gamma = math.atan2(R[2, 1] / sb, -R[2, 0] / sb)
else:
# Gimbal: β ≈ 0 or π — only α+γ or α-γ determined
alpha = math.atan2(-R[0, 1], R[0, 0])
gamma = 0.0
if cbeta < 0:
# β = π
alpha = math.atan2(R[0, 1], -R[0, 0])
return alpha, beta, gamma
def hoa_rotation_matrix(
R3: np.ndarray,
*,
max_order: int = MAX_ORDER,
) -> np.ndarray:
"""Full Ambix ACN rotation matrix for orders 0..max_order.
Applies the *same* geometric rotation as R3 does to Cartesian vectors.
"""
max_order = int(max_order)
nch = (max_order + 1) ** 2
M = np.zeros((nch, nch), dtype=np.float64)
alpha, beta, gamma = rotation_matrix_to_zyz(R3)
# Order 0
M[0, 0] = 1.0
# Order 1: direct Cartesian for numerical exactness / convention lock.
# ACN: [Y, Z, X] = indices 1,2,3 ; cart = [X,Y,Z]
# v' = R3 @ v ⇒ [X',Y',Z'] = R3 @ [X,Y,Z]
if max_order >= 1:
# Build 3×3 block mapping [Y,Z,X] -> [Y',Z',X']
# [X'] [R00 R01 R02] [X]
# [Y'] = [R10 R11 R12] [Y]
# [Z'] [R20 R21 R22] [Z]
# Y' = R10 X + R11 Y + R12 Z
# Z' = R20 X + R21 Y + R22 Z
# X' = R00 X + R01 Y + R02 Z
# Coefficients of (Y,Z,X):
# Y' = R11 Y + R12 Z + R10 X
# Z' = R21 Y + R22 Z + R20 X
# X' = R01 Y + R02 Z + R00 X
B = np.array(
[
[R3[1, 1], R3[1, 2], R3[1, 0]],
[R3[2, 1], R3[2, 2], R3[2, 0]],
[R3[0, 1], R3[0, 2], R3[0, 0]],
],
dtype=np.float64,
)
M[1:4, 1:4] = B
for n in range(2, max_order + 1):
block = real_sh_rotation_block(n, alpha, beta, gamma)
# Verify det ~ 1; if complex conversion convention is flipped we may
# need transpose — tests catch this against plane-wave re-encode.
i0 = n * n # ACN start for order n is n^2 (m=-n → n*(n+1)+(-n)=n^2)
# Wait: n*(n+1)+(-n) = n^2 + n - n = n^2. Yes.
dim = 2 * n + 1
M[i0 : i0 + dim, i0 : i0 + dim] = block
return M
def apply_hoa_rotation(
hoa: np.ndarray,
M: np.ndarray,
) -> np.ndarray:
"""Apply precomputed rotation matrix to (C,) or (C,T) coefficients."""
a = np.asarray(hoa, dtype=np.float64)
nch = M.shape[0]
if a.ndim == 1:
aa = np.zeros(nch, dtype=np.float64)
n = min(nch, a.shape[0])
aa[:n] = a[:n]
out = M @ aa
if a.shape[0] > nch:
full = np.zeros_like(a)
full[:nch] = out
return full
if a.shape[0] < N_CHANNELS:
full = np.zeros(N_CHANNELS, dtype=np.float64)
full[:nch] = out
return full
return out
if a.ndim == 2:
aa = np.zeros((nch, a.shape[1]), dtype=np.float64)
n = min(nch, a.shape[0])
aa[:n] = a[:n]
out = M @ aa
if a.shape[0] >= N_CHANNELS:
full = np.zeros((max(a.shape[0], N_CHANNELS), a.shape[1]), dtype=np.float64)
full[:nch] = out
return full[: a.shape[0]]
full = np.zeros((N_CHANNELS, a.shape[1]), dtype=np.float64)
full[:nch] = out
return full
raise ValueError("hoa must be (C,) or (C,T)")
def clear_wigner_cache() -> None:
wigner_d.cache_clear()