Optical Flow and Motion Tracking in Video
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- Responsible CV: Privacy, Fairness, Security, Governance
Stereo matched two cameras at one instant. Optical flow matches one camera across two instants. It is the same problem with the axis rotated, and it gives you motion: which way things moved, how fast, and whether anything moved at all.
Prerequisites: Stereo vision for the matching idea, and image gradients.
The problem: measuring traffic at a junction
A council wants vehicle counts and average speeds at a junction. A camera is already mounted on the pole. Detecting cars is easy enough, but a detector run frame by frame gives no identity — is the car in frame 91 the same car as in frame 90, or a new one?
Motion answers it. If you know how each part of the image moved between frames, you can follow a car across the whole scene, count it once, and measure how fast it travelled.
graph LR A["Frame t"] --> C["Estimate motion<br/>of each tracked point"] B["Frame t+1"] --> C C --> D["Link points into tracks"] D --> E["Count line crossings"] D --> F["Pixels/frame → m/s<br/>using fps and ground scale"]
Brightness constancy
The whole field rests on one assumption: a point keeps its brightness as it moves.
Expand the right side as a first-order Taylor series and cancel:
| # | Symbol | Meaning | How it is measured |
|---|---|---|---|
| 1 | Horizontal image gradient | Sobel in x on the frame | |
| 2 | Vertical image gradient | Sobel in y on the frame | |
| 3 | Change in brightness over time | frame(t+1) − frame(t) at that pixel | |
| 4 | Horizontal motion (unknown) | what you are solving for | |
| 5 | Vertical motion (unknown) | what you are solving for |
The optical flow equation: three measurable quantities, two unknowns
The equation is intuitive if you read it aloud. If a pixel got darker over time ( negative) and brightness rises to the right ( positive), then whatever was here must have moved right, bringing the darker stuff in.
The aperture problem
One equation, two unknowns. That is not a solvable system, and it is not a technicality — it is a real, visible limitation.
Suppose at some pixel , , :
The horizontal motion is pinned at 3 pixels. The vertical motion is completely undetermined. Moving 3 right, or 3 right and 50 down, or 3 right and 50 up all produce exactly the same brightness change.
Look at those two frames. The edge moved one pixel right — but did the object also move up or down? Through this small window you cannot tell, because the edge looks identical everywhere along its length.
aperture problem Through a small window, only the motion component perpendicular to an edge is observable. Motion along the edge produces no change and is therefore invisible.This is exactly the corner-versus-edge distinction from feature matching, arriving again from a different direction. Only corners give unambiguous motion.
Lucas-Kanade: use a window
Kanade and Lucas made one assumption that fixes everything: all pixels in a small window move together. A 5×5 window gives 25 copies of the flow equation sharing the same and — 25 equations for 2 unknowns, comfortably over-determined.
Solving by least squares gives the normal equations:
Work one through. For a 5×5 window on a car’s number plate corner, the sums come out as , , , , :
The determinant is:
Solve with Cramer’s rule:
Check by substituting back:
This point moved 1.875 pixels right and 1.25 pixels down between frames.
Knowing in advance whether a point is trackable
That 2×2 matrix on the left is the structure tensor The 2x2 matrix of summed squared image gradients over a window. Its two eigenvalues describe how much the patch varies in each of two perpendicular directions. , and its eigenvalues tell you whether the solve is trustworthy — before you rely on the answer.
For our matrix, trace = 700 and det = 105,600:
Both large. This is a corner, and its motion is well determined in every direction.
Now compare three patch types:
| # | Patch type | Structure tensor | Flow result target | ||
|---|---|---|---|---|---|
| 1 | Corner | [[400,120],[120,300]] | 480 | 220 | reliable in both directions |
| 2 | Edge | [[500,0],[0,3]] | 500 | 3 | only across the edge — aperture problem |
| 3 | Flat | [[12,4],[4,9]] | 14.4 | 6.6 | unreliable, noise dominates |
Structure tensor eigenvalues predict trackability. Both large means track it; anything else means do not.
This is precisely what cv2.goodFeaturesToTrack does: it computes the structure tensor everywhere and keeps the points where the smaller eigenvalue is largest. That is the Shi-Tomasi criterion.
import cv2, numpy as np
prev_gray = cv2.cvtColor(prev, cv2.COLOR_BGR2GRAY)
p0 = cv2.goodFeaturesToTrack(prev_gray,
maxCorners=300,
qualityLevel=0.01, # relative to the best λ2 found
minDistance=10, # spread points out
blockSize=7)
Pyramids: handling fast motion
Lucas-Kanade uses a first-order Taylor expansion, which is only accurate for small displacements. A raw 5×5 window handles about 1–2 pixels of motion. A car at 50 km/h across a junction camera moves 30 pixels per frame.
The fix is to shrink the image. At half resolution, a 32-pixel motion becomes 16 pixels. Halve again: 8. Again: 4 — now within Lucas-Kanade’s range.
| # | Pyramid level | Image scale | Motion in that image | Solvable by LK? target |
|---|---|---|---|---|
| 1 | 3 (coarsest) | 1/8 | 4 px | yes |
| 2 | 2 | 1/4 | 8 px | using level 3's estimate |
| 3 | 1 | 1/2 | 16 px | using level 2's estimate |
| 4 | 0 (full) | 1/1 | 32 px | using level 1's estimate |
A 32-pixel motion resolved through a 4-level pyramid. Each level refines the level above.
Solve at the coarsest level, scale the answer up by 2, use it as the starting guess at the next level, and refine. Each level only has to correct a small residual, which is exactly what Lucas-Kanade is good at.
lk_params = dict(
winSize=(21, 21),
maxLevel=3, # 4 levels total: 0, 1, 2, 3
criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01),
)
p1, status, err = cv2.calcOpticalFlowPyrLK(prev_gray, gray, p0, None, **lk_params)
good_new = p1[status == 1]
good_old = p0[status == 1]
| # | maxLevel | Levels | Roughly max motion with a 21×21 window target |
|---|---|---|---|
| 1 | 0 | 1 | ~4 px |
| 2 | 1 | 2 | ~8 px |
| 3 | 2 | 3 | ~16 px |
| 4 | 3 | 4 | ~32 px |
| 5 | 4 | 5 | ~64 px |
Pyramid levels roughly double the trackable motion each time
More levels are not free. At the coarsest level small objects have been blurred away entirely, so a fast-moving pedestrian’s leg may not exist there and the tracker follows the torso instead. Set maxLevel from your actual maximum expected motion rather than maximising it.
Sparse versus dense flow
Lucas-Kanade tracks specific points. Dense methods compute flow for every pixel.
| Property | Lucas-Kanade (sparse) | Farnebäck (dense) | RAFT (learned dense) |
|---|---|---|---|
| Output | ~300 tracked points | flow for every pixel | flow for every pixel |
| Speed, 720p | ~3 ms | ~60 ms | ~80 ms on GPU |
| Textureless regions | no points placed there | smoothed guess | handled well |
| Large motion | needs pyramids | needs pyramids | handled natively |
| Needs training data | no | no | yes |
| Good for | tracking objects, stabilisation | motion segmentation, video effects | highest accuracy |
# Dense flow: an (H, W, 2) array of (dx, dy) per pixel
flow = cv2.calcOpticalFlowFarneback(
prev_gray, gray, None,
pyr_scale=0.5, levels=3, winsize=15,
iterations=3, poly_n=5, poly_sigma=1.2, flags=0)
mag, ang = cv2.cartToPolar(flow[..., 0], flow[..., 1])
# Standard visualisation: hue = direction, value = speed
hsv = np.zeros_like(prev)
hsv[..., 0] = ang * 180 / np.pi / 2
hsv[..., 1] = 255
hsv[..., 2] = cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX)
vis = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
For the junction, sparse is the right choice. You care about a few hundred vehicle corners, not every blade of grass on the verge, and 3 ms per frame leaves headroom for detection on the same CPU.
From pixels per frame to km/h
Flow gives pixels per frame. Two more numbers convert it to real speed.
A car’s tracked corners average 12 px/frame. The camera runs at 30 fps. Painted lane markings, known to be 3 m long, span 60 pixels on the road surface, so the scale is 20 px/m:
| # | Flow (px/frame) | Speed (m/s) | Speed (km/h) target | Note |
|---|---|---|---|---|
| 1 | 3 | 4.5 | 16.2 | slowing for the lights |
| 2 | 7 | 10.5 | 37.8 | urban cruise |
| 3 | 12 | 18.0 | 64.8 | over a 50 km/h limit |
| 4 | 18 | 27.0 | 97.2 | clearly speeding |
| 5 | 30 | 45.0 | 162.0 | check for a tracking failure |
Flow converted at 30 fps and 20 px/m. The last row is the value of a sanity check.
That last row matters in practice. A tracking failure — a point jumping from one car to another — produces a large fake flow that looks exactly like a speeding vehicle. Bound your outputs by what is physically plausible and discard the rest.
The scale factor is the weak link. 20 px/m holds only at the ground plane and only in the part of the image where you measured it. A car further up the road has fewer pixels per metre because of perspective, so the same real speed reads as a lower flow. Either restrict measurement to a small calibrated patch of road, or rectify the road surface to a top-down view with a homography first — the technique from camera calibration.
Building a tracker
Points get lost. Objects go behind poles, leave the frame, or drift off their feature. A tracker needs upkeep.
def update_tracks(prev_gray, gray, p0, min_points=80):
p1, status, err = cv2.calcOpticalFlowPyrLK(prev_gray, gray, p0, None, **lk_params)
# forward-backward check: track back and see if we return to the start
p0r, _, _ = cv2.calcOpticalFlowPyrLK(gray, prev_gray, p1, None, **lk_params)
fb_error = abs(p0 - p0r).reshape(-1, 2).max(axis=1)
keep = (status.ravel() == 1) & (fb_error < 1.0)
p1 = p1[keep]
# top up when too many are lost
if len(p1) < min_points:
extra = cv2.goodFeaturesToTrack(gray, maxCorners=300 - len(p1),
qualityLevel=0.01, minDistance=10)
if extra is not None:
p1 = np.vstack([p1, extra])
return p1
The forward-backward check is the single most valuable line here. Track a point forward to the next frame, then track it back. A good point returns to within a pixel of where it started. A point that latched onto the wrong thing does not, and this catches it with no extra information needed.
| # | Symptom | Cause | Fix |
|---|---|---|---|
| 1 | Points drift off the object over time | Small errors accumulate each frame | Forward-backward check; re-detect periodically |
| 2 | All tracking fails when a cloud passes | Brightness constancy broken globally | Histogram equalisation, or normalised cross-correlation |
| 3 | Points jump between adjacent cars | Similar-looking patches nearby | Forward-backward check; tighter minDistance |
| 4 | Motion detected on stationary trees | Wind, and camera shake | Stabilise first; apply a flow magnitude floor |
| 5 | Fast objects get no track at all | Motion exceeds the pyramid range | Raise maxLevel or raise the frame rate |
| 6 | Flow is wrong on shiny car roofs | Specular highlight moves independently | Track lower on the body; use robust averaging |
Tracking failures by symptom
Row 2 deserves attention. Brightness constancy is an assumption, and outdoor video breaks it constantly — clouds, headlights, auto-exposure adjusting between frames. When every track fails at once, suspect illumination rather than the tracker.
- The camera runs fast enough that motion between frames is small
- Lighting is reasonably steady between consecutive frames
- The scene has corners to track
- You need motion, speed, or stabilisation rather than object identity
- You need something fast and CPU-only
- Frames are far apart in time and objects move a long way
- Lighting changes sharply between frames
- You need to re-identify an object after a long occlusion
- You need object classes as well as motion — pair it with a detector
Practice task
Record 30 seconds of traffic from a footbridge or a window overlooking a road.
- Run
goodFeaturesToTrackon the first frame and draw the points. Where do they land, and where do they not? - Track with
calcOpticalFlowPyrLKatmaxLevel=0. Count how many survive 30 frames. - Repeat at
maxLevel=3. Compare the survival counts. - Add the forward-backward check with a threshold of 1.0 px. How many more get rejected, and do they look like real failures?
- Measure a known length on the road, such as a lane marking, and compute pixels per metre.
- Convert flow to km/h for ten vehicles. Compare with your own estimate by eye.
- Now measure a vehicle near the camera and one far up the road that are clearly travelling at the same speed. Compare the numbers.
Step 7 is the point of the exercise. The far vehicle will read substantially slower, and the size of that gap is your perspective error. Once you have seen it, the case for rectifying the road plane first stops being theoretical.
Summary
Optical flow rests on brightness constancy, which linearises to : one equation, two unknowns. With , , you get and entirely undetermined — the aperture problem, and the reason only corners give reliable motion.
Lucas-Kanade assumes a window moves as one, producing 25 equations from a 5×5 patch. You solved a real system and got , , then checked both rows. The structure tensor’s eigenvalues, 480 and 220 in that case, tell you in advance whether the answer is trustworthy: both large is a corner, one large is an edge, both small is flat.
Pyramids extend the range from about 2 pixels to 32 by solving coarse and refining fine. Converting to real units needs frame rate and ground scale: 12 px/frame at 30 fps and 20 px/m is 18 m/s, or 64.8 km/h — but only if the scale holds where you measured, which perspective makes false across a full frame.
- Brightness constancy gives Ix·u + Iy·v + It = 0 — one equation, two unknowns.
- The aperture problem is real: through a small window, motion along an edge is invisible.
- Lucas-Kanade assumes a whole window shares one motion, giving 25 equations from a 5×5 patch.
- The structure tensor's eigenvalues predict trackability before you track: both large means a corner.
- goodFeaturesToTrack picks points by maximising the smaller eigenvalue — never use a fixed grid.
- Pyramids roughly double trackable motion per level: 4 levels handles about 32 px per frame.
- The forward-backward check catches bad tracks with no extra information and costs one extra call.
- When every track fails at once, suspect a lighting change rather than the tracker.
- speed = flow × fps / pixels-per-metre. Always bound results by what is physically plausible.
- One pixels-per-metre value cannot hold across a frame — rectify the ground plane first.
What comes next
Two directions from here.
If you want to keep working with motion classically, the flow field you just computed is the raw material for motion descriptors. Given (u, v) at each pixel, the magnitude is sqrt(u² + v²) and the direction is atan2(v, u), and histogramming those over a grid of cells gives HOF — a description of what is moving and where, which a classifier can consume directly. Taking spatial gradients of the flow field instead gives MBH, which cancels camera motion entirely. Both are covered in HOG, HOF and MBH descriptors.
If you want the modern treatment, video understanding covers learned flow networks, frame interpolation, stabilisation, temporal denoising, and how to run detection on video without paying full inference cost on every frame.
That completes the classical half of the series. You can now find edges, measure shapes, match points between images, calibrate a camera, recover depth, and measure motion — all without a single trained model, and with every step explainable.
The next track changes approach. Your first image classifier with PyTorch and transfer learning starts from a different premise: instead of designing the features yourself, you let the model learn them from labelled examples. The classical methods do not become obsolete. They remain the fastest, cheapest, and most explainable option whenever the problem is geometric, and they are often the right preprocessing step before a network ever sees the image.