Detection with cascades and DNN modules

Haar cascades, the dnn module for Caffe, ONNX and YOLO models, blob preparation, and post-processing boxes into usable results.

Haar cascades and when to stop using them

import cv2

# cascades ship with OpenCV: the data path is available from cv2.data
face_cascade = cv2.CascadeClassifier(
    cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
eye_cascade = cv2.CascadeClassifier(
    cv2.data.haarcascades + "haarcascade_eye.xml")
profile_cascade = cv2.CascadeClassifier(
    cv2.data.haarcascades + "haarcascade_profileface.xml")

gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.equalizeHist(gray)                  # cascades benefit from even contrast

faces = face_cascade.detectMultiScale(
    gray,
    scaleFactor=1.1,        # how much the window grows each pass: closer to 1 is slower
    minNeighbors=5,         # higher means fewer false positives
    minSize=(30, 30),
    maxSize=(400, 400),
)
print("faces found:", len(faces))

for x, y, w, h in faces:
    cv2.rectangle(image, (x, y), (x + w, y + h), (0, 255, 0), 2)
    roi_gray = gray[y:y + h // 2, x:x + w]
    roi_colour = image[y:y + h // 2, x:x + w]
    eyes = eye_cascade.detectMultiScale(roi_gray, scaleFactor=1.1, minNeighbors=8)
    for ex, ey, ew, eh in eyes:
        cv2.rectangle(roi_colour, (ex, ey), (ex + ew, ey + eh), (255, 0, 0), 2)
PropertyHaar cascadeDNN detector
Speed on CPUVery fastModerate to slow
AccuracyLow, many false positivesMuch higher
Viewpoint robustnessFrontal onlyBroad
TrainingCascade training toolsNeeds a full training pipeline
Best useQuick prototype, embedded CPUAnything user-facing
DependenciesNone beyond OpenCVModel files plus the dnn module
  • Cascades only detect frontal, upright, well-lit faces at a reasonable size. The published performance came from a specific benchmark and does not transfer.
  • scaleFactor near 1.05 searches more scales and is much slower. 1.1 is the usual compromise; 1.3 misses faces.
  • minNeighbors is the precision-recall dial. Raising it removes false positives and starts missing real detections.
  • Use cascades for a quick prototype and replace them before shipping. Their false-positive rate on real scenes is far higher than a demo suggests.

The dnn module

import cv2
import numpy as np

# a pretrained detector, loaded via OpenCV's dnn module
net = cv2.dnn.readNetFromONNX("yolov8n.onnx")
# alternatives: readNetFromCaffe(prototxt, caffemodel), readNetFromTensorflow(pb)

USE_CUDA = False
if USE_CUDA:
    net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
    net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)
    print("CUDA backend enabled")

def detect(net, image, input_size=(640, 640), conf_threshold=0.4, iou_threshold=0.45):
    h, w = image.shape[:2]

    # blobFromImage handles scaling, mean subtraction and channel order
    blob = cv2.dnn.blobFromImage(
        image,
        scalefactor=1 / 255.0,
        size=input_size,
        mean=(0, 0, 0),
        swapRB=True,               # OpenCV is BGR, models expect RGB
        crop=False,
    )
    net.setInput(blob)
    outputs = net.forward()
    print("raw output shape", outputs.shape)

    # YOLOv8 ONNX output is (1, 4 + num_classes, num_boxes)
    predictions = outputs[0].T                     # (num_boxes, 4 + classes)
    boxes, scores, class_ids = [], [], []
    x_scale, y_scale = w / input_size[0], h / input_size[1]

    for row in predictions:
        class_scores = row[4:]
        class_id = int(class_scores.argmax())
        confidence = float(class_scores[class_id])
        if confidence < conf_threshold:
            continue

        cx, cy, bw, bh = row[:4]
        x = int((cx - bw / 2) * x_scale)
        y = int((cy - bh / 2) * y_scale)
        boxes.append([x, y, int(bw * x_scale), int(bh * y_scale)])
        scores.append(confidence)
        class_ids.append(class_id)

    # non-maximum suppression removes overlapping duplicates locally
    indices = cv2.dnn.NMSBoxes(boxes, scores, conf_threshold, iou_threshold)
    kept = [int(i) for i in np.array(indices).flatten()] if len(indices) else []
    return [(boxes[i], scores[i], class_ids[i]) for i in kept]

results = detect(net, image)
for box, score, class_id in results[:10]:
    x, y, bw, bh = box
    cv2.rectangle(image, (x, y), (x + bw, y + bh), (0, 255, 0), 2)
    cv2.putText(image, f"{class_id} {score:.2f}", (x, max(12, y - 6)),
                cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
  • swapRB=True is essential for a model trained on RGB: OpenCV gives you BGR, and without the swap the model sees red and blue exchanged and performs badly.
  • The blob's size must match what the model was trained with. A mismatch is accepted silently and produces plausible-looking nonsense.
  • NMSBoxes expects boxes as [x, y, width, height]. Passing [x1, y1, x2, y2] makes suppression behave erratically rather than raising.
  • The output layout depends on the model's export settings. Print the shape once and write the decoder for that exact layout; every YOLO version differs.

Classical plus DNN

def hybrid_pipeline(image, net, cascade, min_area=1200):
    """Detect candidates cheaply, then confirm the interesting ones with a model."""
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    improved = cv2.equalizeHist(gray)

    candidates = cascade.detectMultiScale(improved, 1.1, 5, minSize=(40, 40))
    if len(candidates) == 0:
        return []

    crops, boxes = [], []
    for x, y, w, h in candidates:
        pad = int(0.15 * w)
        x0, y0 = max(0, x - pad), max(0, y - pad)
        x1, y1 = min(image.shape[1], x + w + pad), min(image.shape[0], y + h + pad)
        crops.append(image[y0:y1, x0:x1])
        boxes.append((x0, y0, x1 - x0, y1 - y0))

    if not crops:
        return []

    batch = np.stack([cv2.dnn.blobFromImage(
        cv2.resize(crop, (64, 64)), 1 / 255.0, (64, 64), (0, 0, 0),
        swapRB=True).ravel() for crop in crops])

    # a classifier stage rejects the cascade's false positives cheaply in a batch
    net.setInput(batch)
    scores = net.forward().ravel()
    confirmed = [box for box, score in zip(boxes, scores) if score > 0.7]
    return confirmed

def annotate(image, boxes, colour=(0, 255, 0)):
    for x, y, w, h in boxes:
        cv2.rectangle(image, (x, y), (x + w, y + h), colour, 2)
    cv2.putText(image, f"{len(boxes)} detected", (10, 28),
                cv2.FONT_HERSHEY_SIMPLEX, 0.7, colour, 2)
    return image
⚠️
Every detector has a threshold, and every threshold encodes a decision about which error is worse. A missing-object threshold and a false-alarm threshold are different products. Choose the operating point from the cost of each error, and record the threshold with the model version.

FAQ

Can I run YOLO in real time with OpenCV's dnn module?
Yes with a small model such as a nano variant: expect tens of milliseconds per frame on a modern CPU and a few milliseconds with the CUDA backend. Use ONNX rather than a framework-specific format for the widest support.
Why are my detections shifted or scaled wrongly?
The blob size and the coordinate scaling do not match the model's input. Compute the scale factors from the actual input size you passed to blobFromImage, and check swapRB.

Contours and object detection basics Video capture and processing

Last refreshed 2026-09-18.