# 0429 · v11a_with_dynamic_10hz —— 动态 DOA 监督 + 真实/QA 数据扩展 > 记录当前 `ov1_local_spatial_v11a_with_dynamic_10hz` preset 的完整链路: > 数据 → loader → 模型 → loss;并标明相对于 `v11a_real_balanced_10hz` 的每一处差异。 > 对应代码入口: > - Preset: `train_spatial_beats.py::make_ov1_local_spatial_v11a_with_dynamic_10hz_config` > - Run script: `run_ov1_v11a_with_dynamic_10hz.sh` > - DCASE 转换器: `tools/dcase_starss_to_jsonl.py` --- ## 1. 一句话定位 **v11a_with_dynamic_10hz = v11a_real_balanced_10hz 的训练数据扩展 + loader/loss 升级到逐帧 target。** - **模型结构不变**:仍然是 `local_spatial_track` + `SourceQueryDecoder (K=4)` + spatial_head_demixer (v11a 的新组件),10 Hz token rate,ov123 top4 目录。 - **预测侧不变**:`FrameTrackPredictionOutput` 仍然是 `[B, K, T_s, ...]` 的逐帧四元组 `(activity, class, direction, distance)`。 - **监督侧升级**:target 张量从 `[B, N_gt]` 扩展为 `[B, N_gt, T_s]`——静态源沿 T_s 轴广播 (和旧行为一致),动态源按每帧轨迹线性插值到 10 Hz 栅格。 - **数据扩展**:新增 5 个训练 manifest(qa_moving / qa_counting / qa_lr_pair / qa_same_doa / dcase_starss_foa.train)和 1 个验证 manifest(dcase_starss_foa.valid)。 - **Hot-start**:默认从 `v11a_real_balanced_10hz/03_ov123_top4/best.pt` 继续训练,`strict=False` 且不继承 optimizer/epoch/best。上游 ov123 静态 clip 的 epoch 0 loss 应与 v11a 吻合 (逐帧 target 对静态源退化为标量广播)。 --- ## 2. 数据:新增的 manifest 与样本格式 ### 2.1 训练集组成 | manifest | 记录数 | 类型 | DOA 来源 | distance 有效 | 复制次数 | | --- | --- | --- | --- | --- | --- | | `ov1_foa.jsonl` (sim) | — | ov1 静态 | scalar | ✓ | 1 | | `ov2_foa.jsonl` (sim) | — | ov2 静态 | scalar | ✓ | 3 | | `ov3_foa.jsonl` (sim) | — | ov3 静态 | scalar | ✓ | 3 | | `ov1_real_static_foa_mapped.jsonl` | — | ov1 real 静态 | scalar | ✗ (null) | 4 | | `ov2_real_static_foa_mapped.jsonl` | — | ov2 real 静态 | scalar | ✗ | 8 | | `ov3_real_static_foa_mapped.jsonl` | — | ov3 real 静态 | scalar | ✗ | 8 | | **`qa_moving.jsonl`** | 19 597 | QA sim 动态(单源平滑轨迹) | `frames[]` per-frame | ✓ | **2** | | **`qa_counting.jsonl`** | 2 428 | QA sim 静态(多源 2-5 个) | scalar | ✓ | **1** | | **`qa_lr_pair.jsonl`** | 6 631 | QA sim 静态(左右成对) | scalar | ✓ | **1** | | **`qa_same_doa.jsonl`** | 7 896 | QA sim 静态(同一方向多源) | scalar | ✓ | **1** | | **`dcase_starss_foa.train.jsonl`** | 12 805 | DCASE 真录 20s 多源动态 | `frames[]` per-frame | ✗ (-1) | **2** | 对应的 `train_manifest_replication = (1, 3, 3, 4, 8, 8, 2, 1, 1, 1, 2)`。 - 总训练 clip 数(未复制)= ov123sim + ov123real + 5 × 新 manifest ≈ ov123 基础 + 49 357。 - 验证集相比 v11a 增加 `dcase_starss_foa.valid.jsonl` (4 560 clips),作为真实录音的统一评估入口。 ### 2.2 manifest schema(以 qa_moving / DCASE 为例) **qa_moving.jsonl**(来自 `build_qa_foa_moving.py` 合成管道) ```jsonc { "scene_id": "...", "output_foa_path": "/abs/.../foa.wav", "output_duration_seconds": 10.0, "sample_rate": 16000, "frame_rate": 10.0, "num_frames": 100, "sources": [ { "source_index": 0, "is_moving": true, "mono_target_label": "speech", // FSD50K 63-class 名字 "active_time": [0.0, 10.0], "doa": null, // ← 动态源 scalar doa 为空 "distance_cm": 150.0, // clip 级 fallback 距离 "trajectory": "...", // sweep_arc / lshape / ... "frames": [ {"frame_idx": 0, "doa": {"azimuth_deg": 175.6, "elevation_deg": -3.2}, "distance_cm": 150.2}, {"frame_idx": 1, "doa": {"azimuth_deg": 177.9, "elevation_deg": -3.1}, "distance_cm": 150.1}, ... ] } ] } ``` **dcase_starss_foa.{train,valid,test}.jsonl**(由 `tools/dcase_starss_to_jsonl.py` 生成) ```jsonc { "scene_id": "fold1_starss22__fold4_room10_mix001_0", "dataset_source": "dcase_starss", "split": "train", "output_foa_path": "/abs/.../foa.wav", "output_duration_seconds": 20.0, "sample_rate": 16000, "frame_rate": 10.0, "sources": [ { "source_index": 0, "is_moving": true, "dcase_class_idx": 5, "dcase_source_idx": 3, "mono_target_label": "speech", // 经 DCASE_TO_FSD50K 重映射 "mono_primary_label": "male_speech", "active_time": [1.3, 4.8], "full_time": [0.0, 20.0], "doa": null, "distance_cm": -1, // DCASE 没有距离 "distance_valid": false, "frames": [ {"frame_idx": 13, "time_s": 1.3, "doa": {"azimuth_deg": -45.0, "elevation_deg": 10.0}, "distance_cm": -1}, ... ] }, ... // 可能 4+ 个 track,但逐帧同时 active 的 ≤ 4(K=4 由 matcher 保证) ] } ``` ### 2.3 生成 DCASE manifest 的一次性步骤 ```bash python tools/dcase_starss_to_jsonl.py \ --dcase-root /apdcephfs_cq10/.../DCASE2024_seld_baseline/prepared_datasets/starss23_foa_plus_29cls_20s \ --output /apdcephfs_cq10/.../data/metadata/dcase_starss_foa.jsonl \ --per-split-output ``` - 扫描 `metadata_dev//.csv`,每行 `frame_idx, class_idx, source_idx, az_deg, el_deg, dist_cm`。 - 按 `(class_idx, source_idx)` 分 track,若相邻 labelled frame 间隔 > `gap_split_frames` (默认 50 帧 = 5s), 就把 track 切成多段 `SourceEvent`,避免在静默区间乱插值。 - **类别空间压缩**:DCASE 29 类 → FSD50K 63 类的语义最近邻映射 (`DCASE_TO_FSD50K` dict,见文件头部);碰不上 FSD50K 词表的 DCASE 类(如 `unknown_*`)整 track 丢弃。 - 输出统计:18 061 CSV → train 12 805 / valid 4 560 / test 505。 ### 2.4 FSD50K 63 类别名 qa_*/DCASE manifest 里可能出现细粒度标签(`male_singing` / `female_singing`), 但 v11a 的 vocab 只有压缩后的 63 类(含 `singing` 不含性别变体)。在 `spatial_dataset.py` 的 `_resolve_class_index` / `_resolve_class_label` 之前先跑一次 `_LABEL_ALIASES.get(raw, raw)` 归一化: ```python _LABEL_ALIASES = { "male_singing": "singing", "female_singing": "singing", } ``` 这样不用重新生成 jsonl,就能把 508 条 `male_singing` + 527 条 `female_singing` 折到 `singing` 上。 --- ## 3. Loader:`spatial_dataset.py` 的逐帧化改造 ### 3.1 `SourceEvent` 新增 5 个可选字段 ```python @dataclass class SourceEvent: class_index: int class_label: str azimuth_deg: float # 静态 scalar;动态时是 frames[0] 的 fallback elevation_deg: float distance: float # 动态时是第一个 valid frame 的 fallback distance_valid: bool start_time_seconds: float end_time_seconds: float # ---- 动态轨迹(仅动态源设置)---- frame_times_s: Optional[Tensor] = None # [N_f] 秒,相对 clip 起点 frame_azi_deg: Optional[Tensor] = None # [N_f] 度,未 unwrap frame_ele_deg: Optional[Tensor] = None # [N_f] 度 frame_distance_m: Optional[Tensor] = None # [N_f] 米 frame_distance_valid: Optional[Tensor] = None # [N_f] bool ``` ### 3.2 `_parse_frame_trajectory` 从 manifest 的 `frames[]` 里抽出 5 个 1D tensor,同时支持两种 layout: 1. **qa_moving**:每帧带 `frame_idx`(不带 `time_s`),clip 级 `frame_rate` 用于换算 `time_s = frame_idx / frame_rate`。 2. **DCASE 转换器输出**:每帧直接给 `time_s`,跳过 `frame_idx / frame_rate` 换算。 距离单位处理:优先读 `distance_cm`(除以 100 得米),`-1` 或缺失标记为 `distance_valid=False`; 其次读 `distance_m`。 ### 3.3 `_build_source_event_from_nested_entry` 的 fallback - 动态源 top-level `doa` 通常是 `null`,所以把 `_get_float` 换成 `_maybe_get_float`, 再用 `frames[0]` 的 DOA 补 `azimuth_deg` / `elevation_deg`(scalar fallback,只有在 loss 层碰到静态路径时才会用到)。 - 距离同理:若 source-level `distance_valid=False`,但 `frames[]` 里有至少一个 `distance_cm >= 0`, 就用第一个 valid frame 的距离作为 scalar fallback;否则保留 `distance_valid=False`。 ### 3.4 `_maybe_crop_sample` 的轨迹裁剪 随机/中心裁剪时,除了裁 waveform 和更新 `start/end_time_seconds`,还要: - 用 `new_start/new_end` 窗口过滤 `frame_times_s`,把留下来的帧时间重置到新 clip 起点(`- crop_start_seconds`)。 - `frame_azi_deg` / `frame_ele_deg` / `frame_distance_m` / `frame_distance_valid` 一起按索引截断。 保证裁剪后的 `SourceEvent` 时间轴仍和 waveform 同源。 ### 3.5 Collate:`[B, N_gt, T_s]` 逐帧 target `collate_spatial_batch` 相对旧实现的关键变化: ```python t_s_max = int(target_num_steps.max()) # batch 内最大 token 数 source_azimuth_deg = zeros(B, N_gt_max, t_s_max) # 原来是 (B, N_gt_max) source_elevation_deg = zeros(B, N_gt_max, t_s_max) source_distance = zeros(B, N_gt_max, t_s_max) source_distance_valid = ones (B, N_gt_max, t_s_max, dtype=bool) # 默认 True for b, sample in enumerate(samples): t_axis = arange(t_s_i) / target_token_rate # 该 sample 的有效时间轴 for s, source in enumerate(sample.sources): azi_row, ele_row, dist_row, dist_valid_row = _build_per_frame_targets( source=source, t_axis=t_axis, t_s_max=t_s_max, ) source_azimuth_deg[b, s] = azi_row source_elevation_deg[b, s] = ele_row source_distance[b, s] = dist_row source_distance_valid[b, s] = dist_valid_row ``` `_build_per_frame_targets` 的两条路径: - **静态源**(`frame_times_s is None`):在 `[0:t_s_i)` 填入 scalar;`[t_s_i:t_s_max)` 填零(padding)。 行为等价于旧版广播。 - **动态源**:对 `t_axis` 做线性插值。方位角先用 `_unwrap_deg` 去掉 ±180° 的跳变 (qa_moving / DCASE 都可能有 170° → -170° 这样跨接的情况),插值后再 wrap 回 `[-180, 180]`; elevation / distance 直接线性插值;`distance_valid` 用**两端都 valid 才 valid**的逻辑 (`_linear_interp_valid_mask`),避免在未知距离段里猜出假的 valid。 ### 3.6 `SpatialBatch` 的契约变化 ```python @dataclass class SpatialBatch: ... source_azimuth_deg: Tensor # [B, N_gt_max, T_s_max] ← 原 [B, N_gt_max] source_elevation_deg: Tensor # [B, N_gt_max, T_s_max] source_distance: Tensor # [B, N_gt_max, T_s_max] source_distance_valid: Tensor # [B, N_gt_max, T_s_max] 新字段 source_class_indices: Tensor # [B, N_gt_max] (class 仍是 clip 级) source_start_time_seconds: Tensor # [B, N_gt_max] source_end_time_seconds: Tensor # [B, N_gt_max] source_valid_mask: Tensor # [B, N_gt_max] ``` `source_class_indices` 保持 clip 级:v11a 没有「同一 source 换类」的需求,且对应 track 内 class 恒定。 --- ## 4. 模型:和 v11a 完全一致 **没改**,为了让 hot-start 生效。这里简要记录一下 v11a 已有的配置,便于对照: ``` FOA 4-ch waveform @ 16 kHz │ ▼ SpatialBEATsPreprocessor (mel, iv feat) │ ┌───────────────────┴──────────────────┐ ▼ ▼ SpatialPatchEmbedding SpatialDeltaPatchAdapter (mel → 768-d patch tokens) (+IV residual contribution) └───────────────┬──────────────────────┘ ▼ BEATs TransformerEncoder (12 层,冻结) │ ▼ LocalSpatialEncoder (IV-aware conv over (T_p, F_p)) │ ▼ TemporalResampler → fused_spatial_embeddings [B, T_s, 768] (T_s @ 10 Hz ,cfg.target_token_rate=10) │ ▼ SourceQueryDecoder (K=4 queries × T_s 次 decode,两段式) • track_latents: [B, K, D] • track_time_feat:[B, K, T_s, D] │ ▼ FrameTrackHeads (+ SpatialHeadDemixer 1 层 attn refine, heads=8) • pred_activity: [B, K, T_s] • pred_class_logits: [B, K, T_s, 63] • pred_direction: [B, K, T_s, 3] L2-normed • pred_distance: [B, K, T_s] softplus 米 • pred_num_active_logits: [B, T_s, K+1] (v10 num_active head) ``` v11a 相对 v9 的关键新组件(均保留): - `use_spatial_head_demixer=True`(1 层 self-attn,8 heads,dropout 0.1)—— 在 FrameTrack head 输出后做一次 track 维解相关。 - `local_spatial_lr_scale=1.0` —— LocalSpatialEncoder 和 head 用相同 LR(v9 默认 0.3 偏低)。 --- ## 5. Loss:逐帧 target + distance valid mask 入口 `compute_frame_track_losses(prediction_output, batch, temporal_padding_mask, config)`。 ### 5.1 target 抽取 ```python targets = _frame_source_target_tensors(batch, t_s_max, device) # 返回: # window_mask: [B, N_gt, T_s] (active_time 内为 True) # source_valid: [B, N_gt] # source_class: [B, N_gt] # source_direction: [B, N_gt, T_s, 3] ← 逐帧 unit vector # source_distance: [B, N_gt, T_s] ← 逐帧米 # source_distance_valid: [B, N_gt, T_s] ← 逐帧 bool ``` 对于来自 loader 的 `source_azimuth_deg` / `source_elevation_deg`,`_align_t` 处理长度不匹配: batch 内 `t_s_max` 可能与 loader 构造时的大小不同(不同 DataLoader 的 collate 边界), 短则 pad 末帧的值,长则截断。 ### 5.2 Hungarian 匹配(代价按每帧) `_match_frame_tracks`(`per_frame` 或 `segment` 两种策略,preset 里走 `segment`) 在 `[B, N, K, T]` 的代价张量上做匹配: ``` cost[b, n, k, t] = class_cost_w * NLL(pred_class, target_class[b, n]) + dir_cost_w * (1 - pred_direction[b,k,t] · target_direction[b,n,t]) + dist_cost_w * |pred_distance[b,k,t] - target_distance[b,n,t]| + (1 - σ(pred_activity[b,k,t])) # include_activity_cost ``` **关键点**:`target_direction` / `target_distance` 从 `[B, N, T, *]` 广播到 `[B, N, 1, T, *]`(之前是 clip 级标量), 代价按每帧独立累加,所以动态源在不同帧的 best-match track 可以不同。segment matching 额外加了一个 −2.0 的 continuity bonus,让同一 GT 在连续的相同 active-set segment 里尽量停留在同一 track。 ### 5.3 监督张量的构建 ```python matched_track: [B, N_gt, T_s] (匹配结果 k∈[0,K) 或 -1) valid_assign = matched_track >= 0 idx_b, idx_gt, idx_t = valid_assign.nonzero(as_tuple=True) idx_k = matched_track[idx_b, idx_gt, idx_t] activity_target[idx_b, idx_k, idx_t] = 1.0 class_target [idx_b, idx_k, idx_t] = targets["source_class"][idx_b, idx_gt] direction_target[idx_b, idx_k, idx_t] = targets["source_direction"][idx_b, idx_gt, idx_t] # ← 3D 索引 distance_target [idx_b, idx_k, idx_t] = targets["source_distance" ][idx_b, idx_gt, idx_t] # ← 3D 索引 dist_supervise_mask[idx_b, idx_k, idx_t] = targets["source_distance_valid"][idx_b, idx_gt, idx_t] ``` 相对旧版(`targets["source_direction"][idx_b, idx_gt]` 是 `[M, 3]`)的差别是把第三个 axis 替换为具体 的 `idx_t`,真正拿到逐帧 GT。`supervise_mask` 是 activity-winning 的 mask,`dist_supervise_mask` 在其基础上再 AND 一个**逐帧 distance validity**:STARSS/DCASE 整源为 False 时, distance loss 就不会回传任何梯度。 ### 5.4 各项损失 | 项 | 公式 | mask | | --- | --- | --- | | activity | `BCE_with_logits(pred_activity, activity_target, pos_weight=dyn)` | `valid_time` 扩到 [B, K, T_s] | | num_active (v10) | `CE(pred_num_active_logits, active_count)` | `valid_time` | | class | `CE(pred_class_logits, class_target)` + 可选 ontology smoothing | `supervise_mask` | | direction | `mean(1 - pred · target)` | `supervise_mask` | | distance | `smooth_l1(pred, target)` | `dist_supervise_mask` ← **逐帧 validity** | **ADPIT duplicate** & **nonwinner soft activity**(v9/v10 的两个辅助)同样全部走逐帧 `source_direction[..., t]` 和 `source_distance_valid[..., t]` 索引;旧版的 `batch.source_azimuth_deg[:, 0]` 之类的 2D 访问被替换成 `[:, 0, 0]`(共 44 处)以避免 shape 冲突。 最终汇总: ``` loss_total = λ_act · loss_activity + λ_cls · loss_class + λ_dir · loss_direction + λ_dist · loss_distance + λ_na · loss_num_active ``` λ 与 v11a 完全相同(在 `SpatialLossConfig` 里走 v10 phase-2 的基线数值)。 ### 5.5 静态源的退化等价性 因为 loader 把静态源沿 T_s 轴广播,`target_direction[b, gt, 0:T_s_i]` 每一帧都一致, Hungarian 代价 `(1 - pred·target)` 和 per-clip 版本逐项相等;distance 同理。因此 **ov123 sim/real clip 的 epoch 0 loss 与 v11a 数值吻合**,这也是为什么可以直接从 v11a best.pt 热启动。 --- ## 6. 训练配置(preset diff) ```python def make_ov1_local_spatial_v11a_with_dynamic_10hz_config(...): cfg = make_ov1_local_spatial_v11a_real_balanced_10hz_config(...) # —— 只改了数据和轮次 —— cfg.train_manifest_paths = ( ov1_sim, ov2_sim, ov3_sim, ov1_real, ov2_real, ov3_real, qa_moving, qa_counting, qa_lr_pair, qa_same_doa, dcase_starss_train, ) cfg.train_manifest_replication = (1, 3, 3, 4, 8, 8, 2, 1, 1, 1, 2) cfg.val_manifest_paths = ( ov1_sim, ov2_sim, ov3_sim, ov1_real, ov2_real, ov3_real, dcase_starss_valid, ) cfg.test_manifest_paths = cfg.val_manifest_paths cfg.num_epochs = 15 cfg.output_dir = "checkpoints/spatial_beats_ov1_local_spatial_v11a_with_dynamic_10hz_exp/03_ov123_top4" return cfg ``` 运行入口(`run_ov1_v11a_with_dynamic_10hz.sh`)默认: ``` GPUS=8 BATCH_SIZE=4 NUM_WORKERS=8 SPATIAL_EPOCHS=15 SPATIAL_LR=1.5e-5 AMP=fp32 RESUME_CKPT=checkpoints/spatial_beats_ov1_local_spatial_v11a_real_balanced_10hz_exp/03_ov123_top4/best.pt --no-resume-optimizer --reset-epoch-on-resume --reset-best-on-resume ``` --- ## 7. 与 v11a_real_balanced_10hz 的差异一览 | 维度 | v11a_real_balanced_10hz | **v11a_with_dynamic_10hz** | | --- | --- | --- | | 训练 manifest 数 | 6 (ov123 sim + ov123 real) | **11**(+5 个 QA/DCASE) | | 训练 clip 量(未复制) | ~O(20k) | +49 357 | | 真实录音监督 | ov123 real 静态 scalar | **+DCASE STARSS 20s 逐帧** | | 动态源监督 | 无 | **qa_moving + DCASE 动态 track** | | `SourceEvent` 字段 | 无 `frame_*` | +5 个 `frame_times_s/azi/ele/dist/distance_valid` | | Loader target shape | `source_*: [B, N_gt]` | **`source_*: [B, N_gt, T_s]`**(静态广播,动态插值) | | `source_distance_valid` | `[B, N_gt]` | **`[B, N_gt, T_s]`**(逐帧) | | Hungarian 代价 | dir/dist 代价用 clip 级标量 target | **用逐帧 `[B, N, T, *]` target 广播** | | distance 监督 mask | 按源 `distance_valid` | **按 (源, 帧) `dist_supervise_mask`**,STARSS/DCASE 不回传梯度 | | Class 词表别名 | 无 | **`_LABEL_ALIASES` 处理 `male_singing`/`female_singing → singing`** | | 验证集 | ov123 sim+real | **+ DCASE valid (4 560 clips)** | | `num_epochs` | 15 (继承 v9) | 15(不变) | | 模型结构 | local_spatial_track + K=4 + demixer | **完全一致** | | 热启动 | v9_real_balanced_10hz best.pt | **v11a_real_balanced_10hz best.pt**, strict=False | | 输出目录 | `.../v11a_real_balanced_10hz_exp/03_ov123_top4` | `.../v11a_with_dynamic_10hz_exp/03_ov123_top4` | --- ## 8. 已知坑 & 兼容性备忘 1. **`pred_*` 的 t_s 与 loader 的 `T_s_max` 有可能不一致**。loss 侧 `_align_t` 会 pad/truncate GT 的最后一维; loader 侧已保证 `target_num_steps = round(duration × target_token_rate)` 与模型 temporal resampler 对齐, 正常只有在 batch 内最长样本决定 `t_s_max` 而 frame-track head 输出更短时需要截断。 2. **方位角跨 ±180°**:qa_moving 里观测到 `175.6° → -177.3°` 的自然轨迹, `_unwrap_deg` 会把它解包成 `175.6° → 182.7°` 插值,最后 wrap 回 `[-180, 180]`,不会出现 -340° 的错误跨越。 3. **DCASE 一个 clip 可以出现 6+ 个 track**,但任意帧同时 active 的 ≤ 4(DCASE 规范约束); `_match_frame_tracks_per_frame` 里 `active_count.clamp(max=K)` 是一层防御。 4. **distance=-1 的 clip** 在 loss 内走 `dist_supervise_mask` 全 False 的分支: `loss_distance = pred_distance.sum() * 0.0`,梯度为 0,这一 clip 只贡献 activity/class/direction loss。 5. **`male_singing/female_singing`** 是 loader 侧别名,若以后又有新细粒度标签冒出来,直接在 `spatial_dataset.py` 的 `_LABEL_ALIASES` 里加映射即可,不需要重跑数据。 6. **ov123 静态 clip 的 epoch 0 loss 必须与 v11a 一致**——这是验证 loader/loss 升级没有引入 回归的烟雾测试;曾经跑过 smoke script 确认 variance=0 for 静态 target、variance>0 for qa_moving target。 --- ## 9. 相关脚本索引 - `tools/dcase_starss_to_jsonl.py` —— DCASE CSV → jsonl + FSD50K 类别映射 - `run_ov1_v11a_with_dynamic_10hz.sh` —— 训练入口(含 6 个动态 manifest 存在性 warning) - `spatial_dataset.py::_parse_frame_trajectory` / `_build_per_frame_targets` —— 动态 target 构建 - `spatial_loss.py::_frame_source_target_tensors` / `compute_frame_track_losses` —— 逐帧 loss - Preset: `train_spatial_beats.py::make_ov1_local_spatial_v11a_with_dynamic_10hz_config` (CLI 名: `--preset ov1_local_spatial_v11a_with_dynamic_10hz`)