A 720p webcam frame is 921,600 pixels. Your face occupies maybe 40,000 of them. Everything in face detection is about finding that region fast enough that the next frame arrives before you finish. Do it in 5 ms and you have room for recognition, tracking, and blur. Do it in 200 ms and your app feels broken.
This tutorial covers three detectors that all run from Python, all read from a webcam, and all differ by roughly two orders of magnitude in accuracy-per-millisecond. Code first, opinions after.
Setup
python -m venv .venv && source .venv/bin/activate
pip install opencv-python numpy
# only if you want YOLO:
pip install ultralyticsOn Linux, opencv-python ships without GStreamer and with a generic V4L2 backend. That's fine for a USB webcam. If you need RTSP from an IP camera without stuttering, build OpenCV with FFmpeg or use opencv-python-headless plus a separate capture library.
Option 1: Haar cascades
The classic. Viola-Jones, 2001. It slides a window across a grayscale image pyramid and evaluates a cascade of tiny rectangular filters. Most windows get rejected in the first two or three stages, which is why it was fast enough for 2001 hardware and is still fast today.
import cv2
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
detector = cv2.CascadeClassifier(cascade_path)
cap = cv2.VideoCapture(0)
while True:
ok, frame = cap.read()
if not ok:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.equalizeHist(gray)
faces = detector.detectMultiScale(
gray,
scaleFactor=1.1,
minNeighbors=5,
minSize=(60, 60),
)
for (x, y, w, h) in faces:
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.imshow("haar", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()The three parameters that actually matter:
| Parameter | What it does |
|---|---|
scaleFactor |
How much the image shrinks between pyramid levels. 1.05 finds more faces and costs roughly 2x the time of 1.2. Below 1.03 you are wasting CPU. |
minNeighbors |
How many overlapping detections must agree before a box is reported. 3 is noisy, 5 is the usual default, 8 starts dropping real faces. |
minSize |
Smallest face in pixels. Raising this is the cheapest speedup available — (80, 80) on a 720p frame can halve runtime. |
What it costs you: Haar is frontal-only. Turn your head 30 degrees and it vanishes. Wear glasses in harsh backlight and it vanishes. It also produces false positives on textured backgrounds — bookshelves are notorious. On a modern laptop CPU it runs about 25-40 FPS at 640x480 single-threaded.
Option 2: OpenCV's DNN face detector (ResNet-10 SSD)
This is the one most people should be using and most tutorials skip. OpenCV ships a Caffe-trained SSD with a ResNet-10 backbone. It handles profile views, partial occlusion, and small faces far better than Haar, and it still runs on CPU.
Grab the two files:
curl -LO https://raw.githubusercontent.com/opencv/opencv/master/samples/dnn/face_detector/deploy.prototxt
curl -LO https://raw.githubusercontent.com/opencv/opencv_3rdparty/dnn_samples_face_detector_20170830/res10_300x300_ssd_iter_140000.caffemodelimport cv2
import numpy as np
net = cv2.dnn.readNetFromCaffe(
"deploy.prototxt",
"res10_300x300_ssd_iter_140000.caffemodel",
)
net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
CONF = 0.6
cap = cv2.VideoCapture(0)
while True:
ok, frame = cap.read()
if not ok:
break
h, w = frame.shape[:2]
blob = cv2.dnn.blobFromImage(
frame, scalefactor=1.0, size=(300, 300),
mean=(104.0, 177.0, 123.0), swapRB=False, crop=False,
)
net.setInput(blob)
detections = net.forward() # shape (1, 1, N, 7)
for i in range(detections.shape[2]):
conf = float(detections[0, 0, i, 2])
if conf < CONF:
continue
box = detections[0, 0, i, 3:7] * np.array([w, h, w, h])
x1, y1, x2, y2 = box.astype(int)
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 200, 255), 2)
cv2.putText(frame, f"{conf:.2f}", (x1, y1 - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 255), 1)
cv2.imshow("dnn", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()Two details people get wrong. The mean values (104, 177, 123) are BGR and non-negotiable — this model was trained with them, and using ImageNet means instead will silently tank your recall. And swapRB must be False because OpenCV already gives you BGR.
The 300x300 input is a hard ceiling on small-face performance. A face that's 20 pixels wide in a 1080p frame becomes 5 pixels after resize and will not be found. If you need distant faces, tile the frame and run inference per tile, or move to YOLO.
Speed on a mid-range CPU: roughly 15-25 FPS at 300x300. It is nearly frame-rate independent of resolution because everything gets squashed to 300x300 anyway.
Option 3: YOLOv8-face
YOLO treats face detection as one-class object detection. The Ultralytics package makes this a three-line affair, and pretrained face weights (yolov8n-face.pt) are widely mirrored on GitHub and Hugging Face.
from ultralytics import YOLO
import cv2
model = YOLO("yolov8n-face.pt")
cap = cv2.VideoCapture(0)
while True:
ok, frame = cap.read()
if not ok:
break
results = model.predict(frame, imgsz=640, conf=0.4, verbose=False)[0]
for box in results.boxes:
x1, y1, x2, y2 = map(int, box.xyxy[0])
cv2.rectangle(frame, (x1, y1), (x2, y2), (255, 80, 0), 2)
cv2.imshow("yolo", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()The n (nano) variant is about 3.2M parameters. On CPU expect 8-15 FPS at imgsz=640; on a modest CUDA GPU, 100+ FPS. Drop to imgsz=320 and CPU throughput roughly triples with a real cost in small-face recall.
What you get for the extra compute: faces at extreme angles, heavy occlusion, motion blur, tiny faces in crowds. On WIDER FACE hard subset, YOLOv8n-face lands far above the ResNet-10 SSD. What you pay: a PyTorch dependency measured in hundreds of megabytes, plus a cold-start of a couple of seconds.
One warning: model.predict() in a tight loop is fine, but model.track() (ByteTrack) is what you want if you need stable IDs across frames. Re-running detection and matching boxes yourself is a waste of an afternoon.
When to use which
| Option | Best for | Avoid when |
|---|---|---|
| Haar cascade | Raspberry Pi, no extra deps, cooperative frontal faces | Profile views, cluttered backgrounds, any accuracy target |
| OpenCV DNN SSD | CPU-only apps, webcam at arm's length, 10 MB deploy budget | Faces under ~30 px, crowds, extreme pose |
| YOLOv8-face | Crowds, surveillance, GPU available, need landmarks/tracking | Tiny binaries, no PyTorch allowed, sub-5 ms budget on CPU |
Decision rules that hold up in practice:
- If a GPU is available at all, use YOLO. The accuracy gap is large and the latency cost disappears.
- If you are shipping a desktop app and want one
pip install, use the OpenCV DNN detector. It is the best accuracy-per-dependency deal in this list. - Reach for Haar only when your compute budget is genuinely tiny (Pi Zero, microcontroller-class) and subjects look at the camera on purpose — kiosks, photo booths, attendance terminals.
- If your false-positive rate is the complaint, raise the confidence threshold before you change models. Going from 0.5 to 0.7 on the SSD kills most background hits.
- If your miss rate is the complaint on distant faces, resolution is your problem, not the model. Increase
imgszor tile the frame.
Webcam details that cost people hours
Set the capture resolution explicitly instead of accepting the driver default, and drain the buffer if your processing is slower than the camera:
cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) # Windows: avoids ~2s open delay
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # latest frame, not oldest
cap.set(cv2.CAP_PROP_FPS, 30)CAP_PROP_BUFFERSIZE is the fix for the classic "my detections lag three seconds behind reality" bug. Not all backends honor it; if yours doesn't, run capture in a thread that overwrites a single-slot frame variable and let the main loop read whatever is current.
And measure. Wrap your detector call, not the whole loop:
import time
t0 = time.perf_counter()
faces = detect(frame)
ms = (time.perf_counter() - t0) * 1000If imshow is eating 8 ms per frame — and on some Linux window managers it does — you'll never see that from an end-to-end FPS counter.
Next step: pick the DNN detector, get it running on your own webcam, then feed the cropped boxes into a recognition model like ArcFace or face_recognition. Detection is the cheap half of the problem.