AI & Computer Vision, lesson 18 of 52
Train Your First Image Classifier: Ball or No Ball
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
| Classification | Detection | |
|---|---|---|
| Question | "Is there a ball in this picture?" | "Where are the balls and players?" |
| Answer | One label (and a confidence) | A list of boxes, each with a label |
| Model | Today's CNN | YOLO (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
| Layer | What it does |
|---|---|
Rescaling(1/255) | Pixels 0 to 255 become 0 to 1, which trains better |
Conv2D(16, 3) + MaxPooling2D | 16 learned 3 by 3 filters find edges and blobs, pooling shrinks the map |
Conv2D(32, 3) + MaxPooling2D | 32 filters find combinations (curves, patches) |
Conv2D(64, 3) + MaxPooling2D | 64 filters find bigger parts |
Flatten | Lays 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 see | It means |
|---|---|
| Training and validation both improve | Learning is working |
| Training keeps improving, validation stops or gets worse | Overfitting: memorising instead of learning (lesson 20) |
| Both stay poor | Underfitting: the model is too simple, the data is too hard, or the labels are bad |
| Validation is far better than training at first | Often just dropout or averaging effects. Watch the trend. |
What you'll need
- Your
cv_courseenvironment 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
| Error | Cause | Fix |
|---|---|---|
No images found / FileNotFoundError | Wrong folder structure | crops/train/ball, crops/train/no_ball, crops/valid/... |
ValueError: class_names ... do not match | Folders named differently | Names must be exactly ball and no_ball |
| Accuracy exactly 50% | Classes are mixed up, or labels are random | Look at 12 crops of each class. Check BALL_ID. |
| Validation accuracy is 100% from epoch 1 | Leakage, or a trivial difference between classes | Check the splits. Look for something that gives it away (all ball crops brighter, bigger or sharper). |
| Very different class counts | Few balls labelled | Add more ball images, or train with fewer no_ball crops |
| Training is very slow | CPU only | Fewer epochs or smaller images. Use a free hosted notebook with a GPU. |
AttributeError for a Keras function | Older TensorFlow | Update: pip install --upgrade tensorflow |
| Predictions are wrong in step 3 | Wrong channel order or size | Convert 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
Why classify small crops instead of whole frames?
What does the last layer output?
Training accuracy rises but validation accuracy falls. What's happening?
Why is Rescaling(1/255) the first layer?
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.

