AI & Computer Vision, lesson 48 of 52
Capstone Data Sprint: Capture & Annotate
Introduction
Your product needs its own data. In this sprint your team captures, labels and checks a dataset in a few days, using everything from Unit 3. More pictures isn't the goal: more variety is. You'll also build a quality gate that refuses bad data before it ever reaches the training stove.
By the end of this lesson you'll be able to:
- Capture images in several different sessions, following the consent rules of lesson 14
- Label to a written rule so two teammates draw the same box
- Split by session, so the test set is a different day from the training set
- Augment the training images only
- Run a readiness gate and fix every FAIL before training
- Explain why a clean small dataset beats a big messy one
Working principle
150 is a floor, not a goal
The outline asks for 150 or more labelled images. Treat that as the least that makes a baseline worth training. If your brief (lesson 47) says "recall of 90%" for a ball, you also need enough examples of the ball, not only enough pictures. The gate checks both: images in total and boxes per class against your quota.
Sessions matter more than pictures
A session is one visit: one place, one day, one light. Fifty frames from one minute of video are almost one picture repeated. Ten pictures from each of five different sessions teach the model far more. The lesson 11 capture script names files <class>_<session>_<n>.jpg, so the session is in the name and the gate can read it.
Aim for at least three sessions with some variety: morning and afternoon, near and far, crowded and empty. The gate fails below two.
Consent comes first (lesson 14)
Nothing in this lesson overrides lesson 14. Before a camera is raised:
- Only film people whose consent (and a parent's, for under 18) you have in writing.
- Capture the minimum. If a face is not needed, frame it out.
- Keep the raw pictures in a private folder, not in a public repository or a shared drive.
- Tell people how to ask for their pictures to be deleted, and then delete them when asked.
India's Digital Personal Data Protection Rules were notified in November 2025 and the parts that cover children's data come in later phases. Please confirm the details and dates with your school; this lesson is general guidance and not legal advice.
Labelling is a written rule
Two teammates draw different boxes round the same ball. The model learns the disagreement. Write the rule down before anyone clicks:
| Question | Our rule, for example |
|---|---|
| How tight is the box? | Touching the visible edge of the object, no gap |
| Partly hidden object? | Label only if more than half is visible |
| Blurred by motion? | Label it, if you can still tell what it is |
| Tiny and far away? | Skip it if it is under 10 pixels, and skip it the same way everywhere |
| Two overlapping players? | Two boxes |
Then have a second person check 10 labels each. The gate catches broken files. It cannot catch a box that is wrong but valid, which is why this check is done by eyes.
Split by session
Roboflow can split randomly by picture. For video frames that is a trap: a frame in training and the very next frame in test look the same, and the test score will be flattering and useless (lesson 13 called this leakage). Put whole sessions in each split. For example, with five sessions: three train, one valid, one test.
How you do it in Roboflow depends on its current interface: upload each session as its own batch and assign batches to the splits, or build the split yourself in Python (lesson 13) before you upload. Check which your version supports. The gate will tell you if you got it wrong.
Augment only in training
Augmentation (flips, brightness, small rotations) makes copies of training images to teach the model that these changes do not matter. Never augment valid or test: they must stay as real as the world. Roboflow applies augmentation to the training split, but check the settings and then confirm with the gate that valid and test have not grown.
Do not flip if left and right mean something: a goal on the left is not the same as a goal on the right in your brief, for example. Do not use a colour change that destroys the cue the model needs (a yellow ball turned blue).
Class balance
If the dataset has 400 players and 30 balls, the model gets good at players and mediocre at the ball. The gate fails when a class is under the quota from your brief, and when the valid or test split has no examples of a class. It warns when train has none.
What the gate checks
| Check | Fail or warn | Why |
|---|---|---|
| Fewer than 150 images | Fail | Too little to train a baseline |
| Fewer than 2 sessions | Fail | The model learns one situation |
| Image without a label file, or the reverse | Fail | A missing label teaches that the object is not there |
| Label line without 5 numbers, or box outside 0 to 1 | Fail | The file is broken |
| Session in more than one split | Fail | Leakage |
| Near-duplicate across splits | Fail | Leakage |
| Class under its quota | Fail | Not enough examples |
| Valid or test with no example of a class | Fail | You cannot measure that class |
| Near-duplicate in the same split | Warn | Wasted effort, not a disaster |
| Very blurry picture | Warn | Look at it and decide |
| Tiny box | Warn | Check it is not a slip |
How the near-duplicate check works
It shrinks each picture to 16 by 16 grey squares, and writes a bit per square: 1 if brighter than the average, 0 if not. That is the picture's fingerprint. Two pictures whose fingerprints differ in only a few bits are almost the same picture. The cut-off (12 of 256 bits) is a starting point to tune on your own data, not a law.
Honest limits
The gate checks files, not truth. It cannot tell you that a box is on the wrong object, that the labelling rule drifted halfway, or that all your sessions were in the same gym. Those need people.
What you'll need
- Your capstone brief from lesson 47
capture.py(lesson 11) and your Roboflow project (lesson 12)- Consent forms for anyone you'll film (lesson 14)
- Your
cv_courseenvironment
Build it
Run these in order in one folder. Cells 1 and 2 give you a practice dataset and the gate. In the sprint you point the gate at your own Roboflow export.
Cell 1: a practice dataset (skip this when you have your own)
It draws pretend balls, players and goals in six sessions, and writes them in the same layout Roboflow exports for YOLO: train, valid, test, each with images and labels.
"""A pretend labelled dataset in the layout Roboflow exports for YOLO, so the sprint checks can be practised.
Names follow lesson 11: <class>_<session>_<n>.jpg (Roboflow later adds its own suffix; the check handles both)."""
import os
import shutil
import sys
import cv2
import numpy as np
NAMES = ["ball", "player", "goal"]
def draw_scene(rng, cls_id, session_seed):
base = np.array((50 + session_seed % 30, 130, 50), np.int16)
blocks = rng.integers(-45, 45, (9, 16, 3), dtype=np.int16) # coarse random patches: every picture is different
img = np.clip(base + cv2.resize(blocks, (640, 360), interpolation=cv2.INTER_NEAREST), 0, 255).astype(np.uint8)
for _ in range(60): # sharp lines, like grass and pitch marks
p = (int(rng.integers(0, 640)), int(rng.integers(0, 360)))
q = (p[0] + int(rng.integers(-40, 40)), p[1] + int(rng.integers(-40, 40)))
cv2.line(img, p, q, tuple(int(v) for v in rng.integers(0, 255, 3)), 2)
if cls_id == 0:
x, y, r = int(rng.integers(40, 600)), int(rng.integers(40, 320)), 9
cv2.circle(img, (x, y), r, (0, 200, 255), -1)
box = (x - r, y - r, x + r, y + r)
elif cls_id == 1:
x, y, w, h = int(rng.integers(40, 560)), int(rng.integers(40, 250)), 30, 70
cv2.rectangle(img, (x, y), (x + w, y + h), (200, 60, 30), -1)
box = (x, y, x + w, y + h)
else:
x, y, w, h = int(rng.integers(20, 480)), int(rng.integers(20, 200)), 120, 70
cv2.rectangle(img, (x, y), (x + w, y + h), (235, 235, 235), 3)
box = (x, y, x + w, y + h)
return img, box
def yolo_line(cls_id, box, w=640, h=360):
x1, y1, x2, y2 = box
return f"{cls_id} {(x1 + x2) / 2 / w:.6f} {(y1 + y2) / 2 / h:.6f} {(x2 - x1) / w:.6f} {(y2 - y1) / h:.6f}\n"
def make(root="dataset", sessions_per_split=None, per_session=10, seed=0):
"""sessions_per_split: how many sessions go to train / valid / test (default 4, 1, 1)."""
sessions_per_split = sessions_per_split or {"train": 4, "valid": 1, "test": 1}
rng = np.random.default_rng(seed)
if os.path.exists(root):
shutil.rmtree(root)
sid = 0
for split, n_sessions in sessions_per_split.items():
os.makedirs(f"{root}/{split}/images"); os.makedirs(f"{root}/{split}/labels")
for _ in range(n_sessions):
sid += 1
session = f"20261105-{9 + sid:02d}0000"
for i in range(per_session * len(NAMES)):
cls_id = i % len(NAMES)
img, box = draw_scene(rng, cls_id, sid)
name = f"{NAMES[cls_id]}_{session}_{i // len(NAMES) + 1:04d}"
cv2.imwrite(f"{root}/{split}/images/{name}.jpg", img)
open(f"{root}/{split}/labels/{name}.txt", "w").write(yolo_line(cls_id, box))
with open(f"{root}/data.yaml", "w") as f:
f.write("names: " + str(NAMES) + "\nnc: 3\ntrain: train/images\nval: valid/images\ntest: test/images\n")
return root
if __name__ == "__main__":
print("made", make(sys.argv[1] if len(sys.argv) > 1 else "dataset"))Cell 2: the readiness gate
One function reads the export and returns fails (must fix before training) and warns (look at them).
"""End-of-sprint gate: is the dataset ready to train on? Reads a Roboflow-style YOLO export:
dataset/{train,valid,test}/{images,labels} and dataset/data.yaml"""
import os
import re
from collections import Counter, defaultdict
import cv2
import numpy as np
SPLITS = ["train", "valid", "test"]
def session_of(filename):
"""ball_20261105-101500_0007.jpg or ball_20261105-101500_0007_jpg.rf.abc123.jpg -> 20261105-101500"""
parts = filename.split("_")
return parts[1] if len(parts) >= 3 else None
def stem(filename):
return filename.rsplit(".", 1)[0] if not filename.endswith(".rf") else filename
def average_hash(img, size=16):
g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
small = cv2.resize(g, (size, size), interpolation=cv2.INTER_AREA)
return "".join("1" if v > small.mean() else "0" for v in small.flatten())
def blur_score(img):
return float(cv2.Laplacian(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), cv2.CV_64F).var())
def read_names(root):
text = open(os.path.join(root, "data.yaml")).read()
m = re.search(r"names:\s*\[(.*?)\]", text)
return [n.strip().strip("'\"") for n in m.group(1).split(",")] if m else []
def check(root, quotas=None, min_images=150, min_sessions=2, blur_below=30.0, dup_distance=12):
fails, warns = [], []
names = read_names(root)
per_split = {}
class_counts = Counter()
sessions = defaultdict(set)
hashes = {} # image hash -> (split, file)
for split in SPLITS:
img_dir, lab_dir = f"{root}/{split}/images", f"{root}/{split}/labels"
if not os.path.isdir(img_dir):
fails.append(f"no {split}/images folder")
continue
imgs = sorted(f for f in os.listdir(img_dir) if f.lower().endswith((".jpg", ".jpeg", ".png")))
labs = sorted(f for f in os.listdir(lab_dir)) if os.path.isdir(lab_dir) else []
per_split[split] = len(imgs)
img_stems = {os.path.splitext(f)[0] for f in imgs}
lab_stems = {os.path.splitext(f)[0] for f in labs if f.endswith(".txt")}
for s in sorted(img_stems - lab_stems):
fails.append(f"{split}: image has no label file: {s}")
for s in sorted(lab_stems - img_stems):
fails.append(f"{split}: label file has no image: {s}")
for f in imgs:
se = session_of(f)
if se:
sessions[se].add(split)
else:
warns.append(f"{split}: cannot read the session from the name: {f}")
img = cv2.imread(f"{img_dir}/{f}")
if img is None:
fails.append(f"{split}: cannot open {f}")
continue
if blur_score(img) < blur_below:
warns.append(f"{split}: very blurry: {f}")
h = average_hash(img)
for oh, (osplit, ofile) in hashes.items():
if sum(a != b for a, b in zip(h, oh)) <= dup_distance:
where = "across splits (LEAKAGE)" if osplit != split else "in the same split"
(fails if osplit != split else warns).append(f"near-duplicate {where}: {split}/{f} ~ {osplit}/{ofile}")
break
hashes[h] = (split, f)
split_classes = Counter()
for f in sorted(lab_stems & img_stems):
for n, line in enumerate(open(f"{lab_dir}/{f}.txt"), 1):
p = line.split()
if not p:
continue
if len(p) != 5:
fails.append(f"{split}: {f}.txt line {n}: not 5 numbers")
continue
try:
cid, xc, yc, w, h = int(p[0]), *map(float, p[1:])
except ValueError:
fails.append(f"{split}: {f}.txt line {n}: not numbers")
continue
if not (0 <= xc <= 1 and 0 <= yc <= 1 and 0 < w <= 1 and 0 < h <= 1):
fails.append(f"{split}: {f}.txt line {n}: box outside the image")
elif w < 0.005 or h < 0.005:
warns.append(f"{split}: {f}.txt line {n}: tiny box")
cname = names[cid] if 0 <= cid < len(names) else f"class {cid}"
class_counts[cname] += 1
split_classes[cname] += 1
for cname in names:
if split_classes[cname] == 0 and per_split.get(split, 0) > 0:
(fails if split in ("valid", "test") else warns).append(f"{split} has no examples of '{cname}'")
total = sum(per_split.values())
if total < min_images:
fails.append(f"only {total} images; the sprint target is {min_images}")
leaked = [s for s, sp in sessions.items() if len(sp) > 1]
for s in sorted(leaked):
fails.append(f"session {s} is in more than one split ({sorted(sessions[s])}): LEAKAGE")
if len(sessions) < min_sessions:
fails.append(f"only {len(sessions)} session(s): the model will only know one situation")
if total:
share = {k: v / total for k, v in per_split.items()}
if share.get("valid", 0) < 0.08 or share.get("test", 0) < 0.05:
warns.append("valid or test set is very small: scores will jump around")
for cname, q in (quotas or {}).items():
if class_counts[cname] < q:
fails.append(f"class '{cname}': {class_counts[cname]} boxes, quota {q}")
return {"ready": not fails, "fails": fails, "warns": warns, "images": per_split, "boxes": dict(class_counts),
"sessions": len(sessions)}
def print_report(r):
print("images per split:", r["images"], "| boxes per class:", r["boxes"], "| sessions:", r["sessions"])
for f in r["fails"][:25]:
print("FAIL", f)
for w in r["warns"][:15]:
print("warn", w)
print("\nREADY TO TRAIN" if r["ready"] else "\nNOT READY: fix the FAIL lines first")
if __name__ == "__main__":
import sys
print_report(check(sys.argv[1] if len(sys.argv) > 1 else "dataset"))Cell 3: run it on the practice set
from make_demo_dataset import make
from sprint_check import check, print_report
make("dataset")
print_report(check("dataset", quotas={"ball": 40, "player": 40, "goal": 40}))You should see READY TO TRAIN. Quotas come from your brief (lesson 47): the least number of boxes per class you promised yourself.
Cell 4: break it on purpose
Before you trust a gate, break the data and watch it complain.
import glob, os, shutil
make("dataset")
# 1. a label goes missing
os.remove(glob.glob("dataset/train/labels/*.txt")[0])
# 2. one train picture is copied into test under a new session name
src = sorted(glob.glob("dataset/train/images/*.jpg"))[0]
new = "ball_20261106-090000_0001"
shutil.copy(src, f"dataset/test/images/{new}.jpg")
shutil.copy(src[:-4].replace("images", "labels") + ".txt", f"dataset/test/labels/{new}.txt")
print_report(check("dataset"))You should see a missing-label FAIL and a near-duplicate-across-splits FAIL, and NOT READY.
Cell 5: the tests
import os, shutil, glob
import cv2, numpy as np
from make_demo_dataset import make
from sprint_check import check
ok = 0
def expect(name, cond):
global ok
print(("PASS " if cond else "FAIL ") + name); ok += bool(cond)
if not cond: raise SystemExit(1)
def fresh(**kw):
make("d", **kw); return "d"
def has(r, text, kind="fails"): return any(text in x for x in r[kind])
r = check(fresh()); expect("clean dataset is ready", r["ready"] and not r["fails"])
expect("counts 180 images in 6 sessions", sum(r["images"].values()) == 180 and r["sessions"] == 6)
root = fresh(); os.remove(glob.glob(f"{root}/train/labels/*.txt")[0])
expect("missing label file is caught", has(check(root), "has no label file"))
root = fresh(); os.remove(glob.glob(f"{root}/train/images/*.jpg")[0])
expect("orphan label is caught", has(check(root), "has no image"))
root = fresh(); p = glob.glob(f"{root}/train/labels/*.txt")[0]; open(p, "w").write("0 1.5 0.5 0.1 0.1\n")
expect("box outside image is caught", has(check(root), "outside the image"))
root = fresh(); p = glob.glob(f"{root}/train/labels/*.txt")[0]; open(p, "w").write("0 0.5 0.5 0.1\n")
expect("malformed label line is caught", has(check(root), "not 5 numbers"))
root = fresh() # leakage by session: move a train image into valid
src = sorted(glob.glob(f"{root}/train/images/*.jpg"))[0]; name = os.path.basename(src)[:-4]
shutil.move(src, f"{root}/valid/images/"); shutil.move(f"{root}/train/labels/{name}.txt", f"{root}/valid/labels/")
expect("session in two splits is caught", has(check(root), "LEAKAGE"))
root = fresh() # near-duplicate across splits: copy a train image into test under a new session
src = sorted(glob.glob(f"{root}/train/images/*.jpg"))[0]
new = "ball_20261106-090000_0001"
shutil.copy(src, f"{root}/test/images/{new}.jpg"); shutil.copy(src[:-4].replace("images", "labels") + ".txt", f"{root}/test/labels/{new}.txt")
expect("duplicate across splits is caught", has(check(root), "near-duplicate across splits"))
root = fresh(); f = sorted(glob.glob(f"{root}/train/images/*.jpg"))[0]
img = cv2.imread(f); cv2.imwrite(f, cv2.GaussianBlur(img, (0, 0), 25))
expect("blurry image is flagged", has(check(root), os.path.basename(f), "warns"))
root = fresh(sessions_per_split={"train": 1, "valid": 0, "test": 0})
r = check(root); expect("one session only fails", has(r, "only 1 session"))
root = fresh(); r = check(root, quotas={"ball": 500})
expect("class quota from the brief is enforced", has(r, "class 'ball'"))
root = fresh() # remove every goal from the test split
for p in glob.glob(f"{root}/test/labels/goal_*.txt"):
n = os.path.basename(p)[:-4]; os.remove(p); os.remove(f"{root}/test/images/{n}.jpg")
expect("test split missing a class fails", has(check(root), "test has no examples of 'goal'"))
root = fresh(per_session=2)
expect("too few images fails", has(check(root), "images; the sprint target"))
shutil.rmtree("d"); shutil.rmtree("dataset", ignore_errors=True)
print(f"\n{ok} checks passed")Expected: 13 checks passed.
If it goes wrong
| Error | Cause | Fix |
|---|---|---|
| Gate says leakage | Random split of frames from the same session | Re-split by session (lesson 13), then re-run the gate |
| Every picture is a near-duplicate of another | Frames taken from one short video, a few per second | Sample every 1 to 2 seconds, and capture more sessions |
| File names have a long suffix | Roboflow adds its own text to export names | The session is still the second part of the name, so the gate reads it; if you rename, keep that pattern |
| A class name with an underscore breaks the session read | The name is split on underscores | Use class names without underscores, like ball and player |
| Gate cannot open an image | Wrong format or a half-copied file | Re-export that picture |
| Test set has no goal | Goals appeared only in the other sessions | Capture another session that contains goals, or move a whole session |
| Score on test looks too good later | Leakage the gate did not see, for example the same scene filmed by two phones | Split by place and day, and test on a new place in lesson 50 |
Try it
- Add a check that warns when one session holds over half the images
- Reduce the project to one class for now and reach 150 boxes of it
Check what you learnt
Is 150 images per class the goal?
Why do sessions matter more than the number of pictures?
What comes before filming anyone?
Why write your labelling rule down?
What does the quality gate do?
Conclusion
A data sprint is shopping before cooking. Aim for many sessions, not just many frames; get consent first; label by a written rule; split by session; and run an automatic gate that checks counts, labels and the split before any training starts. Keep the test set as the guest who has never tasted your cooking.
What next?
Next: Model training, iteration & AI feedback. You'll train, measure and improve your capstone model.

