I’ve heard the same measurement is possible at a distance using a regular phone camera. The goal of this project is to verify this claim.
Preprocessing
Stabilize video (optional), extract metadata (lenght, fps …) and crop frames of video
Analysis
Look at difference between adjacent frames, apply filters, search for local maxima, examine periodicity
Channel | SNR |
---|---|
Red | 41.12 |
Green | 42.64 |
Blue | 41.88 |
All | 42.63 |
Signal-to-noise ratio for the used color channels. “All” refers to the non-separated case.
Based on the above table I carried out the analytic steps on the green channel and all channels combined as their SNR is the highest and almost identical.
import os
import cv2
import numpy as np
import matplotlib.pyplot as plt
from scipy import signal, stats
from scipy.signal import lfilter, find_peaks, butter,peak_prominences, freqz
from scipy.fft import fft, fftfreq
def signaltonoise(array, axis=0, ddof=0):
array = np.asanyarray(array)
avg = np.mean(array,axis)
std = np.std(array,axis=axis, ddof=ddof)
return 20*np.log10(abs(np.where(std == 0, 0, avg/std)))
def moving_avg(series, half_wlen = 3):
res = []
for ind, el in enumerate(series):
start = ind - half_wlen
stop = ind + half_wlen
if start >= 0 and stop <= len(series):
res.append(np.mean(series[start:stop]))
return res
file_name = 'nl_face_long_62_bpm.mp4'
folder = "./data/"
# if the input is taken from the camera, pass 0 instead of the video file name
cap = cv2.VideoCapture(folder+file_name)
# framerate used during recording (fps)
fs = round(cap.get(cv2.CAP_PROP_FPS),2)
# total number of frames in video
num_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
# getting the lenght of the video
vid_len_seconds = round(num_frames / fs, 2)
vid_len_minutes = round(vid_len_seconds / 60.0, 2)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"fps: {fs}")
print(f"total frames: {num_frames}")
print(f"length: {round(vid_len_seconds,2)} s, ({vid_len_minutes} min)")
print(f"frame dims: {width}, {height}")
extracted_images = []
cap = cv2.VideoCapture(folder+file_name)
while cap.isOpened():
success, image = cap.read()
if not success:
print("End of video")
print(f"Extracted {len(extracted_images)} frames")
break
# setting image up to be able to modify it
image.flags.writeable = True
# top left corner of crop area
topLeft = (350, 850)
# bottom right corner of crop area
bottomRight = (600, 1000)
# crop the image, keep the selection
extracted_images.append(
image[topLeft[1] : bottomRight[1], topLeft[0] : bottomRight[0]]
)
# draw rectangle around the cropped section
cv2.rectangle(image, topLeft, bottomRight, color=(0, 0, 255), thickness=3)
# open video in new window, just to check if cropping is correct
cv2.imshow(
"SkyNetV1",
cv2.resize(image, (int(width / 2), int(height / 2))),
)
# press Esc to stop
if cv2.waitKey(5) & 0xFF == 27:
break
cap.release()
cv2.destroyAllWindows()
# m*n frames in a single color
red_ch = extracted_images[:, :, :, 0]
green_ch = extracted_images[:, :, :, 1]
blue_ch = extracted_images[:, :, :, 2]
# taking the avg of m*n frames, length is the num of frames in video
# result is just one number for each frame
red_mean = np.mean(red_ch, axis=(1, 2))
green_mean = np.mean(green_ch, axis=(1, 2))
blue_mean = np.mean(blue_ch, axis=(1, 2))
# averaging of all color channels, result has same dim as single color mean
all_ch_mean = np.mean(extracted_images, axis=(3,2,1))
# lower cutoff frequency. Chosen as 0.6 Hz (36 BPM)
low_cut = 0.6
# upper cutoff frequency. 3 Hz is used (180 BPM)
high_cut = 3
# creating and plotting bandpass filters with same cutoffs but different orders
plt.figure()
for order in [1, 2, 3, 5, 7]:
b, a = signal.butter(
order, [low_cut, high_cut], btype="bandpass", analog=False, fs=fs
)
w, h = freqz(b, a, fs=fs, worN=512)
plt.plot(w, abs(h), label="order = %d" % order)
plt.plot([0, 0.5 * fs], [np.sqrt(0.5), np.sqrt(0.5)], "--", label="sqrt(0.5)")
plt.xlabel("$Frequency (Hz)$")
plt.ylabel("$Gain$")
plt.grid(True)
plt.legend(loc="best")
fig = plt.figure(figsize=(20, 10))
color = ["r", "b" , "m", 'c']
for count, order in enumerate([1,3,5,9]):
b, a = signal.butter(
order, [low_cut, high_cut], btype="bandpass", analog=False, fs=fs
)
smooth_signal = signal.filtfilt(b, a, all_ch_mean)
all_peaks, all_meta = find_peaks(
smooth_signal, distance= fs / high_cut, prominence=0.2
)
plt.subplot(6, 1, count + 1)
plt.plot(smooth_signal, color[count])
plt.plot(all_peaks, smooth_signal[all_peaks], "k*")
plt.title(f"Order={order}, Num peaks={len(all_peaks)}")
plt.ylabel("Amplitude")
plt.grid()
fig.tight_layout()
plt.xlabel("Time (samples)")
N = int(fs * vid_len_seconds)
plt.figure(figsize=(20,5))
for count, order in enumerate([1,3,7]):
b, a = signal.butter(
order, [low_cut, high_cut], btype="bandpass", analog=False, fs=fs
)
smooth_signal = signal.filtfilt(b, a, all_ch_mean)
yf = fft(smooth_signal)
xf = fftfreq(N, 1/fs )[:N//2]
dominant_frequency = xf[np.argmax(np.abs(yf[:N//2]))]
plt.subplot(1,4,count+1)
plt.title(f"order={order}, dominant fr.={round(dominant_frequency,2)} Hz")
plt.plot(xf, np.abs(yf[:N//2]))
plt.plot(dominant_frequency,0,'rv')
plt.xlabel("$Frequency (Hz)$")
plt.ylabel("$Amplitude$")
plt.xlim([0,4])