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
16 changes: 9 additions & 7 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,18 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.11", "3.12"]
steps:
- name: Checkout vitallens-python
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
Expand Down Expand Up @@ -57,6 +57,8 @@ jobs:
env:
VITALLENS_DEV_API_KEY: ${{ secrets.VITALLENS_DEV_API_KEY }}
VITALLENS_API_ORIGIN: "test"
# The jobs share one dev API key and rate limit: run the heavy real-API tests in one job only
VITALLENS_HEAVY_E2E: ${{ matrix.python-version == '3.12' && '1' || '' }}
run: |
pytest

Expand All @@ -65,15 +67,15 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout vitallens-python
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python 3.10
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: "3.11"
- name: Cache pip dependencies
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~\AppData\Local\pip\Cache
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ jobs:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: "3.11"

- name: Install build tool
run: python -m pip install --upgrade build
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ For full API reference, advanced configuration, proxy usage, and result schemas,
<!-- mkdocs-bottom-start -->
## Troubleshooting

- **Prerequisites:** Requires `python>=3.10` and `ffmpeg` installed and on your `$PATH`.
- **Prerequisites:** Requires `python>=3.11` and `ffmpeg` installed and on your `$PATH`.
- **Windows:** [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) are required.
- **ONNX Runtime:** If you encounter installation issues with `onnxruntime`, try installing it via conda first: `conda install -c conda-forge onnxruntime`.

Expand Down
13 changes: 13 additions & 0 deletions docs/results.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,16 @@ You might notice that not all keys (like `hrv_sdnn`) are present in every result

> **Note:** HRV vitals are only available on `vitallens` version 2.0 or greater.

## Getting Accurate Results

Estimates are only as good as the video they come from. For the best accuracy and confidence:

- **Light the face well, with steady light.** Avoid flickering lights, changing screen colors and strong backlight.
- **Keep the subject within 0.5 m of the camera** and facing it, with the whole face in the frame (add the upper chest for respiratory rate).
- **Keep the subject and the camera still.**
- **Avoid heavy video compression.** It removes the subtle color changes the estimation relies on.

