真机日志定位的问题: - 画面外的关键点坐标是 NaN 或字符串,平滑后永久污染膝、踝坐标,深蹲指标恒为 0、站位却被误判通过。 入口处把非数字坐标当作看不见,平滑时上一帧无效就用新坐标重新开始 - 识别引擎单帧跳动越过计数线也会计一次:连续 2 帧达到计数线才算开始;深蹲连续 2 帧回到站立才算完成 - 离太近时腿部点置信度只有 0.1~0.2、位置靠猜:计数的可见阈值保持 0.3,不用膝盖替补脚踝 按真机跟练的实际幅度放宽:深蹲 0.18→0.13、高抬腿 0.22→0.16、开合跳 120°→95°、侧平举 68°→58°, 左右只看清一侧也能算;开合跳 140° 满分,没迈步只提示不压分。
419 lines
17 KiB
TypeScript
419 lines
17 KiB
TypeScript
/**
|
|
* 动作计数与纠错
|
|
*
|
|
* 每个动作一个“单指标 + 滞回阈值”的状态机:
|
|
* 指标越过 enter 阈值 → 进入动作中,记录峰值与动作期间的姿态问题
|
|
* 指标回落到 exit 阈值 → 结束一次,按峰值评分并给出提示
|
|
* 阈值全部是身体比例或关节角度,与人离镜头远近无关。
|
|
*
|
|
* 纯逻辑,不依赖 uni / wx,可直接用 Node 单测。
|
|
*/
|
|
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'
|
|
|
|
export type CoachEvent =
|
|
| { type: 'rep'; count: number; score: number; grade: Grade; tip?: string; joints?: number[] }
|
|
| { type: 'tip'; text: string; joints?: number[] }
|
|
|
|
export interface RepCounter {
|
|
readonly move: MoveId
|
|
readonly count: number
|
|
/** 当前帧的动作幅度(0~1),镜像模式用它驱动教练 */
|
|
readonly live: Amount
|
|
/** 计一次所需的动作幅度(进入阈值换算成 0~1),界面幅度条上的目标线 */
|
|
readonly goal: number
|
|
/** 最近一帧的原始指标;关键点看不清、这一帧没算出来时为 null(诊断用) */
|
|
readonly metric: number | null
|
|
update(pose: Pose): CoachEvent[]
|
|
reset(): void
|
|
}
|
|
|
|
/** 一次动作过程中收集的信息 */
|
|
type RepTrace = {
|
|
peak: number
|
|
startT: number
|
|
/** 动作期间出现过的问题(取最严重的一次) */
|
|
flags: Set<string>
|
|
}
|
|
|
|
type Rule = {
|
|
/** 当前帧指标;关键点不可见时返回 null */
|
|
metric: (pose: Pose, ctx: Ctx) => number | null
|
|
enter: number
|
|
exit: number
|
|
/** 峰值达到 attempt 但没到 enter,回落时提示“幅度不够” */
|
|
attempt: number
|
|
attemptTip: string
|
|
attemptJoints: number[]
|
|
/** 峰值 → 分数与提示 */
|
|
judge: (peak: number, trace: RepTrace) => { score: number; tip?: string; joints?: number[] }
|
|
/** 动作进行中逐帧检查姿态 */
|
|
checkForm?: (pose: Pose, ctx: Ctx, flags: Set<string>) => void
|
|
/** 最短单次时长(ms),更快则提示放慢 */
|
|
minRepMs?: number
|
|
/** 连续几帧回到起始位才算完成一次(默认 1):在起始位会停留的动作设 2,单帧识别抖动不会多计 */
|
|
exitFrames?: number
|
|
/** 指标 → 动作幅度(0 = 起始姿势,1 = 标准到位) */
|
|
amount: (v: number) => number
|
|
/** 左右交替的动作:当前是哪一侧 */
|
|
side?: (pose: Pose) => Side
|
|
}
|
|
|
|
type Ctx = {
|
|
/** 站立基线(腿长等),缓慢自适应 */
|
|
baseline: Record<string, number>
|
|
}
|
|
|
|
/** 纠错提示文案(语音会在训练前预合成,改文案只需改这里) */
|
|
export const TIPS = {
|
|
squat: {
|
|
shallowAttempt: '再蹲低一点,屁股向后坐',
|
|
valgus: '膝盖向外打开,对准脚尖',
|
|
shallow: '不错,下次再蹲深一点',
|
|
},
|
|
march: {
|
|
lowAttempt: '膝盖再抬高一点',
|
|
low: '膝盖抬到腰的高度更有效',
|
|
},
|
|
jack: {
|
|
armsAttempt: '双手举过头顶',
|
|
step: '手举起来时,脚向外迈一步',
|
|
},
|
|
raise: {
|
|
lowAttempt: '手臂再抬高,与肩同高',
|
|
shrug: '肩膀下沉,不要耸肩',
|
|
high: '抬到与肩同高就好,不用更高',
|
|
asym: '两只手一起抬,保持平衡',
|
|
},
|
|
} as const
|
|
|
|
export const TEMPO_TIP = '慢一点,控制好节奏'
|
|
|
|
/** 某个动作可能播报的全部提示 */
|
|
export function tipTextsOf(move: MoveId): string[] {
|
|
return [...Object.values(TIPS[move]), TEMPO_TIP]
|
|
}
|
|
|
|
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')
|
|
|
|
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, 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)
|
|
}
|
|
|
|
/** 肩外展角:躯干侧线(髋→肩)与手臂(肩→腕)的夹角 */
|
|
function abduction(pose: Pose, side: 'L' | 'R'): number | null {
|
|
const [sh, hip, wr] = side === 'L' ? [KP.L_SHOULDER, KP.L_HIP, KP.L_WRIST] : [KP.R_SHOULDER, KP.R_HIP, KP.R_WRIST]
|
|
if (!visibleAll(pose, [sh, hip, wr])) return null
|
|
return angleAt(pt(pose, hip), pt(pose, sh), pt(pose, wr))
|
|
}
|
|
|
|
function avgAbduction(pose: Pose): number | null {
|
|
const l = abduction(pose, 'L')
|
|
const r = abduction(pose, 'R')
|
|
if (l === null && r === null) return null
|
|
if (l === null) return r
|
|
if (r === null) return l
|
|
return (l + r) / 2
|
|
}
|
|
|
|
// ─────────────────────────── 各动作规则 ───────────────────────────
|
|
|
|
/**
|
|
* 深蹲:髋部下降比例 = 1 - 当前(踝→髋高度) / 站立时(踝→髋高度)。左右髋、左右踝各看清一个就够。
|
|
* 不用髋→膝高度做替补:真机上脚踝看不清时膝盖往往也是猜的,两种估算逐帧交替会算出 0 和 0.4 来回跳,凭空计数。
|
|
*/
|
|
function squatRule(): Rule {
|
|
return {
|
|
metric(pose, ctx) {
|
|
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 b.legSpan > 0 ? clamp(1 - span / b.legSpan, 0, 1) : null
|
|
},
|
|
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)
|
|
const ankleGap = Math.abs(pt(pose, KP.L_ANKLE).x - pt(pose, KP.R_ANKLE).x)
|
|
const hipDrop = legSpanDropFlag(pose)
|
|
if (hipDrop && ankleGap > 0 && kneeGap / ankleGap < 0.8) flags.add('valgus')
|
|
},
|
|
judge(peak, trace) {
|
|
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] }
|
|
}
|
|
if (peak < 0.24) return { score, tip: TIPS.squat.shallow, joints: [KP.L_HIP, KP.R_HIP] }
|
|
return { score }
|
|
},
|
|
}
|
|
}
|
|
|
|
/** checkForm 只在蹲下时判断膝内扣,用躯干长度估计是否已明显下蹲 */
|
|
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 = 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])) 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.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.16, 0.45, 72, 100)
|
|
if (peak < 0.3) return { score, tip: TIPS.march.low, joints: [KP.L_KNEE, KP.R_KNEE] }
|
|
return { score }
|
|
},
|
|
}
|
|
}
|
|
|
|
/** 低冲击开合:双臂外展角;同时检查脚是否向外迈开 */
|
|
function jackRule(): Rule {
|
|
return {
|
|
metric: (pose) => avgAbduction(pose),
|
|
// 手臂高过肩就算(只举到与肩平约 90° 仍不计)。真机实测跟练用户的峰值多在 99°~110°,远低于示范的 178°
|
|
enter: 95,
|
|
exit: 50,
|
|
attempt: 65,
|
|
attemptTip: TIPS.jack.armsAttempt,
|
|
amount: (v) => clamp((v - 12) / 158, 0, 1),
|
|
side(pose) {
|
|
const c = mid(pt(pose, KP.L_HIP), pt(pose, KP.R_HIP)).x
|
|
const l = Math.abs(pt(pose, KP.L_ANKLE).x - c)
|
|
const r = Math.abs(pt(pose, KP.R_ANKLE).x - c)
|
|
return l >= r ? 'L' : 'R'
|
|
},
|
|
attemptJoints: [KP.L_WRIST, KP.R_WRIST],
|
|
minRepMs: 500,
|
|
checkForm(pose, ctx, flags) {
|
|
if (!visibleAll(pose, [KP.L_ANKLE, KP.R_ANKLE, KP.L_SHOULDER, KP.R_SHOULDER])) return
|
|
const sw = shoulderWidth(pose)
|
|
if (sw <= 0) return
|
|
const spread = Math.abs(pt(pose, KP.L_ANKLE).x - pt(pose, KP.R_ANKLE).x) / sw
|
|
ctx.baseline.maxSpread = Math.max(ctx.baseline.maxSpread ?? 0, spread)
|
|
if (spread >= 1.05) flags.add('stepped')
|
|
},
|
|
judge(peak, trace) {
|
|
// 满分线按真机跟练的实际幅度定(示范 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 }
|
|
},
|
|
}
|
|
}
|
|
|
|
/** 侧平举:双臂外展角,理想峰值 80~100 度;同时检查耸肩与左右不对称 */
|
|
function raiseRule(): Rule {
|
|
const neckRatio = (pose: Pose) => {
|
|
const sh = mid(pt(pose, KP.L_SHOULDER), pt(pose, KP.R_SHOULDER))
|
|
const sw = shoulderWidth(pose)
|
|
return sw > 0 ? (sh.y - pt(pose, KP.NOSE).y) / sw : null
|
|
}
|
|
return {
|
|
metric(pose, ctx) {
|
|
const a = avgAbduction(pose)
|
|
if (a !== null && a < 30 && isVisible(pose, KP.NOSE)) {
|
|
// 手臂放下时记录颈部基线,用于判断耸肩
|
|
const r = neckRatio(pose)
|
|
if (r !== null) ctx.baseline.neck = ctx.baseline.neck === undefined ? r : ctx.baseline.neck * 0.9 + r * 0.1
|
|
}
|
|
return a
|
|
},
|
|
enter: 58,
|
|
exit: 30,
|
|
attempt: 38,
|
|
attemptTip: TIPS.raise.lowAttempt,
|
|
amount: (v) => clamp((v - 10) / 78, 0, 1),
|
|
attemptJoints: [KP.L_WRIST, KP.R_WRIST],
|
|
minRepMs: 1200,
|
|
checkForm(pose, ctx, flags) {
|
|
const l = abduction(pose, 'L')
|
|
const r = abduction(pose, 'R')
|
|
if (l !== null && r !== null && Math.abs(l - r) > 28) flags.add('asym')
|
|
const base = ctx.baseline.neck
|
|
const cur = isVisible(pose, KP.NOSE) ? neckRatio(pose) : null
|
|
if (base && cur !== null && cur < base * 0.8) flags.add('shrug')
|
|
},
|
|
judge(peak, trace) {
|
|
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, 58, 80, 75, 95)
|
|
return { score }
|
|
},
|
|
}
|
|
}
|
|
|
|
const RULES: Record<MoveId, () => Rule> = {
|
|
squat: squatRule,
|
|
march: marchRule,
|
|
jack: jackRule,
|
|
raise: raiseRule,
|
|
}
|
|
|
|
// ─────────────────────────── 通用状态机 ───────────────────────────
|
|
|
|
export function createCounter(move: MoveId): RepCounter {
|
|
const rule = RULES[move]()
|
|
let ctx: Ctx = { baseline: {} }
|
|
let count = 0
|
|
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 < (ADVICE_TIPS.has(text) ? ADVICE_COOLDOWN_MS : TIP_COOLDOWN_MS)) return false
|
|
lastTipAt.set(text, t)
|
|
return true
|
|
}
|
|
|
|
return {
|
|
move,
|
|
get count() {
|
|
return count
|
|
},
|
|
get live() {
|
|
return live
|
|
},
|
|
get metric() {
|
|
return metric
|
|
},
|
|
goal,
|
|
reset() {
|
|
ctx = { baseline: {} }
|
|
count = 0
|
|
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 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) {
|
|
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) {
|
|
// 做了动作但幅度不够,回到起始位
|
|
if (allowTip(rule.attemptTip, pose.t)) events.push({ type: 'tip', text: rule.attemptTip, joints: rule.attemptJoints })
|
|
attemptPeak = 0
|
|
}
|
|
}
|
|
|
|
if (active) {
|
|
active.peak = Math.max(active.peak, v)
|
|
rule.checkForm?.(pose, ctx, active.flags)
|
|
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
|
|
const duration = pose.t - (lastRepEnd || active.startT)
|
|
if (!tip && rule.minRepMs && lastRepEnd && duration < rule.minRepMs) {
|
|
tip = TEMPO_TIP
|
|
joints = undefined
|
|
}
|
|
const score = Math.round(judged.score)
|
|
const showTip = tip && allowTip(tip, pose.t) ? tip : undefined
|
|
events.push({ type: 'rep', count, score, grade: gradeOf(score), tip: showTip, joints: showTip ? joints : undefined })
|
|
lastRepEnd = pose.t
|
|
active = null
|
|
}
|
|
}
|
|
return events
|
|
},
|
|
}
|
|
}
|