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))