Skip to content

Commit 0921b84

Browse files
committed
Update docs and docstrings
1 parent c519a31 commit 0921b84

6 files changed

Lines changed: 226 additions & 10 deletions

File tree

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ The library provides:
2222
- **High-Fidelity Accuracy:** A simple interface to the VitalLens API for state-of-the-art estimation (heart rate, respiratory rate, HRV).
2323
- **Local Fallbacks:** Implementations of classic rPPG algorithms (`pos`, `chrom`, `g`) for local, API-free processing.
2424
- **Flexible Input:** Support for video files and in-memory `np.ndarray`.
25+
- **Real-time Streaming:** `stream()` context manager for low-latency live inference.
2526
- **Face Detection:** Integrated fast face detection and ROI management.
2627

2728
Using a different language? Check out our [JavaScript client](https://github.com/Rouast-Labs/vitallens.js) and [iOS SDK](https://github.com/Rouast-Labs/vitallens-ios).
@@ -53,7 +54,7 @@ print("Heart Rate:", results[0]['vitals']['heart_rate']['value'])
5354
To get improved accuracy and advanced metrics like **Respiratory Rate** and **HRV**, use the `vitallens` method. You can get a free key from the [API Dashboard](https://www.rouast.com/api).
5455

5556
```python
56-
from vitallens import VitalLens, Method
57+
from vitallens import VitalLens
5758

5859
# Automatically selects the best model for your plan
5960
vl = VitalLens(method="vitallens", api_key="YOUR_API_KEY")
@@ -68,6 +69,23 @@ print(f"Respiratory Rate: {vitals['respiratory_rate']['value']:.1f} rpm")
6869
if 'hrv_sdnn' in vitals:
6970
print(f"HRV (SDNN): {vitals['hrv_sdnn']['value']:.1f} ms")
7071
```
72+
73+
### Real-time Streaming
74+
75+
Process live frames from a webcam or stream.
76+
77+
```python
78+
import time
79+
from vitallens import VitalLens
80+
81+
# Process live frames
82+
vl = VitalLens(method="vitallens", api_key="YOUR_API_KEY")
83+
84+
with vl.stream() as session:
85+
# In your capture loop (e.g., OpenCV)
86+
session.push(frame, timestamp=time.time())
87+
results = session.get_result(block=False)
88+
```
7189
<!-- mkdocs-end -->
7290

7391
## Documentation

examples/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,51 @@ video_arr = np.array(frames)
9797
results = vl(video_arr, fps=fps)
9898
```
9999

100+
### Real-time Streaming
101+
102+
For live feeds or webcams. There are two ways to handle results:
103+
104+
#### Polling (Non-blocking)
105+
106+
Best for applications with their own main loop (e.g., OpenCV display).
107+
108+
```python
109+
import time
110+
from vitallens import VitalLens
111+
112+
vl = VitalLens(method="vitallens", api_key="YOUR_API_KEY")
113+
114+
with vl.stream() as session:
115+
while True:
116+
frame, ts = get_frame() # Your capture logic
117+
session.push(frame, timestamp=ts)
118+
119+
# Check for results whenever you want
120+
results = session.get_result(block=False)
121+
if results:
122+
print(results[0]['vitals']['heart_rate']['value'])
123+
```
124+
125+
#### Callback
126+
127+
Best for event-driven applications. The callback is triggered automatically as soon as inference finishes.
128+
129+
```python
130+
import time
131+
from vitallens import VitalLens
132+
133+
def my_callback(results):
134+
print(f"Callback received HR: {results[0]['vitals']['heart_rate']['value']}")
135+
136+
vl = VitalLens(method="vitallens", api_key="YOUR_API_KEY")
137+
138+
with vl.stream(on_result=my_callback) as session:
139+
while True:
140+
frame, ts = get_frame()
141+
session.push(frame, timestamp=ts)
142+
# No need to call get_result()
143+
```
144+
100145
## Running with Docker
101146

102147
If you encounter dependency issues (e.g., with `onnxruntime` or `ffmpeg`), you can run the example scripts inside our Docker container.

vitallens/client.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,36 @@ def __call__(
250250
return results
251251

252252
def stream(self, on_result: Callable = None) -> StreamSession:
253-
"""
254-
Returns a StreamSession context manager for real-time video processing.
253+
"""Returns a context manager for real-time vital sign estimation.
254+
255+
This method creates a `StreamSession` that manages background inference threads,
256+
sliding window buffers, and signal state via `vitallens-core`. It is designed
257+
for low-latency applications like webcam feeds.
258+
259+
Usage:
260+
```python
261+
with vl.stream() as session:
262+
session.push(frame, timestamp)
263+
results = session.get_result(block=False)
264+
```
265+
266+
Args:
267+
on_result: An optional callback function triggered automatically whenever
268+
new inference results are available. The function should accept one
269+
argument (the results list).
270+
271+
Returns:
272+
session: A `StreamSession` context manager. The session object provides:
273+
* `push(frame, timestamp, face=None)`: Ingests a new RGB frame.
274+
`timestamp` should be in seconds (e.g., `time.time()`).
275+
* `get_result(block=False, timeout=None)`: Pulls the latest analysis
276+
results from the queue.
277+
* `current_face`: The most recently detected face coordinates.
278+
279+
Note:
280+
The results returned by `get_result()` or the callback follow the same
281+
format as `__call__`, but represent the physiological state of the
282+
current sliding window rather than a global file average.
255283
"""
256284
return StreamSession(
257285
rppg_method=self.rppg,

vitallens/methods/simple_rppg_method.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,19 @@ def infer_stream(
123123
fps: float,
124124
state
125125
):
126-
"""TODO"""
126+
"""Estimate pulse signal from a sequence of frames in a streaming context.
127+
128+
Args:
129+
frames: The input video frames of shape (n_frames, h, w, 3).
130+
fps: The sampling frequency of the input frames.
131+
state: The internal state of the rPPG method (unused for simple methods).
132+
Returns:
133+
Tuple of
134+
- sig_dict: A dictionary of the estimated signals.
135+
- conf_dict: A dictionary of the estimated confidences.
136+
- live_out: Dummy live confidence estimation (set to always 1). Shape (n_frames,)
137+
- state: The updated internal state of the rPPG method (None).
138+
"""
127139
sig = self.algorithm(frames, fps)
128140
sig_dict = {'ppg_waveform': sig}
129141
conf_dict = {'ppg_waveform': np.ones_like(sig)}

vitallens/methods/vitallens.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,19 @@ def stack_dicts(dict_list):
223223
return sig_dict, conf_dict, live
224224

225225
def infer_stream(self, frames: np.ndarray, fps: float, state=None):
226-
"""TODO"""
226+
"""Estimate vitals from a sequence of frames using the VitalLens streaming API.
227+
228+
Args:
229+
frames: The input video frames of shape (n_frames, h, w, 3).
230+
fps: The sampling frequency of the input frames.
231+
state: The internal state of the rPPG method used to maintain temporal continuity.
232+
Returns:
233+
Tuple of
234+
- sig_dict: A dictionary of the estimated signals.
235+
- conf_dict: A dictionary of the estimated confidences.
236+
- live: The face live confidence. Shape (n_frames,)
237+
- new_state: The updated internal state of the rPPG method.
238+
"""
227239
headers = {
228240
"Content-Type": "application/octet-stream",
229241
"X-Encoding": "gzip"

vitallens/stream.py

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ class InferenceContext:
3939

4040
class FrameBuffer:
4141
def __init__(self, buffer_id: str, roi: vc.Rect, max_capacity: int, timestamp: float):
42+
"""Initialize a thread-safe frame buffer for a specific region of interest.
43+
44+
Args:
45+
buffer_id: Unique identifier for the buffer.
46+
roi: The region of interest (ROI) associated with this buffer.
47+
max_capacity: The maximum number of frames the buffer can hold.
48+
timestamp: The creation timestamp of the buffer.
49+
"""
4250
self.id = buffer_id
4351
self.roi = roi
4452
self.created_at = timestamp
@@ -51,13 +59,27 @@ def count(self) -> int:
5159
return len(self.buffer)
5260

5361
def append(self, frame: np.ndarray, context: InferenceContext):
62+
"""Add a frame and its metadata to the buffer, maintaining max capacity.
63+
64+
Args:
65+
frame: The parsed RGB image data.
66+
context: Metadata including timestamp and ROI.
67+
"""
5468
self.buffer.append((frame, context))
5569
self.last_seen = context.timestamp
5670
if len(self.buffer) > self.max_capacity:
5771
overflow = len(self.buffer) - self.max_capacity
5872
self.buffer = self.buffer[overflow:]
5973

6074
def execute(self, take_count: int, keep_count: int) -> list:
75+
"""Extract frames for inference and manage buffer overlap.
76+
77+
Args:
78+
take_count: Number of frames to extract for the current batch.
79+
keep_count: Number of frames to retain for the next sliding window.
80+
Returns:
81+
payload: A list of (frame, context) tuples, or None if insufficient frames.
82+
"""
6183
if take_count <= 0 or len(self.buffer) < take_count:
6284
return None
6385
payload = self.buffer[:take_count]
@@ -66,8 +88,13 @@ def execute(self, take_count: int, keep_count: int) -> list:
6688
return payload\
6789

6890
class BufferManager:
69-
"""Thread-safe buffer manager."""
91+
"""Thread-safe manager for multiple streaming frame buffers."""
7092
def __init__(self, buffer_config: vc.BufferConfig):
93+
"""Initialize the manager with specific buffering and overlap constraints.
94+
95+
Args:
96+
buffer_config: Configuration for stream window sizes and overlaps.
97+
"""
7198
self.buffer_planner = vc.BufferPlanner(buffer_config)
7299
self.buffer_config = buffer_config
73100
self.buffers = {}
@@ -84,6 +111,14 @@ def _get_active_metadata(self):
84111
]
85112

86113
def register_target(self, target_roi: vc.Rect, timestamp: float):
114+
"""Register or update a target ROI in the buffer planner.
115+
116+
Args:
117+
target_roi: The latest detected face ROI to track.
118+
timestamp: The current frame timestamp.
119+
Returns:
120+
buffer_id: The ID of the buffer assigned to this target, or None.
121+
"""
87122
with self.lock:
88123
self.current_timestamp = max(self.current_timestamp, timestamp)
89124
action = self.buffer_planner.evaluate_target(target_roi, timestamp, self._get_active_metadata())
@@ -100,12 +135,26 @@ def register_target(self, target_roi: vc.Rect, timestamp: float):
100135
return None
101136

102137
def append(self, buffer_id: str, frame: np.ndarray, context: InferenceContext):
138+
"""Route a frame to a specific active buffer.
139+
140+
Args:
141+
buffer_id: The identifier for the target buffer.
142+
frame: The parsed RGB image data.
143+
context: Metadata including timestamp and ROI.
144+
"""
103145
with self.lock:
104146
self.current_timestamp = max(self.current_timestamp, context.timestamp)
105147
if buffer_id in self.buffers:
106148
self.buffers[buffer_id].append(frame, context)
107149

108150
def poll(self, flush: bool = False):
151+
"""Poll the planner for the next required inference command.
152+
153+
Args:
154+
flush: If True, forces the planner to return commands for remaining data.
155+
Returns:
156+
command: A vc.InferenceCommand containing the buffer ID and frame counts.
157+
"""
109158
with self.lock:
110159
has_state = self.state is not None
111160
plan = self.buffer_planner.poll(
@@ -121,30 +170,62 @@ def poll(self, flush: bool = False):
121170
return plan.command
122171

123172
def execute(self, command: vc.InferenceCommand) -> list:
173+
"""Execute a specific inference command by extracting data from the relevant buffer.
174+
175+
Args:
176+
command: The command containing buffer ID and frame counts.
177+
Returns:
178+
window: A list of frames and contexts ready for inference.
179+
"""
124180
with self.lock:
125181
if command.buffer_id in self.buffers:
126182
return self.buffers[command.buffer_id].execute(command.take_count, command.keep_count)
127183
return None
128184

129185
def get_state(self):
186+
"""Retrieve the current physiological state of the rPPG model.
187+
188+
Returns:
189+
state: The model's internal state vector or None.
190+
"""
130191
with self.lock:
131192
return self.state
132193

133194
def update_state(self, new_state):
195+
"""Update the internal state of the rPPG model after an inference step.
196+
197+
Args:
198+
new_state: The updated state vector returned by the API.
199+
"""
134200
with self.lock:
135201
self.state = new_state
136202

137203
def get_all_buffers(self):
204+
"""Get a list of all currently active buffers.
205+
206+
Returns:
207+
buffers: A list of (buffer_id, roi) tuples.
208+
"""
138209
with self.lock:
139210
return [(buf.id, buf.roi) for buf in self.buffers.values()]
140211

141212
def reset(self):
213+
"""Clear all active buffers and reset the model state."""
142214
with self.lock:
143215
self.buffers.clear()
144216
self.state = None
145217

146218
class StreamSession:
219+
"""Context manager handling background inference and buffer synchronization for live streams."""
147220
def __init__(self, rppg_method, face_detector=None, fdet_fs=1.0, on_result: Optional[Callable] = None):
221+
"""Initialize the streaming session and start the background inference thread.
222+
223+
Args:
224+
rppg_method: The method instance (e.g., VitalLensRPPGMethod) to use.
225+
face_detector: Optional instance of FaceDetector for automatic tracking.
226+
fdet_fs: Frequency [Hz] at which to run automatic face detection.
227+
on_result: Optional callback for asynchronous result handling.
228+
"""
148229
self.rppg = rppg_method
149230
self.face_detector = face_detector
150231
self.fdet_fs = fdet_fs
@@ -167,12 +248,19 @@ def __exit__(self, exc_type, exc_val, exc_tb):
167248
self.close()
168249

169250
def close(self):
251+
"""Stop the background thread and shut down the streaming session."""
170252
self.running = False
171253
if self.thread.is_alive():
172254
self.thread.join(timeout=2.0)
173255

174256
def push(self, frame: np.ndarray, timestamp: float, face: np.ndarray = None):
175-
"""Pushes a single frame to the ingestion pipeline on the main thread."""
257+
"""Push a single frame and its timestamp into the streaming pipeline.
258+
259+
Args:
260+
frame: The input video frame of shape (h, w, 3) in RGB format.
261+
timestamp: The absolute timestamp of the frame in seconds.
262+
face: Optional pre-detected face box [x0, y0, x1, y1].
263+
"""
176264
if not self.running: return
177265
# Throttled face detection
178266
min_interval = 1.0 / self.rppg.fps_target
@@ -235,14 +323,21 @@ def push(self, frame: np.ndarray, timestamp: float, face: np.ndarray = None):
235323
self.buffer_manager.append(buf_id, payload, ctx)
236324

237325
def get_result(self, block=False, timeout=None):
238-
"""Pulls the latest result from the queue."""
326+
"""Pull the latest inference results from the session queue.
327+
328+
Args:
329+
block: Whether to block until a result is available.
330+
timeout: Maximum time to block if block is True.
331+
Returns:
332+
results: A list of analysis results for detected faces.
333+
"""
239334
try:
240335
return self.result_queue.get(block=block, timeout=timeout)
241336
except queue.Empty:
242337
return None
243338

244339
def _inference_loop(self):
245-
"""Background thread executing inference without blocking the webcam."""
340+
"""Background loop that polls for ready buffers and executes model inference."""
246341
while self.running:
247342
command = self.buffer_manager.poll()
248343
if not command:
@@ -279,7 +374,13 @@ def _inference_loop(self):
279374
self.vc_session.reset()
280375

281376
def _format_result(self, session_result):
282-
# TODO: Is there a smarter way to do this?
377+
"""Format the raw session result into the standard vitallens-python result dictionary.
378+
379+
Args:
380+
session_result: The vc.SessionResult object from vitallens-core.
381+
Returns:
382+
res_dict: A list containing a formatted results dictionary for the face.
383+
"""
283384
face_dict = {
284385
'coordinates': [],
285386
'confidence': [],

0 commit comments

Comments
 (0)