Skip to content

relocalization: measured lidar aligner, ground-truth eval, replay demo - #3890

Open
leshy wants to merge 27 commits into
mainfrom
feat/ivan/relocalize2
Open

relocalization: measured lidar aligner, ground-truth eval, replay demo#3890
leshy wants to merge 27 commits into
mainfrom
feat/ivan/relocalize2

Conversation

@leshy

@leshy leshy commented Sep 2, 2026

Copy link
Copy Markdown
Member

dimos run relocalize-mid360 --seek 60

readme, and eval guide is WIP, just sharing so SF team can integrate, reloc module/type spec is stable

leshy added 20 commits September 2, 2026 16:22
Eval takes a recording, a premap built from another stretch of it, and a
window; ground truth is the identity, since both live in the recording's
own frame. Probes are placed across the window and labelled by how much of
each one the premap contains - a place it never saw is not excluded but is
the negative half of the test, where the only right answer is a refusal.

Scored on hit rate over answerable probes, false fixes over all of them,
displacement when right, and latency plus CPU. Tuned with optuna through
dimos/evals/tuning.py, which is a study helper and not a framework: the
knobs are whatever the objective's suggest_* calls declare.

RelocalizeConfig extracts every literal that was hardcoded in relocalize(),
and the eval found three that mattered - oriented FPFH normals,
wide-to-narrow ICP, and RANSAC restarts - taking 2/6 probes to 6/6 with no
false fixes. Premap preprocessing is hoisted into prepare(), so a live
module pays it once at start instead of on every fix.
A trial's score is one draw of `samples` probes against an unseeded RANSAC,
so the trials topping a front are partly the lucky ones - the v9 study put
12 configs at 100% hit and 45 more one probe behind, with nothing to tell
the two groups apart. `verify` re-runs each candidate N times for a rate
worth trusting, then again on `probe_starts(half_step=True)`: the same
count of starts over the same window, offset by half the spacing, so a
config that only learned which handful of places are easy shows it.

Also documents that optuna sits in the `dev` group while this project sets
default-groups=["tests"], so a plain `uv run` uninstalls it as extraneous;
`tune` and `verify` want `uv run --group dev`.
… origin

_yaw_only rebuilt the rotation but kept the translation, which pivots the
cloud about the world origin. These clouds sit 50-140 m from it, so half a
degree of tilt moved them 0.36 m - already past the distance ICP looks -
and two degrees moved them 1.44 m. The correction meant to clean up a
hypothesis was destroying it.

It shows up in the v9 study: 11 of the 12 configs that scored 100% hit with
no false fixes had gravity_aligned off. The tuner was routing around this.

Now the flattening keeps the cloud's centre where the hypothesis put it and
removes only the tilt.
A probe was one guess at a fixed frame count. A robot does not work that
way - it keeps listening while it thinks, so the scans that land during a
failed match are the next attempt's evidence, for free. Attempts now repeat
until a fix clears the cutoff or the frame budget runs out, and each one
sees whatever the lidar has delivered by the time it starts: the clock sets
the frame count, not a schedule. `frames` is no longer tuned.

That prices the real tradeoff. A slower matcher gets fewer attempts in the
same wall time but each sees more scans, and latency - now the whole wait,
from the first scan to a fix or a refusal - charges for both.

It also sharpens the negative set, which the window widening to 400 s
extends to about a third of all probes: an unmatchable place now gets every
attempt to be wrongly accepted rather than one, so the false-fix rate
measures the retry loop's real risk instead of a single draw's.
RelocalizeConfig now carries trial 229 of the go2-sf-area1 study - the
candidate that held its hit rate on probes it was never tuned against,
where two others dropped forty points and quadrupled their false fixes. Its
two neighbours on the front agree to within a few percent on every field,
so this is a plateau rather than a spike.

The notable moves against the old literals: ransac_iters 500k -> 1.58M,
voxel_fine 0.5 -> 0.30, coarse_dist_factor 1.5 -> 2.73, and icp_stages 2
rather than a single pass, which is what lets a hypothesis landing a metre
out still be reached.

gravity_aligned goes back to False and ransac_restarts to 1, reverting
defaults I set earlier off an ablation that held everything else at the old
values: gravity and restarts were rescuing bad hypotheses that these FPFH
settings do not produce. Post-fix, gravity measures as a wash here.

