# 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`、以及取整边界。 ## 一处差异(**已在 v0.2.4 修复**,见 T-WIN-022) > 下面记录的差异已修复:本端改为 `_round_like_js(v) = floor(v + 0.5)`, > 与参考实现的 `Math.round`(半数朝 +∞)一致。`accept-frame-vectors.sh` 的编码口径 > 也从「差异仅限取整边界」**收紧为「必须为 0」**,实测 0 项差异。 > 保留此节是因为「为什么会有这个差异、为什么一开始只敢记不敢修」本身是有效的记录。 本端 `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 解码 → 驱动模型)仍需设备实测。