feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
@@ -0,0 +1,887 @@
import CoreMedia
import Foundation
import NitroModules
import VisionCamera
/// Nitro entry point for camera PPG processing.
///
/// A `HybridFrameSpec` and its `CMSampleBuffer` exist only as local values during
/// `processFrame(frame:)`. Persistent state contains compact numeric metrics only.
final class HybridHaptikaPPGFrameProcessor: HybridHaptikaPPGFrameProcessorSpec {
private enum SessionPhase {
case idle
case active
case thermalStopped
case finishing
}
private struct MeasurementConfiguration: Sendable {
let sessionPurpose: PPGSessionPurpose
let targetSampleRate: Double
let minimumFrequencyHz: Double
let maximumFrequencyHz: Double
let stabilizationSeconds: Double
let measurementSeconds: Double
let emitEveryFrames: Int
let minimumContactConfidence: Double
let maximumInterpolationGapSeconds: Double
let longGapLimitSeconds: Double
let maximumBufferedSampleCount: Int
init(native: NativePPGConfiguration) throws {
guard native.targetSampleRate.isFinite,
native.targetSampleRate >= 20,
native.targetSampleRate <= 120
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"targetSampleRate must be between 20 and 120 Hz"
)
}
guard native.minimumFrequencyHz.isFinite,
native.maximumFrequencyHz.isFinite,
native.minimumFrequencyHz > 0,
native.maximumFrequencyHz > native.minimumFrequencyHz,
native.maximumFrequencyHz < native.targetSampleRate / 2
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"the physiological band must be ordered and below Nyquist"
)
}
guard native.stabilizationSeconds.isFinite,
native.stabilizationSeconds >= 0,
native.stabilizationSeconds <= 30
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"stabilizationSeconds must be between 0 and 30"
)
}
let maximumDuration = PPGCoherencePolicy.maximumDurationSeconds(
isCoherence: native.sessionPurpose == .coherence
)
guard native.measurementSeconds.isFinite,
native.measurementSeconds >= 20,
native.measurementSeconds <= maximumDuration
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"measurementSeconds exceeds the limit for this session purpose"
)
}
guard native.emitEveryFrames.isFinite,
native.emitEveryFrames >= 1,
native.emitEveryFrames.rounded() == native.emitEveryFrames,
native.emitEveryFrames <= 10_000
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"emitEveryFrames must be a positive integer"
)
}
guard native.minimumContactConfidence.isFinite,
(0...1).contains(native.minimumContactConfidence)
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"minimumContactConfidence must be between 0 and 1"
)
}
sessionPurpose = native.sessionPurpose
targetSampleRate = native.targetSampleRate
minimumFrequencyHz = native.minimumFrequencyHz
maximumFrequencyHz = native.maximumFrequencyHz
stabilizationSeconds = native.stabilizationSeconds
measurementSeconds = native.measurementSeconds
emitEveryFrames = Int(native.emitEveryFrames)
minimumContactConfidence = native.minimumContactConfidence
maximumInterpolationGapSeconds = 0.25
longGapLimitSeconds = 0.80
// This bound permits a full measurement plus recovery time after contact interruptions.
let maximumBufferedDuration = sessionPurpose == .coherence
? PPGCoherencePolicy.coherenceBufferDurationSeconds
: stabilizationSeconds + measurementSeconds + 60
maximumBufferedSampleCount = max(
Int((targetSampleRate * maximumBufferedDuration).rounded(.up)),
256
)
}
var coreConfiguration: PPGProcessingConfiguration {
PPGProcessingConfiguration(
targetSampleRateHz: targetSampleRate,
minimumFrequencyHz: minimumFrequencyHz,
maximumFrequencyHz: maximumFrequencyHz,
// The UI may finish as soon as 50 clean beats have been collected.
// Keep a 45 s floor for HRV while allowing completion before the 90 s
// safety limit on a clean signal.
minimumAnalysisDurationSeconds: min(max(30, measurementSeconds * 0.75), 45),
maximumInterpolationGapSeconds: maximumInterpolationGapSeconds,
longGapLimitSeconds: longGapLimitSeconds,
minimumContactConfidence: minimumContactConfidence,
maximumSaturationRatio: 0.95,
maximumMotionArtifactScore: 0.95,
minimumPeakProminence: 0.35,
minimumPeakAmplitude: 0.10,
refractoryPeriodSeconds: 0.25,
maximumMethodDisagreementBPM: 8,
minimumQualityScore: 0.55,
minimumIntervalsForPRV: 35,
maximumRejectedIntervalRatioForPRV: 0.45
)
}
}
private struct SessionSnapshot: Sendable {
let configuration: MeasurementConfiguration
let samples: [PPGFrameSample]
let startedAtMonotonicSeconds: Double
let endedAtMonotonicSeconds: Double
let processedFrameCount: Int
let droppedFrameCount: Int
let acquisitionDroppedFrameCount: Int
let usableDurationSeconds: Double
let maximumAcquisitionGapSeconds: Double
let bufferLimitReached: Bool
let terminalReason: String?
}
private let stateLock = NSLock()
private let metricsExtractor = PPGFrameMetricsExtractor()
private let thermalMonitor = PPGThermalMonitor()
private var phase: SessionPhase = .idle
private var configuration: MeasurementConfiguration?
private var contactDetector: PPGFingerContactDetector?
private var terminalThermalState: ThermalState?
private var numericSamples: [PPGFrameSample] = []
private var startedAtMonotonicSeconds = 0.0
private var terminalReason: String?
private var bufferLimitReached = false
private var processedFrameCount = 0
private var droppedFrameCount = 0
private var acquisitionDroppedFrameCount = 0
private var lastObservedTimestamp: Double?
private var recentFrameIntervals: [Double] = []
private var maximumAcquisitionGapSeconds = 0.0
private var validContactStartedAt: Double?
private var acquisitionStartedAt: Double?
private var lastUsableTimestamp: Double?
private var usableDurationSeconds = 0.0
private var lastMetrics: PPGFrameColorMetrics?
private var lastContactEvaluation: PPGContactEvaluation?
private var liveBpm: Double?
private var liveIpiMs: Double?
private var liveBeatSequence = 0
private var lastLiveAnalysisFrame = 0
private var liveBeatTracker = PPGLiveBeatTracker()
private var pendingLiveBeats: [NativeAcceptedBeat] = []
private var liveWindowQuality: Double?
private var totalCollectedSampleCount = 0
override init() {
recentFrameIntervals.reserveCapacity(90)
super.init()
}
func startMeasurement(configuration nativeConfiguration: NativePPGConfiguration) throws {
let parsedConfiguration = try MeasurementConfiguration(native: nativeConfiguration)
let thermalState = thermalMonitor.currentState()
guard !Self.isUnsafe(thermalState) else {
throw HaptikaPPGFrameProcessorError.unsafeThermalState(thermalState.stringValue)
}
stateLock.lock()
defer { stateLock.unlock() }
resetLocked()
configuration = parsedConfiguration
contactDetector = PPGFingerContactDetector(
minimumConfidence: parsedConfiguration.minimumContactConfidence
)
numericSamples.reserveCapacity(
min(
parsedConfiguration.maximumBufferedSampleCount,
Int((parsedConfiguration.targetSampleRate * parsedConfiguration.measurementSeconds).rounded(.up))
)
)
startedAtMonotonicSeconds = ProcessInfo.processInfo.systemUptime
phase = .active
}
func processFrame(frame: any HybridFrameSpec) throws -> PPGFrameUpdate? {
stateLock.lock()
defer { stateLock.unlock() }
let thermalState = thermalMonitor.currentState()
if phase == .thermalStopped {
// Keep notifying the worklet until React Native acknowledges the terminal state and tears
// the capture session down. A single callback could be lost while the JS runtime is busy.
return makeFrameUpdate(
timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp),
thermalState: terminalThermalState ?? thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
guard phase == .active, let configuration, let contactDetector else {
// Camera teardown and Nitro calls can overlap by a frame; ignoring it is safe and avoids
// retaining an already-finished frame.
return nil
}
if Self.isUnsafe(thermalState) {
phase = .thermalStopped
terminalThermalState = thermalState
terminalReason = "thermal_\(thermalState.stringValue)"
processedFrameCount += 1
return makeFrameUpdate(
timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp),
thermalState: thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
guard frame.isValid,
let nativeFrame = frame as? any NativeFrame,
let sampleBuffer = nativeFrame.sampleBuffer
else {
throw HaptikaPPGFrameProcessorError.frameUnavailable
}
// The sample buffer is intentionally scoped to this call. The extractor returns value-only
// metrics and never stores the CMSampleBuffer or CVPixelBuffer.
let metrics = try metricsExtractor.extract(from: sampleBuffer)
processedFrameCount += 1
let shouldEmit = processedFrameCount.isMultiple(of: configuration.emitEveryFrames)
guard recordFrameTimingLocked(
timestampSeconds: metrics.timestampSeconds,
configuration: configuration
) else {
if shouldEmit {
return makeFrameUpdate(
timestampSeconds: lastObservedTimestamp ?? metrics.timestampSeconds,
thermalState: thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
return nil
}
let evaluation = contactDetector.evaluate(metrics)
lastMetrics = metrics
lastContactEvaluation = evaluation
updateAcquisitionLocked(
metrics: metrics,
evaluation: evaluation,
configuration: configuration
)
updateLiveEstimateLocked(configuration: configuration)
guard shouldEmit else { return nil }
return makeFrameUpdate(
timestampSeconds: metrics.timestampSeconds,
thermalState: thermalState,
metrics: metrics,
evaluation: evaluation
)
}
func finishMeasurement() throws -> NativePPGProcessingResult {
let snapshot: SessionSnapshot
stateLock.lock()
switch phase {
case .active, .thermalStopped:
guard let configuration else {
stateLock.unlock()
throw HaptikaPPGFrameProcessorError.measurementNotStarted
}
phase = .finishing
snapshot = SessionSnapshot(
configuration: configuration,
samples: numericSamples,
startedAtMonotonicSeconds: startedAtMonotonicSeconds,
endedAtMonotonicSeconds: ProcessInfo.processInfo.systemUptime,
processedFrameCount: processedFrameCount,
droppedFrameCount: droppedFrameCount,
acquisitionDroppedFrameCount: acquisitionDroppedFrameCount,
usableDurationSeconds: usableDurationSeconds,
maximumAcquisitionGapSeconds: maximumAcquisitionGapSeconds,
bufferLimitReached: bufferLimitReached,
terminalReason: terminalReason
)
stateLock.unlock()
case .idle, .finishing:
stateLock.unlock()
throw HaptikaPPGFrameProcessorError.measurementNotStarted
}
defer { resetAfterFinishing() }
if let terminalReason = snapshot.terminalReason {
return makeInvalidResult(
snapshot: snapshot,
reason: terminalReason,
issue: "durationInsufficient"
)
}
if snapshot.bufferLimitReached {
return makeInvalidResult(
snapshot: snapshot,
reason: "The numeric measurement buffer reached its safety limit.",
issue: "nonFiniteProcessingResult"
)
}
do {
let processor = try PPGSignalProcessor(
configuration: snapshot.configuration.coreConfiguration
)
let analysis = try processor.analyze(samples: snapshot.samples)
return makeNativeResult(analysis: analysis, snapshot: snapshot)
} catch {
let mapped = Self.mapProcessingError(error)
return makeInvalidResult(
snapshot: snapshot,
reason: mapped.reason,
issue: mapped.issue
)
}
}
func cancelMeasurement() throws {
stateLock.lock()
resetLocked()
stateLock.unlock()
}
func getThermalState() throws -> ThermalState {
thermalMonitor.currentState()
}
private func recordFrameTimingLocked(
timestampSeconds: Double,
configuration: MeasurementConfiguration
) -> Bool {
guard timestampSeconds.isFinite, timestampSeconds >= 0 else {
droppedFrameCount += 1
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 }
return false
}
guard let previousTimestamp = lastObservedTimestamp else {
lastObservedTimestamp = timestampSeconds
return true
}
let interval = timestampSeconds - previousTimestamp
guard interval > 0 else {
droppedFrameCount += 1
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 }
return false
}
if acquisitionStartedAt != nil {
maximumAcquisitionGapSeconds = max(maximumAcquisitionGapSeconds, interval)
}
if recentFrameIntervals.count >= 5 {
let expectedInterval = SignalStatistics.median(recentFrameIntervals)
if expectedInterval > 0, interval > expectedInterval * 1.5 {
let estimatedMissing = min(
max((interval / expectedInterval).rounded() - 1, 0),
100_000
)
let missingCount = Int(estimatedMissing)
droppedFrameCount += missingCount
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += missingCount }
}
}
if interval <= max(configuration.maximumInterpolationGapSeconds, 0.25) {
recentFrameIntervals.append(interval)
if recentFrameIntervals.count > 90 {
recentFrameIntervals.removeFirst(recentFrameIntervals.count - 90)
}
}
lastObservedTimestamp = timestampSeconds
return true
}
private func updateAcquisitionLocked(
metrics: PPGFrameColorMetrics,
evaluation: PPGContactEvaluation,
configuration: MeasurementConfiguration
) {
// Acquisition is intentionally clock-based rather than contact-gated. A
// contact detector is useful feedback, but making it a hard gate can leave
// a complete minute with zero samples on cameras whose color response is
// different. The signal processor still rejects low-SNR, saturated and
// motion-corrupted portions at the end.
// Store compact numeric samples from the first camera frame. The final
// processor decides which portions are usable. Do not derive a camera
// frame count from the 100 Hz resampling target, and do not throw away a
// good signal merely because the contact heuristic is conservative.
if acquisitionStartedAt == nil {
acquisitionStartedAt = metrics.timestampSeconds
lastUsableTimestamp = nil
}
guard acquisitionStartedAt != nil else { return }
let sample = PPGFrameSample(
timestampSeconds: metrics.timestampSeconds,
red: metrics.redMean,
green: metrics.greenMean,
blue: metrics.blueMean,
luminance: metrics.luminanceMean,
contactConfidence: evaluation.confidence,
saturationRatio: metrics.saturationRatio,
motionArtifactScore: evaluation.motionArtifactScore
)
totalCollectedSampleCount += 1
if configuration.sessionPurpose == .coherence {
numericSamples.append(sample)
if numericSamples.count > configuration.maximumBufferedSampleCount {
numericSamples.removeFirst(numericSamples.count - configuration.maximumBufferedSampleCount)
}
} else if numericSamples.count < configuration.maximumBufferedSampleCount {
numericSamples.append(sample)
} else {
bufferLimitReached = true
}
let opticallyUsable = evaluation.confidence >= 0.35
&& metrics.saturationRatio <= 0.95
&& evaluation.motionArtifactScore <= 0.90
if opticallyUsable {
if let previousTimestamp = lastUsableTimestamp {
let interval = metrics.timestampSeconds - previousTimestamp
if interval > 0, interval <= configuration.maximumInterpolationGapSeconds {
usableDurationSeconds += interval
}
}
lastUsableTimestamp = metrics.timestampSeconds
} else {
// Never bridge quality time across a finger removal or a large movement.
lastUsableTimestamp = nil
}
}
private func makeFrameUpdate(
timestampSeconds: Double,
thermalState: ThermalState,
metrics: PPGFrameColorMetrics?,
evaluation: PPGContactEvaluation?
) -> PPGFrameUpdate {
let signalLevel = min(max((evaluation?.pulsatileAmplitude ?? 0) / 0.03, 0), 1)
let waveformValue: Double
if let metrics {
waveformValue = metrics.greenMean / max(metrics.redMean + metrics.greenMean + metrics.blueMean, 1)
} else {
waveformValue = 0
}
return PPGFrameUpdate(
timestampSeconds: timestampSeconds,
contactState: evaluation?.state ?? .absent,
contactConfidence: min(max(evaluation?.confidence ?? 0, 0), 1),
signalLevel: signalLevel,
saturationRatio: min(max(metrics?.saturationRatio ?? 0, 0), 1),
motionArtifactScore: min(max(evaluation?.motionArtifactScore ?? 0, 0), 1),
collectedSampleCount: Double(totalCollectedSampleCount),
usableDurationSeconds: usableDurationSeconds,
droppedFrameCount: Double(droppedFrameCount),
thermalState: thermalState,
waveformValue: waveformValue,
liveBpm: liveBpm,
liveIpiMs: liveIpiMs,
beatSequence: Double(liveBeatSequence),
acceptedBeats: takePendingLiveBeatsLocked(),
liveWindowQuality: liveWindowQuality
)
}
private func takePendingLiveBeatsLocked() -> [NativeAcceptedBeat] {
let beats = pendingLiveBeats
pendingLiveBeats.removeAll(keepingCapacity: true)
return beats
}
private func updateLiveEstimateLocked(configuration: MeasurementConfiguration) {
// Camera input is constrained to 30 fps. The final 100 Hz configuration
// is a resampling timebase and must not delay live BPM warm-up to 23 s.
let liveInputSampleRate = 30.0
guard numericSamples.count >= Int(liveInputSampleRate * 7),
processedFrameCount - lastLiveAnalysisFrame >= 15
else { return }
lastLiveAnalysisFrame = processedFrameCount
let maximumWindowCount = Int(liveInputSampleRate * 15)
let window = Array(numericSamples.suffix(maximumWindowCount))
let liveConfiguration = PPGProcessingConfiguration(
// A lightweight 30 Hz pass is enough for the live display. The final
// analysis still uses the regular 100 Hz timebase.
targetSampleRateHz: min(configuration.targetSampleRate, 30),
minimumFrequencyHz: configuration.minimumFrequencyHz,
maximumFrequencyHz: configuration.maximumFrequencyHz,
minimumAnalysisDurationSeconds: 6,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds,
longGapLimitSeconds: configuration.longGapLimitSeconds,
minimumContactConfidence: min(configuration.minimumContactConfidence, 0.45),
maximumSaturationRatio: 0.55,
maximumMotionArtifactScore: 0.65,
minimumPeakProminence: 0.28,
minimumPeakAmplitude: 0.08,
refractoryPeriodSeconds: 0.30,
maximumMethodDisagreementBPM: 12,
minimumQualityScore: 0.35,
minimumIntervalsForPRV: 4,
maximumRejectedIntervalRatioForPRV: 0.45,
filterTapCount: 31
)
guard let analysis = try? PPGSignalProcessor(configuration: liveConfiguration)
.analyze(samples: window)
else { return }
liveWindowQuality = analysis.quality.score
if let bpm = analysis.bpm.bpm ?? analysis.bpm.temporalBpm,
bpm.isFinite, (35...220).contains(bpm) {
liveBpm = bpm
}
let acceptedIntervals = analysis.intervals.filter(\.isAccepted)
if let interval = acceptedIntervals.last,
interval.durationMilliseconds.isFinite {
liveIpiMs = interval.durationMilliseconds
}
// The live analysis uses an overlapping window. Count every newly seen
// accepted interval instead of incrementing only once per analysis pass;
// otherwise a 90 BPM pulse sampled every 0.5 s loses real beats.
let newlyAccepted = liveBeatTracker.ingest(acceptedIntervals)
for beat in newlyAccepted {
liveBeatSequence = beat.sequence
pendingLiveBeats.append(
NativeAcceptedBeat(
sequence: Double(beat.sequence),
timestampSeconds: beat.timestampSeconds,
intervalMs: beat.intervalMs,
confidence: beat.confidence
)
)
}
}
private func makeNativeResult(
analysis: PPGAnalysisResult,
snapshot: SessionSnapshot
) -> NativePPGProcessingResult {
var issues = analysis.quality.issues.map(Self.nativeIssue)
if analysis.bpm.rejectedPeakCount > max(3, analysis.bpm.validPeakCount / 4) {
Self.appendUnique("tooManyRejectedPeaks", to: &issues)
}
let observedDroppedRatio = snapshot.samples.isEmpty
? 0
: Double(snapshot.acquisitionDroppedFrameCount)
/ Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount)
let droppedFrameRatio = min(
max(max(analysis.quality.droppedFrameRatio, observedDroppedRatio), 0),
1
)
if droppedFrameRatio > 0.12 {
Self.appendUnique("droppedFrames", to: &issues)
}
let timestampJitterRatio = Self.timestampJitterRatio(analysis.timebase)
let longestGapSeconds = max(
analysis.timebase.maximumGapSeconds,
snapshot.maximumAcquisitionGapSeconds
)
let isValid = analysis.status == .valid
let invalidReason = isValid ? nil : Self.invalidReason(for: issues)
let resampledSampleCount = max(
Int((analysis.timebase.durationSeconds * snapshot.configuration.targetSampleRate).rounded(.down)) + 1,
0
)
return NativePPGProcessingResult(
valid: isValid,
invalidReason: invalidReason,
bpm: NativeBPMEstimate(
bpm: analysis.bpm.bpm,
temporalBpm: analysis.bpm.temporalBpm,
frequencyBpm: analysis.bpm.frequencyBpm,
methodAgreementBpm: analysis.bpm.methodAgreementBpm,
validPeakCount: Double(analysis.bpm.validPeakCount),
rejectedPeakCount: Double(analysis.bpm.rejectedPeakCount),
confidence: analysis.bpm.confidence,
selectedChannel: analysis.bpm.selectedChannel.rawValue
),
prv: NativePRVMetrics(
estimatedRmssdMs: analysis.prv.estimatedRmssdMs,
estimatedSdnnMs: analysis.prv.estimatedSdnnMs,
meanIpiMs: analysis.prv.meanIpiMs,
medianIpiMs: analysis.prv.medianIpiMs,
pnn50Percent: analysis.prv.pnn50Percent,
rangeIpiMs: analysis.prv.rangeIpiMs,
modeIpiMs: analysis.prv.modeIpiMs,
amo50Percent: analysis.prv.amo50Percent,
coefficientOfVariation: analysis.prv.coefficientOfVariation,
validIntervalCount: Double(analysis.prv.validIntervalCount),
rejectedIntervalCount: Double(analysis.prv.rejectedIntervalCount),
rejectedIntervalRatio: analysis.prv.rejectedIntervalRatio,
reliable: analysis.prv.reliable
),
quality: NativeSignalQualityResult(
score: analysis.quality.score,
level: analysis.quality.level.rawValue,
usableDurationSeconds: analysis.quality.usableDurationSeconds,
droppedFrameRatio: droppedFrameRatio,
saturationRatio: analysis.quality.saturationRatio,
rejectedIntervalRatio: analysis.quality.rejectedIntervalRatio,
signalToNoiseRatio: analysis.quality.signalToNoiseRatio,
motionArtifactRatio: analysis.quality.motionArtifactRatio,
methodAgreementScore: analysis.quality.methodAgreementScore,
timestampJitterRatio: timestampJitterRatio,
longestGapSeconds: longestGapSeconds,
issues: issues
),
startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds,
endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds,
rawSampleCount: Double(snapshot.samples.count),
resampledSampleCount: Double(resampledSampleCount)
)
}
private func makeInvalidResult(
snapshot: SessionSnapshot,
reason: String,
issue: String
) -> NativePPGProcessingResult {
let intervals = zip(snapshot.samples, snapshot.samples.dropFirst()).compactMap { pair -> Double? in
let interval = pair.1.timestampSeconds - pair.0.timestampSeconds
return interval.isFinite && interval > 0 ? interval : nil
}
let estimatedRate = intervals.isEmpty ? 0 : 1 / max(SignalStatistics.median(intervals), 1e-9)
let intervalMAD = intervals.isEmpty ? 0 : SignalStatistics.mad(intervals)
let jitterRatio = estimatedRate > 0 ? intervalMAD * estimatedRate : 0
let saturation = snapshot.samples.isEmpty
? 0
: SignalStatistics.mean(snapshot.samples.map(\.saturationRatio))
let motion = snapshot.samples.isEmpty
? 0
: SignalStatistics.mean(snapshot.samples.map(\.motionArtifactScore))
let droppedRatio = snapshot.samples.isEmpty
? 0
: Double(snapshot.acquisitionDroppedFrameCount)
/ Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount)
return NativePPGProcessingResult(
valid: false,
invalidReason: reason,
bpm: NativeBPMEstimate(
bpm: nil,
temporalBpm: nil,
frequencyBpm: nil,
methodAgreementBpm: nil,
validPeakCount: 0,
rejectedPeakCount: 0,
confidence: 0,
selectedChannel: "none"
),
prv: NativePRVMetrics(
estimatedRmssdMs: nil,
estimatedSdnnMs: nil,
meanIpiMs: nil,
medianIpiMs: nil,
pnn50Percent: nil,
rangeIpiMs: nil,
modeIpiMs: nil,
amo50Percent: nil,
coefficientOfVariation: nil,
validIntervalCount: 0,
rejectedIntervalCount: 0,
rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0,
reliable: false
),
quality: NativeSignalQualityResult(
score: 0,
level: "invalid",
usableDurationSeconds: snapshot.usableDurationSeconds,
droppedFrameRatio: min(max(droppedRatio, 0), 1),
saturationRatio: min(max(saturation, 0), 1),
rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0,
signalToNoiseRatio: nil,
motionArtifactRatio: min(max(motion, 0), 1),
methodAgreementScore: 0,
timestampJitterRatio: max(jitterRatio, 0),
longestGapSeconds: snapshot.maximumAcquisitionGapSeconds,
issues: [issue]
),
startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds,
endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds,
rawSampleCount: Double(snapshot.samples.count),
resampledSampleCount: 0
)
}
private func resetAfterFinishing() {
stateLock.lock()
resetLocked()
stateLock.unlock()
}
private func resetLocked() {
contactDetector?.reset()
contactDetector = nil
configuration = nil
terminalThermalState = nil
numericSamples.removeAll(keepingCapacity: false)
phase = .idle
startedAtMonotonicSeconds = 0
terminalReason = nil
bufferLimitReached = false
processedFrameCount = 0
droppedFrameCount = 0
acquisitionDroppedFrameCount = 0
lastObservedTimestamp = nil
recentFrameIntervals.removeAll(keepingCapacity: true)
maximumAcquisitionGapSeconds = 0
validContactStartedAt = nil
acquisitionStartedAt = nil
lastUsableTimestamp = nil
usableDurationSeconds = 0
lastMetrics = nil
lastContactEvaluation = nil
liveBpm = nil
liveIpiMs = nil
liveBeatSequence = 0
liveBeatTracker.reset()
lastLiveAnalysisFrame = 0
pendingLiveBeats.removeAll(keepingCapacity: false)
liveWindowQuality = nil
totalCollectedSampleCount = 0
}
private static func isUnsafe(_ state: ThermalState) -> Bool {
PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: state.stringValue)
}
private static func finiteTimestamp(_ timestamp: Double, fallback: Double?) -> Double {
if timestamp.isFinite, timestamp >= 0 { return timestamp }
return fallback ?? 0
}
private static func timestampJitterRatio(_ diagnostics: TimebaseDiagnostics) -> Double {
guard diagnostics.estimatedSampleRateHz > 0 else { return 0 }
return max(diagnostics.frameIntervalMADSeconds * diagnostics.estimatedSampleRateHz, 0)
}
private static func nativeIssue(_ issue: SignalQualityIssue) -> String {
switch issue {
case .insufficientDuration:
return "durationInsufficient"
case .invalidTimestamps, .irregularTimestamps:
return "timestampJitter"
case .longInterruption:
return "longGap"
case .tooManyDroppedFrames:
return "droppedFrames"
case .poorFingerContact:
return "contactInsufficient"
case .excessiveSaturation:
return "saturation"
case .motionArtifacts:
return "motionArtifacts"
case .lowPulsatileAmplitude:
return "lowPulsatileAmplitude"
case .lowSignalToNoise:
return "lowSignalToNoiseRatio"
case .tooFewPeaks:
return "tooFewPeaks"
case .inconsistentBPMMethods:
return "methodDisagreement"
case .tooManyRejectedIntervals:
return "tooManyRejectedIntervals"
case .unstableChannel:
return "channelInstability"
case .nonFiniteInput:
return "nonFiniteProcessingResult"
}
}
private static func appendUnique(_ issue: String, to issues: inout [String]) {
if !issues.contains(issue) { issues.append(issue) }
}
private static func invalidReason(for issues: [String]) -> String {
guard let issue = issues.first else {
return "La qualité du signal est insuffisante."
}
switch issue {
case "contactInsufficient", "contactInterrupted":
return "Le contact du doigt a été insuffisant ou interrompu."
case "durationInsufficient":
return "La durée de signal exploitable est insuffisante."
case "droppedFrames", "timestampJitter", "longGap":
return "La capture comporte trop d’interruptions ou d’images manquantes."
case "saturation":
return "Le signal caméra est trop saturé."
case "motionArtifacts":
return "Trop de mouvements ont perturbé la mesure."
case "lowPulsatileAmplitude", "lowSignalToNoiseRatio":
return "Le signal pulsatile est trop faible ou trop bruité."
case "tooFewPeaks", "tooManyRejectedPeaks", "tooManyRejectedIntervals":
return "Trop peu de pulsations fiables ont été détectées."
case "methodDisagreement":
return "Les estimations temporelle et fréquentielle ne concordent pas."
case "channelInstability":
return "Aucun canal couleur n’est suffisamment stable."
default:
return "Le traitement numérique du signal a produit un résultat invalide."
}
}
private static func mapProcessingError(_ error: Error) -> (reason: String, issue: String) {
guard let signalError = error as? PPGSignalError else {
return ("Le traitement numérique du signal a échoué.", "nonFiniteProcessingResult")
}
switch signalError {
case .insufficientSamples, .insufficientDuration:
return ("La durée de signal exploitable est insuffisante.", "durationInsufficient")
case .invalidTimestamps:
return ("Les horodatages caméra sont invalides.", "timestampJitter")
case .noUsableChannel:
return ("Aucun canal couleur n’est exploitable.", "channelInstability")
case .invalidConfiguration:
return ("La configuration du traitement du signal est invalide.", "nonFiniteProcessingResult")
}
}
}
private enum HaptikaPPGFrameProcessorError: LocalizedError {
case invalidConfiguration(String)
case unsafeThermalState(String)
case frameUnavailable
case measurementNotStarted
var errorDescription: String? {
switch self {
case .invalidConfiguration(let reason):
return "Invalid PPG configuration: \(reason)."
case .unsafeThermalState(let state):
return "PPG measurement refused because the iPhone thermal state is \(state)."
case .frameUnavailable:
return "The VisionCamera frame is invalid or does not expose a CMSampleBuffer."
case .measurementNotStarted:
return "No PPG measurement is currently active."
}
}
}
@@ -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)
}
}
@@ -0,0 +1,237 @@
import CoreMedia
import CoreVideo
import Foundation
struct PPGFrameColorMetrics: Sendable {
let timestampSeconds: Double
let redMean: Double
let greenMean: Double
let blueMean: Double
let redVariance: Double
let greenVariance: Double
let luminanceMean: Double
let saturationRatio: Double
}
enum PPGFrameMetricsExtractorError: Error {
case invalidTimestamp
case unsupportedPixelFormat(OSType)
case pixelBufferLockFailed(CVReturn)
case missingPlane
case missingBaseAddress
case emptyRegionOfInterest
}
/// Extracts compact color statistics from a central ROI without retaining or copying a frame.
final class PPGFrameMetricsExtractor {
private let gridSize: Int
private let roiFraction: Double
init(gridSize: Int = 32, roiFraction: Double = 0.55) {
self.gridSize = max(8, gridSize)
self.roiFraction = min(max(roiFraction, 0.2), 0.9)
}
func extract(from sampleBuffer: CMSampleBuffer) throws -> PPGFrameColorMetrics {
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
guard timestamp.isFinite, timestamp >= 0 else {
throw PPGFrameMetricsExtractorError.invalidTimestamp
}
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
throw PPGFrameMetricsExtractorError.missingBaseAddress
}
let lockFlags = CVPixelBufferLockFlags.readOnly
let lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, lockFlags)
guard lockStatus == kCVReturnSuccess else {
throw PPGFrameMetricsExtractorError.pixelBufferLockFailed(lockStatus)
}
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, lockFlags) }
let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)
switch pixelFormat {
case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange:
return try extractBiPlanarYUV(
from: pixelBuffer,
timestamp: timestamp,
isFullRange: true
)
case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange:
return try extractBiPlanarYUV(
from: pixelBuffer,
timestamp: timestamp,
isFullRange: false
)
case kCVPixelFormatType_32BGRA:
return try extractBGRA(from: pixelBuffer, timestamp: timestamp)
default:
throw PPGFrameMetricsExtractorError.unsupportedPixelFormat(pixelFormat)
}
}
private func extractBiPlanarYUV(
from pixelBuffer: CVPixelBuffer,
timestamp: Double,
isFullRange: Bool
) throws -> PPGFrameColorMetrics {
guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2 else {
throw PPGFrameMetricsExtractorError.missingPlane
}
guard let yBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
let uvBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1)
else {
throw PPGFrameMetricsExtractorError.missingBaseAddress
}
let width = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)
let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
let yStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
let uvStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
let yBytes = yBase.assumingMemoryBound(to: UInt8.self)
let uvBytes = uvBase.assumingMemoryBound(to: UInt8.self)
let roi = regionOfInterest(width: width, height: height)
let stepX = max(roi.width / gridSize, 1)
let stepY = max(roi.height / gridSize, 1)
var accumulator = ColorAccumulator()
for y in stride(from: roi.minY, to: roi.maxY, by: stepY) {
for x in stride(from: roi.minX, to: roi.maxX, by: stepX) {
let yValue = Double(yBytes[y * yStride + x])
let uvOffset = (y / 2) * uvStride + (x / 2) * 2
let cb = Double(uvBytes[uvOffset])
let cr = Double(uvBytes[uvOffset + 1])
let rgb = convertYCbCrToRGB(y: yValue, cb: cb, cr: cr, isFullRange: isFullRange)
accumulator.add(red: rgb.red, green: rgb.green, blue: rgb.blue, luminance: yValue)
}
}
return try accumulator.result(timestamp: timestamp)
}
private func extractBGRA(
from pixelBuffer: CVPixelBuffer,
timestamp: Double
) throws -> PPGFrameColorMetrics {
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
throw PPGFrameMetricsExtractorError.missingBaseAddress
}
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
let bytes = baseAddress.assumingMemoryBound(to: UInt8.self)
let roi = regionOfInterest(width: width, height: height)
let stepX = max(roi.width / gridSize, 1)
let stepY = max(roi.height / gridSize, 1)
var accumulator = ColorAccumulator()
for y in stride(from: roi.minY, to: roi.maxY, by: stepY) {
for x in stride(from: roi.minX, to: roi.maxX, by: stepX) {
let offset = y * bytesPerRow + x * 4
let blue = Double(bytes[offset])
let green = Double(bytes[offset + 1])
let red = Double(bytes[offset + 2])
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
accumulator.add(red: red, green: green, blue: blue, luminance: luminance)
}
}
return try accumulator.result(timestamp: timestamp)
}
private func regionOfInterest(width: Int, height: Int) -> (
minX: Int,
maxX: Int,
minY: Int,
maxY: Int,
width: Int,
height: Int
) {
let roiWidth = max(Int(Double(width) * roiFraction), 1)
let roiHeight = max(Int(Double(height) * roiFraction), 1)
let minX = max((width - roiWidth) / 2, 0)
let minY = max((height - roiHeight) / 2, 0)
return (minX, minX + roiWidth, minY, minY + roiHeight, roiWidth, roiHeight)
}
private func convertYCbCrToRGB(
y: Double,
cb: Double,
cr: Double,
isFullRange: Bool
) -> (red: Double, green: Double, blue: Double) {
let red: Double
let green: Double
let blue: Double
if isFullRange {
let shiftedCb = cb - 128
let shiftedCr = cr - 128
red = y + 1.402 * shiftedCr
green = y - 0.344_136 * shiftedCb - 0.714_136 * shiftedCr
blue = y + 1.772 * shiftedCb
} else {
let shiftedY = max(y - 16, 0)
let shiftedCb = cb - 128
let shiftedCr = cr - 128
red = 1.164_383 * shiftedY + 1.596_027 * shiftedCr
green = 1.164_383 * shiftedY - 0.391_762 * shiftedCb - 0.812_968 * shiftedCr
blue = 1.164_383 * shiftedY + 2.017_232 * shiftedCb
}
return (clampByte(red), clampByte(green), clampByte(blue))
}
private func clampByte(_ value: Double) -> Double {
min(max(value, 0), 255)
}
}
private struct ColorAccumulator {
private(set) var count = 0
private var redMean = 0.0
private var greenMean = 0.0
private var blueMean = 0.0
private var luminanceMean = 0.0
private var redM2 = 0.0
private var greenM2 = 0.0
private var saturatedCount = 0
mutating func add(red: Double, green: Double, blue: Double, luminance: Double) {
count += 1
let n = Double(count)
let redDelta = red - redMean
redMean += redDelta / n
redM2 += redDelta * (red - redMean)
let greenDelta = green - greenMean
greenMean += greenDelta / n
greenM2 += greenDelta * (green - greenMean)
blueMean += (blue - blueMean) / n
luminanceMean += (luminance - luminanceMean) / n
// A fingertip lit by the torch commonly clips only the red channel while
// green still contains a clean pulsatile waveform. Count true optical
// clipping only when both useful color channels (or luminance) are clipped.
if (red >= 250 && green >= 250) || luminance >= 250 {
saturatedCount += 1
}
}
func result(timestamp: Double) throws -> PPGFrameColorMetrics {
guard count > 0 else {
throw PPGFrameMetricsExtractorError.emptyRegionOfInterest
}
let divisor = Double(max(count - 1, 1))
return PPGFrameColorMetrics(
timestampSeconds: timestamp,
redMean: redMean,
greenMean: greenMean,
blueMean: blueMean,
redVariance: redM2 / divisor,
greenVariance: greenM2 / divisor,
luminanceMean: luminanceMean,
saturationRatio: Double(saturatedCount) / Double(count)
)
}
}