From c147faf4afbb482d8402a13e289285c38682f0ab Mon Sep 17 00:00:00 2001 From: Zoe Date: Wed, 10 Jun 2026 16:37:18 -0400 Subject: [PATCH 1/9] code to rip segements from splat using garfvdb Signed-off-by: Zoe --- .../garfvdb/extract_segments.py | 466 ++++++++++++++++++ .../extract_mesh_for_segments.py | 322 ++++++++++++ 2 files changed, 788 insertions(+) create mode 100644 instance_segmentation/garfvdb/extract_segments.py create mode 100644 segmenation_extraction/extract_mesh_for_segments.py diff --git a/instance_segmentation/garfvdb/extract_segments.py b/instance_segmentation/garfvdb/extract_segments.py new file mode 100644 index 0000000..99b5868 --- /dev/null +++ b/instance_segmentation/garfvdb/extract_segments.py @@ -0,0 +1,466 @@ +# Copyright Contributors to the OpenVDB Project +# SPDX-License-Identifier: Apache-2.0 +# +"""Export instance segments from a trained GARfVDB segmentation checkpoint. + +Loads a Gaussian splat reconstruction and segmentation checkpoint, clusters +per-Gaussian affinity features at a chosen scale, filters clusters, then writes +``n`` segment ``.ply`` files (one per selected cluster). + +Example:: + + python extract_segments.py \\ + -s garfvdb_logs/run/checkpoints/00036600/train_ckpt.pt \\ + -r frgs_logs/safety_park_1/checkpoints/00024800/reconstruct_ckpt.pt \\ + -o segments/ \\ + --n 5 \\ + --scale 0.1 +""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path +from typing import Literal + +import numpy as np +import torch +from fvdb import GaussianSplat3d +from scipy.spatial import cKDTree + +# Import directly to avoid fvdb_reality_capture.tools.__init__ pulling optional deps (e.g. DLNR). +from fvdb_reality_capture.tools._filter_splats import ( + filter_splats_above_scale, + filter_splats_by_mean_percentile, + filter_splats_by_opacity_percentile, +) +from garfvdb.training.segmentation import GaussianSplatScaleConditionedSegmentation +from garfvdb.util import load_splats_from_file + +logger = logging.getLogger(__name__) + + +def load_segmentation_runner_from_checkpoint( + checkpoint_path: Path, + gs_model: GaussianSplat3d, + gs_model_path: Path, + device: str | torch.device = "cuda", +) -> GaussianSplatScaleConditionedSegmentation: + """Restore a segmentation runner from a training checkpoint. + Args: + checkpoint_path: Path to the segmentation checkpoint. + gs_model: The Gaussian splat model to use for the segmentation. + gs_model_path: Path to the Gaussian splat reconstruction. + device: The device to use for the segmentation. + """ + checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False) + runner = GaussianSplatScaleConditionedSegmentation.from_state_dict( + state_dict=checkpoint, + gs_model=gs_model, + gs_model_path=gs_model_path, + device=device, + eval_only=True, + ) + if torch.cuda.is_available(): + torch.cuda.empty_cache() + return runner + + +def _is_gpu_oom_error(exc: BaseException) -> bool: + """Return whether an exception indicates GPU out-of-memory. + Args: + exc: The exception raised during clustering. + """ + return "out_of_memory" in str(exc).lower() or isinstance(exc, MemoryError) + + +def _drop_clusters( + cluster_splats: dict[int, GaussianSplat3d], + cluster_coherence: dict[int, float], + keys: list[int], +) -> None: + """Remove clusters from the splat and coherence maps in place. + Args: + cluster_splats: Cluster label to Gaussian splat mapping. + cluster_coherence: Cluster label to coherence score mapping. + keys: Cluster labels to remove. + """ + for key in keys: + cluster_splats.pop(key, None) + cluster_coherence.pop(key, None) + + +def _subsample_gaussians( + gs_model: GaussianSplat3d, + max_gaussians: int, + seed: int, + device: torch.device, +) -> tuple[GaussianSplat3d, torch.Tensor]: + """Randomly subsample Gaussians for memory-bounded clustering. + Args: + gs_model: Full-scene Gaussian splat model. + max_gaussians: Maximum number of Gaussians to keep. + seed: Random seed for reproducible subsampling. + device: Torch device for the returned mask. + """ + rng = np.random.default_rng(seed) + indices = rng.choice(gs_model.num_gaussians, size=max_gaussians, replace=False) + mask = torch.zeros(gs_model.num_gaussians, dtype=torch.bool, device=device) + mask[torch.from_numpy(indices).to(device)] = True + return gs_model[mask], mask + + +def _map_cluster_labels_to_full_scene( + cluster_labels_sub: torch.Tensor, + cluster_probs_sub: torch.Tensor, + gs_model: GaussianSplat3d, + clustering_gs_model: GaussianSplat3d, + device: torch.device, +) -> tuple[torch.Tensor, torch.Tensor]: + """Map cluster labels from a subsampled set back to the full scene. + Args: + cluster_labels_sub: Cluster labels on the subsampled Gaussians. + cluster_probs_sub: Cluster probabilities on the subsampled Gaussians. + gs_model: Full-scene Gaussian splat model. + clustering_gs_model: Subsampled model used for clustering. + device: Torch device for returned tensors. + """ + tree = cKDTree(clustering_gs_model.means.cpu().numpy()) + _, nearest_indices = tree.query(gs_model.means.cpu().numpy(), k=1, workers=-1) + index_tensor = torch.from_numpy(nearest_indices).to(device) + return cluster_labels_sub[index_tensor], cluster_probs_sub[index_tensor] + + +def _filter_high_variance_clusters( + cluster_splats: dict[int, GaussianSplat3d], + cluster_coherence: dict[int, float], + variance_threshold: float, +) -> list[int]: + """Find spatially incoherent clusters by normalized variance. + Args: + cluster_splats: Cluster label to Gaussian splat mapping. + cluster_coherence: Cluster label to coherence score mapping. + variance_threshold: Normalized variance cutoff (variance / extent^2). + """ + removed: list[int] = [] + for label, splat in list(cluster_splats.items()): + means = splat.means + extent = (means.max(dim=0).values - means.min(dim=0).values).max().item() + if extent > 1e-6: + norm_variance = means.var(dim=0).mean().item() / (extent**2) + else: + norm_variance = 0.0 + if norm_variance > variance_threshold: + removed.append(label) + return removed + + +@torch.inference_mode() +def rip_segments( + *, + segmentation_path: Path, + reconstruction_path: Path, + out_dir: Path, + n: int, + scale: float, + scale_is_fraction_of_max: bool, + seed: int, + device: str, + min_splat_scale: float, + opacity_percentile: float, + mean_percentile: tuple[float, ...], + min_cluster_gaussians: int, + filter_high_variance: bool, + variance_threshold: float, + sample_by: Literal["random", "coherence"], + max_gaussians_for_clustering: int, + verbose: bool, +) -> None: + """Cluster Gaussians and export ``n`` segment PLY files.""" + log_level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=log_level, format="%(levelname)s : %(message)s") + + # Clustering depends on GPU libs (cuml/cupy); import lazily so --help works without them. + from garfvdb.evaluation.clustering import ( # noqa: PLC0415 + compute_cluster_labels, + split_gaussians_into_clusters, + ) + + device_t = torch.device(device) + + if not segmentation_path.exists(): + raise FileNotFoundError(f"Segmentation checkpoint not found: {segmentation_path}") + if not reconstruction_path.exists(): + raise FileNotFoundError(f"Reconstruction checkpoint not found: {reconstruction_path}") + if n <= 0: + raise ValueError(f"--n must be > 0, got {n}") + + out_dir.mkdir(parents=True, exist_ok=True) + + logger.info("Loading Gaussian splat model from %s", reconstruction_path) + gs_model, original_metadata = load_splats_from_file(reconstruction_path, device_t) + logger.info("Loaded %s Gaussians (pre-filter)", f"{gs_model.num_gaussians:,}") + + if min_splat_scale > 0: + gs_model = filter_splats_above_scale(gs_model, min_splat_scale) + if opacity_percentile > 0: + gs_model = filter_splats_by_opacity_percentile(gs_model, percentile=opacity_percentile) + if mean_percentile: + gs_model = filter_splats_by_mean_percentile(gs_model, percentile=list(mean_percentile)) + logger.info("Remaining %s Gaussians (post-filter)", f"{gs_model.num_gaussians:,}") + + runner = load_segmentation_runner_from_checkpoint( + checkpoint_path=segmentation_path, + gs_model=gs_model, + gs_model_path=reconstruction_path, + device=device_t, + ) + gs_model = runner.gs_model + segmentation_model = runner.model + + max_scale = float(segmentation_model.max_grouping_scale.item()) + scale_abs = float(scale) * max_scale if scale_is_fraction_of_max else float(scale) + logger.info("Segmentation model max scale: %.6f", max_scale) + logger.info("Clustering at scale: %.6f", scale_abs) + + clustering_gs_model = gs_model + subsample_mask: torch.Tensor | None = None + if max_gaussians_for_clustering > 0 and gs_model.num_gaussians > max_gaussians_for_clustering: + logger.warning( + "Scene has %s gaussians (> %s); subsampling for clustering, then mapping labels back.", + f"{gs_model.num_gaussians:,}", + f"{max_gaussians_for_clustering:,}", + ) + clustering_gs_model, subsample_mask = _subsample_gaussians( + gs_model, max_gaussians_for_clustering, seed, device_t + ) + logger.info( + "Clustering on %s subsampled gaussians", + f"{clustering_gs_model.num_gaussians:,}", + ) + + mask_features = segmentation_model.get_gaussian_affinity_output(scale_abs) + if subsample_mask is not None: + mask_features = mask_features[subsample_mask] + + try: + cluster_labels_sub, cluster_probs_sub = compute_cluster_labels(mask_features, device=device_t) + except Exception as exc: + if not _is_gpu_oom_error(exc): + raise + logger.error( + "GPU OOM while clustering %s gaussians. Try tighter pre-filters or a lower " + "--max-gaussians-for-clustering (current: %s). Target: <500k for clustering.", + f"{clustering_gs_model.num_gaussians:,}", + f"{max_gaussians_for_clustering:,}", + ) + raise RuntimeError( + f"Clustering failed due to GPU memory ({clustering_gs_model.num_gaussians:,} gaussians)." + ) from exc + + if subsample_mask is not None: + logger.info("Mapping cluster labels back to all gaussians via nearest neighbor...") + cluster_labels, cluster_probs = _map_cluster_labels_to_full_scene( + cluster_labels_sub, + cluster_probs_sub, + gs_model, + clustering_gs_model, + device_t, + ) + else: + cluster_labels, cluster_probs = cluster_labels_sub, cluster_probs_sub + + cluster_splats, cluster_coherence, _ = split_gaussians_into_clusters(cluster_labels, cluster_probs, gs_model) + + if min_cluster_gaussians > 0: + removed_small = [ + label for label, splat in cluster_splats.items() if splat.num_gaussians < min_cluster_gaussians + ] + _drop_clusters(cluster_splats, cluster_coherence, removed_small) + if removed_small: + logger.info( + "Removed %d clusters with < %d gaussians", + len(removed_small), + min_cluster_gaussians, + ) + + if filter_high_variance: + removed_variance = _filter_high_variance_clusters(cluster_splats, cluster_coherence, variance_threshold) + _drop_clusters(cluster_splats, cluster_coherence, removed_variance) + if removed_variance: + logger.info( + "Removed %d spatially incoherent clusters (variance_threshold=%.4f)", + len(removed_variance), + variance_threshold, + ) + + if not cluster_splats: + raise RuntimeError("No clusters remaining after filtering; relax filters or try a different --scale.") + + labels = sorted(cluster_splats.keys()) + n_to_export = min(n, len(labels)) + if n_to_export < n: + logger.warning( + "Requested n=%d but only %d clusters available; exporting %d.", + n, + len(labels), + n_to_export, + ) + + if sample_by == "coherence": + chosen_labels = [ + label + for label, _ in sorted(cluster_coherence.items(), key=lambda item: item[1], reverse=True)[:n_to_export] + ] + else: + rng = np.random.default_rng(seed) + chosen_labels = rng.choice(np.array(labels, dtype=np.int64), size=n_to_export, replace=False).tolist() + + for i, label in enumerate(chosen_labels): + splat = cluster_splats[int(label)] + coherence = float(cluster_coherence[int(label)]) + ply_path = out_dir / (f"segment_{i:04d}_cluster{int(label)}_coh{coherence:.3f}_n{splat.num_gaussians}.ply") + if ply_path.exists(): + logger.warning("Overwriting existing file: %s", ply_path) + + metadata: dict = {} + if original_metadata: + metadata.update(original_metadata) + metadata.update( + { + "cluster_id": int(label), + "coherence": coherence, + "num_gaussians": int(splat.num_gaussians), + "scale_abs": scale_abs, + "segmentation_ckpt": str(segmentation_path), + "reconstruction": str(reconstruction_path), + } + ) + + splat.save_ply(str(ply_path), metadata=metadata) + logger.info("Wrote %s (%s gaussians)", ply_path, f"{splat.num_gaussians:,}") + + +def main() -> None: + """Parse CLI arguments and run segment extraction.""" + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument( + "-s", + "--segmentation-path", + type=Path, + required=True, + help="GARfVDB segmentation checkpoint (.pt / .pth)", + ) + parser.add_argument( + "-r", + "--reconstruction-path", + type=Path, + required=True, + help="Gaussian splat reconstruction (.pt / .ply)", + ) + parser.add_argument( + "-o", + "--out-dir", + type=Path, + required=True, + help="Output directory for segment PLY files", + ) + + parser.add_argument("--n", type=int, default=10, help="Number of segments to export") + parser.add_argument( + "--scale", + type=float, + default=0.1, + help="Clustering scale (absolute or fraction of max; see --scale-is-fraction-of-max)", + ) + parser.add_argument( + "--scale-is-fraction-of-max", + action=argparse.BooleanOptionalAction, + default=True, + help="Interpret --scale as a fraction of model.max_grouping_scale", + ) + parser.add_argument("--seed", type=int, default=42, help="Random seed for sampling/subsampling") + parser.add_argument("--device", type=str, default="cuda", help="Torch device") + parser.add_argument("--verbose", action="store_true", help="Enable debug logging") + + parser.add_argument( + "--min-splat-scale", + type=float, + default=0.1, + help="Drop Gaussians with scale below this value (0 disables)", + ) + parser.add_argument( + "--opacity-percentile", + type=float, + default=0.85, + help="Keep Gaussians above this opacity percentile (0 disables)", + ) + parser.add_argument( + "--mean-percentile", + type=float, + nargs="*", + default=[0.96, 0.96, 0.96, 0.96, 0.98, 0.99], + help="Per-channel mean percentiles for splat pre-filtering", + ) + + parser.add_argument( + "--min-cluster-gaussians", + type=int, + default=200, + help="Drop clusters smaller than this (0 disables)", + ) + parser.add_argument( + "--filter-high-variance", + action=argparse.BooleanOptionalAction, + default=True, + help="Remove spatially incoherent clusters", + ) + parser.add_argument( + "--variance-threshold", + type=float, + default=0.1, + help="Normalized variance cutoff when --filter-high-variance is enabled", + ) + parser.add_argument( + "--sample-by", + choices=["random", "coherence"], + default="random", + help="How to pick which clusters to export", + ) + parser.add_argument( + "--max-gaussians-for-clustering", + type=int, + default=500_000, + help="Subsample before clustering when scene is larger (0 disables)", + ) + + args = parser.parse_args() + + rip_segments( + segmentation_path=args.segmentation_path, + reconstruction_path=args.reconstruction_path, + out_dir=args.out_dir, + n=args.n, + scale=args.scale, + scale_is_fraction_of_max=args.scale_is_fraction_of_max, + seed=args.seed, + device=args.device, + min_splat_scale=args.min_splat_scale, + opacity_percentile=args.opacity_percentile, + mean_percentile=tuple(float(x) for x in args.mean_percentile), + min_cluster_gaussians=args.min_cluster_gaussians, + filter_high_variance=args.filter_high_variance, + variance_threshold=args.variance_threshold, + sample_by=args.sample_by, + max_gaussians_for_clustering=args.max_gaussians_for_clustering, + verbose=args.verbose, + ) + + +if __name__ == "__main__": + main() diff --git a/segmenation_extraction/extract_mesh_for_segments.py b/segmenation_extraction/extract_mesh_for_segments.py new file mode 100644 index 0000000..566face --- /dev/null +++ b/segmenation_extraction/extract_mesh_for_segments.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +# Copyright Contributors to the OpenVDB Project +# SPDX-License-Identifier: Apache-2.0 +# +""" +Pulls mesh segment matching GS segment from a large mesh +Requires frgs mesh-dlnr or similar to be run on full scene first +Optionally closes holes via harmonic Laplacian fill then make_mesh_watertight +Output can be used as input for fvdb-reality-capture/scripts/create_isaac_ready_files.py to make a USDZ +""" +from __future__ import annotations + +import argparse +import logging +from pathlib import Path + +import igl +import numpy as np +import point_cloud_utils as pcu +from fvdb import GaussianSplat3d +from scipy.spatial import cKDTree + + +def _reproject_vertex_colors( + target_vertices: np.ndarray, + source_vertices: np.ndarray, + source_colors: np.ndarray, +) -> np.ndarray: + """Copy colors onto new mesh vertices via nearest-neighbor lookup. + Args: + target_vertices: The vertices of the new mesh. + source_vertices: The vertices of the source mesh. + source_colors: The colors of the source mesh. + Returns: + The colors of the new mesh. + """ + finite_mask = np.isfinite(source_vertices).all(axis=1) + if not finite_mask.all(): + source_vertices = source_vertices[finite_mask] + source_colors = source_colors[finite_mask] + if source_vertices.shape[0] == 0: + raise ValueError("No finite source vertices available for color reprojection") + + tree = cKDTree(source_vertices) + query_vertices = np.asarray(target_vertices, dtype=np.float64) + bad = ~np.isfinite(query_vertices).all(axis=1) + if bad.any(): + query_vertices = query_vertices.copy() + query_vertices[bad] = source_vertices.mean(axis=0) + _, indices = tree.query(query_vertices, k=1, workers=-1) + return source_colors[indices] + + +def _has_vertex_colors(vertex_colors: np.ndarray | None) -> bool: + """Return True if the mesh has a non-empty per-vertex color array.""" + return vertex_colors is not None and vertex_colors.size > 0 and vertex_colors.shape[0] > 0 + + +def _normalize_vertex_colors(colors: np.ndarray) -> np.ndarray: + """Convert vertex colors to float RGB(A) in [0, 1] for point_cloud_utils I/O.""" + colors = np.asarray(colors) + if colors.dtype == np.uint8: + colors = colors.astype(np.float64) / 255.0 + else: + colors = colors.astype(np.float64) + if colors.size > 0 and colors.max() > 1.0: + colors = colors / 255.0 + return np.clip(colors, 0.0, 1.0).astype(np.float32) + + +def extract_segment_mesh( + *, + full_mesh_path: Path, + segment_ply_path: Path, + output_path: Path, + distance_threshold: float, + no_gap_fill: bool, + resolution: int, + device: str, + verbose: bool, +) -> None: + """Extract a mesh region corresponding to a Gaussian segment. + + Args: + full_mesh_path: Path to the full scene mesh (.ply). + segment_ply_path: Path to the segment Gaussian splats (.ply). + output_path: Path to save the extracted segment mesh. + distance_threshold: Maximum distance from segment Gaussians to include mesh vertices (in world units). + no_gap_fill: Skip watertight + harmonic gap fill on the extracted patch + resolution: Manifold octree resolution for pcu.make_mesh_watertight (capped to segment size) + device: Device for loading Gaussian splats. + verbose: Enable verbose logging. + """ + log_level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=log_level, format="%(levelname)s : %(message)s") + logger = logging.getLogger(__name__) + + # Load the full mesh + logger.info(f"Loading full scene mesh from {full_mesh_path}") + vertices, faces, vertex_colors = pcu.load_mesh_vfc(str(full_mesh_path)) + logger.info(f"Loaded mesh with {len(vertices):,} vertices and {len(faces):,} faces") + + # Load the segment Gaussians + logger.info(f"Loading segment Gaussians from {segment_ply_path}") + segment_splat, _ = GaussianSplat3d.from_ply(segment_ply_path, device=device) + segment_means = segment_splat.means.cpu().numpy() # [N, 3] + logger.info(f"Loaded {len(segment_means):,} Gaussians in segment") + + # Build KD-tree for fast nearest neighbor queries + logger.info("Building KD-tree for segment Gaussians...") + tree = cKDTree(segment_means) + + # Find mesh vertices within distance threshold of any segment Gaussian + logger.info(f"Finding mesh vertices within {distance_threshold:.3f} units of segment...") + distances, _ = tree.query(vertices, k=1, distance_upper_bound=distance_threshold) + close_vertex_mask = distances < distance_threshold + num_close_vertices = close_vertex_mask.sum() + + logger.info( + f"Found {num_close_vertices:,} vertices ({100 * num_close_vertices / len(vertices):.1f}%) " f"within threshold" + ) + + if num_close_vertices == 0: + raise ValueError( + f"No mesh vertices found within {distance_threshold} units of segment Gaussians. " + f"Try increasing --distance-threshold." + ) + + # Create a mapping from old vertex indices to new vertex indices + old_to_new_vertex_idx = np.full(len(vertices), -1, dtype=np.int64) + old_to_new_vertex_idx[close_vertex_mask] = np.arange(num_close_vertices) + + # Extract only faces where all 3 vertices are close to the segment + face_mask = np.all(close_vertex_mask[faces], axis=1) + extracted_faces = faces[face_mask] + num_extracted_faces = len(extracted_faces) + + logger.info( + f"Extracted {num_extracted_faces:,} faces ({100 * num_extracted_faces / len(faces):.1f}%) " + f"where all vertices are close to segment" + ) + + if num_extracted_faces == 0: + raise ValueError( + "No complete faces found within the distance threshold. " "Try increasing --distance-threshold." + ) + + # Reindex faces to use new vertex indices + extracted_faces_reindexed = old_to_new_vertex_idx[extracted_faces] + + # Extract the corresponding vertices and colors + extracted_vertices = vertices[close_vertex_mask] + extracted_colors = ( + _normalize_vertex_colors(vertex_colors[close_vertex_mask]) if _has_vertex_colors(vertex_colors) else None + ) + + if not no_gap_fill: + # Use the raw extracted patch for color lookup after watertight remeshes vertices. + color_source_vertices = extracted_vertices.copy() + color_source_colors = extracted_colors + + logger.info("Harmonic Laplacian gap fill on extracted patch...") + num_faces_before = len(extracted_faces_reindexed) + num_vertices_before = len(extracted_vertices) + extracted_vertices, extracted_faces_reindexed = fill_mesh_gaps(extracted_vertices, extracted_faces_reindexed) + if len(extracted_faces_reindexed) != num_faces_before: + logger.info( + "Harmonic fill added %d faces and %d vertices (%d -> %d faces)", + len(extracted_faces_reindexed) - num_faces_before, + len(extracted_vertices) - num_vertices_before, + num_faces_before, + len(extracted_faces_reindexed), + ) + effective_resolution = int(min(resolution, max(2_000, len(extracted_faces_reindexed) // 2))) + if effective_resolution != resolution: + logger.info( + "Capped watertight resolution %d -> %d for segment size", + resolution, + effective_resolution, + ) + logger.info("Making mesh watertight (resolution=%d)...", effective_resolution) + num_faces_before = len(extracted_faces_reindexed) + num_vertices_before = len(extracted_vertices) + extracted_vertices, extracted_faces_reindexed = pcu.make_mesh_watertight( + extracted_vertices, + extracted_faces_reindexed, + resolution=effective_resolution, + ) + logger.info( + "Watertight mesh: %d vertices, %d faces (was %d vertices, %d faces)", + len(extracted_vertices), + len(extracted_faces_reindexed), + num_vertices_before, + num_faces_before, + ) + # Reproject colors onto the new mesh vertices if colors are available + if color_source_colors is not None: + extracted_colors = _reproject_vertex_colors(extracted_vertices, color_source_vertices, color_source_colors) + + # Save the extracted mesh + output_path.parent.mkdir(parents=True, exist_ok=True) + logger.info(f"Saving extracted mesh to {output_path}") + + if extracted_colors is not None: + pcu.save_mesh_vfc( + str(output_path), + extracted_vertices, + extracted_faces_reindexed, + _normalize_vertex_colors(extracted_colors), + ) + else: + pcu.save_mesh_vf(str(output_path), extracted_vertices, extracted_faces_reindexed) + + logger.info( + f"Successfully saved mesh with {len(extracted_vertices):,} vertices " + f"and {len(extracted_faces_reindexed):,} faces" + ) + + +def fill_mesh_gaps( + vertices: np.ndarray, + faces: np.ndarray, + *, + k: int = 1, +) -> tuple[np.ndarray, np.ndarray]: + """Close boundary holes with fan caps and harmonic Laplacian fairing. + + Each open boundary loop is triangulated with a Steiner vertex at the loop + centroid. Cap vertex positions are then relaxed by solving a k-harmonic + PDE (k=1: Laplacian, k=2: biharmonic) with the original mesh vertices fixed. + + Args: + vertices: Mesh vertex positions, shape (#V, 3). + faces: Triangle indices, shape (#F, 3). + k: Order of the harmonic operator (1 = harmonic/Laplacian, 2 = biharmonic). + + Returns: + Updated (vertices, faces) with holes capped. + """ + vertices = np.asarray(vertices, dtype=np.float64) + faces = np.asarray(faces, dtype=np.int32) + if faces.ndim != 2 or faces.shape[1] != 3: + raise ValueError("faces must be an (#F, 3) triangle index array") + + orig_vertex_count = vertices.shape[0] + + while True: + loop = igl.boundary_loop(faces) + if loop.size < 3: + break + + centroid = vertices[loop].mean(axis=0, keepdims=True) + cap_idx = vertices.shape[0] + vertices = np.vstack([vertices, centroid]) + + cap_faces = np.array( + [[cap_idx, int(loop[i]), int(loop[(i + 1) % loop.size])] for i in range(loop.size)], + dtype=np.int32, + ) + faces = np.vstack([faces, cap_faces]) + + if vertices.shape[0] == orig_vertex_count: + return vertices.astype(np.float32), faces + + b = np.arange(orig_vertex_count, dtype=np.int32) + bc = vertices[:orig_vertex_count] + smoothed = igl.harmonic(vertices, faces, b, bc, k) + if not np.isfinite(smoothed).all(): + bad = ~np.isfinite(smoothed).all(axis=1) + smoothed = np.asarray(smoothed, dtype=np.float64) + smoothed[bad] = vertices[bad] + vertices = smoothed + + return vertices.astype(np.float32), faces + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + parser = argparse.ArgumentParser( + description="Segment full scene mesh based on a PLY Gaussian Splat segment", + ) + parser.add_argument("--input-splat", type=Path, help="Input splat segment file (PLY format)") + parser.add_argument("--input-mesh", type=Path, help="Input full scene mesh file (PLY/OBJ format)") + parser.add_argument("--output-path", type=Path, required=True, help="Output path") + parser.add_argument( + "--no-gap-fill", + action="store_true", + help="Skip watertight + harmonic gap fill; may cause collision issues in Isaac Sim", + ) + parser.add_argument( + "--resolution", + type=int, + default=20_000, + help="Manifold octree resolution for make_mesh_watertight (default: 20000)", + ) + parser.add_argument("--device", type=str, default="cuda", help="Device to use") + parser.add_argument("--verbose", action="store_true", default=False) + parser.add_argument( + "--distance-threshold", + type=float, + default=0.5, + help="Maximum distance from segment Gaussians to include mesh vertices (default: 0.5)", + ) + args = parser.parse_args() + if args.input_splat is None or args.input_mesh is None: + parser.error("Both --input-splat and --input-mesh are required") + + extract_segment_mesh( + full_mesh_path=args.input_mesh, + segment_ply_path=args.input_splat, + output_path=args.output_path, + distance_threshold=args.distance_threshold, + device=args.device, + verbose=args.verbose, + no_gap_fill=args.no_gap_fill, + resolution=args.resolution, + ) + + +if __name__ == "__main__": + main() From f22d77df67d63e26f588415738dad2e5a6f02c9a Mon Sep 17 00:00:00 2001 From: zlalena Date: Thu, 25 Jun 2026 11:04:59 -0400 Subject: [PATCH 2/9] Update instance_segmentation/garfvdb/extract_segments.py Co-authored-by: Jonathan Swartz Signed-off-by: zlalena --- instance_segmentation/garfvdb/extract_segments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instance_segmentation/garfvdb/extract_segments.py b/instance_segmentation/garfvdb/extract_segments.py index 99b5868..a8c8d47 100644 --- a/instance_segmentation/garfvdb/extract_segments.py +++ b/instance_segmentation/garfvdb/extract_segments.py @@ -5,7 +5,7 @@ Loads a Gaussian splat reconstruction and segmentation checkpoint, clusters per-Gaussian affinity features at a chosen scale, filters clusters, then writes -``n`` segment ``.ply`` files (one per selected cluster). +``n`` segment ``.ply`` Gaussian splat scenes (one per selected cluster). Example:: From fe093d9bb1e37ac371ca6f7eb536fad27caeb64c Mon Sep 17 00:00:00 2001 From: zlalena Date: Thu, 25 Jun 2026 11:05:06 -0400 Subject: [PATCH 3/9] Update segmenation_extraction/extract_mesh_for_segments.py Co-authored-by: Jonathan Swartz Signed-off-by: zlalena --- segmenation_extraction/extract_mesh_for_segments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/segmenation_extraction/extract_mesh_for_segments.py b/segmenation_extraction/extract_mesh_for_segments.py index 566face..ebb5a66 100644 --- a/segmenation_extraction/extract_mesh_for_segments.py +++ b/segmenation_extraction/extract_mesh_for_segments.py @@ -56,7 +56,7 @@ def _has_vertex_colors(vertex_colors: np.ndarray | None) -> bool: return vertex_colors is not None and vertex_colors.size > 0 and vertex_colors.shape[0] > 0 -def _normalize_vertex_colors(colors: np.ndarray) -> np.ndarray: +def _as_float_vertex_colors(colors: np.ndarray) -> np.ndarray: """Convert vertex colors to float RGB(A) in [0, 1] for point_cloud_utils I/O.""" colors = np.asarray(colors) if colors.dtype == np.uint8: From 5e983ee1fd7725efe941c5fc588f394945a9c4a9 Mon Sep 17 00:00:00 2001 From: zlalena Date: Thu, 25 Jun 2026 11:05:13 -0400 Subject: [PATCH 4/9] Update segmenation_extraction/extract_mesh_for_segments.py Co-authored-by: Jonathan Swartz Signed-off-by: zlalena --- segmenation_extraction/extract_mesh_for_segments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/segmenation_extraction/extract_mesh_for_segments.py b/segmenation_extraction/extract_mesh_for_segments.py index ebb5a66..f6aeece 100644 --- a/segmenation_extraction/extract_mesh_for_segments.py +++ b/segmenation_extraction/extract_mesh_for_segments.py @@ -206,7 +206,7 @@ def extract_segment_mesh( str(output_path), extracted_vertices, extracted_faces_reindexed, - _normalize_vertex_colors(extracted_colors), + extracted_colors, ) else: pcu.save_mesh_vf(str(output_path), extracted_vertices, extracted_faces_reindexed) From eba50475557f63d8de172111c9acaa481557638a Mon Sep 17 00:00:00 2001 From: Zoe Date: Thu, 25 Jun 2026 14:35:35 -0400 Subject: [PATCH 5/9] cleanded up description, added docs and toml for meshing and more Signed-off-by: Zoe --- .../garfvdb/extract_segments.py | 41 ++++++----- .../garfvdb/garfvdb_environment.yml | 2 +- segmentation_extraction/README.md | 48 +++++++++++++ .../extract_mesh_segments.py | 70 ++++++++++++++----- segmentation_extraction/pyproject.toml | 22 ++++++ .../segmentation_extraction_environment.yml | 17 +++++ 6 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 segmentation_extraction/README.md rename segmenation_extraction/extract_mesh_for_segments.py => segmentation_extraction/extract_mesh_segments.py (81%) create mode 100644 segmentation_extraction/pyproject.toml create mode 100644 segmentation_extraction/segmentation_extraction_environment.yml diff --git a/instance_segmentation/garfvdb/extract_segments.py b/instance_segmentation/garfvdb/extract_segments.py index a8c8d47..f93ffb5 100644 --- a/instance_segmentation/garfvdb/extract_segments.py +++ b/instance_segmentation/garfvdb/extract_segments.py @@ -14,7 +14,7 @@ -r frgs_logs/safety_park_1/checkpoints/00024800/reconstruct_ckpt.pt \\ -o segments/ \\ --n 5 \\ - --scale 0.1 + --cluster-scale 0.1 """ from __future__ import annotations @@ -29,8 +29,7 @@ from fvdb import GaussianSplat3d from scipy.spatial import cKDTree -# Import directly to avoid fvdb_reality_capture.tools.__init__ pulling optional deps (e.g. DLNR). -from fvdb_reality_capture.tools._filter_splats import ( +from fvdb_reality_capture.tools import ( filter_splats_above_scale, filter_splats_by_mean_percentile, filter_splats_by_opacity_percentile, @@ -163,8 +162,8 @@ def rip_segments( reconstruction_path: Path, out_dir: Path, n: int, - scale: float, - scale_is_fraction_of_max: bool, + cluster_scale: float, + cluster_scale_unit: Literal["fraction", "world"], seed: int, device: str, min_splat_scale: float, @@ -220,14 +219,17 @@ def rip_segments( segmentation_model = runner.model max_scale = float(segmentation_model.max_grouping_scale.item()) - scale_abs = float(scale) * max_scale if scale_is_fraction_of_max else float(scale) + if cluster_scale_unit == "fraction": + scale_abs = float(cluster_scale) * max_scale + else: + scale_abs = float(cluster_scale) logger.info("Segmentation model max scale: %.6f", max_scale) logger.info("Clustering at scale: %.6f", scale_abs) clustering_gs_model = gs_model subsample_mask: torch.Tensor | None = None if max_gaussians_for_clustering > 0 and gs_model.num_gaussians > max_gaussians_for_clustering: - logger.warning( + logger.info( "Scene has %s gaussians (> %s); subsampling for clustering, then mapping labels back.", f"{gs_model.num_gaussians:,}", f"{max_gaussians_for_clustering:,}", @@ -296,7 +298,7 @@ def rip_segments( ) if not cluster_splats: - raise RuntimeError("No clusters remaining after filtering; relax filters or try a different --scale.") + raise RuntimeError("No clusters remaining after filtering; relax filters or try a different --cluster-scale.") labels = sorted(cluster_splats.keys()) n_to_export = min(n, len(labels)) @@ -373,16 +375,19 @@ def main() -> None: parser.add_argument("--n", type=int, default=10, help="Number of segments to export") parser.add_argument( - "--scale", + "--cluster-scale", type=float, default=0.1, - help="Clustering scale (absolute or fraction of max; see --scale-is-fraction-of-max)", + help="Scale passed to GarfVDB affinity clustering (see --cluster-scale-unit)", ) parser.add_argument( - "--scale-is-fraction-of-max", - action=argparse.BooleanOptionalAction, - default=True, - help="Interpret --scale as a fraction of model.max_grouping_scale", + "--cluster-scale-unit", + choices=["fraction", "world"], + default="fraction", + help=( + "How to interpret --cluster-scale: 'fraction' multiplies by max_grouping_scale " + "(e.g. 0.1 = 10%% of max); 'world' treats the value as scene world units" + ), ) parser.add_argument("--seed", type=int, default=42, help="Random seed for sampling/subsampling") parser.add_argument("--device", type=str, default="cuda", help="Torch device") @@ -392,7 +397,7 @@ def main() -> None: "--min-splat-scale", type=float, default=0.1, - help="Drop Gaussians with scale below this value (0 disables)", + help="Drop Gaussians whose max axis scale exceeds this fraction of scene extent (0 disables)", ) parser.add_argument( "--opacity-percentile", @@ -412,7 +417,7 @@ def main() -> None: "--min-cluster-gaussians", type=int, default=200, - help="Drop clusters smaller than this (0 disables)", + help="Drop clusters with fewer than this many member Gaussians (0 disables)", ) parser.add_argument( "--filter-high-variance", @@ -446,8 +451,8 @@ def main() -> None: reconstruction_path=args.reconstruction_path, out_dir=args.out_dir, n=args.n, - scale=args.scale, - scale_is_fraction_of_max=args.scale_is_fraction_of_max, + cluster_scale=args.cluster_scale, + cluster_scale_unit=args.cluster_scale_unit, seed=args.seed, device=args.device, min_splat_scale=args.min_splat_scale, diff --git a/instance_segmentation/garfvdb/garfvdb_environment.yml b/instance_segmentation/garfvdb/garfvdb_environment.yml index fb748fd..0bf58ef 100644 --- a/instance_segmentation/garfvdb/garfvdb_environment.yml +++ b/instance_segmentation/garfvdb/garfvdb_environment.yml @@ -1,4 +1,4 @@ -name: fvdb_garfvdb +name: fvdb_garfvdb_test channels: - conda-forge - nodefaults diff --git a/segmentation_extraction/README.md b/segmentation_extraction/README.md new file mode 100644 index 0000000..dde5113 --- /dev/null +++ b/segmentation_extraction/README.md @@ -0,0 +1,48 @@ +# Segment Mesh Extraction + +Extract a submesh from a full-scene mesh using a Gaussian splat segment as a spatial mask. + +This tool is **segmentation-method agnostic**: the segment splat PLY can come from +any segmentation method provided in fvdb-examples, manual cropping, or any other pipeline that +produces a Gaussian splat `.ply` for the region of interest. + +## Typical workflow + +1. Create a 3D Gaussian splat of a real-world scene using `frgs reconstruct` (fvdb-reality-capture) or a similar Gaussian splatting method. +2. Create a mesh from the splat using `frgs mesh-dlnr` or similar. +3. Run a segmentation method like GarfVDB or LangSplatV2 to get a segment of the splat (in Gaussian splat PLY format) for the object or region you care about. +4. Use `extract_mesh_segments.py` to rip out the part of the larger mesh that corresponds with your chosen segment. +5. Optionally, use the segmented splat (in Gaussian splat PLY format) and mesh pair to make a USDZ with `frgs convert`. The USDZ can then be used for downstream simulation in Isaac Sim or other similar tools. + +Ripping objects from large scenes can result in holes where the object was lying on a surface. Additionally, if the mesh isn't watertight, robots and other meshes can get stuck or fall through the mesh. These issues are fixed by using a harmonic fill method to fill the gap and a watertight remeshing step to fill in smaller holes and solidify the mesh. Use `--no-gap-fill` to skip this step. + +## Installation + +```bash +conda env create -f segmentation_extraction_environment.yml +conda activate fvdb_segment_mesh +pip install -e . +``` + +## Usage + +```bash +python extract_mesh_segments.py \ + --input-mesh /path/to/full_scene_mesh.ply \ + --input-splat /path/to/segment_splats.ply \ + --output-path /path/to/segment_mesh.ply +``` + +### Optional Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--distance-threshold` | `0.5` | Max distance (world units) from segment Gaussians to include mesh vertices | +| `--no-gap-fill` | off | Skip harmonic hole fill and watertight remeshing | +| `--resolution` | `20000` | Octree resolution for `make_mesh_watertight` (auto-capped for small segments) | +| `--device` | `cuda` | Device for loading the segment splat PLY | + +## Related examples + +- [GarfVDB](../instance_segmentation/garfvdb/) — instance segmentation +- [LangSplatV2](../open_vocabulary_segmentation/langsplatv2/) — semantic segmentation diff --git a/segmenation_extraction/extract_mesh_for_segments.py b/segmentation_extraction/extract_mesh_segments.py similarity index 81% rename from segmenation_extraction/extract_mesh_for_segments.py rename to segmentation_extraction/extract_mesh_segments.py index f6aeece..49c28d4 100644 --- a/segmenation_extraction/extract_mesh_for_segments.py +++ b/segmentation_extraction/extract_mesh_segments.py @@ -3,10 +3,18 @@ # SPDX-License-Identifier: Apache-2.0 # """ -Pulls mesh segment matching GS segment from a large mesh -Requires frgs mesh-dlnr or similar to be run on full scene first -Optionally closes holes via harmonic Laplacian fill then make_mesh_watertight -Output can be used as input for fvdb-reality-capture/scripts/create_isaac_ready_files.py to make a USDZ +fVDB-examples provide several methods for Gaussian splat segmentation. +From these method you can get a new Gaussian splat PLY file containing an object or region of interest from the original scene. +A corresponding mesh is commonly used in Isaac Sim and other simulation tools when working with splats. +This method allows for ripping out submeshes corresponding to Gaussian splat segments in PLY format. +Both of which can be used to create a USDZ for downstream simulation in Isaac Sim or other similar tools. +When working with many segments it's faster to rip from one larger mesh corresponding to the original scene mesh than it is to create a new mesh for each segment. + +The segment splat PLY is segmentation-method agnostic (GarfVDB, manual crop, etc.). +The full-scene mesh is typically from an offline reconstruction (for example frgs mesh-dlnr). + +By default, boundary holes are closed with harmonic Laplacian fill and the patch is made +watertight for physics simulation. Use --no-gap-fill to export the raw vertex/face subset. """ from __future__ import annotations @@ -53,7 +61,11 @@ def _reproject_vertex_colors( def _has_vertex_colors(vertex_colors: np.ndarray | None) -> bool: """Return True if the mesh has a non-empty per-vertex color array.""" - return vertex_colors is not None and vertex_colors.size > 0 and vertex_colors.shape[0] > 0 + return ( + vertex_colors is not None + and vertex_colors.size > 0 + and vertex_colors.shape[0] > 0 + ) def _as_float_vertex_colors(colors: np.ndarray) -> np.ndarray: @@ -111,13 +123,16 @@ def extract_segment_mesh( tree = cKDTree(segment_means) # Find mesh vertices within distance threshold of any segment Gaussian - logger.info(f"Finding mesh vertices within {distance_threshold:.3f} units of segment...") + logger.info( + f"Finding mesh vertices within {distance_threshold:.3f} units of segment..." + ) distances, _ = tree.query(vertices, k=1, distance_upper_bound=distance_threshold) close_vertex_mask = distances < distance_threshold num_close_vertices = close_vertex_mask.sum() logger.info( - f"Found {num_close_vertices:,} vertices ({100 * num_close_vertices / len(vertices):.1f}%) " f"within threshold" + f"Found {num_close_vertices:,} vertices ({100 * num_close_vertices / len(vertices):.1f}%) " + f"within threshold" ) if num_close_vertices == 0: @@ -142,7 +157,8 @@ def extract_segment_mesh( if num_extracted_faces == 0: raise ValueError( - "No complete faces found within the distance threshold. " "Try increasing --distance-threshold." + "No complete faces found within the distance threshold. " + "Try increasing --distance-threshold." ) # Reindex faces to use new vertex indices @@ -151,7 +167,9 @@ def extract_segment_mesh( # Extract the corresponding vertices and colors extracted_vertices = vertices[close_vertex_mask] extracted_colors = ( - _normalize_vertex_colors(vertex_colors[close_vertex_mask]) if _has_vertex_colors(vertex_colors) else None + _as_float_vertex_colors(vertex_colors[close_vertex_mask]) + if _has_vertex_colors(vertex_colors) + else None ) if not no_gap_fill: @@ -162,7 +180,9 @@ def extract_segment_mesh( logger.info("Harmonic Laplacian gap fill on extracted patch...") num_faces_before = len(extracted_faces_reindexed) num_vertices_before = len(extracted_vertices) - extracted_vertices, extracted_faces_reindexed = fill_mesh_gaps(extracted_vertices, extracted_faces_reindexed) + extracted_vertices, extracted_faces_reindexed = fill_mesh_gaps( + extracted_vertices, extracted_faces_reindexed + ) if len(extracted_faces_reindexed) != num_faces_before: logger.info( "Harmonic fill added %d faces and %d vertices (%d -> %d faces)", @@ -171,7 +191,9 @@ def extract_segment_mesh( num_faces_before, len(extracted_faces_reindexed), ) - effective_resolution = int(min(resolution, max(2_000, len(extracted_faces_reindexed) // 2))) + effective_resolution = int( + min(resolution, max(2_000, len(extracted_faces_reindexed) // 2)) + ) if effective_resolution != resolution: logger.info( "Capped watertight resolution %d -> %d for segment size", @@ -195,7 +217,9 @@ def extract_segment_mesh( ) # Reproject colors onto the new mesh vertices if colors are available if color_source_colors is not None: - extracted_colors = _reproject_vertex_colors(extracted_vertices, color_source_vertices, color_source_colors) + extracted_colors = _reproject_vertex_colors( + extracted_vertices, color_source_vertices, color_source_colors + ) # Save the extracted mesh output_path.parent.mkdir(parents=True, exist_ok=True) @@ -209,7 +233,9 @@ def extract_segment_mesh( extracted_colors, ) else: - pcu.save_mesh_vf(str(output_path), extracted_vertices, extracted_faces_reindexed) + pcu.save_mesh_vf( + str(output_path), extracted_vertices, extracted_faces_reindexed + ) logger.info( f"Successfully saved mesh with {len(extracted_vertices):,} vertices " @@ -254,7 +280,10 @@ def fill_mesh_gaps( vertices = np.vstack([vertices, centroid]) cap_faces = np.array( - [[cap_idx, int(loop[i]), int(loop[(i + 1) % loop.size])] for i in range(loop.size)], + [ + [cap_idx, int(loop[i]), int(loop[(i + 1) % loop.size])] + for i in range(loop.size) + ], dtype=np.int32, ) faces = np.vstack([faces, cap_faces]) @@ -278,10 +307,17 @@ def main() -> None: logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") parser = argparse.ArgumentParser( - description="Segment full scene mesh based on a PLY Gaussian Splat segment", + description=( + "Extract a submesh from a full-scene mesh using a Gaussian splat segment " + "as a spatial mask. Segment PLY can come from any segmentation pipeline." + ), + ) + parser.add_argument( + "--input-splat", type=Path, help="Input splat segment file (PLY format)" + ) + parser.add_argument( + "--input-mesh", type=Path, help="Input full scene mesh file (PLY/OBJ format)" ) - parser.add_argument("--input-splat", type=Path, help="Input splat segment file (PLY format)") - parser.add_argument("--input-mesh", type=Path, help="Input full scene mesh file (PLY/OBJ format)") parser.add_argument("--output-path", type=Path, required=True, help="Output path") parser.add_argument( "--no-gap-fill", diff --git a/segmentation_extraction/pyproject.toml b/segmentation_extraction/pyproject.toml new file mode 100644 index 0000000..55ae93f --- /dev/null +++ b/segmentation_extraction/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "segment-mesh-extraction" +version = "0.1.0" +description = "Extract a scene mesh patch from a Gaussian splat segment" +requires-python = ">=3.11" +dependencies = [ + "fvdb-core>=0.4.2,<0.5.0", + "libigl", + "point-cloud-utils", + "scipy", + "numpy", +] + +[project.scripts] +extract-mesh-segments = "extract_mesh_segments:main" + +[tool.setuptools] +py-modules = ["extract_mesh_segments"] diff --git a/segmentation_extraction/segmentation_extraction_environment.yml b/segmentation_extraction/segmentation_extraction_environment.yml new file mode 100644 index 0000000..e1ee69f --- /dev/null +++ b/segmentation_extraction/segmentation_extraction_environment.yml @@ -0,0 +1,17 @@ +name: fvdb_segment_mesh +channels: + - conda-forge + - nodefaults +dependencies: + - fvdb-core>=0.4.2,<0.5.0 + - cxx-compiler + - blas=*=mkl + - python + - pytorch-gpu=2.10.0 + - cuda-version>=12.9 + - pip + - scipy + - numpy + - pip: + - libigl + - point-cloud-utils From 136571e1dafb120a2e5bede856cd77839b43ff65 Mon Sep 17 00:00:00 2001 From: Zoe Date: Thu, 25 Jun 2026 14:37:00 -0400 Subject: [PATCH 6/9] formatting Signed-off-by: Zoe --- .../extract_mesh_segments.py | 49 +++++-------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/segmentation_extraction/extract_mesh_segments.py b/segmentation_extraction/extract_mesh_segments.py index 49c28d4..4cd1072 100644 --- a/segmentation_extraction/extract_mesh_segments.py +++ b/segmentation_extraction/extract_mesh_segments.py @@ -61,11 +61,7 @@ def _reproject_vertex_colors( def _has_vertex_colors(vertex_colors: np.ndarray | None) -> bool: """Return True if the mesh has a non-empty per-vertex color array.""" - return ( - vertex_colors is not None - and vertex_colors.size > 0 - and vertex_colors.shape[0] > 0 - ) + return vertex_colors is not None and vertex_colors.size > 0 and vertex_colors.shape[0] > 0 def _as_float_vertex_colors(colors: np.ndarray) -> np.ndarray: @@ -123,16 +119,13 @@ def extract_segment_mesh( tree = cKDTree(segment_means) # Find mesh vertices within distance threshold of any segment Gaussian - logger.info( - f"Finding mesh vertices within {distance_threshold:.3f} units of segment..." - ) + logger.info(f"Finding mesh vertices within {distance_threshold:.3f} units of segment...") distances, _ = tree.query(vertices, k=1, distance_upper_bound=distance_threshold) close_vertex_mask = distances < distance_threshold num_close_vertices = close_vertex_mask.sum() logger.info( - f"Found {num_close_vertices:,} vertices ({100 * num_close_vertices / len(vertices):.1f}%) " - f"within threshold" + f"Found {num_close_vertices:,} vertices ({100 * num_close_vertices / len(vertices):.1f}%) " f"within threshold" ) if num_close_vertices == 0: @@ -157,8 +150,7 @@ def extract_segment_mesh( if num_extracted_faces == 0: raise ValueError( - "No complete faces found within the distance threshold. " - "Try increasing --distance-threshold." + "No complete faces found within the distance threshold. " "Try increasing --distance-threshold." ) # Reindex faces to use new vertex indices @@ -167,9 +159,7 @@ def extract_segment_mesh( # Extract the corresponding vertices and colors extracted_vertices = vertices[close_vertex_mask] extracted_colors = ( - _as_float_vertex_colors(vertex_colors[close_vertex_mask]) - if _has_vertex_colors(vertex_colors) - else None + _as_float_vertex_colors(vertex_colors[close_vertex_mask]) if _has_vertex_colors(vertex_colors) else None ) if not no_gap_fill: @@ -180,9 +170,7 @@ def extract_segment_mesh( logger.info("Harmonic Laplacian gap fill on extracted patch...") num_faces_before = len(extracted_faces_reindexed) num_vertices_before = len(extracted_vertices) - extracted_vertices, extracted_faces_reindexed = fill_mesh_gaps( - extracted_vertices, extracted_faces_reindexed - ) + extracted_vertices, extracted_faces_reindexed = fill_mesh_gaps(extracted_vertices, extracted_faces_reindexed) if len(extracted_faces_reindexed) != num_faces_before: logger.info( "Harmonic fill added %d faces and %d vertices (%d -> %d faces)", @@ -191,9 +179,7 @@ def extract_segment_mesh( num_faces_before, len(extracted_faces_reindexed), ) - effective_resolution = int( - min(resolution, max(2_000, len(extracted_faces_reindexed) // 2)) - ) + effective_resolution = int(min(resolution, max(2_000, len(extracted_faces_reindexed) // 2))) if effective_resolution != resolution: logger.info( "Capped watertight resolution %d -> %d for segment size", @@ -217,9 +203,7 @@ def extract_segment_mesh( ) # Reproject colors onto the new mesh vertices if colors are available if color_source_colors is not None: - extracted_colors = _reproject_vertex_colors( - extracted_vertices, color_source_vertices, color_source_colors - ) + extracted_colors = _reproject_vertex_colors(extracted_vertices, color_source_vertices, color_source_colors) # Save the extracted mesh output_path.parent.mkdir(parents=True, exist_ok=True) @@ -233,9 +217,7 @@ def extract_segment_mesh( extracted_colors, ) else: - pcu.save_mesh_vf( - str(output_path), extracted_vertices, extracted_faces_reindexed - ) + pcu.save_mesh_vf(str(output_path), extracted_vertices, extracted_faces_reindexed) logger.info( f"Successfully saved mesh with {len(extracted_vertices):,} vertices " @@ -280,10 +262,7 @@ def fill_mesh_gaps( vertices = np.vstack([vertices, centroid]) cap_faces = np.array( - [ - [cap_idx, int(loop[i]), int(loop[(i + 1) % loop.size])] - for i in range(loop.size) - ], + [[cap_idx, int(loop[i]), int(loop[(i + 1) % loop.size])] for i in range(loop.size)], dtype=np.int32, ) faces = np.vstack([faces, cap_faces]) @@ -312,12 +291,8 @@ def main() -> None: "as a spatial mask. Segment PLY can come from any segmentation pipeline." ), ) - parser.add_argument( - "--input-splat", type=Path, help="Input splat segment file (PLY format)" - ) - parser.add_argument( - "--input-mesh", type=Path, help="Input full scene mesh file (PLY/OBJ format)" - ) + parser.add_argument("--input-splat", type=Path, help="Input splat segment file (PLY format)") + parser.add_argument("--input-mesh", type=Path, help="Input full scene mesh file (PLY/OBJ format)") parser.add_argument("--output-path", type=Path, required=True, help="Output path") parser.add_argument( "--no-gap-fill", From 9a0ae372bfeb6a13e0fa2d738641cac02fb6dea1 Mon Sep 17 00:00:00 2001 From: Zoe Date: Thu, 25 Jun 2026 14:50:00 -0400 Subject: [PATCH 7/9] comments Signed-off-by: Zoe --- .../garfvdb/extract_segments.py | 109 +++++++++++++----- .../extract_mesh_segments.py | 74 ++++++++---- 2 files changed, 131 insertions(+), 52 deletions(-) diff --git a/instance_segmentation/garfvdb/extract_segments.py b/instance_segmentation/garfvdb/extract_segments.py index f93ffb5..346bade 100644 --- a/instance_segmentation/garfvdb/extract_segments.py +++ b/instance_segmentation/garfvdb/extract_segments.py @@ -46,12 +46,17 @@ def load_segmentation_runner_from_checkpoint( gs_model_path: Path, device: str | torch.device = "cuda", ) -> GaussianSplatScaleConditionedSegmentation: - """Restore a segmentation runner from a training checkpoint. + """ + Restore a GARfVDB segmentation runner from a training checkpoint. + Args: - checkpoint_path: Path to the segmentation checkpoint. - gs_model: The Gaussian splat model to use for the segmentation. - gs_model_path: Path to the Gaussian splat reconstruction. - device: The device to use for the segmentation. + checkpoint_path (Path): Path to the segmentation checkpoint (``.pt`` / ``.pth``). + gs_model (GaussianSplat3d): Gaussian splat reconstruction to segment. + gs_model_path (Path): Path to the Gaussian splat reconstruction file. + device (str | torch.device): Torch device for loading weights and inference. + + Returns: + GaussianSplatScaleConditionedSegmentation: Eval-only segmentation runner. """ checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False) runner = GaussianSplatScaleConditionedSegmentation.from_state_dict( @@ -67,9 +72,14 @@ def load_segmentation_runner_from_checkpoint( def _is_gpu_oom_error(exc: BaseException) -> bool: - """Return whether an exception indicates GPU out-of-memory. + """ + Return whether an exception indicates GPU out-of-memory during clustering. + Args: - exc: The exception raised during clustering. + exc (BaseException): Exception raised while running GPU clustering code. + + Returns: + bool: ``True`` when ``exc`` looks like an OOM error. """ return "out_of_memory" in str(exc).lower() or isinstance(exc, MemoryError) @@ -79,11 +89,13 @@ def _drop_clusters( cluster_coherence: dict[int, float], keys: list[int], ) -> None: - """Remove clusters from the splat and coherence maps in place. + """ + Remove clusters from the splat and coherence maps in place. + Args: - cluster_splats: Cluster label to Gaussian splat mapping. - cluster_coherence: Cluster label to coherence score mapping. - keys: Cluster labels to remove. + cluster_splats (dict[int, GaussianSplat3d]): Cluster label to Gaussian splat mapping. + cluster_coherence (dict[int, float]): Cluster label to coherence score mapping. + keys (list[int]): Cluster labels to remove. """ for key in keys: cluster_splats.pop(key, None) @@ -96,12 +108,17 @@ def _subsample_gaussians( seed: int, device: torch.device, ) -> tuple[GaussianSplat3d, torch.Tensor]: - """Randomly subsample Gaussians for memory-bounded clustering. + """ + Randomly subsample Gaussians for memory-bounded clustering. + Args: - gs_model: Full-scene Gaussian splat model. - max_gaussians: Maximum number of Gaussians to keep. - seed: Random seed for reproducible subsampling. - device: Torch device for the returned mask. + gs_model (GaussianSplat3d): Full-scene Gaussian splat model. + max_gaussians (int): Maximum number of Gaussians to keep. + seed (int): Random seed for reproducible subsampling. + device (torch.device): Torch device for the returned mask tensor. + + Returns: + tuple[GaussianSplat3d, torch.Tensor]: Subsampled model and a boolean mask over the full scene. """ rng = np.random.default_rng(seed) indices = rng.choice(gs_model.num_gaussians, size=max_gaussians, replace=False) @@ -117,13 +134,18 @@ def _map_cluster_labels_to_full_scene( clustering_gs_model: GaussianSplat3d, device: torch.device, ) -> tuple[torch.Tensor, torch.Tensor]: - """Map cluster labels from a subsampled set back to the full scene. + """ + Map cluster labels from a subsampled Gaussian set back to the full scene. + Args: - cluster_labels_sub: Cluster labels on the subsampled Gaussians. - cluster_probs_sub: Cluster probabilities on the subsampled Gaussians. - gs_model: Full-scene Gaussian splat model. - clustering_gs_model: Subsampled model used for clustering. - device: Torch device for returned tensors. + cluster_labels_sub (torch.Tensor): Cluster labels on the subsampled Gaussians. + cluster_probs_sub (torch.Tensor): Cluster probabilities on the subsampled Gaussians. + gs_model (GaussianSplat3d): Full-scene Gaussian splat model. + clustering_gs_model (GaussianSplat3d): Subsampled model used for clustering. + device (torch.device): Torch device for returned tensors. + + Returns: + tuple[torch.Tensor, torch.Tensor]: Cluster labels and probabilities aligned to ``gs_model``. """ tree = cKDTree(clustering_gs_model.means.cpu().numpy()) _, nearest_indices = tree.query(gs_model.means.cpu().numpy(), k=1, workers=-1) @@ -136,11 +158,16 @@ def _filter_high_variance_clusters( cluster_coherence: dict[int, float], variance_threshold: float, ) -> list[int]: - """Find spatially incoherent clusters by normalized variance. + """ + Find spatially incoherent clusters by normalized positional variance. + Args: - cluster_splats: Cluster label to Gaussian splat mapping. - cluster_coherence: Cluster label to coherence score mapping. - variance_threshold: Normalized variance cutoff (variance / extent^2). + cluster_splats (dict[int, GaussianSplat3d]): Cluster label to Gaussian splat mapping. + cluster_coherence (dict[int, float]): Cluster label to coherence score mapping. + variance_threshold (float): Normalized variance cutoff (variance divided by extent squared). + + Returns: + list[int]: Cluster labels that exceed ``variance_threshold``. """ removed: list[int] = [] for label, splat in list(cluster_splats.items()): @@ -176,7 +203,33 @@ def rip_segments( max_gaussians_for_clustering: int, verbose: bool, ) -> None: - """Cluster Gaussians and export ``n`` segment PLY files.""" + """ + Cluster Gaussians at a chosen scale and export segment ``.ply`` Gaussian splat scenes. + + Loads a reconstruction and GARfVDB segmentation checkpoint, optionally pre-filters splats, + computes affinity features at ``cluster_scale``, clusters them, filters clusters, then writes + up to ``n`` segment ``.ply`` files (one per selected cluster). + + Args: + segmentation_path (Path): GARfVDB segmentation checkpoint (``.pt`` / ``.pth``). + reconstruction_path (Path): Gaussian splat reconstruction (``.pt`` / ``.ply``). + out_dir (Path): Output directory for segment ``.ply`` files. + n (int): Maximum number of segments to export. + cluster_scale (float): Scale passed to affinity clustering (see ``cluster_scale_unit``). + cluster_scale_unit (Literal["fraction", "world"]): Whether ``cluster_scale`` is a fraction of + ``max_grouping_scale`` or a world-unit value. + seed (int): Random seed for subsampling and cluster selection. + device (str): Torch device (for example ``"cuda"``). + min_splat_scale (float): Drop Gaussians larger than this fraction of scene extent (``0`` disables). + opacity_percentile (float): Keep Gaussians above this opacity percentile (``0`` disables). + mean_percentile (tuple[float, ...]): Per-channel mean percentiles for splat pre-filtering. + min_cluster_gaussians (int): Drop clusters with fewer member Gaussians than this (``0`` disables). + filter_high_variance (bool): Remove spatially incoherent clusters before export. + variance_threshold (float): Normalized variance cutoff when ``filter_high_variance`` is enabled. + sample_by (Literal["random", "coherence"]): How to choose which clusters to export. + max_gaussians_for_clustering (int): Subsample before clustering when the scene is larger (``0`` disables). + verbose (bool): Enable debug logging. + """ log_level = logging.DEBUG if verbose else logging.INFO logging.basicConfig(level=log_level, format="%(levelname)s : %(message)s") @@ -345,7 +398,7 @@ def rip_segments( def main() -> None: - """Parse CLI arguments and run segment extraction.""" + """Parse CLI arguments and run :func:`rip_segments`.""" parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, diff --git a/segmentation_extraction/extract_mesh_segments.py b/segmentation_extraction/extract_mesh_segments.py index 4cd1072..7788f24 100644 --- a/segmentation_extraction/extract_mesh_segments.py +++ b/segmentation_extraction/extract_mesh_segments.py @@ -34,13 +34,16 @@ def _reproject_vertex_colors( source_vertices: np.ndarray, source_colors: np.ndarray, ) -> np.ndarray: - """Copy colors onto new mesh vertices via nearest-neighbor lookup. + """ + Copy vertex colors onto a new mesh via nearest-neighbor lookup. + Args: - target_vertices: The vertices of the new mesh. - source_vertices: The vertices of the source mesh. - source_colors: The colors of the source mesh. + target_vertices (np.ndarray): Vertex positions of the destination mesh, shape ``(#V, 3)``. + source_vertices (np.ndarray): Vertex positions of the source mesh, shape ``(#V, 3)``. + source_colors (np.ndarray): Per-vertex colors on the source mesh, shape ``(#V, 3)`` or ``(#V, 4)``. + Returns: - The colors of the new mesh. + np.ndarray: Colors for ``target_vertices``, same channel count as ``source_colors``. """ finite_mask = np.isfinite(source_vertices).all(axis=1) if not finite_mask.all(): @@ -60,12 +63,28 @@ def _reproject_vertex_colors( def _has_vertex_colors(vertex_colors: np.ndarray | None) -> bool: - """Return True if the mesh has a non-empty per-vertex color array.""" + """ + Return whether a mesh carries a non-empty per-vertex color array. + + Args: + vertex_colors (np.ndarray | None): Per-vertex colors loaded from mesh I/O, or ``None``. + + Returns: + bool: ``True`` when ``vertex_colors`` has at least one vertex color. + """ return vertex_colors is not None and vertex_colors.size > 0 and vertex_colors.shape[0] > 0 def _as_float_vertex_colors(colors: np.ndarray) -> np.ndarray: - """Convert vertex colors to float RGB(A) in [0, 1] for point_cloud_utils I/O.""" + """ + Convert vertex colors to float RGB(A) in ``[0, 1]`` for ``point_cloud_utils`` I/O. + + Args: + colors (np.ndarray): Input colors as ``uint8`` or float; values above ``1.0`` are treated as 8-bit. + + Returns: + np.ndarray: Float32 colors clipped to ``[0, 1]``. + """ colors = np.asarray(colors) if colors.dtype == np.uint8: colors = colors.astype(np.float64) / 255.0 @@ -87,17 +106,22 @@ def extract_segment_mesh( device: str, verbose: bool, ) -> None: - """Extract a mesh region corresponding to a Gaussian segment. + """ + Extract a mesh patch from a full-scene mesh using a Gaussian splat segment as a spatial mask. + + Mesh vertices within ``distance_threshold`` of any segment Gaussian are kept. Faces whose + three vertices all lie in that set are exported. Unless ``no_gap_fill`` is set, boundary holes + are closed with harmonic Laplacian fill and the patch is made watertight for simulation. Args: - full_mesh_path: Path to the full scene mesh (.ply). - segment_ply_path: Path to the segment Gaussian splats (.ply). - output_path: Path to save the extracted segment mesh. - distance_threshold: Maximum distance from segment Gaussians to include mesh vertices (in world units). - no_gap_fill: Skip watertight + harmonic gap fill on the extracted patch - resolution: Manifold octree resolution for pcu.make_mesh_watertight (capped to segment size) - device: Device for loading Gaussian splats. - verbose: Enable verbose logging. + full_mesh_path (Path): Path to the full-scene triangle mesh (``.ply`` or other ``point_cloud_utils`` format). + segment_ply_path (Path): Path to the segment Gaussian splat scene (``.ply``). + output_path (Path): Output path for the extracted segment mesh. + distance_threshold (float): Maximum distance in world units from segment Gaussians to include mesh vertices. + no_gap_fill (bool): When ``True``, skip harmonic hole fill and watertight remeshing. + resolution (int): Octree resolution passed to ``pcu.make_mesh_watertight`` (capped to segment size). + device (str): Torch device for loading the segment splat PLY (for example ``"cuda"``). + verbose (bool): Enable debug logging. """ log_level = logging.DEBUG if verbose else logging.INFO logging.basicConfig(level=log_level, format="%(levelname)s : %(message)s") @@ -231,19 +255,20 @@ def fill_mesh_gaps( *, k: int = 1, ) -> tuple[np.ndarray, np.ndarray]: - """Close boundary holes with fan caps and harmonic Laplacian fairing. + """ + Close boundary holes with fan caps and harmonic Laplacian fairing. - Each open boundary loop is triangulated with a Steiner vertex at the loop - centroid. Cap vertex positions are then relaxed by solving a k-harmonic - PDE (k=1: Laplacian, k=2: biharmonic) with the original mesh vertices fixed. + Each open boundary loop is triangulated with a Steiner vertex at the loop centroid. Cap vertex + positions are relaxed by solving a k-harmonic PDE (``k=1``: Laplacian, ``k=2``: biharmonic) + with the original mesh vertices fixed. Args: - vertices: Mesh vertex positions, shape (#V, 3). - faces: Triangle indices, shape (#F, 3). - k: Order of the harmonic operator (1 = harmonic/Laplacian, 2 = biharmonic). + vertices (np.ndarray): Mesh vertex positions, shape ``(#V, 3)``. + faces (np.ndarray): Triangle indices, shape ``(#F, 3)``. + k (int): Order of the harmonic operator (``1`` = harmonic/Laplacian, ``2`` = biharmonic). Returns: - Updated (vertices, faces) with holes capped. + tuple[np.ndarray, np.ndarray]: Updated ``(vertices, faces)`` with holes capped. """ vertices = np.asarray(vertices, dtype=np.float64) faces = np.asarray(faces, dtype=np.int32) @@ -283,6 +308,7 @@ def fill_mesh_gaps( def main() -> None: + """Parse CLI arguments and run :func:`extract_segment_mesh`.""" logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") parser = argparse.ArgumentParser( From 382290e33bee70c19ad18cb4a04d85eb024469c0 Mon Sep 17 00:00:00 2001 From: zlalena Date: Thu, 25 Jun 2026 14:37:39 -0400 Subject: [PATCH 8/9] Update garfvdb_environment.yml Signed-off-by: zlalena --- instance_segmentation/garfvdb/garfvdb_environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instance_segmentation/garfvdb/garfvdb_environment.yml b/instance_segmentation/garfvdb/garfvdb_environment.yml index 0bf58ef..fb748fd 100644 --- a/instance_segmentation/garfvdb/garfvdb_environment.yml +++ b/instance_segmentation/garfvdb/garfvdb_environment.yml @@ -1,4 +1,4 @@ -name: fvdb_garfvdb_test +name: fvdb_garfvdb channels: - conda-forge - nodefaults From 110e411957960c18d1e3171b55d0ebdbe0fb8007 Mon Sep 17 00:00:00 2001 From: Zoe Date: Thu, 25 Jun 2026 14:55:01 -0400 Subject: [PATCH 9/9] rename to add mesh Signed-off-by: Zoe --- {segmentation_extraction => segment_mesh_extraction}/README.md | 2 +- .../extract_mesh_segments.py | 0 .../pyproject.toml | 0 .../segment_mesh_extraction_environment.yml | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename {segmentation_extraction => segment_mesh_extraction}/README.md (97%) rename {segmentation_extraction => segment_mesh_extraction}/extract_mesh_segments.py (100%) rename {segmentation_extraction => segment_mesh_extraction}/pyproject.toml (100%) rename segmentation_extraction/segmentation_extraction_environment.yml => segment_mesh_extraction/segment_mesh_extraction_environment.yml (100%) diff --git a/segmentation_extraction/README.md b/segment_mesh_extraction/README.md similarity index 97% rename from segmentation_extraction/README.md rename to segment_mesh_extraction/README.md index dde5113..ad34772 100644 --- a/segmentation_extraction/README.md +++ b/segment_mesh_extraction/README.md @@ -19,7 +19,7 @@ Ripping objects from large scenes can result in holes where the object was lying ## Installation ```bash -conda env create -f segmentation_extraction_environment.yml +conda env create -f segment_mesh_extraction_environment.yml conda activate fvdb_segment_mesh pip install -e . ``` diff --git a/segmentation_extraction/extract_mesh_segments.py b/segment_mesh_extraction/extract_mesh_segments.py similarity index 100% rename from segmentation_extraction/extract_mesh_segments.py rename to segment_mesh_extraction/extract_mesh_segments.py diff --git a/segmentation_extraction/pyproject.toml b/segment_mesh_extraction/pyproject.toml similarity index 100% rename from segmentation_extraction/pyproject.toml rename to segment_mesh_extraction/pyproject.toml diff --git a/segmentation_extraction/segmentation_extraction_environment.yml b/segment_mesh_extraction/segment_mesh_extraction_environment.yml similarity index 100% rename from segmentation_extraction/segmentation_extraction_environment.yml rename to segment_mesh_extraction/segment_mesh_extraction_environment.yml