feat(web): T-WEB-006 BVH/FBX → MediaPipe33 关键点预处理

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent 73e793318e
commit a2e3282603
12 changed files with 1799 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
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 });
});
});