Measured on one outdoor mid360 walk. Another sensor or a room-sized map
wants its own study.
`relocalize()` now returns the Fix or None. Refusing is a real answer - the
right one for a place the premap never saw - and the threshold that decides
it moves onto RelocalizeConfig, beside the knobs it is inseparable from. A
caller configures one object and checks whether it got a fix.

The base module's duplicate gate goes with it. It was a second place to
configure the same decision, on a field only the lidar path ever read, and
two thresholds for one question drift apart. submit() now publishes what an
implementation hands it.

`align()` keeps the always-answers behaviour for callers that need to see a
refused fix - the eval measures where rejected hypotheses actually landed,
which is most of the diagnosis.

gravity_aligned goes back on. It is simply true when both maps come from a
lidar-inertial odometry, and it removes a degree of freedom the answer
cannot use; the tuning measured it as a wash once the pivot bug was fixed,
and a wash on a walk whose hypotheses rarely tilt is not evidence against
the cases where they do.

fitness_threshold ships at 0.5, in the gap between the two populations the
eval measured: right-place fixes at 0.58-0.76, absent places at 0.13-0.17.
global_map/local_map, Prepared's fields and the registration results were
all `Any`, leaving the reader to infer what to pass from a voxel_down_sample
call three functions down. They are PointCloud, Feature and
RegistrationResult, imported under TYPE_CHECKING since open3d stays a lazy
import inside the functions - it is heavy, and a module-scope import would
cost every process that merely touches this file.

Needed `from __future__ import annotations`: Prepared is a NamedTuple, so
its annotations were evaluated at import and a type-checking-only name
raised NameError.

open3d ships no stubs and is in mypy's ignore_missing_imports, so these
widen to Any regardless - they document the contract, they do not enforce it.
align() took a global_map it ignored whenever `prepared` was passed - which
every real caller does - and took a config that prepare() had already been
given, so preparing at one voxel and matching at another was a thing you
could silently do.

Prepared becomes PreparedMap and holds the cloud, its derived forms and the
config that produced them. align(premap, local_map) and
relocalize(premap, local_map) then need nothing else: the derived forms only
mean anything under the settings that built them, so those settings travel
with them rather than being asked for again.
lidar/relocalize.py is now the algorithm - RelocalizeConfig, PreparedMap,
Fix, prepare, align, relocalize - with no streams, no ports, no clock and
no dimos runtime beyond a pydantic base. lidar/module.py keeps what a
module is for: resolving the map file, throttling the cloud stream, and
turning a Fix into a TF.

The eval already only wanted the algorithm, and now says so in its imports.
gravity_aligned and _yaw_only were one feature - the flag existed to call
the function - and the evidence for it does not hold up. The ablation that
sold it held every other knob at the old values, so what it really measured
was gravity rescuing hypotheses that the tuned FPFH settings do not
produce. Measured honestly, after fixing the pivot bug it introduced, it
was a wash.

Removing it also returns the shipped defaults to exactly the configuration
verify checked: trial 229 had gravity_aligned off, and turning it on was my
override, not a measurement.

Tilt stays in the eval as a diagnostic - a tilted answer is still provably
wrong, which is worth seeing - it just no longer steers the aligner.
…nt floor

LidarRelocalizer(global_map, config) replaces prepare()/PreparedMap and the
free functions. The map's preprocessing is the pipeline's dominant cost and
never changes between queries, so it belongs in a constructor; holding the
config there too means the derived forms and the settings that built them
cannot drift apart. Callers now build one and call .relocalize(local_map).

RelocalizeConfig's numbers are scales - voxel sizes, radii, correspondence
distances - so they belong to a rig, not to relocalization. PRESETS names
them: `mid360` is what the go2-sf-area1 study measured, and a new sensor
adds an entry instead of nudging that one. `eval run --preset` selects.

min_local_points was 50_000, sized for a denser mapper. A mid360 sweep is
~2.8k points; two of them voxel down to ~3.5k, which relocalizes fine, and
twenty frames reach only ~13k. The module would have skipped every cloud
this rig produces. Now 1_000, which only rejects a nearly empty map.

readme gains the tuning procedure end to end, including the two ways this
preset was nearly got wrong: an ablation that moves one knob while the rest
sit at stale values measures the interaction, and a threshold tuned for one
call pattern does not transfer to another.
It went out on an interval from startup, regardless of whether anything had
been matched. The premap is stamped in the `map` frame, and `map` does not
exist until a fix relates it to `world`, so every one of those publishes
before the first match sent a cloud whose frame nothing could resolve - a
viewer either drops it or draws it at identity, which is worse.

