Files
zyt/TUICallKit-Vue3/training/pose/session.ts
T
long c3f4d726e0 feat: 新增 AI 跟练(训练分包)
- 页面:pose-hub 选择动作,pose-coach 跟练(教练小甄示范 + 摄像头动作识别计数)
- 动作:徒手深蹲、原地高抬腿、低冲击开合、站姿侧平举
- VisionKit 人体识别,按动作幅度计数、评分、纠错;没有摄像头或识别不可用时自动进入领练模式
- 教练逐帧动画:分包内置 600px 帧,COS 高清帧全部加载完后无缝切换
- 语音报数与口令(WechatSI),舒缓背景音乐(COS,教练说话时自动压低,可关闭)
- 逻辑单测:npm run test:pose
2026-10-31 10:33:25 +08:00

233 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 一次跟练的状态机(纯逻辑,可单测)
*
* 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',
}
}