AI & Computer Vision, lesson 11 of 52
Webcam Data Capture: A Camera Trap for Your Dataset
Introduction
Last lesson you audited a dataset and found its problems. Today you stop those problems at the source. You'll build a capture script that saves frames into the right folder, rejects blurry and repeated frames as it captures, and keeps a diary of everything it did. By the end you'll have 100 good frames of your own.
By the end of this lesson you'll be able to:
- Save frames automatically at a set interval, or manually on a key press
- Name files and folders so every frame is traceable to its class and session
- Reject blurry frames and near-duplicate frames at capture time
- Keep a CSV log of every attempt, with its blur score and the reason it was accepted or rejected
- Track progress towards a target count per class
- Reuse the lesson 9 motion trigger to capture only when something moves
Working principle
The capture loop
Same loop as lesson 2, with a decision added before saving:
- Read a frame.
- Should I try to save it? (the interval has passed, or the spacebar was pressed, or motion was seen)
- Is it good? Sharp enough? Different enough from the last saved frame?
- If yes: save it in the class folder and update the counts. If not: note why.
- Write a line in the log, show the numbers on screen, repeat.
The settings (the "control panel")
These constants sit at the top of the file. Change them for the place you're filming. In lesson 15 you'll explain your choice for each one.
| Setting | Meaning | Classroom default |
|---|---|---|
CLASS_NAME | Which class folder frames go into: player, ball, goal or background | player |
DATASET_ROOT | Root folder for the data | dataset |
BLUR_THRESHOLD | Minimum sharpness (variance of the Laplacian from lesson 10). Lower is more forgiving. | 80 |
CAPTURE_INTERVAL | Seconds between automatic tries. 0 means manual (spacebar) only. | 0.8 |
MIN_DIFF | How many pixels must change from the last saved frame (at 160 by 120) to count as new. Higher is stricter. | 500 |
CAMERA_INDEX | Which camera to open | 0 |
SAVE_QUALITY | JPEG quality, 0 to 100 | 92 |
MOTION_ONLY | Only try to save when something is moving (the lesson 9 trigger) | False |
TARGET_COUNTS | How many frames you want per class | see code |
Names and folders
dataset/
player/ player_20261105-101500_0001.jpg ...
ball/ ball_20261105-101500_0001.jpg ...
capture_log.csvA file name holds the class, a session id and a running number. A session is one run of the script at one camera position. Press N to start a new session when you move the camera. In lesson 13 the session id lets us split train and test by session, so near-identical frames can't leak across.
Why filter at capture time?
It is far cheaper to refuse a bad frame now than to find it later among 3,000. Filtering also keeps the accept rate visible. If it falls below about 40%, something is wrong (shaky camera, light, interval too short), and you can fix it on the spot.
What you'll need
- Your
cv_courseenvironment and a webcam - Something to film: a ball, a toy player, or yourself
Build it
Controls: SPACE try to capture now · C switch to the next class · N start a new session · P pause or resume the automatic mode · Q quit.
import csv
import os
import time
from datetime import datetime
import cv2
import numpy as np
# ---------------- SETTINGS: change these, and justify them in your field brief ----------------
CLASS_NAME = "player" # player | ball | goal | background
DATASET_ROOT = "dataset"
BLUR_THRESHOLD = 80.0 # lower it outdoors (faster motion, wind, distance)
CAPTURE_INTERVAL = 0.8 # seconds between automatic tries. 0 = SPACE only
MIN_DIFF = 500 # pixels that must differ from the last saved frame
CAMERA_INDEX = 0
SAVE_QUALITY = 92
MOTION_ONLY = False # True: only try to save when something is moving (lesson 9)
TARGET_COUNTS = {"player": 100, "ball": 100, "goal": 50, "background": 50}
# ----------------------------------------------------------------------------------------------
CLASSES = list(TARGET_COUNTS.keys())
SMALL = (160, 120) # frames are compared at this small size
def blur_score(gray):
return cv2.Laplacian(gray, cv2.CV_64F).var()
def pixels_changed(a, b):
return int(np.count_nonzero(cv2.absdiff(a, b) > 25))
def new_session_id():
return datetime.now().strftime("%Y%m%d-%H%M%S")
def count_existing(cls):
folder = os.path.join(DATASET_ROOT, cls)
return len([f for f in os.listdir(folder) if f.endswith(".jpg")]) if os.path.isdir(folder) else 0
def main():
cap = cv2.VideoCapture(CAMERA_INDEX)
if not cap.isOpened():
raise SystemExit("Cannot open the camera. Try CAMERA_INDEX = 1 or 2.")
os.makedirs(DATASET_ROOT, exist_ok=True)
for c in CLASSES:
os.makedirs(os.path.join(DATASET_ROOT, c), exist_ok=True)
log_path = os.path.join(DATASET_ROOT, "capture_log.csv")
new_log = not os.path.exists(log_path)
log = open(log_path, "a", newline="")
writer = csv.writer(log)
if new_log:
writer.writerow(["time", "session", "class", "file", "blur", "pixels_changed", "accepted", "reason"])
cls = CLASS_NAME if CLASS_NAME in CLASSES else CLASSES[0]
counts = {c: count_existing(c) for c in CLASSES}
session = new_session_id()
paused = False
last_try = 0.0
last_saved_small = None
recent = [] # last 50 attempts: True or False
message = "ready"
bg = cv2.createBackgroundSubtractorMOG2(history=200, varThreshold=25, detectShadows=False)
kernel = np.ones((3, 3), np.uint8)
print("SPACE capture | C class | N new session | P pause | Q quit")
while True:
ret, frame = cap.read()
if not ret:
print("No frame received.")
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
score = blur_score(gray)
small = cv2.resize(gray, SMALL)
moving = True
if MOTION_ONLY:
fg = cv2.morphologyEx(bg.apply(frame), cv2.MORPH_OPEN, kernel)
moving = np.count_nonzero(fg) > 0.01 * fg.size
now = time.time()
key = cv2.waitKey(1) & 0xFF
manual = key == ord(" ")
auto = (CAPTURE_INTERVAL > 0 and not paused and moving and now - last_try >= CAPTURE_INTERVAL)
if key == ord("q"):
break
if key == ord("c"):
cls = CLASSES[(CLASSES.index(cls) + 1) % len(CLASSES)]
message = f"class: {cls}"
if key == ord("n"):
session = new_session_id()
message = f"new session {session}"
if key == ord("p"):
paused = not paused
if manual or auto:
last_try = now
changed = pixels_changed(small, last_saved_small) if last_saved_small is not None else 10 ** 9
if score < BLUR_THRESHOLD:
accepted, reason = False, "blurry"
elif changed < MIN_DIFF:
accepted, reason = False, "too similar"
else:
accepted, reason = True, "ok"
name = ""
if accepted:
counts[cls] += 1
name = f"{cls}_{session}_{counts[cls]:04d}.jpg"
path = os.path.join(DATASET_ROOT, cls, name)
cv2.imwrite(path, frame, [cv2.IMWRITE_JPEG_QUALITY, SAVE_QUALITY])
last_saved_small = small
writer.writerow([datetime.now().isoformat(timespec="seconds"), session, cls, name,
round(score, 1), changed if changed < 10 ** 9 else "", accepted, reason])
recent = (recent + [accepted])[-50:]
message = f"saved {name}" if accepted else f"rejected: {reason}"
# ---- on-screen display ----
h, w = frame.shape[:2]
rate = 100 * sum(recent) / len(recent) if recent else 0
target = TARGET_COUNTS[cls]
done = counts[cls] >= target
cv2.rectangle(frame, (0, 0), (w, 70), (90, 30, 10), -1)
cv2.putText(frame, f"class: {cls} {counts[cls]}/{target}" + (" TARGET REACHED: press C" if done else ""),
(10, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0) if not done else (0, 255, 255), 2, cv2.LINE_AA)
blur_colour = (0, 255, 0) if score >= BLUR_THRESHOLD else (0, 0, 255)
cv2.putText(frame, f"blur {score:5.0f} (min {BLUR_THRESHOLD:.0f}) accept rate {rate:3.0f}%",
(10, 46), cv2.FONT_HERSHEY_SIMPLEX, 0.55, blur_colour, 1, cv2.LINE_AA)
cv2.putText(frame, message + (" PAUSED" if paused else ""), (10, 64),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (220, 220, 220), 1, cv2.LINE_AA)
cv2.imshow("capture.py", frame)
cap.release()
log.close()
cv2.destroyAllWindows()
print("Counts:", counts)
if __name__ == "__main__":
main()Your turn: capture 100 frames
- Pick a class (your hand, a ball, a shoe) and set
CLASS_NAMEand a smallTARGET_COUNTSentry. - Run the script. Move the object around, closer and further, in different light.
- Watch the blur number and the accept rate on screen.
- When you hit the target, press C and capture a background class: the same scene with the object absent.
- Open
dataset/capture_log.csv. How many attempts were rejected, and why?
Question: if the accept rate is 25%, which setting do you change first?
If it goes wrong
| Error | Cause | Fix |
|---|---|---|
| "Cannot open the camera" | Wrong index or another app has the camera | Try index 1 or 2. Close Zoom and the browser. |
| Almost every frame is rejected as blurry | BLUR_THRESHOLD too high for the webcam or light | Check the on-screen blur number for a sharp scene and set the threshold about 30% below it |
| Almost every frame is rejected as "too similar" | MIN_DIFF too high, or nothing is moving | Move the object more, or lower MIN_DIFF |
| Files saved in the wrong class folder | Forgot to press C after a target was reached | Watch the class name in the banner. In the field, give one person the Class Manager role. |
PermissionError writing files | Folder is read-only or locked | Pick a folder you own, outside synced system folders |
| Counts reset when the script restarts | They are rebuilt from the files in the folder | That is correct: it continues where it stopped |
| Frame looks dark in the first seconds | Auto-exposure still adjusting | Wait a few seconds before capturing |
Try it
- Turn on
MOTION_ONLYand compare the accept rate - Add a key to delete the last saved frame
- Plot the blur scores from the log as a histogram
Check what you learnt
Why compare frames at a small size like 160 by 120?
Your accept rate is very low. What does it tell you?
Why collect a 'background' class?
Why start a new session each time you move the camera?
What does CAPTURE_INTERVAL = 0 mean?
Conclusion
A good capture script is a camera trap: it saves frames on a timer or a key press, throws away blurry and duplicate ones on the spot, files each frame by class and session, and logs every decision. Better data at capture time saves hours of cleaning later.
What next?
Next: Annotation & labelling. You'll label your frames so a model can learn from them.

