""" GaussianSplatToMesh — Convert PLY_DATA (Gaussian Splat point cloud) to TRIMESH. Uses scipy + trimesh + sklearn (no Open3D dependency) for surface reconstruction. Compatible with Python 3.13 and ComfyUI v0.22.0. Supported methods: - marching_cubes: Volumetric marching cubes via scipy/skimage (recommended) - poisson_like: Screened-Poisson-like surface reconstruction - alpha_shape: Alpha shape triangulation via scipy Delaunay - ball_pivoting: Approximate ball-pivoting via local Delaunay patches v2.0 — Major quality improvements: - Proper vertex color transfer with KNN interpolation - Improved marching cubes with KDE-based density field - Poisson-like surface reconstruction method - Voxel grid downsampling (preserves spatial structure) - Density-based outlier removal - Higher default max_points (200K) v2.1 — Color extraction fix: - Fixed bug where Strategy 2 (pts3d_filtered) left colors as None - Added multiple color source fallbacks: images, features_dc, sh_coeffs/shs_0 - Proper filter_mask application for color data - Added mesh normal computation after surface reconstruction """ import numpy as np import torch import trimesh as Trimesh from scipy.spatial import Delaunay, cKDTree # ── SH coefficient C0 for degree-0 spherical harmonics ────────────────────── SH_C0 = 0.28209479177387814 def _extract_points_and_colors(ply_data): """ Extract 3D point positions and RGB colors from PLY_DATA dict. """ points = None colors = None # ── Strategy 1: Use splats (Gaussian Splat parameters) ──────────────── splats = ply_data.get("splats") if splats is not None and "means" in splats: means = splats["means"] if isinstance(means, torch.Tensor): if means.dim() == 3: means = means[0] points = means.detach().cpu().float().numpy() # Extract colors from SH coefficients sh = splats.get("sh") if sh is not None and isinstance(sh, torch.Tensor): if sh.dim() == 4: sh = sh[0] if sh.dim() == 3: sh_dc = sh[:, 0, :] elif sh.dim() == 2: sh_dc = sh else: sh_dc = sh.reshape(-1, 3) rgb = 0.5 + SH_C0 * sh_dc.detach().cpu().float().numpy() colors = np.clip(rgb, 0.0, 1.0) if colors is None: raw_colors = splats.get("colors") if raw_colors is not None and isinstance(raw_colors, torch.Tensor): if raw_colors.dim() == 3: raw_colors = raw_colors[0] colors = raw_colors.detach().cpu().float().numpy() if colors.max() > 1.0: colors = colors / 255.0 colors = np.clip(colors, 0.0, 1.0) # ── Strategy 2: Use pts3d_filtered ──────────────────────────────────── if points is None: pts_filtered = ply_data.get("pts3d_filtered") if pts_filtered is not None and isinstance(pts_filtered, torch.Tensor): points = pts_filtered.detach().cpu().float().numpy().reshape(-1, 3) print(f"[GaussianSplatToMesh] Strategy 2: pts3d_filtered -> {points.shape[0]} points") # ── Strategy 2.5: Color recovery when points found but colors missing ─ # This handles the case where Strategy 2 found points via pts3d_filtered # but colors were not extracted (the original bug). if points is not None and colors is None: # Color source 1: images key images = ply_data.get("images") if images is not None and isinstance(images, torch.Tensor): imgs = images[0] if images.dim() == 5 else images if imgs.dim() == 4 and imgs.shape[1] == 3: # [B, 3, H, W] imgs = imgs.permute(0, 2, 3, 1) # -> [B, H, W, 3] all_colors = imgs.detach().cpu().float().numpy().reshape(-1, 3) if all_colors.max() > 1.0: all_colors = all_colors / 255.0 # Apply filter_mask if available fmask = ply_data.get("filter_mask") if fmask is not None and isinstance(fmask, torch.Tensor): mask_np = fmask.detach().cpu().numpy().astype(bool).reshape(-1) if mask_np.shape[0] == all_colors.shape[0]: all_colors = all_colors[mask_np] if all_colors.shape[0] == points.shape[0]: colors = np.clip(all_colors, 0.0, 1.0) print(f"[GaussianSplatToMesh] Colors from images: {colors.shape}, " f"range [{colors.min():.3f}, {colors.max():.3f}]") # Color source 2: features_dc (SH degree-0 coefficients) if colors is None: features_dc = ply_data.get("features_dc") if features_dc is not None and isinstance(features_dc, torch.Tensor): sh0 = features_dc.detach().cpu().float().numpy() if sh0.ndim == 3: # [N, 1, 3] sh0 = sh0.squeeze(1) if sh0.ndim == 2 and sh0.shape[1] >= 3: rgb = sh0[:, :3] * SH_C0 + 0.5 # Apply filter_mask if available fmask = ply_data.get("filter_mask") if fmask is not None and isinstance(fmask, torch.Tensor): mask_np = fmask.detach().cpu().numpy().astype(bool).reshape(-1) if mask_np.shape[0] == rgb.shape[0]: rgb = rgb[mask_np] if rgb.shape[0] == points.shape[0]: colors = np.clip(rgb, 0.0, 1.0) print(f"[GaussianSplatToMesh] Colors from features_dc (SH0): {colors.shape}, " f"range [{colors.min():.3f}, {colors.max():.3f}]") # Color source 3: sh_coeffs or shs_0 (alternative SH keys) if colors is None: for key in ["sh_coeffs", "shs_0"]: sh_data = ply_data.get(key) if sh_data is not None and isinstance(sh_data, torch.Tensor): sh_np = sh_data.detach().cpu().float().numpy() if sh_np.ndim == 3: sh_np = sh_np.squeeze(1) if sh_np.ndim == 2 and sh_np.shape[1] >= 3: rgb = sh_np[:, :3] * SH_C0 + 0.5 fmask = ply_data.get("filter_mask") if fmask is not None and isinstance(fmask, torch.Tensor): mask_np = fmask.detach().cpu().numpy().astype(bool).reshape(-1) if mask_np.shape[0] == rgb.shape[0]: rgb = rgb[mask_np] if rgb.shape[0] == points.shape[0]: colors = np.clip(rgb, 0.0, 1.0) print(f"[GaussianSplatToMesh] Colors from {key} (SH0): {colors.shape}") break # ── Strategy 3: Use pts3d (full point map) ──────────────────────────── if points is None: pts3d = ply_data.get("pts3d") if pts3d is not None and isinstance(pts3d, torch.Tensor): pts = pts3d[0] points = pts.detach().cpu().float().numpy().reshape(-1, 3) images = ply_data.get("images") if images is not None and isinstance(images, torch.Tensor): imgs = images[0] if imgs.shape[-1] != 3 and imgs.shape[1] == 3: imgs = imgs.permute(0, 2, 3, 1) colors = imgs.detach().cpu().float().numpy().reshape(-1, 3) if colors.max() > 1.0: colors = colors / 255.0 colors = np.clip(colors, 0.0, 1.0) fmask = ply_data.get("filter_mask") if fmask is not None and isinstance(fmask, torch.Tensor): mask_np = fmask.detach().cpu().numpy().astype(bool).reshape(-1) if mask_np.shape[0] == points.shape[0]: points = points[mask_np] if colors is not None and colors.shape[0] == mask_np.shape[0]: colors = colors[mask_np] if points is None: raise ValueError( "PLY_DATA does not contain valid point data. " "Expected keys: 'splats.means', 'pts3d_filtered', or 'pts3d'" ) # Log available keys for debugging when colors are missing if colors is None: available_keys = [k for k in ply_data.keys() if ply_data[k] is not None] print(f"[GaussianSplatToMesh] DEBUG: Available PLY_DATA keys: {available_keys}") for k in available_keys: v = ply_data[k] if isinstance(v, torch.Tensor): print(f"[GaussianSplatToMesh] DEBUG: {k}: Tensor shape={v.shape}, dtype={v.dtype}") elif isinstance(v, dict): print(f"[GaussianSplatToMesh] DEBUG: {k}: dict with keys={list(v.keys())}") else: print(f"[GaussianSplatToMesh] DEBUG: {k}: {type(v).__name__}") # Filter out NaN/Inf points valid = np.isfinite(points).all(axis=1) if colors is not None and colors.shape[0] == points.shape[0]: colors = colors[valid] points = points[valid] if colors is None or colors.shape[0] != points.shape[0]: print("[GaussianSplatToMesh] WARNING: Color data missing or mismatched, using default gray") colors = np.ones((points.shape[0], 3), dtype=np.float64) * 0.7 print(f"[GaussianSplatToMesh] Extracted {points.shape[0]} points, " f"color range: [{colors.min():.3f}, {colors.max():.3f}]") return points.astype(np.float64), colors.astype(np.float64) def _estimate_normals_fast(points, k=20): """Fast vectorized normal estimation using PCA on k-nearest neighbors.""" N = points.shape[0] tree = cKDTree(points) k = min(k, N) _, indices = tree.query(points, k=k) normals = np.zeros((N, 3), dtype=np.float64) batch_size = 10000 for start in range(0, N, batch_size): end = min(start + batch_size, N) batch_indices = indices[start:end] neighbors = points[batch_indices] centroids = neighbors.mean(axis=1) centered = neighbors - centroids[:, np.newaxis, :] covs = np.einsum('bki,bkj->bij', centered, centered) for i in range(end - start): try: eigenvalues, eigenvectors = np.linalg.eigh(covs[i]) normals[start + i] = eigenvectors[:, 0] except np.linalg.LinAlgError: normals[start + i] = [0, 0, 1] # Orient normals outward centroid = points.mean(axis=0) directions = points - centroid dot_products = np.sum(normals * directions, axis=1) flip_mask = dot_products < 0 normals[flip_mask] = -normals[flip_mask] norms = np.linalg.norm(normals, axis=1, keepdims=True) norms = np.maximum(norms, 1e-10) normals = normals / norms return normals def _downsample_voxel(points, colors, voxel_size): """Voxel grid downsampling — preserves spatial structure.""" if np.isscalar(voxel_size): voxel_size_arr = np.array([voxel_size, voxel_size, voxel_size]) else: voxel_size_arr = np.asarray(voxel_size) voxel_indices = np.floor(points / voxel_size_arr).astype(np.int64) voxel_centers = (voxel_indices + 0.5) * voxel_size_arr voxel_dict = {} for i in range(points.shape[0]): key = tuple(voxel_indices[i]) dist_to_center = np.linalg.norm(points[i] - voxel_centers[i]) if key not in voxel_dict or dist_to_center < voxel_dict[key][1]: voxel_dict[key] = (i, dist_to_center) selected_indices = np.array([v[0] for v in voxel_dict.values()]) selected_indices.sort() return points[selected_indices], colors[selected_indices] def _downsample_points(points, colors, max_points=200000): """Downsample point cloud using voxel grid if too large.""" if points.shape[0] <= max_points: return points, colors bbox_extent = points.max(axis=0) - points.min(axis=0) volume = np.prod(np.maximum(bbox_extent, 1e-6)) target_voxel_size = (volume / max_points) ** (1.0 / 3.0) ds_points, ds_colors = _downsample_voxel(points, colors, target_voxel_size) if ds_points.shape[0] > max_points * 1.2: ratio = (ds_points.shape[0] / max_points) ** (1.0 / 3.0) ds_points, ds_colors = _downsample_voxel(points, colors, target_voxel_size * ratio) if ds_points.shape[0] > max_points: idx = np.random.choice(ds_points.shape[0], max_points, replace=False) idx.sort() ds_points, ds_colors = ds_points[idx], ds_colors[idx] print(f"[GaussianSplatToMesh] Voxel downsampled: {points.shape[0]} -> {ds_points.shape[0]} points") return ds_points, ds_colors def _remove_outliers(points, colors, nb_neighbors=20, std_ratio=2.0): """Remove statistical outliers from point cloud.""" if points.shape[0] < nb_neighbors + 1: return points, colors tree = cKDTree(points) k = min(nb_neighbors + 1, points.shape[0]) distances, _ = tree.query(points, k=k) mean_distances = distances[:, 1:].mean(axis=1) global_mean = mean_distances.mean() global_std = mean_distances.std() threshold = global_mean + std_ratio * global_std mask = mean_distances < threshold removed = points.shape[0] - mask.sum() print(f"[GaussianSplatToMesh] Outlier removal: {points.shape[0]} -> {mask.sum()} (removed {removed})") return points[mask], colors[mask] def _remove_outliers_density(points, colors, percentile=5): """Remove outliers based on local density.""" if points.shape[0] < 50: return points, colors tree = cKDTree(points) k = min(10, points.shape[0]) distances, _ = tree.query(points, k=k) local_density = 1.0 / (distances[:, 1:].mean(axis=1) + 1e-10) threshold = np.percentile(local_density, percentile) mask = local_density >= threshold removed = points.shape[0] - mask.sum() print(f"[GaussianSplatToMesh] Density filter: {points.shape[0]} -> {mask.sum()} (removed {removed})") return points[mask], colors[mask] def _transfer_colors_to_vertices(mesh_vertices, source_points, source_colors, k=5): """Transfer colors from source point cloud to mesh vertices using KNN interpolation.""" tree = cKDTree(source_points) k = min(k, source_points.shape[0]) distances, indices = tree.query(mesh_vertices, k=k) if k == 1: vert_colors = source_colors[indices.ravel()] else: distances = np.maximum(distances, 1e-10) weights = 1.0 / distances weights_sum = weights.sum(axis=1, keepdims=True) weights_normalized = weights / weights_sum neighbor_colors = source_colors[indices] vert_colors = np.sum(neighbor_colors * weights_normalized[:, :, np.newaxis], axis=1) vert_colors = np.clip(vert_colors, 0.0, 1.0) vert_colors_uint8 = (vert_colors * 255).astype(np.uint8) vert_colors_rgba = np.hstack([ vert_colors_uint8, np.full((vert_colors_uint8.shape[0], 1), 255, dtype=np.uint8) ]) return vert_colors_rgba def _alpha_shape_mesh(points, colors, alpha=2.0): """Create mesh using Delaunay triangulation with alpha shape filtering.""" print(f"[GaussianSplatToMesh] Running alpha shape (alpha={alpha})...") tri = Delaunay(points) tetrahedra = tri.simplices face_count = {} for tet in tetrahedra: for face in [ tuple(sorted([tet[0], tet[1], tet[2]])), tuple(sorted([tet[0], tet[1], tet[3]])), tuple(sorted([tet[0], tet[2], tet[3]])), tuple(sorted([tet[1], tet[2], tet[3]])), ]: face_count[face] = face_count.get(face, 0) + 1 surface_faces = [f for f, c in face_count.items() if c == 1] if alpha > 0: filtered_faces = [] for face in surface_faces: pts = points[list(face)] a = np.linalg.norm(pts[1] - pts[0]) b = np.linalg.norm(pts[2] - pts[1]) c = np.linalg.norm(pts[0] - pts[2]) s = (a + b + c) / 2 area = np.sqrt(max(s * (s - a) * (s - b) * (s - c), 0)) if area > 1e-10: circumradius = (a * b * c) / (4 * area) if circumradius < 1.0 / alpha: filtered_faces.append(face) surface_faces = filtered_faces if len(surface_faces) == 0: raise ValueError("Alpha shape produced no faces. Try adjusting alpha parameter.") faces_array = np.array(surface_faces, dtype=np.int64) vertex_colors_rgba = _transfer_colors_to_vertices(points, points, colors, k=1) mesh = Trimesh.Trimesh( vertices=points, faces=faces_array, vertex_colors=vertex_colors_rgba, process=False, ) mesh.remove_degenerate_faces() mesh.remove_duplicate_faces() print(f"[GaussianSplatToMesh] Alpha shape: {len(mesh.vertices)} verts, {len(mesh.faces)} faces") return mesh def _marching_cubes_mesh(points, colors, resolution=256, padding=0.1, sigma=1.5): """Create mesh using marching cubes on a KDE-based density field.""" print(f"[GaussianSplatToMesh] Running marching cubes (res={resolution}, sigma={sigma})...") from scipy.ndimage import gaussian_filter pmin = points.min(axis=0) pmax = points.max(axis=0) extent = pmax - pmin pad = extent * padding pmin -= pad pmax += pad extent = pmax - pmin voxel_size = extent / resolution grid = np.zeros((resolution, resolution, resolution), dtype=np.float32) indices = ((points - pmin) / voxel_size).astype(np.int32) indices = np.clip(indices, 0, resolution - 1) flat_indices = (indices[:, 0] * resolution * resolution + indices[:, 1] * resolution + indices[:, 2]) np.add.at(grid.ravel(), flat_indices, 1.0) # Splat to neighbors for smoother density for dx in [-1, 0, 1]: for dy in [-1, 0, 1]: for dz in [-1, 0, 1]: if dx == 0 and dy == 0 and dz == 0: continue shifted = indices + np.array([dx, dy, dz]) shifted = np.clip(shifted, 0, resolution - 1) flat_s = (shifted[:, 0] * resolution * resolution + shifted[:, 1] * resolution + shifted[:, 2]) w = 0.5 ** (abs(dx) + abs(dy) + abs(dz)) np.add.at(grid.ravel(), flat_s, w) grid = gaussian_filter(grid, sigma=sigma) nonzero = grid[grid > 0] if len(nonzero) == 0: raise ValueError("No points in voxel grid") iso_level = np.percentile(nonzero, 30) print(f"[GaussianSplatToMesh] Density: min={grid.min():.3f}, max={grid.max():.3f}, iso={iso_level:.3f}") try: from skimage.measure import marching_cubes verts, faces, normals_mc, _ = marching_cubes(grid, level=iso_level) except ImportError: raise ImportError("scikit-image required for marching_cubes. pip install scikit-image") verts = verts * voxel_size + pmin vertex_colors_rgba = _transfer_colors_to_vertices(verts, points, colors, k=5) mesh = Trimesh.Trimesh( vertices=verts, faces=faces, vertex_colors=vertex_colors_rgba, process=False, ) mesh.remove_degenerate_faces() mesh.remove_duplicate_faces() print(f"[GaussianSplatToMesh] Marching cubes: {len(mesh.vertices)} verts, {len(mesh.faces)} faces") return mesh def _poisson_like_mesh(points, colors, depth=8, scale=1.1): """Screened-Poisson-like surface reconstruction without Open3D.""" resolution = min(2 ** depth, 256) print(f"[GaussianSplatToMesh] Running Poisson-like (depth={depth}, res={resolution})...") from scipy.ndimage import gaussian_filter print("[GaussianSplatToMesh] Estimating normals...") normals = _estimate_normals_fast(points, k=min(20, points.shape[0])) pmin = points.min(axis=0) pmax = points.max(axis=0) center = (pmin + pmax) / 2 extent = (pmax - pmin) * scale pmin = center - extent / 2 pmax = center + extent / 2 voxel_size = extent / resolution grid = np.zeros((resolution, resolution, resolution), dtype=np.float32) weight_grid = np.zeros_like(grid) voxel_coords = ((points - pmin) / voxel_size).astype(np.int32) voxel_coords = np.clip(voxel_coords, 0, resolution - 1) splat_range = 2 bandwidth = np.mean(voxel_size) * 2.0 for i in range(points.shape[0]): ix, iy, iz = voxel_coords[i] for dx in range(-splat_range, splat_range + 1): for dy in range(-splat_range, splat_range + 1): for dz in range(-splat_range, splat_range + 1): nx, ny, nz = ix + dx, iy + dy, iz + dz if 0 <= nx < resolution and 0 <= ny < resolution and 0 <= nz < resolution: vc = pmin + (np.array([nx, ny, nz]) + 0.5) * voxel_size diff = vc - points[i] signed_dist = np.dot(diff, normals[i]) dist_sq = np.sum(diff ** 2) dist_weight = np.exp(-dist_sq / (2 * bandwidth ** 2)) grid[nx, ny, nz] += signed_dist * dist_weight weight_grid[nx, ny, nz] += dist_weight valid_mask = weight_grid > 0 grid[valid_mask] /= weight_grid[valid_mask] grid = gaussian_filter(grid, sigma=1.0) try: from skimage.measure import marching_cubes verts, faces, normals_mc, _ = marching_cubes(grid, level=0.0) except ImportError: raise ImportError("scikit-image required for poisson_like. pip install scikit-image") verts = verts * voxel_size + pmin vertex_colors_rgba = _transfer_colors_to_vertices(verts, points, colors, k=5) mesh = Trimesh.Trimesh( vertices=verts, faces=faces, vertex_colors=vertex_colors_rgba, process=False, ) mesh.remove_degenerate_faces() mesh.remove_duplicate_faces() print(f"[GaussianSplatToMesh] Poisson-like: {len(mesh.vertices)} verts, {len(mesh.faces)} faces") return mesh def _ball_pivoting_mesh(points, colors, radius_factor=1.5): """Approximate ball-pivoting using Delaunay with multi-scale edge filtering.""" print(f"[GaussianSplatToMesh] Running ball pivoting (radius_factor={radius_factor})...") tree = cKDTree(points) k = min(6, points.shape[0]) distances, _ = tree.query(points, k=k) avg_nn_dist = distances[:, 1:].mean() radii = [ avg_nn_dist * radius_factor * 2, avg_nn_dist * radius_factor * 3, avg_nn_dist * radius_factor * 5, ] tri = Delaunay(points) tetrahedra = tri.simplices face_count = {} for tet in tetrahedra: for face in [ tuple(sorted([tet[0], tet[1], tet[2]])), tuple(sorted([tet[0], tet[1], tet[3]])), tuple(sorted([tet[0], tet[2], tet[3]])), tuple(sorted([tet[1], tet[2], tet[3]])), ]: face_count[face] = face_count.get(face, 0) + 1 # Multi-scale: try each radius and collect faces all_faces = set() for max_edge_length in radii: for face, count in face_count.items(): if count == 1 and face not in all_faces: pts = points[list(face)] edges = [ np.linalg.norm(pts[1] - pts[0]), np.linalg.norm(pts[2] - pts[1]), np.linalg.norm(pts[0] - pts[2]), ] if max(edges) < max_edge_length: all_faces.add(face) if len(all_faces) == 0: raise ValueError("Ball pivoting produced no faces. Try adjusting radius_factor.") faces_array = np.array(list(all_faces), dtype=np.int64) vertex_colors_rgba = _transfer_colors_to_vertices(points, points, colors, k=1) mesh = Trimesh.Trimesh( vertices=points, faces=faces_array, vertex_colors=vertex_colors_rgba, process=False, ) mesh.remove_degenerate_faces() mesh.remove_duplicate_faces() print(f"[GaussianSplatToMesh] Ball pivoting: {len(mesh.vertices)} verts, {len(mesh.faces)} faces") return mesh # ───────────────────────────────────────────────────────────────────────────── # ComfyUI Node: GaussianSplatToMesh # ───────────────────────────────────────────────────────────────────────────── class GaussianSplatToMesh: """ Convert PLY_DATA (Gaussian Splat point cloud from HY-World 2.0) to TRIMESH. """ @classmethod def INPUT_TYPES(cls): return { "required": { "ply_data": ("PLY_DATA",), }, "optional": { "method": (["marching_cubes", "poisson_like", "alpha_shape", "ball_pivoting"], { "default": "marching_cubes", "tooltip": ( "Surface reconstruction method:\n" "- marching_cubes: Volumetric reconstruction (recommended, smooth)\n" "- poisson_like: Normal-based surface reconstruction (best quality, slower)\n" "- alpha_shape: Delaunay-based alpha shape (fast)\n" "- ball_pivoting: Approximate ball pivoting (good edge filtering)" ), }), "alpha": ("FLOAT", { "default": 2.0, "min": 0.0, "max": 100.0, "step": 0.1, "tooltip": "Alpha for alpha_shape (0=convex hull, larger=more detail). Typical: 1.0-5.0", }), "resolution": ("INT", { "default": 256, "min": 64, "max": 512, "step": 16, "tooltip": "Voxel grid resolution for marching_cubes/poisson_like", }), "radius_factor": ("FLOAT", { "default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1, "tooltip": "Radius multiplier for ball_pivoting method", }), "max_points": ("INT", { "default": 200000, "min": 10000, "max": 1000000, "step": 10000, "tooltip": "Maximum number of points (voxel-downsampled if exceeded)", }), "remove_outliers": ("BOOLEAN", { "default": True, "tooltip": "Remove statistical outlier points before reconstruction", }), "outlier_std_ratio": ("FLOAT", { "default": 2.0, "min": 0.5, "max": 5.0, "step": 0.1, "tooltip": "Std dev ratio for outlier removal (lower = more aggressive)", }), "density_filter": ("BOOLEAN", { "default": True, "tooltip": "Remove sparse/isolated points based on local density", }), "density_percentile": ("FLOAT", { "default": 5.0, "min": 0.0, "max": 30.0, "step": 1.0, "tooltip": "Percentile threshold for density filter (higher = more aggressive)", }), "color_knn": ("INT", { "default": 5, "min": 1, "max": 20, "step": 1, "tooltip": "Number of nearest neighbors for vertex color interpolation", }), "smooth_sigma": ("FLOAT", { "default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1, "tooltip": "Gaussian smoothing sigma for marching_cubes density field", }), }, } RETURN_TYPES = ("TRIMESH",) RETURN_NAMES = ("trimesh",) FUNCTION = "convert" CATEGORY = "3D/mesh" DESCRIPTION = ( "Convert PLY_DATA (Gaussian Splat point cloud from HY-World 2.0) to TRIMESH mesh. " "The output can be connected to Hy3DExportMesh for GLB/OBJ/PLY/STL export. " "v2.1: Fixed color extraction for pts3d_filtered, added normal computation." ) def convert( self, ply_data, method="marching_cubes", alpha=2.0, resolution=256, radius_factor=2.0, max_points=200000, remove_outliers=True, outlier_std_ratio=2.0, density_filter=True, density_percentile=5.0, color_knn=5, smooth_sigma=1.5, ): print(f"[GaussianSplatToMesh] Starting conversion (method={method})...") # ── 1. Extract points and colors ────────────────────────────────── points, colors = _extract_points_and_colors(ply_data) if points.shape[0] < 4: raise ValueError( f"Not enough points for mesh reconstruction: {points.shape[0]} points. " "Need at least 4 points." ) # ── 2. Remove outliers (statistical) ────────────────────────────── if remove_outliers and points.shape[0] > 50: points, colors = _remove_outliers( points, colors, nb_neighbors=min(20, points.shape[0] - 1), std_ratio=outlier_std_ratio, ) # ── 3. Remove outliers (density-based) ─────────────────────────── if density_filter and points.shape[0] > 100: points, colors = _remove_outliers_density( points, colors, percentile=density_percentile, ) # ── 4. Downsample if needed (voxel grid) ───────────────────────── if points.shape[0] > max_points: print(f"[GaussianSplatToMesh] Downsampling: {points.shape[0]} -> {max_points}") points, colors = _downsample_points(points, colors, max_points=max_points) print(f"[GaussianSplatToMesh] Final point cloud: {points.shape[0]} points") # ── 5. Surface reconstruction ───────────────────────────────────── if method == "marching_cubes": mesh = _marching_cubes_mesh( points, colors, resolution=resolution, sigma=smooth_sigma, ) elif method == "poisson_like": # Compute depth from resolution depth = max(6, min(8, int(np.log2(resolution)))) mesh = _poisson_like_mesh(points, colors, depth=depth) elif method == "alpha_shape": mesh = _alpha_shape_mesh(points, colors, alpha=alpha) elif method == "ball_pivoting": mesh = _ball_pivoting_mesh(points, colors, radius_factor=radius_factor) else: raise ValueError(f"Unknown method: {method}") # ── 6. Fix normals ──────────────────────────────────────────────── try: if not hasattr(mesh, 'vertex_normals') or mesh.vertex_normals is None or len(mesh.vertex_normals) == 0: mesh.fix_normals() print("[GaussianSplatToMesh] Computed mesh normals via fix_normals()") else: # Ensure normals are consistent mesh.fix_normals() except Exception as e: print(f"[GaussianSplatToMesh] WARNING: Could not fix normals: {e}") # ── 7. Verify vertex colors are present ────────────────────────── if mesh.visual is None or not hasattr(mesh.visual, 'vertex_colors'): print("[GaussianSplatToMesh] WARNING: Re-applying vertex colors...") vertex_colors_rgba = _transfer_colors_to_vertices( np.array(mesh.vertices), points, colors, k=color_knn, ) mesh.visual = Trimesh.visual.ColorVisuals( mesh=mesh, vertex_colors=vertex_colors_rgba, ) else: vc = mesh.visual.vertex_colors if vc is None or len(vc) == 0: print("[GaussianSplatToMesh] WARNING: Empty vertex colors, re-applying...") vertex_colors_rgba = _transfer_colors_to_vertices( np.array(mesh.vertices), points, colors, k=color_knn, ) mesh.visual = Trimesh.visual.ColorVisuals( mesh=mesh, vertex_colors=vertex_colors_rgba, ) print( f"[GaussianSplatToMesh] Done: {len(mesh.vertices)} vertices, " f"{len(mesh.faces)} faces, has_colors={mesh.visual.vertex_colors is not None}" ) return (mesh,) # ───────────────────────────────────────────────────────────────────────────── # ComfyUI registration # ───────────────────────────────────────────────────────────────────────────── NODE_CLASS_MAPPINGS = { "GaussianSplatToMesh": GaussianSplatToMesh, } NODE_DISPLAY_NAME_MAPPINGS = { "GaussianSplatToMesh": "🔷 Gaussian Splat to Mesh", }