AI & Computer Vision, lesson 26 of 52

Object Tracking: Following Players

intermediate26 min read

Introduction

Your detector finds every player in every frame, but it has no idea who is who: frame by frame it just sees "player, player, player". To measure how far someone ran or where they spent the match, you need each player to keep the same ID. That's tracking, and today you'll build a simple tracker yourself, then use a professional one.

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

  • Explain why a detector alone has no memory, and what a tracker adds
  • Describe the tracking cycle: predict, match, update, create, retire
  • Explain how IoU is used to match boxes between frames
  • Say what an ID switch and ID fragmentation are, and measure them
  • Tune max_missed (how long to wait for a lost player)
  • Run ByteTrack through Ultralytics and save every track to a CSV file
  • Read basic track statistics and spot a broken track

Working principle

The problem: detection has no identity

In each frame the detector returns boxes without names. For stats like distance run, heatmaps and passes, we need to know that this box is the same player as the one in the last frame.

The tracking cycle

StepWhat happens
PredictFor each known player, guess where they are now (they keep moving as they were)
MatchPair predicted boxes with new detections: the best overlaps (IoU) win. The Hungarian algorithm finds the best overall pairing.
UpdateMatched players get their new position
CreateAn unmatched detection starts a new track (a new ID)
RetireA player not seen for more than max_missed frames is dropped

The tracker predicts where each player goes and matches boxes by overlap, so #7 stays #7 even when they cross.

This is the heart of SORT. ByteTrack adds a clever twist: it also uses low-confidence detections (the half-hidden players that other trackers throw away) to keep tracks alive through occlusions.

Things that go wrong

ProblemWhat you seeTypical cause
ID switchTwo players swap IDs after crossingThey overlap, and the match picks the wrong one
FragmentationOne player gets 2 or 3 IDs over timeThe player was hidden longer than max_missed, so a new track started
Ghost tracksAn ID follows nothing, or a referee or a flagFalse detections that persist
FlickerIDs appear for a few frames onlyWeak detections that never get confirmed

The settings that matter

SettingToo lowToo high
max_missed (patience)The ID is dropped during short occlusions: fragmentationOld tracks hang around and can steal new detections
IoU threshold for matchingWrong matches (ID switches)Fast movers lose their track
min_hits before a track is "confirmed"Ghost tracks appearNew players take longer to appear

How we judge a tracker without ground truth

A perfect answer needs a human to label every player in every frame, which is expensive. A quick proxy: count the different IDs. If 3 players are on screen for 120 frames and the tracker used 9 IDs, it fragmented. A track-statistics script reports IDs used, how long each lived, and how many players are found per frame.

What you'll need

  • Your cv_course environment with Ultralytics
  • Your detector from lesson 24 (or yolov8n.pt) and a match clip

Build it

Step 1: a teaching tracker (the whole algorithm in about 60 lines)

Save as tracker.py.

import numpy as np
from scipy.optimize import linear_sum_assignment
 
 
def iou(a, b):
    ix1, iy1 = max(a[0], b[0]), max(a[1], b[1])
    ix2, iy2 = min(a[2], b[2]), min(a[3], b[3])
    inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
    union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
    return inter / union if union > 0 else 0.0
 
 
class Track:
    next_id = 1
 
    def __init__(self, box):
        self.id = Track.next_id
        Track.next_id += 1
        self.box = np.array(box, float)
        self.velocity = np.zeros(4)                 # how the box moved between the last two frames
        self.missed = 0                             # frames since we last saw it
        self.hits = 1
 
    def predict(self):
        """Guess where the player is now, assuming they keep moving the same way."""
        return self.box + self.velocity
 
    def update(self, box):
        box = np.array(box, float)
        self.velocity = 0.6 * self.velocity + 0.4 * (box - self.box)      # smooth the velocity
        self.box, self.missed, self.hits = box, 0, self.hits + 1
 
 
class SimpleTracker:
    """A teaching version of SORT: predict, match by IoU, update, create, retire."""
 
    def __init__(self, iou_threshold=0.3, max_missed=10, min_hits=3, use_velocity=True):
        self.iou_threshold, self.max_missed, self.min_hits = iou_threshold, max_missed, min_hits
        self.use_velocity = use_velocity
        self.tracks = []
 
    def update(self, detections):
        """detections: list of (x1, y1, x2, y2). Returns [(track_id, box), ...] for confirmed tracks."""
        predicted = [t.predict() if self.use_velocity else t.box.copy() for t in self.tracks]
        matched_t, matched_d = set(), set()
 
        if self.tracks and detections:
            cost = np.array([[1 - iou(p, d) for d in detections] for p in predicted])
            rows, cols = linear_sum_assignment(cost)                # best overall pairing (Hungarian algorithm)
            for r, c in zip(rows, cols):
                if 1 - cost[r, c] >= self.iou_threshold:
                    self.tracks[r].update(detections[c])
                    matched_t.add(r)
                    matched_d.add(c)
 
        for i, t in enumerate(self.tracks):
            if i not in matched_t:
                t.missed += 1
                t.box = predicted[i]                                # coast on the prediction while it is hidden
        for j, d in enumerate(detections):
            if j not in matched_d:
                self.tracks.append(Track(d))                        # a new player appears
 
        self.tracks = [t for t in self.tracks if t.missed <= self.max_missed]
        return [(t.id, tuple(t.box)) for t in self.tracks if t.hits >= self.min_hits and t.missed == 0]

