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 screenTwo ideas from earlier lessons come together here:
- 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.
- 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-ttsWhat each package / tool does
| Package / Tool | Purpose |
|---|---|
bonicbot-bridge | The 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-python | Imported as cv2. Handles video frame rendering (cv2.imshow), bounding box drawing (draw_detections), and translucent description bar overlays (draw_description_bar). |
piper-tts | Fast, local neural Text-to-Speech (TTS) engine. Synthesizes voice WAV audio streams for detected object descriptions. |
ollama | Python client for local LLMs. Sends detected object names to qwen3:0.6b to generate one-sentence descriptions. |
sounddevice | Imported as sd. Plays synthesized audio buffers live through your system speakers. |
numpy | Imported 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-ttsHow to run the program
- 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 | shin your terminal.
- Install PortAudio (system dependency for audio playback):
- Linux:
sudo apt install portaudio19-dev - macOS:
brew install portaudio
- Linux:
- Pull the LLM model:
ollama pull qwen3:0.6b - Update IP address: Find your BonicBot’s IP address and set
HOST = '[IP_ADDRESS]'(or'localhost'for simulation). - Save the code into a file, e.g.,
lesson14_object_describer.py. - Run the script:
python lesson14_object_describer.py - Point BonicBot’s camera at various objects (bottle, chair, keyboard, laptop).
- 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.
- 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:
-
Launch the BonicBot simulation:
ros2 launch my_bot robot_system.launch.py use_sim_time:=true world:=obsworld.sdf use_real_camera:=True -
When
HOST = 'localhost',BonicBotconnects 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 | shon Linux. - PortAudio (needed by PyAudio to record and play sound): Mac users run
brew install portaudio; Linux users runsudo 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-packagesThe 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) — Importscv2,ollama,sd,PiperVoice, andBonicBot. Configures thresholds (DETECTION_CONFIDENCE_THRESHOLD = 0.50,ANNOUNCE_CONFIDENCE_THRESHOLD = 0.80,DESCRIBE_HOLD_FRAMES = 12) and definesSYSTEM_PROMPTinstructing Ollama to describe objects in one simple, friendly sentence. - Ollama & Piper Setup (
lines 107–165) —ensure_ollama_running()startsollama serveif inactive;ensure_model_pulled()pullsqwen3:0.6b;ensure_piper_voice_downloaded()fetches ONNX voice models. - Object Debouncing Tracker (
lines 169–212) —ObjectTrackerimplements temporal hysteresis. RequiresDESCRIBE_HOLD_FRAMESconsecutive detections aboveANNOUNCE_CONFIDENCE_THRESHOLDbefore confirming an object, ignoring short flickers viaMISS_TOLERANCE_FRAMES. - Async Background LLM Worker (
lines 216–300) —DescriptionWorkermanages 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 viasounddevice, 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 toBonicBot, enables YOLO detection, streams camera frames, debounces detected objects, triggers background LLM descriptions for new confirmed items, and renders real-time visual output untilqis 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:
passThe 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_PROMPTso 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?