Part II: Classical Computer Vision
Chapter 15: Motion, Optical Flow & Tracking

Background Subtraction & Change Detection

"I have stared at this hallway for nine thousand frames. I know exactly what nothing looks like. The instant something is not nothing, I will let you know."

A Patient Background Model With Excellent Object Permanence
Big Picture

If the camera holds still, you do not need to track motion at all; you only need to remember what the scene looks like when nothing is happening, then flag every pixel that disagrees. That is background subtraction, the cheapest and most widely deployed motion primitive in all of computer vision. The flow methods of Section 15.2 and Section 15.3 answer "how did this pixel move?"; background subtraction answers the simpler, often more useful question "is this pixel part of the scene or part of something new?" The whole game is building a per-pixel statistical model of "background", updating it as lighting drifts, and deciding the threshold at which a pixel has become "foreground". Get that model right and a $30 camera counts cars, watches a doorway, or triggers a recording. Get it wrong and every passing cloud becomes an intruder.

In the previous three sections the camera and the scene were both allowed to move, and we worked hard to estimate that motion densely or sparsely. This section makes one strong assumption, that the camera is fixed, and is rewarded with an enormous simplification. Under a static camera, the projected position of every static 3D point is constant: the floor, the walls, the parked cars never move in the image. Anything that does move, a person, a vehicle, a falling box, lights up as a difference from the remembered scene. We never compute a velocity; we compute a membership label, foreground or background, at every pixel of every frame. This is change detection, and background subtraction is its dominant classical form.

1. The Static-Camera Assumption and the Simplest Detector Beginner

Start with the most naive idea that could possibly work: keep one reference image $B(x, y)$ of the empty scene, and for each new frame $I_t$, mark a pixel as foreground when it differs from the reference by more than a threshold $\tau$:

$$ F_t(x, y) = \begin{cases} 1 & \text{if } \lvert I_t(x, y) - B(x, y) \rvert > \tau \\ 0 & \text{otherwise.} \end{cases} $$

This is frame differencing against a fixed reference, and it has exactly one virtue and many flaws. The virtue is speed: one subtraction and one comparison per pixel. The flaws are that you need a clean empty frame to begin with (rare in practice), and that the reference goes stale the moment a cloud passes, a light switches, or the sun moves. The fix for staleness is to let the background adapt. The simplest adaptive model is a running average: blend a little of each new frame into the background so it slowly tracks lighting changes while ignoring transient foreground.

$$ B_t = (1 - \alpha) B_{t-1} + \alpha I_t, \qquad 0 < \alpha \ll 1. $$

The learning rate $\alpha$ controls the model's memory: small $\alpha$ (say $0.01$) means a slow, stable background that needs roughly $1/\alpha$ frames to forget the past, while large $\alpha$ lets fast lighting changes in but also bakes a slow-moving foreground object into the background, where it leaves a ghost. Code Fragment 1 implements the running-average detector and shows the staleness-versus-stability tradeoff in one parameter.

import cv2
import numpy as np

cap = cv2.VideoCapture("hallway.mp4")
ok, frame = cap.read()
bg = frame.astype(np.float32)        # running-average background, kept in float
alpha = 0.02                         # learning rate: ~50-frame memory

while True:
    ok, frame = cap.read()
    if not ok:
        break
    cur = frame.astype(np.float32)
    # per-pixel absolute difference, collapsed across color channels
    diff = cv2.absdiff(cur, bg).max(axis=2)
    fg = (diff > 25).astype(np.uint8) * 255          # tau = 25 gray levels

    # update the background ONLY where we believe it is background,
    # so moving objects are not blended in and do not leave ghosts
    mask = (fg == 0)[..., None]
    bg = np.where(mask, (1 - alpha) * bg + alpha * cur, bg)

    cv2.imshow("foreground", fg)
    if cv2.waitKey(1) == 27:
        break
# Typical result: clean silhouettes of walkers; the floor stays black,
# and the model re-absorbs a bag left on the floor over ~50 frames.
Code Fragment 1: A selective running-average background subtractor. The crucial line is the masked update: blending new pixels into the background only where the current label is "background" prevents a slow walker from being learned as scenery, the single most common bug in homemade detectors.

