diff --git a/web-server/src/preprocess/assimp.ts b/web-server/src/preprocess/assimp.ts new file mode 100644 index 0000000..dbef9f5 --- /dev/null +++ b/web-server/src/preprocess/assimp.ts @@ -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 { + 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 ?? '未知错误'), + }; + } +} diff --git a/web-server/src/preprocess/bvh-parser.ts b/web-server/src/preprocess/bvh-parser.ts new file mode 100644 index 0000000..d0823bf --- /dev/null +++ b/web-server/src/preprocess/bvh-parser.ts @@ -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; +} + +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(); + 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 { + const frame = motion.frames[frameIndex]; + const out = new Map(); + + 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; +} diff --git a/web-server/src/preprocess/joint-map.ts b/web-server/src/preprocess/joint-map.ts new file mode 100644 index 0000000..66da627 --- /dev/null +++ b/web-server/src/preprocess/joint-map.ts @@ -0,0 +1,31 @@ +import { MP } from './mediapipe33'; + +/** + * 默认 BVH 关节名映射 —— 契约 model-formats.md §2.2。 + * 按序匹配(不区分大小写),第一个命中的关节被采用。 + * 实际项目中可在本表追加;追加不改变契约,仅提升映射覆盖率。 + */ +export const DEFAULT_JOINT_MAP: Readonly> = 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; diff --git a/web-server/src/preprocess/keypoint-codec.ts b/web-server/src/preprocess/keypoint-codec.ts new file mode 100644 index 0000000..c34820b --- /dev/null +++ b/web-server/src/preprocess/keypoint-codec.ts @@ -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; + 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, + }; +} diff --git a/web-server/src/preprocess/math3d.ts b/web-server/src/preprocess/math3d.ts new file mode 100644 index 0000000..928b972 --- /dev/null +++ b/web-server/src/preprocess/math3d.ts @@ -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(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]))), + }; +} diff --git a/web-server/src/preprocess/mediapipe33.ts b/web-server/src/preprocess/mediapipe33.ts new file mode 100644 index 0000000..1c6912d --- /dev/null +++ b/web-server/src/preprocess/mediapipe33.ts @@ -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 = [ + [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'; diff --git a/web-server/src/preprocess/preprocess.module.ts b/web-server/src/preprocess/preprocess.module.ts new file mode 100644 index 0000000..62a2c98 --- /dev/null +++ b/web-server/src/preprocess/preprocess.module.ts @@ -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 {} diff --git a/web-server/src/preprocess/preprocess.service.ts b/web-server/src/preprocess/preprocess.service.ts new file mode 100644 index 0000000..be4edf1 --- /dev/null +++ b/web-server/src/preprocess/preprocess.service.ts @@ -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, + private readonly storage: StorageService, + private readonly config: AppConfig, + ) {} + + /** 执行预处理并落库;异常会被记录到 preprocessError 后重新抛出 */ + async run(motion: Motion): Promise { + 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 { + 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), + }; + } +} diff --git a/web-server/src/preprocess/retarget.ts b/web-server/src/preprocess/retarget.ts new file mode 100644 index 0000000..9583361 --- /dev/null +++ b/web-server/src/preprocess/retarget.ts @@ -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; +} + +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(); + 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, + jointMap: Record, +): 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 = new Array(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): Vec3 | undefined { + for (const value of map.values()) return value; + return undefined; +} + +/** 无躯干信息时的尺度兜底:取根关节到最远关节的距离 */ +function torsoGuess(world: Map): 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; +} diff --git a/web-server/test/fixtures/generate-sample-bvh.mjs b/web-server/test/fixtures/generate-sample-bvh.mjs new file mode 100644 index 0000000..3f37b41 --- /dev/null +++ b/web-server/test/fixtures/generate-sample-bvh.mjs @@ -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} 字节)`); diff --git a/web-server/test/fixtures/sample.bvh b/web-server/test/fixtures/sample.bvh new file mode 100644 index 0000000..13d7430 --- /dev/null +++ b/web-server/test/fixtures/sample.bvh @@ -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 diff --git a/web-server/test/preprocess.spec.ts b/web-server/test/preprocess.spec.ts new file mode 100644 index 0000000..377cb14 --- /dev/null +++ b/web-server/test/preprocess.spec.ts @@ -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 }); + }); +});