Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ It can be configured using the following parameters:
| mode | Operation mode. {`Mode.BATCH` for indep. videos or `Mode.BURST` for video stream} | `Mode.BATCH` |
| api_key | Usage key for the VitalLens API (required for `Method.VITALLENS`) | `None` |
| detect_faces | `True` if faces need to be detected, otherwise `False`. | `True` |
| estimate_running_vitals | Set `True` to compute running vitals (e.g., `running_heart_rate`). | `True` |
| estimate_rolling_vitals | Set `True` to compute rolling vitals (e.g., `rolling_heart_rate`). | `True` |
| fdet_max_faces | The maximum number of faces to detect (if necessary). | `1` |
| fdet_fs | Frequency [Hz] at which faces should be scanned - otherwise linearly interpolated. | `1.0` |
| export_to_json | If `True`, write results to a json file. | `True` |
Expand All @@ -92,10 +92,10 @@ Calls are configured using the following parameters:
| Name | Type | Returned if |
|----------------------------|---------------------|----------------------------------------------------------------------------------------------------------|
| `heart_rate` | Global value | Video at least 2 seconds long and using `Method.VITALLENS`, `Method.POS`, `Method.CHROM` or `Method.G` |
| `running_heart_rate` | Continuous values | Video more than 10 seconds long and using `Method.VITALLENS`, `Method.POS`, `Method.CHROM` or `Method.G` and `estimate_running_vitals=True` |
| `rolling_heart_rate` | Continuous values | Video more than 10 seconds long and using `Method.VITALLENS`, `Method.POS`, `Method.CHROM` or `Method.G` and `estimate_rolling_vitals=True` |
| `ppg_waveform` | Continuous waveform | Using `Method.VITALLENS`, `Method.POS`, `Method.CHROM` or `Method.G` |
| `respiratory_rate` | Global value | Video at least 4 seconds long and using `Method.VITALLENS` |
| `running_respiratory_rate` | Continuous values | Video more than 30 seconds long and using `Method.VITALLENS` and `estimate_running_vitals=True` |
| `rolling_respiratory_rate` | Continuous values | Video more than 30 seconds long and using `Method.VITALLENS` and `estimate_rolling_vitals=True` |
| `respiratory_waveform` | Continuous waveform | Using `Method.VITALLENS` |

The estimation results are returned as a `list`. It contains a `dict` for each distinct face, with the following structure:
Expand Down Expand Up @@ -133,13 +133,13 @@ The estimation results are returned as a `list`. It contains a `dict` for each d
'confidence': <Estimation confidence for each frame as np.ndarray of shape (n_frames,)>,
'note': <Explanatory note>
},
'running_heart_rate': {
'rolling_heart_rate': {
'data': <Estimated value for each frame as np.ndarray of shape (n_frames,)>,
'unit': <Value unit>,
'confidence': <Estimation confidence for each frame as np.ndarray of shape (n_frames,)>,
'note': <Explanatory note>
},
'running_respiratory_rate': {
'rolling_respiratory_rate': {
'data': <Estimated value for each frame as np.ndarray of shape (n_frames,)>,
'unit': <Value unit>,
'confidence': <Estimation confidence for each frame as np.ndarray of shape (n_frames,)>,
Expand Down
10 changes: 5 additions & 5 deletions examples/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import numpy as np
from prpy.constants import SECONDS_PER_MINUTE
from prpy.numpy.face import get_upper_body_roi_from_det
from prpy.numpy.signal import estimate_freq
from prpy.numpy.physio import estimate_rate_from_signal, EScope, EMethod
from prpy.numpy.physio import HR_MIN, HR_MAX, RR_MIN, RR_MAX
import sys
import threading
import time
Expand All @@ -14,7 +15,6 @@
from vitallens import VitalLens, Mode, Method
from vitallens.buffer import SignalBuffer, MultiSignalBuffer
from vitallens.constants import API_MIN_FRAMES
from vitallens.constants import CALC_HR_MIN, CALC_HR_MAX, CALC_RR_MIN, CALC_RR_MAX

def draw_roi(frame, roi):
roi = np.asarray(roi).astype(np.int32)
Expand Down Expand Up @@ -53,8 +53,8 @@ def draw_fps(frame, fps, text, draw_area_bl_x, draw_area_bl_y):

def draw_vital(frame, sig, text, sig_name, fps, color, draw_area_bl_x, draw_area_bl_y):
if sig_name in sig:
f_range = (CALC_HR_MIN/SECONDS_PER_MINUTE, CALC_HR_MAX/SECONDS_PER_MINUTE) if 'ppg' in sig_name else (CALC_RR_MIN/SECONDS_PER_MINUTE, CALC_RR_MAX/SECONDS_PER_MINUTE)
val = estimate_freq(x=sig[sig_name], f_s=fps, f_res=0.1/SECONDS_PER_MINUTE, f_range=f_range, method='periodogram') * SECONDS_PER_MINUTE
f_range = (HR_MIN/SECONDS_PER_MINUTE, HR_MAX/SECONDS_PER_MINUTE) if 'ppg' in sig_name else (RR_MIN/SECONDS_PER_MINUTE, RR_MAX/SECONDS_PER_MINUTE)
val = estimate_rate_from_signal(signal=sig[sig_name], f_s=fps, f_range=f_range, scope=EScope.GLOBAL, method=EMethod.PERIODOGRAM)
cv2.putText(frame, text=f"{text}: {val:.1f}", org=(draw_area_bl_x, draw_area_bl_y),
fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=0.6, color=color, thickness=2)

Expand All @@ -66,7 +66,7 @@ def __init__(self, method, api_key):
mode=Mode.BURST,
api_key=api_key,
detect_faces=True,
estimate_running_vitals=True,
estimate_rolling_vitals=True,
export_to_json=False)
def __call__(self, inputs, fps):
self.active.set()
Expand Down
10 changes: 4 additions & 6 deletions examples/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@
import matplotlib.pyplot as plt
import os
import pandas as pd
from prpy.constants import SECONDS_PER_MINUTE
from prpy.ffmpeg.probe import probe_video
from prpy.ffmpeg.readwrite import read_video_from_path
from prpy.helpers import str2bool
from prpy.numpy.signal import estimate_freq
from prpy.numpy.physio import estimate_hr_from_signal, estimate_rr_from_signal
from prpy.numpy.physio import EScope, EMethod
import timeit
from vitallens import VitalLens, Method
from vitallens.utils import download_file
from vitallens.constants import CALC_HR_MIN, CALC_HR_MAX
from vitallens.constants import CALC_RR_MIN, CALC_RR_MAX

COLOR_GT = '#000000'
METHOD_COLORS = {
Expand Down Expand Up @@ -67,10 +65,10 @@ def run(args=None):
fig, ax1 = plt.subplots(1, figsize=(12, 6))
fig.suptitle(f"Vital signs estimated from {args.video_path} using {args.method.name} in {time_ms:.2f} ms")
if "ppg_waveform" in vital_signs and ppg_gt is not None:
hr_gt = estimate_freq(ppg_gt, f_s=fps, f_res=0.005, f_range=(CALC_HR_MIN/SECONDS_PER_MINUTE, CALC_HR_MAX/SECONDS_PER_MINUTE), method='periodogram') * SECONDS_PER_MINUTE
hr_gt = estimate_hr_from_signal(signal=ppg_gt, f_s=fps, scope=EScope.GLOBAL, method=EMethod.PERIODOGRAM)
ax1.plot(ppg_gt, color=COLOR_GT, label=f"PPG Waveform Ground Truth -> HR: {hr_gt:.1f} bpm")
if "respiratory_waveform" in vital_signs and resp_gt is not None:
rr_gt = estimate_freq(resp_gt, f_s=fps, f_res=0.005, f_range=(CALC_RR_MIN/SECONDS_PER_MINUTE, CALC_RR_MAX/SECONDS_PER_MINUTE), method='periodogram') * SECONDS_PER_MINUTE
rr_gt = estimate_rr_from_signal(signal=resp_gt, f_s=fps, scope=EScope.GLOBAL, method=EMethod.PERIODOGRAM)
ax2.plot(resp_gt, color=COLOR_GT, label=f"Respiratory Waveform Ground Truth -> RR: {rr_gt:.1f} bpm")
if "ppg_waveform" in vital_signs:
hr_string = f" -> HR: {vital_signs['heart_rate']['value']:.1f} bpm ({vital_signs['heart_rate']['confidence']*100:.0f}% confidence)" if "heart_rate" in vital_signs else ""
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies = [
"importlib_resources>=5.12",
"numpy>=1.24",
"onnxruntime>=1.15.0",
"prpy[ffmpeg,numpy_min]>=0.2.25",
"prpy[ffmpeg,numpy_min]>=0.3.1",
"python-dotenv>=1.0",
"pyyaml>=6.0.1",
"requests>=2.32.0",
Expand Down
24 changes: 1 addition & 23 deletions tests/test_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,8 @@
import sys
sys.path.append('../vitallens-python')

from vitallens.signal import windowed_mean, windowed_freq, reassemble_from_windows, assemble_results
from vitallens.signal import reassemble_from_windows, assemble_results

def test_windowed_mean():
x = np.asarray([0., 1., 2., 3., 4., 5., 6.])
y = np.asarray([1., 1., 2., 3., 4., 5., 5.])
out_y = windowed_mean(x=x, window_size=3, overlap=1)
np.testing.assert_equal(
out_y,
y)

@pytest.mark.parametrize("num", [100, 1000])
@pytest.mark.parametrize("freq", [2.35, 4.89, 13.55])
@pytest.mark.parametrize("window_size", [10, 20])
def test_estimate_freq_periodogram(num, freq, window_size):
# Test data
x = np.linspace(0, freq * 2 * np.pi, num=num)
np.random.seed(0)
y = 100 * np.sin(x) + np.random.normal(scale=8, size=num)
# Check a default use case with axis=-1
np.testing.assert_allclose(
windowed_freq(x=y, window_size=window_size, overlap=window_size//2, f_s=len(x), f_range=(max(freq-2,1),freq+2), f_res=0.05),
np.full((num,), fill_value=freq),
rtol=1)

def test_reassemble_from_windows():
x = np.array([[[2.0, 4.0, 6.0, 8.0, 10.0], [7.0, 1.0, 10.0, 12.0, 18.0]],
[[2.0, 3.0, 4.0, 5.0, 6.0], [7.0, 8.0, 9.0, 10.0, 11.0]]], dtype=np.float32).transpose(1, 0, 2)
Expand Down
76 changes: 45 additions & 31 deletions vitallens/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@
import logging
import numpy as np
import os
from prpy.constants import SECONDS_PER_MINUTE
from prpy.numpy.image import probe_image_inputs
from prpy.numpy.physio import EScope, EMethod
from prpy.numpy.physio import estimate_hr_from_signal, estimate_rr_from_signal
from prpy.numpy.rolling import rolling_calc
from typing import Union

from vitallens.constants import DISCLAIMER, API_MAX_FRAMES
from vitallens.constants import CALC_HR_MIN, CALC_HR_MAX, CALC_HR_WINDOW_SIZE
from vitallens.constants import CALC_RR_MIN, CALC_RR_MAX, CALC_RR_WINDOW_SIZE
from vitallens.constants import CALC_HR_WINDOW_SIZE, CALC_RR_WINDOW_SIZE
from vitallens.enums import Method, Mode
from vitallens.methods.g import GRPPGMethod
from vitallens.methods.chrom import CHROMRPPGMethod
from vitallens.methods.pos import POSRPPGMethod
from vitallens.methods.vitallens import VitalLensRPPGMethod
from vitallens.signal import windowed_freq, windowed_mean
from vitallens.ssd import FaceDetector
from vitallens.utils import load_config, check_faces, convert_ndarray_to_list

Expand All @@ -48,7 +48,7 @@ def __init__(
mode: Mode = Mode.BATCH,
api_key: str = None,
detect_faces: bool = True,
estimate_running_vitals: bool = True,
estimate_rolling_vitals: bool = True,
fdet_max_faces: int = 1,
fdet_fs: float = 1.0,
fdet_score_threshold: float = 0.9,
Expand All @@ -63,7 +63,7 @@ def __init__(
mode: Operate in batch or burst mode
api_key: Usage key for the VitalLens API (required for Method.VITALLENS)
detect_faces: `True` if faces need to be detected, otherwise `False`.
estimate_running_vitals: Set `True` to compute running vitals (e.g., `running_heart_rate`).
estimate_rolling_vitals: Set `True` to compute rolling vitals (e.g., `rolling_heart_rate`).
fdet_max_faces: The maximum number of faces to detect (if necessary).
fdet_fs: Frequency [Hz] at which faces should be scanned. Detections are
linearly interpolated for remaining frames.
Expand Down Expand Up @@ -91,7 +91,7 @@ def __init__(
else:
raise ValueError(f"Method {self.config['model']} not implemented!")
self.detect_faces = detect_faces
self.estimate_running_vitals = estimate_running_vitals
self.estimate_rolling_vitals = estimate_rolling_vitals
self.export_to_json = export_to_json
self.export_dir = export_dir
if detect_faces:
Expand Down Expand Up @@ -223,38 +223,52 @@ def __call__(
'confidence': conf[name],
'note': note[name]
}
if self.estimate_running_vitals:
if self.estimate_rolling_vitals:
try:
if 'ppg_waveform' in self.config['signals']:
window_size = int(CALC_HR_WINDOW_SIZE*fps)
running_hr = windowed_freq(
x=data['ppg_waveform'], f_s=fps, f_res=0.005,
f_range=(CALC_HR_MIN/SECONDS_PER_MINUTE, CALC_HR_MAX/SECONDS_PER_MINUTE),
window_size=window_size, overlap=window_size//2) * SECONDS_PER_MINUTE
running_conf = windowed_mean(
x=conf['ppg_waveform'], window_size=window_size, overlap=window_size//2)
vital_signs_results['running_heart_rate'] = {
'data': running_hr,
rolling_hr = estimate_hr_from_signal(
signal=data['ppg_waveform'],
f_s=fps,
window_size=CALC_HR_WINDOW_SIZE,
scope=EScope.ROLLING,
method=EMethod.PERIODOGRAM
)
rolling_conf = rolling_calc(
x=conf['ppg_waveform'],
calc_fn=lambda x: np.nanmean(x),
min_window_size=CALC_HR_WINDOW_SIZE,
max_window_size=CALC_HR_WINDOW_SIZE,
overlap=CALC_HR_WINDOW_SIZE//2
)
vital_signs_results['rolling_heart_rate'] = {
'data': rolling_hr,
'unit': 'bpm',
'confidence': running_conf,
'note': 'Estimate of the running heart rate using VitalLens, along with frame-wise confidences between 0 and 1.',
'confidence': rolling_conf,
'note': 'Estimate of the rolling heart rate using VitalLens, along with frame-wise confidences between 0 and 1.',
}
if 'respiratory_waveform' in self.config['signals']:
window_size = int(CALC_RR_WINDOW_SIZE*fps)
running_rr = windowed_freq(
x=data['respiratory_waveform'], f_s=fps, f_res=0.005,
f_range=(CALC_RR_MIN/SECONDS_PER_MINUTE, CALC_RR_MAX/SECONDS_PER_MINUTE),
window_size=window_size, overlap=window_size//2) * SECONDS_PER_MINUTE
running_conf = windowed_mean(
x=conf['respiratory_waveform'], window_size=window_size, overlap=window_size//2)
vital_signs_results['running_respiratory_rate'] = {
'data': running_rr,
rolling_rr = estimate_rr_from_signal(
signal=data['respiratory_waveform'],
f_s=fps,
window_size=CALC_RR_WINDOW_SIZE,
scope=EScope.ROLLING,
method=EMethod.PERIODOGRAM
)
rolling_conf = rolling_calc(
x=conf['respiratory_waveform'],
calc_fn=lambda x: np.nanmean(x),
min_window_size=CALC_RR_WINDOW_SIZE,
max_window_size=CALC_RR_WINDOW_SIZE,
overlap=CALC_RR_WINDOW_SIZE//2
)
vital_signs_results['rolling_respiratory_rate'] = {
'data': rolling_rr,
'unit': 'bpm',
'confidence': running_conf,
'note': 'Estimate of the running respiratory rate using VitalLens, along with frame-wise confidences between 0 and 1.',
'confidence': rolling_conf,
'note': 'Estimate of the rolling respiratory rate using VitalLens, along with frame-wise confidences between 0 and 1.',
}
except ValueError as e:
logging.debug(f"Issue while computing running vitals: {e}")
logging.debug(f"Issue while computing rolling vitals: {e}")
face_result['vital_signs'] = vital_signs_results
face_result['message'] = DISCLAIMER
results.append(face_result)
Expand Down
6 changes: 0 additions & 6 deletions vitallens/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,6 @@
import os
load_dotenv()

# Vitals estimation constraints [1/min]
CALC_HR_MIN = 40
CALC_HR_MAX = 240
CALC_RR_MIN = 1
CALC_RR_MAX = 60

# Vitals estimation window sizes [s]
CALC_HR_MIN_WINDOW_SIZE = 2
CALC_HR_WINDOW_SIZE = 10
Expand Down
5 changes: 3 additions & 2 deletions vitallens/methods/chrom.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@
import logging
import numpy as np
from prpy.constants import SECONDS_PER_MINUTE
from prpy.numpy.signal import detrend, standardize, butter_bandpass, div0
from prpy.numpy.core import standardize, div0
from prpy.numpy.filters import detrend, butter_bandpass
from prpy.numpy.physio import detrend_lambda_for_hr_response
from prpy.numpy.stride_tricks import window_view, reduce_window_view

from vitallens.enums import Mode
from vitallens.methods.simple_rppg_method import SimpleRPPGMethod
from vitallens.signal import detrend_lambda_for_hr_response

class CHROMRPPGMethod(SimpleRPPGMethod):
"""The CHROM algorithm by De Haan and Jeanne (2013)"""
Expand Down
7 changes: 4 additions & 3 deletions vitallens/methods/g.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@
# SOFTWARE.

import numpy as np
from prpy.numpy.signal import detrend, moving_average, standardize
from prpy.numpy.core import standardize
from prpy.numpy.filters import detrend, moving_average
from prpy.numpy.physio import detrend_lambda_for_hr_response
from prpy.numpy.physio import moving_average_size_for_hr_response

from vitallens.enums import Mode
from vitallens.methods.simple_rppg_method import SimpleRPPGMethod
from vitallens.signal import detrend_lambda_for_hr_response
from vitallens.signal import moving_average_size_for_hr_response

class GRPPGMethod(SimpleRPPGMethod):
"""The G algorithm by Verkruysse (2008)"""
Expand Down
7 changes: 4 additions & 3 deletions vitallens/methods/pos.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@

import logging
import numpy as np
from prpy.numpy.signal import detrend, moving_average, standardize, div0
from prpy.numpy.core import standardize, div0
from prpy.numpy.filters import detrend, moving_average
from prpy.numpy.physio import detrend_lambda_for_hr_response
from prpy.numpy.physio import moving_average_size_for_hr_response
from prpy.numpy.stride_tricks import window_view, reduce_window_view

from vitallens.enums import Mode
from vitallens.methods.simple_rppg_method import SimpleRPPGMethod
from vitallens.signal import detrend_lambda_for_hr_response
from vitallens.signal import moving_average_size_for_hr_response

class POSRPPGMethod(SimpleRPPGMethod):
"""The POS algorithm by Wang et al. (2017)"""
Expand Down
2 changes: 1 addition & 1 deletion vitallens/methods/simple_rppg_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import numpy as np
from prpy.numpy.face import get_roi_from_det
from prpy.numpy.image import reduce_roi, parse_image_inputs
from prpy.numpy.signal import interpolate_filtered
from prpy.numpy.interp import interpolate_filtered
from typing import Union, Tuple

from vitallens.buffer import SignalBuffer
Expand Down
10 changes: 6 additions & 4 deletions vitallens/methods/vitallens.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
import concurrent.futures
import math
import numpy as np
from prpy.numpy.core import standardize
from prpy.numpy.face import get_roi_from_det
from prpy.numpy.filters import detrend, moving_average
from prpy.numpy.image import probe_image_inputs, parse_image_inputs
from prpy.numpy.signal import detrend, moving_average, standardize
from prpy.numpy.signal import interpolate_filtered
from prpy.numpy.interp import interpolate_filtered
from prpy.numpy.physio import detrend_lambda_for_hr_response, detrend_lambda_for_rr_response
from prpy.numpy.physio import moving_average_size_for_hr_response, moving_average_size_for_rr_response
from prpy.numpy.utils import enough_memory_for_ndarray
import json
import logging
Expand All @@ -37,8 +40,6 @@
from vitallens.enums import Mode
from vitallens.errors import VitalLensAPIKeyError, VitalLensAPIQuotaExceededError, VitalLensAPIError
from vitallens.methods.rppg_method import RPPGMethod
from vitallens.signal import detrend_lambda_for_hr_response, detrend_lambda_for_rr_response
from vitallens.signal import moving_average_size_for_hr_response, moving_average_size_for_rr_response
from vitallens.signal import reassemble_from_windows, assemble_results
from vitallens.utils import check_faces_in_roi

Expand Down Expand Up @@ -237,6 +238,7 @@ def process_api_batch(
if frames_ds.shape[0] != expected_n or idxs.shape[0] != expected_n:
raise ValueError("Unexpected number of frames returned. Try to set `override_global_parse` to `True` or `False`.")
# Prepare API header and payload
# -- by not sending fps information, ask endpoint not to do any processing
headers = {"x-api-key": self.api_key}
payload = {"video": base64.b64encode(frames_ds.tobytes()).decode('utf-8'), "origin": "vitallens-python"}
if self.op_mode == Mode.BURST:
Expand Down
Loading