feat: add point extraction functions and ICPConfig region
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
## 2026-02-10T09:45:00Z Session bootstrap
|
||||||
|
- Initial notepad created for full-icp-pipeline execution.
|
||||||
|
- Baseline code references verified in `aruco/icp_registration.py` and `refine_ground_plane.py`.
|
||||||
|
|
||||||
|
## Task 2: Point Extraction Functions
|
||||||
|
|
||||||
|
### Learnings
|
||||||
|
- Open3D's `remove_statistical_outlier` returns a tuple `(pcd, ind)`, where `ind` is the list of indices. We only need the point cloud.
|
||||||
|
- `estimate_normals` with `KDTreeSearchParamHybrid` is robust for mixed geometry (floor + walls).
|
||||||
|
- Hybrid extraction strategy:
|
||||||
|
1. Extract floor band (spatial filter).
|
||||||
|
2. Extract vertical points (normal-based filter: `abs(normal · floor_normal) < 0.3`).
|
||||||
|
3. Combine using boolean masks on the original point set to avoid duplicates.
|
||||||
|
- `extract_scene_points` provides a unified interface for different registration strategies (floor-only vs full-scene).
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
- Kept `extract_near_floor_band` as a standalone function for backward compatibility and as a helper for `extract_scene_points`.
|
||||||
|
- Used `mode='floor'` as default to match existing behavior.
|
||||||
|
- Implemented `preprocess_point_cloud` to encapsulate downsampling and SOR, making the pipeline cleaner.
|
||||||
|
- Added `region` field to `ICPConfig` to control the extraction mode in future tasks.
|
||||||
@@ -28,10 +28,12 @@ class ICPConfig:
|
|||||||
min_fitness: float = 0.3 # Min ICP fitness to accept pair
|
min_fitness: float = 0.3 # Min ICP fitness to accept pair
|
||||||
min_overlap_area: float = 1.0 # Min XZ overlap area in m^2
|
min_overlap_area: float = 1.0 # Min XZ overlap area in m^2
|
||||||
overlap_margin: float = 0.5 # Inflate bboxes by this margin (m)
|
overlap_margin: float = 0.5 # Inflate bboxes by this margin (m)
|
||||||
|
overlap_mode: str = "xz" # 'xz' or '3d'
|
||||||
gravity_penalty_weight: float = 10.0 # Soft constraint on pitch/roll
|
gravity_penalty_weight: float = 10.0 # Soft constraint on pitch/roll
|
||||||
max_correspondence_distance_factor: float = 1.4
|
max_correspondence_distance_factor: float = 1.4
|
||||||
max_rotation_deg: float = 5.0 # Safety bound on ICP delta
|
max_rotation_deg: float = 5.0 # Safety bound on ICP delta
|
||||||
max_translation_m: float = 0.1 # Safety bound on ICP delta
|
max_translation_m: float = 0.1 # Safety bound on ICP delta
|
||||||
|
region: str = "floor" # "floor", "hybrid", or "full"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -59,6 +61,94 @@ class ICPMetrics:
|
|||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_point_cloud(
|
||||||
|
pcd: o3d.geometry.PointCloud,
|
||||||
|
voxel_size: float,
|
||||||
|
) -> o3d.geometry.PointCloud:
|
||||||
|
"""
|
||||||
|
Preprocess point cloud: downsample and remove outliers.
|
||||||
|
"""
|
||||||
|
pcd_down = pcd.voxel_down_sample(voxel_size)
|
||||||
|
|
||||||
|
# SOR: nb_neighbors=20, std_ratio=2.0
|
||||||
|
pcd_clean, _ = pcd_down.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0)
|
||||||
|
|
||||||
|
return pcd_clean
|
||||||
|
|
||||||
|
|
||||||
|
def extract_scene_points(
|
||||||
|
points_world: PointsNC,
|
||||||
|
floor_y: float,
|
||||||
|
floor_normal: Vec3,
|
||||||
|
mode: str = "floor",
|
||||||
|
band_height: float = 0.3,
|
||||||
|
) -> PointsNC:
|
||||||
|
"""
|
||||||
|
Extract points based on mode:
|
||||||
|
- 'floor': points within band_height of floor
|
||||||
|
- 'hybrid': floor points + vertical structures (walls/pillars)
|
||||||
|
- 'full': all points
|
||||||
|
"""
|
||||||
|
if len(points_world) == 0:
|
||||||
|
return points_world
|
||||||
|
|
||||||
|
if mode == "full":
|
||||||
|
return points_world
|
||||||
|
|
||||||
|
if mode == "floor":
|
||||||
|
return extract_near_floor_band(points_world, floor_y, band_height, floor_normal)
|
||||||
|
|
||||||
|
if mode == "hybrid":
|
||||||
|
# 1. Get floor points
|
||||||
|
floor_pts = extract_near_floor_band(
|
||||||
|
points_world, floor_y, band_height, floor_normal
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Get vertical points
|
||||||
|
# Need normals for this. Create temp PCD
|
||||||
|
pcd = o3d.geometry.PointCloud()
|
||||||
|
pcd.points = o3d.utility.Vector3dVector(points_world)
|
||||||
|
|
||||||
|
# Estimate normals if not present (using hybrid KD-tree)
|
||||||
|
pcd.estimate_normals(
|
||||||
|
search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
normals = np.asarray(pcd.normals)
|
||||||
|
if len(normals) != len(points_world):
|
||||||
|
logger.warning(
|
||||||
|
"Normal estimation failed, falling back to floor points only"
|
||||||
|
)
|
||||||
|
return floor_pts
|
||||||
|
|
||||||
|
# Dot product with floor normal
|
||||||
|
# Vertical surfaces have normals perpendicular to floor normal -> dot product near 0
|
||||||
|
dots = np.abs(normals @ floor_normal)
|
||||||
|
|
||||||
|
# Keep points where normal is roughly perpendicular to floor normal (vertical surface)
|
||||||
|
# Threshold 0.3 allows for some noise/slope (approx 72-108 degrees from floor normal)
|
||||||
|
vertical_mask = dots < 0.3
|
||||||
|
vertical_pts = points_world[vertical_mask]
|
||||||
|
|
||||||
|
if len(vertical_pts) == 0:
|
||||||
|
return floor_pts
|
||||||
|
|
||||||
|
# Combine unique points (though sets are disjoint by definition of mask vs band?
|
||||||
|
# No, band is spatial, vertical is orientation. They might overlap.)
|
||||||
|
# Simply concatenating might duplicate.
|
||||||
|
# Let's use a boolean mask for union.
|
||||||
|
|
||||||
|
# Re-compute floor mask to combine
|
||||||
|
projections = points_world @ floor_normal
|
||||||
|
floor_mask = (projections >= floor_y) & (projections <= floor_y + band_height)
|
||||||
|
|
||||||
|
combined_mask = floor_mask | vertical_mask
|
||||||
|
return points_world[combined_mask]
|
||||||
|
|
||||||
|
# Default fallback
|
||||||
|
return extract_near_floor_band(points_world, floor_y, band_height, floor_normal)
|
||||||
|
|
||||||
|
|
||||||
def extract_near_floor_band(
|
def extract_near_floor_band(
|
||||||
points_world: PointsNC,
|
points_world: PointsNC,
|
||||||
floor_y: float,
|
floor_y: float,
|
||||||
@@ -105,6 +195,29 @@ def compute_overlap_xz(
|
|||||||
return float(dims[0] * dims[1])
|
return float(dims[0] * dims[1])
|
||||||
|
|
||||||
|
|
||||||
|
def compute_overlap_3d(
|
||||||
|
points_a: PointsNC,
|
||||||
|
points_b: PointsNC,
|
||||||
|
margin: float = 0.0,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Compute intersection volume of 3D AABBs.
|
||||||
|
"""
|
||||||
|
if len(points_a) == 0 or len(points_b) == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
min_a = np.min(points_a, axis=0) - margin
|
||||||
|
max_a = np.max(points_a, axis=0) + margin
|
||||||
|
min_b = np.min(points_b, axis=0) - margin
|
||||||
|
max_b = np.max(points_b, axis=0) + margin
|
||||||
|
|
||||||
|
inter_min = np.maximum(min_a, min_b)
|
||||||
|
inter_max = np.minimum(max_a, max_b)
|
||||||
|
|
||||||
|
dims = np.maximum(0, inter_max - inter_min)
|
||||||
|
return float(dims[0] * dims[1] * dims[2])
|
||||||
|
|
||||||
|
|
||||||
def apply_gravity_constraint(
|
def apply_gravity_constraint(
|
||||||
T_icp: Mat44,
|
T_icp: Mat44,
|
||||||
T_original: Mat44,
|
T_original: Mat44,
|
||||||
@@ -383,6 +496,9 @@ def refine_with_icp(
|
|||||||
camera_points[s1], camera_points[s2], config.overlap_margin
|
camera_points[s1], camera_points[s2], config.overlap_margin
|
||||||
)
|
)
|
||||||
if area < config.min_overlap_area:
|
if area < config.min_overlap_area:
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping pair ({s1}, {s2}) due to insufficient overlap: {area:.2f} m^2 < {config.min_overlap_area:.2f} m^2"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
metrics.num_pairs_attempted += 1
|
metrics.num_pairs_attempted += 1
|
||||||
@@ -411,8 +527,9 @@ def refine_with_icp(
|
|||||||
result.transformation, init_T, config.gravity_penalty_weight
|
result.transformation, init_T, config.gravity_penalty_weight
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.converged:
|
|
||||||
pair_results[(s1, s2)] = result
|
pair_results[(s1, s2)] = result
|
||||||
|
metrics.per_pair_results[(s1, s2)] = result
|
||||||
|
if result.converged:
|
||||||
metrics.num_pairs_converged += 1
|
metrics.num_pairs_converged += 1
|
||||||
metrics.per_pair_results[(s1, s2)] = result
|
metrics.per_pair_results[(s1, s2)] = result
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user