Files
wTechArtist-ComfyUI_VVL_Vid…/glb_point_cloud_processor.py
T
2025-06-19 17:28:18 +08:00

2034 lines
101 KiB
Python
Executable File

import os
import json
import tempfile
import logging
import struct
import time
from typing import List, Any, Dict, Tuple
import numpy as np
import torch
# 导入ComfyUI的路径管理
try:
import folder_paths
FOLDER_PATHS_AVAILABLE = True
except ImportError:
folder_paths = None
FOLDER_PATHS_AVAILABLE = False
# 导入trimesh用于GLB文件处理
try:
import trimesh
TRIMESH_AVAILABLE = True
except ImportError:
TRIMESH_AVAILABLE = False
trimesh = None
# 配置日志
logger = logging.getLogger('glb_point_cloud_processor')
class GLBPointCloudProcessor:
"""GLB点云文件处理器 - 专门用于删除黑色点和暗色点优化"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"glb_file_path": ("STRING", {
"default": "",
"tooltip": "GLB点云文件路径:输入需要处理的GLB格式点云文件路径。支持绝对路径(如C:/path/to/file.glb)或相对于ComfyUI output目录的相对路径(如pointcloud.glb)。GLB文件应包含点云数据,支持带颜色信息的点云处理"
}),
},
"optional": {
"black_threshold": ("INT", {
"default": 30, "min": 0, "max": 255, "step": 1,
"tooltip": "黑色阈值控制:RGB三色总和小于此值的点将被删除。0=仅删除纯黑点(0,0,0);30=删除深黑色点;60=删除较暗点;100=删除中等暗度点;255=删除所有点。调大=删除更多暗色点,点云更亮净;调小=保留更多暗色细节,但可能有噪点"
}),
"output_filename": ("STRING", {
"default": "clean_pointcloud",
"tooltip": "输出GLB文件名:处理后点云的保存文件名,系统会自动添加.glb扩展名。文件将保存到ComfyUI的output目录中。建议使用描述性名称如'clean_pointcloud'、'filtered_scan'等便于识别"
}),
}
}
RETURN_TYPES = (
"STRING", # 处理后的GLB文件路径
)
RETURN_NAMES = (
"processed_glb_path",
)
OUTPUT_TOOLTIPS = [
"处理后的GLB文件完整路径",
]
OUTPUT_NODE = True
FUNCTION = "process_point_cloud"
CATEGORY = "💃VVL/Point Cloud Cleaning"
def process_point_cloud(self,
glb_file_path: str,
black_threshold: int = 30,
output_filename: str = "clean_pointcloud"):
"""
处理GLB点云文件,删除黑色点和暗色点
"""
processing_log = []
processing_log.append("开始GLB点云黑色点清理...")
# 检查依赖
if not TRIMESH_AVAILABLE:
error_msg = "trimesh库不可用,无法处理GLB文件"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("",)
# 验证输入文件路径
if not glb_file_path or not glb_file_path.strip():
error_msg = "GLB文件路径为空"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("",)
# 处理文件路径
input_path = self._resolve_file_path(glb_file_path.strip())
processing_log.append(f"输入文件路径: {input_path}")
if not os.path.exists(input_path):
error_msg = f"GLB文件不存在: {input_path}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("",)
try:
# 加载GLB文件
processing_log.append("正在加载GLB文件...")
scene = trimesh.load(input_path)
# 提取点云数据
point_clouds = []
other_geometries = []
if isinstance(scene, trimesh.Scene):
for name, geometry in scene.geometry.items():
if isinstance(geometry, trimesh.PointCloud):
point_clouds.append((name, geometry))
processing_log.append(f"发现点云: {name}, 点数: {len(geometry.vertices)}")
else:
other_geometries.append((name, geometry))
processing_log.append(f"发现其他几何体: {name}, 类型: {type(geometry).__name__}")
elif isinstance(scene, trimesh.PointCloud):
point_clouds.append(("main_pointcloud", scene))
processing_log.append(f"发现点云: main_pointcloud, 点数: {len(scene.vertices)}")
else:
# 尝试转换为点云
if hasattr(scene, 'vertices'):
pc = trimesh.PointCloud(vertices=scene.vertices, colors=getattr(scene.visual, 'vertex_colors', None))
point_clouds.append(("converted_pointcloud", pc))
processing_log.append(f"转换为点云: converted_pointcloud, 点数: {len(pc.vertices)}")
else:
error_msg = "GLB文件中未找到点云数据"
processing_log.append(f"错误: {error_msg}")
return ("",)
if not point_clouds:
error_msg = "GLB文件中没有点云数据"
processing_log.append(f"错误: {error_msg}")
return ("",)
# 处理每个点云
processed_point_clouds = []
total_original_points = 0
total_removed_points = 0
for name, point_cloud in point_clouds:
processing_log.append(f"\n处理点云: {name}")
original_count = len(point_cloud.vertices)
total_original_points += original_count
# 获取原始点云的所有属性和状态
original_vertices = point_cloud.vertices.copy()
colors = None
# 获取颜色信息
if hasattr(point_cloud.visual, 'vertex_colors') and point_cloud.visual.vertex_colors is not None:
colors = point_cloud.visual.vertex_colors.copy()
elif hasattr(point_cloud, 'colors') and point_cloud.colors is not None:
colors = point_cloud.colors.copy()
# 初始化掩码(所有点都保留)
keep_mask = np.ones(len(original_vertices), dtype=bool)
# 删除暗色点(仅基于颜色,不改变顶点坐标)
if colors is not None:
removed_count = self._remove_dark_points(original_vertices, colors, keep_mask, black_threshold, processing_log)
processing_log.append(f"删除黑色点: {removed_count} 个")
else:
processing_log.append("跳过黑色点过滤: 点云无颜色信息")
# 应用掩码,保留原始的顶点坐标
filtered_vertices = original_vertices[keep_mask]
filtered_colors = colors[keep_mask] if colors is not None else None
remaining_count = len(filtered_vertices)
removed_count = original_count - remaining_count
total_removed_points += removed_count
processing_log.append(f"点云 {name}: 原始 {original_count} -> 剩余 {remaining_count} (删除 {removed_count})")
# 创建处理后的点云,保留所有原始属性
if remaining_count > 0:
# 使用原始点云作为模板,只更新顶点和颜色
processed_pc = point_cloud.copy()
processed_pc.vertices = filtered_vertices
if filtered_colors is not None:
processed_pc.visual.vertex_colors = filtered_colors
processed_point_clouds.append((name, processed_pc))
processing_log.append(f"保留点云 {name} 的所有原始属性和状态")
else:
processing_log.append(f"警告: 点云 {name} 处理后没有剩余点")
# 直接修改原始场景,保持所有几何信息不变
if processed_point_clouds:
# 使用原始场景作为基础,直接替换点云数据
new_scene = scene
# 直接修改原始场景中的点云几何体,保持变换不变
for name, processed_pc in processed_point_clouds:
if isinstance(scene, trimesh.Scene):
# 获取原始几何体
if name in scene.geometry:
original_geometry = scene.geometry[name]
# 如果是点云,直接修改顶点和颜色
if isinstance(original_geometry, trimesh.PointCloud):
original_geometry.vertices = processed_pc.vertices
if hasattr(processed_pc.visual, 'vertex_colors') and processed_pc.visual.vertex_colors is not None:
original_geometry.visual.vertex_colors = processed_pc.visual.vertex_colors
processing_log.append(f"直接修改原始点云 {name},保持所有变换和属性不变")
else:
# 如果不是点云类型,替换整个几何体但保持变换
scene.delete_geometry(name)
scene.add_geometry(processed_pc, node_name=name)
processing_log.append(f"替换几何体 {name},尝试保持变换")
else:
# 新增几何体
scene.add_geometry(processed_pc, node_name=name)
processing_log.append(f"添加新点云 {name}")
else:
# 单个几何体的情况
new_scene = processed_pc
processing_log.append("单个几何体,直接使用处理后的点云")
processing_log.append("直接修改原始场景,所有变换矩阵和几何属性完全保持不变")
# 生成输出路径
output_path = self._generate_output_path(output_filename)
processing_log.append(f"输出文件路径: {output_path}")
# 保存处理后的GLB文件
processing_log.append("正在保存处理后的GLB文件...")
new_scene.export(output_path)
# 验证文件是否保存成功
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
processing_log.append(f"GLB文件保存成功,文件大小: {file_size} bytes")
# 验证输出文件的一致性
try:
# 加载输出文件进行验证
verification_scene = trimesh.load(output_path)
processing_log.append("文件验证: 输出文件可正常加载")
# 检查是否保留了场景结构
if isinstance(scene, trimesh.Scene) and isinstance(verification_scene, trimesh.Scene):
processing_log.append(f"场景结构验证: 原始 {len(scene.geometry)} 个对象 -> 输出 {len(verification_scene.geometry)} 个对象")
except Exception as e:
processing_log.append(f"文件验证警告: {str(e)}")
processing_log.append("GLB点云黑色点清理完成! 已保留原始模型的大小、朝向和几何属性")
return (output_path,)
else:
error_msg = "GLB文件保存失败"
processing_log.append(f"错误: {error_msg}")
return ("",)
else:
error_msg = "处理后没有剩余的点云数据"
processing_log.append(f"错误: {error_msg}")
return ("",)
except Exception as e:
error_msg = f"处理GLB文件时发生错误: {str(e)}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
import traceback
traceback.print_exc()
return ("",)
def _resolve_file_path(self, file_path: str) -> str:
"""解析文件路径,支持绝对路径和相对路径"""
if os.path.isabs(file_path):
return file_path
# 尝试相对于ComfyUI输出目录
if FOLDER_PATHS_AVAILABLE:
output_dir = folder_paths.get_output_directory()
candidate_path = os.path.join(output_dir, file_path)
if os.path.exists(candidate_path):
return candidate_path
# 尝试相对于当前工作目录
if os.path.exists(file_path):
return os.path.abspath(file_path)
# 返回原始路径(让后续检查处理错误)
return file_path
def _generate_output_path(self, filename: str) -> str:
"""生成输出文件路径"""
if FOLDER_PATHS_AVAILABLE:
output_dir = folder_paths.get_output_directory()
else:
output_dir = "output"
# 确保输出目录存在
os.makedirs(output_dir, exist_ok=True)
# 处理文件名,确保是.glb扩展名
if not filename.lower().endswith('.glb'):
filename = f"{filename}.glb"
return os.path.join(output_dir, filename)
def _remove_dark_points(self, vertices: np.ndarray, colors: np.ndarray, keep_mask: np.ndarray,
black_threshold: int, processing_log: List[str]) -> int:
"""删除暗色点"""
removed_count = 0
# 确保颜色数据格式正确
if colors.shape[1] == 4: # RGBA
rgb_colors = colors[:, :3]
else: # RGB
rgb_colors = colors
# 如果颜色值在0-1范围内,转换为0-255
if rgb_colors.max() <= 1.0:
rgb_colors = (rgb_colors * 255).astype(np.uint8)
else:
rgb_colors = rgb_colors.astype(np.uint8)
# 计算RGB总和
rgb_sum = rgb_colors.sum(axis=1)
# 应用黑色阈值过滤
if black_threshold > 0:
dark_mask = rgb_sum >= black_threshold
before_count = keep_mask.sum()
keep_mask &= dark_mask
after_count = keep_mask.sum()
removed_by_black = before_count - after_count
removed_count += removed_by_black
processing_log.append(f" 按RGB总和阈值({black_threshold})删除: {removed_by_black} 个点")
return removed_count
class GLBPointCloudBounds:
"""GLB点云包围盒计算器 - 计算包围盒并生成带可视化的预览文件(原模型数据保持不变)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"glb_file_path": ("STRING", {
"default": "",
"tooltip": "GLB点云文件路径:输入需要计算包围盒的GLB格式点云文件。支持绝对路径或相对于ComfyUI output目录的相对路径。注意:此节点在原模型基础上添加预览信息,原模型数据保持不变"
}),
},
"optional": {
"bounding_box_type": (["axis_aligned", "oriented"], {
"default": "axis_aligned",
"tooltip": "包围盒类型:axis_aligned=轴对齐包围盒(AABB),计算快速;oriented=有向包围盒(OBB),体积最小"
}),
"units": (["meters", "centimeters", "millimeters"], {
"default": "meters",
"tooltip": "输出单位:指定包围盒尺寸的输出单位"
}),
"add_bounding_box_visualization": ("BOOLEAN", {
"default": True,
"tooltip": "添加包围盒可视化:是否在输出的点云文件中添加红色包围盒线框点云,便于直接预览验证"
}),
"add_coordinate_axes": ("BOOLEAN", {
"default": False,
"tooltip": "添加坐标轴:是否添加RGB坐标轴(X=红色,Y=绿色,Z=蓝色)到输出点云中"
}),
"wireframe_density": ("INT", {
"default": 150, "min": 20, "max": 200, "step": 10,
"tooltip": "线框密度:每条包围盒边的点数,越高线框越清晰但点数越多。推荐50-100获得清晰效果"
}),
"enhance_visibility": ("BOOLEAN", {
"default": True,
"tooltip": "增强可见性:添加顶点高亮和面中心标记,让包围盒更容易识别"
}),
"output_filename": ("STRING", {
"default": "pointcloud_with_bounds",
"tooltip": "输出文件名:带包围盒可视化的点云文件名,系统会自动添加.glb扩展名"
}),
}
}
RETURN_TYPES = (
"STRING", # 输出的GLB文件路径
"STRING", # JSON格式的scale数组
)
RETURN_NAMES = (
"output_glb_path",
"scale_json",
)
OUTPUT_TOOLTIPS = [
"带预览的GLB文件路径 - 包含原始点云+包围盒可视化+坐标轴的完整点云文件",
"纯净的scale数组JSON: {\"scale\": [长, 宽, 高]} - 仅包含scale信息,可直接用于3D引擎缩放",
]
OUTPUT_NODE = True
FUNCTION = "calculate_bounds_and_visualize"
CATEGORY = "💃VVL/Point Cloud Analysis"
def calculate_bounds_and_visualize(self,
glb_file_path: str,
bounding_box_type: str = "axis_aligned",
units: str = "meters",
add_bounding_box_visualization: bool = True,
add_coordinate_axes: bool = True,
wireframe_density: int = 50,
enhance_visibility: bool = True,
output_filename: str = "pointcloud_with_bounds"):
"""
计算GLB点云文件的包围盒尺寸并生成带可视化的点云文件
"""
processing_log = []
processing_log.append("开始GLB点云包围盒计算和可视化生成...")
# 检查依赖
if not TRIMESH_AVAILABLE:
error_msg = "trimesh库不可用,无法处理GLB文件"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 验证输入文件路径
if not glb_file_path or not glb_file_path.strip():
error_msg = "GLB文件路径为空"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 处理文件路径
input_path = self._resolve_file_path(glb_file_path.strip())
self._current_input_path = input_path # 保存当前输入路径供后续使用
processing_log.append(f"输入文件路径: {input_path}")
if not os.path.exists(input_path):
error_msg = f"GLB文件不存在: {input_path}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("", "")
try:
# 加载GLB文件
processing_log.append("正在加载GLB文件...")
scene = trimesh.load(input_path)
# 收集所有点云数据,考虑变换矩阵
all_vertices = []
point_cloud_count = 0
if isinstance(scene, trimesh.Scene):
for node_name in scene.graph.nodes:
if node_name in scene.geometry:
geometry = scene.geometry[node_name]
transform_matrix = scene.graph[node_name][0] # 获取变换矩阵
if hasattr(geometry, 'vertices') and geometry.vertices is not None:
# 应用变换矩阵获取真实世界坐标
if transform_matrix is not None and not np.allclose(transform_matrix, np.eye(4)):
world_vertices = trimesh.transformations.transform_points(geometry.vertices, transform_matrix)
processing_log.append(f"发现几何体: {node_name}, 点数: {len(geometry.vertices)}, 已应用变换矩阵")
else:
world_vertices = geometry.vertices.copy()
processing_log.append(f"发现几何体: {node_name}, 点数: {len(geometry.vertices)}, 无变换")
all_vertices.append(world_vertices)
point_cloud_count += 1
elif isinstance(scene, trimesh.PointCloud):
all_vertices.append(scene.vertices)
point_cloud_count = 1
processing_log.append(f"发现点云: main_pointcloud, 点数: {len(scene.vertices)}")
elif hasattr(scene, 'vertices') and scene.vertices is not None:
all_vertices.append(scene.vertices)
point_cloud_count = 1
processing_log.append(f"发现几何体(作为点云处理): main_geometry, 点数: {len(scene.vertices)}")
else:
error_msg = "GLB文件中未找到点云或几何体数据"
processing_log.append(f"错误: {error_msg}")
return ("", "")
if not all_vertices:
error_msg = "GLB文件中没有可用的顶点数据"
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 合并所有顶点(已经是世界坐标)
combined_vertices = np.vstack(all_vertices)
total_points = len(combined_vertices)
processing_log.append(f"合并了 {point_cloud_count} 个几何体,总点数: {total_points} (已应用变换矩阵)")
# 计算包围盒
processing_log.append(f"计算包围盒类型: {bounding_box_type}")
if bounding_box_type == "axis_aligned":
# 轴对齐包围盒 (AABB)
min_point = np.min(combined_vertices, axis=0)
max_point = np.max(combined_vertices, axis=0)
extents = max_point - min_point
center = (min_point + max_point) / 2
processing_log.append(f"AABB计算完成:")
processing_log.append(f" 最小点: [{min_point[0]:.6f}, {min_point[1]:.6f}, {min_point[2]:.6f}]")
processing_log.append(f" 最大点: [{max_point[0]:.6f}, {max_point[1]:.6f}, {max_point[2]:.6f}]")
processing_log.append(f" 中心点: [{center[0]:.6f}, {center[1]:.6f}, {center[2]:.6f}]")
processing_log.append(f" 尺寸: [{extents[0]:.6f}, {extents[1]:.6f}, {extents[2]:.6f}]")
processing_log.append(f" 体积: {np.prod(extents):.6f}")
else: # oriented
# 有向包围盒 (OBB)
try:
to_origin, obb_extents = trimesh.bounds.oriented_bounds(combined_vertices)
# 计算OBB的中心点
obb_center = -to_origin[:3, 3] # 变换矩阵的平移部分的负值
center = obb_center # 设置center变量
extents = obb_extents
processing_log.append(f"OBB计算完成:")
processing_log.append(f" 中心点: [{center[0]:.6f}, {center[1]:.6f}, {center[2]:.6f}]")
processing_log.append(f" 尺寸: [{extents[0]:.6f}, {extents[1]:.6f}, {extents[2]:.6f}]")
processing_log.append(f" 体积: {np.prod(extents):.6f}")
except Exception as e:
processing_log.append(f"OBB计算失败,回退到AABB: {str(e)}")
# 回退到AABB
min_point = np.min(combined_vertices, axis=0)
max_point = np.max(combined_vertices, axis=0)
extents = max_point - min_point
center = (min_point + max_point) / 2
# 应用单位转换
unit_scale = {"meters": 1.0, "centimeters": 100.0, "millimeters": 1000.0}
scale_factor = unit_scale.get(units, 1.0)
scaled_extents = extents * scale_factor
# 生成scale JSON (按照 [长, 宽, 高] 的顺序,通常是 [X, Y, Z])
scale_array = [float(scaled_extents[0]), float(scaled_extents[1]), float(scaled_extents[2])]
scale_json = {
"name": output_filename,
"scale": scale_array
}
processing_log.append(f"")
processing_log.append(f"输出结果 (单位: {units}):")
processing_log.append(f" Scale数组: [{scale_array[0]:.6f}, {scale_array[1]:.6f}, {scale_array[2]:.6f}]")
processing_log.append(f" 长度(X): {scale_array[0]:.6f} {units}")
processing_log.append(f" 宽度(Y): {scale_array[1]:.6f} {units}")
processing_log.append(f" 高度(Z): {scale_array[2]:.6f} {units}")
# 生成带可视化的点云文件(原模型数据不变,仅添加预览)
output_glb_path = ""
try:
processing_log.append("正在生成带包围盒可视化的点云文件...")
output_glb_path = self._generate_visualization_pointcloud(
combined_vertices, extents, center, bounding_box_type,
add_bounding_box_visualization, add_coordinate_axes,
wireframe_density, enhance_visibility, output_filename, processing_log
)
processing_log.append("可视化点云文件生成完成!")
except Exception as e:
processing_log.append(f"可视化点云生成失败: {str(e)}")
import traceback
traceback.print_exc()
processing_log.append("GLB点云包围盒计算和可视化完成!")
return (
output_glb_path,
json.dumps(scale_json, indent=2),
)
except Exception as e:
error_msg = f"计算包围盒时发生错误: {str(e)}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
import traceback
traceback.print_exc()
return ("", "")
def _resolve_file_path(self, file_path: str) -> str:
"""解析文件路径,支持绝对路径和相对路径"""
if os.path.isabs(file_path):
return file_path
# 尝试相对于ComfyUI输出目录
if FOLDER_PATHS_AVAILABLE:
output_dir = folder_paths.get_output_directory()
candidate_path = os.path.join(output_dir, file_path)
if os.path.exists(candidate_path):
return candidate_path
# 尝试相对于当前工作目录
if os.path.exists(file_path):
return os.path.abspath(file_path)
# 返回原始路径(让后续检查处理错误)
return file_path
def _get_current_input_path(self):
"""获取当前输入文件路径"""
return getattr(self, '_current_input_path', None)
def _normalize_colors(self, colors):
"""标准化颜色数组为RGBA格式,0-255范围"""
if colors is None:
return None
colors = np.array(colors)
# 如果是0-1范围,转换为0-255
if colors.max() <= 1.0:
colors = (colors * 255).astype(np.uint8)
else:
colors = colors.astype(np.uint8)
# 确保是RGBA格式
if colors.shape[1] == 3: # RGB -> RGBA
alpha_channel = np.full((colors.shape[0], 1), 255, dtype=np.uint8)
colors = np.hstack([colors, alpha_channel])
elif colors.shape[1] != 4:
# 如果不是3或4通道,创建白色RGBA
colors = np.tile([255, 255, 255, 255], (colors.shape[0], 1)).astype(np.uint8)
return colors
def _generate_visualization_pointcloud(self, combined_vertices, extents, center, bounds_type,
add_bounding_box_visualization, add_coordinate_axes,
wireframe_density, enhance_visibility, output_filename, processing_log):
"""生成包含原始点云、包围盒线框和坐标轴的可视化点云文件"""
try:
# 直接使用已加载的原始场景数据,避免重新加载可能改变朝向
input_path = self._get_current_input_path()
# 提取原始点云数据(完全保持原始状态)
all_vertices = []
all_colors = []
original_point_count = 0
# 使用之前已经加载的combined_vertices作为基础,重新从原始场景提取完整数据
original_scene = trimesh.load(input_path)
# 创建一个新的场景,完全保持原始场景的结构和变换
visualization_scene = trimesh.Scene()
# 保持原始场景的完整结构
if isinstance(original_scene, trimesh.Scene):
# 完全复制原始场景的所有几何体和变换
for node_name in original_scene.graph.nodes:
if node_name in original_scene.geometry:
geometry = original_scene.geometry[node_name]
transform_matrix = original_scene.graph[node_name][0] # 获取变换矩阵
# 完全复制几何体,保持所有属性
copied_geometry = geometry.copy()
# 添加到新场景,保持原始的变换矩阵
visualization_scene.add_geometry(copied_geometry, node_name=node_name, transform=transform_matrix)
# 提取顶点用于统计(注意:这里用于显示统计,真实包围盒计算已在前面完成)
if hasattr(geometry, 'vertices') and geometry.vertices is not None:
# 这里不需要再次应用变换,因为包围盒计算已经在combined_vertices中处理过了
all_vertices.append(geometry.vertices)
# 保持原始颜色
if isinstance(geometry, trimesh.PointCloud):
if hasattr(geometry.visual, 'vertex_colors') and geometry.visual.vertex_colors is not None:
colors = geometry.visual.vertex_colors.copy()
elif hasattr(geometry, 'colors') and geometry.colors is not None:
colors = geometry.colors.copy()
else:
colors = np.tile([255, 255, 255, 255], (len(geometry.vertices), 1))
else:
if hasattr(geometry.visual, 'vertex_colors') and geometry.visual.vertex_colors is not None:
colors = geometry.visual.vertex_colors.copy()
else:
colors = np.tile([200, 200, 200, 255], (len(geometry.vertices), 1))
# 确保颜色格式一致 (RGBA, 0-255)
colors = self._normalize_colors(colors)
all_colors.append(colors)
original_point_count += len(geometry.vertices)
processing_log.append(f"完全保持原始几何体: {node_name}, 变换矩阵和所有属性已保留")
elif isinstance(original_scene, trimesh.PointCloud):
# 单个点云,直接复制
visualization_scene = original_scene.copy()
all_vertices.append(original_scene.vertices)
if hasattr(original_scene.visual, 'vertex_colors') and original_scene.visual.vertex_colors is not None:
colors = original_scene.visual.vertex_colors.copy()
elif hasattr(original_scene, 'colors') and original_scene.colors is not None:
colors = original_scene.colors.copy()
else:
colors = np.tile([255, 255, 255, 255], (len(original_scene.vertices), 1))
colors = self._normalize_colors(colors)
all_colors.append(colors)
original_point_count = len(original_scene.vertices)
processing_log.append(f"完全保持原始点云: {original_point_count:,} 个点")
else:
# 单个几何体,转换为场景保持结构
visualization_scene.add_geometry(original_scene.copy(), node_name="main_geometry")
if hasattr(original_scene, 'vertices') and original_scene.vertices is not None:
all_vertices.append(original_scene.vertices)
if hasattr(original_scene.visual, 'vertex_colors') and original_scene.visual.vertex_colors is not None:
colors = original_scene.visual.vertex_colors.copy()
else:
colors = np.tile([200, 200, 200, 255], (len(original_scene.vertices), 1))
colors = self._normalize_colors(colors)
all_colors.append(colors)
original_point_count = len(original_scene.vertices)
processing_log.append(f"完全保持原始几何体: {original_point_count:,} 个点")
processing_log.append(f"原始模型结构完全保留: {original_point_count:,} 个点,所有变换矩阵和朝向不变")
# 添加包围盒线框点云到场景
if add_bounding_box_visualization:
box_vertices, box_colors = self._create_bounding_box_pointcloud(
extents, center, bounds_type, wireframe_density, enhance_visibility, processing_log
)
if len(box_vertices) > 0:
# 创建包围盒点云并添加到场景
bounding_box_pointcloud = trimesh.PointCloud(vertices=box_vertices, colors=box_colors)
visualization_scene.add_geometry(bounding_box_pointcloud, node_name="bounding_box_visualization")
processing_log.append(f"包围盒线框: {len(box_vertices):,} 个点")
# 添加坐标轴点云到场景
if add_coordinate_axes:
axes_vertices, axes_colors = self._create_coordinate_axes_pointcloud(
extents, center, wireframe_density, processing_log
)
if len(axes_vertices) > 0:
# 创建坐标轴点云并添加到场景
coordinate_axes_pointcloud = trimesh.PointCloud(vertices=axes_vertices, colors=axes_colors)
visualization_scene.add_geometry(coordinate_axes_pointcloud, node_name="coordinate_axes")
processing_log.append(f"坐标轴: {len(axes_vertices):,} 个点")
# 统计总点数
total_visualization_points = original_point_count
box_point_count = 0
axes_point_count = 0
if add_bounding_box_visualization:
box_point_count = len(box_vertices) if 'box_vertices' in locals() and len(box_vertices) > 0 else 0
total_visualization_points += box_point_count
if add_coordinate_axes:
axes_point_count = len(axes_vertices) if 'axes_vertices' in locals() and len(axes_vertices) > 0 else 0
total_visualization_points += axes_point_count
processing_log.append(f"总点数: {total_visualization_points:,} 个点")
# 生成输出路径
output_path = self._generate_output_path(output_filename)
# 保存场景,完全保持原始模型的结构和朝向
visualization_scene.export(output_path)
processing_log.append(f"可视化点云已保存: {output_path}")
return output_path
except Exception as e:
processing_log.append(f"可视化点云生成失败: {str(e)}")
raise e
def _create_bounding_box_pointcloud(self, extents, center, bounds_type, wireframe_density, enhance_visibility, processing_log):
"""创建包围盒线框的点云表示"""
try:
# 计算包围盒的8个顶点
min_point = center - extents/2
max_point = center + extents/2
box_vertices = np.array([
[min_point[0], min_point[1], min_point[2]], # 0: min corner
[max_point[0], min_point[1], min_point[2]], # 1
[max_point[0], max_point[1], min_point[2]], # 2
[min_point[0], max_point[1], min_point[2]], # 3
[min_point[0], min_point[1], max_point[2]], # 4
[max_point[0], min_point[1], max_point[2]], # 5
[max_point[0], max_point[1], max_point[2]], # 6: max corner
[min_point[0], max_point[1], max_point[2]], # 7
])
# 定义包围盒的12条边
edges = [
(0, 1), (1, 2), (2, 3), (3, 0), # 底面
(4, 5), (5, 6), (6, 7), (7, 4), # 顶面
(0, 4), (1, 5), (2, 6), (3, 7), # 垂直边
]
wireframe_points = []
# 1. 生成线框边上的点(更密集)
for start_idx, end_idx in edges:
start_point = box_vertices[start_idx]
end_point = box_vertices[end_idx]
# 在每条边上生成密集的点
for i in range(wireframe_density):
t = i / (wireframe_density - 1) if wireframe_density > 1 else 0
point = start_point + t * (end_point - start_point)
wireframe_points.append(point)
# 2. 添加顶点高亮和面中心标记(可选增强可见性)
if enhance_visibility:
# 顶点高亮(每个顶点周围添加小点云)
vertex_highlight_radius = np.min(extents) * 0.01 # 顶点高亮半径
highlight_density = max(5, wireframe_density // 10) # 顶点周围的点数
for vertex in box_vertices:
# 在每个顶点周围创建小的点云球
for i in range(highlight_density):
# 生成随机方向
theta = np.random.uniform(0, 2 * np.pi)
phi = np.random.uniform(0, np.pi)
r = np.random.uniform(0, vertex_highlight_radius)
# 球坐标转换为笛卡尔坐标
x = vertex[0] + r * np.sin(phi) * np.cos(theta)
y = vertex[1] + r * np.sin(phi) * np.sin(theta)
z = vertex[2] + r * np.cos(phi)
wireframe_points.append([x, y, z])
# 面中心点标记
face_centers = [
# 底面中心
(box_vertices[0] + box_vertices[1] + box_vertices[2] + box_vertices[3]) / 4,
# 顶面中心
(box_vertices[4] + box_vertices[5] + box_vertices[6] + box_vertices[7]) / 4,
# 前面中心
(box_vertices[0] + box_vertices[1] + box_vertices[4] + box_vertices[5]) / 4,
# 后面中心
(box_vertices[2] + box_vertices[3] + box_vertices[6] + box_vertices[7]) / 4,
# 左面中心
(box_vertices[0] + box_vertices[3] + box_vertices[4] + box_vertices[7]) / 4,
# 右面中心
(box_vertices[1] + box_vertices[2] + box_vertices[5] + box_vertices[6]) / 4,
]
# 在每个面中心添加标记点
face_mark_density = max(3, wireframe_density // 20)
face_mark_radius = np.min(extents) * 0.005
for face_center in face_centers:
for i in range(face_mark_density):
# 在面中心周围添加小的点云
theta = np.random.uniform(0, 2 * np.pi)
phi = np.random.uniform(0, np.pi)
r = np.random.uniform(0, face_mark_radius)
x = face_center[0] + r * np.sin(phi) * np.cos(theta)
y = face_center[1] + r * np.sin(phi) * np.sin(theta)
z = face_center[2] + r * np.cos(phi)
wireframe_points.append([x, y, z])
wireframe_vertices = np.array(wireframe_points)
# 为包围盒线框设置红色
wireframe_colors = np.tile([255, 0, 0, 255], (len(wireframe_vertices), 1))
processing_log.append(f"包围盒线框生成: {len(wireframe_vertices):,} 个点 (密度={wireframe_density})")
return wireframe_vertices, wireframe_colors
except Exception as e:
processing_log.append(f"创建包围盒点云失败: {str(e)}")
return np.array([]), np.array([])
def _create_coordinate_axes_pointcloud(self, extents, origin_center, wireframe_density, processing_log):
"""创建坐标轴的点云表示"""
try:
axis_length = np.max(extents) * 0.4
axis_thickness = np.min(extents) * 0.005 # 坐标轴厚度
axes_points = []
axes_colors = []
# 计算坐标轴密度
axis_line_density = wireframe_density
axis_thickness_points = max(3, wireframe_density // 15) # 厚度方向的点数
# X轴 - 红色(粗线条)- 从点云中心开始
for i in range(axis_line_density):
t = i / (axis_line_density - 1) if axis_line_density > 1 else 0
base_point = [origin_center[0] + t * axis_length, origin_center[1], origin_center[2]]
# 主轴线
axes_points.append(base_point)
axes_colors.append([255, 0, 0, 255])
# 增加厚度(在YZ平面上添加点)
for j in range(axis_thickness_points):
for k in range(axis_thickness_points):
offset_y = (j - axis_thickness_points//2) * axis_thickness / axis_thickness_points
offset_z = (k - axis_thickness_points//2) * axis_thickness / axis_thickness_points
thick_point = [base_point[0], base_point[1] + offset_y, base_point[2] + offset_z]
axes_points.append(thick_point)
axes_colors.append([255, 0, 0, 255])
# X轴箭头头部
arrow_length = axis_length * 0.1
arrow_base = axis_length * 0.9
for i in range(axis_line_density // 2):
t = i / (axis_line_density // 2 - 1) if axis_line_density > 2 else 0
# 箭头点
arrow_x = origin_center[0] + arrow_base + t * arrow_length
arrow_offset = (1 - t) * axis_thickness * 2
axes_points.append([arrow_x, origin_center[1] + arrow_offset, origin_center[2]])
axes_points.append([arrow_x, origin_center[1] - arrow_offset, origin_center[2]])
axes_points.append([arrow_x, origin_center[1], origin_center[2] + arrow_offset])
axes_points.append([arrow_x, origin_center[1], origin_center[2] - arrow_offset])
axes_colors.extend([[255, 0, 0, 255]] * 4)
# Y轴 - 绿色(粗线条)- 从点云中心开始
for i in range(axis_line_density):
t = i / (axis_line_density - 1) if axis_line_density > 1 else 0
base_point = [origin_center[0], origin_center[1] + t * axis_length, origin_center[2]]
# 主轴线
axes_points.append(base_point)
axes_colors.append([0, 255, 0, 255])
# 增加厚度(在XZ平面上添加点)
for j in range(axis_thickness_points):
for k in range(axis_thickness_points):
offset_x = (j - axis_thickness_points//2) * axis_thickness / axis_thickness_points
offset_z = (k - axis_thickness_points//2) * axis_thickness / axis_thickness_points
thick_point = [base_point[0] + offset_x, base_point[1], base_point[2] + offset_z]
axes_points.append(thick_point)
axes_colors.append([0, 255, 0, 255])
# Y轴箭头头部
for i in range(axis_line_density // 2):
t = i / (axis_line_density // 2 - 1) if axis_line_density > 2 else 0
arrow_y = origin_center[1] + arrow_base + t * arrow_length
arrow_offset = (1 - t) * axis_thickness * 2
axes_points.append([origin_center[0] + arrow_offset, arrow_y, origin_center[2]])
axes_points.append([origin_center[0] - arrow_offset, arrow_y, origin_center[2]])
axes_points.append([origin_center[0], arrow_y, origin_center[2] + arrow_offset])
axes_points.append([origin_center[0], arrow_y, origin_center[2] - arrow_offset])
axes_colors.extend([[0, 255, 0, 255]] * 4)
# Z轴 - 蓝色(粗线条)- 从点云中心开始
for i in range(axis_line_density):
t = i / (axis_line_density - 1) if axis_line_density > 1 else 0
base_point = [origin_center[0], origin_center[1], origin_center[2] + t * axis_length]
# 主轴线
axes_points.append(base_point)
axes_colors.append([0, 0, 255, 255])
# 增加厚度(在XY平面上添加点)
for j in range(axis_thickness_points):
for k in range(axis_thickness_points):
offset_x = (j - axis_thickness_points//2) * axis_thickness / axis_thickness_points
offset_y = (k - axis_thickness_points//2) * axis_thickness / axis_thickness_points
thick_point = [base_point[0] + offset_x, base_point[1] + offset_y, base_point[2]]
axes_points.append(thick_point)
axes_colors.append([0, 0, 255, 255])
# Z轴箭头头部
for i in range(axis_line_density // 2):
t = i / (axis_line_density // 2 - 1) if axis_line_density > 2 else 0
arrow_z = origin_center[2] + arrow_base + t * arrow_length
arrow_offset = (1 - t) * axis_thickness * 2
axes_points.append([origin_center[0] + arrow_offset, origin_center[1], arrow_z])
axes_points.append([origin_center[0] - arrow_offset, origin_center[1], arrow_z])
axes_points.append([origin_center[0], origin_center[1] + arrow_offset, arrow_z])
axes_points.append([origin_center[0], origin_center[1] - arrow_offset, arrow_z])
axes_colors.extend([[0, 0, 255, 255]] * 4)
axes_vertices = np.array(axes_points)
axes_colors_array = np.array(axes_colors)
processing_log.append(f"坐标轴生成: {len(axes_vertices):,} 个点 (长度={axis_length:.3f}, 原点=[{origin_center[0]:.3f}, {origin_center[1]:.3f}, {origin_center[2]:.3f}])")
return axes_vertices, axes_colors_array
except Exception as e:
processing_log.append(f"创建坐标轴点云失败: {str(e)}")
return np.array([]), np.array([])
def _generate_output_path(self, output_filename):
"""生成输出文件路径"""
# 确保文件名有正确的扩展名
if not output_filename.lower().endswith('.glb'):
output_filename += '.glb'
# 生成输出路径
output_dir = folder_paths.get_output_directory() if FOLDER_PATHS_AVAILABLE else "output"
os.makedirs(output_dir, exist_ok=True)
# 添加时间戳避免文件名冲突
import time
timestamp = str(int(time.time()))
name_parts = output_filename.rsplit('.', 1)
if len(name_parts) == 2:
timestamped_filename = f"{name_parts[0]}_{timestamp}.{name_parts[1]}"
else:
timestamped_filename = f"{output_filename}_{timestamp}"
return os.path.join(output_dir, timestamped_filename)
class GLBPointCloudOriginAdjuster:
"""GLB点云原点调整计算器 - 计算原点调整信息并生成带坐标轴的预览文件(原模型数据保持不变)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"glb_file_path": ("STRING", {
"default": "",
"tooltip": "GLB点云文件路径:输入需要调整原点的GLB格式点云文件。注意:此节点在原模型基础上添加坐标轴预览,原模型数据保持不变"
}),
},
"optional": {
"origin_mode": (["center", "bottom_center"], {
"default": "bottom_center",
"tooltip": "原点模式:center=点云几何中心;bottom_center=点云底部中心(脚底)"
}),
"output_units": (["meters", "centimeters", "millimeters"], {
"default": "centimeters",
"tooltip": "输出单位:变换信息的输出单位"
}),
"add_coordinate_axes": ("BOOLEAN", {
"default": True,
"tooltip": "添加坐标轴:是否添加RGB坐标轴(X=红色,Y=绿色,Z=蓝色)到输出点云中,显示调整后的原点位置"
}),
"wireframe_density": ("INT", {
"default": 100, "min": 20, "max": 200, "step": 10,
"tooltip": "坐标轴密度:每条坐标轴的点数,越高坐标轴越清晰但点数越多。推荐80-120获得清晰效果"
}),
"output_filename": ("STRING", {
"default": "adjusted_pointcloud",
"tooltip": "输出文件名:调整后的点云文件名,系统会自动添加.glb扩展名"
}),
}
}
RETURN_TYPES = (
"STRING", # 处理后的GLB文件路径
"STRING", # 变换信息JSON (position)
)
RETURN_NAMES = (
"adjusted_glb_path",
"transform_info",
)
OUTPUT_TOOLTIPS = [
"带预览的GLB文件路径 - 包含原始点云+坐标轴预览的完整点云文件",
"纯净的position信息JSON: {\"position\": [x, y, z]} - 仅包含位置信息,可直接用于UE等引擎",
]
OUTPUT_NODE = True
FUNCTION = "adjust_origin"
CATEGORY = "💃VVL/Point Cloud Transform"
def adjust_origin(self,
glb_file_path: str,
origin_mode: str = "bottom_center",
output_units: str = "centimeters",
add_coordinate_axes: bool = False,
wireframe_density: int = 100,
output_filename: str = "adjusted_pointcloud"):
"""
调整GLB点云的原点位置和旋转
"""
processing_log = []
processing_log.append("开始GLB点云原点调整...")
# 检查依赖
if not TRIMESH_AVAILABLE:
error_msg = "trimesh库不可用,无法处理GLB文件"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 验证输入文件路径
if not glb_file_path or not glb_file_path.strip():
error_msg = "GLB文件路径为空"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 处理文件路径
input_path = self._resolve_file_path(glb_file_path.strip())
processing_log.append(f"输入文件路径: {input_path}")
if not os.path.exists(input_path):
error_msg = f"GLB文件不存在: {input_path}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("", "")
try:
# 加载GLB文件
processing_log.append("正在加载GLB文件...")
scene = trimesh.load(input_path)
# 收集所有几何体和点云,考虑变换矩阵
geometries_to_transform = []
all_vertices = []
total_points = 0
if isinstance(scene, trimesh.Scene):
for node_name in scene.graph.nodes:
if node_name in scene.geometry:
geometry = scene.geometry[node_name]
transform_matrix = scene.graph[node_name][0] # 获取变换矩阵
geometries_to_transform.append((node_name, geometry))
if hasattr(geometry, 'vertices') and geometry.vertices is not None:
# 应用变换矩阵获取真实世界坐标
if transform_matrix is not None and not np.allclose(transform_matrix, np.eye(4)):
world_vertices = trimesh.transformations.transform_points(geometry.vertices, transform_matrix)
processing_log.append(f"发现几何体: {node_name}, 顶点数: {len(geometry.vertices)}, 已应用变换矩阵")
else:
world_vertices = geometry.vertices.copy()
processing_log.append(f"发现几何体: {node_name}, 顶点数: {len(geometry.vertices)}, 无变换")
all_vertices.append(world_vertices)
total_points += len(geometry.vertices)
else:
# 单个几何体
geometries_to_transform.append(("main_geometry", scene))
if hasattr(scene, 'vertices') and scene.vertices is not None:
all_vertices.append(scene.vertices)
total_points = len(scene.vertices)
processing_log.append(f"发现几何体: main_geometry, 顶点数: {total_points}")
if not all_vertices:
error_msg = "GLB文件中没有可用的顶点数据"
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 合并所有顶点计算包围盒(已经是世界坐标)
combined_vertices = np.vstack(all_vertices)
processing_log.append(f"总顶点数: {total_points:,} (已应用变换矩阵)")
# 计算原始包围盒
original_min = np.min(combined_vertices, axis=0)
original_max = np.max(combined_vertices, axis=0)
original_center = (original_min + original_max) / 2
original_size = original_max - original_min
processing_log.append(f"原始包围盒:")
processing_log.append(f" 最小点: [{original_min[0]:.6f}, {original_min[1]:.6f}, {original_min[2]:.6f}]")
processing_log.append(f" 最大点: [{original_max[0]:.6f}, {original_max[1]:.6f}, {original_max[2]:.6f}]")
processing_log.append(f" 中心点: [{original_center[0]:.6f}, {original_center[1]:.6f}, {original_center[2]:.6f}]")
processing_log.append(f" 尺寸: [{original_size[0]:.6f}, {original_size[1]:.6f}, {original_size[2]:.6f}]")
# 确定新的原点位置
if origin_mode == "center":
new_origin = original_center.copy()
processing_log.append("原点模式: 几何中心")
else: # bottom_center
new_origin = np.array([original_center[0], original_center[1], original_min[2]])
processing_log.append("原点模式: 底部中心(脚底)")
# 计算平移向量
translation = -new_origin
processing_log.append(f"平移向量: [{translation[0]:.6f}, {translation[1]:.6f}, {translation[2]:.6f}]")
# 创建平移变换矩阵(仅平移,无旋转)
transform_matrix = np.eye(4)
transform_matrix[:3, 3] = translation
# 应用变换到所有几何体
new_scene = trimesh.Scene()
transformed_vertices_list = []
for name, geometry in geometries_to_transform:
# 复制几何体
new_geometry = geometry.copy()
# 应用变换
new_geometry.apply_transform(transform_matrix)
# 添加到新场景
new_scene.add_geometry(new_geometry, node_name=name)
# 收集变换后的顶点用于统计
if hasattr(new_geometry, 'vertices') and new_geometry.vertices is not None:
transformed_vertices_list.append(new_geometry.vertices)
processing_log.append(f"已变换几何体: {name}")
# 添加坐标轴预览(在变换后的原点位置,即(0,0,0))
if add_coordinate_axes:
try:
# 计算变换后的包围盒来确定坐标轴长度
if transformed_vertices_list:
all_transformed_vertices = np.vstack(transformed_vertices_list)
transformed_extents = np.max(all_transformed_vertices, axis=0) - np.min(all_transformed_vertices, axis=0)
axis_length = np.max(transformed_extents) * 0.4
else:
axis_length = 1.0 # 默认长度
# 在新的原点(0,0,0)处生成坐标轴
axes_vertices, axes_colors = self._create_coordinate_axes_pointcloud_at_origin(
axis_length, wireframe_density, processing_log
)
if len(axes_vertices) > 0:
# 创建坐标轴点云
axes_pointcloud = trimesh.PointCloud(vertices=axes_vertices, colors=axes_colors)
new_scene.add_geometry(axes_pointcloud, node_name="coordinate_axes")
processing_log.append(f"坐标轴预览: {len(axes_vertices):,} 个点 (显示新的原点位置)")
except Exception as e:
processing_log.append(f"坐标轴生成失败: {str(e)}")
# 计算变换后的包围盒
if transformed_vertices_list:
transformed_vertices = np.vstack(transformed_vertices_list)
new_min = np.min(transformed_vertices, axis=0)
new_max = np.max(transformed_vertices, axis=0)
new_center = (new_min + new_max) / 2
new_size = new_max - new_min
processing_log.append(f"变换后包围盒:")
processing_log.append(f" 最小点: [{new_min[0]:.6f}, {new_min[1]:.6f}, {new_min[2]:.6f}]")
processing_log.append(f" 最大点: [{new_max[0]:.6f}, {new_max[1]:.6f}, {new_max[2]:.6f}]")
processing_log.append(f" 中心点: [{new_center[0]:.6f}, {new_center[1]:.6f}, {new_center[2]:.6f}]")
processing_log.append(f" 尺寸: [{new_size[0]:.6f}, {new_size[1]:.6f}, {new_size[2]:.6f}]")
# 生成带预览的输出文件(原模型数据不变,仅添加坐标轴预览)
output_path = self._generate_output_path(output_filename)
processing_log.append(f"输出文件路径: {output_path}")
# 创建包含原始点云和坐标轴的预览场景,完全保持原始结构
original_scene = trimesh.load(input_path) # 重新加载原始场景
# 创建预览场景,完全复制原始场景的结构
if isinstance(original_scene, trimesh.Scene):
preview_scene = trimesh.Scene()
# 完全复制原始场景的所有几何体和变换矩阵
for node_name in original_scene.graph.nodes:
if node_name in original_scene.geometry:
geometry = original_scene.geometry[node_name]
transform_matrix = original_scene.graph[node_name][0] # 获取变换矩阵
# 完全复制几何体,保持所有属性
copied_geometry = geometry.copy()
# 添加到预览场景,保持原始的变换矩阵
preview_scene.add_geometry(copied_geometry, node_name=node_name, transform=transform_matrix)
processing_log.append(f"完全保持原始几何体: {node_name}, 变换矩阵和朝向已保留")
else:
# 单个几何体的情况
preview_scene = trimesh.Scene()
preview_scene.add_geometry(original_scene.copy(), node_name="main_geometry")
processing_log.append("完全保持原始几何体: main_geometry, 所有属性已保留")
# 添加坐标轴预览(如果需要)
if add_coordinate_axes:
try:
# 使用原始包围盒来确定坐标轴长度
axis_length = np.max(original_size) * 0.4
# 在计算出的新原点位置生成坐标轴
axes_vertices, axes_colors = self._create_coordinate_axes_pointcloud_at_position(
new_origin, axis_length, wireframe_density, processing_log
)
if len(axes_vertices) > 0:
# 创建坐标轴点云并添加到场景
axes_pointcloud = trimesh.PointCloud(vertices=axes_vertices, colors=axes_colors)
preview_scene.add_geometry(axes_pointcloud, node_name="coordinate_axes_preview")
processing_log.append(f"坐标轴预览: {len(axes_vertices):,} 个点 (显示建议的新原点位置)")
except Exception as e:
processing_log.append(f"坐标轴生成失败: {str(e)}")
# 保存预览文件
processing_log.append("正在保存带预览的GLB文件...")
preview_scene.export(output_path)
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
processing_log.append(f"预览GLB文件保存成功,文件大小: {file_size} bytes")
else:
error_msg = "预览GLB文件保存失败"
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 应用单位转换
unit_scale = {"meters": 1.0, "centimeters": 100.0, "millimeters": 1000.0}
scale_factor = unit_scale.get(output_units, 1.0)
# 生成变换信息(UE格式)
final_position = new_origin * scale_factor # 相对于原始坐标系的位置
transform_info = {
"name": output_filename,
"position": [float(final_position[0]), float(final_position[1]), float(final_position[2])]
}
processing_log.append("")
processing_log.append(f"变换信息 (单位: {output_units}):")
processing_log.append(f" Position: [{transform_info['position'][0]:.2f}, {transform_info['position'][1]:.2f}, {transform_info['position'][2]:.2f}]")
processing_log.append("原点调整预览生成完成!")
return (
output_path,
json.dumps(transform_info, indent=2),
)
except Exception as e:
error_msg = f"调整原点时发生错误: {str(e)}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
import traceback
traceback.print_exc()
return ("", "")
def _resolve_file_path(self, file_path: str) -> str:
"""解析文件路径,支持绝对路径和相对路径"""
if os.path.isabs(file_path):
return file_path
# 尝试相对于ComfyUI输出目录
if FOLDER_PATHS_AVAILABLE:
output_dir = folder_paths.get_output_directory()
candidate_path = os.path.join(output_dir, file_path)
if os.path.exists(candidate_path):
return candidate_path
# 尝试相对于当前工作目录
if os.path.exists(file_path):
return os.path.abspath(file_path)
# 返回原始路径(让后续检查处理错误)
return file_path
def _generate_output_path(self, filename: str) -> str:
"""生成输出文件路径"""
# 确保文件名有正确的扩展名
if not filename.lower().endswith('.glb'):
filename += '.glb'
# 生成输出路径
output_dir = folder_paths.get_output_directory() if FOLDER_PATHS_AVAILABLE else "output"
os.makedirs(output_dir, exist_ok=True)
# 添加时间戳避免文件名冲突
timestamp = str(int(time.time()))
name_parts = filename.rsplit('.', 1)
if len(name_parts) == 2:
timestamped_filename = f"{name_parts[0]}_{timestamp}.{name_parts[1]}"
else:
timestamped_filename = f"{filename}_{timestamp}"
return os.path.join(output_dir, timestamped_filename)
def _create_coordinate_axes_pointcloud_at_position(self, position, axis_length, wireframe_density, processing_log):
"""在指定位置创建坐标轴的点云表示"""
try:
center = np.array(position) # 指定的位置
axis_thickness = axis_length * 0.01 # 坐标轴厚度
axes_points = []
axes_colors = []
# 计算坐标轴密度
axis_line_density = wireframe_density # 使用参数控制密度
axis_thickness_points = max(3, axis_line_density // 15) # 厚度方向的点数
# X轴 - 红色(从指定位置开始)
for i in range(axis_line_density):
t = i / (axis_line_density - 1) if axis_line_density > 1 else 0
base_point = [center[0] + t * axis_length, center[1], center[2]]
# 主轴线
axes_points.append(base_point)
axes_colors.append([255, 0, 0, 255])
# 增加厚度(在YZ平面上添加点)
for j in range(axis_thickness_points):
for k in range(axis_thickness_points):
offset_y = (j - axis_thickness_points//2) * axis_thickness / axis_thickness_points
offset_z = (k - axis_thickness_points//2) * axis_thickness / axis_thickness_points
thick_point = [base_point[0], base_point[1] + offset_y, base_point[2] + offset_z]
axes_points.append(thick_point)
axes_colors.append([255, 0, 0, 255])
# X轴箭头头部
arrow_length = axis_length * 0.1
arrow_base = axis_length * 0.9
for i in range(axis_line_density // 2):
t = i / (axis_line_density // 2 - 1) if axis_line_density > 2 else 0
arrow_x = center[0] + arrow_base + t * arrow_length
arrow_offset = (1 - t) * axis_thickness * 2
axes_points.append([arrow_x, center[1] + arrow_offset, center[2]])
axes_points.append([arrow_x, center[1] - arrow_offset, center[2]])
axes_points.append([arrow_x, center[1], center[2] + arrow_offset])
axes_points.append([arrow_x, center[1], center[2] - arrow_offset])
axes_colors.extend([[255, 0, 0, 255]] * 4)
# Y轴 - 绿色(从指定位置开始)
for i in range(axis_line_density):
t = i / (axis_line_density - 1) if axis_line_density > 1 else 0
base_point = [center[0], center[1] + t * axis_length, center[2]]
# 主轴线
axes_points.append(base_point)
axes_colors.append([0, 255, 0, 255])
# 增加厚度(在XZ平面上添加点)
for j in range(axis_thickness_points):
for k in range(axis_thickness_points):
offset_x = (j - axis_thickness_points//2) * axis_thickness / axis_thickness_points
offset_z = (k - axis_thickness_points//2) * axis_thickness / axis_thickness_points
thick_point = [base_point[0] + offset_x, base_point[1], base_point[2] + offset_z]
axes_points.append(thick_point)
axes_colors.append([0, 255, 0, 255])
# Y轴箭头头部
for i in range(axis_line_density // 2):
t = i / (axis_line_density // 2 - 1) if axis_line_density > 2 else 0
arrow_y = center[1] + arrow_base + t * arrow_length
arrow_offset = (1 - t) * axis_thickness * 2
axes_points.append([center[0] + arrow_offset, arrow_y, center[2]])
axes_points.append([center[0] - arrow_offset, arrow_y, center[2]])
axes_points.append([center[0], arrow_y, center[2] + arrow_offset])
axes_points.append([center[0], arrow_y, center[2] - arrow_offset])
axes_colors.extend([[0, 255, 0, 255]] * 4)
# Z轴 - 蓝色(从指定位置开始)
for i in range(axis_line_density):
t = i / (axis_line_density - 1) if axis_line_density > 1 else 0
base_point = [center[0], center[1], center[2] + t * axis_length]
# 主轴线
axes_points.append(base_point)
axes_colors.append([0, 0, 255, 255])
# 增加厚度(在XY平面上添加点)
for j in range(axis_thickness_points):
for k in range(axis_thickness_points):
offset_x = (j - axis_thickness_points//2) * axis_thickness / axis_thickness_points
offset_y = (k - axis_thickness_points//2) * axis_thickness / axis_thickness_points
thick_point = [base_point[0] + offset_x, base_point[1] + offset_y, base_point[2]]
axes_points.append(thick_point)
axes_colors.append([0, 0, 255, 255])
# Z轴箭头头部
for i in range(axis_line_density // 2):
t = i / (axis_line_density // 2 - 1) if axis_line_density > 2 else 0
arrow_z = center[2] + arrow_base + t * arrow_length
arrow_offset = (1 - t) * axis_thickness * 2
axes_points.append([center[0] + arrow_offset, center[1], arrow_z])
axes_points.append([center[0] - arrow_offset, center[1], arrow_z])
axes_points.append([center[0], center[1] + arrow_offset, arrow_z])
axes_points.append([center[0], center[1] - arrow_offset, arrow_z])
axes_colors.extend([[0, 0, 255, 255]] * 4)
axes_vertices = np.array(axes_points)
axes_colors_array = np.array(axes_colors)
processing_log.append(f"坐标轴生成: {len(axes_vertices):,} 个点 (长度={axis_length:.3f}, 密度={wireframe_density}, 位置=[{center[0]:.3f}, {center[1]:.3f}, {center[2]:.3f}])")
return axes_vertices, axes_colors_array
except Exception as e:
processing_log.append(f"创建坐标轴点云失败: {str(e)}")
return np.array([]), np.array([])
def _create_coordinate_axes_pointcloud_at_origin(self, axis_length, wireframe_density, processing_log):
"""在原点(0,0,0)创建坐标轴的点云表示"""
try:
origin_center = np.array([0.0, 0.0, 0.0]) # 新的原点位置
axis_thickness = axis_length * 0.01 # 坐标轴厚度
axes_points = []
axes_colors = []
# 计算坐标轴密度
axis_line_density = wireframe_density # 使用参数控制密度
axis_thickness_points = max(3, axis_line_density // 15) # 厚度方向的点数
# X轴 - 红色(从原点开始)
for i in range(axis_line_density):
t = i / (axis_line_density - 1) if axis_line_density > 1 else 0
base_point = [origin_center[0] + t * axis_length, origin_center[1], origin_center[2]]
# 主轴线
axes_points.append(base_point)
axes_colors.append([255, 0, 0, 255])
# 增加厚度(在YZ平面上添加点)
for j in range(axis_thickness_points):
for k in range(axis_thickness_points):
offset_y = (j - axis_thickness_points//2) * axis_thickness / axis_thickness_points
offset_z = (k - axis_thickness_points//2) * axis_thickness / axis_thickness_points
thick_point = [base_point[0], base_point[1] + offset_y, base_point[2] + offset_z]
axes_points.append(thick_point)
axes_colors.append([255, 0, 0, 255])
# X轴箭头头部
arrow_length = axis_length * 0.1
arrow_base = axis_length * 0.9
for i in range(axis_line_density // 2):
t = i / (axis_line_density // 2 - 1) if axis_line_density > 2 else 0
arrow_x = origin_center[0] + arrow_base + t * arrow_length
arrow_offset = (1 - t) * axis_thickness * 2
axes_points.append([arrow_x, origin_center[1] + arrow_offset, origin_center[2]])
axes_points.append([arrow_x, origin_center[1] - arrow_offset, origin_center[2]])
axes_points.append([arrow_x, origin_center[1], origin_center[2] + arrow_offset])
axes_points.append([arrow_x, origin_center[1], origin_center[2] - arrow_offset])
axes_colors.extend([[255, 0, 0, 255]] * 4)
# Y轴 - 绿色(从原点开始)
for i in range(axis_line_density):
t = i / (axis_line_density - 1) if axis_line_density > 1 else 0
base_point = [origin_center[0], origin_center[1] + t * axis_length, origin_center[2]]
# 主轴线
axes_points.append(base_point)
axes_colors.append([0, 255, 0, 255])
# 增加厚度(在XZ平面上添加点)
for j in range(axis_thickness_points):
for k in range(axis_thickness_points):
offset_x = (j - axis_thickness_points//2) * axis_thickness / axis_thickness_points
offset_z = (k - axis_thickness_points//2) * axis_thickness / axis_thickness_points
thick_point = [base_point[0] + offset_x, base_point[1], base_point[2] + offset_z]
axes_points.append(thick_point)
axes_colors.append([0, 255, 0, 255])
# Y轴箭头头部
for i in range(axis_line_density // 2):
t = i / (axis_line_density // 2 - 1) if axis_line_density > 2 else 0
arrow_y = origin_center[1] + arrow_base + t * arrow_length
arrow_offset = (1 - t) * axis_thickness * 2
axes_points.append([origin_center[0] + arrow_offset, arrow_y, origin_center[2]])
axes_points.append([origin_center[0] - arrow_offset, arrow_y, origin_center[2]])
axes_points.append([origin_center[0], arrow_y, origin_center[2] + arrow_offset])
axes_points.append([origin_center[0], arrow_y, origin_center[2] - arrow_offset])
axes_colors.extend([[0, 255, 0, 255]] * 4)
# Z轴 - 蓝色(从原点开始)
for i in range(axis_line_density):
t = i / (axis_line_density - 1) if axis_line_density > 1 else 0
base_point = [origin_center[0], origin_center[1], origin_center[2] + t * axis_length]
# 主轴线
axes_points.append(base_point)
axes_colors.append([0, 0, 255, 255])
# 增加厚度(在XY平面上添加点)
for j in range(axis_thickness_points):
for k in range(axis_thickness_points):
offset_x = (j - axis_thickness_points//2) * axis_thickness / axis_thickness_points
offset_y = (k - axis_thickness_points//2) * axis_thickness / axis_thickness_points
thick_point = [base_point[0] + offset_x, base_point[1] + offset_y, base_point[2]]
axes_points.append(thick_point)
axes_colors.append([0, 0, 255, 255])
# Z轴箭头头部
for i in range(axis_line_density // 2):
t = i / (axis_line_density // 2 - 1) if axis_line_density > 2 else 0
arrow_z = origin_center[2] + arrow_base + t * arrow_length
arrow_offset = (1 - t) * axis_thickness * 2
axes_points.append([origin_center[0] + arrow_offset, origin_center[1], arrow_z])
axes_points.append([origin_center[0] - arrow_offset, origin_center[1], arrow_z])
axes_points.append([origin_center[0], origin_center[1] + arrow_offset, arrow_z])
axes_points.append([origin_center[0], origin_center[1] - arrow_offset, arrow_z])
axes_colors.extend([[0, 0, 255, 255]] * 4)
axes_vertices = np.array(axes_points)
axes_colors_array = np.array(axes_colors)
processing_log.append(f"坐标轴生成: {len(axes_vertices):,} 个点 (长度={axis_length:.3f}, 密度={wireframe_density}, 原点=[0.000, 0.000, 0.000])")
return axes_vertices, axes_colors_array
except Exception as e:
processing_log.append(f"创建坐标轴点云失败: {str(e)}")
return np.array([]), np.array([])
class GLBPointCloudDensityFilter:
"""GLB点云密度过滤器 - 根据局部密度删除稀疏区域,保留密度最高的核心区域"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"glb_file_path": ("STRING", {
"default": "",
"tooltip": "GLB点云文件路径:输入需要进行密度过滤的GLB格式点云文件。支持绝对路径或相对于ComfyUI output目录的相对路径"
}),
},
"optional": {
"density_threshold": ("FLOAT", {
"default": 0.3, "min": 0.01, "max": 0.99, "step": 0.01,
"tooltip": "密度阈值:保留密度高于此比例的点。0.3=保留密度前70%的点;0.5=保留密度前50%的点。值越大删除越多稀疏点"
}),
"neighborhood_radius": ("FLOAT", {
"default": 0.1, "min": 0.01, "max": 1.0, "step": 0.01,
"tooltip": "邻域半径:计算密度时的搜索半径。较小值=精细密度检测;较大值=平滑密度检测。建议0.05-0.2"
}),
"min_neighbors": ("INT", {
"default": 5, "min": 1, "max": 50, "step": 1,
"tooltip": "最小邻居数:一个点在邻域内至少需要的邻居点数量才不被视为稀疏点。值越大要求密度越高"
}),
"preserve_core_percentage": ("FLOAT", {
"default": 0.8, "min": 0.1, "max": 1.0, "step": 0.05,
"tooltip": "核心区域保留比例:无论密度如何,都会保留密度最高的这部分点。0.8=保证保留最密集的80%区域"
}),
"use_adaptive_radius": ("BOOLEAN", {
"default": True,
"tooltip": "自适应半径:根据点云整体尺寸自动调整邻域半径,确保在不同尺寸的点云上都有良好效果"
}),
"output_filename": ("STRING", {
"default": "density_filtered_pointcloud",
"tooltip": "输出GLB文件名:密度过滤后点云的保存文件名,系统会自动添加.glb扩展名"
}),
}
}
RETURN_TYPES = (
"STRING", # 处理后的GLB文件路径
"STRING", # 过滤统计信息JSON
)
RETURN_NAMES = (
"filtered_glb_path",
"filter_stats",
)
OUTPUT_TOOLTIPS = [
"密度过滤后的GLB文件完整路径",
"过滤统计信息JSON:包含原始点数、保留点数、删除点数等统计数据",
]
OUTPUT_NODE = True
FUNCTION = "filter_by_density"
CATEGORY = "💃VVL/Point Cloud Filtering"
def filter_by_density(self,
glb_file_path: str,
density_threshold: float = 0.3,
neighborhood_radius: float = 0.1,
min_neighbors: int = 5,
preserve_core_percentage: float = 0.8,
use_adaptive_radius: bool = True,
output_filename: str = "density_filtered_pointcloud"):
"""
根据局部密度过滤GLB点云文件,删除稀疏区域
"""
processing_log = []
processing_log.append("开始GLB点云密度过滤...")
# 检查依赖
if not TRIMESH_AVAILABLE:
error_msg = "trimesh库不可用,无法处理GLB文件"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 验证输入文件路径
if not glb_file_path or not glb_file_path.strip():
error_msg = "GLB文件路径为空"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 处理文件路径
input_path = self._resolve_file_path(glb_file_path.strip())
processing_log.append(f"输入文件路径: {input_path}")
if not os.path.exists(input_path):
error_msg = f"GLB文件不存在: {input_path}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
return ("", "")
try:
# 加载GLB文件
processing_log.append("正在加载GLB文件...")
scene = trimesh.load(input_path)
# 提取点云数据
point_clouds = []
other_geometries = []
if isinstance(scene, trimesh.Scene):
for name, geometry in scene.geometry.items():
if isinstance(geometry, trimesh.PointCloud):
point_clouds.append((name, geometry))
processing_log.append(f"发现点云: {name}, 点数: {len(geometry.vertices)}")
else:
other_geometries.append((name, geometry))
elif isinstance(scene, trimesh.PointCloud):
point_clouds.append(("main_pointcloud", scene))
processing_log.append(f"发现点云: main_pointcloud, 点数: {len(scene.vertices)}")
else:
# 尝试转换为点云
if hasattr(scene, 'vertices'):
pc = trimesh.PointCloud(vertices=scene.vertices, colors=getattr(scene.visual, 'vertex_colors', None))
point_clouds.append(("converted_pointcloud", pc))
processing_log.append(f"转换为点云: converted_pointcloud, 点数: {len(pc.vertices)}")
else:
error_msg = "GLB文件中未找到点云数据"
processing_log.append(f"错误: {error_msg}")
return ("", "")
if not point_clouds:
error_msg = "GLB文件中没有点云数据"
processing_log.append(f"错误: {error_msg}")
return ("", "")
# 处理每个点云
filtered_point_clouds = []
total_original_points = 0
total_filtered_points = 0
for name, point_cloud in point_clouds:
processing_log.append(f"\n处理点云: {name}")
original_count = len(point_cloud.vertices)
total_original_points += original_count
# 获取原始点云的所有属性和状态
original_vertices = point_cloud.vertices.copy()
colors = None
# 获取颜色信息
if hasattr(point_cloud.visual, 'vertex_colors') and point_cloud.visual.vertex_colors is not None:
colors = point_cloud.visual.vertex_colors.copy()
elif hasattr(point_cloud, 'colors') and point_cloud.colors is not None:
colors = point_cloud.colors.copy()
# 应用密度过滤(仅基于密度,不改变顶点坐标)
keep_mask, filter_info = self._apply_density_filter(
original_vertices, colors, density_threshold, neighborhood_radius, min_neighbors,
preserve_core_percentage, use_adaptive_radius, processing_log
)
# 应用掩码,保留原始的顶点坐标
filtered_vertices = original_vertices[keep_mask]
filtered_colors = colors[keep_mask] if colors is not None else None
filtered_count = len(filtered_vertices)
total_filtered_points += filtered_count
processing_log.append(f"点云 {name}: 原始 {original_count} -> 保留 {filtered_count} (删除 {original_count - filtered_count})")
# 创建过滤后的点云,保留所有原始属性
if filtered_count > 0:
# 使用原始点云作为模板,只更新顶点和颜色
filtered_pc = point_cloud.copy()
filtered_pc.vertices = filtered_vertices
if filtered_colors is not None:
filtered_pc.visual.vertex_colors = filtered_colors
filtered_point_clouds.append((name, filtered_pc))
processing_log.append(f"保留点云 {name} 的所有原始属性和状态")
else:
processing_log.append(f"警告: 点云 {name} 过滤后没有剩余点")
# 直接修改原始场景,保持所有几何信息不变
if filtered_point_clouds:
# 使用原始场景作为基础,直接替换点云数据
new_scene = scene
# 直接修改原始场景中的点云几何体,保持变换不变
for name, filtered_pc in filtered_point_clouds:
if isinstance(scene, trimesh.Scene):
# 获取原始几何体
if name in scene.geometry:
original_geometry = scene.geometry[name]
# 如果是点云,直接修改顶点和颜色
if isinstance(original_geometry, trimesh.PointCloud):
original_geometry.vertices = filtered_pc.vertices
if hasattr(filtered_pc.visual, 'vertex_colors') and filtered_pc.visual.vertex_colors is not None:
original_geometry.visual.vertex_colors = filtered_pc.visual.vertex_colors
processing_log.append(f"直接修改原始点云 {name},保持所有变换和属性不变")
else:
# 如果不是点云类型,替换整个几何体但保持变换
scene.delete_geometry(name)
scene.add_geometry(filtered_pc, node_name=name)
processing_log.append(f"替换几何体 {name},尝试保持变换")
else:
# 新增几何体
scene.add_geometry(filtered_pc, node_name=name)
processing_log.append(f"添加新点云 {name}")
else:
# 单个几何体的情况
new_scene = filtered_pc
processing_log.append("单个几何体,直接使用过滤后的点云")
processing_log.append("直接修改原始场景,所有变换矩阵和几何属性完全保持不变")
# 生成输出路径
output_path = self._generate_output_path(output_filename)
processing_log.append(f"输出文件路径: {output_path}")
# 保存过滤后的GLB文件
processing_log.append("正在保存过滤后的GLB文件...")
new_scene.export(output_path)
# 验证文件是否保存成功
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
processing_log.append(f"GLB文件保存成功,文件大小: {file_size} bytes")
# 生成统计信息
filter_stats = {
"original_points": total_original_points,
"filtered_points": total_filtered_points,
"removed_points": total_original_points - total_filtered_points,
"retention_rate": total_filtered_points / total_original_points if total_original_points > 0 else 0,
"parameters": {
"density_threshold": density_threshold,
"neighborhood_radius": neighborhood_radius,
"min_neighbors": min_neighbors,
"preserve_core_percentage": preserve_core_percentage,
"use_adaptive_radius": use_adaptive_radius
}
}
stats_json = json.dumps(filter_stats, indent=2)
processing_log.append("GLB点云密度过滤完成! 已保留原始模型的大小、朝向和几何属性")
processing_log.append(f"统计: 原始{total_original_points}点 -> 保留{total_filtered_points}点 (保留率: {filter_stats['retention_rate']:.1%})")
return (output_path, stats_json)
else:
error_msg = "GLB文件保存失败"
processing_log.append(f"错误: {error_msg}")
return ("", "")
else:
error_msg = "过滤后没有剩余的点云数据"
processing_log.append(f"错误: {error_msg}")
return ("", "")
except Exception as e:
error_msg = f"处理GLB文件时发生错误: {str(e)}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
import traceback
traceback.print_exc()
return ("", "")
def _apply_density_filter(self, vertices, colors, density_threshold, neighborhood_radius,
min_neighbors, preserve_core_percentage, use_adaptive_radius, processing_log):
"""
应用密度过滤算法,返回保留点的掩码
"""
n_points = len(vertices)
processing_log.append(f"开始密度分析,总点数: {n_points}")
# 自适应半径调整
if use_adaptive_radius:
# 计算点云的包围盒对角线长度
bbox_min = np.min(vertices, axis=0)
bbox_max = np.max(vertices, axis=0)
bbox_diagonal = np.linalg.norm(bbox_max - bbox_min)
# 根据包围盒大小调整半径
adaptive_radius = neighborhood_radius * bbox_diagonal / 10.0 # 可调整的比例因子
processing_log.append(f"自适应半径: {neighborhood_radius} -> {adaptive_radius:.4f} (基于包围盒对角线 {bbox_diagonal:.4f})")
neighborhood_radius = adaptive_radius
# 使用KDTree进行高效的邻域搜索
try:
from scipy.spatial import cKDTree
kdtree = cKDTree(vertices)
processing_log.append("使用scipy.spatial.cKDTree进行邻域搜索")
except ImportError:
processing_log.append("scipy不可用,使用简化的距离计算")
kdtree = None
# 计算每个点的局部密度
densities = np.zeros(n_points)
if kdtree is not None:
# 高效的KDTree搜索
for i in range(n_points):
# 查找邻域内的点
neighbor_indices = kdtree.query_ball_point(vertices[i], neighborhood_radius)
densities[i] = len(neighbor_indices) - 1 # 减去自己
else:
# 简化的距离计算(适用于小点云)
for i in range(n_points):
distances = np.linalg.norm(vertices - vertices[i], axis=1)
neighbor_count = np.sum(distances <= neighborhood_radius) - 1 # 减去自己
densities[i] = neighbor_count
processing_log.append(f"密度统计: 最小={densities.min():.1f}, 最大={densities.max():.1f}, 平均={densities.mean():.1f}")
# 应用多层过滤策略
keep_mask = np.ones(n_points, dtype=bool)
# 1. 基于最小邻居数的过滤
min_neighbor_mask = densities >= min_neighbors
removed_by_min_neighbors = np.sum(~min_neighbor_mask)
keep_mask &= min_neighbor_mask
processing_log.append(f"最小邻居数过滤: 删除 {removed_by_min_neighbors} 个点")
# 2. 基于密度阈值的过滤
if density_threshold > 0 and np.sum(keep_mask) > 0:
valid_densities = densities[keep_mask]
density_percentile = np.percentile(valid_densities, (1 - density_threshold) * 100)
density_mask = densities >= density_percentile
# 保留当前mask中的点,再应用密度过滤
temp_mask = keep_mask.copy()
keep_mask[temp_mask] &= density_mask[temp_mask]
removed_by_density = np.sum(temp_mask) - np.sum(keep_mask)
processing_log.append(f"密度阈值过滤: 删除 {removed_by_density} 个点 (阈值密度: {density_percentile:.1f})")
# 3. 核心区域保护
if preserve_core_percentage < 1.0 and np.sum(keep_mask) > 0:
# 计算需要保留的核心点数
target_core_points = int(n_points * preserve_core_percentage)
current_points = np.sum(keep_mask)
if current_points < target_core_points:
# 当前保留的点数少于核心要求,需要添加高密度点
need_more_points = target_core_points - current_points
# 找到密度最高的点
excluded_indices = np.where(~keep_mask)[0]
if len(excluded_indices) > 0:
excluded_densities = densities[excluded_indices]
# 按密度排序,选择密度最高的点
sorted_indices = excluded_indices[np.argsort(-excluded_densities)]
points_to_restore = min(need_more_points, len(sorted_indices))
keep_mask[sorted_indices[:points_to_restore]] = True
processing_log.append(f"核心区域保护: 恢复 {points_to_restore} 个高密度点")
# 统计信息
original_count = n_points
filtered_count = np.sum(keep_mask)
removed_count = original_count - filtered_count
filter_info = {
"original_points": original_count,
"filtered_points": filtered_count,
"removed_points": removed_count,
"retention_rate": filtered_count / original_count if original_count > 0 else 0,
"density_stats": {
"min": float(densities.min()),
"max": float(densities.max()),
"mean": float(densities.mean()),
"std": float(densities.std())
}
}
processing_log.append(f"密度过滤完成: 保留 {filtered_count}/{original_count} 点 (保留率: {filter_info['retention_rate']:.1%})")
return keep_mask, filter_info
def _resolve_file_path(self, file_path: str) -> str:
"""解析文件路径,支持绝对路径和相对路径"""
if os.path.isabs(file_path):
return file_path
# 尝试相对于ComfyUI输出目录
if FOLDER_PATHS_AVAILABLE:
output_dir = folder_paths.get_output_directory()
candidate_path = os.path.join(output_dir, file_path)
if os.path.exists(candidate_path):
return candidate_path
# 尝试相对于当前工作目录
if os.path.exists(file_path):
return os.path.abspath(file_path)
# 返回原始路径(让后续检查处理错误)
return file_path
def _generate_output_path(self, filename: str) -> str:
"""生成输出文件路径"""
if FOLDER_PATHS_AVAILABLE:
output_dir = folder_paths.get_output_directory()
else:
output_dir = "output"
# 确保输出目录存在
os.makedirs(output_dir, exist_ok=True)
# 处理文件名,确保是.glb扩展名
if not filename.lower().endswith('.glb'):
filename = f"{filename}.glb"
return os.path.join(output_dir, filename)
# -----------------------------------------------------------------------------
# 节点注册 - 更新注册信息
# -----------------------------------------------------------------------------
NODE_CLASS_MAPPINGS = {
"GLBPointCloudProcessor": GLBPointCloudProcessor,
"GLBPointCloudBounds": GLBPointCloudBounds,
"GLBPointCloudOriginAdjuster": GLBPointCloudOriginAdjuster,
"GLBPointCloudDensityFilter": GLBPointCloudDensityFilter,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"GLBPointCloudProcessor": "VVL GLB Point Cloud Processor",
"GLBPointCloudBounds": "VVL GLB Point Cloud Bounds Visualizer",
"GLBPointCloudOriginAdjuster": "VVL GLB Point Cloud Origin Adjuster",
"GLBPointCloudDensityFilter": "VVL GLB Point Cloud Density Filter",
}
# # 添加节点信息,帮助ComfyUI更好地识别
# WEB_DIRECTORY = "./web"
# __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]