AI & Computer Vision, lesson 9 of 52

Motion Detection & Background Subtraction

beginner20 min read

Introduction

A security camera doesn't know what a burglar looks like. It just notices that something in the picture changed. Today you'll build that: first by subtracting one frame from the last, then with a smarter method that learns what the empty scene looks like. You'll finish with a motion alarm that records only while something is moving.

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

  • Detect movement by frame differencing: subtract one frame from the last
  • Use cv2.createBackgroundSubtractorMOG2 and explain what it learns
  • Turn a motion mask into boxes with contours
  • Build a motion alarm with a minimum area and a cool-down time
  • Save frames only while something is moving
  • Explain what moving shadows, camera shake and light changes do to motion detection

Working principle

Frame differencing

Take the absolute difference between this frame and the previous one, in grayscale. Pixels that didn't change are near 0. Pixels that changed are bright.

Subtract the last frame from this one (cv2.absdiff). The goal post didn't move, so it vanishes. Only what changed lights up.

diff = cv2.absdiff(prev_gray, gray)
_, motion = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
motion = cv2.dilate(motion, None, iterations=2)

Always blur the grayscale frames first (lesson 7), or camera noise looks like motion.

Strength: simple and fast. Weakness: an object that stops moving vanishes, and only its edges show up when it moves slowly.

Background subtraction (MOG2)

MOG2 models each pixel as a mix of colours it has seen. If a new pixel doesn't fit the model, it is foreground.

bg = cv2.createBackgroundSubtractorMOG2(history=300, varThreshold=25, detectShadows=True)
fg = bg.apply(frame)
SettingMeaning
historyHow many frames it remembers. Longer means slower to adapt.
varThresholdHow different a pixel must be to count as new. Higher means fewer false alarms.
detectShadowsMarks shadows with value 127 (grey) instead of 255 (white). Threshold at 250 to keep only real foreground.

From mask to alarm

  1. Clean the motion mask (open, then dilate).
  2. Find contours. Ignore any with area below MIN_AREA.
  3. If at least one remains, "motion = True".
  4. Use a cool-down: keep the state "moving" for about a second after the last motion, so saving doesn't flicker on and off.

What fools motion detection

CauseResultFix
Light flicker or auto-exposureWhole frame changesRaise the threshold, require a minimum area, or use MOG2
Moving shadowsFake motiondetectShadows=True, threshold at 250
Wind, trees, screensConstant motionMask out those regions
Camera shakeEverything movesFix the camera. Software can only partly help.
A stopped playerDisappears from the maskExpected. A detector is needed, not motion.

What you'll need

  • Your cv_course environment and a webcam
  • The demo clip from lesson 5 (demo_clip.py makes one)

Build it

Step 1: frame differencing on the demo clip

import cv2
import numpy as np
from demo_clip import make_demo_clip
 
make_demo_clip("demo_clip.mp4")
cap = cv2.VideoCapture("demo_clip.mp4")
 
prev = None
moving_frames = 0
total = 0
 
while True:
    ret, frame = cap.read()
    if not ret:
        break
    total += 1
 
    gray = cv2.GaussianBlur(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY), (21, 21), 0)
    if prev is None:
        prev = gray
        continue
 
    diff = cv2.absdiff(prev, gray)
    _, mask = cv2.threshold(diff, 15, 255, cv2.THRESH_BINARY)
    mask = cv2.dilate(mask, None, iterations=2)
    prev = gray
 
    changed = int(np.count_nonzero(mask))
    if changed > 300:
        moving_frames += 1
 
    cv2.putText(frame, f"changed pixels: {changed}", (10, 25),
                cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2, cv2.LINE_AA)
    cv2.imshow("Frame", frame)
    cv2.imshow("Motion mask", mask)
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break
 
cap.release()
cv2.destroyAllWindows()
print(f"Motion in {moving_frames} of {total} frames")

Question: why is the number of changed pixels never exactly zero, even when nothing moves on a webcam?

Step 2: MOG2 background subtraction

import cv2
import numpy as np
 
SOURCE = "demo_clip.mp4"                 # use 0 for the webcam
MIN_AREA = 200
 
cap = cv2.VideoCapture(SOURCE)
bg = cv2.createBackgroundSubtractorMOG2(history=200, varThreshold=25, detectShadows=True)
kernel = np.ones((3, 3), np.uint8)
 
while True:
    ret, frame = cap.read()
    if not ret:
        break
 
    fg = bg.apply(frame)
    _, fg = cv2.threshold(fg, 250, 255, cv2.THRESH_BINARY)       # drop the grey shadows (127)
    fg = cv2.morphologyEx(fg, cv2.MORPH_OPEN, kernel)
    fg = cv2.dilate(fg, kernel, iterations=2)
 
    contours, _ = cv2.findContours(fg, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    for c in contours:
        if cv2.contourArea(c) < MIN_AREA:
            continue
        x, y, w, h = cv2.boundingRect(c)
        cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
 
    cv2.imshow("MOG2 boxes", frame)
    cv2.imshow("Foreground", fg)
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break
 
cap.release()
cv2.destroyAllWindows()

Question: what happens in the first few frames, when MOG2 hasn't learnt the background yet?

Step 3: motion-triggered capture (motion_capture.py)

This saves a frame only while something moves, with a cool-down so it doesn't flicker. lesson 11 reuses this.

import csv
import os
import time
from datetime import datetime
 
import cv2
import numpy as np
 
SOURCE = "demo_clip.mp4"           # use 0 for the webcam
OUT_DIR = "motion_frames"
MIN_AREA = 300                     # smallest moving blob that counts (pixels)
COOLDOWN = 1.0                     # seconds to stay "moving" after the last motion
SAVE_EVERY = 0.3                   # seconds between saved frames while moving
 
os.makedirs(OUT_DIR, exist_ok=True)
cap = cv2.VideoCapture(SOURCE)
bg = cv2.createBackgroundSubtractorMOG2(history=200, varThreshold=25, detectShadows=True)
kernel = np.ones((3, 3), np.uint8)
 
last_motion = 0.0
last_save = 0.0
saved = 0
frame_no = 0
fps = cap.get(cv2.CAP_PROP_FPS) or 30
log = open(os.path.join(OUT_DIR, "log.csv"), "w", newline="")
writer = csv.writer(log)
writer.writerow(["time", "file", "moving_area"])
 
while True:
    ret, frame = cap.read()
    if not ret:
        break
    frame_no += 1
 
    # for a file, use video time so the clip runs as if live
    now = frame_no / fps if isinstance(SOURCE, str) else time.time()
 
    fg = bg.apply(frame)
    _, fg = cv2.threshold(fg, 250, 255, cv2.THRESH_BINARY)
    fg = cv2.dilate(cv2.morphologyEx(fg, cv2.MORPH_OPEN, kernel), kernel, iterations=2)
 
    contours, _ = cv2.findContours(fg, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    area = sum(cv2.contourArea(c) for c in contours if cv2.contourArea(c) >= MIN_AREA)
 
    if area > 0:
        last_motion = now
    moving = (now - last_motion) < COOLDOWN and frame_no > 30     # skip the learning frames
 
    if moving and (now - last_save) >= SAVE_EVERY:
        name = datetime.now().strftime("%Y%m%d_%H%M%S_") + f"{frame_no:05d}.jpg"
        cv2.imwrite(os.path.join(OUT_DIR, name), frame)
        writer.writerow([round(now, 2), name, int(area)])
        last_save = now
        saved += 1
 
    label = "MOTION" if moving else "still"
    colour = (0, 0, 255) if moving else (0, 200, 0)
    cv2.putText(frame, f"{label}  saved: {saved}", (10, 25),
                cv2.FONT_HERSHEY_SIMPLEX, 0.7, colour, 2, cv2.LINE_AA)
    cv2.imshow("Motion capture", frame)
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break
 
cap.release()
log.close()
cv2.destroyAllWindows()
print(f"Saved {saved} frames to {OUT_DIR}/")

Question: with the webcam, how many frames does it save if you sit still for a minute? What if you wave?

If it goes wrong

ErrorCauseFix
Alarm fires constantlyThreshold too low, or auto-exposure flickerBlur more, raise the threshold, raise MIN_AREA
Never detects slow movementFrame differencing only sees change between framesUse MOG2, or compare against a frame from half a second ago
Mask looks like the outline of a personDifferencing only shows edges of a moving objectDilate more, or use MOG2
MOG2 shows the whole frame as foreground at the startIt hasn't learnt the background yetSkip the first 30 to 60 frames
Frames keep saving after you stopNo cool-down logic, or the cool-down is too longCheck COOLDOWN and last_motion
Folder fills with thousands of filesSaving every frameUse SAVE_EVERY and cap the total

Try it

  • Add a "region of interest" so only the left half of the frame counts
  • Log each motion event's start and end time to a CSV
  • Combine with lesson 8: only trigger when something orange moves

Check what you learnt

  1. What does cv2.absdiff give you?

  2. MOG2 marks shadows as 127. Why is that useful?

  3. What is the cool-down in a motion alarm for?

  4. What can motion detection NOT tell you?

  5. Why ignore small contours in the motion mask?

Conclusion

Motion detection finds change. Frame differencing subtracts the last frame; background subtraction (MOG2) learns what "normal" looks like and flags anything new. Contours turn the motion into boxes, and a minimum area plus a cool-down turn it into a reliable alarm. It can't tell you what moved, but it's a great trigger.

What next?

Next: What makes good sports data?. Unit 3 starts: collecting the data your AI models will learn from.