Skip to content

gsc_pgo: online and offline PGO - #2587

Open
jeff-hykin wants to merge 163 commits into
mainfrom
jeff/feat/jnav_pgo
Open

gsc_pgo: online and offline PGO #2587
jeff-hykin wants to merge 163 commits into
mainfrom
jeff/feat/jnav_pgo

Conversation

@jeff-hykin

@jeff-hykin jeff-hykin commented Jun 24, 2026

Copy link
Copy Markdown
Member
dimos run unitree-go2-mid360-pgo
dimos run unitree-go2-pgo # very conservative

Offline PGO (uses april tags for correction if available)

# china_office.db is pulled from LFS automatically (bare filename -> LFS), so this runs first-try.
python dimos/navigation/jnav/components/loop_closure/gsc_pgo/scripts/post_process.py \
    --db china_office.db \
    --odom pointlio_odometry \
    --lidar pointlio_lidar \
    --tags raw_april_tags \
    --camera color_image \
    --tag-size 0.10 \
    --dict DICT_APRILTAG_36h11 \
    --world-frame world \
    --corrected-odom-frame corrected_odom \
    --corrected-suffix _corrected \
    --suffix "" \
    --closure-spacing 2.0 \
    --lcm-voxel 0.05 \
    --accum-voxel 0.05 \
    --accum-max-range 20.0
# Graceful on other recordings: --odom-tf auto-resolves from the odom stream's own header,
# no camera_info -> AprilTag stage is skipped (ICP + odom only), and a missing base<-optical
# tf edge falls back to the known rig mount geometry. Pass --odom / --lidar to override the
# auto-detected stream names, or --base-optical 'x y z qx qy qz qw' for a non-Go2 rig.
# opt-out toggles (every stage is on by default):
#   --no-odom  --no-lidar  --no-icp  --no-lcm  --no-rrd  --no-accum  --no-tf

Online PGO replay

# eval generates a json and visuals (rrd, plots). china_office.db is pulled from LFS automatically.
uv run python dimos/navigation/jnav/components/loop_closure/eval.py \
    --db-path china_office.db \
    --odom-stream pointlio_odometry \
    --lidar-stream pointlio_lidar \
    --camera-stream color_image \
    --odom-tf odom:mid360_link \
    --tag-frame camera_optical \
    --module-path dimos/navigation/jnav/components/loop_closure/gsc_pgo/module.py \
    --module-name GscPGO \
    --pgo-config-json '{"use_scan_context": true}'
# --odom-tf is 'parent:child' for the odom edge (odom:mid360_link here). On another recording,
# set it to that db's odom parent:child; a missing camera_info stream skips the AprilTag stage.

Note: offline does better than online, but online is so good it doesn't really matter

IMG_20260730_161946_195

Examples

IMG_20260730_170058_261 IMG_20260730_162725_926 IMG_20260730_162831_765 IMG_20260730_162902_133 IMG_20260730_163147_192 IMG_20260730_214048_557

process_observable gains an optional on_drop callback fired once per
message dropped by the dispatcher's single-slot LATEST mailbox. The
Recorder uses it to count dropped frames per stream and log a throttled
warning, so a slow sink no longer loses data silently.
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces the gsc_pgo online and offline Pose Graph Optimization system: a Rust+GTSAM loop-closure module (Scan Context++ descriptors, ICP closures, GNC), an offline multi-stage post-processing pipeline, a lockstep replay evaluation harness, and a BaselinePGO Python wrapper around the existing C++ PGO state — all wired into the Go2 mid360 and L1 blueprints.

  • Online PGO (gsc_pgo/module.py, Rust binary): GscPGO NativeModule publishes corrected odometry, a pose graph, and TF corrections; the lockstep replay harness feeds recordings through the module and captures the final graph for scoring.
  • Offline PGO (post_process.py): Two-stage AprilTag PGO + ICP loop-closure solve; outputs corrected odom/lidar streams, deformation nodes, and aggregated .pc2.lcm clouds back into the recording db.
  • Evaluation (eval.py, utils.py): Self-consistency scoring (tag-spread + lidar-voxel agreement) with before/after top-down/isometric/rrd visuals; RecordingTF caches the full tf stream with a past-only latch so near-static edges published once at recording start survive later lookups.

Confidence Score: 4/5

  • The PR is mostly safe to merge; the one new defect found is isolated to the BaselinePGO pass-through path for placeholder Go2 poses and does not affect the primary GscPGO module.
  • The Rust PGO core and the offline pipeline are large and novel, and the Rust crate has no CI coverage (builds only in the Nix flake dev shell). The BaselinePGO._publish_corrected function calls _transform_to_pose3 with a zero-quaternion odom on the Go2's startup placeholder poses, which produces an invalid GTSAM Pose3 and corrupts the published corrected odometry for those early frames. Everything else reviewed — GscPGO, RecordingTF, the eval harness, and the blueprints — looks correct for the intended use cases.
  • baseline_pgo/module.py (_publish_corrected zero-rotation path); gsc_pgo/rust/ (no CI — must be validated in the Nix dev shell before any Rust changes)

Important Files Changed

Filename Overview
dimos/navigation/jnav/components/loop_closure/baseline_pgo/module.py New BaselinePGO module wrapping the existing C++ PGO state; contains a logic bug where a zero-rotation placeholder odom (from Go2 startup) is passed to _transform_to_pose3 inside _publish_corrected, producing a NaN GTSAM Pose3. Also accesses several private _PGOState fields throughout.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/module.py New GscPGO NativeModule wiring the Rust PGO binary; port types match the binary's outputs. The _on_correction_for_tf subscription pattern correctly republishes the Rust-emitted TFMessage to the tf port. Config is detailed and well-documented.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/utils/replay.py Lockstep replay harness and run_module_graph; temp-file paths keyed only on the parent directory name can collide in parallel batch runs. counts_store not protected by try/finally (also noted in previous review). drift_t0=0.0 default makes drift injection effectively unusable without an explicit start timestamp (also in previous review).
dimos/navigation/jnav/components/loop_closure/eval.py Evaluation driver for loop-closure modules against a recording. SqliteStore lifecycle is now managed with a with statement, addressing the prior open-handle concern. The empty odom stream guard is present. Several edge cases around empty streams and drift injection were previously flagged and appear partially addressed in this version.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/scripts/post_process.py Offline PGO pipeline (AprilTag PGO + ICP loop closures). SqliteStore is now managed via with. Empty odom guard present. Several issues previously flagged (empty lidar stream crash, LCM write crash on zero scans, corrected_store_tf=None accumulation) are partially addressed in the current version, though some edge cases remain.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/utils/artifacts.py Write helpers for corrected odom, lidar, deformation nodes, pose graph, and raycast-accumulated maps. write_aggregated_lcm crashes on an empty scan list (previously flagged). raycast_accumulate signature no longer accepts None for store_tf, addressing the silent-empty-cloud path from previous review.
dimos/navigation/jnav/utils/recording_tf.py New RecordingTF / PastOnlyTBuffer that caches the full tf stream and overrides the odom edge. The lazy-load + past-only latch design correctly handles near-static frames published once at recording start. Edge override drops all time-varying recorded edges before injecting the fed trajectory.
.github/workflows/ci.yml Excludes the gsc_pgo/rust crate from CI Cargo/Clippy (no gtsam on plain Ubuntu runner); acknowledged in comments as intentional — build and tests must be run locally in the Nix flake dev shell. No other structural CI changes.

Reviews (52): Last reviewed commit: "gsc_pgo: relock rust deps after merging ..." | Re-trigger Greptile

Comment thread dimos/memory2/db_tf.py Outdated
Comment thread dimos/memory2/db_tf.py Outdated
Comment thread dimos/navigation/jnav/utils/recording_db.py Outdated
Comment thread dimos/memory2/db_tf.py Outdated
Comment thread dimos/navigation/jnav/components/loop_closure/unrefined_pgo/module.py Outdated
@codecov

