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.

Certifiable localization in SE(3)

This notebook develops a complete certifiable localization example from a small g2o dataset. We solve the same deterministic 20-pose problem three ways: using a local Gauss–Newton solver and using two convex relaxations in the form of semidefinite programs (SDPs), one monolithic and one chordally decomposed. The local solution gives a familiar nonlinear least-squares baseline, while each SDP supplies a global lower bound. When a relaxation has a rank-one solution, its dominant eigenvector recovers the global minimizer directly; more generally, agreement between the lower bound and any feasible solution certifies that solution as globally optimal.

The example is deliberately organized around frame-aware names. We use the convention that TbaT^a_b transforms coordinates from frame bb to frame aa. Thus TkwT^w_k is the conventional GTSAM pose of sensor frame kk in the world, while Twk=(Tkw)1T^k_w=(T^w_k)^{-1} maps world coordinates into sensor frame kk. A world landmark is LwL^w. Its Cartesian measurement in frame kk is P~k\widetilde{P}^k, while the pose predicts TwkLwT^k_w L^w.

The g2o file stores conventional ground-truth poses TkwT^w_k, world landmarks LwL^w, bearing-range observations, and relative-pose measurements TjiT^i_j. The certifiable formulation intentionally optimizes the inverse states TwkT^k_w.

Two known-landmark factors are available in Python. The wrapper exposes KnownLandmarkFactorPose3, which follows the conventional GTSAM TkwT^w_k state direction, and KnownLandmarkFactor2Pose3, which follows the inverse TwkT^k_w direction. This notebook deliberately uses the second version, KnownLandmarkFactor2Pose3, because it is the convention used in the implemented QCQP conversion. The first version is usually the more natural factor for ordinary nonlinear localization, but it is not compatible with the implemented QCQP conversion.

Along the way we will:

  1. convert bearing-range measurements and covariances into Cartesian frame-kk measurements;

  2. build a graph from KnownLandmarkFactor2Pose3 and FrobeniusLeftBetweenFactorPose3;

  3. solve that graph locally over TwkT^k_w poses;

  4. convert the same graph exactly into a QCQP and solve two SDP relaxations; and

  5. recover conventional TkwT^w_k trajectories for comparison in the world frame.

For a tutorial centered on the first, conventional wrapper factor, see Known landmark localization in SE(3). API-level details are in the KnownLandmarkFactor notebook and FrobeniusFactor notebook.

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

From g2o measurements to certifiable factors

The dataset is stored using standard 3D GTSAM factors and values. Pose vertices are conventional TkwT^w_k poses, landmark vertices are LwL^w points, each landmark observation is a BearingRangeFactor3D, and each odometry edge is a BetweenFactorPose3. The target graph has only pose variables: every loaded LwL^w becomes fixed data inside a known-landmark factor.

Bearing-range to Cartesian coordinates

A bearing-range factor stores a measured unit bearing uu, a scalar range ρ\rho, and a Gaussian model in local bearing-range coordinates. The corresponding Cartesian point measured in frame kk is

P~k=ρu.\widetilde{P}^k=\rho u.

Let BR3×2B\in\mathbb{R}^{3\times2} be the tangent basis returned by the measured Unit3. For a small bearing perturbation δbR2\delta b\in\mathbb{R}^2, the induced Cartesian perturbation is δPkρBδb\delta P^k\approx\rho B\,\delta b. Thus the Jacobian with respect to bearing is ρB\rho B, while the Jacobian with respect to range is uu. The Jacobian from local bearing-range coordinates to Cartesian coordinates is therefore

DP/bearingRangek=[ρBu].DP^k_{/\mathrm{bearingRange}}=\begin{bmatrix}\rho B & u\end{bmatrix}.

We push the bearing-range covariance through that Jacobian:

ΣPk=DP/bearingRangekΣbr(DP/bearingRangek)T.\Sigma_{P^k}=DP^k_{/\mathrm{bearingRange}}\,\Sigma_{br}\,(DP^k_{/\mathrm{bearingRange}})^{\mathsf T}.

