Spaces:
Running
Running
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 +18 -0
- recognizer/main.py +17 -1
- recognizer/static/index.html +2 -0
- recognizer/static/main.js +16 -4
- recognizer/static/style.css +11 -0
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;
|