The masked update in Code Fragment 1 is the difference between a toy and something usable. If you update the background everywhere, a person who pauses for a few seconds gets absorbed into the model and vanishes from the foreground mask; worse, when they leave, the spot they occupied now differs from the (contaminated) background and flickers as a false positive. Updating only the background-labeled pixels keeps the model honest. Even so, a single mean per pixel cannot represent a background that legitimately takes more than one value, and most real backgrounds do.

Common Misconception: Foreground Means "Moving"

The foreground mask is easy to read as "the moving objects", but background subtraction never measures motion; it measures disagreement with a learned model of the scene. The two diverge constantly. A car that drives in and parks keeps lighting up as foreground long after it has stopped moving, until the model slowly absorbs it; an object that is removed from the scene leaves a foreground hole where the background no longer matches, even though nothing is moving there now; and a person standing perfectly still fades out of the foreground while plainly present. If you genuinely need "what is moving this instant", that is the per-pixel velocity question answered by the optical flow of Section 15.2 and Section 15.3, not by a background model. Background subtraction answers the different, often more useful question "what does not belong to the remembered scene", and conflating the two produces phantom detections of stopped and removed objects.

Key Insight: Background Is Not One Color, It Is a Distribution

The reason a single running average fails outdoors is that genuine background pixels are multimodal. A pixel on a tree sees leaf, then sky, then leaf as the branch sways; a pixel over a fountain sees water and spray alternating; a pixel on a flickering monitor cycles through colors. None of these is foreground, yet a unimodal model calls every switch a change. The whole evolution of background modeling, from running average to Gaussian mixtures to the non-parametric and learned models, is one long answer to a single question: how do you describe "all the things this pixel is allowed to be" without also accepting the intruder you are trying to catch?

2. The Gaussian Mixture Model: MOG2 Intermediate

The dominant classical answer, and still the default in OpenCV, is to model each pixel's history as a mixture of Gaussians (Stauffer and Grimson, 1999; refined by Zivkovic, 2004, into the MOG2 used today). Instead of one mean, each pixel keeps $K$ Gaussian components (typically three to five), each with a weight $w_k$, mean $\mu_k$, and variance $\sigma_k^2$. The recurring background values, leaf and sky, water and spray, occupy the high-weight, low-variance components; rare or transient values do not. A new pixel value matches a component if it lands within a few standard deviations of that component's mean. The components are sorted by $w_k / \sigma_k$ (high weight and low spread first), and the top components whose weights sum past a fraction $T$ of the total are declared "background". A pixel is foreground when it matches none of the background components.

On each frame, a matched component nudges its mean and variance toward the new value and increases its weight, while unmatched components decay; if nothing matches, the weakest component is replaced by a new Gaussian centered on the current value. Written out, with learning rate $\alpha$ and a match indicator $M_k$ (one for the matched component, zero otherwise), the per-pixel online update for the new value $x_t$ is

$$w_k \leftarrow (1-\alpha)\,w_k + \alpha M_k, \qquad \mu_k \leftarrow \mu_k + M_k\,\rho\,(x_t - \mu_k), \qquad \sigma_k^2 \leftarrow \sigma_k^2 + M_k\,\rho\,\big((x_t - \mu_k)^2 - \sigma_k^2\big),$$

where $\rho \approx \alpha / w_k$ is the per-component rate (faster for low-weight components, so a new mode settles quickly). Every weight decays by $(1-\alpha)$ each frame and only the matched component is pushed back up by $\alpha$, so a value that recurs accumulates weight while a one-off (a person crossing) never does; the weights are renormalized to sum to one after the step. This online update is why a swaying branch's two states both survive as background components, while a person walking through, an unrepeated value, never accumulates enough weight to be accepted. Figure 15.4.1 contrasts the unimodal and mixture views of a single oscillating pixel.

pixel intensity frequency over time background mode A (leaf) background mode B (sky) intruder value one broad Gaussian accepts the valley (false negative) two narrow mixture components reject the valley (correct)
Figure 15.4.1: Why a Gaussian mixture beats a single Gaussian on multimodal pixels. A pixel that alternates between two background values produces two histogram peaks. A single broad Gaussian (gray dashed) covers both peaks but also accepts the intruder value in the valley between them, a false negative. Two narrow mixture components (green) sit tightly on each peak and correctly reject the valley value as foreground.

OpenCV ships two production background subtractors, MOG2 (the Gaussian mixture above) and KNN (a non-parametric k-nearest-neighbor model that compares each pixel against a buffer of recent samples). Both expose a detectShadows option and an online learning rate. Code Fragment 2 runs MOG2 and then cleans its raw mask with the morphological opening and closing of Chapter 6, which is essential because raw subtraction masks are always speckled with noise pixels and pocked with holes.

import cv2

cap = cv2.VideoCapture("street.mp4")

# history: frames of memory; varThreshold: Mahalanobis^2 match gate;
# detectShadows: label soft shadows as gray (127) instead of foreground
mog2 = cv2.createBackgroundSubtractorMOG2(
    history=500, varThreshold=16, detectShadows=True)

kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))

while True:
    ok, frame = cap.read()
    if not ok:
        break
    fg = mog2.apply(frame, learningRate=-1)   # -1 = auto rate from history

    fg[fg == 127] = 0                          # drop shadow pixels (kept as 127)
    fg = cv2.morphologyEx(fg, cv2.MORPH_OPEN, kernel)   # remove speckle
    fg = cv2.morphologyEx(fg, cv2.MORPH_CLOSE, kernel)  # fill small holes

    # connected components give per-object blobs ready for tracking (Section 15.5)
    n, labels, stats, centroids = cv2.connectedComponentsWithStats(fg)
    movers = [i for i in range(1, n) if stats[i, cv2.CC_STAT_AREA] > 400]
    print(f"frame: {len(movers)} moving blobs above 400 px")
# Typical street clip, one printed line per frame:
# frame: 0 moving blobs above 400 px   (empty street)
# frame: 3 moving blobs above 400 px   (two pedestrians and a car)
Code Fragment 2: MOG2 background subtraction with a shadow-aware mask, morphological cleanup from Chapter 6, and a connected-components pass that converts the binary mask into per-object blobs. The area filter discards the residual speckle that morphology missed.
Library Shortcut: A Production Subtractor in Two Lines

A from-scratch Gaussian-mixture subtractor with online weight updates, component replacement, shadow detection, and an adaptive learning rate is roughly 150 lines of careful numerical code. OpenCV reduces it to two: mog2 = cv2.createBackgroundSubtractorMOG2() then fg = mog2.apply(frame) per frame. The library handles the per-pixel mixture state in a packed C++ buffer, the sorting by weight-over-variance, the shadow chromaticity test, and the auto learning-rate from the history parameter. Write the from-scratch version once to understand what "background is a distribution" means in code; ship MOG2 or KNN.

You Could Build This: A Zero-GPU Doorway Counter

The connected-components blobs from Code Fragment 2 are one short step from a useful device: a tripwire counter that tallies how many people cross a line, or how many cars pass a lane, on a static camera with no neural network and no cloud. The build, a beginner project of about an hour: draw a virtual counting line across the frame, track each MOG2 blob's centroid frame to frame by nearest-neighbor matching, and increment a counter whenever a centroid's path crosses the line (record the crossing direction to count "in" versus "out" separately). Add the area filter you already have to ignore birds and leaves, and print a running total. This is the exact primitive behind retail footfall sensors, transit-platform crowd estimates, and the toll-bridge vehicle counter in this section's practical example, all of which run on hardware costing a few tens of dollars. When same-direction objects merge into one blob and the count slips, you have personally discovered why the next two sections add real trackers: the centroid matching here is a one-frame-memory stand-in for the Kalman-plus-association machinery of Section 15.6.

Fun Fact: The Vanishing Napper

Sit perfectly still in front of an unmasked running-average subtractor and something unsettling happens: over a few hundred frames you fade out of the foreground entirely. The model has decided that a person who never moves is simply part of the furniture and quietly absorbed you into the background. Stand up to leave and you reappear as a perfect human-shaped hole, a ghost of where you were learned. Office "is anyone there?" sensors have shut off the lights on motionless workers for exactly this reason, which is the most literal possible demonstration of why the masked update in Code Fragment 1 exists.

3. Shadows, Light Changes, and the Chromaticity Trick Intermediate

The most persistent practical failure of background subtraction is the cast shadow. A person's shadow on the floor differs from the remembered background, so a naive detector labels the shadow as foreground and the silhouette balloons, merging neighboring people and ruining any downstream counting or tracking. The fix exploits a physical fact: a shadow scales a surface's brightness down roughly uniformly across color channels while leaving its chromaticity (the ratio of channels, the surface's color) nearly unchanged. A true foreground object, by contrast, usually changes the color, not just the brightness. So a candidate foreground pixel is reclassified as shadow when its value is a darkened version of the background value at the same hue and saturation, which is exactly the test MOG2's detectShadows applies in a normalized color space. We dropped those shadow-labeled pixels (value 127) in Code Fragment 2.

The harder failure is the sudden global light change: someone flips a light switch, or the sun emerges, and every pixel changes at once. No incremental model can adapt within a single frame, so the entire image flares as foreground for the seconds it takes the learning rate to catch up. Robust systems detect this case explicitly (when more than, say, 70 percent of pixels are flagged foreground at once, treat it as a global illumination event, not an army of intruders) and respond by temporarily boosting the learning rate to re-seed the background fast. This connects back to the photometric normalization ideas of Chapter 2: working in a normalized or gradient-based representation, rather than raw intensity, buys robustness to the lighting changes that wreck raw-intensity models.

Practical Example: Counting Cars at a Toll-Free Bridge

Who: A transportation-analytics startup retrofitting a municipal bridge with a single fixed roadside camera to produce hourly vehicle counts for a traffic study.

Situation: One mast-mounted camera, no loop sensors in the road, a fixed view of three lanes, and a hard requirement to run on a $60 single-board computer at the pole with no cloud connection.

Problem: The first MOG2 deployment counted beautifully at noon and fell apart at dawn and dusk. Long, raking shadows from the low sun stretched across two lanes and merged adjacent cars into single giant blobs, so a five-car cluster counted as one. Passing clouds triggered global-illumination flares that briefly counted the whole bridge as a vehicle.

Decision: Three targeted fixes rather than a model swap. Enable detectShadows and discard shadow pixels (cutting merged blobs by roughly half). Add the global-flare guard: if foreground exceeds 60 percent of the frame, skip counting for that frame and raise the learning rate for ten frames. And add a per-lane region of interest with a minimum and maximum blob area, so a shadow sliver or a bird could never register as a car.

Result: Counting accuracy against a hand-labeled hour rose from 71 percent to 96 percent across a full day, dawn to dusk, with no GPU and no retraining. The shadow fix alone accounted for most of the dawn and dusk gains.

Lesson: Background subtraction rarely fails in the math; it fails on shadows and light changes. Two cheap, physically motivated guards (chromaticity-based shadow rejection and a global-flare detector) recover most of the accuracy a static-camera deployment leaves on the table.

4. When the Camera Moves: Compensated and Panoramic Backgrounds Advanced

Every model so far assumed a fixed camera. The instant the camera pans, tilts, or rides on a moving platform, the static-background assumption collapses: the whole scene shifts in the image, so every pixel differs from its remembered value and the entire frame reads as foreground. There are two classical escapes. The first is motion compensation: estimate the dominant frame-to-frame motion as a global transform, a homography from Chapter 5 for a rotating or distant-scene camera, warp the previous background into the current frame's coordinates, and only then subtract. What remains after compensating the camera's own motion is independent motion, objects moving differently from the static world, which is exactly what you wanted to detect. The global transform is estimated with the same RANSAC-on-keypoint-matches machinery from Chapter 10, or from the sparse optical flow of Section 15.2 by fitting a model to the dominant flow vectors.

The second escape, for a camera that pans across a fixed scene (a security pan-tilt-zoom (PTZ) unit on a tour, for instance), is to build a panoramic background: stitch the views into one large mosaic, register each incoming frame into the mosaic via homography, and subtract against the corresponding mosaic region. This turns a moving-camera problem back into a static-background problem in mosaic coordinates. Code Fragment 3 sketches the compensation idea: estimate the inter-frame homography, warp the running background forward, and subtract.

import cv2
import numpy as np

prev_gray = None
bg = None
orb = cv2.ORB_create(1000)
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)

