Skip to content

feat(relocalization): add fiducial relocalization as a prior to RelocalizationModule - #3137

Closed
AaryanAgrawal wants to merge 66 commits into
dimensionalOS:mainfrom
AaryanAgrawal:feat/relocalization-fiducial-prior
Closed

feat(relocalization): add fiducial relocalization as a prior to RelocalizationModule#3137
AaryanAgrawal wants to merge 66 commits into
dimensionalOS:mainfrom
AaryanAgrawal:feat/relocalization-fiducial-prior

Conversation

@AaryanAgrawal

@AaryanAgrawal AaryanAgrawal commented Jul 22, 2026

Copy link
Copy Markdown

feat(relocalization): fiducial prior + per-prior config for RelocalizationModule

Contribution path

  • Linked issue / discussion: DIM-920 (relocalization)

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:

  1. The fiducial relocalization prior — a surveyed AprilTag becomes an absolute fix that competes in the same judge as RANSAC.
  2. Markers — detect tags, robustly fuse their sightings, and survey them into a marker map.
  3. Eval logging--eval logs a per-source table so you can see which prior won, live or under replay.

1 · The fiducial relocalization prior

RelocalizationModule used 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 its source.

             lidar                                    camera
               │                                        │
       VoxelGridMapper                             DetectMarkers
       carve 0.05 m                          admit? ambiguity · reproj · size · range · angle
               │                                        │ admitted
               ▼                                        ▼
       global_map (world)                          TagAggregator  (3 in 5 s · Huber + Markley)
               │                                        │ aggregated_detections
       RansacPrior                                  FiducialPrior
       polled: time ≥ 2 s AND n_pts ≥ 50k          event: fires on the burst edge
               │ 34 candidates                          │ 1 candidate per pending tag
               │                                        │ map_T_marker @ inv(world_T_marker)
               └───────────►  refine_candidates  ◄──────┘
                    walls only · ≥100 pts · tilt ≤10° · ICP · max fitness
                              │
                    ACCEPT      per-prior fitness_threshold (0.60)
                              │
                    JUMP GUARD  ≤5 m/s · ≤45°/s · tracking only, first fix exempt
                              │
                    publish world_T_map on /tf

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.

  RelocPrior (Protocol)  ── structural typing: a class conforms by SHAPE, not by inheriting ──
     name : str
     propose(global_map, local_map) -> list[Candidate]
        ▲                         ▲
        │ RansacPrior             │ FiducialPrior          ← implicit subtypes; neither says (RelocPrior)
        │  FPFH+RANSAC search     │  observe() a sighting → compose → has_pending → propose()

  PriorConfig  = Annotated[ RansacPriorConfig | FiducialPriorConfig, Field(discriminator="type") ]
                            └──────────── a discriminated (tagged) union ───────────┘
         type="ransac" │                    │ type="fiducial"
                       ▼                    ▼
        RansacPriorConfig            FiducialPriorConfig      ── both extend ──►  PriorConfigBase
          interval_s · min_points      marker_map_file                             enabled=True
                                       marker_length_m · aggregation                fitness_threshold=0.6

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:

  module event handlers                          the prior's own state
  ─────────────────────                          ─────────────────────
  _on_local_map(cloud):                          RansacPrior  — a candidate source
     if now-last ≥ interval and n_pts ≥ min:       (the module owns the timer + points gate)
        fire ransac
     if fiducial.has_pending: fire fiducial       FiducialPrior — the burst is the trigger
        (cold-start: a pending fix, no cloud yet)    observe() fills _pending
  _on_aggregated_detections(burst):                  has_pending reports readiness
     fiducial.observe(each tag)                       propose() drains it (consume-on-use)
     if cloud cached and has_pending: fire

The fiducial fix is one frame composition — a surveyed tag pose against a live-detected one, judged like any other candidate:

  map_T_marker    from the surveyed marker map   ─┐
                                                  ├─►  map_T_world = map_T_marker @ inv(world_T_marker)
  world_T_marker  Huber-fused live sightings     ─┘         (then straight into refine_candidates)

