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