Skip to content
Open
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
8 changes: 4 additions & 4 deletions python/bench/test_bench_vitals.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@ def test_heart_rate_extract_per_frame_cost(benchmark) -> None:
hr = HeartRateExtractor.esp32_default()
rng = Random(43)
for i in range(1500):
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, weights=weights)
residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, phases=phases)

def _one_frame():
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, weights=weights)
residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, phases=phases)

benchmark(_one_frame)

Expand Down
32 changes: 27 additions & 5 deletions python/src/bindings/vitals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,22 @@ impl PyBreathingExtractor {
// ─── HeartRateExtractor ──────────────────────────────────────────────

/// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude
/// residuals via 0.8–2.0 Hz bandpass + autocorrelation peak detection.
/// residuals and per-subcarrier unwrapped phases (radians) via
/// 0.8–2.0 Hz bandpass + autocorrelation peak detection.
///
/// Python:
/// ```python
/// from wifi_densepose import HeartRateExtractor
///
/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window
///
/// # Feed residuals and matching unwrapped phases from your preprocessor.
/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate
/// # extraction because the Rust core requires phase data for each subcarrier.
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
/// if est is not None:
/// print(est.value_bpm, est.confidence)
/// ```
#[pyclass(name = "HeartRateExtractor")]
pub struct PyHeartRateExtractor {
inner: HeartRateExtractor,
Expand All @@ -265,10 +280,17 @@ impl PyHeartRateExtractor {
Self { inner: HeartRateExtractor::esp32_default() }
}

/// Extract heart rate from per-subcarrier residuals. GIL released
/// during DSP.
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
/// Extract heart rate from per-subcarrier residuals and matching
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
/// and return `None` because the Rust extractor requires phase data.
/// GIL released during DSP.
fn extract(
&mut self,
py: Python<'_>,
residuals: Vec<f64>,
phases: Vec<f64>,
) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &phases));
est.map(PyVitalEstimate::from_rust)
}

Expand Down
36 changes: 35 additions & 1 deletion python/tests/test_vitals.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,44 @@ def test_heart_rate_explicit_ctor() -> None:

def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
hr = HeartRateExtractor.esp32_default()
out = hr.extract(residuals=[0.0] * 56, weights=[])
out = hr.extract(residuals=[0.0] * 56, phases=[0.0] * 56)
assert out is None


def test_heart_rate_extract_rejects_old_weights_keyword() -> None:
hr = HeartRateExtractor.esp32_default()
with pytest.raises(TypeError):
hr.extract(residuals=[0.0] * 56, weights=[0.0] * 56)


def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None:
"""Drive the extractor with a synthetic 1.2 Hz sine (72 BPM) plus
same-length phase data. This proves the binding feeds Rust's required
`phases` slice instead of an empty vector that would keep returning None."""
hr = HeartRateExtractor.esp32_default()
sample_rate = 100.0
target_freq = 1.2 # 72 BPM
n_samples = int(60 * sample_rate)
phases = [i * 0.01 for i in range(56)]

produced_estimate = False
rng = Random(43)
for i in range(n_samples):
t = i / sample_rate
base = math.sin(2.0 * math.pi * target_freq * t)
residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)]
est = hr.extract(residuals=residuals, phases=phases)
if est is not None:
produced_estimate = True
assert math.isfinite(est.value_bpm)
assert 0.0 <= est.confidence <= 1.0
break

assert produced_estimate, (
"HeartRateExtractor never produced an estimate after 60s of synthetic data"
)


# ─── Build feature flag ──────────────────────────────────────────────


Expand Down