133 lines
4.5 KiB
Swift
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)
|
|
}
|
|
}
|