AI & Computer Vision, lesson 25 of 52

Real-Time Detection: Making It Fast on a Laptop

intermediate26 min read

Introduction

A detector that takes half a second per frame isn't much use at a live match. Today you'll build the live pipeline, time every stage of it, find the slow one, and speed it up four different ways, keeping an honest record of what each trick gains and what it costs.

By the end of this lesson you'll be able to:

  • Build the four-stage live pipeline: capture, detect, annotate, display
  • Measure frames per second (FPS) and the time each stage takes
  • Find the bottleneck and fix that stage first
  • Apply four speed-ups: a smaller model, a smaller image, frame skipping and an optimised export (ONNX or OpenVINO)
  • Read a camera in a background thread so the detector always sees the newest frame
  • Report honestly what each change gained, and what it cost in accuracy

Working principle

The four-stage pipeline

capture -> detect -> annotate -> display
 camera    model     draw boxes   window

FPS and latency

TermMeaningRule of thumb
FPSFrames processed per second15 or more looks smooth enough for tracking a game; 30 feels live
LatencyDelay from the real event to seeing it on screenUnder about 100 ms feels instant
Frame time1000 / FPS in milliseconds15 FPS means 67 ms per frame for all stages together

Always measure first

Wrap each stage in a timer. The stage with the largest share of time is the bottleneck. Common surprises: imshow can be slow, and a webcam can sit and wait for the next frame.

One frame takes 69 ms, so about 14 FPS. Detection is the bottleneck.

The speed-ups

Speed-upHow it helpsThe cost
A smaller model (nano n instead of s or m)Fewer calculationsLower accuracy, especially the ball
A smaller image (imgsz 640 to 480 or 320, or shrinking the frame)Time falls roughly with the number of pixelsSmall objects like the ball vanish first
Frame skipping (detect every 2nd or 3rd frame, reuse the boxes)Fewer model callsBoxes lag fast movement. Tracking (lesson 26) can fill the gaps.
Optimised export (ONNX, OpenVINO, TensorRT)Same model, run by a faster engineNeeds the export step. Behaviour should be the same.
A background reader threadNo waiting on the camera, and no stale framesA little more code
A GPUHuge speed-upNeeds the hardware

Change one thing at a time and re-measure. Otherwise you won't know what helped.

The accuracy trade-off

Every speed-up that costs accuracy must be checked on the lesson 19/lesson 24 metrics, especially ball recall. "Faster" is only useful if it still finds the ball.

What you'll need

  • Your cv_course environment with Ultralytics
  • Your trained model from lesson 24 (or yolov8n.pt)
  • A webcam or a match clip

Build it

Step 1: the pipeline with timers

Put your lesson 5 demo_clip.py helper beside this file (it makes the test video). The default detector is the lesson 8 colour tracker, which is nearly instant. Set DETECTOR = "slow" to simulate a heavy model, and DETECTOR = "yolo" to use your best.pt (needs pip install ultralytics).

import os
import threading
import time
from collections import defaultdict
 
import cv2
import numpy as np
 
# ---------------- SETTINGS (change one at a time and measure!) ----------------
SOURCE = "demo_clip.mp4"        # a video file, or 0 for the webcam
DETECT_EVERY = 1                # run the detector on every Nth frame (3 = skip two of every three)
SCALE = 1.0                     # shrink the frame before detecting (0.5 = half width and height)
DETECTOR = "colour"             # "colour" (lesson 8, instant) | "slow" (simulates a heavy model) | "yolo" (needs ultralytics)
SIMULATED_MS = 60               # how long the pretend heavy model takes per frame
SHOW = True                     # False = no window (benchmark only)
MAX_FRAMES = 150                # stop after this many frames when benchmarking (0 = run until the end or 'q')
# ------------------------------------------------------------------------------
 
 
class ColourBall:
    """The lesson 8 detector: finds an orange ball by colour. Fast, but only a stand-in for YOLO."""
    LOWER, UPPER = (8, 150, 120), (25, 255, 255)
 
    def __call__(self, frame):
        hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
        mask = cv2.inRange(hsv, self.LOWER, self.UPPER)
        contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        boxes = []
        for c in contours:
            if cv2.contourArea(c) >= 40:
                x, y, w, h = cv2.boundingRect(c)
                boxes.append((x, y, x + w, y + h, "ball", 0.9))
        return boxes
 
 
class SlowDetector:
    """Wraps another detector and adds a delay, to practise speed-ups without a GPU.
    Real models take roughly time proportional to the number of pixels, so the pretend delay does too."""
 
    def __init__(self, inner, milliseconds, reference_pixels=640 * 360):
        self.inner, self.delay, self.ref = inner, milliseconds / 1000, reference_pixels
 
    def __call__(self, frame):
        time.sleep(self.delay * frame.shape[0] * frame.shape[1] / self.ref)
        return self.inner(frame)
 
 
