AI & Computer Vision, lesson 24 of 52

Train a Custom Sports Detector with YOLO

intermediate28 min read

Introduction

The pretrained YOLO from lesson 23 knows "person" and "sports ball", but not your ball on your pitch, and it has never heard of a goal. Today you'll fix that: take the dataset you collected and labelled, train YOLO on it with a free cloud GPU, and learn to read the numbers that tell you whether it worked.

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

  • Export a labelled Roboflow dataset in YOLO format and read data.yaml
  • Run a YOLO training job in Google Colab on a free GPU
  • Explain the training loss, precision, recall, mAP50 and mAP50-95
  • Compute average precision for one class by hand and in code
  • Read the results plots and decide whether to train longer, collect more data, or stop
  • Evaluate on the test split once, and export the model for later sessions
  • Log runs so experiments can be compared

Working principle

The plan for today

Roboflow (your labelled data)  ->  Colab (rent a GPU)  ->  YOLO training  ->  results  ->  best.pt (your model)

Fine-tuning, not training from zero

As in lesson 21, we start from the pretrained weights (yolov8n.pt), which already know edges, shapes and everyday objects, and teach them our classes: player, ball, goal. That's why a few hundred labelled images can be enough.

What the training prints

TermMeaningYou want
box lossHow far the predicted boxes are from the true boxesFalling
cls lossHow wrong the class labels areFalling
dfl lossA loss that sharpens the box edgesFalling
PrecisionOf the boxes it drew, how many were rightHigh
RecallOf the real objects, how many it foundHigh
mAP50Average precision per class at IoU 0.5, averaged over classesHigh
mAP50-95The same, averaged over stricter IoU thresholds 0.5 to 0.95. Rewards tight boxes.High (always lower than mAP50)

Average precision (AP) and mAP in plain words

For one class, sort all detections by confidence. Walk down the list. A detection is correct if its IoU with an unused real box is 0.5 or more. As you go, plot precision against recall. AP is the area under that curve. A perfect detector scores 1. A detector that finds 3 of 4 balls but also makes confident false alarms scores much less. mAP is the mean of the APs of all the classes.

Reading the results

You seeIt probably meansWhat to try
Losses fall, mAP rises, then flattensConvergedStop (the patience setting does this)
Training loss falls, validation loss risesOverfitting (lesson 20)More data, more augmentation, fewer epochs
Player AP high, ball AP lowSmall object, too few examplesMore ball images, larger imgsz, check the labels
Everything lowBad labels, too little data, or too few epochsCheck labels with the lesson 12 checker, then train longer
Test result much worse than validationLeakage in validation (lesson 13)Split by session, as in lesson 13

What you'll need

  • Your labelled Roboflow dataset from lessons 12 and 13 (or the class practice set)
  • A Google account for Google Colab (free GPU)
  • Optional: a free Weights & Biases account for logging runs

Build it

Cell 1: average precision from scratch (runs anywhere)

import numpy as np
 
 
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
 
 
def average_precision(truths, detections, iou_threshold=0.5):
    """
    truths:      {image_id: [box, box, ...]}  the boxes a human drew
    detections:  [(image_id, box, score), ...] what the model predicted
    Returns AP: the area under the precision-recall curve for ONE class.
    """
    n_truth = sum(len(v) for v in truths.values())
    used = {img: [False] * len(boxes) for img, boxes in truths.items()}
    detections = sorted(detections, key=lambda d: -d[2])             # most confident first
 
    tp = np.zeros(len(detections))
    for i, (img, box, score) in enumerate(detections):
        best, best_j = 0.0, -1
        for j, t in enumerate(truths.get(img, [])):
            overlap = iou(box, t)
            if overlap > best:
                best, best_j = overlap, j
        if best >= iou_threshold and not used[img][best_j]:
            tp[i] = 1                                                # a correct, first-time match
            used[img][best_j] = True                                 # each real ball can be found once
    fp = 1 - tp
 
    cum_tp, cum_fp = np.cumsum(tp), np.cumsum(fp)
    recall = cum_tp / max(n_truth, 1)
    precision = cum_tp / np.maximum(cum_tp + cum_fp, 1e-9)
 
    # area under the curve (precision made monotonically decreasing, as in the standard method)
    r = np.concatenate([[0.0], recall, [1.0]])
    p = np.concatenate([[1.0], precision, [0.0]])
    for k in range(len(p) - 2, -1, -1):
        p[k] = max(p[k], p[k + 1])
    idx = np.where(r[1:] != r[:-1])[0]
    return float(np.sum((r[idx + 1] - r[idx]) * p[idx + 1])), precision, recall
 
 
# Three images, four real balls
truths = {"a": [(100, 100, 120, 120)], "b": [(50, 60, 70, 80), (200, 200, 220, 220)], "c": [(300, 40, 320, 60)]}
 