Step 2: a simulated match to test it

Three players: A runs right, B runs left (they cross), C jogs down. We give the tracker noisy boxes and make the detector miss some of them. Save as simulate.py beside tracker.py.

import numpy as np
 
from tracker import iou
 
rng = np.random.default_rng(5)
FRAMES = 120
SIZE = (30, 60)                                       # width, height of a player's box
 
 
def path(start, end):
    return [np.array(start) + (np.array(end) - np.array(start)) * f / (FRAMES - 1) for f in range(FRAMES)]
 
 
# A runs right, B runs left (they cross around frame 60), C jogs down
players = {"A": path((50, 100), (550, 120)), "B": path((550, 130), (50, 110)), "C": path((300, 20), (310, 320))}
 
 
def box_at(centre):
    return (centre[0] - SIZE[0] / 2, centre[1] - SIZE[1] / 2, centre[0] + SIZE[0] / 2, centre[1] + SIZE[1] / 2)
 
 
def run(tracker, miss_rate=0.1, jitter=2.0, blackout=None):
    """
    Feed the tracker noisy, sometimes-missing detections.
    blackout = {"C": (40, 70)} hides player C completely from frame 40 to 70 (hidden behind a crowd).
    Returns (ID switches, number of different IDs used). With 3 real players, a perfect tracker uses exactly 3 IDs.
    """
    blackout = blackout or {}
    owner, switches, seen_ids = {}, 0, set()
    for f in range(FRAMES):
        detections, truth = [], []
        for name, p in players.items():
            start, end = blackout.get(name, (-1, -1))
            if start <= f < end or rng.random() < miss_rate:
                continue
            b = np.array(box_at(p[f])) + rng.normal(0, jitter, 4)
            detections.append(tuple(b))
            truth.append((name, b))
        for tid, box in tracker.update(detections):
            seen_ids.add(tid)
            best = max(truth, key=lambda t: iou(box, tuple(t[1])), default=None)
            if best is None or iou(box, tuple(best[1])) < 0.3:
                continue
            if tid in owner and owner[tid] != best[0]:
                switches += 1                          # this ID jumped to a different player
            owner[tid] = best[0]
    return switches, len(seen_ids)

Step 3: break it on purpose

Save as run_sim.py and run it.

from simulate import run
from tracker import SimpleTracker, Track
 
print("Missed detections (the detector sometimes fails):")
for miss in (0.0, 0.1, 0.3):
    Track.next_id = 1
    switches, ids = run(SimpleTracker(), miss_rate=miss)
    print(f"  {miss:4.0%} missed: {ids} IDs used for 3 players, ID switches = {switches}")
 
print("\nPlayer C hidden behind a crowd for 30 frames (frames 40 to 70):")
for max_missed in (5, 15, 40):
    Track.next_id = 1
    switches, ids = run(SimpleTracker(max_missed=max_missed), miss_rate=0.05, blackout={"C": (40, 70)})
    print(f"  max_missed={max_missed:2d}: IDs used = {ids} (3 is perfect), switches = {switches}")
 
print("\nWhat does max_missed = 0 do? (the tracker gives up the instant it loses a player)")
Track.next_id = 1
switches, ids = run(SimpleTracker(max_missed=0), miss_rate=0.15)
print(f"  IDs used = {ids} for 3 players")

You should see (numbers may differ a little):

  • 0%, 10% and 30% missed detections: still 3 IDs for 3 players, because the tracker coasts through short gaps
  • Player C hidden for 30 frames: with max_missed 5 or 15 the tracker gives up and starts a new ID when C reappears (4 IDs for 3 players, fragmentation); with max_missed = 40 it waits and keeps 3
  • max_missed = 0: 33 IDs for 3 players. It forgets a player the moment a single detection is missing.

Questions: why is a big max_missed risky on a real pitch with 22 players? When would the tracker wrongly give the old ID to a different player? What does the velocity guess (predict) do for a player running in a straight line? What would break it?

Step 4: ByteTrack on a real clip

Needs pip install ultralytics and the best.pt from lesson 24. bytetrack.yaml ships with Ultralytics. It saves tracks.csv with one row per tracked box per frame.

import csv
import sys
from collections import defaultdict
 
import cv2
from ultralytics import YOLO
 
WEIGHTS = "best.pt"
SOURCE = sys.argv[1] if len(sys.argv) > 1 else "match_30s.mp4"
TRACKER = "bytetrack.yaml"            # or "botsort.yaml". Both ship with Ultralytics
CONF = 0.25
TRAIL = 40                            # how many past positions to draw
 
