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.

RelativeTranslationFactor

RelativeTranslationFactor<d> is the translational half of a relative pose measurement, written so that it stays quadratic in every variable it touches. It is the piece that lets certifiable SE(d)SE(d) synchronization reuse the same Burer-Monteiro machinery as rotation averaging.

A standard BetweenFactor<Pose3> couples rotation and translation through a nonlinear residual. Splitting the pose into a rotation key and a translation key, and keeping the translation residual in the ambient linear form, gives a cost that the QCQP lowering can express exactly.

Open In Colab
import numpy as np

import gtsam
from gtsam.symbol_shorthand import R, T

np.set_printoptions(precision=4, suppress=True)

Residual

For an edge iji \to j with measured body-frame offset t~ij\tilde{t}_{ij} and translational precision τij\tau_{ij}, the factor contributes

τijtjtiRit~ij2.\tau_{ij}\,\bigl\| t_j - t_i - R_i\,\tilde{t}_{ij} \bigr\|^2 .

evaluateError returns the weighted residual τij(tjtiRit~ij)\sqrt{\tau_{ij}}\,(t_j - t_i - R_i \tilde{t}_{ij}) under a unit noise model, so the squared residual reproduces the term above. The factor is quadratic in RiR_i, tit_i, and tjt_j jointly: RiR_i enters linearly through Rit~ijR_i \tilde{t}_{ij}, and the translations enter linearly. That is exactly the structure a QCQP needs.

Together with FrobeniusBetweenFactor<Rot3> on the same edge, the pair reproduces the usual SE(d)SE(d) synchronization objective

(i,j)κijRjRiRijF2  +  τijtjtiRit~ij2.\sum_{(i,j)} \kappa_{ij}\,\|R_j - R_i R_{ij}\|_F^2 \;+\; \tau_{ij}\,\|t_j - t_i - R_i \tilde{t}_{ij}\|^2 .
measured = np.array([0.4, -0.2, 0.7])
weight = 2.5
factor = gtsam.RelativeTranslationFactor3(R(0), T(0), T(1), measured, weight)

rotation = gtsam.Rot3.RzRyRx(0.2, -0.1, 0.3)
ti = np.array([1.0, 2.0, 3.0])
tj = np.array([1.5, 1.0, 3.25])

values = gtsam.Values()
values.insert(R(0), rotation)
values.insert(T(0), gtsam.Point3(ti))
values.insert(T(1), gtsam.Point3(tj))

print("residual:", factor.unwhitenedError(values))
print("by hand: ", np.sqrt(weight) * (tj - ti - rotation.matrix() @ measured))
residual: [ 0.1303 -1.2308 -0.6847]
by hand:  [ 0.1303 -1.2308 -0.6847]

A measurement generated from two consistent poses gives an exactly zero residual, which is the sanity check worth running whenever the body-frame convention is in doubt: t~ij=Ri(tjti)\tilde{t}_{ij} = R_i^\top (t_j - t_i).

pose_i = gtsam.Pose3(gtsam.Rot3.RzRyRx(0.1, 0.2, -0.3), np.array([0.5, -1.0, 2.0]))
pose_j = gtsam.Pose3(gtsam.Rot3.RzRyRx(-0.2, 0.4, 0.1), np.array([1.5, 0.5, 1.0]))
consistent = pose_i.rotation().unrotate(pose_j.translation() - pose_i.translation())

exact = gtsam.RelativeTranslationFactor3(R(0), T(0), T(1), consistent, 1.0)
consistent_values = gtsam.Values()
consistent_values.insert(R(0), pose_i.rotation())
consistent_values.insert(T(0), gtsam.Point3(pose_i.translation()))
consistent_values.insert(T(1), gtsam.Point3(pose_j.translation()))

print("residual at the generating poses:", exact.unwhitenedError(consistent_values))
residual at the generating poses: [0. 0. 0.]

QCQP lowering

qcqpFactors rewrites the term as a single quadratic cost Q,XX\langle Q, XX^\top\rangle over the stacked row-space variable. With τ\sqrt{\tau} folded in, the row vector

B=[τt~ij    τ    τ]B = \bigl[\,-\sqrt{\tau}\,\tilde{t}_{ij}^\top \;\big|\; -\sqrt{\tau} \;\big|\; \sqrt{\tau}\,\bigr]

spans the rotation block, pose ii’s translation, and pose jj’s translation, and the cost matrix is Q=BBQ = B^\top B with block dimensions {d,1,1}\{d, 1, 1\}. The rotation contributes dd rows to the lifted variable and each translation contributes a single row.

The factor emits no constraints: translations are unconstrained in SE(d)SE(d) synchronization, and the orthogonality constraints on RiR_i come from the rotation factors on the same edge.

graph = gtsam.NonlinearFactorGraph()
graph.add(gtsam.RelativeTranslationFactor3(R(0), T(0), T(1), measured, weight))

# Lower to a rank-3 QCQP: one quadratic cost, no equality or inequality constraints.
problem = gtsam.QcqpProblem(graph, 3)
print("costs, equalities, inequalities:", problem.dim())

# Lifted variables: the rotation block is d-by-p, each translation is 1-by-p.
lifted = gtsam.Values()
lifted.insert(R(0), rotation.matrix().T)
lifted.insert(T(0), ti.reshape(1, 3))
lifted.insert(T(1), tj.reshape(1, 3))
print("cost, equality violation, inequality violation:", problem.evaluate(lifted))

residual = factor.unwhitenedError(values)
print("0.5 * squared residual:", 0.5 * residual @ residual)
costs, equalities, inequalities: (1, 0, 0)
cost, equality violation, inequality violation: (1.0003141852358166, 0.0, 0.0)
0.5 * squared residual: 1.0003141852358168

API

C++PythonRotation type
RelativeTranslationFactor<2>RelativeTranslationFactor2Rot2
RelativeTranslationFactor<3>RelativeTranslationFactor3Rot3

The constructor takes (rotationKey, translationKey1, translationKey2, measured, weight), where weight is the translational precision τ\tau and must be positive.

Practical notes

The factor is meant to be paired with a rotation factor on the same edge, keyed so that the rotation key is shared. When converting an anisotropic BetweenFactor<Pose3> covariance, reduce it to two isotropic precisions: κ\kappa from the rotational block and τ\tau from the translational block. qcqpFactors requires the staircase column dimension pp to be at least dd.

For the full certifiable pipeline built on this factor, see CertifiablePoseGraphOptimizationPose2 and CertifiablePoseGraphOptimizationPose3.