136 lines
6.3 KiB
Swift
136 lines
6.3 KiB
Swift
import Foundation
|
|
import RemoteCore
|
|
|
|
public enum WristSide: String, Codable, Sendable { case left, right }
|
|
public enum CrownOrientation: String, Codable, Sendable { case towardHand, awayFromHand }
|
|
|
|
/// A normalized, user-relative sample. Values are dimensionless and bounded by
|
|
/// the motion adapter; this type intentionally contains no sensor or TV data.
|
|
public struct NormalizedMotionSample: Sendable, Equatable {
|
|
public let timestamp: TimeInterval
|
|
public let x: Double
|
|
public let y: Double
|
|
public let quality: Double
|
|
|
|
public init(timestamp: TimeInterval, x: Double, y: Double, quality: Double = 1) {
|
|
self.timestamp = timestamp; self.x = x; self.y = y; self.quality = quality
|
|
}
|
|
}
|
|
|
|
public struct GestureRecognition: Sendable, Equatable {
|
|
public let sessionID: UUID
|
|
public let command: RemoteCommand
|
|
public let confidence: Double
|
|
public let timestamp: TimeInterval
|
|
}
|
|
|
|
public struct GestureRecognitionConfiguration: Sendable, Equatable {
|
|
public var activationMagnitude: Double = 0.55
|
|
public var neutralMagnitude: Double = 0.18
|
|
public var minimumNeutralDwell: TimeInterval = 0.20
|
|
public var candidateWindow: ClosedRange<TimeInterval> = 0.12...0.65
|
|
public var cooldown: TimeInterval = 0.40
|
|
public var stableNeutralDwell: TimeInterval = 0.20
|
|
public var maximumSampleGap: TimeInterval = 0.12
|
|
public var dominantAxisRatio: Double = 1.5
|
|
public var minimumQuality: Double = 0.65
|
|
|
|
public init() {}
|
|
}
|
|
|
|
/// A deterministic classifier over calibrated, user-relative samples. It is
|
|
/// deliberately conservative: mixed axes, poor quality, gaps and slow drift
|
|
/// produce no intent. One accepted excursion consumes readiness until a stable
|
|
/// neutral pose is observed after cooldown.
|
|
public struct CalibratedGestureRecognizer: Sendable {
|
|
public let configuration: GestureRecognitionConfiguration
|
|
public private(set) var sessionID: UUID?
|
|
|
|
private var armedAt: TimeInterval?
|
|
private var lastTimestamp: TimeInterval?
|
|
private var neutralSince: TimeInterval?
|
|
private var candidateStart: TimeInterval?
|
|
private var candidatePeakX = 0.0
|
|
private var candidatePeakY = 0.0
|
|
private var candidateQuality = 1.0
|
|
private var cooldownUntil: TimeInterval?
|
|
private var waitingForNeutral = true
|
|
|
|
public init(configuration: GestureRecognitionConfiguration = .init()) {
|
|
self.configuration = configuration
|
|
}
|
|
|
|
public mutating func arm(sessionID: UUID, at timestamp: TimeInterval) {
|
|
disarm()
|
|
guard timestamp.isFinite else { return }
|
|
self.sessionID = sessionID; armedAt = timestamp; lastTimestamp = nil
|
|
waitingForNeutral = true
|
|
}
|
|
|
|
public mutating func disarm() {
|
|
sessionID = nil; armedAt = nil; lastTimestamp = nil
|
|
neutralSince = nil; candidateStart = nil; candidatePeakX = 0; candidatePeakY = 0
|
|
candidateQuality = 1; cooldownUntil = nil; waitingForNeutral = true
|
|
}
|
|
|
|
public mutating func process(_ sample: NormalizedMotionSample) -> GestureRecognition? {
|
|
guard let sessionID, let armedAt, sample.timestamp.isFinite,
|
|
sample.timestamp >= armedAt,
|
|
sample.x.isFinite, sample.y.isFinite,
|
|
sample.quality.isFinite, sample.quality >= 0,
|
|
sample.quality <= 1 else {
|
|
resetCandidate(); waitingForNeutral = true; neutralSince = nil
|
|
return nil
|
|
}
|
|
if let lastTimestamp, sample.timestamp <= lastTimestamp { return nil }
|
|
if let lastTimestamp, sample.timestamp - lastTimestamp > configuration.maximumSampleGap {
|
|
resetCandidate(); waitingForNeutral = true; neutralSince = nil
|
|
}
|
|
lastTimestamp = sample.timestamp
|
|
let magnitude = hypot(sample.x, sample.y)
|
|
guard magnitude <= 1.0 else { resetCandidate(); waitingForNeutral = true; return nil }
|
|
|
|
if magnitude <= configuration.neutralMagnitude {
|
|
if neutralSince == nil { neutralSince = sample.timestamp }
|
|
if let cooldownUntil, sample.timestamp < cooldownUntil { return nil }
|
|
if sample.timestamp - (neutralSince ?? sample.timestamp) >= configuration.stableNeutralDwell {
|
|
waitingForNeutral = false; resetCandidate()
|
|
}
|
|
return nil
|
|
}
|
|
neutralSince = nil
|
|
guard sample.quality >= configuration.minimumQuality else { resetCandidate(); waitingForNeutral = true; return nil }
|
|
guard !waitingForNeutral else { return nil }
|
|
|
|
if candidateStart == nil {
|
|
guard magnitude >= configuration.neutralMagnitude else { return nil }
|
|
candidateStart = sample.timestamp; candidatePeakX = sample.x; candidatePeakY = sample.y
|
|
candidateQuality = sample.quality
|
|
return nil
|
|
}
|
|
guard let start = candidateStart else { return nil }
|
|
let duration = sample.timestamp - start
|
|
guard duration <= configuration.candidateWindow.upperBound else { resetCandidate(); waitingForNeutral = true; return nil }
|
|
candidatePeakX = abs(sample.x) > abs(candidatePeakX) ? sample.x : candidatePeakX
|
|
candidatePeakY = abs(sample.y) > abs(candidatePeakY) ? sample.y : candidatePeakY
|
|
candidateQuality = min(candidateQuality, sample.quality)
|
|
let peakX = abs(candidatePeakX), peakY = abs(candidatePeakY)
|
|
let peak = max(peakX, peakY)
|
|
guard duration >= configuration.candidateWindow.lowerBound,
|
|
peak >= configuration.activationMagnitude else { return nil }
|
|
let dominant = max(peakX, peakY) / max(0.0001, min(peakX, peakY))
|
|
guard dominant >= configuration.dominantAxisRatio else { resetCandidate(); waitingForNeutral = true; return nil }
|
|
let command: RemoteCommand
|
|
if peakX > peakY { command = candidatePeakX > 0 ? .right : .left }
|
|
else { command = candidatePeakY > 0 ? .up : .down }
|
|
let confidence = min(1, 0.5 * (peak / max(0.0001, configuration.activationMagnitude))
|
|
+ 0.3 * min(1, dominant / 3) + 0.2 * candidateQuality)
|
|
resetCandidate(); waitingForNeutral = true; cooldownUntil = sample.timestamp + configuration.cooldown
|
|
return GestureRecognition(sessionID: sessionID, command: command, confidence: confidence, timestamp: sample.timestamp)
|
|
}
|
|
|
|
private mutating func resetCandidate() {
|
|
candidateStart = nil; candidatePeakX = 0; candidatePeakY = 0; candidateQuality = 1
|
|
}
|
|
}
|