Oliver Nitsche Claude Sonnet 4.6 commited on
Commit
f4e577f
·
1 Parent(s): 6d06d8a

Show face snapshot in UI for greeting and enrolling states

Browse files

- face_db.py: add get_face_jpeg(frame_bgr) — detects the face, adds 40%
padding, encodes as JPEG and returns bytes (None if no face found).
- main.py: store snapshot in _shared whenever a face is detected (both
known and unknown); clear it when returning to sleep.
New GET /snapshot endpoint returns the JPEG, 204 when none is available.
- index.html: <img id="face-snapshot"> in greeting section,
<img id="enroll-snapshot"> in enroll section.
- main.js: loadSnapshot() helper (cache-busts URL, hides img on error);
called on state transition to greeting / enrolling.
- style.css: circular 160×160 portrait style for both snapshot imgs.

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

recognizer/face_db.py CHANGED
@@ -195,6 +195,24 @@ def find_match(
195
  return None
196
 
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  def add_face(
199
  name: str,
200
  frame_bgr: np.ndarray,
 
195
  return None
196
 
197
 
198
+ def get_face_jpeg(frame_bgr: np.ndarray, padding: float = 0.4) -> Optional[bytes]:
199
+ """Return a JPEG-encoded crop of the largest detected face, or None."""
200
+ boxes = _detect(frame_bgr)
201
+ if not boxes:
202
+ return None
203
+ x, y, w, h = boxes[0]
204
+ pad_x = int(w * padding)
205
+ pad_y = int(h * padding)
206
+ h_img, w_img = frame_bgr.shape[:2]
207
+ x1 = max(0, x - pad_x)
208
+ y1 = max(0, y - pad_y)
209
+ x2 = min(w_img, x + w + pad_x)
210
+ y2 = min(h_img, y + h + pad_y)
211
+ crop = frame_bgr[y1:y2, x1:x2]
212
+ ok, buf = cv2.imencode(".jpg", crop, [cv2.IMWRITE_JPEG_QUALITY, 85])
213
+ return bytes(buf) if ok else None
214
+
215
+
216
  def add_face(
217
  name: str,
218
  frame_bgr: np.ndarray,
recognizer/main.py CHANGED
@@ -13,10 +13,11 @@ import threading
13
  import time
14
  from enum import Enum, auto
15
  import numpy as np
 
16
  from pydantic import BaseModel
17
  from reachy_mini import ReachyMini, ReachyMiniApp
18
 
19
- from recognizer.face_db import NoFaceDetected, add_face, find_match, wipe as wipe_face_db
20
  from recognizer.face_db import load as load_face_db
21
  from recognizer.tts import speak
22
 
@@ -47,6 +48,7 @@ class Recognizer(ReachyMiniApp):
47
  "state": "sleeping",
48
  "pending_name": None, # set by /set_name when ENROLLING
49
  "recognized_name": None, # set when a known face is found
 
50
  }
51
 
52
  # --- Settings-app REST endpoints ---
@@ -66,6 +68,14 @@ class Recognizer(ReachyMiniApp):
66
  face_db.clear()
67
  return {"ok": True}
68
 
 
 
 
 
 
 
 
 
69
  @self.settings_app.get("/status")
70
  def get_status():
71
  with _lock:
@@ -145,10 +155,12 @@ class Recognizer(ReachyMiniApp):
145
  if frame is not None:
146
  try:
147
  name = find_match(frame, face_db)
 
148
  if name:
149
  greeting_name = name
150
  with _lock:
151
  _shared["recognized_name"] = name
 
152
  state = State.GREETING
153
  else:
154
  speak(
@@ -159,6 +171,7 @@ class Recognizer(ReachyMiniApp):
159
  enrollment_frames = [frame]
160
  with _lock:
161
  _shared["pending_name"] = None
 
162
  state = State.ENROLLING
163
  except NoFaceDetected:
164
  pass # no face in frame yet, keep scanning
@@ -180,6 +193,7 @@ class Recognizer(ReachyMiniApp):
180
  reachy_mini.goto_sleep()
181
  with _lock:
182
  _shared["recognized_name"] = None
 
183
  state = State.SLEEPING
184
 
185
  # ---------- ENROLLING ----------
@@ -209,6 +223,8 @@ class Recognizer(ReachyMiniApp):
209
  enrollment_frames.clear()
210
  speak(f"Nice to meet you, {name}!", reachy_mini)
211
  reachy_mini.goto_sleep()
 
 
212
  state = State.SLEEPING
213
 
214
  time.sleep(0.2)
 
13
  import time
14
  from enum import Enum, auto
15
  import numpy as np
16
+ from fastapi.responses import Response
17
  from pydantic import BaseModel
18
  from reachy_mini import ReachyMini, ReachyMiniApp
19
 
20
+ from recognizer.face_db import NoFaceDetected, add_face, find_match, get_face_jpeg, wipe as wipe_face_db
21
  from recognizer.face_db import load as load_face_db
22
  from recognizer.tts import speak
23
 
 
48
  "state": "sleeping",
49
  "pending_name": None, # set by /set_name when ENROLLING
50
  "recognized_name": None, # set when a known face is found
51
+ "snapshot": None, # JPEG bytes of the current face crop
52
  }
53
 
54
  # --- Settings-app REST endpoints ---
 
68
  face_db.clear()
69
  return {"ok": True}
70
 
71
+ @self.settings_app.get("/snapshot")
72
+ def snapshot():
73
+ with _lock:
74
+ data = _shared["snapshot"]
75
+ if data is None:
76
+ return Response(status_code=204)
77
+ return Response(content=data, media_type="image/jpeg")
78
+
79
  @self.settings_app.get("/status")
80
  def get_status():
81
  with _lock:
 
155
  if frame is not None:
156
  try:
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(
 
171
  enrollment_frames = [frame]
172
  with _lock:
173
  _shared["pending_name"] = None
174
+ _shared["snapshot"] = jpeg
175
  state = State.ENROLLING
176
  except NoFaceDetected:
177
  pass # no face in frame yet, keep scanning
 
193
  reachy_mini.goto_sleep()
194
  with _lock:
195
  _shared["recognized_name"] = None
196
+ _shared["snapshot"] = None
197
  state = State.SLEEPING
198
 
199
  # ---------- ENROLLING ----------
 
223
  enrollment_frames.clear()
224
  speak(f"Nice to meet you, {name}!", reachy_mini)
225
  reachy_mini.goto_sleep()
226
+ with _lock:
227
+ _shared["snapshot"] = None
228
  state = State.SLEEPING
229
 
230
  time.sleep(0.2)
recognizer/static/index.html CHANGED
@@ -16,10 +16,12 @@
16
  </div>
17
 
18
  <div id="greeting-section" style="display:none;">
 
19
  <p>Welcome back, <strong id="greeting-name"></strong>! 👋</p>
20
  </div>
21
 
22
  <div id="enroll-section" style="display:none;">
 
23
  <p>A new face was detected. Enter the person's name:</p>
24
  <div id="enroll-form">
25
  <input type="text" id="name-input" placeholder="Enter name…" autocomplete="off">
 
16
  </div>
17
 
18
  <div id="greeting-section" style="display:none;">
19
+ <img id="face-snapshot" src="" alt="Detected face">
20
  <p>Welcome back, <strong id="greeting-name"></strong>! 👋</p>
21
  </div>
22
 
23
  <div id="enroll-section" style="display:none;">
24
+ <img id="enroll-snapshot" src="" alt="Detected face">
25
  <p>A new face was detected. Enter the person's name:</p>
26
  <div id="enroll-form">
27
  <input type="text" id="name-input" placeholder="Enter name…" autocomplete="off">
recognizer/static/main.js CHANGED
@@ -8,6 +8,15 @@ const STATE_LABELS = {
8
 
9
  let currentState = "";
10
 
 
 
 
 
 
 
 
 
 
11
  async function pollStatus() {
12
  try {
13
  const resp = await fetch("/status");
@@ -20,23 +29,26 @@ async function pollStatus() {
20
  const label = document.getElementById("state-label");
21
  label.textContent = STATE_LABELS[newState] ?? newState;
22
 
23
- // Greeting section: show recognised name
24
  const greetingSection = document.getElementById("greeting-section");
25
  if (newState === "greeting" && data.recognized_name) {
26
  document.getElementById("greeting-name").textContent = data.recognized_name;
 
27
  greetingSection.style.display = "block";
28
  } else {
29
  greetingSection.style.display = "none";
30
  }
31
 
32
- // Enroll section
33
  const enrollSection = document.getElementById("enroll-section");
34
- enrollSection.style.display = newState === "enrolling" ? "block" : "none";
35
-
36
  if (newState === "enrolling") {
 
 
37
  document.getElementById("name-input").value = "";
38
  document.getElementById("enroll-status").textContent = "";
39
  document.getElementById("name-input").focus();
 
 
40
  }
41
  }
42
  } catch (e) {
 
8
 
9
  let currentState = "";
10
 
11
+ function loadSnapshot(imgId) {
12
+ const img = document.getElementById(imgId);
13
+ // Cache-bust so the browser always fetches the latest frame
14
+ img.src = "/snapshot?" + Date.now();
15
+ img.style.display = "none";
16
+ img.onload = () => { img.style.display = "block"; };
17
+ img.onerror = () => { img.style.display = "none"; };
18
+ }
19
+
20
  async function pollStatus() {
21
  try {
22
  const resp = await fetch("/status");
 
29
  const label = document.getElementById("state-label");
30
  label.textContent = STATE_LABELS[newState] ?? newState;
31
 
32
+ // Greeting section: show recognised name + face
33
  const greetingSection = document.getElementById("greeting-section");
34
  if (newState === "greeting" && data.recognized_name) {
35
  document.getElementById("greeting-name").textContent = data.recognized_name;
36
+ loadSnapshot("face-snapshot");
37
  greetingSection.style.display = "block";
38
  } else {
39
  greetingSection.style.display = "none";
40
  }
41
 
42
+ // Enroll section: show unknown face + name input
43
  const enrollSection = document.getElementById("enroll-section");
 
 
44
  if (newState === "enrolling") {
45
+ loadSnapshot("enroll-snapshot");
46
+ enrollSection.style.display = "block";
47
  document.getElementById("name-input").value = "";
48
  document.getElementById("enroll-status").textContent = "";
49
  document.getElementById("name-input").focus();
50
+ } else {
51
+ enrollSection.style.display = "none";
52
  }
53
  }
54
  } catch (e) {
recognizer/static/style.css CHANGED
@@ -28,6 +28,17 @@ h1 {
28
  color: #1a73e8;
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
 
31
  #greeting-section {
32
  background: #e8f5e9;
33
  border: 1px solid #66bb6a;
 
28
  color: #1a73e8;
29
  }
30
 
31
+ #face-snapshot,
32
+ #enroll-snapshot {
33
+ display: none;
34
+ width: 160px;
35
+ height: 160px;
36
+ object-fit: cover;
37
+ border-radius: 50%;
38
+ border: 3px solid currentColor;
39
+ margin-bottom: 0.75rem;
40
+ }
41
+
42
  #greeting-section {
43
  background: #e8f5e9;
44
  border: 1px solid #66bb6a;