Spaces:
Running
Running
Oliver Nitsche Claude Sonnet 4.6 commited on
Commit ·
77c6ffa
1
Parent(s): 715f41e
Replace face-recognition/dlib with AWS Rekognition
Browse filesEliminates the ~15-minute dlib compilation on Raspberry Pi. boto3 and
Pillow (pure Python, pre-built wheels) replace the face-recognition
package. Face embeddings are now stored in an AWS Rekognition collection
instead of a local face_db.json file; existing enrolled faces will need
to be re-registered.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CLAUDE.md +17 -2
- pyproject.toml +2 -1
- recognizer/face_db.py +72 -38
- recognizer/main.py +27 -38
CLAUDE.md
CHANGED
|
@@ -20,9 +20,24 @@ pip install -e .
|
|
| 20 |
|
| 21 |
```bash
|
| 22 |
sudo apt-get install espeak-ng # text-to-speech synthesis
|
| 23 |
-
pip install face-recognition # compiles dlib from source (~15 min on Pi)
|
| 24 |
```
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
## Running the App
|
| 27 |
|
| 28 |
Run directly (connects to a live Reachy Mini robot):
|
|
@@ -72,7 +87,7 @@ SLEEPING →(speech detected × 3)→ WAKING → ACTIVE → SLEEPING
|
|
| 72 |
- **ENROLLING**: robot has detected an unrecognised face; waits for name to be submitted via the web UI (`POST /set_name`). Stores encoding in `face_db.json`, says "Nice to meet you, <name>!", then sleeps.
|
| 73 |
|
| 74 |
**Helper modules**:
|
| 75 |
-
- `recognizer/face_db.py` — load/
|
| 76 |
- `recognizer/tts.py` — synthesises text via `espeak-ng -s 140 -w <tmp.wav>`, plays via `media.play_sound()`, then sleeps to let playback finish.
|
| 77 |
|
| 78 |
**Settings UI** (`recognizer/static/`):
|
|
|
|
| 20 |
|
| 21 |
```bash
|
| 22 |
sudo apt-get install espeak-ng # text-to-speech synthesis
|
|
|
|
| 23 |
```
|
| 24 |
|
| 25 |
+
### AWS Rekognition credentials
|
| 26 |
+
|
| 27 |
+
Face recognition is handled by AWS Rekognition (no local compilation required).
|
| 28 |
+
Set credentials on the robot before running:
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
export AWS_ACCESS_KEY_ID=...
|
| 32 |
+
export AWS_SECRET_ACCESS_KEY=...
|
| 33 |
+
export AWS_DEFAULT_REGION=us-east-1 # or your preferred region
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
Or use `aws configure` if the AWS CLI is installed. The app auto-creates a
|
| 37 |
+
Rekognition collection named `reachy-mini-recognizer` on first run.
|
| 38 |
+
The IAM user/role needs: `rekognition:CreateCollection`,
|
| 39 |
+
`rekognition:IndexFaces`, `rekognition:SearchFacesByImage`.
|
| 40 |
+
|
| 41 |
## Running the App
|
| 42 |
|
| 43 |
Run directly (connects to a live Reachy Mini robot):
|
|
|
|
| 87 |
- **ENROLLING**: robot has detected an unrecognised face; waits for name to be submitted via the web UI (`POST /set_name`). Stores encoding in `face_db.json`, says "Nice to meet you, <name>!", then sleeps.
|
| 88 |
|
| 89 |
**Helper modules**:
|
| 90 |
+
- `recognizer/face_db.py` — AWS Rekognition wrapper. `load()` creates/opens the collection and returns its ID. `find_match(frame_bgr, collection_id)` returns the name or None (raises `NoFaceDetected` if no face present). `add_face(name, frame_bgr, collection_id)` enrolls a face. Similarity threshold = 85 (0–100 scale).
|
| 91 |
- `recognizer/tts.py` — synthesises text via `espeak-ng -s 140 -w <tmp.wav>`, plays via `media.play_sound()`, then sleeps to let playback finish.
|
| 92 |
|
| 93 |
**Settings UI** (`recognizer/static/`):
|
pyproject.toml
CHANGED
|
@@ -11,7 +11,8 @@ readme = "README.md"
|
|
| 11 |
requires-python = ">=3.10"
|
| 12 |
dependencies = [
|
| 13 |
"reachy-mini",
|
| 14 |
-
"
|
|
|
|
| 15 |
"scipy",
|
| 16 |
]
|
| 17 |
keywords = ["reachy-mini-app", "reachy-mini"]
|
|
|
|
| 11 |
requires-python = ">=3.10"
|
| 12 |
dependencies = [
|
| 13 |
"reachy-mini",
|
| 14 |
+
"boto3",
|
| 15 |
+
"Pillow",
|
| 16 |
"scipy",
|
| 17 |
]
|
| 18 |
keywords = ["reachy-mini-app", "reachy-mini"]
|
recognizer/face_db.py
CHANGED
|
@@ -1,52 +1,86 @@
|
|
| 1 |
-
"""Face database:
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from typing import Optional
|
| 6 |
|
| 7 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
except ImportError as exc:
|
| 12 |
-
raise ImportError(
|
| 13 |
-
"face-recognition is required: pip install face-recognition"
|
| 14 |
-
) from exc
|
| 15 |
|
| 16 |
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
-
def
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
-
def
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def find_match(
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
) -> Optional[str]:
|
| 35 |
-
for
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
)
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Face database: backed by AWS Rekognition.
|
| 2 |
|
| 3 |
+
Requires boto3 and AWS credentials configured (e.g. via environment variables
|
| 4 |
+
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, or an IAM role).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import io
|
| 8 |
+
import logging
|
| 9 |
from typing import Optional
|
| 10 |
|
| 11 |
+
import boto3
|
| 12 |
+
from botocore.exceptions import BotoCoreError, ClientError
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
COLLECTION_ID = "reachy-mini-recognizer"
|
| 18 |
+
|
| 19 |
|
| 20 |
+
class NoFaceDetected(Exception):
|
| 21 |
+
"""Raised when no face is found in the provided image."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
+
def _client():
|
| 25 |
+
return boto3.client("rekognition")
|
| 26 |
|
| 27 |
|
| 28 |
+
def _to_jpeg(frame_bgr) -> bytes:
|
| 29 |
+
rgb = frame_bgr[:, :, ::-1]
|
| 30 |
+
buf = io.BytesIO()
|
| 31 |
+
Image.fromarray(rgb).save(buf, format="JPEG")
|
| 32 |
+
return buf.getvalue()
|
| 33 |
|
| 34 |
|
| 35 |
+
def load() -> str:
|
| 36 |
+
"""Ensure the Rekognition collection exists; return its ID."""
|
| 37 |
+
client = _client()
|
| 38 |
+
try:
|
| 39 |
+
client.create_collection(CollectionId=COLLECTION_ID)
|
| 40 |
+
logger.info("Created Rekognition collection '%s'", COLLECTION_ID)
|
| 41 |
+
except client.exceptions.ResourceAlreadyExistsException:
|
| 42 |
+
pass
|
| 43 |
+
return COLLECTION_ID
|
| 44 |
|
| 45 |
|
| 46 |
def find_match(
|
| 47 |
+
frame_bgr,
|
| 48 |
+
collection_id: str,
|
| 49 |
+
threshold: float = 85.0,
|
| 50 |
) -> Optional[str]:
|
| 51 |
+
"""Search for a face in frame_bgr against the collection.
|
| 52 |
+
|
| 53 |
+
Returns the matched name if recognised, None if a face is present but
|
| 54 |
+
unknown. Raises NoFaceDetected if no face appears in the image at all.
|
| 55 |
+
"""
|
| 56 |
+
client = _client()
|
| 57 |
+
try:
|
| 58 |
+
resp = client.search_faces_by_image(
|
| 59 |
+
CollectionId=collection_id,
|
| 60 |
+
Image={"Bytes": _to_jpeg(frame_bgr)},
|
| 61 |
+
FaceMatchThreshold=threshold,
|
| 62 |
+
MaxFaces=1,
|
| 63 |
+
)
|
| 64 |
+
matches = resp.get("FaceMatches", [])
|
| 65 |
+
if matches:
|
| 66 |
+
return matches[0]["Face"]["ExternalImageId"]
|
| 67 |
+
return None # face detected but not in collection
|
| 68 |
+
except client.exceptions.InvalidParameterException:
|
| 69 |
+
raise NoFaceDetected()
|
| 70 |
+
except (BotoCoreError, ClientError) as exc:
|
| 71 |
+
logger.warning("Rekognition error: %s", exc)
|
| 72 |
+
raise NoFaceDetected()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def add_face(name: str, frame_bgr, collection_id: str) -> None:
|
| 76 |
+
"""Index the face in frame_bgr under name in the collection."""
|
| 77 |
+
client = _client()
|
| 78 |
+
resp = client.index_faces(
|
| 79 |
+
CollectionId=collection_id,
|
| 80 |
+
Image={"Bytes": _to_jpeg(frame_bgr)},
|
| 81 |
+
ExternalImageId=name,
|
| 82 |
+
MaxFaces=1,
|
| 83 |
+
DetectionAttributes=[],
|
| 84 |
+
)
|
| 85 |
+
if not resp.get("FaceRecords"):
|
| 86 |
+
raise ValueError("No face detected in enrollment image")
|
recognizer/main.py
CHANGED
|
@@ -18,17 +18,10 @@ import numpy as np
|
|
| 18 |
from pydantic import BaseModel
|
| 19 |
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 20 |
|
| 21 |
-
from recognizer.face_db import add_face, find_match
|
| 22 |
from recognizer.face_db import load as load_face_db
|
| 23 |
from recognizer.tts import speak
|
| 24 |
|
| 25 |
-
try:
|
| 26 |
-
import face_recognition # noqa: F401 – checked at import time
|
| 27 |
-
except ImportError as exc:
|
| 28 |
-
raise ImportError(
|
| 29 |
-
"face-recognition is required: pip install face-recognition"
|
| 30 |
-
) from exc
|
| 31 |
-
|
| 32 |
logger = logging.getLogger(__name__)
|
| 33 |
|
| 34 |
ACTIVE_TIMEOUT = 15.0 # seconds before returning to sleep with no face
|
|
@@ -49,8 +42,6 @@ class Recognizer(ReachyMiniApp):
|
|
| 49 |
request_media_backend: str | None = None
|
| 50 |
|
| 51 |
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
|
| 52 |
-
import face_recognition as fr
|
| 53 |
-
|
| 54 |
# --- Shared mutable state (main loop ↔ FastAPI handlers) ---
|
| 55 |
_lock = threading.Lock()
|
| 56 |
_shared: dict = {
|
|
@@ -75,13 +66,13 @@ class Recognizer(ReachyMiniApp):
|
|
| 75 |
return {"state": _shared["state"]}
|
| 76 |
|
| 77 |
# --- Initialise ---
|
| 78 |
-
|
| 79 |
state = State.SLEEPING
|
| 80 |
doa_angle = math.pi / 2 # default: facing front
|
| 81 |
speech_count = 0
|
| 82 |
active_start = 0.0
|
| 83 |
last_face_check = 0.0
|
| 84 |
-
|
| 85 |
scan_t0 = 0.0 # reference time for head-scan idle animation
|
| 86 |
|
| 87 |
reachy_mini.goto_sleep()
|
|
@@ -138,34 +129,29 @@ class Recognizer(ReachyMiniApp):
|
|
| 138 |
head=_look_direction(1.0, y_scan, 0.0)
|
| 139 |
)
|
| 140 |
|
| 141 |
-
# Throttled face recognition
|
| 142 |
if now - last_face_check >= FACE_INTERVAL:
|
| 143 |
last_face_check = now
|
| 144 |
frame = reachy_mini.media.get_frame()
|
| 145 |
if frame is not None:
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
)
|
| 165 |
-
pending_enc = enc
|
| 166 |
-
with _lock:
|
| 167 |
-
_shared["pending_name"] = None
|
| 168 |
-
state = State.ENROLLING
|
| 169 |
|
| 170 |
# Timeout: nobody showed up
|
| 171 |
if state == State.ACTIVE and time.time() - active_start > ACTIVE_TIMEOUT:
|
|
@@ -183,8 +169,11 @@ class Recognizer(ReachyMiniApp):
|
|
| 183 |
if name:
|
| 184 |
with _lock:
|
| 185 |
_shared["pending_name"] = None
|
| 186 |
-
if
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
| 188 |
speak(f"Nice to meet you, {name}!", reachy_mini)
|
| 189 |
reachy_mini.goto_sleep()
|
| 190 |
state = State.SLEEPING
|
|
|
|
| 18 |
from pydantic import BaseModel
|
| 19 |
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 20 |
|
| 21 |
+
from recognizer.face_db import NoFaceDetected, add_face, find_match
|
| 22 |
from recognizer.face_db import load as load_face_db
|
| 23 |
from recognizer.tts import speak
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
| 27 |
ACTIVE_TIMEOUT = 15.0 # seconds before returning to sleep with no face
|
|
|
|
| 42 |
request_media_backend: str | None = None
|
| 43 |
|
| 44 |
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
|
|
|
|
|
|
|
| 45 |
# --- Shared mutable state (main loop ↔ FastAPI handlers) ---
|
| 46 |
_lock = threading.Lock()
|
| 47 |
_shared: dict = {
|
|
|
|
| 66 |
return {"state": _shared["state"]}
|
| 67 |
|
| 68 |
# --- Initialise ---
|
| 69 |
+
collection_id = load_face_db()
|
| 70 |
state = State.SLEEPING
|
| 71 |
doa_angle = math.pi / 2 # default: facing front
|
| 72 |
speech_count = 0
|
| 73 |
active_start = 0.0
|
| 74 |
last_face_check = 0.0
|
| 75 |
+
pending_frame: Optional[np.ndarray] = None
|
| 76 |
scan_t0 = 0.0 # reference time for head-scan idle animation
|
| 77 |
|
| 78 |
reachy_mini.goto_sleep()
|
|
|
|
| 129 |
head=_look_direction(1.0, y_scan, 0.0)
|
| 130 |
)
|
| 131 |
|
| 132 |
+
# Throttled face recognition via AWS Rekognition
|
| 133 |
if now - last_face_check >= FACE_INTERVAL:
|
| 134 |
last_face_check = now
|
| 135 |
frame = reachy_mini.media.get_frame()
|
| 136 |
if frame is not None:
|
| 137 |
+
try:
|
| 138 |
+
name = find_match(frame, collection_id)
|
| 139 |
+
if name:
|
| 140 |
+
speak(f"Hi {name}!", reachy_mini)
|
| 141 |
+
reachy_mini.goto_sleep()
|
| 142 |
+
state = State.SLEEPING
|
| 143 |
+
else:
|
| 144 |
+
speak(
|
| 145 |
+
"I don't know you yet. "
|
| 146 |
+
"Please enter your name on the control panel.",
|
| 147 |
+
reachy_mini,
|
| 148 |
+
)
|
| 149 |
+
pending_frame = frame
|
| 150 |
+
with _lock:
|
| 151 |
+
_shared["pending_name"] = None
|
| 152 |
+
state = State.ENROLLING
|
| 153 |
+
except NoFaceDetected:
|
| 154 |
+
pass # no face in frame yet, keep scanning
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
# Timeout: nobody showed up
|
| 157 |
if state == State.ACTIVE and time.time() - active_start > ACTIVE_TIMEOUT:
|
|
|
|
| 169 |
if name:
|
| 170 |
with _lock:
|
| 171 |
_shared["pending_name"] = None
|
| 172 |
+
if pending_frame is not None:
|
| 173 |
+
try:
|
| 174 |
+
add_face(name, pending_frame, collection_id)
|
| 175 |
+
except ValueError as exc:
|
| 176 |
+
logger.warning("Enrollment failed: %s", exc)
|
| 177 |
speak(f"Nice to meet you, {name}!", reachy_mini)
|
| 178 |
reachy_mini.goto_sleep()
|
| 179 |
state = State.SLEEPING
|