- 删除从未显示的 idle 手势帧(小程序包内 png 和视频用高清帧),训练分包减少 17KB - 删除骨架连线 BONES、镜像函数 mirrorPose(骨架已去掉)和未使用的 Bgm 类型 - 只给单测用的 sampleMotion 移到测试目录 - 删除训练菜单里未使用的 .menu-icon-text 样式 - 补全 history 单测数据缺少的 adviceKind 字段(严格类型检查报错)
134 lines
5.3 KiB
TypeScript
134 lines
5.3 KiB
TypeScript
/**
|
|
* 计数器单测:用示范动作(motions.ts)合成姿态序列来验证。
|
|
* 运行:node --test training/pose/__tests__/
|
|
*/
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createCounter, type CoachEvent } from '../counters.ts'
|
|
import { MISTAKE_POSES, MOTIONS, type MoveId } from '../motions.ts'
|
|
import { sampleMotion } from './sampleMotion.ts'
|
|
import type { Pose } from '../keypoints.ts'
|
|
|
|
const MOVES: MoveId[] = ['squat', 'march', 'jack', 'raise']
|
|
|
|
/** 固定种子的伪随机,模拟关键点抖动 */
|
|
function rng(seed: number) {
|
|
let s = seed
|
|
return () => ((s = (s * 16807) % 2147483647) / 2147483647) - 0.5
|
|
}
|
|
|
|
function run(move: MoveId, frames: Pose[]) {
|
|
const c = createCounter(move)
|
|
const events: CoachEvent[] = []
|
|
for (const f of frames) events.push(...c.update(f))
|
|
return { count: c.count, events, reps: events.filter((e) => e.type === 'rep'), tips: events.map((e) => (e.type === 'tip' ? e.text : e.tip)).filter(Boolean) }
|
|
}
|
|
|
|
function mistakeFrames(move: MoveId, seconds: number, fps = 12): Pose[] {
|
|
const spec = MOTIONS[move]
|
|
return Array.from({ length: Math.round(seconds * fps) }, (_, i) => {
|
|
const points = MISTAKE_POSES[move](i / fps / spec.cycleSeconds)
|
|
return { points, scores: points.map(() => 0.9), t: (i / fps) * 1000 }
|
|
})
|
|
}
|
|
|
|
/** 同一姿态放进 3:4 竖屏画面:x 归一化坐标被拉伸,aspect 修正后几何不变 */
|
|
function toPortrait(frames: Pose[], aspect = 0.75): Pose[] {
|
|
return frames.map((f) => ({ ...f, aspect, points: f.points.map((p) => ({ x: 0.5 + (p.x - 0.5) / aspect, y: p.y })) }))
|
|
}
|
|
|
|
for (const move of MOVES) {
|
|
const cycles = 5
|
|
const expected = cycles * MOTIONS[move].repsPerCycle
|
|
const frames = sampleMotion(move, MOTIONS[move].cycleSeconds * cycles + 0.3)
|
|
|
|
test(`${move}: 标准示范动作计数准确且评分高`, () => {
|
|
const r = run(move, frames)
|
|
assert.equal(r.count, expected)
|
|
for (const rep of r.reps) if (rep.type === 'rep') assert.ok(rep.score >= 85, `score ${rep.score}`)
|
|
assert.deepEqual(r.tips, [])
|
|
})
|
|
|
|
test(`${move}: 关键点抖动下计数仍准确`, () => {
|
|
const noise = rng(7)
|
|
const noisy = frames.map((f) => ({ ...f, points: f.points.map((p) => ({ x: p.x + noise() * 0.008, y: p.y + noise() * 0.008 })) }))
|
|
assert.equal(run(move, noisy).count, expected)
|
|
})
|
|
|
|
test(`${move}: 竖屏画面比例下结果一致`, () => {
|
|
assert.equal(run(move, toPortrait(frames)).count, expected)
|
|
})
|
|
|
|
test(`${move}: 低帧率(6fps)下不漏计`, () => {
|
|
assert.equal(run(move, sampleMotion(move, MOTIONS[move].cycleSeconds * cycles + 0.3, 6)).count, expected)
|
|
})
|
|
}
|
|
|
|
test('squat: 膝盖内扣会被提示', () => {
|
|
const r = run('squat', mistakeFrames('squat', MOTIONS.squat.cycleSeconds * 3 + 0.3))
|
|
assert.equal(r.count, 3)
|
|
assert.ok(r.tips.some((t) => t!.includes('膝盖向外')), JSON.stringify(r.tips))
|
|
})
|
|
|
|
test('march: 抬腿太低不计数并提示', () => {
|
|
const r = run('march', mistakeFrames('march', MOTIONS.march.cycleSeconds * 4))
|
|
assert.equal(r.count, 0)
|
|
assert.ok(r.tips.some((t) => t!.includes('抬高')), JSON.stringify(r.tips))
|
|
})
|
|
|
|
test('jack: 手臂没举过头不计数并提示', () => {
|
|
const r = run('jack', mistakeFrames('jack', MOTIONS.jack.cycleSeconds * 3))
|
|
assert.equal(r.count, 0)
|
|
assert.ok(r.tips.some((t) => t!.includes('举过头顶')), JSON.stringify(r.tips))
|
|
})
|
|
|
|
test('raise: 耸肩或抬太高会被提示', () => {
|
|
const r = run('raise', mistakeFrames('raise', MOTIONS.raise.cycleSeconds * 3 + 0.3))
|
|
assert.equal(r.count, 3)
|
|
assert.ok(r.tips.some((t) => t!.includes('耸肩') || t!.includes('与肩同高')), JSON.stringify(r.tips))
|
|
})
|
|
|
|
test('关键点不可见时不计数', () => {
|
|
const frames = sampleMotion('squat', 10).map((f) => ({ ...f, scores: f.scores.map(() => 0.1) }))
|
|
assert.equal(run('squat', frames).count, 0)
|
|
})
|
|
|
|
test('同一提示在冷却时间内不重复', () => {
|
|
const r = run('march', mistakeFrames('march', 3))
|
|
const n = r.tips.filter((t) => t!.includes('抬高')).length
|
|
assert.ok(n <= 1, `tips ${n}`)
|
|
})
|
|
|
|
test('live 幅度跟随动作:到位时接近 1,站立时接近 0', () => {
|
|
for (const move of ['squat', 'march', 'jack', 'raise'] as MoveId[]) {
|
|
const c = createCounter(move)
|
|
let max = 0
|
|
let min = 1
|
|
for (const f of sampleMotion(move, MOTIONS[move].cycleSeconds * 2)) {
|
|
c.update(f)
|
|
max = Math.max(max, c.live.amount)
|
|
min = Math.min(min, c.live.amount)
|
|
}
|
|
assert.ok(max > 0.85, `${move} max ${max}`)
|
|
assert.ok(min < 0.12, `${move} min ${min}`)
|
|
}
|
|
})
|
|
|
|
test('live 左右侧判断正确(高抬腿)', () => {
|
|
const c = createCounter('march')
|
|
const spec = MOTIONS.march
|
|
const left = spec.poseAt(0.25)
|
|
c.update({ points: left, scores: left.map(() => 0.9), t: 0 })
|
|
assert.equal(c.live.side, 'L')
|
|
const right = spec.poseAt(0.75)
|
|
c.update({ points: right, scores: right.map(() => 0.9), t: 100 })
|
|
assert.equal(c.live.side, 'R')
|
|
})
|
|
|
|
test('幅度条目标线:到达目标幅度才计数', () => {
|
|
for (const m of ['squat', 'march', 'jack', 'raise'] as const) {
|
|
const c = createCounter(m)
|
|
assert.ok(c.goal > 0.2 && c.goal < 0.9, `${m} goal=${c.goal}`)
|
|
}
|
|
})
|