feat(training): 新增握力环 2.0 摄像头手势识别页
用前置摄像头 + VisionKit/ROI 管线驱动开合计数,复用跟练舞台与连击粒子,并加固结束收尾避免末段卡死无响应。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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>
|
||||
Reference in New Issue
Block a user