Lucas Canade Optical Flow and Shi-Tomasi feature detection on Rust
High-performance Rust implementation of Lucas-Kanade optical flow and Shi-Tomasi feature detection, optimized for real-time applications and WebAssembly (Wasm) compatibility.
Features
- 🎯 Pyramidal Lucas-Kanade optical flow with per-point status and photometric error
- 🔁 Forward-backward consistency check to reject occlusions and outliers
- 🧭 Optional motion prediction (initial guess) for large inter-frame displacements
- 🔍 Shi-Tomasi feature detection, plus grid-based detection for uniform coverage
- ♻️ Zero-allocation steady-state path (
TrackerContext,DetectorContext) for real-time per-frame tracking, with up-front capacity reservation - 🌡️ f32 pipeline for images in physical units, e.g. thermal frames in °C, without 8-bit quantization
- ⚡ Fixed-point SIMD tracking kernels (SSE2/AVX2 on x86,
simd128in wasm) with results bit-identical across targets; SIMD gradients and pyramid: AVX2 (x86), NEON (aarch64),simd128(wasm) - 🌐 Built on the
imagecrate; WebAssembly-ready
Usage
Add to your Cargo.toml:
[]
= "0.6"
Basic example — detect corners in one frame and track them into the next:
use ;
use ;
let prev: GrayImage = open.unwrap.into_luma8;
let next: GrayImage = open.unwrap.into_luma8;
let prev_pyr = build_pyramid;
let next_pyr = build_pyramid;
// Keep corners scoring at least 1% of the strongest one, at least 5px apart.
let mut corners = good_features_to_track;
corners.truncate;
let points: = corners.iter.map.collect;
// 21px window, 30 iterations per level; `None` = no initial guess.
let params = TrackParams ;
let results = calc_optical_flow_ex;
for in points.iter.zip
Real-time tracking
For per-frame tracking (e.g. a VIO front-end or the web demo), reuse a
TrackerContext and pass it each new frame with advance. The pyramid of
the last frame becomes the previous one, so only the new frame's pyramid is
built and you keep no copy of the last frame. After the first frame it
performs no heap allocation, and track_fb adds the forward-backward
consistency check:
use ;
let mut ctx = new;
// Per frame (`frame` is a `&GrayImage`), with `params` as above:
ctx.advance;
let results = ctx.track_fb;
// Points flagged `TrackStatus::FbInconsistent` failed the round-trip.
The context computes each frame's Scharr gradients once and keeps them with
its pyramid, so the backward pass of one frame and the forward pass of the
next share them. TrackOptions tunes a call further: fb_levels runs the
backward pass over the finest levels only, which on a phone makes track_fb
with one level about 30% cheaper, and start_level skips coarse levels when
a prediction is already close:
use ;
const OPTIONS: TrackOptions = new.with_fb_levels;
let results = ctx.track_fb_with;
A single-level check is slightly more permissive: on the bundled example pair it accepts 14-16 of 354 points that the full check rejects, and rejects 0-2 that it accepts.
advance(frame, 4) gives the same results, bit for bit, as
prepare(&last_frame, frame, 4), which builds both pyramids from an explicit
frame pair. On the first call there is no last frame yet, so both pyramids
show frame and tracking measures no motion. FloatTrackerContext has the
same advance.
Detection has the same shape. A DetectorContext owns the gradient, tensor
and score planes plus the spacing grid, so detect / detect_grid allocate
nothing after the first frame:
use ;
let mut detector = new;
// Top up lost tracks every few frames: 6x4 grid, 3 per cell, quality 0.05,
// at least 12 px from each other and from the points already tracked.
let grid = Grid ;
let fresh = detector.detect_grid;
To keep even the first frame free of allocation, create the contexts with
with_capacity. A DetectorCapacity reserves for the largest possible
candidate count, so no image content can grow a buffer later:
use ;
let mut detector = with_capacity;
let mut tracker = with_capacity;
f32 images in physical units
For sensors whose values carry a unit, such as thermal cameras in °C,
FloatDetectorContext and FloatTrackerContext run the same algorithms on
f32 frames, without range normalization or 8-bit quantization. Scores and the
tracker's eigenvalue threshold are in (unit / pixel)^2, residuals in the unit
itself. A FloatImageView borrows a frame after checking that every value is
finite and within range:
use ;
// `prev_celsius` and `next_celsius` are 640x480 row-major `&[f32]` frames.
let prev = new?;
let next = new?;
// Corners scoring at least 0.002 (°C / px)^2, at least 8 px apart.
let mut detector = new;
let corners = detector.detect;
let points: = corners.iter.map.collect;
// The eigenvalue threshold is in (°C / px)^2 as well and has no default.
let params = TrackParams ;
let mut tracker = new;
tracker.prepare;
let results = tracker.track_fb;
On frames holding 8-bit values the f32 detector returns the 8-bit detector's corners bit for bit. On a 640x480 frame on an Apple M2, a whole frame step (pyramids, forward-backward tracking, grid top-up) costs about 1.1x the 8-bit pipeline on the same data, natively and under WASI; tracking alone is faster in f32. See the measurements. Against the published 0.3.1, the f32 frame step is 3.3-3.6x faster natively and 2.9-3.1x faster under WASI (comparison).
Upgrading from 0.4: detection returns
Corners, grid detection takes aGrid, and tracking takesTrackParams. The deprecatedcalc_optical_flowis gone; usecalc_optical_flow_exorTrackerContext. See the changelog.
Performance
Per-frame tracking
The step of a real-time front-end: TrackerContext::advance with the new
frame, then track_fb of the strongest corners, on synthetic textured frames
3 px apart, 4 levels, 30 iterations. Measured for 0.6.0 on 2026-10-20, in
alternation with 0.5.1.
On a phone, the deployment target: Xiaomi 11T (MediaTek Dimensity 1200,
arm64), Android 14, Chrome 155, the web demo's ?bench=1 mode at 360×640.
Medians per step over 60 samples of 5 steps, after the CPU clock ramped up.
| Points, window | 0.5.1 | 0.6.0 | 0.6.0, fb_levels 1 |
|---|---|---|---|
| 44, 13 px | 2.04 ms | 1.00 ms | 0.72 ms |
| 44, 21 px | 3.46 ms | 1.46 ms | 1.06 ms |
| 150, 13 px | 6.24 ms | 2.66 ms | 1.76 ms |
| 150, 21 px | 11.38 ms | 4.38 ms | 2.84 ms |
Each tracked point costs 2.5-2.7x less than in 0.5.1. On a desktop, AMD Ryzen
9 9900X, benches/tracking.rs (regime) at 360×640 with 150 points and a
21 px window:
| 0.5.1 | 0.6.0 | 0.6.0, fb_levels 1 |
|
|---|---|---|---|
| native (AVX2) | 2.82 ms | 0.81 ms | 0.53 ms |
wasm simd128, Node 24 WASI |
3.81 ms | 1.57 ms | 1.00 ms |
All cases, 640×480 included, are in the changelog.
Against OpenCV
Measured against OpenCV 4.11 on the bundled 720×405 frame pair; every row
re-measured for 0.6.0 on 2026-10-20. The harness, parameters and caveats are
in benches/opencv-compare/. Tracking is both
pyramids plus Lucas-Kanade for 200 points, 21 px window, no
forward-backward check.
On the phone above. Medians per call over 100 samples of 5 calls, steady-state runs; 0.5.1 measured 2.5 and 13.0 ms in the same session.
| Detect, ms | Track 200 points, ms | |
|---|---|---|
this crate, wasm simd128 |
2.5 | 5.0 |
| OpenCV.js 4.11 (wasm, scalar build) | 8.6 | 14.1 |
On the desktop above, wasm under Node 24. Medians of 100 calls.
| Detect, ms | Track 200 points, ms | |
|---|---|---|
this crate, wasm simd128 |
1.1 | 1.8 |
| OpenCV.js 4.11 (wasm, scalar build) | 2.5 | 5.8 |
| this crate, native (AVX2) | 0.7 | 0.9 |
| OpenCV native, 1 thread | 2.0 | 2.3 |
The desktop wasm detection figure was 0.8 ms at 0.4.1. The grouped non-maximum suppression of 0.5.0 is faster natively, on the phone and on the synthetic benchmark inputs, but slower on this frame under V8 on x86-64. Details and the per-input figures are in the comparison README.
Corners agree with OpenCV's to the pixel: every corner this crate finds has an OpenCV Scharr corner within 1.5 px, and 99% of OpenCV's have one of this crate's. Tracked endpoints agree to a median of 0.004 px on points both libraries track.
Since 0.6.0 the 8-bit tracker computes its windows in fixed point on x86 and
on wasm with simd128, bit for bit the same on every target; positions
differ from the f32 arithmetic of 0.5 by a median of 0.0003 px on the
bundled pair. TrackKernel::Reference selects the f32 arithmetic, see
TrackOptions.
Live demo
A browser demo runs the tracker entirely client-side in WebAssembly: point your phone's camera at a scene and tap to drop points (or hit Auto to detect Shi-Tomasi corners) and watch them ride the optical flow.
Source and build instructions are in web-demo/.
WebAssembly
The hot image kernels — the fixed-point tracking windows, the Scharr
gradients and the pyramid downsample — have hand-written simd128 paths that
are selected automatically on wasm32 — but only when the target is built
with SIMD enabled, since WASM has no runtime feature detection. The bundled
.cargo/config.toml sets this for you:
[]
= ["-C", "target-feature=+simd128"]
If you build from a different working directory (so that config is not picked up), pass it yourself:
RUSTFLAGS="-C target-feature=+simd128"
Without +simd128 the crate still works, falling back to scalar loops, and
the 8-bit tracker uses its f32 arithmetic (TrackKernel::FixedPoint selects
the scalar fixed-point kernel, with the results of the simd128 build). For
production WASM, also run wasm-opt -O3 on the output (wasm-pack does this
automatically). Tracking 200 points on the bundled pair, the simd128 build
is 3× faster than the scalar build in V8 (Node): 1.8 against 5.5 ms.