Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Fast CPU bundle adjustment: Full and Schur

Created by Codex.

This tutorial solves one bundle-adjustment problem twice with the CPU SfmLevenbergMarquardtOptimizer: first with the fastest measured Full-system configuration, then with explicit Schur elimination. The example includes a calibration shared by every camera, so it also demonstrates that CPU Schur elimination removes Point3 landmarks while keeping poses and calibration in the reduced system.

Open In Colab
import numpy as np
import plotly.graph_objects as go

import gtsam
from gtsam.examples import SFMdata
from gtsam.symbol_shorthand import K, L, X

LinearSolver = gtsam.NonlinearOptimizerParams.LinearSolverType

1. Build a bundle-adjustment graph

Four camera poses observe eight landmarks. Every projection factor refers to the same calibration variable k0; calibration is therefore optimized globally rather than stored separately in each camera. Priors on the first pose and landmark fix the coordinate frame and scale, while a loose calibration prior regularizes self-calibration.

poses = SFMdata.createPoses()[:4]
points = SFMdata.createPoints()
calibration = gtsam.Cal3_S2(50.0, 50.0, 0.0, 50.0, 50.0)

graph = gtsam.NonlinearFactorGraph()
measurement_noise = gtsam.noiseModel.Isotropic.Sigma(2, 1.0)
for i, pose in enumerate(poses):
    camera = gtsam.PinholeCameraCal3_S2(pose, calibration)
    for j, point in enumerate(points):
        graph.add(
            gtsam.GeneralSFMFactor2Cal3_S2(
                camera.project(point), measurement_noise, X(i), L(j), K(0)
            )
        )

graph.addPriorPose3(
    X(0), poses[0],
    gtsam.noiseModel.Diagonal.Sigmas(
        np.array([0.1, 0.1, 0.1, 0.3, 0.3, 0.3])
    ),
)
graph.addPriorPoint3(
    L(0), points[0], gtsam.noiseModel.Isotropic.Sigma(3, 0.1)
)
graph.addPriorCal3_S2(
    K(0), calibration,
    gtsam.noiseModel.Diagonal.Sigmas(
        np.array([500.0, 500.0, 0.1, 100.0, 100.0])
    ),
)
print(f"{graph.size()} factors, {len(poses)} poses, {len(points)} points")
35 factors, 4 poses, 8 points

2. Create one initial estimate

Both optimizations start from exactly the same deterministic perturbation. This makes their final objectives directly comparable.

initial = gtsam.Values()
initial.insert(K(0), gtsam.Cal3_S2(60.0, 60.0, 0.0, 45.0, 45.0))
pose_delta = gtsam.Pose3(
    gtsam.Rot3.Rodrigues(-0.1, 0.2, 0.25),
    gtsam.Point3(0.05, -0.10, 0.20),
)
for i, pose in enumerate(poses):
    initial.insert(X(i), pose.compose(pose_delta))
for j, point in enumerate(points):
    # Keep the fixed Point3 type distinct from a dynamic NumPy vector.
    initial.insertPoint3(
        L(j), point + gtsam.Point3(-0.25, 0.20, 0.15)
    )

initial_error = graph.error(initial)
print(f"Initial error: {initial_error:.6f}")
Initial error: 9805.400745

3. Calculate the ordering once

reduced_ordering contains only the four poses and shared calibration. schur_ordering is the complete point-first ordering: naturally ordered landmarks followed by that METIS reduced ordering. The fastest measured CPU path uses this complete ordering with Full mode and MULTIFRONTAL_SOLVER.

reduced_ordering = (
    gtsam.SfmLevenbergMarquardtOptimizer.CreateReducedOrdering(
        graph, initial
    )
)
schur_ordering = (
    gtsam.SfmLevenbergMarquardtOptimizer.CreateSchurOrdering(
        graph, reduced_ordering
    )
)

print(f"Reduced ordering: {reduced_ordering.size()} variables")
print(f"Complete Schur ordering: {schur_ordering.size()} variables")
assert reduced_ordering.size() == len(poses) + 1
assert schur_ordering.size() == initial.size()
Reduced ordering: 5 variables
Complete Schur ordering: 13 variables

4. Fastest measured CPU path: Full + multifrontal

Full mode passes the entire linearized graph to the ordinary nonlinear-optimizer solver. Supplying schur_ordering makes MultifrontalSolver eliminate all landmarks first and then factor the camera-and-calibration Schur complement in the same Bayes tree—there is no separately materialized reduced graph or second solver call.

full_params = gtsam.SfmLevenbergMarquardtParams.ceresDefaults()
full_params.setEliminationMode(gtsam.SfmEliminationMode.Full)
full_params.setLinearSolver(LinearSolver.MULTIFRONTAL_SOLVER)
full_params.setOrdering(schur_ordering)

full_optimizer = gtsam.SfmLevenbergMarquardtOptimizer(
    graph, initial, full_params
)
full_result = full_optimizer.optimize()
full_error = graph.error(full_result)
print(f"Full final error: {full_error:.9f}")
Full final error: 0.000172184

5. Explicit Schur mode

Schur-mode parameters accept the reduced ordering, not the complete one. With MULTIFRONTAL_SOLVER, the optimizer completes it internally and performs the same landmark-first elimination in one multifrontal factorization. Other solver choices would instead receive an explicitly materialized reduced graph.

schur_params = gtsam.SfmLevenbergMarquardtParams.ceresDefaults()
schur_params.setEliminationMode(gtsam.SfmEliminationMode.Schur)
schur_params.setLinearSolver(LinearSolver.MULTIFRONTAL_SOLVER)
schur_params.setOrdering(reduced_ordering)

schur_optimizer = gtsam.SfmLevenbergMarquardtOptimizer(
    graph, initial, schur_params
)
schur_result = schur_optimizer.optimize()
schur_error = graph.error(schur_result)
print(f"Schur final error: {schur_error:.9f}")
Schur final error: 0.000172184

6. Verify both paths

The two modes may take slightly different floating-point paths, but they should converge to the same objective and nearly identical shared calibration. These assertions also make the notebook a useful end-to-end smoke test of the Python wrappers.

assert full_error < initial_error
assert schur_error < initial_error
np.testing.assert_allclose(full_error, schur_error, rtol=1e-7, atol=1e-9)
np.testing.assert_allclose(
    full_result.atCal3_S2(K(0)).vector(),
    schur_result.atCal3_S2(K(0)).vector(),
    rtol=1e-6,
    atol=1e-6,
)
print("Full and Schur agree.")
print("Recovered calibration:", full_result.atCal3_S2(K(0)).vector())
Full and Schur agree.
Recovered calibration: [ 4.99680801e+01  4.66069104e+01 -2.45373588e-07  5.00012934e+01
  4.83109424e+01]

7. Inspect the reconstruction

The recovered landmarks should overlay the ground truth. Hover over either point cloud to inspect coordinates.

truth_xyz = np.asarray(points)
result_xyz = np.asarray(
    [full_result.atPoint3(L(j)) for j in range(len(points))]
)
figure = go.Figure()
figure.add_trace(
    go.Scatter3d(
        x=truth_xyz[:, 0], y=truth_xyz[:, 1], z=truth_xyz[:, 2],
        mode="markers", name="Ground truth", marker={"size": 6},
    )
)
figure.add_trace(
    go.Scatter3d(
        x=result_xyz[:, 0], y=result_xyz[:, 1], z=result_xyz[:, 2],
        mode="markers", name="Optimized", marker={"size": 3},
    )
)
figure.update_layout(
    title="CPU SFM reconstruction",
    scene={"aspectmode": "data"},
    margin={"l": 0, "r": 0, "b": 0, "t": 40},
)
figure.show()
Loading...