Standalone ComfyUI custom node that adds the Trellis2MeshRefinerMultiView node. Monkey-patches sample_mesh_slat_multiview and refine_mesh_multiview onto Trellis2ImageTo3DPipeline lazily (on first node execution) so the addon is agnostic to sibling-package load order under ComfyUI's loader. Method bodies are ports of the tested implementation from https://github.com/visualbruno/ComfyUI-Trellis2/pull/125 — test matrix covered front-only, front+back, and all-4-view inputs at 512/1024/1536. Credit to bribblej-web for suggesting the drop-in packaging pattern on the PR thread. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
523 lines
19 KiB
Python
523 lines
19 KiB
Python
"""
|
|
Multi-view mesh refiner for ComfyUI-Trellis2.
|
|
|
|
Adds one node: Trellis2MeshRefinerMultiView.
|
|
|
|
This package sits next to ComfyUI-Trellis2 in custom_nodes/ and does not
|
|
import from it at load time — ComfyUI registers custom-node modules under
|
|
path-derived sys.modules keys, and load order across sibling packages is
|
|
not guaranteed. Instead, this node resolves everything lazily from the
|
|
pipeline instance it receives at runtime (via type(pipeline).__module__),
|
|
then monkey-patches two methods onto the pipeline class on first use:
|
|
|
|
- sample_mesh_slat_multiview
|
|
- refine_mesh_multiview
|
|
|
|
The method bodies are ports of the tested implementation from
|
|
https://github.com/visualbruno/ComfyUI-Trellis2/pull/125 — tested across
|
|
front-only / front+back / all-4-view inputs at resolutions 512, 1024, 1536.
|
|
"""
|
|
|
|
import gc
|
|
import logging
|
|
import sys
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers inlined from ComfyUI-Trellis2's nodes.py (kept self-contained so we
|
|
# don't have to chase cross-package imports at load time).
|
|
# ---------------------------------------------------------------------------
|
|
def reset_cuda():
|
|
torch.cuda.synchronize()
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
|
|
|
|
def tensor2pil(image):
|
|
if isinstance(image, torch.Tensor):
|
|
t = image.detach().cpu()
|
|
if t.ndim == 4:
|
|
if t.shape[0] != 1:
|
|
raise ValueError(f"tensor2pil expects batch of 1, got batch={t.shape[0]}")
|
|
t = t[0]
|
|
elif t.ndim != 3:
|
|
raise ValueError(f"tensor2pil expects (H,W,C) or (1,H,W,C), got shape={tuple(t.shape)}")
|
|
arr = (t.numpy() * 255.0).clip(0, 255).astype(np.uint8)
|
|
return Image.fromarray(arr)
|
|
raise TypeError(f"tensor2pil expected torch.Tensor, got {type(image)}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pipeline method: sample_mesh_slat_multiview
|
|
# Resolves SparseTensor and samplers lazily from the pipeline's own module,
|
|
# so this function is agnostic to how the parent package was registered.
|
|
# ---------------------------------------------------------------------------
|
|
def _sample_mesh_slat_multiview(
|
|
self,
|
|
mesh_slat,
|
|
conds,
|
|
views,
|
|
flow_model,
|
|
resolution,
|
|
sampler_params=None,
|
|
max_num_tokens=49152,
|
|
downsampling=16,
|
|
front_axis='z',
|
|
blend_temperature=2.0,
|
|
):
|
|
if sampler_params is None:
|
|
sampler_params = {}
|
|
|
|
pipeline_mod = sys.modules[type(self).__module__]
|
|
SparseTensor = pipeline_mod.SparseTensor
|
|
samplers = pipeline_mod.samplers
|
|
|
|
self.load_shape_slat_decoder()
|
|
logger.info('Decoding mesh slat (MultiView) ...')
|
|
if self.low_vram:
|
|
self.models['shape_slat_decoder'].to(self.device)
|
|
self.models['shape_slat_decoder'].low_vram = True
|
|
hr_coords = self.models['shape_slat_decoder'].upsample(mesh_slat, upsample_times=4)
|
|
if self.low_vram:
|
|
self.models['shape_slat_decoder'].cpu()
|
|
self.models['shape_slat_decoder'].low_vram = False
|
|
hr_resolution = resolution
|
|
|
|
if not self.keep_models_loaded:
|
|
self.unload_shape_slat_decoder()
|
|
|
|
lr_resolution = resolution
|
|
|
|
while True:
|
|
quant_coords = torch.cat([
|
|
hr_coords[:, :1],
|
|
((hr_coords[:, 1:] + 0.5) / lr_resolution * (hr_resolution // downsampling)).int(),
|
|
], dim=1)
|
|
coords = quant_coords.unique(dim=0)
|
|
num_tokens = coords.shape[0]
|
|
if num_tokens < max_num_tokens:
|
|
if hr_resolution != resolution:
|
|
logger.info(f"Due to the limited number of tokens, the resolution is reduced to {hr_resolution}.")
|
|
break
|
|
hr_resolution -= 128
|
|
if hr_resolution < 1024 and resolution >= 1024:
|
|
hr_resolution = 1024
|
|
break
|
|
if hr_resolution < 512:
|
|
hr_resolution = 512
|
|
break
|
|
|
|
if self.low_vram:
|
|
for v in conds:
|
|
conds[v] = self._cond_to(conds[v], self.device)
|
|
|
|
coords_dev = coords.to(self.device)
|
|
noise = SparseTensor(
|
|
feats=torch.randn(coords.shape[0], flow_model.in_channels, device=self.device),
|
|
coords=coords_dev,
|
|
)
|
|
|
|
sampler_params = {**self.shape_slat_sampler_params, **sampler_params}
|
|
|
|
sampler_class = getattr(
|
|
samplers,
|
|
f"Flow{self._sampler_prefix}MultiViewGuidanceIntervalSampler",
|
|
samplers.FlowEulerMultiViewGuidanceIntervalSampler,
|
|
)
|
|
sampler = sampler_class(
|
|
sigma_min=1e-5,
|
|
resolution=flow_model.resolution if hasattr(flow_model, 'resolution') else flow_model[0].resolution,
|
|
)
|
|
|
|
if self.low_vram:
|
|
flow_model.to(self.device)
|
|
|
|
slat = sampler.sample(
|
|
flow_model,
|
|
noise,
|
|
conds=conds,
|
|
views=views,
|
|
front_axis=front_axis,
|
|
blend_temperature=blend_temperature,
|
|
**sampler_params,
|
|
verbose=True,
|
|
tqdm_desc="Sampling mesh shape SLat (MultiView)",
|
|
).samples
|
|
|
|
if self.low_vram:
|
|
flow_model.cpu()
|
|
self._cleanup_cuda()
|
|
|
|
std = torch.tensor(self.shape_slat_normalization['std'])[None].to(slat.device)
|
|
mean = torch.tensor(self.shape_slat_normalization['mean'])[None].to(slat.device)
|
|
slat = slat * std + mean
|
|
|
|
del coords_dev
|
|
if self.low_vram:
|
|
for v in conds:
|
|
conds[v] = self._cond_cpu(conds[v])
|
|
self._cleanup_cuda()
|
|
|
|
return slat, hr_resolution
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pipeline method: refine_mesh_multiview
|
|
# ---------------------------------------------------------------------------
|
|
@torch.no_grad()
|
|
def _refine_mesh_multiview(
|
|
self,
|
|
mesh,
|
|
front,
|
|
back=None,
|
|
left=None,
|
|
right=None,
|
|
seed=42,
|
|
shape_slat_sampler_params=None,
|
|
tex_slat_sampler_params=None,
|
|
resolution=1024,
|
|
max_num_tokens=50000,
|
|
generate_texture_slat=True,
|
|
return_latent=False,
|
|
downsampling=16,
|
|
use_tiled=True,
|
|
front_axis='z',
|
|
blend_temperature=2.0,
|
|
sampler='euler',
|
|
):
|
|
if shape_slat_sampler_params is None:
|
|
shape_slat_sampler_params = {}
|
|
if tex_slat_sampler_params is None:
|
|
tex_slat_sampler_params = {}
|
|
|
|
pipeline_mod = sys.modules[type(self).__module__]
|
|
seed_all = pipeline_mod.seed_all
|
|
|
|
self.switch_samplers(sampler)
|
|
|
|
mesh = self.preprocess_mesh(mesh)
|
|
seed_all(seed)
|
|
|
|
views_dict = {'front': front}
|
|
if back is not None:
|
|
views_dict['back'] = back
|
|
if left is not None:
|
|
views_dict['left'] = left
|
|
if right is not None:
|
|
views_dict['right'] = right
|
|
views_list = list(views_dict.keys())
|
|
|
|
self.load_image_cond_model()
|
|
conds = {}
|
|
cond_resolution = 512 if resolution == 512 else 1024
|
|
for v, img in views_dict.items():
|
|
conds[v] = self.get_cond([img], cond_resolution)
|
|
if not self.keep_models_loaded:
|
|
self.unload_image_cond_model()
|
|
|
|
mesh_slat = self.encode_shape_slat(mesh, resolution)
|
|
|
|
if resolution == 512:
|
|
self.unload_shape_slat_flow_model_1024()
|
|
self.load_shape_slat_flow_model_512()
|
|
shape_slat, res = self.sample_mesh_slat_multiview(
|
|
mesh_slat, conds, views_list,
|
|
self.models['shape_slat_flow_model_512'],
|
|
512, shape_slat_sampler_params,
|
|
max_num_tokens, downsampling,
|
|
front_axis, blend_temperature,
|
|
)
|
|
|
|
if not self.keep_models_loaded:
|
|
self.unload_shape_slat_flow_model_512()
|
|
|
|
if generate_texture_slat:
|
|
self.unload_tex_slat_flow_model_1024()
|
|
self.load_tex_slat_flow_model_512()
|
|
tex_slat = self.sample_tex_slat_multiview(
|
|
conds, views_list,
|
|
shape_slat=shape_slat,
|
|
flow_model=self.models['tex_slat_flow_model_512'],
|
|
sampler_params=tex_slat_sampler_params,
|
|
front_axis=front_axis,
|
|
blend_temperature=blend_temperature,
|
|
)
|
|
|
|
if not self.keep_models_loaded:
|
|
self.unload_tex_slat_flow_model_512()
|
|
elif resolution == 1024:
|
|
self.unload_shape_slat_flow_model_512()
|
|
self.load_shape_slat_flow_model_1024()
|
|
shape_slat, res = self.sample_mesh_slat_multiview(
|
|
mesh_slat, conds, views_list,
|
|
self.models['shape_slat_flow_model_1024'],
|
|
1024, shape_slat_sampler_params,
|
|
max_num_tokens, downsampling,
|
|
front_axis, blend_temperature,
|
|
)
|
|
|
|
if not self.keep_models_loaded:
|
|
self.unload_shape_slat_flow_model_1024()
|
|
|
|
if generate_texture_slat:
|
|
self.unload_tex_slat_flow_model_512()
|
|
self.load_tex_slat_flow_model_1024()
|
|
tex_slat = self.sample_tex_slat_multiview(
|
|
conds, views_list,
|
|
shape_slat=shape_slat,
|
|
flow_model=self.models['tex_slat_flow_model_1024'],
|
|
sampler_params=tex_slat_sampler_params,
|
|
front_axis=front_axis,
|
|
blend_temperature=blend_temperature,
|
|
)
|
|
|
|
if not self.keep_models_loaded:
|
|
self.unload_tex_slat_flow_model_1024()
|
|
elif resolution == 1536:
|
|
self.unload_shape_slat_flow_model_512()
|
|
self.load_shape_slat_flow_model_1024()
|
|
shape_slat, res = self.sample_mesh_slat_multiview(
|
|
mesh_slat, conds, views_list,
|
|
self.models['shape_slat_flow_model_1024'],
|
|
1536, shape_slat_sampler_params,
|
|
max_num_tokens, downsampling,
|
|
front_axis, blend_temperature,
|
|
)
|
|
|
|
if not self.keep_models_loaded:
|
|
self.unload_shape_slat_flow_model_1024()
|
|
|
|
if generate_texture_slat:
|
|
self.unload_tex_slat_flow_model_512()
|
|
self.load_tex_slat_flow_model_1024()
|
|
tex_slat = self.sample_tex_slat_multiview(
|
|
conds, views_list,
|
|
shape_slat=shape_slat,
|
|
flow_model=self.models['tex_slat_flow_model_1024'],
|
|
sampler_params=tex_slat_sampler_params,
|
|
front_axis=front_axis,
|
|
blend_temperature=blend_temperature,
|
|
)
|
|
|
|
if not self.keep_models_loaded:
|
|
self.unload_tex_slat_flow_model_1024()
|
|
|
|
torch.cuda.synchronize()
|
|
torch.cuda.empty_cache()
|
|
if generate_texture_slat:
|
|
out_mesh = self.decode_latent(shape_slat, tex_slat, res, use_tiled=use_tiled)
|
|
else:
|
|
out_mesh = self.decode_latent(shape_slat, None, res, use_tiled=use_tiled)
|
|
torch.cuda.synchronize()
|
|
torch.cuda.empty_cache()
|
|
|
|
if return_latent:
|
|
if generate_texture_slat:
|
|
return out_mesh, (shape_slat, tex_slat, res)
|
|
else:
|
|
return out_mesh, (shape_slat, None, res)
|
|
else:
|
|
return out_mesh
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Deferred pipeline patching. Runs once on first process() call.
|
|
# ---------------------------------------------------------------------------
|
|
_REQUIRED_PIPELINE_ATTRS = [
|
|
'load_shape_slat_decoder', 'unload_shape_slat_decoder',
|
|
'load_shape_slat_flow_model_512', 'unload_shape_slat_flow_model_512',
|
|
'load_shape_slat_flow_model_1024', 'unload_shape_slat_flow_model_1024',
|
|
'load_tex_slat_flow_model_512', 'unload_tex_slat_flow_model_512',
|
|
'load_tex_slat_flow_model_1024', 'unload_tex_slat_flow_model_1024',
|
|
'load_image_cond_model', 'unload_image_cond_model',
|
|
'get_cond', 'encode_shape_slat', 'preprocess_mesh', 'switch_samplers',
|
|
'sample_tex_slat_multiview', 'decode_latent',
|
|
'_cond_to', '_cond_cpu', '_cleanup_cuda',
|
|
]
|
|
|
|
_REQUIRED_MODULE_ATTRS = ['SparseTensor', 'samplers', 'seed_all']
|
|
|
|
|
|
_patched_classes = set()
|
|
|
|
|
|
def _ensure_patched(pipeline_cls):
|
|
if pipeline_cls in _patched_classes:
|
|
return
|
|
|
|
missing_cls = [a for a in _REQUIRED_PIPELINE_ATTRS if not hasattr(pipeline_cls, a)]
|
|
if missing_cls:
|
|
raise RuntimeError(
|
|
"ComfyUI-Trellis2-MultiViewRefiner is out of date: "
|
|
f"{pipeline_cls.__name__} is missing expected helpers {missing_cls}. "
|
|
"Upstream ComfyUI-Trellis2 has changed. "
|
|
"Check for an updated version at "
|
|
"https://github.com/cuzelac/ComfyUI-Trellis2-MultiViewRefiner"
|
|
)
|
|
|
|
pipeline_mod = sys.modules[pipeline_cls.__module__]
|
|
missing_mod = [a for a in _REQUIRED_MODULE_ATTRS if not hasattr(pipeline_mod, a)]
|
|
if missing_mod:
|
|
raise RuntimeError(
|
|
"ComfyUI-Trellis2-MultiViewRefiner is out of date: "
|
|
f"pipeline module {pipeline_cls.__module__} is missing {missing_mod}. "
|
|
"Upstream ComfyUI-Trellis2 has changed. "
|
|
"Check for an updated version at "
|
|
"https://github.com/cuzelac/ComfyUI-Trellis2-MultiViewRefiner"
|
|
)
|
|
|
|
samplers = pipeline_mod.samplers
|
|
if not hasattr(samplers, 'FlowEulerMultiViewGuidanceIntervalSampler'):
|
|
raise RuntimeError(
|
|
"ComfyUI-Trellis2-MultiViewRefiner is out of date: "
|
|
"'FlowEulerMultiViewGuidanceIntervalSampler' not found in "
|
|
"ComfyUI-Trellis2's samplers module."
|
|
)
|
|
|
|
if not hasattr(pipeline_cls, 'sample_mesh_slat_multiview'):
|
|
pipeline_cls.sample_mesh_slat_multiview = _sample_mesh_slat_multiview
|
|
if not hasattr(pipeline_cls, 'refine_mesh_multiview'):
|
|
pipeline_cls.refine_mesh_multiview = _refine_mesh_multiview
|
|
|
|
_patched_classes.add(pipeline_cls)
|
|
logger.info(
|
|
"ComfyUI-Trellis2-MultiViewRefiner: patched %s with "
|
|
"sample_mesh_slat_multiview / refine_mesh_multiview",
|
|
pipeline_cls.__name__,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Node
|
|
# ---------------------------------------------------------------------------
|
|
class Trellis2MeshRefinerMultiView:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"pipeline": ("TRELLIS2PIPELINE",),
|
|
"trimesh": ("TRIMESH",),
|
|
"front_image": ("IMAGE",),
|
|
"seed": ("INT", {"default": 12345, "min": 0, "max": 0x7fffffff}),
|
|
"resolution": ([512, 1024, 1536], {"default": 1024}),
|
|
"shape_steps": ("INT", {"default": 12, "min": 1, "max": 100}),
|
|
"shape_guidance_strength": ("FLOAT", {"default": 6.50, "min": 0.00, "max": 99.99, "step": 0.01}),
|
|
"shape_guidance_rescale": ("FLOAT", {"default": 0.05, "min": 0.00, "max": 1.00, "step": 0.01}),
|
|
"shape_rescale_t": ("FLOAT", {"default": 4.00, "min": 0.00, "max": 9.99, "step": 0.01}),
|
|
"texture_steps": ("INT", {"default": 12, "min": 1, "max": 100}),
|
|
"texture_guidance_strength": ("FLOAT", {"default": 3.00, "min": 0.00, "max": 99.99, "step": 0.01}),
|
|
"texture_guidance_rescale": ("FLOAT", {"default": 0.20, "min": 0.00, "max": 1.00, "step": 0.01}),
|
|
"texture_rescale_t": ("FLOAT", {"default": 3.00, "min": 0.00, "max": 9.99, "step": 0.01}),
|
|
"max_num_tokens": ("INT", {"default": 999999, "min": 0, "max": 999999}),
|
|
"generate_texture_slat": ("BOOLEAN", {"default": True}),
|
|
"downsampling": ([16, 32, 64], {"default": 16}),
|
|
"shape_guidance_interval_start": ("FLOAT", {"default": 0.10, "min": 0.00, "max": 1.00, "step": 0.01}),
|
|
"shape_guidance_interval_end": ("FLOAT", {"default": 1.00, "min": 0.00, "max": 1.00, "step": 0.01}),
|
|
"texture_guidance_interval_start": ("FLOAT", {"default": 0.00, "min": 0.00, "max": 1.00, "step": 0.01}),
|
|
"texture_guidance_interval_end": ("FLOAT", {"default": 0.90, "min": 0.00, "max": 1.00, "step": 0.01}),
|
|
"use_tiled_decoder": ("BOOLEAN", {"default": True}),
|
|
"front_axis": (["z", "x"], {"default": "z"}),
|
|
"blend_temperature": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1}),
|
|
"sampler": (["euler", "heun", "rk4", "rk5"], {"default": "euler"}),
|
|
},
|
|
"optional": {
|
|
"back_image": ("IMAGE",),
|
|
"left_image": ("IMAGE",),
|
|
"right_image": ("IMAGE",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("MESHWITHVOXEL", "BVH",)
|
|
RETURN_NAMES = ("mesh", "bvh",)
|
|
FUNCTION = "process"
|
|
CATEGORY = "Trellis2Wrapper"
|
|
OUTPUT_NODE = True
|
|
|
|
def process(
|
|
self,
|
|
pipeline, trimesh, front_image, seed, resolution,
|
|
shape_steps, shape_guidance_strength, shape_guidance_rescale, shape_rescale_t,
|
|
texture_steps, texture_guidance_strength, texture_guidance_rescale, texture_rescale_t,
|
|
max_num_tokens, generate_texture_slat, downsampling,
|
|
shape_guidance_interval_start, shape_guidance_interval_end,
|
|
texture_guidance_interval_start, texture_guidance_interval_end,
|
|
use_tiled_decoder, front_axis, blend_temperature, sampler,
|
|
back_image=None, left_image=None, right_image=None,
|
|
):
|
|
_ensure_patched(type(pipeline))
|
|
|
|
reset_cuda()
|
|
|
|
front_pil = tensor2pil(front_image)
|
|
back_pil = tensor2pil(back_image) if back_image is not None else None
|
|
left_pil = tensor2pil(left_image) if left_image is not None else None
|
|
right_pil = tensor2pil(right_image) if right_image is not None else None
|
|
|
|
shape_slat_sampler_params = {
|
|
"steps": shape_steps,
|
|
"guidance_strength": shape_guidance_strength,
|
|
"guidance_rescale": shape_guidance_rescale,
|
|
"guidance_interval": [shape_guidance_interval_start, shape_guidance_interval_end],
|
|
"rescale_t": shape_rescale_t,
|
|
}
|
|
tex_slat_sampler_params = {
|
|
"steps": texture_steps,
|
|
"guidance_strength": texture_guidance_strength,
|
|
"guidance_rescale": texture_guidance_rescale,
|
|
"guidance_interval": [texture_guidance_interval_start, texture_guidance_interval_end],
|
|
"rescale_t": texture_rescale_t,
|
|
}
|
|
|
|
mesh = pipeline.refine_mesh_multiview(
|
|
mesh=trimesh,
|
|
front=front_pil,
|
|
back=back_pil,
|
|
left=left_pil,
|
|
right=right_pil,
|
|
seed=seed,
|
|
shape_slat_sampler_params=shape_slat_sampler_params,
|
|
tex_slat_sampler_params=tex_slat_sampler_params,
|
|
resolution=resolution,
|
|
max_num_tokens=max_num_tokens,
|
|
generate_texture_slat=generate_texture_slat,
|
|
downsampling=downsampling,
|
|
use_tiled=use_tiled_decoder,
|
|
front_axis=front_axis,
|
|
blend_temperature=blend_temperature,
|
|
sampler=sampler,
|
|
)[0]
|
|
|
|
vertices = mesh.vertices.cuda()
|
|
faces = mesh.faces.cuda()
|
|
|
|
if generate_texture_slat:
|
|
import cumesh as CuMesh
|
|
logger.info("Building BVH for current mesh...")
|
|
bvh = CuMesh.cuBVH(vertices.detach().clone(), faces.detach().clone())
|
|
bvh.vertices = vertices.detach().clone()
|
|
bvh.faces = faces.detach().clone()
|
|
else:
|
|
logger.info("Not building BVH, only used for texturing")
|
|
bvh = None
|
|
|
|
return (mesh, bvh,)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ComfyUI registration
|
|
# ---------------------------------------------------------------------------
|
|
NODE_CLASS_MAPPINGS = {
|
|
"Trellis2MeshRefinerMultiView": Trellis2MeshRefinerMultiView,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"Trellis2MeshRefinerMultiView": "Trellis2 - Mesh Refiner Multi-View",
|
|
}
|