Files
zyt/TUICallKit-Vue3/training/pose/counters.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

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
},
}
}