Camera, AI Detection, and IMU¶
Access the OAK-D Lite camera, AI inference, and IMU sensors through the pib3 API.
Objectives¶
By the end of this tutorial, you will:
- Stream camera images from the OAK-D Lite
- Run AI models on the camera: object detection, body pose, hand landmarks
- Switch models, and run several at once
- Access IMU accelerometer and gyroscope data
- Understand how models are shared between clients
Prerequisites¶
- pib3 installed:
pip install "pib3 @ git+https://github.com/mamrehn/pib3.git" - A PIB robot with OAK-D Lite camera connected
- Rosbridge running on the robot (port 9090)
- The models in the robot's model store; for the YOLO26 models of this tutorial see the note in AI & Camera Subsystems
Key Concept: Models Run on Request, and Are Shared¶
The camera node of the robot runs AI models in its OAK-D Lite on request:
- Image and IMU streams only run while someone is subscribed.
- A model runs from
/start_modeluntil the last client that asked for it calls/stop_model. Each client names itself (the owner), so two scripts, cerebra and the web programs can use the same model without switching it away from each other. - Every start or stop rebuilds the camera pipeline: video and IMU pause for a few seconds.
- Depth is the camera's resting state. It runs while no model runs and is gone while one does, because both need the camera's cores.
robot.ai does the bookkeeping: robot.ai.set_model(...) starts a model and releases the others this client started, robot.ai.stop() releases everything (it also runs when the with block ends).
# Image stream: subscribe -> frames arrive, unsubscribe -> they stop
sub = robot.subscribe_camera_image(callback)
sub.unsubscribe()
# A model: start -> results arrive, stop -> the robot may free the cores
robot.ai.set_model(AIModel.YOLO26S)
robot.ai.stop()
Quick Start: Subsystem API (Recommended)¶
For most use cases, use the simplified subsystem APIs instead of raw subscriptions. The subsystems automatically manage subscriptions and provide typed results.
AI Subsystem (robot.ai)¶
from pib3 import Robot, AIModel
with Robot(host="192.168.178.71") as robot:
# Set AI model (waits for confirmation)
robot.ai.set_model(AIModel.YOLO26S)
# Get detections (waits automatically for results)
for det in robot.ai.get_detections(latest_only=True):
print(f"{det.label}: {det.confidence:.0%} at {det.bbox.center}")
# Check performance
print(f"FPS: {robot.ai.fps:.1f}")
Hand Tracking with Servo Control¶
from pib3 import Robot, AIModel
with Robot(host="192.168.178.71") as robot:
robot.ai.set_model(AIModel.HAND)
for hand in robot.ai.get_hand_landmarks(latest_only=True):
print(f"{hand.handedness}: index={hand.finger_angles.index:.0f}°")
# Convert finger angles to robot servo percentages
servos = hand.finger_angles.to_servo_values()
# Mirror to robot hand
if hand.handedness.value == "left":
robot.set_joints({
"index_left_stretch": servos["index"],
"middle_left_stretch": servos["middle"],
})
Camera Subsystem (robot.camera)¶
from pib3 import Robot
with Robot(host="192.168.178.71") as robot:
# Get latest frame
frame = robot.camera.get_frame()
if frame:
print(f"Frame {frame.frame_id}: {len(frame.jpeg_bytes)} bytes")
# Decode to numpy (requires OpenCV)
img = frame.to_numpy()
# Configure camera
robot.camera.configure(fps=10, quality=80)
API Reference
For complete details on subsystem methods and types, see the AI & Camera Subsystems API reference.
The Same Code in Simulation¶
The simulated pib has a camera in its head, so sim.camera and sim.ai
offer the same contract as above. Because the head carries the camera, turning
the head changes what the robot sees — visual servoing in simulation is a real
closed loop, not a controller pushing against a static picture.
import pib3
from pib3 import Joint
with pib3.Webots() as sim: # inside a Webots controller
sim.ai.set_model("recognition") # the default source
while sim.step(): # step() renders the next frame
img = sim.camera.get_frame().to_numpy() # BGR, same as the robot
for det in sim.ai.get_detections():
x, _ = det.bbox.center
sim.set_joint(Joint.TURN_HEAD, 50 + 60 * (x - 0.5), async_=True)
Four differences from the real robot:
| Real robot | Simulation | |
|---|---|---|
| Frame source | MJPEG over rosbridge, buffered | rendered per step, one frame cached |
| Loop driver | frames arrive on their own | you must call sim.step() |
| Inference | on the OAK-D's accelerator | host CPU/GPU, or ground truth |
| Hand / pose | on-device models | needs pib3[sim]; see below |
A perception loop must call sim.step()
Motion calls step the simulator internally, but a loop that only reads does
not. Without a step the camera keeps handing back the same frame and the
loop spins on stale data. sim.step() returns False on shutdown, so it
reads naturally as the loop condition.
The same rule bites once more at startup: set_model() only takes effect
on the following step. Enabling a Webots sensor never yields data in the
same step, so a get_detections() placed immediately after set_model()
returns an empty list — and since results are cached per frame, that
emptiness persists for the current frame. Step first, then read:
sim.ai.set_model("recognition")
sim.step() # <- without this the first read is empty
detections = sim.ai.get_detections()
In the while sim.step(): loop above this is automatic. It only shows up
in straight-line scripts.
Choosing a perception source¶
sim.ai.set_model() picks between simulator ground truth and a real network:
"recognition"(default) — Webots reports objects directly: exact boxes,confidencealways1.0, no model and no inference cost. Ideal for teaching the downstream logic (debouncing, state machines, control) without perception noise in the way.AIModel.YOLO26S,AIModel.POSE_YOLO,AIModel.HAND— runs ultralytics or mediapipe on the simulated frames and emits the same message the robot publishes, so results come back as the same typedDetection/PoseKeypoints/HandLandmarks. Models run together, as on the robot. Install withpip install "pib3[sim] @ git+https://github.com/mamrehn/pib3.git".
Objects must opt into recognition
A Solid is reported only if it sets recognitionColors, and its model
field becomes det.label:
Solid {
translation 0 -0.6 1.0
children [ Shape {
appearance PBRAppearance { baseColor 1 0 0 roughness 1 metalness 0 }
geometry Sphere { radius 0.06 }
} ]
name "testball"
model "ball"
recognitionColors [ 1 0 0 ]
}
A COCO-trained detector, by contrast, sees very little in an untextured synthetic scene — that is the world, not a bug.
Runnable example: examples/webots_camera_view.py.
If the camera misbehaves, examples/webots_camera_check.py
diagnoses the device, its mounting and the recognition setup step by step.
Low-Level API: Raw Subscriptions¶
The following sections cover the raw subscription API for advanced use cases (custom buffering, multiple callbacks, etc.).
Camera Streaming¶
Basic Camera Streaming¶
The camera publishes JPEG frames (base64 text on /camera_topic). The callback receives raw JPEG bytes:
from pib3 import Robot
import cv2
import numpy as np
def on_frame(jpeg_bytes):
"""Callback receives raw JPEG bytes."""
# Decode JPEG to numpy array
img_array = np.frombuffer(jpeg_bytes, dtype=np.uint8)
frame = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
# Process frame...
print(f"Frame shape: {frame.shape}")
# Display (optional)
cv2.imshow("Camera", frame)
cv2.waitKey(1)
with Robot(host="192.168.178.71") as robot:
# Subscribe to camera (streaming starts)
sub = robot.subscribe_camera_image(on_frame)
# Keep running for 10 seconds
import time
time.sleep(10)
# Unsubscribe (streaming stops)
sub.unsubscribe()
cv2.destroyAllWindows()
Using PIL for Image Processing¶
from PIL import Image
import io
def on_frame(jpeg_bytes):
"""Process frames with PIL."""
img = Image.open(io.BytesIO(jpeg_bytes))
print(f"Image size: {img.size[0]}x{img.size[1]} pixels")
# Save frame
img.save("captured_frame.jpg")
with Robot(host="192.168.178.71") as robot:
sub = robot.subscribe_camera_image(on_frame)
time.sleep(5)
sub.unsubscribe()
Configuring Camera Settings¶
Adjust FPS, quality, and resolution:
with Robot(host="192.168.178.71") as robot:
# Set individual parameters
robot.set_camera_config(fps=10)
robot.set_camera_config(quality=80)
robot.set_camera_config(resolution=(1280, 720))
# Or set multiple at once
robot.set_camera_config(
fps=10,
quality=80,
resolution=(1280, 720)
)
Keep the frame 16:9
The camera publishes 1280×720 by default. AI models read the same frame, and the camera refuses to feed them one of another aspect, so use sizes such as 1280×720 or 640×360. Changing the resolution restarts the camera pipeline.
AI Models¶
Available Models¶
robot.ai.available_models() lists the models in the robot's model store:
with Robot(host="192.168.178.71") as robot:
for info in robot.ai.available_models():
state = "running" if info.active else ("ready" if info.available else "not installed")
print(f"{info.name:36s} {info.task:22s} {info.shaves} cores {state}")
The AIModel enum names the common ones (YOLO26S and the faster YOLO26N, POSE_YOLO and POSE_YOLO26N, HAND, faces, QR codes); see AI & Camera Subsystems for the table. Any listed id also works as a string. The camera has 16 processing cores, and a model uses a fixed number of them, so two or three models fit together.
Running a Model¶
from pib3 import AIModel, Robot
with Robot(host="192.168.178.71") as robot:
if not robot.ai.set_model(AIModel.YOLO26S):
print("The robot did not start the model") # the log says why
else:
for det in robot.ai.get_detections(latest_only=True):
print(f"{det.label}: {det.confidence:.0%}")
set_model stops the models this client started before, then starts the new one, and returns when the robot reports it running. That is two pipeline rebuilds, a few seconds each. A model that is already running costs nothing.
If the robot does not have the model, set_model returns False and the log lists the models it does offer.
A failed start can stop the other models
If a model fails to start, the camera falls back to colour only, which also stops the models that ran before. robot.ai.available_models() shows what runs; start the others again.
Reading Results¶
dets = robot.ai.get_detections(latest_only=True) # boxes (objects, persons, faces, ...)
hands = robot.ai.get_hand_landmarks(latest_only=True) # HandLandmarks, with finger angles
poses = robot.ai.get_poses(latest_only=True) # PoseKeypoints, 17 COCO keypoints
latest_only=True returns the newest frame only. Without it you get every buffered frame (up to 100), so in a loop the same object shows up once per frame. Use it for control loops and counting; leave it off only when you want every frame since the last call.
Results of the face family carry more than a box:
robot.ai.set_model(AIModel.HEAD_POSE)
for det in robot.ai.get_detections(latest_only=True):
print(det.scalars) # {"yaw": ..., "pitch": ..., "roll": ...} in degrees
Several Models at Once¶
robot.ai.start_model(AIModel.YOLO26S)
robot.ai.start_model(AIModel.POSE_YOLO) # YOLO26S keeps running
objects = robot.ai.get_detections(latest_only=True, model=AIModel.YOLO26S)
people = robot.ai.get_poses(latest_only=True) # the current model: POSE_YOLO
robot.ai.stop_model(AIModel.POSE_YOLO)
Another Client Uses the Camera¶
robot.ai.stop_model() only releases this client's hold; a model another client holds keeps running. A model another client starts does not affect you either: your results come on your model's own topic. What other clients do change is the camera's timing, since they rebuild the pipeline.
Raw Subscriptions¶
Under robot.ai, each model publishes datatypes/DetectionArray messages on detections/<model>:
from pib3 import AIModel, parse_detection_message # typed objects from one message
def on_message(message):
for obj in parse_detection_message(message):
print(type(obj).__name__, getattr(obj, "label", ""))
with Robot(host="192.168.178.71") as robot:
robot.start_ai_model(AIModel.YOLO26S) # the model must be running
sub = robot.subscribe_ai_detections(AIModel.YOLO26S, on_message)
time.sleep(10)
sub.unsubscribe()
robot.stop_ai_model(AIModel.YOLO26S)
The message holds pixels with the frame size, keypoint_names/keypoint_x/keypoint_y and scalar_names/scalar_values; see the robot backend reference.
Watching Model Status¶
Each model reports state (idle, starting, running, failed), a message that says why it failed, fps and active. examples/monitor_ai_model.py prints state changes.
IMU Sensor Data¶
Access accelerometer and gyroscope from the OAK-D Lite's BMI270 IMU.
Data Source
All IMU data types (full, accelerometer, gyroscope) subscribe to the
same /imu ROS topic (sensor_msgs/Imu). The accelerometer and
gyroscope options pick one half client-side for convenience. Axes follow
ROS (x forward, y left, z up): a robot at rest reads about +9.8 on z.
Full IMU Data¶
Get both accelerometer and gyroscope:
def on_imu(data):
"""Callback receives full IMU data."""
# Accelerometer (m/s²)
accel = data['linear_acceleration']
print(f"Accel: x={accel['x']:.2f}, y={accel['y']:.2f}, z={accel['z']:.2f}")
# Gyroscope (rad/s)
gyro = data['angular_velocity']
print(f"Gyro: x={gyro['x']:.4f}, y={gyro['y']:.4f}, z={gyro['z']:.4f}")
with Robot(host="192.168.178.71") as robot:
sub = robot.subscribe_imu(on_imu, data_type="full")
import time
time.sleep(5)
sub.unsubscribe()
Accelerometer Only¶
Get just acceleration data in a simplified format:
def on_accel(data):
"""Callback receives accelerometer data."""
# Data is in Vector3Stamped-like format
vec = data['vector']
header = data['header']
print(f"Accel: {vec['x']:.2f}, {vec['y']:.2f}, {vec['z']:.2f} m/s²")
with Robot(host="192.168.178.71") as robot:
sub = robot.subscribe_imu(on_accel, data_type="accelerometer")
time.sleep(3)
sub.unsubscribe()
Gyroscope Only¶
def on_gyro(data):
"""Callback receives gyroscope data."""
vec = data['vector']
print(f"Gyro: {vec['x']:.4f}, {vec['y']:.4f}, {vec['z']:.4f} rad/s")
with Robot(host="192.168.178.71") as robot:
sub = robot.subscribe_imu(on_gyro, data_type="gyroscope")
time.sleep(3)
sub.unsubscribe()
IMU Rate¶
The camera node publishes the IMU at a fixed 100 Hz on /imu; there is nothing to set (set_imu_frequency() raises NotImplementedError). Skip samples in your callback for a lower rate.
IMU Data Format¶
Full IMU (data_type="full"):
{
"header": {
"stamp": {"sec": 1234567890, "nanosec": 123456789},
"frame_id": "oak_imu_frame"
},
"linear_acceleration": {"x": 0.05, "y": -0.02, "z": 9.81},
"angular_velocity": {"x": 0.001, "y": -0.002, "z": 0.0005},
"orientation": {"x": 0, "y": 0, "z": 0, "w": 1}, # identity: the BMI270 cannot fuse one
"linear_acceleration_covariance": [...],
"angular_velocity_covariance": [...],
"orientation_covariance": [-1.0, 0.0, ...] # -1 in [0] marks orientation as unavailable
}
Accelerometer only (data_type="accelerometer"):
Gyroscope only (data_type="gyroscope"):
Complete Example: Multi-Model AI Demo¶
Detection, then body pose, then both at once:
import time
from pib3 import AIModel, Robot
def watch(robot, seconds, read, describe):
"""Print what ``read`` finds in the newest frame, twice a second."""
end = time.time() + seconds
while time.time() < end:
for item in read(timeout=1.0, latest_only=True):
print(" ", describe(item))
time.sleep(0.5)
with Robot(host="192.168.178.71") as robot:
print("OBJECT DETECTION")
if robot.ai.set_model(AIModel.YOLO26S):
watch(robot, 5, robot.ai.get_detections,
lambda d: f"{d.label} ({d.confidence:.2f})")
print("POSE ESTIMATION")
if robot.ai.set_model(AIModel.POSE_YOLO): # stops YOLO26S first
watch(robot, 5, robot.ai.get_poses,
lambda p: f"nose at ({p.nose.x:.2f}, {p.nose.y:.2f})")
print("BOTH AT ONCE")
if robot.ai.start_model(AIModel.YOLO26S): # POSE_YOLO keeps running
time.sleep(3)
print(" objects:", len(robot.ai.get_detections(latest_only=True, model=AIModel.YOLO26S)))
print(" people: ", len(robot.ai.get_poses(latest_only=True, model=AIModel.POSE_YOLO)))
# leaving the with-block releases every model this script started
Complete Example: Vision-Based Control¶
Combine camera, AI, and robot control. The loop reads the newest frame each time and moves the head toward the most confident person:
import time
from pib3 import AIModel, Joint, Robot
def track_person(robot, duration=30):
"""Turn the head toward a person for ``duration`` seconds."""
if not robot.ai.set_model(AIModel.YOLO26S):
print("The robot did not start the model")
return
end = time.time() + duration
while time.time() < end:
people = [d for d in robot.ai.get_detections(timeout=1.0, latest_only=True)
if d.label == "person" and d.confidence > 0.7]
if people:
center_x = max(people, key=lambda d: d.confidence).bbox.center[0]
if center_x < 0.4:
robot.set_joint(Joint.TURN_HEAD, 60) # person on the left: turn left
elif center_x > 0.6:
robot.set_joint(Joint.TURN_HEAD, 40) # person on the right: turn right
else:
robot.set_joint(Joint.TURN_HEAD, 50) # person centered
time.sleep(0.1)
with Robot(host="192.168.178.71") as robot:
track_person(robot)
API Reference Summary¶
Camera Methods¶
| Method | Description |
|---|---|
subscribe_camera_image(callback) |
Stream camera images (JPEG bytes) |
set_camera_config(fps, quality, resolution) |
Configure camera settings |
AI Methods¶
| Method | Description |
|---|---|
robot.ai.set_model(model) |
Run this model, release the others this client started |
robot.ai.start_model(model) / stop_model(model) |
Run several models; release one |
robot.ai.get_detections() / get_hand_landmarks() / get_poses() |
Typed results, latest_only=True, model= |
robot.ai.available_models() |
The models the robot offers, with state |
robot.ai.stop() |
Release every model this client started |
get_available_ai_models(timeout) |
The same list as a dict |
start_ai_model(model) / stop_ai_model(model) |
The robot services, (success, message) |
subscribe_ai_detections(model, callback) |
Raw DetectionArray messages of one model |
get_ai_detections(model) |
The latest DetectionArray once |
subscribe_ai_status(callback) |
State of the models (/models_status) |
IMU Methods¶
| Method | Description |
|---|---|
subscribe_imu(callback, data_type) |
Subscribe to IMU data (full, accelerometer, gyroscope), 100 Hz |
subscribe_imu_raw(callback) |
The whole sensor_msgs/Imu message |
Helper Functions¶
| Function | Description |
|---|---|
parse_detection_message(message) |
One DetectionArray as Detection / HandLandmarks / PoseKeypoints |
rle_decode(rle) |
Decode RLE segmentation mask (simulation only) |
Troubleshooting¶
No Camera Data¶
- Check OAK-D Lite is connected to the robot
- Verify the ROS camera node is running:
ros2 topic list | grep camera_topic - Check network connectivity to the robot
- Verify rosbridge is running on port 9090
set_model Returns False¶
- The log line names the reason the robot gave ("Unknown model", "Model is unavailable") and lists the models it offers
- A model the robot does not list is not in its model store;
robot.ai.available_models()showsavailable - A model that failed to start shows
state: failedwith a message inrobot.subscribe_ai_status(); the camera then runs colour only until a model starts - Allow time: a start rebuilds the camera pipeline, a few seconds
Results Are Empty¶
- Check that the model runs:
robot.ai.modelandrobot.ai.available_models() - A hand or pose model returns nothing while no hand or person is in view
- The YOLO models drop detections below 0.5 confidence; this is set in the model's archive and pib3 cannot change it
- Depth and a running model exclude each other; this does not affect detections
AI Inference Slow¶
- The models and their parsers run on the camera; the robot's Raspberry Pi converts and forwards each result.
YOLO26Sdelivers about 12 results/s on the camera itself;YOLO26Nabout twice that, less accurately - Video to the laptop costs the Pi more than the results do: every frame is JPEG-encoded and sent as text while something subscribes to the camera image
- Several models at once share the camera's 16 cores and its USB link;
robot.ai.fpsshows the current model - Reduce camera resolution
IMU Data Issues¶
- Use
data_type="full"to verify data is coming through - Check the
/imutopic:ros2 topic echo /imu - The BMI270 gives no orientation; integrate the gyroscope or fuse it yourself
Connection Issues¶
- Verify robot IP:
ping 192.168.178.71 - Check rosbridge port:
nc -zv 192.168.178.71 9090 - Increase connection timeout:
Robot(host="...", timeout=10.0)
Next Steps¶
- Controlling the Robot - Joint control basics
- Image to Trajectory - Drawing with IK
- Custom Configurations - Fine-tune parameters