332 lines
13 KiB
Swift
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
|
|
}
|
|
}
|