Merge branch 'main' of https://github.com/PozzettiAndrea/ComfyUI-GeometryPack
This commit is contained in:
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-geometrypack"
|
||||
description = "CGAL - Blender - IGL - Paraview - VTK. Geometry processing nodes for ComfyUI - mesh manipulation, remeshing, hole filling, UV unwrapping, and 3D visualization"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
license = { text = "GPL-3.0-or-later" }
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
||||
|
||||
+59
-24
@@ -82,18 +82,13 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.scale-control input[type="range"] {
|
||||
width: 100px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.scale-control input[type="number"] {
|
||||
width: 50px;
|
||||
width: 60px;
|
||||
background: #333;
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -129,8 +124,7 @@
|
||||
<div id="controlsBar">
|
||||
<div class="scale-control">
|
||||
<span>Scale:</span>
|
||||
<input type="range" id="gaussianScale" min="0.1" max="3.0" step="0.05" value="1.0" title="Gaussian scale multiplier">
|
||||
<input type="number" id="gaussianScaleValue" min="0.1" max="3.0" step="0.05" value="1.0">
|
||||
<input type="number" id="gaussianScaleValue" min="0.01" max="100" step="0.1" value="1.0" title="Gaussian scale multiplier">
|
||||
</div>
|
||||
<button id="resetCamera" title="Reset camera view">Reset View</button>
|
||||
<button id="screenshot" title="Take screenshot">Screenshot</button>
|
||||
@@ -156,7 +150,10 @@
|
||||
let controls = null;
|
||||
let animationId = null;
|
||||
let currentSplat = null;
|
||||
let originalScales = null; // Store original scales for dynamic adjustment
|
||||
|
||||
// Default focal length
|
||||
const DEFAULT_FOCAL_LENGTH = 30;
|
||||
|
||||
// Initialize the viewer
|
||||
function initViewer() {
|
||||
@@ -166,6 +163,10 @@
|
||||
renderer = new SPLAT.WebGLRenderer(canvas);
|
||||
controls = new SPLAT.OrbitControls(camera, canvas);
|
||||
|
||||
// Set default focal length
|
||||
camera.data.fx = DEFAULT_FOCAL_LENGTH;
|
||||
camera.data.fy = DEFAULT_FOCAL_LENGTH;
|
||||
|
||||
// Handle resize
|
||||
const resize = () => {
|
||||
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
|
||||
@@ -197,6 +198,10 @@
|
||||
// Store initial camera params for reset
|
||||
let initialCameraData = null;
|
||||
|
||||
// Scale compensation: when focal length is scaled down for canvas size,
|
||||
// gaussian scales need to be multiplied by this factor to maintain correct screen-space size
|
||||
let gaussianScaleCompensation = 1.0;
|
||||
|
||||
// Set camera from extrinsics and intrinsics
|
||||
// extrinsics: 4x4 matrix (identity for SHARP = camera at origin looking down +Z)
|
||||
// intrinsics: 3x3 matrix [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
|
||||
@@ -259,8 +264,14 @@
|
||||
camera.data.fx = scaledF;
|
||||
camera.data.fy = scaledF;
|
||||
|
||||
// IMPORTANT: Compensate gaussian scales for the focal length scaling
|
||||
// Screen-space size = world_scale * fx / depth
|
||||
// When we scale fx down, gaussians appear smaller. Multiply scales by inverse.
|
||||
gaussianScaleCompensation = imageWidth / canvasWidth;
|
||||
|
||||
console.log('[GaussianViewer] Canvas size:', canvasWidth, 'x', canvasHeight);
|
||||
console.log('[GaussianViewer] Uniform scale:', scale, 'scaled focal length:', scaledF);
|
||||
console.log('[GaussianViewer] Gaussian scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
|
||||
|
||||
// Calculate vertical FOV in radians for logging
|
||||
const fovY = 2 * Math.atan(imageHeight / (2 * fy));
|
||||
@@ -346,6 +357,21 @@
|
||||
}
|
||||
currentSplat = null;
|
||||
|
||||
// Pre-calculate scale compensation from intrinsics BEFORE loading
|
||||
// This ensures gaussian scales are correct from the start
|
||||
gaussianScaleCompensation = 1.0; // Default if no intrinsics
|
||||
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
|
||||
const fx = intrinsics[0][0];
|
||||
const cx = intrinsics[0][2];
|
||||
const imageWidth = cx * 2;
|
||||
const canvasWidth = canvas.clientWidth || 512;
|
||||
// Compensation = original_size / canvas_size
|
||||
// This makes gaussians appear at their correct world-space size
|
||||
gaussianScaleCompensation = imageWidth / canvasWidth;
|
||||
console.log('[GaussianViewer] Pre-calculated scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
|
||||
console.log('[GaussianViewer] (Image:', imageWidth, 'px -> Canvas:', canvasWidth, 'px)');
|
||||
}
|
||||
|
||||
console.log('[GaussianViewer] Loading from data, size:', arrayBuffer.byteLength);
|
||||
|
||||
// Create a blob URL from the data
|
||||
@@ -376,9 +402,22 @@
|
||||
const numGaussians = scales.length / 3;
|
||||
let minScale = Infinity, maxScale = -Infinity, sumScale = 0;
|
||||
|
||||
// Store a copy of original scales for dynamic adjustment
|
||||
originalScales = new Float32Array(scales);
|
||||
console.log('[GaussianViewer] Stored original scales for dynamic adjustment');
|
||||
// Store a copy of original scales (BEFORE compensation) for dynamic adjustment
|
||||
// The stored scales include the compensation factor so user scale=1 shows correct size
|
||||
originalScales = new Float32Array(scales.length);
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
// Apply compensation: gaussians need to be larger when focal length is scaled down
|
||||
originalScales[i] = scales[i] * gaussianScaleCompensation;
|
||||
}
|
||||
console.log('[GaussianViewer] Stored compensated scales (factor:', gaussianScaleCompensation.toFixed(2), 'x)');
|
||||
|
||||
// Apply compensated scales immediately
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
scales[i] = originalScales[i];
|
||||
}
|
||||
|
||||
// Mark data as changed so gsplat.js re-uploads to GPU
|
||||
currentSplat.data.changed = true;
|
||||
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
const s = scales[i];
|
||||
@@ -388,12 +427,12 @@
|
||||
}
|
||||
|
||||
const avgScale = sumScale / scales.length;
|
||||
console.log('[GaussianViewer] === GAUSSIAN SCALE DEBUG ===');
|
||||
console.log('[GaussianViewer] === GAUSSIAN SCALE DEBUG (after compensation) ===');
|
||||
console.log('[GaussianViewer] Number of Gaussians:', numGaussians);
|
||||
console.log('[GaussianViewer] Scale min:', minScale.toFixed(6));
|
||||
console.log('[GaussianViewer] Scale max:', maxScale.toFixed(6));
|
||||
console.log('[GaussianViewer] Scale avg:', avgScale.toFixed(6));
|
||||
console.log('[GaussianViewer] ===========================');
|
||||
console.log('[GaussianViewer] Compensated scale min:', minScale.toFixed(6));
|
||||
console.log('[GaussianViewer] Compensated scale max:', maxScale.toFixed(6));
|
||||
console.log('[GaussianViewer] Compensated scale avg:', avgScale.toFixed(6));
|
||||
console.log('[GaussianViewer] =================================================');
|
||||
} else {
|
||||
console.log('[GaussianViewer] No scales data found on splat object');
|
||||
console.log('[GaussianViewer] Splat properties:', Object.keys(currentSplat));
|
||||
@@ -403,8 +442,7 @@
|
||||
originalScales = null;
|
||||
}
|
||||
|
||||
// Reset scale slider when loading new splat
|
||||
scaleSlider.value = 1.0;
|
||||
// Reset scale when loading new splat
|
||||
scaleInput.value = 1.0;
|
||||
currentScale = 1.0;
|
||||
}
|
||||
@@ -494,18 +532,16 @@
|
||||
}
|
||||
|
||||
// Gaussian scale control
|
||||
const scaleSlider = document.getElementById('gaussianScale');
|
||||
const scaleInput = document.getElementById('gaussianScaleValue');
|
||||
let currentScale = 1.0;
|
||||
let scaleUpdatePending = false;
|
||||
let lastScaleValue = 1.0;
|
||||
|
||||
function updateGaussianScale(newScale) {
|
||||
newScale = Math.max(0.1, Math.min(3.0, parseFloat(newScale) || 1.0));
|
||||
newScale = Math.max(0.01, Math.min(100, parseFloat(newScale) || 1.0));
|
||||
currentScale = newScale;
|
||||
|
||||
// Update both controls
|
||||
scaleSlider.value = newScale;
|
||||
// Update input
|
||||
scaleInput.value = newScale;
|
||||
|
||||
// Apply scale to current splat by modifying the underlying scale data
|
||||
@@ -593,7 +629,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
scaleSlider.addEventListener('input', (e) => updateGaussianScale(e.target.value));
|
||||
scaleInput.addEventListener('change', (e) => updateGaussianScale(e.target.value));
|
||||
|
||||
// Event listeners
|
||||
|
||||
Reference in New Issue
Block a user