class YoloDetector:
    def __init__(self, weights="best.pt", conf=0.25):
        from ultralytics import YOLO
        self.model, self.conf = YOLO(weights), conf
 
    def __call__(self, frame):
        result = self.model(frame, conf=self.conf, verbose=False)[0]
        out = []
        for b in result.boxes:
            x1, y1, x2, y2 = (int(v) for v in b.xyxy[0].tolist())
            out.append((x1, y1, x2, y2, self.model.names[int(b.cls[0])], float(b.conf[0])))
        return out
 
 
def make_detector():
    if DETECTOR == "yolo":
        return YoloDetector()
    if DETECTOR == "slow":
        return SlowDetector(ColourBall(), SIMULATED_MS)
    return ColourBall()
 
 
timers = defaultdict(list)
 
 
def timed(name, fn, *args):
    t = time.perf_counter()
    out = fn(*args)
    timers[name].append((time.perf_counter() - t) * 1000)
    return out
 
 
def draw(frame, boxes):
    for (x1, y1, x2, y2, label, conf) in boxes:
        cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
        cv2.putText(frame, f"{label} {conf:.2f}", (x1, max(12, y1 - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
    return frame
 
 
def detect_scaled(detector, frame):
    """Detect on a smaller copy, then scale the boxes back up to the full-size frame."""
    if SCALE == 1.0:
        return detector(frame)
    small = cv2.resize(frame, None, fx=SCALE, fy=SCALE)
    return [(int(x1 / SCALE), int(y1 / SCALE), int(x2 / SCALE), int(y2 / SCALE), label, conf)
            for (x1, y1, x2, y2, label, conf) in detector(small)]
 
 
def main():
    if isinstance(SOURCE, str) and not os.path.exists(SOURCE):
        from demo_clip import make_demo_clip            # the lesson 5 helper
        make_demo_clip(SOURCE)
    cap = cv2.VideoCapture(SOURCE)
    detector = make_detector()
    boxes, frame_no = [], 0
    start = time.perf_counter()
 
    while True:
        ret, frame = timed("1 capture", cap.read)
        if not ret:
            break
        if frame_no % DETECT_EVERY == 0:                             # frame skipping: reuse the last boxes in between
            boxes = timed("2 detect", detect_scaled, detector, frame)
        frame = timed("3 annotate", draw, frame, boxes)
        if SHOW:
            timed("4 display", cv2.imshow, "pipeline", frame)
            if cv2.waitKey(1) & 0xFF == ord("q"):
                break
        frame_no += 1
        if MAX_FRAMES and frame_no >= MAX_FRAMES:
            break
 
    seconds = time.perf_counter() - start
    cap.release()
    cv2.destroyAllWindows()
 
    print(f"\n{frame_no} frames in {seconds:.2f}s  ->  {frame_no / seconds:.1f} FPS   "
          f"(detector={DETECTOR}, every {DETECT_EVERY}, scale {SCALE})")
    print(f"{'stage':<12}{'calls':>7}{'avg ms':>9}{'share of time':>15}")
    total = sum(sum(v) for v in timers.values())
    for name in sorted(timers):
        v = timers[name]
        print(f"{name:<12}{len(v):>7}{np.mean(v):>9.2f}{100 * sum(v) / total:>14.0f}%")
    slowest = max(timers, key=lambda k: sum(timers[k]))
    print("Slowest stage overall:", slowest, " <- speed this one up first")
 
 
if __name__ == "__main__":
    main()

Run it three ways and read the table it prints at the end:

  1. DETECTOR = "colour": every stage is tiny, so the loop is fast. This is your baseline.
  2. DETECTOR = "slow": the detect stage dominates (about 99%).
  3. DETECTOR = "yolo": the real thing.

Step 2: record each change

Fill this in with your own numbers. These are from my run with the simulated 60 ms detector on a 640 by 360 clip. They show the pattern, not real YOLO speeds.

SettingFPS (simulated detector)What it tells us
Baseline: detect every frame, full size16Detect is 99% of the time
DETECT_EVERY = 348Close to 3 times faster: one call in three
SCALE = 0.562A quarter of the pixels, so about a quarter of the time
Both170The effects multiply (until other stages become the limit)

Questions: with DETECT_EVERY = 3 the box lags behind a fast ball. Move the ball faster in the demo clip and see. What would fill the gaps between detections? (Hint: lesson 26.)

Step 3: read the camera in a background thread

For a webcam, cap.read() waits for the next frame, and frames queue up while the detector is busy, so the picture you see gets older and older. This helper keeps only the newest frame. A video file would be read instantly by a thread like this, so the test below uses a pretend 30 FPS camera.

import threading
import time
 
import cv2
 
 
class LatestFrame:
    """Reads the camera in a background thread and always keeps only the NEWEST frame.
    The slow detector never works on old frames, and the camera buffer never fills up (no growing lag)."""
 
    def __init__(self, source=0, capture=None):
        self.cap = capture if capture is not None else cv2.VideoCapture(source)
        self.frame, self.ok, self.running = None, False, True
        self.lock = threading.Lock()
        threading.Thread(target=self._run, daemon=True).start()
 
    def _run(self):
        while self.running:
            ok, frame = self.cap.read()
            with self.lock:
                self.ok = ok
                if ok:
                    self.frame = frame              # keep the last good frame if the camera stops
            if not ok:
                break
 
    def read(self):
        with self.lock:
            return self.ok, (None if self.frame is None else self.frame.copy())
 
    def release(self):
        self.running = False
        self.cap.release()
 
 
# Try it with a pretend 30 FPS camera (a real webcam is slow like this; a video file would be read instantly)
class PretendCamera:
    def __init__(self, path):
        self.inner = cv2.VideoCapture(path)
 
    def read(self):
        time.sleep(1 / 30)
        return self.inner.read()
 
    def release(self):
        self.inner.release()
 
 
grabber = LatestFrame(capture=PretendCamera("demo_clip.mp4"))
time.sleep(0.2)
 
seen = set()
for _ in range(20):
    ok, frame = grabber.read()
    if ok and frame is not None:
        seen.add(int(frame.sum()))         # a rough fingerprint of the frame, to count distinct frames
    time.sleep(0.05)                       # pretend the detector takes 50 ms per frame
grabber.release()
print("distinct frames processed in 1 second:", len(seen), "(the camera made ~30; we kept up with the newest, not the oldest)")

To use it in pipeline.py, replace cap = cv2.VideoCapture(SOURCE) with cap = LatestFrame(SOURCE). The rest of the loop stays the same.

Step 4: the real model, and an optimised export

Needs best.pt from lesson 24 and a test image. ONNX Runtime is installed with the export. Export once, then time both versions on the same frame. The first call is always slower, so the script does a warm-up.

import time
 
import cv2
import numpy as np
from ultralytics import YOLO
 
# 1. Export once (writes best.onnx next to best.pt). Other formats exist, for example "openvino" for Intel CPUs.
YOLO("best.pt").export(format="onnx", imgsz=640)
 
# 2. Compare speed: same frame, same settings
frame = cv2.imread("test_frame.jpg")
for name in ("best.pt", "best.onnx"):
    model = YOLO(name)
    model(frame, verbose=False)                                  # warm-up run (the first call is always slow)
    times = []
    for _ in range(20):
        t = time.perf_counter()
        model(frame, imgsz=640, verbose=False)
        times.append((time.perf_counter() - t) * 1000)
    print(f"{name:12s} {np.mean(times):6.1f} ms per frame  ({1000 / np.mean(times):.1f} FPS)")

Results depend heavily on your laptop. ONNX is often a little faster on a CPU; the gain on your machine may be small or large. Other formats (OpenVINO for Intel CPUs, TensorRT for NVIDIA GPUs) can give more: see the Ultralytics export documentation for the current list.

Questions: which speed-up helped most on your laptop? Did any change make the ball harder to find? Check ball recall with the lesson 24 validation command at the lower imgsz.

Results table: template

RunModelimgszSkipFormatFPSBall found in the test clip? (yes or no, or recall)
1best.pt6401PyTorch
2best.pt4801PyTorch
3best.pt6402PyTorch
4best.onnx6401ONNX
5bestthe best combinationGoal: over 15 FPS

If it goes wrong

ErrorCauseFix
FPS is much lower than expectedimshow or the camera is the bottleneckRead the stage table. Try SHOW = False to see the model alone.
The video lags behind real lifeFrames queue up in the camera bufferUse the LatestFrame thread
ModuleNotFoundError: demo_clipThe lesson 5 helper is not beside the scriptCopy it, or point SOURCE at any video
Boxes jump or flicker with skippingDetections only every Nth frameLower the skip, or use tracking (lesson 26)
ONNX is not fasterSmall model, or the CPU is already efficientReport it honestly. Try OpenVINO on an Intel CPU.
Ball disappears at imgsz=320Too few pixels on the ballKeep imgsz at 480 or above for the ball, or crop around the pitch
First frame is slowModel warm-upIgnore the first frame in timing
cv2.imshow error on a remote machineNo displaySet SHOW = False and save a video instead

Try it

  • Add a rolling FPS counter drawn on the video
  • Crop the frame to the pitch area before detecting and measure the gain
  • Make the skip adaptive: detect more often when the ball moves fast

Check what you learnt

  1. What sets the speed of the whole pipeline?

  2. Why change only one thing at a time?

  3. What does frame skipping cost?

  4. Why read the webcam in a background thread?

  5. Why check ball recall after shrinking the image?

Conclusion

A live pipeline is capture, detect, annotate, display, and it runs only as fast as its slowest stage. Measure each stage first, then try the speed-ups (a smaller model, a smaller image, frame skipping, an optimised export, a camera thread), one at a time, and always check what you lost in accuracy, especially for the ball.

What next?

Next: Object tracking: following players. You'll give every player an ID that sticks from frame to frame.