Merge pull request #529 from DenisKochetov/fastmesh

Fastmesh loading and saving
This commit is contained in:
Mr. For Example
2025-09-13 15:29:20 +04:00
committed by GitHub
7 changed files with 1333 additions and 419 deletions
@@ -6,6 +6,9 @@ import base64
import io
import tempfile
import os
import sys
sys.path.append(os.path.join(os.path.dirname(__file__), "../../../"))
from mesh_processor.fastmesh import FastMesh
def combine_metallic_roughness(metallic_path, roughness_path, output_path):
@@ -82,7 +85,8 @@ def create_glb_with_pbr_materials(obj_path, textures_dict, output_path):
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_mr_file:
mr_combined_path = temp_mr_file.name
temp_files.append(mr_combined_path)
combine_metallic_roughness(textures_dict["metallic"], textures_dict["roughness"], mr_combined_path)
# combine_metallic_roughness(textures_dict["metallic"], textures_dict["roughness"], mr_combined_path)
combine_metallic_roughness_fast(textures_dict["metallic"], textures_dict["roughness"], mr_combined_path)
textures_dict["metallicRoughness"] = mr_combined_path
# 6. 添加图像到GLTF
@@ -158,3 +162,17 @@ def create_glb_with_pbr_materials(obj_path, textures_dict, output_path):
os.remove(temp_file)
except Exception as e:
print(f"Warning: Failed to remove temporary file {temp_file}: {e}")
# FastMesh-based functions (new optimized versions)
def combine_metallic_roughness_fast(metallic_path, roughness_path, output_path):
"""Fast combine metallic and roughness maps using FastMesh"""
combined_array = FastMesh._combine_metallic_roughness(metallic_path, roughness_path)
combined = Image.fromarray(combined_array)
combined.save(output_path)
return output_path
def create_glb_with_pbr_materials_fast(obj_path, textures_dict, output_path):
"""Fast create GLB with PBR materials using FastMesh"""
FastMesh.create_glb_with_pbr_materials(obj_path, textures_dict, output_path)
@@ -29,7 +29,7 @@ from .utils.pipeline_utils import ViewProcessor
from .utils.image_super_utils import imageSuperNet
from .utils.uvwrap_utils import mesh_uv_wrap
from .DifferentiableRenderer.mesh_utils import convert_obj_to_glb
from .convert_utils import create_glb_with_pbr_materials
from .convert_utils import create_glb_with_pbr_materials, create_glb_with_pbr_materials_fast
import warnings
warnings.filterwarnings("ignore")
@@ -209,7 +209,9 @@ class Hunyuan3DPaintPipeline:
textures_dict['normal'] = normal_path
try:
create_glb_with_pbr_materials(output_mesh_path, textures_dict, output_glb_path)
# create_glb_with_pbr_materials(output_mesh_path, textures_dict, output_glb_path)
print("Creating GLB with full PBR materials using FastMesh")
create_glb_with_pbr_materials_fast(output_mesh_path, textures_dict, output_glb_path)
print(f"Created GLB with full PBR materials: {output_glb_path}")
except Exception as e:
print(f"Warning: Failed to create GLB with PBR materials: {e}")
+1 -1
View File
@@ -34,9 +34,9 @@ from .rigging_ops import (
from .validate import validate_before_save
from .mesh import (
Mesh,
FastMesh,
PointCloud,
)
from .fastmesh import FastMesh
__all__ = [
"GltfDocument",
+2 -1
View File
@@ -5,7 +5,8 @@ Direct functions WITHOUT trimesh dependency
import torch
import numpy as np
from .mesh import Mesh, FastMesh
from .mesh import Mesh
from .fastmesh import FastMesh
def export_to_fastmesh(mesh_output, device=None):
File diff suppressed because it is too large Load Diff
-398
View File
@@ -1130,404 +1130,6 @@ class Mesh:
return pcd
class FastMesh:
"""
Fast implementation of mesh based on FastGLB without dependency on trimesh.
Uses direct loading of GLB/GLTF through mesh_processor.
"""
def __init__(
self,
v: Optional[Tensor] = None,
f: Optional[Tensor] = None,
vn: Optional[Tensor] = None,
fn: Optional[Tensor] = None,
vt: Optional[Tensor] = None,
ft: Optional[Tensor] = None,
vc: Optional[Tensor] = None,
albedo: Optional[Tensor] = None,
metallicRoughness: Optional[Tensor] = None,
device: Optional[torch.device] = None,
):
"""Initialize FastMesh with the same parameters as Mesh"""
self.device = device if device is not None else torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.v = v
self.vn = vn
self.vt = vt
self.f = f
self.fn = fn
self.ft = ft
self.vc = vc
self.albedo = albedo
self.metallicRoughness = metallicRoughness
self.ori_center = 0
self.ori_scale = 1
@classmethod
def load(cls, path, resize=True, renormal=True, retex=False, clean=False, bound=0.5, front_dir='+z', **kwargs):
"""Loading mesh through FastGLB approach"""
device = kwargs.get('device')
if device is None:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if path.endswith(".obj"):
mesh = cls._load_obj_fast(path, device=device, **kwargs)
elif path.endswith((".glb", ".gltf")):
mesh = cls._load_gltf_fast(path, device=device, **kwargs)
else:
standard_mesh = Mesh.load(path, resize=False, renormal=False, retex=False, clean=False, **kwargs)
mesh = cls._from_standard_mesh(standard_mesh)
if mesh is None:
return None
if clean:
mesh._clean_mesh()
print(f"[FastMesh loading] v: {mesh.v.shape}, f: {mesh.f.shape}")
if resize:
mesh.auto_size(bound=bound)
if renormal or mesh.vn is None:
mesh.auto_normal()
print(f"[FastMesh loading] vn: {mesh.vn.shape}, fn: {mesh.fn.shape}")
if retex or (mesh.albedo is not None and mesh.vt is None):
mesh.auto_uv(cache_path=path)
print(f"[FastMesh loading] vt: {mesh.vt.shape}, ft: {mesh.ft.shape}")
if front_dir != "+z":
mesh._apply_front_dir_rotation(front_dir)
return mesh
@classmethod
def _load_gltf_fast(cls, path, device=None, **kwargs):
"""Fast loading of GLB/GLTF through mesh_processor"""
try:
print(f"[FastMesh] Loading GLB/GLTF: {path}")
# Load GLTF document
from .io_gltf import load_gltf_or_glb, get_binary_data
from .mesh_ops import get_all_meshes_triangles
from .accessors import access_data
doc = load_gltf_or_glb(path)
print(f"[FastMesh] GLTF document: {len(doc.meshes())} meshes")
# Extract geometry
vertices, faces, mesh_groups = get_all_meshes_triangles(doc, transform_to_global=True)
if len(vertices) == 0 or len(faces) == 0:
print(f"[FastMesh] No geometry in {path}")
return None
mesh = cls(device=device)
mesh.v = torch.tensor(vertices, dtype=torch.float32, device=device)
mesh.f = torch.tensor(faces, dtype=torch.int32, device=device)
# Extract additional data
cls._extract_gltf_attributes(mesh, doc, device)
cls._extract_gltf_textures(mesh, doc, device)
print(f"[FastMesh] Loaded: {len(vertices)} vertices, {len(faces)} faces")
return mesh
except Exception as e:
print(f"[FastMesh] Error loading GLB/GLTF {path}: {e}")
return None
@classmethod
def _extract_gltf_attributes(cls, mesh, doc, device):
"""Extract attributes from GLTF (UV, normals, colors)"""
if not doc.meshes():
return
first_mesh = doc.meshes()[0]
for primitive in first_mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
# UV coordinates
if "TEXCOORD_0" in attributes and mesh.vt is None:
try:
from .accessors import access_data
uv_accessor_idx = int(attributes["TEXCOORD_0"])
uv_data = access_data(doc, uv_accessor_idx).astype(np.float32)
uv_data[:, 1] = 1.0 - uv_data[:, 1] # Flip V
mesh.vt = torch.tensor(uv_data, dtype=torch.float32, device=device)
mesh.ft = mesh.f
print(f"[FastMesh] UV: {mesh.vt.shape}")
except Exception as e:
print(f"[FastMesh] Error UV: {e}")
# Normals
if "NORMAL" in attributes and mesh.vn is None:
try:
from .accessors import access_data
normal_accessor_idx = int(attributes["NORMAL"])
normal_data = access_data(doc, normal_accessor_idx).astype(np.float32)
mesh.vn = torch.tensor(normal_data, dtype=torch.float32, device=device)
mesh.fn = mesh.f
print(f"[FastMesh] Normals: {mesh.vn.shape}")
except Exception as e:
print(f"[FastMesh] Error normals: {e}")
# Vertex colors
if "COLOR_0" in attributes and mesh.vc is None:
try:
from .accessors import access_data
color_accessor_idx = int(attributes["COLOR_0"])
color_data = access_data(doc, color_accessor_idx).astype(np.float32)
if color_data.shape[1] > 3:
color_data = color_data[:, :3]
mesh.vc = torch.tensor(color_data, dtype=torch.float32, device=device)
print(f"[FastMesh] Colors: {mesh.vc.shape}")
except Exception as e:
print(f"[FastMesh] Error colors: {e}")
@classmethod
def _extract_gltf_textures(cls, mesh, doc, device):
"""Extract textures from GLTF"""
should_create_empty = True
try:
if not (doc.materials() and doc.textures() and doc.images()):
mesh._create_empty_albedo()
return
material = doc.materials()[0]
pbr = material.get("pbrMetallicRoughness", {})
# Albedo texture
base_color_texture = pbr.get("baseColorTexture")
if base_color_texture:
albedo_texture = cls._extract_texture_by_index(doc, base_color_texture.get("index", 0))
if albedo_texture is not None:
albedo_float = albedo_texture.astype(np.float32) / 255.0
mesh.albedo = torch.tensor(albedo_float, dtype=torch.float32, device=device).contiguous()
should_create_empty = False
print(f"[FastMesh] Albedo: {mesh.albedo.shape}")
# Metallic-Roughness texture
mr_texture_info = pbr.get("metallicRoughnessTexture")
if mr_texture_info and not should_create_empty:
mr_texture = cls._extract_texture_by_index(doc, mr_texture_info.get("index", 0))
if mr_texture is not None:
mr_float = mr_texture.astype(np.float32) / 255.0
mesh.metallicRoughness = torch.tensor(mr_float, dtype=torch.float32, device=device).contiguous()
print(f"[FastMesh] MetallicRoughness: {mesh.metallicRoughness.shape}")
except Exception as e:
print(f"[FastMesh] Error extracting textures: {e}")
if should_create_empty:
mesh._create_empty_albedo()
@classmethod
def _extract_texture_by_index(cls, doc, texture_idx):
"""Extract texture by index from GLTF"""
try:
from .io_gltf import get_binary_data
if texture_idx >= len(doc.textures()):
return None
texture = doc.textures()[texture_idx]
image_idx = texture.get("source", 0)
if image_idx >= len(doc.images()):
return None
image_info = doc.images()[image_idx]
buffer_view_idx = image_info.get("bufferView")
if buffer_view_idx is None:
return None
buffer_view = doc.bufferViews()[buffer_view_idx]
buffer_idx = buffer_view.get("buffer", 0)
byte_offset = buffer_view.get("byteOffset", 0)
byte_length = buffer_view.get("byteLength", 0)
binary_data = get_binary_data(doc, buffer_idx)
image_bytes = binary_data[byte_offset:byte_offset + byte_length]
# Decode image
image_array = np.frombuffer(image_bytes, dtype=np.uint8)
texture_image = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
if texture_image is not None:
texture_image = cv2.cvtColor(texture_image, cv2.COLOR_BGR2RGB)
return texture_image
except Exception as e:
print(f"[FastMesh] Error extracting texture {texture_idx}: {e}")
return None
@classmethod
def _load_obj_fast(cls, path, device=None, **kwargs):
"""Fast loading of OBJ (fallback to standard method)"""
standard_mesh = Mesh.load_obj(path, device=device, **kwargs)
return cls._from_standard_mesh(standard_mesh)
@classmethod
def _from_standard_mesh(cls, standard_mesh):
"""Convert standard Mesh to FastMesh"""
if standard_mesh is None:
return None
return cls(
v=standard_mesh.v,
f=standard_mesh.f,
vn=standard_mesh.vn,
fn=standard_mesh.fn,
vt=standard_mesh.vt,
ft=standard_mesh.ft,
vc=standard_mesh.vc,
albedo=standard_mesh.albedo,
metallicRoughness=standard_mesh.metallicRoughness,
device=standard_mesh.device
)
def _create_empty_albedo(self):
"""Create empty albedo texture"""
texture = np.ones((1024, 1024, 3), dtype=np.float32) * np.array([0.5, 0.5, 0.5])
self.albedo = torch.tensor(texture, dtype=torch.float32, device=self.device)
print(f"[FastMesh] Empty texture: {self.albedo.shape}")
def _clean_mesh(self):
"""Clean mesh (simplified version)"""
try:
from kiui.mesh_utils import clean_mesh
vertices = self.v.detach().cpu().numpy()
triangles = self.f.detach().cpu().numpy()
vertices, triangles = clean_mesh(vertices, triangles, remesh=False)
self.v = torch.from_numpy(vertices).contiguous().float().to(self.device)
self.f = torch.from_numpy(triangles).contiguous().int().to(self.device)
except Exception as e:
print(f"[FastMesh] Error cleaning: {e}")
def _apply_front_dir_rotation(self, front_dir):
"""Apply rotation for aligning front_dir with +z"""
# Use the same logic as in standard Mesh
if front_dir == "+z":
return
# Define transformation matrix
if "-z" in front_dir:
T = torch.tensor([[1, 0, 0], [0, 1, 0], [0, 0, -1]], device=self.device, dtype=torch.float32)
elif "+x" in front_dir:
T = torch.tensor([[0, 0, 1], [0, 1, 0], [1, 0, 0]], device=self.device, dtype=torch.float32)
elif "-x" in front_dir:
T = torch.tensor([[0, 0, -1], [0, 1, 0], [1, 0, 0]], device=self.device, dtype=torch.float32)
elif "+y" in front_dir:
T = torch.tensor([[1, 0, 0], [0, 0, 1], [0, 1, 0]], device=self.device, dtype=torch.float32)
elif "-y" in front_dir:
T = torch.tensor([[1, 0, 0], [0, 0, -1], [0, 1, 0]], device=self.device, dtype=torch.float32)
else:
T = torch.tensor([[1, 0, 0], [0, 1, 0], [0, 0, 1]], device=self.device, dtype=torch.float32)
# Additional rotations
if '1' in front_dir:
T @= torch.tensor([[0, -1, 0], [1, 0, 0], [0, 0, 1]], device=self.device, dtype=torch.float32)
elif '2' in front_dir:
T @= torch.tensor([[1, 0, 0], [0, -1, 0], [0, 0, 1]], device=self.device, dtype=torch.float32)
elif '3' in front_dir:
T @= torch.tensor([[0, 1, 0], [-1, 0, 0], [0, 0, 1]], device=self.device, dtype=torch.float32)
# Apply transformation
if self.v is not None:
self.v @= T
if self.vn is not None:
self.vn @= T
# Use methods from standard Mesh
def aabb(self):
"""AABB mesh"""
return torch.min(self.v, dim=0).values, torch.max(self.v, dim=0).values
@torch.no_grad()
def auto_size(self, bound=0.9):
"""Automatic size change"""
vmin, vmax = self.aabb()
self.ori_center = (vmax + vmin) / 2
self.ori_scale = 2 * bound / torch.max(vmax - vmin).item()
self.v = (self.v - self.ori_center) * self.ori_scale
def auto_normal(self):
"""Automatic calculation of normals"""
i0, i1, i2 = self.f[:, 0].long(), self.f[:, 1].long(), self.f[:, 2].long()
v0, v1, v2 = self.v[i0, :], self.v[i1, :], self.v[i2, :]
face_normals = torch.cross(v1 - v0, v2 - v0, dim=-1)
vn = torch.zeros_like(self.v)
vn.scatter_add_(0, i0[:, None].repeat(1, 3), face_normals)
vn.scatter_add_(0, i1[:, None].repeat(1, 3), face_normals)
vn.scatter_add_(0, i2[:, None].repeat(1, 3), face_normals)
vn = torch.where(
dot(vn, vn) > 1e-20,
vn,
torch.tensor([0.0, 0.0, 1.0], dtype=torch.float32, device=vn.device),
)
vn = safe_normalize(vn)
self.vn = vn
self.fn = self.f
def auto_uv(self, cache_path=None, vmap=True):
"""Automatic calculation of UV (use method from standard Mesh)"""
# Temporarily delegate to standard Mesh
temp_mesh = Mesh(
v=self.v, f=self.f, vn=self.vn, fn=self.fn,
vt=self.vt, ft=self.ft, vc=self.vc,
albedo=self.albedo, metallicRoughness=self.metallicRoughness,
device=self.device
)
temp_mesh.auto_uv(cache_path=cache_path, vmap=vmap)
# Copy result back
self.vt = temp_mesh.vt
self.ft = temp_mesh.ft
if vmap:
self.v = temp_mesh.v
self.f = temp_mesh.f
self.vn = temp_mesh.vn
self.fn = temp_mesh.fn
if temp_mesh.vc is not None:
self.vc = temp_mesh.vc
def to(self, device):
"""Move to another device"""
self.device = device
for name in ["v", "f", "vn", "fn", "vt", "ft", "albedo", "vc", "metallicRoughness"]:
tensor = getattr(self, name)
if tensor is not None:
setattr(self, name, tensor.to(device))
return self
def write(self, path):
"""Saving to file (use methods from standard Mesh)"""
# Create temporary standard Mesh for saving
temp_mesh = Mesh(
v=self.v, f=self.f, vn=self.vn, fn=self.fn,
vt=self.vt, ft=self.ft, vc=self.vc,
albedo=self.albedo, metallicRoughness=self.metallicRoughness,
device=self.device
)
temp_mesh.ori_center = self.ori_center
temp_mesh.ori_scale = self.ori_scale
return temp_mesh.write(path)
class PointCloud(NamedTuple):
points: np.array
colors: np.array
+29 -16
View File
@@ -38,7 +38,8 @@ import trimesh
from PIL import Image
from mesh_processor.export_utils import export_to_fastmesh, export_to_mesh
from mesh_processor.mesh import FastMesh, Mesh
from mesh_processor.fastmesh import FastMesh
from mesh_processor.mesh import Mesh
from mesh_processor.mesh_utils import (
ply_to_points_cloud,
get_target_axis_and_scale,
@@ -288,6 +289,7 @@ class Load_3D_Mesh:
return {
"required": {
"mesh_file_path": ("STRING", {"default": '', "multiline": False}),
"use_fastmesh": ("BOOLEAN", {"default": True, "tooltip": "Use FastMesh for GLB/GLTF files"}),
"resize": ("BOOLEAN", {"default": False},),
"renormal": ("BOOLEAN", {"default": True},),
"retex": ("BOOLEAN", {"default": False},),
@@ -306,7 +308,7 @@ class Load_3D_Mesh:
FUNCTION = "load_mesh"
CATEGORY = "Comfy3D/Import|Export"
def load_mesh(self, mesh_file_path, resize, renormal, retex, optimizable, clean, resize_bound):
def load_mesh(self, mesh_file_path, use_fastmesh, resize, renormal, retex, optimizable, clean, resize_bound):
mesh = None
if not os.path.isabs(mesh_file_path):
@@ -316,7 +318,10 @@ class Load_3D_Mesh:
folder, filename = os.path.split(mesh_file_path)
if filename.lower().endswith(SUPPORTED_3D_EXTENSIONS):
with torch.inference_mode(not optimizable):
mesh = Mesh.load(mesh_file_path, resize, renormal, retex, clean, resize_bound)
if use_fastmesh and mesh_file_path.endswith((".glb", ".gltf")):
mesh = FastMesh.load(mesh_file_path, resize, renormal, retex, clean, resize_bound)
else:
mesh = Mesh.load(mesh_file_path, resize, renormal, retex, clean, resize_bound)
else:
cstr(f"[{self.__class__.__name__}] File name {filename} does not end with supported 3D file extensions: {SUPPORTED_3D_EXTENSIONS}").error.print()
else:
@@ -366,6 +371,7 @@ class Save_3D_Mesh:
"required": {
"mesh": ("MESH",),
"save_path": ("STRING", {"default": 'Mesh_%Y-%m-%d-%M-%S-%f.glb', "multiline": False}),
"use_fastmesh": ("BOOLEAN", {"default": True, "tooltip": "Use FastMesh for fast saving GLB"}),
},
}
@@ -379,11 +385,26 @@ class Save_3D_Mesh:
FUNCTION = "save_mesh"
CATEGORY = "Comfy3D/Import|Export"
def save_mesh(self, mesh, save_path):
def save_mesh(self, mesh, save_path, use_fastmesh):
save_path = parse_save_filename(save_path, comfy_paths.output_directory, SUPPORTED_3D_EXTENSIONS, self.__class__.__name__)
if save_path is not None:
mesh.write(save_path)
if use_fastmesh and save_path.endswith((".glb", ".ply", ".obj")):
if hasattr(mesh, '_write_glb_fast'):
mesh.write(save_path)
else:
fast_mesh = FastMesh(
v=mesh.v, f=mesh.f, vn=mesh.vn, fn=mesh.fn,
vt=mesh.vt, ft=mesh.ft, vc=mesh.vc,
albedo=mesh.albedo, metallicRoughness=getattr(mesh, 'metallicRoughness', None),
device=mesh.device
)
fast_mesh.ori_center = getattr(mesh, 'ori_center', 0)
fast_mesh.ori_scale = getattr(mesh, 'ori_scale', 1)
fast_mesh.write(save_path)
else:
mesh.write(save_path)
return (save_path, )
@@ -5540,15 +5561,8 @@ class Hunyuan3D_21_TexGen:
if os.path.exists(glb_path):
try:
glb_scene = trimesh.load(glb_path)
if hasattr(glb_scene, 'geometry') and glb_scene.geometry:
mesh_name = list(glb_scene.geometry.keys())[0]
glb_mesh = glb_scene.geometry[mesh_name]
else:
glb_mesh = glb_scene
mesh_out = Mesh.load_trimesh(given_mesh=glb_mesh)
print(f"[Hunyuan3D] Loading GLB with PBR materials")
mesh_out = FastMesh.load(glb_path, load_pbr=True)
mesh_out.auto_normal()
print(f"Loaded GLB mesh with PBR materials from: {glb_path}")
except Exception as e:
@@ -5557,8 +5571,7 @@ class Hunyuan3D_21_TexGen:
# If PBR failed or not requested, load regular textured mesh
if mesh_out is None:
textured_mesh = trimesh.load(result_path)
mesh_out = Mesh.load_trimesh(given_mesh=textured_mesh)
mesh_out = FastMesh.load(result_path, load_pbr=create_pbr)
mesh_out.auto_normal()
if create_pbr:
print("Warning: PBR creation failed, loaded regular textured mesh")