HOA7 Spatial Field Decoder (hoa64 v0.5.0): 7th-order Ambisonics encode/decode, Wigner-D rotation, DOA analysis, vision fuse, diffusion conditioning
570b87b verified | """Spatial field analysis on HOA-7 coefficients (no learning).""" | |
| from __future__ import annotations | |
| import math | |
| from typing import Tuple | |
| import numpy as np | |
| from .basis import MAX_ORDER, N_CHANNELS, az_el_from_unit, unit_vector | |
| from .decode import decode_directions, decode_grid | |
| def field_energy(hoa: np.ndarray, max_order: int | None = None) -> float | np.ndarray: | |
| """Sum of squares of channels (proxy energy; SN3D-weighted optional later). | |
| hoa: (C,) → scalar; (C,T) → (T,) | |
| """ | |
| a = np.asarray(hoa, dtype=np.float64) | |
| if max_order is not None: | |
| nch = (max_order + 1) ** 2 | |
| a = a[..., :nch] if a.ndim == 1 else a[:nch, :] | |
| if a.ndim == 1: | |
| return float(np.dot(a, a)) | |
| return np.sum(a * a, axis=0) | |
| def intensity_vector(hoa: np.ndarray) -> np.ndarray: | |
| """Pseudo-intensity from order-1 SN3D (Ambix). | |
| For SN3D B-format-like (W,Y,Z,X) = (a0,a1,a2,a3): | |
| I ∝ W * (X, Y, Z) in Cartesian (front, left, up). | |
| Returns (3,) or (3, T) as [Ix, Iy, Iz]. | |
| """ | |
| a = np.asarray(hoa, dtype=np.float64) | |
| if a.ndim == 1: | |
| W, Y, Z, X = a[0], a[1], a[2], a[3] | |
| return np.array([W * X, W * Y, W * Z], dtype=np.float64) | |
| W, Y, Z, X = a[0], a[1], a[2], a[3] | |
| return np.stack([W * X, W * Y, W * Z], axis=0) | |
| def doa_from_intensity( | |
| hoa: np.ndarray, | |
| *, | |
| degrees: bool = True, | |
| ) -> Tuple[float, float] | Tuple[np.ndarray, np.ndarray]: | |
| """Direction of arrival from order-1 intensity vector. | |
| Returns (azimuth, elevation). | |
| """ | |
| I = intensity_vector(hoa) | |
| if I.ndim == 1: | |
| n = np.linalg.norm(I) | |
| if n < 1e-15: | |
| return (0.0, 0.0) if degrees else (0.0, 0.0) | |
| u = I / n | |
| az, el = az_el_from_unit(u, degrees=degrees) | |
| return float(az), float(el) | |
| # (3, T) | |
| n = np.linalg.norm(I, axis=0, keepdims=True) | |
| n = np.maximum(n, 1e-15) | |
| u = (I / n).T # (T, 3) | |
| az, el = az_el_from_unit(u, degrees=degrees) | |
| return az, el | |
| def directional_power( | |
| hoa: np.ndarray, | |
| n_azi: int = 72, | |
| n_el: int = 36, | |
| *, | |
| max_order: int = MAX_ORDER, | |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: | |
| """Power map |a · Y(Ω)|² on a sphere grid. | |
| Returns azi, el, power[azi, el]. | |
| """ | |
| azi, el, samp = decode_grid( | |
| hoa, n_azi=n_azi, n_el=n_el, degrees=True, max_order=max_order | |
| ) | |
| if samp.ndim == 2: | |
| power = samp * samp | |
| else: | |
| # (A, E, T) → average over time | |
| power = np.mean(samp * samp, axis=-1) | |
| return azi, el, power | |
| def peak_direction( | |
| hoa: np.ndarray, | |
| n_azi: int = 96, | |
| n_el: int = 48, | |
| *, | |
| max_order: int = MAX_ORDER, | |
| degrees: bool = True, | |
| ) -> Tuple[float, float, float]: | |
| """Argmax of directional power (azimuth, elevation, peak_value).""" | |
| azi, el, power = directional_power( | |
| hoa, n_azi=n_azi, n_el=n_el, max_order=max_order | |
| ) | |
| idx = np.unravel_index(int(np.argmax(power)), power.shape) | |
| az = float(azi[idx[0]]) | |
| e = float(el[idx[1]]) | |
| if not degrees: | |
| az, e = math.radians(az), math.radians(e) | |
| return az, e, float(power[idx]) | |
| def angular_error_deg( | |
| az0: float, el0: float, az1: float, el1: float | |
| ) -> float: | |
| """Great-circle angle between two az/el directions (degrees).""" | |
| u = unit_vector(az0, el0, degrees=True) | |
| v = unit_vector(az1, el1, degrees=True) | |
| c = float(np.clip(np.dot(u, v), -1.0, 1.0)) | |
| return math.degrees(math.acos(c)) | |