Skip to content

Commit 7fda1bb

Browse files
authored
Add speak detection model (#135)
* add speak detection and improve frame rate * update run.py file * update the camera_reader * update speaking logi * update the format * update format * fix format issue
1 parent ae5fc3b commit 7fda1bb

8 files changed

Lines changed: 1106 additions & 34 deletions

File tree

.typos.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ extend-ignore-re = []
44
[default.extend-words]
55
thr="thr"
66

7+
[default.extend-identifiers]
8+
solvePnP="solvePnP"
9+
710
[files]
811
extend-exclude = [ ]

src/om1_vlm/anonymizationSys/face_recog_stream/camera_reader.py

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import logging
44
import subprocess
5+
import threading
6+
import time
57
from typing import Optional
68

79
import cv2
@@ -11,7 +13,14 @@
1113

1214
class CameraReader:
1315
"""
14-
Camera reader using OpenCV with V4L2 backend.
16+
Camera reader using OpenCV with a V4L2 backend.
17+
18+
Capture runs in a BACKGROUND THREAD that continuously pulls the newest frame
19+
and keeps only the latest one. ``read_frame()`` then returns that latest
20+
frame without blocking. This mirrors what ``v4l2-ctl --stream-mmap`` does
21+
(drain continuously) and avoids the classic OpenCV problem where a
22+
synchronous ``cap.read()`` in the main loop serializes capture with
23+
processing and throttles FPS far below what the sensor can deliver.
1524
"""
1625

1726
def __init__(
@@ -21,36 +30,37 @@ def __init__(
2130
height: int,
2231
fps: int,
2332
rotate_90_cw: bool = False,
33+
threaded: bool = True,
2434
):
25-
"""
26-
Initialize the camera reader with the specified device and settings.
27-
28-
Parameters
29-
----------
30-
device : str
31-
Video device path (e.g., '/dev/video0').
32-
width : int
33-
Desired frame width.
34-
height : int
35-
Desired frame height.
36-
fps : int
37-
Desired frames per second.
38-
rotate_90_cw : bool
39-
Whether to rotate frames 90 degrees clockwise.
40-
"""
4135
self.device = device
4236
self.width = width
4337
self.height = height
4438
self.fps = fps
4539
self.rotate_90_cw = rotate_90_cw
40+
self.threaded = threaded
4641

4742
self.cap: Optional[cv2.VideoCapture] = None
43+
44+
# Latest-frame handoff between the grab thread and read_frame().
45+
self._latest = None
46+
self._latest_lock = threading.Lock()
47+
self._frame_ready = threading.Event()
48+
self._stop = threading.Event()
49+
self._grab_thread: Optional[threading.Thread] = None
50+
4851
self.open_camera()
4952

53+
if self.threaded:
54+
self._grab_thread = threading.Thread(
55+
target=self._grab_loop, name="camera-grab", daemon=True
56+
)
57+
self._grab_thread.start()
58+
# Give the thread a moment to land the first frame so the first
59+
# read_frame() isn't None.
60+
self._frame_ready.wait(timeout=2.0)
61+
5062
def open_camera(self):
51-
"""
52-
Open the camera using the specified device and settings.
53-
"""
63+
"""Open the camera using the specified device and settings."""
5464
self.open_capture(self.device, self.width, self.height, int(self.fps))
5565
if self.cap is None or not self.cap.isOpened():
5666
raise RuntimeError(f"Failed to open camera on device {self.device}")
@@ -79,7 +89,6 @@ def open_capture(
7989
self.cap.set(cv2.CAP_PROP_FPS, fps)
8090
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
8191
self.cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 3)
82-
8392
try:
8493
subprocess.run(
8594
[
@@ -104,10 +113,8 @@ def open_capture(
104113
device,
105114
(e.stderr or e.stdout or "").strip(),
106115
)
107-
108116
if not self.cap or not self.cap.isOpened():
109117
raise RuntimeError(f"Failed to open camera device {device}")
110-
111118
actual_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
112119
actual_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
113120
actual_fps = float(self.cap.get(cv2.CAP_PROP_FPS)) or float(fps)
@@ -119,11 +126,41 @@ def open_capture(
119126
actual_fps,
120127
)
121128
self.width, self.height, self.fps = actual_width, actual_height, actual_fps
122-
123129
except Exception as e:
124130
logging.error("Error opening camera %s: %s", device, e)
125131
self.cap = None
126132

133+
def _grab_loop(self):
134+
"""Continuously read the newest frame; keep only the latest."""
135+
fail = 0
136+
while not self._stop.is_set():
137+
if self.cap is None or not self.cap.isOpened():
138+
time.sleep(0.05)
139+
try:
140+
self.cap = None
141+
self.open_capture(
142+
self.device, self.width, self.height, int(self.fps or 30)
143+
)
144+
except Exception:
145+
pass
146+
continue
147+
148+
ret, frame = self.cap.read()
149+
if not ret or frame is None:
150+
fail += 1
151+
if fail % 50 == 0:
152+
logging.warning("camera grab failing (%d)", fail)
153+
time.sleep(0.005)
154+
continue
155+
fail = 0
156+
157+
if self.rotate_90_cw:
158+
frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
159+
160+
with self._latest_lock:
161+
self._latest = frame
162+
self._frame_ready.set()
163+
127164
def is_opened(self) -> bool:
128165
"""
129166
Check if the camera is opened.
@@ -136,21 +173,25 @@ def is_opened(self) -> bool:
136173

137174
def read_frame(self):
138175
"""
139-
Read a frame from the camera.
176+
Return the most recent frame.
140177
141178
Returns
142179
-------
143-
The captured frame as a cv2.Mat object, or None if reading failed.
180+
Threaded mode: non-blocking, returns the latest grabbed frame (or None
181+
until the first one arrives). Non-threaded mode: the original synchronous
182+
read, kept for compatibility.
144183
"""
184+
if self.threaded:
185+
with self._latest_lock:
186+
return None if self._latest is None else self._latest.copy()
187+
145188
if not self.is_opened():
146189
logging.warning("Camera is not opened. Reopening...")
147190
self.open_capture(self.device, self.width, self.height, int(self.fps or 30))
148-
149191
ret, frame = self.cap.read() if self.cap else (False, None)
150192
if not ret:
151193
logging.warning("Failed to read frame from camera.")
152194
return None
153-
154195
if self.rotate_90_cw and frame is not None:
155196
frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
156197
return frame
@@ -159,6 +200,9 @@ def release(self) -> None:
159200
"""
160201
Release the camera resource.
161202
"""
203+
self._stop.set()
204+
if self._grab_thread and self._grab_thread.is_alive():
205+
self._grab_thread.join(timeout=1.0)
162206
if self.cap:
163207
self.cap.release()
164208
self.cap = None

src/om1_vlm/anonymizationSys/face_recog_stream/face_tracker.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@
3434
import time
3535
from collections import Counter
3636
from dataclasses import dataclass, field
37-
from typing import Callable, Dict, List, Optional, Tuple
37+
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
38+
39+
if TYPE_CHECKING:
40+
from .vvad_speaker import VVADScorer
3841

3942
import cv2
4043
import numpy as np
@@ -255,6 +258,8 @@ def __init__(
255258
# Set as a plain attribute (like on_unknown_sample) by run.py, wired to
256259
# the auto-merge handler. None = feature off.
257260
self.on_identity_flip: Optional[Callable[[int, str, str], None]] = None
261+
# Vision-only "who is speaking" scorer (set by run.py). Optional.
262+
self.vvad: Optional["VVADScorer"] = None
258263

259264
# Gallery (set via set_gallery). All three arrays are parallel —
260265
# row i of _gal_feats has name _gal_labels[i] and UUID _gal_uuids[i].
@@ -493,6 +498,23 @@ def update(
493498

494499
self._active_ids.add(track_id)
495500

501+
# Feed the speaking-detector buffer. Pad the (tight) SCRFD box so the
502+
# crop keeps the full mouth/chin context the VVAD model expects;
503+
# clipping the mouth squashes the speaking score. Recognition still
504+
# uses the original tight box below.
505+
if self.vvad is not None:
506+
bw, bh = x2 - x1, y2 - y1
507+
px = int(bw * 0.20)
508+
pyt = int(bh * 0.15) # a little on top
509+
pyb = int(bh * 0.30) # more on bottom (mouth/chin)
510+
cx1, cy1 = max(0, x1 - px), max(0, y1 - pyt)
511+
cx2, cy2 = min(W, x2 + px), min(H, y2 + pyb)
512+
self.vvad.push(
513+
track_id,
514+
frame[cy1:cy2, cx1:cx2],
515+
kps=track_det_map.get(track_id, {}).get("kps"),
516+
)
517+
496518
# Get or create identity state
497519
if track_id not in self._identities:
498520
self._identities[track_id] = _TrackIdentity(
@@ -558,6 +580,9 @@ def update(
558580
# Cleanup tracks that BoTSORT stopped reporting
559581
self._cleanup_stale()
560582

583+
if self.vvad is not None:
584+
self.vvad.evict(self._active_ids)
585+
561586
# Build the by-area sorted face list used by /who and unknown capture
562587
self._current_faces = self._build_current_faces(results)
563588

0 commit comments

Comments
 (0)