/** * 生成 239 字节关键点帧的**跨实现一致性向量**(契约 websocket-keypoints §2) * * 为什么由 Web 端生成:`web-server/src/preprocess/keypoint-codec.ts` 是契约的 * **参考实现**(`encodeBinaryFrame` 注释即「Android 端参考实现」, * `decodeBinaryFrame` 注释即「Windows 端参考实现(T-WIN-004)」)。 * Android 的 Kotlin 实现已用「金标准字节向量」与它对齐; * **Windows 的 GDScript 解码器此前没有与它逐值比对过** —— 本脚本产出的向量 * 就是给那份比对用的输入与期望值。 * * 用法(需在 opendance-web/web-server 下执行): * npx vite-node scripts/gen-frame-vectors.mts -- --out /path/frame-vectors.json * * 输出结构: * { * "format": "keypoint-frame-vectors@1", * "source": "opendance-web web-server/src/preprocess/keypoint-codec.ts", * "cases": [ * { "name": "...", "frameId": 7, "flags": 3, "hex": "4f44...", * "points": [[x, y, z, conf] × 33], // **参考实现的解码结果**(float64) * "inputPoints": [...], // 生成该帧时喂给编码器的原始值(float64) * "f32Hex": "4f44...", // 用 f32InputPoints 编码出的字节(与 GDScript * // 重编码做"同输入"比较的唯一正确基准) * "f32InputPoints": [...] } // 同上,但已收敛到 float32(GDScript 侧点/置信度 * // 就是 float32;用它重编码才能与参考比特流 * // 做"同输入"比较,差异才只剩取整规则) * ] * } * * 注意:期望值里的坐标是 **float64**;GDScript 侧把点存进 `PackedVector3Array` * (float32),因此比对时必须对期望值做 `Math.fround` 等价的收敛 —— * 这是**表示精度**的差异,不是协议不兼容。向量里同时给出两者的对比口径。 */ import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { encodeBinaryFrame, decodeBinaryFrame } from '../src/preprocess/keypoint-codec'; import { KEYPOINT_COUNT } from '../src/preprocess/mediapipe33'; type Point = [number, number, number, number]; /** 33 点全同的构造器(便于构造边界用例) */ function uniform(x: number, y: number, z: number, conf: number): Point[] { return Array.from({ length: KEYPOINT_COUNT }, () => [x, y, z, conf] as Point); } /** 按索引摆动的一组"像真实动作"的值,覆盖正负与小数 */ function varying(): Point[] { return Array.from({ length: KEYPOINT_COUNT }, (_, i) => { const x = -0.9 + (1.8 * i) / (KEYPOINT_COUNT - 1); const y = 0.5 * Math.sin(i) + 0.1; const z = 0.02 * i - 0.3; const conf = i === 5 ? 100 / 255 : 240 / 255; return [x, y, z, conf] as Point; }); } interface Case { name: string; frameId: number; flags: number; points: Point[]; note?: string; } const cases: Case[] = [ { name: '全零', frameId: 0, flags: 0, points: uniform(0, 0, 0, 0) }, { name: '全 1 与满分置信度', frameId: 1, flags: 0, points: uniform(1, 1, 1, 1) }, { name: '负边界 -1', frameId: 2, flags: 0, points: uniform(-1, -1, -1, 1) }, { name: 'i16 最小值 -32768(-32768/32767 = -1.0000305…)', frameId: 3, flags: 0, points: uniform(-32768 / 32767, -32768 / 32767, -32768 / 32767, 1), note: '解码后应得到 i16 的 -32768,而不是被夹到 -32767', }, { name: 'i16 最大值 32767', frameId: 4, flags: 0, points: uniform(32767 / 32767, 0, 0, 1) }, { name: '置信度 128/255(低置信度阈值)', frameId: 5, flags: 0x02, points: uniform(0.5, -0.5, 0.25, 128 / 255) }, { name: 'flags 全 3 位(镜像/低置信度/半身)', frameId: 6, flags: 0x07, points: varying() }, { name: '按索引摆动(接近真实动作)', frameId: 7, flags: 0, points: varying() }, { name: 'frame_id 取 u32 最大值', frameId: 4294967295, flags: 0, points: uniform(0.1, 0.2, 0.3, 0.5) }, { name: 'frame_id 高位(0x80000000)', frameId: 2147483648, flags: 0x01, points: uniform(-0.1, -0.2, -0.3, 1) }, { name: '编码取整:正数恰好 .5', frameId: 9, flags: 0, points: uniform(2.5 / 32767, 3.5 / 32767, 0.5 / 32767, 1), note: 'x*32767 落在 .5 上:JS Math.round 与 GDScript roundi 对**负数**的进位方向不同', }, { name: '编码取整:负数恰好 -.5', frameId: 10, flags: 0, points: uniform(-2.5 / 32767, -3.5 / 32767, -0.5 / 32767, 1), note: '同上;若 GDScript 侧编解码与 TS 不一致,这条会先暴露', }, ]; const out: { format: string; source: string; keypointCount: number; frameBytes: number; cases: Array<{ name: string; frameId: number; flags: number; hex: string; points: Point[]; inputPoints: Point[]; f32InputPoints: Point[]; f32Hex: string; note?: string; }>; } = { format: 'keypoint-frame-vectors@1', source: 'opendance-web web-server/src/preprocess/keypoint-codec.ts', keypointCount: KEYPOINT_COUNT, frameBytes: 239, cases: [], }; for (const c of cases) { const buf = encodeBinaryFrame(c.frameId, c.points, c.flags); if (buf.length !== 239) throw new Error(`编码结果长度异常:${buf.length}`); const decoded = decodeBinaryFrame(buf); const f32Points = c.points.map( ([x, y, z, conf]) => [Math.fround(x), Math.fround(y), Math.fround(z), Math.fround(conf)] as Point, ); if (decoded.frameId !== c.frameId || decoded.flags !== c.flags) { throw new Error(`用例 ${c.name} 头部字段不一致`); } // **关键**:写进向量的 `points` 必须是**参考解码器对同一字节的解码结果**, // 而不是喂进编码器的原始值 —— 否则下游比对就变成了「比编码量化」, // 而不是「比两个解码器是否一致」。(第一版就写错了,靠抽查数值发现。) out.cases.push({ name: c.name, frameId: c.frameId, flags: c.flags, hex: buf.toString('hex'), points: decoded.points, inputPoints: c.points, f32InputPoints: f32Points, f32Hex: encodeBinaryFrame(c.frameId, f32Points, c.flags).toString('hex'), note: c.note, }); } const args = process.argv.slice(2); const outIdx = args.indexOf('--out'); const outPath = resolve(outIdx >= 0 && args[outIdx + 1] ? args[outIdx + 1] : 'frame-vectors.json'); mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, `${JSON.stringify(out, null, 2)}\n`, 'utf8'); console.log(`✓ 已生成 ${out.cases.length} 个向量 → ${outPath}`); for (const c of out.cases) { console.log(` · ${c.name}(${c.hex.length / 2} 字节${c.note ? ';' + c.note : ''})`); }