See the [Accuracy & Guidelines](https://docs.rouast.com/guides/accuracy/) page for the details.

Every value comes with a `confidence` between 0 and 1. As a guide, the VitalLens apps show heart rate and respiratory rate from a confidence of 0.8, and show HRV only when it and the heart rate both reach 0.8. Consider applying a similar rule: an HRV value next to a heart rate with low confidence is not reliable.

9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ authors = [
]
description = "Vital sign estimation from facial video"
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
keywords = ["python", "rppg", "vital signs monitoring", "heart rate", "pulse", "respiration"]
license = {text = "MIT License"}
classifiers = [
Expand All @@ -29,7 +29,7 @@ dependencies = [
"python-dotenv>=1.0",
"pyyaml>=6.0.1",
"requests>=2.32.0",
"vitallens-core==0.2.5",
"vitallens-core==0.3.4",
]
dynamic = ["version"]

Expand All @@ -44,3 +44,8 @@ Issues = "https://github.com/Rouast-Labs/vitallens-python/issues"
packages = ["vitallens"]

[tool.setuptools_scm]

[tool.pytest.ini_options]
markers = [
"integration: calls the real VitalLens API (needs VITALLENS_DEV_API_KEY); deselect with -m \"not integration\"",
]
35 changes: 35 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import numpy as np
import os
import pytest
import time
from unittest.mock import patch

import sys
Expand Down Expand Up @@ -42,6 +43,11 @@ def test_VitalLens(request, method_arg, detect_faces, file, export):
assert data[0]['vitals']['heart_rate']['confidence'] == 1.0
os.remove(test_json_path)

# The CI jobs share one dev API key and its rate limit. The heavy real-API tests (many requests in a
# short time) therefore run in one CI job only, which sets VITALLENS_HEAVY_E2E. Set it locally to run them.
heavy_e2e = pytest.mark.skipif(not os.getenv("VITALLENS_HEAVY_E2E"), reason="Set VITALLENS_HEAVY_E2E=1 to run heavy real-API tests")

@pytest.mark.integration
def test_VitalLens_API(request):
api_key = request.getfixturevalue('test_dev_api_key')
vl = VitalLens(method=Method.VITALLENS, api_key=api_key, detect_faces=True, export_to_json=False)
Expand All @@ -66,6 +72,35 @@ def test_VitalLens_API(request):
np.testing.assert_allclose(result[0]['vitals']['hrv_rmssd']['value'], 30, atol=20)
assert not os.path.exists("test.json")

@pytest.mark.integration
@heavy_e2e
def test_VitalLens_API_stream(request):
"""Streams a video through the real API: frames in, results out, with state carried across requests."""
api_key = request.getfixturevalue('test_dev_api_key')
test_video_ndarray = request.getfixturevalue('test_video_ndarray')
test_video_fps = request.getfixturevalue('test_video_fps')
test_video_faces = request.getfixturevalue('test_video_faces')
vl = VitalLens(method=Method.VITALLENS, api_key=api_key, export_to_json=False)
results = []
n_frames = 360 # 12 s: enough for a heart rate
with vl.stream() as session:
for i in range(n_frames):
# Paced like a camera: the stream buffer drops frames if they arrive faster than the API can take them
session.push(test_video_ndarray[i], timestamp=i / test_video_fps, face=test_video_faces[i])
time.sleep(1 / test_video_fps)
# Collect what the background thread processed, until it has gone quiet
while True:
result = session.get_result(block=True, timeout=20)
if result is None:
break
results.extend(result)
assert len(results) > 0
n_samples = sum(len(r['waveforms']['ppg_waveform']['data']) for r in results if 'ppg_waveform' in r['waveforms'])
assert n_samples > 0.6 * n_frames
with_hr = [r for r in results if 'heart_rate' in r['vitals']]
assert len(with_hr) > 0
np.testing.assert_allclose(with_hr[-1]['vitals']['heart_rate']['value'], 60, atol=10)

def test_VitalLens_proxies():
"""Test that proxies are passed down correctly."""
proxies = {"https": "http://10.10.1.10:3128"}
Expand Down
28 changes: 28 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,31 @@ def test_check_faces_in_roi(scenario):
expected = scenario[3]
actual = check_faces_in_roi(faces=faces, roi=roi, percentage_required_inside_roi=percentage_required_inside_roi)
assert expected == actual

def test_get_api_origin_includes_version(monkeypatch):
from unittest import mock
from vitallens.utils import get_api_origin
monkeypatch.delenv('VITALLENS_API_ORIGIN', raising=False)
with mock.patch('vitallens.utils.importlib_metadata.version', return_value='0.6.2'):
assert get_api_origin() == 'vitallens-python_0.6.2'

def test_get_api_origin_env_override_is_used_verbatim(monkeypatch):
from vitallens.utils import get_api_origin
monkeypatch.setenv('VITALLENS_API_ORIGIN', 'test')
assert get_api_origin() == 'test'

def test_get_api_origin_without_installed_version(monkeypatch):
from unittest import mock
from vitallens.utils import get_api_origin
monkeypatch.delenv('VITALLENS_API_ORIGIN', raising=False)
with mock.patch('vitallens.utils.importlib_metadata.version', side_effect=Exception('not installed')):
assert get_api_origin() == 'vitallens-python'

def test_get_api_origin_strips_characters_the_api_rejects(monkeypatch):
from unittest import mock
from vitallens.utils import get_api_origin
monkeypatch.delenv('VITALLENS_API_ORIGIN', raising=False)
with mock.patch('vitallens.utils.importlib_metadata.version', return_value='0.6.2.dev3+g1234abc.d2026 10/24'):
origin = get_api_origin()
assert origin == 'vitallens-python_0.6.2.dev3+g1234abc.d20261024'
assert len(origin) <= 64
93 changes: 85 additions & 8 deletions tests/test_vitallens.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import base64
import json
import numpy as np
import os
from prpy.numpy.image import parse_image_inputs
import pytest
import requests
Expand All @@ -10,10 +11,17 @@
import sys
sys.path.append('../vitallens-python')

from vitallens.constants import API_MAX_FRAMES, API_MIN_FRAMES, API_FILE_URL, API_RESOLVE_URL
from vitallens.constants import API_MAX_FRAMES, API_MIN_FRAMES, API_FILE_URL, API_RESOLVE_URL, API_RESOLVE_TIMEOUT, API_STREAM_TIMEOUT, API_FILE_TIMEOUT
from vitallens.methods.vitallens import VitalLensRPPGMethod, _resolve_model_config
from vitallens.errors import VitalLensAPIKeyError, VitalLensAPIError, VitalLensAPIQuotaExceededError

# The CI jobs share one dev API key and its rate limit. The heavy real-API tests (many requests in a
# short time) therefore run in one CI job only, which sets VITALLENS_HEAVY_E2E. Set it locally to run them.
heavy_e2e = pytest.mark.skipif(not os.getenv("VITALLENS_HEAVY_E2E"), reason="Set VITALLENS_HEAVY_E2E=1 to run heavy real-API tests")

# The mocked API only accepts keys of a plausible length, like the real one. Not a real key.
FAKE_API_KEY = "fake-api-key-for-mocked-tests-0000000000"

def create_mock_response(
status_code: int,
json_data: dict
Expand Down Expand Up @@ -122,9 +130,9 @@ def create_mock_api_response(
@pytest.mark.parametrize("override_fps_target", [None, 15, 10])
@pytest.mark.parametrize("override_global_parse", [False, True])
@pytest.mark.parametrize("requested_model", ["vitallens-2.0"])
@patch('requests.post', side_effect=create_mock_api_response)
@patch('requests.Session.post', side_effect=create_mock_api_response)
def test_VitalLensRPPGMethod_file_mock(mock_post, mock_resolve_config, request, override_fps_target, override_global_parse, requested_model):
api_key = request.getfixturevalue('test_dev_api_key')
api_key = FAKE_API_KEY
test_video_path = request.getfixturevalue('test_video_path')
test_video_ndarray = request.getfixturevalue('test_video_ndarray')
test_video_faces = request.getfixturevalue('test_video_faces')
Expand All @@ -149,9 +157,9 @@ def test_VitalLensRPPGMethod_file_mock(mock_post, mock_resolve_config, request,
@pytest.mark.parametrize("long", [True])
@pytest.mark.parametrize("override_global_parse", [False, True])
@pytest.mark.parametrize("requested_model", ["vitallens"])
@patch('requests.post', side_effect=create_mock_api_response)
@patch('requests.Session.post', side_effect=create_mock_api_response)
def test_VitalLensRPPGMethod_ndarray_mock(mock_post, mock_resolve_config, request, long, override_fps_target, override_global_parse, requested_model):
api_key = request.getfixturevalue('test_dev_api_key')
api_key = FAKE_API_KEY
test_video_ndarray = request.getfixturevalue('test_video_ndarray')
test_video_fps = request.getfixturevalue('test_video_fps')
test_video_faces = request.getfixturevalue('test_video_faces')
Expand Down Expand Up @@ -229,6 +237,7 @@ def test_VitalLensRPPGMethod_stream_mock(mock_post, mock_resolve_config, request
assert 'X-State' in kwargs2['headers']
assert kwargs2['headers']['x-api-key'] == api_key

@pytest.mark.integration
@pytest.mark.parametrize("process_signals", [True, False])
@pytest.mark.parametrize("n_frames", [16, 250])
def test_VitalLens_API_valid_response(request, process_signals, n_frames):
Expand Down Expand Up @@ -282,6 +291,41 @@ def test_VitalLens_API_valid_response(request, process_signals, n_frames):
state = np.asarray(response_body["state"]["data"])
assert state.shape == (256,)

@pytest.mark.integration
@heavy_e2e
def test_VitalLensRPPGMethod_large_upload_with_the_real_api(request):
"""A multi-request /file upload with the real API.

Deliberately not mocked: whether a few MB of video can be sent within the request timeout only
shows up on a real connection. A timeout that applied to the upload as a whole (the `requests`
library sends the body within the connect timeout) once cut such uploads off.
"""
api_key = request.getfixturevalue('test_dev_api_key')
test_video_ndarray = request.getfixturevalue('test_video_ndarray')
test_video_fps = request.getfixturevalue('test_video_fps')
test_video_faces = request.getfixturevalue('test_video_faces')
method = VitalLensRPPGMethod(api_key=api_key, requested_model_name="vitallens")
n_repeats = (API_MAX_FRAMES * 3) // test_video_ndarray.shape[0] + 1
video = np.repeat(test_video_ndarray, repeats=n_repeats, axis=0)
faces = np.repeat(test_video_faces, repeats=n_repeats, axis=0)
data, conf, live = method.infer_batch(inputs=video, faces=faces, fps=test_video_fps)
assert data['ppg_waveform'].shape == (video.shape[0],)
assert conf['ppg_waveform'].shape == (video.shape[0],)
assert live.shape == (video.shape[0],)

def test_file_timeout_lets_slow_uplinks_finish():
"""The /file upload budget must allow a full chunk to be sent over a slow mobile link."""
chunk_bytes = API_MAX_FRAMES * 40 * 40 * 3 * 4 / 3 # base64 in the JSON payload
slow_uplink_bytes_per_s = 0.2e6 / 8 # 0.2 Mbit/s
assert chunk_bytes / slow_uplink_bytes_per_s <= API_FILE_TIMEOUT[0]

def test_stream_timeout_lets_a_normal_request_finish():
"""A /stream request (a few dozen frames, gzip-compressed) must fit the budget on 1 Mbit/s."""
request_bytes = 100 * 40 * 40 * 3 # a long queue of 100 frames, uncompressed
uplink_bytes_per_s = 1e6 / 8
assert request_bytes / uplink_bytes_per_s <= API_STREAM_TIMEOUT[0]

@pytest.mark.integration
def test_VitalLens_API_wrong_api_key(request):
test_video_ndarray = request.getfixturevalue('test_video_ndarray')
test_video_fps = request.getfixturevalue('test_video_fps')
Expand All @@ -294,13 +338,15 @@ def test_VitalLens_API_wrong_api_key(request):
response = requests.post(API_FILE_URL, headers=headers, json=payload)
assert response.status_code == 403

@pytest.mark.integration
def test_VitalLens_API_no_video(request):
api_key = request.getfixturevalue('test_dev_api_key')
headers = {"x-api-key": api_key}
payload = {"some_key": "irrelevant", "origin": "vitallens-python"}
response = requests.post(API_FILE_URL, headers=headers, json=payload)
assert response.status_code == 400

@pytest.mark.integration
def test_VitalLens_API_no_parseable_video(request):
api_key = request.getfixturevalue('test_dev_api_key')
headers = {"x-api-key": api_key}
Expand All @@ -320,11 +366,14 @@ def test_resolve_model_config_errors():
with pytest.raises(VitalLensAPIError, match="Server Error"):
_resolve_model_config("valid_key", "vitallens")

def test_VitalLens_API_integration(mock_resolve_config, request):
@pytest.mark.integration
def test_VitalLens_API_resolve_model(request):
"""Resolves the model with the real API."""
api_key = request.getfixturevalue('test_dev_api_key')
method = VitalLensRPPGMethod(requested_model_name="vitallens", api_key=api_key)
assert method.resolved_model == 'vitallens-2.0'
assert method.resolved_model.startswith('vitallens')
assert method.input_size == 40
assert method.fps_target > 0

@patch('vitallens.methods.vitallens._resolve_model_config')
def test_VitalLensRPPGMethod_init_errors(mock_resolve):
Expand Down Expand Up @@ -378,4 +427,32 @@ def test_proxy_and_auth_offloading(mock_get, mock_post, request):
method_offload.infer_batch(test_video_ndarray[:16], faces[:16], fps=30.0)
args, kwargs = mock_post.call_args
assert kwargs['proxies'] == proxies
assert 'x-api-key' not in kwargs['headers']
assert 'x-api-key' not in kwargs['headers']

# Requests need a timeout: without one, a stalled connection blocks forever.

@patch('requests.get', side_effect=requests.exceptions.Timeout)
def test_resolve_model_config_timeout(mock_get):
with pytest.raises(VitalLensAPIError, match="timed out"):
_resolve_model_config(api_key="key", model_name="vitallens")
assert mock_get.call_args.kwargs['timeout'] == API_RESOLVE_TIMEOUT

@patch('requests.Session.post', side_effect=requests.exceptions.Timeout)
def test_VitalLensRPPGMethod_stream_timeout(mock_post, mock_resolve_config):
method = VitalLensRPPGMethod(api_key="key", requested_model_name="vitallens")
frames = np.random.randint(0, 255, (16, 40, 40, 3), dtype=np.uint8)
with pytest.raises(VitalLensAPIError, match="timed out"):
method.infer_stream(frames=frames, fps=30.0, state=None)
assert mock_post.call_args.kwargs['timeout'] == API_STREAM_TIMEOUT

@patch('requests.Session.post', side_effect=requests.exceptions.Timeout)
def test_VitalLensRPPGMethod_file_timeout(mock_post, mock_resolve_config):
method = VitalLensRPPGMethod(api_key="key", requested_model_name="vitallens")
n = 32
frames = np.random.randint(0, 255, (n, 40, 40, 3), dtype=np.uint8)
faces = np.tile(np.array([[10, 10, 90, 90]], dtype=np.int64), (n, 1))
with pytest.raises(VitalLensAPIError, match="timed out"):
method.process_api_batch(
batch=1, n_batches=1, inputs=frames, inputs_shape=(n, 100, 100, 3),
faces=faces, fps_target=30.0, fps=30.0, global_parse=True)
assert mock_post.call_args.kwargs['timeout'] == API_FILE_TIMEOUT
Loading
Loading