diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 71be36f..f5dab19 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Cache pip dependencies @@ -71,7 +71,7 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python 3.11 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Cache pip dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 085b9a8..0401022 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" @@ -36,7 +36,7 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: dist/* generate_release_notes: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0169e0c..32bb9c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,12 +67,6 @@ python -m build The artifacts will be generated in the `dist/` directory. -Since this is a Python project using `setuptools_scm`, versioning is handled automatically based on git tags. I've adapted the workflow from your JS library to Python conventions while maintaining the same straightforward structure. - -Add this section to the end of your `CONTRIBUTING.md`: - ---- - ## Releases Releases are automated via GitHub Actions and triggered by pushing git tags. The project uses `setuptools_scm`, so the version is derived directly from the tag name. diff --git a/docs/results.md b/docs/results.md index c4eb0fd..ddf87ac 100644 --- a/docs/results.md +++ b/docs/results.md @@ -14,7 +14,7 @@ hr = results[0]['vitals']['heart_rate']['value'] Waveform data, confidences and timestamps are returned as lists; scalar values are Python floats. -Besides `face`, `vitals`, `waveforms` and `message`, each result contains: +Besides `face`, `vitals`, `waveforms`, `message`, `model_used` (VitalLens API only) and `core_version`, each result contains: | Key | Contents | | --- | --- | diff --git a/pyproject.toml b/pyproject.toml index aed82c1..2403932 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.4.0", + "vitallens-core==0.4.2", ] dynamic = ["version"] diff --git a/tests/test_client.py b/tests/test_client.py index 84ba756..bf10a69 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,6 +8,7 @@ import sys sys.path.append('../vitallens-python') +import vitallens_core from vitallens.client import VitalLens, Method @pytest.mark.parametrize("method_arg", [Method.G, "chrom", "pos"]) @@ -30,6 +31,8 @@ def test_VitalLens(request, method_arg, detect_faces, file, export): assert np.asarray(result[0]['waveforms']['ppg_waveform']['confidence']).shape == (630,) np.testing.assert_allclose(result[0]['vitals']['heart_rate']['value'], 60, atol=10) assert result[0]['vitals']['heart_rate']['confidence'] == 1.0 + assert result[0]['core_version'] == vitallens_core.core_version() + assert 'model_used' not in result[0] if export: test_json_path = os.path.join("test.json") assert os.path.exists(test_json_path) @@ -62,14 +65,15 @@ def test_VitalLens_API(request): assert np.asarray(result[0]['waveforms']['respiratory_waveform']['confidence']).shape == (630,) assert 'hrv_sdnn' in result[0]['vitals'] assert 'hrv_rmssd' in result[0]['vitals'] - np.testing.assert_allclose(result[0]['vitals']['heart_rate']['value'], 60, atol=0.5) + # HR and HRV reference values from the contact PPG in examples/sample_vitals_2.csv + np.testing.assert_allclose(result[0]['vitals']['heart_rate']['value'], 60.4, atol=0.5) np.testing.assert_allclose(result[0]['vitals']['heart_rate']['confidence'], 1.0, atol=0.1) np.testing.assert_allclose(result[0]['vitals']['respiratory_rate']['value'], 13, atol=1.0) np.testing.assert_allclose(result[0]['vitals']['respiratory_rate']['confidence'], 1.0, atol=0.1) supports_hrv = result[0].get('model_used', '') == 'vitallens-2.0' if supports_hrv: - np.testing.assert_allclose(result[0]['vitals']['hrv_sdnn']['value'], 40, atol=20) - np.testing.assert_allclose(result[0]['vitals']['hrv_rmssd']['value'], 30, atol=20) + np.testing.assert_allclose(result[0]['vitals']['hrv_sdnn']['value'], 62, atol=10) + np.testing.assert_allclose(result[0]['vitals']['hrv_rmssd']['value'], 54, atol=10) assert not os.path.exists("test.json") @pytest.mark.integration diff --git a/tests/test_stream.py b/tests/test_stream.py index d3cc8b9..f7750e1 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -74,4 +74,5 @@ def test_stream_session(): assert len(res) > 0 assert 'vitals' in res[0] assert 'waveforms' in res[0] - assert 'ppg_waveform' in res[0]['waveforms'] \ No newline at end of file + assert 'ppg_waveform' in res[0]['waveforms'] + assert res[0]['core_version'] == vc.core_version() \ No newline at end of file diff --git a/tests/test_vitallens.py b/tests/test_vitallens.py index bd07b7d..93be4c9 100644 --- a/tests/test_vitallens.py +++ b/tests/test_vitallens.py @@ -152,6 +152,7 @@ def test_VitalLensRPPGMethod_file_mock(mock_post, mock_resolve_config, request, assert data['respiratory_waveform'].shape == (test_video_ndarray.shape[0],) assert conf['respiratory_waveform'].shape == (test_video_ndarray.shape[0],) assert live.shape == (test_video_ndarray.shape[0],) + assert method.model_used == requested_model @pytest.mark.parametrize("override_fps_target", [None, 15, 10]) @pytest.mark.parametrize("long", [True]) diff --git a/vitallens/client.py b/vitallens/client.py index 94ca557..a1cc191 100644 --- a/vitallens/client.py +++ b/vitallens/client.py @@ -167,7 +167,9 @@ def __call__( }, }, - 'message': + 'message': , + 'model_used': 'vitallens-2.0', + 'core_version': '0.4.2' }, { @@ -234,6 +236,8 @@ def __call__( for k, v in session_result.rolling_vitals.items() } if session_result.rolling_vitals else {}, 'message': session_result.message, + **({'model_used': self.rppg.model_used} if getattr(self.rppg, 'model_used', None) else {}), + 'core_version': vc.core_version(), 'fps': session_result.fps, 'n': len(session_result.timestamp), 'time': session_result.timestamp @@ -271,7 +275,7 @@ def stream(self, on_result: Callable = None) -> StreamSession: Returns: session: A `StreamSession` context manager. The session object provides: * `push(frame, timestamp, face=None)`: Ingests a new RGB frame. - `timestamp` should be in seconds (e.g., `time.time()`). + `timestamp` is the capture time in seconds (e.g., `time.monotonic()`). * `get_result(block=False, timeout=None)`: Pulls the latest analysis results from the queue. * `current_face`: The most recently detected face coordinates. diff --git a/vitallens/methods/vitallens.py b/vitallens/methods/vitallens.py index 275b8fc..d4f2424 100644 --- a/vitallens/methods/vitallens.py +++ b/vitallens/methods/vitallens.py @@ -115,6 +115,7 @@ def __init__( self.session_config = _resolve_model_config(api_key=api_key, model_name=requested_model_name, proxies=proxies) self.parse_config(self.session_config) self.resolved_model = self.session_config.model_name + self.model_used = None self.requested_model_name = requested_model_name if requested_model_name != "vitallens" else None self.http_session = requests.Session() @@ -270,6 +271,7 @@ def infer_stream(self, frames: np.ndarray, fps: float, state=None): else: raise VitalLensAPIError(f"API Error: {response_body.get('message')}") response_body = response.json() + self.model_used = response_body.get('model_used', self.model_used) api_waveforms = response_body.get("waveforms", {}) sig_dict, conf_dict = {}, {} @@ -378,6 +380,7 @@ def process_api_batch( raise VitalLensAPIError(f"API Error: {response_body['message']}") else: raise Exception(f"Error {response.status_code}: {response_body['message']}") + self.model_used = response_body.get('model_used', self.model_used) # Dynamic dict parsing api_waveforms = response_body.get("waveforms", {}) sig_ds = {} diff --git a/vitallens/stream.py b/vitallens/stream.py index 1ad1d0a..439d5c5 100644 --- a/vitallens/stream.py +++ b/vitallens/stream.py @@ -399,6 +399,8 @@ def _format_result(self, session_result): 'vitals': {}, 'waveforms': {}, 'message': session_result.message, + **({'model_used': self.rppg.model_used} if getattr(self.rppg, 'model_used', None) else {}), + 'core_version': vc.core_version(), 'fps': session_result.fps, 'n': len(session_result.timestamp), 'time': session_result.timestamp