good = [("a", (101, 101, 121, 121), 0.95), ("b", (50, 61, 71, 81), 0.90), ("b", (201, 199, 221, 219), 0.85), ("c", (299, 41, 319, 61), 0.80)]
sloppy = [("a", (101, 101, 121, 121), 0.95), ("c", (500, 500, 520, 520), 0.92),          # a confident false alarm
          ("b", (50, 61, 71, 81), 0.70), ("b", (400, 100, 420, 120), 0.60), ("c", (299, 41, 319, 61), 0.40)]
 
for name, dets in (("good detector", good), ("sloppy detector", sloppy)):
    ap, precision, recall = average_precision(truths, dets)
    print(f"{name}: AP@0.5 = {ap:.2f}   (found {int(recall[-1] * 4)} of 4 balls, {len(dets)} detections)")

Check one value by hand. For the sloppy detector the walk down the list is: hit, false alarm, hit, false alarm, hit. Precision at each hit is 1.0, 0.67 and 0.6 and each hit adds 0.25 of recall, so AP is 0.25 × 1.0 + 0.25 × 0.67 + 0.25 × 0.6 ≈ 0.57.

Questions: which mistake hurt more, the confident false alarm at 0.92 or the unfound ball? What happens to AP if the false alarm had a confidence of 0.10 instead?

Colab cells: train on your own data

Open a new notebook at colab.research.google.com. Choose Runtime, Change runtime type, GPU. Each block below is one notebook cell.

Cell A: install

!pip install -q ultralytics roboflow

Cell B: add your key safely. Click the key icon (Secrets) on the left of Colab, add a secret called ROBOFLOW_API_KEY and switch on notebook access. Never paste a key into the code, especially if you share the notebook.

from google.colab import userdata
from roboflow import Roboflow
 
rf = Roboflow(api_key=userdata.get("ROBOFLOW_API_KEY"))
project = rf.workspace("YOUR-WORKSPACE").project("YOUR-PROJECT")        # copy both names from the Roboflow URL
dataset = project.version(1).download("yolov8")                          # the YOLO format, as in lesson 12 and lesson 13
print(dataset.location)

If the "yolov8" format name is rejected, open the Export page in Roboflow and use the name it shows for the YOLO format.

Cell C: check the dataset before spending GPU time. Save this as yamlcheck.py (or paste into a cell). It confirms the paths and that every image has a label file.

import os
import sys
 
import yaml
 
root = sys.argv[1] if len(sys.argv) > 1 else "football-1"          # the folder Roboflow downloaded
cfg = yaml.safe_load(open(os.path.join(root, "data.yaml")))
print("classes:", cfg.get("names"), " count:", cfg.get("nc"))
 
problems = []
for split in ("train", "val", "test"):
    rel = cfg.get(split)
    if not rel:
        problems.append(f"no '{split}' entry in data.yaml")
        continue
    images = os.path.normpath(os.path.join(root, rel))
    if not os.path.isdir(images):                            # Roboflow often writes "../train/images"
        images = os.path.normpath(os.path.join(root, rel.replace("../", "")))
    labels = images.replace("images", "labels")
    n_img = len([f for f in os.listdir(images)]) if os.path.isdir(images) else -1
    n_lab = len([f for f in os.listdir(labels)]) if os.path.isdir(labels) else -1
    print(f"{split:5s}: {n_img} images, {n_lab} label files   ({images})")
    if n_img < 0:
        problems.append(f"folder not found: {images}")
    elif n_img != n_lab:
        problems.append(f"{split}: {n_img} images but {n_lab} label files")
print("PROBLEMS:" if problems else "data.yaml looks fine", *problems, sep="\n  ")

Run it with python yamlcheck.py YOUR-DATASET-FOLDER (the folder printed by dataset.location). Fix any problem it prints before training.

Cell D: train

from ultralytics import YOLO
 
model = YOLO("yolov8n.pt")                      # the small pretrained model (or "yolo26n.pt": see lesson 23)
results = model.train(
    data=f"{dataset.location}/data.yaml",
    epochs=50,                                   # an upper limit
    patience=15,                                 # stop early if validation stops improving for 15 epochs
    imgsz=640,                                   # larger helps the small ball but is slower
    batch=16,
    project="football",
    name="run1",
)

The progress bar shows the losses, precision, recall and mAP after each epoch. Training on a free Colab GPU for a few hundred images typically takes minutes, not hours. The best model is saved as football/run1/weights/best.pt. Depending on the version, the folder may be runs/detect/football/run1: read the last lines of the output for the exact path.

Cell E: evaluate on the test split, once

