This commit is contained in:
wTechArtist
2025-06-19 17:28:18 +08:00
parent 1df29d4da7
commit eeed84de32
6 changed files with 1431 additions and 19 deletions
+109
View File
@@ -0,0 +1,109 @@
# VVL Mask转坐标节点
## 功能描述
`MaskToCoordinates` 节点可以从输入的 mask 图像中自动提取坐标信息,生成符合 SAM2 分割节点要求的 `coordinates_positive` 和 `coordinates_negative` 参数。
- **白色区域** (值 >= threshold) → `coordinates_positive` (正样本点)
- **黑色区域** (值 < threshold) → `coordinates_negative` (负样本点)
## 输入参数
### 必需参数
- **mask** (MASK): 输入的 mask 图像
- **sample_method** (选择): 采样方法
- `random`: 随机采样
- `grid`: 网格采样
- `contour`: 轮廓采样
- **positive_points** (INT): 正样本点数量 (1-1000, 默认: 10)
- **negative_points** (INT): 负样本点数量 (0-1000, 默认: 10)
- **threshold** (FLOAT): 二值化阈值 (0.0-1.0, 默认: 0.5)
### 可选参数
- **min_distance** (INT): 点之间最小距离 (1-100, 默认: 10)
- **edge_margin** (INT): 边缘留白像素 (0-50, 默认: 5)
## 输出结果
- **coordinates_positive** (STRING): 正坐标 JSON 字符串,格式: `[{"x": x1, "y": y1}, {"x": x2, "y": y2}, ...]`
- **coordinates_negative** (STRING): 负坐标 JSON 字符串,格式同上
- **visualization** (IMAGE): 可视化图像,显示采样点位置
- 绿色圆点: 正样本点
- 红色圆点: 负样本点
## 使用示例
### 与 SAM2 分割节点连接
```
[Mask输入] → [MaskToCoordinates] → [Sam2Segmentation]
↓
coordinates_positive
coordinates_negative
```
### 典型工作流程
1. 准备或生成 mask 图像
2. 连接到 `MaskToCoordinates` 节点
3. 调整采样参数 (点数量、采样方法等)
4. 将生成的坐标输出连接到 `Sam2Segmentation` 节点的对应输入
5. 查看可视化输出确认采样效果
## 采样方法说明
### Random (随机采样)
- 在有效区域内随机选择点
- 支持最小距离约束,避免点过于密集
- 适用于大多数情况
### Grid (网格采样)
- 按网格模式均匀分布采样点
- 确保点分布均匀
- 适用于需要规整分布的场景
### Contour (轮廓采样)
- 沿着区域轮廓边缘采样
- 优先选择最大轮廓
- 适用于边界敏感的分割任务
## 技术细节
### 坐标格式
生成的坐标完全兼容 SAM2 分割节点,格式为:
```json
[
{"x": 100, "y": 150},
{"x": 200, "y": 250},
...
]
```
### 边缘处理
- `edge_margin` 参数可设置边缘留白,避免在图像边缘采样
- 防止分割时边界效应
### 性能优化
- 支持批处理 (自动处理第一帧)
- 内存高效的采样算法
- 快速的轮廓检测
## 注意事项
1. **Mask 格式**: 支持标准的 ComfyUI MASK 格式
2. **坐标范围**: 生成的坐标在 mask 图像尺寸范围内
3. **点数量**: 实际生成的点数可能少于设定值(当有效区域不足时)
4. **阈值设置**: 建议根据具体 mask 特征调整 threshold 值
## 故障排除
- **生成点数不足**: 检查 mask 是否有足够的有效区域
- **点分布不理想**: 尝试不同的采样方法或调整参数
- **边缘点过多**: 增大 `edge_margin` 值
- **坐标格式错误**: 确保输出连接到正确的 SAM2 节点输入
## 版本信息
- 版本: 1.0.0
- 兼容: ComfyUI SAM2 节点
- 依赖: PyTorch, OpenCV, NumPy
+10
View File
@@ -6,6 +6,8 @@ ComfyUI VVL Video Camera Advanced
from .comfyui_vggt_nodes import VGGTMultiInputNode
from .vggt_model_loader import VVLVGGTLoader
from .glb_point_cloud_processor import *
from .mask_to_coordinates import MaskToCoordinates
from .json_merger import JSONMerger, UESceneGenerator
# 节点类映射
NODE_CLASS_MAPPINGS = {
@@ -14,6 +16,10 @@ NODE_CLASS_MAPPINGS = {
"GLBPointCloudProcessor": GLBPointCloudProcessor,
"GLBPointCloudBounds": GLBPointCloudBounds,
"GLBPointCloudOriginAdjuster": GLBPointCloudOriginAdjuster,
"GLBPointCloudDensityFilter": GLBPointCloudDensityFilter,
"MaskToCoordinates": MaskToCoordinates,
"JSONMerger": JSONMerger,
"UESceneGenerator": UESceneGenerator,
}
# 节点显示名称映射
@@ -23,6 +29,10 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"GLBPointCloudProcessor": "VVL GLB 点云黑色点清理",
"GLBPointCloudBounds": "VVL GLB 点云包围盒计算",
"GLBPointCloudOriginAdjuster": "VVL GLB 点云原点调整",
"GLBPointCloudDensityFilter": "VVL GLB 点云密度过滤",
"MaskToCoordinates": "VVL Mask转坐标",
"JSONMerger": "VVL JSON数据合并器",
"UESceneGenerator": "VVL UE场景生成器",
}
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+63 -9
View File
@@ -529,20 +529,61 @@ def _matrices_to_json(intrinsic, extrinsic, source_type="video") -> (str, str):
json.dumps(poses_data, ensure_ascii=False, indent=2),
)
def _create_traj_preview(extrinsic: torch.Tensor) -> torch.Tensor:
def _create_traj_preview(extrinsic) -> torch.Tensor:
"""根据相机外参创建3D轨迹可视化(使用matplotlib 3D绘图)。"""
ext = extrinsic.cpu().numpy() # (N,3,4)
# 支持torch.Tensor和numpy.ndarray输入
if isinstance(extrinsic, torch.Tensor):
ext = extrinsic.cpu().numpy()
elif isinstance(extrinsic, np.ndarray):
ext = extrinsic
else:
print(f"VGGT: 不支持的外参数据类型: {type(extrinsic)}")
return _create_insufficient_data_image()
print(f"VGGT: 轨迹预览处理外参矩阵形状: {ext.shape}")
positions = []
orientations = []
for mat in ext:
R = mat[:3, :3]
t = mat[:3, 3]
pos = -R.T @ t # 相机在世界坐标系中的位置
# 检查矩阵形状并相应处理
if len(ext.shape) == 3 and ext.shape[-1] == 4: # (N, 3, 4) 格式
print(f"VGGT: 使用完整外参矩阵 (N, 3, 4)")
for mat in ext:
R = mat[:3, :3]
t = mat[:3, 3]
pos = -R.T @ t # 相机在世界坐标系中的位置
positions.append(pos)
# 提取相机朝向(Z轴方向)
forward = -R[:, 2] # 相机朝向(Z轴负方向)
orientations.append(forward)
elif len(ext.shape) == 3 and ext.shape[-1] == 3: # (N, 3, 3) 格式,只有旋转矩阵
print(f"VGGT: 外参矩阵只包含旋转信息 {ext.shape},使用索引作为位置")
for i, mat in enumerate(ext):
R = mat[:3, :3]
# 没有平移信息,使用索引作为位置
pos = np.array([i * 1.0, 0.0, 0.0]) # 简单的线性排列
positions.append(pos)
# 提取相机朝向(Z轴方向)
forward = -R[:, 2] # 相机朝向(Z轴负方向)
orientations.append(forward)
elif len(ext.shape) == 2 and ext.shape == (3, 4): # 单个 (3, 4) 矩阵
print(f"VGGT: 单个外参矩阵 (3, 4)")
R = ext[:3, :3]
t = ext[:3, 3]
pos = -R.T @ t
positions.append(pos)
# 提取相机朝向(Z轴方向)
forward = -R[:, 2] # 相机朝向(Z轴负方向)
forward = -R[:, 2]
orientations.append(forward)
elif len(ext.shape) == 2 and ext.shape == (3, 3): # 单个 (3, 3) 矩阵
print(f"VGGT: 单个旋转矩阵 (3, 3)")
R = ext[:3, :3]
pos = np.array([0.0, 0.0, 0.0]) # 原点位置
positions.append(pos)
forward = -R[:, 2]
orientations.append(forward)
else:
print(f"VGGT: 不支持的外参矩阵形状: {ext.shape}")
return _create_insufficient_data_image()
positions = np.array(positions)
orientations = np.array(orientations)
@@ -1265,9 +1306,22 @@ class VGGTMultiInputNode:
trajectory_preview = _create_insufficient_data_image() # 默认图像
if 'cameras' in raw_results:
try:
trajectory_preview = _create_traj_preview(raw_results['cameras']['extrinsic'])
extrinsic_for_preview = raw_results['cameras']['extrinsic']
# 应用与GLB生成相同的维度处理逻辑
if isinstance(extrinsic_for_preview, torch.Tensor):
if extrinsic_for_preview.ndim == 4 and extrinsic_for_preview.shape[0] == 1:
extrinsic_for_preview = extrinsic_for_preview.squeeze(0) # (1, S, 3, 4) -> (S, 3, 4)
elif isinstance(extrinsic_for_preview, np.ndarray):
if extrinsic_for_preview.ndim == 4 and extrinsic_for_preview.shape[0] == 1:
extrinsic_for_preview = np.squeeze(extrinsic_for_preview, axis=0) # (1, S, 3, 4) -> (S, 3, 4)
logger.info(f"轨迹预览外参形状: {extrinsic_for_preview.shape}")
trajectory_preview = _create_traj_preview(extrinsic_for_preview)
except Exception as e:
logger.warning(f"轨迹预览生成失败: {e}")
import traceback
traceback.print_exc()
# 生成3D模型文件
model_3d_path = ""
+427 -10
View File
@@ -46,8 +46,8 @@ class GLBPointCloudProcessor:
"tooltip": "黑色阈值控制:RGB三色总和小于此值的点将被删除。0=仅删除纯黑点(0,0,0);30=删除深黑色点;60=删除较暗点;100=删除中等暗度点;255=删除所有点。调大=删除更多暗色点,点云更亮净;调小=保留更多暗色细节,但可能有噪点"
}),
"output_filename": ("STRING", {
"default": "clean_pointcloud.glb",
"tooltip": "输出GLB文件名:处理后点云的保存文件名,自动添加.glb扩展名。文件将保存到ComfyUI的output目录中。建议使用描述性名称如'clean_pointcloud.glb'、'filtered_scan.glb'等便于识别"
"default": "clean_pointcloud",
"tooltip": "输出GLB文件名:处理后点云的保存文件名,系统会自动添加.glb扩展名。文件将保存到ComfyUI的output目录中。建议使用描述性名称如'clean_pointcloud'、'filtered_scan'等便于识别"
}),
}
}
@@ -68,7 +68,7 @@ class GLBPointCloudProcessor:
def process_point_cloud(self,
glb_file_path: str,
black_threshold: int = 30,
output_filename: str = "clean_pointcloud.glb"):
output_filename: str = "clean_pointcloud"):
"""
处理GLB点云文件,删除黑色点和暗色点
"""
@@ -375,8 +375,8 @@ class GLBPointCloudBounds:
"tooltip": "增强可见性:添加顶点高亮和面中心标记,让包围盒更容易识别"
}),
"output_filename": ("STRING", {
"default": "pointcloud_with_bounds.glb",
"tooltip": "输出文件名:带包围盒可视化的点云文件名"
"default": "pointcloud_with_bounds",
"tooltip": "输出文件名:带包围盒可视化的点云文件名,系统会自动添加.glb扩展名"
}),
}
}
@@ -405,7 +405,7 @@ class GLBPointCloudBounds:
add_coordinate_axes: bool = True,
wireframe_density: int = 50,
enhance_visibility: bool = True,
output_filename: str = "pointcloud_with_bounds.glb"):
output_filename: str = "pointcloud_with_bounds"):
"""
计算GLB点云文件的包围盒尺寸并生成带可视化的点云文件
"""
@@ -538,6 +538,7 @@ class GLBPointCloudBounds:
# 生成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
}
@@ -1044,8 +1045,8 @@ class GLBPointCloudOriginAdjuster:
"tooltip": "坐标轴密度:每条坐标轴的点数,越高坐标轴越清晰但点数越多。推荐80-120获得清晰效果"
}),
"output_filename": ("STRING", {
"default": "adjusted_pointcloud.glb",
"tooltip": "输出文件名:调整后的点云文件名"
"default": "adjusted_pointcloud",
"tooltip": "输出文件名:调整后的点云文件名,系统会自动添加.glb扩展名"
}),
}
}
@@ -1072,7 +1073,7 @@ class GLBPointCloudOriginAdjuster:
output_units: str = "centimeters",
add_coordinate_axes: bool = False,
wireframe_density: int = 100,
output_filename: str = "adjusted_pointcloud.glb"):
output_filename: str = "adjusted_pointcloud"):
"""
调整GLB点云的原点位置和旋转
"""
@@ -1303,6 +1304,7 @@ class GLBPointCloudOriginAdjuster:
# 生成变换信息(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])]
}
@@ -1596,6 +1598,419 @@ class GLBPointCloudOriginAdjuster:
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)
# -----------------------------------------------------------------------------
# 节点注册 - 更新注册信息
# -----------------------------------------------------------------------------
@@ -1604,14 +2019,16 @@ 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"]
# __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+494
View File
@@ -0,0 +1,494 @@
import json
import logging
from typing import Any, Dict, Union
# 配置日志
logger = logging.getLogger('json_merger')
class JSONMerger:
"""JSON合并器 - 用于合并GLBPointCloudBounds和GLBPointCloudOriginAdjuster的输出数据"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"json_data_1": ("STRING", {
"tooltip": "第一个JSON数据:来自GLBPointCloudBounds或GLBPointCloudOriginAdjuster的输出。支持包含name、scale、position等字段的JSON格式数据"
}),
"json_data_2": ("STRING", {
"tooltip": "第二个JSON数据:来自GLBPointCloudBounds或GLBPointCloudOriginAdjuster的输出。支持包含name、scale、position等字段的JSON格式数据"
}),
},
"optional": {
"json_data_3": ("STRING", {
"tooltip": "第三个JSON数据(可选):额外的JSON数据,用于三路合并。留空则只合并前两个数据"
}),
"merge_mode": (["smart_merge", "array_concat"], {
"default": "smart_merge",
"tooltip": "合并模式:smart_merge=智能合并(相同name合并字段,不同name用逗号连接);array_concat=数组连接(所有数据放入数组)"
}),
}
}
RETURN_TYPES = (
"STRING", # 合并后的JSON字符串
)
RETURN_NAMES = (
"merged_json",
)
OUTPUT_TOOLTIPS = [
"合并后的JSON数据:根据name字段智能合并或连接多个JSON对象的结果",
]
OUTPUT_NODE = True
FUNCTION = "merge_json_data"
CATEGORY = "💃VVL/JSON Processing"
def merge_json_data(self,
json_data_1: str,
json_data_2: str,
json_data_3: str = "",
merge_mode: str = "smart_merge"):
"""
合并多个JSON数据
"""
processing_log = []
processing_log.append("开始JSON数据合并...")
try:
# 解析输入的JSON数据
json_objects = []
# 解析第一个JSON
if json_data_1.strip():
try:
obj1 = json.loads(json_data_1.strip())
json_objects.append(obj1)
processing_log.append(f"解析JSON数据1成功: {self._get_object_summary(obj1)}")
except json.JSONDecodeError as e:
error_msg = f"JSON数据1解析失败: {str(e)}"
processing_log.append(f"错误: {error_msg}")
return (json.dumps({"error": error_msg}, indent=2),)
# 解析第二个JSON
if json_data_2.strip():
try:
obj2 = json.loads(json_data_2.strip())
json_objects.append(obj2)
processing_log.append(f"解析JSON数据2成功: {self._get_object_summary(obj2)}")
except json.JSONDecodeError as e:
error_msg = f"JSON数据2解析失败: {str(e)}"
processing_log.append(f"错误: {error_msg}")
return (json.dumps({"error": error_msg}, indent=2),)
# 解析第三个JSON(可选)
if json_data_3.strip():
try:
obj3 = json.loads(json_data_3.strip())
json_objects.append(obj3)
processing_log.append(f"解析JSON数据3成功: {self._get_object_summary(obj3)}")
except json.JSONDecodeError as e:
error_msg = f"JSON数据3解析失败: {str(e)}"
processing_log.append(f"错误: {error_msg}")
return (json.dumps({"error": error_msg}, indent=2),)
if not json_objects:
error_msg = "没有有效的JSON数据输入"
processing_log.append(f"错误: {error_msg}")
return (json.dumps({"error": error_msg}, indent=2),)
if len(json_objects) == 1:
processing_log.append("只有一个有效JSON对象,直接返回")
result = json_objects[0]
else:
# 根据合并模式处理
if merge_mode == "smart_merge":
result = self._smart_merge(json_objects, processing_log)
else: # array_concat
result = self._array_concat(json_objects, processing_log)
# 生成最终的JSON字符串
merged_json = json.dumps(result, indent=2, ensure_ascii=False)
processing_log.append("JSON数据合并完成!")
processing_log.append(f"最终结果: {self._get_object_summary(result)}")
return (merged_json,)
except Exception as e:
error_msg = f"合并JSON数据时发生错误: {str(e)}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
import traceback
traceback.print_exc()
return (json.dumps({"error": error_msg}, indent=2),)
def _smart_merge(self, json_objects: list, processing_log: list) -> Union[Dict, list]:
"""
智能合并:相同name的对象合并字段,不同name的对象用数组形式返回
"""
processing_log.append("使用智能合并模式...")
# 按name字段分组
name_groups = {}
unnamed_objects = []
for i, obj in enumerate(json_objects):
if isinstance(obj, dict):
name = obj.get("name")
if name:
if name not in name_groups:
name_groups[name] = []
name_groups[name].append(obj)
processing_log.append(f"对象{i+1}归入组 '{name}'")
else:
unnamed_objects.append(obj)
processing_log.append(f"对象{i+1}没有name字段,归入未命名组")
else:
unnamed_objects.append(obj)
processing_log.append(f"对象{i+1}不是字典类型,归入未命名组")
# 合并结果
merged_results = []
# 处理有name的组
for name, group_objects in name_groups.items():
if len(group_objects) == 1:
# 只有一个对象,直接使用
merged_results.append(group_objects[0])
processing_log.append(f"组 '{name}' 只有一个对象,直接使用")
else:
# 多个对象,合并字段
merged_obj = {"name": name}
for obj in group_objects:
for key, value in obj.items():
if key != "name": # 跳过name字段
if key not in merged_obj:
merged_obj[key] = value
else:
# 如果字段已存在,优先使用非空值
if not merged_obj[key] and value:
merged_obj[key] = value
merged_results.append(merged_obj)
processing_log.append(f"组 '{name}' 合并了 {len(group_objects)} 个对象")
# 添加未命名对象
merged_results.extend(unnamed_objects)
if unnamed_objects:
processing_log.append(f"添加了 {len(unnamed_objects)} 个未命名对象")
# 返回结果
if len(merged_results) == 1:
processing_log.append("合并结果为单个对象")
return merged_results[0]
else:
processing_log.append(f"合并结果为 {len(merged_results)} 个对象的数组")
return merged_results
def _array_concat(self, json_objects: list, processing_log: list) -> list:
"""
数组连接:将所有对象放入一个数组
"""
processing_log.append("使用数组连接模式...")
processing_log.append(f"将 {len(json_objects)} 个对象连接为数组")
return json_objects
def _get_object_summary(self, obj: Any) -> str:
"""
获取对象的简要描述
"""
if isinstance(obj, dict):
keys = list(obj.keys())
name = obj.get("name", "未命名")
return f"字典对象 name='{name}', 字段=[{', '.join(keys)}]"
elif isinstance(obj, list):
return f"数组对象,包含 {len(obj)} 个元素"
else:
return f"其他类型: {type(obj).__name__}"
class UESceneGenerator:
"""UE场景生成器 - 将JSONMerger的输出转换为UE场景配置格式"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"merged_json": ("*", {
"tooltip": "合并后的JSON数据:来自JSONMerger的输出,包含name、position、scale等字段的JSON数据,支持STRING/LIST/DICT类型"
}),
},
"optional": {
"camera_position": ("STRING", {
"default": "[-1000, 0, 100]",
"tooltip": "相机位置:UE场景中相机的世界坐标位置 [X, Y, Z],默认为[-1000, 0, 100]"
}),
"camera_rotation": ("STRING", {
"default": "[-12, 0, 0]",
"tooltip": "相机旋转:UE场景中相机的旋转角度 [Pitch, Yaw, Roll],默认为[-12, 0, 0]"
}),
"camera_fov": ("FLOAT", {
"default": 58.53, "min": 1.0, "max": 179.0, "step": 0.1,
"tooltip": "相机视野角度:UE场景中相机的视野角度(FOV),默认为58.53度。注意:这是一个数值参数,不要连接数组输入!"
}),
"default_object_rotation": ("STRING", {
"default": "[0, 0, 0]",
"tooltip": "默认物体旋转:当输入数据中没有rotation字段时使用的默认旋转值 [Pitch, Yaw, Roll]"
}),
"scale_multiplier": ("FLOAT", {
"default": 1.0, "min": 0.001, "max": 1000.0, "step": 0.1,
"tooltip": "缩放倍数:对所有scale值进行统一缩放的倍数,用于调整物体大小到合适的UE单位。注意:这是一个数值参数,不要连接数组输入!"
}),
}
}
RETURN_TYPES = (
"STRING", # UE场景配置JSON
)
RETURN_NAMES = (
"ue_scene_json",
)
OUTPUT_TOOLTIPS = [
"UE场景配置JSON:符合UE导入格式的完整场景配置,包含相机设置和物体列表",
]
OUTPUT_NODE = True
FUNCTION = "generate_ue_scene"
CATEGORY = "💃VVL/UE Integration"
def generate_ue_scene(self,
merged_json,
camera_position: str = "[-1000, 0, 100]",
camera_rotation: str = "[-12, 0, 0]",
camera_fov = 58.53,
default_object_rotation: str = "[0, 0, 0]",
scale_multiplier = 1.0):
"""
将合并的JSON数据转换为UE场景配置格式
"""
processing_log = []
processing_log.append("开始生成UE场景配置...")
# 参数验证和修复
try:
# 修复可能错误连接的参数
if isinstance(camera_fov, str):
processing_log.append(f"警告: camera_fov参数类型错误(收到字符串'{camera_fov}'),使用默认值58.53")
camera_fov = 58.53
elif not isinstance(camera_fov, (int, float)):
processing_log.append(f"警告: camera_fov参数类型错误(收到{type(camera_fov).__name__}),使用默认值58.53")
camera_fov = 58.53
if isinstance(scale_multiplier, str):
processing_log.append(f"警告: scale_multiplier参数类型错误(收到字符串'{scale_multiplier}'),使用默认值1.0")
scale_multiplier = 1.0
elif not isinstance(scale_multiplier, (int, float)):
processing_log.append(f"警告: scale_multiplier参数类型错误(收到{type(scale_multiplier).__name__}),使用默认值1.0")
scale_multiplier = 1.0
# 确保参数在合理范围内
camera_fov = max(1.0, min(179.0, float(camera_fov)))
scale_multiplier = max(0.001, min(1000.0, float(scale_multiplier)))
processing_log.append(f"参数验证完成: camera_fov={camera_fov}, scale_multiplier={scale_multiplier}")
except Exception as e:
processing_log.append(f"参数验证失败: {str(e)},使用默认值")
camera_fov = 58.53
scale_multiplier = 1.0
try:
# 处理输入的合并JSON数据(可能是字符串或直接的数据)
if isinstance(merged_json, str):
# 字符串输入,需要解析
if not merged_json.strip():
error_msg = "合并JSON数据为空"
processing_log.append(f"错误: {error_msg}")
return (json.dumps({"error": error_msg}, indent=2),)
try:
merged_data = json.loads(merged_json.strip())
processing_log.append("字符串JSON数据解析成功")
except json.JSONDecodeError as e:
error_msg = f"JSON数据解析失败: {str(e)}"
processing_log.append(f"错误: {error_msg}")
return (json.dumps({"error": error_msg}, indent=2),)
elif isinstance(merged_json, (list, dict)):
# 直接的数据结构输入
merged_data = merged_json
processing_log.append(f"直接数据输入: {type(merged_json).__name__}")
# 详细分析输入结构
if isinstance(merged_json, list):
processing_log.append(f"列表包含 {len(merged_json)} 个元素:")
for i, item in enumerate(merged_json):
if isinstance(item, dict):
keys = list(item.keys())
processing_log.append(f" 元素{i}: dict with keys {keys}")
elif isinstance(item, str):
preview = item[:100] + "..." if len(item) > 100 else item
processing_log.append(f" 元素{i}: string '{preview}'")
else:
processing_log.append(f" 元素{i}: {type(item).__name__}")
else:
error_msg = f"不支持的输入数据类型: {type(merged_json).__name__}"
processing_log.append(f"错误: {error_msg}")
return (json.dumps({"error": error_msg}, indent=2),)
# 解析相机参数
try:
cam_pos = json.loads(camera_position)
cam_rot = json.loads(camera_rotation)
default_rot = json.loads(default_object_rotation)
if not isinstance(cam_pos, list) or len(cam_pos) != 3:
raise ValueError("相机位置必须是包含3个数值的数组")
if not isinstance(cam_rot, list) or len(cam_rot) != 3:
raise ValueError("相机旋转必须是包含3个数值的数组")
if not isinstance(default_rot, list) or len(default_rot) != 3:
raise ValueError("默认旋转必须是包含3个数值的数组")
processing_log.append(f"相机参数解析成功: 位置{cam_pos}, 旋转{cam_rot}, FOV{camera_fov}")
except (json.JSONDecodeError, ValueError) as e:
error_msg = f"相机参数解析失败: {str(e)}"
processing_log.append(f"错误: {error_msg}")
return (json.dumps({"error": error_msg}, indent=2),)
# 创建UE场景配置基础结构
ue_scene = {
"camera": {
"position": cam_pos,
"rotation": cam_rot,
"fov": camera_fov
},
"objects": []
}
# 处理物体数据
objects_processed = 0
def process_data_item(data_item, item_name=""):
"""递归处理数据项,支持嵌套结构"""
nonlocal objects_processed
if isinstance(data_item, dict):
# 单个物体字典
ue_object = self._convert_to_ue_object(
data_item, default_rot, scale_multiplier, processing_log
)
if ue_object:
ue_scene["objects"].append(ue_object)
objects_processed += 1
elif isinstance(data_item, list):
# 数组,递归处理每个元素
for i, sub_item in enumerate(data_item):
sub_name = f"{item_name}[{i}]" if item_name else f"item_{i}"
process_data_item(sub_item, sub_name)
elif isinstance(data_item, str):
# 字符串,尝试解析为JSON
try:
parsed_item = json.loads(data_item)
processing_log.append(f"解析嵌套JSON字符串: {item_name}")
process_data_item(parsed_item, f"{item_name}_parsed")
except json.JSONDecodeError:
processing_log.append(f"跳过无法解析的字符串: {item_name} = '{data_item[:50]}...'")
else:
processing_log.append(f"跳过不支持的数据类型 {item_name}: {type(data_item).__name__}")
# 开始处理数据
if isinstance(merged_data, (dict, list)):
processing_log.append(f"开始处理输入数据类型: {type(merged_data).__name__}")
process_data_item(merged_data, "root")
else:
error_msg = f"不支持的合并数据类型: {type(merged_data).__name__}"
processing_log.append(f"错误: {error_msg}")
return (json.dumps({"error": error_msg}, indent=2),)
processing_log.append(f"成功处理 {objects_processed} 个物体")
# 生成最终的UE场景JSON
ue_scene_json = json.dumps(ue_scene, indent=2, ensure_ascii=False)
processing_log.append("UE场景配置生成完成!")
processing_log.append(f"场景包含: 1个相机 + {len(ue_scene['objects'])} 个物体")
return (ue_scene_json,)
except Exception as e:
error_msg = f"生成UE场景配置时发生错误: {str(e)}"
logger.error(error_msg)
processing_log.append(f"错误: {error_msg}")
import traceback
traceback.print_exc()
return (json.dumps({"error": error_msg}, indent=2),)
def _convert_to_ue_object(self, obj_data: dict, default_rotation: list,
scale_multiplier: float, processing_log: list) -> dict:
"""
将单个对象数据转换为UE物体格式
"""
try:
# 获取物体名称
name = obj_data.get("name", "未命名物体")
# 获取位置信息
position = obj_data.get("position", [0, 0, 0])
if not isinstance(position, list) or len(position) != 3:
processing_log.append(f"物体 '{name}' 位置格式错误,使用默认值 [0, 0, 0]")
position = [0, 0, 0]
# 获取旋转信息
rotation = obj_data.get("rotation", default_rotation)
if not isinstance(rotation, list) or len(rotation) != 3:
processing_log.append(f"物体 '{name}' 旋转格式错误,使用默认值 {default_rotation}")
rotation = default_rotation.copy()
# 获取缩放信息
scale = obj_data.get("scale", [1, 1, 1])
if not isinstance(scale, list) or len(scale) != 3:
processing_log.append(f"物体 '{name}' 缩放格式错误,使用默认值 [1, 1, 1]")
scale = [1, 1, 1]
# 应用缩放倍数
if scale_multiplier != 1.0:
scale = [s * scale_multiplier for s in scale]
processing_log.append(f"物体 '{name}' 应用缩放倍数 {scale_multiplier}")
# 创建UE物体
ue_object = {
"name": name,
"position": [float(position[0]), float(position[1]), float(position[2])],
"rotation": [float(rotation[0]), float(rotation[1]), float(rotation[2])],
"scale": [float(scale[0]), float(scale[1]), float(scale[2])]
}
processing_log.append(f"转换物体 '{name}': 位置{position}, 旋转{rotation}, 缩放{scale}")
return ue_object
except Exception as e:
processing_log.append(f"转换物体失败: {str(e)}")
return None
# -----------------------------------------------------------------------------
# 节点注册
# -----------------------------------------------------------------------------
NODE_CLASS_MAPPINGS = {
"JSONMerger": JSONMerger,
"UESceneGenerator": UESceneGenerator,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"JSONMerger": "VVL JSON Merger",
"UESceneGenerator": "VVL UE Scene Generator",
}
# 添加节点信息,帮助ComfyUI更好地识别
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+328
View File
@@ -0,0 +1,328 @@
"""
Mask To Coordinates Node
从 mask 生成 coordinates_positive 和 coordinates_negative 坐标信息
"""
import torch
import numpy as np
import json
import cv2
from typing import Tuple, List, Optional
class MaskToCoordinates:
"""
从 mask 生成坐标信息的节点
白色区域 (值 >= threshold) 生成 coordinates_positive
黑色区域 (值 < threshold) 生成 coordinates_negative
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask": ("MASK", {
"tooltip": "输入的遮罩图像。白色区域将生成正样本坐标,黑色区域将生成负样本坐标。"
}),
"sample_method": (["random", "grid", "contour"], {
"default": "random",
"tooltip": "坐标采样方法:\n• random - 随机采样,在有效区域内随机选择点\n• grid - 网格采样,按网格模式均匀分布采样点\n• contour - 轮廓采样,沿着区域轮廓边缘采样"
}),
"positive_points": ("INT", {
"default": 10,
"min": 1,
"max": 1000,
"step": 1,
"tooltip": "从白色区域(值≥阈值)生成的正样本坐标点数量。这些坐标将作为 coordinates_positive 输出到 SAM2 分割节点。"
}),
"negative_points": ("INT", {
"default": 10,
"min": 0,
"max": 1000,
"step": 1,
"tooltip": "从黑色区域(值<阈值)生成的负样本坐标点数量。这些坐标将作为 coordinates_negative 输出到 SAM2 分割节点。设为0则不生成负样本点。"
}),
"threshold": ("FLOAT", {
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "二值化阈值,用于区分正负样本区域。像素值≥此阈值的区域被视为白色(正样本),<此阈值的区域被视为黑色(负样本)。"
}),
},
"optional": {
"min_distance": ("INT", {
"default": 10,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "采样点之间的最小距离(像素)。用于避免点过于密集,确保采样点分布更均匀。仅在random和contour采样方法中有效。"
}),
"edge_margin": ("INT", {
"default": 5,
"min": 0,
"max": 50,
"step": 1,
"tooltip": "图像边缘留白像素数。在图像边缘指定像素范围内不会生成采样点,避免边界效应影响分割效果。设为0则不留白。"
}),
}
}
RETURN_TYPES = ("STRING", "STRING", "IMAGE")
RETURN_NAMES = ("coordinates_positive", "coordinates_negative", "visualization")
FUNCTION = "mask_to_coordinates"
CATEGORY = "VVL"
DESCRIPTION = "从 mask 生成正负坐标信息,用于 SAM2 分割"
def mask_to_coordinates(
self,
mask: torch.Tensor,
sample_method: str = "random",
positive_points: int = 10,
negative_points: int = 10,
threshold: float = 0.5,
min_distance: int = 10,
edge_margin: int = 5
) -> Tuple[str, str, torch.Tensor]:
"""
从 mask 生成坐标信息
Args:
mask: 输入的 mask 张量
sample_method: 采样方法 ("random", "grid", "contour")
positive_points: 正样本点数量
negative_points: 负样本点数量
threshold: 二值化阈值
min_distance: 点之间最小距离
edge_margin: 边缘留白
Returns:
coordinates_positive: 正坐标 JSON 字符串
coordinates_negative: 负坐标 JSON 字符串
visualization: 可视化图像
"""
# 处理 mask 张量
if mask.dim() == 3:
mask = mask[0] # 取第一帧
elif mask.dim() == 4:
mask = mask[0, 0] # 取第一帧第一通道
# 转换为 numpy 数组
mask_np = mask.cpu().numpy()
height, width = mask_np.shape
# 二值化处理
positive_mask = (mask_np >= threshold).astype(np.uint8)
negative_mask = (mask_np < threshold).astype(np.uint8)
# 应用边缘留白
if edge_margin > 0:
positive_mask = self._apply_edge_margin(positive_mask, edge_margin)
negative_mask = self._apply_edge_margin(negative_mask, edge_margin)
# 生成坐标点
positive_coords = self._sample_points(
positive_mask, positive_points, sample_method, min_distance
)
negative_coords = self._sample_points(
negative_mask, negative_points, sample_method, min_distance
)
# 转换为 JSON 格式
coordinates_positive = json.dumps([
{"x": int(x), "y": int(y)} for x, y in positive_coords
])
coordinates_negative = json.dumps([
{"x": int(x), "y": int(y)} for x, y in negative_coords
]) if negative_coords else json.dumps([])
# 生成可视化图像
visualization = self._create_visualization(
mask_np, positive_coords, negative_coords, threshold
)
return coordinates_positive, coordinates_negative, visualization
def _apply_edge_margin(self, mask: np.ndarray, margin: int) -> np.ndarray:
"""应用边缘留白"""
if margin <= 0:
return mask
result = mask.copy()
result[:margin, :] = 0
result[-margin:, :] = 0
result[:, :margin] = 0
result[:, -margin:] = 0
return result
def _sample_points(
self,
mask: np.ndarray,
num_points: int,
method: str,
min_distance: int
) -> List[Tuple[int, int]]:
"""从 mask 中采样点"""
if num_points <= 0:
return []
# 找到所有有效像素位置
valid_positions = np.where(mask > 0)
if len(valid_positions[0]) == 0:
return []
valid_coords = list(zip(valid_positions[1], valid_positions[0])) # (x, y)
if len(valid_coords) == 0:
return []
if method == "random":
return self._random_sampling(valid_coords, num_points, min_distance)
elif method == "grid":
return self._grid_sampling(mask, num_points)
elif method == "contour":
return self._contour_sampling(mask, num_points, min_distance)
else:
return self._random_sampling(valid_coords, num_points, min_distance)
def _random_sampling(
self,
valid_coords: List[Tuple[int, int]],
num_points: int,
min_distance: int
) -> List[Tuple[int, int]]:
"""随机采样"""
if not valid_coords:
return []
selected_points = []
available_coords = valid_coords.copy()
for _ in range(min(num_points, len(available_coords))):
if not available_coords:
break
# 随机选择一个点
idx = np.random.randint(len(available_coords))
point = available_coords[idx]
selected_points.append(point)
# 移除距离太近的点
if min_distance > 0:
available_coords = [
coord for coord in available_coords
if np.sqrt((coord[0] - point[0])**2 + (coord[1] - point[1])**2) >= min_distance
]
else:
available_coords.remove(point)
return selected_points
def _grid_sampling(self, mask: np.ndarray, num_points: int) -> List[Tuple[int, int]]:
"""网格采样"""
height, width = mask.shape
# 计算网格大小
grid_size = int(np.sqrt(num_points))
if grid_size == 0:
grid_size = 1
points = []
step_y = height // (grid_size + 1)
step_x = width // (grid_size + 1)
for i in range(1, grid_size + 1):
for j in range(1, grid_size + 1):
y = i * step_y
x = j * step_x
# 检查该位置是否有效
if y < height and x < width and mask[y, x] > 0:
points.append((x, y))
if len(points) >= num_points:
break
if len(points) >= num_points:
break
return points
def _contour_sampling(
self,
mask: np.ndarray,
num_points: int,
min_distance: int
) -> List[Tuple[int, int]]:
"""轮廓采样"""
# 查找轮廓
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return []
# 选择最大的轮廓
largest_contour = max(contours, key=cv2.contourArea)
if len(largest_contour) < num_points:
# 如果轮廓点数不够,返回所有轮廓点
return [(int(point[0][0]), int(point[0][1])) for point in largest_contour]
# 均匀采样轮廓点
step = len(largest_contour) // num_points
sampled_points = []
for i in range(0, len(largest_contour), step):
if len(sampled_points) >= num_points:
break
point = largest_contour[i][0]
sampled_points.append((int(point[0]), int(point[1])))
return sampled_points
def _create_visualization(
self,
mask: np.ndarray,
positive_coords: List[Tuple[int, int]],
negative_coords: List[Tuple[int, int]],
threshold: float
) -> torch.Tensor:
"""创建可视化图像"""
height, width = mask.shape
# 创建彩色图像
vis_image = np.zeros((height, width, 3), dtype=np.uint8)
# 绘制 mask 背景
mask_norm = (mask * 255).astype(np.uint8)
vis_image[:, :, 0] = mask_norm # 红色通道显示 mask
vis_image[:, :, 1] = mask_norm # 绿色通道显示 mask
vis_image[:, :, 2] = mask_norm # 蓝色通道显示 mask
# 绘制正样本点 (绿色)
for x, y in positive_coords:
cv2.circle(vis_image, (x, y), 3, (0, 255, 0), -1)
cv2.circle(vis_image, (x, y), 5, (0, 255, 0), 1)
# 绘制负样本点 (红色)
for x, y in negative_coords:
cv2.circle(vis_image, (x, y), 3, (0, 0, 255), -1)
cv2.circle(vis_image, (x, y), 5, (0, 0, 255), 1)
# 转换为 torch 张量
vis_tensor = torch.from_numpy(vis_image).float() / 255.0
vis_tensor = vis_tensor.unsqueeze(0) # 添加 batch 维度
return vis_tensor
# 节点类映射
NODE_CLASS_MAPPINGS = {
"MaskToCoordinates": MaskToCoordinates,
}
# 节点显示名称映射
NODE_DISPLAY_NAME_MAPPINGS = {
"MaskToCoordinates": "VVL Mask转坐标",
}