OptiTrack (3/3): Isaac wrapper, mocap EV fusion in sim, and a Circle-trajectory e2e - #376
Draft
JohnYanxinLiu wants to merge 24 commits into
Draft
OptiTrack (3/3): Isaac wrapper, mocap EV fusion in sim, and a Circle-trajectory e2e#376JohnYanxinLiu wants to merge 24 commits into
JohnYanxinLiu wants to merge 24 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-natnet-server
branch
from
August 13, 2026 15:31
4e19fba to
dfb75fb
Compare
JohnYanxinLiu
force-pushed
the
johnliu/optitrack-isaac-wrapper
branch
from
August 13, 2026 15:31
e56b638 to
82dd8ef
Compare
JohnYanxinLiu
changed the base branch from
johnliu/optitrack-natnet-server
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-isaac-wrapper
branch
from
August 13, 2026 16:04
82dd8ef to
cbd4bba
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>
JohnYanxinLiu
force-pushed
the
johnliu/optitrack-isaac-wrapper
branch
from
August 13, 2026 17:12
cbd4bba to
5ea50ac
Compare
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>
… tests The pure-Python NatNet server that emulates an OptiTrack Motive server so natnet_ros2 can be driven without hardware. USD/Isaac-free — this is the protocol + server core (unicast server, data/model/server types, serializers, default catalogs). The Isaac wrapper that maps a USD scene onto this server lands next. Registers the emulator package's co-located unit tests via a `sim:` entry in tests/colcon_unit_test_packages.yaml (base's simulation/**/<pkg>/test glob). The root conftest now puts each unit-test package's import root on sys.path so co-located tests import their package without a per-package conftest.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drive the real natnet_ros2 client from the host NatNet server emulator and check the drone pose reaches ROS at rate (single-body and multi-body profiles). No sim, no GPU — uses the base's `robot_autonomy_stack` fixture + `integration` mark. The Isaac-wrapper variant lands with the Isaac wrapper PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Isaac integration layer that maps a live USD scene onto the NatNet server: catalog/config/frames/manager/scene_setup/ui_extension/usd_bindings, the extension manifest (config/), and the USD schema. Adds the natnet Pegasus launch scripts that spawn the emulator alongside PX4 in Isaac Sim, the isaac unit tests (incl. a float-tolerance loosen on the pose round-trip for float32/USD noise), and the Isaac-wrapper host integration test. scipy + usd-core added for the emulator's USD/pose-sampling tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One dedicated Isaac bring-up (example_one_px4_pegasus_natnet_launch_script + LAUNCH_NATNET=true) that asserts the full NatNet chain: emulator → natnet_ros2 pose_cov >= 5 Hz, then PX4 local_position alive (EKF2 fusing the vision). Its own `optitrack` mark + _MODULE_ORDER slot — deliberately NOT a third parametrized sim, so the generic liveliness/sensors/flight suites aren't re-run under NatNet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the NatNet emulator Isaac Sim documentation (docs/simulation/isaac_sim/ natnet_emulator.md) and the optitrack-development agent skill covering the emulator, natnet_ros2, and the NatNet wire-protocol handshake. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Isaac launch scripts import `optitrack.natnet.emulator`, but Kit was only pointed at the shared exts dir (`~/.local/share/ov/data/documents/Kit/shared/exts`), where Dockerfile.isaac-ros installs pegasus.simulator at image build. The emulator lives in the repo at simulation/isaac-sim/extensions/ and is never copied there, so it was not a registered extension and the import depended on ambient sys.path. Kit accepts repeated --ext-folder, so both standalone commands now pass the repo's extensions dir as a second search root. Chosen over copying the extension into the shared dir at build time because the repo tree is bind-mounted: emulator edits take effect on relaunch instead of requiring an image rebuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EKF2_EV_CTRL defaults to 0, and the isaac compose set no PX4 params at all, so PX4 discarded the vision entirely and flew on sim GPS. The emulator could stream perfectly and change nothing. PX4 SITL's rcS applies any PX4_PARAM_<NAME> env var at boot and Pegasus passes the container env through, so no new mechanism is needed. Each entry defaults to PX4's own default, read out of the firmware in this image — unset is an explicit no-op and non-mocap sims are unaffected. They cannot be defined-but-empty: the rcS loop has no empty-value guard. Also hooks NATNET_BODY_ID in the single-drone launch script. The emulator hardcoded streaming id 1 while the client reads the env var, so a real Motive id would desync the two into a connected client that never publishes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_px4_fuses_vision claimed to prove EKF2 fused the external vision but only waited for local_position/pose, which publishes off GPS regardless — it passed with vision disabled. The stack now comes up with GPS, baro and range aiding off, so mocap is the vehicle's only position source, and the module flies the Circle trajectory. Sustained lateral motion is where a wrong EV delay or a too-tight innovation gate shows up; a hover would not reveal either. Cross-track error is scored by the same helpers the autonomy benchmark uses, imported rather than reimplemented. test_px4_fuses_vision is kept as the pre-flight gate — it now establishes only that an estimate exists, and says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pull_request branch passed no args, so opening a PR ran pytest's defaults: every mark, both sims, all four trajectory types. Now it runs the one end-to-end flight that covers the whole chain. Every other suite is unchanged and still reachable on demand — /pytest comments, workflow_dispatch inputs, and local airstack test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings up the emulator plus PX4 on external-vision fusion in one command — the same configuration test_optitrack_e2e.py uses, so the test environment is reproducible by hand. Sets PLAY_SIM_ON_START explicitly because the root .env ships it false: the scene then loads paused, /clock never ticks, and every use_sim_time node sits frozen while the stack looks healthy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The natnet launch scripts died with ModuleNotFoundError: No module named 'optitrack'. Pointing Kit's --ext-folder at the repo extensions dir was not enough — that only makes Kit aware of an extension, it does not put the package on sys.path. Handle it the same way pegasus.simulator already is: bake a copy into the Kit shared exts dir and pip-install it editable, then bind-mount the repo copy over it so edits stay live. The scripts now enable_extension() before importing, which registers the extension and its omni.isaac.core / omni.usd dependencies. The repo-extensions --ext-folder flag is dropped; the extension now lives in the dir the image already searches. Verified in a running container: extension starts, emulator serves on 172.31.0.200 :1510/:1511, and the robot sees /robot_1/perception/optitrack/drone/pose_cov at ~101 Hz feeding vision_pose and PX4 local_position at ~32 Hz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The emulator read NATNET_BODY_NAME / NATNET_BODY_ID from the environment to stay in sync with the client. The client now takes its bodies from its per-robot profile in natnet_config.yaml, so the env hook was asymmetric and, being global, could not describe a multi-robot scene anyway. Both are now constants in the launch scripts, with the pairing spelled out inline, in the emulator sim doc, and in the optitrack-development skill — including that a mismatched id fails silently: the client connects and never publishes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JohnYanxinLiu
force-pushed
the
johnliu/optitrack-isaac-wrapper
branch
from
August 13, 2026 20:34
5ea50ac to
f108853
Compare
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. #375 added a NatNet server emulator with no pose source. This drives it from a live Isaac
Sim scene, so the whole OptiTrack stack runs in simulation with no mocap hardware.
Additional description if not fully described in the GitHub issue
Isaac wrapper — a USD scene interface (
NatNetInterfaceprim + schema) binding stageprims to streamed rigid bodies, so the emulator streams a live flying drone's pose. Includes
natnet variants of the single- and multi-drone Pegasus launch scripts.
overrides/isaac-natnet-vision.env— one command to bring up Isaac + emulator + PX4 onmocap external-vision fusion.
Sim e2e (
optitrackmark) — flies a Circle trajectory on mocap EKF2 fusion.PR CI now enforces exactly that one flight instead of pytest's full defaults.
Four bugs fixed along the way, all found by actually running it:
EKF2_EV_CTRLdefaults to0(verified in the firmwareshipped in our image) and the isaac compose set no PX4 parameters at all, so the emulator
could stream perfectly while PX4 flew on sim GPS. The compose now passes the EKF2
external-vision set as
PX4_PARAM_*, which PX4 SITL'srcSapplies at boot.test_px4_fuses_visionasserted only thatlocal_position/posepublishes — which it does off GPS regardless. It passed with externalvision disabled. Replaced with the Circle flight.
ModuleNotFoundError: No module named 'optitrack'. Being on a Kit--ext-foldersearch path only makes Kit aware of anextension; it does not put the package on
sys.path.together explicitly (see below).
Startup, from the Isaac tmux pane:
Measured on the robot side with the stack up:
perception/optitrack/drone/pose_covinterface/mavros/vision_pose/pose_covinterface/mavros/local_position/poseHow did you implement it?
EV parameters are opt-in and default to no-op. Each
PX4_PARAM_*entry defaults to PX4'sown default, read out of the firmware in this image (
EKF2_EV_CTRL=0,EKF2_HGT_REF=1,EKF2_GPS_CTRL=7, …). Leaving them unset is therefore an explicit no-op and every non-mocap simis unaffected; the mocap path opts in. They cannot be defined-but-empty — the
rcSloop has noempty-value guard and would run
param set X "". Values mirrorpx4_params.yamlfrom #374, sosim and the real robot run the same EV configuration.
The streamed body is a constant in the launch script, not an env var.
NATNET_BODY_NAME/NATNET_BODY_IDare set inexample_one_px4_pegasus_natnet_launch_script.pyand must match a body entry in the robot'sprofile in
natnet_config.yaml. The client reads its bodies from that per-robot profile, whichis what lets each robot in a multi-robot scene track a different body — a single global env var
could not express that. The pairing is documented inline in both scripts, in the emulator sim
doc, and in the
optitrack-developmentskill, including the failure mode: the client filtersframes by numeric id, so a mismatch gives a client that connects and then never publishes,
with no error on either side.
Extension install mirrors
pegasus.simulator:Dockerfile.isaac-rosbakes a copy into theKit shared exts dir and
pip install -es it; compose bind-mounts the repo copy over it so editsstay live; the launch scripts
enable_extension()before importing.The e2e flies a Circle rather than hovering. With GPS, baro and range aiding all disabled,
mocap is the only position source — so tracking a circle within a cross-track tolerance
exercises the entire chain under sustained lateral motion, which is where a wrong EV delay or a
too-tight innovation gate actually shows up. It reuses
test_fixed_trajectory.py's phase chainand cross-track scoring rather than reimplementing them.
CI scope: the
pull_requestbranch previously passed no args, so opening any PR ran everymark, both sims, all four trajectory types. It now runs this one flight. Everything else remains
available on demand —
/pytest <args>comments,workflow_dispatchinputs, localairstack test.How do you run and use it?
Isaac logs to its tmux pane, not
docker logs:Then confirm the chain on the robot:
Negative control, worth running once — it is what proves the test isn't vacuous. Set
PX4_EV_CTRL="0"in the override and re-run: with GPS also off there is no position source, sothe flight must fail. If it still passes, the test is not proving fusion.
Testing with PyTest
The
optitracksim e2e: emulator →natnet_ros2at rate (dependency gate), then takeoff →Circle trajectory → land, flown with mocap as the only position source, scored on
cross-track error.
airstack test -m optitrackThe drone completes the circle within tolerance. On
developthis test passes while PX4 flieson GPS; on this branch it only passes if EKF2 is genuinely fusing the mocap stream.
Honest status: the emulator chain is verified live (rates above, measured in a running
container) and the EV parameters are verified to resolve and reach PX4 SITL. The full
airstack test -m optitrackflight has not yet been run end to end — it needs a GPU Isaacbring-up. Reviewers with a GPU runner, that is the thing to exercise.
Documentation
y — adds the emulator sim documentation to the nav.
Yes: how the emulator binds to a USD scene, how to bring the stack up with one override, a
"Changing which body is streamed" section naming exactly which two files must agree and warning
that a mismatch fails silently, the sim-vs-real differences, and an
optitrack-developmentskill for agents working in this area.
The startup log and the measured rate table above are the observable surface; the sim itself is
the demo.
Versioning
Yes —
0.19.0-alpha.15.🤖 Generated with Claude Code