Per prior, its own trigger and accept bar:

prior trigger fitness_threshold why that bar
ransac cloud, time ≥ 2 s AND min_local_points=50000 0.60 a geometric search has only the walls it landed on
fiducial a completed tag burst (3 sightings / 5 s) 0.60 a decoded id names the tag, it does not show the composed pose fits the walls

enabled toggles 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:

    OFFLINE  (dimos map … --markers)             ONLINE  (live reloc)
    all sightings, whole recording               sliding window: last 5 s, ≥3 sightings
    one static PGO-corrected frame               live LIO frame drifts → must forget
              └──────────► robust_cluster_pose (Huber + Markley) ◄──────────┘
    → one map_T_marker per id                    → world_T_marker to the FiducialPrior
       written to <premap>.marker_map.json

The offline survey is a single pass: PGO-corrected world_T_tag per sighting, grouped by id, fused, and written alongside the premap as the marker_map.json the fiducial prior loads. The live path streams aggregated_detections — one aggregated pose per completed burst — additively beside the detector's existing per-frame detections.


3 · Eval logging

RelocEval (--eval) listens on the real streams — no ground truth, live and under --replay alike — and logs a per-source table so you can see which prior is winning:

/tf   (world_T_map fixes) ─┐
/odom (robot path) ────────┤ RelocEval  ──►  per-source table (logged at exit + on Ctrl+C)
run-log verbose trace ─────┘                 source · prop · acc · rej · false · %traj · med_fit

The run log is the only place each accept's winning source + fitness live (the /tf carries neither), so --eval turns on the module's verbose trace and joins accepts to sources by translation.


Breaking Changes


  • fitness_threshold and min_local_points moved onto the prior entries. The old module-level keys raise, naming the new home.
  • RANSAC's accept bar is 0.60, was 0.45. Shipped presets are not byte-identical.
  • priors is required on RelocalizationModule.Config.
  • unitree-go2-relocalization is 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 new aggregated_detections stream + burst aggregator beside the existing detector. Upstream detections untouched.
  • mapping/relocalization/{module,priors,relocalize}.py — the prior pool, per-prior triggers/thresholds, jump guard, and the shared judge (refine_candidates unchanged from arkluc's go2 relocalization #2160).
  • mapping/utils/cli/map.py--markers also fuses the sightings and writes <premap>.marker_map.json alongside the export.
  • robot/cli/dimos.py-o tolerates pydantic missing, so a partial overlay may omit a required field like relocalizationmodule.priors. Unknown keys and type errors still raise.
  • blueprints/smart/unitree_go2.py + regenerated all_blueprints.py — the three presets. No core/ or transport/ change.

How to Test

Hardware. Run on a Go2 against an sf office premap with surveyed tags, started cold in the mapped room:

uv run dimos --robot-ip <robot ip> run unitree-go2-relocalization-lidar-fiducial --eval \
  -o relocalizationmodule.map_file=<premap>.pgo_markers.pc2.lcm \
  -o relocalizationmodule.marker_map_file=<premap>.marker_map.json

Watch relocalize accepted for the winning source=; Ctrl+C logs the per-source table.

Replay, both priors, no robot:

uv run dimos --replay --replay-db=hk_village3 run unitree-go2-relocalization-lidar-fiducial --eval \
  -o relocalizationmodule.map_file=data/replay_gate/hk_village3.pc2.lcm \
  -o relocalizationmodule.marker_map_file=data/replay_gate/hk_village3.marker_map.json

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:

  • 3D nav. The judge gates candidates to within gravity_tilt_max_deg (10°) of upright and scores wall-only fitness, so world_T_map is a gravity-aligned planar correction. How it composes with a 3D nav stack — ramps, stairs, multi-floor — is untested.
  • Robot on a ramp. On an incline the body tilts with the ground: the 10° gravity gate may reject a valid fix, and wall-only fitness assumes vertical walls.
  • Fiducial without a lidar scan. The fiducial prior still judges against a cached 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

  • This PR is scoped to one clearly stated problem.
  • I ran the relevant checks (uv run pytest, pre-commit) for the files I changed.
  • I have reviewed and understood every line in this PR.
  • I disclosed AI assistance above.
  • I have read and approved the CLA.

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.

dimos run unitree-go2-relocalization-lidar-fiducial --eval \
  -o relocalizationmodule.map_file=<premap>.pc2.lcm \
  -o relocalizationmodule.marker_map_file=<premap>.marker_map.json

--eval logs the per-source table.

AaryanAgrawal and others added 30 commits July 17, 2026 23:45
…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
AaryanAgrawal marked this pull request as draft July 23, 2026 19:45
AaryanAgrawal and others added 3 commits July 23, 2026 12:56
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>
@AaryanAgrawal

AaryanAgrawal commented Jul 23, 2026

Copy link
Copy Markdown
Author

Architecture

  GO2              lidar ~7 Hz                        camera ~14 Hz
                        |                                   |
                VoxelGridMapper                       DetectMarkers
                carve, 0.05 m                         +-------------------------+
                        |                             | ADMIT a sighting?       |
                        | emit_every=5                |  ambiguity ratio  2.0   |
                        v                             |  reproj      <=   2.0px |
                  global_map (world) ----+            |  tag size    >=  24 px  |
                        |                 | cached     |  distance    <=  1.0 m  |
                        |                 |            |  view angle  <= 45 deg  |
                        |                 |            +-----------+-------------+
                        |                 |                        | admitted
                        |                 |                 TagAggregator
                        |                 |                 3 in 5 s  (TRIGGER)
                        |                 |                 Huber + Markley
                        |                 |                        |
                        |                 |          detections    |  aggregated_detections
                        |                 |          (unchanged)   |  + covariance, score
                        |                 |                v       v
                        |                 |          MarkerTf   FiducialPrior
                        |                 |          viz        fires INSTANTLY on burst
                        v                 |                     consume-on-use, no accumulate
              +------------------+        +----judge against---------+
              | RANSAC prior     |        the cached cloud           |
              | is_due: 2.0 s    |                                   |
              | n_pts >= 50k     |                          map_T_tag . inv(world_T_tag)
              +--------+---------+                                   |
                       | 34 candidates                     1 per pending tag
                       +------------> refine_candidates <-----------+
                                      (own pool each, never waiting)
                                      walls only . >=100 pts . tilt <=10 deg
                                      rank pre-ICP -> top 10 -> ICP -> max fitness
                                              |
                                    ACCEPT (per source)   ransac >= 0.60
                                                          fiducial >= 0.60
                                              |
                                    JUMP GUARD (tracking)  <=5 m/s . <=45 deg/s
                                              |
                                        publish world_T_map on /tf
                                        |                    |
                                  merged_map            RelocEval (--eval)
                                  -> CostMapper         per-source stats, survives Ctrl+C
                                  (silent until 1st fix)

Blueprints: -lidar = RANSAC prior . -fiducial = fiducial prior . -lidar-fiducial = both, independent triggers.

Marker locations come from dimos map global --pgo --export --markers-out map.json — one premap with a map_T_tag per tag. The prior composes map_T_tag . inv(world_T_tag) and the shared judge accepts on wall fitness.

AaryanAgrawal and others added 21 commits July 23, 2026 14:27
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>
@AaryanAgrawal

Copy link
Copy Markdown
Author

Superseded by #3160 — rebuilt on a clean branch (one commit, curated files, no tests).

@AaryanAgrawal
AaryanAgrawal deleted the feat/relocalization-fiducial-prior branch July 24, 2026 05:34
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