Skip to Content
BonicBot A2DevelopmentArtificial IntelligenceCapstone 2 — BonicBot Library Checkout Assistant

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:

  1. Watch for a member to hold up a blue badge, signalling they want to start a checkout/return session.
  2. Verify who they are by comparing their face against a prebuilt database of known members — without freezing the live camera feed while it thinks.
  3. Greet the member by name and tell them how many books they currently have checked out, if any.
  4. 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.
  5. Log every checkout and return to an attendance-style file, so the current state can always be rebuilt from the log.
  6. 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 numpy

What each package / tool does

Package / ToolPurpose
bonicbot-bridgeThe 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-pythonImported 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).
sounddeviceImported as sd. Used by VoiceSpeaker to play synthesized audio samples (sd.play, sd.wait) directly through system audio output.
piper-ttsProvides Python bindings for the Piper neural text-to-speech engine (PiperVoice, SynthesisConfig). Synthesizes spoken text into audio buffers locally without relying on cloud services.
deepfaceProvides 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.
numpyImported 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 numpy

How to run the program

  1. Find your BonicBot’s IP address. Check the robot’s on-device display, its companion app, or your router’s connected-devices list.

  2. 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.

  3. Save all code parts into a single file, e.g. capstone2_library.py (Part 1 through Part 5 in order).

  4. Replace 'localhost' in BonicBot(host='localhost', ...) inside main() with your robot’s actual IP address if connecting over the network.

  5. Ensure audio output is available (speakers or headphones connected to your system) so you can hear spoken greetings and status announcements.

  6. 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).
  7. Run the script:

    python capstone2_library.py
  8. A window titled “Lab Checkout” will open showing the live camera feed.

  9. 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.

  10. Press q with 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:

  1. Launch the BonicBot simulation:

    ros2 launch my_bot robot_system.launch.py use_sim_time:=true world:=obsworld.sdf use_real_camera:=True
    • use_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.
  2. Leave host='localhost' in the BonicBot(...) call in main() unchanged when using the simulator.

  3. 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_score

This 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.

🔊To hear BonicBot’s greetings and checkout confirmations, make sure the machine running this script has a working audio output device. 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

  • Importstime, csv, pickle, threading, queue, subprocess, importlib, os, wave, and io handle data storage, multithreading, audio synthesis, and system operations; cv2 and numpy process images; sounddevice plays audio; PiperVoice generates speech; BonicBot and BonicBotError control the robot bridge.
  • Config constants (PIPER_VOICE, FACE_DB_PATH, LOG_PATH, etc.) — Set voice models, threshold levels (ArcFace cosine similarity 0.30), HSV badge bounds (green 3585, blue 100130), session timeout duration (30.0s), and thread polling windows (FACE_MATCH_WINDOW = 10.0s).
  • Thread-safe frame access (set_latest_frame, get_latest_frame) — Uses threading.Lock() so the main camera loop and the background face recognition thread can share the latest frame without race conditions or camera hardware contention.
  • VoiceSpeaker class — A non-blocking TTS worker. Receives speech strings via a queue.Queue(), synthesizes WAV audio using Piper in a background thread (_run), and streams audio through sounddevice.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_busy event 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) — Reads face_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 using DeepFace.represent, normalizes it, and calculates the dot product against all enrolled member vectors in face_db to 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 (session dictionary guarded by session_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 to FACE_MATCH_WINDOW (10s), tracking the highest match score seen. If the best score clears FACE_MATCH_THRESHOLD (0.30), it opens a session and speaks a greeting; otherwise, it announces an unrecognized visitor notice and clears busy.

Part 4 — Checkout/Return Logic & Attendance Log

  • rebuild_state() — Reads checkout_log.csv at startup to reconstruct current checked-out items and per-member item lists from past transactions.
  • log_event(action, item_id, name) — Appends timestamped checkout, return, or release records to checkout_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_worker in a background thread if busy is 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.
  • Overlay rendering & cleanup — Renders active user info and badge target rectangle on the live OpenCV window ("Lab Checkout") and exits cleanly when q is 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,-,Alice

If 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() calls bot.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() is False), main() sets busy and hands the job to face_worker() on a background daemon thread. That thread can poll for up to FACE_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() calls get_latest_frame(), which reads the most recent frame the main loop already captured. latest_frame and frame_lock exist specifically so two threads can safely share one captured frame instead of two threads pulling from the camera hardware at once.
  • busy guarantees only one verification attempt happens at a time. As long as busy is set, the main loop won’t start a second face_worker, even if the badge is still held up.
  • session_lock protects shared session state. Both the main thread (badge/QR handling) and the background thread (face_worker, on success) read and write the same session dictionary, 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.

Last updated on