Oliver Nitsche Claude Sonnet 4.6 commited on
Commit
8dc6771
·
1 Parent(s): 83afe82

Tighten recognition: raise threshold to 0.50, add 2-hit debounce

Browse files

Two fixes for "always recognises the same person":

1. Threshold 0.35 → 0.50
MobileFaceNet embeddings for different people typically score 0.20–0.45;
the same person scores 0.55–0.85. The old threshold of 0.35 sits right
in the overlap zone, so random faces exceeded it. 0.50 sits safely
above the inter-person range while remaining reachable for the same
person.

2. 2-hit recognition debounce (RECOGNITION_DEBOUNCE = 2)
The same name must appear in two consecutive 0.5-second frames before
GREETING is triggered. A single ambiguous frame (slight pose change,
partial occlusion) can no longer cause a false positive. The counter
resets on every mismatch, NoFaceDetected, wake-up, or state change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. recognizer/face_db.py +1 -1
  2. recognizer/main.py +32 -11
recognizer/face_db.py CHANGED
@@ -214,7 +214,7 @@ def wipe() -> None:
214
  def find_match(
215
  frame_bgr: np.ndarray,
216
  db: dict[str, list[list[float]]],
217
- threshold: float = 0.35,
218
  ) -> Optional[str]:
219
  """Return matched name if recognised, None if face present but unknown.
220
 
 
214
  def find_match(
215
  frame_bgr: np.ndarray,
216
  db: dict[str, list[list[float]]],
217
+ threshold: float = 0.50,
218
  ) -> Optional[str]:
219
  """Return matched name if recognised, None if face present but unknown.
220
 
recognizer/main.py CHANGED
@@ -23,10 +23,11 @@ from recognizer.tts import speak
23
 
24
  logger = logging.getLogger(__name__)
25
 
26
- ACTIVE_TIMEOUT = 15.0 # seconds before returning to sleep with no face
27
- DOA_DEBOUNCE = 3 # consecutive speech-detected readings to wake up
28
- FACE_INTERVAL = 0.5 # seconds between face-recognition attempts
29
- SCAN_AMPLITUDE = 0.35 # head scan amplitude (world-y, metres) during ACTIVE
 
30
 
31
 
32
  class State(Enum):
@@ -93,7 +94,9 @@ class Recognizer(ReachyMiniApp):
93
  last_face_check = 0.0
94
  enrollment_frames: list[np.ndarray] = []
95
  greeting_name = ""
96
- scan_t0 = 0.0 # reference time for head-scan idle animation
 
 
97
 
98
  reachy_mini.goto_sleep()
99
 
@@ -134,6 +137,8 @@ class Recognizer(ReachyMiniApp):
134
  scan_t0 = active_start
135
  last_face_check = 0.0
136
  enrollment_frames.clear()
 
 
137
  state = State.ACTIVE
138
 
139
  # ---------- ACTIVE ----------
@@ -157,12 +162,27 @@ class Recognizer(ReachyMiniApp):
157
  name = find_match(frame, face_db)
158
  jpeg = get_face_jpeg(frame)
159
  if name:
160
- greeting_name = name
161
- with _lock:
162
- _shared["recognized_name"] = name
163
- _shared["snapshot"] = jpeg
164
- state = State.GREETING
 
 
 
 
 
 
 
 
 
 
 
 
165
  else:
 
 
 
166
  speak(
167
  "I don't know you yet. "
168
  "Please enter your name on the control panel.",
@@ -174,7 +194,8 @@ class Recognizer(ReachyMiniApp):
174
  _shared["snapshot"] = jpeg
175
  state = State.ENROLLING
176
  except NoFaceDetected:
177
- pass # no face in frame yet, keep scanning
 
178
 
179
  # Timeout: nobody showed up
180
  if state == State.ACTIVE and time.time() - active_start > ACTIVE_TIMEOUT:
 
23
 
24
  logger = logging.getLogger(__name__)
25
 
26
+ ACTIVE_TIMEOUT = 15.0 # seconds before returning to sleep with no face
27
+ DOA_DEBOUNCE = 3 # consecutive speech-detected readings to wake up
28
+ FACE_INTERVAL = 0.5 # seconds between face-recognition attempts
29
+ SCAN_AMPLITUDE = 0.35 # head scan amplitude (world-y, metres) during ACTIVE
30
+ RECOGNITION_DEBOUNCE = 2 # consecutive same-name hits required to confirm identity
31
 
32
 
33
  class State(Enum):
 
94
  last_face_check = 0.0
95
  enrollment_frames: list[np.ndarray] = []
96
  greeting_name = ""
97
+ rec_candidate = "" # name accumulating consecutive hits
98
+ rec_hits = 0 # consecutive hit count for rec_candidate
99
+ scan_t0 = 0.0 # reference time for head-scan idle animation
100
 
101
  reachy_mini.goto_sleep()
102
 
 
137
  scan_t0 = active_start
138
  last_face_check = 0.0
139
  enrollment_frames.clear()
140
+ rec_candidate = ""
141
+ rec_hits = 0
142
  state = State.ACTIVE
143
 
144
  # ---------- ACTIVE ----------
 
162
  name = find_match(frame, face_db)
163
  jpeg = get_face_jpeg(frame)
164
  if name:
165
+ # Debounce: require RECOGNITION_DEBOUNCE consecutive
166
+ # hits of the same name before confirming.
167
+ if name == rec_candidate:
168
+ rec_hits += 1
169
+ else:
170
+ rec_candidate = name
171
+ rec_hits = 1
172
+ logger.debug("Recognition hit %d/%d: %s",
173
+ rec_hits, RECOGNITION_DEBOUNCE, name)
174
+ if rec_hits >= RECOGNITION_DEBOUNCE:
175
+ rec_candidate = ""
176
+ rec_hits = 0
177
+ greeting_name = name
178
+ with _lock:
179
+ _shared["recognized_name"] = name
180
+ _shared["snapshot"] = jpeg
181
+ state = State.GREETING
182
  else:
183
+ # Unknown face — reset debounce and enroll
184
+ rec_candidate = ""
185
+ rec_hits = 0
186
  speak(
187
  "I don't know you yet. "
188
  "Please enter your name on the control panel.",
 
194
  _shared["snapshot"] = jpeg
195
  state = State.ENROLLING
196
  except NoFaceDetected:
197
+ rec_candidate = ""
198
+ rec_hits = 0
199
 
200
  # Timeout: nobody showed up
201
  if state == State.ACTIVE and time.time() - active_start > ACTIVE_TIMEOUT: