Files
zyt/TUICallKit-Vue3/training/pose/__tests__/counters.test.ts
T
long a8d00597f4 fix(training): 修复 AI 跟练真机不计数和误计数,放宽动作要求
真机日志定位的问题:
- 画面外的关键点坐标是 NaN 或字符串,平滑后永久污染膝、踝坐标,深蹲指标恒为 0、站位却被误判通过。
  入口处把非数字坐标当作看不见,平滑时上一帧无效就用新坐标重新开始
- 识别引擎单帧跳动越过计数线也会计一次:连续 2 帧达到计数线才算开始;深蹲连续 2 帧回到站立才算完成
- 离太近时腿部点置信度只有 0.1~0.2、位置靠猜:计数的可见阈值保持 0.3,不用膝盖替补脚踝

按真机跟练的实际幅度放宽:深蹲 0.18→0.13、高抬腿 0.22→0.16、开合跳 120°→95°、侧平举 68°→58°,
左右只看清一侧也能算;开合跳 140° 满分,没迈步只提示不压分。
2026-10-31 11:56:31 +08:00

279 lines
12 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}`)
}
})
/** 把示范动作的幅度按比例缩小(与站立姿势线性插值),模拟做得没那么到位的用户 */
function shallow(move: MoveId, k: number, seconds: number, fps = 12): Pose[] {
const stand = MOTIONS[move].poseAt(0)
return sampleMotion(move, seconds, fps).map((f) => ({
...f,
points: f.points.map((p, i) => ({ x: stand[i].x + (p.x - stand[i].x) * k, y: stand[i].y + (p.y - stand[i].y) * k })),
}))
}
test('squat: 左右只看清一侧的髋和踝也能计数', () => {
const cycles = 4
const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3).map((f) => ({
...f,
scores: f.scores.map((s, i) => ([12, 16].includes(i) ? 0.05 : s)),
}))
assert.equal(run('squat', frames).count, cycles)
})
test('动作要求放宽:幅度没到旧标准、但到了新标准的动作也计数', () => {
// 各动作选一个幅度:峰值落在旧进入阈值和新进入阈值之间(放宽前不计、放宽后计数)
const cases: Array<[MoveId, number, number]> = [
['squat', 0.45, 0.18],
['march', 0.3, 0.22],
['jack', 0.52, 120],
['raise', 0.55, 68],
]
for (const [m, k, oldEnter] of cases) {
const cycles = 4
const c = createCounter(m)
let peak = 0
for (const f of shallow(m, k, MOTIONS[m].cycleSeconds * cycles + 0.3)) {
c.update(f)
if (c.metric !== null) peak = Math.max(peak, c.metric)
}
assert.ok(peak < oldEnter, `${m} peak ${peak} 应低于旧阈值 ${oldEnter}`)
assert.equal(c.count, cycles * MOTIONS[m].repsPerCycle, `${m} count ${c.count}`)
}
})
test('squat: 蹲到底时脚踝偶尔一帧没识别到,不会多计(真机复现)', () => {
const cycles = 4
const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3)
const c = createCounter('squat')
frames.forEach((f, i) => {
// 蹲下过程中(幅度大于 0.2)每隔几帧丢一次脚踝
const deep = (c.metric ?? 0) > 0.2
const drop = deep && i % 4 === 0
c.update({ ...f, scores: f.scores.map((s, k) => (drop && (k === 15 || k === 16) ? 0.05 : s)) })
})
assert.equal(c.count, cycles)
})
test('单帧识别抖动回到站立位置不计数:需要连续两帧', () => {
const cycles = 3
const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3)
const c = createCounter('squat')
const stand = frames[0]
frames.forEach((f, i) => {
c.update(f)
// 蹲到底时插入一帧“站立”噪声
if ((c.metric ?? 0) > 0.3 && i % 3 === 0) c.update({ ...stand, t: f.t + 1 })
})
assert.equal(c.count, cycles)
})
test('离得太近、腿部关键点置信度很低时不计数(真机复现:脚踝位置乱飘、人没有下蹲却被计数)', () => {
const stand = sampleMotion('squat', 0.1)[0]
const c = createCounter('squat')
for (let i = 0; i < 60; i++) {
const ankleSeen = i % 3 !== 0
const drift = 0.15 * Math.abs(Math.sin(i / 4)) // 脚踝的识别位置在乱飘
c.update({
...stand,
t: i * 100,
points: stand.points.map((p, k) => (k === 15 || k === 16 ? { x: p.x, y: p.y - drift } : p)),
scores: stand.scores.map((_, k) => ([11, 12, 13, 14].includes(k) ? 0.15 : k === 15 || k === 16 ? (ankleSeen ? 0.22 : 0.05) : 0.6)),
})
}
assert.equal(c.count, 0)
})
test('jack: 真机跟练的抬手幅度(手高过肩约 100°)计数,只举到与肩平不计', () => {
const c = createCounter('jack')
const sweep = (peak: number, t0: number) => {
// 手臂外展角 20° → peak → 20°,用示范动作在对应角度附近的姿态合成
const frames = sampleMotion('jack', MOTIONS.jack.cycleSeconds)
let best = frames[0]
let bestDiff = Infinity
for (const f of frames) {
c.update({ ...f, t: t0 })
const d = Math.abs((c.metric ?? 0) - peak)
if (d < bestDiff) [best, bestDiff] = [f, d]
}
return best
}
// 先找到 ~100° 和 ~88° 的姿态,再用它们做重复动作
const c100 = sweep(100, 0)
const c88 = sweep(88, 0)
const rest = sampleMotion('jack', 0.1)[0]
const counter = createCounter('jack')
let t = 0
for (let i = 0; i < 3; i++) for (const f of [rest, rest, c100, c100, rest, rest]) counter.update({ ...f, t: (t += 150) })
assert.equal(counter.count, 3, '约 100° 应计数')
const before = counter.count
for (let i = 0; i < 3; i++) for (const f of [rest, rest, c88, c88, rest, rest]) counter.update({ ...f, t: (t += 150) })
assert.equal(counter.count, before, '约 88° 不应计数')
})
test('单帧识别跳动越过计数线不计数(真机复现:高抬腿一帧跳到 0.46 又落回)', () => {
for (const m of ['squat', 'march', 'jack', 'raise'] as MoveId[]) {
const frames = sampleMotion(m, MOTIONS[m].cycleSeconds * 2)
const rest = frames[0]
// 找一帧“到位”的姿态当作跳动
const probe = createCounter(m)
let spike = rest
let best = -Infinity
for (const f of frames) {
probe.update(f)
if ((probe.metric ?? -Infinity) > best) [best, spike] = [probe.metric ?? -Infinity, f]
}
const c = createCounter(m)
let t = 0
for (let i = 0; i < 5; i++) for (const f of [rest, rest, rest, spike, rest, rest, rest]) c.update({ ...f, t: (t += 100) })
assert.equal(c.count, 0, `${m} 单帧跳动被计了 ${c.count} 次`)
}
})
test('jack: 不迈步只提示、不压分,提示 20 秒内最多一次', () => {
const cycles = 6
const stand = MOTIONS.jack.poseAt(0)
// 脚始终保持站立时的位置(不迈步),手臂照常
const frames = sampleMotion('jack', MOTIONS.jack.cycleSeconds * cycles + 0.3).map((f) => ({
...f,
points: f.points.map((p, i) => ([15, 16, 19, 20, 21, 22].includes(i) ? stand[i] : p)),
}))
const r = run('jack', frames)
assert.equal(r.count, cycles * MOTIONS.jack.repsPerCycle)
for (const rep of r.reps) if (rep.type === 'rep') assert.ok(rep.score >= 90, `score ${rep.score}`)
const stepTips = r.tips.filter((t) => t!.includes('迈一步')).length
const seconds = MOTIONS.jack.cycleSeconds * cycles
assert.ok(stepTips >= 1 && stepTips <= Math.ceil(seconds / 20), `step tips ${stepTips}`)
})