Search…

Optical Flow and Motion Tracking in Video

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. 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.

Brightness constancy

The whole field rests on one assumption: a point keeps its brightness as it moves.

I(x,y,t)=I(x+Δx,  y+Δy,  t+Δt)I(x, y, t) = I(x + \Delta x,\; y + \Delta y,\; t + \Delta t)

Expand the right side as a first-order Taylor series and cancel:

Ixu+Iyv+It=0I_x u + I_y v + I_t = 0

# Symbol Meaning How it is measured
1 IxI_x Horizontal image gradient Sobel in x on the frame
2 IyI_y Vertical image gradient Sobel in y on the frame
3 ItI_t Change in brightness over time frame(t+1) − frame(t) at that pixel
4 uu Horizontal motion (unknown) what you are solving for
5 vv 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 (ItI_t negative) and brightness rises to the right (IxI_x 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 Ix=10I_x = 10, Iy=0I_y = 0, It=30I_t = -30:

10u+0v30=0    u=3,v=anything10u + 0v - 30 = 0 \;\Rightarrow\; u = 3, \quad v = \text{anything}

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.

Frame t: vertical edge (5×5)
200
200
20
20
20
200
200
20
20
20
200
200
20
20
20
200
200
20
20
20
200
200
20
20
20
motion?
Frame t+1: same view (5×5)
200
200
200
20
20
200
200
200
20
20
200
200
200
20
20
200
200
200
20
20
200
200
200
20
20

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 uu and vv — 25 equations for 2 unknowns, comfortably over-determined.

Solving by least squares gives the normal equations:

[Ix2IxIyIxIyIy2][uv]=[IxItIyIt]\begin{bmatrix} \sum I_x^2 & \sum I_x I_y \\ \sum I_x I_y & \sum I_y^2 \end{bmatrix} \begin{bmatrix} u \\ v \end{bmatrix} = -\begin{bmatrix} \sum I_x I_t \\ \sum I_y I_t \end{bmatrix}

Work one through. For a 5×5 window on a car’s number plate corner, the sums come out as Ix2=400\sum I_x^2 = 400, IxIy=120\sum I_x I_y = 120, Iy2=300\sum I_y^2 = 300, IxIt=900\sum I_x I_t = -900, IyIt=600\sum I_y I_t = -600:

[400120120300][uv]=[900600]\begin{bmatrix} 400 & 120 \\ 120 & 300 \end{bmatrix} \begin{bmatrix} u \\ v \end{bmatrix} = \begin{bmatrix} 900 \\ 600 \end{bmatrix}

The determinant is:

det=(400)(300)(120)(120)=120,00014,400=105,600\det = (400)(300) - (120)(120) = 120{,}000 - 14{,}400 = 105{,}600

Solve with Cramer’s rule:

u=(900)(300)(120)(600)105,600=270,00072,000105,600=198,000105,600=1.875u = \frac{(900)(300) - (120)(600)}{105{,}600} = \frac{270{,}000 - 72{,}000}{105{,}600} = \frac{198{,}000}{105{,}600} = 1.875

v=(400)(600)(120)(900)105,600=240,000108,000105,600=132,000105,600=1.25v = \frac{(400)(600) - (120)(900)}{105{,}600} = \frac{240{,}000 - 108{,}000}{105{,}600} = \frac{132{,}000}{105{,}600} = 1.25

Check by substituting back:

400(1.875)+120(1.25)=750+150=900  400(1.875) + 120(1.25) = 750 + 150 = 900 \;\checkmark 120(1.875)+300(1.25)=225+375=600  120(1.875) + 300(1.25) = 225 + 375 = 600 \;\checkmark

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:

λ=700±70024(105,600)2=700±490,000422,4002=700±2602\lambda = \frac{700 \pm \sqrt{700^2 - 4(105{,}600)}}{2} = \frac{700 \pm \sqrt{490{,}000 - 422{,}400}}{2} = \frac{700 \pm 260}{2}

λ1=480,λ2=220\lambda_1 = 480, \qquad \lambda_2 = 220

Both large. This is a corner, and its motion is well determined in every direction.

Now compare three patch types:

# Patch type Structure tensor λ1\lambda_1 λ2\lambda_2 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 λ2\lambda_2 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.

PropertyLucas-Kanade (sparse)Farnebäck (dense)RAFT (learned dense)
Output~300 tracked pointsflow for every pixelflow for every pixel
Speed, 720p~3 ms~60 ms~80 ms on GPU
Textureless regionsno points placed theresmoothed guesshandled well
Large motionneeds pyramidsneeds pyramidshandled natively
Needs training datanonoyes
Good fortracking objects, stabilisationmotion segmentation, video effectshighest accuracy
Sparse and dense optical flow compared
# 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.

speed=flow (px/frame)×fpspixels per metre\text{speed} = \frac{\text{flow (px/frame)} \times \text{fps}}{\text{pixels per metre}}

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:

speed=12×3020=36020=18 m/s=64.8 km/h\text{speed} = \frac{12 \times 30}{20} = \frac{360}{20} = 18 \text{ m/s} = 64.8 \text{ km/h}

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

Practice task

Record 30 seconds of traffic from a footbridge or a window overlooking a road.

  1. Run goodFeaturesToTrack on the first frame and draw the points. Where do they land, and where do they not?
  2. Track with calcOpticalFlowPyrLK at maxLevel=0. Count how many survive 30 frames.
  3. Repeat at maxLevel=3. Compare the survival counts.
  4. Add the forward-backward check with a threshold of 1.0 px. How many more get rejected, and do they look like real failures?
  5. Measure a known length on the road, such as a lane marking, and compute pixels per metre.
  6. Convert flow to km/h for ten vehicles. Compare with your own estimate by eye.
  7. 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 Ixu+Iyv+It=0I_x u + I_y v + I_t = 0: one equation, two unknowns. With Ix=10I_x = 10, Iy=0I_y = 0, It=30I_t = -30 you get u=3u = 3 and vv 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 u=1.875u = 1.875, v=1.25v = 1.25, 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.

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.

Test your understanding
You track corners on a car with Lucas-Kanade. When the car passes under a bridge shadow, every point fails at once, then recovers on the far side. What broke?
Test your understanding
Your junction system reports 65 km/h for vehicles near the camera and 22 km/h for vehicles far up the road, though traffic is clearly flowing at one steady speed. Why?

Frequently asked questions

What is the difference between optical flow and object tracking?
Optical flow estimates how pixels or points moved between two frames. Object tracking maintains the identity of a specific object over many frames, usually surviving occlusion and appearance change. Flow is often the engine inside a tracker, but on its own it has no notion of an object: it will happily follow a point as it slides from one car onto another. Adding a detector, a motion model, and an identity association step turns flow into tracking.
Should I use sparse or dense optical flow?
Sparse for tracking specific things, stabilisation, and anything where speed matters — a few hundred points cost around 3 ms at 720p. Dense when you need motion everywhere, such as segmenting moving regions, video effects, or frame interpolation, and you can afford roughly 60 ms per frame or a GPU. Most production systems use sparse, because the extra information dense flow provides is rarely worth twenty times the compute.
How do I know if a track has gone wrong?
Use the forward-backward check: track the point to the next frame, then track it back. A good point returns to within about a pixel of its start, while a point that jumped to a different object does not. Also watch the status flag from calcOpticalFlowPyrLK, the residual error it returns, and whether the implied speed is physically plausible. Any one of those catches most failures, and together they catch nearly all.
Why do my points slowly drift off the object?
Every frame introduces a small sub-pixel error, and tracking frame to frame accumulates them. After a hundred frames the drift can be several pixels. The standard fixes are to track back to a stored reference patch rather than only to the previous frame, to re-detect features every 20 to 30 frames, and to drop points whose forward-backward error is rising even while they still report success.
Can optical flow work with a moving camera?
Yes, but the flow then mixes camera motion with object motion. The usual approach is to fit a global transform to the dominant flow with RANSAC, which captures the camera's own movement because static background points outnumber moving ones, and then treat the residual flow as genuine object motion. That is exactly how video stabilisation works, and it is why stabilisation naturally ignores the moving subject.
Start typing to search across all content
navigate Enter open Esc close