Project Overview
Arduino Uno + OpenCV face tracking pan-tilt webcam: In this project, a Python script on your computer uses OpenCV to detect the biggest face in a webcam feed, then sends pan and tilt angles over USB to an Arduino Uno that drives two servos and three status LEDs so the camera follows you.
Face detection is far too heavy for an 8-bit microcontroller, but trivial for a laptop. This build splits the work: the computer does the vision and sends commands, while the Arduino focuses on smooth servo control and simple status outputs (red when nobody is in view, yellow while it is moving to catch up, green when a face is locked near the center). If you walk out of frame, the head starts a slow search sweep until it finds you again.
- Time: ~2 hours
- Skill level: Intermediate
- What you will build: A webcam on a two-servo pan-tilt head that follows the nearest face, with a Python + OpenCV tracker, an Arduino servo controller, and red/yellow/green status LEDs.
Parts List
From ShillehTek
- Super Starter Kit for Arduino Uno R3 - the Uno plus the LEDs, 220 Ω resistors, breadboard, and wires used here
- Pan-Tilt Servo Bracket Kit for SG90 & MG90S - the mechanical mount for the two-axis camera head
- MG90S Metal Gear Micro Servo ×2 - one for pan and one for tilt
- LM2596 Adjustable Step-Down Module - dedicated 5 V supply for the servos
- Electrolytic Capacitor Kit - use a 470 µF cap across the servo power rail
- Dupont Jumper Wires - for breadboard wiring
External
- A light USB webcam (under about 100 g) and a Windows, macOS, or Linux computer with Python 3
- A 9 to 12 V DC adapter to feed the LM2596
- Zip ties or a small screw to fix the webcam to the tilt bracket
Note: Do not run two servos from the Uno's 5V pin. A sudden move can pull more current than USB can supply and reset the board mid-track. Set the LM2596 to 5.0 V before connecting anything, and join its ground to the Uno's ground. All video processing happens on your own computer; nothing is uploaded anywhere.
Step-by-Step Guide
Step 1 - Wire the servos, LEDs, and power
Goal: Two servos with their own supply, three status lights, and one USB link to the computer.
What to do: Connect the DC adapter to the LM2596 input and turn its trimmer until the output reads 5.0 V on a multimeter. LM2596 OUT+ goes to both servos' red wires. LM2596 OUT- goes to both servos' brown wires and Uno GND. Put a 470 µF capacitor across the servo rail (stripe to ground). Pan servo signal (orange) goes to D8 and tilt servo signal goes to D9.
For the LEDs: red LED to D2, yellow LED to D3, green LED to D4. Connect each LED long leg to the pin, and the short leg through a 220 Ω resistor to GND. Power and control the Uno over its USB cable.
Expected result: A clean 5 V servo rail that shares ground with the Uno, and three LEDs on the breadboard.
Step 2 - Upload the Arduino sketch
Goal: Read pan, tilt, and state messages from the computer, then drive two servos and three LEDs.
Code:
#include <Servo.h>
Servo panServo, tiltServo;
const byte PAN_PIN = 8, TILT_PIN = 9;
const byte RED = 2, YELLOW = 3, GREEN = 4; // no face / moving to it / locked on
char buf[20];
byte n = 0;
bool inMsg = false;
unsigned long lastMsg = 0;
void showState(int s) {
digitalWrite(RED, s == 0);
digitalWrite(YELLOW, s == 1);
digitalWrite(GREEN, s == 2);
}
void setup() {
Serial.begin(115200);
pinMode(RED, OUTPUT);
pinMode(YELLOW, OUTPUT);
pinMode(GREEN, OUTPUT);
panServo.attach(PAN_PIN);
tiltServo.attach(TILT_PIN);
panServo.write(90); // straight ahead
tiltServo.write(90); // level
showState(0);
}
void loop() {
while (Serial.available()) { // messages look like <92,85,1>
char c = Serial.read();
if (c == '<') {
inMsg = true;
n = 0;
} else if (c == '>' && inMsg) {
buf[n] = 0;
inMsg = false;
int pan, tilt, state;
if (sscanf(buf, "%d,%d,%d", &pan, &tilt, &state) == 3) {
panServo.write(constrain(pan, 0, 180));
tiltServo.write(constrain(tilt, 20, 160));
showState(state);
lastMsg = millis();
}
} else if (inMsg && n < sizeof(buf) - 1) {
buf[n++] = c;
}
}
if (millis() - lastMsg > 2000) showState(0); // the PC script stopped talking
}
What to do: Upload to the Uno (board: Arduino Uno). The start and end markers mean a half-received message is ignored, so a glitch cannot send a servo to a random angle.
Expected result: Both servos snap to 90° and hold there, and the red LED is on.
Step 3 - Assemble the pan-tilt head
Goal: Mechanical center that matches software center.
What to do: With the sketch running and the servos holding 90°, build the bracket kit: fit the pan servo in the base and the tilt servo in the upper bracket. Press on the horns so the head faces straight ahead and the tilt plate is level.
Fix the webcam to the tilt plate with zip ties or a screw, as close to the tilt axis as you can. A camera hanging far forward makes the small servo work much harder. Leave a loop of slack in the webcam's USB cable so it never pulls against the pan movement.
Expected result: A head that looks straight at you when both servos are at 90°.
Step 4 - Run the Python OpenCV face tracker
Goal: Detect a face on the computer and stream servo angles and a state value to the Arduino over USB serial.
Code:
# face_tracker.py - pip install opencv-python pyserial
import time
import cv2
import serial
PORT = "COM5" # Windows COMx, macOS /dev/cu.usbserial-xxxx or /dev/cu.usbmodemxxxx, Linux /dev/ttyACM0
CAMERA = 0 # 0 = first webcam; try 1 if a laptop's built-in camera opens instead
KP = 0.02 # degrees per pixel of error, per frame
DEAD = 30 # pixels from the centre that count as "locked on"
PAN_DIR, TILT_DIR = 1, -1 # flip a sign if the camera turns away from your face
arduino = serial.Serial(PORT, 115200, timeout=0)
time.sleep(2) # the Uno resets when the port opens
cam = cv2.VideoCapture(CAMERA)
cam.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cam.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
detector = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
pan, tilt = 90.0, 90.0
last_seen, sweep = time.time(), 1
while True:
ok, frame = cam.read()
if not ok:
break
h, w = frame.shape[:2]
gray = cv2.equalizeHist(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))
faces = detector.detectMultiScale(gray, scaleFactor=1.2, minNeighbors=6, minSize=(60, 60))
if len(faces):
x, y, fw, fh = map(int, max(faces, key=lambda f: f[2] * f[3])) # biggest (closest) face
ex = (x + fw / 2) - w / 2 # horizontal error in pixels
ey = (y + fh / 2) - h / 2 # vertical error in pixels
if abs(ex) > DEAD:
pan -= PAN_DIR * KP * ex
if abs(ey) > DEAD:
tilt -= TILT_DIR * KP * ey
state = 2 if abs(ex) <= DEAD and abs(ey) <= DEAD else 1
last_seen = time.time()
colour = (0, 255, 0) if state == 2 else (0, 200, 255)
cv2.rectangle(frame, (x, y), (x + fw, y + fh), colour, 2)
else:
state = 0
if time.time() - last_seen > 3: # nobody for 3 s: slow search sweep
pan += sweep * 0.8
if pan > 150 or pan < 30:
sweep = -sweep
tilt += (90 - tilt) * 0.05 # drift back to level while searching
pan = max(0, min(180, pan))
tilt = max(20, min(160, tilt))
arduino.write(f"<{int(pan)},{int(tilt)},{state}>".encode())
cv2.circle(frame, (w // 2, h // 2), DEAD, (255, 255, 255), 1)
cv2.imshow("ShillehTek face tracker - press q to quit", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cam.release()
cv2.destroyAllWindows()
arduino.close()
What to do: Install the packages with pip install opencv-python pyserial. Close the Arduino Serial Monitor (only one program can use the port). Set PORT to the Uno's port as shown in the Arduino IDE, plug in the webcam, and run python face_tracker.py. The Haar cascade file ships inside OpenCV, so there is nothing else to download.
Expected result: A window shows the camera view with a box around your face. Move sideways and the head turns to follow, with the LED yellow while it catches up and green once your face sits inside the small center circle. Step out of view: red, and after three seconds the head starts sweeping left and right until it finds you.
Step 5 - Tune the response
Goal: Smooth, confident tracking instead of jitter or chasing.
What to do: If the head turns away from you, flip the sign of PAN_DIR (left to right) or TILT_DIR (up to down). It depends on which way round the servos are mounted. KP sets the speed: around 0.01 is calm, 0.04 is snappy. If the head swings past your face and back, it is too high because the camera is always a few frames behind the servos. A larger DEAD zone stops the head hunting when you are nearly centered.
For detection, good front lighting matters more than anything. Lower minNeighbors to catch faces sooner (at the cost of more false boxes) or raise minSize to ignore faces far away.
Expected result: The head settles on your face in well under a second and stays there while you move around the room.
Step 6 - Take it further
Goal: Extend the same platform to more capable tracking and new use cases.
What to do: The Haar cascade mainly finds faces looking at the camera. OpenCV's DNN detector (cv2.FaceDetectorYN with the small YuNet model file) also handles turned heads and dim rooms. Swap face detection for color tracking (an HSV threshold and cv2.findContours) to follow a ball or a marker. Save a clip with cv2.VideoWriter whenever a face appears for a simple person-aware camera. Or replace the Uno with an ESP32 that receives the same <pan,tilt,state> messages over Wi-Fi, so the head can sit anywhere in the room.
Expected result: A vision-guided pan-tilt platform you can point at whatever problem comes next.
Conclusion
You built a face-tracking camera by giving each part the job it is best at: OpenCV on the computer does the seeing, a short Python loop turns pixel errors into angles, and an Arduino Uno drives two MG90S servos on a pan-tilt bracket, with three LEDs indicating tracking state.
Want the exact parts used in this build? Grab them from ShillehTek.com. If you want help customizing this project or building something for your product, check out our IoT consulting services.
Credits
All photos and images in this tutorial are credited to Little_french_kev on Hackster.io. The original guide by Little_french_kev served as the reference for this ShillehTek version. We thank them for their excellent work in the maker community.








