| name | rppg-signal-review |
| description | Domain invariants for VitalSense's rPPG signal chain (dsp_pipeline.py, signal_extractor.py, hrv.py). Check any edit to these files against this before applying it. |
| user-invocable | false |
Before changing dsp_pipeline.py, signal_extractor.py, or hrv.py, check the edit against these invariants. They exist because breaking them silently produces a plausible-looking BPM/HRV number that's wrong, not a crash.
Fixed constants — don't drift without a reason
FS = 30 (dsp_pipeline.py) — sampling rate assumption baked through the whole chain. If a caller resamples or the camera runs at a different FPS, FS must travel with the signal, not be re-hardcoded.
- Cardiac band
LOW=0.7, HIGH=4.0 Hz (42–240 BPM) — bandpass and FFT peak search both key off this. Widening it lets non-cardiac noise (motion, lighting flicker) win the peak search.
WINDOW_SECONDS = 15.0 (signal_extractor.py) — buffer length the FFT bin resolution depends on. Shrinking it lowers frequency resolution silently.
- HRV needs ≥30s of samples (
compute_lf_hf) and ≥3 detected peaks (compute_hrv) before returning a number — these are correctness gates, not arbitrary; don't loosen them to "make something show up."
- LF/HF bands: LF 0.04–0.15 Hz, HF 0.15–0.40 Hz — standard HRV band split, don't rename or merge.
Structural rules
- Filtering is zero-phase (
sosfiltfilt, not lfilter) and SOS form, not ba. Both choices are deliberate (no time shift, numerical stability at higher orders) — don't swap them for a "simpler" API.
- Detrend before filter: subtract the mean before
sosfiltfilt so DC/lighting offset doesn't dominate. Any new signal path into the filter needs the same detrend step.
- Every DSP/signal function fails soft, returning a documented sentinel (
(0, 0), None, (None, None), empty array) instead of raising, and logs via the module's _log_*_error three-part helper (what failed / what caused it / fallback). Match this pattern in new code — don't let an exception propagate out of the signal path into the main loop.
- Green channel only for rPPG signal (
roi[:, :, 1]) — this is the documented, literature-backed choice (Verkruysse et al. 2008), not an arbitrary pick.
FOREHEAD_IDS is a specific stable MediaPipe landmark set — changing it changes ROI stability; don't swap in a different index list without checking it still bounds a forehead-only region.
Calibration debt — already flagged, don't "fix" it as a bug
- RMSSD stress thresholds (20ms, 30ms) and LF/HF threshold (2.0) in
hrv.py::classify_stress are explicitly marked as uncalibrated starting points pending ground-truth validation. Don't tune them speculatively; if asked to adjust, say they're unvalidated and ask for the calibration source.
Review checklist for any diff to these three files
- Does it still return the same sentinel value shape on failure (caller code assumes it)?
- Does a new numeric path get detrended the same way before FFT/filtering?
- Does it preserve the try/except-with-fallback-log structure, or does it introduce a code path that can raise into
main.py's loop?
- If it changes a constant (
FS, LOW, HIGH, WINDOW_SECONDS, min-sample gates), does every consumer of that constant get updated together?