963 lines
37 KiB
Python
963 lines
37 KiB
Python
import os
|
|
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = '1'
|
|
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
|
|
os.environ["ATTN_BACKEND"] = "flash_attn_3"
|
|
|
|
import json
|
|
import math
|
|
import torch
|
|
import trimesh
|
|
import o_voxel
|
|
import numpy as np
|
|
import torch.nn as nn
|
|
import trellis2.modules.sparse as sp
|
|
|
|
from PIL import Image
|
|
from tqdm import tqdm
|
|
from trellis2 import models
|
|
from collections import OrderedDict
|
|
from transformers import Mistral3ForConditionalGeneration
|
|
from trellis2.pipelines.rembg import BiRefNet
|
|
from trellis2.representations import MeshWithVoxel
|
|
from trellis2.modules.image_feature_extractor import DinoV3FeatureExtractor
|
|
|
|
try:
|
|
import cv2
|
|
import nvdiffrast.torch as nr
|
|
from flex_gemm.ops.grid_sample import grid_sample_3d
|
|
BAKE_ENABLED = True
|
|
except ImportError as e:
|
|
print(f"[SegviGen] Warning: Texture baking dependencies NOT found ({e}). Skipping bake mode.")
|
|
BAKE_ENABLED = False
|
|
|
|
try:
|
|
import fast_simplification
|
|
FAST_SIMPLIFY_AVAILABLE = True
|
|
except ImportError:
|
|
print("[SegviGen] Warning: fast-simplification NOT found. Falling back to trimesh simplification.")
|
|
FAST_SIMPLIFY_AVAILABLE = False
|
|
|
|
|
|
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
TRELLIS_PIPELINE_JSON = os.path.join(ROOT_DIR, "data_toolkit/texturing_pipeline.json")
|
|
TRELLIS_TEX_FLOW = "microsoft/TRELLIS.2-4B/ckpts/slat_flow_imgshape2tex_dit_1_3B_512_bf16"
|
|
TRELLIS_SHAPE_ENC = "microsoft/TRELLIS.2-4B/ckpts/shape_enc_next_dc_f16c32_fp16"
|
|
TRELLIS_TEX_ENC = "microsoft/TRELLIS.2-4B/ckpts/tex_enc_next_dc_f16c32_fp16"
|
|
TRELLIS_SHAPE_DEC = "microsoft/TRELLIS.2-4B/ckpts/shape_dec_next_dc_f16c32_fp16"
|
|
TRELLIS_TEX_DEC = "microsoft/TRELLIS.2-4B/ckpts/tex_dec_next_dc_f16c32_fp16"
|
|
DINO_PATH = "Aero-Ex/Dinov3"
|
|
|
|
try:
|
|
import folder_paths
|
|
COMFY_MODELS_DIR = folder_paths.models_dir
|
|
except ImportError:
|
|
COMFY_MODELS_DIR = "/home/aero/comfy/ComfyUI/models"
|
|
|
|
|
|
EARLY_SIMPLIFY_ENABLED = True
|
|
EARLY_SIMPLIFY_TARGET_FACES = 120000
|
|
EARLY_SIMPLIFY_AGGRESSION = 2
|
|
|
|
MAX_PREPROCESS_TEX_SIZE = 1024
|
|
EXPORT_TEXTURE_SIZE = 2048
|
|
|
|
|
|
def _scene_to_single_mesh(asset):
|
|
if isinstance(asset, trimesh.Scene):
|
|
mesh = asset.to_mesh()
|
|
elif isinstance(asset, trimesh.Trimesh):
|
|
mesh = asset
|
|
else:
|
|
raise TypeError(f"Unsupported asset type: {type(asset)}")
|
|
if mesh is None or (hasattr(mesh, 'faces') and len(mesh.faces) == 0):
|
|
# Fallback for empty geometries
|
|
return mesh
|
|
return mesh
|
|
|
|
|
|
def make_texture_square_pow2(img: Image.Image, target_size=None, max_size=1024):
|
|
w, h = img.size
|
|
max_side = max(w, h)
|
|
pow2 = 1
|
|
while pow2 < max_side:
|
|
pow2 *= 2
|
|
if target_size is not None:
|
|
pow2 = target_size
|
|
pow2 = min(pow2, max_size)
|
|
return img.resize((pow2, pow2), Image.BILINEAR)
|
|
|
|
|
|
def preprocess_scene_textures(asset, max_texture_size=1024):
|
|
if not isinstance(asset, trimesh.Scene):
|
|
return asset
|
|
tex_keys = ["baseColorTexture", "normalTexture", "metallicRoughnessTexture", "emissiveTexture", "occlusionTexture"]
|
|
for geom in asset.geometry.values():
|
|
visual = getattr(geom, "visual", None)
|
|
mat = getattr(visual, "material", None)
|
|
if mat is None:
|
|
continue
|
|
for key in tex_keys:
|
|
if not hasattr(mat, key):
|
|
continue
|
|
tex = getattr(mat, key)
|
|
if tex is None:
|
|
continue
|
|
if isinstance(tex, Image.Image):
|
|
setattr(mat, key, make_texture_square_pow2(tex, max_size=max_texture_size))
|
|
elif hasattr(tex, "image") and tex.image is not None:
|
|
img = tex.image
|
|
if not isinstance(img, Image.Image):
|
|
img = Image.fromarray(img)
|
|
tex.image = make_texture_square_pow2(img, max_size=max_texture_size)
|
|
if hasattr(mat, "image") and mat.image is not None:
|
|
img = mat.image
|
|
if not isinstance(img, Image.Image):
|
|
img = Image.fromarray(img)
|
|
mat.image = make_texture_square_pow2(img, max_size=max_texture_size)
|
|
return asset
|
|
|
|
|
|
def _apply_neutral_visual(mesh: trimesh.Trimesh) -> trimesh.Trimesh:
|
|
if mesh.visual is not None:
|
|
return mesh
|
|
face_colors = np.tile(
|
|
np.array([[200, 200, 200, 255]], dtype=np.uint8),
|
|
(len(mesh.faces), 1),
|
|
)
|
|
mesh.visual = trimesh.visual.color.ColorVisuals(mesh=mesh, face_colors=face_colors)
|
|
return mesh
|
|
|
|
|
|
def get_work_glb_path(item):
|
|
base, _ = os.path.splitext(item["input_vxz"])
|
|
return f"{base}_work.glb"
|
|
|
|
|
|
def build_simplified_work_glb(
|
|
input_glb_path,
|
|
output_glb_path,
|
|
target_faces=EARLY_SIMPLIFY_TARGET_FACES,
|
|
aggression=EARLY_SIMPLIFY_AGGRESSION,
|
|
):
|
|
"""
|
|
Build a simplified geometry-only GLB for early rendering and shape processing.
|
|
This keeps the original GLB untouched for texture/attribute extraction.
|
|
"""
|
|
os.makedirs(os.path.dirname(output_glb_path), exist_ok=True)
|
|
|
|
asset = trimesh.load(input_glb_path, force="scene", process=False)
|
|
mesh = _scene_to_single_mesh(asset)
|
|
src_faces = int(len(mesh.faces))
|
|
|
|
if src_faces <= target_faces:
|
|
mesh = _apply_neutral_visual(mesh.copy())
|
|
mesh.export(output_glb_path)
|
|
print(f"[Simplify] Skip: faces={src_faces} <= target={target_faces}")
|
|
return output_glb_path, src_faces, src_faces
|
|
|
|
try:
|
|
if FAST_SIMPLIFY_AVAILABLE:
|
|
# fast_simplification uses reduction ratio (0.0 to 1.0)
|
|
# trimesh uses target face count.
|
|
# Convert target_faces to a ratio if possible, or just use a fixed 0.7 for now
|
|
# as it's common for 'pre-simplification' to be aggressive.
|
|
reduction = max(0.1, 1.0 - (target_faces / src_faces))
|
|
print(f"[Simplify] Using fast-simplification (reduction={reduction:.2f})")
|
|
new_v, new_f = fast_simplification.simplify(mesh.vertices, mesh.faces, target_reduction=reduction)
|
|
simplified = trimesh.Trimesh(vertices=new_v, faces=new_f, process=False)
|
|
else:
|
|
print(f"[Simplify] Using trimesh quadric decimation (target={target_faces})")
|
|
simplified = mesh.simplify_quadric_decimation(
|
|
face_count=target_faces,
|
|
aggression=aggression,
|
|
)
|
|
|
|
if simplified is None or len(simplified.faces) == 0:
|
|
raise RuntimeError("Simplification returned empty mesh")
|
|
|
|
simplified = _apply_neutral_visual(simplified)
|
|
simplified.export(output_glb_path)
|
|
dst_faces = int(len(simplified.faces))
|
|
print(f"[Simplify] faces: {src_faces} -> {dst_faces}")
|
|
return output_glb_path, src_faces, dst_faces
|
|
except Exception as e:
|
|
print(f"[Simplify] Failed, fallback to original mesh: {e}")
|
|
mesh = _apply_neutral_visual(mesh.copy())
|
|
mesh.export(output_glb_path)
|
|
return output_glb_path, src_faces, src_faces
|
|
|
|
|
|
def _colorvisuals_to_texturevisuals(mesh: trimesh.Trimesh) -> trimesh.Trimesh:
|
|
"""
|
|
Convert ColorVisuals to TextureVisuals by baking per-face colors into a tiny atlas
|
|
and generating per-face UVs. Ensure the resulting material is PBRMaterial to satisfy
|
|
downstream GLTF/PBR-only pipelines.
|
|
"""
|
|
if mesh.visual is None:
|
|
return mesh
|
|
|
|
if isinstance(mesh.visual, trimesh.visual.texture.TextureVisuals):
|
|
mat = getattr(mesh.visual, "material", None)
|
|
if isinstance(mat, trimesh.visual.material.SimpleMaterial):
|
|
mesh = mesh.copy()
|
|
try:
|
|
mesh.visual.material = mat.to_pbr()
|
|
except Exception:
|
|
mesh.visual.material = trimesh.visual.material.PBRMaterial(
|
|
baseColorTexture=mat.image
|
|
)
|
|
return mesh
|
|
|
|
if not isinstance(mesh.visual, trimesh.visual.color.ColorVisuals):
|
|
return mesh
|
|
|
|
F = int(len(mesh.faces))
|
|
if F <= 0:
|
|
return mesh
|
|
|
|
face_rgba = None
|
|
|
|
if hasattr(mesh.visual, "face_colors") and mesh.visual.face_colors is not None:
|
|
fc = np.asarray(mesh.visual.face_colors)
|
|
if fc.ndim == 2 and fc.shape[0] == F:
|
|
face_rgba = fc[:, :4].astype(np.uint8)
|
|
|
|
if face_rgba is None and hasattr(mesh.visual, "vertex_colors") and mesh.visual.vertex_colors is not None:
|
|
vc = np.asarray(mesh.visual.vertex_colors)
|
|
if vc.ndim == 2 and vc.shape[0] == len(mesh.vertices):
|
|
tri = mesh.faces
|
|
vcol = vc[tri]
|
|
face_rgba = np.rint(vcol.mean(axis=1)).astype(np.uint8)
|
|
|
|
if face_rgba is None:
|
|
face_rgba = np.tile(np.array([[255, 255, 255, 255]], dtype=np.uint8), (F, 1))
|
|
|
|
grid = int(math.ceil(math.sqrt(F)))
|
|
img = np.zeros((grid, grid, 4), dtype=np.uint8)
|
|
|
|
for i in range(F):
|
|
x = i % grid
|
|
y = i // grid
|
|
if y >= grid:
|
|
break
|
|
img[y, x, :] = face_rgba[i]
|
|
|
|
pil_img = Image.fromarray(img, mode="RGBA")
|
|
|
|
v_new = mesh.vertices[mesh.faces].reshape(-1, 3)
|
|
f_new = np.arange(F * 3, dtype=np.int64).reshape(F, 3)
|
|
|
|
uv_new = np.zeros((F * 3, 2), dtype=np.float32)
|
|
for i in range(F):
|
|
x = i % grid
|
|
y = i // grid
|
|
u = (x + 0.5) / float(grid)
|
|
v = (y + 0.5) / float(grid)
|
|
uv_new[i * 3 : i * 3 + 3, 0] = u
|
|
uv_new[i * 3 : i * 3 + 3, 1] = v
|
|
|
|
pbr = trimesh.visual.material.PBRMaterial(
|
|
baseColorTexture=pil_img,
|
|
metallicFactor=0.0,
|
|
roughnessFactor=1.0,
|
|
doubleSided=True,
|
|
alphaMode="BLEND",
|
|
)
|
|
visual = trimesh.visual.texture.TextureVisuals(uv=uv_new, material=pbr)
|
|
|
|
out = trimesh.Trimesh(vertices=v_new, faces=f_new, visual=visual, process=False)
|
|
return out
|
|
|
|
|
|
def ensure_texture_visuals(asset):
|
|
"""
|
|
Ensure all geometries in a Scene (or a single Trimesh) use TextureVisuals.
|
|
For ColorVisuals, we bake them into a synthetic atlas.
|
|
"""
|
|
if isinstance(asset, trimesh.Scene):
|
|
for geom_name, g in list(asset.geometry.items()):
|
|
if isinstance(g, trimesh.Trimesh):
|
|
asset.geometry[geom_name] = _colorvisuals_to_texturevisuals(g)
|
|
return asset
|
|
|
|
if isinstance(asset, trimesh.Trimesh):
|
|
return _colorvisuals_to_texturevisuals(asset)
|
|
|
|
return asset
|
|
|
|
|
|
class Sampler:
|
|
def _inference_model(self, model, x_t, tex_slat, shape_slat, coords_len_list, t, cond):
|
|
t = torch.tensor([t * 1000] * x_t.shape[0], dtype=torch.float32).cuda()
|
|
return model(x_t, tex_slat, shape_slat, t, cond, coords_len_list)
|
|
|
|
def guidance_inference_model(self, model, x_t, tex_slat, shape_slat, coords_len_list, t, cond_dict, guidance_strength, guidance_rescale=0.0):
|
|
if guidance_strength == 1:
|
|
return self._inference_model(model, x_t, tex_slat, shape_slat, coords_len_list, t, cond_dict['cond'])
|
|
elif guidance_strength == 0:
|
|
return self._inference_model(model, x_t, tex_slat, shape_slat, coords_len_list, t, cond_dict['neg_cond'])
|
|
else:
|
|
pred_pos = self._inference_model(model, x_t, tex_slat, shape_slat, coords_len_list, t, cond_dict['cond'])
|
|
pred_neg = self._inference_model(model, x_t, tex_slat, shape_slat, coords_len_list, t, cond_dict['neg_cond'])
|
|
pred = guidance_strength * pred_pos + (1 - guidance_strength) * pred_neg
|
|
if guidance_rescale > 0:
|
|
x_0_pos = self._pred_to_xstart(x_t, t, pred_pos)
|
|
x_0_cfg = self._pred_to_xstart(x_t, t, pred)
|
|
std_pos = x_0_pos.std(dim=list(range(1, x_0_pos.ndim)), keepdim=True)
|
|
std_cfg = x_0_cfg.std(dim=list(range(1, x_0_cfg.ndim)), keepdim=True)
|
|
x_0_rescaled = x_0_cfg * (std_pos / std_cfg)
|
|
x_0 = guidance_rescale * x_0_rescaled + (1 - guidance_rescale) * x_0_cfg
|
|
pred = self._xstart_to_pred(x_t, t, x_0)
|
|
return pred
|
|
|
|
def interval_inference_model(self, model, x_t, tex_slat, shape_slat, coords_len_list, t, cond_dict, sampler_params):
|
|
guidance_strength = sampler_params['guidance_strength']
|
|
guidance_interval = sampler_params['guidance_interval']
|
|
guidance_rescale = sampler_params['guidance_rescale']
|
|
if guidance_interval[0] <= t <= guidance_interval[1]:
|
|
return self.guidance_inference_model(model, x_t, tex_slat, shape_slat, coords_len_list, t, cond_dict, guidance_strength, guidance_rescale)
|
|
else:
|
|
return self.guidance_inference_model(model, x_t, tex_slat, shape_slat, coords_len_list, t, cond_dict, 1, guidance_rescale)
|
|
|
|
@torch.no_grad()
|
|
def sample_once(self, model, x_t, tex_slat, shape_slat, coords_len_list, t, t_prev, cond_dict, sampler_params):
|
|
pred_v = self.interval_inference_model(model, x_t, tex_slat, shape_slat, coords_len_list, t, cond_dict, sampler_params)
|
|
pred_x_prev = x_t - (t - t_prev) * pred_v
|
|
return pred_x_prev
|
|
|
|
@torch.no_grad()
|
|
def sample(self, model, noise, tex_slat, shape_slat, coords_len_list, cond_dict, sampler_params):
|
|
sample = noise
|
|
steps = sampler_params['steps']
|
|
rescale_t = sampler_params['rescale_t']
|
|
t_seq = np.linspace(1, 0, steps + 1)
|
|
t_seq = rescale_t * t_seq / (1 + (rescale_t - 1) * t_seq)
|
|
t_seq = t_seq.tolist()
|
|
t_pairs = list((t_seq[i], t_seq[i + 1]) for i in range(steps))
|
|
for t, t_prev in tqdm(t_pairs, desc="Sampling"):
|
|
sample = self.sample_once(model, sample, tex_slat, shape_slat, coords_len_list, t, t_prev, cond_dict, sampler_params)
|
|
return sample
|
|
|
|
|
|
class Gen3DSeg(nn.Module):
|
|
def __init__(self, flow_model):
|
|
super().__init__()
|
|
self.flow_model = flow_model
|
|
|
|
def forward(self, x_t, tex_slats, shape_slats, t, cond, coords_len_list):
|
|
input_tex_feats_list = []
|
|
input_tex_coords_list = []
|
|
shape_feats_list = []
|
|
shape_coords_list = []
|
|
begin = 0
|
|
for coords_len in coords_len_list:
|
|
end = begin + coords_len
|
|
input_tex_feats_list.append(x_t.feats[begin:end])
|
|
input_tex_feats_list.append(tex_slats.feats[begin:end])
|
|
input_tex_coords_list.append(x_t.coords[begin:end])
|
|
input_tex_coords_list.append(tex_slats.coords[begin:end])
|
|
shape_feats_list.append(shape_slats.feats[begin:end])
|
|
shape_feats_list.append(shape_slats.feats[begin:end])
|
|
shape_coords_list.append(shape_slats.coords[begin:end])
|
|
shape_coords_list.append(shape_slats.coords[begin:end])
|
|
begin = end
|
|
x_t = sp.SparseTensor(torch.cat(input_tex_feats_list), torch.cat(input_tex_coords_list))
|
|
shape_slats = sp.SparseTensor(torch.cat(shape_feats_list), torch.cat(shape_coords_list))
|
|
|
|
output_tex_slats = self.flow_model(x_t, t, cond, shape_slats)
|
|
|
|
output_tex_feats_list = []
|
|
output_tex_coords_list = []
|
|
begin = 0
|
|
for coords_len in coords_len_list:
|
|
end = begin + coords_len
|
|
output_tex_feats_list.append(output_tex_slats.feats[begin:end])
|
|
output_tex_coords_list.append(output_tex_slats.coords[begin:end])
|
|
begin = begin + 2 * coords_len
|
|
output_tex_slat = sp.SparseTensor(torch.cat(output_tex_feats_list), torch.cat(output_tex_coords_list))
|
|
return output_tex_slat
|
|
|
|
|
|
def make_texture_square_pow2(img: Image.Image, target_size=None, max_size=MAX_PREPROCESS_TEX_SIZE):
|
|
w, h = img.size
|
|
max_side = max(w, h)
|
|
pow2 = 1
|
|
while pow2 < max_side:
|
|
pow2 *= 2
|
|
if target_size is not None:
|
|
pow2 = target_size
|
|
pow2 = min(pow2, max_size)
|
|
return img.resize((pow2, pow2), Image.BILINEAR)
|
|
|
|
|
|
def preprocess_scene_textures(asset, max_texture_size=MAX_PREPROCESS_TEX_SIZE):
|
|
if not isinstance(asset, trimesh.Scene):
|
|
return asset
|
|
tex_keys = ["baseColorTexture", "normalTexture", "metallicRoughnessTexture", "emissiveTexture", "occlusionTexture"]
|
|
for geom in asset.geometry.values():
|
|
visual = getattr(geom, "visual", None)
|
|
mat = getattr(visual, "material", None)
|
|
if mat is None:
|
|
continue
|
|
for key in tex_keys:
|
|
if not hasattr(mat, key):
|
|
continue
|
|
tex = getattr(mat, key)
|
|
if tex is None:
|
|
continue
|
|
if isinstance(tex, Image.Image):
|
|
setattr(mat, key, make_texture_square_pow2(tex, max_size=max_texture_size))
|
|
elif hasattr(tex, "image") and tex.image is not None:
|
|
img = tex.image
|
|
if not isinstance(img, Image.Image):
|
|
img = Image.fromarray(img)
|
|
tex.image = make_texture_square_pow2(img, max_size=max_texture_size)
|
|
if hasattr(mat, "image") and mat.image is not None:
|
|
img = mat.image
|
|
if not isinstance(img, Image.Image):
|
|
img = Image.fromarray(img)
|
|
mat.image = make_texture_square_pow2(img, max_size=max_texture_size)
|
|
return asset
|
|
|
|
|
|
def process_glb_to_vxz(glb_path, vxz_path, shape_glb_path=None):
|
|
"""
|
|
Use the original GLB for texture/material attributes,
|
|
and an optional simplified GLB for the geometry-heavy shape branch.
|
|
"""
|
|
tex_asset = trimesh.load(glb_path, force='scene')
|
|
tex_asset = ensure_texture_visuals(tex_asset)
|
|
tex_asset = preprocess_scene_textures(tex_asset, max_texture_size=MAX_PREPROCESS_TEX_SIZE)
|
|
|
|
if shape_glb_path is None:
|
|
shape_asset = trimesh.load(glb_path, force='scene')
|
|
else:
|
|
shape_asset = trimesh.load(shape_glb_path, force='scene')
|
|
|
|
aabb = tex_asset.bounding_box.bounds
|
|
center = (aabb[0] + aabb[1]) / 2
|
|
max_side = (aabb[1] - aabb[0]).max()
|
|
scale = 0.99999 / max_side
|
|
print(f"[SegviGen] Vxz Calibration: AABB_MIN={aabb[0].tolist()}, AABB_MAX={aabb[1].tolist()}, Center={center.tolist()}, Scale={scale:.12f}, MaxSide={max_side:.12f}")
|
|
|
|
tex_asset.apply_translation(-center)
|
|
tex_asset.apply_scale(scale)
|
|
|
|
shape_asset.apply_translation(-center)
|
|
shape_asset.apply_scale(scale)
|
|
|
|
shape_mesh = _scene_to_single_mesh(shape_asset)
|
|
vertices = torch.from_numpy(shape_mesh.vertices).float()
|
|
faces = torch.from_numpy(shape_mesh.faces).long()
|
|
|
|
voxel_indices, dual_vertices, intersected = o_voxel.convert.mesh_to_flexible_dual_grid(
|
|
vertices,
|
|
faces,
|
|
grid_size=512,
|
|
aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
|
|
face_weight=1.0,
|
|
boundary_weight=0.2,
|
|
regularization_weight=1e-2,
|
|
timing=False,
|
|
)
|
|
vid = o_voxel.serialize.encode_seq(voxel_indices)
|
|
mapping = torch.argsort(vid)
|
|
voxel_indices = voxel_indices[mapping]
|
|
dual_vertices = dual_vertices[mapping]
|
|
intersected = intersected[mapping]
|
|
|
|
# Material Voxelization (using full scene asset for correct attribute mapping)
|
|
voxel_indices_mat, attributes = o_voxel.convert.textured_mesh_to_volumetric_attr(
|
|
tex_asset, grid_size=512, aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]], timing=False
|
|
)
|
|
vid_mat = o_voxel.serialize.encode_seq(voxel_indices_mat)
|
|
mapping_mat = torch.argsort(vid_mat)
|
|
attributes = {k: v[mapping_mat] for k, v in attributes.items()}
|
|
|
|
# Quantize dual_vertices and intersection flags
|
|
dual_vertices = dual_vertices * 512 - voxel_indices
|
|
dual_vertices = (torch.clamp(dual_vertices, 0, 1) * 255).type(torch.uint8)
|
|
intersected = (intersected[:, 0:1] + 2 * intersected[:, 1:2] + 4 * intersected[:, 2:3]).type(torch.uint8)
|
|
|
|
attributes.update({'dual_vertices': dual_vertices})
|
|
attributes.update({'intersected': intersected})
|
|
|
|
os.makedirs(os.path.dirname(vxz_path), exist_ok=True)
|
|
o_voxel.io.write(vxz_path, voxel_indices, attributes)
|
|
print(f"[SegviGen] Vxz: done")
|
|
|
|
|
|
def vxz_to_latent_slat(shape_encoder, shape_decoder, tex_encoder, vxz_path):
|
|
device = next(shape_encoder.parameters()).device
|
|
coords, data = o_voxel.io.read(vxz_path)
|
|
coords = torch.cat([torch.zeros(coords.shape[0], 1, dtype=torch.int32), coords], dim=1).to(device)
|
|
vertices = (data['dual_vertices'].to(device) / 255)
|
|
intersected = torch.cat([data['intersected'] % 2, data['intersected'] // 2 % 2, data['intersected'] // 4 % 2], dim=-1).bool().to(device)
|
|
vertices_sparse = sp.SparseTensor(vertices, coords)
|
|
intersected_sparse = sp.SparseTensor(intersected.float(), coords)
|
|
with torch.no_grad():
|
|
shape_slat = shape_encoder(vertices_sparse, intersected_sparse)
|
|
shape_slat = sp.SparseTensor(shape_slat.feats.to(device), shape_slat.coords.to(device))
|
|
shape_decoder.set_resolution(512)
|
|
meshes, subs = shape_decoder(shape_slat, return_subs=True)
|
|
try:
|
|
for i, m in enumerate(meshes):
|
|
v_min = m.vertices.min(dim=0).values.tolist()
|
|
v_max = m.vertices.max(dim=0).values.tolist()
|
|
print(f"[Log - Decoder Output] Component {i} bounds: {v_min} to {v_max}")
|
|
except Exception:
|
|
pass
|
|
|
|
base_color = (data['base_color'] / 255)
|
|
metallic = (data['metallic'] / 255)
|
|
roughness = (data['roughness'] / 255)
|
|
alpha = (data['alpha'] / 255)
|
|
attr = torch.cat([base_color, metallic, roughness, alpha], dim=-1).float().to(device) * 2 - 1
|
|
with torch.no_grad():
|
|
tex_slat = tex_encoder(sp.SparseTensor(attr, coords))
|
|
return shape_slat, meshes, subs, tex_slat
|
|
|
|
|
|
def preprocess_image(rembg_model, input):
|
|
if input.mode != "RGB":
|
|
bg = Image.new("RGB", input.size, (255, 255, 255))
|
|
bg.paste(input, mask=input.split()[3])
|
|
input = bg
|
|
has_alpha = False
|
|
if input.mode == 'RGBA':
|
|
alpha = np.array(input)[:, :, 3]
|
|
if not np.all(alpha == 255):
|
|
has_alpha = True
|
|
max_size = max(input.size)
|
|
scale = min(1, 1024 / max_size)
|
|
if scale < 1:
|
|
input = input.resize((int(input.width * scale), int(input.height * scale)), Image.Resampling.LANCZOS)
|
|
if has_alpha:
|
|
output = input
|
|
else:
|
|
input = input.convert('RGB')
|
|
output = rembg_model(input)
|
|
output_np = np.array(output)
|
|
alpha = output_np[:, :, 3]
|
|
bbox = np.argwhere(alpha > 0.8 * 255)
|
|
bbox = np.min(bbox[:, 1]), np.min(bbox[:, 0]), np.max(bbox[:, 1]), np.max(bbox[:, 0])
|
|
center = (bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2
|
|
size = max(bbox[2] - bbox[0], bbox[3] - bbox[1])
|
|
size = int(size * 1)
|
|
bbox = center[0] - size // 2, center[1] - size // 2, center[0] + size // 2, center[1] + size // 2
|
|
output = output.crop(bbox)
|
|
output = np.array(output).astype(np.float32) / 255
|
|
output = output[:, :, :3] * output[:, :, 3:4]
|
|
output = Image.fromarray((output * 255).astype(np.uint8))
|
|
return output
|
|
|
|
|
|
def get_cond(image_cond_model, image):
|
|
image_cond_model.image_size = 512
|
|
cond = image_cond_model(image)
|
|
neg_cond = torch.zeros_like(cond)
|
|
return {'cond': cond, 'neg_cond': neg_cond}
|
|
|
|
|
|
def tex_slat_sample_single(gen3dseg, sampler, pipeline_args, shape_slat, input_tex_slat, cond_dict):
|
|
device = shape_slat.feats.device
|
|
shape_std = torch.tensor(pipeline_args['shape_slat_normalization']['std'])[None].to(device)
|
|
shape_mean = torch.tensor(pipeline_args['shape_slat_normalization']['mean'])[None].to(device)
|
|
tex_std = torch.tensor(pipeline_args['tex_slat_normalization']['std'])[None].to(device)
|
|
tex_mean = torch.tensor(pipeline_args['tex_slat_normalization']['mean'])[None].to(device)
|
|
shape_slat = ((shape_slat - shape_mean) / shape_std)
|
|
input_tex_slat = ((input_tex_slat - tex_mean) / tex_std)
|
|
coords_len_list = [shape_slat.coords.shape[0]]
|
|
noise = sp.SparseTensor(torch.randn_like(input_tex_slat.feats), shape_slat.coords)
|
|
output_tex_slat = sampler.sample(gen3dseg, noise, input_tex_slat, shape_slat, coords_len_list, cond_dict, pipeline_args['tex_slat_sampler']['params'])
|
|
output_tex_slat = output_tex_slat * tex_std + tex_mean
|
|
return output_tex_slat
|
|
|
|
|
|
def make_texture_square_pow2(img: Image.Image, target_size=None, max_size=1024):
|
|
w, h = img.size
|
|
max_side = max(w, h)
|
|
pow2 = 1
|
|
while pow2 < max_side:
|
|
pow2 *= 2
|
|
if target_size is not None:
|
|
pow2 = target_size
|
|
pow2 = min(pow2, max_size)
|
|
return img.resize((pow2, pow2), Image.BILINEAR)
|
|
|
|
|
|
def bake_to_mesh(glb_path, tex_voxels, output_path, resolution=512, texture_size=2048, generate_uv=False):
|
|
"""
|
|
Bake texture from voxels onto an existing GLB mesh using the same logic as to_glb.
|
|
Handles scene graph transforms for accurate alignment.
|
|
"""
|
|
if not BAKE_ENABLED:
|
|
raise RuntimeError("Texture baking is not enabled (missing dependencies).")
|
|
|
|
print(f"[SegviGen] Bake: baking onto {glb_path} -> {output_path}")
|
|
asset = trimesh.load(glb_path, force='scene')
|
|
device = torch.device("cuda")
|
|
|
|
# Prepare the attribute volume (sparse) as to_glb expects
|
|
if isinstance(tex_voxels, list):
|
|
attr_volume = torch.cat([vox.feats for vox in tex_voxels]).to(device)
|
|
attr_coords = torch.cat([vox.coords for vox in tex_voxels]).to(device)[:, -3:]
|
|
else:
|
|
attr_volume = tex_voxels.feats.to(device)
|
|
attr_coords = tex_voxels.coords.to(device)[:, -3:]
|
|
|
|
pbr_attr_layout = {
|
|
'base_color': slice(0, 3),
|
|
'metallic': slice(3, 4),
|
|
'roughness': slice(4, 5),
|
|
'alpha': slice(5, 6),
|
|
}
|
|
|
|
# Calculate normalization (must match process_glb_to_vxz)
|
|
full_aabb = asset.bounding_box.bounds
|
|
center = (full_aabb[0] + full_aabb[1]) / 2
|
|
max_side = (full_aabb[1] - full_aabb[0]).max()
|
|
scale = 0.99999 / max_side
|
|
|
|
# Iterate over nodes to handle scene graph transforms
|
|
for node_name in asset.graph.nodes_geometry:
|
|
transform, geom_name = asset.graph[node_name]
|
|
geom = asset.geometry[geom_name]
|
|
|
|
has_uv = hasattr(geom.visual, 'uv') and geom.visual.uv is not None
|
|
if not generate_uv and not has_uv:
|
|
print(f"[SegviGen] Bake Warning: Skipping {node_name} - No UVs found.")
|
|
continue
|
|
|
|
print(f"[SegviGen] Bake: sampling {node_name}...")
|
|
|
|
# 1. Transform local vertices to world space
|
|
world_vertices = trimesh.transformations.transform_points(geom.vertices, transform)
|
|
|
|
# 2. Normalize world vertices to fit AI unit cube [-0.5, 0.5]
|
|
norm_vertices = (world_vertices - center) * scale
|
|
|
|
# Use to_glb with sparse input
|
|
baked_mesh = o_voxel.postprocess.to_glb(
|
|
vertices=torch.from_numpy(norm_vertices).float().to(device).contiguous(),
|
|
faces=torch.from_numpy(geom.faces).int().to(device).contiguous(),
|
|
attr_volume=attr_volume,
|
|
coords=attr_coords,
|
|
attr_layout=pbr_attr_layout,
|
|
aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
|
|
voxel_size=1.0 / resolution,
|
|
texture_size=texture_size,
|
|
remesh=False,
|
|
verbose=False
|
|
)
|
|
|
|
if isinstance(baked_mesh, trimesh.Scene):
|
|
baked_mesh = list(baked_mesh.geometry.values())[0]
|
|
|
|
# 3. Reverse the axis swap/invert from to_glb (Y->Z, Z->-Y)
|
|
y_glb = baked_mesh.vertices[:, 1].copy()
|
|
z_glb = baked_mesh.vertices[:, 2].copy()
|
|
baked_mesh.vertices[:, 1] = -z_glb # restore original Y
|
|
baked_mesh.vertices[:, 2] = y_glb # restore original Z
|
|
|
|
# 4. Denormalize output vertices to match original GLB coordinates (Back to World Space)
|
|
world_baked_vertices = (baked_mesh.vertices / scale) + center
|
|
|
|
# 5. Transform back to LOCAL space for replacement in the original node
|
|
inv_transform = np.linalg.inv(transform)
|
|
baked_mesh.vertices = trimesh.transformations.transform_points(world_baked_vertices, inv_transform)
|
|
|
|
# Replace geometry in the scene with the baked version
|
|
asset.geometry[geom_name] = baked_mesh
|
|
|
|
asset.export(output_path)
|
|
print(f"[SegviGen] Bake: saved to {output_path}")
|
|
return output_path
|
|
|
|
asset.export(output_path)
|
|
print(f"[SegviGen] Bake: saved to {output_path}")
|
|
return output_path
|
|
|
|
|
|
def slat_to_glb(meshes, tex_voxels, resolution=512):
|
|
pbr_attr_layout = {
|
|
'base_color': slice(0, 3),
|
|
'metallic': slice(3, 4),
|
|
'roughness': slice(4, 5),
|
|
'alpha': slice(5, 6),
|
|
}
|
|
out_mesh = []
|
|
for i, (m, v) in enumerate(zip(meshes, tex_voxels)):
|
|
try:
|
|
v_min = m.vertices.min(dim=0).values.tolist()
|
|
v_max = m.vertices.max(dim=0).values.tolist()
|
|
print(f"[Log - Slat Input] Component {i}: vertices={len(m.vertices)}, bounds={v_min} to {v_max}")
|
|
except Exception:
|
|
pass
|
|
out_mesh.append(
|
|
MeshWithVoxel(
|
|
m.vertices,
|
|
m.faces,
|
|
origin=[-0.5, -0.5, -0.5],
|
|
voxel_size=1 / resolution,
|
|
coords=v.coords[:, 1:],
|
|
attrs=v.feats,
|
|
voxel_shape=torch.Size([*v.shape, *v.spatial_shape]),
|
|
layout=pbr_attr_layout,
|
|
)
|
|
)
|
|
mesh = out_mesh[0]
|
|
|
|
try:
|
|
mesh.simplify(200000)
|
|
except Exception as e:
|
|
print(f"[Export] mesh.simplify skipped: {e}")
|
|
|
|
glb = o_voxel.postprocess.to_glb(
|
|
vertices=mesh.vertices,
|
|
faces=mesh.faces,
|
|
attr_volume=mesh.attrs.cuda(),
|
|
coords=mesh.coords.cuda(),
|
|
attr_layout=mesh.layout,
|
|
voxel_size=mesh.voxel_size,
|
|
aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
|
|
decimation_target=50000,
|
|
texture_size=EXPORT_TEXTURE_SIZE,
|
|
remesh=True,
|
|
remesh_band=1,
|
|
remesh_project=0,
|
|
verbose=False,
|
|
)
|
|
return glb
|
|
|
|
|
|
class _LoadedPipeline:
|
|
def __init__(self):
|
|
self.loaded = False
|
|
self.current_ckpt = None
|
|
|
|
self.pipeline_args = None
|
|
self.tex_slat_flow_model = None
|
|
self.gen3dseg = None
|
|
self.sampler = None
|
|
|
|
self.shape_encoder = None
|
|
self.tex_encoder = None
|
|
self.shape_decoder = None
|
|
self.tex_decoder = None
|
|
|
|
self.rembg_model = None
|
|
self.image_cond_model = None
|
|
|
|
def load_all_models(self):
|
|
if self.loaded:
|
|
return
|
|
|
|
print("-" * 100)
|
|
print("[Init] Loading pipeline config .....................")
|
|
with open(TRELLIS_PIPELINE_JSON, "r") as f:
|
|
pipeline_config = json.load(f)
|
|
self.pipeline_args = pipeline_config['args']
|
|
self.sampler = Sampler()
|
|
self.loaded = True
|
|
print("[Init] Config loaded. Models will be loaded on-demand and unloaded to save RAM.")
|
|
|
|
def get_rembg(self):
|
|
if self.rembg_model is None:
|
|
import os
|
|
rmbg_local = os.path.join(COMFY_MODELS_DIR, "briaai/RMBG-2.0")
|
|
if os.path.isdir(rmbg_local):
|
|
print(f"[OnDemand] Loading RMBG-2.0 from local path")
|
|
self.rembg_model = BiRefNet(model_name_or_path=rmbg_local)
|
|
else:
|
|
self.rembg_model = BiRefNet(model_name="briaai/RMBG-2.0")
|
|
self.rembg_model.eval()
|
|
return self.rembg_model
|
|
|
|
def get_cond_model(self):
|
|
if self.image_cond_model is None:
|
|
print(f"[OnDemand] Loading DinoV3 conditioners")
|
|
self.image_cond_model = DinoV3FeatureExtractor(DINO_PATH, local_dir=COMFY_MODELS_DIR, subfolder="facebook/dinov3-vitl16-pretrain-lvd1689m")
|
|
self.image_cond_model.eval()
|
|
return self.image_cond_model
|
|
|
|
def get_encoders_decoder(self):
|
|
# Load encoders and shape decoder together as they are used in vxz_to_latent_slat
|
|
if self.shape_encoder is None:
|
|
print(f"[OnDemand] Loading Encoders and Shape Decoder")
|
|
self.shape_encoder = models.from_pretrained(TRELLIS_SHAPE_ENC, local_dir=COMFY_MODELS_DIR).eval()
|
|
self.tex_encoder = models.from_pretrained(TRELLIS_TEX_ENC, local_dir=COMFY_MODELS_DIR).eval()
|
|
self.shape_decoder = models.from_pretrained(TRELLIS_SHAPE_DEC, local_dir=COMFY_MODELS_DIR).eval()
|
|
return self.shape_encoder, self.tex_encoder, self.shape_decoder
|
|
|
|
def get_gen3dseg(self):
|
|
if self.gen3dseg is None:
|
|
print(f"[OnDemand] Loading Backbone (Gen3DSeg)")
|
|
self.tex_slat_flow_model = models.from_pretrained(TRELLIS_TEX_FLOW, local_dir=COMFY_MODELS_DIR)
|
|
self.gen3dseg = Gen3DSeg(self.tex_slat_flow_model)
|
|
|
|
if self.current_ckpt:
|
|
print(f"[OnDemand] Applying deferred checkpoint: {self.current_ckpt}")
|
|
ckpt_path = self.current_ckpt
|
|
filename = os.path.basename(ckpt_path).replace(".ckpt", ".safetensors")
|
|
local_safetensors = os.path.join(COMFY_MODELS_DIR, "checkpoints", filename)
|
|
|
|
if os.path.exists(local_safetensors):
|
|
from safetensors.torch import load_file
|
|
state_dict = load_file(local_safetensors)
|
|
else:
|
|
state_dict = torch.load(ckpt_path)['state_dict']
|
|
|
|
state_dict = OrderedDict([(k.replace("gen3dseg.", ""), v) for k, v in state_dict.items()])
|
|
self.gen3dseg.load_state_dict(state_dict)
|
|
|
|
self.gen3dseg.eval()
|
|
return self.gen3dseg
|
|
|
|
def get_tex_decoder(self):
|
|
if self.tex_decoder is None:
|
|
print(f"[OnDemand] Loading Texture Decoder")
|
|
self.tex_decoder = models.from_pretrained(TRELLIS_TEX_DEC, local_dir=COMFY_MODELS_DIR).eval()
|
|
return self.tex_decoder
|
|
|
|
def unload(self, *attr_names):
|
|
import gc
|
|
for name in attr_names:
|
|
val = getattr(self, name, None)
|
|
if val is not None:
|
|
if hasattr(val, 'cpu'): val.cpu()
|
|
setattr(self, name, None)
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
|
|
def load_ckpt_if_needed(self, ckpt_path: str):
|
|
if self.current_ckpt == ckpt_path:
|
|
return
|
|
self.current_ckpt = ckpt_path
|
|
print(f"[OnDemand] Checkpoint set to: {ckpt_path}. Will be applied when model is loaded.")
|
|
|
|
|
|
PIPE = _LoadedPipeline()
|
|
|
|
|
|
def inference_with_loaded_models(ckpt_path, item):
|
|
PIPE.load_all_models()
|
|
PIPE.load_ckpt_if_needed(ckpt_path)
|
|
|
|
work_glb = item["glb"]
|
|
if EARLY_SIMPLIFY_ENABLED:
|
|
work_glb = get_work_glb_path(item)
|
|
build_simplified_work_glb(
|
|
input_glb_path=item["glb"],
|
|
output_glb_path=work_glb,
|
|
target_faces=EARLY_SIMPLIFY_TARGET_FACES,
|
|
aggression=EARLY_SIMPLIFY_AGGRESSION,
|
|
)
|
|
|
|
if not item["2d_map"]:
|
|
generate_2d_map_from_glb(
|
|
glb_path=work_glb,
|
|
transforms_path=item["transforms"],
|
|
out_img_path=item["img"],
|
|
)
|
|
|
|
process_glb_to_vxz(
|
|
glb_path=item["glb"],
|
|
vxz_path=item["input_vxz"],
|
|
)
|
|
|
|
image = Image.open(item["img"])
|
|
|
|
# 1. Background removal
|
|
rembg_model = PIPE.get_rembg()
|
|
rembg_model.cuda()
|
|
image = preprocess_image(rembg_model, image)
|
|
PIPE.unload('rembg_model')
|
|
|
|
# 2. Condition generation
|
|
cond_model = PIPE.get_cond_model()
|
|
cond_model.cuda()
|
|
cond = get_cond(cond_model, [image])
|
|
# Offload cond to CPU
|
|
cond = {k: v.cpu() for k, v in cond.items()}
|
|
PIPE.unload('image_cond_model')
|
|
|
|
# 3. VXZ to Latent SLat (Encoders + Shape Decoder)
|
|
shape_enc, tex_enc, shape_dec = PIPE.get_encoders_decoder()
|
|
shape_enc.cuda()
|
|
tex_enc.cuda()
|
|
shape_dec.cuda()
|
|
|
|
shape_slat, meshes, subs, tex_slat = vxz_to_latent_slat(
|
|
shape_enc,
|
|
shape_dec,
|
|
tex_enc,
|
|
item["input_vxz"],
|
|
)
|
|
# Offload products to CPU
|
|
shape_slat = sp.SparseTensor(shape_slat.feats.cpu(), shape_slat.coords.cpu())
|
|
tex_slat = sp.SparseTensor(tex_slat.feats.cpu(), tex_slat.coords.cpu())
|
|
# subs are usually small indices or similar, keep them as is
|
|
|
|
PIPE.unload('shape_encoder', 'tex_encoder', 'shape_decoder')
|
|
|
|
# 4. Sampling (Backbone)
|
|
gen3dseg = PIPE.get_gen3dseg()
|
|
gen3dseg.cuda()
|
|
# Move inputs to GPU for sampling
|
|
shape_slat_gpu = sp.SparseTensor(shape_slat.feats.cuda(), shape_slat.coords.cuda())
|
|
tex_slat_gpu = sp.SparseTensor(tex_slat.feats.cuda(), tex_slat.coords.cuda())
|
|
cond_gpu = {k: v.cuda() for k, v in cond.items()}
|
|
|
|
output_tex_slat = tex_slat_sample_single(
|
|
gen3dseg, PIPE.sampler, PIPE.pipeline_args, shape_slat_gpu, tex_slat_gpu, cond_gpu
|
|
)
|
|
|
|
# Offload result to CPU
|
|
output_tex_slat_cpu = sp.SparseTensor(output_tex_slat.feats.cpu(), output_tex_slat.coords.cpu())
|
|
|
|
# Cleanup GPU inputs
|
|
del shape_slat_gpu, tex_slat_gpu, cond_gpu, output_tex_slat
|
|
PIPE.unload('gen3dseg', 'tex_slat_flow_model')
|
|
|
|
# 5. Texture Decoding
|
|
tex_decoder = PIPE.get_tex_decoder()
|
|
tex_decoder.cuda()
|
|
output_tex_slat_gpu = sp.SparseTensor(output_tex_slat_cpu.feats.cuda(), output_tex_slat_cpu.coords.cuda())
|
|
with torch.no_grad():
|
|
subs_gpu = [s.cuda() if isinstance(s, torch.Tensor) else s for s in subs]
|
|
tex_voxels = tex_decoder(output_tex_slat_gpu, guide_subs=subs_gpu) * 0.5 + 0.5
|
|
# Move result to CPU immediately
|
|
tex_voxels = [v.cpu() for v in tex_voxels]
|
|
|
|
PIPE.unload('tex_decoder')
|
|
|
|
if item.get("bake", False):
|
|
bake_to_mesh(
|
|
item["glb"],
|
|
tex_voxels,
|
|
item["export_glb"],
|
|
resolution=512,
|
|
texture_size=2048,
|
|
generate_uv=item.get("generate_uv", False)
|
|
)
|
|
else:
|
|
glb = slat_to_glb(meshes, tex_voxels)
|
|
T = np.eye(4, dtype=np.float64)
|
|
T[:3, :3] = np.array(
|
|
[
|
|
[1, 0, 0],
|
|
[0, 0, -1],
|
|
[0, 1, 0],
|
|
],
|
|
dtype=np.float64,
|
|
)
|
|
|
|
if hasattr(glb, "apply_transform") and callable(getattr(glb, "apply_transform")):
|
|
glb.apply_transform(T)
|
|
glb.export(item["export_glb"])
|
|
else:
|
|
glb.export(item["export_glb"])
|
|
scene_or_mesh = trimesh.load(item["export_glb"], force="scene")
|
|
scene_or_mesh.apply_transform(T)
|
|
scene_or_mesh.export(item["export_glb"]) |