def estimate_homography(g0, g1):
    """Dominant global motion g0 -> g1 as a homography (Chapter 5 / Chapter 10)."""
    k0, d0 = orb.detectAndCompute(g0, None)
    k1, d1 = orb.detectAndCompute(g1, None)
    m = sorted(bf.match(d0, d1), key=lambda x: x.distance)[:300]
    p0 = np.float32([k0[x.queryIdx].pt for x in m])
    p1 = np.float32([k1[x.trainIdx].pt for x in m])
    H, _ = cv2.findHomography(p0, p1, cv2.RANSAC, 3.0)   # robust to movers
    return H

cap = cv2.VideoCapture("panning_camera.mp4")
while True:
    ok, frame = cap.read()
    if not ok:
        break
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    if prev_gray is None:
        bg = gray.astype(np.float32)
    else:
        H = estimate_homography(prev_gray, gray)         # camera motion
        h, w = gray.shape
        bg = cv2.warpPerspective(bg, H, (w, h))          # align bg to new frame
        diff = cv2.absdiff(gray.astype(np.float32), bg)
        fg = (diff > 25).astype(np.uint8) * 255          # residual = movers
        bg = 0.98 * bg + 0.02 * gray                     # adapt in new frame
        cv2.imshow("independent motion", fg)
        if cv2.waitKey(1) == 27:
            break
    prev_gray = gray
Code Fragment 3: Motion-compensated background subtraction for a panning camera. The ORB-plus-RANSAC homography from Chapters 5 and 10 captures the camera's own motion; warping the background into the new frame before subtracting leaves only objects that move independently of the static world.

The catch in Code Fragment 3 is that a single homography is exact only when the scene is planar or the camera rotates about its optical center. For a freely translating camera viewing a 3D scene, parallax makes the global model leak, near objects move more than far ones, so background near depth discontinuities is misclassified as motion. Handling that properly requires the multi-view geometry of Chapter 13 and Chapter 14, or it is simply deferred to the learned motion-segmentation models that the research frontier below describes.

Research Frontier: Promptable and Learned Change Detection (2024-2026)

Classical background subtraction is being absorbed into video segmentation. The CDNet 2014 benchmark long set the bar; learned methods on it (FgSegNet and successors) now top it but need scene-specific training. The more disruptive shift is open-world video segmentation: Segment Anything 2 (SAM 2, Meta, 2024, arXiv:2408.00714) tracks any prompted object through a video with a memory bank, doing per-object change detection without any background model at all, and DEVA / Tracking-Anything pipelines chain it with detectors for label-free moving-object discovery. For unsupervised motion segmentation specifically, recent work treats the residual after camera-motion compensation as a learnable signal rather than a thresholded difference. The practical takeaway for 2026: for a genuinely static camera on cheap hardware, MOG2 remains unbeaten on cost; the moment the camera moves or you need semantic labels, a promptable segmenter such as SAM 2 is the modern replacement, and it connects directly to the deep video understanding of Chapter 26.

Exercise 15.4.1: The Learning-Rate Tradeoff Conceptual

For the running-average model $B_t = (1-\alpha)B_{t-1} + \alpha I_t$, explain in terms of "memory length" what happens at the two extremes $\alpha \to 0$ and $\alpha \to 1$. A person stands still in front of the camera for $N$ frames. Derive, approximately, how large $\alpha$ must be for them to be absorbed into the background within those $N$ frames, and explain why the selective (masked) update in Code Fragment 1 changes the answer. Which failure mode does each extreme produce: stale backgrounds, or ghosting?

Exercise 15.4.2: Shadow Rejection by Chromaticity Coding

Take any clip of a person walking on a sunny pavement. Run MOG2 twice, once with detectShadows=False and once with detectShadows=True (discarding the gray shadow label). Overlay both masks on the frame and measure the foreground blob area in each. Then implement your own shadow test: convert frame and background to HSV, label a candidate foreground pixel as shadow when its value (V) is between 0.4 and 0.9 of the background V while hue and saturation are within small tolerances. Compare your mask to MOG2's shadow output and report where they disagree.

Exercise 15.4.3: Diagnosing a Moving-Camera Failure Analysis

Run MOG2 unmodified on a clip from a slowly panning camera and observe the whole frame flagging as foreground. Now apply the motion-compensation scheme of Code Fragment 3. Identify the regions where compensated subtraction still produces false positives, and argue from the parallax discussion why they cluster near depth discontinuities (object edges, foreground-background boundaries). What property of the scene would make the single-homography compensation exact, and how does this relate to the planar and pure-rotation cases of Chapter 13?