Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ class BandPassFilter {
}

double filter(double x) {
if (!x.isFinite) {
return _isInitialized ? y1 : 0;
}

if (!_isInitialized) {
_isInitialized = true;
x1 = x;
Expand All @@ -60,6 +64,14 @@ class BandPassFilter {
}

final y = a0 * x + a1 * x1 + a2 * x2 - b1 * y1 - b2 * y2;
if (!y.isFinite) {
_isInitialized = false;
x1 = x;
x2 = x;
y1 = 0;
y2 = 0;
return 0;
}
x2 = x1;
x1 = x;
y2 = y1;
Expand Down
120 changes: 105 additions & 15 deletions open_wearable/lib/apps/heart_tracker/model/ppg_filter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import 'dart:math';

import 'package:open_wearable/apps/heart_tracker/model/band_pass_filter.dart';
import 'package:open_wearable/apps/heart_tracker/model/high_pass_filter.dart';
import 'package:open_wearable/apps/heart_tracker/model/msptd_fast_v2_detector.dart';

enum PpgSignalQuality {
unavailable,
Expand Down Expand Up @@ -81,8 +80,6 @@ class PpgFilter {
final double sampleFreq;
final int timestampExponent;

final MsptdFastV2Detector _msptdDetector = const MsptdFastV2Detector();

double _hrEstimate = 75.0;
double _hrCovariance = 1.0;
final double _hrProcessNoise = 0.02;
Expand Down Expand Up @@ -114,13 +111,18 @@ class PpgFilter {
this.opticalTemperatureStream,
});

void initialize() {
// Eagerly build pipelines so filters/subscriptions are ready on app start.
displaySignalStream;
_sampleStream;
_metricsStream;
}

Stream<(int, double)> get displaySignalStream {
if (_displaySignalStream != null) {
return _displaySignalStream!;
}
_displaySignalStream = _sampleStream
.map((sample) => (sample.timestamp, sample.displaySignal))
.asBroadcastStream();
_displaySignalStream = _createLiveDisplaySignalStream().asBroadcastStream();
return _displaySignalStream!;
}

Expand Down Expand Up @@ -173,6 +175,63 @@ class PpgFilter {
return _vitalsStream!;
}

Stream<(int, double)> _createLiveDisplaySignalStream() {
final safeSampleFreq =
sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0;
final bandPassFilter = BandPassFilter(
sampleFreq: safeSampleFreq,
lowCut: 0.5,
highCut: 3.2,
);
int? selectedChannel;
var lastFiniteSample = 0.0;

return inputStream.map((sample) {
selectedChannel ??= _pickStableDisplayChannel(sample);
var selectedOpticalSignal = _readDisplayChannel(
sample,
selectedChannel!,
);
if (!selectedOpticalSignal.isFinite) {
selectedOpticalSignal = lastFiniteSample;
} else {
lastFiniteSample = selectedOpticalSignal;
}
final bandPassed = bandPassFilter.filter(selectedOpticalSignal);
return (sample.timestamp, bandPassed);
});
}

int _pickStableDisplayChannel(PpgOpticalSample sample) {
final candidates = [sample.green, sample.red, sample.ir];
var bestChannel = 0;
var bestEnergy = -1.0;
for (var i = 0; i < candidates.length; i++) {
final value = candidates[i];
if (!value.isFinite) {
continue;
}
final energy = value.abs();
if (energy > bestEnergy && energy > 1e-9) {
bestEnergy = energy;
bestChannel = i;
}
}
return bestChannel;
}

double _readDisplayChannel(PpgOpticalSample sample, int channelIndex) {
switch (channelIndex) {
case 1:
return sample.red;
case 2:
return sample.ir;
case 0:
default:
return sample.green;
}
}

Stream<_MotionAwareSample> _createProcessedStream() {
final safeSampleFreq =
sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0;
Expand Down Expand Up @@ -267,20 +326,51 @@ class PpgFilter {
return (heartRateBpm: null, peakTimestamps: const []);
}

// Match MSPTDfast-v2 usage: run beat detection on the raw PPG waveform
// (lightly centered only) and let the detector handle detrending/scales.
// Simple extraction: local maxima on the filtered waveform with a fixed
// refractory distance and dynamic amplitude threshold.
final signal =
samples.map((sample) => sample.rawGreen).toList(growable: false);
samples.map((sample) => sample.signal).toList(growable: false);
final mean = signal.reduce((a, b) => a + b) / signal.length;
final centered =
signal.map((value) => value - mean).toList(growable: false);
final msptdIndices = _msptdDetector.detectPeakIndices(
centered,
sampleFreqHz: estimatedSampleFreqHz,
);

final peaks = msptdIndices
.where((index) => index >= 0 && index < samples.length)
final signalStd = _standardDeviation(centered);
if (!signalStd.isFinite || signalStd < 1e-4) {
return (heartRateBpm: null, peakTimestamps: const []);
}

final safeSampleFreq =
estimatedSampleFreqHz.isFinite && estimatedSampleFreqHz > 0
? estimatedSampleFreqHz
: (sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0);
final minPeakDistanceSamples =
max(1, (safeSampleFreq * _minBeatIntervalSec * 0.85).round());
final amplitudeThreshold = max(0.04, signalStd * 0.35);

final peakIndices = <int>[];
for (var i = 1; i < centered.length - 1; i++) {
final current = centered[i];
if (!current.isFinite || current < amplitudeThreshold) {
continue;
}
final isLocalMaximum =
current >= centered[i - 1] && current > centered[i + 1];
if (!isLocalMaximum) {
continue;
}

if (peakIndices.isNotEmpty &&
(i - peakIndices.last) < minPeakDistanceSamples) {
// Within refractory period keep only the stronger peak.
if (current > centered[peakIndices.last]) {
peakIndices[peakIndices.length - 1] = i;
}
continue;
}
peakIndices.add(i);
}

final peaks = peakIndices
.map((index) => samples[index].timestamp)
.toList(growable: false);

Expand Down
Loading