Fixing OpenSfM Bundle Adjustment Divergence

Your ODM run does not crash — it finishes, and the model is wrong. The point cloud bows upward at the edges, flight lines shear apart, or the OpenSfM log shows the reprojection error climbing across iterations instead of settling, sometimes ending with a “bundle adjustment did not converge” note. This is divergence: the non-linear solver that jointly refines camera poses, intrinsics, and 3D points has walked away from the true minimum instead of toward it, and every downstream product inherits the distortion. The cause is never the solver itself — it is a bad constraint the solver faithfully honoured: over-tight GPS priors, too few valid matches to pin the geometry, a camera model that does not fit the lens, or a contaminated ground control point (GCP) dragging the solution. This page shows how to read the reprojection statistics, identify which constraint is poisoning the solve, and correct it. It is the reconstruction-quality deep dive for setting up OpenDroneMap with Python.

Why bundle adjustment diverges

Bundle adjustment minimizes the sum of squared reprojection residuals — the pixel distance between each observed feature xij\mathbf{x}_{ij} and where the current camera and point estimates project it, π(K,Ri,ti,Xj)\pi(K, R_i, t_i, X_j):

RMSE=1Ni,jxijπ(K,Ri,ti,Xj)22\mathrm{RMSE} = \sqrt{\frac{1}{N}\sum_{i,j}\big\lVert \mathbf{x}_{ij} - \pi(K, R_i, t_i, X_j)\big\rVert_2^{2}}

A healthy solve on calibrated drone optics drives this toward roughly 0.3–0.7 normalized pixels and decreases monotonically. Divergence — an RMSE that rises or oscillates — has four dominant root causes, each a constraint the solver cannot satisfy without distorting everything else:

  1. Over-weighted GPS priors. ODM converts GPS accuracy into a prior weight; if standalone-GNSS positions (3–10 m real error) are treated as if they were RTK-tight (centimetres), the solver is forced to honour physically impossible camera positions and warps the block to fit them. This is the most common cause of edge-bowing (“doming”).
  2. Insufficient overlap or matches. When a region has too few tie points, its cameras are under-constrained; the solver has a near-degenerate sub-problem and small errors blow up into drift. Thin cross-strip overlap is the usual culprit.
  3. A wrong camera model. Forcing a simple perspective model onto a fisheye lens (or the reverse) means the intrinsics can never explain the observations, so residuals never converge and the solver oscillates trying to absorb lens distortion into pose.
  4. GCP contamination. A single mistyped GCP coordinate, a mislabeled marker, or a GCP with a wrong CRS acts as an enormously heavy, wrong constraint that the least-squares solve spreads across the whole model.

The fix is to relax or correct the offending constraint and let a robust loss suppress the residual outliers, rather than letting one bad prior dominate the squared-error cost.

Minimal reproducible solution

The wrapper below reads the reprojection RMSE from OpenSfM’s own statistics, and when it exceeds a convergence threshold re-runs ODM with corrected constraints: a GPS accuracy that matches how the positions were actually captured, an explicit camera model, and — if a control file exists — GCPs seeded so the datum is pinned by survey truth rather than weak GNSS. Matching --gps-accuracy to reality is the single highest-leverage change.

import json
import subprocess
import logging
from pathlib import Path

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

def read_reprojection_rmse(project: Path) -> float:
    """Pull the normalized reprojection error from OpenSfM's stats.json."""
    stats = project / "opensfm" / "stats" / "stats.json"
    data = json.loads(stats.read_text())
    rec = data["reconstruction_statistics"]
    # Prefer the normalized error; fall back to the raw pixel RMSE.
    return float(rec.get("reprojection_error_normalized",
                         rec.get("reprojection_error", 99.0)))

def run_with_constraints(datasets: Path, project: str, gps_accuracy_m: float,
                         lens: str = "brown", timeout_s: int = 86_400) -> None:
    """Re-run ODM with priors that match capture reality and a fixed lens model."""
    cmd = [
        "docker", "run", "--rm", "-v", f"{datasets}:/datasets",
        "opendronemap/odm:3.5.4", "--project-path", "/datasets", project,
        "--gps-accuracy", str(gps_accuracy_m),   # standalone ~5.0, RTK ~0.05
        "--camera-lens", lens,                    # brown / perspective / fisheye
        "--dsm", "--orthophoto-resolution", "2.0",
    ]
    gcp = datasets / project / "gcp_list.txt"
    if gcp.is_file():
        cmd += ["--force-gps"]                     # let GCPs anchor over weak GPS
        logging.info("GCP file present; anchoring datum on surveyed control.")
    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s)
    if proc.returncode != 0:
        raise RuntimeError(f"ODM exit {proc.returncode}:\n{(proc.stderr or '')[-1500:]}")

