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 / 产物级实体注册核对 / 启动冒烟。
This commit is contained in:
@@ -24,7 +24,8 @@
|
||||
"migration:revert": "node scripts/migrate.mjs revert",
|
||||
"migration:show": "node scripts/migrate.mjs show",
|
||||
"version:check": "node scripts/check-version.mjs",
|
||||
"bootstrap:check": "node scripts/check-bootstrap.mjs"
|
||||
"bootstrap:check": "node scripts/check-bootstrap.mjs",
|
||||
"vectors:frame": "vite-node scripts/gen-frame-vectors.mts --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.12",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 生成 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 : ''})`);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 239 字节关键点帧编解码的**跨实现语义**(契约 websocket-keypoints §2)
|
||||
*
|
||||
* 为什么单独一个文件:`preprocess.spec.ts` 已经覆盖了「编码→解码往返」,
|
||||
* 但跨实现一致性真正会翻车的地方是**边界与取整规则** —— 本文件把这几条显式钉住,
|
||||
* 因为它们是「参考实现」对其他两端的承诺:
|
||||
*
|
||||
* * `i16` 最小值:`-32768/32767 = -1.0000305…`,编码必须得到 `0x8000`
|
||||
* (不能被夹成 `-32767`,也不能当成溢出);
|
||||
* * **取整规则**:JS `Math.round` 对「恰好 .5」是**朝 +∞** 取整
|
||||
* (`Math.round(-2.5) === -2`)。Android 的 Kotlin 实现用的是 `Math.round`,
|
||||
* 与本文件一致;**Windows 的 GDScript 用的是 `roundi`(半数远离零)**,
|
||||
* 两者在 `-X.5` 上会差 1 个 LSB —— 这一条已在
|
||||
* `opendance-windows/build/integration/probe-frame-vectors.gd` 里被实测并归类,
|
||||
* 此处把它写成参考实现的**显式契约**,避免以后有人"顺手"改掉;
|
||||
* * 置信度 `128/255` 是低置信度阈值(契约 §2:`conf < 128` 的点坐标无意义);
|
||||
* * `frame_id` 是 u32(含 `0xFFFFFFFF` 与 `0x80000000`);`flags` 只占低 3 位。
|
||||
*
|
||||
* 这些向量同时被 `opendance-windows/build/integration/frame-vectors.json` 使用
|
||||
* (由 `scripts/gen-frame-vectors.mts` 生成),所以本文件的改动会影响那边的比对基准。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BINARY_FRAME_BYTES,
|
||||
MAGIC,
|
||||
PROTOCOL_VERSION,
|
||||
decodeBinaryFrame,
|
||||
encodeBinaryFrame,
|
||||
} from '../src/preprocess/keypoint-codec';
|
||||
import { KEYPOINT_COUNT } from '../src/preprocess/mediapipe33';
|
||||
|
||||
type Point = [number, number, number, number];
|
||||
|
||||
const uniform = (x: number, y: number, z: number, conf: number): Point[] =>
|
||||
Array.from({ length: KEYPOINT_COUNT }, () => [x, y, z, conf] as Point);
|
||||
|
||||
describe('关键点二进制帧:跨实现边界语义', () => {
|
||||
it('帧头字段:magic / version / flags / frame_id(u32,小端)', () => {
|
||||
const buf = encodeBinaryFrame(0xffffffff, uniform(0, 0, 0, 0), 0x07);
|
||||
expect(buf.length).toBe(BINARY_FRAME_BYTES);
|
||||
expect(buf.readUInt16LE(0)).toBe(MAGIC);
|
||||
expect(buf.readUInt8(2)).toBe(PROTOCOL_VERSION);
|
||||
expect(buf.readUInt8(3)).toBe(0x07);
|
||||
expect(buf.readUInt32LE(4)).toBe(0xffffffff);
|
||||
// flags 只保留低 8 位(契约:bit3-7 保留,必须为 0;这里验证编码侧不越界)
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, 0), 0x1ff).readUInt8(3)).toBe(0xff);
|
||||
});
|
||||
|
||||
it('i16 最小值边界:-32768/32767 必须编码成 0x8000 并原样解回', () => {
|
||||
const scaled = -32768 / 32767; // -1.000030518509476
|
||||
const buf = encodeBinaryFrame(3, uniform(scaled, scaled, scaled, 1));
|
||||
expect(buf.readInt16LE(8)).toBe(-32768);
|
||||
expect(buf.readInt16LE(10)).toBe(-32768);
|
||||
expect(buf.readInt16LE(12)).toBe(-32768);
|
||||
const decoded = decodeBinaryFrame(buf);
|
||||
expect(decoded.points[0][0]).toBeCloseTo(scaled, 12);
|
||||
expect(decoded.points[0][1]).toBeCloseTo(scaled, 12);
|
||||
});
|
||||
|
||||
it('超出 ±1 的输入被夹到 i16 范围,不溢出', () => {
|
||||
const buf = encodeBinaryFrame(0, uniform(5, -5, 2, 1));
|
||||
expect(buf.readInt16LE(8)).toBe(32767);
|
||||
expect(buf.readInt16LE(10)).toBe(-32768);
|
||||
expect(buf.readInt16LE(12)).toBe(32767);
|
||||
});
|
||||
|
||||
it('取整规则是 JS Math.round(半数朝 +∞)—— 与 Android 的 Math.round 一致', () => {
|
||||
// -2.5 → -2(而不是 -3);+2.5 → 3。这条是参考实现对「取整」的承诺。
|
||||
const buf = encodeBinaryFrame(0, uniform(-2.5 / 32767, 2.5 / 32767, -0.5 / 32767, 1));
|
||||
expect(buf.readInt16LE(8)).toBe(-2);
|
||||
expect(buf.readInt16LE(10)).toBe(3);
|
||||
expect(buf.readInt16LE(12)).toBe(0);
|
||||
expect(Math.round(-2.5)).toBe(-2);
|
||||
expect(Math.round(-0.5)).toBe(-0);
|
||||
});
|
||||
|
||||
it('置信度:128/255 是低置信度阈值,0 与 255 两端都保留', () => {
|
||||
const buf = encodeBinaryFrame(0, uniform(0, 0, 0, 128 / 255));
|
||||
const conf = buf.readUInt8(8 + 6);
|
||||
expect(conf).toBe(128);
|
||||
expect(decodeBinaryFrame(buf).points[0][3]).toBeCloseTo(128 / 255, 12);
|
||||
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, 0)).readUInt8(14)).toBe(0);
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, 1)).readUInt8(14)).toBe(255);
|
||||
// 越界置信度被夹紧(而非回绕)
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, 2)).readUInt8(14)).toBe(255);
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, -1)).readUInt8(14)).toBe(0);
|
||||
});
|
||||
|
||||
it('解码拒绝长度 / magic / 版本不合法的帧', () => {
|
||||
expect(() => decodeBinaryFrame(Buffer.alloc(BINARY_FRAME_BYTES - 1))).toThrow(/239/);
|
||||
const badMagic = Buffer.alloc(BINARY_FRAME_BYTES);
|
||||
badMagic.writeUInt16LE(0x1234, 0);
|
||||
expect(() => decodeBinaryFrame(badMagic)).toThrow(/magic/);
|
||||
const badVersion = Buffer.alloc(BINARY_FRAME_BYTES);
|
||||
badVersion.writeUInt16LE(MAGIC, 0);
|
||||
badVersion.writeUInt8(0x02, 2);
|
||||
expect(() => decodeBinaryFrame(badVersion)).toThrow(/版本/);
|
||||
});
|
||||
|
||||
it('编解码往返在量化误差内稳定(含随机输入)', () => {
|
||||
for (let seed = 0; seed < 20; seed += 1) {
|
||||
const points: Point[] = Array.from({ length: KEYPOINT_COUNT }, (_, i) => {
|
||||
const v = (n: number) => Math.sin(seed * 13.7 + i * 1.9 + n) * 0.9;
|
||||
return [v(0), v(1), v(2), (Math.cos(seed + i) + 1) / 2] as Point;
|
||||
});
|
||||
const decoded = decodeBinaryFrame(encodeBinaryFrame(seed, points));
|
||||
for (let i = 0; i < KEYPOINT_COUNT; i += 1) {
|
||||
for (let c = 0; c < 3; c += 1) {
|
||||
// 量化步长 1/32767 ≈ 3.05e-5,允许半个步长
|
||||
expect(Math.abs(decoded.points[i][c] - points[i][c])).toBeLessThanOrEqual(1.6e-5);
|
||||
}
|
||||
expect(Math.abs(decoded.points[i][3] - points[i][3])).toBeLessThanOrEqual(1 / 255 + 1e-9);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user