#!/usr/bin/env python """Probe FOA azimuth conventions using a coarse active-intensity estimate. This script is intended for debugging Spatial-BEATs training when azimuth learning stalls near random. It reads manifest entries, crops each source to its weak active window, computes a coarse FOA active-intensity vector from the mixture waveform, and compares several azimuth conventions against the GT. The goal is not to produce a perfect DOA estimator. The goal is to answer: "Is the current FOA / azimuth coordinate convention obviously flipped, swapped, or rotated before I even train the model?" """ from __future__ import annotations import argparse import math from collections import defaultdict from pathlib import Path from typing import Dict, Iterable, List, Optional, Sequence, Tuple import torch from tqdm.auto import tqdm from spatial_dataset import _load_audio_file, _load_manifest_entries def circular_distance_deg(a_deg: float, b_deg: float) -> float: """Return the wrapped absolute angular distance in degrees.""" return abs(((a_deg - b_deg + 180.0) % 360.0) - 180.0) def normalize_deg(angle_deg: float) -> float: """Normalize an angle to [0, 360).""" return angle_deg % 360.0 def resolve_clip_path(entry: Dict[str, object]) -> str: """Resolve the FOA waveform path for one manifest entry.""" for key in ("output_foa_path", "waveform_path", "audio_path", "foa_path"): value = entry.get(key) if value: return str(value) raise KeyError("Manifest entry is missing an FOA waveform path.") def resolve_clip_duration_seconds(entry: Dict[str, object], waveform: torch.Tensor, sample_rate: int) -> float: """Resolve clip duration, falling back to waveform length when needed.""" for key in ("clip_duration_seconds", "output_duration_seconds", "duration"): value = entry.get(key) if value is not None: return float(value) return float(waveform.size(-1)) / float(sample_rate) def resolve_entry_id(entry: Dict[str, object], default_index: int) -> str: """Resolve a human-readable sample identifier for logging.""" for key in ("scene_id", "pair_id", "sample_id", "id"): value = entry.get(key) if value is not None: return str(value) return str(default_index) def resolve_source_times(source_entry: Dict[str, object], clip_duration_seconds: float) -> Tuple[float, float]: """Resolve the weak active window used by the supervision pipeline.""" active_time = source_entry.get("active_time") full_time = source_entry.get("full_time") if isinstance(active_time, Sequence) and len(active_time) >= 2: return float(active_time[0]), float(active_time[1]) if isinstance(full_time, Sequence) and len(full_time) >= 2: return float(full_time[0]), float(full_time[1]) return 0.0, float(clip_duration_seconds) def resolve_source_azimuth_deg(entry: Dict[str, object], source_entry: Dict[str, object]) -> float: """Resolve GT azimuth in degrees from source-level or top-level fields.""" doa = source_entry.get("doa") if isinstance(doa, dict) and doa.get("azimuth_deg") is not None: return float(doa["azimuth_deg"]) for key in ("azimuth_deg", "azimuth"): value = source_entry.get(key) if value is not None: return float(value) if entry.get("rir_doa_azimuth_deg") is not None: return float(entry["rir_doa_azimuth_deg"]) raise KeyError("Unable to resolve GT azimuth from manifest entry.") def resolve_source_label(source_entry: Dict[str, object]) -> str: """Resolve a readable label for debugging output.""" for key in ("mono_target_label", "mono_primary_label", "final_label", "label"): value = source_entry.get(key) if value: return str(value) return "" def iter_sources(entry: Dict[str, object], clip_duration_seconds: float) -> List[Dict[str, object]]: """Return source dicts in a unified shape for ov1/ov2/ov3 manifests.""" sources = entry.get("sources") if isinstance(sources, list) and sources: return [dict(source) for source in sources if isinstance(source, dict)] return [ { "mono_target_label": entry.get("mono_target_label", entry.get("mono_primary_label")), "doa": { "azimuth_deg": entry.get("rir_doa_azimuth_deg"), "elevation_deg": entry.get("rir_doa_elevation_deg"), }, "active_time": [0.0, clip_duration_seconds], "full_time": [0.0, clip_duration_seconds], } ] def is_isolated_window(source_index: int, sources: Sequence[Dict[str, object]], clip_duration_seconds: float) -> bool: """Check whether a source weak window overlaps with any other source window.""" start_a, end_a = resolve_source_times(sources[source_index], clip_duration_seconds) for other_index, other_source in enumerate(sources): if other_index == source_index: continue start_b, end_b = resolve_source_times(other_source, clip_duration_seconds) if min(end_a, end_b) > max(start_a, start_b): return False return True def crop_waveform_to_window( waveform: torch.Tensor, sample_rate: int, start_time_seconds: float, end_time_seconds: float, ) -> torch.Tensor: """Crop one FOA waveform to a weak source activity window.""" total_num_samples = waveform.size(-1) start_sample = max(0, min(int(math.floor(start_time_seconds * sample_rate)), total_num_samples - 1)) end_sample = max(start_sample + 1, min(int(math.ceil(end_time_seconds * sample_rate)), total_num_samples)) return waveform[:, start_sample:end_sample].contiguous() def reorder_dcase_wyzx_to_wxyz(waveform: torch.Tensor) -> torch.Tensor: """Convert stored DCASE FOA waveform order [W, Y, Z, X] to [W, X, Y, Z].""" if waveform.ndim != 2 or waveform.size(0) != 4: raise ValueError(f"Expected waveform [4, T], got {tuple(waveform.shape)}") return waveform[[0, 3, 1, 2], :] def estimate_active_intensity_vector( waveform: torch.Tensor, sample_rate: int, n_fft: int, win_length: int, hop_length: int, frame_energy_quantile: float, ) -> Tuple[float, float, float]: """Estimate a coarse FOA active-intensity vector from one cropped waveform. Returns: Tuple[float, float, float]: Mean active-intensity components (Ix, Iy, Iz). """ if waveform.ndim != 2 or waveform.size(0) != 4: raise ValueError(f"Expected waveform [4, T], got {tuple(waveform.shape)}") waveform = reorder_dcase_wyzx_to_wxyz(waveform) window = torch.hann_window(win_length, dtype=waveform.dtype, device=waveform.device) stft = torch.stft( waveform, n_fft=n_fft, hop_length=hop_length, win_length=win_length, window=window, center=True, pad_mode="reflect", return_complex=True, ) w = stft[0] x = stft[1] y = stft[2] z = stft[3] power = w.abs().pow(2.0) frame_energy = power.sum(dim=0) if frame_energy.numel() == 0: return 0.0, 0.0, 0.0 threshold = torch.quantile(frame_energy, q=float(frame_energy_quantile)) active_frame_mask = frame_energy >= threshold if not bool(active_frame_mask.any()): active_frame_mask = torch.ones_like(frame_energy, dtype=torch.bool) power = power[:, active_frame_mask] ix = torch.real(w[:, active_frame_mask] * torch.conj(x[:, active_frame_mask])) iy = torch.real(w[:, active_frame_mask] * torch.conj(y[:, active_frame_mask])) iz = torch.real(w[:, active_frame_mask] * torch.conj(z[:, active_frame_mask])) weight = power denom = torch.clamp(weight.sum(), min=1e-8) ix_mean = float((ix * weight).sum().item() / denom.item()) iy_mean = float((iy * weight).sum().item() / denom.item()) iz_mean = float((iz * weight).sum().item() / denom.item()) return ix_mean, iy_mean, iz_mean def azimuth_from_components(x_comp: float, y_comp: float) -> float: """Convert x/y Cartesian components to azimuth degrees.""" return normalize_deg(math.degrees(math.atan2(y_comp, x_comp))) def build_convention_predictions(ix: float, iy: float) -> Dict[str, float]: """Evaluate several common FOA azimuth sign / axis conventions.""" return { "atan2(+y,+x)": azimuth_from_components(+ix, +iy), "atan2(-y,+x)": azimuth_from_components(+ix, -iy), "atan2(+y,-x)": azimuth_from_components(-ix, +iy), "atan2(-y,-x)": azimuth_from_components(-ix, -iy), "atan2(+x,+y)": azimuth_from_components(+iy, +ix), "atan2(-x,+y)": azimuth_from_components(+iy, -ix), "atan2(+x,-y)": azimuth_from_components(-iy, +ix), "atan2(-x,-y)": azimuth_from_components(-iy, -ix), } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Probe FOA IV azimuth alignment against GT.") parser.add_argument("--manifest", type=str, required=True, help="Path to ov*.jsonl manifest.") parser.add_argument("--split", type=str, default=None, help="Optional split filter, e.g. train/valid/test.") parser.add_argument("--limit", type=int, default=200, help="Maximum number of usable source windows to evaluate.") parser.add_argument("--sample-rate", type=int, default=16000, help="Expected FOA sample rate.") parser.add_argument("--n-fft", type=int, default=400, help="STFT FFT size.") parser.add_argument("--win-length", type=int, default=400, help="STFT window length.") parser.add_argument("--hop-length", type=int, default=160, help="STFT hop length.") parser.add_argument("--min-window-seconds", type=float, default=0.3, help="Skip very short source windows.") parser.add_argument("--frame-energy-quantile", type=float, default=0.7, help="Use only high-energy frames above this quantile.") parser.add_argument( "--require-isolated-window", action="store_true", help="Only evaluate source windows that do not overlap any other source window in the same clip.", ) parser.add_argument("--show-examples", type=int, default=12, help="Number of per-sample examples to print.") return parser.parse_args() def main() -> None: args = parse_args() manifest_path = Path(args.manifest) entries = _load_manifest_entries(manifest_path, show_progress=False) if args.split is not None: entries = [entry for entry in entries if entry.get("split") == args.split] convention_errors: Dict[str, List[float]] = defaultdict(list) examples: List[Dict[str, object]] = [] num_skipped_short = 0 num_skipped_overlap = 0 num_skipped_zero_vector = 0 num_audio_failures = 0 progress = tqdm(entries, desc=f"Probe IV azimuth {manifest_path.name}") usable_windows = 0 for entry_index, entry in enumerate(progress): if usable_windows >= args.limit: break try: clip_path = resolve_clip_path(entry) waveform = _load_audio_file(clip_path, args.sample_rate) except Exception: num_audio_failures += 1 continue clip_duration_seconds = resolve_clip_duration_seconds(entry, waveform, args.sample_rate) sources = iter_sources(entry, clip_duration_seconds) sample_id = resolve_entry_id(entry, entry_index) for source_index, source in enumerate(sources): if usable_windows >= args.limit: break if args.require_isolated_window and not is_isolated_window(source_index, sources, clip_duration_seconds): num_skipped_overlap += 1 continue start_time_seconds, end_time_seconds = resolve_source_times(source, clip_duration_seconds) if end_time_seconds - start_time_seconds < args.min_window_seconds: num_skipped_short += 1 continue segment = crop_waveform_to_window( waveform=waveform, sample_rate=args.sample_rate, start_time_seconds=start_time_seconds, end_time_seconds=end_time_seconds, ) ix, iy, iz = estimate_active_intensity_vector( waveform=segment, sample_rate=args.sample_rate, n_fft=args.n_fft, win_length=args.win_length, hop_length=args.hop_length, frame_energy_quantile=args.frame_energy_quantile, ) xy_norm = math.sqrt(ix * ix + iy * iy) if xy_norm < 1e-8: num_skipped_zero_vector += 1 continue gt_azimuth_deg = normalize_deg(resolve_source_azimuth_deg(entry, source)) predictions = build_convention_predictions(ix, iy) for convention_name, pred_azimuth_deg in predictions.items(): convention_errors[convention_name].append( circular_distance_deg(pred_azimuth_deg, gt_azimuth_deg) ) examples.append( { "sample_id": sample_id, "source_index": source_index, "label": resolve_source_label(source), "gt_azimuth_deg": gt_azimuth_deg, "ix": ix, "iy": iy, "iz": iz, "window": (start_time_seconds, end_time_seconds), "predictions": predictions, } ) usable_windows += 1 progress.set_postfix(usable=usable_windows) print() print(f"Manifest: {manifest_path}") print(f"Split: {args.split or ''}") print(f"Usable source windows: {usable_windows}") print(f"Skipped short windows: {num_skipped_short}") print(f"Skipped overlapping windows: {num_skipped_overlap}") print(f"Skipped zero XY intensity: {num_skipped_zero_vector}") print(f"Audio load failures: {num_audio_failures}") if usable_windows == 0: print("No usable source windows found.") return summary_rows: List[Tuple[str, float, float]] = [] for convention_name, errors in convention_errors.items(): error_tensor = torch.tensor(errors, dtype=torch.float32) summary_rows.append( ( convention_name, float(error_tensor.mean().item()), float(error_tensor.median().item()), ) ) summary_rows.sort(key=lambda row: row[1]) print() print("Convention ranking by circular azimuth error:") for convention_name, mean_error, median_error in summary_rows: print( f" {convention_name:<15} mean_abs_err={mean_error:7.3f} deg" f" median_abs_err={median_error:7.3f} deg" ) best_convention = summary_rows[0][0] print() print(f"Examples using best convention: {best_convention}") for example in examples[: args.show_examples]: pred = float(example["predictions"][best_convention]) err = circular_distance_deg(pred, float(example["gt_azimuth_deg"])) print( f" {example['sample_id']} src={example['source_index']} " f"label={example['label']} window={example['window'][0]:.2f}-{example['window'][1]:.2f}s " f"GT={example['gt_azimuth_deg']:7.2f} pred={pred:7.2f} err={err:6.2f} " f"IV=({example['ix']:+.4f},{example['iy']:+.4f},{example['iz']:+.4f})" ) if __name__ == "__main__": main()