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
2528class 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+
5166class 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 ,
0 commit comments