feat(relocalization): add fiducial relocalization as a prior to RelocalizationModule - #3137
Closed
AaryanAgrawal wants to merge 66 commits into
Closed
feat(relocalization): add fiducial relocalization as a prior to RelocalizationModule#3137AaryanAgrawal wants to merge 66 commits into
AaryanAgrawal wants to merge 66 commits into
Conversation
…map TF correction) Camera-based counterpart to RelocalizationModule: known marker-map poses plus a live ArUco detection recover the robot's pose via solvePnP run in reverse, publishing the world->map drift correction the same way lidar relocalization does. Reuses create_aruco_detector/estimate_marker_pose/marker_reprojection_error from marker_pose.py; no new detection or PnP code. Extends test coverage to maintainer grade: no-marker frames, unmapped tags, degenerate/high-reprojection detections, min_tags corroboration, marker-map YAML round-trip/missing-file/malformed-entry, and the static camera_info / marker_map guard clauses on MarkerLocalizationModule. Fixes a real bug the multi-tag test exposed: localize_from_detections picked good[0] (whichever detection happened to be first in detector order) instead of the lowest-reprojection-error estimate, so a noisier-but-still-gate-passing tag could silently win over a clean one even though min_tags implies multiple tags should corroborate each other. Now picks the lowest-error estimate among tags clearing the gate, deterministically at least as accurate as the best single tag.
… expose min_tags and camera frame Review feedback on dimensionalOS#2808: - load_marker_map now rejects short/scalar translations (previously zero-filled silently via Vector3's scalar branch), non-4-element rotations, and zero/non-finite-norm quaternions (previously crashed later in Quaternion.inverse() or corrupted a published pose) - min_tags exposed on MarkerLocalizationModuleConfig (ge=1) and threaded into the localization gate - camera_optical_frame configurable; TF lookup miss now logs a warning instead of returning silently
…ocalization A planar tag at weak perspective (small or near-head-on in frame) has two IPPE solutions whose reprojection errors can be near-identical while one is the flipped mirror pose (Collins & Bartoli); the absolute reprojection gate alone accepts the flip. Solve with solvePnPGeneric, drop non-finite solver output, and only trust the best candidate when it beats the runner-up by ambiguity_ratio_min (default 2.0, configurable, 1.0 disables) in reprojection error. Measured in the standalone synthetic harness: removes 100% of flip-signature poses (rot err > 30 deg) at 110 deg HFOV and 80% at 70 deg while keeping 73% of good poses; full-pipeline ATE improves 0.33 -> 0.26 m on the nominal run and a 3.15 m flip-induced worst-frame spike disappears.
A translation like [.nan, 0, 0] passed the shape check and loaded silently into the map, corrupting any pose computed from that marker. Validate translation components with np.isfinite at load time, failing loudly and naming the marker, matching the existing malformed-value behavior. Non-finite rotations were already rejected by the quaternion-norm check; a test now pins that too.
…alization A frame with no markers in view is the normal case and was logging a warning per frame (~15/s in replay); warn only when tags were seen but rejected by the gate.
…ization TF Adds map_frame to MarkerLocalizationModuleConfig (default "map", matching MAP_FRAME / RelocalizationModule's FRAME_MAP) so the module can run in shadow mode alongside RelocalizationModule (which owns world->map): - map_frame threaded into the published correction TF's child_frame_id and the localized pose's frame_id before inversion/composition - default unchanged: existing deployments and RelocalizationModule's own world->map publish see identical behavior - -o markerlocalizationmodule.map_frame=map_marker retargets the publish for side-by-side evaluation of the two correctors Tests: default frame unchanged; custom map_frame appears in the published transform and the default target is left untouched (33 tests, up from 31).
…ister module/Go2 blueprint + portable marker-map default MarkerLocalizationModule -> VisualRelocalizationModule (capability naming): MarkerLocalizationModuleConfig -> VisualRelocalizationModuleConfig, marker_localization.py -> visual_relocalization.py, marker_localization_module.py -> visual_relocalization_module.py, test_marker_localization.py -> test_visual_relocalization.py (all via `git mv`). Config override prefix is `visualrelocalizationmodule.*` (ModuleBase.name = class name lowercased). Swept every reference: the blueprint file, marker_pose.py's docstring, and every docstring/comment naming the old class. Registers the module as "visual-relocalization-module" and adds the unitree_go2_visual_relocalization blueprint (autoconnect(unitree_go2, VisualRelocalizationModule.blueprint(...))) alongside the other smart go2 blueprints, registered as "unitree-go2-visual-relocalization" so `dimos run unitree-go2-visual-relocalization` works. all_blueprints.py regenerated via `pytest dimos/robot/test_all_blueprints_generation.py`. The blueprint's `marker_map_file` defaults to the bare "office_markers.yaml", resolved via `resolve_named_path` (checked against the given path, DIMOS_PROJECT_ROOT, then the shared/LFS data dir -- never a user-specific absolute path), documented in the blueprint's own comment: override with `-o visualrelocalizationmodule.marker_map_file=/abs/path`. Verify: - uv run pytest dimos/perception/fiducial/ -q -> 66 passed - dimos/robot/test_all_blueprints.py::test_blueprint_is_valid[unitree-go2-visual-relocalization] -> passed; full Ubuntu blueprint set -> 80 passed, 3 skipped (optional deps), 41 deselected - mypy --strict on all renamed/touched files -> no issues - pre-commit (license, ruff format/check, whitespace, LFS/large-file, doclinks) -> clean - grep -ri markerlocalization dimos/ --include=*.py -> zero matches Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…en + shared judge Pulls the SCALE_PLAN FPFH+RANSAC candidate loop (+ its centroid-aware yaw flip) out into generate_ransac_candidates(), and the gravity filter -> wall rerank -> top-K ICP -> final polish tail out into refine_candidates() (now returning a winning_index alongside T/fitness, so a future caller pooling candidates from multiple sources can attribute which one won). relocalize() composes the two and keeps its exact public signature -- module.py's _relocalize and the dimensionalOS#2137 offline eval harness both rely on it unchanged. Sets up dimos/mapping/relocalization/priors.py (next commit): refine_ candidates becomes the one shared judge every relocalization prior's candidates go through, RANSAC included. Parity verified byte-for-byte against the pre-refactor implementation via a deterministic synthetic-scene script (seed=42, RANSAC_ITERS monkeypatched down for speed only) -- three runs (2 pre-refactor, 1 post) produced an identical T and fitness. That fixture is now dimos/mapping/relocalization/ test_relocalize.py's test_relocalize_parity_with_pre_refactor_baseline (added in a later commit alongside the rest of the new suite). No behavior change for any existing caller.
Priors are candidate PROPOSERS; refine_candidates (relocalize.py) stays the single referee -- no source is trusted, a candidate wins by surviving the wall-only fine-fitness rerank, never by its own reported confidence tier. - Candidate: a proposed local_map->global_map transform + source + a coarse 0..1 confidence (informational only, never read by the judge). - RelocPrior: the proposer Protocol (name + propose()). - RansacPrior: wraps generate_ransac_candidates -- the first prior, and the only one relocalize() itself uses today. - LastPosePrior: carries the last accepted relocalization forward as a single seed candidate for cheap continuous tracking. Frame convention documented explicitly in its docstring (repo rule: every pose names its frame) -- it stores relocalize()'s own map_T_world, never module.py's inverted, published world_T_map TF. - relocalize_with_priors(): pools every prior's candidates, feeds the shared refine_candidates tail, returns (T, fitness, winning_source). Compute economics (skip RANSAC when a prior candidate wins) are out of scope for this phase -- this builds the system with RANSAC as the first prior.
…suite module.py: Config.use_last_pose_seed (default False = today's behavior exactly, via the unchanged relocalize()). When True, RelocalizationModule keeps a LastPosePrior updated from each accepted answer and relocalizes via relocalize_with_priors([RansacPrior(), last_pose_prior]); the accept-log line gains "source=<winning_source>" on that path. test_relocalize.py (new): - parity: refactored relocalize() reproduces the pre-refactor baseline T/fitness on a deterministic synthetic L-room scene, within 1e-6 (the only test here that runs real RANSAC, iters monkeypatched down for speed). - judge integrity: a near-truth candidate beats several wrong-room/wrong-yaw decoys through refine_candidates' wall-fitness rerank. - priors plumbing: a stub prior's candidate wins with the right winning_source; LastPosePrior proposes nothing unset, one candidate once set. - no-bypass: a high-confidence WRONG candidate loses to a low-confidence correct one -- confidence never overrides the fitness judge. uv run pytest dimos/mapping/relocalization/ -x -q -> 6 passed. uv run pytest dimos/perception/fiducial/ -q -> 66 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…_detections Go2's equidistant-fisheye distortion coefficients were being misread as radtan k1,k2,p1,p2 by the PnP helpers: marker_detect.py already passes camera_info.distortion_model through, but localize_from_detections dropped it, so poses were solved against the wrong lens model. Regression test projects marker corners with cv2.fisheye (the real lens model), off-center where the model mismatch is largest, and requires <2cm recovery. uv run pytest dimos/perception/fiducial/ -q -> 67 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hared judge, age-decayed, toggleable - priors.py: FiducialPrior (age-gated/decayed confidence, informational only — the judge never reads it; a wrong fix must lose on wall fitness, tested) - module.py: use_fiducial_prior config (default off = today's behavior); world_map_fix In stream feeds the prior (invert-once at the boundary) - visual_relocalization_module.py: publish the world->map fix as a typed world_map_fix Out stream alongside the TF (name/type autoconnect) Offline replay of the hk_village3 recording (120 sections, PGO-silver truth): ransac 77.5% -> ransac+fiducial 95.8%; fiducial+judge 95.0% at 0.4s median vs 9.7s (the markers-visible compute-economics case). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ht seed must not orphan another source's all-tilted pool
Offline replay of the hk_village recordings caught the walkover: on
all-tilted-RANSAC frames (sparse outdoor submaps, tilts 15-44 deg) the
pool-global fallback ('upright if upright else all') let a single
near-upright stale seed discard all 34 RANSAC candidates and win unopposed
— turning wins into meter-scale failures (aggregate 56/72 -> 53/72 on
hk_village 1/5/6), one of them PASSING the 0.45 gate. FiducialPrior had
identical exposure.
refine_candidates gains sources=None (default = old path, bit-for-bit);
relocalize_with_priors passes each candidate's source so every source
falls back within itself. Regression test reproduces the walkover under
the global gate and asserts the per-source fix; repro in the field:
hk_village1 frame 831.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… full-cloud fallback
_wall_subset used to hand back the FULL cloud (floors included) when the
wall subset had < 100 points, so the "wall-only" rerank silently scored
on rotationally-symmetric floors — rotation-blind: on a floors-only
SIMULATED scene (seed 21) the old path returns fitness=1.000, with a
180-deg flip decoy scoring the same (re-derived by monkeypatching the
fallback back in).
Now refine_candidates raises InsufficientWallEvidence(ValueError) after
building the src/tgt wall subsets, naming both counts ("insufficient
wall evidence: submap walls=0, map walls=0 < 100 — skipping solve");
_wall_subset returns the subset unconditionally. Threshold kept as
MIN_WALL_POINTS = 100, marked arbitrary/untuned — inherited verbatim
from the old check, never calibrated against real sparse-wall data.
Live-path verified in module.py: _try_relocalize wraps both _relocalize
and _relocalize_with_priors in `except Exception` -> logger.exception +
return None, and _publish_tf drops None — the frame is logged and
skipped, no TF published. The design rule: a sparse-wall frame must not
quietly degrade the solve — refuse loudly and skip that frame.
Prophylactic hardening: the fallback was never observed to fire in
offline replay of the hk_village1..6 recordings, so this changes no
observed behavior — it removes a latent rotation-blind failure mode.
Regression test added (file style: plain asserts, no pytest import):
floors-only _sample_plane scene must raise InsufficientWallEvidence and
must not return a confident fitness. pytest: 11 passed. mypy: clean on
relocalize.py (the 3 test-file errors pre-date this change; verified
identical at HEAD).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l_relocalization Mechanical reflows only (a def signature, two call sites, an over-packed argument line) — `ruff format --check` fails on exactly these three files and CI's pre-commit lint gate runs ruff-format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ficientWallEvidenceError (N818) ruff N818 (error-suffix on exception names) fails CI's pre-commit lint gate and has no autofix. All usages and docstring mentions updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mport (N814) - priors.py: import block sorted; Callable now from collections.abc (typing.Callable is deprecated there) — ruff --fix output under repo config. - test_relocalize.py: the function-local `Rotation as _R` (N814, no autofix) becomes a top-level unaliased import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… nonexistent marker map marker_map_file="office_markers.yaml" was a phantom: the file exists nowhere in the repo and is not in the git-tracked LFS registry, so resolve_named_path falls through to get_data() and raises — the blueprint failed at start out of the box. Default to None instead: VisualRelocalizationModule.start() already no-ops on a falsy marker_map_file, matching RelocalizationModule's map_file=None convention. The -o override example stays in the comment. Verify: uv run pytest dimos/robot/test_all_blueprints_generation.py -> 1 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment/docstring-only sweep — every pointer a cloner of this PR can actually resolve: - relocalize.py MIN_WALL_POINTS: keep ARBITRARY/UNTUNED; anchor the never-fired observation to offline replay of the hk_village1..6 recordings (git-tracked LFS data, the loop_closure/eval.py defaults). - relocalize.py + test_relocalize.py walkover notes: 'found by the offline benchmark' -> 'found in offline replay of hk_village1' — the frame-831 repro pointer stays (the recording ships with the repo). - relocalize.py relocalize() docstring: module.py's _relocalize is the caller relying on the (T, fitness) tuple; dimensionalOS#2137's eval entrypoint shares only the (global_map, local_map) argument convention and returns a bare 4x4 — the old wording claimed the tuple satisfied it. - priors.py module docstring: name all three shipped priors (Ransac/ LastPose/Fiducial) instead of describing two of them as future work; the judge invariant binds any future prior. - priors.py FiducialPrior: decay/cutoff tuning ownership points at dimensionalOS#2137's autoresearch harness. - test_relocalize.py baseline provenance: the pin named a commit SHA that a rebase orphaned (unreachable from this branch) -> 'the parent of the split-judge commit'; the dimensionalOS#2137 program.md line paraphrased, not quoted. - test_relocalize.py: internal phase numbering dropped from the section header. - visual_relocalization.py: fisheye comment reworded forward-looking (upstream never shipped the broken path); max_reprojection_error_px=3.0 and ambiguity_ratio_min=2.0 now carry guessed-not-tuned provenance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… later stubs Commit-accretion debris: FiducialPrior was imported function-locally three times (Candidate and relocalize_with_priors were even re-imported locally while already at the top); the two later prior stubs used bare-`list`/ `object` signatures unlike the file's earlier fully-annotated stubs, and two of their params shadowed readability with `l`. All imports now live at the top; both stubs match the earlier stubs' annotations; _apply wraps its result in np.asarray. Not CI-blocking (repo mypy config excludes test_* files) — done for consistency with the file's own conventions; `uv run mypy` on the file now reports clean as a side effect (was 3 errors). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iss warnings Both warnings fire once per camera frame for as long as their condition holds — a tag in view but marginal (gate rejection), or a misnamed static TF chain (TF miss) — flooding the log at camera rate. Throttle to one line per 5 s via the same time.monotonic pattern the sibling RelocalizationModule uses for its skip warning (_maybe_log_skip). uv run pytest dimos/perception/fiducial/ -q -> 67 passed (the tf-miss warning test still sees the first, unthrottled fire). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r + the world_map_fix stream The config-reference table enumerates exactly RelocalizationModule's flags; the two new default-off flags were missing, leaving the table wrong-by-omission. Both rows state the judge invariant (candidates compete through the wall-fitness referee, never bypass it), and a sentence explains the world_map_fix In stream and its name/type autoconnect with VisualRelocalizationModule's Out. pre-commit doclinks hook on the file: Passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l+ICP reloc blueprint autoconnect wires VisualRelocalizationModule's world_map_fix Out[Transform] into RelocalizationModule's In of the same name+type (one shared transport, ModuleCoordinator._connect_streams groups by (name, type)); the reloc side runs use_fiducial_prior=True so marker fixes propose into the shared judge. Both map inputs stay unset by default — verified by execution that each module starts and idles until -o overrides configure a premap + marker map. Tests: composition + wiring key, no-op defaults, and an in-process LCM round-trip asserting the FiducialPrior stores the INVERTED fix (map_T_world). The test file pre-warms the process-global ThreadPoolScheduler at import so conftest's leak check doesn't blame the first In.observable() user for the pool's persistent workers. Registry regenerated by pytest dimos/robot/test_all_blueprints_generation.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ngth_m Replay rehearsal (hk_village3) proved the documented override alone crashes: load_config_args validates -o args against the module config with no blueprint kwargs merged, and marker_length_m is required-no-default, so '-o visualrelocalizationmodule.marker_map_file=...' dies in validation before deploy. Repeat marker_length_m in the override set until the CLI validates against merged kwargs. (unitree_go2_visual_relocalization's comment has the same landmine — flagged, not touched here.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lage3 replay; log names the gate Live single-tag fixes on the hk_village3 rehearsal scattered ~3 m (n=112, median 2.83 m vs scan-matching reference) and the judge rightly took none. Measurement (trial repo, live_fix_quality analysis) traced the scatter to world-frame tag-orientation error through the 31 m tag-to-map-origin lever arm (~0.55 m/deg, rho(orientation dev, fix deviation)=+0.97 (reference-free scatter; vs-reference error rho 0.54)); the offline benchmark's QualityWindow/SpeedLimit variables and tag range did NOT discriminate on this data, so no new knobs. The one live signal tracking the mechanism is the existing IPPE mirror-ambiguity ratio (rho -0.57): at 5.0 it keeps 41/112 fixes (median error 2.38 m vs 3.35 m cut, orientation dev 2.4 vs 5.8 deg) with all 5 sighting bursts covered. Tuned on that ONE recording; untuned beyond it. localize_from_detections now fills an optional reject_counts (unmapped_id/mirror_ambiguous/high_reprojection) and the module's throttled warning prints it, so robot-day logs show WHICH gate fired. Tests: mid-ratio (~3.6 measured) straddle detection rejected by the new default but accepted at the old 2.0; defaults pinned at 5.0 core+module; reject_counts per gate; module reprojection test disables the ambiguity gate (corrupted quad's ratio ~2.6 would now trip it first) and asserts the warning names high_reprojection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
corrected_marker_transforms extracted from eval.py's _eval_recording (behavior preserved) so the test derives its marker map from the recording instead of duplicating the pipeline. Opt-in via -m self_hosted; deselected by default, skipped in CI, skips without the local recording. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… core Port jnav's aggregation core (dimos/navigation/jnav/utils/apriltags.py) to the fiducial reloc path: per-glimpse gates (reproj/tag-px/distance/view-angle/speed, each skipped when its input is absent), cluster_by_time, cluster_medoid, and Huber-IRLS robust_cluster_pose (weighted-mean translation + Markley quaternion eigen-mean). Batch aggregate_visits + streaming TagAggregator, mirroring jnav's batch/streaming split. Pose is a frame-agnostic 7-vec so the prior fuses the drift-free world_T_marker. Huber IRLS + Markley cited; sharpness gate dropped (the detector's QualityWindow already drops blur). 11 seeded tests.
Candidate = {T, source}. Nothing read confidence: the judge (refine_candidates)
ranks purely on wall-only fine fitness, so the self-reported tier was dead
telemetry. A calibrated composite confidence is the Phase-4 fusion arbiter's
job, measured downstream of the judge, not asserted by the proposer. FiducialPrior
keeps only the hard age_max_s cutoff (no decay curve). Tests updated to the
{T, source} constructor + age-cutoff behavior.
…ghtings The fiducial reloc pipeline moves into the FiducialPrior: it consumes MarkerDetectionStreamModule's detections stream and Huber-fuses each tag's sightings (apriltag_aggregation core) into ONE robust world->map candidate per tag, then composes it with the surveyed map_T_marker. Fuses the drift-free world_T_marker (detector already computes it), so camera motion within a visit never enters the fused pose. - FiducialPrior.observe(marker_id, world_T_marker, ts, *, world_T_optical, corners_px): ambiguity gate (marker_pose.ambiguity_gated_pose, IPPE mirror, ours) + reproj/tag-px/distance/view gates run wherever their inputs are present; a pixel-less wire delivery degrades to min-obs + time-window fusion. - RelocalizationModule: world_map_fix In[Transform] -> detections In[Detection3DArray]; threads camera_info + marker_map_file + marker_length + ambiguity_ratio_min (reverted 5.0 -> 2.0 provisional). Note: today's wire drops corners_px, so the live ambiguity/reproj/view gates are dormant and the medoid/Huber carries mirror-flip rejection; the harness exercises the full gate stack on real pixels. - DELETE VisualRelocalizationModule (+ its test); marker detection is no longer re-implemented. visual_relocalization.py -> fiducial_relocalization.py (only load_marker_map survives; the PnP+ambiguity moved to marker_pose). Blueprint rewired MarkerDetectionStreamModule.detections -> reloc; all_blueprints + the standalone visual blueprint removed. Cite IPPE/Schweighofer-Pinz. 57 fiducial+reloc tests + 3 blueprint tests green; ruff + mypy clean.
AaryanAgrawal
marked this pull request as draft
July 23, 2026 19:45
Drop nine log-shape/default-restatement tests (module.py holds 32-miss baseline), the zero-reader accepted_fixes.csv writer + its three tests, three load_marker_map validation tests subsumed by the parametrized survivors, and the two order-asserts in the verbose test (the console renderer sorts kwargs, so order is not an invariant). Runtime and coverage unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
ArchitectureBlueprints: Marker locations come from |
Cut duplicate/library-behaviour tests, collapse copy-paste tests into parametrize, and consolidate test_aggregation_integration.py into test_apriltag_aggregation.py. Every runtime module's line+branch coverage is byte-identical to before (apriltag/priors/marker_pose/fiducial_reloc 100%, module 85%, relocalize 99%, eval_module 76%, marker_detect/transformer 90%). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slash the added test surface from ~5.6k lines to ~750, keeping one ultra-concise test per shipping-bug invariant: jump guard, consume-on-use, per-prior accept gate, RANSAC point-floor, burst-fires-on-cached-cloud, fiducial composition, RANSAC parity (the one real-RANSAC test), gravity gate, wall-evidence floor, robust mirror-flip aggregation, eval per-source stats + atexit report, and the marker-map loader. Delete the blueprint/replay/CLI/library-behaviour suites and revert the pre-existing marker_* / cli tests to their upstream state. Lands the coupled dead-code removal it was pinning (LastPosePrior, JudgeReport + margin, per-source gravity gating, the judge-finalists log). Surviving suites green: 85 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… CLI tests - refine_candidates: an all-tilted candidate pool is REFUSED (NoUprightCandidateError), not resurrected by the old `else indexed` fallback -- a lone tilted fiducial fix no longer sails through the gravity gate. - _try_relocalize: catch the EXPECTED InsufficientWallEvidenceError (sparse acquisition cloud) narrowly -> throttled warning, not an ERROR traceback every fire. The unjudged fix is still dropped, on purpose. - relocalize_with_priors: an empty proposal pool is EmptyProposalError, a benign double-fire race, no-op'd at the boundary instead of crash-logged. - restore the 13 upstream test_dimos.py tests trimmed earlier (5 -> 19) + add a test that load_config_args tolerates a partial overlay's "missing" required field yet still rejects unknown keys / bad types. - map global --markers: gate the survey DetectMarkers with the live detector's IPPE ambiguity floor (was gate-off) so map_T_tag isn't mirror-ambiguous. - fix stale --eval help (JSON + PNG, no CSV); drop dead aggregate_visits + cluster_gap_s (cluster_by_time kept -- the offline harness reuses it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… runtime The Umeyama held-out accuracy driver (umeyama_alignment, resolve_heldout_alignment, aligned_err_t_m, run_offline_report, load_odom_txyz) has no in-dimos caller -- only the trial harness drives it -- so it moves harness-side. The shipped --eval path (RelocEval, live) and the shared pure analysis (compute_stats/format_report/ plot_trajectory) are untouched; mode="held_out" still renders. cluster_by_time was dead in dimos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Repo ships features with ~0 unit tests; keep only fiducial compose+consume, per-prior accept gate, and the jump guard. Upstream test restores untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ore) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r, fiducial event-driven Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… logging Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t logging, drop the standalone analytics module Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ts own tally Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…vention) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e identical) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stream test_dimos.py The one surviving test change is a required assertion update in test_marker_detection_stream_module.py -- our aggregated_detections stream changes the module's declared stream set, so the upstream test must include it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ore leshy's relocalize comments Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
|
Superseded by #3160 — rebuilt on a clean branch (one commit, curated files, no tests). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(relocalization): fiducial prior + per-prior config for RelocalizationModule
Contribution path
Problem
A robot waking up in a prior map where lidar geometry is ambiguous — a corridor, a bare room, a symmetric hall — relocalizes into the wrong place on wall fitness alone, and every nav goal after it inherits the error. RANSAC is the only source, it cannot answer until the live map is dense enough to search, and an AprilTag surveyed into that map has no way in.
What this adds
Three things, and nothing else is load-bearing:
--evallogs a per-source table so you can see which prior won, live or under replay.1 · The fiducial relocalization prior
RelocalizationModuleused to have one hardcoded source — RANSAC. This generalizes "a relocalization source" into a prior: a thing that proposes candidate poses on its own trigger. Every prior's candidates go through the one shared judge; the winner is published with itssource.The design is a Protocol + a discriminated union — the Strategy pattern, made declarative. A prior is a candidate source (structural interface); a blueprint declares which priors run.
The two triggers look like what they are — RANSAC is polled on each cloud with a time+geometry gate; the fiducial is edge-triggered by a completed tag burst:
The fiducial fix is one frame composition — a surveyed tag pose against a live-detected one, judged like any other candidate:
Per prior, its own trigger and accept bar:
fitness_thresholdmin_local_points=50000enabledtoggles a prior in or out; a fiducial-only or lidar-only blueprint runs the same path.2 · Markers
Detect tags, gate each glimpse, and fuse a marker's sightings into one robust pose via
robust_cluster_pose(Huber-IRLS translation + Markley quaternion mean). One fusion, two windows — because time means different things offline and online:The offline survey is a single pass: PGO-corrected
world_T_tagper sighting, grouped by id, fused, and written alongside the premap as themarker_map.jsonthe fiducial prior loads. The live path streamsaggregated_detections— one aggregated pose per completed burst — additively beside the detector's existing per-framedetections.3 · Eval logging
RelocEval(--eval) listens on the real streams — no ground truth, live and under--replayalike — and logs a per-source table so you can see which prior is winning:The run log is the only place each accept's winning
source+ fitness live (the/tfcarries neither), so--evalturns on the module's verbose trace and joins accepts to sources by translation.Breaking Changes
fitness_thresholdandmin_local_pointsmoved onto the prior entries. The old module-level keys raise, naming the new home.priorsis required onRelocalizationModule.Config.unitree-go2-relocalizationis replaced by three blueprints named by their priors:-lidar,-lidar-fiducial,-fiducial.Core changes — why
perception/fiducial/apriltag_aggregation.py(new) — per-tag robust fusion: Huber-IRLS translation + Markley quaternion mean, each cited inline; a streaming aggregator for the live path.perception/fiducial/{marker_transformer,marker_detection_stream_module,marker_pose}.py— additive: a newaggregated_detectionsstream + burst aggregator beside the existing detector. Upstreamdetectionsuntouched.mapping/relocalization/{module,priors,relocalize}.py— the prior pool, per-prior triggers/thresholds, jump guard, and the shared judge (refine_candidatesunchanged from arkluc's go2 relocalization #2160).mapping/utils/cli/map.py—--markersalso fuses the sightings and writes<premap>.marker_map.jsonalongside the export.robot/cli/dimos.py—-otolerates pydanticmissing, so a partial overlay may omit a required field likerelocalizationmodule.priors. Unknown keys and type errors still raise.blueprints/smart/unitree_go2.py+ regeneratedall_blueprints.py— the three presets. Nocore/ortransport/change.How to Test
Hardware. Run on a Go2 against an sf office premap with surveyed tags, started cold in the mapped room:
Watch
relocalize acceptedfor the winningsource=; Ctrl+C logs the per-source table.Replay, both priors, no robot:
Test to read:
test_relocalize.py::test_fiducial_composes_map_T_world_then_consumes_it_once. Existing suites unchanged.Follow-up
To investigate before this is trusted beyond flat, single-floor sites:
gravity_tilt_max_deg(10°) of upright and scores wall-only fitness, soworld_T_mapis a gravity-aligned planar correction. How it composes with a 3D nav stack — ramps, stairs, multi-floor — is untested.global_map. A decoded tag is an absolute fix on its own; scoring it against the stored premap submap near the tag instead would drop the lidar dependency at acquisition.AI assistance
Claude Code (Opus 4.8) — design, implementation, tests; all changes reviewed.
Checklist
uv run pytest, pre-commit) for the files I changed.Announcement (Discord / PR comment)
Relocalization takes priors now, and one of them is an AprilTag.
A robot waking in a prior map waits on RANSAC, which cannot answer until the live map is dense enough to search, and in a corridor or a bare room it can answer confidently and wrongly. A surveyed tag is an absolute fix. Both propose into the same judge, which refines against the premap walls and publishes the winner with its source. Triggers are per prior: the tag fires on a sighting burst, RANSAC on its 2 s timer. Both clear the same 0.60 wall-fitness bar.
--evallogs the per-source table.