Skip to content

gsc_pgo: online and offline PGO #2587

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

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

Conversation

@jeff-hykin

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

Copy link
Copy Markdown
Member

gsc_pgo, Ports the PGO / loop-closure stack into the new jnav layout, plus a tf-tree feature for memory2 stores.

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 ports the PGO / loop-closure stack into the new jnav layout, adding the gsc_pgo native module (GTSAM iSAM2 + PCL ICP), offline post-processing scripts, an evaluation harness, and a RecordingTF helper that buffers the full tf stream for batch playback.

  • gsc_pgo/module.py: wraps the C++ binary via NativeModule; exposes lidar, odometry, and optional location_constraints inputs plus corrected outputs; publishing the initial map→odom tf transform on start() is correct.
  • post_process.py: two-stage offline solve (tag PGO → ICP loop closure); the SQL injection guard added via regex is good, but the .pc2.lcm aggregation path will crash with a bare ValueError when LIDAR_STREAM has zero scans.
  • recording_db.py / recording_tf.py: the previously flagged store-leak is now fixed with atexit.register(close_all); RecordingTF._ensure correctly loads the entire recording in one pass to preserve one-shot static tf edges.

Confidence Score: 4/5

The core PGO module and eval harness are solid; the offline scripts have narrow crash paths under unusual recordings that do not affect the live robot stack.

The .pc2.lcm aggregation path in post_process.py raises a bare ValueError whenever the lidar stream has zero scans. The make_rrd.py script retains unguarded key accesses on empty or diagnostics-free streams from prior review rounds. Neither issue affects the live unitree_go2_pgo blueprint at runtime.

dimos/navigation/jnav/components/loop_closure/gsc_pgo/scripts/post_process.py and make_rrd.py — both contain crash paths for edge-case recordings that remain open from previous review rounds.

Important Files Changed

Filename Overview
dimos/navigation/jnav/components/loop_closure/gsc_pgo/module.py Adds the PGO native module wrapping the C++ gsc_pgo binary; clean port from the original nav stack, correct stream typing and tf publishing.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/scripts/post_process.py Large offline PGO script; SQL injection guard added (good), but several crash paths remain including the new empty-lidar collapse crash found in this review.
dimos/navigation/jnav/components/loop_closure/gsc_pgo/scripts/make_rrd.py RRD visualization builder; accumulate() still crashes on all-empty-point streams, landmarks() has direct key access for lin_speed/ang_speed causing KeyError on older tag streams — flagged in previous review threads.
dimos/navigation/jnav/components/loop_closure/eval.py Comprehensive eval harness; StopIteration crash on empty odom stream when drift injection is enabled (flagged in previous thread), but the core logic is sound.
dimos/navigation/jnav/utils/recording_db.py Store cache now correctly registers an atexit handler via close_all() — the previously flagged resource leak is fixed.
dimos/navigation/jnav/utils/recording_tf.py RecordingTF subclass overrides _ensure to load the full tf stream in one pass — correct design for offline playback with one-shot static frame edges.
dimos/navigation/jnav/msgs/LocationConstraint.py New LocationConstraint message consolidating MapConstraint and GTSAM factor injection; well-documented design.
dimos/robot/unitree/go2/blueprints/smart/unitree_go2_pgo.py Blueprint wires PointLio + PGO + Rerun visualizer; n_workers=3 appropriate for the three module types.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Rec as Recording DB
    participant PP as post_process.py
    participant GTSAM as GTSAM (LM)
    participant ICP as Open3D ICP
    participant Out as Output Streams

    PP->>Rec: read raw_april_tags stream
    PP->>Rec: SQL SELECT from odom stream
    PP->>PP: select keyframes
    PP->>GTSAM: Stage 1 - tag PGO
    GTSAM-->>PP: corrected keyframe poses
    PP->>Rec: read lidar submaps for candidate pairs
    PP->>ICP: Stage 2 - ICP loop closures
    ICP-->>PP: accepted BetweenFactorPose3 constraints
    PP->>GTSAM: Stage 2 re-solve
    GTSAM-->>PP: final estimate
    PP->>Out: write gt_tf_deformation_nodes
    PP->>Out: write pose_graph
    PP->>Out: write gt_odometry
    PP->>Out: write gt_lidar plus pc2.lcm
    PP->>PP: build comparison RRD
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 Rec as Recording DB
    participant PP as post_process.py
    participant GTSAM as GTSAM (LM)
    participant ICP as Open3D ICP
    participant Out as Output Streams

    PP->>Rec: read raw_april_tags stream
    PP->>Rec: SQL SELECT from odom stream
    PP->>PP: select keyframes
    PP->>GTSAM: Stage 1 - tag PGO
    GTSAM-->>PP: corrected keyframe poses
    PP->>Rec: read lidar submaps for candidate pairs
    PP->>ICP: Stage 2 - ICP loop closures
    ICP-->>PP: accepted BetweenFactorPose3 constraints
    PP->>GTSAM: Stage 2 re-solve
    GTSAM-->>PP: final estimate
    PP->>Out: write gt_tf_deformation_nodes
    PP->>Out: write pose_graph
    PP->>Out: write gt_odometry
    PP->>Out: write gt_lidar plus pc2.lcm
    PP->>PP: build comparison RRD