codecov Bot commented Jun 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 37.74235% with 1445 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
dimos/navigation/jnav/utils/apriltags.py 27.30% 367 Missing and 3 partials ⚠️
...s/navigation/jnav/components/loop_closure/utils.py 13.22% 269 Missing ⚠️
dimos/navigation/jnav/utils/trajectory_metrics.py 15.50% 158 Missing ⚠️
...omponents/loop_closure/gsc_pgo/scripts/make_rrd.py 19.55% 143 Missing and 1 partial ⚠️
...components/loop_closure/gsc_pgo/utils/artifacts.py 21.71% 137 Missing ⚠️
...mponents/loop_closure/gsc_pgo/utils/offline_pgo.py 42.22% 103 Missing and 1 partial ⚠️
dimos/navigation/jnav/msgs/Graph3D.py 35.92% 66 Missing ⚠️
dimos/navigation/jnav/utils/recording_tf.py 31.52% 63 Missing ⚠️
dimos/navigation/jnav/msgs/GraphDelta3D.py 34.17% 52 Missing ⚠️
dimos/navigation/jnav/msgs/DeformationNode.py 47.61% 22 Missing ⚠️
... and 8 more
@@            Coverage Diff             @@
##             main    #2587      +/-   ##
==========================================
- Coverage   77.29%   76.55%   -0.75%     
==========================================
  Files        1268     1288      +20     
  Lines      120892   123213    +2321     
  Branches    10670    10952     +282     
==========================================
+ Hits        93442    94320     +878     
- Misses      24381    25819    +1438     
- Partials     3069     3074       +5     
Flag Coverage Δ
OS-ubuntu-24.04-arm 71.55% <37.74%> (-0.67%) ⬇️
OS-ubuntu-latest 73.41% <37.74%> (-0.70%) ⬇️
Py-3.10 73.41% <37.74%> (-0.70%) ⬇️
Py-3.11 73.41% <37.74%> (-0.69%) ⬇️
Py-3.12 73.41% <37.74%> (-0.70%) ⬇️
Py-3.13 73.41% <37.74%> (-0.70%) ⬇️
Py-3.14 73.42% <37.74%> (-0.69%) ⬇️
Py-3.14t 73.41% <37.74%> (-0.69%) ⬇️
SelfHosted-Large 29.87% <22.23%> (-0.14%) ⬇️
SelfHosted-Linux 34.88% <22.23%> (-0.24%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
.../loop_closure/gsc_pgo/scripts/test_post_process.py 100.00% <100.00%> (ø)
...os/navigation/jnav/msgs/test_LocationConstraint.py 100.00% <100.00%> (ø)
dimos/navigation/jnav/utils/test_apriltags.py 100.00% <100.00%> (ø)
dimos/robot/all_blueprints.py 100.00% <ø> (ø)
...ree/go2/blueprints/smart/unitree_go2_mid360_pgo.py 90.90% <90.90%> (ø)
...ot/unitree/go2/blueprints/smart/unitree_go2_pgo.py 91.66% <91.66%> (ø)
.../robot/unitree/go2/go2_mid360_static_transforms.py 81.25% <33.33%> (-11.06%) ⬇️
...ion/jnav/components/loop_closure/gsc_pgo/module.py 94.50% <94.50%> (ø)
dimos/navigation/jnav/msgs/LocationConstraint.py 88.34% <88.34%> (ø)
dimos/navigation/jnav/utils/voxel_map.py 60.00% <60.00%> (ø)
... and 12 more

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jeff-hykin jeff-hykin changed the title jnav: port PGO/loop-closure + tf-tree for memory2 stores gsc_pgo: online and offline PGO Jun 24, 2026
@jeff-hykin
jeff-hykin enabled auto-merge (squash) June 24, 2026 08:35
…c mounts so lookups route through the fed trajectory
…r loop committed right before stopping still gets removed
…hdog raises, settle-based drain); drop RateReplay and scan caps
…nal pose graphs with edge types, total runtime)
…r rejection

