用前置摄像头 + VisionKit/ROI 管线驱动开合计数,复用跟练舞台与连击粒子,并加固结束收尾避免末段卡死无响应。 Co-authored-by: Cursor <cursoragent@cursor.com>
190 lines
6.1 KiB
TypeScript
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>
|