Skip to content

Camera Updaters

CameraUpdater<T> (sdk/include/branes/sdk/msckf/camera_updater.hpp, #44) is the MSCKF camera measurement update: it turns a feature tracked across a window of cloned poses into a state correction. Clean-room from the MSCKF measurement model (Mourikis & Roumeliotis, 2007).

It works entirely in normalized image coordinates — camera intrinsics and distortion are removed by the front end — which keeps it intrinsics-agnostic. Mono and stereo are handled uniformly: an observation names the clone it was taken in and the camera (extrinsic) it came through, so a stereo feature simply contributes observations through two extrinsics that share the clone poses.

The whole update hangs on one picture: one world feature, seen from several camera poses. Drag to orbit, scroll to zoom, ⤢ to reset.

feature p_f camera (clone) ray to feature image plane + projection IMU + extrinsic

A feature sits on a wall 4 m ahead and 1 m to the right of where the robot started. The robot moves to a second viewpoint 2 m further along (a deliberately wide baseline so the effect is easy to see) and takes a second frame — the two clones. Each camera shoots a ray at the feature; the rays meet at the feature — running that intersection backwards is triangulation. On each camera’s image plane (the yellow square at unit depth) the feature lands at a different spot: x=0.2375x = 0.2375 for clone 0 and x=0.2625x = -0.2625 for clone 1 — it has crossed to the other side of the optical axis. That difference — a parallax of 0.50.5 — over the 2 m baseline is exactly what pins the 4 m depth. Everything sits at the same height (y=0y = 0), so the scene lays out on the ground plane.

symbolmeaningconstant?code
R,p\mathbf{R}, \mathbf{p}the live nav state — current IMU pose, “now” (R\mathbf{R} = world←imu, p\mathbf{p} = world position). Moves every IMU sample and every update.noState::R, State::p
Ri,pi\mathbf{R}_i, \mathbf{p}_iclone ii — the body pose frozen at the image time of clone ii. augment_clone snapshots the live (R,p)(\mathbf{R}, \mathbf{p}) into the sliding window; ii indexes which past frame. A feature is seen from several clones.yes, after the snapshots.clones[i].R/.p
Ric,pic\mathbf{R}_{ic}, \mathbf{p}_{ic}the extrinsic — the fixed rigid camera↔IMU mount (not a body pose). ic = “imu ← cam”: Ric\mathbf{R}_{ic} rotates camera axes into the IMU frame, pic\mathbf{p}_{ic} is the camera origin in the IMU frame. The ~90° EuRoC mount.yes — unless S10 online calibration estimates itCameraExtrinsics::R_imu_cam/p_imu_cam
pf\mathbf{p}_fa fixed 3D point in the world — the landmark this observation is of. “Triangulated” only means the filter computes it from the rays; it is never measured directly.yes (a world point)p_f in update()
y\mathbf{y}the same point, re-expressed in the clone’s IMU framey in to_camera
pc\mathbf{p}_cthe same point, re-expressed in the camera frame (zz = optical axis); dividing by zz gives the image coordinatep_c in to_camera

The key point: Ri,pi\mathbf{R}_i, \mathbf{p}_i are not iterations of R,p\mathbf{R}, \mathbf{p} — they are frozen snapshots of it. One moving body pose, a window of snapshots, and a fixed mount.

All metres; rotations are identity in this example, so the transposes vanish:

quantityclone 0clone 1what it is
pf\mathbf{p}_f(1.0,0,4.0)(1.0,\,0,\,4.0)(1.0,0,4.0)(1.0,\,0,\,4.0)the same world point
Ri, pi\mathbf{R}_i,\ \mathbf{p}_iI, (0,0,0)\mathbf{I},\ (0,0,0)I, (2.0,0,0)\mathbf{I},\ (2.0,0,0)the clone (frozen body pose)
Ric, pic\mathbf{R}_{ic},\ \mathbf{p}_{ic}I, (0.05,0,0)\mathbf{I},\ (0.05,0,0)I, (0.05,0,0)\mathbf{I},\ (0.05,0,0)the same camera mount
y=Ri(pfpi)\mathbf{y} = \mathbf{R}_i^\top(\mathbf{p}_f-\mathbf{p}_i)(1.00,0,4.0)(1.00,\,0,\,4.0)(1.00,0,4.0)(-1.00,\,0,\,4.0)feature in the IMU frame
pc=Ric(ypic)\mathbf{p}_c = \mathbf{R}_{ic}^\top(\mathbf{y}-\mathbf{p}_{ic})(0.95,0,4.0)(0.95,\,0,\,4.0)(1.05,0,4.0)(-1.05,\,0,\,4.0)feature in the camera frame
image (x/z, y/z)(x/z,\ y/z)(0.2375,0)(0.2375,\,0)(0.2625,0)(-0.2625,\,0)the normalized observation

The update linearizes the measurement h(x)=π(pc)h(\mathbf{x}) = \pi(\mathbf{p}_c), with π\pi the normalized projection π(pc)=(pc,xpc,z, pc,ypc,z)\pi(\mathbf{p}_c) = \left(\tfrac{p_{c,x}}{p_{c,z}},\ \tfrac{p_{c,y}}{p_{c,z}}\right). Distortion and focal length are not here — the front end removed them — so this is the geometric model only.

Stage 1 — the two frame hops (to_camera). The world feature is carried into the camera through the clone pose, then the extrinsic:

y = Ri.transpose() * (p_f - p_i); // world -> IMU frame
p_c = Ric.transpose() * (y - p_ic); // IMU -> camera frame
return p_c[2] > 0; // cheirality: must be in front

Stage 2 — the projection derivative dh=π/pc\mathrm{dh} = \partial\pi/\partial\mathbf{p}_c (2×32\times 3, evaluated at pc\mathbf{p}_c):

dh=1z[10x/z01y/z],z=pc,z, x=pc,x, y=pc,y\mathrm{dh} = \frac{1}{z}\begin{bmatrix} 1 & 0 & -x/z \\ 0 & 1 & -y/z \end{bmatrix}, \qquad z = p_{c,z},\ x = p_{c,x},\ y = p_{c,y}

Stage 3 — chain-rule each state block (with Rct=Ric\mathbf{R}_{ct} = \mathbf{R}_{ic}^\top, Rit=Ri\mathbf{R}_{it} = \mathbf{R}_i^\top, M=RctRit\mathbf{M} = \mathbf{R}_{ct}\mathbf{R}_{it}):

statepc/()\partial\mathbf{p}_c / \partial\,(\cdot)Jacobian (dh())\big(\mathrm{dh}\cdot(\cdot)\big)code
feature δpf\delta\mathbf{p}_fM\mathbf{M}Hf=dhM\mathbf{H}_f = \mathrm{dh}\,\mathbf{M}J.Hf = dh * M
clone rotation δθ\delta\boldsymbol\thetaRic[y]×\mathbf{R}_{ic}^\top [\mathbf{y}]_\timesHθ=dhRic[y]×\mathbf{H}_\theta = \mathrm{dh}\,\mathbf{R}_{ic}^\top [\mathbf{y}]_\timesJ.Htheta = dh * dpc_dtheta
clone position δp\delta\mathbf{p}M-\mathbf{M}Hf-\mathbf{H}_fassembled as -J.Hf
extrinsic rotation δθic\delta\boldsymbol\theta_{ic}[pc]×[\mathbf{p}_c]_\timesHθ,ic=dh[pc]×\mathbf{H}_{\theta,ic} = \mathrm{dh}\,[\mathbf{p}_c]_\timesJ.Hext_theta
extrinsic translation δpic\delta\mathbf{p}_{ic}Ric-\mathbf{R}_{ic}^\topdhRic-\mathrm{dh}\,\mathbf{R}_{ic}^\topJ.Hext_p

The two non-obvious signs come from the right-perturbation convention RRExp(δθ)\mathbf{R} \leftarrow \mathbf{R}\,\mathrm{Exp}(\delta\boldsymbol\theta) of the state: the clone-rotation term picks up [y]×[\mathbf{y}]_\times, and the clone-position block is additive, so pc/δp=M\partial\mathbf{p}_c/\partial\delta\mathbf{p} = -\mathbf{M}literally Hf-\mathbf{H}_f, the feature block negated. (Hold that thought.)

Stage 4 — stack into the big matrices (update). Each of the mm observations fills one 2-row strip of the feature Jacobian Hf\mathbf{H}_f (2m×32m\times 3) and the state Jacobian Hx\mathbf{H}_x (2m×n2m\times n, zero except this clone’s 6 columns — δθ = Hθ, δp = −Hf). If S10 calibration is on, the strip also writes the extrinsic columns; because every observation through that camera writes the same extrinsic block, the coupling across the window is what makes TCIT_{CI} observable.

Stage 5 — marginalize the feature, then update. The feature pf\mathbf{p}_f was computed from these clones, so treating it as an independent measurement would be circular and make the filter over-confident. Marginalizing means eliminating pf\mathbf{p}_f — like solving a system by substitution so the unwanted variable drops out:

proj = msckf_left_nullspace_project(Hf, Hx, r, ...); // project onto left-null(Hf)

Multiplying [HfHxr][\,\mathbf{H}_f \mid \mathbf{H}_x \mid \mathbf{r}\,] by an orthonormal basis of Hf\mathbf{H}_f‘s left null space (NHf=0\mathbf{N}^\top \mathbf{H}_f = 0, via a Householder QR of Hf\mathbf{H}_f) zeroes the feature columns and drops 3 rows; the noise stays σ2I\sigma^2\mathbf{I}. The surviving system constrains only the clones/extrinsics. It is then gated by a Mahalanobis test γ=r(HxPHx+R)1r\gamma = \mathbf{r}^\top(\mathbf{H}_x \mathbf{P}\mathbf{H}_x^\top + \mathbf{R})^{-1}\mathbf{r} (a χ2\chi^2 quantile) and applied via StateHelper::ekf_update.

Why the δp=Hf\delta\mathbf{p} = -\mathbf{H}_f detail matters (the #212 thread)

Section titled “Why the δp=−Hf\delta\mathbf{p} = -\mathbf{H}_fδp=−Hf​ detail matters (the #212 thread)”

A global translation shifts every clone and the feature by the same vector t\mathbf{t}. In one strip the feature contributes +Hft+\mathbf{H}_f\mathbf{t} and the clone-position block contributes Hft-\mathbf{H}_f\mathbf{t} — they cancel exactly, for any Hf\mathbf{H}_f. So translation is unobservable by construction. The yaw gauge has no such clean cancellation: its clone-rotation direction is Rig^\mathbf{R}_i^\top \hat{\mathbf{g}}, which depends on each clone’s estimate. Linearize different clones at differently-drifted Ri\mathbf{R}_i and the [y]×/Ri[\mathbf{y}]_\times / \mathbf{R}_i^\top terms stop annihilating the yaw null vector — the over-confidence tracked in issue #212, and exactly the term the right-invariant reparameterization replaces with a state-independent constant.

CameraUpdaterOptions carries normalized_sigma (the measurement σ in normalized coordinates — not pixels, deliberately named so daemon config can’t pass pixel-space values) and the gating threshold. The constructor validates those options — it requires a positive normalized_sigma, a positive gate when gating is on, and a non-negative calib_rot_sigma. The cameras list itself isn’t constrained at construction; instead an observation whose camera_index (or clone_index) is out of range is not aliased to camera 0 — the whole track is dropped at update time via a graceful early return.

The end-to-end test builds a window of clones at known poses, places synthetic features, and checks that:

  • a camera update reduces the covariance and keeps it PSD;
  • a single-observation track and a behind-the-camera track are rejected;
  • the filter stays stable with a well-conditioned innovation over 1000 consecutive updates — the covariance trace is monotonically non-increasing per step (an update only removes uncertainty, never adds it), and every innovation factorizes (stays PD).

This 1000-step stability result is the acceptance bar from issue #44.