Build a Face-Following Robot
Build an autonomous robot that uses a Raspberry Pi and camera to detect and follow faces.
Introduction
30 Second Summary
Ever notice how some security cameras track you as you walk past? That same "follow the person" logic powers everything from warehouse robots to self-driving cars.
In this project, you will build a fully autonomous robot that detects a human face through an onboard camera and steers itself to follow that person. No laptop tether required. The robot's brain is a Raspberry Pi running the entire vision and motor control stack.
What You'll Build
A self-contained face-following robot powered by a Raspberry Pi 4, Pi Camera Module 3, and OpenCV for real-time face detection, driving two DC motors through an L293D H-bridge motor driver directly from the Pi's GPIO pins.
By the end of this project, you'll have:
- A headless Raspberry Pi running Python with OpenCV and Picamera2, detecting faces in real time from an onboard camera.
- A GPIO-driven motor circuit with two DC motors controlled via an L293D H-bridge for differential steering.
- A complete autonomous robot that physically steers left, right, or forward to track a detected face with no laptop connection.
- Secret Mission: Add distance-based speed control so the robot moves faster when the face is small (far away) and slower when it's large (close up).
What hardware do I need to buy?
This project requires physical hardware. You will need a Raspberry Pi 4 (2GB+), a Pi Camera Module 3, an L293D motor driver, two DC gear motors with wheels, a robot chassis, a USB-C power bank, a 9V battery, a microSD card, and jumper wires.
Total cost is approximately $130–$140. A full parts list with links is provided in Step 1.
Install Python and Arduino IDE
Set Up the Raspberry Pi
Your face-following robot needs two tools working together. Python with OpenCV handles the "seeing" part on your Mac, detecting faces in a webcam feed and deciding where to steer.
The Arduino IDE handles the "moving" part by programming your Arduino board to spin motors. In this step, you'll install both tools and set up a dedicated project folder with the right packages.
In this step, get ready to:
- Install Python 3.13 on your Mac.
- Create a virtual environment with OpenCV and pySerial packages.
- Install Arduino IDE 2.3.9.
Install Python 3.13
Python is the programming language your face-detection script runs on. You need version 3.13 specifically for compatibility with the packages you'll install next.
- Open Terminal by pressing Cmd+Space to open Spotlight.
- Type Terminal and press Enter to open it.
- Check your current Python version by running this command:
python3 --version
✔️ I see version 3.13
Python 3.13 is already installed. You're ready to move on to the pip upgrade below.
ⓧ I see an older version
Your Mac has an older Python version (like 3.9 or 3.12) that ships with macOS or was installed previously. You need to install Python 3.13 alongside it.
- Open your browser and navigate to python.org/downloads/macos/.
- Click Download macOS 64-bit universal2 installer next to the latest Python 3.13 release.
- Open the downloaded .pkg file and follow the installer prompts. Accept the default settings.
- Once installed, close and reopen Terminal, then run the version check again:
python3 --version
You should now see Python 3.13.x in the output.
Still seeing the old version?
The python.org installer adds Python 3.13 at /usr/local/bin/python3. If your terminal still shows an older version, try running python3.13 --version directly to confirm the install succeeded.
ⓧ Command not found
Python is not installed on your Mac yet. You'll download it from the official website.
- Open your browser and navigate to python.org/downloads/macos/.
- Click Download macOS 64-bit universal2 installer next to the latest Python 3.13 release.
- Open the downloaded .pkg file and follow the installer prompts. Accept the default settings.
- Once installed, close and reopen Terminal, then verify:
python3 --version
You should see Python 3.13.x in the output.
- Upgrade pip (Python's package manager) by running this command:
python3 -m pip install --upgrade pip
You should see a message confirming pip is up to date or was upgraded successfully.
Permission denied error?
If you see a permissions error, try adding --user to the end of the command. This installs pip for your user account only. If it still fails, don't worry. The virtual environment in the next step has its own pip.
Help me fix a pip upgrade permission error on macOS.
Create a virtual environment and install packages
A virtual environment is an isolated folder where Python packages live. It keeps this project's dependencies separate from your system Python so nothing conflicts.
- Create your project folder and set up the virtual environment by running these commands:
mkdir ~/face-robot && cd ~/face-robot
python3 -m venv venv
source venv/bin/activate
What do these commands do?
mkdir ~/face-robot creates a new folder called face-robot in your home directory. cd ~/face-robot moves your terminal into that folder.
python3 -m venv venv creates the virtual environment inside a subfolder called venv. source venv/bin/activate activates it. You'll see (venv) appear at the start of your terminal prompt, confirming you're inside the environment.
- Confirm you see (venv) at the start of your terminal prompt.
- Install the two Python packages your robot needs by running:
pip install opencv-python==4.13.0.92 pyserial==3.5
What are these packages?
opencv-python is the computer vision library that detects faces in your webcam feed. pyserial lets Python send commands over USB to your Arduino board.
- Wait for the installation to complete. You should see Successfully installed followed by the package names in the terminal output.
Installation failed?
Make sure you see (venv) in your prompt. If it's missing, run source venv/bin/activate again. If you get a network error, check your internet connection and retry.
Help me troubleshoot a failed pip install for opencv-python or pyserial.
Install Arduino IDE
The Arduino IDE is where you'll write and upload code to your Arduino UNO board. It compiles your sketch and sends it to the board over USB.
- Press Cmd+Space to open Spotlight.
- Type Arduino IDE and see if it appears in the results.
✔️ I see Arduino IDE 2.x
Arduino IDE 2.x is already installed. Open it and click Help in the menu bar, then About Arduino IDE to confirm your version is 2.3.0 or higher. If it is, you're all set.
If you see version 1.x, follow the instructions in the "I need to install it" tab to download the current version.
ⓧ I see Arduino IDE 1.x (legacy version)
The legacy Arduino IDE 1.x looks very different from the modern 2.x version. You need to download the new version.
- Open your browser and navigate to arduino.cc/en/software.
- Download the macOS version of Arduino IDE (the .dmg file).
- Open the .dmg file and drag Arduino IDE into your Applications folder. This replaces the old version.
- Launch Arduino IDE from Applications.
- The first launch will download board indexes automatically. Wait for this to complete.
ⓧ It's not installed
You'll download Arduino IDE from the official website.
- Open your browser and navigate to arduino.cc/en/software.
- Download the macOS version of Arduino IDE (the .dmg file).
- Open the downloaded .dmg file and drag Arduino IDE into your Applications folder.
- Launch Arduino IDE from Applications (or press Cmd+Space, type Arduino IDE, and press Enter).
- The first launch will download board indexes automatically. Wait for this to complete before continuing.
What are board indexes?
Board indexes tell Arduino IDE how to compile code for different boards (like the UNO R3). The IDE downloads them on first launch so it knows what hardware you can target.
Why macOS 10.15 or newer?
Arduino IDE 2.x requires macOS Catalina (10.15) or newer. If you're running an older macOS version, you'll need to update your operating system before continuing.
Your software is ready. Next up, you'll wire the L293D motor driver circuit that gives your Arduino the power to control two DC motors.
Wire the L293D Motor Driver Circuit
Your software tools are installed and ready. Now it's time to build the physical hardware that gives your robot the ability to move.
Your robot needs a way to translate digital signals from the Arduino into motor movement. The L293D chip acts as a bridge between the low-power Arduino pins and the high-current DC motors.
In this step, get ready to:
- Identify the L293D chip and place it correctly on the breadboard.
- Wire power and ground connections between the Arduino, battery, and L293D.
- Wire the motor control pins to connect both DC motors.
Understand the L293D pinout
The L293D is a 16-pin IC that contains two H-bridge circuits. Each H-bridge can drive one motor forward or backward, giving you independent control of two motors from a single chip.
- Find the L293D chip in your ELEGOO kit. It is a small black rectangular IC with 8 pins on each side (16 total).
- Look for the small semicircular notch on one end of the chip. Pin 1 is directly to the left of this notch when you view the chip from the top.
- Place the L293D straddling the center gap of your breadboard so that each row of pins sits in a separate section. The notch should face the top (away from you).
What is an H-bridge?
An H-bridge is a circuit that can reverse the direction of current flowing through a motor. By changing which pair of switches are open or closed, the motor spins forward or backward.
The L293D contains two independent H-bridges, so it can control two motors at the same time. The Arduino tells each H-bridge which direction to spin by setting input pins HIGH or LOW.
💡 How does the L293D pinout work?
Pins are numbered counter-clockwise starting from Pin 1 (left of the notch). Pins 1 through 8 run down the left side, and Pins 9 through 16 run up the right side.
- Pin 1 (Enable 1,2) and Pin 9 (Enable 3,4) turn each motor channel on or off.
- Pins 2, 7, 10, 15 are input pins that control motor direction.
- Pins 3, 6, 11, 14 are output pins that connect directly to the motors.
- Pins 4, 5, 12, 13 are ground pins (also used as a heat sink).
- Pin 16 (Vcc1) powers the chip's internal logic. Pin 8 (Vcc2) powers the motors.
Wire power and ground
The L293D needs two separate power sources. One powers the chip's logic (5V from the Arduino) and the other powers the motors (9V battery). Both sources must share a common ground for the circuit to work.
- Connect a jumper wire from L293D Pin 16 (Vcc1, logic power) to the Arduino's 5V pin.
- Connect a jumper wire from L293D Pin 8 (Vcc2, motor power) to the positive terminal of your 9V battery connector.
- Connect jumper wires from L293D Pins 4, 5, 12, and 13 (all GND pins) to the breadboard's ground rail (the blue or black rail running along the edge).
- Connect a jumper wire from the Arduino's GND pin to the same breadboard ground rail.
- Connect the 9V battery's negative terminal to the same breadboard ground rail.
Why does everything share a ground?
The Arduino, L293D, and 9V battery all need a common ground reference so that electrical signals between them are measured from the same baseline. Without a shared ground, the Arduino's logic signals would not register correctly at the L293D's input pins.
🙋♀️ Motor power not working later?
The most common wiring mistake is forgetting to connect all three grounds together (Arduino GND, battery negative, and L293D GND pins). Double-check that all ground connections meet at the same rail on the breadboard.
help me debug my L293D ground connections
Wire the motor control pins
Now connect the Arduino's digital pins to the L293D's input and enable pins. This lets the Arduino control motor speed and direction.
- Connect L293D Pin 1 (Enable 1,2) to Arduino Pin 9. This pin controls Motor A's speed via PWM.
- Connect L293D Pin 2 (Input 1) to Arduino Pin 7.
- Connect L293D Pin 7 (Input 2) to Arduino Pin 8.
- Connect L293D Pin 3 (Output 1) to Motor A's first terminal.
- Connect L293D Pin 6 (Output 2) to Motor A's second terminal.
Motor A (the left motor) is now fully wired. Repeat for Motor B (the right motor):
- Connect L293D Pin 9 (Enable 3,4) to Arduino Pin 10. This pin controls Motor B's speed via PWM.
- Connect L293D Pin 10 (Input 3) to Arduino Pin 4.
- Connect L293D Pin 15 (Input 4) to Arduino Pin 2.
- Connect L293D Pin 11 (Output 3) to Motor B's first terminal.
- Connect L293D Pin 14 (Output 4) to Motor B's second terminal.
Why use PWM on the Enable pins?
The Enable pins accept a PWM (Pulse Width Modulation) signal from the Arduino's analogWrite() function. By varying the duty cycle, you control how much power the motor receives. A value of 255 is full speed, and 0 is stopped.
The Input pins only need digital HIGH or LOW signals to set direction. The combination of both gives you full control over each motor's speed and direction.
Verify your wiring by doing a visual check. Trace each jumper wire from the Arduino pin to the correct L293D pin. Confirm the notch on the L293D is oriented correctly (Pin 1 is to the left of the notch).
Label your motors
Use a small piece of tape to mark which motor is A (left) and which is B (right). This saves confusion later when you test serial commands and the wrong motor spins.
🙋♀️ Not sure which motor terminal is which?
DC motors only have two terminals, and either wire can go to either terminal. If a motor spins the wrong direction later, simply swap the two wires on that motor's terminals.
help me verify my L293D motor wiring
Your motor driver circuit is complete. Next up, you'll upload Arduino code that listens for serial commands and translates them into motor actions.
Upload the Arduino Motor Control Sketch
Your circuit is wired and ready to receive instructions. Now it needs a brain.
In this step, you will write an Arduino sketch that listens for single-character commands over USB and translates them into motor actions. The Arduino acts as the "muscle" of your robot, turning serial communication signals from your Mac into physical motor movement.
In this step, get ready to:
- Connect the Arduino to your Mac and configure the IDE.
- Write and upload the motor control sketch.
- Test the motors using the Serial Monitor.
Connect Arduino to Mac via USB
Before you can upload code, the Arduino IDE needs to know which board is connected and on which port.
- Plug the USB cable from your ELEGOO kit into the Arduino UNO and then into your Mac.
- Switch back to Arduino IDE.
- Click Tools in the menu bar.
- Hover over Board and select Arduino Uno.
- Click Tools again and hover over Port.
- Select the port that shows your Arduino (it will appear as something like /dev/cu.usbmodem14101 (Arduino Uno)).
What if I don't see a port?
If the Port option is missing or no ports appear, try unplugging and re-plugging the USB cable. Make sure you are using a data USB cable, not a charge-only cable.
Write and upload the motor control sketch
The sketch listens for single characters sent over USB serial and calls the corresponding motor function. It supports four commands: F (forward), L (left), R (right), and S (stop).
- In Arduino IDE, click File then New Sketch to create a blank sketch.
- Select all the existing code in the editor and delete it.
- Paste the following pin declarations and setup() function:
// Motor A (Left) pins
const int enA = 9;
const int in1 = 7;
const int in2 = 8;
// Motor B (Right) pins
const int enB = 10;
const int in3 = 4;
const int in4 = 2;
const int motorSpeed = 200;
void setup() {
pinMode(enA, OUTPUT);
pinMode(in1, OUTPUT);
pinMode(in2, OUTPUT);
pinMode(enB, OUTPUT);
pinMode(in3, OUTPUT);
pinMode(in4, OUTPUT);
Serial.begin(9600);
stopMotors();
}
What does this code do?
- The const int lines define which Arduino pins connect to the L293D. These match the wiring you did in the previous step.
- motorSpeed = 200 sets the PWM speed for the motors (0 is off, 255 is full speed).
- Serial.begin(9600) opens serial communication at 9600 baud so the Arduino can receive commands from your Mac.
- stopMotors() ensures both motors start in a stopped state.
- Directly below the setup() function, paste the loop() function that reads incoming commands:
void loop() {
if (Serial.available() > 0) {
char command = Serial.read();
switch (command) {
case 'F':
moveForward();
break;
case 'L':
turnLeft();
break;
case 'R':
turnRight();
break;
case 'S':
stopMotors();
break;
}
}
}
What does the loop do?
The loop() function runs continuously. Each cycle, it checks if a byte has arrived over serial. If it has, it reads the character and uses a switch statement to call the matching motor function.
- Finally, paste the four motor control functions below loop():
void moveForward() {
analogWrite(enA, motorSpeed);
analogWrite(enB, motorSpeed);
digitalWrite(in1, HIGH);
digitalWrite(in2, LOW);
digitalWrite(in3, HIGH);
digitalWrite(in4, LOW);
}
void turnLeft() {
analogWrite(enA, motorSpeed);
analogWrite(enB, motorSpeed);
digitalWrite(in1, LOW);
digitalWrite(in2, HIGH);
digitalWrite(in3, HIGH);
digitalWrite(in4, LOW);
}
void turnRight() {
analogWrite(enA, motorSpeed);
analogWrite(enB, motorSpeed);
digitalWrite(in1, HIGH);
digitalWrite(in2, LOW);
digitalWrite(in3, LOW);
digitalWrite(in4, HIGH);
}
void stopMotors() {
analogWrite(enA, 0);
analogWrite(enB, 0);
digitalWrite(in1, LOW);
digitalWrite(in2, LOW);
digitalWrite(in3, LOW);
digitalWrite(in4, LOW);
}
How do the motor functions control direction?
Each function sets the H-bridge input pins to HIGH or LOW to control which way current flows through each motor. Swapping which pin is HIGH reverses the motor direction.
- moveForward() spins both motors in the same direction.
- turnLeft() reverses Motor A while Motor B drives forward, pivoting the robot left.
- turnRight() reverses Motor B while Motor A drives forward, pivoting the robot right.
- stopMotors() sets the enable pins to 0 (no power) and all inputs to LOW.
- Click the Upload button (the right-arrow icon in the top-left toolbar) to compile and upload the sketch to your Arduino.
- Wait until you see Done uploading. in the output panel at the bottom of the IDE.
Upload failed?
- Check that Tools > Board is set to Arduino Uno and a port is selected under Tools > Port.
- Make sure the USB cable is firmly plugged in on both ends.
- Try pressing the physical reset button on the Arduino board, then click Upload again immediately.
Help me troubleshoot my Arduino sketch upload failing.
✔️ Awesome, I've got everything!
Great. Make sure you save the sketch with Cmd+S so you can return to it later.
ⓧ I'd like to double check the full code
Here is the complete motor_control.ino sketch. Your code should match this exactly:
// Motor A (Left) pins
const int enA = 9;
const int in1 = 7;
const int in2 = 8;
// Motor B (Right) pins
const int enB = 10;
const int in3 = 4;
const int in4 = 2;
const int motorSpeed = 200;
void setup() {
pinMode(enA, OUTPUT);
pinMode(in1, OUTPUT);
pinMode(in2, OUTPUT);
pinMode(enB, OUTPUT);
pinMode(in3, OUTPUT);
pinMode(in4, OUTPUT);
Serial.begin(9600);
stopMotors();
}
void loop() {
if (Serial.available() > 0) {
char command = Serial.read();
switch (command) {
case 'F':
moveForward();
break;
case 'L':
turnLeft();
break;
case 'R':
turnRight();
break;
case 'S':
stopMotors();
break;
}
}
}
void moveForward() {
analogWrite(enA, motorSpeed);
analogWrite(enB, motorSpeed);
digitalWrite(in1, HIGH);
digitalWrite(in2, LOW);
digitalWrite(in3, HIGH);
digitalWrite(in4, LOW);
}
void turnLeft() {
analogWrite(enA, motorSpeed);
analogWrite(enB, motorSpeed);
digitalWrite(in1, LOW);
digitalWrite(in2, HIGH);
digitalWrite(in3, HIGH);
digitalWrite(in4, LOW);
}
void turnRight() {
analogWrite(enA, motorSpeed);
analogWrite(enB, motorSpeed);
digitalWrite(in1, HIGH);
digitalWrite(in2, LOW);
digitalWrite(in3, LOW);
digitalWrite(in4, HIGH);
}
void stopMotors() {
analogWrite(enA, 0);
analogWrite(enB, 0);
digitalWrite(in1, LOW);
digitalWrite(in2, LOW);
digitalWrite(in3, LOW);
digitalWrite(in4, LOW);
}
Test the motors with Serial Monitor
The Serial Monitor lets you send characters to the Arduino over USB, just like the Python script will do later. This is how you verify your motors respond correctly before adding the computer vision layer.
- In Arduino IDE, click Tools in the menu bar.
- Click Serial Monitor to open it. A panel will appear at the bottom of the IDE.
- In the baud rate dropdown (bottom-right of the Serial Monitor panel), make sure 9600 baud is selected.
- Change the line ending dropdown to No line ending. This ensures only the character you type is sent, with no extra newline characters.
- Type F in the input field at the top of the Serial Monitor and press Cmd+Enter to send it.
Both motors should start spinning. You have just sent your first serial command to the Arduino.
- Type S and press Cmd+Enter to stop the motors.
- Type L and press Cmd+Enter. One motor should reverse while the other drives forward.
- Type R and press Cmd+Enter to test the opposite turn direction.
- Type S and press Cmd+Enter to stop.
Motors not spinning?
- Check that your 9V battery is connected and has charge. The Arduino USB cable does not supply enough power for the motors.
- Verify the baud rate in Serial Monitor is set to 9600 and line ending is set to No line ending.
- Double-check that L293D Enable pins (Pins 1 and 9 on the IC) are wired to Arduino Pins 9 and 10. Without these connections, the motors get no power signal.
- Make sure you are sending uppercase letters (F, not f). The sketch only recognizes uppercase commands.
Help me debug why my DC motors are not responding to serial commands.
Your Arduino now has a working motor control system that responds to serial commands. Next up, you will write the Python script that detects faces and sends these commands automatically.
Write the Python Face Detection Script
Your Arduino can already drive the motors based on serial commands. Now you need the "brain" that decides which command to send.
In this step, you'll write a Python script that captures webcam video, detects faces using OpenCV's Haar cascade classifier, and sends steering commands to the Arduino over serial. By the end, the software side of your robot is complete.
In this step, get ready to:
- Write a Python script that detects faces in a webcam feed.
- Add navigation logic that converts face position into steering commands.
- Send throttled serial commands to the Arduino and display live annotations.
Write the face detection logic
A Haar cascade is a pre-trained model that scans an image at multiple scales looking for patterns that match a face. OpenCV bundles one called haarcascade_frontalface_default.xml so you don't need to download anything extra.
- Create a new file called face_follower.py inside your face-robot folder by running this command:
touch ~/face-robot/face_follower.py
- Open face_follower.py in your text editor and paste the following imports, configuration, and helper function:
import cv2
import serial
import time
import sys
# Serial port configuration
# Change this to your Arduino's port (check Arduino IDE > Tools > Port)
SERIAL_PORT = "/dev/cu.usbmodem14101"
BAUD_RATE = 9600
def find_arduino_port():
"""List available serial ports to help find the Arduino."""
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
for port in ports:
print(f" {port.device} - {port.description}")
return ports
What does this code do?
- The imports bring in OpenCV (cv2) for face detection, serial for Arduino communication, time for throttling, and sys for clean exits.
- SERIAL_PORT and BAUD_RATE configure the connection to your Arduino. The port must match what you see in Arduino IDE under Tools > Port.
- find_arduino_port() is a helper that prints all available serial ports so you can identify which one is your Arduino.
- Below the find_arduino_port() function, paste the beginning of the main() function that connects to the Arduino, loads the face detector, and opens the webcam:
def main():
# Connect to Arduino
print("Connecting to Arduino...")
print("Available ports:")
ports = find_arduino_port()
try:
arduino = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
time.sleep(2) # Wait for Arduino to reset after serial connection
print(f"Connected to {SERIAL_PORT}")
except serial.SerialException:
print(f"ERROR: Could not connect to {SERIAL_PORT}")
print("Update SERIAL_PORT in this script to match your Arduino port.")
sys.exit(1)
# Load Haar cascade face detector
face_cascade = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)
# Open webcam
cap = cv2.VideoCapture(0)
if not cap.isOpened():
print("ERROR: Could not open webcam")
arduino.close()
sys.exit(1)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
print("Face follower running. Press 'q' to quit.")
last_command = ""
last_send_time = 0
send_interval = 0.1 # Send commands at most every 100ms
What does this code do?
- serial.Serial() opens the USB connection to the Arduino. The 2-second sleep gives the Arduino time to reset after the serial connection triggers its bootloader.
- cv2.CascadeClassifier() loads the Haar cascade face detection model that ships bundled with opencv-python.
- cv2.VideoCapture(0) opens the default webcam. The resolution is set to 640x480 for a balance of speed and detection accuracy.
- send_interval = 0.1 limits serial writes to once every 100ms so the Arduino isn't flooded with duplicate commands.
💡 Why does the Arduino reset?
When Python opens the serial port, the USB-to-serial chip pulls the DTR line low, which triggers the Arduino's hardware reset. The 2-second delay accounts for the bootloader running before your sketch starts listening for commands.
Add navigation logic
The webcam frame is divided into three vertical zones: left third, center third, and right third. If the detected face is in the left zone, the robot turns left. Right zone means turn right. Center means drive forward.
- Directly below the send_interval line, paste the main detection loop that captures frames, finds faces, and determines a steering command:
try:
while True:
ret, frame = cap.read()
if not ret:
break
# Convert to grayscale for face detection
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# Detect faces
faces = face_cascade.detectMultiScale(
gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30)
)
frame_height, frame_width = frame.shape[:2]
frame_center_x = frame_width // 2
# Define zones (left third, center third, right third)
left_boundary = frame_width // 3
right_boundary = 2 * frame_width // 3
command = "S" # Default: stop if no face detected
if len(faces) > 0:
# Use the largest face (closest person)
largest_face = max(faces, key=lambda f: f[2] * f[3])
x, y, w, h = largest_face
# Draw rectangle around face
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
# Calculate face center
face_center_x = x + w // 2
# Draw face center point
cv2.circle(frame, (face_center_x, y + h // 2), 5, (0, 0, 255), -1)
# Determine command based on face position
if face_center_x < left_boundary:
command = "L"
elif face_center_x > right_boundary:
command = "R"
else:
command = "F"
What does this code do?
- Each frame is converted to grayscale because Haar cascades operate on brightness patterns, not colour.
- detectMultiScale() scans the grayscale image at multiple sizes (controlled by scaleFactor=1.1) to find faces of different scales. minNeighbors=5 requires 5 overlapping detections before confirming a face, which reduces false positives.
- If multiple faces are detected, the script picks the largest one (closest person) using area (w * h).
- The frame is split into thirds. The face's horizontal center (face_center_x) is compared against left_boundary and right_boundary to pick 'L', 'R', or 'F'.
💡 What is a Haar cascade?
A Haar cascade is a machine learning model trained on thousands of face/non-face images. It identifies rectangular patterns of light and dark that correspond to facial features (eye sockets are darker than cheeks, for example). The "cascade" part means it applies quick reject filters first, so most non-face regions are discarded early without expensive computation.
Add serial communication and display
The navigation logic picks a command each frame, but you don't want to send every single one. Throttling to 100ms intervals prevents flooding the Arduino's serial buffer.
- Directly below the navigation if/elif/else block (still inside the while True loop), paste the serial sending, display annotations, and cleanup code:
# Send command to Arduino (throttled)
current_time = time.time()
if current_time - last_send_time >= send_interval:
if command != last_command:
arduino.write(command.encode())
last_command = command
last_send_time = current_time
# Draw zone boundaries and command on frame
cv2.line(frame, (left_boundary, 0), (left_boundary, frame_height), (255, 0, 0), 1)
cv2.line(frame, (right_boundary, 0), (right_boundary, frame_height), (255, 0, 0), 1)
cv2.putText(frame, f"Command: {command}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
# Show frame
cv2.imshow("Face Follower", frame)
# Quit on 'q' key
if cv2.waitKey(1) & 0xFF == ord("q"):
break
finally:
# Cleanup
arduino.write(b"S") # Stop motors
cap.release()
cv2.destroyAllWindows()
arduino.close()
print("Shut down cleanly.")
if __name__ == "__main__":
main()
What does this code do?
- The throttle logic only sends a command when it differs from the last one AND at least 100ms has passed. This prevents the Arduino from being overwhelmed.
- arduino.write(command.encode()) converts the string ('L', 'R', 'F', or 'S') to bytes and sends it over USB serial.
- Blue vertical lines and green text overlay the video feed so you can see the zone boundaries and current command in real time.
- The finally block guarantees the motors stop (sends 'S'), the webcam releases, and the serial port closes even if the script crashes.
- Save face_follower.py.
- Verify the file has no syntax errors by running this in your terminal (with the venv still activated):
python3 -c "import ast; ast.parse(open('face_follower.py').read()); print('Syntax OK')"
You should see Syntax OK printed. You won't run the full script yet because the hardware needs to be mounted first (next step).
Seeing a SyntaxError?
- Check that every indentation level uses exactly 4 spaces. Mixing tabs and spaces causes Python to reject the file.
- Make sure the try: block is indented once inside main(), and the while True: loop body is indented twice inside main().
- Confirm that the finally: block lines up with the try: at the same indentation level.
Help me fix a SyntaxError in my face_follower.py file
✔️ Awesome, I've got everything!
Great. Double check you've saved face_follower.py and move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete face_follower.py for comparison:
import cv2
import serial
import time
import sys
# Serial port configuration
# Change this to your Arduino's port (check Arduino IDE > Tools > Port)
SERIAL_PORT = "/dev/cu.usbmodem14101"
BAUD_RATE = 9600
def find_arduino_port():
"""List available serial ports to help find the Arduino."""
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
for port in ports:
print(f" {port.device} - {port.description}")
return ports
def main():
# Connect to Arduino
print("Connecting to Arduino...")
print("Available ports:")
ports = find_arduino_port()
try:
arduino = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
time.sleep(2) # Wait for Arduino to reset after serial connection
print(f"Connected to {SERIAL_PORT}")
except serial.SerialException:
print(f"ERROR: Could not connect to {SERIAL_PORT}")
print("Update SERIAL_PORT in this script to match your Arduino port.")
sys.exit(1)
# Load Haar cascade face detector
face_cascade = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)
# Open webcam
cap = cv2.VideoCapture(0)
if not cap.isOpened():
print("ERROR: Could not open webcam")
arduino.close()
sys.exit(1)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
print("Face follower running. Press 'q' to quit.")
last_command = ""
last_send_time = 0
send_interval = 0.1 # Send commands at most every 100ms
try:
while True:
ret, frame = cap.read()
if not ret:
break
# Convert to grayscale for face detection
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# Detect faces
faces = face_cascade.detectMultiScale(
gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30)
)
frame_height, frame_width = frame.shape[:2]
frame_center_x = frame_width // 2
# Define zones (left third, center third, right third)
left_boundary = frame_width // 3
right_boundary = 2 * frame_width // 3
command = "S" # Default: stop if no face detected
if len(faces) > 0:
# Use the largest face (closest person)
largest_face = max(faces, key=lambda f: f[2] * f[3])
x, y, w, h = largest_face
# Draw rectangle around face
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
# Calculate face center
face_center_x = x + w // 2
# Draw face center point
cv2.circle(frame, (face_center_x, y + h // 2), 5, (0, 0, 255), -1)
# Determine command based on face position
if face_center_x < left_boundary:
command = "L"
elif face_center_x > right_boundary:
command = "R"
else:
command = "F"
# Send command to Arduino (throttled)
current_time = time.time()
if current_time - last_send_time >= send_interval:
if command != last_command:
arduino.write(command.encode())
last_command = command
last_send_time = current_time
# Draw zone boundaries and command on frame
cv2.line(frame, (left_boundary, 0), (left_boundary, frame_height), (255, 0, 0), 1)
cv2.line(frame, (right_boundary, 0), (right_boundary, frame_height), (255, 0, 0), 1)
cv2.putText(frame, f"Command: {command}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
# Show frame
cv2.imshow("Face Follower", frame)
# Quit on 'q' key
if cv2.waitKey(1) & 0xFF == ord("q"):
break
finally:
# Cleanup
arduino.write(b"S") # Stop motors
cap.release()
cv2.destroyAllWindows()
arduino.close()
print("Shut down cleanly.")
if __name__ == "__main__":
main()
Your face detection script is ready. Next up, you'll mount the hardware on a chassis and run the full system live.
Run the Face-Following Robot
Your Python script can detect faces and your Arduino can drive motors. Now it's time to bring both systems together on a physical robot chassis.
In this step, you'll mount everything onto Lego, launch the face-following script, and watch your robot physically steer toward your face in real time.
In this step, get ready to:
- Mount motors, breadboard, and webcam on a Lego chassis.
- Launch the face-following system and confirm it tracks your face.
- Tune the tracking response for smooth operation.
Mount the hardware on your chassis
Your robot needs a body. A Lego chassis gives you a flat platform to attach everything and wheels for mobility.
- Attach the two DC motors to opposite sides of your Lego chassis, with wheels pressed onto the motor shafts.
- Mount the breadboard (with the L293D and wiring) onto the top of the chassis using tape or Lego studs.
- Secure the Arduino UNO on the chassis next to the breadboard.
- Position the USB webcam pointing forward from the front of the chassis. Tape or clip it in place so it doesn't shift.
- Route the USB cables from both the webcam and Arduino back to your Mac. The robot will be tethered for now.
- Connect the 9V battery to the breadboard power rail (if not already connected).
Keep the cables slack
Leave enough slack in the USB cables so the robot can turn left and right without pulling on the connections. A short cable will yank the chassis and ruin your tracking test.
- Confirm both wheels spin freely when you hold the chassis off the ground.
- Place the robot on a flat surface with space to roll forward and turn.
Why a tethered robot?
The face detection runs on your Mac (Python + OpenCV), not on the Arduino. The Arduino only handles motor commands. The USB cable carries both the video feed from the webcam and the serial commands to the Arduino.
A wireless version would need either a Raspberry Pi onboard or WiFi-based serial communication. That's a project for another day.
Launch the face-following system
With everything physically assembled, your Python script can now control a real robot. When you run the script, it opens the webcam, detects faces, and sends steering commands to the Arduino over USB serial.
macOS will ask for camera permission
The first time your Python script tries to access the webcam, macOS will show a system dialog asking for camera access permission for Terminal. Click Allow to continue. Without this permission, the webcam feed will not open.
- Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
- If the virtual environment is not active, re-activate it by running:
cd ~/face-robot
source venv/bin/activate
- Launch the face-following script by running:
python3 face_follower.py
A window titled Face Follower should appear showing your webcam feed. You'll see two vertical blue lines dividing the frame into three zones (left, center, right).
- Stand in front of the webcam so your face is visible. A green rectangle should appear around your face.
- Move your face to the left side of the frame. The text in the top-left corner should read Command: L and the robot should steer left.
- Move your face to the right. The command should change to Command: R and the robot should steer right.
- Center your face in the middle third of the frame. The command should read Command: F and both motors should drive forward.
- Step out of the frame entirely. The command should change to Command: S and the motors should stop.
Webcam window not appearing?
If you see the error ERROR: Could not open webcam, check that your USB webcam is plugged in and that you allowed camera access when macOS prompted you.
If you see ERROR: Could not connect to /dev/cu.usbmodem14101, your Arduino's port may be different. Open Arduino IDE, check Tools > Port for the correct port name, and update the SERIAL_PORT variable in face_follower.py.
If the window opens but motors don't respond, make sure the 9V battery is connected and the Arduino USB cable is plugged into your Mac.
Help me debug my face-following robot
- Press q on your keyboard (while the Face Follower window is focused) to stop the script cleanly.
Tune and troubleshoot the tracking
Your robot is following your face, but you may notice it reacts too aggressively or too sluggishly. A few adjustments in the script can smooth things out.
Common tuning adjustments
- Robot oscillates left-right rapidly: The center "dead zone" is too narrow. In face_follower.py, change left_boundary = frame_width // 3 to a smaller value (like frame_width // 4) and right_boundary to 3 * frame_width // 4. This widens the center zone so the robot only turns for larger movements.
- Face detection feels laggy: Reduce the frame resolution. Change CAP_PROP_FRAME_WIDTH from 640 to 320 and CAP_PROP_FRAME_HEIGHT from 480 to 240. A smaller frame processes faster.
- Motors don't respond to commands: Verify that the SERIAL_PORT in your script matches the port shown in Arduino IDE under Tools > Port. Also check that the Arduino IDE Serial Monitor is closed. Only one program can use the serial port at a time.
- Run python3 face_follower.py again after making any changes to test the updated behavior.
- Move left, right, and center several times to confirm the robot tracks smoothly without rapid oscillation.
Why does the robot need a dead zone?
Without a dead zone, even tiny movements of your face near a zone boundary would cause the robot to rapidly alternate between left and right commands. This is called "hunting" or "oscillation" in control systems.
A wider center zone means the face has to move further before triggering a turn. This trades tracking precision for stability.
Secret mission
Add Proportional Speed Control
Your robot currently turns at the same speed regardless of how far off-center the face is. That means a face barely outside the center zone triggers the same aggressive turn as a face at the very edge of the frame. Real robots need smoother, more natural movement. Can you make the robot turn faster when the face is far from center and slower when it's close?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud services, so there are no ongoing software costs. The only ongoing drain is the 9V battery if left connected.
Resources you used:
- ~/face-robot project folder (virtual environment, face_follower.py).
- Arduino IDE 2.3.9 application (installed in Applications).
- Python 3.13 (installed from python.org).
- Breadboard circuit (L293D, jumper wires, 9V battery connection).
- Lego chassis assembly (motors, wheels, webcam mount).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your face-following robot.
- Disconnect the 9V battery from the breadboard when not in use to preserve battery life.
- Python 3.13 and Arduino IDE remain available for future projects.
- Your virtual environment and scripts are ready to reactivate anytime.
Pause - I'll come back to this later
Shut down running processes and disconnect power to preserve your battery. Everything stays installed and ready to resume.
- Disconnect the 9V battery from the breadboard.
- Unplug the USB cables (webcam and Arduino) from your Mac.
- Deactivate your virtual environment by running:
- To resume later, reconnect the battery and USB cables, then reactivate the virtual environment and run the script.
deactivate
To resume later, reconnect hardware and reactivate with:
cd ~/face-robot
source venv/bin/activate
python3 face_follower.py
Delete - I don't want to use this again
Remove all project files, uninstall tools, and disassemble the physical circuit. This frees up disk space and returns your hardware to a clean state.
- Disconnect the 9V battery from the breadboard.
- Unplug the USB cables (webcam and Arduino) from your Mac.
- Disassemble the breadboard circuit. Remove all jumper wires and the L293D IC. Return components to your ELEGOO kit.
- Detach the motors and webcam from the Lego chassis. Disassemble the chassis.
- Delete the project folder by running:
rm -rf ~/face-robot
- Delete Arduino IDE by running:
rm -rf "/Applications/Arduino IDE.app"
- Remove Python 3.13 by running:
sudo rm -rf /Library/Frameworks/Python.framework/Versions/3.13
rm -rf "/Applications/Python 3.13"
Why two Python paths?
The python.org installer places the framework in /Library/Frameworks/Python.framework/Versions/3.13 and a shortcut folder in /Applications/Python 3.13. Removing both fully uninstalls Python 3.13 from your system.
Nice Work!
Nice Work!
You just built a robot that can see a human face and physically steer toward it. That is a fully autonomous feedback loop bridging computer vision and hardware.
You've learned how to:
- Wire an L293D H-bridge circuit to drive two DC motors bidirectionally from an Arduino.
- Use OpenCV's Haar cascade face detection to locate a face in real-time video and convert its position into navigation commands.
- Connect a Python vision system to Arduino hardware via USB serial communication, turning visual data into physical movement.
- Added proportional speed control so the robot turns faster when the face is far from center and creeps gently when nearly aligned.
Ready to quiz yourself?