FAST-Sync is a sparse initializer that recovers absolute group elements from noisy relative measurements. This tutorial builds self-contained GTSAM graphs for every supported group, explains the input contract and gauge convention, and shows how to refine the result with a nonlinear optimizer.
Reference: Shane Holmes, Yiran Luo, Firat Taxpulat, David M. Rosen, and Frank Dellaert, “FAST-Sync: Fast Group Synchronization for Any Matrix Lie Group,” IEEE Robotics and Automation Letters, vol. 11, no. 9, pp. 10377–10384, September 2026. Holmes et al. (2026).
import numpy as np
import plotly.graph_objects as go
import gtsamWhat problem does FAST-Sync solve?¶
Suppose every unknown belongs to the same matrix Lie group and each edge supplies a relative measurement . Synchronization estimates all jointly. In GTSAM, the measurements are BetweenFactor<T> objects and the answer is returned as a Values container keyed exactly like the input graph.
With Rot2 or Rot3, this problem is usually called rotation synchronization or rotation averaging. It is a common early step in structure from motion (SfM): pairwise image matches supply relative camera rotations, and the synchronized absolute rotations help initialize the subsequent translation and 3D structure estimation. With Pose2 or Pose3, the unknowns are complete robot poses and synchronization is pose-graph optimization—the pose-graph component of 2D or 3D SLAM. FAST-Sync initializes these problems; the resulting values can then seed bundle adjustment or nonlinear pose-graph optimization.
FAST-Sync is most useful when you need a fast, graph-wide initial estimate before nonlinear optimization. It does not need an initial Values, and it does not iterate on the manifold. Instead, it solves one sparse chordal problem in the ambient matrix space, projects every matrix back to the requested group, and optionally aligns the result to one prior.
A typical GTSAM workflow is:
Build a connected graph of same-type between factors.
Give every between factor finite, positive, isotropic Gaussian noise.
Optionally add one matching prior to choose the global frame.
Call the type-specific
fastSync...wrapper.Use the returned
Valuesdirectly or as the initial estimate for Levenberg–Marquardt or Gauss–Newton.
Step 1: build a valid synchronization graph¶
Each edge below stores values[i].between(values[j]), which has the required direction . FAST-Sync preserves arbitrary keys, so the deliberately nonconsecutive keys (10, 42, 77) reappear unchanged in the result. The three edges form a connected cycle; a disconnected graph cannot establish relative transforms between its components and is rejected.
The noise model dimension must match the group’s tangent dimension: 1 for Rot2, 3 for Rot3 and Pose2, 6 for Pose3, 4 for Similarity2, 7 for Similarity3, and 15 for SL4. FAST-Sync currently accepts only finite, positive, isotropic Gaussian noise. Robust, constrained, and anisotropic between-factor models are rejected rather than silently approximated.
The optional prior has a special role. FAST-Sync first solves using an identity gauge selected by the requested ordering (METIS by default, with another OrderingType or a complete custom Ordering accepted), then left-aligns the complete rounded solution to the prior. The prior therefore selects the global frame without changing any estimated relative transform. At most one matching prior is allowed.
def triangle_graph(values, between_factor, prior_factor, dimension, perturbations=None):
keys = (10, 42, 77)
model = gtsam.noiseModel.Isotropic.Sigma(dimension, 0.1)
graph = gtsam.NonlinearFactorGraph()
for edge, (i, j) in enumerate(((0, 1), (1, 2), (2, 0))):
measured = values[i].between(values[j])
if perturbations is not None:
measured = measured.compose(perturbations[edge])
graph.add(between_factor(keys[i], keys[j], measured, model))
graph.add(prior_factor(keys[0], values[0], model))
return graph, keysStep 2: choose the wrapper that matches the factor type¶
The C++ API is one template, fastSync<T>(graph). GTSAM’s wrapper generator exposes a type-suffixed Python function for each supported instantiation:
| Between factor | Solver | Result accessor | Tangent dimension |
|---|---|---|---|
BetweenFactorRot2 | fastSyncRot2 | atRot2 | 1 |
BetweenFactorRot3 | fastSyncRot3 | atRot3 | 3 |
BetweenFactorPose2 | fastSyncPose2 | atPose2 | 3 |
BetweenFactorPose3 | fastSyncPose3 | atPose3 | 6 |
BetweenFactorSimilarity2 | fastSyncSimilarity2 | atSimilarity2 | 4 |
BetweenFactorSimilarity3 | fastSyncSimilarity3 | atSimilarity3 | 7 |
BetweenFactorSL4 | fastSyncSL4 | atSL4 | 15 |
The following compact sweep verifies the five groups not developed in the detailed walkthroughs. The Pose2 measurements receive small deterministic perturbations, just as the detailed Pose3 graph does below; the remaining compact cases use exact measurements to isolate wrapper and projection behavior. A positive determinant confirms orientation preservation; for Similarity2 and Similarity3, it also reflects GTSAM’s positive-scale convention.
rot2_values = [gtsam.Rot2(a) for a in (0.2, 0.7, -0.4)]
pose2_values = [gtsam.Pose2(1, -2, 0.2), gtsam.Pose2(2, 0.5, 0.7), gtsam.Pose2(-1, 1.5, -0.4)]
pose2_perturbations = [
gtsam.Pose2.Expmap(0.01 * np.array([np.cos(edge), np.sin(edge), (-1)**edge]))
for edge in range(3)
]
sim2_values = [gtsam.Similarity2(rot2_values[i], np.array([i, -0.5*i]), s) for i, s in enumerate((1.1, 0.9, 1.3))]
rot3_small = [gtsam.Rot3.Expmap(np.array([0.04*i, -0.02*i, 0.03*i])) for i in range(3)]
sim3_values = [gtsam.Similarity3(rot3_small[i], np.array([i, 0.2*i, -0.1*i]), s) for i, s in enumerate((1.1, 0.9, 1.3))]
sl4_values = [gtsam.SL4.Expmap(np.linspace(0.001*(i + 1), 0.015*(i + 1), 15)) for i in range(3)]
cases = [
('Rot2', rot2_values, gtsam.BetweenFactorRot2, gtsam.PriorFactorRot2, 1, gtsam.fastSyncRot2, lambda v, k: v.atRot2(k), None),
('Pose2', pose2_values, gtsam.BetweenFactorPose2, gtsam.PriorFactorPose2, 3, gtsam.fastSyncPose2, lambda v, k: v.atPose2(k), pose2_perturbations),
('Similarity2', sim2_values, gtsam.BetweenFactorSimilarity2, gtsam.PriorFactorSimilarity2, 4, gtsam.fastSyncSimilarity2, lambda v, k: v.atSimilarity2(k), None),
('Similarity3', sim3_values, gtsam.BetweenFactorSimilarity3, gtsam.PriorFactorSimilarity3, 7, gtsam.fastSyncSimilarity3, lambda v, k: v.atSimilarity3(k), None),
('SL4', sl4_values, gtsam.BetweenFactorSL4, gtsam.PriorFactorSL4, 15, gtsam.fastSyncSL4, lambda v, k: v.atSL4(k), None),
]
for name, values, between, prior, dim, solve, at, perturbations in cases:
graph, keys = triangle_graph(values, between, prior, dim, perturbations)
result = solve(graph)
determinant = np.linalg.det(at(result, keys[-1]).matrix())
print(f'{name:11s}: {result.size()} values, det(last)={determinant:.6f}')Rot2 : 3 values, det(last)=1.000000
Pose2 : 3 values, det(last)=1.000000
Similarity2: 3 values, det(last)=0.769231
Similarity3: 3 values, det(last)=0.769231
SL4 : 3 values, det(last)=1.000000
Step 3: initialize a noisy Rot3 graph¶
Rotation synchronization is a natural use case when translations are unavailable or should be estimated later. Here seven absolute rotations create a chain, a loop closure (6, 0), and an extra chord (1, 5). Each ideal relative rotation is right-composed with a small deterministic perturbation, while the isotropic model supplies the confidence used by the chordal solve. In a real application, measured would come from odometry, registration, or another front end rather than known truth.
No initial rotations are passed to fastSyncRot3; the graph is the complete input. The one prior aligns key 0 to the desired frame after projection. The Plotly chart reports geodesic error in degrees because this synthetic tutorial knows the truth. On real data, inspect loop residuals or the nonlinear graph error instead. Hover over each bar to read its value.
rotations = [gtsam.Rot3.Expmap(np.array([0.10*i, -0.04*i, 0.06*i])) for i in range(7)]
rot3_graph = gtsam.NonlinearFactorGraph()
rot3_model = gtsam.noiseModel.Isotropic.Sigma(3, 0.08)
rot3_edges = [(i, i + 1) for i in range(6)] + [(6, 0), (1, 5)]
for edge, (i, j) in enumerate(rot3_edges):
noise = gtsam.Rot3.Expmap(0.012 * np.array([np.sin(edge), np.cos(edge), (-1)**edge]))
measured = rotations[i].between(rotations[j]).compose(noise)
rot3_graph.add(gtsam.BetweenFactorRot3(i, j, measured, rot3_model))
rot3_graph.add(gtsam.PriorFactorRot3(0, rotations[0], rot3_model))
rot3_result = gtsam.fastSyncRot3(rot3_graph)
rot3_error = [np.linalg.norm(gtsam.Rot3.Logmap(rotations[i].between(rot3_result.atRot3(i)))) * 180 / np.pi for i in range(7)]
fig = go.Figure(go.Bar(x=list(range(7)), y=rot3_error, marker_color='#4c78a8'))
fig.update_layout(
title='FAST-Sync on a noisy Rot3 graph',
xaxis_title='key',
yaxis_title='orientation error (deg)',
template='plotly_white',
height=320,
)
fig.show()Step 4: initialize a noisy Pose3 trajectory¶
For Pose3, FAST-Sync estimates rotation and translation together in one ambient linear system. The graph below uses the same topology as the rotation example and perturbs every six-dimensional relative pose. Projection orthogonalizes each rotational block while retaining the recovered translation.
An isotropic six-dimensional model assigns one standard deviation to all tangent coordinates. That restriction is important: many production pose graphs use different rotational and translational sigmas, which are anisotropic and therefore rejected by this API. If your sensor model is anisotropic, retain that model for nonlinear optimization and construct a justified isotropic initialization graph separately rather than silently discarding the distinction.
The interactive Plotly view overlays truth and the FAST-Sync trajectory. Drag to rotate, scroll to zoom, and click legend entries to isolate a trace. The returned pose3_result is already a normal GTSAM Values object.
poses = [gtsam.Pose3(rotations[i], np.array([i, 0.12*i*i, 0.3*np.sin(i)])) for i in range(7)]
pose3_graph = gtsam.NonlinearFactorGraph()
pose3_model = gtsam.noiseModel.Isotropic.Sigma(6, 0.1)
for edge, (i, j) in enumerate(rot3_edges):
delta = 0.01 * np.array([np.sin(edge), np.cos(edge), (-1)**edge, np.cos(edge), np.sin(edge), 0.5])
measured = poses[i].between(poses[j]).compose(gtsam.Pose3.Expmap(delta))
pose3_graph.add(gtsam.BetweenFactorPose3(i, j, measured, pose3_model))
pose3_graph.add(gtsam.PriorFactorPose3(0, poses[0], pose3_model))
pose3_result = gtsam.fastSyncPose3(pose3_graph)
truth_xyz = np.vstack([pose.translation() for pose in poses])
estimate_xyz = np.vstack([pose3_result.atPose3(i).translation() for i in range(7)])
pose3_fig = go.Figure()
pose3_fig.add_trace(go.Scatter3d(
x=truth_xyz[:, 0], y=truth_xyz[:, 1], z=truth_xyz[:, 2],
mode='lines+markers', name='truth',
line=dict(color='#54a24b'), marker=dict(symbol='circle'),
))
pose3_fig.add_trace(go.Scatter3d(
x=estimate_xyz[:, 0], y=estimate_xyz[:, 1], z=estimate_xyz[:, 2],
mode='lines+markers', name='FAST-Sync',
line=dict(color='#e45756', dash='dash'), marker=dict(symbol='square'),
))
pose3_fig.update_layout(
title='Noisy Pose3 initialization', template='plotly_white', height=500,
scene=dict(xaxis_title='x', yaxis_title='y', zaxis_title='z'),
)
pose3_fig.show()
print('RMS translation error:', np.sqrt(np.mean(np.sum((truth_xyz - estimate_xyz)**2, axis=1))))RMS translation error: 0.01912428191534728
Step 5: refine the initialization on the manifold¶
FAST-Sync minimizes a chordal ambient-space objective, so its noisy result generally does not minimize the original nonlinear factor-graph error. The standard GTSAM pattern is to pass the returned Values directly to a nonlinear optimizer. This preserves the original factors and their manifold error functions while giving the optimizer a globally informed starting point.
For this small graph, Levenberg–Marquardt lowers the factor-graph error from the FAST-Sync initialization. On a production graph, configure optimizer parameters as usual and keep any sensor-specific priors or factors that FAST-Sync ignored.
initial_error = pose3_graph.error(pose3_result)
refined_result = gtsam.LevenbergMarquardtOptimizer(
pose3_graph, pose3_result
).optimize()
refined_error = pose3_graph.error(refined_result)
refined_xyz = np.vstack([refined_result.atPose3(i).translation() for i in range(7)])
pose3_fig.add_trace(go.Scatter3d(
x=refined_xyz[:, 0], y=refined_xyz[:, 1], z=refined_xyz[:, 2],
mode='lines+markers', name='After LM',
line=dict(color='#b279a2', dash='dot'), marker=dict(symbol='diamond'),
))
pose3_fig.update_layout(title='Pose3 initialization and nonlinear refinement')
pose3_fig.show()
print(f'graph error: {initial_error:.6g} -> {refined_error:.6g}')graph error: 0.098991 -> 0.0383198
Troubleshooting and next steps¶
If FAST-Sync rejects a graph, check the contract before changing the algorithm:
No result or an empty-graph error: the graph must contain at least one
BetweenFactor<T>matching the selected wrapper. Other factor types are ignored by initialization.Disconnected graph: every estimated key must be connected through matching between factors. Solve components separately only if independent gauges are acceptable.
Noise-model error: between-factor noise must be finite, positive, Gaussian, and isotropic with the correct tangent dimension. Robust, constrained, and anisotropic models are deliberately rejected.
Multiple-prior error: use at most one matching
PriorFactor<T>for alignment. Additional priors can remain in a separate nonlinear graph used for refinement.
See the FAST-Sync documentation for the reduced block equations, projection trait, and implementation details, and the paper for the algorithmic development.
- Holmes, S., Luo, Y., Taxpulat, F., Rosen, D. M., & Dellaert, F. (2026). FAST-Sync: Fast Group Synchronization for Any Matrix Lie Group. IEEE Robotics and Automation Letters, 11(9), 10377–10384. 10.1109/lra.2026.3710327