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
6.88 kB
"""Ambix ACN + SN3D real spherical harmonics up to order 7.
Conventions (Angelo Farina / ISO-style Ambix):
- Cartesian: +X front, +Y left, +Z up (ISO 2631-style).
- Spherical: azimuth a in XY from +X toward +Y (0=front, +90=left);
elevation e from horizontal (+90=zenith, -90=nadir).
- Channel order: ACN n*(n+1)+m for m = -n .. +n
- Normalization: SN3D (Ambix). Order-0 channel W = 1.
Evaluation uses associated Legendre functions (no Condon–Shortley phase)
with Schmidt/SN3D scaling, matching Farina's explicit Ambix formulas for
orders 0–5 and 7. Order 6 is generated by the same recurrence (Farina's
Cartesian transcription for n=6 had inconsistent norms).
"""
from __future__ import annotations
import math
from typing import Dict, Tuple
import numpy as np
MAX_ORDER = 7
N_CHANNELS = (MAX_ORDER + 1) ** 2 # 64
def acn_index(n: int, m: int) -> int:
"""Ambix ACN index for degree n and order m ∈ [-n, n]."""
if n < 0 or abs(m) > n:
raise ValueError(f"invalid (n,m)=({n},{m})")
return n * (n + 1) + m
def acn_nm(acn: int) -> Tuple[int, int]:
"""Inverse of acn_index."""
if acn < 0:
raise ValueError(acn)
n = int(math.floor(math.sqrt(acn)))
m = acn - n * (n + 1)
return n, m
_NAME_N3 = {
(0, 0): "W",
(1, -1): "Y",
(1, 0): "Z",
(1, 1): "X",
(2, -2): "V",
(2, -1): "T",
(2, 0): "R",
(2, 1): "S",
(2, 2): "U",
(3, -3): "Q",
(3, -2): "O",
(3, -1): "M",
(3, 0): "K",
(3, 1): "L",
(3, 2): "N",
(3, 3): "P",
}
def channel_names(max_order: int = MAX_ORDER) -> list[str]:
names: list[str] = []
for n in range(max_order + 1):
for m in range(-n, n + 1):
names.append(_NAME_N3.get((n, m), f"Y{n}_{m:+d}"))
return names
def unit_vector(
azimuth: float | np.ndarray,
elevation: float | np.ndarray,
degrees: bool = True,
) -> np.ndarray:
"""Direction → unit vector (x, y, z). Broadcasts over arrays. Shape (..., 3)."""
a = np.asarray(azimuth, dtype=np.float64)
e = np.asarray(elevation, dtype=np.float64)
if degrees:
a = np.deg2rad(a)
e = np.deg2rad(e)
ce = np.cos(e)
x = np.cos(a) * ce
y = np.sin(a) * ce
z = np.sin(e)
return np.stack([x, y, z], axis=-1)
def az_el_from_unit(vec: np.ndarray, degrees: bool = True) -> Tuple[np.ndarray, np.ndarray]:
"""Unit vector(s) → azimuth, elevation."""
v = np.asarray(vec, dtype=np.float64)
x, y, z = v[..., 0], v[..., 1], v[..., 2]
z = np.clip(z, -1.0, 1.0)
el = np.arcsin(z)
az = np.arctan2(y, x)
if degrees:
return np.rad2deg(az), np.rad2deg(el)
return az, el
def _associated_legendre_no_cs(n_max: int, z: float) -> Dict[Tuple[int, int], float]:
"""P_n^m(z) without Condon–Shortley phase, 0 ≤ m ≤ n ≤ n_max."""
z = float(np.clip(z, -1.0, 1.0))
st = math.sqrt(max(0.0, 1.0 - z * z))
P: Dict[Tuple[int, int], float] = {(0, 0): 1.0}
if n_max >= 1:
P[(1, 0)] = z
P[(1, 1)] = st
for n in range(2, n_max + 1):
for m in range(0, n + 1):
if m == n:
P[(n, n)] = (2 * n - 1) * st * P[(n - 1, n - 1)]
elif m == n - 1:
P[(n, n - 1)] = (2 * n - 1) * z * P[(n - 1, n - 1)]
else:
P[(n, m)] = (
(2 * n - 1) * z * P[(n - 1, m)] - (n + m - 1) * P[(n - 2, m)]
) / (n - m)
return P
def _sn3d_one(x: float, y: float, z: float, max_order: int) -> np.ndarray:
"""SN3D ACN vector at one unit direction."""
r = math.sqrt(x * x + y * y + z * z)
if r == 0.0:
r = 1.0
x, y, z = x / r, y / r, z / r
az = math.atan2(y, x)
P = _associated_legendre_no_cs(max_order, z)
nch = (max_order + 1) ** 2
out = np.empty(nch, dtype=np.float64)
k = 0
for n in range(max_order + 1):
for m in range(-n, n + 1):
if m == 0:
val = P[(n, 0)]
else:
am = abs(m)
norm = math.sqrt(2.0 * math.factorial(n - am) / math.factorial(n + am))
if m > 0:
val = norm * P[(n, am)] * math.cos(am * az)
else:
val = norm * P[(n, am)] * math.sin(am * az)
out[k] = val
k += 1
return out
def _eval_sn3d_cartesian(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray:
"""Evaluate all 64 SN3D ACN channels at unit direction(s). Output (..., 64)."""
x = np.asarray(x, dtype=np.float64)
y = np.asarray(y, dtype=np.float64)
z = np.asarray(z, dtype=np.float64)
shape = np.broadcast(x, y, z).shape
x, y, z = np.broadcast_arrays(x, y, z)
flat = x.size
out = np.empty((flat, N_CHANNELS), dtype=np.float64)
xf, yf, zf = x.reshape(-1), y.reshape(-1), z.reshape(-1)
for i in range(flat):
out[i] = _sn3d_one(float(xf[i]), float(yf[i]), float(zf[i]), MAX_ORDER)
return out.reshape(shape + (N_CHANNELS,))
def sh_sn3d(
azimuth: float | np.ndarray,
elevation: float | np.ndarray,
degrees: bool = True,
max_order: int = MAX_ORDER,
) -> np.ndarray:
"""Spherical harmonics Y (Ambix SN3D) at direction(s). Shape (..., n_channels)."""
if max_order < 0 or max_order > MAX_ORDER:
raise ValueError(f"max_order must be in 0..{MAX_ORDER}")
uv = unit_vector(azimuth, elevation, degrees=degrees)
y = _eval_sn3d_cartesian(uv[..., 0], uv[..., 1], uv[..., 2])
nch = (max_order + 1) ** 2
return y[..., :nch]
def sh_sn3d_batch(
directions_xyz: np.ndarray,
max_order: int = MAX_ORDER,
) -> np.ndarray:
"""Y at unit vectors. directions_xyz: (..., 3) → (..., n_channels)."""
d = np.asarray(directions_xyz, dtype=np.float64)
if d.shape[-1] != 3:
raise ValueError("last dim must be 3")
y = _eval_sn3d_cartesian(d[..., 0], d[..., 1], d[..., 2])
nch = (max_order + 1) ** 2
return y[..., :nch]
def sphere_grid(
n_azi: int = 72,
n_el: int = 36,
degrees: bool = True,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Product azimuth × elevation grid with integration weights ≈ cos(e) de da."""
if degrees:
azi = np.linspace(-180.0, 180.0, n_azi, endpoint=False)
el = np.linspace(-90.0, 90.0, n_el)
el_r = np.deg2rad(el)
da = 2.0 * math.pi / n_azi
de = math.pi / max(n_el - 1, 1)
w_el = np.cos(el_r) * de
weights = np.outer(np.full(n_azi, da), np.maximum(w_el, 0.0))
else:
azi = np.linspace(-math.pi, math.pi, n_azi, endpoint=False)
el = np.linspace(-0.5 * math.pi, 0.5 * math.pi, n_el)
da = 2.0 * math.pi / n_azi
de = math.pi / max(n_el - 1, 1)
w_el = np.cos(el) * de
weights = np.outer(np.full(n_azi, da), np.maximum(w_el, 0.0))
return azi, el, weights