Files
haptika/packages/react-native-haptika-ppg/ios/FrameProcessor/PPGFingerContactDetector.swift
2026-07-31 16:20:10 +02:00

133 lines
4.5 KiB
Swift

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)
}
}