Skip to content
Merged
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down
6 changes: 0 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/results.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
10 changes: 7 additions & 3 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/test_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
assert 'ppg_waveform' in res[0]['waveforms']
assert res[0]['core_version'] == vc.core_version()
1 change: 1 addition & 0 deletions tests/test_vitallens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
8 changes: 6 additions & 2 deletions vitallens/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ def __call__(
},
<other waveforms...>
},
'message': <Message about estimates>
'message': <Message about estimates>,
'model_used': 'vitallens-2.0',
'core_version': '0.4.2'
},
{
<same structure for face 2 if present>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions vitallens/methods/vitallens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 = {}, {}
Expand Down Expand Up @@ -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 = {}
Expand Down
2 changes: 2 additions & 0 deletions vitallens/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading