AI & Computer Vision, lesson 8 of 52
Contours & Colour Tracking: Follow the Ball
Introduction
In lesson 4 you made a mask that showed where a colour is. A mask is just white blobs, though. Today you'll turn those blobs into shapes the computer can measure: how big, where the middle is. Then you'll do it on every frame and follow a ball around your webcam, drawing its path as it moves.
By the end of this lesson you'll be able to:
- Find the contours (outlines) of white blobs in a mask with
cv2.findContours - Measure a contour's area, bounding box and centroid (its centre point)
- Filter out noise by area and by roundness
- Track a coloured ball across frames and draw its path
- Say why colour tracking breaks when the light, the background or the ball colour changes
Working principle
From mask to shapes
In lesson 4 you made a mask: white where the colour matches, black elsewhere. A contour is the list of points around the edge of a white blob.
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)RETR_EXTERNALkeeps only outer outlines, not holes inside them.CHAIN_APPROX_SIMPLEstores only the corner points, which saves memory.- In OpenCV 4 and 5 it returns two things. (Old tutorials for OpenCV 3 show three.)
- It needs a white-on-black mask, so clean it first (lesson 7).
Measuring a contour
| Measure | Code | Use |
|---|---|---|
| Area | cv2.contourArea(c) | Ignore tiny specks and huge blobs |
| Bounding box | x, y, w, h = cv2.boundingRect(c) | Draw a box, crop the object |
| Centroid | m = cv2.moments(c), then cx = m["m10"] / m["m00"], cy = m["m01"] / m["m00"] | The ball's position |
| Perimeter | cv2.arcLength(c, True) | For roundness |
| Roundness | 4 * pi * area / perimeter ** 2 | 1.0 is a perfect circle. A ball scores close to 1. |
Check m["m00"] is not zero before dividing.
The colour tracking recipe
- Read a frame. Blur it a little.
- Convert to HSV.
- Mask the ball's colour with
inRange. - Open then close the mask to clean it.
- Find the contours. Keep the ones of a sensible area and roundness.
- Take the biggest. Use its centroid as the ball position.
- Add the position to a trail (
collections.dequewithmaxlen=30) and draw the trail.
When it breaks
| Problem | Why |
|---|---|
| Other orange things (a cone, a shirt) | The mask only knows colour, not "ball" |
| The ball goes dark in shade | The value falls below your lower bound |
| Motion blur | The ball smears into a long streak and its shape changes |
| The ball is hidden | There is no contour at all, so the code must cope with None |
This is exactly why lesson 23 onwards use a trained detector. Colour tracking is the fast, simple baseline you compare against.
What you'll need
- Your
cv_courseenvironment and a webcam - A brightly coloured ball (orange or yellow works best), or any bright object
Build it
Step 1: contours on a still frame
import cv2
import numpy as np
from demo_clip import make_demo_clip
make_demo_clip("demo_clip.mp4") # from lesson 5, so this runs without footage
cap = cv2.VideoCapture("demo_clip.mp4")
cap.set(cv2.CAP_PROP_POS_FRAMES, 60)
ret, frame = cap.read()
cap.release()
hsv = cv2.cvtColor(cv2.GaussianBlur(frame, (7, 7), 0), cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, (8, 150, 120), (25, 255, 255)) # orange
kernel = np.ones((3, 3), np.uint8)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
print("Contours found:", len(contours))
for c in contours:
area = cv2.contourArea(c)
x, y, w, h = cv2.boundingRect(c)
m = cv2.moments(c)
if m["m00"] == 0:
continue
cx, cy = int(m["m10"] / m["m00"]), int(m["m01"] / m["m00"])
print(f"area={area:.0f} box=({x},{y},{w},{h}) centroid=({cx},{cy})")
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.circle(frame, (cx, cy), 4, (0, 0, 255), -1)
cv2.imwrite("step1_contours.jpg", frame)Question: how many contours did you find? Which one is the ball? What area does it have?
Step 2: the live tracker with a trail
Set SOURCE = 0 for your webcam, or keep the clip. The tracker returns None when no ball is found, so the loop copes.
from collections import deque
import cv2
import numpy as np
from demo_clip import make_demo_clip
SOURCE = "demo_clip.mp4" # use 0 for the webcam
LOWER = (8, 150, 120) # orange (H, S, V), tune for your ball
UPPER = (25, 255, 255)
MIN_AREA = 40 # ignore specks smaller than this
MIN_ROUND = 0.6 # 1.0 = perfect circle
KERNEL = np.ones((3, 3), np.uint8)
def find_ball(frame):
"""Return (cx, cy, radius) of the ball, or None."""
hsv = cv2.cvtColor(cv2.GaussianBlur(frame, (7, 7), 0), cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, LOWER, UPPER)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, KERNEL)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, KERNEL)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
best = None
best_area = 0
for c in contours:
area = cv2.contourArea(c)
per = cv2.arcLength(c, True)
if area < MIN_AREA or per == 0:
continue
roundness = 4 * np.pi * area / per ** 2
if roundness < MIN_ROUND:
continue
if area > best_area:
best, best_area = c, area
if best is None:
return None
(x, y), r = cv2.minEnclosingCircle(best)
return int(x), int(y), int(r)
if SOURCE == "demo_clip.mp4":
make_demo_clip(SOURCE)
cap = cv2.VideoCapture(SOURCE)
trail = deque(maxlen=30)
lost = 0
while True:
ret, frame = cap.read()
if not ret:
break
ball = find_ball(frame)
if ball is None:
lost += 1
trail.append(None) # a gap in the path
else:
x, y, r = ball
trail.append((x, y))
cv2.circle(frame, (x, y), r + 3, (0, 255, 0), 2)
cv2.putText(frame, f"ball ({x},{y})", (x + 10, y - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# Draw the trail: older segments are thinner
for i in range(1, len(trail)):
if trail[i - 1] is None or trail[i] is None:
continue
thickness = int(1 + 4 * i / len(trail))
cv2.line(frame, trail[i - 1], trail[i], (0, 255, 255), thickness)
cv2.imshow("Ball tracker", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()
print("Frames where the ball was lost:", lost)Question: how many frames did the tracker lose the ball? Cover the ball with your hand (webcam) and watch what happens to the trail.
Step 3: extension, a live HSV tuner
Sliders let you find the right range for your ball in seconds.
import cv2
import numpy as np
cap = cv2.VideoCapture(0)
cv2.namedWindow("Tuner")
for name, start, top in [("H min", 8, 179), ("H max", 25, 179),
("S min", 150, 255), ("S max", 255, 255),
("V min", 120, 255), ("V max", 255, 255)]:
cv2.createTrackbar(name, "Tuner", start, top, lambda v: None)
while True:
ret, frame = cap.read()
if not ret:
break
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
lo = np.array([cv2.getTrackbarPos(n, "Tuner") for n in ("H min", "S min", "V min")])
hi = np.array([cv2.getTrackbarPos(n, "Tuner") for n in ("H max", "S max", "V max")])
mask = cv2.inRange(hsv, lo, hi)
cv2.imshow("Tuner", np.hstack([frame, cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)]))
if cv2.waitKey(1) & 0xFF == ord("q"):
print("LOWER =", tuple(lo), " UPPER =", tuple(hi))
break
cap.release()
cv2.destroyAllWindows()When the mask shows only the ball, press Q and copy the printed LOWER and UPPER values into your tracker.
If it goes wrong
| Error | Cause | Fix |
|---|---|---|
ValueError: not enough values to unpack | Following an OpenCV 3 tutorial that expects 3 return values | OpenCV 4 and 5 return two: contours, _ = ... |
ZeroDivisionError in the centroid | m00 is 0 for a degenerate contour | Check m["m00"] first, or filter by area |
| Many tiny contours | Mask is noisy | Blur first, open the mask, raise MIN_AREA |
| Tracker jumps to another orange object | The biggest contour isn't the ball | Add the roundness filter, or restrict to a region of the frame |
| Nothing found in daylight or shade | HSV range tuned in different light | Retune with the sliders in the actual light |
TypeError drawing a line | A None is in the trail | Skip segments that include None, as in the code |
Frame size error with np.hstack | The mask has one channel and the frame has three | Convert the mask with cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) |
Try it
- Print the ball's speed in pixels per frame from the last two trail points
- Track two coloured balls with two different colours at once
- Smooth the position with the average of the last 3 centroids
Check what you learnt
What is a centroid?
Why check the area of each contour?
Two orange things are in view. What could help pick the ball?
Why must the code handle 'no contour found'?
What's a weakness of colour tracking?
Conclusion
cv2.findContours turns a mask's white blobs into shapes you can measure: area, bounding box and centroid. Filter by size and roundness, take the best one on every frame, and you're tracking. It's fast and simple, but it only knows colour, so light, background and look-alike colours can fool it.
What next?
Next: Motion detection & background subtraction. Instead of following a colour, you'll catch anything that moves.

