Added node "Projection HighPoly to LowPoly" + "Render MultiView"
This commit is contained in:
@@ -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" |
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user