OptiTrack (1/3): robot-side NatNet client + PX4 external-vision fusion - #374
Open
JohnYanxinLiu wants to merge 19 commits into
Open
OptiTrack (1/3): robot-side NatNet client + PX4 external-vision fusion#374JohnYanxinLiu wants to merge 19 commits into
JohnYanxinLiu wants to merge 19 commits into
Conversation
…ion baseline Take the natnet_ros2 package from #367 onto the reworked base: the C++ NatNet client (natnet_ros2_node + client adapter + natnet_logic seam), the base mavros_gp_origin and vision_pose_converter nodes, per-robot natnet_config profiles, launch files, and the co-located C++/Python unit tests. natnet_ros2 is already listed in tests/colcon_unit_test_packages.yaml, so the base's YAML-driven collection picks up the updated unit tests directly — no proxy files. Real-robot PX4 external-vision fusion (px4_param_setter, geoid-corrected origin, EV-pose bounds) is layered on next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Layer the Hummingbird real-robot fusion pipeline onto natnet_ros2 so an OptiTrack-only drone (no GNSS/mag/baro) fuses mocap pose into PX4 EKF2: - mavros_gp_origin_node: publishes a guarded synthetic GPS origin. On real HW, use_geoid_altitude feeds the egm96-5 geoid undulation (N ≈ 54 m at Lisbon) so mavros's ellipsoidal→AMSL conversion cancels and local z == OptiTrack z (fixes the ~36 m = 90 − 54 boot offset; see docs). Auto-skipped in sim. - vision_pose_converter_node: rate-limited mocap → MAVROS vision_pose bridge. - px4_params.yaml: the external-vision EKF2 param set. - natnet_ros2.launch.py wires the bridges when a robot's vision_pose block is on. px4_param_setter reworked into a **checker** (R3): auto_set=false by default — it reads and *flags* FCU params that differ from the desired set instead of writing them; on_mismatch=warn|halt (default warn). Set the params in QGroundControl; the node is the pre-flight safety net. auto_set=true restores the legacy enforce path. Excludes the duplicate vendored NatNet SDK (sensors/natnet_ros2) and deployment override .envs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the PX4 external-vision setup guide into docs/ (was a repo-root markdown) and wire it into the mkdocs nav under Perception. Adapt it to the reworked param checker (auto_set default off; check-and-flag, not enforce), and add a "height datum" section explaining the ~36 m local_z offset: AirStack's 90.0 ellipsoidal world datum minus the egm96-5 geoid undulation (N ≈ 54 m at Lisbon) = 36 m; fixed by publishing the geoid-corrected origin altitude so mavros's conversion cancels. Documents why it's invisible in sim and why the shared 90.0 datum must not be changed globally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hema Refine the perception bringup comment on the LAUNCH_NATNET include so it points at the per-robot natnet_config.yaml schema parsed by natnet_ros2.launch.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uning Three defects that together meant the OptiTrack client could never connect to anything, in sim or on a real robot. 1. NATNET_SERVER_IP was unreachable config. natnet_config.yaml resolves it via $(env ...), but docker compose only injects variables named in a service's `environment:` block and no service declared it — not the compose files, not .env, not tests/system/test_optitrack_e2e.py. The client therefore always fell back to its hardcoded default (192.168.123.199), which is neither the in-sim emulator (172.31.0.200) nor any Motive host. Forwarded in robot-base-docker-compose.yaml, defaulting to the emulator so the sim path works unconfigured. 2. The tracked rigid body could never match. robot_1 pinned "Hummingbird" id 1146 while the emulator streams "Drone" id 1, and the NatNet client filters incoming frames by NUMERIC id — a mismatch yields a connected client that silently never publishes. Body name/id now accept $(env ...) (expanded in _build_node_params, with the id still coerced to int) and default to the emulator's body; sites override via NATNET_BODY_NAME / NATNET_BODY_ID. 3. EV tuning was not the deployment-validated set. EKF2_EV_DELAY 8.0 -> 7.0 and EKF2_EVP_NOISE 0.01 -> 0.05. EKF2_EVP_NOISE is not marker precision: it also sets the innovation gate at EKF2_EVP_GATE (default 5) sigma, so 0.01 gave a 5 cm gate that rejected legitimate mocap updates and refused to arm. 0.05 is a 25 cm gate, still far tighter than PX4's 0.1 default. px4_params.yaml keeps the evidence inline, including two results that are expensive to rediscover: raising EKF2_EV_DELAY to 50.0 measurably degrades tracking (the negative best-fit time shift shows the estimate running ahead of truth), and the drift-and-snap excursions were a 90 deg body-yaw offset in the Motive rigid-body definition, not a gate problem — so the fix belongs in Motive, never as yaw compensation in code. Adds two unit tests covering body-field env expansion and the emulator-matching defaults (natnet_ros2: 14 -> 16 passing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mocap counterpart to l4t-px4-realrobot.env: same Jetson stack, plus the NatNet server/body settings and LAUNCH_NATNET. Carries the two things that are easy to get wrong and produce no error. The body id must match Motive's streaming id, since the client filters frames numerically and a mismatch just never publishes. And nothing writes the EKF2 external-vision parameters to a real FCU — px4_param_setter only reads them back and warns — so they have to be set once in QGroundControl. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JohnYanxinLiu
force-pushed
the
johnliu/optitrack-autonomy
branch
from
August 13, 2026 15:29
eaf4b13 to
a1287ca
Compare
JohnYanxinLiu
changed the base branch from
johnliu/test-infra-rework
to
develop
August 13, 2026 15:33
This was referenced Aug 13, 2026
The rigid body a robot tracks is now set only in its natnet_config.yaml profile, keyed by ROBOT_NAME. NATNET_BODY_NAME / NATNET_BODY_ID are gone: a single global env var cannot express per-robot values, so it blocked the multi-robot case the profiles already handle. NATNET_SERVER_IP stays in the environment — one Motive host serves every robot. Comments across the package are cut back to what is not evident from the code. The EKF2 tuning results that were buried in px4_params.yaml move into docs/robot/px4_external_vision.md, which also had stale values (EV_DELAY 15.0, EVP_NOISE 0.01) contradicting the config: that raising EV_DELAY measurably hurts tracking, and that drift-and-snap was a Motive rigid-body yaw offset rather than a gate problem. Kept: the license header, and the note on why the SDK needs a reachability pre-check before Connect(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JohnYanxinLiu
force-pushed
the
johnliu/optitrack-autonomy
branch
from
August 13, 2026 16:04
bfa9b5c to
2c467ef
Compare
desired_floor_amsl 0.0 -> 36.0, the world datum (90 m ellipsoidal) expressed in AMSL, so a mocap robot's reported global altitude agrees with sim and the GCS instead of sitting at sea level. The published ellipsoidal origin works out to ~90 m, the datum itself. local_position.z equals the OptiTrack height for any value of this parameter — it only moves the global altitude. Reasoning lives in the external-vision doc, which also now records that GeoPoint.altitude is ellipsoidal by contract, so AMSL must not be sent here. Not yet confirmed on hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MAVROS constructs the egm96-5 geoid in its UAS core, before any plugin loads, and throws std::invalid_argument if the dataset is absent — mavros_node terminates at startup, so there is no MAVROS at all, GPS or mocap. The image could ship without it. mavros' install_geographiclib_datasets.sh sends the downloader's output to /dev/null and, on failure, prints "Error while installing" and returns without a non-zero exit, so the RUN layer succeeded regardless. The tool it calls, geographiclib-get-geoids, was also only a transitive dependency of ros-mavros rather than something we pinned. Now pins geographiclib-tools and asserts the file landed, so a failed download fails the build. Verified against the shipped image: with the downloader broken the script still exits 0, and the new test -f returns non-zero. This is the dependency the OptiTrack external-vision path needs — mavros_gp_origin resolves the geoid undulation with the same egm96-5 model — hence landing it here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They resolved relative to docs/robot/, so mkdocs looked for docs/robot/robot/ros_ws/... and warned on every one. Prefixed with ../../; the file now builds warning-free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 3 documented MAVLink serial setup at length — MAV_n_CONFIG / SER_TEL2_BAUD tables, wiring, USB-vs-TELEM2 comparison — all of which is standard PX4 setup that PX4 documents better and keeps current. Replaced with links to the companion computer, MAVLink peripherals, and serial configuration pages. Kept the part PX4 does not cover: the Cube Orange USB CDC-ACM stall, which starves EKF2 of vision updates and is why the companion link belongs on TELEM2. Four other sections and the troubleshooting table point here for that symptom. 65 lines -> 19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 4 now leads with what mavros_gp_origin does — inject a synthetic global position so PX4 will arm in modes that need one without GNSS — rather than presenting the height datum as a peer topic. The ~36 m offset becomes a note under it, scoped to real deployments and ending with why sim never sees it (the geoid path is skipped under use_sim_time, and sim's synthetic GPS is self-consistent with the spawn). Section 4b is gone; it had no inbound references. Dropped the "don't change the 90.0 globally" warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
validate_connection_type returned "unicast" for anything it did not recognise, so "mutlicast" or "Unicast" produced a client that connected on the wrong transport and then never received a frame — with only a warning to show for it. It now throws std::invalid_argument naming the offending value, and the node turns that into a fatal startup error rather than a warning it flies past. Case-sensitivity is deliberate: accepting "Unicast" would mean the config silently disagrees with itself. Tests updated from fallback to throw, plus one asserting the message names the bad value. 60 gtests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment trims in natnet_ros2_node.cpp (no code change). Records what cube_orange_latency_ms actually is: an estimate of the FCU hop, added to a logged total and never fused. Only the transport half of EKF2_EV_DELAY is measured, and that measurement starts at the NatNet server transmit, so Motive's own capture pipeline is not in it either. Also notes, for whoever retunes next, that the node stamps poses with its receive time — so delay after that stamp does not belong in EKF2_EV_DELAY, which points lower than 7.0 and matches the negative best-fit shift already recorded. Not chased down; 7.0 flies. CameraMidExposureTimestamp would replace the estimate with a measurement if it ever matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the two long tuning write-ups with a short troubleshooting tip (check the Motive rigid-body definition first — x forward, z up) and cuts the latency section back to what is measured versus estimated. Fixed a dangling "see below" in the EKF2_EV_DELAY table row, which pointed at the removed tuning result; the warning it carried is now stated inline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JohnYanxinLiu
commented
Aug 14, 2026
JohnYanxinLiu
left a comment
Collaborator
Author
There was a problem hiding this comment.
Code manually reviewed and verified on real hardware.
JohnYanxinLiu
marked this pull request as ready for review
August 14, 2026 21:55
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What features did you add and/or bugs did you address?
None — this is the robot-side half of running AirStack on OptiTrack motion capture, brought
up against a real Jetson + Cube Orange in a mocap room.
Lets PX4 fly on mocap instead of GPS:
natnet_ros2connects to a Motive server,publishes the tracked rigid body, and a bridge feeds it to PX4 EKF2 as external vision.
Three pieces:
natnet_ros2client brought up to theoptitrack_emulationbaseline — anatnet:config schema describing multiple robots and multiple rigid bodies per robot, plus
per-message latency reporting.
mavros_gp_origininjects a synthetic global position(so modes needing one will arm without GNSS) with a geoid-corrected origin altitude,
vision_pose_converterbridges the mocap pose to MAVROS, andpx4_param_setterchecks the FCU's EKF2 parameters (
auto_set: falseby default; warn/halt).overrides/l4t-optitrack-realrobot.env, the mocap counterpartto
l4t-px4-realrobot.env.Bugs fixed, all found while deploying:
NATNET_SERVER_IPcould never resolve.natnet_config.yamlreads it via$(env ...), but no compose service declared it, so the client always fell back to ahardcoded default and could reach neither the in-sim emulator nor a real Motive host.
The tracked rigid body could never match.
robot_1's profile pinned a site-specificbody (id 1146) that no emulator streams. The client filters frames by numeric id, so
a mismatch produces a connected client that silently never publishes. It now defaults to
the emulator's body (
Drone, id 1).An unknown
connection_typesilently becameunicast. A typo likemutlicastproduced a client on the wrong transport that never received a frame, with only a warning.
validate_connection_typenow throws and the node fails at startup naming the bad value.The image could ship without the GeographicLib
egm96-5geoid, and MAVROS will notstart without it — it builds the geoid in its UAS core before any plugin loads and
throws if the file is absent, so
mavros_nodeterminates outright. Confirmed on theJetson: the l4t image needs that download for MAVROS to run in offboard. mavros' own
install_geographiclib_datasets.shswallows a failed download and still exits 0, andgeographiclib-toolswas only a transitive dependency —Dockerfile.robotnow pins thetool and asserts the file landed, failing the build instead.
EV tuning corrected from flight bags —
EKF2_EV_DELAY8.0 → 7.0 andEKF2_EVP_NOISE0.01 → 0.05.
EKF2_EVP_NOISEis not marker precision: it also sets the innovation gate atEKF2_EVP_GATE(default 5) sigma, so 0.01 gave a 5 cm gate that rejected valid mocapupdates and blocked arming. 0.05 is a 25 cm gate, still far tighter than PX4's 0.1 default.
Please add videos and images
N/A — no GUI component. Setup guide:
docs/robot/px4_external_vision.md.How did you implement it?
Per-robot bodies live in
natnet_config.yaml, not the environment. Eachrobots.<robot_name>profile names the bodies that robot tracks, and the launch fileselects the profile by
ROBOT_NAME. A single global env var cannot express per-robotvalues, so it would block the multi-robot case the profiles already handle.
NATNET_SERVER_IPstays in the environment because one Motive host serves every robot.px4_param_setteris a checker, not a writer. Nothing here writes parameters to a realFCU; they are set once in QGroundControl and the node warns on mismatch, so a missed
parameter shows up in the logs rather than in the air.
The synthetic GPS origin is geoid-corrected.
geographic_msgs/GeoPoint.altitudeis aheight above the WGS-84 ellipsoid and MAVROS converts it to AMSL with egm96-5. Publishing
the shared datum's literal
90.0anchors PX4 atAMSL = 90 − N ≈ 36 mwhile mocap says thefloor is
z = 0— the drone reads ~36 m sitting on the floor. The node publishesN + desired_floor_amslinstead, computingNat runtime so the conversion cancelsexactly.
desired_floor_amsl: 36.0puts the floor at the shared datum, so the robot'sglobal altitude agrees with sim and the GCS. Not yet confirmed on hardware.
Rationale lives in
docs/robot/px4_external_vision.mdrather than in comments, and itsparameter table — which was stale, still showing
EV_DELAY 15.0/EVP_NOISE 0.01— wascorrected to match the config.
How do you run and use it?
Real robot (Jetson):
Verify the chain in order — each stage failing points at a different cause:
Watch the robot container's startup logs for
px4_param_settermismatch warnings — that isthe check that the FCU is actually configured for external vision.
Testing with PyTest
natnet_ros2unit tests (unitmark, co-locatedtest/) covering the config-flatteninghelpers:
$(env ...)expansion for the server block, per-robot profile selection, body-listflattening, covariance defaults, topic namespacing, and the quaternion canonicalization in
vision_pose_converter.There is also a C++ gtest suite (
test_natnet_logic.cpp) over the SDK-free logic —covariance assembly, topic naming, connection config, frame helpers, and the negotiation
seam — which runs under
colcon testin the container rather than pytest.airstack test -m unitAll pass;
natnet_ros2contributes 14 pytest cases. They are hermetic — no Docker, simor GPU. The gtest suite is 60 cases.
The hardware path (a real Motive server and FCU) is not covered by automated tests. #375
adds host integration tests that drive this client from an emulated server, and #376
adds a sim e2e flight.
Documentation
y — adds
docs/robot/px4_external_vision.mdto the nav.Yes. The guide covers the three things that must all be right (EKF2 parameters, the
companion MAVLink link, the vision pose pipeline), a parameter table with the reasoning per
entry, the height-datum explanation, and a symptom → cause → where-to-look troubleshooting
table. Companion-link setup links out to the PX4 docs rather than restating them.
Data-path diagrams are in the guide; there is no GUI surface.
Versioning
Yes —
0.19.0-alpha.13.Known gaps
desired_floor_amsl: 36.0choice is not yet confirmed on hardware — verify thereported global altitude on the next mocap flight.
EKF2_EV_DELAYis only partly measured: ~0.7 ms of it is a real transport measurement,the ~5 ms FCU hop is an estimate (diagnostic only, never fused). Documented in the guide;
7.0flies and this has not been chased down.🤖 Generated with Claude Code