AI & Computer Vision, lesson 11 of 52

Webcam Data Capture: A Camera Trap for Your Dataset

intermediate24 min read

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:

  1. Read a frame.
  2. Should I try to save it? (the interval has passed, or the spacebar was pressed, or motion was seen)
  3. Is it good? Sharp enough? Different enough from the last saved frame?
  4. If yes: save it in the class folder and update the counts. If not: note why.
  5. 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.

SettingMeaningClassroom default
CLASS_NAMEWhich class folder frames go into: player, ball, goal or backgroundplayer
DATASET_ROOTRoot folder for the datadataset
BLUR_THRESHOLDMinimum sharpness (variance of the Laplacian from lesson 10). Lower is more forgiving.80
CAPTURE_INTERVALSeconds between automatic tries. 0 means manual (spacebar) only.0.8
MIN_DIFFHow many pixels must change from the last saved frame (at 160 by 120) to count as new. Higher is stricter.500
CAMERA_INDEXWhich camera to open0
SAVE_QUALITYJPEG quality, 0 to 10092
MOTION_ONLYOnly try to save when something is moving (the lesson 9 trigger)False
TARGET_COUNTSHow many frames you want per classsee code

Names and folders

dataset/
  player/  player_20261105-101500_0001.jpg ...
  ball/    ball_20261105-101500_0001.jpg ...
  capture_log.csv

A 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_course environment 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

  1. Pick a class (your hand, a ball, a shoe) and set CLASS_NAME and a small TARGET_COUNTS entry.
  2. Run the script. Move the object around, closer and further, in different light.
  3. Watch the blur number and the accept rate on screen.
  4. When you hit the target, press C and capture a background class: the same scene with the object absent.
  5. 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

ErrorCauseFix
"Cannot open the camera"Wrong index or another app has the cameraTry index 1 or 2. Close Zoom and the browser.
Almost every frame is rejected as blurryBLUR_THRESHOLD too high for the webcam or lightCheck 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 movingMove the object more, or lower MIN_DIFF
Files saved in the wrong class folderForgot to press C after a target was reachedWatch the class name in the banner. In the field, give one person the Class Manager role.
PermissionError writing filesFolder is read-only or lockedPick a folder you own, outside synced system folders
Counts reset when the script restartsThey are rebuilt from the files in the folderThat is correct: it continues where it stopped
Frame looks dark in the first secondsAuto-exposure still adjustingWait a few seconds before capturing

Try it

  • Turn on MOTION_ONLY and 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

  1. Why compare frames at a small size like 160 by 120?

  2. Your accept rate is very low. What does it tell you?

  3. Why collect a 'background' class?

  4. Why start a new session each time you move the camera?

  5. 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.