AI & Computer Vision, lesson 8 of 52

Contours & Colour Tracking: Follow the Ball

beginner20 min read

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_EXTERNAL keeps only outer outlines, not holes inside them.
  • CHAIN_APPROX_SIMPLE stores 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

MeasureCodeUse
Areacv2.contourArea(c)Ignore tiny specks and huge blobs
Bounding boxx, y, w, h = cv2.boundingRect(c)Draw a box, crop the object
Centroidm = cv2.moments(c), then cx = m["m10"] / m["m00"], cy = m["m01"] / m["m00"]The ball's position
Perimetercv2.arcLength(c, True)For roundness
Roundness4 * pi * area / perimeter ** 21.0 is a perfect circle. A ball scores close to 1.

Check m["m00"] is not zero before dividing.

The colour tracking recipe

  1. Read a frame. Blur it a little.
  2. Convert to HSV.
  3. Mask the ball's colour with inRange.
  4. Open then close the mask to clean it.
  5. Find the contours. Keep the ones of a sensible area and roundness.
  6. Take the biggest. Use its centroid as the ball position.
  7. Add the position to a trail (collections.deque with maxlen=30) and draw the trail.

When it breaks

ProblemWhy
Other orange things (a cone, a shirt)The mask only knows colour, not "ball"
The ball goes dark in shadeThe value falls below your lower bound
Motion blurThe ball smears into a long streak and its shape changes
The ball is hiddenThere 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_course environment 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

ErrorCauseFix
ValueError: not enough values to unpackFollowing an OpenCV 3 tutorial that expects 3 return valuesOpenCV 4 and 5 return two: contours, _ = ...
ZeroDivisionError in the centroidm00 is 0 for a degenerate contourCheck m["m00"] first, or filter by area
Many tiny contoursMask is noisyBlur first, open the mask, raise MIN_AREA
Tracker jumps to another orange objectThe biggest contour isn't the ballAdd the roundness filter, or restrict to a region of the frame
Nothing found in daylight or shadeHSV range tuned in different lightRetune with the sliders in the actual light
TypeError drawing a lineA None is in the trailSkip segments that include None, as in the code
Frame size error with np.hstackThe mask has one channel and the frame has threeConvert 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

  1. What is a centroid?

  2. Why check the area of each contour?

  3. Two orange things are in view. What could help pick the ball?

  4. Why must the code handle 'no contour found'?

  5. 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.