Loading

Reviews (17): Last reviewed commit: "refactor(pointlio): remove dead config v..." | 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
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 61.88179% with 316 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/trajectory_metrics.py 15.67% 113 Missing ⚠️
dimos/navigation/jnav/msgs/Graph3D.py 35.92% 66 Missing ⚠️
dimos/navigation/jnav/msgs/GraphDelta3D.py 34.17% 52 Missing ⚠️
dimos/navigation/jnav/utils/apriltag_agreement.py 57.35% 28 Missing and 1 partial ⚠️
dimos/navigation/jnav/msgs/DeformationNode.py 47.61% 22 Missing ⚠️
dimos/navigation/jnav/msgs/LocationConstraint.py 88.34% 12 Missing ⚠️
dimos/navigation/jnav/utils/voxel_map.py 60.00% 12 Missing ⚠️
...ion/jnav/components/loop_closure/gsc_pgo/module.py 91.56% 7 Missing ⚠️
dimos/navigation/jnav/utils/kitti.py 95.00% 1 Missing and 1 partial ⚠️
...ot/unitree/go2/blueprints/smart/unitree_go2_pgo.py 90.90% 1 Missing ⚠️
@@            Coverage Diff             @@
##             main    #2587      +/-   ##
==========================================
- Coverage   72.56%   71.68%   -0.88%     
==========================================
  Files         902      918      +16     
  Lines       81820    83914    +2094     
  Branches     7396     8007     +611     
==========================================
+ Hits        59373    60156     +783     
- Misses      20548    21758    +1210     
- Partials     1899     2000     +101     
Flag Coverage Δ
OS-ubuntu-24.04-arm 64.61% <61.88%> (+0.08%) ⬆️
OS-ubuntu-latest 67.22% <61.88%> (+0.03%) ⬆️
Py-3.10 67.21% <61.88%> (+0.03%) ⬆️
Py-3.11 67.21% <61.88%> (+0.03%) ⬆️
Py-3.12 67.20% <61.88%> (+0.02%) ⬆️
Py-3.13 67.21% <61.88%> (+0.03%) ⬆️
Py-3.14 67.22% <61.88%> (+0.03%) ⬆️
Py-3.14t 67.21% <61.88%> (+0.03%) ⬆️
SelfHosted-macOS ?

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

Files with missing lines Coverage Δ
dimos/hardware/sensors/lidar/pointlio/module.py 91.58% <ø> (-0.38%) ⬇️
...os/navigation/jnav/msgs/test_LocationConstraint.py 100.00% <100.00%> (ø)
...s/navigation/jnav/utils/test_apriltag_agreement.py 100.00% <100.00%> (ø)
dimos/navigation/jnav/utils/test_kitti.py 100.00% <100.00%> (ø)
dimos/robot/all_blueprints.py 100.00% <ø> (ø)
...ot/unitree/go2/blueprints/smart/unitree_go2_pgo.py 90.90% <90.90%> (ø)
dimos/navigation/jnav/utils/kitti.py 95.00% <95.00%> (ø)
...ion/jnav/components/loop_closure/gsc_pgo/module.py 91.56% <91.56%> (ø)
dimos/navigation/jnav/msgs/LocationConstraint.py 88.34% <88.34%> (ø)
dimos/navigation/jnav/utils/voxel_map.py 60.00% <60.00%> (ø)
... and 5 more

... and 55 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
…ory2

Replace the branch's memory2 tf-tree/DbTf layer with a jnav-local
RecordingTF(StreamTF) built from the recording store, leaving memory2
net-zero vs origin/main (no changes to Ivan's code). RecordingTF full-loads
the recording's tf once so one-shot static frames aren't evicted by
StreamTF's windowed cache. post_process.py and eval.py now construct
RecordingTF.from_store(store); the live-path deformation feature in
protocol/tf is kept and its stale DbTfSql comment refs scrubbed.
Comment on lines +299 to +302

keyframe_indices = [0]
prev_rot, prev_pos = row_pose(odom_rows[0])
for row_index in range(1, len(odom_rows)):

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 Crash on empty odometry stream

odom_rows[0] at line 301 throws IndexError if the SQL query returns zero rows (e.g. the stream table exists but is empty, or the recording was truncated). A recording with an empty odometry stream will produce an opaque traceback rather than a useful error. A guard with sys.exit before line 300 would surface the problem clearly.

Keep the PR focused on dimos/navigation/jnav. Revert protocol/tf, core,
and lidar recorders to origin/main; drop the unused deformation buffer and
DeformationNode consumer wiring; move DeformationNode into jnav (its only
consumer is the gsc_pgo module).

- revert dimos/protocol/tf/tf.py to main (lookup() had no callers)
- delete dimos/protocol/tf/deformation.py + test (unused)
- revert core/module.py on_drop plumbing (dead code)
- revert fastlio2/pointlio recorders and go2 static transforms to main
- move DeformationNode.py into dimos/navigation/jnav/msgs
Comment on lines +69 to +81
def accumulate(stream_name):
scans = []
for scan_index, observation in enumerate(store.stream(stream_name)):
if scan_index % SCAN_STRIDE:
continue
points = np.asarray(observation.data.points_f32())
if len(points):
scans.append(points[::3])
all_points = np.concatenate(scans, 0)
_, unique_indices = np.unique(
np.floor(all_points / VOXEL).astype(np.int64), axis=0, return_index=True
)
return all_points[unique_indices]

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 accumulate() crashes on empty or all-zero-point streams

np.concatenate(scans, 0) raises ValueError: need at least one array to concatenate when every scan in the stream is empty or the stream itself has no data. This path is reached unconditionally in build() for lidar_stream, so a recording with a sparse or missing lidar stream crashes the entire build() call — and by extension the OPEN_RRD branch of post_process.py — with an opaque NumPy traceback rather than a useful diagnostic. Adding a guard before the concatenate call (return an empty (0, 3) array or raise with a clear message) would make the failure legible.

@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jul 6, 2026
@@ -0,0 +1,66 @@
# Copyright 2026 Dimensional Inc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

needed something pure and lightweight for scoring (e.g. couldn't reuse existing voxel mapper)

detect_tags.py duplicated ensure_april_streams, which add_april.py already
calls to build raw_april_tags (same gate diagnostics) plus the gated stream
and summary. Repoint the two docstring/doc references to add_april.py.
Point the nix flake ref at github:jeff-hykin/gsc_pgo/v1.0.0 (a version
branch at the previously-pinned rev) so the pin reads as a version and can
be bumped on the gsc_pgo side without editing a hash here.
Scopes the "to" location variable to a named map so a constraint can close
a loop across maps (cross-map closure). Empty = current/default map.
Appended to the wire string group; roundtrip + defaults tests cover it.
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Jul 6, 2026
# world offset added at each time; the raw-baseline scoring must apply the
# SAME offset so it compares against what the module actually saw.
drift_per_sec = drift_per_sec or [0.0, 0.0, 0.0]
drift_t0 = next(iterate_stream(db_path, odom_stream))[0] if has_drift(drift_per_sec) else 0.0

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 StopIteration crash on empty odometry stream when drift injection is enabled

next(iterate_stream(db_path, odom_stream)) at this line raises bare StopIteration when the odom stream exists in the database (passing the name-existence guard at line 671) but contains zero rows. The caller sees an opaque traceback pointing at line 682 — no indication that the stream is empty. Since drift injection is the primary use-case for drift_t0, this path is exercised by CI/eval runs that test drift correction, making this a real failure mode rather than a theoretical one.

…kind

Supersedes the earlier single `map` string (added as a 4th field inside
the existing string group) with the consolidated design from
jeff/feat/repulsive-field-local-planner: map_id (multi-map frame system)
and kind (coarse category, defaults to the to_id URL scheme) ride as
tolerant TAIL fields after the covariance, so pre-merge payloads and the
native gsc_pgo fixed-sequence decoder both stay compatible. Also adds
from_confidence()/confidence and the (1-c)/c variance convention.
Mirrors gsc_pgo v1.1.0 C++ decode (map_id + kind tolerant tail).
@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 6, 2026
The repo's test_no_section_markers convention check rejects the '# ----'
banner comments; remove them (carried over from the source file).
Comment on lines +120 to +135
if not (
float(tag_metrics["sharpness"]) >= GATE["s"]
and float(tag_metrics["reproj_px"]) <= GATE["r"]
and float(tag_metrics["tag_px"]) >= GATE["px"]
# older tag streams lack distance/view-angle; unknown passes the gate
and float(tag_metrics.get("distance_m", 0.0)) <= GATE["d"]
and float(tag_metrics.get("view_angle_deg", 0.0)) <= GATE["a"]
and (
float(tag_metrics["lin_speed"]) < 0
or float(tag_metrics["lin_speed"]) <= GATE["lv"]
)
and (
float(tag_metrics["ang_speed"]) < 0
or float(tag_metrics["ang_speed"]) <= GATE["av"]
)
):

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 KeyError on lin_speed/ang_speed for tag streams without speed metadata

tag_metrics["lin_speed"] and tag_metrics["ang_speed"] use direct key access, but lin_speed and ang_speed are only written to a stream when _write_tag_stream(..., diagnostics=True). An older recording or a stream written without diagnostics won't carry these keys, causing KeyError and crashing build(). The immediately preceding lines already apply this pattern correctly for distance_m/view_angle_deg with .get() defaults — the same treatment is needed here. Use float(tag_metrics.get("lin_speed", -1.0)) (and likewise for ang_speed); a value of -1.0 always passes the gate because of the < 0 short-circuit already present in the condition.

Drop the local make_detector/_object_points/estimate_marker_pose/
reprojection_error_px in apriltags.py in favor of the shared
create_aruco_detector/estimate_marker_pose/marker_reprojection_error
in perception/fiducial/marker_pose.py.
@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 6, 2026
Comment on lines +116 to +139
odom_timestamps = np.array([timestamp for timestamp, _ in odom_poses])
positions_by_marker = {}
for tag_observation in store.stream(tag_stream):
tag_metrics = tag_observation.tags
if not (
float(tag_metrics["sharpness"]) >= GATE["s"]
and float(tag_metrics["reproj_px"]) <= GATE["r"]
and float(tag_metrics["tag_px"]) >= GATE["px"]
# older tag streams lack distance/view-angle; unknown passes the gate
and float(tag_metrics.get("distance_m", 0.0)) <= GATE["d"]
and float(tag_metrics.get("view_angle_deg", 0.0)) <= GATE["a"]
and (
float(tag_metrics["lin_speed"]) < 0
or float(tag_metrics["lin_speed"]) <= GATE["lv"]
)
and (
float(tag_metrics["ang_speed"]) < 0
or float(tag_metrics["ang_speed"]) <= GATE["av"]
)
):
continue
tag_pose = tag_observation.data
closest_base_pose = odom_poses[
int(np.argmin(np.abs(odom_timestamps - float(tag_observation.ts))))

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 ValueError when the gt_odom stream exists but has zero rows

landmarks(gt_odoms[0]) is called only when gt_odoms is non-empty — meaning the stream name exists — but not when the stream has any rows. When store.stream(gt_odom) yields nothing, odom_poses = [] and odom_timestamps = np.array([]). The first tag observation that passes the gate then executes np.argmin(np.abs(np.array([]) - float(tag_observation.ts))), which raises ValueError: zero-size array to reduction operation fmin which has no identity, crashing the entire build() call. A recording in which post_process.py was interrupted after creating the gt_ stream entry but before writing any rows (e.g. a kill during the lidar-write phase) reproduces this reliably.

cube_side_length, det_range, odom_only, and timestamp_unit are parsed
and forwarded as CLI args but never consumed by the compiled Point-LIO
binary: cube_len/DET_RANGE/odom_only are write-only globals (the iVox
port dropped the fov-segment logic that used them) and preprocess.cpp
hardcodes the ms timestamp scale, ignoring time_unit. Drop them from
PointLioConfig, main.cpp arg parsing, and the pcap_to_db CLI.
Comment on lines +769 to +782
if WRITE_LCM:
if buffered_points: # flush remainder
downsampled_points, downsampled_intensities = collapse(
buffered_points, buffered_intensities if have_intensities else [], LCM_VOXEL
)
aggregated_points.append(downsampled_points)
if downsampled_intensities is not None:
aggregated_intensities.append(downsampled_intensities)
# final unified voxel pass over the per-chunk results, then statistical outlier removal
merged_intensity_chunks = aggregated_intensities if have_intensities else []
downsampled_points, downsampled_intensities = collapse(
aggregated_points, merged_intensity_chunks, LCM_VOXEL
)
print(

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 ValueError crash when lidar stream has zero scans in .pc2.lcm path

collapse(aggregated_points, ...) is called unconditionally at line 779, but aggregated_points is only populated inside if len(buffered_points) >= CHUNK: and the partial-flush if buffered_points: guard above it. If LIDAR_STREAM contains zero scan observations — a truncated or interrupted recording — neither branch fires, so aggregated_points remains []. np.concatenate([]) inside collapse then raises ValueError: need at least one array to concatenate, crashing the .pc2.lcm write phase. Adding a guard before the final collapse (e.g. if not aggregated_points: ...) surfaces the empty-stream case clearly instead of producing an opaque NumPy traceback.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants