14 KiB
Ground Plane Detection and Auto-Alignment
TL;DR
Quick Summary: Add ground plane detection and optional world-frame alignment to
calibrate_extrinsics.pyso the output coordinate system always has Y-up, regardless of how the calibration box is placed.Deliverables:
- New
aruco/alignment.pymodule with ground detection and alignment utilities- CLI options:
--auto-align,--ground-face,--ground-marker-id- Face metadata in marker parquet files (or hardcoded mapping)
- Debug logs for alignment decisions
Estimated Effort: Medium Parallel Execution: NO - sequential (dependencies between tasks) Critical Path: Task 1 → Task 2 → Task 3 → Task 4 → Task 5
Context
Original Request
User wants to detect which side of the calibration box is on the ground and auto-align the world frame so Y is always up, matching the ZED convention seen in inside_network.json.
Interview Summary
Key Discussions:
- Ground detection: support both heuristic (camera up-vector) AND user-specified (face name or marker ID)
- Alignment: opt-in via
--auto-alignflag (default OFF) - Y-up convention confirmed from reference calibration
Research Findings:
inside_network.jsonshows Y-up convention (cameras at Y ≈ -1.2m)- Camera 41831756 has identity rotation → its axes match world axes
- Marker parquet contains face names and corner coordinates
- Face normals can be computed from corners:
cross(c1-c0, c3-c0) object_points.parquet: 3 faces (a, b, c) with 4 markers eachstandard_box_markers.parquet: 6 faces with 1 marker each (21=bottom)
Work Objectives
Core Objective
Enable calibrate_extrinsics.py to detect the ground-facing box face and apply a corrective rotation so the output world frame has Y pointing up.
Concrete Deliverables
aruco/alignment.py: Ground detection and alignment utilities- Updated
calibrate_extrinsics.pywith new CLI options - Updated marker parquet files with face metadata (optional enhancement)
Definition of Done
uv run calibrate_extrinsics.py --auto-align ...produces extrinsics with Y-up--ground-faceand--ground-marker-idwork as explicit overrides- Debug logs show which face was detected as ground and alignment applied
- Tests pass, basedpyright shows 0 errors
Must Have
- Heuristic ground detection using camera up-vector
- User override via
--ground-faceor--ground-marker-id - Alignment rotation applied to all camera poses
- Debug logging for alignment decisions
Must NOT Have (Guardrails)
- Do NOT modify marker parquet file format (use code-level face mapping for now)
- Do NOT change behavior when
--auto-alignis not specified - Do NOT assume IMU/gravity data is available
- Do NOT break existing calibration workflow
Verification Strategy
UNIVERSAL RULE: ZERO HUMAN INTERVENTION All tasks verifiable by agent using tools.
Test Decision
- Infrastructure exists: YES (pytest)
- Automated tests: YES (tests-after)
- Framework: pytest
Agent-Executed QA Scenarios (MANDATORY)
Scenario: Auto-align with heuristic detection
Tool: Bash
Steps:
1. uv run calibrate_extrinsics.py --svo output --markers aruco/markers/object_points.parquet --aruco-dictionary DICT_APRILTAG_36h11 --auto-align --no-preview --sample-interval 100
2. Parse output JSON
3. Assert: All camera poses have rotation matrices where Y-axis column ≈ [0, 1, 0] (within tolerance)
Expected Result: Extrinsics aligned to Y-up
Scenario: Explicit ground face override
Tool: Bash
Steps:
1. uv run calibrate_extrinsics.py --svo output --markers aruco/markers/object_points.parquet --aruco-dictionary DICT_APRILTAG_36h11 --auto-align --ground-face b --no-preview --sample-interval 100
2. Check debug logs mention "using specified ground face: b"
Expected Result: Uses face 'b' as ground regardless of heuristic
Scenario: No alignment when flag omitted
Tool: Bash
Steps:
1. uv run calibrate_extrinsics.py --svo output --markers aruco/markers/object_points.parquet --aruco-dictionary DICT_APRILTAG_36h11 --no-preview --sample-interval 100
2. Compare output to previous run without --auto-align
Expected Result: Output unchanged from current behavior
Execution Strategy
Dependency Chain
Task 1: Create alignment module
↓
Task 2: Add face-to-normal mapping
↓
Task 3: Implement ground detection heuristic
↓
Task 4: Add CLI options and integrate
↓
Task 5: Add tests and verify
TODOs
-
1. Create
aruco/alignment.pymodule with core utilitiesWhat to do:
- Create new file
aruco/alignment.py - Implement
compute_face_normal(corners: np.ndarray) -> np.ndarray: compute unit normal from (4,3) corners - Implement
rotation_align_vectors(from_vec: np.ndarray, to_vec: np.ndarray) -> np.ndarray: compute 3x3 rotation matrix that alignsfrom_vectoto_vecusing Rodrigues formula - Implement
apply_alignment_to_pose(T: np.ndarray, R_align: np.ndarray) -> np.ndarray: apply alignment rotation to 4x4 pose matrix - Add type hints and docstrings
Must NOT do:
- Do not add CLI logic here (that's Task 4)
- Do not hardcode face mappings here (that's Task 2)
Recommended Agent Profile:
- Category:
quick - Skills: [
git-master]
Parallelization:
- Can Run In Parallel: NO
- Blocks: Task 2, 3, 4
References:
aruco/pose_math.py- Similar matrix utilities (rvec_tvec_to_matrix, invert_transform)aruco/marker_geometry.py- Pattern for utility modules- Rodrigues formula:
R = I + sin(θ)K + (1-cos(θ))K²where K is skew-symmetric of axis
Acceptance Criteria:
- File
aruco/alignment.pyexists compute_face_normalreturns unit vector for valid (4,3) cornersrotation_align_vectors([0,0,1], [0,1,0])produces 90° rotation about Xuv run python -c "from aruco.alignment import compute_face_normal, rotation_align_vectors, apply_alignment_to_pose"→ no errors.venv/bin/basedpyright aruco/alignment.py→ 0 errors
Commit: YES
- Message:
feat(aruco): add alignment utilities for ground plane detection - Files:
aruco/alignment.py
- Create new file
-
2. Add face-to-marker-id mapping
What to do:
- In
aruco/alignment.py, addFACE_MARKER_MAPconstant:FACE_MARKER_MAP: dict[str, list[int]] = { # object_points.parquet "a": [16, 17, 18, 19], "b": [20, 21, 22, 23], "c": [24, 25, 26, 27], # standard_box_markers.parquet "bottom": [21], "top": [23], "front": [24], "back": [22], "left": [25], "right": [26], } - Implement
get_face_normal_from_geometry(face_name: str, marker_geometry: dict[int, np.ndarray]) -> np.ndarray | None:- Look up marker IDs for face
- Get corners from geometry
- Compute and return average normal across markers in that face
Must NOT do:
- Do not modify parquet files
Recommended Agent Profile:
- Category:
quick - Skills: [
git-master]
Parallelization:
- Can Run In Parallel: NO
- Blocked By: Task 1
- Blocks: Task 3, 4
References:
- Bash output from parquet inspection (earlier in conversation):
- Face a: IDs [16-19], normal ≈ [0,0,1]
- Face b: IDs [20-23], normal ≈ [0,1,0]
- Face c: IDs [24-27], normal ≈ [1,0,0]
Acceptance Criteria:
FACE_MARKER_MAPcontains mappings for both parquet filesget_face_normal_from_geometry("b", geometry)returns ≈ [0,1,0]- Returns
Nonefor unknown face names
Commit: YES (group with Task 1)
- In
-
3. Implement ground detection heuristic
What to do:
- In
aruco/alignment.py, implement:def detect_ground_face( visible_marker_ids: set[int], marker_geometry: dict[int, np.ndarray], camera_up_vector: np.ndarray = np.array([0, -1, 0]), # -Y in camera frame ) -> tuple[str, np.ndarray] | None: - Logic:
- For each face in
FACE_MARKER_MAP:- Check if any of its markers are in
visible_marker_ids - If yes, compute face normal from geometry
- Check if any of its markers are in
- Find the face whose normal most closely aligns with
camera_up_vector(highest dot product) - Return (face_name, face_normal) or None if no faces visible
- For each face in
- Add debug logging with loguru
Must NOT do:
- Do not transform normals by camera pose here (that's done in caller)
Recommended Agent Profile:
- Category:
unspecified-low - Skills: [
git-master]
Parallelization:
- Can Run In Parallel: NO
- Blocked By: Task 2
- Blocks: Task 4
References:
calibrate_extrinsics.py:385- Where marker IDs are detected- Dot product alignment:
np.dot(normal, up_vec)→ highest = most aligned
Acceptance Criteria:
- Function returns face with normal most aligned to camera up
- Returns None when no mapped markers are visible
- Debug log shows which faces were considered and scores
Commit: YES (group with Task 1, 2)
- In
-
4. Integrate into
calibrate_extrinsics.pyWhat to do:
- Add CLI options:
--auto-align/--no-auto-align(default: False)--ground-face(optional string, e.g., "b", "bottom")--ground-marker-id(optional int)
- Add imports from
aruco.alignment - After computing all camera poses (after the main loop, before saving):
- If
--auto-alignis False, skip alignment - Determine ground face:
- If
--ground-facespecified: use it directly - If
--ground-marker-idspecified: find which face contains that ID - Else: use heuristic
detect_ground_face()with visible markers from first camera
- If
- Get ground face normal from geometry
- Compute
R_align = rotation_align_vectors(ground_normal, [0, 1, 0]) - Apply to all camera poses:
T_aligned = R_align @ T - Log alignment info
- If
- Update results dict with aligned poses
Must NOT do:
- Do not change behavior when
--auto-alignis not specified - Do not modify per-frame pose computation (only post-process)
Recommended Agent Profile:
- Category:
unspecified-high - Skills: [
git-master]
Parallelization:
- Can Run In Parallel: NO
- Blocked By: Task 3
- Blocks: Task 5
References:
calibrate_extrinsics.py:456-477- Where final poses are computed and storedcalibrate_extrinsics.py:266-271- Existing CLI option patternaruco/alignment.py- New utilities from Tasks 1-3
Acceptance Criteria:
--auto-alignflag exists and defaults to False--ground-faceaccepts string face names--ground-marker-idaccepts integer marker ID- When
--auto-alignused, output poses are rotated - Debug logs show: "Detected ground face: X, normal: [a,b,c], applying alignment"
uv run python -m py_compile calibrate_extrinsics.py→ success.venv/bin/basedpyright calibrate_extrinsics.py→ 0 errors
Commit: YES
- Message:
feat(calibrate): add --auto-align for ground plane detection and Y-up alignment - Files:
calibrate_extrinsics.py
- Add CLI options:
-
5. Add tests and verify end-to-end
What to do:
- Create
tests/test_alignment.py:- Test
compute_face_normalwith known corners - Test
rotation_align_vectorswith various axis pairs - Test
detect_ground_facewith mock marker data
- Test
- Run full calibration with
--auto-alignand verify output - Compare aligned output to reference
inside_network.jsonY-up convention
Must NOT do:
- Do not require actual SVO files for unit tests (mock data)
Recommended Agent Profile:
- Category:
quick - Skills: [
git-master]
Parallelization:
- Can Run In Parallel: NO
- Blocked By: Task 4
References:
tests/test_depth_cli_postprocess.py- Existing test pattern/workspaces/zed-playground/zed_settings/inside_network.json- Reference for Y-up verification
Acceptance Criteria:
uv run pytest tests/test_alignment.py→ all passuv run pytest→ all tests pass (including existing)- Manual verification: aligned poses have Y-axis column ≈ [0,1,0] in rotation
Commit: YES
- Message:
test(aruco): add alignment module tests - Files:
tests/test_alignment.py
- Create
Commit Strategy
| After Task | Message | Files | Verification |
|---|---|---|---|
| 1, 2, 3 | feat(aruco): add alignment utilities for ground plane detection |
aruco/alignment.py |
uv run python -c "from aruco.alignment import *" |
| 4 | feat(calibrate): add --auto-align for ground plane detection and Y-up alignment |
calibrate_extrinsics.py |
uv run python -m py_compile calibrate_extrinsics.py |
| 5 | test(aruco): add alignment module tests |
tests/test_alignment.py |
uv run pytest tests/test_alignment.py |
Success Criteria
Verification Commands
# Compile check
uv run python -m py_compile calibrate_extrinsics.py
# Type check
.venv/bin/basedpyright aruco/alignment.py calibrate_extrinsics.py
# Unit tests
uv run pytest tests/test_alignment.py
# Integration test (requires SVO files)
uv run calibrate_extrinsics.py --svo output --markers aruco/markers/object_points.parquet --aruco-dictionary DICT_APRILTAG_36h11 --auto-align --no-preview --sample-interval 100 --output aligned_extrinsics.json
# Verify Y-up in output
uv run python -c "import json, numpy as np; d=json.load(open('aligned_extrinsics.json')); T=np.fromstring(list(d.values())[0]['pose'], sep=' ').reshape(4,4); print('Y-axis:', T[:3,1])"
# Expected: Y-axis ≈ [0, 1, 0]
Final Checklist
--auto-alignflag works--ground-faceoverride works--ground-marker-idoverride works- Heuristic detection works without explicit face specification
- Output extrinsics have Y-up when aligned
- No behavior change when
--auto-alignnot specified - All tests pass
- Type checks pass