Gated with the same with_latest_from the base already uses to republish tf:
it emits nothing until the first fix lands, and republishes for late
subscribers after that.
The two module.py files had no stated boundary, so runtime bits sat on
whichever one they were written into. Draw it at what every strategy
shares: a prior map and a Fix, hence `tf` and `loaded_map`.

Base: Fix, both Out ports, accept() to invert a fix into the world -> map
transform, set_premap() to stamp the map frame and gate its republish on a
fix. Lidar: the global_map input, the .pc2.lcm premap, min_local_points,
reloc_interval, the relocalizer. Matching apriltags or GPS shares none of
the latter, so none of it is in the base.
loaded_map is a PointCloud2 on every strategy, so the .pc2.lcm behind it is
the base's concern too: map_file, the load, the map-frame stamp and the
gated republish all move up, and set_premap() goes away with them.

An implementation now reads self.premap after super().start() - None means
no map was configured - and is left with only what the base cannot know:
its inputs, when to attempt a fix, and how good one must be.
Implementations inherit this alongside their own module's methods, where a
bare `accept` says nothing about what is being accepted.
The matrix was open3d's output shape leaking through the contract, and it
left the frames implicit - accept_relocalization had to know which way round
the matrix ran and stamp map/world onto it from the outside.

The aligner stamps it now, so the direction travels with the value and
submit's frame assertion doubles as a check that an implementation stamped
its Fix correctly. accept_relocalization is one line. The eval, which does
matrix arithmetic on the placement, calls to_matrix().
Inlier RMSE is ICP's and margin is the spread across RANSAC restarts;
neither means anything to a GPS or apriltag fix, and defaulting them to 0.0
in the base only hid that.

Fix is now a frozen dataclass of the two fields every strategy has, and
LidarFix subclasses it with the two the pipeline actually produces. Nothing
acts on either - accepting is still fitness against fitness_threshold.
The class carried mid360's measured numbers as field defaults, so a bare
RelocalizeConfig() silently meant one particular Livox on one particular
walk. The scales - voxel sizes, neighbourhood radii, correspondence
distances - are now required fields, and MID360 spells all of them out under
a name that says which rig they came from. LidarRelocalizer's config
argument loses its default for the same reason.

What keeps a default is the handful of search budgets and caps (max_nn,
ransac_confidence/n, icp_max_iter): those are about how hard to look, not
how big the world is, so they are not per-rig.

Left the class name alone: eval.py builds one from optuna params while
tuning whatever rig you point it at, and LidarConfig.relocalize annotates
any rig's settings, so Mid360Config would be wrong in both places. The
preset instance is where the rig's name belongs.
…st fix

blueprints.py runs the stack without a robot: a RecordingPlayer replays a
recording's registered scans at wall-clock rate (seek/duration/speed/loop)
into the same VoxelGridMapper the Go2 stack uses, so LidarRelocalization
meets the global_map it would on hardware. Recording and premap are the
eval's dataset, so a demo that looks wrong and an eval that scores badly are
the same bug. Measured on the mid360 walk: fix in 0.5 s, fitness 0.840,
0.14 m off identity.

Watching it run showed the module re-matching forever after succeeding, so
Config grows relocalize_once (default on): a premap fix does not go stale
the way odometry does, the accepted TF is republished either way, and every
further attempt is CPU plus a chance to overwrite a good answer with a worse
one. keep_relocalizing() is on the base because that reasoning is not
lidar's; the lidar module gates its input on it.
One caller, and the wrapper was mostly docstring - the parts worth keeping
(studies resume by name, browse with optuna-dashboard) are already in
lidar/readme.md. create_study with a seeded sampler and load_if_exists sits
in `tune` where you can see it while reading the search space.
docs/capabilities/navigation/relocalization.md is the Go2 how-to, not this
refactor's documentation. Back to main's version; what the split and the
tuning look like is in lidar/readme.md and the modules' own docstrings.
@greptile-apps

greptile-apps Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds lidar relocalization evaluation, parameter tuning, holdout verification, and replay support. Runtime checks found that holdout verification uses fewer probes than the tuning set, custom recordings with --from but no --to generate timestamps beyond the recording and fail during map accumulation, and all evaluation commands accept zero samples before crashing instead of reporting invalid input.

Confidence Score: 3/5

The evaluation and verification commands need correction before they can reliably assess or tune relocalization behavior.

Focused runtime checks reproduced mismatched verification sample populations, invalid custom-window probe scheduling, and zero-sample crashes across the evaluation commands.

Files Needing Attention: dimos/mapping/relocalization/lidar/eval.py, especially holdout probe generation, custom dataset window resolution, and sample-count validation.

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex posted a proof for a P1 finding, including the focused probe-starts runtime harness and its runtime output.
  • T-Rex reproduced the controlled relocalization window using the reproduction source and observed the unbounded-window output.
  • T-Rex executed a zero-sample mock harness and captured the zero-sample command-path output.
  • T-Rex performed general-contract-validation to identify the missing upper bound in the relocalization window and recommended deriving the end from the recording or removing the artificial bound.
  • T-Rex performed additional general-contract-validation to confirm the uploaded source and observed-output artifacts and to locate affected declarations in the codebase.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (2)

  1. General comment

    P1 Custom dataset --from without --to schedules probes beyond the recording

    • Bug
      • The executed harness registered (10.0, 1000000000.0) for a custom dataset backed by a 30-second recording, generated starts at 500000002.5 and 999999995.0, and run_probes raised ValueError: only 0 of 1 scans had a pose from 500000002s.
    • Cause
      • eval.py:581 uses 1e9 as the implicit upper window bound, and probe_starts honors that explicit stored window rather than using the lidar stream's actual end time.
    • Fix
      • For a custom recording without --to, determine the window end from the lidar stream or preserve an unspecified upper bound for probe_starts to resolve from the stream.

    T-Rex Ran code and verified through T-Rex

  2. General comment

    P1 Zero samples are accepted and crash lidar evaluation commands

    • Bug
      • All three commands accept --samples 0; their mocked runtime paths produce no probes and each raises ZeroDivisionError in summarize([]) rather than a CLI validation error.
    • Cause
      • The three Typer sample options have no positive lower bound, while summarize unconditionally divides false_fixes by len(probes).
    • Fix
      • Require samples >= 1 on each command option and optionally reject an empty probe list defensively in summarize.

    T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "relocalization: relocalize-mid360 replay..." | Re-trigger Greptile

Comment on lines +348 to +350
starts = np.linspace(lo, last, samples)
if half_step:
starts = starts[:-1] + np.diff(starts) / 2

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 Holdout probe count shrinks

When half_step=True, the final evenly spaced start is removed before shifting the remaining values. Consequently, verify --samples 2 runs two tuned probes but one holdout probe, while --samples 10 runs ten tuned probes but nine holdout probes. The verification result therefore compares unequal populations rather than the documented same-sized disjoint holdout set. Generate the requested number of shifted positions without dropping a probe.

Artifacts

Focused probe-starts runtime harness

  • This exact executable harness loads the actual eval.py implementation while mocking only unavailable import dependencies and the dataset/store seam, ending with the takeaway.

Focused probe-starts runtime output

  • This captured successful command output shows the actual implementation returning 1 holdout probe for samples=2 and 9 for samples=10, ending with the takeaway.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +579 to +582
if window_from is not None or window_to is not None:
lo = window_from if window_from is not None else (window[0] if window else 0.0)
hi = window_to if window_to is not None else (window[1] if window else 1e9)
window = (lo, hi)

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 Custom window gets fake endpoint

For an unregistered recording, passing --from without --to stores 1e9 as the upper bound. Probe generation then treats it as a real recording window: on a 30-second recording it selects timestamps at 500000002.5 and 999999995.0 seconds, and map accumulation raises ValueError because those scans do not exist. Resolve an omitted upper bound from the lidar recording, or defer resolving it until probe_starts reads the stream range.

Artifacts

Controlled relocalization window reproduction source

  • This exact executed harness invokes production registration, probe generation, and runtime accumulation against a 30-second controlled recording seam, and the takeaway is that the test directly covers the alleged path.

Observed current-code unbounded-window output

  • This captured command output records the `1e9` window, probes at 500000002.5 and 999999995.0 seconds, and the accumulation ValueError, and the takeaway is that the reported defect occurs at runtime.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +516 to +521
