This commit is contained in:
Andrea Pozzetti
2026-02-12 21:55:40 +01:00
parent 9fc0643372
commit 4bcce8f8ab
6 changed files with 451 additions and 12 deletions
+4
View File
@@ -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']
+210
View File
@@ -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",
}
+3
View File
@@ -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,
+200
View File
@@ -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)",
}
+5 -7
View File
@@ -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
+29 -5
View File
@@ -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: