System designHow-to

Phase correlation video stabilization: rotation rejection

Build a synthetic sequence with known shifts and rotation, score phase correlation video stabilization by RMS error, and set the drift trip that rejects roll.

LumaSpec Vision11 min read Share on LinkedIn

On this page 10 sections
  1. 01Why rotation moves the correlation peak
  2. 02What you need
  3. 03Build the synthetic sequence
  4. 04Score every frame against the ground truth
  5. 05Worked example: 100 frames at ±5 px shift and ±1.5° rotation
  6. 06The trip that rejects rotation drift
  7. 07Where the test lies: windowing, texture, rounding
  8. 08Take it to real footage, and where it runs
  9. 09Pre-flight checklist
  10. 10Questions

A phase-correlation stabilizer returns dx and dy for every frame, and live footage gives you no ground truth to hold it against. Camera roll quietly drags the correlation peak, the correction drifts one-sided, and the picture starts to swim. The fix is a synthetic test loop: crop one large master image into frames with known shifts and small rotations, run your estimator on every pair, and accumulate the errors into numbers you can act on. This walkthrough builds that harness step by step, works a 100-frame example end to end, and ends with the numeric trip that rejects rotation-induced peak drift. It is written for engineers running their own correlation code on a vision controller or a bench machine.

Why rotation moves the correlation peak

Phase correlation reads translation straight out of the Fourier shift theorem: a shift by (dx, dy) adds a linear phase ramp to the spectrum, so the normalized cross-power spectrum of two frames transforms back into a single sharp peak at (dx, dy). The phase correlation article on Wikipedia covers the derivation. Rotation does not fit that model. A roll of θ about the frame center keeps the center fixed and moves every feature at radius r by roughly r·θ, with θ in radians.

d = r * theta_rad

In a 1024 × 1024 frame the corners sit at r = 512 × sqrt(2) ≈ 724 px from the center. A 1° roll (0.01745 rad) displaces them by 724 × 0.01745 ≈ 12.6 px, and the ±1.5° bound used below displaces them by up to 18.9 px, while the true translation stays within ±5 px. The estimator sees corner motion several times larger than the shift, the peak smears, and its location biases in one direction as roll accumulates. The harness measures how much of that leaks into the translation estimate; the tracker catches the drift before it reaches the output.

What you need

  • One master image, textured and non-periodic. Chessboards, gratings and repeating conveyor texture return several comparable peaks, an ambiguity the phase-correlation literature calls out directly; natural scene texture keeps a single dominant peak.
  • Your estimator: the self-written correlation function under test, returning dx, dy and a peak response per pair.
  • A reference implementation. OpenCV's phaseCorrelate returns a sub-pixel shift and a response value, so it doubles as a sanity baseline for your own function.
  • A ground-truth table, one row per frame: dx and dy in pixels, θ in degrees, written before any estimating happens.
  • A machine for the offline harness, with enough memory to hold the master image and the frame sequence; the deployed loop that later runs in the cell is what a fanless industrial mini PC is sized for, not the test itself.

Build the synthetic sequence

Sizes first. Work with 1024 × 1024 px frames cut from a 2048 × 2048 px master, and check the margin before writing code: a 1024 px crop rotated by 1.5° spans 1024 × (cos 1.5° + sin 1.5°) ≈ 1050 px, and ±5 px of crop offset adds another 10 px, so the master needs to be at least a 1060 px square. The 2048 px master leaves about 490 px of slack on each side.

Then the loop:

  1. Draw the ground truth for frame i: dx and dy uniform in ±5 px, θ uniform in ±1.5°. These bounds encode the working assumption for this class of stabilizer, shifts of a few pixels and rotation near or under a degree, and they go into the truth table before anything is estimated.
  2. Warp the crop out of the master with a 2 × 3 affine matrix: rotation θ about the frame center plus translation (dx, dy), bilinear interpolation. The interpolation is what makes the shift sub-pixel; nearest-neighbour sampling quantizes it back to whole pixels and ruins the test. The matrix arithmetic is laid out step by step in the guide on arbitrary angle image rotation in machine vision.
  3. Convert each warped frame to single-channel float32 and apply the same Hann window to both members of every pair.
  4. Run your estimator frame by frame and log what it returns: dx, dy, the peak response, and θ if your implementation estimates one.
  5. Repeat the whole sequence with every θ forced to 0. That control pass isolates how much rotation alone inflates the shift error.

Score every frame against the ground truth

Three numbers per axis tell the whole story. The error is e = estimate − truth in pixels. The RMS punishes large per-frame mistakes, the mean exposes bias that integrates into visible drift, and the response tracks how much you should trust each estimate.

rms_x = sqrt( sum(e_x^2) / N ) bias_x = sum(e_x) / N

