update
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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转坐标",
|
||||
}
|
||||
Reference in New Issue
Block a user