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 geometry, in 3D
Section titled “The geometry, in 3D”The whole update hangs on one picture: one world feature, seen from several camera poses. Drag to orbit, scroll to zoom, ⤢ to reset.
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: for clone 0 and for clone 1 — it has crossed to the other side of the optical axis. That difference — a parallax of — over the 2 m baseline is exactly what pins the 4 m depth. Everything sits at the same height (), so the scene lays out on the ground plane.
The symbols
Section titled “The symbols”| symbol | meaning | constant? | code |
|---|---|---|---|
| the live nav state — current IMU pose, “now” ( = world←imu, = world position). Moves every IMU sample and every update. | no | State::R, State::p | |
clone — the body pose frozen at the image time of clone . augment_clone snapshots the live into the sliding window; indexes which past frame. A feature is seen from several clones. | yes, after the snapshot | s.clones[i].R/.p | |
the extrinsic — the fixed rigid camera↔IMU mount (not a body pose). ic = “imu ← cam”: rotates camera axes into the IMU frame, is the camera origin in the IMU frame. The ~90° EuRoC mount. | yes — unless S10 online calibration estimates it | CameraExtrinsics::R_imu_cam/p_imu_cam | |
| a 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() | |
| the same point, re-expressed in the clone’s IMU frame | — | y in to_camera | |
| the same point, re-expressed in the camera frame ( = optical axis); dividing by gives the image coordinate | — | p_c in to_camera |
The key point: are not iterations of — they are frozen snapshots of it. One moving body pose, a window of snapshots, and a fixed mount.
The numbers in the scene
Section titled “The numbers in the scene”All metres; rotations are identity in this example, so the transposes vanish:
| quantity | clone 0 | clone 1 | what it is |
|---|---|---|---|
| the same world point | |||
| the clone (frozen body pose) | |||
| the same camera mount | |||
| feature in the IMU frame | |||
| feature in the camera frame | |||
| image | the normalized observation |
The projection-Jacobian chain
Section titled “The projection-Jacobian chain”The update linearizes the measurement , with the normalized projection . 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 framep_c = Ric.transpose() * (y - p_ic); // IMU -> camera framereturn p_c[2] > 0; // cheirality: must be in frontStage 2 — the projection derivative (, evaluated at ):
Stage 3 — chain-rule each state block (with , , ):
| state | Jacobian | code | |
|---|---|---|---|
| feature | J.Hf = dh * M | ||
| clone rotation | J.Htheta = dh * dpc_dtheta | ||
| clone position | assembled as -J.Hf | ||
| extrinsic rotation | J.Hext_theta | ||
| extrinsic translation | J.Hext_p |
The two non-obvious signs come from the right-perturbation convention of the state: the clone-rotation term picks up , and the clone-position block is additive, so — literally , the feature block negated. (Hold that thought.)
Stage 4 — stack into the big matrices (update). Each of the observations fills one 2-row strip of the feature Jacobian () and the state Jacobian (, 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 observable.
Stage 5 — marginalize the feature, then update. The feature was computed from these clones, so treating it as an independent measurement would be circular and make the filter over-confident. Marginalizing means eliminating — 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 by an orthonormal basis of ‘s left null space (, via a Householder QR of ) zeroes the feature columns and drops 3 rows; the noise stays . The surviving system constrains only the clones/extrinsics. It is then gated by a Mahalanobis test (a quantile) and applied via StateHelper::ekf_update.
Why the 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 . In one strip the feature contributes and the clone-position block contributes — they cancel exactly, for any . So translation is unobservable by construction. The yaw gauge has no such clean cancellation: its clone-rotation direction is , which depends on each clone’s estimate. Linearize different clones at differently-drifted and the 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.
Configuration & safety
Section titled “Configuration & safety”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.
Validated
Section titled “Validated”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.