Skip to content

OptiTrack (1/3): robot-side NatNet client + PX4 external-vision fusion - #374

Draft
JohnYanxinLiu wants to merge 10 commits into
developfrom
johnliu/optitrack-autonomy
Draft

OptiTrack (1/3): robot-side NatNet client + PX4 external-vision fusion#374
JohnYanxinLiu wants to merge 10 commits into
developfrom
johnliu/optitrack-autonomy

Conversation

@JohnYanxinLiu

@JohnYanxinLiuJohnYanxinLiu commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Stacked PR — 1 of 3. All three target develop so each can be reviewed and tested
independently. Merge in order: #374#375#376. Because they are stacked, #375 and
#376 currently show this PR's commits too; those diffs shrink automatically as each merges.

PRAddsOwn diff
#374 (this)robot-side OptiTrack client + PX4 external-vision fusion27 files
#375NatNet server emulator + host integration tests25 files
#376Isaac wrapper, sim EV fusion, Circle e2e39 files

What features did you add and/or bugs did you address?

  • Which GitHub issue does this 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.

  • Additional description if not fully described in the GitHub issue

Lets PX4 fly on mocap instead of GPS: natnet_ros2 connects to a Motive server, publishes
the tracked rigid body, and a bridge feeds it to PX4 EKF2 as external vision.

Three pieces:

  1. natnet_ros2 client brought up to the optitrack_emulation baseline — a natnet:
    config schema describing multiple robots and multiple rigid bodies per robot, plus
    per-message latency reporting.
  2. PX4 external-vision fusionmavros_gp_origin publishes a geoid-corrected synthetic
    GPS origin so local_position.z equals OptiTrack z (fixing a ~36 m boot offset),
    vision_pose_converter bridges the mocap pose to MAVROS, and px4_param_setterchecks
    the FCU's EKF2 parameters (auto_set: false by default; on_mismatch warn/halt).
  3. Deployment overrideoverrides/l4t-optitrack-realrobot.env, the mocap counterpart to
    l4t-px4-realrobot.env.

Also fixes three defects found while deploying:

  • NATNET_SERVER_IP could never resolve.natnet_config.yaml reads it via $(env ...),
    but no compose service declared it, so the client always fell back to a hardcoded default and
    could reach neither the in-sim emulator nor a real Motive host. Now forwarded in
    robot-base-docker-compose.yaml.

  • The tracked rigid body could never match.robot_1's profile pinned a site-specific body
    (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).

  • EV tuning corrected from flight bagsEKF2_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 valid mocap updates
    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. Each
robots.<robot_name> profile names the bodies that robot tracks, and the launch file selects
the profile by ROBOT_NAME.

px4_param_setter is a checker, not a writer. Nothing here writes parameters to a real FCU;
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.

Tuning rationale lives in the docs, not in comments.docs/robot/px4_external_vision.md
gained a "Two tuning results worth not rediscovering" section, and its parameter table — which
was stale, still showing EKF2_EV_DELAY 15.0 / EKF2_EVP_NOISE 0.01 — was corrected to
match the config:

  • Raising EKF2_EV_DELAY is measurably worse, not better. 50.0 vs 7.0: median |odom −
    mocap| while moving 0.037 → 0.060 m. The negative best-fit time shift is the tell — over-
    declaring delay makes EKF2 attribute the measurement to too old a state, so the estimate runs
    ahead of truth. It can never compensate for apparent lag.
  • The drift-and-snap excursions were not a gate problem — they were a 90° body-yaw offset in
    the Motive rigid-body definition. Fixing the rigid body cut moving error 0.25 → 0.04 m.
    Do not add yaw compensation in code; natnet_ros2 and vision_pose_converter are
    deliberate identity pass-throughs and a code-side correction would double-compensate.

Comments across the package were cut back to what is not evident from the code. Two kept
deliberately: the license header, and the note on why the SDK needs a reachability pre-check
before Connect() (it can SIGABRT instead of returning an error, so the check looks redundant
and is not).

How do you run and use it?

Real robot (Jetson):

# 1. Set the Motive host$EDITOR overrides/l4t-optitrack-realrobot.env # NATNET_SERVER_IP# 2. Set the tracked rigid body to match Motive, in this robot's profile$EDITOR robot/ros_ws/src/perception/natnet_ros2/config/natnet_config.yaml
# robots.robot_1.bodies[0].rigid_body_name / .id# 3. Set the EKF2 parameters once in QGroundControl — docs/robot/px4_external_vision.md# 4. Bring up the Jetson stack
airstack image-build --profile l4t robot-l4t
airstack up --env-file overrides/l4t-optitrack-realrobot.env robot-l4t

Verify the chain in order — each stage failing points at a different cause:

SETUP='source /root/AirStack/robot/ros_ws/install/setup.bash'# mocap arrives (silent => wrong server IP, or body id doesn't match Motive)
docker exec -e ROS_DOMAIN_ID=1 <robot-container> bash -lc \
"$SETUP && ros2 topic hz /robot_1/perception/optitrack/drone/pose_cov"# bridged to MAVROS
docker exec -e ROS_DOMAIN_ID=1 <robot-container> bash -lc \
"$SETUP && ros2 topic hz /robot_1/interface/mavros/vision_pose/pose_cov"# PX4 fused it
docker exec -e ROS_DOMAIN_ID=1 <robot-container> bash -lc \
"$SETUP && ros2 topic echo --once /robot_1/interface/mavros/local_position/pose"

Watch the robot container's startup logs for px4_param_setter mismatch warnings — that is the
check that the FCU is actually configured for external vision.

Sim reviewers: this PR is the robot half only and has no emulator to talk to. To exercise it
without hardware, review #376, which adds the in-sim NatNet emulator and a one-command
override.

Testing with PyTest

  • What pytests did you add?

natnet_ros2 unit tests (unit mark, co-located test/) covering the config-flattening
helpers: $(env ...) expansion for the server block, per-robot profile selection, body-list
flattening, covariance defaults, topic namespacing, and the quaternion canonicalization in
vision_pose_converter.

  • Exact command
airstack test -m unit
  • Expected results

All pass; natnet_ros2 contributes 14. They are hermetic — no Docker, sim or GPU.

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

  • Was mkdocs.yml updated? (y/n)

y — adds docs/robot/px4_external_vision.md to the nav.

  • Sufficient scope?

Yes. The guide covers the three things that must all be right (EKF2 parameters, frame
conventions, height datum), a parameter table with the reasoning per entry, the two tuning
results above, and a symptom → cause → where-to-look troubleshooting table.

  • Sufficient visual media?

Diagrams of the data path and the height-datum explainer are in the guide.

Versioning

  • Version bumped?

Yes0.19.0-alpha.13.

🤖 Generated with Claude Code

JohnYanxinLiuand others added 7 commits August 5, 2026 15:54
…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
JohnYanxinLiuforce-pushed the johnliu/optitrack-autonomy branch from eaf4b13 to a1287caCompareAugust 13, 2026 15:29
@JohnYanxinLiu
JohnYanxinLiu changed the base branch from johnliu/test-infra-rework to developAugust 13, 2026 15:33
@JohnYanxinLiuJohnYanxinLiu changed the title OptiTrack (1/3): autonomy fixes — natnet_ros2 client + PX4 external-vision fusionOptiTrack (1/3): robot-side NatNet client + PX4 external-vision fusionAug 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
JohnYanxinLiuforce-pushed the johnliu/optitrack-autonomy branch from bfa9b5c to 2c467efCompareAugust 13, 2026 16:04
JohnYanxinLiuand others added 2 commits August 13, 2026 13:11
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@JohnYanxinLiu