Added node "Projection HighPoly to LowPoly" + "Render MultiView"

This commit is contained in:
Bruno Fargnoli
2026-03-21 11:41:56 +01:00
parent 8cc00ebeb3
commit 8597c6ec9e
6 changed files with 766 additions and 50 deletions
+1
View File
@@ -14,6 +14,7 @@
| Date | Description |
| --- | --- |
| **2026-03-21** | Added node "Projection HighPoly to LowPoly"<br>Added node "Render MultiView" |
| **2026-03-17** | Added Inpainting Choice NS and TELEA |
| **2026-03-14** | Added Experimental node "Projection MultiView Texturing"<br>Check in example_workflows folder |
| **2026-03-08** | Updated CuMesh wheels for Torch 2.7, 2.8 and Linux<br>You can use the node "Fill Holes with Cumesh" |
+373 -1
View File
@@ -4020,7 +4020,7 @@ class Trellis2MultiViewTexturing:
custom_weights="",
camera_config = None
):
from .texture_projection_multiview import texture_mesh_with_multiview
from .projection.texture_projection_multiview import texture_mesh_with_multiview
reset_cuda()
@@ -4120,6 +4120,374 @@ class Trellis2MultiViewTexturing:
print(f"[MultiView] Warning: Could not parse angles: {angle_string}")
return []
class Trellis2ProjectHighPolyToLowPoly:
"""
Apply texture to mesh by projecting multiple view images.
Uses angle-weighted blending: each surface receives texture from all views
that can "see" it, weighted by how directly the surface faces each camera.
Camera angles (Y-up coordinate system):
- Azimuth: rotation around Y axis
- 0° = front (looking in -Z direction)
- 90° = left (looking in -X direction)
- 180° = back (looking in +Z direction)
- 270° = right (looking in +X direction)
- Elevation: rotation around X axis
- 0° = horizontal
- 90° = top (looking in -Y direction, from above)
- -90° = bottom (looking in +Y direction, from below)
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"high_poly_trimesh": ("TRIMESH",),
"low_poly_trimesh": ("TRIMESH",),
"texture_size": ("INT", {"default": 4096, "min": 512, "max": 8192}),
"blend_texture": ("BOOLEAN", {"default":True}),
"blend_exponent": ("FLOAT", {"default": 1.0, "min": 0.5, "max": 8.0, "step": 0.5}),
"ortho_scale": ("FLOAT", {"default": 1.1, "min": 0.05, "max": 10.0, "step": 0.01}),
"norm_size": ("FLOAT",{"default":1.15, "min":0.0, "max":9.99, "step":0.01}),
"fill_holes": ("BOOLEAN",{"default":True}),
"max_hole_size": ("INT",{"default":20,"min":0,"max":99999,"step":1}),
"use_metallic": ("BOOLEAN",{"default":True}),
"depth_eps": ("FLOAT",{"default":0.0100,"min":0.0001,"max":1.0000,"step":0.0001}),
},
"optional": {
# Standard views
"front_image": ("IMAGE",), # az=0, el=0
"back_image": ("IMAGE",), # az=180, el=0
"left_image": ("IMAGE",), # az=90, el=0
"right_image": ("IMAGE",), # az=270, el=0
"top_image": ("IMAGE",), # az=0, el=90
"bottom_image": ("IMAGE",), # az=0, el=-90
"front_weight": ("FLOAT",{"default":1.000,"min":0.001,"max":1.000,"step":0.001}),
"back_weight": ("FLOAT",{"default":1.000,"min":0.001,"max":1.000,"step":0.001}),
"left_weight": ("FLOAT",{"default":0.010,"min":0.001,"max":1.000,"step":0.001}),
"right_weight": ("FLOAT",{"default":0.010,"min":0.001,"max":1.000,"step":0.001}),
"top_weight": ("FLOAT",{"default":0.010,"min":0.001,"max":1.000,"step":0.001}),
"bottom_weight": ("FLOAT",{"default":0.010,"min":0.001,"max":1.000,"step":0.001}),
# Custom views
"custom_images": ("IMAGE",),
"custom_azimuths": ("STRING", {"default": ""}),
"custom_elevations": ("STRING", {"default": ""}),
"custom_weights": ("STRING", {"default": ""}),
"camera_config": ("HY3DCAMERA",),
}
}
RETURN_TYPES = ("TRIMESH", "IMAGE", "IMAGE",)
RETURN_NAMES = ("trimesh", "base_color", "metallic_roughness",)
FUNCTION = "process"
CATEGORY = "Trellis2Wrapper"
OUTPUT_NODE = True
def process(
self,
high_poly_trimesh,
low_poly_trimesh,
texture_size,
blend_texture,
blend_exponent,
ortho_scale,
norm_size,
fill_holes,
max_hole_size,
use_metallic,
depth_eps,
baseColorTexture = None,
front_image=None,
back_image=None,
left_image=None,
right_image=None,
top_image=None,
bottom_image=None,
front_weight=None,
back_weight=None,
left_weight=None,
right_weight=None,
top_weight=None,
bottom_weight=None,
custom_images=None,
custom_azimuths="",
custom_elevations="",
custom_weights="",
camera_config = None,
):
from .projection.texture_projection_multiview import texture_mesh_with_multiview
reset_cuda()
# Collect views
images = []
azimuths = []
elevations = []
weights = []
# Standard views with their camera angles
standard_views = [
(front_image, 0, 0, "front", front_weight),
(back_image, 180, 0, "back", back_weight),
(left_image, 90, 0, "left", left_weight),
(right_image, 270, 0, "right", right_weight),
(top_image, 0, 90, "top", top_weight),
(bottom_image, 0, -90, "bottom", bottom_weight),
]
for img, az, el, name, w in standard_views:
if img is not None:
images.append(self._tensor_to_pil(img))
azimuths.append(az)
elevations.append(el)
weights.append(w)
print(f"[MultiView] Added {name} view (az={az}, el={el}, w={w})")
# Custom views
if custom_images is not None:
custom_az_list = self._parse_angles(custom_azimuths)
custom_el_list = self._parse_angles(custom_elevations)
custom_w_list = self._parse_angles(custom_weights)
if custom_az_list and custom_el_list:
num_custom = min(len(custom_az_list), len(custom_el_list), int(custom_images.shape[0]), len(custom_w_list))
for i in range(num_custom):
images.append(self._tensor_to_pil(custom_images[i:i+1]))
azimuths.append(custom_az_list[i])
elevations.append(custom_el_list[i])
weights.append(custom_w_list[i])
print(f"[MultiView] Added custom view {i+1} (az={custom_az_list[i]}, el={custom_el_list[i]})")
elif camera_config:
selected_camera_azims = camera_config["selected_camera_azims"]
selected_camera_elevs = camera_config["selected_camera_elevs"]
selected_view_weights = camera_config["selected_view_weights"]
#ortho_scale = camera_config["ortho_scale"]
num_custom = min(len(selected_camera_azims), len(selected_camera_elevs), int(custom_images.shape[0]))
for i in range(num_custom):
images.append(self._tensor_to_pil(custom_images[i:i+1]))
azimuths.append(selected_camera_azims[i])
elevations.append(selected_camera_elevs[i])
weights.append(selected_view_weights[i])
print(f"[MultiView] Added custom view {i+1} (az={selected_camera_azims[i]}, el={selected_camera_elevs[i]}, w={selected_view_weights[i]})")
if len(images) == 0:
raise ValueError("No input images provided! Please connect at least one image.")
print(f"[MultiView] Total views: {len(images)}")
print(f"[MultiView] Azimuths: {azimuths}")
print(f"[MultiView] Elevations: {elevations}")
trimesh_obj, base_color, mr = texture_mesh_with_multiview(
high_poly_trimesh,
images,
azimuths,
elevations,
weights,
texture_size=texture_size,
blend_exponent=blend_exponent,
ortho_scale=ortho_scale,
blend_texture=blend_texture,
fill_holes=fill_holes,
norm_size=norm_size,
max_hole_size=max_hole_size,
use_metallic=use_metallic,
depth_eps=depth_eps,
low_poly_mesh=low_poly_trimesh
)
return (trimesh_obj, pil2tensor(base_color), pil2tensor(mr))
def _tensor_to_pil(self, tensor):
"""Convert ComfyUI IMAGE tensor to PIL."""
if len(tensor.shape) == 4:
arr = (tensor[0].cpu().numpy() * 255).astype(np.uint8)
else:
arr = (tensor.cpu().numpy() * 255).astype(np.uint8)
return Image.fromarray(arr)
def _parse_angles(self, angle_string):
"""Parse comma-separated angles into list of floats."""
if not angle_string or angle_string.strip() == "":
return []
try:
return [float(x.strip()) for x in angle_string.split(",") if x.strip()]
except ValueError:
print(f"[MultiView] Warning: Could not parse angles: {angle_string}")
return []
class Trellis2RenderMultiView:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"trimesh": ("TRIMESH",),
"render_size": ("INT", {"default": 4096, "min": 512, "max": 8192}),
"ortho_scale": ("FLOAT", {"default": 1.1, "min": 0.05, "max": 10.0, "step": 0.01}),
"blender_exec_path": ("STRING",),
"azimuths": ("STRING",{"default":"0,90,180,270,0,0"}),
"elevations": ("STRING",{"default":"0,0,0,0,90,-90"}),
},
}
RETURN_TYPES = ("IMAGE","FLOAT", "STRING", "STRING",)
RETURN_NAMES = ("images","ortho_scale", "azimuths", "elevations",)
FUNCTION = "process"
CATEGORY = "Trellis2Wrapper"
OUTPUT_NODE = True
def process(
self,
trimesh,
render_size,
ortho_scale,
blender_exec_path,
azimuths,
elevations
):
reset_cuda()
if not hasattr(trimesh.visual, 'material'):
raise Exception("Trimesh does not have a material")
custom_az_list = self._parse_angles(azimuths)
custom_el_list = self._parse_angles(elevations)
if custom_az_list and custom_el_list:
if len(custom_az_list) != len(custom_el_list):
raise Exception("azimuths and elevations must have the same amount of values")
textured_maps = self.render_textured_multiview(
custom_el_list, custom_az_list, ortho_scale, render_size, blender_exec_path, trimesh)
custom_images = torch.stack(textured_maps, dim=0)
return (custom_images, ortho_scale, azimuths, elevations,)
else:
raise Exception("azimuths and elevations are required")
def _parse_angles(self, angle_string):
"""Parse comma-separated angles into list of floats."""
if not angle_string or angle_string.strip() == "":
return []
try:
return [float(x.strip()) for x in angle_string.split(",") if x.strip()]
except ValueError:
print(f"[MultiView] Warning: Could not parse angles: {angle_string}")
return []
def render_textured_multiview(self, camera_elevs, camera_azims, ortho_scale, resolution, blender_exec_path, mesh):
from .projection.camera_utils import get_orthographic_projection_matrix
proj = get_orthographic_projection_matrix(
left=-ortho_scale * 0.5, right=ortho_scale * 0.5,
bottom=-ortho_scale * 0.5, top=ortho_scale * 0.5,
near=0.1, far=100
)
textured_maps = []
for elev, azim in zip(camera_elevs, camera_azims):
textured_map = self.render(
elev, azim, filter_mode='linear', return_type='th', scale=ortho_scale, resolution=resolution, blender_exec_path=blender_exec_path,proj=proj,mesh=mesh)
textured_maps.append(textured_map)
return textured_maps
def render(
self,
elev,
azim,
camera_distance=None,
center=None,
resolution=None,
tex=None,
keep_alpha=False,
bgcolor=None,
filter_mode=None,
return_type='th',
scale=1.0,
blender_exec_path=None,
proj=None,
mesh=None,
):
from .projection.camera_utils import get_mv_matrix
r_mv = get_mv_matrix(
elev=elev,
azim=azim,
camera_distance=1.1,
center=center)
r_mvp = np.matmul(proj, r_mv).astype(np.float32)
if tex is not None:
if isinstance(tex, Image.Image):
tex = torch.tensor(np.array(tex) / 255.0)
elif isinstance(tex, np.ndarray):
tex = torch.tensor(tex)
if tex.dim() == 2:
tex = tex.unsqueeze(-1)
tex = tex.float().to(self.device)
# image = self._render(r_mvp, self.vtx_pos, self.pos_idx, self.vtx_uv, self.uv_idx,
# self.tex if tex is None else tex,
# self.default_resolution if resolution is None else resolution,
# self.max_mip_level, True, filter_mode if filter_mode else self.filter_mode,
# elev=elev, azim=azim, camera_distance=camera_distance,scale=scale,blender_exec_path=blender_exec_path)
image = self.raster_texture(tex, mesh.visual.uv, elev=elev, azim=azim, camera_distance=camera_distance, resolution=resolution, scale=scale, blender_exec_path=blender_exec_path,mesh=mesh)
mask = (image[..., [-1]] == 1).float()
if bgcolor is None:
bgcolor = [0 for _ in range(image.shape[-1] - 1)]
image = image * mask + (1 - mask) * \
torch.tensor(bgcolor + [0])
if keep_alpha == False:
image = image[..., :-1]
if return_type == 'np':
image = image.cpu().numpy()
elif return_type == 'pl':
image = image.squeeze(-1).cpu().numpy() * 255
image = Image.fromarray(image.astype(np.uint8))
return image
def raster_texture(self, tex, uv, uv_da=None, mip_level_bias=None, mip=None, filter_mode='auto',
boundary_mode='wrap', max_mip_level=None, elev=None, azim=None, camera_distance=None, resolution=None, scale=1.0,
blender_exec_path=None,mesh=None):
import tempfile
import subprocess
with tempfile.NamedTemporaryFile(suffix=".obj", delete=False) as tmp_mesh:
mesh_path = tmp_mesh.name
tmp_mesh.close()
mesh.export(mesh_path)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_out:
output_path = tmp_out.name
tmp_out.close()
blender_script = os.path.join(os.path.dirname(__file__), 'projection', 'blender_render.py')
res = resolution[0] if isinstance(resolution, (list, tuple)) else resolution
cmd = [
blender_exec_path, '-b', '-P', blender_script, '--',
'--mesh', mesh_path,
'--output', output_path,
'--elev', str(elev),
'--azim', str(azim),
'--scale', str(scale),
'--resolution', str(res)
]
subprocess.run(cmd, check=True)
image = Image.open(output_path)
image = torch.tensor(np.array(image) / 255.0).float()
if os.path.exists(mesh_path):
os.remove(mesh_path)
if os.path.exists(output_path):
os.remove(output_path)
return image
class Trellis2CudaReset:
@classmethod
def INPUT_TYPES(s):
@@ -4190,6 +4558,8 @@ NODE_CLASS_MAPPINGS = {
"Trellis2Continue5": Trellis2Continue5,
"Trellis2Continue6": Trellis2Continue6,
"Trellis2CudaReset": Trellis2CudaReset,
"Trellis2ProjectHighPolyToLowPoly": Trellis2ProjectHighPolyToLowPoly,
"Trellis2RenderMultiView": Trellis2RenderMultiView,
}
@@ -4244,4 +4614,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"Trellis2Continue5": "Trellis2 - Continue 5",
"Trellis2Continue6": "Trellis2 - Continue 6",
"Trellis2CudaReset": "Trellis2 - Cuda Reset",
"Trellis2ProjectHighPolyToLowPoly": "Trellis2 - Projection HighPoly To LowPoly",
"Trellis2RenderMultiView": "Trellis2 - Render MultiView",
}
+255
View File
@@ -0,0 +1,255 @@
import bpy
import sys
import argparse
import math
import os
def reset_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
def configure_gpu(rendering):
if rendering == 'GPU':
bpy.context.scene.cycles.device = 'GPU'
prefs = bpy.context.preferences
cprefs = prefs.addons['cycles'].preferences
for compute_device_type in ('OPTIX', 'CUDA', 'HIP', 'METAL'):
try:
cprefs.compute_device_type = compute_device_type
cprefs.get_devices()
devices = cprefs.devices
if devices:
print(f"Blender: Found {compute_device_type} devices:")
for device in devices:
device.use = True
print(f" - Activated: {device.name}")
return
except Exception as e:
continue
print("Blender: No GPU found, falling back to CPU.")
bpy.context.scene.cycles.device = 'CPU'
else:
bpy.context.scene.cycles.device = 'CPU'
def set_camera(elev, azim, distance, scale, clip_start=0.1, clip_end=100):
# Location calculation
elev_rad = math.radians(elev)
azim_rad = math.radians(azim - 90)
x = distance * math.cos(elev_rad) * math.cos(azim_rad)
y = distance * math.cos(elev_rad) * math.sin(azim_rad)
z = distance * math.sin(elev_rad)
camera_data = bpy.data.cameras.new(name='Camera')
# Orthographic settings
camera_data.type = 'ORTHO'
camera_data.ortho_scale = scale
camera_data.clip_start = clip_start
camera_data.clip_end = clip_end
camera_object = bpy.data.objects.new('Camera', camera_data)
bpy.context.scene.collection.objects.link(camera_object)
bpy.context.scene.camera = camera_object
camera_object.location = (x, y, z)
# Point camera at origin using a Track To constraint
bpy.ops.object.empty_add(location=(0, 0, 0))
target = bpy.context.active_object
constraint = camera_object.constraints.new(type='TRACK_TO')
constraint.target = target
constraint.track_axis = 'TRACK_NEGATIVE_Z'
constraint.up_axis = 'UP_Y'
def setup_lighting():
# Ensure a World data block exists (fix for factory settings)
if bpy.context.scene.world is None:
new_world = bpy.data.worlds.new("New_Render_World")
bpy.context.scene.world = new_world
world = bpy.context.scene.world
world.use_nodes = True
# Get or create the Background node
if 'Background' not in world.node_tree.nodes:
world.node_tree.nodes.new('ShaderNodeBackground')
output_node = world.node_tree.nodes['World Output']
bg_node = world.node_tree.nodes['Background']
world.node_tree.links.new(bg_node.outputs['Background'], output_node.inputs['Surface'])
bg_node = world.node_tree.nodes['Background']
bg_node.inputs['Color'].default_value = (1.0, 1.0, 1.0, 1.0)
bg_node.inputs['Strength'].default_value = 1.0
bpy.context.scene.view_settings.view_transform = 'Standard'
bpy.context.scene.cycles.max_bounces = 8
bpy.context.scene.cycles.diffuse_bounces = 4 # How many times light reflects off diffuse surfaces
def auto_center_and_scale(obj, norm_size):
"""
Centers the mesh object's geometry at the origin and scales it such that
(Max Radius * 2.0) equals norm_size.
"""
if obj.type != 'MESH':
print(f"Object {obj.name} is not a mesh. Skipping transformation.")
return
# Ensure we are in Object Mode before modifying geometry
if bpy.context.object and bpy.context.object.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
# Get the bounding box coordinates (min/max for X, Y, Z)
# The bounding box is an 8-tuple of (x,y,z) coordinates.
# The Bounding Box is in local coordinates when the object's scale is (1,1,1)
bbox = obj.bound_box
# Find min and max coordinates
min_x = min(v[0] for v in bbox)
max_x = max(v[0] for v in bbox)
min_y = min(v[1] for v in bbox)
max_y = max(v[1] for v in bbox)
min_z = min(v[2] for v in bbox)
max_z = max(v[2] for v in bbox)
# Calculate Center (equivalent to your (max_bb + min_bb) / 2)
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
center_z = (min_z + max_z) / 2
center = (center_x, center_y, center_z)
# 1. Translate Geometry (Equivalent to vtx_pos = (vtx_pos - center))
# This moves the geometry relative to the object's local origin.
# The context must be set correctly for bpy.ops.transform.translate to work.
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
# Enter Edit Mode
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# Translate vertices by -center vector
bpy.ops.transform.translate(value=(-center_x, -center_y, -center_z))
# Return to Object Mode
bpy.ops.object.mode_set(mode='OBJECT')
# The object's geometry is now centered on the local origin (0,0,0).
# We ensure the object's world location is also (0,0,0)
obj.location = (0, 0, 0)
# 2. Calculate Maximum Radius (for scaling)
current_max_radius_sq = 0.0
mesh = obj.data
# Iterate over vertices to find the farthest one from the origin
# The vertex coordinates are now relative to the center
for vertex in mesh.vertices:
if vertex.co.length_squared > current_max_radius_sq:
current_max_radius_sq = vertex.co.length_squared
current_max_radius = math.sqrt(current_max_radius_sq)
if current_max_radius < 1e-6:
print("Skipping scaling: mesh has negligible size.")
return
# 3. Calculate and Apply Scale
# User's scaling basis: scale_user = current_max_radius * 2.0
scale_user = current_max_radius * 2.0
# Target scale factor: (scale_factor / scale_user)
scale_factor_needed = norm_size / scale_user
# Apply scale to the object
obj.scale = (scale_factor_needed, scale_factor_needed, scale_factor_needed)
# 4. Apply transformation to bake scale into geometry (optional but good practice)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
print(f"Mesh '{obj.name}' centered. Scaled by {scale_factor_needed:.4f}.")
print(f"Final normalized size (Max Radius * 2.0) is approx {norm_size:.4f}.")
def import_mesh(mesh_path):
if not os.path.exists(mesh_path):
print(f"Error: Mesh file not found at {mesh_path}")
sys.exit(1)
if mesh_path.endswith('.obj'):
if hasattr(bpy.ops.wm, 'obj_import'):
bpy.ops.wm.obj_import(filepath=mesh_path)
else:
bpy.ops.import_scene.obj(filepath=mesh_path)
elif mesh_path.endswith('.glb') or mesh_path.endswith('.gltf'):
bpy.ops.import_scene.gltf(filepath=mesh_path)
else:
print(f"Unsupported mesh format: {mesh_path}")
sys.exit(1)
# Return the first imported mesh object
return bpy.context.view_layer.objects.active
def render_scene(output_path, resolution, rendering):
abs_output_path = os.path.abspath(output_path)
output_dir = os.path.dirname(abs_output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
bpy.context.scene.render.engine = 'CYCLES'
configure_gpu(rendering)
bpy.context.scene.render.resolution_x = resolution
bpy.context.scene.render.resolution_y = resolution
bpy.context.scene.render.image_settings.file_format = 'PNG'
bpy.context.scene.render.filepath = abs_output_path
bpy.context.scene.render.use_file_extension = False
bpy.context.scene.cycles.samples = 512
bpy.context.scene.cycles.preview_samples = 32
bpy.context.scene.cycles.use_denoising = True
print(f"Rendering to: {abs_output_path}")
bpy.ops.render.render(write_still=True)
def main():
argv = sys.argv
if "--" not in argv:
argv = []
else:
argv = argv[argv.index("--") + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--mesh', required=True)
parser.add_argument('--output', required=True)
parser.add_argument('--elev', type=float, required=True)
parser.add_argument('--azim', type=float, required=True)
parser.add_argument('--distance', type=float, default=1.45)
parser.add_argument('--scale', type=float, default=2.0, help="Orthographic scale (field of view size)")
# New argument for normalization size
parser.add_argument('--norm_size', type=float, default=1.15, help="Target size for Max Radius * 2.0")
parser.add_argument('--resolution', type=int, default=1024)
parser.add_argument('--rendering', default="GPU")
args = parser.parse_args(argv)
reset_scene()
# Import and get the mesh object
mesh_obj = import_mesh(args.mesh)
# Auto-center and scale the mesh
if mesh_obj:
auto_center_and_scale(mesh_obj, args.norm_size)
# Set up the camera and lighting
set_camera(args.elev, args.azim, args.distance, args.scale)
setup_lighting()
render_scene(args.output, args.resolution, args.rendering)
if __name__ == "__main__":
main()
+124
View File
@@ -0,0 +1,124 @@
# Open Source Model Licensed under the Apache License Version 2.0
# and Other Licenses of the Third-Party Components therein:
# The below Model in this distribution may have been modified by THL A29 Limited
# ("Tencent Modifications"). All Tencent Modifications are Copyright (C) 2024 THL A29 Limited.
# Copyright (C) 2024 THL A29 Limited, a Tencent company. All rights reserved.
# The below software and/or models in this distribution may have been
# modified by THL A29 Limited ("Tencent Modifications").
# All Tencent Modifications are Copyright (C) THL A29 Limited.
# Hunyuan 3D is licensed under the TENCENT HUNYUAN NON-COMMERCIAL LICENSE AGREEMENT
# except for the third-party components listed below.
# Hunyuan 3D does not impose any additional limitations beyond what is outlined
# in the repsective licenses of these third-party components.
# Users must comply with all terms and conditions of original licenses of these third-party
# components and must ensure that the usage of the third party components adheres to
# all relevant laws and regulations.
# For avoidance of doubts, Hunyuan 3D means the large language models and
# their software and algorithms, including trained model weights, parameters (including
# optimizer states), machine-learning model code, inference-enabling code, training-enabling code,
# fine-tuning enabling code and other elements of the foregoing made publicly available
# by Tencent in accordance with TENCENT HUNYUAN COMMUNITY LICENSE AGREEMENT.
import math
import numpy as np
import torch
def transform_pos(mtx, pos, keepdim=False):
t_mtx = torch.from_numpy(mtx).to(
pos.device) if isinstance(
mtx, np.ndarray) else mtx
if pos.shape[-1] == 3:
posw = torch.cat(
[pos, torch.ones([pos.shape[0], 1]).to(pos.device)], axis=1)
else:
posw = pos
if keepdim:
return torch.matmul(posw, t_mtx.t())[...]
else:
return torch.matmul(posw, t_mtx.t())[None, ...]
def get_mv_matrix(elev, azim, camera_distance, center=None, pan_x=0.0, pan_y=0.0):
elev = -elev
azim += 90
elev_rad = math.radians(elev)
azim_rad = math.radians(azim)
# Calculate base camera position
camera_position = np.array([camera_distance * math.cos(elev_rad) * math.cos(azim_rad),
camera_distance *
math.cos(elev_rad) * math.sin(azim_rad),
camera_distance * math.sin(elev_rad)])
if center is None:
center = np.array([0, 0, 0])
else:
center = np.array(center)
# Calculate view direction
lookat = center - camera_position
lookat = lookat / np.linalg.norm(lookat)
# Calculate up and right vectors
up = np.array([0, 0, 1.0])
right = np.cross(lookat, up)
right = right / np.linalg.norm(right)
up = np.cross(right, lookat)
up = up / np.linalg.norm(up)
# Apply panning by moving camera position and center
pan_offset = (right * pan_x + up * pan_y)
camera_position += pan_offset
# Create camera matrix
c2w = np.concatenate(
[np.stack([right, up, -lookat], axis=-1), camera_position[:, None]], axis=-1)
w2c = np.zeros((4, 4))
w2c[:3, :3] = np.transpose(c2w[:3, :3], (1, 0))
w2c[:3, 3:] = -np.matmul(np.transpose(c2w[:3, :3], (1, 0)), c2w[:3, 3:])
w2c[3, 3] = 1.0
return w2c.astype(np.float32)
def get_orthographic_projection_matrix(
left=-1, right=1, bottom=-1, top=1, near=0, far=2):
"""
计算正交投影矩阵。
参数:
left (float): 投影区域左侧边界。
right (float): 投影区域右侧边界。
bottom (float): 投影区域底部边界。
top (float): 投影区域顶部边界。
near (float): 投影区域近裁剪面距离。
far (float): 投影区域远裁剪面距离。
返回:
numpy.ndarray: 正交投影矩阵。
"""
ortho_matrix = np.eye(4, dtype=np.float32)
ortho_matrix[0, 0] = 2 / (right - left)
ortho_matrix[1, 1] = 2 / (top - bottom)
ortho_matrix[2, 2] = -2 / (far - near)
ortho_matrix[0, 3] = -(right + left) / (right - left)
ortho_matrix[1, 3] = -(top + bottom) / (top - bottom)
ortho_matrix[2, 3] = -(far + near) / (far - near)
return ortho_matrix
def get_perspective_projection_matrix(fovy, aspect_wh, near, far):
fovy_rad = math.radians(fovy)
return np.array([[1.0 / (math.tan(fovy_rad / 2.0) * aspect_wh), 0, 0, 0],
[0, 1.0 / math.tan(fovy_rad / 2.0), 0, 0],
[0, 0, -(far + near) / (far - near), -
2.0 * far * near / (far - near)],
[0, 0, -1, 0]]).astype(np.float32)
@@ -125,7 +125,7 @@ def project_texels_to_image(tex_pos, right, up, ortho_scale):
# ---------------------------------------------------------------------------
def texture_mesh_with_multiview(
mesh: trimesh.Trimesh,
high_poly_mesh: trimesh.Trimesh,
images: list,
azimuths: list,
elevations: list,
@@ -143,6 +143,7 @@ def texture_mesh_with_multiview(
max_hole_size: int = 10,
use_metallic: bool = True,
depth_eps: float = 0.002,
low_poly_mesh: trimesh.Trimesh = None
):
if not (len(images) == len(azimuths) == len(elevations)):
raise ValueError("images, azimuths, and elevations must have the same length")
@@ -161,6 +162,11 @@ def texture_mesh_with_multiview(
num_views = len(images)
print(f"[MultiView] {num_views} views | texture={texture_size} | ortho_scale={ortho_scale}")
if low_poly_mesh is not None:
mesh = low_poly_mesh
else:
mesh = high_poly_mesh
# =========================================================================
# STEP 1 – UV unwrap
# =========================================================================
@@ -428,7 +434,7 @@ def texture_mesh_with_multiview(
#-- Load and resample the existing PBR base color texture ----------------
existing_base = None
try:
mat = mesh.visual.material
mat = high_poly_mesh.visual.material
existing_base = getattr(mat, 'baseColorTexture', None)
if existing_base is None:
# Fallback: try accessing via image attribute (SimpleMaterial / PBRMaterial variants)
@@ -528,49 +534,7 @@ def texture_mesh_with_multiview(
if (n_holes + n_pad) > 0:
for c in range(3):
color_np[..., c] = cv2.inpaint(color_np[..., c], final_inpaint_mask, 3, cv2.INPAINT_NS)
alpha_np = cv2.inpaint(alpha_np, final_inpaint_mask, 3, cv2.INPAINT_NS)
# if fill_holes:
# print('Filling holes ...')
# # 1. Get the raw mask of all holes (1 for hole, 0 for valid)
# raw_hole_mask = (~valid_mask.cpu().numpy()).astype(np.uint8)
# # 2. Filter by size if a limit is set
# if max_hole_size > 0:
# filtered_hole_mask = np.zeros_like(raw_hole_mask)
# # Find connected components (connectivity=8 handles diagonals)
# num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(raw_hole_mask, connectivity=8)
# print(f"Num Labels: {num_labels}")
# # Label 0 is the background (non-holes), so we start checking from Label 1
# if num_labels>0:
# progress_bar = tqdm(total=num_labels, desc="Filling holes")
# for label_id in range(1, num_labels):
# area = stats[label_id, cv2.CC_STAT_AREA]
# if area <= max_hole_size:
# # If the hole is small enough, add it to our filtered mask
# filtered_hole_mask[labels == label_id] = 1
# progress_bar.update(1)
# progress_bar.close()
# print(f"Number of filtered holes: {len(filtered_hole_mask)}")
# hole_mask = filtered_hole_mask
# else:
# hole_mask = raw_hole_mask
# n_holes = int(hole_mask.sum())
# print(f" Inpainting {n_holes} hole texels ({100.0*n_holes/hole_mask.size:.1f}%)...")
# if n_holes > 0:
# for c in range(3):
# color_np[..., c] = cv2.inpaint(color_np[..., c], hole_mask, 3, cv2.INPAINT_NS)
# alpha_np = cv2.inpaint(alpha_np, hole_mask, 3, cv2.INPAINT_NS)
alpha_np = cv2.inpaint(alpha_np, final_inpaint_mask, 3, cv2.INPAINT_NS)
# =========================================================================
# STEP 5 – Build output textures and trimesh
@@ -578,9 +542,9 @@ def texture_mesh_with_multiview(
baseColorTexture = Image.fromarray(np.dstack([color_np, alpha_np]))
metallicRoughnessTexture = None
if hasattr(mesh, 'visual') and hasattr(mesh.visual, 'material') and isinstance(mesh.visual.material, trimesh.visual.material.PBRMaterial):
if mesh.visual.material.metallicRoughnessTexture:
metallicRoughnessTexture = mesh.visual.material.metallicRoughnessTexture
if hasattr(high_poly_mesh, 'visual') and hasattr(high_poly_mesh.visual, 'material') and isinstance(high_poly_mesh.visual.material, trimesh.visual.material.PBRMaterial):
if high_poly_mesh.visual.material.metallicRoughnessTexture:
metallicRoughnessTexture = high_poly_mesh.visual.material.metallicRoughnessTexture
if metallicRoughnessTexture is None or not use_metallic:
mr_np = np.zeros((texture_size, texture_size, 3), dtype=np.uint8)
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "trellis2"
description = "ComfyUI Wrapper for Microsoft Trellis.2 - Native and Compact Structured Latents for 3D Generation"
version = "1.0.17"
version = "1.0.18"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)