Fixing Not Enough Inliers RANSAC Failures

Your matcher reports hundreds of candidate matches for an image pair, but the moment geometric verification runs, the log fills with not enough inliers, the fundamental matrix comes back None, and the edge between those two frames is silently dropped. Repeat that across a strip and the block fractures: pairs that clearly overlap refuse to connect, the view graph splits into components, and bundle adjustment either diverges or leaves whole flight lines unregistered. The matches were never the problem — the geometric verification stage is rejecting almost all of them, and the usual reflex of “add more features” makes it worse by feeding the matcher even more ambiguous candidates. This page isolates why RANSAC collapses on drone imagery and gives you the four parameters that actually recover the inliers.

Why RANSAC rejects nearly every match

RANSAC estimates a geometric model — a fundamental matrix F for a general scene, or an essential matrix E when the camera is calibrated — by repeatedly sampling a minimal set of correspondences, fitting the model, and counting how many other matches agree with it within a reprojection threshold. An inlier is a match consistent with that model; the “not enough inliers” failure means no sampled model ever gathered enough agreeing matches to be trusted. On drone imagery this collapses for four concrete reasons, and they need opposite fixes, which is why blind parameter changes so often fail.

The first is a Lowe ratio that is too strict. The ratio test discards a match whose nearest and second-nearest descriptor distances are close. On aerial scenes with self-similar texture, most correct matches are ambiguous by that measure, so a tight ratio like 0.6 throws away the very correspondences RANSAC needs, leaving too few to seed a model. The second is repetitive texture itself — crop rows, solar arrays, roof tiles, lane markings — which produces matches that are individually confident but collectively inconsistent, so RANSAC cannot find one model they all fit. The third is a wrong RANSAC reprojection threshold: set it too tight and real matches carrying normal lens-distortion and rolling-shutter residuals get scored as outliers; set it too loose and RANSAC locks onto a garbage model. The fourth is planar-scene degeneracy — when the overlapping ground is nearly flat, the fundamental matrix is under-constrained (a homography would describe the scene equally well), so F is numerically unstable and RANSAC returns a degenerate or None result even with plenty of good matches. Placing this failure in the wider alignment picture is the job of troubleshooting alignment and matching failures; this page is the focused repair.

Minimal reproducible solution

The fix is to loosen the ratio test enough to keep ambiguous-but-correct matches, set a resolution-appropriate RANSAC threshold, and use OpenCV’s USAC estimator (USAC_MAGSAC), which is markedly more robust to the exact threshold than the legacy FM_RANSAC. The routine below verifies one pair and returns the inlier ratio so the caller can decide, rather than dropping the edge silently.

import cv2
import numpy as np


def verify_pair(kp1, desc1, kp2, desc2,
                ratio: float = 0.8,          # looser than 0.75: keep ambiguous matches
                px_thresh: float = 2.0,      # RANSAC reproj threshold, scale with sensor
                min_matches: int = 15) -> tuple[np.ndarray, np.ndarray, float]:
    """Geometrically verify one image pair, returning inlier points + ratio.

    Uses USAC_MAGSAC, which tolerates a wider threshold band than FM_RANSAC
    and is the single highest-leverage change for inlier recovery.
    """
    bf = cv2.BFMatcher(cv2.NORM_L2, crossCheck=False)
    knn = bf.knnMatch(desc1, desc2, k=2)

    # Loosened Lowe ratio: on self-similar aerial texture, a strict ratio
    # discards the correct-but-ambiguous matches RANSAC needs to seed a model.
    good = [m for m, n in (p for p in knn if len(p) == 2)
            if m.distance < ratio * n.distance]
    if len(good) < min_matches:
        return np.empty((0, 2)), np.empty((0, 2)), 0.0

    pts1 = np.float64([kp1[m.queryIdx].pt for m in good])
    pts2 = np.float64([kp2[m.trainIdx].pt for m in good])

    # USAC_MAGSAC weights matches by a soft threshold instead of a hard cutoff,
    # so a slightly-wrong px_thresh no longer collapses the inlier set.
    F, mask = cv2.findFundamentalMat(pts1, pts2, cv2.USAC_MAGSAC,
                                     px_thresh, 0.999, 10000)
    if F is None or mask is None:
        return np.empty((0, 2)), np.empty((0, 2)), 0.0

    inliers = mask.ravel().astype(bool)
    ratio_in = float(inliers.sum()) / len(good)
    return pts1[inliers], pts2[inliers], ratio_in

Four knobs did the work. The Lowe ratio rose from a strict 0.75 to 0.8 so ambiguous matches survive to the verifier; px_thresh is set for the sensor rather than left at a library default; USAC_MAGSAC replaces the brittle FM_RANSAC; and min_matches guards against fitting a model from too few points. If the scene is genuinely near-planar, the fundamental matrix will still misbehave — the production wrapper below detects that case and falls back to a homography model, which is the correct geometry for a flat overlap.