return (
len(hits) / answerable if answerable else 0.0,
false_fixes / len(probes),
error,
latency,
float(np.median([p.cpu_s for p in probes])),

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.

P2 Zero samples crash commands

run, tune, and verify accept --samples 0, which produces no probes. Each command subsequently calls summarize([]), where false_fixes / len(probes) raises ZeroDivisionError rather than returning a command-line validation error. Require at least one sample on each option and retain a defensive empty-probe check in summarize.

Artifacts

Executed zero-sample mock harness source

  • This is the exact Python harness executed against the unchanged evaluation module to mock unavailable services and invoke every requested zero-sample path, with the takeaway that it reaches the production summarization logic.

Observed zero-sample command-path output

  • This captured output from the executed harness shows empty probe lists and `ZeroDivisionError` for summarize, run, tune, and verify while every samples option has `min=None`, with the takeaway that zero is accepted and fails at runtime.

View artifacts

T-Rex Ran code and verified through T-Rex

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
4707 1 4706 179
View the top 1 failed test(s) by shortest run time
dimos.codebase_checks.test_no_dunder_new::test_no_dunder_new
Stack Traces | 2.12s run time
def test_no_dunder_new() -> None:
        """Fail if any test file calls `__new__` to bypass `__init__`."""
        dimos_dir = DIMOS_PROJECT_ROOT / "dimos"
        hits = find_dunder_new_calls()
        if hits:
            listing = "\n".join(
                f"  - {p.relative_to(dimos_dir)}:{lineno}: {line.strip()}" for p, lineno, line in hits
            )
>           raise AssertionError(
                f"Found __new__ call(s) in test files:\n{listing}\n\n"
                "Tests must construct objects with the real constructor: __init__ is "
                "code under test too, and an object assembled by hand silently rots "
                "when the constructor changes. If __init__ does heavy work, mock the "
                "collaborators it needs instead of skipping it. Only if that is truly "
                "impossible, add the call to the WHITELIST in "
                "dimos/codebase_checks/test_no_dunder_new.py."
            )
E           AssertionError: Found __new__ call(s) in test files:
E             - mapping/relocalization/test_module.py:33: m = RelocalizationModule.__new__(RelocalizationModule)  # no Module.__init__: no threads
E             - mapping/relocalization/test_module.py:51: m = RelocalizationModule.__new__(RelocalizationModule)
E             - mapping/relocalization/test_module.py:92: m = RelocalizationModule.__new__(RelocalizationModule)
E             - mapping/relocalization/test_module.py:71: m = RelocalizationModule.__new__(RelocalizationModule)
E           
E           Tests must construct objects with the real constructor: __init__ is code under test too, and an object assembled by hand silently rots when the constructor changes. If __init__ does heavy work, mock the collaborators it needs instead of skipping it. Only if that is truly impossible, add the call to the WHITELIST in dimos/codebase_checks/test_no_dunder_new.py.

dimos_dir  = PosixPath('.../dimos/dimos/dimos')
hits       = [(PosixPath('.../dimos/dimo.../mapping/relocalization/test_module.py'), 33, '    m = RelocalizationM....../mapping/relocalization/test_module.py'), 71, '        m = RelocalizationModule.__new__(RelocalizationModule)')]
listing    = '  - mapping/relocalization/test_module.py:33: m = RelocalizationModule.__new__(RelocalizationModule)  # no Module.__i...alizationModule)\n  - mapping/relocalization/test_module.py:71: m = RelocalizationModule.__new__(RelocalizationModule)'

dimos/codebase_checks/test_no_dunder_new.py:67: AssertionError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

…up advice

The file is about running and tuning the eval, so name it after that.

Its install line was wrong in a way that bites: it said `uv run --group dev`
for optuna, but asking for a group triggers a full sync, and a sync prunes
maturin-built extensions - so it handed you optuna and took away
dimos_voxel_ray_tracing, which every command in the file needs. Documented
`VIRTUAL_ENV=$PWD/.venv uv pip install optuna` instead, which adds the
package without resyncing, and verified `tune` runs end to end that way.

optuna.db is gitignored; it is a local study store.
The prune only happens on the sync that first installs the group. Doing
`uv sync --group dev` and *then* `maturin develop` leaves both in place, and
every later invocation - with or without --group dev - has nothing to sync
and leaves the extension alone. Verified end to end.

Replaces the `uv pip install optuna` workaround, which sidestepped the sync
instead of just ordering it.
Was a shape (`dimos map global <recording> --export`) rather than something
you could run. The real one, with --seek 400 as the point of the exercise:
the premap is the walk after 400 s, the probes come from before it, and only
because the walk is a loop do the two halves overlap in space at all.

--voxel 0.005 confirmed against the file - downsampling the shipped premap
at 0.005 is a no-op, so that is its grid. Also corrects the DATASETS snippet,
which still showed the old premap filename.
Rewrote to match the register Ivan used in his own edit to the file: short
sentences, one idea each, no em dashes, ordinary words. Same content.
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