Spaces:
Running on Zero
Running on Zero
Download dataset_process/utils/dataset_utils.py from YuePanEdward/RAP: direct link, hf CLI and curl.
- Browser
- Download file 33.2 kB
-
https://huggingface.co/spaces/YuePanEdward/RAP/resolve/main/dataset_process/utils/dataset_utils.py
- Command line
-
hf download hf://spaces/YuePanEdward/RAP/dataset_process/utils/dataset_utils.py
-
curl -L -o dataset_utils.py https://huggingface.co/spaces/YuePanEdward/RAP/resolve/main/dataset_process/utils/dataset_utils.py
33.2 kB
| """ | |
| General utility functions for dataset processing. | |
| This module contains common functions that can be used across different dataset loaders, | |
| such as point cloud processing, normal estimation, and downsampling. | |
| """ | |
| import numpy as np | |
| import open3d as o3d | |
| from typing import Optional, Tuple, Union, List | |
| import logging | |
| import torch | |
| import os | |
| # Import necessary modules from other utils files | |
| from .io_utils import get_dataset_name | |
| logger = logging.getLogger(__name__) | |
| def random_downsample_points(points: np.ndarray, max_points: int, seed: Optional[int] = None) -> np.ndarray: | |
| """ | |
| Randomly downsample a point cloud to a maximum number of points. | |
| Args: | |
| points: Input point cloud (N, 3) or (N, 4) with intensity | |
| max_points: Maximum number of points to keep | |
| seed: Random seed for reproducibility (optional) | |
| Returns: | |
| Downsampled point cloud | |
| """ | |
| if len(points) <= max_points: | |
| return points | |
| if seed is not None: | |
| np.random.seed(seed) | |
| indices = np.random.choice(len(points), max_points, replace=False) | |
| downsampled_points = points[indices] | |
| # logger.debug(f"Randomly downsampled {len(points)} to {len(downsampled_points)} points") | |
| return downsampled_points | |
| def farthest_point_sampling(points: np.ndarray, num_samples: int, seed: Optional[int] = None) -> Tuple[np.ndarray, np.ndarray]: | |
| """ | |
| Perform farthest point sampling (FPS) on a point cloud using PyTorch3D when available. | |
| Note: this is still super slow when the input point cloud is too large. | |
| Args: | |
| points: Input point cloud (N, 3) | |
| num_samples: Number of points to sample | |
| seed: Random seed for reproducibility (optional) | |
| Returns: | |
| Tuple of (sampled_points, sampled_indices) | |
| - sampled_points: The sampled points (num_samples, 3) | |
| - sampled_indices: The indices of sampled points in original array (num_samples,) | |
| """ | |
| if len(points) == 0: | |
| return np.array([]), np.array([], dtype=int) | |
| if num_samples >= len(points): | |
| return points.copy(), np.arange(len(points)) | |
| if seed is not None: | |
| np.random.seed(seed) | |
| if PYTORCH3D_AVAILABLE: | |
| torch.manual_seed(seed) | |
| if PYTORCH3D_AVAILABLE: | |
| # Use PyTorch3D's efficient implementation | |
| try: | |
| # Determine device (use GPU if available for large point clouds) | |
| device = torch.device('cuda' if torch.cuda.is_available() and len(points) > 10000 else 'cpu') | |
| # Convert to torch tensor | |
| points_tensor = torch.from_numpy(points).float().unsqueeze(0).to(device) # Add batch dimension | |
| # Sample farthest points | |
| sampled_points_tensor, sampled_indices_tensor = sample_farthest_points( | |
| points_tensor, K=num_samples, random_start_point=True | |
| ) | |
| # Convert back to numpy | |
| sampled_points = sampled_points_tensor.squeeze(0).cpu().numpy() # Remove batch dimension and move to CPU | |
| sampled_indices = sampled_indices_tensor.squeeze(0).cpu().numpy().astype(int) | |
| device_str = f" on {device}" if device.type == 'cuda' else "" | |
| logger.info(f"FPS (PyTorch3D{device_str}) downsampled {len(points)} to {len(sampled_points)} points") | |
| return sampled_points, sampled_indices | |
| except Exception as e: | |
| logger.warning(f"PyTorch3D FPS failed ({e}), falling back to NumPy implementation") | |
| # Fall through to NumPy implementation | |
| # Fallback NumPy implementation | |
| logger.debug("Using NumPy FPS implementation (PyTorch3D not available)") | |
| num_points = len(points) | |
| sampled_indices = [] | |
| distances = np.full(num_points, np.inf) | |
| # Start with a random point | |
| current_idx = np.random.randint(0, num_points) | |
| sampled_indices.append(current_idx) | |
| for _ in range(1, num_samples): | |
| # Update distances to the current point | |
| current_point = points[current_idx] | |
| current_distances = np.linalg.norm(points - current_point, axis=1) | |
| distances = np.minimum(distances, current_distances) | |
| # Select the farthest point | |
| current_idx = np.argmax(distances) | |
| sampled_indices.append(current_idx) | |
| sampled_indices = np.array(sampled_indices) | |
| sampled_points = points[sampled_indices] | |
| logger.debug(f"FPS (NumPy) downsampled {len(points)} to {len(sampled_points)} points") | |
| return sampled_points, sampled_indices | |
| def farthest_point_sampling_with_normals(points: np.ndarray, | |
| normals: Optional[np.ndarray] = None, | |
| num_samples: int = 1000, | |
| seed: Optional[int] = None) -> Tuple[np.ndarray, Optional[np.ndarray]]: | |
| """ | |
| Perform farthest point sampling on a point cloud with optional normals. | |
| Args: | |
| points: Input point cloud (N, 3) | |
| normals: Optional normal vectors (N, 3) | |
| num_samples: Number of points to sample | |
| seed: Random seed for reproducibility (optional) | |
| Returns: | |
| Tuple of (sampled_points, sampled_normals) | |
| - sampled_points: The sampled points (num_samples, 3) | |
| - sampled_normals: The sampled normals (num_samples, 3) or None if normals is None | |
| """ | |
| sampled_points, sampled_indices = farthest_point_sampling(points, num_samples, seed) | |
| if normals is not None and len(normals) > 0: | |
| sampled_normals = normals[sampled_indices] | |
| return sampled_points, sampled_normals | |
| else: | |
| return sampled_points, None | |
| def downsample_points(points: np.ndarray, | |
| normals: Optional[np.ndarray] = None, | |
| method: str = "voxel", | |
| voxel_size: float = 0.1, | |
| num_points: Optional[int] = None, | |
| seed: Optional[int] = None, | |
| use_torch: bool = True) -> Tuple[np.ndarray, Optional[np.ndarray]]: | |
| """ | |
| Unified function for downsampling point clouds using different methods. | |
| Args: | |
| points: Input point cloud (N, 3) | |
| normals: Optional normal vectors (N, 3) | |
| method: Downsampling method ("voxel", "fps", or "random") | |
| voxel_size: Size of voxels for voxel downsampling | |
| num_points: Target number of points (required for FPS and random methods) | |
| seed: Random seed for reproducibility (optional) | |
| use_torch: If True and method is "voxel", use torch-based voxel downsampling for speedup (default: False) | |
| Returns: | |
| Tuple of (downsampled_points, downsampled_normals) | |
| - downsampled_points: The downsampled points | |
| - downsampled_normals: The downsampled normals (or None if normals is None) | |
| """ | |
| if len(points) == 0: | |
| return points, normals | |
| if method == "voxel": | |
| if use_torch: | |
| # Use torch-based voxel downsampling for speedup | |
| device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') | |
| points_tensor = torch.from_numpy(points).float().to(device) | |
| # Get downsampled indices using torch | |
| downsampled_indices_tensor = voxel_down_sample_torch(points_tensor, voxel_size) | |
| downsampled_indices = downsampled_indices_tensor.cpu().numpy() | |
| # Apply indices to points and normals | |
| downsampled_points = points[downsampled_indices] | |
| downsampled_normals = normals[downsampled_indices] if normals is not None else None | |
| else: | |
| # Use numpy-based voxel downsampling (original implementation) | |
| downsampled_indices = _get_voxel_downsample_indices(points, voxel_size) | |
| downsampled_points = points[downsampled_indices] | |
| downsampled_normals = normals[downsampled_indices] if normals is not None else None | |
| elif method == "fps": | |
| if num_points is None: | |
| raise ValueError("num_points must be specified for FPS downsampling") | |
| downsampled_points, downsampled_normals = farthest_point_sampling_with_normals( | |
| points, normals, num_points, seed | |
| ) | |
| elif method == "random": | |
| if num_points is None: | |
| raise ValueError("num_points must be specified for random downsampling") | |
| if len(points) <= num_points: | |
| downsampled_points = points.copy() | |
| downsampled_normals = normals.copy() if normals is not None else None | |
| else: | |
| if seed is not None: | |
| np.random.seed(seed) | |
| indices = np.random.choice(len(points), num_points, replace=False) | |
| downsampled_points = points[indices] | |
| downsampled_normals = normals[indices] if normals is not None else None | |
| else: | |
| raise ValueError(f"Unknown downsampling method: {method}. Choose from 'voxel', 'fps', or 'random'") | |
| method_str = f"{method} method" | |
| if method == "voxel" and use_torch: | |
| method_str = f"{method} method (torch-based)" | |
| logger.debug(f"Downsampled {len(points)} to {len(downsampled_points)} points using {method_str}") | |
| return downsampled_points, downsampled_normals | |
| def _get_voxel_downsample_indices(points: np.ndarray, voxel_size: float) -> np.ndarray: | |
| """ | |
| Get indices of points after voxel downsampling. | |
| Args: | |
| points: Input point cloud (N, 3) | |
| voxel_size: Size of voxels for downsampling | |
| Returns: | |
| Indices of downsampled points | |
| """ | |
| if len(points) == 0: | |
| return np.array([], dtype=int) | |
| # Convert to voxel coordinates | |
| voxel_coords = np.floor(points / voxel_size).astype(int) | |
| # Create unique voxel keys and track which points belong to each voxel | |
| voxel_to_points = {} | |
| for i, voxel_coord in enumerate(voxel_coords): | |
| voxel_key = tuple(voxel_coord) | |
| if voxel_key not in voxel_to_points: | |
| voxel_to_points[voxel_key] = [] | |
| voxel_to_points[voxel_key].append(i) | |
| # For each voxel, find the point closest to the voxel center | |
| downsampled_indices = [] | |
| for voxel_key, point_indices in voxel_to_points.items(): | |
| if len(point_indices) == 1: | |
| # Only one point in this voxel, keep it | |
| downsampled_indices.append(point_indices[0]) | |
| else: | |
| # Multiple points in this voxel, find the one closest to voxel center | |
| voxel_center = (np.array(voxel_key) + 0.5) * voxel_size | |
| voxel_points = points[point_indices] | |
| # Calculate distances to voxel center | |
| distances = np.linalg.norm(voxel_points - voxel_center, axis=1) | |
| # Find the point with minimum distance | |
| closest_idx = np.argmin(distances) | |
| downsampled_indices.append(point_indices[closest_idx]) | |
| return np.array(downsampled_indices) | |
| def voxel_down_sample_torch(points: torch.tensor, voxel_size: float): | |
| """ | |
| voxel based downsampling. Returns the indices of the points which are closest to the voxel centers. | |
| Args: | |
| points (torch.Tensor): [N,3] point coordinates | |
| voxel_size (float): grid resolution | |
| Returns: | |
| indices (torch.Tensor): [M] indices of the original point cloud, downsampled point cloud would be `points[indices]` | |
| Reference: Louis Wiesmann | |
| """ | |
| _quantization = 1000 # if change to 1, then it would take the first (smallest) index lie in the voxel | |
| offset = torch.floor(points.min(dim=0)[0] / voxel_size).long() | |
| grid = torch.floor(points / voxel_size) | |
| center = (grid + 0.5) * voxel_size | |
| dist = ((points - center) ** 2).sum(dim=1) ** 0.5 | |
| dist = ( | |
| dist / dist.max() * (_quantization - 1) | |
| ).long() # for speed up # [0-_quantization] | |
| grid = grid.long() - offset | |
| v_size = grid.max().ceil() | |
| grid_idx = grid[:, 0] + grid[:, 1] * v_size + grid[:, 2] * v_size * v_size | |
| unique, inverse = torch.unique(grid_idx, return_inverse=True) | |
| idx_d = torch.arange(inverse.size(0), dtype=inverse.dtype, device=inverse.device) | |
| offset = 10 ** len(str(idx_d.max().item())) | |
| idx_d = idx_d + dist.long() * offset | |
| idx = torch.empty( | |
| unique.shape, dtype=inverse.dtype, device=inverse.device | |
| ).scatter_reduce_( | |
| dim=0, index=inverse, src=idx_d, reduce="amin", include_self=False | |
| ) | |
| # https://pytorch.org/docs/stable/generated/torch.Tensor.scatter_reduce_.html | |
| # This operation may behave nondeterministically when given tensors on | |
| # a CUDA device. consider to change a more stable implementation | |
| idx = idx % offset | |
| return idx | |
| def estimate_point_normals_open3d(points: np.ndarray, k_neighbors: int = 20, radius: float = 0.5) -> np.ndarray: | |
| """ | |
| Estimate point-wise normals using Open3D. | |
| Args: | |
| points: Input point cloud (N, 3) | |
| k_neighbors: Number of neighbors for normal estimation (default: 20) | |
| radius: Search radius for normal estimation (default: 0.5m) | |
| Returns: | |
| Normal vectors (N, 3) with unit length | |
| """ | |
| if len(points) == 0: | |
| return np.array([]) | |
| # Create Open3D point cloud | |
| pcd = o3d.geometry.PointCloud() | |
| pcd.points = o3d.utility.Vector3dVector(points) | |
| # Estimate normals | |
| # Use both k-neighbors and radius for robust estimation | |
| pcd.estimate_normals( | |
| search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=k_neighbors) | |
| ) | |
| # Orient normals consistently (optional but recommended) | |
| pcd.orient_normals_consistent_tangent_plane(k=k_neighbors) | |
| # Get normals as numpy array | |
| normals = np.asarray(pcd.normals) | |
| logger.debug(f"Estimated normals for {len(points)} points") | |
| return normals | |
| def transform_points(points: np.ndarray, transform: np.ndarray) -> np.ndarray: | |
| """ | |
| Transform points using 4x4 transformation matrix. | |
| Args: | |
| points: Input point cloud (N, 3) | |
| transform: 4x4 transformation matrix | |
| Returns: | |
| Transformed point cloud (N, 3) | |
| """ | |
| # Convert to homogeneous coordinates | |
| points_homo = np.hstack([points, np.ones((points.shape[0], 1))]) | |
| # Apply transformation | |
| transformed_points = (transform @ points_homo.T).T | |
| return transformed_points[:, :3] | |
| def transform_normals(normals: np.ndarray, transform: np.ndarray) -> np.ndarray: | |
| """ | |
| Transform normals using 4x4 transformation matrix. | |
| Normals are transformed using the rotation part of the transformation matrix. | |
| Args: | |
| normals: Normal vectors (N, 3) | |
| transform: 4x4 transformation matrix | |
| Returns: | |
| Transformed normal vectors (N, 3) | |
| """ | |
| if len(normals) == 0: | |
| return normals | |
| # Extract rotation matrix (3x3 upper-left part) | |
| rotation_matrix = transform[:3, :3] | |
| # Transform normals using rotation matrix only | |
| # Note: We don't add homogeneous coordinate for normals | |
| transformed_normals = (rotation_matrix @ normals.T).T | |
| # Normalize the transformed normals | |
| norms = np.linalg.norm(transformed_normals, axis=1, keepdims=True) | |
| # Avoid division by zero | |
| norms = np.where(norms < 1e-8, 1.0, norms) | |
| transformed_normals = transformed_normals / norms | |
| return transformed_normals | |
| def voxel_downsample_points(points: np.ndarray, voxel_size: float = 0.1) -> np.ndarray: | |
| """ | |
| Downsample points using voxel grid, keeping the actual points closest to voxel centers. | |
| Args: | |
| points: Input point cloud (N, 3) | |
| voxel_size: Size of voxels for downsampling | |
| Returns: | |
| Downsampled point cloud with actual points (not voxel centers) | |
| Note: | |
| This function is kept for backward compatibility. Consider using downsample_points() for more options. | |
| """ | |
| downsampled_points, _ = downsample_points(points, method="voxel", voxel_size=voxel_size) | |
| logger.debug(f"Voxel downsampled {len(points)} to {len(downsampled_points)} points") | |
| return downsampled_points | |
| def voxel_downsample_points_open3d(points: np.ndarray, voxel_size: float = 0.1) -> np.ndarray: | |
| """ | |
| Downsample points using Open3D's voxel downsampling (returns voxel centers). | |
| Args: | |
| points: Input point cloud (N, 3) | |
| voxel_size: Size of voxels for downsampling | |
| Returns: | |
| Downsampled point cloud with voxel center points | |
| """ | |
| pcd = o3d.geometry.PointCloud() | |
| pcd.points = o3d.utility.Vector3dVector(points) | |
| # Apply voxel downsampling (returns voxel centers) | |
| downsampled_pcd = pcd.voxel_down_sample(voxel_size=voxel_size) | |
| return np.asarray(downsampled_pcd.points) | |
| def get_pose_center(pose: np.ndarray) -> np.ndarray: | |
| """ | |
| Extract the center position from a 4x4 pose matrix. | |
| Args: | |
| pose: 4x4 pose matrix | |
| Returns: | |
| Center position (3,) as numpy array | |
| """ | |
| return pose[:3, 3] | |
| def filter_keyframes_by_motion(poses: List[np.ndarray], | |
| translation_threshold: float = 0.5, | |
| rotation_threshold_degrees: float = 5.0, | |
| min_frame_interval: int = 1) -> List[int]: | |
| """ | |
| Filter poses to extract keyframes based on translation and rotation thresholds. | |
| This function identifies keyframes by comparing the motion between consecutive poses. | |
| A frame is selected as a keyframe if either: | |
| 1. The translation distance from the last keyframe exceeds translation_threshold, OR | |
| 2. The rotation angle from the last keyframe exceeds rotation_threshold_degrees | |
| Args: | |
| poses: List of 4x4 pose matrices | |
| translation_threshold: Minimum translation distance in meters to trigger keyframe selection (default: 0.5m) | |
| rotation_threshold_degrees: Minimum rotation angle in degrees to trigger keyframe selection (default: 5.0°) | |
| min_frame_interval: Minimum number of frames between keyframes (default: 1, meaning consecutive frames allowed) | |
| Returns: | |
| List of keyframe indices (0-based) | |
| """ | |
| if poses is None or len(poses) == 0: | |
| return [] | |
| if len(poses) == 1: | |
| return [0] | |
| # Convert rotation threshold from degrees to radians | |
| rotation_threshold_rad = np.radians(rotation_threshold_degrees) | |
| keyframe_indices = [0] # Always include the first frame | |
| last_keyframe_pose = poses[0] | |
| last_keyframe_idx = 0 | |
| for i in range(1, len(poses)): | |
| current_pose = poses[i] | |
| # Check minimum frame interval | |
| if i - last_keyframe_idx < min_frame_interval: | |
| continue | |
| # Calculate translation distance | |
| translation_distance = np.linalg.norm( | |
| get_pose_center(current_pose) - get_pose_center(last_keyframe_pose) | |
| ) | |
| # Calculate rotation angle difference | |
| rotation_angle_rad = calculate_rotation_angle_between_poses(current_pose, last_keyframe_pose) | |
| # Check if either threshold is exceeded | |
| if (translation_distance >= translation_threshold or | |
| rotation_angle_rad >= rotation_threshold_rad): | |
| keyframe_indices.append(i) | |
| last_keyframe_pose = current_pose | |
| last_keyframe_idx = i | |
| # Always include the last frame if it's not already included | |
| if keyframe_indices[-1] != len(poses) - 1: | |
| keyframe_indices.append(len(poses) - 1) | |
| logger.info(f"Selected {len(keyframe_indices)} keyframes from {len(poses)} total frames " | |
| f"(translation_threshold: {translation_threshold}m, " | |
| f"rotation_threshold: {rotation_threshold_degrees:.1f}°)") | |
| return keyframe_indices | |
| def calculate_rotation_angle_between_poses(pose1: np.ndarray, pose2: np.ndarray) -> float: | |
| """ | |
| Calculate the rotation angle between two 4x4 pose matrices. | |
| Args: | |
| pose1: First 4x4 pose matrix | |
| pose2: Second 4x4 pose matrix | |
| Returns: | |
| Rotation angle in radians (0 to π) | |
| """ | |
| # Extract rotation matrices | |
| R1 = pose1[:3, :3] | |
| R2 = pose2[:3, :3] | |
| # Calculate relative rotation | |
| R_rel = R1.T @ R2 | |
| # Calculate rotation angle using trace formula | |
| # For rotation matrix R, the angle θ satisfies: trace(R) = 1 + 2*cos(θ) | |
| trace = np.trace(R_rel) | |
| # Clamp trace to valid range [-1, 3] to handle numerical errors | |
| trace = np.clip(trace, -1.0, 3.0) | |
| # Calculate angle: cos(θ) = (trace - 1) / 2 | |
| cos_angle = (trace - 1.0) / 2.0 | |
| cos_angle = np.clip(cos_angle, -1.0, 1.0) # Ensure valid range for arccos | |
| angle = np.arccos(cos_angle) | |
| return angle | |
| def filter_poses_and_data_by_keyframes(poses: List[np.ndarray], | |
| data_list: Optional[List] = None, | |
| translation_threshold: float = 0.5, | |
| rotation_threshold_degrees: float = 5.0, | |
| min_frame_interval: int = 1) -> Tuple[List[np.ndarray], List[int], Optional[List]]: | |
| """ | |
| Filter poses and associated data by keyframes based on motion thresholds. | |
| This is a convenience function that combines keyframe filtering with data filtering. | |
| Args: | |
| poses: List of 4x4 pose matrices | |
| data_list: Optional list of data (e.g., point clouds, timestamps) to filter alongside poses | |
| translation_threshold: Minimum translation distance in meters to trigger keyframe selection | |
| rotation_threshold_degrees: Minimum rotation angle in degrees to trigger keyframe selection | |
| min_frame_interval: Minimum number of frames between keyframes | |
| Returns: | |
| Tuple of (filtered_poses, keyframe_indices, filtered_data_list) | |
| - filtered_poses: List of keyframe poses | |
| - keyframe_indices: List of original indices of selected keyframes | |
| - filtered_data_list: Filtered data list (None if data_list was None) | |
| """ | |
| keyframe_indices = filter_keyframes_by_motion( | |
| poses, translation_threshold, rotation_threshold_degrees, min_frame_interval | |
| ) | |
| # Filter poses | |
| filtered_poses = [poses[i] for i in keyframe_indices] | |
| # Filter data if provided | |
| filtered_data_list = None | |
| if data_list is not None: | |
| if len(data_list) != len(poses): | |
| logger.warning(f"Data list length ({len(data_list)}) doesn't match poses length ({len(poses)})") | |
| else: | |
| filtered_data_list = [data_list[i] for i in keyframe_indices] | |
| return filtered_poses, keyframe_indices, filtered_data_list | |
| def calculate_point_cloud_overlap_ratio_fast(points1: np.ndarray, | |
| points2: np.ndarray, | |
| voxel_size: float = 2.0, | |
| max_points_per_cloud: int = 20000) -> float: | |
| """ | |
| Fast approximate overlap calculation using downsampling and voxelization. | |
| Args: | |
| points1: First point cloud (N, 3) | |
| points2: Second point cloud (M, 3) | |
| voxel_size: Size of voxels for discretization | |
| max_points_per_cloud: Maximum points to use per cloud for speed | |
| Returns: | |
| Overlap ratio (0.0 to 1.0) | |
| """ | |
| if len(points1) == 0 or len(points2) == 0: | |
| return 0.0 | |
| # Downsample if too many points | |
| if len(points1) > max_points_per_cloud: | |
| indices = np.random.choice(len(points1), max_points_per_cloud, replace=False) | |
| points1 = points1[indices] | |
| if len(points2) > max_points_per_cloud: | |
| indices = np.random.choice(len(points2), max_points_per_cloud, replace=False) | |
| points2 = points2[indices] | |
| # Voxelize both point clouds | |
| def voxelize_points(points): | |
| # Convert to voxel coordinates | |
| voxel_coords = np.floor(points / voxel_size).astype(int) | |
| # Create unique voxel keys | |
| voxel_keys = set(map(tuple, voxel_coords)) | |
| return voxel_keys | |
| voxels1 = voxelize_points(points1) | |
| voxels2 = voxelize_points(points2) | |
| # Calculate intersection and union | |
| intersection = len(voxels1.intersection(voxels2)) | |
| union = len(voxels1.union(voxels2)) | |
| if union == 0: | |
| return 0.0 | |
| overlap_ratio = intersection / union | |
| return overlap_ratio | |
| def sample_truncated_gaussian(min_val: int, max_val: int, mean: float, std: float, max_attempts: int = 100) -> int: | |
| """ | |
| Sample from a truncated Gaussian distribution bounded by min_val and max_val. | |
| Args: | |
| min_val: Minimum allowed value | |
| max_val: Maximum allowed value | |
| mean: Mean of the Gaussian distribution | |
| std: Standard deviation of the Gaussian distribution | |
| max_attempts: Maximum attempts to find a valid sample | |
| Returns: | |
| Sampled integer value within [min_val, max_val] | |
| """ | |
| for _ in range(max_attempts): | |
| # Sample from normal distribution | |
| sample = np.random.normal(mean, std) | |
| # Round to nearest integer | |
| sample_int = int(round(sample)) | |
| # Check if within bounds | |
| if min_val <= sample_int <= max_val: | |
| return sample_int | |
| # If we can't find a valid sample, fall back to uniform distribution | |
| logger.warning(f"Could not sample from truncated Gaussian with mean={mean}, std={std}, bounds=[{min_val}, {max_val}]. Using uniform distribution.") | |
| import random | |
| return random.randint(min_val, max_val) | |
| def deskewing( | |
| points: np.ndarray, | |
| ts: np.ndarray, | |
| pose: np.ndarray, | |
| ts_mid_pose: float = 0.5 | |
| ) -> np.ndarray: | |
| """ | |
| Deskew a batch of points at timestamp ts by a relative transformation matrix. | |
| PyTorch implementation using roma library for faster SLERP. | |
| Args: | |
| points: Point cloud with shape (N, 3) or (N, 4) if including intensity | |
| ts: Timestamps for each point, shape (N,) with values in range [0, 1] | |
| pose: Transformation matrix from current to last frame, shape (4, 4) | |
| ts_mid_pose: Timestamp corresponding to the pose (default: 0.5) | |
| Returns: | |
| Deskewed point cloud with same shape as input points | |
| """ | |
| if ts is None: | |
| return points # no deskewing | |
| try: | |
| import torch | |
| import roma | |
| except ImportError: | |
| raise ImportError("PyTorch and roma are required for fast deskewing. Install with: pip install torch roma") | |
| # Convert inputs to torch tensors | |
| points_torch = torch.from_numpy(points).float() | |
| ts_torch = torch.from_numpy(ts).float() | |
| pose_torch = torch.from_numpy(pose).float() | |
| # Ensure ts is 1D | |
| ts_torch = ts_torch.squeeze(-1) | |
| # Normalize the tensor to the range [0, 1] | |
| # NOTE: you need to figure out the begin and end of a frame because | |
| # sometimes there's only partial measurements, some part are blocked by some occlusions | |
| min_ts = torch.min(ts_torch) | |
| max_ts = torch.max(ts_torch) | |
| # Avoid division by zero | |
| if max_ts - min_ts > 1e-8: | |
| ts_torch = (ts_torch - min_ts) / (max_ts - min_ts) | |
| else: | |
| # If all timestamps are the same, set them to 0.5 | |
| ts_torch = torch.full_like(ts_torch, 0.5) | |
| # this is related to: https://github.com/PRBonn/kiss-icp/issues/299 | |
| ts_torch -= ts_mid_pose | |
| # Use roma for fast SLERP | |
| rotmat_slerp = roma.rotmat_slerp( | |
| torch.eye(3).to(points_torch), pose_torch[:3, :3].to(points_torch), ts_torch | |
| ) | |
| tran_lerp = ts_torch[:, None] * pose_torch[:3, 3].to(points_torch) | |
| points_deskewed_torch = points_torch.clone() | |
| points_deskewed_torch[:, :3] = (rotmat_slerp @ points_torch[:, :3].unsqueeze(-1)).squeeze(-1) + tran_lerp | |
| # Convert back to numpy | |
| points_deskewed = points_deskewed_torch.cpu().numpy() | |
| return points_deskewed | |
| def get_global_transformation_matrix(sequence_name: str) -> np.ndarray: | |
| """Get global transformation matrix based on sequence name.""" | |
| if sequence_name.startswith('7-scenes') or \ | |
| sequence_name.startswith('bundlefusion') or \ | |
| sequence_name.startswith('rgbd-scenes') or \ | |
| sequence_name.startswith('sun3d'): # for the test set | |
| # Transformation for 7-scenes, bundlefusion, rgbd-scenes | |
| return np.array([[0, 0, 1], | |
| [-1, 0, 0], | |
| [0, -1, 0]], dtype=np.float32) | |
| # elif sequence_name.startswith('sun3d'): | |
| # # Transformation for sun3d | |
| # return np.array([[0, 0, -1], | |
| # [1, 0, 0], | |
| # [0, 1, 0]], dtype=np.float32) | |
| elif sequence_name.startswith('analysis-by-synthesis'): | |
| # Identity transformation for analysis-by-synthesis | |
| return np.eye(3, dtype=np.float32) | |
| else: | |
| # Default to identity for other sequences | |
| return np.eye(3, dtype=np.float32) | |
| def save_num_points_to_folder(output_dir: str, | |
| dataset_name: str, | |
| sample_num_points: List[int], | |
| sample_dirs: List[Tuple[str, str]]): | |
| """Save num_points data to folder structure following precompute_num_points.py format.""" | |
| num_points_dir = os.path.join(output_dir, "num_points") | |
| os.makedirs(num_points_dir, exist_ok=True) | |
| # For folder structure, we need to map samples to train/val splits | |
| # We'll try to read the data_split files to determine which samples belong to which split | |
| data_split_dir = os.path.join(output_dir, 'data_split') | |
| # Create sample path to num_points mapping | |
| sample_to_num_points = {} | |
| all_num_points = [] | |
| def normalize_path(path: str) -> str: | |
| """Normalize path by removing dataset prefix and _processed suffix for matching.""" | |
| # Remove dataset name prefix if present | |
| if path.startswith(f"{dataset_name}/"): | |
| path = path[len(f"{dataset_name}/"):] | |
| # Remove _processed suffix if present | |
| if path.endswith("_processed"): | |
| path = path[:-len("_processed")] | |
| return path | |
| for i, (relative_path, _) in enumerate(sample_dirs): | |
| num_points = sample_num_points[i] if i < len(sample_num_points) else 0 | |
| normalized_path = normalize_path(relative_path) | |
| sample_to_num_points[normalized_path] = num_points | |
| all_num_points.append(num_points) | |
| # Create split-specific num_points lists | |
| split_num_points = {} | |
| # Read all split files and create corresponding num_points lists | |
| if os.path.exists(data_split_dir): | |
| for split_file_name in os.listdir(data_split_dir): | |
| if split_file_name.endswith('.txt'): | |
| split_name = split_file_name.replace('.txt', '') | |
| split_file_path = os.path.join(data_split_dir, split_file_name) | |
| with open(split_file_path, 'r') as f: | |
| samples = [line.strip() for line in f if line.strip()] | |
| # Create num_points list for this split | |
| split_points = [] | |
| for sample in samples: | |
| normalized_sample = normalize_path(sample) | |
| if normalized_sample in sample_to_num_points: | |
| split_points.append(sample_to_num_points[normalized_sample]) | |
| else: | |
| logger.warning(f"Sample {sample} (normalized: {normalized_sample}) not found in processed data for {split_name} split") | |
| split_points.append(0) | |
| if split_points: | |
| split_num_points[split_name] = split_points | |
| # Always include 'all' split | |
| split_num_points['all'] = all_num_points | |
| # Save all split files | |
| for split_name, num_points_data in split_num_points.items(): | |
| if num_points_data: | |
| split_file = os.path.join(num_points_dir, f'{split_name}.txt') | |
| with open(split_file, 'w') as f: | |
| for num_point in num_points_data: | |
| f.write(f"{num_point}\n") | |
| logger.info(f"Saved num_points for {split_name} split: {split_file} ({len(num_points_data)} samples)") | |
| logger.debug(f"{split_name} num_points stats: min={min(num_points_data)}, max={max(num_points_data)}, mean={np.mean(num_points_data):.1f}") | |
| def save_points_to_ply(points: np.ndarray, ply_path: str, normals: Optional[np.ndarray] = None) -> None: | |
| """Save points to a PLY file using Open3D.""" | |
| if o3d is None: | |
| raise ImportError("open3d is required. Install with: pip install open3d") | |
| pcd = o3d.geometry.PointCloud() | |
| pcd.points = o3d.utility.Vector3dVector(points.astype(np.float64, copy=False)) | |
| if normals is not None: | |
| pcd.normals = o3d.utility.Vector3dVector(normals.astype(np.float64, copy=False)) | |
| os.makedirs(os.path.dirname(ply_path), exist_ok=True) | |
| o3d.io.write_point_cloud(ply_path, pcd, write_ascii=False) | |