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
5.2 kB
"""Rotate HOA fields.
Phase 2 default: Wigner-D block rotation (fast, all orders).
Phase 0 reference: dense spherical re-projection (``method="dense"``).
Order-1 shortcut: ``rotate_matrix_order1`` (exact Cartesian).
"""
from __future__ import annotations
import math
import numpy as np
from .basis import MAX_ORDER, N_CHANNELS, sh_sn3d_batch, sphere_grid, unit_vector, acn_index
from .wigner import (
apply_hoa_rotation,
hoa_rotation_matrix,
rotation_matrix_zyx,
)
def _rotation_matrix_zyx(yaw: float, pitch: float, roll: float, degrees: bool) -> np.ndarray:
return rotation_matrix_zyx(yaw, pitch, roll, degrees=degrees)
def rotate_matrix_order1(
hoa: np.ndarray,
yaw: float = 0.0,
pitch: float = 0.0,
roll: float = 0.0,
*,
degrees: bool = True,
) -> np.ndarray:
"""Exact rotation for order-1 channels (Y,Z,X); W and n≥2 unchanged."""
a = np.array(hoa, dtype=np.float64, copy=True)
R = _rotation_matrix_zyx(yaw, pitch, roll, degrees)
if a.ndim == 1:
cart = np.array([a[3], a[1], a[2]])
Xp, Yp, Zp = R @ cart
a[3], a[1], a[2] = Xp, Yp, Zp
return a
cart = np.stack([a[3], a[1], a[2]], axis=0)
rot = R @ cart
a[3], a[1], a[2] = rot[0], rot[1], rot[2]
return a
def _sn3d_channel_scale(max_order: int) -> np.ndarray:
nch = (max_order + 1) ** 2
scale = np.empty(nch, dtype=np.float64)
for n in range(max_order + 1):
s = float(2 * n + 1)
for m in range(-n, n + 1):
scale[acn_index(n, m)] = s
return scale
def rotate_yaw_pitch_roll(
hoa: np.ndarray,
yaw: float = 0.0,
pitch: float = 0.0,
roll: float = 0.0,
*,
degrees: bool = True,
max_order: int = MAX_ORDER,
method: str = "wigner",
n_azi: int = 96,
n_el: int = 48,
) -> np.ndarray:
"""Rotate HOA field (active): values move with R.
Parameters
----------
method :
``"wigner"`` (default, Phase 2) — exact band-limited SH rotation.
``"dense"`` — Phase 0 spherical sample / re-encode (slow reference).
"""
if method == "wigner":
R = _rotation_matrix_zyx(yaw, pitch, roll, degrees)
M = hoa_rotation_matrix(R, max_order=max_order)
return apply_hoa_rotation(hoa, M)
if method == "dense":
return _rotate_dense(
hoa,
yaw,
pitch,
roll,
degrees=degrees,
max_order=max_order,
n_azi=n_azi,
n_el=n_el,
)
raise ValueError("method must be 'wigner' or 'dense'")
def _rotate_dense(
hoa: np.ndarray,
yaw: float,
pitch: float,
roll: float,
*,
degrees: bool,
max_order: int,
n_azi: int,
n_el: int,
) -> np.ndarray:
"""Phase 0 dense re-encode (reference)."""
a = np.asarray(hoa, dtype=np.float64)
if a.ndim == 2:
out = np.zeros(((max_order + 1) ** 2, a.shape[1]), dtype=np.float64)
for t in range(a.shape[1]):
out[:, t] = _rotate_dense(
a[:, t],
yaw,
pitch,
roll,
degrees=degrees,
max_order=max_order,
n_azi=n_azi,
n_el=n_el,
)
full = np.zeros((N_CHANNELS, a.shape[1]), dtype=np.float64)
full[: out.shape[0]] = out
return full
from .decode import decode_directions
nch = (max_order + 1) ** 2
a = a[:nch]
R = _rotation_matrix_zyx(yaw, pitch, roll, degrees)
azi, el, weights = sphere_grid(n_azi, n_el, degrees=True)
AA, EE = np.meshgrid(azi, el, indexing="ij")
dirs = unit_vector(AA, EE, degrees=True).reshape(-1, 3)
w = weights.reshape(-1)
f = decode_directions(
a, AA.reshape(-1), EE.reshape(-1), degrees=True, max_order=max_order
)
dirs_rot = (R @ dirs.T).T
Y_rot = sh_sn3d_batch(dirs_rot, max_order=max_order)
a_new = np.einsum("i,i,ic->c", w, f, Y_rot)
a_new *= _sn3d_channel_scale(max_order) / (4.0 * math.pi)
full = np.zeros(N_CHANNELS, dtype=np.float64)
full[: a_new.shape[0]] = a_new
return full
def rotate_source_directions(
azimuths: np.ndarray,
elevations: np.ndarray,
yaw: float = 0.0,
pitch: float = 0.0,
roll: float = 0.0,
*,
degrees: bool = True,
) -> tuple[np.ndarray, np.ndarray]:
"""Rotate source az/el by the same R (exact plane-wave ground truth)."""
dirs = unit_vector(azimuths, elevations, degrees=degrees)
flat = np.atleast_2d(dirs.reshape(-1, 3))
R = _rotation_matrix_zyx(yaw, pitch, roll, degrees)
rot = (R @ flat.T).T
from .basis import az_el_from_unit
az, el = az_el_from_unit(rot, degrees=degrees)
return az.reshape(np.shape(azimuths)), el.reshape(np.shape(elevations))
def rotate_plane_wave_field(
hoa: np.ndarray,
yaw: float = 0.0,
pitch: float = 0.0,
roll: float = 0.0,
*,
degrees: bool = True,
max_order: int = MAX_ORDER,
method: str = "wigner",
) -> np.ndarray:
return rotate_yaw_pitch_roll(
hoa,
yaw,
pitch,
roll,
degrees=degrees,
max_order=max_order,
method=method,
)