真机日志定位的问题: - 画面外的关键点坐标是 NaN 或字符串,平滑后永久污染膝、踝坐标,深蹲指标恒为 0、站位却被误判通过。 入口处把非数字坐标当作看不见,平滑时上一帧无效就用新坐标重新开始 - 识别引擎单帧跳动越过计数线也会计一次:连续 2 帧达到计数线才算开始;深蹲连续 2 帧回到站立才算完成 - 离太近时腿部点置信度只有 0.1~0.2、位置靠猜:计数的可见阈值保持 0.3,不用膝盖替补脚踝 按真机跟练的实际幅度放宽:深蹲 0.18→0.13、高抬腿 0.22→0.16、开合跳 120°→95°、侧平举 68°→58°, 左右只看清一侧也能算;开合跳 140° 满分,没迈步只提示不压分。
279 lines
12 KiB
TypeScript
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}`)
|
|
})
|