From 4bcce8f8ab9ef76e16a0aa0d7ee43fc3f40c3432 Mon Sep 17 00:00:00 2001 From: Andrea Pozzetti Date: Thu, 12 Feb 2026 21:55:40 +0100 Subject: [PATCH] total --- nodes/conversion/__init__.py | 4 + nodes/conversion/subsample_pointcloud.py | 210 ++++++++++++++++++++ nodes/io/__init__.py | 3 + nodes/io/load_mesh_path.py | 200 +++++++++++++++++++ nodes/io/save_mesh.py | 12 +- nodes/reconstruction/reconstruct_surface.py | 34 +++- 6 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 nodes/conversion/subsample_pointcloud.py create mode 100644 nodes/io/load_mesh_path.py diff --git a/nodes/conversion/__init__.py b/nodes/conversion/__init__.py index 5550ea7c..82cf97b4 100644 --- a/nodes/conversion/__init__.py +++ b/nodes/conversion/__init__.py @@ -5,14 +5,18 @@ from .mesh_to_pointcloud import NODE_CLASS_MAPPINGS as MESH_TO_PC_MAPPINGS from .mesh_to_pointcloud import NODE_DISPLAY_NAME_MAPPINGS as MESH_TO_PC_DISPLAY +from .subsample_pointcloud import NODE_CLASS_MAPPINGS as SUBSAMPLE_PC_MAPPINGS +from .subsample_pointcloud import NODE_DISPLAY_NAME_MAPPINGS as SUBSAMPLE_PC_DISPLAY # Combine all mappings NODE_CLASS_MAPPINGS = { **MESH_TO_PC_MAPPINGS, + **SUBSAMPLE_PC_MAPPINGS, } NODE_DISPLAY_NAME_MAPPINGS = { **MESH_TO_PC_DISPLAY, + **SUBSAMPLE_PC_DISPLAY, } __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/conversion/subsample_pointcloud.py b/nodes/conversion/subsample_pointcloud.py new file mode 100644 index 00000000..1e7cec05 --- /dev/null +++ b/nodes/conversion/subsample_pointcloud.py @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Subsample Point Cloud Node - Reduce point cloud density while preserving attributes +""" + +import numpy as np +import trimesh + + +class SubsamplePointCloudNode: + """ + Subsample a point cloud to reduce point count while preserving attributes. + + Supports multiple sampling methods and preserves colors, normals, and other vertex data. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "point_cloud": ("TRIMESH",), + "method": (["random", "uniform_grid", "farthest_point"], { + "default": "random", + "tooltip": "random: fast random selection. uniform_grid: voxel-based uniform spacing. farthest_point: maximize coverage (slow for large clouds)." + }), + "target_count": ("INT", { + "default": 100000, + "min": 100, + "max": 10000000, + "step": 1000, + "tooltip": "Target number of points to keep" + }), + }, + "optional": { + "seed": ("INT", { + "default": 42, + "min": 0, + "max": 2147483647, + "tooltip": "Random seed for reproducible results (random method only)" + }), + } + } + + RETURN_TYPES = ("TRIMESH",) + RETURN_NAMES = ("point_cloud",) + FUNCTION = "subsample" + CATEGORY = "geompack/conversion" + + def _random_subsample(self, vertices, target_count, seed): + """Random subsampling - fast and simple.""" + np.random.seed(seed) + indices = np.random.choice(len(vertices), size=target_count, replace=False) + indices.sort() # Keep spatial ordering + return indices + + def _uniform_grid_subsample(self, vertices, target_count): + """Voxel-based uniform subsampling for even spatial distribution.""" + # Calculate voxel size to achieve target count + bbox_min = vertices.min(axis=0) + bbox_max = vertices.max(axis=0) + bbox_size = bbox_max - bbox_min + + # Estimate voxel size based on target count and bounding box volume + volume = np.prod(bbox_size) + voxel_size = (volume / target_count) ** (1/3) + + # Quantize points to voxel grid + voxel_indices = ((vertices - bbox_min) / voxel_size).astype(np.int32) + + # Use dictionary to keep one point per voxel (first encountered) + voxel_to_point = {} + for i, voxel in enumerate(voxel_indices): + key = tuple(voxel) + if key not in voxel_to_point: + voxel_to_point[key] = i + + indices = np.array(sorted(voxel_to_point.values())) + + # If we got more points than target, randomly subsample + if len(indices) > target_count: + np.random.seed(42) + indices = np.random.choice(indices, size=target_count, replace=False) + indices.sort() + + return indices + + def _farthest_point_subsample(self, vertices, target_count): + """Farthest point sampling for maximum coverage (slow for large clouds).""" + n_points = len(vertices) + + # Start with a random point + np.random.seed(42) + indices = [np.random.randint(n_points)] + + # Track minimum distance to selected set for each point + min_distances = np.full(n_points, np.inf) + + for _ in range(target_count - 1): + # Update distances based on last selected point + last_selected = vertices[indices[-1]] + distances = np.linalg.norm(vertices - last_selected, axis=1) + min_distances = np.minimum(min_distances, distances) + + # Select point with maximum minimum distance + # Exclude already selected points + min_distances[indices] = -1 + next_idx = np.argmax(min_distances) + indices.append(next_idx) + + # Progress logging for slow operations + if len(indices) % 10000 == 0: + print(f"[SubsamplePointCloud] FPS progress: {len(indices):,}/{target_count:,}") + + return np.array(sorted(indices)) + + def subsample(self, point_cloud, method, target_count, seed=42): + """ + Subsample point cloud while preserving all vertex attributes. + + Args: + point_cloud: Input trimesh.PointCloud or Trimesh object + method: Subsampling method + target_count: Target number of points + seed: Random seed for reproducibility + + Returns: + tuple: (subsampled_point_cloud,) + """ + vertices = np.asarray(point_cloud.vertices) + n_points = len(vertices) + + print(f"[SubsamplePointCloud] Input: {n_points:,} points, target: {target_count:,}") + + # If already at or below target, return as-is + if n_points <= target_count: + print(f"[SubsamplePointCloud] Point count already at or below target, returning unchanged") + return (point_cloud,) + + # Get indices based on method + if method == "random": + indices = self._random_subsample(vertices, target_count, seed) + elif method == "uniform_grid": + indices = self._uniform_grid_subsample(vertices, target_count) + elif method == "farthest_point": + # Warn if using FPS on large clouds + if n_points > 100000: + print(f"[SubsamplePointCloud] WARNING: Farthest point sampling on {n_points:,} points will be slow. Consider 'random' or 'uniform_grid'.") + indices = self._farthest_point_subsample(vertices, target_count) + else: + raise ValueError(f"Unknown method: {method}") + + print(f"[SubsamplePointCloud] Selected {len(indices):,} points using {method} method") + + # Extract subsampled vertices + new_vertices = vertices[indices] + + # Create new point cloud + new_cloud = trimesh.PointCloud(vertices=new_vertices) + + # Preserve vertex colors if present + if hasattr(point_cloud, 'colors') and point_cloud.colors is not None: + colors = np.asarray(point_cloud.colors) + if len(colors) == n_points: + new_cloud.colors = colors[indices] + print(f"[SubsamplePointCloud] Preserved vertex colors") + + # Also check visual.vertex_colors (trimesh stores colors here sometimes) + if hasattr(point_cloud, 'visual') and hasattr(point_cloud.visual, 'vertex_colors'): + vc = point_cloud.visual.vertex_colors + if vc is not None and len(vc) == n_points: + new_cloud.colors = vc[indices] + print(f"[SubsamplePointCloud] Preserved visual.vertex_colors") + + # Preserve vertex normals if present + if hasattr(point_cloud, 'vertex_normals'): + normals = point_cloud.vertex_normals + if normals is not None and len(normals) == n_points: + new_cloud.vertex_normals = normals[indices] + print(f"[SubsamplePointCloud] Preserved vertex normals") + + # Preserve metadata + if hasattr(point_cloud, 'metadata') and point_cloud.metadata: + new_cloud.metadata = dict(point_cloud.metadata) + new_cloud.metadata['subsampled'] = True + new_cloud.metadata['subsample_method'] = method + new_cloud.metadata['original_point_count'] = n_points + else: + new_cloud.metadata = { + 'is_point_cloud': True, + 'subsampled': True, + 'subsample_method': method, + 'original_point_count': n_points, + 'sample_count': len(indices) + } + + print(f"[SubsamplePointCloud] Output: {len(new_vertices):,} points") + + return (new_cloud,) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackSubsamplePointCloud": SubsamplePointCloudNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackSubsamplePointCloud": "Subsample Point Cloud", +} diff --git a/nodes/io/__init__.py b/nodes/io/__init__.py index dfb1d9e0..93bcf1f9 100644 --- a/nodes/io/__init__.py +++ b/nodes/io/__init__.py @@ -6,6 +6,7 @@ I/O Nodes - Load and save mesh files """ from .load_mesh import NODE_CLASS_MAPPINGS as LoadMesh_mappings, NODE_DISPLAY_NAME_MAPPINGS as LoadMesh_display +from .load_mesh_path import NODE_CLASS_MAPPINGS as LoadMeshPath_mappings, NODE_DISPLAY_NAME_MAPPINGS as LoadMeshPath_display from .load_mesh_batch import NODE_CLASS_MAPPINGS as LoadMeshBatch_mappings, NODE_DISPLAY_NAME_MAPPINGS as LoadMeshBatch_display from .load_mesh_fbx import NODE_CLASS_MAPPINGS as LoadMeshFBX_mappings, NODE_DISPLAY_NAME_MAPPINGS as LoadMeshFBX_display from .load_mesh_blend import NODE_CLASS_MAPPINGS as LoadMeshBlend_mappings, NODE_DISPLAY_NAME_MAPPINGS as LoadMeshBlend_display @@ -16,6 +17,7 @@ from .get_mesh_filename import NODE_CLASS_MAPPINGS as GetMeshFilename_mappings, # Aggregate all node mappings NODE_CLASS_MAPPINGS = { **LoadMesh_mappings, + **LoadMeshPath_mappings, **LoadMeshBatch_mappings, **LoadMeshFBX_mappings, **LoadMeshBlend_mappings, @@ -26,6 +28,7 @@ NODE_CLASS_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = { **LoadMesh_display, + **LoadMeshPath_display, **LoadMeshBatch_display, **LoadMeshFBX_display, **LoadMeshBlend_display, diff --git a/nodes/io/load_mesh_path.py b/nodes/io/load_mesh_path.py new file mode 100644 index 00000000..769a252c --- /dev/null +++ b/nodes/io/load_mesh_path.py @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Load Mesh (Path) Node - Load a mesh from a string path input +""" + +import os +import numpy as np + +# ComfyUI folder paths +try: + import folder_paths + COMFYUI_INPUT_FOLDER = folder_paths.get_input_directory() + COMFYUI_OUTPUT_FOLDER = folder_paths.get_output_directory() +except (ImportError, AttributeError): + COMFYUI_INPUT_FOLDER = None + COMFYUI_OUTPUT_FOLDER = None + +from .._utils import mesh_ops + +try: + from PIL import Image + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + +try: + import torch + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + + +class LoadMeshPath: + """ + Load a mesh from a string path (OBJ, PLY, STL, OFF, etc.) + Takes a string input for the path, allowing dynamic path construction. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "file_path": ("STRING", { + "default": "", + "multiline": False, + "tooltip": "Path to mesh file (absolute or relative to input/output folder)" + }), + }, + } + + RETURN_TYPES = ("TRIMESH", "IMAGE") + RETURN_NAMES = ("mesh", "texture") + FUNCTION = "load_mesh" + CATEGORY = "geompack/io" + + @classmethod + def IS_CHANGED(cls, file_path): + """Force re-execution when file changes.""" + resolved_path = cls._resolve_path(file_path) + if resolved_path and os.path.exists(resolved_path): + return os.path.getmtime(resolved_path) + return file_path + + @classmethod + def _resolve_path(cls, file_path): + """Resolve file path, checking multiple locations.""" + if not file_path or file_path.strip() == "": + return None + + file_path = file_path.strip() + + # Try absolute path first + if os.path.isabs(file_path) and os.path.exists(file_path): + return file_path + + # Try relative to output folder (common for generated meshes) + if COMFYUI_OUTPUT_FOLDER is not None: + output_path = os.path.join(COMFYUI_OUTPUT_FOLDER, file_path) + if os.path.exists(output_path): + return output_path + + # Try relative to input/3d folder + if COMFYUI_INPUT_FOLDER is not None: + input_3d_path = os.path.join(COMFYUI_INPUT_FOLDER, "3d", file_path) + if os.path.exists(input_3d_path): + return input_3d_path + + # Try relative to input folder + input_path = os.path.join(COMFYUI_INPUT_FOLDER, file_path) + if os.path.exists(input_path): + return input_path + + # Try as-is (might be absolute path that exists) + if os.path.exists(file_path): + return file_path + + return None + + def _extract_texture_image(self, mesh): + """Extract texture from mesh and convert to ComfyUI IMAGE format.""" + if not PIL_AVAILABLE or not TORCH_AVAILABLE: + return None + + texture_image = None + + # Check if mesh has texture + if hasattr(mesh, 'visual') and hasattr(mesh.visual, 'material'): + material = mesh.visual.material + if material is not None: + # Check for PBR baseColorTexture (GLB/GLTF files) + if hasattr(material, 'baseColorTexture') and material.baseColorTexture is not None: + img = material.baseColorTexture + if isinstance(img, Image.Image): + texture_image = img + print(f"[LoadMeshPath] Found texture in material.baseColorTexture: {texture_image.size}") + elif isinstance(img, str) and os.path.exists(img): + texture_image = Image.open(img) + print(f"[LoadMeshPath] Loaded texture from material.baseColorTexture path: {texture_image.size}") + + # Check for standard material.image (OBJ/MTL files) + if texture_image is None and hasattr(material, 'image') and material.image is not None: + img = material.image + if isinstance(img, Image.Image): + texture_image = img + print(f"[LoadMeshPath] Found texture in material.image: {texture_image.size}") + elif isinstance(img, str) and os.path.exists(img): + texture_image = Image.open(img) + print(f"[LoadMeshPath] Loaded texture from material.image path: {texture_image.size}") + + if texture_image is None: + print("[LoadMeshPath] No texture found in mesh") + # Return black 64x64 placeholder + texture_image = Image.new('RGB', (64, 64), color=(0, 0, 0)) + + # Convert to ComfyUI IMAGE format (BHWC with values 0-1) + img_array = np.array(texture_image.convert("RGB")).astype(np.float32) / 255.0 + return torch.from_numpy(img_array)[None,] + + def load_mesh(self, file_path): + """ + Load mesh from file path string. + + Args: + file_path: Path to mesh file (absolute or relative to output/input folders) + + Returns: + tuple: (trimesh.Trimesh, IMAGE) + """ + if not file_path or file_path.strip() == "": + raise ValueError("File path cannot be empty") + + file_path = file_path.strip() + + # Resolve the path + full_path = self._resolve_path(file_path) + + if full_path is None: + # Build error message with searched paths + searched_paths = [file_path] + if COMFYUI_OUTPUT_FOLDER: + searched_paths.append(os.path.join(COMFYUI_OUTPUT_FOLDER, file_path)) + if COMFYUI_INPUT_FOLDER: + searched_paths.append(os.path.join(COMFYUI_INPUT_FOLDER, "3d", file_path)) + searched_paths.append(os.path.join(COMFYUI_INPUT_FOLDER, file_path)) + + error_msg = f"File not found: '{file_path}'\nSearched in:" + for path in searched_paths: + error_msg += f"\n - {path}" + raise ValueError(error_msg) + + print(f"[LoadMeshPath] Loading mesh from: {full_path}") + + # Load the mesh + loaded_mesh, error = mesh_ops.load_mesh_file(full_path) + + if loaded_mesh is None: + raise ValueError(f"Failed to load mesh: {error}") + + # Handle both meshes and pointclouds + if hasattr(loaded_mesh, 'faces') and loaded_mesh.faces is not None: + print(f"[LoadMeshPath] Loaded: {len(loaded_mesh.vertices)} vertices, {len(loaded_mesh.faces)} faces") + else: + print(f"[LoadMeshPath] Loaded pointcloud: {len(loaded_mesh.vertices)} points") + + # Extract texture + texture = self._extract_texture_image(loaded_mesh) + + return (loaded_mesh, texture) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackLoadMeshPath": LoadMeshPath, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackLoadMeshPath": "Load Mesh (Path)", +} diff --git a/nodes/io/save_mesh.py b/nodes/io/save_mesh.py index 821340fc..0c657da8 100644 --- a/nodes/io/save_mesh.py +++ b/nodes/io/save_mesh.py @@ -43,7 +43,7 @@ class SaveMesh: } RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("status",) + RETURN_NAMES = ("file_path",) FUNCTION = "save_mesh" CATEGORY = "geompack/io" OUTPUT_NODE = True @@ -120,14 +120,12 @@ class SaveMesh: raise ValueError(f"Failed to save trimesh: {error}") geom_type = "point cloud" if is_point_cloud else "mesh" - status = f"Successfully saved {geom_type} to: {full_path}\n" - status += f" Vertices: {len(trimesh.vertices)}" + print(f"[SaveMesh] Successfully saved {geom_type} to: {full_path}") + print(f"[SaveMesh] Vertices: {len(trimesh.vertices)}") if not is_point_cloud: - status += f"\n Faces: {len(trimesh.faces)}" + print(f"[SaveMesh] Faces: {len(trimesh.faces)}") - print(f"[SaveMesh] {status}") - - return (status,) + return (full_path,) # Node mappings diff --git a/nodes/reconstruction/reconstruct_surface.py b/nodes/reconstruction/reconstruct_surface.py index 6ebd1656..d530de4c 100644 --- a/nodes/reconstruction/reconstruct_surface.py +++ b/nodes/reconstruction/reconstruct_surface.py @@ -8,6 +8,13 @@ Surface Reconstruction Node - Point cloud to mesh conversion import numpy as np import trimesh as trimesh_module +# ComfyUI progress reporting +try: + from comfy.utils import ProgressBar + PROGRESS_AVAILABLE = True +except ImportError: + PROGRESS_AVAILABLE = False + class ReconstructSurfaceNode: """ @@ -110,13 +117,15 @@ class ReconstructSurfaceNode: normals = points.vertex_normals print(f"[Reconstruct] Using normals from input") - # Check if this is a point cloud (from MeshToPointCloud node) + # Check if this is a point cloud is_point_cloud = False - if hasattr(points, 'metadata') and points.metadata.get('is_point_cloud', False): + face_count = len(points.faces) if hasattr(points, 'faces') and points.faces is not None else 0 + + if face_count == 0 or (hasattr(points, 'metadata') and points.metadata.get('is_point_cloud', False)): is_point_cloud = True - print(f"[Reconstruct] Input type: Point cloud (TRIMESH with {len(vertices)} points, 0 faces)") + print(f"[Reconstruct] Input type: Point cloud ({len(vertices)} points)") else: - print(f"[Reconstruct] Input type: TRIMESH ({len(vertices)} vertices, {len(points.faces)} faces)") + print(f"[Reconstruct] Input type: TRIMESH ({len(vertices)} vertices, {face_count} faces)") print(f"[Reconstruct] Method: {method}") @@ -155,37 +164,49 @@ class ReconstructSurfaceNode: def _poisson(self, vertices, normals, depth, scale, estimate_normals, normal_radius): """Poisson surface reconstruction using Open3D or PyMeshLab.""" + # Set up progress bar (5 steps for Open3D path) + pbar = ProgressBar(5) if PROGRESS_AVAILABLE else None + # Try Open3D first try: import open3d as o3d print(f"[Reconstruct] Using Open3D Poisson reconstruction...") + print(f"[Reconstruct] Step 1/5: Creating point cloud...") # Create point cloud pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(vertices) + if pbar: pbar.update(1) # Estimate normals if needed + print(f"[Reconstruct] Step 2/5: Estimating normals...") if normals is None or estimate_normals: pcd.estimate_normals( search_param=o3d.geometry.KDTreeSearchParamHybrid( radius=normal_radius, max_nn=30 ) ) + print(f"[Reconstruct] Step 3/5: Orienting normals...") pcd.orient_normals_consistent_tangent_plane(k=10) else: pcd.normals = o3d.utility.Vector3dVector(normals) + if pbar: pbar.update(1) # Poisson reconstruction + print(f"[Reconstruct] Step 4/5: Running Poisson reconstruction (depth={depth})... This may take a while.") mesh_o3d, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson( pcd, depth=depth, scale=scale, linear_fit=False ) + if pbar: pbar.update(1) # Remove low density vertices (noise) + print(f"[Reconstruct] Step 5/5: Cleaning up mesh...") densities = np.asarray(densities) density_threshold = np.quantile(densities, 0.01) vertices_to_remove = densities < density_threshold mesh_o3d.remove_vertices_by_mask(vertices_to_remove) + if pbar: pbar.update(1) # Convert to trimesh result = trimesh_module.Trimesh( @@ -194,6 +215,9 @@ class ReconstructSurfaceNode: process=False ) + if pbar: pbar.update(1) + print(f"[Reconstruct] Done! Output: {len(result.vertices):,} vertices, {len(result.faces):,} faces") + info = f"""Reconstruct Surface Results (Poisson): Engine: Open3D @@ -211,7 +235,7 @@ Poisson reconstruction creates smooth, watertight surfaces. return result, info except ImportError: - pass + if pbar: pbar.update(5) # Skip to end if Open3D not available # Fallback to PyMeshLab try: