AI & Computer Vision, lesson 9 of 52
Motion Detection & Background Subtraction
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.createBackgroundSubtractorMOG2and 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)| Setting | Meaning |
|---|---|
history | How many frames it remembers. Longer means slower to adapt. |
varThreshold | How different a pixel must be to count as new. Higher means fewer false alarms. |
detectShadows | Marks shadows with value 127 (grey) instead of 255 (white). Threshold at 250 to keep only real foreground. |
From mask to alarm
- Clean the motion mask (open, then dilate).
- Find contours. Ignore any with area below
MIN_AREA. - If at least one remains, "motion = True".
- 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
| Cause | Result | Fix |
|---|---|---|
| Light flicker or auto-exposure | Whole frame changes | Raise the threshold, require a minimum area, or use MOG2 |
| Moving shadows | Fake motion | detectShadows=True, threshold at 250 |
| Wind, trees, screens | Constant motion | Mask out those regions |
| Camera shake | Everything moves | Fix the camera. Software can only partly help. |
| A stopped player | Disappears from the mask | Expected. A detector is needed, not motion. |
What you'll need
- Your
cv_courseenvironment and a webcam - The demo clip from lesson 5 (
demo_clip.pymakes 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
| Error | Cause | Fix |
|---|---|---|
| Alarm fires constantly | Threshold too low, or auto-exposure flicker | Blur more, raise the threshold, raise MIN_AREA |
| Never detects slow movement | Frame differencing only sees change between frames | Use MOG2, or compare against a frame from half a second ago |
| Mask looks like the outline of a person | Differencing only shows edges of a moving object | Dilate more, or use MOG2 |
| MOG2 shows the whole frame as foreground at the start | It hasn't learnt the background yet | Skip the first 30 to 60 frames |
| Frames keep saving after you stop | No cool-down logic, or the cool-down is too long | Check COOLDOWN and last_motion |
| Folder fills with thousands of files | Saving every frame | Use 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
What does cv2.absdiff give you?
MOG2 marks shadows as 127. Why is that useful?
What is the cool-down in a motion alarm for?
What can motion detection NOT tell you?
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.

