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