feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
|
||||
struct PPGContactEvaluation: Sendable {
|
||||
let state: FingerContactState
|
||||
let confidence: Double
|
||||
let pulsatileAmplitude: Double
|
||||
let motionArtifactScore: Double
|
||||
}
|
||||
|
||||
/// Camera-agnostic coverage detector.
|
||||
///
|
||||
/// Contact is deliberately not a prerequisite for collecting frames. This
|
||||
/// class only coaches the user and supplies soft quality metadata. The final
|
||||
/// decision is made from the periodic optical signal, never from skin colour.
|
||||
final class PPGFingerContactDetector {
|
||||
private let requiredStableFrames = 6
|
||||
private let requiredReleaseFrames = 12
|
||||
private let historyLimit = 90
|
||||
private var history: [PPGFrameColorMetrics] = []
|
||||
private var state: FingerContactState = .adjusting
|
||||
private var stableFrames = 0
|
||||
private var releaseFrames = 0
|
||||
|
||||
init(minimumConfidence _: Double) {
|
||||
history.reserveCapacity(historyLimit)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
history.removeAll(keepingCapacity: true)
|
||||
state = .adjusting
|
||||
stableFrames = 0
|
||||
releaseFrames = 0
|
||||
}
|
||||
|
||||
func evaluate(_ metrics: PPGFrameColorMetrics) -> PPGContactEvaluation {
|
||||
let previous = history.last
|
||||
history.append(metrics)
|
||||
if history.count > historyLimit {
|
||||
history.removeFirst(history.count - historyLimit)
|
||||
}
|
||||
|
||||
let brightness = metrics.luminanceMean / 255
|
||||
let red = metrics.redMean / 255
|
||||
let green = metrics.greenMean / 255
|
||||
let blue = metrics.blueMean / 255
|
||||
let chroma = max(red, green, blue) - min(red, green, blue)
|
||||
let spatialDeviation = sqrt(max(metrics.redVariance + metrics.greenVariance, 0) / 2)
|
||||
let uniformity = clamp01(1 - spatialDeviation / 82)
|
||||
let exposure = smoothWindow(brightness, lower: 0.025, upper: 0.985, feather: 0.10)
|
||||
let warmth = clamp01(0.5 + (red - blue) * 1.4)
|
||||
let coverage = clamp01(0.45 * uniformity + 0.30 * exposure + 0.15 * warmth + 0.10 * min(chroma * 3, 1))
|
||||
let motion = motionScore(current: metrics, previous: previous)
|
||||
let amplitude = pulsatileAmplitude()
|
||||
let temporalStability = clamp01(1 - motion / 0.55)
|
||||
let confidence = clamp01(0.72 * coverage + 0.28 * temporalStability)
|
||||
|
||||
let candidate: FingerContactState
|
||||
if brightness < 0.015 || (uniformity < 0.12 && warmth < 0.25) {
|
||||
candidate = .absent
|
||||
} else if brightness > 0.992 || metrics.saturationRatio > 0.92 {
|
||||
candidate = .overexposed
|
||||
} else if history.count >= 15 && motion > 0.68 {
|
||||
candidate = .unstable
|
||||
} else if confidence >= 0.48 {
|
||||
candidate = .valid
|
||||
} else {
|
||||
candidate = .adjusting
|
||||
}
|
||||
|
||||
applyHysteresis(candidate)
|
||||
return PPGContactEvaluation(
|
||||
state: state,
|
||||
confidence: confidence,
|
||||
pulsatileAmplitude: amplitude,
|
||||
motionArtifactScore: motion
|
||||
)
|
||||
}
|
||||
|
||||
private func applyHysteresis(_ candidate: FingerContactState) {
|
||||
if candidate == .valid {
|
||||
stableFrames += 1
|
||||
releaseFrames = 0
|
||||
if stableFrames >= requiredStableFrames { state = .valid }
|
||||
return
|
||||
}
|
||||
|
||||
stableFrames = 0
|
||||
releaseFrames += 1
|
||||
// Brief changes are feedback, not session resets. Holding the last valid
|
||||
// state avoids a flashing UI while automatic exposure settles.
|
||||
if state == .valid && releaseFrames < requiredReleaseFrames { return }
|
||||
state = candidate
|
||||
}
|
||||
|
||||
private func pulsatileAmplitude() -> Double {
|
||||
let window = history.suffix(60)
|
||||
guard window.count >= 20 else { return 0 }
|
||||
let normalized = window.map {
|
||||
$0.greenMean / max($0.redMean + $0.greenMean + $0.blueMean, 1)
|
||||
}
|
||||
return max((normalized.max() ?? 0) - (normalized.min() ?? 0), 0)
|
||||
}
|
||||
|
||||
private func motionScore(
|
||||
current: PPGFrameColorMetrics,
|
||||
previous: PPGFrameColorMetrics?
|
||||
) -> Double {
|
||||
guard let previous else { return 0 }
|
||||
let lightDelta = abs(current.luminanceMean - previous.luminanceMean) / 255
|
||||
let redDelta = abs(current.redMean - previous.redMean) / 255
|
||||
let greenDelta = abs(current.greenMean - previous.greenMean) / 255
|
||||
// Normal pulse modulation is small. Only large frame-to-frame steps are
|
||||
// considered motion, regardless of the absolute colour of the fingertip.
|
||||
return clamp01(max(lightDelta / 0.16, (redDelta + greenDelta) / 0.28))
|
||||
}
|
||||
|
||||
private func smoothWindow(
|
||||
_ value: Double,
|
||||
lower: Double,
|
||||
upper: Double,
|
||||
feather: Double
|
||||
) -> Double {
|
||||
if value <= lower || value >= upper { return 0 }
|
||||
if value < lower + feather { return clamp01((value - lower) / feather) }
|
||||
if value > upper - feather { return clamp01((upper - value) / feather) }
|
||||
return 1
|
||||
}
|
||||
|
||||
private func clamp01(_ value: Double) -> Double {
|
||||
min(max(value.isFinite ? value : 0, 0), 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user