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

277 lines
11 KiB
Swift

import Foundation
public enum PPGPeakDetector {
public static func detect(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
configuration: PPGProcessingConfiguration
) throws -> PeakDetectionResult {
let configuration = try configuration.validated()
guard signal.count == timestampsSeconds.count,
signal.count == validityMask.count,
signal.count >= 5
else {
throw PPGSignalError.insufficientSamples(required: 5, actual: signal.count)
}
let positive = detectPolarity(
signal: signal,
timestampsSeconds: timestampsSeconds,
validityMask: validityMask,
polarity: 1,
configuration: configuration
)
let negative = detectPolarity(
signal: signal,
timestampsSeconds: timestampsSeconds,
validityMask: validityMask,
polarity: -1,
configuration: configuration
)
if negative.confidence > positive.confidence + 1e-12 {
return negative
}
if abs(negative.confidence - positive.confidence) <= 1e-12,
negative.acceptedIntervals.count > positive.acceptedIntervals.count {
return negative
}
return positive
}
private struct Candidate {
var index: Int
var amplitude: Double
var prominence: Double
}
private static func detectPolarity(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
polarity: Int,
configuration: PPGProcessingConfiguration
) -> PeakDetectionResult {
let values = signal.map { ($0.isFinite ? $0 : 0) * Double(polarity) }
let timestampIntervals = zip(timestampsSeconds, timestampsSeconds.dropFirst())
.map { $1 - $0 }
.filter { $0.isFinite && $0 > 0 }
let sampleInterval = SignalStatistics.median(timestampIntervals)
guard sampleInterval > 0 else {
return PeakDetectionResult(peaks: [], intervals: [], selectedPolarity: polarity, confidence: 0)
}
let sampleRate = 1 / sampleInterval
let validValues = values.indices.compactMap { validityMask[$0] ? values[$0] : nil }
let center = SignalStatistics.median(validValues)
let robustScale = max(1.4826 * SignalStatistics.mad(validValues, around: center), 1e-6)
let amplitudeThreshold = center + max(configuration.minimumPeakAmplitude, robustScale * 0.18)
let prominenceRadius = max(Int((0.45 * sampleRate).rounded()), 2)
var candidates: [Candidate] = []
candidates.reserveCapacity(max(signal.count / 15, 8))
for index in 1..<(values.count - 1) {
guard validityMask[index],
validityMask[index - 1],
validityMask[index + 1],
values[index] >= amplitudeThreshold,
values[index] > values[index - 1],
values[index] >= values[index + 1]
else { continue }
let leftStart = max(0, index - prominenceRadius)
let rightEnd = min(values.count - 1, index + prominenceRadius)
let leftMinimum = values[leftStart..<index].min() ?? values[index]
let rightMinimum = values[(index + 1)...rightEnd].min() ?? values[index]
let prominence = min(values[index] - leftMinimum, values[index] - rightMinimum)
guard prominence >= configuration.minimumPeakProminence else { continue }
candidates.append(Candidate(index: index, amplitude: values[index], prominence: prominence))
}
let minimumDistance = max(
Int((configuration.refractoryPeriodSeconds * sampleRate).rounded()),
2
)
var selected: [Candidate] = []
for candidate in candidates {
if let last = selected.last, candidate.index - last.index < minimumDistance {
let candidateStrength = candidate.prominence + 0.25 * candidate.amplitude
let lastStrength = last.prominence + 0.25 * last.amplitude
if candidateStrength > lastStrength {
selected[selected.count - 1] = candidate
}
} else {
selected.append(candidate)
}
}
let peaks = selected.map { candidate -> DetectedPeak in
let index = candidate.index
let left = values[index - 1]
let middle = values[index]
let right = values[index + 1]
let denominator = left - 2 * middle + right
let fractionalOffset: Double
if abs(denominator) > 1e-12 {
fractionalOffset = min(max(0.5 * (left - right) / denominator, -0.5), 0.5)
} else {
fractionalOffset = 0
}
let refinedTimestamp = timestampsSeconds[index] + fractionalOffset * sampleInterval
return DetectedPeak(
sampleIndex: index,
timestampSeconds: refinedTimestamp,
amplitude: candidate.amplitude,
prominence: candidate.prominence
)
}
var intervals = zip(peaks, peaks.dropFirst()).enumerated().map { sourceIndex, pair in
let durationMilliseconds = (pair.1.timestampSeconds - pair.0.timestampSeconds) * 1_000
let lowerIndex = min(pair.0.sampleIndex, pair.1.sampleIndex)
let upperIndex = max(pair.0.sampleIndex, pair.1.sampleIndex)
let crossesInvalidRegion = validityMask[lowerIndex...upperIndex].contains(false)
return PulseInterval(
sourceIndex: sourceIndex,
startSeconds: pair.0.timestampSeconds,
endSeconds: pair.1.timestampSeconds,
durationMilliseconds: durationMilliseconds,
rejectionReason: crossesInvalidRegion ? .invalidSignalRegion : nil
)
}
let minimumInterval = 1_000 / configuration.maximumFrequencyHz
let maximumInterval = 1_000 / configuration.minimumFrequencyHz
for index in intervals.indices where intervals[index].rejectionReason == nil {
let duration = intervals[index].durationMilliseconds
if !duration.isFinite || duration < minimumInterval || duration > maximumInterval {
intervals[index].rejectionReason = .outsidePhysiologicalRange
}
}
// Score every pulse independently. A short movement or pressure change can
// therefore reject only the affected beats instead of invalidating the
// complete recording. Cycles are normalized to 50 points before their
// shape is compared with neighbouring cycles, following the published
// morphology-based quality approach used for camera PPG.
let cycleShapes = intervals.indices.map { index in
normalizedCycle(
values: values,
lowerIndex: peaks[index].sampleIndex,
upperIndex: peaks[index + 1].sampleIndex,
outputCount: 50
)
}
let medianProminence = max(SignalStatistics.median(peaks.map(\.prominence)), 1e-9)
for index in intervals.indices {
let lowerPeakQuality = SignalStatistics.clamp01(peaks[index].prominence / medianProminence)
let upperPeakQuality = SignalStatistics.clamp01(peaks[index + 1].prominence / medianProminence)
let prominenceQuality = min(lowerPeakQuality, upperPeakQuality)
let neighbourhood = max(0, index - 3)...min(intervals.count - 1, index + 3)
let correlations = neighbourhood.compactMap { neighbour -> Double? in
guard neighbour != index,
let shape = cycleShapes[index],
let other = cycleShapes[neighbour]
else { return nil }
return shapeCorrelation(shape, other)
}
let morphologyQuality: Double
if correlations.isEmpty {
morphologyQuality = intervals.count < 3 ? 0.75 : 0
} else {
// Use the best two local matches so one adjacent corrupted cycle does
// not drag a clean beat below the acceptance threshold.
let strongest = correlations.sorted(by: >).prefix(2)
morphologyQuality = SignalStatistics.clamp01(
(SignalStatistics.mean(Array(strongest)) + 1) / 2
)
}
let technicalQuality = SignalStatistics.clamp01(
0.65 * morphologyQuality + 0.35 * prominenceQuality
)
intervals[index].qualityScore = technicalQuality
if intervals[index].rejectionReason == nil,
intervals.count >= 3,
technicalQuality < 0.42 {
intervals[index].rejectionReason = .lowMorphologyQuality
}
}
let physiologicallyPlausible = intervals
.filter(\.isAccepted)
.map(\.durationMilliseconds)
if physiologicallyPlausible.count >= 3 {
let median = SignalStatistics.median(physiologicallyPlausible)
let robustDeviation = 1.4826 * SignalStatistics.mad(physiologicallyPlausible, around: median)
let allowedDeviation = max(0.28 * median, 3.5 * robustDeviation, 90)
for index in intervals.indices where intervals[index].rejectionReason == nil {
if abs(intervals[index].durationMilliseconds - median) > allowedDeviation {
intervals[index].rejectionReason = .localOutlier
}
}
}
let accepted = intervals.filter(\.isAccepted)
let acceptedDurations = accepted.map(\.durationMilliseconds)
let intervalAcceptance = intervals.isEmpty
? 0
: Double(accepted.count) / Double(intervals.count)
let countScore = min(Double(accepted.count) / 8, 1)
let regularity: Double
if acceptedDurations.count >= 2 {
let median = max(SignalStatistics.median(acceptedDurations), 1)
let robustCV = 1.4826 * SignalStatistics.mad(acceptedDurations, around: median) / median
regularity = SignalStatistics.clamp01(1 - robustCV / 0.20)
} else {
regularity = 0
}
let prominenceScore = SignalStatistics.clamp01(medianProminence / 1.5)
let validCoverage = Double(validityMask.filter { $0 }.count) / Double(max(validityMask.count, 1))
let confidence = 0.27 * countScore
+ 0.23 * intervalAcceptance
+ 0.20 * regularity
+ 0.20 * prominenceScore
+ 0.10 * validCoverage
return PeakDetectionResult(
peaks: peaks,
intervals: intervals,
selectedPolarity: polarity,
confidence: confidence
)
}
private static func normalizedCycle(
values: [Double],
lowerIndex: Int,
upperIndex: Int,
outputCount: Int
) -> [Double]? {
guard lowerIndex >= 0,
upperIndex < values.count,
upperIndex - lowerIndex >= 3,
outputCount >= 3
else { return nil }
let span = Double(upperIndex - lowerIndex)
let sampled = (0..<outputCount).map { outputIndex -> Double in
let position = Double(lowerIndex)
+ Double(outputIndex) * span / Double(outputCount - 1)
let left = min(max(Int(position.rounded(.down)), lowerIndex), upperIndex)
let right = min(left + 1, upperIndex)
let fraction = position - Double(left)
return values[left] + fraction * (values[right] - values[left])
}
let center = SignalStatistics.mean(sampled)
let scale = SignalStatistics.standardDeviation(sampled)
guard scale.isFinite, scale > 1e-8 else { return nil }
return sampled.map { ($0 - center) / scale }
}
private static func shapeCorrelation(_ lhs: [Double], _ rhs: [Double]) -> Double? {
guard lhs.count == rhs.count, lhs.count >= 3 else { return nil }
let value = zip(lhs, rhs).reduce(0.0) { $0 + $1.0 * $1.1 } / Double(lhs.count)
return value.isFinite ? min(max(value, -1), 1) : nil
}
}