model = YOLO(WEIGHTS)
cap = cv2.VideoCapture(SOURCE)
trails = defaultdict(list)            # id -> list of (x, y) foot positions
out = open("tracks.csv", "w", newline="")
writer = csv.writer(out)
writer.writerow(["frame", "id", "cls", "x1", "y1", "x2", "y2", "conf"])
 
frame_no = 0
while True:
    ok, frame = cap.read()
    if not ok:
        break
    # persist=True tells the tracker "this frame follows the last one", so IDs carry over
    result = model.track(frame, persist=True, tracker=TRACKER, conf=CONF, verbose=False)[0]
 
    if result.boxes.id is not None:                       # no IDs yet on the first frames, or when nothing is found
        for box, tid, cls, conf in zip(result.boxes.xyxy.tolist(), result.boxes.id.int().tolist(),
                                       result.boxes.cls.int().tolist(), result.boxes.conf.tolist()):
            x1, y1, x2, y2 = (int(v) for v in box)
            name = model.names[cls]
            writer.writerow([frame_no, tid, name, x1, y1, x2, y2, round(conf, 3)])
            foot = ((x1 + x2) // 2, y2)                   # the bottom-centre of the box is where the player stands
            trails[tid].append(foot)
            trails[tid] = trails[tid][-TRAIL:]
            cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
            cv2.putText(frame, f"{name} #{tid}", (x1, max(14, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 2, cv2.LINE_AA)
            for a, b in zip(trails[tid], trails[tid][1:]):
                cv2.line(frame, a, b, (0, 200, 255), 2)
 
    cv2.imshow("tracking", frame)
    frame_no += 1
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break
 
cap.release()
out.close()
cv2.destroyAllWindows()
print("wrote tracks.csv with", frame_no, "frames. Now run:  python track_stats.py tracks.csv")

Step 5: how good are the tracks?

import sys
 
import pandas as pd
 
path = sys.argv[1] if len(sys.argv) > 1 else "tracks.csv"
df = pd.read_csv(path)                      # columns: frame, id, cls, x1, y1, x2, y2, conf
print(f"{len(df)} detections, {df['id'].nunique()} different IDs, {df['frame'].nunique()} frames")
 
life = df.groupby("id")["frame"].agg(first="min", last="max", seen="count")
life["span"] = life["last"] - life["first"] + 1
life["gaps"] = life["span"] - life["seen"]               # frames inside its life where it was not found
print("\nLongest-lived IDs:")
print(life.sort_values("seen", ascending=False).head(8).to_string())
 
short = life[life["seen"] < 15]
print(f"\nIDs seen for fewer than 15 frames: {len(short)}  <- often flickers or ID breaks, not real players")
 
per_frame = df.groupby("frame")["id"].nunique()
print(f"\nPlayers per frame: min {per_frame.min()}, median {per_frame.median():.0f}, max {per_frame.max()}")
print("If the true number of players on screen is steady, a big spread here means missed detections.")

Run it as python track_stats.py tracks.csv. On my simulated tracker output it showed 4 IDs for 3 players, with ID 3 ending at frame 39 and ID 4 starting at frame 72: exactly the "player hidden behind a crowd" fragmentation.

Questions: how many IDs did you get for the number of players you can count? Which IDs live for fewer than 15 frames? Watch the video around those frames: what is happening?

If it goes wrong

ErrorCauseFix
result.boxes.id is NoneNo tracks yet, or persist=True missingCheck for None as the code does. Use persist=True.
IDs restart every frameCalling model() instead of model.track() or missing persist=TrueUse model.track(frame, persist=True)
Many short-lived IDsLow conf creates flickerRaise conf to 0.35 or higher
Same player has 3 IDsFragmentation from occlusionIncrease tracker patience in the tracker YAML (track_buffer), check lighting and crowd cover
IDs swap when players crossOverlap confuses matchingTry botsort.yaml, or a camera angle with less overlap
ModuleNotFoundError: scipyNot installedpip install scipy pandas
Processing is very slowThe model runs on a CPUUse a smaller clip or imgsz, or run in Colab
KeyError in track_stats.pyWrong CSV columnsUse the CSV written by track_yolo.py

Try it

  • Add a step to tracker.py that draws each track's trail on the simulated frames
  • Compare bytetrack.yaml with botsort.yaml on the same clip and plot IDs used
  • Compute each player's total distance in pixels from tracks.csv (a preview of lesson 45)

Check what you learnt

  1. Why can't a detector alone tell how far a player ran?

  2. How does a tracker match detections to players?

  3. What is ID fragmentation?

  4. What's the risk of a very large max_missed?

  5. What does ByteTrack do with low-confidence boxes?

Conclusion

A tracker gives a detector a memory. Each frame it predicts where tracks will be, matches them to new detections with IoU, updates them, creates new tracks and retires lost ones. Watch for ID switches and fragmentation, tune max_missed, and use ByteTrack for real footage.

What next?

Next: Browser detection & triggers in JS. You'll run detection in the browser and make it trigger events.