import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { forwardKinematics, parseBvh } from '../src/preprocess/bvh-parser'; import { bvhToKeypointFrames } from '../src/preprocess/retarget'; import { BINARY_FRAME_BYTES, decodeBinaryFrame, encodeBinaryFrame, parseFramesNdjson, serializeFramesNdjson, } from '../src/preprocess/keypoint-codec'; import { ANGLE_TRIPLETS, KEYPOINT_COUNT, MP, PARENT_TABLE, VIRTUAL } from '../src/preprocess/mediapipe33'; import { angleCos } from '../src/preprocess/math3d'; import { CONTRACT_VERSIONS, parseVersionFile } from '../src/config/contract-versions'; const samplePath = join(__dirname, 'fixtures', 'sample.bvh'); const sampleText = readFileSync(samplePath, 'utf8'); describe('BVH 解析器', () => { it('解析层级、帧数与通道数', () => { const motion = parseBvh(sampleText); expect(motion.frameCount).toBe(60); expect(motion.channelCount).toBe(63); expect(motion.frameTime).toBeCloseTo(1 / 30, 6); expect(motion.root.name).toBe('Hips'); expect(motion.frames).toHaveLength(60); expect(motion.frames[0]).toHaveLength(63); expect(motion.byName.get('leftforearm')).toBeDefined(); }); it('前向运动学给出有限且随帧变化的世界坐标', () => { const motion = parseBvh(sampleText); const first = forwardKinematics(motion, 0); const last = forwardKinematics(motion, 59); const hipsFirst = first.get('hips')!.position; expect(hipsFirst.every((v) => Number.isFinite(v))).toBe(true); const handFirst = first.get('lefthand')!.position; const handLast = last.get('lefthand')!.position; expect(handFirst).not.toEqual(handLast); }); it('拒绝非法输入', () => { expect(() => parseBvh('不是 BVH')).toThrow(); expect(() => parseBvh('HIERARCHY\nMOTION\n')).toThrow(); }); it('通道值与帧数不匹配时报错', () => { const broken = sampleText.replace('Frames: 60', 'Frames: 61'); expect(() => parseBvh(broken)).toThrow(/期望 3843 个通道值/); }); }); describe('MediaPipe33 拓扑常量', () => { it('父节点表覆盖 33 点 + 2 个虚拟关节', () => { expect(PARENT_TABLE).toHaveLength(35); expect(PARENT_TABLE[VIRTUAL.PELVIS]).toBe(-1); expect(PARENT_TABLE[VIRTUAL.NECK]).toBe(VIRTUAL.PELVIS); expect(PARENT_TABLE[MP.NOSE]).toBe(VIRTUAL.NECK); }); it('父节点索引不产生环(除根外均指向更大的层级)', () => { // 从任意点沿父链上溯必须能终止在 -1 for (let i = 0; i <= VIRTUAL.PELVIS; i += 1) { let cursor = i; let hops = 0; while (cursor !== -1) { cursor = PARENT_TABLE[cursor]; hops += 1; expect(hops).toBeLessThan(64); } } }); it('关节角三元组共 9 组且索引合法', () => { expect(ANGLE_TRIPLETS).toHaveLength(9); for (const [a, b, c] of ANGLE_TRIPLETS) { for (const index of [a, b, c]) { expect(index).toBeGreaterThanOrEqual(0); expect(index).toBeLessThanOrEqual(VIRTUAL.PELVIS); } } }); }); describe('BVH → MediaPipe33 重定向', () => { const motion = parseBvh(sampleText); const frames = bvhToKeypointFrames(motion); it('每帧输出 33 个关键点(虚拟关节不外泄)', () => { expect(frames).toHaveLength(60); for (const frame of frames) { expect(frame.points).toHaveLength(KEYPOINT_COUNT); expect(frame.angles).toHaveLength(9); } }); it('坐标落在 MediaPipe 归一化范围', () => { for (const frame of frames) { for (const [x, y, z, conf] of frame.points) { expect(x).toBeGreaterThanOrEqual(0); expect(x).toBeLessThanOrEqual(1); expect(y).toBeGreaterThanOrEqual(0); expect(y).toBeLessThanOrEqual(1); expect(z).toBeGreaterThanOrEqual(-1); expect(z).toBeLessThanOrEqual(1); expect(conf).toBeGreaterThanOrEqual(0); expect(conf).toBeLessThanOrEqual(1); } } }); it('髋部相对骨盆居中(左右髋对称于中心附近)', () => { const x = frames[0].points[MP.LEFT_HIP][0]; const y = frames[0].points[MP.RIGHT_HIP][0]; expect(Math.abs(x - y)).toBeLessThan(0.2); }); it('直接映射的点置信度为 1,推算点为 0.5', () => { const confs = new Set(frames[0].points.map((p) => p[3])); expect(confs.has(1)).toBe(true); expect(confs.has(0.5)).toBe(true); for (const conf of confs) expect([1, 0.5, 0]).toContain(conf); }); it('时间戳按帧率推进', () => { expect(frames[0].timeMs).toBe(0); expect(frames[30].timeMs).toBeCloseTo(1000, 1); }); it('关节角余弦在 [-1,1] 且非全零', () => { const nonZero = frames.some((f) => f.angles.some((v) => Math.abs(v) > 1e-6)); expect(nonZero).toBe(true); for (const frame of frames) { for (const value of frame.angles) { expect(value).toBeGreaterThanOrEqual(-1.0001); expect(value).toBeLessThanOrEqual(1.0001); } } }); it('动作随时间变化(不是静止序列)', () => { const firstHand = frames[0].points[MP.LEFT_WRIST]; const lastHand = frames[59].points[MP.LEFT_WRIST]; const delta = Math.hypot(firstHand[0] - lastHand[0], firstHand[1] - lastHand[1]); expect(delta).toBeGreaterThan(0.01); }); }); describe('角度计算', () => { it('直角余弦为 0,同向为 1,反向为 -1', () => { const b: [number, number, number] = [0, 0, 0]; expect(angleCos([1, 0, 0], b, [0, 1, 0])).toBeCloseTo(0, 6); expect(angleCos([1, 0, 0], b, [2, 0, 0])).toBeCloseTo(1, 6); expect(angleCos([1, 0, 0], b, [-1, 0, 0])).toBeCloseTo(-1, 6); }); it('退化向量返回 0 而不产生 NaN', () => { expect(angleCos([0, 0, 0], [0, 0, 0], [1, 0, 0])).toBe(0); }); }); describe('239 字节二进制帧编解码(契约 websocket-keypoints.md)', () => { const makePoints = (): Array<[number, number, number, number]> => Array.from({ length: KEYPOINT_COUNT }, (_, i) => [ (i % 33) / 32, 1 - (i % 33) / 32, ((i % 7) - 3) / 10, i % 2 === 0 ? 1 : 0.5, ]); it('编码结果恰好 239 字节且 magic/版本正确', () => { const buf = encodeBinaryFrame(42, makePoints(), 1); expect(buf).toHaveLength(BINARY_FRAME_BYTES); expect(buf.readUInt16LE(0)).toBe(0x4f44); expect(buf.readUInt8(2)).toBe(1); expect(buf.readUInt8(3)).toBe(1); expect(buf.readUInt32LE(4)).toBe(42); }); it('编解码往返误差小于定点精度', () => { const points = makePoints(); const decoded = decodeBinaryFrame(encodeBinaryFrame(7, points, 0)); expect(decoded.frameId).toBe(7); expect(decoded.points).toHaveLength(33); points.forEach((original, i) => { for (let axis = 0; axis < 3; axis += 1) { expect(decoded.points[i][axis]).toBeCloseTo(original[axis], 3); } expect(decoded.points[i][3]).toBeCloseTo(original[3], 2); }); }); it('拒绝错误长度与错误 magic', () => { expect(() => decodeBinaryFrame(Buffer.alloc(238))).toThrow(/239/); const bad = Buffer.alloc(BINARY_FRAME_BYTES); expect(() => decodeBinaryFrame(bad)).toThrow(/magic/); }); it('极值不会溢出 int16', () => { const points = makePoints(); points[0] = [1, 1, -1, 1]; points[1] = [0, 0, 1, 0]; const decoded = decodeBinaryFrame(encodeBinaryFrame(0, points)); expect(decoded.points[0][0]).toBeCloseTo(1, 4); expect(decoded.points[0][2]).toBeCloseTo(-1, 4); }); }); describe('NDJSON 关键点序列', () => { it('序列化后可解析回原结构', () => { const motion = parseBvh(sampleText); const frames = bvhToKeypointFrames(motion).slice(0, 5); const ndjson = serializeFramesNdjson(frames); expect(ndjson.trim().split('\n')).toHaveLength(5); const parsed = parseFramesNdjson(ndjson); expect(parsed).toHaveLength(5); // JSON 往返会把 -0 变成 0,因此逐元素按精度比较 parsed[0].points.forEach((point, i) => { point.forEach((value, axis) => { expect(value).toBeCloseTo(frames[0].points[i][axis], 10); }); }); parsed[4].angles.forEach((value, i) => { expect(value).toBeCloseTo(frames[4].angles[i], 10); }); expect(parsed[4].points).toHaveLength(KEYPOINT_COUNT); }); it('拒绝点数不足的帧', () => { expect(() => parseFramesNdjson('{"frame":0,"timeMs":0,"points":[[0,0,0,1]],"angles":[]}')).toThrow(/33/); }); it('拒绝非法 JSON 行', () => { expect(() => parseFramesNdjson('{oops}')).toThrow(/合法 JSON/); }); }); describe('契约版本一致性', () => { it('解析 contracts/VERSION 的键值', () => { const parsed = parseVersionFile('# 注释\nrest-api: 0.1.0\n\ndatabase: 0.1.0\n'); expect(parsed).toEqual({ 'rest-api': '0.1.0', database: '0.1.0' }); }); it('代码内常量与 contracts/VERSION 完全一致(否则 npm run contracts:check 会失败)', () => { const versionFile = join(__dirname, '..', '..', 'contracts', 'VERSION'); const fileVersions = parseVersionFile(readFileSync(versionFile, 'utf8')); expect(fileVersions).toEqual({ ...CONTRACT_VERSIONS }); }); });