Skip to content

Nav pt3: Clean up PGO + Tests#2099

Open
jeff-hykin wants to merge 142 commits into
mainfrom
jeff/feat/better_pgo
Open

Nav pt3: Clean up PGO + Tests#2099
jeff-hykin wants to merge 142 commits into
mainfrom
jeff/feat/better_pgo

Conversation

@jeff-hykin

@jeff-hykin jeff-hykin commented May 15, 2026

Copy link
Copy Markdown
Member

Closes #2007

Changes

  1. Make PGO publish loop-transform events and related data (pose graph)
  2. Add a meaninful loop closure benchmark

How to Test

without benchmark

source .venv/bin/activate

# native rebuild
cd dimos/navigation/nav_stack/modules/pgo/cpp && nix build .#default

# unit/integration
uv run pytest dimos/navigation/nav_stack/modules/pgo -v

# slow tests (rosbag + synthetic drift)
./bin/pytest-slow dimos/navigation/nav_stack/modules/pgo -v

With benchmark

The benchmark is KITTI 360 - a standard dataset but its stuck behind a registration page (no public download). Its also 12Gb. https://www.cvlibs.net/datasets/kitti-360/user_login.php

There's no good 100% public loop closure benchmark that I could find.

Not in this PR

  • KITTI-360 actual precision/recall numbers (needs the ~12 GB Test SLAM split downloaded manually).
  • On-start relocalization via Scan Context (the descriptor/ring-key index is already populated — wiring this is a follow-up).
  • Intensity Scan Context / SC++ — would close the gap on reverse-heavy sequences (seq 09), but not needed for the cross-wall sim case.

Contributor License Agreement

  • I have read and approved the CLA.

jeff-hykin added 10 commits May 12, 2026 07:48
… rrb

Native module (cpp/main.cpp) now publishes two new streams on every
keyframe: GraphNodes3D for keyframe optimized poses, LineSegments3D for
odometry (traversability=1.0) and loop-closure (0.4) edges. Both wire
through SimplePGO::keyPoses() + historyPairs() — no changes needed to
simple_pgo.{h,cpp} since the accessors already exist. Native binary
rebuilt cleanly via nix build .#default --no-write-lock-file.

Python (pgo.py) declares matching pgo_graph_nodes / pgo_graph_edges Out
streams so the rerun bridge auto-discovers and logs them.

nav_stack_rerun_config() now picks _agentic_debug_rerun_blueprint when
agentic_debug=True — an rrb.Horizontal layout with a 3D pane and a
dedicated top-down pane (both Spatial3DView over origin="world", named
"3D" and "top_down" so dimos-viewer persists camera state separately).

demo_better_pgo_viz.py composes the cross-wall sim blueprint with
agentic_debug=True so the new layout + pose graph render together. Used
for manual screenshot validation.
Adds visual_override entries for world/pgo_graph_nodes and
world/pgo_graph_edges that mirror the existing FAR pattern: when
agentic_debug=True, the PGO pose graph renders at z=_AGENTIC_DEBUG_LIFT
(3.0m) instead of the default 1.7m, with slightly larger node radii
(0.15) and edge thickness (0.06) so the green keyframe trajectory
stands out clearly above the terrain cloud in the top-down pane.

Verified visually via demo_better_pgo_viz with the cross-wall sim —
green keyframe nodes + edges are now plainly identifiable above
terrain in both the 3D and top_down rerun panels.
rerun's Spatial3DView doesn't have a top-down camera API, so the
"top_down" pane introduced in a7a9be9 was just a duplicate 3D view.
Drop _agentic_debug_rerun_blueprint and use _default_rerun_blueprint
unconditionally — the agentic_debug lift on visual_override is what
actually makes the pose graph and nav markers readable from any angle.
C++ side (main.cpp): when searchForLoopPairs sets m_cache_pairs (i.e.
this keyframe will be incorporated into iSAM2 with a loop factor),
snapshot the current global poses before smoothAndUpdate. After the
update, build a nav_msgs::Path-encoded LoopClosureDeltas message:
position = post.t - r_delta * pre.t, orientation = quaternion(post.R *
pre.R^T). Publish on the new pgo_loop_closure topic. Stderr logs the
event count for live observability.

