Oliver Nitsche Claude Sonnet 4.6 commited on
Commit
77c6ffa
·
1 Parent(s): 715f41e

Replace face-recognition/dlib with AWS Rekognition

Browse files

Eliminates 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>

Files changed (4) hide show
  1. CLAUDE.md +17 -2
  2. pyproject.toml +2 -1
  3. recognizer/face_db.py +72 -38
  4. 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/save/query face encodings. Database at `recognizer/face_db.json` (gitignored). `find_match()` tolerance = 0.55.
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
- "face-recognition",
 
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: persist face encodings keyed by name."""
2
 
3
- import json
4
- from pathlib import Path
 
 
 
 
5
  from typing import Optional
6
 
7
- import numpy as np
 
 
 
 
 
 
 
8
 
9
- try:
10
- import face_recognition
11
- except ImportError as exc:
12
- raise ImportError(
13
- "face-recognition is required: pip install face-recognition"
14
- ) from exc
15
 
16
 
17
- DB_PATH = Path(__file__).parent / "face_db.json"
 
18
 
19
 
20
- def load() -> dict[str, list[list[float]]]:
21
- if DB_PATH.exists():
22
- return json.loads(DB_PATH.read_text())
23
- return {}
 
24
 
25
 
26
- def save(db: dict[str, list[list[float]]]) -> None:
27
- DB_PATH.write_text(json.dumps(db, indent=2))
 
 
 
 
 
 
 
28
 
29
 
30
  def find_match(
31
- encoding: np.ndarray,
32
- db: dict[str, list[list[float]]],
33
- tolerance: float = 0.55,
34
  ) -> Optional[str]:
35
- for name, enc_list in db.items():
36
- known = [np.array(e) for e in enc_list]
37
- if any(face_recognition.compare_faces(known, encoding, tolerance=tolerance)):
38
- return name
39
- return None
40
-
41
-
42
- def add_face(
43
- name: str,
44
- encoding: np.ndarray,
45
- db: dict[str, list[list[float]]],
46
- max_per_person: int = 5,
47
- ) -> None:
48
- if name not in db:
49
- db[name] = []
50
- if len(db[name]) < max_per_person:
51
- db[name].append(encoding.tolist())
52
- save(db)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- face_db = load_face_db()
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
- pending_enc: Optional[np.ndarray] = None
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
- rgb = frame[::2, ::2, ::-1] # 2× downsample + BGR→RGB
147
- locs = fr.face_locations(rgb, model="hog")
148
- if locs:
149
- # Scale locations back to full-res for accurate encoding
150
- full_locs = [(t*2, r*2, b*2, l*2) for t, r, b, l in locs]
151
- encs = fr.face_encodings(frame[:, :, ::-1], full_locs)
152
- if encs:
153
- enc = encs[0]
154
- name = find_match(enc, face_db)
155
- if name:
156
- speak(f"Hi {name}!", reachy_mini)
157
- reachy_mini.goto_sleep()
158
- state = State.SLEEPING
159
- else:
160
- speak(
161
- "I don't know you yet. "
162
- "Please enter your name on the control panel.",
163
- reachy_mini,
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 pending_enc is not None:
187
- add_face(name, pending_enc, face_db)
 
 
 
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