best = YOLO("football/run1/weights/best.pt")
metrics = best.val(data=f"{dataset.location}/data.yaml", split="test")     # the test folder, for the first time
print("mAP50:", round(metrics.box.map50, 3), " mAP50-95:", round(metrics.box.map, 3))
print("per-class mAP50-95:", dict(zip(best.names.values(), [round(float(v), 3) for v in metrics.box.maps])))

Look at the per-class numbers. The ball is likely the lowest.

Cell F: try it and export

best.predict("some_test_image.jpg", save=True, conf=0.25)     # writes an annotated copy
best.export(format="onnx")                                    # used for speed in lesson 25

Download best.pt (right-click in the Colab file panel) before the session ends: the Colab files vanish when the runtime resets.

Cell G: plot the training curves

After training, download results.csv from the run folder, then run this on your laptop (or in Colab):

import sys
 
import matplotlib
import pandas as pd
 
matplotlib.use("Agg")
import matplotlib.pyplot as plt
 
path = sys.argv[1] if len(sys.argv) > 1 else "runs/detect/run1/results.csv"
df = pd.read_csv(path)
df.columns = [c.strip() for c in df.columns]                 # Ultralytics pads some column names with spaces
 
fig, axes = plt.subplots(1, 3, figsize=(12, 3.4))
for col in ("train/box_loss", "val/box_loss"):
    if col in df:
        axes[0].plot(df["epoch"], df[col], label=col)
axes[0].set_title("box loss")
axes[0].legend()
for col in ("metrics/precision(B)", "metrics/recall(B)"):
    if col in df:
        axes[1].plot(df["epoch"], df[col], label=col.split("/")[1])
axes[1].set_title("precision and recall")
axes[1].legend()
for col in ("metrics/mAP50(B)", "metrics/mAP50-95(B)"):
    if col in df:
        axes[2].plot(df["epoch"], df[col], label=col.split("/")[1])
axes[2].set_title("mAP")
axes[2].legend()
for a in axes:
    a.set_xlabel("epoch")
plt.tight_layout()
plt.savefig("yolo_training.png", dpi=100)
 
last = df.iloc[-1]
best = df["metrics/mAP50(B)"].idxmax() if "metrics/mAP50(B)" in df else None
print("epochs run:", int(last["epoch"]))
if best is not None:
    print(f"best mAP50 = {df.loc[best, 'metrics/mAP50(B)']:.3f} at epoch {int(df.loc[best, 'epoch'])}")

Run it as python results.py path/to/results.csv.

Logging runs with Weights & Biases (optional)

Ultralytics can send each run to Weights & Biases so that you can compare several experiments in one dashboard. How it is switched on has changed between Ultralytics versions, so open the Ultralytics page on the Weights & Biases integration and follow the current steps. Free accounts are fine for students; check the terms for under-18s before you create accounts. If W&B isn't allowed, results.csv plus the plotting script above gives you the same information.

A simple lab-book habit that works either way: after each run, write one line in a shared sheet.

RunData (images)ModelimgszEpochsmAP50Ball APWhat changed
run1180yolov8n64050baseline

If it goes wrong

ErrorCauseFix
"No GPU" or training is very slowGPU runtime not selectedRuntime, Change runtime type, GPU
FileNotFoundError ... data.yamlWrong dataset pathUse dataset.location, and run yamlcheck.py
Dataset not found or label warningsRelative paths in data.yamlUse the generated dataset folder, or fix the paths as yamlcheck.py suggests
CUDA out of memoryBatch too bigbatch=8, or imgsz=512
All metrics 0Wrong class order or empty labelsCompare names in data.yaml with your labels
Training stops earlypatience triggeredThat's by design. Check the plots.
Colab session timed out, files lostIdle or reset runtimeDownload best.pt and results.csv as soon as training ends
roboflow API key errorSecret not enabled for the notebookToggle "Notebook access" on the secret
Version-specific API errorsLibrary updatedpip install -U ultralytics or pin the version your teacher tested

Try it

  • Compare imgsz=640 and imgsz=960 for the ball AP
  • Train with augmentation turned down and up and plot both
  • Write a short "experiment report" from the lab book

Check what you learnt

  1. Why start from yolov8n.pt instead of from zero?

  2. What does mAP50-95 reward that mAP50 doesn't?

  3. Player AP is 0.9, ball AP is 0.3. What should you do?

  4. Why not paste your API key into the notebook?

  5. Validation loss rises while training loss falls. What's happening?

Conclusion

Training a custom detector is fine-tuning: start from pretrained YOLO weights, train on your labelled data on a GPU, and read precision, recall, mAP50 and mAP50-95 per class. Low AP on one class usually means that class needs more or better data. Test once, export, and log every run so you can compare.

What next?

Next: Real-time pipeline & making it fast on a laptop. You'll make detection run live, and fast, on a normal laptop.