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)