Skip to content

gsc_pgo: online and offline PGO #2587

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

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

Conversation

@jeff-hykin

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

Copy link
Copy Markdown
Member

Run real time

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 for the images

Online PGO: Mid360 Huge Loop Fastlio

photo_2026-07-25 20 12 57

Online PGO: Mid360 Huge Loop Pointlio

photo_2026-07-25 20 13 22

Online PGO: Mid360 China Office

photo_2026-07-25 20 13 51

Online PGO: Mid360 Gir Stairs

photo_2026-07-25 20 14 20

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 adds online and offline Pose Graph Optimization (PGO) for the Unitree Go2 robot — a Rust implementation (gsc_pgo) wrapping GTSAM via FFI, with Scan Context++ place recognition, ICP loop closure, and a two-stage offline solver (tag PGO + ICP). An evaluation harness replays recordings through any conforming module and scores correction quality by AprilTag spread and lidar-voxel agreement.

  • Rust PGO core (gsc_pgo.rs, main.rs): keyframe gating, Scan-Context++ descriptor search, ICP verification with multiple feature-poverty / degeneracy gates, anisotropic odometry noise, and iSAM2 incremental updates — all connected to the dimos_module framework via a worker thread that owns the GTSAM state.
  • Offline pipeline (post_process.py, offline_pgo.py, artifacts.py): two-stage GTSAM solve (tag landmark factors → ICP loop closures → re-solve), followed by corrected odom/lidar streams written back to the recording db and a raycast-accumulated map.
  • Eval harness (eval.py, replay.py, utils.py): lockstep or fixed-rate replay through any module conforming to spec.py, with AprilTag spread and voxel-agreement scoring plus before/after PNG and RRD visuals.

Confidence Score: 4/5

Safe to merge for the online PGO flow; two resource and correctness gaps in the offline tooling need attention before heavy offline use.

The Rust PGO core and eval harness are well-structured. Two concrete defects exist in the offline pipeline: run_module_graph opens a SqliteStore for stream counting without a try/finally, leaking it on any exception before stop(); and raycast_accumulate is called for the raw lidar stream without guarding against store_tf=None, silently writing an empty cloud at timestamp 0.0 while iterating the entire lidar stream. The corrected-lidar path is correctly guarded at the same call site. Neither defect affects the real-time online PGO.

Files Needing Attention: replay.py (run_module_graph counts_store start/stop) and artifacts.py / post_process.py (unguarded raycast_accumulate with store_tf=None).

Important Files Changed

Filename Overview
dimos/navigation/jnav/components/loop_closure/gsc_pgo/utils/replay.py run_module_graph uses explicit counts_store.start()/stop() without try/finally — leaks the store on any exception before stop(). LockstepReplay._load uses with SqliteStore correctly.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/utils/artifacts.py raycast_accumulate silently iterates all scans and writes an empty cloud at timestamp 0.0 when store_tf is None, whereas the corrected-lidar call site in post_process.py is correctly guarded by if corrected_store_tf is not None.
dimos/navigation/jnav/components/loop_closure/baseline_pgo/module.py Thin adapter over _PGOState that accesses private internals (_key_poses, _accepted_loops, _world_correction) — no runtime risk today but creates a fragile coupling.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/module.py New Python wrapper for the Rust PGO binary; correctly declares TFMessage port and unpacks transforms in _on_correction_for_tf.
dimos/navigation/jnav/components/loop_closure/eval.py Evaluation driver uses with SqliteStore context manager throughout, addressing previously noted resource-leak issues. Stream existence checks and empty-stream guards are present.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/scripts/post_process.py Top-level offline pipeline; uses with SqliteStore correctly. First raycast_accumulate call is not guarded against store_tf=None (--no-tf path), unlike the corrected-lidar call which is correctly guarded.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/utils/offline_pgo.py Two-stage GTSAM solve (tag PGO + ICP loop closures); ICP initial guess and between-factor direction are both correct.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/rust/src/gsc_pgo.rs Core Rust PGO with well-structured feature-poverty and degeneracy gates. No new issues found.
dimos/navigation/jnav/utils/trajectory_metrics.py Pure-math pose/trajectory utilities; drift_delta_lookup nearest-keyframe lookup is unbounded by design and clearly documented.
dimos/navigation/jnav/utils/apriltags.py AprilTag detection with multiple rejection gates (blur, reproj error, motion speed) and Huber-weighted robust cluster pose refinement.