def verify_pair_planar_safe(kp1, desc1, kp2, desc2,
                            px_thresh: float = 2.0, ratio: float = 0.8):
    """Try fundamental-matrix verification; if the scene is degenerate-planar,
    fall back to a homography, which is the valid model for a flat overlap.
    """
    pts1, pts2, r_f = verify_pair(kp1, desc1, kp2, desc2, ratio, px_thresh)
    if len(pts1) >= 20 and r_f >= 0.35:
        return pts1, pts2, r_f, "fundamental"

    # Re-match and test a homography for a planar/degenerate scene.
    bf = cv2.BFMatcher(cv2.NORM_L2, crossCheck=False)
    knn = bf.knnMatch(desc1, desc2, k=2)
    good = [m for m, n in (p for p in knn if len(p) == 2)
            if m.distance < ratio * n.distance]
    if len(good) < 15:
        return pts1, pts2, r_f, "rejected"
    p1 = np.float64([kp1[m.queryIdx].pt for m in good])
    p2 = np.float64([kp2[m.trainIdx].pt for m in good])
    H, mask = cv2.findHomography(p1, p2, cv2.USAC_MAGSAC, px_thresh)
    if H is None or mask is None:
        return pts1, pts2, r_f, "rejected"
    m = mask.ravel().astype(bool)
    r_h = float(m.sum()) / len(good)
    return p1[m], p2[m], r_h, "homography"

Edge-case matrix

These are the input conditions that actually produce inlier collapse on UAV blocks, the downstream symptom each causes if unhandled, and the expected handling.

Input variant Downstream symptom if unchecked Expected handling
Lowe ratio ≤ 0.65 on self-similar texture Too few candidates reach RANSAC; edge dropped Raise ratio to 0.8; keep ambiguous matches for the verifier
Repetitive pattern (crop rows, panels) Many confident-but-inconsistent matches; RANSAC finds no model USAC_MAGSAC + tighten px_thresh; add texture via CLAHE upstream
4K+ sensor, px_thresh=1.0 Real matches scored as outliers; inlier ratio craters Scale px_thresh up to 3–4 px for high-resolution frames
Near-planar overlap (flat field, water) Fundamental matrix degenerate or None Fall back to a homography model (planar-safe wrapper)
Fewer than min_matches after ratio test RANSAC fits a model from too few points; false-valid edge Reject the pair explicitly; do not admit degenerate geometry
Unseeded RANSAC across a batch Inlier count varies run to run; non-reproducible drops cv2.setRNGSeed(0) before verification for deterministic results

Verification snippet

Confirm the fix on a pair that previously failed by asserting the inlier ratio cleared a floor and that enough absolute inliers survived to seed a stable pose. Logging both turns a silent edge-drop into a tracked metric.

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")


def assert_pair_recovered(kp1, desc1, kp2, desc2,
                          min_inliers: int = 20, floor: float = 0.35):
    pts1, pts2, ratio, model = verify_pair_planar_safe(kp1, desc1, kp2, desc2)
    logging.info("model=%s inliers=%d inlier_ratio=%.3f", model, len(pts1), ratio)
    assert model != "rejected", "pair still rejected — inspect texture/overlap"
    assert len(pts1) >= min_inliers, f"only {len(pts1)} inliers (< {min_inliers})"
    assert ratio >= floor, f"inlier ratio {ratio:.3f} below floor {floor}"
    return pts1, pts2

If the assertion passes on strips that previously dropped, the verification parameters were the fix. The inlier ratio of 0.35 and the model label are the two values worth logging per pair — a sudden shift to homography across a whole strip is a reliable early signal of a flat, degeneracy-prone survey area.

When to escalate

This page recovers inliers for pairs whose geometry is genuinely there. Escalate when the failure is not about verification parameters:

  • Even a loose ratio and USAC leave a frame with almost no keypoints. The problem is upstream in extraction, not verification — the frame is starved on low-texture terrain. Return to the extraction diagnosis in troubleshooting alignment and matching failures and retune the detector per feature detection algorithms for drone imagery.
  • Pairs verify cleanly but the block still fractures into submodels. Individual edges are fine; the view graph has a connectivity gap. Use the union-find diagnosis in the parent guide to find and bridge the disconnected components.
  • You cannot decide whether to keep hand-tuning OpenCV or move to an integrated matcher. If you are re-implementing ratio, RANSAC, and pruning by hand, compare the trade-off in OpenCV vs OpenSfM for feature matching before investing further in a bespoke verifier.

Troubleshooting Alignment and Matching Failures