HOA7 Spatial Field Decoder (hoa64 v0.5.0): 7th-order Ambisonics encode/decode, Wigner-D rotation, DOA analysis, vision fuse, diffusion conditioning
570b87b verified | """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, | |
| ) | |