Robust Estimation with RANSAC#
cuNLS ships two RANSAC minimizers, RansacGaussNewtonMinimizer and
RansacLevenbergMarquardtMinimizer, with the common base RansacMinimizer
(Python pycunls; C++ header cunls/minimizer/ransac_minimizer.h). They solve the
same Problem as the regular minimizers, but they assume that some
measurements are gross outliers: wrong data association, not just noise.
They find the estimate that most measurements agree with, refine it on those
measurements, and report which measurements were inliers.
This page explains when to use them, the theory behind them, exactly what the
implementation does, how to call it from Python and C++, and how to tune it.
How to write custom factors and states that work with RANSAC is covered in
Custom Factors and States (Python and C++). Complete runnable programs are in
python/examples/ransac_pnp.py (Python) and examples/ransac_pnp (C++);
python/examples/tartan_vio.py combines RANSAC with an IMU factor and priors
(always-on batches) in a visual-inertial odometry on real data.
When to use RANSAC#
A least-squares solver weighs every residual by its square. A single measurement that is wrong by 100 noise standard deviations contributes as much as 10,000 good ones, so a few outliers move the solution arbitrarily far. There are two families of remedies:
- Robust losses (Huber, Cauchy, Tukey, …; see Robustifier API)
down-weight large residuals inside the ordinary solver. They are cheap and work well when the outlier ratio is moderate and the initial guess is already close: the loss decides what “large” means from the current estimate, so a poor start can make the outliers look like the inliers.
- RANSAC (RANdom SAmple Consensus) does not trust the initial estimate
to tell inliers from outliers. It generates many candidate solutions (“hypotheses”) from tiny random subsets of the measurements, keeps the one that most measurements agree with, and only then refines it. It tolerates very high outlier ratios (80–90% in the PnP benchmarks) and returns an explicit inlier/outlier classification.
Use the RANSAC minimizers when:
a significant fraction of the measurements can be completely wrong (feature mismatches, wrong loop closures, spurious returns);
you need the inlier set, not just the estimate;
the free state is small: the sum of the tangent dimensions of all non-constant states must be at most
kMaxRansacTangentDim = 64(a camera pose is 6, a pose plus a focal length is 7, a rig of 10 poses is 60). Constant states (e.g. known 3D points) do not count, however many there are, and the number of factors is unlimited.
Typical problems: PnP (pose from 3D-2D matches), point-cloud registration (pose from 3D-3D matches), relative pose / extrinsic calibration, fitting a low-dimensional model (line, plane, homography-like) to data, a multi-camera rig pose with known extrinsics.
Theory#
The problem#
The regular minimizers solve
over the free state \(x\) (dimension \(D\)) for residual blocks \(r_i\) of dimension \(m_i\). RANSAC assumes the residual blocks are of two kinds:
inliers: \(r_i(x^\star)\) at the true state \(x^\star\) is small, of the size of the measurement noise;
outliers: \(r_i(x^\star)\) is arbitrary.
Which is which is unknown. The goal is the state that explains as many measurements as possible within the noise level, and that partition.
In cuNLS every residual batch has a role:
RansacRole.sampled(C++RansacRole::kSampled)Data factors that may be outliers. RANSAC samples from them and classifies every one of them. Each sampled batch has an inlier threshold \(\tau\): factor \(i\) is an inlier of state \(x\) iff \(\|r_i(x)\| \le \tau\).
RansacRole.always_on(C++RansacRole::kAlwaysOn)Trusted factors: priors, motion models, known extrinsic constraints. They are part of every solve and never classified.
Hypotheses from minimal samples#
A minimal sample is a set of \(s\) sampled factors that, together with the always-on factors, determines the state. Each factor contributes \(m\) equations, so \(s = \lceil D / m_{\min} \rceil\) factors are enough in general (\(m_{\min}\) is the smallest residual dimension among the sampled batches). For PnP, \(D = 6\) and \(m = 2\), so \(s = 3\) correspondences.
If all \(s\) factors of a sample are inliers, the state fitted to them is close to \(x^\star\), and most other inliers will agree with it. If any is an outlier, the fitted state is essentially random and few factors agree with it. RANSAC therefore draws many samples and keeps the hypothesis with the most support.
Classic RANSAC fits each sample with a problem-specific closed-form “minimal solver” (e.g. P3P for PnP). cuNLS instead fits each sample with a few Gauss-Newton or Levenberg-Marquardt iterations from the initial guess, using the problem’s own factors. This needs no problem-specific code, so any factor (built-in or custom) works, at the price of needing an initial guess inside the convergence basin of the sample problem. In the PnP benchmarks this basin is wide: rotations off by 0.3 rad and translations off by 10% of the depth converge reliably.
Scoring#
Every hypothesis \(x_k\) is scored against all sampled factors. The default rule is MSAC (M-estimator SAmple Consensus), a truncated quadratic:
Lower is better. Inliers contribute their (small) squared residual, outliers
a constant \(\tau^2\). Compared with counting inliers, MSAC also prefers
the hypothesis whose inliers fit more tightly, which breaks ties between
hypotheses with the same support. RansacScoring::kInlierCount counts
inliers instead (ties broken by MSAC). When score_always_on is set (the
default), the always-on cost is added so that hypotheses violating a trusted
prior are penalized.
Informative inliers. Some factors report a zero residual for
configurations they cannot evaluate. PnPFactorBatch, for example,
returns zero residual and zero Jacobian for points behind the camera. A
hypothesis that puts every point behind the camera would then look perfect.
With require_informative_inliers (default on), a factor counts as an
inlier only if its Jacobian has a non-zero entry on a free state.
How many hypotheses?#
Let \(w\) be the inlier ratio. A random sample of \(s\) factors is
all-inlier with probability \(w^s\). To draw at least one all-inlier
sample with probability \(p\) (confidence), one needs
hypotheses. For \(p = 0.999\):
inlier ratio \(w\) |
\(s = 2\) |
\(s = 3\) |
\(s = 4\) |
\(s = 6\) |
|---|---|---|---|---|
0.9 |
5 |
6 |
7 |
10 |
0.5 |
25 |
52 |
108 |
439 |
0.3 |
74 |
253 |
850 |
9,473 |
0.2 |
170 |
861 |
4,314 |
107,931 |
0.1 |
688 |
6,905 |
69,075 |
\(6.9 \cdot 10^6\) |
\(w\) is unknown in advance, so cuNLS works in rounds of
hypotheses_per_round (default 256) hypotheses and stops adaptively: after
each round it sets \(w\) to the best inlier ratio found so far and stops
as soon as the hypotheses drawn reach the bound above (or the inlier ratio
reaches early_stop_inlier_ratio, or max_rounds is hit). The table
shows why small samples matter: prefer factors with larger residual
dimension (fewer factors per sample) and keep \(D\) small.
Choosing the inlier threshold#
\(\tau\) is in the units of the raw residual (before any loss function), and it is the single most important parameter. If the residual of an inlier is Gaussian with per-component standard deviation \(\sigma\), then \(\|r\|^2 / \sigma^2\) follows a \(\chi^2\) distribution with \(m\) degrees of freedom, and
keeps a fraction \(q\) of the true inliers:
residual dim \(m\) |
\(q = 0.95\) |
\(q = 0.99\) |
\(q = 0.999\) |
|---|---|---|---|
1 |
\(1.96\sigma\) |
\(2.58\sigma\) |
\(3.29\sigma\) |
2 |
\(2.45\sigma\) |
\(3.03\sigma\) |
\(3.72\sigma\) |
3 |
\(2.80\sigma\) |
\(3.37\sigma\) |
\(4.03\sigma\) |
6 |
\(3.55\sigma\) |
\(4.10\sigma\) |
\(4.74\sigma\) |
Too small a threshold rejects good measurements and makes the estimate noisy;
too large a threshold accepts outliers that lie close to the model. If the
noise is not isotropic, wrap the factor batch in an InformationFactorBatch
with the square-root information matrix: the residual is then whitened
(\(\sigma = 1\)) and \(\tau = \sqrt{\chi^2_m(q)}\) directly.
Refinement#
The best hypothesis was fitted to only \(s\) factors, so it carries the
noise of those few measurements. The final step classifies all sampled
factors at the best hypothesis and runs final_iterations of
Gauss-Newton / Levenberg-Marquardt on the inliers plus the always-on
factors, which averages the noise of all inliers. The inliers are then
re-classified at the refined state, and the result and its inlier mask are
returned. If the refined state scores worse than the best hypothesis (rare;
it can happen when the hypothesis was poor), the hypothesis is returned
instead and RansacSummary::refinement_reverted is set.
What the implementation does#
One call to minimize(stream, problem) (C++ Minimize):
Validate and lay out the problem (see Limits and requirements): find the free states and their tangent columns (\(D\) total), the role of every residual batch, the sample size \(s\).
Rounds (at most
max_rounds), each with \(K\) =hypotheses_per_roundhypotheses solved in parallel on the GPU:Every hypothesis gets its own copy (“replica”) of the free state batches, initialized from the problem’s current state values.
Hypothesis \(k\) draws its sample: the first \(s\) entries of a keyed pseudo-random permutation of all sampled factors (key = seed, round, \(k\)). No sorting, no RNG state; the same seed gives the same samples.
hypothesis_iterations(default 5) GN or LM iterations per hypothesis on its sample plus the always-on factors. Each iteration builds the small dense normal equations \(J^\top J\,\delta = -J^\top r\) per hypothesis, solves them with a pivoted LDLᵀ (default) or Cholesky, and applies the step through the state batch’sPlus. A hypothesis whose first solve fails (degenerate sample) is marked invalid.Every hypothesis is scored on all sampled factors (MSAC). For large problems, two-stage scoring (when there are more than 2 ×
scoring_subset_sizesampled factors) first scores all hypotheses on a random subset of 16,384 factors and then scores only the bestscoring_finalists(default 4) on all factors.The best hypothesis so far is kept on the device. The adaptive stopping rule decides whether another round is needed.
Refine the best hypothesis on its inliers (
final_iterations, default 20), re-classify, write the state back into the problem’s state batches, and fill the summary.
All hypotheses of a round are evaluated together. The minimizer does not
call your factor once per hypothesis. It calls FactorBatch::Evaluate
once per residual batch for all hypotheses, using the item parameters
factor_ids / num_factor_ids to say which factor each output row
belongs to and which hypothesis’s state it reads. Likewise it calls
StateBatch::Plus once per state batch for all hypotheses, using
num_replicas. This is why custom factors and states must honor those
parameters (Custom Factors and States (Python and C++)). Everything else (normal
equations, solves, scoring, selection) runs in a handful of fused kernels
without atomics.
Deterministic. Every reduction runs in a fixed order and sampling is
counter-based, so the same problem and the same seed give bitwise
identical results, run after run.
Synchronization. The host reads a few scalars once per round (to decide whether to stop) and at the end. Everything else is asynchronous on the given stream.
Usage#
Important
Capacity vs. active count. Factor and state batches are constructed with
their capacity (how many factors / states their buffers hold) and
start with zero active entries: call set_num_active_factors(n) /
set_num_active_states(n) (C++: SetNumActiveFactors /
SetNumActiveStates) before solving, and again whenever the problem
size changes. See Capacity and active count.
Build the problem exactly as for the regular minimizers. Then choose the
roles and thresholds, run the minimizer, and read the inlier mask. The
example below is the PnP problem from python/examples/ransac_pnp.py
(C++: examples/ransac_pnp): one SE(3) pose state and one
PnPFactorBatch with a factor per 3D-2D correspondence, all pointing to
the pose.
Python#
import pycunls
# ... states, factors and problem built as usual:
# pose_state.set_num_active_states(1)
# pnp.set_num_active_factors(num_matches)
# problem.add_state_batch(pose_state)
# problem.add_factor_batch(pnp, pointers) # residual batch 0
options = pycunls.RansacLevenbergMarquardtMinimizerOptions()
ransac = options.base_options # a reference: edits change `options`
ransac.factor_batches = [ # assign a whole list, one entry per batch
pycunls.RansacFactorBatchOptions(pycunls.RansacRole.sampled, 0.01),
]
ransac.seed = 1
minimizer = pycunls.RansacLevenbergMarquardtMinimizer(options)
summary = minimizer.minimize(stream, problem) # writes the estimate back
mask = minimizer.inlier_mask(0) # numpy uint8, 1 = inlier
print(summary) # RansacSummary(rounds=..., inliers=...)
Note
options.factor_batches returns a copy of the list:
options.factor_batches.append(...) has no effect. Always assign a
complete list.
C++#
#include "cunls/cunls.h"
// ... states, factors and problem built as usual:
// pose_state.SetNumActiveStates(1);
// pnp.SetNumActiveFactors(num_matches);
// problem.AddStateBatch(&pose_state);
// problem.AddFactorBatch(&pnp, pointers); // residual batch 0
cunls::RansacLevenbergMarquardtMinimizerOptions options;
cunls::RansacMinimizerOptions &ransac = options.base_options;
// One entry per residual batch, in the order they were added to the problem.
ransac.factor_batches = {{cunls::RansacRole::kSampled, /*inlier_threshold=*/0.01f}};
ransac.seed = 1;
cunls::RansacLevenbergMarquardtMinimizer minimizer(options);
cunls::RansacSummary summary = minimizer.Minimize(stream, problem);
// The estimate is now in the problem's state batches (here: pose_state).
// Inlier mask of residual batch 0: device memory, one byte per factor.
std::vector<uint8_t> mask(minimizer.InlierMaskSize(0));
cudaMemcpy(mask.data(), minimizer.InlierMask(0), mask.size(), cudaMemcpyDeviceToHost);
printf("%zu rounds, %zu inliers (%.1f%%)\n", summary.num_rounds, summary.num_inliers,
100.f * summary.inlier_ratio);
RansacGaussNewtonMinimizer takes a RansacMinimizerOptions directly
(Python: pycunls.RansacGaussNewtonMinimizer(options) with a
pycunls.RansacMinimizerOptions):
cunls::RansacMinimizerOptions options;
options.default_inlier_threshold = 0.01f; // every batch sampled with this threshold
cunls::RansacGaussNewtonMinimizer minimizer(options);
Roles in practice#
One data batch, nothing else (PnP, registration, model fitting): leave
factor_batchesempty and setdefault_inlier_threshold; every batch is then sampled with that threshold.Data plus priors (e.g. a pose prior from odometry): mark the prior batches
RansacRole.always_on(C++kAlwaysOn). They take part in every hypothesis solve and the refinement and, withscore_always_on, in the score. If the prior may be wrong by much more than its stated uncertainty, setscore_always_on = False(C++false) so it does not veto the correct hypothesis.Several data batches (e.g. one per camera of a rig): mark each
RansacRole.sampled(C++kSampled) with its own threshold. Samples are drawn from the union of all sampled factors, andinlier_mask(i)(C++InlierMask(i)) gives the mask of batchi.Known quantities (3D landmarks in PnP, rig extrinsics): put them in state batches with constant states (
const_state_ids); they cost nothing towards \(D\).
Gauss-Newton or Levenberg-Marquardt?#
Both variants share every option and step (they are the two
RansacMinimizer subclasses; a RansacMinimizer& accepts either).
RansacGaussNewtonMinimizer
takes full Gauss-Newton steps and is the fastest. RansacLevenbergMarquardt
Minimizer damps each hypothesis’s steps with its own \(\lambda\), which
widens the convergence basin when the initial guess is far off or the problem
is strongly nonlinear, at some extra cost per iteration. Start with GN; switch
to LM if hypotheses fail to converge from your initial guesses.
Options reference and tuning#
Defaults are tuned on PnP and work for most small problems.
Option |
Default |
Meaning and advice |
|---|---|---|
|
empty |
Role and inlier threshold per residual batch (same order as
|
|
1.0 |
Threshold \(\tau\) used when |
|
256 |
Hypotheses solved in parallel per round. Larger rounds use the GPU better but can overshoot the number actually needed; 256–1024 is a good range. |
|
8 |
Upper bound on rounds. The maximum number of hypotheses is
|
|
0.999 |
Target probability of having drawn at least one all-inlier sample. |
|
1.0 |
Stop as soon as the best inlier ratio reaches this value (1 disables). |
|
0 |
Factors per minimal sample; 0 = \(\lceil D / m_{\min} \rceil\). Increase it if minimal samples are often degenerate (e.g. collinear points), at the price of more hypotheses. |
|
0 |
Sampler seed. Same seed and problem, bitwise identical result. |
|
MSAC |
|
|
true |
Add the always-on cost to the score. |
|
true |
Count a factor as an inlier only if its Jacobian is non-zero on a free state. Disable only for factors that never report zero residuals for invalid configurations; it saves one Jacobian evaluation per scored factor. |
|
16384 |
Two-stage scoring for problems with more than twice this many sampled factors. 0 always scores every hypothesis on every factor. |
|
4 |
Hypotheses re-scored on all factors in two-stage scoring (at most 64). |
|
64 MiB |
Device memory for scoring buffers; bounds how many hypotheses are scored at once. Results do not depend on it. |
|
5 |
GN / LM iterations per hypothesis. More helps a poor initial guess. |
|
20 |
Iterations of the final refinement (stops early on convergence). |
|
1e-10, 1e-7 |
Per-hypothesis convergence on the squared step norm and the relative cost decrease. |
|
LDLT |
|
RansacLevenbergMarquardtMinimizerOptions adds the damping parameters of
the regular LM minimizer (initial_lambda = 1e-3, lambda_upscale = 2,
lambda_downscale = 0.5, lambda_min/lambda_max,
step_accept_threshold, lambda_downscale_threshold), applied per
hypothesis.
Reading the summary. RansacSummary extends MinimizerSummary
(initial_cost over all factors, final_cost of the refinement over the
inliers and always-on factors, num_iterations and iteration_costs of
the refinement) with num_rounds, num_hypotheses,
num_valid_hypotheses, num_inliers, inlier_ratio, best_score
and refinement_reverted.
Troubleshooting.
Too few inliers / wrong estimate: check the threshold against the actual residual noise (evaluate the residuals at a known good state); raise
max_roundsfor low inlier ratios; use the LM variant or morehypothesis_iterationsif the initial guess is far.Many invalid hypotheses (
num_valid_hypothesesmuch smaller thannum_hypotheses): samples are often degenerate. Increasesample_sizeby one or two.Inliers that are clearly wrong: the threshold is too large, or a factor returns zero residuals for invalid configurations without
require_informative_inliers.
Limits and requirements#
minimize raises ValueError (C++: Minimize throws
std::invalid_argument) with an explanatory message when:
the free tangent dimension \(D\) is 0 or exceeds 64;
no residual batch is sampled, or the sample size exceeds the number of sampled factors;
factor_batchesis non-empty but its length differs from the number of residual batches, or a sampled batch has a non-positive threshold;a factor references more than 8 states, or a state that belongs to no registered state batch;
a residual batch uses numeric Jacobians (
JacobianMode::kNumeric), which RANSAC does not support yet;a residual batch is a constraint (solved by
AugmentedLagrangianMinimizer), a state batch has box bounds, or the problem has a subproblem partition or state stages: RANSAC does not implement these and rejects them rather than ignoring them;Problem.check_consistency()(C++Problem::CheckConsistency()) fails, or an option is out of range (hypotheses_per_round,max_roundsorhypothesis_iterationsof 0,confidenceoutside (0, 1)).
Every factor and state batch must support the item / replica parameters of
Evaluate and Plus. All built-in batches do; for your own types see
Custom Factors and States (Python and C++).
Performance#
PnP, 30% outliers, one round of 256 hypotheses, median wall time (NVIDIA RTX A6000):
correspondences |
LM + Cauchy loss |
RANSAC-GN |
RANSAC-LM |
|---|---|---|---|
1,000 |
0.7 ms |
1.4 ms |
1.4 ms |
100,000 |
3.7 ms |
7.3 ms |
7.5 ms |
1,000,000 |
31.6 ms |
14.9 ms |
47.3 ms |
In the same benchmarks RANSAC succeeds in 100% of trials from 0% to 90% uniformly random outliers, from both small and large initial errors, and against coherent outliers (a second, competing pose); plain GN/LM fail from 10% outliers and LM with a Huber loss from 60%.