Capstone 2 — BonicBot Library Checkout Assistant
Learning Objective
Build a complete AI-powered library assistant by combining badge-based session control, background ArcFace face recognition against a prebuilt member database, QR code scanning for book checkout and return, session-timeout handling, and speech-plus-gesture announcements into a single continuously running BonicBot application.
Introduction
Previously, you’ve taught BonicBot several individual AI and vision skills.
It can now:
- classify a badge’s color to determine what a visitor wants to do
- build a database of known faces
- recognize a face using ArcFace embeddings and cosine similarity
- scan a QR code to read information encoded on it
In this capstone project, you’ll combine all of these abilities into a single intelligent robot application.
Project Scenario
Imagine BonicBot is working the front desk of a library, helping members check books out and return them.
When a member approaches, the robot should:
- Watch for a member to hold up a blue badge, signalling they want to start a checkout/return session.
- Verify who they are by comparing their face against a prebuilt database of known members — without freezing the live camera feed while it thinks.
- Greet the member by name and tell them how many books they currently have checked out, if any.
- Let the member hold up each book’s QR tag, one at a time, toggling it checked-out or returned depending on its current state.
- Log every checkout and return to an attendance-style file, so the current state can always be rebuilt from the log.
- End the session when the member holds up a green badge, or automatically after 30 seconds of inactivity — then get ready for the next person.
Unlike a one-shot pipeline that walks a single visitor through every phase in order and then stops, BonicBot’s library assistant runs continuously. It never stops watching the camera: badge colors and QR codes are checked on every frame, and the one genuinely slow step — face verification — is handed off to a background thread so it never blocks the live feed. The only thing that halts the whole program is the operator pressing q.
Setup: Installing Packages
Before running the code, make sure your computer (or the robot’s onboard system) has the required Python packages installed. Open a terminal and run:
pip install bonicbot-bridge opencv-python sounddevice piper-tts deepface numpyWhat each package / tool does
| Package / Tool | Purpose |
|---|---|
bonicbot-bridge | The official BonicBot SDK. Provides the BonicBot class used to connect to the robot, control its camera feed via bot.system.start_camera() / bot.start_camera(), and send arm gesture movements (bot.move_left_arm()). Also provides BonicBotError for safe error handling. |
opencv-python | Imported as cv2. Used to capture video frames, convert patches to HSV for badge color classification (cv2.cvtColor), decode book QR codes (cv2.QRCodeDetector), draw text/overlays, and display the live video feed (cv2.imshow). |
sounddevice | Imported as sd. Used by VoiceSpeaker to play synthesized audio samples (sd.play, sd.wait) directly through system audio output. |
piper-tts | Provides Python bindings for the Piper neural text-to-speech engine (PiperVoice, SynthesisConfig). Synthesizes spoken text into audio buffers locally without relying on cloud services. |
deepface | Provides face recognition using the ArcFace deep learning model (DeepFace.represent). Used by match_face() to generate 512-dimensional facial embeddings and calculate cosine similarity scores against prebuilt member profiles in face_database.pkl. |
numpy | Imported as np. Used for vector operations (L2 vector normalization, dot-product cosine similarity calculation) and array manipulation for video frame patches and audio buffers. |
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 sounddevice piper-tts deepface numpyHow to run the program
-
Find your BonicBot’s IP address. Check the robot’s on-device display, its companion app, or your router’s connected-devices list.
-
Prepare face database. Follow the face-enrollment steps from Lesson 7 to capture member faces and generate
face_database.pkl. Place this file in the same folder as your script. -
Save all code parts into a single file, e.g.
capstone2_library.py(Part 1 through Part 5 in order). -
Replace
'localhost'inBonicBot(host='localhost', ...)insidemain()with your robot’s actual IP address if connecting over the network. -
Ensure audio output is available (speakers or headphones connected to your system) so you can hear spoken greetings and status announcements.
-
Prepare badges and QR codes:
- A blue badge (HSV hue 100–130) to signal starting a session.
- A green badge (HSV hue 35–85) to signal ending a session.
- Text QR codes representing book items (e.g.,
BOOK001,BOOK002).
-
Run the script:
python capstone2_library.py -
A window titled “Lab Checkout” will open showing the live camera feed.
-
Test the flow: Hold up a blue badge → look at the camera for face recognition → listen for the personalized welcome → hold up book QR codes to check out/return items → hold up a green badge (or wait 30s for timeout) to complete the session.
-
Press
qwith the video window focused to stop the program and exit cleanly.
Don’t have a physical BonicBot? Try it in simulation (optional)
If you don’t have physical access to a BonicBot, you can still work through this capstone using the ROS 2 simulation environment instead of a real robot:
-
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 gesture movements are simulated. This allows you to test badge detection, face recognition, and QR scanning directly using your laptop camera.use_real_camera:=False— the camera feed comes from Gazebo instead.
-
Leave
host='localhost'in theBonicBot(...)call inmain()unchanged when using the simulator. -
Everything else in the code — background face worker, badge triggering, QR scanning, CSV logging, and Piper speech output — works the same way.
Code
Part 1 — Imports, Configuration, Voice, and Gesture Output
import time, csv, pickle, threading, queue, sys, subprocess, importlib, os, wave, io
from pathlib import Path
import cv2, numpy as np
import sounddevice as sd
from piper import PiperVoice, SynthesisConfig
# ---- config ----
PIPER_VOICE = "en_US-lessac-medium"
PIPER_VOICE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "piper_voices")
FACE_DB_PATH, LOG_PATH = 'face_database.pkl', 'checkout_log.csv'
FACE_MATCH_THRESHOLD, DETECTOR_BACKEND = 0.30, "opencv"
RECOGNIZE_EVERY_N = 5
SESSION_TIMEOUT = 30.0
GREEN_HUE_MIN, GREEN_HUE_MAX = 35, 85
BLUE_HUE_MIN, BLUE_HUE_MAX = 100, 130
FACE_MATCH_WINDOW, FACE_MATCH_RETRY_INTERVAL = 10.0, 0.15
FACE_EARLY_EXIT_SCORE = 0.45 # stop polling early once we're confidently past threshold
latest_frame, frame_lock = None, threading.Lock()
def set_latest_frame(frame):
global latest_frame
with frame_lock: latest_frame = frame.copy()
def get_latest_frame():
with frame_lock: return None if latest_frame is None else latest_frame.copy()
def ensure_piper_voice(voice):
os.makedirs(PIPER_VOICE_DIR, exist_ok=True)
model_path = os.path.join(PIPER_VOICE_DIR, f"{voice}.onnx")
if not (os.path.exists(model_path) and os.path.exists(model_path + ".json")):
subprocess.run([sys.executable, "-m", "piper.download_voices", "--data-dir", PIPER_VOICE_DIR, voice], check=True)
return model_path
# ---- speech ----
class VoiceSpeaker:
def __init__(self, voice, volume=2.5):
self._voice, self._cfg, self._q = voice, SynthesisConfig(length_scale=0.85, noise_scale=0.9, volume=volume), queue.Queue()
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
while (text := self._q.get()) is not None:
try: self._speak(text)
except Exception as ex: print(f"⚠️ TTS error: {ex}")
def _speak(self, text):
with io.BytesIO() as buf:
with wave.open(buf, "wb") as wf: self._voice.synthesize_wav(text, wf, syn_config=self._cfg)
buf.seek(0)
with wave.open(buf, "rb") as wf:
ch, rate = wf.getnchannels(), wf.getframerate()
frames = wf.readframes(wf.getnframes())
audio = np.frombuffer(frames, dtype=np.int16)
if ch > 1: audio = audio.reshape(-1, ch)
sd.play(audio, samplerate=rate); sd.wait()
def speak(self, text): self._q.put(text)
def stop(self): self._q.put(None)
# ---- body language (left-arm gesture) ----
ARM_REST, ARM_GESTURE, GESTURE_HOLD = (0, 0), (40, 25), 0.6
gesture_busy = threading.Event()
def _gesture_worker(bot):
try:
bot.move_left_arm(*ARM_GESTURE, wait=True); time.sleep(GESTURE_HOLD); bot.move_left_arm(*ARM_REST, wait=True)
except Exception as e: print(f"⚠️ Gesture error: {e}")
finally: gesture_busy.clear()
def gesture(bot):
if bot is None or gesture_busy.is_set(): return
gesture_busy.set(); threading.Thread(target=_gesture_worker, args=(bot,), daemon=True).start()
def announce(speaker, text, bot=None):
print(f"🔊 {text}"); speaker.speak(text); gesture(bot)Part 2 — Badge Detection & Face Recognition Setup
# ---- badge detection ----
def badge_color(frame, lo, hi):
h, w = frame.shape[:2]; cx, cy, half = w//2, h//2, 40
patch = frame[max(0,cy-half):cy+half, max(0,cx-half):cx+half]
if patch.size == 0: return False
hsv = cv2.cvtColor(patch, cv2.COLOR_BGR2HSV)
sat, val, hue = hsv[:,:,1].mean(), hsv[:,:,2].mean(), hsv[:,:,0].mean()
return sat >= 120 and val >= 100 and lo <= hue <= hi
# ---- face setup ----
def ensure(mod, pkg=None):
try: return importlib.import_module(mod)
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg or mod]); return importlib.import_module(mod)
def pick_detector_backend(DeepFace):
if hasattr(cv2, "CascadeClassifier"): return "opencv"
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.")
def load_face_database(path):
if not Path(path).exists(): return {}
with open(path, "rb") as f: raw = pickle.load(f)
if isinstance(raw, dict) and "identities" in raw and "embeddings" in raw:
db = {}
for n, e in zip(raw["identities"], raw["embeddings"]):
v = np.array(e, dtype=np.float32); norm = np.linalg.norm(v)
if norm: db.setdefault(n, []).append(v / norm)
return db
if isinstance(raw, dict):
# Defensive: raw dict format is NOT guaranteed to be pre-normalized.
# match_face() uses a plain dot product assuming unit vectors, so
# un-normalized entries here will silently produce wrong similarity
# scores. Normalize defensively and warn if anything was off.
db, fixed = {}, 0
for name, vecs in raw.items():
norm_vecs = []
for e in vecs:
v = np.array(e, dtype=np.float32); norm = np.linalg.norm(v)
if norm:
if abs(norm - 1.0) > 1e-3: fixed += 1
norm_vecs.append(v / norm)
db[name] = norm_vecs
if fixed:
print(f"⚠️ face_database.pkl: {fixed} embedding(s) were not unit-normalized; normalized on load. "
f"Consider re-saving the pkl with normalization baked in to avoid ambiguity.")
return db
sys.exit(f"❌ Bad face_database.pkl: {type(raw)}")
def match_face(frame, face_db):
"""Returns (best_name, best_score). best_name is None only if no face
was detected at all. If a face is detected but doesn't clear the
threshold, best_name will still be the closest match with its score,
and threshold decisions are left to the caller."""
from deepface import DeepFace
try: r = DeepFace.represent(frame, model_name="ArcFace", detector_backend=DETECTOR_BACKEND, enforce_detection=True)
except ValueError: return None, None
emb = np.array(r[0]["embedding"], dtype=np.float32); emb /= np.linalg.norm(emb) + 1e-9
best_name, best_score = None, -1.0
for name, vecs in face_db.items():
for v in vecs:
s = float(np.dot(emb, v))
if s > best_score: best_name, best_score = name, s
return best_name, best_scoreThis phase needs face_database.pkl, a previously generated face database of your library members. Follow the face-enrollment steps from Lesson 7 to capture each member’s face and build this file, then place it in the same folder as this script before running it — otherwise every member will come back unrecognized and no session will start.
Part 3 — Session State & Background Face Verification
# ---- session lock (UNKNOWN/no-face never calls start_session, so active stays False -> blue badge retry works) ----
busy = threading.Event()
session_lock = threading.Lock()
session = {"name": None, "active": False, "last_activity": 0.0, "items": set(), "last_item": None}
def start_session(name):
with session_lock:
session["name"], session["active"], session["last_activity"], session["items"], session["last_item"] = name, True, time.time(), set(), None
def end_session():
with session_lock:
session["name"], session["active"], session["items"], session["last_item"] = None, False, set(), None
def touch_session():
with session_lock: session["last_activity"] = time.time()
def session_snapshot():
with session_lock: return session["active"], session["name"], session["last_item"]
def session_timed_out():
with session_lock: return session["active"] and (time.time() - session["last_activity"] > SESSION_TIMEOUT)
def mark_checked_this_session(item_id):
with session_lock: session["items"].add(item_id)
def checked_this_session(item_id):
with session_lock: return item_id in session["items"]
def face_worker(get_frame_fn, face_db, speaker, bot):
"""Polls for up to FACE_MATCH_WINDOW seconds, tracking the BEST score
seen across all frames rather than stopping at the first frame that
produces a detection. This matters because per-frame ArcFace similarity
naturally jitters (blink, micro-angle, motion blur, compression noise)
even under identical lighting on the same face -- a single low-scoring
frame should not end the attempt early."""
deadline = time.time() + FACE_MATCH_WINDOW
best_name, best_score, frames_with_face, attempts = None, -1.0, 0, 0
while time.time() < deadline:
frame = get_frame_fn()
if frame is not None:
attempts += 1
try:
cand_name, cand_score = match_face(frame, face_db)
except Exception as e:
print(f"⚠️ match_face error on attempt {attempts}: {e}")
cand_name, cand_score = None, None
if cand_name is not None:
frames_with_face += 1
print(f" ↳ [{frames_with_face}] candidate={cand_name} score={cand_score:.3f}")
if cand_score > best_score:
best_name, best_score = cand_name, cand_score
if best_score >= FACE_EARLY_EXIT_SCORE:
break
time.sleep(FACE_MATCH_RETRY_INTERVAL)
print(f"🔍 face_worker done: attempts={attempts} frames_with_face={frames_with_face} "
f"best={best_name} best_score={(f'{best_score:.3f}' if best_score > -1.0 else 'n/a')}")
if best_score >= FACE_MATCH_THRESHOLD:
name = best_name
start_session(name)
items = person_items.get(name)
announce(speaker, f"Welcome back {name}, you have {len(items)} item(s) out. Scan to return." if items else f"Hi {name}, go ahead and scan your items.", bot)
elif frames_with_face > 0:
announce(speaker, "I don't recognize you, please try again or register.", bot)
else:
announce(speaker, "I couldn't see a face clearly, please try again.", bot)
busy.clear()Part 4 — Checkout/Return Logic & Attendance Log
# ---- checkout logic ----
checked_out, person_items = {}, {}
def rebuild_state():
checked_out, person_items = {}, {}
if not Path(LOG_PATH).exists(): return checked_out, person_items
with open(LOG_PATH, newline="") as f:
for row in csv.DictReader(f):
item, name = row["item"], row["name"]
if row["action"] == "checkout":
checked_out[item] = {"name": name, "time": row["timestamp"]}; person_items.setdefault(name, set()).add(item)
elif row["action"] == "return":
checked_out.pop(item, None); person_items.get(name, set()).discard(item)
return checked_out, person_items
def log_event(action, item_id, name):
exists = Path(LOG_PATH).exists()
with open(LOG_PATH, "a", newline="") as f:
w = csv.writer(f)
if not exists: w.writerow(["timestamp","action","item","name"])
w.writerow([time.strftime("%Y-%m-%d %H:%M:%S"), action, item_id, name])
def handle_scan(item_id, name, speaker, bot):
if checked_this_session(item_id): return
mark_checked_this_session(item_id)
with session_lock: session["last_item"] = item_id
if item_id in checked_out:
checked_out.pop(item_id); person_items.get(name, set()).discard(item_id)
log_event("return", item_id, name); announce(speaker, f"{item_id} returned, thanks {name}.", bot)
else:
checked_out[item_id] = {"name": name, "time": time.time()}
person_items.setdefault(name, set()).add(item_id)
log_event("checkout", item_id, name); announce(speaker, f"{item_id} checked out for {name}.", bot)Part 5 — Main Loop
# ---- main loop ----
def main():
global checked_out, person_items, DETECTOR_BACKEND
checked_out, person_items = rebuild_state()
face_db = load_face_database(FACE_DB_PATH)
from deepface import DeepFace
DETECTOR_BACKEND = pick_detector_backend(DeepFace)
print("🔧 Warming up face recognition model...")
try:
DeepFace.represent(np.zeros((100, 100, 3), np.uint8), model_name="ArcFace", detector_backend=DETECTOR_BACKEND, enforce_detection=False, align=True)
print("✅ Face model warmed up.")
except Exception as e: print(f"⚠️ Warmup failed: {e}")
from bonicbot_bridge import BonicBot, BonicBotError
speaker = VoiceSpeaker(PiperVoice.load(ensure_piper_voice(PIPER_VOICE)))
qr_detector = cv2.QRCodeDetector()
seen_tag, frame_count = None, 0
try:
with BonicBot(host='localhost', port=9090, timeout=10) as bot:
bot.system.start_camera(); bot.start_camera(); bot.camera.wait_for_image(timeout=5.0)
print("✅ Checkout assistant online. Press 'q' to stop.")
while True:
frame = bot.get_image()
if frame is None: continue
frame_count += 1
set_latest_frame(frame)
active, locked_name, last_item = session_snapshot()
if not active:
if badge_color(frame, BLUE_HUE_MIN, BLUE_HUE_MAX) and not busy.is_set():
busy.set()
threading.Thread(target=face_worker, args=(get_latest_frame, face_db, speaker, bot), daemon=True).start()
else:
if badge_color(frame, GREEN_HUE_MIN, GREEN_HUE_MAX):
log_event("release", "-", locked_name)
announce(speaker, f"Thanks {locked_name}, ready for the next person.", bot)
end_session()
else:
found, texts, points, _ = qr_detector.detectAndDecodeMulti(frame)
tag = next((t.strip().upper() for t in (texts or []) if t and t.strip()), None) if found else None
if tag and tag != seen_tag:
handle_scan(tag, locked_name, speaker, bot); touch_session()
seen_tag = tag
if session_timed_out():
announce(speaker, f"Session timed out for {locked_name}.", bot); end_session()
active, locked_name, last_item = session_snapshot()
text = (f"{locked_name} | {last_item}" if last_item else locked_name) if active else \
"Verifying..." if busy.is_set() else \
"Section started" if badge_color(frame, BLUE_HUE_MIN, BLUE_HUE_MAX) else "Waiting for next section"
cv2.putText(frame, text, (10,30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,255,0), 2)
h, w = frame.shape[:2]; cx, cy, half = w // 2, h // 2, 40
cv2.rectangle(frame, (cx - half, cy - half), (cx + half, cy + half), (255, 255, 0), 2)
cv2.imshow("Lab Checkout", frame)
if cv2.waitKey(1) & 0xFF == ord('q'): break
bot.stop_camera(); bot.system.stop_camera()
except BonicBotError as e: print(f"⚠️ Robot error: {e}")
finally: cv2.destroyAllWindows(); speaker.stop()
if __name__ == "__main__":
main()This script connects with host='localhost', so it expects the bridge to be reachable on the same machine the script runs on. If you’re connecting to your BonicBot over the network instead, replace 'localhost' inside the BonicBot(...) call in main() with your robot’s IP address.
📌 Example:
with BonicBot(host="172.20.10.2", port=9090, timeout=10) as bot:The OpenCV window is titled "Lab Checkout", carried over from an earlier lab-equipment version of this script — feel free to rename it to "Library Checkout" in your own copy.
VoiceSpeaker synthesizes speech locally with Piper and plays it back through sounddevice, so it doesn’t depend on the robot itself having a speaker. The first run also downloads the en_US-lessac-medium Piper voice automatically via ensure_piper_voice().Code Walkthrough
Line-by-line explanation
Part 1 — Imports, Configuration, Voice, and Gesture Output
- Imports —
time,csv,pickle,threading,queue,subprocess,importlib,os,wave, andiohandle data storage, multithreading, audio synthesis, and system operations;cv2andnumpyprocess images;sounddeviceplays audio;PiperVoicegenerates speech;BonicBotandBonicBotErrorcontrol the robot bridge. - Config constants (
PIPER_VOICE,FACE_DB_PATH,LOG_PATH, etc.) — Set voice models, threshold levels (ArcFace cosine similarity0.30), HSV badge bounds (green35–85, blue100–130), session timeout duration (30.0s), and thread polling windows (FACE_MATCH_WINDOW = 10.0s). - Thread-safe frame access (
set_latest_frame,get_latest_frame) — Usesthreading.Lock()so the main camera loop and the background face recognition thread can share the latest frame without race conditions or camera hardware contention. VoiceSpeakerclass — A non-blocking TTS worker. Receives speech strings via aqueue.Queue(), synthesizes WAV audio using Piper in a background thread (_run), and streams audio throughsounddevice.play().- Arm gesture helper (
gesture,_gesture_worker,announce) — Moves BonicBot’s left arm into a wave gesture (ARM_GESTURE) upon spoken announcements without blocking the camera thread (gesture_busyevent guard).
Part 2 — Badge Detection & Face Recognition Setup
badge_color(frame, lo, hi)— Extracts a 40px center patch from the camera frame, converts it to HSV, and checks if average saturation and value are high enough and average hue falls within[lo, hi].pick_detector_backend(DeepFace)— Automatically tests available face detection backends (opencv,mediapipe,mtcnn,retinaface) and picks the first functional detector for ArcFace.load_face_database(path)— Readsface_database.pkl, extracts identities and feature vectors, and ensures all embedding vectors are unit-normalized (v / norm) for fast cosine similarity dot products.match_face(frame, face_db)— Extracts a 512-dimensional ArcFace embedding from the frame usingDeepFace.represent, normalizes it, and calculates the dot product against all enrolled member vectors inface_dbto find the highest matching identity score.
Part 3 — Session State & Background Face Verification
- Session locking & state variables (
start_session,end_session,touch_session, etc.) — Maintains member session state (sessiondictionary guarded bysession_lock), tracking who is logged in, active items, and last activity timestamps for 30-second timeouts. face_worker(get_frame_fn, face_db, speaker, bot)— Spawns as a background thread when a blue badge is detected. Polls latest frames for up toFACE_MATCH_WINDOW(10s), tracking the highest match score seen. If the best score clearsFACE_MATCH_THRESHOLD(0.30), it opens a session and speaks a greeting; otherwise, it announces an unrecognized visitor notice and clearsbusy.
Part 4 — Checkout/Return Logic & Attendance Log
rebuild_state()— Readscheckout_log.csvat startup to reconstruct current checked-out items and per-member item lists from past transactions.log_event(action, item_id, name)— Appends timestampedcheckout,return, orreleaserecords tocheckout_log.csv.handle_scan(item_id, name, speaker, bot)— Toggles item state: if an item is already checked out, returns it; if available, checks it out for the active member. Logs the event and announces the action via voice and arm gesture.
Part 5 — Main Loop
- Initialization — Rebuilds log state, loads face database, picks and warms up the ArcFace deep learning model, initializes
VoiceSpeaker,cv2.QRCodeDetector, and connects to BonicBot (BonicBot(host='localhost', ...)). - Continuous camera processing — Main loop continuously grabs camera frames, updates
latest_frame, and checks session status:- Inactive session: Monitors center frame patch for a blue badge. When detected, spawns
face_workerin a background thread ifbusyis free. - Active session: Checks for a green badge to manually release the session, scans for QR codes using
qr_detector.detectAndDecodeMulti()to handle item checkouts/returns, or triggers automatic timeout if 30 seconds of inactivity elapse.
- Inactive session: Monitors center frame patch for a blue badge. When detected, spawns
- Overlay rendering & cleanup — Renders active user info and badge target rectangle on the live OpenCV window (
"Lab Checkout") and exits cleanly whenqis pressed.
Expected Output
Click to see expected output
Visual Output:
📌 Note: This visual demonstration is using the ROS 2 simulation with use_real_camera:=True.
Terminal Output:
🔧 Warming up face recognition model...
✅ Face model warmed up.
✅ Checkout assistant online. Press 'q' to stop.
↳ [1] candidate=Alice score=0.812
🔍 face_worker done: attempts=2 frames_with_face=2 best=Alice best_score=0.812
🔊 Hi Alice, go ahead and scan your items.
🔊 BOOK001 checked out for Alice.
🔊 BOOK002 checked out for Alice.
🔊 Thanks Alice, ready for the next person.Each checkout, return, and session release also appends a new row to checkout_log.csv:
timestamp,action,item,name
2026-07-24 10:15:32,checkout,BOOK001,Alice
2026-07-24 10:16:05,checkout,BOOK002,Alice
2026-07-24 10:20:41,release,-,AliceIf a member’s face isn’t recognized, the badge attempt is simply abandoned — no session opens, and BonicBot waits for the blue badge again. If a session sits idle for 30 seconds, it’s ended automatically with a spoken timeout notice instead of a “thanks” message.
🔧 Under the Hood
How does BonicBot verify identity without freezing the camera feed?
Unlike a pipeline that walks a single visitor through fixed phases one at a time, the library assistant is built around one always-running loop with a single background helper, not a sequence of blocking wait-functions.
- The main loop never stops capturing. Every iteration of
main()callsbot.get_image()once, checks the current badge color, checks for a QR code, updates the on-screen overlay, and moves on — none of these steps ever pause for more than a single frame. - Face verification is the one slow step, so it’s moved off the main thread. When a blue badge is seen and nothing is already in progress (
busy.is_set()isFalse),main()setsbusyand hands the job toface_worker()on a background daemon thread. That thread can poll for up toFACE_MATCH_WINDOW(10 seconds) without ever blocking the camera loop. - The background thread never touches the camera directly. Instead of calling
bot.get_image()itself,face_worker()callsget_latest_frame(), which reads the most recent frame the main loop already captured.latest_frameandframe_lockexist specifically so two threads can safely share one captured frame instead of two threads pulling from the camera hardware at once. busyguarantees only one verification attempt happens at a time. As long asbusyis set, the main loop won’t start a secondface_worker, even if the badge is still held up.session_lockprotects shared session state. Both the main thread (badge/QR handling) and the background thread (face_worker, on success) read and write the samesessiondictionary, so every access goes through the lock.
The badge colors themselves work as session triggers, not states in a bigger machine: blue means “try to start a session here,” and green means “end the session that’s active.” Everything else — which member, which books, how long they’ve been idle — lives in session and is updated in place.
Gestures follow the same non-blocking pattern as speech: gesture() spawns its own background thread guarded by gesture_busy, so a spoken announcement and its accompanying arm motion never freeze the loop either.
Student Challenge
Extend the library assistant so it only accepts QR tags that belong to books actually in the library’s catalog. Right now, handle_scan() will happily check out or return any QR code a member holds up, even a random one that isn’t a real book tag.
Hint
Add a set of known book IDs near your other config constants:
KNOWN_BOOKS = {
"BOOK001",
"BOOK002",
"BOOK003",
# ...
}Then, in the main loop, right after a new tag is detected (where tag != seen_tag) but before calling handle_scan(), check whether tag is in KNOWN_BOOKS. If it is, proceed as usual. If it isn’t, call announce(speaker, "That book isn't in our catalog.", bot) instead of calling handle_scan(), so unrecognized tags are never checked out, returned, or logged.
Reflection Question
handle_scan() guards against duplicate scans in two different ways at once: the main loop only calls it when tag != seen_tag (the QR text just changed), and handle_scan() itself immediately checks checked_this_session(item_id) before doing anything else.
Why does the code need both checks? Walk through what would go wrong for a member holding the same book steady in front of the camera for several seconds if only the tag != seen_tag check existed — and separately, what would go wrong if only the checked_this_session() guard existed, with no change-detection in the main loop at all.