RAP / dataset_process /utils /dataset_utils.py
YuePanEdward's picture
Squash history: release superseded example-data blobs
be88765
Raw History Blame Contribute Delete
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)