def repair_divergence(datasets: Path, project: str, threshold: float = 1.0) -> None:
    """If the reconstruction diverged, loosen priors and re-run once."""
    rmse = read_reprojection_rmse(datasets / project)
    logging.info("reprojection RMSE = %.3f (threshold %.2f)", rmse, threshold)
    if rmse > threshold:
        # A high RMSE with standalone GPS almost always means over-tight priors.
        run_with_constraints(datasets, project, gps_accuracy_m=5.0, lens="brown")

The --gps-accuracy 5.0 value is deliberate: it tells the solver the camera positions are trustworthy to about five metres, so it stops distorting the geometry to hit centimetre-wrong GNSS fixes and lets the tie-point network define the shape. If you actually flew RTK/PPK, tighten it toward 0.05 instead — the failure mode there is the opposite, a loose prior that lets the block drift. Setting --camera-lens explicitly (rather than letting ODM auto-detect on a sparse block) stops the solver from absorbing distortion into pose. When a gcp_list.txt is present, anchoring on surveyed control is the durable fix; producing that file correctly is covered in automating GCP detection with Python.

Edge-case matrix

The signature in the log and stats tells you which constraint to correct. Applying the wrong fix — tightening priors on a drifting RTK block, for instance — makes divergence worse.

Input variant Downstream symptom if unchecked Expected handling
Standalone GPS, tight prior Edge-bowing / doming, RMSE climbs Loosen --gps-accuracy to ~5.0 m
RTK/PPK, loose prior Whole block drifts off true position Tighten --gps-accuracy to ~0.05 m
Thin cross-strip overlap Under-constrained cameras drift, oscillating RMSE Add overlap or GCPs; treat sparse edges as missing
Fisheye lens, perspective model Residuals never converge, warped intrinsics Set --camera-lens fisheye to match the sensor
One mistyped GCP coordinate Model sheared toward the bad control point Remove/fix the GCP; check its CRS and sign
GCP in wrong CRS Systematic offset plus divergence Reproject control to the project CRS first

Verification snippet

Confirm the re-run actually converged rather than just finishing: assert the reprojection RMSE dropped below threshold and that the observation count did not collapse (a “converged” solve on almost no points is not a fix).

from pathlib import Path

def verify_convergence(project: Path, threshold: float = 1.0,
                       min_observations: int = 5000) -> None:
    """Assert the bundle adjustment converged on a healthy observation set."""
    import json
    rec = json.loads((project / "opensfm" / "stats" / "stats.json").read_text())
    stats = rec["reconstruction_statistics"]
    rmse = float(stats.get("reprojection_error_normalized",
                           stats.get("reprojection_error", 99.0)))
    observations = int(stats.get("observations_count", 0))

    assert rmse <= threshold, f"still diverged: RMSE {rmse:.3f} > {threshold}"
    assert observations >= min_observations, \
        f"converged on too few observations ({observations}); geometry is thin"
    print(f"converged: RMSE {rmse:.3f} over {observations} observations")

A pass here means the solver settled on a low, stable residual over a dense tie-point network — the two conditions that together certify a metrically sound block. Track the RMSE across runs so a regression is caught immediately rather than at delivery.

When to escalate

Escalate when the constraint is correct but the geometry itself cannot support a solve:

  • RMSE stays high even with matched priors and a correct lens. The tie-point network is too thin. This is a coverage problem — fix it upstream with the flight overlap validation routine, which rejects sparse blocks before reconstruction.
  • You need to tune the solver’s loss function or Jacobian directly. ODM’s flags have a ceiling; the robust-loss and residual-weighting internals live in optimizing bundle adjustment with Python.
  • The divergence is really a GCP residual problem. If control points are fighting each other, the diagnosis belongs with GCP error distribution rather than the solver — return through setting up OpenDroneMap with Python and audit the control network.

Setting Up OpenDroneMap with Python