- 页面:pose-hub 选择动作,pose-coach 跟练(教练小甄示范 + 摄像头动作识别计数) - 动作:徒手深蹲、原地高抬腿、低冲击开合、站姿侧平举 - VisionKit 人体识别,按动作幅度计数、评分、纠错;没有摄像头或识别不可用时自动进入领练模式 - 教练逐帧动画:分包内置 600px 帧,COS 高清帧全部加载完后无缝切换 - 语音报数与口令(WechatSI),舒缓背景音乐(COS,教练说话时自动压低,可关闭) - 逻辑单测:npm run test:pose
233 lines
8.3 KiB
TypeScript
233 lines
8.3 KiB
TypeScript
/**
|
||
* 一次跟练的状态机(纯逻辑,可单测)
|
||
*
|
||
* framing ──站好并保持 1.5s──▶ countdown ──3s──▶ training ──达到目标──▶ done
|
||
* │ ▲
|
||
* 离开画面 >1.5s ▼ │ 回到画面
|
||
* lost
|
||
* 任意训练阶段可 pause / resume,finish 提前结束。
|
||
*/
|
||
import { checkFraming, type FramingIssue, type Pose } from './keypoints.ts'
|
||
import { createCounter, type CoachEvent, type Grade, type RepCounter } from './counters.ts'
|
||
import type { Amount, MoveId } from './motions.ts'
|
||
|
||
export type SessionPhase = 'framing' | 'countdown' | 'training' | 'lost' | 'paused' | 'done'
|
||
|
||
export type SessionEvent =
|
||
| { type: 'phase'; phase: SessionPhase }
|
||
| { type: 'countdown'; n: number }
|
||
| { type: 'combo'; combo: number }
|
||
| CoachEvent
|
||
|
||
export type RepRecord = { score: number; grade: Grade; tip?: string }
|
||
|
||
export const FRAMING_HOLD_MS = 1500
|
||
export const COUNTDOWN_MS = 3000
|
||
export const LOST_AFTER_MS = 1500
|
||
/** 连击:连续得分不低于该值 */
|
||
export const COMBO_SCORE = 80
|
||
const COMBO_MILESTONES = [3, 5, 10, 15, 20, 30, 40, 50]
|
||
|
||
export class TrainingSession {
|
||
readonly move: MoveId
|
||
readonly targetReps: number
|
||
readonly counter: RepCounter
|
||
|
||
phase: SessionPhase = 'framing'
|
||
framingIssue: FramingIssue = 'no_body'
|
||
/** 站位保持进度 0~1 */
|
||
holdProgress = 0
|
||
/** 倒计时剩余整数秒 */
|
||
countdown = 0
|
||
combo = 0
|
||
bestCombo = 0
|
||
reps: RepRecord[] = []
|
||
/** 有效训练时长(不含站位、倒计时、离开画面与暂停) */
|
||
activeMs = 0
|
||
|
||
/** 领练模式(无识别)按节奏累计的次数 */
|
||
private guidedReps = 0
|
||
private readonly detectLost: boolean
|
||
private okSince = 0
|
||
private countdownStart = 0
|
||
private lastSeen = 0
|
||
private lastTick = 0
|
||
private beforePause: SessionPhase = 'training'
|
||
|
||
constructor(move: MoveId, targetReps: number, opts: { detectLost?: boolean } = {}) {
|
||
this.move = move
|
||
this.targetReps = targetReps
|
||
this.counter = createCounter(move)
|
||
this.detectLost = opts.detectLost ?? true
|
||
}
|
||
|
||
get count() {
|
||
return this.counter.count + this.guidedReps
|
||
}
|
||
|
||
get live(): Amount {
|
||
return this.counter.live
|
||
}
|
||
|
||
get progress() {
|
||
return Math.min(1, this.count / this.targetReps)
|
||
}
|
||
|
||
/** 每收到一帧检测结果调用(pose 为 null 表示画面里没人) */
|
||
feed(pose: Pose | null, now: number): SessionEvent[] {
|
||
const events = this.tick(now)
|
||
const issue = checkFraming(pose)
|
||
this.framingIssue = issue
|
||
if (issue !== 'no_body') this.lastSeen = now
|
||
|
||
switch (this.phase) {
|
||
case 'framing':
|
||
if (issue === 'none') {
|
||
if (!this.okSince) this.okSince = now
|
||
this.holdProgress = Math.min(1, (now - this.okSince) / FRAMING_HOLD_MS)
|
||
if (now - this.okSince >= FRAMING_HOLD_MS) {
|
||
this.countdownStart = now
|
||
this.countdown = 3
|
||
events.push(...this.setPhase('countdown'), { type: 'countdown', n: 3 })
|
||
}
|
||
} else {
|
||
this.okSince = 0
|
||
this.holdProgress = 0
|
||
}
|
||
break
|
||
case 'training':
|
||
if (pose && issue !== 'no_body') {
|
||
for (const e of this.counter.update(pose)) events.push(...this.onCoach(e))
|
||
}
|
||
break
|
||
case 'lost':
|
||
if (issue === 'none' || issue === 'partial') events.push(...this.setPhase('training'))
|
||
break
|
||
}
|
||
return events
|
||
}
|
||
|
||
/** 驱动计时与倒计时;没有新帧时也应定时调用 */
|
||
tick(now: number): SessionEvent[] {
|
||
const events: SessionEvent[] = []
|
||
const dt = this.lastTick ? now - this.lastTick : 0
|
||
this.lastTick = now
|
||
|
||
if (this.phase === 'training') {
|
||
this.activeMs += dt
|
||
if (this.detectLost && this.lastSeen && now - this.lastSeen > LOST_AFTER_MS) events.push(...this.setPhase('lost'))
|
||
} else if (this.phase === 'countdown') {
|
||
const left = Math.ceil((COUNTDOWN_MS - (now - this.countdownStart)) / 1000)
|
||
if (left <= 0) {
|
||
this.lastSeen = now
|
||
events.push(...this.setPhase('training'))
|
||
} else if (left !== this.countdown) {
|
||
this.countdown = left
|
||
events.push({ type: 'countdown', n: left })
|
||
}
|
||
}
|
||
return events
|
||
}
|
||
|
||
/** 跳过站位直接倒计时(领练模式) */
|
||
startCountdown(now: number): SessionEvent[] {
|
||
if (this.phase !== 'framing') return []
|
||
this.countdownStart = now
|
||
this.countdown = 3
|
||
this.lastTick = now
|
||
return [...this.setPhase('countdown'), { type: 'countdown', n: 3 }]
|
||
}
|
||
|
||
/** 领练模式:按示范节奏计一次(不评分,不计入动作质量) */
|
||
addGuidedRep(): SessionEvent[] {
|
||
if (this.phase !== 'training') return []
|
||
this.guidedReps += 1
|
||
const out: SessionEvent[] = [{ type: 'rep', count: this.count, score: 0, grade: 'ok' }]
|
||
if (this.count >= this.targetReps) out.push(...this.setPhase('done'))
|
||
return out
|
||
}
|
||
|
||
pause(): SessionEvent[] {
|
||
if (this.phase === 'paused' || this.phase === 'done') return []
|
||
this.beforePause = this.phase === 'lost' ? 'training' : this.phase
|
||
return this.setPhase('paused')
|
||
}
|
||
|
||
resume(now: number): SessionEvent[] {
|
||
if (this.phase !== 'paused') return []
|
||
this.lastTick = now
|
||
this.lastSeen = now
|
||
// 倒计时中被暂停,恢复后重新站位
|
||
const next = this.beforePause === 'countdown' ? 'framing' : this.beforePause
|
||
this.okSince = 0
|
||
return this.setPhase(next)
|
||
}
|
||
|
||
finish(): SessionEvent[] {
|
||
return this.phase === 'done' ? [] : this.setPhase('done')
|
||
}
|
||
|
||
private setPhase(p: SessionPhase): SessionEvent[] {
|
||
if (this.phase === p) return []
|
||
this.phase = p
|
||
return [{ type: 'phase', phase: p }]
|
||
}
|
||
|
||
private onCoach(e: CoachEvent): SessionEvent[] {
|
||
if (e.type !== 'rep') return [e]
|
||
const out: SessionEvent[] = [e]
|
||
this.reps.push({ score: e.score, grade: e.grade, tip: e.tip })
|
||
if (e.score >= COMBO_SCORE && !e.joints) {
|
||
this.combo += 1
|
||
this.bestCombo = Math.max(this.bestCombo, this.combo)
|
||
if (COMBO_MILESTONES.includes(this.combo)) out.push({ type: 'combo', combo: this.combo })
|
||
} else {
|
||
this.combo = 0
|
||
}
|
||
if (this.count >= this.targetReps) out.push(...this.setPhase('done'))
|
||
return out
|
||
}
|
||
}
|
||
|
||
export type SessionSummary = {
|
||
move: MoveId
|
||
reps: number
|
||
targetReps: number
|
||
avgScore: number
|
||
bestCombo: number
|
||
durationSec: number
|
||
kcal: number
|
||
stars: 1 | 2 | 3
|
||
scores: number[]
|
||
/** 出现最多的纠正提示,作为下次训练建议;没有纠正时为鼓励语 */
|
||
advice: string
|
||
/** advice 是纠正建议(tip)还是鼓励(praise) */
|
||
adviceKind: 'tip' | 'praise'
|
||
}
|
||
|
||
/** 汇总结果;kcal = 净 MET × 体重 × 小时 */
|
||
export function summarize(s: TrainingSession, netMet: number, weightKg = 60): SessionSummary {
|
||
const scores = s.reps.map((r) => r.score)
|
||
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0
|
||
const durationSec = Math.round(s.activeMs / 1000)
|
||
const kcal = Math.round(netMet * weightKg * (durationSec / 3600) * 10) / 10
|
||
const tips = new Map<string, number>()
|
||
for (const r of s.reps) if (r.tip) tips.set(r.tip, (tips.get(r.tip) ?? 0) + 1)
|
||
const top = [...tips.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
|
||
const done = s.count >= s.targetReps
|
||
const stars: 1 | 2 | 3 = done && avg >= 88 ? 3 : done && avg >= 75 ? 2 : 1
|
||
return {
|
||
move: s.move,
|
||
reps: s.count,
|
||
targetReps: s.targetReps,
|
||
avgScore: avg,
|
||
bestCombo: s.bestCombo,
|
||
durationSec,
|
||
kcal,
|
||
stars,
|
||
scores,
|
||
advice: top ?? (avg >= 90 ? '动作非常标准,继续保持!' : '保持节奏,每次都做到位'),
|
||
adviceKind: top ? 'tip' : 'praise',
|
||
}
|
||
}
|