Sequence Diagram

sequenceDiagram
    participant DB as Recording DB
    participant Replay as LockstepReplay
    participant PGO as GscPGO (Rust)
    participant Capture as GraphCapture
    participant Eval as evaluate()

    Eval->>DB: count lidar/odom rows
    Eval->>Replay: start (db, streams, strides)
    loop For each odom message
        Replay->>PGO: Odometry (fire-and-forget, paced)
    end
    loop For each lidar scan
        Replay->>PGO: PointCloud2
        PGO-->>Replay: corrected_odometry (ack)
        PGO->>Capture: pose_graph (Graph3D)
        opt loop detected
            PGO->>Capture: loop_closure_event (GraphDelta3D)
        end
    end
    Capture->>DB: graph JSON (via temp file)
    Eval->>DB: read graph JSON
    Eval->>Eval: drift_delta_lookup(graph, raw_odom)
    Eval->>Eval: score_tags + lidar_voxel_agreement
    Eval->>Eval: write topdown PNG / isometric PNG / RRD
Loading

Comments Outside Diff (1)

  1. dimos/navigation/jnav/components/loop_closure/gsc_pgo/utils/replay.py, line 693-697 (link)

    P1 counts_store not closed on exception

    counts_store.start() acquires file handles and a background thread, but .stop() is only reached on the happy path — there is no try/finally. If either .count() call raises (e.g. the stream table is missing in the db, SQLite I/O error), stop() is skipped and the store leaks its resources. The subsequent coordinator.stop() in the outer finally block does not cover this store. Using with SqliteStore(...) as counts_store: matches every other SqliteStore use in this file and closes this gap.

Reviews (50): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | 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 39.48113% with 1283 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.72% 362 Missing and 3 partials ⚠️
dimos/navigation/jnav/utils/trajectory_metrics.py 15.50% 158 Missing ⚠️
...s/navigation/jnav/components/loop_closure/utils.py 16.75% 154 Missing ⚠️
...components/loop_closure/gsc_pgo/utils/artifacts.py 22.67% 133 Missing ⚠️
...omponents/loop_closure/gsc_pgo/scripts/make_rrd.py 21.21% 129 Missing and 1 partial ⚠️
...mponents/loop_closure/gsc_pgo/utils/offline_pgo.py 39.50% 97 Missing and 1 partial ⚠️
dimos/navigation/jnav/msgs/Graph3D.py 35.92% 66 Missing ⚠️
dimos/navigation/jnav/msgs/GraphDelta3D.py 34.17% 52 Missing ⚠️
dimos/navigation/jnav/utils/recording_tf.py 32.83% 45 Missing ⚠️
dimos/navigation/jnav/msgs/DeformationNode.py 47.61% 22 Missing ⚠️
... and 8 more
@@            Coverage Diff             @@
##             main    #2587      +/-   ##
==========================================
- Coverage   74.09%   73.40%   -0.69%     
==========================================
  Files        1103     1123      +20     
  Lines      104077   106197    +2120     
  Branches     9520     9867     +347     
