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
- ⚡ SIMD-accelerated gradients and pyramid: AVX2 (x86), NEON (aarch64),
simd128(wasm) - 🌐 Built on the
imagecrate; WebAssembly-ready
Usage
Add to your Cargo.toml:
[]
= "0.5"
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. 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 pair (`prev`, `next` are `&GrayImage`), with `params` as above:
ctx.prepare;
let results = ctx.track_fb;
// Points flagged `TrackStatus::FbInconsistent` failed the round-trip.
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
Measured against OpenCV 4.11 on the bundled 720×405 frame pair at 0.5.0 on
2026-10-20. The harness, parameters and caveats are in
benches/opencv-compare/.
On a phone, the deployment target: Xiaomi 11T (MediaTek Dimensity 1200, arm64), Android 14, Chrome 155. Medians per call over 100 samples of 5 calls, steady-state rounds after warm-up.
| Detect, ms | Track 200 points, ms | |
|---|---|---|
this crate, wasm simd128 |
2.9 | 14.7 |
| OpenCV.js 4.11 (wasm, scalar build) | 9.9 | 16.1 |
On a desktop, AMD Ryzen 9 9900X, wasm under Node 24. Medians of 100 calls.
| Detect, ms | Track 200 points, ms | |
|---|---|---|
this crate, wasm simd128 |
1.1 | 4.2 |
| OpenCV.js 4.11 (wasm, scalar build) | 2.5 | 5.8 |
| this crate, native (AVX2) | 0.7 | 3.4 |
| 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.
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 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. For
production WASM, also run wasm-opt -O3 on the output (wasm-pack does this
automatically). On a 640×480 per-frame track step, the simd128 build plus the
bounds-check-free bilinear sampler is roughly 2× faster than the scalar
build in V8 (Node).