Native Android port of the camera-based PPG heart-rate monitor. Unlike web_app
(which POSTs frames to the FastAPI backend in server.py), this app runs the
entire pipeline on-device — no network calls, no server. Camera frames never
leave the phone.
The DSP pipeline runs as actual Python, embedded in the APK via
Chaquopy (a real CPython interpreter bundled
into the app, called from Kotlin through a JNI bridge). This is deliberate:
scipy and PyWavelets have no Android build (verified against Chaquopy's own
package repository and PyPI — neither publishes anything for Android), so the
two scipy/pywt calls in the original pipeline were rewritten using numpy only
(which Chaquopy does support). Everything else is numpy's own rfft /
linalg.eigh — the same class of primitive the original backend uses, not a
hand-rolled substitute.
Backend (pulsefusionnet/, scipy+pywt) |
On-device (app/src/main/python/pulsefusion_ppg.py, numpy-only) |
|---|---|
RealPhysiologicalPreprocessor.preprocess_camera_ppg |
preprocess_camera_ppg() — same stages, same order |
scipy.signal.butter + filtfilt
|
_butter_bandpass / _filtfilt — hand-written zpk + bilinear-transform IIR design (scipy has no Android build) |
pywt.wavedec / waverec (sym4) |
_wavedec / _waverec — self-contained sym4 DWT/IDWT + soft threshold (pywt has no Android build) |
scipy.signal.savgol_filter |
_savgol_11 — same 11-pt cubic coefficients |
ClassicalPPGExtractor.extract_ensemble_bpm |
extract_ensemble_bpm() — real numpy.fft.rfft zero-padded spectrum + Autocorrelation (ACF) Time-Lag Peak Estimator + Multi-Domain Harmonic Disambiguation. |
| Option 1 Quality Weighting |
_compute_spectral_snr + _compute_acf_prominence + _compute_abs_skewness — dynamic window quality weighting |
| Option 2 Adaptive Filtering |
analyze_session() — dynamic lowcut shifting ( |
| Option 4 IMU Motion Cancellation |
cancel_imu_motion_artifacts() — 3-axis IMU accelerometer NLMS adaptive noise cancellation filter. Overloaded bridge in PyPpgBridge.kt. |
| App.js finger/movement heuristics |
ppg/FingerMovementDetector.kt (Kotlin) — identical thresholds, ambient light only (no torch/flash, same as the web app) |
| Server-side EMA smoothing | Same α=0.30 EMA, done in MeasurementViewModel
|
Only two functions are genuinely hand-rolled substitutes (the Butterworth
filter and the wavelet denoise, standing in for scipy/pywt) — everything else
in pulsefusion_ppg.py is the exact same algorithm using numpy's real FFT and
linear algebra. Those two substitutes are validated against the real
scipy/pywt backend (see below): correlation ~0.999 on the cleaned signal,
consensus BPM within 0.00–0.04 BPM across a 58–140 BPM sweep.
- APK size: bundling a Python interpreter + numpy adds real weight —
expect tens of MB per ABI (we restrict to
arm64-v8a+x86_64inapp/build.gradle.ktsto avoid paying that 4x). Use Play's per-ABI App Bundle splitting so users don't download both. - Startup cost:
Python.start()(inPulseFusionApplication) initializes the interpreter once at process start, adding to cold-start latency. - Licensing: Chaquopy is free for open-source projects; commercial/closed- source use requires checking their current license terms directly at chaquo.com before shipping to production — I have not verified pricing/terms for a commercial release, only that the package repository lacks scipy/pywt.
A native Material3 app, not a ported web page — a branded cold-start splash
(OS-level androidx.core.splashscreen icon, then an in-app loading screen
while camera permission is checked) followed by an explicit journey:
Loading → Permission → Home → Detecting → Measuring → Result/Failed, custom
stroke-icon set (no emoji), animated screen transitions, a live PPG sparkline,
and a countdown ring drawn with Canvas. No camera preview surface is shown
(once a fingertip covers the lens the raw feed is just a dark-red blob with no
information); instead the scan/measure screens show the derived waveform and
an animated fingerprint/scan indicator, which communicates progress better.
android_app/
app/src/main/python/pulsefusion_ppg.py — the REAL on-device algorithm (numpy-only, runs via Chaquopy)
app/src/main/kotlin/com/pulsefusionnet/app/
PulseFusionApplication.kt — starts the embedded Python interpreter once at process start
MainActivity.kt — permission flow, camera lifecycle, screen routing
MeasurementViewModel.kt — state machine mirroring app.js (detect → stabilize → 60s scan → result)
ppg/PyPpgBridge.kt — Kotlin -> Python call bridge (Chaquopy)
ppg/FingerMovementDetector.kt — finger/movement heuristics (plain Kotlin, no numpy needed)
camera/CameraController.kt — CameraX ImageAnalysis, YUV_420_888 → RGB frame stats
ui/ — Compose screens, theme, icon set, shared components
- Open the
android_app/folder in Android Studio (Koala+ recommended). - Android Studio will offer to generate the Gradle wrapper jar if it's
missing — accept it (this repo ships
gradlew/gradle-wrapper.propertiesbut not the binarygradle-wrapper.jar, since it can't be authored as text). Alternatively, if you have Gradle installed locally:gradle wrapperfrom this directory once. - First sync will download the Chaquopy plugin and its Python/numpy
distribution from
chaquo.com/maven— needs network access. - Sync, then Run on a device with a rear camera (API 26+). An emulator's virtual camera won't produce a real PPG signal — test on a physical device.
Same protocol as the web app: cover the rear camera lens fully with a fingertip, keep it still for the ~2s stabilization + 60s measurement window. flash is used when ambient-light is very dark.
pulsefusion_ppg.py has zero Chaquopy/Android-specific code — it's plain
numpy, so it's tested by directly comparing it against the real scipy/pywt
backend, on your machine, with your pulsefusionnet conda env:
conda run -n pulsefusionnet python scripts/validate_numpy_ppg_port.pyThis runs both pipelines over four synthetic signals (58/72/105/140 BPM) and
prints, per case: the lagged correlation between the two cleaned signals, the
full BPM breakdown from each pipeline, and the consensus-BPM difference. Last
run: correlation ≥0.998, consensus BPM within 0.00–0.04 BPM of the real
backend across the whole range. Re-run this after touching
pulsefusion_ppg.py.
- Butterworth filter design and wavelet denoise in
pulsefusion_ppg.pyare hand-written numpy substitutes for scipy/pywt (see table above) — validated against the real backend on synthetic signals, not against real device recordings. - No PCG/audio, murmur detection, or ONNX quality-scorer models are ported;
this is the camera-PPG heart-rate path only, matching what
web_appexposes. - Chaquopy's APK size and licensing tradeoffs (above) haven't been weighed against your actual distribution plan — do that before shipping.