AI & Computer Vision, lesson 18 of 52

Train Your First Image Classifier: Ball or No Ball

intermediate24 min read

Introduction

Time to train a real model. You'll make a pile of small picture crops, some with a ball and some without, build a small CNN in Keras, and teach it to tell them apart. Then you'll read its learning curves to see whether it truly learned or just memorised.

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

  • Explain the difference between classification (one answer per picture) and detection (boxes, from lesson 23)
  • Prepare ball and no-ball crops from labelled images, split by session
  • Build a small CNN in Keras and say what each layer does
  • Train it and read the training and validation curves
  • Save the trained model to a file
  • Say why the test folder stays untouched until lesson 19

Working principle

Classification versus detection

ClassificationDetection
Question"Is there a ball in this picture?""Where are the balls and players?"
AnswerOne label (and a confidence)A list of boxes, each with a label
ModelToday's CNNYOLO (lesson 23 onwards)

We start with classification because it's the simplest complete training loop. Detection builds on it.

Why crops?

A full football frame is mostly grass. A ball is a few pixels. A classifier shown the whole frame would mostly learn "grass". So we make crops: a small square window around each labelled ball (about three times the ball's size, so some context is included), and random crops with no ball in them. Both are resized to 64 by 64.

This uses the boxes you drew in lesson 12, and the splits from lesson 13. Crops from the training images go to crops/train, those from the validation images to crops/valid, those from the test images to crops/test. Never mix them, or the leakage problem from lesson 10 and lesson 13 comes back.

The model

LayerWhat it does
Rescaling(1/255)Pixels 0 to 255 become 0 to 1, which trains better
Conv2D(16, 3) + MaxPooling2D16 learned 3 by 3 filters find edges and blobs, pooling shrinks the map
Conv2D(32, 3) + MaxPooling2D32 filters find combinations (curves, patches)
Conv2D(64, 3) + MaxPooling2D64 filters find bigger parts
FlattenLays the feature maps out as one list
Dense(64)Combines everything
Dense(1, sigmoid)One number between 0 and 1: the probability of "ball"

This is lesson 17's recipe, with lesson 16's loss and gradient descent running underneath (adam is a smarter version of gradient descent that adjusts the step size for each weight).

Reading the curves

You seeIt means
Training and validation both improveLearning is working
Training keeps improving, validation stops or gets worseOverfitting: memorising instead of learning (lesson 20)
Both stay poorUnderfitting: the model is too simple, the data is too hard, or the labels are bad
Validation is far better than training at firstOften just dropout or averaging effects. Watch the trend.

What you'll need

  • Your cv_course environment with TensorFlow (pip install tensorflow)
  • Your labelled frames from lessons 11 to 13, or the synthetic crops made in Step 1b

Build it

Install first (once): pip install tensorflow opencv-python matplotlib.

Step 1a: make crops from your labelled data

Run once for each of train, valid and test (change the SPLIT line). ROOT is the unzipped YOLO export from Roboflow (lesson 13). In data.yaml, check which number is ball and set BALL_ID.

import os
import random
 
import cv2
 
ROOT = "football_yolo"                     # the unzipped YOLO export from lesson 12/lesson 13 (has train/, valid/, test/)
SPLIT = "train"                            # run once for each of: train, valid, test
IMAGES = f"{ROOT}/{SPLIT}/images"
LABELS = f"{ROOT}/{SPLIT}/labels"
OUT = f"crops/{SPLIT}"                     # creates crops/<split>/ball and crops/<split>/no_ball
BALL_ID = 1                                # class number of "ball" in data.yaml
SIZE = 64                                  # every crop is resized to 64 x 64
NO_BALL_PER_IMAGE = 2                      # random crops with no ball in them
random.seed(0)
 
 
def iou(a, b):
    ix1, iy1, ix2, iy2 = max(a[0], b[0]), max(a[1], b[1]), 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 else 0
 
 
os.makedirs(f"{OUT}/ball", exist_ok=True)
os.makedirs(f"{OUT}/no_ball", exist_ok=True)
n_ball = n_other = 0
 
for name in sorted(os.listdir(IMAGES)):
    img = cv2.imread(os.path.join(IMAGES, name))
    label_path = os.path.join(LABELS, os.path.splitext(name)[0] + ".txt")
    if img is None or not os.path.exists(label_path):
        continue
    h, w = img.shape[:2]
    ball_boxes = []
    with open(label_path) as f:
        for line in f:
            parts = line.split()
            if len(parts) != 5 or int(parts[0]) != BALL_ID:
                continue
            xc, yc, bw, bh = (float(v) for v in parts[1:])
            # take a square window around the ball, about 3x the ball's size, so context is included
            half = max(bw * w, bh * h) * 1.5
            x1, y1 = int(max(0, xc * w - half)), int(max(0, yc * h - half))
            x2, y2 = int(min(w, xc * w + half)), int(min(h, yc * h + half))
            if x2 - x1 < 8 or y2 - y1 < 8:
                continue
            ball_boxes.append((x1, y1, x2, y2))
            cv2.imwrite(f"{OUT}/ball/{n_ball:05d}.jpg", cv2.resize(img[y1:y2, x1:x2], (SIZE, SIZE)))
            n_ball += 1
    # random crops of similar size that do not overlap any ball
    made, tries = 0, 0
    while made < NO_BALL_PER_IMAGE and tries < 50:
        tries += 1
        side = random.randint(int(min(h, w) * 0.1), int(min(h, w) * 0.3))
        x1, y1 = random.randint(0, w - side), random.randint(0, h - side)
        box = (x1, y1, x1 + side, y1 + side)
        if all(iou(box, b) == 0 for b in ball_boxes):
            cv2.imwrite(f"{OUT}/no_ball/{n_other:05d}.jpg", cv2.resize(img[y1:y1 + side, x1:x1 + side], (SIZE, SIZE)))
            n_other += 1
            made += 1
 
print("ball crops:", n_ball, "  no-ball crops:", n_other)

Step 1b: or make synthetic crops (no field data needed)

If your dataset isn't ready, or you want to test your set-up, this makes pretend crops: noisy grass, sometimes a pitch line or a "player" rectangle, and a white round ball in half of them.

import os
 
import cv2
import numpy as np
 
rng = np.random.default_rng(1)
SIZE = 64
 
 
def one_crop(has_ball):
    img = rng.normal(0, 12, (SIZE, SIZE, 3)) + np.array([45, 125, 50])       # noisy grass (BGR)
    if rng.random() < 0.3:
        cv2.line(img, (0, int(rng.integers(10, 54))), (SIZE, int(rng.integers(10, 54))), (230, 230, 230), 2)   # a pitch line
    if rng.random() < 0.3:
        x = int(rng.integers(5, 50))
        cv2.rectangle(img, (x, 5), (x + 10, 50), tuple(int(c) for c in rng.integers(40, 255, 3)), -1)         # a "player"
    if has_ball:
        r = int(rng.integers(4, 9))
        cv2.circle(img, (int(rng.integers(r + 2, SIZE - r - 2)), int(rng.integers(r + 2, SIZE - r - 2))), r, (240, 240, 240), -1)
    return np.clip(img, 0, 255).astype(np.uint8)
 
 
for split, n in (("train", 300), ("valid", 80), ("test", 80)):
    for label in ("ball", "no_ball"):
        folder = f"crops/{split}/{label}"
        os.makedirs(folder, exist_ok=True)
        for i in range(n):
            cv2.imwrite(f"{folder}/{i:04d}.jpg", one_crop(label == "ball"))
print("made synthetic crops in crops/train, crops/valid, crops/test")

Question: open six crops from each class. Could a person tell them apart instantly? Is anything in the data that gives the answer away unfairly?

Step 2: build, train and save

import matplotlib
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
 
matplotlib.use("Agg")
import matplotlib.pyplot as plt
 
IMG_SIZE = (64, 64)
BATCH = 32
EPOCHS = 15
CLASS_NAMES = ["no_ball", "ball"]          # order matters: no_ball = 0, ball = 1
 
 
def load(split, shuffle):
    return keras.utils.image_dataset_from_directory(
        f"crops/{split}",
        class_names=CLASS_NAMES,
        label_mode="binary",
        image_size=IMG_SIZE,
        batch_size=BATCH,
        shuffle=shuffle,
        seed=42,
    )
 
 
train_ds = load("train", True)
val_ds = load("valid", False)             # the test folder is NOT touched here: it is for lesson 19
 
model = keras.Sequential([
    keras.Input(shape=IMG_SIZE + (3,)),
    layers.Rescaling(1 / 255),                              # pixel values 0..255 -> 0..1
    layers.Conv2D(16, 3, activation="relu"), layers.MaxPooling2D(),
    layers.Conv2D(32, 3, activation="relu"), layers.MaxPooling2D(),
    layers.Conv2D(64, 3, activation="relu"), layers.MaxPooling2D(),
    layers.Flatten(),
    layers.Dense(64, activation="relu"),
    layers.Dense(1, activation="sigmoid"),                  # probability of "ball"
])
model.summary()
 
model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"])
history = model.fit(train_ds, validation_data=val_ds, epochs=EPOCHS)
 
model.save("ball_classifier.keras")
print("saved ball_classifier.keras")
 
fig, (a1, a2) = plt.subplots(1, 2, figsize=(9, 3.2))
a1.plot(history.history["loss"], label="train")
a1.plot(history.history["val_loss"], label="valid")
a1.set_title("loss")
a1.legend()
a2.plot(history.history["accuracy"], label="train")
a2.plot(history.history["val_accuracy"], label="valid")
a2.set_title("accuracy")
a2.legend()
plt.tight_layout()
plt.savefig("training_curves.png", dpi=100)

On the synthetic crops this typically reaches high validation accuracy within a few epochs. On real football crops expect lower numbers, and a good first target is over 80%.

Questions: at which epoch does validation accuracy stop improving? Is the gap between the training and validation lines growing? What would you change first: more data, more filters, or fewer epochs?

Step 3: try one prediction

import cv2
import numpy as np
from tensorflow import keras
 
model = keras.models.load_model("ball_classifier.keras")
img = cv2.imread("crops/valid/ball/0000.jpg")          # any crop
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)             # OpenCV reads BGR, Keras trained on RGB
x = cv2.resize(img, (64, 64))[None].astype("float32")  # add the batch dimension; the model rescales inside
p = float(model.predict(x, verbose=0)[0, 0])
print(f"probability of ball: {p:.2f}")

