Chapter 4 — Visual-Inertial Odometry: ORB-SLAM3 and cuVSLAM¶
Implements
Frontend (§1.5), and the whole B→C loop of §1.6. Consumes Frame plus a NavState prior, produces Feature[], Landmark[] and the projection Factor[] the backend consumes.
4.1 The measurement model¶
A 3D landmark \(\mathbf{X}_j\) in world coordinates, observed in camera \(i\):
Pinhole projection:
Fisheye (Kannala-Brandt, which ORB-SLAM3 supports natively): \(r = \sqrt{X^2+Y^2}\), \(\theta = \arctan(r/Z)\), \(\theta_d = \theta(1 + k_1\theta^2 + k_2\theta^4 + k_3\theta^6 + k_4\theta^8)\), then scale \([X,Y]\) by \(\theta_d/r\).
Reprojection Jacobian, factored through the chain rule:
The \(-f_x X/Z^2\) terms are why depth uncertainty dominates: a landmark at large \(Z\) contributes almost nothing to translation observability. This is the analytical reason stereo baselines matter and why monocular VO at long range is essentially a bearing-only problem.
4.2 The VI bundle adjustment objective¶
The joint MAP problem over a window of keyframes \(\mathcal{K}\), landmarks \(\mathcal{L}\):
Three terms: visual reprojection (with a robust kernel \(\rho\), typically Huber), the IMU preintegration factors built in Chapter 2 — this objective is where they are finally spent — and the marginalization prior carrying the information of everything already dropped from the window. The inertial term is what fixes metric scale and the gravity direction; without it the visual half alone is the \(Sim(3)\)-ambiguous problem described just below.
Gauge freedom. Vision-only monocular is invariant under \(Sim(3)\) — 7 unobservable DoF. Adding an IMU fixes scale and the gravity direction (roll and pitch), leaving 4 unobservable DoF: global position (3) and yaw (1). Fix the gauge with a prior factor on the first keyframe, or accept a rank-deficient Hessian and use a pseudo-inverse. Practically, an unfixed gauge shows up as a Hessian with 4 near-zero eigenvalues and an LM solver that behaves oddly at small \(\lambda\).
4.3 ORB-SLAM3 — architecture¶
Three concurrent threads plus the Atlas.
flowchart TB
classDef t1 fill:#31456b,stroke:#8ab4f8,color:#fff
classDef t2 fill:#6b3145,stroke:#f8a1b4,color:#fff
classDef t3 fill:#3d5b3d,stroke:#9ad49a,color:#fff
IN["stereo / mono / RGB-D + IMU"]
subgraph TRK["<b>FRONTEND</b> · tier 1 · per frame — Tracking::Track()"]
direction TB
EXT["ORBextractor — 8-level pyramid"]:::t1
PRE["PreintegrateIMU() → PredictStateIMU()"]:::t1
TWM["TrackWithMotionModel()<br/><i>SearchByProjection — guided</i>"]:::t1
TRK2["TrackReferenceKeyFrame()<br/><i>SearchByBoW — fallback</i>"]:::t1
REL["Relocalization()<br/><i>else CreateMapInAtlas()</i>"]:::t1
TLM["TrackLocalMap() → <b>PoseOptimization()</b><br/>or PoseInertialOptimizationLast{KeyFrame,Frame}<br/><i>1 pose, landmarks FIXED</i>"]:::t1
NKF{"NeedNewKeyFrame()"}
EXT --> PRE --> TWM
TWM -->|fail| TRK2 -->|fail| REL
TWM & TRK2 & REL --> TLM --> NKF
end
subgraph LMP["<b>BACKEND</b> · tier 2 · per keyframe — LocalMapping::Run()"]
direction TB
PNK["ProcessNewKeyFrame()"]:::t2
MPC["MapPointCulling()"]:::t2
CNM["CreateNewMapPoints() — triangulate"]:::t2
SIN["SearchInNeighbors()"]:::t2
LBA["<b>LocalBundleAdjustment()</b> — covisibility window<br/><b>LocalInertialBA()</b> — temporal, maxOpt 10/25<br/><i>poses AND landmarks move</i>"]:::t2
KFC["KeyFrameCulling()"]:::t2
IMUI["InitializeIMU() · ScaleRefinement()"]:::t2
PNK --> MPC --> CNM --> SIN --> LBA --> KFC
IMUI -.-> LBA
end
subgraph LCM["<b>BACKEND</b> · tier 3 · on loop — LoopClosing::Run()"]
direction TB
DET["NewDetectCommonRegions()<br/>DetectCommonRegionsFromBoW / FromLastKF"]:::t3
SIM["DetectAndReffineSim3FromLastKF()<br/>SearchAndFuse() · FindMatchesByProjection()"]:::t3
COR["<b>CorrectLoop()</b> → <b>OptimizeEssentialGraph()</b><br/><i>ALL poses, NO landmarks —<br/>points dragged by reference KF</i>"]:::t3
MRG["MergeLocal() / MergeLocal2()<br/><i>match in a non-active map</i>"]:::t3
GBA["<b>RunGlobalBundleAdjustment()</b><br/>GlobalBundleAdjustemnt / FullInertialBA<br/><i>separate thread — ALL poses AND landmarks</i>"]:::t3
DET --> SIM --> COR --> GBA
SIM -.-> MRG -.-> GBA
end
ATLAS[("<b>ATLAS</b> — active map + non-active maps 1..N<br/>KFs · MapPoints · covisibility graph<br/>spanning tree · DBoW2 database")]
IN --> EXT
NKF -->|yes| PNK
KFC --> DET
TRK <--> ATLAS
LMP <--> ATLAS
LCM <--> ATLAS
style ATLAS fill:#4a4a2b,stroke:#d4d49a,color:#fff
Colours are the tiers of §1.1: blue = per frame, landmarks fixed; red = per keyframe, landmarks move; green = on loop, whole map. The three subgraphs are ORB-SLAM3's three threads, which map onto frontend (Tracking) and backend (LocalMapping, LoopClosing).
What actually moves, at each tier
| Poses | Landmarks | |
|---|---|---|
PoseOptimization() |
1 — current frame | fixed, used as measurements |
LocalBundleAdjustment() / LocalInertialBA() |
window | free variables — each moves independently to fit all the keyframes that observe it |
OptimizeEssentialGraph() |
all | not variables at all — afterwards each point is rigidly dragged by its reference keyframe |
RunGlobalBundleAdjustment() |
all | free variables again |
The pose-graph row is the subtle one, and the code states it plainly:
KeyFrame* pRefKF = pMP->GetReferenceKeyFrame();
g2o::Sim3 Srw = vScw[nIDr]; // reference KF, before
g2o::Sim3 correctedSwr = vCorrectedSwc[nIDr]; // reference KF, after
eigCorrectedP3Dw = correctedSwr.map(Srw.map(eigP3Dw));
pMP->SetWorldPos(eigCorrectedP3Dw.cast<float>());
A point is pushed into its reference keyframe's old frame and pulled back out through the corrected one. It moves rigidly with that one keyframe, so the map does not tear apart — but its geometry is never re-fitted. That is precisely the information the pose graph discarded, and precisely what the full BA afterwards exists to restore.
The four things that make ORB-SLAM3 what it is:
-
MAP-based inertial initialization, in three stages: (a) vision-only MAP to get an up-to-scale map, (b) inertial-only MAP which jointly estimates scale, gravity direction, velocities and biases treating the visual trajectory as fixed, (c) joint visual-inertial MAP refinement. In the source these are three overloads of one function —
InertialOptimization(pMap, Rwg, scale, bg, ba, bMono, covInertial, …)for the full inertial-only stage, then(pMap, bg, ba)and(pMap, Rwg, scale)for the cheaper refinements — and the full one returnscovInertial, so the initialization reports its own uncertainty rather than a point estimate. Converges in ~2 s versus tens of seconds for the classical closed-form-plus-refinement approaches. The insight is that scale and gravity should be estimated with their uncertainty, not solved in closed form and then hoped about. -
Atlas / multi-map. When tracking is lost, rather than trying to relocalize forever in the current map, start a new active map and keep the old one. Place recognition runs against all maps; a match in a non-active map triggers map merging rather than a loop closure. This makes the system robust to kidnapping and to genuinely disconnected sessions.
-
Improved place recognition with local-window verification. Classical DBoW2 requires temporal consistency across several consecutive frames before accepting a candidate, which costs recall and delays closure. ORB-SLAM3 instead checks a candidate immediately against a local window of its covisible keyframes and their map points, requiring geometric consistency in 3D. Higher recall, faster closure — and recall matters more than precision here only because the geometric check is strong enough to keep precision high.
-
Covisibility graph + essential graph. Local BA optimizes only the covisible set (bounded cost, independent of map size). Loop closure runs pose-graph optimization over the sparser essential graph (strong covisibility edges + spanning tree + loop edges) rather than full BA, then spawns full BA in a separate thread so tracking never blocks.
Tracking pseudocode:
track(frame, imu_measurements):
ORB_extract(frame) # 8-level pyramid, FAST, rBRIEF
if IMU_initialized:
predicted_pose = preintegrate(last_state, imu_measurements)
else:
predicted_pose = constant_velocity_model(last_pose)
ok = track_with_motion_model(predicted_pose) # guided matching
if not ok:
ok = track_reference_keyframe() # BoW matching
if not ok:
ok = relocalize() # DBoW2 + PnP RANSAC
if not ok:
if IMU_initialized and time_lost < THRESH:
continue_on_IMU_dead_reckoning()
else:
atlas.create_new_map() # ← Atlas
return
track_local_map() # project local MapPoints, match, pose-only BA
if need_new_keyframe(): # tracked-points ratio, elapsed time,
create_keyframe() # IMU-driven frequency when uninitialized
4.4 cuVSLAM¶
NVIDIA's GPU visual-inertial system, the engine under isaac_ros_visual_slam, and directly relevant if the target stack is Isaac ROS + Nav2 + nvblox on Jetson.
Open source since 2026
cuVSLAM is fully open source at github.com/nvidia-isaac/cuVSLAM — 252 .cpp/.cu and 259 header files, no binary blob. Everything below is traced from that source.
The licence is the NVIDIA Community License, and its restriction is platform, not source: the works are licensed for use on NVIDIA platforms. Commercial use, modification and redistribution are permitted on that hardware. So the contrast with ORB-SLAM3 is not open-vs-closed — both are readable and modifiable — it is copyleft-on-any-hardware (GPLv3) versus permissive-on-NVIDIA-hardware. Prebuilt wheels and packages are published as a convenience, not as the only form of distribution.
Supported configurations, per the repository:
| Mode | Configuration |
|---|---|
| Monocular | 1 camera — scale-ambiguous |
| RGB-D | 1 RGB-D camera with aligned depth |
| Multicamera | ≥2 cameras with an overlapping pair, up to 32 cameras total |
| Inertial | stereo pair + 1 IMU |
| Multisensor | ≥1 RGB-D or overlapping pair, optional IMU (experimental) |
Platforms are Ubuntu 22.04/24.04 on x86_64 and aarch64, Jetson Orin (JetPack 6.x, CUDA 12) and Jetson Thor (JetPack 7.x, CUDA 13). APIs are C++ and Python — PyCuVSLAM ships prebuilt wheels for Python 3.10+, which makes it far easier to benchmark against ORB-SLAM3 or OpenVINS than it used to be.
The release also exposes cuNLS, NVIDIA's CUDA-accelerated nonlinear least-squares solver, built from source via FetchContent and bundled into libcuvslam (USE_CUNLS=ON by default). It targets exactly the workloads of Chapter 3 — bundle adjustment, pose-graph optimization, ICP-style alignment — which makes it the first credible GPU alternative to Ceres/g2o/GTSAM for the backend, not just the frontend. If you have been assuming the GPU only helps with feature extraction, that assumption is now out of date.
flowchart TB
classDef t0 fill:#6b5b31,stroke:#f8d48a,color:#fff
classDef t1 fill:#31456b,stroke:#8ab4f8,color:#fff
classDef t2 fill:#6b3145,stroke:#f8a1b4,color:#fff
classDef t3 fill:#3d5b3d,stroke:#9ad49a,color:#fff
classDef ext fill:#4a316b,stroke:#b48af8,color:#fff
subgraph PF["PER FRAME — MultiVisualOdometryBase::track()"]
direction TB
PRED["<b>1.</b> do_predict() — pose prediction<br/><i>odometry/pose_prediction</i>"]
T2D["<b>2. "2D tracking stage"</b><br/>feature_tracker→trackNextFrame()<br/><i>sof/: image_pyramid → gftt (Shi-Tomasi)<br/>→ klt_tracker · lk_tracker · st_tracker</i>"]
KFSEL["keyframe decision → FrameState::Key<br/><i>sof/kf_selector.cpp</i>"]
PRED --> T2D --> KFSEL
end
subgraph SNF["<b>3. "PnP stage"</b> — SolverSfMInertial::solveNextFrame()"]
direction TB
GATE["check_imu_drops()"]
ASSOC["associate tracks ↔ map landmarks<br/>map_.get_recent_landmarks()<br/>setLocation3D(·, kTriangulated)"]
PNPSEL{"IMU state == Ok?"}
IPNP["runInertialPnP() — uses gravity + prev_pose<br/><i>pipelines/inertial_pnp</i>"]
SPNP["runStereoPnP()<br/><i>pnp/multicam_pnp</i>"]
SMU["imu_sm_.update_frame_state()<br/><i>pipelines/tracker_state_machine</i>"]
GATE --> ASSOC --> PNPSEL
PNPSEL -->|yes| IPNP --> SMU
PNPSEL -->|no| SPNP --> SMU
end
subgraph KFP["ON KEYFRAME ONLY"]
direction TB
TRI["triangulator.triangulate(world_from_rig, obs)<br/><i>pipelines/triangulator</i>"]
ADDKF["map_.add_keyframe()<br/><i>map/UnifiedMap</i>"]
TRI --> ADDKF
end
subgraph IMUB["INERTIAL — libs/imu"]
direction TB
PRE["<b>IMUPreintegration</b><br/>dR, dV, dP · Σ 9×9 · 5 bias Jacobians"]
INIT["gravity / bias init — optimize_inertial()<br/>SolveGyroBias → SolveGravityDirection<br/>→ LinearAlignment → RefineGravity"]
PRE --> INIT
end
subgraph SBAS["ASYNC SBA SERVICE — chosen by sba::Mode"]
direction TB
ISBA["<b>ImuSbaGPUService / ImuSbaCPUService</b><br/>joint visual + inertial BA<br/>ImuBAProblem · Schur complement · cuNLS"]
OSBA["GpuSbaService / CpuSbaService — vision only<br/><i>sba/schur_complement_bundler_{cpu,gpu}</i>"]
end
subgraph BE["BACKEND — asynchronous"]
direction TB
ASLAM["async SLAM<br/><i>slam/async_slam</i>"]
LCS["loop-closure solver + RANSAC<br/><i>slam/slam/loop_closure_solver</i>"]
LOCZ["async localizer · LocalizeInMap · SaveMap"]
ASLAM --> LCS
ASLAM --> LOCZ
end
IMGS["ImageSet — 1..32 cameras"] --> PRED
IMUM["ImuMeasurement"] --> PRE
KFSEL --> GATE
PRE -.->|"prior for prediction"| PRED
INIT --> PNPSEL
SMU -->|"PoseEstimate — Odometry::Track()"| ODO(("odom → base_link<br/>smooth, high rate"))
SMU -->|"is_keyframe"| TRI
ADDKF --> ISBA
ADDKF --> OSBA
PRE --> ISBA
ISBA -.->|"new bias → first-order patch,<br/>or Reintegrate() past 1e-4"| PRE
ADDKF --> ASLAM
LCS -->|"map → odom"| MAPO(("map → odom<br/>discrete corrections"))
style PRE fill:#31456b,stroke:#8ab4f8,color:#fff
style ISBA fill:#6b3145,stroke:#f8a1b4,color:#fff
style T2D fill:#31456b,stroke:#8ab4f8,color:#fff
style PRE fill:#6b5b31,stroke:#f8d48a,color:#fff
style PRED fill:#31456b,stroke:#8ab4f8,color:#fff
style T2D fill:#31456b,stroke:#8ab4f8,color:#fff
style KFSEL fill:#31456b,stroke:#8ab4f8,color:#fff
style GATE fill:#31456b,stroke:#8ab4f8,color:#fff
style ASSOC fill:#31456b,stroke:#8ab4f8,color:#fff
style IPNP fill:#31456b,stroke:#8ab4f8,color:#fff
style SPNP fill:#31456b,stroke:#8ab4f8,color:#fff
style SMU fill:#31456b,stroke:#8ab4f8,color:#fff
style TRI fill:#6b3145,stroke:#f8a1b4,color:#fff
style ADDKF fill:#6b3145,stroke:#f8a1b4,color:#fff
style ISBA fill:#6b3145,stroke:#f8a1b4,color:#fff
style OSBA fill:#6b3145,stroke:#f8a1b4,color:#fff
style INIT fill:#6b3145,stroke:#f8a1b4,color:#fff
style ASLAM fill:#3d5b3d,stroke:#9ad49a,color:#fff
style LCS fill:#3d5b3d,stroke:#9ad49a,color:#fff
style LOCZ fill:#3d5b3d,stroke:#9ad49a,color:#fff
Traced from source
Read from MultiVisualOdometryBase::track() and SolverSfMInertial::solveNextFrame(), not from the paper. The stage names are the code's own: its failure paths log "Failed to track on the 2D tracking stage" and "Failed to track on the PnP stage".
What v17.0.0 changed. The skeleton holds, but the inertial path was refactored out of the solver into a dedicated ImuFusionContext (libs/pipelines/imu_fusion_context.h), which now owns add_measurement(), solve_inertial() / solve_visual() as explicit alternatives, and finalize_frame() → FrameResult{valid_pose, lost} — so "PnP succeeded", "IMU carried the frame" and "both failed" became distinct outcomes rather than a boolean. Also new: track_lifter (the keyframe step is now a single triangulate_and_lift trace event), depth_maps_context for RGB-D and multisensor rigs, track_online_multisensor for the mixed RGB/RGB-D + optional IMU mode, and gyro_bias_nec — a bearing-vector gyro-bias estimator used when there are too few keyframes, or the motion is static or pure rotation and the linear method degenerates. cuNLS is enabled by default.
Three details are worth pulling out, because each contradicts the obvious guess:
- Triangulation happens after PnP, and only on keyframes. Per frame, 3D points are not created — they are fetched:
map_.get_recent_landmarks()marks trackskTriangulated, and PnP resects against those. Only once a frame is accepted as a keyframe doestriangulator.triangulate()create new landmarks. Frame-rate work stays resection-only. - RANSAC is not in the per-frame path. In this tree the RANSAC implementations live under
slam/slam/loop_closure_solverandslam/async_localizer— loop closure and relocalization, where a false match is catastrophic. Per-frame outlier handling is carried by the tracker and the robust costs in the bundler instead. - The gauge is fixed through the information matrix, not a prior factor. On the first run,
prev_pose.infois set to a diagonal with \(10^6\) on the first six entries, commented "first pose is fixed, but velocities and biases are free" — the anchoring of §4.2, done by weighting rather than by adding a factor.
| Module | Role |
|---|---|
libs/sof |
sparse optical flow: image pyramids, gftt (Shi-Tomasi), KLT/LK/ST trackers, kf_selector |
libs/odometry |
per-mode odometry — mono, multi, RGB-D, stereo-inertial, multisensor; pose prediction; ground constraint |
libs/pnp |
mono / multicam PnP, visual ICP, multisensor pose estimator |
libs/imu |
preintegration, inertial SBA (CPU + GPU), gravity/bias initialization, gyro_bias_nec fallback |
libs/sba |
Schur-complement bundler, CPU and GPU |
libs/refinement |
cost functions (pinhole, rational polynomial) and robust losses |
libs/epipolar |
fundamental matrix, homography, triangulation, resectioning utilities |
libs/map |
UnifiedMap: keyframes, landmarks, depth-point and plane maps |
libs/slam |
async SLAM, loop-closure solvers (with RANSAC), localizer |
libs/pipelines |
orchestration — track_online_{mono,multi,rgbd,inertial,multisensor}, ImuFusionContext, track_lifter, depth_maps_context, state machine, async SBA service |
The per-frame settings struct decomposes along exactly these stages — TrackPerFrameSettings has sof, kf, pnp and icp sub-structs, one per box in the frontend row.
Reported performance: average trajectory error below 1% on KITTI odometry and mean position error under 5 cm on EuRoC, running in real time on Jetson. Deployed processing 8 Full-HD distorted RGB images at 30 FPS from 4 stereo cameras on a Jetson Orin AGX within the Isaac Perceptor framework. Multi-camera mode gives two documented benefits: trajectory reliability in feature-poor environments, and higher loop-closure detection rates. A demonstrated robustness test covered cameras randomly with opaque film for 20–60 s intervals with at least one stereo pair uncovered, and tracking survived.
The public API is the §1.2 split, shipping¶
Two classes, and they are exactly the frontend/backend division of Chapter 1 — which is the strongest evidence that split is not just a pedagogical device:
class Odometry { // frontend — Ch.1 domains A–C
PoseEstimate Track(const ImageSet& images,
const ImageSet& masks = {},
const ImageSet& depths = {});
void RegisterImuMeasurement(uint32_t sensor_index, const ImuMeasurement&);
void GetState(State& state) const;
};
class Slam { // backend — Ch.1 domain D
void Track(const Odometry::State& state, const Pose* gt_pose = nullptr);
Pose GetPose() const;
void SaveMap(folder, callback);
void LocalizeInMap(folder, timestamp_ns, guess_pose, ...);
void GetLoopClosurePoses(std::vector<PoseStamped>&);
};
Slam::Track() takes an Odometry::State, so that struct is the wire between the two — the concrete instance of §1.3's contract:
Odometry::State field |
Corresponds to |
|---|---|
Pose delta — change since last keyframe |
the relative odometry constraint, never a global pose |
bool keyframe |
the keyframe-selector decision, i.e. the domain B → C boundary of §1.4 |
std::optional<Gravity> gravity |
present only in Inertial/Multisensor with an IMU — the gravity direction that §4.2 says inertial data makes observable |
std::vector<Observation> observations |
Feature |
std::vector<Landmark> landmarks |
Landmark |
ContextMap context |
opaque, backend-internal |
Two details reward attention. Slam exposes its PoseGraph as explicit nodes and edges — the pose-graph optimization of Chapter 3, not hidden behind an opaque handle. And v17.0.0 "split Slam::Track() into a void tracking call and Slam::GetPose()": the backend no longer makes the caller wait for a pose, which is precisely the domain-D-must-not-block rule of §1.4, learned the same way everyone learns it.
Version 17.0.0 (2026-07-21) enables cuNLS by default and adds the Multisensor mode — any mix of RGB and RGB-D cameras with an optional IMU, which requires a -DUSE_CUNLS=ON build and currently supports pinhole cameras only.
Practical guidance from the NVIDIA docs that matters for a real rig: synchronization significantly affects performance and should ideally be hardware-based with verified relative timestamps across cameras; VGA or higher resolution is recommended; 30 FPS suits typical human-speed motion; and image quality — lenses, exposure, white balance — matters because clipped regions destroy features.
That last cluster is your hardware-integration territory. The XVS master/slave sync on global-shutter sensors and FC-triggered capture is exactly what "ideally through hardware synchronization" means, and being able to say why — that a 10 ms inter-camera skew at 1 m/s injects a 1 cm baseline error that the triangulation attributes to depth — is a much stronger statement than "I set up the cameras."
4.5 VINS-Fusion — architecture¶
Traced from vins/src/{featureTracker,estimator,factor,initial} and loop_fusion/src. Structurally it is the odd one out: three separate ROS nodes, not three threads in one process, and the split is where the interesting design decisions sit.
flowchart TB
classDef t0 fill:#6b5b31,stroke:#f8d48a,color:#fff
classDef t1 fill:#31456b,stroke:#8ab4f8,color:#fff
classDef t2 fill:#6b3145,stroke:#f8a1b4,color:#fff
classDef t3 fill:#3d5b3d,stroke:#9ad49a,color:#fff
classDef ext fill:#4a316b,stroke:#b48af8,color:#fff
subgraph VN["<b>vins</b> node — rosNodeTest.cpp"]
direction TB
subgraph FTS["feature tracker"]
FT["trackImage()<br/>Shi-Tomasi goodFeaturesToTrack + KLT<br/>calcOpticalFlowPyrLK, fwd-bwd check<br/><i>featureTracker/feature_tracker.cpp</i>"]
end
subgraph ES["estimator — processMeasurements() thread"]
direction TB
GIMU["getIMUInterval(prevTime, curTime)"]
PIMU["processIMU() → IntegrationBase::push_back()<br/><b>midPointIntegration</b><br/><i>factor/integration_base.h</i>"]
PIMG["processImage() → FeatureManager<br/>triangulate · outliersRejection"]
SF{"SolverFlag"}
INITS["<b>INITIAL</b>: initialStructure()<br/>relativePose (solve_5pts) → initial_sfm<br/>→ visualInitialAlign()<br/>gyro bias · gravity · scale · velocity<br/>+ initial_ex_rotation (online cam↔IMU)"]
OPT["<b>NON_LINEAR</b>: optimization() — Ceres<br/>IMUFactor · ProjectionTwoFrame*Factor<br/>MarginalizationFactor"]
SW["slideWindow()<br/>MARGIN_OLD | MARGIN_SECOND_NEW"]
GIMU --> PIMU --> PIMG --> SF
SF -->|not initialized| INITS --> OPT
SF -->|initialized| OPT --> SW
end
FASTP["<b>inputIMU()</b> → fastPredictIMU()<br/>→ pubLatestOdometry()<br/><i>IMU-rate output — the §2.9 predictor</i>"]
end
subgraph LF["<b>loop_fusion</b> node — separate process"]
direction TB
KFDB["KeyFrame + BRIEF descriptors<br/>DBoW2 <i>(ThirdParty/DBoW)</i>"]
DET["detectLoop() → PnP RANSAC verification"]
PG4["<b>optimize4DoF()</b> — x, y, z, yaw<br/><i>loop_fusion/src/pose_graph.cpp</i>"]
KFDB --> DET --> PG4
end
subgraph GF["<b>global_fusion</b> node"]
GPS["GPS / GNSS fused into a global pose graph"]
end
IMU(("IMU")) --> FASTP
IMU --> GIMU
CAM(("camera(s)")) --> FT --> PIMG
FASTP -->|"odometry @ IMU rate"| CTRL(("controller"))
SW -->|"keyframe + VIO pose"| KFDB
PG4 -->|"drift-corrected pose,<br/>yaw_drift + shift_r"| GPS
SW -->|"window pose"| GPS
style PIMU fill:#31456b,stroke:#8ab4f8,color:#fff
style OPT fill:#6b3145,stroke:#f8a1b4,color:#fff
style FASTP fill:#6b3145,stroke:#f8a1b4,color:#fff
style PIMU fill:#6b5b31,stroke:#f8d48a,color:#fff
style FASTP fill:#6b5b31,stroke:#f8d48a,color:#fff
style FT fill:#31456b,stroke:#8ab4f8,color:#fff
style GIMU fill:#31456b,stroke:#8ab4f8,color:#fff
style PIMG fill:#31456b,stroke:#8ab4f8,color:#fff
style INITS fill:#6b3145,stroke:#f8a1b4,color:#fff
style OPT fill:#6b3145,stroke:#f8a1b4,color:#fff
style SW fill:#6b3145,stroke:#f8a1b4,color:#fff
style KFDB fill:#3d5b3d,stroke:#9ad49a,color:#fff
style DET fill:#3d5b3d,stroke:#9ad49a,color:#fff
style PG4 fill:#3d5b3d,stroke:#9ad49a,color:#fff
style GPS fill:#4a316b,stroke:#b48af8,color:#fff
Four decisions that distinguish it.
-
Loop closure is a different process. ORB-SLAM3 runs it as a thread sharing the Atlas; cuVSLAM as an async service sharing the map. VINS ships
loop_fusionas its own node, communicating over topics. The estimator therefore cannot be blocked by loop closure at all — the strongest possible form of the domain-D rule in §1.4 — at the cost of duplicating keyframe state across a process boundary. -
fastPredictIMU()on every IMU sample.inputIMU()calls it and immediately publishespubLatestOdometry(). This is the output predictor of §2.9 as a first-class citizen, which is why VINS is the one of the three you can hand directly to a flight controller. Neither of the others publishes at IMU rate. -
Two marginalization modes.
MARGIN_OLDdrops the oldest keyframe and folds it into the prior;MARGIN_SECOND_NEWthrows away the second-newest frame instead, keeping the window's time span when the newest frame carries little parallax. That choice is made per frame from the tracked-feature parallax — the fixed-lag machinery of §3.3 with a heuristic on top. -
Online extrinsic calibration.
initial_ex_rotationestimates the camera↔IMU rotation during initialization. The others require it from a calibration file. If your rig's extrinsics are uncertain — a compliant mount, a field-swapped camera — this alone can decide the choice.
Initialization is loosely coupled, unlike ORB-SLAM3's MAP formulation: a pure-visual SfM (relativePose via the 5-point algorithm, then initial_sfm) is solved first, and visualInitialAlign() then aligns inertial quantities to that fixed visual trajectory — solving gyro bias, gravity direction, metric scale and per-frame velocities as a linear problem. cuVSLAM's SolveGyroBias/LinearAlignment follows the same recipe and says so in its comments; ORB-SLAM3 is the one that replaced it with a MAP estimate carrying covariance.
4.6 Contrast: MSCKF, the filter-based alternative¶
Worth knowing because OpenVINS and many commercial VIOs use it, and because the null-space trick is elegant.
State = IMU state + a sliding window of past camera poses. Landmarks are not in the state. For a feature observed in \(M\) poses, stack the residuals:
Let \(\mathbf{N}\) be a basis for the left null space of \(\mathbf{H}_f\) (i.e. \(\mathbf{N}^\top\mathbf{H}_f = \mathbf{0}\)). Project:
The landmark is gone. You get a landmark-free EKF update whose cost is linear in window size rather than quadratic in landmark count. The price is that the landmark is marginalized by projection, so its information is used once and discarded — no map is built, hence no loop closure.
Consistency. Naive MSCKF gains spurious information along the 4 unobservable directions, because the same state is linearized at different points across timesteps, artificially raising the rank of the observability matrix. Fixes: FEJ (fix each state's linearization point at its first estimate) or OC-EKF (project Jacobians onto the correct nullspace). The consequence of not fixing it is an overconfident covariance — which is worse than an inaccurate one, because everything downstream trusts it.
4.7 Comparison¶
One table, traced from the three source trees rather than the three papers: ORB-SLAM3 (src/ImuTypes.cc, Tracking.cc, LocalMapping.cc, LoopClosing.cc, include/Optimizer.h), cuVSLAM v17.0.0 (libs/{imu,pipelines,sof,sba,slam}), VINS-Fusion (vins/src/{factor,estimator,initial}, loop_fusion/src).
| ORB-SLAM3 | cuVSLAM | VINS-Fusion | |
|---|---|---|---|
| Process model | 3 threads + Atlas, one process | frontend + async SBA service + async SLAM | 3 ROS nodes, separate processes |
| Preintegration class | IMU::Preintegrated |
sba_imu::IMUPreintegration |
IntegrationBase |
| Integration scheme | Euler — dP/dV use the not-yet-updated dR |
Euler — identical | Midpoint — midPointIntegration() |
| Rotation storage | Matrix3f dR + NormalizeRotation() |
Matrix3T dR + CalculateRotationFromSVD() |
Quaterniond delta_q |
| Precision | float |
float |
double |
| Bias Jacobians | JRg, JVg, JVa, JPg, JPa |
same five names | blocks of a 15×15 jacobian |
| Covariance | Matrix<float,15,15> C |
Matrix9T + separate 3×3 random-walk blocks |
Matrix<double,15,15> covariance |
| Re-integration | Reintegrate() |
Reintegrate() past reintegration_thresh = 1e-4 |
repropagate(ba, bg) |
| Frontend | ORB, 8-level pyramid (ORBextractor) |
GFTT/Shi-Tomasi + KLT/LK/ST on GPU (libs/sof) |
Shi-Tomasi + KLT (feature_tracker) |
| Per-frame 3D work | project local map, pose-only BA | resection only — landmarks fetched from map, triangulation deferred to keyframes | triangulate in FeatureManager |
| Tracking-time optimizer | PoseOptimization, PoseInertialOptimizationLast{KeyFrame,Frame} — g2o, landmarks fixed |
runStereoPnP / runInertialPnP (v17: solve_visual() / solve_inertial()) — resection, landmarks fixed |
none — initFramePoseByPnP() only seeds the window solve |
| Local optimization | g2o LocalBundleAdjustment / LocalInertialBA (bLarge flag) — separate thread, per keyframe |
async SBA service, ImuBAProblem — per keyframe |
Ceres optimization() — one window BA per frame, same thread |
| Marginalization | Optimizer::Marginalize(H, start, end) |
inside the bundler | marginalization_factor, MARGIN_OLD / MARGIN_SECOND_NEW |
| Gauge fixing | fixed keyframes in g2o | prev_pose.info = 1e6 on first 6 entries |
marginalization prior |
| Global refinement | GlobalBundleAdjustemnt, FullInertialBA (priorG=1e2, priorA=1e6; returns singular values / Hessian) |
async pose graph — no full BA | none — pose graph only |
| Pose-graph variant | OptimizeEssentialGraph, OptimizeSim3 (mono), OptimizeEssentialGraph4DoF |
pose graph, nodes + edges | optimize4DoF() |
| Inertial init | three InertialOptimization overloads — MAP, returns covInertial |
SolveGyroBias → SolveGravityDirection → LinearAlignment → RefineGravity |
initialStructure() → SfM → visualInitialAlign() |
| Landmarks in state | yes (map) | yes (map) | yes (window) |
| Loop closure | LoopClosing thread + Atlas merge |
slam/async_slam + loop_closure_solver (RANSAC here) |
separate loop_fusion node |
| Multi-map / merge | Atlas, MergeInertialBA |
map save/load, LocalizeInMap |
none |
| High-rate output | — | Odometry::Track() per frame |
fastPredictIMU() at IMU rate |
| Also solved online | — | — | camera↔IMU extrinsics (para_Ex_Pose) and time offset (para_Td), inside the same solve |
| Hardware | CPU, multi-thread | GPU / Jetson (Orin, Thor) | CPU |
| Licence | GPLv3 (copyleft, any hardware) | NVIDIA Community License — full source, use on NVIDIA platforms | GPLv3 |
The licence row is not a footnote — it is often the actual decision driver in a commercial robot. All three are source-available; the question is what each obliges you to do. GPLv3 propagates to derivative works whatever the hardware; the NVIDIA Community License is permissive about derivatives but confines them to NVIDIA platforms.
Local BA is not shared either. It is tempting to assume the tier-2 solve is the one place all three agree, since they all "run local bundle adjustment". They do not agree on any of the five things that define one:
| ORB-SLAM3 | cuVSLAM | VINS-Fusion | |
|---|---|---|---|
| Trigger | new keyframe | new keyframe | every image frame |
| Thread | LocalMapping thread |
async SBA service | the estimator thread itself |
| Window | covisibility for the visual BA (GetVectorCovisibleKeyFrames); temporal for LocalInertialBA — maxOpt = 10, or 25 when bLarge |
map window with num_fixed_key_frames at the boundary |
fixed temporal sliding window (~10) |
| How old states leave | KeyFrameCulling() — redundant keyframes deleted outright, no prior kept |
frozen: fixed keyframes, downweighted by boundary_imu_penalty |
marginalization → a MarginalizationFactor prior |
| Landmark parameters | 3-DoF XYZ |
3-DoF XYZ (std::vector<Vector3T> points) |
1-DoF inverse depth — SIZE_FEATURE = 1, anchored at the first observing frame |
| Solver | g2o | Schur bundler, CPU/GPU, cuNLS | Ceres |
Three of those are worth dwelling on.
ORB-SLAM3 changes its own window definition when inertial data appears. The visual local BA is covisibility-based — optimize the keyframes that see what this one sees, which follows the structure of the scene. LocalInertialBA cannot do that, because IMU factors chain keyframes in time: an inertial residual only exists between temporally adjacent keyframes, so the window has to be a temporal one. The bLarge flag mentioned above is exactly maxOpt going from 10 to 25.
Only VINS keeps a marginalization prior. ORB-SLAM3 culls redundant keyframes and keeps no record of them, and cuVSLAM freezes boundary keyframes rather than eliminating them. VINS is the only one paying the §3.3 cost — a dense prior over the Markov blanket — and the only one that therefore keeps the discarded information. That is the classic fixed-lag-smoother trade: ORB-SLAM3 throws information away to stay sparse; VINS keeps it and accepts fill-in.
Inverse depth is a real difference, not a detail. A VINS landmark is one scalar anchored at its first observing frame; an ORB-SLAM3 or cuVSLAM landmark is three. That makes VINS's Hessian much smaller for the same feature count, keeps distant points well-conditioned (an inverse depth near zero is perfectly representable, an XYZ at infinity is not), and is part of why a full window BA every frame is affordable at all.
Two tiers, or one. The pose-optimization / bundle-adjustment split is not universal, and where a system draws it says a lot about its threading:
- ORB-SLAM3 and cuVSLAM are two-tier. A cheap per-frame step with landmarks fixed —
PoseOptimizationin g2o, or a PnP resection — runs in the tracking path, and an expensive per-keyframe BA that moves landmarks runs in another thread or an async service. The frame budget is protected by construction. - VINS-Fusion is one-tier.
initFramePoseByPnP()only seeds an initial pose; there is no pose-only optimizer.optimization()is a single Ceres problem over the whole sliding window — poses, velocities, biases, inverse depths (para_Feature), extrinsics and time offset — and it runs every image frame, in the estimator thread.
That explains VINS's small window (~10 frames): a full BA has to fit inside the frame period, so the window is sized by the compute budget rather than by how much history is useful. It also explains why VINS can calibrate para_Ex_Pose and para_Td online while the others cannot — those variables are only observable in a joint solve, and VINS is running one continuously.
The finding worth the trip. cuVSLAM's preintegration is not merely a Forster implementation — it is line-for-line ORB-SLAM3's, down to the member names and the statement order:
// ORB-SLAM3, src/ImuTypes.cc // cuVSLAM, libs/imu/imu_preintegration.cpp
dP = dP + dV*dt + 0.5f*dR*acc*dt*dt; dP += dV*dt + 0.5f*dR*lin_acc*dt*dt;
dV = dV + dR*acc*dt; dV += dR*lin_acc*dt;
JPa = JPa + JVa*dt - 0.5f*dR*dt*dt; JPa += JVa*dt - 0.5f*dR*dt*dt;
JVa = JVa - dR*dt; JVa -= dR*dt;
JRg = dRi.deltaR.transpose()*JRg JRg = deltaR.transpose()*JRg
- dRi.rightJ*dt; - rightJ*dt;
dR = NormalizeRotation(dR*dRi.deltaR); dR = CalculateRotationFromSVD(dR*deltaR);
ORB-SLAM3 even carries the comment "rely on no-updated delta rotation" on the dP/dV lines — the ordering constraint of §2.6, stated in the code.
Two implementations, one conclusion about gauge. ORB-SLAM3 exposes OptimizeEssentialGraph4DoF; VINS-Fusion's loop-closure node runs optimize4DoF() and prints "perfrom 4 DoF (x, y, z, yaw) pose graph optimization". Neither borrowed it from the other. Both restrict loop-closure pose-graph optimization to four degrees of freedom because the IMU makes roll and pitch observable, leaving only position and yaw free — which is exactly the gauge-freedom count of §4.2, arrived at independently and encoded in a function name. When a monocular map has no inertial data, ORB-SLAM3 falls back to OptimizeSim3, because scale is unobservable again and the seventh DoF returns.
Two further details from Optimizer.h worth knowing. FullInertialBA takes explicit bias priors (priorG = 1e2, priorA = 1e6 — accelerometer bias trusted two orders of magnitude less, since it is the harder one to observe) and can hand back singular values and a Hessian flag, which is how the system checks whether the inertial problem was actually well-conditioned before accepting an initialization. And Optimizer::Marginalize(H, start, end) is the Schur complement of §3.3 written out as a standalone matrix routine.
VINS-Fusion is the genuine dissenter, and every difference is a deliberate trade:
- Midpoint over Euler.
midPointIntegration()averages consecutive gyro and accel samples. More accurate per step at the same rate — the free win §2.8 recommends — at the cost of departing from Forster as published. - Quaternion over rotation matrix.
delta_qupdated by a small-angle quaternion, so drift off the manifold is fixed by normalization rather than by an SVD projection. doubleoverfloat. Roughly 2× the memory traffic, which is exactly the wrong trade on a GPU — and is why the two GPU-adjacent implementations chosefloat.- One 15×15 Jacobian block matrix rather than five named 3×3 members: more general, less readable.
- It ships the two things the others lack for a real vehicle —
fastPredictIMU(), the output predictor of §1.5/§2.9 that gives the controller a state now, andinitial_ex_rotation, which calibrates the camera↔IMU rotation online.
Read together: ORB-SLAM3 defined the reference implementation, cuVSLAM adopted it and moved the surrounding pipeline onto the GPU, and VINS-Fusion re-derived the numerics with different trade-offs and paid more attention to what a flight controller actually needs downstream.