Validation

Does it agree with a medical device?

The camera's heart rate was compared with a fingertip pulse oximeter on 15 recordings, and only 6 of them count: the released model was trained on the first 72 per cent of the subject list, so on the other 9 it would be measuring memory rather than skill.

Off by, vs the pulse trace
0.39BPM

6 unseen people, ten-second windows. The oximeter's recorded pulse, put through the same processing as the camera signal.

Off by, vs the device readout
2.96BPM

The number the device itself displays, which our code cannot influence — the stricter of the two. Across all 15 it is 6.08, inflated by two recordings where the device's own readout is wrong by over 20 BPM.

Agreement
0.971Pearson r
1.00would be perfect

Whether the two rise and fall together across subjects. Blind to a constant offset, which the plot below is not.

Within 5 BPM
80% of 15

73 % land within 3 BPM. One reading per recording, all subjects.

Agreement plot

Bland-Altman: the standard way clinicians check whether a new measurement agrees with an established one. All 15 recordings, 9 of which the model has seen.

held out (6) trained on (9)

Each dot is one person. The solid line is the average gap between camera and reference — -0.59 BPM, so the camera does not systematically read high or low. Nineteen readings in twenty should fall between the dashed lines at -7.9 and 6.8 BPM, and that width, not the average, is what a monitoring claim would have to live with.

Camera against reference

One reading per recording

held out (6) trained on (9)

Dots on the dashed line are exact; above it the camera read fast, below it slow. The spread along the line is the range of resting heart rates in the group, not error.

Who it fails on

Worst first, whole recording

10 of 15 sit at zero, drawn as a hairline. That is a caveat, not applause: the frequency analysis resolves about 0.88 BPM, so zero means the same bin. The worst unseen person is subject47 at 8.8 BPM — and 0.46 once the same recording is scored in ten-second windows.

Can it tell when it is struggling?

Signal quality against error, one dot per person

held out (6) trained on (9)

Signal quality is how far the pulse stands above the noise, and it can be measured live with no reference to compare against. If a low score reliably meant a large error, the model could refuse to answer — a monitoring product has to be able to say “I do not know” rather than guess. It cannot yet: everything above 10 dB is exact, but below that an exact reading and an 8 BPM miss sit side by side.

Every recording

All 15, unseen subjects first. Nothing averaged away.

780 windows in total
PersonSplitReferenceCameraOff bySignalWindowed
subject5held out101.1101.1
0.00
16.50.26
subject44held out87.987.9
0.00
-1.40.62
subject46held out91.491.4
0.00
1.60.35
subject47held out105.5114.3
8.79
0.30.46
subject48held out91.491.4
0.00
10.60.49
subject49held out86.186.1
0.00
14.60.16
subject14trained on70.370.3
0.00
1.52.16
subject15trained on119.5112.5
7.03
-2.90.14
subject18trained on128.3124.8
3.52
3.80.23
subject24trained on94.987.0
7.91
-3.20.36
subject25trained on113.4113.4
0.00
12.60.12
subject27trained on111.6111.6
0.00
4.01.48
subject30trained on102.8102.8
0.00
5.90.32
subject32trained on116.9116.9
0.00
0.40.51
subject35trained on116.0116.9
0.88
-1.60.28

Reference, camera and off-by are in BPM; signal is in dB. The last column re-scores the same recording in ten-second windows — heart rate moves within a minute, so one number per video averages away the thing being measured.

How the video was processed

Every step between the raw frames and the numbers above

face detection
Haar cascade (HC), first frame only
large box coef
1.5
resize
128 × 128
interpolation
INTER_AREA
pixel range
0-255 (no /255 normalisation, matching the toolbox)
clip length
128
data type
Raw
method
repo calculate_metric_per_video
steps
detrend (lambda=100), Butterworth bandpass 0.75-2.5 Hz (45-150 BPM), FFT peak
diff flag
0

One line there decides whether any of this works: pixel values stay in the 0–255 range. The reference implementation never divides by 255, so normalising raises no error and silently returns a plausible-looking waveform that is noise.

04_ubfc_eval.py·08_windowed_eval.py·exported Sat, 29 Aug 2026 20:23:15 GMT·schema 1.0