If it goes wrong

ErrorCauseFix
No images found / FileNotFoundErrorWrong folder structurecrops/train/ball, crops/train/no_ball, crops/valid/...
ValueError: class_names ... do not matchFolders named differentlyNames must be exactly ball and no_ball
Accuracy exactly 50%Classes are mixed up, or labels are randomLook at 12 crops of each class. Check BALL_ID.
Validation accuracy is 100% from epoch 1Leakage, or a trivial difference between classesCheck the splits. Look for something that gives it away (all ball crops brighter, bigger or sharper).
Very different class countsFew balls labelledAdd more ball images, or train with fewer no_ball crops
Training is very slowCPU onlyFewer epochs or smaller images. Use a free hosted notebook with a GPU.
AttributeError for a Keras functionOlder TensorFlowUpdate: pip install --upgrade tensorflow
Predictions are wrong in step 3Wrong channel order or sizeConvert BGR to RGB and resize to 64 by 64

Try it

  • Add a Dropout(0.3) layer and compare the curves
  • Change the filters (8, 16, 32) and report the parameter count from model.summary()
  • Make a confusion matrix by hand from the validation predictions (a preview of lesson 19)

Check what you learnt

  1. Why classify small crops instead of whole frames?

  2. What does the last layer output?

  3. Training accuracy rises but validation accuracy falls. What's happening?

  4. Why is Rescaling(1/255) the first layer?

  5. Why leave the test folder alone today?

Conclusion

Classification gives one answer per picture. You made balanced ball and no-ball crops split by session, built a small CNN (rescale, convolution and pooling layers, then a single output), trained it, and read the curves: the best epoch is where validation accuracy peaks, not training.

What next?

Next: Evaluation: metrics that matter. You'll find out how good your model really is, and why accuracy alone can lie.