diff --git a/README.md b/README.md
index 17db7b0..5ccccce 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
| Date | Description |
| --- | --- |
+| **2026-03-21** | Added node "Projection HighPoly to LowPoly"
Added node "Render MultiView" |
| **2026-03-17** | Added Inpainting Choice NS and TELEA |
| **2026-03-14** | Added Experimental node "Projection MultiView Texturing"
Check in example_workflows folder |
| **2026-03-08** | Updated CuMesh wheels for Torch 2.7, 2.8 and Linux
You can use the node "Fill Holes with Cumesh" |
diff --git a/nodes.py b/nodes.py
index 83ba91f..bc0f6c0 100644
--- a/nodes.py
+++ b/nodes.py
@@ -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",
}
diff --git a/projection/blender_render.py b/projection/blender_render.py
new file mode 100644
index 0000000..3a8e1b8
--- /dev/null
+++ b/projection/blender_render.py
@@ -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()
\ No newline at end of file
diff --git a/projection/camera_utils.py b/projection/camera_utils.py
new file mode 100644
index 0000000..2a4dd0e
--- /dev/null
+++ b/projection/camera_utils.py
@@ -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)
diff --git a/texture_projection_multiview.py b/projection/texture_projection_multiview.py
similarity index 91%
rename from texture_projection_multiview.py
rename to projection/texture_projection_multiview.py
index f6e30a0..788a5d1 100644
--- a/texture_projection_multiview.py
+++ b/projection/texture_projection_multiview.py
@@ -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)
diff --git a/pyproject.toml b/pyproject.toml
index 75f6aac..7ae16ae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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)