Lesson 9: Teaching BonicBot to Recognize Faces
Learning Objective
Teach BonicBot to recognize people by comparing a newly detected face with the face embeddings stored in the database created in the previous lesson.
Introduction
In the previous lesson, BonicBot learned how to remember faces by converting every face image into a face embedding and storing those embeddings in a database.
Now it’s time to use that database.
Whenever BonicBot sees a face, it will:
- Detect the face.
- Generate a new face embedding.
- Compare that embedding with every embedding stored in the database.
- Find the most similar face.
- Announce the person’s name if a match is found.
This process is called face recognition.
Instead of comparing photographs pixel by pixel, BonicBot compares the embeddings produced by the ArcFace model. If two embeddings are very similar, they most likely belong to the same person.
To make the robot more reliable, it only announces a person’s name after recognizing them consistently for several consecutive frames. This prevents repeated announcements caused by temporary recognition errors.
Setup: Installing Packages
Before running the code, make sure your computer has the required Python packages installed. Open a terminal and run:
pip install bonicbot-bridge opencv-python numpy pyttsx3 deepfaceWhat each package does
| Package | Purpose |
|---|---|
bonicbot-bridge | The official BonicBot SDK. Provides the BonicBot class and BonicBotError exception used to connect via with BonicBot(host=HOST, port=9090, timeout=10) as bot:, plus the camera lifecycle calls bot.system.start_camera(), bot.start_camera(), bot.camera.wait_for_image(), bot.camera.get_latest_image(), and bot.system.stop_camera(). |
opencv-python | Imported as cv2 (via the ensure("cv2", "opencv-python") auto-installer). Used to resize frames for faster recognition (cv2.resize), draw bounding boxes and labels (cv2.rectangle, cv2.putText), display the live feed (cv2.imshow), and read the quit key (cv2.waitKey). |
numpy | Imported as np. Used throughout recognize_faces() to convert embeddings to arrays, normalize them with np.linalg.norm, stack the known database into known_matrix with np.vstack, and compute similarity scores via np.dot / np.argmax. |
pyttsx3 | Text-to-speech library used by VoiceSpeaker to announce recognized (or unknown) people. Optional — the script checks for it with a try/except ImportError and sets _TTS_AVAILABLE; if it isn’t installed, recognition and tracking still work, just without spoken announcements. |
deepface | Provides the DeepFace class used in pick_detector_backend() and recognize_faces() to load the ArcFace model (DeepFace.build_model("ArcFace")) and generate embeddings for each detected face (DeepFace.represent(...)). |
This lesson also relies on face_database.pkl, the file produced by Lesson 8. Make sure that file exists in the same directory before running this script — the program will exit immediately if it isn’t found.
If you already installed bonicbot-bridge, opencv-python, numpy, pyttsx3, and deepface in earlier lessons, you don’t need to reinstall anything for this lesson.
If pip install fails, try pip3 install ... instead, or use a virtual environment:
python3 -m venv bonicbot-env
source bonicbot-env/bin/activate # On Windows: bonicbot-env\Scripts\activate
pip install bonicbot-bridge opencv-python numpy pyttsx3 deepfaceHow to run the program
- Find your BonicBot’s IP address — via the robot’s on-device display, its companion app, or your router’s connected-devices list.
- Make sure
face_database.pklfrom Lesson 8 is in the same folder as this script — the program loads it on startup and will exit with an error if it’s missing. - Save the code below into a file, e.g.
lesson9_face_recognition.py. - Note the connection line. This script sets
HOST = "localhost"and connects withwith BonicBot(host=HOST, port=9090, timeout=10) as bot:.- If you’re running this against the ROS 2 simulation, you can leave
HOSTas"localhost"and run it as-is. - If you’re connecting to a real BonicBot, change
HOST = "localhost"to your robot’s actual IP address, e.g.HOST = "172.20.10.2".
- If you’re running this against the ROS 2 simulation, you can leave
- Make sure your BonicBot (real or simulated) is powered on and network-connected, and that at least one person whose face is in
face_database.pklis standing clearly in view of the camera. - (Optional) Connect a Bluetooth speaker to hear the spoken announcements. Recognition still works without one.
- Run the script:
python lesson9_face_recognition.py- A window titled “Face Recognition Feed” should open, showing the live feed with a green bounding box and name/confidence label drawn around each detected face.
- Press
qwith the video window focused to stop the stream and exit cleanly.
Don’t have a physical BonicBot? Try it in simulation (optional)
This lesson’s code already defaults to HOST = "localhost", so it’s already set up to run directly against the ROS 2 simulation environment without any code changes:
- Launch the BonicBot simulation:
ros2 launch my_bot robot_system.launch.py use_sim_time:=true world:=obsworld.sdf use_real_camera:=Trueuse_real_camera:=True— the camera feed comes from your laptop’s webcam, while the robot’s body and motion are still simulated. This is the easiest way to test recognition, since you can just show your own face (if it’s inface_database.pkl) to your laptop camera.use_real_camera:=False— the camera feed comes from Gazebo instead (i.e. whatever the simulated camera sees inside the simulated world).
- With the simulation running, simply run the script as written — since
HOSTis already"localhost", no code changes are needed. - Everything else in the code — face detection, embedding comparison, stability tracking, and voice announcements — works the same way, since the simulation exposes the same interface as a real robot.
This path is mainly useful for exploring the lesson without hardware on hand; if you have a real BonicBot, changing HOST to its actual IP address is still the recommended way to go through this lesson on real hardware.
Code
Click to view the complete program
import os, sys, subprocess, importlib, time, pickle, queue, threading
import numpy as np
from bonicbot_bridge import BonicBot, BonicBotError
# ---------------- Auto-install dependencies ----------------
def pip_install(pkg):
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
def ensure(module, pkg=None):
try:
return importlib.import_module(module)
except ImportError:
print(f"📦 Installing '{pkg or module}'...")
pip_install(pkg or module)
return importlib.import_module(module)
cv2 = ensure("cv2", "opencv-python")
ensure("deepface")
try:
import pyttsx3
_TTS_AVAILABLE = True
except ImportError:
_TTS_AVAILABLE = False
from deepface import DeepFace
# ---------------- Detector backend (handles OpenCV 5.x missing CascadeClassifier) ----------------
def pick_detector_backend():
if hasattr(cv2, "CascadeClassifier"):
return "opencv"
print(f"⚠️ cv2 {cv2.__version__} lacks CascadeClassifier — probing fallback detectors...")
for backend, pkg, mod in [("mediapipe", "mediapipe", "mediapipe"),
("mtcnn", "mtcnn", "mtcnn"),
("retinaface", "retina-face", "retinaface")]:
try:
ensure(mod, pkg)
DeepFace.represent(np.zeros((100, 100, 3), np.uint8), model_name="ArcFace",
detector_backend=backend, enforce_detection=False, align=True)
print(f"✅ Using '{backend}' detector backend.")
return backend
except Exception as e:
print(f" ↳ '{backend}' failed: {e}")
sys.exit("❌ No working face detector found (tried mediapipe, mtcnn, retinaface).")
DETECTOR_BACKEND = pick_detector_backend()
print("📥 Loading ArcFace model...")
DeepFace.build_model("ArcFace")
# ---------------- Speech ----------------
class VoiceSpeaker:
def __init__(self, rate=175, volume=0.8):
self._q = queue.Queue()
self._rate, self._volume = rate, volume
if _TTS_AVAILABLE:
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
while (text := self._q.get()) is not None:
try:
engine = pyttsx3.init()
engine.setProperty('rate', self._rate)
engine.setProperty('volume', self._volume)
engine.say(text)
engine.runAndWait()
engine.stop()
except Exception as e:
print(f"⚠️ TTS error: {e}")
def speak(self, text):
if _TTS_AVAILABLE:
self._q.put(text)
def stop(self):
if _TTS_AVAILABLE:
self._q.put(None)
# ---------------- Stability tracker ----------------
HOLD_FRAMES, MISS_TOLERANCE = 5, 6
class RecognitionTracker:
def __init__(self):
self._state = {} # name -> {"streak", "announced", "missed"}
def update(self, seen_names):
seen = set(seen_names)
for name in [n for n in self._state if n not in seen]:
self._state[name]["missed"] += 1
if self._state[name]["missed"] > MISS_TOLERANCE:
del self._state[name]
stable = []
for name in seen:
e = self._state.setdefault(name, {"streak": 0, "announced": False, "missed": 0})
e["missed"], e["streak"] = 0, e["streak"] + 1
if e["streak"] == HOLD_FRAMES and not e["announced"]:
e["announced"] = True
stable.append(name)
return stable
# ---------------- Face database ----------------
print("Loading face database...")
try:
with open("face_database.pkl", "rb") as f:
db = pickle.load(f)
known_names, known_matrix = db["identities"], np.vstack(db["embeddings"])
print(f"Loaded {len(known_names)} profiles.")
except FileNotFoundError:
sys.exit("Database not found. Please create it first.")
def recognize_faces(frame):
"""Return list of (name, confidence, region) for each face in frame."""
results = []
try:
faces = DeepFace.represent(img_path=frame, model_name="ArcFace",
detector_backend=DETECTOR_BACKEND,
enforce_detection=False, align=True)
for fd in faces:
conf = fd.get("face_confidence", 0)
if conf < 0.6:
continue
vec = np.array(fd["embedding"])
vec = vec / np.linalg.norm(vec) if np.linalg.norm(vec) > 0 else vec
sims = np.dot(known_matrix, vec)
idx = np.argmax(sims)
name = known_names[idx] if sims[idx] >= 0.45 else "Unknown"
results.append((name, conf, fd.get("facial_area", {})))
except Exception as e:
print(f"⚠️ recognize_faces error: {e}")
return results
# ---------------- Threaded recognition (keeps video smooth) ----------------
HOST, MAX_FAILS = "localhost", 10
RECOGNIZE_EVERY_N_FRAMES, DOWNSCALE_WIDTH = 5, 480
speaker, tracker = VoiceSpeaker(), RecognitionTracker()
lock = threading.Lock()
latest_detections = []
busy = threading.Event()
def recognition_worker(small_frame, scale):
"""Runs DeepFace in the background, then publishes bounding boxes
scaled back up to full-frame coordinates. Only x/y/w/h are kept —
DeepFace's facial_area dict can also contain (x, y) eye-position
tuples under other keys, which aren't numeric and can't be scaled."""
global latest_detections
try:
dets = recognize_faces(small_frame)
scaled = []
for n, c, r in dets:
box = {k: int(r[k] / scale) for k in ("x", "y", "w", "h") if k in r}
scaled.append((n, c, box))
with lock:
latest_detections = scaled
finally:
busy.clear()
# ---------------- Main loop ----------------
try:
with BonicBot(host=HOST, port=9090, timeout=10) as bot:
print("📷 Starting camera...")
bot.system.start_camera()
bot.start_camera()
bot.camera.wait_for_image(timeout=5.0)
print("✅ Camera running. Press 'q' to stop.\n")
frame_count, fails = 0, 0
try:
while True:
frame = bot.camera.get_latest_image()
if frame is None:
fails += 1
if fails >= MAX_FAILS:
print(f"Failed to grab frame after {MAX_FAILS} attempts.")
break
time.sleep(0.05)
continue
fails = 0
if frame_count % RECOGNIZE_EVERY_N_FRAMES == 0 and not busy.is_set():
busy.set()
h, w = frame.shape[:2]
scale = DOWNSCALE_WIDTH / w if w > DOWNSCALE_WIDTH else 1.0
small = cv2.resize(frame, (int(w * scale), int(h * scale))) if scale < 1.0 else frame.copy()
threading.Thread(target=recognition_worker, args=(small, scale), daemon=True).start()
with lock:
detections = list(latest_detections)
for name in tracker.update([d[0] for d in detections]):
msg = "Unknown person detected" if name == "Unknown" else f"{name} recognized"
print(f"[{time.strftime('%H:%M:%S')}] 🟢 {msg}")
speaker.speak(msg)
for name, conf, r in detections:
x, y, w, h = r.get("x", 0), r.get("y", 0), r.get("w", 0), r.get("h", 0)
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.putText(frame, f"{name} ({conf:.2f})", (x, max(y - 8, 0)),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
cv2.imshow('Face Recognition Feed', frame)
frame_count += 1
if cv2.waitKey(1) & 0xFF == ord('q'):
break
finally:
cv2.destroyAllWindows()
bot.system.stop_camera()
except BonicBotError as e:
print(f"Robot error: {e}")
finally:
speaker.stop()
print("Camera shutdown and window closed.")
Replace [IP_ADDRESS] with the IP address of your own BonicBot. Every BonicBot may have a different IP address depending on your network configuration.
📌 Example:
with BonicBot(host="172.20.10.2") as bot:Replace 172.20.10.2 with the IP address assigned to your BonicBot.
Code Walkthrough
Line-by-line explanation
- Imports —
os,sys,subprocess,importlibsupport the auto-install logic;timehandles timestamps and retry delays;pickleloads the face database;queueandthreadingpower the background TTS worker and the threaded recognition pipeline;numpy(np) handles embedding math;BonicBot/BonicBotErrormanage the robot connection. pip_install(pkg)/ensure(module, pkg=None)— Small helper pair used to auto-install missing dependencies at runtime:ensure()triesimportlib.import_module(module)first, and only callspip_install()if the import fails, then imports again.cv2is loaded this way (cv2 = ensure("cv2", "opencv-python")), as isdeepface(ensure("deepface")).pyttsx3import block — Wrapped intry/except ImportErrorto set_TTS_AVAILABLE, the same optional-TTS pattern used in earlier lessons.pick_detector_backend()— Checks forcv2.CascadeClassifierfirst and returns"opencv"if present. Otherwise it loops throughmediapipe,mtcnn, andretinaface, usingensure()to install each one if needed and testing it with a throwawayDeepFace.represent()call on a blank image before returning the first backend that works. Exits withsys.exit(...)if none succeed.DETECTOR_BACKEND = pick_detector_backend()— Runs once at startup, andDeepFace.build_model("ArcFace")is called right after to pre-load the model so the first real recognition call isn’t slowed down by a cold start.VoiceSpeaker— Same background-thread TTS pattern as previous lessons:speak()pushes text onto aqueue.Queue,_run()consumes it on a daemon thread and speaks each item withpyttsx3, andstop()pushesNoneas a sentinel to end the loop.HOLD_FRAMES,MISS_TOLERANCE— Tuning constants for theRecognitionTracker: how many consecutive “seen” updates are needed before a name is announced, and how many consecutive misses are tolerated before a person’s tracking state is forgotten.RecognitionTracker— Maintains a_statedictionary keyed by name, each holding astreak,announcedflag, andmissedcounter.update(seen_names)incrementsmissedfor names no longer seen (deleting their state oncemissedexceedsMISS_TOLERANCE), and incrementsstreakfor names currently seen, resettingmissedto 0. A name is added to the returnedstablelist — andannouncedis flipped toTrue— only the moment itsstreakfirst reachesHOLD_FRAMES, so each appearance is announced exactly once.- Face database loading — Opens
face_database.pklwithpickle.load(), pulling outknown_names(theidentitieslist) and stacking every stored embedding into a single 2D array withnp.vstack(db["embeddings"])for fast batch comparison. If the file doesn’t exist,sys.exit(...)stops the script immediately with a clear message. recognize_faces(frame)— CallsDeepFace.represent(img_path=frame, ...)to detect and embed every face in the given frame, usingenforce_detection=Falseso a frame with no face doesn’t raise. For each detected face (fd), it skips low-confidence detections (face_confidence < 0.6), normalizes the new embedding the same way Lesson 8 did, then computes similarity against every known embedding at once vianp.dot(known_matrix, vec)and picks the best match withnp.argmax(sims). If that best similarity score is below0.45, the person is labeled"Unknown"instead of a database name. Returns a list of(name, confidence, facial_area)tuples.recognition_worker(small_frame, scale)— Runsrecognize_faces()on a background thread so the main video loop never blocks waiting on DeepFace. Because the frame passed in was downscaled for speed, each face’s bounding box (x,y,w,h) is divided back byscaleto map it to full-frame coordinates before being stored in the sharedlatest_detectionslist (protected bylock). Thebusyevent is cleared in afinallyblock so a new recognition pass can be scheduled once this one finishes.HOST,MAX_FAILS,RECOGNIZE_EVERY_N_FRAMES,DOWNSCALE_WIDTH— Connection target and performance tuning constants: how many consecutive failed frame grabs are tolerated before giving up, how often (in frames) a new recognition pass is kicked off, and how narrow frames are resized to before being sent to DeepFace.- Shared state (
speaker,tracker,lock,latest_detections,busy) — A singleVoiceSpeakerandRecognitionTrackerare created once;lockguardslatest_detections, the most recent set of recognized faces available to the main loop;busyis athreading.Eventused to prevent overlapping recognition passes from running at the same time. - Main loop — Inside
with BonicBot(host=HOST, port=9090, timeout=10) as bot:, the camera is started withbot.system.start_camera()/bot.start_camera()andbot.camera.wait_for_image(). Each iteration:- Pulls a frame with
bot.camera.get_latest_image(), tracking consecutive failures infailsand breaking out afterMAX_FAILSis reached. - Every
RECOGNIZE_EVERY_N_FRAMESframes — and only if no recognition pass is already running (not busy.is_set()) — downsizes the frame viacv2.resizeand launchesrecognition_workeron a new daemon thread, rather than running DeepFace on every single frame. - Reads the latest results out of
latest_detectionsunderlock, then callstracker.update(...)with just the detected names; any name returned as newly “stable” triggers a printed log line and aspeaker.speak(...)announcement — “Unknown person detected” for unmatched faces,"{name} recognized"otherwise. - Draws a green bounding box and a
"{name} ({confidence})"label for every currently tracked detection usingcv2.rectangleandcv2.putText, then shows the frame in the'Face Recognition Feed'window. - Checks for the
qkey viacv2.waitKey(1)to break the loop.
- Pulls a frame with
- Cleanup — The inner
finallyblock closes the OpenCV window and callsbot.system.stop_camera()once the loop ends. The outertry/except BonicBotErrorcatches robot connection errors, and the outermostfinallycallsspeaker.stop()and prints a final shutdown message regardless of how the script exited.
Expected Output
Click to see expected output
Visual Output:
Terminal Output:
Loading face database...
Loaded 18 profiles.
📷 Starting BonicBot camera and streaming...
✅ Camera running.
[14:52:08] 🟢 Alice recognized
[14:52:17] 🟢 Bob recognized
[14:52:29] 🟢 Unknown person detectedThe BonicBot Vision Window will display:
- A live view from BonicBot’s vision system.
- Face recognition running continuously.
- Console messages whenever a person is successfully recognized.
- Voice announcements for recognized and unknown people.
BonicBot only announces a name once while the person remains in view. If they leave and return later, the robot will announce them again.
🔧 Under the Hood
How does BonicBot recognize a face?
This lesson uses the ArcFace deep learning model for face recognition.
When BonicBot sees a face, it first generates a new face embedding.
For example:
Person Standing in Front of BonicBot
↓
ArcFace
↓
[0.14, -0.32, 0.87, ...]The program then compares this new embedding with every embedding stored inside face_database.pkl.
To measure how similar two embeddings are, it uses Cosine Similarity.
New Face Embedding
│
▼
Compare with Database
│
▼
Highest Cosine Similarity
│
▼
Recognized PersonIf the highest similarity score is greater than the chosen threshold, BonicBot considers the face to be a match.
Otherwise, the person is labeled as Unknown.
The program also uses a Recognition Tracker, which waits until the same person has been recognized for several consecutive processed frames before announcing their name. This makes the robot much more stable and prevents repeated announcements while someone remains standing in front of BonicBot.
Student Challenge
Modify the program so BonicBot greets each recognized person by name.
For example:
- “Hello Alice!”
- “Welcome back Bob!”
- “Nice to see you Charlie!”
You can also use different greetings for different people.
Hint
Instead of saying:
speaker.speak(message)Create a custom greeting.
For example:
speaker.speak(f"Hello {name}!")You could also use an if statement to give different greetings to different people.
Reflection Question
Suppose two different people look very similar, or someone is wearing glasses, a mask, or a hat.
Why do you think BonicBot compares face embeddings instead of comparing the original images directly? How might this make face recognition more reliable?