The 200m candidate-distance gate discarded ~700 genuine revisits on
huge_loop with drifted fastlio odom (inflated raw frame makes true
revisits appear far apart); with it off, tag spread collapses
59.6->1.81m. Gate never fired on any go2 run, so this is a no-op there.
Auto-scaled scan-context range (0 = first-scan extent) validated at the
sweet spot for both go2 L1 and mid360 (manual 15/25/45 sweep). Also adds
pgo-eval --lidar-tf for scans recorded in a different frame than the
odom body (fastlio_lidar in mid360_link vs base_link).
On large graphs the background full-graph GNC solve takes minutes, so the
harness settle window (60s) expired while the classification that rejects
false closures was still in flight, leaving them committed in iSAM2. Track
dispatched-vs-applied GNC sequences, republish the graph while a solve is
pending so the settle heartbeat stays fresh, raise the settle cap, and give
factor removals the same extra relinearization passes as insertions.
A forced relinearization can throw on a graph mid-outlier-rejection (many
conflicting closures being removed); the estimate is still usable and later
updates recover, so log and stop refining instead of killing the pipeline.
Removing hundreds of committed-then-rejected false closures leaves iSAM2
with unrecoverable linearization damage. At idle, adopt the batch GNC
poses and rebuild iSAM2 from the odometry backbone plus GNC-kept loops
so the live end state matches offline finalize.
Once GTSAM throws mid-update, iSAM2 is left unusable and every later
update fails. Recover by rebuilding from the odometry backbone plus
still-inlier loops (shared with the idle adoption path); stays fatal
with location constraints since the rebuild would drop their factors.
Mid-rejection the graph holds hundreds of conflicting false closures;
without the Huber kernel the live path applies, a fresh batch update
throws too. If GTSAM still throws, rebuild from the backbone alone —
pure odometry always solves and the next GNC adoption restores loops.
The per-keyframe classification poll drains the worker channel and
discarded the batch poses, so if the last solve landed before the
stream ended the idle adoption never fired. Store the newest result
and adopt from it, guarded by an adopted-sequence counter.
The rendered map strided to 400 scans, so each scan laid down an isolated set
of ground rings that never merged into a surface — the ground read as a lattice
of stripes rather than terrain. Accumulate every scan instead and keep one point
per 10cm voxel, which is cheaper than the strided map was. The voxel-agreement
metric keeps its own stride so its numbers stay comparable.

Also swap the isometric height ramp to the cool half of turbo and clip its color
limits to percentiles, since the crop window floor sits below any real ground.
The keyframe thresholds, odometry/ICP factor noise, and every ICP gate were
module constants, so tuning a recording whose environment the defaults do not
suit (narrow corridors, tag-free rigs, heavy LIO drift) meant editing the
pipeline. Collect them into an offline_pgo.Tuning dataclass and generate one
CLI flag per field.
…ability

The shim constructed GncOptimizer and never touched its inlier threshold, so
every solve ran GTSAM's built-in 0.99 chi-squared default. The only lever was
loop_gnc_var_scale, which is blunt: inflating a loop factor's variance loosens
the outlier test but also weakens the surviving edge's pull.

Threading the probability through lets the outlier test be tightened on its own.
On hotel.db it moves the kept-loop count monotonically (0.01/0.5/0.9/0.99 keep
0/1/4/5 of 8 closures).

Also converts eval.rs's to_pgo to a struct literal, which clippy rejected as
field_reassign_with_default once the new field was added.
The post_process rrd lost its camera frustums in an earlier refactor, so the
tag landmarks had no photo to check them against. Each tag now carries the
medoid glimpse's image on a pinhole at the pose it was taken from.

Logging that image as a JPEG EncodedImage (what Image.to_rerun() returns)
hangs the rerun 0.32 viewer indefinitely, so the raw pixels go in instead.
The rrd also ships a blueprint now: a single 3D view, no per-image 2D panels.

