File size: 3,447 Bytes
570b87b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | """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))
|