Files
opendance-web/web-server/test/keypoint-frame-codec.spec.ts
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

118 lines
5.8 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)
*
* 为什么单独一个文件:`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);
}
}
});
});