Files

253 lines
9.0 KiB
TypeScript
Raw Permalink 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.
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 });
});
});