Files
zed-playground/py_workspace/.sisyphus/plans/icp-registration.md
T

716 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ICP Registration for Multi-Camera Extrinsic Refinement
## TL;DR
> **Quick Summary**: Add ICP-based pairwise registration with pose-graph optimization to `refine_ground_plane.py`, refining multi-camera extrinsics after RANSAC ground-plane leveling. Supports Point-to-Plane and GICP methods on near-floor-band point clouds with gravity-constrained DOF.
>
> **Deliverables**:
> - `aruco/icp_registration.py` — core ICP module (near-floor extraction, pairwise ICP, overlap detection, pose graph, gravity constraint)
> - Updated `refine_ground_plane.py` — CLI flags (`--icp`, `--icp-method`, `--icp-voxel-size`)
> - `tests/test_icp_registration.py` — unit tests for all core functions
>
> **Estimated Effort**: Medium
> **Parallel Execution**: YES — 2 waves
> **Critical Path**: Task 1 → Task 2 → Task 3 → Task 4
---
## Context
### Original Request
Add ICP registration to refine multi-camera extrinsics in a ZED depth camera calibration pipeline. ICP chains after existing RANSAC ground-plane correction. User also asked about colored ICP — deferred after analysis showed it requires extending the HDF5 pipeline to save RGB images (significant plumbing work not justified for floor-only scope). GICP chosen as alternative method option instead (same data pipeline, no RGB needed).
### Interview Summary
**Key Discussions**:
- ICP complements RANSAC leveling, does NOT replace it
- **Near-floor band** scope (floor_y to floor_y + ~30cm) — includes slight 3D structure (baseboards, table legs, cables) for better constraints; strict plane inliers are degenerate for yaw/XZ
- **Gravity-constrained**: pitch/roll regularized (soft penalty) to preserve RANSAC gravity alignment; ICP primarily refines yaw + XZ translation + small height
- **Two ICP methods**: Point-to-Plane (default) + GICP (optional via `--icp-method`)
- **Colored ICP deferred**: current HDF5 pipeline doesn't save RGB images; adding it requires extending `depth_save.py`, `load_depth_data`, and threading RGB through entire pipeline — not worth it for floor-only scope
- Global pose-graph optimization for multi-camera consistency
- Tests-after strategy (implement, then add tests)
**Research Findings**:
- `unproject_depth_to_points` already exists in `aruco/ground_plane.py` — reuse for point cloud generation
- `detect_floor_plane` does RANSAC segmentation — use the detected floor_y to define the near-floor band
- Open3D already a dependency — provides `registration_icp`, `registration_generalized_icp`, `PoseGraph`, `global_optimization`
- Multi-scale ICP recommended: derive from base voxel size as `[4×, 2×, 1×]`, `max_iter = [50, 30, 14]`
- `get_information_matrix_from_point_clouds` provides edge weights for pose graph
- All depth values in meters (ZED SDK `coordinate_units=sl.UNIT.METER`)
- GICP (`registration_generalized_icp`) models local structure as Gaussian distributions — more robust than point-to-plane for noisy stereo data
### Metis Review
**Identified Gaps** (addressed):
- **Floor points definition**: Resolved → near-floor band (not strict plane inliers) to provide 3D structure for stable ICP constraints
- **Gravity preservation**: Resolved → soft-constrain pitch/roll with regularization penalty after ICP
- **Planar degeneracy**: Mitigated by near-floor band including objects with 3D relief
- **Disconnected graph**: Handle by optimizing only connected component with reference camera, warn about unconnected cameras
- **Overlap detection robustness**: Inflate bounding boxes with margin to handle initial extrinsic error
- **Failure policies**: Skip pair with warning on non-convergence / low fitness; return original extrinsics if all ICP fails
- **Unit consistency**: All meters — assert in tests; voxel sizes only meaningful in meters
- **Reference camera**: Use first camera (sorted by serial) by default; not user-configurable initially
---
## Work Objectives
### Core Objective
Add ICP-based registration as a refinement step in `refine_ground_plane.py` that improves multi-camera alignment by running pairwise ICP on near-floor-band point clouds, followed by pose-graph global optimization, with gravity alignment preserved via soft constraints.
### Concrete Deliverables
- `aruco/icp_registration.py` — new module (~300-400 lines)
- Updated `refine_ground_plane.py` — 3 new CLI flags + integration call (~50-80 lines added)
- `tests/test_icp_registration.py` — comprehensive unit tests (~300-400 lines)
### Definition of Done
- [x] `uv run basedpyright` passes with zero errors
- [x] `uv run pytest` passes with zero failures (all existing + new tests)
- [x] `uv run python refine_ground_plane.py --help` shows `--icp`, `--icp-method`, `--icp-voxel-size` flags
- [x] Running with `--no-icp` produces identical output to current behavior
- [x] Running with `--icp` on synthetic data produces measurably improved extrinsics
### Must Have
- Near-floor-band point extraction (floor_y from RANSAC + configurable band height)
- Pairwise ICP between overlapping camera pairs (Point-to-Plane + GICP options)
- Multi-scale ICP (coarse→fine voxel sizes derived from base)
- Pose-graph global optimization with fixed reference camera
- Gravity constraint: soft-penalize pitch/roll deviation from RANSAC solution
- Overlap detection via world XZ bounding-box intersection (with margin)
- Edge gating: minimum fitness threshold to accept ICP pair result
- Graceful failure handling (skip pair, warn, return original if all fail)
- ICP metrics recorded in `_meta.icp_refined`
- Comprehensive unit tests with synthetic data
### Must NOT Have (Guardrails)
- ❌ Colored ICP or any RGB pipeline changes (explicitly deferred)
- ❌ Changes to HDF5 schema (`depth_save.py` / `load_depth_data`)
- ❌ New external dependencies beyond what's in `pyproject.toml`
- ❌ Removal or replacement of RANSAC ground-plane leveling logic
- ❌ Modification of existing function signatures in `aruco/ground_plane.py`
- ❌ Auto-selection of "best" reference camera
- ❌ Real-time / streaming ICP
- ❌ Full-scene (non-floor) point cloud registration
- ❌ Changes to existing JSON output schema beyond updated extrinsic matrices and optional ICP metrics block under `_meta`
- ❌ AI-slop: unnecessary abstractions, premature generalization, or over-documented code
- ❌ Documentation updates (out of scope — add later if needed)
---
## Verification Strategy
> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION**
>
> ALL tasks MUST be verifiable WITHOUT any human action.
### Test Decision
- **Infrastructure exists**: YES (`pytest` configured in `pyproject.toml`)
- **Automated tests**: Tests-after (implement first, then add tests)
- **Framework**: `pytest` with `numpy.testing.assert_allclose`
### Agent-Executed QA Scenarios (MANDATORY — ALL tasks)
**Verification Tool by Deliverable Type:**
| Type | Tool | How Agent Verifies |
|------|------|-------------------|
| Python module | Bash (`uv run pytest`) | Run tests, assert pass |
| CLI integration | Bash (`uv run python refine_ground_plane.py --help`) | Run help, check flags |
| Type safety | Bash (`uv run basedpyright`) | Run type checker, assert zero errors |
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Start Immediately):
└── Task 1: Core ICP module (aruco/icp_registration.py)
Wave 2 (After Wave 1):
├── Task 2: CLI integration (refine_ground_plane.py)
└── Task 3: Unit tests (tests/test_icp_registration.py)
Wave 3 (After Wave 2):
└── Task 4: Integration testing & type checking (verification only)
```
### Dependency Matrix
| Task | Depends On | Blocks | Can Parallelize With |
|------|------------|--------|---------------------|
| 1 | None | 2, 3 | None (foundational) |
| 2 | 1 | 4 | 3 |
| 3 | 1 | 4 | 2 |
| 4 | 2, 3 | None | None (final) |
### Agent Dispatch Summary
| Wave | Tasks | Recommended Agents |
|------|-------|-------------------|
| 1 | 1 | `task(category="unspecified-high")` — algorithmic Open3D work |
| 2 | 2, 3 | `task(category="quick")` for 2; `task(category="unspecified-high")` for 3 |
| 3 | 4 | `task(category="quick")` — verification only |
---
## TODOs
- [x] 1. Core ICP Registration Module (`aruco/icp_registration.py`)
**What to do**:
Create the core ICP registration module with the following functions and dataclasses:
**Dataclasses** (follow `aruco/ground_plane.py` pattern):
- `ICPConfig` — configuration parameters:
- `voxel_size: float = 0.02` (base voxel size in meters; multi-scale derives `[4×, 2×, 1×]`)
- `max_iterations: list[int] = [50, 30, 14]` (per scale)
- `method: str = "point_to_plane"` (or `"gicp"`)
- `band_height: float = 0.3` (near-floor band height in meters above detected floor)
- `min_fitness: float = 0.3` (minimum ICP fitness to accept pair result)
- `min_overlap_area: float = 1.0` (minimum XZ overlap area in m²)
- `overlap_margin: float = 0.5` (inflate bboxes by this margin in meters)
- `gravity_penalty_weight: float = 10.0` (soft constraint on pitch/roll deviation)
- `max_correspondence_distance_factor: float = 1.4` (multiplied by voxel_size per scale)
- `max_rotation_deg: float = 5.0` (safety bound on ICP delta)
- `max_translation_m: float = 0.1` (safety bound on ICP delta)
- `ICPResult` — per-pair result:
- `transformation: np.ndarray` (4x4)
- `fitness: float`
- `inlier_rmse: float`
- `information_matrix: np.ndarray` (6x6)
- `converged: bool`
- `ICPMetrics` — overall metrics:
- `success: bool`
- `num_pairs_attempted: int`
- `num_pairs_converged: int`
- `num_cameras_optimized: int`
- `num_disconnected: int`
- `per_pair_results: dict[tuple[str, str], ICPResult]`
- `reference_camera: str`
- `message: str`
**Functions**:
1. `extract_near_floor_band(points_world, floor_y, band_height, floor_normal)``np.ndarray`
- Given world-frame points and detected floor parameters, filter to points within the band:
- Project each point onto floor normal direction
- Keep points where `floor_y <= projection <= floor_y + band_height`
- Returns filtered `(M, 3)` array
- This captures floor + low objects (baseboards, table legs, cables) for 3D structure
2. `compute_overlap_xz(points_a, points_b, margin)``float`
- Project both point clouds onto XZ plane (ignore Y)
- Compute axis-aligned bounding box for each
- Inflate bboxes by `margin`
- Return intersection area (m²); 0.0 if no overlap
3. `pairwise_icp(source_pcd, target_pcd, config, init_transform)``ICPResult`
- Multi-scale ICP loop (coarse→fine):
- Derive voxel sizes: `[config.voxel_size * 4, * 2, * 1]`
- For each scale:
- Downsample with `voxel_down_sample(voxel_size)`
- Estimate normals: `estimate_normals(KDTreeSearchParamHybrid(radius=voxel_size*2, max_nn=30))`
- If `config.method == "point_to_plane"`:
- `registration_icp(source_down, target_down, voxel_size * config.max_correspondence_distance_factor, current_transform, TransformationEstimationPointToPlane(), ICPConvergenceCriteria(...))`
- If `config.method == "gicp"`:
- `registration_generalized_icp(source_down, target_down, voxel_size * config.max_correspondence_distance_factor, current_transform, TransformationEstimationForGeneralizedICP(), ICPConvergenceCriteria(...))`
- Chain: `current_transform = result.transformation`
- After final scale: compute information matrix via `get_information_matrix_from_point_clouds`
- Return `ICPResult`
4. `apply_gravity_constraint(T_icp, T_original, penalty_weight)``np.ndarray`
- Purpose: preserve RANSAC gravity alignment while allowing yaw + XZ + height refinement
- Decompose rotation of `T_icp` into pitch/roll/yaw (use scipy `Rotation.from_matrix().as_euler('xyz')` or manual decomposition)
- Decompose rotation of `T_original` similarly
- Blend pitch/roll: `blended = original + (icp - original) / (1 + penalty_weight)`
- Keep ICP yaw unchanged
- Recompose rotation from blended euler angles
- Keep ICP translation (yaw + XZ + height are all allowed to change)
- Return constrained 4x4 transform
5. `build_pose_graph(serials, extrinsics, pair_results, reference_serial)``o3d.pipelines.registration.PoseGraph`
- Create one node per camera
- Set reference camera node as anchor (identity odometry edge)
- For each converged pair: add loop-closure edge with the ICP transformation and information matrix
- Detect connected components: only include cameras reachable from reference
- Log warning for any cameras not in the reference's component
6. `optimize_pose_graph(pose_graph)``o3d.pipelines.registration.PoseGraph`
- Run `o3d.pipelines.registration.global_optimization` with:
- `GlobalOptimizationLevenbergMarquardt()`
- `GlobalOptimizationConvergenceCriteria()`
- `GlobalOptimizationOption(max_correspondence_distance=..., reference_node=0)`
- Return optimized graph
7. `refine_with_icp(camera_data, extrinsics, floor_planes, config)``tuple[dict[str, np.ndarray], ICPMetrics]`
- Main orchestrator (follows `refine_ground_from_depth` pattern):
1. For each camera that has a detected floor plane:
- Unproject depth to world points (reuse `unproject_depth_to_points` + world transform)
- Extract near-floor band using `floor_y` from detected `FloorPlane.d` and `FloorPlane.normal`
- Convert to Open3D PointCloud
2. Detect overlapping pairs via `compute_overlap_xz`
3. For each overlapping pair:
- Compute initial relative transform from current extrinsics
- Run `pairwise_icp`
- Apply `apply_gravity_constraint` to the result
4. Build pose graph from converged pairs
5. Run `optimize_pose_graph`
6. Extract refined extrinsics from optimized graph
7. Validate per-camera deltas against `max_rotation_deg` / `max_translation_m` (reject cameras that exceed bounds)
8. Return (new_extrinsics, metrics)
- If no overlapping pairs found or all ICP fails: return original extrinsics with `success=False`
**Must NOT do**:
- Do NOT modify `aruco/ground_plane.py` function signatures or dataclasses
- Do NOT add RGB/color handling
- Do NOT add dependencies not in `pyproject.toml`
- Do NOT import from `refine_ground_plane.py` (module should be self-contained)
- Do NOT write to files (the caller handles I/O)
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Algorithmic module with Open3D integration, multiple interacting functions, mathematical constraints (gravity regularization, pose graph). Not purely frontend or quick-fix.
- **Skills**: []
- **Skills Evaluated but Omitted**:
- `playwright`: No browser interaction
- `frontend-ui-ux`: No UI work
- `git-master`: Commit handled at end
**Parallelization**:
- **Can Run In Parallel**: NO (foundational — all other tasks depend on this)
- **Parallel Group**: Wave 1 (solo)
- **Blocks**: Tasks 2, 3
- **Blocked By**: None (can start immediately)
**References** (CRITICAL):
**Pattern References** (existing code to follow):
- `aruco/ground_plane.py:19-68` — Dataclass pattern for Config/Metrics/Result (FloorPlane, FloorCorrection, GroundPlaneConfig, GroundPlaneMetrics). Follow same style for ICPConfig, ICPResult, ICPMetrics.
- `aruco/ground_plane.py:71-111``unproject_depth_to_points` implementation. Reuse this function directly (import from `aruco.ground_plane`) for point cloud generation.
- `aruco/ground_plane.py:114-157``detect_floor_plane` RANSAC pattern. The returned `FloorPlane.d` and `FloorPlane.normal` define where the floor is — use these to define the near-floor band.
- `aruco/ground_plane.py:358-540``refine_ground_from_depth` orchestrator pattern. Follow this structure for `refine_with_icp`: iterate cameras, validate, compute, apply bounds, return metrics.
- `aruco/ground_plane.py:1-16` — Import structure and type aliases (Vec3, Mat44, PointsNC). Reuse these.
- `aruco/depth_refine.py:71-227` — Optimization pattern with stats/metrics reporting and safety bounds.
**API/Type References** (contracts to implement against):
- `aruco/ground_plane.py:10-16` — Type aliases (Vec3, Mat44, PointsNC) — reuse these
- `aruco/ground_plane.py:20-23``FloorPlane` dataclass — `normal` and `d` fields define the floor for band extraction
- `aruco/ground_plane.py:54-68``GroundPlaneMetrics``camera_planes: Dict[str, FloorPlane]` provides per-camera floor parameters
**External References** (Open3D APIs to use):
- `o3d.pipelines.registration.registration_icp` — Point-to-plane ICP
- `o3d.pipelines.registration.TransformationEstimationPointToPlane` — estimation method for point-to-plane
- `o3d.pipelines.registration.registration_generalized_icp` — GICP variant
- `o3d.pipelines.registration.TransformationEstimationForGeneralizedICP` — estimation method for GICP
- `o3d.pipelines.registration.ICPConvergenceCriteria` — convergence parameters
- `o3d.pipelines.registration.PoseGraph` + `PoseGraphNode` + `PoseGraphEdge` — pose graph construction
- `o3d.pipelines.registration.global_optimization` — graph optimizer
- `o3d.pipelines.registration.GlobalOptimizationLevenbergMarquardt` — LM solver
- `o3d.pipelines.registration.GlobalOptimizationConvergenceCriteria` — optimizer convergence
- `o3d.pipelines.registration.GlobalOptimizationOption` — optimizer options (reference_node)
- `o3d.pipelines.registration.get_information_matrix_from_point_clouds` — edge weight computation
- `o3d.geometry.PointCloud` + `voxel_down_sample` + `estimate_normals` — preprocessing
- `o3d.geometry.KDTreeSearchParamHybrid` — normal estimation parameters
**WHY Each Reference Matters**:
- `ground_plane.py` dataclasses: Follow same style so the ICP module feels native to the codebase
- `unproject_depth_to_points`: Don't reinvent — import and reuse for depth→point cloud conversion
- `FloorPlane.d` / `.normal`: These define the floor height and orientation from RANSAC — the band extraction needs these
- `refine_ground_from_depth`: The orchestrator pattern (iterate cameras, validate, compute, return metrics) should be followed exactly
- Open3D registration APIs: The core of the implementation — all ICP, pose graph, and optimization
**Acceptance Criteria**:
**Agent-Executed QA Scenarios (MANDATORY):**
```
Scenario: Module imports without errors
Tool: Bash
Preconditions: None
Steps:
1. uv run python -c "from aruco.icp_registration import ICPConfig, ICPResult, ICPMetrics, refine_with_icp, pairwise_icp, extract_near_floor_band, compute_overlap_xz, build_pose_graph, optimize_pose_graph, apply_gravity_constraint"
2. Assert: exit code 0
Expected Result: All symbols importable
Evidence: Command output
Scenario: Type check passes on new module
Tool: Bash
Preconditions: Module file exists
Steps:
1. uv run basedpyright aruco/icp_registration.py
2. Assert: exit code 0, no error-level diagnostics
Expected Result: Clean type check
Evidence: basedpyright output
Scenario: Smoke test with synthetic overlapping floor points
Tool: Bash
Preconditions: Module implemented
Steps:
1. uv run python -c "
import numpy as np
from aruco.icp_registration import refine_with_icp, ICPConfig
from aruco.ground_plane import FloorPlane
rng = np.random.default_rng(42)
# Two cameras with overlapping floor + small box
floor1 = np.column_stack([rng.uniform(-2, 2, 500), np.zeros(500) + rng.normal(0, 0.005, 500), rng.uniform(0, 4, 500)])
box1 = np.column_stack([rng.uniform(0, 0.5, 50), rng.uniform(0, 0.3, 50), rng.uniform(1.5, 2.0, 50)])
pts1 = np.vstack([floor1, box1])
floor2 = np.column_stack([rng.uniform(-1, 3, 500), np.zeros(500) + rng.normal(0, 0.005, 500), rng.uniform(1, 5, 500)])
box2 = np.column_stack([rng.uniform(0, 0.5, 50), rng.uniform(0, 0.3, 50), rng.uniform(1.5, 2.0, 50)])
pts2 = np.vstack([floor2, box2])
camera_data = {
'cam1': {'depth': np.ones((100,100)), 'K': np.eye(3)},
'cam2': {'depth': np.ones((100,100)), 'K': np.eye(3)},
}
extrinsics = {'cam1': np.eye(4), 'cam2': np.eye(4)}
planes = {
'cam1': FloorPlane(normal=np.array([0,1,0]), d=0.0),
'cam2': FloorPlane(normal=np.array([0,1,0]), d=0.0),
}
config = ICPConfig(voxel_size=0.05)
new_ext, metrics = refine_with_icp(camera_data, extrinsics, planes, config)
print(f'success={metrics.success}, pairs={metrics.num_pairs_attempted}')
print('smoke test passed')
"
2. Assert: exit code 0, prints "smoke test passed"
Expected Result: ICP runs without crashing on synthetic data
Evidence: stdout captured
```
**Commit**: YES
- Message: `feat(aruco): add ICP registration module with pose-graph optimization`
- Files: `aruco/icp_registration.py`
- Pre-commit: `uv run basedpyright aruco/icp_registration.py`
---
- [x] 2. CLI Integration (`refine_ground_plane.py`)
**What to do**:
Add ICP refinement as an optional post-processing step in `refine_ground_plane.py`:
1. **Add CLI flags** (follow existing click pattern at lines 19-92):
- `--icp/--no-icp` (default: `False`) — Enable ICP refinement after ground-plane correction
- `--icp-method` (type `click.Choice(["point_to_plane", "gicp"])`, default: `"point_to_plane"`) — ICP variant
- `--icp-voxel-size` (float, default: `0.02`) — Base voxel size in meters (multi-scale derived internally as `[4×, 2×, 1×]`)
2. **Add parameters to `main()` function** (line ~93-107): `icp: bool`, `icp_method: str`, `icp_voxel_size: float`
3. **Integration point**: After step 4 (line ~183, after `refine_ground_from_depth()`) and before step 5 (Save Output Extrinsics):
```python
# 4.5 Optional ICP Refinement
if icp:
from aruco.icp_registration import refine_with_icp, ICPConfig
icp_config = ICPConfig(
method=icp_method,
voxel_size=icp_voxel_size,
max_rotation_deg=max_rotation_deg,
max_translation_m=max_translation_m,
)
icp_extrinsics, icp_metrics = refine_with_icp(
camera_data_for_refine,
new_extrinsics, # post-ground-plane extrinsics
metrics.camera_planes, # FloorPlane per camera from RANSAC
icp_config,
)
if icp_metrics.success:
new_extrinsics = icp_extrinsics
logger.info(f"ICP refinement: {icp_metrics.message}")
else:
logger.warning(f"ICP refinement failed: {icp_metrics.message}. Using ground-plane-only extrinsics.")
```
4. **Add ICP metrics to output JSON** under `_meta.icp_refined` (same pattern as `_meta.ground_refined` at lines 218-238)
**Must NOT do**:
- Do NOT change the existing ground-plane refinement logic
- Do NOT change existing CLI flag behavior or defaults
- Do NOT make ICP enabled by default
- Do NOT add more than the 3 specified flags
- Do NOT modify existing function signatures
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Small, well-scoped integration — adding click flags and calling an existing function. ~50-80 lines of changes.
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES (with Task 3)
- **Parallel Group**: Wave 2 (with Task 3)
- **Blocks**: Task 4
- **Blocked By**: Task 1
**References**:
**Pattern References**:
- `refine_ground_plane.py:19-92` — Existing click options pattern. Follow exact style for new flags (decorator placement, help text style, defaults).
- `refine_ground_plane.py:93-107` — `main()` function signature. Add `icp: bool`, `icp_method: str`, `icp_voxel_size: float` parameters.
- `refine_ground_plane.py:179-183` — Integration point: after `refine_ground_from_depth` call, before save. ICP step goes here.
- `refine_ground_plane.py:218-238` — `_meta.ground_refined` output pattern. Copy this structure for `_meta.icp_refined`.
**API References**:
- `aruco/icp_registration.py:refine_with_icp` — The main function to call (from Task 1)
- `aruco/icp_registration.py:ICPConfig` — Configuration dataclass (from Task 1)
- `aruco/ground_plane.py:GroundPlaneMetrics.camera_planes` — `Dict[str, FloorPlane]` from RANSAC, passed to ICP
**WHY Each Reference Matters**:
- `refine_ground_plane.py` click patterns: Must match existing style exactly so CLI feels consistent
- `_meta.ground_refined`: Follow this exact pattern so output JSON is consistent
- `metrics.camera_planes`: This is the bridge — RANSAC detects floor planes, ICP uses them for band extraction
**Acceptance Criteria**:
**Agent-Executed QA Scenarios (MANDATORY):**
```
Scenario: CLI help shows new flags
Tool: Bash
Preconditions: refine_ground_plane.py updated
Steps:
1. uv run python refine_ground_plane.py --help
2. Assert: exit code 0
3. Assert: output contains "--icp / --no-icp"
4. Assert: output contains "--icp-method"
5. Assert: output contains "--icp-voxel-size"
Expected Result: All three new flags visible in help
Evidence: Help output captured
Scenario: --no-icp preserves existing behavior (no regression)
Tool: Bash
Preconditions: Test fixtures work
Steps:
1. uv run pytest tests/test_refine_ground_cli.py -x -vv
2. Assert: all tests pass (exit code 0)
Expected Result: Zero regressions in non-ICP path
Evidence: pytest output
Scenario: Type check passes
Tool: Bash
Steps:
1. uv run basedpyright refine_ground_plane.py
2. Assert: exit code 0
Expected Result: Clean type check
Evidence: basedpyright output
```
**Commit**: YES (groups with Task 3)
- Message: `feat(cli): add --icp/--icp-method/--icp-voxel-size to refine_ground_plane.py`
- Files: `refine_ground_plane.py`
- Pre-commit: `uv run basedpyright refine_ground_plane.py && uv run pytest tests/test_refine_ground_cli.py -x`
---
- [x] 3. Unit Tests (`tests/test_icp_registration.py`)
**What to do**:
Create comprehensive unit tests following existing test patterns in `tests/test_ground_plane.py`:
**Test functions to implement** (~19 tests):
*Near-floor band extraction:*
1. `test_extract_near_floor_band_basic` — synthetic points at known heights; verify only points within band are kept
2. `test_extract_near_floor_band_empty` — no points in band → returns empty array
3. `test_extract_near_floor_band_all_in_band` — all points within band → returns all
*Overlap detection:*
4. `test_compute_overlap_xz_full_overlap` — identical point clouds → overlap area ≈ full area
5. `test_compute_overlap_xz_no_overlap` — disjoint clouds → overlap = 0.0
6. `test_compute_overlap_xz_partial` — known partial overlap → verify area within tolerance
7. `test_compute_overlap_xz_with_margin` — margin inflates overlap detection
*Pairwise ICP:*
8. `test_pairwise_icp_identity` — source == target → transform ≈ identity, high fitness
9. `test_pairwise_icp_known_transform_point_to_plane` — source = target transformed by known small T → recovered T within tolerance (≤2cm, ≤1°)
10. `test_pairwise_icp_known_transform_gicp` — same as above but with method="gicp"
11. `test_pairwise_icp_insufficient_points` — too few points → converged=False or low fitness
*Gravity constraint:*
12. `test_apply_gravity_constraint_preserves_yaw` — yaw component unchanged
13. `test_apply_gravity_constraint_regularizes_pitch_roll` — pitch/roll pulled toward original values
14. `test_apply_gravity_constraint_identity` — no pitch/roll change → output ≈ input
*Pose graph:*
15. `test_build_pose_graph_basic` — 3 cameras, 2 converged pairs → graph has correct nodes/edges
16. `test_build_pose_graph_disconnected` — cameras with no overlap → warns, excludes disconnected
*Integration (orchestrator):*
17. `test_refine_with_icp_synthetic_offset` — 3+ cameras with known offset from ground truth → ICP reduces error
18. `test_refine_with_icp_no_overlap` — cameras with no floor overlap → returns original extrinsics, success=False
19. `test_refine_with_icp_single_camera` — only 1 camera → skip ICP, return original
**Synthetic data strategy** (follow `tests/test_ground_plane.py` patterns):
- Generate 3D point clouds of "floor + small box/wall" scene for 3D structure
- Apply known rigid transforms to simulate camera viewpoints
- Add Gaussian noise (~0.005m) to simulate depth sensor noise
- Use `np.random.default_rng(seed)` for reproducibility
- Use `numpy.testing.assert_allclose` for numerical comparisons with `atol` appropriate for ICP (1-2cm)
- Use `pytest.raises` for error cases
**Must NOT do**:
- Do NOT require real ZED data or SVO files
- Do NOT require network access
- Do NOT modify existing test files
- Do NOT use mocks for Open3D (test actual ICP convergence)
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Many test functions (~19), synthetic 3D data generation with noise, numerical assertions with appropriate tolerances. Needs careful attention to geometry.
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES (with Task 2)
- **Parallel Group**: Wave 2 (with Task 2)
- **Blocks**: Task 4
- **Blocked By**: Task 1
**References**:
**Pattern References** (testing patterns to follow):
- `tests/test_ground_plane.py:18-37` — `test_unproject_depth_to_points_simple`: How to create synthetic depth map + intrinsics matrix + assert shape/values
- `tests/test_ground_plane.py:80-97` — `test_detect_floor_plane_perfect`: How to create synthetic planar point clouds with known normal/d and assert detection
- `tests/test_ground_plane.py:123-136` — `test_detect_floor_plane_with_outliers`: Adding noise/outliers pattern
- `tests/test_ground_plane.py:298-311` — `test_compute_floor_correction_identity`: Testing identity transform and small deltas
- `tests/test_ground_plane.py:495-606` — Integration test `test_refine_ground_from_depth_*`: Constructing synthetic camera_data + extrinsics dictionaries
**API References**:
- `aruco/icp_registration.py` — All public functions and dataclasses (from Task 1)
**WHY Each Reference Matters**:
- `test_ground_plane.py` patterns: Follow the EXACT same style (assert_allclose, synthetic data, seed-based reproducibility) so tests feel native
- Integration test patterns: Show how to construct `camera_data` and `extrinsics` dicts matching the expected format
**Acceptance Criteria**:
**Agent-Executed QA Scenarios (MANDATORY):**
```
Scenario: All new tests pass
Tool: Bash
Preconditions: Task 1 complete (icp_registration.py exists)
Steps:
1. uv run pytest tests/test_icp_registration.py -x -vv
2. Assert: exit code 0
3. Assert: output shows ≥19 tests passed
Expected Result: All tests green
Evidence: pytest output with test names and pass counts
Scenario: Tests complete in reasonable time
Tool: Bash
Steps:
1. timeout 120 uv run pytest tests/test_icp_registration.py -x -vv
2. Assert: completes within 120 seconds
Expected Result: No individual test takes more than ~30s
Evidence: pytest timing output
Scenario: Known transform recovery within tolerance
Tool: Bash
Steps:
1. uv run pytest tests/test_icp_registration.py -k "known_transform" -x -vv
2. Assert: passes
Expected Result: ICP recovers known rigid transform within ≤2cm translation, ≤1° rotation
Evidence: Test output captured
```
**Commit**: YES (groups with Task 2)
- Message: `test(icp): add comprehensive unit tests for ICP registration`
- Files: `tests/test_icp_registration.py`
- Pre-commit: `uv run pytest tests/test_icp_registration.py -x`
---
- [x] 4. Integration Testing & Final Verification
**What to do**:
Run the full verification suite to ensure everything works together:
1. Run full test suite (existing + new) to catch regressions
2. Run basedpyright on entire project
3. Verify CLI end-to-end with `--help`
4. Verify `--no-icp` default preserves existing behavior
**Must NOT do**:
- Do NOT fix issues in other tasks' code (flag them instead)
- Do NOT add new features
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Verification-only task — running commands and checking results. No new code.
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: NO (final integration — depends on everything)
- **Parallel Group**: Wave 3 (solo)
- **Blocks**: None (final task)
- **Blocked By**: Tasks 2, 3
**References**:
- `pyproject.toml:41-43` — pytest configuration (testpaths, norecursedirs)
- `AGENTS.md` — build/test commands reference
**Acceptance Criteria**:
**Agent-Executed QA Scenarios (MANDATORY):**
```
Scenario: Full test suite passes
Tool: Bash
Steps:
1. uv run pytest -x -vv
2. Assert: exit code 0
3. Assert: no failures, no errors
Expected Result: All tests green including new ICP tests
Evidence: Full pytest output
Scenario: Full type check passes
Tool: Bash
Steps:
1. uv run basedpyright
2. Assert: exit code 0
Expected Result: Zero type errors across entire project
Evidence: basedpyright output
Scenario: CLI help is correct
Tool: Bash
Steps:
1. uv run python refine_ground_plane.py --help
2. Assert: output contains "--icp / --no-icp"
3. Assert: output contains "--icp-method"
4. Assert: output contains "--icp-voxel-size"
Expected Result: All flags documented
Evidence: Help output
```
**Commit**: NO (verification only — all code committed in Tasks 1-3)
---
## Commit Strategy
| After Task | Message | Files | Verification |
|------------|---------|-------|--------------|
| 1 | `feat(aruco): add ICP registration module with pose-graph optimization` | `aruco/icp_registration.py` | `uv run basedpyright aruco/icp_registration.py` |
| 2+3 | `feat(cli): add --icp to refine_ground_plane.py` + `test(icp): add ICP unit tests` | `refine_ground_plane.py`, `tests/test_icp_registration.py` | `uv run pytest -x` |
| 4 | (no commit — verification only) | — | `uv run pytest && uv run basedpyright` |
---
## Success Criteria
### Verification Commands
```bash
uv run pytest -x -vv # All tests pass
uv run basedpyright # Zero type errors
uv run python refine_ground_plane.py --help # Shows new flags
uv run pytest tests/test_icp_registration.py -x -vv # ICP tests specifically
```
### Final Checklist
- [x] All "Must Have" items present and functional
- [x] All "Must NOT Have" items confirmed absent
- [x] All existing tests still pass (no regressions)
- [x] New ICP tests (≥19) all pass
- [x] basedpyright clean
- [x] CLI flags documented in help output
- [x] `--no-icp` produces identical behavior to current code