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