Decision lines for this frame size and shift range: an RMS at or below 0.1 px passes, and a mean within ±0.01 px per frame passes. The 0.1 px line has a documented basis: windowed phase correlation on clean synthetic pairs is a sub-pixel method, with published results clustering around 0.01–0.1 px RMS, so a number above it points at windowing, rounding or rotation bias rather than at the method itself. If the rotated pass more than doubles the control pass, rotation is the dominant error source, and you need the trip in the next section or a rotation-estimating stage. What a small bias does over a full clip is what the worked example below puts a number on.

Worked example: 100 frames at ±5 px shift and ±1.5° rotation

Inputs, with their sources:

  • N = 100 frames from one random seed; the FAQ gives the precision behind that choice.
  • True shifts uniform in ±5 px and true rotation uniform in ±1.5°, the bounds set in the build step.
  • Frames 1024 × 1024 px, float32, Hann window on both members of each pair.
  • Illustrative measured sums from the run: Σ e_x² = 0.28 px², Σ e_y² = 0.35 px², Σ e_x = +1.6 px; control pass at θ = 0: Σ e_x² = 0.12 px².

The rule, in words: RMS is the square root of the accumulated squared error divided by the frame count, and bias is the plain average error.

The arithmetic:

  • rms_x = sqrt(0.28 / 100) = sqrt(0.0028) = 0.053 px
  • rms_y = sqrt(0.35 / 100) = sqrt(0.0035) = 0.059 px
  • bias_x = +1.6 / 100 = +0.016 px per frame
  • control rms_x = sqrt(0.12 / 100) = sqrt(0.0012) = 0.035 px

Result: translation RMS lands at 0.053–0.059 px, inside the 0.1 px pass line, and rotation lifts the shift RMS from 0.035 px to 0.053 px, a factor of about 1.5. Rotation is present but not dominant. The failure is the bias: +0.016 px per frame integrates to 16 px of drift over a 1000-frame clip (0.016 × 1000), well past the ±0.01 px per frame line.

Decision: the translation estimator passes on per-frame accuracy and fails on bias, so leave the correlation math alone and fix the drift. Enable the re-anchor trip below, which fires long before 16 px can accumulate, then re-run with two more seeds before signing off. These pass lines are set for 1024 px frames with ±5 px shifts; recompute them if your geometry differs.

The trip that rejects rotation drift

The peak's position tells you whether roll is steering it. Three rules turn that observation into code:

  1. Jump guard: if the peak moves more than 10 px between consecutive frames, double the ±5 px bound, treat it as a lost or relocated peak. Re-anchor the reference to the current frame, zero the accumulated correction, and skip this frame's estimate. That is the accept-the-move-once, do-not-chase-it behavior.
  2. Drift trip: keep the last 10 peak increments. When at least 8 share one sign and their sum exceeds 1 px, the peak is creeping rather than jittering; re-anchor and reset the accumulator. Translation jitter changes sign frame to frame, roll does not.
  3. Trip margin from geometry: under a steady 0.1° of roll per frame, the corners of a 1024 px frame move 724 × 0.001745 ≈ 1.3 px per frame in the same direction, so the accumulator sees eight same-sign increments totalling about 10 px and fires after roughly eight frames, long before peak smearing at the 1.5° bound corrupts the translation estimate.

Be clear about what rejection does and does not do. It stops the stabilizer from following roll, and a false trip costs one dropped estimate because re-anchoring only redefines where zero is. It does not level a rotating scene. If the roll is real content that the viewer must see level, estimate it instead of rejecting it: resampling the log-magnitude spectra into log-polar coordinates turns rotation and scale into shifts, the Fourier–Mellin extension of phase correlation, and the control pass from your harness tells you whether that stage is needed.

Where the test lies: windowing, texture, rounding

Four more ways a harness lies to you:

  • No window, or two different windows. Without a Hann window the DFT treats frame edges as wrapping around, and that wraparound smears the peak. Apply the same window to both frames, once.
  • Periodic master texture. Chessboards, gratings and repeated patterns yield several comparable peaks and an ambiguous answer. Use natural texture, and treat a periodic real scene as outside what this harness can certify.
  • Testing at the wrong resolution or dtype. An error measured on half-size 8-bit previews does not transfer to a full-resolution float32 deployment path; validate exactly what ships.
  • Shifts approaching the window edge. A 5 px shift on a 1024 px frame is about 0.5% of the width, deep inside the unambiguous range, while circular-shift ambiguity begins as a shift approaches half the window. If real footage moves hundreds of pixels per frame, enlarge the frames or re-anchor more often.

Take it to real footage, and where it runs

