/** * 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); } } }); });