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

332 lines
13 KiB
Swift

import Foundation
public struct PPGSignalProcessor: Sendable {
public let configuration: PPGProcessingConfiguration
public init(configuration: PPGProcessingConfiguration = .production) throws {
self.configuration = try configuration.validated()
}
public func analyze(samples inputSamples: [PPGFrameSample]) throws -> PPGAnalysisResult {
let finiteSamples = inputSamples.filter(\.hasFiniteSignalValues)
let discardedInputCount = inputSamples.count - finiteSamples.count
guard finiteSamples.count >= 3 else {
throw PPGSignalError.insufficientSamples(required: 3, actual: finiteSamples.count)
}
let sortedTimestamps = finiteSamples.map(\.timestampSeconds).sorted()
guard let firstTimestamp = sortedTimestamps.first,
let lastTimestamp = sortedTimestamps.last,
lastTimestamp > firstTimestamp
else {
throw PPGSignalError.invalidTimestamps
}
let duration = lastTimestamp - firstTimestamp
guard duration >= configuration.minimumAnalysisDurationSeconds else {
throw PPGSignalError.insufficientDuration(
requiredSeconds: configuration.minimumAnalysisDurationSeconds,
actualSeconds: duration
)
}
let red = try resample(finiteSamples, value: \.red)
let green = try resample(finiteSamples, value: \.green)
let blue = try resample(finiteSamples, value: \.blue)
let luminance = try resample(finiteSamples, value: \.luminance)
let contact = try resample(finiteSamples, value: \.contactConfidence)
let saturation = try resample(finiteSamples, value: \.saturationRatio)
let motion = try resample(finiteSamples, value: \.motionArtifactScore)
let greenRedRatio = try PPGSignalResampler.resample(
finiteSamples.map {
let total = max($0.red + $0.green + $0.blue, 1)
return TimestampedValue(
timestampSeconds: $0.timestampSeconds,
value: $0.green / total
)
},
targetSampleRateHz: configuration.targetSampleRateHz,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds
)
let blueRedRatio = try PPGSignalResampler.resample(
finiteSamples.map {
let total = max($0.red + $0.green + $0.blue, 1)
return TimestampedValue(
timestampSeconds: $0.timestampSeconds,
value: $0.blue / total
)
},
targetSampleRateHz: configuration.targetSampleRateHz,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds
)
guard red.values.count == green.values.count,
red.values.count == luminance.values.count,
red.values.count == contact.values.count,
red.values.count == saturation.values.count,
red.values.count == motion.values.count
else {
throw PPGSignalError.invalidTimestamps
}
let blendValues = normalizedBlend(
red: red.values,
green: green.values,
blue: blue.values,
luminance: luminance.values
)
let blendMask = zip(red.validityMask, zip(green.validityMask, luminance.validityMask)).map {
$0.0 && $0.1.0 && $0.1.1
}
let blend = ResampledSignal(
timestampsSeconds: red.timestampsSeconds,
values: blendValues,
validityMask: blendMask,
diagnostics: red.diagnostics
)
let filter = try PPGBandPassFilter(
sampleRateHz: configuration.targetSampleRateHz,
lowCutoffHz: configuration.minimumFrequencyHz,
highCutoffHz: configuration.maximumFrequencyHz,
tapCount: configuration.filterTapCount
)
let commonMetricMask = zip(contact.validityMask, zip(saturation.validityMask, motion.validityMask))
.map { $0.0 && $0.1.0 && $0.1.1 }
// The coaching contact score must never decide whether a frame contains a
// pulse. Camera colour varies too much between front sensors and exposure
// modes. Only timestamp continuity and extreme optical corruption create
// invalid regions; periodicity and beat consistency decide quality later.
let physiologicalMask = contact.values.indices.map { index in
commonMetricMask[index]
&& saturation.values[index] <= configuration.maximumSaturationRatio
&& motion.values[index] <= configuration.maximumMotionArtifactScore
}
let guardedPhysiologicalMask = PPGSignalPreprocessor.dilateInvalidRegions(
physiologicalMask,
radius: max(Int((configuration.targetSampleRateHz * 0.20).rounded()), 1)
)
let sourceCandidates: [(PPGSignalChannel, ResampledSignal)] = [
(.red, red),
(.green, green),
(.blue, blue),
(.luminance, luminance),
(.greenRedRatio, greenRedRatio),
(.blueRedRatio, blueRedRatio),
(.normalizedBlend, blend),
]
let candidates = try sourceCandidates.map { channel, resampled in
try analyzeChannel(
channel: channel,
resampled: resampled,
guardedPhysiologicalMask: guardedPhysiologicalMask,
filter: filter
)
}
guard var selected = candidates.max(by: channelCandidateIsWorse) else {
throw PPGSignalError.noUsableChannel
}
selected.timebase.inputSampleCount = inputSamples.count
selected.timebase.discardedSampleCount += discardedInputCount
let prvCalculation = try PPGPRVCalculator.calculate(
intervals: selected.peakDetection.intervals,
configuration: configuration
)
let validCoverage = Double(selected.validityMask.filter { $0 }.count)
/ Double(max(selected.validityMask.count, 1))
var qualityTimebase = selected.timebase
qualityTimebase.usableDurationSeconds = min(
qualityTimebase.usableDurationSeconds,
qualityTimebase.durationSeconds * validCoverage
)
let quality = PPGSignalQualityEvaluator.evaluate(
SignalQualityInput(
timebase: qualityTimebase,
contactConfidence: contact.values,
saturationRatio: saturation.values,
motionArtifactScore: motion.values,
filteredSignal: selected.filteredSignal,
validityMask: selected.validityMask,
bpmEstimate: selected.bpm,
intervals: prvCalculation.intervals,
signalToNoiseRatioDB: selected.snrDB,
channelStabilityScore: selected.selectionScore
),
configuration: configuration
)
// A camera PPG can provide a useful mean heart rate before it provides
// beat-to-beat timing good enough for PRV. Publish that conservative BPM
// when the evaluator did not mark the signal structurally invalid; keep
// PRV reliability gated below by the stricter quality/confidence checks.
// This mirrors how practical camera HR apps remain useful on borderline
// captures without turning them into HRV measurements.
let isValid = quality.level != .invalid
&& quality.score >= PPGPublicationThresholds.bpmQualityScore
&& selected.bpm.confidence >= PPGPublicationThresholds.bpmConfidence
&& selected.bpm.bpm != nil
var publishedBPM = selected.bpm
var publishedPRV = prvCalculation.metrics
if !isValid {
// Keep independent estimates for diagnostics, but do not publish a definitive BPM.
publishedBPM.bpm = nil
publishedPRV.reliable = false
} else if quality.score < PPGPublicationThresholds.prvQualityScore
|| selected.bpm.confidence < PPGPublicationThresholds.prvBpmConfidence {
// Beat-to-beat variability requires a stricter signal than mean pulse rate.
publishedPRV.reliable = false
}
return PPGAnalysisResult(
status: isValid ? .valid : .invalid,
bpm: publishedBPM,
prv: publishedPRV,
quality: quality,
peaks: selected.peakDetection.peaks,
intervals: prvCalculation.intervals,
timebase: selected.timebase
)
}
private struct ChannelCandidate {
var channel: PPGSignalChannel
var filteredSignal: [Double]
var validityMask: [Bool]
var timebase: TimebaseDiagnostics
var peakDetection: PeakDetectionResult
var bpm: BPMEstimate
var snrDB: Double?
var selectionScore: Double
}
private func analyzeChannel(
channel: PPGSignalChannel,
resampled: ResampledSignal,
guardedPhysiologicalMask: [Bool],
filter: PPGBandPassFilter
) throws -> ChannelCandidate {
let prepared = PPGSignalPreprocessor.detrendAndNormalize(
resampled.values,
sampleRateHz: configuration.targetSampleRateHz
)
let filtered = filter.apply(to: prepared)
let gapGuardedMask = PPGSignalPreprocessor.dilateInvalidRegions(
resampled.validityMask,
radius: filter.invalidRegionGuardRadius
)
var validityMask = zip(gapGuardedMask, guardedPhysiologicalMask).map { $0.0 && $0.1 }
let edgeGuard = min(filter.invalidRegionGuardRadius, validityMask.count / 4)
if edgeGuard > 0 {
for index in 0..<edgeGuard { validityMask[index] = false }
for index in (validityMask.count - edgeGuard)..<validityMask.count {
validityMask[index] = false
}
}
let peakDetection = try PPGPeakDetector.detect(
signal: filtered,
timestampsSeconds: resampled.timestampsSeconds,
validityMask: validityMask,
configuration: configuration
)
let bpm = try PPGBPMEstimator.estimate(
signal: filtered,
timestampsSeconds: resampled.timestampsSeconds,
validityMask: validityMask,
peakDetection: peakDetection,
selectedChannel: channel,
configuration: configuration
)
let snrDB = bpm.frequencyBpm.flatMap { frequencyBPM in
SignalStatistics.periodicBandSNRDB(
values: filtered,
timestamps: resampled.timestampsSeconds,
validityMask: validityMask,
fundamentalFrequencyHz: frequencyBPM / 60,
minimumFrequencyHz: configuration.minimumFrequencyHz,
maximumFrequencyHz: configuration.maximumFrequencyHz
)
}
let snrScore = snrDB.map { SignalStatistics.clamp01(($0 + 3) / 15) } ?? 0
let coverage = Double(validityMask.filter { $0 }.count) / Double(max(validityMask.count, 1))
let acceptedIntervals = peakDetection.acceptedIntervals
let intervalAcceptance = peakDetection.intervals.isEmpty
? 0
: Double(acceptedIntervals.count) / Double(peakDetection.intervals.count)
let beatQuality = acceptedIntervals.isEmpty
? 0
: SignalStatistics.mean(acceptedIntervals.map(\.qualityScore))
let selectionScore = SignalStatistics.clamp01(
0.42 * bpm.confidence
+ 0.23 * snrScore
+ 0.13 * coverage
+ 0.12 * intervalAcceptance
+ 0.10 * beatQuality
)
return ChannelCandidate(
channel: channel,
filteredSignal: filtered,
validityMask: validityMask,
timebase: resampled.diagnostics,
peakDetection: peakDetection,
bpm: bpm,
snrDB: snrDB,
selectionScore: selectionScore
)
}
private func resample(
_ samples: [PPGFrameSample],
value keyPath: KeyPath<PPGFrameSample, Double>
) throws -> ResampledSignal {
try PPGSignalResampler.resample(
samples.map {
TimestampedValue(timestampSeconds: $0.timestampSeconds, value: $0[keyPath: keyPath])
},
targetSampleRateHz: configuration.targetSampleRateHz,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds
)
}
private func normalizedBlend(
red: [Double],
green: [Double],
blue: [Double],
luminance: [Double]
) -> [Double] {
let standardizedRed = robustStandardize(red)
let standardizedGreen = robustStandardize(green)
let standardizedBlue = robustStandardize(blue)
let standardizedLuminance = robustStandardize(luminance)
return standardizedRed.indices.map { index in
// A compact chrominance projection suppresses common illumination and
// automatic-exposure changes while preserving blood-volume modulation.
0.70 * standardizedGreen[index]
- 0.40 * standardizedRed[index]
- 0.30 * standardizedBlue[index]
+ 0.10 * standardizedLuminance[index]
}
}
private func robustStandardize(_ values: [Double]) -> [Double] {
let center = SignalStatistics.median(values)
let scale = max(
1.4826 * SignalStatistics.mad(values, around: center),
SignalStatistics.standardDeviation(values) * 0.20,
1e-9
)
return values.map { min(max(($0 - center) / scale, -12), 12) }
}
private func channelCandidateIsWorse(_ lhs: ChannelCandidate, _ rhs: ChannelCandidate) -> Bool {
if abs(lhs.selectionScore - rhs.selectionScore) > 1e-12 {
return lhs.selectionScore < rhs.selectionScore
}
let lhsIndex = PPGSignalChannel.allCases.firstIndex(of: lhs.channel) ?? 0
let rhsIndex = PPGSignalChannel.allCases.firstIndex(of: rhs.channel) ?? 0
// Prefer the earlier, simpler channel when scores are exactly tied.
return lhsIndex > rhsIndex
}
}