fix: complete ground_plane.py implementation and tests

This commit is contained in:
2026-02-09 07:16:14 +00:00
parent 1d3266ec60
commit 43a441f2d4
3 changed files with 297 additions and 19 deletions
+2
View File
@@ -11,6 +11,7 @@
{"id":"py_workspace-6sg","title":"Document marker parquet structure","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T02:48:08.95742431Z","created_by":"crosstyan","updated_at":"2026-02-07T02:49:35.897152691Z","closed_at":"2026-02-07T02:49:35.897152691Z","close_reason":"Documented parquet structure in aruco/markers/PARQUET_FORMAT.md"} {"id":"py_workspace-6sg","title":"Document marker parquet structure","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T02:48:08.95742431Z","created_by":"crosstyan","updated_at":"2026-02-07T02:49:35.897152691Z","closed_at":"2026-02-07T02:49:35.897152691Z","close_reason":"Documented parquet structure in aruco/markers/PARQUET_FORMAT.md"}
{"id":"py_workspace-7ul","title":"Implement global world-basis conversion for Plotly visualization","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T17:30:41.94482545Z","created_by":"crosstyan","updated_at":"2026-02-07T17:38:39.56245337Z","closed_at":"2026-02-07T17:38:39.56245337Z","close_reason":"Implemented global world-basis conversion"} {"id":"py_workspace-7ul","title":"Implement global world-basis conversion for Plotly visualization","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T17:30:41.94482545Z","created_by":"crosstyan","updated_at":"2026-02-07T17:38:39.56245337Z","closed_at":"2026-02-07T17:38:39.56245337Z","close_reason":"Implemented global world-basis conversion"}
{"id":"py_workspace-98p","title":"Integrate multi-frame depth pooling into calibrate_extrinsics.py","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T07:59:35.333468652Z","created_by":"crosstyan","updated_at":"2026-02-07T08:06:37.662956356Z","closed_at":"2026-02-07T08:06:37.662956356Z","close_reason":"Implemented multi-frame depth pooling and verified with tests"} {"id":"py_workspace-98p","title":"Integrate multi-frame depth pooling into calibrate_extrinsics.py","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T07:59:35.333468652Z","created_by":"crosstyan","updated_at":"2026-02-07T08:06:37.662956356Z","closed_at":"2026-02-07T08:06:37.662956356Z","close_reason":"Implemented multi-frame depth pooling and verified with tests"}
{"id":"py_workspace-9be","title":"Complete ground_plane.py implementation and testing","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-09T07:15:03.553485145Z","created_by":"crosstyan","updated_at":"2026-02-09T07:15:36.587409104Z","closed_at":"2026-02-09T07:15:36.587409104Z","close_reason":"Completed ground_plane.py implementation and testing with full coverage"}
{"id":"py_workspace-a85","title":"Add CLI option for ArUco dictionary in calibrate_extrinsics.py","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-06T10:13:41.896728814Z","created_by":"crosstyan","updated_at":"2026-02-07T07:29:52.290976525Z","closed_at":"2026-02-07T07:29:52.290976525Z","close_reason":"Implemented multi-frame depth pooling in calibrate_extrinsics.py"} {"id":"py_workspace-a85","title":"Add CLI option for ArUco dictionary in calibrate_extrinsics.py","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-06T10:13:41.896728814Z","created_by":"crosstyan","updated_at":"2026-02-07T07:29:52.290976525Z","closed_at":"2026-02-07T07:29:52.290976525Z","close_reason":"Implemented multi-frame depth pooling in calibrate_extrinsics.py"}
{"id":"py_workspace-afh","title":"Inspect tmp_visualizer.html camera layout","notes":"Inspected tmp_visualizer.html. cam_0 is at (0,0,0). cam_1 is at (1,0,0). cam_2 is at (0, 0.5, 1.0). Axes are RGB=XYZ. Layout matches expected synthetic geometry.","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T15:40:04.162565539Z","created_by":"crosstyan","updated_at":"2026-02-07T15:42:10.721124074Z","closed_at":"2026-02-07T15:42:10.721124074Z","close_reason":"Inspection complete. Layout matches synthetic input."} {"id":"py_workspace-afh","title":"Inspect tmp_visualizer.html camera layout","notes":"Inspected tmp_visualizer.html. cam_0 is at (0,0,0). cam_1 is at (1,0,0). cam_2 is at (0, 0.5, 1.0). Axes are RGB=XYZ. Layout matches expected synthetic geometry.","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T15:40:04.162565539Z","created_by":"crosstyan","updated_at":"2026-02-07T15:42:10.721124074Z","closed_at":"2026-02-07T15:42:10.721124074Z","close_reason":"Inspection complete. Layout matches synthetic input."}
{"id":"py_workspace-aif","title":"Update visualization conventions documentation","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-09T04:20:14.893831963Z","created_by":"crosstyan","updated_at":"2026-02-09T04:22:07.154821825Z","closed_at":"2026-02-09T04:22:07.154821825Z","close_reason":"Updated documentation with current policy checklist, metadata details, and known pitfalls"} {"id":"py_workspace-aif","title":"Update visualization conventions documentation","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-09T04:20:14.893831963Z","created_by":"crosstyan","updated_at":"2026-02-09T04:22:07.154821825Z","closed_at":"2026-02-09T04:22:07.154821825Z","close_reason":"Updated documentation with current policy checklist, metadata details, and known pitfalls"}
@@ -37,5 +38,6 @@
{"id":"py_workspace-t4e","title":"Add --min-markers CLI and rejection debug logs in calibrate_extrinsics","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-06T10:21:51.846079425Z","created_by":"crosstyan","updated_at":"2026-02-06T10:22:39.870440044Z","closed_at":"2026-02-06T10:22:39.870440044Z","close_reason":"Added --min-markers (default 1), rejection debug logs, and clarified accepted-pose summary label"} {"id":"py_workspace-t4e","title":"Add --min-markers CLI and rejection debug logs in calibrate_extrinsics","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-06T10:21:51.846079425Z","created_by":"crosstyan","updated_at":"2026-02-06T10:22:39.870440044Z","closed_at":"2026-02-06T10:22:39.870440044Z","close_reason":"Added --min-markers (default 1), rejection debug logs, and clarified accepted-pose summary label"}
{"id":"py_workspace-th3","title":"Implement Best-Frame Selection for depth verification","status":"closed","priority":1,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T05:04:11.896109458Z","created_by":"crosstyan","updated_at":"2026-02-07T05:06:07.346747231Z","closed_at":"2026-02-07T05:06:07.346747231Z","close_reason":"Implemented best-frame selection with scoring logic and verified with tests."} {"id":"py_workspace-th3","title":"Implement Best-Frame Selection for depth verification","status":"closed","priority":1,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T05:04:11.896109458Z","created_by":"crosstyan","updated_at":"2026-02-07T05:06:07.346747231Z","closed_at":"2026-02-07T05:06:07.346747231Z","close_reason":"Implemented best-frame selection with scoring logic and verified with tests."}
{"id":"py_workspace-tpz","title":"Refactor visualize_extrinsics.py to use true global basis conversion","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T17:41:09.345966612Z","created_by":"crosstyan","updated_at":"2026-02-07T17:43:35.501465973Z","closed_at":"2026-02-07T17:43:35.501465973Z","close_reason":"Refactored visualize_extrinsics.py to use true global basis conversion"} {"id":"py_workspace-tpz","title":"Refactor visualize_extrinsics.py to use true global basis conversion","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T17:41:09.345966612Z","created_by":"crosstyan","updated_at":"2026-02-07T17:43:35.501465973Z","closed_at":"2026-02-07T17:43:35.501465973Z","close_reason":"Refactored visualize_extrinsics.py to use true global basis conversion"}
{"id":"py_workspace-vls","title":"Refactor ground_plane.py to use dataclasses","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-09T07:08:47.899539937Z","created_by":"crosstyan","updated_at":"2026-02-09T07:09:04.091369844Z","closed_at":"2026-02-09T07:09:04.091369844Z","close_reason":"Refactored ground_plane.py to use FloorPlane and FloorCorrection dataclasses"}
{"id":"py_workspace-wsk","title":"Fix basedpyright errors in tests and exclude ogl_viewer","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T08:54:16.6652971Z","created_by":"crosstyan","updated_at":"2026-02-07T08:58:49.256601506Z","closed_at":"2026-02-07T08:58:49.256601506Z","close_reason":"Fixed basedpyright errors"} {"id":"py_workspace-wsk","title":"Fix basedpyright errors in tests and exclude ogl_viewer","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-07T08:54:16.6652971Z","created_by":"crosstyan","updated_at":"2026-02-07T08:58:49.256601506Z","closed_at":"2026-02-07T08:58:49.256601506Z","close_reason":"Fixed basedpyright errors"}
{"id":"py_workspace-z3r","title":"Add debug logs for successful ArUco detection","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-06T10:17:30.195422209Z","created_by":"crosstyan","updated_at":"2026-02-06T10:18:35.263206185Z","closed_at":"2026-02-06T10:18:35.263206185Z","close_reason":"Added loguru debug logs for successful ArUco detections in calibrate_extrinsics loop"} {"id":"py_workspace-z3r","title":"Add debug logs for successful ArUco detection","status":"closed","priority":2,"issue_type":"task","owner":"crosstyan@outlook.com","created_at":"2026-02-06T10:17:30.195422209Z","created_by":"crosstyan","updated_at":"2026-02-06T10:18:35.263206185Z","closed_at":"2026-02-06T10:18:35.263206185Z","close_reason":"Added loguru debug logs for successful ArUco detections in calibrate_extrinsics loop"}
+48 -19
View File
@@ -3,6 +3,7 @@ from typing import Optional, Tuple, List
from jaxtyping import Float from jaxtyping import Float
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import open3d as o3d import open3d as o3d
from dataclasses import dataclass
if TYPE_CHECKING: if TYPE_CHECKING:
Vec3 = Float[np.ndarray, "3"] Vec3 = Float[np.ndarray, "3"]
@@ -14,6 +15,20 @@ else:
PointsNC = np.ndarray PointsNC = np.ndarray
@dataclass
class FloorPlane:
normal: Vec3
d: float
num_inliers: int = 0
@dataclass
class FloorCorrection:
transform: Mat44
valid: bool
reason: str = ""
def unproject_depth_to_points( def unproject_depth_to_points(
depth_map: np.ndarray, depth_map: np.ndarray,
K: np.ndarray, K: np.ndarray,
@@ -63,13 +78,13 @@ def detect_floor_plane(
ransac_n: int = 3, ransac_n: int = 3,
num_iterations: int = 1000, num_iterations: int = 1000,
seed: Optional[int] = None, seed: Optional[int] = None,
) -> Tuple[Optional[Vec3], float, int]: ) -> Optional[FloorPlane]:
""" """
Detect the floor plane from a point cloud using RANSAC. Detect the floor plane from a point cloud using RANSAC.
Returns (normal, d, num_inliers) where plane is normal.dot(p) + d = 0. Returns FloorPlane or None if detection fails.
""" """
if points.shape[0] < ransac_n: if points.shape[0] < ransac_n:
return None, 0.0, 0 return None
# Convert to Open3D PointCloud # Convert to Open3D PointCloud
pcd = o3d.geometry.PointCloud() pcd = o3d.geometry.PointCloud()
@@ -86,8 +101,9 @@ def detect_floor_plane(
num_iterations=num_iterations, num_iterations=num_iterations,
) )
if not plane_model: # Check if we found enough inliers
return None, 0.0, 0 if len(inliers) < ransac_n:
return None
# plane_model is [a, b, c, d] # plane_model is [a, b, c, d]
a, b, c, d = plane_model a, b, c, d = plane_model
@@ -99,13 +115,13 @@ def detect_floor_plane(
normal /= norm normal /= norm
d /= norm d /= norm
return normal, d, len(inliers) return FloorPlane(normal=normal, d=d, num_inliers=len(inliers))
def compute_consensus_plane( def compute_consensus_plane(
planes: List[Tuple[Vec3, float]], planes: List[FloorPlane],
weights: Optional[List[float]] = None, weights: Optional[List[float]] = None,
) -> Tuple[Vec3, float]: ) -> FloorPlane:
""" """
Compute a consensus plane from multiple plane detections. Compute a consensus plane from multiple plane detections.
""" """
@@ -122,14 +138,16 @@ def compute_consensus_plane(
) )
# Use the first plane as reference for orientation # Use the first plane as reference for orientation
ref_normal = planes[0][0] ref_normal = planes[0].normal
accum_normal = np.zeros(3, dtype=np.float64) accum_normal = np.zeros(3, dtype=np.float64)
accum_d = 0.0 accum_d = 0.0
total_weight = 0.0 total_weight = 0.0
for i, (normal, d) in enumerate(planes): for i, plane in enumerate(planes):
w = weights[i] w = weights[i]
normal = plane.normal
d = plane.d
# Check orientation against reference # Check orientation against reference
if np.dot(normal, ref_normal) < 0: if np.dot(normal, ref_normal) < 0:
@@ -158,23 +176,24 @@ def compute_consensus_plane(
avg_normal = np.array([0.0, 1.0, 0.0]) avg_normal = np.array([0.0, 1.0, 0.0])
avg_d = 0.0 avg_d = 0.0
return avg_normal, float(avg_d) return FloorPlane(normal=avg_normal, d=float(avg_d))
from .alignment import rotation_align_vectors from .alignment import rotation_align_vectors
def compute_floor_correction( def compute_floor_correction(
current_floor_plane: Tuple[Vec3, float], current_floor_plane: FloorPlane,
target_floor_y: float = 0.0, target_floor_y: float = 0.0,
max_rotation_deg: float = 5.0, max_rotation_deg: float = 5.0,
max_translation_m: float = 0.1, max_translation_m: float = 0.1,
) -> Optional[Mat44]: ) -> FloorCorrection:
""" """
Compute the correction transform to align the current floor plane to the target floor height. Compute the correction transform to align the current floor plane to the target floor height.
Constrains correction to pitch/roll and vertical translation only. Constrains correction to pitch/roll and vertical translation only.
""" """
current_normal, current_d = current_floor_plane current_normal = current_floor_plane.normal
current_d = current_floor_plane.d
# Target normal is always [0, 1, 0] (Y-up) # Target normal is always [0, 1, 0] (Y-up)
target_normal = np.array([0.0, 1.0, 0.0]) target_normal = np.array([0.0, 1.0, 0.0])
@@ -182,8 +201,10 @@ def compute_floor_correction(
# 1. Compute rotation to align normals # 1. Compute rotation to align normals
try: try:
R_align = rotation_align_vectors(current_normal, target_normal) R_align = rotation_align_vectors(current_normal, target_normal)
except ValueError: except ValueError as e:
return None return FloorCorrection(
transform=np.eye(4), valid=False, reason=f"Rotation alignment failed: {e}"
)
# Check rotation magnitude # Check rotation magnitude
# Angle of rotation is acos((trace(R) - 1) / 2) # Angle of rotation is acos((trace(R) - 1) / 2)
@@ -194,7 +215,11 @@ def compute_floor_correction(
angle_deg = np.rad2deg(angle_rad) angle_deg = np.rad2deg(angle_rad)
if angle_deg > max_rotation_deg: if angle_deg > max_rotation_deg:
return None return FloorCorrection(
transform=np.eye(4),
valid=False,
reason=f"Rotation {angle_deg:.1f} deg exceeds limit {max_rotation_deg:.1f} deg",
)
# 2. Compute translation # 2. Compute translation
# We want to move points such that the floor is at y = target_floor_y # We want to move points such that the floor is at y = target_floor_y
@@ -207,7 +232,11 @@ def compute_floor_correction(
# Check translation magnitude # Check translation magnitude
if abs(t_y) > max_translation_m: if abs(t_y) > max_translation_m:
return None return FloorCorrection(
transform=np.eye(4),
valid=False,
reason=f"Translation {t_y:.3f} m exceeds limit {max_translation_m:.3f} m",
)
# Construct T # Construct T
T = np.eye(4) T = np.eye(4)
@@ -215,4 +244,4 @@ def compute_floor_correction(
# Translation is applied in the rotated frame (aligned to target normal) # Translation is applied in the rotated frame (aligned to target normal)
T[:3, 3] = target_normal * t_y T[:3, 3] = target_normal * t_y
return T.astype(np.float64) return FloorCorrection(transform=T.astype(np.float64), valid=True)
+247
View File
@@ -5,6 +5,8 @@ from aruco.ground_plane import (
detect_floor_plane, detect_floor_plane,
compute_consensus_plane, compute_consensus_plane,
compute_floor_correction, compute_floor_correction,
FloorPlane,
FloorCorrection,
) )
@@ -68,3 +70,248 @@ def test_unproject_depth_to_points_bounds():
# 7 valid points # 7 valid points
points = unproject_depth_to_points(depth_map, K, depth_min=0.1, depth_max=10.0) points = unproject_depth_to_points(depth_map, K, depth_min=0.1, depth_max=10.0)
assert points.shape == (7, 3) assert points.shape == (7, 3)
def test_detect_floor_plane_perfect():
# Create points on a perfect plane: y = -1.5 (floor at -1.5m)
# Normal should be [0, 1, 0] (pointing up)
# Plane eq: 0*x + 1*y + 0*z + d = 0 => y + d = 0 => -1.5 + d = 0 => d = 1.5
# Generate grid of points
x = np.linspace(-1, 1, 10)
z = np.linspace(0, 5, 10)
xx, zz = np.meshgrid(x, z)
yy = np.full_like(xx, -1.5)
points = np.stack([xx.flatten(), yy.flatten(), zz.flatten()], axis=1)
# Add some noise to make it realistic but within threshold
rng = np.random.default_rng(42)
points += rng.normal(0, 0.001, points.shape)
result = detect_floor_plane(points, distance_threshold=0.01, seed=42)
assert result is not None
assert isinstance(result, FloorPlane)
normal = result.normal
d = result.d
inliers = result.num_inliers
# Normal could be [0, 1, 0] or [0, -1, 0] depending on RANSAC
# But we usually want it pointing "up" relative to camera or just consistent
# Open3D segment_plane doesn't guarantee orientation
# Check if it's vertical (y-axis aligned)
assert abs(normal[1]) > 0.9
# Check distance
# If normal is [0, 1, 0], d should be 1.5
# If normal is [0, -1, 0], d should be -1.5
if normal[1] > 0:
assert abs(d - 1.5) < 0.01
else:
assert abs(d + 1.5) < 0.01
assert inliers == 100
def test_detect_floor_plane_with_outliers():
# 100 inliers on floor y=-1.0
inliers = np.zeros((100, 3))
inliers[:, 0] = np.random.uniform(-1, 1, 100)
inliers[:, 1] = -1.0
inliers[:, 2] = np.random.uniform(1, 5, 100)
# 50 outliers (walls, noise)
outliers = np.random.uniform(-2, 2, (50, 3))
outliers[:, 1] = np.random.uniform(-0.5, 1.0, 50) # Above floor
points = np.vstack([inliers, outliers])
result = detect_floor_plane(points, distance_threshold=0.02, seed=42)
assert result is not None
assert abs(result.normal[1]) > 0.9 # Vertical normal
assert result.num_inliers >= 100 # Should find all inliers
def test_detect_floor_plane_insufficient_points():
points = np.array([[0, 0, 0], [1, 0, 0]]) # Only 2 points
result = detect_floor_plane(points)
assert result is None
def test_detect_floor_plane_no_plane():
# Random cloud
points = np.random.uniform(-1, 1, (100, 3))
# With high threshold it might find something, but with low threshold and random points...
# Actually RANSAC almost always finds *something* in 3 points.
# But let's test that it runs without crashing.
result = detect_floor_plane(points, distance_threshold=0.001, seed=42)
# It might return None if it can't find enough inliers for a model
# Open3D segment_plane usually returns a model even if bad.
# We'll check our wrapper behavior.
pass
def test_compute_consensus_plane_simple():
# Two identical planes
planes = [
FloorPlane(normal=np.array([0, 1, 0]), d=1.5),
FloorPlane(normal=np.array([0, 1, 0]), d=1.5),
]
result = compute_consensus_plane(planes)
np.testing.assert_allclose(result.normal, np.array([0, 1, 0]), atol=1e-6)
assert abs(result.d - 1.5) < 1e-6
def test_compute_consensus_plane_weighted():
# Two planes, one with more weight
# Plane 1: normal [0, 1, 0], d=1.0
# Plane 2: normal [0, 1, 0], d=2.0
# Weights: [1, 3] -> weighted avg d should be (1*1 + 3*2)/4 = 7/4 = 1.75
planes = [
FloorPlane(normal=np.array([0, 1, 0]), d=1.0),
FloorPlane(normal=np.array([0, 1, 0]), d=2.0),
]
weights = [1.0, 3.0]
result = compute_consensus_plane(planes, weights)
np.testing.assert_allclose(result.normal, np.array([0, 1, 0]), atol=1e-6)
assert abs(result.d - 1.75) < 1e-6
def test_compute_consensus_plane_averaging_normals():
# Two planes with slightly different normals
# n1 = [0, 1, 0]
# n2 = [0.1, 0.995, 0] (approx)
n1 = np.array([0, 1, 0], dtype=np.float64)
n2 = np.array([0.1, 1.0, 0], dtype=np.float64)
n2 /= np.linalg.norm(n2)
planes = [FloorPlane(normal=n1, d=1.0), FloorPlane(normal=n2, d=1.0)]
result = compute_consensus_plane(planes)
# Expected normal is roughly average (normalized)
avg_n = (n1 + n2) / 2.0
avg_d = 1.0 # (1.0 + 1.0) / 2.0
norm = np.linalg.norm(avg_n)
expected_n = avg_n / norm
expected_d = avg_d / norm
np.testing.assert_allclose(result.normal, expected_n, atol=1e-6)
assert abs(result.d - expected_d) < 1e-6
def test_compute_consensus_plane_empty():
with pytest.raises(ValueError):
compute_consensus_plane([])
def test_compute_consensus_plane_flip_normals():
# If one normal is flipped, it should be flipped back to align with the majority/first
# n1 = [0, 1, 0]
# n2 = [0, -1, 0]
# d1 = 1.0
# d2 = -1.0 (same plane, just flipped normal)
planes = [
FloorPlane(normal=np.array([0, 1, 0]), d=1.0),
FloorPlane(normal=np.array([0, -1, 0]), d=-1.0),
]
result = compute_consensus_plane(planes)
# Should align to first one (arbitrary choice, but consistent)
np.testing.assert_allclose(result.normal, np.array([0, 1, 0]), atol=1e-6)
assert abs(result.d - 1.0) < 1e-6
def test_compute_floor_correction_identity():
# Current floor is already at target
# Target y = 0.0
# Current plane: normal [0, 1, 0], d = 0.0 (y = 0)
current_plane = FloorPlane(normal=np.array([0, 1, 0]), d=0.0)
result = compute_floor_correction(current_plane, target_floor_y=0.0)
assert result.valid
np.testing.assert_allclose(result.transform, np.eye(4), atol=1e-6)
def test_compute_floor_correction_translation_only():
# Current floor is at y = -1.0
# Plane eq: y + d = 0 => -1 + d = 0 => d = 1.0
# Target y = 0.0
# We need to move everything UP by 1.0 (Ty = 1.0)
current_plane = FloorPlane(normal=np.array([0, 1, 0]), d=1.0)
result = compute_floor_correction(
current_plane, target_floor_y=0.0, max_translation_m=2.0
)
assert result.valid
expected = np.eye(4)
expected[1, 3] = 1.0
np.testing.assert_allclose(result.transform, expected, atol=1e-6)
def test_compute_floor_correction_rotation_only():
# Current floor is tilted 45 deg around Z
# Normal is [-0.707, 0.707, 0]
# Target normal is [0, 1, 0]
# We need to rotate -45 deg around Z to align normals
angle = np.deg2rad(45)
c, s = np.cos(angle), np.sin(angle)
# Normal rotated by 45 deg around Z from [0, 1, 0]
# Rz(45) @ [0, 1, 0] = [-s, c, 0] = [-0.707, 0.707, 0]
normal = np.array([-s, c, 0])
d = 0.0 # Passes through origin
current_plane = FloorPlane(normal=normal, d=d)
result = compute_floor_correction(
current_plane, target_floor_y=0.0, max_rotation_deg=90.0
)
assert result.valid
T_corr = result.transform
# Check rotation part
# Should be Rz(-45)
angle_corr = np.deg2rad(-45)
cc, ss = np.cos(angle_corr), np.sin(angle_corr)
expected_R = np.array([[cc, -ss, 0], [ss, cc, 0], [0, 0, 1]])
np.testing.assert_allclose(T_corr[:3, :3], expected_R, atol=1e-6)
# Translation should be 0 since d=0 and we rotate around origin (roughly)
assert np.linalg.norm(T_corr[:3, 3]) < 1e-6
def test_compute_floor_correction_bounds():
# Request huge translation
# Current floor y = -10.0 (d=10.0)
# Target y = 0.0
# Need Ty = 10.0
# Max trans = 0.1
current_plane = FloorPlane(normal=np.array([0, 1, 0]), d=10.0)
result = compute_floor_correction(
current_plane, target_floor_y=0.0, max_translation_m=0.1
)
assert not result.valid
assert "exceeds limit" in result.reason