test(codec): 关键点帧跨实现一致性验收——包内 GDScript 解码器 × Web 参考实现(T-WIN-021)

契约 §2 的参考实现是 opendance-web 的 keypoint-codec.ts(其 decodeBinaryFrame
注释就是「Windows 端参考实现」)。Android 已用金标准向量与它对齐;而本端的解码器
此前**只跟自己比过**(自己编码、自己解码),两个方向共用同一份错误假设也能一直绿。

新增:
  * build/integration/frame-vectors.json —— 由参考实现生成并锁定的向量
    (12 用例:i16 最小值 -32768、i16 最大值、±1 全量程、置信度 0/128/255、
    flags 低 3 位组合、frame_id 0xFFFFFFFF 与 0x80000000、取整边界)。
  * build/integration/probe-frame-vectors.gd —— 用**包内** KeypointFrame 解码同一批字节,
    与参考解码结果**逐值精确比对**(坐标/置信度先收敛到 float32 再比精确相等,
    因为本端点存 PackedVector3Array、置信度存 PackedFloat32Array)。
  * build/accept-frame-vectors.sh —— 编排:用参考实现重新生成向量并与锁定文件比对
    (参考实现变了却不同步向量即失败)→ 解包 → 包内解码比对 → 报告核对。

实测(**已发布**的 v0.2.3 资产重新下载后跑):
  ✓ 锁定的向量与参考实现重新生成的结果逐字节一致
  ✓ 解码用例 12/12,逐点比对 396 个,解码差异 0 项
  ✓ 编码器差异 1 项,且全部属于「量化前恰好 .5」的取整边界
  关键点帧跨实现一致性验收全部通过

已测量并归类的一处差异(非缺陷):JS Math.round 与 Kotlin(生产发送方)Math.round
对 -2.5 取 -2(半数朝 +∞),本端 roundi 取 -3(半数远离零)。Windows 是接收方,
encode() 只在自测/协议回归里用,故对产品无影响;脚本硬性要求这类差异只能出现在
取整边界用例上,否则视为新问题。若将来本端要当发送方,把 roundi 换成 floor(x+0.5) 即可。

踩坑(两处「看着通过其实没测到」,都写进任务文档):
  1. 生成器第一版把「喂进编码器的输入」当成 points 写进向量 → 比对变成「比编码量化」,
     靠抽查数值才发现;现在向量同时含 inputPoints / f32InputPoints / f32Hex / points。
  2. 编码器比对一开始用 float64 输入编出的 hex 当基准(正数恰好 .5 那例整例 99 字节不同),
     实际只是输入从 float64 收敛到 float32 的量化差;改用同输入的 f32Hex 基准后,
     差异收敛到唯一一个真正的取整边界用例。教训:跨实现比对必须先统一输入。

