Skip to main content

Architecture

Vision Navigation is built around a single design rule: the camera, the IMU, the rangefinder, and the estimator are independent pieces. Any estimator runs on any combination of camera, IMU, and optional rangefinder; a new estimator plugs in behind a stable contract without rewiring the plugin. The agent half is a compiled Rust binary. The plugin host execs it directly under systemd, hands it the per-plugin socket on the command line, and passes the capability token and agent id through the unit environment. The binary subscribes to the agent’s shared vision frame bus instead of opening a camera itself. Operator-facing behaviour is unchanged from earlier releases; this page documents the shape for developers who want to add a new estimator, extend the calibration loader, or audit the data flow.

Module map (agent half)

The agent crate lives at extensions/vision-nav/agent/. The hot path (frame consumption, optical-flow tracking, the six-mode degradation state machine, the scale ladder, the pre-arm gate, the MAVLink component router, TIMESYNC clock alignment, IMU sourcing, rangefinder drivers, the VIO vendor-binary bridge, and the heartbeat snapshot) is Rust.
The estimator, the scale source, the rangefinder, and the IMU source each sit behind a Rust trait. Adding a new IMU source (DroneCAN over a USB-CAN adapter, for example) is a new type that implements the IMU contract. The one-time camera-IMU calibration stays Python. It is a heavyweight, infrequent, OpenCV-bound flow that runs once per camera mount, far off the 30 Hz pose-emit path, so it is not on the Rust hot path. It lives at calibration-helper/ and produces a Kalibr-style camchain.yaml that the Rust agent reads at start-up.

The EstimatorOutput contract

Every estimator answers step(...) with one EstimatorOutput:
An optical-flow sample fills the flow_* fields and leaves the VIO fields None; a VIO sample fills pose / velocity / covariance and leaves the flow fields None. The component router reads output_mode and dispatches to either the OF emitter (component 198, OPTICAL_FLOW_RAD) or the VIO emitter (component 197, VISION_POSITION_ESTIMATE). A hybrid tick carries a co-emitted OF sample in extras_of so one tick fires on both components.
The simplified flow for one frame in optical_flow mode:
In vio_openvins mode the path changes at step 4: the worker bridges the frame into the VIO engine’s shared-memory ring and hands IMU samples to the engine over its UDS control channel. The vendor binary returns pose messages asynchronously; the estimator drains them per tick and fills the pose six-tuple. The router emits on component 197 instead of 198.

Calibration (separate Python helper)

The Rust agent does not run calibration in its hot loop. The one-time camera-IMU calibration is a separate Python package at calibration-helper/ (altnautica_vision_nav_calib) with three modules: intrinsics (Kalibr cam0 intrinsics loader and validator), extrinsics (the T_cam_imu plus timeshift loader and validator), and runner (the wizard coroutine). The runner decodes the captured frame bundle the GCS sends, runs OpenCV AprilTag detection (cv2.aruco, AprilGrid t36h11), solves the pinhole plus radial-tangential intrinsics with cv2.calibrateCamera, and fits the camera-IMU timeshift with a golden-section search over [-200 ms, +200 ms]. It writes a Kalibr-compatible camchain.yaml (the cam0 block: camera_model, intrinsics, distortion_model, distortion_coeffs, resolution, T_cam_imu, timeshift_cam_imu). The maths is in Calibration math. The Rust agent loads camchain.yaml at start-up. VIO modes feed it to the vendor estimator and to the time aligner. A calibration uploaded directly as YAML skips the wizard: the agent validates the file, persists it, and applies the new timeshift.

The scale ladder

scale::ScaleLadder runs only in optical_flow_degraded mode, where the tracker has no dedicated rangefinder. It walks four rungs on every pick() and returns the first healthy one, falling back to a static value so the estimator can always emit at the lowest quality rather than refuse to feed the EKF:
Distance is clamped to [0.3, 50.0] m so a glitch reading cannot produce a runaway scale. The estimator multiplies the OF tracker’s raw quality by the rung’s multiplier before emitting, so the EKF auto-de-weights degraded rungs. The estimator marks itself degraded when the static rung is active so the GCS surfaces the warning banner. A new scale source plugs in behind the same contract; the estimator does not need to know which rung produced the number.