Python side (pgo.py): declare pgo_loop_closure: Out[NavPath] so the
new topic is registered alongside corrected_odometry/pgo_tf/etc.

Slow test (test_pgo_loop_closure.py): replays og_nav_60s through the
native binary with permissive thresholds (loop_time_thresh=5s,
min_loop_detect_duration=1s, loop_search_radius=2m,
loop_score_thresh=0.5) so the recording reliably triggers loop
closures. Subscribes to pgo_loop_closure, logs each event the moment
it arrives (event #, poses_length, frame_id, first delta), and after
the run validates each event has >0 poses, finite translations
(<100m), and unit-norm quaternions (drift <0.05). Stdout from a run
shows 19 events, sizes 10..35, max |t|=0.0013m, max |q|-1|=1e-6 —
exactly the small-nudge profile expected from a self-consistent
recording.
Replaces the kdtree-on-keyframe-positions loop search with a Scan
Context (Kim & Kim 2018) descriptor-based pipeline:

  1. addKeyPose now also caches a polar-binned (20 rings × 60 sectors)
     max-z descriptor + the per-row mean "ring key" for each keyframe.
     The descriptor is appearance-based and pose-independent, so it
     keeps working even when odometry has drifted enough that the new
     keyframe is no longer "near" its old neighbours in pose-space.

  2. searchForLoopPairs first asks Scan Context for a candidate:
     ring-key L2 distance ranks all past keyframes, top-K are scored
     by column-shifted cosine distance on the full descriptor, the
     best below the threshold (default 0.4) is the candidate. The
     winning column shift is also converted to a yaw rotation and used
     to seed ICP, which dramatically improves convergence on revisits
     that arrive at a different heading from the original pass.

  3. Position-based search is retained as a fallback when SC is
     disabled or finds nothing, so existing behaviour is preserved.

Replaces ~50 lines of position-search with ~30 lines of SC retrieval
in searchForLoopPairs; new scan_context.{h,cpp} (~150 lines, MIT
attribution to upstream irapkaist/scancontext concepts but no source
copied) implements the descriptor + distance.

Side-effect: this makes on-start relocalization a small follow-up
addition — descriptors + ring-keys + poses are now per-keyframe state
that can be serialised, and the SC search path already does
"appearance-based pose recovery without an initial pose guess."

Verified via test_pgo_loop_closure.py: 17 loop-closure events fired
across the og_nav_60s rosbag (was 19 with naive position search; SC
is more selective and rejects two borderline-position matches that
weren't actually visual revisits). All events have valid shape + tiny
quaternion/translation deltas as expected for a self-consistent bag.
…n search misses

Adds CLI args to expose Scan Context config on the native binary
(--use_scan_context, --sc_n_rings, --sc_n_sectors, --sc_max_range_m,
--sc_top_k, --sc_match_threshold).

New slow test test_pgo_synthetic_drift.py:
- Synthesises a 4-wall point-cloud room with two distinctive interior
  columns (so the scene isn't rotationally symmetric).
- Generates an out-and-back trajectory: drives east 8m then returns
  to the origin, heading unchanged.
- Injects DRIFT_AT_REVISIT_M = 5m of additive y-drift into the
  reported odometry, ramped linearly with travelled distance. The
  body-frame scan stays byte-identical between first and second visit
  (same true sensor view of the same scene); the odom pose at revisit
  is 5m offset.
- Runs the native PGO binary twice over the same input:
  * use_scan_context=true  → expect ≥1 loop event
  * use_scan_context=false → expect 0 loop events (drift >> 1m radius)
- Dumps PGO stderr after each run for diagnostics.

Result: SC fires 10 loop closure events on the synthetic trajectory;
position-based search fires 0 — exactly the demonstration of why we
swapped to appearance-based place recognition. Both assertions pass.

Verifies the core SC value prop: appearance-based place recognition
doesn't depend on the (drifted) pose, so it keeps working when the
odometry has wandered far enough that the kdtree-on-positions search
no longer finds neighbours.
Test files now use setup_logger() / logger.info(...) per the
fix_nits rule "no print() calls in tests; use logging if diagnostics
are genuinely needed." Matches the existing test_pgo_rosbag.py
convention. Also drops the now-unused sys import.

Also clears a stale docstring on demo_better_pgo_viz.py: it claimed
the demo enabled a "horizontal 3D + top-down panes" layout, which was
reverted in 1801759 — rerun's Spatial3DView didn't support an
initial camera angle (rrb.EyeControls3D existed at the time but
wasn't used). The remaining value of agentic_debug=True is the visual
override lift, which the new docstring describes accurately.

No behavioural change. Tests still pass.
Sweep over names introduced by the better_pgo work that hit fix_nits
"expand mod -> module" rule:

- scan_context: cfg -> config (param + 12 call-sites); d (return val) ->
  descriptor in make_descriptor/make_ring_key/make_sector_key; pt -> point
  in the descriptor build loop; zf -> point_z (float cast); q_col/c_col
  -> query_column/candidate_column; q_norm/c_norm -> query_norm/
  candidate_norm; cj -> shifted_j; d (in best_distance return loop) ->
  distance with min_distance for the running best.

- simple_pgo: desc -> descriptor on the per-keyframe cache; k ->
  top_k_count for the partial-sort bound; structured-binding `auto [d,
  shift]` -> `auto [distance, shift]`.

- main.cpp: kp -> keyframe; ps -> pose_stamped (build_graph_nodes and
  build_loop_closure_deltas); a/b -> start/end and p1/p2 ->
  start_pose/end_pose in append_segment; n -> count for the loop bound;
  lc_msg -> loop_closure_msg at the publish site.

- tests: ps -> pose in the validate loop (test_pgo_loop_closure);
  c,s -> cos_yaw,sin_yaw in _yaw_rotation (test_pgo_synthetic_drift).

Names that intentionally stay short are the math-convention ones:
r/t for SE(3) rotation+translation, q for quaternion, i/j as loop
indices, idx as keyframe index, ts as timestamp, dt for time delta,
tx/ty/tz/qx/qy/qz/qw for component decomposition. The fix_nits rule
calls out mod/lc as the target pattern; expanding the math-notation
names would make the code less readable, not more.

Also drops one section-label comment ("# Log each event the moment it
arrives.") whose adjacent function name already conveys the same and
one in-loop "# node_type 1 = odom/robot" that repeats info already
stated in the function-level docstring.

Native binary rebuilt + slow test still passes (17 events, all valid).
Drops in the wiring for evaluating the PGO native module on KITTI-360.
Cannot run end-to-end yet — the dataset is gated behind a registered
login at cvlibs.net so the data download is a manual user step.

What's here:
- kitti360_loader.py: parses the KITTI-360 directory layout (data_3d_raw
  + data_poses + calibration); composes per-frame lidar→world pose by
  chaining cam0_to_world ⊕ inv(velo_to_cam). Exposes a frame iterator
  + scan_xyz(frame_id).
- loop_groundtruth.py: LCDNet/KITTI-convention groundtruth (≥50 frame
  gap, ≤4m radius), order-agnostic scoring of detected pairs.
- run_kitti360_benchmark.py: argparse CLI, spawns the native binary on
  private LCM topics, plays (registered_scan, odometry) from disk,
  subscribes to pgo_graph_edges to extract loop-closure pairs (via
  traversability ≈ 0.4 segments) and pgo_loop_closure for delta event
  counts. Writes JSON.
- README.md: download instructions for the official "Test SLAM 3D"
  12 GB package, published SOTA reference numbers from LCDNet + ISC
  papers (LCDNet 0.91-0.93 AP, Scan Context 0.62-0.78 AP), expected
  ballpark for our minimal SC port.
@codecov

codecov Bot commented May 15, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Patch % Lines
...vigation/nav_stack/modules/pgo/lockstep_harness.py 30.54% 257 Missing ⚠️
...imos/navigation/nav_stack/tests/rosbag_fixtures.py 35.71% 54 Missing ⚠️
.../nav_stack/modules/pgo/test_pgo_synthetic_drift.py 36.36% 42 Missing ⚠️
dimos/msgs/nav_msgs/Graph3D.py 73.78% 27 Missing ⚠️
dimos/msgs/nav_msgs/GraphDelta3D.py 86.07% 11 Missing ⚠️
dimos/navigation/nav_stack/main.py 33.33% 2 Missing ⚠️
...modules/local_planner/test_local_planner_rosbag.py 0.00% 1 Missing ⚠️
...modules/path_follower/test_path_follower_rosbag.py 0.00% 1 Missing ⚠️
...s/terrain_analysis/test_terrain_analysis_rosbag.py 0.00% 1 Missing ⚠️
@@            Coverage Diff             @@
##             main    #2099      +/-   ##
==========================================
- Coverage   70.86%   70.75%   -0.11%     
==========================================
  Files         852      872      +20     
  Lines       77118    77934     +816     
  Branches     6855     6919      +64     
==========================================
+ Hits        54647    55146     +499     
- Misses      20669    20989     +320     
+ Partials     1802     1799       -3     
Flag Coverage Δ
OS-ubuntu-24.04-arm 62.94% <53.08%> (+<0.01%) ⬆️
OS-ubuntu-latest 65.74% <53.08%> (-0.04%) ⬇️
Py-3.10 65.74% <53.08%> (-0.03%) ⬇️
Py-3.11 65.74% <53.08%> (-0.04%) ⬇️
Py-3.12 65.73% <53.08%> (-0.04%) ⬇️
Py-3.13 65.73% <53.08%> (-0.04%) ⬇️
Py-3.14 65.75% <53.08%> (-0.04%) ⬇️
Py-3.14t 65.73% <53.08%> (-0.04%) ⬇️
SelfHosted-Large 30.22% <34.72%> (+0.12%) ⬆️
SelfHosted-Linux 37.80% <34.71%> (+0.08%) ⬆️
SelfHosted-macOS 36.63% <34.71%> (+0.09%) ⬆️

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

Files with missing lines Coverage Δ
dimos/conftest.py 80.50% <100.00%> (+0.68%) ⬆️
dimos/msgs/nav_msgs/test_Graph3D.py 100.00% <100.00%> (ø)
dimos/msgs/nav_msgs/test_GraphDelta3D.py 100.00% <100.00%> (ø)
dimos/navigation/nav_stack/modules/pgo/pgo.py 88.73% <100.00%> (+3.54%) ⬆️
dimos/navigation/nav_stack/specs.py 100.00% <100.00%> (ø)
dimos/robot/all_blueprints.py 100.00% <ø> (ø)
...modules/local_planner/test_local_planner_rosbag.py 31.86% <0.00%> (ø)
...modules/path_follower/test_path_follower_rosbag.py 28.57% <0.00%> (ø)
...s/terrain_analysis/test_terrain_analysis_rosbag.py 33.33% <0.00%> (ø)
dimos/navigation/nav_stack/main.py 44.01% <33.33%> (-0.14%) ⬇️
... and 5 more

... and 36 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 marked this pull request as ready for review May 16, 2026 02:43
@greptile-apps

greptile-apps Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR expands the PGO (pose-graph optimization) module with Scan Context place recognition, a new pose_graph/loop_closure_event publish loop, a synthetic-drift lockstep test harness, and a KITTI-odometry benchmark runner — while reorganising the registry so the production "pgo" entry points to a frozen unrefined_pgo snapshot and the in-development binary lives separately.

  • New graph topics: pose_graph: Out[Graph3D] and loop_closure_event: Out[GraphDelta3D] are published by the C++ binary on each keyframe; the Python side adds matching Graph3D/GraphDelta3D message types with round-trip LCM codecs.
  • Scan Context integration: searchByScanContext + searchByPosition fallback added to simple_pgo.cpp, with a degeneracy gate, occupancy gate, and Lowe-ratio diagnostic; six new PGOConfig fields expose all tunable knobs.
  • Test harness: SyntheticLockstepReplay/LockstepReplay/GraphCapture in lockstep_harness.py replace the deleted raw-LCM test_pgo_rosbag.py; RosbagScanOdomPlaybackModule migrates to the coordinator framework with proper try/except/finally guards.

Confidence Score: 4/5

Safe to merge for the test harness and message-type work; the C++ loop-closure path has a known recall regression where a Scan Context candidate rejected by ICP silently prevents the position-based fallback from running.

The SC false-positive→ICP-rejection path that silently skips the position fallback remains unaddressed in searchForLoopPairs. Several issues from the previous review were resolved: RosbagScanOdomPlaybackModule gained proper try/except/finally guarding, all six SC config fields are now in PGOConfig, the port-type mismatch is fixed, and the loop_score_tresh typo is corrected throughout.

simple_pgo.cpp (SC→ICP→no-position-fallback flow) and all_blueprints.py (test/eval harness modules in the production registry).

Important Files Changed

Filename Overview
dimos/navigation/nav_stack/modules/pgo/cpp/simple_pgo.cpp Large refactor adding Scan Context search alongside position-based search, degeneracy gate, and occupancy gate. SC false-positive→ICP-rejection still silently drops the position fallback (flagged in previous review); the fallback only fires when SC returns -1, not when ICP fails a valid SC candidate.
dimos/navigation/nav_stack/modules/pgo/cpp/main.cpp Adds pose_graph and loop_closure_event publish paths; fixes loop_time_thresh/loop_score_thresh typos; snapshot pre/post iSAM2 poses correctly; drain_stale_scans flag wired through.
dimos/navigation/nav_stack/modules/pgo/pgo.py Adds all SC config fields to PGOConfig, renames pgo_tf→corrected_tf, adds pose_graph and loop_closure_event ports typed as Graph3D/GraphDelta3D. Resolves previous port-type mismatch findings.
dimos/navigation/nav_stack/tests/rosbag_fixtures.py Adds RosbagScanOdomPlaybackModule with proper try/except/finally guard and playback_error() RPC; renames LcmCollector.msg_type→message_type; adds make_isolated_lcm_url utility.
dimos/navigation/nav_stack/modules/pgo/lockstep_harness.py New file; SyntheticLockstepReplay and LockstepReplay both have correct try/except/finally guards and error() RPCs; GraphCapture correctly accumulates loop_closure_event without dropping.
dimos/robot/all_blueprints.py pgo registry entry now points to unrefined_pgo (intentional staging); five test/eval harness modules added to production registry including two with required config fields.
dimos/msgs/nav_msgs/Graph3D.py New message type; wire format matches C++ hpp; to_rerun() and to_rerun_multi() both defined correctly.
dimos/msgs/nav_msgs/GraphDelta3D.py New message type; lcm_encode/lcm_decode field order matches C++ GraphDelta3D.hpp encoding exactly.
dimos/navigation/nav_stack/modules/pgo/benchmark_kitti.py New benchmark file; baseline metric discards odom timestamps and uses index alignment (flagged in previous review at line 193); corrected-metric path is correctly timestamp-matched via _gt_at.
dimos/navigation/nav_stack/main.py Adds _pose_graph_colors_debug calling Graph3D.to_rerun_multi(), which exists on the new type; no issues with the debug visualization path.
dimos/navigation/nav_stack/specs.py New file; defines LoopClosure Protocol with registered_scan, odometry, loop_closure_event, and pose_graph ports. Clean and minimal.
dimos/navigation/nav_stack/modules/pgo/test_pgo_synthetic_drift.py New test; uses SyntheticLockstepReplay which has proper error handling; _run_pgo checks replay.error() after polling; validates SE(3) delta quaternion norms and translation bounds.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Replay as SyntheticLockstepReplay
    participant PGO as PGO (C++ binary)
    participant GraphCapture

    Replay->>PGO: odometry (drifted pose)
    Replay->>PGO: registered_scan (world-frame points)
    PGO->>PGO: addKeyPose build SC descriptor
    PGO->>PGO: searchForLoopPairs (SC ICP gate)
    alt SC candidate found and ICP passes
        PGO->>PGO: smoothAndUpdate (iSAM2)
        PGO->>GraphCapture: loop_closure_event (GraphDelta3D)
    end
    PGO->>GraphCapture: pose_graph (Graph3D, all keyframes)
    PGO->>Replay: corrected_odometry (ACK)
    Replay->>PGO: next scan (lockstep)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Replay as SyntheticLockstepReplay
    participant PGO as PGO (C++ binary)
    participant GraphCapture

    Replay->>PGO: odometry (drifted pose)
    Replay->>PGO: registered_scan (world-frame points)
    PGO->>PGO: addKeyPose build SC descriptor
    PGO->>PGO: searchForLoopPairs (SC ICP gate)
    alt SC candidate found and ICP passes
        PGO->>PGO: smoothAndUpdate (iSAM2)
        PGO->>GraphCapture: loop_closure_event (GraphDelta3D)
    end
    PGO->>GraphCapture: pose_graph (Graph3D, all keyframes)
    PGO->>Replay: corrected_odometry (ACK)
    Replay->>PGO: next scan (lockstep)
Loading

Reviews (74): Last reviewed commit: "ci: re-trigger ci (pull_request trigger ..." | Re-trigger Greptile

Comment thread dimos/navigation/nav_stack/modules/pgo/cpp/scan_context.h
- run_kitti360_benchmark: type the scipy Rotation.as_quat result to
  silence no-any-return.
- demo_better_pgo_viz: annotate build_blueprint() -> Blueprint.
Comment thread dimos/navigation/nav_stack/modules/pgo/benchmark/run_kitti360_benchmark.py Outdated
shift comes from best_distance, which scans [0, n_sectors-1], so the
raw yaw is in (-2pi, 0] and `yaw > M_PI` can never fire. Only the
negative-wrap guard is needed to normalize into [-pi, pi].
dimos/ disallows __init__.py files (test_no_init_files) — the empty
one in pgo/benchmark/ slipped in with c7fd631 and was tripping the
3.14 test job.
Comment thread dimos/navigation/nav_stack/modules/pgo/benchmark/run_kitti360_benchmark.py Outdated
mypy can't infer parameter types on a lambda subscribed to LCM; lift
the body into a tiny factory function with explicit
Callable[[str, bytes], None] signature so the lint job passes.
Comment thread dimos/navigation/nav_stack/benchmarks/pose_graph_kitti360/kitti360_loader.py Outdated
sklearn doesn't ship a py.typed marker; the new place_recognition_ap
benchmark is the only sklearn user in dimos, so a per-import ignore is
the smallest fix to unstick the lint job.
Comment thread dimos/navigation/nav_stack/modules/pgo/pgo.py Outdated
Naming sweep across dimos/navigation/nav_stack/modules/pgo per fix_nits
review conventions: expanded short locals (frac, true_pos, dt, ts, pos,
yaw, msg, sub, idx, fid, gt, cfg, desc, dots, sims, dists, pa/pb, …) to
full descriptive names in the benchmark scripts, the synthetic-drift
test, and the loop-closure test.

Greptile P1 fixes on PR #2099:
- c2: benchmark sender and the timestamp_ms→frame_id cache now share a
  single _compute_send_timestamps source of truth. Previously the cache
  was keyed by raw KITTI timestamps while the runner sent
  max(raw_ts, 1.0 + index*0.001), so early-frame endpoints in PGO loop
  edges never matched the cache and were silently dropped — deflating
  recall without any warning.
- c3: load_kitti360_sequence now raises on .bin/timestamps.txt length
  mismatch instead of silently leaving timestamps={}, which previously
  caused the benchmark to report recall=0 with no indication that the
  timestamp file was unusable.
- c1 follow-up: rename the misspelled C++ field loop_score_tresh →
  loop_score_thresh in simple_pgo.{h,cpp} and main.cpp. The CLI flag
  was always spelled correctly; this is cosmetic but removes the source
  of confusion greptile (correctly) flagged.

New regression test (test_pgo_synthetic_drift.py):
test_scan_context_catches_reverse_loop drives the synthetic robot out
8m facing east, turns 180°, and drives back facing west. Body-frame
scans on the return leg are rotated 180° relative to outbound, so this
exercises the init_guess fix in searchForLoopPairs (yaw rotated about
the source keyframe instead of the world origin). Reverting that fix
reproduces the failure.

New runners:
- benchmark/place_recognition_ap.py: apples-to-apples place-recognition
  AP eval against published Scan Context numbers. Reports AP=0.97-0.98
  on seq 02/04/08 of the Test SLAM split, with precision 1.000 at the
  Kim & Kim 0.13 threshold.
- benchmark/smoke_test.py: ~10s liveness probe that subscribes to all
  six PGO output topics, captures stderr, and prints per-topic message
  counts plus a one-line verdict — used to distinguish "PGO crashed"
  vs "no keyframes" vs "no loops" vs "alive" during debugging.

The benchmark runner also now captures PGO's stderr and dumps it
behind --print-stderr; previously its diagnostic prints (keyframes,
loop-closure events) were discarded.
@jeff-hykin jeff-hykin changed the title feat(pgo): pose-graph viz + Scan Context loop closure + KITTI-360 benchmark scaffolding Enable PGO topics, Optimize PGO, and add KITTI-360 benchmark scaffolding May 16, 2026
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jun 15, 2026
@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 Jun 16, 2026
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Jun 17, 2026
Relocate the make_gt_china GT pipeline into dimos2 as a self-contained `pgo/`
package so it no longer depends on the dimos3 jnav clone. Vendors the helpers it
needs under pgo/utils/ (recording_db, trajectory_metrics, voxel_map, apriltags);
everything else resolves from this clone's own `dimos` package.

- detect_tags.py: build the unfiltered raw AprilTag stream (with per-detection
  gate diagnostics). Args: --rec (required), --camera, --tag-size, --dict,
  --intrinsics, --out.
- post_process.py: two-stage solve (quality-weighted tag PGO + ICP loop-closure
  refinement) -> <out>_odometry / <out>_lidar. --rec now required (no china
  default). Args for stream sources: --lidar, --odom, --tags, --out, --suffix,
  --no-icp. Emits a <out>_lidar.pc2.lcm log (--no-lcm) and builds+opens a
  comparison rrd (--no-rrd). Flushed progress logging on the slow ICP stages.
- make_rrd.py: parameterized comparison-rrd builder (importable build()).

Verified numerically identical to the original dimos3 run on china_office1
(262 tag factors, 44526 ICP closures, 8.8 m max shift).
- Stamp each corrected PointCloud2 (m.ts) before append: dimos2's
  PointCloud2.lcm_encode does int(self.ts) with no None-guard (dimos3's has
  one), so the db-write codec crashed on the lidar stage. Latent dimos2 bug;
  worked around here.
- The .pc2.lcm is now a single aggregated cloud (voxel-downsampled +
  statistical-outlier-removed) instead of one event per scan. Memory bounded
  via incremental per-chunk voxel collapse; intensity preserved through
  open3d's color-channel averaging. New flag: --lcm-voxel (default 0.05).
@@ -135,28 +142,28 @@ class LcmCollector:
"""Subscribes to an LCM topic and collects decoded messages with timestamps."""

topic: str

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 msg_type rename leaves test_far_planner_rosbag.py broken

This PR renames the LcmCollector field msg_typemessage_type. Three test files in the diff were updated accordingly, but test_far_planner_rosbag.py (which was not in this diff) still passes msg_type=PointStamped at line 220. Because LcmCollector is a @dataclass, the old keyword argument is now invalid, and that test will raise TypeError: LcmCollector.__init__() got an unexpected keyword argument 'msg_type' the moment the fixture runs.

CI's mypy runner has no gtsam installed, so the bare '# type: ignore[import-untyped]'
on these two imports didn't cover the import-not-found error -> lint failed and
fail-fast cancelled the whole test matrix. Match the repo's established
'# type: ignore[import-not-found,import-untyped]' pattern.
The repo's no-sections policy test forbids '# --- section ---' / '# === section ==='
style comment banners. The relocated pgo/ scripts carried several from the original
make_gt_china.py, and the PR's own ivan/unrefined pgo modules + vendored apriltags
had a few too. Convert them to plain comments / drop pure separators.
The branch renamed the pgo blueprint (pgo.pgo.PGO -> unrefined_pgo.module.PGO) and
added rate-replay, but the generated registry was never refreshed. Regenerate it.
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jun 20, 2026
Relocalization loads the premap via PointCloud2.lcm_decode(read_bytes()),
which expects a bare lcm_encode() message (matching dimos map --export).
post_process wrapped it in an lcm.EventLog, so the file failed to decode.
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Jun 21, 2026
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jun 21, 2026
Restore detect_tags/post_process/make_rrd under modules/pgo/scripts/ (recovered
from history, rewired onto eval_utils, sys.path hack removed). Add a navigation
map-postprocessing guide.
…t replay

Cross-talk on the default LCM multicast channel silently wedged every
lockstep eval: a live odometry message from another dimos clone, stamped
with wall-clock time, poisoned PGO's monotonic out-of-order guard so every
replayed (older-timestamp) scan was dropped and the replay timed out on
each one forever.

Give each eval its own multicast port via LCM_DEFAULT_URL — set pre-launch
in eval_all's cell subprocess (so the forkserver workers inherit it) and via
re-exec in eval.py for direct runs. Omit a literal "?ttl=0" query string: it
hangs the worker transport setup, and udpm already defaults to ttl=0 so
loopback isolation is preserved. Also gate PGO's debug scan prints behind
g_debug so they can be toggled.
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Jun 21, 2026
…son report

- scripts/add_april.py: one step builds raw_april_tags (unfiltered) + april_tags
  (gated) and writes an april_tags section (filter_parameters + per-tag revisit
  result, incl. all_unfiltered_tag_ids) into each recording's summary.json;
  --summary (read-only), --output, --dynamic (drop moving tags from filtered).
- apriltags.py: relax gates to post_process values + make them the single source
  of truth; refactor into reusable detect_raw_detections / gate_detections /
  write_april_streams / ensure_april_streams / gate_params.
- post_process.py: import gate thresholds from apriltags (drop the duplicate GATE).
- eval.py: include the full corrected_trajectory in each cell summary.
- eval_results/report.md + reference_comparison.md (china_office1 added) + charts.
- docs/map_postprocessing.md: cover add_april + the summary.json april_tags section.
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jun 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Required CI checks have passed on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Switch to PGO as Native Module

1 participant