package-release.sh 结尾把四条发布前验收一次列全;verify.sh 全绿(774 通过 / 0 失败)。
This commit is contained in:
2026-09-17 05:52:11 +08:00
parent cb1c293dcf
commit 1c5f28cd41
8 changed files with 7776 additions and 2 deletions
+86
View File
@@ -0,0 +1,86 @@
# T-WIN-021:关键点帧跨实现一致性(Web 参考实现 × 包内 GDScript 解码器)
- **状态**: done
- **依赖**: T-WIN-004(关键点帧解码)/ T-WEB-006(动作内部标准)
- **文档**: v1.6 §7.6.2
- **契约**: `websocket-keypoints 0.2.0` §2
## 为什么需要
`opendance-web/web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的**参考实现**
(它的 `encodeBinaryFrame` 注释就是「Android 端参考实现」,
`decodeBinaryFrame` 注释是「Windows 端参考实现(T-WIN-004)」):
* **Android ↔ Web** 已经用「金标准字节向量」对齐(`KeypointFrameTest.goldenVectorMatchesWebImplementation`);
* **Windows 的解码器此前只跟自己比过** —— 自己编码、自己解码,
两个方向用同一份错误假设也能一直绿。这条边是本轮补的。
## 实现
| 文件 | 角色 |
|--|--|
| `opendance-web/web-server/scripts/gen-frame-vectors.mts`(新增) | 用**参考实现**生成向量:12 个用例的字节(`hex`)+参考解码结果(`points`,float64)+ float32 输入基准(`f32InputPoints` / `f32Hex`) |
| `opendance-web/web-server/test/keypoint-frame-codec.spec.ts`(新增,7 项) | 把参考实现对其他两端的**承诺显式钉住**:i16 最小值边界、**取整规则**、置信度阈值 128、u32 frame_id、flags 掩码、非法帧拒绝、随机输入往返 |
| `opendance-windows/build/integration/frame-vectors.json`(锁定) | 由参考实现生成并入库的向量(比对基准) |
| `opendance-windows/build/integration/probe-frame-vectors.gd`(新增) | 用**包内** `KeypointFrame` 解码同一批字节并逐值比对,产出 JSON 报告 |
| `opendance-windows/build/accept-frame-vectors.sh`(新增) | 编排:**重新生成并比对向量** → 解包 → 包内解码比对 → 报告核对 |
### 比对口径(关键)
* `flags` / `frame_id`:**精确相等**;
* 坐标与置信度:本端点存进 `PackedVector3Array`、置信度存进 `PackedFloat32Array`,
即 **float32**;参考实现是 float64。因此先把期望值收敛到 float32
(GDScript 里借 `Vector3(...)` / `PackedFloat32Array([...])` 天然完成)再比**精确相等**
—— 比用容差更严格,并把差异限定在「表示精度」这一点上。
## 实测(已发布的 v0.2.3 资产重新下载后跑)
```
[accept-fv] 用参考实现重新生成向量并比对…
✓ 锁定的向量与参考实现重新生成的结果逐字节一致
[accept-fv] 包内解码器 × 参考向量…
[frame-vectors] 用例 12 个;解码用例 12/12,比对点 396 个
[frame-vectors] ✓ 解码结果与 Web 参考实现逐值一致(float32 精确相等)
✓ 解码差异 0 项(必须为 0)
✓ 逐点比对 396 个(期望 396)
✓ 编码器差异 1 项,全部属于「量化前恰好 .5」的取整边界
关键点帧跨实现一致性验收全部通过
```
覆盖的边界:`i16` 最小值 `-32768`(`-32768/32767`,必须是 `0x8000` 而不是被夹成 `-32767`)、
`i16` 最大值、`±1` 全量程、置信度 `0 / 128 / 255`、`flags` 低 3 位组合、
`frame_id` 的 `0xFFFFFFFF` 与 `0x80000000`、以及取整边界。
## 一处**已测量并归类**的差异(非缺陷)
本端 `KeypointFrame.encode()` 与参考编码器在**一个**用例上差 33 字节:
| | 取整方式 | `-X.5` 的行为 |
|--|--|--|
| JS 参考实现(`Math.round`) | 半数朝 **+∞** | `-2.5 → -2` |
| **Android(生产发送方,`Math.round`)** | 同上 | `-2.5 → -2` ✅ 与参考一致 |
| Windows `KeypointFrame.encode()`(`roundi`,半数远离零) | | `-2.5 → -3` ❌ 差 1 LSB |
**影响**:Windows 是**接收方**,生产链路里不调用 `encode()`(它只用于本仓自测与协议回归),
因此对产品无影响。`accept-frame-vectors.sh` 因此把编码差异记为「归类记录」,
但**硬性要求差异只能出现在「量化前恰好落在 .5」的用例上** —— 若哪天出现在别处,
就说明还有别的问题。若将来 Windows 需要成为发送方,把 `roundi` 换成
`floor(x + 0.5)` 即可与参考实现完全一致。
## 踩坑记录(两处"看起来通过、其实没测到")
1. **期望值写成了输入值**:第一版生成器把「喂进编码器的原始点」当成 `points` 写进向量,
于是比对变成了「比编码量化」而不是「比两个解码器是否一致」。
靠抽查数值(`-2.5/32767` 的原值出现在解码结果里)才发现。
→ 向量里现在同时有 `inputPoints`(原始)、`f32InputPoints` / `f32Hex`(同输入基准)
与 `points`(**参考解码结果**),并各自注明用途。
2. **拿不同输入的字节做比较**:编码器比对一开始用 `hex`(float64 输入编出来的)当基准,
于是「正数恰好 .5」整例 99 字节不同 —— 实际只是输入从 float64 收敛到 float32 的量化差,
与取整规则无关。改用 `f32Hex`(**同输入**基准)后,差异收敛到唯一一个真正的取整边界用例。
→ 教训:跨实现比对必须先统一**输入**,否则差异无法归因。
## 未覆盖
* 三端矩阵的另外两条边已有证据:Android ↔ Web(金标准向量,Android 仓 JVM 测试)、
Android → Windows(T-WIN-020 的实时链路验收,真实 WebSocket 发 239 字节帧);
* 真机端到端(相机 → MediaPipe → 编码 → Wi-Fi → Windows 解码 → 驱动模型)仍需设备实测。