This is the covariance seen by KnownLandmarkFactor2Pose3. The transformation is exact at the measurement linearization point. It does not make a nonlinear Gaussian in bearing-range coordinates globally identical to a Cartesian Gaussian; away from the measured ray, the two likelihood surfaces generally differ. Here κ(ΣPk)\kappa(\Sigma_{P^k}) is the spectral condition number, so κ(ΣPk)\sqrt{\kappa(\Sigma_{P^k})} is the ratio between the longest and shortest one-standard-deviation axes. The code verifies that this ratio is 10.

Why the inverse-state factors are quadratic

This is where the distinction between the two wrapped landmark factors matters. KnownLandmarkFactorPose3 would evaluate a conventional TkwT^w_k with transformTo; here we instead instantiate KnownLandmarkFactor2Pose3 and supply a TwkT^k_w. The 2 variant preserves the same physical prediction—a known LwL^w expressed as a measured P~k\widetilde{P}^k—but reverses the optimized pose direction to expose the matrix structure used below.

For a fixed world landmark LwL^w, KnownLandmarkFactor2Pose3 uses residual terms of the form

ek(Twk)=TwkLwP~k.e_k(T^k_w)=T^k_w L^w-\widetilde{P}^k.

Here the multiplication is the usual homogeneous point transformation. Because LwL^w is fixed, this residual is affine in the matrix entries of TwkT^k_w. A full Gaussian noise model supplies the Cartesian information matrix, and the weighted squared residual is quadratic even when the noise is anisotropic and non-diagonal.

For consecutive poses, the loaded relative measurement TjiT^i_j and inverse states obey

Twi=TjiTwj.T^i_w=T^i_j T^j_w.

That is precisely the left-composition convention used by FrobeniusLeftBetweenFactorPose3. Consequently, both factor families admit an exact D=1 QCQP representation of their nonlinear objectives. The following cell performs both conversions, rejects unexpected factor types, and confirms that the resulting graph contains 20×4=8020\times4=80 landmark factors plus 19 relative-pose factors.

data_file = gtsam.findExampleDataFile(
    "known_landmark_localization_20.g2o"
)
source_graph, dataset_values = gtsam.readG2o(data_file, is3D=True)

graph = gtsam.NonlinearFactorGraph()
pose_keys, landmark_keys = set(), set()
kPCovariances = []
for index in range(source_graph.size()):
    factor = source_graph.at(index)
    keys = tuple(factor.keys())
    if isinstance(factor, gtsam.BearingRangeFactor3D):
        k, l = keys
        measured_kBearingRange = factor.measured()
        kRange = measured_kBearingRange.range()
        kBearing = measured_kBearingRange.bearing()
        measured_kP = kRange * kBearing.unitVector()
        DkP_dbearingRange = np.column_stack(
            (kRange * kBearing.basis(), kBearing.unitVector())
        )
        kPCovariance = (
            DkP_dbearingRange
            @ factor.noiseModel().covariance()
            @ DkP_dbearingRange.T
        )
        graph.add(
            gtsam.KnownLandmarkFactor2Pose3(
                k,
                dataset_values.atPoint3(l),
                measured_kP,
                gtsam.noiseModel.Gaussian.Covariance(kPCovariance),
            )
        )
        pose_keys.add(k)
        landmark_keys.add(l)
        kPCovariances.append(kPCovariance)
    elif isinstance(factor, gtsam.BetweenFactorPose3):
        i, j = keys
        graph.add(
            gtsam.FrobeniusLeftBetweenFactorPose3(
                i, j, factor.measured(), factor.noiseModel()
            )
        )
        pose_keys.update(keys)
    else:
        raise TypeError(f"Unexpected factor type: {type(factor).__name__}")

pose_keys, landmark_keys = sorted(pose_keys), sorted(landmark_keys)
eigenvalues = np.linalg.eigvalsh(kPCovariances[0])
anisotropicity = np.sqrt(eigenvalues[-1] / eigenvalues[0])
print(f"certifiable factors: {graph.size()}")
print(f"inverse pose states:     {len(pose_keys)}")
print(f"sqrt covariance condition number: {anisotropicity:.1f}")
assert np.isclose(anisotropicity, 10.0)
certifiable factors: 99
inverse pose states:     20
sqrt covariance condition number: 10.0

