Edge Impulse public project: https://studio.edgeimpulse.com/public/953867/live
GitHub repository: https://github.com/SamuelAlexander/face-following-robot-uno-q
Demonstration video: https://youtu.be/TYtrBvlE7Mc
Introduction
This project combines computer vision and robotics on a single board. A camera captures video, a lightweight face-detection model (powered by Edge Impulse) locates the face, and a proportional controller converts that position into differential wheel commands, all at ~10-15 FPS. The UNO Q’s split architecture makes this possible:- MPU (Qualcomm, Linux) —> runs the ML model and control logic in Python
- MCU (STM32, Zephyr RTOS) —> drives servo PWM with real-time precision

What you’ll learn
- How the UNO Q’s MPU/MCU split architecture works
- Using App Lab bricks (pre-built middleware) for AI and web interfaces
- Bridge RPC communication between Python and an Arduino sketch
- Proportional steering control for differential-drive robots
- Live parameter tuning through a Socket.IO web dashboard
Prerequisites
Hardware
| Component | Used in This Project | Notes |
|---|---|---|
| Arduino UNO Q | — | 2 GB or 4 GB variant |
| USB webcam | Logitech C922 Pro Stream | Most UVC-compatible webcams work |
| 2x continuous-rotation servo | Parallax Continuous Rotation Servo | Any continuous servo with 1000-2000 us pulse range |
| USB-C power bank (PD) | 20000 mAh, USB-C PD output | Must supply stable 5 V / 2+ A |
| USB-C splitter | With USB-A port | Powers UNO Q via USB-C, camera via USB-A |
| Small decoupling capacitors | 100 uF electrolytic across servo power/GND | Reduces servo electrical noise |
| Robot chassis | Custom 3D-printed (STL files included) | Any two-wheel platform works |
| Jumper wires | — | Signal + power for servos |
Software
- Arduino App Lab v0.6 or later (runs in-browser, no local install needed)
- A computer on the same network as the UNO Q
- Edge Impulse Studio account
Source code
The full project source is available at: https://github.com/SamuelAlexander/face-following-robot-uno-qArchitecture overview
The system has three layers: Why split MPU and MCU? ML inference is computationally heavy and non-deterministic, it belongs on a Linux processor. Servo PWM timing is safety-critical and must be jitter-free, it belongs on a real-time microcontroller. Bridge RPC connects the two with ~8 ms round-trip latency.Hardware assembly
Chassis overview
The robot uses a simple differential-drive layout:- Two continuous-rotation servos mounted on opposite sides
- A rear caster or skid point for stability
- A top-mounted platform for the UNO Q and camera
- The USB power bank sits on or below the platform as ballast

Wiring
| Servo | Signal Pin | Power | Ground |
|---|---|---|---|
| Left wheel | D3 | 5 V | GND |
| Right wheel | D6 | 5 V | GND |

Note: The right servo is mounted mirrored relative to the left. The firmware handles this with a pulse inversion flag, no crossed wires needed.
Project setup in App Lab
File structure
Every App Lab project follows this layout:Configuring bricks
Bricks are pre-built middleware packages that run on the MPU. They provide high-level capabilities without writing boilerplate. This project uses two: Inapp.yaml:
video_object_detection, captures camera frames, runs the face-detection model, and delivers results to your Python code via callbacksweb_ui, serves the HTML/JS dashboard and provides a Socket.IO server for real-time communication
model: face-detection line selects a built-in lightweight face-detection model. No training or Edge Impulse account needed.
Sketch dependencies
Insketch/sketch.yaml, the key libraries are Arduino_RouterBridge (Bridge RPC) and Servo (PWM output):
The MCU sketch: Servo control
The MCU firmware is intentionally minimal, it receives pulse-width commands over Bridge RPC and writes them to the PWM hardware. All decision-making lives on the MPU.Bridge RPC endpoint
The sketch exposes a single function that the MPU can call. It uses the Arduino Servo library for reliable pin-level PWM output:- Arduino Servo library handles PWM output via
attach(pin)andwriteMicroseconds(). This is more portable than the low-level Zephyr PWM API (pwm_set_dt), which depends on devicetree index mappings that can vary between board revisions. Bridge.provide_safe()registers the function so it runs in the Arduinoloop()context, safe for hardware operations like PWM writes. Never useBridge.provide()for GPIO/servo/motor calls, as that runs in a background RPC thread where hardware APIs can fail.- Pulse inversion handles the mirrored right servo:
inverted = 3000 - pulse. This mirrors the pulse around the 1500 us stop point, so the same “forward” command from Python moves both wheels in the same physical direction. - Empty
loop()is correct, the Bridge library hooks into the loop internally to dispatch queuedprovide_safecallbacks.
Continuous-rotation servo basics
| Pulse (us) | Behavior |
|---|---|
| 1500 | Stop |
| 1000 | Full speed, one direction |
| 2000 | Full speed, opposite direction |
The MPU application: Detection to steering
The Python application on the MPU handles three responsibilities: receiving detections from the vision brick, computing steering commands, and communicating with both the MCU and the web UI.Brick initialization
VideoObjectDetection brick fires a callback every time it processes a frame. The on_detect_all variant fires for every frame, including ones with no detections, useful for keeping a watchdog timestamp fresh:
Detection format
The brick delivers detections as a dictionary, with each label mapped to a list of detection instances:confidence score and a bounding_box_xyxy tuple of (x_min, y_min, x_max, y_max) in pixel coordinates. The TARGET_CLASS constant must match the model’s label exactly, “face”` for the built-in face-detection model.
Important: The per-label value is always a list, even for a single detection. Code that expects a plain dict per label (without the list wrapper) will silently miss all detections.
Proportional steering controller
The controller converts the face’s horizontal position into differential wheel commands:STEER_CURVE = 0.4) makes the response more aggressive for small errors and softer near the frame edges, a simple alternative to PID that works well for this use case.
Coasting and lost target
When the face momentarily disappears (occlusion, blink, brief look-away), the controller coasts on the last-known position forTRACKING_TIMEOUT seconds (default 0.5s) before declaring the target lost. This avoids jittery stop-start behavior.
When the target is truly lost, the robot stops. An optional search mode (disabled by default) slowly rotates to scan for the face.
Bridge communication and rate limiting
The UNO Q Bridge has no internal queue, sending commands too fast crashes the serial link. The application rate-limits Bridge calls to a maximum of 20 Hz (50 ms intervals):Bridge.notify() is fire-and-forget (no response waited), which avoids blocking the inference loop. Bridge.call() would wait for a return value and slow down the control loop.
Safety note: The emergency stop bypasses rate limiting entirely to guarantee the MCU receives the stop command immediately.
Watchdog
A background thread checks whether detection callbacks have stopped arriving. If no callback fires for 1.5 seconds (indicating a camera or pipeline failure), the watchdog forces a stop command:The web UI
The dashboard runs in any browser on the same network and provides live monitoring and parameter tuning.
Features
| Element | Function |
|---|---|
| Video stream | Live camera feed via iframe (port 4912) |
| Confidence slider | Adjusts minimum detection confidence (0.0-1.0) |
| Steer gain slider | Controls how aggressively the robot turns (0-0.30) |
| Drive bias slider | Adds fixed forward/backward offset (-0.08 to +0.08) |
| Emergency stop | Immediately halts all wheel motion |
| Motor test | Runs a left-right-stop sequence to verify servo wiring |
| Feedback | Visual confirmation when a face is detected |
| Recent detections | Live log of the last 5 detection and state events |
Deploy and run
App Lab GUI
- Open Arduino App Lab in your browser
- Create a new app or import the project files
- Click Run, App Lab compiles the sketch, flashes the MCU, and starts the Python app
First boot checklist
- Check the log for the
sample_detectionsline, it prints the raw detection payload on the first frame. Verify:- The label is
"face"(matchesTARGET_CLASS) - The bounding box coordinates are reasonable for your camera resolution
- The label is
- Open the Web UI at
http://<UNO_Q_IP>:7000, you should see the video stream and detection events appearing in “Recent detections” - Verify servos, press the Motor Test button in the UI. Both wheels should turn left, pause, then turn right. If not, check wiring and servo power before proceeding to face tracking.

Tuning guide
Steering direction
If the robot turns away from your face instead of toward it, flip the steering sign inmain.py:
Frame width calibration
Check thesample_detections log output. If the max x coordinate in bounding_box_xyxy is significantly different from FRAME_WIDTH_FALLBACK (default 640), update the constant to match. A wrong value causes asymmetric or weak steering.
Gain tuning
Start with these defaults and use the Web UI sliders to adjust:| Parameter | Default | Effect |
|---|---|---|
STEER_GAIN | 0.15 | Higher = faster turns, risk of overshoot |
MAX_TURN_SPEED | 0.25 | Hard cap on wheel speed (~±125 us). Must exceed servo dead zone. |
CENTER_DEADBAND | 0.05 | Larger = more tolerance before turning |
DRIVE_BIAS | 0.0 | Positive = creep forward, negative = backward |
STEER_GAIN until the robot centers on your face smoothly. If it oscillates (overshoots left-right), reduce the gain. Once tuned, update the defaults in main.py so the robot starts with good values.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| UI shows detections but “lost target - stopped” | TARGET_CLASS doesn’t match model label | Check sample_detections log, set TARGET_CLASS to the exact label string |
| Wheels don’t move at all | Servo power, wiring, or Bridge issue | Use the Motor Test button to isolate; check servo power rail and signal wires on D3/D6 |
| Robot turns the wrong way | Steering sign or inversion mismatch | Flip STEER_SIGN to -1.0; or swap LEFT/RIGHT_WHEEL_INVERTED flags |
| Jerky, oscillating motion | Gain too high or deadband too low | Reduce STEER_GAIN via slider; increase CENTER_DEADBAND |
| Video stream not loading | Camera not detected | Check USB connection; try a powered USB hub |
| Emergency stop doesn’t work | Socket.IO disconnected | Check for error banner in the Web UI |