Skip to content

Commit 55b2aa6

Browse files
committed
feat: ROI-crop face detection to cut worker CPU
1 parent 252ac94 commit 55b2aa6

2 files changed

Lines changed: 69 additions & 3 deletions

File tree

src/reachy_mini/vision/face_tracking.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020

2121
_logger = logging.getLogger("reachymini-face-tracker")
2222

23+
# Full-frame detect cadence while locked, so a moved or new face is re-found within ~1 s.
24+
_FULL_SCAN_INTERVAL = 12
25+
2326

2427
@dataclass
2528
class FaceObservation:
@@ -48,6 +51,18 @@ def _dist2(a: tuple[float, float], b: tuple[float, float]) -> float:
4851
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
4952

5053

54+
def _offset_faces(faces: "list[Face]", dx: int, dy: int) -> "list[Face]":
55+
"""Shift faces detected in a crop back to full-frame pixel coordinates."""
56+
return [
57+
type(f)(
58+
bbox=(f.bbox[0] + dx, f.bbox[1] + dy, f.bbox[2], f.bbox[3]),
59+
right_eye=(f.right_eye[0] + dx, f.right_eye[1] + dy),
60+
left_eye=(f.left_eye[0] + dx, f.left_eye[1] + dy),
61+
)
62+
for f in faces
63+
]
64+
65+
5166
class Tracker:
5267
"""Greedy single-face track that rejects spurious detections.
5368
@@ -62,11 +77,13 @@ def __init__(
6277
min_area_frac: float = 0.003,
6378
max_jump: float = 0.5,
6479
max_misses: int = 20,
80+
crop: int = 256,
6581
) -> None:
66-
"""Create a tracker with the given acquisition and association gates."""
82+
"""Create a tracker with the given gates and ROI crop size (pixels)."""
6783
self._min_area_frac = min_area_frac
6884
self._max_jump = max_jump
6985
self._max_misses = max_misses
86+
self._crop = crop
7087
self._center: tuple[float, float] | None = None
7188
self._misses = 0
7289

@@ -92,6 +109,20 @@ def select(self, faces: "list[Face]", width: int, height: int) -> "Face | None":
92109
self._misses = 0
93110
return face
94111

112+
def roi(self, width: int, height: int) -> tuple[int, int, int, int] | None:
113+
"""Crop box around the current track for ROI detection, or None when unlocked."""
114+
if self._center is None:
115+
return None
116+
cx = round((self._center[0] + 1.0) * 0.5 * max(width - 1, 1))
117+
cy = round((self._center[1] + 1.0) * 0.5 * max(height - 1, 1))
118+
half = self._crop // 2
119+
return (
120+
max(0, cx - half),
121+
max(0, cy - half),
122+
min(width, cx + half),
123+
min(height, cy + half),
124+
)
125+
95126
def _miss(self) -> None:
96127
self._misses += 1
97128
if self._misses > self._max_misses:
@@ -169,6 +200,7 @@ def run(conn: Connection, stop: EventType, camera_specs: "CameraSpecs") -> None:
169200

170201
crop_scale = camera_specs.default_resolution.value[3]
171202
camera_matrix: NDArray[np.float64] | None = None
203+
since_full_scan = 0
172204
try:
173205
while not stop.is_set():
174206
sample = appsink.emit("try-pull-sample", 200_000_000)
@@ -185,7 +217,16 @@ def run(conn: Connection, stop: EventType, camera_specs: "CameraSpecs") -> None:
185217
camera_matrix = intrinsics_for_size(
186218
camera_specs.K, crop_scale, (width, height)
187219
)
188-
face = tracker.select(detector.detect(frame), width, height)
220+
roi = tracker.roi(width, height)
221+
if roi is None or since_full_scan >= _FULL_SCAN_INTERVAL:
222+
faces = detector.detect(frame)
223+
since_full_scan = 0
224+
else:
225+
x0, y0, x1, y1 = roi
226+
window = np.ascontiguousarray(frame[y0:y1, x0:x1])
227+
faces = _offset_faces(detector.detect(window), x0, y0)
228+
since_full_scan += 1
229+
face = tracker.select(faces, width, height)
189230
obs = to_observation(
190231
face,
191232
width,

tests/unit_tests/test_face_tracking.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import numpy as np
66

7-
from reachy_mini.vision.face_tracking import Tracker, to_observation
7+
from reachy_mini.vision.face_tracking import Tracker, _offset_faces, to_observation
88

99

1010
def _face(
@@ -71,3 +71,28 @@ def test_tracker_drops_track_after_misses_then_reacquires() -> None:
7171
assert tracker.select([far], 200, 200) is None # miss 1, track held
7272
assert tracker.select([far], 200, 200) is None # miss 2, track dropped
7373
assert tracker.select([far], 200, 200) is far # re-acquired
74+
75+
76+
def test_tracker_roi_is_none_until_locked_then_crops_around_track() -> None:
77+
"""ROI is full-frame (None) until a lock, then a crop box centered on the track."""
78+
tracker = Tracker(min_area_frac=0.0, crop=100)
79+
assert tracker.roi(200, 200) is None
80+
81+
tracker.select(
82+
[_face((90.0, 88.0, 20.0, 20.0), (95.0, 95.0), (105.0, 95.0))], 200, 200
83+
)
84+
roi = tracker.roi(200, 200)
85+
assert roi is not None
86+
x0, y0, x1, y1 = roi
87+
assert (x1 - x0, y1 - y0) == (100, 100)
88+
assert x0 <= 100 <= x1 and y0 <= 95 <= y1 # contains the eye center
89+
90+
91+
def test_offset_faces_maps_crop_coords_to_full_frame() -> None:
92+
"""A face found in a crop is shifted by the crop origin; its size is unchanged."""
93+
shifted = _offset_faces(
94+
[_face((10.0, 20.0, 30.0, 40.0), (15.0, 25.0), (35.0, 25.0))], 100, 200
95+
)
96+
assert shifted[0].bbox == (110.0, 220.0, 30.0, 40.0)
97+
assert shifted[0].right_eye == (115.0, 225.0)
98+
assert shifted[0].left_eye == (135.0, 225.0)

0 commit comments

Comments
 (0)