Local optimization over TwkT^k_w

Gauss–Newton needs values expressed in the same convention as the factors. The values loaded from g2o are TkwT^w_k, so we explicitly invert each one before inserting it into ground_truth_kTws. This conversion is easy to overlook: inserting the loaded poses unchanged would give every factor the opposite transformation from the one its residual expects.

The initial estimate is constructed by retracting each ground-truth TwkT^k_w with a fixed tangent perturbation scaled by the pose index. The growing scale creates accumulated drift along the trajectory rather than giving every pose the same offset. This is useful pedagogically because the initial graph error is visibly poor, while the problem remains within the convergence basin of a local optimizer.

Gauss–Newton minimizes the nonlinear graph directly. It provides a feasible trajectory at every iteration, but it is a local method: convergence alone does not prove that another basin cannot contain a better solution. We therefore use it as both a practical baseline and a candidate solution to compare with the SDP lower bounds.

The measurements in the committed dataset are perturbed while the vertices store the exact generating trajectory. For that reason, the maximum-likelihood estimate is expected to have a slightly lower objective than the ground truth. This is not overfitting or an inconsistency; it is the normal consequence of optimizing against one particular realization of measurement noise. The assertions check that the objective decreases from both the initial estimate and the generating trajectory, and that the recovered poses nevertheless remain close to ground truth. Pose error is computed with Pose3.localCoordinates, so it is the norm of a six-dimensional rotational/translational tangent vector.

perturbation = np.array([0.02, -0.015, 0.01, 0.08, -0.05, 0.06])
ground_truth_kTws = gtsam.Values()
initial_kTws = gtsam.Values()
for index, k in enumerate(pose_keys):
    kTw = dataset_values.atPose3(k).inverse()
    ground_truth_kTws.insert(k, kTw)
    initial_kTws.insert(k, kTw.retract((index + 1) * perturbation))

parameters = gtsam.GaussNewtonParams()
parameters.setMaxIterations(100)
parameters.setRelativeErrorTol(1e-12)
gauss_newton_kTws = gtsam.GaussNewtonOptimizer(
    graph, initial_kTws, parameters
).optimize()

def pose_errors(actual_kTws):
    return np.array([
        np.linalg.norm(
            ground_truth_kTws.atPose3(k).localCoordinates(actual_kTws.atPose3(k))
        )
        for k in pose_keys
    ])

ground_truth_error = graph.error(ground_truth_kTws)
initial_error = graph.error(initial_kTws)
gauss_newton_error = graph.error(gauss_newton_kTws)
gauss_newton_pose_errors = pose_errors(gauss_newton_kTws)

print(f"ground-truth error: {ground_truth_error:.3f}")
print(f"initial error:      {initial_error:.3f}")
print(f"Gauss-Newton error: {gauss_newton_error:.3f}")
print(f"maximum pose error: {gauss_newton_pose_errors.max():.4f} m")

assert gauss_newton_error < initial_error
assert gauss_newton_error < ground_truth_error
assert gauss_newton_pose_errors.max() < 0.05
ground-truth error: 620.180
initial error:      964140.401
Gauss-Newton error: 556.769
maximum pose error: 0.0207 m

Monolithic and chordal semidefinite relaxations

The constructor QcqpProblem(graph, 1) creates a QCQP reformulation in which each of the above factors becomes a quadratic cost term in a flattened, homogenized pose vector (thus D=1), and quadratic constraints make each variable a valid vectorized pose. At a feasible collection of TwkT^k_w poses, the QCQP objective is the same weighted least-squares objective evaluated by the nonlinear graph. This is neither a linearization nor a relaxation of the factor graph: the QCQP is an exact reformulation of its nonlinear objective and pose constraints.

