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 atextensions/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.
calibration-helper/ and produces a Kalibr-style camchain.yaml
that the Rust agent reads at start-up.
The EstimatorOutput contract
Every estimator answersstep(...) with one EstimatorOutput:
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.
Sequence: a frame becomes a MAVLink message
The simplified flow for one frame inoptical_flow mode:
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 atcalibration-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:
[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_relayis the default and universal path. The flight controller already publishesDISTANCE_SENSOR; the plugin relays the latest reading. No extra wiring.tfluna_uartis a fully implemented Benewake TF-Luna UART driver (a pure 9-byte frame parser).garmin_lidarlite_i2candvl53l1x_i2care 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 thefc_relaypath instead.
The VIO bridge and the vendor binaries
The VIO modes spawn an out-of-process vendor binary through the plugin SDK’sprocess.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.
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:
- Adding the mode key to the registry in
estimators.rs. - Adding the mode variant to
config.rs(Mode). - Adding a branch to
PreArmGate::evaluate()that picks the right check set for the new mode. - Adding the mode label to the GCS mode card and the drone-card pill.
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):
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:- Implement the
Estimatortrait inestimators.rs(or a new module):estimator_id,output_mode,step, plusconfigure/shutdownif it backs a subprocess. - Register the id in
available_estimators()so the heartbeat advertises it and the GCS mode picker offers it. - Add the mode variant to
Modeinconfig.rswith its wire string. - Add a branch to
PreArmGate::evaluate()listing the pre-arm checks the new estimator needs. - 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.
- Write tests. The existing Rust unit tests cover the contract; the new estimator’s tests verify its state machine and output shape.