Skip to content

refactor(backend): keep the unmerged graph odometric; derive merged poses via per-node transforms#157

Draft
harelb wants to merge 7 commits into
MIT-SPARK:developfrom
harelb:feat/odometric-unmerged-deform
Draft

refactor(backend): keep the unmerged graph odometric; derive merged poses via per-node transforms#157
harelb wants to merge 7 commits into
MIT-SPARK:developfrom
harelb:feat/odometric-unmerged-deform

Conversation

@harelb

@harelb harelb commented Jul 8, 2026

Copy link
Copy Markdown

Summary

Eliminates deformation compounding by construction: the backend's unmerged/source graph is never written by deformation — it stays odometric and IS the source of original values. The deform callback reads odometric source attributes and writes position, bounding box, and (new) world_R_object field-wise into the merged graph only (applyNodeDeformation, unit-testable free function). NodeCache records each node's last odometric→optimized transform so merge hooks can bring attributes rebuilt from odometric constituents into the optimized frame.

Supersedes-in-spirit the narrower cached-copy approach of #153 (that PR fixes the object-stamp bug within the old design; this one removes the class of bug — happy to coordinate which lands, cc that discussion).

Depends on #154 (stacked on its branch): with the old always-solve-after-first-LC behavior gone, post-LC spins receive cached optimizer values, which is what re-derives merged active nodes reset by mergeGraph. The two were developed and validated together.

Commits

  1. hoist per-spin bookkeeping out of the update-functor loop (cleanup hooks/forced-active restore ran inside the loop → only the first functor saw them) + new test_dsg_updater.cpp
  2. updateFromValues no longer mutates unmerged places (+test)
  3. core: odometric invariant, applyNodeDeformation, NodeCache::last_transform, traversability merge hook re-applies the survivor's last transform; imports the full test_deformation_interpolator.cpp suite (no-compounding, transform caching, field-wise writes, bbox/rotation)
  4. pass-0 find_merges on the merged graph (duplicates revealed by an LC only overlap in the optimized frame) + forced-active flags restored on target copies too (a stuck flag blinds archived-only merge candidate searches)
  5. test fix: object-merge tests updated to merged-graph merge-finding semantics

Semantics reviewers should weigh in on

  • Saved-graph semantics change (user-visible): the backend's internal source graph is now odometric; the optimized map is the merged graph (published / saved-with-mesh). Downstream consumers of the unmerged save see odometric geometry.
  • Staleness by design: archived merged nodes are re-derived on LC cycles only (active-tracker views bound the per-spin work); between solves they hold their last optimized pose.
  • Known limitation: KhronosObjectAttributes trajectory fields (first/last observed positions) are not transformed.
  • Edge: UpdateObjectsFunctor falls back to attribute positions when mesh_connections is empty; behavior preserved but worth a look.

Validation

  • 173/173 unit tests on this branch (includes the new deformation-interpolator suite).
  • Fork field results (38-min indoor bag, ~250 loop-closure edges): re-deforming archived nodes is idempotent (unit-verified; save-level md5 also matched), objects-on-mesh median 0.119 m vs 0.121 baseline, traversability places-above-floor median 1.45 m → 0.50 m, and a full-flood run (235 LC edges in one session) with map quality holding at 0.106 m median and no regressions.

harelb added 7 commits July 8, 2026 16:10
have_loopclosures_ is latched forever, so after the first LC every spin
ran a full KimeraRpgo batch solve (1.86s x 314 spins for ONE LC in the
box_7 run = 41% of backend time), starving the queue and lagging the
published DSG by 15-20 min. Solve only when new LC factors arrived;
between solves deform with the cached optimizer values (temp values
cover not-yet-solved nodes).
backend/optimization and backend/mesh_update were never registered, so
dsg_pgmo_status.csv optimize_time/mesh_update_time were always NaN.
The cleanup-hook invocation, the forced-active flag restore, and the
new-node status clear all ran inside the per-functor loop, so only the
first functor of each spin observed forced-active nodes and new-node
status, and earlier functors' cleanup hooks re-ran once per remaining
functor. Move all three after the loop so they happen once per spin.
…lues

updateFromValues wrote the optimized position through the unmerged
layer view before cloning into the merged graph, leaking optimized
state into the unmerged (odometric) graph. Clone first and only write
the merged graph, and find place merges on the merged graph like the
object tests already do.
… via per-node transforms

Partially supersedes the narrow init_pos cache: with the in-place
unmerged position write removed, the unmerged graph is odometric by
construction and IS the source of original values, so the cache no
longer needs to freeze per-node state.

- the deform callback no longer writes into the unmerged graph; it
  reads the odometric source attributes and writes position, bounding
  box, and (new) world_R_object field-wise into the merged graph only,
  via applyNodeDeformation (field-wise so merged-only state, e.g.
  merge-accumulated mesh connections, survives)
- NodeCache drops init_pos freezing, refreshes unconditionally, and
  records each node's last odometric->optimized transform
- the traversability merge hook re-applies the surviving node's last
  transform to attributes rebuilt from odometric unmerged constituents,
  keeping merged parents in the optimized frame after
  applyMerges/updateAllMergeAttributes
- documents the odometric invariant on unmerged_graph_/source_graph_
  (relies on every post-LC spin receiving optimizer values - fresh from
  a solve or cached, per the new-loop-closure gating - so merged active
  nodes reset by mergeGraph are re-derived)
Pass-0 merge finding ran on the source (unmerged) graph, which is
odometric: duplicates revealed by a loop closure only overlap in the
optimized frame, so post-LC duplicates were never proposed. Run pass 0
on the target graph like the exhaustive passes already do (applyMerges
already remaps proposals through prior merges).

Move the forced-active hack before the graph merge so NEW-but-archived
nodes are also active in the target graph's views, and restore the
flag on the target copies at the end of the spin (they are never
re-cloned once the source node is archived again, so the flag would
otherwise stick forever).
The image-storage rework of UpdateObjectsFunctor::call stopped writing
mesh-derived geometry back into the unmerged graph (clone-first), so
find_merges run against the unmerged graph sees stale zero-extent boxes
and proposes nothing. Merges are found on the merged graph, which is
where the updated geometry lives; point the test helper there to match
(the DsgUpdater pass-0 call site changes separately).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant