feat: implement Haptika PPG and Watch V1
This commit is contained in:
+887
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user