==========================================
+ Hits        77114    77956     +842     
- Misses      24270    25544    +1274     
- Partials     2693     2697       +4     
Flag Coverage Δ
OS-ubuntu-24.04-arm 67.40% <39.48%> (-0.59%) ⬇️
OS-ubuntu-latest 69.43% <39.48%> (-0.61%) ⬇️
Py-3.10 69.42% <39.48%> (-0.62%) ⬇️
Py-3.11 69.42% <39.48%> (-0.62%) ⬇️
Py-3.12 69.42% <39.48%> (-0.62%) ⬇️
Py-3.13 69.41% <39.48%> (-0.62%) ⬇️
Py-3.14 69.43% <39.48%> (-0.62%) ⬇️
Py-3.14t 69.42% <39.48%> (-0.62%) ⬇️
SelfHosted-Large 29.51% <23.01%> (-0.14%) ⬇️
SelfHosted-Linux 35.86% <23.01%> (-0.26%) ⬇️

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 93.67% <93.67%> (ø)
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
@jeff-hykin
jeff-hykin enabled auto-merge July 25, 2026 05:56
The gsc_pgo/rust crate links C++ gtsam via an FFI build script and cannot
compile without gtsam present, so the lint job's `pre-commit --all-files`
run failed at cargo-clippy. The dedicated rust CI job already excludes this
crate for the same reason; mirror that exclusion in the pre-commit hook so
plain environments (and the lint job) pass. Clippy it locally in the nix
flake dev shell.
Both LockstepReplay._load and RateReplay._load called store.stop() only on
the happy path, so a deserialization or schema error inside either islice
loop propagated out with the store's file handles still open. Wrap the
iteration in try/finally so stop() always runs — same pattern as
registered_scans in utils.py.
Recordings captured without a CameraInfo stream have no intrinsics for
AprilTag post-processing. This script writes the Go2 static front-camera
CameraInfo (K + distortion, camera_optical frame) into the recording db,
adding the camera_info stream only if it is missing.
Add a camera_info CameraInfo stream to the china_office.db LFS recording so
the AprilTag PGO stage runs without a fabricated intrinsics source.
Simplify read_camera_info to the exact camera_info stream (no splat-search),
un-gate rrd output, and add an empty-world-frame odom fallback for the legacy
go2 path.
Iterating an untyped store.stream() left `observation` un-annotated, which
mypy (no gsc_pgo scripts exemption) flags as var-annotated. Type it Any —
only .ts is read.
…sing

The post_process CameraInfo-missing warning now tells go2 users to run
scripts/add_camera_info.py to inject the static front-camera intrinsics
before re-running, instead of silently skipping the AprilTag stage.
Comment thread dimos/navigation/jnav/components/loop_closure/eval.py Outdated
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jul 25, 2026
gsc_pgo vs IvanPGO self-consistency across every usable mid360/go2
recording, one config per sensor. Documents huge-loop closure (pointlio),
go2/L1 harm cases, and the sf_office_survey zenoh-wedge skip.
…d loops

The 80 m ICP gate skipped the huge-loop revisit when FAST-LIO odometry had
drifted ~60-175 m by the time the robot returned, so the loop never closed
(corrected start-end spread stuck at 12.9 m). Raising the default to 200 m
lets the far-drifted revisit reach ICP: fastlio huge loop 59.6 -> 2.3 m,
pointlio 10.8 -> 0.09 m, with no previously-good mid360 map regressing. go2
is unaffected (its blueprint overrides this to 13.42 m).
Raising loop_candidate_max_distance_m 80->200 closes the FAST-LIO huge loop
(59.557 -> 2.275 m) and tightens pointlio (0.329 -> 0.093 m); no previously-good
mid360 row regresses to HARM. Also benches the two sf_office_survey go2
recordings (survey1 no-harm, survey2 marginal HARM) that were previously skipped.
@github-actions github-actions Bot added ready-to-merge Required CI checks have passed on this PR and removed ready-to-merge Required CI checks have passed on this PR labels Jul 25, 2026
Use the store's context-manager protocol (with SqliteStore(...) as store)
so stop() runs on early SystemExit, exceptions, and GeneratorExit. Replaces
the leaking start()/stop() pairs in evaluate() and load_tag_detections(),
and collapses the existing try/finally sites (registered_scans, replay _load).
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Jul 25, 2026
Comment thread dimos/navigation/jnav/components/loop_closure/gsc_pgo/scripts/post_process.py Outdated
Comment thread dimos/navigation/jnav/components/loop_closure/gsc_pgo/scripts/post_process.py Outdated
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jul 25, 2026
Wrap main() in `with SqliteStore(...) as store:` so the store's file
handle and background thread are released on every exit path (greptile
P1: previously only stop()'d on the happy path). Guard the corrected
raycast_accumulate with `corrected_store_tf is not None` so --no-odom
+ --accum warns and skips instead of silently writing an empty cloud.
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Jul 26, 2026
post_process.py already imports this to place AprilTag detections for
recordings that carry a camera_info stream but no tf tree; the import
was committed without its definition, leaving mypy/lint red.
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jul 26, 2026
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Jul 26, 2026
Comment on lines +346 to +352
lockstep: bool = True,
drift_per_sec: list[float] | None = None,
drift_t0: float = 0.0,
) -> tuple[list[GraphPose], int, dict[str, Any]]:
"""Replay the recording through the module; return its optimized pose graph
(with orientations), loop-closure count, and replay stats.

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 Drift injection broken by Unix-epoch drift_t0 default

run_module_graph defaults drift_t0=0.0, so when a caller passes drift_per_sec=[dx, dy, dz] the computed per-message offset is drift * (unix_timestamp - 0.0)drift * 1.7e9 s. A "1 cm/s" injection immediately produces a ~17 000 km offset rather than a small perturbation from the start of the recording. The feature is documented and wired up, but its default value makes it effectively unusable without explicitly supplying a recording-start timestamp.

The fix is to auto-detect drift_t0 from the first message timestamp inside run_module_graph when drift is non-zero, e.g. drift_t0 = messages[0][0] after the load call.

Comment on lines +358 to +362
output_path = Path(tempfile.gettempdir()) / f"jnav_lc_eval_{db_path.parent.name}.json"
output_path.unlink(missing_ok=True)
done_path = Path(tempfile.gettempdir()) / f"jnav_lc_eval_done_{db_path.parent.name}.json"
done_path.unlink(missing_ok=True)
Path(str(done_path) + ".progress").unlink(missing_ok=True)

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 Temp-file names keyed only by parent directory name — concurrent runs collide

output_path and done_path are keyed by db_path.parent.name (e.g., "2026-06-04_12-56pm-PST"). Two parallel calls to run_module_graph for any two recordings that share the same parent directory name (a common situation in a batch benchmark runner that processes multiple mem2.db files from same-named sub-directories, or re-runs of the same recording) will overwrite each other's files. A late arrival from run A could feed stale graph data into run B's json.loads at the very end. Using tempfile.mkstemp or including more path components (e.g., db_path.stem + "_" + db_path.parent.name) in the name would remove the collision.

@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jul 26, 2026
# Conflicts:
#	dimos/robot/all_blueprints.py
Comment on lines +306 to +330
)
cloud = o3d.geometry.PointCloud()
cloud.points = o3d.utility.Vector3dVector(points.astype(np.float64))
if intensities is not None:
cloud.colors = o3d.utility.Vector3dVector(
np.repeat(intensities.astype(np.float64)[:, None], 3, axis=1)
)
cloud, _keep = cloud.remove_statistical_outlier(LCM_OUTLIER_NN, LCM_OUTLIER_STD)
merged_xyz = np.asarray(cloud.points, np.float32)
merged_intensities = (
np.asarray(cloud.colors, np.float32)[:, 0] if intensities is not None else None
)
merged = PointCloud2.from_numpy(
merged_xyz, frame_id=world_frame, intensities=merged_intensities
)
merged.ts = stamp
lcm_path.write_bytes(merged.lcm_encode())
print(
f"wrote {lcm_path}: 1 aggregated cloud, {len(merged_xyz):,} pts (voxel {voxel} m)",
flush=True,
)


def raycast_accumulate(
store: Store,

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 Silent empty-cloud written when store_tf is None

raycast_accumulate accepts store_tf: RecordingTF | None, but when it is None the inner loop's continue skips every scan, leaving mapper empty and last_ts = 0.0. The function then writes PointCloud2.from_numpy([], ..., timestamp=0.0) into <in_stream>_accumulated — a silently wrong empty result at epoch-zero timestamp.

The caller in post_process.py correctly guards the corrected-lidar call (if corrected_store_tf is not None:) but does NOT guard the first call for the raw lidar stream. Passing --no-tf (which sets store_tf = None) combined with --accum therefore silently produces an empty raw-accumulated cloud while iterating the entire lidar stream for nothing. An early-return guard at the top of raycast_accumulate when store_tf is None (or a matching guard at the call site in post_process.py) would surface this as a clear diagnostic.

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