Landmarks were being skipped entirely on the d455 rig because its intrinsics
live in realsense_color_image_camera_info, not camera_info; resolve_camera_info
now tries the image-derived name first.
Tuning loop closure meant juggling fourteen separate gates that all trade the
same thing off against each other. loop_conservativeness collapses them into a
single 0-4 knob, where 2 reproduces the current defaults, 0 accepts nearly any
match, and 4 turns every gate on with a tight GNC.

It deliberately overlaps the individual loop_* fields: setting it overwrites
all of them, and the default of -1 leaves them alone. The gates it skips are
the ones that are not accept/reject tradeoffs -- keyframing, the search radius,
the odometry variances, and the Huber kernel.
Building an rrd for a recording without raw_april_tags died on the stream
lookup rather than falling back to the clouds and trajectories it can draw.
# Conflicts:
#	dimos/robot/all_blueprints.py
#	pyproject.toml
The TF service was retired on main (#3169); tf is now a normal topic, so
GscPGO needs its own `tf: Out[TFMessage]` port.
Main renamed the `dimos.memory2` package to `dimos.memory`, which broke every
gsc_pgo import. Also defers the open3d/cv2/rerun imports into the functions that
use them, matching the pattern already used in dimos/mapping, and drops the
gsc_pgo lcm-msgs rev pin so the lockfile resolves to the same e7c9428b every
other crate in the repo uses instead of carrying a second copy.
# Conflicts:
#	dimos/robot/all_blueprints.py
main pinned zenoh to =1.9.0 with default-features off (tcp/udp only), so the
tls/ws/quic/unixsock link crates and their subtrees drop out of the lock.
Comment on lines +130 to +146
def _publish_corrected(self, odom: Transform | None, ts: float) -> None:
"""Ack each scan with the drift-corrected current pose (world_correction ∘ odom)."""
if odom is None:
corrected = Pose(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
else:
optimized = self.pgo._world_correction.compose(_transform_to_pose3(odom))
t = np.asarray(optimized.translation())
q = Quaternion.from_rotation_matrix(optimized.rotation().matrix())
corrected = Pose(float(t[0]), float(t[1]), float(t[2]), q.x, q.y, q.z, q.w)
self.corrected_odometry.publish(
Odometry(
ts=ts,
frame_id=self.config.world_frame,
child_frame_id=self.config.body_frame,
pose=corrected,
)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Zero-rotation odom passed to _transform_to_pose3 in pass-through path

handle_cloud exits early via self._publish_corrected(odom, msg.ts) for all three placeholder conditions: odom is None, odom.translation.is_zero(), and odom.rotation.is_zero(). Inside _publish_corrected, only the odom is None branch is guarded; the other two fall into else and call _transform_to_pose3(odom). When odom.rotation.is_zero() (all-zero quaternion — the "uninitialized" placeholder), tf.to_matrix() produces a NaN or all-zero rotation matrix, and gtsam.Pose3(that_matrix) produces undefined / NaN state. The published corrected_odometry is then garbage, and on a Go2 recording (which regularly publishes placeholder poses at startup) this fires before any keyframes exist.

Comment on lines +130 to +134
def _publish_corrected(self, odom: Transform | None, ts: float) -> None:
"""Ack each scan with the drift-corrected current pose (world_correction ∘ odom)."""
if odom is None:
corrected = Pose(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
else:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The placeholder guard in _publish_corrected should mirror the one in handle_cloud. Currently only odom is None is handled safely; the odom.translation.is_zero() and odom.rotation.is_zero() cases slip into the else branch and call _transform_to_pose3 with an invalid quaternion.

Suggested change
def _publish_corrected(self, odom: Transform | None, ts: float) -> None:
"""Ack each scan with the drift-corrected current pose (world_correction ∘ odom)."""
if odom is None:
corrected = Pose(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
else:
def _publish_corrected(self, odom: Transform | None, ts: float) -> None:
"""Ack each scan with the drift-corrected current pose (world_correction ∘ odom)."""
if odom is None or odom.translation.is_zero() or odom.rotation.is_zero():
corrected = Pose(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)
else:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:skip Skip creating a backport to any release branches ready-to-merge Required CI checks have passed on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants