Skip to Content
BonicBot A2DevelopmentArtificial IntelligenceLesson 14: Teaching BonicBot to Describe What It Sees

Lesson 14: Teaching BonicBot to Describe What It Sees

Learning Objective

Combine everything from the vision lessons and the voice lessons into one program: BonicBot watches through its camera, recognizes objects, and — instead of just naming them — asks its local AI brain to describe each one out loud in a friendly sentence.


Introduction

Back in the object detection lessons, BonicBot learned to spot things in front of it and label them — “bottle,” “chair,” “person.” That’s useful, but it’s not very conversational. A label doesn’t teach you anything new.

In this lesson, we connect BonicBot’s eyes to its brain. Every time BonicBot confidently recognizes a new object, it doesn’t just draw a box around it — it sends the object’s name to its local language model and asks for a short, interesting description, which it then speaks out loud.

The flow, every frame, looks like this:

camera -> object detections -> pick the most confident one -> confirm it's real (seen consistently for a few frames) -> if it's a NEW object, ask the local LLM to describe it -> speak the description and show it on screen

Two ideas from earlier lessons come together here:

  1. Debouncing, the same trick used to confirm ArUco markers and gestures — BonicBot waits until an object has been seen for several frames in a row before trusting it, so it doesn’t chatter about something that flickered into view for half a second.
  2. The local LLM, the same Ollama-powered “brain” from the voice assistant lessons — except now its job is simply to turn a single word like "bottle" into a sentence like “A bottle is a container that holds liquids, and its shape helps keep whatever’s inside from spilling!”

Because asking the LLM takes a moment to think, that request runs on a background thread, so BonicBot’s camera preview never freezes up while it’s composing its sentence.


Setup: Installing Packages

Before running the code, make sure your computer has the required Python packages and system libraries installed. Open a terminal and run:

pip install bonicbot-bridge opencv-python numpy ollama sounddevice piper-tts

What each package / tool does

Package / ToolPurpose
bonicbot-bridgeThe official BonicBot SDK. Provides BonicBot for camera streaming (bot.start_camera(), bot.get_image()) and object detection (bot.enable_detection("yolo"), bot.get_detections()).
opencv-pythonImported as cv2. Handles video frame rendering (cv2.imshow), bounding box drawing (draw_detections), and translucent description bar overlays (draw_description_bar).
piper-ttsFast, local neural Text-to-Speech (TTS) engine. Synthesizes voice WAV audio streams for detected object descriptions.
ollamaPython client for local LLMs. Sends detected object names to qwen3:0.6b to generate one-sentence descriptions.
sounddeviceImported as sd. Plays synthesized audio buffers live through your system speakers.
numpyImported as np. Used for image matrix manipulation and audio array conversions.

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 ollama sounddevice piper-tts

How to run the program

  1. Install Ollama (one-time setup):
    • Windows / Mac: Download and run the installer from ollama.com/download .
    • Linux: Run curl -fsSL https://ollama.com/install.sh | sh in your terminal.
  2. Install PortAudio (system dependency for audio playback):
    • Linux: sudo apt install portaudio19-dev
    • macOS: brew install portaudio
  3. Pull the LLM model:
    ollama pull qwen3:0.6b
  4. Update IP address: Find your BonicBot’s IP address and set HOST = '[IP_ADDRESS]' (or 'localhost' for simulation).
  5. Save the code into a file, e.g., lesson14_object_describer.py.
  6. Run the script:
    python lesson14_object_describer.py
  7. Point BonicBot’s camera at various objects (bottle, chair, keyboard, laptop).
  8. When an object is held steadily for 12 consecutive frames with >80% confidence, BonicBot will query Ollama in the background, speak a custom description aloud, and render subtitles on the camera window.
  9. Press ‘q’ in the video window to exit.

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 lesson using the ROS 2 simulation environment:

  1. Launch the BonicBot simulation:

    ros2 launch my_bot robot_system.launch.py use_sim_time:=true world:=obsworld.sdf use_real_camera:=True
  2. When HOST = 'localhost', BonicBot connects to the simulated camera feed and runs YOLO object detection, background LLM description generation, and Piper TTS audio output seamlessly.


Code

Click to view the complete program

import re import shutil import subprocess import sys import threading import time import difflib from collections import Counter from pathlib import Path import numpy as np import ollama import pyaudio import speech_recognition as sr from faster_whisper import WhisperModel from piper import PiperVoice, SynthesisConfig from piper.download_voices import download_voice # NOTE: this assumes your BonicBot control class lives here and exposes # move_left_arm() / move_right_arm() / look_left() / look_right() / # look_center() as described below. Swap this import (and the # `bot = BonicBot()` line in main()) for whatever actually creates your # `bot` object if it's different. from bonicbot_bridge import BonicBot HOST = "[IP_ADDRESS]" OLLAMA_MODEL = "qwen3:0.6b" DEFAULT_VOICE = "en_US-lessac-medium" PIPER_VOICES_DIR = Path(__file__).resolve().parent / "piper_voices" PIPER_LENGTH_SCALE = 0.8 PIPER_VOLUME = 1.0 WHISPER_MODEL_SIZE = "small.en" WHISPER_BEAM_SIZE = 1 SERVER_START_TIMEOUT = 15.0 SERVER_POLL_INTERVAL = 0.5 LISTEN_TIMEOUT = 15.0 PHRASE_TIME_LIMIT = 25.0 PAUSE_THRESHOLD = 0.6 NON_SPEAKING_DURATION = 0.4 ENERGY_THRESHOLD_CEILING = 4000 MAX_HISTORY_MESSAGES = 9 REPEAT_SIMILARITY_THRESHOLD = 0.85 SHORT_UTTERANCE_WORD_LIMIT = 6 EXIT_WORDS = {"goodbye", "bye", "stop", "exit", "quit"} DEFAULT_PERSONA = "buddy" # ---------------------------------------------------------------------- # GESTURE CONFIG # ---------------------------------------------------------------------- GESTURES_ENABLED = True # set False to run with no robot body attached # Shoulder/elbow rest position, in degrees, shared by every persona. ARM_REST_SHOULDER = 0 ARM_REST_ELBOW = 0 # Fallback gesture values used for any persona that doesn't override them. DEFAULT_GESTURES = { "forward_shoulder": 35, # "explaining" pose held while BonicBot talks "forward_elbow": 15, "thinking_style": "bounce", # neck animation played while the LLM works "thinking_pause": 0.7, # seconds between each step of that animation "greeting_style": "wave", # one-off gesture played before the greeting line } THINKING_GESTURE_MIN_PAUSE = 0.3 # sanity floor so a bad persona config can't spin the neck servo SAFETY_SUFFIX = ( " Always answer the student's actual question directly and specifically " "in your very first sentence — never substitute a catchphrase, a " "reintroduction of yourself, or a repeat of an earlier reply for a real " "answer, and never reuse the same sentence twice in a row. No matter " "which persona you are playing, keep replies short — one to two " "sentences, meant to be spoken out loud. Never claim to be a real human " "or to have real feelings, never pretend the roleplay is literally true, " "and never give unsafe, harmful, illegal, or otherwise inappropriate " "advice. Stay in character, but stay safe and honest — if you don't know " "something, say so instead of guessing." ) PERSONAS = { "buddy": { "display_name": "Best Friend", "aliases": ["buddy", "best friend", "bestie", "friend", "study buddy", "pal", "bro"], "base_prompt": ( "You are BonicBot playing the user's chill, upbeat best friend and " "study buddy — casual, warm, and full of energy, like a friend " "hyping you up between classes. Use relaxed, casual language and " "light slang ('let's go', 'you got this', 'no worries'), keep it " "short and punchy, and always answer the actual question clearly " "and correctly before adding any hype." ), "greeting": ( "Heyyy, it's your buddy BonicBot! Ready to figure stuff out " "together — what's up?" ), "voice": "en_US-amy-medium", # Energetic, quick movements: a fast arm wave hello, a snappy # left-right neck bounce while thinking, and a bigger arm-forward # pose while talking — like a friend who talks with their hands. "gestures": { "forward_shoulder": 50, "forward_elbow": 25, "thinking_style": "bounce", "thinking_pause": 0.4, "greeting_style": "wave", }, }, "teacher": { "display_name": "Strict Professor", "aliases": ["teacher", "professor", "strict teacher", "prof", "sir", "principal"], "base_prompt": ( "You are BonicBot playing a strict, no-nonsense professor — " "formal, precise, and impatient with vague answers. Speak " "crisply and seriously, address the student as 'student', and " "treat every question like it might appear on an exam, but " "always give the actual correct answer first, clearly, with zero " "fluff." ), "greeting": ( "Attention. This is Professor BonicBot. State your question " "clearly, and I shall answer precisely." ), "voice": "en_US-lessac-medium", # Slower, more restrained movements: one deliberate look around the # room before speaking, a slow single-side "considering" tilt while # thinking, and a smaller, stiffer arm-forward pose — like someone # pointing at a chalkboard rather than gesturing broadly. "gestures": { "forward_shoulder": 20, "forward_elbow": 10, "thinking_style": "tilt", "thinking_pause": 1.0, "greeting_style": "survey", }, }, } SWITCH_TRIGGERS = [ "switch persona to", "switch to", "become a", "become the", "become", "be a ", "be the ", "turn into a", "turn into the", "turn into", "change persona to", "change to", "act like a", "act like the", "act like", ] LIST_TRIGGERS = [ "what personas", "which personas", "list personas", "who can you be", "what characters", "what other personas", "what personalities", ] SWITCH_INTENT_WORDS = ("change", "become", "switch", "turn", "act", " be ") EMOJI_PATTERN = re.compile( "[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF" "\U0001F900-\U0001F9FF\U00002190-\U000021FF\U00002B00-\U00002BFF]+", flags=re.UNICODE, ) def clean_for_speech(text): text = EMOJI_PATTERN.sub("", text) text = re.sub(r"\*\*(.*?)\*\*", r"\1", text) text = re.sub(r"\*(.*?)\*", r"\1", text) text = re.sub(r"`(.*?)`", r"\1", text) text = re.sub(r"#+\s*", "", text) text = re.sub(r"[*_~]", "", text) return re.sub(r"\s+", " ", text).strip() def ensure_ollama_installed(): if shutil.which("ollama") is None: print("Install Ollama from https://ollama.com/download, then re-run this script.") sys.exit(1) def ensure_ollama_running(): try: ollama.list() return except Exception: pass subprocess.Popen(["ollama", "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) start = time.time() while time.time() - start < SERVER_START_TIMEOUT: try: ollama.list() return except Exception: time.sleep(SERVER_POLL_INTERVAL) def is_model_available(model_name): try: ollama.show(model_name) return True except Exception: return False def ensure_model_pulled(model_name): if is_model_available(model_name): return print(f"Downloading model '{model_name}'...") last_status = None for progress in ollama.pull(model_name, stream=True): status = progress.get("status", "") if status and status != last_status: print(f" {status}") last_status = status def ensure_piper_voice_ready(voice_name, voices_dir): voices_dir.mkdir(parents=True, exist_ok=True) model_path = voices_dir / f"{voice_name}.onnx" config_path = voices_dir / f"{voice_name}.onnx.json" if model_path.exists() and config_path.exists(): return model_path, config_path print(f"Downloading voice '{voice_name}'...") try: download_voice(voice_name, voices_dir) except Exception as e: print(f"Couldn't download voice: {e}") sys.exit(1) return model_path, config_path def load_persona_voice(persona_key, voice_cache): voice_name = PERSONAS[persona_key].get("voice", DEFAULT_VOICE) if voice_name not in voice_cache: model_path, config_path = ensure_piper_voice_ready(voice_name, PIPER_VOICES_DIR) voice_cache[voice_name] = PiperVoice.load(model_path, config_path=config_path) return voice_cache[voice_name] def speak(piper_voice, pyaudio_instance, text): print(f"BonicBot: {text}") speech_text = clean_for_speech(text) syn_config = SynthesisConfig(length_scale=PIPER_LENGTH_SCALE, volume=PIPER_VOLUME) stream = None try: for chunk in piper_voice.synthesize(speech_text, syn_config=syn_config): if stream is None: stream = pyaudio_instance.open( format=pyaudio_instance.get_format_from_width(chunk.sample_width), channels=chunk.sample_channels, rate=chunk.sample_rate, output=True, ) stream.write(chunk.audio_int16_bytes) finally: if stream is not None: stream.stop_stream() stream.close() NO_SPEECH_PROB_THRESHOLD = 0.82 AVG_LOGPROB_THRESHOLD = -1.5 HALLUCINATION_MAX_REPEAT = 3 HALLUCINATION_MIN_CHARS = 20 MIN_SPEECH_DURATION = 0.3 def _looks_like_hallucination(text): if len(text) < HALLUCINATION_MIN_CHARS: return False sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()] if len(sentences) < 4: return False counts = Counter(sentences) if counts.most_common(1)[0][1] >= HALLUCINATION_MAX_REPEAT: return True return len(counts) / len(sentences) < 0.5 def listen(recognizer, mic, whisper_model): with mic as source: print("\n🎤 Listening... (say 'goodbye' to quit, or 'who can you be?')") try: audio = recognizer.listen(source, timeout=LISTEN_TIMEOUT, phrase_time_limit=PHRASE_TIME_LIMIT) except sr.WaitTimeoutError: return None audio_np = np.frombuffer(audio.get_raw_data(), dtype=np.int16).astype(np.float32) / 32768.0 segments, _ = whisper_model.transcribe( audio_np, language="en", beam_size=WHISPER_BEAM_SIZE, vad_filter=True, vad_parameters={"min_silence_duration_ms": 500, "threshold": 0.35, "speech_pad_ms": 300}, ) kept = [] speech_duration = 0.0 for seg in segments: if seg.no_speech_prob is not None and seg.no_speech_prob > NO_SPEECH_PROB_THRESHOLD: continue if seg.avg_logprob is not None and seg.avg_logprob < AVG_LOGPROB_THRESHOLD: continue kept.append(seg.text) speech_duration += seg.end - seg.start text = " ".join(kept).strip() if not text or speech_duration < MIN_SPEECH_DURATION or _looks_like_hallucination(text): return None print(f"You said: {text}") return text LLM_OPTIONS = { "temperature": 0.6, "top_p": 0.9, "top_k": 40, "repeat_penalty": 1.4, "repeat_last_n": 48, "num_predict": 100, } def ask_llm(history): try: response = ollama.chat(model=OLLAMA_MODEL, messages=history, think=False, options=LLM_OPTIONS) except ollama.ResponseError as e: if e.status_code == 404: ensure_model_pulled(OLLAMA_MODEL) return ask_llm(history) raise return response["message"]["content"].strip() def ask_llm_in_background(history, result): """Run ask_llm() on a worker thread and stash the outcome in `result` (a plain dict) so the main thread can pick it up once ready — this is what lets the thinking gesture play concurrently with the actual LLM call instead of after it.""" try: result["reply"] = ask_llm(history) except Exception as e: result["error"] = e def trim_history(history): if len(history) > MAX_HISTORY_MESSAGES: history = [history[0]] + history[-(MAX_HISTORY_MESSAGES - 1):] return history def is_repeat_question(current_text, previous_text): if not previous_text: return False a, b = current_text.strip().lower(), previous_text.strip().lower() return difflib.SequenceMatcher(None, a, b).ratio() >= REPEAT_SIMILARITY_THRESHOLD def build_system_message(persona_key): persona = PERSONAS[persona_key] return {"role": "system", "content": persona["base_prompt"] + SAFETY_SUFFIX} def get_persona_gestures(persona_key): """Merge a persona's gesture overrides on top of DEFAULT_GESTURES, so any persona that's added later without a "gestures" block still gets sane, working values instead of a KeyError.""" gestures = dict(DEFAULT_GESTURES) gestures.update(PERSONAS[persona_key].get("gestures", {})) gestures["thinking_pause"] = max(gestures["thinking_pause"], THINKING_GESTURE_MIN_PAUSE) return gestures def find_persona_by_fragment(fragment): fragment = fragment.strip(" .!?,") if not fragment: return None for key, persona in PERSONAS.items(): for alias in [key] + persona["aliases"]: if alias in fragment or fragment in alias: return key return None def try_match_switch_command(text_lower): for trigger in SWITCH_TRIGGERS: idx = text_lower.find(trigger) if idx != -1: match = find_persona_by_fragment(text_lower[idx + len(trigger):]) if match: return match words = text_lower.split() if any(word in text_lower for word in SWITCH_INTENT_WORDS) or len(words) <= SHORT_UTTERANCE_WORD_LIMIT: for key, persona in PERSONAS.items(): for alias in [key] + persona["aliases"]: if alias in text_lower: return key return None def is_list_command(text_lower): return any(trigger in text_lower for trigger in LIST_TRIGGERS) def speak_persona_list(piper_voice, pyaudio_instance): names = ", ".join(p["display_name"] for p in PERSONAS.values()) speak(piper_voice, pyaudio_instance, f"I can be: {names}. Just say something like 'become a pirate'.") # ============================================================================ # ROBOT GESTURES # ============================================================================ def reset_arms_to_rest(bot): """Move both arms back to their resting position.""" if not GESTURES_ENABLED or bot is None: return try: bot.move_left_arm(ARM_REST_SHOULDER, ARM_REST_ELBOW, wait=False) bot.move_right_arm(ARM_REST_SHOULDER, ARM_REST_ELBOW, wait=True) except Exception as e: print(f"⚠️ Couldn't reset arms to rest: {e}") def center_head(bot): """Center the head — done right before BonicBot starts speaking.""" if not GESTURES_ENABLED or bot is None: return try: bot.look_center() except Exception as e: print(f"⚠️ Couldn't center head: {e}") def push_arms_forward(bot, shoulder, elbow): """Move both arms into a persona's 'explaining' pose once and hold it there — no waving. Called synchronously, blocking, right before speak() starts. Angles are passed in per-persona (see get_persona_gestures).""" if not GESTURES_ENABLED or bot is None: return try: bot.move_left_arm(shoulder, elbow, wait=False) bot.move_right_arm(shoulder, elbow, wait=True) except Exception as e: print(f"⚠️ Couldn't push arms forward: {e}") def thinking_gesture_loop(bot, stop_event, style, pause): """Play a persona-specific neck animation until stop_event is set — this is what plays while the LLM is generating its reply in the background. Runs on its own thread; always leaves the neck centered when it exits, even if something above it goes wrong. style="bounce": quick, repeated left-right turns (energetic personas). style="tilt": a single slow look to one side, back to center, repeat (deliberate/serious personas) — same neck API, just a calmer cadence.""" if not GESTURES_ENABLED or bot is None: return try: while not stop_event.is_set(): if style == "bounce": bot.look_left() if stop_event.wait(pause): break bot.look_right() if stop_event.wait(pause): break else: # "tilt" or any unrecognized style falls back to this calmer version bot.look_left() if stop_event.wait(pause): break bot.look_center() if stop_event.wait(pause): break except Exception as e: print(f"⚠️ Thinking gesture skipped: {e}") finally: try: bot.look_center() except Exception as e: print(f"⚠️ Couldn't re-center neck: {e}") def play_greeting_gesture(bot, style): """One-off gesture played right before a persona's greeting line — this is what makes switching persona feel like meeting someone new instead of just hearing a different voice. style="wave": two quick shoulder bobs on the left arm. Uses only the shoulder axis (not the elbow) since the elbow servo doesn't reliably track commanded positions on this rig — see the diagnostic notes referenced in the base script. style="survey": one slow look left, then right, then back to center — like someone sizing up a room before addressing it.""" if not GESTURES_ENABLED or bot is None: return try: if style == "wave": for _ in range(2): bot.move_left_arm(60, ARM_REST_ELBOW, wait=True) bot.move_left_arm(20, ARM_REST_ELBOW, wait=True) bot.move_left_arm(ARM_REST_SHOULDER, ARM_REST_ELBOW, wait=True) elif style == "survey": bot.look_left() time.sleep(0.6) bot.look_right() time.sleep(0.6) bot.look_center() except Exception as e: print(f"⚠️ Greeting gesture skipped: {e}") def main(): ensure_ollama_installed() ensure_ollama_running() ensure_model_pulled(OLLAMA_MODEL) pyaudio_instance = pyaudio.PyAudio() voice_cache = {} current_persona = DEFAULT_PERSONA piper_voice = load_persona_voice(current_persona, voice_cache) history = [build_system_message(current_persona)] last_user_text = None bot = BonicBot(host=HOST) if GESTURES_ENABLED else None if bot is not None: reset_arms_to_rest(bot) # known starting position center_head(bot) print("Loading Whisper model...") whisper_model = WhisperModel(WHISPER_MODEL_SIZE, device="cpu", compute_type="int8") recognizer = sr.Recognizer() recognizer.pause_threshold = PAUSE_THRESHOLD recognizer.non_speaking_duration = NON_SPEAKING_DURATION recognizer.dynamic_energy_threshold = True mic = sr.Microphone(sample_rate=16000) print("Calibrating microphone, please stay quiet...") with mic as source: recognizer.adjust_for_ambient_noise(source, duration=1.0) if recognizer.energy_threshold > ENERGY_THRESHOLD_CEILING: recognizer.energy_threshold = ENERGY_THRESHOLD_CEILING play_greeting_gesture(bot, get_persona_gestures(current_persona)["greeting_style"]) speak(piper_voice, pyaudio_instance, PERSONAS[current_persona]["greeting"]) try: while True: user_text = listen(recognizer, mic, whisper_model) if user_text is None: speak(piper_voice, pyaudio_instance, "Sorry, I didn't catch that. Could you say it again?") continue text_lower = re.sub(r"[^\w\s]", "", user_text.strip().lower()) if text_lower in EXIT_WORDS: speak(piper_voice, pyaudio_instance, "Goodbye! It was nice talking with you.") break if is_list_command(text_lower): speak_persona_list(piper_voice, pyaudio_instance) continue matched_persona = try_match_switch_command(text_lower) if matched_persona: if matched_persona == current_persona: speak(piper_voice, pyaudio_instance, f"I'm already your {PERSONAS[current_persona]['display_name']}!") else: current_persona = matched_persona piper_voice = load_persona_voice(current_persona, voice_cache) history = [build_system_message(current_persona)] last_user_text = None reset_arms_to_rest(bot) play_greeting_gesture(bot, get_persona_gestures(current_persona)["greeting_style"]) speak(piper_voice, pyaudio_instance, PERSONAS[current_persona]["greeting"]) continue if is_repeat_question(user_text, last_user_text): turn_content = ( f"{user_text}\n\n[The user just asked this again — your previous answer was " "likely wrong, unclear, or not what they needed. Do not repeat it. If you're " "not confident of the correct answer, say so honestly instead of guessing " "again, while staying in character.]" ) else: turn_content = user_text history.append({"role": "user", "content": turn_content}) last_user_text = user_text gestures = get_persona_gestures(current_persona) # Play the persona's thinking gesture and run the LLM call at # the same time, so the wait isn't dead air on the robot side. stop_thinking = threading.Event() gesture_thread = threading.Thread( target=thinking_gesture_loop, args=(bot, stop_thinking, gestures["thinking_style"], gestures["thinking_pause"]), daemon=True, ) gesture_thread.start() llm_result = {} llm_thread = threading.Thread( target=ask_llm_in_background, args=(history, llm_result), daemon=True ) llm_thread.start() llm_thread.join() stop_thinking.set() gesture_thread.join() if "error" in llm_result: raise llm_result["error"] reply = llm_result["reply"] history.append({"role": "assistant", "content": reply}) history = trim_history(history) center_head(bot) push_arms_forward(bot, gestures["forward_shoulder"], gestures["forward_elbow"]) speak(piper_voice, pyaudio_instance, reply) reset_arms_to_rest(bot) except KeyboardInterrupt: pass finally: pyaudio_instance.terminate() if __name__ == "__main__": main()

Before running the program, make sure the two non-Python pieces from earlier lessons are in place (one-time setup):

  • Ollama: download and run the installer from ollama.com/download  (Windows/Mac), or run curl -fsSL https://ollama.com/install.sh | sh on Linux.
  • PortAudio (needed by PyAudio to record and play sound): Mac users run brew install portaudio; Linux users run sudo apt install portaudio19-dev. Windows usually needs nothing extra.

Also install the required Python packages before running:

pip install ollama SpeechRecognition piper-tts pyaudio --break-system-packages

The first time each persona’s voice is used, it will be downloaded automatically into a piper_voices folder next to the script — most personas share one voice, so only the Shakespearean Bard triggers a second download.

Code Walkthrough

Line-by-line explanation

  • Imports & Configuration (lines 60–103) — Imports cv2, ollama, sd, PiperVoice, and BonicBot. Configures thresholds (DETECTION_CONFIDENCE_THRESHOLD = 0.50, ANNOUNCE_CONFIDENCE_THRESHOLD = 0.80, DESCRIBE_HOLD_FRAMES = 12) and defines SYSTEM_PROMPT instructing Ollama to describe objects in one simple, friendly sentence.
  • Ollama & Piper Setup (lines 107–165)ensure_ollama_running() starts ollama serve if inactive; ensure_model_pulled() pulls qwen3:0.6b; ensure_piper_voice_downloaded() fetches ONNX voice models.
  • Object Debouncing Tracker (lines 169–212)ObjectTracker implements temporal hysteresis. Requires DESCRIBE_HOLD_FRAMES consecutive detections above ANNOUNCE_CONFIDENCE_THRESHOLD before confirming an object, ignoring short flickers via MISS_TOLERANCE_FRAMES.
  • Async Background LLM Worker (lines 216–300)DescriptionWorker manages a background thread and a 1-element queue (queue.Queue(maxsize=1)). _ask_llm() queries Ollama asynchronously, while _speak() synthesizes WAV audio with Piper and streams it via sounddevice, ensuring the main camera loop never stutters.
  • Visual Overlays (lines 302–330)draw_detections() renders bounding boxes and class labels; draw_description_bar() overlays a translucent banner displaying live subtitles at the bottom of the video window.
  • Main Camera & Inference Loop (lines 333–391) — Connects to BonicBot, enables YOLO detection, streams camera frames, debounces detected objects, triggers background LLM descriptions for new confirmed items, and renders real-time visual output until 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:

✅ Model 'qwen2.5:0.5b' is already downloaded. 📷 Starting camera and streaming... 🚀 Starting object detection mode... ✅ Live Stream Active. Press 'q' to quit. 🔎 New object confirmed: bottle — asking the LLM... 🔎 New object confirmed: chair — asking the LLM...

Whenever BonicBot confirms a new object, it will:

  • Print that the object was confirmed and that it’s asking the local model
  • Speak a short, friendly description of the object out loud
  • Show the description in a translucent bar along the bottom of the video window, so it’s readable even without sound

The BonicBot Vision Window will also keep showing green bounding boxes and confidence percentages around every currently detected object, just like in the earlier detection lessons.


🔧 Under the Hood

Why does the description run on a background thread?

Asking the local LLM for a sentence takes a moment — even a small model needs some time to “think.” If BonicBot asked the question and waited right there in the main camera loop, the video preview would freeze for that whole time, which feels broken and laggy.

Instead, DescriptionWorker runs on its own background thread with a small queue:

def describe(self, class_name): try: self._queue.put_nowait(class_name) except queue.Full: pass

The main loop just drops a class name into the queue and immediately moves on to display the next camera frame. The background thread picks up that request whenever it’s ready, asks the LLM, and speaks the result — all without ever blocking the video.

Notice also that the queue has maxsize=1, and a full queue is simply ignored rather than causing an error. That’s intentional: if BonicBot is still describing “bottle” when a “chair” shows up, we don’t want dozens of old requests piling up — we only care about describing what’s in front of the camera right now.

The ObjectTracker class plays the same debouncing role you’ve seen before with markers and gestures: an object only counts as “confirmed” after being seen for DESCRIBE_HOLD_FRAMES frames in a row, and a brief flicker (up to MISS_TOLERANCE_FRAMES missed frames) doesn’t reset the count. This keeps BonicBot from rambling about an object that only appeared for a fraction of a second.


Student Challenge

Modify the program so BonicBot’s descriptions match a specific audience or style.

For example:

  • Change SYSTEM_PROMPT so descriptions are aimed at a much younger audience, using very simple words.
  • Make BonicBot describe objects as if it were amazed to see them for the very first time.
  • Add a rule so BonicBot always ends its description with a fun question, like “Have you ever wondered how that works?”

Try running the program in a room with a few different objects and see how BonicBot’s descriptions change.

Hint

You only need to edit the text inside SYSTEM_PROMPT — everything else in the pipeline stays exactly the same:

SYSTEM_PROMPT = ( "You are BonicBot's vision assistant, talking to a curious 6-year-old. " "Describe the detected object in ONE short, simple, exciting sentence, " "and end with a fun question about it." )

Reflection Question

This program combines two separate AI systems — an object detector and a language model — that don’t actually know anything about each other. What are the risks of trusting a description generated this way, especially if the object detector gets the object’s name wrong in the first place?

Last updated on