Files
zyt/TUICallKit-Vue3/training/motion/open-close-classifier.ts
T
longandCursor 729d2a084d feat(training): 新增握力环 2.0 摄像头手势识别页
用前置摄像头 + VisionKit/ROI 管线驱动开合计数,复用跟练舞台与连击粒子,并加固结束收尾避免末段卡死无响应。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:31:31 +08:00

190 lines
6.1 KiB
TypeScript

import type { GesturePhase, GestureSource, HandSample, RepCompletedInfo, TrackingQuality } from './types'
export interface ClassifierOutput {
phase: GesturePhase
trackingQuality: TrackingQuality
openness: number
compression: number
holdProgress: number
source: GestureSource
rep?: RepCompletedInfo
}
/**
* 开合状态机(握力环校准版)
* 计数:OPEN → CLOSING → CLOSED(保持) → REOPENING → 相对张开,才 +1
* 松开不必完全张开:相对握紧谷底抬升即可计次,避免做到后期张不开导致卡在 29
*/
export function createOpenCloseClassifier(options?: {
openEnter?: number
openExit?: number
closeEnter?: number
closeExit?: number
minHoldMs?: number
lostMs?: number
emaAlpha?: number
/** 相对松开:相对握紧谷底至少抬升这么多才计 1 次 */
reopenRise?: number
}) {
const openEnter = options?.openEnter ?? 0.48
const openExit = options?.openExit ?? 0.4
const closeEnter = options?.closeEnter ?? 0.38
const closeExit = options?.closeExit ?? 0.44
const minHoldMs = options?.minHoldMs ?? 140
const lostMs = options?.lostMs ?? 1200
const emaAlpha = options?.emaAlpha ?? 0.4
const reopenRise = options?.reopenRise ?? 0.1
let phase: GesturePhase = 'UNTRACKED'
let openness = 0.5
let holdStartedAt = 0
let holdProgress = 0
let lastSeenAt = 0
let opennessPeak = 0
let closedTrough = 1
let source: GestureSource = 'none'
let lastRepTs = 0
let reopenRiseActive = reopenRise
function resetPartial() {
holdStartedAt = 0
holdProgress = 0
opennessPeak = 0
closedTrough = 1
}
/** 接近目标次数时进一步放宽松开判定 */
function setNearGoal(enabled: boolean) {
reopenRiseActive = enabled ? 0.045 : reopenRise
}
function canCountRep(now: number) {
// 防连发:两次有效计次至少间隔 280ms
return now - lastRepTs >= 280
}
function update(sample: HandSample | null, now = Date.now()): ClassifierOutput {
let rep: RepCompletedInfo | undefined
if (!sample || sample.score <= 0.05) {
if (lastSeenAt && now - lastSeenAt > lostMs) {
phase = 'UNTRACKED'
resetPartial()
}
const withinGrace = lastSeenAt > 0 && now - lastSeenAt <= 600
return {
phase,
trackingQuality: phase === 'UNTRACKED' ? 'lost' : withinGrace ? 'good' : 'weak',
openness,
compression: 1 - openness,
holdProgress,
source,
rep,
}
}
lastSeenAt = now
source = sample.source
openness = openness * (1 - emaAlpha) + sample.openness * emaAlpha
const quality: TrackingQuality = sample.score >= 0.22 ? 'good' : 'weak'
switch (phase) {
case 'UNTRACKED':
if (openness >= openEnter) {
phase = 'OPEN_READY'
opennessPeak = openness
resetPartial()
}
break
case 'OPEN_READY':
opennessPeak = Math.max(opennessPeak, openness)
if (openness <= closeEnter) {
phase = 'CLOSING'
closedTrough = openness
}
break
case 'CLOSING':
closedTrough = Math.min(closedTrough, openness)
if (openness <= closeEnter) {
phase = 'CLOSED_CONFIRMED'
holdStartedAt = now
holdProgress = 0
closedTrough = openness
} else if (openness >= openEnter) {
phase = 'OPEN_READY'
}
break
case 'CLOSED_CONFIRMED': {
closedTrough = Math.min(closedTrough, openness)
const held = now - holdStartedAt
holdProgress = Math.min(1, held / minHoldMs)
if (held >= minHoldMs && openness >= closeExit) {
phase = 'REOPENING'
} else if (openness >= openEnter && held < minHoldMs) {
// 保持不足就完全张开:作废本轮
phase = 'OPEN_READY'
resetPartial()
}
break
}
case 'REOPENING': {
closedTrough = Math.min(closedTrough, openness)
const rise = openness - closedTrough
// 绝对张开,或相对握紧谷底抬升足够 → 计 1 次
const reopened = openness >= openEnter || (rise >= reopenRiseActive && openness >= closeExit)
if (reopened && canCountRep(now)) {
lastRepTs = now
rep = {
ts: now,
holdMs: holdStartedAt ? now - holdStartedAt : 0,
opennessPeak,
source,
}
phase = 'OPEN_READY'
resetPartial()
opennessPeak = openness
} else if (openness <= closeEnter) {
// 松开中又握回去:回到保持,不直接作废
phase = 'CLOSED_CONFIRMED'
holdStartedAt = now
holdProgress = 0
}
break
}
}
return {
phase,
trackingQuality: quality,
openness,
compression: 1 - openness,
holdProgress,
source,
rep,
}
}
function reset() {
phase = 'UNTRACKED'
openness = 0.5
source = 'none'
lastSeenAt = 0
lastRepTs = 0
reopenRiseActive = reopenRise
resetPartial()
}
function getPhase() {
return phase
}
return { update, reset, getPhase, setNearGoal }
}
export type OpenCloseClassifier = ReturnType<typeof createOpenCloseClassifier>