feat: implement ground plane orchestration

This commit is contained in:
2026-02-09 07:27:36 +00:00
parent 6f34cd48fe
commit 94d9a27724
2 changed files with 318 additions and 2 deletions
+160
View File
@@ -5,8 +5,11 @@ from aruco.ground_plane import (
detect_floor_plane,
compute_consensus_plane,
compute_floor_correction,
refine_ground_from_depth,
FloorPlane,
FloorCorrection,
GroundPlaneConfig,
GroundPlaneMetrics,
)
@@ -315,3 +318,160 @@ def test_compute_floor_correction_bounds():
assert not result.valid
assert "exceeds limit" in result.reason
def test_refine_ground_from_depth_disabled():
config = GroundPlaneConfig(enabled=False)
extrinsics = {"cam1": np.eye(4)}
camera_data = {"cam1": {"depth": np.zeros((10, 10)), "K": np.eye(3)}}
new_extrinsics, metrics = refine_ground_from_depth(camera_data, extrinsics, config)
assert not metrics.success
assert "disabled" in metrics.message
assert new_extrinsics == extrinsics
def test_refine_ground_from_depth_insufficient_cameras():
# Only 1 camera, need 2
config = GroundPlaneConfig(min_valid_cameras=2, min_inliers=10)
# Create fake depth map that produces a plane
# Plane at y=-1.0
width, height = 20, 20
K = np.eye(3)
K[0, 2] = 10
K[1, 2] = 10
K[0, 0] = 20
K[1, 1] = 20
# Generate points on plane y=-1.0
# In camera frame (assuming cam at origin looking -Z), floor is at Y=-1.0
# But wait, standard camera frame is Y-down.
# Let's assume world frame is Y-up.
# If cam is at origin, and looking down -Z (OpenGL) or +Z (OpenCV).
# Let's use identity extrinsics -> cam frame = world frame.
# World frame Y-up.
# So we want points with y=-1.0.
# But unproject_depth gives points in camera frame.
# If we want world y=-1.0, and T=I, then cam y=-1.0.
# But unproject uses OpenCV convention: Y-down.
# So y=-1.0 means 1m UP in camera frame.
# Let's just make points that form A plane, doesn't matter which one,
# as long as it's detected.
# Let's make a flat plane at Z=2.0 (fronto-parallel)
depth_map = np.full((height, width), 2.0, dtype=np.float32)
# Need to ensure we have enough points for RANSAC
# 20x20 = 400 points.
# Stride default is 8. 20/8 = 2. 2x2 = 4 points.
# RANSAC n=3. So 4 points is enough.
# But min_inliers=10. 4 < 10.
# So we need to reduce stride or increase size.
config.stride = 1
camera_data = {"cam1": {"depth": depth_map, "K": K}}
extrinsics = {"cam1": np.eye(4)}
new_extrinsics, metrics = refine_ground_from_depth(camera_data, extrinsics, config)
assert not metrics.success
assert "Found 1 valid planes" in metrics.message
assert metrics.num_cameras_valid == 1
def test_refine_ground_from_depth_success():
# 2 cameras, both seeing floor at y=-1.0
# We want to correct it to y=0.0
config = GroundPlaneConfig(
min_valid_cameras=2,
min_inliers=10,
target_y=0.0,
max_translation_m=2.0,
ransac_dist_thresh=0.05,
)
width, height = 20, 20
K = np.eye(3)
K[0, 2] = 10
K[1, 2] = 10
K[0, 0] = 20
K[1, 1] = 20
# Create points on plane y=-1.0 in WORLD frame
# Cam 1 at origin. T_world_cam = I.
# So points in cam 1 should be at y=-1.0.
# OpenCV cam: Y-down. So y=-1.0 is UP.
# Let's just use the fact that we transform points to world frame before detection.
# So if we make depth map such that unprojected points + extrinsics -> plane y=-1.0.
# Let's manually mock the detection to avoid complex depth map math
# We can't easily mock internal functions without monkeypatching.
# Instead, let's construct a depth map that corresponds to a plane.
# Simplest: Camera looking down at floor.
# Cam at (0, 2, 0) looking at (0, 0, 0).
# World floor at y=0.
# Cam floor distance = 2.0.
# But here we want to simulate a MISALIGNED floor.
# Say we think floor is at y=-1.0 (in our current world frame).
# So we generate points at y=-1.0.
# Let's try a simpler approach:
# Create depth map for a plane Z=2.0 in camera frame.
# Set extrinsics such that this plane becomes Y=-1.0 in world frame.
# Plane Z=2.0 in cam frame: (x, y, 2).
# We want R * (x, y, 2) + t = (X, -1, Z).
# Let R = Rotation(-90 deg around X).
# R = [[1, 0, 0], [0, 0, 1], [0, -1, 0]]
# R * (x, y, 2) = (x, 2, -y).
# We want Y_world = -1.
# So 2 + ty = -1 => ty = -3.
# So if we put cam at y=-3, rotated -90X.
# Then Z=2 plane becomes Y=-1 plane.
# Rotation -90 deg around X
Rx_neg90 = np.array([[1, 0, 0], [0, 0, 1], [0, -1, 0]])
t = np.array([0, -3, 0])
T_world_cam = np.eye(4)
T_world_cam[:3, :3] = Rx_neg90
T_world_cam[:3, 3] = t
# Depth map: constant 2.0
depth_map = np.full((height, width), 2.0, dtype=np.float32)
# Need to ensure we have enough points for RANSAC
# 20x20 = 400 points.
# Stride default is 8. 20/8 = 2. 2x2 = 4 points.
# RANSAC n=3. So 4 points is enough.
# But min_inliers=10. 4 < 10.
# So we need to reduce stride or increase size.
config.stride = 1
camera_data = {
"cam1": {"depth": depth_map, "K": K},
"cam2": {"depth": depth_map, "K": K},
}
extrinsics = {
"cam1": T_world_cam,
"cam2": T_world_cam,
}
new_extrinsics, metrics = refine_ground_from_depth(camera_data, extrinsics, config)
assert metrics.success
assert metrics.num_cameras_valid == 2
assert metrics.correction_applied
# We started with floor at y=-1.0. Target is y=0.0.
# So we expect translation of +1.0 in Y.
# T_corr should have ty approx 1.0.
T_corr = metrics.correction_transform
assert abs(T_corr[1, 3] - 1.0) < 0.1 # Allow some slack for RANSAC noise
# Check new extrinsics
# New T = T_corr @ Old T
# Old T origin y = -3.
# New T origin y should be -3 + 1 = -2.
T_new = new_extrinsics["cam1"]
assert abs(T_new[1, 3] - (-2.0)) < 0.1