The rangefinder drivers

rangefinder.rs supports four sources behind one trait:
  • fc_relay is the default and universal path. The flight controller already publishes DISTANCE_SENSOR; the plugin relays the latest reading. No extra wiring.
  • tfluna_uart is a fully implemented Benewake TF-Luna UART driver (a pure 9-byte frame parser).
  • garmin_lidarlite_i2c and vl53l1x_i2c are documented stubs that fail safe (they return no reading) until the plugin SDK exposes an I2C facade. Wire an I2C sensor to the flight controller and use the fc_relay path instead.

The VIO bridge and the vendor binaries

The VIO modes spawn an out-of-process vendor binary through the plugin SDK’s process.spawn allowlist. The plugin host’s subprocess sandbox checks that the basename is declared in the manifest’s subprocess_spawn list (ados_openvins_shim, ados_vins_fusion_shim) and that the binary lives under <install_dir>/vendor/. CI builds the binaries on tag push and attaches the signed tarballs to the release; the install path unpacks them under the plugin’s cgroup slice. They stay on disk and never execute unless a VIO mode is selected.
The Rust plugin no longer captures frames. It bridges the shared vision-bus frames into the SHM ring the engine owns, then notifies the binary over the control channel. vio::VioEngine owns one spawned binary and the two channels it speaks: a POSIX shared-memory ring for camera frames (8 slots, the binary opens it read-only and reads the highest-sequence slot) and a length-prefixed msgpack control channel over a Unix-domain socket (hello / config / imu / frame_ready out; hello_ack / pose / alive / log in). The byte layouts match the vendored C++ adapters exactly so the binaries run unchanged. A missed alive heartbeat tears the engine down; a pose that fails to decode is dropped. Adding a third engine (a future home-grown estimator, for example) is a new VioEngine variant plus a vendor binary that speaks the same protocol.

The pre-arm gate

pre_arm::PreArmGate::evaluate() is a pure function over its inputs. It produces a report with a list of individual checks, each carrying a severity (ok / pending / blocking) and an operator-readable detail string. The aggregate armable flag is true only when every check is ok. The gate is mode-aware. Adding a new mode means:
  1. Adding the mode key to the registry in estimators.rs.
  2. Adding the mode variant to config.rs (Mode).
  3. Adding a branch to PreArmGate::evaluate() that picks the right check set for the new mode.
  4. Adding the mode label to the GCS mode card and the drone-card pill.
The gate does not call the network, does not touch the filesystem, and never blocks. The pipeline invokes it on the same tick that publishes the snapshot to the cloud relay.

How a heartbeat is built

Every health tick, health::HealthSnapshot::to_value() returns the navigation block (all keys camelCase; field names that drift to snake_case silently drop at the relay):
This rides the cloud relay’s cmd_droneStatus.navigation field. The Mission Control normaliser reads it and routes it into the per-drone capability store. Every UI surface (the mode card, the sensors card, the estimator card, the fallback banner, the source-set switcher, the pre-arm status, the drone-card pill, and the fleet GPS-denied count) reads from that store.

Adding a new estimator

The full recipe:
  1. Implement the Estimator trait in estimators.rs (or a new module): estimator_id, output_mode, step, plus configure / shutdown if it backs a subprocess.
  2. Register the id in available_estimators() so the heartbeat advertises it and the GCS mode picker offers it.
  3. Add the mode variant to Mode in config.rs with its wire string.
  4. Add a branch to PreArmGate::evaluate() listing the pre-arm checks the new estimator needs.
  5. Update the GCS: add the mode to the mode card with a description and hardware-requirements string, add it to the GCS mode type, and add the drone-card pill label.
  6. Write tests. The existing Rust unit tests cover the contract; the new estimator’s tests verify its state machine and output shape.
Doing this for a stereo VIO engine, a learned monocular depth network, or a vendor MSCKF implementation is the same recipe each time. The pipeline does not change.