A QCQP is still nonconvex because the pose constraints are nonconvex. To form the semidefinite relaxation, we lift the homogeneous vector xx to a matrix X=xxTX=xx^{\mathsf T}. The quadratic objective and constraints become linear in XX; dropping the nonconvex condition rank(X)=1\operatorname{rank}(X)=1 produces a convex semidefinite program whose optimum is a lower bound on the original minimization problem. If the optimal lifted blocks are rank one, the relaxation is tight and their dominant eigenvectors recover a globally optimal feasible QCQP solution. Rank one is sufficient but not necessary for tightness: even when an SDP solution has higher rank, a feasible local solution is certified globally optimal whenever its objective matches the SDP lower bound, up to numerical tolerance.

This notebook solves two representations of the same relaxation:

  • Monolithic SDP: one global positive-semidefinite matrix contains all lifted variables. This is the most direct formulation, but it does not exploit much of the graph’s sparsity.

  • Chordal SDP: a METIS ordering exposes sparse cliques. Smaller positive-semidefinite matrices are assigned to those cliques, with overlap constraints enforcing consistency. This usually reduces memory and can improve scalability while preserving the relaxation.

The MOSEK backend is optional, so the cell first checks whether its wrappers are available. After each solve, qcqpValues() recovers one homogeneous QCQP block per pose and extractQcqpValuesPose3 converts those blocks into feasible TwkT^k_w poses. We then evaluate the recovered poses in the original nonlinear graph, not merely in the relaxation.

The eigenvalue ratio (EVR) λ1/λ2\lambda_1/\lambda_2 measures how close each lifted block is to rank one. A very large minimum EVR means the leading eigenvector dominates every block. In that rank-one case, the recovered feasible objective must agree with the SDP lower bound, and the recovered poses are globally optimal. Independently, agreement between the Gauss–Newton objective and the SDP lower bound certifies the local solution even if the relaxation were tight with a higher-rank optimizer. Here we require all EVRs to exceed 105, all recovered poses to remain within the same small error threshold as the local solution, and all three objectives to agree closely.

qcqp = gtsam.QcqpProblem(graph, 1)
sdp_results_kTws = {}
sdp_summaries = {}

if not hasattr(gtsam, "MosekMonolithicSDP"):
    print("This GTSAM build does not include the optional MOSEK backend.")
else:
    solvers = {
        "monolithic SDP": gtsam.MosekMonolithicSDP(qcqp),
        "chordal SDP": gtsam.MosekChordalSDP(
            qcqp, gtsam.ChordalOrderingType.Metis
        ),
    }
    for name, solver in solvers.items():
        if not solver.solve(
            {"intpntCoTolRelGap": 1e-10, "optimizerMaxTime": 600.0}
        ):
            raise RuntimeError(f"{name} did not return a readable solution")

        recovered_kTws = gtsam.extractQcqpValuesPose3(solver.qcqpValues())
        recovered_pose_errors = pose_errors(recovered_kTws)
        recoveredEVRs = np.asarray(solver.variableEVRs())
        sdp_results_kTws[name] = recovered_kTws
        sdp_summaries[name] = {
            "objective": solver.objectiveValue(),
            "feasible_error": graph.error(recovered_kTws),
            "time": solver.solveTimeSeconds(),
            "evrs": recoveredEVRs,
            "pose_errors": recovered_pose_errors,
        }
        print(
            f"{name:15s}: objective={solver.objectiveValue():.3f}, "
            f"feasible error={graph.error(recovered_kTws):.3f}, "
            f"time={solver.solveTimeSeconds():.3f} s, "
            f"min EVR={recoveredEVRs.min():.3e}, "
            f"max pose error={recovered_pose_errors.max():.4f} m"
        )

        assert recovered_pose_errors.max() < 0.05
        assert np.all(recoveredEVRs >= 1e5)
monolithic SDP : objective=556.769, feasible error=556.769, time=0.844 s, min EVR=1.024e+10, max pose error=0.0207 m
chordal SDP    : objective=556.770, feasible error=556.769, time=0.546 s, min EVR=1.630e+08, max pose error=0.0207 m

Compare all results in the world frame

Every optimized state in the certifiable graph is TwkT^k_w, but trajectories are normally visualized as sensor positions in the world. The translation stored directly in TwkT^k_w is the world origin expressed in frame kk; it is not the position of frame kk in the world. We therefore invert every optimized pose before reading its translation:

Tkw=(Twk)1,Pkw=translation(Tkw).T^w_k=(T^k_w)^{-1},\qquad P^w_k=\operatorname{translation}(T^w_k).

The loaded ground truth is already stored as TkwT^w_k, so its translations can be plotted directly. The landmarks are loaded as LwL^w and likewise require no conversion. The helper world_translations makes the inversion explicit for the initial, Gauss–Newton, and recovered SDP values.

The plot is intended as a convention check as much as a performance comparison. If a Tkw/TwkT^w_k/T^k_w conversion were accidentally omitted, the trajectory would not merely be shifted—its position and orientation would generally be interpreted in the wrong frame. With the conversions in place, the local and certifiable trajectories should nearly overlap the generating trajectory, while the dashed initialization visibly shows the injected drift.

def world_translations(kTws):
    return np.vstack([
        kTws.atPose3(k).inverse().translation() for k in pose_keys
    ])

ground_truth_wPs = np.vstack([
    dataset_values.atPose3(k).translation() for k in pose_keys
])
initial_wPs = world_translations(initial_kTws)
gauss_newton_wPs = world_translations(gauss_newton_kTws)
wLs = np.vstack([dataset_values.atPoint3(l) for l in landmark_keys])

fig = go.Figure()
fig.add_scatter(
    x=initial_wPs[:, 0], y=initial_wPs[:, 1], mode="lines+markers",
    line={"dash": "dash"}, name="initial kTw (shown as wTk)",
)
fig.add_scatter(
    x=ground_truth_wPs[:, 0], y=ground_truth_wPs[:, 1],
    mode="lines+markers", line={"color": "black"}, name="ground truth wTk",
)
fig.add_scatter(
    x=gauss_newton_wPs[:, 0], y=gauss_newton_wPs[:, 1],
    mode="lines+markers", marker={"symbol": "x"}, name="Gauss-Newton",
)
for name, recovered_kTws in sdp_results_kTws.items():
    recovered_wPs = world_translations(recovered_kTws)
    fig.add_scatter(
        x=recovered_wPs[:, 0], y=recovered_wPs[:, 1],
        mode="lines+markers", name=name,
    )
fig.add_scatter(
    x=wLs[:, 0], y=wLs[:, 1], mode="markers",
    marker={"symbol": "star", "size": 12}, name="known wL",
)
fig.update_layout(
    title="Certifiable localization: local and SDP solutions",
    xaxis_title="world x [m]", yaxis_title="world y [m]",
    template="plotly_white", width=780, height=540,
)
fig.update_yaxes(scaleanchor="x", scaleratio=1)
fig.show()
Loading...

Reading the result

The numerical results connect the local and certifiable viewpoints:

  • The large initial error confirms that the deterministic perturbation created a meaningful optimization problem.

  • Gauss–Newton lowers the objective below both the initialization and the ground truth while keeping every pose close to the generating trajectory.

  • Each SDP objective is a lower bound on the QCQP optimum.

  • Each recovered feasible graph error is an upper bound.

  • The large EVRs show that the lifted SDP blocks are numerically rank one, so their dominant eigenvectors recover globally optimal feasible poses.

  • Independently, the nearly matching SDP lower bound and Gauss–Newton error certify that the local optimizer found the globally optimal solution for this instance, within solver tolerance.

The central modeling choice is the state direction. KnownLandmarkFactor2Pose3 and FrobeniusLeftBetweenFactorPose3 optimize TwkT^k_w because this exposes the exact quadratic structure needed by the QCQP. That direction is intentional and local to the certifiable formulation; ordinary GTSAM localization normally represents poses as TkwT^w_k. Whenever results leave the optimization graph—for plotting, interpretation, or comparison with the loaded values—we invert them back to TkwT^w_k.

Finally, the certificate applies to the optimization model constructed here: the Cartesian known-landmark likelihood, the left-Frobenius relative-pose likelihood, and the supplied Gaussian weights. The earlier bearing-range-to-Cartesian covariance conversion is locally exact at each measurement, but the resulting nonlinear Cartesian model is not globally identical to the original bearing-range Gaussian model. Keeping that distinction explicit is part of interpreting the certificate correctly.