Re-run the scorer on the stabilized output of a real clip: estimate motion between consecutive stabilized frames. A healthy run hovers around zero with no one-sided trend, which is the same frame-shift tracking the guide on detecting camera vibration with OpenCV frame tracking uses to catch vibration. Optical flow then refines what correlation starts; the walkthrough on video stabilization with phase correlation and optical flow covers that complete loop.

Budget before you deploy. A 1024 × 1024 real FFT costs on the order of 2 × 10⁷ operations, three transforms per estimate, two forward and one inverse, so a 30 fps stream lands near 2 GFLOPS. That is light for a desktop-class CPU, but it multiplies with stream count, and the controller also runs acquisition, lighting and your application. The correlation stage itself runs on a windowed ROI of each frame, so ROI size, not sensor size, sets the FFT cost.

The VC5000-class controller below carries 32 GB of memory, 5+ LAN ports and PoE camera ports, which suits a multi-camera line where every stream adds its own FFT budget:

Vision controllersHikrobot MV-VC5525-256G70-NN VC5000 Vision Controller, 12th-I5, 32GB+256G SSD, Supported PCIEMV-VC5525-256G70-NN
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
PoE
PoE camera ports
Light outputs
8 built-in channels
$4,479.00 · quotedView and quoteBuild a system with it

The VC2000-class unit below covers bench validation and single-stream cells:

Vision controllersHikrobot MV-VC2140-128G40-1T VC2100 Vision Controller, N97, 8GB+128G SSD+ 1THDDMV-VC2140-128G40-1T
LAN ports
3-4 LAN ports
USB 3 ports
2 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$1,679.00View partBuild a system with it

Live capture in the cell comes from the area scan cameras range. A global shutter sensor keeps frame geometry intact during pans, and the 12 MP USB3 model below fits this pipeline:

Area scan camerasHikrobot MV-CH120-11UC 12 MP Area Scan Camera, USB3, IMX304, High-speed version, ColorMV-CH120-11UC
Sensor format
1.1"
Lens mount
C-mount
Interface
USB3
Resolution
12 MP
$2,339.00 · quotedView and quoteBuild a system with it

Pre-flight checklist

  • Master margin: covers the rotated crop, about a 1060 px square for 1024 px frames at ±1.5° with ±5 px offsets.
  • Texture: natural and non-periodic, one dominant structure per window.
  • Pipeline: warped frames in float32, the same Hann window applied once to each pair member.
  • Truth table: written before estimating; estimates diff against it, never against each other.
  • Metrics: RMS and mean per axis, plus a θ = 0 control pass.
  • Pass lines for this geometry: RMS at or below 0.1 px, mean within ±0.01 px per frame.
  • Trip settings: 10 px jump guard, 1 px same-sign drift across the last 10 increments.
  • Real footage: residual-motion scorer on the stabilized output, watching for one-sided trends.

To size the machine that runs the deployed loop, work through the vision controllers range or start the system configurator; related systems walk-throughs sit under the systems topic page.

Questions

How many synthetic frames do I need for a trustworthy RMS?

Treat 100 frames as the floor. The relative uncertainty of an RMS estimate over N samples is about 1/sqrt(2N), so 100 frames gives roughly 7% and 400 frames roughly 3.5%. Run two or three different random seeds as well: if the RMS moves by more than about 20% between runs, the sequence is too short or the master texture has degenerate structure.

Can phase correlation measure the rotation angle itself?

Not in the standard translation form. In the log-polar (Fourier–Mellin) extension, rotation and scale turn into shifts along the angle and log-radius axes, so the usual pipeline estimates and removes rotation first, then phase-correlates for translation. Let the harness decide whether you need that stage: a rotated pass whose RMS matches the θ = 0 control says rejection is enough.

Why is my correlation peak off-center when nothing moved?

Usually the frames were not windowed identically, or borders differ through vignetting or exposure drift, so DFT wraparound adds a phase ramp. Apply the same Hann window to both frames, mask a border of a few tens of pixels, and prove the path with a zero-shift control: two identical crops must return (0, 0) before any other result means anything.

What peak response value means the estimate failed?

Track the response your function returns per frame against a running median. A healthy estimate sits near the median; a fall to roughly half of it means the pair de-correlated through occlusion, blur or periodic ambiguity and the shift is noise. Skip that frame or re-anchor instead of applying it, and tune the factor of two on your own synthetic runs where the truth is known.

Does the harness still work with a rolling shutter camera?

The synthetic harness does, because it tests your estimator rather than the sensor. On real footage a rolling shutter reads the frame line by line, so a panning camera bends the geometry and a single (dx, dy) cannot represent the motion; shear remains after stabilization. Expose short, keep pan speeds modest, or specify a global shutter sensor for the deployed stream.