From ffbe0411eba95631dfc4794db3abe96d1f9ea5db Mon Sep 17 00:00:00 2001 From: Philipp Rouast Date: Fri, 9 Oct 2026 12:22:12 +1100 Subject: [PATCH 1/9] Add TODO to derive /file split length from core buffer config for non-40x40 models --- vitallens/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/vitallens/constants.py b/vitallens/constants.py index f2e7d5b..e7feb7a 100644 --- a/vitallens/constants.py +++ b/vitallens/constants.py @@ -24,6 +24,7 @@ # API settings API_MIN_FRAMES = 16 +# TODO: 900 assumes 40x40 inputs. Derive /file split length from vc.compute_buffer_config(...).file_max (input_size aware, ~625 at 48x48). API_MAX_FRAMES = 900 API_OVERLAP = 30 API_FILE_URL = "https://api.rouast.com/vitallens-v3/file" From e0df0fbcdeaa7b9db53267ed7b431d40f4f24ebf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:58:08 +1100 Subject: [PATCH 2/9] Bump actions/cache from 5 to 6 (#59) Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 17a2aa0..739e828 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,7 +26,7 @@ jobs: 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') }} @@ -73,7 +73,7 @@ jobs: with: python-version: "3.10" - 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') }} From f6ab1e5af326bca260710a32bd9f0d26b2a98f49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:58:36 +1100 Subject: [PATCH 3/9] Bump actions/checkout from 6 to 7 (#58) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/main.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 739e828..79b5b22 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,7 @@ jobs: python-version: ["3.10", "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 }} @@ -65,7 +65,7 @@ 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db4f45e..d12dfb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 From bfd37e54a789a8c9747c9daf0fdbd47a4e1bda5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:32:34 +1100 Subject: [PATCH 4/9] Bump vitallens-core from 0.2.5 to 0.3.3 (#57) Bumps [vitallens-core](https://github.com/Rouast-Labs/vitallens-core) from 0.2.5 to 0.3.3. - [Release notes](https://github.com/Rouast-Labs/vitallens-core/releases) - [Commits](https://github.com/Rouast-Labs/vitallens-core/compare/v0.2.5...v0.3.3) --- updated-dependencies: - dependency-name: vitallens-core dependency-version: 0.3.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 391c1e6..47a77a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.3", ] dynamic = ["version"] From 957078b9a116c65d2f363da8dafb02a737245f0b Mon Sep 17 00:00:00 2001 From: Philipp Rouast Date: Mon, 19 Oct 2026 18:00:28 +1100 Subject: [PATCH 5/9] Use vitallens-core 0.3.4, add request timeouts, note on getting accurate results - vitallens-core 0.2.5 -> 0.3.4 (full test suite passes). - Requests to resolve-model, /stream and /file now have timeouts and raise VitalLensAPIError when they run out; without one, a stalled connection blocked forever. /file allows 300 s for the ~6 MB upload so slow mobile links are not cut off. - docs/results.md: a short Getting Accurate Results section. --- docs/results.md | 13 +++++++++++++ pyproject.toml | 2 +- tests/test_vitallens.py | 32 ++++++++++++++++++++++++++++++-- vitallens/constants.py | 11 +++++++++++ vitallens/methods/vitallens.py | 15 ++++++++++++--- 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/results.md b/docs/results.md index de235ed..3634b82 100644 --- a/docs/results.md +++ b/docs/results.md @@ -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. + diff --git a/pyproject.toml b/pyproject.toml index 47a77a0..dd04bad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "python-dotenv>=1.0", "pyyaml>=6.0.1", "requests>=2.32.0", - "vitallens-core==0.3.3", + "vitallens-core==0.3.4", ] dynamic = ["version"] diff --git a/tests/test_vitallens.py b/tests/test_vitallens.py index 8d483c2..25c71cb 100644 --- a/tests/test_vitallens.py +++ b/tests/test_vitallens.py @@ -10,7 +10,7 @@ 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 @@ -378,4 +378,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'] \ No newline at end of file + 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 diff --git a/vitallens/constants.py b/vitallens/constants.py index e7feb7a..2f4e8c2 100644 --- a/vitallens/constants.py +++ b/vitallens/constants.py @@ -37,6 +37,17 @@ if 'API_RESOLVE_URL' in os.environ: API_RESOLVE_URL = os.getenv('API_RESOLVE_URL') +# Request timeouts in seconds, as (connect, read). Without a timeout, a stalled connection blocks +# forever. Note that requests sends the request body within the *connect* timeout, so for uploads +# the first number is the time budget for sending the whole body, not just for connecting: +# /file uploads video chunks of about 6 MB and the API may take a while to process them: 300 s +# still lets an uplink of ~0.2 Mbit/s finish, so customers on slow mobile links are not cut off. +# A /stream request carries ~100 KB, and one that takes longer than this is of no use any more +# (newer frames queue behind it). +API_RESOLVE_TIMEOUT = (10, 30) +API_STREAM_TIMEOUT = (10, 10) +API_FILE_TIMEOUT = (300, 300) + # For local development against dev endpoints, create a `.env` file in the root # of the project and set the variables, for example: # API_FILE_URL="https://api-dev.rouast.com/vitallens-dev/file" diff --git a/vitallens/methods/vitallens.py b/vitallens/methods/vitallens.py index 006c9a3..e1fd9b9 100644 --- a/vitallens/methods/vitallens.py +++ b/vitallens/methods/vitallens.py @@ -36,6 +36,7 @@ from vitallens.constants import API_MAX_FRAMES, API_OVERLAP from vitallens.constants import API_FILE_URL, API_STREAM_URL, API_RESOLVE_URL +from vitallens.constants import API_RESOLVE_TIMEOUT, API_STREAM_TIMEOUT, API_FILE_TIMEOUT from vitallens.errors import VitalLensAPIKeyError, VitalLensAPIQuotaExceededError, VitalLensAPIError from vitallens.methods.rppg_method import RPPGMethod from vitallens.signal import reassemble_from_windows @@ -62,7 +63,7 @@ def _resolve_model_config( if model_name != "vitallens": params['model'] = model_name try: - response = requests.get(API_RESOLVE_URL, headers=headers, params=params, proxies=proxies) + response = requests.get(API_RESOLVE_URL, headers=headers, params=params, proxies=proxies, timeout=API_RESOLVE_TIMEOUT) response.raise_for_status() data = response.json() config_dict = data['config'] @@ -75,6 +76,8 @@ def _resolve_model_config( roi_method=config_dict.get('roi_method', 'upper_body_cropped'), return_waveforms=['ppg_waveform', 'respiratory_waveform'] ) + except requests.exceptions.Timeout as e: + raise VitalLensAPIError("The request to resolve the model config timed out. Please check your connection and try again.") from e except requests.exceptions.HTTPError as e: error_msg = f"Failed to resolve model config: Status {e.response.status_code}" try: @@ -255,7 +258,10 @@ def infer_stream(self, frames: np.ndarray, fps: float, state=None): compressed_data = gzip.compress(raw_rgb_bytes) # Post the binary payload - response = self.http_session.post(API_STREAM_URL, headers=headers, data=compressed_data, proxies=self.proxies) + try: + response = self.http_session.post(API_STREAM_URL, headers=headers, data=compressed_data, proxies=self.proxies, timeout=API_STREAM_TIMEOUT) + except requests.exceptions.Timeout as e: + raise VitalLensAPIError("The request to the VitalLens API timed out. The connection may be too slow for streaming.") from e if response.status_code != 200: response_body = response.json() @@ -357,7 +363,10 @@ def process_api_batch( if self.requested_model_name: payload['model'] = self.requested_model_name # Ask API to process video - response = self.http_session.post(API_FILE_URL, headers=headers, json=payload, proxies=self.proxies) + try: + response = self.http_session.post(API_FILE_URL, headers=headers, json=payload, proxies=self.proxies, timeout=API_FILE_TIMEOUT) + except requests.exceptions.Timeout as e: + raise VitalLensAPIError("The request to the VitalLens API timed out. Please check your connection and try again.") from e response_body = json.loads(response.text) # Check if call was successful if response.status_code != 200: From a28aada8be49907ebcd518032983b34d954677ca Mon Sep 17 00:00:00 2001 From: Philipp Rouast Date: Mon, 19 Oct 2026 18:09:55 +1100 Subject: [PATCH 6/9] Tests: mock the Session in the mock tests, mark real API tests as integration, add real large upload and stream e2e The file_mock and ndarray_mock tests patched requests.post, but the code posts through a Session since 0f6630d, so they quietly called the real dev API. They now patch requests.Session.post and use a fake key. The tests that deliberately call the real API are marked integration (registered in pyproject.toml). New: a real multi-request /file upload (the kind of test that catches a timeout too tight for large uploads), a real streaming test paced like a camera, and a resolve-model test that is really resolved. Two cheap checks that the timeouts leave room for slow uplinks. Full suite 3m30s -> 1m53s; pytest -m 'not integration' takes about 17 s. --- pyproject.toml | 5 ++++ tests/test_client.py | 30 ++++++++++++++++++++++ tests/test_vitallens.py | 55 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dd04bad..7f077bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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\"", +] diff --git a/tests/test_client.py b/tests/test_client.py index dd9c9a1..7a42f38 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,6 +2,7 @@ import numpy as np import os import pytest +import time from unittest.mock import patch import sys @@ -42,6 +43,7 @@ def test_VitalLens(request, method_arg, detect_faces, file, export): assert data[0]['vitals']['heart_rate']['confidence'] == 1.0 os.remove(test_json_path) +@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) @@ -66,6 +68,34 @@ 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 +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"} diff --git a/tests/test_vitallens.py b/tests/test_vitallens.py index 25c71cb..6ff8941 100644 --- a/tests/test_vitallens.py +++ b/tests/test_vitallens.py @@ -14,6 +14,9 @@ from vitallens.methods.vitallens import VitalLensRPPGMethod, _resolve_model_config from vitallens.errors import VitalLensAPIKeyError, VitalLensAPIError, VitalLensAPIQuotaExceededError +# 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 @@ -122,9 +125,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') @@ -149,9 +152,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') @@ -229,6 +232,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): @@ -282,6 +286,40 @@ 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 +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') @@ -294,6 +332,7 @@ 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} @@ -301,6 +340,7 @@ def test_VitalLens_API_no_video(request): 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} @@ -320,11 +360,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): From d5c0458ff13bac6f0095a98bf4fdf04c2eb50174 Mon Sep 17 00:00:00 2001 From: Philipp Rouast Date: Mon, 19 Oct 2026 19:05:40 +1100 Subject: [PATCH 7/9] CI: run the heavy real-API tests in one job only The four CI jobs share one dev API key and rate limit. The real streaming and large upload tests made enough requests to trigger 429s when all jobs ran them at once. They are now skipped unless VITALLENS_HEAVY_E2E is set, which only the Linux Python 3.12 job does. --- .github/workflows/main.yml | 2 ++ tests/test_client.py | 5 +++++ tests/test_vitallens.py | 6 ++++++ 3 files changed, 13 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 79b5b22..19e4141 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/tests/test_client.py b/tests/test_client.py index 7a42f38..84ba756 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -43,6 +43,10 @@ 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') @@ -69,6 +73,7 @@ def test_VitalLens_API(request): 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') diff --git a/tests/test_vitallens.py b/tests/test_vitallens.py index 6ff8941..bd07b7d 100644 --- a/tests/test_vitallens.py +++ b/tests/test_vitallens.py @@ -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 @@ -14,6 +15,10 @@ 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" @@ -287,6 +292,7 @@ def test_VitalLens_API_valid_response(request, process_signals, n_frames): 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. From 2bb4598be6695d6135ecf2fe65eb98b6e7d23b61 Mon Sep 17 00:00:00 2001 From: Philipp Rouast Date: Sat, 24 Oct 2026 13:41:58 +1100 Subject: [PATCH 8/9] Send the client version in the API origin (vitallens-python_) The label lets us see which SDK versions are in use. VITALLENS_API_ORIGIN still overrides it verbatim. --- tests/test_utils.py | 28 ++++++++++++++++++++++++++++ vitallens/methods/vitallens.py | 7 +++---- vitallens/utils.py | 18 ++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2f736f5..57dcb7c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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 diff --git a/vitallens/methods/vitallens.py b/vitallens/methods/vitallens.py index e1fd9b9..275b8fc 100644 --- a/vitallens/methods/vitallens.py +++ b/vitallens/methods/vitallens.py @@ -30,7 +30,6 @@ import json import logging import requests -import os from typing import Union, Tuple import vitallens_core as vc @@ -40,7 +39,7 @@ from vitallens.errors import VitalLensAPIKeyError, VitalLensAPIQuotaExceededError, VitalLensAPIError from vitallens.methods.rppg_method import RPPGMethod from vitallens.signal import reassemble_from_windows -from vitallens.utils import check_faces_in_roi +from vitallens.utils import check_faces_in_roi, get_api_origin def _resolve_model_config( api_key: str, @@ -245,7 +244,7 @@ def infer_stream(self, frames: np.ndarray, fps: float, state=None): } if self.api_key: headers["x-api-key"] = self.api_key - origin = os.getenv('VITALLENS_API_ORIGIN', 'vitallens-python') + origin = get_api_origin() headers["X-Origin"] = origin if self.requested_model_name: headers["X-Model"] = self.requested_model_name @@ -358,7 +357,7 @@ def process_api_batch( headers = {} if self.api_key: headers["x-api-key"] = self.api_key - origin = os.getenv('VITALLENS_API_ORIGIN', 'vitallens-python') + origin = get_api_origin() payload = {"video": base64.b64encode(frames_ds.tobytes()).decode('utf-8'), "origin": origin} if self.requested_model_name: payload['model'] = self.requested_model_name diff --git a/vitallens/utils.py b/vitallens/utils.py index 5f8f687..b22b8b8 100644 --- a/vitallens/utils.py +++ b/vitallens/utils.py @@ -18,12 +18,30 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import importlib.metadata as importlib_metadata import logging import numpy as np import os +import re from typing import Union import urllib.request +def get_api_origin() -> str: + """Get the origin label sent to the API: `vitallens-python_`. + + The version lets us see which client versions are in use. `VITALLENS_API_ORIGIN` overrides the label + and is sent as is (the API treats `test` specially). Without an installed version, e.g. when run from + a source checkout, only `vitallens-python` is sent. + """ + override = os.getenv('VITALLENS_API_ORIGIN') + if override: + return override + try: + version = re.sub(r'[^A-Za-z0-9._+-]', '', importlib_metadata.version('vitallens'))[:40] + except Exception: + return 'vitallens-python' + return f'vitallens-python_{version}' if version else 'vitallens-python' + def download_file(url: str, dest: str): """Download a file if necessary. From 1f0d4e33a485c9d0bd9e8733f2eafb3fe767f131 Mon Sep 17 00:00:00 2001 From: Philipp Rouast Date: Sat, 24 Oct 2026 13:45:35 +1100 Subject: [PATCH 9/9] Require Python 3.11 or newer Python 3.10 reaches end of life this month, and the fixed releases of pillow and filelock behind the security audit need 3.11, so the 3.10 CI leg cannot pass the audit. --- .github/workflows/main.yml | 6 +++--- .github/workflows/release.yml | 2 +- README.md | 2 +- pyproject.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 19e4141..71be36f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ 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@v7 @@ -70,10 +70,10 @@ jobs: 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@v6 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d12dfb1..085b9a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: - 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 diff --git a/README.md b/README.md index 13a37e0..2ad1bb9 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ For full API reference, advanced configuration, proxy usage, and result schemas, ## 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`. diff --git a/pyproject.toml b/pyproject.toml index 7f077bd..c34ba90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [