Last active
March 16, 2025 02:37
-
-
Save yoi-hibino/63ba638c5dc0fd13225bfb75e5899e9c to your computer and use it in GitHub Desktop.
LiDAR Localization in 3D CAD Environment
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import numpy as np | |
import open3d as o3d | |
import matplotlib.pyplot as plt | |
import transforms3d as t3d | |
import trimesh | |
import scipy.spatial | |
class SensorLocalizer: | |
def __init__(self, cad_model_path, scale_factor=1.0): | |
""" | |
Initialize the localizer with a 3D CAD model | |
Args: | |
cad_model_path: Path to the 3D CAD model file (OBJ, STL, etc.) | |
scale_factor: Scale factor to convert CAD model units to real-world meters | |
(e.g., if CAD is in mm and real world is in m, use 0.001) | |
""" | |
self.cad_model = trimesh.load(cad_model_path) | |
self.scale_factor = scale_factor | |
# Apply scale factor to the model if needed | |
if scale_factor != 1.0: | |
self.cad_model.apply_scale(scale_factor) | |
# Generate arbitrary slices of the model | |
self.reference_slices = self._extract_arbitrary_slices() | |
def _extract_arbitrary_slices(self, num_slices=20): | |
""" | |
Extract multiple 2D slices from the 3D CAD model at different orientations | |
Args: | |
num_slices: Number of different orientations to try | |
Returns: | |
Dictionary mapping orientation parameters to 2D point arrays | |
""" | |
reference_slices = {} | |
# Fixed height for central slicing | |
center_z = self.cad_model.bounds.mean(axis=0)[2] | |
# Generate a range of roll and pitch angles to try | |
# We'll use a Fibonacci sphere to distribute points evenly on a hemisphere | |
angles = [] | |
# Simple approach: grid of roll/pitch angles | |
roll_angles = np.linspace(-np.pi/6, np.pi/6, int(np.sqrt(num_slices))) # ±30 degrees | |
pitch_angles = np.linspace(-np.pi/6, np.pi/6, int(np.sqrt(num_slices))) # ±30 degrees | |
for roll in roll_angles: | |
for pitch in pitch_angles: | |
angles.append((roll, pitch)) | |
# Try each orientation | |
for roll, pitch in angles: | |
# Create rotation matrix for this orientation | |
R = t3d.euler.euler2mat(roll, pitch, 0, 'sxyz') | |
# Normal vector for the slicing plane (rotated z-axis) | |
normal = R @ np.array([0, 0, 1]) | |
# Use the center of the model as the plane origin | |
origin = self.cad_model.bounds.mean(axis=0) | |
try: | |
# Create a cross-section with this orientation | |
slice_path = self.cad_model.section( | |
plane_origin=origin, | |
plane_normal=normal | |
) | |
if slice_path is not None: | |
# Convert the slice to points | |
points = [] | |
for entity in slice_path.entities: | |
for idx in entity.points: | |
points.append(slice_path.vertices[idx]) | |
if points: | |
# Store orientation parameters with the slice | |
orientation = (roll, pitch, origin) | |
reference_slices[orientation] = np.array(points) | |
except Exception as e: | |
print(f"Failed to extract slice at roll={np.degrees(roll):.1f}°, pitch={np.degrees(pitch):.1f}°: {e}") | |
if not reference_slices: | |
raise ValueError("No valid slices found in the CAD model") | |
return reference_slices | |
def process_lidar_scan(self, ranges, angles, imu_roll, imu_pitch, imu_yaw): | |
""" | |
Process a 2D LiDAR scan and correct it using IMU data | |
Args: | |
ranges: Array of range measurements from LiDAR | |
angles: Array of corresponding angles for each range | |
imu_roll: Roll angle from IMU in radians | |
imu_pitch: Pitch angle from IMU in radians | |
imu_yaw: Yaw angle from IMU in radians | |
Returns: | |
3D point cloud after IMU correction | |
""" | |
# Convert polar coordinates to 3D Cartesian in LiDAR frame | |
# Initially, points are in x-y plane (z=0) from LiDAR's perspective | |
x = ranges * np.cos(angles) | |
y = ranges * np.sin(angles) | |
z = np.zeros_like(x) | |
# Create 3D point cloud | |
points = np.column_stack((x, y, z)) | |
# Create rotation matrix from IMU data | |
R = t3d.euler.euler2mat(imu_roll, imu_pitch, imu_yaw, 'sxyz') | |
# Apply rotation to correct for sensor attitude | |
# Now points are in the world frame with proper orientation | |
corrected_points = np.dot(points, R.T) | |
# Return full 3D points to handle non-horizontal LiDAR | |
return corrected_points | |
def estimate_position(self, scan_points_3d, initial_guess=None, max_iterations=50): | |
""" | |
Estimate sensor position by matching raw LiDAR scan to arbitrarily sliced CAD model | |
Args: | |
scan_points_3d: 3D points from processed LiDAR scan | |
initial_guess: Initial [x, y, z, roll, pitch, yaw] guess, if available | |
max_iterations: Maximum ICP iterations | |
Returns: | |
Estimated [x, y, z, roll, pitch, yaw] position of the sensor in the CAD model | |
(position values will be in the same units as your LiDAR data) | |
""" | |
if initial_guess is None: | |
# Start with a guess at the center of the map with no rotation | |
initial_guess = [0, 0, 1.0, 0, 0, 0] | |
best_error = float('inf') | |
best_position = initial_guess | |
best_orientation = None | |
# Convert scan points to a more efficient representation for matching | |
scan_pcd = o3d.geometry.PointCloud() | |
scan_pcd.points = o3d.utility.Vector3dVector(scan_points_3d) | |
# Try matching with each reference slice at different orientations | |
for orientation, slice_points in self.reference_slices.items(): | |
roll, pitch, origin = orientation | |
# Convert to Open3D format for efficient matching | |
slice_pcd = o3d.geometry.PointCloud() | |
slice_pcd.points = o3d.utility.Vector3dVector(slice_points) | |
# Create initial pose guess incorporating this orientation | |
ori_initial_guess = initial_guess.copy() | |
ori_initial_guess[3:5] = [roll, pitch] # Use this slice's orientation | |
# Match at this orientation | |
position, error = self._match_with_slice(scan_pcd, slice_pcd, ori_initial_guess, orientation, max_iterations) | |
if error < best_error: | |
best_error = error | |
best_position = position | |
best_orientation = orientation | |
if best_orientation is None: | |
raise ValueError("Could not find a good match with any orientation") | |
best_roll, best_pitch, _ = best_orientation | |
print(f"Best match found at orientation: roll={np.degrees(best_roll):.2f}°, pitch={np.degrees(best_pitch):.2f}°") | |
return best_position, best_error | |
def _match_with_slice(self, scan_pcd, slice_pcd, initial_guess, orientation, max_iterations): | |
""" | |
Match scan points with a specific oriented slice | |
Args: | |
scan_pcd: Open3D PointCloud of the scan | |
slice_pcd: Open3D PointCloud of the slice | |
initial_guess: Initial pose guess [x, y, z, roll, pitch, yaw] | |
orientation: (roll, pitch, origin) of the slice | |
max_iterations: Maximum ICP iterations | |
Returns: | |
Estimated [x, y, z, roll, pitch, yaw] and error | |
""" | |
# Extract orientation parameters | |
slice_roll, slice_pitch, slice_origin = orientation | |
# Create initial transformation from initial guess | |
x, y, z, roll, pitch, yaw = initial_guess | |
# Combine the slice orientation with the yaw component of the initial guess | |
# This creates a transformation that aligns the scan with the slicing plane | |
R_slice = t3d.euler.euler2mat(slice_roll, slice_pitch, yaw, 'sxyz') | |
T_slice = np.eye(4) | |
T_slice[:3, :3] = R_slice | |
T_slice[:3, 3] = [x, y, z] | |
# Convert numpy transformation to Open3D format | |
init_transform = o3d.geometry.TransformationMatrix(T_slice) | |
# Run point-to-point ICP | |
result = o3d.pipelines.registration.registration_icp( | |
scan_pcd, slice_pcd, 0.2, # max_correspondence_distance | |
init_transform.get_matrix(), | |
o3d.pipelines.registration.TransformationEstimationPointToPoint(), | |
o3d.pipelines.registration.ICPConvergenceCriteria(max_iteration=max_iterations) | |
) | |
# Extract the transformation matrix | |
transformation = result.transformation | |
# Convert back to our 6-DOF representation [x, y, z, roll, pitch, yaw] | |
translation = transformation[:3, 3] | |
rotation_matrix = transformation[:3, :3] | |
# Extract Euler angles (might need refinement for gimbal lock cases) | |
angles = t3d.euler.mat2euler(rotation_matrix, 'sxyz') | |
# Final pose estimation | |
estimated_pose = [translation[0], translation[1], translation[2], angles[0], angles[1], angles[2]] | |
return estimated_pose, result.inlier_rmse | |
def visualize_result(self, scan_points_3d, estimated_position, best_orientation=None): | |
""" | |
Visualize the localization result | |
Args: | |
scan_points_3d: Original 3D scan points | |
estimated_position: Estimated [x, y, z, roll, pitch, yaw] position | |
best_orientation: The orientation parameters of the best matching slice | |
""" | |
x, y, z, roll, pitch, yaw = estimated_position | |
# Create full 3D transformation matrix from estimated position | |
R = t3d.euler.euler2mat(roll, pitch, yaw, 'sxyz') | |
transformation_3d = np.eye(4) | |
transformation_3d[:3, :3] = R | |
transformation_3d[:3, 3] = [x, y, z] | |
# Apply transformation to 3D scan points | |
scan_points_hom = np.column_stack((scan_points_3d, np.ones(len(scan_points_3d)))) | |
transformed_points_3d = np.dot(scan_points_hom, transformation_3d.T)[:, :3] | |
# Get the best matching slice for visualization | |
if best_orientation is None: | |
# Find the closest orientation in our stored slices | |
orient_diffs = [] | |
for orientation in self.reference_slices.keys(): | |
o_roll, o_pitch, _ = orientation | |
diff = np.sqrt((o_roll - roll)**2 + (o_pitch - pitch)**2) | |
orient_diffs.append((diff, orientation)) | |
_, best_orientation = min(orient_diffs) | |
best_slice = self.reference_slices[best_orientation] | |
best_roll, best_pitch, slice_origin = best_orientation | |
# Create a 3D visualization | |
# For better visualization, we can use Open3D's visualization tools | |
scan_pcd = o3d.geometry.PointCloud() | |
scan_pcd.points = o3d.utility.Vector3dVector(transformed_points_3d) | |
scan_pcd.paint_uniform_color([1, 0, 0]) # Red for scan | |
slice_pcd = o3d.geometry.PointCloud() | |
slice_pcd.points = o3d.utility.Vector3dVector(best_slice) | |
slice_pcd.paint_uniform_color([0, 0, 1]) # Blue for CAD slice | |
# Create a coordinate frame to show the sensor position | |
coordinate_frame = o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5) | |
coordinate_frame.transform(transformation_3d) | |
# Plot using matplotlib for simpler integration | |
plt.figure(figsize=(12, 10)) | |
# Create a 3D subplot | |
ax = plt.subplot(111, projection='3d') | |
# Plot the scan points and slice | |
ax.scatter(transformed_points_3d[:, 0], transformed_points_3d[:, 1], transformed_points_3d[:, 2], | |
c='red', s=1, label='Aligned LiDAR Scan') | |
ax.scatter(best_slice[:, 0], best_slice[:, 1], best_slice[:, 2], | |
c='blue', s=1, alpha=0.5, label='Best CAD Model Slice') | |
ax.scatter([x], [y], [z], c='green', s=100, marker='*', label='Estimated Sensor Position') | |
# Draw sensor orientation (principal axes) | |
axis_length = 0.5 | |
colors = ['g', 'b', 'r'] # x=green, y=blue, z=red | |
for i, color in enumerate(colors): | |
axis = np.zeros(3) | |
axis[i] = axis_length | |
rotated_axis = np.dot(R, axis) | |
ax.quiver(x, y, z, rotated_axis[0], rotated_axis[1], rotated_axis[2], | |
color=color, linewidth=2) | |
# Add a plane representing the slice orientation | |
# Create a grid of points on the best matching plane | |
plane_size = 2.0 | |
xx, yy = np.meshgrid(np.linspace(-plane_size, plane_size, 5), | |
np.linspace(-plane_size, plane_size, 5)) | |
zz = np.zeros_like(xx) | |
plane_points = np.stack([xx.flatten(), yy.flatten(), zz.flatten()]).T | |
# Rotate and position the plane | |
R_plane = t3d.euler.euler2mat(best_roll, best_pitch, 0, 'sxyz') | |
plane_points = np.dot(plane_points, R_plane.T) + slice_origin | |
# Plot the plane wireframe | |
ax.plot_wireframe(plane_points.reshape(5, 5, 3)[:,:,0], | |
plane_points.reshape(5, 5, 3)[:,:,1], | |
plane_points.reshape(5, 5, 3)[:,:,2], | |
color='cyan', alpha=0.3) | |
ax.set_xlabel('X') | |
ax.set_ylabel('Y') | |
ax.set_zlabel('Z') | |
ax.set_title(f'LiDAR Localization using Arbitrary Slice Matching\n' | |
f'Roll: {np.degrees(roll):.1f}°, Pitch: {np.degrees(pitch):.1f}°, Yaw: {np.degrees(yaw):.1f}°') | |
ax.legend() | |
plt.tight_layout() | |
plt.show() | |
# Return Open3D visualization objects so they can be viewed interactively if desired | |
return [scan_pcd, slice_pcd, coordinate_frame] | |
# Example usage | |
if __name__ == "__main__": | |
# Example data (would come from your sensors in a real application) | |
# Simulate a LiDAR scan (ranges and angles) | |
angles = np.linspace(0, 2*np.pi, 360, endpoint=False) | |
ranges = np.full_like(angles, 5.0) # 5 meter range in all directions | |
# Add some "walls" to the simulated data | |
ranges[0:60] = 3.0 # Wall at 3m from 0 to 60 degrees | |
ranges[180:240] = 2.0 # Wall at 2m from 180 to 240 degrees | |
# Add noise to make it more realistic | |
ranges += np.random.normal(0, 0.05, ranges.shape) | |
# Simulated IMU data (in radians) | |
imu_roll = 0.05 # Small roll | |
imu_pitch = 0.02 # Small pitch | |
imu_yaw = np.pi/6 # 30 degrees yaw | |
# Path to your CAD model (replace with your actual model) | |
cad_model_path = "room_model.stl" | |
# Set the scale factor based on your CAD model units | |
# Examples: | |
# - If CAD is in millimeters and real world is in meters: scale_factor = 0.001 | |
# - If CAD is in inches and real world is in meters: scale_factor = 0.0254 | |
# - If CAD is in feet and real world is in meters: scale_factor = 0.3048 | |
scale_factor = 1.0 # Adjust this to your CAD model's scale | |
# For demonstration purposes, if you don't have a CAD model, | |
# uncomment this code to create a simple model with walls and features: | |
""" | |
import trimesh | |
# Create a more complex room with walls at different heights and features | |
room = trimesh.Trimesh() | |
# Outer walls (full height) | |
walls = [] | |
# Floor | |
floor = trimesh.creation.box(extents=[10, 8, 0.1], transform=trimesh.transformations.translation_matrix([0, 0, -0.05])) | |
# Ceiling | |
ceiling = trimesh.creation.box(extents=[10, 8, 0.1], transform=trimesh.transformations.translation_matrix([0, 0, 2.95])) | |
# Front wall | |
front_wall = trimesh.creation.box(extents=[10, 0.2, 3], transform=trimesh.transformations.translation_matrix([0, -4, 1.5])) | |
# Back wall | |
back_wall = trimesh.creation.box(extents=[10, 0.2, 3], transform=trimesh.transformations.translation_matrix([0, 4, 1.5])) | |
# Left wall | |
left_wall = trimesh.creation.box(extents=[0.2, 8, 3], transform=trimesh.transformations.translation_matrix([-5, 0, 1.5])) | |
# Right wall | |
right_wall = trimesh.creation.box(extents=[0.2, 8, 3], transform=trimesh.transformations.translation_matrix([5, 0, 1.5])) | |
# Add a partial height divider in the middle | |
divider = trimesh.creation.box(extents=[5, 0.2, 1.5], transform=trimesh.transformations.translation_matrix([0, 1, 0.75])) | |
# Add a column for feature recognition | |
column = trimesh.creation.cylinder(radius=0.3, height=3, transform=trimesh.transformations.translation_matrix([3, -2, 1.5])) | |
# Add furniture-like objects | |
table = trimesh.creation.box(extents=[1.5, 0.8, 0.75], transform=trimesh.transformations.translation_matrix([-2, -2, 0.375])) | |
# Combine all elements | |
room = trimesh.util.concatenate([floor, ceiling, front_wall, back_wall, left_wall, right_wall, divider, column, table]) | |
# Export the model | |
room.export('room_model.stl') | |
cad_model_path = 'room_model.stl' | |
""" | |
try: | |
# Initialize the localizer with appropriate scale factor | |
localizer = SensorLocalizer(cad_model_path, scale_factor=scale_factor) | |
# Process the LiDAR scan with IMU correction | |
scan_points_3d = localizer.process_lidar_scan(ranges, angles, imu_roll, imu_pitch, imu_yaw) | |
# Estimate position with 6DOF | |
initial_guess = [0.5, 0.5, 1.0, imu_roll, imu_pitch, imu_yaw] # Starting guess: x, y, z, roll, pitch, yaw | |
estimated_position, error = localizer.estimate_position(scan_points_3d, initial_guess) | |
# Extract position components for readability | |
x, y, z, roll, pitch, yaw = estimated_position | |
print(f"Estimated sensor position: x={x:.2f}m, y={y:.2f}m, z={z:.2f}m") | |
print(f"Estimated sensor orientation: roll={np.degrees(roll):.2f}°, pitch={np.degrees(pitch):.2f}°, yaw={np.degrees(yaw):.2f}°") | |
print(f"Average error: {error:.3f}m") | |
# Visualize the result | |
localizer.visualize_result(scan_points_3d, estimated_position, best_height=z) | |
except Exception as e: | |
print(f"Error: {e}") | |
print("If you don't have a CAD model available, use the code in the comments to create a simple one.") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment