perception/fiducial: add marker-map robot localization (world->map TF correction)#2808
perception/fiducial: add marker-map robot localization (world->map TF correction)#2808AaryanAgrawal wants to merge 6 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.
Greptile SummaryThis PR adds camera-based marker-map localization for correcting
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (3): Last reviewed commit: "chore(robot): register marker-localizati..." | Re-trigger Greptile |
| marker_map_file: str | None = None # via `resolve_named_path`, RelocalizationModule convention | ||
| aruco_dictionary: str = "DICT_APRILTAG_36h11" | ||
| marker_length_m: float = Field(..., gt=0.0) | ||
| max_reprojection_error_px: float = Field(3.0, gt=0.0) |
There was a problem hiding this comment.
LocalizationConfig has a min_tags gate, but the module never exposes it and always builds self._cfg with the default of one tag. A deployment that needs two mapped tags for corroboration cannot configure that safety check, so one noisy or spurious passing detection can publish a new world -> map correction.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| if pose is None: | ||
| logger.warning(f"MarkerLocalizationModule: gate rejected ({len(detections)} tags seen)") | ||
| return | ||
| if (world_T_optical := self.tf.get(WORLD_FRAME, OPTICAL_FRAME, time_point=msg.ts)) is None: |
There was a problem hiding this comment.
handle_color_image always looks up world -> camera_optical, while real camera streams can publish TF under names such as a front or color optical frame. With a valid marker map and detections from any differently named camera frame, this branch returns without publishing the correction, so localization silently stays inactive.
| rotation=Quaternion(*entry["rotation"]), | ||
| frame_id=MAP_FRAME, |
There was a problem hiding this comment.
Malformed Map Poses Are Accepted
The YAML values are unpacked directly into Vector3 and Quaternion, so a malformed entry like translation: [1.0] is accepted as (1, 0, 0) instead of failing at load time. The module can then compute and publish a world -> map correction from the wrong marker pose, and invalid rotations such as a zero quaternion can fail later during transform inversion.
… 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
| if not isinstance(translation, (list, tuple)) or len(translation) != 3: | ||
| raise ValueError(f"marker {marker_id}: translation must be [x, y, z], got {translation!r}") |
There was a problem hiding this comment.
This accepts non-finite translation values from the marker map. PyYAML can load .nan or .inf as floats, and this check only verifies that translation is a 3-element list or tuple. A map entry such as translation: [.nan, 0.0, 0.0] reaches Vector3(*translation), then localize_from_detections() composes it into the selected marker pose, and the module can publish a world -> map transform containing non-finite coordinates. Please reject non-finite translation components at load time.
…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.
|
Hi, we considered fiducial marker relocalization, I didn't read the code yet but sounds roughly ok. Can you give me instructions how to test? I'd like to print markers and test on my go2 to ensure this works before reviewing the code. |
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.
|
Thanks — here's everything needed to test on a Go2, end to end. No hand-surveying required: step 2 uses the existing 1. Print tags dimos apriltag --ids 0-5 --size-mm 100 --family tag36h11 -o markers.pdf100 mm matches the existing markers blueprint default so no size overrides are needed anywhere. After printing, measure a tag's black border with the ruler printed on the sheet — printers rescale silently, and tag size is how PnP infers distance, so a 5% print-scale error is a systematic 5% range error on every fix. If yours printed at a different size, use the measured value for 2. Build the marker map with the robot (no tape measure) Run the existing markers blueprint with the robot somewhere with good odometry (fresh boot is fine — the survey run's dimos run unitree-go2-markersLet it see each tag from a couple of angles, then read the # office_markers.yaml — poses in the map frame, quaternion is [x, y, z, w]
markers:
0:
translation: [3.12, -0.40, 0.35]
rotation: [0.5, -0.5, 0.5, 0.5]
1:
translation: [5.80, 2.10, 0.33]
rotation: [0.0, 0.0, 0.707, 0.707]If you'd rather survey by hand: translation is the tag center in meters; orientation matters more than position (a 2° orientation error displaces the computed camera pose ~3.5 cm per meter of distance), which is why I'd let the robot do it. 3. Compose and run Add next to from dimos.perception.fiducial.marker_localization_module import MarkerLocalizationModule
unitree_go2_marker_localization = autoconnect(
unitree_go2,
MarkerLocalizationModule.blueprint(
marker_map_file="/home/you/office_markers.yaml", # absolute path: ~ is not expanded
marker_length_m=0.10, # your measured print size
camera_info=GO2Connection.camera_info_static,
),
).global_config(n_workers=11, robot_model="unitree_go2")Then register and run: pytest dimos/robot/test_all_blueprints_generation.py # regenerates all_blueprints.py; it FAILS
# with "please commit" — that's expected,
# the registry is updated either way
dimos run unitree-go2-marker-localizationThe Go2's front camera chain already publishes 4. What to expect Whenever a mapped tag clears the reprojection gate you'll see a So far this is validated synthetically — a rendered-room harness running the real cv2.aruco detector frame-by-frame shows an 81% reduction in trajectory error against a drifting-odometry model (ATE 1.75 m -> 0.33 m). Yours would be the first real-hardware run, so I'm keen on whatever you hit — happy to iterate on anything that doesn't behave. |
Problem
RelocalizationModulecorrectsworld->mapdrift from lidar against a frozenpremap. A robot without lidar, or one operating where lidar degrades
(elevator cabs, glass, feature-poor corridors), has no equivalent. The nav
deep dive is explicit about the gap: "We don't have proper loop closure and
stable odometry... does drift eventually"
(docs/capabilities/navigation/deep_dive.md). This adds the same class of fix
using a camera and known fiducial markers instead of a lidar premap.
What this adds
Known marker-map pose (map frame) + a live ArUco detection -> camera pose
via solvePnP run in reverse, gated on reprojection error and tag count.
color_image In, static CameraInfo, publishes TF world->map -- the same job
as RelocalizationModule, camera swapped in for lidar.
cv2.aruco-rendered frames at known poses, end to end through detection and
pose recovery, plus the boundaries: empty frames, unmapped tags,
corrupted-corner rejection, multi-tag selection, map-file errors
(missing/malformed YAML), and config validation.
3 files, 484 insertions (181 implementation, 303 tests), no changes to
existing code.
What this reuses
create_aruco_detector, estimate_marker_pose (SOLVEPNP_IPPE_SQUARE),
camera_info_to_cv_matrices, marker_reprojection_error, and
rvec_tvec_to_transform, all from marker_pose.py -- no new detection or PnP
code. TF publish + resolve_named_path map-file loading follow
RelocalizationModule's pattern exactly.
Validation
Beyond the in-PR tests: a synthetic ground-truth harness (a rendered room,
15 wall tags, a drifting-odometry model, the same estimator running on real
cv2.aruco detections frame by frame) measures ATE 1.75 m raw odometry ->
0.33 m corrected, a 5.3x reduction. When several tags pass the gate in one
frame, the estimate with the lowest reprojection error wins, and an
order-independence regression test pins that behavior. The demo GIF below
shows the run: detections on the left, raw vs corrected trajectory on the
right. The harness is standalone and not part of this diff; happy to share
it.
81% reduction in trajectory error (ATE 1.75 m -> 0.33 m, 5.3x) on the synthetic ground-truth run shown above.
Follow-ups
Fisheye intrinsics, multi-tag weighted fusion (currently best single tag by
reprojection error), smoothing/hold-last-good, a demo blueprint + fixture
map.
Limitations
Validated on synthetic ground truth and rendered cv2.aruco frames, not yet
on a physical rig.