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