Files
opendance-web/web-server/scripts/gen-frame-vectors.mts
T
zijunchiang 5a78749e49 test(codec): 钉住关键点帧的跨实现语义 + 提供一致性向量生成器(T-WIN-021)
`web-server/src/preprocess/keypoint-codec.ts` 是契约 websocket-keypoints §2 的
**参考实现**(其 encodeBinaryFrame 注释即「Android 端参考实现」,
decodeBinaryFrame 注释即「Windows 端参考实现(T-WIN-004)」)。Android 已用
「金标准字节向量」与它对齐,而 **Windows 的 GDScript 解码器此前只跟自己比过**
(自己编码、自己解码)—— 那条边由本仓暴露出的向量来补。

新增:
  * scripts/gen-frame-vectors.mts(+ npm run vectors:frame):用参考实现生成 12 个
    用例的字节与**参考解码结果**,供 Windows 仓的 accept-frame-vectors.sh 锁定比对。
    向量里同时给出 inputPoints(原始 float64 输入)、f32InputPoints / f32Hex
    (收敛到 float32 的**同输入**基准)与 points(参考解码结果),各自注明用途。
  * test/keypoint-frame-codec.spec.ts(7 项):把参考实现对其他两端的承诺显式钉住 ——
    i16 最小值边界(-32768/32767 必须是 0x8000,不能被夹成 -32767)、
    **取整规则**(JS Math.round 半数朝 +∞:-2.5 → -2,与 Android 的 Math.round 一致)、
    置信度阈值 128/255 与两端夹紧、u32 frame_id(0xFFFFFFFF / 0x80000000)、
    flags 只取低 8 位、非法帧(长度 / magic / 版本)拒绝、20 组随机输入的往返量化误差。

实测:npm run verify 全绿 —— 契约 6 项 / 35 路由 / 版本一致 / 26 文件 302 项测试
(原 295)/ build / 产物级实体注册核对 / 启动冒烟。
2026-09-17 05:51:26 +08:00

158 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 生成 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 : ''})`);
}