AI & Computer Vision, lesson 25 of 52
Real-Time Detection: Making It Fast on a Laptop
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 windowFPS and latency
| Term | Meaning | Rule of thumb |
|---|---|---|
| FPS | Frames processed per second | 15 or more looks smooth enough for tracking a game; 30 feels live |
| Latency | Delay from the real event to seeing it on screen | Under about 100 ms feels instant |
| Frame time | 1000 / FPS in milliseconds | 15 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-up | How it helps | The cost |
|---|---|---|
A smaller model (nano n instead of s or m) | Fewer calculations | Lower accuracy, especially the ball |
A smaller image (imgsz 640 to 480 or 320, or shrinking the frame) | Time falls roughly with the number of pixels | Small objects like the ball vanish first |
| Frame skipping (detect every 2nd or 3rd frame, reuse the boxes) | Fewer model calls | Boxes lag fast movement. Tracking (lesson 26) can fill the gaps. |
| Optimised export (ONNX, OpenVINO, TensorRT) | Same model, run by a faster engine | Needs the export step. Behaviour should be the same. |
| A background reader thread | No waiting on the camera, and no stale frames | A little more code |
| A GPU | Huge speed-up | Needs 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_courseenvironment 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:
DETECTOR = "colour": every stage is tiny, so the loop is fast. This is your baseline.DETECTOR = "slow": the detect stage dominates (about 99%).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.
| Setting | FPS (simulated detector) | What it tells us |
|---|---|---|
| Baseline: detect every frame, full size | 16 | Detect is 99% of the time |
DETECT_EVERY = 3 | 48 | Close to 3 times faster: one call in three |
SCALE = 0.5 | 62 | A quarter of the pixels, so about a quarter of the time |
| Both | 170 | The 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
| Run | Model | imgsz | Skip | Format | FPS | Ball found in the test clip? (yes or no, or recall) |
|---|---|---|---|---|---|---|
| 1 | best.pt | 640 | 1 | PyTorch | ||
| 2 | best.pt | 480 | 1 | PyTorch | ||
| 3 | best.pt | 640 | 2 | PyTorch | ||
| 4 | best.onnx | 640 | 1 | ONNX | ||
| 5 | best | the best combination | Goal: over 15 FPS |
If it goes wrong
| Error | Cause | Fix |
|---|---|---|
| FPS is much lower than expected | imshow or the camera is the bottleneck | Read the stage table. Try SHOW = False to see the model alone. |
| The video lags behind real life | Frames queue up in the camera buffer | Use the LatestFrame thread |
ModuleNotFoundError: demo_clip | The lesson 5 helper is not beside the script | Copy it, or point SOURCE at any video |
| Boxes jump or flicker with skipping | Detections only every Nth frame | Lower the skip, or use tracking (lesson 26) |
| ONNX is not faster | Small model, or the CPU is already efficient | Report it honestly. Try OpenVINO on an Intel CPU. |
Ball disappears at imgsz=320 | Too few pixels on the ball | Keep imgsz at 480 or above for the ball, or crop around the pitch |
| First frame is slow | Model warm-up | Ignore the first frame in timing |
cv2.imshow error on a remote machine | No display | Set 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
What sets the speed of the whole pipeline?
Why change only one thing at a time?
What does frame skipping cost?
Why read the webcam in a background thread?
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.

