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
+62
View File
@@ -0,0 +1,62 @@
import { BadRequestException } from '@nestjs/common';
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { basename, extname, join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export const ASSIMP_CONVERT_TIMEOUT_MS = 120_000;
/**
* 使用外部 Assimp 可执行文件把 FBX / glTF 转换为 BVH。
* Assimp 是**可选**依赖(契约 model-formats.md §2):
* 未安装时抛出可读错误,由调用方落为 preprocessState=failed,不影响 BVH 通路。
*/
export async function convertToBvhWithAssimp(
inputPath: string,
outputDir: string,
assimpBin: string,
): Promise<string> {
if (!existsSync(inputPath)) {
throw new BadRequestException(`源文件不存在:${inputPath}`);
}
const outPath = join(outputDir, `${basename(inputPath, extname(inputPath))}-assimp.bvh`);
try {
await execFileAsync(assimpBin, ['export', inputPath, outPath], {
timeout: ASSIMP_CONVERT_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024,
});
} catch (error) {
const err = error as NodeJS.ErrnoException & { stderr?: string };
if (err.code === 'ENOENT') {
throw new BadRequestException(
`未找到 Assimp 可执行文件「${assimpBin}」。FBX / glTF 动作预处理需要 Assimp(BSD-3-Clause),` +
'请安装后设置 ASSIMP_BIN 环境变量;BVH 动作无需 Assimp。',
);
}
const detail = (err.stderr ?? err.message ?? '').toString().trim().slice(0, 500);
throw new BadRequestException(`Assimp 转换失败:${detail || '未知错误'}`);
}
if (!existsSync(outPath)) {
throw new BadRequestException('Assimp 未产出 BVH 文件,可能源文件中不含骨骼动画');
}
return outPath;
}
/** 探测 Assimp 是否可用(用于 /health 与部署自检) */
export async function detectAssimp(assimpBin: string): Promise<{ available: boolean; version?: string; error?: string }> {
try {
const { stdout } = await execFileAsync(assimpBin, ['version'], { timeout: 10_000 });
const version = stdout.split(/\r?\n/).find((line) => line.toLowerCase().includes('assimp')) ?? stdout.trim();
return { available: true, version: version.trim() };
} catch (error) {
const err = error as NodeJS.ErrnoException;
return {
available: false,
error: err.code === 'ENOENT' ? `未安装(${assimpBin})` : (err.message ?? '未知错误'),
};
}
}
+227
View File
@@ -0,0 +1,227 @@
import { BadRequestException } from '@nestjs/common';
import { Mat3, Vec3, composeRotation, IDENTITY3, matMul, matVec } from './math3d';
export interface BvhJoint {
name: string;
offset: Vec3;
channels: string[];
/** 在该帧通道值数组中的起始下标 */
channelIndex: number;
parent: BvhJoint | null;
children: BvhJoint[];
}
export interface BvhMotion {
root: BvhJoint;
/** 先序展开的全部关节(不含 End Site) */
joints: BvhJoint[];
frameCount: number;
frameTime: number;
/** frames[frameIndex][channelIndex] = 通道值 */
frames: number[][];
channelCount: number;
/** 关节名(小写)→ 关节 */
byName: Map<string, BvhJoint>;
}
const CHANNEL_AXIS = /^([XYZ])(position|rotation)$/i;
function tokenize(text: string): string[] {
const tokens = text.match(/[^\s{}]+|[{}]/g);
return tokens ?? [];
}
/**
* BVH 解析器(纯 TypeScript,无原生依赖)。
* 仅支持标准 BVH:HIERARCHY + MOTION,End Site 视为叶子不产生关节。
*/
export function parseBvh(text: string): BvhMotion {
const tokens = tokenize(text);
let pos = 0;
const peek = (): string | undefined => tokens[pos];
const next = (): string => {
const token = tokens[pos];
if (token === undefined) throw new BadRequestException('BVH 解析失败:内容意外结束');
pos += 1;
return token;
};
const expect = (value: string): void => {
const token = next();
if (token !== value) {
throw new BadRequestException(`BVH 解析失败:期望 "${value}",实际 "${token}"`);
}
};
if ((peek() ?? '').toUpperCase() !== 'HIERARCHY') {
throw new BadRequestException('BVH 解析失败:缺少 HIERARCHY 段');
}
next();
const joints: BvhJoint[] = [];
let channelCursor = 0;
const parseNode = (parent: BvhJoint | null): BvhJoint => {
const kind = next().toUpperCase();
if (kind !== 'ROOT' && kind !== 'JOINT') {
throw new BadRequestException(`BVH 解析失败:期望 ROOT/JOINT,实际 ${kind}`);
}
const name = next();
const joint: BvhJoint = {
name,
offset: [0, 0, 0],
channels: [],
channelIndex: channelCursor,
parent,
children: [],
};
if (parent) parent.children.push(joint);
joints.push(joint);
expect('{');
for (;;) {
const token = next();
const upper = token.toUpperCase();
if (upper === '}') break;
if (upper === 'OFFSET') {
joint.offset = [Number(next()), Number(next()), Number(next())];
if (joint.offset.some((n) => !Number.isFinite(n))) {
throw new BadRequestException(`BVH 解析失败:关节 ${name} 的 OFFSET 非法`);
}
} else if (upper === 'CHANNELS') {
const count = Number(next());
if (!Number.isInteger(count) || count <= 0) {
throw new BadRequestException(`BVH 解析失败:关节 ${name} 的通道数非法`);
}
const list: string[] = [];
for (let i = 0; i < count; i += 1) {
const channel = next();
if (!CHANNEL_AXIS.test(channel)) {
throw new BadRequestException(`BVH 解析失败:未知通道 ${channel}`);
}
list.push(channel);
}
joint.channels = list;
joint.channelIndex = channelCursor;
channelCursor += count;
} else if (upper === 'JOINT') {
pos -= 1;
parseNode(joint);
} else if (upper === 'END') {
// End Site { OFFSET x y z }
expect('Site');
expect('{');
for (;;) {
const inner = next();
if (inner === '}') break;
if (inner.toUpperCase() === 'OFFSET') {
next();
next();
next();
}
}
} else {
throw new BadRequestException(`BVH 解析失败:关节 ${name} 中出现未知标记 ${token}`);
}
}
return joint;
};
const root = parseNode(null);
expect('MOTION');
expect('Frames:');
const frameCount = Number(next());
expect('Frame');
expect('Time:');
const frameTime = Number(next());
if (!Number.isInteger(frameCount) || frameCount <= 0) {
throw new BadRequestException('BVH 解析失败:Frames 数量非法');
}
if (!Number.isFinite(frameTime) || frameTime <= 0) {
throw new BadRequestException('BVH 解析失败:Frame Time 非法');
}
const flat: number[] = [];
while (pos < tokens.length) {
const value = Number(tokens[pos]);
pos += 1;
if (Number.isFinite(value)) flat.push(value);
}
const expected = frameCount * channelCursor;
if (flat.length < expected) {
throw new BadRequestException(
`BVH 解析失败:期望 ${expected} 个通道值(${frameCount} 帧 × ${channelCursor} 通道),实际 ${flat.length}`,
);
}
const frames: number[][] = [];
for (let f = 0; f < frameCount; f += 1) {
frames.push(flat.slice(f * channelCursor, (f + 1) * channelCursor));
}
const byName = new Map<string, BvhJoint>();
for (const joint of joints) {
const key = joint.name.toLowerCase();
if (!byName.has(key)) byName.set(key, joint);
}
return { root, joints, frameCount, frameTime, frames, channelCount: channelCursor, byName };
}
export interface JointWorld {
name: string;
position: Vec3;
rotation: Mat3;
}
/** 读取关节在某帧的通道值,并按通道名拆分为平移/旋转 */
function readChannels(joint: BvhJoint, frame: number[]): { translation: Vec3; rotations: Array<{ axis: string; value: number }> } {
const translation: Vec3 = [0, 0, 0];
const rotations: Array<{ axis: string; value: number }> = [];
joint.channels.forEach((channel, i) => {
const match = CHANNEL_AXIS.exec(channel);
const value = frame[joint.channelIndex + i] ?? 0;
if (!match) return;
const axis = match[1].toUpperCase();
if (match[2].toLowerCase() === 'position') {
if (axis === 'X') translation[0] = value;
else if (axis === 'Y') translation[1] = value;
else translation[2] = value;
} else {
rotations.push({ axis, value });
}
});
return { translation, rotations };
}
/**
* 逐帧前向运动学,返回关节世界坐标。
* 世界变换 = 父世界变换 × T(offset + 自身平移) × R(通道旋转)。
*/
export function forwardKinematics(motion: BvhMotion, frameIndex: number): Map<string, JointWorld> {
const frame = motion.frames[frameIndex];
const out = new Map<string, JointWorld>();
const walk = (joint: BvhJoint, parentR: Mat3, parentT: Vec3): void => {
const { translation, rotations } = readChannels(joint, frame);
const localR = composeRotation(rotations);
// 旋转矩阵相乘:worldR = parentR × localR
const worldR = matMul(parentR, localR);
const offset = joint.offset;
const shifted: Vec3 = [
offset[0] + translation[0],
offset[1] + translation[1],
offset[2] + translation[2],
];
const rotated = matVec(parentR, shifted);
const worldT: Vec3 = [parentT[0] + rotated[0], parentT[1] + rotated[1], parentT[2] + rotated[2]];
out.set(joint.name.toLowerCase(), { name: joint.name, position: worldT, rotation: worldR });
for (const child of joint.children) walk(child, worldR, worldT);
};
walk(motion.root, IDENTITY3, [0, 0, 0]);
return out;
}
+31
View File
@@ -0,0 +1,31 @@
import { MP } from './mediapipe33';
/**
* 默认 BVH 关节名映射 —— 契约 model-formats.md §2.2。
* 按序匹配(不区分大小写),第一个命中的关节被采用。
* 实际项目中可在本表追加;追加不改变契约,仅提升映射覆盖率。
*/
export const DEFAULT_JOINT_MAP: Readonly<Record<number, readonly string[]>> = Object.freeze({
[MP.NOSE]: ['head', 'head_nub', 'neck1', 'skull'],
[MP.LEFT_SHOULDER]: ['leftshoulder', 'leftarm', 'lshoulder', 'shoulder_l', 'l_arm'],
[MP.RIGHT_SHOULDER]: ['rightshoulder', 'rightarm', 'rshoulder', 'shoulder_r', 'r_arm'],
[MP.LEFT_ELBOW]: ['leftforearm', 'leftelbow', 'lelbow', 'forearm_l'],
[MP.RIGHT_ELBOW]: ['rightforearm', 'rightelbow', 'relbow', 'forearm_r'],
[MP.LEFT_WRIST]: ['lefthand', 'leftwrist', 'lhand', 'wrist_l'],
[MP.RIGHT_WRIST]: ['righthand', 'rightwrist', 'rhand', 'wrist_r'],
[MP.LEFT_HIP]: ['leftupleg', 'lefthip', 'lhipjoint', 'hip_l', 'l_thigh'],
[MP.RIGHT_HIP]: ['rightupleg', 'righthip', 'rhipjoint', 'hip_r', 'r_thigh'],
[MP.LEFT_KNEE]: ['leftleg', 'leftknee', 'lknee', 'knee_l', 'l_shin'],
[MP.RIGHT_KNEE]: ['rightleg', 'rightknee', 'rknee', 'knee_r', 'r_shin'],
[MP.LEFT_ANKLE]: ['leftfoot', 'leftankle', 'lankle', 'ankle_l'],
[MP.RIGHT_ANKLE]: ['rightfoot', 'rightankle', 'rankle', 'ankle_r'],
[MP.LEFT_FOOT_INDEX]: ['lefttoebase', 'lefttoe', 'ltoe', 'toe_l'],
[MP.RIGHT_FOOT_INDEX]: ['righttoebase', 'righttoe', 'rtoe', 'toe_r'],
});
/** 直接映射的置信度 */
export const CONF_DIRECT = 1.0;
/** 由父节点推算的置信度 —— 契约 model-formats.md §2.2 */
export const CONF_DERIVED = 0.5;
/** 完全无法推算 */
export const CONF_MISSING = 0.0;
+147
View File
@@ -0,0 +1,147 @@
import { BadRequestException } from '@nestjs/common';
import { KEYPOINT_COUNT, KEYPOINT_FORMAT, KeypointFrame } from './mediapipe33';
/** 二进制帧总长度 —— 契约 websocket-keypoints.md §2(冻结) */
export const BINARY_FRAME_BYTES = 239;
export const MAGIC = 0x4f44; // 'OD'
export const PROTOCOL_VERSION = 0x01;
const SCALE = 32767;
export interface DecodedPoseFrame {
flags: number;
frameId: number;
points: Array<[number, number, number, number]>;
}
const clampInt16 = (v: number): number => (v < -32768 ? -32768 : v > 32767 ? 32767 : Math.round(v));
/** 编码 239 字节二进制帧 —— Android 端参考实现 */
export function encodeBinaryFrame(frameId: number, points: Array<[number, number, number, number]>, flags = 0): Buffer {
if (points.length !== KEYPOINT_COUNT) {
throw new BadRequestException(`关键点数量必须为 ${KEYPOINT_COUNT},实际 ${points.length}`);
}
const buf = Buffer.alloc(BINARY_FRAME_BYTES);
buf.writeUInt16LE(MAGIC, 0);
buf.writeUInt8(PROTOCOL_VERSION, 2);
buf.writeUInt8(flags & 0xff, 3);
buf.writeUInt32LE(frameId >>> 0, 4);
for (let i = 0; i < KEYPOINT_COUNT; i += 1) {
const offset = 8 + i * 7;
const [x, y, z, conf] = points[i];
buf.writeInt16LE(clampInt16(x * SCALE), offset);
buf.writeInt16LE(clampInt16(y * SCALE), offset + 2);
buf.writeInt16LE(clampInt16(z * SCALE), offset + 4);
buf.writeUInt8(Math.max(0, Math.min(255, Math.round(conf * 255))), offset + 6);
}
return buf;
}
/** 解码 239 字节二进制帧 —— Windows 端参考实现(T-WIN-004) */
export function decodeBinaryFrame(buf: Buffer): DecodedPoseFrame {
if (buf.length !== BINARY_FRAME_BYTES) {
throw new BadRequestException(`帧长度必须为 ${BINARY_FRAME_BYTES} 字节,实际 ${buf.length}`);
}
const magic = buf.readUInt16LE(0);
if (magic !== MAGIC) throw new BadRequestException('帧 magic 校验失败');
const version = buf.readUInt8(2);
if (version !== PROTOCOL_VERSION) {
throw new BadRequestException(`不支持的协议版本 ${version}`);
}
const flags = buf.readUInt8(3);
const frameId = buf.readUInt32LE(4);
const points: Array<[number, number, number, number]> = [];
for (let i = 0; i < KEYPOINT_COUNT; i += 1) {
const offset = 8 + i * 7;
points.push([
buf.readInt16LE(offset) / SCALE,
buf.readInt16LE(offset + 2) / SCALE,
buf.readInt16LE(offset + 4) / SCALE,
buf.readUInt8(offset + 6) / 255,
]);
}
return { flags, frameId, points };
}
/** 关键点序列 → NDJSON(契约 model-formats.md §2.1) */
export function serializeFramesNdjson(frames: KeypointFrame[]): string {
return frames
.map((frame) =>
JSON.stringify({
frame: frame.frame,
timeMs: frame.timeMs,
points: frame.points,
angles: frame.angles,
}),
)
.join('\n')
.concat('\n');
}
export function parseFramesNdjson(text: string): KeypointFrame[] {
return text
.split(/\r?\n/)
.filter((line) => line.trim().length > 0)
.map((line, index) => {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
throw new BadRequestException(`NDJSON 第 ${index + 1} 行不是合法 JSON`);
}
return validateFrame(parsed, index);
});
}
function validateFrame(input: unknown, index: number): KeypointFrame {
const obj = input as Partial<KeypointFrame>;
if (!obj || typeof obj !== 'object') {
throw new BadRequestException(`第 ${index} 帧不是对象`);
}
if (!Array.isArray(obj.points) || obj.points.length !== KEYPOINT_COUNT) {
throw new BadRequestException(`第 ${index} 帧的 points 必须为 ${KEYPOINT_COUNT} 项`);
}
const points = obj.points.map((point, i) => {
if (!Array.isArray(point) || point.length !== 4 || point.some((v) => typeof v !== 'number')) {
throw new BadRequestException(`第 ${index} 帧第 ${i} 个点必须为 [x, y, z, confidence] 四个数字`);
}
return [point[0], point[1], point[2], point[3]] as [number, number, number, number];
});
const angles = Array.isArray(obj.angles) && obj.angles.every((v) => typeof v === 'number')
? (obj.angles as number[])
: new Array(9).fill(0);
return {
frame: typeof obj.frame === 'number' ? obj.frame : index,
timeMs: typeof obj.timeMs === 'number' ? obj.timeMs : 0,
points,
angles,
};
}
/**
* 校验并规范化「已是 mediapipe33 结构」的 JSON 关键点文件。
* 接受两种形态:{ format, fps, frames: [...] } 或裸数组。
*/
export function parseKeypointsJson(text: string): { frames: KeypointFrame[]; fps?: number } {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new BadRequestException('关键点 JSON 解析失败');
}
if (Array.isArray(parsed)) {
return { frames: parsed.map((f, i) => validateFrame(f, i)) };
}
const obj = parsed as { format?: string; fps?: number; frames?: unknown };
if (!Array.isArray(obj.frames)) {
throw new BadRequestException('关键点 JSON 必须为数组或包含 frames 数组的对象');
}
if (obj.format && obj.format !== KEYPOINT_FORMAT) {
throw new BadRequestException(`不支持的 keypointFormat:${obj.format},期望 ${KEYPOINT_FORMAT}`);
}
return {
frames: obj.frames.map((f, i) => validateFrame(f, i)),
fps: typeof obj.fps === 'number' ? obj.fps : undefined,
};
}
+109
View File
@@ -0,0 +1,109 @@
export type Vec3 = [number, number, number];
/** 3×3 行主序矩阵 */
export type Mat3 = [number, number, number, number, number, number, number, number, number];
export const IDENTITY3: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
export function vAdd(a: Vec3, b: Vec3): Vec3 {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}
export function vSub(a: Vec3, b: Vec3): Vec3 {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
export function vScale(a: Vec3, s: number): Vec3 {
return [a[0] * s, a[1] * s, a[2] * s];
}
export function vMid(a: Vec3, b: Vec3): Vec3 {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
}
export function vLen(a: Vec3): number {
return Math.hypot(a[0], a[1], a[2]);
}
export function vNorm(a: Vec3): Vec3 {
const len = vLen(a);
if (len < 1e-9) return [0, 0, 0];
return [a[0] / len, a[1] / len, a[2] / len];
}
export function vDot(a: Vec3, b: Vec3): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
/** 向量 ba 与 bc 夹角的余弦,落在 [-1, 1] —— 契约 skeleton-topology.md §5 */
export function angleCos(a: Vec3, b: Vec3, c: Vec3): number {
const ba = vSub(a, b);
const bc = vSub(c, b);
const denom = vLen(ba) * vLen(bc);
if (denom < 1e-9) return 0;
return Math.max(-1, Math.min(1, vDot(ba, bc) / denom));
}
export function matMul(a: Mat3, b: Mat3): Mat3 {
const out = new Array<number>(9);
for (let r = 0; r < 3; r += 1) {
for (let c = 0; c < 3; c += 1) {
out[r * 3 + c] = a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c];
}
}
return out as Mat3;
}
export function matVec(m: Mat3, v: Vec3): Vec3 {
return [
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
];
}
const DEG2RAD = Math.PI / 180;
/** 绕单轴旋转矩阵,axis ∈ {X, Y, Z} */
export function rotationMatrix(axis: string, degrees: number): Mat3 {
const rad = degrees * DEG2RAD;
const c = Math.cos(rad);
const s = Math.sin(rad);
switch (axis.toUpperCase()) {
case 'X':
return [1, 0, 0, 0, c, -s, 0, s, c];
case 'Y':
return [c, 0, s, 0, 1, 0, -s, 0, c];
case 'Z':
return [c, -s, 0, s, c, 0, 0, 0, 1];
default:
return IDENTITY3;
}
}
/**
* 由通道序列合成局部旋转矩阵。
* BVH 约定:按通道声明顺序依次左乘,即 R = R1 × R2 × R3(内旋)。
*/
export function composeRotation(channels: ReadonlyArray<{ axis: string; value: number }>): Mat3 {
let out: Mat3 = IDENTITY3;
for (const { axis, value } of channels) {
out = matMul(out, rotationMatrix(axis, value));
}
return out;
}
/** 世界变换:旋转 + 平移 */
export interface Transform {
r: Mat3;
t: Vec3;
}
export const IDENTITY_TRANSFORM: Transform = { r: IDENTITY3, t: [0, 0, 0] };
export function applyTransform(parent: Transform, offset: Vec3, localR: Mat3, extraT?: Vec3): Transform {
return {
r: matMul(parent.r, localR),
t: vAdd(parent.t, matVec(parent.r, vAdd(offset, extraT ?? [0, 0, 0]))),
};
}
+117
View File
@@ -0,0 +1,117 @@
/** 33 点索引常量 —— 契约 skeleton-topology.md §1 */
export const MP = {
NOSE: 0,
LEFT_EYE_INNER: 1,
LEFT_EYE: 2,
LEFT_EYE_OUTER: 3,
RIGHT_EYE_INNER: 4,
RIGHT_EYE: 5,
RIGHT_EYE_OUTER: 6,
LEFT_EAR: 7,
RIGHT_EAR: 8,
MOUTH_LEFT: 9,
MOUTH_RIGHT: 10,
LEFT_SHOULDER: 11,
RIGHT_SHOULDER: 12,
LEFT_ELBOW: 13,
RIGHT_ELBOW: 14,
LEFT_WRIST: 15,
RIGHT_WRIST: 16,
LEFT_PINKY: 17,
RIGHT_PINKY: 18,
LEFT_INDEX: 19,
RIGHT_INDEX: 20,
LEFT_THUMB: 21,
RIGHT_THUMB: 22,
LEFT_HIP: 23,
RIGHT_HIP: 24,
LEFT_KNEE: 25,
RIGHT_KNEE: 26,
LEFT_ANKLE: 27,
RIGHT_ANKLE: 28,
LEFT_HEEL: 29,
RIGHT_HEEL: 30,
LEFT_FOOT_INDEX: 31,
RIGHT_FOOT_INDEX: 32,
} as const;
export const KEYPOINT_COUNT = 33;
/** 虚拟关节索引 —— skeleton-topology.md §2 */
export const VIRTUAL = { NECK: 33, PELVIS: 34 } as const;
/** 父节点表 —— skeleton-topology.md §3,索引到 34 */
export const PARENT_TABLE: readonly number[] = [
33, // 0 nose
0, // 1 left_eye_inner
0, // 2 left_eye
0, // 3 left_eye_outer
0, // 4 right_eye_inner
0, // 5 right_eye
0, // 6 right_eye_outer
3, // 7 left_ear
6, // 8 right_ear
0, // 9 mouth_left
0, // 10 mouth_right
33, // 11 left_shoulder
33, // 12 right_shoulder
11, // 13 left_elbow
12, // 14 right_elbow
13, // 15 left_wrist
14, // 16 right_wrist
15, // 17 left_pinky
16, // 18 right_pinky
15, // 19 left_index
16, // 20 right_index
15, // 21 left_thumb
16, // 22 right_thumb
34, // 23 left_hip
34, // 24 right_hip
23, // 25 left_knee
24, // 26 right_knee
25, // 27 left_ankle
26, // 28 right_ankle
27, // 29 left_heel
28, // 30 right_heel
29, // 31 left_foot_index
30, // 32 right_foot_index
34, // 33 neck
-1, // 34 pelvis (world root)
];
/** 关节角特征使用的 (a, b, c) 三元组,顺序冻结 —— skeleton-topology.md §5 */
export const ANGLE_TRIPLETS: ReadonlyArray<readonly [number, number, number]> = [
[MP.LEFT_ELBOW, MP.LEFT_SHOULDER, MP.LEFT_HIP], // shoulder_L
[MP.RIGHT_ELBOW, MP.RIGHT_SHOULDER, MP.RIGHT_HIP], // shoulder_R
[MP.LEFT_SHOULDER, MP.LEFT_ELBOW, MP.LEFT_WRIST], // elbow_L
[MP.RIGHT_SHOULDER, MP.RIGHT_ELBOW, MP.RIGHT_WRIST], // elbow_R
[MP.LEFT_SHOULDER, MP.LEFT_HIP, MP.LEFT_KNEE], // hip_L
[MP.RIGHT_SHOULDER, MP.RIGHT_HIP, MP.RIGHT_KNEE], // hip_R
[MP.LEFT_HIP, MP.LEFT_KNEE, MP.LEFT_ANKLE], // knee_L
[MP.RIGHT_HIP, MP.RIGHT_KNEE, MP.RIGHT_ANKLE], // knee_R
[MP.NOSE, VIRTUAL.NECK, VIRTUAL.PELVIS], // spine/neck
];
export const ANGLE_NAMES = [
'shoulder_L',
'shoulder_R',
'elbow_L',
'elbow_R',
'hip_L',
'hip_R',
'knee_L',
'knee_R',
'spine',
] as const;
/** 单帧关键点:[x, y, z, confidence] */
export type Keypoint = [number, number, number, number];
export interface KeypointFrame {
frame: number;
timeMs: number;
points: Keypoint[];
angles: number[];
}
export const KEYPOINT_FORMAT = 'mediapipe33@0.1.0';
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Motion } from '../entities/motion.entity';
import { StorageModule } from '../storage/storage.module';
import { PreprocessService } from './preprocess.service';
@Module({
imports: [TypeOrmModule.forFeature([Motion]), StorageModule],
providers: [PreprocessService],
exports: [PreprocessService],
})
export class PreprocessModule {}
@@ -0,0 +1,144 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Repository } from 'typeorm';
import { AssetKind, MotionFormat, PreprocessState } from '../common/enums';
import { AppConfig } from '../config/configuration';
import { Motion } from '../entities/motion.entity';
import { StorageService } from '../storage/storage.service';
import { parseBvh } from './bvh-parser';
import { parseKeypointsJson, serializeFramesNdjson } from './keypoint-codec';
import { KEYPOINT_FORMAT, KeypointFrame } from './mediapipe33';
import { bvhToKeypointFrames } from './retarget';
import { convertToBvhWithAssimp } from './assimp';
export interface PreprocessOutcome {
frames: number;
fps: number;
durationMs: number;
format: string;
}
/**
* T-WEB-006:BVH / FBX → MediaPipe 33 点关键点序列预处理。
*
* - BVH:内置纯 TS 解析器 + FK + 重定向,无外部依赖
* - glTF / GLB / FBX:调用可选的外部 Assimp 转为 BVH 后再走同一通路
* - keypoints-json:直通校验与规范化
*/
@Injectable()
export class PreprocessService {
private readonly logger = new Logger(PreprocessService.name);
constructor(
@InjectRepository(Motion) private readonly motions: Repository<Motion>,
private readonly storage: StorageService,
private readonly config: AppConfig,
) {}
/** 执行预处理并落库;异常会被记录到 preprocessError 后重新抛出 */
async run(motion: Motion): Promise<Motion> {
const started = Date.now();
motion.preprocessState = PreprocessState.Running;
motion.preprocessError = null;
await this.motions.save(motion);
try {
const result = await this.convert(motion);
const ndjson = serializeFramesNdjson(result.frames);
const stored = await this.storage.persistBuffer(
AssetKind.Keypoints,
ndjson,
`motion-${motion.id}.ndjson`,
'application/x-ndjson',
);
motion.keypointFormat = KEYPOINT_FORMAT;
motion.keypointAssetId = stored.asset.id;
motion.keypointFrames = result.frames.length;
motion.fps = result.fps;
motion.frameCount = result.frames.length;
motion.durationMs = result.durationMs;
motion.preprocessState = PreprocessState.Done;
motion.preprocessError = null;
const saved = await this.motions.save(motion);
this.logger.log(
`动作 #${motion.id} 预处理完成:${result.frames.length} 帧,${Date.now() - started}ms`,
);
return saved;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
motion.preprocessState = PreprocessState.Failed;
motion.preprocessError = message.slice(0, 2000);
await this.motions.save(motion);
this.logger.error(`动作 #${motion.id} 预处理失败:${message}`);
throw error;
}
}
/** 读取已完成预处理的关键点 NDJSON */
async readKeypointsNdjson(motion: Motion): Promise<string> {
if (motion.preprocessState !== PreprocessState.Done || !motion.keypointAssetId) {
throw new BadRequestException('该动作尚未完成关键点预处理,请先调用 /preprocess');
}
const asset = await this.storage.findById(motion.keypointAssetId);
if (!asset) throw new NotFoundException('关键点文件已丢失');
return this.storage.readAssetText(asset);
}
private async convert(
motion: Motion,
): Promise<{ frames: KeypointFrame[]; fps: number; durationMs: number }> {
const asset = await this.storage.findById(motion.fileAssetId);
if (!asset) throw new BadRequestException('动作源文件缺失,无法预处理');
const sourcePath = this.storage.absolutePath(asset);
let workDir: string | null = null;
try {
if (motion.format === MotionFormat.Bvh) {
const text = await readFile(sourcePath, 'utf8');
return this.fromBvh(text, motion.fps ?? undefined);
}
if (motion.format === MotionFormat.KeypointsJson) {
const text = await readFile(sourcePath, 'utf8');
const { frames, fps } = parseKeypointsJson(text);
if (frames.length === 0) throw new BadRequestException('关键点 JSON 中没有任何帧');
const effectiveFps = fps ?? motion.fps ?? 30;
return {
frames: frames.map((frame, index) => ({
...frame,
frame: index,
timeMs: frame.timeMs || Math.round((index / effectiveFps) * 1000 * 1000) / 1000,
})),
fps: effectiveFps,
durationMs: Math.round((frames.length / effectiveFps) * 1000),
};
}
// FBX / glTF / GLB:交给可选的外部 Assimp
workDir = await mkdtemp(join(tmpdir(), 'opendance-assimp-'));
const bvhPath = await convertToBvhWithAssimp(sourcePath, workDir, this.config.assimpBin);
const text = await readFile(bvhPath, 'utf8');
return this.fromBvh(text, motion.fps ?? undefined);
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true });
}
}
private fromBvh(text: string, fpsOverride?: number): { frames: KeypointFrame[]; fps: number; durationMs: number } {
const motion = parseBvh(text);
if (motion.frameCount === 0) throw new BadRequestException('BVH 中没有帧');
const frames = bvhToKeypointFrames(motion, { fps: fpsOverride });
const fps = fpsOverride && fpsOverride > 0 ? fpsOverride : 1 / motion.frameTime;
return {
frames,
fps: Math.round(fps * 1000) / 1000,
durationMs: Math.round((frames.length / fps) * 1000),
};
}
}
+317
View File
@@ -0,0 +1,317 @@
import { Vec3, vAdd, vMid, vNorm, vScale, vSub, angleCos, vLen } from './math3d';
import { BvhMotion, forwardKinematics } from './bvh-parser';
import { CONF_DERIVED, CONF_DIRECT, CONF_MISSING, DEFAULT_JOINT_MAP } from './joint-map';
import {
ANGLE_TRIPLETS,
KEYPOINT_COUNT,
KeypointFrame,
MP,
VIRTUAL,
} from './mediapipe33';
export interface RetargetOptions {
/** 源帧率;缺省由 frameTime 推导 */
fps?: number;
/** 自适应缩放系数:投影后关键点范围为 ±scaleFactor */
scaleFactor?: number;
/** 关节名映射覆盖(可选) */
jointMap?: Record<number, readonly string[]>;
}
interface RawPoint {
p: Vec3;
conf: number;
}
const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
const clampPm1 = (v: number): number => (v < -1 ? -1 : v > 1 ? 1 : v);
/** 规范化单人骨骼体积内的合理躯干长度,避免退化输入导致除零 */
const MIN_TORSO = 1e-3;
/**
* BVH 动作 → MediaPipe 33 点关键点序列。
*
* 流程:逐帧 FK → 直接映射 15 个主关节点 → 按层级推算其余点 →
* 骨盆归零 → 全局自适应缩放 → 投影到 MediaPipe 归一化图像空间 → 计算 9 个关节角。
*/
export function bvhToKeypointFrames(motion: BvhMotion, options: RetargetOptions = {}): KeypointFrame[] {
const jointMap = options.jointMap ?? DEFAULT_JOINT_MAP;
const scaleFactor = options.scaleFactor ?? 0.35;
const fps = options.fps && options.fps > 0 ? options.fps : 1 / motion.frameTime;
// 第一遍:逐帧解算原始 3D 位置(骨盆归零前)
const rawFrames: RawPoint[][] = [];
let maxAbs = 0;
for (let f = 0; f < motion.frameCount; f += 1) {
const world = forwardKinematics(motion, f);
const positions = new Map<string, Vec3>();
for (const [name, joint] of world) positions.set(name, joint.position);
const points = resolveFrame(positions, jointMap);
// 骨盆归零
const pelvis = points[VIRTUAL.PELVIS].p;
for (const point of points) point.p = vSub(point.p, pelvis);
for (const point of points) {
maxAbs = Math.max(maxAbs, Math.abs(point.p[0]), Math.abs(point.p[1]), Math.abs(point.p[2]));
}
rawFrames.push(points);
}
const scale = maxAbs > 1e-9 ? scaleFactor / maxAbs : 1;
// 第二遍:投影 + 关节角
const frames: KeypointFrame[] = [];
for (let f = 0; f < rawFrames.length; f += 1) {
const points = rawFrames[f];
const positions = points.map((entry) => entry.p);
// points 含 33 个真实点 + 2 个虚拟关节(neck=33 / pelvis=34),
// 虚拟关节只参与关节角计算,不进入输出序列。
const projected = points.slice(0, KEYPOINT_COUNT).map(({ p, conf }): [number, number, number, number] => [
clamp01(0.5 + p[0] * scale),
clamp01(0.5 - p[1] * scale),
clampPm1(-p[2] * scale),
conf,
]);
const angles = ANGLE_TRIPLETS.map(([a, b, c]) => {
const pa = positions[a];
const pb = positions[b];
const pc = positions[c];
if (!pa || !pb || !pc) return 0;
return angleCos(pa, pb, pc);
});
frames.push({
frame: f,
timeMs: Math.round((f / fps) * 1000 * 1000) / 1000,
points: projected,
angles,
});
}
return frames;
}
/**
* 解算单帧的 **35** 项:索引 0..32 为 MediaPipe 33 点,33 = neck、34 = pelvis 为虚拟关节。
* 调用方只输出前 33 项,虚拟关节用于关节角特征。
* 依赖顺序:躯干 → 肩髋 → 肘/膝 → 腕/踝 → 头/手足末端。
*/
function resolveFrame(
world: Map<string, Vec3>,
jointMap: Record<number, readonly string[]>,
): RawPoint[] {
const lookup = (index: number): Vec3 | null => {
const candidates = jointMap[index];
if (!candidates) return null;
for (const name of candidates) {
const position = world.get(name);
if (position) return position;
}
return null;
};
const points: Array<RawPoint | null> = new Array<RawPoint | null>(VIRTUAL.PELVIS + 1).fill(null);
const put = (index: number, p: Vec3, conf: number): void => {
if (!points[index]) points[index] = { p, conf };
};
// ---- 1. 直接映射 ----
for (const key of Object.keys(jointMap)) {
const index = Number(key);
const p = lookup(index);
if (p) put(index, p, CONF_DIRECT);
}
// 根关节兜底(多数 BVH 的 Hips)
const rootFallback =
world.get('hips') ?? world.get('root') ?? world.get('pelvis') ?? firstValue(world);
if (!rootFallback) throw new Error('BVH 中找不到任何关节位置');
// ---- 2. 躯干 ----
if (!points[MP.LEFT_HIP]) put(MP.LEFT_HIP, vAdd(rootFallback, [torsoGuess(world) * 0.09, 0, 0]), CONF_DERIVED);
if (!points[MP.RIGHT_HIP]) put(MP.RIGHT_HIP, vAdd(rootFallback, [-torsoGuess(world) * 0.09, 0, 0]), CONF_DERIVED);
const pelvis = vMid(points[MP.LEFT_HIP]!.p, points[MP.RIGHT_HIP]!.p);
put(VIRTUAL.PELVIS, pelvis, Math.min(points[MP.LEFT_HIP]!.conf, points[MP.RIGHT_HIP]!.conf));
// 肩:缺失时按直立姿态从骨盆向上、向两侧推算
const rawTorso = vLen(vSub(pelvis, vMid(
points[MP.LEFT_SHOULDER]?.p ?? pelvis,
points[MP.RIGHT_SHOULDER]?.p ?? pelvis,
)));
const torso = rawTorso > MIN_TORSO ? rawTorso : torsoGuess(world);
if (!points[MP.LEFT_SHOULDER]) {
put(MP.LEFT_SHOULDER, vAdd(pelvis, [torso * 0.22, torso * 0.95, 0]), CONF_DERIVED);
}
if (!points[MP.RIGHT_SHOULDER]) {
put(MP.RIGHT_SHOULDER, vAdd(pelvis, [-torso * 0.22, torso * 0.95, 0]), CONF_DERIVED);
}
const neck = vMid(points[MP.LEFT_SHOULDER]!.p, points[MP.RIGHT_SHOULDER]!.p);
put(
VIRTUAL.NECK,
neck,
Math.min(points[MP.LEFT_SHOULDER]!.conf, points[MP.RIGHT_SHOULDER]!.conf),
);
const up: Vec3 = (() => {
const candidate = vNorm(vSub(neck, pelvis));
return vLen(candidate) > 0 ? candidate : [0, 1, 0];
})();
// ---- 3. 头 ----
if (!points[MP.NOSE]) {
put(MP.NOSE, vAdd(neck, vScale(up, torso * 0.45)), CONF_DERIVED);
}
const nose = points[MP.NOSE]!.p;
const headScale = torso * 0.30;
const headOffsets: Array<[number, [number, number, number]]> = [
[MP.LEFT_EYE_INNER, [0.16, 0.06, -0.20]],
[MP.LEFT_EYE, [0.26, 0.06, -0.16]],
[MP.LEFT_EYE_OUTER, [0.36, 0.06, -0.10]],
[MP.RIGHT_EYE_INNER, [-0.16, 0.06, -0.20]],
[MP.RIGHT_EYE, [-0.26, 0.06, -0.16]],
[MP.RIGHT_EYE_OUTER, [-0.36, 0.06, -0.10]],
[MP.LEFT_EAR, [0.48, 0.0, 0.04]],
[MP.RIGHT_EAR, [-0.48, 0.0, 0.04]],
[MP.MOUTH_LEFT, [0.16, -0.18, -0.24]],
[MP.MOUTH_RIGHT, [-0.16, -0.18, -0.24]],
];
for (const [index, offset] of headOffsets) {
if (!points[index]) {
put(
index,
vAdd(nose, [offset[0] * headScale, offset[1] * headScale, offset[2] * headScale]),
CONF_DERIVED,
);
}
}
// ---- 4. 四肢:肘 / 膝(缺失时沿规范方向推算)----
const armLen = { upper: torso * 0.62, fore: torso * 0.55, hand: torso * 0.22 };
const legLen = { thigh: torso * 0.92, shin: torso * 0.88, foot: torso * 0.38 };
const limbChains: Array<{
proximal: number;
mid: number;
distal: number;
end: number;
sign: number;
lens: { a: number; b: number; c: number };
}> = [
{
proximal: MP.LEFT_SHOULDER,
mid: MP.LEFT_ELBOW,
distal: MP.LEFT_WRIST,
end: MP.LEFT_INDEX,
sign: 1,
lens: { a: armLen.upper, b: armLen.fore, c: armLen.hand },
},
{
proximal: MP.RIGHT_SHOULDER,
mid: MP.RIGHT_ELBOW,
distal: MP.RIGHT_WRIST,
end: MP.RIGHT_INDEX,
sign: -1,
lens: { a: armLen.upper, b: armLen.fore, c: armLen.hand },
},
{
proximal: MP.LEFT_HIP,
mid: MP.LEFT_KNEE,
distal: MP.LEFT_ANKLE,
end: MP.LEFT_FOOT_INDEX,
sign: 1,
lens: { a: legLen.thigh, b: legLen.shin, c: legLen.foot },
},
{
proximal: MP.RIGHT_HIP,
mid: MP.RIGHT_KNEE,
distal: MP.RIGHT_ANKLE,
end: MP.RIGHT_FOOT_INDEX,
sign: -1,
lens: { a: legLen.thigh, b: legLen.shin, c: legLen.foot },
},
];
for (const chain of limbChains) {
if (!points[chain.mid]) {
const dir = vNorm([chain.sign * 0.22, -1, 0]);
put(chain.mid, vAdd(points[chain.proximal]!.p, vScale(dir, chain.lens.a)), CONF_DERIVED);
}
if (!points[chain.distal]) {
const dir = vNorm(vSub(points[chain.mid]!.p, points[chain.proximal]!.p));
const fallback: Vec3 = vLen(dir) > 0 ? dir : [chain.sign * 0.2, -1, 0];
put(chain.distal, vAdd(points[chain.mid]!.p, vScale(fallback, chain.lens.b)), CONF_DERIVED);
}
}
// ---- 5. 手部末端(小指 / 食指 / 拇指)----
const handChains: Array<{ wrist: number; elbow: number; sign: number; pinky: number; index: number; thumb: number }> = [
{ wrist: MP.LEFT_WRIST, elbow: MP.LEFT_ELBOW, sign: 1, pinky: MP.LEFT_PINKY, index: MP.LEFT_INDEX, thumb: MP.LEFT_THUMB },
{ wrist: MP.RIGHT_WRIST, elbow: MP.RIGHT_ELBOW, sign: -1, pinky: MP.RIGHT_PINKY, index: MP.RIGHT_INDEX, thumb: MP.RIGHT_THUMB },
];
for (const chain of handChains) {
const wrist = points[chain.wrist]!.p;
const elbow = points[chain.elbow]!.p;
const dir = vNorm(vSub(wrist, elbow));
const forward: Vec3 = vLen(dir) > 0 ? dir : [chain.sign * 0.2, -1, 0];
const hand = torso * 0.20;
if (!points[chain.pinky]) {
put(chain.pinky, vAdd(wrist, vAdd(vScale(forward, hand * 0.35), [chain.sign * hand * 0.25, -hand * 0.2, 0])), CONF_DERIVED);
}
if (!points[chain.index]) {
put(chain.index, vAdd(wrist, vAdd(vScale(forward, hand * 0.55), [chain.sign * hand * 0.1, -hand * 0.08, 0])), CONF_DERIVED);
}
if (!points[chain.thumb]) {
put(chain.thumb, vAdd(wrist, vAdd(vScale(forward, hand * 0.25), [chain.sign * -hand * 0.3, -hand * 0.1, 0])), CONF_DERIVED);
}
}
// ---- 6. 足跟(足尖已在链尾解算)----
const footChains: Array<{ ankle: number; toe: number; heel: number; sign: number }> = [
{ ankle: MP.LEFT_ANKLE, toe: MP.LEFT_FOOT_INDEX, heel: MP.LEFT_HEEL, sign: 1 },
{ ankle: MP.RIGHT_ANKLE, toe: MP.RIGHT_FOOT_INDEX, heel: MP.RIGHT_HEEL, sign: -1 },
];
for (const chain of footChains) {
if (!points[chain.toe]) {
const ankle = points[chain.ankle]!.p;
put(chain.toe, vAdd(ankle, [chain.sign * torso * 0.04, -legLen.foot * 0.5, -legLen.foot * 0.6]), CONF_DERIVED);
}
if (!points[chain.heel]) {
const ankle = points[chain.ankle]!.p;
put(chain.heel, vAdd(ankle, [chain.sign * torso * 0.03, -legLen.foot * 0.5, legLen.foot * 0.35]), CONF_DERIVED);
}
}
// ---- 7. 兜底 ----
for (let i = 0; i <= VIRTUAL.PELVIS; i += 1) {
if (!points[i]) {
points[i] = { p: [...pelvis] as Vec3, conf: CONF_MISSING };
}
}
return points as RawPoint[];
}
function firstValue(map: Map<string, Vec3>): Vec3 | undefined {
for (const value of map.values()) return value;
return undefined;
}
/** 无躯干信息时的尺度兜底:取根关节到最远关节的距离 */
function torsoGuess(world: Map<string, Vec3>): number {
const root = world.get('hips') ?? world.get('root') ?? world.get('pelvis');
if (!root) return 1;
let maxDistance = 0;
for (const position of world.values()) {
maxDistance = Math.max(maxDistance, vLen(vSub(position, root)));
}
return maxDistance > MIN_TORSO ? maxDistance : 1;
}
+197
View File
@@ -0,0 +1,197 @@
/**
* 生成用于测试与验收的 BVH 样例(60 帧挥手 + 深蹲)。
* 用法:node test/fixtures/generate-sample-bvh.mjs [输出路径]
*/
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const outPath = process.argv[2] ?? join(here, 'sample.bvh');
const HIERARCHY = `HIERARCHY
ROOT Hips
{
OFFSET 0.0 0.0 0.0
CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation
JOINT Spine
{
OFFSET 0.0 12.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT Neck
{
OFFSET 0.0 14.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT Head
{
OFFSET 0.0 8.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET 0.0 8.0 0.0
}
}
}
JOINT LeftShoulder
{
OFFSET 6.0 10.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftArm
{
OFFSET 12.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftForeArm
{
OFFSET 22.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftHand
{
OFFSET 20.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET 8.0 0.0 0.0
}
}
}
}
}
JOINT RightShoulder
{
OFFSET -6.0 10.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightArm
{
OFFSET -12.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightForeArm
{
OFFSET -22.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightHand
{
OFFSET -20.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET -8.0 0.0 0.0
}
}
}
}
}
}
JOINT LeftUpLeg
{
OFFSET 5.0 -3.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftLeg
{
OFFSET 0.0 -26.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftFoot
{
OFFSET 0.0 -25.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftToeBase
{
OFFSET 0.0 -4.0 10.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET 0.0 -2.0 6.0
}
}
}
}
}
JOINT RightUpLeg
{
OFFSET -5.0 -3.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightLeg
{
OFFSET 0.0 -26.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightFoot
{
OFFSET 0.0 -25.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightToeBase
{
OFFSET 0.0 -4.0 10.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET 0.0 -2.0 6.0
}
}
}
}
}
}
MOTION
`;
/** 通道顺序(与上面的 HIERARCHY 一致):Hips 6 通道 + 19 个关节各 3 通道 */
const JOINT_ORDER = [
'Hips',
'Spine',
'Neck',
'Head',
'LeftShoulder',
'LeftArm',
'LeftForeArm',
'LeftHand',
'RightShoulder',
'RightArm',
'RightForeArm',
'RightHand',
'LeftUpLeg',
'LeftLeg',
'LeftFoot',
'LeftToeBase',
'RightUpLeg',
'RightLeg',
'RightFoot',
'RightToeBase',
];
const CHANNELS = 6 + 3 * (JOINT_ORDER.length - 1);
const FRAMES = 60;
const FRAME_TIME = 1 / 30;
const lines = [];
for (let f = 0; f < FRAMES; f += 1) {
const t = f / FRAMES;
const phase = Math.sin(t * Math.PI * 2);
const squat = (1 - Math.cos(t * Math.PI * 2)) / 2;
const values = [];
for (const joint of JOINT_ORDER) {
if (joint === 'Hips') {
values.push(0, -squat * 12, 0); // Xposition Yposition Zposition
values.push(phase * 6, 0, 0); // Zrotation Xrotation Yrotation
continue;
}
const rot = { z: 0, x: 0, y: 0 };
if (joint === 'LeftArm') rot.z = 20 + phase * 55;
if (joint === 'RightArm') rot.z = -20 - phase * 55;
if (joint === 'LeftForeArm') rot.x = -25 - phase * 30;
if (joint === 'RightForeArm') rot.x = -25 + phase * 30;
if (joint === 'LeftUpLeg') rot.x = -squat * 35;
if (joint === 'RightUpLeg') rot.x = -squat * 35;
if (joint === 'LeftLeg') rot.x = squat * 60;
if (joint === 'RightLeg') rot.x = squat * 60;
if (joint === 'LeftFoot') rot.x = -squat * 25;
if (joint === 'RightFoot') rot.x = -squat * 25;
if (joint === 'Spine') rot.z = phase * 5;
if (joint === 'Neck') rot.z = -phase * 3;
values.push(rot.z, rot.x, rot.y);
}
if (values.length !== CHANNELS) throw new Error(`帧 ${f} 通道数错误:${values.length}`);
lines.push(values.map((v) => v.toFixed(4)).join(' '));
}
const text = `${HIERARCHY}Frames: ${FRAMES}\nFrame Time: ${FRAME_TIME.toFixed(7)}\n${lines.join('\n')}\n`;
writeFileSync(outPath, text, 'utf8');
console.log(`已生成 ${outPath}(${FRAMES} 帧,${CHANNELS} 通道,${text.length} 字节)`);
+184
View File
@@ -0,0 +1,184 @@
HIERARCHY
ROOT Hips
{
OFFSET 0.0 0.0 0.0
CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation
JOINT Spine
{
OFFSET 0.0 12.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT Neck
{
OFFSET 0.0 14.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT Head
{
OFFSET 0.0 8.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET 0.0 8.0 0.0
}
}
}
JOINT LeftShoulder
{
OFFSET 6.0 10.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftArm
{
OFFSET 12.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftForeArm
{
OFFSET 22.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftHand
{
OFFSET 20.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET 8.0 0.0 0.0
}
}
}
}
}
JOINT RightShoulder
{
OFFSET -6.0 10.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightArm
{
OFFSET -12.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightForeArm
{
OFFSET -22.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightHand
{
OFFSET -20.0 0.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET -8.0 0.0 0.0
}
}
}
}
}
}
JOINT LeftUpLeg
{
OFFSET 5.0 -3.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftLeg
{
OFFSET 0.0 -26.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftFoot
{
OFFSET 0.0 -25.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT LeftToeBase
{
OFFSET 0.0 -4.0 10.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET 0.0 -2.0 6.0
}
}
}
}
}
JOINT RightUpLeg
{
OFFSET -5.0 -3.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightLeg
{
OFFSET 0.0 -26.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightFoot
{
OFFSET 0.0 -25.0 0.0
CHANNELS 3 Zrotation Xrotation Yrotation
JOINT RightToeBase
{
OFFSET 0.0 -4.0 10.0
CHANNELS 3 Zrotation Xrotation Yrotation
End Site
{
OFFSET 0.0 -2.0 6.0
}
}
}
}
}
}
MOTION
Frames: 60
Frame Time: 0.0333333
0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 20.0000 0.0000 0.0000 0.0000 -25.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -20.0000 0.0000 0.0000 0.0000 -25.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000
0.0000 -0.0329 0.0000 0.6272 0.0000 0.0000 0.5226 0.0000 0.0000 -0.3136 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 25.7491 0.0000 0.0000 0.0000 -28.1359 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -25.7491 0.0000 0.0000 0.0000 -21.8641 0.0000 0.0000 0.0000 0.0000 0.0000 -0.0959 0.0000 0.0000 0.1643 0.0000 0.0000 -0.0685 0.0000 0.0000 0.0000 0.0000 0.0000 -0.0959 0.0000 0.0000 0.1643 0.0000 0.0000 -0.0685 0.0000 0.0000 0.0000 0.0000
0.0000 -0.1311 0.0000 1.2475 0.0000 0.0000 1.0396 0.0000 0.0000 -0.6237 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 31.4351 0.0000 0.0000 0.0000 -31.2374 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -31.4351 0.0000 0.0000 0.0000 -18.7626 0.0000 0.0000 0.0000 0.0000 0.0000 -0.3824 0.0000 0.0000 0.6556 0.0000 0.0000 -0.2732 0.0000 0.0000 0.0000 0.0000 0.0000 -0.3824 0.0000 0.0000 0.6556 0.0000 0.0000 -0.2732 0.0000 0.0000 0.0000 0.0000
0.0000 -0.2937 0.0000 1.8541 0.0000 0.0000 1.5451 0.0000 0.0000 -0.9271 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 36.9959 0.0000 0.0000 0.0000 -34.2705 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -36.9959 0.0000 0.0000 0.0000 -15.7295 0.0000 0.0000 0.0000 0.0000 0.0000 -0.8565 0.0000 0.0000 1.4683 0.0000 0.0000 -0.6118 0.0000 0.0000 0.0000 0.0000 0.0000 -0.8565 0.0000 0.0000 1.4683 0.0000 0.0000 -0.6118 0.0000 0.0000 0.0000 0.0000
0.0000 -0.5187 0.0000 2.4404 0.0000 0.0000 2.0337 0.0000 0.0000 -1.2202 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 42.3705 0.0000 0.0000 0.0000 -37.2021 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -42.3705 0.0000 0.0000 0.0000 -12.7979 0.0000 0.0000 0.0000 0.0000 0.0000 -1.5130 0.0000 0.0000 2.5936 0.0000 0.0000 -1.0807 0.0000 0.0000 0.0000 0.0000 0.0000 -1.5130 0.0000 0.0000 2.5936 0.0000 0.0000 -1.0807 0.0000 0.0000 0.0000 0.0000
0.0000 -0.8038 0.0000 3.0000 0.0000 0.0000 2.5000 0.0000 0.0000 -1.5000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 47.5000 0.0000 0.0000 0.0000 -40.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -47.5000 0.0000 0.0000 0.0000 -10.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -2.3446 0.0000 0.0000 4.0192 0.0000 0.0000 -1.6747 0.0000 0.0000 0.0000 0.0000 0.0000 -2.3446 0.0000 0.0000 4.0192 0.0000 0.0000 -1.6747 0.0000 0.0000 0.0000 0.0000
0.0000 -1.1459 0.0000 3.5267 0.0000 0.0000 2.9389 0.0000 0.0000 -1.7634 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 52.3282 0.0000 0.0000 0.0000 -42.6336 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -52.3282 0.0000 0.0000 0.0000 -7.3664 0.0000 0.0000 0.0000 0.0000 0.0000 -3.3422 0.0000 0.0000 5.7295 0.0000 0.0000 -2.3873 0.0000 0.0000 0.0000 0.0000 0.0000 -3.3422 0.0000 0.0000 5.7295 0.0000 0.0000 -2.3873 0.0000 0.0000 0.0000 0.0000
0.0000 -1.5411 0.0000 4.0148 0.0000 0.0000 3.3457 0.0000 0.0000 -2.0074 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 56.8022 0.0000 0.0000 0.0000 -45.0739 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -56.8022 0.0000 0.0000 0.0000 -4.9261 0.0000 0.0000 0.0000 0.0000 0.0000 -4.4950 0.0000 0.0000 7.7057 0.0000 0.0000 -3.2107 0.0000 0.0000 0.0000 0.0000 0.0000 -4.4950 0.0000 0.0000 7.7057 0.0000 0.0000 -3.2107 0.0000 0.0000 0.0000 0.0000
0.0000 -1.9852 0.0000 4.4589 0.0000 0.0000 3.7157 0.0000 0.0000 -2.2294 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 60.8730 0.0000 0.0000 0.0000 -47.2943 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -60.8730 0.0000 0.0000 0.0000 -2.7057 0.0000 0.0000 0.0000 0.0000 0.0000 -5.7902 0.0000 0.0000 9.9261 0.0000 0.0000 -4.1359 0.0000 0.0000 0.0000 0.0000 0.0000 -5.7902 0.0000 0.0000 9.9261 0.0000 0.0000 -4.1359 0.0000 0.0000 0.0000 0.0000
0.0000 -2.4733 0.0000 4.8541 0.0000 0.0000 4.0451 0.0000 0.0000 -2.4271 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 64.4959 0.0000 0.0000 0.0000 -49.2705 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -64.4959 0.0000 0.0000 0.0000 -0.7295 0.0000 0.0000 0.0000 0.0000 0.0000 -7.2138 0.0000 0.0000 12.3664 0.0000 0.0000 -5.1527 0.0000 0.0000 0.0000 0.0000 0.0000 -7.2138 0.0000 0.0000 12.3664 0.0000 0.0000 -5.1527 0.0000 0.0000 0.0000 0.0000
0.0000 -3.0000 0.0000 5.1962 0.0000 0.0000 4.3301 0.0000 0.0000 -2.5981 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 67.6314 0.0000 0.0000 0.0000 -50.9808 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -67.6314 0.0000 0.0000 0.0000 0.9808 0.0000 0.0000 0.0000 0.0000 0.0000 -8.7500 0.0000 0.0000 15.0000 0.0000 0.0000 -6.2500 0.0000 0.0000 0.0000 0.0000 0.0000 -8.7500 0.0000 0.0000 15.0000 0.0000 0.0000 -6.2500 0.0000 0.0000 0.0000 0.0000
0.0000 -3.5596 0.0000 5.4813 0.0000 0.0000 4.5677 0.0000 0.0000 -2.7406 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 70.2450 0.0000 0.0000 0.0000 -52.4064 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -70.2450 0.0000 0.0000 0.0000 2.4064 0.0000 0.0000 0.0000 0.0000 0.0000 -10.3821 0.0000 0.0000 17.7979 0.0000 0.0000 -7.4158 0.0000 0.0000 0.0000 0.0000 0.0000 -10.3821 0.0000 0.0000 17.7979 0.0000 0.0000 -7.4158 0.0000 0.0000 0.0000 0.0000
0.0000 -4.1459 0.0000 5.7063 0.0000 0.0000 4.7553 0.0000 0.0000 -2.8532 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 72.3081 0.0000 0.0000 0.0000 -53.5317 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -72.3081 0.0000 0.0000 0.0000 3.5317 0.0000 0.0000 0.0000 0.0000 0.0000 -12.0922 0.0000 0.0000 20.7295 0.0000 0.0000 -8.6373 0.0000 0.0000 0.0000 0.0000 0.0000 -12.0922 0.0000 0.0000 20.7295 0.0000 0.0000 -8.6373 0.0000 0.0000 0.0000 0.0000
0.0000 -4.7525 0.0000 5.8689 0.0000 0.0000 4.8907 0.0000 0.0000 -2.9344 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 73.7981 0.0000 0.0000 0.0000 -54.3444 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -73.7981 0.0000 0.0000 0.0000 4.3444 0.0000 0.0000 0.0000 0.0000 0.0000 -13.8615 0.0000 0.0000 23.7626 0.0000 0.0000 -9.9011 0.0000 0.0000 0.0000 0.0000 0.0000 -13.8615 0.0000 0.0000 23.7626 0.0000 0.0000 -9.9011 0.0000 0.0000 0.0000 0.0000
0.0000 -5.3728 0.0000 5.9671 0.0000 0.0000 4.9726 0.0000 0.0000 -2.9836 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 74.6987 0.0000 0.0000 0.0000 -54.8357 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -74.6987 0.0000 0.0000 0.0000 4.8357 0.0000 0.0000 0.0000 0.0000 0.0000 -15.6708 0.0000 0.0000 26.8641 0.0000 0.0000 -11.1934 0.0000 0.0000 0.0000 0.0000 0.0000 -15.6708 0.0000 0.0000 26.8641 0.0000 0.0000 -11.1934 0.0000 0.0000 0.0000 0.0000
0.0000 -6.0000 0.0000 6.0000 0.0000 0.0000 5.0000 0.0000 0.0000 -3.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 75.0000 0.0000 0.0000 0.0000 -55.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -75.0000 0.0000 0.0000 0.0000 5.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -17.5000 0.0000 0.0000 30.0000 0.0000 0.0000 -12.5000 0.0000 0.0000 0.0000 0.0000 0.0000 -17.5000 0.0000 0.0000 30.0000 0.0000 0.0000 -12.5000 0.0000 0.0000 0.0000 0.0000
0.0000 -6.6272 0.0000 5.9671 0.0000 0.0000 4.9726 0.0000 0.0000 -2.9836 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 74.6987 0.0000 0.0000 0.0000 -54.8357 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -74.6987 0.0000 0.0000 0.0000 4.8357 0.0000 0.0000 0.0000 0.0000 0.0000 -19.3292 0.0000 0.0000 33.1359 0.0000 0.0000 -13.8066 0.0000 0.0000 0.0000 0.0000 0.0000 -19.3292 0.0000 0.0000 33.1359 0.0000 0.0000 -13.8066 0.0000 0.0000 0.0000 0.0000
0.0000 -7.2475 0.0000 5.8689 0.0000 0.0000 4.8907 0.0000 0.0000 -2.9344 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 73.7981 0.0000 0.0000 0.0000 -54.3444 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -73.7981 0.0000 0.0000 0.0000 4.3444 0.0000 0.0000 0.0000 0.0000 0.0000 -21.1385 0.0000 0.0000 36.2374 0.0000 0.0000 -15.0989 0.0000 0.0000 0.0000 0.0000 0.0000 -21.1385 0.0000 0.0000 36.2374 0.0000 0.0000 -15.0989 0.0000 0.0000 0.0000 0.0000
0.0000 -7.8541 0.0000 5.7063 0.0000 0.0000 4.7553 0.0000 0.0000 -2.8532 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 72.3081 0.0000 0.0000 0.0000 -53.5317 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -72.3081 0.0000 0.0000 0.0000 3.5317 0.0000 0.0000 0.0000 0.0000 0.0000 -22.9078 0.0000 0.0000 39.2705 0.0000 0.0000 -16.3627 0.0000 0.0000 0.0000 0.0000 0.0000 -22.9078 0.0000 0.0000 39.2705 0.0000 0.0000 -16.3627 0.0000 0.0000 0.0000 0.0000
0.0000 -8.4404 0.0000 5.4813 0.0000 0.0000 4.5677 0.0000 0.0000 -2.7406 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 70.2450 0.0000 0.0000 0.0000 -52.4064 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -70.2450 0.0000 0.0000 0.0000 2.4064 0.0000 0.0000 0.0000 0.0000 0.0000 -24.6179 0.0000 0.0000 42.2021 0.0000 0.0000 -17.5842 0.0000 0.0000 0.0000 0.0000 0.0000 -24.6179 0.0000 0.0000 42.2021 0.0000 0.0000 -17.5842 0.0000 0.0000 0.0000 0.0000
0.0000 -9.0000 0.0000 5.1962 0.0000 0.0000 4.3301 0.0000 0.0000 -2.5981 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 67.6314 0.0000 0.0000 0.0000 -50.9808 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -67.6314 0.0000 0.0000 0.0000 0.9808 0.0000 0.0000 0.0000 0.0000 0.0000 -26.2500 0.0000 0.0000 45.0000 0.0000 0.0000 -18.7500 0.0000 0.0000 0.0000 0.0000 0.0000 -26.2500 0.0000 0.0000 45.0000 0.0000 0.0000 -18.7500 0.0000 0.0000 0.0000 0.0000
0.0000 -9.5267 0.0000 4.8541 0.0000 0.0000 4.0451 0.0000 0.0000 -2.4271 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 64.4959 0.0000 0.0000 0.0000 -49.2705 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -64.4959 0.0000 0.0000 0.0000 -0.7295 0.0000 0.0000 0.0000 0.0000 0.0000 -27.7862 0.0000 0.0000 47.6336 0.0000 0.0000 -19.8473 0.0000 0.0000 0.0000 0.0000 0.0000 -27.7862 0.0000 0.0000 47.6336 0.0000 0.0000 -19.8473 0.0000 0.0000 0.0000 0.0000
0.0000 -10.0148 0.0000 4.4589 0.0000 0.0000 3.7157 0.0000 0.0000 -2.2294 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 60.8730 0.0000 0.0000 0.0000 -47.2943 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -60.8730 0.0000 0.0000 0.0000 -2.7057 0.0000 0.0000 0.0000 0.0000 0.0000 -29.2098 0.0000 0.0000 50.0739 0.0000 0.0000 -20.8641 0.0000 0.0000 0.0000 0.0000 0.0000 -29.2098 0.0000 0.0000 50.0739 0.0000 0.0000 -20.8641 0.0000 0.0000 0.0000 0.0000
0.0000 -10.4589 0.0000 4.0148 0.0000 0.0000 3.3457 0.0000 0.0000 -2.0074 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 56.8022 0.0000 0.0000 0.0000 -45.0739 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -56.8022 0.0000 0.0000 0.0000 -4.9261 0.0000 0.0000 0.0000 0.0000 0.0000 -30.5050 0.0000 0.0000 52.2943 0.0000 0.0000 -21.7893 0.0000 0.0000 0.0000 0.0000 0.0000 -30.5050 0.0000 0.0000 52.2943 0.0000 0.0000 -21.7893 0.0000 0.0000 0.0000 0.0000
0.0000 -10.8541 0.0000 3.5267 0.0000 0.0000 2.9389 0.0000 0.0000 -1.7634 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 52.3282 0.0000 0.0000 0.0000 -42.6336 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -52.3282 0.0000 0.0000 0.0000 -7.3664 0.0000 0.0000 0.0000 0.0000 0.0000 -31.6578 0.0000 0.0000 54.2705 0.0000 0.0000 -22.6127 0.0000 0.0000 0.0000 0.0000 0.0000 -31.6578 0.0000 0.0000 54.2705 0.0000 0.0000 -22.6127 0.0000 0.0000 0.0000 0.0000
0.0000 -11.1962 0.0000 3.0000 0.0000 0.0000 2.5000 0.0000 0.0000 -1.5000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 47.5000 0.0000 0.0000 0.0000 -40.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -47.5000 0.0000 0.0000 0.0000 -10.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -32.6554 0.0000 0.0000 55.9808 0.0000 0.0000 -23.3253 0.0000 0.0000 0.0000 0.0000 0.0000 -32.6554 0.0000 0.0000 55.9808 0.0000 0.0000 -23.3253 0.0000 0.0000 0.0000 0.0000
0.0000 -11.4813 0.0000 2.4404 0.0000 0.0000 2.0337 0.0000 0.0000 -1.2202 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 42.3705 0.0000 0.0000 0.0000 -37.2021 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -42.3705 0.0000 0.0000 0.0000 -12.7979 0.0000 0.0000 0.0000 0.0000 0.0000 -33.4870 0.0000 0.0000 57.4064 0.0000 0.0000 -23.9193 0.0000 0.0000 0.0000 0.0000 0.0000 -33.4870 0.0000 0.0000 57.4064 0.0000 0.0000 -23.9193 0.0000 0.0000 0.0000 0.0000
0.0000 -11.7063 0.0000 1.8541 0.0000 0.0000 1.5451 0.0000 0.0000 -0.9271 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 36.9959 0.0000 0.0000 0.0000 -34.2705 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -36.9959 0.0000 0.0000 0.0000 -15.7295 0.0000 0.0000 0.0000 0.0000 0.0000 -34.1435 0.0000 0.0000 58.5317 0.0000 0.0000 -24.3882 0.0000 0.0000 0.0000 0.0000 0.0000 -34.1435 0.0000 0.0000 58.5317 0.0000 0.0000 -24.3882 0.0000 0.0000 0.0000 0.0000
0.0000 -11.8689 0.0000 1.2475 0.0000 0.0000 1.0396 0.0000 0.0000 -0.6237 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 31.4351 0.0000 0.0000 0.0000 -31.2374 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -31.4351 0.0000 0.0000 0.0000 -18.7626 0.0000 0.0000 0.0000 0.0000 0.0000 -34.6176 0.0000 0.0000 59.3444 0.0000 0.0000 -24.7268 0.0000 0.0000 0.0000 0.0000 0.0000 -34.6176 0.0000 0.0000 59.3444 0.0000 0.0000 -24.7268 0.0000 0.0000 0.0000 0.0000
0.0000 -11.9671 0.0000 0.6272 0.0000 0.0000 0.5226 0.0000 0.0000 -0.3136 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 25.7491 0.0000 0.0000 0.0000 -28.1359 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -25.7491 0.0000 0.0000 0.0000 -21.8641 0.0000 0.0000 0.0000 0.0000 0.0000 -34.9041 0.0000 0.0000 59.8357 0.0000 0.0000 -24.9315 0.0000 0.0000 0.0000 0.0000 0.0000 -34.9041 0.0000 0.0000 59.8357 0.0000 0.0000 -24.9315 0.0000 0.0000 0.0000 0.0000
0.0000 -12.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 20.0000 0.0000 0.0000 0.0000 -25.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -20.0000 0.0000 0.0000 0.0000 -25.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -35.0000 0.0000 0.0000 60.0000 0.0000 0.0000 -25.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -35.0000 0.0000 0.0000 60.0000 0.0000 0.0000 -25.0000 0.0000 0.0000 0.0000 0.0000
0.0000 -11.9671 0.0000 -0.6272 0.0000 0.0000 -0.5226 0.0000 0.0000 0.3136 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 14.2509 0.0000 0.0000 0.0000 -21.8641 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -14.2509 0.0000 0.0000 0.0000 -28.1359 0.0000 0.0000 0.0000 0.0000 0.0000 -34.9041 0.0000 0.0000 59.8357 0.0000 0.0000 -24.9315 0.0000 0.0000 0.0000 0.0000 0.0000 -34.9041 0.0000 0.0000 59.8357 0.0000 0.0000 -24.9315 0.0000 0.0000 0.0000 0.0000
0.0000 -11.8689 0.0000 -1.2475 0.0000 0.0000 -1.0396 0.0000 0.0000 0.6237 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 8.5649 0.0000 0.0000 0.0000 -18.7626 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -8.5649 0.0000 0.0000 0.0000 -31.2374 0.0000 0.0000 0.0000 0.0000 0.0000 -34.6176 0.0000 0.0000 59.3444 0.0000 0.0000 -24.7268 0.0000 0.0000 0.0000 0.0000 0.0000 -34.6176 0.0000 0.0000 59.3444 0.0000 0.0000 -24.7268 0.0000 0.0000 0.0000 0.0000
0.0000 -11.7063 0.0000 -1.8541 0.0000 0.0000 -1.5451 0.0000 0.0000 0.9271 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 3.0041 0.0000 0.0000 0.0000 -15.7295 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -3.0041 0.0000 0.0000 0.0000 -34.2705 0.0000 0.0000 0.0000 0.0000 0.0000 -34.1435 0.0000 0.0000 58.5317 0.0000 0.0000 -24.3882 0.0000 0.0000 0.0000 0.0000 0.0000 -34.1435 0.0000 0.0000 58.5317 0.0000 0.0000 -24.3882 0.0000 0.0000 0.0000 0.0000
0.0000 -11.4813 0.0000 -2.4404 0.0000 0.0000 -2.0337 0.0000 0.0000 1.2202 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -2.3705 0.0000 0.0000 0.0000 -12.7979 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 2.3705 0.0000 0.0000 0.0000 -37.2021 0.0000 0.0000 0.0000 0.0000 0.0000 -33.4870 0.0000 0.0000 57.4064 0.0000 0.0000 -23.9193 0.0000 0.0000 0.0000 0.0000 0.0000 -33.4870 0.0000 0.0000 57.4064 0.0000 0.0000 -23.9193 0.0000 0.0000 0.0000 0.0000
0.0000 -11.1962 0.0000 -3.0000 0.0000 0.0000 -2.5000 0.0000 0.0000 1.5000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -7.5000 0.0000 0.0000 0.0000 -10.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 7.5000 0.0000 0.0000 0.0000 -40.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -32.6554 0.0000 0.0000 55.9808 0.0000 0.0000 -23.3253 0.0000 0.0000 0.0000 0.0000 0.0000 -32.6554 0.0000 0.0000 55.9808 0.0000 0.0000 -23.3253 0.0000 0.0000 0.0000 0.0000
0.0000 -10.8541 0.0000 -3.5267 0.0000 0.0000 -2.9389 0.0000 0.0000 1.7634 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -12.3282 0.0000 0.0000 0.0000 -7.3664 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 12.3282 0.0000 0.0000 0.0000 -42.6336 0.0000 0.0000 0.0000 0.0000 0.0000 -31.6578 0.0000 0.0000 54.2705 0.0000 0.0000 -22.6127 0.0000 0.0000 0.0000 0.0000 0.0000 -31.6578 0.0000 0.0000 54.2705 0.0000 0.0000 -22.6127 0.0000 0.0000 0.0000 0.0000
0.0000 -10.4589 0.0000 -4.0148 0.0000 0.0000 -3.3457 0.0000 0.0000 2.0074 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -16.8022 0.0000 0.0000 0.0000 -4.9261 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 16.8022 0.0000 0.0000 0.0000 -45.0739 0.0000 0.0000 0.0000 0.0000 0.0000 -30.5050 0.0000 0.0000 52.2943 0.0000 0.0000 -21.7893 0.0000 0.0000 0.0000 0.0000 0.0000 -30.5050 0.0000 0.0000 52.2943 0.0000 0.0000 -21.7893 0.0000 0.0000 0.0000 0.0000
0.0000 -10.0148 0.0000 -4.4589 0.0000 0.0000 -3.7157 0.0000 0.0000 2.2294 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -20.8730 0.0000 0.0000 0.0000 -2.7057 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 20.8730 0.0000 0.0000 0.0000 -47.2943 0.0000 0.0000 0.0000 0.0000 0.0000 -29.2098 0.0000 0.0000 50.0739 0.0000 0.0000 -20.8641 0.0000 0.0000 0.0000 0.0000 0.0000 -29.2098 0.0000 0.0000 50.0739 0.0000 0.0000 -20.8641 0.0000 0.0000 0.0000 0.0000
0.0000 -9.5267 0.0000 -4.8541 0.0000 0.0000 -4.0451 0.0000 0.0000 2.4271 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -24.4959 0.0000 0.0000 0.0000 -0.7295 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 24.4959 0.0000 0.0000 0.0000 -49.2705 0.0000 0.0000 0.0000 0.0000 0.0000 -27.7862 0.0000 0.0000 47.6336 0.0000 0.0000 -19.8473 0.0000 0.0000 0.0000 0.0000 0.0000 -27.7862 0.0000 0.0000 47.6336 0.0000 0.0000 -19.8473 0.0000 0.0000 0.0000 0.0000
0.0000 -9.0000 0.0000 -5.1962 0.0000 0.0000 -4.3301 0.0000 0.0000 2.5981 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -27.6314 0.0000 0.0000 0.0000 0.9808 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 27.6314 0.0000 0.0000 0.0000 -50.9808 0.0000 0.0000 0.0000 0.0000 0.0000 -26.2500 0.0000 0.0000 45.0000 0.0000 0.0000 -18.7500 0.0000 0.0000 0.0000 0.0000 0.0000 -26.2500 0.0000 0.0000 45.0000 0.0000 0.0000 -18.7500 0.0000 0.0000 0.0000 0.0000
0.0000 -8.4404 0.0000 -5.4813 0.0000 0.0000 -4.5677 0.0000 0.0000 2.7406 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -30.2450 0.0000 0.0000 0.0000 2.4064 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 30.2450 0.0000 0.0000 0.0000 -52.4064 0.0000 0.0000 0.0000 0.0000 0.0000 -24.6179 0.0000 0.0000 42.2021 0.0000 0.0000 -17.5842 0.0000 0.0000 0.0000 0.0000 0.0000 -24.6179 0.0000 0.0000 42.2021 0.0000 0.0000 -17.5842 0.0000 0.0000 0.0000 0.0000
0.0000 -7.8541 0.0000 -5.7063 0.0000 0.0000 -4.7553 0.0000 0.0000 2.8532 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -32.3081 0.0000 0.0000 0.0000 3.5317 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 32.3081 0.0000 0.0000 0.0000 -53.5317 0.0000 0.0000 0.0000 0.0000 0.0000 -22.9078 0.0000 0.0000 39.2705 0.0000 0.0000 -16.3627 0.0000 0.0000 0.0000 0.0000 0.0000 -22.9078 0.0000 0.0000 39.2705 0.0000 0.0000 -16.3627 0.0000 0.0000 0.0000 0.0000
0.0000 -7.2475 0.0000 -5.8689 0.0000 0.0000 -4.8907 0.0000 0.0000 2.9344 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -33.7981 0.0000 0.0000 0.0000 4.3444 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 33.7981 0.0000 0.0000 0.0000 -54.3444 0.0000 0.0000 0.0000 0.0000 0.0000 -21.1385 0.0000 0.0000 36.2374 0.0000 0.0000 -15.0989 0.0000 0.0000 0.0000 0.0000 0.0000 -21.1385 0.0000 0.0000 36.2374 0.0000 0.0000 -15.0989 0.0000 0.0000 0.0000 0.0000
0.0000 -6.6272 0.0000 -5.9671 0.0000 0.0000 -4.9726 0.0000 0.0000 2.9836 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -34.6987 0.0000 0.0000 0.0000 4.8357 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 34.6987 0.0000 0.0000 0.0000 -54.8357 0.0000 0.0000 0.0000 0.0000 0.0000 -19.3292 0.0000 0.0000 33.1359 0.0000 0.0000 -13.8066 0.0000 0.0000 0.0000 0.0000 0.0000 -19.3292 0.0000 0.0000 33.1359 0.0000 0.0000 -13.8066 0.0000 0.0000 0.0000 0.0000
0.0000 -6.0000 0.0000 -6.0000 0.0000 0.0000 -5.0000 0.0000 0.0000 3.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -35.0000 0.0000 0.0000 0.0000 5.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 35.0000 0.0000 0.0000 0.0000 -55.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -17.5000 0.0000 0.0000 30.0000 0.0000 0.0000 -12.5000 0.0000 0.0000 0.0000 0.0000 0.0000 -17.5000 0.0000 0.0000 30.0000 0.0000 0.0000 -12.5000 0.0000 0.0000 0.0000 0.0000
0.0000 -5.3728 0.0000 -5.9671 0.0000 0.0000 -4.9726 0.0000 0.0000 2.9836 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -34.6987 0.0000 0.0000 0.0000 4.8357 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 34.6987 0.0000 0.0000 0.0000 -54.8357 0.0000 0.0000 0.0000 0.0000 0.0000 -15.6708 0.0000 0.0000 26.8641 0.0000 0.0000 -11.1934 0.0000 0.0000 0.0000 0.0000 0.0000 -15.6708 0.0000 0.0000 26.8641 0.0000 0.0000 -11.1934 0.0000 0.0000 0.0000 0.0000
0.0000 -4.7525 0.0000 -5.8689 0.0000 0.0000 -4.8907 0.0000 0.0000 2.9344 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -33.7981 0.0000 0.0000 0.0000 4.3444 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 33.7981 0.0000 0.0000 0.0000 -54.3444 0.0000 0.0000 0.0000 0.0000 0.0000 -13.8615 0.0000 0.0000 23.7626 0.0000 0.0000 -9.9011 0.0000 0.0000 0.0000 0.0000 0.0000 -13.8615 0.0000 0.0000 23.7626 0.0000 0.0000 -9.9011 0.0000 0.0000 0.0000 0.0000
0.0000 -4.1459 0.0000 -5.7063 0.0000 0.0000 -4.7553 0.0000 0.0000 2.8532 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -32.3081 0.0000 0.0000 0.0000 3.5317 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 32.3081 0.0000 0.0000 0.0000 -53.5317 0.0000 0.0000 0.0000 0.0000 0.0000 -12.0922 0.0000 0.0000 20.7295 0.0000 0.0000 -8.6373 0.0000 0.0000 0.0000 0.0000 0.0000 -12.0922 0.0000 0.0000 20.7295 0.0000 0.0000 -8.6373 0.0000 0.0000 0.0000 0.0000
0.0000 -3.5596 0.0000 -5.4813 0.0000 0.0000 -4.5677 0.0000 0.0000 2.7406 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -30.2450 0.0000 0.0000 0.0000 2.4064 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 30.2450 0.0000 0.0000 0.0000 -52.4064 0.0000 0.0000 0.0000 0.0000 0.0000 -10.3821 0.0000 0.0000 17.7979 0.0000 0.0000 -7.4158 0.0000 0.0000 0.0000 0.0000 0.0000 -10.3821 0.0000 0.0000 17.7979 0.0000 0.0000 -7.4158 0.0000 0.0000 0.0000 0.0000
0.0000 -3.0000 0.0000 -5.1962 0.0000 0.0000 -4.3301 0.0000 0.0000 2.5981 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -27.6314 0.0000 0.0000 0.0000 0.9808 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 27.6314 0.0000 0.0000 0.0000 -50.9808 0.0000 0.0000 0.0000 0.0000 0.0000 -8.7500 0.0000 0.0000 15.0000 0.0000 0.0000 -6.2500 0.0000 0.0000 0.0000 0.0000 0.0000 -8.7500 0.0000 0.0000 15.0000 0.0000 0.0000 -6.2500 0.0000 0.0000 0.0000 0.0000
0.0000 -2.4733 0.0000 -4.8541 0.0000 0.0000 -4.0451 0.0000 0.0000 2.4271 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -24.4959 0.0000 0.0000 0.0000 -0.7295 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 24.4959 0.0000 0.0000 0.0000 -49.2705 0.0000 0.0000 0.0000 0.0000 0.0000 -7.2138 0.0000 0.0000 12.3664 0.0000 0.0000 -5.1527 0.0000 0.0000 0.0000 0.0000 0.0000 -7.2138 0.0000 0.0000 12.3664 0.0000 0.0000 -5.1527 0.0000 0.0000 0.0000 0.0000
0.0000 -1.9852 0.0000 -4.4589 0.0000 0.0000 -3.7157 0.0000 0.0000 2.2294 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -20.8730 0.0000 0.0000 0.0000 -2.7057 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 20.8730 0.0000 0.0000 0.0000 -47.2943 0.0000 0.0000 0.0000 0.0000 0.0000 -5.7902 0.0000 0.0000 9.9261 0.0000 0.0000 -4.1359 0.0000 0.0000 0.0000 0.0000 0.0000 -5.7902 0.0000 0.0000 9.9261 0.0000 0.0000 -4.1359 0.0000 0.0000 0.0000 0.0000
0.0000 -1.5411 0.0000 -4.0148 0.0000 0.0000 -3.3457 0.0000 0.0000 2.0074 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -16.8022 0.0000 0.0000 0.0000 -4.9261 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 16.8022 0.0000 0.0000 0.0000 -45.0739 0.0000 0.0000 0.0000 0.0000 0.0000 -4.4950 0.0000 0.0000 7.7057 0.0000 0.0000 -3.2107 0.0000 0.0000 0.0000 0.0000 0.0000 -4.4950 0.0000 0.0000 7.7057 0.0000 0.0000 -3.2107 0.0000 0.0000 0.0000 0.0000
0.0000 -1.1459 0.0000 -3.5267 0.0000 0.0000 -2.9389 0.0000 0.0000 1.7634 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -12.3282 0.0000 0.0000 0.0000 -7.3664 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 12.3282 0.0000 0.0000 0.0000 -42.6336 0.0000 0.0000 0.0000 0.0000 0.0000 -3.3422 0.0000 0.0000 5.7295 0.0000 0.0000 -2.3873 0.0000 0.0000 0.0000 0.0000 0.0000 -3.3422 0.0000 0.0000 5.7295 0.0000 0.0000 -2.3873 0.0000 0.0000 0.0000 0.0000
0.0000 -0.8038 0.0000 -3.0000 0.0000 0.0000 -2.5000 0.0000 0.0000 1.5000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -7.5000 0.0000 0.0000 0.0000 -10.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 7.5000 0.0000 0.0000 0.0000 -40.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -2.3446 0.0000 0.0000 4.0192 0.0000 0.0000 -1.6747 0.0000 0.0000 0.0000 0.0000 0.0000 -2.3446 0.0000 0.0000 4.0192 0.0000 0.0000 -1.6747 0.0000 0.0000 0.0000 0.0000
0.0000 -0.5187 0.0000 -2.4404 0.0000 0.0000 -2.0337 0.0000 0.0000 1.2202 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -2.3705 0.0000 0.0000 0.0000 -12.7979 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 2.3705 0.0000 0.0000 0.0000 -37.2021 0.0000 0.0000 0.0000 0.0000 0.0000 -1.5130 0.0000 0.0000 2.5936 0.0000 0.0000 -1.0807 0.0000 0.0000 0.0000 0.0000 0.0000 -1.5130 0.0000 0.0000 2.5936 0.0000 0.0000 -1.0807 0.0000 0.0000 0.0000 0.0000
0.0000 -0.2937 0.0000 -1.8541 0.0000 0.0000 -1.5451 0.0000 0.0000 0.9271 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 3.0041 0.0000 0.0000 0.0000 -15.7295 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -3.0041 0.0000 0.0000 0.0000 -34.2705 0.0000 0.0000 0.0000 0.0000 0.0000 -0.8565 0.0000 0.0000 1.4683 0.0000 0.0000 -0.6118 0.0000 0.0000 0.0000 0.0000 0.0000 -0.8565 0.0000 0.0000 1.4683 0.0000 0.0000 -0.6118 0.0000 0.0000 0.0000 0.0000
0.0000 -0.1311 0.0000 -1.2475 0.0000 0.0000 -1.0396 0.0000 0.0000 0.6237 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 8.5649 0.0000 0.0000 0.0000 -18.7626 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -8.5649 0.0000 0.0000 0.0000 -31.2374 0.0000 0.0000 0.0000 0.0000 0.0000 -0.3824 0.0000 0.0000 0.6556 0.0000 0.0000 -0.2732 0.0000 0.0000 0.0000 0.0000 0.0000 -0.3824 0.0000 0.0000 0.6556 0.0000 0.0000 -0.2732 0.0000 0.0000 0.0000 0.0000
0.0000 -0.0329 0.0000 -0.6272 0.0000 0.0000 -0.5226 0.0000 0.0000 0.3136 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 14.2509 0.0000 0.0000 0.0000 -21.8641 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 -14.2509 0.0000 0.0000 0.0000 -28.1359 0.0000 0.0000 0.0000 0.0000 0.0000 -0.0959 0.0000 0.0000 0.1643 0.0000 0.0000 -0.0685 0.0000 0.0000 0.0000 0.0000 0.0000 -0.0959 0.0000 0.0000 0.1643 0.0000 0.0000 -0.0685 0.0000 0.0000 0.0000 0.0000
+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 });
});
});