AI & Computer Vision, lesson 26 of 52
Object Tracking: Following Players
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
| Step | What happens |
|---|---|
| Predict | For each known player, guess where they are now (they keep moving as they were) |
| Match | Pair predicted boxes with new detections: the best overlaps (IoU) win. The Hungarian algorithm finds the best overall pairing. |
| Update | Matched players get their new position |
| Create | An unmatched detection starts a new track (a new ID) |
| Retire | A 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
| Problem | What you see | Typical cause |
|---|---|---|
| ID switch | Two players swap IDs after crossing | They overlap, and the match picks the wrong one |
| Fragmentation | One player gets 2 or 3 IDs over time | The player was hidden longer than max_missed, so a new track started |
| Ghost tracks | An ID follows nothing, or a referee or a flag | False detections that persist |
| Flicker | IDs appear for a few frames only | Weak detections that never get confirmed |
The settings that matter
| Setting | Too low | Too high |
|---|---|---|
max_missed (patience) | The ID is dropped during short occlusions: fragmentation | Old tracks hang around and can steal new detections |
| IoU threshold for matching | Wrong matches (ID switches) | Fast movers lose their track |
min_hits before a track is "confirmed" | Ghost tracks appear | New 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_courseenvironment 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_missed5 or 15 the tracker gives up and starts a new ID when C reappears (4 IDs for 3 players, fragmentation); withmax_missed = 40it 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
| Error | Cause | Fix |
|---|---|---|
result.boxes.id is None | No tracks yet, or persist=True missing | Check for None as the code does. Use persist=True. |
| IDs restart every frame | Calling model() instead of model.track() or missing persist=True | Use model.track(frame, persist=True) |
| Many short-lived IDs | Low conf creates flicker | Raise conf to 0.35 or higher |
| Same player has 3 IDs | Fragmentation from occlusion | Increase tracker patience in the tracker YAML (track_buffer), check lighting and crowd cover |
| IDs swap when players cross | Overlap confuses matching | Try botsort.yaml, or a camera angle with less overlap |
ModuleNotFoundError: scipy | Not installed | pip install scipy pandas |
| Processing is very slow | The model runs on a CPU | Use a smaller clip or imgsz, or run in Colab |
KeyError in track_stats.py | Wrong CSV columns | Use the CSV written by track_yolo.py |
Try it
- Add a step to
tracker.pythat draws each track's trail on the simulated frames - Compare
bytetrack.yamlwithbotsort.yamlon 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
Why can't a detector alone tell how far a player ran?
How does a tracker match detections to players?
What is ID fragmentation?
What's the risk of a very large max_missed?
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.

