Camera PPG
Reading a pulse waveform off a phone camera
Open the live monitor →Open this on a phone. Needs a rear camera and works best with the torch on.
Not a medical device. This is a demonstration of a signal processing pipeline, built for my own curiosity and for this portfolio. It is not validated against clinical reference equipment and must not be used for diagnosis or treatment.
The idea
Photoplethysmography measures blood volume optically. Press a fingertip against a light source and a detector, and the light that reaches the detector is modulated by the blood in the tissue between them. With every heartbeat the arteries distend, the optical path picks up more haemoglobin, more light is absorbed, and the detector reads slightly darker. That rhythmic dimming is the pulse.
A pulse oximeter does this with a dedicated LED and photodiode. A phone already has both parts: the torch is the light source and the camera sensor is the detector. Apps like Instant Heart Rate have used this since 2010. I wanted to build the pipeline myself rather than take it on faith, so this page reduces every camera frame to a single number and recovers heart rate and respiration from that time series.
Signal chain
→irregular time series→resample to uniform grid
→detrend + Hann window→FFT
→peak in 0.7–4.0 Hz = heart rate
→peak in 0.13–0.55 Hz = respiration
Reducing the frame
Each frame is cropped to the centre 50% and box-averaged down to 32×32 before the
channel means are taken. The downscale is done by drawImage, so the GPU does
the averaging and the CPU only ever touches 1,024 pixels per frame. Averaging is also what
makes the measurement possible at all: the per-pixel pulsatile signal is buried under
sensor noise, and pooling thousands of pixels is what lifts it out.
Only the red channel is used. Haemoglobin absorbs weakly in the red band, so red light penetrates furthest through the finger and carries the largest pulsatile fraction — and with the torch on, the green and blue channels are usually clipped to nothing anyway.
Timing
Camera frames do not arrive on a clean 30 Hz grid. Where the browser supports it the page
uses requestVideoFrameCallback, which fires once per presented frame and
reports that frame's own media timestamp, rather than requestAnimationFrame,
which fires on the display refresh and will happily hand you the same camera frame twice.
Each sample is stored with its true timestamp and the series is linearly resampled onto a
uniform grid before the FFT. Skipping that step smears the spectral peak and biases the
rate estimate.
Fighting the camera's own processing
The most counter-intuitive problem is that the phone is actively trying to erase the signal. Auto-exposure and auto-white-balance exist to cancel exactly the brightness fluctuations being measured. Where the platform allows it — Chrome on Android — the page requests manual exposure, white balance and focus, and reports in the header which locks it managed to apply. iOS does not currently expose these controls, which is one reason readings there are noisier.
Which lens
On a phone with several rear cameras the choice matters. The gap between the LED and each sensor determines how much light travels straight across the cover glass into the lens instead of through the fingertip. Light that never entered the tissue carries no pulse but plenty of DC, so a lens close to the flash tends to give a washed-out, low-amplitude signal. The lens furthest from the flash usually wins. The monitor enumerates the rear cameras, lets you pick, and remembers the choice — worth trying each one and comparing the perfusion index.
Rate estimation
Heart rate comes from a 12-second window resampled to 30 Hz, linearly detrended, Hann-windowed, and zero-padded into a 1024-point FFT. The strongest bin in 0.7–4.0 Hz (42–240 BPM) is refined by parabolic interpolation on the log magnitudes, which recovers sub-bin precision from the 1.76 BPM raw bin spacing. Successive estimates go through a 9-deep median filter so a single corrupted window cannot yank the display.
Respiration is read from the same signal over a 30-second window at 10 Hz. Breathing modulates the baseline intensity — respiration-induced intensity variation — and shows up as a peak between 0.13 and 0.55 Hz, or roughly 8 to 33 breaths per minute.
Signal quality
A frequency estimator always returns an answer, so it needs a companion number saying whether to believe it. The quality score is the share of in-band power concentrated around the detected rate and its second harmonic. A clean pulse is spectrally sparse and scores high; noise spreads power evenly and scores low.
The subtlety is that this ratio has a floor. If the signal window covers a quarter of the band, pure noise still scores 25%. The implementation therefore normalises against what a flat spectrum would produce, so noise-only maps to zero. That correction also has to be band-aware: the tolerance window must exceed the Hann mainlobe half-width or a clean tone is undercounted, but stay small relative to the band or it swallows the whole thing. The heart-rate and respiration bands need different tolerances for exactly this reason.
Parameters
| Parameter | Value | Why |
|---|---|---|
| ROI | centre 50%, 32×32 | Spatial averaging lifts the pulse out of sensor noise |
| Channel | red | Deepest tissue penetration, largest pulsatile fraction |
| HR window | 12 s @ 30 Hz | Trades responsiveness against frequency resolution |
| HR FFT | 1024-point | Zero-padded from 360 samples; 1.76 BPM bins |
| HR band | 0.7–4.0 Hz | 42–240 BPM |
| Resp window | 30 s @ 10 Hz | Breathing is slow; needs a long window |
| Resp band | 0.13–0.55 Hz | 8–33 breaths/min |
| Smoothing | 9-point median | Rejects single-window outliers |
Validation
The DSP functions are tested against synthetic fingertip signals with a known heart rate, known respiration rate, realistic frame-timing jitter and added noise. Across 48–190 BPM the recovered rate lands within 0.05 BPM of truth, and within 0.07 BPM even when the frame rate is dropped to 15 fps. Respiration recovers within 0.05 breaths/min across 9–30 per minute. The quality metric reads 100% on a clean pulse, degrades smoothly as noise is added, and falls to 7–14% on noise or drift with no pulse present.
Synthetic tests prove the estimator recovers a rate that is genuinely present in the signal. They say nothing about whether the optical front end delivers a faithful signal in the first place — that would need simultaneous recording against a reference pulse oximeter, which I have not done.
Where it breaks
- Motion is indistinguishable from pulse. A steady tremor or rub in the 0.7–4 Hz band produces a sharp spectral peak and scores high quality. In testing, a synthetic 1.9 Hz wobble with no pulse at all reports a confident 114 BPM. This is the method's fundamental weakness and no amount of filtering fixes it.
- Harmonic ambiguity. When the fundamental is weak the estimator can lock onto the second harmonic and report double the true rate. The harmonic term in the quality score reduces but does not eliminate this.
- iOS cannot lock exposure. Auto-exposure partially cancels the signal, so amplitude and perfusion index are less trustworthy there than on Android.
- Respiration is the least reliable output. RIIV amplitude varies a lot between people and easily disappears under baseline drift.
- No SpO₂. That needs two known wavelengths and a calibration curve derived from controlled desaturation studies. A camera's broadband RGB filters and a white LED do not give you the ratio-of-ratios that oximetry depends on. Anything claiming a camera-only SpO₂ reading is guessing.
- Perfusion index is relative. Useful for comparing lenses or finger pressure within a session, not comparable to a clinical PI.
Getting a good reading
- Cover the lens and the flash completely with the pad of your fingertip.
- Use firm, constant pressure. Too light lets ambient light leak in; too hard occludes the very blood flow being measured.
- Rest your hand on something and stay still — motion is the dominant error source.
- Give it the full 12 seconds before trusting the number, and watch the quality bar.
- Warm hands help. Cold fingers have poor peripheral perfusion and a weak signal.
Implementation notes
The whole thing is one self-contained HTML file with no dependencies — the FFT, resampler, detrender and peak finder are all written out longhand. Nothing is uploaded; the camera stream never leaves the device, and the CSV export writes a local file containing the timestamped ROI means so the recording can be re-analysed offline in Python.
A screen wake lock is held during recording, since a phone that sleeps mid-measurement
kills the capture. The page also stops the camera and releases the torch on
pagehide, which matters more than it sounds — a torch left on burns battery
and gets genuinely hot.