RAP / dataset_process /utils /point_sampling_utils.py
YuePanEdward's picture
Squash history: release superseded example-data blobs
be88765
Raw History Blame Contribute Delete
13.8 kB
import os
import numpy as np
import logging
from typing import List, Tuple, Optional, Union
import torch # For torch.manual_seed in _allocate_fps_points
from pytorch3d.ops import sample_farthest_points
import open3d as o3d
logger = logging.getLogger(__name__)
def calculate_voxel_coverage(points: np.ndarray, voxel_size: float) -> int:
"""
Calculate the number of unique voxels occupied by the point cloud.
Args:
points: Point cloud array (N, 3)
voxel_size: Voxel size in meters
Returns:
Number of unique voxels occupied
"""
if len(points) == 0:
return 0
# Convert points to voxel coordinates
voxel_coords = np.floor(points / voxel_size).astype(int)
# Find unique voxels
unique_voxels = np.unique(voxel_coords, axis=0)
return len(unique_voxels)
def calculate_adaptive_sample_count_per_part(
parts_points: List[np.ndarray],
voxel_size: float,
voxel_ratio: float,
min_points_per_part: int,
max_sample_points: int
) -> List[int]:
"""
Calculate the number of sample points for each part based on its occupied voxels.
Args:
parts_points: List of point cloud arrays for each part
voxel_size: Voxel size in meters for spatial coverage calculation
voxel_ratio: Ratio of occupied voxels to sample points
min_points_per_part: Minimum number of points each part should have
max_sample_points: Maximum number of sample points for voxel_adaptive method
Returns:
List of sample points to allocate for each part
"""
if not parts_points:
return []
sample_counts_per_part = []
for i, points in enumerate(parts_points):
if len(points) == 0:
sample_counts_per_part.append(0)
continue
# Calculate voxel coverage for this part
voxel_coverage = calculate_voxel_coverage(points, voxel_size)
# Calculate adaptive sample count for this part
adaptive_sample_count = int(voxel_coverage * voxel_ratio)
# Apply min/max constraints for this part
adaptive_sample_count = max(min_points_per_part, adaptive_sample_count)
# Don't exceed available points in this part
adaptive_sample_count = min(len(points), adaptive_sample_count)
# Apply global max constraint
adaptive_sample_count = min(max_sample_points, adaptive_sample_count)
sample_counts_per_part.append(adaptive_sample_count)
logger.debug(f"Part {i}: {voxel_coverage} occupied voxels -> {adaptive_sample_count} sample points "
f"(ratio: {voxel_ratio}, voxel_size: {voxel_size}m)")
total_sample_points = sum(sample_counts_per_part)
logger.debug(f"Voxel-adaptive per-part sampling: total {total_sample_points} sample points across {len(parts_points)} parts")
return sample_counts_per_part
def allocate_fps_points(
parts_data: Union[List[int], List[np.ndarray]],
allocation_method: str,
num_points: int,
min_points_per_part: int,
voxel_size: float,
voxel_ratio: float,
total_sample_points: Optional[List[int]] = None # for voxel_adaptive method
) -> np.ndarray:
"""
Allocate FPS points to parts proportionally with minimum constraints.
Args:
parts_data: Either list of point counts (for point_count method) or
list of point arrays (for spatial_coverage and voxel_adaptive methods)
allocation_method: Method for allocating FPS points ('point_count', 'spatial_coverage', or 'voxel_adaptive')
num_points: Total number of sample points to allocate (for point_count and spatial_coverage methods)
min_points_per_part: Minimum number of points each part should have after FPS
voxel_size: Voxel size in meters for spatial coverage calculation (used in spatial_coverage and voxel_adaptive)
voxel_ratio: Ratio of occupied voxels to sample points for voxel_adaptive method
total_sample_points: Total number of sample points to allocate for voxel_adaptive (per-part counts)
Returns:
Array of target points to sample from each part
"""
if allocation_method == 'point_count':
if isinstance(parts_data[0], np.ndarray):
pts_per_part = np.array([len(part) for part in parts_data])
else:
pts_per_part = np.array(parts_data)
return _allocate_by_point_count_logic(pts_per_part, num_points, min_points_per_part)
elif allocation_method == 'spatial_coverage':
if isinstance(parts_data[0], np.ndarray):
coverage_per_part = np.array([calculate_voxel_coverage(part, voxel_size) for part in parts_data])
pts_per_part = np.array([len(part) for part in parts_data])
else:
raise ValueError("spatial_coverage allocation method requires point arrays, not point counts")
return _allocate_by_spatial_coverage_logic(coverage_per_part, pts_per_part, num_points, min_points_per_part)
elif allocation_method == 'voxel_adaptive':
if isinstance(parts_data[0], np.ndarray):
if total_sample_points is None:
raise ValueError("voxel_adaptive allocation method requires total_sample_points parameter (per-part sample counts)")
if not isinstance(total_sample_points, list):
raise ValueError("voxel_adaptive allocation method requires total_sample_points to be a list of per-part counts")
per_part_sample_counts = np.array(total_sample_points)
pts_per_part = np.array([len(part) for part in parts_data])
target_per_part = np.minimum(per_part_sample_counts, pts_per_part)
logger.debug(f"Voxel-adaptive per-part allocation (voxel_size={voxel_size}m, ratio={voxel_ratio}):")
for i, (requested_count, available_count, final_count) in enumerate(zip(per_part_sample_counts, pts_per_part, target_per_part)):
logger.debug(f" Part {i}: requested {requested_count}, available {available_count} -> {final_count} sampled")
return target_per_part
else:
raise ValueError("voxel_adaptive allocation method requires point arrays, not point counts")
else:
raise ValueError(f"Unknown allocation method: {allocation_method}")
def _allocate_by_point_count_logic(pts_per_part: np.ndarray, num_points: int, min_points_per_part: int) -> np.ndarray:
"""
Allocate FPS points based on point count (original method) - internal logic.
"""
n_parts = len(pts_per_part)
min_per_part = np.minimum(min_points_per_part, pts_per_part)
total_min_points = min_per_part.sum()
if total_min_points > num_points:
logger.warning(f"Minimum points per part ({min_points_per_part}) * {n_parts} parts = {total_min_points} "
f"exceeds target ({num_points}). Scaling down.")
scale_factor = num_points / total_min_points
min_per_part = np.maximum(1, np.round(min_per_part * scale_factor).astype(int))
total_min_points = min_per_part.sum()
target_per_part = min_per_part.copy()
remaining_points = num_points - total_min_points
if remaining_points > 0:
remaining_capacity = pts_per_part - min_per_part
total_capacity = remaining_capacity.sum()
if total_capacity > 0:
extra = np.round(remaining_capacity * remaining_points / total_capacity).astype(int)
target_per_part = np.minimum(target_per_part + extra, pts_per_part)
diff = num_points - target_per_part.sum()
while diff != 0 and ((diff > 0 and (target_per_part < pts_per_part).sum() > 0) or
(diff < 0 and (target_per_part > min_per_part).sum() > 0)):
if diff > 0:
valid_mask = target_per_part < pts_per_part
else:
valid_mask = target_per_part > min_per_part
if valid_mask.sum() > 0:
idx = np.random.choice(np.where(valid_mask)[0])
target_per_part[idx] += 1 if diff > 0 else -1
diff -= 1 if diff > 0 else -1
else:
break
return target_per_part
def _allocate_by_spatial_coverage_logic(coverage_per_part: np.ndarray, pts_per_part: np.ndarray, num_points: int, min_points_per_part: int) -> np.ndarray:
"""
Allocate FPS points based on spatial coverage (voxel count) - internal logic.
"""
n_parts = len(coverage_per_part)
min_per_part = np.minimum(min_points_per_part, pts_per_part)
total_min_points = min_per_part.sum()
if total_min_points > num_points:
logger.warning(f"Minimum points per part ({min_points_per_part}) * {n_parts} parts = {total_min_points} "
f"exceeds target ({num_points}). Scaling down.")
scale_factor = num_points / total_min_points
min_per_part = np.maximum(1, np.round(min_per_part * scale_factor).astype(int))
total_min_points = min_per_part.sum()
target_per_part = min_per_part.copy()
remaining_points = num_points - total_min_points
if remaining_points > 0:
remaining_capacity = pts_per_part - min_per_part
total_coverage = coverage_per_part.sum()
if total_coverage > 0:
coverage_proportion = coverage_per_part / total_coverage
extra_by_coverage = np.round(coverage_proportion * remaining_points).astype(int)
extra_by_coverage = np.minimum(extra_by_coverage, remaining_capacity)
target_per_part += extra_by_coverage
diff = num_points - target_per_part.sum()
iteration_count = 0
max_iterations = remaining_points
while diff != 0 and iteration_count < max_iterations:
if diff > 0 and (target_per_part < pts_per_part).sum() > 0:
valid_mask = target_per_part < pts_per_part
valid_indices = np.where(valid_mask)[0]
if len(valid_indices) > 0:
coverage_weights = coverage_per_part[valid_indices]
if coverage_weights.sum() > 0:
coverage_weights = coverage_weights / coverage_weights.sum()
idx = valid_indices[np.random.choice(len(valid_indices), p=coverage_weights)]
else:
idx = np.random.choice(valid_indices)
target_per_part[idx] += 1
diff -= 1
elif diff < 0 and (target_per_part > min_per_part).sum() > 0:
valid_mask = target_per_part > min_per_part
valid_indices = np.where(valid_mask)[0]
if len(valid_indices) > 0:
coverage_weights = coverage_per_part[valid_indices]
if coverage_weights.sum() > 0:
inv_weights = 1.0 / (coverage_weights + 1e-8)
inv_weights = inv_weights / inv_weights.sum()
idx = valid_indices[np.random.choice(len(valid_indices), p=inv_weights)]
else:
idx = np.random.choice(valid_indices)
target_per_part[idx] -= 1
diff += 1
else:
break
iteration_count += 1
if iteration_count >= max_iterations:
logger.warning(f"Fine-tuning reached maximum iterations ({max_iterations}), final diff: {diff}")
return target_per_part
def apply_batched_fps(
batch_augmented_tensor: torch.Tensor,
batch_lengths_tensor: torch.Tensor,
batch_k_tensor: torch.Tensor,
global_seed: int,
device: torch.device
) -> Tuple[List[torch.Tensor], List[torch.Tensor]]:
"""
Apply batched FPS using PyTorch3D.
Args:
batch_augmented_tensor: Batched augmented point clouds (N, max_points, 3)
batch_lengths_tensor: Lengths of point clouds in the batch (N,)
batch_k_tensor: Number of points to sample for each point cloud (N,)
global_seed: Global random seed for reproducible sampling
device: Device to perform computation on
Returns:
Tuple of (list of sampled augmented parts, list of sampled indices)
"""
try:
# Set the FPS-specific random seed for reproducible sampling
torch.manual_seed(global_seed)
# PyTorch3D FPS requires a contiguous tensor, ensure it
batch_augmented_tensor = batch_augmented_tensor.contiguous()
sampled_points, indices_tensor = sample_farthest_points(
batch_augmented_tensor.to(device),
lengths=batch_lengths_tensor.to(device),
K=batch_k_tensor.to(device),
random_start_point=True
)
# Extract sampled points using indices (keep as tensors to avoid redundant conversions)
sampled_augmented_parts = []
for i, (k_i, indices_i) in enumerate(zip(batch_k_tensor, indices_tensor)):
# Get valid indices (not padded) - keep as tensor
valid_indices = indices_i[:k_i]
sampled_augmented_parts.append(batch_augmented_tensor[i][valid_indices])
return sampled_augmented_parts, indices_tensor
except Exception as e:
logger.error(f"PyTorch3D batched FPS failed: {e}")
raise RuntimeError(f"PyTorch3D FPS failed: {e}") from e