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° 满分,没迈步只提示不压分。
This commit is contained in:
@@ -131,3 +131,148 @@ test('幅度条目标线:到达目标幅度才计数', () => {
|
||||
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}`)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 关键点转换与平滑:真机上画面外的点坐标是 NaN,不能让它污染后续帧。
|
||||
* 运行:node --test training/pose/__tests__/
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { anchorToPose, checkFraming, smoothPose, KP, type Pose } from '../keypoints.ts'
|
||||
import { createCounter } from '../counters.ts'
|
||||
import { MOTIONS } from '../motions.ts'
|
||||
import { sampleMotion } from './sampleMotion.ts'
|
||||
|
||||
const LEG = [KP.L_KNEE, KP.R_KNEE, KP.L_ANKLE, KP.R_ANKLE]
|
||||
|
||||
/** 示范姿态转成 VisionKit 的 anchor,指定的点坐标为 NaN(画面外) */
|
||||
function anchorOf(pose: Pose, nanIdx: number[] = []) {
|
||||
return {
|
||||
points: pose.points.map((p, i) => (nanIdx.includes(i) ? { x: NaN, y: NaN } : { x: p.x, y: p.y })),
|
||||
confidence: pose.scores.map((s, i) => (nanIdx.includes(i) ? 0.9 : s)),
|
||||
score: 0.9,
|
||||
}
|
||||
}
|
||||
|
||||
test('anchorToPose:坐标为 NaN 的点视为看不见', () => {
|
||||
const [f] = sampleMotion('squat', 0.1)
|
||||
const p = anchorToPose(anchorOf(f, LEG), 480, 853, 0)!
|
||||
for (const i of LEG) {
|
||||
assert.equal(p.scores[i], 0, `score ${i}`)
|
||||
assert.ok(Number.isFinite(p.points[i].x) && Number.isFinite(p.points[i].y), `point ${i}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('smoothPose:上一帧是 NaN 时直接采用新坐标,不会一直是 NaN', () => {
|
||||
const [f] = sampleMotion('squat', 0.1)
|
||||
const poisoned: Pose = { ...f, points: f.points.map((p, i) => (LEG.includes(i) ? { x: NaN, y: NaN } : p)) }
|
||||
const next = smoothPose(poisoned, f)
|
||||
for (const i of LEG) assert.ok(Number.isFinite(next.points[i].y), `point ${i}`)
|
||||
})
|
||||
|
||||
test('站位:脚踝坐标是 NaN 时不能判定为站位良好', () => {
|
||||
const [f] = sampleMotion('squat', 0.1)
|
||||
const p: Pose = { ...f, points: f.points.map((q, i) => (i === KP.L_ANKLE || i === KP.R_ANKLE ? { x: NaN, y: NaN } : q)) }
|
||||
assert.notEqual(checkFraming(p), 'none')
|
||||
})
|
||||
|
||||
test('真机复现:开始时腿在画面外(NaN),退回画面后深蹲照常计数', () => {
|
||||
const cycles = 4
|
||||
const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3)
|
||||
const counter = createCounter('squat')
|
||||
let smoothed: Pose | null = null
|
||||
// 前 1 秒:离得太近,膝和踝在画面外
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const raw = anchorToPose(anchorOf(frames[0], LEG), 480, 853, i * 83)!
|
||||
smoothed = smoothPose(smoothed, raw)
|
||||
}
|
||||
for (const f of frames) {
|
||||
smoothed = smoothPose(smoothed, anchorToPose(anchorOf(f), 480, 853, f.t)!)
|
||||
counter.update(smoothed)
|
||||
}
|
||||
assert.equal(counter.count, cycles)
|
||||
})
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* 纯逻辑,不依赖 uni / wx,可直接用 Node 单测。
|
||||
*/
|
||||
import { KP, angleAt, isVisible, mid, pt, shoulderWidth, torsoLength, type Pose } from './keypoints.ts'
|
||||
import { KP, MIN_POINT_SCORE, angleAt, isVisible, mid, pt, shoulderWidth, type Point, type Pose } from './keypoints.ts'
|
||||
import type { Amount, MoveId, Side } from './motions.ts'
|
||||
|
||||
export type Grade = 'perfect' | 'good' | 'ok'
|
||||
@@ -24,6 +24,8 @@ export interface RepCounter {
|
||||
readonly live: Amount
|
||||
/** 计一次所需的动作幅度(进入阈值换算成 0~1),界面幅度条上的目标线 */
|
||||
readonly goal: number
|
||||
/** 最近一帧的原始指标;关键点看不清、这一帧没算出来时为 null(诊断用) */
|
||||
readonly metric: number | null
|
||||
update(pose: Pose): CoachEvent[]
|
||||
reset(): void
|
||||
}
|
||||
@@ -51,6 +53,8 @@ type Rule = {
|
||||
checkForm?: (pose: Pose, ctx: Ctx, flags: Set<string>) => void
|
||||
/** 最短单次时长(ms),更快则提示放慢 */
|
||||
minRepMs?: number
|
||||
/** 连续几帧回到起始位才算完成一次(默认 1):在起始位会停留的动作设 2,单帧识别抖动不会多计 */
|
||||
exitFrames?: number
|
||||
/** 指标 → 动作幅度(0 = 起始姿势,1 = 标准到位) */
|
||||
amount: (v: number) => number
|
||||
/** 左右交替的动作:当前是哪一侧 */
|
||||
@@ -93,6 +97,14 @@ export function tipTextsOf(move: MoveId): string[] {
|
||||
}
|
||||
|
||||
const TIP_COOLDOWN_MS = 3500
|
||||
/** 只是建议、不影响计数和评分的提示,冷却更久,免得每次都念 */
|
||||
const ADVICE_TIPS: ReadonlySet<string> = new Set([TIPS.jack.step])
|
||||
const ADVICE_COOLDOWN_MS = 20000
|
||||
/**
|
||||
* 连续几帧达到计数线才算动作开始:识别引擎偶尔有单帧跳动(真机:高抬腿一帧从 0.05 跳到 0.46 又落回),
|
||||
* 真实动作在计数线以上至少持续 0.3 秒,两帧确认不影响正常计数
|
||||
*/
|
||||
const ENTER_FRAMES = 2
|
||||
|
||||
const gradeOf = (score: number): Grade => (score >= 90 ? 'perfect' : score >= 75 ? 'good' : 'ok')
|
||||
|
||||
@@ -101,8 +113,30 @@ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v
|
||||
/** 线性映射到分数区间 */
|
||||
const scale = (v: number, v0: number, v1: number, s0: number, s1: number) => s0 + (s1 - s0) * clamp((v - v0) / (v1 - v0), 0, 1)
|
||||
|
||||
/**
|
||||
* 计数用的关键点可见阈值,与站位一致。
|
||||
* 真机实测:离得太近、腿被画面裁掉时,腿部点置信度只有 0.1~0.2,位置是模型猜的(膝盖跑到髋上方、脚踝乱飘),
|
||||
* 放宽到 0.2 会把这些点拿来计数、没下蹲也计数。看不清时宁可不计,由页面提示用户后退。
|
||||
*/
|
||||
export const TRACK_MIN_SCORE = MIN_POINT_SCORE
|
||||
|
||||
function visibleAll(pose: Pose, idx: number[]) {
|
||||
return idx.every((i) => isVisible(pose, i))
|
||||
return idx.every((i) => isVisible(pose, i, TRACK_MIN_SCORE))
|
||||
}
|
||||
|
||||
/** 一组点里看得清的那些的平均位置(左右成对的点只看清一个也能用);一个都看不清返回 null */
|
||||
function avgVisible(pose: Pose, idx: number[]): Point | null {
|
||||
const seen = idx.filter((i) => isVisible(pose, i, TRACK_MIN_SCORE)).map((i) => pt(pose, i))
|
||||
if (!seen.length) return null
|
||||
return { x: seen.reduce((s, p) => s + p.x, 0) / seen.length, y: seen.reduce((s, p) => s + p.y, 0) / seen.length }
|
||||
}
|
||||
|
||||
/** 躯干长度:看得清的肩、髋的中点之间的距离 */
|
||||
function visibleTorso(pose: Pose): number | null {
|
||||
const sh = avgVisible(pose, [KP.L_SHOULDER, KP.R_SHOULDER])
|
||||
const hip = avgVisible(pose, [KP.L_HIP, KP.R_HIP])
|
||||
if (!sh || !hip) return null
|
||||
return Math.hypot(sh.x - hip.x, sh.y - hip.y)
|
||||
}
|
||||
|
||||
/** 肩外展角:躯干侧线(髋→肩)与手臂(肩→腕)的夹角 */
|
||||
@@ -123,29 +157,30 @@ function avgAbduction(pose: Pose): number | null {
|
||||
|
||||
// ─────────────────────────── 各动作规则 ───────────────────────────
|
||||
|
||||
/** 深蹲:髋部下降比例 = 1 - 当前(踝→髋高度) / 站立时(踝→髋高度) */
|
||||
/**
|
||||
* 深蹲:髋部下降比例 = 1 - 当前(踝→髋高度) / 站立时(踝→髋高度)。左右髋、左右踝各看清一个就够。
|
||||
* 不用髋→膝高度做替补:真机上脚踝看不清时膝盖往往也是猜的,两种估算逐帧交替会算出 0 和 0.4 来回跳,凭空计数。
|
||||
*/
|
||||
function squatRule(): Rule {
|
||||
const legSpan = (pose: Pose) => {
|
||||
const hip = mid(pt(pose, KP.L_HIP), pt(pose, KP.R_HIP))
|
||||
const ankle = mid(pt(pose, KP.L_ANKLE), pt(pose, KP.R_ANKLE))
|
||||
return ankle.y - hip.y
|
||||
}
|
||||
return {
|
||||
metric(pose, ctx) {
|
||||
if (!visibleAll(pose, [KP.L_HIP, KP.R_HIP, KP.L_ANKLE, KP.R_ANKLE])) return null
|
||||
const span = legSpan(pose)
|
||||
const hip = avgVisible(pose, [KP.L_HIP, KP.R_HIP])
|
||||
const ankle = avgVisible(pose, [KP.L_ANKLE, KP.R_ANKLE])
|
||||
if (!hip || !ankle) return null
|
||||
const span = ankle.y - hip.y
|
||||
const b = ctx.baseline
|
||||
// 站立基线:取近期最大值,并缓慢衰减以适应前后走动
|
||||
b.legSpan = Math.max(span, (b.legSpan ?? span) * 0.998)
|
||||
return clamp(1 - span / b.legSpan, 0, 1)
|
||||
return b.legSpan > 0 ? clamp(1 - span / b.legSpan, 0, 1) : null
|
||||
},
|
||||
enter: 0.18,
|
||||
exit: 0.08,
|
||||
attempt: 0.1,
|
||||
enter: 0.13,
|
||||
exit: 0.06,
|
||||
attempt: 0.08,
|
||||
attemptTip: TIPS.squat.shallowAttempt,
|
||||
amount: (v) => clamp(v / 0.34, 0, 1),
|
||||
attemptJoints: [KP.L_HIP, KP.R_HIP],
|
||||
minRepMs: 1200,
|
||||
exitFrames: 2,
|
||||
checkForm(pose, _ctx, flags) {
|
||||
if (!visibleAll(pose, [KP.L_KNEE, KP.R_KNEE, KP.L_ANKLE, KP.R_ANKLE])) return
|
||||
const kneeGap = Math.abs(pt(pose, KP.L_KNEE).x - pt(pose, KP.R_KNEE).x)
|
||||
@@ -154,7 +189,7 @@ function squatRule(): Rule {
|
||||
if (hipDrop && ankleGap > 0 && kneeGap / ankleGap < 0.8) flags.add('valgus')
|
||||
},
|
||||
judge(peak, trace) {
|
||||
let score = scale(peak, 0.18, 0.32, 70, 100)
|
||||
let score = scale(peak, 0.13, 0.3, 70, 100)
|
||||
if (trace.flags.has('valgus')) {
|
||||
score = Math.min(score, 72)
|
||||
return { score, tip: TIPS.squat.valgus, joints: [KP.L_KNEE, KP.R_KNEE] }
|
||||
@@ -169,30 +204,30 @@ function squatRule(): Rule {
|
||||
function legSpanDropFlag(pose: Pose): boolean {
|
||||
const hip = mid(pt(pose, KP.L_HIP), pt(pose, KP.R_HIP))
|
||||
const ankle = mid(pt(pose, KP.L_ANKLE), pt(pose, KP.R_ANKLE))
|
||||
const torso = torsoLength(pose)
|
||||
const torso = visibleTorso(pose) ?? 0
|
||||
return torso > 0 && (ankle.y - hip.y) / torso < 1.25
|
||||
}
|
||||
|
||||
/** 高抬腿:抬起侧膝盖比支撑侧膝盖高出多少(按躯干长度归一) */
|
||||
/** 高抬腿:抬起侧膝盖比支撑侧膝盖高出多少(按躯干长度归一;肩、髋各看清一侧即可) */
|
||||
function marchRule(): Rule {
|
||||
return {
|
||||
metric(pose) {
|
||||
if (!visibleAll(pose, [KP.L_KNEE, KP.R_KNEE, KP.L_SHOULDER, KP.R_SHOULDER, KP.L_HIP, KP.R_HIP])) return null
|
||||
const torso = torsoLength(pose)
|
||||
if (torso <= 0) return null
|
||||
if (!visibleAll(pose, [KP.L_KNEE, KP.R_KNEE])) return null
|
||||
const torso = visibleTorso(pose)
|
||||
if (!torso) return null
|
||||
const diff = Math.abs(pt(pose, KP.L_KNEE).y - pt(pose, KP.R_KNEE).y)
|
||||
return diff / torso
|
||||
},
|
||||
enter: 0.22,
|
||||
exit: 0.08,
|
||||
attempt: 0.09,
|
||||
enter: 0.16,
|
||||
exit: 0.06,
|
||||
attempt: 0.08,
|
||||
attemptTip: TIPS.march.lowAttempt,
|
||||
amount: (v) => clamp(v / 0.55, 0, 1),
|
||||
side: (pose) => (pt(pose, KP.L_KNEE).y < pt(pose, KP.R_KNEE).y ? 'L' : 'R'),
|
||||
attemptJoints: [KP.L_KNEE, KP.R_KNEE],
|
||||
minRepMs: 250,
|
||||
judge(peak) {
|
||||
const score = scale(peak, 0.22, 0.45, 72, 100)
|
||||
const score = scale(peak, 0.16, 0.45, 72, 100)
|
||||
if (peak < 0.3) return { score, tip: TIPS.march.low, joints: [KP.L_KNEE, KP.R_KNEE] }
|
||||
return { score }
|
||||
},
|
||||
@@ -203,7 +238,8 @@ function marchRule(): Rule {
|
||||
function jackRule(): Rule {
|
||||
return {
|
||||
metric: (pose) => avgAbduction(pose),
|
||||
enter: 120,
|
||||
// 手臂高过肩就算(只举到与肩平约 90° 仍不计)。真机实测跟练用户的峰值多在 99°~110°,远低于示范的 178°
|
||||
enter: 95,
|
||||
exit: 50,
|
||||
attempt: 65,
|
||||
attemptTip: TIPS.jack.armsAttempt,
|
||||
@@ -225,11 +261,10 @@ function jackRule(): Rule {
|
||||
if (spread >= 1.05) flags.add('stepped')
|
||||
},
|
||||
judge(peak, trace) {
|
||||
let score = scale(peak, 120, 155, 72, 100)
|
||||
if (!trace.flags.has('stepped')) {
|
||||
score = Math.min(score, 78)
|
||||
return { score, tip: TIPS.jack.step, joints: [KP.L_ANKLE, KP.R_ANKLE] }
|
||||
}
|
||||
// 满分线按真机跟练的实际幅度定(示范 178°,用户多在 100°~130°):举到 140° 就是满分
|
||||
const score = scale(peak, 95, 140, 75, 100)
|
||||
// 没迈步只提示、不压分:手臂到位才是评分依据(提示冷却较长,不会每次都念)
|
||||
if (!trace.flags.has('stepped')) return { score, tip: TIPS.jack.step, joints: [KP.L_ANKLE, KP.R_ANKLE] }
|
||||
return { score }
|
||||
},
|
||||
}
|
||||
@@ -252,9 +287,9 @@ function raiseRule(): Rule {
|
||||
}
|
||||
return a
|
||||
},
|
||||
enter: 68,
|
||||
enter: 58,
|
||||
exit: 30,
|
||||
attempt: 42,
|
||||
attempt: 38,
|
||||
attemptTip: TIPS.raise.lowAttempt,
|
||||
amount: (v) => clamp((v - 10) / 78, 0, 1),
|
||||
attemptJoints: [KP.L_WRIST, KP.R_WRIST],
|
||||
@@ -271,7 +306,7 @@ function raiseRule(): Rule {
|
||||
if (trace.flags.has('shrug')) return { score: 70, tip: TIPS.raise.shrug, joints: [KP.L_SHOULDER, KP.R_SHOULDER] }
|
||||
if (peak > 112) return { score: 75, tip: TIPS.raise.high, joints: [KP.L_WRIST, KP.R_WRIST] }
|
||||
if (trace.flags.has('asym')) return { score: 78, tip: TIPS.raise.asym, joints: [KP.L_WRIST, KP.R_WRIST] }
|
||||
const score = peak >= 80 ? 100 : scale(peak, 68, 80, 75, 95)
|
||||
const score = peak >= 80 ? 100 : scale(peak, 58, 80, 75, 95)
|
||||
return { score }
|
||||
},
|
||||
}
|
||||
@@ -293,13 +328,16 @@ export function createCounter(move: MoveId): RepCounter {
|
||||
let active: RepTrace | null = null
|
||||
let attemptPeak = 0
|
||||
let lastRepEnd = 0
|
||||
let exitStreak = 0
|
||||
let enterStreak = 0
|
||||
let live: Amount = { amount: 0, side: 'L' }
|
||||
let metric: number | null = null
|
||||
const goal = rule.amount(rule.enter)
|
||||
const lastTipAt = new Map<string, number>()
|
||||
|
||||
const allowTip = (text: string, t: number) => {
|
||||
const last = lastTipAt.get(text) ?? -Infinity
|
||||
if (t - last < TIP_COOLDOWN_MS) return false
|
||||
if (t - last < (ADVICE_TIPS.has(text) ? ADVICE_COOLDOWN_MS : TIP_COOLDOWN_MS)) return false
|
||||
lastTipAt.set(text, t)
|
||||
return true
|
||||
}
|
||||
@@ -312,6 +350,9 @@ export function createCounter(move: MoveId): RepCounter {
|
||||
get live() {
|
||||
return live
|
||||
},
|
||||
get metric() {
|
||||
return metric
|
||||
},
|
||||
goal,
|
||||
reset() {
|
||||
ctx = { baseline: {} }
|
||||
@@ -319,19 +360,28 @@ export function createCounter(move: MoveId): RepCounter {
|
||||
active = null
|
||||
attemptPeak = 0
|
||||
lastRepEnd = 0
|
||||
exitStreak = 0
|
||||
enterStreak = 0
|
||||
live = { amount: 0, side: 'L' }
|
||||
metric = null
|
||||
lastTipAt.clear()
|
||||
},
|
||||
update(pose) {
|
||||
const events: CoachEvent[] = []
|
||||
const v = rule.metric(pose, ctx)
|
||||
const raw = rule.metric(pose, ctx)
|
||||
// 兜底:指标算出 NaN / Infinity(坐标异常)时按看不清处理,不让幅度条和计数卡住
|
||||
const v = raw !== null && Number.isFinite(raw) ? raw : null
|
||||
metric = v
|
||||
if (v === null) return events
|
||||
live = { amount: rule.amount(v), side: rule.side?.(pose) ?? 'L' }
|
||||
|
||||
if (!active) {
|
||||
if (v >= rule.enter) {
|
||||
active = { peak: v, startT: pose.t, flags: new Set() }
|
||||
enterStreak = v >= rule.enter ? enterStreak + 1 : 0
|
||||
if (enterStreak >= ENTER_FRAMES) {
|
||||
enterStreak = 0
|
||||
active = { peak: Math.max(v, attemptPeak), startT: pose.t, flags: new Set() }
|
||||
attemptPeak = 0
|
||||
exitStreak = 0
|
||||
} else if (v >= rule.attempt) {
|
||||
attemptPeak = Math.max(attemptPeak, v)
|
||||
} else if (attemptPeak > 0 && v <= rule.exit) {
|
||||
@@ -344,7 +394,9 @@ export function createCounter(move: MoveId): RepCounter {
|
||||
if (active) {
|
||||
active.peak = Math.max(active.peak, v)
|
||||
rule.checkForm?.(pose, ctx, active.flags)
|
||||
if (v <= rule.exit) {
|
||||
exitStreak = v <= rule.exit ? exitStreak + 1 : 0
|
||||
if (exitStreak >= (rule.exitFrames ?? 1)) {
|
||||
exitStreak = 0
|
||||
count += 1
|
||||
const judged = rule.judge(active.peak, active)
|
||||
let { tip, joints } = judged
|
||||
|
||||
@@ -92,8 +92,13 @@ export function angleAt(a: Point, b: Point, c: Point): number {
|
||||
return (Math.acos(cos) * 180) / Math.PI
|
||||
}
|
||||
|
||||
/** 坐标是有效数字(真机上画面外的点坐标是 NaN) */
|
||||
export function isFinitePoint(p: Point | undefined): p is Point {
|
||||
return !!p && Number.isFinite(p.x) && Number.isFinite(p.y)
|
||||
}
|
||||
|
||||
export function isVisible(pose: Pose, idx: number, min = MIN_POINT_SCORE): boolean {
|
||||
return (pose.scores[idx] ?? 0) >= min
|
||||
return (pose.scores[idx] ?? 0) >= min && isFinitePoint(pose.points[idx])
|
||||
}
|
||||
|
||||
/** 肩宽,作为身体尺度基准(对远近不敏感) */
|
||||
@@ -145,12 +150,14 @@ export const FRAMING_TIPS: Record<FramingIssue, string> = {
|
||||
/**
|
||||
* 指数平滑:抑制关键点抖动。alpha 越大越跟手,越小越稳。
|
||||
* 置信度低的点保留上一帧位置,避免骨架乱跳。
|
||||
* 上一帧坐标无效(NaN)时直接用新坐标重新开始——否则 NaN 会一直传下去,这个点永远是 NaN。
|
||||
*/
|
||||
export function smoothPose(prev: Pose | null, next: Pose, alpha = 0.55): Pose {
|
||||
if (!prev) return next
|
||||
const points = next.points.map((p, i) => {
|
||||
const q = prev.points[i]
|
||||
if ((next.scores[i] ?? 0) < MIN_POINT_SCORE) return q
|
||||
if (!isFinitePoint(q)) return p
|
||||
if ((next.scores[i] ?? 0) < MIN_POINT_SCORE || !isFinitePoint(p)) return q
|
||||
return { x: q.x + (p.x - q.x) * alpha, y: q.y + (p.y - q.y) * alpha }
|
||||
})
|
||||
const scores = next.scores.map((s, i) => Math.max(s, (prev.scores[i] ?? 0) * 0.7))
|
||||
@@ -163,6 +170,7 @@ type RawPoint = { x: number; y: number } | [number, number]
|
||||
* 把 VisionKit 的 anchor 转成归一化 Pose。
|
||||
* 官方示例里 points 已是 0~1 的 {x,y};文档描述为“图像中的坐标”,
|
||||
* 这里两种都兼容:发现坐标 > 1.5 即按像素处理。
|
||||
* 画面外的点真机返回 NaN 坐标(置信度却可能不低):坐标置 0、置信度置 0,当作看不见。
|
||||
*/
|
||||
export function anchorToPose(
|
||||
anchor: { points?: RawPoint[]; confidence?: number[]; score?: number },
|
||||
@@ -172,14 +180,15 @@ export function anchorToPose(
|
||||
): Pose | null {
|
||||
const raw = anchor?.points
|
||||
if (!raw || raw.length < 17) return null
|
||||
const pts: Point[] = raw.map((p) => (Array.isArray(p) ? { x: p[0], y: p[1] } : { x: p.x, y: p.y }))
|
||||
const isPixel = pts.some((p) => p.x > 1.5 || p.y > 1.5)
|
||||
const points = pts.map((p) => (isPixel ? { x: p.x / frameWidth, y: p.y / frameHeight } : p))
|
||||
const pts: Point[] = raw.map((p) => (Array.isArray(p) ? { x: p[0], y: p[1] } : { x: p?.x, y: p?.y }))
|
||||
const isPixel = pts.some((p) => isFinitePoint(p) && (p.x > 1.5 || p.y > 1.5))
|
||||
const points = pts.map((p) => (!isFinitePoint(p) ? { x: 0, y: 0 } : isPixel ? { x: p.x / frameWidth, y: p.y / frameHeight } : p))
|
||||
while (points.length < KEYPOINT_COUNT) points.push({ x: 0, y: 0 })
|
||||
const fallback = typeof anchor.score === 'number' ? anchor.score : 1
|
||||
const scores = points.map((_, i) => {
|
||||
if (i >= raw.length || !isFinitePoint(pts[i])) return 0
|
||||
const c = anchor.confidence?.[i]
|
||||
return typeof c === 'number' ? c : i < raw.length ? fallback : 0
|
||||
return typeof c === 'number' && Number.isFinite(c) ? c : fallback
|
||||
})
|
||||
return { points, scores, t, aspect: frameWidth / frameHeight }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user